# 인증·메일 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` `MAIL_007` `MAIL_008` `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`
```jsonc
// 요청
{ "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`)
### 타입
```ts
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 // 본문 그림(아래)만 있으면 false — 서명 그림만 붙은 메일은 첨부 표시를 하지 않는다
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 → "외부 이미지 차단" 안내
isHtmlBodyOmitted: boolean // HTML 이 있었지만 정화 한도를 넘어 htmlBody 를 비웠으면 true (아래 "읽기 한도")
isBodyTruncated: boolean // 보여 줄 본문(htmlBody — null 이거나 공백뿐이면 textBody)이 읽기 한도에서 잘렸으면 true
attachments: MessageAttachment[]
}
interface Attachment { id: string; name: string; type: string; size: number }
interface MessageAttachment extends Attachment {
isInline: boolean // 돌려준 htmlBody 안에 그림으로 들어갔다(cid → data:) — 읽기 화면의 첨부 목록에서 뺀다. 전달할 때는 함께 붙인다
}
```
역할별 한국어 이름: `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 로 바꿔 넣고 나머지는 지운다.
넣은 그림은 `attachments` 에 `isInline: true` 로 남는다 — 읽기 화면은 첨부 목록에서 빼고, 전달은 원문 그림을 잃지 않게 함께 붙인다
(인용 본문에서는 그림이 빠지기 때문이다). 넣지 못한 그림(크거나 많거나 읽지 못함)은 `isInline: false` 로 첨부 목록에 보인다.
**클라이언트는 그래도 격리된 iframe 에만 그린다** (스크립트 비허용 sandbox + 내부 CSP — 이중 방어).
**`hasAttachment`** 는 엔진 값이 아니라 서버가 첨부 부분의 머리로 정한다 — "본문 그림"이 아닌 첨부가 하나라도 있으면 `true`.
본문 그림 = 본문에 넣을 수 있는 그림 형식(PNG·JPEG·GIF·WebP) · `Content-ID` 있음 · `Content-Disposition` 이 `attachment` 가 아님(`inline` 이거나 없음) ·
크기 1바이트~300KB. 목록은 본문을 읽지 않으므로 이 판정은 "실제로 본문에 들어갔나"(`isInline`)와 드물게 다를 수 있다
(본문이 가리키지 않는 그런 그림, 20장·합계 2MB 를 넘는 본문 그림 — 목록은 클립이 없는데 상세의 첨부 목록에는 보인다).
**읽기 한도**: 상세 조회는 본문 한 부분을 UTF-8 2,000,000바이트까지 읽는다 — 넘는 부분은 잘린 채 오고, 보여 줄 본문이 잘렸으면 `isBodyTruncated: true` 다.
HTML 은 정화하기 **전에** 길이(HTML 부분이 여럿이면 이어 붙인 것이 2,000,000자 이하)와 파서 비용을 본다 —
약 2만 단을 넘는 중첩이나, 깊게 열린 태그 아래로 짝 없는 닫는 태그를 아주 많이 둔 HTML 은 정화가 서버를 수십 초씩 붙잡기 때문이다.
넘으면 정화하지 않고 `htmlBody: null`·`hasBlockedImages: false`·`isHtmlBodyOmitted: true` 로 내려준다. `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/mail-state` | — | 200 `MailState` (아래 "새 메일 확인") | 503 `MAIL_001` |
| `GET /api/messages` | `mailboxId` · `cursor` · `limit`(기본 20, 최대 100) · `q` — `mailboxId` 와 `q` 중 하나는 있어야 한다(`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` |
| `PATCH /api/messages` | `{ ids, isRead?, isFlagged?, mailboxId? }` — 여러 통 (아래 "여러 통 한꺼번에") | 200 `MessagesPatched` | 400 `VAL_001`, 404 `RES_001`(없는 `mailboxId` — 아무것도 바꾸지 않음), 503 `MAIL_001` |
| `DELETE /api/messages/{id}` | — | 204 — 휴지통이 아니면 휴지통으로 이동, 휴지통에만 있으면 영구 삭제(확인한 상태 그대로일 때만 — 아래). 휴지통과 다른 메일함에 함께 있으면 휴지통에서만 뺀다. **웹 클라이언트는 쓰지 않는다**(3단계 — 아래 "여러 통 한꺼번에") | 404 `RES_001`, 503 `MAIL_001`(확인하는 사이 계속 바뀜 — 지우지 않았다) |
| `POST /api/message-deletions` | `{ ids }` — 휴지통에서 영구 삭제, 여러 통 (아래 "여러 통 한꺼번에") | 200 `MessageDeletionResult` | 400 `VAL_001`, 503 `MAIL_001`(확인하는 사이 계속 바뀜 · 휴지통 없음 — 지우지 않았다) |
| `POST /api/messages` | 아래 발송 본문 + `Idempotency-Key` 헤더 | 201 `{ id }` (보낸편지함의 메일 id) | 400 `MAIL_002`·`MAIL_003`·`MAIL_004`·`MAIL_006`·`MAIL_007`·`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` |
| `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` |
| `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` |
| `POST /api/mail-signature/images` | multipart `file` 1개 | 201 `SignatureImage` | 400 `FILE_001`·`VAL_001`(올려 둔 그림 10장 초과), 413 `FILE_002`(300KB 초과) |
| `GET /api/mail-signature/images/{id}` | — | 그림 파일 (표준 래퍼 없음, 본인 그림만) | 404 `RES_001` |
| `GET /api/recipient-suggestions` | `q`(선택, 1~100자) · `limit`(기본 20, 최대 100) · `cursor` | 200 `{ items: RecipientSuggestion[], pageInfo }` (아래 "받는 사람 추천") | 400 `VAL_001`, 503 `MAIL_001` |
표에 없는 실패 (구현에서 확정):
| 상황 | 응답 |
| --- | --- |
| 없는 `mailboxId`, 엔진이 해석하지 못하는 `mailboxId` | 404 `RES_001` |
| 엔진이 해석하지 못하는 id — 여러 통 처리의 `ids`(`PATCH /api/messages` · `POST /api/message-deletions`), 메일함 없이 하는 검색 | 400 `VAL_001` (본문에서 온 값이라서 — 메일함 하나의 목록은 위처럼 404 `RES_001`) |
| 형식이 틀린 경로 id | 404 `RES_001` |
| 받는 사람 합계 100명 초과 (배열 하나가 100을 넘으면 `VAL_001`) | 400 `MAIL_002` |
| 엔진에서 찾을 수 없는 첨부 id — 올려 두고 오래 지나 만료된 업로드, 그사이 지워진 원본 메일의 첨부 등 | 400 `MAIL_004` — 클라이언트는 해당 첨부를 실패로 표시한다. 올린 파일은 다시 올리게, 전달하는 원본 첨부는 빼게 한다 (원본은 다시 올릴 수 없다) |
| 첨부 20개 초과 | 400 `MAIL_006` — 클라이언트는 첨부를 추가할 때 미리 막는다 |
| 본문이 너무 김 — 텍스트 100,000자·서식 본문 500,000자 초과(JS 문자열 길이, UTF-16 단위), 서식 본문이 정화 전 비용 한도·정화 뒤 2,000,000바이트 초과 | 400 `MAIL_007` — 메일은 나가지 않는다. 클라이언트는 보내기 전에 같은 한도로 막고 본문 아래에 알린다 (아래 "서식 본문"의 한도) |
| JSON 본문 1MB 초과 (발송 `POST /api/messages` 와 임시 저장 `POST /api/drafts`·`PUT /api/drafts/{id}` 만 4MB — 서식 본문과 긴 인용 때문) | 413 `FILE_002` |
메일함 정렬(확정 디자인 기준): 받은편지함 → 보낸편지함 → 임시보관함 → 스팸메일함 → 휴지통 → 보관함 → 그 외(`sortOrder`, 이름 순).
```ts
// 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`. 키가 없으면 중복 방지 없이 보낸다.)
서버는 발송 요청에 55초 안에 답한다 — 앞 단계(첨부 확인 · 서명 그림 올리기)가 늦어 보낼 시간이 10초도 남지 않으면 메일을 만들지 않고 503 `MAIL_001`
(아무것도 나가지 않았으니 같은 키로 다시 보내면 된다). 보낸 뒤 이어 쓴 저장본 지우기는 5초까지만 기다리고, 넘으면 먼저 답한 뒤 마저 지운다.
- 같은 키의 요청이 **아직 처리 중**이면 기다리지 않고 429 `REQ_001` — 같은 메일이 동시에 두 번 나가지 않게 한다.
엔진 장애(`MAIL_001`)로 끝난 키도 **30초 동안은 처리 중**으로 본다 (시간 초과라면 엔진이 아직 보내는 중일 수 있다).
클라이언트는 이 429 에 **같은 키로 즉시 자동 재시도하지 않고**, 잠시 뒤 사용자가 다시 보내게 한다(키는 그대로).
- 같은 키인데 **내용이 다르면**(실패 뒤 받는 사람·제목·본문·첨부를 고친 경우) 보내지 않고 409 `MAIL_005`.
첫 메일이 실제로 나갔을 수 있으므로, 클라이언트는 사용자에게 보낸편지함을 확인하게 하고 **새 키**로 다시 보낼 수 있게 한다.
서버는 첫 발송 때 내용 지문(`Idempotency-Key` 를 키로 한 HMAC — 받는 사람은 키를 모른다)을 함께 남겨 비교한다.
- 지문에 들어가는 것: 받는 사람·참조·숨은참조의 **주소**(대소문자·순서 무시), 제목, 본문(줄바꿈 정규화), 서식 본문(서버가 정화한 HTML), 첨부 id(순서 무시), `inReplyTo`.
- 들어가지 않는 것: 표시 이름, 첨부 이름 — 이것만 바꾼 재시도는 "같은 내용"으로 보고 처음 결과를 돌려준다.
- **알려진 한계** (1단계는 발송 기록을 따로 저장하지 않고 보낸편지함에서 찾는다):
- 첫 발송 뒤 보낸 사본을 다른 메일함으로 옮기거나 지웠으면, 또는 그사이 보낸편지함·임시보관함에 200통 넘게 쌓였으면 같은 키의 재시도가 **다시 나갈 수 있다**.
- "처리 중" 표시는 서버 메모리에 있어 서버가 재시작되면 사라진다. 엔진 장애 직후의 드문 경합에서도 두 번 나갈 수 있다.
- 서식 본문의 지문은 **정화한 결과**로 만든다 — 첫 발송과 재시도 사이에 서버의 정화 규칙이 바뀌면(배포) 같은 키의 재시도가
`MAIL_005` 로 막힐 수 있다. 그때도 위 409 처리(보낸편지함 확인 → 새 키)를 따른다.
- 이 한계를 없애려면 (사용자, 키) → (지문, 메일 id) 를 서버 DB 에 저장한다 — 서버를 여러 대로 늘릴 때 함께 검토한다.
```jsonc
{
"to": [{ "name": "이도윤", "email": "doyun.lee@arasystem.it.kr" }], // 1개 이상
"cc": [], // 선택
"bcc": [], // 선택 (받는 사람 합계 100 이하)
"subject": "Re: 메일 서버 이전 일정 공유", // 255자 이하
"body": "도윤님, 확인했습니다.\n…", // 일반 텍스트(서식 본문의 대체 본문), 100,000자 이하
"bodyHtml": "
", // 선택 — 서식 편집기 본문, 500,000자 이하 (아래 "서식 본문")
"attachments": [{ "id": "<업로드 응답 id>", "name": "체크리스트.xlsx" }], // 선택, 20개 이하 (넘으면 MAIL_006)
"inReplyTo": "<답장할 메일의 id>", // 선택 — 스레드 헤더를 서버가 채운다
"draftId": "<이어 쓴 임시 저장본 id>" // 선택 — 보낸 뒤 서버가 그 초안을 지운다 (아래 "임시 저장")
}
```
**전달**: 원본 메일 `attachments[].id` 를 발송 본문의 `attachments` 에 그대로 넣으면 원본 첨부가 다시 붙는다
(같은 계정 안의 파일이라 다시 올릴 필요가 없다). 제목 `Fwd:` 와 인용 본문은 클라이언트가 만들고, `inReplyTo` 는 보내지 않는다.
발송 본문의 첨부 이름(`attachments[].name`)은 업로드한 파일과 같은 규칙으로 확장자를 허용 목록에서 검사한다(밖이면 400 `FILE_001`) — 원본 첨부를 그대로 전달할 때도 같다.
**답장·전달 초안의 길이**: 클라이언트가 초안을 만들 때 한도에 맞춘다 — 제목은 255자, 본문은 텍스트 100,000자·서식 본문 500,000자.
넘으면 인용을 줄이고 끝에 생략 표시를 둔다 (본문을 넘긴 채 보내면 400 `MAIL_007`, 제목은 `VAL_001`).
처음 맞출 때는 쓸 글 몫으로 한도의 5%를 남기고(텍스트 95,000자·서식 475,000자), 보내기 직전에 실제로 보낼 본문을 다시 재서 넘으면 인용을 더 줄인다.
인용 없이 쓴 글만으로 넘으면 보내지 않고 쓰기 화면의 본문 아래에 `MAIL_007` 문구로 알린다 (서버가 `MAIL_007` 을 돌려줘도 같다).
**서식 본문 (`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` 만 8~99.99px, 색은 `#rgb`·`#rrggbb`·`rgb()`·`rgba()`(색 이름·`hsl()` 은 지운다) — 글자 색(`color`)의 투명도(알파)는 0.3 이상,
여백은 `0` 또는 `px`(음수 없음),
줄 간격은 단위 없는 숫자 1~3(소수 둘째 자리까지), 글꼴은 쓰기 화면의 글꼴 목록과 흔한 대체 이름(`Helvetica`·`Times`·`Courier`·
`AppleGothic`·`AppleMyungjo`)·일반 이름(`sans-serif`·`serif`·`monospace`)으로만 이뤄진 목록.
- 형광 표시는 `mark` 가 아니라 span 의 `background-color` 로 보낸다 (`mark`·`h1` 같은 모르는 태그는 태그만 지우고 글자는 남긴다).
- 정화하고 남는 것이 없으면 `body` 만으로 텍스트 메일을 보낸다.
- **그림은 보내는 사람 자신의 서명 그림만** 남는다 — `
` 이고 그 id 가 보내는 사람의 그림일 때.
속성은 `src`·`width`(1~600, 숫자만)·`alt`(200자 이하)만 남는다. 서버가 그 그림을 메일 안에 함께 실어(`multipart/related`, `cid:`) 보내므로
받는 쪽에서 외부 이미지처럼 막히지 않는다. 그 밖의 `img`·찾지 못한 서명 그림(지운 그림)은 태그째 지운다. 클라이언트 편집기도 이 그림만 받는다.
- 보이지 않을 만큼 작은 글자(8px 미만)·거의 투명한 글자 색(알파 0.3 미만)은 그 선언을 지운다 — 붙여 넣거나 인용한 글에 그렇게 숨긴 글이
그대로 실려 나가지 않게. 바탕과 같은 색(흰 바탕 위 흰 글자 등)은 바탕을 알 수 없어 막지 않는다
(받은 메일을 읽을 때의 정화는 이 제한을 두지 않는다).
- 한도 — 넘으면 400 `MAIL_007`. 엔진·첨부 조회 **전**이라 메일은 나가지 않으므로, 고친 뒤 **같은 키**로 다시 보내면 된다.
정화 전에는 받은 메일과 같은 파서 비용 한도(위 "읽기 한도")를, 정화 뒤에는 결과가 UTF-8 2,000,000바이트 이하인지 본다
(우리 웹메일이 다시 읽을 때 잘리지 않게). 500,000자 안이어도 넘을 수 있다 — 정화가 `&` 를 `&`(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
- 다운로드는 `` 이동으로 받는다 — 같은 출처라 쿠키가 함께 간다.
이동은 axios 를 거치지 않아 401 재시도 규칙이 적용되지 않으므로, **이동하기 전에 세션을 확인**한다
(`/auth/me` 가 `AUTH_001` 이면 `/auth/refresh` 한 번 → 실패하면 세션 만료 처리).
### 임시 저장 (`/api/drafts`)
```ts
interface Draft {
id: string // 저장할 때마다 바뀐다 — 응답의 id 로 바꿔 쓴다
to: Address[]
cc: Address[]
bcc: Address[]
subject: string
body: string // 텍스트 대체 본문
bodyHtml: string | null // 서식 본문 — 보내는 정화를 거친 값 (없거나 비면 null). 다른 메일 프로그램이 만든 초안도 같은 정화를 거친다
attachments: Attachment[] // 이 판의 첨부 — id 가 저장할 때마다 바뀐다
inReplyTo: string | null // 답장할 메일 id (없거나 원 메일이 사라졌으면 null)
updatedAt: string // 마지막으로 저장한 시각 (ISO 8601 UTC)
}
interface DraftSaved { id: string; attachments: Attachment[]; updatedAt: string }
```
- **초안 본문**은 발송 본문과 같은 필드·한도다(본문 길이 `MAIL_007`, 첨부 20개 `MAIL_006`, 첨부 합계 `FILE_002`, JSON 본문 4MB …). 다만 받는 사람은 0명이어도 되고
(합계 100명 이하), `draftId` 는 없다.
- **저장**: 처음은 `POST`, 그다음은 `PUT /api/drafts/{직전 id}`. 메일은 고칠 수 없어 서버가 새 초안을 만들고, 다시 읽어 확인한 뒤 옛것을 지운다 —
클라이언트는 응답의 `id`·`attachments` 로 바꿔 쓴다. 서버는 초안에 발송 중복 방지용 Message-ID·지문 헤더를 넣지 않는다.
만들기와 지우기를 한 요청에 넣지 않는다 — 엔진은 같은 요청에서 만들기가 실패해도 지우기를 그대로 해서, 저장에 실패한 순간 저장본이 사라진다.
- "임시보관함의 초안" = 임시보관함에**만** 있고 `$draft` 이며 보내기 중에 생긴 내부 초안이 아닌 메일. PUT 의 id 가 그렇지 않으면(휴지통·보낸 메일 등)
아무것도 만들거나 지우지 않고 404 `RES_001`.
- 옛 초안이 이미 없으면(다른 창·기기에서 먼저 고쳤거나 지웠음) 409 `MAIL_008` — 처음 확인에서 없으면 아무것도 만들지 않고, 만든 뒤 지우는 사이에
없어졌으면 방금 만든 것을 지운다.
- 클라이언트는 `MAIL_008` 과 PUT 의 404 `RES_001`(다른 창에서 휴지통으로 옮긴 경우 등)을 모두 막지 않고, 지금 내용을 `POST` 로 새로 남긴다.
- 옛것은 **확인한 뒤로 메일함이 바뀌지 않았을 때만** 지운다(JMAP `ifInState`, 엇갈리면 다시 확인 — 모두 세 번까지). 지우기는 되돌릴 수 없어서다.
그사이 다른 곳에서 옮겼으면 지우지 않고, 계속 엇갈리면 옛것이 한 통 더 남는다 (저장은 성공).
- **목록·버리기·되돌리기**는 기존 API 다 — 목록 `GET /api/messages?mailboxId=<임시보관함>`(보내기 중에 생긴 내부 초안은 나오지 않는다),
버리기는 휴지통으로 옮기기 `PATCH /api/messages/{id}` `{ mailboxId: <휴지통> }`(웹은 `DELETE` 를 쓰지 않는다 — 이미 휴지통인 메일은 `DELETE` 가 영구 삭제한다),
되돌리기 `PATCH /api/messages/{id}` `{ mailboxId: <임시보관함> }`.
- **보내기**: 이어 쓴 초안은 발송 본문에 `draftId` 를 넣어 보낸다. 서버는 지금의 발송 경로(정화·지문·중복 방지 그대로)로 보내고, 성공한 뒤
(같은 키 재시도로 처음 결과를 돌려줄 때 포함) 그 초안을 지운다 — 초안 자체를 그대로 제출하지 않는다. 지우기는 위 저장과 같은 조건(임시보관함의 초안 ·
확인한 뒤 바뀌지 않음)일 때만 하고, 못 지우면 로그만 남긴다(발송은 성공). `draftId` 는 지문에 넣지 않는다.
- **클라이언트 저장 주기**: 바꾼 내용이 있을 때 입력이 3초 멈추면 · 쉬지 않고 쓰는 중에도 30초마다 · 10초에 한 번까지 · 화면을 떠날 때, 한 번에 하나씩.
찾지 못한 첨부(`MAIL_004`)가 남은 동안과 본문이 한도를 넘는 동안은 저장하지 않는다 (첨부가 빠진 판으로 저장본을 덮지 않게).
다른 메일 프로그램이 만든 저장본에 실을 수 없는 첨부(허용 목록 밖 · 개수 · 합계 초과)가 있으면 첫 저장은 PUT 이 아니라 POST 다 — 원본을 지우지 않는다.
보내기 전에는 진행 중인 저장을 기다린다 (그래야 `draftId` 가 최신이다).
- **알려진 한계**:
- 저장할 때마다 첨부 내용이 새 초안으로 복사돼 큰 첨부가 있으면 저장이 무겁다. 메일함 용량이 거의 차면 교체하는 순간 두 판이
함께 있어 `MAIL_003` 이 날 수 있다.
- 답장 대상은 초안의 사용자 머리 `X-Ara-Reply-To-Id`(메일 id)에 둔다. 우리 서버는 보낼 때 새 메일을 만들어 이 머리가 나가지 않지만,
다른 메일 프로그램이 그 초안을 그대로 보내면 함께 나갈 수 있다.
- 보내기에서 제출은 됐지만 보낸편지함으로 옮기지 못한 메일은 임시보관함에 보이지 않는 채로 남는다 (같은 키의 재시도는 그 메일을 찾는다).
- 저장 응답을 받지 못하면(시간 초과 등) 서버는 이미 새 저장본을 만들었을 수 있다 — 다음 PUT 은 `MAIL_008` 이 되어 클라이언트가 새로 남기므로
임시보관함에 한 통이 더 남을 수 있다 (잃는 내용은 없다).
### 서명 (`/api/mail-signature`)
```ts
interface MailSignature {
bodyHtml: string | null // 서명 서식 — 서식 본문 규칙 + 서명 그림. 정화 뒤 10,000자 이하
body: string | null // 텍스트 서명 (텍스트 대체 본문에 넣는다) — 2,000자 이하
isForNew: boolean // 새 메일에 넣는다 (기본 true)
isForReply: boolean // 답장·전달에 넣는다 — 인용 위 (기본 true)
images: SignatureImage[] // 서명이 쓰는 그림
updatedAt: string | null // 저장한 적 없으면 null
}
interface SignatureImage {
id: string
type: 'image/png' | 'image/jpeg' | 'image/gif'
size: number // 바이트
width: number // 원본 픽셀
height: number
}
```
- 사용자마다 하나다. 저장한 적 없으면 기본값(`bodyHtml: null`·`body: null`·둘 다 `true`·`images: []`)을 돌려준다. 서버 DB 에 둔다(메일 엔진에 두지 않는다).
- `PUT` 의 한도 — 넘으면 400 `VAL_001`: 텍스트 서명 2,000자, 서식 서명은 정화 전에 서식 본문과 같은 한도(500,000자·파서 비용)와 정화 뒤 10,000자,
서명 그림 3장(서로 다른 그림 기준).
- **서명 그림**: `POST /api/mail-signature/images` (multipart `file`) — PNG·JPEG·GIF 만(확장자·MIME·파일 앞부분 모두 검사, WebP·SVG 는 받지 않는다),
한 장 300KB 이하(넘으면 413 `FILE_002` — 받은 메일의 본문 그림 한도와 같아서, 보낸 메일을 우리 웹메일로 다시 읽어도 그림이 보인다),
가로·세로 각각 4096px 이하(넘으면 400 `FILE_001` — 작은 파일 안의 아주 큰 그림이 받는 쪽을 무겁게 하지 않게).
서명에는 3장까지 넣는다(편집기가 먼저 막는다). 발송 본문·임시 저장본도 서로 다른 서명 그림 3장까지다(넘으면 400 `VAL_001`).
올려 두기만 한 그림을 포함해 한 사람 10장까지 둘 수 있다(넘으면 올리기가 400 `VAL_001`).
서명 HTML 은 그림을 `
` 로 가리킨다(`width` 1~600 — 작게 96 · 보통 144 · 원본은 원래 폭, 600 이하).
- `GET /api/mail-signature/images/{id}` 는 그 사람의 그림만 내준다(남의 것은 404 `RES_001`) — 편집기·미리 보기용이고, 받는 사람은 이 주소를 쓰지 않는다.
저장할 때 확인한 형식 그대로, `Content-Disposition: inline` · `X-Content-Type-Options: nosniff` · `Cache-Control: private, max-age=86400`
(그림 id 의 내용은 바뀌지 않는다 — 새로 올리면 새 id).
- `PUT` 은 서명 HTML 이 더는 가리키지 않는 그림을 지운다 — 다만 한 번도 저장하지 않은 그림 가운데 10분 안에 올린 것은 남긴다
(그림을 넣자마자 저장하면 올리기와 저장이 엇갈릴 수 있다). 올리고 저장하지 않은 그림은 하루 뒤 지운다.
- **보낼 때**: 쓰기 화면이 서명을 본문에 보통 글로 넣어 둔 그대로 보낸다 — 서명 그림은 위 "서식 본문"의 그림 예외로 메일 안에 함께 실린다
(`multipart/related` 의 그림 부분 — `Content-Disposition: inline`, `Content-ID: `, 이름 `signature-1.png` …,
첨부가 있으면 그 묶음을 `multipart/mixed` 안에 둔다). 우리 웹메일에서는 위 `hasAttachment`·`isInline` 규칙으로 첨부로 보이지 않는다.
텍스트 대체 본문에는 그림이 없다. 임시 저장본도 같은 그림 주소를 쓴다.
- **알려진 한계**: 서명 그림을 지운 뒤 그 그림을 쓴 임시 저장본을 보내면 그 그림은 빠진다. 답장·전달로 인용한 원문 속 그림(남의 서명 로고 포함)은 인용에서 빠진다.
### 받는 사람 추천 (`/api/recipient-suggestions`)
```ts
interface RecipientSuggestion {
name: string | null
email: string
source: 'directory' | 'recent' // directory = 사내(웹메일에 로그인한 적 있는 직원), recent = 최근 주고받은 주소
}
```
- `q` 가 있으면 이름·주소에 그 글자가 든 것만 준다(대소문자 무시). 응답은 커서 페이지네이션 규칙 그대로(`{ items, pageInfo }`) —
커서는 받은 그 `q` 로만 쓴다(다른 `q` 와 함께 보내면 400 `VAL_001`).
- **순서**: 사내 → 최근 주고받은 주소, 각 묶음 안에서는 주고받은 횟수와 최근성으로. 두 쪽에 다 있는 주소는 사내로 한 번만 나온다.
- **최근 주고받은 주소**는 그 사람의 보낸편지함 최근 500통(받는 사람·참조·숨은참조)과 받은편지함 최근 500통(보낸 사람)에서 모은다 —
다른 직원의 메일함은 읽지 않는다. 나·메일 엔진 관리자·게이트웨이·막은 계정·noreply 류는 뺀다.
- **사내 목록**은 로그인에 성공한 직원(주소·이름)을 서버가 기록해 만든다 — 메일 엔진의 계정 목록 권한은 쓰지 않는다(일부러 막아 두었다).
한 번도 로그인하지 않은 직원은 그 사람과 메일을 주고받기 전까지 나오지 않는다. 최근 90일 안에 로그인한 직원만 사내로 추천한다
(퇴사자가 계속 맨 앞에 나와 반송되지 않게 — 웹메일 세션은 최대 30일이라 쓰는 사람은 그 안에 다시 로그인한다).
- **알려진 한계**: 최근 주고받은 주소의 이름은 받은 메일의 보낸 사람 표시 이름에서 온다 — 바깥 사람이 동료 이름을 표시 이름으로 쓰면 그 이름으로 추천된다.
추천 줄은 늘 주소를 함께 보이고, 사내와 최근 묶음을 나눠 보인다.
- 응답은 `Cache-Control: private, no-store`. 서버는 사용자마다 10분 캐시하고(서버 메모리, 최대 500명), 그 사람이 메일을 보내면 다시 모은다.
같은 사람의 동시 요청은 한 번만 모은다. 로그에는 개수만 남긴다.
- **클라이언트**: 쓰기 화면을 열 때 `q` 없이 `limit=100` 으로 한 번 받아 두고 치는 대로 거른다(초성 포함). 맞는 것이 모자랄 때만 `q` 로 다시 묻는다(250ms 기다린 뒤).
### 새 메일 확인 (`/api/mail-state`) — 3단계
```ts
// GET /api/mail-state — 새 메일 · 다른 창(메일 프로그램)의 변화를 알아채는 데 쓴다
interface MailState {
state: string // 불투명 — 메일함이나 메일이 하나라도 바뀌면 달라진다. 해석하지 말고 지난 값과 같은지만 본다
}
```
- 서버는 엔진의 메일함 · 메일 상태 문자열을 이어 붙여 돌려준다 (JMAP `Mailbox/get` · `Email/get` 에 `ids: []` — 메일을 읽지 않는 가벼운 조회).
새 메일이 오거나 다른 창 · 메일 프로그램이 읽음 · 이동 · 삭제를 하면 값이 바뀐다 (2026-10-27 개발 엔진에서 확인). 응답은 `Cache-Control: private, no-store`.
- **클라이언트**: 로그인한 동안, 화면이 보이는 동안(`visibilityState === 'visible'`)만 60초마다 묻는다. 탭이 다시 보이면 마지막으로 물은 지 10초가 지났을 때 바로 한 번. 한 번에 하나씩.
- 처음 받은 값은 기준으로만 쓴다. 값이 바뀌면 메일함 개수 · 사용량을 다시 받고, 보이는 메일함 목록을 **지금까지 불러온 만큼(최대 100통)** 다시 받아 맞춘다 —
고른 메일 · 열린 메일 · 스크롤 위치는 그대로. 100통보다 많이 불러왔으면 첫 100통만 맞추고 그 뒤는 둔다.
검색 결과는 다시 찾지 않는다(보던 결과가 저절로 바뀌지 않게).
- 이 탭이 바꾼 것(읽음 · 이동 등)도 값을 바꾸므로 다음 확인에서 한 번 더 받게 된다 — 해롭지 않다.
- 실패(`MAIL_001` · 네트워크)는 알리지 않고(`silent`) 다음 주기에 다시 묻는다. 401 은 지금의 세션 흐름(갱신 1회 → 만료 대화상자)을 그대로 탄다.
- 받은편지함에 안 읽은 메일이 있으면 탭 제목 앞에 `(N) ` 을 붙인다 (디자인 README "3단계").
- 요청 수 한도(`THROTTLE_LIMIT`, 기본 1분 100회)는 경로마다 · IP 마다 센다 — 사무실 전체가 한 IP 로 나가면 보이는 탭이 100개를 넘을 때
1분 확인이 429 `REQ_001` 로 조용히 실패한다(다음 주기에 다시). 값이 바뀌었을 때의 맞추기(`GET /api/messages` · `/api/mailboxes` · `/api/mail-quota`)는
평소 사용과 같은 칸을 쓴다 — `GET /api/messages` 는 대략 "쓰고 있는 탭 수 × 1분에 1번 + 평소 목록 · 더 보기"다. 운영 전환 때 직원 수에 맞춰 정한다.
- 실시간 푸시는 이 단계에서 하지 않는다. 엔진은 JMAP EventSource 를 지원하므로(2026-10-27 확인) 1분이 모자라면 백엔드가 SSE 로 중계하는 방식으로 바꾼다 —
그때는 nginx · 외부 프록시의 응답 버퍼링과 시간 제한도 함께 바꿔야 한다.
### 검색 (`GET /api/messages` 의 `q`) — 3단계
- `mailboxId` 가 있으면 그 메일함에서, **없으면 휴지통 · 스팸메일함을 뺀 모든 메일함**에서 찾는다. 둘 다 없으면 400 `VAL_001`.
- `q` 는 앞뒤 공백을 뺀 1~200자(JS 문자열 길이 — UTF-16 단위), 낱말(띄어쓰기로 나눈 것) 8개까지(넘으면 400 `VAL_001`). 보낸 사람 · 받는 사람 · 참조 · 제목 · 본문에서 찾고,
여러 낱말이면 모두 들어간 메일만 찾는다. 큰따옴표로 감싼 부분은 그 구절 그대로 찾는다.
낱말 앞뒤의 문장부호는 떼고 찾는다('회의,' · '(회의)' → '회의' — 엔진도 문장부호에서 낱말을 나눈다, 가운데의 문장부호는 둔다).
글자 · 숫자가 하나도 없는 낱말(`-` · 따옴표뿐 등)은 찾지 않고, 찾을 낱말이 하나도 남지 않으면 엔진에 묻지 않고 빈 결과(200)다.
- **한국어 — 서버가 보완한다**: 엔진(Stalwart 0.16.27)의 전문 검색은 띄어쓰기 · 문장부호로 나눈 낱말이 **통째로 같을 때만** 맞는다 —
'회의'로는 '회의를'을 찾지 못한다 (2026-10-27 개발 엔진에서 확인). 그래서 서버는 한글로 끝나는 낱말마다 흔한 조사를 받침에 맞게 붙인 형태를 함께 찾는다
(회의 → 회의가 · 회의를 · 회의는 · 회의에서 · 회의입니다 … / 일정 → 일정이 · 일정을 · 일정은 …, 낱말 안에서는 OR · 낱말끼리는 AND).
영문 · 숫자로 끝나는 낱말(ERP · 2026)은 받침을 알 수 없어 두 가지를 다 붙인다. 따옴표 구절에는 붙이지 않는다.
조사 목록은 서버 상수 한 곳(`jmap/search-terms.ts`)에 둔다 — 한글로 끝나는 낱말은 형태 44개, 영문 · 숫자로 끝나면 59개가 되고,
개발 엔진에서 낱말 8개(조건 472개)를 전체 메일함에서 찾는 데 0.16초였다.
- **알려진 한계** (엔진 색인 때문 — 자체 검색 색인을 두기 전까지): 붙여 쓴 말의 일부('회의실'의 '회의', '서버이전'의 '서버'), 앞부분만 친 말('견적' → '견적서'),
끝이 바뀐 말('보냈습니다'의 '보내'), **제목 맨 앞의 말머리**('[공지]' · '[긴급]' · 'Re:' · 'Fwd:' — 엔진이 제목을 정규화하며 색인에서 뺀다. 제목 가운데의 '[결재]'는 찾는다).
화면은 결과가 없을 때 이 한계를 한 줄로 알린다.
- 결과는 받은 시각 내림차순, `total` 포함. 커서는 그 검색어 · 범위로만 쓴다(다르면 400 `VAL_001`). 발송 중인 내부 초안은 지금처럼 빠진다.
- 검색어는 접근 로그 · 백엔드 로그에 남기지 않는다 — 백엔드는 지금처럼 `maskUrlForLog()`(`q` · `cursor` 는 길이만). 화면 주소에도 검색어가 들어가므로 nginx 는 `/api` 뿐 아니라
**화면 요청의 접근 로그도 쿼리 없이** 남기고(Referer 도 `?` 앞까지), 화면 응답의 `Referrer-Policy` 를 `strict-origin` 으로 둔다 —
검색 화면에서 나가는 요청(1분 확인 등)의 Referer 에 검색어가 실리지 않게. 오류 로그는 장애 원인 줄을 잃지 않게 지금처럼 요청 줄을 쿼리째 남긴다 (docker 스킬 "로그").
- **클라이언트**: 상단 검색 칸(메일 화면에서는 "메일 검색")에서 Enter 로 찾는다 — 치는 동안에는 묻지 않는다.
서버와 같은 한도(200자 · 낱말 8개)를 먼저 보고, 낱말이 8개를 넘으면 보내지 않고 `VAL_001` 사전 문구로 알린다.
검색 화면 안에서 다시 찾거나 범위를 바꾸면 방문 기록을 쌓지 않고 주소를 바꿔 끼운다(메일함에서 시작한 첫 검색만 기록에 쌓인다).
주소는 `/mail/search?q=…&in=<메일함 id | all>`, 결과에서 연 메일은 `/mail/search/<메일 id>?q=…&in=…` (뒤로 가기: 메일 → 결과 → 검색 전 메일함).
범위의 기본값은 검색을 시작한 메일함이고 결과 머리에서 "그 메일함 | 전체 메일함"으로 바꾼다(휴지통 · 스팸메일함에서 시작하면 그 안에서 찾는다).
✕ · Esc · "검색 닫기"는 검색 전 메일함으로 돌아간다. 임시보관함의 결과를 누르면 지금처럼 쓰기 화면에서 이어 쓴다.
### 여러 통 한꺼번에 (`PATCH /api/messages` · `POST /api/message-deletions`) — 3단계
```ts
// PATCH /api/messages — 읽음 · 중요 표시 · 이동을 여러 통에
interface MessagesPatch {
ids: string[] // 1~100, 같은 id 를 두 번 넣지 않는다
isRead?: boolean
isFlagged?: boolean
mailboxId?: string // 이동 = 그 메일함 하나에만 속하게 (단건 PATCH 와 같다)
} // 바꿀 것이 하나 이상
interface MessagesPatched {
updatedIds: string[]
failedIds: string[] // 없음 · 그사이 지워짐 · 엔진이 거절함 — 나머지는 그대로 처리한다
}
// POST /api/message-deletions — 휴지통에서 영구 삭제, 여러 통
interface MessageDeletionRequest { ids: string[] } // 1~100, 같은 id 를 두 번 넣지 않는다
interface MessageDeletionResult {
deletedIds: string[] // 영구 삭제했다(휴지통에만 있었다) · 휴지통에서만 뺐다(다른 메일함에도 있었다)
skippedIds: string[] // 휴지통에 없음 · 없음 · 엔진이 거절 — 지우지 않았다
}
```
- `PATCH /api/messages` 는 엔진 요청 한 번으로 처리한다. 없는 `mailboxId` 는 404 `RES_001` 이고 아무것도 바꾸지 않는다.
503 `MAIL_001` 이면 일부만 바뀌었을 수 있다 — 같은 요청을 다시 보내도 결과가 같다(멱등).
- `POST /api/message-deletions` 는 **휴지통에 있는 메일만** 다룬다 — `DELETE /api/messages/{id}` 와 달리 휴지통 밖의 메일을 휴지통으로 옮기지 않는다.
대상을 확인한 뒤 확인한 상태 그대로일 때만 한 번에 지운다(`ifInState` — 그사이 다른 창이 휴지통 밖으로 되돌린 메일을 지우지 않는다).
어긋나면 다시 확인해 합계 `CONDITIONAL_DESTROY_ATTEMPTS` 번, 계속 어긋나면 **아무것도 지우지 않고** 503 `MAIL_001`. 휴지통이 없어도 503 `MAIL_001`.
지우는 요청이 엔진 시간 초과로 끝난 503 `MAIL_001` 만은 일부가 지워졌을 수 있다 — 같은 요청을 다시 보내면 이미 지운 메일은 `skippedIds` 로 온다.
한 번에 지우기(destroy)와 휴지통에서 빼기(update)를 함께 보내고, 옛 상태면 엔진이 둘 다 거절한다 (2026-10-27 개발 엔진에서 확인).
- 한 요청의 `ids` 는 100개까지다 (엔진의 한 번 처리 한도 500 보다 작게).
- **클라이언트**:
- 고를 수 있는 것은 불러온 메일까지다 (표 머리의 고르기 = 불러온 메일 모두). 100통이 넘으면 100통씩 차례로 보낸다.
- 휴지통 밖의 「삭제」는 `PATCH { mailboxId: 휴지통 }` 이다 — 「되돌리기」는 원래 메일함별로 묶어 다시 `PATCH`.
`POST /api/message-deletions` 는 휴지통에서 「영구 삭제」를 확인한 뒤에만 부른다.
- **웹 클라이언트는 `DELETE /api/messages/{id}` 를 쓰지 않는다** — 그사이 다른 창이 휴지통으로 옮긴 메일을 확인 없이 영구 삭제하게 된다.
읽기 화면의 「삭제」도 그 메일의 `mailboxIds` 를 보고 휴지통 밖이면 휴지통으로 `PATCH`(+ 되돌리기), 휴지통이면 확인 대화상자 뒤 `POST /api/message-deletions` 다.
휴지통 목록 밖(검색 결과 등)에서는 **휴지통에만 있는** 메일만 「영구 삭제」다 — 휴지통과 다른 메일함에 함께 있는 메일은 「삭제」(휴지통으로 옮기기)로 다룬다
(영구 삭제를 확인했는데 서버가 휴지통에서만 빼고 다른 메일함에 남기는 일이 없게).
`mailboxIds` 가 낡았어도 해가 없다 — 이미 휴지통인 메일을 휴지통으로 옮기면 그대로이고, 휴지통 밖으로 돌아간 메일은 서버가 `skippedIds` 로 건너뛴다.
- **알려진 한계**: 「되돌리기」는 이 화면이 알던 메일함 한 곳으로 되돌린다 — 그사이 다른 창이 옮긴 메일(저절로 바뀌지 않는 검색 결과 등)은 그 전 메일함으로,
여러 메일함에 함께 있던 메일(다른 메일 프로그램의 복사)은 첫 메일함으로만 돌아간다.
- 「되돌리기」 알림은 만든 세션에서만 움직인다 — 로그아웃 · 세션 만료 · 다른 계정 로그인 뒤에는 아무것도 하지 않는다(메일 id 는 계정마다 따로 매겨져 겹칠 수 있다).
- 읽음 · 중요 표시는 화면을 먼저 바꾸고 `failedIds` 만 되돌린다. 이동 · 삭제는 응답을 받은 뒤 `updatedIds` · `deletedIds` 만 목록에서 빼고 개수를 다시 맞춘다
(전체 메일함 검색 결과에서 범위 안의 다른 메일함으로 옮긴 메일은 여전히 찾은 메일이라 빼지 않고 메일함 표시만 바꾼다 — 휴지통 · 스팸메일함으로 옮기면 뺀다).
요청이 통째로 실패해 하나도 처리하지 못하면 고른 것을 그대로 둔다(다시 시도할 수 있게).
- `failedIds` · `skippedIds` 가 있으면 알림에 "N통은 그사이 옮겨졌거나 지워져 그대로 두었습니다"를 덧붙인다.
- 메일함을 바꾸거나 검색하면 선택을 푼다. 새 메일 확인으로 목록을 맞출 때는 남아 있는 메일의 선택을 그대로 둔다.
### 이 단계에서 하지 않는 것
실시간 푸시(SSE · 데스크톱 알림 · 소리), 본문 안 이미지(인라인 cid — 붙여 넣은 이미지는 첨부로, 서명 그림만 예외), 외부 이미지 보기(이미지 중계),
여러 개의 서명, 엔진 계정 목록 기반의 사내 주소록, 자체 검색 색인(붙여 쓴 말 · 앞부분 · 제목 말머리로 찾기), 조건 검색(보낸 사람 · 기간 · 첨부 있음), 검색어 강조,
불러오지 않은 메일까지 한꺼번에(메일함 전체 읽음 표시 · 휴지통 비우기), 끌어다 놓아 옮기기.