docs: 서식 본문 계약과 메일 쓰기 편집기 디자인

- 계약 2절: 서식 본문(bodyHtml)의 허용 목록·값 형식·한도(VAL_001)·지문, 읽기 한도와 정화 전 검사(htmlBody null),
  답장·전달 초안 길이 맞춤, 발송 JSON 4MB, 서식 본문 지문의 알려진 한계
- 디자인: 쓰기 화면 입력칸 포커스 규칙, 편집기 툴바·메뉴·단축키·좁은 폭 순서·팔레트, 원문 인용, 본문 이미지 → 첨부
  (캔버스 Compose·ComposeEditor 보드와 구현하며 정한 것)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-27 07:49:40 +09:00
parent 2a854073f7
commit d807cb4441
5 changed files with 1502 additions and 37 deletions
+49 -6
View File
@@ -150,6 +150,12 @@ interface Attachment { id: string; name: string; type: string; size: number }
본문 안 인라인 이미지(`cid:`)는 작은 것만 `data:` URI 로 바꿔 넣고 나머지는 지운다(첨부 목록에는 남는다).
**클라이언트는 그래도 격리된 iframe 에만 그린다** (스크립트 비허용 sandbox + 내부 CSP — 이중 방어).
**읽기 한도**: 상세 조회는 본문 한 부분을 UTF-8 2,000,000바이트까지 읽는다 — 넘는 부분은 잘린 채 온다(잘렸다는 표시는 아직 없다).
HTML 은 정화하기 **전에** 길이(HTML 부분이 여럿이면 이어 붙인 것이 2,000,000자 이하)와 파서 비용을 본다 —
약 2만 단을 넘는 중첩이나, 깊게 열린 태그 아래로 짝 없는 닫는 태그를 아주 많이 둔 HTML 은 정화가 서버를 수십 초씩 붙잡기 때문이다.
넘으면 정화하지 않고 `htmlBody: null`·`hasBlockedImages: false` 로 내려준다. `textBody` 는 그대로라 클라이언트는 그것을 보여 주고
답장·전달 인용도 그것으로 만든다 (HTML 만 있는 메일이면 둘 다 `null`). 수백 단의 인용 스레드·2MB 짜리 큰 표·Outlook 조건부 주석은 한도 안이다.
### 엔드포인트
| 메서드 · 경로 | 요청 | 성공 | 주요 실패 |
@@ -173,7 +179,7 @@ interface Attachment { id: string; name: string; type: string; size: number }
| 받는 사람 합계 100명 초과 (배열 하나가 100을 넘으면 `VAL_001`) | 400 `MAIL_002` |
| 엔진에서 찾을 수 없는 첨부 id — 올려 두고 오래 지나 만료된 업로드, 그사이 지워진 원본 메일의 첨부 등 | 400 `MAIL_004` — 클라이언트는 해당 첨부를 실패로 표시한다. 올린 파일은 다시 올리게, 전달하는 원본 첨부는 빼게 한다 (원본은 다시 올릴 수 없다) |
| 첨부 20개 초과 | 400 `MAIL_006` — 클라이언트는 첨부를 추가할 때 미리 막는다 |
| JSON 본문 1MB 초과 | 413 `FILE_002` |
| JSON 본문 1MB 초과 (발송 `POST /api/messages` 만 4MB — 서식 본문과 긴 인용 때문) | 413 `FILE_002` |
메일함 정렬(확정 디자인 기준): 받은편지함 → 보낸편지함 → 임시보관함 → 스팸메일함 → 휴지통 → 보관함 → 그 외(`sortOrder`, 이름 순).
@@ -202,11 +208,13 @@ interface MailQuota {
- 같은 키인데 **내용이 다르면**(실패 뒤 받는 사람·제목·본문·첨부를 고친 경우) 보내지 않고 409 `MAIL_005`.
첫 메일이 실제로 나갔을 수 있으므로, 클라이언트는 사용자에게 보낸편지함을 확인하게 하고 **새 키**로 다시 보낼 수 있게 한다.
서버는 첫 발송 때 내용 지문(`Idempotency-Key` 를 키로 한 HMAC — 받는 사람은 키를 모른다)을 함께 남겨 비교한다.
- 지문에 들어가는 것: 받는 사람·참조·숨은참조의 **주소**(대소문자·순서 무시), 제목, 본문(줄바꿈 정규화), 첨부 id(순서 무시), `inReplyTo`.
- 지문에 들어가는 것: 받는 사람·참조·숨은참조의 **주소**(대소문자·순서 무시), 제목, 본문(줄바꿈 정규화), 서식 본문(서버가 정화한 HTML), 첨부 id(순서 무시), `inReplyTo`.
- 들어가지 않는 것: 표시 이름, 첨부 이름 — 이것만 바꾼 재시도는 "같은 내용"으로 보고 처음 결과를 돌려준다.
- **알려진 한계** (1단계는 발송 기록을 따로 저장하지 않고 보낸편지함에서 찾는다):
- 첫 발송 뒤 보낸 사본을 다른 메일함으로 옮기거나 지웠으면, 또는 그사이 보낸편지함·임시보관함에 200통 넘게 쌓였으면 같은 키의 재시도가 **다시 나갈 수 있다**.
- "처리 중" 표시는 서버 메모리에 있어 서버가 재시작되면 사라진다. 엔진 장애 직후의 드문 경합에서도 두 번 나갈 수 있다.
- 서식 본문의 지문은 **정화한 결과**로 만든다 — 첫 발송과 재시도 사이에 서버의 정화 규칙이 바뀌면(배포) 같은 키의 재시도가
`MAIL_005` 로 막힐 수 있다. 그때도 위 409 처리(보낸편지함 확인 → 새 키)를 따른다.
- 이 한계를 없애려면 (사용자, 키) → (지문, 메일 id) 를 서버 DB 에 저장한다 — 서버를 여러 대로 늘릴 때 함께 검토한다.
```jsonc
@@ -215,7 +223,8 @@ interface MailQuota {
"cc": [], // 선택
"bcc": [], // 선택 (받는 사람 합계 100 이하)
"subject": "Re: 메일 서버 이전 일정 공유", // 255자 이하
"body": "도윤님, 확인했습니다.\n…", // 일반 텍스트, 100,000자 이하
"body": "도윤님, 확인했습니다.\n…", // 일반 텍스트(서식 본문의 대체 본문), 100,000자 이하
"bodyHtml": "<div style=\"font-family:…\"><p>도윤님, 확인했습니다.</p>…</div>", // 선택 — 서식 편집기 본문, 500,000자 이하 (아래 "서식 본문")
"attachments": [{ "id": "<업로드 응답 id>", "name": "체크리스트.xlsx" }], // 선택, 20개 이하 (넘으면 MAIL_006)
"inReplyTo": "<답장할 메일의 id>" // 선택 — 스레드 헤더를 서버가 채운다
}
@@ -223,10 +232,44 @@ interface MailQuota {
**전달**: 원본 메일 `attachments[].id` 를 발송 본문의 `attachments` 에 그대로 넣으면 원본 첨부가 다시 붙는다
(같은 계정 안의 파일이라 다시 올릴 필요가 없다). 제목 `Fwd:` 와 인용 본문은 클라이언트가 만들고, `inReplyTo` 는 보내지 않는다.
발송 본문의 첨부 이름(`attachments[].name`)은 업로드한 파일과 같은 규칙으로 확장자를 허용 목록에서 검사한다(밖이면 400 `FILE_001`) — 원본 첨부를 그대로 전달할 때도 같다.
**답장·전달 초안의 길이**: 클라이언트가 초안을 만들 때 한도에 맞춘다 — 제목은 255자, 본문은 100,000자.
**답장·전달 초안의 길이**: 클라이언트가 초안을 만들 때 한도에 맞춘다 — 제목은 255자, 본문은 텍스트 100,000자·서식 본문 500,000자.
넘으면 인용을 줄이고 끝에 생략 표시를 둔다 (넘긴 채 보내면 400 `VAL_001`).
업로드한 파일과 같은 규칙으로 이름의 확장자를 허용 목록에서 검사한다(밖이면 400 `FILE_001`).
처음 맞출 때는 쓸 글 몫으로 한도의 5%를 남기고(텍스트 95,000자·서식 475,000자), 보내기 직전에 실제로 보낼 본문을 다시 재서 넘으면 인용을 더 줄인다.
인용 없이 쓴 글만으로 넘으면 보내지 않고 쓰기 화면에서 알린다.
**서식 본문 (`bodyHtml`)**: 쓰기 화면의 서식 편집기가 만든 HTML 이다.
- 클라이언트는 같은 내용의 일반 텍스트를 `body` 로 **함께** 보낸다 (받는 쪽 프로그램이 텍스트만 볼 때 쓰는 대체 본문 —
목록은 `- `/`1. `, 인용은 `> ` 를 붙인다). 서버는 둘을 `multipart/alternative` 로 보낸다. `bodyHtml` 이 없으면 텍스트 메일이다.
- 서버는 보내기 전에 **허용 목록으로 정화**한다 — 편집기가 만드는 것만 남긴다:
문단·줄바꿈, 굵게·기울임·밑줄·취소선, 글자 색·배경 색·글꼴·글자 크기(span 의 style), 정렬·들여쓰기·줄 간격, 목록, 인용, 가로줄,
표(칸 폭을 적는 `colgroup`·`col` 포함), 링크(`http`·`https`·`mailto`).
그 밖의 태그·속성·스타일·스크립트는 지운다. 지문에는 정화한 결과가 들어간다.
- 남는 속성: `style`(`a` 를 뺀 모든 태그), `a` 의 `href`(링크에는 이것만 남는다 — 링크 글자 색은 안쪽 span 의 style 로), `ol` 의 `start`,
`td`·`th` 의 `colspan`·`rowspan`(숫자만).
- 남는 style: `color`·`background-color`·`font-family`·`font-size`·`font-weight`·`line-height`·`text-align`·`margin`·`padding`(각 변 포함)·
`border`(각 변·`border-color`·`border-style`·`border-width`·`border-collapse`)·`width`·`min-width`·`vertical-align`·`white-space`.
- style 은 **값의 형식까지** 맞아야 남는다 — 형식이 틀린 선언은 그 선언만 지운다:
글자 크기는 `px` 만, 색은 `#rgb`·`#rrggbb`·`rgb()`·`rgba()`(색 이름·`hsl()` 은 지운다), 여백은 `0` 또는 `px`(음수 없음),
줄 간격은 단위 없는 숫자 1~3(소수 둘째 자리까지), 글꼴은 쓰기 화면의 글꼴 목록과 흔한 대체 이름(`Helvetica`·`Times`·`Courier`·
`AppleGothic`·`AppleMyungjo`)·일반 이름(`sans-serif`·`serif`·`monospace`)으로만 이뤄진 목록.
- 형광 표시는 `mark` 가 아니라 span 의 `background-color` 로 보낸다 (`mark`·`h1` 같은 모르는 태그는 태그만 지우고 글자는 남긴다).
- 정화하고 남는 것이 없으면 `body` 만으로 텍스트 메일을 보낸다.
- 한도 — 넘으면 400 `VAL_001`. 엔진·첨부 조회 **전**이라 메일은 나가지 않으므로, 고친 뒤 **같은 키**로 다시 보내면 된다.
정화 전에는 받은 메일과 같은 파서 비용 한도(위 "읽기 한도")를, 정화 뒤에는 결과가 UTF-8 2,000,000바이트 이하인지 본다
(우리 웹메일이 다시 읽을 때 잘리지 않게). 500,000자 안이어도 넘을 수 있다 — 정화가 `&` 를 `&amp;`(5바이트)로 바꾸고,
한글은 글자당 3바이트·이모지는 4바이트다.
- 기본 글꼴은 **맑은 고딕** 이다 — 클라이언트가 본문 전체를 `font-family`·`font-size:14px`·`line-height:1.75` 를 가진 `div` 하나로 감싸 보낸다.
편집기 화면의 CSS 는 메일에 따라가지 않으므로 문단 여백(0)·인용 막대·표 테두리도 각 요소의 인라인 `style` 로 보낸다.
- 클라이언트도 **같은 값 형식만 읽는다** — 붙여 넣은 글과 원문 인용을 편집기로 옮길 때 위 형식 밖의 서식(색 이름·`font-size:0`·목록에 없는 글꼴 등)은
버린다 (글자 크기의 `pt` 는 `px` 로 바꾼다). 서버만 지우면 편집기에서 숨은 글이 받는 사람에게 보이는 식으로 화면과 보낸 메일이 달라진다.
- 여러 칸 띄어쓰기와 줄 앞 공백은 받는 쪽이 합치거나 지우므로 nbsp 로 바꿔 보낸다 (보통 공백과 번갈아 — 긴 공백에서도 줄은 바뀐다).
탭은 다음 8칸 자리까지의 공백으로 바꾼다. 텍스트 대체 본문(`body`)은 그대로 둔다.
- 우리 웹메일이 다시 읽을 때(보낸편지함·받은 사람)도 이 서식이 남아야 한다 — 받은 메일 정화(`htmlBody`)의 허용 목록이 위 서식을 포함한다.
- 답장·전달의 원문 인용은 두 경우 모두 **"원본 메일"** 머리(보낸 사람·날짜·제목, 전달은 받는 사람·참조도)와 회색 인용 막대로 만든다.
원문은 `htmlBody`(없으면 `textBody`)를 편집기로 옮기며, 편집기가 모르는 요소(이미지 등)는 빠진다.
- **본문 안 이미지는 이번 단계에서 넣지 않는다** — 붙여 넣거나 끌어다 놓은 이미지는 첨부 파일로 올린다 (첨부 한도 규칙 그대로).
### 첨부 업로드 한도
@@ -241,4 +284,4 @@ interface MailQuota {
### 이 단계에서 하지 않는 것
실시간 새 메일 알림(SSE), 검색 화면, 임시 저장(Drafts 쓰기), 서식 있는 편집기, 외부 이미지 보기(이미지 중계), 다중 선택 일괄 처리.
실시간 새 메일 알림(SSE), 검색 화면, 임시 저장(Drafts 쓰기), 본문 안 이미지(인라인 cid — 붙여 넣은 이미지는 첨부로), 외부 이미지 보기(이미지 중계), 다중 선택 일괄 처리.