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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 07:49:40 +09:00

24 KiB

인증·메일 API 계약 (1단계)

백엔드와 프론트엔드를 병렬로 만들기 위한 계약이다. 구현이 끝나면 Swagger(/api-docs)와 프론트 타입이 기준이며, 이 문서와 어긋나면 이 문서를 고친다. 바꿔야 할 일이 생기면 한쪽에서 조용히 바꾸지 말고 이 문서부터 고친 뒤 양쪽에 알린다.

공통 규칙은 그대로 따른다 — 표준 응답 래퍼(response-format), 에러 코드(error-codes), API 규약(api-design).

  • 성공 { "success": true, "data": … } / 실패 { "success": false, "error": { "code", "message", "traceId" } }
  • 날짜·시각은 ISO 8601 UTC 문자열, 목록은 { items, pageInfo }
  • 이 단계에서 쓰는 에러 코드: AUTH_001 AUTH_002(클라이언트 전용) AUTH_003 AUTH_004 VAL_001 RES_001 REQ_001 FILE_001 FILE_002 MAIL_001 MAIL_002 MAIL_003 MAIL_004 MAIL_005 MAIL_006 SYS_001
  • 적용 범위: 1단계는 웹(브라우저) 전용이다. 인증은 HttpOnly 쿠키뿐이고 토큰을 본문으로 주지 않는다. 모바일 앱(Flutter)의 인증 방식(토큰 발급·flutter_secure_storage)은 앱 착수 때 이 문서에 추가한다.
  • 배포 형태: 화면과 API 는 같은 주소(origin)를 쓴다. 운영은 프론트 nginx 가 /api 를 백엔드로 넘기고, 개발은 vite 프록시가 넘긴다. 그래서 CORS 가 필요 없고 CSP connect-src 'self' 로 충분하다.
  • 출처 검사(CSRF): 상태를 바꾸는 요청(POST·PATCH·PUT·DELETE)은 브라우저가 붙이는 Sec-Fetch-Site·Origin 을 검사한다. 다른 사이트에서 온 요청은 403 AUTH_003. 출처 정보가 아예 없는 요청(브라우저가 아닌 클라이언트)은 통과한다.
  • 프록시가 직접 내는 오류: 프론트 nginx 가 백엔드에 닿기 전에 끝내는 요청도 표준 실패 형식으로 온다 — 본문 한도 초과 413 FILE_002, 백엔드 응답 없음 502·시간 초과 504 SYS_001. traceId 는 nginx 요청 ID 이고 message 는 비어 있을 수 있으니 클라이언트는 코드로 사전 문구를 쓴다.

1. 인증 (쿠키 기반 — 토큰은 응답 본문에 없다)

쿠키 내용 속성
access_token 액세스 JWT (JWT_ACCESS_EXPIRES_IN, 기본 1h) HttpOnly · SameSite=Lax · Path=/api · 운영 Secure
refresh_token 리프레시 JWT (JWT_REFRESH_EXPIRES_IN, 기본 14d) HttpOnly · SameSite=Strict · Path=/api/auth · 운영 Secure

직원 계정 = 메일 계정이다. 백엔드는 로그인할 때만 비밀번호를 메일 엔진(Stalwart)에 확인하고 저장하지 않는다.

POST /api/auth/login

// 요청
{ "email": "ara.kim@arasystem.it.kr", "password": "…" }
// 200 + Set-Cookie(access_token, refresh_token)
{ "email": "ara.kim@arasystem.it.kr", "name": "김아라" }
실패 코드
계정 없음 · 비밀번호 틀림 · 사내 도메인이 아닌 주소 · 메일 엔진 관리자 계정(웹메일 로그인 대상이 아니다) (구분하지 않는다) 401 AUTH_004
형식 오류 400 VAL_001
시도 과다 — 같은 IP 에서 1분에 실패 20회, 같은 주소로 15분에 실패 10회 (성공한 로그인은 세지 않는다 — 사무실처럼 여러 직원이 한 IP 를 쓰는 환경 때문). 이와 별도로 같은 IP 의 로그인 요청은 1분에 60회까지. "같은 IP" 는 IPv6 를 /64 단위로 묶는다(IPv4-mapped 주소는 IPv4 로). 사내 도메인이 아닌 주소는 IP 한도에만 센다 429 REQ_001
같은 주소의 로그인이 아직 처리 중 (버튼을 두 번 누른 경우 등) 429 REQ_001
대량 공격으로 서버가 기억하는 시도 기록이 상한에 닿았고 밀어낼 수 있는 기록이 없음 — 새 IP·주소의 로그인 (잠금 기록을 지키려고 막는 쪽을 택한다) 429 REQ_001
메일 엔진 장애 503 MAIL_001

