From c5cc1a6705515d3174581d9e065e7669c89a6e7e Mon Sep 17 00:00:00 2001 From: ttipo Date: Sat, 31 Oct 2026 00:35:25 +0900 Subject: [PATCH 1/5] =?UTF-8?q?docs:=2011=EB=8B=A8=EA=B3=84=20=EB=8C=80?= =?UTF-8?q?=EC=9A=A9=EB=9F=89=20=EC=B2=A8=EB=B6=80=20=E2=80=94=20=EA=B3=84?= =?UTF-8?q?=EC=95=BD=204=EC=A0=88=20=C2=B7=20=EB=94=94=EC=9E=90=EC=9D=B8?= =?UTF-8?q?=20=EB=B3=B4=EB=93=9C=20=EB=8B=A4=EC=84=AF=20=C2=B7=20README?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 일반 첨부 한도를 넘는 파일을 로그인 없이 받는 링크로 보내는 기능의 계약과 화면 원본이다. 계약 4절은 2026-10-30 확정한 한도(파일 2GB · 메일 10개 · 직원 10GB · 30일)와 조각 올리기 · 악성 검사(ClamAV 실측 판정) · 공개 호스트 분리 · 받기 동시 연결 상한 · 운영 확인을 담는다. 리뷰에서 바꾼 것(경로 대소문자 · 공개 호스트 경로 해석 · 판정 대기 30분 + 1MB 마다 1초 · 전체 상한을 공개 400 · 앱 안 200 으로 나눔)과 알려진 한계도 함께 적었다. Co-Authored-By: Claude Opus 5.5 --- docs/design/webmail/ComposeLargeFiles.dc.html | 459 ++++++++++++ docs/design/webmail/LargeFileBlock.dc.html | 213 ++++++ .../design/webmail/MailReadLargeFiles.dc.html | 296 ++++++++ docs/design/webmail/README.md | 163 ++++- .../design/webmail/SettingsLargeFiles.dc.html | 386 ++++++++++ docs/design/webmail/SharePage.dc.html | 287 ++++++++ docs/design/webmail/canvas.json | 12 +- docs/mail/api-contract.md | 687 +++++++++++++++++- 8 files changed, 2496 insertions(+), 7 deletions(-) create mode 100644 docs/design/webmail/ComposeLargeFiles.dc.html create mode 100644 docs/design/webmail/LargeFileBlock.dc.html create mode 100644 docs/design/webmail/MailReadLargeFiles.dc.html create mode 100644 docs/design/webmail/SettingsLargeFiles.dc.html create mode 100644 docs/design/webmail/SharePage.dc.html diff --git a/docs/design/webmail/ComposeLargeFiles.dc.html b/docs/design/webmail/ComposeLargeFiles.dc.html new file mode 100644 index 0000000..0f5b525 --- /dev/null +++ b/docs/design/webmail/ComposeLargeFiles.dc.html @@ -0,0 +1,459 @@ + + + + +메일 쓰기 · 대용량 첨부 + + + + + + + +
+ +
+

메일 쓰기 · 대용량 첨부

+

20MB 가 넘는 파일과, 메일에 담은 파일의 합계를 25MB 넘게 만드는 파일은 링크로 보냅니다. 파일은 회사 서버에 올라가고, 보낼 때 서버가 본문에 내려받기 안내를 넣습니다. 받는 사람은 보낸 날부터 30일 동안 내려받습니다. 큰 파일은 조각으로 나눠 올려 끊겨도 올린 만큼은 남습니다. 초안 — 사용자 확인 전.

+
+ +
+ +
+
+

쓰기 화면 — 첨부 칸 · 보내기 줄

+ 콘텐츠 열 800 · 32px 선 그대로 · 위쪽(받는 사람 · 제목 · 툴바)은 줄였습니다 +
+ +
+

김민수 과장님, 요청하신 설계도와 현장 영상 보내드립니다.

+

파일이 커서 링크로 보내니 메일 아래의 주소로 내려받아 주세요.

+


+

김아라 · 인프라팀 · ARA SYSTEM

+
+ +
+
+ + 파일당 최대 20MB · 합계 25MB · 최대 20개 · 더 큰 파일은 링크로 보냅니다 (파일당 2GB · 10개까지) +
+ +
+

메일에 담는 파일 2개

+ 합계 18.1 MB / 25 MB +
+
    +
  • + + 견적서_11월.xlsx + 52 KB + + + 링크로 보내기 +
  • +
  • + + 현장사진_요약.pdf + 18 MB + + +
  • +
+ +
+

링크로 보내는 파일 3개

+ 받는 사람은 보낸 날부터 30일 동안 내려받습니다 — 오늘 보내면 11월 29일(일)까지 +
+
    +
  • +
    + + 설계도_v3.dwg + + +
    +
    + 올리는 중 · 420 MB / 1.2 GB + 약 3분 남음 +
    +
    +
  • +
  • +
    + + 현장영상_1029.mp4 + +
    +
    612 MB · 보안 검사 중 — 끝나지 않아도 보낼 수 있습니다
    +
  • +
  • +
    + + 회의자료_전체.pptx + +
    +
    9.4 MB
    +
  • +
+

+ + ‘회의자료_전체.pptx’(9.4 MB)는 메일에 담으면 합계가 25MB 를 넘어 링크로 보냅니다. +

+
+ +
+
+ + + +
+ +

+ + 파일 1개를 올리는 중입니다 — 다 올리면 보낼 수 있습니다. +

+ 보내는 사람 김아라 <ara.kim@arasystem.it.kr> +
+
+ +
+
+

링크로 바뀌는 때

+
    +
  • 파일 하나가 20MB 를 넘으면 그 파일은 링크로 보냅니다.
  • +
  • 메일에 담은 파일의 합계가 25MB 를 넘게 되면 그 파일부터 링크로 보냅니다(고른 차례대로). 그때 칸 아래에 한 줄로 알립니다(왼쪽 ⓘ).
  • +
  • 메일에 담지 못하는 형식(동영상 mp4 · mov, CAD dwg · dxf, 디자인 psd · ai, 7z)은 크기와 상관없이 링크로 보냅니다.
  • +
  • 메일에 담은 파일도 링크 단추(「링크로 보내기」)로 바꿀 수 있습니다 — 받는 사람이 많거나 받는 서버가 큰 메일을 막을 때. 단추는 이 쓰기 화면에서 고른 파일에만 있습니다 — 전달 원본 · 저장본에서 이어 온 첨부에는 없습니다(처음 판).
  • +
  • 받는 사람이 사내 직원이어도 같은 규칙입니다.
  • +
+

묻는 창은 띄우지 않습니다(국내 메일은 모두 저절로 바꾸고, 칸 안의 글로 충분합니다). 링크 파일을 메일에 담는 쪽으로 되돌리는 단추는 처음 판에 없습니다 — 빼고 다시 고르면 한도 안에서 메일에 담깁니다.

+
+ +
+

링크 파일의 한도 · 모양

+
    +
  • 파일당 2GB · 메일 한 통에 10개 · 직원마다 동시에 보관 10GB(올리는 중 · 임시 저장 포함). 모두 운영 설정입니다.
  • +
  • 칸 안의 두 묶음 — 「메일에 담는 파일」 · 「링크로 보내는 파일」. 링크 파일만 있거나 일반 첨부만 있으면 묶음 이름은 그 하나만.
  • +
  • 링크 파일은 아이콘이 링크 모양(청록)이고 줄이 둘입니다 — 이름 · 단추 / 크기 · 상태(12px). 뱃지 · 알약 없이 글자만.
  • +
  • ✕ 는 「빼기」 — 올리던 것을 멈추고 서버에 올린 조각도 지웁니다.
  • +
+

본문 편집기에는 내려받기 안내가 보이지 않습니다 — 보낼 때 서버가 내 글 · 서명 아래, 인용 위에 넣습니다(「본문 덩이」 보드).

+
+
+
+ +
+ +
+
+

올리는 중 · 멈춤

+

16MB 조각으로 올립니다 · 남은 시간은 최근 속도로 셉니다

+
+
    +
  • +
    + + 설계도_v3.dwg + + +
    +
    + 올리는 중 · 420 MB / 1.2 GB + 약 3분 남음 +
    + +
  • +
  • +
    + + 설계도_v3.dwg + + +
    +
    멈춤 · 420 MB / 1.2 GB 올림
    + +
  • +
+

[멈춤]은 그 파일만 멈추고 [이어 올리기]로 다시 올립니다 — 다른 파일은 계속 올립니다. 처음 몇 초는 남은 시간 대신 "시간 계산 중". 멈춘 파일이 있으면 보내기는 막힌 채입니다. 진행 글은 화면 낭독기에 10% 마다 알립니다.

+
+ +
+
+

잠깐 끊김 — 저절로 다시

+

망이 끊기거나 PC 가 잠들었다 깨었을 때

+
+
    +
  • +
    + + 설계도_v3.dwg + + +
    +
    + 연결이 끊겨 다시 잇는 중… · 420 MB / 1.2 GB 올림 +
    + +
  • +
+
    +
  • 2 · 5 · 15 · 30초 간격으로 저절로 다시 잇습니다(실시간 알림의 다시 잇기와 같은 결). 누를 것이 없습니다.
  • +
  • 서버에 받은 조각은 그대로 — 받지 못한 조각부터 다시 보냅니다. 이어지면 글이 "올리는 중"으로 돌아갑니다.
  • +
  • 로그인이 끝났으면 세션을 한 번 갱신하고 같은 조각을 다시 보냅니다(지금 요청 규칙). 갱신도 실패하면 세션 만료 창.
  • +
+
+ +
+
+

오래 끊김 — [다시 시도]

+

저절로 다시 잇기를 1분 넘게 하지 못했을 때 · 올린 만큼은 남습니다

+
+
    +
  • +
    + + 설계도_v3.dwg + + +
    +

    올리지 못하고 멈췄습니다 — 올린 420 MB 는 남아 있습니다.

    + +
  • +
+
    +
  • [다시 시도]는 올린 곳부터 이어서 올립니다. 서버는 마지막 조각을 받은 뒤 24시간 기다립니다 — 그 뒤에는 처음부터 다시 올립니다("처음부터 다시 올립니다" 한 줄).
  • +
  • 서버가 조각을 거절하면(받은 내용이 깨짐) 그 조각만 다시 보냅니다 — 사용자에게는 보이지 않습니다. 세 번 거듭되면 이 모양으로 멈춥니다.
  • +
  • 알림은 띄우지 않습니다 — 칸 안의 빨간 줄과 보내기 줄의 까닭으로 충분합니다.
  • +
+
+
+ +
+ +
+
+

다 올린 뒤 — 보안 검사

+

서버가 올리기를 마치는 대로 검사합니다(ClamAV)

+
+
    +
  • +
    + + 현장영상_1029.mp4 + +
    +
    612 MB · 보안 검사 중 — 끝나지 않아도 보낼 수 있습니다
    +
  • +
  • +
    + + 설계도_v3.dwg + +
    +
    1.2 GB
    +
  • +
  • +
    + + 협력사_자료모음.7z + +
    +
    860 MB · 검사하지 못함 — 암호가 걸린 압축 파일
    +
  • +
  • +
    + + 견적_요청서.zip + +
    +

    위험한 내용이 발견되어 보낼 수 없습니다. 이 파일을 빼 주세요.

    +
  • +
+

검사가 끝나기 전에 보내도 됩니다 — 받는 사람은 검사가 끝난 뒤 받습니다. 검사가 끝난 깨끗한 파일은 크기만 남습니다. 암호 압축처럼 검사하지 못하는 파일은 막지 않고 글로만 알립니다. 위험이면 보낼 수 없고, 보낸 뒤에 위험이 나오면 그 파일을 막고 알립니다(설정 › 대용량 첨부).

+
+ +
+
+

한도를 넘을 때 — 올리지 않는다

+

고르는 순간 먼저 검사합니다(지금 첨부 사전 검사와 같은 결)

+
+
    +
  • +
    + + 전체백업_2026.zip + 2.4 GB + +
    +

    파일 하나는 2GB 까지 보낼 수 있습니다.

    +
  • +
  • +
    + + 회의녹화_11.mp4 + 380 MB + +
    +

    링크로 보내는 파일은 메일 한 통에 10개까지입니다.

    +
  • +
  • +
    + + 시공영상_전체.mov + 1.9 GB + +
    +

    대용량 보관 한도(10GB)를 넘어 올릴 수 없습니다. 보내지 않은 파일을 지우거나 링크를 끊으면 바로 공간이 생깁니다. 대용량 첨부 보기

    +
  • +
+
+ 링크로 보내는 파일 2개 + 받는 사람은 보낸 날부터 30일 … + 보관 8.4 / 10 GB +
+

넘는 파일은 빨간 줄로 남고 보내기를 막지 않습니다(그 파일만 빠집니다 — 지금 첨부와 같다). 보관 사용량은 80%를 넘을 때만 링크 묶음 머리 오른쪽에 보입니다(점선 상자). 서버 공간이 모자라면 같은 모양으로 "지금은 서버에 공간이 모자라 올릴 수 없습니다. 잠시 후 다시 시도해 주세요."

+
+ +
+
+

막힌 형식

+

허용 목록에 있는 형식만 — 실행 파일 · 디스크 이미지는 막습니다

+
+
    +
  • +
    + + 설치파일_v2.exe + 48 MB + +
    +

    허용되지 않는 파일 형식입니다. 파일을 확인해 주세요.

    +
  • +
+
+
메일에 담기
pdf · doc · docx · xls · xlsx · ppt · pptx · hwp · hwpx · txt · csv · png · jpg · jpeg · gif · webp · zip (지금 그대로)
+
링크로 더
mp4 · mov · dwg · dxf · psd · ai · 7z
+
막음
exe · msi · bat · cmd · js · vbs · scr 같은 실행 파일, iso · img · vhd 같은 디스크 이미지, 그 밖의 목록 밖 형식
+
+

파일 고르기 창도 두 목록을 합친 형식만 보여 줍니다. 서버도 이름의 형식을 다시 봅니다. 받는 쪽은 형식과 상관없이 늘 "내려받기"로만 받습니다 — 브라우저가 열어 실행하지 않습니다. 칸 아래 문구는 사전의 FILE_001 그대로입니다.

+
+
+ +
+ +
+
+

새로고침 · 창을 닫은 뒤 — 저장본을 다시 열면

+

브라우저는 고른 파일을 스스로 다시 읽지 못합니다

+
+
    +
  • +
    + + 현장영상_1029.mp4 + + +
    +

    올리다 멈춘 파일 — 같은 파일을 다시 고르면 이어서 올립니다 · 420 MB / 1.2 GB 올림

    + +

    고른 파일이 멈춘 파일과 다릅니다(이름 · 크기). 같은 파일을 골라 주세요.

    +
  • +
  • +
    + + 설계도_v3.dwg + +
    +
    1.2 GB · 이 임시 저장본에 11월 29일까지 남습니다
    +
  • +
  • +
    + + 회의녹화_09.mp4 + +
    +

    기간이 지나 지운 파일입니다 — 빼고 다시 올려 주세요.

    +
  • +
  • +
    + + 시공도면_v4.dwg + +
    +

    설정 › 대용량 첨부에서 지운 파일입니다 — 빼고 다시 올려 주세요.

    +
  • +
+

그래서 같은 파일을 한 번 더 골라야 이어서 올립니다(이름 · 크기 · 고친 시각으로 맞춰 봅니다 — 서버는 마지막 조각 뒤 24시간 기다립니다). 다 올린 파일은 임시 저장본에 붙어 올린 날부터 30일 동안 남고, 지나거나 설정 › 대용량 첨부에서 지우면 빨간 줄입니다(글만 다르다). 네이버웍스 모바일처럼 저장하면 큰 파일이 빠지는 일은 없습니다.

+
+ +
+
+

다 올릴 때까지 보내기를 막는다

+

꺼진 단추는 aria-disabled — 까닭을 단추 옆 글로

+
+
+
+ + + +
+ +

파일 2개를 올리는 중입니다 — 다 올리면 보낼 수 있습니다.

+
+
+
올리는 중 · 다시 잇는 중
"파일 2개를 올리는 중입니다 — 다 올리면 보낼 수 있습니다."
+
멈춤 · 오래 끊김 · 새로고침 뒤 멈춘 파일
"다 올리지 못한 파일이 있습니다 — 이어 올리거나 빼 주세요."
+
위험 · 지운 파일(기간 지남 · 설정에서 지움)
"보낼 수 없는 파일이 있습니다 — 빨간 줄의 파일을 빼 주세요."
+
+

[보내기]와 ▾ 는 초점을 받습니다. 누르거나 Ctrl+Enter 를 치면 보내지 않고 까닭 글을 화면 낭독기에 읽어 줍니다(role=status). 「예약 발송…」도 같은 까닭으로 막힙니다. 임시 저장은 그대로 됩니다 — 올리는 중인 파일도 저장본에 남습니다(왼쪽 카드).

+
+ +
+
+

나가기 확인 — 올리는 중인 파일

+

앱 안에서 다른 화면으로 갈 때 · 쓰기 화면 닫기(✕)

+
+
+
+

올리는 중인 파일이 있습니다

+

이 화면을 나가면 올리기가 멈춥니다. 임시보관함에서 이 메일을 다시 열고 같은 파일을 고르면 이어서 올립니다.

+
+ + +
+
+
+

처음 초점은 [계속 쓰기] · Esc 도 같습니다(지금 나가기 확인과 같은 모양). 떠나기 전 임시 저장은 지금처럼 먼저 하고, 올리는 중인 파일은 저장본에 "올리다 멈춘 파일"로 남깁니다. 탭을 닫거나 새로 고치면 브라우저가 묻습니다. 다 올린 뒤에는 이 확인이 없습니다.

+
+
+
+
+ + + diff --git a/docs/design/webmail/LargeFileBlock.dc.html b/docs/design/webmail/LargeFileBlock.dc.html new file mode 100644 index 0000000..1a3b7de --- /dev/null +++ b/docs/design/webmail/LargeFileBlock.dc.html @@ -0,0 +1,213 @@ + + + + +본문 덩이 — 바깥 메일 프로그램에서 + + + + + + + +
+ +
+

본문 덩이 — 바깥 메일 프로그램에서

+

링크 파일이 있는 메일은 보낼 때 서버가 본문에 내려받기 안내(덩이)를 넣습니다. 자리는 내 글 · 서명 아래, 인용 위입니다. 서식 본문(HTML)과 텍스트 대체 본문에 같은 내용이 들어갑니다. 우리 웹메일 안에서는 링크 주인이 보낸 메일이면 덩이를 접고 첨부 칸에 첨부처럼 보입니다(「읽기 화면 · 대용량 첨부」 보드). 초안 — 사용자 확인 전.

+
+ +
+ +
+
+ 바깥 메일 프로그램 + (예: Gmail · Outlook · 네이버 메일 — 받는 사람 김민수, 거래처) +
+
+

Re: 11월 현장 점검 자료 요청

+ 김아라 <ara.kim@arasystem.it.kr> + 받는 사람: 김민수 <minsu.kim@hanbit-partners.co.kr> · 2026. 10. 30. 오후 2:15 +
+
+

김민수 과장님, 안녕하세요.

+


+

요청하신 설계도와 현장 영상 보내드립니다.

+

파일이 커서 아래 주소로 내려받으실 수 있습니다.

+


+

김아라 · 인프라팀 · ARA SYSTEM

+

010-1234-5678

+ + + + + + + + + + + +
대용량 첨부 2개 (합계 1.8 GB)
2026년 11월 29일(일)까지 내려받을 수 있습니다 (한국 시간)
설계도_v3.dwg1.2 GB
현장영상_1029.mp4612 MB
내려받기 https://files.arasystem.it.kr/s/Qx7mK2vR9pL4wT8nB3cF6hJ1dS5gA0eY2uI7oP9kZ3x
내려받을 때 로그인이나 비밀번호를 묻지 않습니다.
Large files: download by Nov 29, 2026 (KST) — no login or password needed.
+ +
+
+

원본 메일

+ + + + + + +
보낸 사람김민수 <minsu.kim@hanbit-partners.co.kr>
날짜2026. 10. 29. (목) 오후 5:02
제목11월 현장 점검 자료 요청
+
+

김아라 매니저님, 11월 현장 점검 자료(설계도 · 현장 영상)를 보내 주실 수 있을까요?

+

용량이 크면 나눠서 보내 주셔도 됩니다.

+
+
+
+ +
+

덩이에 들어가는 것 (위에서부터)

+
    +
  1. 제목 줄 — "대용량 첨부 2개 (합계 1.8 GB)" 14px 굵게
  2. +
  3. 기간 줄 — "2026년 11월 29일(일)까지 내려받을 수 있습니다 (한국 시간)". 보낸 때(예약 발송은 보낼 시각)부터 30일째 날 23:59:59 까지, 받는 사람 모두 같은 날
  4. +
  5. 파일 줄 — 이름 · 크기(회색), 파일마다 한 줄. 이름은 링크가 아닙니다 — 메일 한 통에 링크가 하나라 모두 같은 화면으로 갑니다
  6. +
  7. 주소 줄 — "내려받기" + 주소를 글자 그대로 보이는 링크. 단축 주소 · 단추 모양은 쓰지 않습니다(받는 사람이 회사 도메인을 확인할 수 있게)
  8. +
  9. 안내 줄 — "내려받을 때 로그인이나 비밀번호를 묻지 않습니다." 12px 회색
  10. +
  11. 영어 한 줄 — 거래처가 외국 회사일 수 있어서
  12. +
+

모양

+
    +
  • 테두리 1px #D0D5DD 상자 + 글자. 안 여백 12 · 16. 뱃지 · 알약 · 그림 · 아이콘 · 단추 없음(그림은 받는 쪽에서 외부 이미지로 막힙니다)
  • +
  • 보내는 서식의 허용 목록 안에서만 — table · td(colspan) · a, style 은 테두리 · 여백 · 색 · 글자 크기 · 굵기 · 정렬 · 줄바꿈 막기. 둥근 모서리가 허용 목록에 없어 모서리는 곧습니다
  • +
  • 글꼴은 본문과 같은 맑은 고딕, 글자 14 · 13 · 12. 상자 폭은 내용에 맞춥니다 — 주소 줄(약 75자)이 가장 깁니다. 좁은 화면에서는 받는 프로그램이 줄을 끊습니다
  • +
+

서버가 넣는다

+
    +
  • 정화 · 서명 그림 · 중복 확인을 마친 뒤에 넣습니다 — 쓰기 화면의 편집기에는 보이지 않고, 쓰는 사람은 첨부 칸의 링크 파일로 봅니다
  • +
  • 자리는 쓰기 화면이 알려 준 내 글과 인용의 경계 — 내 글 · 서명 아래, 인용 위. 인용이 없으면 맨 끝
  • +
  • 보이지 않는 표시 <!--ara-large-files:v1--> … <!--/ara-large-files--> — 보내는 정화가 주석을 지우므로 사용자가 흉내 낼 수 없습니다
  • +
  • 보내기 취소로 되돌린 메일은 덩이를 떼고 쓰기 화면의 링크 파일로 돌려줍니다(다시 보내면 새 링크)
  • +
  • 나중에 링크를 끊어도 본문의 날짜 · 주소는 그대로입니다 — 누르면 내려받기 화면이 "공유 멈춤"을 보입니다
  • +
+
+
+ +
+ +
+
+

텍스트 대체 본문 — 같은 내용

+

글자로만 읽는 메일 프로그램 · 미리 보기 · 인쇄에 쓰인다

+
+
김민수 과장님, 안녕하세요.
+
+요청하신 설계도와 현장 영상 보내드립니다.
+파일이 커서 아래 주소로 내려받으실 수 있습니다.
+
+김아라 · 인프라팀 · ARA SYSTEM
+010-1234-5678
+
+----------------------------------------
+대용량 첨부 2개 (합계 1.8 GB)
+2026년 11월 29일(일)까지 내려받을 수 있습니다 (한국 시간)
+- 설계도_v3.dwg (1.2 GB)
+- 현장영상_1029.mp4 (612 MB)
+내려받기: https://files.arasystem.it.kr/s/Qx7mK2vR9pL4wT8nB3cF6hJ1dS5gA0eY2uI7oP9kZ3x
+내려받을 때 로그인이나 비밀번호를 묻지 않습니다.
+Large files: download by Nov 29, 2026 (KST) — no login or password needed.
+----------------------------------------
+
+> 원본 메일
+> 보낸 사람 | 김민수 <minsu.kim@hanbit-partners.co.kr>
+> 날짜 | 2026. 10. 29. (목) 오후 5:02
+> 제목 | 11월 현장 점검 자료 요청
+> 김아라 매니저님, 11월 현장 점검 자료(설계도 · 현장 영상)를 보내 주실 수 있을까요?
+
    +
  • 자리 · 내용은 HTML 덩이와 같습니다. 위아래를 대시 줄로 가르고, 파일은 "- 이름 (크기)", 주소는 글자 그대로(텍스트 프로그램이 알아서 링크로 만듭니다)
  • +
  • 되돌린 초안에서 뗄 때는 주소의 토큰으로 그 링크의 덩이인지 확인한 뒤 뗍니다 — 사용자가 같은 글을 쳐 넣었어도 지우지 않게
  • +
  • 덩이를 넣은 뒤 본문 한도(서식 2,000,000바이트 · 글자 100,000자)를 다시 봅니다 — 쓰기 화면은 덩이 몫(파일 10개면 수 KB)을 남겨 두고 맞춥니다
  • +
+
+ +
+
+
+

답장 · 전달할 때 — 인용 속에 남은 덩이

+

우리 웹메일의 답장 쓰기 화면 · 박서준이 김아라의 메일에 답장

+
+
+
+

아라님, 받았습니다. 금요일까지 검토하겠습니다.

+


+

박서준 · 품질팀

+
+
+

원본 메일

+ + + + + +
보낸 사람김아라 <ara.kim@arasystem.it.kr>
제목11월 현장 점검 자료 보내드립니다
+
+

서준님, 현장 점검 자료입니다. 설계도와 영상은 링크로 보냅니다.

+ + + + + + + + +
대용량 첨부 2개 (합계 1.8 GB)
2026년 11월 29일(일)까지 내려받을 수 있습니다 (한국 시간)
설계도_v3.dwg1.2 GB
현장영상_1029.mp4612 MB
내려받기 https://files.arasystem.it.kr/s/Qx7mK2…
+
+
+
+ 파일 첨부 + 첨부 없음 — 답장은 링크 파일을 다시 싣지 않습니다 +
+
+
    +
  • 답장은 링크 파일을 다시 싣지 않습니다(일반 첨부와 같은 관례). 인용 속 덩이는 표째 글로 남고, 그 링크는 처음 기간까지 열립니다
  • +
  • 인용으로 옮길 때 보이지 않는 표시는 지워집니다 — 이 답장을 받는 쪽에서는 이 덩이가 그냥 인용된 글입니다(첨부 칸으로 올라오지 않음)
  • +
  • 전달도 같습니다 — 원래 링크는 인용 속 글로 함께 가고 첨부 칸에 다시 싣지 않습니다. 쓰기 화면은 칸 아래 한 줄로만 알립니다("원래 메일의 대용량 첨부 2개는 인용 속 링크로 함께 갑니다 — 11월 29일까지")
  • +
+
+ +
+
+

바깥 사람의 답장을 우리 웹메일에서 읽을 때

+

김민수의 메일 프로그램이 김아라의 메일을 통째로 인용했다

+
+
+

잘 받았습니다. 감사합니다.

+

2026년 10월 30일 (금) 오후 2:15, 김아라 <ara.kim@arasystem.it.kr>님이 작성:

+
+

김민수 과장님, 안녕하세요. 요청하신 설계도와 …

+

대용량 첨부 2개 (합계 1.8 GB) · 2026년 11월 29일(일)까지 … 내려받기 https://files.arasystem.it.kr/s/Qx7mK2…

+
+
+

인용 속 덩이는 본문 그대로 둡니다 — 첨부 칸으로 올리지 않고 접지도 않습니다. 첨부처럼 올리는 것은 링크 주인이 그 메일의 보낸 사람일 때뿐입니다(이 메일은 김민수가 보냈고 링크 주인은 김아라).

+
+
+
+
+
+ + + diff --git a/docs/design/webmail/MailReadLargeFiles.dc.html b/docs/design/webmail/MailReadLargeFiles.dc.html new file mode 100644 index 0000000..4207994 --- /dev/null +++ b/docs/design/webmail/MailReadLargeFiles.dc.html @@ -0,0 +1,296 @@ + + + + +읽기 화면 · 대용량 첨부 + + + + + + + +
+ +
+

읽기 화면 · 대용량 첨부

+

링크 주인이 그 메일의 보낸 사람이면 링크 파일을 첨부 칸에 첨부처럼 보이고, 본문의 덩이는 접어 둡니다. 바깥 사람 답장의 인용 속 링크 · 전달된 원래 링크 · 비슷하게 꾸민 덩이(서버에 없는 링크)는 그냥 본문입니다. 보낸 사람 자신에게는 받은 수와 [링크 복사] · [링크 끊기]가 더해집니다. 초안 — 사용자 확인 전.

+
+ +
+ +
+
+ + + + + + + +
+ +
+

11월 현장 점검 자료 보내드립니다

+
+ +
+
+ 김아라 + ara.kim@arasystem.it.kr + 2026. 10. 30. (금) 오후 2:15 +
+ 받는 사람 박서준 <seojun.park@arasystem.it.kr> +
+
+ +
+

서준님, 현장 점검 자료입니다.

+

견적서는 첨부했고, 설계도와 영상은 용량이 커서 링크로 보냅니다.

+


+

김아라 · 인프라팀 · ARA SYSTEM

+ +
+ +
+

첨부 파일 3개

+
    +
  • + +
    견적서_11월.xlsx52 KB
    + +
  • +
  • + +
    설계도_v3.dwg1.2 GB · 11월 29일까지
    + +
  • +
  • + +
    현장영상_1029.mp4612 MB · 11월 29일까지
    + +
  • +
+
+
+
+ +
+
+

사내 받는 사람에게

+
    +
  • 링크 파일은 첨부 칸에 일반 첨부와 함께 — 이름 아래 "크기 · 11월 29일까지"(끝나는 날, 한국 시간). 아이콘 · 단추 모양은 일반 첨부와 같습니다
  • +
  • [다운로드]는 내려받기 화면을 거치지 않고 바로 받습니다. 받은 수에 들어갑니다(보낸 사람 자신은 빼고)
  • +
  • 「첨부 파일 N개」는 메일에 담긴 첨부와 링크 파일을 합한 수입니다
  • +
  • 첨부처럼 올리는 조건 — 주소가 우리 내려받기 주소이고 서버에 있는 링크이며, 링크 주인이 그 메일의 보낸 사람일 때만. 바깥 사람 답장의 인용 속 링크 · 전달된 원래 링크는 본문 글로 둡니다
  • +
  • 목록의 클립 아이콘은 지금처럼 메일에 담긴 첨부만 봅니다 — 링크 파일만 있는 메일은 처음 판에 클립이 없습니다
  • +
+
+ +
+
+

덩이를 펼치면

+

본문 틀 안의 접기 줄 — 바깥 사람이 보는 그대로

+
+ + + + + + + + + +
대용량 첨부 2개 (합계 1.8 GB)
2026년 11월 29일(일)까지 내려받을 수 있습니다 (한국 시간)
설계도_v3.dwg1.2 GB
현장영상_1029.mp4612 MB
내려받기 https://files.arasystem.it.kr/s/Qx7mK2…
+

접기 줄은 본문 틀(격리된 iframe) 안에서 스크립트 없이 열고 닫힙니다. 높이 40, 글 12.5px. 메일을 다시 열면 다시 접혀 있습니다. 인쇄 화면은 펼친 채로 인쇄합니다(종이에는 주소가 있어야 하므로).

+
+
+
+ +
+ +
+
+

보낸 사람 자신 — 보낸편지함에서 같은 메일

+

첨부 칸만 보입니다 · 위쪽(머리 · 본문 · 접기 줄)은 받는 사람과 같습니다

+
+
+

첨부 파일 3개

+
    +
  • + +
    견적서_11월.xlsx52 KB
    + +
  • +
  • + +
    설계도_v3.dwg1.2 GB · 3회 받음 · 11월 29일까지
    + +
  • +
  • + +
    현장영상_1029.mp4612 MB · 아직 받지 않음 · 11월 29일까지
    + +
  • +
+
+ 링크로 보낸 파일 2개 — 받는 사람 모두 같은 링크로 받습니다. 끊으면 둘 다 받을 수 없습니다. + + + + +
+
+
    +
  • 받은 수는 실제로 받기 시작한 수입니다 — 화면 열기 · 이어 받기 · 내가 받은 것은 세지 않습니다. 누가 받았는지는 남기지 않습니다
  • +
  • [링크 복사] · [링크 끊기]는 메일에 한 번(36 — 차단한 주소의 [차단 풀기]와 같은 밀도 예외, 새 토큰 없음). 링크가 메일 한 통에 하나라 파일마다 두지 않습니다
  • +
  • [링크 복사]는 이 메일 본문의 덩이에서 주소를 읽어 복사합니다 — 알림 "링크를 복사했습니다."(못 하면 "링크를 복사하지 못했습니다."). 설정 › 대용량 첨부에는 없습니다(서버는 링크를 해시로만 압니다)
  • +
  • 사내 받는 사람에게는 이 줄과 받은 수가 없습니다. 보낸편지함이 아니어도(내가 나에게 보낸 메일 등) 링크 주인이면 보입니다
  • +
+
+ +
+
+
+

대용량 첨부 링크를 끊을까요?

+

받는 사람 모두 이 메일의 파일 2개(설계도_v3.dwg · 현장영상_1029.mp4)를 더는 내려받을 수 없습니다. 이미 받은 파일은 되돌릴 수 없고, 끊은 링크는 다시 열 수 없습니다.

+
+ + +
+
+
+

되돌릴 수 없는 일이라 처음 초점은 「취소」(파란 고리) · Esc 도 취소입니다. 끊으면 알림 하나 — 되돌리기 단추는 없습니다:

+
+ + 링크를 끊었습니다. 받는 사람은 더 이상 내려받을 수 없습니다. +
+

끊은 뒤 첨부 칸은 아래 "공유 멈춤" 모양이 되고, 쓰던 보관 공간은 바로 비워집니다. 같은 확인 창을 설정 › 메일 › 대용량 첨부에서도 씁니다(창 글에 메일 제목).

+
+
+ +
+ +
+
+

기간 지남 · 공유 멈춤

+

흐린 글자 + 까닭 · 내려받기 단추 없음

+
+
    +
  • + +
    설계도_v3.dwg1.2 GB · 기간 지남 — 11월 29일까지였습니다
    +
  • +
  • + +
    현장영상_1029.mp4612 MB · 공유 멈춤 — 보낸 사람이 링크를 끊었습니다
    +
  • +
  • + +
    설계도_v3.dwg1.2 GB · 3회 받음 · 공유 멈춤 — 10월 31일에 끊었습니다
    +
  • +
+

셋째 줄은 보낸 사람 자신이 본 모양입니다(받은 수는 남습니다). 흐리게는 투명도가 아니라 글자색 #667085 · 아이콘 #98A2B3(대비 4.5:1 이상). 본문의 접기 줄은 그대로 둡니다. 끝난 뒤 90일이 지나면 서버가 그 링크를 잊어 첨부 칸에서 빠지고 덩이는 그냥 본문이 됩니다.

+
+ +
+
+

보안 검사 — 받는 사람에게

+

파일마다 · 글자로만

+
+
    +
  • + +
    현장영상_1029.mp4612 MB · 보안 검사 중 — 끝나면 받을 수 있습니다
    + +
  • +
  • + +
    견적_요청서.zip위험 — 보안 검사에서 막았습니다. 받을 수 없습니다
    +
  • +
  • + +
    협력사_자료모음.7z860 MB · 11월 26일까지 · 검사하지 못함 — 암호가 걸린 압축 파일
    + +
  • +
+

검사 중이면 [다운로드]를 끄고(aria-disabled — 초점은 받고, 누르면 까닭을 읽어 줍니다) 끝나는 날 대신 까닭을 씁니다. 메일을 다시 열거나 새 메일 확인 때 상태를 다시 받습니다. 검사하지 못한 파일은 받을 수 있습니다 — 글로만 알립니다.

+
+ +
+
+

전달 쓰기 화면 — 원래 링크는 인용 속 글로

+

박서준이 이 메일을 바깥 협력사에 전달 · 첨부 칸에 다시 싣지 않는다

+
+
+
파일 첨부
+
+ + 견적서_11월.xlsx + 52 KB + +
+

+ + 원래 메일의 대용량 첨부 2개는 인용 속 링크로 함께 갑니다 — 11월 29일까지 +

+
+
+ 공유가 멈췄거나 기간이 지난 링크를 인용한 전달이면 +

+ + 인용 속 ‘도면_초안.dwg’ 링크는 공유가 멈춰 받을 수 없습니다. +

+

+ + 인용 속 ‘행사사진_원본.zip’ 링크는 기간이 지나 받을 수 없습니다. +

+
+
    +
  • 원래 메일의 덩이는 인용 속 표 · 링크로 그대로 갑니다 — 첨부 칸에 다시 싣지 않고 칸 아래 한 줄로만 알립니다(링크 파일 수 · 끝나는 날). 파일이 여럿인 끝난 링크는 "‘설계도_v3.dwg’ 외 1개"
  • +
  • 끝난 링크도 보내기는 막지 않습니다 — 갈색 한 줄로만 알립니다
  • +
  • 전달하는 사람이 새 큰 파일을 더하면 그것은 이 메일의 새 링크입니다 — 첨부 칸의 「링크로 보내는 파일」(전달하는 사람의 보관 한도에 잡힘)
  • +
  • 받는 쪽에서 인용 속 원래 링크는 본문 글입니다(링크 주인이 보낸 메일이 아니라서 첨부 칸으로 올리지 않음). 내려받기 화면의 보낸 사람 · 끊을 수 있는 사람은 처음 보낸 사람(링크 주인)입니다. 답장도 같습니다
  • +
