diff --git a/.claude/rules/env-structure.md b/.claude/rules/env-structure.md
index 32ffd9b..b4fe872 100644
--- a/.claude/rules/env-structure.md
+++ b/.claude/rules/env-structure.md
@@ -24,10 +24,12 @@ description: 환경변수 단일 진실 공급원(SSOT) 구조 — 루트 .env.{
├─▶ redis : REDIS_PASSWORD, REDIS_DATA_DIR 볼륨
├─▶ uploads : 운영은 named volume _uploads (env 없음), 개발만 UPLOAD_DATA_DIR 바인드 마운트
├─▶ mail : MAIL_PUBLIC_URL·MAIL_RECOVERY_ADMIN → STALWART_* (backend 에는 MAIL_JMAP_URL·MAIL_DOMAIN·MAIL_GATEWAY_*·MAIL_ATTACHMENT_*·
- │ MAIL_ADMIN_LOCAL_PART·MAIL_BLOCKED_ACCOUNTS)
+ │ MAIL_ADMIN_LOCAL_PART·MAIL_BLOCKED_ACCOUNTS·MAIL_DIRECTORY_LOCAL_PART)
│ 운영의 MAIL_RECOVERY_ADMIN 은 파일에 두지 않고 셸 일회성 override 로만 준다 (mail/README.md "운영 배포")
│ MAIL_ADMIN_LOCAL_PART 는 setup.mjs 와 backend 가 함께 읽는다 — bootstrap 전에만 정할 수 있다
+ │ MAIL_DIRECTORY_LOCAL_PART(사내 목록 계정, 기본 mail-directory)도 함께 읽는다 — 계정은 setup 이 복구 관리자로만 만든다(운영 --recovery)
└─▶ mail/scripts/setup.mjs : `node --env-file=.env.{APP_ENV}` 로 같은 파일을 직접 읽는다 (mail/README.md)
+ MAIL_ATTACHMENT_*(첨부 한도)도 읽는다 — 엔진의 임시 업로드 한도를 그 20배로 맞추므로 첨부 한도를 바꾸면 setup 도 다시 돌린다
```
규칙:
@@ -70,7 +72,7 @@ description: 환경변수 단일 진실 공급원(SSOT) 구조 — 루트 .env.{
> 루트 env의 대표 키: `PROJECT_NAME`, `APP_ENV`, `BUILD_MODE`, 각종 `*_PORT`, `APP_NETWORK_SUBNET`·`EDGE_NETWORK_SUBNET`, `BACKEND_API_URL`, `CORS_ORIGIN`, `TRUST_PROXY`,
> `DB_*`/`DB_DATA_DIR`, `REDIS_*`/`REDIS_DATA_DIR`, `THROTTLE_TTL_MS`/`THROTTLE_LIMIT`, `UPLOAD_MAX_SIZE_MB`(·개발 전용 `UPLOAD_DATA_DIR`),
> 메일 `MAIL_IMAGE`(패치까지 고정)·`MAIL_DOMAIN`·`MAIL_HOSTNAME`·`MAIL_PUBLIC_URL`·`MAIL_ADMIN_LOCAL_PART`(관리자 아이디, 기본 `admin` — setup·backend 공용, bootstrap 전에만)·
-> `MAIL_ADMIN_PASSWORD`·`MAIL_RECOVERY_ADMIN`(운영은 일회성 override)·`MAIL_GATEWAY_USER`/`MAIL_GATEWAY_PASSWORD`·
+> `MAIL_ADMIN_PASSWORD`·`MAIL_RECOVERY_ADMIN`(운영은 일회성 override)·`MAIL_GATEWAY_USER`/`MAIL_GATEWAY_PASSWORD`·`MAIL_DIRECTORY_LOCAL_PART`(사내 목록 계정 아이디, 기본 `mail-directory` — setup·backend 공용)·
> `MAIL_JMAP_URL`·`MAIL_ATTACHMENT_MAX_SIZE_MB`/`MAIL_ATTACHMENT_TOTAL_MAX_SIZE_MB`·`MAIL_TRUSTED_NETWORKS`(비우면 `APP_NETWORK_SUBNET`, 그보다 넓으면 setup 실패)·
> `MAIL_BLOCKED_ACCOUNTS`(선택 — 관리자 외에 웹메일 로그인·대리 접근을 막을 주소, 콤마. 비어 있어도 관리자는 항상 막힌다)
> (개발 전용 `MAIL_ADMIN_PORT`·`MAIL_DEV_USER_PASSWORD`·`MAILPIT_IMAGE`/`MAILPIT_UI_PORT`). 실제 목록은 `.env.development.example` 참조.
diff --git a/.claude/skills/docker/SKILL.md b/.claude/skills/docker/SKILL.md
index 5446603..8a4b88c 100644
--- a/.claude/skills/docker/SKILL.md
+++ b/.claude/skills/docker/SKILL.md
@@ -122,6 +122,8 @@ sudo docker compose --env-file .env.production logs -f backend
**다른 프로젝트와 함께 쓰면 주소 목록을 권장한다** — `,<외부 프록시 IP>` (예: `172.16.72.0/24,172.20.0.10`).
홉 수는 "오른쪽에서 몇 번째 주소"만 믿으므로, 같은 네트워크의 다른 컨테이너가 외부 프록시를 건너뛰고 frontend 에 바로 붙으면
`X-Forwarded-For` 를 꾸며 IP 를 바꿀 수 있다. 외부 프록시 IP 는 frontend 가 보는 주소(proxy 네트워크 안)이며 고정해 둔다(`ipv4_address` 등).
+ - **요청 수 한도 `THROTTLE_LIMIT`** (운영 확인 항목 — 기본 1분 100회, `THROTTLE_TTL_MS`): 백엔드는 **경로마다 · 클라이언트 IP 마다** 센다 — 사무실이 NAT 하나로 나가면 모든 직원이 경로마다 한 칸을 나눠 쓴다.
+ "직원 수 × 한 사람의 평소 분당 요청(가장 많이 부르는 경로 기준 — 예: 주소록 목록 `GET /api/contacts`)"보다 넉넉히 정한다. 넘으면 그 경로가 429 `REQ_001` 이다.
- **본문 한도는 `MAIL_ATTACHMENT_MAX_SIZE_MB` + 1MB 이상**, 읽기 시간 제한은 150s 이상. 작으면 백엔드에 닿기 전에 413 이 나고
사용자에게는 `FILE_002` 로 보인다 (프론트 nginx 는 자동으로 맞지만 외부 프록시는 직접 맞춘다).
- **실시간 알림 `/api/mail-events` 를 흘려보낸다** (운영 확인 항목, 계약 2절 "실시간 알림"):
@@ -294,16 +296,21 @@ node --env-file=.env.development mail/scripts/setup.mjs
- 백엔드는 `MAIL_JMAP_URL`(http://mail:8080)·`MAIL_DOMAIN`·`MAIL_GATEWAY_USER`·`MAIL_GATEWAY_PASSWORD`·`MAIL_ATTACHMENT_MAX_SIZE_MB`(기본 20)·
`MAIL_ATTACHMENT_TOTAL_MAX_SIZE_MB`(기본 25) 를 compose 로 받는다.
- 데이터는 named volume `_mail-config`·`_mail-data` (UID 2000 소유가 필요해 바인드 마운트를 쓰지 않는다).
-- setup 이 잠그는 것: 공개 443 의 JMAP·관리 API·웹 포털(→ 403, MTA-STS 정책 파일만 연다), 직원 역할의 계정 목록(Principal) 조회.
+- setup 이 잠그는 것: 공개 443 의 JMAP·관리 API·웹 포털(→ 403, MTA-STS 정책 파일만 연다), 직원 역할의 계정 목록(Principal) 조회,
+ 메일을 받지 않는 시스템 계정(관리자·게이트웨이·사내 목록)으로 오는 메일(→ RCPT 에서 550 — 받아 놓고 위조된 보낸 사람에게 반송하지 않게, mail/README.md "보안").
- setup 은 엔진의 받는 쪽 한도 "같은 보낸 도메인 → 한 받는 사람"(기본 시간당 25)을 **인증 없는 세션에만** 건다 — 사내 메일은 보낸 도메인이 같아 동료 모두 합쳐
한 사람에게 25통을 넘으면 웹메일 발송이 거절됐다(2026-10-28). 보낸 IP 마다 초당 연결 5개 한도는 그대로. 한도 값 · 직원마다 내보내는 양의 한도는 운영 전에 정한다 (mail/README.md "엔진 한도").
신뢰 대역은 고정한 app-network 서브넷(`APP_NETWORK_SUBNET`) 안으로만 받는다 — 더 넓으면 실패한다.
+- setup 은 엔진의 임시 업로드 크기 합계(`x:Jmap` 의 `uploadQuota` — 계정마다 1시간 동안 올린 양, 기본 50MB)를 첨부 합계 한도(`MAIL_ATTACHMENT_TOTAL_MAX_SIZE_MB`, 백엔드와 같은 계산)의
+ 20배(기본 524,288,000바이트) 이상으로 올린다 — 기본값이면 20MB 첨부 세 개로 그 직원의 첨부 올리기 · 필터 · 부재중 저장이 1시간까지 429 `REQ_001` 로 막혔다(2026-10-29).
+ 더 크게 둔 값은 내리지 않고, 개수(1,000) · 기간 · 한 번 크기는 그대로다. 첨부 한도 env 를 바꾸면 setup 도 다시 돌린다 (mail/README.md "엔진 한도").
- setup 은 **바꾸기 전에** 앱 네트워크 서브넷·구성원(frontend 등이 붙어 있지 않은지)·백엔드 주소를 확인하고, 어긋나면 아무것도 바꾸지 않고 멈춘다.
**운영은 docker 를 쓸 수 있는 사용자로 호스트에서 돌린다** (docker 그룹 또는 `sudo -E`) — docker 가 없으면 바로 실패한다.
- 운영의 복구 관리자(`MAIL_RECOVERY_ADMIN`)는 `.env.production` 에 두지 않고 셸 일회성 override + `setup.mjs --recovery` 로만 쓴다
(파일에 있거나, 끝난 뒤 mail 컨테이너에 남아 있으면 setup 이 실패한다 — 절차는 mail/README.md "운영 배포").
- 영구 관리자 아이디는 `MAIL_ADMIN_LOCAL_PART`(기본 `admin`) — setup 과 백엔드가 함께 읽는다 (compose 가 backend 에 넘긴다).
**bootstrap 전에만 정할 수 있다** — 바꾼 값이 엔진의 관리자와 다르면 setup 이 멈춘다. 다른 관리자 계정은 `MAIL_BLOCKED_ACCOUNTS` 에 넣는다.
+- 사내 목록 계정 `@도메인`(기본 `mail-directory`, compose 가 backend 에 넘긴다) — 백엔드가 엔진의 계정 목록에서 이름·주소만 읽는 비밀번호 없는 계정(게이트웨이 대리 접근으로만). 엔진 관리자는 그 권한을 내줄 수 없어 setup 이 **복구 관리자로 돌 때만** 만든다(개발은 늘, 운영은 `--recovery` — 이미 운영 중이면 한 번 더). 없으면 `[주의]` 만 내고 백엔드는 로그인한 직원 기록으로 대신한다 (mail/README.md "사내 목록 계정").
- ⚠️ **게이트웨이 비밀번호 = 관리자급 비밀.** Stalwart 0.16 은 대리 접근 대상을 제한하지 못해, 신뢰 대역 안에서는 게이트웨이로 관리자 계정도
대리 접근된다 (setup 이 매번 `[주의]` 로 보여 준다). 막는 일은 백엔드가 한다 — mail/README.md "보안".
diff --git a/.claude/skills/nestjs/SKILL.md b/.claude/skills/nestjs/SKILL.md
index f93e47e..6f219c3 100644
--- a/.claude/skills/nestjs/SKILL.md
+++ b/.claude/skills/nestjs/SKILL.md
@@ -32,9 +32,10 @@ description: NestJS 백엔드(backend/) 개발 규칙 — 컨트롤러/서비스
| 길이 검사 데코레이터 | `src/common/decorators/code-units.decorator.ts` | `MaxCodeUnits` · `MinCodeUnits` — JS 문자열 길이(UTF-16 코드 단위)로 센다(웹의 `.length` 와 같다). class-validator 의 `MinLength`·`MaxLength` 는 서로게이트 쌍 · 이모지를 덜 세어 클라이언트가 센 길이와 어긋난다 — 클라이언트와 같은 한도를 거는 문자열은 이것을 쓴다 |
| 공백 · 중복 데코레이터 | `src/common/decorators/trim-string.decorator.ts` · `array-distinct.decorator.ts` | `TrimString()` — 앞뒤 공백을 지운 값으로 검사하고 그 값으로 저장한다(메일함 이름 · 필터의 이름 · 찾을 글 · 전달 주소). `ArrayDistinct(options)` — 배열 항목이 겹치지 않는지 Set 으로 한 번 훑는다(선형 — 원시 값은 값으로, 객체는 같은 객체일 때만. 객체 배열의 id 중복은 서비스가 Set 으로 본다 — 예: 필터 규칙 `findFilterRulesProblem`). class-validator 의 `ArrayUnique` 는 `indexOf` 비교라 항목 수의 제곱이다 — **쓰지 않는다** |
| 직원 인증 | `modules/auth` | 쿠키 JWT 로그인·`me`·`refresh`·`logout` (계약 `docs/mail/api-contract.md` 1절) · 비밀번호 변경 `PUT /api/auth/password`(4단계 — 직원 본인 자격으로 엔진의 `x:AccountPassword/set`, 로그인과 같은 실패 카운터 · 진행 중 잠금, 바뀌면 이 요청의 세션만 남기고 그 직원의 세션을 끊는다 — 결과를 모르는 실패에도 끊는다). 설정 `auth.settings.ts`, 쿠키 `auth-cookies.ts`, 로그인 실패 횟수 `login-attempt.service.ts`, **로그인 세션(DB)** `auth-session.entity.ts`·`auth-session.policy.ts`(1회용 회전·재사용 감지)·`auth-session.cleanup.ts`(끝난 세션 정리 cron). **메일 엔진 관리자 차단** — 정적 목록(`employee-address.ts` + `MAIL_BLOCKED_ACCOUNTS`)과 로그인·리프레시 때 엔진 권한 확인(`jmap/jmap.client.ts`) 두 겹 |
-| 메일 (웹메일 API) | `modules/mail` | 엔진 포트 `mail.provider.ts`(`MAIL_PROVIDER`) + Stalwart JMAP 어댑터 `jmap/`, 받은 메일 HTML 정화 `html/sanitize-mail-html.ts`, 보내는 서식 본문 정화(허용 목록) `html/sanitize-outgoing-html.ts`, 두 정화 앞의 사전 검사(길이·파서 스택 비용, 선형 시간) `html/html-parse-budget.ts`, 첨부 형식 `attachment.policy.ts`, 설정 `mail.settings.ts`, 발송 중복 방지(`Idempotency-Key`) `send-idempotency.ts`, 발송의 받는 사람별 결과 `jmap/delivery-status.ts`(제출 다음에 따로 읽는다 — 일부 거절은 201 의 `rejectedRecipients`, 모두 거절이면 보낸 사본을 지우고 `MAIL_010` · `MAIL_011`, 계약 2절 "받는 사람별 결과"), 첨부 임시 디스크 저장 `mail-upload-storage.ts`, 임시 저장 `draft.controller.ts`(엔진 초안 — 새로 만들고 다시 읽은 뒤 옛것을 지운다), 서명 그림 주소 규칙 `html/signature-images.ts`(보낼 때 `cid:` 로), 본문 그림 판정 `html/inline-images.ts`, 받는 사람 추천 `recipient-suggestions.service.ts`(사용자별 10분 캐시) · 순서 `recipient-ranking.ts` · 커서 `recipient-suggestion-cursor.ts`, 검색어 → 엔진 조건 `jmap/search-terms.ts`(한국어 조사 표는 여기 한 곳 — 낱말 안 OR · 낱말끼리 AND, 따옴표 구절), 여러 통 `PATCH /api/messages`(엔진 요청 한 번) · 휴지통에서 영구 삭제 `message-deletion.controller.ts`(휴지통의 메일만, 조건부 지우기) — 본문 `dto/bulk-messages.dto.ts`, 새 메일 확인 `GET /api/mail-state`(`mailbox.controller.ts`), 메일함 관리 · 전체 읽음 · 비우기(`mailbox.controller.ts`, 본문 `dto/mailbox.dto.ts` — 오래 걸리는 처리는 어댑터(`BulkRun`)가 500통씩 · 한 요청 25초 안에서(엔진 호출마다 남은 시간만큼) 하고 남은 수를 돌려준다, 같은 직원 · 같은 메일함은 한 번에 하나(`mail.service.ts` 의 `mailboxOperations` — 진행 중이면 `REQ_001`), 지우기는 안의 메일을 휴지통으로 옮긴 뒤) · 부재중 자동 응답 `MailVacationController`(`dto/mail-vacation.dto.ts` — 6단계부터 저장 자리는 아래 필터의 Sieve 스크립트) · 메일 필터 `mail-filter.controller.ts`(6단계 — `GET`·`PUT /api/mail-filters`, 본문 `dto/mail-filter.dto.ts`, 칸 사이 규칙 `mail-filters.ts`. 엔진 쪽은 Sieve 스크립트 하나(`arasystem` — 필터와 **부재중을 함께**, 엔진의 VacationResponse 는 쓰지 않고 처음 읽을 때만 이어받는다): 저장소 `jmap/jmap-filter-script.ts`(읽은 상태 그대로일 때만 쓰고 켠다 · 그사이 바뀌면 다시 읽기 · 같은 규칙이면 옛 version 이어도 저장), 글 만들기 `jmap/sieve-script.ts`, 머리 주석의 원본(JSON · 판 `FILTER_SOURCE_FORMAT`) `jmap/filter-source.ts`, 부재중 제목 · 본문 인코딩 `jmap/mime-text.ts`, JMAP 결과 읽기 `jmap/jmap-results.ts`) · 원문 `GET /api/messages/{id}/source`(이름 `attachment.policy.ts` 의 `messageSourceFileName`) · 스팸 표시 `isJunk` (계약 2절) · 실시간 알림 `GET /api/mail-events`(5단계 — SSE, `mail-events.controller.ts` · 엔진을 모르는 `mail-events.service.ts`(직원마다 연결 5개 · 25초 ping · 토큰 만료 30초 전 `end` · 종료 때 `end`) · 엔진 쪽 `jmap/jmap-mail-watch.ts`(직원마다 엔진 EventSource 하나 · 75초 무소식이면 다시 잇기) · `jmap/jmap-push.ts`(스트림 읽기) · `jmap/new-mail.ts`(새 메일 거르기), SSE 형식 `shared/utils/sse.ts`. 머리글은 스트림을 열기로 정하면 바로(`flushHeaders`), 처리기에 닿기 전에 끊긴 응답(`res.destroyed`)도 정리한다) |
+| 메일 (웹메일 API) | `modules/mail` | 엔진 포트 `mail.provider.ts`(`MAIL_PROVIDER`) + Stalwart JMAP 어댑터 `jmap/`, 받은 메일 HTML 정화 `html/sanitize-mail-html.ts`, 보내는 서식 본문 정화(허용 목록) `html/sanitize-outgoing-html.ts`, 두 정화 앞의 사전 검사(길이·파서 스택 비용, 선형 시간) `html/html-parse-budget.ts`, 첨부 형식 `attachment.policy.ts`, 설정 `mail.settings.ts`, 발송 중복 방지(`Idempotency-Key`) `send-idempotency.ts`, 발송의 받는 사람별 결과 `jmap/delivery-status.ts`(제출 다음에 따로 읽는다 — 일부 거절은 201 의 `rejectedRecipients`, 모두 거절이면 보낸 사본을 지우고 `MAIL_010` · `MAIL_011`, 계약 2절 "받는 사람별 결과"), 첨부 임시 디스크 저장 `mail-upload-storage.ts`, 임시 저장 `draft.controller.ts`(엔진 초안 — 새로 만들고 다시 읽은 뒤 옛것을 지운다), 서명 그림 주소 규칙 `html/signature-images.ts`(보낼 때 `cid:` 로), 본문 그림 판정 `html/inline-images.ts`, 받는 사람 추천 `recipient-suggestions.service.ts`(사용자별 10분 캐시) · 순서 `recipient-ranking.ts` · 커서 `recipient-suggestion-cursor.ts` · q 맞추기 `recipient-match.ts`(8단계 — **frontend `shared/utils/recipient-match.ts` 와 짝**, 같은 표본 표를 두 쪽 테스트에 둔다 — 한쪽을 고치면 둘 다. 이름은 코드 포인트로 한 번 훑는다 — 길이에 비례), 검색어 → 엔진 조건 `jmap/search-terms.ts`(한국어 조사 표는 여기 한 곳 — 낱말 안 OR · 낱말끼리 AND, 따옴표 구절), 여러 통 `PATCH /api/messages`(엔진 요청 한 번) · 휴지통에서 영구 삭제 `message-deletion.controller.ts`(휴지통의 메일만, 조건부 지우기) — 본문 `dto/bulk-messages.dto.ts`, 새 메일 확인 `GET /api/mail-state`(`mailbox.controller.ts`), 메일함 관리 · 전체 읽음 · 비우기(`mailbox.controller.ts`, 본문 `dto/mailbox.dto.ts` — 오래 걸리는 처리는 어댑터(`BulkRun` — `jmap/bulk-run.ts`)가 500통씩 · 한 요청 25초 안에서(엔진 호출마다 남은 시간만큼) 하고 남은 수를 돌려준다, 같은 직원 · 같은 메일함은 한 번에 하나(`mail.service.ts` 의 `mailboxOperations` — 진행 중이면 `REQ_001`), 지우기는 안의 메일을 휴지통으로 옮긴 뒤) · 부재중 자동 응답 `MailVacationController`(`dto/mail-vacation.dto.ts` — 6단계부터 저장 자리는 아래 필터의 Sieve 스크립트. 응답의 `isActive`(7단계)는 엔진에서 그 스크립트가 켜져 있는지 — 본문으로는 받지 않는다) · 메일 필터 `mail-filter.controller.ts`(6단계 — `GET`·`PUT /api/mail-filters`, 본문 `dto/mail-filter.dto.ts`, 칸 사이 규칙 `mail-filters.ts`. 엔진 쪽은 Sieve 스크립트 하나(`arasystem` — 필터와 **부재중을 함께**, 엔진의 VacationResponse 는 쓰지 않고 처음 읽을 때만 이어받는다): 저장소 `jmap/jmap-filter-script.ts`(읽은 상태 그대로일 때만 쓰고 켠다 · 그사이 바뀌면 다시 읽기 · 같은 규칙이면 옛 version 이어도 저장), 글 만들기 `jmap/sieve-script.ts`, 머리 주석의 원본(JSON · 판 `FILTER_SOURCE_FORMAT`) `jmap/filter-source.ts`, 부재중 제목 · 본문 인코딩 `jmap/mime-text.ts`, JMAP 결과 읽기 `jmap/jmap-results.ts`) · 이미 받은 메일에 필터 적용 `POST /api/mail-filter-runs`(7단계 — `mail-filter.controller.ts`, 본문 `dto/mail-filter-run.dto.ts`. 저장한 필터가 아니라 본문의 규칙으로 받은편지함을 최근 메일부터 판정하고 표시 · 옮기기만 한다(전달 · 지우기 없음). 판정 `mail-filter-match.ts` 는 **`jmap/sieve-script.ts` 의 `FIELD_TESTS` 와 짝**이다 — 엔진 검색은 낱말 단위라 쓰지 않고 Sieve 의 판정을 그대로 옮겼다(한쪽을 바꾸면 다른 쪽도). 견줄 글 `jmap/filter-run-texts.ts`(원래 머리글 `header:From:all` 등 — `:asRaw` 는 붙이지 않는다(엔진이 응답의 이름에서 뗀다), 풀기 `jmap/mime-text.ts` 의 `decodeHeaderText` — 접힌 줄은 띄어쓰기 하나, 인코딩된 글은 홀로 선 것만), HTML 글자 `html/html-text.ts`(엔진의 `body :text` 와 같게 — 닫는 `p` · `br` 만 줄을 끊고 `style` · `script` · `head` · `template` 은 글째 빼며 그 밖의 태그는 빈자리 없이 뺀다. 속성이 붙은 태그는 엔진처럼 알아보지 않는다 — 2026-10-29 인증 발송 네 묶음으로 맞춤, 규칙은 계약 2절), 어댑터 `jmap/jmap-filter-run.ts`(머리글 500통 · 본문 100통 차례, 본문 값은 부분마다 256KiB · 한 통 1MiB — 바깥 메일 한 통이 메모리를 크게 쓰지 않게), 이어 할 자리 `jmap/filter-run-cursor.ts`(2판 — 위 경계 · anchor · 그 받은 시각 · 규칙 지문. 모든 조회에 `before` 를 걸어 시작한 뒤 온 메일은 보지 않고, 빠진 anchor 는 그 초부터 다시 읽는다). 한 요청의 시간은 메일함 처리와 같은 `BulkRun`, 같은 직원은 한 번에 하나(`mail.service.ts` 의 `filterRuns` — 진행 중이면 `REQ_001`)) · 원문 `GET /api/messages/{id}/source`(이름 `attachment.policy.ts` 의 `messageSourceFileName`) · 스팸 표시 `isJunk` (계약 2절) · 실시간 알림 `GET /api/mail-events`(5단계 — SSE, `mail-events.controller.ts` · 엔진을 모르는 `mail-events.service.ts`(직원마다 연결 5개 · 25초 ping · 토큰 만료 30초 전 `end` · 종료 때 `end`) · 엔진 쪽 `jmap/jmap-mail-watch.ts`(직원마다 엔진 EventSource 하나 · 75초 무소식이면 다시 잇기) · `jmap/jmap-push.ts`(스트림 읽기) · `jmap/new-mail.ts`(새 메일 거르기), SSE 형식 `shared/utils/sse.ts`. 머리글은 스트림을 열기로 정하면 바로(`flushHeaders`), 처리기에 닿기 전에 끊긴 응답(`res.destroyed`)도 정리한다) |
| 메일 서명 | `modules/mail-signature` | 사용자마다 서명 하나(DB) + 서명 그림. 그림 형식은 내용으로 판별(매직 바이트·머리의 가로·세로, 새 의존성 없음) `signature-image-format.ts`, 파일은 업로드 볼륨의 `mail-signatures/` (`signature-image-storage.ts`), 서명에 붙지 않은 그림 정리 cron `mail-signature.cleanup.ts`. 메일 모듈이 발송·임시 저장 때 `MailSignatureService` 로 그림을 쓴다 (계약 2절 "서명") |
-| 사내 목록 | `modules/mail-directory` | 웹메일 로그인에 성공한 직원(관리자·게이트웨이·막은 계정 제외)을 `AuthService` 가 기록 → 받는 사람 추천의 "사내". 엔진의 계정 목록(Principal) 권한은 쓰지 않는다(일부러 막아 두었다) |
+| 사내 목록(로그인 기록) | `modules/mail-directory` | 웹메일 로그인에 성공한 직원(관리자·게이트웨이·막은 계정 제외)을 `AuthService` 가 기록한다 — 8단계부터는 엔진의 사내 목록(아래 주소록)을 **읽지 못할 때만** 받는 사람 추천의 "사내"를 대신한다. 직원 역할에는 엔진의 계정 목록(Principal) 권한이 없다(일부러 막아 두었다) |
+| 주소록 (8단계) | `modules/contacts` | 내 주소록(연락처 · 그룹)과 사내 목록 — 계약 3절. 컨트롤러 `contacts.controller.ts` · `contact-groups.controller.ts` · `directory-entries.controller.ts`, 서비스 `contacts.service.ts`(직원마다 주소록 캐시 `contact-book-cache.ts` — 엔진 state 로 확인(`ContactCard/get` 빈 ids, 3ms, 판은 읽기 전에 물은 것), 200명 · 모두 2,400만 · 한 사람 800만 · 30분, 같은 직원의 바꾸기는 차례로(`writeQueues`), 엔진에는 `ifInState` — 어긋남 · `serverUnavailable` 은 다시 읽어 세 번까지, 넘으면 `MAIL_001`. 쓰기의 크기 한도 800만(`MAX_ADDRESS_BOOK_WEIGHT` — 캐시의 한 사람 상한과 같은 값, 넘게 커지는 쓰기는 `CONTACT_002`). 고치기는 `PATCH`(보낸 칸만 검사) — 칸 안의 남의 속성은 어댑터 `contact-card.ts` 의 `contactPatch` 가 지금 카드와 합쳐 지킨다. 되돌리기 표 `contact-restore-store.ts`(지우기 직전에 읽은 카드 전체 — 5분 · 직원마다 20개 · 256KB · 모두 32MB) + `contact-restorations.controller.ts`) · `directory.service.ts`(사내 목록 10분 캐시, 거르기 `directory-policy.ts` — 로그인 막기와 같은 `isEmployeeAddress` + 설명의 `[setup.mjs]`). 순수 함수 `address-book.ts` · `contact-search.ts`(초성 · 겹자음 · 전화 숫자 · NFC) · `contact-paging.ts`(위치 커서 — `shared/utils/offset-cursor.ts`, 받는 사람 추천 커서도 이것), 경로 id `contact-id.pipe.ts`, 빈 글 → null `TrimStringOrNull()`. 포트 `contacts.provider.ts`(`CONTACTS_PROVIDER` · `DIRECTORY_PROVIDER`) — 엔진 어댑터는 `modules/mail/jmap/` 의 `jmap-contacts.provider.ts` · `contact-card.ts`(JSContact ↔ 도메인) · `jmap-directory.provider.ts`(`JmapClient.callDirectory()` — **사내 목록 계정만** 대리 접근). 엔진 교체는 `contacts.module.ts` 의 useClass 만. 모듈 순환을 막으려고 `MAIL_SETTINGS` · `JmapClient` 는 `modules/mail/mail-engine.module.ts` 에 둔다(메일 → 주소록 방향만, 인증 모듈은 이것을 가져온다). 사내 목록 계정(`MAIL_DIRECTORY_LOCAL_PART`, 기본 `mail-directory`)은 setup 이 복구 관리자로만 만든다 — `mail.settings.ts` 의 막는 목록에도 들어 있다 |
| Rate Limiting | `app.module.ts` 의 `ClientIpThrottlerGuard`(APP_GUARD, `common/guards/client-ip-throttler.guard.ts`) | 초과 시 429 → `REQ_001`. 루트 `.env` 의 `THROTTLE_*` 로 조정. `ThrottlerGuard` 를 상속해 tracker 만 바꿨다 — IP 는 `shared/utils/client-ip.ts` 의 `clientIpKey()`(IPv6 는 /64 로 묶음, 로그인 실패 카운터와 같은 함수) |
| 로깅 | `shared/logger/winston.config.ts` | `console.log` 대신 사용 ([[logging]]) |
| env 검증 | `shared/config/env.validation.ts` | 잘못된 설정이면 **기동 실패**(fail-fast) ([[env-structure]]) |
@@ -231,9 +232,13 @@ SwaggerModule.setup('api-docs', app, document);
> 로그인 실패 카운터(`modules/auth/fixed-window-counter.ts` → `INCR`+`PEXPIRE` 를 한 Lua 스크립트로),
> 같은 주소의 진행 중 로그인·비밀번호 변경·같은 `Idempotency-Key` 의 진행 중 발송(`SET NX PX` 잠금), `@nestjs/throttler` 저장소,
> 같은 직원 · 같은 메일함의 오래 걸리는 처리(`mail.service.ts` 의 `mailboxOperations` — 그대로면 두 인스턴스가 같은 휴지통을 함께 비우며 서로 어긋난다 → 잠금),
+ > 같은 직원의 이미 받은 메일에 필터 적용(`mail.service.ts` 의 `filterRuns` — 그대로면 두 인스턴스가 같은 받은편지함을 함께 읽으며 서로 옮긴 메일 때문에 자리가 어긋난다 → 잠금),
> 사용자별 서명 그림 올리기 차례(`mail-signature.service.ts` 의 `uploadQueues` — 그대로면 동시 업로드가 보관 상한을 넘을 수 있다 → 잠금),
> 받는 사람 추천 캐시(`recipient-suggestions.service.ts` — 틀리지는 않지만 보낸 직후 다른 인스턴스가 10분까지 옛 목록을 준다 → 공유 캐시나 무효화 알림),
- > 실시간 알림의 직원당 연결 5개(`mail-events.service.ts` — 기능은 맞지만 인스턴스마다 따로 세고, 엔진 연결도 인스턴스마다 하나씩 생긴다 → 필요하면 공유 셈):
+ > 실시간 알림의 직원당 연결 5개(`mail-events.service.ts` — 기능은 맞지만 인스턴스마다 따로 세고, 엔진 연결도 인스턴스마다 하나씩 생긴다 → 필요하면 공유 셈),
+ > 주소록의 직원마다 캐시 · 바꾸기 차례 · 사내 목록 10분 캐시(`contacts.service.ts` · `directory.service.ts` — 캐시는 엔진 state 로 확인하고 쓰기는 `ifInState` 라 틀리지는 않는다.
+ > 인스턴스마다 따로 읽어 엔진 읽기가 늘고, 같은 직원의 동시 바꾸기가 어긋나 다시 하는 일이 잦아질 뿐 → 필요하면 잠금 · 공유 캐시),
+ > 주소록의 되돌리기 표(`contact-restore-store.ts` — 그대로면 다른 인스턴스로 간 [되돌리기]가 404 `RES_001` 이다 → 공유 저장소에 5분짜리 항목으로):
> 0. 컨테이너 활성화 — 루트 `.env` 에 `COMPOSE_PROFILES=redis` ([[docker]])
> 1. Redis store 패키지 설치 (**컨테이너 안에서** — 위 의존성 주의 항목 참조)
> 2. `app.module.ts` 의 `CacheModule.register` 를 `registerAsync` + Redis store 로 교체
@@ -352,6 +357,10 @@ dc exec backend npm uninstall <패키지>
- [ ] **목록 API**: 페이지네이션 적용, 응답은 `{items, pageInfo}`.
- [ ] **DTO**: 모든 입력 DTO 에 `class-validator` + 한국어 메시지 + `@ApiProperty()`. 배열 중복은 `ArrayDistinct`(`ArrayUnique` 금지 — 항목 수의 제곱).
DTO spec 은 전역 파이프 옵션에서 `stopAtFirstError` 만 빼고 검사한다(걸린 규칙을 모두 보려고 — 통과 · 실패 판정은 같다).
+ **검사 차례**: class-validator 는 그냥 쌓은 데코레이터를 **아래 것부터** 돌린다 — 그래서 8단계 DTO 는 칸의 검사를 `applyDecorators(형식, 길이 · 개수, 모양)` 으로 묶어 **적은 차례대로** 돌게 한다
+ (`modules/contacts/dto/contact.dto.ts`). 길이 · 개수가 비싼 검사(정규식 · `IsEmail`)보다 먼저 돌아 `stopAtFirstError` 가 긴 입력을 거기서 멈춘다.
+ 정규식은 입력 길이에 비례하게 쓴다 — 같은 글자를 나눠 가질 수 있는 `*` 둘(`^[a]*[0-9][a]*$` 꼴)은 제곱이다(2026-10-29 리뷰: 전화번호 정규식이 1MB 본문 하나로 24분).
+ 차례 · 비용을 보는 테스트는 `stopAtFirstError` 를 빼지 않고 전역 옵션 그대로(`createValidationPipe()`) 돌린다.
- [ ] **Swagger**: 새 엔드포인트에 `@ApiTags`/`@ApiOperation` + `@ApiStandardResponse`/`@ApiErrorResponse`.
- [ ] **경로 alias**: `@/` 사용 (`tsconfig.json` paths + jest `moduleNameMapper` 설정 완료). 깊은 상대경로 금지.
- [ ] **lint/test**: 커밋 전 `npm run lint` (`--max-warnings 0`) + `npm run test` 통과. 자동 수정은 `npm run lint:fix`.
@@ -377,9 +386,17 @@ dc exec backend npm uninstall <패키지>
같은 요청에서 만들기와 지우기를 함께 하지 않는다 — 엔진은 만들기가 실패해도 지우기를 한다.
**메일함 지우기(`Mailbox/set destroy`)는 `onDestroyRemoveEmails: false` 만 쓴다** — `true` 는 안의 메일을 영구 삭제한다. 안의 메일은 먼저 휴지통으로 옮기고,
그사이 들어온 메일이 있으면 엔진이 `mailboxHasEmail` 로 거절하므로 다시 옮긴다 (2026-10-28 확인).
+ **주소록 카드**(8단계)는 만들 때 `uid`(`urn:uuid:…`)를 넣는다 — 엔진이 만든 뒤에는 바꾸지 못하게 하고, 없으면 그룹(`members` 는 uid 로 잇는다)에 넣을 수 없다.
+ 고치기는 우리가 다루는 칸만 패치하고, 그 칸 안에서도 남의 속성(label · 다른 contexts · fax 같은 features · pref)을 지킨다 — 같은 주소 · 번호로 짝지어 합친다(`contact-card.ts` 의 `contactPatch`).
+ 그룹 구성원은 `members` 를 통째로 쓴다(빈 그룹은 `members/` 패치를 받지 않는다). 되돌리기는 지울 때 읽은 카드 전체(`properties: null`)에서 id 만 빼고 다시 만든다(같은 주소록의 같은 uid 는 엔진이 거절 — 새 uid).
+ **한 요청의 `destroy` 에 같은 id 를 두 번 넣지 않는다** — 엔진이 요청 전체를 `serverUnavailable`(`store.assert-value-failed`)로 거절하고 다시 해도 같다(2026-10-29 확인).
+ 사내 목록은 `callDirectory()` 로만 읽는다 — 그 길로 다른 계정을 대리 접근하지 않는다. 연락처 내용(이름 · 주소 · 전화 · 메모)은 로그에 남기지 않는다.
**Sieve 스크립트**(6단계 — 필터 · 부재중)는 `jmap/sieve-script.ts` 한 곳에서 만든다: 사용자 글은 `quote()` 만 거친다(`\` · `"` 이스케이프), `${` 는 받지 않는다
(엔진이 require 와 상관없이 변수로 펼친다 — `%{…}` 는 펼치지 않는다), 부재중 제목은 RFC 2047 · 본문은 `vacation :mime` base64, 만든 글이 90,000바이트를 넘으면 `MAIL_013`.
**스팸 판정은 엔진의 분류 표시**(`X-Spam-Score` 가 `spam` 으로 시작)로 한다 — 점수 문턱을 따로 두지 않는다(`spamtest` 는 점수를 정수로 내린 값이라 10 으로 막다가 5~10점 스팸을 놓쳤다, 2026-10-28 실엔진).
날짜 비교는 `currentdate :zone "+0000"`. 엔진 오류의 설명 문장은 로그에 남기지 않는다(줄 · 칸만 — 만난 문자열에 규칙의 글이 들어 있다).
원본 판(`FILTER_SOURCE_FORMAT`)을 올린 배포를 되돌리면 새 판으로 저장한 직원의 필터 · 부재중 저장이 503 `MAIL_001` 로 막힌다(옛 서버가 새 판을 지우지 않게) — 판을 올릴 때는 되돌리기 계획을 함께 둔다.
+ **이미 받은 메일에 필터 적용**(7단계)은 서버가 엔진 Sieve 의 판정을 따라 한다 — 필터의 칸 · 견줄 곳 · 비교 방법을 바꾸면 `sieve-script.ts` 의 `FIELD_TESTS` ·
+ `mail-filter-match.ts` · `jmap/filter-run-texts.ts`(· HTML 은 `html/html-text.ts`)를 함께 고치고 실배달로 다시 맞춘다(엔진의 규칙은 계약 2절 "이미 받은 메일에 적용").
+ 메일을 지우지 않는다 — `Email/set update` 만 쓴다(옮기기는 `ifInState` — 어긋나면 다시 확인, 표시는 조건 없이).
- [ ] **env**: DB/Redis/CORS/포트는 루트 `.env` 에서 주입된다. 백엔드 고유 시크릿(`JWT_SECRET` 등)만 `backend/.env.{env}` 에 작성(compose `env_file` 로 로드).
diff --git a/.claude/skills/vue3/SKILL.md b/.claude/skills/vue3/SKILL.md
index edca093..12db125 100644
--- a/.claude/skills/vue3/SKILL.md
+++ b/.claude/skills/vue3/SKILL.md
@@ -32,19 +32,19 @@ description: Vue 3 프론트엔드(frontend/) 개발 규칙 — Composition API
| 전역 셸 | `src/components/shell/AppShell.vue` | 앱 레일 + 상단 바 + 모듈 영역. 레일 모듈 목록은 `src/shared/constants/app-modules.ts` 하나 |
| 디자인 토큰·글꼴 | `src/styles/tokens.css` · `src/styles/base.css` | 색·간격·글자 크기 변수. 글꼴은 npm `pretendard` 자체 호스팅(`main.ts` 에서 import — CSP `font-src 'self'`) |
| 브랜드 자산 | `src/assets/brand/` · `src/shared/constants/brand.ts` | 흰 로고(전체·ARA 마크). 원본은 `docs/brand/` |
-| 토스트 | `src/stores/toast.store.ts` · `src/components/feedback/ToastHost.vue` | 오류는 `useApi.ts` 의 `showErrorUI()`, 성공 안내는 `composables/useToast.ts`(API 가 아닌 화면 쪽 실패 — 예: 브라우저가 알림을 막음 · 보냈지만 받는 사람 일부가 받지 못함(+ [보낸편지함 보기], 문장은 `shared/utils/mail-send-result.ts`) — 는 `useToast().error()`). 알림 안의 단추는 `{ label, run }`(예: 임시 저장본 [되돌리기]), 이동 링크는 지금처럼. 단추가 있는 알림은 10초(`TOAST_ACTION_DURATION_MS`), 마우스 · 초점이 있는 동안은 `hold`·`release` 로 멈춘다. **단추는 띄운 세션에서만 움직인다** — `useToast` 가 `authStore.generation` 에 묶고, 로그아웃 · 세션 만료 · 다른 계정 로그인 때 단추가 있는 알림을 닫는다(메일 id 는 계정마다 겹칠 수 있다). 오래 걸리는 처리는 **진행 알림** `useToast().progress(글)` — 저절로 닫히지 않고(넘쳐도 다른 알림부터 닫는다) 글을 고치다가(`update`) 끝나면 그 자리에서 결과가 된다(`succeed` · `fail` — `toast.store` 의 `update`). 진행 알림도 세션이 바뀌면 닫는다 |
-| 모달 대화상자 | `src/components/feedback/ModalDialog.vue` | 네이티브 `
뒤')).toBe('앞\n뒤');
+ for (const br of ['
', '
', '
', '', '
', '< br >']) {
+ expect(htmlToText(`앞${br}뒤`)).toBe('앞\n뒤');
+ }
+ });
+
+ it('태그 이름은 앞의 공백 · / 와 끝의 공백 · / 를 뺀 나머지 전부 — 속성이 붙으면 알아보지 않는다', () => {
+ // 속성이 붙은 br · p 는 줄을 끊지 않는다
+ expect(htmlToText('앞
뒤')).toBe('앞뒤');
+ expect(htmlToText('앞뒤')).toBe('앞뒤');
+ // o:p 는 p 가 아니다
+ expect(htmlToText('앞뒤')).toBe('앞뒤');
+ // 속성이 붙은 style · script · head · template 은 글을 빼지 않는다
+ expect(htmlToText('뒤')).toBe('a{}뒤');
+ expect(htmlToText('제목')).toBe(
+ '제목',
+ );
+ expect(htmlToText('글')).toBe('글');
+ });
+
+ it('뒤')).toBe('뒤');
+ expect(htmlToText('보임')).toBe('보임');
+ expect(htmlToText('보임')).toBe('보임');
+ expect(htmlToText('제목본문
')).toBe(
+ '본문\n',
+ );
+ expect(htmlToText('숨김보임')).toBe('보임');
+ // 넷을 따로 센다 — 다른 이름의 닫는 태그로는 끝나지 않는다
+ expect(htmlToText('머리아직끝')).toBe('끝');
+ });
+
+ it('글을 빼는 동안에도 태그는 본다 — 그 안의 ·
도 줄바꿈이다', () => {
+ expect(htmlToText('앞뒤')).toBe('앞\n뒤');
+ expect(htmlToText('앞뒤')).toBe('앞\n뒤');
+ });
+
+ it('title · noscript · textarea · iframe · object · svg · xml 은 글이 남는다', () => {
+ for (const name of [
+ 'title',
+ 'noscript',
+ 'textarea',
+ 'iframe',
+ 'object',
+ 'svg',
+ 'xml',
+ ]) {
+ expect(htmlToText(`<${name}>글${name}>`)).toBe('글');
+ }
+ });
+
+ it('태그는 < 부터 다음 > 까지 — < 뒤가 무엇이든 태그이고, 따옴표는 보지 않는다', () => {
+ // `<광고>` 는 태그로 빠지고, 맨 앞이 된 공백은 넣지 않는다
+ expect(htmlToText('<광고> 안내
')).toBe('안내\n');
+ expect(htmlToText('
사진')).toBe('b">사진');
+ expect(htmlToText('z')).toBe(
+ '링크 회의 안내 z',
+ );
+ expect(htmlToText('<>글')).toBe('글');
+ });
+
+ it('태그 안에서 < 가 또 나오면 거기서 태그를 다시 시작한다 — 닫히지 않은 < 는 끝까지 삼킨다', () => {
+ // `< 1000원할인` 은 버려지고 `` 가 태그가 된다
+ expect(htmlToText('가격 < 1000원할인
뒤')).toBe('가격\n뒤');
+ expect(htmlToText('앞 < 뒤')).toBe('앞');
+ expect(htmlToText('앞b')).toBe('a\nb');
+ });
+
+ it(' 는 안의 글까지 뺀다 — 닫히지 않으면 끝까지. 그 밖의 (DOCTYPE · mso 조건)는 보통 태그다', () => {
+ expect(htmlToText('보임')).toBe('보임');
+ expect(htmlToText('보임')).toBe('보임');
+ expect(htmlToText('보임')).toBe('보임');
+ expect(htmlToText('글
')).toBe('글\n');
+ expect(htmlToText('앞` 까지 주석이다(안의 글까지 뺀다). 그 밖의 ``(DOCTYPE · ``)는 보통 태그다
+ * - 태그의 이름 = 태그 글에서 앞의 공백 · `/` 와 끝의 공백 · `/` 를 뺀 **나머지 전부** — ASCII 대소문자 없이 견준다.
+ * 닫는 태그 = 첫 공백 아닌 글자가 `/`. 속성이 붙으면 이름이 아니다 — `
` · `` · `` 는 알아본다
+ * - 알아보는 이름: `br`(여는 것 · 닫는 것) → 줄바꿈, `p` 의 닫는 것만 → 줄바꿈(여는 `` · `
` 는 아무것도 아니다),
+ * `style` · `script` · `head` · `template` → 여는 것부터 같은 이름의 닫는 것까지 글을 뺀다(넷을 따로 센다 — 짝을 맞추지 않는다).
+ * `title` · `noscript` · `textarea` · `iframe` · `object` · `svg` · `xml` 은 글이 남는다. 글을 빼는 동안에도 태그는 본다(그 안의 `` · `
` 도 줄바꿈)
+ * - 그 밖의 태그는 빈자리 없이 뺀다 — 낱말 중간의 인라인 태그(`회의`) · div · li · 표 칸도 이어 붙는다
+ * - 글의 이어진 공백(띄어쓰기 · 탭 · CR · LF — 그 밖의 제어 문자 · U+00A0 · U+3000 은 글자다)은 띄어쓰기 하나다 — 태그를 건너서도 하나(`회의 안내` → "회의 안내")
+ * - 문자 참조는 `;` 로 끝나는 것만 푼다(` ` 는 그대로) — 이름(HTML 4 의 이름 · `'`) · 10진 · 16진. 푼 글자는 공백 줄이기에 넣지 않고 그대로 넣는다:
+ * ` ` → 띄어쓰기 둘, `
` → 줄바꿈, `회의 안내` → "회의 안내"(앞의 글 공백을 먼저 넣는다), ` ` → U+00A0.
+ * HTML5 에만 있는 이름(`.` · 대문자 `&` 등 — 엔진은 푼다)은 글자 그대로 둔다(계약의 알려진 한계)
+ * - 줄바꿈(태그 · 참조가 만든 것)은 찾을 글에 없는 글자라(제어 문자를 받지 않는다) 그 자리를 건너 맞는 글이 없다 — 엔진과 같다
+ *
+ * 입력을 한 번 훑는다 — 길이에 비례한다(태그를 다시 시작해도 앞으로만 간다). 정규식 · htmlparser2(sanitize-html)는 쓰지 않는다 —
+ * htmlparser2 는 중첩 깊이의 제곱으로 느려진다 (html-parse-budget.ts). 태그의 짝도 보지 않는다(깊이를 세지 않는다).
+ */
+
+const LESS_THAN = 0x3c;
+const GREATER_THAN = 0x3e;
+const SLASH = 0x2f;
+const AMPERSAND = 0x26;
+const HASH = 0x23;
+const SEMICOLON = 0x3b;
+
+/** 태그 · 참조가 만든 줄바꿈 — 공백 줄이기에 들어가지 않는다 */
+const LINE_BREAK = '\n';
+
+/** 주석의 처음 · 끝 */
+const COMMENT_OPEN = '';
+
+/** 안의 글을 빼는 태그 — 소문자 (넷을 따로 센다) */
+const HIDING_TAGS = ['style', 'script', 'head', 'template'] as const;
+type HidingTag = (typeof HIDING_TAGS)[number];
+
+/** 알아보는 이름 가운데 가장 긴 것(template)의 길이 — 이보다 긴 태그 글은 이름을 견주지 않는다 */
+const LONGEST_KNOWN_TAG = 8;
+
+/** 문자 참조의 이름 · 숫자를 읽는 최대 길이 — 넘으면 참조가 아니라 글자로 본다 (`&` 마다 앞으로 조금만 본다 — 길이에 비례) */
+const MAX_REFERENCE_NAME_LENGTH = 32;
+const MAX_REFERENCE_DIGITS = 8;
+
+/** 유니코드의 끝 · 대체 문자 */
+const MAX_CODE_POINT = 0x10ffff;
+const REPLACEMENT_CHARACTER = '�';
+
+/** U+00A0 ~ U+00FF 의 이름 — HTML 4 의 Latin-1 문자 참조, 차례대로 */
+const LATIN1_REFERENCE_NAMES = [
+ 'nbsp iexcl cent pound curren yen brvbar sect uml copy ordf laquo not shy reg macr',
+ 'deg plusmn sup2 sup3 acute micro para middot cedil sup1 ordm raquo frac14 frac12 frac34 iquest',
+ 'Agrave Aacute Acirc Atilde Auml Aring AElig Ccedil Egrave Eacute Ecirc Euml Igrave Iacute Icirc Iuml',
+ 'ETH Ntilde Ograve Oacute Ocirc Otilde Ouml times Oslash Ugrave Uacute Ucirc Uuml Yacute THORN szlig',
+ 'agrave aacute acirc atilde auml aring aelig ccedil egrave eacute ecirc euml igrave iacute icirc iuml',
+ 'eth ntilde ograve oacute ocirc otilde ouml divide oslash ugrave uacute ucirc uuml yacute thorn yuml',
+]
+ .join(' ')
+ .split(' ');
+const LATIN1_REFERENCE_START = 0xa0;
+
+/** 그리스 문자 — 대문자 U+0391 ~ U+03A9(U+03A2 는 없다), 소문자 U+03B1 ~ U+03C9 (HTML 4) */
+const GREEK_UPPER_NAMES = [
+ 'Alpha Beta Gamma Delta Epsilon Zeta Eta Theta Iota Kappa Lambda Mu Nu Xi Omicron Pi Rho',
+ '- Sigma Tau Upsilon Phi Chi Psi Omega',
+]
+ .join(' ')
+ .split(' ');
+const GREEK_LOWER_NAMES = [
+ 'alpha beta gamma delta epsilon zeta eta theta iota kappa lambda mu nu xi omicron pi rho',
+ 'sigmaf sigma tau upsilon phi chi psi omega',
+]
+ .join(' ')
+ .split(' ');
+const GREEK_UPPER_START = 0x391;
+const GREEK_LOWER_START = 0x3b1;
+
+/** 그 밖의 이름 — HTML 4 의 특수 · 기호 문자 참조와 `'` (HTML 5 · XML) */
+const OTHER_REFERENCES: readonly (readonly [string, number])[] = [
+ ['quot', 0x22],
+ ['amp', 0x26],
+ ['apos', 0x27],
+ ['lt', 0x3c],
+ ['gt', 0x3e],
+ ['OElig', 0x152],
+ ['oelig', 0x153],
+ ['Scaron', 0x160],
+ ['scaron', 0x161],
+ ['Yuml', 0x178],
+ ['fnof', 0x192],
+ ['circ', 0x2c6],
+ ['tilde', 0x2dc],
+ ['thetasym', 0x3d1],
+ ['upsih', 0x3d2],
+ ['piv', 0x3d6],
+ ['ensp', 0x2002],
+ ['emsp', 0x2003],
+ ['thinsp', 0x2009],
+ ['zwnj', 0x200c],
+ ['zwj', 0x200d],
+ ['lrm', 0x200e],
+ ['rlm', 0x200f],
+ ['ndash', 0x2013],
+ ['mdash', 0x2014],
+ ['lsquo', 0x2018],
+ ['rsquo', 0x2019],
+ ['sbquo', 0x201a],
+ ['ldquo', 0x201c],
+ ['rdquo', 0x201d],
+ ['bdquo', 0x201e],
+ ['dagger', 0x2020],
+ ['Dagger', 0x2021],
+ ['bull', 0x2022],
+ ['hellip', 0x2026],
+ ['permil', 0x2030],
+ ['prime', 0x2032],
+ ['Prime', 0x2033],
+ ['lsaquo', 0x2039],
+ ['rsaquo', 0x203a],
+ ['oline', 0x203e],
+ ['frasl', 0x2044],
+ ['euro', 0x20ac],
+ ['image', 0x2111],
+ ['weierp', 0x2118],
+ ['real', 0x211c],
+ ['trade', 0x2122],
+ ['alefsym', 0x2135],
+ ['larr', 0x2190],
+ ['uarr', 0x2191],
+ ['rarr', 0x2192],
+ ['darr', 0x2193],
+ ['harr', 0x2194],
+ ['crarr', 0x21b5],
+ ['lArr', 0x21d0],
+ ['uArr', 0x21d1],
+ ['rArr', 0x21d2],
+ ['dArr', 0x21d3],
+ ['hArr', 0x21d4],
+ ['forall', 0x2200],
+ ['part', 0x2202],
+ ['exist', 0x2203],
+ ['empty', 0x2205],
+ ['nabla', 0x2207],
+ ['isin', 0x2208],
+ ['notin', 0x2209],
+ ['ni', 0x220b],
+ ['prod', 0x220f],
+ ['sum', 0x2211],
+ ['minus', 0x2212],
+ ['lowast', 0x2217],
+ ['radic', 0x221a],
+ ['prop', 0x221d],
+ ['infin', 0x221e],
+ ['ang', 0x2220],
+ ['and', 0x2227],
+ ['or', 0x2228],
+ ['cap', 0x2229],
+ ['cup', 0x222a],
+ ['int', 0x222b],
+ ['there4', 0x2234],
+ ['sim', 0x223c],
+ ['cong', 0x2245],
+ ['asymp', 0x2248],
+ ['ne', 0x2260],
+ ['equiv', 0x2261],
+ ['le', 0x2264],
+ ['ge', 0x2265],
+ ['sub', 0x2282],
+ ['sup', 0x2283],
+ ['nsub', 0x2284],
+ ['sube', 0x2286],
+ ['supe', 0x2287],
+ ['oplus', 0x2295],
+ ['otimes', 0x2297],
+ ['perp', 0x22a5],
+ ['sdot', 0x22c5],
+ ['lceil', 0x2308],
+ ['rceil', 0x2309],
+ ['lfloor', 0x230a],
+ ['rfloor', 0x230b],
+ // HTML 5 는 꺾쇠를 U+27E8 · U+27E9 로 바꿨다 (HTML 4 는 U+2329 · U+232A)
+ ['lang', 0x27e8],
+ ['rang', 0x27e9],
+ ['loz', 0x25ca],
+ ['spades', 0x2660],
+ ['clubs', 0x2663],
+ ['hearts', 0x2665],
+ ['diams', 0x2666],
+];
+
+/** 이름 → 글자 (대소문자를 가린다 — `Ä` 과 `ä` 은 다르다) */
+const NAMED_REFERENCES: ReadonlyMap = (() => {
+ const named = new Map();
+ const addRun = (names: readonly string[], start: number) =>
+ names.forEach((name, offset) => {
+ // '-' 는 빈 자리 (그 번호에는 이름이 없다)
+ if (name !== '-') named.set(name, String.fromCodePoint(start + offset));
+ });
+ addRun(LATIN1_REFERENCE_NAMES, LATIN1_REFERENCE_START);
+ addRun(GREEK_UPPER_NAMES, GREEK_UPPER_START);
+ addRun(GREEK_LOWER_NAMES, GREEK_LOWER_START);
+ for (const [name, codePoint] of OTHER_REFERENCES) {
+ named.set(name, String.fromCodePoint(codePoint));
+ }
+ return named;
+})();
+
+/** 이름 있는 문자 참조의 수 — 테스트가 표를 빠뜨리지 않았는지 본다 (HTML 4 의 252개 + apos) */
+export const NAMED_REFERENCE_COUNT = NAMED_REFERENCES.size;
+
+/**
+ * 뽑은 글 — 글의 이어진 공백은 하나로 모았다가 다음 글자 앞에 띄어쓰기 하나로 넣고(맨 앞이면 넣지 않는다),
+ * 푼 참조 · 태그가 만든 줄바꿈은 줄이지 않고 그대로 넣는다
+ */
+class TextSink {
+ private readonly pieces: string[] = [];
+ /** 글의 공백을 만났다 — 다음 글자 앞에 띄어쓰기 하나 */
+ private pendingSpace = false;
+
+ space(): void {
+ this.pendingSpace = true;
+ }
+
+ /** 글자 · 푼 참조 — 모아 둔 공백을 먼저 넣는다 */
+ text(value: string): void {
+ if (this.pendingSpace && this.pieces.length > 0) this.pieces.push(' ');
+ this.pendingSpace = false;
+ this.pieces.push(value);
+ }
+
+ /** `
` · `` 의 줄바꿈 — 모아 둔 공백은 버린다 (줄바꿈을 건너 맞는 글은 없다) */
+ lineBreak(): void {
+ this.pendingSpace = false;
+ this.pieces.push(LINE_BREAK);
+ }
+
+ toString(): string {
+ return this.pieces.join('');
+ }
+}
+
+/** 안의 글을 빼는 태그 가운데 열려 있는 것 — 넷을 따로 둔다(짝 · 깊이를 세지 않는다 — 여는 것은 켜고 닫는 것은 끈다) */
+class HidingTags {
+ private readonly open = new Set();
+
+ get active(): boolean {
+ return this.open.size > 0;
+ }
+
+ toggle(tag: HidingTag, opening: boolean): void {
+ if (opening) this.open.add(tag);
+ else this.open.delete(tag);
+ }
+}
+
+/** HTML 의 글자 부분 — 위 머리말의 규칙 */
+export function htmlToText(html: string): string {
+ const sink = new TextSink();
+ const hiding = new HidingTags();
+ let index = 0;
+ while (index < html.length) {
+ const code = html.charCodeAt(index);
+ if (code === LESS_THAN) {
+ index = readMarkup(html, index, sink, hiding);
+ continue;
+ }
+ if (isHtmlSpace(code)) {
+ if (!hiding.active) sink.space();
+ index += 1;
+ continue;
+ }
+ if (code === AMPERSAND) {
+ const reference = readReference(html, index);
+ if (reference !== null) {
+ if (!hiding.active) sink.text(reference.value);
+ index = reference.end;
+ continue;
+ }
+ }
+ // 글자 — 다음 `<` · `&` · 공백 앞까지 한 번에 (풀지 못한 `&` 는 글자다)
+ const end = textRunEnd(html, index + 1);
+ if (!hiding.active) sink.text(html.slice(index, end));
+ index = end;
+ }
+ return sink.toString();
+}
+
+/** 글자가 이어지는 끝 — `<` · `&` · 공백 앞 */
+function textRunEnd(html: string, from: number): number {
+ let index = from;
+ while (index < html.length) {
+ const code = html.charCodeAt(index);
+ if (code === LESS_THAN || code === AMPERSAND || isHtmlSpace(code)) break;
+ index += 1;
+ }
+ return index;
+}
+
+/**
+ * `<` 에서 시작하는 표시를 읽고 그 다음 자리를 돌려준다 — 주석이면 `-->` 까지, 아니면 다음 `>` 까지의 태그.
+ * 태그 안에서 `<` 가 또 나오면 거기서 다시 시작한다(앞의 것은 버린다). 닫히지 않으면 html.length — 나머지를 모두 삼킨다
+ */
+function readMarkup(
+ html: string,
+ open: number,
+ sink: TextSink,
+ hiding: HidingTags,
+): number {
+ let start = open;
+ for (;;) {
+ if (html.startsWith(COMMENT_OPEN, start)) {
+ const close = html.indexOf(COMMENT_CLOSE, start + COMMENT_OPEN.length);
+ return close === -1 ? html.length : close + COMMENT_CLOSE.length;
+ }
+ let index = start + 1;
+ while (index < html.length) {
+ const code = html.charCodeAt(index);
+ if (code === GREATER_THAN || code === LESS_THAN) break;
+ index += 1;
+ }
+ if (index >= html.length) return html.length;
+ if (html.charCodeAt(index) === LESS_THAN) {
+ start = index;
+ continue;
+ }
+ applyTag(html, start + 1, index, sink, hiding);
+ return index + 1;
+ }
+}
+
+/**
+ * 태그 글(`<` 와 `>` 사이)의 할 일 — 이름은 앞의 공백 · `/` 와 끝의 공백 · `/` 를 뺀 나머지 전부다(속성이 붙으면 알아보지 않는다).
+ * 닫는 태그는 첫 공백 아닌 글자가 `/` 인 것. br → 줄바꿈 · 닫는 p → 줄바꿈 · style · script · head · template → 안의 글을 빼기 시작 · 끝
+ */
+function applyTag(
+ html: string,
+ from: number,
+ to: number,
+ sink: TextSink,
+ hiding: HidingTags,
+): void {
+ let start = from;
+ while (start < to && isHtmlSpace(html.charCodeAt(start))) start += 1;
+ const closing = start < to && html.charCodeAt(start) === SLASH;
+ while (start < to && isNameEdge(html.charCodeAt(start))) start += 1;
+ let end = to;
+ while (end > start && isNameEdge(html.charCodeAt(end - 1))) end -= 1;
+ if (end - start > LONGEST_KNOWN_TAG) return;
+
+ if (isTagName(html, start, end, 'br')) {
+ sink.lineBreak();
+ return;
+ }
+ if (isTagName(html, start, end, 'p')) {
+ if (closing) sink.lineBreak();
+ return;
+ }
+ const tag = HIDING_TAGS.find((name) => isTagName(html, start, end, name));
+ if (tag) hiding.toggle(tag, !closing);
+}
+
+/** 태그 이름 자리가 그 이름(소문자)과 같은가 — ASCII 대소문자만 무시한다 */
+function isTagName(
+ html: string,
+ start: number,
+ end: number,
+ name: string,
+): boolean {
+ return end - start === name.length && matchesAsciiLower(html, start, name);
+}
+
+/** 이름 앞뒤에서 빼는 글자 — 공백 · `/` */
+function isNameEdge(code: number): boolean {
+ return isHtmlSpace(code) || code === SLASH;
+}
+
+/** html 의 그 자리부터가 소문자 낱말(영문)과 같은가 — ASCII 대소문자만 무시한다 */
+function matchesAsciiLower(html: string, at: number, word: string): boolean {
+ if (at + word.length > html.length) return false;
+ for (let offset = 0; offset < word.length; offset += 1) {
+ let code = html.charCodeAt(at + offset);
+ if (code >= 0x41 && code <= 0x5a) code += 0x20;
+ if (code !== word.charCodeAt(offset)) return false;
+ }
+ return true;
+}
+
+/** `&` 자리에서 시작하는 문자 참조 — `;` 로 끝나는 것만, 풀 수 없으면 null (글자 그대로 둔다) */
+function readReference(
+ text: string,
+ at: number,
+): { readonly value: string; readonly end: number } | null {
+ if (text.charCodeAt(at + 1) === HASH) return readNumericReference(text, at);
+ const nameStart = at + 1;
+ const limit = Math.min(text.length, nameStart + MAX_REFERENCE_NAME_LENGTH);
+ let index = nameStart;
+ while (index < limit && isAsciiAlphanumeric(text.charCodeAt(index))) {
+ index += 1;
+ }
+ if (index === nameStart || text.charCodeAt(index) !== SEMICOLON) return null;
+ const value = NAMED_REFERENCES.get(text.slice(nameStart, index));
+ return value === undefined ? null : { value, end: index + 1 };
+}
+
+/** `{` · `😀` — 없는 글자(0 · 대리 · 유니코드 밖)는 대체 문자 (HTML 표준) */
+function readNumericReference(
+ text: string,
+ at: number,
+): { readonly value: string; readonly end: number } | null {
+ const marker = text.charCodeAt(at + 2);
+ const hex = marker === 0x78 || marker === 0x58;
+ const digitsStart = hex ? at + 3 : at + 2;
+ const limit = Math.min(text.length, digitsStart + MAX_REFERENCE_DIGITS);
+ let index = digitsStart;
+ while (
+ index < limit &&
+ (hex
+ ? isHexDigit(text.charCodeAt(index))
+ : isDecimalDigit(text.charCodeAt(index)))
+ ) {
+ index += 1;
+ }
+ if (index === digitsStart || text.charCodeAt(index) !== SEMICOLON) {
+ return null;
+ }
+ const codePoint = Number.parseInt(
+ text.slice(digitsStart, index),
+ hex ? 16 : 10,
+ );
+ return { value: characterOf(codePoint), end: index + 1 };
+}
+
+function characterOf(codePoint: number): string {
+ if (
+ codePoint === 0 ||
+ codePoint > MAX_CODE_POINT ||
+ (codePoint >= 0xd800 && codePoint <= 0xdfff)
+ ) {
+ return REPLACEMENT_CHARACTER;
+ }
+ return String.fromCodePoint(codePoint);
+}
+
+/** 글의 공백 — 띄어쓰기 · 탭 · LF · CR (엔진과 같다 — FF · U+00A0 · U+3000 은 아니다) */
+function isHtmlSpace(code: number): boolean {
+ return code === 0x20 || code === 0x09 || code === 0x0a || code === 0x0d;
+}
+
+function isAsciiLetter(code: number): boolean {
+ return (code >= 0x41 && code <= 0x5a) || (code >= 0x61 && code <= 0x7a);
+}
+
+function isDecimalDigit(code: number): boolean {
+ return code >= 0x30 && code <= 0x39;
+}
+
+function isHexDigit(code: number): boolean {
+ return (
+ isDecimalDigit(code) ||
+ (code >= 0x41 && code <= 0x46) ||
+ (code >= 0x61 && code <= 0x66)
+ );
+}
+
+function isAsciiAlphanumeric(code: number): boolean {
+ return isAsciiLetter(code) || isDecimalDigit(code);
+}
diff --git a/backend/src/modules/mail/html/sanitize-mail-html.spec.ts b/backend/src/modules/mail/html/sanitize-mail-html.spec.ts
index 87c4a57..6e9520b 100644
--- a/backend/src/modules/mail/html/sanitize-mail-html.spec.ts
+++ b/backend/src/modules/mail/html/sanitize-mail-html.spec.ts
@@ -250,17 +250,19 @@ describe('sanitizeMailHtml', () => {
);
});
- it('주소가 남지 않은 이미지가 아주 많아도 입력 길이에 비례해 끝난다 (예전: 27만 개에 15초)', () => {
- const count = 333_333; // 2MB
+ it('주소가 남지 않은 이미지가 아주 많아도 입력 길이에 비례해 끝난다 (예전: 67만 개에 100초)', () => {
+ // 정화에 닿는 길이 한도(html-parse-budget.ts 의 MAX_HTML_PARSE_LENGTH, 200만 자)보다 일부러 크게 — 예전 경로(이미지 수의 제곱)와의 차이를 벌린다
+ const count = 666_666; // 4MB
const cpuMs = startCpuTimer();
const result = sanitizeMailHtml('x
'.repeat(count));
expect(result.html).toBe('x'.repeat(count));
- // CPU 시간 (test/cpu-time.ts) — 지금 1.1~1.6초. 예전 경로(결과를 이미지마다 다시 자름)는 27만 개(1.6MB)에 15초, 이 크기면 20초가 넘는다.
+ // CPU 시간 (test/cpu-time.ts) — 지금 2.0~2.4초(전체 테스트와 함께 돌 때 — 혼자면 1~1.7초). 예전 경로(결과를 이미지마다 다시 자름)는
+ // 같은 크기에 100~134초(이 PC 의 Node 22)·154초(Node 24 Linux 컨테이너)다 (2026-10-29). 2MB 로 재던 때는 예전 경로가 26~28초뿐이라 한도와 2배도 차이 나지 않았다.
// 벽시계로 재던 때는 바쁜 PC 에서 5.9초·9.1초로 재져 실패했다 (2026-10-27). CPU 시간도 2026-10-28 CI 와 같은 Linux 컨테이너에서
- // 메모리가 모자라고 테스트가 함께 돌 때 5.75초까지 재져 한도 5초로 실패했다 — 그래서 평소의 10배 가까이, 예전 경로(20초+)보다는 아래로 둔다
- expect(cpuMs()).toBeLessThan(15_000);
+ // 메모리가 모자라고 테스트가 함께 돌 때 2MB 가 5.75초까지 재졌다 — 그래서 한도는 평소의 10배쯤, 예전 경로는 그 4배를 넘는다
+ expect(cpuMs()).toBeLessThan(25_000);
});
});
diff --git a/backend/src/modules/mail/jmap/bulk-run.ts b/backend/src/modules/mail/jmap/bulk-run.ts
new file mode 100644
index 0000000..6107668
--- /dev/null
+++ b/backend/src/modules/mail/jmap/bulk-run.ts
@@ -0,0 +1,99 @@
+import { BusinessException } from '@/common/errors/business.exception';
+import { JMAP_REQUEST_TIMEOUT_MS, type JmapCallOptions } from './jmap.client';
+
+/**
+ * 오래 걸리는 처리 한 요청의 시간 · 진행 · 멈춘 차례 — 메일함 전체 읽음 · 비우기 · 지우기(jmap-mail.provider.ts, 계약 2절 "메일함 관리")와
+ * 이미 받은 메일에 필터 적용(jmap-filter-run.ts, 계약 2절 "이미 받은 메일에 적용")이 같은 규칙을 쓴다.
+ * 이 모듈은 `modules/mail/jmap/` 밖으로 내보내지 않는다.
+ */
+
+/**
+ * 한 요청의 시간 — 실제 상한이다 (BulkRun). 이 안에서 끝내고 남은 수(또는 이어 할 자리)를 돌려준다.
+ * 클라이언트가 남은 것이 없을 때까지 같은 요청을 다시 보낸다.
+ */
+export const BULK_REQUEST_BUDGET_MS = 25_000;
+/** 남은 시간이 이보다 적으면 다음 차례를 시작하지 않는다 — 한 차례(엔진 호출 두어 번)가 끝까지 시간 안에 들게 */
+export const BULK_ROUND_RESERVE_MS = 5_000;
+
+/**
+ * 상태가 어긋나면(ifInState — stateMismatch) 다시 확인하는 횟수 — 조건부로 바꾸는 요청을 모두 합쳐.
+ * 사용자 초안(교체 저장·보낸 뒤)과 휴지통에서 영구 삭제(DELETE /api/messages/{id} · POST /api/message-deletions)가 함께 쓰고,
+ * 오래 걸리는 처리(BulkRun)는 아무것도 바뀌지 않은 차례가 이어서 이 횟수면 멈춘다.
+ * 계정의 메일이 하나라도 바뀌면(새 메일 도착·다른 메일의 읽음 표시 포함) 상태가 바뀌므로 바쁜 메일함에서는 어긋날 수 있다 —
+ * 그래도 이 횟수 안에 맞지 않으면 지우지(옮기지) 않는다.
+ */
+export const CONDITIONAL_DESTROY_ATTEMPTS = 3;
+
+/**
+ * 오래 걸리는 처리의 한 요청 — 시간 · 진행 · 멈춘 차례.
+ * - 시간: 처음 엔진 호출(메일함 목록) **전부터** BULK_REQUEST_BUDGET_MS 가 실제 상한이다. 다음 차례는 남은 시간이 BULK_ROUND_RESERVE_MS 보다
+ * 많을 때만 시작하고, 엔진 호출마다 제한 시간을 기본 제한 시간(JMAP_REQUEST_TIMEOUT_MS)과 남은 시간 중 짧은 쪽으로 준다 —
+ * 엔진이 느려도 한 요청이 앞단 nginx 의 시간 제한에 걸리지 않게
+ * - 멈춘 차례: 아무것도 바뀌지 않은 차례(어긋남 · 바뀐 것도 거절도 없음 · 비운 메일함을 지우는 사이 메일이 들어옴)가
+ * 이어서 CONDITIONAL_DESTROY_ATTEMPTS 번이면 멈춘다 — 엔진에 끝없이 묻지 않게. 진행한 차례가 있으면 처음부터 다시 센다
+ * - 일찍 멈췄을 때(멈춘 차례 · 남은 시간 · 남은 시간 때문에 끊긴 호출) 이 요청에서 처리한 것이 있으면 거기까지로 답하고, 없으면 MAIL_001
+ * 처리한 것(processed)은 쓰는 쪽이 정한다 — 메일함 처리는 바꾼 메일 수, 필터 적용은 판정한 메일 수.
+ * 시계는 만들 때부터 간다 — 앞서 시작한 때(startedAt)를 주면 그때부터 센다(필터 적용은 서비스에 들어온 때 — 옮길 메일함 확인을 포함한다)
+ */
+export class BulkRun {
+ private readonly deadline: number;
+ private stalls = 0;
+ private lastStall: string | null = null;
+ /** 이번 요청에서 처리한 메일 수 */
+ processed = 0;
+
+ constructor(startedAt: number = Date.now()) {
+ this.deadline = startedAt + BULK_REQUEST_BUDGET_MS;
+ }
+
+ /** 다음 차례를 시작해도 되는가 */
+ canStartRound(): boolean {
+ return this.left() > BULK_ROUND_RESERVE_MS;
+ }
+
+ /** 이번 엔진 호출의 제한 시간 — 기본 제한 시간과 남은 시간 중 짧은 쪽 */
+ callOptions(): JmapCallOptions {
+ return {
+ timeoutMs: Math.max(1, Math.min(JMAP_REQUEST_TIMEOUT_MS, this.left())),
+ };
+ }
+
+ /** 처리했다 — 멈춘 차례를 처음부터 다시 센다 */
+ progress(count: number): void {
+ if (count <= 0) return;
+ this.processed += count;
+ this.stalls = 0;
+ }
+
+ /** 아무것도 바뀌지 않은 차례 — 이어서 CONDITIONAL_DESTROY_ATTEMPTS 번째면 true (멈춘다) */
+ stalled(why: string): boolean {
+ this.stalls += 1;
+ this.lastStall = why;
+ return this.stalls >= CONDITIONAL_DESTROY_ATTEMPTS;
+ }
+
+ /**
+ * 한 요청의 시간을 다 써서 엔진 호출이 끊겼고, 이 요청에서 처리한 것이 있는가 — 그러면 거기까지로 답한다.
+ * 끊긴 Email/set 은 엔진이 적용했을 수 있다 — 남은 수는 다음 요청이 새로 읽어 맞춘다 (조건부 규칙 그대로)
+ */
+ cutOffAfterProgress(error: unknown): boolean {
+ return (
+ this.processed > 0 &&
+ this.left() <= 0 &&
+ error instanceof BusinessException &&
+ error.code === 'MAIL_001'
+ );
+ }
+
+ /** 끝까지 하지 못하고 멈췄는데 이 요청에서 처리한 것이 없으면 MAIL_001 — 클라이언트가 같은 요청을 끝없이 다시 보내지 않게 */
+ assertProgress(operation: string): void {
+ if (this.processed > 0) return;
+ throw new BusinessException('MAIL_001', {
+ context: { reason: `${operation}-no-progress`, stalled: this.lastStall },
+ });
+ }
+
+ private left(): number {
+ return this.deadline - Date.now();
+ }
+}
diff --git a/backend/src/modules/mail/jmap/contact-card.spec.ts b/backend/src/modules/mail/jmap/contact-card.spec.ts
new file mode 100644
index 0000000..674102a
--- /dev/null
+++ b/backend/src/modules/mail/jmap/contact-card.spec.ts
@@ -0,0 +1,861 @@
+import { BusinessException } from '@/common/errors/business.exception';
+import type { ContactFields } from '@/modules/contacts/contacts.types';
+import {
+ cardOriginal,
+ contactPatch,
+ groupPatch,
+ newCardJson,
+ patchedCard,
+ readContactCard,
+} from './contact-card';
+import type { JmapContactCard } from './jmap.types';
+
+/** 2026-10-29 개발 엔진(Stalwart 0.16.27)이 돌려준 카드 모양 — p8-contacts-state.log */
+const ENGINE_CARD: JmapContactCard = {
+ id: 'ju',
+ uid: 'urn:uuid:0b9a4f9e-7c2d-4d3a-9f4e-1a2b3c4d5e6f',
+ kind: 'individual',
+ name: { full: '시험' },
+ emails: {
+ e1: { address: 't@example.com', contexts: { work: true }, pref: 1 },
+ },
+ phones: { p1: { number: '010-1234-5678', features: { mobile: true } } },
+ organizations: { o1: { name: '시험회사', units: [{ name: '시험부서' }] } },
+ titles: { t1: { name: '과장', kind: 'title' } },
+ notes: { n1: { note: '메모\n둘째 줄' } },
+};
+
+const fields: ContactFields = {
+ name: '박지훈',
+ emails: [
+ { address: 'jihoon@hanbit.example', label: 'work' },
+ { address: 'jihoon@home.example', label: 'personal' },
+ { address: 'jihoon@old.example', label: 'other' },
+ ],
+ phones: [
+ { number: '010-3192-5540', label: 'mobile' },
+ { number: '02-333-4444', label: 'work' },
+ { number: '031-555-6666', label: 'home' },
+ { number: '070-1111-2222', label: 'other' },
+ ],
+ company: '한빛상사',
+ department: '구매팀',
+ title: '과장',
+ note: '견적 담당\n월요일 오전',
+};
+
+describe('JSContact 카드 읽기 (readContactCard)', () => {
+ it('엔진이 돌려준 카드 — 우리 칸으로 그대로 (메모의 줄바꿈 포함)', () => {
+ expect(readContactCard(ENGINE_CARD)).toEqual({
+ kind: 'contact',
+ card: {
+ id: 'ju',
+ uid: 'urn:uuid:0b9a4f9e-7c2d-4d3a-9f4e-1a2b3c4d5e6f',
+ name: '시험',
+ emails: [{ address: 't@example.com', label: 'work' }],
+ phones: [{ number: '010-1234-5678', label: 'mobile' }],
+ company: '시험회사',
+ department: '시험부서',
+ title: '과장',
+ note: '메모\n둘째 줄',
+ },
+ });
+ });
+
+ it('uid 없는 카드(다른 프로그램이 uid 없이 만든 것) — uid 는 null', () => {
+ const read = readContactCard({ id: 'a', name: { full: '옛 카드' } });
+ expect(read?.card.uid).toBeNull();
+ });
+
+ it('칸이 없는 카드 — 엔진은 이름 없는 카드에 빈 full 을 준다 → 이름 null, 나머지는 빈 값', () => {
+ expect(readContactCard({ id: 'j0', name: { full: '' } })).toEqual({
+ kind: 'contact',
+ card: {
+ id: 'j0',
+ uid: null,
+ name: null,
+ emails: [],
+ phones: [],
+ company: null,
+ department: null,
+ title: null,
+ note: null,
+ },
+ });
+ // kind 가 없거나 group 이 아니면 연락처다
+ expect(readContactCard({ id: 'x', kind: 'org' })?.kind).toBe('contact');
+ // id 가 없으면 읽지 않는다
+ expect(readContactCard({ id: '' })).toBeNull();
+ });
+
+ it('이메일 6개 이상(화면은 5개까지 더한다) — 모두 읽고, pref(작을수록 먼저) → 엔진이 준 차례', () => {
+ const emails: Record> = Object.fromEntries(
+ Array.from({ length: 7 }, (_, index) => [
+ `e${index + 1}`,
+ { address: `p${index + 1}@x.example` },
+ ]),
+ );
+ emails.e6 = {
+ address: 'p6@x.example',
+ pref: 1,
+ contexts: { private: true },
+ };
+ emails.e3 = { address: 'p3@x.example', pref: 50 };
+
+ const read = readContactCard({ id: 'many', emails });
+ expect(
+ read?.kind === 'contact' &&
+ read.card.emails.map((email) => email.address),
+ ).toEqual([
+ 'p6@x.example',
+ 'p3@x.example',
+ 'p1@x.example',
+ 'p2@x.example',
+ 'p4@x.example',
+ 'p5@x.example',
+ 'p7@x.example',
+ ]);
+ expect(read?.kind === 'contact' && read.card.emails[0]?.label).toBe(
+ 'personal',
+ );
+ });
+
+ it('종류 — 이메일은 contexts(work 회사 · private 개인 · 그 밖 기타), 전화는 features.mobile → contexts work · private → 기타', () => {
+ const read = readContactCard({
+ id: 'k',
+ emails: {
+ a: { address: 'a@x.example', contexts: { work: true, private: true } },
+ b: { address: 'b@x.example', contexts: { private: true } },
+ c: { address: 'c@x.example', contexts: { billing: true } },
+ },
+ phones: {
+ a: {
+ number: '1',
+ features: { mobile: true },
+ contexts: { work: true },
+ },
+ b: { number: '2', contexts: { work: true } },
+ c: { number: '3', contexts: { private: true } },
+ d: { number: '4', features: { fax: true } },
+ },
+ });
+
+ expect(read?.kind === 'contact' && read.card.emails).toEqual([
+ { address: 'a@x.example', label: 'work' },
+ { address: 'b@x.example', label: 'personal' },
+ { address: 'c@x.example', label: 'other' },
+ ]);
+ expect(read?.kind === 'contact' && read.card.phones).toEqual([
+ { number: '1', label: 'mobile' },
+ { number: '2', label: 'work' },
+ { number: '3', label: 'home' },
+ { number: '4', label: 'other' },
+ ]);
+ });
+
+ it('다른 프로그램의 모양 — 이름 조각 · tel: 번호 · 문자열 부서 · role 직책 · 여러 메모(\\r\\n · 탭) · 모양이 틀린 값은 건너뛴다', () => {
+ const read = readContactCard({
+ id: 'v',
+ name: {
+ components: [
+ { kind: 'surname', value: '김' },
+ { kind: 'given', value: '아라' },
+ ],
+ },
+ emails: {
+ a: { address: 42 },
+ b: null,
+ c: { address: ' ara@x.example ' },
+ },
+ phones: { a: { number: 'tel:+82-10-1234-5678' }, b: { number: '' } },
+ organizations: { o: { name: '한빛\t상사', units: ['영업팀'] } },
+ titles: {
+ r: { name: '총무', kind: 'role' },
+ t: { name: '대리' },
+ },
+ notes: { a: { note: '첫 줄\r\n둘째\t줄' }, b: { note: '다른 메모' } },
+ });
+
+ expect(read).toEqual({
+ kind: 'contact',
+ card: {
+ id: 'v',
+ uid: null,
+ name: '김아라',
+ emails: [{ address: 'ara@x.example', label: 'other' }],
+ phones: [{ number: '+82-10-1234-5678', label: 'other' }],
+ company: '한빛 상사',
+ department: '영업팀',
+ title: '대리',
+ note: '첫 줄\n둘째 줄\n다른 메모',
+ },
+ });
+ });
+
+ it.each<[string, Record, string]>([
+ [
+ '한글 조각끼리는 붙여 쓴다',
+ {
+ components: [
+ { kind: 'surname', value: '남궁' },
+ { kind: 'given', value: '민수' },
+ ],
+ },
+ '남궁민수',
+ ],
+ [
+ '로마자 조각은 띄어 쓴다',
+ {
+ components: [
+ { kind: 'given', value: 'Ara' },
+ { kind: 'surname', value: 'Kim' },
+ ],
+ },
+ 'Ara Kim',
+ ],
+ [
+ '한글과 로마자 사이는 띄어 쓴다',
+ {
+ components: [
+ { kind: 'surname', value: '김' },
+ { kind: 'given', value: 'Ara' },
+ ],
+ },
+ '김 Ara',
+ ],
+ [
+ 'separator 조각은 그 글 그대로 (기본 구분보다 먼저)',
+ {
+ isOrdered: true,
+ defaultSeparator: ' ',
+ components: [
+ { kind: 'surname', value: '김' },
+ { kind: 'separator', value: ', ' },
+ { kind: 'given', value: '아라' },
+ ],
+ },
+ '김, 아라',
+ ],
+ [
+ 'defaultSeparator 가 있으면 한글끼리도 그것으로',
+ {
+ isOrdered: true,
+ defaultSeparator: ' ',
+ components: [
+ { kind: 'surname', value: '김' },
+ { kind: 'given', value: '아라' },
+ ],
+ },
+ '김 아라',
+ ],
+ [
+ '빈 separator 는 붙여 쓰기 · 앞뒤의 separator 와 빈 조각은 버린다',
+ {
+ isOrdered: true,
+ defaultSeparator: ' ',
+ components: [
+ { kind: 'separator', value: '-' },
+ { kind: 'given', value: 'Jean' },
+ { kind: 'separator', value: '' },
+ { kind: 'given2', value: 'Luc' },
+ { kind: 'title', value: ' ' },
+ { kind: 'separator', value: '!' },
+ ],
+ },
+ 'JeanLuc',
+ ],
+ [
+ 'full 이 있으면 조각은 보지 않는다',
+ { full: '김아라 과장', components: [{ kind: 'given', value: '아라' }] },
+ '김아라 과장',
+ ],
+ ])('이름 조각만 있는 이름 — %s', (_label, name, expected) => {
+ expect(readContactCard({ id: 'n', name })?.card.name).toBe(expected);
+ });
+
+ it('그룹 — 이름과 구성원(true 인 uid 만). 구성원을 모두 빼면 엔진은 빈 members 를 준다', () => {
+ expect(
+ readContactCard({
+ id: 'g',
+ uid: 'urn:uuid:g',
+ kind: 'group',
+ name: { full: '거래처' },
+ members: { 'urn:uuid:a': true, 'urn:uuid:b': false, ' ': true },
+ }),
+ ).toEqual({
+ kind: 'group',
+ card: {
+ id: 'g',
+ uid: 'urn:uuid:g',
+ name: '거래처',
+ memberUids: ['urn:uuid:a'],
+ },
+ });
+ expect(
+ readContactCard({ id: 'e', kind: 'group', members: {} }),
+ ).toMatchObject({ card: { name: '', memberUids: [] } });
+ });
+});
+
+describe('JSContact 카드 쓰기 (newCardJson · cardPatch)', () => {
+ it('새 연락처 — @type · version · uid · kind · 기본 주소록, 대표 주소만 pref 1, 종류는 contexts · features', () => {
+ expect(
+ newCardJson('book', {
+ kind: 'contact',
+ key: 'contact',
+ uid: 'urn:uuid:new',
+ fields,
+ }),
+ ).toEqual({
+ '@type': 'Card',
+ version: '1.0',
+ uid: 'urn:uuid:new',
+ addressBookIds: { book: true },
+ kind: 'individual',
+ name: { full: '박지훈' },
+ emails: {
+ e1: {
+ address: 'jihoon@hanbit.example',
+ contexts: { work: true },
+ pref: 1,
+ },
+ e2: { address: 'jihoon@home.example', contexts: { private: true } },
+ e3: { address: 'jihoon@old.example' },
+ },
+ phones: {
+ p1: { number: '010-3192-5540', features: { mobile: true } },
+ p2: { number: '02-333-4444', contexts: { work: true } },
+ p3: { number: '031-555-6666', contexts: { private: true } },
+ p4: { number: '070-1111-2222' },
+ },
+ organizations: {
+ o1: { name: '한빛상사', units: [{ name: '구매팀' }] },
+ },
+ titles: { t1: { name: '과장', kind: 'title' } },
+ notes: { n1: { note: '견적 담당\n월요일 오전' } },
+ });
+ });
+
+ it('새 연락처의 빈 칸은 보내지 않는다 — 부서만 있으면 소속은 부서만', () => {
+ expect(
+ newCardJson('book', {
+ kind: 'contact',
+ key: 'contact',
+ uid: 'urn:uuid:new',
+ fields: {
+ name: null,
+ emails: [{ address: 'a@x.example', label: 'other' }],
+ phones: [],
+ company: null,
+ department: '총무팀',
+ title: null,
+ note: null,
+ },
+ }),
+ ).toEqual({
+ '@type': 'Card',
+ version: '1.0',
+ uid: 'urn:uuid:new',
+ addressBookIds: { book: true },
+ kind: 'individual',
+ emails: { e1: { address: 'a@x.example', pref: 1 } },
+ organizations: { o1: { units: [{ name: '총무팀' }] } },
+ });
+ });
+
+ it('새 그룹 — kind group · 이름 · 구성원(uid 맵), 구성원이 없으면 members 를 보내지 않는다', () => {
+ expect(
+ newCardJson('book', {
+ kind: 'group',
+ key: 'group',
+ uid: 'urn:uuid:g',
+ name: '거래처',
+ memberUids: ['urn:uuid:a', 'urn:uuid:b'],
+ }),
+ ).toEqual({
+ '@type': 'Card',
+ version: '1.0',
+ uid: 'urn:uuid:g',
+ addressBookIds: { book: true },
+ kind: 'group',
+ name: { full: '거래처' },
+ members: { 'urn:uuid:a': true, 'urn:uuid:b': true },
+ });
+ expect(
+ newCardJson('book', {
+ kind: 'group',
+ key: 'group',
+ uid: 'urn:uuid:g',
+ name: '빈 그룹',
+ memberUids: [],
+ }),
+ ).not.toHaveProperty('members');
+ });
+
+ it('쓰고 다시 읽으면 같다 (왕복)', () => {
+ const json = newCardJson('book', {
+ kind: 'contact',
+ key: 'contact',
+ uid: 'urn:uuid:new',
+ fields,
+ });
+ expect(readContactCard({ ...json, id: 'n1' })).toEqual({
+ kind: 'contact',
+ card: { id: 'n1', uid: 'urn:uuid:new', ...fields },
+ });
+ });
+});
+
+describe('되돌리기 카드 (newCardJson 의 restored · cardOriginal)', () => {
+ /** 2026-10-29 개발 엔진이 properties: null 로 돌려준 카드 모양 (be-probe-r2.log — 다른 프로그램의 칸이 많은 카드) */
+ const ORIGINAL = {
+ id: 'lm',
+ '@type': 'Card',
+ version: '1.0',
+ uid: 'urn:uuid:orig',
+ kind: 'individual',
+ addressBookIds: { old: true },
+ name: { full: '시험 원본', components: [{ kind: 'given', value: '원본' }] },
+ nicknames: { k1: { name: '별명' } },
+ emails: { x1: { address: 'one@x.example', label: '본사', pref: 1 } },
+ anniversaries: {
+ b1: {
+ date: { '@type': 'PartialDate', month: 4, day: 26 },
+ kind: 'birth',
+ },
+ },
+ keywords: { vip: true },
+ };
+
+ it('원본에서 id 만 빼고 uid · 주소록을 바꾼다 — 우리가 다루지 않는 칸(별명 · 생일 · 키워드)도 그대로', () => {
+ const original = cardOriginal(ORIGINAL);
+ const { id: _id, ...rest } = ORIGINAL;
+ void _id;
+ expect(
+ newCardJson('book', {
+ kind: 'restored',
+ key: 'restored',
+ uid: 'urn:uuid:new',
+ original,
+ memberUids: null,
+ card: {
+ kind: 'contact',
+ card: {
+ id: 'lm',
+ uid: 'urn:uuid:orig',
+ name: '시험 원본',
+ emails: [],
+ phones: [],
+ company: null,
+ department: null,
+ title: null,
+ note: null,
+ },
+ },
+ }),
+ ).toEqual({ ...rest, uid: 'urn:uuid:new', addressBookIds: { book: true } });
+ });
+
+ it('그룹은 구성원을 넘긴 것으로 바꾼다 — 비면 members 를 뺀다', () => {
+ const original = cardOriginal({
+ id: 'g',
+ '@type': 'Card',
+ version: '1.0',
+ uid: 'urn:uuid:g',
+ kind: 'group',
+ addressBookIds: { book: true },
+ name: { full: '거래처' },
+ members: { 'urn:uuid:gone': true, 'urn:uuid:a': true },
+ });
+ const restored = (memberUids: string[]) =>
+ newCardJson('book', {
+ kind: 'restored',
+ key: 'restored',
+ uid: 'urn:uuid:g',
+ original,
+ memberUids,
+ card: {
+ kind: 'group',
+ card: { id: 'g', uid: 'urn:uuid:g', name: '거래처', memberUids: [] },
+ },
+ });
+ expect(restored(['urn:uuid:a'])).toMatchObject({
+ kind: 'group',
+ members: { 'urn:uuid:a': true },
+ });
+ expect(restored(['urn:uuid:a']).members).toEqual({ 'urn:uuid:a': true });
+ expect(restored([])).not.toHaveProperty('members');
+ });
+
+ it('원본의 크기는 UTF-8 바이트 — 한글은 세 바이트', () => {
+ expect(cardOriginal({ id: 'a', name: { full: '가' } }).bytes).toBe(
+ Buffer.byteLength('{"id":"a","name":{"full":"가"}}', 'utf8'),
+ );
+ });
+
+ it('원본이 우리가 쓴 글이 아니면 MAIL_001 (서버 안의 잘못)', () => {
+ expect(() =>
+ newCardJson('book', {
+ kind: 'restored',
+ key: 'restored',
+ uid: 'urn:uuid:x',
+ original: { source: '[1,2]', bytes: 5 },
+ memberUids: null,
+ card: {
+ kind: 'group',
+ card: { id: 'g', uid: null, name: '', memberUids: [] },
+ },
+ }),
+ ).toThrow(BusinessException);
+ });
+});
+
+describe('연락처 고치기 (contactPatch — 칸 안의 남의 정보를 지킨다, 계약 3절 "고치기")', () => {
+ /** 다른 프로그램이 만든 카드 — 2026-10-29 개발 엔진에 넣고 다시 읽은 모양(be-probe-r2.log) */
+ const RICH: Record = {
+ id: 'lm',
+ uid: 'urn:uuid:rich',
+ kind: 'individual',
+ name: {
+ full: '시험 원본',
+ components: [
+ { kind: 'surname', value: '시험' },
+ { kind: 'given', value: '원본' },
+ ],
+ defaultSeparator: ' ',
+ isOrdered: true,
+ },
+ emails: {
+ x1: {
+ address: 'one@x.example',
+ contexts: { work: true, billing: true },
+ pref: 1,
+ label: '본사',
+ },
+ x2: { address: 'two@x.example', contexts: { private: true }, pref: 3 },
+ },
+ phones: {
+ y1: {
+ number: 'tel:+82-2-333-4444',
+ contexts: { work: true },
+ features: { fax: true, voice: true },
+ label: '팩스',
+ },
+ y2: { number: '010-1111-2222', features: { mobile: true, text: true } },
+ },
+ organizations: {
+ o1: {
+ name: '한빛상사',
+ units: [{ name: '구매팀' }, { name: '1파트' }],
+ sortAs: 'HANBIT',
+ },
+ o2: { name: '두번째 회사' },
+ },
+ titles: {
+ t2: { name: '과장', kind: 'title', organizationId: 'o1' },
+ t1: { name: '팀장', kind: 'role', organizationId: 'o1' },
+ },
+ notes: {
+ n1: { note: '첫 메모', author: { name: '누군가' } },
+ n2: { note: '둘째 메모' },
+ },
+ };
+ const contactOf = (card: Record) => {
+ const read = readContactCard(card);
+ if (read?.kind !== 'contact') throw new Error('연락처가 아니다');
+ return read.card;
+ };
+
+ it('읽은 모양 — 대표 주소 · 팩스는 기타 · role 은 직책이 아니다 · 첫 조직 · 메모는 이어서', () => {
+ expect(contactOf(RICH)).toEqual({
+ id: 'lm',
+ uid: 'urn:uuid:rich',
+ name: '시험 원본',
+ emails: [
+ { address: 'one@x.example', label: 'work' },
+ { address: 'two@x.example', label: 'personal' },
+ ],
+ phones: [
+ { number: '+82-2-333-4444', label: 'work' },
+ { number: '010-1111-2222', label: 'mobile' },
+ ],
+ company: '한빛상사',
+ department: '구매팀',
+ title: '과장',
+ note: '첫 메모\n둘째 메모',
+ });
+ });
+
+ it('이메일 — 같은 주소(대소문자 무시) 항목의 남의 속성을 남기고, 종류를 바꾼 것만 우리 값(다른 contexts 는 둔다) · 새 주소는 새 키 · 빠진 주소는 뺀다', () => {
+ const patch = contactPatch(RICH, {
+ emails: [
+ { address: 'TWO@x.example', label: 'work' },
+ { address: 'one@x.example', label: 'other' },
+ { address: 'new@x.example', label: 'personal' },
+ ],
+ });
+ expect(patch).toEqual({
+ emails: {
+ // 대표 주소가 된 둘째 — pref 1, 대소문자를 바꾼 주소는 보낸 대로, 종류만 work 로
+ x2: { address: 'TWO@x.example', contexts: { work: true }, pref: 1 },
+ // 첫째 — 종류를 other 로: work · private 만 빼고 billing · label 은 둔다. pref 1 은 첫 주소만이라 뺀다
+ x1: {
+ address: 'one@x.example',
+ contexts: { billing: true },
+ label: '본사',
+ },
+ e1: { address: 'new@x.example', contexts: { private: true } },
+ },
+ });
+ });
+
+ it('이메일을 그대로 다시 보내면 항목이 그대로다 (주소 · 종류 · 남의 속성 · pref)', () => {
+ expect(
+ contactPatch(RICH, {
+ emails: [
+ { address: 'one@x.example', label: 'work' },
+ { address: 'two@x.example', label: 'personal' },
+ ],
+ }),
+ ).toEqual({ emails: RICH.emails });
+ });
+
+ it('pref — 첫 주소는 1, 나머지는 차례를 바꾸지 않는 것만 남긴다 (pref 없는 항목 뒤의 pref 도 뺀다)', () => {
+ const card = {
+ id: 'p',
+ emails: {
+ a: { address: 'a@x.example', pref: 2 },
+ b: { address: 'b@x.example', pref: 5 },
+ c: { address: 'c@x.example' },
+ d: { address: 'd@x.example', pref: 9 },
+ e: { address: 'e@x.example', pref: 7 },
+ },
+ };
+ const patch = contactPatch(card, {
+ emails: ['b', 'a', 'e', 'c', 'd'].map((key) => ({
+ address: `${key}@x.example`,
+ label: 'other' as const,
+ })),
+ });
+ expect(patch.emails).toEqual({
+ b: { address: 'b@x.example', pref: 1 },
+ a: { address: 'a@x.example', pref: 2 },
+ e: { address: 'e@x.example', pref: 7 },
+ c: { address: 'c@x.example' },
+ d: { address: 'd@x.example' },
+ });
+ // 다시 읽으면 보낸 차례 그대로
+ expect(
+ contactOf(patchedCard(card, patch)).emails.map((email) => email.address),
+ ).toEqual(['b', 'a', 'e', 'c', 'd'].map((key) => `${key}@x.example`));
+ });
+
+ it('전화 — 같은 번호(보인 글 → 같은 숫자) 항목의 fax · text · label 을 남기고 종류만 우리 값, 보인 번호를 그대로 받으면 tel: 글을 둔다', () => {
+ const patch = contactPatch(RICH, {
+ phones: [
+ // 팩스 번호 — 종류를 집으로: contexts.work → private, features.fax · voice · label 은 둔다
+ { number: '+82-2-333-4444', label: 'home' },
+ // 휴대전화 번호를 띄어 써 보냄 — 같은 숫자라 짝, 종류를 회사로: features.mobile 만 빼고 text 는 둔다
+ { number: '010 1111 2222', label: 'work' },
+ { number: '031-555-6666', label: 'mobile' },
+ ],
+ });
+ expect(patch).toEqual({
+ phones: {
+ y1: {
+ number: 'tel:+82-2-333-4444',
+ contexts: { private: true },
+ features: { fax: true, voice: true },
+ label: '팩스',
+ },
+ y2: {
+ number: '010 1111 2222',
+ features: { text: true },
+ contexts: { work: true },
+ },
+ p1: { number: '031-555-6666', features: { mobile: true } },
+ },
+ });
+ });
+
+ it('직책 — role 이 아닌 첫 직책만 고치고(다른 속성은 둔다) role 은 그대로, 지우면 그 항목만 뺀다', () => {
+ expect(contactPatch(RICH, { title: '차장' })).toEqual({
+ titles: {
+ t2: { name: '차장', kind: 'title', organizationId: 'o1' },
+ t1: { name: '팀장', kind: 'role', organizationId: 'o1' },
+ },
+ });
+ expect(contactPatch(RICH, { title: null })).toEqual({
+ titles: { t1: { name: '팀장', kind: 'role', organizationId: 'o1' } },
+ });
+ // 직책이 없던 카드 — 새 항목 (role 은 그대로)
+ expect(
+ contactPatch(
+ { id: 'r', titles: { t1: { name: '팀장', kind: 'role' } } },
+ { title: '대리' },
+ ),
+ ).toEqual({
+ titles: {
+ t1: { name: '팀장', kind: 'role' },
+ t2: { name: '대리', kind: 'title' },
+ },
+ });
+ expect(contactPatch({ id: 'e' }, { title: null })).toEqual({
+ titles: null,
+ });
+ });
+
+ it('회사 · 부서 — 첫 조직의 이름 · 첫 units 만(sortAs · 둘째 부서 · 둘째 조직은 둔다)', () => {
+ expect(contactPatch(RICH, { company: '한빛상사2' })).toEqual({
+ organizations: {
+ o1: {
+ name: '한빛상사2',
+ units: [{ name: '구매팀' }, { name: '1파트' }],
+ sortAs: 'HANBIT',
+ },
+ o2: { name: '두번째 회사' },
+ },
+ });
+ expect(contactPatch(RICH, { department: '영업팀' })).toEqual({
+ organizations: {
+ o1: {
+ name: '한빛상사',
+ units: [{ name: '영업팀' }, { name: '1파트' }],
+ sortAs: 'HANBIT',
+ },
+ o2: { name: '두번째 회사' },
+ },
+ });
+ // 부서를 지우면 첫 units 만 빠진다
+ expect(contactPatch(RICH, { department: null })).toMatchObject({
+ organizations: { o1: { units: [{ name: '1파트' }] } },
+ });
+ });
+
+ it('회사 · 부서를 둘 다 비우면 — 남은 것이 없는 조직은 빼고(둘째 조직은 그대로), 남은 칸이 있으면 둔다', () => {
+ const plain = {
+ id: 'o',
+ organizations: {
+ a: { '@type': 'Organization', name: '회사', units: [{ name: '부서' }] },
+ b: { name: '다른 회사' },
+ },
+ };
+ expect(contactPatch(plain, { company: null, department: null })).toEqual({
+ organizations: { b: { name: '다른 회사' } },
+ });
+ expect(
+ contactPatch(
+ { id: 'o', organizations: { a: { name: '회사' } } },
+ { company: null },
+ ),
+ ).toEqual({ organizations: null });
+ expect(
+ contactPatch(RICH, { company: null, department: null }),
+ ).toMatchObject({
+ organizations: {
+ o1: { units: [{ name: '1파트' }], sortAs: 'HANBIT' },
+ o2: { name: '두번째 회사' },
+ },
+ });
+ // 조직이 없던 카드 — 새 조직
+ expect(
+ contactPatch({ id: 'n' }, { company: '새 회사', department: '새 부서' }),
+ ).toEqual({
+ organizations: { o1: { name: '새 회사', units: [{ name: '새 부서' }] } },
+ });
+ });
+
+ it('메모 — 여러 개였으면 첫 항목의 다른 속성(author)을 남기고 하나로, null 이면 칸을 뺀다', () => {
+ expect(contactPatch(RICH, { note: '하나로 쓴 메모' })).toEqual({
+ notes: { n1: { note: '하나로 쓴 메모', author: { name: '누군가' } } },
+ });
+ expect(contactPatch(RICH, { note: null })).toEqual({ notes: null });
+ expect(contactPatch({ id: 'n' }, { note: '새 메모' })).toEqual({
+ notes: { n1: { note: '새 메모' } },
+ });
+ });
+
+ it('이름 — { full } 로 (이름 조각과 그에 딸린 칸은 지운다), null 이면 칸을 뺀다', () => {
+ expect(contactPatch(RICH, { name: '시험 고침' })).toEqual({
+ name: { full: '시험 고침' },
+ });
+ expect(contactPatch(RICH, { name: null })).toEqual({ name: null });
+ });
+
+ it('보내지 않은 칸은 패치에 없다 · 빈 목록은 칸을 뺀다', () => {
+ expect(contactPatch(RICH, {})).toEqual({});
+ expect(contactPatch(RICH, { emails: [], phones: [] })).toEqual({
+ emails: null,
+ phones: null,
+ });
+ });
+
+ it('왕복 — 고친 카드를 다시 읽으면 보낸 칸 그대로이고, 다른 칸 · 칸 안의 남의 정보(fax · role · 둘째 조직 · label)는 남는다', () => {
+ const fields: Partial = {
+ name: '시험 고침',
+ emails: [
+ { address: 'two@x.example', label: 'work' },
+ { address: 'one@x.example', label: 'work' },
+ ],
+ phones: [{ number: '+82-2-333-4444', label: 'home' }],
+ company: '새 회사',
+ title: '차장',
+ note: '고친 메모',
+ };
+ const next = patchedCard(
+ { ...RICH, addresses: { a1: { countryCode: 'KR' } } },
+ contactPatch(RICH, fields),
+ );
+ expect(contactOf(next)).toEqual({
+ id: 'lm',
+ uid: 'urn:uuid:rich',
+ name: '시험 고침',
+ emails: fields.emails,
+ phones: fields.phones,
+ company: '새 회사',
+ department: '구매팀',
+ title: '차장',
+ note: '고친 메모',
+ });
+ expect(next.addresses).toEqual({ a1: { countryCode: 'KR' } });
+ expect(next.phones).toEqual({
+ y1: {
+ number: 'tel:+82-2-333-4444',
+ contexts: { private: true },
+ features: { fax: true, voice: true },
+ label: '팩스',
+ },
+ });
+ expect(next.titles).toMatchObject({ t1: { kind: 'role', name: '팀장' } });
+ expect(next.organizations).toMatchObject({ o2: { name: '두번째 회사' } });
+ expect(next.emails).toMatchObject({
+ x1: { label: '본사', contexts: { work: true, billing: true } },
+ });
+ });
+});
+
+describe('그룹 고치기 (groupPatch)', () => {
+ it('이름은 name 통째로, 구성원은 members 통째로(엔진은 구성원 없는 그룹에 members/ 패치를 받지 않는다), 비면 null', () => {
+ expect(groupPatch({ kind: 'group', id: 'g', name: '협력사' })).toEqual({
+ name: { full: '협력사' },
+ });
+ expect(
+ groupPatch({ kind: 'group', id: 'g', memberUids: ['urn:uuid:a'] }),
+ ).toEqual({ members: { 'urn:uuid:a': true } });
+ expect(groupPatch({ kind: 'group', id: 'g', memberUids: [] })).toEqual({
+ members: null,
+ });
+ });
+
+ it('patchedCard — null 인 칸은 뺀다', () => {
+ expect(
+ patchedCard(
+ { id: 'g', name: { full: 'a' }, members: { x: true } },
+ {
+ members: null,
+ name: { full: 'b' },
+ },
+ ),
+ ).toEqual({ id: 'g', name: { full: 'b' } });
+ });
+});
diff --git a/backend/src/modules/mail/jmap/contact-card.ts b/backend/src/modules/mail/jmap/contact-card.ts
new file mode 100644
index 0000000..ffb6a5f
--- /dev/null
+++ b/backend/src/modules/mail/jmap/contact-card.ts
@@ -0,0 +1,777 @@
+import { BusinessException } from '@/common/errors/business.exception';
+import type {
+ AddressBookCard,
+ AddressBookCardUpdate,
+ NewAddressBookCard,
+ RemovedCardOriginal,
+} from '@/modules/contacts/contacts.provider';
+import type {
+ ContactEmail,
+ ContactEmailLabel,
+ ContactFields,
+ ContactPhone,
+ ContactPhoneLabel,
+} from '@/modules/contacts/contacts.types';
+import type { JmapContactCard } from './jmap.types';
+
+/**
+ * JSContact 카드(RFC 9553 — 엔진의 ContactCard) ↔ 주소록 카드 (순수 함수) — 계약 3절 "엔진에 두는 모양" · "고치기" · "되돌리기".
+ * 이 파일의 함수 · 엔진 모양은 `modules/mail/jmap/` 밖으로 내보내지 않는다 (jmap-contacts.provider.ts 가 쓴다).
+ *
+ * 읽기 (다른 프로그램 · CardDAV 에서 온 카드도 — 모양이 맞지 않는 값은 건너뛴다):
+ * - kind 가 group 이면 그룹(구성원은 `members` 의 uid), 그 밖은 연락처
+ * - 이름: name.full — 없으면 이름 조각(components)을 이어 붙인 것(조각 사이는 separator 조각 → defaultSeparator → 한글끼리는 붙이고 그 밖은
+ * 띄어쓰기 — 김 + 아라 → 김아라). 엔진은 이름 없는 카드에 빈 full 을 준다 → null
+ * - 이메일 · 전화: pref(작을수록 먼저) → 엔진이 준 차례. 이메일 종류는 contexts(work 회사 · private 개인 · 그 밖 기타),
+ * 전화는 features.mobile 휴대전화 → contexts work 회사 · private 집 → 기타. 번호의 `tel:` 은 뗀다
+ * - 소속: organizations 의 첫째 — name 이 회사, units 첫째가 부서. 직책: titles 에서 kind 가 role 이 아닌 첫째. 메모: notes 를 줄바꿈으로 잇는다
+ * - 한 줄 칸은 제어 문자를 띄어쓰기로 바꾸고 앞뒤 공백을 뺀다. 메모는 줄바꿈을 \n 으로 맞추고 그 밖의 제어 문자는 띄어쓰기로 — 고치기 화면이
+ * 받은 그대로 다시 보내도 요청 검증(contacts/dto/contact.dto.ts)을 통과하게
+ *
+ * 만들기: `@type: Card` · `version: 1.0` · `uid` · `kind` · `addressBookIds`(기본 주소록) + 우리 칸. 대표 주소는 `pref: 1`, 키는 e1 · p1 …
+ * (엔진이 넣은 차례를 지킨다 — 2026-10-29 확인). 되돌리기는 지운 카드의 원본에서 id 만 빼고 uid · 주소록(그룹은 구성원도)만 바꿔 그대로 만든다 —
+ * 엔진은 읽은 카드 전체를 그대로 다시 받는다(id 말고 빼야 할 칸 없음 · 같은 주소록 안의 같은 uid 는 invalidProperties uid — 2026-10-29 확인)
+ *
+ * 고치기(계약 3절 "고치기"): 바뀐 칸만, 지금 카드와 합쳐 그 칸을 통째로 쓴다(맨 위 칸 — 엔진은 남의 속성을 실은 칸을 그대로 받는다, 2026-10-29 확인).
+ * - 이메일 · 전화: 보낸 주소(대소문자 무시) · 번호(같은 글 → 같은 숫자)를 지금 항목과 짝지어 그 항목의 다른 속성(label · 다른 contexts · fax 같은 features ·
+ * pref)을 남기고, 종류를 바꾼 것만 우리 값(contexts work · private, features.mobile)으로 고친다. 대표 주소 `pref: 1` 은 첫 주소에만 —
+ * 다른 pref 는 그대로 두되 1 이거나 차례를 바꾸게 되면 뺀다(읽는 차례가 보낸 차례와 같게)
+ * - 직책: `kind: title`(role 이 아닌) 첫 항목 하나만. 회사 · 부서: 첫 조직의 이름과 첫 units 만 (둘 다 비고 남은 것이 없으면 그 조직을 뺀다).
+ * 메모: 여러 개였으면 하나로. 이름: name 을 `{ full }` 로 — 이름 조각(components)과 그에 딸린 칸은 지운다
+ */
+
+const JSCONTACT_TYPE = 'Card';
+const JSCONTACT_VERSION = '1.0';
+const GROUP_KIND = 'group';
+const INDIVIDUAL_KIND = 'individual';
+/** 직책 칸의 종류 — role(역할)은 직책이 아니다 */
+const ROLE_TITLE_KIND = 'role';
+const TITLE_KIND = 'title';
+/** 이름 조각의 종류 — 조각 사이에 넣는 글 */
+const SEPARATOR_KIND = 'separator';
+/** 객체의 종류 표시 — 이것만 남은 조직은 빈 조직이다 */
+const TYPE_PROPERTY = '@type';
+
+/** 종류를 나타내는 contexts · features 의 키 — 종류를 바꿀 때 이것만 우리 값으로 바꾸고 다른 키는 둔다 */
+const WORK_CONTEXT = 'work';
+const PRIVATE_CONTEXT = 'private';
+const MOBILE_FEATURE = 'mobile';
+
+/** 새 항목의 키 앞머리 — e1 · p1 · o1 · t1 · n1 */
+const EMAIL_KEY_PREFIX = 'e';
+const PHONE_KEY_PREFIX = 'p';
+const ORGANIZATION_KEY_PREFIX = 'o';
+const TITLE_KEY_PREFIX = 't';
+const NOTE_KEY_PREFIX = 'n';
+
+/** 대표 주소의 pref */
+const PRIMARY_PREF = 1;
+
+/** 제어 문자 (C0 · DEL · C1) */
+// eslint-disable-next-line no-control-regex
+const CONTROL_CHARACTERS = /[\u0000-\u001f\u007f-\u009f]+/g;
+/** 메모에서 줄바꿈 말고 다른 제어 문자 */
+// eslint-disable-next-line no-control-regex
+const NOTE_CONTROL_CHARACTERS = /[\u0000-\u0009\u000b-\u001f\u007f-\u009f]+/g;
+const TEL_SCHEME = /^tel:/i;
+const NON_DIGITS = /\D+/g;
+/** 한글 음절 — 이름 조각을 붙여 쓸지 정한다 */
+const HANGUL_SYLLABLE_START = 0xac00;
+const HANGUL_SYLLABLE_END = 0xd7a3;
+
+type JsonObject = Record;
+
+function isRecord(value: unknown): value is JsonObject {
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
+}
+
+/** 한 줄 글 — 문자열이 아니거나 비면 null */
+function singleLine(value: unknown): string | null {
+ if (typeof value !== 'string') return null;
+ const text = value.replace(CONTROL_CHARACTERS, ' ').trim();
+ return text === '' ? null : text;
+}
+
+/** 여러 줄 글 (메모) — 줄바꿈은 \n 으로 맞추고 앞뒤 공백을 뺀다 */
+function multiLine(value: unknown): string | null {
+ if (typeof value !== 'string') return null;
+ const text = value
+ .replace(/\r\n?/g, '\n')
+ .replace(NOTE_CONTROL_CHARACTERS, ' ')
+ .trim();
+ return text === '' ? null : text;
+}
+
+function prefOf(entry: JsonObject): number {
+ return typeof entry.pref === 'number' && Number.isFinite(entry.pref)
+ ? entry.pref
+ : Number.POSITIVE_INFINITY;
+}
+
+/** 맵 칸(emails 등)의 항목 — 객체인 것만, pref(작을수록 먼저) → 엔진이 준 차례 */
+function preferredEntries(map: unknown): [string, JsonObject][] {
+ if (!isRecord(map)) return [];
+ return Object.entries(map)
+ .filter((entry): entry is [string, JsonObject] => isRecord(entry[1]))
+ .map((entry, index) => ({ entry, index }))
+ .sort(
+ (a, b) => prefOf(a.entry[1]) - prefOf(b.entry[1]) || a.index - b.index,
+ )
+ .map(({ entry }) => entry);
+}
+
+function flag(map: unknown, key: string): boolean {
+ return isRecord(map) && map[key] === true;
+}
+
+function isHangulSyllable(char: string | undefined): boolean {
+ const code = char?.charCodeAt(0) ?? 0;
+ return code >= HANGUL_SYLLABLE_START && code <= HANGUL_SYLLABLE_END;
+}
+
+/** 조각 사이의 기본 글 — 한글 음절끼리는 붙여 쓴다(김 + 아라 → 김아라), 그 밖은 띄어쓰기 */
+function joinerBetween(previous: string, next: string): string {
+ return isHangulSyllable(previous.at(-1)) && isHangulSyllable(next[0])
+ ? ''
+ : ' ';
+}
+
+/** 이름 조각을 이어 붙인다 — separator 조각이 있으면 그 글, 없으면 defaultSeparator, 그것도 없으면 joinerBetween */
+function joinNameComponents(name: JsonObject): string | null {
+ if (!Array.isArray(name.components)) return null;
+ const defaultSeparator =
+ typeof name.defaultSeparator === 'string' ? name.defaultSeparator : null;
+ let text = '';
+ let previous: string | null = null;
+ let separator: string | null = null;
+ for (const component of name.components) {
+ if (!isRecord(component)) continue;
+ if (component.kind === SEPARATOR_KIND) {
+ if (typeof component.value === 'string') {
+ separator = (separator ?? '') + component.value;
+ }
+ continue;
+ }
+ const part = singleLine(component.value);
+ if (part === null) continue;
+ if (previous !== null) {
+ text += separator ?? defaultSeparator ?? joinerBetween(previous, part);
+ }
+ text += part;
+ previous = part;
+ separator = null;
+ }
+ return singleLine(text);
+}
+
+function nameOf(name: unknown): string | null {
+ if (!isRecord(name)) return null;
+ return singleLine(name.full) ?? joinNameComponents(name);
+}
+
+function emailLabelOf(contexts: unknown): ContactEmailLabel {
+ if (flag(contexts, WORK_CONTEXT)) return 'work';
+ if (flag(contexts, PRIVATE_CONTEXT)) return 'personal';
+ return 'other';
+}
+
+function phoneLabelOf(entry: JsonObject): ContactPhoneLabel {
+ if (flag(entry.features, MOBILE_FEATURE)) return 'mobile';
+ if (flag(entry.contexts, WORK_CONTEXT)) return 'work';
+ if (flag(entry.contexts, PRIVATE_CONTEXT)) return 'home';
+ return 'other';
+}
+
+/** 읽은 주소 — 한 줄 글 */
+function emailAddressOf(entry: JsonObject): string | null {
+ return singleLine(entry.address);
+}
+
+/** 읽은 번호 — 한 줄 글에서 `tel:` 을 뗀 것 */
+function phoneNumberOf(entry: JsonObject): string | null {
+ const raw = singleLine(entry.number);
+ return raw === null ? null : singleLine(raw.replace(TEL_SCHEME, ''));
+}
+
+function emailsOf(map: unknown): ContactEmail[] {
+ return preferredEntries(map).flatMap(([, entry]) => {
+ const address = emailAddressOf(entry);
+ return address === null
+ ? []
+ : [{ address, label: emailLabelOf(entry.contexts) }];
+ });
+}
+
+function phonesOf(map: unknown): ContactPhone[] {
+ return preferredEntries(map).flatMap(([, entry]) => {
+ const number = phoneNumberOf(entry);
+ return number === null ? [] : [{ number, label: phoneLabelOf(entry) }];
+ });
+}
+
+/** 첫째 조직 — 객체인 첫 값 (키와 함께) */
+function firstOrganization(map: unknown): [string, JsonObject] | undefined {
+ if (!isRecord(map)) return undefined;
+ return Object.entries(map).find((entry): entry is [string, JsonObject] =>
+ isRecord(entry[1]),
+ );
+}
+
+function unitName(unit: unknown): string | null {
+ return singleLine(isRecord(unit) ? unit.name : unit);
+}
+
+/** 소속 — 첫째 조직의 이름(회사)과 첫째 부서 */
+function organizationOf(map: unknown): {
+ company: string | null;
+ department: string | null;
+} {
+ const first = firstOrganization(map)?.[1];
+ if (!first) return { company: null, department: null };
+ const unit: unknown = Array.isArray(first.units) ? first.units[0] : undefined;
+ return { company: singleLine(first.name), department: unitName(unit) };
+}
+
+/** 직책으로 읽는 항목 — role 이 아니고 이름이 있는 첫째 (키와 함께) */
+function titleEntry(map: unknown): [string, JsonObject] | undefined {
+ if (!isRecord(map)) return undefined;
+ return Object.entries(map).find(
+ (entry): entry is [string, JsonObject] =>
+ isRecord(entry[1]) &&
+ entry[1].kind !== ROLE_TITLE_KIND &&
+ singleLine(entry[1].name) !== null,
+ );
+}
+
+function titleOf(map: unknown): string | null {
+ return singleLine(titleEntry(map)?.[1].name);
+}
+
+function noteOf(map: unknown): string | null {
+ if (!isRecord(map)) return null;
+ const notes = Object.values(map)
+ .filter(isRecord)
+ .map((entry) => multiLine(entry.note))
+ .filter((note): note is string => note !== null);
+ return notes.length > 0 ? notes.join('\n') : null;
+}
+
+function membersOf(map: unknown): string[] {
+ if (!isRecord(map)) return [];
+ return Object.entries(map)
+ .filter(([uid, member]) => member === true && uid.trim() !== '')
+ .map(([uid]) => uid);
+}
+
+function uidOf(value: unknown): string | null {
+ return typeof value === 'string' && value.trim() !== '' ? value : null;
+}
+
+/** 엔진의 카드 하나를 읽는다 — 카드가 아니거나 id 가 없으면 null */
+export function readContactCard(
+ card: JmapContactCard | JsonObject | null | undefined,
+): AddressBookCard | null {
+ if (
+ typeof card !== 'object' ||
+ card === null ||
+ typeof card.id !== 'string' ||
+ card.id === ''
+ ) {
+ return null;
+ }
+ const uid = uidOf(card.uid);
+
+ if (card.kind === GROUP_KIND) {
+ return {
+ kind: 'group',
+ card: {
+ id: card.id,
+ uid,
+ name: nameOf(card.name) ?? '',
+ memberUids: membersOf(card.members),
+ },
+ };
+ }
+ return {
+ kind: 'contact',
+ card: {
+ id: card.id,
+ uid,
+ name: nameOf(card.name),
+ emails: emailsOf(card.emails),
+ phones: phonesOf(card.phones),
+ ...organizationOf(card.organizations),
+ title: titleOf(card.titles),
+ note: noteOf(card.notes),
+ },
+ };
+}
+
+// ── 만들기 ───────────────────────────────────────────────
+
+function emailContexts(label: ContactEmailLabel): JsonObject | undefined {
+ if (label === 'work') return { [WORK_CONTEXT]: true };
+ if (label === 'personal') return { [PRIVATE_CONTEXT]: true };
+ return undefined;
+}
+
+function newEmailEntry(email: ContactEmail): JsonObject {
+ const contexts = emailContexts(email.label);
+ return { address: email.address, ...(contexts ? { contexts } : {}) };
+}
+
+function newPhoneEntry(phone: ContactPhone): JsonObject {
+ return { number: phone.number, ...phoneTypeKeys(phone.label) };
+}
+
+/** 전화 종류의 우리 값 — 휴대전화는 features.mobile, 회사 · 집은 contexts work · private */
+function phoneTypeKeys(label: ContactPhoneLabel): {
+ features?: JsonObject;
+ contexts?: JsonObject;
+} {
+ if (label === 'mobile') return { features: { [MOBILE_FEATURE]: true } };
+ if (label === 'work') return { contexts: { [WORK_CONTEXT]: true } };
+ if (label === 'home') return { contexts: { [PRIVATE_CONTEXT]: true } };
+ return {};
+}
+
+function newOrganization(
+ company: string | null,
+ department: string | null,
+): JsonObject {
+ return {
+ ...(company !== null ? { name: company } : {}),
+ ...(department !== null ? { units: [{ name: department }] } : {}),
+ };
+}
+
+/** 새 연락처의 우리 칸 (빈 칸은 빼고) */
+function newContactProperties(fields: ContactFields): JsonObject {
+ const properties: JsonObject = {};
+ if (fields.name !== null) properties.name = { full: fields.name };
+ if (fields.emails.length > 0) {
+ properties.emails = Object.fromEntries(
+ fields.emails.map((email, index) => [
+ `${EMAIL_KEY_PREFIX}${index + 1}`,
+ {
+ ...newEmailEntry(email),
+ // 대표 주소 — 다른 프로그램도 이것을 먼저 보인다
+ ...(index === 0 ? { pref: PRIMARY_PREF } : {}),
+ },
+ ]),
+ );
+ }
+ if (fields.phones.length > 0) {
+ properties.phones = Object.fromEntries(
+ fields.phones.map((phone, index) => [
+ `${PHONE_KEY_PREFIX}${index + 1}`,
+ newPhoneEntry(phone),
+ ]),
+ );
+ }
+ if (fields.company !== null || fields.department !== null) {
+ properties.organizations = {
+ [`${ORGANIZATION_KEY_PREFIX}1`]: newOrganization(
+ fields.company,
+ fields.department,
+ ),
+ };
+ }
+ if (fields.title !== null) {
+ properties.titles = {
+ [`${TITLE_KEY_PREFIX}1`]: { name: fields.title, kind: TITLE_KIND },
+ };
+ }
+ if (fields.note !== null) {
+ properties.notes = { [`${NOTE_KEY_PREFIX}1`]: { note: fields.note } };
+ }
+ return properties;
+}
+
+function membersJson(uids: readonly string[]): Record | null {
+ return uids.length === 0
+ ? null
+ : Object.fromEntries(uids.map((uid) => [uid, true as const]));
+}
+
+/** 지운 카드의 원본을 읽는다 — 우리가 쓴 글만 받는다(아니면 서버 안의 잘못) */
+function parseOriginal(original: RemovedCardOriginal): JsonObject {
+ let parsed: unknown;
+ try {
+ parsed = JSON.parse(original.source);
+ } catch {
+ parsed = null;
+ }
+ if (!isRecord(parsed)) {
+ throw new BusinessException('MAIL_001', {
+ context: { reason: 'restore-original-malformed' },
+ });
+ }
+ return parsed;
+}
+
+/** 만들 카드 — 기본 주소록에 (ContactCard/set 의 create) */
+export function newCardJson(
+ addressBookId: string,
+ card: NewAddressBookCard,
+): JsonObject {
+ const addressBookIds = { [addressBookId]: true };
+ if (card.kind === 'restored') {
+ // 원본 그대로 — 엔진이 주는 id 만 빼고 uid · 주소록(그룹은 구성원도)을 바꾼다
+ const restored: JsonObject = { ...parseOriginal(card.original) };
+ delete restored.id;
+ restored.uid = card.uid;
+ restored.addressBookIds = addressBookIds;
+ if (card.memberUids !== null) {
+ const members = membersJson(card.memberUids);
+ if (members === null) delete restored.members;
+ else restored.members = members;
+ }
+ return restored;
+ }
+ const base = {
+ '@type': JSCONTACT_TYPE,
+ version: JSCONTACT_VERSION,
+ uid: card.uid,
+ addressBookIds,
+ };
+ if (card.kind === 'group') {
+ const members = membersJson(card.memberUids);
+ return {
+ ...base,
+ kind: GROUP_KIND,
+ name: { full: card.name },
+ ...(members !== null ? { members } : {}),
+ };
+ }
+ return {
+ ...base,
+ kind: INDIVIDUAL_KIND,
+ ...newContactProperties(card.fields),
+ };
+}
+
+/** 지운 카드의 원본 — 되돌리기가 그대로 다시 만든다 (엔진 카드 전체) */
+export function cardOriginal(card: JsonObject): RemovedCardOriginal {
+ const source = JSON.stringify(card);
+ return { source, bytes: Buffer.byteLength(source, 'utf8') };
+}
+
+// ── 고치기 ───────────────────────────────────────────────
+
+/** 이미 쓴 키와 겹치지 않는 새 키 — 앞머리 + 1, 2, … */
+function freshKeys(prefix: string, used: Set): () => string {
+ let next = 1;
+ return () => {
+ while (used.has(`${prefix}${next}`)) next += 1;
+ const key = `${prefix}${next}`;
+ used.add(key);
+ return key;
+ };
+}
+
+/** 키를 뺀 객체 — 비면 undefined */
+function without(
+ map: unknown,
+ keys: readonly string[],
+): JsonObject | undefined {
+ if (!isRecord(map)) return undefined;
+ const rest = Object.fromEntries(
+ Object.entries(map).filter(([key]) => !keys.includes(key)),
+ );
+ return Object.keys(rest).length > 0 ? rest : undefined;
+}
+
+/** 객체에 값을 넣거나(값이 있으면) 뺀다(undefined) */
+function assign(target: JsonObject, key: string, value: unknown): void {
+ if (value === undefined) delete target[key];
+ else target[key] = value;
+}
+
+/** 다른 contexts 는 두고 종류(work · private)만 우리 값으로 */
+function relabeledEmail(entry: JsonObject, label: ContactEmailLabel): void {
+ const ours = emailContexts(label);
+ const rest = without(entry.contexts, [WORK_CONTEXT, PRIVATE_CONTEXT]);
+ const contexts = rest || ours ? { ...rest, ...ours } : undefined;
+ assign(entry, 'contexts', contexts);
+}
+
+/** 다른 features · contexts 는 두고 종류(features.mobile · contexts work · private)만 우리 값으로 */
+function relabeledPhone(entry: JsonObject, label: ContactPhoneLabel): void {
+ const ours = phoneTypeKeys(label);
+ const features = without(entry.features, [MOBILE_FEATURE]);
+ const contexts = without(entry.contexts, [WORK_CONTEXT, PRIVATE_CONTEXT]);
+ assign(
+ entry,
+ 'features',
+ features || ours.features ? { ...features, ...ours.features } : undefined,
+ );
+ assign(
+ entry,
+ 'contexts',
+ contexts || ours.contexts ? { ...contexts, ...ours.contexts } : undefined,
+ );
+}
+
+/**
+ * pref 를 보낸 차례에 맞춘다 — first 가 있으면 첫 항목은 그 값(대표 주소 1)이고 다른 항목의 그 값은 뺀다.
+ * 나머지 pref 는 앞 항목보다 작지 않을 때만 남긴다(pref 가 없는 항목 뒤의 pref 도 뺀다) — 읽는 차례(pref → 맵 차례)가 보낸 차례와 같게
+ */
+function orderPrefs(
+ entries: readonly JsonObject[],
+ first: number | null,
+): void {
+ /** 앞 항목의 pref — 아직 없으면 null, 앞에 pref 없는 항목이 있으면 +∞ */
+ let last: number | null = null;
+ entries.forEach((entry, index) => {
+ if (index === 0 && first !== null) {
+ entry.pref = first;
+ last = first;
+ return;
+ }
+ const pref = prefOf(entry);
+ const keep =
+ Number.isFinite(pref) &&
+ pref !== first &&
+ (last === null || pref >= last);
+ if (keep) {
+ last = pref;
+ } else {
+ delete entry.pref;
+ last = Number.POSITIVE_INFINITY;
+ }
+ });
+}
+
+/** 보낸 것마다 짝이 되는 지금 항목을 찾는다 — 한 항목은 한 번만. matchers 차례로(앞의 것이 더 엄격하다) */
+function pairEntries(
+ sent: readonly T[],
+ current: [string, JsonObject][],
+ matchers: readonly ((item: T, entry: JsonObject) => boolean)[],
+): ([string, JsonObject] | undefined)[] {
+ const taken = new Set();
+ const pairs: ([string, JsonObject] | undefined)[] = sent.map(() => undefined);
+ for (const matches of matchers) {
+ sent.forEach((item, index) => {
+ if (pairs[index]) return;
+ const found = current.find(
+ ([key, entry]) => !taken.has(key) && matches(item, entry),
+ );
+ if (found) {
+ taken.add(found[0]);
+ pairs[index] = found;
+ }
+ });
+ }
+ return pairs;
+}
+
+function emailKey(address: string): string {
+ return address.trim().toLowerCase();
+}
+
+function mergedEmails(
+ current: unknown,
+ emails: readonly ContactEmail[],
+): JsonObject | null {
+ if (emails.length === 0) return null;
+ const entries = preferredEntries(current);
+ const pairs = pairEntries(emails, entries, [
+ (email, entry) => emailAddressOf(entry) === email.address,
+ (email, entry) => {
+ const address = emailAddressOf(entry);
+ return address !== null && emailKey(address) === emailKey(email.address);
+ },
+ ]);
+ const keyOf = freshKeys(
+ EMAIL_KEY_PREFIX,
+ new Set(pairs.flatMap((pair) => (pair ? [pair[0]] : []))),
+ );
+ const merged = emails.map((email, index): [string, JsonObject] => {
+ const pair = pairs[index];
+ if (!pair) return [keyOf(), newEmailEntry(email)];
+ const entry = { ...pair[1] };
+ if (emailAddressOf(entry) !== email.address) entry.address = email.address;
+ if (emailLabelOf(entry.contexts) !== email.label) {
+ relabeledEmail(entry, email.label);
+ }
+ return [pair[0], entry];
+ });
+ orderPrefs(
+ merged.map(([, entry]) => entry),
+ PRIMARY_PREF,
+ );
+ return Object.fromEntries(merged);
+}
+
+function digitsOf(text: string): string {
+ return text.replace(NON_DIGITS, '');
+}
+
+function mergedPhones(
+ current: unknown,
+ phones: readonly ContactPhone[],
+): JsonObject | null {
+ if (phones.length === 0) return null;
+ const entries = preferredEntries(current);
+ const pairs = pairEntries(phones, entries, [
+ (phone, entry) => phoneNumberOf(entry) === phone.number,
+ (phone, entry) => {
+ const number = phoneNumberOf(entry);
+ const digits = digitsOf(phone.number);
+ return number !== null && digits !== '' && digitsOf(number) === digits;
+ },
+ ]);
+ const keyOf = freshKeys(
+ PHONE_KEY_PREFIX,
+ new Set(pairs.flatMap((pair) => (pair ? [pair[0]] : []))),
+ );
+ const merged = phones.map((phone, index): [string, JsonObject] => {
+ const pair = pairs[index];
+ if (!pair) return [keyOf(), newPhoneEntry(phone)];
+ const entry = { ...pair[1] };
+ // 보인 번호를 그대로 돌려받았으면 엔진의 글(tel: 등)을 그대로 둔다
+ if (phoneNumberOf(entry) !== phone.number) entry.number = phone.number;
+ if (phoneLabelOf(entry) !== phone.label) relabeledPhone(entry, phone.label);
+ return [pair[0], entry];
+ });
+ orderPrefs(
+ merged.map(([, entry]) => entry),
+ null,
+ );
+ return Object.fromEntries(merged);
+}
+
+/** 회사 · 부서 — 첫 조직의 이름과 첫 units 만. 둘 다 비고 남은 것이 없으면 그 조직을 뺀다 */
+function mergedOrganizations(
+ current: unknown,
+ change: {
+ readonly company?: string | null;
+ readonly department?: string | null;
+ },
+): JsonObject | null {
+ const map: JsonObject = isRecord(current) ? { ...current } : {};
+ const first = firstOrganization(map);
+ if (first) {
+ const [key, organization] = first;
+ const next = { ...organization };
+ if (change.company !== undefined)
+ assign(next, 'name', change.company ?? undefined);
+ if (change.department !== undefined) {
+ const units: unknown[] = Array.isArray(next.units)
+ ? [...(next.units as unknown[])]
+ : [];
+ if (change.department === null) {
+ units.shift();
+ } else {
+ const unit = units[0];
+ units[0] = isRecord(unit)
+ ? { ...unit, name: change.department }
+ : { name: change.department };
+ }
+ assign(next, 'units', units.length > 0 ? units : undefined);
+ }
+ const remains = Object.keys(next).some((name) => name !== TYPE_PROPERTY);
+ if (remains) map[key] = next;
+ else delete map[key];
+ } else {
+ const company = change.company ?? null;
+ const department = change.department ?? null;
+ if (company !== null || department !== null) {
+ const keyOf = freshKeys(
+ ORGANIZATION_KEY_PREFIX,
+ new Set(Object.keys(map)),
+ );
+ map[keyOf()] = newOrganization(company, department);
+ }
+ }
+ return Object.keys(map).length > 0 ? map : null;
+}
+
+/** 직책 — 직책으로 읽는 항목 하나만 (role 등 다른 항목은 그대로) */
+function mergedTitles(
+ current: unknown,
+ title: string | null,
+): JsonObject | null {
+ const map: JsonObject = isRecord(current) ? { ...current } : {};
+ const entry = titleEntry(map);
+ if (entry) {
+ const [key, value] = entry;
+ if (title === null) delete map[key];
+ else map[key] = { ...value, name: title };
+ } else if (title !== null) {
+ const keyOf = freshKeys(TITLE_KEY_PREFIX, new Set(Object.keys(map)));
+ map[keyOf()] = { name: title, kind: TITLE_KIND };
+ }
+ return Object.keys(map).length > 0 ? map : null;
+}
+
+/** 메모 — 하나로 (여러 개였으면 첫 항목의 다른 속성을 남기고 글만 바꾼다) */
+function mergedNotes(current: unknown, note: string | null): JsonObject | null {
+ if (note === null) return null;
+ const first = isRecord(current)
+ ? Object.entries(current).find((entry): entry is [string, JsonObject] =>
+ isRecord(entry[1]),
+ )
+ : undefined;
+ return first
+ ? { [first[0]]: { ...first[1], note } }
+ : { [`${NOTE_KEY_PREFIX}1`]: { note } };
+}
+
+/**
+ * 연락처 고치기 — 바뀐 칸(fields 에 있는 것)만, 지금 카드(current — 그 칸들을 읽은 것)와 합쳐 그 칸을 통째로 쓰는 패치.
+ * 값이 null 인 칸은 카드에서 뺀다
+ */
+export function contactPatch(
+ current: JsonObject,
+ fields: Partial,
+): JsonObject {
+ const patch: JsonObject = {};
+ if (fields.name !== undefined) {
+ patch.name = fields.name === null ? null : { full: fields.name };
+ }
+ if (fields.emails !== undefined) {
+ patch.emails = mergedEmails(current.emails, fields.emails);
+ }
+ if (fields.phones !== undefined) {
+ patch.phones = mergedPhones(current.phones, fields.phones);
+ }
+ if (fields.company !== undefined || fields.department !== undefined) {
+ patch.organizations = mergedOrganizations(current.organizations, {
+ ...(fields.company !== undefined ? { company: fields.company } : {}),
+ ...(fields.department !== undefined
+ ? { department: fields.department }
+ : {}),
+ });
+ }
+ if (fields.title !== undefined) {
+ patch.titles = mergedTitles(current.titles, fields.title);
+ }
+ if (fields.note !== undefined) {
+ patch.notes = mergedNotes(current.notes, fields.note);
+ }
+ return patch;
+}
+
+/** 그룹 고치기 — 이름 · 구성원을 통째로 */
+export function groupPatch(
+ update: Extract,
+): JsonObject {
+ return {
+ ...(update.name !== undefined ? { name: { full: update.name } } : {}),
+ ...(update.memberUids !== undefined
+ ? { members: membersJson(update.memberUids) }
+ : {}),
+ };
+}
+
+/** 맨 위 칸 패치를 카드에 옮긴 모양 (null 은 칸을 뺀다) — 고친 뒤의 카드를 읽는 데 쓴다 */
+export function patchedCard(card: JsonObject, patch: JsonObject): JsonObject {
+ const next = { ...card };
+ for (const [key, value] of Object.entries(patch)) {
+ if (value === null) delete next[key];
+ else next[key] = value;
+ }
+ return next;
+}
diff --git a/backend/src/modules/mail/jmap/filter-run-cursor.spec.ts b/backend/src/modules/mail/jmap/filter-run-cursor.spec.ts
new file mode 100644
index 0000000..8fdaba7
--- /dev/null
+++ b/backend/src/modules/mail/jmap/filter-run-cursor.spec.ts
@@ -0,0 +1,174 @@
+import { BusinessException } from '@/common/errors/business.exception';
+import type { MailFilterRunRequest } from '@/modules/mail/mail.types';
+import {
+ decodeFilterRunCursor,
+ encodeFilterRunCursor,
+ MAX_FILTER_RUN_SECONDS,
+ type FilterRunPosition,
+ type FilterRunRule,
+} from './filter-run-cursor';
+
+const rule: FilterRunRule = {
+ match: 'any',
+ conditions: [
+ { field: 'from', operator: 'contains', value: '@hanbit-partners.co.kr' },
+ ],
+ actions: { moveToMailboxId: 'j', markAsRead: true, flag: false },
+};
+
+/** 2026-10-29T09:00:00Z (초) */
+const NINE = 1_793_264_400;
+
+function failureOf(run: () => unknown): Record | undefined {
+ try {
+ run();
+ } catch (error) {
+ if (!(error instanceof BusinessException)) throw error;
+ return { code: error.code, reason: error.context?.reason };
+ }
+ return undefined;
+}
+
+const forged = (payload: unknown) =>
+ Buffer.from(JSON.stringify(payload)).toString('base64url');
+
+/** 진짜 cursor 의 규칙 지문 — 손으로 만든 cursor 에 붙인다 */
+function hashOf(cursor: string): string {
+ return (
+ JSON.parse(Buffer.from(cursor, 'base64url').toString('utf8')) as {
+ r: string;
+ }
+ ).r;
+}
+
+describe('이미 받은 메일에 필터 적용의 cursor (계약 2절 "이미 받은 메일에 적용")', () => {
+ it('만든 자리를 그대로 되돌린다 — 위 경계 · 남긴 마지막 메일 · 그 받은 시각 (남긴 메일이 아직 없으면 anchor 는 null, 받은 시각만 있을 수 있다)', () => {
+ const positions: FilterRunPosition[] = [
+ { before: NINE + 1, anchor: 'seaaaaer', anchorTime: NINE - 60 },
+ { before: NINE + 1, anchor: null, anchorTime: NINE - 60 },
+ { before: NINE + 1, anchor: null, anchorTime: null },
+ { before: MAX_FILTER_RUN_SECONDS, anchor: 'm1', anchorTime: 0 },
+ ];
+ for (const position of positions) {
+ expect(
+ decodeFilterRunCursor(encodeFilterRunCursor(position, rule), rule),
+ ).toEqual(position);
+ }
+ });
+
+ it('URL 에 그대로 넣을 수 있는 글자만 쓰고 짧다 (요청 검증의 1,024자 안 — 가장 긴 메일 id · 가장 큰 시각이어도)', () => {
+ const cursor = encodeFilterRunCursor(
+ {
+ before: MAX_FILTER_RUN_SECONDS,
+ anchor: 'a'.repeat(255),
+ anchorTime: MAX_FILTER_RUN_SECONDS - 1,
+ },
+ rule,
+ );
+ expect(cursor).toMatch(/^[A-Za-z0-9_-]+$/);
+ expect(cursor.length).toBeLessThan(1_024);
+ });
+
+ it('다른 규칙의 cursor 는 VAL_001 — 묶는 방법 · 조건 · 할 일 어느 것이 달라도', () => {
+ const cursor = encodeFilterRunCursor(
+ { before: NINE, anchor: 'm1', anchorTime: NINE - 1 },
+ rule,
+ );
+ const others: FilterRunRule[] = [
+ { ...rule, match: 'all' },
+ {
+ ...rule,
+ conditions: [{ ...rule.conditions[0], value: '@hanbit-partners' }],
+ },
+ {
+ ...rule,
+ conditions: [{ ...rule.conditions[0], operator: 'notContains' }],
+ },
+ { ...rule, actions: { ...rule.actions, moveToMailboxId: 'k' } },
+ { ...rule, actions: { ...rule.actions, flag: true } },
+ ];
+ for (const other of others) {
+ expect(failureOf(() => decodeFilterRunCursor(cursor, other))).toEqual({
+ code: 'VAL_001',
+ reason: 'invalid-filter-run-cursor:other-rule',
+ });
+ }
+ });
+
+ it('규칙 객체의 키 순서 · 다른 속성(cursor 등)과는 상관없다 — 같은 규칙이면 같은 지문', () => {
+ const position = { before: NINE, anchor: 'm1', anchorTime: NINE - 1 };
+ const cursor = encodeFilterRunCursor(position, rule);
+ const reordered: MailFilterRunRequest = {
+ cursor: 'anything',
+ actions: { flag: false, markAsRead: true, moveToMailboxId: 'j' },
+ conditions: [
+ {
+ value: '@hanbit-partners.co.kr',
+ operator: 'contains',
+ field: 'from',
+ },
+ ],
+ match: 'any',
+ };
+ expect(decodeFilterRunCursor(cursor, reordered)).toEqual(position);
+ });
+
+ it('깨진 cursor 는 VAL_001 — base64url · JSON 이 아님 · 판 · 모양(시각의 형식 · 범위 · 차례, 메일 id 형식) · 너무 김', () => {
+ const r = hashOf(
+ encodeFilterRunCursor(
+ { before: NINE, anchor: 'm1', anchorTime: NINE - 1 },
+ rule,
+ ),
+ );
+ const good = { v: 2, b: NINE, a: 'm1', t: NINE - 1, r };
+ const cases: [string, string][] = [
+ ['%%%', 'not-json'],
+ [Buffer.from('not json').toString('base64url'), 'not-json'],
+ // 1판(자리 수로 이어 하던 것)은 받지 않는다
+ [forged({ v: 1, a: 'm1', p: 1, r }), 'shape'],
+ [forged({ ...good, v: 3 }), 'shape'],
+ // 위 경계 — 없음 · 0 · 음수 · 소수 · 글 · 범위 밖
+ [forged({ ...good, b: undefined }), 'shape'],
+ [forged({ ...good, b: 0, t: null, a: null }), 'shape'],
+ [forged({ ...good, b: -5 }), 'shape'],
+ [forged({ ...good, b: NINE + 0.5 }), 'shape'],
+ [forged({ ...good, b: String(NINE) }), 'shape'],
+ [forged({ ...good, b: MAX_FILTER_RUN_SECONDS + 1 }), 'shape'],
+ // 남긴 메일의 받은 시각 — 위 경계와 같거나 뒤 · 음수 · 소수 · 글 · 없음(anchor 가 있는데)
+ [forged({ ...good, t: NINE }), 'shape'],
+ [forged({ ...good, t: NINE + 10 }), 'shape'],
+ [forged({ ...good, t: -1 }), 'shape'],
+ [forged({ ...good, t: NINE - 0.5 }), 'shape'],
+ [forged({ ...good, t: String(NINE - 1) }), 'shape'],
+ [forged({ ...good, t: null }), 'shape'],
+ [forged({ ...good, t: undefined }), 'shape'],
+ // 메일 id 형식
+ [forged({ ...good, a: 'm/1' }), 'shape'],
+ [forged({ ...good, a: '' }), 'shape'],
+ [forged({ ...good, a: 7 }), 'shape'],
+ [forged({ ...good, a: undefined }), 'shape'],
+ // 지문 없음 · 글이 아님
+ [forged({ ...good, r: undefined }), 'shape'],
+ [forged({ ...good, r: 1 }), 'shape'],
+ [forged(null), 'shape'],
+ [forged([2, NINE]), 'shape'],
+ [forged('cursor'), 'shape'],
+ ['a'.repeat(1_025), 'too-long'],
+ ];
+ for (const [cursor, reason] of cases) {
+ expect(failureOf(() => decodeFilterRunCursor(cursor, rule))).toEqual({
+ code: 'VAL_001',
+ reason: `invalid-filter-run-cursor:${reason}`,
+ });
+ }
+ // 손으로 만들어도 모양 · 지문이 맞으면 받는다 (자리는 엔진에 묻는 시작점일 뿐 — 위 경계 아래의 받은편지함만 읽는다)
+ expect(decodeFilterRunCursor(forged(good), rule)).toEqual({
+ before: NINE,
+ anchor: 'm1',
+ anchorTime: NINE - 1,
+ });
+ expect(
+ decodeFilterRunCursor(forged({ ...good, a: null, t: null }), rule),
+ ).toEqual({ before: NINE, anchor: null, anchorTime: null });
+ });
+});
diff --git a/backend/src/modules/mail/jmap/filter-run-cursor.ts b/backend/src/modules/mail/jmap/filter-run-cursor.ts
new file mode 100644
index 0000000..057c3e1
--- /dev/null
+++ b/backend/src/modules/mail/jmap/filter-run-cursor.ts
@@ -0,0 +1,133 @@
+import { createHash } from 'crypto';
+
+import { BusinessException } from '@/common/errors/business.exception';
+import {
+ MAIL_ID_PATTERN,
+ MAX_MAIL_FILTER_RUN_CURSOR_LENGTH,
+} from '@/modules/mail/mail.constants';
+import type { MailFilterRunRequest } from '@/modules/mail/mail.types';
+
+/**
+ * 이미 받은 메일에 필터 적용의 이어 할 자리 (계약 2절 "이미 받은 메일에 적용"의 cursor — 클라이언트에게는 불투명 문자열, base64url JSON).
+ * 엔진의 조회 규칙(2026-10-29 확인)에 기대어 자리를 받은 시각으로 잡는다 — 받은 시각은 초 단위이고, `before` 는 미만(<)이며,
+ * 받은 시각이 같은 메일은 만든 차례로 나오고 표시를 바꾸거나 옮겼다 되돌려도 차례가 그대로다:
+ * - before(위 경계): 첫 요청이 본 받은편지함 맨 위 메일의 받은 시각 + 1초 — 모든 조회에 `before` 로 건다. 적용을 시작한 뒤 온 메일은 보지 않는다
+ * (새로 온 메일은 Sieve 가 규칙 차례대로 이미 판정했다 — 한 규칙으로 다시 판정하면 위 규칙의 "멈춤"을 건너뛴다)
+ * - anchor: 지금까지 확인하고 받은편지함에 **남긴**(옮기지 않은) 마지막 메일 — 다음 차례는 그 다음부터(`anchor` + 1) 읽는다
+ * - anchorTime: 그 메일의 받은 시각(초) — 그 메일이 그사이 받은편지함에서 빠졌으면(`anchorNotFound`) `before: anchorTime + 1초` 의 처음부터 읽는다
+ * (그 초의 메일을 다시 판정한다 — 건너뛰지 않는다). 그렇게 읽은 차례에 남긴 메일이 없으면 anchor 는 없고 anchorTime 만 남는다
+ * - 규칙의 지문: 묶는 방법 · 조건 · 할 일 — 다른 규칙의 cursor 를 섞어 쓰지 못하게 (충돌보다는 "다른 규칙" 감지가 목적이라 짧게 둔다)
+ * 시각은 UTC 의 초(epoch)다 — 해석할 때 범위(0 ~ 9999-12-31)를 본다. 깨졌거나 다른 규칙의 것이면 400 VAL_001 — 엔진에 묻기 전에 본다
+ */
+const CURSOR_VERSION = 2;
+
+/** 규칙 지문의 길이 */
+const RULE_HASH_LENGTH = 16;
+
+/** cursor 에 담는 시각의 끝 — JMAP UTCDate 의 끝(9999-12-31T23:59:59Z, 초) */
+export const MAX_FILTER_RUN_SECONDS = 253_402_300_799;
+
+/** 이어 할 자리 */
+export interface FilterRunPosition {
+ /** 위 경계(초) — 받은 시각이 이보다 앞선 메일만 본다 */
+ readonly before: number;
+ /** 받은편지함에 남긴 마지막 메일 — 없으면 null */
+ readonly anchor: string | null;
+ /** 그 메일(또는 빠진 그 메일)의 받은 시각(초) — 아직 없으면 null. anchor 가 있으면 늘 있다 */
+ readonly anchorTime: number | null;
+}
+
+/** 이 cursor 를 만든 규칙 — 묶는 방법 · 조건 · 할 일 */
+export type FilterRunRule = Pick<
+ MailFilterRunRequest,
+ 'match' | 'conditions' | 'actions'
+>;
+
+interface EncodedFilterRunCursor {
+ v: number;
+ b: number;
+ a: string | null;
+ t: number | null;
+ r: string;
+}
+
+/** 규칙의 지문 — 정해진 차례의 JSON 을 SHA-256 으로 (들어온 객체의 키 순서 · 다른 속성과 상관없다) */
+function ruleHash(rule: FilterRunRule): string {
+ const canonical = JSON.stringify([
+ rule.match,
+ rule.conditions.map(({ field, operator, value }) => [
+ field,
+ operator,
+ value,
+ ]),
+ [rule.actions.moveToMailboxId, rule.actions.markAsRead, rule.actions.flag],
+ ]);
+ return createHash('sha256')
+ .update(canonical, 'utf8')
+ .digest('base64url')
+ .slice(0, RULE_HASH_LENGTH);
+}
+
+function invalidCursor(reason: string): BusinessException {
+ return new BusinessException('VAL_001', {
+ context: { reason: `invalid-filter-run-cursor:${reason}` },
+ });
+}
+
+/** cursor 에 담을 수 있는 시각(초)인가 */
+function isSeconds(value: unknown): value is number {
+ return (
+ typeof value === 'number' &&
+ Number.isSafeInteger(value) &&
+ value >= 0 &&
+ value <= MAX_FILTER_RUN_SECONDS
+ );
+}
+
+export function encodeFilterRunCursor(
+ position: FilterRunPosition,
+ rule: FilterRunRule,
+): string {
+ const payload: EncodedFilterRunCursor = {
+ v: CURSOR_VERSION,
+ b: position.before,
+ a: position.anchor,
+ t: position.anchorTime,
+ r: ruleHash(rule),
+ };
+ return Buffer.from(JSON.stringify(payload)).toString('base64url');
+}
+
+/** cursor 를 해석한다 — 형식 · 범위가 틀리거나 다른 규칙의 것이면 VAL_001 */
+export function decodeFilterRunCursor(
+ cursor: string,
+ rule: FilterRunRule,
+): FilterRunPosition {
+ if (cursor.length > MAX_MAIL_FILTER_RUN_CURSOR_LENGTH) {
+ throw invalidCursor('too-long');
+ }
+
+ let parsed: unknown;
+ try {
+ parsed = JSON.parse(Buffer.from(cursor, 'base64url').toString('utf8'));
+ } catch {
+ throw invalidCursor('not-json');
+ }
+
+ const { v, b, a, t, r } = (parsed ?? {}) as Partial;
+ if (v !== CURSOR_VERSION || !isSeconds(b) || b === 0) {
+ throw invalidCursor('shape');
+ }
+ if (!(a === null || (typeof a === 'string' && MAIL_ID_PATTERN.test(a)))) {
+ throw invalidCursor('shape');
+ }
+ // anchor 가 있으면 그 받은 시각도 있다 — 위 경계보다 앞선다
+ if (!(t === null || (isSeconds(t) && t < b)) || (a !== null && t === null)) {
+ throw invalidCursor('shape');
+ }
+ if (typeof r !== 'string') throw invalidCursor('shape');
+
+ if (r !== ruleHash(rule)) throw invalidCursor('other-rule');
+
+ return { before: b, anchor: a, anchorTime: t };
+}
diff --git a/backend/src/modules/mail/jmap/filter-run-texts.spec.ts b/backend/src/modules/mail/jmap/filter-run-texts.spec.ts
new file mode 100644
index 0000000..1b9bce0
--- /dev/null
+++ b/backend/src/modules/mail/jmap/filter-run-texts.spec.ts
@@ -0,0 +1,1412 @@
+import { judgeFilter } from '@/modules/mail/mail-filter-match';
+import type { MailFilterField } from '@/modules/mail/mail.types';
+import {
+ bodyTextsOf,
+ estimatedBodyBytes,
+ filterRunProperties,
+ headerTextsOf,
+ textPartsOf,
+} from './filter-run-texts';
+import type { JmapEmail } from './jmap.types';
+
+/** 엔진 메일 — 원래 머리글 속성(`header:From:all` 등)이 더 붙는다 */
+type DeliveredEmail = JmapEmail & Record;
+
+/** 실배달 한 통과 규칙 하나 — 엔진(Sieve)이 새로 받은 그 메일에 내린 판정 */
+interface DeliveredCase {
+ readonly key: string;
+ readonly note: string;
+ readonly field: MailFilterField;
+ readonly value: string;
+ /** 엔진의 판정 — 규칙의 옮기기가 일어났는가. null 은 판정 불가(엔진이 스팸으로 분류해 규칙을 돌리지 않았다) */
+ readonly engine: boolean | null;
+ readonly email: DeliveredEmail;
+}
+
+/**
+ * 2026-10-29 개발 엔진(Stalwart 0.16.27)에 실제로 배달한 시험 메일 20통 — 규칙(견줄 곳 · 찾을 글)마다 엔진이 Sieve 로 판정한 결과와,
+ * 같은 메일을 JMAP 으로 읽은 응답(원래 머리글 · 부분 · 본문 값). 원래 머리글은 엔진이 준 그대로다(앞의 공백 · encoded-word).
+ * 받는 쪽 주소는 시험 계정(arasystem.test), 보낸 쪽은 example.com. p7smvs8xqfv 는 시험마다 붙인 표지다
+ */
+const DELIVERED: readonly DeliveredCase[] = [
+ {
+ key: 'B1',
+ note: 'HTML 만 — 보이는 글',
+ field: 'body',
+ value: 'VISp7smvs8xqfv',
+ engine: true,
+ email: {
+ id: 'seaaaaer',
+ mailboxIds: { a: true },
+ subject: 'B1 p7smvs8xqfv',
+ 'header:From:all': [' Probe '],
+ 'header:To:all': [' '],
+ 'header:Cc:all': [],
+ textBody: [{ partId: '0', type: 'text/html' }],
+ htmlBody: [{ partId: '0', type: 'text/html' }],
+ attachments: [],
+ bodyValues: { '0': { value: '보이는 VISp7smvs8xqfv
' } },
+ },
+ },
+ {
+ key: 'B2',
+ note: 'HTML 속성 값은 글이 아니다',
+ field: 'body',
+ value: 'ATTRp7smvs8xqfv',
+ engine: false,
+ email: {
+ id: 'siaaaaes',
+ mailboxIds: { a: true },
+ subject: 'B2 p7smvs8xqfv',
+ 'header:From:all': [' Probe '],
+ 'header:To:all': [' '],
+ 'header:Cc:all': [],
+ textBody: [{ partId: '0', type: 'text/html' }],
+ htmlBody: [{ partId: '0', type: 'text/html' }],
+ attachments: [],
+ bodyValues: {
+ '0': { value: 'x
y' },
+ },
+ },
+ },
+ {
+ key: 'B3',
+ note: 'x