docs: 7단계(이미 받은 메일에 필터 적용) 계약과 디자인

필터를 저장할 때 받은편지함에 이미 있는 메일에도 그 필터 하나를 적용한다. 엔진의 Sieve 는 받을 때만
돌고 엔진 검색은 낱말 단위라, 서버가 받은편지함을 읽어 Sieve 와 같은 규칙으로 판정한다.

- 계약 2절 "이미 받은 메일에 적용"(POST /api/mail-filter-runs): 판정 규칙은 실엔진 배달로 맞췄다 —
  HTML 은 닫는 p · br 만 줄바꿈, style · script · head · template 은 글째 빼고, 속성이 붙은 태그는
  엔진처럼 알아보지 않는다. 머리글은 접힌 줄을 띄어쓰기 하나로, 인코딩된 글은 홀로 선 것만 푼다
- 시작한 때 받은편지함에 있던 메일만 보고(위 경계), 마지막으로 남긴 메일이 빠지면 그 초부터 다시 읽는다.
  본문 값은 한 통 1MiB 까지 — 바깥 메일 한 통으로 서버가 메모리를 크게 쓰지 않게
- 부재중 응답에 isActive(엔진에서 우리 규칙이 꺼졌는지), 엔진의 임시 업로드 한도, 바깥 주소 자동 전달은
  허용하지 않는다는 정책(2026-10-29 결정)