GET /api/auth/me

앱 시작 시 세션 복원용. 200 { "email", "name" } / 로그인 안 됨 401 AUTH_001.

POST /api/auth/refresh

리프레시 쿠키로 두 쿠키를 새로 발급한다(회전). 200 { "email", "name" }.

실패 응답 세션·쿠키
쿠키 없음·만료·끊긴 세션, 메일 엔진이 계정을 찾지 못함(대리 접근 401), 그 계정이 엔진 관리 권한을 얻음 401 AUTH_001 쿠키 삭제·세션 폐기
메일 엔진 장애, 게이트웨이의 대리 접근 권한 문제(엔진 403 — 설정 실수) 503 MAIL_001 유지 — 설정을 고치면 그대로 이어 쓴다
서버 쪽 드문 불일치(회전 충돌) 500 SYS_001 유지 — 같은 쿠키로 다시 시도할 수 있다

세션 규칙 (서버에 저장한다)

  • 로그인하면 서버에 세션이 하나 생긴다. 리프레시 토큰은 1회용이다 — 갱신할 때마다 새 토큰을 주고 이전 토큰은 폐기한다.
  • 이미 쓴 리프레시 토큰이 다시 오면 탈취로 보고 그 세션 전체를 끊는다 → 401 AUTH_001.
    • 예외(유예): 회전 직후 30초 안에 바로 이전 토큰이 오면(탭 여러 개가 동시에 갱신한 경우) 세션을 끊지 않고 200 { "email", "name" } 으로 답하되 쿠키는 새로 주지 않는다 — 먼저 갱신한 요청이 받은 쿠키를 같이 쓴다. 두 번 이상 지난 토큰이나 30초가 지난 이전 토큰은 위 규칙대로 세션을 끊는다.
  • 세션은 로그인 후 최대 30일(절대 만료)이다. 그 안에서도 리프레시 토큰을 14일 동안 쓰지 않으면 끝난다.
  • 액세스 토큰은 서버 조회 없이 검증한다. 그래서 세션을 끊어도 이미 발급된 액세스 토큰은 만료(최대 1시간)까지 유효하다.

POST /api/auth/logout

항상 200 (data: null). 두 쿠키를 지우고 서버의 세션을 폐기한다.

클라이언트 처리 규칙

  • 보호 API 가 401 AUTH_001 을 주면 한 번만 /auth/refresh 를 시도하고, 성공하면 원래 요청을 다시 보낸다. 실패하면 기존 규칙대로 AUTH_002 로 승격하고 로그인 화면으로 보낸다. /auth/login · /auth/refresh · /auth/me · /auth/logout 은 이 재시도 대상이 아니다.
  • /auth/refresh 의 200 은 Set-Cookie 가 없어도 성공으로 다룬다 (위 유예 응답).
  • 세션이 끝난 것은 /auth/refresh 의 401 AUTH_001 뿐이다. 5xx(MAIL_001·SYS_001)·네트워크 오류는 일시 장애로 알리고 로그아웃으로 다루지 않는다.
  • 로그인 버튼은 응답이 올 때까지 다시 누를 수 없게 한다 — 같은 주소로 동시에 보낸 두 번째 요청은 429 REQ_001 이다.
  • 로그아웃하면 같은 브라우저의 다른 탭에도 알린다(BroadcastChannel). 알림을 받은 탭은 로그인 화면으로 간다.
  • 로그아웃(어느 탭에서든) 뒤에 도착한 /auth/refresh 성공은 쓰지 않고 /auth/logout 을 한 번 더 부른다. 갱신과 로그아웃이 겹치면 서버가 로그아웃 뒤에 새 쿠키를 구울 수 있기 때문이다 (세션은 끊겼어도 액세스 토큰은 만료까지 유효).
  • 앱 시작 시 /auth/me 로 세션을 복원한다. 이 호출의 AUTH_001 은 로그인 전 정상 상태이므로 알림을 띄우지 않는다.
  • 복원 중 /auth/me 가 AUTH_001 이면 액세스 토큰만 만료됐을 수 있으므로 /auth/refresh 를 알림 없이 한 번 부르고, 성공하면 복원 완료, 실패하면 로그아웃 상태로 시작한다. → 백엔드는 액세스 쿠키가 없거나 만료돼도 리프레시 쿠키만으로 /auth/refresh 가 동작해야 한다.