+
+
+
+
+ + + diff --git a/docs/design/webmail/README.md b/docs/design/webmail/README.md index 7ff5bfe..c3b1e90 100644 --- a/docs/design/webmail/README.md +++ b/docs/design/webmail/README.md @@ -495,7 +495,7 @@ API 와 서버 규칙은 계약 2절 "실시간 알림". ### 보내기 취소 · 예약 발송 (`MailSendLater`) -- **[보내기 ▾]**: 쓰기 화면의 [보내기]를 둘로 나눈다 — 왼쪽은 지금의 [보내기], 오른쪽 ▾(44 — 보드는 40, 밀도 예외를 새로 만들지 않는다)는 메뉴 「예약 발송…」(시계 아이콘). Ctrl+Enter 는 그대로 [보내기]. +- **[보내기 ▾]**: 쓰기 화면의 [보내기]를 둘로 나눈다 — 왼쪽은 지금의 [보내기], 오른쪽 ▾(44 — 보드는 40, 밀도 예외를 새로 만들지 않는다)는 메뉴 「예약 발송…」(시계 아이콘). 보내기 단축키는 없다(쓰기 화면에 Ctrl+Enter 묶음이 없다 — 2026-10-30 확인). 보내는 중 · 저장 중 · 첨부를 올리는 중에는 두 단추를 함께 끈다(지금의 [보내기]와 같은 규칙). - **예약 창**(모달 400): 제목 "예약 발송". 고르기 넷 — 「내일 오전」 9:00 · 「내일 오후」 1:00 · 「다음 주 월요일」 9:00 · 「직접 고르기」(날짜 칸 + 30분 단위 시각) — 줄마다 오른쪽에 날짜 · 요일 · 시각. 오늘 오후가 남았으면 「오늘 오후」 6:00 이 첫 줄. 안내 "이 PC 의 시간(한국 표준시)으로 보냅니다 · 7일 안에서 고를 수 있습니다." [예약] [취소]. @@ -523,6 +523,145 @@ API 와 서버 규칙은 계약 2절 "실시간 알림". - 목록: 줄마다 주소(굵게) + 아래 작은 글 "10월 29일 차단"(도메인은 "도메인 전체 · …") + [차단 풀기](36). 누르면 바로 풀고 알림 "‘주소’ 차단을 풀었습니다." + [되돌리기]. 이미 받은 메일은 옮기지 않는다. 비었으면 "차단한 주소가 없습니다. 메일의 ⋯ › 보낸 사람 차단으로도 막을 수 있습니다." +## 11단계 — 대용량 첨부 (2026-10-30 확정 — `ComposeLargeFiles` · `LargeFileBlock` · `MailReadLargeFiles` · `SharePage` · `SettingsLargeFiles`) + +메일에 담지 못하는 큰 파일을 링크로 보낸다. 파일은 회사 서버에 조각으로 올리고(끊겨도 이어서), 보낼 때 서버가 본문에 내려받기 안내(덩이)를 넣는다. +받는 사람은 로그인 없이 내려받기 화면 `files.arasystem.it.kr/s/{토큰}` 에서 받는다. 보관은 보낸 때(예약 발송은 보낼 시각)부터 30일째 날 23:59:59(한국 시간)까지 — 받는 사람 모두 같은 날이다. +한도는 파일당 2GB · 메일 한 통에 링크 파일 10개 · 직원마다 동시 보관 10GB(올리는 중 · 임시 저장 포함) — 모두 운영 설정이다. +제품 · 기술 결정은 2026-10-30 확정본과 같은 날 정한 보완([링크 복사]는 읽기 화면에서만 · 보내지 않은 파일 지우기 · 전달은 인용 속 링크로 · 첨부처럼 올리는 규칙 — 아래 각 절)을 따른다. +데이터 · 규칙은 계약 4절(`docs/mail/api-contract.md` "대용량 첨부 — 11단계")이 정하고, 아래 문구가 새 에러 코드(`FILE_003`~`FILE_008` · `MAIL_016` — 계약 4절 "에러 코드")의 사전 문구 초안이다. +새 아이콘: 멈춤(pause) · 이어 올리기(play) · 경고 삼각형(alert-triangle). 새 토큰은 없다 — 밀도 예외도 있는 값(40 · 36)을 다시 쓴다. + +### 쓰기 화면 — 첨부 칸 (`ComposeLargeFiles`) + +- **링크로 바뀌는 때**: 파일 하나가 20MB 를 넘을 때, 메일에 담은 파일의 합계를 25MB 넘게 만들 때(고른 차례대로 그 파일부터), 메일에 담지 못하는 형식(mp4 · mov · dwg · dxf · psd · ai · 7z)일 때 저절로 링크로 보낸다. + 묻는 창은 없다 — 합계 때문에 바뀌면 칸 아래 ⓘ 한 줄 "‘회의자료_전체.pptx’(9.4 MB)는 메일에 담으면 합계가 25MB 를 넘어 링크로 보냅니다."(role=status). 받는 사람이 사내뿐이어도 같은 규칙이다. + 메일에 담은 파일도 줄의 링크 단추(40 · 「링크로 보내기」 — 마우스를 올리면 같은 글)로 바꿀 수 있다 — **이 쓰기 화면에서 고른 파일에만** 있다(전달 원본 · 저장본에서 이어 온 첨부에는 없다 — 처음 판). + 링크 → 메일에 담기 단추는 없다(빼고 다시 고르면 한도 안에서 담긴다 — 아래 "처음 판에서 미룬 것"). +- **안내문**: "파일당 최대 20MB · 합계 25MB · 최대 20개 · 더 큰 파일은 링크로 보냅니다 (파일당 2GB · 10개까지)". +- **두 묶음**: 링크 파일이 있으면 칸을 「메일에 담는 파일 N개」(+ "합계 18.1 MB / 25 MB") · 「링크로 보내는 파일 N개」(+ "받는 사람은 보낸 날부터 30일 동안 내려받습니다 — 오늘 보내면 11월 29일(일)까지")로 나눈다(12.5 굵게 + 12 회색). + 일반 첨부만 있으면 지금처럼 묶음 이름 없이 — 링크 파일만 있어도 「링크로 보내는 파일 N개」 머리는 둔다(기간 안내 · 보관 경고의 자리, 보드대로). 보관 사용량이 80%를 넘으면 링크 묶음 머리 오른쪽에 "보관 8.4 / 10 GB"(갈색 굵게). +- **링크 파일 줄**: 아이콘이 링크(청록 #0A5F67), 두 줄 — 이름 · 단추(40) / 크기 · 상태(12px, 아이콘 선만큼 26px 들여), 올리는 동안 3px 막대. ✕ 는 「빼기」 — 올리던 것을 멈추고 서버의 조각도 지운다. + + | 상태 | 둘째 줄 | 단추 · 모양 | + | --- | --- | --- | + | 올리는 중 | "올리는 중 · 420 MB / 1.2 GB" + 오른쪽 "약 3분 남음"(처음 몇 초는 "시간 계산 중") | [멈춤] ✕ · 청록 막대 | + | 멈춤 | "멈춤 · 420 MB / 1.2 GB 올림" | [이어 올리기] ✕ · 회색 막대 — 그 파일만 멈춘다 | + | 잠깐 끊김 | "연결이 끊겨 다시 잇는 중… · 420 MB / 1.2 GB 올림"(갈색) | 누를 것 없음 — 2 · 5 · 15 · 30초 간격으로 저절로, 받은 조각은 그대로 | + | 오래 끊김 | "올리지 못하고 멈췄습니다 — 올린 420 MB 는 남아 있습니다."(빨강) | 빨간 줄 · [다시 시도](40) ✕ — 저절로 다시 잇기를 1분 넘게 못 했을 때 | + | 다 올림 · 검사 중 | "612 MB · 보안 검사 중 — 끝나지 않아도 보낼 수 있습니다" | ✕ | + | 검사 끝 | 깨끗하면 크기만 "1.2 GB" · 못 했으면 "860 MB · 검사하지 못함 — 암호가 걸린 압축 파일 등"(까닭은 응답에 없다 — 암호 압축 · 한도 밖 · 검사기 없음을 함께 쓴다) | ✕ | + | 위험 | "위험한 내용이 발견되어 보낼 수 없습니다. 이 파일을 빼 주세요."(경고 삼각형) | 빨간 줄 · ✕ | + +- **올리기 전 검사**(지금 첨부 사전 검사와 같은 결 — 넘으면 올리지 않고 빨간 줄, 그 파일만 빠지고 보내기는 막지 않는다): + "파일 하나는 2GB 까지 보낼 수 있습니다." · "링크로 보내는 파일은 메일 한 통에 10개까지입니다." · + "대용량 보관 한도(10GB)를 넘어 올릴 수 없습니다. 보내지 않은 파일을 지우거나 링크를 끊으면 바로 공간이 생깁니다. [대용량 첨부 보기]" · + "지금은 서버에 공간이 모자라 올릴 수 없습니다. 잠시 후 다시 시도해 주세요." · 형식은 `FILE_001` 사전 문구. + 링크로 보낼 수 있는 형식 = 일반 첨부 목록 + mp4 · mov · dwg · dxf · psd · ai · 7z. 실행 파일(exe · msi · bat · cmd · js · vbs · scr 등)과 디스크 이미지(iso · img · vhd)는 막는다. 파일 고르기 창은 두 목록을 합친 형식만. +- **다시 열 때**: 브라우저는 고른 파일을 스스로 다시 읽지 못한다. 새로고침 · 창을 닫은 뒤 저장본을 열면 노란 줄 "올리다 멈춘 파일 — 같은 파일을 다시 고르면 이어서 올립니다 · 420 MB / 1.2 GB 올림" + [파일 고르기](40) ✕. + 다른 파일을 고르면 그 줄 안에 "고른 파일이 멈춘 파일과 다릅니다(이름 · 크기). 같은 파일을 골라 주세요."(이름 · 크기 · 고친 시각으로 맞춘다 — 서버는 마지막 조각 뒤 24시간 기다린다). + 다 올린 파일은 저장본에 붙어 "1.2 GB · 이 임시 저장본에 11월 29일까지 남습니다"(올린 날부터 30일), 지나면 빨간 줄 "기간이 지나 지운 파일입니다 — 빼고 다시 올려 주세요." + 설정 › 대용량 첨부에서 지웠으면 같은 모양에 글만 "설정 › 대용량 첨부에서 지운 파일입니다 — 빼고 다시 올려 주세요." +- **다 올릴 때까지 보내기를 막는다**: [보내기]와 ▾ 를 `disabled` 가 아니라 `aria-disabled` 로 끄고(초점은 받는다) [임시 저장] 오른쪽에 까닭 글(ⓘ 12.5 — 두 단추의 `aria-describedby`). + 누르면 보내지 않고 까닭을 읽어 준다(role=status — 보내기 단축키는 앱에 없다). 「예약 발송…」도 같다. 임시 저장은 그대로 된다. 까닭 글 — + 올리는 중 · 다시 잇는 중 "파일 2개를 올리는 중입니다 — 다 올리면 보낼 수 있습니다." · + 멈춤 · 오래 끊김 · 다시 열어 멈춘 파일 "다 올리지 못한 파일이 있습니다 — 이어 올리거나 빼 주세요." · + 위험 · 지운 파일(기간 지남 · 설정에서 지움) "보낼 수 없는 파일이 있습니다 — 빨간 줄의 파일을 빼 주세요." +- **나가기 확인**: 올리는 중이면 "올리는 중인 파일이 있습니다" / "이 화면을 나가면 올리기가 멈춥니다. 임시보관함에서 이 메일을 다시 열고 같은 파일을 고르면 이어서 올립니다." [계속 쓰기](처음 초점 · Esc) [나가기]. + 떠나기 전 임시 저장은 지금처럼 먼저 하고, 올리는 중인 파일은 저장본에 "올리다 멈춘 파일"로 남긴다. 다 올린 뒤에는 이 확인이 없다. + +### 본문 덩이 — 바깥 메일 프로그램에서 (`LargeFileBlock`) + +- **자리**: 내 글 · 서명 아래, 인용 위(쓰기 화면이 알려 준 경계). 인용이 없으면 맨 끝. 서버가 정화 · 서명 그림 · 중복 확인을 마친 뒤 넣는다 — 쓰기 화면의 편집기에는 보이지 않는다. +- **내용**(위에서부터): "대용량 첨부 2개 (합계 1.8 GB)"(14 굵게) → "2026년 11월 29일(일)까지 내려받을 수 있습니다 (한국 시간)" → 파일마다 이름 · 크기(회색, 오른쪽) → + "내려받기 https://files.arasystem.it.kr/s/…"(주소를 글자 그대로 보이는 링크 — 단축 주소 · 단추 모양 없음. 파일 이름은 링크가 아니다 — 메일 한 통에 링크 하나) → + "내려받을 때 로그인이나 비밀번호를 묻지 않습니다."(12 회색) → "Large files: download by Nov 29, 2026 (KST) — no login or password needed."(12 회색). +- **모양**: 테두리 1px #D0D5DD 표 + 글자, 안 여백 12 · 16, 맑은 고딕 14 · 13 · 12. 보내는 서식의 허용 목록 안에서만(table · td colspan · a, 테두리 · 여백 · 색 · 크기 · 굵기 · 정렬 · 줄바꿈 막기) — + 둥근 모서리 · 그림 · 아이콘 · 뱃지 없음. 폭은 내용에 맞춘다(주소 줄이 가장 길다 — 좁은 화면에서는 받는 프로그램이 줄을 끊는다). +- **텍스트 대체 본문**: 같은 자리에 대시 줄로 가른 같은 내용 — 파일은 "- 이름 (크기)", 주소는 "내려받기: 주소". +- 보이지 않는 표시 `` … `` — 보내는 정화가 주석을 지우므로 사용자가 흉내 낼 수 없다. 보내기 취소로 되돌린 메일은 덩이를 떼고 링크 파일로 돌려준다. + 나중에 링크를 끊어도 본문의 날짜 · 주소는 그대로다(누르면 내려받기 화면이 "공유 멈춤"). +- **답장 · 전달**: 링크 파일을 첨부 칸에 다시 싣지 않는다 — 인용 속 덩이는 표째 글(링크)로 그대로 가고 링크는 처음 기간까지 열린다. 인용으로 옮기면 표시가 지워진다. + 전달 쓰기 화면은 칸 아래 한 줄로만 알린다(아래 "읽기 화면 › 전달 쓰기 화면"). 바깥 사람의 답장에 인용된 덩이도 우리 웹메일에서 본문 그대로다 — 첨부 칸으로 올리지 않고 접지도 않는다. + +### 읽기 화면 (`MailReadLargeFiles`) + +- **첨부처럼 올리는 규칙**: 주소가 우리 내려받기 주소이고 서버에 있는 링크이며, **링크 주인이 그 메일의 보낸 사람과 같을 때만** 첨부 칸으로 올린다 — + 바깥 사람 답장의 인용 속 링크 · 전달된 원래 링크는 본문 글이다. +- **사내 받는 사람**: 링크 파일을 첨부 칸에 일반 첨부와 함께 보인다 — 이름 아래 "1.2 GB · 11월 29일까지". 아이콘 · [다운로드](44)는 일반 첨부와 같고, 내려받기 화면을 거치지 않고 바로 받는다. 「첨부 파일 N개」는 둘을 합한 수. +- **본문의 덩이는 접는다**: 그 자리에 접기 줄(높이 40 · 12.5 · 바탕 #F6F7F9) "본문의 대용량 첨부 안내 (접어 둠) — 파일은 아래 첨부 칸에 있습니다" + "펼치기". + 본문 틀 안에서 스크립트 없이 열고 닫는다. 메일을 다시 열면 접혀 있고, 인쇄 화면은 펼친 채 인쇄한다. +- **보낸 사람 자신**: 파일마다 "1.2 GB · 3회 받음 · 11월 29일까지"(받지 않았으면 "아직 받지 않음"), 첨부 칸 아래 한 줄 + "링크로 보낸 파일 2개 — 받는 사람 모두 같은 링크로 받습니다. 끊으면 둘 다 받을 수 없습니다." + [링크 복사] [링크 끊기](36 — 차단한 주소의 [차단 풀기]와 같은 밀도 예외, [링크 끊기]는 빨간 글 테두리 단추). + [링크 복사]는 이 메일 본문의 덩이에서 주소를 읽어 복사한다 — "링크를 복사했습니다."(못 하면 "링크를 복사하지 못했습니다."). 설정 목록에는 없다(서버는 링크를 해시로만 안다). + 받은 수는 처음부터 받기 시작한 수뿐(화면 열기 · 이어 받기 · 내가 받은 것은 빼고)이고, 누가 받았는지는 남기지 않는다. +- **링크 끊기 확인**(alertdialog 400): "대용량 첨부 링크를 끊을까요?" / "받는 사람 모두 이 메일의 파일 2개(설계도_v3.dwg · 현장영상_1029.mp4)를 더는 내려받을 수 없습니다. 이미 받은 파일은 되돌릴 수 없고, 끊은 링크는 다시 열 수 없습니다." + [링크 끊기](빨강) [취소](처음 초점 · Esc). 끊으면 "링크를 끊었습니다. 받는 사람은 더 이상 내려받을 수 없습니다." — 되돌리기는 없다. 쓰던 보관 공간은 바로 비워진다. +- **기간 지남 · 공유 멈춤**: 흐리게(이름 #667085 · 아이콘 #98A2B3 — 투명도가 아니라 글자색) + "기간 지남 — 11월 29일까지였습니다" · "공유 멈춤 — 보낸 사람이 링크를 끊었습니다", 내려받기 단추 없음. + 보낸 사람 자신에게는 받은 수가 남고 끊은 날이 보인다 — "1.2 GB · 3회 받음 · 공유 멈춤 — 10월 31일에 끊었습니다"(한국 날짜 · 응답의 `revokedAt`, 없으면 "공유 멈춤 — 링크를 끊었습니다"). + 끝난 뒤 90일이 지나면 첨부 칸에서 빠지고 덩이는 그냥 본문이 된다. +- **보안 검사**: 검사 중이면 [다운로드]를 `aria-disabled`(title · 까닭 연결) + "보안 검사 중 — 끝나면 받을 수 있습니다". 위험은 빨간 줄 "위험 — 보안 검사에서 막았습니다. 받을 수 없습니다"(단추 없음). 검사하지 못한 파일은 받을 수 있고 글로만 알린다. +- **전달 쓰기 화면**: 원래 메일의 덩이는 인용 속 글(링크)로 그대로 간다 — 첨부 칸에 다시 싣지 않고 칸 아래 ⓘ 한 줄로만 알린다 "원래 메일의 대용량 첨부 2개는 인용 속 링크로 함께 갑니다 — 11월 29일까지". + 공유가 멈췄거나 기간이 지난 링크면 갈색 한 줄 "인용 속 ‘도면_초안.dwg’ 링크는 공유가 멈춰 받을 수 없습니다."("… 기간이 지나 받을 수 없습니다.", 파일이 여럿이면 "‘설계도_v3.dwg’ 외 1개") — 보내기는 막지 않는다. + 전달하는 사람이 새 큰 파일을 더하면 그것은 이 메일의 새 링크다(첨부 칸의 「링크로 보내는 파일」 — 전달하는 사람의 보관 한도에 잡힌다). + 받는 쪽에서 인용 속 원래 링크는 본문 글이고, 내려받기 화면의 보낸 사람 · 끊을 수 있는 사람은 처음 보낸 사람(링크 주인)이다. 답장도 같다. +- 목록의 클립 아이콘은 지금처럼 메일에 담긴 첨부만 본다 — 링크 파일만 있는 메일은 클립이 없다(아래 "처음 판에서 미룬 것"). + +### 내려받기 화면 (`SharePage`) + +- **독립 화면**: 앱 레일 · 상단 바 · 로그인 메뉴가 없다. 머리 바(#0B3640, 60)에 흰 ARA SYSTEM 로고(88) + "파일 내려받기", 바탕 #F6F7F9 에 가운데 열 640. 탭 제목 "김아라 님이 보낸 파일 — ARA SYSTEM". +- **정상**: "김아라 님이 보낸 파일 2개 (1.8 GB)"(20) → "보낸 사람 김아라 · ARA SYSTEM"(로그인한 계정의 값 — 보낸 사람이 바꿀 수 없다) → + "2026년 11월 29일(일)까지 내려받을 수 있습니다 (한국 시간) · 30일 남음" → 파일 목록(줄마다 아이콘 36 · 이름 · "1.2 GB · 보안 검사 · 이상 없음" · [내려받기](채운 단추 44)) → + "내려받다 끊기면 브라우저의 다운로드 목록에서 다시 시도하세요 — 받은 곳부터 이어서 받습니다." → + 청록 상자 "이 화면은 로그인이나 비밀번호를 묻지 않습니다. 묻는 화면이 나오면 닫고 보낸 사람에게 확인해 주세요." → + "받을 이유가 없는 파일이면 postmaster@arasystem.it.kr 로 알려 주세요."(운영 설정 — 처음 값 postmaster@메일 도메인) → 영어 한 줄. +- 메일 제목 · 본문 · 받는 사람, 내려받은 수 · 남은 수는 보이지 않는다. [내려받기]는 형식과 상관없이 늘 저장이고, 누르기 직전에 상태를 한 번 더 묻는다. noindex · no-referrer. +- **상태**(모든 상태에서 머리 · 영어 한 줄은 그대로. 신고 주소 줄은 링크 정보를 받은 상태에서만 — 없는 링크 · 요청이 너무 많음 · 오류 응답에는 그 값이 없다): + - 검사 중 — 모두 검사 중이면 제목 "보안 검사 중입니다" / "파일을 검사하고 있습니다. 몇 분 뒤 이 화면을 다시 열어 주세요." + [다시 확인]. 일부면 그 줄만 "보안 검사 중" + [내려받기] `aria-disabled`. + - 위험 — "내려받을 수 없는 파일입니다" / "보안 검사에서 위험한 내용이 발견되어 막았습니다. 보낸 사람에게 알렸습니다." 그 줄은 빨강 "위험 — 막았습니다", 단추 없음. + - 검사하지 못함 — 제목은 그대로, 줄에 갈색 "검사하지 못함 — 암호가 걸린 압축 파일 등 안을 볼 수 없는 파일입니다. 보낸 사람을 아는 경우에만 여세요." + [내려받기](받을 수 있다). + - 횟수 초과(파일마다 운영 설정 — 처음 값 500번, 넘은 파일만) — 그 줄에 "내려받을 수 있는 횟수를 넘었습니다. 보낸 사람에게 다시 보내 달라고 요청해 주세요."(숫자는 쓰지 않는다 — 한도는 운영 설정이고 응답에 없다) + - 만료 — "내려받을 수 있는 기간이 지났습니다" / "2026년 11월 29일까지 받을 수 있었던 파일입니다. 파일은 지워져 다시 받을 수 없습니다. 필요하면 보낸 사람(김아라, ara.kim@arasystem.it.kr)에게 다시 보내 달라고 요청해 주세요." + - 회수 — "보낸 사람이 공유를 멈췄습니다" / "필요하면 보낸 사람(김아라, ara.kim@arasystem.it.kr)에게 문의해 주세요." 파일 이름도 보이지 않는다. + - 없는 링크(모르는 주소 · 끝난 지 90일이 지나 기록을 지운 링크) — "찾을 수 없는 링크입니다" / "메일의 주소가 끝까지 복사되었는지 확인해 주세요." 보낸 사람도 보이지 않는다. + - 잠시 막힘(같은 IP 의 요청이 너무 많음) — "요청이 너무 많습니다" / "잠시 뒤 다시 시도해 주세요." + [다시 시도]. +- **좁은 폭(휴대폰 360)**: 머리 52 · 로고 76, 보낸 사람 주소는 이름 아래 줄로, 줄마다 [내려받기]가 이름 아래 전체 폭(44). + +### 설정 › 메일 › 대용량 첨부 (`SettingsLargeFiles`) + +- **메뉴**: 메일 묶음의 차단한 주소 아래 「대용량 첨부」 — 서명 · 부재중 자동 응답 · 필터 · 보내기 · 읽기 · 차단한 주소 · 대용량 첨부 · 알림. +- 머리 "대용량 첨부" + "메일에 링크로 보낸 큰 파일입니다. 받는 사람은 보낸 날부터 30일 동안 내려받고, 링크를 끊으면 바로 받을 수 없습니다." +- **사용량 상자**: "보관 사용량 6.2 / 10 GB"(메일함 사용량과 같은 표기) + 4px 막대(90% 를 넘으면 빨강 — 메일함 사용량과 같다) + + "보낸 링크 4.3 GB · 보내지 않은 파일 1.9 GB(임시 저장 · 올리다 멈춤). 기간이 지나거나, 링크를 끊거나, 보내지 않은 파일을 지우면 바로 줄어듭니다." +- **보내지 않은 파일**(사용량 상자 바로 아래 — 공간을 바로 비울 수 있는 곳이라 보낸 링크보다 위): 「보내지 않은 파일 3개」 + "1.9 GB · 임시 저장한 메일에 실렸거나 올리다 멈춘 파일 — 지우면 바로 공간이 생깁니다". 없으면 묶음째 보이지 않는다. + - 줄(52): 아이콘 · 이름 / (올리다 멈춘 파일은 앞에 갈색 "올리다 멈춤") · 크기(멈춘 파일은 "1.2 GB" / "420 MB 올림") · + 실린 저장본의 제목 · 링크는 보이지 않는다(2026-10-30 — 저장본 id 는 저장할 때마다 바뀌고 엔진 밖에서도 지워져 서버가 따라가기 무겁다, 계약 4절 `LargeFile` 에 없음) · + 남는 기한 "11월 29일까지 남음" / "올린 뒤 30일"(올리다 멈춘 파일은 이어 올리기 규칙대로 "내일 오후 2:41까지 남음" / "마지막으로 올린 뒤 24시간") · [지우기](36, 빨간 글). + - [지우기] → 확인 창(alertdialog 400) "보내지 않은 파일을 지울까요?" / "‘시공도면_v4.dwg’(620 MB) 파일을 지웁니다. 이 파일을 실은 임시 저장본이 있으면 열 때 첨부 칸에 ‘지운 파일’로 보입니다. 지운 파일은 되돌릴 수 없습니다." + [지우기](빨강) [취소](처음 초점 · Esc) → "‘시공도면_v4.dwg’ 파일을 지웠습니다." — 줄이 사라지고 사용량이 바로 준다. + 그 저장본을 쓰기 화면에서 열면 첨부 칸에 빨간 줄 "설정 › 대용량 첨부에서 지운 파일입니다 — 빼고 다시 올려 주세요."(쓰기 화면의 "기간이 지나 지운 파일"과 같은 모양 · 다른 글). +- **보낸 링크**: 「보낸 링크 6개」 + "메일마다 한 덩이 · 최근에 보낸 차례". 메일(링크)마다 한 덩이 — 링크가 메일 한 통에 하나라 [링크 끊기]는 덩이에 한 번이다(파일 줄마다 두면 같은 메일의 다른 파일까지 끊긴다). + - 머리: 메일 제목(청록 링크 → 보낸편지함의 그 메일, 메일을 지웠으면 글자만) / "10월 30일 (금) 보냄 · 파일 2개 · 1.8 GB" · 오른쪽 "11월 29일까지" / "30일 남음"(3일 이하면 갈색 굵게) · [링크 끊기](36). + - 파일 줄(44 · 바탕 #FBFBFC): 아이콘 · 이름 · 크기 · "3회 받음" / "마지막 오늘 오후 2:41"(받지 않았으면 "아직 받지 않음") · 상태. + - 상태 글: 사용 중 · 검사 중(회색) · 위험(빨강 굵게 + "내려받기를 막음", 줄 바탕 #FEF3F2) · 기간 지남 · 공유 멈춤. 검사하지 못한 파일은 상태 아래 갈색 "검사하지 못함". 뱃지 없이 글자만. + - 끝난 링크는 덩이째 흐리게 — 머리 오른쪽 "공유 멈춤 / 10월 22일에 끊음" · "기간 지남 / 10월 25일까지였습니다", 단추 없음. 90일 뒤 목록에서 빠진다. + - 최근에 보낸 차례 · 20개씩 [더 보기]. +- **[링크 복사]는 두지 않는다** — 서버는 링크를 해시로만 알아 원래 주소를 모른다. 주소는 보낸 메일의 읽기 화면(보낸 사람 자신)에서 복사한다. +- [링크 끊기] → 읽기 화면과 같은 확인 창 · 같은 알림(창 글에 메일 제목). 끊는 동안 그 덩이의 [링크 끊기]를 막고, 끝나면 초점을 덩이 머리로. +- **빈 상태**: 사용량 "0 / 10 GB" + "링크로 보낸 파일이 없습니다" / "메일 쓰기에서 20MB 가 넘는 파일을 고르면 링크로 보냅니다. 받는 사람은 보낸 날부터 30일 동안 내려받을 수 있습니다." + 보내지 않은 파일이 있으면 그 묶음은 위에 그대로 있다. 목록을 받지 못하면 지금의 목록 오류와 같은 모양. +- **위험이 나오면** 빨간 알림 "올린 대용량 파일 ‘견적_요청서.zip’에서 위험한 내용이 발견되어 막았습니다." + [대용량 첨부 보기] — + 웹메일을 열어 둔 동안 실시간 알림(`large-file`)으로 바로 띄운다. 그때 열려 있지 않았으면 알림은 없고 목록 · 첨부 칸의 상태(위험)로만 안다 — + 이벤트에 보냈는지가 없어 "보낸 파일 · 내려받기를 막았습니다"로 나누지 않는다(계약 4절 "악성 검사"의 문구). 같은 메일의 다른 파일은 그대로 받을 수 있다. + +### 처음 판에서 미룬 것 + +- 링크 → 메일에 담기 되돌리기 단추(제품 조사 3.1) — 빼고 다시 고르면 한도 안에서 메일에 담긴다. +- 링크 파일만 있는 메일의 목록 클립 아이콘 — 목록의 클립은 지금처럼 메일에 담긴 첨부만 본다. +- 전달 원본 · 저장본에서 이어 온 첨부의 「링크로 보내기」 — 이 쓰기 화면에서 고른 파일에만. +- 확정본이 미룬 것: 기간 고르기 · 연장, 다시 보내기, 받는 사람 확인 코드 · 비밀번호, 사내만, 받는 사람별 링크 · 기록, 처음 받음 · 만료 전 알림, 미리 보기, zip 모두 받기, + 폴더 올리기, 관리자 화면 · 퇴사자 처리, 보낸 메일을 지울 때 파일도, 모바일 앱, 파일 저장소 연동. + ## 구현하며 정한 보완 (2026-10-26 — 캔버스에는 아직 없다) - 찾지 못한 첨부(보관 시간이 지난 업로드·지워진 원본)는 실패로 표시하고, 전달 원본에는 "빼기"만 둔다(다시 올릴 수 없다). @@ -801,3 +940,25 @@ API 와 서버 규칙은 계약 2절 "실시간 알림". 엔진 규칙이 꺼져 있으면(`isActive: false`) 머리 아래 노란 줄(단추 없음) "지금은 차단이 동작하지 않습니다. 메일 서버의 규칙이 다른 곳에서 바뀌었습니다 — 주소를 차단하거나 풀면 다시 켜집니다." — 차단 · 풀기가 성공하면 켜진 것으로 본다(서버가 다시 켠다 — 계약 2절). - **새 토큰 · 아이콘**: `--color-on-brand-track` · `--color-on-brand-progress` · `--color-on-brand-divider` · `--color-warning-border`, 아이콘 printer · clock · block(원 + 빗금). + +### 11단계 구현하며 정한 보완 (2026-10-30 — 캔버스에는 아직 없다) + +데이터 · 값은 계약 4절 "올리기 › 클라이언트"의 "구현하며 정함"이 같은 내용이다. + +- **올리기 차례**: 고르자마자 서버에 올리기를 모두 만든다(임시 저장본에 바로 실려 나가기 확인 글이 그대로 맞다). 조각은 파일 3개까지 함께 보내고, 나머지 줄은 오른쪽에 "차례를 기다리는 중"(처음 몇 초의 "시간 계산 중" 자리). +- **끝내기**: 조각을 다 보냈으면 끝내기를 기다리는 동안 오른쪽 글은 "마무리하는 중"이다. 끝내기가 오래 진행 중이면 2 · 5 · 15 · 30초 간격으로 다시 묻고, 1분 넘게 그대로면 "오래 끊김"처럼 멈추고 [다시 시도]. +- **서버가 올리기를 잊었을 때(404)**: 멈춤 + "서버에서 올리던 파일을 찾을 수 없습니다 — 다시 시도하면 처음부터 올립니다." — [다시 시도]를 누를 때만 처음부터 올린다(사전 `RES_001` 문구로는 [다시 시도]의 뜻을 알 수 없어 화면 글로 둔다). + 고른 파일이 없는 줄(저장본에서 이어 온 것)이나 보내기의 `MAIL_016` 뒤 다시 물어 404 면 "서버에서 찾을 수 없는 파일입니다 — 빼고 다시 올려 주세요." +- **서버 거절 문구**: 서버가 준 코드(`FILE_001` · `FILE_002` · `FILE_005` · `FILE_006` · `VAL_001` · `FILE_003`)는 사전 문구다. 위 "올리기 전 검사"의 화면 글은 서버에 묻기 전(크기 · 개수 · 보관량)에만 쓴다 — + "지금은 서버에 공간이 모자라 …"는 쓰지 않고 `FILE_006` 사전 문구. +- **「링크로 보내기」**: 대용량 사전 검사(개수 · 보관량)를 먼저 보고 통과할 때만 일반 첨부에서 뺀다. 걸리면 첨부는 그대로 두고 그 글을 오류 알림으로(보관량이면 + [대용량 첨부 보기]). +- **다시 고르기**: 이름 · 크기 · 고친 시각이 다르면 줄 안에 알리고 기다린다(보드). 같은데 맨 앞 조각의 SHA-256 이 다르거나 서버가 맨 앞 조각을 아직 못 받았으면 앞 올리기를 지우고 새로 올린다(계약). + `crypto.subtle` 이 없는 곳(https · localhost 밖)에서는 지문 없이 보내고 다시 고르기는 늘 새 올리기다 — 운영은 https 라 해당 없다. +- **인용 경계**: 다시 연 답장 · 전달 저장본(보내기 취소로 되돌린 초안 포함)은 "맨 위 인용의 첫 자식이 ‘원본 메일’ 머리 문단 + 머리 표"로 원문 인용을 알아본다 — + 그래야 덩이가 인용 위에 들어가고, 저장본에서 돌지 않던 인용 줄이기도 함께 돈다(2026-10-30 리뷰). +- **검사 상태 다시 묻기**: 쓰기 화면 · 읽기 화면은 검사 중인 파일이 있는 동안 15 · 30 · 60초 간격으로 최대 30번, 내려받기 화면은 20번. 읽기 화면은 막힌 [다운로드]를 누르면 한 번 바로 묻고, + 메일을 바꾸거나 닫거나 세션이 바뀌면 멈춘다. +- **보내기 직전 인용 줄이기**: 서버가 넣을 덩이 몫(파일 수 · 이름 길이로 어림)을 더해 잰다. +- **설정 › 대용량 첨부**: 보낸 링크 머리의 메일 제목이 없으면(지웠거나 읽지 못함 — `subject: null`) 글자만, 빈 제목은 메일이 있는 것이라 링크다. + [지우기]가 `FILE_003`(그사이 보내져 링크에 실림)이면 목록 · 사용량을 다시 받고 사전 문구로 알린다. +- **내려받기 화면**: 보낸 사람 이름이 비어 있으면 주소를 쓴다. 불러오는 동안은 `role="status"` "불러오는 중"(자리 막대는 `aria-hidden`). diff --git a/docs/design/webmail/SettingsLargeFiles.dc.html b/docs/design/webmail/SettingsLargeFiles.dc.html new file mode 100644 index 0000000..72636a9 --- /dev/null +++ b/docs/design/webmail/SettingsLargeFiles.dc.html @@ -0,0 +1,386 @@ + + + + +설정 · 대용량 첨부 + + + + + + + +
+ +
+

설정 · 대용량 첨부

+

링크로 보낸 큰 파일과 아직 보내지 않은 파일을 한곳에서 봅니다 — 보관 사용량, 끝나는 날, 받은 수를 보고 링크를 끊거나 보내지 않은 파일을 지웁니다. 메뉴는 메일 묶음의 「대용량 첨부」(차단한 주소 아래). 초안 — 사용자 확인 전.

+
+ +
+ + + +
+
+

대용량 첨부

+

메일에 링크로 보낸 큰 파일입니다. 받는 사람은 보낸 날부터 30일 동안 내려받고, 링크를 끊으면 바로 받을 수 없습니다.

+
+ +
+
+ 보관 사용량 + 6.2 / 10 GB +
+
+

보낸 링크 4.3 GB · 보내지 않은 파일 1.9 GB(임시 저장 · 올리다 멈춤). 기간이 지나거나, 링크를 끊거나, 보내지 않은 파일을 지우면 바로 줄어듭니다.

+
+ +
+
+

보내지 않은 파일 3개

+ 1.9 GB · 임시 저장한 메일에 실렸거나 올리다 멈춘 파일 — 지우면 바로 공간이 생깁니다 +
+
    +
  • + + 시공도면_v4.dwg저장본 12월 정기 점검 일정 안내 + 620 MB + 11월 29일까지 남음올린 뒤 30일 + +
  • +
  • + + 현장영상_1030.mp4올리다 멈춤 · 저장본 11월 현장 점검 자료 2차 + 1.2 GB420 MB 올림 + 내일 오후 2:41까지 남음마지막으로 올린 뒤 24시간 + +
  • +
  • + + 발표자료_초안.pptx저장본 없음 + 64 MB + 11월 25일까지 남음올린 뒤 30일 + +
  • +
+
+ +
+
+

보낸 링크 6개

+ 메일마다 한 덩이 · 최근에 보낸 차례 +
+
    + +
  • +
    +
    + 11월 현장 점검 자료 보내드립니다 + 10월 30일 (금) 보냄 · 파일 2개 · 1.8 GB +
    +
    + 11월 29일까지 + 30일 남음 +
    +
    + +
    +
    +
      +
    • + + 설계도_v3.dwg + 1.2 GB + 3회 받음마지막 오늘 오후 2:41 + 사용 중 +
    • +
    • + + 현장영상_1029.mp4 + 612 MB + 아직 받지 않음 + 검사 중 +
    • +
    +
  • + +
  • +
    +
    + 견적 요청서 회신드립니다 + 10월 28일 (수) 보냄 · 파일 1개 · 24 MB +
    +
    + 11월 27일까지 + 28일 남음 +
    +
    + +
    +
    +
      +
    • + + 견적_요청서.zip + 24 MB + 아직 받지 않음 + 위험내려받기를 막음 +
    • +
    +
  • + +
  • +
    +
    + 협력사 자료 공유드립니다 + 10월 27일 (화) 보냄 · 파일 1개 · 860 MB +
    +
    + 11월 26일까지 + 27일 남음 +
    +
    + +
    +
    +
      +
    • + + 협력사_자료모음.7z + 860 MB + 2회 받음마지막 10월 28일 오전 9:30 + 사용 중검사하지 못함 +
    • +
    +
  • + +
  • +
    +
    + 홍보 영상 최종본 전달드립니다 + 10월 2일 (금) 보냄 · 파일 1개 · 1.6 GB +
    +
    + 11월 1일까지 + 2일 남음 +
    +
    + +
    +
    +
      +
    • + + 홍보영상_최종.mov + 1.6 GB + 12회 받음마지막 10월 29일 오전 11:05 + 사용 중 +
    • +
    +
  • + +
  • +
    +
    + 도면 초안 검토 부탁드립니다 + 10월 20일 (화) 보냄 · 파일 1개 · 340 MB +
    +
    + 공유 멈춤 + 10월 22일에 끊음 +
    +
    +
      +
    • + + 도면_초안.dwg + 340 MB + 1회 받음마지막 10월 21일 오후 3:20 + 공유 멈춤 +
    • +
    +
  • + +
  • +
    +
    + 행사 사진 원본입니다 + 9월 25일 (금) 보냄 · 파일 1개 · 1.1 GB +
    +
    + 기간 지남 + 10월 25일까지였습니다 +
    +
    +
      +
    • + + 행사사진_원본.zip + 1.1 GB + 7회 받음마지막 10월 19일 오전 10:12 + 기간 지남 +
    • +
    +
  • +
