# 인증·메일 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` `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` 을 더한다. 12단계 — 5절 "계정 보안"이 `AUTH_006` ~ `AUTH_009` 를, 13단계 — 6절 "전환 준비"가 `AUTH_010` 을 더한다)
- **적용 범위: 웹(브라우저)은 HttpOnly 쿠키뿐이고 토큰을 본문으로 주지 않는다.**
모바일 앱(Flutter)은 1절 끝 "앱 로그인 (앱 전용 토큰 경로)"를 쓴다(2026-11-05 — 세션 규칙은 웹과 같고 토큰을 나르는 길만 다르다).
- **배포 형태: 화면과 API 는 같은 주소(origin)를 쓴다.** 운영은 프론트 nginx 가 `/api` 를 백엔드로 넘기고,
개발은 vite 프록시가 넘긴다. 그래서 CORS 가 필요 없고 CSP `connect-src 'self'` 로 충분하다.
- **요청 대상에 날 `\` · `#` · 제어 문자 · 공백 · 0x80 이상 바이트가 있으면 라우팅 전에 400 `VAL_001` 이다**(12단계 — 백엔드 `request-target` 미들웨어, 앞단 nginx 도 막는다).
Express 가 이런 대상을 다르게 읽어 앞단이 닫은 경로가 백엔드에서 열렸다(2026-10-31 리뷰). 제어 문자 · 날 UTF-8 은 Node 의 HTTP 파서가 앱 전에 거절한다(JSON 이 아닌 400). 브라우저는 이런 요청을 보내지 않는다.
- **경로는 대소문자를 가린다** — 이 문서의 경로는 모두 소문자이고, 대소문자 변형(`/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 가 백엔드에 닿기 전에 끝내는 요청도 표준 실패 형식으로 온다 —
본문 한도 초과 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`
> 12단계: 응답에 `status` 가 붙고, 2단계 인증을 켠 직원은 코드 단계를 거친다 — 5절 "로그인".
```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 한도에만 센다. 비밀번호 변경(`PUT /api/auth/password`)의 지금 비밀번호 틀림도 같은 한도로 함께 센다 | 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시간)까지 유효하다.
→ 12단계부터는 끊은 세션을 바로 막는다(액세스 토큰의 세션 id + 서버가 기억한 끊은 세션 — 5절 "로그인된 기기").
### `POST /api/auth/logout`
항상 200 (`data: null`). 두 쿠키를 지우고 **서버의 세션을 폐기**한다.
### `PUT /api/auth/password` — 4단계
> 12단계: 2단계 인증을 켠 직원은 `otpCode` 를 함께 보낸다 — 5절 "비밀번호 바꾸기".
```jsonc
// 요청 (로그인 필요)
{ "currentPassword": "…", "newPassword": "…" }
// 200 (data: null)
```
| 실패 | 코드 |
| --- | --- |
| 지금 비밀번호가 틀림 | 400 `AUTH_005` — 세션은 그대로다. 401 이 아닌 까닭: 클라이언트는 401 이면 세션을 갱신하고 같은 요청을 다시 보낸다 |
| 새 비밀번호가 8자 미만 · 128자 초과 · 지금 비밀번호와 같음, 엔진 규칙에 어긋남 · 지금 비밀번호가 비었거나 256자 초과(로그인과 같은 한도) — 길이는 JS 문자열 길이 | 400 `VAL_001` |
| 지금 비밀번호 틀림이 잦음 — 로그인 실패와 **같은 한도**(같은 IP 1분 20회 · 같은 주소 15분 10회)를 함께 센다. 같은 주소의 로그인 · 변경이 처리 중 | 429 `REQ_001` |
| 웹메일로 다루지 않는 계정(메일 엔진 관리자 · 게이트웨이 · 막은 계정 — 엔진에 묻지 않는다) | 401 `AUTH_001` |
| 메일 엔진 장애 — 바꾸는 요청의 결과를 알 수 없는 실패(시간 초과 · 연결 끊김 · 5xx)도 | 503 `MAIL_001` — 결과를 모를 때는 바뀌었을 수 있으므로 **다른 세션을 끊어 둔다** |
- 서버는 메일 엔진에 **그 직원 본인의 자격으로**(지금 비밀번호) 본인 비밀번호 변경(Stalwart `x:AccountPassword/set` 에 `currentSecret` · `secret`)을 보낸다 —
게이트웨이 대리 접근이나 관리자 권한을 쓰지 않는다. 엔진은 지금 비밀번호 없이는 바꾸지 않고 8자 미만을 거절한다 (2026-10-28 개발 엔진에서 확인).
세션은 같은 값으로 열렸는데 엔진이 바꾸기를 거절하면(`forbidden` · `currentSecret` 의 `invalidProperties` · 바꾸기 요청의 401 — 앱 비밀번호를 넣은 경우 · 역할의 권한 설정)
`AUTH_005` 로 답하되 실패 횟수로 세지 않고 세션도 끊지 않는다(추측이 아니라서). 결과를 알 수 없는 실패도 실패 횟수로 세지 않는다.
- 바뀌면 그 직원의 **다른 로그인 세션을 모두 끊는다** — 이 요청의 리프레시 쿠키(Path=`/api/auth` 라 이 요청에도 온다)가 가리키는 세션만 남긴다.
쿠키가 없거나 다른 직원 것이면 이 세션도 끊긴다(다음 갱신에서 로그인 화면으로). 끊긴 기기의 액세스 토큰은 만료(최대 1시간)까지 쓰인다(위 세션 규칙).
휴대폰 · PC 메일 프로그램(IMAP)은 새 비밀번호를 다시 넣어야 한다.
→ 12단계부터: 끊긴 기기는 바로 막히고, 앱 비밀번호도 모두 지운다(5절 "비밀번호 바꾸기").
- 비밀번호는 바뀌었는데 세션 끊기가 실패하면 200 으로 답하고 오류 로그를 남긴다 — 실패로 답하면 사용자가 옛 비밀번호로 다시 시도하기 때문이다.
- 비밀번호는 로그 · 응답에 남기지 않는다. 바꾼 일은 info 로그에 주소(가린 것)만 남긴다.
- **클라이언트**: 같은 규칙(8자 이상 · 지금과 다름 · 확인 칸 일치)을 먼저 보고, `AUTH_005` 는 지금 비밀번호 칸 아래에 사전 문구로 보인다. 성공하면 세 칸을 비운다.
요청 한도는 30초다 — 서버가 엔진을 두 번 부르고 세션을 끊으므로, 기본 한도(10초)로는 서버가 바꾼 뒤 화면만 실패로 볼 수 있다.
### 클라이언트 처리 규칙
- 보호 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` 가 동작해야 한다.
### 앱 로그인 (앱 전용 토큰 경로) — 앱 1단계 (2026-11-05 정함 · 2026-11-06 서버)
> 2026-11-05 사용자가 앱 전용 토큰 경로를 정했다(웹의 쿠키를 앱에 쓰지 않는다). 앱(`flutter/`)과 서버(`modules/auth` 의 `app-auth.controller.ts`)가 이 절대로 만들었다.
> **세션 규칙은 웹과 같다**: 1회용 회전 · 해시만 저장 · 재사용 감지 · 30초 유예 · 끊은 세션은 `sid` 로 바로 막기 · 절대 30일 · 미사용 14일 · 로그인 실패 카운터 · 2단계 코드 가드 · 앱 비밀번호 모양 거절 · 제한된 세션(6절). 다른 것은 토큰을 나르는 길뿐이다.
| 경로 | 본문 | 응답 |
| --- | --- | --- |
| `POST /api/auth/app/login` | 웹 login 과 같다(`email` · `password` · `otpCode?`) | 웹 login 응답과 같은 평평한 모양(`status` · `email` · `name` · `restriction` · `twoFactorDeadline`) + `tokens`. `signedIn`(웹이 쿠키를 심는 바로 그 경우)에만 `tokens` 가 차고, 그 밖(`twoFactorRequired` 등)은 `tokens: null` |
| `POST /api/auth/app/refresh` | `{ refreshToken }`(글 — 없거나 글이 아니면 400 `VAL_001`) | 웹 refresh 와 같은 칸(`email` · `name` · `restriction` · `twoFactorDeadline` — 6절: 늘 싣는다) + `tokens`. 30초 유예 안의 다시 쓰기는 웹처럼 새 토큰 없이 200(`tokens: null`) — **앱 세션은 그때 서버가 그 세션을 끝낸다**(아래). 끝난 세션 · 틀린 토큰(빈 글 · 2,048자 넘는 글 포함 — 서명을 보기 전에)은 401 `AUTH_001` |
| `POST /api/auth/app/logout` | `{ refreshToken? }`(보안 저장소를 읽지 못했으면 빈 본문 `{}`) + 머리 `Authorization: Bearer`(있으면) | 웹 logout 과 같다 — 리프레시 토큰으로 세션을 찾고, 없거나 틀리면(빈 글 · 2,048자 넘는 글 포함) 액세스 토큰의 `sid` 로 끝낸다(액세스 토큰은 만료됐어도 서명이 맞으면 — 세션을 끝내기만 한다). 어느 쪽도 맞지 않아도 200. 본문 모양이 틀리면(`refreshToken` 이 글이 아님 · 모르는 칸) 400 `VAL_001` |
- `tokens = { accessToken, accessExpiresAt, refreshToken, refreshExpiresAt }`(시각은 ISO 8601 UTC) — 로그인 · 갱신 응답은 `Cache-Control: private, no-store`(처리기가 낸 오류 응답도 — 가드 · 본문 파서가 먼저 막은 응답(403 출처 · 429 한도 · 400 · 413 · 415)에는 토큰이 없어 이 머리가 없다).
- 보호 API 는 `Authorization: Bearer `. **앱 토큰에만 표시 `cli: 'app'`** 를 싣고, Bearer 는 그 표시와 `sid` 가 있는 토큰만 · 쿠키는 표시가 없는(웹) 토큰만 받는다(서로 바꿔 쓰지 못하게 — 리프레시 토큰도, 세션 표의 `client` 도 맞아야 한다. 다르면 401 이고 재사용으로 보지 않는다). `Authorization: Bearer` 가 있으면 그것만 보고 쿠키는 보지 않는다. `GET /api/auth/me` · 6절의 제한 허용 경로도 Bearer 로 그대로 — 제한(`rst`)과 403 `AUTH_010` 도 웹과 같다.
- 실패 카운터 · 요청 수 한도 · 2단계 코드 가드 · 앱 비밀번호 모양 거절은 웹 login 과 같은 함수를 지난다(앱 경로가 한도를 비켜 가지 않게 — 로그인 요청 수 한도는 웹 · 앱이 IP 마다 1분 60회 한 칸을 함께 쓴다). 출처 검사(OriginGuard)는 앱(`Origin` · `Sec-Fetch-Site` 없음)을 지금 규칙 그대로 통과시킨다.
- **"지금 세션"**: 앱 요청은 리프레시 쿠키가 없으므로 액세스 토큰의 `sid` 로 안다 — `PUT /api/auth/password`(이 세션만 남기기) · `/api/auth/sessions`(isCurrent · 다른 곳 모두 끊기) · 앱 logout.
- 세션 표에 `client`(`web` | `app`) 칸을 둔다(마이그레이션 `AddAppSessionClient` — 기본 `web`). 앱은 User-Agent `ARASYSTEM-App/<앱 판> (Android <판>; <기종>)` 을 보내고, 서버는 로그인된 기기 · 로그인 기록에 "ARA SYSTEM 앱 · Android" 꼴로 보이고 로그인 기록 `method` 는 `app` 이다.
**앱 이름은 진짜 클라이언트(세션의 `client` · 로그인 기록의 `method`)로만 붙인다** — 웹 세션이 앱 꼴 User-Agent 를 보내도 앱 이름을 받지 않는다(비밀번호를 훔친 사람이 직원 본인의 앱처럼 보이지 않게). 세션 목록 항목에 `client` 칸을 싣는다(5절).
- **앱 세션의 갱신 유예는 그 세션을 끝낸다**(2026-11-06 리뷰): 앱은 갱신을 한 번에 하나만 하므로 앱 세션의 유예는 "회전 응답을 잃음" 아니면 "훔친 토큰을 누가 먼저 씀"이다. 서버는 그 세션을 끊고(사유 `app-refresh-grace`) 200 `tokens: null` 로 답한다 — 앱이 다음 실행 직후라 조용한 logout 을 보내지 못해도 남는 세션이 없다. 웹의 유예는 그대로다(탭 여러 개).
**앱이 할 일**
- 리프레시 토큰은 `flutter_secure_storage`(키 `kRefreshTokenKey`)에만, 액세스 토큰은 메모리에만 둔다. 새 리프레시 토큰은 받자마자 저장한다 — 회전 직후 앱이 죽으면 다음 실행의 옛 토큰이 30초 뒤 재사용으로 세션을 끊는다(알려진 한계).
iOS Keychain 은 앱을 지워도 남는다 — 첫 실행 표시(일반 저장소)가 없으면 남은 토큰을 지운다. Android 는 백업을 끈다(토큰 · 첫 실행 표시가 다른 기기로 복원되지 않게).
- 보호 API 의 401 이면 갱신을 **한 번에 하나**(동시 401 을 한 갱신에 묶는다) → 원래 요청 한 번 다시. 앱 login · refresh · logout 은 갱신하지 않는다. 갱신의 401 이면 토큰을 지우고 `AUTH_002`(클라이언트 전용) + 로그인 화면, 5xx · 연결 끊김은 세션을 그대로 둔다.
`accessExpiresAt` 30초 전에는 미리 갱신한다.
- **유예 응답(`tokens: null`)은 세션 끝으로 다룬다** — 앱은 갱신을 한 번에 하나만 하므로 그 응답은 "앞 갱신의 응답을 잃었다"는 뜻이고 이어 쓸 리프레시 토큰이 없다(서버는 해시만 두어 같은 토큰을 다시 줄 수 없다).
메모리의 액세스 토큰이 아직 있으면 그것을 Bearer 로 실어 조용히 앱 logout 을 부른다(서버도 그 세션을 이미 끝냈다 — 남는 것이 없게 두 겹).
나중에 푸시를 따로 받는 백그라운드 작업이 생겨도 갱신은 앱과 같은 자물쇠를 함께 쓴다 — 유예가 세션 끝이므로 둘이 따로 갱신하면 서로의 세션을 끊는다.
- **알려진 한계**: 회전 응답을 잃고 30초가 지난 뒤 같은 토큰을 다시 보내면 재사용으로 세션이 끊긴다(서버 로그에 탈취 의심 warn) — 모바일 망에서는 웹보다 잦을 수 있다.
- `AUTH_010` 은 알림 없이 me 를 다시 받아 그 단계로 — me 에 제한이 없으면 웹처럼 갱신한 뒤 원래 요청을 한 번 다시 보낸다.
- 로그아웃 · 다른 계정으로 로그인 · 세션 끝 뒤에 도착한 응답(로그인 · 코드 · 갱신 · me)은 쓰지 않는다 — 버린 로그인 결과에 토큰이 있으면 그 토큰으로 조용히 앱 logout 을 부른다(웹의 "로그아웃 뒤 늦은 갱신"과 같은 결).
- 코드 단계 · 제한 단계에서 다시 쓸 비밀번호는 화면 메모리에만 10분 — 성공 · 다른 계정 · 떠남 · 제한이 풀릴 때 지운다(웹과 같다).
### 앱 새 메일 알림 · 앱 설정 — 앱 1단계 (2026-11-07 정함 · 서버)
> 2026-11-07 사용자가 추천안대로 정했다(앞 메모의 `/api/push-devices` · `{ minVersion, latestVersion, storeUrl }` 를 이 절로 바꾼다).
> 서버는 `modules/push`(등록 · 끝난 세션 정리 · 보내기 — 메일을 모른다) · `modules/mail/new-mail-push.service.ts`(새 메일 알아채기 · N · 간격 · 따라잡기) · `modules/app-config` 가 이 절대로 만들었다.
> 보내는 곳은 FCM HTTP v1 하나 — 새 의존성 없이(node crypto 의 RS256 + 전역 fetch). 휴대폰 주소는 FCM 등록 토큰이 아니라 **FID**(Firebase 설치 id — v1 의 `token` 칸은 deprecated, `message.fid` 로 보낸다)다.
| 경로 | 본문 | 응답 |
| --- | --- | --- |
| `GET /api/app-config` (로그인 없음) | — | `{ minSupportedVersion: { android: "1.0.0", ios: null }, pushAvailable: boolean }` · `Cache-Control: no-store` |
| `PUT /api/push-registrations/current` (앱 토큰만) | `{ fid, platform: "android", appVersion }` | 200 `{ updatedAt }` (ISO 8601 UTC · `Cache-Control: private, no-store`) |
| `DELETE /api/push-registrations/current` (앱 토큰만) | — | 204 (등록이 없어도) |
| `DELETE /api/push-registrations/current/unseen` (앱 토큰만) | — | 204 (등록이 없어도) |
- **app-config**: 최소 판은 서버 코드 상수(`modules/app-config/app-config.constants.ts`) — 옛 앱이 쓸 수 없게 서버 API 를 바꾸는 배포가 **같은 커밋에서** 올린다. `ios: null` 은 막지 않는다는 뜻(iOS 앱이 아직 없다).
`pushAvailable` 은 서버가 FCM 키를 읽었는가(키가 막힌 것 — OAuth 거절 — 은 로그로만 알린다). 요청 수는 기본 한도(경로마다 IP 당 1분 100회 — 문의 연락처와 같다), 실패는 429 `REQ_001` 뿐이다.
- **본문**: `fid` 는 영문 · 숫자 · `-` · `_` 64자 이하(Firebase 의 FID 는 22자), `platform` 은 `android` 만, `appVersion` 은 `숫자.숫자.숫자`(칸마다 1~4자리 · 앞자리 0 없이 — 앱의 `PackageInfo.version`). 모르는 칸은 400.
- **실패**: 웹(쿠키) 세션 403 `AUTH_003`(`AppClientGuard` — 토큰은 맞지만 이 경로를 쓸 수 없는 클라이언트) · 제한된 세션(6절) 403 `AUTH_010` · 토큰 없음 · 그사이 끝난 · 끊긴 세션 401 `AUTH_001` · 본문 400 `VAL_001` · 요청 수 429 `REQ_001`. 새 오류 코드는 없다.
- **등록 — 세션(sid)마다 한 줄**(표 `push_registrations` — 마이그레이션 `AddPushRegistrations`, 세션 표와 외래 키로 묶지 않는다). PUT 은 한 트랜잭션이다:
그 세션이 살아 있는 · 제한 없는 앱 세션인지 보고(아니면 아무것도 바꾸지 않는다) → 같은 FID 의 다른 줄(다른 세션 · 다른 직원 — 같은 휴대폰에서 다른 직원이 로그인)을 지운다 →
넣거나 바꾼다(FID · 플랫폼 · 판 · `updatedAt`) → 한 직원 10줄을 넘으면 가장 오래 바꾸지 않은 줄부터 지운다. 같은 FID 의 등록은 DB 잠금으로 차례가 된다(함께 등록해도 한 줄만 남는다).
**새 줄이면 세는 시작(`seen_at`) = 지금**, 있던 줄은 세는 시작 · 마지막으로 보낸 때를 건드리지 않는다. FCM 키가 없어도(`pushAvailable: false`) 저장한다 — 키를 넣으면 다시 등록하지 않아도 알림이 시작된다.
켬 · 끔은 휴대폰이 들고 있다 — 끄면 앱이 DELETE 한다(서버에 켜짐 칸은 없다).
- **`/unseen`**: 그 줄의 `seen_at` = 지금(서버 시각) — "이 휴대폰에서 앱을 봤다". 다음 알림의 N 을 그때부터 센다.
- **새 메일 알아채기**: 보낼 기기(살아 있는 · 제한 없는 앱 세션의 등록)가 있는 사내 직원은 SSE 연결이 없어도 서버가 그 직원의 엔진 연결을 **붙잡아 둔다**(2절 "실시간 알림"의 연결 — `MailEventsService.hold`, 직원마다 여전히 하나).
판정은 웹의 `new-mail` 그대로다(받은편지함 · 초안 아님 · 읽지 않음 · 보낸 사람이 나 아님 · 본 때보다 10분 넘게 앞서 받은 메일 빼기). 붙잡을 직원은 서버가 뜰 때 · 5분마다 · 등록이 바뀐 직원은 그때 맞춘다(함께 여는 수 4).
붙잡기에 실패하면 1 · 5 · 15 · 60분 뒤 다시(계정을 쓸 수 없음 — `AUTH_001` · 비밀번호 만료 · 잠김 · 엔진 연결이 끝남 — 은 60분부터). FCM 키가 없으면 아무도 붙잡지 않는다.
곁 효과: 알림을 켠 직원은 웹을 닫아도 14단계 검색 색인의 실시간 맞추기가 돈다.
- **N**: 받은편지함의 안 읽은 메일(초안 빼고) 가운데 **이 휴대폰에서 마지막으로 앱을 본 뒤**(`seen_at` — 같은 초 포함) 받은 수 — 보낼 때 엔진에 센다(기기마다). PC 에서 읽은 메일은 빠진다. **0 이면 보내지 않는다.**
같은 초를 넣으므로 그 메일과 같은 초에 `/unseen` 을 받으면 그 메일은 다음 알림에 다시 든다 — 앱은 앞에서 받은 알림 뒤의 `/unseen` 을 1.5초 늦춘다(아래 "클라이언트 — 앱").
보내는 때는 웹 `new-mail` 이 있을 때뿐이다(안 읽음 표시 바꾸기 · 옛 메일 옮기기로는 보내지 않는다).
- **간격**: 같은 직원은 30초에 한 번까지 — 첫 알림은 바로, 그사이 온 것은 30초 끝에 그때의 N 으로 한 번. 같은 직원의 보내기는 차례로 한다.
- **따라잡기**: 붙잡을 때마다(서버를 다시 띄움 · 다시 붙잡음) 그 전 10분 안 · 마지막으로 보낸 뒤 · 본 뒤에 온 안 읽은 메일이 있으면 그 기기에 그때의 N 으로 한 번 — 이미 알린 · 이미 본 메일로는 다시 울리지 않는다.
"보낸 뒤 · 본 뒤"는 `last_pushed_at`(그 알림의 N 을 센 때) · `seen_at` 의 **다음 초부터**다 — 받은 시각은 초라 그 초에 받은 메일은 그 알림에 이미 들었거나 이미 보았다고 본다(2026-11-07 리뷰). N 은 위처럼 `seen_at` 의 초부터 센다.
그 초에 센 · 본 뒤 들어온 메일은 따로 새 메일 신호를 낸다 — 그 알림이 나가기 전(그 1초 안 · 30초 간격의 끝을 기다리는 사이)에 서버가 멈추면 놓친다. 따라잡기로 보내도 30초 간격에 든다.
**붙잡은 직후의 메일 하나에는 한 번만 울린다**(2026-11-08 리뷰): 붙잡은 뒤 새 메일 신호가 먼저 왔으면 따라잡기는 건너뛴다(신호 쪽이 그때의 N 으로 보낸다).
따라잡기로 보낸 뒤 첫 신호 보내기는 따라잡기와 같은 규칙(`seen_at` · `last_pushed_at` 의 다음 초부터)으로 새로 받은 메일이 있는 기기에만 — 따라잡기가 센 메일의 신호가 뒤늦게 와도 간격 끝에 다시 울리지 않는다.
그래서 따라잡기가 센 그 초에 센 뒤 들어온 메일은 그 보내기에서 빠진다(다음 알림의 N 에는 든다).
**엔진의 `after` 는 그 초를 넣지 않는다**("그 초보다 뒤" — RFC 8621 은 "같거나 뒤"라 적는다, 2026-11-08 개발 엔진 0.16.27 에서 확인) — 서버는 한 초 앞을 넘겨 같은 초를 센다(`jmap-mail.provider.ts`). 엔진 판을 올리면 다시 본다(같은 초를 넣게 바뀌면 따라잡기가 이미 알린 메일로 한 번 더 울린다).
- **보내는 메시지** (FCM v1 `messages:send` 의 `message` — 서버 `modules/push/fcm/fcm-message.ts` 한 곳, 받는 것은 FID 와 개수뿐):
```json
{
"fid": "",
"notification": { "title": "새 메일 3통" },
"data": { "module": "mail", "kind": "new-mail" },
"android": {
"collapse_key": "new-mail",
"priority": "HIGH",
"ttl": "86400s",
"notification": {
"tag": "new-mail",
"channel_id": "new_mail",
"notification_priority": "PRIORITY_HIGH",
"visibility": "PUBLIC",
"notification_count": 3
}
}
}
```
글은 제목 "새 메일 N통"뿐이다(천 단위 쉼표 — 원본은 서버 `modules/push/push-text.ts`). 주소 · 이름 · 보낸 사람 · 제목 · 메일 id · 세션 id 는 싣지 않는다 — 누르면 늘 메일 탭 받은편지함(`data.module`).
꺼져 있던 휴대폰은 마지막 것 하나만 받고(`collapse_key`), 24시간이 지나면 버린다(`ttl`).
- **그 휴대폰을 직원이 건드리지 않았는데 앱 세션이 끝나면** 5분 정리가 그 등록을 지운 뒤(`DELETE … RETURNING` — 한 번만) 그 휴대폰에 한 번 보낸다 — `kind: "session-ended"`, 같은 tag · 채널 · `collapse_key`(옛 "새 메일 N통"을 바꿔 쓴다), `priority: NORMAL`,
글은 보드 `App1Push` ⑧(2026-11-07 확정) 그대로 — 제목 "새 메일 알림이 멈췄습니다" · 내용 "안전을 위해 로그아웃했습니다. 앱을 열어 다시 로그인해 주세요."(원본 `push-text.ts`).
끝난 까닭(세션 표의 `revoked_reason`)마다 — 표는 서버 `modules/push/push.constants.ts` 의 `SESSION_ENDED_NOTICE` 한 곳(까닭을 더하면 거기서 정해야 컴파일된다):
| 끝난 까닭 | 값 | 알림 |
| --- | --- | --- |
| 저절로 끝남(미사용 14일 · 절대 30일) | 없음(끊기지 않았다) | 보낸다 |
| 다른 곳에서 비밀번호를 바꿈 · 바꾸다 결과를 알 수 없게 됨 | `password-changed` · `password-change-uncertain` | 보낸다 |
| 앱 갱신 유예(회전 응답을 잃었거나 토큰이 두 번 쓰였다) | `app-refresh-grace` | 보낸다 |
| 리프레시 토큰 재사용 감지 | `refresh-token-reused` | 보낸다 |
| 그 휴대폰에서 로그아웃 | `logout` | 보내지 않는다 |
| 로그인된 기기에서 끊기 · 다른 곳 모두 로그아웃 | `ended-by-user` | 보내지 않는다 |
| 운영 도구(재설정 · 잠금 · 이전용 비밀번호 다시 만들기 · 이전 마치기 · 퇴사 마무리) | `ended-by-operator` | 보내지 않는다 |
| 계정을 쓸 수 없음(갱신 때) | `account-unavailable` | 보내지 않는다 |
| 2단계를 켜 끊긴 세션(제한된 세션) | `two-factor-not-verified` | 보내지 않는다 |
제한이 붙은 세션 · 까닭이 없거나 표에 없는 값(다른 판이 남긴 줄) · 세션 줄이 없는 등록에는 보내지 않는다.
보내기 직전 그 FID 를 다른 등록이 쓰고 있으면(그 휴대폰에서 다른 직원 · 같은 직원이 다시 로그인했다) 보내지 않는다.
- **등록이 사라지는 길**: 보낼 때는 살아 있는 · 제한 없는 앱 세션의 줄만 고른다 — 끊은 세션(위 표의 까닭 모두)은 DB 의 `revoked_at` 으로 **바로** 빠진다(15초 기억과 상관없이).
끝난 세션의 줄은 5분 정리(`modules/push/push-cleanup.service.ts`)가 지운다 — 위 표에서 "보낸다"인 세션의 휴대폰에만 지운 뒤 한 번 알린다. 제한이 붙은 세션의 줄은 남는다(보내지 않는다 — 풀리면 다시). 같은 휴대폰에서 다른 직원이 등록하면 앞 줄을 지운다(앞 직원의 붙잡기도 다시 고른다 — 알리지 않는다).
**보내기 직전(다시 하기마다) 그 세션의 줄이 아직 그 FID 이고 세션이 살아 있는지 다시 본다** — 센 뒤 · 다시 하기를 기다리는 사이 그 휴대폰이 다른 직원에게 옮겨 갔으면 보내지 않는다.
FCM 이 받지 않는 휴대폰(아래 "실패")은 그 줄을 지운다 — 그사이 PUT 이 FID 를 바꿨으면 둔다. 퇴사 마무리(6절)는 그 주소의 줄을 한 트랜잭션에 지운다("앱 알림 등록").
- **FCM 의 실패**(`modules/push/fcm/fcm-sender.ts`): `UNREGISTERED`(404) · `SENDER_ID_MISMATCH`(403) · `message.fid` 칸의 `INVALID_ARGUMENT`(400) → 그 줄을 지운다 /
401 → access token 을 버리고 새 토큰으로 한 번 더 / 429(`QUOTA_EXCEEDED`) → 바로 다시 보내지 않는다 — `Retry-After` 가 있고 남은 시간 안이면 그때 한 번, 아니면 이번 알림은 버린다(다음 새 메일이 그때의 N 으로 다시 간다 — 같은 직원은 30초에 한 번이라 한도에 닿을 일이 드물다) /
5xx · 연결 오류 · 시간 초과 → 다시(1 · 4초 ±20% — 응답의 `Retry-After` 는 남은 시간 안에서만, 시도 3번 · 시도마다 10초 · 한 번 보내기 20초 안) /
그 밖(400 · 403 `PERMISSION_DENIED` · 오류 코드 없는 404 · `THIRD_PARTY_AUTH_ERROR` · 3xx · OAuth 거절) → 지우지 않는다(프로젝트 id 가 틀린 404 로 모든 등록을 지우지 않게) · error. 서버 전체에서 함께 보내는 수 4.
OAuth access token 은 만료 5분 전까지 기억한다(함께 부르면 교환은 한 번).
- **로그**: FID(지문도) · 서비스 계정 키 · 개인 키 · JWT · access token · FCM · Google 의 오류 설명 글은 남기지 않는다 — 상태 · 오류 코드만, 주소는 가린다(`maskEmail`).
보낸 것은 debug("앱 알림: user=a***@…, 기기 1, N 3"), 끝내 못 보냄은 warn · 설정 오류는 error(종류마다 한 시간에 한 번 — 묻힌 수를 다음 줄에), 붙잡기가 이어서 실패하면 warn 한 번 · 다시 되면 info 한 번.
서버 종료로 끊긴 보내기는 로그를 남기지 않는다(배포마다 남는 잡음이 되지 않게).
- **환경 변수**: `backend/.env.{env}` 의 `FCM_SERVICE_ACCOUNT_KEY` = 서비스 계정 JSON 키 파일을 통째로 base64 한 줄(JWT_SECRET 과 같은 자리 — compose 는 바꿀 것이 없다, 매일 백업에 함께 든다).
비면 알림만 끈다(기동은 한다 — 운영 필수가 아니다, 기동 로그 warn). 값이 있으면 **모든 환경에서** 모양(base64 → JSON → `service_account` · `project_id` · `client_email` · `private_key_id` · PEM RSA 2048 이상 · `token_uri`)을 보고 틀리면 기동하지 않는다(어느 칸이 틀렸는지만 — 값은 싣지 않는다).
켜지면 기동 로그 한 줄 "앱 새 메일 알림: 켬 (Firebase 프로젝트 )". 서비스 계정은 알림 전용 하나 + 역할 "Firebase Cloud Messaging API Admin" 하나, 키는 개발 · 운영 따로.
- **운영 확인**: backend 컨테이너 → `oauth2.googleapis.com:443` · `fcm.googleapis.com:443`(외부 이미지 중계와 같은 길 — 호스트 방화벽 · NCP ACG 의 나가는 규칙), 서버 시계(NTP — JWT 의 `iat`, 틀리면 토큰 교환이 거절된다).
- **실기기 · 실제 키로 확인할 것**(첫 보내기 때): FCM 이 `message.fid` 와 메시지 칸(`priority "HIGH"` · `notification_priority` · `visibility` · `notification_count` · `ttl` 글)을 받는지,
오류 본문의 모양(`UNREGISTERED` · `SENDER_ID_MISMATCH` · 429 · 503 의 `Retry-After`) — **잘못된 FID 를 `message.fid` 가 아닌 칸(예: `message.token`)으로 알리면 지금은 그 줄을 지우지 않고 설정 오류(error — 한 시간에 한 번)로만 남긴다**(안전한 쪽 — 오류 본문을 보고 칸 이름을 맞춘다),
실기기의 FID 길이 · 글자(22자 base64url 로 본다), 같은 tag 로 바꿔 쓴 알림이 다시 울리는지 · `notification_count` 의 런처 배지(삼성).
**2026-11-08 갤럭시 S23(Android 16 · Google Play 서비스 26.42)에서 확인한 것**: FCM 이 `message.fid` 와 메시지 칸을 받았다(오류 없음 — 배달된 그 초에 보내 1초 안에 표시), FID 22자,
같은 tag 로 바꿔 씀(알림 하나가 "새 메일 2통" · 개수 2로), 중요도 높음 · 잠금 화면 표시 · 강조색 · 작은 아이콘, 앱이 앞에 있으면 그리지 않음,
앱을 닫아 프로세스가 없어도 휴대폰이 앱을 깨워 표시, 누르면 받은편지함(닫힌 앱도), `notification_count` 의 런처 배지(삼성 홈 화면). 아직 보지 않은 것: 오류 본문의 모양 · 바꿔 쓴 알림이 다시 울리는지 · 절전(Doze) 중 도착.
- **알려진 한계**: Google 은 기기마다 언제 몇 통(N)이 왔는지는 안다(내용 · 주소는 가지 않는다). 푸시는 최선을 다할 뿐이다(FCM 장애 · 강제 종료한 앱 · 배터리 제한). 웹에서 다 읽어도 이미 뜬 알림은 다음 알림 · 앱을 열 때까지 그대로다.
나에게 보낸 메일도 N 에 든다(그 메일이 알림의 방아쇠가 되지는 않는다). 서버가 10분 넘게 멈췄으면 그동안 온 메일은 다음 알림의 N 으로만 알린다.
**인스턴스를 늘리면 알림이 겹친다**(붙잡기 · 간격이 프로세스 메모리 — nestjs 스킬의 Redis 목록).
Google Play 서비스가 26.12(versionCode 261200000)보다 옛 휴대폰은 SDK 가 FID 대신 옛 토큰 길로 돌아가 등록 소식의 값이 FID 모양이 아닐 수 있다 — `:` 가 섞이고 64자를 넘으면
`PUSH_FID_PATTERN` 에 걸려 PUT 이 400 `VAL_001` 이다(스위치로 켜면 그 사전 문구, 저절로 보낸 PUT 은 앞으로 올 때마다 다시). 후속 거리: 앱이 FID 모양이 아닌 값을 받으면 "이 휴대폰에서는 받을 수 없음"으로 둔다.
**클라이언트 — 앱**(Flutter — 앱 디자인 README "새 메일 알림 · 업데이트 · 앱 아이콘", 2026-11-07)
- 등록은 FCM 의 FID 로 한다(`register()` · `onRegistered` — 자동 초기화는 끈다). 안드로이드는 매니페스트에 `firebase_messaging_installation_id_enabled` = `true` 가 있어야 한다 — 없으면 SDK(firebase-messaging 26.0.1)가 `register()` · `unregister()` 를 곧바로 거절한다(2026-11-08 실기기). 보낼 조건 = 받을 수 있는 휴대폰 ∧ `pushAvailable` 이 거짓이 아님(모르면 보낼 수 있는 것으로) ∧ 메일을 쓸 수 있는 세션(제한 단계가 아님) ∧ 이 휴대폰에서 켬(처음 켜짐) ∧ 알림 권한 ∧ 새 메일 채널.
`pushAvailable: false` 면 PUT 하지 않는다 — 그 전에 등록된 줄은 이어지고, 새 휴대폰은 앱이 앱 설정을 다시 받은 뒤(앞으로 올 때 — 1시간마다) 등록한다.
- **PUT** 은 FID 를 받았을 때(등록 뒤 첫 소식 · FID 가 바뀜) · 세션이 열릴 때마다 한 번(앱을 띄울 때 — 지난 실행의 FID 로 바로) · 설정 스위치를 켤 때만 보낸다. `appVersion` 은 앱 판(모르면 `0.0.0`).
스위치로 켤 때만 실패를 알리고(그 코드의 사전 문구) 이 휴대폰의 값을 끔으로 둔다(처음 값 켬에서 허락하고 켰어도 — 스위치는 꺼진 채, 저절로 다시 하지 않는다).
저절로 보낸 PUT 의 실패는 조용히 다음 기회(앞으로 옴 · FID 바뀜 · 다음 로그인)에 다시 한다 — 등록이 된 뒤로는 앞으로 와도 보내지 않는다.
- **DELETE** 는 설정 스위치를 끌 때(지운 뒤 꺼진다 — 실패면 켬 그대로, 알린다)와 앞으로 올 때 권한 · 새 메일 채널이 꺼진 것을 알았을 때(조용히)만.
**로그아웃 · 세션 끝에는 부르지 않는다** — 앱은 토큰을 지운 뒤 서버 logout 과 함께 알림창을 지우고 FCM `unregister()` 로 FID 를 버린다(서로 기다리지 않는다).
풀지 못하면 다음 실행 · 로그아웃 상태로 앞으로 올 때 다시 푼다. 그사이 같은 휴대폰에서 다른 직원이 로그인하면 풀기 뒤에 다시 등록해 같은 FID 로 PUT 한다(서버가 같은 FID 의 앞 줄을 지운다).
그 밖의 앞 직원 줄은 서버가 지운다 — 끊은 세션은 보낼 때 바로 빠지고, 끝난 세션의 줄은 5분 정리가 지운다.
- **unseen** 은 앱이 앞으로 올 때와 앞에서 새 메일 알림을 받았을 때 보낸다 — 30초에 한 번(그사이 다시 오면 끝에 한 번 더), 조용히. 앱이 앞에 있을 때만 보내고, 기다리는 동안(30초 간격 끝 · 아래 1.5초) 뒤로 가면 그 자리에서 보낸다(N 을 "마지막으로 본 뒤"로 세게).
앞에서 받은 알림 뒤의 unseen 은 **받은 때부터 1.5초가 지나기 전에는 보내지 않는다** — 서버가 `seen_at` 의 초부터 같은 초를 세므로, 그 알림을 일으킨 메일과 같은 초에 보내면 목록으로 보고 열지 않은 그 메일이 다음 알림에 다시 세어진다(2026-11-08 실기기 — 한 통이 "2통"이 됨).
그 1.5초 안에 뒤로 가면 그 자리에서 보내므로, 그 좁은 경우에는 같은 초가 되어 그 메일이 다음 알림에 다시 들 수 있다(받아들였다).
- 세 경로의 404 `RES_001`(그 경로가 없는 옛 서버)은 조용히 넘기고 등록 없이 지낸다.
- **app-config** 는 앱을 띄우자마자 한 번 묻는다 — 인증 인터셉터 줄 밖이라 세션 되살리기(미리 갱신)를 기다리지 않는다. 앞으로 올 때 마지막으로 받은 지 1시간이 지났으면 다시(실패는 1분 간격으로 다시 — 알리지 않는다).
404 는 옛 서버(최소 판 없음 · `pushAvailable` 거짓). 이 앱의 판이 `minSupportedVersion.android` 보다 낮으면 어느 화면에서든(로그인 전 포함) 업데이트 화면이다(쓰던 메일은 임시보관함에 저장) — 모양이 틀리거나 받지 못했거나 이 앱의 판을 모르면 막지 않는다. 서버가 최소 판을 내리면 다음 확인에서 풀린다.
- **받는 알림**: 앱이 앞에 있으면 휴대폰이 그리지 않는다 — 앱은 앞에서 받은 `new-mail` 로 새 메일을 바로 확인하고(2절 "새 메일 확인"의 클라이언트 — 앱), 앞으로 올 때 알림창의 `new-mail` 알림(tag)을 지운다.
누르면 늘 메일 탭 받은편지함이다 — `kind` 로 가르지 않는다(`session-ended` 를 눌렀으면 받은편지함의 첫 요청이 세션 끝을 알아 로그인 화면으로 간다).
작은 아이콘 · 색은 서버가 싣지 않는다 — 앱 매니페스트의 기본값(`ic_stat_ara` · #0E7C86)이고, 채널 `new_mail`(이름 "새 메일" · 중요도 높음)은 앱이 만든다.
---
## 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 // 바이트
pendingSend: PendingSend | null // 아직 보내지 않은 내 메일(보내기 취소를 기다리는 중 · 예약)이면 그 정보 — 9단계 "보내기 취소 · 예약 발송". 그 밖에는 null
}
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 → "외부 이미지 차단" 안내 (보이게 했으면 false)
imagesShown: boolean // 외부 이미지를 중계 주소로 바꿔 넣었으면 true — 9단계 "외부 이미지 보기" (`images=show` 또는 설정)
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 }
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` (보이게 할 때는 중계 주소로 바꾼다 — 아래 "외부 이미지 보기", 9단계).
본문 안 인라인 이미지(`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` |
| `POST /api/mailboxes` | `{ name, parentId? }` (아래 "메일함 관리") | 201 `Mailbox` | 400 `VAL_001`, 404 `RES_001`(없는 parentId), 409 `MAIL_009`(같은 자리에 같은 이름), 503 `MAIL_001` |
| `PATCH /api/mailboxes/{id}` | `{ name }` | 200 `Mailbox` | 400 `VAL_001`(기본 메일함), 404 `RES_001`, 409 `MAIL_009`, 503 `MAIL_001` |
| `DELETE /api/mailboxes/{id}` | — | 200 `MailboxDeletion` — 안의 메일을 휴지통으로 옮긴 뒤 지운다 | 400 `VAL_001`(기본 메일함 · 하위 메일함 있음), 404 `RES_001`, 409 `MAIL_015`(남은 메일이 붙잡힌 메일뿐 — 9단계 "보내기 취소 · 예약 발송"), 429 `REQ_001`(같은 메일함의 처리가 진행 중), 503 `MAIL_001` |
| `PATCH /api/mailboxes/{id}/messages` | `{ isRead: true }` — 그 메일함 전체를 읽음으로 | 200 `MailboxBulkResult` | 400 `VAL_001`, 404 `RES_001`, 429 `REQ_001`(같은 메일함의 처리가 진행 중), 503 `MAIL_001` |
| `DELETE /api/mailboxes/{id}/messages` | — 휴지통 · 스팸메일함 비우기(영구 삭제) | 200 `MailboxBulkResult` | 400 `VAL_001`(휴지통 · 스팸메일함이 아님), 404 `RES_001`, 409 `MAIL_015`(남은 메일이 붙잡힌 메일뿐 — 9단계), 429 `REQ_001`(같은 메일함의 처리가 진행 중), 503 `MAIL_001` |
| `GET /api/mail-vacation` | — | 200 `MailVacationState` (아래 "부재중 자동 응답" — `MailVacation` + 7단계의 `isActive`) | 503 `MAIL_001` |
| `PUT /api/mail-vacation` | `MailVacation` (전체 교체 — `isActive` 는 보내지 않는다) | 200 `MailVacationState`(저장한 값) | 400 `VAL_001` · `MAIL_013`(필터와 함께 만든 엔진 규칙이 한도를 넘음 — 6단계), 503 `MAIL_001` |
| `GET /api/mail-filters` | — | 200 `MailFilterSet` (아래 "메일 필터", 6단계) | 503 `MAIL_001` |
| `PUT /api/mail-filters` | `{ rules, version }` (전체 교체) | 200 `MailFilterSet` | 400 `VAL_001` · `MAIL_013`(만든 규칙이 엔진 한도를 넘음), 409 `MAIL_012`(그사이 다른 곳에서 필터가 바뀜 — 저장 안 함), 503 `MAIL_001` |
| `POST /api/mail-filter-runs` | `MailFilterRunRequest` — 받은편지함에 이미 있는 메일에 규칙 하나를 적용한다(아래 "이미 받은 메일에 적용", 7단계) | 200 `MailFilterRun` — 붙잡힌 메일(9단계)은 옮기지 않고 표시만 | 400 `VAL_001`(규칙 검사 · 할 일 없음 · 깨진 cursor), 429 `REQ_001`(같은 직원의 적용이 진행 중), 503 `MAIL_001` |
| `GET /api/mail-quota` | — | 200 `MailQuota` | `MAIL_001` |
| `GET /api/mail-state` | — | 200 `MailState` (아래 "새 메일 확인") | 503 `MAIL_001` |
| `GET /api/mail-events` | — | `text/event-stream` (표준 래퍼 없음 — 아래 "실시간 알림", 5단계) | 401 `AUTH_001`, 429 `REQ_001`(한 직원의 연결 5개 초과), 503 `MAIL_001` |
| `GET /api/messages` | `mailboxId` · `cursor` · `limit`(기본 20, 최대 100) · `q` · 조건 `from` · `to` · `subject` · `body` · `after` · `before` · `hasAttachment`(14단계) — `mailboxId` 와 검색(`q` 또는 조건) 중 하나는 있어야 한다(검색만 있으면 휴지통·스팸메일함을 뺀 모든 메일함에서 찾는다 — 아래 "검색" · "검색 — 14단계") | 200 `{ items: MessageSummary[], pageInfo }` — 받은 시각 내림차순, `total` 포함(14단계 색인 방식은 첫 쪽만 · 1,000까지 — 넘으면 `null`) | 400 `VAL_001`, `MAIL_001` |
| `GET /api/messages/{id}` | `images`(선택 — `show` 면 외부 이미지를 보이는 본문, 9단계 "외부 이미지 보기") · 검색 쿼리 `q` · `from` · `to` · `subject` · `body`(선택 — 맞은 말 강조, 14단계) | 200 `MessageDetail` — **조회만으로 읽음 처리하지 않는다** | 400 `VAL_001`(모르는 `images` · 모르는 쿼리 — 9단계부터, 전에는 무시했다), 404 `RES_001` |
| `GET /api/messages/{id}/conversation` | `cursor` · `limit`(기본 100, 최대 100) | 200 `{ items: MessageSummary[], pageInfo, basis }` — 그 메일의 같은 대화, 오래된 차례(아래 "대화 — 14단계") | 400 `VAL_001`, 404 `RES_001`, 503 `MAIL_001` |
| `GET /api/conversations` | `mailboxId`(필수) · `cursor` · `limit`(기본 20, 최대 100) — 검색 쿼리는 받지 않는다 | 200 `{ items: ConversationSummary[], pageInfo, basis }` — 그 메일함을 대화로 묶은 목록(아래 "대화 — 14단계") | 400 `VAL_001`, 404 `RES_001`(없는 메일함), 503 `MAIL_001` |
| `GET /api/search-index` | — | 200 `{ status, done, total }` — 그 직원의 검색 색인 준비 상태(아래 "검색 색인", 14단계) | — |
| `PATCH /api/messages/{id}` | `{ isRead?, isFlagged?, mailboxId?, isJunk? }` (하나 이상 — `isJunk` 는 4단계 "스팸 · 원문") | 200 `MessageSummary` | 404 `RES_001`, 400 `VAL_001`, 409 `MAIL_015`(붙잡힌 메일의 `mailboxId` · `isJunk` — 9단계, 아래 "보내기 취소 · 예약 발송"), 503 `MAIL_001`(붙잡혔는지 확인하지 못함 — 바꾸지 않았다) |
| `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`(받는 사람 모두 거절 — 없는 주소 등)·`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`·`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` |
| `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` |
| `GET /api/scheduled-messages` | `cursor` · `limit`(기본 20, 최대 100) | 200 `{ items: MessageSummary[], pageInfo }` — 예약한 메일, 보낼 시각 차례, `total` 포함 (아래 "보내기 취소 · 예약 발송", 9단계) | 400 `VAL_001`, 503 `MAIL_001` |
| `DELETE /api/scheduled-messages/{id}` | — (`id` 는 보낸 사본의 메일 id) | 200 `{ draftId }` — 붙잡은 발송을 취소하고 임시보관함으로(보내기 취소 · 예약 취소). 이미 취소한 발송이면 다시 200(같은 id) | 404 `RES_001`(형식이 틀린 id 포함), 409 `MAIL_014`(이미 보냄 · 기다리는 발송이 아님), 503 `MAIL_001` |
| `GET /api/mail-images/{token}` | — | 바깥 이미지 (표준 래퍼 없음 — 아래 "외부 이미지 보기", 9단계) | 404 `RES_001`(까닭을 가리지 않는다) |
| `GET /api/mail-settings` | — | 200 `MailSettings` (아래 "보내기 · 읽기 설정", 9단계) | — |
| `PATCH /api/mail-settings` | `Partial` — 하나 이상 | 200 `MailSettings` | 400 `VAL_001` |
| `GET /api/mail-blocks` | — | 200 `MailBlockList` (아래 "보낸 사람 차단", 9단계) | 503 `MAIL_001` |
| `POST /api/mail-blocks` | `{ address }` — 주소 또는 `@도메인` | 201 `BlockedSender` (이미 있으면 200 — 꺼져 있던 규칙도 켠다) | 400 `VAL_001` · `MAIL_013`, 503 `MAIL_001` |
| `DELETE /api/mail-blocks/{address}` | — (URL 인코딩) | 200 `{ address }` | 404 `RES_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` |
| JSON 본문의 모양이 한도를 넘음 — 배열 하나 1,000개 · 값(객체 · 배열 · 원시 값) 10,000개 · 깊이 32단 | 400 `VAL_001` |
| JSON 본문이 UTF-8 이 아님 — `charset` 이 `utf-16` · `utf-32` · `utf-7` 등 (`charset` 을 붙이지 않거나 `utf-8` 이면 된다) | 400 `VAL_001` |
**JSON 본문 모양 한도 (2026-10-28 — 6단계 리뷰에서, 전부터 있던 경로도 같은 원인)**: 본문 크기 한도 안에서도 작은 객체 수만~수십만 개면 DTO 변환 · 검사가 몇 초씩 서버를 멈추고 수백 MB 를 썼다
(리뷰 측정 — 1MB 에 필터 규칙 8만 개 7.8초, 조건 15만 개는 500). 그래서 모든 JSON 본문을 DTO 에 닿기 전에 본다 — 읽기 전에 글자를 한 번 훑고, 읽은 뒤 한 번 더.
한도는 지금 계약의 어떤 본문보다 넉넉하다(가장 긴 배열 100 · 가장 깊은 본문 5단 · 값이 가장 많은 필터 50개 저장이 약 1,600개).
이 검사와 UTF-8 검사는 본문 크기 한도처럼 인증보다 앞이라, 로그인하지 않은 요청도 401 이 아니라 400 이다. JSON 은 UTF-8 만 받는다(RFC 8259 8.1 — 웹 · 앱 모두 UTF-8 로 보낸다).
메일함 정렬(확정 디자인 기준): 받은편지함 → 보낸편지함 → 임시보관함 → 스팸메일함 → 휴지통 → 보관함 → 그 외(`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 `SentMessage` — 아래 "받는 사람별 결과")를 돌려준다. 응답이 늦어 `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>", // 선택 — 보낸 뒤 서버가 그 초안을 지운다 (아래 "임시 저장")
"sendAt": "2026-10-30T00:00:00.000Z", // 선택 — 예약 발송: 지금부터 5분 뒤 ~ 7일 안 (9단계 "보내기 취소 · 예약 발송")
"largeFileIds": ["<올리기 id>"], // 선택 — 이 메일에 새로 싣는 내 대용량 파일(링크로 보낸다) (11단계 — 4절 "발송 · 임시 저장과 함께")
"bodyQuoteStart": 1234 // 선택 — body 에서 인용이 시작하는 자리 = 덩이를 넣을 자리 (4절)
}
```
**받는 사람별 결과 (발송 응답) — 6단계 앞 수정**
```ts
interface SentMessage {
id: string // 보낸편지함의 메일 id
rejectedRecipients: RejectedRecipient[] // 엔진이 받지 않은 사람 — 없으면 빈 배열
pendingSend: PendingSend | null // 엔진이 붙잡아 둔 발송이면 그 정보(보내기 취소 · 예약 — 9단계), 바로 보냈으면 null
}
interface RejectedRecipient {
email: string // 요청에 쓴 주소 그대로 (받는 사람 · 참조 · 숨은참조 모두 — 같은 주소는 한 번)
reason: 'not-found' | 'refused' | 'temporary' // 없는 주소 · 받는 쪽이 거절 · 지금은 받을 수 없음(한도 등)
}
```
- 엔진은 제출(`EmailSubmission/set`)을 만들면서 받는 사람마다 그 자리에서 SMTP 로 묻는다. 거절된 사람은 제출 기록의 `deliveryStatus` 에
`delivered: "no"` 와 SMTP 응답(`550 5.1.2 Mailbox does not exist` · `452 4.4.5 Rate limit exceeded` 등)으로만 남고 **다시 보내지도 반송하지도 않는다**
(Stalwart 0.16.27 — 2026-10-28 확인. 전에는 서버가 이것을 보지 않아, 없는 사내 주소로 보낸 메일도 "보냈습니다"로 끝났다). 그래서 서버가 제출한 뒤 결과를 읽어 알린다.
- 결과는 제출 다음 **따로** 읽는다(`EmailSubmission/get`) — 제출 응답(`created`)에는 없고, 같은 요청 안의 참조(`#ids`)는 `onSuccessUpdateEmail` 이 덧붙이는
응답 때문에 엔진이 풀지 못한다(같은 날 확인).
- 분류 — SMTP 응답 맨 앞의 기본 코드(`550`)와 확장 상태(`5.1.2`)만 읽는다(응답 글 안의 숫자는 보지 않는다). 4xx 면 `temporary`, 5xx 이고 확장 상태가 `5.1.x`(받는 사람 주소 문제)면 `not-found`,
그 밖의 5xx 와 읽을 수 없는 응답은 `refused` — 두 값이 어긋나면 기본 코드를 따른다(`450 5.1.1` 은 temporary).
`5.1.7` · `5.1.8`(보낸 사람 주소 문제)도 `not-found` 로 보이는데, 엔진의 제출 때 거절에는 나오지 않고 같은 키 재시도가 바깥 반송까지 읽을 때만 생길 수 있다.
`delivered` 가 `"no"` 가 아니거나 결과가 없는 사람은 받은 것으로 본다 — 바깥 주소는 대기열에 들어가 `250 Queued` 이고, 나중에 실패하면 지금처럼 반송 메일이 온다.
- 결과를 읽지 못하면(엔진 오류 · 시간) 보낸 것으로 보고 `rejectedRecipients: []` 로 답한다(warn 로그) — 제출은 이미 되었다.
결과 읽기는 발송 전체 55초 안에서 최대 5초(모두 거절일 때 사본을 지울 3초를 남긴다), 쓸 시간이 1초도 안 남으면 묻지 않는다.
- 같은 주소(대소문자 무시)는 한 번만 싣는다 — 받는 사람 → 참조 → 숨은참조 순서이고 처음 쓴 모양 그대로.
- **엔진이 받지 않는 주소**(9단계 리뷰 뒤 — 2026-10-29 실엔진): 발송은 늘 envelope(받는 사람 · 참조 · 숨은참조, 같은 주소는 하나)을 직접 준다.
엔진은 envelope 에 받지 않는 주소(모르는 최상위 도메인 `x@example.cmo` · 따옴표 앞부분 `"a b"@…` 등)가 있으면 제출 **전체**를 거절하고 그 가운데 첫 주소 하나만 알려 준다.
서버는 그 주소를 `not-found` 로 빼고 같은 메일을 나머지에게 다시 제출한다 — 붙잡기(9단계)는 그대로이고, 차례마다 **하나씩** 뺀다.
거절 글이 따옴표로 싼 주소와 정확히 같은 받는 사람부터 찾는다 — 따옴표 앞부분 안에 다른 받는 사람의 주소가 글자로 들어 있어도 그 사람은 빼지 않는다(2026-10-29 리뷰 2).
- 다시 내기는 20차례(뺀 주소 21개)까지다. 그래도 거절이 남고 받는 사람이 남아 있으면 사본을 지우고 400 `VAL_001`(21번째를 뺀 뒤 남은 사람이 없으면 `MAIL_010`).
- 남는 사람이 없으면 만든 사본을 지우고 400 `MAIL_010`(아래 "모두 거절"과 같다).
- 다시 낼 시간(발송 55초 안에서 10초 이상)이 모자라면 사본을 지우고 503 `MAIL_001` — 아직 아무것도 제출하지 않았으니 같은 키로 다시 보내면 된다.
발송 응답은 사본 정리까지 56초 안이다(정리는 남은 시간 안에서, 남은 시간이 없어도 1초까지는 기다린다).
- 다시 낸 제출이 요청째 거절되면(메서드 오류 · HTTP 4xx — 엔진이 실행하지 않았다) 사본을 지우고 첫 제출과 같은 오류로 끝난다.
결과를 모르는 실패(시간 초과 · 연결 끊김 · 5xx)는 지우지 않는다 — 나갔을 수 있어 같은 키의 재시도가 맡는다(첫 제출과 같다).
- 거절 글에 우리 주소가 없으면 지금처럼 400 `VAL_001`.
- 전에는 envelope 을 주지 않아 엔진이 그런 주소를 아무 말 없이 빼고 나머지에게만 보냈다 — 오타 도메인으로 간 사람이 응답에 없었다.
- 결과(`deliveryStatus`)를 읽지 못해도 envelope 에서 뺀 사람은 `rejectedRecipients` 에 싣는다. 같은 키의 재시도는 제출 기록의 envelope 에 없는 받는 사람을 `not-found` 로 다시 만든다.
- 주소는 대소문자를 무시하고 국제화 도메인은 `xn--` 꼴로 맞춘다 — 엔진은 유니코드 · `xn--` 모두 받고 제출 기록에는 `xn--` 로 둔다.
엔진의 거절 글은 로그에 남기지 않는다(뺀 주소만 가려서).
- **숨은참조**: 엔진은 내보내는 사본(사내 배달 · 바깥 SMTP 모두)에서 `Bcc` 머리글을 뗀다 — 보낸 사본에만 남는다(envelope 을 주든 말든 같다).
개발의 Mailpit 은 받은 envelope 가운데 머리글에 없는 주소를 자기 `Bcc:` 줄로 **맨 앞에 붙여** 보인다 — 엔진이 보낸 것이 아니다(머리글 없는 메일을 envelope 으로만 보내도 그렇다).
- **파일럿 공존 동안**(10단계 — mail/README.md "파일럿 공존"): 새 서버에 없는 사내 주소는 거절되지 않는다 — 엔진이 옛 서버(mailcow)로 중계하고,
그쪽이 받지 않으면 몇 초 뒤 반송 메일(`MAILER-DAEMON@<도메인>`)이 받은편지함에 온다(바깥 주소와 같은 모양). 그래서 그런 주소는 `rejectedRecipients` · `MAIL_010` · `MAIL_011` 에
나오지 않고, 제출 기록도 대기열에서 빠지면 `250 2.1.5 Queued` 로 돌아가 나중의 실패를 남기지 않는다. 시스템 계정(관리자 · 게이트웨이 · 사내 목록)은 그대로 `not-found`.
setup 이 받는 쪽 가드를 확인하지 못하면 중계를 켜지 않거나 다시 닫는다(종료 코드 1 — 열린 중계를 막으려고). 그동안은 평소처럼 곧바로 `not-found` 다.
- **모두 거절**(받은 사람이 아무도 없음) — 보내지 않은 것으로 다룬다. 이 요청이 만든 보낸 사본은 지우고(아무에게도 가지 않았다 — 그 요청이 방금 만든 메일이라 조건 없이 지운다),
이어 쓴 저장본(`draftId`)은 지우지 않는다. 거절 가운데 5xx 가 하나라도 있으면 400 `MAIL_010`, 모두 4xx 면 503 `MAIL_011`.
같은 키로 다시 보내면 새로 보낸다(지운 사본은 중복 방지에 걸리지 않는다).
사본을 지우지 못해도(엔진 오류 — warn) 코드는 그대로다. **알려진 한계**: 그때 보낸편지함에 사본 한 통이 남고, 같은 키 · 같은 내용의 재시도는 그 사본을 찾아
201 + 받는 사람 전원의 `rejectedRecipients` 로 답한다(이어 쓴 저장본도 지운다), 내용을 고친 재시도는 409 `MAIL_005` 다 —
지금 남은 결과만으로는 "제출 때 거절"과 "보낸 뒤 바깥에서 반송"을 가를 수 없어 다시 보내지 않는다.
제출 응답을 받지 못해 503 `MAIL_001` 로 끝났는데 엔진에서는 제출되어 모두 거절된 경우도 같다(사본을 지우는 데까지 가지 못했다) — 30초 잠금이 풀린 뒤 같은 키의 재시도가 그 사본을 찾는다.
- **일부 거절** — 보낸 것이다. 201 에 `rejectedRecipients` 를 싣고, 보낸 사본은 보낸편지함에 남고, `draftId` 는 지운다.
- 같은 키의 재시도가 처음 메일을 찾아 그대로 돌려줄 때도 그 메일의 제출 기록으로 `rejectedRecipients` 를 다시 만든다(찾지 못하면 빈 배열).
- 로그 — 모두 거절은 warn(`[MAIL_010]` · `[MAIL_011]`), 일부 거절은 log. 주소는 가리고(`maskEmail`) SMTP 상태 코드(`550 5.1.2` 등)만 남긴다.
`MAIL_011` 은 503 이라 전역 예외 필터가 5xx 규칙대로 error(스택 포함)로 한 줄 더 남긴다(`MAIL_001` 과 같다) — 웹메일 발송은 엔진 한도에 걸리지 않게 했으므로 드물다.
- 엔진 기본 한도 중 받는 사람 단위의 것("보낸 도메인 → 한 받는 사람" 시간당 25통)은 **로그인한 직원의 발송에는 걸지 않는다** —
사내 메일은 보낸 도메인이 모두 같아, 동료 모두 합쳐 한 사람에게 시간당 25통을 넘으면 그 뒤가 거절되던 문제(같은 날 확인). mail setup 이 맞춘다(mail/README.md "엔진 한도").
바깥에서 들어오는 메일(인증 없는 SMTP)에는 그대로 건다. 다른 기본 한도 "보낸 IP 마다 초당 연결 5개"는 연결할 때 세는 것이라 웹메일 발송에는 원래 걸리지 않고, 그대로 둔다
(조건을 붙이면 엔진이 그 한도를 연결이 아닌 MAIL FROM 단계로 옮겨 바깥 접속을 더는 세지 않는다 — Stalwart 0.16.27 소스로 확인).
- **클라이언트**
- 일부 거절 — 보낸 것으로 처리한다(쓰기 화면을 닫고 떠난다). 보냈다는 알림 대신 실패 알림(「보낸편지함 보기」 링크 — 보냈다는 알림과 같은 글, 단추 있는 알림과 같은 10초)으로
"보냈지만 N명은 받지 못했습니다. nobody@arasystem.it.kr (없는 주소)" — 두 명까지 주소와 까닭을 적고 더 있으면 " 외 N명".
까닭 문구는 `not-found` "없는 주소" · `refused` "받는 쪽이 거절" · `temporary` "지금은 받을 수 없음".
- `MAIL_010` — 받는 사람 칸의 안내 띠(`MAIL_002` 와 같은 자리 · 같은 초점)로만 알린다(토스트 없음). 쓰던 메일 · 키는 그대로.
- `MAIL_011` — 인터셉터 알림(사전 문구). 쓰던 메일 · 키는 그대로 — 잠시 뒤 다시 누르면 같은 키로 간다.
**전달**: 원본 메일 `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`)를 편집기로 옮기며, 편집기가 모르는 요소(이미지 등)는 빠진다.
- **본문 안 이미지는 이번 단계에서 넣지 않는다** — 붙여 넣거나 끌어다 놓은 이미지는 첨부 파일로 올린다 (첨부 한도 규칙 그대로).
서명 그림만 예외다 (아래 "서명").
### 첨부 업로드 한도
- 한도를 넘는 파일은 일반 첨부가 아니라 **대용량 첨부(링크)** 로 보낸다 — 4절(11단계). 아래 일반 첨부의 한도 · 흐름은 그대로다.
- 파일당 `MAIL_ATTACHMENT_MAX_SIZE_MB` (기본 20MB) — 넘으면 413 `FILE_002`
- 메일 한 통의 첨부 합계(전달하는 원본 첨부 포함, 같은 첨부를 두 번 붙이면 두 번 센다) `attachmentTotalMaxBytes`
(`MAIL_ATTACHMENT_TOTAL_MAX_SIZE_MB`, 기본 25MB) — 넘으면 발송이 413 `FILE_002`.
클라이언트는 첨부를 추가할 때 이 값으로 **미리 막고** 안내한다 (다 쓰고 보낼 때 알게 하지 않는다).
- **엔진의 임시 업로드 한도**(7단계에 찾음): 계정마다 1시간 동안 올린 크기 합계 · 개수(Stalwart `uploadQuota` · `maxUploadCount` · `uploadTtl`). 엔진 기본값은 50MB · 1,000개 —
넘으면 올린 것이 만료될 때까지(1시간) 그 직원의 첨부 올리기와 필터 · 부재중 저장(엔진에 규칙 글을 올린다) · 서명 그림이 든 발송이 429 `REQ_001` 로 막힌다
(2026-10-29 개발 엔진: 1,000개를 넘기자 필터 저장이 막혔고, 20MiB 두 개 뒤 세 번째 업로드가 429 — 크기 · 개수 어느 쪽이든 같은 `REQ_001`, 백엔드 로그 `"call":"upload"`).
그래서 mail setup 이 크기 합계를 첨부 합계 한도의 20배(기본 25MB × 20 = 524,288,000바이트)로 둔다 — 개수(1,000)는 그대로(mail/README.md "엔진 한도").
- 허용 형식(확장자 + 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)
largeFiles: (LargeFile | MissingLargeFile)[] // 대용량 첨부 — 내 파일(올리는 중 포함). id 는 저장해도 바뀌지 않는다 (11단계 — 4절 "임시 저장")
}
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: 'contact' | 'directory' | 'recent' // contact = 내 주소록(8단계), directory = 사내(8단계부터 엔진의 사내 목록 — 3절), recent = 최근 주고받은 주소
company: string | null // contact 만 — 그 연락처의 회사 (그 밖에는 null)
emailLabel: 'work' | 'personal' | 'other' | null // contact 만 — 그 주소의 종류 (그 밖에는 null)
}
```
- `q` 가 있으면 이름·주소에 그 글자가 든 것만 준다(대소문자 무시). 8단계부터 쓰기 화면과 **같은 맞추기**다 — 이름은 초성 · 조합 중 글자를 섞어도 맞고
(`ㄷㅇ` · `도ㅇ` → 이도윤, 윤을 치는 중인 `도유` · `동` → 도윤), 주소는 그 글자가 든 것(완전한 주소를 쳤으면 같은 아이디도). 규칙의 짝은 frontend `shared/utils/recipient-match.ts` 이고
두 쪽 테스트가 같은 표본 표를 쓴다. 응답은 커서 페이지네이션 규칙 그대로(`{ items, pageInfo }`) —
커서는 받은 그 `q` 로만 쓴다(다른 `q` 와 함께 보내면 400 `VAL_001`).
- **순서**: 주소록(8단계) → 사내 → 최근 주고받은 주소. 주소록은 이름 차례(3절), 사내 · 최근은 주고받은 횟수와 최근성으로.
여러 묶음에 있는 주소는 앞 묶음으로 한 번만 나온다 — 3절 "메일과 함께".
- **최근 주고받은 주소**는 그 사람의 보낸편지함 최근 500통(받는 사람·참조·숨은참조)과 받은편지함 최근 500통(보낸 사람)에서 모은다 —
다른 직원의 메일함은 읽지 않는다. 나·메일 엔진 관리자·게이트웨이·막은 계정·noreply 류는 뺀다.
- **사내 목록**은 8단계부터 엔진의 사내 목록(3절 "사내 목록")이다. 그것을 읽지 못할 때만 아래 옛 규칙으로 대신한다 —
로그인에 성공한 직원(주소·이름)을 서버가 기록해 만든다(직원 역할에는 엔진의 계정 목록 권한이 없다).
한 번도 로그인하지 않은 직원은 그 사람과 메일을 주고받기 전까지 나오지 않는다. 최근 90일 안에 로그인한 직원만 사내로 추천한다
(퇴사자가 계속 맨 앞에 나와 반송되지 않게 — 웹메일 세션은 최대 30일이라 쓰는 사람은 그 안에 다시 로그인한다).
- **알려진 한계**: 최근 주고받은 주소의 이름은 받은 메일의 보낸 사람 표시 이름에서 온다 — 바깥 사람이 동료 이름을 표시 이름으로 쓰면 그 이름으로 추천된다.
추천 줄은 늘 주소를 함께 보이고, 사내와 최근 묶음을 나눠 보인다.
- 응답은 `Cache-Control: private, no-store`. 서버는 사용자마다 10분 캐시하고(서버 메모리, 최대 500명), 그 사람이 메일을 보내면 다시 모은다.
같은 사람의 동시 요청은 한 번만 모은다. 로그에는 개수만 남긴다.
- **클라이언트**: 쓰기 화면을 열 때 `q` 없이 `limit=100` 으로 한 번 받아 두고 치는 대로 거른다(초성 포함). 맞는 것이 모자라고 더 있을 때만 `q` 로 다시 묻는다(250ms 기다린 뒤) —
8단계부터는 초성 · 조합 중 글자도 묻는다(주소록이 크면 받아 둔 100줄이 주소록으로 차서 사내 · 최근 주소가 그 안에 없다). 맞추기가 같아서 받아 둔 것에서 8개를 찾았으면 서버에 물어도 같은 8개다.
두 쪽 모두 유니코드 정규형(NFC)으로 맞추지 않는다 — 화면이 원래 글자 위치로 강조하기 때문이다(다른 정규형으로 저장된 이름은 그 글자로 맞지 않는다).
### 새 메일 확인 (`/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번 + 평소 목록 · 더 보기"다(3단계).
5단계부터는 실시간 연결이 살아 있는 동안 1분 확인 대신 "바뀔 때마다(1초 모아서) 보이는 탭 수 × 1번"이다 — 사무실이 바쁠수록 늘 수 있다. 운영 전환 때 직원 수에 맞춰 정한다.
- 5단계부터는 실시간 연결(아래 "실시간 알림")이 살아 있는 동안 1분 확인을 멈추고, 끊기면 1분 확인으로 돌아간다. 이 API 와 규칙은 그대로다.
### 검색 (`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초였다.
- **알려진 한계** (엔진 색인 때문 — 14단계부터는 그 직원의 자체 색인을 채우는 동안(`building`)의 검색에만 남는다, 아래 "검색 — 14단계"): 붙여 쓴 말의 일부('회의실'의 '회의', '서버이전'의 '서버'), 앞부분만 친 말('견적' → '견적서'),
끝이 바뀐 말('보냈습니다'의 '보내'), **제목 맨 앞의 말머리**('[공지]' · '[긴급]' · '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 · "검색 닫기"는 검색 전 메일함으로 돌아간다. 임시보관함의 결과를 누르면 지금처럼 쓰기 화면에서 이어 쓴다.
### 검색 색인 (`GET /api/search-index`) — 14단계 (색인 바탕 — 검색은 아래 "검색 — 14단계", 대화는 "대화 — 14단계")
14단계는 엔진 검색 대신 **서버의 자체 색인**(지금 DB — 디자인 README 「14단계 — 검색 · 대화 묶기」)으로 찾는다. 이 절은 그 바탕 — 색인을 만들고 맞추는 규칙과 준비 상태다.
- **색인에 드는 것**: 직원 주소 + 엔진 메일 id 마다 한 줄 — 메일함 · 키워드 · 받은 시각 · 답장 관계(Message-ID · In-Reply-To · References) · 제목 · 보낸 사람 · 받는 사람 · 참조 · 첨부 이름 · 본문 글(앞 2만 자 — 잰 뒤 정한다).
메일 글이 DB 와 매일 백업에 한 벌 더 들어간다(사용자가 받아들였다). 퇴사 마무리는 이 표도 지운다. 언제나 로그인한 직원 주소로만 읽고 쓴다.
- **만드는 때**: 직원이 웹메일에 처음 로그인할 때(비밀번호가 만료된 로그인은 빼고 — 새 비밀번호를 정하면 만든다) 또는 아래 경로를 처음 물을 때 그 직원의 상태 줄을 `building` 으로 만든다. 로그인한 적 없는 계정은 색인하지 않는다.
- **지난 메일 채우기**: 밤에만 한 사람씩(KST 22:00 ~ 02:45 · 05:00 ~ 07:30 — 03:00 · 04:00 · 04:30 엔진 정리 · 03:30 백업을 피한다). 다 채우면 `ready`.
- **새로 온 · 바뀐 · 지운 메일**은 엔진의 변경(Email/changes)으로 바로 맞춘다 — 실시간 연결이 알릴 때 · 5분마다 · 검색 바로 전. 엔진이 변경을 셀 수 없다고 하면 그 직원 전체를 대조한다.
비밀번호가 만료됐거나 잠긴 계정은 대리 접근이 막혀 건너뛰고, 다음 로그인 · 아래 경로를 물을 때 다시 한다.
- `GET /api/search-index` → `{ status, done, total }`
- `status`: `building`(지난 메일을 채우는 중 — 그동안 그 직원의 검색 · 대화는 지금 방식(엔진)이고, 화면은 검색 결과 머리 아래에 "검색 준비 중" 줄을 보인다) · `ready`.
- `done`: 채운 메일 수(`total` 을 넘지 않는다) · `total`: 채우기를 시작할 때의 메일 수(시작 전이면 `null`, `ready` 면 `done` 과 같다).
- **조회지만 상태 줄을 만들고 멈춤을 푼다** — 줄이 없던 직원은 이때 `building` 으로 만들고, 대리 접근이 막혀 멈췄던 줄은 다시 해 보게 한다(로그인한 직원이 물었으니 다시 될 것이다). 같은 직원이 여러 번 물어도 결과는 같다(멱등).
- 로그인 필요(401 `AUTH_001`) · 제한된 세션은 403 `AUTH_010`(6절).
- 로그 가림: 검색 조건의 이름 `from` · `to` · `subject` · `body` 도 `q` · `cursor` 처럼 길이만 남긴다(`maskUrlForLog`). 개발 환경도 DB 쿼리 로그(매개변수)를 남기지 않는다 — 메일 글이 DB 에 있다.
### 검색 — 14단계 (자체 색인 · 조건 검색 · 강조)
- **방식**: 그 직원의 색인이 `ready` 면 색인으로, 아니면(`building`) 엔진으로 찾는다(위 3단계 절의 규칙 · 한계 그대로 — 조건은 엔진 필터로, 낱말이 통째로 같을 때만).
방식은 첫 쪽에서 정해지고 커서가 그 방식을 잇는다 — 쪽을 넘기는 중에 준비가 끝나도 그 검색은 처음 방식대로다. 색인 방식은 찾기 바로 전에 그 직원의 색인을 한 번 맞춘다(1.5초 안 — 방금 온 메일도 찾아지게).
- **쿼리** (`GET /api/messages` — 3단계의 `mailboxId` · `q` · `cursor` · `limit` 에 더한다)
| 이름 | 찾는 곳 | 검사 |
| --- | --- | --- |
| `q` | 보낸 사람 · 받는 사람 · 참조 · 제목 · 본문 · 첨부 이름 | 3단계와 같다 |
| `from` | 보낸 사람(이름 · 주소) | 넷 모두 `q` 와 같은 검사 — 앞뒤 공백을 지우고 공백뿐이면 없는 것, 1~200자(UTF-16), 낱말 8개, 길이를 먼저 본다 |
| `to` | 받는 사람과 참조 | |
| `subject` | 제목 | |
| `body` | 본문 | |
| `after` · `before` | 받은 시각이 `after` 이상 · `before` 미만 | ISO 8601 UTC(`Z` · 소수 초 3자리까지 · 있는 날짜) |
| `hasAttachment` | 진짜 첨부가 있는 메일만(목록의 `hasAttachment` 와 같은 판정) | `true` 만 받는다 |
`q` 나 조건 하나라도 있으면 검색이다(검색 칸이 비어도 조건만으로). 메일함 범위는 3단계와 같고(`mailboxId` 가 없으면 휴지통 · 스팸메일함을 뺀 모두), `mailboxId` 도 검색도 없으면 400 `VAL_001`.
**`q` 와 조건 칸 넷(`from` · `to` · `subject` · `body`)을 합친 낱말은 8개까지**다(넘으면 400 `VAL_001` — 색인을 채우는 동안의 엔진 방식이 낱말마다 조사 형태를 붙여 묻기 때문에, 3단계에서 잰 비용(낱말 8개 · 조건 472개 · 0.16초) 안에 두려고. 화면은 보내기 전에 같은 수를 본다).
검색 글의 NUL(U+0000)은 색인과 같이 U+FFFD 로 바꿔 찾는다.
화면은 고른 날짜를 브라우저 시간대의 시작일 0시 · 끝나는 날 다음 날 0시로 바꿔 보낸다(부재중과 같은 방식).
- **찾는 규칙 (색인 방식)**
- 부분 일치 — 낱말이 그 칸의 글 어디에 있어도 맞는다('계약' → 계약서 · 계약은 · 계약직, 제목 맨 앞의 [공지] · Re: 도). 영문 대소문자 · 전각 영숫자는 가리지 않는다(NFC).
- 여러 낱말은 모두 맞아야 하고 큰따옴표는 구절이다(3단계와 같다). 구절은 줄바꿈 · NBSP 로 갈린 글에도 맞는다 — 띄어쓰기 두 칸에는 맞지 않는다.
- **한 글자 낱말**: 두 글자 넘는 낱말과 함께면 모두 찾는다. 한 글자 낱말뿐이면 제목 · 사람(보낸 사람 · 받는 사람 · 참조) · 첨부 이름에서만 찾고 본문은 찾지 않는다(2026-11-05 사용자 확정 — 본문을 다 훑으면 메일이 많은 직원은 수 초. 화면은 결과 머리에 한 줄 알린다).
- **본문 강조**(목록 미리 보기 · 읽기 화면)는 본문을 찾은 두 글자 이상 낱말만이다. 한 글자 낱말뿐이면 본문에 강조하지 않고(제목 · 첨부 이름은 그대로), 두 글자 이상 낱말과 섞이면 그 낱말만 강조한다 —
함께 찾은 한 글자 낱말은 본문에서 맞았어도 강조하지 않는다(본문 곳곳에 흩어진다 — 2026-11-06 실사용 시험). 두 글자 이상 낱말이 보낸 사람 · 받는 사람 조건에만 있어도 섞인 검색이다.
- 본문은 앞 2만 자까지 찾는다. 첨부 파일 안의 글 · 끝이 바뀐 말('보내' → '보냈습니다')은 찾지 않는다(첨부 이름은 찾는다). 받는 사람 칸은 주소들을 이어 보므로 구절이 이웃한 두 주소에 걸쳐 맞을 수 있다(작은 한계).
- **응답 (목록)**
- 항목(MessageSummary)에 `searchHighlight: { subject: [start, end][], preview: { text, ranges: [start, end][] } } | null` — 색인 방식 검색에서만 차고, 메일함 목록 · 엔진 방식은 `null`.
위치는 NFC 글의 UTF-16 [시작, 끝)이고, 강조가 있으면 `subject` 도 NFC 로 싣는다. `preview.text` 는 본문에서 맞았으면 맞은 말 둘레 한 줄(잘린 쪽에 "…"), 아니면 엔진 미리 보기다.
본문의 맞은 곳은 이 차례로 고른다(2026-11-06 실사용 시험 — 답장은 처음 맞은 곳이 거의 늘 원문 인용 안이었다).
① 인용 줄(앞 공백 뒤 `>`) 밖의 첫 맞은 곳 — 둘레는 인용 줄 전까지.
② 인용 안에서만 맞았고, 본문에서 맞은 말이 모두 검색어(`q`)의 낱말로 제목 · 보낸 사람 · 받는 사람과 참조 · 첨부 이름에서도 맞았으면 인용 밖의 첫 글 — 맞은 곳이 없으면 `ranges` 는 빈 배열, 인용 밖 글이 없으면 ③.
③ 그 밖에는 줄 앞 `>` 표시(겹친 것 · 사이 공백)를 뺀 인용 조각을 그 인용 안에서만 — `ranges` 는 보이는 글 기준.
내용 조건(`body`)의 낱말은 ②에 들지 않는다. `>` 없는 인용 꼴("-----Original Message-----" 등)과 인용 머리 줄("…님이 작성:")은 알아보지 않는다(알려진 한계).
- **`hasNext: true` 인데 항목이 `limit` 보다 적거나 0개일 수 있다**(색인 방식) — 한 요청이 확인하는 메일 수에 상한이 있어(3,000 — 1초 안에 답하려고) 그때까지 찾은 것과 이어 찾을 커서를 준다. 화면은 커서가 있으면 이어 묻는다(빈 쪽이 이어지면 몇 번까지만 — 화면 상수).
- `total`: 첫 쪽에서 셀 수 있으면 0~1,000, 1,000 을 넘거나 이 요청에서 다 세지 못했으면 `null`, 다음 쪽은 늘 `null`(엔진 방식은 3단계와 같다).
**화면은 `null` 이면 통 수를 보이지 않는다** — "1,000통 넘게"라고 쓰지 않는다(다 세지 못한 것일 수도 있다).
- 커서는 불투명한 문자열이다. 방식 · 메일함 · `q` · 모든 조건이 지문에 들어가 다른 검색에 쓰면 400 `VAL_001`. 3단계의 옛 커서는 그대로 통한다.
- **읽기 화면** (`GET /api/messages/{id}`)
- `images` 에 더해 `q` · `from` · `to` · `subject` · `body` 를 받는다(검사는 목록과 같다 — `from` · `to` 는 받기만 하고 강조하지 않는다). `after` · `before` · `hasAttachment` · `cursor` · `limit` 은 받지 않는다(400 `VAL_001`) — 화면은 검색 주소의 조건 가운데 이 다섯만 넘긴다.
- 강조할 말이 있으면 `searchHighlight: { subject: Range[], attachments: { [첨부 id]: Range[] }(맞은 첨부만), textBody: Range[] }` 를 싣고, `subject` · 첨부 이름 · `textBody` 를 NFC 로 싣는다. 없으면 `null`.
- `htmlBody` 는 **정화한 뒤** 맞은 글 조각에 속성 없는 `` 를 넣는다 — 태그를 건너는 말 · 속성 · 주석 · `style` 안은 건드리지 않고, 한 통에 1,000개까지.
- 강조할 말: `q` 는 제목 · 본문 · 첨부 이름에, `subject` 는 제목에, `body` 는 본문에. `from` · `to` 는 강조하지 않는다(섞인 검색인지 정하는 데만 쓴다). 본문에는 목록과 같이 본문을 찾은 두 글자 이상 낱말만이다. 화면은 `` 를 노랑 `#FCE7A4` · 글자색 그대로로 그린다(디자인 README 14단계).
- **강조가 든 응답은 보이기만 한다** — 답장 · 전달의 원문 인용 · 인쇄 · 다른 화면의 메일 기억(목록 · 스토어)에는 검색 쿼리 없이 받은 응답을 쓴다(보내는 정화는 `mark` 를 지우지만, 인용 · 인쇄 모양이 검색에 따라 달라지지 않게).
- 한 글자 낱말뿐이라 본문을 찾지 않았다는 표시는 응답에 없다 — 화면은 서버와 같은 낱말 나누기(짝 — backend `shared/utils/search-text.ts` · 표본 표 `backend/test/search-text-samples.ts`)로 알아보고 결과 머리에 한 줄 알린다.
- 보낸 사람이 본문에 직접 쓴 ``(받은 메일 정화가 남긴다)도 같은 노랑으로 보여 검색 강조와 가르지 않는다(드물고 브라우저 기본도 노랑이다 — 알려진 한계).
- **클라이언트 — 화면 주소**(14단계 웹, 3단계 주소에 더한다): 조건도 검색어처럼 화면 주소에 싣는다(앞단 nginx 의 접근 로그는 쿼리를 남기지 않는다).
- 꼴: `/mail/search?q=…&in=<메일함 id | all>&from=…&to=…&subject=…&body=…&since=YYYY-MM-DD&until=YYYY-MM-DD&attachment=1`, 결과에서 연 메일은 `/mail/search/<메일 id>?…`(같은 쿼리).
빈 칸은 싣지 않는다(검색어가 비면 `q` 도 뺀다 — 조건만으로 찾는다). `in` 이 비면 `all`.
- `q` · `from` · `to` · `subject` · `body`: 앞뒤 공백을 지우고 200자에서 자른다. 같은 이름이 여럿이면 첫 값, 모르는 이름은 무시한다.
- `since` · `until`: 브라우저 시간대의 날짜이고 **양 끝을 포함한다** — API 에는 `after` = `since` 날 0시, `before` = `until` 다음 날 0시(UTC ISO)로 보낸다. "최근 1주" 같은 기간은 고른 순간 날짜로 바꿔 싣는다(7일 전 · 달은 말일을 넘지 않는다).
있는 날짜이고 1970-01-01 ~ 9999-12-30 이어야 한다(아니면 그 날짜만 버린다). `since` 가 `until` 보다 늦으면 기간을 통째로 버린다. 한쪽만 있어도 된다.
- `attachment`: `1` 일 때만 `hasAttachment=true` 다(다른 값은 무시).
- 검색어와 조건 칸 넷을 합친 낱말이 8개를 넘으면 목록을 묻지 않고 "찾을 말이 너무 많습니다"를 보인다(손으로 고친 주소 — 화면의 [찾기]는 보내기 전에 막는다). 그때 연 메일은 강조 쿼리 없이 받는다.
- 읽기 화면 API 에는 `q` · `from` · `to` · `subject` · `body` 다섯만 넘긴다([이미지 보기]도 같은 쿼리로).
- **클라이언트 — 이어 찾기**: `hasNext` 인데 쪽이 모자라면 한 번에(열 때 · [메일 더 보기] · [더 찾기] · 목록 보기의 [다음]) 요청을 5번까지 보낸다(화면 상수 `MAIL_SEARCH_FILL_MAX_REQUESTS`) — 새로 20통이 차거나 `hasNext` 가 false 면 멈춘다.
그래도 못 찾았으면 [더 찾기] 화면이다. 다음 쪽의 `total` 이 `null` 이면 첫 쪽의 수를 남기고, 첫 쪽부터 `null` 이면 통 수 · "n / 전체"를 보이지 않는다.
목록을 새로 고치는 동안은 이어 받지 않는다(옛 커서로 붙지 않게).
- **클라이언트 — 준비 상태**: `GET /api/search-index` 를 받지 못하면(실패) "검색 준비 중" · 옛 한계 안내 · 한 글자 안내를 모두 보이지 않는다. 한 글자 안내는 `ready` 이고 본문을 찾을 말(검색어나 내용 조건)이 있을 때만이다.
- 새 에러 코드는 없다(`VAL_001` · `RES_001` · `MAIL_001`).
### 대화 — 14단계 (같은 대화 · 대화로 묶어 보기)
읽기 화면은 그 메일의 같은 대화를 늘 보이고, 목록은 사용자가 켜면 대화로 묶는다(디자인 README 「14단계 — 검색 · 대화 묶기」의 `Thread14` · `ListThreads14`). 검색 결과는 묶지 않는다.
- **대화의 기준(`basis`)** — 응답마다 싣는다.
- `index`(그 직원의 색인이 `ready`): 답장 관계(Message-ID · In-Reply-To · References)로 이어지고 **말머리를 뗀 제목이 같은** 메일이 한 대화다.
말머리는 앞의 `Re` · `RE` · `Fw` · `FW` · `Fwd` · `AW` · `WG` · `회신` · `답장` · `전달`(뒤의 `[2]` 같은 수 · 전각 쌍점 `:` 도) + 쌍점과 대괄호 꼬리표(`[공지]` 등)이고 여러 겹을 뗀다(짝 — backend `shared/utils/subject-base.ts` · frontend `shared/utils/search-match.ts`).
Message-ID 가 없는 메일은 혼자다. 원본이 늦게 와도 이어지고, 한 번 이어진 대화는 나뉘지 않는다(가운데 메일을 지워도). 답장 관계가 있어도 제목이 다르면 묶지 않는다(엔진과 같다).
- `engine`(색인을 채우는 동안): 엔진의 스레드(`threadId`). 두 기준의 묶음은 조금 다를 수 있다 — 준비가 끝나면 묶음이 바뀔 수 있다.
- 기준은 첫 쪽에서 정해지고 커서가 잇는다(검색과 같다). 색인 방식은 읽기 바로 전에 그 직원의 색인을 한 번 맞춘다(1.5초 안). 첫 쪽에서 색인을 읽지 못하면(DB 장애) 엔진으로 답한다.
- **대화에 드는 메일**: 모든 메일함(보낸편지함 · 임시보관함 포함)의 메일 — 휴지통 · 스팸메일함에만 든 메일은 뺀다. 보내는 중인 내부 초안은 빼고, 붙잡힌 메일(보내기 취소 · 예약 — `pendingSend`)은 넣는다.
#### `GET /api/messages/{id}/conversation` — 같은 대화
- 쿼리 `cursor` · `limit`(1~100, 처음 100). 응답 `{ items: MessageSummary[], pageInfo, basis: 'index' | 'engine' }`.
- **오래된 차례**(받은 시각 — 같으면 id). 그 메일은 휴지통 · 스팸메일함에 있어도 늘 든다. 한 통뿐이면 그 메일 하나다(화면은 칸을 보이지 않는다).
- `total`: `index` 는 첫 쪽에서만(다음 쪽 `null`), `engine` 은 쪽마다(스레드가 1,000통을 넘으면 `null`).
- **`hasNext: true` 인데 항목이 `limit` 보다 적거나 0개일 수 있다**(`engine` — 한 요청이 커서 자리부터 1,000통까지만 훑는다. 검색과 같은 결 — 응답은 늘 커서 자리보다 앞으로 간다).
화면은 커서가 있으면 이어 묻되, 빈 쪽이 이어지면 몇 번까지만 묻고 [더 보기]로 넘긴다(검색의 이어 찾기와 같은 결 — 휴지통에만 든 메일이 많은 큰 스레드).
- 커서는 그 메일 · 그 기준에서만 통한다 — 다른 메일 · 꾸민 커서는 400 `VAL_001`(그 메일이 색인에 없어도 커서를 먼저 본다).
- 404 `RES_001`: 없는 메일 · 쪽을 넘기는 중에 색인에서 사라진 메일.
503 `MAIL_001`: 엔진 장애 · `engine` 기준이 한 요청의 시간(15초) 안에 1,000통 이하 스레드를 다 읽지 못하거나 더 큰 스레드의 첫 묶음도 읽지 못함.
- **클라이언트 — 읽기 화면의 같은 대화 칸**(14단계 웹 — 디자인 README "14단계 구현하며 정한 보완 — 같은 대화")
- 한 번에 100통씩 받고, 쪽이 모자라면 한 번에 요청을 5번까지 이어 묻는다(화면 상수 `MAIL_CONVERSATION_FILL_MAX_REQUESTS` — 새로 100통이 차거나 `hasNext` 가 false 면 멈춘다). 남으면 [더 보기]. 화면의 시간 한도는 30초다.
- 칸 머리의 통 수: `total` → (다음 쪽이 없으면) 받은 수 → 모르면 수 없이 "같은 대화"(짐작한 수는 보이지 않는다).
- 처음 받기 · 다시 받기 · 펼친 본문은 알림 없이(`silent`) 칸 · 줄 안에 안내와 [다시 시도]를 둔다. 404 · 모양이 틀린 응답이면 칸이 없다(이 경로가 없는 옛 서버). [더 보기]만 인터셉터가 알린다.
- 다시 받는 때: 메일함들의 통 수(`totalCount`)가 바뀌었을 때(새 메일 · 지움 · 옮김 — 읽음 · 중요 표시만 바뀐 소식으로는 받지 않는다), 쓰기 화면에서 돌아올 때, 붙잡힌 메일이 보낼 시각 + 2초가 됐을 때.
받기가 끝난 때부터 10초 안에 온 소식은 모아 한 번 받고, 받는 중에 온 소식은 끝난 뒤 한 번 더 받는다. [더 보기]로 늘린 대화는 저절로 다시 받지 않는다(붙잡힌 메일만 `GET /api/messages/{id}` 로 고친다).
알려진 한계: 같은 메일함에서 한 통이 들어오며 한 통이 빠지면 통 수가 같아 그 소식으로는 다시 받지 않는다.
- 펼친 줄은 검색 쿼리 없이 `GET /api/messages/{id}` 로 받고, 받은 뒤에도 펼쳐 있으며 안 읽은 메일이면 `PATCH` 로 읽음으로 바꾼다(읽기 화면과 같은 길). 그 메일이 그사이 지워졌으면(404) 대화를 다시 받아 그 줄을 뺀다.
- 줄의 읽음(2026-11-07): 안 읽은 줄을 목록처럼 굵게 그린다. 이 탭이 바꾼 읽음은 줄에 바로 적고, 받는 동안 바꾼 것 · 받기를 시작할 때 보내던 것은 받은 값보다 앞세운다(받은 값은 그 바꿈이 엔진에 닿기 전일 수 있다). 다른 탭 · 기기의 읽음은 다음 받기 때 보인다(읽음만 바뀐 소식으로는 다시 받지 않는다).
- 줄 차례: 받은 메일은 응답 차례 그대로, 붙잡힌 메일(`pendingSend` — 보내기 취소 · 예약)은 받은 줄 뒤에 `sendAt` 차례로 둔다. 줄의 미리 보기는 우리 원문 인용 머리 "> 원본 메일"부터 잘라 내고(남는 글이 없으면 제목), 펼친 줄은 첨부 목록 대신 첨부 수만 안내한다(2026-11-07).
#### `GET /api/conversations` — 대화로 묶어 보기
```ts
interface ConversationSummary {
id: string // 대화 id — 불투명(기준마다 다르다)
latest: MessageSummary // 그 메일함 안의 마지막 메일 — 줄의 제목 · 시각 · pendingSend
participants: Address[] // 대화 전체의 보낸 사람(주소가 있는 사람만) — 받은 차례, 주소마다 한 번, 10명까지 · 이름 100자까지
messageCount: number // 대화 전체의 메일 수 — 위 "대화에 드는 메일"에 그 메일함의 메일을 더한 수(휴지통 · 스팸메일함을 묶어 볼 때)
messageIds: string[] // 그 메일함 안의 그 대화 메일 — 최신부터 500통까지
pendingSendIds: string[] // messageIds 가운데 붙잡힌 메일(보내기 취소 · 예약 — pendingSend 가 있는 메일). 없으면 []
isRead: boolean // 그 메일함 안의 메일을 모두 읽었으면 true
isFlagged: boolean // 그 메일함 안에 중요 표시한 메일이 하나라도 있으면 true
hasAttachment: boolean // 그 메일함 안에 첨부가 있는 메일이 하나라도 있으면 true
}
```
- 쿼리 `mailboxId`(필수) · `cursor` · `limit`(1~100, 처음 20). 검색 쿼리(`q` · 조건)는 받지 않는다(400 `VAL_001`).
- 응답 `{ items: ConversationSummary[], pageInfo, basis }` — 그 메일함 안의 마지막 받은 시각 최신부터.
- `total`: `index` 는 첫 쪽에서 그 메일함의 대화 수(다음 쪽 `null`), `engine` 은 엔진이 센 스레드 수(쪽마다).
- 커서는 그 메일함 · 그 기준에서만 통한다(다른 메일함 · 다른 목록 · 꾸민 커서는 400 `VAL_001`). 없는 메일함은 404 `RES_001`, 엔진 장애(메일함을 읽지 못함 포함)는 503 `MAIL_001`.
- **`engine` 기준의 상한**: 한 요청이 스레드마다 최신 500통 · 모두 2,000통까지 · 15초 안에서 읽는다(몫은 작은 스레드부터 다 주고 남는 것을 큰 스레드끼리 나눈다).
시간이 끝나도 503 이 아니라 그 쪽의 줄을 읽은 만큼으로 채워 답한다(커서는 보통의 다음 쪽 커서다).
상한에 걸린 줄(스레드마다 500 · 한 요청 2,000 · 15초 — 500통 이하 스레드도 한 쪽에 큰 스레드가 많으면 걸린다)은 `messageCount` 가 어림이다(읽지 못한 오래된 메일을 모두 센다 — 휴지통 · 스팸메일함에만 든 것도).
`participants` · `messageIds` · `isRead` · `isFlagged` · `hasAttachment` · `pendingSendIds` 는 읽은 최신 메일로만 정한다.
- 고르기 · 읽음 · 중요 · 이동 · 삭제는 그 줄의 `messageIds` 를 지금의 여러 통 API(`PATCH /api/messages` · `POST /api/message-deletions`)로 보낸다.
**`pendingSendIds` 는 빼고 보낸다** — 붙잡힌 메일은 취소로만 보낸편지함을 떠난다(9단계 "보내기 취소 · 예약 발송" — 섞여 와도 서버가 `failedIds` · `skippedIds` 로 막는다).
줄의 메일이 모두 붙잡힌 메일이면 그 줄은 고를 수 없다(목록의 고르기 칸과 같은 결).
- 500통이 넘는 대화는 `messageIds` 가 최신 500통이다 — 그 줄에 한 일은 그만큼에만 하고, 남은 메일은 다음에 다시 맞춘 목록의 같은 줄로 남는다(드문 경우).
- **알려진 한계**: 쪽을 넘기는 사이 늦게 온 원본으로 두 대화가 합쳐지면 줄이 겹치거나 빠질 수 있다 — 처음부터 다시 받으면 맞는다.
마지막 메일이 그 메일함을 떠났는데 색인이 아직 맞추지 않았으면 그 줄이 한 번 빠진다.
- **클라이언트 — 목록을 대화로 묶어 보기**(14단계 웹 — 디자인 README "14단계 구현하며 정한 보완 — 목록 대화로 묶어 보기")
- 켜고 끄기: 목록 머리 [대화로 묶기]와 설정 › 메일 › 보내기 · 읽기의 한 줄이 같은 `groupByConversation` 을 `PATCH` 로 저장한다 — 화면을 먼저 바꾸고 끝내 실패하면 되돌린다(칸마다 저장은 한 번에 하나 — 아래 "보내기 · 읽기 설정").
메일함 목록만 묶는다 — 검색 결과 · 「예약됨」은 묶지 않는다.
- 첫 진입: 설정을 받기 전 · 받지 못했으면 이 브라우저가 그 직원에게서 마지막으로 안 값(localStorage — 직원마다, 처음이거나 읽지 못하면 끔)으로 연다. 받은 값이 다르면 바로 그 값으로 다시 연다
(짐작이 틀리면 버릴 첫 쪽 요청이 하나 나간다). 받지 못한 동안에도 [대화로 묶기]를 누를 수 있고, 저장 응답(설정 전체)을 받은 설정으로 삼는다 — 실패하면 누르기 전 짐작으로 돌아간다.
로그아웃 · 세션 만료는 짐작을 바꾸지 않고, 세션이 끝나면(제한된 세션 포함) 목록을 다시 부르지 않는다.
- 쪽은 20줄 · 받은 커서 그대로. 켜고 끔 · 메일함 바꿈 · 다른 목록으로 가면 앞 목록의 응답을 버리고 그 요청을 끊는다(`AbortSignal` — `engine` 기준은 한 요청이 15초까지). 끊긴 요청은 알리지 않는다.
- 새 메일 확인 · 실시간: 메일 목록 대신 불러온 줄 수만큼(최대 100줄) `silent` 로 처음 쪽부터 다시 받는다 — 요청 수는 메일 목록과 같다.
- 다른 탭 · PC 에서 바꾼 설정: 메일 화면은 탭이 다시 보일 때 설정을 마지막으로 받은 지 60초가 지났으면 `GET /api/mail-settings` 를 `silent` 로 다시 받는다 — 저장하는 중이면 받지 않고, 받는 사이 고른 값이 있으면 받은 값을 버리고, 세션이 끝났으면 받지 않는다. 받은 `groupByConversation` 이 다르면 그 값으로 목록을 다시 연다(2026-11-07).
- 줄에 하는 일: 할 일 메일 = `messageIds` − `pendingSendIds` − (마지막 메일이 붙잡힌 메일이면) 그 메일. 100통씩 나눠 보낸다.
- 화면을 먼저 바꾸는 것은 줄의 메일을 모두 바꿨을 때만 줄 전체(읽음 · 중요 해제)다 — 일부만이면 그대로 두고 다음 맞추기가 서버 값으로 고친다. 중요 해제는 붙잡힌 메일까지 바꿨을 때만이다(붙잡힌 메일의 중요 표시는 남는다).
- 줄의 별: 중요 표시가 하나라도 있으면 모두 풀고, 없으면 할 일 메일을 모두 표시한다. 붙잡힌 메일이 든 줄을 풀면 줄을 미리 풀지 않고 묶은 목록을 한 번 다시 받는다 — 그래도 중요 표시가 남으면 알린다.
- 읽기 화면의 별(한 통)이 실패하면 그 메일이 든 줄을 앞 값으로 되돌린다.
- 옮기기 · 삭제 뒤에는 묶은 목록을 한 번 다시 받는다(남은 줄의 마지막 메일 · 통 수를 맞추려고). 서버가 바꾸지 못한 메일(`failedIds` · `skippedIds`)이 든 줄은 앞 값으로 되돌린 뒤 다시 받는다. 안 읽은 수는 끝난 뒤 메일함 목록으로 맞춘다.
- 목록 머리의 대화 수는 `total` 이다 — `null` 이면 그 부분을 뺀다.
- 알려진 한계: 여러 통인 줄의 마지막 메일만 읽으면 다음 맞추기(실시간이면 곧, 아니면 1분)까지 그 줄이 굵은 채다 — 읽을 때마다 다시 받지 않는다(경로마다 1분 100회).
마지막 메일이 아닌 붙잡힌 메일은 보낼 시각을 몰라, 풀린 뒤에도 다음 맞추기까지 그 메일만 든 줄을 고를 수 없다.
#### 설정
- `groupByConversation`(아래 "보내기 · 읽기 설정") — 처음 `false`. 서버는 저장해 돌려주기만 하고, 묶은 목록을 부를지는 화면이 정한다.
새 에러 코드는 없다(`VAL_001` · `RES_001` · `MAIL_001`).
### 여러 통 한꺼번에 (`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통은 그사이 옮겨졌거나 지워져 그대로 두었습니다"를 덧붙인다.
- 메일함을 바꾸거나 검색하면 선택을 푼다. 새 메일 확인으로 목록을 맞출 때는 남아 있는 메일의 선택을 그대로 둔다.
### 메일함 관리 (`/api/mailboxes`) — 4단계
```ts
interface MailboxCreate { name: string; parentId?: string | null } // parentId 없음 = 맨 위
interface MailboxRename { name: string }
interface MailboxDeletion {
deleted: boolean // true = 지웠다. false = 아직 옮길 메일이 남았다 — 같은 요청을 다시 보낸다
processed: number // 이번 요청에서 이 메일함에서 빠진 메일 수 — 휴지통으로 옮김 + 다른 메일함에도 있어 이 메일함에서만 뺌 (진행 표시용)
movedToTrash: number // 그중 휴지통으로 옮긴 수 (끝난 알림의 "안의 메일 N통은 휴지통에 있습니다")
remaining: number // 아직 남은 메일 수
}
interface MailboxBulkResult {
processed: number // 이번 요청에서 읽음으로 바꾼 · 지운 메일 수
remaining: number // 남은 수 — 0 이 될 때까지 같은 요청을 다시 보낸다
}
```
- **이름**: 앞뒤 공백을 뺀 1~64자(JS 문자열 길이), `/` 는 쓰지 않는다(경로 구분자로 읽는 메일 프로그램이 있다). 제어 문자도 쓰지 않는다 —
C0(U+0000–U+001F, 탭 · 줄바꿈 포함) · DEL(U+007F) · C1(U+0080–U+009F). 그 밖의 문자(NBSP · 이모지 등)는 받는다.
같은 상위 안에 같은 이름은 409 `MAIL_009` (엔진이 `alreadyExists` 로 거절한다 — 2026-10-28 확인). 지금 이름으로 바꾸면 엔진에 묻지 않고 그대로 돌려준다.
만든 메일함은 구독 상태(`isSubscribed`)라 IMAP 메일 프로그램에도 보인다.
- **만들 수 있는 자리**: 맨 위, 받은편지함 · 보관함 · 내 메일함(역할 없음) 아래. 보낸편지함 · 임시보관함 · 스팸메일함 · 휴지통 아래는 400 `VAL_001`.
웹 화면은 받은편지함 · 내 메일함 아래만 메뉴에 둔다(보관함 아래는 다른 메일 프로그램이 만든 것을 그대로 보인다).
- **기본 메일함**(역할이 있는 메일함)은 이름을 바꾸거나 지울 수 없다 — 400 `VAL_001`. 화면은 메뉴에 그 항목을 두지 않는다.
- **지우기**: 하위 메일함이 있으면 400 `VAL_001`(화면이 먼저 막는다). 안의 메일은 **휴지통으로 옮긴 뒤**(다른 메일함에도 있는 메일은 이 메일함에서만 뺀다)
빈 메일함을 지운다 — 엔진은 메일이 든 메일함을 `mailboxHasEmail` 로 거절하므로(`onDestroyRemoveEmails: false` — 메일을 영구 삭제하는 옵션은 쓰지 않는다) 그사이 들어온 메일도 남지 않는다.
메일이 많으면 한 요청에 다 옮기지 못한다 — `deleted: false` 면 클라이언트가 같은 요청을 다시 보낸다.
- **전체 읽음**(`PATCH /api/mailboxes/{id}/messages {isRead:true}`): 안 읽은 메일을 500통씩 읽음으로 바꾼다. **비우기**(`DELETE /api/mailboxes/{id}/messages`): 휴지통 · 스팸메일함만,
500통씩 확인한 상태 그대로일 때만(`ifInState`) 영구 삭제한다 — 그 메일함에만 있는 메일은 지우고, 다른 메일함에도 있는 메일은 그 메일함에서만 뺀다(`POST /api/message-deletions` 와 같은 규칙).
- 오래 걸리는 처리(전체 읽음 · 비우기 · 메일함 지우기)는 한 요청을 25초 안에 끝내고(엔진 호출마다 남은 시간만큼만 기다린다) `remaining` 을 돌려준다.
클라이언트는 0(또는 `deleted: true`)이 될 때까지 다시 보내며 진행을 알림으로 보인다("…하는 중 · 600 / 1,234통"). 도중에 실패하면 거기까지 처리한 수를 알리고, 다시 하면 남은 것만 처리한다.
- 같은 직원 · 같은 메일함의 처리는 한 번에 하나다 — 진행 중이면 엔진에 묻지 않고 429 `REQ_001`(다른 탭에서 같은 메일함을 처리할 때). 다른 메일함끼리는 함께 된다.
- 한 요청의 상한 25초는 메일함 목록을 읽기 전부터 잰다(엔진의 세션 조회 포함). 다음 500통은 남은 시간이 5초보다 많을 때만 시작한다.
- 멈추는 경우 — 진행한 것이 있으면 거기까지 200(`remaining` > 0 · `deleted: false`), 하나도 처리하지 못했으면 503 `MAIL_001`(클라이언트가 끝없이 다시 보내지 않게):
아무것도 바뀌지 않는 차례(확인하는 사이 계속 바뀜 · 바뀐 것도 거절도 없음 · 비운 메일함을 지우는 사이 메일이 들어옴)가 이어서 3번, 남은 시간 때문에 엔진 호출이 끊김.
엔진이 한 차례를 모두 거절하면 진행한 것이 없을 때 그 거절의 코드(권한 없음 403 `AUTH_003` 등)다.
- **비우기는 차례마다 그 시점의 메일함을 다시 읽는다** — 비우는 동안 들어온 메일(새 스팸, 다른 탭에서 휴지통으로 옮긴 메일)도 지운다(흔한 메일 프로그램과 같다).
그래서 화면은 휴지통 비우기와 메일함 지우기(안의 메일이 휴지통으로 간다)를 함께 돌리지 않는다.
- **클라이언트**: 메일함 패널의 각 메일함 ⋯ 메뉴 — 모두 읽음으로 표시(안 읽은 메일이 있을 때) · 하위 메일함 만들기 · 이름 바꾸기 · 지우기(내 메일함만) · 휴지통 / 스팸메일함 비우기.
휴지통 · 스팸메일함 목록 머리에도 「비우기」. 비우기 · 지우기는 확인 대화상자(처음 초점 「취소」) 뒤에만 하고, 확인한 그 메일함만 처리한다.
다른 탭에서 먼저 끝냈으면 실패로 보이지 않는다 — 모두 읽음 · 비우기의 0통은 "…에 안 읽은 메일이 없습니다" · "…이 이미 비어 있습니다", 지우기의 404 `RES_001` 은 지운 것으로 본다.
메일함 지우기의 진행은 `processed` 로 센다(`movedToTrash` 는 휴지통으로 간 것만이라 다른 메일함에도 든 메일만 남으면 늘지 않는다).
### 부재중 자동 응답 (`/api/mail-vacation`) — 4단계
```ts
interface MailVacation {
isEnabled: boolean
fromDate: string | null // ISO 8601 UTC — null 이면 켠 때부터
toDate: string | null // null 이면 끌 때까지
subject: string // 켤 때 150자까지(꺼 둔 채로는 200자까지 — 아래 "길이 한도의 근거") — 켤 때(isEnabled true)는 공백이 아닌 글자 1자 이상. 제어 문자(메일함 이름과 같은 범위 — 탭 · 줄바꿈 포함)는 쓰지 않는다(한 줄짜리 머리글)
textBody: string // 켤 때 600자까지(꺼 둔 채로는 4,000자까지) — 켤 때는 공백이 아닌 글자 1자 이상. 글자만(서식 없음) — 제어 문자는 탭 · LF · CR 만. 서명은 붙지 않는다
}
interface MailVacationState extends MailVacation { // GET · PUT 의 응답 (7단계)
isActive: boolean // false = 엔진에서 우리 규칙(필터와 한 스크립트)이 꺼져 있어 켜 둔 자동 응답이 지금은 동작하지 않는다 — 저장하면 다시 켜진다.
// 메일 필터의 isActive 와 같은 값(한 번도 저장하지 않은 계정은 true). PUT 본문에는 넣지 않는다(넣으면 400 VAL_001 — 모르는 필드)
}
```
- **길이 한도의 근거 (6단계 앞 수정)**: 엔진이 UTF-8 **바이트**로 막는다 — 제목 511바이트(한글 170자 · 영문 511자), 본문 2,047바이트(한글 682자 · 영문 2,047자)
(Stalwart 0.16.27, 2026-10-28 확인 — 넘으면 엔진이 `invalidProperties` 로 거절해 400 `VAL_001` 이었다. 4단계 계약의 200자 · 4,000자는 한글로는 맞지 않았다).
글자마다 바이트가 달라(한글 3 · 이모지 4바이트/2자) 어떤 글이어도 넘지 않는 150자 · 600자로 둔다(JS 문자열 길이).
이 한도는 **켤 때만** 본다 — 꺼 둔 채로는 4단계 한도(200자 · 4,000자)까지 받는다. 4단계에 엔진 한도 안으로 저장해 둔 긴 글(영문 내용 1,000자 등)이 켜져 있으면
화면은 꺼 둘 때 칸이 흐려 고칠 수 없는데, 끄기는 늘 저장되어야 하기 때문이다. 다시 켤 때 150 · 600 에 걸리면 줄인 뒤 켠다.
- 한 번도 설정하지 않은 계정은 `{ isEnabled: false, fromDate: null, toDate: null, subject: "", textBody: "" }` 로 온다(엔진의 null 은 빈 문자열로). 끈 채로는 빈 제목 · 내용도 저장된다.
제목 · 내용은 쓴 그대로 저장한다 — "공백이 아닌 글자" 검사만 앞뒤 공백을 뺀 값(JS `trim()` — NBSP · U+3000 포함)으로 한다. 제어 문자 검사는 꺼 둔 채로도 한다.
- 저장 자리 — 6단계부터 메일 필터와 같은 엔진 규칙(Sieve 스크립트 `arasystem`)에 둔다(아래 "메일 필터"의 "저장 자리"). 4단계에는 엔진의 JMAP `VacationResponse`(singleton)였다.
서식 본문은 없다(글자만). 저장은 전체 교체이고 응답은 저장한 값이다. 꺼도 쓴 제목 · 내용은 남겨 둔다.
필터와 한 스크립트라서 부재중을 저장해도 스크립트를 다시 켠다(필터가 꺼져 있었으면 함께 다시 동작한다). 필터 규칙은 건드리지 않는다.
- 6단계부터 바뀐 동작: 엔진이 스팸으로 분류한 메일에는 답하지 않는다(필터와 같은 스팸 막기 — "메일 필터"의 "만드는 Sieve").
- 우리 스크립트가 엔진에서 꺼져 있으면(`isActive: false`) 부재중도 동작하지 않는다 — `isEnabled` 는 저장한 값이라 그때도 `true` 다.
7단계부터 응답의 `isActive` 로 알린다 — 화면은 켜 두었을 때 머리 아래 줄 "다른 곳에서 메일 설정이 바뀌어 켜 둔 자동 응답이 지금은 나가지 않습니다.
아래 [저장]을 누르면 다시 켜집니다."(단추 없음 — 저장 단추는 늘 누를 수 있다. 6단계의 알려진 한계를 고쳤다).
- 저장해 둔 원본이 깨진 계정은 꺼짐(`isEnabled: false`, 빈 제목 · 내용)으로 온다. 원본이 이 서버보다 새 판이면 저장은 503 `MAIL_001` 이다("메일 필터"의 "저장 자리").
- `fromDate` · `toDate` 는 `Z` 로 끝나는 UTC 시각이고, 두 키는 늘 보낸다(값은 null 가능). 엔진에는 초 단위로 저장한다(소수 초는 버린다).
둘 다 있으면 버린 값으로 `fromDate < toDate`(아니면 400 `VAL_001`). 화면은 날짜만 고르고, 브라우저 시간대의 시작일 0시 · 끝나는 날 다음 날 0시를 UTC 로 보낸다.
- 같은 사람에게 매번 답하지는 않는다 — JMAP 규격(RFC 8621 8절)이 "보낸 사람마다 적당한 기간에 한 번" 을 요구하고 엔진이 따른다.
6단계부터는 Sieve `vacation`(RFC 5230)이 같은 일을 한다 — 간격은 엔진 기본값이다.
- **클라이언트**: 설정 › 메일 › 부재중 자동 응답. 켜져 있으면 메일 화면의 메일함 패널 아래(사용량 위)에 "부재중 자동 응답 켜짐 · 11월 3일까지"(누르면 설정)를 보인다 —
메일 화면에 들어올 때 한 번 받고, 시간이 지나면 다시 계산한다(끝나는 시각이 지나면 숨기고, 시작 전이면 "10월 30일 ~ 11월 3일 · 설정").
### 스팸 · 원문 — 4단계
- **스팸 신고 · 스팸 아님**: `PATCH /api/messages/{id}` · `PATCH /api/messages` 의 `isJunk` — `true` 면 키워드 `$junk` 를 붙이고 `$notjunk` 를 뗀다, `false` 면 반대(RFC 8621 4.1.1).
화면은 이동과 함께 보낸다 — 스팸 신고 `{ mailboxId: <스팸메일함>, isJunk: true }`, 스팸 아님 `{ mailboxId: <받은편지함>, isJunk: false }`. 엔진의 스팸 학습에 쓰이는지는 엔진 몫이다.
「되돌리기」는 원래 메일함으로 옮기며 보낸 값의 반대를 보낸다 — 표시를 지우는 값은 두지 않아서, 원래 아무 표시도 없던 메일도 되돌린 뒤에는 `$notjunk`(스팸 아님)가 남는다.
- **원문 내려받기**: `GET /api/messages/{id}/source` — 엔진의 원본(blob)을 그대로 흘려보낸다(`message/rfc822`, `Cache-Control: private, no-store`). 파일 이름은 제목(없으면 "메일")에
`.eml` — 다운로드 헤더는 첨부와 같은 규칙(`shared/utils/content-disposition.ts`). 화면은 첨부 내려받기처럼 이동 전에 세션을 확인한다(`confirmSession()`).
### 실시간 알림 (`GET /api/mail-events`) — 5단계
서버가 메일 엔진(Stalwart)의 JMAP EventSource(RFC 8620 7.3)를 직원마다 하나 열고, 그 소식을 브라우저에 SSE(`text/event-stream`)로 흘려보낸다. 표준 응답 래퍼는 없다.
```text
event: state
data: {"state":"","inboxUnread":3}
event: new-mail
data: {"count":2,"messages":[{"id":"…","from":{"name":"박서준","email":"seojun.park@arasystem.it.kr"},"subject":"…","receivedAt":"2026-10-28T05:42:00.000Z"}]}
event: ping
data: {"upstream":true}
event: end
data: {"reason":"token-expiring"}
```
| 이벤트 | 언제 · 뜻 |
| --- | --- |
| `state` | 연결 직후 한 번, 그 뒤 메일함 · 메일이 바뀔 때마다(엔진 신호를 0.3초 모아서). `state` 는 `GET /api/mail-state` 와 같은 값, `inboxUnread` 는 받은편지함의 안 읽은 수 |
| `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) | 코드 |
| --- | --- |
| 로그인 안 됨 · 토큰 만료 | 401 `AUTH_001` |
| 한 직원의 연결이 5개를 넘음(브라우저마다 하나라 기기 · 브라우저 5개) · 요청 수 한도(경로마다 IP 당) · 엔진 한도 | 429 `REQ_001` |
| 엔진에 연결할 수 없음(처음 연결 10초 상한) · 기다리는 사이 엔진 연결이 끝남 · 서버 종료 중 | 503 `MAIL_001` |
스트림이 열린 뒤 계정을 더 쓸 수 없게 되면(엔진이 대리 접근을 거절) `end` 없이 닫는다 — 클라이언트는 끊김으로 보고 다시 이어 401 → 갱신 실패 → 세션 만료로 간다.
- **서버**: 직원마다 엔진 연결 하나를 그 직원의 모든 연결에 나눠 쓴다(마지막 연결이 닫히고 30초 뒤 닫는다 — 새로 고침에 다시 열지 않게).
**앱 새 메일 알림을 켠 직원은 연결이 없어도 엔진 연결을 붙잡는다**(앱 1단계 — 1절 "앱 새 메일 알림 · 앱 설정"): 같은 엔진 연결을 함께 쓰고(직원마다 여전히 하나) 연결 상한(5)에 세지 않는다.
붙잡은 쪽은 새 메일 신호(같은 판정 — 보낸 사람 · 제목 없이)와 끝남만 받는다. 놓으면 남은 연결이 없을 때 30초 뒤 닫는다.
엔진 연결은 대리 접근 자격 · `types=Mailbox,Email` · `ping=30` 이다 — 엔진은 30초보다 짧게 주지 않는다(10초를 달라고 해도 30초, 2026-10-28 확인).
75초 동안 아무것도 오지 않으면 끊긴 것으로 보고 다시 잇는다(1 · 2 · 5 · 10 · 30초 간격에 ±20% — 엔진이 다시 뜰 때 모든 직원이 같은 순간에 붙지 않게).
그동안 클라이언트에는 `ping` 의 `upstream: false` 로 알린다. 같은 기준으로 이어서 3번 묻지 못하면 기준을 버리고 처음 상태로 다시 잡는다(그 차례는 `new-mail` 없이 — 엔진 한도 · 엔진이 내려간 동안은 세지 않는다).
엔진이 묻기를 한도(429)로 막으면 엔진 연결은 두고 5초(±20%) 뒤 다시 묻는다(`upstream` 은 true 그대로 — 다시 잇는 중이면 다시 잇기 간격대로).
**5분 안전판**: 엔진 신호가 없어도 마지막으로 물은 지 5분이 지나면 한 번 묻고, 달라졌으면 `state`(와 `new-mail`)를 보낸다 — 연결이 살아 있는 동안 클라이언트는 1분 확인을 멈추므로, 엔진이 신호를 빠뜨려도 낡은 값이 5분 넘게 남지 않게.
- **새 메일 판단**: 엔진의 `Email/changes`(지난 state 부터 생긴 메일) → `Email/get` 으로 한다. 엔진의 `EmailDelivery` 신호는 초안을 만들어도 바뀌므로 쓰지 않는다(2026-10-28 확인).
배달에서 신호까지 0.5초 남짓이다(같은 날 개발 엔진). 다음 경우에는 `new-mail` 없이 `state` 만 보내고 기준을 다시 잡는다:
바뀐 것을 셀 수 없음(`cannotCalculateChanges`), 엔진이 옛 기준을 거부함(Stalwart 0.16.27 은 해석하지 못하는 `sinceState` 에 HTTP 400 `notRequest` 로 요청 전체를 거부한다 — 2026-10-28 확인),
한 번에 2,000건(500 × 4) 넘게 바뀜.
`Email/changes` 의 생성은 "배달"이 아니라 "만들어짐"이다 — 받은 시각(`receivedAt`)이 변화를 본 때보다 10분 넘게 앞선 메일은 알리지 않는다
(메일 프로그램의 올리기 · mailcow 에서 옮길 때의 IMAP 가져오기(imapsync)가 옛 메일을 넣어도 알림이 쏟아지지 않게). 받은 시각을 읽지 못한 메일도 빼고, 엔진 시계가 앞서 미래 시각인 메일은 넣는다.
그래서 서버와 엔진 사이가 10분 넘게 끊겼다 다시 이으면, 그동안 온 메일은 `state` 로만 알린다(목록에는 나타나고 바탕화면 알림은 없다).
"보낸 사람이 나"는 토큰의 주소와 발신 ID(별칭) 주소로 본다(대소문자 무시).
- 오래 열린 엔진 연결은 엔진의 동시 요청 한도(4)를 잡아먹지 않는다 — 한 직원의 스트림 8개를 연 채 그 직원 · 다른 직원의 보통 요청과 동시 요청 6개가 모두 됐다(2026-10-28 확인).
- 연결은 액세스 토큰이 끝날 때 닫는다(최대 1시간 — 그 전에 `end`). 12단계부터 로그아웃 · 비밀번호 변경 · 세션 끊기로 끊은 세션의 연결은 다음 ping 때 `end` 없이 닫는다(계정을 더 쓸 수 없을 때와 같은 길 — 5절 "로그인된 기기").
서버가 종료될 때는 열린 연결을 `end` 로 먼저 닫는다 — 오래 열린 연결이 종료를 붙잡지 않게.
알려진 한계: 잠자기 · VPN 재연결 · 망 바꿈으로 반쯤 열린 연결은 서버가 ping 을 쓰다 실패할 때(OS 기본 15분 안팎)까지 자리를 잡는다 —
그사이 그 직원의 연결이 5개에 닿으면 새 연결은 429 이고 화면은 1분 확인으로 이어진다.
- 응답 머리글: `Content-Type: text/event-stream`, `Cache-Control: no-cache, no-transform`, `X-Accel-Buffering: no`. 서버는 스트림을 열기로 정하면 머리글을 바로 보낸다.
앞단 nginx 는 이 경로만 버퍼링 · 캐시 · 압축을 끄고, 읽기 사이 시간 제한을 90초로 둔다(응답 전체가 아니라 두 번 읽는 사이 — ping 25초 · 클라이언트 감시 75초보다 길게,
머리글이 오지 않으면 90초에 504). 외부 리버스 프록시도 버퍼링을 끄고, 응답 전체 시간에 상한이 있으면 1시간 넘게 둔다(운영 확인 항목 — docker 스킬).
앱 로그에는 열고 닫는 것을 남기지 않는다(반복되는 정상 흐름) — 엔진 연결이 이어서 실패하면 warn. nginx 접근 로그에는 연결이 끝날 때 한 줄이 남는다(`rt=` 가 이어진 시간).
- **클라이언트**
- 브라우저마다 연결 하나 — 탭이 여러 개면 한 탭만 연결하고(Web Locks) 다른 탭에 소식을 나눈다(BroadcastChannel). 탭마다 열면 HTTP/1.1 의 주소당 연결 6개를 금방 다 쓴다.
연결한 탭을 닫으면 다른 탭이 이어받는다. Web Locks 가 없는 브라우저는 실시간 없이 1분 확인만 한다.
연결은 로그인한 동안 **앱 전체**에서 유지한다(설정 등 메일 밖 화면에서도 바탕화면 알림 · 연결 상태가 이어지게). 목록 맞추기 · 1분 확인 멈춤은 메일 화면이 있을 때만 한다.
- `fetch` 로 읽는다 — 브라우저의 `EventSource` 는 401 을 알 수 없어 세션을 갱신할 수 없다. 401 이면 세션을 한 번 갱신하고 다시 잇는다(갱신도 실패하면 지금의 세션 만료 흐름).
- 연결이 살아 있는 동안(75초 안에 `ping` · `state` 가 오고 `upstream: true`)은 1분 확인을 멈춘다. 75초는 요청을 시작한 때부터 센다(머리글이 오지 않는 연결도 끊게).
끊기면(읽기 오류 포함 — 서버의 `end` 가 아닌 끝) 1분 확인으로 돌아가고 2 · 5 · 15 · 30 · 60초 간격으로 다시 잇는다.
연결이 **자리 잡으면**(첫 `state` 뒤 `upstream: true` 인 ping 이 오거나 20초가 지나면) 처음 간격부터 다시 센다. 자리 잡은 연결의 `end` 는 바로 다시 잇는다
(`token-expiring` 은 세션을 갱신한 뒤, `server-shutdown` 은 0~3초 흔들어 — 배포 때 모든 브라우저가 같은 순간에 붙지 않게). 그동안은 끊김으로 보지 않는다.
**자리 잡기 전에 온 `end` 는 까닭과 상관없이 실패처럼 간격을 둔다** — 열리자마자 끝나는 연결(세션 끝 직전의 짧은 토큰 · 응답을 짧게 끊는 프록시)에서
갱신 · 다시 잇기를 왕복 시간 속도로 되풀이하지 않게(갱신 한도는 IP 마다라 같은 사무실 직원까지 막는다). 다시 잇는 간격의 흔들림은 ±20%.
- 세션을 갱신한 **바로 뒤의 다시 잇기**가 또 401 이면 세션 만료로 본다(axios 의 401 재시도와 같다 — 그 시도가 다른 까닭으로 끝나면 이 표시는 지운다).
갱신이 장애(5xx · 네트워크)로 실패하면 위 간격으로 기다렸다 다시 갱신해 본다. 한 이벤트가 1MB 를 넘으면 실패로 끊는다.
- 연결한 탭이 그만둘 때(닫힘 · 로그아웃 · `pagehide`)와 잠금을 이어받은 탭이 연결하기 전에 다른 탭에 끊김을 알린다 — 이어받은 탭의 연결이 열릴 때까지 모든 탭이 1분 확인으로 돌아간다
(탭 닫기 · 탭 폐기처럼 그만두는 탭이 알리지 못한 경우도 덮는다).
살아 있던 연결이 끊기면 1분 확인이 바로 한 번 묻고, 그 뒤 60초마다 묻는다.
- `state` 가 바뀌면 보이는 탭은 지금의 1분 확인이 하는 일(개수 · 보이는 목록 맞추기)을 그대로 하고, 숨은 탭은 `inboxUnread` 로 탭 제목만 맞춘 뒤 다시 보일 때 맞춘다.
실시간이 부른 맞추기는 1초 모아서 한 번 한다(한 통을 읽어도 state 가 바뀐다 — 연달은 바뀜에 목록을 여러 번 받지 않게).
- **바탕화면 알림**(설정 › 메일 › 알림 — 브라우저 · 사용자마다 localStorage): 켜져 있고 브라우저가 허락했을 때, `new-mail` 을 받았는데 **웹메일을 보고 있는 탭**(보이고 초점이 있는 탭 — `visibilityState === 'visible' && document.hasFocus()`)이 하나도 없으면 알린다.
두 번째 모니터에 웹메일을 띄워 두고 다른 창에서 일할 때도 알린다("다른 창을 보는 동안에도").
한 통이면 제목 = 보낸 사람(이름, 없으면 주소) · 내용 = 메일 제목(「메일 제목도 보이기」를 끄면 "새 메일이 왔습니다"), 여러 통이면 "새 메일 N통" · "박서준 · 이도윤 외 1명".
같은 tag 로 앞 알림을 바꿔 쓴다(쌓이지 않는다). 누르면 그 탭으로 와서 메일을 연다(여러 통이면 받은편지함). 사용자가 웹메일로 돌아오면(어느 탭이든 보이고 초점이 생기면) 띄운 알림을 닫는다.
허락은 사용자가 누른 뒤에만 묻는다(목록 위 한 번 권하기 · 설정의 켜기 칸). `new Notification()` 을 막는 브라우저(휴대폰 Chrome)는 처음 실패할 때 "지원하지 않음"으로 바꾼다.
### 메일 필터 (`/api/mail-filters`) — 6단계
```ts
interface MailFilterSet {
rules: MailFilterRule[] // 위에서부터 차례로 적용한다 — 50개까지
version: string // 지금 규칙의 지문 — 해석하지 말고 PUT 에 그대로 돌려보낸다(그사이 다른 곳에서 규칙이 바뀌었으면 409 MAIL_012). 영문 · 숫자 · - · _ 128자까지. 규칙만 본다(부재중이 바뀌어도 그대로)
isActive: boolean // false = 우리 규칙이 엔진에서 꺼져 있거나(다른 규칙이 켜짐) 저장해 둔 원본을 읽을 수 없어 지금은 필터 · 부재중 응답이 동작하지 않는다(드묾) — 저장하면 다시 켜진다
// (아래 "저장 자리"의 새 판 원본은 예외). 한 번도 저장하지 않은 계정은 true(막힌 것이 없다)
}
interface MailFilterRule {
id: string // 클라이언트가 만든다(UUID — 영문 · 숫자 · - · _ 64자까지) — 목록 안에서 겹치지 않게
name: string // 0~50자(앞뒤 공백은 지워 저장한다), 제어 문자는 안 된다 — 비면 화면이 조건으로 이름을 보인다
isEnabled: boolean
match: 'all' | 'any' | 'always' // 조건이 모두 맞음 · 하나라도 맞음 · 모든 메일(조건 없음)
conditions: MailFilterCondition[] // all · any 는 1~5개, always 는 빈 배열
actions: MailFilterActions // 하나 이상
}
interface MailFilterCondition {
field: 'from' | 'to' | 'subject' | 'body' // 보낸 사람(이름 · 주소) · 받는 사람(받는 사람 · 참조의 이름 · 주소) · 제목 · 본문(글자 부분)
operator: 'contains' | 'notContains'
value: string // 1~200자(앞뒤 공백을 지운 값 — 그 값으로 저장한다), 제어 문자 · `${` 는 안 된다
}
interface MailFilterActions {
moveToMailboxId: string | null // 받은편지함 · 내 메일함 · 보관함 · 스팸메일함 · 휴지통 (보낸편지함 · 임시보관함은 400 VAL_001). 없는 id 도 받는다 — 지운 메일함을 가리키는 규칙이 남아 있을 수 있다
markAsRead: boolean
flag: boolean
forwardTo: string | null // 사내 주소(도메인이 `MAIL_DOMAIN` 과 똑같은 주소 — 하위 도메인은 안 된다)만, 자기 주소는 안 된다(대소문자 무시).
// 앞뒤 공백은 지워 저장한다. `${` 는 안 된다
}
```
- PUT 본문은 `{ rules, version }` 뿐이다 — `isActive` 같은 다른 키를 보내면 400 `VAL_001`(모르는 필드). 응답은 저장한 규칙(앞뒤 공백을 지운 값)과 새 `version` 이다.
- **적용 순서** — 새로 온 메일을 위의 규칙부터 차례로 견주고, 맞는 규칙의 할 일을 모두 한다(읽음 · 중요는 겹쳐 붙는다).
**메일함으로 옮기는 규칙에 맞으면 거기서 멈춘다** — 아래 규칙은 보지 않는다. 받은편지함으로 옮기는 규칙을 위에 두면 "늘 받은편지함에"가 된다.
전달은 한 통에 한 곳 — 엔진 한도(사용자 스크립트의 리다이렉트 1)라 두 번째 전달은 엔진이 조용히 버린다(2026-10-28 확인). 사본은 늘 남긴다(`redirect :copy`).
**스팸으로 판정된 메일에는 아무 규칙도 적용하지 않는다**(부재중 응답도 하지 않는다) — 엔진은 규칙의 옮기기가 스팸메일함 분류를 이기므로(같은 날 확인) 막는다.
판정은 엔진의 분류 그대로다(지금 엔진 설정은 5점부터 스팸 — 아래 "만드는 Sieve"의 스팸 막기).
꺼 둔 규칙은 목록에만 있고 적용하지 않는다. 이미 받은 메일에는 저절로 적용하지 않는다 — 저장할 때 고르면 아래 "이미 받은 메일에 적용"(7단계).
- **저장 자리** — 엔진의 Sieve 스크립트 하나(`arasystem`)에 필터와 **부재중 자동 응답을 함께** 둔다. Stalwart 0.16.27 은 계정마다 켜진 스크립트가 하나뿐이고
엔진의 VacationResponse 도 스크립트(`vacation`)라서, 부재중을 켜면 필터가 꺼지고 필터를 켜면 부재중이 꺼진다(같은 날 확인).
- 4단계 `/api/mail-vacation` 은 계약 그대로 두고 저장 자리만 이 스크립트로 옮긴다 — 엔진의 VacationResponse 는 더 쓰지 않는다.
스크립트가 아직 없는 계정은 처음 읽을 때 VacationResponse 의 값을 가져온다(개발 데이터 이어받기). 엔진의 `vacation` 스크립트는 고칠 수 없다(엔진이 `forbidden`).
- 스크립트 머리의 주석에 규칙 · 부재중 원본(JSON — 판 번호 `v` 를 함께 둔다)을 두고, 읽을 때는 그것만 믿는다(엔진의 Sieve 를 다시 해석하지 않는다).
- 스크립트가 아직 없으면 빈 규칙 + `isActive: true`, 부재중은 엔진의 VacationResponse 값(위 이어받기).
- 스크립트는 있는데 원본이 없거나 깨졌으면 빈 규칙 + `isActive: false`, 부재중은 꺼짐 — 저장하면(필터든 부재중이든) 덮어쓴다.
- 원본의 판이 이 서버가 아는 것보다 새로우면(새 서버를 배포했다 되돌린 경우) 읽기는 위와 같지만 **쓰기는 거절한다**(503 `MAIL_001`) — 옛 서버가 새 판의 규칙을 지우지 않게.
- 쓸 때는 읽은 상태 그대로일 때만(`ifInState`) 쓰고 켠다(`onSuccessActivateScript`). 그사이 바뀌면 다시 읽어 확인한다
(쓰기 3번까지 — 시도마다 읽는 사이 글이 사라지면 그 읽기도 3번까지 다시 한다. 그래도 어긋나면 503 `MAIL_001`).
필터 저장은 다시 읽은 규칙의 지문이 `version` 과 다르면 409 `MAIL_012`(부재중 응답만 바뀐 것은 걸리지 않는다). 부재중 저장은 규칙을 그대로 두고 부재중 부분만 바꾼다.
다만 저장해 둔 규칙이 보내는 규칙과 이미 같으면 지문이 달라도 200 으로 저장한다 — 결과를 모르는 실패 뒤에 같은 요청을 다시 보낸 경우가 409 로 끝나지 않게
(꺼져 있었으면 이때 다시 켜진다).
- **만드는 Sieve** (엔진 확인 사실 — 2026-10-28):
- 조건: `from` → `header :contains "from"`, `to` → `header :contains ["to", "cc"]`, `subject` → `header :contains "subject"`, `body` → `body :text :contains`.
한국어 제목(인코딩된 머리글) · 본문 · 표시 이름 모두 맞는다. 대소문자는 ASCII 만 무시한다(엔진 기본 비교 — `i;unicode-casemap` 은 없다).
- 한 규칙은 `if` 하나: 표시(`addflag` — 옮기기보다 먼저, 엔진은 옮기는 순간의 표시만 붙인다) → 전달 → 옮기기(`fileinto :mailboxid "" "INBOX"` + `stop`).
옮길 메일함이 없으면 대체 이름 "INBOX" 로 받은편지함에 들어온다.
- 스팸 막기는 엔진의 분류 표시로 한다 — `X-Spam-Score` 머리글(`spam, score=9.70` · `ham, score=2.20`)이 `spam` 으로 시작하면 아무 규칙도 · 부재중도 하지 않는다
(`header :matches "X-Spam-Score" "spam*"`). 엔진이 우리 스크립트를 돌리기 **전에** 붙인다. `X-Spam-Status: Yes|No` 는 스크립트가 끝난 뒤 실제로 들어간 곳에 따라 붙어 쓸 수 없다
(우리 규칙이 옮긴 스팸은 "No" 였다). 엔진 설정의 기준 점수(`scoreSpam` — 지금 5)를 바꿔도 이 표시가 따라간다.
- `spamtest` 는 쓰지 않는다 — 엔진의 값은 "스팸이면 10"이 아니라 점수를 정수로 내린 값(10에서 멈춤)이라, 10 으로 막으면 5~10점 스팸을 놓친다
(2026-10-28 실엔진: 2.20점 → 2, 9.70점 → 10 미만, 11.2점 → 10. 처음 구현은 10 으로 막아 9.70점 스팸을 규칙대로 옮기고 부재중 응답까지 보냈다).
- 바깥에서 같은 이름의 머리글을 넣어 와도 엔진의 머리글이 함께 남는다(하나라도 맞으면 참). 그래서 위조로 스팸을 숨길 수는 없고, 자기 메일에 규칙이 적용되지 않게만 할 수 있다
(그 메일은 엔진 판정대로 들어간다). 엔진의 스팸 필터는 바깥에서 오는(인증 없는) 메일에만 돌아 사내 메일에는 이 머리글이 없다 — 규칙이 그대로 적용된다.
- 부재중: 엔진이 하던 대로 `vacation :mime`(본문 base64) — 제목은 RFC 2047 로 인코딩해 넣는다. Stalwart 는 require 와 상관없이 문자열의 `${…}` 를 늘 변수로 펼쳐서
그대로 넣으면 지워진다(같은 날 확인). 기간은 `currentdate :zone "+0000"`(엔진 시간대에 기대지 않게 — 같은 날 확인). 같은 사람에게 다시 답하는 간격은 엔진 기본값(지금과 같다).
- 규칙 값에 `${` 는 받지 않는다(400 `VAL_001`) — 펼쳐져 빈 값이 되면 `:contains` 가 모든 메일에 맞는다. 문자열은 `"` · `\` 를 이스케이프한다.
`%{…}`(Stalwart 의 식 모양)는 펼치지 않아 막지 않는다 — `%{"a"}` · `%{}` · `%{1+1}` · `%{subject}` 모두 글자 그대로 견주었다(같은 날 확인).
- 크기: 엔진 한도는 스크립트 102,400바이트 · 문자열 4,096바이트다. 만든 스크립트가 90,000바이트를 넘으면 저장하지 않고 400 `MAIL_013`.
- 로그 — 규칙의 글(조건 값 · 전달 주소)은 남기지 않는다(검색어처럼 직원의 내용이다). 규칙 수 · 크기 · 결과만.
엔진이 스크립트를 거절하면(`invalidScript` → 500 `SYS_001`) 엔진 설명의 줄 · 칸만 남긴다 — 설명 문장에는 만난 문자열(규칙의 글)이 그대로 들어갈 수 있다.
- **클라이언트**: 설정 › 메일 › 필터(`/settings/mail/filters`, 탭 제목 "메일 필터") — 메뉴는 서명 · 부재중 자동 응답 · 필터 · 알림.
- 켜기 · 끄기 · 순서 바꾸기 · 지우기 · 고치기 모두 `PUT` 전체 교체(받은 `version` 으로). 켜기 · 순서는 화면을 먼저 바꾸고 실패하면 되돌린다(오류 알림).
지우기는 확인 없이 하고 알림의 [되돌리기](10초)로 지운 필터를 **지운 자리에 다시 넣어** 저장한다 — 그사이 바꾼 다른 필터(켜기 · 순서 등)는 그대로 둔다.
저장은 한 번에 하나다 — 앞 저장이 도는 동안 누른 [되돌리기]는 차례로 보낸다. 고치기 창의 [저장]이 끝나면 "필터를 저장했습니다."
- `MAIL_012` — 목록 위 줄 "다른 곳에서 필터가 바뀌어 저장하지 않았습니다. 다시 불러온 뒤 고쳐 주세요." + [다시 불러오기]. 고치던 창은 닫지 않는다 —
창 안에도 같은 안내와 [다시 불러오기]를 둔다(모달이라 뒤의 줄을 누를 수 없다). 창에서 다시 불러오면 쓴 내용은 그대로이고, 다음 [저장]은 다시 받은 목록에
그 필터를 넣는다(같은 id 자리 — 그사이 다른 곳에서 지워졌으면 맨 아래).
- `isActive: false` — 목록 위 줄 "다른 곳에서 메일 설정이 바뀌어 지금은 필터와 부재중 자동 응답이 되지 않습니다." + [다시 켜기](지금 목록 그대로 저장).
- 읽기 화면 ⋯ 「이런 메일로 필터 만들기」 → `/settings/mail/filters?from=<보낸 사람 주소>` — 새 필터 창이 "보낸 사람 · 포함 · 그 주소"로 채워져 열린다(초점은 할 일의 첫 칸).
50개를 다 만들었는지 알려고 ⋯ 메뉴를 열 때 `GET` 을 한다(받아 둔 목록이 없거나 50개일 때만 — 알림 없이).
- 검사는 서버와 같은 규칙(이름 50 · 값 200 · 조건 5 · 규칙 50 · `${` · 제어 문자 · 할 일 하나 이상 · 전달은 사내 주소이고 자기 주소 아님) — 칸 아래에만 알린다.
전달 주소의 앞부분은 서버(`IsEmail`)보다 좁게 본다(RFC 5322 dot-atom · 64자) — 화면이 통과시킨 주소를 서버가 거절하지 않게.
규칙이 50개면 [새 필터] · 「이런 메일로 필터 만들기」를 끄고 까닭을 보인다. 지운 메일함을 가리키는 규칙은 목록에 빨간 안내.
- 이 단계에서 하지 않는 것: **바깥 주소로 전달** — 전달한 메일은 보낸 사람(From)이 원래의 바깥 도메인인데 우리 서버가 건넨다. 엔진은 봉투 보낸 사람을
필터 주인으로 바꿔 SPF 는 통과하지만(SRS 는 없다 — 거절되면 반송은 원래 보낸 사람이 아니라 필터 주인에게 온다), 받는 쪽의 DMARC 는 From 기준이라
원래 보낸 쪽의 DKIM 서명에 기댄다. 서명이 없거나 깨진 메일은 그 도메인이 엄격하면(p=reject) Gmail 등이 거절하거나 스팸으로 보낸다 — 전달 메일의 일반적인 한계라
SRS 가 있어도 같다. 그리고 회사 메일이 개인 메일로 자동으로 빠지는 통로다 — **회사 정책으로 허용하지 않는다**(2026-10-29 결정 — 전달은 사내 주소로만).
보통 발송(직원이 바깥 주소로 보내는 메일)과는 상관없다.
크기 · 첨부 · 날짜 조건, 정규식, 맞는 메일 미리 보기, mailcow(SOGo)의 필터 옮겨 오기(운영 전환 때 — imapsync 로는 옮겨지지 않는다).
### 이미 받은 메일에 적용 (`/api/mail-filter-runs`) — 7단계
필터를 저장할 때 고르면(고치기 창 「받은편지함에 이미 있는 메일에도 적용」) 받은편지함에 이미 있는 메일에도 그 규칙 하나를 적용한다.
엔진의 Sieve 는 메일을 받을 때만 돌기 때문에 서버가 받은편지함을 읽어 같은 규칙으로 판정하고 표시 · 옮기기를 한다.
```ts
interface MailFilterRunRequest {
match: 'all' | 'any' | 'always' // 필터 규칙과 같은 검사(MailFilterRule 의 match · conditions)
conditions: MailFilterCondition[] // all · any 는 1~5개, always 는 빈 배열
actions: { // 하나 이상 — 전달은 없다(이미 받은 메일은 전달하지 않는다)
moveToMailboxId: string | null // 필터 규칙과 같은 메일함만(보낸편지함 · 임시보관함은 400). 받은편지함이거나 없는 메일함이면 옮기지 않는다
markAsRead: boolean
flag: boolean
}
cursor: string | null // 처음은 null, 이어 할 때는 앞 응답의 cursor 그대로(해석하지 않는다)
}
interface MailFilterRun {
scanned: number // 이번 요청에서 확인한 받은편지함 메일 수
matched: number // 그중 조건에 맞아 할 일을 한 메일 수(이미 읽음 · 중요였던 메일도 센다)
remaining: number // 아직 확인하지 않은 메일 수 — 어림(그사이 들어오고 나간 메일이 있다)
cursor: string | null // null = 끝. 아니면 같은 본문에 이 cursor 를 넣어 다시 보낸다
}
```
- 받은편지함만, 받은 시각이 최근인 메일부터 본다. 저장해 둔 필터를 읽지 않고 본문의 규칙을 쓴다(클라이언트는 방금 저장한 그 규칙을 보낸다) —
그래서 적용하는 동안 다른 필터를 켜고 끄거나 저장해도 멈추지 않는다.
- 한 요청은 25초 안에 끝내고 `cursor` 를 돌려준다 — 4단계 "오래 걸리는 처리"와 같은 상한 · 같은 규칙(다음 차례는 남은 시간이 5초보다 많을 때만, 엔진 호출마다 남은 시간만큼,
진행한 것이 있으면 거기까지 200, 하나도 확인하지 못했으면 503 `MAIL_001`). 같은 직원의 적용은 한 번에 하나 — 진행 중이면 엔진에 묻지 않고 429 `REQ_001`(다른 탭 · PC).
- **판정은 서버가 한다** — 새로 오는 메일의 Sieve(위 "메일 필터"의 "만드는 Sieve")와 같은 규칙을 따라 한다. 엔진 검색(`Email/query` 의 글자 조건)은 쓰지 않는다 —
낱말이 통째로 같을 때만 맞고('회의'로 '회의를' · '아라'로 '김아라'를 못 찾는다) `header` 조건은 아무것도 찾지 못한다(Stalwart 0.16.27, 2026-10-28 확인).
- 보낸 사람: From 머리글의 원래 글 — 접힌 줄(줄바꿈 + 공백 · 탭)은 띄어쓰기 하나로 펴고(접지 않은 탭 · 이어진 공백은 그대로), 인코딩된 글(RFC 2047 — 문자셋 그대로)은
**홀로 선 것만** 풀고(앞이 처음 · 공백 · `"` · `,`, 뒤가 끝 · 공백 · `"` — `abc=?…?=` · `=?…?=` · `(=?…?=)` 는 그대로. 공백만 사이에 둔 인코딩된 글끼리는 붙인다),
앞뒤 공백을 뺀 것 — 따옴표 · 꺾쇠까지 견준다(`"Kim, Ara" ` 에 `"Kim` 이 맞는다 — 엔진과 같다). 받는 사람: To · Cc 머리글 각각(하나라도 맞으면). 제목: 풀어 쓴 제목.
- 본문: 텍스트 · HTML 부분과 **텍스트 첨부**(text/* — 파일로 붙인 것도. 엔진이 그렇게 본다). 한 부분은 앞 256KiB 까지, 한 통은 모두 합쳐 1MiB 까지 본다
(글자 부분이 많아 넘으면 부분마다 같은 몫만큼 앞쪽만 — 바깥에서 온 메일 한 통으로 서버가 메모리를 크게 쓰지 않게). HTML 은 글자 부분만 — 엔진과 같게:
- 태그는 `<` 부터 다음 `>` 까지다 — `<` 뒤가 무엇이든 태그이고(`a < b` · `<광고>` 도), 따옴표 안의 `>` 에서도 끝난다. 닫히지 않은 `<` 는 끝까지 삼킨다.
태그는 속성까지 **빈자리 없이** 뺀다(`회의록` → "회의록", `div` · `li` · `h1` · 표 칸 · 표 줄 · `hr` · 여는 `p` 사이도 이어 붙는다). 주석(``)은 안의 글까지 뺀다
- `
`(닫는 것만) · `
`(`
` · `
` · `` · 대문자도)은 줄바꿈이다 — 찾을 글(줄바꿈이 없다)은 이 자리를 건너 맞지 않는다(`앞
뒤
` 에 "앞뒤" · "앞 뒤" 모두 안 맞음)
- `