2. 메일 (모두 로그인 필요 — 없으면 401 AUTH_001)

타입

interface Address { name: string | null; email: string }

interface Mailbox {
  id: string
  name: string            // 역할이 있으면 한국어 표준 이름, 없으면 서버의 이름
  role: 'inbox' | 'drafts' | 'sent' | 'junk' | 'trash' | 'archive' | null
  parentId: string | null
  sortOrder: number
  totalCount: number
  unreadCount: number
}

interface MessageSummary {
  id: string
  threadId: string
  mailboxIds: string[]
  from: Address | null
  to: Address[]
  subject: string         // 제목이 없으면 빈 문자열
  preview: string
  receivedAt: string
  isRead: boolean
  isFlagged: boolean
  hasAttachment: boolean
  size: number            // 바이트
}

interface MessageDetail extends MessageSummary {
  cc: Address[]
  bcc: Address[]
  replyTo: Address[]
  sentAt: string | null
  messageId: string | null    // Message-ID 헤더 (답장 스레드 연결용)
  references: string[]
  htmlBody: string | null     // 서버가 정화한 HTML (아래 참고)
  textBody: string | null
  hasBlockedImages: boolean   // 외부 이미지를 지웠으면 true → "외부 이미지 차단" 안내
  attachments: Attachment[]
}

interface Attachment { id: string; name: string; type: string; size: number }

역할별 한국어 이름: inbox 받은편지함 · drafts 임시보관함 · sent 보낸편지함 · junk 스팸메일함 · trash 휴지통 · archive 보관함.