+
+ +
+ + 최근에 보낸 차례 · 20개씩. 끝난 링크는 90일 뒤 목록에서 빠집니다. +
+
+ +
+
+

줄 만드는 법

+
    +
  • 위 「보내지 않은 파일」 — 임시 저장한 메일에 실렸거나 올리다 멈춘 파일. 줄마다 이름 · 실린 저장본(없으면 "저장본 없음") · 크기 · 남는 기한 · [지우기]. 없으면 묶음째 보이지 않습니다. 공간을 바로 비울 수 있는 곳이라 위에 둡니다
  • +
  • 아래 「보낸 링크」 — 메일(링크)마다 한 덩이. 머리에 메일 제목 · 보낸 날 · 끝나는 날 · [링크 끊기], 아래에 파일 줄(이름 · 크기 · 받은 수와 마지막으로 받은 때 · 상태). 링크가 메일 한 통에 하나라 단추는 덩이에 한 번입니다
  • +
  • 상태 글: 사용 중 · 검사 중 · 위험(빨강) · 기간 지남 · 공유 멈춤(흐리게). 검사하지 못한 파일은 상태 아래 갈색 한 줄. 뱃지 없이 글자만
  • +
  • 남은 날이 3일 이하면 "n일 남음"을 갈색 굵게
  • +
  • [링크 끊기] · [지우기] 36 — 차단한 주소의 [차단 풀기]와 같은 밀도 예외(새 토큰 없음). 끝난 링크에는 단추가 없습니다
  • +
  • [링크 복사]는 두지 않습니다 — 서버는 링크를 해시로만 알아 원래 주소를 모릅니다. 주소는 보낸 메일의 읽기 화면에서 복사합니다
  • +
+
+
+

누르면

+
    +
  • 메일 제목 → 보낸편지함의 그 메일. 메일을 지웠으면 제목은 글자만(링크 아님)
  • +
  • 저장본 제목 → 쓰기 화면에서 그 저장본을 이어 씁니다
  • +
  • [링크 끊기] → 확인 창(아래) → 그 덩이는 "공유 멈춤"으로 바뀌고 사용량이 바로 줄어듭니다
  • +
  • [지우기] → 확인 창(아래) → 줄이 사라지고 사용량이 바로 줄어듭니다. 그 파일을 싣고 있던 저장본을 열면 첨부 칸에 "지운 파일"
  • +
  • 받은 수는 처음부터 받기 시작한 수 — 화면 열기 · 이어 받기 · 내가 받은 것은 빼고. 누가 받았는지는 남기지 않습니다
  • +
+
+
+
+ +
+ +
+
+

링크 끊기 확인 창

+

모달 400 · 되돌릴 수 없는 일 — 처음 초점 「취소」

+
+
+
+

대용량 첨부 링크를 끊을까요?

+

‘11월 현장 점검 자료 보내드립니다’의 파일 2개를 받는 사람 모두 더는 내려받을 수 없습니다. 이미 받은 파일은 되돌릴 수 없고, 끊은 링크는 다시 열 수 없습니다.

+
+ + +
+
+
+
+ + 링크를 끊었습니다. 받는 사람은 더 이상 내려받을 수 없습니다. +
+

읽기 화면의 [링크 끊기]와 같은 창 · 같은 알림입니다(되돌리기 단추 없음). 끊는 동안 그 덩이의 [링크 끊기]를 막고, 끝나면 초점을 덩이 머리로 옮깁니다.

+
+ +
+
+

보내지 않은 파일 지우기 확인 창

+

모달 400 · 되돌릴 수 없는 일 — 처음 초점 「취소」

+
+
+
+

보내지 않은 파일을 지울까요?

+

‘시공도면_v4.dwg’(620 MB) 파일을 지웁니다. 이 파일을 실은 임시 저장본 「12월 정기 점검 일정 안내」를 열면 첨부 칸에 ‘지운 파일’로 보입니다. 지운 파일은 되돌릴 수 없습니다.

+
+ + +
+
+
+
+ + ‘시공도면_v4.dwg’ 파일을 지웠습니다. +
+

지우면 줄이 사라지고 사용량이 바로 줄어듭니다(되돌리기 없음). 그 저장본을 쓰기 화면에서 열면 첨부 칸에 빨간 줄 "설정 › 대용량 첨부에서 지운 파일입니다 — 빼고 다시 올려 주세요."(쓰기 보드의 "기간이 지나 지운 파일"과 같은 모양). 저장본이 없으면 둘째 문장을 뺍니다. 올리다 멈춘 파일도 같은 창입니다 — 지우면 이어 올릴 수 없습니다.

+
+ +
+
+
+

빈 상태

+

링크로 보낸 메일이 하나도 없을 때 · 사용량은 그대로, 보내지 않은 파일이 있으면 그 묶음도 위에 그대로

+
+
+
+ 보관 사용량 + 0 / 10 GB +
+ +
+
+ +

링크로 보낸 파일이 없습니다

+

메일 쓰기에서 20MB 가 넘는 파일을 고르면 링크로 보냅니다. 받는 사람은 보낸 날부터 30일 동안 내려받을 수 있습니다.

+
+

목록을 받지 못하면 지금의 목록 오류와 같은 모양 — "메일 서버와 연결할 수 없습니다 …" + [다시 시도].

+
+ +
+
+

보낸 뒤 위험이 나왔을 때

+

오른쪽 아래 알림 — 목록의 빨간 줄은 그대로 남는다

+
+
+ + 보낸 파일 ‘견적_요청서.zip’에서 위험한 내용이 발견되어 내려받기를 막았습니다. + 대용량 첨부 보기 +
+
    +
  • 위험 알림은 웹메일을 열어 둔 동안이면 바로(실시간 알림의 길), 아니면 다음에 메일 화면에 들어올 때 한 번. 단추가 있으니 10초 — 마우스 · 초점이 있으면 멈춤(지금 알림 규칙)
  • +
  • 같은 메일의 다른 파일은 그대로 받을 수 있습니다 — 링크를 끊을지는 보낸 사람이 정합니다
  • +
+
+
+
+
+
+ + + diff --git a/docs/design/webmail/SharePage.dc.html b/docs/design/webmail/SharePage.dc.html new file mode 100644 index 0000000..e7ebf9c --- /dev/null +++ b/docs/design/webmail/SharePage.dc.html @@ -0,0 +1,287 @@ + + + + +내려받기 화면 — files.arasystem.it.kr + + + + + + + +
+ +
+

내려받기 화면 — files.arasystem.it.kr/s/…

+

메일 속 링크는 파일이 아니라 이 화면으로 옵니다. 로그인 없이 링크가 있는 누구나 열고, 계정 비밀번호는 절대 묻지 않습니다. 앱 레일 · 로그인 메뉴가 없는 독립 화면이며, 메일 제목 · 본문 · 받는 사람은 보이지 않습니다. 초안 — 사용자 확인 전.

+
+ +
+ +
+
+ +
+ + files.arasystem.it.kr/s/Qx7mK2vR9pL4wT8nB3cF6hJ1dS5gA0eY2uI7oP9kZ3x +
+ 탭 제목 "김아라 님이 보낸 파일 — ARA SYSTEM" +
+ +
+
+ ARA SYSTEM + + 파일 내려받기 +
+ +
+
+
+

김아라 님이 보낸 파일 2개 (1.8 GB)

+

보낸 사람 김아라 <ara.kim@arasystem.it.kr> · ARA SYSTEM

+

2026년 11월 29일(일)까지 내려받을 수 있습니다 (한국 시간) · 30일 남음

+
+ +
    +
  • + +
    설계도_v3.dwg1.2 GB · 보안 검사 · 이상 없음
    + 내려받기 +
  • +
  • + +
    현장영상_1029.mp4612 MB · 보안 검사 · 이상 없음
    + 내려받기 +
  • +
+

내려받다 끊기면 브라우저의 다운로드 목록에서 다시 시도하세요 — 받은 곳부터 이어서 받습니다.

+ +
+ +

이 화면은 로그인이나 비밀번호를 묻지 않습니다. 묻는 화면이 나오면 닫고 보낸 사람에게 확인해 주세요.

+
+

받을 이유가 없는 파일이면 postmaster@arasystem.it.kr 로 알려 주세요.

+

Files from 김아라 (ara.kim@arasystem.it.kr) — download by Nov 29, 2026 (KST). This page never asks for a login or password.

+
+
+
ARA SYSTEM · files.arasystem.it.kr
+
+
+ +
+
+

넣는 것

+
    +
  • 회사 로고 · 이름 — 모양을 늘 같게("평소와 다르면 의심"이 통하게)
  • +
  • 보낸 사람 이름 · 주소 — 로그인한 계정의 값이라 보낸 사람이 바꿀 수 없습니다. "someone" 같은 말은 쓰지 않습니다
  • +
  • 끝나는 날(한국 시간) · n일 남음
  • +
  • 파일마다 이름 · 크기 · 검사 상태 · [내려받기]. 묶음 크기는 제목에
  • +
  • 로그인 · 비밀번호를 묻지 않는다는 안내(청록 상자), 신고 · 문의 주소(운영 설정 — 처음 값 postmaster@메일 도메인), 영어 한 줄
  • +
+

넣지 않는 것

+
    +
  • 메일 제목 · 본문 · 받는 사람 — 링크가 전달되면 그 사람도 보고, 숨은참조가 드러납니다
  • +
  • 내려받은 수 · 남은 수 — 상한(파일당 500회)은 넘었을 때만 알립니다
  • +
  • 로그인 칸 · 광고 · 다른 서비스 안내 · 앱 레일 · 상단 바
  • +
+
+ +
+

동작

+
    +
  • [내려받기]는 파일을 바로 받습니다 — 형식과 상관없이 늘 저장(브라우저가 열지 않음). 누르기 직전에 상태를 한 번 더 물어, 그사이 끝났으면 화면에서 알립니다
  • +
  • 세는 것은 처음부터 받기 시작한 수뿐 — 화면 열기 · 이어 받기 · 보안 장비가 링크를 미리 여는 것은 세지 않습니다
  • +
  • 검색 엔진에 걸리지 않고(noindex), 이 화면에서 나가는 요청에 주소가 실리지 않습니다(no-referrer)
  • +
  • 폭 640 의 가운데 열. 좁으면 줄마다 단추가 이름 아래로 내려갑니다(아래 휴대폰)
  • +
  • 모든 상태에서 머리(로고) · 신고 주소 · 영어 한 줄은 그대로입니다
  • +
+
+
+
+ +
+ +
+
+

좁은 폭 — 휴대폰 360px

+

메일 앱의 링크로 열었을 때 · 단추는 줄 아래 전체 폭

+
+
+
+ ARA SYSTEM + + 파일 내려받기 +
+
+
+

김아라 님이 보낸 파일 2개 (1.8 GB)

+

보낸 사람 김아라
ara.kim@arasystem.it.kr

+

2026년 11월 29일(일)까지 내려받을 수 있습니다 (한국 시간) · 30일 남음

+
+
    +
  • +
    + +
    설계도_v3.dwg1.2 GB · 보안 검사 · 이상 없음
    +
    + 내려받기 +
  • +
  • +
    + +
    현장영상_1029.mp4612 MB · 보안 검사 · 이상 없음
    +
    + 내려받기 +
  • +
+

내려받다 끊기면 브라우저의 다운로드 목록에서 다시 시도하세요 — 이어서 받습니다.

+
+ +

이 화면은 로그인이나 비밀번호를 묻지 않습니다. 묻는 화면이 나오면 닫고 보낸 사람에게 확인해 주세요.

+
+

받을 이유가 없는 파일이면 postmaster@arasystem.it.kr 로 알려 주세요.

+

Files from 김아라 (ara.kim@arasystem.it.kr) — download by Nov 29, 2026 (KST). This page never asks for a login or password.

+
+
+
+ +
+ +
+
+

검사 중

+

모든 파일이 검사 중일 때의 제목 · 일부면 그 줄만

+
+
+

보안 검사 중입니다

+

파일을 검사하고 있습니다. 몇 분 뒤 이 화면을 다시 열어 주세요.

+
+
현장영상_1029.mp4612 MB · 보안 검사 중
+ 내려받기 +
+ +

Security scan in progress — please check again in a few minutes.

+
+
+ +
+
+

위험 — 막음

+

보안 검사에서 위험이 나온 파일 · 보낸 사람에게 알림

+
+
+

내려받을 수 없는 파일입니다

+

보안 검사에서 위험한 내용이 발견되어 막았습니다. 보낸 사람에게 알렸습니다.

+
+ +
견적_요청서.zip24 MB · 위험 — 막았습니다
+
+

This file was blocked by a security scan.

+
+
+ +
+
+

검사하지 못함 — 받을 수는 있음

+

암호 압축처럼 안을 볼 수 없는 파일 · 제목은 그대로

+
+
+
+
협력사_자료모음.7z860 MB · 검사하지 못함 — 암호가 걸린 압축 파일입니다. 보낸 사람을 아는 경우에만 여세요.
+ 내려받기 +
+

Not scanned (password-protected archive) — open it only if you know the sender.

+
+
+ +
+
+

횟수 초과

+

파일마다 500번 — 넘은 파일만, 그 줄에서 알립니다

+
+
+
+ 홍보영상_최종.mov + 내려받을 수 있는 횟수를 넘었습니다 — 이 파일은 500번까지 받을 수 있습니다. 보낸 사람에게 다시 보내 달라고 요청해 주세요. +
+

Download limit reached for this file — please ask the sender to share it again.

+
+
+ +
+
+

만료

+

끝나는 날이 지났다 · 파일은 지웠다

+
+
+ +

내려받을 수 있는 기간이 지났습니다

+

2026년 11월 29일까지 받을 수 있었던 파일입니다. 파일은 지워져 다시 받을 수 없습니다. 필요하면 보낸 사람(김아라, ara.kim@arasystem.it.kr)에게 다시 보내 달라고 요청해 주세요.

+

This link expired on Nov 29, 2026 (KST).

+
+
+ +
+
+

회수 — 공유 멈춤

+

보낸 사람이 링크를 끊었다 · 파일 이름도 보이지 않는다

+
+
+ +

보낸 사람이 공유를 멈췄습니다

+

필요하면 보낸 사람(김아라, ara.kim@arasystem.it.kr)에게 문의해 주세요.

+

The sender stopped sharing these files.

+
+
+ +
+
+

없는 링크

+

모르는 주소 · 끝난 지 90일이 지나 기록을 지운 링크 — 보낸 사람도 보이지 않는다

+
+
+ +

찾을 수 없는 링크입니다

+

메일의 주소가 끝까지 복사되었는지 확인해 주세요.

+

Link not found — check that the whole address was copied.

+
+
+ +
+
+

잠시 막힘

+

같은 곳(IP)에서 요청이 너무 많을 때 · 화면 열기 · 내려받기 모두

+
+
+

요청이 너무 많습니다

+

잠시 뒤 다시 시도해 주세요.

+ +

Too many requests — please try again shortly.

