# 인증·메일 API 계약 (1단계) > 백엔드와 프론트엔드를 **병렬로** 만들기 위한 계약이다. > 구현이 끝나면 Swagger(`/api-docs`)와 프론트 타입이 기준이며, 이 문서와 어긋나면 **이 문서를 고친다.** > 바꿔야 할 일이 생기면 한쪽에서 조용히 바꾸지 말고 이 문서부터 고친 뒤 양쪽에 알린다. 공통 규칙은 그대로 따른다 — 표준 응답 래퍼([[response-format]]), 에러 코드([[error-codes]]), API 규약([[api-design]]). - 성공 `{ "success": true, "data": … }` / 실패 `{ "success": false, "error": { "code", "message", "traceId" } }` - 날짜·시각은 ISO 8601 UTC 문자열, 목록은 `{ items, pageInfo }` - 이 단계에서 쓰는 에러 코드: `AUTH_001` `AUTH_002`(클라이언트 전용) `AUTH_003` `AUTH_004` `VAL_001` `RES_001` `REQ_001` `FILE_001` `FILE_002` `MAIL_001` `MAIL_002` `MAIL_003` `MAIL_004` `MAIL_005` `MAIL_006` `SYS_001` - **적용 범위: 1단계는 웹(브라우저) 전용이다.** 인증은 HttpOnly 쿠키뿐이고 토큰을 본문으로 주지 않는다. 모바일 앱(Flutter)의 인증 방식(토큰 발급·`flutter_secure_storage`)은 앱 착수 때 이 문서에 추가한다. - **배포 형태: 화면과 API 는 같은 주소(origin)를 쓴다.** 운영은 프론트 nginx 가 `/api` 를 백엔드로 넘기고, 개발은 vite 프록시가 넘긴다. 그래서 CORS 가 필요 없고 CSP `connect-src 'self'` 로 충분하다. - **출처 검사(CSRF)**: 상태를 바꾸는 요청(POST·PATCH·PUT·DELETE)은 브라우저가 붙이는 `Sec-Fetch-Site`·`Origin` 을 검사한다. 다른 사이트에서 온 요청은 403 `AUTH_003`. 출처 정보가 아예 없는 요청(브라우저가 아닌 클라이언트)은 통과한다. - **프록시가 직접 내는 오류**: 프론트 nginx 가 백엔드에 닿기 전에 끝내는 요청도 표준 실패 형식으로 온다 — 본문 한도 초과 413 `FILE_002`, 백엔드 응답 없음 502·시간 초과 504 `SYS_001`. `traceId` 는 nginx 요청 ID 이고 `message` 는 비어 있을 수 있으니 클라이언트는 **코드로** 사전 문구를 쓴다. --- ## 1. 인증 (쿠키 기반 — 토큰은 응답 본문에 없다) | 쿠키 | 내용 | 속성 | | --- | --- | --- | | `access_token` | 액세스 JWT (`JWT_ACCESS_EXPIRES_IN`, 기본 1h) | HttpOnly · SameSite=Lax · Path=`/api` · 운영 Secure | | `refresh_token` | 리프레시 JWT (`JWT_REFRESH_EXPIRES_IN`, 기본 14d) | HttpOnly · SameSite=Strict · Path=`/api/auth` · 운영 Secure | 직원 계정 = 메일 계정이다. 백엔드는 로그인할 때만 비밀번호를 메일 엔진(Stalwart)에 확인하고 저장하지 않는다. ### `POST /api/auth/login` ```jsonc // 요청 { "email": "ara.kim@arasystem.it.kr", "password": "…" } // 200 + Set-Cookie(access_token, refresh_token) { "email": "ara.kim@arasystem.it.kr", "name": "김아라" } ``` | 실패 | 코드 | | --- | --- | | 계정 없음 · 비밀번호 틀림 · 사내 도메인이 아닌 주소 · 메일 엔진 관리자 계정(웹메일 로그인 대상이 아니다) (구분하지 않는다) | 401 `AUTH_004` | | 형식 오류 | 400 `VAL_001` | | 시도 과다 — 같은 IP 에서 1분에 **실패** 20회, 같은 주소로 15분에 **실패** 10회 (성공한 로그인은 세지 않는다 — 사무실처럼 여러 직원이 한 IP 를 쓰는 환경 때문). 이와 별도로 같은 IP 의 로그인 **요청**은 1분에 60회까지. "같은 IP" 는 IPv6 를 **/64 단위**로 묶는다(IPv4-mapped 주소는 IPv4 로). 사내 도메인이 아닌 주소는 IP 한도에만 센다 | 429 `REQ_001` | | 같은 주소의 로그인이 아직 처리 중 (버튼을 두 번 누른 경우 등) | 429 `REQ_001` | | 대량 공격으로 서버가 기억하는 시도 기록이 상한에 닿았고 밀어낼 수 있는 기록이 없음 — 새 IP·주소의 로그인 (잠금 기록을 지키려고 막는 쪽을 택한다) | 429 `REQ_001` | | 메일 엔진 장애 | 503 `MAIL_001` | ### `GET /api/auth/me` 앱 시작 시 세션 복원용. 200 `{ "email", "name" }` / 로그인 안 됨 401 `AUTH_001`. ### `POST /api/auth/refresh` 리프레시 쿠키로 두 쿠키를 새로 발급한다(회전). 200 `{ "email", "name" }`. | 실패 | 응답 | 세션·쿠키 | | --- | --- | --- | | 쿠키 없음·만료·끊긴 세션, 메일 엔진이 계정을 찾지 못함(대리 접근 401), 그 계정이 엔진 관리 권한을 얻음 | 401 `AUTH_001` | 쿠키 삭제·세션 폐기 | | 메일 엔진 장애, 게이트웨이의 대리 접근 권한 문제(엔진 403 — 설정 실수) | 503 `MAIL_001` | **유지** — 설정을 고치면 그대로 이어 쓴다 | | 서버 쪽 드문 불일치(회전 충돌) | 500 `SYS_001` | **유지** — 같은 쿠키로 다시 시도할 수 있다 | **세션 규칙 (서버에 저장한다)** - 로그인하면 서버에 세션이 하나 생긴다. 리프레시 토큰은 **1회용**이다 — 갱신할 때마다 새 토큰을 주고 이전 토큰은 폐기한다. - **이미 쓴 리프레시 토큰이 다시 오면** 탈취로 보고 그 세션 전체를 끊는다 → 401 `AUTH_001`. - 예외(유예): 회전 직후 **30초 안에 바로 이전 토큰**이 오면(탭 여러 개가 동시에 갱신한 경우) 세션을 끊지 않고 200 `{ "email", "name" }` 으로 답하되 **쿠키는 새로 주지 않는다** — 먼저 갱신한 요청이 받은 쿠키를 같이 쓴다. 두 번 이상 지난 토큰이나 30초가 지난 이전 토큰은 위 규칙대로 세션을 끊는다. - 세션은 로그인 후 **최대 30일**(절대 만료)이다. 그 안에서도 리프레시 토큰을 14일 동안 쓰지 않으면 끝난다. - 액세스 토큰은 서버 조회 없이 검증한다. 그래서 세션을 끊어도 이미 발급된 액세스 토큰은 만료(최대 1시간)까지 유효하다. ### `POST /api/auth/logout` 항상 200 (`data: null`). 두 쿠키를 지우고 **서버의 세션을 폐기**한다. ### 클라이언트 처리 규칙 - 보호 API 가 401 `AUTH_001` 을 주면 **한 번만** `/auth/refresh` 를 시도하고, 성공하면 원래 요청을 다시 보낸다. 실패하면 기존 규칙대로 `AUTH_002` 로 승격하고 로그인 화면으로 보낸다. `/auth/login` · `/auth/refresh` · `/auth/me` · `/auth/logout` 은 이 재시도 대상이 아니다. - `/auth/refresh` 의 200 은 `Set-Cookie` 가 없어도 **성공**으로 다룬다 (위 유예 응답). - 세션이 끝난 것은 `/auth/refresh` 의 401 `AUTH_001` 뿐이다. 5xx(`MAIL_001`·`SYS_001`)·네트워크 오류는 일시 장애로 알리고 **로그아웃으로 다루지 않는다**. - 로그인 버튼은 응답이 올 때까지 다시 누를 수 없게 한다 — 같은 주소로 동시에 보낸 두 번째 요청은 429 `REQ_001` 이다. - 로그아웃하면 같은 브라우저의 다른 탭에도 알린다(BroadcastChannel). 알림을 받은 탭은 로그인 화면으로 간다. - 로그아웃(어느 탭에서든) 뒤에 도착한 `/auth/refresh` 성공은 쓰지 않고 `/auth/logout` 을 한 번 더 부른다. 갱신과 로그아웃이 겹치면 서버가 로그아웃 뒤에 새 쿠키를 구울 수 있기 때문이다 (세션은 끊겼어도 액세스 토큰은 만료까지 유효). - 앱 시작 시 `/auth/me` 로 세션을 복원한다. 이 호출의 `AUTH_001` 은 로그인 전 정상 상태이므로 **알림을 띄우지 않는다.** - 복원 중 `/auth/me` 가 `AUTH_001` 이면 액세스 토큰만 만료됐을 수 있으므로 **`/auth/refresh` 를 알림 없이 한 번** 부르고, 성공하면 복원 완료, 실패하면 로그아웃 상태로 시작한다. → 백엔드는 **액세스 쿠키가 없거나 만료돼도** 리프레시 쿠키만으로 `/auth/refresh` 가 동작해야 한다. --- ## 2. 메일 (모두 로그인 필요 — 없으면 401 `AUTH_001`) ### 타입 ```ts interface Address { name: string | null; email: string } interface Mailbox { id: string name: string // 역할이 있으면 한국어 표준 이름, 없으면 서버의 이름 role: 'inbox' | 'drafts' | 'sent' | 'junk' | 'trash' | 'archive' | null parentId: string | null sortOrder: number totalCount: number unreadCount: number } interface MessageSummary { id: string threadId: string mailboxIds: string[] from: Address | null to: Address[] subject: string // 제목이 없으면 빈 문자열 preview: string receivedAt: string isRead: boolean isFlagged: boolean hasAttachment: boolean size: number // 바이트 } interface MessageDetail extends MessageSummary { cc: Address[] bcc: Address[] replyTo: Address[] sentAt: string | null messageId: string | null // Message-ID 헤더 (답장 스레드 연결용) references: string[] htmlBody: string | null // 서버가 정화한 HTML (아래 참고) textBody: string | null hasBlockedImages: boolean // 외부 이미지를 지웠으면 true → "외부 이미지 차단" 안내 attachments: Attachment[] } interface Attachment { id: string; name: string; type: string; size: number } ``` 역할별 한국어 이름: `inbox` 받은편지함 · `drafts` 임시보관함 · `sent` 보낸편지함 · `junk` 스팸메일함 · `trash` 휴지통 · `archive` 보관함. **`htmlBody` 정화 규칙 (서버 책임)**: 스크립트·스타일 태그·이벤트 핸들러·폼·iframe·object·embed·svg 제거, 링크는 `http` · `https` · `mailto` 만 남기고 새 창(`target="_blank" rel="noopener noreferrer"`), 외부(`http/https`) 이미지는 제거하고 `hasBlockedImages = true`. 본문 안 인라인 이미지(`cid:`)는 작은 것만 `data:` URI 로 바꿔 넣고 나머지는 지운다(첨부 목록에는 남는다). **클라이언트는 그래도 격리된 iframe 에만 그린다** (스크립트 비허용 sandbox + 내부 CSP — 이중 방어). **읽기 한도**: 상세 조회는 본문 한 부분을 UTF-8 2,000,000바이트까지 읽는다 — 넘는 부분은 잘린 채 온다(잘렸다는 표시는 아직 없다). HTML 은 정화하기 **전에** 길이(HTML 부분이 여럿이면 이어 붙인 것이 2,000,000자 이하)와 파서 비용을 본다 — 약 2만 단을 넘는 중첩이나, 깊게 열린 태그 아래로 짝 없는 닫는 태그를 아주 많이 둔 HTML 은 정화가 서버를 수십 초씩 붙잡기 때문이다. 넘으면 정화하지 않고 `htmlBody: null`·`hasBlockedImages: false` 로 내려준다. `textBody` 는 그대로라 클라이언트는 그것을 보여 주고 답장·전달 인용도 그것으로 만든다 (HTML 만 있는 메일이면 둘 다 `null`). 수백 단의 인용 스레드·2MB 짜리 큰 표·Outlook 조건부 주석은 한도 안이다. ### 엔드포인트 | 메서드 · 경로 | 요청 | 성공 | 주요 실패 | | --- | --- | --- | --- | | `GET /api/mailboxes` | — | 200 `{ items: Mailbox[], pageInfo }` (hasNext=false) | `MAIL_001` | | `GET /api/mail-quota` | — | 200 `MailQuota` | `MAIL_001` | | `GET /api/messages` | `mailboxId`(필수) · `cursor` · `limit`(기본 20, 최대 100) · `q`(선택) | 200 `{ items: MessageSummary[], pageInfo }` — 받은 시각 내림차순, `total` 포함 | 400 `VAL_001`, `MAIL_001` | | `GET /api/messages/{id}` | — | 200 `MessageDetail` — **조회만으로 읽음 처리하지 않는다** | 404 `RES_001` | | `PATCH /api/messages/{id}` | `{ isRead?, isFlagged?, mailboxId? }` (하나 이상) | 200 `MessageSummary` | 404 `RES_001`, 400 `VAL_001` | | `DELETE /api/messages/{id}` | — | 204 — 휴지통이 아니면 휴지통으로 이동, 휴지통이면 영구 삭제 | 404 `RES_001` | | `POST /api/messages` | 아래 발송 본문 + `Idempotency-Key` 헤더 | 201 `{ id }` (보낸편지함의 메일 id) | 400 `MAIL_002`·`MAIL_003`·`MAIL_004`·`MAIL_006`·`VAL_001`, 403 `AUTH_003`(보낼 권한 없음), 409 `MAIL_005`(같은 키·다른 내용), 413 `FILE_002`(첨부 합계 초과 포함), 429 `REQ_001`(엔진 발송 한도·같은 키 처리 중·엔진 장애 직후 30초), 503 `MAIL_001` | | `POST /api/mail-attachments` | multipart `file` 1개 | 201 `Attachment` | 400 `FILE_001`, 413 `FILE_002` | | `GET /api/messages/{id}/attachments/{attachmentId}` | — | 파일 (표준 래퍼 없음, `Content-Disposition: attachment`) | 404 `RES_001` | 표에 없는 실패 (구현에서 확정): | 상황 | 응답 | | --- | --- | | 없는 `mailboxId`, 엔진이 해석하지 못하는 `mailboxId` | 404 `RES_001` | | 형식이 틀린 경로 id | 404 `RES_001` | | 받는 사람 합계 100명 초과 (배열 하나가 100을 넘으면 `VAL_001`) | 400 `MAIL_002` | | 엔진에서 찾을 수 없는 첨부 id — 올려 두고 오래 지나 만료된 업로드, 그사이 지워진 원본 메일의 첨부 등 | 400 `MAIL_004` — 클라이언트는 해당 첨부를 실패로 표시한다. 올린 파일은 다시 올리게, 전달하는 원본 첨부는 빼게 한다 (원본은 다시 올릴 수 없다) | | 첨부 20개 초과 | 400 `MAIL_006` — 클라이언트는 첨부를 추가할 때 미리 막는다 | | JSON 본문 1MB 초과 (발송 `POST /api/messages` 만 4MB — 서식 본문과 긴 인용 때문) | 413 `FILE_002` | 메일함 정렬(확정 디자인 기준): 받은편지함 → 보낸편지함 → 임시보관함 → 스팸메일함 → 휴지통 → 보관함 → 그 외(`sortOrder`, 이름 순). ```ts // GET /api/mail-quota — 메일함 패널 하단 사용량 표시와 첨부 사전 검사에 쓴다 interface MailQuota { usedBytes: number limitBytes: number | null // 한도가 없으면 null → "사용량 0.9GB" 처럼 사용량만 표시 attachmentMaxBytes: number // 서버의 MAIL_ATTACHMENT_MAX_SIZE_MB — 클라이언트는 상수 대신 이 값으로 사전 검사·안내한다 attachmentTotalMaxBytes: number // 메일 한 통의 첨부 합계 한도 — MAIL_ATTACHMENT_TOTAL_MAX_SIZE_MB 와 엔진 한도 중 작은 값 (발송 때 검사하는 값과 같다) } ``` `cursor` 는 불투명 문자열이다. 클라이언트는 해석하지 말고 `pageInfo.nextCursor` 를 그대로 다시 보낸다. ### 발송 본문 (`POST /api/messages`) **중복 발송 방지 — `Idempotency-Key` 요청 헤더**: 클라이언트는 쓰기 화면마다 무작위 키(UUID) 하나를 만들어 보내고, 같은 메일을 다시 보낼 때(타임아웃 뒤 재시도 등)에도 **같은 키**를 보낸다. 서버는 같은 사용자·같은 키로 이미 발송된 메일이 있으면 다시 보내지 않고 그 결과(201 `{ id }`)를 그대로 돌려준다. 응답이 늦어 `MAIL_001` 을 받았어도 실제로는 나갔을 수 있으므로, 재시도는 반드시 같은 키로 한다. (키 형식이 틀리면 400 `VAL_001`. 키가 없으면 중복 방지 없이 보낸다.) - 같은 키의 요청이 **아직 처리 중**이면 기다리지 않고 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": "
도윤님, 확인했습니다.
…