docs: 메일 15단계(메일 · 메일함 다루기)를 계약 · 디자인 README · 스킬에 적는다

계약 2절에 메일함 옮기기 · 하위까지 지우기 · 받은편지함 정리 · 지금 보내기(결과를 모르는 실패와 알려진 한계까지),
디자인 README 에 15단계 절과 구현하며 정한 보완, nestjs · vue3 스킬 표에 새 파일과 잠금(여러 대로 늘릴 때 옮길 것).
14단계 "미룬 것"에서 15단계에 한 것(대화째 끌기 · 모두 펼치기 · 줄 머리 답장)을 뺐다.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-09 18:38:11 +09:00
parent d02a983d57
commit 7f39aa4eb2
4 changed files with 296 additions and 24 deletions
+107 -11
View File
@@ -411,8 +411,8 @@ HTML 은 정화하기 **전에** 길이(HTML 부분이 여럿이면 이어 붙
| --- | --- | --- | --- |
| `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}` | `{ name?, parentId? }` — 하나 이상(`parentId` 는 15단계 "메일함 옮기기" — `null` 은 맨 위) | 200 `Mailbox` | 400 `VAL_001`(빈 본문 · 기본 메일함 · 만들 수 없는 자리 · 자기 자신 · 자기 하위 아래), 404 `RES_001`(없는 메일함 · 없는 새 상위), 409 `MAIL_009`, 429 `REQ_001`(옮길 때 — 그 메일함 · 새 상위의 처리가 진행 중), 503 `MAIL_001` |
| `DELETE /api/mailboxes/{id}` | `withChildren=true`(선택 — 15단계 "하위 메일함까지 지우기") | 200 `MailboxDeletion` — 안의 메일을 휴지통으로 옮긴 뒤 지운다(하위까지면 나무 전체) | 400 `VAL_001`(기본 메일함 · 하위 메일함이 있는데 `withChildren=true` 가 아님 · 하위에 기본 메일함 · 다른 값), 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` |
@@ -446,13 +446,15 @@ HTML 은 정화하기 **전에** 길이(HTML 부분이 여럿이면 이어 붙
| `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` |
| `DELETE /api/scheduled-messages/{id}` | — (`id` 는 보낸 사본의 메일 id) | 200 `{ draftId }` — 붙잡은 발송을 취소하고 임시보관함으로(보내기 취소 · 예약 취소). 이미 취소한 발송이면 다시 200(같은 id) | 404 `RES_001`(형식이 틀린 id 포함), 409 `MAIL_014`(이미 보냄 · 기다리는 발송이 아님), 429 `REQ_001`(같은 메일의 지금 보내기가 진행 중 — 15단계), 503 `MAIL_001` |
| `POST /api/scheduled-messages/{id}/release` | — 본문 없음(`id` 는 보낸 사본의 메일 id) | 200 `ReleasedSend` — 예약한 메일을 지금 보낸다(15단계, 아래 "보내기 취소 · 예약 발송"의 "지금 보내기"). 이미 보냈으면 200 `pendingSend: null` | 400 `MAIL_010`(받는 사람 모두 거절 — 임시보관함으로 되돌렸다), 404 `RES_001`(예약이 아닌 메일 · 형식이 틀린 id 포함), 429 `REQ_001`(같은 메일의 예약 취소 · 지금 보내기가 진행 중), 503 `MAIL_001`(임시보관함으로 되돌렸거나 · 결과를 몰라 보낸편지함에 그대로 — 그 메일을 다시 읽어 가른다) · `MAIL_011` |
| `GET /api/mail-images/{token}` | — | 바깥 이미지 (표준 래퍼 없음 — 아래 "외부 이미지 보기", 9단계) | 404 `RES_001`(까닭을 가리지 않는다) |
| `GET /api/mail-settings` | — | 200 `MailSettings` (아래 "보내기 · 읽기 설정", 9단계) | — |
| `PATCH /api/mail-settings` | `Partial<MailSettings>` — 하나 이상 | 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` |
| `POST /api/mail-block-runs` | `{ sender, cursor }` — 받은편지함에 이미 있는 그 보낸 사람의 메일을 스팸메일함으로(15단계, 아래 "보낸 사람 차단"의 "받은편지함 정리") | 200 `MailBlockRun` | 400 `VAL_001`(형식 · 깨진 cursor · 다른 보낸 사람의 cursor), 404 `RES_001`(차단 목록에 없음), 429 `REQ_001`(같은 직원의 받은편지함 정리(필터 적용 포함) · 스팸메일함 비우기가 진행 중), 503 `MAIL_001`(스팸메일함 없음 포함) |
표에 없는 실패 (구현에서 확정):
@@ -1082,12 +1084,12 @@ interface MessageDeletionResult {
```ts
interface MailboxCreate { name: string; parentId?: string | null } // parentId 없음 = 맨 위
interface MailboxRename { name: string }
interface MailboxUpdate { name?: string; parentId?: string | null } // 하나 이상 — 보내지 않은 칸은 그대로. parentId: null = 맨 위 (15단계 — 4단계의 MailboxRename { name } 을 넓혔다)
interface MailboxDeletion {
deleted: boolean // true = 지웠다. false = 아직 옮길 메일이 남았다 — 같은 요청을 다시 보낸다
processed: number // 이번 요청에서 이 메일함에서 빠진 메일 수 — 휴지통으로 옮김 + 다른 메일함에도 있어 이 메일함에서만 뺌 (진행 표시용)
deleted: boolean // true = 지웠다(하위까지면 나무 전체). false = 아직 옮길 메일이 남았다 — 같은 요청을 다시 보낸다
processed: number // 이번 요청에서 이 메일함(하위까지면 나무)에서 빠진 메일 수 — 휴지통으로 옮김 + 다른 메일함에도 있어 이 메일함에서만 뺌 (진행 표시용)
movedToTrash: number // 그중 휴지통으로 옮긴 수 (끝난 알림의 "안의 메일 N통은 휴지통에 있습니다")
remaining: number // 아직 남은 메일 수
remaining: number // 아직 남은 메일 수 (하위까지면 나무 전체에 — 여러 메일함에 든 메일도 한 번)
}
interface MailboxBulkResult {
processed: number // 이번 요청에서 읽음으로 바꾼 · 지운 메일 수
@@ -1102,14 +1104,35 @@ interface MailboxBulkResult {
- **만들 수 있는 자리**: 맨 위, 받은편지함 · 보관함 · 내 메일함(역할 없음) 아래. 보낸편지함 · 임시보관함 · 스팸메일함 · 휴지통 아래는 400 `VAL_001`.
웹 화면은 받은편지함 · 내 메일함 아래만 메뉴에 둔다(보관함 아래는 다른 메일 프로그램이 만든 것을 그대로 보인다).
- **기본 메일함**(역할이 있는 메일함)은 이름을 바꾸거나 지울 수 없다 — 400 `VAL_001`. 화면은 메뉴에 그 항목을 두지 않는다.
- **지우기**: 하위 메일함이 있으면 400 `VAL_001`(화면이 먼저 막는다). 안의 메일은 **휴지통으로 옮긴 뒤**(다른 메일함에도 있는 메일은 이 메일함에서만 뺀다)
- **지우기**: 하위 메일함이 있으면 400 `VAL_001`(화면이 먼저 막는다 — 15단계부터는 아래 "하위 메일함까지 지우기"). 안의 메일은 **휴지통으로 옮긴 뒤**(다른 메일함에도 있는 메일은 이 메일함에서만 뺀다)
빈 메일함을 지운다 — 엔진은 메일이 든 메일함을 `mailboxHasEmail` 로 거절하므로(`onDestroyRemoveEmails: false` — 메일을 영구 삭제하는 옵션은 쓰지 않는다) 그사이 들어온 메일도 남지 않는다.
메일이 많으면 한 요청에 다 옮기지 못한다 — `deleted: false` 면 클라이언트가 같은 요청을 다시 보낸다.
- **옮기기**(15단계 — `PATCH /api/mailboxes/{id}` 의 `parentId`): `{ name?, parentId? }` 가운데 바꿀 것만 보낸다 — 둘 다 없으면 400 `VAL_001`, `parentId: null` 은 맨 위.
- 새 상위는 **만들기와 같은 자리**다 — 맨 위 · 받은편지함 · 보관함 · 내 메일함 아래(그 밖은 400 `VAL_001`, 없는 상위는 404 `RES_001`). 기본 메일함은 옮길 수 없다(400 `VAL_001`).
- 자기 자신 · 자기 하위(손주까지) 아래로는 400 `VAL_001` — 서버가 엔진에 묻기 전에 메일함 목록으로 본다. 엔진도 고리를 `invalidProperties`(parentId)로 막는다(2026-11-09 개발 엔진 확인) —
그 거절이 오면 같은 요청에서 다시 읽은 목록에 새 상위가 있으면 400 `VAL_001`(그사이 생긴 고리), 없으면 404 `RES_001`(그사이 지워짐)이다.
- 새 자리에 같은 이름이 있으면 409 `MAIL_009` — **엔진은 대소문자를 가리지 않는다**(`p15-자식` 도 `P15-자식` 과 겹친다 — 같은 날 확인). 화면은 같은 이름이 있는 곳을 처음부터 고르지 못하게 한다(같은 판정).
- 하위 메일함과 안의 메일은 함께 간다 — 메일함 id 는 그대로라 그 메일함으로 옮기는 필터도 그대로 돈다. 깊이 한도는 두지 않는다(만들기와 같다 — 엔진은 15칸까지 막지 않았다).
- 이름 · 상위가 지금과 같으면 엔진에 묻지 않고 200(그대로). 응답은 지금처럼 `Mailbox`(새 `parentId`).
- 옮길 때(`parentId` 를 보낼 때) 그 메일함이나 새 상위의 오래 걸리는 처리(아래)가 진행 중이면 엔진에 묻지 않고 429 `REQ_001` — 지우는 나무 안으로 옮기거나 지우는 메일함을 빼 가지 않게. 이름만 바꾸기는 지금처럼 잡지 않는다.
- 로그: 실제로 옮겼을 때만, 메일함 id 와 새 상위 id 만(이름 없이 — 사용자가 정한 글이다).
- **하위 메일함까지 지우기**(15단계 — `DELETE /api/mailboxes/{id}?withChildren=true`): `withChildren=true` 일 때만 하위가 있어도 지운다 — 없으면 지금처럼 400 `VAL_001`(옛 화면이 모르고 하위를 지우지 않게).
값은 `true` 만 받는다(그 밖 · 모르는 쿼리는 400 `VAL_001`).
- 나무(그 메일함과 하위 모두)를 지금 지우기와 같은 규칙으로 — 나무의 메일을 한 조회(`inMailbox` 의 OR — 엔진이 겹치지 않게 센다)로 500통씩 읽어, 나무 안에만 있는 메일은 휴지통으로,
나무 밖의 메일함에도 있는 메일은 나무의 메일함에서만 뺀다(확인한 상태 그대로일 때만 — `ifInState`). 붙잡힌 메일은 그대로 둔다(9단계 — 남은 것이 그것뿐이면 `MAIL_015`).
- 비었으면 빈 메일함을 **깊은 것부터 한 요청에** 지운다(`onDestroyRemoveEmails: false`) — 엔진은 한 요청의 destroy 를 적은 차례대로 한다(2026-11-09 확인 — 위에서부터 적으면 맨 아래 하나만 지워진다).
그사이 메일이 들어와 거절되면 지운 하위는 나무에서 빼고 남은 나무의 메일을 다시 옮긴다. 그사이 나무 밖의 하위 메일함이 생기면(`mailboxHasChild`) 목록을 다시 읽어 그 메일함까지 지운다.
- 하위에 기본 메일함(역할이 있는 메일함 — 다른 프로그램이 만든 이상한 나무)이 있으면 400 `VAL_001`.
- 응답은 지금 `MailboxDeletion` 그대로 — 수는 나무 전체의 것이다(`deleted: true` 까지 다시 보낸다). 그 밖의 오류는 지금 지우기와 같다.
- 잠금: 나무의 메일함 모두(아래 "같은 직원 · 같은 메일함의 처리는 한 번에 하나") — 서버는 그 메일함을 먼저 잡고(처리 중이면 엔진에 묻지 않고 429 `REQ_001`)
메일함 목록을 읽어 하위 메일함까지 잡는다(하위가 처리 중이면 목록만 읽고 429 — 아무것도 바꾸지 않는다). 휴지통 비우기와 함께 돌지 않는 것은 지금처럼 화면이 막는다.
- **전체 읽음**(`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`(다른 탭에서 같은 메일함을 처리할 때). 다른 메일함끼리는 함께 된다.
하위까지 지우기는 나무의 메일함 모두를(하위는 목록을 읽은 뒤), 옮기기는 그 메일함과 새 상위를 함께 잡는다(15단계 — 하나라도 처리 중이면 429).
차단할 때 받은편지함 정리(15단계 — 아래 "보낸 사람 차단")가 도는 동안은 그 직원의 스팸메일함도 잡혀 있다 — 그동안 스팸메일함 비우기 · 전체 읽음은 429.
- 한 요청의 상한 25초는 메일함 목록을 읽기 전부터 잰다(엔진의 세션 조회 포함). 다음 500통은 남은 시간이 5초보다 많을 때만 시작한다.
- 멈추는 경우 — 진행한 것이 있으면 거기까지 200(`remaining` > 0 · `deleted: false`), 하나도 처리하지 못했으면 503 `MAIL_001`(클라이언트가 끝없이 다시 보내지 않게):
아무것도 바뀌지 않는 차례(확인하는 사이 계속 바뀜 · 바뀐 것도 거절도 없음 · 비운 메일함을 지우는 사이 메일이 들어옴)가 이어서 3번, 남은 시간 때문에 엔진 호출이 끊김.
@@ -1480,6 +1503,45 @@ interface PendingSend {
- 서명 그림 부분 빼기 · `cid:sig-…` 되돌리기는 취소 표시(`$ara-canceled`)가 있는 초안에만 한다(다른 메일 프로그램이 만든 초안의 그림은 건드리지 않는다).
- 취소와 옮기기는 다른 요청이다(같은 요청에 두면 취소가 실패해도 옮겨진다). 취소는 됐는데 옮기지 못하면(엔진 오류) 한 번 더 하고, 그래도 못 하면 503 `MAIL_001` —
그 메일은 보내지 않은 채 보낸편지함에 남는다(드묾 — 알려진 한계, warn 로그).
- 같은 메일의 지금 보내기(아래)가 진행 중이면 엔진에 묻지 않고 429 `REQ_001`(15단계).
- **지금 보내기**(15단계 — `POST /api/scheduled-messages/{id}/release`, `id` 는 보낸 사본의 메일 id, 본문 없음): 예약한 메일을 [보내기]를 누른 것처럼 보낸다.
엔진 사실(2026-11-09 개발 엔진): 기다리는 예약 제출을 취소(`undoStatus: canceled`)하고 같은 메일을 바로 다시 제출하면 받는 사람에게 **정확히 한 통** 간다 —
취소가 된 뒤에만 다시 내므로 두 번 나갈 틈이 없다(그 전에 엔진이 보냈으면 취소가 `cannotUnsend`). 이미 보냈거나 이미 취소한 제출을 취소해도 `cannotUnsend` 라 서버는 제출의 `undoStatus` 를 다시 읽어 가른다.
```ts
interface ReleasedSend { // 발송 응답(SentMessage)과 같은 칸
id: string // 같은 메일 id (보낸 사본)
rejectedRecipients: RejectedRecipient[] // 받는 사람별 결과 — 발송과 같은 규칙(아래 "받는 사람별 결과")
pendingSend: PendingSend | null // { kind: 'undo', sendAt } — 보내기 취소를 기다린다 · null — 바로 보냈다 · 이미 보냈다
}
```
- 차례: 그 메일 · 메일함 · 그 메일의 제출(envelope · 발신 ID · 결과까지)을 한 요청으로 읽고 → 다시 낼 envelope 을 먼저 정한 뒤(읽지 못하면 취소하지 않고 503 `MAIL_001`) →
기다리는 예약 제출을 취소한다(엔진이 취소를 확인한 것만 — 확인이 없거나 결과를 모르면 다시 읽어 취소됐을 때만 잇고, 아직 기다리면 다시 내지 않고 503 `MAIL_001`) →
**다른 요청으로** 같은 메일을 그 제출의 envelope 그대로(받는 사람 · 참조 · 숨은참조 · envelope 에서 뺀 주소까지 원래 제출의 것 — 머리글에서 다시 만들지 않는다) 다시 제출한다.
MAIL FROM 의 붙잡기 매개변수만 바꾼다 — 그 직원의 보내기 취소 설정(`undoSendSeconds`)만큼 `HOLDFOR`(0 이면 붙잡지 않는다 — 설정을 읽지 못하면 처음 값 10초).
성공하면 같은 요청에서 예약 표시(`$ara-scheduled`)를 뗀다 — 이제 보내기 취소를 기다리는 메일이다(취소는 지금처럼 `DELETE` — 쓰기 화면으로).
- **이미 보낸 메일**(예약 시각이 막 지나 제출이 `final` · 보낼 시각이 지나 배달 중 · 취소하려니 엔진이 `cannotUnsend`)은 오류가 아니다 → 200 `pendingSend: null`(보낸 것으로).
- **이미 지금 보내기를 한 메일**(취소한 예약 제출 + 그 뒤 새 제출 — 다시 누름 · 응답을 받지 못한 재시도)도 다시 내지 않고 그 새 제출의 지금 상태로 200(기다리면 `pendingSend: { kind: 'undo' }`).
- 예약이 아닌 메일(보내기 취소를 기다리는 메일 · 취소해 임시보관함으로 간 메일(`$ara-canceled`) · 제출이 없음 · 예약하지 않고 보낸 메일) · 없는 메일 · 형식이 틀린 id 는 404 `RES_001` — 아무것도 바꾸지 않는다.
- 앞 요청이 예약을 취소한 뒤 끊겼으면(예약 표시가 있고 취소 표시는 없는데 제출이 모두 `canceled`) 취소를 건너뛰고 다시 제출만 한다 — 보내지지도 예약돼 있지도 않은 메일이 남지 않게(구현하며 정함).
- 다시 제출이 실패하면 둘로 가른다(처음 발송과 같은 결 — 위 "발송 본문"의 "엔진이 받지 않는 주소"에서 요청째 거절과 결과를 모르는 실패를 가르는 것과 같다):
- **엔진이 분명히 거절했다**(SetError · 메서드 오류 · 요청을 실행하지 않았다는 4xx · 429 — 아무것도 나가지 않았다): 한 번 더 낸다. 그래도 거절이면 그 메일을 임시보관함으로 되돌리고
(예약 취소와 같은 되돌리기 — `$draft` · `$ara-canceled`, 대용량 첨부 링크도 취소한 발송처럼) 503 `MAIL_001`(warn — 메일은 임시보관함에 있다).
- **결과를 모른다**(시간 초과 · 연결 끊김 · 5xx · 해석하지 못한 응답 — 나갔을 수 있다): 다시 내지도 · 임시보관함으로 옮기지도 않는다. 제출을 한 번 다시 읽어(남은 시간이 없어도 3초)
새 제출이 보이면 그 상태로 200, 아니면 503 `MAIL_001` — 메일은 예약이 취소된 채 보낸편지함에 그대로다(링크도 그대로). 같은 요청을 다시 보내면 위의 "끊긴 지금 보내기"로 이어 낸다
(그때 새 제출이 보이면 "이미 지금 보내기를 한 메일"로 200).
- 제출마다 먼저 남은 시간을 본다 — 10초보다 적으면 내지 않고 503 `MAIL_001`(결과를 모를 때와 같다 — 메일은 보낸편지함에 그대로, 다시 보내면 이어 낸다).
- 임시보관함으로 되돌리기마저 실패하면 503 `MAIL_001` — 메일은 예약이 취소된 채 보낸편지함에 남는다(위 예약 취소의 "옮기지 못하면"과 같다).
- 503 `MAIL_001` 은 응답만으로는 어느 쪽인지 모른다(응답 꼴은 그대로) — **클라이언트는 503 뒤에 그 메일을 다시 읽어**(`GET /api/messages/{id}`) 임시보관함에 있으면 되돌린 것으로,
보낸편지함에 있으면 [다시 시도](같은 `POST` — 이어 낸다)를 보인다.
- **알려진 한계**: 결과를 모르는 503 뒤에 다시 보내지 않으면 그 메일은 보내지 않은 채(예약도 취소된 채) 보낸편지함에 남는다 — 9단계 예약 취소의 "옮기지 못하면"과 같은 한계다(드묾, warn 로그).
- 받는 사람이 **모두** 받지 않으면(발송의 `MAIL_010` · `MAIL_011` 과 같은 판정) 보내지 않은 것이다 — 기다리는 새 제출을 취소하고 그 메일을 임시보관함으로 되돌린 뒤
400 `MAIL_010` · 503 `MAIL_011`(구현하며 정함 — 발송처럼 사본을 지우지 않는다: 쓰기 화면이 없는 메일이라 잃지 않게). 일부만 받지 않았으면 200 `rejectedRecipients`.
- 대용량 첨부(4절): 다시 제출한 뒤 그 발송이 만든 링크의 **보낸 때를 지금으로 앞당기고 끝나는 날은 그대로 둔다** — 보낸 메일의 덩이가 예약 시각으로 센 끝나는 날을 이미 적었다(앞당기면 받는 사람이 본 날짜보다 먼저 끝난다).
이미 보낸 메일이면 링크를 건드리지 않는다.
- 같은 직원 · 같은 메일의 예약 취소 · 지금 보내기는 한 번에 하나 — 진행 중이면 엔진에 묻지 않고 429 `REQ_001`.
서버는 54초 남짓 안에 답한다 — 엔진 호출은 45초 안에서 남은 시간만큼, 정리(새 제출 취소 · 임시보관함으로 되돌리기 · 결과를 모를 때 다시 읽기)는 남은 시간이 없어도 3초씩 더 쓴다.
앞단 nginx · 클라이언트의 60초 안이다(클라이언트는 발송과 같은 시간 한도로 부른다).
- 로그: 메일 id · 예약했던 시각 · 보낼 시각만(받는 사람 · 제목 없음 — 위 "로그"와 같다).
- **중복 발송 방지와 함께**: 취소한 발송의 `Idempotency-Key` 로 다시 보내면 409 `MAIL_005`(보내지 않는다) — 클라이언트는 취소 뒤 새 키로 보낸다.
서버는 취소한 발송의 메일을 "보낸 것"으로도, "끊긴 발송이 남긴 초안"으로도 보지 않는다(지우지 않는다). 취소 표시(`$ara-canceled`)가 있으면 제출 기록과 상관없이 취소한 발송이다.
붙잡는 시간은 지문에 넣지 않는다 — 같은 키로 다시 보내면 `sendAt` 이 달라도 처음 결과(그때의 `pendingSend`)를 돌려준다.
@@ -1547,7 +1609,8 @@ interface MailBlockList {
```
- **효과**: 차단한 보낸 사람(머리글 From 의 주소 · 도메인 — 대소문자 무시)의 메일은 스팸메일함으로 간다. 필터 · 부재중보다 먼저 돌고 멈추므로 필터는 돌지 않고 부재중 응답도 나가지 않는다.
이미 받은 메일은 옮기지 않는다 — 읽기 화면의 차단 창은 그 메일만 함께 스팸메일함으로 옮긴다(`PATCH /api/messages/{id}` 의 `isJunk`).
이미 받은 메일은 저절로 옮기지 않는다 — 읽기 화면의 차단 창은 그 메일만 함께 스팸메일함으로 옮기고(`PATCH /api/messages/{id}` 의 `isJunk`),
받은편지함에 있는 그 보낸 사람의 다른 메일은 고르면 아래 "받은편지함 정리"(15단계)로 옮긴다.
- **검사**: 주소는 발송의 주소와 같은 규칙에 `@` 앞은 dot-atom · 64자까지(따옴표 앞부분은 받지 않는다 — 클라이언트의 `isStrictEmail` 과 같은 폭, 2026-10-29 리뷰),
도메인은 `@` + 호스트 이름(점이 하나 이상 · 253자 · IP 주소 아님 · ASCII 만 — 국제화 도메인은 `xn--` 꼴로, `${` 는 받지 않는다).
이미 차단한 주소를 다시 `POST` 하면 200 — 엔진에서 규칙이 꺼져 있었으면 같은 원본을 다시 써서 켠다. 그래서 `POST` · `DELETE` 가 성공한 뒤에는 늘 켜져 있다(`isActive: true`).
@@ -1568,6 +1631,34 @@ interface MailBlockList {
- **클라이언트**: 읽기 화면 ⋯ 「보낸 사람 차단」(사내 직원 · 내 주소면 없고, 이미 차단했으면 「보낸 사람 차단 풀기」, 500개면 꺼짐 + 까닭) → 확인 창(이 주소만 · 이 도메인 전체,
「이 메일도 스팸메일함으로 옮기기」) → `POST`(+ 옮기기) → 알림 + [되돌리기](차단을 풀고 메일을 원래 메일함으로 — 구현하며 정함). 설정 › 메일 › 차단한 주소에서 더하고 푼다.
스팸메일함에서 차단한 보낸 사람의 메일을 열면 안내 줄 + [차단 풀기]. 차단 목록은 메일 화면에 들어올 때 받아 둔다.
- **받은편지함 정리**(15단계 — `POST /api/mail-block-runs`): 차단한 뒤 받은편지함에 이미 있는 그 보낸 사람의 메일을 스팸메일함으로 옮기고 스팸 표시를 붙인다(`$junk` 를 붙이고 `$notjunk` 를 뗀다 — 스팸 신고 `isJunk: true` 와 같다).
```ts
interface MailBlockRunRequest {
sender: string // 차단 목록의 값 그대로 — 소문자 주소 또는 "@도메인" (BlockedSender.address — 대소문자 · 앞뒤 공백은 서버가 맞춘다)
cursor: string | null // 처음은 null, 이어 할 때는 앞 응답의 cursor 그대로 — 늘 보낸다
}
interface MailBlockRun { // 이미 받은 메일에 필터 적용(MailFilterRun)과 같은 꼴
scanned: number // 이번 요청에서 확인한 받은편지함 메일 수
matched: number // 그중 스팸메일함으로 옮긴 수 (붙잡힌 메일은 옮기지 않아 세지 않는다)
remaining: number // 아직 확인하지 않은 수 — 어림
cursor: string | null // null = 끝
}
```
- 차단 목록에 없으면(차단한 뒤에만 부른다 · 그사이 풀었다) 404 `RES_001` — 요청마다 본다(도중에 풀면 다음 요청이 멈춘다). 보낸 사람의 모양은 차단하기와 같은 검사(400 `VAL_001`).
- 틀은 위 "이미 받은 메일에 적용"(7단계)과 같다: 받은편지함만(정리해 둔 메일함은 건드리지 않는다) · 받은 시각이 최근인 메일부터 · 첫 요청의 위 경계 뒤에 온 메일은 보지 않는다(새로 온 메일은 차단 규칙이 이미 옮겼다) ·
`cursor`(위 경계 · 마지막으로 남긴 메일과 그 받은 시각 · 보낸 사람의 지문 — 다른 보낸 사람의 cursor · 필터 적용의 cursor · 깨진 cursor 는 400 `VAL_001`) ·
한 요청 25초 · 다음 차례는 남은 시간 5초 넘을 때만 · 나아감 없는 차례 3번이면 멈춤 · 하나도 확인하지 못하면 503 `MAIL_001`. 옮기기는 읽은 상태 그대로일 때만(`ifInState`).
- **같은 직원의 받은편지함 정리(필터 적용 · 이것)는 한 번에 하나** — 같은 잠금, 진행 중이면 엔진에 묻지 않고 429 `REQ_001`.
- 요청이 도는 동안 그 직원의 **스팸메일함**을 메일함 처리 잠금(위 "메일함 관리" — 같은 메일함의 처리는 한 번에 하나)으로 잡는다 — 옮겨 넣는 동안 스팸메일함 비우기 · 전체 읽음이 함께 돌지 않게(그동안 그쪽이 429).
스팸메일함을 비우는 중이면 메일함 목록을 읽은 뒤 429 `REQ_001` — 받은편지함은 건드리지 않았다(비우기가 끝난 뒤 같은 `cursor` 로 다시 보낸다).
잠금은 **요청 하나씩**이다 — 다른 창에서 비우기와 차단 정리가 함께 진행되면 요청 사이에 번갈아 돌 수 있어, 차단 정리가 옮겨 넣은 메일을 다음 비우기 요청이 지울 수 있다
(알려진 한계 — 휴지통 비우기와 메일함 지우기의 관계와 같다. 같은 탭에서는 화면이 둘을 함께 돌리지 않는다).
- **판정은 차단 규칙(위 "만든 Sieve")과 같다** — 주소는 From 의 주소 가운데 하나가 같을 때(`address :all :is`), 도메인은 From 주소의 `@` 뒤가 그 도메인과 같을 때(`address :domain :is` — 하위 도메인 · 더 긴 도메인은 아님),
대소문자는 ASCII 만 무시(`i;ascii-casemap`). 견주는 것은 **엔진이 푼 From 머리글의 주소**(`header:From:asAddresses:all` — 같은 이름의 머리글이 여럿이면 모두)다 — 표시 이름에 그 주소를 쓴 남의 메일은 걸리지 않는다.
엔진 검색(`Email/query` 의 `from`)은 쓰지 않는다 — 낱말 단위라 넓게 잡는다(2026-11-09 개발 엔진: `kim.lee@` · `@sub.` · `.example.net` · 표시 이름에만 그 주소를 쓴 남의 메일까지).
- 붙잡힌 메일(9단계)은 옮기지 않는다(받은편지함에는 없지만 지금 규칙대로). 스팸메일함이 없으면 503 `MAIL_001`. 되돌리기는 없다(스팸메일함에서 [스팸 아님]으로).
- 로그: 보낸 사람은 가린다(`maskEmail` — 도메인 차단은 도메인만). 확인 · 옮긴 수 · 차례 · 시간만.
- **클라이언트**: 차단 창의 「받은편지함에 있는 이 보낸 사람의 다른 메일도 옮기기」(처음 꺼짐)를 켜면 차단(`POST /api/mail-blocks`)이 성공한 뒤 이 요청을 `cursor` 가 null 이 될 때까지 보낸다 — 진행 · 결과 알림은 디자인 README "15단계".
### 보내기 · 읽기 설정 (`/api/mail-settings`) — 9단계
@@ -1594,8 +1685,10 @@ interface MailSettings {
탭을 모두 닫아도 오는 알림(웹 푸시 — 엔진은 `webpush-vapid` 를 지원한다), 알림 소리(운영체제 설정을 따른다), 휴대폰 푸시, 본문 안 이미지(인라인 cid — 붙여 넣은 이미지는 첨부로, 서명 그림만 예외), 메일 안의 배경 그림 보이기 · 보낸 사람마다 고르는 "이미지 늘 보기" 목록(9단계는 설정 세 가지로),
여러 개의 서명(자체 검색 색인 · 조건 검색 · 검색어 강조는 14단계에서 했다 — 위 "검색 — 14단계"),
끌어다 놓아 옮기기, 메일함 옮기기(다른 상위로) · 하위 메일함까지 한꺼번에 지우기, 앱 비밀번호 · 2단계 인증(엔진은 지원한다), 부재중 응답의 서식 본문 · 사내/외부 구분,
예약한 메일의 「지금 보내기」 · 7일 넘는 예약(엔진 한도) · 차단할 때 이미 받은 그 보낸 사람의 메일 모두 옮기기(9단계는 그 메일만).
메일함을 끌어 옮기기 · 메일함 순서 바꾸기(15단계는 ⋯ 메뉴 「옮기기」 창으로 — 메일을 끌어다 메일함에 놓기는 15단계에서 했다, 서버는 지금 `PATCH /api/messages` 그대로), 옮기기 단축키,
부재중 응답의 서식 본문 · 사내/외부 구분(앱 비밀번호 · 2단계 인증은 12단계에서 했다), 7일 넘는 예약(엔진 한도), 예약됨 목록 줄의 [지금 보내기](15단계는 읽기 화면의 예약 안내 줄에만),
차단 정리의 되돌리기 · 받은편지함 밖 메일함의 정리(15단계는 받은편지함만 — 위 "보낸 사람 차단"의 "받은편지함 정리").
15단계에 한 것: 메일함 옮기기 · 하위 메일함까지 지우기(위 "메일함 관리"), 차단할 때 받은편지함 정리(위 "보낸 사람 차단"), 예약한 메일 지금 보내기(위 "보내기 취소 · 예약 발송").
---
@@ -2128,6 +2221,9 @@ interface FileUploadCreate {
- 덩이는 제출할 때 메일 안에 들어 있다 — 엔진이 붙잡았다가 그대로 보낸다.
- 링크의 보낸 때: 예약은 `sendAt`(끝나는 날도 그 날짜로 센다 — 예약 7일 + 30일이면 37일까지 보관), 보내기 취소를 기다리는 발송은 요청한 때(몇 초 차이는 무시).
예약 링크는 보낼 시각 전에도 열린다(주소를 아는 사람은 보낸 사람뿐이다).
- 예약한 메일을 **지금 보내면**(15단계 — 2절 "보내기 취소 · 예약 발송"의 "지금 보내기") 보낸 때를 다시 제출한 때로 앞당긴다(조건부 UPDATE 한 문장 — 취소한 링크 · 이미 그보다 앞선 보낸 때는 그대로).
**끝나는 날은 그대로 둔다** — 보낸 메일의 덩이가 예약 시각으로 센 날짜를 이미 적었다(메일은 고칠 수 없다 — 앞당기면 받는 사람이 본 날짜보다 먼저 끝난다). 파일의 보관 기간도 그대로다.
끝내 보내지 못해 임시보관함으로 **되돌렸을 때만** 예약 취소처럼 그 링크를 취소한 링크로 둔다 — 결과를 몰라 보낸편지함에 그대로 둔 503 은 링크도 그대로다(다시 보내면 이어 낸다).
- 취소(`DELETE /api/scheduled-messages/{id}`)가 엔진에서 확인된 뒤 그 발송이 만든 링크를 취소한 링크로 둔다. 파일은 남는다(되돌린 초안이 쓴다 — 임시 저장 기간으로, 보내지 않은 파일 목록에 다시 나온다).
- 되돌린 초안(취소 표시 `$ara-canceled`)을 열면 서버가 원문 HTML 의 표시 사이(덩이)를 정화 **전에** 떼고(`mail.service.ts:1282-1304` 의 `draftHtml` — 취소한 발송의 서명 그림을 되돌리는 그 자리),
텍스트 본문의 덩이도 뗀다(서버가 넣은 글 그대로일 때 — 손대면 남는다). 그 링크의 파일을 `largeFiles` 로 돌려준다 → 쓰기 화면은 대용량 목록으로 보이고, 다시 보내면 새 링크다.