+
+
+
+
+
+
+ + + diff --git a/docs/design/webmail/canvas.json b/docs/design/webmail/canvas.json index 46f295e..dc28efa 100644 --- a/docs/design/webmail/canvas.json +++ b/docs/design/webmail/canvas.json @@ -33,9 +33,14 @@ "MailReadTools.dc.html": { "x": 0, "y": 16820, "w": 1440, "h": 1220, "title": "읽기 화면 — 외부 이미지 · 인쇄 · 보낸 사람 차단" }, "MailPrint.dc.html": { "x": 1520, "y": 16820, "w": 1440, "h": 1060, "title": "인쇄 화면" }, "MailSendLater.dc.html": { "x": 3040, "y": 16820, "w": 1440, "h": 1500, "title": "보내기 취소 · 예약 발송" }, - "SettingsSendRead.dc.html": { "x": 4560, "y": 16820, "w": 1440, "h": 1000, "title": "설정 · 보내기 · 읽기 · 차단한 주소" } + "SettingsSendRead.dc.html": { "x": 4560, "y": 16820, "w": 1440, "h": 1000, "title": "설정 · 보내기 · 읽기 · 차단한 주소" }, + "ComposeLargeFiles.dc.html": { "x": 0, "y": 18920, "w": 1440, "h": 2360, "title": "메일 쓰기 · 대용량 첨부" }, + "LargeFileBlock.dc.html": { "x": 1520, "y": 18920, "w": 1440, "h": 2020, "title": "본문 덩이 — 바깥 메일 프로그램에서" }, + "MailReadLargeFiles.dc.html": { "x": 3040, "y": 18920, "w": 1440, "h": 1940, "title": "읽기 화면 · 대용량 첨부" }, + "SharePage.dc.html": { "x": 4560, "y": 18920, "w": 1440, "h": 2130, "title": "내려받기 화면 (files.arasystem.it.kr)" }, + "SettingsLargeFiles.dc.html": { "x": 6080, "y": 18920, "w": 1440, "h": 2200, "title": "설정 · 대용량 첨부" } }, - "order": ["Main.dc.html", "Login.dc.html", "Compose.dc.html", "States.dc.html", "MainList.dc.html", "MainListRead.dc.html", "ComposeEditor.dc.html", "Settings.dc.html", "ComposeAssist.dc.html", "Search.dc.html", "BulkSelect.dc.html", "ListStates.dc.html", "SettingsAccount.dc.html", "SettingsVacation.dc.html", "MailboxManage.dc.html", "MailActions4.dc.html", "SettingsNotify.dc.html", "MailRealtime.dc.html", "SettingsFilter.dc.html", "MailFilterStates.dc.html", "MailFilterApply.dc.html", "MailFilterApplyStates.dc.html", "Contacts.dc.html", "ContactsEdit.dc.html", "ContactsMail.dc.html", "MailReadTools.dc.html", "MailPrint.dc.html", "MailSendLater.dc.html", "SettingsSendRead.dc.html"], + "order": ["Main.dc.html", "Login.dc.html", "Compose.dc.html", "States.dc.html", "MainList.dc.html", "MainListRead.dc.html", "ComposeEditor.dc.html", "Settings.dc.html", "ComposeAssist.dc.html", "Search.dc.html", "BulkSelect.dc.html", "ListStates.dc.html", "SettingsAccount.dc.html", "SettingsVacation.dc.html", "MailboxManage.dc.html", "MailActions4.dc.html", "SettingsNotify.dc.html", "MailRealtime.dc.html", "SettingsFilter.dc.html", "MailFilterStates.dc.html", "MailFilterApply.dc.html", "MailFilterApplyStates.dc.html", "Contacts.dc.html", "ContactsEdit.dc.html", "ContactsMail.dc.html", "MailReadTools.dc.html", "MailPrint.dc.html", "MailSendLater.dc.html", "SettingsSendRead.dc.html", "ComposeLargeFiles.dc.html", "LargeFileBlock.dc.html", "MailReadLargeFiles.dc.html", "SharePage.dc.html", "SettingsLargeFiles.dc.html"], "notes": { "flow": { "x": 0, "y": -300, "text": "arasystem 웹메일 — 1단계 화면 (로그인 → 메일함 → 쓰기)", "kind": "title1", "maxW": 4480 }, "phase2": { "x": 0, "y": 2180, "text": "2단계 화면 — 임시 저장 · 받는 사람 자동 완성 · 서명 (2026-10-27 확정)", "kind": "title1", "maxW": 2960 }, @@ -45,7 +50,8 @@ "phase6": { "x": 0, "y": 10380, "text": "6단계 화면 — 메일 필터 (설정 › 메일 › 필터 · 읽기 화면 ⋯ 「이런 메일로 필터 만들기」) — 2026-10-28 확정", "kind": "title1", "maxW": 2960 }, "phase7": { "x": 0, "y": 12400, "text": "7단계 화면 — 이미 받은 메일에 필터 적용 · 필터 창 나가기 확인 · 부재중 안내 — 2026-10-29 확정", "kind": "title1", "maxW": 2960 }, "phase8": { "x": 0, "y": 14280, "text": "8단계 화면 — 주소록 (내 연락처 · 그룹 · 사내 목록 · 메일에서 쓰기) — 2026-10-29 확정", "kind": "title1", "maxW": 4480 }, - "phase9": { "x": 0, "y": 16520, "text": "9단계 화면 — 보내기 취소 · 예약 발송 · 외부 이미지 보기 · 인쇄 · 보낸 사람 차단 — 2026-10-29 확정", "kind": "title1", "maxW": 6000 } + "phase9": { "x": 0, "y": 16520, "text": "9단계 화면 — 보내기 취소 · 예약 발송 · 외부 이미지 보기 · 인쇄 · 보낸 사람 차단 — 2026-10-29 확정", "kind": "title1", "maxW": 6000 }, + "phase11": { "x": 0, "y": 18620, "text": "11단계 화면 — 대용량 첨부 (쓰기 · 본문 덩이 · 읽기 · 내려받기 화면 · 설정) — 초안, 사용자 확인 전", "kind": "title1", "maxW": 7520 } }, "designSystems": [] } diff --git a/docs/mail/api-contract.md b/docs/mail/api-contract.md index b51007a..a4c65a6 100644 --- a/docs/mail/api-contract.md +++ b/docs/mail/api-contract.md @@ -9,10 +9,13 @@ - 성공 `{ "success": true, "data": … }` / 실패 `{ "success": false, "error": { "code", "message", "traceId" } }` - 날짜·시각은 ISO 8601 UTC 문자열, 목록은 `{ items, pageInfo }` - 이 단계에서 쓰는 에러 코드: `AUTH_001` `AUTH_002`(클라이언트 전용) `AUTH_003` `AUTH_004` `AUTH_005` `VAL_001` `RES_001` `REQ_001` `FILE_001` `FILE_002` `MAIL_001` `MAIL_002` `MAIL_003` `MAIL_004` `MAIL_005` `MAIL_006` `MAIL_007` `MAIL_008` `MAIL_009` `MAIL_010` `MAIL_011` `MAIL_012` `MAIL_013` `MAIL_014` `MAIL_015` `CONTACT_001` `CONTACT_002` `SYS_001` + (11단계 — 4절 "대용량 첨부"가 `FILE_003` ~ `FILE_008` · `MAIL_016` 을 더한다) - **적용 범위: 1단계는 웹(브라우저) 전용이다.** 인증은 HttpOnly 쿠키뿐이고 토큰을 본문으로 주지 않는다. 모바일 앱(Flutter)의 인증 방식(토큰 발급·`flutter_secure_storage`)은 앱 착수 때 이 문서에 추가한다. - **배포 형태: 화면과 API 는 같은 주소(origin)를 쓴다.** 운영은 프론트 nginx 가 `/api` 를 백엔드로 넘기고, 개발은 vite 프록시가 넘긴다. 그래서 CORS 가 필요 없고 CSP `connect-src 'self'` 로 충분하다. +- **경로는 대소문자를 가린다** — 이 문서의 경로는 모두 소문자이고, 대소문자 변형(`/API/…` · `/api/Messages/…` · `…/LARGE-FILES/…` · `/api/public-links/…/FILES/…`)은 + 처리기에 닿지 않고 404 `RES_001` 이다(2026-10-30 — 앞단 nginx 의 받기 location 이 놓친 변형이 동시 연결 상한을 비켜 갔다, 4절 "운영 확인"). - **출처 검사(CSRF)**: 상태를 바꾸는 요청(POST·PATCH·PUT·DELETE)은 브라우저가 붙이는 `Sec-Fetch-Site`·`Origin` 을 검사한다. 다른 사이트에서 온 요청은 403 `AUTH_003`. 출처 정보가 아예 없는 요청(브라우저가 아닌 클라이언트)은 통과한다. - **프록시가 직접 내는 오류**: 프론트 nginx 가 백엔드에 닿기 전에 끝내는 요청도 표준 실패 형식으로 온다 — @@ -169,6 +172,7 @@ interface MessageDetail extends MessageSummary { isHtmlBodyOmitted: boolean // HTML 이 있었지만 정화 한도를 넘어 htmlBody 를 비웠으면 true (아래 "읽기 한도") isBodyTruncated: boolean // 보여 줄 본문(htmlBody — null 이거나 공백뿐이면 textBody)이 읽기 한도에서 잘렸으면 true attachments: MessageAttachment[] + largeFiles: MessageLargeFile[] // 우리 서버가 넣은 대용량 첨부 덩이의 파일 — 링크 주인이 이 메일의 보낸 사람일 때만, 없으면 빈 배열 (11단계 — 4절 "우리 웹메일에서 읽기") } interface Attachment { id: string; name: string; type: string; size: number } @@ -224,12 +228,13 @@ HTML 은 정화하기 **전에** 길이(HTML 부분이 여럿이면 이어 붙 | `PATCH /api/messages` | `{ ids, isRead?, isFlagged?, mailboxId?, isJunk? }` — 여러 통 (아래 "여러 통 한꺼번에") | 200 `MessagesPatched` — 붙잡힌 메일(9단계)의 `mailboxId` · `isJunk` 는 바꾸지 않고 `failedIds` 로 | 400 `VAL_001`, 404 `RES_001`(없는 `mailboxId` — 아무것도 바꾸지 않음), 503 `MAIL_001` | | `DELETE /api/messages/{id}` | — | 204 — 휴지통이 아니면 휴지통으로 이동, 휴지통에만 있으면 영구 삭제(확인한 상태 그대로일 때만 — 아래). 휴지통과 다른 메일함에 함께 있으면 휴지통에서만 뺀다. **웹 클라이언트는 쓰지 않는다**(3단계 — 아래 "여러 통 한꺼번에") | 404 `RES_001`, 409 `MAIL_015`(붙잡힌 메일 — 9단계), 503 `MAIL_001`(확인하는 사이 계속 바뀜 — 지우지 않았다) | | `POST /api/message-deletions` | `{ ids }` — 휴지통에서 영구 삭제, 여러 통 (아래 "여러 통 한꺼번에") — 붙잡힌 메일(9단계)은 건너뛴다(`skippedIds`) | 200 `MessageDeletionResult` | 400 `VAL_001`, 503 `MAIL_001`(확인하는 사이 계속 바뀜 · 휴지통 없음 — 지우지 않았다) | -| `POST /api/messages` | 아래 발송 본문 + `Idempotency-Key` 헤더 (예약은 `sendAt` — 9단계) | 201 `SentMessage` (보낸편지함의 메일 id + 받지 못한 사람 — 아래 "받는 사람별 결과" + 붙잡아 둔 발송 `pendingSend` — 9단계) | 400 `MAIL_002`·`MAIL_003`·`MAIL_004`·`MAIL_006`·`MAIL_007`·`MAIL_010`(받는 사람 모두 거절 — 없는 주소 등)·`VAL_001`, 403 `AUTH_003`(보낼 권한 없음), 409 `MAIL_005`(같은 키·다른 내용), 413 `FILE_002`(첨부 합계 초과 포함), 429 `REQ_001`(엔진 발송 한도·같은 키 처리 중·엔진 장애 직후 30초), 503 `MAIL_001` · `MAIL_011`(받는 사람 모두 지금은 받을 수 없음) | +| `POST /api/messages` | 아래 발송 본문 + `Idempotency-Key` 헤더 (예약은 `sendAt` — 9단계) | 201 `SentMessage` (보낸편지함의 메일 id + 받지 못한 사람 — 아래 "받는 사람별 결과" + 붙잡아 둔 발송 `pendingSend` — 9단계) | 400 `MAIL_002`·`MAIL_003`·`MAIL_004`·`MAIL_006`·`MAIL_007`·`MAIL_010`(받는 사람 모두 거절 — 없는 주소 등)·`MAIL_016`(대용량 첨부 — 11단계, 4절)·`VAL_001`, 403 `AUTH_003`(보낼 권한 없음), 409 `MAIL_005`(같은 키·다른 내용), 413 `FILE_002`(첨부 합계 초과 포함), 429 `REQ_001`(엔진 발송 한도·같은 키 처리 중·엔진 장애 직후 30초), 503 `MAIL_001` · `MAIL_011`(받는 사람 모두 지금은 받을 수 없음) | | `POST /api/mail-attachments` | multipart `file` 1개 | 201 `Attachment` | 400 `FILE_001`, 413 `FILE_002` | | `GET /api/messages/{id}/attachments/{attachmentId}` | — | 파일 (표준 래퍼 없음, `Content-Disposition: attachment`) | 404 `RES_001` | +| `GET`·`HEAD /api/messages/{id}/large-files/{fileId}` | `Range`(선택) | 그 메일의 대용량 첨부 파일 (표준 래퍼 없음, 200 · 206 · 416 — 11단계, 4절 "우리 웹메일에서 읽기") | 404 `RES_001`, 409 `FILE_008`(검사 중), 410 `FILE_007`(더 받을 수 없음), 503 `MAIL_001` | | `GET /api/messages/{id}/source` | — | 받은 그대로의 메일 `message/rfc822` (표준 래퍼 없음, `Content-Disposition: attachment; filename="<제목>.eml"`) — 4단계 | 404 `RES_001`, 503 `MAIL_001` | | `GET /api/drafts/{id}` | — | 200 `Draft` (아래 "임시 저장") | 404 `RES_001`(없음·초안이 아님), 503 `MAIL_001` | -| `POST /api/drafts` | 초안 본문 | 201 `DraftSaved` | 400 `VAL_001`·`MAIL_002`·`MAIL_003`·`MAIL_004`·`MAIL_006`·`MAIL_007`·`FILE_001`, 413 `FILE_002`, 503 `MAIL_001` | +| `POST /api/drafts` | 초안 본문 | 201 `DraftSaved` | 400 `VAL_001`·`MAIL_002`·`MAIL_003`·`MAIL_004`·`MAIL_006`·`MAIL_007`·`MAIL_016`(대용량 첨부 — 11단계, 4절)·`FILE_001`, 413 `FILE_002`, 503 `MAIL_001` | | `PUT /api/drafts/{id}` | 초안 본문 (전체 교체) | 200 `DraftSaved` — **새 id** | 위와 같음 + 404 `RES_001`(초안이 아닌 id — 지우지 않음) + 409 `MAIL_008`(다른 곳에서 이미 바뀜 — 저장 안 함) | | `GET /api/mail-signature` | — | 200 `MailSignature` (아래 "서명") | — | | `PUT /api/mail-signature` | `{ bodyHtml, body, isForNew, isForReply }` | 200 `MailSignature` | 400 `VAL_001` | @@ -314,7 +319,9 @@ interface MailQuota { "attachments": [{ "id": "<업로드 응답 id>", "name": "체크리스트.xlsx" }], // 선택, 20개 이하 (넘으면 MAIL_006) "inReplyTo": "<답장할 메일의 id>", // 선택 — 스레드 헤더를 서버가 채운다 "draftId": "<이어 쓴 임시 저장본 id>", // 선택 — 보낸 뒤 서버가 그 초안을 지운다 (아래 "임시 저장") - "sendAt": "2026-10-30T00:00:00.000Z" // 선택 — 예약 발송: 지금부터 5분 뒤 ~ 7일 안 (9단계 "보내기 취소 · 예약 발송") + "sendAt": "2026-10-30T00:00:00.000Z", // 선택 — 예약 발송: 지금부터 5분 뒤 ~ 7일 안 (9단계 "보내기 취소 · 예약 발송") + "largeFileIds": ["<올리기 id>"], // 선택 — 이 메일에 새로 싣는 내 대용량 파일(링크로 보낸다) (11단계 — 4절 "발송 · 임시 저장과 함께") + "bodyQuoteStart": 1234 // 선택 — body 에서 인용이 시작하는 자리 = 덩이를 넣을 자리 (4절) } ``` @@ -438,6 +445,7 @@ interface RejectedRecipient { ### 첨부 업로드 한도 +- 한도를 넘는 파일은 일반 첨부가 아니라 **대용량 첨부(링크)** 로 보낸다 — 4절(11단계). 아래 일반 첨부의 한도 · 흐름은 그대로다. - 파일당 `MAIL_ATTACHMENT_MAX_SIZE_MB` (기본 20MB) — 넘으면 413 `FILE_002` - 메일 한 통의 첨부 합계(전달하는 원본 첨부 포함, 같은 첨부를 두 번 붙이면 두 번 센다) `attachmentTotalMaxBytes` (`MAIL_ATTACHMENT_TOTAL_MAX_SIZE_MB`, 기본 25MB) — 넘으면 발송이 413 `FILE_002`. @@ -465,6 +473,7 @@ interface Draft { attachments: Attachment[] // 이 판의 첨부 — id 가 저장할 때마다 바뀐다 inReplyTo: string | null // 답장할 메일 id (없거나 원 메일이 사라졌으면 null) updatedAt: string // 마지막으로 저장한 시각 (ISO 8601 UTC) + largeFiles: (LargeFile | MissingLargeFile)[] // 대용량 첨부 — 내 파일(올리는 중 포함). id 는 저장해도 바뀌지 않는다 (11단계 — 4절 "임시 저장") } interface DraftSaved { id: string; attachments: Attachment[]; updatedAt: string } @@ -789,6 +798,7 @@ data: {"reason":"token-expiring"} | `new-mail` | 받은편지함에 새로 배달된 메일이 있을 때 — `$draft` 아님 · `$seen` 아님 · 보낸 사람이 나 아님. 새것부터 최대 5통(`count` 는 전체 수). 같은 변화의 `state` 다음에 온다 | | `ping` | 25초마다(직원마다 한 간격 — 새 연결의 첫 ping 은 더 빨리 올 수 있다), 그리고 `upstream` 이 바뀌면 그때 바로. `upstream: false` 면 서버와 엔진 사이가 끊겨 다시 잇는 중이다 — 그동안 클라이언트는 1분 확인으로 돌아간다. 다시 잇는 중에 새로 연결하면 마지막으로 알던 `state` 와 `ping {upstream:false}` 가 바로 온다 | | `end` | 서버가 곧 닫는다 — `token-expiring`(액세스 토큰이 끝나기 30초 전 — 남은 시간이 30초 이하인 토큰(세션이 끝나기 30초 안에 갱신한 토큰)이면 토큰이 실제로 끝날 때) · `server-shutdown`(서버 종료 · 재시작) | +| `large-file` | 내가 올린 대용량 파일이 악성 검사에서 위험으로 판정됐을 때 — `{"fileId","name","scanStatus":"infected"}` (11단계 — 4절 "악성 검사"). 지금 클라이언트는 모르는 이벤트를 버린다 | | 연결 전 실패 (표준 실패 형식 JSON) | 코드 | | --- | --- | @@ -1369,3 +1379,674 @@ interface DirectoryEntry { 상태에는 연 직원의 주소를 함께 싣고, 지금 로그인한 직원과 다르면 쓰지 않는다(같은 탭에서 다른 직원이 로그인한 뒤 뒤로 가기로 돌아온 경우 — 2026-10-29 리뷰). - **읽기 화면 ⋯**: 보낸 사람 주소가 내 주소록에 없으면 「보낸 사람을 주소록에 추가」(이름 · 주소를 채운 연락처 추가 창), 있으면 「주소록에서 보기」. 판단은 메뉴를 열 때 `GET /api/contacts?q=<보낸 사람 주소>` 로 알림 없이 하고, 주소가 같은(대소문자 무시) 연락처가 있는지 본다. + +--- + +## 4. 대용량 첨부 — 11단계 (2026-10-30 확정) + +> 2026-10-30 에 확정한 제품 · 기술 결정(사용자 "추천대로")과 디자인 보드를 그리며 정한 것(같은 날 — 이쪽이 앞선다)을 계약으로 옮겼고, 같은 날 사용자가 캔버스와 함께 확정했다(아래 "정한 것"의 추천도 그대로). +> 백엔드 · 프론트가 이 절로 함께 만들고, 구현하며 바뀌면 이 절부터 고친다. "구현하며 정함"은 구현 때 값을 정해 여기에 적을 곳이다. +> 화면 모양 · 글은 디자인 README "11단계"가 정한다 — 이 절은 데이터와 규칙만 다룬다. + +일반 첨부 한도(파일 20MB · 합계 25MB — 2절 "첨부 업로드 한도")를 넘는 파일은 **메일 엔진이 아니라 우리 서버에** 올려 두고, 메일 본문에는 내려받기 링크를 넣는다(이하 "덩이"). +받는 사람은 로그인 없이 공개 화면 `/s/{token}` 에서 받는다. 보관은 보낸 날(한국)부터 30일째 날 끝까지다. + +- **엔진에 두지 않는다** — 엔진(Stalwart 0.16.27)은 올리기 본문을 통째로 메모리에 받고(한 번 50,000,000바이트), 파일이 그 직원의 메일함 용량 · 메일 백업과 묶이며, 로그인 없는 링크가 없다(2026-10-30 개발 엔진 · 소스 확인). + 파일은 백엔드의 볼륨(`_files` → `/app/files`), 기록은 PostgreSQL 이다. 새 의존성은 없다(백엔드 · 프론트 모두). +- **개념 셋** — 파일(내용 · 주인 · 이름 · 크기 · 검사 결과), 공유 링크(메일 한 통의 파일 묶음 하나 — 토큰 · 끝나는 때 · 끊김), 쓰임(임시 저장본 · 보낸 메일). 2부 파일 저장소가 같은 파일 · 링크 · 올리기 API 를 쓴다. +- **링크는 메일 한 통에 하나다** — 그 메일에 새로 실은 내 파일의 묶음이다. 보낸 사본과 받는 사람의 사본은 같은 메일이라(어댑터가 메일 하나를 만들어 같은 요청에서 제출한다 — `jmap-mail.provider.ts:2042-2075`) + 받는 사람별 링크 · 기록은 없고, **끊기도 링크(메일) 단위**다. +- **답장 · 전달의 인용 속 원래 링크는 글(링크)로 그대로 간다** — 서버가 다시 싣거나 우리 링크로 바꾸지 않는다. 전달하는 사람이 새 대용량 파일을 더하면 그 메일의 링크는 하나(전달하는 사람 것)다. +- 공개 경로는 로그인이 없다 — 바깥 거래처의 브라우저 · 내려받기 관리자 · 받는 쪽 보안 장비가 부른다(머리말의 "웹 전용" · 출처 검사는 로그인한 API 의 규칙이다). 모바일 앱은 이 단계 범위 밖이다. + +### 공유 계약을 바꾸는 곳 (한데 모음) + +| 곳 | 바뀌는 것 | 호환 | +| --- | --- | --- | +| 발송 `POST /api/messages` 본문 (2절) | `largeFileIds` · `bodyQuoteStart` 추가, 서식 본문의 인용 경계 표시 `
` | 필드 추가 — 쓰지 않으면 지금과 같다(지문도 같다) | +| 발송 실패 | 400 `MAIL_016` 추가. `MAIL_007` 은 덩이를 넣은 뒤에도 잰다 | 새 코드 | +| 임시 저장 `POST`·`PUT /api/drafts` 본문 · `GET /api/drafts/{id}` 응답 (2절) | 같은 두 필드(올리는 중인 파일도 된다), 초안 머리글 `X-Ara-Large-Files`, `Draft.largeFiles`, 실패 `MAIL_016` | 필드 추가 | +| 읽기 `GET /api/messages/{id}` (2절) | `MessageDetail.largeFiles`(링크 주인이 그 메일의 보낸 사람일 때만), `htmlBody` 안 그 덩이의 바깥 요소에 `data-ara-large-files` | 필드 · 속성 추가 — 지금 클라이언트는 `data-*` 를 지우고 덩이를 보통 본문으로 그린다 | +| 받기 `GET`·`HEAD /api/messages/{id}/large-files/{fileId}` (2절 표에 한 줄) | 새 경로 — 우리 웹메일에서 대용량 첨부 받기 | 새 경로 | +| 실시간 알림 `GET /api/mail-events` (2절) | `large-file` 이벤트(위험 판정) | 지금 클라이언트는 모르는 이벤트를 버린다(`frontend/src/shared/utils/mail-events.ts:97-110`) | +| 이 절의 새 API | `/api/file-uploads` · `/api/file-quota` · `/api/share-links` · `/api/share-link-lookups` · `/api/public-links`, 화면 `/s/{token}` | 새 경로 — `/api/files` 는 템플릿 업로드 예제가 쓰고 있어 피했다 | +| 에러 코드 | `FILE_003` ~ `FILE_008` · `MAIL_016` (아래 "에러 코드") | 레지스트리에 더하고 `npm run sync:error-codes` | + +### 한도 + +| 한도 | 값(기본) | 넘으면 | 바꾸는 곳 | +| --- | --- | --- | --- | +| 한 파일 | 2,047MB (2,146,435,072바이트 — ClamAV 가 검사할 수 있는 2GiB 아래) | 413 `FILE_002`(만들 때) | `MAIL_LARGE_FILE_MAX_SIZE_MB` | +| 형식 | 일반 첨부 허용 목록 + mp4 · mov · dwg · dxf · psd · ai · 7z — 실행 형식(exe · msi · bat · js · vbs · scr 등) · 디스크 이미지(iso · img · vhd)를 비롯해 그 밖은 받지 않는다 | 400 `FILE_001` | 상수 — 백엔드 정책과 프론트 상수가 짝(한쪽을 고치면 둘 다) | +| 메일 한 통 | 대용량 파일 10개 — 그 메일에 새로 싣는 내 파일(인용 속 원래 링크는 세지 않는다). 일반 첨부(20개)와 따로 센다 | 400 `VAL_001` | `MAIL_LARGE_FILE_MAX_COUNT` | +| 직원당 보관량 | 10GB (10,737,418,240바이트) — 올리는 중(선언한 크기) + 보내지 않은 파일 + 기간 안의 보낸 파일 | 409 `FILE_005`(만들 때) | `MAIL_LARGE_FILE_USER_QUOTA_GB` | +| 직원당 보내지 않은 파일 수 | 50개(올리는 중 포함) | 409 `FILE_005` | 상수 | +| 서버 총량 | 없음(비움) — 모든 직원의 보관량 합 | 507 `FILE_006`(만들 때) | `FILE_STORAGE_MAX_TOTAL_GB` | +| 디스크 여유 | 만든 뒤에도 10GB 가 남아야 한다(진행 중인 올리기의 아직 받지 않은 바이트를 빼고) | 507 `FILE_006`(만들 때 · 쓰다 `ENOSPC`) | `FILE_STORAGE_MIN_FREE_GB` | +| 조각 | 16MiB (16,777,216바이트) 고정 — 2,047MB 면 128개 | 400 `VAL_001`(길이) · 413 `FILE_002`(앞단 17MB) | 계약 상수 | +| 내려받기 | 파일당 500회(받기 시작한 수 — 아래 "파일 응답"의 횟수) | 410 `FILE_007` | `SHARE_DOWNLOAD_LIMIT` | +| 보관 | 보낸 날(한국)부터 30일째 날 끝 | 410 `FILE_007` | `MAIL_LARGE_FILE_RETENTION_DAYS` | + +- MB · GB 는 1,048,576 · 1,073,741,824바이트다(일반 첨부 한도와 같은 셈 — `mail.settings.ts:74`). 한 파일 한도는 env 로 2,047MB 를 넘길 수 없다(검사기 한도 — 아래 "악성 검사"). +- 보관량 · 총량 · 여유는 **만들 때 선언한 크기로 미리 잡는다** — 조각을 보내는 동안에는 한도를 다시 보지 않는다. 같은 직원의 만들기는 하나씩 처리한다(동시에 둘이 한도를 넘지 않게). + 구현(2026-10-30): 만들기는 **서버 전체에서 하나씩**이다 — 서버 총량 · 디스크 여유를 함께 보므로 DB 의 트랜잭션 잠금(`pg_advisory_xact_lock`) 안에서 세고 만든다(인스턴스를 늘려도 그대로 맞다). +- 기간이 지나거나 링크를 끊거나 보내지 않은 파일을 지우면 보관량은 정리 작업을 기다리지 않고 바로 줄어든다(요청 때 상태로 계산한다). + +### 타입 + +```ts +type ScanStatus = 'pending' | 'clean' | 'infected' | 'unscannable' // 검사 중 · 깨끗함 · 위험 · 검사하지 못함 + +/** 내가 올린 대용량 파일 — 올리기 API 의 응답 · 보내지 않은 파일 목록 · Draft.largeFiles */ +interface LargeFile { + id: string // 올리기 id = 다 올린 뒤의 파일 id (바뀌지 않는다) — 발송 · 임시 저장의 largeFileIds 에 쓴다 + name: string // 서버가 정리한 이름 — 일반 첨부와 같은 정리(경로 · 제어 · 방향 제어 문자를 지우고 255자, 확장자는 남긴다 — attachment.policy.ts:220-239) + size: number // 바이트 + status: 'uploading' | 'ready' // 올리는 중 · 다 올림 + chunkSize: number // 16,777,216 — 마지막 조각만 짧다 + chunkCount: number // ceil(size / chunkSize) + receivedChunks: number[] // 받은 조각 번호(0 부터, 오름차순) — ready 면 모두 + firstChunkSha256: string | null // 받은 맨 앞 조각(0번)의 SHA-256 (base64) — 받기 전이면 null. 새로고침 뒤 다시 고른 파일이 같은지 맞춰 본다 + scanStatus: ScanStatus | null // ready 일 때만 (uploading 은 null) + createdAt: string + expiresAt: string // 이대로 두면 지워지는 때 — 아래 "기간 · 보관 · 정리" + lastModified: number | null // 만들 때 받은 File.lastModified (ms) +} + +/** 임시 저장본이 가리키는데 이제 없는 파일 */ +interface MissingLargeFile { + id: string + status: 'missing' + reason: 'deleted' | 'expired' // 지운 파일(설정 · 쓰기 화면에서 지움) · 기간이 지남(올리다 멈춘 지 24시간 · 임시 저장 30일 등) + name: string | null // 기록이 남아 있으면(지운 뒤 30일) 이름 · 크기 + size: number | null +} + +/** 링크 안 파일 하나의 상태 — 받는 쪽에서 본 것 */ +type SharedFileStatus = + | 'available' // 받을 수 있다 (검사에서 깨끗함) + | 'unscannable' // 받을 수 있다 — "검사하지 못함" (암호 압축 · 검사 한도 밖 · 검사기를 쓰지 않는 개발) + | 'scanning' // 검사 중 — 끝나면 받는다 (그동안 받기는 409 FILE_008) + | 'infected' // 위험 — 막았다 + | 'limit-reached' // 이 파일의 내려받기 상한에 닿았다 + | 'revoked' // 보낸 사람(또는 운영)이 링크를 끊었다 + | 'expired' // 기간이 지났다 + | 'missing' // 파일을 찾을 수 없다 (저장소 복원 등 — 드묾) + +/** 메일 속 대용량 첨부 하나 — MessageDetail.largeFiles */ +interface MessageLargeFile { + linkId: string + fileId: string + name: string // 서버 기록의 이름 — 메일 글이 아니다 + size: number + status: SharedFileStatus + expiresAt: string // 링크가 끝나는 때 (아래 "기간 · 보관 · 정리") + revokedAt: string | null // 링크를 끊은 때 (보낸 사람 · 운영) — 끊지 않았으면 null (2026-10-30 더함 — 화면의 "…에 끊었습니다") + url: string // 공개 화면 주소 `/s/` — 본문 덩이의 그 주소. 같은 링크의 파일은 같은 주소 + downloads: { // 보는 사람이 그 링크를 만든 사람일 때만 — 아니면 null + count: number // 받은 수 (받기 시작한 수) + lastDownloadedAt: string | null + } | null +} + +/** GET /api/file-quota */ +interface FileQuota { + usedBytes: number // 지금 잡고 있는 양 — 위 "한도"의 보관량 + limitBytes: number // 직원당 보관량 한도 + maxFileBytes: number // 한 파일 + maxFilesPerMessage: number // 메일 한 통에 새로 싣는 대용량 파일 수 + chunkSize: number // 16,777,216 — 올리기 응답의 chunkSize 와 같다 + retentionDays: number // 보낸 날부터 받을 수 있는 날 수 (30) +} + +/** GET /api/share-links 의 항목 — 내가 만든 링크 (보낸 메일 한 통) */ +interface ShareLink { + id: string + sentEmailId: string | null // 보낸 사본의 메일 id — [보낸 메일 보기]. 보낸 결과를 모르면 null (아래 "링크를 만드는 때") + subject: string | null // 보낸 메일 제목 — 엔진에서 읽는다 (지웠거나 읽지 못하면 null) + sentAt: string // 보낸 때 (예약은 보낼 시각) + expiresAt: string + status: 'active' | 'expired' | 'revoked' // revoked = 보낸 사람(또는 운영)이 끊었다 + revokedAt: string | null // 끊은 때 — revoked 일 때만 값이 있다 (2026-10-30 더함 — 설정의 "10월 22일에 끊음") + files: SharedFile[] // 링크의 파일 (보낸 차례) +} +interface SharedFile { + id: string + name: string + size: number + status: SharedFileStatus + downloadCount: number + lastDownloadedAt: string | null +} + +/** POST /api/share-link-lookups — 인용 속 링크의 상태 (누구의 링크든 상태 · 끝나는 날만) */ +interface ShareLinkLookupRequest { + urls: string[] // 1~20개, 겹치지 않게 — 본문(인용)에서 찾은 공개 화면 주소, 한 개 300자까지 +} +interface ShareLinkState { + url: string // 보낸 그대로 + status: 'active' | 'expired' | 'revoked' | 'unknown' // unknown = 우리 링크 모양이 아님 · 모르는 · 보내기를 취소한 · 기록을 지운 링크 + expiresAt: string | null // unknown 이면 null +} + +/** GET /api/public-links/{token} — 로그인 없음 */ +interface PublicLink { + status: 'active' | 'expired' | 'revoked' + sender: { name: string | null; email: string } // 보낼 때 로그인한 계정의 이름 · 주소 — 보낸 사람이 꾸밀 수 없다 + sentAt: string + expiresAt: string + files: PublicLinkFile[] // active 일 때만 — 끝난 링크는 빈 배열 (파일 이름도 보이지 않는다) + reportAddress: string // 신고 · 문의 주소 (SHARE_REPORT_ADDRESS) +} +interface PublicLinkFile { + id: string + name: string + size: number + status: Exclude +} +``` + +### 엔드포인트 + +| 메서드 · 경로 | 요청 | 성공 | 주요 실패 | +| --- | --- | --- | --- | +| `POST /api/file-uploads` | `FileUploadCreate` (아래 "올리기") | 201 `LargeFile` | 400 `VAL_001` · `FILE_001`, 409 `FILE_005`, 413 `FILE_002`, 429 `REQ_001`, 507 `FILE_006` | +| `GET /api/file-uploads` | `cursor` · `limit`(기본 20, 최대 100) | 200 `{ items: LargeFile[], pageInfo }` — **보내지 않은 내 파일**(살아 있는 링크에 실리지 않은 것 — 올리는 중 포함), 만든 때 최근부터, `total` 포함 | 400 `VAL_001` | +| `GET /api/file-uploads/{id}` | — | 200 `LargeFile` | 404 `RES_001` | +| `PUT /api/file-uploads/{id}/chunks/{index}` | 조각 바이트 (`application/octet-stream`) | 200 `LargeFile` | 400 `VAL_001` · `FILE_004`, 404 `RES_001`, 409 `FILE_003`, 413 `FILE_002`, 429 `REQ_001`, 507 `FILE_006` | +| `POST /api/file-uploads/{id}/completion` | — | 200 `LargeFile` (`ready`) | 404 `RES_001`, 409 `FILE_003`(빠진 조각 · 진행 중) | +| `DELETE /api/file-uploads/{id}` | — | 204 — 올리기를 그만두거나 보내지 않은 파일을 지운다 | 404 `RES_001`, 409 `FILE_003`(살아 있는 링크에 실린 파일) | +| `GET /api/file-quota` | — | 200 `FileQuota` | — | +| `GET /api/share-links` | `cursor` · `limit`(기본 20, 최대 100) | 200 `{ items: ShareLink[], pageInfo }` — 보낸 때 최근부터, `total` 포함 | 400 `VAL_001` | +| `POST /api/share-links/{id}/revocation` | — | 200 `ShareLink` — 그 링크(메일)를 끊는다 | 404 `RES_001` | +| `POST /api/share-link-lookups` | `ShareLinkLookupRequest` | 200 `{ links: ShareLinkState[] }` — 요청 차례 그대로 | 400 `VAL_001` | +| `GET /api/public-links/{token}` | — (로그인 없음) | 200 `PublicLink` | 404 `RES_001`, 429 `REQ_001` | +| `GET`·`HEAD /api/public-links/{token}/files/{fileId}` | `Range`(선택) — 로그인 없음 | 파일 200 · 206 · 416 (표준 래퍼 없음) | 404 `RES_001`, 409 `FILE_008`, 410 `FILE_007`, 429 `REQ_001` | +| `GET`·`HEAD /api/messages/{id}/large-files/{fileId}` | `Range`(선택) | 파일 200 · 206 · 416 (표준 래퍼 없음) | 404 `RES_001`, 409 `FILE_008`, 410 `FILE_007`, 503 `MAIL_001` | + +- `/api/public-links` 밖은 모두 로그인 필요(쿠키 — 없으면 401 `AUTH_001`)이고, 상태를 바꾸는 요청은 출처 검사를 받는다(머리말). +- 경로의 id 는 UUID 모양이 아니면 404 `RES_001`. 남의 것 · 지운 것 · 없는 것을 가리지 않고 404 다. +- 공개 경로 밖의 응답은 `Cache-Control: private, no-store`. + +### 올리기 (`/api/file-uploads`) + +```ts +interface FileUploadCreate { + name: string // 1~255자 (JS 문자열 길이) — 서버가 정리한 이름(응답의 name)으로 둔다 + size: number // 바이트 — 1 이상 · 한 파일 한도 이하의 정수 + purpose: 'mail' // 쓰임 — 11단계는 'mail' 만 (2부 파일 저장소가 더한다) + lastModified?: number | null // 선택 — 브라우저 File.lastModified (ms, 0 이상 정수) +} +``` + +1. **만들기** `POST /api/file-uploads` — 형식은 정리한 이름의 확장자로만 본다(밖이면 400 `FILE_001`). 브라우저가 주는 형식(MIME)은 받지 않는다 — 이 경로에서는 믿을 근거가 없고, + 내려줄 때 늘 `octet-stream` + `attachment` + `nosniff` + sandbox 이며 내용은 악성 검사가 본다(보안 규칙 "확장자와 MIME 둘 다"의 예외 — 열린 질문). + 그다음 크기(413 `FILE_002`) → 직원 한도(409 `FILE_005`) → 서버 총량 · 디스크 여유(507 `FILE_006`) 차례로 본다. 201 `LargeFile`(`uploading`, `receivedChunks: []`). +2. **조각** `PUT /api/file-uploads/{id}/chunks/{index}` — `index` 는 0 ~ `chunkCount − 1`, 본문은 그 조각의 바이트 그대로. + - `Content-Type: application/octet-stream` 만 받는다(JSON 이면 본문 파서의 1MB 한도에 먼저 걸려 413, 그 밖은 400 `VAL_001`). + 조각은 JSON 본문 파서 · 모양 한도를 지나지 않고(두 파서 모두 `application/json` 만 읽는다 — `app.setup.ts:164-170, 208-220`) 처리기가 요청 스트림을 직접 읽는다. + - `Content-Length` 필수 = `chunkSize`(마지막 조각만 `size − chunkSize × (chunkCount − 1)`). 없거나(청크 전송) 다르면 읽기 전에 400 `VAL_001`. 압축(`Content-Encoding`)도 400 `VAL_001`. + - 선택 `Content-Digest: sha-256=::`(RFC 9530) — 웹은 늘 보낸다. 받은 바이트와 다르면 400 `FILE_004`(그 조각을 받지 않았다 — 같은 조각을 다시 보낸다). sha-256 이 없으면(다른 알고리즘만) 없는 것으로 본다. + sha-256 값의 모양이 틀리면(base64 로 32바이트가 아니면) 읽기 전에 400 `VAL_001`(구현 — 2026-10-30). + - 조각 번호는 앞자리 0 없는 10진수(0 ~ 999,999)다 — `01` · `+1` · ` 1` · `1.0` · `1e1` · `0x1` · `0b1` 등은 400 `VAL_001`(숫자로 바꿔 보지 않는다 — 2026-10-30 리뷰). 본문을 다 받지 못했으면(길이가 다름 · 클라이언트가 끊음) 400 `VAL_001`, 디스크 등 그 밖의 오류는 500 `SYS_001`(error 로그). + - 다 받아 디스크에 쓰고(`datasync`) 나서야 받은 것으로 적는다 — 중간에 끊기면 적지 않는다. 서버는 조각마다 SHA-256 을 계산해 둔다(맨 앞 조각의 값이 `firstChunkSha256`). + - **다시 보내기**: 받지 않은 조각은 쓴다. 이미 받은 조각은 쓰지 않고 내용을 견준다 — 같으면 200 그대로, 다르면 409 `FILE_003`(아무것도 바꾸지 않는다). + 같은 조각을 동시에 보내면 뒤의 것, 이미 끝낸 올리기에 보내면 409 `FILE_003`. + - 차례는 자유다(빠진 조각만 다시). 웹은 파일마다 한 번에 한 조각. + - 디스크가 차면(`ENOSPC`) 507 `FILE_006`(error 로그) — 그 조각은 받지 않았다. + - 200 `LargeFile`(받은 조각 반영). +3. **끝내기** `POST /api/file-uploads/{id}/completion`(본문 없음) — 조각이 모두 있으면 파일로 만들고(`ready`) 곧바로 악성 검사 차례에 넣는다 → 200 `LargeFile`(`scanStatus: 'pending'` — 검사기를 쓰지 않으면 `'unscannable'`). + 빠진 조각이 있으면 409 `FILE_003`(클라이언트는 `GET` 으로 빠진 번호를 보고 보낸다), 끝내기가 진행 중이어도 409 `FILE_003`. 이미 끝냈으면 그대로 200(응답을 못 받은 재시도). + 파일 전체의 해시는 따로 보지 않는다(조각마다 봤다). +4. **상태** `GET /api/file-uploads/{id}` — 끊긴 뒤 무엇을 받았는지 본다. 올리는 중은 마지막 조각 뒤 24시간 지나면 404 다(아래 "기간 · 보관 · 정리"). +5. **보내지 않은 파일 목록** `GET /api/file-uploads` — 살아 있는 링크(보낸 메일)에 실리지 않은 내 파일(올리는 중 · 다 올림)이다. 보내기를 취소해 되돌린 파일도 여기 든다. 설정의 "보내지 않은 파일"과 [지우기]가 쓴다. +6. **그만두기 · 지우기** `DELETE /api/file-uploads/{id}` — 올리는 중이면 그만두고, 다 올렸어도 살아 있는 링크에 실리지 않았으면 지운다 — 보관량에서 바로 빠진다. + 그 파일을 싣고 있던 임시 저장본을 열면 `MissingLargeFile`(`reason: 'deleted'` — "지운 파일")로 온다. 살아 있는 링크에 실린 파일은 409 `FILE_003`(링크 끊기로 — 아래 "보낸 사람 관리"). + +**요청 수 · 동시 수** + +| 대상 | 한도 | 넘으면 | +| --- | --- | --- | +| 조각 `PUT` | IP 마다 1분 1,200회 (이 경로만 `@Throttle`) · 직원마다 동시 4개 · 서버 전체 동시 32개 | 429 `REQ_001` | +| 만들기 · 끝내기 · 상태 · 목록 · 지우기 | 전역 한도 (경로마다 IP 당 — `THROTTLE_*`, 기본 1분 100회) | 429 `REQ_001` | + +- 조각 경로만 크게 둔다 — 16MiB 조각은 한 사람이 1분에 수십 번 보내고 사무실은 IP 하나로 나간다(전역 한도에 걸리면 큰 파일이 429 로 멈춘다). + 두 번째 limiter 가 아니라 라우트의 `@Throttle` 이다(외부 이미지 중계와 같은 결 — `mail-image.controller.ts:79`). +- 동시 수는 기다리지 않는다 — 넘으면 429, 클라이언트가 잠시 뒤 같은 조각을 다시 보낸다. 잠금 · 셈은 서버 메모리다(한 인스턴스 전제). +- 앞단 nginx 는 이 경로만 본문 17MB · 읽기 90초이고, 요청 본문을 다 받아 둔 뒤 백엔드에 넘긴다(느린 연결은 nginx 가 받는다 — 아래 "운영 확인"). + +**클라이언트** +- 조각 요청은 `silent` 로 보내고 실패는 그 파일 줄에 보인다(첨부의 `FILE_001` · `FILE_002` 처럼). + 네트워크 오류 · 시간 초과 · 429 · 502 · 503 · 504 는 같은 조각을 저절로 다시 보내고(간격을 늘리며 — 구현하며 정함), 넘으면 멈추고 [다시 시도](받은 조각은 남는다). 401 은 지금 규칙(갱신 한 번 → 같은 조각을 다시). + `FILE_004` 는 그 조각을 다시, `FILE_003` 은 `GET` 으로 상태를 보고 잇는다. `FILE_001` · `FILE_002` · `FILE_005` · `FILE_006` · 404 는 멈추고 알린다. + 구현하며 정함(2026-10-30 — 웹): + - 만들기는 파일을 고르자마자 모두 보낸다(임시 저장본에 바로 실린다). 조각은 파일 3개까지만 함께 보내고 나머지는 차례를 기다린다(위 직원당 동시 4개는 조각 `PUT` 만 센다). + - 저절로 다시 보내는 간격은 2 · 5 · 15 · 30초, 1분 넘게 잇지 못하면 멈추고 [다시 시도]. 요청 시간 한도는 조각 5분 · 그 밖 30초. + - 조각이 `FILE_003` 인데 상태가 "받음"이면 그 조각을 한 번 더 보내 200 을 확인한다(같은 내용이면 200) — 또 `FILE_003` 이면 멈춘다(내용이 다른 조각). + - 끝내기가 진행 중(`FILE_003`)이면 2 · 5 · 15 · 30초 간격으로 다시 묻고 1분 넘게 그대로면 멈추고 [다시 시도](끝내기는 거듭해도 같다). + - 404(서버가 올리기를 잊음)는 멈추고 [다시 시도]를 누를 때만 새로 만들어 처음부터 올린다 — 저절로 다시 올리지 않는다. + - 검사 상태 다시 묻기: 쓰기 화면 · 읽기 화면은 15 · 30 · 60초 간격 최대 30번, 공개 화면은 20번(공개 경로의 IP 한도 안). +- 진행은 받은 바이트로 보인다("420MB / 1.2GB · 약 3분"), 멈춤 · 빼기. 빼면 `DELETE`(알림 없이 — 404 는 지운 것으로 본다). 쓰기 화면을 버리면(휴지통) 지우지 않는다 — 되돌리면 다시 쓴다. +- **새로고침 · 창 닫힘 뒤 이어 올리기(처음 판에 넣는다)**: 올리는 중인 파일도 임시 저장본 머리글에 실린다(아래 "임시 저장") — 다시 열면 `uploading` 으로 오고 + "올리다 멈춘 파일 — 같은 파일을 다시 고르면 이어서 올립니다"(브라우저는 고른 파일을 스스로 다시 읽지 못한다). + 다시 고른 파일은 **이름 · 크기 · `lastModified` 가 같고 그 파일의 맨 앞 조각 SHA-256 이 `firstChunkSha256` 과 같을 때** 같은 파일로 보고 빠진 조각부터 잇는다. + 다르면 새로 만든다(앞 올리기는 `DELETE`). 맨 앞 조각을 받기 전(`firstChunkSha256: null`)이면 이을 것이 없다 — 새로 만든다. + 올리는 중은 마지막 조각 뒤 24시간 남는다 — 그 뒤에 열면 `MissingLargeFile`(`reason: 'expired'`)이라 다시 올린다. +- 올리는 동안은 보내기를 막는다(아래 "보내기를 막는 까닭"). 나가기 확인에 올리는 중인 파일을 넣는다(2절 "임시 저장"의 나가기 규칙). + +### 쓰기 화면이 고르는 것 (클라이언트) + +- **링크로 바뀌는 때**: 파일 하나가 `attachmentMaxBytes`(20MB — `GET /api/mail-quota`)를 넘거나, 일반 첨부 합계가 `attachmentTotalMaxBytes`(25MB)를 넘게 만드는 파일부터 자동으로 대용량(링크)이다. + 일반 첨부와 한 메일에 섞는다. 서버는 바꾸지 않는다 — 보낸 대로 다룬다. +- **손으로 「링크로 보내기」**는 이 쓰기 화면에서 고른 파일(브라우저에 내용이 있는 것)에만 있다. 전달하는 원본 메일의 첨부 · 임시 저장본의 첨부(엔진에 있는 것)는 처음 판에서 바꾸지 않는다. 링크 → 일반 첨부로 되돌리기도 처음 판에는 없다. +- **올리기 전에 막는다**(지금 첨부 사전 검사와 같은 결): 형식(대용량 허용 목록 — 짝 상수), 크기(`maxFileBytes`), 한 통의 개수(`maxFilesPerMessage`), 보관량(`usedBytes` + 이 파일 > `limitBytes`). + 값은 `GET /api/file-quota` 로 받는다(못 받으면 위 기본값). +- 받는 사람이 사내뿐이어도 같은 규칙이다. + +**보내기를 막는 까닭** — 일반 첨부의 까닭(올리는 중 · 찾지 못함)에 더한다. 단추는 `aria-disabled` + 까닭 글로 막는다(글 · 모양은 디자인 README). 서버도 같은 것을 막는다. + +| 까닭 코드 (클라이언트) | 상태 | 서버가 받으면 | +| --- | --- | --- | +| `large-file-uploading` — 올리는 중 · 멈춘 올리기 · 다시 골라야 이어지는 파일 | `LargeFile.status: 'uploading'` | 400 `MAIL_016` | +| `large-file-missing` — 지운 파일 · 기간이 지난 파일 | `MissingLargeFile`(`reason`) | 400 `MAIL_016` | +| `large-file-infected` — 위험 판정 | `scanStatus: 'infected'` | 400 `MAIL_016` | +| `large-file-count` — 한 통의 개수 초과 | `largeFileIds` 가 `maxFilesPerMessage` 보다 많음 | 400 `VAL_001` | + +- 검사 중(`pending`) · 검사하지 못함(`unscannable`)은 막지 않는다 — 받는 쪽이 "검사 중"을 보고 끝나면 받는다. 보관량은 보내기가 아니라 올리기 전에 막는다(만들기의 409 `FILE_005`). + +### 발송 · 임시 저장과 함께 + +**본문 필드** (발송 · 임시 저장 같다 — 2절 "발송 본문"에 더한다) + +```jsonc +{ + // … 2절 그대로 … + "largeFileIds": ["<올리기 id>"], // 선택 — 이 메일에 새로 싣는 내 대용량 파일, 겹치지 않게, 20개까지 (남용 방지 — 실제 한도는 maxFilesPerMessage) + "bodyQuoteStart": 1234 // 선택 — body(텍스트)에서 인용이 시작하는 자리 = 덩이를 넣을 자리 +} +``` + +- **인용 경계 — 서식 본문**: 쓰기 화면은 인용("원본 메일" 머리) 바로 앞에 빈 표시 `
` 하나를 둔다. + 보내는 정화는 이 속성을 `div` 에만 남기고(그 밖의 `data-*` 는 지금처럼 지운다 — `sanitize-outgoing-html.ts:75-82`), 서버는 첫 표시 자리를 덩이 자리로 쓴 뒤 표시를 **모두 지운다** — + 받는 사람에게 가지 않고, 임시 저장본에도 남지 않고, 지문에도 들지 않는다. 지금 쓰기 화면이 보내기 전에 지우는 인용 머리 표시(`data-quote-header` — `mail-serialize.ts:202-206`)와는 따로다. +- **인용 경계 — 텍스트**: `bodyQuoteStart` — 0 ~ `body.length`(JS 문자열 위치)이고 줄의 처음이어야 한다(0 이거나 바로 앞이 줄바꿈). 아니면 400 `VAL_001`. + 서버는 텍스트 본문을 고치지 않고 넘기므로(`mail.service.ts:1192`) 클라이언트가 센 자리 그대로다. 지문에 넣지 않는다. +- 표시 · `bodyQuoteStart` 가 없으면 덩이는 맨 끝(서명 아래)이다. 대용량 파일이 없으면 둘 다 쓰지 않는다(표시는 지운다). + +**보내기 전 확인** (`POST /api/messages` — 엔진에 묻기 전이라 메일은 나가지 않는다) +- `largeFileIds` 의 파일은 있고 · 내 것이고 · 다 올렸고(`ready`) · 위험이 아니어야 한다. 아니면(없음 · 남의 것 · 지움 · 기간 지남 · 올리는 중 · 위험) 400 `MAIL_016` — 다 받았는지 서버가 확인한 뒤에만 보낸다. + 검사 중(`pending`) · 검사하지 못함은 보낸다(보내는 사람을 기다리게 하지 않는다). +- 개수가 `maxFilesPerMessage` 를 넘으면 400 `VAL_001`. +- 클라이언트는 `MAIL_016` 이면 파일마다 `GET /api/file-uploads/{id}` 로 다시 물어(404 면 없는 파일) 쓸 수 없는 것을 보이고 빼게 한다 — `MAIL_004` 와 같은 흐름. 쓰던 메일 · 키는 그대로다. + +**덩이 — 서버가 본문에 넣는 안내** +- 보낼 때만 만든다(임시 저장본에는 넣지 않는다). 한 통에 하나 — 이번 발송의 새 링크(내 파일) 묶음이다. +- **자리**: 서식 본문은 첫 경계 표시 자리, 텍스트는 `bodyQuoteStart` — 없으면 맨 끝. 그래서 **내 글 · 서명 아래, 인용 위**다. +- **내용** — HTML 과 텍스트에 같은 내용(글 · 모양은 디자인 README "11단계", 구현하며 맞춘다): + 개수 · 합계 · 끝나는 날("2026년 11월 29일(일)까지 내려받을 수 있습니다 (한국 시간)"), 파일마다 이름 · 크기, 내려받기 주소(**주소 글자가 보이는 링크** — 단축 주소 없음), + "내려받을 때 로그인이나 비밀번호를 묻지 않습니다", 영어 한 줄("Large files — download by Nov 29, 2026 (KST)"). 받는 사람 · 제목은 넣지 않는다. + 크기는 화면과 같은 표기(1024 단위 — `1.2 GB` · `612 MB`, 짝: frontend `shared/utils/format.ts` 의 `formatFileSize`), 날짜는 한국 날짜. +- **HTML 모양**: 보내는 정화의 허용 목록 안의 태그 · style 값으로만 만든다(2절 "서식 본문" — div · p · span · a · br · table, 색 hex · 여백 px · 글자 8~99px …) — + 다시 읽을 때 · 답장 · 전달의 인용 · 보내기 취소로 되돌릴 때도 모양이 남게. 그림 · 뱃지 · 알약 없이 테두리 상자와 글자. +- **표시**: HTML 덩이의 앞뒤에 주석 `` · `` 를 둔다. 보내는 정화는 HTML 주석을 지우므로(sanitize-html 2.18 — `
ab
` → `
ab
`, 2026-10-30 확인) + 사용자가 쓴 본문(인용 포함)에는 이 표시가 들어갈 수 없다 — 서버만 넣는다. 받는 쪽 메일 프로그램에는 보이지 않는다. +- **텍스트 덩이**: 같은 내용을 줄로 — 앞뒤에 빈 줄. +- **한도**: 넣은 뒤 다시 잰다 — 텍스트 100,000자 · 서식 본문 UTF-8 2,000,000바이트. 넘으면 400 `MAIL_007`(메일도 링크도 만들지 않는다). + 덩이는 파일 10개 · 이름 255자여도 HTML 수십 KB 안이다 — 클라이언트는 지금처럼 보내기 직전에 재고 넘으면 인용을 줄인다(2절 "답장·전달 초안의 길이"). 인용 위라 다시 읽기 한도에서 잘리는 일도 드물다. + +**지문 · 같은 키 재시도** +- 덩이는 **정화 · 서명 그림 · 지문 · 중복 확인을 마친 뒤**, 엔진에 보내기 바로 앞에 넣는다(`mail.service.ts:1036-1117` 의 `deliverOnce` — 지문 `:1056` · 이미 보냈는지 `:1058` · 보내기 `:1099`). + 앞에 넣으면 시도마다 새 토큰이 지문에 들어가 같은 키의 재시도가 `MAIL_005` 가 된다. +- 지문에는 덩이 대신 `largeFileIds`(정렬)를 **있을 때만** 넣는다 — 대용량 파일이 없는 발송의 지문은 지금과 같다(`send-idempotency.ts:109-128` — 서식 본문을 있을 때만 붙이는 것과 같은 결). + 경계 표시 · `bodyQuoteStart` 는 넣지 않는다. +- 같은 키의 재시도가 처음 메일을 찾으면 처음 결과를 그대로 준다 — 링크를 새로 만들지 않는다. 대용량 파일을 바꾼 재시도는 409 `MAIL_005`. + +**링크를 만드는 때 · 발송이 실패하면** +- 이번 발송에 내 파일이 있으면 링크 하나를 만든다 — 중복 확인 뒤 · 엔진에 보내기 바로 앞. 토큰(아래 "공개 내려받기"), 보낸 사람 이름 · 주소(로그인한 계정의 그때 값), 보낸 때, 끝나는 때를 둔다. + 보내기에 성공하면 보낸 사본의 메일 id 를 잇는다. DB 쓰기 한 번이라 발송 55초 예산에 영향이 거의 없다. +- **보내지 않은 것이 확실한 실패**(받는 사람 모두 거절 `MAIL_010` · `MAIL_011`, 엔진이 요청을 거절한 `VAL_001` · `MAIL_004` · `FILE_002` · `MAIL_003` · `AUTH_003` · `REQ_001`(구현 때 더함 — 엔진의 요청 수 한도 거절도 제출 전이다), + 시간이 모자라 시작하지 않은 `MAIL_001` — 어댑터가 "시작하지 않음"으로 표시한 것만(`SendNotStartedException` — 로그 전용인 context 의 글로 가르지 않는다))면 + 그 링크를 **취소한 링크**로 둔다 — 공개 경로는 404(없는 링크)이고 보낸 사람 목록에 나오지 않는다. +- **결과를 모르는 실패**(제출 중 시간 초과 · 연결 끊김 — `MAIL_001`)는 그대로 둔다(나갔을 수 있다). 같은 키의 재시도가 그 메일을 찾으면 그 링크에 보낸 사본을 잇는다. + 찾지 못해 새로 보내면 새 링크가 생기고, 앞 링크는 보낸 메일을 모르는 채(`sentEmailId: null`) 기간까지 산다 — 목록에서 끊을 수 있다(드묾 — 2절 중복 방지의 알려진 한계와 같은 경우). +- 일부 받는 사람만 거절(201 `rejectedRecipients`)은 보낸 것이다 — 링크는 산다. + +**임시 저장** +- 저장 본문의 `largeFileIds` 는 내 파일이면 **올리는 중이어도** 된다(새로고침 뒤 이어 올리기). 없음 · 남의 것 · 지움 · 기간 지남은 400 `MAIL_016` — + 첨부의 `MAIL_004` 처럼 클라이언트는 그런 파일(`MissingLargeFile`)이 남은 동안 저장하지 않는다(사용자가 빼면 다시 저장한다). 위험 판정 파일도 저장은 된다(보낼 때 `MAIL_016`). +- 서버는 초안에 머리글로 둔다 — `X-Ara-Large-Files: <파일 id>, …`(올리는 중인 올리기 id 도 — 같은 id 다. 답장 대상 머리글 `X-Ara-Reply-To-Id` 와 같은 길 — `jmap-mail.provider.ts:193, 2946`). 덩이는 넣지 않는다. + 파일 id 는 저장해도 바뀌지 않는다 — 일반 첨부처럼 응답으로 바꿔 쓸 일이 없다(`DraftSaved` 는 그대로다). +- 저장하면 그 파일들을 "임시 저장에 실린 파일"로 적는다 — 다 올린 파일의 남는 기간이 24시간에서 다 올린 날부터 30일로 는다(아래 "기간 · 보관 · 정리"). 올리는 중인 파일은 그대로 마지막 조각 뒤 24시간이다. +- `GET /api/drafts/{id}`: 머리글의 파일 가운데 **그 직원의 것만** `largeFiles` 로 준다 — 있으면 `LargeFile`, 지웠거나 기간이 지났으면 `MissingLargeFile`(`reason`). + 남의 id 와 기록까지 지워진 id 는 버린다(다른 프로그램이 만든 초안일 수 있다 — 둘은 가릴 수 없다). +- 알려진 한계(답장 대상 머리글과 같다): 다른 메일 프로그램이 그 초안을 그대로 보내면 머리글만 나가고 덩이는 없다. + +**보내기 취소 · 예약 발송** (2절, 9단계) +- 덩이는 제출할 때 메일 안에 들어 있다 — 엔진이 붙잡았다가 그대로 보낸다. +- 링크의 보낸 때: 예약은 `sendAt`(끝나는 날도 그 날짜로 센다 — 예약 7일 + 30일이면 37일까지 보관), 보내기 취소를 기다리는 발송은 요청한 때(몇 초 차이는 무시). + 예약 링크는 보낼 시각 전에도 열린다(주소를 아는 사람은 보낸 사람뿐이다). +- 취소(`DELETE /api/scheduled-messages/{id}`)가 엔진에서 확인된 뒤 그 발송이 만든 링크를 취소한 링크로 둔다. 파일은 남는다(되돌린 초안이 쓴다 — 임시 저장 기간으로, 보내지 않은 파일 목록에 다시 나온다). +- 되돌린 초안(취소 표시 `$ara-canceled`)을 열면 서버가 원문 HTML 의 표시 사이(덩이)를 정화 **전에** 떼고(`mail.service.ts:1282-1304` 의 `draftHtml` — 취소한 발송의 서명 그림을 되돌리는 그 자리), + 텍스트 본문의 덩이도 뗀다(서버가 넣은 글 그대로일 때 — 손대면 남는다). 그 링크의 파일을 `largeFiles` 로 돌려준다 → 쓰기 화면은 대용량 목록으로 보이고, 다시 보내면 새 링크다. + 덩이를 떼지 못하면 다시 보낼 때 덩이가 둘이 된다 — 테스트로 지킨다. 인용 속 남의 덩이(표시 없는 글)는 건드리지 않는다. +- 붙잡힌 메일은 우리 API 로 옮기거나 지울 수 없다(`MAIL_015`) — 그동안 링크 · 파일도 그대로다. +- ~~⚠️ 구현 전 확인: 엔진이 보낸 사본의 HTML 주석을 그대로 두는지~~ — 확인함(2026-10-30, 개발 엔진 Stalwart 0.16.27): 만든 메일을 다시 읽으면 HTML 주석 · 텍스트 본문(줄바꿈 그대로)과 + `X-Ara-Large-Files` 머리글(`header:X-Ara-Large-Files:asText`)이 그대로 돌아온다. e2e 가 취소 → 되돌린 초안 → 다시 보내기에서 덩이가 하나인지 본다. + +**답장 · 전달** +- 원본의 대용량 첨부를 다시 싣지 않는다 — 원래 메일의 덩이는 **인용 속 글(링크)로 그대로** 간다(편집기가 `data-ara-large-files` 를 버린다 — 링크는 보통 링크다). + 서버는 인용 속 링크를 다시 싣거나 우리 링크로 바꾸지 않는다. 받는 사람은 그 링크로 원래 공개 화면에서 받는다 — 원래 파일의 받은 수 · 상한 · 끝나는 날을 따른다. +- 전달 · 답장하는 사람이 새 대용량 파일을 더하면 그 메일의 링크는 하나(그 사람 것)이고, 새 덩이는 인용 위에 들어간다. +- 쓰기 화면은 인용 속 원래 링크의 상태(끝나는 날 · 기간 지남 · 공유 멈춤)를 첨부 칸 아래 **글자로만** 알린다 — 아래 `POST /api/share-link-lookups`. + +### 인용 속 링크의 상태 (`POST /api/share-link-lookups`) + +- 쓰기 화면(전달 · 답장)이 인용에서 찾은 공개 화면 주소를 보내면 링크마다 **상태 · 끝나는 날만** 준다 — 누구의 링크든 같다(이름 · 보낸 사람 · 받은 수는 주지 않는다. 주소를 가진 사람은 공개 화면에서 더 많이 본다). +- 클라이언트는 주소의 경로가 `/s/` + 43자(`[A-Za-z0-9_-]`)인 링크를 고른다. 서버는 `/s/<토큰>` 모양이 아니거나 모르는 · 보내기를 취소한 · 기록을 지운 링크면 `unknown` 이다. + 이 길에서는 주소 뒤의 쿼리 · `#` 은 보지 않는다(받는 쪽 보안 장비가 붙이기도 한다 — 구현). 읽기 화면의 덩이 알아보기는 쿼리 · `#` 이 없는 주소만 받는다. +- 상태: `active`(받을 수 있는 기간 — `expiresAt`), `expired`, `revoked`. 파일마다의 상태(검사 중 · 위험 등)는 주지 않는다 — 받는 사람이 공개 화면에서 본다. +- 주소(토큰)를 쿼리에 싣지 않으려고 `POST` 본문으로 받는다(요청 주소가 로그에 남는다 — 아래 "로그"). 상태를 바꾸지 않는다. 본문 검사(1~20개 · 겹침 · 300자)에 걸리면 400 `VAL_001`. +- **클라이언트**: 첨부 칸 아래에 "인용한 대용량 첨부 링크 — 11월 29일까지 받을 수 있습니다" · "기간이 지났습니다" · "보낸 사람이 공유를 멈췄습니다"(글은 디자인 README). `unknown` 은 알리지 않는다. 알림 없이(`silent`) 부르고 실패하면 글자를 두지 않는다. + +### 우리 웹메일에서 읽기 (`MessageDetail.largeFiles` · `GET /api/messages/{id}/large-files/{fileId}`) + +- **첨부처럼 올리는 것은 셋이 모두 맞을 때만이다**: + 1. 원문 HTML(정화 전)의 표시 주석 사이에 있는 링크다 — 표시가 없으면 DB 를 보지 않는다. + 2. `href` 가 `/s/<토큰>` 꼴이고 토큰이 우리 기록에 있다(보내기를 취소한 링크는 빼고 — 한 통에 10개까지 본다). + 3. 그 링크를 만든 직원이 **그 메일의 보낸 사람**(머리글 From 의 주소 — 대소문자 무시)이다. +- 맞으면 그 링크의 파일을 `largeFiles` 로 준다 — 이름 · 크기 · 상태는 메일 글이 아니라 서버 기록이다. `htmlBody` 에서는 그 덩이의 바깥 요소에 `data-ara-large-files` 를 붙인다. + 받은 HTML 에 있던 같은 속성은 정화가 지운다(받은 메일 정화의 속성 허용 목록에 `data-*` 가 없다 — `sanitize-mail-html.ts:165-`) — 그래서 서버만 붙일 수 있다. +- **맞지 않으면 본문 글로 둔다** — 바깥이 흉내 낸 덩이, 바깥 사람 답장의 인용 속 링크, 전달된 원래 링크(인용 속 덩이에는 표시가 없고 보낸 사람도 다르다), 다른 주소. 속성도 `largeFiles` 도 없고 링크는 다른 링크처럼 새 창이다. +- 텍스트만 있는 메일과, HTML 이 정화 한도를 넘어 비운 메일(`isHtmlBodyOmitted`)의 덩이는 알아보지 않는다(알려진 한계). +- `downloads`(받은 수 · 마지막으로 받은 때)는 보는 사람이 그 링크를 만든 사람일 때만 붙는다 — 보낸편지함의 사본, 나에게도 보낸 메일. +- 목록의 `hasAttachment`(클립)는 지금처럼 일반 첨부만 본다 — 대용량 첨부만 있는 메일은 클립이 없다(처음 판에서 뺀다). +- **받기** `GET`·`HEAD /api/messages/{id}/large-files/{fileId}` — 그 메일에서 첨부처럼 올린 파일만 준다(아니면 404 `RES_001`). + 머리글 · 범위 · 상태별 실패는 아래 "공개 내려받기"와 같고(`FILE_007` · `FILE_008`), 엔진을 읽지 못하면 503 `MAIL_001`. 요청 수는 IP 마다 1분 300회. + 보는 사람이 링크를 만든 사람이면 **세지 않는다**(보낸 사람 자신의 확인). 다른 직원(사내 받는 사람)은 공개 내려받기처럼 센다. + 공개 경로에서는 보낸 사람을 알아볼 수 없다 — 포털 쿠키는 호스트 한정(Domain 없음 — `auth-cookies.ts:29-36`)이라 공개 호스트로 가지 않는다. +- **클라이언트**: 첨부 칸에 첨부처럼 — 이름 · 크기 · "11월 29일까지". 받을 수 없으면 흐리게 + 글자("검사 중" · "기간 지남" · "공유 멈춤" · "위험" · "횟수 초과" · "찾을 수 없음"), "검사하지 못함"은 받을 수 있는 채 글자만. + 덩이(`data-ara-large-files` 요소)는 접어 둔다 — 그리기 전에 떨어진 문서에서 처리한다(클라이언트 정화가 `data-*` 를 지우므로 그 전에 — `frontend/src/shared/utils/mail-html.ts:85`). + 받기는 첨부처럼 `` 이동이고, 이동 전에 세션을 확인한다(`confirmSession()`). + 보낸 사람 자신에게는 "3회 받음 · 11월 29일까지" · [링크 복사](덩이의 주소 — `url`) · [링크 끊기](그 메일의 링크 — 확인 대화상자, 처음 초점 「취소」). + +### 보낸 사람 관리 (`/api/share-links` · `/api/file-uploads` · `/api/file-quota`) + +- **보낸 링크 목록** `GET /api/share-links` — 내가 만든 링크(취소한 링크는 빼고), 보낸 때 최근부터. 기록을 지우기 전(끝나거나 끊긴 뒤 90일)까지 기간 지남 · 끊음도 나온다. + `subject` 는 그 쪽의 보낸 사본을 엔진에서 한 번에 읽는다 — 지웠거나 엔진을 읽지 못하면 null 이고 목록은 그대로 준다(warn). +- **링크 끊기** `POST /api/share-links/{id}/revocation` — **그 링크(메일)의 파일 모두**의 공유를 멈춘다(파일 하나만 끊지 않는다). 되돌릴 수 없다. 200 `ShareLink`(`revoked`). + 다음 요청부터 공개 화면은 "공유 멈춤"이고 받기는 410 `FILE_007` 이다 — 이미 시작한 내려받기는 끝까지 간다. + 그 파일들이 다른 살아 있는 링크 · 임시 저장에 없으면 보관량에서 바로 빠지고 정리 때 지운다. 이미 끊었거나 기간이 지났으면 바꾸지 않고 200(지금 상태). 남의 링크 · 없는 것은 404 `RES_001`. +- **링크 복사는 목록에 두지 않는다** — 토큰은 SHA-256 만 두어 되살릴 수 없다. 보낸 사람은 보낸 메일 본문의 덩이에서 복사한다(읽기 화면 — `largeFiles[].url`). +- **보내지 않은 파일** `GET /api/file-uploads` · [지우기] `DELETE /api/file-uploads/{id}` — 보관량이 바로 준다. 그 파일을 싣고 있던 임시 저장본을 열면 "지운 파일"(`MissingLargeFile` · `reason: 'deleted'`)로 보인다. +- **사용량** `GET /api/file-quota`. +- **클라이언트**: 설정 › 메일 › 대용량 첨부 — + - 맨 위 사용량("6.2 / 10 GB" — `formatQuotaUsage`). + - 보낸 링크는 **메일(링크)마다 한 덩이**: 메일 제목 · 보낸 날 · 끝나는 날("n일 남음") · 상태(사용 중 · 만료 · 회수), 파일마다 이름 · 크기 · 받은 수 · 마지막으로 받은 때 · 파일 상태(검사 중 · 위험 · 검사하지 못함 · 횟수 초과), + [보낸 메일 보기](`sentEmailId` — null 이면 없다) · [링크 끊기](확인 — "이 메일로 보낸 대용량 파일 N개를 더는 받을 수 없게 됩니다", 처음 초점 「취소」). + - 보내지 않은 파일: 이름 · 크기 · 상태(올리는 중 · 다 올림 · 검사 상태) · 지워지는 때 · [지우기]. + +### 공개 내려받기 (로그인 없음) + +- 누구나(바깥 거래처 · 내려받기 관리자 · 받는 쪽 보안 장비) 부른다. 로그인 · 쿠키를 쓰지 않는다(읽지도 않는다). 상태를 바꾸지 않는 `GET` 이라 출처 검사도 해당 없다. +- **응답에 실리는 메일 정보는 보낸 사람 이름 · 주소뿐이다** — 제목 · 본문 · 받는 사람 · 메일 id 는 싣지 않는다(링크가 전달되면 그 사람도 본다 — 숨은참조가 드러나지 않게). 링크에 받는 사람 정보를 넣지도 않는다. + +**토큰** +- 링크마다 `crypto.randomBytes(32)` 의 base64url(43자, `=` 없음 — 256비트). 서버 서명 키(`JWT_SECRET`)에서 만들지 않는다(보안 규칙). +- DB 에는 **SHA-256 만** 두고 그것으로 찾는다 — 원문은 메일 본문에만 있고, 서버는 링크를 다시 만들 수 없다. 맞혀 볼 수 없는 값이라 느린 해시는 필요 없다. +- 경로에 둔다(`/s/<토큰>` · `/api/public-links/<토큰>`) — 받는 쪽 보안 장비의 링크 바꿔 쓰기와 잘 맞는다. 로그에서는 가린다(아래 "로그"). +- 모양(`^[A-Za-z0-9_-]{43}$`)이 아니면 DB 에 묻지 않고 404. + +**상태별 응답** + +| 상황 | 링크 정보 `GET /api/public-links/{token}` | 파일 `GET /api/public-links/{token}/files/{fileId}` | +| --- | --- | --- | +| 모양이 틀린 · 모르는 토큰, 보내기를 취소한 링크, 기록을 지운 링크(끝난 뒤 90일), 그 링크에 없는 파일 | 404 `RES_001` | 404 `RES_001` | +| 기간 지남 | 200 `status: 'expired'` (`files: []`) | 410 `FILE_007` | +| 링크를 끊음(보낸 사람 · 운영) | 200 `status: 'revoked'` (`files: []`) | 410 `FILE_007` | +| 검사 중 | 200 — `scanning` | 409 `FILE_008` | +| 위험 · 횟수 초과 · 찾을 수 없음 | 200 — `infected` · `limit-reached` · `missing` | 410 `FILE_007` | +| 받을 수 있음 · 검사하지 못함 | 200 — `available` · `unscannable` | 200 · 206 · 416 | +| 요청 수 한도 | 429 `REQ_001` | 429 `REQ_001` | + +- 없는 링크와 끝난 링크를 가려 알려도 된다 — 토큰을 가진 사람에게만 보이는 구분이다. +- 404 · 409 · 410 은 표준 실패 형식이지만 전역 필터를 거치지 않고 컨트롤러가 쓴다 — 로그는 debug 만(끝난 링크를 누르는 받는 사람 · 봇이 잦아 warn 이면 알림이 쌓인다 — + 외부 이미지 중계와 같은 결 `mail-image.controller.ts:64-67`). 5xx 는 지금처럼 error. + 구현(2026-10-30): 공개 컨트롤러에만 건 같은 예외 필터(`HttpExceptionFilter` 의 `quietStatuses` — 404 · 409 · 410 은 debug)가 쓴다 — 형식 · traceId 는 다른 API 와 같다. +- 링크 정보 응답도 `Cache-Control: no-store` · `X-Robots-Tag: noindex` · `Referrer-Policy: no-referrer`. + +**파일 응답** (공개 받기 · 우리 웹메일 받기 공용) +- **머리글**: `Content-Type: application/octet-stream`(늘 — 형식은 서버가 정한다) · `Content-Disposition: attachment; filename=…; filename*=UTF-8''…`(일반 첨부와 같은 `buildAttachmentDisposition` — `shared/utils/content-disposition.ts:39`) · + `X-Content-Type-Options: nosniff` · `Content-Security-Policy: default-src 'none'; sandbox` · `Cache-Control: private, no-store` · `Referrer-Policy: no-referrer` · `X-Robots-Tag: noindex` · + `Accept-Ranges: bytes` · `Content-Length` · `ETag: ""`(강한 — 파일 내용은 id 마다 바뀌지 않는다) · `Last-Modified`(다 올린 때) · `X-Accel-Buffering: no`. +- **범위(Range)**: + - 없으면 200 전체. `bytes=` 범위 하나면 206 + `Content-Range: bytes <처음>-<끝>/<크기>`(끝이 크기를 넘으면 맞춰 자르고, `bytes=-N` 은 끝 N 바이트). + - 범위가 여럿이거나 형식이 틀리면 범위를 무시하고 200 전체(RFC 9110 이 허락한다). `If-Range` 가 ETag · Last-Modified 와 다르면 200 전체. + - 처음이 크기 이상이면 416 + `Content-Range: bytes */<크기>` — 본문은 비운다(내려받기 관리자가 읽는 응답이라 표준 실패 형식이 아니다 — 2절 첨부 받기처럼 래핑 예외). + - `HEAD` 는 같은 머리글만 준다(파일을 열지 않고 세지 않는다). +- **횟수**: + - 센다: `GET` 이고 0번째 바이트부터 보내는 응답(범위 없음 · 무시 · `bytes=0-…`). 머리글을 보내기 직전에 원자적으로 더한다 — 상한에 닿았으면 더하지 않고 410 `FILE_007`. + - 세지 않는다: 이어 받기(범위의 처음 > 0), `HEAD`, 링크 정보(화면 열기), 실패 응답, 링크를 만든 사람이 우리 웹메일 경로로 받은 것. + - 상한(`SHARE_DOWNLOAD_LIMIT`, 파일당 500)에 닿으면 그 파일은 이어 받기도 410 `FILE_007` 이다. 남은 횟수는 어디에도 내세우지 않는다. + - 받은 수 · 마지막으로 받은 때는 보낸 사람만 본다(목록 · 읽기 화면의 `downloads`). 누가 · IP 는 남기지 않는다. +- 내려받는 동안 링크를 끊거나 기간이 지나도 이미 시작한 응답은 끝까지 간다(다음 요청부터 막힌다). 서버 종료가 시작되면 진행 중인 내려받기를 바로 끊고(종료 유예 30초를 붙잡지 않게 — 실시간 알림의 종료와 같은 결) 그 뒤에 온 받기는 시작하지 않는다 — 브라우저가 범위로 이어 받는다. +- ⚠️ Nest 의 `StreamableFile` 로는 206 · 416 을 보낼 수 없다(`@Res({ passthrough: true })` 면 라우트 상태로 덮어쓴다 — `@nestjs/platform-express` 11.2.5) — + 처리기가 `@Res()` 로 상태 · 머리글을 직접 쓰고 흘려보낸다(2절 첨부 받기 `message.controller.ts:357-381` 과 다른 길). + +**요청 수 한도**: IP 마다(`clientIpKey()` — IPv6 는 /64) 링크 정보 1분 60회, 파일 1분 300회(`HEAD` 포함 — 내려받기 관리자가 범위를 나눠 여러 번 부른다). 넘으면 429 `REQ_001`. +요청 수일 뿐 바이트는 세지 않는다 — 퍼진 링크의 대역폭은 횟수 상한 · 기간 · 끊기가 막는다(앞단 동시 연결 — IP 마다 공개 10 · 앱 안 30, 전체 400 · 200 — 아래 "운영 확인"). + +**화면 `/s/{token}`** — SPA 의 공개 라우트 +- 셸 · 로그인 없이 그린다 — 라우트의 공개 표시(`meta.public`)로 세션 복원(`/auth/me` · `/auth/refresh` — 지금 가드는 모든 이동 전에 부른다 `frontend/src/router/index.ts:26-28`)과 로그인 화면 보내기를 건너뛴다. + API 는 세션 갱신 · 세션 만료 대화상자를 타지 않는 따로 된 요청(쿠키 없이)으로 부른다. +- 보이는 것: 회사 표시, 보낸 사람 이름 · 주소, 보낸 날 · 끝나는 날(한국 시간), 파일 목록(이름 · 크기 · 검사 상태 · [내려받기]), "이 화면은 로그인이나 비밀번호를 묻지 않습니다", 신고 · 문의 주소, 영어 한 줄. + 상태: 정상 · 검사 중 · 위험 · 검사하지 못함 · 만료 · 회수 · 횟수 초과 · 없는 링크 · 요청이 너무 많음, 좁은 폭(휴대폰). 글 · 모양은 디자인 README "11단계". +- [내려받기]는 `` 이동이다(큰 파일을 `fetch` 로 받으면 메모리에 다 오른다) — 누르기 직전에 링크 정보를 한 번 더 묻고, 받을 수 없게 됐으면 화면에서 알린다(이동 뒤 JSON 오류가 뜨지 않게). + 끊기면 브라우저의 다시 시도로 이어 받는다. 검사 중이면 화면이 잠시 뒤 다시 묻는다(간격은 구현하며 정함 — 위 요청 수 안에서). + +**공개 호스트** (`SHARE_PUBLIC_URL`) +- 운영은 `https://files.arasystem.it.kr` — 포털과 다른 호스트라 털린 계정이 퍼뜨린 악성 파일로 포털까지 차단 목록에 오르는 위험을 어느 정도 떼어 놓고, 포털 쿠키도 가지 않는다 + (외부 리버스 프록시가 포털과 같은 사설 주소:8080 으로 넘긴다). +- 공개 호스트에서는 **화면 `/s/{token}`(과 그 자산) · `/api/public-links/…` 만** 연다 — 그 밖은 404. 포털 호스트에서는 `/s/…` · `/api/public-links/…` 를 열지 않는다(404). 둘이 같은 주소면(개발) 둘 다 연다. + 앞단 nginx(호스트마다 server)와 백엔드(요청의 `Host` — nginx 가 원래 값을 넘긴다, `frontend/nginx.conf` 의 `proxy_set_header Host $host`)가 함께 막는다(구현하며 정함). + 구현(2026-10-30 — 백엔드): 요청 추적 ID 바로 뒤의 미들웨어(`modules/file-sharing/public-host.ts`)가 `Host` 의 호스트 이름(포트 없이 · 소문자 · 끝의 점 없이)을 `SHARE_PUBLIC_URL` 의 호스트 이름과 견준다. + 공개 호스트이면 `/api/public-links/` 만, 아니면 그 경로만 404 `RES_001`(표준 실패 형식 · 로그 없음 — 경로의 대소문자는 가리지 않는다). + 경로는 스택이 읽을 수 있는 모든 모양 — 원문(`?` 앞) · 라우터가 쓰는 경로(Express 의 `req.path`: 절대 주소 꼴은 스킴 · 호스트를 떼고, `#` 이 섞이면 `\` 를 `/` 로 읽는다) · + WHATWG URL(`\` → `/`, `%2e` 조각) · 퍼센트 인코딩을 한 번 푼 원문(nginx 처럼 `%2F` 도) — 을 정규화(겹친 `/` · `.` · `..` 조각 · 대소문자)해 본다. + 공개 호스트는 모든 모양이 공개 경로일 때만 열고(그래서 절대 주소 꼴은 받지 않는다), 포털은 어느 모양이라도 공개 경로면 닫는다. 읽지 못한 모양이 있으면 어느 쪽도 열지 않는다 + (nginx 의 정규화에 기대지 않는다 — 2026-10-30, 처음 판은 포털 Host 의 절대 주소 꼴 · `\…#` 요청이 공개 컨트롤러에 닿았다). 공개 호스트 이름이 포털의 출처(`CORS_ORIGIN`, 비운영은 개발 기본 주소 포함)의 + 호스트 이름과 같으면(개발) 가르지 않는다 — 포트만 다른 두 주소는 nginx 가 포트 없는 `Host` 를 넘겨 가를 수 없기 때문이다. +- 공개 주소는 `CORS_ORIGIN` 에 넣지 않는다 — 공개 화면은 같은 호스트의 공개 API 만 부른다(`connect-src 'self'` 그대로). 포털 화면이 링크의 상태를 볼 때는 포털의 `POST /api/share-link-lookups` 를 쓴다. +- 덩이 · `url` 은 늘 지금의 `SHARE_PUBLIC_URL` 로 만든다. 바꾸면 그 전 메일의 덩이를 알아보지 않고, 옛 주소는 옛 호스트가 남아 있어야 열린다 — 바꾸지 않는다. + +### 기간 · 보관 · 정리 + +- **링크가 끝나는 때** = 보낸 때(예약은 `sendAt`)의 **한국 날짜에 30일을 더한 날의 23:59:59.999(한국 시간 — UTC+9, 일광 절약 없음)**. + 예: 2026-10-30 에 보내면 `expiresAt` 은 `2026-11-29T14:59:59.999Z`(11월 29일(일) 끝까지). 받는 사람 모두 같은 날짜이고, 덩이 · 화면은 그 한국 날짜 하나로 적는다. + 요청마다 `지금 ≤ expiresAt` 으로 본다 — 정리 작업이 늦어도 정확하다. + +| 대상 | 남는 기간 | 그 뒤 | +| --- | --- | --- | +| 올리는 중 | 마지막 조각(없으면 만든 때) 뒤 24시간 — 임시 저장에 실려도 같다 | 지운다 — `GET` 404, 임시 저장본에서는 `MissingLargeFile`(`expired`) | +| 다 올렸지만 어디에도 실리지 않은 파일 | 다 올린 뒤 24시간 | 지운다 | +| 임시 저장에 한 번이라도 실린 파일 | 다 올린 날(한국)부터 위와 같은 셈으로 30일째 날 끝 | 지운다 — 임시 저장본에서는 `MissingLargeFile`(`expired` — "기간이 지나 다시 올려야 하는 파일") | +| 사용자가 지운 보내지 않은 파일 | 곧(보관량에서는 그때 바로 빠진다) | 임시 저장본에서는 `MissingLargeFile`(`deleted` — "지운 파일") | +| 보낸 파일 | 그 파일을 실은 살아 있는 링크가 끝나는 때 가운데 가장 늦은 것 (임시 저장 기간보다 늦으면) | 지운다 | +| 끊은 링크의 파일 · 위험 판정 파일 | 다른 쓰임이 없으면 곧 | 지운다 — 보관량에서는 그때 바로 빠진다 | +| 링크 기록 | 끝나거나 끊긴 뒤 90일 — 그동안 공개 화면은 "기간 지남" · "공유 멈춤"이고 목록에 남는다 | 지운다 → "없는 링크"(404), 목록에서 사라진다 | +| 취소한 링크 (보내기 취소 · 보내지 못함) | 되돌린 초안이 쓰는 동안 — 90일 | 지운다 | +| 파일 기록 (이름 · 크기) | 그 파일을 가리키는 링크 기록이 다 지워질 때까지. 보낸 적 없는 파일은 지운 뒤 30일 | 지운다 — 그 뒤 임시 저장본은 그 id 를 버린다 | + +- 임시 저장본을 영구 삭제해도 파일은 위 기간까지 남는다 — 초안은 우리 API 밖(IMAP · 자동 비우기)에서도 지워져 서버가 다 알 수 없다(알려진 한계 — 보관량에 잡히고, 보내지 않은 파일 목록에서 지울 수 있다). +- 받은 기록은 **받은 수 · 마지막으로 받은 때만** 둔다 — 누가 · IP · 브라우저는 남기지 않는다. +- 정리 작업(cron)이 바이트를 지운다 — 기록을 먼저 바꾸고 실제로 바꾼 것만 지운다(서명 그림 정리와 같은 결). 기록에 없는 파일은 하루 지난 것만 지운다(구현하며 정함). + 구현(2026-10-30): 파일 정리는 **한 시간마다**(`file-storage.cleanup.ts`) — 기간이 지난 파일을 지움(기간 지남)으로 바꾸고 바이트를 지운다, 지웠거나 위험 판정인데 바이트가 남은 것을 지운다, + 끝내기를 시작한 지 10분 넘게 멈춘 올리기를 되살린다(시작한 때 `completion_started_at` 로 고른다 — 다 옮겼으면 다 올림, 아니면 올리는 중), 지운 지 30일 지난 파일 기록을 지운다, 기록에 없는 저장소 파일 가운데 하루 지난 것을 지운다. + 링크 정리는 **하루 한 번 05:00(서버 시각)**(`file-sharing.cleanup.ts`) — 끝나거나 끊기거나 취소한 지 90일 지난 링크 기록을 지운다. 링크 기록은 파일 이름 · 크기를 따로 두므로 파일 기록과 따로 지운다. +- **백업**: 파일 바이트는 백업하지 않는다(30일짜리 전송물) — 기록은 DB 백업에 든다. 복원한 뒤 바이트가 없는 파일은 `missing` 이다. + +### 악성 검사 (ClamAV) + +- **언제**: 끝내기 직후 검사 차례에 넣는다 — 서버 전체에서 한 번에 1~2개, 먼저 끝낸 것부터(구현하며 정함). 2GB 는 분 단위가 걸릴 수 있다. + 구현(2026-10-30): 한 번에 **2개**, 먼저 끝낸 것부터. 차례는 DB(`scan_status = 'pending'` · `scan_next_at`)라 서버를 다시 띄워도 잇는다 — 다 올린 직후 · 30초마다 · 검사 하나가 끝날 때 깨운다. + clamd 연결은 10초, 흘려보내는 동안 무소식 10분, 다 보낸 뒤 판정까지 30분 + 보낸 1MB 마다 1초(2,047MB 면 3,847초 ≈ 64분 — `file-storage.constants.ts` 의 `clamavVerdictTimeoutMs`)를 넘으면 끊고 미뤄서 다시 한다. + 30분은 크기와 상관없는 몫이다 — 7z · tar.gz 는 MaxScanTime 없이 MaxScanSize(4,094MB)까지 풀리고, 실측 약 7MB/s 로 10분 · 운영 CPU 가 2~3배 느리면 20~30분이다. 1MB 마다 1초는 파일 자체의 몫(실측의 약 7배)이다. + 판정이 끝내 오지 않아 같은 파일을 끝없이 다시 검사하는 것보다 한 번 오래 기다리는 편을 골랐다(2026-10-30). + 연결에는 TCP keepalive 를 켠다(60초 뒤부터 1초 간격 10번 — Node 24 · libuv 1.52): 컨테이너째 사라져 RST 도 오지 않는 clamd 를 약 70초 안에 `read ETIMEDOUT` 으로 끊고 다른 끊김처럼 미뤄서 다시 한다. + 살아 있는 채 멈춘 clamd 는 keepalive 로 알 수 없어 판정 대기까지 검사 자리를 잡는다. +- **검사 중에도 보낸다** — 받는 쪽은 `scanning`(받기 409 `FILE_008`)이고, 끝나면 받는다. +- **판정** — 백엔드가 clamd 에 `INSTREAM` 으로 흘려보낸다(규약을 직접 구현 — 새 의존성 없음): + - `OK` → `clean`(받을 수 있음). + - `FOUND` 가운데 암호 · 검사 한도 표시(`Heuristics.Encrypted.*` · `Heuristics.Limits.Exceeded.*`) → `unscannable`(받을 수 있음 · "검사하지 못함"). + ClamAV 1.4.6 실측(2026-10-30): 암호 zip(ZipCrypto · AES) `Heuristics.Encrypted.Zip` · 암호 7z `.7Zip` · 사용자 암호 pdf `.PDF`(소유자 암호만 있는 pdf 는 `OK`), + 20겹 zip `Heuristics.Limits.Exceeded.MaxRecursion` · 파일 12,000개 zip `.MaxFiles` · 풀면 수 GB 인 zip `.MaxScanTime` · 한 파일 한도를 넘는 파일 `.MaxFileSize`. + 같은 압축 안에 진짜 서명과 휴리스틱(암호 · 한도)이 함께 있으면 진짜 서명이 나온다(`infected`). + - 그 밖의 `FOUND` → `infected`(EICAR 는 `Eicar-Test-Signature FOUND` — 압축 안에 있어도 같다). + - 스트림 크기 한도 오류(`INSTREAM size limit exceeded. ERROR` — clamd 의 `StreamMaxLength` 가 파일보다 작다) → **`pending` 그대로 + error 로그**(설정 오류 — 아래 "닿지 못함"과 같이 다시 시도). + 처음 초안의 `unscannable` + warn 은 2026-10-30 리뷰로 바꿨다 — 운영에서 `StreamMaxLength` 를 올리는 것을 잊으면 20MB 를 넘는 모든 파일이 검사 없이 열리고 흔적은 warn 한 줄뿐이었다. + clamd 는 이 글을 보낸 뒤 연결을 닫고(FIN), 백엔드가 계속 보내면 RST 다 — 백엔드가 글을 읽기 전에 RST 가 오면 판정 글 없이 `EPIPE` 로 끝나 아래 "끊음"으로 다룬다(세 번째부터 error). + 운영 설정은 `StreamMaxLength` = 한 파일 한도의 최대값(2047M)이라 정상에서는 일어나지 않는다. + - clamd 가 판정 글을 보내기 전에 연결을 끊음(RST · 빈 응답) → 다시 시도(`pending`). +- **검사기에 닿지 못함** · 시간 초과 · 그 밖의 오류 → `pending` 그대로 두고 간격을 늘려 다시 한다(1시간마다까지 — 구현하며 정함). 이어서 실패하면 error 로그(운영 알림). + 구현(2026-10-30): 1분부터 두 배씩 1시간까지, 세 번째 실패부터 error(그 전은 warn). 판정 이름은 `Heuristics.Encrypted…` · `Heuristics.Limits.Exceeded…` 로 시작하면 `unscannable` 이다 — + 판정 글 견주기는 `modules/file-storage/clamav-client.ts` 의 `CLAMAV_REPLY` · `classifyClamavReply` 한 곳이고, `\0` 까지 받은 글만 판정으로 본다(끝나지 않은 글은 끊김 — 다시 시도). + 그 로그에는 단계와 보낸 바이트 수가 남는다 — 시도마다 같은 "흘려보내는 중(보낸 N바이트)"이면 크기 한도를 의심한다. 판정 글이 없으면 크기 한도와 clamd 재시작을 가를 수 없어 설정 오류로 단정하지 않는다(2026-10-30 확인). + 2026-10-30 release-ops 가 실제 clamd(ClamAV 1.4.6 LTS · 운영 설정)로 위 이름들을 재어 이 견주기와 맞음을 확인했다 — 판 · 설정을 바꾸면 다시 잰다(실측표는 mail/README.md "악성 검사 (ClamAV)"). + **판정 없이 받을 수 있게 되는 일은 없다**(닫힌 쪽). +- **위험**: 받기를 막고(410 `FILE_007`) · 보내기를 막고(`MAIL_016`) · 보낸 사람에게 알린다 — 목록 상태 + 실시간 알림 `large-file` 이벤트(아래). 바이트는 곧 지우고(보관량에서 바로 빠진다) 기록(이름 · 판정 이름)은 남긴다. +- **검사하지 못함**: 막지 않는다 — 공개 화면 · 목록 · 읽기 화면에 "검사하지 못함" 글자. +- 검사는 한 번이다 — 나중에 나온 서명으로 다시 보지 않는다(알려진 한계). +- 검사기를 쓰지 않는 개발(`CLAMAV_HOST` 비움)은 끝내자마자 `unscannable` 이다. 운영은 `CLAMAV_HOST` 가 없으면 기동하지 않는다. +- **실시간 알림**(2절 `GET /api/mail-events` 에 더한다) — 위험 판정 때 그 직원의 연결에 보낸다: + + ```text + event: large-file + data: {"fileId":"…","name":"견적서.zip","scanStatus":"infected"} + ``` + + 클라이언트는 알림("올린 대용량 파일 ‘견적서.zip’에서 위험한 내용이 발견되어 막았습니다." + [대용량 첨부 보기])을 띄우고, 쓰기 화면에 그 파일이 있으면 "위험 — 보낼 수 없음"으로 바꾼다. + 연결이 없던 직원은 목록 상태로만 안다(정한 것 — 2026-10-30, 다음에 들어올 때 따로 알리지 않는다). +- 운영(release-ops): clamd 의 `StreamMaxLength` · `MaxFileSize` · `MaxScanSize` 를 한 파일 한도 이상으로, `AlertEncrypted` · `AlertExceedsMax` 를 켠다. 메모리 3~4GiB(운영 메일 서버 8GB), 서명 갱신(freshclam)의 바깥 연결, + **backend 와만 잇는 새 내부 네트워크**(`app-network` 가 아니다 — mail setup 의 구성원 검사에 걸리고 메일 엔진의 신뢰 대역에 든다). + 구현(2026-10-30): 컨테이너 둘 — `clamav`(clamd · `_clamav` internal 네트워크에만 · 서명 볼륨 읽기 전용)과 `clamav-updater`(freshclam · 자기 바깥 길 `clamav-egress` 에만). + `StreamMaxLength` = `MaxFileSize` = 2047M · `MaxScanSize` 4094M · `MaxScanTime` 5분(1.4 에서는 ZIP 에만 걸린다). clamd 메모리 1GB 안팎(한도 2g — 서명을 다시 읽을 때 두 벌을 들지 않게 `ConcurrentDatabaseReload no`, 그동안 30초 안팎 검사가 기다린다). + 2,047MB 무작위 파일도 끝까지 검사한다(`OK` — 다 보낸 뒤 296초, 코어 하나 · 개발 PC). 설정 `mail/clamav/`, 운영 절차 · 메모리 예산 mail/README.md "대용량 첨부 (11단계 — 인프라)". + +### 환경 변수 (루트 env — 넣는 일은 release-ops, 검증 · 테스트는 backend `env.validation.ts`) + +| 키 | 기본값 | 범위 · 검사 | 뜻 | +| --- | --- | --- | --- | +| `SHARE_PUBLIC_URL` | 비운영에서 비우면 `CORS_ORIGIN` 의 첫 주소 | 출처만(스킴 · 호스트 · 포트 — 경로 · 끝 `/` 없음). **운영 필수** · `https` · 로컬 주소 · 예시 도메인 금지(`ALLOW_LOCALHOST_URL` 예외 — `CORS_ORIGIN` · `BACKEND_API_URL` 과 같은 검사). 모든 환경: 앞단 nginx(`frontend/nginx-env.envsh` 의 `nginx_env_origin_host`)가 받는 모양만 — 스킴 소문자, 호스트는 영문 · 숫자 · `.` · `-`(점 · 하이픈으로 시작하거나 끝나지 않고 점이 이어지지 않게 — 끝 점 없이), 포트는 0 으로 시작하지 않는 숫자, 사용자 정보 · 대괄호(IPv6) · 앞뒤 공백 없이(다르면 백엔드만 뜨는 반쪽 배포) | 링크 · 덩이 · 덩이 알아보기 · 공개 호스트 막기. 운영 `https://files.arasystem.it.kr` | +| `SHARE_REPORT_ADDRESS` | `postmaster@` | 메일 주소 | 공개 화면의 신고 · 문의 | +| `SHARE_DOWNLOAD_LIMIT` | 500 | 1 ~ 100,000 | 파일당 내려받기 상한(받기 시작한 수) | +| `MAIL_LARGE_FILE_MAX_SIZE_MB` | 2047 | 1 ~ 2047, `MAIL_ATTACHMENT_MAX_SIZE_MB` 이상 | 한 파일 | +| `MAIL_LARGE_FILE_MAX_COUNT` | 10 | 1 ~ 20 | 메일 한 통 | +| `MAIL_LARGE_FILE_USER_QUOTA_GB` | 10 | 1 ~ 1,000 | 직원당 보관량 | +| `MAIL_LARGE_FILE_RETENTION_DAYS` | 30 | 1 ~ 90 | 보낸 뒤 · 임시 저장의 보관 날 수 | +| `FILE_STORAGE_MAX_TOTAL_GB` | 비움 = 한도 없음(여유만 본다) | 1 이상 | 서버 전체 보관량 | +| `FILE_STORAGE_MIN_FREE_GB` | 10 | 1 이상 | 만든 뒤에도 남길 디스크 여유 | +| `CLAMAV_HOST` | 비움 = 검사하지 않음(개발) | **운영 필수** | clamd 주소 | +| `CLAMAV_PORT` | 3310 | 1 ~ 65,535 | clamd 포트 | +| `FILE_STORAGE_DIR` | `/app/files` | compose 가 고정해 넣는다(루트 env 아님) | 저장 위치 — named volume `_files` | + +- 숫자는 앞자리 0 없는 10진수다(`isPositiveIntString` — 지금 규칙). compose 가 넣는 빈 문자열은 기본값이다(`parsePositiveInt`). nginx 는 이 값들을 읽지 않는다(조각 17MB 는 계약 상수). +- 구현 때 더한 검사(2026-10-30 — `env.validation.ts`): `MAIL_LARGE_FILE_USER_QUOTA_GB` × 1,024 는 `MAIL_LARGE_FILE_MAX_SIZE_MB` 이상이어야 한다(한도까지 큰 파일 하나는 올릴 수 있게). + `MAIL_LARGE_FILE_MAX_SIZE_MB` 와 `MAIL_ATTACHMENT_MAX_SIZE_MB` 는 앞의 값을 정했을 때만 견준다. `CLAMAV_HOST` 는 호스트 이름만(스킴 · 포트 없이). `SHARE_REPORT_ADDRESS` 는 메일 주소. + 운영에서 `SHARE_PUBLIC_URL` 의 호스트 이름이 `CORS_ORIGIN` 의 어느 호스트 이름과 같으면(포트만 다르거나 대소문자 · 끝의 점만 달라도) 기동하지 않는다 — 공개 호스트 막기가 소리 없이 꺼지므로(2026-10-30 리뷰). + 공개 주소는 `CORS_ORIGIN` 에 넣지 않는다. 로컬에서 운영 이미지를 검증할 때도 이름을 나눈다(예: `localhost` 와 `127.0.0.1`). + `SHARE_PUBLIC_URL` 의 모양은 모든 환경에서 앞단 nginx 와 같은 규칙으로 본다(위 표 — 2026-10-30, 대문자 스킴 · 끝 점 · 앞뒤 공백도 거절). + `FILE_STORAGE_DIR` 를 compose 가 넣지 않으면 작업 폴더의 `files`(컨테이너에서는 `/app/files`, 호스트 개발에서는 `backend/files` — gitignore)다. + +### 로그 + +- **토큰 · 링크 주소는 남기지 않는다**: 백엔드는 요청 주소의 `/api/public-links/<토큰>` 을 길이만 남긴다(`maskUrlForLog` 에 더한다 — 지금은 `/api/mail-images/<표>` 만, `mask.ts:26`). 경로의 대소문자는 가리지 않는다(라우팅이 가리지 않는다). + 구현(2026-10-30): 경로 어디서든(`//api/…` · `/api//…` · `/api/./…` 도) `public-links` 조각 뒤의 조각을 길이만 남긴다 — `files` 와 파일 id(UUID)는 남긴다. + `POST /api/share-link-lookups` 의 본문(주소)도 남기지 않는다 — 개수만. + 앞단 nginx 접근 로그도 요청 줄 · Referer 의 토큰을 가린다(쿼리는 원래 빼고 — `frontend/nginx.conf` 의 `log_format main_rid` 앞 map 들. 2026-10-30 리뷰로 경로 모양이 아니라 토큰 모양(43자 base64url)으로도 가린다 — `//s/` · `%73` 같은 변형. 요청 줄에서는 둘까지 가리고, 43자가 아닌 값(UUID · 메일 id)은 그대로다. 토큰 안을 퍼센트 인코딩으로 쪼갠 꼴은 가리지 못한다 — 한계). + nginx 오류 로그(502 · 504 등)는 지금 정책대로 요청 줄을 그대로 남긴다 — 공개 내려받기의 그 줄에는 토큰이 남는다(운영 담당만 읽는다). 외부 리버스 프록시도 같게(운영 확인). +- 파일 이름은 남기지 않는다(직원의 글이다) — 파일 · 링크 id 와 크기만. 직원 주소는 가린다(`maskEmail`). +- 받는 쪽 IP 는 남기지 않는다 — 요청 수 한도의 셈(메모리)에만 쓴다. 앞단 nginx 접근 로그의 IP 는 지금 운영 로그 정책 그대로 — 바깥 사람의 개인 정보일 수 있어 보관 기간을 운영에서 정한다. +- 레벨: 링크 만들기 · 끊기 · 다 올림 · 보내지 않은 파일 지우기는 log, 위험 판정은 warn(판정 이름 · 파일 id), 조각 · 내려받기 성공은 남기지 않는다(반복되는 정상 흐름), 공개 경로의 404 · 409 · 410 은 debug, + 검사기 장애가 이어지거나 디스크가 모자라면 error. + +### 에러 코드 (11단계에 더함 — 제안, 문구는 레지스트리에서 정한다) + +| 코드 | HTTP | 뜻 | 쓰는 곳 | 문구 (제안) | +| --- | --- | --- | --- | --- | +| `FILE_003` | 409 | 올리기 · 파일의 지금 상태와 맞지 않는 요청 — 끝낸 올리기에 조각, 같은 조각을 동시에, 받은 조각과 내용이 다른 조각, 빠진 조각이 있는데 끝내기, 끝내기가 진행 중, 살아 있는 링크에 실린 파일 지우기. 아무것도 바꾸지 않았다 | 조각 · 끝내기 · 지우기 | "파일 올리기 상태가 맞지 않습니다. 다시 시도해 주세요." | +| `FILE_004` | 400 | 조각의 `Content-Digest` 가 받은 바이트와 다르다 — 그 조각을 받지 않았다(같은 조각을 다시) | 조각 | "파일 조각이 보내는 중에 손상되었습니다. 다시 시도해 주세요." | +| `FILE_005` | 409 | 직원 보관 한도(보관량 · 보내지 않은 파일 수)를 넘는다 — 만들지 않았다 | 만들기 | "대용량 첨부 보관 한도를 넘어 더 올릴 수 없습니다. 보낸 파일의 링크를 끊거나 보내지 않은 파일을 지우면 공간이 생깁니다." | +| `FILE_006` | 507 | 서버 저장 공간이 모자란다 — 서버 총량 · 디스크 여유 · 쓰다 `ENOSPC` | 만들기 · 조각 | "서버의 저장 공간이 모자라 지금은 파일을 올릴 수 없습니다. 잠시 뒤 다시 시도해 주세요." | +| `FILE_007` | 410 | 더 내려받을 수 없는 파일 — 기간 지남 · 공유 멈춤 · 횟수 초과 · 위험 · 찾을 수 없음 | 공개 받기 · 우리 웹메일 받기 | "이 파일은 더 내려받을 수 없습니다. 필요하면 보낸 사람에게 문의해 주세요." | +| `FILE_008` | 409 | 아직 보안 검사 중인 파일 — 끝나면 받는다 | 공개 받기 · 우리 웹메일 받기 | "보안 검사가 끝나지 않은 파일입니다. 잠시 뒤 다시 시도해 주세요." | +| `MAIL_016` | 400 | 보내거나 저장할 대용량 파일을 쓸 수 없다 — 없음 · 다른 직원 것 · 지움 · 기간 지남, (보낼 때) 올리는 중 · 위험. 메일은 나가지 않았다 | 발송 · 임시 저장 | "대용량 첨부 파일을 보낼 수 없습니다. 해당 파일을 빼거나 다시 올린 뒤 보내 주세요." | + +- 문구에 숫자를 넣지 않는다 — 한도가 env 로 바뀐다(`MAIL_006` · `CONTACT_002` 처럼 상수와 견주는 테스트를 둘 수 없다). +- 그 밖은 지금 코드를 쓴다 — 형식 400 `FILE_001`, 한 파일 · 조각 본문 413 `FILE_002`, 개수 · 모양 400 `VAL_001`, 없음 404 `RES_001`, 요청 수 · 동시 수 429 `REQ_001`, 엔진 503 `MAIL_001`. +- 410 · 507 은 상태 역매핑(`error-codes.ts` 의 `STATUS_TO_ERROR_CODE`)에 넣지 않는다 — 이 코드들은 `BusinessException` 으로만 나온다. 앞단 nginx 는 백엔드의 4xx · 5xx 를 손대지 않는다(`proxy_intercept_errors` 꺼짐). +- 세 클라이언트의 목록 테스트에는 클라이언트가 실제로 분기하는 코드만 더한다(웹: `FILE_003` · `FILE_004` · `FILE_005` · `FILE_006` · `MAIL_016` — 2026-10-30. `FILE_008` 은 웹이 분기하지 않는다 — 받기는 `` 이동이고 검사 중이면 화면이 먼저 막는다). + +### 운영 확인 (이 계약이 기대하는 것 — 고치는 일은 release-ops) + +- 앞단 nginx: `/api/file-uploads` 본문 17MB · 읽기 90초. `/api/public-links/` · `/api/messages/*/large-files/` 는 응답 버퍼링을 끄고 `X-Accel-Buffering` 을 외부로 넘긴다 + (기본값이면 느린 받는 사람마다 임시 파일을 1GB 까지 만든다 — 실시간 알림과 같은 방법). `/s/` 는 `index.html` + `Cache-Control: no-cache` · `X-Robots-Tag: noindex, nofollow` · `Referrer-Policy: no-referrer`. + 접근 로그의 경로 토큰 가리기, 공개 호스트의 server(위 "공개 호스트"). + **내려받기 동시 연결 상한**(2026-10-30 리뷰 — 버퍼링을 끈 느린 받기는 연결 하나가 nginx 연결 둘과 백엔드 fd 를 잡아, 토큰 하나로 프론트 nginx 를 바닥낼 수 있다): `limit_conn` 으로 + 공개 받기 IP 마다 10 · 앱 안 받기 IP 마다 30(사무실 NAT) · 서버 전체 공개 받기 400 · 앱 안 받기 200, 넘으면 429 `REQ_001`(nginx 오류 JSON). `worker_connections` 는 이 수와 SSE · 포털을 함께 버티게. + 전체 상한을 경로마다 따로 둔다(2026-10-30 리뷰 — 처음 판은 두 경로가 500 을 함께 써, 바깥 IP 50개(IPv6 면 /64 하나)로 직원의 앱 안 받기가 모두 429 가 됐다). + 경로의 대소문자 변형(`/FILES/` · `/api/Messages/…`)도 같은 상한 · 같은 흘려보내기를 탄다(2026-10-30 리뷰 — 처음 판은 글자 하나로 상한 · `X-Accel-Buffering` 넘김을 비켜 갔다). + 구현(2026-10-30): 클라이언트 주소는 `X-Forwarded-For` 의 마지막 칸(외부 프록시가 이어 붙인 값 — 없으면 접속 주소). 워커마다 `worker_connections` 4096 · 열린 파일 8192. + 받기 location 은 `/api/` 블록 안의 `~*` 정규식(id · 토큰 칸 `.+`) — 600개를 붙잡아 401번째 공개 · 201번째 앱 안이 429 이고 그때도 다른 쪽 · SSE 는 200 임을 확인했다. + 받기 머리글 버퍼 16k(한글 255자 이름이면 3.5KB). 공개 server 의 그 밖 `/api` · 포털이 닫은 공개 API(대소문자 변형도)는 nginx 의 404 `RES_001` JSON, 1m 넘는 본문은 413 `FILE_002` JSON. + 조각 경로는 오류 로그를 `error` 부터 남긴다(본문을 임시 파일에 받는다는 warn 이 조각마다 남았다). + 백엔드는 종료할 때 진행 중인 파일 응답을 먼저 끊는다(`BeforeApplicationShutdown` — 실시간 알림과 같은 결, 브라우저는 Range 로 잇는다). + 백엔드의 공개 호스트 판정은 nginx 가 넘기는 `Host`(`$host` — 소문자 · 포트 · 끝 점을 뗀 값)와 origin-form 요청 줄에 기댄다 — files 호스트의 server 도 `proxy_set_header Host $host` 를 둔다. +- 외부 리버스 프록시: `files.` 호스트(같은 사설 주소:8080 · TLS), 조각 경로 본문 17MB 이상 · 읽기 150초, 내려받기 응답의 버퍼링 · 임시 파일 끔(또는 `X-Accel-Buffering` 따르기), 공개 경로는 **경로까지** 접근 로그에 남기지 않기, 받기 응답 머리글 버퍼 16k(NPM 은 `proxy_buffer_size` · `proxy_busy_buffers_size` 두 줄). +- 볼륨 `_files` — 크기 · 디스크(DB · 엔진과 다른 디스크 검토) · 감시, 백업에서 빼기, `down -v` 가 지운다는 경고. Dockerfile 이 `/app/files` 를 node 소유로 만든다. +- ClamAV 컨테이너(위 "악성 검사" — clamd 는 띄울 때 `/tmp/clamav-*` 를 지운다: 검사 중에 죽으면 임시 파일이 쓰기 계층에 쌓였다). 사내 도메인을 꾸민 바깥 메일(From)은 DMARC 로 막는다(읽기 화면의 "보낸 사람 = 링크 주인" 판정이 기댄다 — 아래 "알려진 한계"). +- 확인(2026-10-30 — 운영 nginx 설정): 버퍼링을 끈 내려받기에서 `proxy_read_timeout` 은 돌지 않는다. 받는 쪽이 느려도 조금씩 받으면 이어지고(초당 120KB 로 80초), + 60초 동안 하나도 받지 않으면 `send_timeout`(기본 60초)이 끊고 동시 연결 수를 돌려준다. +- 남은 확인(시험): 브라우저(Chrome · Edge · Firefox · Safari)가 우리 머리글로 끊긴 내려받기를 이어 받는지. + +### 알려진 한계 + +- 같은 사본이라 받는 사람별 링크 · 기록 · 끊기가 없다 — 받은 수는 "링크를 가진 누군가"의 수다. +- 횟수는 0번째 바이트부터 받기 시작한 수다 — 범위 요청만으로 받으면 세지 않는다(열린 질문 1). + 거꾸로 토큰을 가진 사람이 `Range: bytes=0-0` 같은 작은 요청으로 상한을 빨리 써 버리면 같은 링크의 다른 받는 사람이 410 이 된다(IP 하나로 몇 분) — 닫힌 쪽이라 새는 것은 없고, 보낸 사람이 다시 보내면 된다(2026-10-30 리뷰 — 보낸 바이트 기준 상한은 처음 판에 넣지 않는다). +- 끊어도 이미 시작한 내려받기는 끝까지 간다. 받아 간 파일은 되돌릴 수 없다. +- 인용 속 원래 링크는 글이다 — 우리 웹메일도 첨부처럼 보이지 않고, 원래 링크의 받은 수 · 상한 · 기간을 따른다(전달받은 사람이 받아도 원래 보낸 사람의 받은 수로 센다). +- 읽기 화면의 "보낸 사람 = 링크 주인" 판정은 머리글 From 을 믿는다 — 사내 도메인을 꾸민 메일이 그 직원의 진짜 토큰을 담으면 첨부처럼 보인다(파일은 진짜다. DMARC 가 막는다 — 운영 확인). +- 다른 메일 프로그램이 우리 임시 저장본을 그대로 보내면 머리글만 나가고 덩이는 없다. 우리 API 밖에서 지운 임시 저장본의 파일은 기간까지 보관량을 잡는다(보내지 않은 파일 목록에서 지울 수 있다). +- 텍스트만 있는 메일 · HTML 이 정화 한도를 넘은 메일의 덩이는 알아보지 않는다. +- `SHARE_PUBLIC_URL` 을 바꾸면 그 전 메일의 덩이를 알아보지 않는다. +- 경로 토큰이 nginx 오류 로그 · 외부 프록시 로그에 남을 수 있다. +- 검사는 한 번이다 — 나중에 나온 서명으로 다시 보지 않는다. +- ClamAV 1.4.6 은 zip **안** 항목이 크기 한도(`MaxFileSize` · `MaxScanSize`)를 넘을 때 `AlertExceedsMax` 를 켜도 `OK` 로 줄 때가 있다(한도를 작게 둔 시험 clamd 에서 확인 — + 운영 값에서는 `MaxScanTime` 이 먼저 걸렸다). 7z · tar.gz 처럼 zip 이 아닌 압축은 시간 상한 없이 `MaxScanSize` 까지 풀어 검사가 오래 걸릴 수 있다. +- 내려받기 동시 연결 상한은 `X-Forwarded-For` 의 마지막 칸으로 센다 — 외부 프록시를 건너뛰어 8080 에 바로 닿는 길이 있으면 주소마다 상한을 피한다(경로마다 서버 전체 상한은 그대로). + IPv6 는 주소 그대로 센다(/64 로 묶지 않는다). +- 한 인스턴스 전제 — 올리기의 동시 수 · 만들기 차례 · 같은 조각 잠금은 서버 메모리, 파일은 로컬 볼륨이다. 인스턴스를 늘리면 공유 저장(S3 호환 — 같은 저장 포트의 새 어댑터)과 잠금으로 옮긴다 + (nestjs 스킬의 "확장 때 옮길 것"에 더한다). + +### 이 단계에서 하지 않는 것 + +기간 고르기 · 연장, 다시 보내기(다시 올리지 않고 새 링크), 받는 사람 확인 코드 · 비밀번호, 사내만(로그인해야 받기), 받는 사람별 링크 · 기록, 처음 받음 · 만료 전 알림, 미리 보기, 모두 받기(zip), 폴더 올리기, +파일 하나만 끊기(끊기는 링크 단위), 설정 목록의 [링크 복사](토큰을 되살릴 수 없다 — 보낸 메일의 덩이에서), 대용량 첨부만 있는 메일의 목록 클립, 링크 → 일반 첨부로 되돌리기, +전달 원본 · 임시 저장본의 엔진 첨부를 링크로 바꾸기, 인용 속 원래 링크를 다시 싣기, +관리자 화면 · 퇴사자 처리(신고를 받은 링크는 그때까지 운영 스크립트로 끊는다 — 구현하며 정함), 보낸 메일을 지울 때 파일도 지우기, 모바일 앱, 파일 저장소 연동, +다른 PC · 다른 브라우저에서 이어 올리기(같은 브라우저에서 같은 파일을 다시 고르면 이어진다). + +### 정한 것 (열린 질문이었던 것 — 2026-10-30 추천대로) + +1. **횟수 상한의 범위 요청 우회** — 0번째 바이트를 받지 않는 범위 요청은 세지 않아, 도구를 쓰면 상한을 피할 수 있다. 추천: 처음 판은 그대로(브라우저 · 내려받기 관리자는 0번부터 받는다)이고 한계로 적는다. + 남용이 보이면 "보낸 바이트 ÷ 파일 크기" 상한을 함께 둔다. +2. **위험 판정 알림** — 추천: 실시간 이벤트 + 목록 상태(처음 판). 연결이 없던 직원까지 알리는 받은편지함 시스템 메일은 털린 계정을 알아채는 데 좋지만 엔진에 메일을 넣는 길이 새로 든다 — 13단계(관리자 절차)와 함께 다시 본다. +3. **검사기 장애가 길 때** — 추천: 받기를 막은 채(검사 중) 두고 error 로그로 운영이 고친다(닫힌 쪽). 대안 — 몇 시간 뒤 "검사하지 못함"으로 연다(받는 쪽이 오래 기다리지 않지만 검사 없이 퍼진다). +4. **ClamAV 크기 한도** — 기본 2,047MB 가 운영에 쓸 clamd 판에서 끝까지 검사되는지 실측한다(자료는 "2GB 넘는 파일은 검사하지 못함" — 판마다 다를 수 있다). 안 되면 기본값을 낮춘다. + 실측(2026-10-30, ClamAV 1.4.6): 2,047MB 는 끝까지 검사된다(`OK`) — 기본값 그대로 둔다. 1.4.6 은 2GiB − 1 바이트 넘게는 검사하지 못한다. +5. **위험 파일의 바이트** — 추천: 곧 지운다(디스크에 악성 파일을 두지 않고 보관량에서 바로 뺀다). 오탐을 다시 보려면 서명 갱신 뒤 다시 올린다. +6. **일반 첨부 목록 밖 · 대용량 목록 안 형식의 작은 파일**(5MB 짜리 mp4 · dwg 등) — 지금 일반 첨부로는 보낼 수 없는 형식이다. 추천: 크기와 상관없이 링크로(쓰기 화면이 자동으로 — 이 쓰기 화면에서 고른 파일만이라 위 "손으로 「링크로 보내기」" 규칙과 맞는다). +7. **보내지 않은 파일 목록에 올리는 중인 것도 넣을지** — 추천: 넣는다(이 계약). 보관량을 잡고 있어, 창을 닫아 멈춘 올리기를 24시간 전에 지울 길이 된다. +8. **공개 호스트의 화면 묶음** — 추천: 처음 판은 같은 SPA 묶음 + nginx 가 `/s/` · 자산 · `/api/public-links/` 만 연다. 공개 화면에 앱 코드를 싣지 않으려면 Vite 다중 입구(`share.html`)를 쓴다. +9. **`FILE_006` 의 HTTP 상태** — 추천: 507(저장 공간을 정확히 말하고, 클라이언트의 자동 다시 시도 대상(502 · 503 · 504)과 상태로 갈린다). 대안 503. +10. **형식 검사의 MIME** — 추천: 확장자 허용 목록만(위 "올리기" 1). 보안 규칙(`.claude/rules/security.md` "확장자와 MIME 을 둘 다")의 예외라 규칙 문서에 까닭과 함께 적는다(rules 담당). +11. **인용 속 링크 상태를 묻는 길** — 추천: 주소 목록을 보내는 `POST /api/share-link-lookups`(이 계약 — 저장하지 않은 쓰기 화면의 인용에도 쓴다). 대안 — 원본 메일 id 로 서버가 본문에서 찾기(`GET /api/messages/{id}/…`) — 클라이언트가 주소를 고르지 않아도 되지만 임시 저장본 · 고친 인용은 따로 다뤄야 한다. -- 2.52.0 From 93147b88b76c05a715fff60d9761623eee5348dc Mon Sep 17 00:00:00 2001 From: ttipo Date: Sat, 31 Oct 2026 00:37:21 +0900 Subject: [PATCH 2/5] =?UTF-8?q?feat(docker):=20=EB=8C=80=EC=9A=A9=EB=9F=89?= =?UTF-8?q?=20=EC=B2=A8=EB=B6=80=20=E2=80=94=20ClamAV=20=EA=B2=80=EC=82=AC?= =?UTF-8?q?=EA=B8=B0=20=C2=B7=20=EA=B3=B5=EA=B0=9C=20=EB=82=B4=EB=A0=A4?= =?UTF-8?q?=EB=B0=9B=EA=B8=B0=20=ED=98=B8=EC=8A=A4=ED=8A=B8=20=C2=B7=20?= =?UTF-8?q?=EB=B0=9B=EA=B8=B0=20=EB=8F=99=EC=8B=9C=20=EC=97=B0=EA=B2=B0=20?= =?UTF-8?q?=EC=83=81=ED=95=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ClamAV 1.4.6 LTS 를 둘로 나눴다: clamd 는 백엔드와만 닿는 internal 네트워크(바깥 길 없음 · 서명 볼륨 읽기 전용), freshclam 은 자기 바깥 길에만. 비-root · cap_drop ALL. 운영은 늘 뜨고 개발은 profile clamav. clamd.conf 는 한 파일 한도 2047M 까지 끝까지 검사하고 암호 · 한도 초과를 판정으로 알린다(실측표는 mail/README). 검사 중에 죽으면 남던 임시 파일을 띄울 때 지운다. - nginx 를 포털 · 공개 내려받기(files.) 두 server 로 가른다(SHARE_PUBLIC_URL 과 CORS_ORIGIN 의 호스트 비교 — 같으면 포털이 함께 연다). 조각 PUT 17m, 받기는 버퍼링 · 압축 끔 + X-Accel-Buffering 넘김 + 머리글 버퍼 16k, limit_conn 은 X-Forwarded-For 마지막 칸으로 IP 마다 공개 10 · 앱 안 30, 전체 공개 400 · 앱 안 200. 받기 location 은 /api/ 블록 안의 대소문자를 가리지 않는 정규식이다(리뷰 — /FILES/ 같은 변형이 상한을 비켜 갔다). 접근 로그는 경로 모양과 43자 토큰 모양으로 토큰을 가린다. - 파일은 named volume _files(개발도 — builder 단계가 /app/files 를 node 소유로 만든다), 백업하지 않는다. - CI infra 잡: nginx 두 꼴 · 받기 경로 변형 시험(가짜 백엔드) · clamconf(-n 없이 기본값까지) · 틀린 SHARE_PUBLIC_URL 거부. - mail/README: 악성 검사 · 공개 호스트 · NPM 칸(Advanced 다섯 줄) · 메모리 예산 · 문제 해결. Co-Authored-By: Claude Opus 5.5 --- .env.development.example | 32 +++ .env.production.example | 36 ++- .github/workflows/ci.yml | 123 ++++++++- .gitignore | 2 + backend/.dockerignore | 2 + backend/Dockerfile | 7 +- docker-compose.dev.yml | 22 ++ docker-compose.yml | 153 ++++++++++- frontend/Dockerfile | 14 +- frontend/nginx-api-errors.conf | 48 ++++ frontend/nginx-env.envsh | 72 ++++- frontend/nginx-share-api-closed.conf | 14 + frontend/nginx-share-api-open.conf | 47 ++++ frontend/nginx-share-closed.conf | 12 + frontend/nginx-share-open.conf | 27 ++ frontend/nginx.conf | 375 ++++++++++++++++++++------- mail/README.md | 200 +++++++++++++- mail/clamav/clamd.conf | 66 +++++ mail/clamav/freshclam.conf | 27 ++ 19 files changed, 1176 insertions(+), 103 deletions(-) create mode 100644 frontend/nginx-api-errors.conf create mode 100644 frontend/nginx-share-api-closed.conf create mode 100644 frontend/nginx-share-api-open.conf create mode 100644 frontend/nginx-share-closed.conf create mode 100644 frontend/nginx-share-open.conf create mode 100644 mail/clamav/clamd.conf create mode 100644 mail/clamav/freshclam.conf diff --git a/.env.development.example b/.env.development.example index 42db467..ffc1a83 100644 --- a/.env.development.example +++ b/.env.development.example @@ -91,6 +91,8 @@ DB_SYNCHRONIZE=false # mail 메일 엔진 + Mailpit (기본) → COMPOSE_PROFILES=mail # redis 캐시·세션 공유가 필요해질 때 → COMPOSE_PROFILES=mail,redis # frontend vite 를 컨테이너로 띄워야 할 때 → COMPOSE_PROFILES=mail,frontend +# clamav 대용량 첨부의 악성 검사기(ClamAV — 메모리 1GB 남짓 · 처음에 서명 110MB 를 받는다) +# → COMPOSE_PROFILES=mail,clamav 와 함께 아래 "대용량 첨부" 의 CLAMAV_HOST=clamav (운영은 profile 없이 늘 뜬다) # # 일회성으로 켜려면: docker compose --profile redis ... up -d # @@ -108,6 +110,36 @@ COMPOSE_PROFILES=mail UPLOAD_DATA_DIR=./data/uploads UPLOAD_MAX_SIZE_MB=5 +# ========================================== +# 대용량 첨부 (11단계 — docs/mail/api-contract.md 4절, mail/README.md "악성 검사 (ClamAV)") +# +# 일반 첨부 한도(아래 MAIL_ATTACHMENT_*)를 넘는 파일은 메일 엔진이 아니라 백엔드의 볼륨(_files — 개발도 named volume)에 올리고, +# 메일에는 받는 사람이 로그인 없이 여는 내려받기 링크를 넣는다. 아래 값은 백엔드 기본값과 같다 — 비워 두어도 그 값이다. +# SHARE_PUBLIC_URL : 공개 내려받기 화면의 출처(스킴 · 호스트[· 포트] — 경로 · 끝의 / 없이). 개발은 비운다 = CORS_ORIGIN 의 첫 주소(포털과 같은 호스트). +# 운영은 포털과 다른 호스트다(.env.production.example) +# SHARE_REPORT_ADDRESS : 공개 화면의 신고 · 문의 주소 (비우면 postmaster@) +# SHARE_DOWNLOAD_LIMIT : 파일마다 내려받기 상한(받기 시작한 수, 1~100000) +# MAIL_LARGE_FILE_MAX_SIZE_MB : 한 파일(MB, 1~2047 — 악성 검사기가 끝까지 검사할 수 있는 2GiB 아래, MAIL_ATTACHMENT_MAX_SIZE_MB 이상) +# MAIL_LARGE_FILE_MAX_COUNT : 메일 한 통에 새로 싣는 대용량 파일 수(1~20) +# MAIL_LARGE_FILE_USER_QUOTA_GB : 직원마다 동시에 보관하는 양(GB, 1~1000 — 올리는 중 · 임시 저장 포함) +# MAIL_LARGE_FILE_RETENTION_DAYS : 보낸 날(한국)부터 받을 수 있는 날 수(1~90) +# FILE_STORAGE_MAX_TOTAL_GB : 서버 전체 보관량(GB). 비우면 한도 없음 — 디스크 여유만 본다 +# FILE_STORAGE_MIN_FREE_GB : 올리기를 만든 뒤에도 남길 디스크 여유(GB) — 개발은 Docker Desktop 가상 디스크의 여유를 본다 +# CLAMAV_HOST · CLAMAV_PORT : 악성 검사기(clamd). 개발은 비워 둔다 = 검사하지 않고 다 올리자마자 "검사하지 못함". +# 켜려면 COMPOSE_PROFILES=mail,clamav 와 함께 CLAMAV_HOST=clamav (compose 의 clamav 서비스, 포트 3310) +# ========================================== +SHARE_PUBLIC_URL= +SHARE_REPORT_ADDRESS= +SHARE_DOWNLOAD_LIMIT=500 +MAIL_LARGE_FILE_MAX_SIZE_MB=2047 +MAIL_LARGE_FILE_MAX_COUNT=10 +MAIL_LARGE_FILE_USER_QUOTA_GB=10 +MAIL_LARGE_FILE_RETENTION_DAYS=30 +FILE_STORAGE_MAX_TOTAL_GB= +FILE_STORAGE_MIN_FREE_GB=10 +CLAMAV_HOST= +CLAMAV_PORT=3310 + # ========================================== # 메일 엔진 (Stalwart) — profile `mail` # diff --git a/.env.production.example b/.env.production.example index 698c961..a8d2249 100644 --- a/.env.production.example +++ b/.env.production.example @@ -77,6 +77,8 @@ REDIS_DATA_DIR=./data/redis # true·0·false 는 운영에서 기동이 거부된다. # ========================================== BACKEND_API_URL=https://portal.arasystem.it.kr/api +# ⚠️ 대용량 첨부의 공개 내려받기 호스트(아래 SHARE_PUBLIC_URL — files.)는 여기에 넣지 않는다 — 넣으면 공개 호스트 분리가 꺼져 +# 포털에서도 공개 링크가 열린다(백엔드가 운영에서는 기동을 막는다). 공개 화면은 자기 호스트의 /api/public-links/ 만 부른다 CORS_ORIGIN=https://portal.arasystem.it.kr TRUST_PROXY=2 @@ -100,7 +102,7 @@ DB_SYNCHRONIZE=false # 선택적 서비스 (Compose profiles) # # 기본값 = mail — 메일 엔진(Stalwart)을 띄운다. 직원 로그인이 메일 엔진에 의존한다. -# (redis 는 기동하지 않는다) +# (redis 는 기동하지 않는다. 대용량 첨부의 악성 검사기 clamav · clamav-updater 는 profile 없이 늘 뜬다 — 아래 "대용량 첨부") # 캐시·세션 공유가 필요해지면 redis 를 추가하면 컨테이너가 함께 뜬다. # COMPOSE_PROFILES=mail,redis # 일회성으로 켜려면: docker compose --profile redis ... up -d @@ -133,6 +135,38 @@ FRONTEND_PRIVATE_BIND= # ========================================== UPLOAD_MAX_SIZE_MB=5 +# ========================================== +# 대용량 첨부 (11단계 — docs/mail/api-contract.md 4절, mail/README.md "악성 검사 (ClamAV)" · "대용량 첨부 — 운영") +# +# 일반 첨부 한도(아래 MAIL_ATTACHMENT_*)를 넘는 파일은 백엔드의 볼륨(_files — 30일짜리 전송물이라 백업하지 않는다)에 올리고, +# 메일에는 받는 사람이 로그인 없이 여는 내려받기 링크를 넣는다. 아래 값은 백엔드 기본값과 같다(SHARE_PUBLIC_URL · CLAMAV_HOST 는 운영 필수). +# SHARE_PUBLIC_URL : 공개 내려받기 화면의 출처 — **운영 필수**, https, 경로 · 끝의 / 없이. 포털과 다른 호스트로 둔다: +# 털린 계정이 퍼뜨린 악성 파일로 포털 주소까지 차단 목록에 오르는 위험을 떼어 놓고, 포털 쿠키도 그 호스트로 가지 않는다. +# DNS(가비아 files A → 외부 리버스 프록시 서버) · 외부 프록시의 Proxy Host(같은 사설 IP:8080, 접근 로그 끔)가 있어야 한다. +# ⚠️ 이 호스트를 위 CORS_ORIGIN 에 넣지 않는다(넣으면 분리가 꺼진다 — 백엔드가 기동을 막는다). +# ⚠️ 한 번 정하면 바꾸지 않는다 — 이미 보낸 메일의 링크가 이 주소다(옛 호스트가 남아 있어야 열린다) +# SHARE_REPORT_ADDRESS : 공개 화면의 신고 · 문의 주소 (비우면 postmaster@) +# SHARE_DOWNLOAD_LIMIT : 파일마다 내려받기 상한(받기 시작한 수, 1~100000) +# MAIL_LARGE_FILE_MAX_SIZE_MB : 한 파일(MB, 1~2047 — 악성 검사기가 끝까지 검사할 수 있는 2GiB 아래, MAIL_ATTACHMENT_MAX_SIZE_MB 이상) +# MAIL_LARGE_FILE_MAX_COUNT : 메일 한 통에 새로 싣는 대용량 파일 수(1~20) +# MAIL_LARGE_FILE_USER_QUOTA_GB : 직원마다 동시에 보관하는 양(GB, 1~1000 — 올리는 중 · 임시 저장 포함) +# MAIL_LARGE_FILE_RETENTION_DAYS : 보낸 날(한국)부터 받을 수 있는 날 수(1~90) +# FILE_STORAGE_MAX_TOTAL_GB : 서버 전체 보관량(GB). 비우면 한도 없음 — 디스크 여유만 본다. 디스크를 메일 · DB 와 함께 쓰면 정해 둔다 +# FILE_STORAGE_MIN_FREE_GB : 올리기를 만든 뒤에도 남길 디스크 여유(GB) — 도커 data-root 의 디스크(볼륨 · 악성 검사의 임시 파일이 함께 쓴다) +# CLAMAV_HOST · CLAMAV_PORT : 악성 검사기(clamd) — **운영 필수**. compose 의 clamav 서비스(호스트 이름만 — 스킴 · 포트 없이) +# ========================================== +SHARE_PUBLIC_URL=https://files.arasystem.it.kr +SHARE_REPORT_ADDRESS= +SHARE_DOWNLOAD_LIMIT=500 +MAIL_LARGE_FILE_MAX_SIZE_MB=2047 +MAIL_LARGE_FILE_MAX_COUNT=10 +MAIL_LARGE_FILE_USER_QUOTA_GB=10 +MAIL_LARGE_FILE_RETENTION_DAYS=30 +FILE_STORAGE_MAX_TOTAL_GB= +FILE_STORAGE_MIN_FREE_GB=10 +CLAMAV_HOST=clamav +CLAMAV_PORT=3310 + # ========================================== # 메일 엔진 (Stalwart) — profile `mail` # diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5348e51..fea8973 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -180,14 +180,132 @@ jobs: run: | tag="ci-nginx-config:${{ github.run_id }}" docker build --target nginx-config -t "$tag" frontend + # 공개 내려받기 호스트(11단계)를 따로 두지 않을 때 — 포털이 /s/ · /api/public-links/ 를 연다(nginx-share-open.conf · nginx-share-api-open.conf) docker run --rm -e PROJECT_NAME=ci "$tag" nginx -t - # 잘못된 값은 기동을 멈춰야 한다 (frontend/nginx-env.envsh — 앞자리 0, 경로가 붙은 백엔드 주소) + # 따로 둘 때 — 공개 내려받기 server(files.) · 포털은 닫는다(nginx-share-closed.conf · nginx-share-api-closed.conf). 두 꼴 모두 문법을 본다 + docker run --rm -e PROJECT_NAME=ci -e SHARE_PUBLIC_URL=https://files.ci.test -e CORS_ORIGIN=https://portal.ci.test "$tag" nginx -t + rendered=$(docker run --rm -e PROJECT_NAME=ci -e SHARE_PUBLIC_URL=https://files.ci.test -e CORS_ORIGIN=https://portal.ci.test "$tag" nginx -T 2>/dev/null) + for want in 'server_name files.ci.test;' 'include /etc/nginx/snippets/share-closed.conf;' 'include /etc/nginx/snippets/share-api-closed.conf;' \ + 'worker_connections 4096;' 'limit_conn download_public_total 400;' 'limit_conn download_app_total 200;'; do + printf '%s\n' "$rendered" | grep -F "$want" >/dev/null || { echo "::error::렌더링한 nginx 설정에 '$want' 가 없습니다"; exit 1; } + done + # 잘못된 값은 기동을 멈춰야 한다 (frontend/nginx-env.envsh — 앞자리 0, 경로가 붙은 백엔드 주소, 출처가 아닌 공개 내려받기 주소) if docker run --rm -e PROJECT_NAME=ci -e MAIL_ATTACHMENT_MAX_SIZE_MB=08 "$tag" nginx -t; then echo "::error::MAIL_ATTACHMENT_MAX_SIZE_MB=08 을 받아들였습니다 (거부해야 한다)"; exit 1 fi if docker run --rm -e NGINX_BACKEND_UPSTREAM=http://ci-backend:3000/ "$tag" nginx -t; then echo "::error::경로가 붙은 NGINX_BACKEND_UPSTREAM 을 받아들였습니다 (거부해야 한다)"; exit 1 fi + for value in https://files.ci.test/ https://files.ci.test/s files.ci.test 'https://files.ci.test;return 200' https://files.ci.test.; do + if docker run --rm -e PROJECT_NAME=ci -e "SHARE_PUBLIC_URL=$value" "$tag" nginx -t; then + echo "::error::SHARE_PUBLIC_URL=$value 를 받아들였습니다 (거부해야 한다)"; exit 1 + fi + done + + # 대용량 받기 경로(11단계)를 실제 요청으로 본다 — 백엔드(Express)는 경로의 대소문자를 가리지 않아, 받기 location(동시 연결 수 · 버퍼링 끔 · + # X-Accel-Buffering 넘김 · 머리글 버퍼 16k)과 포털이 닫은 공개 API 는 대소문자 · %2F · 겹 / 변형도 같은 곳을 타야 한다 (frontend/nginx.conf 머리말). + # 위 단계의 이미지에 가짜 백엔드(같은 컨테이너의 127.0.0.1:3000 — 늘 X-Accel-Buffering: no 를 붙인다. ?hold=1 이면 10MB 를 1KB/s 로, + # ?pad=1 이면 머리글 6KB)를 더해 네트워크 없이 띄운다. 그 머리글은 받기 location 을 탄 요청만 클라이언트까지 온다 — 그것으로 location 을 가른다 + - name: 대용량 받기 경로 시험 + run: | + base="ci-nginx-config:${{ github.run_id }}" + tag="ci-nginx-paths:${{ github.run_id }}" + name="ci-nginx-paths-${{ github.run_id }}" + ctx=$(mktemp -d) + cat > "$ctx/paths.sh" <<'EOF' + #!/bin/sh + P=http://127.0.0.1:8080 + T=AbCdEfGhIjKlMnOpQrStUvWxYz0123456789_-abcd1 + F=11111111-2222-4333-8444-555555555555 + fails=0 + # host path → "상태 xab|-" + probe() { + timeout 10 wget -S -O /dev/null --header "Host: $1" "$P$2" 2>&1 | awk ' + /^ HTTP\// { s = $2 } + / error: HTTP\// { for (i = 1; i <= NF; i++) if ($i ~ /^HTTP\//) s = $(i + 1) } + tolower($0) ~ /^ x-accel-buffering: no/ { x = "xab" } + END { print (s ? s : "none"), (x ? x : "-") }' + } + # host method path [머리글] → "상태 Content-Type code" — nginx 가 직접 끝낸 응답의 JSON + raw() { + { printf '%s %s HTTP/1.1\r\nHost: %s\r\n' "$2" "$3" "$1"; [ -n "$4" ] && printf '%s\r\n' "$4"; printf 'Connection: close\r\n\r\n'; } \ + | timeout 10 nc 127.0.0.1 8080 | tr -d '\r' | awk ' + NR == 1 { s = $2 } + /^[Cc]ontent-[Tt]ype:/ { t = $2 } + /^\{/ { if (match($0, /"code":"[A-Z_0-9]+"/)) c = substr($0, RSTART + 8, RLENGTH - 9) } + END { print s, (t ? t : "-"), (c ? c : "-") }' + } + check() { if [ "$got" = "$1" ]; then echo "ok $2 → $got"; else echo "FAIL $2 → $got (기대 $1)"; fails=$((fails + 1)); fi; } + expect() { got=$(probe "$2" "$3"); check "$1" "$2$3"; } + expect_raw() { got=$(raw "$2" "$3" "$4" "$5"); check "$1" "$3 $2$4 $5"; } + hold() { n=0; while [ "$n" -lt "$3" ]; do wget -q -O /dev/null --header "Host: $1" "$P$2?hold=1" >/dev/null 2>&1 & n=$((n + 1)); done; sleep 3; } + release() { killall wget >/dev/null 2>&1; sleep 2; } + PUBLIC="/api/public-links/$T/files/$F /api/Public-Links/$T/FILES/$F /api/PUBLIC-LINKS/$T/Files/$F /api//public-links/$T/files/$F /api/public%2Dlinks/$T/files/$F" + INAPP="/api/messages/M1/large-files/$F /api/Messages/M1/LARGE-FILES/$F /api/MESSAGES/M1/Large-Files/$F /api//messages/M1/large-files/$F /api/messages/M1%2Fx/large-files/$F" + + echo "== 받기 location 을 탄다(X-Accel-Buffering) · 포털은 공개 API 를 닫는다 — 대소문자 · %2F · 겹 / 변형도" + for p in $PUBLIC; do expect "200 xab" files.ci.test "$p"; expect "404 -" portal.ci.test "$p"; done + for p in $INAPP; do expect "200 xab" portal.ci.test "$p"; done + expect "200 -" files.ci.test "/api/Public-Links/$T" + expect "200 -" portal.ci.test "/api/Messages/M1" + expect "200 xab" files.ci.test "/api/Public-Links/$T/files/$F?pad=1" + expect "200 xab" portal.ci.test "/api/Messages/M1/large-files/$F?pad=1" + echo "== nginx 가 끝내는 /api 응답은 JSON" + expect_raw "404 application/json RES_001" files.ci.test GET /api/auth/me + expect_raw "413 application/json FILE_002" files.ci.test POST /api/auth/login "Content-Length: 2000000" + expect_raw "404 application/json RES_001" portal.ci.test GET "/api/Public-Links/$T/files/$F" + echo "== 동시 연결 — 주소마다 공개 10 · 앱 안 30. 붙잡아 둔 뒤 변형으로 하나 더" + hold files.ci.test "/api/public-links/$T/files/$F" 10 + for p in $PUBLIC; do expect "429 -" files.ci.test "$p"; done + expect_raw "429 application/json REQ_001" files.ci.test GET "/api/Public-Links/$T/files/$F" + expect "200 xab" portal.ci.test "/api/messages/M1/large-files/$F" + release + hold portal.ci.test "/api/messages/M1/large-files/$F" 30 + for p in $INAPP; do expect "429 -" portal.ci.test "$p"; done + expect "200 xab" files.ci.test "/api/public-links/$T/files/$F" + release + echo "fails=$fails" + [ "$fails" -eq 0 ] + EOF + { printf 'FROM %s\n' "$base"; cat <<'EOF'; } > "$ctx/Dockerfile" + RUN mkdir -p /tmp/stub && head -c 10485760 /dev/zero > /tmp/stub/big && echo ok > /tmp/stub/small \ + && pad=$(head -c 3000 /dev/zero | tr '\0' a) \ + && printf 'map $arg_hold $stub_rate { 1 1k; default 0; }\nmap $arg_hold $stub_file { 1 /big; default /small; }\nmap $arg_pad $stub_pad { 1 "%s"; default ""; }\nserver {\n listen 127.0.0.1:3000;\n access_log off;\n location / {\n root /tmp/stub;\n limit_rate $stub_rate;\n add_header X-Accel-Buffering no;\n add_header X-Pad1 $stub_pad;\n add_header X-Pad2 $stub_pad;\n try_files $stub_file =404;\n }\n}\n' "$pad" > /etc/nginx/conf.d/zz-stub.conf + COPY --chmod=755 paths.sh /usr/local/bin/paths.sh + EOF + docker build -t "$tag" "$ctx" + docker run -d --name "$name" --network none -e NGINX_BACKEND_UPSTREAM=http://127.0.0.1:3000 \ + -e SHARE_PUBLIC_URL=https://files.ci.test -e CORS_ORIGIN=https://portal.ci.test "$tag" + for _ in $(seq 1 20); do docker exec "$name" wget -q -O /dev/null --header 'Host: portal.ci.test' http://127.0.0.1:8080/ 2>/dev/null && break; sleep 1; done + docker exec "$name" sh /usr/local/bin/paths.sh || { docker logs --tail 40 "$name"; exit 1; } + + # 대용량 첨부의 악성 검사기(clamav — 11단계) 설정 검사 — compose 와 같은 이미지 · 같은 사용자(100:101)로 clamconf. + # 설정 파일은 바인드 마운트 대신 빌드 컨텍스트로 넣는다 (위 머리말). clamconf 는 모르는 · 틀린 항목을 "ERROR: Parse error" 로 알리지만 + # 종료 코드는 0 이라(1.4.6 확인) 출력으로 본다. 판정에 꼭 필요한 값(한도 · 경보 · 서명 다시 읽기)이 그대로인지도 본다 — + # AlertExceedsMax · AlertEncrypted 가 꺼지면 한도를 넘거나 암호가 걸린 파일이 "깨끗함(OK)"으로 온다 (mail/README.md "악성 검사 (ClamAV)") + - name: ClamAV 설정 검사 + run: | + touch backend/.env.production + # clamav · clamav-updater 가 같은 이미지라 두 줄 — 첫 줄을 awk 로 고른다(끝까지 읽는다). grep -m1 · head 는 먼저 끝나 pipefail 셸에서 + # compose 의 SIGPIPE 가 결과를 뒤집을 수 있다 (위 compose 단계와 같은 까닭) + image=$(docker compose -f docker-compose.yml --env-file .env.production.example config --images | awk '/^clamav\/clamav:/ && !seen { print; seen = 1 }') + [ -n "$image" ] || { echo "::error::docker-compose.yml 에서 clamav/clamav 이미지를 찾지 못했습니다"; exit 1; } + tag="ci-clamav-config:${{ github.run_id }}" + printf 'FROM %s\nCOPY clamd.conf freshclam.conf /etc/clamav/\n' "$image" | docker build -t "$tag" -f- mail/clamav + conf=$(docker run --rm --user 100:101 --entrypoint clamconf "$tag" -c /etc/clamav -n 2>&1) + printf '%s\n' "$conf" + if printf '%s\n' "$conf" | grep -E '^(ERROR|WARNING)' >/dev/null; then + echo "::error::clamconf 가 mail/clamav 의 설정을 읽지 못했습니다 (위 ERROR 줄)"; exit 1 + fi + for want in 'StreamMaxLength = "2146435072"' 'MaxFileSize = "2146435072"' 'MaxScanSize = "4292870144"' 'MaxScanTime = "300000"' 'AlertExceedsMax = "yes"' 'AlertEncrypted = "yes"' 'ConcurrentDatabaseReload disabled' 'TCPSocket = "3310"'; do + printf '%s\n' "$conf" | grep -F "$want" >/dev/null || { echo "::error::clamd.conf 에 '$want' 가 없습니다 (mail/clamav/clamd.conf)"; exit 1; } + done + # -n 은 기본값과 같은 값을 숨긴다 — 기본값과 같지만 판정에 기대는 값(서명 다시 읽기 10분 · 진짜 서명이 휴리스틱보다 먼저)은 + # -n 없이 clamd.conf 칸에서 본다 (기본값이 바뀐 판으로 올려도 알아차리게) + clamd=$(docker run --rm --user 100:101 --entrypoint clamconf "$tag" -c /etc/clamav 2>&1 | awk '/^Config file: /{ on = ($3 == "clamd.conf") } on') + for want in 'SelfCheck = "600"' 'HeuristicScanPrecedence disabled'; do + printf '%s\n' "$clamd" | grep -F -x "$want" >/dev/null || { echo "::error::clamd.conf 의 값이 '$want' 가 아닙니다 (mail/clamav/clamd.conf)"; exit 1; } + done # 메일 전용 DNS 리졸버(mail-resolver)의 설정 검사 — compose 와 같은 이미지 · 같은 사용자(101:102)로 unbound-checkconf. # 설정 파일은 바인드 마운트 대신 빌드 컨텍스트로 넣는다 (위 머리말 — 잡 컨테이너의 경로는 호스트에 없다) @@ -202,8 +320,11 @@ jobs: - name: 정리 if: always() run: | + docker rm -f "ci-nginx-paths-${{ github.run_id }}" || true + docker image rm -f "ci-nginx-paths:${{ github.run_id }}" || true docker image rm -f "ci-nginx-config:${{ github.run_id }}" || true docker image rm -f "ci-unbound-config:${{ github.run_id }}" || true + docker image rm -f "ci-clamav-config:${{ github.run_id }}" || true # 게시 주소 확인이 중간에 끊겼을 때 남은 컨테이너 (보통은 run --rm 이 지운다) docker ps -aq --filter "label=com.docker.compose.project=ci-bind-${{ github.run_id }}" | xargs -r docker rm -f || true diff --git a/.gitignore b/.gitignore index 1ae546a..602e317 100644 --- a/.gitignore +++ b/.gitignore @@ -23,6 +23,8 @@ ci/runner-data/ # 업로드 파일 (로컬 저장) — 코드가 아니다 backend/uploads/ +# 대용량 첨부 파일 (11단계 — FILE_STORAGE_DIR 의 기본값 ./files, 볼륨을 붙이지 않은 개발 · 호스트 실행) — 코드가 아니다 +backend/files/ # ========================================== # 의존성/빌드 산출물 diff --git a/backend/.dockerignore b/backend/.dockerignore index 63ac85f..247fa1c 100644 --- a/backend/.dockerignore +++ b/backend/.dockerignore @@ -12,6 +12,8 @@ node_modules dist coverage uploads +# 대용량 첨부 파일 (11단계 — 호스트에서 백엔드를 띄우면 FILE_STORAGE_DIR 기본값 ./files 에 쌓인다. 2GB 짜리가 빌드 컨텍스트에 실리지 않게) +files logs *.log .env diff --git a/backend/Dockerfile b/backend/Dockerfile index a90b692..4e18384 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -25,6 +25,9 @@ RUN npm ci COPY --chown=node:node . . RUN npm run build +# 대용량 첨부 파일 위치(FILE_STORAGE_DIR=/app/files — 11단계) — dev 는 이 단계에 named volume _files 를 붙인다. +# 새 볼륨은 이 디렉터리의 소유자(node)를 물려받는다 (없으면 root 소유로 생겨 node 가 쓰지 못한다). 빌드 컨텍스트의 files/ 는 .dockerignore 가 뺀다 +RUN mkdir -p /app/files FROM node:${NODE_VERSION}-alpine3.24 AS production ARG NPM_VERSION @@ -41,10 +44,10 @@ RUN npm ci --omit=dev && npm cache clean --force COPY --from=builder /app/dist ./dist -# 업로드 저장 위치(compose 의 UPLOAD_DIR=/app/uploads) — node 소유로 만들어 둔다. +# 업로드 저장 위치(compose 의 UPLOAD_DIR=/app/uploads)와 대용량 첨부 파일 위치(FILE_STORAGE_DIR=/app/files — 11단계) — node 소유로 만들어 둔다. # 운영 compose 는 여기에 named volume 을 붙이는데, 새 볼륨은 이 디렉터리의 소유자를 물려받아 node 가 쓸 수 있다. # (호스트 디렉터리를 바인드 마운트하면 sudo docker compose 가 root 소유로 만들어 업로드가 실패한다) -RUN mkdir -p /app/uploads && chown node:node /app/uploads +RUN mkdir -p /app/uploads /app/files && chown node:node /app/uploads /app/files EXPOSE 3000 diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index 69658ca..ce04651 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -83,6 +83,10 @@ services: # 소스 바인드 마운트(/app) 위에 다시 얹는다. Windows·macOS 의 Docker Desktop 은 소유자 문제가 없고, # 리눅스 호스트는 도커가 없는 디렉터리를 root 소유로 만들므로 처음 한 번 mkdir -p data/uploads 를 해 둔다 - ${UPLOAD_DATA_DIR:-./data/uploads}:/app/uploads + # 대용량 첨부 파일(11단계)은 개발도 base 그대로 named volume _files(/app/files)다 — 호스트 디렉터리로 바꾸지 않는다: + # 2GB 까지의 파일을 Windows 바인드 마운트 너머로 쓰고 읽으면 느리고, 호스트 백신(Windows Defender)이 시험 파일(EICAR 등)을 + # 지워 악성 검사 시험이 어긋난다. 새 볼륨은 builder 단계의 /app/files(node 소유)를 물려받는다 (backend/Dockerfile). + # 디스크 여유 검사(FILE_STORAGE_MIN_FREE_GB)는 Docker Desktop 가상 디스크의 여유를 본다 # dev 는 watch 컴파일(swc)과 타입 검사(tsc, nest-cli.json 의 typeCheck)가 앱과 함께 돌아 # base 의 1g 로는 OOM 으로 두 번 죽었다 → dev 에서만 2g. 운영 한도(1g)는 그대로다 deploy: @@ -117,6 +121,24 @@ services: # 개발도 같은 리졸버를 띄운다 — 엔진은 도커 DNS 를 거쳐(System) 바깥 이름을 이 리졸버에 묻는다(Mailpit 같은 도커 내부 이름은 도커 DNS 가 푼다) restart: "no" + # ---------------------------------------- + # 악성 검사 (ClamAV) — 개발은 기본으로 띄우지 않는다 (profile `clamav`, 운영은 늘 뜬다) + # + # 서명을 읽은 검사기가 메모리를 1GB 안팎 쓰고, 처음 기동은 서명(110MB 안팎)을 받는다. 끈 동안 백엔드(CLAMAV_HOST 비움)는 + # 다 올린 대용량 파일을 바로 "검사하지 못함"으로 둔다 (계약 4절 "악성 검사"). 켜려면 루트 .env.development 에 둘 다: + # COMPOSE_PROFILES=mail,clamav + # CLAMAV_HOST=clamav + # 그다음 docker compose ... up -d (backend 가 새 값으로 다시 만들어진다). 서명은 볼륨 _clamav-db 에 남아 다음에는 바로 뜬다 + # ---------------------------------------- + clamav: + profiles: ["clamav"] + # dev에서는 Docker Desktop 시작 시 자동 실행 방지 (base 의 unless-stopped 를 덮어씀) + restart: "no" + + clamav-updater: + profiles: ["clamav"] + restart: "no" + # ---------------------------------------- # mailpit — 개발 전용 메일 수집기 (profile `mail`, 운영 compose 에는 없다) # diff --git a/docker-compose.yml b/docker-compose.yml index 4964697..ee91c1b 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -12,6 +12,10 @@ services: # app-network backend ↔ db·redis·mail — 메일 엔진의 신뢰 대역이다. frontend 는 붙이지 않는다 # (붙이면 frontend 가 mail:8080 관리 API 에 닿고, 그 주소가 자동 차단 예외·게이트웨이 로그인 대역에 든다) # mail-dns mail ↔ mail-resolver(메일 전용 DNS) 전용 — 바깥 길 없음. 리졸버의 바깥 조회는 mail-dns-egress 로만 (6번 참조) + # clamav backend ↔ clamav(악성 검사기) 전용 — 바깥 길 없음. 서명 갱신(clamav-updater)은 clamav-egress 로만 (7번 참조) + # + # 호스트 이름 둘 — 포털(CORS_ORIGIN)과 대용량 첨부의 공개 내려받기(SHARE_PUBLIC_URL, 11단계)를 server 하나씩으로 가른다 + # (frontend/nginx.conf · nginx-env.envsh). 외부 리버스 프록시는 두 호스트를 모두 이 컨테이너의 8080 으로 넘기고 Host 를 그대로 넘긴다 # ---------------------------------------- frontend: container_name: ${PROJECT_NAME}-frontend @@ -32,7 +36,16 @@ services: # 서비스 이름(backend)이 아니라 고유 컨테이너 이름 — proxy 네트워크를 다른 프로젝트와 함께 쓰면 # 그쪽의 backend 라는 이름으로 풀릴 수 있다 (nginx 는 붙은 모든 네트워크에서 이름을 찾는다) - NGINX_BACKEND_UPSTREAM=http://${PROJECT_NAME}-backend:${BACKEND_PORT} + # 대용량 첨부의 공개 내려받기 호스트(11단계) — 백엔드와 같은 두 값으로 가른다: SHARE_PUBLIC_URL 의 호스트 이름이 포털(CORS_ORIGIN)과 다르면 + # 그 이름의 server 가 공개 화면(/s/)· 공개 API 만 열고 포털은 그 둘을 404 로 닫는다. 비었거나 같으면 포털이 함께 연다 (nginx-env.envsh) + - SHARE_PUBLIC_URL=${SHARE_PUBLIC_URL:-} + - CORS_ORIGIN=${CORS_ORIGIN:-} restart: unless-stopped + # 열린 파일 수 — nginx 의 연결마다 하나(워커마다 worker_connections 4096 — frontend/Dockerfile). 데몬 기본값(배포판 · 판마다 1024 일 수 있다)에 기대지 않는다 + ulimits: + nofile: + soft: 8192 + hard: 8192 networks: - edge - proxy @@ -115,19 +128,41 @@ services: - MAIL_ATTACHMENT_MAX_SIZE_MB=${MAIL_ATTACHMENT_MAX_SIZE_MB:-20} # 메일 한 통의 첨부 합계(MB) — 파일당 한도 이상이어야 한다 - MAIL_ATTACHMENT_TOTAL_MAX_SIZE_MB=${MAIL_ATTACHMENT_TOTAL_MAX_SIZE_MB:-25} + # 대용량 첨부 (11단계 — docs/mail/api-contract.md 4절 "환경 변수"). 비운 값은 백엔드 기본값이다 (검사: shared/config/env.validation.ts) + # 공개 내려받기 화면의 출처 — 운영 필수(https, 포털과 다른 호스트 권장 · 예 https://files.arasystem.it.kr). 비우면(개발) CORS_ORIGIN 의 첫 주소 + - SHARE_PUBLIC_URL=${SHARE_PUBLIC_URL:-} + - SHARE_REPORT_ADDRESS=${SHARE_REPORT_ADDRESS:-} + - SHARE_DOWNLOAD_LIMIT=${SHARE_DOWNLOAD_LIMIT:-} + - MAIL_LARGE_FILE_MAX_SIZE_MB=${MAIL_LARGE_FILE_MAX_SIZE_MB:-} + - MAIL_LARGE_FILE_MAX_COUNT=${MAIL_LARGE_FILE_MAX_COUNT:-} + - MAIL_LARGE_FILE_USER_QUOTA_GB=${MAIL_LARGE_FILE_USER_QUOTA_GB:-} + - MAIL_LARGE_FILE_RETENTION_DAYS=${MAIL_LARGE_FILE_RETENTION_DAYS:-} + - FILE_STORAGE_MAX_TOTAL_GB=${FILE_STORAGE_MAX_TOTAL_GB:-} + - FILE_STORAGE_MIN_FREE_GB=${FILE_STORAGE_MIN_FREE_GB:-} + # 파일을 두는 곳 — 아래 named volume _files (루트 env 로 바꾸지 않는다) + - FILE_STORAGE_DIR=/app/files + # 악성 검사기(clamd) — 운영 필수: 아래 clamav 서비스(호스트 이름 clamav, 포트 3310). 비우면(개발 기본) 검사하지 않고 다 올리자마자 "검사하지 못함" + - CLAMAV_HOST=${CLAMAV_HOST:-} + - CLAMAV_PORT=${CLAMAV_PORT:-} # 업로드 파일은 컨테이너 재생성과 무관하게 보존되어야 한다 — named volume _uploads. # 호스트 디렉터리(./data/uploads)를 쓰지 않는 이유: sudo docker compose 가 없는 디렉터리를 root 소유로 만들어 # backend(node)가 쓰지 못한다. 새 named volume 은 이미지의 /app/uploads(node 소유) 소유자를 물려받는다 (backend/Dockerfile). # (개발은 docker-compose.dev.yml 이 ./data/uploads 바인드 마운트로 바꾼다) # (메일 첨부는 업로드 중 /tmp/arasystem-mail-uploads 에 잠시 받는다 — 컨테이너 쓰기 계층 디스크. # read_only 로 바꾸면 그 경로에 tmpfs 가 아닌 볼륨을 붙인다: tmpfs 는 메모리 한도에 잡힌다) + # 대용량 첨부 파일(11단계)은 named volume _files — 같은 이유로 이미지의 /app/files(node 소유)를 물려받는다. + # 한 파일 2GB 까지 · 직원마다 10GB 까지 쌓인다 — 디스크 여유는 백엔드가 만들 때 본다(FILE_STORAGE_MIN_FREE_GB). 30일짜리 전송물이라 백업하지 않는다 + # (개발도 같은 named volume 이다 — 업로드와 달리 호스트 디렉터리로 바꾸지 않는다, docker-compose.dev.yml) volumes: - uploads:/app/uploads + - files:/app/files restart: unless-stopped - # 바깥에서는 프론트 nginx 의 /api 로만 들어온다 — edge(frontend 전용)와 app-network(db·mail)에만 붙고 proxy 에는 붙지 않는다 + # 바깥에서는 프론트 nginx 의 /api 로만 들어온다 — edge(frontend 전용)와 app-network(db·mail)에만 붙고 proxy 에는 붙지 않는다. + # clamav 는 악성 검사기와만 잇는 internal 네트워크다 (7번) networks: - app-network - edge + - clamav depends_on: db: condition: service_healthy @@ -325,6 +360,99 @@ services: cpus: '0.25' memory: 128m + # ---------------------------------------- + # 7. 악성 검사 (ClamAV) — 대용량 첨부(11단계)의 검사기 · 운영은 늘 뜬다 (개발은 profile `clamav` — docker-compose.dev.yml) + # + # 백엔드가 다 올린 파일을 clamd 의 INSTREAM(TCP 3310)으로 흘려보내 판정을 받는다 (docs/mail/api-contract.md 4절 "악성 검사", + # backend modules/file-storage/clamav-client.ts). 설정 mail/clamav/ · 자세한 것 mail/README.md "악성 검사 (ClamAV)". + # + # 둘로 나눈다 — 바깥에서 온 파일을 여는 쪽과 바깥에 나가는 쪽을 가른다: + # clamav clamd(검사기). 백엔드와만 닿는 clamav 네트워크(internal) 하나에 붙는다 — 바깥 길이 없다(파일 해석기에 구멍이 나도 + # 바깥 · 다른 컨테이너에 닿지 못하게). 서명 볼륨은 읽기 전용으로 붙이고 바뀐 서명을 스스로 다시 읽는다(clamd.conf 의 SelfCheck) + # clamav-updater freshclam(서명 갱신). 서명을 받아 볼륨에 쓴다 — 자기 바깥 길 clamav-egress 에만 붙고 백엔드 · 검사기와 잇지 않는다 + # 이미지는 공식 clamav/clamav 의 LTS(1.4) 판을 digest 까지 고정한다(docker 스킬 "이미지 버전 고정") — _base 는 서명이 들지 않은 이미지다(서명은 볼륨에). + # 두 컨테이너 모두 이미지의 clamav 사용자(100:101) · 권한(capability) 없음 · no-new-privileges. 호스트 포트는 어떤 환경에서도 열지 않는다 + # ---------------------------------------- + clamav: + container_name: ${PROJECT_NAME}-clamav + # 1.4 는 ClamAV 의 LTS(2024-08 부터 3년). 1.4.6 은 보안 릴리스(2026-08-07). digest 는 2026-10-30 의 manifest list (이미지는 linux/amd64 만) + image: clamav/clamav:1.4.6_base@sha256:e73991068f2037e10cf15669dd3b48c24345658f5488430746b414c0f761270f + user: "100:101" + cap_drop: + - ALL + security_opt: + - no-new-privileges:true + # 이미지의 /init 은 root 로 소유자를 바꾸고 freshclam 까지 함께 띄운다 — 검사기만 바로 띄운다. PID 1 은 docker 의 tini(sh 는 exec 로 clamd 가 된다). + # 띄우기 전에 clamd 의 임시 파일(clamd.conf 의 TemporaryDirectory /tmp — clamav-.tmp)을 지운다: 검사 중에 죽으면(OOM 등) 받던 스트림 · + # 푼 내용이 남고, 재시작은 같은 쓰기 계층을 쓰므로 죽을 때마다 쌓인다(2026-10-30 — 흘려보내는 중에 죽일 때마다 수백 MB 씩 남았다). + # 지우지 못해도(소유자가 다르면) clamd 는 띄운다 — 그 줄이 docker logs 에 남는다 + init: true + entrypoint: ["/bin/sh", "-c", "rm -rf /tmp/clamav-*; exec clamd --foreground --config-file=/etc/clamav/clamd.conf"] + expose: + - "3310" + volumes: + - clamav-db:/var/lib/clamav:ro + - ./mail/clamav/clamd.conf:/etc/clamav/clamd.conf:ro + restart: unless-stopped + networks: + - clamav + # 서명이 볼륨에 생긴 뒤에 뜬다 — 처음 기동은 clamav-updater 가 서명을 받는 몇 분을 기다린다(없으면 clamd 가 바로 끝난다). + # required: false — 서명이 오래됐다고(아래 updater 의 healthcheck) 검사기를 띄우지 않으면 검사가 통째로 서므로 경고만 내고 띄운다 + depends_on: + clamav-updater: + condition: service_healthy + required: false + healthcheck: + # 이미지에 든 clamdcheck.sh — 3310 에 PING 을 보내 PONG 을 받는지. 서명을 다시 읽는 동안(30초 안팎)은 답하지 않는다 — 세 번까지 기다린다 + test: ["CMD", "clamdcheck.sh"] + interval: 60s + timeout: 10s + retries: 3 + start_period: 5m + # CPU — 검사 하나는 코어 하나만 쓴다(2GB 에 5분 안팎 — 2026-10-30 개발 PC). 백엔드가 한 번에 2개까지 보내므로 2개까지 쓰게 두고(서버가 2 vCPU 보다 적으면 낮춘다), + # 다른 컨테이너와 다툴 때는 절반의 몫(cpu_shares 512 — 기본 1024)만 받는다 — 검사가 웹메일 · 메일 엔진을 늦추지 않게 + cpu_shares: 512 + # 메모리 — 서명을 읽으면 1GB 안팎(2026-10 서명 363만 개). 다시 읽을 때 두 벌을 들지 않는다(clamd.conf 의 ConcurrentDatabaseReload no — 1GB 그대로). + # 검사하는 동안 받은 파일을 /tmp 에 써 그 페이지 캐시가 이 한도에 함께 잡힌다(다시 쓸 수 있는 메모리라 줄여 가며 쓴다). 예산은 mail/README.md "메모리 예산" + deploy: + resources: + limits: + cpus: '2.0' + memory: 2g + + clamav-updater: + container_name: ${PROJECT_NAME}-clamav-updater + image: clamav/clamav:1.4.6_base@sha256:e73991068f2037e10cf15669dd3b48c24345658f5488430746b414c0f761270f + user: "100:101" + cap_drop: + - ALL + security_opt: + - no-new-privileges:true + init: true + # freshclam 데몬 — 기동 때 한 번 받고 Checks(하루 12번)마다 묻는다. 멈추면 컨테이너가 끝나 재시작 정책을 탄다 + entrypoint: ["freshclam", "--daemon", "--foreground", "--stdout", "--config-file=/etc/clamav/freshclam.conf"] + volumes: + - clamav-db:/var/lib/clamav + - ./mail/clamav/freshclam.conf:/etc/clamav/freshclam.conf:ro + restart: unless-stopped + networks: + - clamav-egress + healthcheck: + # 서명(main · daily)이 있고 daily 가 이틀 안에 바뀌었으면 healthy — 갱신이 이틀 넘게 멈추면 unhealthy(docker ps 로 보인다. 검사는 옛 서명으로 이어 간다). + # 처음 기동은 서명을 받는 몇 분 동안 실패해도 세지 않는다(start_period) + test: ["CMD-SHELL", "ls /var/lib/clamav/main.c[lv]d >/dev/null 2>&1 && find /var/lib/clamav -maxdepth 1 -name 'daily.c[lv]d' -mmin -2880 | grep -q ."] + interval: 60s + timeout: 10s + retries: 3 + start_period: 10m + # 메모리 — 평소 수십 MB. 받은 서명을 바꿔 넣기 전에 읽어 보는 동안(freshclam.conf 의 TestDatabases) 잠시 커진다 — + # 처음 기동(서명 전체)이 가장 커서 0.7GB 남짓이었다(2026-10-30). 평소 갱신은 daily 만이라 그보다 작다 + deploy: + resources: + limits: + cpus: '0.5' + memory: 1g + networks: app-network: # 이름·서브넷을 고정한다. 메일 엔진은 이 대역만 신뢰한다 — 게이트웨이 로그인·자동 차단 예외 @@ -359,17 +487,34 @@ networks: # 도커가 기본 주소 풀(172.17~31.x · 192.168.x)에서 정한다. 서버의 사설망(VPC)이 그 대역이면 겹치지 않는지 본다 (mail/README.md) name: ${PROJECT_NAME}_mail-dns-egress driver: bridge + clamav: + # backend ↔ clamav(검사기) 전용. internal — 이 네트워크로는 바깥에 나가지 못한다(검사기는 바깥 길이 없다). + # clamd 는 암호화하지 않은 TCP 이고 접속을 가리지 않는다 — 다른 컨테이너를 붙이지 않는다. 주소로 믿는 곳이 없어 서브넷은 도커가 정한다 + name: ${PROJECT_NAME}_clamav + driver: bridge + internal: true + clamav-egress: + # 서명 갱신(clamav-updater)이 database.clamav.net 에 묻는 길 — updater 혼자 붙는다. 서브넷은 도커 기본 주소 풀(172.17~31.x · 192.168.x)에서 — + # 서버의 사설망(VPC)이 그 대역이면 겹치지 않는지 본다 (mail-dns-egress 와 같다) + name: ${PROJECT_NAME}_clamav-egress + driver: bridge proxy: external: true # 메일 설정(config.json)·저장소(RocksDB). 컨테이너 사용자(UID 2000)가 써야 하므로 # 바인드 마운트 대신 named volume 을 쓴다. 이름을 고정해 백업 명령이 디렉터리 이름에 좌우되지 않게 한다. -# 업로드도 같은 이유(컨테이너 사용자 node 가 써야 한다)로 named volume 이다. -# ⚠️ `docker compose down -v` 는 이 볼륨들까지 지운다 — 메일함 전체와 업로드 파일이 사라진다. +# 업로드 · 대용량 첨부 파일도 같은 이유(컨테이너 사용자 node 가 써야 한다)로 named volume 이다. 악성 검사 서명은 clamav 사용자(100)가 쓴다. +# ⚠️ `docker compose down -v` 는 이 볼륨들까지 지운다 — 메일함 전체와 업로드 · 대용량 첨부 파일이 사라진다. volumes: mail-config: name: ${PROJECT_NAME}_mail-config mail-data: name: ${PROJECT_NAME}_mail-data uploads: - name: ${PROJECT_NAME}_uploads \ No newline at end of file + name: ${PROJECT_NAME}_uploads + # 대용량 첨부 파일 (11단계) — 기록은 DB 에 있다. 백업하지 않는다(30일짜리 전송물 — mail/README.md "백업") + files: + name: ${PROJECT_NAME}_files + # 악성 검사 서명 (110MB 안팎 — 2026-10) — 지워도 clamav-updater 가 다시 받는다. 백업하지 않는다 + clamav-db: + name: ${PROJECT_NAME}_clamav-db \ No newline at end of file diff --git a/frontend/Dockerfile b/frontend/Dockerfile index ea647fd..3b759b1 100644 --- a/frontend/Dockerfile +++ b/frontend/Dockerfile @@ -46,8 +46,20 @@ FROM nginxinc/nginx-unprivileged:1.30.6-alpine3.24 AS nginx-config ENV NGINX_ENVSUBST_FILTER=^NGINX_ \ NGINX_ENTRYPOINT_LOCAL_RESOLVERS=1 COPY nginx.conf /etc/nginx/templates/default.conf.template -# 템플릿 값 계산 (루트 env 의 첨부·업로드 한도 → nginx 본문 한도). 실행 권한이 있어야 entrypoint 가 source 한다 +# 템플릿 값 계산 (루트 env 의 첨부·업로드 한도 → nginx 본문 한도, 공개 내려받기 호스트). 실행 권한이 있어야 entrypoint 가 source 한다 COPY --chmod=755 nginx-env.envsh /docker-entrypoint.d/16-nginx-env.envsh +# 두 server(포털 · 공개 내려받기 — 11단계)가 include 하는 location 묶음. 템플릿이 아니다 — envsubst 를 거치지 않고 그대로 읽는다. +# share-open · share-closed 는 server 단계(화면 /s/), share-api-open · share-api-closed 는 /api/ 블록 안(공개 API — 대소문자를 가리지 않는다) +COPY nginx-share-open.conf /etc/nginx/snippets/share-open.conf +COPY nginx-share-closed.conf /etc/nginx/snippets/share-closed.conf +COPY nginx-share-api-open.conf /etc/nginx/snippets/share-api-open.conf +COPY nginx-share-api-closed.conf /etc/nginx/snippets/share-api-closed.conf +COPY nginx-api-errors.conf /etc/nginx/snippets/api-errors.conf +# 워커마다 연결 수 — 이미지 기본 1024 → 4096. 대용량 받기(버퍼링 끔 — 받기 하나가 연결 둘, 공개 400 · 앱 안 200 개까지) · 실시간 알림(브라우저마다 연결 둘을 +# 1시간) · 포털 요청이 함께 버티게 (nginx.conf 의 http 단계 "동시 연결 수"). 워커 하나에 몰려도 모자라지 않게 워커마다 잡는다. +# 연결마다 파일 하나(fd)를 쓰므로 compose 가 frontend 의 nofile 을 8192 로 둔다. 이미지의 nginx.conf 꼴이 바뀌어 고치지 못하면 빌드를 멈춘다 +RUN sed -i 's/^\([[:space:]]*worker_connections[[:space:]]\{1,\}\)[0-9]\{1,\};/\14096;/' /etc/nginx/nginx.conf \ + && grep -Eq '^[[:space:]]*worker_connections[[:space:]]+4096;' /etc/nginx/nginx.conf # 운영 이미지 — compose 는 target 없이 빌드하므로 이 단계가 파일의 마지막이어야 한다 FROM nginx-config AS production-stage diff --git a/frontend/nginx-api-errors.conf b/frontend/nginx-api-errors.conf new file mode 100644 index 0000000..5599614 --- /dev/null +++ b/frontend/nginx-api-errors.conf @@ -0,0 +1,48 @@ +# ========================================== +# /api 의 nginx 오류 응답 — 두 server(포털 · 공개 내려받기)가 넣는다 (nginx.conf). 템플릿이 아니다(/etc/nginx/snippets/api-errors.conf) +# +# message 는 비운다 (문구는 클라이언트가 code 로 사전에서 찾는다, 레지스트리와 겹치지 않게). +# traceId 는 이 nginx 의 요청 ID(로그의 rid=)다. 이름 있는 location 은 server 단계라 화면용 헤더 상속을 끈다 +# (접근 로그는 server 단계의 쿼리 없는 main_rid 를 그대로 물려받는다). +# return 200 은 본문만 정한다 — error_page 를 = 없이 썼으므로 상태 코드는 원래 값(404·413·429·502·504 등)이 그대로 나간다 (2026-10-27 확인) +# ========================================== + +# nginx 가 닫은 경로 404 — 공개 내려받기 server 의 /api/(공개 API 밖) · 포털의 공개 API(공개 호스트를 따로 둘 때). 없는 경로와 같은 RES_001 +location @api_not_found { + add_header_inherit off; + default_type application/json; + add_header Cache-Control "no-store" always; + add_header X-Content-Type-Options "nosniff" always; + add_header X-Request-Id $request_id always; + return 200 '{"success":false,"error":{"code":"RES_001","message":"","traceId":"$request_id"}}'; +} + +# 본문 한도 초과 413 — 첨부 · 업로드 · 대용량 조각 · 공개 내려받기 server 의 /api/(1m) +location @api_payload_too_large { + add_header_inherit off; + default_type application/json; + add_header Cache-Control "no-store" always; + add_header X-Content-Type-Options "nosniff" always; + add_header X-Request-Id $request_id always; + return 200 '{"success":false,"error":{"code":"FILE_002","message":"","traceId":"$request_id"}}'; +} + +# 동시 연결 수 초과 429 — 대용량 받기의 limit_conn (11단계 — nginx.conf 의 http 단계 "동시 연결 수"). 백엔드의 요청 수 한도와 같은 REQ_001 +location @api_too_many_requests { + add_header_inherit off; + default_type application/json; + add_header Cache-Control "no-store" always; + add_header X-Content-Type-Options "nosniff" always; + add_header X-Request-Id $request_id always; + return 200 '{"success":false,"error":{"code":"REQ_001","message":"","traceId":"$request_id"}}'; +} + +# 백엔드 응답 없음 · 시간 초과 등 5xx +location @api_unavailable { + add_header_inherit off; + default_type application/json; + add_header Cache-Control "no-store" always; + add_header X-Content-Type-Options "nosniff" always; + add_header X-Request-Id $request_id always; + return 200 '{"success":false,"error":{"code":"SYS_001","message":"","traceId":"$request_id"}}'; +} diff --git a/frontend/nginx-env.envsh b/frontend/nginx-env.envsh index 50f9c91..3154c7b 100644 --- a/frontend/nginx-env.envsh +++ b/frontend/nginx-env.envsh @@ -64,4 +64,74 @@ esac # 도커 내장 DNS — 15-local-resolvers.envsh 가 /etc/resolv.conf 에서 읽어 둔다 (Dockerfile 의 NGINX_ENTRYPOINT_LOCAL_RESOLVERS=1) NGINX_LOCAL_RESOLVERS="${NGINX_LOCAL_RESOLVERS:-127.0.0.11}" -export NGINX_MAIL_ATTACHMENT_BODY_LIMIT NGINX_UPLOAD_BODY_LIMIT NGINX_BACKEND_UPSTREAM NGINX_LOCAL_RESOLVERS +# ------------------------------------------ +# 대용량 첨부의 공개 내려받기 호스트 (11단계 — docs/mail/api-contract.md 4절 "공개 호스트") +# +# SHARE_PUBLIC_URL(출처 — http(s)://호스트[:포트])의 호스트 이름을 포털(CORS_ORIGIN — 콤마로 여럿)의 호스트 이름들과 견준다. +# 백엔드(modules/file-sharing/file-sharing.settings.ts 의 dedicatedPublicHost)와 같은 판정이다 — 포트는 보지 않는다(nginx 는 포트 없는 Host 를 넘긴다): +# 다르면 공개 내려받기 server 의 이름 = 그 호스트 이름, 포털은 /s/ · /api/public-links/ 를 404 로 닫는다(share-closed · share-api-closed) +# 같거나 SHARE_PUBLIC_URL 이 비었으면 포털이 함께 연다(share-open · share-api-open), 공개 내려받기 server 는 어느 요청에도 맞지 않는 이름(share.invalid — RFC 2606) +# SHARE_PUBLIC_URL 의 모양이 틀리면 기동을 멈춘다 — 경로 · 공백 · ; 등이 server_name 에 들어가 설정을 깨거나 지시어를 끼워 넣지 않게. +# CORS_ORIGIN 의 모양은 백엔드가 본다(운영은 틀리면 백엔드가 뜨지 않는다) — 여기서는 읽을 수 있는 주소만 견준다. +# ------------------------------------------ + +# 출처 → 호스트 이름 (소문자, 포트 없이). 모양이 틀리면 아무것도 내지 않고 1 +nginx_env_origin_host() { + case "$1" in + http://*) nginx_env_hostport="${1#http://}" ;; + https://*) nginx_env_hostport="${1#https://}" ;; + *) return 1 ;; + esac + # 영문 · 숫자 · . - : 만 — 경로(/) · 쿼리 · 공백 · 대괄호(IPv6) · 사용자 정보(@) 를 받지 않는다 + case "$nginx_env_hostport" in + "" | *[!A-Za-z0-9.:-]*) return 1 ;; + esac + nginx_env_host="${nginx_env_hostport%%:*}" + nginx_env_port="${nginx_env_hostport#"$nginx_env_host"}" + case "$nginx_env_port" in + "") ;; + :0* | : | :*[!0-9]* | :??????*) return 1 ;; + esac + # 빈 이름 · 점으로 시작 · 끝(끝 점 — nginx 의 $host 는 끝 점을 떼어 백엔드와 어긋난다) · 연이은 점 · 하이픈으로 시작 · 끝 + case "$nginx_env_host" in + "" | .* | *. | *..* | -* | *-) return 1 ;; + esac + printf '%s' "$nginx_env_host" | tr 'A-Z' 'a-z' +} + +nginx_env_share_url="${SHARE_PUBLIC_URL:-}" +nginx_env_share_host="" +if [ -n "$nginx_env_share_url" ]; then + nginx_env_share_host="$(nginx_env_origin_host "$nginx_env_share_url")" \ + || nginx_env_fail "SHARE_PUBLIC_URL 은 공개 내려받기 화면의 출처(http(s)://호스트[:포트] — 경로 · 끝의 / · 끝 점 없이)여야 합니다 (예: https://files.arasystem.it.kr, 현재: \"$nginx_env_share_url\")." +fi + +nginx_env_same_host="" +if [ -z "$nginx_env_share_host" ]; then + nginx_env_same_host=1 +else + # 콤마로 나눠 하나씩 — 앞뒤 공백과 끝의 / 하나는 떼고 견준다 (백엔드도 출처로 고쳐 견준다) + while IFS= read -r nginx_env_origin; do + nginx_env_origin="$(printf '%s' "$nginx_env_origin" | tr -d ' ')" + nginx_env_origin="${nginx_env_origin%/}" + nginx_env_portal_host="$(nginx_env_origin_host "$nginx_env_origin")" || continue + if [ "$nginx_env_portal_host" = "$nginx_env_share_host" ]; then + nginx_env_same_host=1 + fi + done < (11단계 — docs/mail/api-contract.md 4절 "공개 내려받기") +# +# 공개 내려받기 server 가 넣는다(nginx.conf). 공개 호스트를 따로 두지 않으면 포털 server 도 넣는다(nginx-env.envsh 의 NGINX_PORTAL_SHARE=open). +# 템플릿이 아니다 — /etc/nginx/snippets/share-open.conf 로 그대로 들어간다(Dockerfile). ${NGINX_*} 를 쓰지 않는다 — $page_csp 는 nginx.conf 의 http 단계. +# 공개 API(/api/public-links/)는 여기가 아니라 /api/ 블록 안의 nginx-share-api-open.conf 다(대소문자를 가리지 않게 — nginx.conf 머리말). +# 로그인 · 쿠키를 쓰지 않는 길이다 — 바깥 거래처의 브라우저 · 내려받기 관리자 · 받는 쪽 보안 장비가 부른다. +# ========================================== + +# 화면 /s/<토큰> — SPA 의 공개 라우트(셸 · 로그인 없음 — 세션 복원을 건너뛴다). 파일은 토큰과 상관없이 index.html 하나다. +# server 단계의 보안 헤더를 물려받지 않고 여기서 다 쓴다 — 포털 server 에 들어갈 때(같은 호스트) 포털의 Referrer-Policy(strict-origin)와 +# 두 줄이 되지 않게. 포털 화면과 다른 것: Referer 를 아예 보내지 않는다(주소에 토큰이 있다 — 화면에서 나가는 요청 · 내려받기 이동에 실리지 않게), +# 검색 엔진에 올리지 않는다. index.html 이라 매번 재검증한다(배포 뒤 지워진 해시 자산을 부르지 않게 — 포털의 /index.html 과 같은 까닭) +# 대소문자를 가린다 — 화면 라우터도 /s/ 만 공개 화면으로 그린다(routes.ts 의 sensitive). /S/… 는 공개 내려받기 server 에서는 404, +# 포털에서는 SPA 의 다른 화면이다(공개 화면이 그려지지 않는다) +location ^~ /s/ { + root /usr/share/nginx/html; + try_files /index.html =404; + add_header_inherit off; + add_header Cache-Control "no-cache" always; + add_header Referrer-Policy "no-referrer" always; + add_header X-Robots-Tag "noindex, nofollow" always; + add_header X-Frame-Options "SAMEORIGIN" always; + add_header X-Content-Type-Options "nosniff" always; + add_header Permissions-Policy "geolocation=(), microphone=(), camera=()" always; + add_header Content-Security-Policy $page_csp always; +} diff --git a/frontend/nginx.conf b/frontend/nginx.conf index 852a32e..339643b 100644 --- a/frontend/nginx.conf +++ b/frontend/nginx.conf @@ -4,10 +4,27 @@ # 같은 주소(origin) 배포 — 브라우저는 화면과 같은 주소의 /api 를 부른다 (docs/mail/api-contract.md 머리말). # 그래서 CORS 가 필요 없고 CSP connect-src 'self' 로 충분하다. 운영의 입구는 이 nginx 하나다. # +# server 가 둘이다 (11단계 — 대용량 첨부의 공개 내려받기, 계약 4절 "공개 호스트"). 어느 쪽인지는 Host 로 가른다 — +# 외부 리버스 프록시는 두 호스트를 모두 이 8080 으로 넘기며 Host 를 그대로 둔다 (mail/README.md "Nginx Proxy Manager 설정"): +# 포털 default_server — 웹메일 화면과 /api. 공개 내려받기(/s/ · /api/public-links/)는 공개 호스트를 따로 두면 404 로 닫는다 +# 공개 내려받기 SHARE_PUBLIC_URL 의 호스트 이름 — /s/<토큰>(화면) · 화면 자산 · /api/public-links/ 만 연다. 그 밖은 404 +# 공개 호스트를 따로 두지 않으면(SHARE_PUBLIC_URL 이 비었거나 그 호스트 이름이 CORS_ORIGIN 에 있다) 포털이 둘 다 연다 — nginx-env.envsh +# +# ⚠️ 백엔드(Express)는 경로의 대소문자를 가리지 않는다 — /api/Public-Links/… · /api/messages/x/LARGE-FILES/… 도 처리기에 닿는다. +# 그래서 /api 아래에서 경로로 무엇을 정하는 location(받기의 동시 연결 수 · 버퍼링 끔, 포털의 공개 API 닫기, 조각 본문 한도)은 +# 대소문자를 가리지 않는 정규식(~*)으로 /api/ 블록 안에 둔다 — 대소문자를 가리는 prefix location(^~ /api/messages 등)에 기대면 변형이 비켜 간다 +# (2026-10-30 리뷰 — 받기 limit_conn 을 /FILES/ · /Messages/ 로 비켜 갔다). 백엔드의 대소문자 구분(겹 방어)에는 기대지 않는다. +# /API/… 처럼 /api/ 자체가 다르면 /api/ 블록에 들지 않아 백엔드로 가지 않는다(포털은 SPA 화면, 공개 내려받기는 404). +# # ⚠️ 이 파일은 템플릿이다. 컨테이너가 뜰 때 nginx 이미지의 entrypoint 가 이 파일 # (/etc/nginx/templates/default.conf.template)의 ${NGINX_*} 만 envsubst 로 채워 /etc/nginx/conf.d/default.conf 를 만든다. -# 값은 nginx-env.envsh 가 루트 env(MAIL_ATTACHMENT_MAX_SIZE_MB·UPLOAD_MAX_SIZE_MB 등)에서 계산한다. +# 값은 nginx-env.envsh 가 루트 env(MAIL_ATTACHMENT_MAX_SIZE_MB·UPLOAD_MAX_SIZE_MB·SHARE_PUBLIC_URL·CORS_ORIGIN 등)에서 계산한다. # nginx 변수($uri 등)는 NGINX_ 로 시작하지 않아 그대로 남는다 (Dockerfile 의 NGINX_ENVSUBST_FILTER). +# 두 server 가 함께 쓰는 location 은 /etc/nginx/snippets/ 의 파일이다(템플릿이 아니라 그대로 쓴다): +# share-open.conf · share-closed.conf server 단계 — 공개 화면 /s/ (연다 · 닫는다) +# share-api-open.conf · share-api-closed.conf /api/ 블록 안 — 공개 API /api/public-links/ (연다 · 닫는다) +# api-errors.conf server 단계 — nginx 가 직접 끝내는 /api 오류의 JSON(@api_*) +# 이 파일은 http 단계에 들어간다(이미지의 /etc/nginx/nginx.conf 가 conf.d 를 http 안에서 읽는다) — 아래 map · proxy_* · gzip 은 두 server 가 물려받는다. # ========================================== # 접근 로그 — 쿼리를 남기지 않는다. 검색어(q)가 /api 주소뿐 아니라 화면 주소(/mail/search?q=…)에도 들어간다 @@ -17,20 +34,53 @@ # $uri 로 다시 짜지 않는다 — SPA 폴백(try_files … /index.html) 뒤에는 $uri 가 /index.html 이라 어느 화면이었는지 사라진다. # - Referer: ? 앞까지만 남긴다. 화면 응답이 Referrer-Policy strict-origin 이라(아래 보안 헤더) 브라우저는 같은 주소의 요청에도 # origin 만 보내지만, 배포 전에 연 탭은 옛 정책(보던 화면 주소를 쿼리째)으로 계속 보내고 브라우저가 아닌 클라이언트는 무엇이든 보낸다. +# - 내려받기 토큰도 가린다 (11단계 — 계약 4절 "로그"). 토큰을 가진 누구나 파일을 받으므로 로그를 읽는 사람이 받지 못하게 — 요청 줄 · Referer 둘 다, +# 두 가지로 겹쳐 가린다: +# (1) 경로 모양 — /s/<…> · /api/public-links/<…> 의 그 칸을 길이와 상관없이 ***(대소문자 무시). 잘린 토큰(몇 글자 모자란 것)도 가린다 +# (2) 토큰 모양 — 토큰은 base64url 43자다(backend modules/file-sharing/share-token.ts). 앞뒤가 [A-Za-z0-9_-] 가 아닌 딱 43자 덩어리, +# 그리고 %XY(퍼센트 인코딩) 바로 뒤의 43자 덩어리를 *** 로 — 경로 모양이 어긋나도(//s/T · /s//T · /./s/T · /%73/T · /x/../s/T · +# /api//public-links/T · /api/public%2Dlinks/T · /api/public-links%2FT — nginx 는 정규화해 받지만 로그는 받은 그대로다) 가린다. +# 43자가 아닌 값(UUID 36자 · 메일 id · 엔진의 첨부 id 처럼 더 긴 것)은 가리지 않는다. 요청 줄은 두 번 본다(한 줄에 토큰이 둘이어도) +# 토큰 안을 퍼센트 인코딩으로 쪼갠 꼴(/s/AbC%44ef…)은 가리지 못한다 — 그 토큰을 가진 본인만 만들 수 있는 꼴이다 # - 값이 없으면 지금처럼 "-" 다 (map 의 정규식은 빈 값에는 돌지 않아 "" 를 따로 적는다). -# 오류 로그는 nginx 가 요청 줄·upstream·referrer 를 쿼리째 남긴다 — 장애 원인 줄을 잃지 않으려고 그대로 둔다 (docker 스킬 문서 "로그"). -map $request $log_request_line { +# 오류 로그는 nginx 가 요청 줄·upstream·referrer 를 쿼리째(토큰째) 남긴다 — 장애 원인 줄을 잃지 않으려고 그대로 둔다 (docker 스킬 문서 "로그"). +map $request $log_request_unqueried { "" "-"; "~^(?[^?]*)\?[^ ]*(?.*)" $log_request_head$log_request_tail; default $request; } -map $http_referer $log_referer { +map $log_request_unqueried $log_request_pathmasked { + "~*^(?[^ ]+ (?:https?://[^/ ]+)?/(?:s|api/public-links)/)[^/ ]+(?.*)$" $log_path_head***$log_path_tail; + default $log_request_unqueried; +} + +map $log_request_pathmasked $log_request_tokenmasked { + "~^(?.*?)(?:(?.*)$" $log_token1_head***$log_token1_tail; + default $log_request_pathmasked; +} + +map $log_request_tokenmasked $log_request_line { + "~^(?.*?)(?:(?.*)$" $log_token2_head***$log_token2_tail; + default $log_request_tokenmasked; +} + +map $http_referer $log_referer_unqueried { "" "-"; "~^(?[^?]*)\?" $log_referer_head; default $http_referer; } +map $log_referer_unqueried $log_referer_pathmasked { + "~*^(?(?:https?://[^/]+)?/(?:s|api/public-links)/)[^/]+(?.*)$" $log_referer_path_head***$log_referer_path_tail; + default $log_referer_unqueried; +} + +map $log_referer_pathmasked $log_referer { + "~^(?.*?)(?:(?.*)$" $log_referer_token_head***$log_referer_token_tail; + default $log_referer_pathmasked; +} + # 새 메일 확인(GET /api/mail-state — 화면이 보이는 동안 탭마다 1분에 한 번)은 성공(2xx·304)하면 접근 로그를 남기지 않는다 — # 로깅 규칙 "반복되는 정상 흐름(헬스체크, 폴링)은 로그를 남기지 않는다". 실패(401·5xx 등, 오류용 이름 있는 location 포함)는 남긴다. # 0 이면 server 단계 access_log 의 if= 가 그 줄을 쓰지 않는다. $uri 는 쿼리를 뺀 경로다 (이 요청은 내부 이동이 없다) @@ -46,15 +96,118 @@ map "$request_method:$uri:$status" $log_access { log_format main_rid '$remote_addr - $remote_user [$time_local] "$log_request_line" $status $body_bytes_sent ' '"$log_referer" "$http_user_agent" "$http_x_forwarded_for" rid=$request_id rt=$request_time'; +# Content-Security-Policy — XSS 방어의 핵심 헤더. 두 server(포털 · 공개 내려받기)와 공개 화면(/s/)이 이 값 하나를 쓴다 (map 은 늘 같은 값) +# +# ⚠️ 프로젝트에 맞게 조정이 필요한 설정이다. 기본값 설명: +# - default-src 'self' : 기본적으로 같은 출처만 허용 +# - style-src 'unsafe-inline' : 웹메일 본문 iframe(srcdoc) 때문이다 (아래). srcdoc 문서는 이 정책을 상속하는데, +# 그 안에는 앱이 넣는 diff --git a/frontend/src/components/mail/ComposeAttachmentList.vue b/frontend/src/components/mail/ComposeAttachmentList.vue index 61882dc..0b5fa9d 100644 --- a/frontend/src/components/mail/ComposeAttachmentList.vue +++ b/frontend/src/components/mail/ComposeAttachmentList.vue @@ -1,21 +1,30 @@ @@ -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); diff --git a/frontend/src/components/mail/MessageLargeFileItem.vue b/frontend/src/components/mail/MessageLargeFileItem.vue new file mode 100644 index 0000000..2168497 --- /dev/null +++ b/frontend/src/components/mail/MessageLargeFileItem.vue @@ -0,0 +1,198 @@ + + + + + diff --git a/frontend/src/components/mail/MessageLargeFileLinkBar.vue b/frontend/src/components/mail/MessageLargeFileLinkBar.vue new file mode 100644 index 0000000..865debc --- /dev/null +++ b/frontend/src/components/mail/MessageLargeFileLinkBar.vue @@ -0,0 +1,162 @@ + + + + + diff --git a/frontend/src/components/mail/MessageReader.vue b/frontend/src/components/mail/MessageReader.vue index ae33227..88b22c8 100644 --- a/frontend/src/components/mail/MessageReader.vue +++ b/frontend/src/components/mail/MessageReader.vue @@ -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" /> diff --git a/frontend/src/components/mail/editor/MailEditor.spec.ts b/frontend/src/components/mail/editor/MailEditor.spec.ts index 8aee544..38b0cc9 100644 --- a/frontend/src/components/mail/editor/MailEditor.spec.ts +++ b/frontend/src/components/mail/editor/MailEditor.spec.ts @@ -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('

') - expect(view.vm.getContent()).toEqual({ text: '', html: null }) + expect(view.vm.getContent()).toEqual({ text: '', html: null, quoteStart: null }) }) it('보낼 본문 — 서식 본문은 기본 글꼴 div 로 감싸고, 같은 내용의 텍스트를 함께 만든다', () => { diff --git a/frontend/src/components/mail/editor/MailEditor.vue b/frontend/src/components/mail/editor/MailEditor.vue index bd00c4a..f7622d0 100644 --- a/frontend/src/components/mail/editor/MailEditor.vue +++ b/frontend/src/components/mail/editor/MailEditor.vue @@ -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 } diff --git a/frontend/src/components/mail/editor/fit-draft.spec.ts b/frontend/src/components/mail/editor/fit-draft.spec.ts index 672ec6a..6731406 100644 --- a/frontend/src/components/mail/editor/fit-draft.spec.ts +++ b/frontend/src/components/mail/editor/fit-draft.spec.ts @@ -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, '

옛 원문

').replace('

', '') + const nested = createDocument( + '

내 글

내가 인용한 글

' + + 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]) + }) +}) diff --git a/frontend/src/components/mail/editor/fit-draft.ts b/frontend/src/components/mail/editor/fit-draft.ts index 727c7db..5d1db2e 100644 --- a/frontend/src/components/mail/editor/fit-draft.ts +++ b/frontend/src/components/mail/editor/fit-draft.ts @@ -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, } } diff --git a/frontend/src/components/mail/editor/mail-editor-handle.ts b/frontend/src/components/mail/editor/mail-editor-handle.ts index 1740a8a..539f70b 100644 --- a/frontend/src/components/mail/editor/mail-editor-handle.ts +++ b/frontend/src/components/mail/editor/mail-editor-handle.ts @@ -5,8 +5,19 @@ import type { InjectionKey } from 'vue' export interface MailEditorContent { /** 일반 텍스트 (body) — 서식 본문의 대체 본문. 비었으면 '' */ text: string - /** 서식 본문 (bodyHtml) — 기본 글꼴 div 로 감쌌다. 본문이 비었으면 null (텍스트 메일로 보낸다) */ + /** + * 서식 본문 (bodyHtml) — 기본 글꼴 div 로 감쌌다. 본문이 비었으면 null (텍스트 메일로 보낸다). + * 원문 인용이 있으면 그 바로 앞에 인용 경계 표시(
)가 있다 — 대용량 파일이 없는 메일은 + * 쓰기 화면이 지우고 보낸다 (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 /** diff --git a/frontend/src/components/mail/editor/mail-extensions.ts b/frontend/src/components/mail/editor/mail-extensions.ts index a4c4a5b..8480d1e 100644 --- a/frontend/src/components/mail/editor/mail-extensions.ts +++ b/frontend/src/components/mail/editor/mail-extensions.ts @@ -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({ }, { types: ['blockquote'], - attributes: { mailQuote: flagAttribute('mailQuote', 'data-mail-quote', () => false) }, + attributes: { mailQuote: flagAttribute('mailQuote', 'data-mail-quote', isMailQuoteBlock) }, }, ] }, diff --git a/frontend/src/components/mail/editor/mail-serialize.spec.ts b/frontend/src/components/mail/editor/mail-serialize.spec.ts index 225590e..58948ac 100644 --- a/frontend/src/components/mail/editor/mail-serialize.spec.ts +++ b/frontend/src/components/mail/editor/mail-serialize.spec.ts @@ -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( + `

확인했습니다.

${signature}` + + buildQuoteHtml([{ label: '보낸 사람', value: '이도윤 ' }], '

원문

'), + schema, + ) + + it('서식 본문 — 원문 인용 바로 앞에 빈 표시 하나 (markQuoteStart). 표시를 빼면 지금 모양 그대로', () => { + const doc = quoted('

김아라 · 인프라팀

') + 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} { + const doc = createDocument('

글

내가 쓴 인용

', 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('

김아라 · 인프라팀

') + + 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: '일정' }], '

원문

').replace('

', ''), schema) + expect(docToTextWithQuoteStart(doc).quoteStart).toBe(0) + }) +}) + describe('남의 HTML 을 화면 문서로 옮기지 않는다 (붙여 넣기 · 보낼 모양 — 처리기 실행 방지)', () => { afterEach(() => { vi.restoreAllMocks() diff --git a/frontend/src/components/mail/editor/mail-serialize.ts b/frontend/src/components/mail/editor/mail-serialize.ts index 80424ed..f853600 100644 --- a/frontend/src/components/mail/editor/mail-serialize.ts +++ b/frontend/src/components/mail/editor/mail-serialize.ts @@ -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절 "발송 · 임시 저장과 함께"): 서버가 대용량 첨부 덩이를 내 글 · 서명 아래, 인용 위에 넣도록 +// 서식 본문은 원문 인용 바로 앞에
(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>)+$/, '') } +/** 원문 인용 바로 앞에 인용 경계 표시를 둔다 — 맨 위 단계의 첫 원문 인용 (서버는 첫 표시 자리를 쓴다) */ +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 { + /** + * 원문 인용 바로 앞에 인용 경계 표시(
)를 둔다 — 대용량 첨부 덩이 자리 (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`, diff --git a/frontend/src/components/mail/editor/signature/SignatureEditor.vue b/frontend/src/components/mail/editor/signature/SignatureEditor.vue index f127045..0006015 100644 --- a/frontend/src/components/mail/editor/signature/SignatureEditor.vue +++ b/frontend/src/components/mail/editor/signature/SignatureEditor.vue @@ -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 */ diff --git a/frontend/src/components/settings/LargeFileDeleteDialog.vue b/frontend/src/components/settings/LargeFileDeleteDialog.vue new file mode 100644 index 0000000..f3785b0 --- /dev/null +++ b/frontend/src/components/settings/LargeFileDeleteDialog.vue @@ -0,0 +1,62 @@ + + + diff --git a/frontend/src/components/settings/LargeFileLinkList.vue b/frontend/src/components/settings/LargeFileLinkList.vue new file mode 100644 index 0000000..ffdd742 --- /dev/null +++ b/frontend/src/components/settings/LargeFileLinkList.vue @@ -0,0 +1,529 @@ + + + + + diff --git a/frontend/src/components/settings/LargeFileUnsentList.vue b/frontend/src/components/settings/LargeFileUnsentList.vue new file mode 100644 index 0000000..7196093 --- /dev/null +++ b/frontend/src/components/settings/LargeFileUnsentList.vue @@ -0,0 +1,324 @@ + + + + + diff --git a/frontend/src/components/settings/LargeFileUsage.vue b/frontend/src/components/settings/LargeFileUsage.vue new file mode 100644 index 0000000..a2f0499 --- /dev/null +++ b/frontend/src/components/settings/LargeFileUsage.vue @@ -0,0 +1,145 @@ + + + + + diff --git a/frontend/src/components/share/ShareFileRow.vue b/frontend/src/components/share/ShareFileRow.vue new file mode 100644 index 0000000..a22668e --- /dev/null +++ b/frontend/src/components/share/ShareFileRow.vue @@ -0,0 +1,234 @@ + + + + + diff --git a/frontend/src/composables/useApi.spec.ts b/frontend/src/composables/useApi.spec.ts index b296099..db7867a 100644 --- a/frontend/src/composables/useApi.spec.ts +++ b/frontend/src/composables/useApi.spec.ts @@ -76,6 +76,12 @@ describe('에러 코드 사전', () => { 'MAIL_013', // 필터가 엔진 한도를 넘음 — 필터 고치기 창이 알림 없이 창 안에 보인다 // 웹메일 8단계 (주소록) 'CONTACT_001', // 같은 이름의 그룹 — 새 그룹 · 이름 바꾸기 창이 알림 없이 칸 아래에 보인다 + // 웹메일 11단계 (대용량 첨부) — 조각 올리기 · 보내기가 이 코드로 분기한다 (FILE_008 은 분기하지 않는다 — 받기는
이동이다) + 'FILE_003', // 올리기 상태가 맞지 않음 — 상태를 다시 묻고 잇는다(받았다는 조각은 한 번 더 보내 보고, 또 409 면 멈춘다) + 'FILE_004', // 조각 손상 — 그 조각만 다시 보낸다 + 'FILE_005', // 보관 한도 — 그 파일 줄에 사전 문구 + [대용량 첨부 보기] + 'FILE_006', // 서버 저장 공간 — 그 파일 줄에 사전 문구, 멈추고 [다시 시도] + 'MAIL_016', // 대용량 파일을 쓸 수 없음 — 쓰기 화면이 파일마다 상태를 다시 묻는다 ] for (const code of usedByClient) { diff --git a/frontend/src/composables/useAttachmentUploads.ts b/frontend/src/composables/useAttachmentUploads.ts index c8c3105..8f3d432 100644 --- a/frontend/src/composables/useAttachmentUploads.ts +++ b/frontend/src/composables/useAttachmentUploads.ts @@ -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] diff --git a/frontend/src/composables/useComposeLargeFiles.ts b/frontend/src/composables/useComposeLargeFiles.ts new file mode 100644 index 0000000..a588cbc --- /dev/null +++ b/frontend/src/composables/useComposeLargeFiles.ts @@ -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 + /** 메일에 담은(올리는 중 포함) 파일의 합계 */ + attachedBytes: Readonly> + /** 서버가 찾지 못한 첨부(MAIL_004)가 남았다 */ + hasMissing: Readonly> + addFiles: (files: Iterable) => File[] + remove: (key: number) => void +} + +export interface ComposeLargeFilesOptions { + clock: Clock + normal: ComposeNormalAttachments +} + +/** 본문 필드 — 발송 · 임시 저장 같다 */ +export type ComposeBodyFields = Pick + +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([]) + /** 양식을 새로 채운 횟수 — 늦게 온 인용 링크 상태를 버린다 */ + 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 { + 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, + } +} diff --git a/frontend/src/composables/useLargeFileAlerts.ts b/frontend/src/composables/useLargeFileAlerts.ts new file mode 100644 index 0000000..e8d042d --- /dev/null +++ b/frontend/src/composables/useLargeFileAlerts.ts @@ -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 } +} diff --git a/frontend/src/composables/useLargeFileScanRefresh.spec.ts b/frontend/src/composables/useLargeFileScanRefresh.spec.ts new file mode 100644 index 0000000..9d819ae --- /dev/null +++ b/frontend/src/composables/useLargeFileScanRefresh.spec.ts @@ -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 Promise>> + + 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>(() => 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((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() + }) +}) diff --git a/frontend/src/composables/useLargeFileScanRefresh.ts b/frontend/src/composables/useLargeFileScanRefresh.ts new file mode 100644 index 0000000..d831984 --- /dev/null +++ b/frontend/src/composables/useLargeFileScanRefresh.ts @@ -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 + 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 } +} diff --git a/frontend/src/composables/useLargeFileUploads.spec.ts b/frontend/src/composables/useLargeFileUploads.spec.ts new file mode 100644 index 0000000..569a51d --- /dev/null +++ b/frontend/src/composables/useLargeFileUploads.spec.ts @@ -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 { + 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 +} + +/** 서버가 내보내는 모양 — 받은 조각의 내용(chunks)은 빼고 번호만 */ +function largeFileOf(upload: FakeUpload): LargeFile { + const copy: Partial = { ...upload } + delete copy.chunks + return { ...(copy as LargeFile), receivedChunks: [...upload.chunks.keys()].sort((a, b) => a - b) } +} + +/** 붙잡힌 조각 요청 — 풀리거나(released) 요청이 취소되면(aborted) 끝난다 */ +function waitGate(gate: Promise, 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() + 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 | null = null + + function view(upload: FakeUpload): LargeFile { + return largeFileOf(upload) + } + + async function handle(request: MockRequest): Promise { + 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((resolve) => { + release = () => { + chunkGate = null + resolve() + } + }) + return release + }, + } +} + +describe('useLargeFileUploads', () => { + let clock: ManualClock + let scope: EffectScope + let server: ReturnType + 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 = {}) { + 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) + }) +}) diff --git a/frontend/src/composables/useLargeFileUploads.ts b/frontend/src/composables/useLargeFileUploads.ts new file mode 100644 index 0000000..82bd5a7 --- /dev/null +++ b/frontend/src/composables/useLargeFileUploads.ts @@ -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 & { baseUsedBytes: number } + clock?: Clock + /** 조각의 SHA-256(base64) — 테스트는 바꿔 넘긴다. 셈할 수 없으면(보안 컨텍스트가 아님) null */ + digest?: (chunk: Blob) => Promise +} + +/** [다시 시도]를 누르면 처음부터 새로 만든다 — 서버가 올리기를 잊었다(404) · 받은 조각과 내용이 다르다(409) */ +type RestartReason = 'gone' | 'changed' + +/** 파일마다 화면에 그리지 않는 것 — 고른 파일 · 조각 셈 · 기다림 · 다시 하기 셈 */ +interface UploadRuntime { + file: File | null + chunkSize: number + chunkCount: number + received: Set + /** 서버가 아는 맨 앞 조각 해시 — 다시 고른 파일을 견줄 때 */ + 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([]) + const runtimes = new Map() + 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 { + 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 { + 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 { + 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 { + 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): 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 { + 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 { + 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, + } +} diff --git a/frontend/src/composables/useLargeFiles.ts b/frontend/src/composables/useLargeFiles.ts new file mode 100644 index 0000000..6b5ef89 --- /dev/null +++ b/frontend/src/composables/useLargeFiles.ts @@ -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 + +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 { + return api.post('/file-uploads', body, { timeout: LARGE_FILE_REQUEST_TIMEOUT_MS, ...options }) + } + + /** 상태 — 끊긴 뒤 무엇을 받았는지 본다. 올리는 중은 마지막 조각 뒤 24시간 지나면 404 */ + function getUpload(id: string, options?: LargeFileRequestOptions): Promise { + return api.get(`/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 { + const headers: Record = { 'Content-Type': LARGE_FILE_CHUNK_CONTENT_TYPE } + if (options.sha256) headers[LARGE_FILE_DIGEST_HEADER] = contentDigestValue(options.sha256) + return api.put(`/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 { + return api.post(`/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 { + return api.delete(`/file-uploads/${segment(id)}`, options) + } + + /** 보내지 않은 내 파일 — 살아 있는 링크에 실리지 않은 것(올리는 중 포함), 만든 때 최근부터, total 포함 */ + function listUploads(query: LargeFileListQuery, options?: LargeFileRequestOptions): Promise> { + return api.get, PaginatedResult>('/file-uploads', { ...options, params: query }) + } + + /** 보관 사용량 · 한도 */ + function getFileQuota(options?: LargeFileRequestOptions): Promise { + return api.get('/file-quota', options) + } + + // ── 보낸 사람 관리 (계약 4절 "보낸 사람 관리") ───────────── + + /** 내가 만든 링크 — 보낸 때 최근부터, total 포함 (끝나거나 끊긴 뒤 90일까지 나온다) */ + function listShareLinks(query: LargeFileListQuery, options?: LargeFileRequestOptions): Promise> { + return api.get, PaginatedResult>('/share-links', { ...options, params: query }) + } + + /** 링크 끊기 — 그 링크(메일)의 파일 모두. 되돌릴 수 없다. 이미 끊었거나 기간이 지났으면 그대로 200 */ + function revokeShareLink(id: string, options?: LargeFileRequestOptions): Promise { + return api.post(`/share-links/${segment(id)}/revocation`, null, options) + } + + /** 인용 속 링크의 상태 — 주소(토큰)를 쿼리에 싣지 않으려고 본문으로 보낸다 (1~20개 · 300자) */ + function lookupShareLinks(urls: readonly string[], options?: LargeFileRequestOptions): Promise { + return api.post('/share-link-lookups', { urls }, options) + } + + // ── 우리 웹메일에서 받기 (계약 4절 "우리 웹메일에서 읽기") ───────────── + + /** 메일 속 대용량 첨부 주소 — 첨부처럼 로 받는다 (표준 래퍼 없음) */ + 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 { + if (!(await confirmSession())) return false + startBrowserDownload(messageLargeFileUrl(messageId, fileId)) + return true + } + + return { + createUpload, + getUpload, + putChunk, + completeUpload, + deleteUpload, + listUploads, + getFileQuota, + listShareLinks, + revokeShareLink, + lookupShareLinks, + messageLargeFileUrl, + downloadMessageLargeFile, + } +} diff --git a/frontend/src/composables/useMail.ts b/frontend/src/composables/useMail.ts index bf2692b..da7515c 100644 --- a/frontend/src/composables/useMail.ts +++ b/frontend/src/composables/useMail.ts @@ -98,8 +98,9 @@ const segment = (value: string) => encodeURIComponent(value) /** * 화면을 떠나지 않고 브라우저 다운로드를 시작한다 — 임시 를 눌러 원래 링크와 똑같이 받는다. * (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 을 따른다 diff --git a/frontend/src/composables/useMailRealtime.ts b/frontend/src/composables/useMailRealtime.ts index 2ded03c..4e1d024 100644 --- a/frontend/src/composables/useMailRealtime.ts +++ b/frontend/src/composables/useMailRealtime.ts @@ -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 + } } } diff --git a/frontend/src/composables/usePublicShare.ts b/frontend/src/composables/usePublicShare.ts new file mode 100644 index 0000000..666d800 --- /dev/null +++ b/frontend/src/composables/usePublicShare.ts @@ -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) 부른다. +// 받는 쪽은 바깥 거래처의 브라우저다 — 실패는 화면이 상태로 그린다(알림 없음). 파일은 이동으로 받는다(큰 파일을 fetch 로 받으면 메모리에 다 오른다). + +export type PublicShareFetch = (input: string, init: RequestInit) => Promise + +/** 링크 정보 결과 — 받음 · 없는 링크(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 + const sender = link.sender as Record | 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 { + 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 | 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 } +} diff --git a/frontend/src/pages/mail/ComposePage.drafts.spec.ts b/frontend/src/pages/mail/ComposePage.drafts.spec.ts index d95ecda..93e13fd 100644 --- a/frontend/src/pages/mail/ComposePage.drafts.spec.ts +++ b/frontend/src/pages/mail/ComposePage.drafts.spec.ts @@ -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([]) diff --git a/frontend/src/pages/mail/ComposePage.editor-load.spec.ts b/frontend/src/pages/mail/ComposePage.editor-load.spec.ts index eb0b066..fe1c515 100644 --- a/frontend/src/pages/mail/ComposePage.editor-load.spec.ts +++ b/frontend/src/pages/mail/ComposePage.editor-load.spec.ts @@ -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('아직 초점이 없다') diff --git a/frontend/src/pages/mail/ComposePage.large-files.spec.ts b/frontend/src/pages/mail/ComposePage.large-files.spec.ts new file mode 100644 index 0000000..f77ca84 --- /dev/null +++ b/frontend/src/pages/mail/ComposePage.large-files.spec.ts @@ -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 = + '
' + + '

확인했습니다.

' + + '


' + + '
' + + '

원본 메일

' + + '' + + '' + + '
보낸 사람이도윤 <doyun.lee@arasystem.it.kr>
' + + '

김아라님, 안녕하세요.

' + + '
' + +function draftWith(largeFiles: DraftLargeFile[]): Draft { + return { + id: 'd9', + to: [{ name: '박준호', email: 'junho.park@arasystem.it.kr' }], + cc: [], + bcc: [], + subject: '11월 현장 점검 자료', + body: '자료 보냅니다.', + bodyHtml: '

자료 보냅니다.

', + attachments: [], + inReplyTo: null, + updatedAt: '2026-10-30T05:00:00Z', + largeFiles, + } +} + +function sharedFile(changes: Partial): 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: '', + references: [], + htmlBody: `

자료입니다.

지난 자료
`, + 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 + + /** 화면이 늘 부르는 것 — 보관량 · 저장본 · 원본 메일. 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 | 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((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 | 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('
원본 메일") + 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 + | 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('
{ 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('
{ diff --git a/frontend/src/pages/mail/ComposePage.vue b/frontend/src/pages/mail/ComposePage.vue index f7d6322..aeafb48 100644 --- a/frontend/src/pages/mail/ComposePage.vue +++ b/frontend/src/pages/mail/ComposePage.vue @@ -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([]) const cc = ref([]) @@ -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(null) const subjectInputRef = ref(null) const fileInputRef = ref(null) const attachButtonRef = ref(null) -const attachmentListRef = ref | null>(null) +const attachmentListRef = ref | null>(null) function queryId(value: unknown): string | null { return typeof value === 'string' && value !== '' ? value : null @@ -304,9 +339,6 @@ const source = computed(() => 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 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() +})