- 디자인 캔버스 확정본 MailFilterApply · MailFilterApplyStates 와 README "7단계"(구현하며 정한 보완 포함)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-29 08:03:42 +09:00
parent 188b9e84f4
commit 5ddc849c11
5 changed files with 896 additions and 11 deletions
+95 -7
View File
@@ -208,10 +208,11 @@ HTML 은 정화하기 **전에** 길이(HTML 부분이 여럿이면 이어 붙
| `DELETE /api/mailboxes/{id}` | — | 200 `MailboxDeletion` — 안의 메일을 휴지통으로 옮긴 뒤 지운다 | 400 `VAL_001`(기본 메일함 · 하위 메일함 있음), 404 `RES_001`, 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`, 429 `REQ_001`(같은 메일함의 처리가 진행 중), 503 `MAIL_001` |
| `GET /api/mail-vacation` | — | 200 `MailVacation` (아래 "부재중 자동 응답") | 503 `MAIL_001` |
| `PUT /api/mail-vacation` | `MailVacation` (전체 교체) | 200 `MailVacation`(저장한 값) | 400 `VAL_001` · `MAIL_013`(필터와 함께 만든 엔진 규칙이 한도를 넘음 — 6단계), 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` | 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` |
@@ -408,6 +409,10 @@ interface RejectedRecipient {
- 메일 한 통의 첨부 합계(전달하는 원본 첨부 포함, 같은 첨부를 두 번 붙이면 두 번 센다) `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
- 다운로드는 `<a href>` 이동으로 받는다 — 같은 출처라 쿠키가 함께 간다.
이동은 axios 를 거치지 않아 401 재시도 규칙이 적용되지 않으므로, **이동하기 전에 세션을 확인**한다
@@ -681,6 +686,10 @@ interface MailVacation {
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자)
@@ -696,8 +705,9 @@ interface MailVacation {
서식 본문은 없다(글자만). 저장은 전체 교체이고 응답은 저장한 값이다. 꺼도 쓴 제목 · 내용은 남겨 둔다.
필터와 한 스크립트라서 부재중을 저장해도 스크립트를 다시 켠다(필터가 꺼져 있었으면 함께 다시 동작한다). 필터 규칙은 건드리지 않는다.
- 6단계부터 바뀐 동작: 엔진이 스팸으로 분류한 메일에는 답하지 않는다(필터와 같은 스팸 막기 — "메일 필터"의 "만드는 Sieve").
- 우리 스크립트가 엔진에서 꺼져 있으면(필터의 `isActive: false`) 부재중도 동작하지 않는다. 이 API 의 `isEnabled` 는 저장한 값이라 그때도 `true` 로 온다
(알려진 한계 — 드묾. 화면은 필터 화면의 줄로만 알린다).
- 우리 스크립트가 엔진에서 꺼져 있으면(`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 로 보낸다.
@@ -832,7 +842,7 @@ interface MailFilterActions {
전달은 한 통에 한 곳 — 엔진 한도(사용자 스크립트의 리다이렉트 1)라 두 번째 전달은 엔진이 조용히 버린다(2026-10-28 확인). 사본은 늘 남긴다(`redirect :copy`).
**스팸으로 판정된 메일에는 아무 규칙도 적용하지 않는다**(부재중 응답도 하지 않는다) — 엔진은 규칙의 옮기기가 스팸메일함 분류를 이기므로(같은 날 확인) 막는다.
판정은 엔진의 분류 그대로다(지금 엔진 설정은 5점부터 스팸 — 아래 "만드는 Sieve"의 스팸 막기).
꺼 둔 규칙은 목록에만 있고 적용하지 않는다. 이미 받은 메일에는 적용하지 않는다.
꺼 둔 규칙은 목록에만 있고 적용하지 않는다. 이미 받은 메일에는 저절로 적용하지 않는다 — 저장할 때 고르면 아래 "이미 받은 메일에 적용"(7단계).
- **저장 자리** — 엔진의 Sieve 스크립트 하나(`arasystem`)에 필터와 **부재중 자동 응답을 함께** 둔다. Stalwart 0.16.27 은 계정마다 켜진 스크립트가 하나뿐이고
엔진의 VacationResponse 도 스크립트(`vacation`)라서, 부재중을 켜면 필터가 꺼지고 필터를 켜면 부재중이 꺼진다(같은 날 확인).
- 4단계 `/api/mail-vacation` 은 계약 그대로 두고 저장 자리만 이 스크립트로 옮긴다 — 엔진의 VacationResponse 는 더 쓰지 않는다.
@@ -878,8 +888,86 @@ interface MailFilterActions {
- 검사는 서버와 같은 규칙(이름 50 · 값 200 · 조건 5 · 규칙 50 · `${` · 제어 문자 · 할 일 하나 이상 · 전달은 사내 주소이고 자기 주소 아님) — 칸 아래에만 알린다.
전달 주소의 앞부분은 서버(`IsEmail`)보다 좁게 본다(RFC 5322 dot-atom · 64자) — 화면이 통과시킨 주소를 서버가 거절하지 않게.
규칙이 50개면 [새 필터] · 「이런 메일로 필터 만들기」를 끄고 까닭을 보인다. 지운 메일함을 가리키는 규칙은 목록에 빨간 안내.
- 이 단계에서 하지 않는 것: 이미 받은 메일에 적용, **바깥 주소로 전달**(엔진에 SRS 가 없어 Gmail 등이 DMARC 로 거절할 수 있고, 회사 메일이 개인 메일로 빠지는 통로다 —
필요해지면 정책과 함께), 크기 · 첨부 · 날짜 조건, 정규식, 맞는 메일 미리 보기, mailcow(SOGo)의 필터 옮겨 오기(운영 전환 때 — imapsync 로는 옮겨지지 않는다).
- 이 단계에서 하지 않는 것: **바깥 주소로 전달** — 전달한 메일은 보낸 사람(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=?…?=` · `=?…?=<a@b>` · `(=?…?=)` 는 그대로. 공백만 사이에 둔 인코딩된 글끼리는 붙인다),
앞뒤 공백을 뺀 것 — 따옴표 · 꺾쇠까지 견준다(`"Kim, Ara" <kim@…>` 에 `"Kim` 이 맞는다 — 엔진과 같다). 받는 사람: To · Cc 머리글 각각(하나라도 맞으면). 제목: 풀어 쓴 제목.
- 본문: 텍스트 · HTML 부분과 **텍스트 첨부**(text/* — 파일로 붙인 것도. 엔진이 그렇게 본다). 한 부분은 앞 256KiB 까지, 한 통은 모두 합쳐 1MiB 까지 본다
(글자 부분이 많아 넘으면 부분마다 같은 몫만큼 앞쪽만 — 바깥에서 온 메일 한 통으로 서버가 메모리를 크게 쓰지 않게). HTML 은 글자 부분만 — 엔진과 같게:
- 태그는 `<` 부터 다음 `>` 까지다 — `<` 뒤가 무엇이든 태그이고(`a < b` · `<광고>` 도), 따옴표 안의 `>` 에서도 끝난다. 닫히지 않은 `<` 는 끝까지 삼킨다.
태그는 속성까지 **빈자리 없이** 뺀다(`회<b>의</b>록` → "회의록", `div` · `li` · `h1` · 표 칸 · 표 줄 · `hr` · 여는 `p` 사이도 이어 붙는다). 주석(`<!-- … -->`)은 안의 글까지 뺀다
- `</p>`(닫는 것만) · `<br>`(`<br/>` · `<br />` · `</br>` · 대문자도)은 줄바꿈이다 — 찾을 글(줄바꿈이 없다)은 이 자리를 건너 맞지 않는다(`<p>앞</p><p>뒤</p>` 에 "앞뒤" · "앞 뒤" 모두 안 맞음)
- `<style>` · `<script>` · `<head>` · `<template>` 은 안의 글까지 뺀다(`<title>` · `<noscript>` 등은 글이 남는다). 그 안의 `</p>` · `<br>` 도 줄바꿈이다
- **태그 이름 뒤에 속성이 붙으면 엔진은 그 태그를 알아보지 못한다** — `<br clear="all">` 은 줄바꿈이 아니고 `<style type="text/css">` · `<head lang="ko">` 는 안의 글이 남는다. 서버도 그렇게 본다
- 원문의 이어진 공백(띄어쓰기 · 탭 · 줄바꿈)은 태그를 건너서도 띄어쓰기 하나. 문자 참조를 푼다(이름 · 10진 · 16진 — `;` 로 끝나는 것만, `&nbsp;` 는 U+00A0) —
푼 글자는 줄이지 않고 그대로 넣는다(`&#32;&#32;` 는 띄어쓰기 둘, `&#10;` 은 줄바꿈)
- 대소문자는 ASCII 만 무시한다. "포함하지 않음"은 그 머리글 · 본문 부분이 없을 때도 참이다(엔진과 같다).
- 확인(2026-10-29): 실배달 20통 — 본문 14가지(HTML 만 · 속성 · style · 문자 참조 · 대안 두 쪽 · 텍스트 첨부 · 주석 · script · 인라인 부분 · QP · EUC-KR · `&nbsp;` · 블록 사이)와
머리글 6가지(따옴표 이름 · 받는 사람 여럿 · 인코딩된 참조 · 인코딩된 제목 · EUC-KR 제목), 인증 발송 25통 — HTML 글자 뽑기(인라인 태그 · 원문 줄바꿈 · 공백 겹침 ·
p · br · div · li · h1 · 표 · blockquote · hr · title · 숫자 참조 · 대문자 태그 등), 인증 발송 69통 — 속성이 붙은 태그 · 글을 빼는 태그(head · template 등) ·
글자 그대로의 `<` · 따옴표 안 `>` · 참조로 쓴 공백 · 머리글의 접힌 줄 · 인코딩된 글의 경계에서 엔진과 같은 판정이 되게 맞췄다(스팸으로 분류된 1통은 판정 불가).
- **알려진 한계**(드묾): 첨부한 메일(.eml) 안, 256KiB · 1MiB 를 넘는 뒤쪽, HTML5 에만 있는 이름 참조(`&period;` · 대문자 `&AMP;` 등 — 엔진은 푼다)는 엔진과 다를 수 있다.
- 스팸 판정 표시는 보지 않는다 — 받은편지함에 있는 메일은 사용자가 이미 본 메일이다("스팸 아님"으로 되돌린 메일 포함).
- 할 일: 읽음 · 중요 표시를 붙이고, 옮기기는 받은편지함에서 빼고 그 메일함에 넣는다(맞은 메일을 차례마다 엔진 요청 한 번으로). 확인하는 사이 받은편지함에서 빠진 메일은 건너뛴다.
옮길 메일함이 없거나(지워짐) 받은편지함이면 옮기지 않고 표시만 한다. 영구 삭제는 하지 않는다(휴지통으로 옮기는 규칙도 옮기기다).
- 적용을 시작한 때 받은편지함에 있던 메일만 본다 — 첫 요청이 받은편지함의 가장 최근 받은 시각을 위 경계로 잡고(cursor 에 담는다) 그 뒤에 온 메일은 보지 않는다
(새로 온 메일은 Sieve 가 규칙 차례대로 이미 판정했다 — 한 규칙으로 다시 판정하면 위 규칙의 "멈춤"을 건너뛴다).
- `cursor` 는 서버가 만든 글이다(위 경계 · 마지막으로 받은편지함에 남긴 메일과 그 받은 시각 · 규칙의 지문). 그 메일이 그사이 빠졌으면(다른 탭에서 옮김 등)
그 받은 시각의 메일부터 다시 읽는다 — 건너뛰지 않는다(그 초의 메일을 다시 판정해 맞은 수를 두 번 셀 수는 있다). 엔진은 받은 시각이 같은 메일을 늘 같은 차례로 준다(만든 차례 — 2026-10-29 확인).
다른 규칙의 cursor · 깨진 cursor 는 400 `VAL_001`.
- 엔진 한도: `Email/query` 는 한 번에 5,000통까지(`queryMaxResults`)라 차례마다 나눠 읽는다. 측정(개발 엔진, 2026-10-29, 시험 메일 5,000통): 보낸 사람 조건으로 4,500통을 확인하고
450통을 옮기는 데 1.3초, 본문 2,000통 읽기 1초 안팎 — 받은편지함 수만 통도 머리글 조건이면 한 요청, 본문 조건이면 몇 요청이다.
- 로그 — 규칙의 글은 남기지 않는다(필터와 같다). 확인 · 적용 수 · 차례 수 · 걸린 시간만.
- **클라이언트**: 고치기 창의 「받은편지함에 이미 있는 메일에도 적용」 — 창을 열 때마다 꺼져 있다(필터의 설정이 아니다). `PUT /api/mail-filters` 가 성공한 뒤 저장한 그 규칙
(응답 목록의 같은 id — 앞뒤 공백을 지운 값)으로 이 요청을 `cursor` 가 null 이 될 때까지 보낸다.
- 칸을 끈다(칸 아래 설명이 까닭을 말한다): 이 탭에서 다른 적용이 진행 중, 그리고 **이미 받은 메일에 할 일이 없는 필터** — 읽음 · 중요 표시가 없고
옮기기도 없거나 받은편지함으로 · 지워진 메일함으로 옮기는 것뿐(까닭 문구는 지워진 메일함 → 받은편지함 → 전달만 차례로 고른다). 서버는 그런 규칙도 받는다(맞은 수만 센다).
- 진행 알림 "‘{이름}’ 필터를 받은편지함에 적용하는 중 · {확인} / {전체}통 확인"(확인 = scanned 합, 전체 = 확인 + remaining) → 끝나면 그 자리에서
"‘{이름}’ 필터를 받은편지함의 메일 N통에 적용했습니다."(N = matched 합) + 옮기는 필터이고 1통 이상이면 [{메일함} 보기], 0통이면 "받은편지함에 ‘{이름}’ 필터에 맞는 메일이 없습니다."
- 멈춤: "‘{이름}’ 필터 — {전체}통 가운데 {확인}통까지 확인하고 멈췄습니다(N통에 적용). 다시 시도해 주세요." + [다시 시도](마지막 cursor 로 이어서).
처음 요청부터 실패하면 "필터는 저장했지만 받은편지함에 적용하지 못했습니다. 다시 시도해 주세요." + [다시 시도](처음부터).
429 는 "다른 창에서 필터를 받은편지함에 적용하는 중입니다. 끝난 뒤 다시 시도해 주세요." — 도중의 429 면 [다시 시도](이어서)도 붙인다.
확인 0 인데 `cursor` 가 있는 응답이 세 번 이어지면 멈춘다(같은 요청을 끝없이 보내지 않게 — 멈춤 문구와 같다).
- 그동안 다른 화면으로 가도 이어 한다. 탭을 닫으면 멈춘다(이어 하지 않는다). 세션이 바뀌면(로그아웃 · 만료 · 다른 계정) 다음 요청을 보내지 않고 알림을 닫는다.
- **고치기 창의 나가기 확인**(6단계 한계를 고친다): 창을 연 뒤 바꾼 것이 있으면 [취소] · Esc · 다른 화면으로 가기에 확인 — "저장하지 않은 필터가 있습니다" /
"창을 닫으면 고친 내용이 사라집니다." [계속 고치기](처음 초점 · Esc) [닫기]. 다른 화면으로 갈 때는 "이 화면을 나가면 고친 내용이 사라집니다." [나가기].
탭 닫기 · 새로고침은 브라우저 확인창. 읽기 화면에서 채워 연 새 필터는 채운 그대로면 묻지 않는다.
### 이 단계에서 하지 않는 것