htmlBody 정화 규칙 (서버 책임): 스크립트·스타일 태그·이벤트 핸들러·폼·iframe·object·embed·svg 제거, 링크는 http · https · mailto 만 남기고 새 창(target="_blank" rel="noopener noreferrer"), 외부(http/https) 이미지는 제거하고 hasBlockedImages = true. 본문 안 인라인 이미지(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 조건부 주석은 한도 안이다.

엔드포인트

메서드 · 경로 요청 성공 주요 실패
GET /api/mailboxes — 200 { items: Mailbox[], pageInfo } (hasNext=false) MAIL_001
GET /api/mail-quota — 200 MailQuota MAIL_001
GET /api/messages mailboxId(필수) · cursor · limit(기본 20, 최대 100) · q(선택) 200 { items: MessageSummary[], pageInfo } — 받은 시각 내림차순, total 포함 400 VAL_001, MAIL_001
GET /api/messages/{id} — 200 MessageDetail — 조회만으로 읽음 처리하지 않는다 404 RES_001
PATCH /api/messages/{id} { isRead?, isFlagged?, mailboxId? } (하나 이상) 200 MessageSummary 404 RES_001, 400 VAL_001
DELETE /api/messages/{id} — 204 — 휴지통이 아니면 휴지통으로 이동, 휴지통이면 영구 삭제 404 RES_001
POST /api/messages 아래 발송 본문 + Idempotency-Key 헤더 201 { id } (보낸편지함의 메일 id) 400 MAIL_002·MAIL_003·MAIL_004·MAIL_006·VAL_001, 403 AUTH_003(보낼 권한 없음), 409 MAIL_005(같은 키·다른 내용), 413 FILE_002(첨부 합계 초과 포함), 429 REQ_001(엔진 발송 한도·같은 키 처리 중·엔진 장애 직후 30초), 503 MAIL_001
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

표에 없는 실패 (구현에서 확정):

상황 응답
없는 mailboxId, 엔진이 해석하지 못하는 mailboxId 404 RES_001
형식이 틀린 경로 id 404 RES_001
받는 사람 합계 100명 초과 (배열 하나가 100을 넘으면 VAL_001) 400 MAIL_002
엔진에서 찾을 수 없는 첨부 id — 올려 두고 오래 지나 만료된 업로드, 그사이 지워진 원본 메일의 첨부 등 400 MAIL_004 — 클라이언트는 해당 첨부를 실패로 표시한다. 올린 파일은 다시 올리게, 전달하는 원본 첨부는 빼게 한다 (원본은 다시 올릴 수 없다)
첨부 20개 초과 400 MAIL_006 — 클라이언트는 첨부를 추가할 때 미리 막는다
JSON 본문 1MB 초과 (발송 POST /api/messages 만 4MB — 서식 본문과 긴 인용 때문) 413 FILE_002

메일함 정렬(확정 디자인 기준): 받은편지함 → 보낸편지함 → 임시보관함 → 스팸메일함 → 휴지통 → 보관함 → 그 외(sortOrder, 이름 순).

// GET /api/mail-quota — 메일함 패널 하단 사용량 표시와 첨부 사전 검사에 쓴다
interface MailQuota {
  usedBytes: number
  limitBytes: number | null    // 한도가 없으면 null → "사용량 0.9GB" 처럼 사용량만 표시
  attachmentMaxBytes: number   // 서버의 MAIL_ATTACHMENT_MAX_SIZE_MB — 클라이언트는 상수 대신 이 값으로 사전 검사·안내한다
  attachmentTotalMaxBytes: number // 메일 한 통의 첨부 합계 한도 — MAIL_ATTACHMENT_TOTAL_MAX_SIZE_MB 와 엔진 한도 중 작은 값 (발송 때 검사하는 값과 같다)
}

cursor 는 불투명 문자열이다. 클라이언트는 해석하지 말고 pageInfo.nextCursor 를 그대로 다시 보낸다.

발송 본문 (POST /api/messages)

중복 발송 방지 — Idempotency-Key 요청 헤더: 클라이언트는 쓰기 화면마다 무작위 키(UUID) 하나를 만들어 보내고, 같은 메일을 다시 보낼 때(타임아웃 뒤 재시도 등)에도 같은 키를 보낸다. 서버는 같은 사용자·같은 키로 이미 발송된 메일이 있으면 다시 보내지 않고 그 결과(201 { id })를 그대로 돌려준다. 응답이 늦어 MAIL_001 을 받았어도 실제로는 나갔을 수 있으므로, 재시도는 반드시 같은 키로 한다. (키 형식이 틀리면 400 VAL_001. 키가 없으면 중복 방지 없이 보낸다.)

  • 같은 키의 요청이 아직 처리 중이면 기다리지 않고 429 REQ_001 — 같은 메일이 동시에 두 번 나가지 않게 한다. 엔진 장애(MAIL_001)로 끝난 키도 30초 동안은 처리 중으로 본다 (시간 초과라면 엔진이 아직 보내는 중일 수 있다). 클라이언트는 이 429 에 같은 키로 즉시 자동 재시도하지 않고, 잠시 뒤 사용자가 다시 보내게 한다(키는 그대로).
  • 같은 키인데 내용이 다르면(실패 뒤 받는 사람·제목·본문·첨부를 고친 경우) 보내지 않고 409 MAIL_005. 첫 메일이 실제로 나갔을 수 있으므로, 클라이언트는 사용자에게 보낸편지함을 확인하게 하고 새 키로 다시 보낼 수 있게 한다. 서버는 첫 발송 때 내용 지문(Idempotency-Key 를 키로 한 HMAC — 받는 사람은 키를 모른다)을 함께 남겨 비교한다.
    • 지문에 들어가는 것: 받는 사람·참조·숨은참조의 주소(대소문자·순서 무시), 제목, 본문(줄바꿈 정규화), 서식 본문(서버가 정화한 HTML), 첨부 id(순서 무시), inReplyTo.
    • 들어가지 않는 것: 표시 이름, 첨부 이름 — 이것만 바꾼 재시도는 "같은 내용"으로 보고 처음 결과를 돌려준다.
  • 알려진 한계 (1단계는 발송 기록을 따로 저장하지 않고 보낸편지함에서 찾는다):
    • 첫 발송 뒤 보낸 사본을 다른 메일함으로 옮기거나 지웠으면, 또는 그사이 보낸편지함·임시보관함에 200통 넘게 쌓였으면 같은 키의 재시도가 다시 나갈 수 있다.
    • "처리 중" 표시는 서버 메모리에 있어 서버가 재시작되면 사라진다. 엔진 장애 직후의 드문 경합에서도 두 번 나갈 수 있다.
    • 서식 본문의 지문은 정화한 결과로 만든다 — 첫 발송과 재시도 사이에 서버의 정화 규칙이 바뀌면(배포) 같은 키의 재시도가 MAIL_005 로 막힐 수 있다. 그때도 위 409 처리(보낸편지함 확인 → 새 키)를 따른다.
    • 이 한계를 없애려면 (사용자, 키) → (지문, 메일 id) 를 서버 DB 에 저장한다 — 서버를 여러 대로 늘릴 때 함께 검토한다.
{
  "to":  [{ "name": "이도윤", "email": "doyun.lee@arasystem.it.kr" }],  // 1개 이상
  "cc":  [],                                                              // 선택
  "bcc": [],                                                              // 선택 (받는 사람 합계 100 이하)
  "subject": "Re: 메일 서버 이전 일정 공유",                               // 255자 이하
  "body": "도윤님, 확인했습니다.\n…",                                       // 일반 텍스트(서식 본문의 대체 본문), 100,000자 이하
  "bodyHtml": "<div style=\"font-family:…\"><p>도윤님, 확인했습니다.</p>…</div>", // 선택 — 서식 편집기 본문, 500,000자 이하 (아래 "서식 본문")
  "attachments": [{ "id": "<업로드 응답 id>", "name": "체크리스트.xlsx" }], // 선택, 20개 이하 (넘으면 MAIL_006)
  "inReplyTo": "<답장할 메일의 id>"                                        // 선택 — 스레드 헤더를 서버가 채운다
}

전달: 원본 메일 attachments[].id 를 발송 본문의 attachments 에 그대로 넣으면 원본 첨부가 다시 붙는다 (같은 계정 안의 파일이라 다시 올릴 필요가 없다). 제목 Fwd: 와 인용 본문은 클라이언트가 만들고, inReplyTo 는 보내지 않는다. 발송 본문의 첨부 이름(attachments[].name)은 업로드한 파일과 같은 규칙으로 확장자를 허용 목록에서 검사한다(밖이면 400 FILE_001) — 원본 첨부를 그대로 전달할 때도 같다.

답장·전달 초안의 길이: 클라이언트가 초안을 만들 때 한도에 맞춘다 — 제목은 255자, 본문은 텍스트 100,000자·서식 본문 500,000자. 넘으면 인용을 줄이고 끝에 생략 표시를 둔다 (넘긴 채 보내면 400 VAL_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)를 편집기로 옮기며, 편집기가 모르는 요소(이미지 등)는 빠진다.
  • 본문 안 이미지는 이번 단계에서 넣지 않는다 — 붙여 넣거나 끌어다 놓은 이미지는 첨부 파일로 올린다 (첨부 한도 규칙 그대로).

첨부 업로드 한도

  • 파일당 MAIL_ATTACHMENT_MAX_SIZE_MB (기본 20MB) — 넘으면 413 FILE_002
  • 메일 한 통의 첨부 합계(전달하는 원본 첨부 포함, 같은 첨부를 두 번 붙이면 두 번 센다) attachmentTotalMaxBytes (MAIL_ATTACHMENT_TOTAL_MAX_SIZE_MB, 기본 25MB) — 넘으면 발송이 413 FILE_002. 클라이언트는 첨부를 추가할 때 이 값으로 미리 막고 안내한다 (다 쓰고 보낼 때 알게 하지 않는다).
  • 허용 형식(확장자 + MIME 둘 다 검사): pdf · doc/docx · xls/xlsx · ppt/pptx · hwp/hwpx · txt · csv · png · jpg/jpeg · gif · webp · zip
  • 다운로드는 <a href> 이동으로 받는다 — 같은 출처라 쿠키가 함께 간다. 이동은 axios 를 거치지 않아 401 재시도 규칙이 적용되지 않으므로, 이동하기 전에 세션을 확인한다 (/auth/me 가 AUTH_001 이면 /auth/refresh 한 번 → 실패하면 세션 만료 처리).

이 단계에서 하지 않는 것

실시간 새 메일 알림(SSE), 검색 화면, 임시 저장(Drafts 쓰기), 본문 안 이미지(인라인 cid — 붙여 넣은 이미지는 첨부로), 외부 이미지 보기(이미지 중계), 다중 선택 일괄 처리.