Compare commits

..

11 Commits

Author SHA1 Message Date
ttipo bd2f55ad5a docs(rules): 8단계 부품 — 주소록 모듈 · DTO 검사 차례 · 맞추기 짝 · 확장 때 옮길 되돌리기 표
CI (Flutter) / Flutter (analyze · test) (pull_request) Successful in 21m9s
CI / Backend (lint · type-check · test · e2e · build) (pull_request) Failing after 7m21s
CI / Frontend (lint · type-check · test · build) (pull_request) Successful in 11m51s
CI / Infra (compose · Dockerfile · nginx) (pull_request) Successful in 20s
CI / 에러 코드 SSOT 동기화 검사 (pull_request) Successful in 10m20s
CI (Flutter) / Flutter (analyze · test) (push) Has been cancelled
CI / Backend (lint · type-check · test · e2e · build) (push) Has been cancelled
CI / Frontend (lint · type-check · test · build) (push) Has been cancelled
CI / Infra (compose · Dockerfile · nginx) (push) Has been cancelled
CI / 에러 코드 SSOT 동기화 검사 (push) Has been cancelled
nestjs: 주소록 행(캐시 · 크기 한도 · PATCH 병합 · 되돌리기 표), 받는 사람 q 맞추기가 frontend 와 짝이라는 것,
DTO 검사 차례(그냥 쌓으면 아래 것부터 돈다 — applyDecorators 로 형식 → 길이 → 모양), 엔진 사실(같은 id 를 한 요청의 destroy 에 두 번 넣지 않는다),
인스턴스를 늘릴 때 옮길 메모리 목록에 되돌리기 표.
vue3: 주소록 · 메일과 함께 행, 받는 사람 자동 완성이 초성도 서버에 묻는 것, ModalDialog medium, 8단계의 밀도 예외(새 토큰 없이 다시 씀).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 14:16:53 +09:00
ttipo e2c41d38bb feat(frontend): 주소록 화면 · 메일 연동
레일의 주소록 모듈 — 내 연락처 · 그룹 · 사내 목록(직원 · 공용 주소), 초성 찾기, 연락처 추가 · 고치기 창, 그룹 · 구성원 넣기 —
과 메일 연동(받는 사람 추천의 주소록 · 그룹, 연락처 · 그룹 · 사내의 [메일 쓰기], 읽기 화면 ⋯ 「보낸 사람을 주소록에 추가」)을 더한다.
디자인 README "8단계", 계약 3절.

리뷰로 고친 것:
- 쓰기 화면을 채우는 라우터 상태에 연 직원을 싣는다 — 같은 탭에서 다른 직원이 로그인한 뒤 뒤로 가면 앞 직원의 받는 사람이 채워졌다.
- [고치기]는 창을 열 때 새로 받고 바꾼 칸만 보낸다 — 받아 둔 값으로 다른 탭 · 휴대폰의 변경을 덮었다. 검사도 바꾼 칸만.
- [되돌리기]는 서버의 되돌리기 표로 — 다시 만들기로는 다른 프로그램의 칸 · uid 를 잃었다.
- 읽기 화면에서 연 연락처 추가 창을 메일 레이아웃으로 옮겼다 — 메일을 바꾸면 쓴 것이 확인 없이 사라졌다.
- 그룹을 이어 고르면 앞 그룹이 빠지던 것, 저장 중 창을 닫으면 거절이 묻히던 것, 구성원 넣기의 첫 쪽이 모두 구성원이면 막히던 것(excludeGroupId).
- 받는 사람 추천은 초성 · 조합 중 글자도 서버에 묻는다(서버가 같은 맞추기 — 같은 표본 표를 두 쪽 테스트에 둔다).
- 바꿀 때마다 불러온 쪽을 모두 다시 받지 않고 응답을 서버 차례로 그 자리에 넣는다.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 14:13:20 +09:00
ttipo 08cf31fbe6 feat(backend): 주소록 API — 연락처 · 그룹 · 사내 목록 · 되돌리기, 받는 사람 추천에 주소록
계약 3절. 내 주소록은 엔진의 JMAP Contacts(직원마다 기본 주소록)에, 사내 목록은 엔진 계정 목록을 사내 목록 계정으로만 읽는다.
엔진 검색은 낱말 단위이고 이름 정렬을 못 해서, 직원마다 주소록을 엔진 state 로 확인하는 캐시에 두고 서버가 찾고(초성 · 전화 숫자) 정렬한다.
쓰기는 직원마다 차례로 · ifInState 로 하고, 어긋나거나 엔진이 잠깐 거절하면 다시 읽어 세 번까지.

리뷰로 고친 것:
- 전화번호 정규식이 입력 길이의 제곱이었다(1MB 본문 하나로 약 24분 CPU) — 정규식을 나누고, DTO 검사를 applyDecorators 로 묶어 형식 → 길이 → 모양 차례로 돌린다.
- 고치기는 PATCH(보낸 칸만 검사) — 칸 안의 남의 속성(label · contexts · fax · pref · role · 둘째 조직)은 지금 카드와 합쳐 지킨다(CardDAV 와 함께 쓰려고).
- [되돌리기]는 지우기 직전에 읽은 카드 전체를 5분 기억했다가 같은 uid 로 다시 만든다(되돌리기 표 — 우리 규칙 밖의 값 · 다른 프로그램의 칸도 그대로).
- 큰 주소록이 캐시에 들지 못해 요청마다 엔진을 통째로 다시 읽었다 — 크기 한도 800만을 쓰기에 걸고 캐시의 한 사람 상한과 맞췄다.
- 받는 사람 추천의 q 를 화면과 같은 맞추기(초성 · 조합 중 글자)로 — 주소록이 크면 미리 받은 100줄이 주소록으로 차서 사내 · 최근 주소의 초성 찾기가 사라졌다.
- excludeGroupId · 사내 항목 하나 · q 254자, 판은 읽기 전에 물은 것으로, 한글 이름 조각은 붙여 잇는다.

에러 코드 CONTACT_001(같은 이름 그룹) · CONTACT_002(주소록 한도)를 더하고 클라이언트 사전을 생성했다.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 14:09:59 +09:00
ttipo 59420df1b9 feat(docker): mail setup — 사내 목록 계정 · 메일을 받지 않는 시스템 계정의 RCPT 거절
사내 목록은 엔진의 계정 목록(이름 · 주소)을 읽어야 하는데, 직원 역할은 그 권한을 일부러 막아 두었고 엔진 관리자는 그 권한을 내주지 못한다.
그래서 setup 이 복구 관리자로 돌 때 비밀번호 없는 읽기 전용 계정(MAIL_DIRECTORY_LOCAL_PART, 기본 mail-directory)을 만든다 —
백엔드는 게이트웨이 대리 접근으로만 쓴다. 메일을 받지 않게 역할에서 받기 권한을 빼고 저장 한도도 작게 둔다.

관리자 · 게이트웨이 · 사내 목록 계정은 메일을 받지 않는데, 엔진은 RCPT 를 받아 놓고 배달할 때 거절해 보낸 사람에게 반송했다(backscatter —
위조된 발신자에게 반송이 나가 서버 평판이 떨어진다). RCPT 단계 스크립트로 이 주소(+태그 꼴 포함)를 없는 주소처럼 550 으로 거절하고,
setup 이 끝날 때 RCPT 만 보내 스스로 확인한다. 다른 설정이 그 자리를 쓰고 있으면 건드리지 않고 알린다.
일반 관리자는 공용 주소(Group 계정)를 만들지 못한다는 것도 운영 배포 절차에 적었다 — 복구 관리자로 만든다.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 14:06:27 +09:00
ttipo 7615329c32 docs: 8단계(주소록) 계약과 디자인
주소록(내 연락처 · 그룹 · 사내 목록)과 메일 연동의 계약(3절)을 정하고, 확정한 캔버스 보드 셋(Contacts · ContactsEdit · ContactsMail)을 둔다.
리뷰(서버 · 화면)로 정한 것도 함께 적었다 — 되돌리기는 서버가 원본을 5분 기억했다가 그대로 다시 만들고,
고치기는 PATCH(바꾼 칸만 · 칸 안의 남의 속성을 지킨다), 받는 사람 추천의 q 는 쓰기 화면과 같은 맞추기,
주소록 크기 한도 800만(캐시가 통째로 기억할 수 있게), excludeGroupId · 사내 항목 하나 · q 254자, 쓰기 화면 채우기는 연 직원에게 묶는다.
엔진이 한 요청의 destroy 에 같은 id 가 두 번 있으면 통째로 serverUnavailable 로 거절하는 것도 적었다(2026-10-29 확인).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 14:05:36 +09:00
ttipo 7bf95c1335 docs(rules): 7단계 부품 — 필터 적용 · 모달 다시 띄우기 · 확장 때 옮길 잠금
CI / Frontend (lint · type-check · test · build) (pull_request) Successful in 11m19s
CI / Infra (compose · Dockerfile · nginx) (pull_request) Successful in 20s
CI / 에러 코드 SSOT 동기화 검사 (pull_request) Successful in 10m38s
CI (Flutter) / Flutter (analyze · test) (pull_request) Successful in 20m12s
CI / Backend (lint · type-check · test · e2e · build) (pull_request) Successful in 11m40s
- vue3 스킬: 이미 받은 메일에 필터 적용 행, 진행 알림 결과의 단추, ModalDialog 의 reshow(),
  모달 창의 나가기 확인(useDialogLeaveConfirm), 부재중 isActive 줄
- nestjs 스킬: mail-filter-runs 의 부품(판정 · 견줄 글 · HTML 글자 · cursor 2판 · 본문 상한)과
  엔진 Sieve 와 짝인 파일을 함께 고치고 실배달로 다시 맞추는 규칙, 인스턴스를 늘리면 옮길 잠금에 filterRuns

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 08:38:10 +09:00
ttipo aef67aa310 feat(frontend): 필터 고치기 창 — 이미 받은 메일에도 적용 · 나가기 확인, 부재중 동작 안 함 줄
- 고치기 창의 「받은편지함에 이미 있는 메일에도 적용」(창을 열 때마다 꺼짐): 저장한 뒤 cursor 가 끝날 때까지
  보내며 진행 알림을 고치고, 끝나면 그 자리에서 결과(적용한 수 · [{메일함} 보기], 멈추면 [다시 시도]로 이어서)
- 이미 받은 메일에 할 일이 없는 필터(지워진 메일함 · 받은편지함으로만 · 전달만)와 이 탭에서 적용 중에는 칸을 끈다.
  실패 알림은 응답이 온 때 세션이 그대로일 때만, 나아가지 못한 응답이 세 번이면 멈춘다
- 고치기 창의 나가기 확인([취소] · Esc · 다른 화면 · 탭 닫기 — 6단계의 한계를 고친다). 브라우저가 스스로 닫은 창은
  ModalDialog 의 reshow() 로 다시 띄우고 초점을 닫히기 전 자리로 돌려준다
- 부재중 화면: 켜 둔 자동 응답이 엔진에서 꺼져 있으면(isActive: false) 머리 아래 노란 줄
- 진행 알림의 결과에도 단추를 붙인다(useToast)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 08:38:01 +09:00
ttipo 6fd0911b86 feat(backend): 이미 받은 메일에 필터 적용 POST /api/mail-filter-runs · 부재중 isActive
본문의 규칙 하나로 받은편지함을 최근 메일부터 판정하고 표시 · 옮기기만 한다(전달 · 지우기 없음).
한 요청은 25초 안에서 하고 cursor 로 이어 하며, 같은 직원은 한 번에 하나다(진행 중이면 429 REQ_001).

- 판정(mail-filter-match.ts)은 엔진 Sieve(sieve-script.ts 의 FIELD_TESTS)를 그대로 옮겼다. 견줄 글은
  원래 머리글(mime-text.ts 의 decodeHeaderText)과 텍스트 · HTML · 텍스트 첨부(html/html-text.ts)에서 뽑는다.
  실엔진 배달 94통(HTML 75 · 머리글 19)을 표 테스트로 고정했다 — HTML5 에만 있는 이름 참조는 알려진 한계
- 이어 할 자리(cursor 2판)는 위 경계 · 남긴 마지막 메일과 그 받은 시각이다. 시작한 뒤 온 메일은 보지 않고,
  그 메일이 빠지면 그 초부터 다시 읽는다 — 리뷰가 가짜 엔진으로 재현한 건너뜀을 고쳤다
- 옮기기는 읽은 상태 그대로일 때만(ifInState), 본문 값은 부분마다 256KiB · 한 통 1MiB 까지
- 오래 걸리는 처리의 시간 규칙(BulkRun)을 jmap/bulk-run.ts 로 옮겨 메일함 처리와 함께 쓴다
- 부재중 GET · PUT 응답에 isActive — 엔진에서 우리 Sieve 스크립트가 꺼져 있으면 false(본문으로는 받지 않는다)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 08:37:51 +09:00
ttipo ab4124ba94 fix(docker): mail setup — 엔진 임시 업로드 한도를 첨부 합계의 20배로
엔진(Stalwart 0.16.27)은 계정마다 1시간 동안 올린 양을 50MB 까지만 받는다(x:Jmap 의 uploadQuota).
20MB 첨부 셋이면 그 직원의 첨부 올리기와 필터 · 부재중 저장(Sieve 스크립트도 업로드로 올린다)이
한 시간까지 429 REQ_001 로 막혔다(2026-10-29 개발 엔진에서 확인).

- setup 이 uploadQuota 를 첨부 합계 한도(MAIL_ATTACHMENT_TOTAL_MAX_SIZE_MB — 백엔드와 같은 계산)의
  20배 이상으로 올린다. 더 크게 둔 값은 내리지 않고, 개수 · 기간 · 한 번 크기는 그대로다
- 첨부 한도 env 를 바꾸면 setup 도 다시 돌린다(env 예시 · mail/README.md · docker 스킬 · env 규칙)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 08:35:20 +09:00
ttipo 5ddc849c11 docs: 7단계(이미 받은 메일에 필터 적용) 계약과 디자인
필터를 저장할 때 받은편지함에 이미 있는 메일에도 그 필터 하나를 적용한다. 엔진의 Sieve 는 받을 때만
돌고 엔진 검색은 낱말 단위라, 서버가 받은편지함을 읽어 Sieve 와 같은 규칙으로 판정한다.

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 08:03:42 +09:00
ttipo 188b9e84f4 test(backend): 이미지 정화 CPU 단언 — 입력 4MB 로 옛 경로와 한도 사이를 넓힌다
2MB 로 다시 재 보니 옛 경로(주소 없는 img 를 exclusiveFilter 로 지울 때마다 결과를 다시 자름)가
26~28초뿐이라 한도 15초와 2배도 차이 나지 않았다 — 빠른 PC 에서는 옛 경로로 돌아가도 통과할 수 있었다.
주석의 옛 기준(27만 개에 15초)은 벽시계로 잰 옛 값이었다.

입력을 4MB(66만 개)로 키우고 한도를 25초로 둔다 — 지금 경로는 부하 아래 2.0~2.4초(10~12배 여유),
옛 경로는 100~134초(Node 22 호스트) · 154초(Node 24 Linux 컨테이너)로 한도의 4배를 넘는다.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 00:54:53 +09:00
214 changed files with 37051 additions and 779 deletions
+4 -2
View File
@@ -24,10 +24,12 @@ description: 환경변수 단일 진실 공급원(SSOT) 구조 — 루트 .env.{
├─▶ redis : REDIS_PASSWORD, REDIS_DATA_DIR 볼륨
├─▶ uploads : 운영은 named volume <PROJECT_NAME>_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` 참조.
+8 -1
View File
@@ -122,6 +122,8 @@ sudo docker compose --env-file .env.production logs -f backend
**다른 프로젝트와 함께 쓰면 주소 목록을 권장한다** — `<EDGE_NETWORK_SUBNET>,<외부 프록시 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 `<PROJECT_NAME>_mail-config`·`<PROJECT_NAME>_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_LOCAL_PART>@도메인`(기본 `mail-directory`, compose 가 backend 에 넘긴다) — 백엔드가 엔진의 계정 목록에서 이름·주소만 읽는 비밀번호 없는 계정(게이트웨이 대리 접근으로만). 엔진 관리자는 그 권한을 내줄 수 없어 setup 이 **복구 관리자로 돌 때만** 만든다(개발은 늘, 운영은 `--recovery` — 이미 운영 중이면 한 번 더). 없으면 `[주의]` 만 내고 백엔드는 로그인한 직원 기록으로 대신한다 (mail/README.md "사내 목록 계정").
- ⚠️ **게이트웨이 비밀번호 = 관리자급 비밀.** Stalwart 0.16 은 대리 접근 대상을 제한하지 못해, 신뢰 대역 안에서는 게이트웨이로 관리자 계정도
대리 접근된다 (setup 이 매번 `[주의]` 로 보여 준다). 막는 일은 백엔드가 한다 — mail/README.md "보안".
File diff suppressed because one or more lines are too long
+9 -6
View File
@@ -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` | 네이티브 `<dialog>`(초점·Esc·닫힘 처리)와 모양. 세션 만료(`SessionExpiredDialog`)·나가기 확인(공용 `components/feedback/LeaveConfirmDialog.vue` — 쓰기 화면 `ComposeLeaveDialog.vue`·서명 설정 · 부재중 설정이 쓴다) · 메일함 대화상자(`components/mail/Mailbox*Dialog.vue` — 기본 슬롯에 이름 칸) · 필터 고치기 창(`components/settings/MailFilterDialog.vue` — 칸이 많은 창은 `wide`: 폭 620)이 쓴다 — 새 대화상자도 이것으로 만든다. 되돌릴 수 없는 일의 확인은 처음 초점을 「취소」에 둔다. 초점을 지키려고 `aria-disabled` 로 막은 단추에는 마우스를 올린 모양이 걸리지 않는다 |
| 토스트 | `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`). 결과에도 단추를 붙일 수 있다(`succeed(글, action)` · `fail(글, action)` — 7단계 필터 적용의 [{메일함} 보기] · [다시 시도], 시간 · 세션 묶기는 다른 단추와 같다). 진행 알림도 세션이 바뀌면 닫는다 |
| 모달 대화상자 | `src/components/feedback/ModalDialog.vue` | 네이티브 `<dialog>`(초점·Esc·닫힘 처리)와 모양. 세션 만료(`SessionExpiredDialog`)·나가기 확인(공용 `components/feedback/LeaveConfirmDialog.vue` — 쓰기 화면 `ComposeLeaveDialog.vue`·서명 설정 · 부재중 설정 · 필터 고치기 창 위(7단계)가 쓴다) · 메일함 대화상자(`components/mail/Mailbox*Dialog.vue` — 기본 슬롯에 이름 칸) · 필터 고치기 창(`components/settings/MailFilterDialog.vue` — 칸이 많은 창은 `wide`: 폭 620) · 주소록의 연락처 · 그룹 창(`medium`: 폭 400 — 8단계)이 쓴다 — 새 대화상자도 이것으로 만든다. 되돌릴 수 없는 일의 확인은 처음 초점을 「취소」에 둔다. 초점을 지키려고 `aria-disabled` 로 막은 단추에는 마우스를 올린 모양이 걸리지 않는다. Esc 는 `cancel` 로 알리기만 한다 — 브라우저가 막지 못하고 스스로 닫았는데(Esc 를 거듭 누름) 쓰는 쪽이 닫지 않기로 했으면 `reshow()`(ref 로 부른다)로 다시 띄운다. 초점은 열린 동안 `focusin` 으로 기억한 창 안의 마지막 요소로 돌려준다(브라우저는 `close` 전에 초점을 창 밖으로 옮긴다) |
| 외부 HTML 격리 렌더링 | `src/components/mail/MailBodyFrame.vue` · `src/shared/utils/mail-html.ts` | 메일 본문 등 외부 HTML 은 이 컴포넌트로만 그린다 (sandbox·내부 CSP 근거는 파일 머리말). 보여 줄 HTML 고르기는 `displayedHtmlBody()` 하나다 — null 이거나 공백뿐이면 textBody (읽기 화면·인용 공용, 서버의 `isBodyTruncated` 와 같은 규칙) |
| 메일 모듈 | `src/pages/mail/*` · `src/components/mail/*` | API 계약 `docs/mail/api-contract.md`, 디자인 원본 `docs/design/webmail/` |
| 콘텐츠 열 토큰 | `src/styles/tokens.css` 의 `--content-*` · `--content-toolbar-start` | 읽기·쓰기 화면이 같은 최대 폭·여백·32px 시작선을 쓴다 (새 화면도 이 토큰으로) |
| 밀도 예외 크기 | `src/styles/tokens.css` 의 `--size-compact-control` · `--size-segment-item` · `--size-segment-item-lg` · `--size-editor-tool` · `--size-editor-menu-item` · `--size-editor-swatch-cell` · `--size-editor-table-cell` · `--table-col-check` · `--size-row-check` · `--size-search-clear-width`/`-height` · `--size-notice-bar` · `--size-mailbox-more` · `--size-notice-action` · `--size-filter-control` · `--size-filter-remove` (주석에 "밀도 예외") | 클릭 영역 44px 기준의 의도된 예외(목록 표의 별·보기 전환, 설정 화면의 미리 보기 전환 36(`.segmented`)·서명 그림 크기 줄 36(작게·보통·원본·그림 빼기), 편집기 툴바 버튼 40×44·메뉴 항목 40·색 견본 40·표 고르기 칸 22, 3단계의 목록 표 고르기 칸 32·분할 행 고르기 칸 폭 36·검색 칸 ✕ 40×38·새 메일 알림 줄 40·검색 범위 전환 36·이동 메뉴 항목 40, 4단계의 메일함 행 ⋯ 32×40 · ⋯ 메뉴 항목 40, 5단계의 목록 위 알림 권하기 줄 40 · 줄 안의 단추 36, 6단계의 필터 조건 칸 40 · 조건 ✕ 36 · 필터 행 ▲▼ 32×40 · 켜기 스위치 누르는 곳 40×44 · 고치기 창 안 [다시 불러오기] 36)에만 쓴다 — 사용자 확정(캔버스). 그 밖의 `--size-editor-*`(툴바 높이·글꼴 단추·본문 최소 높이 등)는 예외가 아니라 보통 크기다 |
| 밀도 예외 크기 | `src/styles/tokens.css` 의 `--size-compact-control` · `--size-segment-item` · `--size-segment-item-lg` · `--size-editor-tool` · `--size-editor-menu-item` · `--size-editor-swatch-cell` · `--size-editor-table-cell` · `--table-col-check` · `--size-row-check` · `--size-search-clear-width`/`-height` · `--size-notice-bar` · `--size-mailbox-more` · `--size-notice-action` · `--size-filter-control` · `--size-filter-remove` (주석에 "밀도 예외") | 클릭 영역 44px 기준의 의도된 예외(목록 표의 별·보기 전환, 설정 화면의 미리 보기 전환 36(`.segmented`)·서명 그림 크기 줄 36(작게·보통·원본·그림 빼기), 편집기 툴바 버튼 40×44·메뉴 항목 40·색 견본 40·표 고르기 칸 22, 3단계의 목록 표 고르기 칸 32·분할 행 고르기 칸 폭 36·검색 칸 ✕ 40×38·새 메일 알림 줄 40·검색 범위 전환 36·이동 메뉴 항목 40, 4단계의 메일함 행 ⋯ 32×40 · ⋯ 메뉴 항목 40, 5단계의 목록 위 알림 권하기 줄 40 · 줄 안의 단추 36, 6단계의 필터 조건 칸 40 · 조건 ✕ 36 · 필터 행 ▲▼ 32×40 · 켜기 스위치 누르는 곳 40×44 · 고치기 창 안 [다시 불러오기] 36, 8단계의 패널 그룹 줄 ⋯ 32×40 · 연락처 창의 줄 · 칸 40 · 줄 ✕ 36×40 · 구성원 넣기 창 전환 36 · 찾기 칸 40 — 새 토큰 없이 위 것을 다시 쓴다, tokens.css 주석)에만 쓴다 — 사용자 확정(캔버스). 그 밖의 `--size-editor-*`(툴바 높이·글꼴 단추·본문 최소 높이 등)는 예외가 아니라 보통 크기다 |
| 사용자별 화면 설정 | `src/stores/mail-view.store.ts` | localStorage(키에 사용자 email, try/catch, 실패 시 기본값). 서버 설정 API 가 생기면 교체 |
| 화면 이동 규칙 | `src/composables/useMailNavigation.ts` | 이전/다음은 replace, "목록으로"는 뒤로 가기 또는 replace (기록이 쌓이지 않게) |
| 발송 키 규칙 | `src/shared/utils/idempotency-key.ts` 의 `shouldRenewSendKey()` | `Idempotency-Key` 는 성공과 `MAIL_005` 때만 바꾼다 — 그 밖의 실패 뒤 다시 보내기는 같은 키 (계약 2절 "발송 본문"). UUID 만들기는 `shared/utils/uuid.ts` 하나(필터 id 와 공용 — `crypto.randomUUID` 가 없으면 대체 경로) |
| 첨부 사전 검사 | `src/shared/utils/mail-attachment.ts` 의 `precheckAttachment()` | 형식·파일당·합계 한도(`mail.store` 의 `attachmentMaxBytes`·`attachmentTotalMaxBytes`, 서버 값이 없으면 대체값). 넘는 파일은 올리지 않는다 |
| 메일 본문 편집기 | `src/components/mail/editor/` — `MailEditor.vue` (쓰기 화면이 `defineAsyncComponent` 로 불러온다) | TipTap core (MIT 무료 패키지만, `@tiptap/*` 는 모두 같은 버전으로 고정). 툴바 배치·좁은 폭에서 접는 순서·단축키는 `docs/design/webmail/README.md` "메일 쓰기". 보낼 모양은 `mail-serialize.ts` — 서식 본문 `bodyHtml`(기본 글꼴 div, 인라인 style) + 텍스트 대체 본문 `body` (계약 2절 "서식 본문"). 답장·전달 초안 한도는 `fit-draft.ts`(처음 맞출 때 내 글 몫 5%를 남기고, 보내기 직전 `getContentForSend()` 가 다시 재서 인용을 더 줄인다 — 내 글만으로 넘으면 보내지 않고 칸 아래에 알린다), 붙여 넣거나 끌어다 놓은 파일은 첨부로(`shared/utils/editor-files.ts`, 화면 어디에 놓아도 — `composables/useWindowFileDrop.ts`). **읽어 들이는 style 값은 서버 정화와 같은 형식만** 받는다 — 규칙 `shared/utils/outgoing-html-rules.ts`(짝: backend `sanitize-outgoing-html.ts`, 같은 표본 표 `test-utils/outgoing-style-samples.ts` — 한쪽을 고치면 둘 다), 적용 `editor/mail-format-extensions.ts`. 편집기 청크를 못 받으면 `ComposeEditorLoadError.vue`("다시 시도"), 단축키 표기는 플랫폼별(`shared/utils/keyboard-shortcut.ts`). **그림은 서명 그림만** 받는다 — 노드 `editor/signature-image.ts`(쓰기 본문·서명 칸 공용, 규칙은 `outgoing-html-rules.ts` 의 그림 부분 — 짝: backend `html/signature-images.ts`·`keepSignatureImage`, 표본 `IMAGE_SAMPLES`). 메뉴(`editor/EditorMenu.vue` + `EditorPopup.vue` — 이동 ▾ · 메일함 ⋯ · 읽기 화면 ⋯ 도 같은 부품)의 꺼진 항목은 `disabled` 가 아니라 `aria-disabled` — 화살표로 가서 초점은 받되 고를 수 없다(WAI-ARIA 메뉴). 누를 수 없는 까닭은 항목의 `note`(이름 아래 작은 글, `aria-describedby`) |
| 임시 저장 | `src/composables/useDraftSaving.ts` · `components/mail/ComposeDraftStatus.vue` · `composables/useComposeDraftLink.ts`(주소 맞추기 · 옛 id 기록 · 보내지 못한 메일을 저장본으로) · `composables/useDraftTrash.ts`(휴지통 · 되돌리기) | 3초 멈춤 · 쉬지 않고 쓰면 30초 · 10초 간격 · 한 번에 하나(계약 2절 "임시 저장", 상수 `shared/constants/mail.ts` 의 `DRAFT_*`). `MAIL_008`·PUT 404 는 막지 않고 POST 로 새로. 찾지 못한 첨부가 남았거나 본문이 한도를 넘는 동안은 저장하지 않고, 실을 수 없는 첨부가 든 남의 저장본은 첫 저장을 POST 로(원본 보존). 보내기·버리기 전에는 `suspend`+`settle` — 보내기는 settle 뒤 첨부 상태를 다시 본다. 저장할 때마다 주소를 `?draft=<새 id>` 로 맞추고(떠나기 전 저장 제외), 옛 id 로 돌아오면 `mail.store` 의 `latestDraftId()` 로 마지막 저장본을 연다. 쓰기 화면 휴지통은 DELETE 가 아니라 PATCH(휴지통으로 이동) — 이미 휴지통인 메일은 DELETE 가 영구 삭제한다 |
| 받는 사람 자동 완성 | `src/composables/useRecipientSuggestions.ts` · `shared/utils/recipient-match.ts` · `components/mail/RecipientField.vue` · `RecipientSuggestionList.vue` | 검색어는 input · compositionupdate 의 `target.value`(한글 조합 중 글자 포함)로 따로 두고, 칩 확정은 v-model. Enter 등의 조합 판정은 `shared/utils/keyboard-shortcut.ts` 의 `isImeComposing()`(`isComposing` 또는 keyCode 229 — Safari · macOS) — 새 입력 칸의 Enter 처리도 이것을 쓴다. 쓰기 화면을 열 때 `limit=100` 한 번, 모자랄 때만 250ms 뒤 `q` 로(초성·자모만 친 글자는 묻지 않는다, 늦은 응답은 버린다). 초성 검색·맞은 글자 강조는 `recipient-match.ts`(강조는 `<strong>` 조각 — v-html 없음). combobox 접근성(aria-activedescendant · listbox · 조합 중 Enter 무시), 완전한 주소면 미리 고르지 않는다 |
| 받는 사람 자동 완성 | `src/composables/useRecipientSuggestions.ts` · `shared/utils/recipient-match.ts` · `components/mail/RecipientField.vue` · `RecipientSuggestionList.vue` | 검색어는 input · compositionupdate 의 `target.value`(한글 조합 중 글자 포함)로 따로 두고, 칩 확정은 v-model. Enter 등의 조합 판정은 `shared/utils/keyboard-shortcut.ts` 의 `isImeComposing()`(`isComposing` 또는 keyCode 229 — Safari · macOS) — 새 입력 칸의 Enter 처리도 이것을 쓴다. 쓰기 화면을 열 때 `limit=100` 한 번, 모자랄 때만 250ms 뒤 `q` 로(8단계부터 초성 · 조합 중 글자도 묻는다 — 서버의 q 가 같은 맞추기다: 짝 backend `modules/mail/recipient-match.ts`, 같은 표본 표 `test-utils/recipient-match-samples.ts` — 한쪽을 고치면 둘 다. 늦은 응답은 버린다). 초성 검색·맞은 글자 강조는 `recipient-match.ts`(강조는 `<strong>` 조각 — v-html 없음). combobox 접근성(aria-activedescendant · listbox · 조합 중 Enter 무시), 완전한 주소면 미리 고르지 않는다 |
| 메일 서명 | `src/stores/mail-signature.store.ts` · `components/mail/editor/signature/` · `shared/utils/mail-signature.ts` · `pages/settings/` | 서명은 메일 화면에 들어올 때 받아 두고 쓰기 화면이 넣는다(새 메일은 끝, 답장·전달은 인용 위 — 이어 쓰기는 다시 넣지 않는다). 설정 화면(`SettingsLayout.vue` 왼쪽 메뉴 — 묶음 · 항목은 `shared/constants/settings-menu.ts` + `MailSignaturePage.vue`)은 들어올 때마다 새로 받는다. 미리 보기는 편집기 스키마의 `DOMSerializer` 로 그린다. 그림 3장은 서명 칸 안의 그림 수로(서버 보관 상한 10장은 서버가 센다) |
| 메일 스토어 구성 | `src/stores/mail.store.ts`(조립 · 공개 API) + `src/stores/mail/` — `mailboxes.ts` · `list.ts` · `selection.ts` · `message.ts` · `bulk.ts` · `mailbox-admin.ts`(4단계 메일함 관리) | Pinia 스토어는 `useMailStore` 하나이고 기능별 setup 모듈을 조립한다(스토어를 여러 개로 나누지 않는다 — 서로 부르는 고리가 생긴다). 목록 출처는 `shared/utils/mail-list-source.ts`(메일함 · 검색). **늦게 온 응답 버리기**: 목록 맞추기는 이 탭의 목록 변경 세대(`localGeneration` — 읽음 · 중요 · 빼기 · 옮기기마다 오른다)가 그사이 바뀌었으면 응답을 버리고 한 번 더 맞춘다. 여러 통 처리 · 한 통 삭제는 시작할 때의 출처 순번(`sourceSeq`)과 다르면 목록을 건드리지 않고 개수만 맞춘다 |
| 새 메일 확인 · 탭 제목 | `src/composables/useMailStatePolling.ts`(메일 화면 `MailLayout` 이 시작) · `useListArrivalNotice.ts` + `components/mail/MailNewNotice.vue` · `useMailTabTitle.ts` + `shared/utils/document-title.ts` | 화면이 보이는 동안 60초마다 `GET /mail-state`, 탭으로 돌아오면 10초 지났을 때 바로, 한 번에 하나, 실패는 `silent`. 바뀌면 `syncCounts()` + 보이는 목록 맞추기(불러온 만큼 · 최대 100통, 검색 결과는 건너뜀). 목록을 내려 본 채면 "새 메일 N통 · 맨 위로" 줄. 받은편지함 안 읽음이 있으면 탭 제목 앞 `(N) ` (메일 라우트에서만) — 계약 2절 "새 메일 확인" |
@@ -53,12 +53,15 @@ description: Vue 3 프론트엔드(frontend/) 개발 규칙 — Composition API
| 실시간 테스트 도구 | `src/test-utils/event-stream.ts`(가짜 SSE 서버) · `notification.ts`(가짜 Notification) · `web-locks.ts`(가짜 `navigator.locks` · BroadcastChannel) | 실시간 · 알림 테스트는 이것들과 손으로 돌리는 시계(`manual-clock.ts`)로 — 진짜 타이머 · 고정 대기 없이 |
| 메일 검색 | `src/components/mail/MailSearchBox.vue`(상단 바의 search 슬롯) · `composables/useMailSearchNavigation.ts` · `MailSearchHeader.vue` · `MailSearchScope.vue` · `MailSearchEmpty.vue` · 라우트 `/mail/search` · `/mail/search/:messageId` | Enter 로만 찾는다(`isImeComposing()` 이면 무시), ✕ · Esc 는 검색을 시작한 메일함으로. 주소 `?q=…&in=<메일함 id \| all>` — 검색 화면 안의 다시 찾기 · 범위 바꾸기는 replace. 서버와 같은 한도(200자 · 낱말 8개)를 먼저 본다. 조사 처리 · 한계는 서버 몫(계약 2절 "검색") |
| 여러 통 처리 · 삭제 | `src/components/mail/MailCheckbox.vue` · `MailSelectionToolbar.vue`(+ `MailBulkButton` · `MailBulkMoveButton`) · `MailPurgeDialog.vue` · `MessageDeleteButton.vue` · `composables/useBulkMailActions.ts` · `useMessageRemoval.ts` · `useMailMoveFeedback.ts` · `shared/utils/mail-selection.ts` · `mail-bulk.ts` | 100통씩 차례로. 읽음 · 중요는 먼저 바꾸고 `failedIds` 만 되돌리고, 이동 · 삭제는 응답의 id 만 뺀다. **웹은 `DELETE /api/messages/{id}` 를 쓰지 않는다** — 휴지통 밖 삭제는 `PATCH` 이동(+ 되돌리기), 휴지통에서는 확인 대화상자(처음 초점 「취소」) 뒤 `POST /message-deletions`. 읽기 화면도 그 메일의 `mailboxIds` 로 같은 규칙을 탄다. 도구 줄은 role="toolbar" 로빙 tabindex(바쁜 · 꺼진 단추는 건너뛴다) — 계약 2절 "여러 통 한꺼번에" |
| 설정 › 비밀번호 · 부재중 (4단계) | `src/pages/settings/AccountPasswordPage.vue` · `MailVacationPage.vue` · `components/settings/`(`SettingsPageFrame` 공용 틀 — 머리 오른쪽 단추는 `actions` 슬롯 · `PasswordChangeForm` · `PasswordField` · `Vacation*`) · `stores/mail-vacation.store.ts` · `shared/utils/password-change.ts` · `mail-vacation.ts` · 메뉴 `shared/constants/settings-menu.ts` | 메뉴는 묶음(계정 › 비밀번호 / 메일 › 서명 · 부재중 자동 응답 · 필터(6단계) · 알림(5단계)), 라우트 이름은 `SETTINGS_ROUTES` 하나. 비밀번호는 서버와 같은 규칙을 먼저 보고, 서버 거절(`AUTH_005` · `VAL_001`)은 칸 아래에만(알림 없음) — 성공하면 세 칸을 비운다. 부재중은 날짜만 고르고 브라우저 시간대의 시작일 0시 · 끝나는 날 다음 날 0시를 UTC 로 보낸다(변환은 `mail-vacation.ts` 한 곳 — 되읽을 때는 1ms 앞 날짜). 켤 때만 검사하고 꺼 둔 채로는 빈 값도 저장한다(길이 150 · 600 도 켤 때만 — 꺼 둔 채로는 4단계 한도까지, 끄기는 늘 저장되게). 부재중 상태는 메일 화면에 들어올 때 받고, 패널 안내 `components/mail/MailVacationNotice.vue` 가 1분마다 다시 계산한다(`useClock` · `VACATION_NOTICE_REFRESH_MS`). 저장 응답은 세션 세대(`authStore.generation`)가 바뀌었으면 쓰지 않는다 — 계약 1절 · 2절 "부재중 자동 응답" |
| 설정 › 비밀번호 · 부재중 (4단계) | `src/pages/settings/AccountPasswordPage.vue` · `MailVacationPage.vue` · `components/settings/`(`SettingsPageFrame` 공용 틀 — 머리 오른쪽 단추는 `actions` 슬롯 · `PasswordChangeForm` · `PasswordField` · `Vacation*`) · `stores/mail-vacation.store.ts` · `shared/utils/password-change.ts` · `mail-vacation.ts` · 메뉴 `shared/constants/settings-menu.ts` | 메뉴는 묶음(계정 › 비밀번호 / 메일 › 서명 · 부재중 자동 응답 · 필터(6단계) · 알림(5단계)), 라우트 이름은 `SETTINGS_ROUTES` 하나. 비밀번호는 서버와 같은 규칙을 먼저 보고, 서버 거절(`AUTH_005` · `VAL_001`)은 칸 아래에만(알림 없음) — 성공하면 세 칸을 비운다. 부재중은 날짜만 고르고 브라우저 시간대의 시작일 0시 · 끝나는 날 다음 날 0시를 UTC 로 보낸다(변환은 `mail-vacation.ts` 한 곳 — 되읽을 때는 1ms 앞 날짜). 켤 때만 검사하고 꺼 둔 채로는 빈 값도 저장한다(길이 150 · 600 도 켤 때만 — 꺼 둔 채로는 4단계 한도까지, 끄기는 늘 저장되게). 부재중 상태는 메일 화면에 들어올 때 받고, 패널 안내 `components/mail/MailVacationNotice.vue` 가 1분마다 다시 계산한다(`useClock` · `VACATION_NOTICE_REFRESH_MS`). 저장 응답은 세션 세대(`authStore.generation`)가 바뀌었으면 쓰지 않는다. 저장한 값이 켜짐인데 엔진에서 규칙이 꺼져 있으면(응답의 `isActive: false` — 7단계) 머리 아래 노란 줄(`MailFilterNotice` 를 단추 없이 — `actionLabel` 을 주지 않는다). 칸을 고친 값이 아니라 저장한 값으로 보고, `isActive` 는 PUT 본문에 넣지 않는다(`useMail().saveVacation` 이 다섯 칸만) — 계약 1절 · 2절 "부재중 자동 응답" |
| 메일함 관리 · 오래 걸리는 처리 (4단계) | `src/components/mail/MailboxTree.vue` · `MailboxPanelRow.vue` · `MailboxMoreButton.vue`(⋯ 메뉴) · `MailboxDialogs.vue`(+ `Mailbox*Dialog.vue`) · `MailboxEmptyButton.vue`(목록 머리 「비우기」) · `composables/useMailboxActions.ts` · `stores/mail/mailbox-admin.ts` · `shared/utils/mailbox-operation.ts` · 상수 `shared/constants/mailbox-manage.ts` | 만들기 · 이름 바꾸기는 칸 안 실패(`MAIL_009` · `VAL_001`)를 알림 없이 칸 아래에. 모두 읽음 · 비우기 · 지우기는 남은 수가 0(지우기는 `deleted: true`)이 될 때까지 다시 보내며 진행 알림을 고친다 — 진행은 응답의 `processed`, 세션이 바뀌면 멈춘다(`epoch` — 늦게 온 만들기 · 이름 바꾸기 응답도 버린다), 같은 메일함의 같은 처리는 하나, **휴지통 비우기와 메일함 지우기는 함께 돌리지 않는다**(비우기는 그사이 휴지통에 들어온 메일도 지운다 — 판정은 스토어의 `isMailboxOperationBlocked` 하나, 메뉴 · 단추 · 대화상자 확인이 모두 이것을 본다). 끝난 뒤 누른 단추가 사라져 초점을 잃었으면 패널의 지금 메일함 항목으로 옮긴다. ⋯ 메뉴는 보이는 영역 안에 다 들게 연다(`shared/utils/popup-placement.ts` — 아래가 모자라면 위로). 되돌릴 수 없는 일은 확인 대화상자(처음 초점 「취소」) 뒤에, 확인한 그 메일함만 — 계약 2절 "메일함 관리" |
| 스팸 · 원문 (4단계) | `src/components/mail/MessageSpamButton.vue`(읽기 화면) · 도구 줄의 스팸 단추(`MailSelectionToolbar.vue`) · `MessageMoreMenu.vue`(⋯ › 원문 내려받기) | 스팸 신고 · 스팸 아님은 이동과 `isJunk` 를 한 요청에 보내고(`mailStore.moveMessages(…, { isJunk })`), [되돌리기]는 원래 메일함으로 반대 값을 보낸다. 원문(.eml)은 `useMail().downloadMessageSource()` — 첨부처럼 `confirmSession()` 을 먼저 거친다 — 계약 2절 "스팸 · 원문" |
| 메일 필터 (6단계) | `src/pages/settings/MailFilterPage.vue` · `components/settings/MailFilter*.vue`(목록 · 행 · 목록 위 줄 `MailFilterNotice` · 빈 상태 · 안내 상자 `MailFilterNotes` · 고치기 창 `MailFilterDialog` + 조건 `MailFilterConditionFields` · `MailFilterConditionRow` · 할 일 `MailFilterActionFields`) · `mail-filter-ui.ts` · `stores/mail-filter.store.ts` · `composables/useMailFilterActions.ts`(켜기 · 순서 · 지우기 · 되돌리기 알림) · `useMailFilterDialog.ts`(창 상태) · `shared/utils/mail-filter.ts`(검사 · 요약 · 옮길 곳 · 되돌릴 자리) · `shared/constants/mail-filter.ts` · 읽기 화면 ⋯ 「이런 메일로 필터 만들기」(`MessageMoreMenu.vue` → `/settings/mail/filters?from=`) | 바꾸는 일은 모두 `PUT /api/mail-filters` 전체 교체(받은 `version`)이고 **저장은 한 번에 하나**(`saving`). 켜기 · 순서는 화면을 먼저 바꾸고 실패하면(그사이 목록이 안 바뀌었으면) 되돌린다. 창의 저장은 그 순간 목록의 켜짐 값을 쓴다. [되돌리기]는 지운 필터만 앞 · 뒤 필터 기준 자리에 다시 넣고, 누른 차례대로 앞 저장이 끝난 뒤 보낸다(`restoreQueue` — 기다림과 `send()` 사이에 await 를 두지 않는다 — 2026-10-28 리뷰의 경합). `MAIL_012` 는 목록 위 줄 + 창 안 안내와 [다시 불러오기](쓴 내용을 지키고 다시 받은 목록 위에 저장), 늦게 온 409 는 보낼 때의 순번(seq)이 그대로일 때만 줄을 켠다. 검사는 서버와 같은 규칙(전달 주소의 @ 앞은 dot-atom · 64자로 서버보다 좁게)이고 거절은 칸 아래(`VAL_001` · `MAIL_013` 은 알림을 끈다). 필터 화면이 떠 있지 않을 때 실패한 되돌리기는 알림으로(`attachPage` · `pageVisible`). 세션이 바뀌면 늦은 응답을 버린다(`authStore.generation`) — 계약 2절 "메일 필터", 디자인 README "6단계" |
| 이미 받은 메일에 필터 적용 (7단계) | `src/components/settings/MailFilterApplyField.vue`(고치기 창의 「받은편지함에 이미 있는 메일에도 적용」 — 창을 열 때마다 꺼짐) · `composables/useMailFilterRun.ts`(진행 · 결과 알림) · `stores/mail-filter-run.store.ts`(cursor 를 이어 보내기 — 탭마다 하나 `running`) · `shared/utils/mail-filter-run.ts`(할 일 판정 · 보낼 규칙 · 끝 알림의 메일함 · 문구) | 저장이 성공한 뒤에만 `POST /api/mail-filter-runs` 를 `cursor` 가 null 이 될 때까지 다시 보내며 진행 알림을 고친다(4단계 오래 걸리는 처리와 같은 결 — 전체는 확인 + `remaining`, 첫 응답 전에는 받은편지함의 메일 수). 전달은 보내지 않는다. 이미 받은 메일에 할 일이 없는 필터(옮길 메일함이 지워짐 · 받은편지함으로 옮기기만 · 전달만)는 칸을 끄고 시작하지도 않는다 — 판정은 `filterRunIdleReason` 하나(칸 `filterApplyBlock` · 시작 `filterRunRuleOf` 가 함께 쓴다), 이 탭에서 적용이 도는 중에도 칸을 끈다. 요청은 `silent` 로 보내고 실패는 응답이 온 때 세션이 그대로일 때만 스토어가 알린다(`REQ_001` — 다른 창 · PC 에서 적용 중 — 은 화면이 "다른 창에서 …"로). 멈추면 [다시 시도](받은 `cursor` 부터 — 수도 이어 센다), 나아가지 못한 응답이 거듭되면(`MAIL_FILTER_RUN_MAX_STALLS`) 멈춘다. 다른 화면으로 가도 이어 하고, 세션이 바뀌면 다음 요청을 보내지 않고 알림을 닫는다. 끝났거나 나아간 뒤 멈췄으면 개수 · 목록을 맞춘다 — 계약 2절 "이미 받은 메일에 적용", 디자인 README "7단계" |
| 주소록 (8단계) | `src/pages/contacts/`(`ContactsLayout.vue` — 패널 · 창 묶음 `ContactsDialogs` 의 자리 · `ContactsPage.vue`) · `components/contacts/`(패널 `ContactsPanel` · 그룹 줄 `ContactsGroupRow` · 목록 `ContactsList` · `ContactsListItem` · 상세 `ContactDetail` · 찾기 `ContactsSearchBox` · 연락처 창 `ContactDialog` + 줄 `ContactEntryRows` · 그룹 이름 창 · 구성원 넣기 창 `ContactGroupMembersDialog` · ⋯ `ContactMoreMenu` · `ContactAvatar` · `contacts-ui.ts`) · `stores/contacts.store.ts` · `composables/useContacts.ts`(API) · `useContactActions.ts`(지우기 · [되돌리기] · 그룹에 메일 쓰기 · 고치기 열기) · `useContactsDialogs.ts`(창 상태) · `useContactDuplicateCheck.ts`(같은 주소 경고) · `shared/utils/contacts.ts`(차례 `compareContacts` — **backend `contact-search.ts` 와 짝** · 칸 정리 · 바꾼 칸 `contactPatchOf` · 검사) · `contacts-route.ts` · `shared/constants/contacts.ts`(라우트 `CONTACTS_ROUTES` · 한도) · `shared/utils/clipboard.ts` | 레일의 주소록 모듈(`app-modules.ts`). 고치기는 창을 열 때 그 연락처를 새로 받고 **바꾼 칸만 `PATCH`** 한다(검사도 바꾼 칸만 — 손대지 않은 남의 값은 그대로, 바꾼 것이 없으면 보내지 않는다). 지우기는 확인 없이 하고 [되돌리기]는 지우기 응답의 `restoreToken` 으로 `POST /contact-restorations`(표가 없으면 단추 없이 알린다). 바꾼 연락처는 응답을 서버의 차례로 받은 쪽 안의 자리에 넣는다(스토어의 `placeContact` — 자리를 모를 때만 첫 쪽을 한 번 다시 받는다, 바꿀 때마다 불러온 쪽을 모두 다시 받지 않는다). 구성원 넣기 창은 `excludeGroupId` 로 받고, 주소로 연 사내 항목은 `GET /directory-entries/{id}` 로. 창은 주소록 틀(`ContactsLayout`)에 둔다 — 주소록 안에서 옮겨 다녀도 남고, 떠날 때는 나가기 확인(`useDialogLeaveConfirm`), 세션이 바뀌면 닫는다. 저장 중에 창을 닫았으면 창 안에 보일 거절도 `notifyApiError` 로. 이메일은 서버보다 좁게 본다(`mail-address.ts` 의 `isStrictEmail` — 필터의 전달 주소와 공용), 이메일 칸은 `type="text"` + `inputmode="email"`(브라우저가 한글 도메인을 punycode 로 바꾸지 않게). 전환은 `.segmented--outline`(base.css), 도구 단추 `components/mail/MailToolButton.vue` 의 `unavailable`(aria-disabled — 클릭 무시) · `busy`(aria-busy) · `describedby` — 계약 3절, 디자인 README "8단계" |
| 메일과 함께 (8단계) | `shared/utils/compose-prefill.ts` · `components/mail/MailContactDialogHost.vue` + `composables/useMailContactDialog.ts` · `MessageMoreMenu.vue`(⋯ 「보낸 사람을 주소록에 추가」 · 「주소록에서 보기」) · 받는 사람 칸의 그룹(`useRecipientSuggestions` 의 `optionsFor` · `expandGroup`, `recipient-match.ts` 의 `matchContactGroups` · `arrangeRecipientOptions`) | 쓰기 화면을 받는 사람을 채워 열 때는 라우터 상태(`composeTo` — 주소는 URL 에 싣지 않는다)에 연 직원의 주소를 함께 싣고, 지금 직원과 다르면 쓰지 않는다(같은 탭에서 다른 직원이 로그인한 뒤 뒤로 가기). 읽기 화면의 연락처 추가 창은 메일 레이아웃(`MailLayout` 이 provide)에 둔다 — 메일을 바꾸거나 목록으로 돌아가도 남는다(읽기 화면 안 `v-if="message"` 에 두면 사라진다). 받는 사람 추천은 주소록 → 그룹 → 사내 → 최근. 그룹은 쓰기 화면을 열 때 받아 둔 목록에서 이름으로 찾고, 고르면 구성원을 받아 대표 주소를 넣는다 — 그룹마다 따로 기다려 이어 골라도 앞 그룹이 빠지지 않는다 — 계약 3절 "메일과 함께" |
| 시계 주입 | `src/composables/useClock.ts` · `src/test-utils/manual-clock.ts` | 타이머를 쓰는 로직(자동 저장 등)은 시계를 주입받는다 — 테스트는 손으로 돌리는 시계로 시간을 넘긴다(고정 대기 없이, [[testing]]) |
| 나가기 전 확인 | `src/composables/useLeaveConfirm.ts` · `src/shared/utils/compose-changes.ts` | 화면을 연 뒤(또는 마지막으로 저장한 뒤) 바꾼 것이 있으면 앱 안 이동(라우트 떠남·바뀜)은 확인 대화상자, 탭 닫기·새로고침은 `beforeunload`. 쓰기 화면은 떠나기 전에 먼저 저장하고 저장하지 못한 것이 있을 때만 묻는다(서명 설정은 저장하지 않은 서명이 있으면 묻는다). 가드가 도는 중은 `guarding`. 바꿈 여부는 연 직후(저장한 뒤에는 마지막으로 저장한 판)의 스냅숏과 비교한다(편집기는 `isChanged()`). 떠나기 전 저장은 5초까지만 기다린다. 보낸 뒤·보내는 중·세션이 끝났을 때는 묻지 않는다 — 보내는 중에 떠나면 결과는 토스트로만 알리고 떠난 양식·새 양식을 건드리지 않는다(양식 세대로 늦게 온 결과를 버린다, 디자인 README "나가기 전 확인") |
| 나가기 전 확인 | `src/composables/useLeaveConfirm.ts` · `src/shared/utils/compose-changes.ts` | 화면을 연 뒤(또는 마지막으로 저장한 뒤) 바꾼 것이 있으면 앱 안 이동(라우트 떠남·바뀜)은 확인 대화상자, 탭 닫기·새로고침은 `beforeunload`. 쓰기 화면은 떠나기 전에 먼저 저장하고 저장하지 못한 것이 있을 때만 묻는다(서명 설정은 저장하지 않은 서명이 있으면 묻는다). 가드가 도는 중은 `guarding`. 바꿈 여부는 연 직후(저장한 뒤에는 마지막으로 저장한 판)의 스냅숏과 비교한다(편집기는 `isChanged()`). 떠나기 전 저장은 5초까지만 기다린다. 보낸 뒤·보내는 중·세션이 끝났을 때는 묻지 않는다 — 보내는 중에 떠나면 결과는 토스트로만 알리고 떠난 양식·새 양식을 건드리지 않는다(양식 세대로 늦게 온 결과를 버린다, 디자인 README "나가기 전 확인"). **고치던 모달 창**(필터 고치기 창 — 7단계)은 `composables/useDialogLeaveConfirm.ts` — 화면의 확인에 "창 닫기" 길을 더한다: [취소] · Esc 는 `asking: 'close'`([계속 고치기]면 창을 그대로 — 브라우저가 닫았으면 `reshow()`), 다른 화면은 `'route'`, 탭 닫기 · 새로고침은 `beforeunload`. 보내는 중 · 세션이 끝났을 때는 묻지 않는다 |
| 읽기 화면 본문 안내 | `src/shared/utils/mail-body-notices.ts` · `shared/constants/mail.ts` 의 `MAIL_BODY_NOTICES` | 서버의 `isHtmlBodyOmitted`·`isBodyTruncated`(계약 2절 "읽기 한도")를 외부 이미지 차단과 같은 안내 줄로 보인다(`.reader__notice[data-notice]`) |
| 쓰기 칸 포커스 | `src/styles/base.css` 의 `.compose-field` | 글자 입력 행(받는 사람·참조·숨은참조·제목·본문)은 아래 선 하나로 포커스를 보인다. 새 입력 행에도 이 클래스를 붙인다 — 칸마다 포커스 규칙을 따로 만들지 않는다 |
| 테두리 입력칸 | `src/styles/base.css` 의 `.form-input` · `.form-input__control` · `.form-message` | 설정 화면 · 메일함 이름 대화상자의 테두리 있는 칸(44)과 칸 아래 안내 · 오류(12.5px). 칸 안에 단추가 있는 상자(비밀번호 보기)는 상자에 `.form-input`, 입력 요소에 `.form-input__control` — 입력 요소에 초점이 있을 때만 상자에 초점을 보인다. 오류는 입력 요소의 `aria-invalid="true"`. 쓰기 화면의 밑줄 칸(`.compose-field`)과는 다른 규칙이다 |
+7 -1
View File
@@ -140,12 +140,18 @@ MAIL_RECOVERY_ADMIN=admin:change-me-in-local-env
# ⚠️ 이 비밀번호는 전 직원 메일함의 열쇠다
MAIL_GATEWAY_USER=mail-gateway@arasystem.test
MAIL_GATEWAY_PASSWORD=change-me-in-local-env
# 사내 목록 계정 아이디 (주소는 <이 값>@MAIL_DOMAIN, 미설정 시 mail-directory) — 백엔드가 엔진의 계정 목록에서 직원 이름·주소만 읽을 때
# 게이트웨이 대리 접근으로 쓰는 전용 계정이다. 비밀번호가 없고, setup 스크립트가 복구 관리자(위 MAIL_RECOVERY_ADMIN)로 만든다.
# 관리자·게이트웨이 아이디와 달라야 한다. 바꾸면 setup 을 다시 실행하고 backend 를 다시 띄운다 (mail/README.md "사내 목록 계정").
# 영문·숫자·. _ - 만, 앞뒤는 영문·숫자, 64자 이하 (MAIL_ADMIN_LOCAL_PART 와 같은 규칙)
MAIL_DIRECTORY_LOCAL_PART=mail-directory
# 백엔드가 쓰는 내부 JMAP 주소 (compose 내부망). 보통 바꿀 일이 없다
MAIL_JMAP_URL=http://mail:8080
# 첨부 1개 최대 크기(MB, 미설정 시 20). 프론트 nginx 의 본문 한도가 이 값 + 1MB 로 따라간다
# (그 앞에 외부 리버스 프록시를 두면 그쪽 한도도 맞춘다 — docker 스킬 "운영 환경")
MAIL_ATTACHMENT_MAX_SIZE_MB=20
# 메일 한 통의 첨부 합계(MB, 미설정 시 25) — 파일당 한도 이상이어야 한다
# 메일 한 통의 첨부 합계(MB, 미설정 시 25) — 파일당 한도 이상이어야 한다.
# 바꾸면 mail setup 도 다시 실행한다 — 엔진의 임시 업로드 한도를 이 값의 20배로 맞춘다 (mail/README.md "엔진 한도")
MAIL_ATTACHMENT_TOTAL_MAX_SIZE_MB=25
# 게이트웨이 계정을 쓸 수 있고 자동 차단에서 빠지는 대역 (콤마 구분).
# 비우면 APP_NETWORK_SUBNET 과 같다. 그 안의 더 좁은 대역만 쓸 수 있다 — 더 넓으면 setup 이 실패한다
+8 -1
View File
@@ -149,13 +149,20 @@ MAIL_ADMIN_PASSWORD=CHANGE-ME-BEFORE-DEPLOY
# ⚠️ 이 비밀번호는 전 직원 메일함의 열쇠다
MAIL_GATEWAY_USER=mail-gateway@arasystem.it.kr
MAIL_GATEWAY_PASSWORD=CHANGE-ME-BEFORE-DEPLOY
# 사내 목록 계정 아이디 (주소는 <이 값>@MAIL_DOMAIN, 미설정 시 mail-directory) — 백엔드가 엔진의 계정 목록에서 직원 이름·주소만 읽을 때
# 게이트웨이 대리 접근으로 쓰는 전용 계정이다. 비밀번호가 없다. 관리자·게이트웨이 아이디와 달라야 하고, 옮겨 올 메일함과 겹치지 않게 정한다.
# ⚠️ 엔진 관리자는 이 계정의 권한을 내줄 수 없어 setup 의 --recovery 실행(위 복구 관리자 절차)에서만 만들어진다 — 첫 배포 때 만들어지고,
# 이미 운영 중인 서버라면 --recovery 를 한 번 더 한다. 없는 동안 백엔드는 로그인한 직원 기록으로 사내 목록을 대신한다 (mail/README.md "사내 목록 계정").
# 바꾸면 --recovery 로 setup 을 다시 실행하고 backend 를 다시 띄운다. 영문·숫자·. _ - 만, 앞뒤는 영문·숫자, 64자 이하 (MAIL_ADMIN_LOCAL_PART 와 같은 규칙)
MAIL_DIRECTORY_LOCAL_PART=mail-directory
# 백엔드가 쓰는 내부 JMAP 주소 (compose 내부망)
MAIL_JMAP_URL=http://mail:8080
# 첨부 1개 최대 크기(MB, 미설정 시 20). 프론트 nginx 의 본문 한도는 이 값 + 1MB 로 자동으로 따라간다.
# ⚠️ 그 앞의 외부 리버스 프록시 본문 한도는 직접 이 값 + 1MB 이상으로 둔다 —
# 작으면 프록시가 먼저 413 을 내고 사용자에게는 FILE_002 로 보인다 (docker 스킬 "운영 환경")
MAIL_ATTACHMENT_MAX_SIZE_MB=20
# 메일 한 통의 첨부 합계(MB, 미설정 시 25) — 파일당 한도 이상이어야 한다
# 메일 한 통의 첨부 합계(MB, 미설정 시 25) — 파일당 한도 이상이어야 한다.
# 바꾸면 mail setup 도 다시 실행한다 — 엔진의 임시 업로드 한도를 이 값의 20배로 맞춘다 (mail/README.md "엔진 한도")
MAIL_ATTACHMENT_TOTAL_MAX_SIZE_MB=25
# 게이트웨이 계정을 쓸 수 있고 자동 차단에서 빠지는 대역 (콤마 구분).
# 비우면 APP_NETWORK_SUBNET 과 같다. 그 안의 더 좁은 대역만 쓸 수 있다 — 더 넓으면 setup 이 실패한다
+3
View File
@@ -9,6 +9,7 @@ import { APP_GUARD } from '@nestjs/core';
import { ClientIpThrottlerGuard } from '@/common/guards/client-ip-throttler.guard';
import { OriginGuard } from '@/common/guards/origin.guard';
import { AuthModule } from '@/modules/auth/auth.module';
import { ContactsModule } from '@/modules/contacts/contacts.module';
import { HealthModule } from '@/modules/health/health.module';
import { MailModule } from '@/modules/mail/mail.module';
import { UploadModule } from '@/modules/upload/upload.module';
@@ -65,6 +66,8 @@ const THROTTLE_DEFAULT_LIMIT = 100; // 구간당 IP별 최대 요청 수
AuthModule,
// 웹메일 API (Stalwart JMAP 어댑터)
MailModule,
// 주소록 API — 내 주소록 · 사내 목록 (8단계, 메일 모듈도 받는 사람 추천에 쓴다)
ContactsModule,
UploadModule,
UserModule,
],
@@ -1,13 +1,19 @@
import { plainToInstance } from 'class-transformer';
import { TrimString } from './trim-string.decorator';
import { TrimString, TrimStringOrNull } from './trim-string.decorator';
class Named {
@TrimString()
name!: unknown;
}
class Optional {
@TrimStringOrNull()
name!: unknown;
}
const nameOf = (name: unknown) => plainToInstance(Named, { name }).name;
const optionalOf = (name: unknown) => plainToInstance(Optional, { name }).name;
describe('TrimString — DTO 변환에서 앞뒤 공백을 지운다', () => {
it('문자열은 JS trim() 과 같게 — 탭 · 줄바꿈 · NBSP · U+3000 도 지운다, 가운데는 그대로', () => {
@@ -22,3 +28,19 @@ describe('TrimString — DTO 변환에서 앞뒤 공백을 지운다', () => {
expect(nameOf([' a '])).toEqual([' a ']);
});
});
describe('TrimStringOrNull — 앞뒤 공백을 지우고 빈 글은 null', () => {
it('지운 뒤 남은 글이 없으면 null, 있으면 지운 글', () => {
expect(optionalOf(' 한빛상사 ')).toBe('한빛상사');
expect(optionalOf('\n첫 줄\n둘째 줄\n')).toBe('첫 줄\n둘째 줄');
for (const blank of ['', ' ', '\t\n\u00a0\u3000']) {
expect(optionalOf(blank)).toBeNull();
}
});
it('문자열이 아니면 그대로 둔다 — null 은 null, 키가 없으면 undefined(형식 검사가 막는다)', () => {
expect(optionalOf(null)).toBeNull();
expect(optionalOf(7)).toBe(7);
expect(plainToInstance(Optional, {}).name).toBeUndefined();
});
});
@@ -3,7 +3,7 @@ import { Transform, type TransformFnParams } from 'class-transformer';
/**
* 앞뒤 공백을 지운다 — DTO 변환(class-transformer) 단계라 검사(class-validator)는 지운 값으로 한다.
* 문자열이 아니면 그대로 두어 IsString · IsEmail 같은 검사가 막는다. 지우는 공백은 JS `trim()` 과 같다(NBSP · U+3000 포함 — 웹의 `.trim()` 과 같게 센다).
* 메일함 이름(dto/mailbox.dto.ts) · 메일 필터의 이름 · 찾을 글 · 전달 주소(dto/mail-filter.dto.ts)가 쓴다
* 메일함 이름(dto/mailbox.dto.ts) · 메일 필터의 이름 · 찾을 글 · 전달 주소(dto/mail-filter.dto.ts) · 주소록의 주소 · 번호 · 그룹 이름이 쓴다
*/
export function TrimString(): PropertyDecorator {
return Transform(trimStringValue);
@@ -14,3 +14,19 @@ export function trimStringValue({ value }: TransformFnParams): unknown {
const raw: unknown = value;
return typeof raw === 'string' ? raw.trim() : raw;
}
/**
* 앞뒤 공백을 지우고, 남은 글이 없으면 null 로 바꾼다 — "값 없음"을 null 로 받는 칸(연락처의 이름 · 회사 · 부서 · 직책 · 메모 —
* 계약 3절 "빈 글은 null")이 쓴다. 검사는 바꾼 값으로 한다 (null 이면 ValidateIf 로 건너뛰게 둔다). 문자열이 아니면 그대로 둔다
*/
export function TrimStringOrNull(): PropertyDecorator {
return Transform(trimStringOrNullValue);
}
/** TrimStringOrNull 의 변환 */
export function trimStringOrNullValue({ value }: TransformFnParams): unknown {
const raw: unknown = value;
if (typeof raw !== 'string') return raw;
const trimmed = raw.trim();
return trimmed === '' ? null : trimmed;
}
@@ -64,6 +64,11 @@ describe('에러 코드 레지스트리', () => {
expect(ERROR_CODES.MAIL_013.status).toBe(HttpStatus.BAD_REQUEST);
});
it('주소록 — 같은 이름의 그룹(CONTACT_001)과 주소록 한도(CONTACT_002)는 409 다', () => {
expect(ERROR_CODES.CONTACT_001.status).toBe(HttpStatus.CONFLICT);
expect(ERROR_CODES.CONTACT_002.status).toBe(HttpStatus.CONFLICT);
});
it('클라이언트 전용 코드(AUTH_002)는 서버가 상태 역매핑으로 선택하지 않는다', () => {
const clientOnlyCodes = Object.entries(ERROR_CODES)
.filter(([, definition]) => 'clientOnly' in definition)
+13
View File
@@ -161,6 +161,19 @@ const DOMAIN_ERROR_CODES = {
message:
'필터가 너무 길어 저장할 수 없습니다. 조건을 줄이거나 쓰지 않는 필터를 지워 주세요.',
},
// 주소록(8단계) — 그룹 만들기 · 이름 바꾸기 때 이 주소록에 같은 이름의 그룹이 있다(앞뒤 공백을 빼고 ASCII 대소문자를 무시하고 견준다).
// 화면은 이름 칸 아래에 알림 없이 보인다 (계약 3절)
CONTACT_001: {
status: HttpStatus.CONFLICT,
message: '같은 이름의 그룹이 있습니다. 다른 이름을 써 주세요.',
},
// 주소록 한도(연락처 3,000개 · 그룹 100개 · 그룹 하나에 구성원 500명)를 넘는다 — 아무것도 바꾸지 않았다 (계약 3절).
// 문구의 숫자는 modules/contacts/contacts.constants.ts 의 한도와 같아야 한다 (contacts.constants.spec.ts 가 확인한다)
CONTACT_002: {
status: HttpStatus.CONFLICT,
message:
'주소록 한도를 넘어 더 넣을 수 없습니다. (연락처 3,000개 · 그룹 100개 · 그룹 하나에 500명까지)',
},
} as const satisfies Record<string, ErrorCodeDefinition>;
export const ERROR_CODES = {
@@ -33,7 +33,11 @@ const mailSettings: MailSettings = {
gatewayPassword: 'gateway-secret',
attachmentMaxBytes: 20 * 1024 * 1024,
attachmentTotalMaxBytes: 25 * 1024 * 1024,
blockedAccounts: new Set(['admin@arasystem.test']),
blockedAccounts: new Set([
'admin@arasystem.test',
'mail-directory@arasystem.test',
]),
directoryAccount: 'mail-directory@arasystem.test',
};
const settings: AuthSettings = {
+3 -1
View File
@@ -29,7 +29,9 @@ export function normalizeEmail(email: string): string {
* 로그인할 수 있는 직원 주소인지 확인한다.
* - 사내 도메인(MAIL_DOMAIN) 주소만
* - 게이트웨이 서비스 계정은 직접 로그인할 수 없다
* - 막는 계정(메일 관리자 `<MAIL_ADMIN_LOCAL_PART>@<도메인>`·MAIL_BLOCKED_ACCOUNTS)도 직원 주소가 아니다 (mail.settings.ts 의 blockedAccounts)
* - 막는 계정(메일 관리자 `<MAIL_ADMIN_LOCAL_PART>@<도메인>`·사내 목록 계정 `<MAIL_DIRECTORY_LOCAL_PART>@<도메인>`·MAIL_BLOCKED_ACCOUNTS)도
* 직원 주소가 아니다 (mail.settings.ts 의 blockedAccounts)
* 사내 목록(계약 3절)에서 빼는 계정도 이 함수로 고른다 (contacts/directory-policy.ts) — 로그인을 막는 목록과 같게
*/
export function isEmployeeAddress(
email: string,
@@ -0,0 +1,498 @@
import { startCpuTimer } from '@/../test/cpu-time';
import {
applyAddressBookWrite,
buildBookView,
cardWeight,
emailKey,
groupWeight,
newCardUid,
weightAfterChanges,
} from './address-book';
import { matchesContact, parseContactSearch } from './contact-search';
import type {
AddressBookCard,
AddressBookContactCard,
AddressBookGroupCard,
AddressBookSnapshot,
} from './contacts.provider';
const card = (
id: string,
overrides: Partial<AddressBookContactCard> = {},
): AddressBookContactCard => ({
id,
uid: `urn:uuid:${id}`,
name: id,
emails: [],
phones: [],
company: null,
department: null,
title: null,
note: null,
...overrides,
});
const group = (
id: string,
name: string,
memberUids: string[],
overrides: Partial<AddressBookGroupCard> = {},
): AddressBookGroupCard => ({
id,
uid: `urn:uuid:${id}`,
name,
memberUids,
...overrides,
});
const snapshot = (
contacts: AddressBookContactCard[],
groups: AddressBookGroupCard[] = [],
): AddressBookSnapshot => ({ version: 'v1', contacts, groups });
describe('buildBookView (주소록 한 사람분)', () => {
it('연락처 · 그룹을 이름 차례로 — 연락처의 그룹 · 그룹의 구성원 수는 uid 로 잇는다', () => {
const view = buildBookView(
snapshot(
[
card('c-ha', { name: '하늘' }),
card('c-ga', { name: '가나다' }),
card('c-na', { name: '나비' }),
],
[
group('g-z', '협력사', ['urn:uuid:c-ha']),
group('g-a', '거래처', ['urn:uuid:c-ha', 'urn:uuid:c-ga']),
],
),
);
expect(view.contacts.map((entry) => entry.contact.id)).toEqual([
'c-ga',
'c-na',
'c-ha',
]);
expect(view.groups.map((entry) => entry.group)).toEqual([
{ id: 'g-a', name: '거래처', memberCount: 2 },
{ id: 'g-z', name: '협력사', memberCount: 1 },
]);
// 그룹 차례 — 거래처 → 협력사
expect(view.contactsById.get('c-ha')?.contact.groupIds).toEqual([
'g-a',
'g-z',
]);
expect(view.contactsById.get('c-na')?.contact.groupIds).toEqual([]);
// 구성원은 연락처의 이름 차례
expect(view.groupsById.get('g-a')?.memberIds).toEqual(['c-ga', 'c-ha']);
expect(view.version).toBe('v1');
});
it('구성원 수는 이 주소록의 연락처만 센다 — 지워진 카드 · 다른 주소록 · 그룹을 가리키는 uid 는 세지 않는다', () => {
const view = buildBookView(
snapshot(
[card('c1')],
[
group('g-inner', '안쪽', []),
group('g', '거래처', [
'urn:uuid:c1',
'urn:uuid:gone',
'urn:uuid:g-inner',
]),
],
),
);
expect(view.groupsById.get('g')?.group.memberCount).toBe(1);
expect(view.groupsById.get('g')?.memberIds).toEqual(['c1']);
});
it('uid 가 없는 카드는 어느 그룹에도 들지 않는다 · 같은 uid 의 카드 둘은 둘 다 구성원이다', () => {
const view = buildBookView(
snapshot(
[
card('no-uid', { uid: null }),
card('twin-a', { uid: 'urn:uuid:twin' }),
card('twin-b', { uid: 'urn:uuid:twin' }),
],
[group('g', '거래처', ['urn:uuid:twin'])],
),
);
expect(view.contactsById.get('no-uid')?.contact.groupIds).toEqual([]);
expect(view.groupsById.get('g')?.group.memberCount).toBe(2);
});
it('주소로 연락처를 찾는 표(대소문자 무시) · 받는 사람 추천 줄 — 연락처의 이름 차례, 대표 주소가 먼저, 같은 주소는 한 번', () => {
const view = buildBookView(
snapshot([
card('c-b', {
name: '박지훈',
company: '한빛상사',
emails: [
{ address: 'Jihoon@Hanbit.example', label: 'work' },
{ address: 'jihoon@home.example', label: 'personal' },
{ address: 'JIHOON@hanbit.example', label: 'other' },
],
}),
card('c-a', {
name: '가나다',
emails: [{ address: 'jihoon@hanbit.example', label: 'other' }],
}),
]),
);
expect(
view.contactsByEmail
.get('jihoon@hanbit.example')
?.map((entry) => entry.contact.id),
).toEqual(['c-a', 'c-b']);
expect(view.suggestionLines).toEqual([
{
name: '가나다',
email: 'jihoon@hanbit.example',
company: null,
emailLabel: 'other',
},
{
name: '박지훈',
email: 'Jihoon@Hanbit.example',
company: '한빛상사',
emailLabel: 'work',
},
{
name: '박지훈',
email: 'jihoon@home.example',
company: '한빛상사',
emailLabel: 'personal',
},
]);
});
it('읽는 상한(5,000장) · 그룹 100개 × 구성원 500명도 만들고 찾는 데 계산량이 몇 배로 늘지 않는다 (CPU 시간 — 지금 250ms 남짓)', () => {
const contacts = Array.from({ length: 5_000 }, (_, index) =>
card(`c${index}`, {
name: `연락처 ${(index * 7_919) % 5_000} 박지훈`,
emails: [0, 1, 2].map((n) => ({
address: `p${index}.${n}@x.example`,
label: 'work' as const,
})),
phones: [{ number: `010-${index}-5540`, label: 'mobile' as const }],
company: '한빛상사',
note: 'x'.repeat(200),
}),
);
const groups = Array.from({ length: 100 }, (_, g) =>
group(
`g${g}`,
`그룹 ${g}`,
Array.from(
{ length: 500 },
(_, m) => `urn:uuid:c${(g * 37 + m) % 5_000}`,
),
),
);
const query = parseContactSearch('ㅂㅈㅎ');
if (query === null) throw new Error('찾는 글을 해석하지 못했다');
const cpuMs = startCpuTimer();
const view = buildBookView(snapshot(contacts, groups));
for (let round = 0; round < 10; round += 1) {
expect(
view.contacts.filter((entry) => matchesContact(entry.search, query)),
).toHaveLength(5_000);
}
expect(cpuMs()).toBeLessThan(2_500);
expect(view.groupsById.get('g99')?.group.memberCount).toBe(500);
});
it('uid 로 찾는 표 — 연락처(같은 uid 둘이면 둘 다) · 이 주소록이 쓰는 uid(그룹 포함, uid 없는 카드 제외)', () => {
const view = buildBookView(
snapshot(
[
card('a', { uid: 'urn:uuid:twin' }),
card('b', { uid: 'urn:uuid:twin' }),
card('c', { uid: null }),
],
[group('g', '거래처', [])],
),
);
expect(
view.contactsByUid.get('urn:uuid:twin')?.map((entry) => entry.card.id),
).toEqual(['a', 'b']);
expect([...view.uidsInUse].sort()).toEqual(['urn:uuid:g', 'urn:uuid:twin']);
});
it('크기는 글자 수로 어림한다 — 메모가 길면 크다', () => {
const small = buildBookView(snapshot([card('c1')]));
const large = buildBookView(
snapshot([card('c1', { note: 'x'.repeat(1_000) })]),
);
expect(large.weight - small.weight).toBe(1_000);
});
});
describe('applyAddressBookWrite (쓴 바꾸기를 기억한 주소록에)', () => {
const base = snapshot(
[
card('c1', { name: '하나', company: '옛 회사', department: '옛 부서' }),
card('c2'),
],
[group('g1', '거래처', ['urn:uuid:c1'])],
);
const contactCard = (
id: string,
overrides: Partial<AddressBookContactCard> = {},
): AddressBookCard => ({ kind: 'contact', card: card(id, overrides) });
it('지우기 · 그룹 고치기(보낸 구성원)를 옮기고, 만들거나 고친 연락처는 어댑터가 준 모양으로 — 새 판을 단다', () => {
const next = applyAddressBookWrite(
base,
{
create: [
{
kind: 'contact',
key: 'contact',
uid: 'urn:uuid:new',
fields: {
name: '새 사람',
emails: [{ address: 'new@x.example', label: 'work' }],
phones: [],
company: null,
department: null,
title: null,
note: null,
},
},
{
kind: 'group',
key: 'group',
uid: 'urn:uuid:g-new',
name: '새 그룹',
memberUids: ['urn:uuid:new'],
},
],
update: [
{
kind: 'contact',
id: 'c1',
fields: { company: '새 회사' },
},
{
kind: 'group',
id: 'g1',
memberUids: ['urn:uuid:c1', 'urn:uuid:new'],
},
],
destroy: ['c2'],
},
{
createdIds: new Map([
['contact', 'c-new'],
['group', 'g-new'],
]),
cards: new Map<string, AddressBookCard>([
// 어댑터가 칸 안의 남의 정보와 합친 결과 — 부서는 그대로, 직책은 다른 프로그램의 둘째 직책이 보인다
[
'c1',
contactCard('c1', {
name: '하나',
company: '새 회사',
department: '옛 부서',
title: '둘째 직책',
}),
],
[
'c-new',
contactCard('c-new', { uid: 'urn:uuid:new', name: '새 사람' }),
],
[
'g-new',
{
kind: 'group',
card: group('g-new', '새 그룹', ['urn:uuid:new'], {
uid: 'urn:uuid:g-new',
}),
},
],
]),
},
'v2',
);
expect(next?.version).toBe('v2');
expect(next?.contacts.map((entry) => entry.id)).toEqual(['c1', 'c-new']);
expect(next?.contacts[0]).toMatchObject({
company: '새 회사',
department: '옛 부서',
title: '둘째 직책',
});
expect(next?.groups).toEqual([
group('g1', '거래처', ['urn:uuid:c1', 'urn:uuid:new']),
group('g-new', '새 그룹', ['urn:uuid:new'], { uid: 'urn:uuid:g-new' }),
]);
// 받은 주소록은 그대로 둔다
expect(base.contacts).toHaveLength(2);
expect(base.groups[0]?.memberUids).toEqual(['urn:uuid:c1']);
});
it('고친 연락처의 모양이 없으면 보낸 칸을 옮긴다 · 그룹 이름만 바꾸면 구성원은 그대로', () => {
const next = applyAddressBookWrite(
base,
{
create: [],
update: [
{ kind: 'contact', id: 'c1', fields: { department: null } },
{ kind: 'group', id: 'g1', name: '협력사' },
],
destroy: [],
},
{ createdIds: new Map(), cards: new Map() },
'v2',
);
expect(next?.contacts[0]).toMatchObject({
company: '옛 회사',
department: null,
});
expect(next?.groups).toEqual([group('g1', '협력사', ['urn:uuid:c1'])]);
});
it('만든 카드의 id 나 모양이 없으면 null — 다시 읽어야 한다', () => {
const created = {
create: [
{
kind: 'group' as const,
key: 'group',
uid: newCardUid(),
name: '새 그룹',
memberUids: [],
},
],
update: [],
destroy: [],
};
expect(
applyAddressBookWrite(
base,
created,
{ createdIds: new Map(), cards: new Map() },
'v2',
),
).toBeNull();
expect(
applyAddressBookWrite(
base,
created,
{ createdIds: new Map([['group', 'g-new']]), cards: new Map() },
'v2',
),
).toBeNull();
});
});
describe('weightAfterChanges (쓰기 전에 재는 크기 — 계약 3절 "크기")', () => {
const base = snapshot(
[card('c1', { note: 'x'.repeat(100) }), card('c2')],
[group('g1', '거래처', ['urn:uuid:c1'])],
);
const book = buildBookView(base);
const fields = {
name: '새 사람',
emails: [{ address: 'new@x.example', label: 'work' as const }],
phones: [],
company: null,
department: null,
title: null,
note: 'y'.repeat(500),
};
it('만들기는 더하고 · 지우기는 빼고 · 고치기는 바뀐 만큼', () => {
expect(
weightAfterChanges(book, { create: [], update: [], destroy: [] }),
).toBe(book.weight);
expect(
weightAfterChanges(book, {
create: [{ kind: 'contact', key: 'k', uid: 'urn:uuid:n', fields }],
update: [],
destroy: [],
}),
).toBe(book.weight + cardWeight({ id: '', uid: 'urn:uuid:n', ...fields }));
expect(
weightAfterChanges(book, { create: [], update: [], destroy: ['c1'] }),
).toBe(book.weight - cardWeight(base.contacts[0]));
// 메모 100자 → 10자
expect(
weightAfterChanges(book, {
create: [],
update: [
{ kind: 'contact', id: 'c1', fields: { note: 'z'.repeat(10) } },
],
destroy: [],
}),
).toBe(book.weight - 90);
// 그룹에 구성원 하나 더
expect(
weightAfterChanges(book, {
create: [],
update: [
{
kind: 'group',
id: 'g1',
memberUids: ['urn:uuid:c1', 'urn:uuid:c2'],
},
],
destroy: [],
}),
).toBe(
book.weight -
groupWeight(base.groups[0]) +
groupWeight(group('g1', '거래처', ['urn:uuid:c1', 'urn:uuid:c2'])),
);
});
it('되돌리기는 지울 때의 모양으로 잰다 — 그룹은 다시 넣을 구성원으로', () => {
const restoredGroup = group('old', '옛 그룹', [
'urn:uuid:c1',
'urn:uuid:gone',
]);
expect(
weightAfterChanges(book, {
create: [
{
kind: 'restored',
key: 'r',
uid: 'urn:uuid:old',
original: { source: '{}', bytes: 2 },
memberUids: ['urn:uuid:c1'],
card: { kind: 'group', card: restoredGroup },
},
],
update: [],
destroy: [],
}),
).toBe(
book.weight +
groupWeight({
...restoredGroup,
id: '',
uid: 'urn:uuid:old',
memberUids: ['urn:uuid:c1'],
}),
);
});
});
describe('도움 함수', () => {
it('새 카드의 uid 는 urn:uuid 형식이고 매번 다르다', () => {
const a = newCardUid();
const b = newCardUid();
expect(a).toMatch(
/^urn:uuid:[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/,
);
expect(a).not.toBe(b);
});
it('주소 비교는 앞뒤 공백을 빼고 소문자로', () => {
expect(emailKey(' Ara.Kim@AraSystem.Test ')).toBe(
'ara.kim@arasystem.test',
);
});
});
@@ -0,0 +1,362 @@
import { randomUUID } from 'crypto';
import {
compareContacts,
compareGroups,
contactSearchIndex,
type ContactSearchIndex,
} from './contact-search';
import type {
AddressBookCard,
AddressBookCardUpdate,
AddressBookChanges,
AddressBookContactCard,
AddressBookGroupCard,
AddressBookSnapshot,
NewAddressBookCard,
} from './contacts.provider';
import type {
Contact,
ContactFields,
ContactGroup,
ContactSuggestionLine,
} from './contacts.types';
/**
* 주소록 한 사람분을 화면 모양으로 — 연락처(속한 그룹) · 그룹(구성원 수) · 찾기 자료 · 받는 사람 추천 줄 (순수 함수).
* 엔진에서 읽은 것(AddressBookSnapshot)으로 한 번 만들어 판(version)과 함께 기억한다 (contacts.service.ts).
*
* - 그룹 구성원은 카드의 uid 로 잇는다 — 이 주소록에 있는 연락처만 센다(지워진 카드를 가리키는 uid 는 세지 않는다)
* - 연락처 · 그룹은 이름 차례(contact-search.ts)
*/
/** 연락처 한 장 — 카드(uid)와 화면 모양 · 찾기 자료 */
export interface BookContact {
readonly card: AddressBookContactCard;
readonly contact: Contact;
readonly search: ContactSearchIndex;
}
/** 그룹 하나 — 카드와 화면 모양 · 구성원(이 주소록의 연락처 id, 이름 차례) */
export interface BookGroup {
readonly card: AddressBookGroupCard;
readonly group: ContactGroup;
readonly memberIds: readonly string[];
}
export interface BookView {
readonly version: string;
readonly snapshot: AddressBookSnapshot;
/** 이름 차례 */
readonly contacts: readonly BookContact[];
readonly groups: readonly BookGroup[];
readonly contactsById: ReadonlyMap<string, BookContact>;
readonly groupsById: ReadonlyMap<string, BookGroup>;
/** 주소(소문자) → 그 주소가 있는 연락처 (이름 차례) — 사내 목록에서 넣을 때 같은 주소의 연락처를 찾는다 */
readonly contactsByEmail: ReadonlyMap<string, readonly BookContact[]>;
/** uid → 그 uid 의 연락처 (같은 uid 의 카드가 둘 이상일 수 있다 — 다른 프로그램이 만든 것) */
readonly contactsByUid: ReadonlyMap<string, readonly BookContact[]>;
/** 이 주소록의 카드(연락처 · 그룹)가 쓰는 uid — 엔진은 한 주소록 안의 같은 uid 를 받지 않는다(2026-10-29 확인) */
readonly uidsInUse: ReadonlySet<string>;
/** 받는 사람 추천의 주소록 묶음 — 연락처의 이름 차례, 연락처 안에서는 대표 주소가 먼저 */
readonly suggestionLines: readonly ContactSuggestionLine[];
/** 대략의 크기(글자 수) — 쓰기의 크기 한도(MAX_ADDRESS_BOOK_WEIGHT)와 기억하는 양의 상한에 쓴다 (contact-book-cache.ts) */
readonly weight: number;
}
/** 카드 하나에 더하는 대략의 덩치 — 객체 · 배열 자리 */
const CARD_OVERHEAD = 64;
const ITEM_OVERHEAD = 16;
/** 새 카드의 uid — 다른 프로그램(CardDAV)과 같은 형식. 엔진은 만든 뒤 uid 를 바꾸지 못하게 한다 */
export function newCardUid(): string {
return `urn:uuid:${randomUUID()}`;
}
/** 비교 · 중복 확인용 주소 — 앞뒤 공백을 빼고 소문자로 */
export function emailKey(address: string): string {
return address.trim().toLowerCase();
}
function textWeight(...texts: (string | null)[]): number {
return texts.reduce((sum, text) => sum + (text?.length ?? 0), 0);
}
/** 연락처 카드 하나의 크기 — 우리가 다루는 칸의 글자 수 + 카드 · 항목마다 조금 (계약 3절 "크기") */
export function cardWeight(card: AddressBookContactCard): number {
return (
CARD_OVERHEAD +
textWeight(
card.id,
card.uid,
card.name,
card.company,
card.department,
card.title,
card.note,
) +
card.emails.reduce(
(sum, email) => sum + ITEM_OVERHEAD + email.address.length,
0,
) +
card.phones.reduce(
(sum, phone) => sum + ITEM_OVERHEAD + phone.number.length,
0,
)
);
}
/** 그룹 카드 하나의 크기 */
export function groupWeight(card: AddressBookGroupCard): number {
return (
CARD_OVERHEAD +
textWeight(card.id, card.uid, card.name) +
card.memberUids.reduce((sum, uid) => sum + ITEM_OVERHEAD + uid.length, 0)
);
}
function fieldsOf(card: AddressBookContactCard): ContactFields {
return {
name: card.name,
emails: card.emails,
phones: card.phones,
company: card.company,
department: card.department,
title: card.title,
note: card.note,
};
}
/** 주소록 한 사람분을 만든다 */
export function buildBookView(snapshot: AddressBookSnapshot): BookView {
// uid → 그 uid 의 연락처 id (같은 uid 의 카드가 둘 이상일 수 있다 — 엔진이 막지 않는다)
const idsByUid = new Map<string, string[]>();
for (const card of snapshot.contacts) {
if (card.uid === null) continue;
const ids = idsByUid.get(card.uid) ?? [];
ids.push(card.id);
idsByUid.set(card.uid, ids);
}
const contactOrder = [...snapshot.contacts].sort(compareContacts);
const rank = new Map(contactOrder.map((card, index) => [card.id, index]));
const rankOf = (id: string) => rank.get(id) ?? 0;
const groupCards = [...snapshot.groups].sort(compareGroups);
const groupIdsByContact = new Map<string, string[]>();
const groups: BookGroup[] = groupCards.map((card) => {
const members = new Set<string>();
for (const uid of card.memberUids) {
for (const id of idsByUid.get(uid) ?? []) members.add(id);
}
const memberIds = [...members].sort((a, b) => rankOf(a) - rankOf(b));
for (const id of memberIds) {
const groupIds = groupIdsByContact.get(id) ?? [];
groupIds.push(card.id);
groupIdsByContact.set(id, groupIds);
}
return {
card,
group: { id: card.id, name: card.name, memberCount: memberIds.length },
memberIds,
};
});
const contacts: BookContact[] = contactOrder.map((card) => {
const contact: Contact = {
id: card.id,
...fieldsOf(card),
groupIds: groupIdsByContact.get(card.id) ?? [],
};
// 찾기 자료는 처음 찾을 때 만든다 — 바꿀 때마다 주소록을 다시 만드는데, 그때마다 모든 연락처의 글을 맞추지 않게
// (5,000장에 CPU 90ms 남짓 — 2026-10-29 잼)
let search: ContactSearchIndex | null = null;
return {
card,
contact,
get search() {
search ??= contactSearchIndex(contact);
return search;
},
};
});
const contactsByUid = new Map<string, BookContact[]>();
for (const entry of contacts) {
if (entry.card.uid === null) continue;
const owners = contactsByUid.get(entry.card.uid) ?? [];
owners.push(entry);
contactsByUid.set(entry.card.uid, owners);
}
const uidsInUse = new Set<string>(contactsByUid.keys());
for (const card of snapshot.groups) {
if (card.uid !== null) uidsInUse.add(card.uid);
}
const contactsByEmail = new Map<string, BookContact[]>();
const suggestionLines: ContactSuggestionLine[] = [];
for (const entry of contacts) {
const seen = new Set<string>();
for (const email of entry.contact.emails) {
const key = emailKey(email.address);
if (seen.has(key)) continue;
seen.add(key);
const owners = contactsByEmail.get(key) ?? [];
owners.push(entry);
contactsByEmail.set(key, owners);
suggestionLines.push({
name: entry.contact.name,
email: email.address,
company: entry.contact.company,
emailLabel: email.label,
});
}
}
return {
version: snapshot.version,
snapshot,
contacts,
groups,
contactsById: new Map(contacts.map((entry) => [entry.card.id, entry])),
groupsById: new Map(groups.map((entry) => [entry.card.id, entry])),
contactsByEmail,
contactsByUid,
uidsInUse,
suggestionLines,
weight:
snapshot.contacts.reduce((sum, card) => sum + cardWeight(card), 0) +
snapshot.groups.reduce((sum, card) => sum + groupWeight(card), 0),
};
}
/** 고치기의 바꾼 칸만 (undefined 는 바꾸지 않음) */
function definedFields(fields: Partial<ContactFields>): Partial<ContactFields> {
return Object.fromEntries(
Object.entries(fields).filter(([, value]) => value !== undefined),
);
}
function applyContactUpdate(
card: AddressBookContactCard,
update: AddressBookCardUpdate | undefined,
): AddressBookContactCard {
if (update?.kind !== 'contact') return card;
return { ...card, ...definedFields(update.fields) };
}
function applyGroupUpdate(
card: AddressBookGroupCard,
update: AddressBookCardUpdate | undefined,
): AddressBookGroupCard {
if (update?.kind !== 'group') return card;
return {
...card,
...(update.name !== undefined ? { name: update.name } : {}),
...(update.memberUids !== undefined
? { memberUids: [...update.memberUids] }
: {}),
};
}
/** 만들 카드의 크기 (id 는 엔진이 정하므로 빼고 잰다) */
function newCardWeight(card: NewAddressBookCard): number {
if (card.kind === 'contact') {
return cardWeight({ id: '', uid: card.uid, ...card.fields });
}
if (card.kind === 'group') {
return groupWeight({
id: '',
uid: card.uid,
name: card.name,
memberUids: card.memberUids,
});
}
const original = card.card;
return original.kind === 'contact'
? cardWeight({ ...original.card, id: '', uid: card.uid })
: groupWeight({
...original.card,
id: '',
uid: card.uid,
memberUids: card.memberUids ?? original.card.memberUids,
});
}
/** 바꾸기를 한 뒤의 주소록 크기 (어림 — 고친 칸은 보낸 값으로 잰다) — 크기 한도(계약 3절 "크기")를 엔진에 쓰기 전에 본다 */
export function weightAfterChanges(
book: BookView,
changes: AddressBookChanges,
): number {
const destroyed = new Set(changes.destroy);
let weight = book.weight;
for (const id of destroyed) {
const contact = book.contactsById.get(id)?.card;
if (contact) weight -= cardWeight(contact);
const group = book.groupsById.get(id)?.card;
if (group) weight -= groupWeight(group);
}
for (const update of changes.update) {
if (destroyed.has(update.id)) continue;
if (update.kind === 'contact') {
const current = book.contactsById.get(update.id)?.card;
if (current) {
weight +=
cardWeight(applyContactUpdate(current, update)) - cardWeight(current);
}
} else {
const current = book.groupsById.get(update.id)?.card;
if (current) {
weight +=
groupWeight(applyGroupUpdate(current, update)) - groupWeight(current);
}
}
}
for (const card of changes.create) weight += newCardWeight(card);
return weight;
}
/** 엔진에 쓴 바꾸기 — 만든 카드의 id · 만들거나 고친 카드의 지금 모양 (어댑터가 엔진에 보낸 카드를 읽은 것) */
export interface WrittenChanges {
readonly createdIds: ReadonlyMap<string, string>;
readonly cards: ReadonlyMap<string, AddressBookCard>;
}
/**
* 엔진이 받은 바꾸기를 기억한 주소록에 옮긴다 — 판이 같을 때만 쓰므로(ifInState) 엔진의 새 내용과 같다.
* 연락처의 고치기 · 만든 카드는 어댑터가 준 모양(칸 안의 남의 정보와 합친 결과)을, 그룹 고치기는 보낸 이름 · 구성원을 쓴다.
* 만든 카드의 id 나 모양이 하나라도 없으면 null (옮길 수 없다 — 다시 읽는다)
*/
export function applyAddressBookWrite(
snapshot: AddressBookSnapshot,
changes: AddressBookChanges,
written: WrittenChanges,
version: string,
): AddressBookSnapshot | null {
const destroyed = new Set(changes.destroy);
const updates = new Map(changes.update.map((update) => [update.id, update]));
const fresh = (id: string, kind: AddressBookCard['kind']) => {
const card = written.cards.get(id);
return card?.kind === kind ? card : undefined;
};
const contacts = snapshot.contacts
.filter((card) => !destroyed.has(card.id))
.map((card) => {
const read = fresh(card.id, 'contact');
if (read?.kind === 'contact') return read.card;
return applyContactUpdate(card, updates.get(card.id));
});
const groups = snapshot.groups
.filter((card) => !destroyed.has(card.id))
.map((card) => applyGroupUpdate(card, updates.get(card.id)));
for (const card of changes.create) {
const id = written.createdIds.get(card.key);
const read = id === undefined ? undefined : written.cards.get(id);
if (read === undefined) return null;
if (read.kind === 'contact') contacts.push(read.card);
else groups.push(read.card);
}
return { version, contacts, groups };
}
@@ -0,0 +1,123 @@
import type { BookView } from './address-book';
import { ContactBookCache } from './contact-book-cache';
/** 판과 크기만 쓰는 가짜 주소록 */
const view = (version: string, weight = 10): BookView =>
({ version, weight }) as unknown as BookView;
describe('ContactBookCache (직원마다 기억하는 주소록)', () => {
let now: number;
const clock = () => now;
beforeEach(() => {
now = 1_000_000;
});
it('판이 같을 때만 꺼내 준다 — 다르면 null (다시 읽는다)', () => {
const cache = new ContactBookCache(
{
maxUsers: 10,
maxTotalWeight: 1_000,
maxUserWeight: 1_000,
idleMs: 60_000,
},
clock,
);
const book = view('v1');
cache.set('ara', book);
expect(cache.get('ara', 'v1')).toBe(book);
expect(cache.get('ara', 'v2')).toBeNull();
expect(cache.get('doyun', 'v1')).toBeNull();
});
it('사람 수 상한 — 가장 오래 쓰지 않은 사람부터 잊는다', () => {
const cache = new ContactBookCache(
{
maxUsers: 2,
maxTotalWeight: 1_000,
maxUserWeight: 1_000,
idleMs: 60_000,
},
clock,
);
cache.set('a', view('va'));
cache.set('b', view('vb'));
// a 를 쓴다 — 이제 가장 오래 쓰지 않은 사람은 b
expect(cache.get('a', 'va')).not.toBeNull();
cache.set('c', view('vc'));
expect(cache.get('a', 'va')).not.toBeNull();
expect(cache.get('b', 'vb')).toBeNull();
expect(cache.get('c', 'vc')).not.toBeNull();
expect(cache.size).toBe(2);
});
it('크기 상한 — 합이 넘으면 오래된 사람부터 잊고, 한 사람 상한을 넘는 주소록은 기억하지 않는다', () => {
const cache = new ContactBookCache(
{ maxUsers: 10, maxTotalWeight: 100, maxUserWeight: 60, idleMs: 60_000 },
clock,
);
cache.set('a', view('va', 60));
cache.set('b', view('vb', 30));
cache.set('c', view('vc', 30));
expect(cache.get('a', 'va')).toBeNull();
expect(cache.get('b', 'vb')).not.toBeNull();
expect(cache.get('c', 'vc')).not.toBeNull();
cache.set('huge', view('vh', 61));
expect(cache.get('huge', 'vh')).toBeNull();
// 큰 주소록 때문에 다른 사람을 밀어내지 않는다
expect(cache.size).toBe(2);
});
it('한 사람 상한 안의 주소록은 늘 기억한다 — 다른 사람을 밀어내고라도 (계약 3절 "크기" 안의 주소록은 늘 기억할 수 있다)', () => {
const cache = new ContactBookCache(
{ maxUsers: 10, maxTotalWeight: 100, maxUserWeight: 80, idleMs: 60_000 },
clock,
);
cache.set('a', view('va', 50));
cache.set('b', view('vb', 40));
cache.set('big', view('vbig', 80));
expect(cache.get('big', 'vbig')).not.toBeNull();
expect(cache.get('a', 'va')).toBeNull();
expect(cache.get('b', 'vb')).toBeNull();
});
it('같은 사람을 다시 기억하면 크기를 한 번만 센다 · 잊으면 크기도 빠진다', () => {
const cache = new ContactBookCache(
{ maxUsers: 10, maxTotalWeight: 100, maxUserWeight: 100, idleMs: 60_000 },
clock,
);
for (let index = 0; index < 5; index += 1) {
cache.set('a', view(`v${index}`, 60));
}
cache.set('b', view('vb', 40));
expect(cache.size).toBe(2);
cache.delete('a');
cache.set('c', view('vc', 60));
expect(cache.get('b', 'vb')).not.toBeNull();
expect(cache.get('c', 'vc')).not.toBeNull();
});
it('오래 쓰지 않은 것은 기억할 때 함께 지운다 (줄지 않는 표를 두지 않는다)', () => {
const cache = new ContactBookCache(
{
maxUsers: 10,
maxTotalWeight: 1_000,
maxUserWeight: 1_000,
idleMs: 60_000,
},
clock,
);
cache.set('old', view('vo'));
now += 60_000;
cache.set('new', view('vn'));
expect(cache.size).toBe(1);
expect(cache.get('old', 'vo')).toBeNull();
});
});
@@ -0,0 +1,82 @@
import type { BookView } from './address-book';
export interface ContactBookCacheLimits {
/** 기억하는 사람 수 */
readonly maxUsers: number;
/** 모두 합친 대략의 크기(글자 수) — 넘으면 가장 오래 쓰지 않은 사람부터 잊는다 */
readonly maxTotalWeight: number;
/** 한 사람의 크기 상한 — 이보다 큰 주소록은 기억하지 않는다(요청마다 다시 읽는다). 쓰기의 크기 한도와 같은 잣대 */
readonly maxUserWeight: number;
/** 이만큼 쓰지 않으면 잊는다 (ms) */
readonly idleMs: number;
}
interface Entry {
readonly view: BookView;
lastUsedAt: number;
}
/**
* 직원마다 기억하는 주소록 (계약 3절 "캐시") — 판(version)이 같을 때만 꺼내 준다. 쓰는 쪽은 요청마다 엔진에 판을 물어 견준다.
* 가장 오래 쓰지 않은 사람부터 잊는다(사람 수 · 모두 합친 크기 상한), 오래 쓰지 않은 것은 기억할 때 함께 지운다 (줄지 않는 표를 두지 않는다).
* 한 사람의 상한(maxUserWeight)은 모두 합친 상한보다 작게 둔다 — 크기 한도 안의 주소록은 늘 기억할 수 있다(다른 사람을 밀어낼 뿐).
*
* ⚠️ 프로세스 메모리다. 인스턴스를 늘리면 인스턴스마다 따로 기억한다 — 틀리지는 않는다(쓸 때마다 판을 엔진에 묻는다), 처음 읽기가 인스턴스마다 생길 뿐이다.
*/
export class ContactBookCache {
/** 넣은(쓴) 차례 — 맨 앞이 가장 오래 쓰지 않은 사람 */
private readonly entries = new Map<string, Entry>();
private totalWeight = 0;
constructor(
private readonly limits: ContactBookCacheLimits,
private readonly now: () => number = () => Date.now(),
) {}
/** 판이 같으면 기억한 주소록 (쓴 사람을 맨 뒤로), 아니면 null */
get(user: string, version: string): BookView | null {
const entry = this.entries.get(user);
if (!entry || entry.view.version !== version) return null;
entry.lastUsedAt = this.now();
this.entries.delete(user);
this.entries.set(user, entry);
return entry.view;
}
set(user: string, view: BookView): void {
this.delete(user);
this.dropIdle();
// 한 사람의 상한을 넘는 주소록(다른 프로그램이 크게 만든 것)은 기억하지 않는다 — 요청마다 다시 읽는다
if (view.weight > this.limits.maxUserWeight) return;
this.entries.set(user, { view, lastUsedAt: this.now() });
this.totalWeight += view.weight;
while (
this.entries.size > this.limits.maxUsers ||
this.totalWeight > this.limits.maxTotalWeight
) {
const oldest = this.entries.keys().next().value;
if (oldest === undefined) break;
this.delete(oldest);
}
}
delete(user: string): void {
const entry = this.entries.get(user);
if (!entry) return;
this.entries.delete(user);
this.totalWeight -= entry.view.weight;
}
/** 기억한 사람 수 (점검용) */
get size(): number {
return this.entries.size;
}
private dropIdle(): void {
const cutoff = this.now() - this.limits.idleMs;
for (const [user, entry] of this.entries) {
if (entry.lastUsedAt <= cutoff) this.delete(user);
}
}
}
@@ -0,0 +1,164 @@
import {
Body,
Controller,
Delete,
Get,
Header,
HttpCode,
HttpStatus,
Param,
Patch,
Post,
Query,
UseGuards,
} from '@nestjs/common';
import { ApiCookieAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import {
ApiErrorResponse,
ApiStandardResponse,
} from '@/common/decorators/api-standard-response.decorator';
import {
CurrentUser,
type AuthenticatedUser,
} from '@/common/decorators/current-user.decorator';
import { CursorPaginationQueryDto } from '@/common/dto/pagination.dto';
import { JwtAuthGuard } from '@/common/guards/jwt-auth.guard';
import { ContactIdPipe } from './contact-id.pipe';
import {
CONTACT_GROUPS_ROUTE,
MAX_CONTACT_GROUP_MEMBERS,
MAX_CONTACT_GROUP_NAME_LENGTH,
MAX_CONTACT_GROUPS,
MAX_CONTACTS,
MAX_GROUP_MEMBERS_PER_REQUEST,
} from './contacts.constants';
import { ContactsService } from './contacts.service';
import {
ContactGroupCreateDto,
ContactGroupDeletedDto,
ContactGroupDto,
ContactGroupMembersAddedDto,
ContactGroupMembersDto,
ContactGroupPageDto,
ContactGroupRenameDto,
} from './dto/contact-group.dto';
const NAME_RULES = `이름은 앞뒤 공백을 뺀 1~${MAX_CONTACT_GROUP_NAME_LENGTH}자 · 제어 문자 없음, 이 주소록 안에서 겹치지 않는다(앞뒤 공백을 빼고 ASCII 대소문자를 무시하고 견준다).`;
// 내 주소록 — 그룹 (계약 3절 "주소록", 8단계). 엔진 카드는 JSContact kind group — 구성원은 연락처 카드의 uid 로 잇는다
@ApiTags('Contacts')
@ApiCookieAuth()
@UseGuards(JwtAuthGuard)
@ApiErrorResponse('AUTH_001', '로그인 필요 (쿠키 없음·만료)')
@ApiErrorResponse('MAIL_001', '메일 엔진 장애')
@Controller(CONTACT_GROUPS_ROUTE)
export class ContactGroupsController {
constructor(private readonly contacts: ContactsService) {}
@Get()
@Header('Cache-Control', 'private, no-store')
@ApiOperation({
summary: '그룹 목록 (커서 페이지네이션 · total 포함)',
description:
'이름 차례(한국어 사전 차례 → id). memberCount 는 이 주소록에 있는 연락처 가운데 구성원 수. ' +
'그룹은 받는 사람 추천에 넣지 않는다 — 쓰기 화면이 이 목록을 받아 그룹 이름으로 찾는다. Cache-Control: private, no-store.',
})
@ApiStandardResponse(ContactGroupPageDto, { description: '그룹 목록' })
@ApiErrorResponse('VAL_001', '잘못된 커서 · limit 범위 밖')
list(
@CurrentUser() user: AuthenticatedUser,
@Query() query: CursorPaginationQueryDto,
) {
return this.contacts.listGroups(user, query);
}
@Post()
@ApiOperation({
summary: '그룹 만들기',
description:
`${NAME_RULES} 그룹은 ${MAX_CONTACT_GROUPS}개까지. 본문은 { name } 만 — 구성원은 POST /api/contact-groups/{id}/members 로 넣고, ` +
'지운 그룹은 POST /api/contact-restorations 로 되돌린다.',
})
@ApiStandardResponse(ContactGroupDto, {
status: 201,
description: '만든 그룹',
})
@ApiErrorResponse('VAL_001', '이름 형식 · 길이 · 모르는 필드')
@ApiErrorResponse(
'CONTACT_001',
`CONTACT_001 같은 이름의 그룹이 있다 / CONTACT_002 그룹 ${MAX_CONTACT_GROUPS}개 한도 — 둘 다 409, 아무것도 바꾸지 않았다`,
)
create(
@CurrentUser() user: AuthenticatedUser,
@Body() dto: ContactGroupCreateDto,
) {
return this.contacts.createGroup(user, dto);
}
@Patch(':id')
@ApiOperation({
summary: '그룹 이름 바꾸기',
description: `${NAME_RULES} 같은 이름이면 바꾸지 않고 그대로 돌려준다.`,
})
@ApiStandardResponse(ContactGroupDto, { description: '바꾼 그룹' })
@ApiErrorResponse('VAL_001', '이름 형식 · 길이 · 모르는 필드')
@ApiErrorResponse('RES_001', '없는 그룹 (형식이 틀린 id 포함)')
@ApiErrorResponse('CONTACT_001', '같은 이름의 다른 그룹이 있다')
rename(
@CurrentUser() user: AuthenticatedUser,
@Param('id', ContactIdPipe) id: string,
@Body() dto: ContactGroupRenameDto,
) {
return this.contacts.renameGroup(user, id, dto.name);
}
@Delete(':id')
@ApiOperation({
summary: '그룹 지우기',
description:
'그룹 카드만 지운다 — 연락처는 그대로. 응답은 이름 · 구성원과 되돌리기 표(restoreToken — POST /api/contact-restorations 로 5분 안에 한 번, ' +
'그때의 구성원 가운데 지금도 있는 연락처로 다시 만든다. 서버가 기억하지 못했으면 null).',
})
@ApiStandardResponse(ContactGroupDeletedDto, {
description: '지운 그룹 — 이름 · 구성원 · 되돌리기 표',
})
@ApiErrorResponse('RES_001', '없는 그룹')
remove(
@CurrentUser() user: AuthenticatedUser,
@Param('id', ContactIdPipe) id: string,
) {
return this.contacts.removeGroup(user, id);
}
@Post(':id/members')
@HttpCode(HttpStatus.OK)
@ApiOperation({
summary: '그룹에 구성원 넣기',
description:
`contactIds(내 연락처)와 directoryIds(사내 목록)를 합쳐 1~${MAX_GROUP_MEMBERS_PER_REQUEST}개. ` +
'사내 목록에서 고른 사람은 같은 주소(대소문자 무시)의 연락처가 내 주소록에 있으면 그 연락처를, 없으면 이름 · 주소로 새로 만들어 넣는다. ' +
`이미 구성원인 연락처 · uid 가 없는 카드는 세지 않는다. 연락처 ${MAX_CONTACTS.toLocaleString('en-US')}개 · 그룹 하나에 ${MAX_CONTACT_GROUP_MEMBERS}명 · ` +
'주소록 크기 800만까지 — 넘으면 아무것도 하지 않는다.',
})
@ApiStandardResponse(ContactGroupMembersAddedDto, {
description: '넣은 뒤의 그룹 · 이번에 넣은 수 · 새로 만든 연락처 수',
})
@ApiErrorResponse(
'VAL_001',
`합쳐 0개 · ${MAX_GROUP_MEMBERS_PER_REQUEST}개 초과 · 형식 · 겹침 · 모르는 필드`,
)
@ApiErrorResponse('RES_001', '없는 그룹 · 연락처 · 사내 목록 id')
@ApiErrorResponse(
'CONTACT_002',
`연락처 ${MAX_CONTACTS.toLocaleString('en-US')}개 · 그룹 구성원 ${MAX_CONTACT_GROUP_MEMBERS}명 · 크기 한도 — 아무것도 바꾸지 않았다`,
)
@ApiErrorResponse('MAIL_001', '메일 엔진 장애 · 사내 목록을 읽지 못함')
addMembers(
@CurrentUser() user: AuthenticatedUser,
@Param('id', ContactIdPipe) id: string,
@Body() dto: ContactGroupMembersDto,
) {
return this.contacts.addGroupMembers(user, id, dto);
}
}
@@ -0,0 +1,20 @@
import { Injectable, PipeTransform } from '@nestjs/common';
import { BusinessException } from '@/common/errors/business.exception';
import { CONTACT_ID_PATTERN } from './contacts.constants';
/**
* 경로의 연락처 · 그룹 id 형식 확인.
* 형식이 틀린 id 는 "그런 대상이 없다"와 같으므로 RES_001 로 응답한다 (엔진까지 보내지 않는다 — 메일의 MailIdPipe 와 같은 규칙)
*/
@Injectable()
export class ContactIdPipe implements PipeTransform<unknown, string> {
transform(value: unknown): string {
if (typeof value === 'string' && CONTACT_ID_PATTERN.test(value)) {
return value;
}
throw new BusinessException('RES_001', {
context: { reason: 'malformed-contact-id' },
});
}
}
@@ -0,0 +1,55 @@
import {
type PaginatedResult,
toPaginatedResult,
} from '@/common/dto/pagination.dto';
import { BusinessException } from '@/common/errors/business.exception';
import {
decodeOffsetCursor,
encodeOffsetCursor,
} from '@/shared/utils/offset-cursor';
/**
* 주소록 · 그룹 · 사내 목록의 커서 페이지 (계약 3절) — 서버가 그 자리에서 거르고 줄 세운 목록의 위치를 커서로 준다.
* 커서는 받은 그 범위(목록 · q · groupId · kind)로만 쓴다 — 다르면 엔진에 묻기 전에 400 VAL_001.
* 차례 사이에 목록이 바뀌면 다음 쪽에서 한두 개가 겹치거나 빠질 수 있다.
*/
/** 목록 범위 — 목록 이름과 거르는 값(없으면 null)을 이은 글 */
export function listScope(
list: string,
...filters: (string | null | undefined)[]
): string {
return JSON.stringify([list, ...filters.map((value) => value ?? null)]);
}
/** 커서의 위치 — 없으면 0 */
export function cursorOffset(
cursor: string | undefined,
scope: string,
): number {
if (cursor === undefined) return 0;
const reading = decodeOffsetCursor(cursor, scope);
if ('problem' in reading) {
throw new BusinessException('VAL_001', {
context: { reason: `invalid-contact-cursor:${reading.problem}` },
});
}
return reading.offset;
}
/** 거른 목록의 한 쪽 — total 은 거른 개수 */
export function pageOf<T>(
items: readonly T[],
start: number,
limit: number,
scope: string,
): PaginatedResult<T> {
const page = items.slice(start, start + limit);
const next = start + page.length;
const hasNext = next < items.length;
return toPaginatedResult([...page], {
hasNext,
nextCursor: hasNext ? encodeOffsetCursor(next, scope) : null,
total: items.length,
});
}
@@ -0,0 +1,63 @@
import { Body, Controller, Post, UseGuards } from '@nestjs/common';
import { ApiCookieAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import {
ApiErrorResponse,
ApiStandardResponse,
} from '@/common/decorators/api-standard-response.decorator';
import {
CurrentUser,
type AuthenticatedUser,
} from '@/common/decorators/current-user.decorator';
import { JwtAuthGuard } from '@/common/guards/jwt-auth.guard';
import {
CONTACT_RESTORATIONS_ROUTE,
MAX_CONTACT_GROUP_MEMBERS,
MAX_CONTACT_GROUPS,
MAX_CONTACTS,
} from './contacts.constants';
import { ContactsService } from './contacts.service';
import {
ContactRestorationDto,
ContactRestoredDto,
} from './dto/contact-restoration.dto';
// 내 주소록 — 지운 연락처 · 그룹 되돌리기 (계약 3절 "되돌리기", 8단계). 지우기 응답의 restoreToken 으로 원본 그대로 다시 만든다
@ApiTags('Contacts')
@ApiCookieAuth()
@UseGuards(JwtAuthGuard)
@ApiErrorResponse('AUTH_001', '로그인 필요 (쿠키 없음·만료)')
@Controller(CONTACT_RESTORATIONS_ROUTE)
export class ContactRestorationsController {
constructor(private readonly contacts: ContactsService) {}
@Post()
@ApiOperation({
summary: '지운 연락처 · 그룹 되돌리기',
description:
'서버가 지우기 직전에 읽은 엔진 카드 전체(다른 프로그램의 칸 · uid 포함)를 그대로 다시 만든다 — 같은 uid(그사이 같은 uid 의 카드가 생겼으면 새 uid), ' +
`id 는 새로 받는다. 연락처는 그때 속했고 지금도 있는 그룹에 다시 넣고(구성원 ${MAX_CONTACT_GROUP_MEMBERS}명이 찬 그룹은 건너뛴다), ` +
'그룹은 그때의 구성원 가운데 지금도 있는 연락처로 다시 만든다. 표는 5분 · 한 번만(성공하면 지운다 — 실패하면 5분 안에 다시 쓸 수 있다). ' +
'서버를 다시 띄우면 표는 사라진다.',
})
@ApiStandardResponse(ContactRestoredDto, {
status: 201,
description: '다시 만든 연락처 · 그룹',
})
@ApiErrorResponse('VAL_001', '표 형식 · 모르는 필드')
@ApiErrorResponse(
'RES_001',
'표가 없음 · 지남 · 이미 씀 · 다른 직원의 표 (모두 같게 답한다)',
)
@ApiErrorResponse(
'CONTACT_001',
`CONTACT_001 같은 이름의 그룹이 그사이 생겼다 / CONTACT_002 연락처 ${MAX_CONTACTS.toLocaleString('en-US')}개 · 그룹 ${MAX_CONTACT_GROUPS}개 · 크기 한도 — 둘 다 409, 아무것도 바꾸지 않았다`,
)
@ApiErrorResponse('MAIL_001', '메일 엔진 장애 (엔진이 원본을 받지 않음 포함)')
restore(
@CurrentUser() user: AuthenticatedUser,
@Body() dto: ContactRestorationDto,
) {
return this.contacts.restore(user, dto.token);
}
}
@@ -0,0 +1,114 @@
import {
ContactRestoreStore,
type RestorableCard,
} from './contact-restore-store';
import {
CONTACT_RESTORE_TOKEN_BYTES,
CONTACT_RESTORE_TOKEN_PATTERN,
} from './contacts.constants';
const LIMITS = {
ttlMs: 5 * 60_000,
maxPerUser: 3,
maxCardBytes: 100,
maxTotalBytes: 250,
tokenBytes: CONTACT_RESTORE_TOKEN_BYTES,
};
const contact = (bytes = 10, name = '박지훈'): RestorableCard => ({
kind: 'contact',
original: { source: '{}', bytes },
card: {
id: 'c1',
uid: 'urn:uuid:c1',
name,
emails: [],
phones: [],
company: null,
department: null,
title: null,
note: null,
},
groups: [],
});
describe('ContactRestoreStore (되돌리기 표)', () => {
let now: number;
let store: ContactRestoreStore;
beforeEach(() => {
now = 1_000_000;
store = new ContactRestoreStore(LIMITS, () => now);
});
it('표는 무작위 128비트 base64url(22자) — 그 직원만 찾는다', () => {
const card = contact();
const token = store.remember('ara', card);
expect(token).toMatch(CONTACT_RESTORE_TOKEN_PATTERN);
expect(store.find('ara', token ?? '')).toBe(card);
expect(store.find('doyun', token ?? '')).toBeNull();
expect(store.find('ara', 'AAAAAAAAAAAAAAAAAAAAAA')).toBeNull();
// 다른 직원이 찾아도 표는 그대로 남는다
expect(store.find('ara', token ?? '')).toBe(card);
expect(store.remember('ara', contact())).not.toBe(token);
});
it('한 번만 — 쓰는 쪽이 forget 하면 다시 찾지 못한다', () => {
const token = store.remember('ara', contact()) ?? '';
store.forget(token);
expect(store.find('ara', token)).toBeNull();
expect(store.size).toBe(0);
// 없는 표를 잊어도 괜찮다
expect(() => store.forget(token)).not.toThrow();
});
it('5분이 지나면 찾지 못한다 (그때 잊는다)', () => {
const token = store.remember('ara', contact()) ?? '';
now += LIMITS.ttlMs - 1;
expect(store.find('ara', token)).not.toBeNull();
now += 1;
expect(store.find('ara', token)).toBeNull();
expect(store.size).toBe(0);
});
it('새로 기억할 때 지난 표를 함께 잊는다 (줄지 않는 표를 두지 않는다)', () => {
store.remember('ara', contact());
store.remember('doyun', contact());
now += LIMITS.ttlMs;
store.remember('seojun', contact());
expect(store.size).toBe(1);
});
it('직원마다 최근 maxPerUser 개 — 넘으면 그 직원의 가장 오래된 표부터 (다른 직원 것은 그대로)', () => {
const tokens = Array.from(
{ length: LIMITS.maxPerUser + 1 },
(_, index) => store.remember('ara', contact(10, `n${index}`)) ?? '',
);
const other = store.remember('doyun', contact()) ?? '';
expect(store.find('ara', tokens[0] ?? '')).toBeNull();
for (const token of tokens.slice(1)) {
expect(store.find('ara', token)).not.toBeNull();
}
expect(store.find('doyun', other)).not.toBeNull();
});
it('원본이 maxCardBytes 를 넘으면 기억하지 않는다 — 표 null', () => {
expect(store.remember('ara', contact(LIMITS.maxCardBytes))).not.toBeNull();
expect(store.remember('ara', contact(LIMITS.maxCardBytes + 1))).toBeNull();
expect(store.size).toBe(1);
});
it('모두 합친 크기가 넘으면 가장 오래된 표부터 잊는다 — 직원이 달라도', () => {
const first = store.remember('ara', contact(100)) ?? '';
const second = store.remember('doyun', contact(100)) ?? '';
const third = store.remember('seojun', contact(100)) ?? '';
expect(store.find('ara', first)).toBeNull();
expect(store.find('doyun', second)).not.toBeNull();
expect(store.find('seojun', third)).not.toBeNull();
// 쓴 표를 잊으면 크기도 빠진다 — 다음 표가 앞 표를 밀어내지 않는다
store.forget(second);
const fourth = store.remember('ara', contact(100)) ?? '';
expect(store.find('seojun', third)).not.toBeNull();
expect(store.find('ara', fourth)).not.toBeNull();
});
});
@@ -0,0 +1,143 @@
import { randomBytes } from 'crypto';
import type {
AddressBookContactCard,
AddressBookGroupCard,
RemovedCardOriginal,
} from './contacts.provider';
/** 지운 연락처가 그때 속했던 그룹 — 되돌릴 때 uid 로 찾는다(그사이 되돌린 그룹은 id 가 바뀌어도 uid 는 같다), uid 가 없으면 id 로 */
export interface RestorableGroupRef {
readonly id: string;
readonly uid: string | null;
}
/** 되돌릴 수 있는 것 — 지운 카드의 원본(엔진 카드 전체)과 지울 때의 우리 칸 */
export type RestorableCard =
| {
readonly kind: 'contact';
readonly original: RemovedCardOriginal;
readonly card: AddressBookContactCard;
/** 그때 속했던 그룹 */
readonly groups: readonly RestorableGroupRef[];
}
| {
readonly kind: 'group';
readonly original: RemovedCardOriginal;
readonly card: AddressBookGroupCard;
};
export interface ContactRestoreLimits {
/** 표를 기억하는 시간 (ms) */
readonly ttlMs: number;
/** 직원마다 기억하는 표 수 — 넘으면 그 직원의 가장 오래된 표부터 잊는다 */
readonly maxPerUser: number;
/** 원본 하나의 상한(UTF-8 바이트) — 넘으면 기억하지 않는다(표 null) */
readonly maxCardBytes: number;
/** 모두 합친 원본의 상한(바이트) — 넘으면 가장 오래된 표부터 잊는다 */
readonly maxTotalBytes: number;
/** 표의 무작위 바이트 수 */
readonly tokenBytes: number;
}
interface Entry {
readonly user: string;
readonly card: RestorableCard;
readonly expiresAt: number;
}
/**
* 되돌리기 표 (계약 3절 "되돌리기") — 지운 연락처 · 그룹의 원본을 직원마다 잠깐 기억한다(서버 메모리).
* - 표는 무작위 128비트(base64url) — 지운 직원의 것만 쓸 수 있다(다른 직원 · 지난 표 · 없는 표는 find 가 null)
* - 한 번만 쓴다 — 되돌리기가 성공하면 쓰는 쪽이 forget 한다(실패하면 남겨 두어 다시 쓸 수 있다)
* - 5분 · 직원마다 최근 20개 · 원본 하나 256KB · 모두 합쳐 32MB (contacts.constants.ts) — 넣은 차례가 곧 끝나는 차례라 앞에서부터 잊는다
* - 표 · 원본은 로그에 남기지 않는다
*
* ⚠️ 프로세스 메모리다. 서버를 여러 대로 늘리면 공유 저장소로 옮겨야 한다 — 다른 인스턴스로 간 되돌리기는 404 RES_001 이 된다.
*/
export class ContactRestoreStore {
/** 표 → 기억한 것 (넣은 차례 — 맨 앞이 가장 먼저 끝난다) */
private readonly entries = new Map<string, Entry>();
/** 직원 → 그 직원의 표 (넣은 차례) */
private readonly byUser = new Map<string, string[]>();
private totalBytes = 0;
constructor(
private readonly limits: ContactRestoreLimits,
private readonly now: () => number = () => Date.now(),
) {}
/** 기억하고 표를 준다 — 원본이 상한을 넘으면 기억하지 않고 null */
remember(user: string, card: RestorableCard): string | null {
this.dropExpired();
if (card.original.bytes > this.limits.maxCardBytes) return null;
const tokens = this.byUser.get(user) ?? [];
while (tokens.length >= this.limits.maxPerUser) {
const oldest = tokens[0];
if (oldest === undefined) break;
this.forget(oldest);
}
let token = this.newToken();
while (this.entries.has(token)) token = this.newToken();
this.entries.set(token, {
user,
card,
expiresAt: this.now() + this.limits.ttlMs,
});
const owned = this.byUser.get(user) ?? [];
owned.push(token);
this.byUser.set(user, owned);
this.totalBytes += card.original.bytes;
while (this.totalBytes > this.limits.maxTotalBytes) {
const oldest = this.entries.keys().next().value;
if (oldest === undefined) break;
this.forget(oldest);
}
return this.entries.has(token) ? token : null;
}
/** 그 직원의 표 — 없거나 · 지났거나 · 다른 직원의 것이면 null (지난 표는 이때 잊는다) */
find(user: string, token: string): RestorableCard | null {
const entry = this.entries.get(token);
if (!entry) return null;
if (entry.expiresAt <= this.now()) {
this.forget(token);
return null;
}
return entry.user === user ? entry.card : null;
}
/** 표를 잊는다 (쓴 표 · 밀려난 표) */
forget(token: string): void {
const entry = this.entries.get(token);
if (!entry) return;
this.entries.delete(token);
this.totalBytes -= entry.card.original.bytes;
const owned = this.byUser.get(entry.user);
if (!owned) return;
const index = owned.indexOf(token);
if (index >= 0) owned.splice(index, 1);
if (owned.length === 0) this.byUser.delete(entry.user);
}
/** 기억한 표 수 (점검용) */
get size(): number {
return this.entries.size;
}
private newToken(): string {
return randomBytes(this.limits.tokenBytes).toString('base64url');
}
/** 지난 표를 잊는다 — 넣은 차례가 곧 끝나는 차례다 */
private dropExpired(): void {
const now = this.now();
for (const [token, entry] of this.entries) {
if (entry.expiresAt > now) break;
this.forget(token);
}
}
}
@@ -0,0 +1,223 @@
import {
compareContacts,
compareDirectoryEntries,
compareGroups,
contactSearchIndex,
foldText,
matchesContact,
matchesDirectoryEntry,
nameInitials,
parseContactSearch,
type ContactSearchQuery,
} from './contact-search';
import type { Contact, DirectoryEntry } from './contacts.types';
const contact = (overrides: Partial<Contact> = {}): Contact => ({
id: 'c1',
name: '박지훈',
emails: [{ address: 'Jihoon.Park@Hanbit.example', label: 'work' }],
phones: [{ number: '010-3192-5540', label: 'mobile' }],
company: '한빛상사',
department: '구매팀',
title: 'Manager',
note: '메모에는 찾는 글이 있어도 맞지 않는다',
groupIds: [],
...overrides,
});
const search = (q: string): ContactSearchQuery => {
const parsed = parseContactSearch(q);
if (parsed === null) throw new Error(`해석하지 못한 찾는 글: ${q}`);
return parsed;
};
const matches = (q: string, target: Contact = contact()) =>
matchesContact(contactSearchIndex(target), search(q));
describe('찾는 글 해석 (parseContactSearch)', () => {
it('앞뒤 공백을 빼고 비면 null — 거르지 않는다', () => {
for (const q of [undefined, null, '', ' ', '\u3000']) {
expect(parseContactSearch(q)).toBeNull();
}
});
it('초성만 친 글 — 띄어쓰기는 빼고, 겹자음은 두 초성으로 나눈다 (입력기가 ㄱ 다음 ㅅ 을 ㄳ 으로 합친다)', () => {
expect(parseContactSearch('ㅂㅈㅎ')).toEqual({
kind: 'initials',
initials: 'ㅂㅈㅎ',
});
expect(parseContactSearch(' ㅂ ㅈ ㅎ ')).toEqual({
kind: 'initials',
initials: 'ㅂㅈㅎ',
});
expect(parseContactSearch('ㄳㅇ')).toEqual({
kind: 'initials',
initials: 'ㄱㅅㅇ',
});
// 쌍자음은 그대로 (초성이다)
expect(parseContactSearch('ㄲㅊ')).toEqual({
kind: 'initials',
initials: 'ㄲㅊ',
});
});
it('초성과 다른 글자가 섞이면 글로 찾는다 — 번호처럼 생긴 글이면 숫자도', () => {
expect(parseContactSearch('박ㅈ')).toEqual({
kind: 'text',
text: '박ㅈ',
digits: null,
});
expect(parseContactSearch(' 010-3192 ')).toEqual({
kind: 'text',
text: '010-3192',
digits: '0103192',
});
expect(parseContactSearch('Hanbit')).toEqual({
kind: 'text',
text: 'hanbit',
digits: null,
});
// 숫자가 없는 기호뿐인 글은 번호로 보지 않는다
expect(parseContactSearch('+-')).toMatchObject({ digits: null });
// 숫자와 글자가 섞이면 번호가 아니다
expect(parseContactSearch('a1')).toMatchObject({ digits: null });
});
});
describe('연락처 찾기 (matchesContact)', () => {
it('초성 — 이름의 초성과 견준다 (띄어쓰기는 빼고, 가운데부터여도)', () => {
expect(matches('ㅂㅈㅎ')).toBe(true);
expect(matches('ㅈㅎ')).toBe(true);
expect(matches('ㅂㅎ')).toBe(false);
expect(matches('ㅂㅈㅎ', contact({ name: '박 지훈' }))).toBe(true);
// 이름이 없으면 초성으로는 찾지 못한다
expect(matches('ㅎ', contact({ name: null }))).toBe(false);
// 초성은 이름만 본다 — 회사(한빛상사)의 초성은 보지 않는다
expect(matches('ㅎㅂ')).toBe(false);
});
it('겹자음으로 합쳐진 초성도 찾는다 — 김선우를 ㄱ ㅅ ㅇ 으로 치면 ㄳㅇ', () => {
expect(matches('ㄳㅇ', contact({ name: '김선우' }))).toBe(true);
});
it('이름 · 주소 · 회사 · 부서 · 직책 · 전화번호에 든 글 — ASCII 대소문자 무시, 메모는 보지 않는다', () => {
for (const q of [
'지훈',
'jihoon.park',
'HANBIT',
'한빛',
'구매',
'manager',
]) {
expect(matches(q)).toBe(true);
}
expect(matches('찾는 글')).toBe(false);
expect(matches('없는 사람')).toBe(false);
});
it('전화번호는 숫자만 견준다 — 01031925540 · 3192 · 010 3192 가 010-3192-5540 에 맞는다', () => {
for (const q of ['01031925540', '3192', '010 3192', '(010)3192', '5540']) {
expect(matches(q)).toBe(true);
}
expect(matches('3193')).toBe(false);
// 번호 칸의 글 그대로도 맞는다
expect(matches('010-3192')).toBe(true);
});
it('조합형(NFD)으로 친 한글도 찾는다', () => {
expect(matches('지훈'.normalize('NFD'))).toBe(true);
});
it('ASCII 가 아닌 대소문자는 가리지 않고 그대로 견준다 (계약 — ASCII 만 무시)', () => {
expect(foldText('ÄBC')).toBe('Äbc');
});
});
describe('사내 목록 찾기 (matchesDirectoryEntry)', () => {
const entry: DirectoryEntry = {
id: 'd',
name: '이도윤',
email: 'doyun.lee@arasystem.test',
kind: 'person',
};
it('이름 · 주소로 같은 규칙 — 초성 · 글', () => {
expect(matchesDirectoryEntry(entry, search('ㄷㅇ'))).toBe(true);
expect(matchesDirectoryEntry(entry, search('도윤'))).toBe(true);
expect(matchesDirectoryEntry(entry, search('DOYUN'))).toBe(true);
expect(matchesDirectoryEntry(entry, search('ㅅㅈ'))).toBe(false);
expect(matchesDirectoryEntry(entry, search('서준'))).toBe(false);
});
});
describe('이름의 초성 (nameInitials)', () => {
it('한글 음절은 초성으로, 띄어쓰기는 빼고, 그 밖의 글자는 그대로', () => {
expect(nameInitials('박지훈')).toBe('ㅂㅈㅎ');
expect(nameInitials('꽃 님')).toBe('ㄲㄴ');
expect(nameInitials('Kim 아라')).toBe('kimㅇㄹ');
});
});
describe('차례', () => {
it('연락처 — 이름(한국어 사전 차례), 이름이 없으면 대표 주소, 같으면 대표 주소 → id, 이름도 주소도 없으면 맨 뒤', () => {
const cards = [
contact({ id: 'z', name: null, emails: [] }),
contact({ id: 'c', name: '하늘', emails: [] }),
contact({
id: 'b',
name: null,
emails: [{ address: 'apple@x.example', label: 'other' }],
}),
contact({
id: 'a2',
name: '가나다',
emails: [{ address: 'b@x.example', label: 'work' }],
}),
contact({
id: 'a1',
name: '가나다',
emails: [{ address: 'b@x.example', label: 'work' }],
}),
contact({
id: 'a0',
name: '가나다',
emails: [{ address: 'a@x.example', label: 'work' }],
}),
contact({ id: 'd', name: '나비', emails: [] }),
];
expect([...cards].sort(compareContacts).map((card) => card.id)).toEqual([
'a0',
'a1',
'a2',
'd',
'c',
// 한국어 사전 차례는 한글이 영문(apple — 이름이 없어 대표 주소로 견준다)보다 앞이다
'b',
'z',
]);
});
it('그룹 — 이름 → id · 사내 목록 — 이름 → 주소 → id', () => {
expect(
[
{ id: '2', name: '하나' },
{ id: '1', name: '거래처' },
{ id: '0', name: '거래처' },
]
.sort(compareGroups)
.map((group) => group.id),
).toEqual(['0', '1', '2']);
const people: DirectoryEntry[] = [
{ id: '3', name: '이도윤', email: 'b@x.test', kind: 'person' },
{ id: '2', name: '이도윤', email: 'a@x.test', kind: 'person' },
{ id: '1', name: '김아라', email: 'z@x.test', kind: 'person' },
];
expect(people.sort(compareDirectoryEntries).map((p) => p.id)).toEqual([
'1',
'2',
'3',
]);
});
});
@@ -0,0 +1,204 @@
import type { Contact, DirectoryEntry } from './contacts.types';
/**
* 주소록 · 사내 목록의 찾기(q)와 차례 (순수 함수) — 계약 3절 "찾기" · "차례".
*
* - 초성만 친 글(ㄱ~ㅎ, 쌍자음 · 겹자음 포함)이면 이름의 초성과 견준다 — ㅂㅈㅎ → 박지훈. 겹자음은 나눈다(ㄱ 다음 ㅅ 을 치면 입력기가 ㄳ 으로 합친다)
* - 그 밖에는 이름 · 주소 · 회사 · 부서 · 직책 · 전화번호에 그 글이 들어 있으면 맞다 (ASCII 대소문자 무시, 유니코드 NFC 로 맞춰 견준다)
* - 전화번호는 숫자만 견준다 — 번호처럼 생긴 글(숫자 · 띄어쓰기 · + - ( ) . # *)이면 그 숫자가 번호의 숫자 안에 있으면 맞다
* (`01031925540` · `3192` 가 `010-3192-5540` 에 맞는다)
* - 사내 목록은 이름 · 주소로 같은 규칙
*
* 엔진의 주소록 검색은 낱말 단위이고 이름 정렬을 하지 못해서(2026-10-29 확인) 서버가 주소록 전체를 두고 찾고 줄 세운다.
*/
const SYLLABLE_START = 0xac00;
const SYLLABLE_END = 0xd7a3;
/** 한 초성에 딸린 글자 수 — 중성 21 × 종성 28 */
const SYLLABLES_PER_INITIAL = 21 * 28;
/** 초성 — 호환 자모 */
const INITIALS = Array.from('ㄱㄲㄴㄷㄸㄹㅁㅂㅃㅅㅆㅇㅈㅉㅊㅋㅌㅍㅎ');
/** 호환 자모의 자음(ㄱ U+3131 ~ ㅎ U+314E) — 쌍자음 · 겹자음 포함 */
const CONSONANT_JAMO = /^[ㄱ-ㅎ]$/u;
/** 겹자음 → 두 초성 */
const SPLIT_CONSONANTS: Readonly<Record<string, string>> = {
ㄳ: 'ㄱㅅ',
ㄵ: 'ㄴㅈ',
ㄶ: 'ㄴㅎ',
ㄺ: 'ㄹㄱ',
ㄻ: 'ㄹㅁ',
ㄼ: 'ㄹㅂ',
ㄽ: 'ㄹㅅ',
ㄾ: 'ㄹㅌ',
ㄿ: 'ㄹㅍ',
ㅀ: 'ㄹㅎ',
ㅄ: 'ㅂㅅ',
};
/** 번호처럼 생긴 글 — 숫자 · 띄어쓰기 · + - ( ) . # * 만 (숫자가 하나 이상인지는 따로 본다) */
const PHONE_LIKE = /^[0-9\s+\-().#*]+$/u;
const WHITESPACE = /\s/u;
const NON_DIGITS = /\D+/g;
/** 견주기용 글 — NFC 로 맞추고 ASCII 대문자만 소문자로 */
export function foldText(text: string): string {
return text
.normalize('NFC')
.replace(/[A-Z]+/g, (letters) => letters.toLowerCase());
}
/** 이름의 초성 — 한글 음절은 초성으로, 띄어쓰기는 빼고, 그 밖의 글자는 그대로(견주기용으로 맞춰) 둔다 */
export function nameInitials(name: string): string {
let initials = '';
for (const char of foldText(name)) {
if (WHITESPACE.test(char)) continue;
const code = char.codePointAt(0) ?? 0;
initials +=
code >= SYLLABLE_START && code <= SYLLABLE_END
? (INITIALS[
Math.floor((code - SYLLABLE_START) / SYLLABLES_PER_INITIAL)
] ?? char)
: char;
}
return initials;
}
/** 해석한 찾는 글 — 초성으로 찾기 · 글로 찾기(번호처럼 생겼으면 그 숫자도) */
export type ContactSearchQuery =
| { readonly kind: 'initials'; readonly initials: string }
| {
readonly kind: 'text';
readonly text: string;
/** 번호처럼 생긴 글이면 그 숫자 — 아니면 null */
readonly digits: string | null;
};
/** 찾는 글을 해석한다 — 앞뒤 공백을 빼고 비면 null(거르지 않는다) */
export function parseContactSearch(
query: string | null | undefined,
): ContactSearchQuery | null {
const trimmed = query?.trim() ?? '';
if (trimmed === '') return null;
const letters = Array.from(trimmed).filter((char) => !WHITESPACE.test(char));
if (letters.every((char) => CONSONANT_JAMO.test(char))) {
return {
kind: 'initials',
initials: letters.map((char) => SPLIT_CONSONANTS[char] ?? char).join(''),
};
}
const digits = trimmed.replace(NON_DIGITS, '');
return {
kind: 'text',
text: foldText(trimmed),
digits: PHONE_LIKE.test(trimmed) && digits !== '' ? digits : null,
};
}
/** 연락처 하나의 찾기 자료 — 주소록을 읽을 때 한 번 만들어 둔다 (address-book.ts) */
export interface ContactSearchIndex {
/** 이름의 초성 — 이름이 없으면 빈 글 */
readonly initials: string;
/** 이름 · 주소 · 회사 · 부서 · 직책 · 전화번호 (견주기용) */
readonly texts: readonly string[];
/** 전화번호의 숫자 */
readonly phoneDigits: readonly string[];
}
export function contactSearchIndex(contact: Contact): ContactSearchIndex {
const texts = [
contact.name,
...contact.emails.map((email) => email.address),
contact.company,
contact.department,
contact.title,
...contact.phones.map((phone) => phone.number),
]
.filter((text): text is string => text !== null && text !== '')
.map(foldText);
return {
initials: contact.name === null ? '' : nameInitials(contact.name),
texts,
phoneDigits: contact.phones
.map((phone) => phone.number.replace(NON_DIGITS, ''))
.filter((digits) => digits !== ''),
};
}
export function matchesContact(
index: ContactSearchIndex,
query: ContactSearchQuery,
): boolean {
if (query.kind === 'initials') return index.initials.includes(query.initials);
if (index.texts.some((text) => text.includes(query.text))) return true;
const { digits } = query;
return (
digits !== null && index.phoneDigits.some((phone) => phone.includes(digits))
);
}
/** 사내 목록 한 줄이 맞는가 — 이름 · 주소로 같은 규칙 */
export function matchesDirectoryEntry(
entry: DirectoryEntry,
query: ContactSearchQuery,
): boolean {
if (query.kind === 'initials') {
return nameInitials(entry.name).includes(query.initials);
}
return (
foldText(entry.name).includes(query.text) ||
foldText(entry.email).includes(query.text)
);
}
/** 한국어 사전 차례 (계약 3절 "차례") */
const collator = new Intl.Collator('ko');
/** 둘 다 있으면 사전 차례, 하나만 없으면 없는 쪽이 뒤 */
function compareOptional(a: string | null, b: string | null): number {
if (a === null || b === null) {
return a === b ? 0 : a === null ? 1 : -1;
}
return collator.compare(a, b);
}
function compareIds(a: string, b: string): number {
return a < b ? -1 : a > b ? 1 : 0;
}
/**
* 연락처의 차례 — 이름(없으면 대표 주소)으로 견주고, 같으면 대표 주소 → id.
* 이름도 주소도 없는 연락처(다른 프로그램이 전화번호만 넣은 카드 등)는 맨 뒤
*/
export function compareContacts(
a: Pick<Contact, 'id' | 'name' | 'emails'>,
b: Pick<Contact, 'id' | 'name' | 'emails'>,
): number {
const primaryA = a.emails[0]?.address ?? null;
const primaryB = b.emails[0]?.address ?? null;
return (
compareOptional(a.name ?? primaryA, b.name ?? primaryB) ||
compareOptional(primaryA, primaryB) ||
compareIds(a.id, b.id)
);
}
/** 그룹의 차례 — 이름 → id */
export function compareGroups(
a: { readonly id: string; readonly name: string },
b: { readonly id: string; readonly name: string },
): number {
return collator.compare(a.name, b.name) || compareIds(a.id, b.id);
}
/** 사내 목록의 차례 — 이름 → 주소 → id */
export function compareDirectoryEntries(
a: DirectoryEntry,
b: DirectoryEntry,
): number {
return (
collator.compare(a.name, b.name) ||
collator.compare(a.email, b.email) ||
compareIds(a.id, b.id)
);
}
@@ -0,0 +1,85 @@
import { startCpuTimer } from '@/../test/cpu-time';
import { ERROR_CODES } from '@/common/errors/error-codes';
import {
CONTACT_NOTE_PATTERN,
CONTACT_PHONE_DIGIT,
CONTACT_PHONE_PATTERN,
CONTACT_RESTORE_MAX_CARD_BYTES,
CONTACT_RESTORE_MAX_PER_USER,
CONTACT_RESTORE_MAX_TOTAL_BYTES,
MAX_ADDRESS_BOOK_WEIGHT,
CONTACT_BOOK_CACHE_MAX_TOTAL_WEIGHT,
MAX_CONTACT_GROUP_MEMBERS,
MAX_CONTACT_GROUPS,
MAX_CONTACTS,
} from './contacts.constants';
const isPhone = (number: string) =>
CONTACT_PHONE_PATTERN.test(number) && CONTACT_PHONE_DIGIT.test(number);
describe('주소록 상수', () => {
it('CONTACT_002 문구의 숫자가 한도와 같다 — 연락처 · 그룹 · 그룹 하나의 구성원', () => {
const { message } = ERROR_CODES.CONTACT_002;
expect(message).toContain(
`연락처 ${MAX_CONTACTS.toLocaleString('en-US')}개`,
);
expect(message).toContain(`그룹 ${MAX_CONTACT_GROUPS}개`);
expect(message).toContain(`${MAX_CONTACT_GROUP_MEMBERS}명`);
});
it('전화번호 — 숫자가 하나 이상이고 숫자 · 띄어쓰기 · + - ( ) . # * 만', () => {
for (const number of [
'010-3192-5540',
'+82 10 3192 5540',
'(02) 333.4444',
'1588-0000#2',
'*77',
'7',
]) {
expect(isPhone(number)).toBe(true);
}
for (const number of [
'',
' ',
'+-()',
'#*',
'tel:010-1234-5678',
'010-1234-5678 내선',
'010\t1234',
'010',
]) {
expect(isPhone(number)).toBe(false);
}
});
it('전화번호 정규식은 길이에 비례한다 — 숫자 100만 개 뒤에 틀린 글자 하나도 바로 (CPU 시간)', () => {
// 한 정규식(`^[…]*[0-9][…]*$`)이던 때는 두 * 가 숫자를 나눠 가져 길이의 제곱 — 2만 자에 0.5초, 100만 자면 수십 분 (2026-10-29 리뷰 H1)
const digits = '1'.repeat(1_000_000);
const cpuMs = startCpuTimer();
expect(isPhone(`${digits}x`)).toBe(false);
expect(isPhone(`x${digits}`)).toBe(false);
expect(isPhone(` ${'-'.repeat(1_000_000)}`)).toBe(false);
expect(isPhone(digits)).toBe(true);
// 지금 수 ms — 한도 1초는 부하를 견디고 옛 정규식(2만 자 0.5초 · 100만 자 수십 분)은 확실히 넘는다
expect(cpuMs()).toBeLessThan(1_000);
});
it('메모 — 줄바꿈(\\n)만 되고 탭 · \\r · 그 밖의 제어 문자는 안 된다', () => {
expect(CONTACT_NOTE_PATTERN.test('첫 줄\n둘째 줄')).toBe(true);
for (const note of ['a\tb', 'a\r\nb', 'a\u0000', 'a\u007f', 'a\u0085']) {
expect(CONTACT_NOTE_PATTERN.test(note)).toBe(false);
}
});
it('크기 — 한 사람의 쓰기 한도(800만)는 캐시 전체(2,400만)보다 작다 (한도 안의 주소록은 늘 기억할 수 있다)', () => {
expect(MAX_ADDRESS_BOOK_WEIGHT).toBeLessThan(
CONTACT_BOOK_CACHE_MAX_TOTAL_WEIGHT,
);
});
it('되돌리기 — 한 직원이 표를 다 채워도(20개 × 256KB) 전체 상한 안이다', () => {
expect(
CONTACT_RESTORE_MAX_PER_USER * CONTACT_RESTORE_MAX_CARD_BYTES,
).toBeLessThanOrEqual(CONTACT_RESTORE_MAX_TOTAL_BYTES);
});
});
@@ -0,0 +1,106 @@
import {
MAIL_ID_PATTERN,
MAX_EMAIL_LENGTH,
} from '@/modules/mail/mail.constants';
/**
* 주소록 API 한도 · 형식 상수 — 계약(docs/mail/api-contract.md 3절 "주소록")의 숫자를 한 곳에 모은다.
* 글자 수는 앞뒤 공백을 뺀 값의 JS 문자열 길이(UTF-16 코드 단위 — 웹의 `.length` 와 같다)로 센다.
*/
/** 라우트 경로 (`/api/contacts` · `/api/contact-groups` · `/api/contact-restorations` · `/api/directory-entries`) */
export const CONTACTS_ROUTE = 'contacts';
export const CONTACT_GROUPS_ROUTE = 'contact-groups';
export const CONTACT_RESTORATIONS_ROUTE = 'contact-restorations';
export const DIRECTORY_ENTRIES_ROUTE = 'directory-entries';
/**
* 주소록 한도 — 넘으면 409 CONTACT_002 (아무것도 바꾸지 않는다).
* ⚠️ CONTACT_002 의 문구(common/errors/error-codes.ts)에 같은 숫자가 있다 — contacts.constants.spec.ts 가 맞는지 본다
*/
export const MAX_CONTACTS = 3_000;
export const MAX_CONTACT_GROUPS = 100;
export const MAX_CONTACT_GROUP_MEMBERS = 500;
/**
* 주소록 크기 한도 — 서버가 재는 크기(address-book.ts 의 weight — 우리가 다루는 칸의 글자 수 + 항목마다 조금)를 이보다 크게 만드는 쓰기
* (만들기 · 고치기 · 구성원 넣기 · 되돌리기)는 409 CONTACT_002. 줄이는 쓰기와 지우기는 늘 된다 (계약 3절 "크기").
* 한 사람분 캐시의 상한과 같은 잣대다 — 이 안의 주소록은 늘 통째로 기억할 수 있다.
* 연락처 3,000개에 그룹 100개를 구성원 500명씩 채워도 연락처마다 1,000자 넘게 남는다 (2026-10-29 리뷰: 칸마다 최대인 연락처 3,000장은 2,400만이었다)
*/
export const MAX_ADDRESS_BOOK_WEIGHT = 8_000_000;
/** 연락처 칸의 길이 — 이름 · 회사 · 부서 · 직책 100자, 메모 1,000자, 주소 254자, 번호 40자 */
export const MAX_CONTACT_TEXT_LENGTH = 100;
export const MAX_CONTACT_NOTE_LENGTH = 1_000;
export const MAX_CONTACT_EMAIL_LENGTH = MAX_EMAIL_LENGTH;
export const MAX_CONTACT_PHONE_LENGTH = 40;
/** 한 연락처의 이메일 · 전화번호 수 — 화면은 5개까지 더한다(다른 프로그램이 넣은 것은 그대로 보이고 저장된다) */
export const MAX_CONTACT_EMAILS = 20;
export const MAX_CONTACT_PHONES = 20;
/** 한 연락처가 속하는 그룹 수 (본문의 groupIds) */
export const MAX_CONTACT_GROUP_IDS = 100;
/** 그룹 이름 1~50자 */
export const MAX_CONTACT_GROUP_NAME_LENGTH = 50;
/** 구성원 넣기 한 번에 contactIds + directoryIds 합쳐 1~100개 */
export const MAX_GROUP_MEMBERS_PER_REQUEST = 100;
/** 찾는 글(q) 최대 길이 — 연락처 · 사내 목록. 메일 주소 하나(254자)를 통째로 찾을 수 있게 (읽기 화면 ⋯ 의 "주소록에 있는가") */
export const MAX_CONTACT_QUERY_LENGTH = MAX_EMAIL_LENGTH;
/** 연락처 · 그룹 · 사내 목록 id — 엔진의 객체 id(JMAP Id: URL-safe base64 문자 1~255자)와 같은 형식이다 */
export const CONTACT_ID_PATTERN = MAIL_ID_PATTERN;
/**
* 전화번호 — 숫자 · 띄어쓰기 · + - ( ) . # * 만(CONTACT_PHONE_PATTERN)이고 숫자가 하나 이상(CONTACT_PHONE_DIGIT).
* 두 정규식으로 나눠 둔다 — 한 정규식(`^[…]*[0-9][…]*$`)으로 쓰면 두 `*` 가 숫자를 나눠 가져, 맞지 않는 긴 글에서 길이의 제곱으로 느려진다
* (2026-10-29 리뷰 — 4만 자 한 칸에 CPU 1.7초, 100만 자면 수십 분). 둘 다 길이에 비례한다. 웹(frontend shared/utils/contacts.ts 의
* isValidContactPhone)도 같은 두 검사다
*/
export const CONTACT_PHONE_PATTERN = /^[0-9 +\-().#*]+$/;
export const CONTACT_PHONE_DIGIT = /[0-9]/;
/** 메모 — 줄바꿈(\n)은 되고 그 밖의 제어 문자(탭 · \r 포함 C0 · DEL · C1)는 안 된다 */
export const CONTACT_NOTE_PATTERN =
// eslint-disable-next-line no-control-regex
/^[^\u0000-\u0009\u000b-\u001f\u007f-\u009f]*$/;
/**
* 직원마다 기억하는 주소록 (계약 3절 "캐시") — 요청마다 엔진에 판(상태)만 묻고, 그대로면 다시 읽지 않는다.
* - 사람 수 200명 · 모두 합쳐 크기 2,400만(대략 — 넘으면 가장 오래 쓰지 않은 사람부터 잊는다)
* - 한 사람은 크기 한도(MAX_ADDRESS_BOOK_WEIGHT — 800만)까지 — 그보다 큰 주소록(다른 프로그램이 크게 만든 것)은 기억하지 않고 요청마다 다시 읽는다
* - 30분 동안 쓰지 않으면 잊는다 (기억할 때 그때그때 지운다)
*/
export const CONTACT_BOOK_CACHE_MAX_USERS = 200;
export const CONTACT_BOOK_CACHE_MAX_TOTAL_WEIGHT = 24_000_000;
export const CONTACT_BOOK_CACHE_IDLE_MS = 30 * 60_000;
/**
* 판이 그대로일 때만 쓰는 횟수 — 그사이 다른 곳(다른 탭 · 메일 프로그램)에서 바뀌거나(stateMismatch) 엔진이 같은 카드의 동시 고치기를
* 거절하면(serverUnavailable) 다시 읽고 다시 한다. 넘으면 MAIL_001
*/
export const CONTACT_WRITE_ATTEMPTS = 3;
/**
* 되돌리기 표 (계약 3절 "되돌리기") — 지운 연락처 · 그룹의 엔진 카드 전체를 직원마다 기억한다(서버 메모리).
* - 5분 · 직원마다 최근 20개 · 카드 하나가 256KB(JSON, UTF-8)를 넘으면 기억하지 않는다(표 null)
* - 모두 합쳐 32MB 를 넘으면 가장 오래된 표부터 잊는다 — 사진이 든 큰 카드를 여러 직원이 한꺼번에 지워도 메모리가 끝없이 늘지 않게
* ⚠️ 서버를 여러 대로 늘리면 표도 공유 저장소로 옮겨야 한다(다른 인스턴스로 간 되돌리기는 404 RES_001)
*/
export const CONTACT_RESTORE_TTL_MS = 5 * 60_000;
export const CONTACT_RESTORE_MAX_PER_USER = 20;
export const CONTACT_RESTORE_MAX_CARD_BYTES = 256 * 1024;
export const CONTACT_RESTORE_MAX_TOTAL_BYTES = 32 * 1024 * 1024;
/** 표 — 무작위 128비트를 base64url(채움 없이)로 쓴 22자 */
export const CONTACT_RESTORE_TOKEN_BYTES = 16;
export const CONTACT_RESTORE_TOKEN_PATTERN = /^[A-Za-z0-9_-]{22}$/;
/** 사내 목록을 기억하는 시간 — 모든 직원이 함께 쓴다 (계약 3절 "사내 목록") */
export const DIRECTORY_CACHE_TTL_MS = 10 * 60_000;
/** 사내 목록을 읽지 못한 일을 error 로그로 다시 남기는 간격 — 계정이 없는 동안 요청마다 남기지 않게 */
export const DIRECTORY_FAILURE_LOG_INTERVAL_MS = 10 * 60_000;
@@ -0,0 +1,172 @@
import {
Body,
Controller,
Delete,
Get,
Header,
Param,
Patch,
Post,
Query,
UseGuards,
} from '@nestjs/common';
import { ApiCookieAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import {
ApiErrorResponse,
ApiStandardResponse,
} from '@/common/decorators/api-standard-response.decorator';
import {
CurrentUser,
type AuthenticatedUser,
} from '@/common/decorators/current-user.decorator';
import { JwtAuthGuard } from '@/common/guards/jwt-auth.guard';
import { ContactIdPipe } from './contact-id.pipe';
import {
CONTACTS_ROUTE,
MAX_CONTACT_EMAILS,
MAX_CONTACT_GROUP_MEMBERS,
MAX_CONTACT_NOTE_LENGTH,
MAX_CONTACT_PHONES,
MAX_CONTACT_QUERY_LENGTH,
MAX_CONTACTS,
MAX_CONTACT_TEXT_LENGTH,
} from './contacts.constants';
import { ContactsService } from './contacts.service';
import {
ContactDeletedDto,
ContactDto,
ContactInputDto,
ContactListQueryDto,
ContactPageDto,
ContactPatchDto,
} from './dto/contact.dto';
/** 개인 정보라 공유 캐시 · 브라우저 캐시 어디에도 남기지 않는다 (받는 사람 추천과 같다) */
const NO_STORE = 'private, no-store';
const INPUT_RULES =
`이름이나 이메일 가운데 하나는 있어야 한다. 이름 · 회사 · 부서 · 직책 ${MAX_CONTACT_TEXT_LENGTH}자 · 메모 ${MAX_CONTACT_NOTE_LENGTH.toLocaleString('en-US')}자(줄바꿈만 되는 제어 문자) · ` +
`이메일 ${MAX_CONTACT_EMAILS}개(254자 · 메일 주소 형식 · 같은 연락처 안에서 겹치지 않게 — 대소문자 무시) · 전화번호 ${MAX_CONTACT_PHONES}개(40자 · 숫자와 띄어쓰기 · + - ( ) . # *). ` +
'글은 앞뒤 공백을 지워 저장한다(빈 글은 null).';
const SIZE_RULE =
'주소록 크기(우리가 다루는 칸의 글자 수 + 항목마다 조금)를 800만 넘게 하는 쓰기는 409 CONTACT_002 — 줄이는 쓰기 · 지우기는 늘 된다.';
// 내 주소록 — 연락처 (계약 3절 "주소록", 8단계). 엔진의 기본 주소록 하나를 그 직원 본인으로 읽고 쓴다
@ApiTags('Contacts')
@ApiCookieAuth()
@UseGuards(JwtAuthGuard)
@ApiErrorResponse('AUTH_001', '로그인 필요 (쿠키 없음·만료)')
@ApiErrorResponse('MAIL_001', '메일 엔진 장애')
@Controller(CONTACTS_ROUTE)
export class ContactsController {
constructor(private readonly contacts: ContactsService) {}
@Get()
@Header('Cache-Control', NO_STORE)
@ApiOperation({
summary: '연락처 목록 (커서 페이지네이션 · total 포함)',
description:
'이름 차례(한국어 사전 차례 — 이름이 없으면 대표 주소, 같으면 대표 주소 → id). q 는 초성만 친 글이면 이름의 초성과(ㅂㅈㅎ → 박지훈), ' +
'그 밖에는 이름 · 주소 · 회사 · 부서 · 직책 · 전화번호에 든 것(ASCII 대소문자 무시 — 전화번호는 숫자만 견준다: 3192 가 010-3192-5540 에 맞는다). ' +
'groupId 면 그 그룹의 구성원만, excludeGroupId 면 그 그룹의 구성원을 빼고(둘을 함께 쓰면 400 VAL_001). ' +
'커서는 받은 그 q · groupId · excludeGroupId 로만 쓴다. 차례 사이에 연락처가 바뀌면 다음 쪽에서 한두 개가 겹치거나 빠질 수 있다. ' +
'서버는 주소록을 엔진의 상태와 함께 기억하고 요청마다 상태만 확인한다. Cache-Control: private, no-store.',
})
@ApiStandardResponse(ContactPageDto, { description: '연락처 목록' })
@ApiErrorResponse(
'VAL_001',
`q ${MAX_CONTACT_QUERY_LENGTH}자 초과 · groupId 와 excludeGroupId 를 함께 · 잘못된 커서 · 다른 q · 그룹의 커서 · limit 범위 밖 · 모르는 쿼리`,
)
@ApiErrorResponse('RES_001', '없는 groupId · excludeGroupId')
list(
@CurrentUser() user: AuthenticatedUser,
@Query() query: ContactListQueryDto,
) {
return this.contacts.list(user, query);
}
@Get(':id')
@Header('Cache-Control', NO_STORE)
@ApiOperation({ summary: '연락처 하나' })
@ApiStandardResponse(ContactDto, { description: '연락처' })
@ApiErrorResponse('RES_001', '없는 연락처 (형식이 틀린 id 포함)')
get(
@CurrentUser() user: AuthenticatedUser,
@Param('id', ContactIdPipe) id: string,
) {
return this.contacts.get(user, id);
}
@Post()
@ApiOperation({
summary: '연락처 추가',
description:
`${INPUT_RULES} groupIds 의 그룹에도 함께 넣는다. 연락처는 ${MAX_CONTACTS.toLocaleString('en-US')}개 · 그룹 하나에 구성원 ${MAX_CONTACT_GROUP_MEMBERS}명까지. ` +
`${SIZE_RULE} 엔진 카드는 JSContact(kind individual) — 서버가 uid(urn:uuid)를 넣는다.`,
})
@ApiStandardResponse(ContactDto, {
status: 201,
description: '만든 연락처',
})
@ApiErrorResponse(
'VAL_001',
'형식 · 길이 · 개수 · 이름도 이메일도 없음 · 같은 주소가 두 번 · 모르는 필드',
)
@ApiErrorResponse('RES_001', '없는 groupId')
@ApiErrorResponse(
'CONTACT_002',
`연락처 ${MAX_CONTACTS.toLocaleString('en-US')}개 · 그룹 구성원 ${MAX_CONTACT_GROUP_MEMBERS}명 · 크기 한도 — 아무것도 바꾸지 않았다`,
)
create(@CurrentUser() user: AuthenticatedUser, @Body() dto: ContactInputDto) {
return this.contacts.create(user, dto);
}
@Patch(':id')
@ApiOperation({
summary: '연락처 고치기 (바꾼 칸만)',
description:
'보낸 칸만 바꾼다(하나 이상 — 비면 400 VAL_001). 보낸 칸만 검사하고 "이름이나 이메일"은 지금 연락처와 합친 결과로 본다. ' +
'emails · phones · groupIds 는 보내면 통째로 바뀐다. 엔진 카드의 다른 칸(주소 · 생일 · 사진 · 다른 프로그램의 칸)과 칸 안의 남의 정보' +
'(같은 주소 · 번호 항목의 label · 다른 contexts · fax 같은 features, role 직책, 다른 조직 · 부서)는 그대로 둔다 — 종류를 바꾼 것만 우리 값으로. ' +
'이름을 고치면 이름 조각(components)은 지운다. groupIds 로 그룹에 넣고 뺀다 — 다른 프로그램이 uid 없이 만든 카드는 그룹에 넣을 수 없다(400 VAL_001). ' +
`같은 칸을 양쪽에서 바꿨으면 나중에 저장한 쪽이 이긴다(판을 묻지 않는다). ${INPUT_RULES} ${SIZE_RULE}`,
})
@ApiStandardResponse(ContactDto, { description: '고친 연락처' })
@ApiErrorResponse(
'VAL_001',
'빈 본문 · 형식 · 길이 · 개수 · 합친 결과에 이름도 이메일도 없음 · 같은 주소가 두 번 · uid 없는 카드를 그룹에 넣음 · 모르는 필드',
)
@ApiErrorResponse('RES_001', '없는 연락처 · 없는 groupId')
@ApiErrorResponse(
'CONTACT_002',
`그룹 구성원 ${MAX_CONTACT_GROUP_MEMBERS}명 · 크기 한도 — 아무것도 바꾸지 않았다`,
)
patch(
@CurrentUser() user: AuthenticatedUser,
@Param('id', ContactIdPipe) id: string,
@Body() dto: ContactPatchDto,
) {
return this.contacts.patch(user, id, dto);
}
@Delete(':id')
@ApiOperation({
summary: '연락처 지우기 (영구 삭제)',
description:
'엔진 주소록에는 휴지통이 없다. 그 연락처를 가리키는 그룹 구성원도 함께 뺀다. ' +
'응답은 지운 연락처와 되돌리기 표(restoreToken — POST /api/contact-restorations 로 5분 안에 한 번, 원본 그대로 다시 만든다. ' +
'서버가 기억하지 못했으면 null).',
})
@ApiStandardResponse(ContactDeletedDto, {
description: '지운 연락처와 되돌리기 표',
})
@ApiErrorResponse('RES_001', '없는 연락처')
remove(
@CurrentUser() user: AuthenticatedUser,
@Param('id', ContactIdPipe) id: string,
) {
return this.contacts.remove(user, id);
}
}
@@ -0,0 +1,37 @@
import { Module } from '@nestjs/common';
import { JmapContactsProvider } from '@/modules/mail/jmap/jmap-contacts.provider';
import { JmapDirectoryProvider } from '@/modules/mail/jmap/jmap-directory.provider';
import { MailEngineModule } from '@/modules/mail/mail-engine.module';
import { ContactGroupsController } from './contact-groups.controller';
import { ContactRestorationsController } from './contact-restorations.controller';
import { ContactsController } from './contacts.controller';
import { CONTACTS_PROVIDER, DIRECTORY_PROVIDER } from './contacts.provider';
import { ContactsService } from './contacts.service';
import { DirectoryEntriesController } from './directory-entries.controller';
import { DirectoryService } from './directory.service';
/**
* 주소록 모듈 (계약 3절 "주소록" — 8단계) — 내 주소록(연락처 · 그룹)과 사내 목록.
* 엔진을 바꿀 때는 `CONTACTS_PROVIDER` · `DIRECTORY_PROVIDER` 의 구현(useClass)만 바꾼다 — 서비스 · 컨트롤러는 포트만 안다.
* 지금 구현은 Stalwart(JMAP) — 메일과 같은 엔진 연결(MailEngineModule)을 쓰고, 어댑터는 `modules/mail/jmap/` 에 있다.
*
* 메일 모듈이 받는 사람 추천에 쓰도록 서비스를 내보낸다 — 이 모듈은 메일 모듈을 가져오지 않는다 (순환 없음)
*/
@Module({
imports: [MailEngineModule],
controllers: [
ContactsController,
ContactGroupsController,
ContactRestorationsController,
DirectoryEntriesController,
],
providers: [
{ provide: CONTACTS_PROVIDER, useClass: JmapContactsProvider },
{ provide: DIRECTORY_PROVIDER, useClass: JmapDirectoryProvider },
ContactsService,
DirectoryService,
],
exports: [ContactsService, DirectoryService],
})
export class ContactsModule {}
@@ -0,0 +1,173 @@
import type { ContactFields, DirectoryEntryKind } from './contacts.types';
/**
* 주소록 · 사내 목록 엔진 포트 — 서비스 계층은 이 인터페이스만 안다 (메일 엔진 포트 mail.provider.ts 와 같은 규칙).
*
* 지금 구현은 Stalwart(JMAP) — 주소록은 `modules/mail/jmap/jmap-contacts.provider.ts`, 사내 목록은 `jmap-directory.provider.ts`.
* 엔진을 바꿀 때는 이 두 인터페이스의 새 구현을 contacts.module.ts 에 연결하면 된다.
*
* 규칙:
* - 우리 도메인 타입만 받고 돌려준다 (엔진 타입 · 카드 형식을 밖으로 흘리지 않는다)
* - 실패는 BusinessException(에러 코드)으로 던진다 — 엔진 장애 MAIL_001 등
* - `user` 는 인증된 직원 주소다. 어댑터는 그 직원의 주소록에만 접근한다
* - 주소록은 엔진의 **기본 주소록** 하나만 쓴다 (계약 3절)
*/
export const CONTACTS_PROVIDER = Symbol('CONTACTS_PROVIDER');
export const DIRECTORY_PROVIDER = Symbol('DIRECTORY_PROVIDER');
/** 주소록의 연락처 카드 한 장 (그룹 아님) — 엔진 카드에서 우리가 다루는 칸만 */
export interface AddressBookContactCard extends ContactFields {
readonly id: string;
/**
* 카드의 uid — 그룹 구성원이 이 값으로 잇는다. 다른 프로그램이 uid 없이 만든 카드에는 없다(null) —
* 엔진이 만든 뒤에는 uid 를 바꾸지 못하게 해서(2026-10-29 확인) 그런 카드는 그룹에 넣을 수 없다
*/
readonly uid: string | null;
}
/** 주소록의 그룹 카드 */
export interface AddressBookGroupCard {
readonly id: string;
readonly uid: string | null;
/** 그룹 이름 — 없으면 빈 글 */
readonly name: string;
/** 구성원 카드의 uid — 이 주소록에 없는 카드(지워짐 · 다른 주소록)를 가리키는 것도 그대로 둔다 */
readonly memberUids: readonly string[];
}
/** 주소록의 카드 하나 — 연락처 또는 그룹 */
export type AddressBookCard =
| { readonly kind: 'contact'; readonly card: AddressBookContactCard }
| { readonly kind: 'group'; readonly card: AddressBookGroupCard };
/** 주소록 전체 — 그 내용의 판(version)과 함께 */
export interface AddressBookSnapshot {
/** 판 — 불투명 문자열. 엔진에서 주소록이 무엇이든 바뀌면 달라진다 (writeAddressBook 에 그대로 돌려준다) */
readonly version: string;
readonly contacts: readonly AddressBookContactCard[];
readonly groups: readonly AddressBookGroupCard[];
}
/**
* 지운 카드의 원본 — 엔진 카드 전체(다른 프로그램의 칸 · uid 포함)를 어댑터가 글로 쓴 것.
* 서비스는 들여다보지 않고 기억했다가 되돌리기(NewAddressBookCard 의 restored)에 그대로 돌려준다 (계약 3절 "되돌리기")
*/
export interface RemovedCardOriginal {
/** 엔진 카드 전체 — 어댑터만 읽는다 */
readonly source: string;
/** source 의 크기(UTF-8 바이트) — 되돌리기 표에 기억할지 정한다(256KB) */
readonly bytes: number;
}
/** 새로 만들 카드 — key 는 이 바꾸기 안의 이름(결과의 createdIds 의 키), uid 는 서비스가 정한다 (그룹이 같은 바꾸기 안에서 가리킨다) */
export type NewAddressBookCard =
| {
readonly kind: 'contact';
readonly key: string;
readonly uid: string;
readonly fields: ContactFields;
}
| {
readonly kind: 'group';
readonly key: string;
readonly uid: string;
readonly name: string;
readonly memberUids: readonly string[];
}
| {
/** 지운 카드를 원본 그대로 다시 만든다 — uid · 주소록만 바꾸고(그룹은 구성원도) 나머지 칸은 원본 그대로 */
readonly kind: 'restored';
readonly key: string;
readonly uid: string;
readonly original: RemovedCardOriginal;
/** 그룹이면 다시 넣을 구성원 uid(원본의 구성원 대신), 연락처면 null */
readonly memberUids: readonly string[] | null;
/** 원본의 우리 칸 (지울 때 읽은 모양) — 서비스가 크기를 미리 잴 때 쓴다 */
readonly card: AddressBookCard;
};
/**
* 고칠 카드 — 바꾸는 칸만(undefined 는 그대로). 연락처는 칸 안의 남의 정보를 지킨다 — 어댑터가 지금 카드와 합친다(계약 3절 "고치기").
* 그룹의 구성원은 통째로 바꾼다 — 엔진은 구성원이 없는 그룹에 한 사람만 더하는 패치를 받지 않는다(2026-10-29 확인)
*/
export type AddressBookCardUpdate =
| {
readonly kind: 'contact';
readonly id: string;
readonly fields: Partial<ContactFields>;
}
| {
readonly kind: 'group';
readonly id: string;
readonly name?: string;
readonly memberUids?: readonly string[];
};
/** 한 번에 하는 바꾸기 — 엔진 요청 하나 (만들기 → 고치기 → 지우기 차례로 한다) */
export interface AddressBookChanges {
readonly create: readonly NewAddressBookCard[];
readonly update: readonly AddressBookCardUpdate[];
readonly destroy: readonly string[];
}
/**
* 바꾸기의 결과
* - written: 모두 했다 — 새 판(엔진이 알려 주지 않으면 null) · 만든 카드의 id(key → id, 만든 것은 모두 있다) ·
* 만들거나 고친 연락처 · 만든 그룹의 지금 모양(id → 카드 — 엔진에 보낸 카드를 읽은 것, 그룹 고치기는 넣지 않는다) · 지운 카드의 원본(id → 원본)
* - conflict: 받은 판과 엔진의 지금 판이 달라 아무것도 하지 않았다 (그사이 다른 곳에서 바뀌었다 — 다시 읽고 다시 한다)
* - busy: 엔진이 같은 카드의 동시 고치기를 거절했다(serverUnavailable) — 일부가 들어갔을 수 있다. 다시 읽고 다시 한다
* (서비스의 계획은 앞 시도가 들어갔는지 보고 이어 한다 — 지운 카드의 원본은 함께 준다)
*/
export type AddressBookWriteOutcome =
| {
readonly kind: 'written';
readonly version: string | null;
readonly createdIds: ReadonlyMap<string, string>;
readonly cards: ReadonlyMap<string, AddressBookCard>;
readonly removed: ReadonlyMap<string, RemovedCardOriginal>;
}
| { readonly kind: 'conflict' }
| {
readonly kind: 'busy';
readonly removed: ReadonlyMap<string, RemovedCardOriginal>;
};
export interface ContactsProvider {
/** 지금 주소록의 판 — 카드를 읽지 않는 가벼운 조회. readAddressBook 의 version 과 견준다 */
getAddressBookVersion(user: string): Promise<string>;
/** 주소록 전체 (기본 주소록의 연락처 · 그룹) — 엔진에 주소록이 없으면 빈 주소록 */
readAddressBook(user: string): Promise<AddressBookSnapshot>;
/**
* 바꾸기를 한 번에 — 받은 판(version)이 엔진의 지금 판과 같을 때만 한다(아니면 conflict, 아무것도 하지 않음).
* 고칠 연락처 · 지울 카드는 그 판 그대로 먼저 읽는다(칸 안의 남의 정보를 지키고 · 되돌리기 원본을 남기려고 — 그사이 바뀌었으면 conflict).
* 엔진이 동시 고치기를 거절하면 busy. 엔진이 일부를 받지 않으면 그 코드로 던진다(엔진 오류 종류에 따라 VAL_001 · MAIL_001 등 —
* 받은 것은 이미 바뀌었을 수 있다). 주소록이 없는데 만들 카드가 있으면 MAIL_001
*/
writeAddressBook(
user: string,
version: string,
changes: AddressBookChanges,
): Promise<AddressBookWriteOutcome>;
}
/** 엔진의 계정 하나 — 사내 목록이 될 수 있는 것(개인 · 그룹 계정)만 */
export interface DirectoryAccount {
/** 엔진의 계정 id */
readonly id: string;
/** 개인 계정은 직원(person), 그룹 계정은 공용 주소(shared) */
readonly kind: DirectoryEntryKind;
/** 엔진의 표시 이름(계정의 설명 — 관리 화면에서 넣는 이름) — 없으면 null */
readonly displayName: string | null;
/** 계정의 주소 — 없으면 null */
readonly email: string | null;
}
export interface DirectoryProvider {
/**
* 엔진의 계정 목록 — 서버만 쓰는 사내 목록 계정으로 읽는다 (계약 3절 "사내 목록").
* 그 계정이 없거나 읽을 권한이 없으면 · 엔진 장애면 던진다 (MAIL_001 등). 걸러 내기(관리자 · 게이트웨이 · 막은 계정)는 서비스가 한다
*/
listAccounts(): Promise<DirectoryAccount[]>;
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,131 @@
/**
* 주소록 도메인 타입 — API 계약(docs/mail/api-contract.md 3절 "주소록")과 같은 모양이다.
*
* 메일 엔진(JMAP Contacts · Principal)의 타입은 어댑터(modules/mail/jmap/) 밖으로 나오지 않는다. 엔진을 바꿔도 이 타입은 그대로다.
*/
/** 이메일의 종류 — 회사 · 개인 · 기타 */
export type ContactEmailLabel = 'work' | 'personal' | 'other';
/** 전화번호의 종류 — 휴대전화 · 회사 · 집 · 기타 */
export type ContactPhoneLabel = 'mobile' | 'work' | 'home' | 'other';
export const CONTACT_EMAIL_LABELS: readonly ContactEmailLabel[] = [
'work',
'personal',
'other',
];
export const CONTACT_PHONE_LABELS: readonly ContactPhoneLabel[] = [
'mobile',
'work',
'home',
'other',
];
export interface ContactEmail {
readonly address: string;
readonly label: ContactEmailLabel;
}
export interface ContactPhone {
readonly number: string;
readonly label: ContactPhoneLabel;
}
/** 우리가 다루는 연락처 칸 — 그룹은 따로 (그룹 카드의 구성원으로 잇는다) */
export interface ContactFields {
/** 표시 이름 — 없으면 null */
readonly name: string | null;
/** 앞의 것이 대표 주소 — 받는 사람 추천의 첫 줄 · 그룹으로 보낼 때 쓴다 */
readonly emails: readonly ContactEmail[];
readonly phones: readonly ContactPhone[];
readonly company: string | null;
readonly department: string | null;
/** 직책 */
readonly title: string | null;
/** 메모 (줄바꿈 있음) */
readonly note: string | null;
}
/** 연락처 (계약 3절 Contact) */
export interface Contact extends ContactFields {
readonly id: string;
/** 속한 그룹 — 이 주소록의 그룹만 */
readonly groupIds: readonly string[];
}
/** POST 본문 (계약 3절 ContactInput) — 요청 검증이 앞뒤 공백을 지운 값(빈 글은 null) */
export interface ContactInput extends ContactFields {
readonly groupIds: readonly string[];
}
/**
* PATCH 본문 (계약 3절 ContactPatch) — 바꾼 칸만(하나 이상). 보내지 않은 칸(undefined)은 검사하지도 바꾸지도 않는다.
* emails · phones · groupIds 는 보내면 그 칸이 통째로 바뀐다
*/
export type ContactPatch = Partial<ContactInput>;
/** 연락처 지우기의 응답 (계약 3절 ContactDeleted) */
export interface ContactDeleted {
/** 지운 연락처 */
readonly contact: Contact;
/** [되돌리기] 표 — 서버가 기억하지 못했으면 null (카드가 256KB 를 넘음 등) */
readonly restoreToken: string | null;
}
/** 그룹 (계약 3절 ContactGroup) */
export interface ContactGroup {
readonly id: string;
readonly name: string;
/** 이 주소록에 있는 연락처 가운데 구성원 수 */
readonly memberCount: number;
}
/** 그룹 지우기의 응답 (계약 3절 ContactGroupDeleted) */
export interface ContactGroupDeleted {
readonly id: string;
readonly name: string;
readonly memberIds: readonly string[];
/** [되돌리기] 표 — 서버가 기억하지 못했으면 null */
readonly restoreToken: string | null;
}
/** 되돌리기의 응답 (계약 3절 ContactRestored) — 다시 만든 연락처 · 그룹 (id 는 새로 받는다) */
export type ContactRestored =
| { readonly kind: 'contact'; readonly contact: Contact }
| { readonly kind: 'group'; readonly group: ContactGroup };
export interface ContactGroupMembersAdded {
readonly group: ContactGroup;
/** 이번에 그룹에 들어간 수 (이미 구성원이던 것은 세지 않는다) */
readonly addedCount: number;
/** 사내 목록에서 골라 내 주소록에 새로 만든 연락처 수 */
readonly createdCount: number;
}
/** 사내 목록의 종류 — 직원(개인 계정) · 공용 주소(그룹 계정) */
export type DirectoryEntryKind = 'person' | 'shared';
export const DIRECTORY_ENTRY_KINDS: readonly DirectoryEntryKind[] = [
'person',
'shared',
];
/** 사내 목록 한 줄 (계약 3절 DirectoryEntry) */
export interface DirectoryEntry {
/** 엔진의 계정 id */
readonly id: string;
/** 엔진의 표시 이름 — 없으면 주소의 @ 앞 */
readonly name: string;
readonly email: string;
readonly kind: DirectoryEntryKind;
}
/** 받는 사람 추천에 넣는 주소록의 한 줄 — 연락처의 주소 하나 (계약 3절 "메일과 함께") */
export interface ContactSuggestionLine {
readonly name: string | null;
readonly email: string;
readonly company: string | null;
readonly emailLabel: ContactEmailLabel;
}
@@ -0,0 +1,87 @@
import {
Controller,
Get,
Header,
Param,
Query,
UseGuards,
} from '@nestjs/common';
import { ApiCookieAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import {
ApiErrorResponse,
ApiStandardResponse,
} from '@/common/decorators/api-standard-response.decorator';
import {
CurrentUser,
type AuthenticatedUser,
} from '@/common/decorators/current-user.decorator';
import { JwtAuthGuard } from '@/common/guards/jwt-auth.guard';
import { ContactIdPipe } from './contact-id.pipe';
import {
DIRECTORY_ENTRIES_ROUTE,
MAX_CONTACT_QUERY_LENGTH,
} from './contacts.constants';
import { ContactsService } from './contacts.service';
import { DirectoryService } from './directory.service';
import {
DirectoryEntryDto,
DirectoryEntryPageDto,
DirectoryEntryQueryDto,
} from './dto/directory-entry.dto';
const NO_STORE = 'private, no-store';
const UNREADABLE =
'사내 목록을 읽지 못함 — 사내 목록 계정이 없음(메일 엔진 설정 스크립트를 복구 관리자로 실행해야 만든다) · 권한 · 엔진 장애';
// 사내 목록 (계약 3절 "사내 목록", 8단계) — 엔진의 계정 목록에서 직원 · 공용 주소의 이름 · 주소만. 모든 직원이 같은 목록을 본다
@ApiTags('Contacts')
@ApiCookieAuth()
@UseGuards(JwtAuthGuard)
@ApiErrorResponse('AUTH_001', '로그인 필요 (쿠키 없음·만료)')
@Controller(DIRECTORY_ENTRIES_ROUTE)
export class DirectoryEntriesController {
constructor(
private readonly directory: DirectoryService,
private readonly contacts: ContactsService,
) {}
@Get()
@Header('Cache-Control', NO_STORE)
@ApiOperation({
summary: '사내 목록 (커서 페이지네이션 · total 포함)',
description:
'엔진의 개인 계정은 직원(person), 그룹 계정은 공용 주소(shared). 이름은 엔진의 표시 이름(없으면 주소의 @ 앞). ' +
'엔진 관리자 · 게이트웨이 · 사내 목록 계정 · 막은 계정(MAIL_BLOCKED_ACCOUNTS) · 메일 엔진 설정 스크립트가 만든 계정과 사내 도메인이 아닌 · 주소가 없는 계정은 뺀다. ' +
'이름 차례(한국어 사전 차례 → 주소 → id). q 는 초성만 친 글이면 이름의 초성과, 그 밖에는 이름 · 주소에 든 것(ASCII 대소문자 무시). ' +
'excludeGroupId 면 내 주소록의 그 그룹 구성원이 가진 주소(대소문자 무시)를 뺀다(구성원 넣기 창). ' +
'커서는 받은 그 q · kind · excludeGroupId 로만 쓴다. 서버는 목록을 10분 기억한다(모든 직원이 함께). Cache-Control: private, no-store.',
})
@ApiStandardResponse(DirectoryEntryPageDto, { description: '사내 목록' })
@ApiErrorResponse(
'VAL_001',
`q ${MAX_CONTACT_QUERY_LENGTH}자 초과 · kind 가 person · shared 가 아님 · 잘못된 커서 · 다른 q · kind · excludeGroupId 의 커서 · limit 범위 밖`,
)
@ApiErrorResponse('RES_001', '없는 excludeGroupId (내 주소록의 그룹)')
@ApiErrorResponse('MAIL_001', UNREADABLE)
list(
@CurrentUser() user: AuthenticatedUser,
@Query() query: DirectoryEntryQueryDto,
) {
return this.contacts.listDirectory(user, query);
}
@Get(':id')
@Header('Cache-Control', NO_STORE)
@ApiOperation({
summary: '사내 목록 한 항목',
description:
'기억한 사내 목록(10분)에서 찾는다 — 화면이 주소로 연 사내 항목. 빼는 계정(관리자 · 게이트웨이 · 막은 계정 등)은 없는 것과 같다.',
})
@ApiStandardResponse(DirectoryEntryDto, { description: '사내 목록 한 항목' })
@ApiErrorResponse('RES_001', '없거나 빼는 계정 (형식이 틀린 id 포함)')
@ApiErrorResponse('MAIL_001', UNREADABLE)
get(@Param('id', ContactIdPipe) id: string) {
return this.directory.findById(id);
}
}
@@ -0,0 +1,123 @@
import type { DirectoryAccount } from './contacts.provider';
import {
SETUP_MANAGED_MARK,
toDirectoryEntries,
type DirectoryPolicy,
} from './directory-policy';
const policy: DirectoryPolicy = {
domain: 'arasystem.test',
gatewayUser: 'mail-gateway@arasystem.test',
blockedAccounts: new Set([
'admin@arasystem.test',
'mail-directory@arasystem.test',
'ops@arasystem.test',
]),
};
const account = (
id: string,
email: string | null,
displayName: string | null = null,
kind: DirectoryAccount['kind'] = 'person',
): DirectoryAccount => ({ id, kind, displayName, email });
describe('사내 목록에 넣는 계정 (toDirectoryEntries)', () => {
it('엔진 관리자 · 게이트웨이 · 사내 목록 계정 · 막은 계정은 뺀다 — 웹메일 로그인을 막는 것과 같은 목록', () => {
const entries = toDirectoryEntries(
[
account('a', 'admin@arasystem.test', 'System administrator'),
account(
'g',
'mail-gateway@arasystem.test',
`메일 게이트웨이 (백엔드 대리 접근 전용) ${SETUP_MANAGED_MARK}`,
),
account(
'm',
'mail-directory@arasystem.test',
`사내 목록 (백엔드 계정 목록 읽기 전용) ${SETUP_MANAGED_MARK}`,
),
account('o', 'Ops@AraSystem.test', '운영'),
account('k', 'ara.kim@arasystem.test', '김아라'),
],
policy,
);
expect(entries).toEqual([
{
id: 'k',
name: '김아라',
email: 'ara.kim@arasystem.test',
kind: 'person',
},
]);
});
it('메일 엔진 설정 스크립트가 만든 계정(설명에 [setup.mjs])도 뺀다 — 이름이 정적 목록과 달라도', () => {
expect(
toDirectoryEntries(
[
account(
'x',
'renamed-service@arasystem.test',
`무엇 ${SETUP_MANAGED_MARK}`,
),
],
policy,
),
).toEqual([]);
});
it('사내 도메인이 아닌 주소 · 주소 없음 · 직원 주소 형식이 아닌 주소는 뺀다', () => {
expect(
toDirectoryEntries(
[
account('1', 'partner@vendor.example', '바깥'),
account('2', 'ara.kim@mail.arasystem.test', '하위 도메인'),
account('3', null, '주소 없음'),
account('4', ' ', '빈 주소'),
account('5', 'ara%gw@arasystem.test', '대리 접근 구분자'),
],
policy,
),
).toEqual([]);
});
it('주소는 소문자로, 같은 주소는 처음 것 하나만 — 이름은 표시 이름(제어 문자는 띄어쓰기), 없으면 주소의 @ 앞', () => {
const entries = toDirectoryEntries(
[
account('d1', 'Doyun.Lee@AraSystem.Test', ' 이도윤\t(영업) '),
account('d2', 'doyun.lee@arasystem.test', '중복'),
account('s', 'sales@arasystem.test', null, 'shared'),
account('e', 'seojun.park@arasystem.test', ' '),
],
policy,
);
expect(entries).toEqual([
// 이름 차례 — 한글이 영문보다 앞이다
{
id: 'd1',
name: '이도윤 (영업)',
email: 'doyun.lee@arasystem.test',
kind: 'person',
},
{ id: 's', name: 'sales', email: 'sales@arasystem.test', kind: 'shared' },
{
id: 'e',
name: 'seojun.park',
email: 'seojun.park@arasystem.test',
kind: 'person',
},
]);
});
it('메일 엔진 설정이 없으면(도메인 없음) 아무도 넣지 않는다', () => {
expect(
toDirectoryEntries([account('k', 'ara.kim@arasystem.test', '김아라')], {
...policy,
domain: null,
}),
).toEqual([]);
});
});
@@ -0,0 +1,66 @@
import { isEmployeeAddress } from '@/modules/auth/employee-address';
import { compareDirectoryEntries } from './contact-search';
import type { DirectoryAccount } from './contacts.provider';
import type { DirectoryEntry } from './contacts.types';
/**
* 사내 목록에 넣는 계정 (순수 함수) — 계약 3절 "사내 목록".
*
* 빼는 계정은 웹메일 로그인에서 막는 것과 **같은 함수**(auth/employee-address.ts 의 isEmployeeAddress)로 고른다:
* 사내 도메인(MAIL_DOMAIN)이 아닌 주소 · 주소 없음 · 게이트웨이 · 막은 계정(엔진 관리자 · 사내 목록 계정 · MAIL_BLOCKED_ACCOUNTS —
* mail.settings.ts 의 blockedAccounts). 거기에 더해 setup 이 만든 계정(설명에 SETUP_MANAGED_MARK)도 뺀다.
*/
/** 메일 엔진 설정 스크립트가 자기가 만든 객체의 설명에 붙이는 표시 — mail/scripts/setup.mjs 의 MANAGED_MARK 와 같다 */
export const SETUP_MANAGED_MARK = '[setup.mjs]';
export interface DirectoryPolicy {
/** 사내 메일 도메인 (소문자) — 없으면(메일 엔진 설정 없음) 아무도 넣지 않는다 */
readonly domain: string | null;
readonly gatewayUser: string | null;
readonly blockedAccounts: ReadonlySet<string>;
}
/** 표시 이름 — 제어 문자는 띄어쓰기로, 앞뒤 공백은 뺀다. 없으면 주소의 @ 앞 */
function displayNameOf(account: DirectoryAccount, email: string): string {
const name = account.displayName
// eslint-disable-next-line no-control-regex
?.replace(/[\u0000-\u001f\u007f-\u009f]+/g, ' ')
.trim();
return name ? name : email.slice(0, email.lastIndexOf('@'));
}
/** 엔진의 계정 목록 → 사내 목록 (이름 차례) — 같은 주소는 처음 것 하나만 */
export function toDirectoryEntries(
accounts: readonly DirectoryAccount[],
policy: DirectoryPolicy,
): DirectoryEntry[] {
const { domain } = policy;
if (domain === null) return [];
const seen = new Set<string>();
const entries: DirectoryEntry[] = [];
for (const account of accounts) {
const email = account.email?.trim().toLowerCase() ?? '';
if (
!isEmployeeAddress(
email,
domain,
policy.gatewayUser,
policy.blockedAccounts,
) ||
(account.displayName ?? '').includes(SETUP_MANAGED_MARK) ||
seen.has(email)
) {
continue;
}
seen.add(email);
entries.push({
id: account.id,
name: displayNameOf(account, email),
email,
kind: account.kind,
});
}
return entries.sort(compareDirectoryEntries);
}
@@ -0,0 +1,257 @@
import { BusinessException } from '@/common/errors/business.exception';
import type { MailSettings } from '@/modules/mail/mail.settings';
import { DIRECTORY_CACHE_TTL_MS } from './contacts.constants';
import type { DirectoryAccount } from './contacts.provider';
import { DirectoryService } from './directory.service';
import type { DirectoryEntryQueryDto } from './dto/directory-entry.dto';
const settings: MailSettings = {
jmapBaseUrl: 'http://mail:8080',
domain: 'arasystem.test',
gatewayUser: 'mail-gateway@arasystem.test',
gatewayPassword: 'secret',
attachmentMaxBytes: 20 * 1024 * 1024,
attachmentTotalMaxBytes: 25 * 1024 * 1024,
blockedAccounts: new Set([
'admin@arasystem.test',
'mail-directory@arasystem.test',
]),
directoryAccount: 'mail-directory@arasystem.test',
};
const ACCOUNTS: DirectoryAccount[] = [
{
id: 'a',
kind: 'person',
displayName: 'System administrator',
email: 'admin@arasystem.test',
},
{
id: 'm',
kind: 'person',
displayName: '사내 목록 (백엔드 계정 목록 읽기 전용) [setup.mjs]',
email: 'mail-directory@arasystem.test',
},
{
id: 's',
kind: 'shared',
displayName: '영업팀',
email: 'sales@arasystem.test',
},
{
id: 'p',
kind: 'person',
displayName: '박서준',
email: 'seojun.park@arasystem.test',
},
{
id: 'd',
kind: 'person',
displayName: '이도윤',
email: 'doyun.lee@arasystem.test',
},
{
id: 'k',
kind: 'person',
displayName: '김아라',
email: 'ara.kim@arasystem.test',
},
];
const query = (
overrides: Partial<DirectoryEntryQueryDto> = {},
): DirectoryEntryQueryDto => ({ limit: 20, ...overrides });
async function failureOf(
promise: Promise<unknown>,
): Promise<BusinessException> {
const error = await promise.then(
() => null,
(caught: unknown) => caught,
);
if (!(error instanceof BusinessException)) {
throw new Error('BusinessException 이 아니다');
}
return error;
}
describe('DirectoryService (사내 목록)', () => {
const NOW = 1_800_000_000_000;
let listAccounts: jest.Mock<Promise<DirectoryAccount[]>, []>;
let service: DirectoryService;
beforeEach(() => {
jest.spyOn(Date, 'now').mockReturnValue(NOW);
listAccounts = jest
.fn<Promise<DirectoryAccount[]>, []>()
.mockResolvedValue(ACCOUNTS);
service = new DirectoryService({ listAccounts }, settings);
});
afterEach(() => jest.restoreAllMocks());
it('이름 차례 · total 포함 — 관리자 · 사내 목록 계정은 빠진다', async () => {
const page = await service.list(query());
expect(page.items.map((entry) => entry.name)).toEqual([
'김아라',
'박서준',
'영업팀',
'이도윤',
]);
expect(page.pageInfo).toEqual({
hasNext: false,
nextCursor: null,
total: 4,
});
});
it('q 는 초성 · 이름 · 주소 — kind 는 직원 · 공용 주소만', async () => {
expect(
(await service.list(query({ q: 'ㄷㅇ' }))).items.map((e) => e.id),
).toEqual(['d']);
expect(
(await service.list(query({ q: ' SEOJUN ' }))).items.map((e) => e.id),
).toEqual(['p']);
expect(
(await service.list(query({ kind: 'shared' }))).items.map((e) => e.id),
).toEqual(['s']);
expect(
(await service.list(query({ kind: 'person', q: '영업' }))).items,
).toEqual([]);
});
it('커서 — 다음 쪽으로 이어지고, 받은 그 q · kind 로만 쓴다 (다르면 엔진에 묻기 전에 VAL_001)', async () => {
const first = await service.list(query({ limit: 3 }));
expect(first.pageInfo).toMatchObject({ hasNext: true, total: 4 });
const cursor = first.pageInfo.nextCursor ?? '';
const second = await service.list(query({ limit: 3, cursor }));
expect(second.items.map((entry) => entry.id)).toEqual(['d']);
expect(second.pageInfo.hasNext).toBe(false);
listAccounts.mockClear();
for (const other of [
query({ cursor, kind: 'person' }),
query({ cursor, q: '김' }),
query({ cursor: 'not-a-cursor' }),
]) {
const error = await failureOf(service.list(other));
expect(error.code).toBe('VAL_001');
expect(error.context?.reason).toMatch(/^invalid-contact-cursor:/);
}
expect(listAccounts).not.toHaveBeenCalled();
});
it('모든 직원이 함께 10분 기억한다 — 같은 때의 여러 요청은 한 번만 읽는다', async () => {
await Promise.all([
service.entries(),
service.entries(),
service.list(query()),
]);
expect(listAccounts).toHaveBeenCalledTimes(1);
jest.spyOn(Date, 'now').mockReturnValue(NOW + DIRECTORY_CACHE_TTL_MS - 1);
await service.entries();
expect(listAccounts).toHaveBeenCalledTimes(1);
jest.spyOn(Date, 'now').mockReturnValue(NOW + DIRECTORY_CACHE_TTL_MS + 1);
await service.entries();
expect(listAccounts).toHaveBeenCalledTimes(2);
});
it('읽지 못하면 MAIL_001 이고 기억하지 않는다 — 엔진 한도(REQ_001) 등도 MAIL_001 로 (사유는 로그용으로 남긴다)', async () => {
listAccounts.mockRejectedValueOnce(
new BusinessException('MAIL_001', {
context: { reason: 'directory-account-missing' },
}),
);
expect((await failureOf(service.entries())).context?.reason).toBe(
'directory-account-missing',
);
listAccounts.mockRejectedValueOnce(
new BusinessException('REQ_001', {
context: { reason: 'mail-engine-rate-limit' },
}),
);
const limited = await failureOf(service.entries());
expect(limited.code).toBe('MAIL_001');
expect(limited.context).toMatchObject({
reason: 'mail-engine-rate-limit',
code: 'REQ_001',
});
// 다음 요청은 다시 읽는다
await expect(service.entries()).resolves.toHaveLength(4);
expect(listAccounts).toHaveBeenCalledTimes(3);
});
it('id 로 찾는다 — 넘긴 차례대로, 사내 목록에 없는 id(빠진 관리자 포함)는 RES_001', async () => {
expect(
(await service.findByIds(['k', 's'])).map((entry) => entry.email),
).toEqual(['ara.kim@arasystem.test', 'sales@arasystem.test']);
expect((await failureOf(service.findByIds(['k', 'a']))).code).toBe(
'RES_001',
);
// 찾을 것이 없으면 엔진에 묻지 않는다
listAccounts.mockClear();
await expect(service.findByIds([])).resolves.toEqual([]);
expect(listAccounts).not.toHaveBeenCalled();
});
it('한 항목(GET /api/directory-entries/{id}) — 기억한 목록에서, 없거나 빼는 계정이면 RES_001', async () => {
await expect(service.findById('d')).resolves.toEqual({
id: 'd',
name: '이도윤',
email: 'doyun.lee@arasystem.test',
kind: 'person',
});
for (const id of ['a', 'm', 'nope']) {
expect((await failureOf(service.findById(id))).code).toBe('RES_001');
}
expect(listAccounts).toHaveBeenCalledTimes(1);
});
it('excludeGroupId — 받은 주소(소문자)를 뺀다 · 커서는 그 값으로만 쓰고, 커서를 먼저 확인한다(빼기 목록을 묻기 전에)', async () => {
const excluded = jest
.fn<Promise<ReadonlySet<string>>, []>()
.mockResolvedValue(
new Set(['doyun.lee@arasystem.test', 'other@x.example']),
);
const page = await service.list(
query({ excludeGroupId: 'g', limit: 2 }),
excluded,
);
expect(page.items.map((entry) => entry.id)).toEqual(['k', 'p']);
expect(page.pageInfo).toMatchObject({ hasNext: true, total: 3 });
const cursor = page.pageInfo.nextCursor ?? '';
const next = await service.list(
query({ excludeGroupId: 'g', limit: 2, cursor }),
excluded,
);
expect(next.items.map((entry) => entry.id)).toEqual(['s']);
excluded.mockClear();
for (const other of [
query({ cursor }),
query({ cursor, excludeGroupId: 'h' }),
]) {
expect((await failureOf(service.list(other, excluded))).code).toBe(
'VAL_001',
);
}
expect(excluded).not.toHaveBeenCalled();
// 그룹이 없으면(주소록 서비스가 RES_001) 그대로
excluded.mockRejectedValueOnce(new BusinessException('RES_001'));
expect(
(
await failureOf(
service.list(query({ excludeGroupId: 'gone' }), excluded),
)
).code,
).toBe('RES_001');
});
});
@@ -0,0 +1,149 @@
import { Inject, Injectable } from '@nestjs/common';
import type { PaginatedResult } from '@/common/dto/pagination.dto';
import { BusinessException } from '@/common/errors/business.exception';
import { MAIL_SETTINGS, type MailSettings } from '@/modules/mail/mail.settings';
import { cursorOffset, listScope, pageOf } from './contact-paging';
import { matchesDirectoryEntry, parseContactSearch } from './contact-search';
import { DIRECTORY_CACHE_TTL_MS } from './contacts.constants';
import {
DIRECTORY_PROVIDER,
type DirectoryProvider,
} from './contacts.provider';
import type { DirectoryEntry } from './contacts.types';
import type { DirectoryEntryQueryDto } from './dto/directory-entry.dto';
import { toDirectoryEntries, type DirectoryPolicy } from './directory-policy';
/** 기억한 사내 목록 */
interface DirectoryListing {
readonly entries: readonly DirectoryEntry[];
readonly byId: ReadonlyMap<string, DirectoryEntry>;
readonly expiresAt: number;
}
function directoryEntryNotFound(): BusinessException {
return new BusinessException('RES_001', {
context: { reason: 'directory-entry-not-found' },
});
}
/** 사내 목록을 읽지 못했다 — 받는 사람 추천은 이것을 보고 로그인 기록으로 대신한다. 503 MAIL_001 로 답한다 */
function directoryFailure(error: unknown): unknown {
if (!(error instanceof BusinessException) || error.code === 'MAIL_001') {
return error;
}
// 엔진 한도(REQ_001) 등 — 직원의 요청 때문이 아니라 서버가 쓰는 계정의 일이라 엔진 장애로 답한다
return new BusinessException('MAIL_001', {
context: {
reason: 'directory-unavailable',
code: error.code,
...(error.context ?? {}),
},
});
}
/**
* 사내 목록 (계약 3절 "사내 목록") — 엔진의 계정 목록에서 직원 · 공용 주소의 이름 · 주소만.
* - 모든 직원이 함께 쓰는 목록을 10분 기억한다(서버 메모리). 같은 때의 여러 요청은 한 번만 읽는다
* - 읽지 못하면(사내 목록 계정이 없음 · 권한 · 엔진 장애) 기억하지 않고 MAIL_001 — 다음 요청이 다시 읽는다
* - 빼는 계정은 웹메일 로그인에서 막는 것과 같다 (directory-policy.ts)
*
* ⚠️ 프로세스 메모리다. 인스턴스를 늘리면 인스턴스마다 10분씩 따로 기억한다 (틀리지는 않고, 계정을 바꾼 뒤 보이는 때가 인스턴스마다 다를 뿐이다).
*/
@Injectable()
export class DirectoryService {
private cached: DirectoryListing | null = null;
private loading: Promise<DirectoryListing> | null = null;
private readonly policy: DirectoryPolicy;
constructor(
@Inject(DIRECTORY_PROVIDER) private readonly provider: DirectoryProvider,
@Inject(MAIL_SETTINGS) settings: MailSettings,
) {
this.policy = {
domain: settings.domain,
gatewayUser: settings.gatewayUser,
blockedAccounts: settings.blockedAccounts,
};
}
/** 사내 목록 전체 (이름 차례) — 읽지 못하면 MAIL_001 */
async entries(): Promise<readonly DirectoryEntry[]> {
return (await this.listing()).entries;
}
/**
* GET /api/directory-entries — 찾는 글 · 종류로 거른 한 쪽. 커서는 엔진에 묻기 전에 확인한다.
* excluded 가 있으면(excludeGroupId — 주소록 서비스가 그 그룹 구성원의 주소를 준다) 그 주소(소문자)를 뺀다
*/
async list(
query: DirectoryEntryQueryDto,
excluded?: () => Promise<ReadonlySet<string>>,
): Promise<PaginatedResult<DirectoryEntry>> {
const q = query.q?.trim() ? query.q.trim() : undefined;
const scope = listScope(
'directory-entries',
q,
query.kind,
query.excludeGroupId,
);
const start = cursorOffset(query.cursor, scope);
const except = excluded ? await excluded() : null;
const { entries } = await this.listing();
const search = parseContactSearch(q);
const matched = entries.filter(
(entry) =>
(query.kind === undefined || entry.kind === query.kind) &&
(except === null || !except.has(entry.email.toLowerCase())) &&
(search === null || matchesDirectoryEntry(entry, search)),
);
return pageOf(matched, start, query.limit, scope);
}
/** GET /api/directory-entries/{id} — 기억한 목록에서 (없거나 빼는 계정이면 RES_001) */
async findById(id: string): Promise<DirectoryEntry> {
const [entry] = await this.findByIds([id]);
if (!entry) throw directoryEntryNotFound();
return entry;
}
/** id 로 찾는다 (넘긴 차례대로) — 사내 목록에 없는 id 가 하나라도 있으면 RES_001 */
async findByIds(ids: readonly string[]): Promise<DirectoryEntry[]> {
if (ids.length === 0) return [];
const { byId } = await this.listing();
return ids.map((id) => {
const entry = byId.get(id);
if (!entry) throw directoryEntryNotFound();
return entry;
});
}
private listing(): Promise<DirectoryListing> {
if (this.cached && this.cached.expiresAt > Date.now()) {
return Promise.resolve(this.cached);
}
if (this.loading) return this.loading;
const loading = this.provider
.listAccounts()
.then((accounts) => {
const entries = toDirectoryEntries(accounts, this.policy);
const listing: DirectoryListing = {
entries,
byId: new Map(entries.map((entry) => [entry.id, entry])),
expiresAt: Date.now() + DIRECTORY_CACHE_TTL_MS,
};
this.cached = listing;
return listing;
})
.catch((error: unknown) => {
throw directoryFailure(error);
})
.finally(() => {
if (this.loading === loading) this.loading = null;
});
this.loading = loading;
return loading;
}
}
@@ -0,0 +1,174 @@
import { plainToInstance } from 'class-transformer';
import { validate, type ValidationError } from 'class-validator';
import {
MAX_CONTACT_GROUP_NAME_LENGTH,
MAX_CONTACT_QUERY_LENGTH,
MAX_GROUP_MEMBERS_PER_REQUEST,
} from '@/modules/contacts/contacts.constants';
import { DirectoryEntryQueryDto } from './directory-entry.dto';
import {
ContactGroupCreateDto,
ContactGroupMembersDto,
ContactGroupRenameDto,
} from './contact-group.dto';
import { ContactRestorationDto } from './contact-restoration.dto';
function constraintNames(errors: ValidationError[]): string[] {
return errors.flatMap((error) => [
...Object.keys(error.constraints ?? {}),
...constraintNames(error.children ?? []),
]);
}
/** 전역 ValidationPipe 와 같은 옵션 (stopAtFirstError 만 빼고 — 걸린 규칙을 모두 본다) */
async function check<T extends object>(
type: new () => T,
body: object,
): Promise<{ names: string[]; dto: T }> {
const dto = plainToInstance(type, body);
const errors = await validate(dto, {
whitelist: true,
forbidNonWhitelisted: true,
});
return { names: constraintNames(errors), dto };
}
const ids = (count: number, prefix = 'c') =>
Array.from({ length: count }, (_, index) => `${prefix}${index}`);
describe('그룹 본문 (POST · PATCH /api/contact-groups)', () => {
it(`이름 — 앞뒤 공백을 뺀 1~${MAX_CONTACT_GROUP_NAME_LENGTH}자, 제어 문자 없음 — 지운 값으로 저장한다`, async () => {
const { names, dto } = await check(ContactGroupRenameDto, {
name: ` ${'가'.repeat(MAX_CONTACT_GROUP_NAME_LENGTH)} `,
});
expect(names).toEqual([]);
expect(dto.name).toBe('가'.repeat(MAX_CONTACT_GROUP_NAME_LENGTH));
for (const name of ['', ' ']) {
expect((await check(ContactGroupRenameDto, { name })).names).toEqual([
'minCodeUnits',
]);
}
expect(
(
await check(ContactGroupRenameDto, {
name: 'x'.repeat(MAX_CONTACT_GROUP_NAME_LENGTH + 1),
})
).names,
).toEqual(['maxCodeUnits']);
expect(
(await check(ContactGroupRenameDto, { name: '거래\n처' })).names,
).toEqual(['matches']);
expect((await check(ContactGroupRenameDto, {})).names).toContain(
'isString',
);
expect(
(await check(ContactGroupRenameDto, { name: '거래처', memberIds: [] }))
.names,
).toEqual(['whitelistValidation']);
});
it('만들기는 { name } 만 — memberIds 는 받지 않는다(지운 그룹은 POST /api/contact-restorations 로 되돌린다)', async () => {
expect(
(await check(ContactGroupCreateDto, { name: '거래처' })).names,
).toEqual([]);
expect(
(await check(ContactGroupCreateDto, { name: '거래처', memberIds: ['a'] }))
.names,
).toEqual(['whitelistValidation']);
});
});
describe('구성원 넣기 본문 (POST /api/contact-groups/{id}/members)', () => {
it(`contactIds · directoryIds — 둘 다 없어도 형식은 맞다(합쳐 1~${MAX_GROUP_MEMBERS_PER_REQUEST}개는 서비스가 본다), 각각 ${MAX_GROUP_MEMBERS_PER_REQUEST}개까지`, async () => {
expect((await check(ContactGroupMembersDto, {})).names).toEqual([]);
expect(
(
await check(ContactGroupMembersDto, {
contactIds: ids(MAX_GROUP_MEMBERS_PER_REQUEST),
directoryIds: ids(1, 'd'),
})
).names,
).toEqual([]);
for (const key of ['contactIds', 'directoryIds']) {
expect(
(
await check(ContactGroupMembersDto, {
[key]: ids(MAX_GROUP_MEMBERS_PER_REQUEST + 1),
})
).names,
).toEqual(['arrayMaxSize']);
expect(
(await check(ContactGroupMembersDto, { [key]: ['x', 'x'] })).names,
).toEqual(['arrayDistinct']);
}
expect(
(await check(ContactGroupMembersDto, { members: ['x'] })).names,
).toEqual(['whitelistValidation']);
});
});
describe('되돌리기 본문 (POST /api/contact-restorations)', () => {
it('token — 지우기 응답의 restoreToken 모양(base64url 22자)만, 모르는 칸은 받지 않는다', async () => {
expect(
(await check(ContactRestorationDto, { token: 'q3Jd0Vb1Lw9xA2mYk7Pz4g' }))
.names,
).toEqual([]);
for (const token of [
'',
'short',
'q3Jd0Vb1Lw9xA2mYk7Pz4g=',
'q3Jd0Vb1Lw9xA2mYk7Pz4/',
'x'.repeat(100_000),
]) {
expect((await check(ContactRestorationDto, { token })).names).toEqual([
'matches',
]);
}
expect((await check(ContactRestorationDto, { token: 7 })).names).toContain(
'isString',
);
expect(
(
await check(ContactRestorationDto, {
token: 'q3Jd0Vb1Lw9xA2mYk7Pz4g',
kind: 'contact',
})
).names,
).toEqual(['whitelistValidation']);
});
});
describe('사내 목록 쿼리 (GET /api/directory-entries)', () => {
it(`kind 는 person · shared 만 · q 는 ${MAX_CONTACT_QUERY_LENGTH}자까지 · excludeGroupId 는 id 형식`, async () => {
expect(
(await check(DirectoryEntryQueryDto, { kind: 'person', q: '도윤' }))
.names,
).toEqual([]);
expect(
(await check(DirectoryEntryQueryDto, { kind: 'group' })).names,
).toEqual(['isIn']);
expect(
(
await check(DirectoryEntryQueryDto, {
q: 'x'.repeat(MAX_CONTACT_QUERY_LENGTH),
excludeGroupId: 'jy',
})
).names,
).toEqual([]);
expect(
(
await check(DirectoryEntryQueryDto, {
q: 'x'.repeat(MAX_CONTACT_QUERY_LENGTH + 1),
})
).names,
).toEqual(['maxCodeUnits']);
expect(
(await check(DirectoryEntryQueryDto, { excludeGroupId: 'a b' })).names,
).toEqual(['matches']);
expect(
(await check(DirectoryEntryQueryDto, { groupId: 'g' })).names,
).toEqual(['whitelistValidation']);
});
});
@@ -0,0 +1,178 @@
import { applyDecorators } from '@nestjs/common';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import {
ArrayMaxSize,
IsArray,
IsOptional,
IsString,
Matches,
} from 'class-validator';
import { ArrayDistinct } from '@/common/decorators/array-distinct.decorator';
import {
MaxCodeUnits,
MinCodeUnits,
} from '@/common/decorators/code-units.decorator';
import { TrimString } from '@/common/decorators/trim-string.decorator';
import { PageInfoDto } from '@/common/dto/pagination.dto';
import {
CONTACT_ID_PATTERN,
MAX_CONTACT_GROUP_NAME_LENGTH,
MAX_GROUP_MEMBERS_PER_REQUEST,
} from '@/modules/contacts/contacts.constants';
import type {
ContactGroup,
ContactGroupDeleted,
ContactGroupMembersAdded,
} from '@/modules/contacts/contacts.types';
import { SINGLE_LINE_TEXT_PATTERN } from '@/modules/mail/mail.constants';
/**
* 그룹 — 만들기 · 이름 바꾸기 · 구성원 넣기 본문과 응답 (계약 3절 "주소록").
* 이름은 앞뒤 공백을 지운 값으로 검사하고 그 값으로 저장한다. 같은 이름(앞뒤 공백 · ASCII 대소문자 무시)은 서비스가 본다(409 CONTACT_001).
* 칸의 검사는 applyDecorators 로 묶어 적은 차례대로 돈다 — 형식 → 길이 · 개수 → 모양 (contact.dto.ts 머리말 — stopAtFirstError 라 길이에서 걸리면 정규식을 돌리지 않는다)
*/
const NAME_DESCRIPTION = `그룹 이름 — 앞뒤 공백을 뺀 1~${MAX_CONTACT_GROUP_NAME_LENGTH}자(JS 문자열 길이) · 제어 문자 없음. 이 주소록 안에서 겹치지 않는다(ASCII 대소문자 무시 — 아니면 409 CONTACT_001)`;
/** 그룹 이름 — 앞뒤 공백을 뺀 1~50자 · 제어 문자 없음 */
function GroupName(): PropertyDecorator {
return applyDecorators(
TrimString(),
IsString({ message: '그룹 이름 형식이 올바르지 않습니다.' }),
MinCodeUnits(1, { message: '그룹 이름을 입력해 주세요.' }),
MaxCodeUnits(MAX_CONTACT_GROUP_NAME_LENGTH, {
message: `그룹 이름은 ${MAX_CONTACT_GROUP_NAME_LENGTH}자 이하로 입력해 주세요.`,
}),
Matches(SINGLE_LINE_TEXT_PATTERN, {
message: '그룹 이름에는 줄바꿈 같은 제어 문자를 쓸 수 없습니다.',
}),
);
}
/** 구성원 넣기의 id 목록 — 없어도 되고, 100개까지 · id 형식 · 겹치지 않게 */
function MemberIdList(duplicateMessage: string): PropertyDecorator {
return applyDecorators(
IsOptional(),
IsArray({ message: '구성원 목록 형식이 올바르지 않습니다.' }),
ArrayMaxSize(MAX_GROUP_MEMBERS_PER_REQUEST, {
message: `한 번에 ${MAX_GROUP_MEMBERS_PER_REQUEST}명까지 넣을 수 있습니다.`,
}),
Matches(CONTACT_ID_PATTERN, {
each: true,
message: '구성원을 다시 골라 주세요.',
}),
ArrayDistinct({ message: duplicateMessage }),
);
}
/** 이름 칸 — 만들기 · 이름 바꾸기가 같은 규칙 */
export class ContactGroupNameDto {
@ApiProperty({
description: NAME_DESCRIPTION,
example: '거래처',
minLength: 1,
maxLength: MAX_CONTACT_GROUP_NAME_LENGTH,
})
@GroupName()
name!: string;
}
/** POST /api/contact-groups — `{ name }` 만 (지운 그룹은 POST /api/contact-restorations 로 되돌린다) */
export class ContactGroupCreateDto extends ContactGroupNameDto {}
/** PATCH /api/contact-groups/{id} */
export class ContactGroupRenameDto extends ContactGroupNameDto {}
/** POST /api/contact-groups/{id}/members — 합쳐 1~100개 (서비스가 본다 — 아니면 400 VAL_001) */
export class ContactGroupMembersDto {
@ApiPropertyOptional({
description: `넣을 내 연락처 id — 겹치지 않게. contactIds 와 directoryIds 를 합쳐 1~${MAX_GROUP_MEMBERS_PER_REQUEST}개. 이 주소록에 없는 연락처면 404 RES_001`,
type: [String],
maxItems: MAX_GROUP_MEMBERS_PER_REQUEST,
example: ['jw'],
})
@MemberIdList('같은 연락처를 두 번 넣을 수 없습니다.')
contactIds?: string[];
@ApiPropertyOptional({
description:
'넣을 사내 목록 id(엔진의 계정 id) — 겹치지 않게. 같은 주소(대소문자 무시)의 연락처가 내 주소록에 있으면 그 연락처를 넣고, ' +
'없으면 이름 · 주소(종류는 회사)로 연락처를 새로 만들어 넣는다. 사내 목록에 없는 id 면 404 RES_001, 사내 목록을 읽지 못하면 503 MAIL_001',
type: [String],
maxItems: MAX_GROUP_MEMBERS_PER_REQUEST,
example: ['b'],
})
@MemberIdList('같은 사람을 두 번 넣을 수 없습니다.')
directoryIds?: string[];
}
/** 응답 — 계약 3절 ContactGroup */
export class ContactGroupDto implements ContactGroup {
@ApiProperty({ description: '그룹 id (엔진의 카드 id)', example: 'jy' })
id!: string;
@ApiProperty({ description: '그룹 이름', example: '거래처' })
name!: string;
@ApiProperty({
description:
'이 주소록에 있는 연락처 가운데 구성원 수 (지워진 카드를 가리키는 구성원은 세지 않는다)',
example: 4,
})
memberCount!: number;
}
export class ContactGroupPageDto {
@ApiProperty({
type: [ContactGroupDto],
description: '이름 차례(한국어 사전 차례 → id)',
})
items!: ContactGroupDto[];
@ApiProperty({ type: PageInfoDto, description: 'total 포함' })
pageInfo!: PageInfoDto;
}
/** 그룹 지우기의 응답 — 계약 3절 ContactGroupDeleted */
export class ContactGroupDeletedDto implements ContactGroupDeleted {
@ApiProperty({ description: '지운 그룹 id', example: 'jy' })
id!: string;
@ApiProperty({ description: '지운 그룹 이름', example: '거래처' })
name!: string;
@ApiProperty({
description: '지우기 전 구성원(이 주소록의 연락처 id, 이름 차례)',
type: [String],
example: ['jw', 'jx'],
})
memberIds!: string[];
@ApiProperty({
description:
'[되돌리기] 표 — POST /api/contact-restorations 의 token. 5분 · 한 번만 쓴다. 서버가 기억하지 못했으면 null',
nullable: true,
type: String,
example: 'q3Jd0Vb1Lw9xA2mYk7Pz4g',
})
restoreToken!: string | null;
}
export class ContactGroupMembersAddedDto implements ContactGroupMembersAdded {
@ApiProperty({ type: ContactGroupDto, description: '넣은 뒤의 그룹' })
group!: ContactGroupDto;
@ApiProperty({
description:
'이번에 그룹에 들어간 수 (이미 구성원이던 것 · 그룹에 넣을 수 없는 카드는 세지 않는다)',
example: 2,
})
addedCount!: number;
@ApiProperty({
description: '사내 목록에서 골라 내 주소록에 새로 만든 연락처 수',
example: 1,
})
createdCount!: number;
}
@@ -0,0 +1,56 @@
import { applyDecorators } from '@nestjs/common';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsString, Matches } from 'class-validator';
import { CONTACT_RESTORE_TOKEN_PATTERN } from '@/modules/contacts/contacts.constants';
import { ContactGroupDto } from './contact-group.dto';
import { ContactDto } from './contact.dto';
/**
* 되돌리기 — POST /api/contact-restorations 본문과 응답 (계약 3절 "되돌리기").
* 표는 지우기 응답의 restoreToken 그대로다(무작위 128비트 — base64url 22자). 모양이 틀리면 400 VAL_001, 모양은 맞는데 없는 표 · 지난 표 ·
* 이미 쓴 표 · 다른 직원의 표는 404 RES_001 (서비스가 본다). 표는 로그에 남기지 않는다
*/
function RestoreToken(): PropertyDecorator {
return applyDecorators(
IsString({ message: '되돌리기 표 형식이 올바르지 않습니다.' }),
Matches(CONTACT_RESTORE_TOKEN_PATTERN, {
message: '되돌리기 표 형식이 올바르지 않습니다.',
}),
);
}
export class ContactRestorationDto {
@ApiProperty({
description:
'지우기 응답(DELETE /api/contacts/{id} · /api/contact-groups/{id})의 restoreToken — 5분 · 한 번만 (성공하면 지운다. 실패하면 5분 안에 다시 쓸 수 있다)',
example: 'q3Jd0Vb1Lw9xA2mYk7Pz4g',
})
@RestoreToken()
token!: string;
}
/** 응답 — 계약 3절 ContactRestored (kind 에 따라 contact 나 group 하나만 있다) */
export class ContactRestoredDto {
@ApiProperty({
description: '다시 만든 것 — contact 연락처 · group 그룹',
enum: ['contact', 'group'],
example: 'contact',
})
kind!: 'contact' | 'group';
@ApiPropertyOptional({
type: ContactDto,
description:
'kind 가 contact 일 때 — 다시 만든 연락처(새 id). 그때 속했고 지금도 있는 그룹에 다시 들어 있다(구성원 500명이 찬 그룹은 건너뛴다)',
})
contact?: ContactDto;
@ApiPropertyOptional({
type: ContactGroupDto,
description:
'kind 가 group 일 때 — 다시 만든 그룹(새 id). 구성원은 그때의 구성원 가운데 지금도 있는 연락처',
})
group?: ContactGroupDto;
}
@@ -0,0 +1,462 @@
import { BadRequestException } from '@nestjs/common';
import { plainToInstance } from 'class-transformer';
import { validate, type ValidationError } from 'class-validator';
import { startCpuTimer } from '@/../test/cpu-time';
import { createValidationPipe } from '@/app.setup';
import {
MAX_CONTACT_EMAIL_LENGTH,
MAX_CONTACT_EMAILS,
MAX_CONTACT_GROUP_IDS,
MAX_CONTACT_NOTE_LENGTH,
MAX_CONTACT_PHONE_LENGTH,
MAX_CONTACT_PHONES,
MAX_CONTACT_QUERY_LENGTH,
MAX_CONTACT_TEXT_LENGTH,
} from '@/modules/contacts/contacts.constants';
import { ContactGroupNameDto } from './contact-group.dto';
import {
ContactInputDto,
ContactListQueryDto,
ContactPatchDto,
} from './contact.dto';
import { DirectoryEntryQueryDto } from './directory-entry.dto';
/** 걸린 규칙 이름 — 중첩된 칸(emails.0.address 등)까지 */
function constraintNames(errors: ValidationError[]): string[] {
return errors.flatMap((error) => [
...Object.keys(error.constraints ?? {}),
...constraintNames(error.children ?? []),
]);
}
/** 전역 ValidationPipe 와 같은 옵션으로 변환 · 검증한다 (app.setup.ts — stopAtFirstError 만 빼고 — 걸린 규칙을 모두 본다, 통과 · 실패는 같다) */
async function check<T extends object>(
type: new () => T,
body: object,
stopAtFirstError = false,
): Promise<{ names: string[]; dto: T }> {
const dto = plainToInstance(type, body);
const errors = await validate(dto, {
whitelist: true,
forbidNonWhitelisted: true,
stopAtFirstError,
});
return { names: constraintNames(errors), dto };
}
const body = (overrides: object = {}) => ({
name: '박지훈',
emails: [{ address: 'jihoon.park@hanbit.example', label: 'work' }],
phones: [{ number: '010-3192-5540', label: 'mobile' }],
company: '한빛상사',
department: '구매팀',
title: '과장',
note: '견적 담당\n월요일 오전',
groupIds: ['jy'],
...overrides,
});
const namesOf = async (overrides: object) =>
(await check(ContactInputDto, body(overrides))).names;
describe('연락처 본문 (POST /api/contacts)', () => {
it('맞는 본문 — 이메일 · 전화 · 그룹이 없어도 형식은 맞다 (이름이나 이메일 가운데 하나는 서비스가 본다)', async () => {
expect(await namesOf({})).toEqual([]);
expect(
await namesOf({
name: null,
emails: [],
phones: [],
company: null,
department: null,
title: null,
note: null,
groupIds: [],
}),
).toEqual([]);
});
it('글은 앞뒤 공백을 지운 값으로 검사하고 그 값으로 저장한다 — 빈 글은 null', async () => {
const { names, dto } = await check(
ContactInputDto,
body({
name: ` ${'가'.repeat(MAX_CONTACT_TEXT_LENGTH)} `,
company: ' ',
department: '\u3000구매팀\u3000',
title: '',
note: '\n 메모 \n',
emails: [{ address: ' a@x.example ', label: 'other' }],
phones: [{ number: ' 02-333-4444 ', label: 'work' }],
}),
);
expect(names).toEqual([]);
expect(dto.name).toBe('가'.repeat(MAX_CONTACT_TEXT_LENGTH));
expect(dto.company).toBeNull();
expect(dto.department).toBe('구매팀');
expect(dto.title).toBeNull();
expect(dto.note).toBe('메모');
expect(dto.emails[0]?.address).toBe('a@x.example');
expect(dto.phones[0]?.number).toBe('02-333-4444');
});
it('모든 칸을 보낸다 — 키가 빠지면 형식 오류 · 모르는 칸은 받지 않는다', async () => {
for (const key of [
'name',
'emails',
'phones',
'company',
'department',
'title',
'note',
'groupIds',
]) {
const partial: Record<string, unknown> = body();
delete partial[key];
expect(
(await check(ContactInputDto, partial)).names.length,
).toBeGreaterThan(0);
}
expect(await namesOf({ birthday: '1990-05-01' })).toEqual([
'whitelistValidation',
]);
expect(
await namesOf({
emails: [{ address: 'a@x.example', label: 'work', pref: 1 }],
}),
).toEqual(['whitelistValidation']);
});
it(`한 줄 칸(이름 · 회사 · 부서 · 직책) — ${MAX_CONTACT_TEXT_LENGTH}자까지(JS 문자열 길이), 제어 문자 없음`, async () => {
for (const key of ['name', 'company', 'department', 'title']) {
expect(
await namesOf({ [key]: 'x'.repeat(MAX_CONTACT_TEXT_LENGTH + 1) }),
).toEqual(['maxCodeUnits']);
// 이모지는 두 자로 센다 (웹의 .length 와 같다)
expect(
await namesOf({
[key]: `${'😀'.repeat(MAX_CONTACT_TEXT_LENGTH / 2)}x`,
}),
).toEqual(['maxCodeUnits']);
expect(await namesOf({ [key]: '가\t나' })).toEqual(['matches']);
expect(await namesOf({ [key]: 7 })).toContain('isString');
}
});
it(`메모 — ${MAX_CONTACT_NOTE_LENGTH}자까지, 줄바꿈(\\n)만 되고 탭 · \\r 등은 안 된다`, async () => {
expect(
await namesOf({ note: 'x'.repeat(MAX_CONTACT_NOTE_LENGTH + 1) }),
).toEqual(['maxCodeUnits']);
expect(
await namesOf({ note: `줄\n${'x'.repeat(MAX_CONTACT_NOTE_LENGTH - 2)}` }),
).toEqual([]);
for (const note of ['a\tb', 'a\r\nb', 'a\u0000b']) {
expect(await namesOf({ note })).toEqual(['matches']);
}
});
it(`이메일 — ${MAX_CONTACT_EMAILS}개까지 · 메일 주소 형식 · 254자 · 종류는 work · personal · other (같은 주소 두 번은 서비스가 본다)`, async () => {
const email = { address: 'a@x.example', label: 'work' };
expect(
await namesOf({
emails: Array.from({ length: MAX_CONTACT_EMAILS + 1 }, (_, i) => ({
...email,
address: `a${i}@x.example`,
})),
}),
).toEqual(['arrayMaxSize']);
expect(
await namesOf({
emails: Array.from({ length: MAX_CONTACT_EMAILS }, (_, i) => ({
...email,
address: `a${i}@x.example`,
})),
}),
).toEqual([]);
expect(await namesOf({ emails: [{ ...email, address: 'a@' }] })).toContain(
'isEmail',
);
expect(
await namesOf({
emails: [
{ ...email, address: `${'a'.repeat(64)}@${'b'.repeat(190)}.example` },
],
}),
).toContain('maxCodeUnits');
expect(await namesOf({ emails: [{ ...email, label: 'home' }] })).toEqual([
'isIn',
]);
expect(await namesOf({ emails: email })).toContain('isArray');
expect(await namesOf({ emails: [null] })).not.toEqual([]);
expect(await namesOf({ emails: [email, email] })).toEqual([]);
});
it(`전화번호 — ${MAX_CONTACT_PHONES}개까지 · ${MAX_CONTACT_PHONE_LENGTH}자 · 숫자가 하나 이상이고 숫자 · 띄어쓰기 · + - ( ) . # * 만 · 종류는 mobile · work · home · other`, async () => {
const phone = { number: '010-3192-5540', label: 'mobile' };
expect(
await namesOf({
phones: Array.from({ length: MAX_CONTACT_PHONES + 1 }, () => phone),
}),
).toEqual(['arrayMaxSize']);
expect(
await namesOf({
phones: [
{ ...phone, number: '1'.repeat(MAX_CONTACT_PHONE_LENGTH + 1) },
],
}),
).toEqual(['maxCodeUnits']);
// 글자가 틀림(내선 · tel:) · 숫자가 없음(+-) · 빈 글
for (const number of ['내선 12', 'tel:010', '+-', '(#*)', '', ' ']) {
expect(await namesOf({ phones: [{ ...phone, number }] })).toEqual([
'matches',
]);
}
for (const number of ['7', '+82 10-3192-5540', '(02) 333.4444 #2']) {
expect(await namesOf({ phones: [{ ...phone, number }] })).toEqual([]);
}
expect(await namesOf({ phones: [{ ...phone, label: 'fax' }] })).toEqual([
'isIn',
]);
});
it(`그룹 — ${MAX_CONTACT_GROUP_IDS}개까지 · id 형식 · 겹치지 않게(ArrayDistinct)`, async () => {
expect(
await namesOf({
groupIds: Array.from(
{ length: MAX_CONTACT_GROUP_IDS + 1 },
(_, i) => `g${i}`,
),
}),
).toEqual(['arrayMaxSize']);
expect(await namesOf({ groupIds: ['a/b'] })).toEqual(['matches']);
expect(await namesOf({ groupIds: ['g1', 'g1'] })).toEqual([
'arrayDistinct',
]);
expect(await namesOf({ groupIds: 'g1' })).toContain('isArray');
});
});
describe('연락처 목록 쿼리 (GET /api/contacts)', () => {
const queryNames = async (query: object) =>
(await check(ContactListQueryDto, query)).names;
it(`q 는 앞뒤 공백을 지운 ${MAX_CONTACT_QUERY_LENGTH}자까지 · groupId · excludeGroupId 는 id 형식 · limit 1~100 · 모르는 쿼리는 받지 않는다`, async () => {
expect(await queryNames({})).toEqual([]);
expect(
await queryNames({ q: 'ㅂㅈㅎ', groupId: 'jy', limit: '100' }),
).toEqual([]);
expect(
(
await check(ContactListQueryDto, {
q: ` ${'x'.repeat(MAX_CONTACT_QUERY_LENGTH)} `,
})
).names,
).toEqual([]);
expect(
await queryNames({ q: 'x'.repeat(MAX_CONTACT_QUERY_LENGTH + 1) }),
).toEqual(['maxCodeUnits']);
expect(await queryNames({ groupId: 'a b' })).toEqual(['matches']);
// excludeGroupId 도 id 형식 (groupId 와 함께 쓰는 것은 서비스가 400)
expect(await queryNames({ excludeGroupId: 'jy' })).toEqual([]);
expect(await queryNames({ excludeGroupId: 'a/b' })).toEqual(['matches']);
expect(await queryNames({ limit: '101' })).toEqual(['max']);
expect(await queryNames({ sort: 'name' })).toEqual(['whitelistValidation']);
});
});
describe('연락처 고치기 본문 (PATCH /api/contacts/{id} — 바꾼 칸만)', () => {
const patchNames = async (body: object) =>
(await check(ContactPatchDto, body)).names;
it('보낸 칸만 검사한다 — 빈 본문 · 칸 하나도 형식은 맞다(빈 본문은 서비스가 400), 보내지 않은 칸은 undefined', async () => {
expect(await patchNames({})).toEqual([]);
const { names, dto } = await check(ContactPatchDto, {
department: ' 영업팀 ',
});
expect(names).toEqual([]);
expect(dto.department).toBe('영업팀');
expect(dto.name).toBeUndefined();
expect(dto.emails).toBeUndefined();
expect(dto.groupIds).toBeUndefined();
});
it('글 칸의 null · 빈 글은 "지운다"(null) — 배열 칸의 null 은 형식 오류', async () => {
const { names, dto } = await check(ContactPatchDto, {
name: null,
company: ' ',
note: '',
});
expect(names).toEqual([]);
expect(dto.name).toBeNull();
expect(dto.company).toBeNull();
expect(dto.note).toBeNull();
for (const key of ['emails', 'phones', 'groupIds']) {
expect(await patchNames({ [key]: null })).toContain('isArray');
}
});
it('보낸 칸은 POST 와 같은 규칙 — 길이 · 모양 · 개수 · 모르는 칸', async () => {
expect(
await patchNames({ title: 'x'.repeat(MAX_CONTACT_TEXT_LENGTH + 1) }),
).toEqual(['maxCodeUnits']);
expect(await patchNames({ name: '가\t나' })).toEqual(['matches']);
expect(await patchNames({ note: 'a\tb' })).toEqual(['matches']);
expect(
await patchNames({ phones: [{ number: '내선', label: 'work' }] }),
).toEqual(['matches']);
expect(
await patchNames({ emails: [{ address: 'a@', label: 'work' }] }),
).toContain('isEmail');
expect(
await patchNames({
groupIds: Array.from(
{ length: MAX_CONTACT_GROUP_IDS + 1 },
(_, i) => `g${i}`,
),
}),
).toEqual(['arrayMaxSize']);
expect(await patchNames({ groupIds: ['g1', 'g1'] })).toEqual([
'arrayDistinct',
]);
expect(await patchNames({ birthday: '1990-05-01' })).toEqual([
'whitelistValidation',
]);
});
});
describe('검사 차례 — 형식 → 길이 · 개수 → 모양 (stopAtFirstError 로 길이에서 멈춘다, 2026-10-29 리뷰 H1)', () => {
/** 전역 파이프처럼 첫 실패에서 멈춘다 — 길이와 모양이 함께 틀린 값은 길이만 걸려야 한다(정규식 · 주소 검사를 돌리지 않는다) */
const firstNames = async (type: new () => object, body: object) =>
(await check(type, body, true)).names;
/** 모양이 틀린 글자를 가운데에 둔다 — 앞뒤에 두면 앞뒤 공백 지우기가 먼저 지운다 */
const tooLong = (length: number, bad: string) =>
`x${bad}${'x'.repeat(length)}`;
it.each([
['name', MAX_CONTACT_TEXT_LENGTH, '\t'],
['company', MAX_CONTACT_TEXT_LENGTH, '\t'],
['department', MAX_CONTACT_TEXT_LENGTH, '\t'],
['title', MAX_CONTACT_TEXT_LENGTH, '\t'],
['note', MAX_CONTACT_NOTE_LENGTH, '\t'],
])('%s — 길고 제어 문자도 든 글은 maxCodeUnits 만', async (key, max, bad) => {
for (const type of [ContactInputDto, ContactPatchDto]) {
expect(
await firstNames(type, body({ [key]: tooLong(max, bad) })),
).toEqual(['maxCodeUnits']);
}
});
it('전화번호 · 주소 — 길고 모양도 틀린 값은 maxCodeUnits 만, 문자열이 아니면 isString 만', async () => {
expect(
await firstNames(
ContactInputDto,
body({
phones: [
{
number: tooLong(MAX_CONTACT_PHONE_LENGTH, '내선'),
label: 'work',
},
],
}),
),
).toEqual(['maxCodeUnits']);
expect(
await firstNames(
ContactInputDto,
body({
emails: [
{
address: tooLong(MAX_CONTACT_EMAIL_LENGTH, '@@ '),
label: 'work',
},
],
}),
),
).toEqual(['maxCodeUnits']);
expect(
await firstNames(
ContactInputDto,
body({ phones: [{ number: 7, label: 'work' }] }),
),
).toEqual(['isString']);
});
it('그룹 이름 · 찾는 글(q) — 길이가 먼저', async () => {
expect(
await firstNames(ContactGroupNameDto, { name: tooLong(50, '\n') }),
).toEqual(['maxCodeUnits']);
for (const type of [ContactListQueryDto, DirectoryEntryQueryDto]) {
expect(
await firstNames(type, { q: 'x'.repeat(MAX_CONTACT_QUERY_LENGTH + 1) }),
).toEqual(['maxCodeUnits']);
}
});
it('그룹 id 목록 — 배열이 아니면 isArray 만 · 개수가 넘으면 arrayMaxSize 만(항목 모양 · 겹침은 보지 않는다)', async () => {
expect(await firstNames(ContactInputDto, body({ groupIds: 'g1' }))).toEqual(
['isArray'],
);
expect(
await firstNames(
ContactInputDto,
body({
groupIds: Array.from(
{ length: MAX_CONTACT_GROUP_IDS + 1 },
() => 'a b',
),
}),
),
).toEqual(['arrayMaxSize']);
});
});
describe('전역 DTO 검증의 비용 (createValidationPipe — 한 칸에 100만 자, CPU 시간)', () => {
/**
* 본문 1MB 안에 한 칸을 100만 자로 채워도 길이에서 싸게 400 이다 (2026-10-29 리뷰 H1 — 전에는 전화번호 정규식이 길이 검사보다 먼저 돌았고
* 그 정규식이 길이의 제곱이라 4만 자 한 칸에 CPU 1.7초, 100만 자면 수십 분이었다).
* 지금 한 칸에 수 ms(Windows 틱 15.6ms 아래) — 한도 1초는 부하(CI 의 4~7배)를 견디고, 옛 경로(4만 자에 1.7초)는 확실히 넘는다
*/
const BUDGET_MS = 1_000;
const MILLION = 1_000_000;
it.each<[string, new () => object, object]>([
[
'전화번호 (숫자 뒤에 틀린 글자 하나 — 옛 정규식이 가장 느렸던 모양)',
ContactInputDto,
body({ phones: [{ number: `${'1'.repeat(MILLION)}x`, label: 'work' }] }),
],
[
'전화번호 (고치기)',
ContactPatchDto,
{ phones: [{ number: `${'1'.repeat(MILLION)}x`, label: 'work' }] },
],
[
'주소',
ContactInputDto,
body({
emails: [
{ address: `${'a'.repeat(MILLION)}@x.example`, label: 'work' },
],
}),
],
[
'메모 (끝에 탭)',
ContactInputDto,
body({ note: `${'가'.repeat(MILLION)}\t` }),
],
['이름 (고치기)', ContactPatchDto, { name: `${'가'.repeat(MILLION)}\t` }],
[
'찾는 글',
ContactListQueryDto,
{ q: `${'ㄱ'.repeat(MILLION)}`, limit: '20' },
],
])('%s — 1초 안에 400', async (_label, metatype, value) => {
const pipe = createValidationPipe();
const cpuMs = startCpuTimer();
await expect(
pipe.transform(value, { type: 'body', metatype }),
).rejects.toBeInstanceOf(BadRequestException);
expect(cpuMs()).toBeLessThan(BUDGET_MS);
});
});
@@ -0,0 +1,525 @@
import { applyDecorators } from '@nestjs/common';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import {
ArrayMaxSize,
IsArray,
IsEmail,
IsIn,
IsOptional,
IsString,
Matches,
ValidateIf,
ValidateNested,
} from 'class-validator';
import { ArrayDistinct } from '@/common/decorators/array-distinct.decorator';
import { MaxCodeUnits } from '@/common/decorators/code-units.decorator';
import {
TrimString,
TrimStringOrNull,
} from '@/common/decorators/trim-string.decorator';
import {
CursorPaginationQueryDto,
PageInfoDto,
} from '@/common/dto/pagination.dto';
import {
CONTACT_ID_PATTERN,
CONTACT_NOTE_PATTERN,
CONTACT_PHONE_DIGIT,
CONTACT_PHONE_PATTERN,
MAX_CONTACT_EMAIL_LENGTH,
MAX_CONTACT_EMAILS,
MAX_CONTACT_GROUP_IDS,
MAX_CONTACT_NOTE_LENGTH,
MAX_CONTACT_PHONE_LENGTH,
MAX_CONTACT_PHONES,
MAX_CONTACT_QUERY_LENGTH,
MAX_CONTACT_TEXT_LENGTH,
} from '@/modules/contacts/contacts.constants';
import {
CONTACT_EMAIL_LABELS,
CONTACT_PHONE_LABELS,
type Contact,
type ContactDeleted,
type ContactEmail,
type ContactEmailLabel,
type ContactInput,
type ContactPatch,
type ContactPhone,
type ContactPhoneLabel,
} from '@/modules/contacts/contacts.types';
import { SINGLE_LINE_TEXT_PATTERN } from '@/modules/mail/mail.constants';
/**
* 연락처 — POST 본문(ContactInput) · PATCH 본문(ContactPatch)과 응답(Contact · ContactDeleted) (계약 3절 "주소록").
* 여기서는 칸마다 형식을 본다. 이름이나 이메일 가운데 하나는 있어야 함 · 같은 연락처 안의 같은 주소(대소문자 무시) · 빈 PATCH 는 서비스가 본다 (모두 400 VAL_001).
* 글은 앞뒤 공백을 지운 값으로 검사하고 그 값으로 저장한다 — 빈 글은 null.
* - POST 는 모든 칸을 보낸다(값 없음은 null · 빈 배열, 키가 빠지면 형식 오류)
* - PATCH 는 바꾼 칸만 보낸다 — 보내지 않은 칸은 검사하지 않는다(다른 프로그램이 우리 규칙 밖의 값을 넣었어도 다른 칸은 고칠 수 있다)
*
* **검사 차례**: 칸의 검사는 applyDecorators 로 묶어 **적은 차례대로** 돈다 — 형식(IsString · IsArray) → 길이 · 개수 → 모양(정규식 · 메일 주소).
* 전역 파이프는 stopAtFirstError 라(app.setup.ts) 길이에서 걸리면 그 뒤의 정규식 · 주소 검사를 하지 않는다 — 1MB 짜리 한 칸이 와도 싸게 400 이다
* (2026-10-29 리뷰 H1 — 데코레이터를 그냥 쌓으면 아래 것부터 돌아, 정규식이 길이 검사보다 먼저 돌았다)
*/
/** POST — null 은 "없음"으로 받는다. 키가 없으면(undefined) 형식 오류다 (모든 칸을 보낸다) */
const unlessNull = (_object: object, value: unknown) => value !== null;
/** PATCH — 보내지 않은 칸(undefined)과 "없음"(null)은 검사하지 않는다 */
const whenSentText = (_object: object, value: unknown) =>
value !== undefined && value !== null;
/** PATCH — 보내지 않은 배열 칸은 검사하지 않는다 (null 은 형식 오류) */
const whenSent = (_object: object, value: unknown) => value !== undefined;
type Sent = 'always' | 'patch';
/** 한 줄 글 칸(이름 · 회사 · 부서 · 직책) — 앞뒤 공백을 빼고 100자, 제어 문자 없음, 빈 글은 null */
function SingleLineText(
label: string,
topic: string,
sent: Sent,
): PropertyDecorator {
return applyDecorators(
TrimStringOrNull(),
ValidateIf(sent === 'always' ? unlessNull : whenSentText),
IsString({ message: `${label} 형식이 올바르지 않습니다.` }),
MaxCodeUnits(MAX_CONTACT_TEXT_LENGTH, {
message: `${topic} ${MAX_CONTACT_TEXT_LENGTH}자 이하로 입력해 주세요.`,
}),
Matches(SINGLE_LINE_TEXT_PATTERN, {
message: `${label}에는 줄바꿈 같은 제어 문자를 쓸 수 없습니다.`,
}),
);
}
function NoteText(sent: Sent): PropertyDecorator {
return applyDecorators(
TrimStringOrNull(),
ValidateIf(sent === 'always' ? unlessNull : whenSentText),
IsString({ message: '메모 형식이 올바르지 않습니다.' }),
MaxCodeUnits(MAX_CONTACT_NOTE_LENGTH, {
message: `메모는 ${MAX_CONTACT_NOTE_LENGTH.toLocaleString('en-US')}자 이하로 입력해 주세요.`,
}),
Matches(CONTACT_NOTE_PATTERN, {
message: '메모에는 줄바꿈 말고 다른 제어 문자를 쓸 수 없습니다.',
}),
);
}
/** 메일 주소 — 254자 · 메일 주소 형식 (앞뒤 공백을 지운 값) */
function EmailAddress(): PropertyDecorator {
return applyDecorators(
TrimString(),
IsString({ message: '이메일 형식이 올바르지 않습니다.' }),
MaxCodeUnits(MAX_CONTACT_EMAIL_LENGTH, {
message: `이메일 주소는 ${MAX_CONTACT_EMAIL_LENGTH}자 이하로 입력해 주세요.`,
}),
IsEmail({}, { message: '이메일 형식이 올바르지 않습니다.' }),
);
}
/** 전화번호 — 40자 · 숫자 · 띄어쓰기 · + - ( ) . # * 만 · 숫자가 하나 이상 (앞뒤 공백을 지운 값) */
function PhoneNumber(): PropertyDecorator {
return applyDecorators(
TrimString(),
IsString({ message: '전화번호 형식이 올바르지 않습니다.' }),
MaxCodeUnits(MAX_CONTACT_PHONE_LENGTH, {
message: `전화번호는 ${MAX_CONTACT_PHONE_LENGTH}자 이하로 입력해 주세요.`,
}),
Matches(CONTACT_PHONE_PATTERN, {
message:
'전화번호에는 숫자와 띄어쓰기 · + - ( ) . # * 만 쓸 수 있습니다.',
}),
Matches(CONTACT_PHONE_DIGIT, {
message: '전화번호에는 숫자가 하나 이상 있어야 합니다.',
}),
);
}
export class ContactEmailDto implements ContactEmail {
@ApiProperty({
description: `메일 주소 — ${MAX_CONTACT_EMAIL_LENGTH}자까지 · 메일 주소 형식 (앞뒤 공백은 지워 저장한다)`,
example: 'jihoon.park@hanbit.co.kr',
maxLength: MAX_CONTACT_EMAIL_LENGTH,
})
@EmailAddress()
address!: string;
@ApiProperty({
description: '종류 — work 회사 · personal 개인 · other 기타',
enum: [...CONTACT_EMAIL_LABELS],
example: 'work',
})
@IsIn(CONTACT_EMAIL_LABELS, { message: '이메일 종류를 다시 골라 주세요.' })
label!: ContactEmailLabel;
}
export class ContactPhoneDto implements ContactPhone {
@ApiProperty({
description: `전화번호 — ${MAX_CONTACT_PHONE_LENGTH}자까지, 숫자가 하나 이상이고 숫자 · 띄어쓰기 · + - ( ) . # * 만 (앞뒤 공백은 지워 저장한다)`,
example: '010-3192-5540',
maxLength: MAX_CONTACT_PHONE_LENGTH,
})
@PhoneNumber()
number!: string;
@ApiProperty({
description: '종류 — mobile 휴대전화 · work 회사 · home 집 · other 기타',
enum: [...CONTACT_PHONE_LABELS],
example: 'mobile',
})
@IsIn(CONTACT_PHONE_LABELS, { message: '전화번호 종류를 다시 골라 주세요.' })
label!: ContactPhoneLabel;
}
function EmailList(sent: Sent): PropertyDecorator {
return applyDecorators(
...(sent === 'patch' ? [ValidateIf(whenSent)] : []),
IsArray({ message: '이메일 목록 형식이 올바르지 않습니다.' }),
ArrayMaxSize(MAX_CONTACT_EMAILS, {
message: `이메일은 ${MAX_CONTACT_EMAILS}개까지 넣을 수 있습니다.`,
}),
ValidateNested({ each: true }),
Type(() => ContactEmailDto),
);
}
function PhoneList(sent: Sent): PropertyDecorator {
return applyDecorators(
...(sent === 'patch' ? [ValidateIf(whenSent)] : []),
IsArray({ message: '전화번호 목록 형식이 올바르지 않습니다.' }),
ArrayMaxSize(MAX_CONTACT_PHONES, {
message: `전화번호는 ${MAX_CONTACT_PHONES}개까지 넣을 수 있습니다.`,
}),
ValidateNested({ each: true }),
Type(() => ContactPhoneDto),
);
}
function GroupIdList(sent: Sent): PropertyDecorator {
return applyDecorators(
...(sent === 'patch' ? [ValidateIf(whenSent)] : []),
IsArray({ message: '그룹 목록 형식이 올바르지 않습니다.' }),
ArrayMaxSize(MAX_CONTACT_GROUP_IDS, {
message: `그룹은 ${MAX_CONTACT_GROUP_IDS}개까지 고를 수 있습니다.`,
}),
Matches(CONTACT_ID_PATTERN, {
each: true,
message: '그룹을 다시 골라 주세요.',
}),
ArrayDistinct({ message: '같은 그룹을 두 번 고를 수 없습니다.' }),
);
}
/** 한 줄 글 칸의 설명 */
function singleLineDescription(what: string): string {
return `${what} — 앞뒤 공백을 뺀 ${MAX_CONTACT_TEXT_LENGTH}자까지(JS 문자열 길이) · 제어 문자 없음. 없으면 null (빈 글은 null 로 저장한다)`;
}
const NOTE_DESCRIPTION = `메모 — 앞뒤 공백을 뺀 ${MAX_CONTACT_NOTE_LENGTH.toLocaleString('en-US')}자까지. 줄바꿈(\\n)은 되고 그 밖의 제어 문자(탭 · \\r 포함)는 안 된다. 없으면 null`;
const EMAILS_DESCRIPTION = `이메일 — ${MAX_CONTACT_EMAILS}개까지(화면은 5개까지 더한다), 앞의 것이 대표 주소. 같은 연락처 안에서 겹치지 않게(대소문자 무시 — 아니면 400 VAL_001)`;
const PHONES_DESCRIPTION = `전화번호 — ${MAX_CONTACT_PHONES}개까지`;
const GROUP_IDS_DESCRIPTION = `속할 그룹 id — ${MAX_CONTACT_GROUP_IDS}개까지, 겹치지 않게. 이 주소록에 없는 그룹이면 404 RES_001`;
export class ContactInputDto implements ContactInput {
@ApiProperty({
description: singleLineDescription('표시 이름'),
nullable: true,
type: String,
example: '박지훈',
maxLength: MAX_CONTACT_TEXT_LENGTH,
})
@SingleLineText('이름', '이름은', 'always')
name!: string | null;
@ApiProperty({
description: EMAILS_DESCRIPTION,
type: [ContactEmailDto],
maxItems: MAX_CONTACT_EMAILS,
})
@EmailList('always')
emails!: ContactEmailDto[];
@ApiProperty({
description: PHONES_DESCRIPTION,
type: [ContactPhoneDto],
maxItems: MAX_CONTACT_PHONES,
})
@PhoneList('always')
phones!: ContactPhoneDto[];
@ApiProperty({
description: singleLineDescription('회사'),
nullable: true,
type: String,
example: '한빛상사',
maxLength: MAX_CONTACT_TEXT_LENGTH,
})
@SingleLineText('회사', '회사는', 'always')
company!: string | null;
@ApiProperty({
description: singleLineDescription('부서'),
nullable: true,
type: String,
example: '구매팀',
maxLength: MAX_CONTACT_TEXT_LENGTH,
})
@SingleLineText('부서', '부서는', 'always')
department!: string | null;
@ApiProperty({
description: singleLineDescription('직책'),
nullable: true,
type: String,
example: '과장',
maxLength: MAX_CONTACT_TEXT_LENGTH,
})
@SingleLineText('직책', '직책은', 'always')
title!: string | null;
@ApiProperty({
description: NOTE_DESCRIPTION,
nullable: true,
type: String,
example: '견적 담당\n월요일 오전에 연락',
maxLength: MAX_CONTACT_NOTE_LENGTH,
})
@NoteText('always')
note!: string | null;
@ApiProperty({
description: GROUP_IDS_DESCRIPTION,
type: [String],
maxItems: MAX_CONTACT_GROUP_IDS,
example: ['jy'],
})
@GroupIdList('always')
groupIds!: string[];
}
const PATCH_NOTE =
' — 보낼 때만 검사하고 바꾼다(보내지 않으면 그대로, 다른 프로그램이 넣은 규칙 밖의 값도 그대로)';
/**
* PATCH 본문 — 바꾼 칸만(하나 이상 — 비면 400 VAL_001, 서비스가 본다). 보낸 칸만 위 규칙으로 보고, "이름이나 이메일"은 지금 연락처와 합친 결과로 본다.
* emails · phones · groupIds 는 보내면 그 칸이 통째로 바뀐다. 텍스트 칸의 null 은 "지운다"
*/
export class ContactPatchDto implements ContactPatch {
@ApiPropertyOptional({
description: `${singleLineDescription('표시 이름')}${PATCH_NOTE}`,
nullable: true,
type: String,
example: '박지훈',
maxLength: MAX_CONTACT_TEXT_LENGTH,
})
@SingleLineText('이름', '이름은', 'patch')
name?: string | null;
@ApiPropertyOptional({
description: `${EMAILS_DESCRIPTION}${PATCH_NOTE}`,
type: [ContactEmailDto],
maxItems: MAX_CONTACT_EMAILS,
})
@EmailList('patch')
emails?: ContactEmailDto[];
@ApiPropertyOptional({
description: `${PHONES_DESCRIPTION}${PATCH_NOTE}`,
type: [ContactPhoneDto],
maxItems: MAX_CONTACT_PHONES,
})
@PhoneList('patch')
phones?: ContactPhoneDto[];
@ApiPropertyOptional({
description: `${singleLineDescription('회사')}${PATCH_NOTE}`,
nullable: true,
type: String,
example: '한빛상사',
maxLength: MAX_CONTACT_TEXT_LENGTH,
})
@SingleLineText('회사', '회사는', 'patch')
company?: string | null;
@ApiPropertyOptional({
description: `${singleLineDescription('부서')}${PATCH_NOTE}`,
nullable: true,
type: String,
example: '구매팀',
maxLength: MAX_CONTACT_TEXT_LENGTH,
})
@SingleLineText('부서', '부서는', 'patch')
department?: string | null;
@ApiPropertyOptional({
description: `${singleLineDescription('직책')}${PATCH_NOTE}`,
nullable: true,
type: String,
example: '과장',
maxLength: MAX_CONTACT_TEXT_LENGTH,
})
@SingleLineText('직책', '직책은', 'patch')
title?: string | null;
@ApiPropertyOptional({
description: `${NOTE_DESCRIPTION}${PATCH_NOTE}`,
nullable: true,
type: String,
example: '견적 담당',
maxLength: MAX_CONTACT_NOTE_LENGTH,
})
@NoteText('patch')
note?: string | null;
@ApiPropertyOptional({
description: `${GROUP_IDS_DESCRIPTION}${PATCH_NOTE}`,
type: [String],
maxItems: MAX_CONTACT_GROUP_IDS,
example: ['jy'],
})
@GroupIdList('patch')
groupIds?: string[];
}
/** 응답 — 계약 3절 Contact */
export class ContactDto implements Contact {
@ApiProperty({ description: '연락처 id (엔진의 카드 id)', example: 'jw' })
id!: string;
@ApiProperty({
description:
'표시 이름 (엔진 카드의 name.full — 없으면 이름 조각을 이어 붙인 것(한글 조각끼리는 붙여 쓴다), 그것도 없으면 null)',
nullable: true,
type: String,
example: '박지훈',
})
name!: string | null;
@ApiProperty({
description:
'이메일 — 앞의 것이 대표 주소(받는 사람 추천의 첫 줄 · 그룹으로 보낼 때 쓴다). 다른 프로그램이 넣은 것은 개수 제한 없이 그대로 보인다',
type: [ContactEmailDto],
})
emails!: ContactEmailDto[];
@ApiProperty({ description: '전화번호', type: [ContactPhoneDto] })
phones!: ContactPhoneDto[];
@ApiProperty({
description: '회사 (없으면 null)',
nullable: true,
type: String,
example: '한빛상사',
})
company!: string | null;
@ApiProperty({
description: '부서 (없으면 null)',
nullable: true,
type: String,
example: '구매팀',
})
department!: string | null;
@ApiProperty({
description: '직책 (없으면 null)',
nullable: true,
type: String,
example: '과장',
})
title!: string | null;
@ApiProperty({
description: '메모 (줄바꿈 있음, 없으면 null)',
nullable: true,
type: String,
example: '견적 담당\n월요일 오전에 연락',
})
note!: string | null;
@ApiProperty({
description: '속한 그룹 id — 이 주소록의 그룹만 (그룹 이름 차례)',
type: [String],
example: ['jy'],
})
groupIds!: string[];
}
/** 연락처 지우기의 응답 — 계약 3절 ContactDeleted */
export class ContactDeletedDto implements ContactDeleted {
@ApiProperty({
type: ContactDto,
description: '지운 연락처 (그룹에서도 뺐다)',
})
contact!: ContactDto;
@ApiProperty({
description:
'[되돌리기] 표 — POST /api/contact-restorations 의 token. 5분 · 한 번만 쓴다. 서버가 기억하지 못했으면 null(카드가 256KB 를 넘음 등 — 화면은 [되돌리기] 없이 알린다)',
nullable: true,
type: String,
example: 'q3Jd0Vb1Lw9xA2mYk7Pz4g',
})
restoreToken!: string | null;
}
export class ContactPageDto {
@ApiProperty({
type: [ContactDto],
description:
'이름 차례(한국어 사전 차례 — 이름이 없으면 대표 주소로 견준다. 같으면 대표 주소 → id). 이름도 주소도 없는 연락처는 맨 뒤',
})
items!: ContactDto[];
@ApiProperty({ type: PageInfoDto, description: 'total 포함 (거른 개수)' })
pageInfo!: PageInfoDto;
}
/** 찾는 글(q) — 연락처 · 사내 목록이 같은 규칙 */
export function ContactQueryText(): PropertyDecorator {
return applyDecorators(
IsOptional(),
TrimString(),
IsString({ message: '찾는 글 형식이 올바르지 않습니다.' }),
MaxCodeUnits(MAX_CONTACT_QUERY_LENGTH, {
message: `찾는 글은 ${MAX_CONTACT_QUERY_LENGTH}자 이하로 입력해 주세요.`,
}),
);
}
/** 그룹 id 쿼리(groupId · excludeGroupId) */
export function ContactGroupIdQuery(): PropertyDecorator {
return applyDecorators(
IsOptional(),
IsString({ message: '그룹을 다시 골라 주세요.' }),
Matches(CONTACT_ID_PATTERN, { message: '그룹을 다시 골라 주세요.' }),
);
}
/** GET /api/contacts — 커서 페이지네이션 + 찾는 글 · 그룹 */
export class ContactListQueryDto extends CursorPaginationQueryDto {
@ApiPropertyOptional({
description:
`찾는 글 — 앞뒤 공백을 뺀 ${MAX_CONTACT_QUERY_LENGTH}자까지(비면 거르지 않는다). 초성만 친 글이면 이름의 초성과 견주고(ㅂㅈㅎ → 박지훈), ` +
'그 밖에는 이름 · 주소 · 회사 · 부서 · 직책 · 전화번호에 든 것(ASCII 대소문자 무시 — 전화번호는 숫자만 견준다)',
example: 'ㅂㅈㅎ',
})
@ContactQueryText()
q?: string;
@ApiPropertyOptional({
description: '이 그룹의 구성원만 — 없는 그룹이면 404 RES_001',
example: 'jy',
})
@ContactGroupIdQuery()
groupId?: string;
@ApiPropertyOptional({
description:
'이 그룹의 구성원을 빼고(구성원 넣기 창) — 없는 그룹이면 404 RES_001. groupId 와 함께 쓰지 않는다(400 VAL_001)',
example: 'jy',
})
@ContactGroupIdQuery()
excludeGroupId?: string;
}
@@ -0,0 +1,84 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsIn, IsOptional } from 'class-validator';
import {
CursorPaginationQueryDto,
PageInfoDto,
} from '@/common/dto/pagination.dto';
import { MAX_CONTACT_QUERY_LENGTH } from '@/modules/contacts/contacts.constants';
import {
DIRECTORY_ENTRY_KINDS,
type DirectoryEntry,
type DirectoryEntryKind,
} from '@/modules/contacts/contacts.types';
import { ContactGroupIdQuery, ContactQueryText } from './contact.dto';
/** GET /api/directory-entries — 커서 페이지네이션 + 찾는 글 · 종류 (계약 3절 "사내 목록") */
export class DirectoryEntryQueryDto extends CursorPaginationQueryDto {
@ApiPropertyOptional({
description:
`찾는 글 — 앞뒤 공백을 뺀 ${MAX_CONTACT_QUERY_LENGTH}자까지(비면 거르지 않는다). 초성만 친 글이면 이름의 초성과 견주고, ` +
'그 밖에는 이름 · 주소에 든 것 (ASCII 대소문자 무시)',
example: '도윤',
})
@ContactQueryText()
q?: string;
@ApiPropertyOptional({
description: 'person 직원(개인 계정)만 · shared 공용 주소(그룹 계정)만',
enum: [...DIRECTORY_ENTRY_KINDS],
example: 'person',
})
@IsOptional()
@IsIn(DIRECTORY_ENTRY_KINDS, { message: '종류를 다시 골라 주세요.' })
kind?: DirectoryEntryKind;
@ApiPropertyOptional({
description:
'이 그룹(내 주소록) 구성원이 가진 주소(대소문자 무시)를 뺀다 — 구성원 넣기 창. 없는 그룹이면 404 RES_001',
example: 'jy',
})
@ContactGroupIdQuery()
excludeGroupId?: string;
}
/** 응답 — 계약 3절 DirectoryEntry */
export class DirectoryEntryDto implements DirectoryEntry {
@ApiProperty({
description:
'엔진의 계정 id — 그룹 구성원 넣기(POST /api/contact-groups/{id}/members)의 directoryIds 에 쓴다',
example: 'b',
})
id!: string;
@ApiProperty({
description:
'엔진의 표시 이름(계정의 설명 — 관리 화면에서 넣는 이름), 없으면 주소의 @ 앞',
example: '이도윤',
})
name!: string;
@ApiProperty({
description: '사내 주소 (소문자)',
example: 'doyun.lee@arasystem.it.kr',
})
email!: string;
@ApiProperty({
description: 'person 직원(개인 계정) · shared 공용 주소(그룹 계정)',
enum: [...DIRECTORY_ENTRY_KINDS],
example: 'person',
})
kind!: DirectoryEntryKind;
}
export class DirectoryEntryPageDto {
@ApiProperty({
type: [DirectoryEntryDto],
description: '이름 차례(한국어 사전 차례 → 주소 → id)',
})
items!: DirectoryEntryDto[];
@ApiProperty({ type: PageInfoDto, description: 'total 포함 (거른 개수)' })
pageInfo!: PageInfoDto;
}
@@ -6,9 +6,10 @@ const EMAIL_COLUMN_LENGTH = 254;
export const DIRECTORY_NAME_LENGTH = 200;
/**
* 사내 목록 (계약 2절 "받는 사람 추천") — 웹메일 로그인에 성공한 직원의 주소·이름.
* 메일 엔진의 계정 목록(Principal) 권한은 쓰지 않는다 — 일부러 막아 두었다 (mail/scripts/setup.mjs).
* 메일 엔진 관리자·게이트웨이·막은 계정은 넣지 않는다 (로그인할 수도 없다).
* 로그인 기록 (계약 2절 "받는 사람 추천") — 웹메일 로그인에 성공한 직원의 주소·이름.
* 8단계부터 받는 사람 추천의 "사내"는 엔진의 사내 목록(contacts/directory.service.ts — 사내 목록 계정으로 계정 목록을 읽는다)이고,
* 이 표는 그것을 읽지 못할 때 대신 쓴다. 직원 역할의 계정 목록(Principal) 권한은 그대로 막아 두었다 (mail/scripts/setup.mjs).
* 메일 엔진 관리자·게이트웨이·사내 목록 계정·막은 계정은 넣지 않는다 (로그인할 수도 없다).
*/
@Entity({ name: 'mail_directory' })
export class MailDirectoryEntryEntity {
@@ -5,7 +5,7 @@ import { MailDirectoryEntryEntity } from './mail-directory.entity';
import { MailDirectoryService } from './mail-directory.service';
/**
* 사내 목록 모듈 — 인증 모듈(로그인 때 기록)과 메일 모듈(받는 사람 추천)이 함께 쓴다.
* 로그인 기록 모듈 — 인증 모듈(로그인 때 기록)과 메일 모듈(받는 사람 추천 — 엔진의 사내 목록을 읽지 못할 때 대신)이 함께 쓴다.
* 두 모듈 사이에 두어 서로를 가져오지 않게 한다 (AuthModule → MailModule 방향만 있다).
*/
@Module({
@@ -20,7 +20,8 @@ export const MAIL_DIRECTORY_ACTIVE_DAYS = 90;
const DAY_MS = 24 * 60 * 60 * 1000;
/**
* 사내 목록 — 로그인에 성공한 직원을 기록하고(AuthService), 받는 사람 추천이 읽는다(RecipientSuggestionService).
* 로그인 기록 — 로그인에 성공한 직원을 기록하고(AuthService), 받는 사람 추천이 엔진의 사내 목록을 읽지 못할 때 대신 읽는다
* (RecipientSuggestionService — 8단계부터 "사내"는 contacts/directory.service.ts).
* 누가 넣을지(관리자·게이트웨이 제외)는 부르는 쪽이 정한다.
*/
@Injectable()
@@ -0,0 +1,150 @@
import { plainToInstance } from 'class-transformer';
import { validate, type ValidationError } from 'class-validator';
import {
MAX_MAIL_FILTER_CONDITIONS,
MAX_MAIL_FILTER_RUN_CURSOR_LENGTH,
MAX_MAIL_FILTER_VALUE_LENGTH,
} from '@/modules/mail/mail.constants';
import { MailFilterRunRequestDto } from './mail-filter-run.dto';
/** 걸린 규칙 이름 — 중첩된 칸(conditions.0.value · actions.flag 등)까지 */
function constraintNames(errors: ValidationError[]): string[] {
return errors.flatMap((error) => [
...Object.keys(error.constraints ?? {}),
...constraintNames(error.children ?? []),
]);
}
/** 전역 ValidationPipe 와 같은 옵션으로 변환 · 검증한다 (app.setup.ts — stopAtFirstError 만 빼고 — 걸린 규칙을 모두 본다, 통과 · 실패는 같다) */
async function check(
body: object,
): Promise<{ names: string[]; dto: MailFilterRunRequestDto }> {
const dto = plainToInstance(MailFilterRunRequestDto, body);
const errors = await validate(dto, {
whitelist: true,
forbidNonWhitelisted: true,
});
return { names: constraintNames(errors), dto };
}
const namesOf = async (body: object) => (await check(body)).names;
const condition = {
field: 'from',
operator: 'contains',
value: '@hanbit-partners.co.kr',
};
const actions = { moveToMailboxId: 'j', markAsRead: true, flag: false };
const body = (overrides: object = {}) => ({
match: 'any',
conditions: [condition],
actions,
cursor: null,
...overrides,
});
const withCondition = (overrides: object) =>
body({ conditions: [{ ...condition, ...overrides }] });
const withActions = (overrides: object) =>
body({ actions: { ...actions, ...overrides } });
describe('이미 받은 메일에 필터 적용 본문 (POST /api/mail-filter-runs)', () => {
it('맞는 본문 — 처음(cursor null) · 이어 하기(cursor 글) · 모든 메일 · 옮기지 않는 할 일', async () => {
expect(await namesOf(body())).toEqual([]);
expect(await namesOf(body({ cursor: 'eyJ2IjoxfQ' }))).toEqual([]);
expect(
await namesOf(
body({
match: 'always',
conditions: [],
actions: { moveToMailboxId: null, markAsRead: false, flag: true },
}),
),
).toEqual([]);
});
it('찾을 글은 필터 규칙과 같은 검사 — 앞뒤 공백을 지운 값으로 검사하고 그 값을 쓴다', async () => {
const { names, dto } = await check(
withCondition({ value: ' @hanbit-partners.co.kr ' }),
);
expect(names).toEqual([]);
expect(dto.conditions[0].value).toBe('@hanbit-partners.co.kr');
for (const value of [
'',
' ',
'x'.repeat(MAX_MAIL_FILTER_VALUE_LENGTH + 1),
'광고${x}',
'광고\n줄바꿈',
42,
]) {
expect(await namesOf(withCondition({ value }))).not.toEqual([]);
}
expect(await namesOf(withCondition({ field: 'cc' }))).toContain('isIn');
expect(await namesOf(withCondition({ operator: 'equals' }))).toContain(
'isIn',
);
});
it(`묶는 방법 · 조건 수 — 모르는 방법 · 조건 ${MAX_MAIL_FILTER_CONDITIONS}개 초과 · 배열이 아님은 400 (방법과 조건 수의 짝은 서비스가 본다)`, async () => {
expect(await namesOf(body({ match: 'none' }))).toContain('isIn');
expect(
await namesOf(
body({
conditions: Array.from(
{ length: MAX_MAIL_FILTER_CONDITIONS + 1 },
() => condition,
),
}),
),
).toContain('arrayMaxSize');
expect(await namesOf(body({ conditions: condition }))).toContain('isArray');
});
it('할 일 — 전달(forwardTo) 등 다른 키는 모르는 필드 · 옮길 메일함 id 형식 · 표시는 참거짓 · 객체가 아님', async () => {
expect(
await namesOf(withActions({ forwardTo: 'doyun.lee@arasystem.test' })),
).toContain('whitelistValidation');
expect(await namesOf(withActions({ forwardTo: null }))).toContain(
'whitelistValidation',
);
expect(await namesOf(withActions({ moveToMailboxId: '메일함' }))).toContain(
'matches',
);
expect(await namesOf(withActions({ flag: 'yes' }))).toContain('isBoolean');
expect(await namesOf(body({ actions: [] }))).not.toEqual([]);
// 할 일이 하나도 없는 것은 형식이 아니라 칸 사이의 규칙이다 — 서비스가 VAL_001 (findFilterRunProblem)
expect(
await namesOf(
withActions({ moveToMailboxId: null, markAsRead: false, flag: false }),
),
).toEqual([]);
});
it(`cursor — 늘 보낸다(빠지면 400) · 글이면 ${MAX_MAIL_FILTER_RUN_CURSOR_LENGTH}자까지 base64url 글자만`, async () => {
const withoutCursor = Object.fromEntries(
Object.entries(body()).filter(([key]) => key !== 'cursor'),
);
expect(await namesOf(withoutCursor)).toContain('isString');
expect(
await namesOf(
body({ cursor: 'a'.repeat(MAX_MAIL_FILTER_RUN_CURSOR_LENGTH + 1) }),
),
).toContain('maxLength');
expect(await namesOf(body({ cursor: 'abc=' }))).toContain('matches');
expect(await namesOf(body({ cursor: '' }))).toContain('matches');
expect(await namesOf(body({ cursor: 7 }))).toContain('isString');
});
it('모르는 필드는 400 — 규칙의 id · 이름 · 켜짐 · 규칙 목록 · version 은 이 요청에 없다', async () => {
for (const extra of [
{ id: 'r1' },
{ name: '거래처' },
{ isEnabled: true },
{ rules: [] },
{ version: 'v' },
]) {
expect(await namesOf(body(extra))).toContain('whitelistValidation');
}
});
});
@@ -0,0 +1,123 @@
import { ApiProperty, PickType } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import {
IsObject,
IsString,
Matches,
MaxLength,
ValidateIf,
ValidateNested,
} from 'class-validator';
import {
MailFilterActionsDto,
MailFilterRuleDto,
} from '@/modules/mail/dto/mail-filter.dto';
import {
MAIL_FILTER_RUN_CURSOR_PATTERN,
MAX_MAIL_FILTER_RUN_CURSOR_LENGTH,
} from '@/modules/mail/mail.constants';
import type {
MailFilterRun,
MailFilterRunActions,
MailFilterRunRequest,
} from '@/modules/mail/mail.types';
/**
* 이미 받은 메일에 필터 적용 — POST /api/mail-filter-runs 의 본문과 응답 (계약 2절 "이미 받은 메일에 적용" — 7단계).
* 묶는 방법 · 조건 · 할 일은 필터 규칙(dto/mail-filter.dto.ts)의 칸을 그대로 가져와 같은 검사를 한다(앞뒤 공백 · 길이 · `${` · 제어 문자).
* 칸 사이의 규칙(묶는 방법 ↔ 조건 수 · 할 일 하나 이상)과 옮길 메일함(보낸편지함 · 임시보관함 아님)은 서비스가 본다 (모두 400 VAL_001).
* 전달은 없다 — actions 에 forwardTo 같은 다른 키를 보내면 모르는 필드로 400 VAL_001
*/
/** null 은 "처음부터"로 받는다 — 키가 없으면(undefined) 형식 오류다 (늘 보낸다) */
const unlessNull = (_object: object, value: unknown) => value !== null;
/** 할 일 — 필터 규칙의 할 일에서 전달을 뺀 세 칸 (같은 검사) */
export class MailFilterRunActionsDto
extends PickType(MailFilterActionsDto, [
'moveToMailboxId',
'markAsRead',
'flag',
] as const)
implements MailFilterRunActions
{
@ApiProperty({
description:
'옮길 메일함 id (없으면 null) — 필터 규칙과 같은 메일함만(보낸편지함 · 임시보관함은 400 VAL_001). ' +
'받은편지함이거나 없는 메일함(지워짐)이면 옮기지 않고 표시만 한다',
nullable: true,
type: String,
example: 'j',
})
moveToMailboxId!: string | null;
}
/** POST /api/mail-filter-runs — 규칙 하나(방금 저장한 그 규칙)와 이어 할 자리 */
export class MailFilterRunRequestDto
extends PickType(MailFilterRuleDto, ['match', 'conditions'] as const)
implements MailFilterRunRequest
{
@ApiProperty({
description:
'할 일 — 하나 이상(셋 다 없으면 400 VAL_001). 읽음 · 중요 표시를 붙이고(이미 있으면 그대로) 받은편지함에서 옮길 메일함으로 옮긴다. ' +
'전달은 없다 — 이미 받은 메일은 전달하지 않는다',
type: MailFilterRunActionsDto,
})
@IsObject({ message: '할 일 형식이 올바르지 않습니다.' })
@ValidateNested()
@Type(() => MailFilterRunActionsDto)
actions!: MailFilterRunActionsDto;
@ApiProperty({
description:
'이어 할 자리 — 처음은 null, 이어 할 때는 앞 응답의 cursor 그대로(해석하지 않는다). 늘 보낸다. ' +
'다른 규칙의 cursor · 깨진 cursor 는 400 VAL_001',
nullable: true,
type: String,
maxLength: MAX_MAIL_FILTER_RUN_CURSOR_LENGTH,
example: null,
})
@ValidateIf(unlessNull)
@IsString({ message: '이어 할 자리를 다시 받아 주세요.' })
@MaxLength(MAX_MAIL_FILTER_RUN_CURSOR_LENGTH, {
message: '이어 할 자리를 다시 받아 주세요.',
})
@Matches(MAIL_FILTER_RUN_CURSOR_PATTERN, {
message: '이어 할 자리를 다시 받아 주세요.',
})
cursor!: string | null;
}
/** POST /api/mail-filter-runs 의 응답 (계약 2절의 MailFilterRun) */
export class MailFilterRunDto implements MailFilterRun {
@ApiProperty({
description: '이번 요청에서 확인한 받은편지함 메일 수',
example: 500,
})
scanned!: number;
@ApiProperty({
description:
'그중 조건에 맞아 할 일을 한 메일 수 — 이미 읽음 · 중요였던 메일도 센다',
example: 37,
})
matched!: number;
@ApiProperty({
description:
'아직 확인하지 않은 메일 수 — 어림(그사이 들어오고 나간 메일이 있다). 진행의 전체는 지금까지 확인한 수 + 이 값',
example: 7_912,
})
remaining!: number;
@ApiProperty({
description:
'null = 끝. 아니면 같은 본문에 이 cursor 를 넣어 다시 보낸다 (해석하지 않는다)',
nullable: true,
type: String,
example:
'eyJ2IjoxLCJhIjoic2VhYWFhZXIiLCJwIjo1MDAsInIiOiJkMlZ3dHo3TjJnQlVUcmdpIn0',
})
cursor!: string | null;
}
@@ -1,6 +1,10 @@
import { ApiProperty } from '@nestjs/swagger';
import { PageInfoDto } from '@/common/dto/pagination.dto';
import {
CONTACT_EMAIL_LABELS,
type ContactEmailLabel,
} from '@/modules/contacts/contacts.types';
import { MAX_BODY_VALUE_BYTES } from '@/modules/mail/mail.constants';
import type {
Draft,
@@ -526,6 +530,7 @@ export class DraftSavedDto implements SavedDraft {
}
const SUGGESTION_SOURCES: RecipientSuggestion['source'][] = [
'contact',
'directory',
'recent',
];
@@ -548,18 +553,36 @@ export class RecipientSuggestionDto implements RecipientSuggestion {
@ApiProperty({
description:
'directory = 사내(웹메일에 로그인한 적 있는 직원), recent = 최근 주고받은 주소. 두 쪽에 다 있으면 directory',
'contact = 내 주소록(연락처의 주소마다 한 줄), directory = 사내(엔진의 사내 목록 — 읽지 못하면 웹메일에 로그인한 적 있는 직원), ' +
'recent = 최근 주고받은 주소. 여러 묶음에 있으면 앞 묶음(contact → directory → recent)',
enum: SUGGESTION_SOURCES,
example: 'directory',
})
source!: RecipientSuggestion['source'];
@ApiProperty({
description: 'contact 만 — 그 연락처의 회사 (그 밖에는 null)',
nullable: true,
type: String,
example: null,
})
company!: string | null;
@ApiProperty({
description:
'contact 만 — 그 주소의 종류 work 회사 · personal 개인 · other 기타 (그 밖에는 null)',
nullable: true,
enum: [...CONTACT_EMAIL_LABELS],
example: null,
})
emailLabel!: ContactEmailLabel | null;
}
export class RecipientSuggestionPageDto {
@ApiProperty({
type: [RecipientSuggestionDto],
description:
'사내 → 최근 주고받은 주소, 묶음 안에서는 주고받은 횟수와 최근성 순',
'주소록(연락처의 이름 차례 — 대표 주소가 먼저) → 사내 → 최근 주고받은 주소, 사내 · 최근 안에서는 주고받은 횟수와 최근성 순',
})
items!: RecipientSuggestionDto[];
@@ -17,7 +17,10 @@ import {
MULTI_LINE_TEXT_PATTERN,
SINGLE_LINE_TEXT_PATTERN,
} from '@/modules/mail/mail.constants';
import type { MailVacation } from '@/modules/mail/mail.types';
import type {
MailVacation,
MailVacationState,
} from '@/modules/mail/mail.types';
/** ISO 8601 UTC 시각 — 'Z' 로 끝나고 소수 초는 3자리까지 (JS toISOString 의 모양, 계약 머리말 "날짜·시각") */
const UTC_DATE_TIME = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,3})?Z$/;
@@ -46,7 +49,7 @@ function IsUtcDateTime(options: ValidationOptions) {
const unlessNull = (_object: object, value: unknown) => value !== null;
/**
* 부재중 자동 응답 — GET 응답과 PUT 본문(전체 교체)이 같은 모양이다 (계약 2절 "부재중 자동 응답").
* 부재중 자동 응답 — PUT 본문(전체 교체). GET · PUT 응답은 이것에 isActive 를 더한 MailVacationStateDto 다 (계약 2절 "부재중 자동 응답", 7단계).
* 켤 때 제목 · 내용이 비었는지 · 켤 때의 길이(150자 · 600자), 시작이 끝보다 앞인지는 서비스가 본다 (400 VAL_001).
* 여기서는 켬 · 끔과 상관없는 한도(4단계의 200자 · 4,000자)만 본다 — 같은 속성에 ValidateIf 를 걸면 그 속성의 검사가 모두 빠져
* 켤 때만의 한도를 함께 걸 수 없다. 서식 본문은 받지 않는다.
@@ -112,3 +115,20 @@ export class MailVacationDto implements MailVacation {
})
textBody!: string;
}
/**
* GET · PUT /api/mail-vacation 의 응답 (계약 2절의 MailVacationState — 7단계) — 저장한 값 + 지금 동작하는지.
* PUT 본문에는 isActive 를 넣지 않는다 (넣으면 모르는 필드 — 400 VAL_001). 응답 문서용이라 검사 데코레이터를 더하지 않는다
*/
export class MailVacationStateDto
extends MailVacationDto
implements MailVacationState
{
@ApiProperty({
description:
'false = 메일 엔진에서 우리 규칙(메일 필터와 한 규칙)이 꺼져 있어 켜 둔 자동 응답이 지금은 동작하지 않는다 — 저장하면 다시 켜진다. ' +
'메일 필터의 isActive 와 같은 값이다(한 번도 저장하지 않은 계정은 true). PUT 본문에는 넣지 않는다',
example: true,
})
isActive!: boolean;
}
@@ -7,7 +7,7 @@ import { MAX_SUGGESTION_QUERY_LENGTH } from '@/modules/mail/mail.constants';
/** GET /api/recipient-suggestions — 커서 페이지네이션 + 거르는 글자 (계약 2절 "받는 사람 추천") */
export class RecipientSuggestionQueryDto extends CursorPaginationQueryDto {
@ApiPropertyOptional({
description: `이름·주소에 든 글자 (${MAX_SUGGESTION_QUERY_LENGTH}자 이하, 대소문자 무시). 없거나 공백뿐이면 거르지 않는다`,
description: `찾는 글자 (${MAX_SUGGESTION_QUERY_LENGTH}자 이하) — 쓰기 화면과 같은 맞추기: 이름은 초성 · 조합 중 글자도, 주소는 그 글자가 든 것(완전한 주소면 같은 아이디도), 대소문자 무시. 없거나 공백뿐이면 거르지 않는다`,
example: '도윤',
})
@IsOptional()
@@ -0,0 +1,175 @@
import { startCpuTimer } from '@/../test/cpu-time';
import { NAMED_REFERENCE_COUNT, htmlToText } from './html-text';
/**
* CPU 시간 한도 (벽시계가 아니다 — test/cpu-time.ts). 지금 함수는 아래 2MB 입력을 하나에 CPU 0.15초 안쪽으로 읽는다(혼자 돌 때).
* 한도는 넉넉히 둔다 — 무거운 테스트 파일(html-parse-budget.spec.ts 등) 다음에 같은 일꾼에서 돌면 앞 파일이 남긴 큰 힙의 GC 가
* 재는 사이에 들어와 2초 가까이 재졌다(2026-10-29, 전체 테스트). 막으려는 것은 입력 길이의 제곱이 되는 경로(`<` 마다 `>` 를 끝까지 다시 찾기,
* `&` 마다 끝까지 다시 찾기, 역추적하는 정규식) — 2MB 에서 수천 초가 걸려 이 한도를 확실히 넘는다. 입력은 재기 전에 만들어 둔다(만들기 · 펴기는 재지 않는다)
*/
const LINEAR_CPU_LIMIT_MS = 10_000;
const TWO_MEGABYTES = 2_000_000;
/**
* 엔진(Stalwart 0.16.27 Sieve `body :text`)의 글자 뽑기 규칙 — 엔진과 같은 판정인지는 jmap/filter-run-texts.spec.ts 의 W · X · Y · Z 표(인증 발송 75통)가 본다.
* 여기서는 규칙 하나하나가 만드는 글을 본다 (줄바꿈 = 태그 · 참조가 만든 것)
*/
describe('htmlToText — HTML 의 글자 부분 (계약 2절 "이미 받은 메일에 적용"의 본문 판정)', () => {
it('태그는 빈자리 없이 뺀다 — 인라인 태그 · div · li · 표 칸 · hr · 여는 p 도 이어 붙는다', () => {
expect(htmlToText('회<b class="x">의</b>록')).toBe('회의록');
expect(htmlToText('<div>앞</div><div>뒤</div>')).toBe('앞뒤');
expect(htmlToText('<ul><li>앞</li><li>뒤</li></ul>')).toBe('앞뒤');
expect(htmlToText('<td>앞</td><td>뒤</td>앞<hr>뒤')).toBe('앞뒤앞뒤');
expect(htmlToText('앞<p>뒤 앞<p/>뒤 앞<P class="a">뒤')).toBe(
'앞뒤 앞뒤 앞뒤',
);
});
it('</p> (닫는 것만) · <br> (여는 것 · 닫는 것 · <br/> · <br /> · 대문자)은 줄바꿈이다', () => {
expect(htmlToText('<p>앞</p><p>뒤</p>')).toBe('앞\n뒤\n');
expect(htmlToText('앞</P >뒤')).toBe('앞\n뒤');
for (const br of ['<br>', '<br/>', '<br />', '</br>', '<BR>', '< br >']) {
expect(htmlToText(`앞${br}뒤`)).toBe('앞\n뒤');
}
});
it('태그 이름은 앞의 공백 · / 와 끝의 공백 · / 를 뺀 나머지 전부 — 속성이 붙으면 알아보지 않는다', () => {
// 속성이 붙은 br · p 는 줄을 끊지 않는다
expect(htmlToText('앞<br clear="all">뒤')).toBe('앞뒤');
expect(htmlToText('앞</p class="x">뒤')).toBe('앞뒤');
// o:p 는 p 가 아니다
expect(htmlToText('<o:p>앞</o:p>뒤')).toBe('앞뒤');
// 속성이 붙은 style · script · head · template 은 글을 빼지 않는다
expect(htmlToText('<style type="text/css">a{}</style>뒤')).toBe('a{}뒤');
expect(htmlToText('<head lang="ko"><title>제목</title></head>')).toBe(
'제목',
);
expect(htmlToText('<template id="t">글</template>')).toBe('글');
});
it('<style> · <script> · <head> · <template> 은 여는 것부터 같은 이름의 닫는 것까지 글을 뺀다 — 대소문자 없이, 닫는 태그 끝의 공백도 된다', () => {
// 글을 빼는 동안의 `>` 는 그냥 글자다
expect(htmlToText('<style>a>b{}</style>뒤')).toBe('뒤');
expect(htmlToText('<STYLE>숨김</STYLE>보임')).toBe('보임');
expect(htmlToText('<script>var a = 1;</script >보임')).toBe('보임');
expect(htmlToText('<head><title>제목</title></head><p>본문</p>')).toBe(
'본문\n',
);
expect(htmlToText('<TEMPLATE>숨김</TEMPLATE>보임')).toBe('보임');
// 넷을 따로 센다 — 다른 이름의 닫는 태그로는 끝나지 않는다
expect(htmlToText('<head>머리<style>x</style>아직</head>끝')).toBe('끝');
});
it('글을 빼는 동안에도 태그는 본다 — 그 안의 </p> · <br> 도 줄바꿈이다', () => {
expect(htmlToText('앞<script></p></script>뒤')).toBe('앞\n뒤');
expect(htmlToText('앞<style><br></style>뒤')).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('<p><광고> 안내</p>')).toBe('안내\n');
expect(htmlToText('<img alt="a>b">사진')).toBe('b">사진');
expect(htmlToText('<a href="x>링크</a> 회의 안내 <b class="y">z</b>')).toBe(
'링크 회의 안내 z',
);
expect(htmlToText('<>글')).toBe('글');
});
it('태그 안에서 < 가 또 나오면 거기서 태그를 다시 시작한다 — 닫히지 않은 < 는 끝까지 삼킨다', () => {
// `< 1000원할인` 은 버려지고 `</p>` 가 태그가 된다
expect(htmlToText('<p>가격 < 1000원할인</p>뒤')).toBe('가격\n뒤');
expect(htmlToText('앞 < 뒤')).toBe('앞');
expect(htmlToText('앞<b')).toBe('앞');
expect(htmlToText('a<<<br>b')).toBe('a\nb');
});
it('<!-- … --> 는 안의 글까지 뺀다 — 닫히지 않으면 끝까지. 그 밖의 <!…>(DOCTYPE · mso 조건)는 보통 태그다', () => {
expect(htmlToText('<!-- 주석 -->보임')).toBe('보임');
expect(htmlToText('<!--[if mso]>숨김<![endif]-->보임')).toBe('보임');
expect(htmlToText('<![if !mso]>보임<![endif]>')).toBe('보임');
expect(htmlToText('<!DOCTYPE html><p>글</p>')).toBe('글\n');
expect(htmlToText('앞<!-- 열림')).toBe('앞');
});
it('글의 이어진 공백(띄어쓰기 · 탭 · CR · LF)은 띄어쓰기 하나 — 태그를 건너서도 하나. 맨 앞 공백은 넣지 않는다', () => {
expect(htmlToText('<p>프로젝트\r\n알파</p>')).toBe('프로젝트 알파\n');
expect(htmlToText('둘 공백\t\t끝')).toBe('둘 공백 끝');
expect(htmlToText('<p>회의 <b> 안내</b></p>')).toBe('회의 안내\n');
expect(htmlToText('<span>회의</span> <span>일정</span>')).toBe('회의 일정');
expect(htmlToText(' \n 앞')).toBe('앞');
// U+00A0 · U+3000 · FF 는 공백이 아니다 (글자 그대로)
expect(htmlToText('a\fb \f c')).toBe('a\fb \f c');
expect(htmlToText('a  b c')).toBe('a  b c');
});
it('문자 참조는 ; 로 끝나는 것만 — 푼 글자는 줄이지 않고 그대로 넣는다(앞의 글 공백을 먼저)', () => {
expect(htmlToText('회의&#32;&#32;안내')).toBe('회의 안내');
expect(htmlToText('회의 &#32;안내')).toBe('회의 안내');
expect(htmlToText('회의&#10;안내')).toBe('회의\n안내');
expect(htmlToText('회의&nbsp;안내 · 회의&nbsp안내')).toBe(
'회의 안내 · 회의&nbsp안내',
);
expect(htmlToText('&#54620;&#xAE00;&#X1F600;')).toBe('한글😀');
expect(
htmlToText('A&amp;B &lt;x&gt; &quot;q&quot; &apos;s&#39; &copy; &Omega;'),
).toBe('A&B <x> "q" \'s\' © Ω');
});
it('풀 수 없는 참조는 글자 그대로 — 모르는 이름(HTML5 에만 있는 이름 포함) · ; 없음 · 숫자 없음 · 너무 긴 이름. 없는 글자는 대체 문자', () => {
expect(htmlToText('&AMP; &period; &amp &; &#; &#x; AT&T')).toBe(
'&AMP; &period; &amp &; &#; &#x; AT&T',
);
expect(htmlToText(`&${'a'.repeat(40)};`)).toBe(`&${'a'.repeat(40)};`);
expect(htmlToText('&#0;&#xD800;&#x110000;')).toBe('���');
expect(htmlToText('&Auml;&auml;')).toBe('Ää');
});
it('글을 빼는 동안에는 참조 · 공백도 넣지 않는다 — 앞에 모아 둔 공백은 다음 글자 앞에 그대로', () => {
expect(htmlToText('회의 <style> &#32; x </style>안내')).toBe('회의 안내');
});
it('이름 있는 문자 참조 표는 HTML 4 의 252개와 &apos; 다', () => {
expect(NAMED_REFERENCE_COUNT).toBe(253);
expect(
htmlToText('&nbsp;&yuml;&times;&divide;&thetasym;&diams;&lang;'),
).toBe(' ÿ×÷ϑ♦⟨');
});
describe('입력 길이에 비례한다 (CPU 시간)', () => {
it.each<[string, () => string]>([
['깊게 열린 태그', () => '<b>'.repeat(TWO_MEGABYTES / 3)],
['닫히지 않은 < 만', () => '<'.repeat(TWO_MEGABYTES)],
['다시 시작하는 태그', () => `${'<a'.repeat(TWO_MEGABYTES / 2)}>`],
['; 없는 & 만', () => '&'.repeat(TWO_MEGABYTES)],
['이름이 긴 & 만', () => `&${'a'.repeat(30)}`.repeat(TWO_MEGABYTES / 31)],
['닫히지 않은 주석 여럿', () => '<!--'.repeat(TWO_MEGABYTES / 4)],
['줄바꿈 태그 여럿', () => '<br>'.repeat(TWO_MEGABYTES / 4)],
[
'글을 빼는 태그 여럿',
() => '<style><head></style>'.repeat(TWO_MEGABYTES / 21),
],
['낱말 · 공백 여럿', () => 'a \n'.repeat(TWO_MEGABYTES / 3)],
])('%s (2MB)', (_label, build) => {
const html = build();
// repeat 가 만든 이어 붙인 글을 재기 전에 한 줄로 편다 (V8 은 처음 찾을 때 편다)
html.indexOf('\u0000');
const cpuMs = startCpuTimer();
htmlToText(html);
expect(cpuMs()).toBeLessThan(LINEAR_CPU_LIMIT_MS);
});
});
});
+473
View File
@@ -0,0 +1,473 @@
/**
* HTML 의 글자 부분 (순수 함수) — 이미 받은 메일에 필터를 적용할 때 HTML 본문을 견주는 글이다
* (계약 2절 "이미 받은 메일에 적용"의 판정 — 새로 오는 메일은 엔진의 Sieve `body :text` 가 같은 일을 한다. mail-filter-match.ts).
* 엔진(Stalwart 0.16.27)의 글자 뽑기에 맞췄다 — 2026-10-29 인증 발송 75통(HTML 본문)으로 엔진과 같은 판정을 확인한 규칙 (jmap/filter-run-texts.spec.ts 의 W · X · Y · Z 표):
*
* - 태그는 `<` 부터 다음 `>` 까지다 — `<` 뒤가 무엇이든 태그다(`a < b` · `<광고>` 도). 따옴표는 보지 않는다(`alt="a>b"` 는 첫 `>` 에서 끝난다).
* 태그 안에서 `<` 가 또 나오면 거기서 태그를 다시 시작한다. 닫히지 않은 `<` 는 끝까지 삼킨다.
* `<!--` 는 `-->` 까지 주석이다(안의 글까지 뺀다). 그 밖의 `<!…>`(DOCTYPE · `<![if !mso]>`)는 보통 태그다
* - 태그의 이름 = 태그 글에서 앞의 공백 · `/` 와 끝의 공백 · `/` 를 뺀 **나머지 전부** — ASCII 대소문자 없이 견준다.
* 닫는 태그 = 첫 공백 아닌 글자가 `/`. 속성이 붙으면 이름이 아니다 — `<br clear="all">` · `<style type=…>` · `<head lang=…>` 는 알아보지 않고,
* `<br />` · `</style >` · `</p >` 는 알아본다
* - 알아보는 이름: `br`(여는 것 · 닫는 것) → 줄바꿈, `p` 의 닫는 것만 → 줄바꿈(여는 `<p>` · `<p/>` 는 아무것도 아니다),
* `style` · `script` · `head` · `template` → 여는 것부터 같은 이름의 닫는 것까지 글을 뺀다(넷을 따로 센다 — 짝을 맞추지 않는다).
* `title` · `noscript` · `textarea` · `iframe` · `object` · `svg` · `xml` 은 글이 남는다. 글을 빼는 동안에도 태그는 본다(그 안의 `</p>` · `<br>` 도 줄바꿈)
* - 그 밖의 태그는 빈자리 없이 뺀다 — 낱말 중간의 인라인 태그(`회<b>의</b>`) · div · li · 표 칸도 이어 붙는다
* - 글의 이어진 공백(띄어쓰기 · 탭 · CR · LF — 그 밖의 제어 문자 · U+00A0 · U+3000 은 글자다)은 띄어쓰기 하나다 — 태그를 건너서도 하나(`회의 <b> 안내` → "회의 안내")
* - 문자 참조는 `;` 로 끝나는 것만 푼다(`&nbsp` 는 그대로) — 이름(HTML 4 의 이름 · `&apos;`) · 10진 · 16진. 푼 글자는 공백 줄이기에 넣지 않고 그대로 넣는다:
* `&#32;&#32;` → 띄어쓰기 둘, `&#10;` → 줄바꿈, `회의 &#32;안내` → "회의 안내"(앞의 글 공백을 먼저 넣는다), `&nbsp;` → U+00A0.
* HTML5 에만 있는 이름(`&period;` · 대문자 `&AMP;` 등 — 엔진은 푼다)은 글자 그대로 둔다(계약의 알려진 한계)
* - 줄바꿈(태그 · 참조가 만든 것)은 찾을 글에 없는 글자라(제어 문자를 받지 않는다) 그 자리를 건너 맞는 글이 없다 — 엔진과 같다
*
* 입력을 한 번 훑는다 — 길이에 비례한다(태그를 다시 시작해도 앞으로만 간다). 정규식 · 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 COMMENT_CLOSE = '-->';
/** 안의 글을 빼는 태그 — 소문자 (넷을 따로 센다) */
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 의 특수 · 기호 문자 참조와 `&apos;` (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],
];
/** 이름 → 글자 (대소문자를 가린다 — `&Auml;` 과 `&auml;` 은 다르다) */
const NAMED_REFERENCES: ReadonlyMap<string, string> = (() => {
const named = new Map<string, string>();
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);
}
/** `<br>` · `</p>` 의 줄바꿈 — 모아 둔 공백은 버린다 (줄바꿈을 건너 맞는 글은 없다) */
lineBreak(): void {
this.pendingSpace = false;
this.pieces.push(LINE_BREAK);
}
toString(): string {
return this.pieces.join('');
}
}
/** 안의 글을 빼는 태그 가운데 열려 있는 것 — 넷을 따로 둔다(짝 · 깊이를 세지 않는다 — 여는 것은 켜고 닫는 것은 끈다) */
class HidingTags {
private readonly open = new Set<HidingTag>();
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 };
}
/** `&#123;` · `&#x1F600;` — 없는 글자(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);
}
@@ -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<img>'.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);
});
});
+99
View File
@@ -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();
}
}
@@ -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<string, Record<string, unknown>> = 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, unknown>, 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<string, unknown> = {
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<string, unknown>) => {
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<ContactFields> = {
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/<uid> 패치를 받지 않는다), 비면 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' } });
});
});
@@ -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<string, unknown>;
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<string, true> | 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>): () => 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<T>(
sent: readonly T[],
current: [string, JsonObject][],
matchers: readonly ((item: T, entry: JsonObject) => boolean)[],
): ([string, JsonObject] | undefined)[] {
const taken = new Set<string>();
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<ContactFields>,
): 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<AddressBookCardUpdate, { kind: 'group' }>,
): 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;
}
@@ -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<string, unknown> | 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 });
});
});
@@ -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<EncodedFilterRunCursor>;
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 };
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,143 @@
import { htmlToText } from '@/modules/mail/html/html-text';
import type { MailFilterTexts } from '@/modules/mail/mail-filter-match';
import type { MailFilterField } from '@/modules/mail/mail.types';
import { decodeHeaderText } from './mime-text';
import type { JmapBodyPart, JmapEmail } from './jmap.types';
/**
* 이미 받은 메일에 필터를 적용할 때 엔진의 메일(JMAP Email)에서 견줄 글을 뽑는다 (순수 함수 — 판정은 mail-filter-match.ts).
* 엔진의 Sieve 가 새로 온 메일에서 보는 글과 같게 뽑는다 (sieve-script.ts 의 FIELD_TESTS — 2026-10-29 실배달 20통과
* 인증 발송 94통(HTML 본문 75 · 머리글 19)으로 확인, filter-run-texts.spec.ts 의 표):
* - 보낸 사람 · 받는 사람: 원래 머리글(`header:From:all` · `header:To:all` · `header:Cc:all` — 같은 이름이 여럿이면 모두)을
* 접힌 줄을 펴고 · 홀로 선 RFC 2047 낱말만 풀고 · 앞뒤 공백을 뺀 것 (mime-text.ts 의 decodeHeaderText). 엔진은 원래 글을 앞에 공백 한 칸 ·
* 접힌 줄(CRLF + 공백) · encoded-word 그대로 준다. 이름에 `:asRaw` 를 붙여 물으면 엔진은 응답의 이름에서 그것을 뗀다 — 그래서 붙이지 않는다
* (원래 글은 머리글 속성의 기본 모양이다 — RFC 8621 §4.1.2)
* - 제목: 엔진이 풀어 준 제목(`subject`)
* - 본문: textBody · htmlBody · attachments 의 text/* 부분(같은 partId 는 한 번) — 파일로 붙인 텍스트 첨부도 본다(엔진이 그렇게 본다).
* HTML(text/html — 본문이든 첨부든)은 엔진의 규칙대로 글자만 (html/html-text.ts). 값은 `fetchAllBodyValues` 로 받는다 (jmap-filter-run.ts)
*/
/** 견줄 곳마다 원래 머리글 이름 — 받는 사람은 To · Cc 둘 다 (Sieve 의 `header :contains ["to", "cc"]`) */
const FROM_HEADERS = ['From'] as const;
const RECIPIENT_HEADERS = ['To', 'Cc'] as const;
/** 엔진에 묻는 원래 머리글 속성 — `:all` 은 같은 이름의 머리글을 모두 배열로 준다 (없으면 빈 배열) */
function rawHeaderProperty(name: string): string {
return `header:${name}:all`;
}
/** 본문 부분마다 읽는 속성 — 같은 부분 가리기 · 글자 부분인지 · 크기(나눠 읽을 어림) */
export const FILTER_RUN_PART_PROPERTIES = ['partId', 'type', 'size'];
/** 본문을 읽을 때의 속성 — 그사이 받은편지함에서 빠졌는지(mailboxIds) · 부분 · 값 */
export const FILTER_RUN_BODY_PROPERTIES = [
'id',
'mailboxIds',
'textBody',
'htmlBody',
'attachments',
'bodyValues',
];
/**
* 한 차례에 메일을 읽을 때의 속성 — 받은편지함에 아직 있는지 · 이미 읽음 · 중요인지(바꿀 것만 바꾼다) · 받은 시각(이어 할 자리) · 규칙이 견주는 머리글.
* 본문을 견주면 부분(값은 빼고)도 — 머리글로 판정이 나지 않는 메일만 값을 따로 읽는다
*/
export function filterRunProperties(
fields: ReadonlySet<MailFilterField>,
): string[] {
return [
'id',
'mailboxIds',
'keywords',
'receivedAt',
...(fields.has('from') ? FROM_HEADERS.map(rawHeaderProperty) : []),
...(fields.has('to') ? RECIPIENT_HEADERS.map(rawHeaderProperty) : []),
...(fields.has('subject') ? ['subject'] : []),
...(fields.has('body') ? ['textBody', 'htmlBody', 'attachments'] : []),
];
}
/**
* 원래 머리글의 값들 — 물은 이름(`header:From:all`)으로 찾되 대소문자 · `:asRaw` 가 달라도 찾는다
* (엔진이 응답의 속성 이름을 바꿔 주더라도 머리글이 없는 것처럼 보이지 않게 — 그러면 "포함하지 않음"이 모두 맞는다)
*/
function rawHeaderValues(email: JmapEmail, name: string): string[] {
const wanted = new Set([
rawHeaderProperty(name).toLowerCase(),
`header:${name}:asRaw:all`.toLowerCase(),
]);
for (const [key, value] of Object.entries(email)) {
if (!wanted.has(key.toLowerCase())) continue;
if (typeof value === 'string') return [value];
return Array.isArray(value)
? value.filter((item): item is string => typeof item === 'string')
: [];
}
return [];
}
function decodedHeaders(email: JmapEmail, names: readonly string[]): string[] {
return names.flatMap((name) =>
rawHeaderValues(email, name).map(decodeHeaderText),
);
}
/** 머리글의 견줄 글 — 보낸 사람 · 받는 사람 · 제목 (본문은 bodyTextsOf) */
export function headerTextsOf(email: JmapEmail): Omit<MailFilterTexts, 'body'> {
const subject = email.subject;
return {
from: decodedHeaders(email, FROM_HEADERS),
to: decodedHeaders(email, RECIPIENT_HEADERS),
subject: typeof subject === 'string' ? [subject] : [],
};
}
function isTextPart(part: JmapBodyPart): boolean {
return (part.type ?? '').toLowerCase().startsWith('text/');
}
/** 글자 부분 — textBody · htmlBody · attachments 차례로, 같은 partId 는 한 번 (한 부분이 textBody 와 htmlBody 에 함께 오기도 한다) */
export function textPartsOf(email: JmapEmail): JmapBodyPart[] {
const seen = new Set<string>();
const parts: JmapBodyPart[] = [];
for (const part of [
...(email.textBody ?? []),
...(email.htmlBody ?? []),
...(email.attachments ?? []),
]) {
const { partId } = part;
if (!partId || seen.has(partId)) continue;
seen.add(partId);
if (isTextPart(part)) parts.push(part);
}
return parts;
}
/** 본문의 견줄 글 — 글자 부분마다(HTML 은 글자만). 값을 받지 못한 부분은 뺀다 */
export function bodyTextsOf(email: JmapEmail): string[] {
return textPartsOf(email).flatMap((part) => {
const value = email.bodyValues?.[part.partId ?? '']?.value;
if (typeof value !== 'string') return [];
return [
(part.type ?? '').toLowerCase() === 'text/html'
? htmlToText(value)
: value,
];
});
}
/** 본문 값을 받을 때 응답 크기의 어림(바이트) — 부분마다 상한까지, 문자셋을 UTF-8 로 바꾸며 늘 수 있어 두 배로 (크기를 모르면 상한) */
export function estimatedBodyBytes(
email: JmapEmail,
maxPartBytes: number,
): number {
return textPartsOf(email).reduce(
(sum, part) =>
sum +
(typeof part.size === 'number' && part.size >= 0
? Math.min(maxPartBytes, part.size * 2)
: maxPartBytes),
0,
);
}
@@ -0,0 +1,826 @@
import { Logger } from '@nestjs/common';
import { BusinessException } from '@/common/errors/business.exception';
import type { AddressBookChanges } from '@/modules/contacts/contacts.provider';
import type { JmapClient } from './jmap.client';
import {
ADDRESS_BOOK_READ_MAX_CARDS,
JmapContactsProvider,
} from './jmap-contacts.provider';
import type { JmapContactCard, JmapInvocation } from './jmap.types';
const ARA = 'ara.kim@arasystem.test';
const CONTACTS = 'urn:ietf:params:jmap:contacts';
interface FakeBook {
id: string;
isDefault?: boolean;
}
/**
* 대역 엔진 — 주소록(AddressBook) · 카드(ContactCard) · 상태(ifInState)만 흉내 낸다
* (JMAP for Contacts — Stalwart 0.16.27 에서 확인한 모양: 지난 ifInState 는 메서드 오류 stateMismatch)
*/
class FakeContactsEngine {
state = 10;
books: FakeBook[] = [{ id: 'book', isDefault: true }];
/** 카드 id → 담긴 주소록 · 카드 칸 */
readonly cards = new Map<
string,
{ book: string; card: Record<string, unknown> }
>();
readonly requests: { calls: JmapInvocation[]; options: unknown }[] = [];
/** 다음 ContactCard/set 의 응답을 대신한다 */
setReply: ((args: Record<string, unknown>) => JmapInvocation) | null = null;
private nextId = 1;
readonly client = {
call: jest.fn(
(
_user: string,
build: (accountId: string) => JmapInvocation[],
options?: unknown,
) => {
const calls = build('acc');
this.requests.push({ calls, options });
const responses: JmapInvocation[] = [];
for (const call of calls) responses.push(this.handle(call, responses));
return Promise.resolve(responses);
},
),
};
add(card: JmapContactCard, book = 'book'): void {
this.cards.set(card.id, { book, card: { ...card } });
}
cardOf(id: string): Record<string, unknown> | undefined {
return this.cards.get(id)?.card;
}
private handle(
[method, args, callId]: JmapInvocation,
previous: JmapInvocation[],
): JmapInvocation {
switch (method) {
case 'AddressBook/get':
return [
method,
{ state: 'books', list: this.books.map((book) => ({ ...book })) },
callId,
];
case 'ContactCard/query': {
const filter = args.filter as { inAddressBook?: string };
const ids = [...this.cards.entries()]
.filter(([, stored]) => stored.book === filter.inAddressBook)
.map(([id]) => id);
const position = Number(args.position ?? 0);
const limit = Number(args.limit ?? ids.length);
return [
method,
{
ids: ids.slice(position, position + limit),
position,
...(args.calculateTotal === true ? { total: ids.length } : {}),
},
callId,
];
}
case 'ContactCard/get': {
const reference = args['#ids'] as { resultOf: string } | undefined;
const ids = reference
? ((previous.find(([, , id]) => id === reference.resultOf)?.[1]
.ids as string[]) ?? [])
: (args.ids as string[]);
return [
method,
{
state: `s${this.state}`,
list: ids.flatMap((id) => {
const card = this.cardOf(id);
return card ? [{ ...card }] : [];
}),
notFound: ids.filter((id) => !this.cardOf(id)),
},
callId,
];
}
case 'ContactCard/set':
return this.set(args, callId);
default:
return ['error', { type: 'unknownMethod' }, callId];
}
}
private set(args: Record<string, unknown>, callId: string): JmapInvocation {
if (this.setReply) {
const reply = this.setReply(args);
this.setReply = null;
return reply;
}
if (args.ifInState !== `s${this.state}`) {
return ['error', { type: 'stateMismatch' }, callId];
}
const oldState = `s${this.state}`;
const created: Record<string, { id: string }> = {};
for (const [key, card] of Object.entries(
(args.create as Record<string, Record<string, unknown>>) ?? {},
)) {
const id = `n${this.nextId++}`;
const books = Object.keys(card.addressBookIds as Record<string, true>);
this.add({ ...card, id }, books[0]);
created[key] = { id };
}
const updated: Record<string, null> = {};
for (const [id, patch] of Object.entries(
(args.update as Record<string, Record<string, unknown>>) ?? {},
)) {
const card = this.cardOf(id);
if (card) {
Object.assign(card, patch);
updated[id] = null;
}
}
for (const id of (args.destroy as string[]) ?? []) this.cards.delete(id);
this.state += 1;
return [
'ContactCard/set',
{ oldState, newState: `s${this.state}`, created, updated },
callId,
];
}
}
const version = (book: string | null, state: string) =>
JSON.stringify([book, state]);
const noChanges: AddressBookChanges = { create: [], update: [], destroy: [] };
async function failureOf(
promise: Promise<unknown>,
): Promise<BusinessException> {
const error = await promise.then(
() => null,
(caught: unknown) => caught,
);
if (!(error instanceof BusinessException)) {
throw new Error(`BusinessException 이 아니다: ${String(error)}`);
}
return error;
}
describe('JmapContactsProvider (엔진 주소록)', () => {
let engine: FakeContactsEngine;
let provider: JmapContactsProvider;
let warnSpy: jest.SpyInstance;
let errorSpy: jest.SpyInstance;
beforeEach(() => {
engine = new FakeContactsEngine();
provider = new JmapContactsProvider(engine.client as unknown as JmapClient);
warnSpy = jest
.spyOn(Logger.prototype, 'warn')
.mockImplementation(() => undefined);
errorSpy = jest
.spyOn(Logger.prototype, 'error')
.mockImplementation(() => undefined);
});
afterEach(() => jest.restoreAllMocks());
describe('판 (getAddressBookVersion)', () => {
it('기본 주소록 id 와 카드 상태 — 한 요청, 카드는 읽지 않는다(ids: []), contacts 기능을 선언한다', async () => {
engine.books = [{ id: 'shared' }, { id: 'mine', isDefault: true }];
await expect(provider.getAddressBookVersion(ARA)).resolves.toBe(
version('mine', 's10'),
);
const [request] = engine.requests;
expect(request?.calls.map(([method]) => method)).toEqual([
'AddressBook/get',
'ContactCard/get',
]);
expect(request?.calls[1]?.[1]).toMatchObject({ ids: [] });
expect(request?.options).toEqual({ capabilities: [CONTACTS] });
});
it('기본 주소록이 없으면 첫 주소록, 주소록이 없으면 null — 카드가 바뀌면 판이 달라진다', async () => {
engine.books = [{ id: 'first' }, { id: 'second' }];
await expect(provider.getAddressBookVersion(ARA)).resolves.toBe(
version('first', 's10'),
);
engine.books = [];
engine.state = 11;
await expect(provider.getAddressBookVersion(ARA)).resolves.toBe(
version(null, 's11'),
);
});
it('엔진이 상태를 알려 주지 않으면 MAIL_001', async () => {
engine.client.call.mockResolvedValueOnce([
['AddressBook/get', { list: [] }, 'b'],
['ContactCard/get', { list: [] }, 's'],
]);
expect((await failureOf(provider.getAddressBookVersion(ARA))).code).toBe(
'MAIL_001',
);
});
});
describe('읽기 (readAddressBook)', () => {
it('기본 주소록의 카드만 500장씩 — 연락처와 그룹으로 나누고, 판은 읽기 전에 물은 것이다', async () => {
for (let index = 0; index < 1_200; index += 1) {
engine.add({
id: `c${index}`,
uid: `urn:uuid:c${index}`,
kind: 'individual',
name: { full: `연락처 ${index}` },
});
}
engine.add({
id: 'g',
uid: 'urn:uuid:g',
kind: 'group',
name: { full: '거래처' },
members: { 'urn:uuid:c1': true },
});
engine.add({ id: 'elsewhere', name: { full: '다른 주소록' } }, 'other');
const snapshot = await provider.readAddressBook(ARA);
expect(snapshot.version).toBe(version('book', 's10'));
expect(snapshot.contacts).toHaveLength(1_200);
expect(snapshot.groups).toEqual([
{
id: 'g',
uid: 'urn:uuid:g',
name: '거래처',
memberUids: ['urn:uuid:c1'],
},
]);
const queries = engine.requests
.flatMap((request) => request.calls)
.filter(([method]) => method === 'ContactCard/query');
expect(queries.map(([, args]) => [args.position, args.limit])).toEqual([
[0, 500],
[500, 500],
[1_000, 500],
]);
expect(queries[0]?.[1]).toMatchObject({
filter: { inAddressBook: 'book' },
calculateTotal: true,
});
// 사진 등 큰 칸은 읽지 않는다
const get = engine.requests[1]?.calls[1]?.[1];
expect(get?.properties).not.toContain('media');
});
it('읽는 사이 차례가 밀려 같은 카드가 두 번 와도 한 번만 넣는다', async () => {
for (let index = 0; index < 600; index += 1) {
engine.add({ id: `c${index}`, name: { full: `연락처 ${index}` } });
}
// 첫 차례(판을 읽은 다음 요청)를 읽은 뒤 맨 앞에 카드가 하나 생긴다 — 둘째 차례의 첫 카드는 이미 읽은 것이다
const original = engine.client.call.getMockImplementation();
let requests = 0;
engine.client.call.mockImplementation((user, build, options) => {
const result = original?.(user, build, options);
requests += 1;
if (requests === 2) {
const rest = [...engine.cards.entries()];
engine.cards.clear();
engine.add({ id: 'new', name: { full: '새 카드' } });
for (const [id, stored] of rest) engine.cards.set(id, stored);
}
return result ?? Promise.resolve([]);
});
const snapshot = await provider.readAddressBook(ARA);
const ids = snapshot.contacts.map((card) => card.id);
expect(ids).toHaveLength(600);
expect(new Set(ids).size).toBe(600);
expect(ids).not.toContain('new');
});
it('판은 읽기 전에 물은 것 — 읽는 사이 다른 곳에서 카드가 생겨 상태가 올라도 옛 판으로 둔다(다음 요청이 판이 달라 다시 읽는다)', async () => {
for (let index = 0; index < 600; index += 1) {
engine.add({ id: `c${index}`, name: { full: `연락처 ${index}` } });
}
const original = engine.client.call.getMockImplementation();
let requests = 0;
engine.client.call.mockImplementation((user, build, options) => {
requests += 1;
// 판을 물은 뒤 · 첫 차례를 읽기 전에 다른 곳에서 카드를 만든다
if (requests === 2) {
engine.add({ id: 'late', name: { full: '늦게 온 카드' } });
engine.state += 1;
}
return original?.(user, build, options) ?? Promise.resolve([]);
});
const snapshot = await provider.readAddressBook(ARA);
expect(snapshot.version).toBe(version('book', 's10'));
await expect(provider.getAddressBookVersion(ARA)).resolves.not.toBe(
snapshot.version,
);
});
it('주소록이 없으면 빈 주소록 (카드를 묻지 않는다)', async () => {
engine.books = [];
await expect(provider.readAddressBook(ARA)).resolves.toEqual({
version: version(null, 's10'),
contacts: [],
groups: [],
});
expect(engine.requests).toHaveLength(1);
});
it(`${ADDRESS_BOOK_READ_MAX_CARDS}장을 넘으면 앞의 그만큼만 읽고 warn 로그(개수만)`, async () => {
for (let index = 0; index < ADDRESS_BOOK_READ_MAX_CARDS + 3; index += 1) {
engine.add({ id: `c${index}`, name: { full: `비밀 이름 ${index}` } });
}
const snapshot = await provider.readAddressBook(ARA);
expect(snapshot.contacts).toHaveLength(ADDRESS_BOOK_READ_MAX_CARDS);
const logged = warnSpy.mock.calls
.map(([message]) => String(message))
.join('\n');
expect(logged).toContain(`카드 ${ADDRESS_BOOK_READ_MAX_CARDS + 3}장`);
expect(logged).not.toContain('비밀 이름');
expect(logged).not.toContain(ARA);
});
});
describe('쓰기 (writeAddressBook)', () => {
const newPerson = {
name: '새 사람',
emails: [],
phones: [],
company: null,
department: null,
title: null,
note: null,
};
it('고칠 연락처 · 지울 카드를 그 판에서 먼저 읽고(한 요청) ContactCard/set 하나에 만들기 · 고치기 · 지우기 — 새 판 · 만든 id · 카드 모양 · 지운 원본을 준다', async () => {
engine.add({
id: 'c1',
uid: 'urn:uuid:c1',
kind: 'individual',
name: { full: '하나' },
anniversaries: { a: { kind: 'birth' } },
} as JmapContactCard);
engine.add({
id: 'c2',
uid: 'urn:uuid:c2',
name: { full: '둘' },
photos: { p: { href: 'https://x.example/p.jpg' } },
} as JmapContactCard);
engine.add({
id: 'g',
kind: 'group',
name: { full: '거래처' },
members: { 'urn:uuid:c1': true },
});
const outcome = await provider.writeAddressBook(
ARA,
version('book', 's10'),
{
create: [
{
kind: 'contact',
key: 'contact',
uid: 'urn:uuid:new',
fields: newPerson,
},
],
update: [
{ kind: 'contact', id: 'c1', fields: { name: '고친 이름' } },
{
kind: 'group',
id: 'g',
memberUids: ['urn:uuid:c1', 'urn:uuid:new'],
},
],
destroy: ['c2'],
},
);
// 먼저 읽기 — 고칠 연락처는 우리 칸만, 지울 카드는 전체(properties: null)
const [read, write] = engine.requests;
expect(read?.calls.map(([method, args]) => [method, args.ids])).toEqual([
['ContactCard/get', ['c1']],
['ContactCard/get', ['c2']],
]);
expect(read?.calls[0]?.[1].properties).toEqual(
expect.arrayContaining(['name', 'emails', 'organizations']),
);
expect(read?.calls[1]?.[1].properties).toBeNull();
const [call] = write?.calls ?? [];
expect(call?.[0]).toBe('ContactCard/set');
expect(call?.[1]).toMatchObject({
ifInState: 's10',
create: {
contact: {
'@type': 'Card',
uid: 'urn:uuid:new',
kind: 'individual',
addressBookIds: { book: true },
name: { full: '새 사람' },
},
},
update: {
c1: { name: { full: '고친 이름' } },
g: { members: { 'urn:uuid:c1': true, 'urn:uuid:new': true } },
},
destroy: ['c2'],
});
// 고친 칸만 보낸다 — 다른 칸(생일)은 엔진에 그대로
expect(engine.cardOf('c1')).toMatchObject({
name: { full: '고친 이름' },
anniversaries: { a: { kind: 'birth' } },
});
expect(write?.options).toEqual({ capabilities: [CONTACTS] });
if (outcome.kind !== 'written') throw new Error('written 이 아니다');
expect(outcome.version).toBe(version('book', 's11'));
expect(outcome.createdIds).toEqual(new Map([['contact', 'n1']]));
// 만들거나 고친 연락처의 모양 (그룹 고치기는 넣지 않는다)
expect([...outcome.cards.keys()].sort()).toEqual(['c1', 'n1']);
expect(outcome.cards.get('c1')).toMatchObject({
kind: 'contact',
card: { id: 'c1', name: '고친 이름', uid: 'urn:uuid:c1' },
});
expect(outcome.cards.get('n1')).toMatchObject({
kind: 'contact',
card: { id: 'n1', uid: 'urn:uuid:new', name: '새 사람' },
});
// 지운 카드의 원본 — 카드 전체(사진 칸 포함)
const original = outcome.removed.get('c2');
expect(JSON.parse(original?.source ?? 'null')).toEqual({
id: 'c2',
uid: 'urn:uuid:c2',
name: { full: '둘' },
photos: { p: { href: 'https://x.example/p.jpg' } },
});
expect(original?.bytes).toBe(
Buffer.byteLength(original?.source ?? '', 'utf8'),
);
});
it('연락처 고치기는 지금 카드와 합친다 — 같은 주소의 label · 다른 contexts 를 남긴다 (contact-card.ts)', async () => {
engine.add({
id: 'c1',
uid: 'urn:uuid:c1',
emails: {
k: {
address: 'a@x.example',
contexts: { work: true, billing: true },
label: '본사',
pref: 1,
},
},
});
await provider.writeAddressBook(ARA, version('book', 's10'), {
...noChanges,
update: [
{
kind: 'contact',
id: 'c1',
fields: {
emails: [
{ address: 'a@x.example', label: 'personal' },
{ address: 'b@x.example', label: 'work' },
],
},
},
],
});
expect(engine.cardOf('c1')?.emails).toEqual({
k: {
address: 'a@x.example',
contexts: { billing: true, private: true },
label: '본사',
pref: 1,
},
e1: { address: 'b@x.example', contexts: { work: true } },
});
});
it('먼저 읽은 상태가 판과 다르거나 카드가 없으면 conflict — set 을 보내지 않는다', async () => {
engine.add({ id: 'c1', name: { full: '하나' } });
await expect(
provider.writeAddressBook(ARA, version('book', 's9'), {
...noChanges,
update: [{ kind: 'contact', id: 'c1', fields: { name: '둘' } }],
}),
).resolves.toEqual({ kind: 'conflict' });
await expect(
provider.writeAddressBook(ARA, version('book', 's10'), {
...noChanges,
destroy: ['gone'],
}),
).resolves.toEqual({ kind: 'conflict' });
expect(
engine.requests
.flatMap((request) => request.calls)
.filter(([method]) => method === 'ContactCard/set'),
).toHaveLength(0);
expect(engine.cardOf('c1')).toMatchObject({ name: { full: '하나' } });
});
it('만들기 · 그룹 고치기만이면 먼저 읽지 않는다 · 빈 칸(만들기 · 고치기 · 지우기)은 보내지 않는다', async () => {
engine.add({ id: 'g', kind: 'group', name: { full: '거래처' } });
await provider.writeAddressBook(ARA, version('book', 's10'), {
...noChanges,
update: [{ kind: 'group', id: 'g', name: '협력사' }],
});
expect(engine.requests).toHaveLength(1);
expect(Object.keys(engine.requests[0]?.calls[0]?.[1] ?? {})).toEqual([
'accountId',
'ifInState',
'update',
]);
});
it('그사이 바뀌었으면(stateMismatch) conflict — 아무것도 하지 않았다', async () => {
engine.add({ id: 'g', kind: 'group', name: { full: '거래처' } });
await expect(
provider.writeAddressBook(ARA, version('book', 's9'), {
...noChanges,
update: [{ kind: 'group', id: 'g', name: '협력사' }],
}),
).resolves.toEqual({ kind: 'conflict' });
expect(engine.cardOf('g')).toMatchObject({ name: { full: '거래처' } });
});
it('엔진이 동시 고치기를 거절하면(serverUnavailable — 메서드 오류 · 카드마다) busy — 지운 카드의 원본을 함께, warn 로그에 엔진 설명은 없다', async () => {
engine.add({ id: 'c1', uid: 'urn:uuid:c1', name: { full: '하나' } });
engine.setReply = () => [
'error',
{
type: 'serverUnavailable',
description: 'store.assert-value-failed 하나',
},
'w',
];
const busy = await provider.writeAddressBook(
ARA,
version('book', 's10'),
{ ...noChanges, destroy: ['c1'] },
);
expect(busy.kind).toBe('busy');
expect(busy.kind === 'busy' && [...busy.removed.keys()]).toEqual(['c1']);
engine.setReply = () => [
'ContactCard/set',
{
newState: 's11',
created: { contact: { id: 'n9' } },
notUpdated: { g: { type: 'serverUnavailable' } },
},
'w',
];
await expect(
provider.writeAddressBook(ARA, version('book', 's10'), {
create: [
{
kind: 'contact',
key: 'contact',
uid: 'urn:uuid:new',
fields: newPerson,
},
],
update: [{ kind: 'group', id: 'g', memberUids: ['urn:uuid:new'] }],
destroy: [],
}),
).resolves.toMatchObject({ kind: 'busy' });
const logged = warnSpy.mock.calls
.map(([message]) => String(message))
.join('\n');
expect(logged).toContain('serverUnavailable');
expect(logged).not.toMatch(/assert-value|하나|ara\.kim@/);
});
it('엔진이 새 상태를 알려 주지 않으면 version 은 null', async () => {
engine.add({ id: 'c1', name: { full: '하나' } });
engine.setReply = (args) => [
'ContactCard/set',
{ created: {}, destroyed: args.destroy },
'w',
];
await expect(
provider.writeAddressBook(ARA, version('book', 's10'), {
...noChanges,
destroy: ['c1'],
}),
).resolves.toMatchObject({
kind: 'written',
version: null,
createdIds: new Map(),
});
});
it('엔진이 일부를 받지 않으면 그 종류의 코드 — warn 로그는 개수 · 종류만(카드 내용 · 엔진 설명 없음)', async () => {
engine.setReply = () => [
'ContactCard/set',
{
newState: 's11',
notCreated: {
contact: {
type: 'invalidProperties',
description: 'Invalid email 박지훈 <jihoon@x>',
properties: ['emails/e1/address'],
},
},
},
'w',
];
const error = await failureOf(
provider.writeAddressBook(ARA, version('book', 's10'), {
...noChanges,
create: [
{
kind: 'contact',
key: 'contact',
uid: 'urn:uuid:new',
fields: {
...newPerson,
name: '박지훈',
emails: [{ address: 'jihoon@x', label: 'work' }],
},
},
],
}),
);
expect(error.code).toBe('VAL_001');
expect(error.context).toMatchObject({
reason: 'address-book-change-refused',
jmapType: 'invalidProperties',
});
const logged = warnSpy.mock.calls
.map(([message]) => String(message))
.join('\n');
expect(logged).toContain('만들기 1/1');
expect(logged).not.toMatch(/박지훈|jihoon|Invalid email|ara\.kim@/);
});
it('되돌리기 — 원본에서 id 만 빼고 uid · 기본 주소록을 바꿔 만든다. 엔진이 받지 않으면 MAIL_001(입력 오류가 아니다)', async () => {
const original = {
source: JSON.stringify({
id: 'old',
'@type': 'Card',
version: '1.0',
uid: 'urn:uuid:old',
kind: 'individual',
addressBookIds: { gone: true },
name: { full: '돌아온 사람' },
nicknames: { k: { name: '별명' } },
}),
bytes: 1,
};
const restored = {
kind: 'restored' as const,
key: 'restored',
uid: 'urn:uuid:old',
original,
memberUids: null,
card: {
kind: 'contact' as const,
card: {
id: 'old',
uid: 'urn:uuid:old',
...newPerson,
name: '돌아온 사람',
},
},
};
const outcome = await provider.writeAddressBook(
ARA,
version('book', 's10'),
{ ...noChanges, create: [restored] },
);
expect(engine.cardOf('n1')).toEqual({
id: 'n1',
'@type': 'Card',
version: '1.0',
uid: 'urn:uuid:old',
kind: 'individual',
addressBookIds: { book: true },
name: { full: '돌아온 사람' },
nicknames: { k: { name: '별명' } },
});
expect(outcome).toMatchObject({ kind: 'written' });
expect(
outcome.kind === 'written' && outcome.cards.get('n1')?.card.name,
).toBe('돌아온 사람');
engine.setReply = () => [
'ContactCard/set',
{
newState: 's12',
notCreated: {
restored: { type: 'invalidProperties', properties: ['media'] },
},
},
'w',
];
const error = await failureOf(
provider.writeAddressBook(ARA, version('book', 's11'), {
...noChanges,
create: [restored],
}),
);
expect(error.code).toBe('MAIL_001');
expect(error.context).toMatchObject({
reason: 'restore-refused',
jmapType: 'invalidProperties',
properties: ['media'],
});
});
it('메서드 오류 · 만든 id 가 없는 응답은 코드로 — serverFail 은 MAIL_001, 권한 없음은 AUTH_003', async () => {
engine.add({ id: 'c1', name: { full: '하나' } });
engine.setReply = () => ['error', { type: 'serverFail' }, 'w'];
expect(
(
await failureOf(
provider.writeAddressBook(ARA, version('book', 's10'), {
...noChanges,
destroy: ['c1'],
}),
)
).code,
).toBe('MAIL_001');
engine.setReply = () => ['error', { type: 'forbidden' }, 'w'];
expect(
(
await failureOf(
provider.writeAddressBook(ARA, version('book', 's10'), {
...noChanges,
destroy: ['c1'],
}),
)
).code,
).toBe('AUTH_003');
engine.setReply = () => [
'ContactCard/set',
{ newState: 's11', created: {} },
'w',
];
const missing = await failureOf(
provider.writeAddressBook(ARA, version('book', 's10'), {
...noChanges,
create: [
{
kind: 'group',
key: 'group',
uid: 'urn:uuid:g',
name: '거래처',
memberUids: [],
},
],
}),
);
expect(missing.context?.reason).toBe('created-card-id-missing');
});
it('주소록이 없는데 만들 카드가 있으면 MAIL_001 (엔진에 묻지 않고 error 로그) · 우리가 만들지 않은 판은 MAIL_001', async () => {
const error = await failureOf(
provider.writeAddressBook(ARA, version(null, 's10'), {
...noChanges,
create: [
{
kind: 'group',
key: 'group',
uid: 'urn:uuid:g',
name: '거래처',
memberUids: [],
},
],
}),
);
expect(error.context?.reason).toBe('address-book-missing');
expect(errorSpy).toHaveBeenCalledTimes(1);
for (const bad of ['not-json', '["book"]', '[1, "s"]', '["book", ""]']) {
expect(
(await failureOf(provider.writeAddressBook(ARA, bad, noChanges)))
.context?.reason,
).toBe('address-book-version-malformed');
}
expect(engine.requests).toHaveLength(0);
});
});
});
@@ -0,0 +1,449 @@
import { Injectable, Logger } from '@nestjs/common';
import { BusinessException } from '@/common/errors/business.exception';
import type {
AddressBookCard,
AddressBookChanges,
AddressBookContactCard,
AddressBookGroupCard,
AddressBookSnapshot,
AddressBookWriteOutcome,
ContactsProvider,
RemovedCardOriginal,
} from '@/modules/contacts/contacts.provider';
import { traceTag } from '@/shared/logger/request-context';
import { maskEmail } from '@/shared/utils/mask';
import {
cardOriginal,
contactPatch,
groupPatch,
newCardJson,
patchedCard,
readContactCard,
} from './contact-card';
import { JmapClient } from './jmap.client';
import { jmapFailure, mapJmapErrorType } from './jmap.errors';
import {
expectResult,
findResult,
firstSetError,
isState,
} from './jmap-results';
import type {
JmapAddressBook,
JmapContactCard,
JmapGetResult,
JmapInvocation,
JmapQueryResult,
JmapSetResult,
} from './jmap.types';
/** JMAP for Contacts — 이 호출에만 선언한다 (계정 id 는 메일 계정과 같다 — 2026-10-29 확인) */
const CONTACTS_CAPABILITY = 'urn:ietf:params:jmap:contacts';
/** 한 번에 읽는 카드 수 — 엔진의 maxObjectsInGet(500) 이하 */
const READ_PAGE_SIZE = 500;
/**
* 주소록을 읽는 상한 — 우리 한도(연락처 3,000 · 그룹 100)보다 넉넉히. 다른 프로그램이 더 넣었으면 앞의 이만큼만 읽는다(warn 로그).
* 한 사람의 주소록이 서버 메모리 · 엔진 호출을 끝없이 쓰지 않게
*/
export const ADDRESS_BOOK_READ_MAX_CARDS = 5_000;
/** 카드에서 읽는 칸 — 사진 등 큰 칸은 읽지 않는다 */
const CARD_PROPERTIES = [
'uid',
'kind',
'name',
'emails',
'phones',
'organizations',
'titles',
'notes',
'members',
];
/** JMAP 메서드 오류 — ifInState 가 지금 상태와 다르다 (RFC 8620 §5.3 — ContactCard/set 도 같다, 2026-10-29 확인) */
const STATE_MISMATCH = 'stateMismatch';
/**
* 엔진이 같은 카드를 동시에 고치는 요청을 거절한 것(store.assert-value-failed — 2026-10-29 확인). 메서드 오류로도 · 카드마다의 SetError 로도
* 받는다고 보고, 어느 쪽이든 일부가 들어갔을 수 있다 — busy 로 알려 서비스가 다시 읽고 이어 하게 한다
*/
const SERVER_UNAVAILABLE = 'serverUnavailable';
/** 판 — 기본 주소록 id 와 카드 상태 (서비스에는 불투명 문자열) */
interface BookVersion {
readonly addressBookId: string | null;
readonly state: string;
}
function encodeVersion(version: BookVersion): string {
return JSON.stringify([version.addressBookId, version.state]);
}
/** 우리가 만든 판만 받는다 — 아니면 서버 안의 잘못이다 */
function decodeVersion(text: string): BookVersion {
let parsed: unknown;
try {
parsed = JSON.parse(text);
} catch {
parsed = null;
}
if (
Array.isArray(parsed) &&
parsed.length === 2 &&
(parsed[0] === null || typeof parsed[0] === 'string') &&
isState(parsed[1])
) {
return { addressBookId: parsed[0] as string | null, state: parsed[1] };
}
throw new BusinessException('MAIL_001', {
context: { reason: 'address-book-version-malformed' },
});
}
/** 기본 주소록 — isDefault, 없으면 엔진이 준 첫 주소록 (계약 3절 "엔진에 주소록이 여럿이면 기본 주소록만") */
function pickAddressBook(books: readonly JmapAddressBook[]): string | null {
const preferred = books.find((book) => book.isDefault === true) ?? books[0];
return typeof preferred?.id === 'string' && preferred.id !== ''
? preferred.id
: null;
}
/**
* 메일 엔진(Stalwart)의 주소록 — JMAP for Contacts (계약 3절 "주소록"). 직원마다 기본 주소록 하나를 게이트웨이 대리 접근으로 읽고 쓴다.
*
* - 판: AddressBook/get(기본 주소록 id) + ContactCard/get(ids: [] — 상태만, 가볍다). 주소록 · 카드 무엇이든 바뀌면 판이 달라진다
* - 읽기: 판을 먼저 묻고 ContactCard/query(그 주소록) + get 을 500장씩 — 판은 읽기 전에 물은 것이다(읽는 사이 바뀌면 다음 요청이 판이 달라 다시 읽는다)
* - 쓰기: 고칠 연락처 · 지울 카드를 그 판에서 먼저 읽고(칸 안의 남의 정보 · 되돌리기 원본 — contact-card.ts), ContactCard/set 하나에
* 만들기 · 고치기 · 지우기를 담아 읽은 상태 그대로일 때만(ifInState) 한다. 어긋나면 stateMismatch → conflict, 동시 고치기 거절은 busy.
* 엔진은 만든 뒤 uid 를 바꾸지 못하게 하고(invalidProperties), 구성원이 없는 그룹에 `members/<uid>` 패치를 받지 않는다 — 그래서 uid 는 만들 때
* 넣고, 구성원은 통째로 바꾼다 (2026-10-29 확인)
* - 이름 · 주소 · 메모 등 카드 내용은 로그에 남기지 않는다 (개수 · 엔진 오류 종류만)
*/
@Injectable()
export class JmapContactsProvider implements ContactsProvider {
private readonly logger = new Logger(JmapContactsProvider.name);
constructor(private readonly jmap: JmapClient) {}
async getAddressBookVersion(user: string): Promise<string> {
return encodeVersion(await this.readVersion(user));
}
async readAddressBook(user: string): Promise<AddressBookSnapshot> {
const head = await this.readVersion(user);
const { addressBookId } = head;
if (addressBookId === null) {
return { version: encodeVersion(head), contacts: [], groups: [] };
}
const contacts: AddressBookContactCard[] = [];
const groups: AddressBookGroupCard[] = [];
// 읽는 사이 카드가 생기거나 지워지면 차례가 밀려 같은 카드가 두 번 올 수 있다 — 한 번만 (판이 달라져 다음 요청이 다시 읽는다)
const seen = new Set<string>();
let position = 0;
let total: number | null = null;
while (position < ADDRESS_BOOK_READ_MAX_CARDS) {
const limit = Math.min(
READ_PAGE_SIZE,
ADDRESS_BOOK_READ_MAX_CARDS - position,
);
const responses = await this.call(user, (accountId) => [
[
'ContactCard/query',
{
accountId,
filter: { inAddressBook: addressBookId },
position,
limit,
calculateTotal: true,
},
'q',
],
[
'ContactCard/get',
{
accountId,
'#ids': {
resultOf: 'q',
name: 'ContactCard/query',
path: '/ids',
},
properties: CARD_PROPERTIES,
},
'g',
],
]);
const query = expectResult<JmapQueryResult>(
responses,
'q',
'ContactCard/query',
);
const page = expectResult<JmapGetResult<JmapContactCard>>(
responses,
'g',
'ContactCard/get',
);
for (const card of page.list ?? []) {
const read = readContactCard(card);
if (read === null || seen.has(read.card.id)) continue;
seen.add(read.card.id);
if (read.kind === 'contact') contacts.push(read.card);
else groups.push(read.card);
}
const ids = query.ids ?? [];
position += ids.length;
total = typeof query.total === 'number' ? query.total : null;
if (ids.length === 0 || (total !== null && position >= total)) break;
}
if (total !== null && total > position) {
this.logger.warn(
`[MAIL_001]${traceTag()} 주소록이 커서 앞의 ${position}장만 읽었습니다: user=${maskEmail(user)}, 카드 ${total}장`,
);
}
// 판은 읽기 전에 물은 것 — 읽는 사이 다른 곳에서 만든 카드를 빠뜨린 채 "최신"으로 기억하지 않게 (그 카드가 판을 바꿨으니 다음 요청이 다시 읽는다)
return { version: encodeVersion(head), contacts, groups };
}
async writeAddressBook(
user: string,
version: string,
changes: AddressBookChanges,
): Promise<AddressBookWriteOutcome> {
const { addressBookId, state } = decodeVersion(version);
if (changes.create.length > 0 && addressBookId === null) {
this.logger.error(
`[MAIL_001]${traceTag()} 메일 엔진에 이 직원의 주소록이 없어 연락처를 만들 수 없습니다 — 엔진의 주소록 설정을 확인하세요: user=${maskEmail(user)}`,
);
throw new BusinessException('MAIL_001', {
context: { reason: 'address-book-missing', user: maskEmail(user) },
});
}
// 고칠 연락처 · 지울 카드를 그 판 그대로 먼저 읽는다 — 칸 안의 남의 정보를 지키고(고치기) 되돌리기 원본을 남긴다(지우기)
const current = await this.readCurrentCards(
user,
state,
changes.update.flatMap((change) =>
change.kind === 'contact' ? [change.id] : [],
),
changes.destroy,
);
if (current === null) return { kind: 'conflict' };
const removed = current.originals;
const create = new Map(
changes.create.map((card) => [
card.key,
newCardJson(addressBookId ?? '', card),
]),
);
const update: Record<string, Record<string, unknown>> = {};
const cards = new Map<string, AddressBookCard>();
for (const change of changes.update) {
if (change.kind === 'group') {
update[change.id] = groupPatch(change);
continue;
}
const card = current.cards.get(change.id) ?? { id: change.id };
const patch = contactPatch(card, change.fields);
update[change.id] = patch;
const view = readContactCard(patchedCard(card, patch));
if (view) cards.set(change.id, view);
}
const responses = await this.call(user, (accountId) => [
[
'ContactCard/set',
{
accountId,
ifInState: state,
...(create.size > 0 ? { create: Object.fromEntries(create) } : {}),
...(changes.update.length > 0 ? { update } : {}),
...(changes.destroy.length > 0 ? { destroy: changes.destroy } : {}),
},
'w',
],
]);
const result = findResult(responses, 'w', 'ContactCard/set');
if (!result.ok) {
if (result.error.type === STATE_MISMATCH) return { kind: 'conflict' };
if (result.error.type === SERVER_UNAVAILABLE) {
this.warnBusy(user, 'method');
return { kind: 'busy', removed };
}
throw jmapFailure(result.error, { method: 'ContactCard/set' });
}
const set = result.args as JmapSetResult;
const refusals = [
...Object.values(set.notCreated ?? {}),
...Object.values(set.notUpdated ?? {}),
...Object.values(set.notDestroyed ?? {}),
];
if (refusals.some((refusal) => refusal?.type === SERVER_UNAVAILABLE)) {
this.warnBusy(user, 'set-error');
return { kind: 'busy', removed };
}
const refusal =
firstSetError(set.notCreated) ??
firstSetError(set.notUpdated) ??
firstSetError(set.notDestroyed);
if (refusal) {
// 받은 것은 이미 바뀌었다 — 서비스가 기억한 주소록을 버리고 다시 읽는다
this.logger.warn(
`[${mapJmapErrorType(refusal.type)}]${traceTag()} 엔진이 주소록 바꾸기의 일부를 받지 않았습니다: user=${maskEmail(user)}, jmapType=${refusal.type ?? 'unknown'}, ` +
`거절 — 만들기 ${Object.keys(set.notCreated ?? {}).length}/${changes.create.length} · 고치기 ${Object.keys(set.notUpdated ?? {}).length}/${changes.update.length} · 지우기 ${Object.keys(set.notDestroyed ?? {}).length}/${changes.destroy.length}`,
);
// 되돌리기를 엔진이 받지 않은 것은 사용자가 고칠 수 있는 입력 오류가 아니다 — 엔진 장애로 답한다
const restoring = changes.create.some((card) => card.kind === 'restored');
if (restoring && firstSetError(set.notCreated)) {
throw new BusinessException('MAIL_001', {
context: {
reason: 'restore-refused',
jmapType: refusal.type ?? 'unknown',
...(refusal.properties?.length
? { properties: refusal.properties }
: {}),
},
});
}
throw jmapFailure(refusal, {
method: 'ContactCard/set',
reason: 'address-book-change-refused',
});
}
const createdIds = new Map<string, string>();
for (const card of changes.create) {
const id = set.created?.[card.key]?.id;
if (typeof id !== 'string' || id === '') {
throw new BusinessException('MAIL_001', {
context: { reason: 'created-card-id-missing' },
});
}
createdIds.set(card.key, id);
// 만든 카드 — 엔진에 보낸 카드를 읽은 것 (엔진은 보낸 칸을 그대로 둔다 — 2026-10-29 확인)
const view = readContactCard({ ...create.get(card.key), id });
if (view) cards.set(id, view);
}
return {
kind: 'written',
version: isState(set.newState)
? encodeVersion({ addressBookId, state: set.newState })
: null,
createdIds,
cards,
removed,
};
}
/**
* 고칠 연락처(우리가 다루는 칸)와 지울 카드(카드 전체 — 되돌리기 원본)를 그 판(state)에서 읽는다 — 엔진 요청 하나.
* 판이 달라졌거나 카드가 없으면 null (그사이 다른 곳에서 바뀌었다 — 서비스가 다시 읽고 다시 정한다)
*/
private async readCurrentCards(
user: string,
state: string,
updateIds: readonly string[],
destroyIds: readonly string[],
): Promise<{
cards: ReadonlyMap<string, Record<string, unknown>>;
originals: ReadonlyMap<string, RemovedCardOriginal>;
} | null> {
if (updateIds.length === 0 && destroyIds.length === 0) {
return { cards: new Map(), originals: new Map() };
}
const calls = (accountId: string): JmapInvocation[] => [
...(updateIds.length > 0
? [
[
'ContactCard/get',
{ accountId, ids: [...updateIds], properties: CARD_PROPERTIES },
'u',
] as JmapInvocation,
]
: []),
...(destroyIds.length > 0
? [
[
'ContactCard/get',
// 되돌리기 원본 — 다른 프로그램의 칸까지 모두
{ accountId, ids: [...destroyIds], properties: null },
'd',
] as JmapInvocation,
]
: []),
];
const responses = await this.call(user, calls);
const read = (callId: string, ids: readonly string[]) => {
if (ids.length === 0) return new Map<string, Record<string, unknown>>();
const got = expectResult<JmapGetResult<JmapContactCard>>(
responses,
callId,
'ContactCard/get',
);
if (got.state !== state) return null;
const byId = new Map<string, Record<string, unknown>>(
(got.list ?? []).map((card) => [card.id, { ...card }]),
);
return ids.every((id) => byId.has(id)) ? byId : null;
};
const updating = read('u', updateIds);
const destroying = read('d', destroyIds);
if (updating === null || destroying === null) return null;
return {
cards: updating,
originals: new Map(
[...destroying].map(([id, card]) => [id, cardOriginal(card)]),
),
};
}
/** 엔진이 동시 고치기를 거절했다 — 서비스가 다시 읽고 다시 한다 (엔진 오류의 설명 문장은 남기지 않는다) */
private warnBusy(user: string, where: 'method' | 'set-error'): void {
this.logger.warn(
`[MAIL_001]${traceTag()} 엔진이 주소록 바꾸기를 잠시 받지 않았습니다(serverUnavailable — 같은 카드를 동시에 고침) — 다시 읽고 다시 합니다: user=${maskEmail(user)}, where=${where}`,
);
}
/** 기본 주소록 id 와 카드 상태 — 한 요청 */
private async readVersion(user: string): Promise<BookVersion> {
const responses = await this.call(user, (accountId) => [
[
'AddressBook/get',
{ accountId, ids: null, properties: ['isDefault'] },
'b',
],
['ContactCard/get', { accountId, ids: [] }, 's'],
]);
const books = expectResult<JmapGetResult<JmapAddressBook>>(
responses,
'b',
'AddressBook/get',
);
const cards = expectResult<JmapGetResult<JmapContactCard>>(
responses,
's',
'ContactCard/get',
);
if (!isState(cards.state)) {
throw new BusinessException('MAIL_001', {
context: { reason: 'contact-state-missing' },
});
}
return {
addressBookId: pickAddressBook(books.list ?? []),
state: cards.state,
};
}
private call(
user: string,
build: (accountId: string) => JmapInvocation[],
): Promise<JmapInvocation[]> {
return this.jmap.call(user, build, { capabilities: [CONTACTS_CAPABILITY] });
}
}
@@ -0,0 +1,201 @@
import { Logger } from '@nestjs/common';
import { BusinessException } from '@/common/errors/business.exception';
import type { JmapClient } from './jmap.client';
import {
DIRECTORY_READ_MAX_ACCOUNTS,
JmapDirectoryProvider,
toDirectoryAccount,
} from './jmap-directory.provider';
import type { JmapInvocation, JmapPrincipal } from './jmap.types';
/** 대역 엔진 — 계정 목록(Principal/query · get)만 흉내 낸다 (2026-10-29 개발 엔진이 준 칸: id · type · description · email) */
class FakePrincipalEngine {
principals: JmapPrincipal[] = [];
readonly requests: JmapInvocation[][] = [];
/** 이 메서드는 오류로 답한다 */
refuse: { method: string; type: string } | null = null;
readonly client = {
callDirectory: jest.fn((build: (accountId: string) => JmapInvocation[]) => {
const calls = build('dir');
this.requests.push(calls);
const responses: JmapInvocation[] = [];
for (const [method, args, callId] of calls) {
if (this.refuse?.method === method) {
responses.push(['error', { type: this.refuse.type }, callId]);
continue;
}
if (method === 'Principal/query') {
const position = Number(args.position ?? 0);
const limit = Number(args.limit ?? this.principals.length);
responses.push([
method,
{
ids: this.principals
.slice(position, position + limit)
.map((principal) => principal.id),
position,
...(args.calculateTotal === true
? { total: this.principals.length }
: {}),
},
callId,
]);
} else if (method === 'Principal/get') {
const query = responses.find(([name]) => name === 'Principal/query');
const ids = (query?.[1].ids as string[] | undefined) ?? [];
responses.push([
method,
{
list: ids.map((id) =>
this.principals.find((principal) => principal.id === id),
),
},
callId,
]);
}
}
return Promise.resolve(responses);
}),
};
}
describe('JmapDirectoryProvider (엔진의 계정 목록)', () => {
let engine: FakePrincipalEngine;
let provider: JmapDirectoryProvider;
let warnSpy: jest.SpyInstance;
beforeEach(() => {
engine = new FakePrincipalEngine();
provider = new JmapDirectoryProvider(
engine.client as unknown as JmapClient,
);
warnSpy = jest
.spyOn(Logger.prototype, 'warn')
.mockImplementation(() => undefined);
});
afterEach(() => jest.restoreAllMocks());
it('사내 목록 계정으로 Principal/query(500개씩 · total) + get 을 한 요청에 — 개인은 person, 그룹은 shared', async () => {
engine.principals = [
{
id: 'a',
type: 'individual',
name: 'admin@arasystem.test',
description: 'System administrator',
email: 'admin@arasystem.test',
},
{
id: 's',
type: 'group',
description: '영업팀',
email: 'sales@arasystem.test',
},
{
id: 'r',
type: 'resource',
description: '회의실',
email: 'room@arasystem.test',
},
];
await expect(provider.listAccounts()).resolves.toEqual([
{
id: 'a',
kind: 'person',
displayName: 'System administrator',
email: 'admin@arasystem.test',
},
{
id: 's',
kind: 'shared',
displayName: '영업팀',
email: 'sales@arasystem.test',
},
]);
const [calls] = engine.requests;
expect(calls?.map(([method]) => method)).toEqual([
'Principal/query',
'Principal/get',
]);
expect(calls?.[0]?.[1]).toEqual({
accountId: 'dir',
position: 0,
limit: 500,
calculateTotal: true,
});
expect(calls?.[1]?.[1]).toMatchObject({
'#ids': { resultOf: 'q', name: 'Principal/query', path: '/ids' },
properties: ['type', 'description', 'email'],
});
});
it('500개가 넘으면 이어 읽는다', async () => {
engine.principals = Array.from({ length: 1_100 }, (_, index) => ({
id: `p${index}`,
type: 'individual',
description: `직원 ${index}`,
email: `p${index}@arasystem.test`,
}));
await expect(provider.listAccounts()).resolves.toHaveLength(1_100);
expect(engine.requests.map((calls) => calls[0]?.[1].position)).toEqual([
0, 500, 1_000,
]);
});
it(`${DIRECTORY_READ_MAX_ACCOUNTS}개를 넘으면 앞의 그만큼만 — warn 로그(개수만)`, async () => {
engine.principals = Array.from(
{ length: DIRECTORY_READ_MAX_ACCOUNTS + 1 },
(_, index) => ({
id: `p${index}`,
type: 'individual',
description: `비밀 이름 ${index}`,
email: `p${index}@arasystem.test`,
}),
);
await expect(provider.listAccounts()).resolves.toHaveLength(
DIRECTORY_READ_MAX_ACCOUNTS,
);
const logged = warnSpy.mock.calls
.map(([message]) => String(message))
.join('\n');
expect(logged).toContain(`계정 ${DIRECTORY_READ_MAX_ACCOUNTS + 1}개`);
expect(logged).not.toContain('비밀 이름');
});
it('읽기 권한이 없으면(forbidden) 사내 목록을 읽지 못한 것 — MAIL_001 (AUTH_003 이 아니다)', async () => {
engine.refuse = { method: 'Principal/query', type: 'forbidden' };
const error = await provider
.listAccounts()
.catch((caught: unknown) => caught);
expect(error).toBeInstanceOf(BusinessException);
expect(error).toMatchObject({
code: 'MAIL_001',
context: {
reason: 'directory-read-refused',
method: 'Principal/query',
jmapType: 'forbidden',
},
});
});
it('계정 하나 — 모양이 틀린 값은 null 로, 사내 목록 종류가 아니면 넣지 않는다', () => {
expect(
toDirectoryAccount({
id: 'x',
type: 'individual',
description: 7,
email: null,
}),
).toEqual({ id: 'x', kind: 'person', displayName: null, email: null });
expect(toDirectoryAccount({ id: 'x', type: 'location' })).toBeNull();
expect(toDirectoryAccount({ id: '', type: 'individual' })).toBeNull();
expect(toDirectoryAccount({ id: 'x' })).toBeNull();
});
});
@@ -0,0 +1,140 @@
import { Injectable, Logger } from '@nestjs/common';
import { BusinessException } from '@/common/errors/business.exception';
import type {
DirectoryAccount,
DirectoryProvider,
} from '@/modules/contacts/contacts.provider';
import type { DirectoryEntryKind } from '@/modules/contacts/contacts.types';
import { traceTag } from '@/shared/logger/request-context';
import { JmapClient } from './jmap.client';
import { findResult } from './jmap-results';
import type {
JmapGetResult,
JmapInvocation,
JmapPrincipal,
JmapQueryResult,
} from './jmap.types';
/** 한 번에 읽는 계정 수 — 엔진의 maxObjectsInGet(500) 이하 */
const READ_PAGE_SIZE = 500;
/** 계정 목록을 읽는 상한 — 회사 하나의 계정 수로 넉넉하다. 넘으면 앞의 이만큼만 (warn 로그) */
export const DIRECTORY_READ_MAX_ACCOUNTS = 10_000;
/** 읽는 칸 — 엔진이 주는 칸은 id · type · name(로그인 이름) · description(표시 이름) · email 뿐이다 (2026-10-29 확인) */
const PRINCIPAL_PROPERTIES = ['type', 'description', 'email'];
/** 엔진의 계정 종류 → 사내 목록의 종류 — 그 밖(resource · location 등)은 넣지 않는다 */
const KINDS: ReadonlyMap<string, DirectoryEntryKind> = new Map([
['individual', 'person'],
['group', 'shared'],
]);
/** 엔진의 계정 하나 — 모양이 맞지 않거나 사내 목록 종류가 아니면 null */
export function toDirectoryAccount(
principal: JmapPrincipal | null | undefined,
): DirectoryAccount | null {
if (
typeof principal !== 'object' ||
principal === null ||
typeof principal.id !== 'string' ||
principal.id === ''
) {
return null;
}
const kind =
typeof principal.type === 'string' ? KINDS.get(principal.type) : undefined;
if (kind === undefined) return null;
return {
id: principal.id,
kind,
displayName:
typeof principal.description === 'string' ? principal.description : null,
email: typeof principal.email === 'string' ? principal.email : null,
};
}
/** 메서드 응답을 꺼낸다 — 메서드 오류(읽기 권한 없음 forbidden 등)는 사내 목록을 읽지 못한 것(MAIL_001)이다 */
function expectDirectoryResult<T>(
responses: JmapInvocation[],
callId: string,
method: string,
): T {
const result = findResult(responses, callId, method);
if (!result.ok) {
throw new BusinessException('MAIL_001', {
context: {
reason: 'directory-read-refused',
method,
jmapType: result.error.type ?? 'unknown',
},
});
}
return result.args as T;
}
/**
* 메일 엔진(Stalwart)의 계정 목록 — 사내 목록 (계약 3절 "사내 목록"). setup 이 만든 사내 목록 계정으로만 읽는다(JmapClient.callDirectory).
* Principal/query(500개씩 · total) + Principal/get 을 한 요청에 — 읽기만 한다. 걸러 내기(관리자 · 게이트웨이 · 막은 계정)는 서비스가 한다.
* 계정의 이름 · 주소는 로그에 남기지 않는다 (개수 · 엔진 오류 종류만)
*/
@Injectable()
export class JmapDirectoryProvider implements DirectoryProvider {
private readonly logger = new Logger(JmapDirectoryProvider.name);
constructor(private readonly jmap: JmapClient) {}
async listAccounts(): Promise<DirectoryAccount[]> {
const accounts: DirectoryAccount[] = [];
let position = 0;
let total: number | null = null;
while (position < DIRECTORY_READ_MAX_ACCOUNTS) {
const limit = Math.min(
READ_PAGE_SIZE,
DIRECTORY_READ_MAX_ACCOUNTS - position,
);
const responses = await this.jmap.callDirectory((accountId) => [
[
'Principal/query',
{ accountId, position, limit, calculateTotal: true },
'q',
],
[
'Principal/get',
{
accountId,
'#ids': { resultOf: 'q', name: 'Principal/query', path: '/ids' },
properties: PRINCIPAL_PROPERTIES,
},
'g',
],
]);
const query = expectDirectoryResult<JmapQueryResult>(
responses,
'q',
'Principal/query',
);
const page = expectDirectoryResult<JmapGetResult<JmapPrincipal>>(
responses,
'g',
'Principal/get',
);
for (const principal of page.list ?? []) {
const account = toDirectoryAccount(principal);
if (account) accounts.push(account);
}
const ids = query.ids ?? [];
position += ids.length;
total = typeof query.total === 'number' ? query.total : null;
if (ids.length === 0 || (total !== null && position >= total)) break;
}
if (total !== null && total > position) {
this.logger.warn(
`[MAIL_001]${traceTag()} 엔진의 계정이 많아 사내 목록에 앞의 ${position}개만 읽었습니다: 계정 ${total}개`,
);
}
return accounts;
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,773 @@
import { Injectable, Logger } from '@nestjs/common';
import { BusinessException } from '@/common/errors/business.exception';
import {
filterFields,
judgeFilter,
type MailFilterTexts,
} from '@/modules/mail/mail-filter-match';
import type {
Mailbox,
MailFilterRunContext,
MailFilterRunOutcome,
MailFilterRunRequest,
} from '@/modules/mail/mail.types';
import { BulkRun } from './bulk-run';
import {
MAX_FILTER_RUN_SECONDS,
decodeFilterRunCursor,
encodeFilterRunCursor,
} from './filter-run-cursor';
import {
FILTER_RUN_BODY_PROPERTIES,
FILTER_RUN_PART_PROPERTIES,
bodyTextsOf,
estimatedBodyBytes,
filterRunProperties,
headerTextsOf,
textPartsOf,
} from './filter-run-texts';
import { JmapClient, type JmapCallOptions } from './jmap.client';
import { jmapFailure } from './jmap.errors';
import { toJmapUtcDate } from './jmap.mapper';
import {
expectResult,
findResult,
firstRefusal,
isState,
warnRefusals,
} from './jmap-results';
import type {
JmapEmail,
JmapEmailFilterCondition,
JmapGetResult,
JmapInvocation,
JmapQueryResult,
JmapSetResult,
} from './jmap.types';
/**
* 한 차례에 확인하는 메일 수 — 머리글만 견주면 엔진의 한 번 처리 한도(maxObjectsInGet · maxObjectsInSet 500)까지,
* 본문을 견주면 줄인다(본문 값을 읽는 시간 · 크기 — 차례가 남은 시간 안에 끝나게).
* 측정(개발 엔진, 2026-10-29, 시험 메일 5,000통): 머리글 500통 읽기 0.17~0.19초 · 본문 2,000통 1초 안팎 · 500통 옮기기 0.35초
*/
const HEADER_ROUND_SIZE = 500;
const BODY_ROUND_SIZE = 100;
/**
* 본문을 읽는 상한 (UTF-8 바이트 — 계약의 "한 부분은 앞 256KiB 까지, 한 통은 모두 합쳐 1MiB 까지") — 넘는 뒤쪽은 견주지 않는다.
* 글자 부분이 많아 한 통의 몫을 넘으면 부분마다 같은 몫(1MiB ÷ 부분 수)만큼 앞쪽만 읽는다 — 바깥에서 온 메일 한 통으로 서버가 메모리를 크게 쓰지 않게.
* 텍스트 · HTML 대안 본문이 함께 오는 메일은 텍스트 쪽이 작아 긴 메일의 뒷부분도 대개 이 안에 든다
*/
export const FILTER_RUN_BODY_VALUE_BYTES = 262_144;
export const FILTER_RUN_MESSAGE_BODY_BYTES = 1_048_576;
/** 본문을 한 번에 읽는 양의 어림 상한 — 부분의 크기로 어림해 넘으면 나눠 읽는다 (응답 하나가 수십 MB 가 되지 않게) */
const BODY_FETCH_BUDGET_BYTES = 4_000_000;
/** 엔진의 한 번 읽기 한도 (maxObjectsInGet) */
const MAX_IDS_PER_GET = 500;
/** 받은 시각이 최근인 메일부터 — 같은 받은 시각의 메일은 엔진이 만든 차례로 늘 같게 준다 (2026-10-29 확인) */
const NEWEST_FIRST = [{ property: 'receivedAt', isAscending: false }];
/** JMAP 메서드 오류 — ifInState 가 지금 상태와 다르다 · 지난 차례의 마지막 메일(anchor)이 받은편지함에 없다 (RFC 8620 §5.3 · §5.5) */
const STATE_MISMATCH = 'stateMismatch';
const ANCHOR_NOT_FOUND = 'anchorNotFound';
/** SetError — 그 메일이 없다 (그사이 지워짐) */
const NOT_FOUND = 'notFound';
const MS_PER_SECOND = 1_000;
/**
* 한 통의 본문을 부분마다 읽는 몫 (바이트) — 한 부분은 FILTER_RUN_BODY_VALUE_BYTES 까지, 한 통은 모두 합쳐 FILTER_RUN_MESSAGE_BODY_BYTES 까지.
* 글자 부분이 많으면 같은 몫씩 나눈다 (적어도 1바이트). 엔진의 maxBodyValueBytes 는 요청 단위라 몫이 다른 메일은 따로 읽는다
*/
export function bodyValueQuota(textParts: number): number {
if (textParts <= 0) return FILTER_RUN_BODY_VALUE_BYTES;
return Math.max(
1,
Math.min(
FILTER_RUN_BODY_VALUE_BYTES,
Math.floor(FILTER_RUN_MESSAGE_BODY_BYTES / textParts),
),
);
}
/** 이어 할 자리 (filter-run-cursor.ts 의 FilterRunPosition) — 위 경계는 첫 차례를 읽기 전에는 모른다(null) */
interface RunState {
readonly before: number | null;
readonly anchor: string | null;
readonly anchorTime: number | null;
}
/** 할 일 — 맞은 메일의 패치를 만든다 */
interface RunPlan {
readonly inboxId: string;
/** 옮길 메일함 — 옮기지 않으면 null (규칙에 없음 · 그 메일함이 없음 · 받은편지함) */
readonly moveTo: string | null;
readonly markAsRead: boolean;
readonly flag: boolean;
}
/** 한 차례에 무엇을 읽나 — 속성 · 본문 부분을 읽는지 · 메일 수 */
interface PageRead {
readonly properties: readonly string[];
readonly needsBody: boolean;
readonly limit: number;
}
/** 조회의 시작 — 위 경계(없으면 걸지 않는다)와 anchor 다음, 또는 그 경계의 처음 */
type PageStart = { readonly before: number | null } & (
| { readonly anchor: string }
| { readonly fromTop: true }
);
/** 한 차례에 읽은 받은편지함의 메일 */
interface RoundPage {
/** 첫 메일의 자리 (그 조회의 경계 안에서) */
readonly start: number;
/** 받은 시각 내림차순 */
readonly ids: readonly string[];
/** 그 조회의 경계 안 받은편지함 메일 수 — 엔진이 세어 주지 않으면 어림 */
readonly total: number;
readonly emails: ReadonlyMap<string, JmapEmail>;
/** 이 읽기의 Email 상태 — 옮길 때 ifInState 에 쓴다 (엔진이 알려 주지 않으면 null) */
readonly state: string | null;
/** 이 차례를 읽은 자리 — anchor 가 그사이 빠졌으면 anchor 를 지운 것 (그 받은 시각부터 다시 읽었다) */
readonly from: RunState;
}
/** 한 차례를 마친 결과 */
interface RoundResult {
/** 판정한 메일 수 — 조회와 읽기 사이에 받은편지함에서 빠진 메일은 세지 않는다 */
readonly judged: number;
/** 맞아서 할 일을 한 메일 수 (이미 읽음 · 중요였던 메일 포함) */
readonly matched: number;
/** 받은편지함에 남긴 마지막 메일과 그 받은 시각(초 — 엔진이 주지 않았으면 null) — 남긴 메일이 없으면 null */
readonly lastStayed: {
readonly id: string;
readonly time: number | null;
} | null;
}
/** 맞은 메일에 할 일을 한 결과 */
interface Applied {
readonly updated: ReadonlySet<string>;
/** 그사이 받은편지함에서 빠졌거나 없어진 메일 — 하지 않았다 */
readonly gone: ReadonlySet<string>;
}
/** 본문 값을 한 번에 읽을 메일 — 부분마다의 몫이 같은 메일끼리 */
interface BodyBatch {
readonly quota: number;
readonly ids: readonly string[];
}
/** 옮길 메일함 — 규칙에 없거나 · 받은편지함이거나 · 메일함 목록에 없으면(지워짐) 옮기지 않는다 (계약 — 표시는 한다) */
function runPlan(
request: MailFilterRunRequest,
inboxId: string,
mailboxes: readonly Mailbox[],
): RunPlan {
const target = request.actions.moveToMailboxId;
const moveTo =
target !== null &&
target !== inboxId &&
mailboxes.some((mailbox) => mailbox.id === target)
? target
: null;
return {
inboxId,
moveTo,
markAsRead: request.actions.markAsRead,
flag: request.actions.flag,
};
}
/** 맞은 메일 한 통의 패치 — 이미 있는 표시는 다시 붙이지 않는다. 바꿀 것이 없으면 null */
function emailPatch(
plan: RunPlan,
email: JmapEmail | undefined,
): Record<string, unknown> | null {
const patch: Record<string, unknown> = {};
if (plan.markAsRead && email?.keywords?.$seen !== true) {
patch['keywords/$seen'] = true;
}
if (plan.flag && email?.keywords?.$flagged !== true) {
patch['keywords/$flagged'] = true;
}
if (plan.moveTo !== null) {
patch[`mailboxIds/${plan.inboxId}`] = null;
patch[`mailboxIds/${plan.moveTo}`] = true;
}
return Object.keys(patch).length > 0 ? patch : null;
}
/** 받은 시각(초) — 엔진은 초 단위로 둔다(2026-10-29 확인). 읽을 수 없으면 null */
function receivedSeconds(email: JmapEmail | undefined): number | null {
const time = email?.receivedAt ? Date.parse(email.receivedAt) : Number.NaN;
if (!Number.isFinite(time)) return null;
const seconds = Math.floor(time / MS_PER_SECOND);
return seconds >= 0 && seconds < MAX_FILTER_RUN_SECONDS ? seconds : null;
}
/** 초 → `before` 조건의 UTCDate */
function utcDate(seconds: number): string {
return toJmapUtcDate(new Date(seconds * MS_PER_SECOND).toISOString());
}
/** 차례에서 가장 최근의 받은 시각(초) — 받은편지함 맨 위 메일 (읽은 메일이 없으면 null) */
function newestSecond(page: RoundPage): number | null {
let newest: number | null = null;
for (const email of page.emails.values()) {
const seconds = receivedSeconds(email);
if (seconds !== null && (newest === null || seconds > newest)) {
newest = seconds;
}
}
return newest;
}
/**
* 본문 값을 읽을 묶음 — 부분마다의 몫(bodyValueQuota)이 같은 메일끼리, 어림한 크기가 상한을 넘거나 엔진의 한 번 읽기 한도에 닿으면 나눈다.
* 한 통의 어림은 몫 × 부분 수 이하라(1MiB 안쪽) 한 묶음에 여러 통이 든다
*/
function bodyBatches(emails: readonly JmapEmail[]): BodyBatch[] {
const groups = new Map<number, JmapEmail[]>();
for (const email of emails) {
const quota = bodyValueQuota(textPartsOf(email).length);
const group = groups.get(quota);
if (group) group.push(email);
else groups.set(quota, [email]);
}
const batches: BodyBatch[] = [];
for (const [quota, group] of groups) {
let ids: string[] = [];
let bytes = 0;
for (const email of group) {
const estimate = estimatedBodyBytes(email, quota);
if (
ids.length > 0 &&
(bytes + estimate > BODY_FETCH_BUDGET_BYTES ||
ids.length >= MAX_IDS_PER_GET)
) {
batches.push({ quota, ids });
ids = [];
bytes = 0;
}
ids.push(email.id);
bytes += estimate;
}
if (ids.length > 0) batches.push({ quota, ids });
}
return batches;
}
/**
* 받은편지함에 이미 있는 메일에 필터 규칙 하나를 적용한다 (계약 2절 "이미 받은 메일에 적용" — 7단계).
* 엔진의 Sieve 는 메일을 받을 때만 돌기 때문에, 받은편지함을 차례로 읽어 Sieve 와 같은 규칙으로 판정하고(mail-filter-match.ts) 표시 · 옮기기를 한다.
* 엔진의 검색(`Email/query` 의 글자 조건)은 쓰지 않는다 — 낱말이 통째로 같을 때만 맞고 `header` 조건은 아무것도 찾지 못한다(Stalwart 0.16.27).
*
* - 차례마다: `Email/query`(받은편지함 · 위 경계 미만 · 받은 시각 내림차순 · 지난 차례에 남긴 마지막 메일 다음부터) + `Email/get`(받은편지함에 아직 있는지 ·
* 표시 · 받은 시각 · 견줄 머리글 · 본문 조건이면 부분) → 판정 → 머리글로 판정이 나지 않은 메일만 본문 값을 나눠 읽어 판정 → 맞은 메일에 `Email/set` 한 번
* - 적용을 시작한 때 받은편지함에 있던 메일만 본다 — 첫 차례가 맨 위 메일의 받은 시각 + 1초를 위 경계로 잡는다 (filter-run-cursor.ts)
* - 확인하는 사이 받은편지함에서 빠진 메일은 건너뛰고 세지 않는다. 옮기기는 읽은 상태 그대로일 때만(ifInState) — 그사이 다른 곳으로 옮긴 메일을
* 옮길 메일함에 더 넣지 않게. 어긋나면 맞은 메일의 메일함을 다시 읽어 남은 것만 다시 한다. 표시만 할 때는 조건 없이(되돌릴 수 있는 표시)
* - 영구 삭제는 하지 않는다 — 이 파일에는 Email/set 의 destroy 가 없다(휴지통으로 옮기는 규칙도 옮기기다)
* - 한 요청의 시간 · 멈춤은 BulkRun(bulk-run.ts) — 메일함의 오래 걸리는 처리와 같다. 시간은 서비스에 들어온 때부터(context.startedAt),
* 진행(processed)은 판정한 메일 수다 — 판정한 것이 없는 차례는 멈춘 차례로 센다
* - 이어 할 자리는 cursor — 한 차례를 다 마친 곳까지만 (도중에 멈춘 차례는 다음 요청이 다시 한다 — 할 일은 다시 해도 같다)
* - 받은편지함 · 옮길 메일함은 서비스가 읽은 메일함 목록(context.mailboxes)에서 찾는다 — 다시 읽지 않는다
*/
@Injectable()
export class JmapFilterRunner {
private readonly logger = new Logger(JmapFilterRunner.name);
constructor(private readonly jmap: JmapClient) {}
async run(
user: string,
request: MailFilterRunRequest,
context: MailFilterRunContext,
): Promise<MailFilterRunOutcome> {
// 이어 할 자리는 엔진에 묻기 전에 본다 — 다른 규칙 · 깨진 cursor 면 VAL_001
let position: RunState =
request.cursor === null
? { before: null, anchor: null, anchorTime: null }
: decodeFilterRunCursor(request.cursor, request);
const run = new BulkRun(context.startedAt);
const inbox = context.mailboxes.find((mailbox) => mailbox.role === 'inbox');
// 받은편지함이 없는 계정 — 적용할 메일이 없다
if (!inbox) {
return { scanned: 0, matched: 0, remaining: 0, cursor: null, rounds: 0 };
}
const plan = runPlan(request, inbox.id, context.mailboxes);
const fields = filterFields(request);
const needsBody = fields.has('body');
const read: PageRead = {
properties: filterRunProperties(fields),
needsBody,
limit: needsBody ? BODY_ROUND_SIZE : HEADER_ROUND_SIZE,
};
let scanned = 0;
let matched = 0;
let rounds = 0;
let remaining = inbox.totalCount;
let finished = false;
try {
while (run.canStartRound()) {
rounds += 1;
const page = await this.readPage(user, inbox.id, position, read, run);
position = page.from;
if (page.ids.length === 0) {
finished = true;
break;
}
// 첫 차례 — 맨 위 메일의 받은 시각 + 1초를 위 경계로 (그 뒤에 온 메일은 보지 않는다)
const newest = position.before === null ? newestSecond(page) : null;
if (newest !== null) position = { ...position, before: newest + 1 };
const round = await this.applyRound(user, request, plan, page, run);
// 멈춘다 — 이 차례는 하지 않은 것으로 (다음 요청이 같은 자리부터 다시 한다)
if (round === null) break;
const exhausted = page.start + page.ids.length >= page.total;
remaining = Math.max(0, page.total - (page.start + page.ids.length));
if (round.lastStayed !== null) {
position = {
...position,
anchor: round.lastStayed.id,
anchorTime: round.lastStayed.time,
};
}
if (round.judged > 0) {
scanned += round.judged;
matched += round.matched;
run.progress(round.judged);
}
if (exhausted) {
finished = true;
break;
}
// 읽은 메일이 모두 그사이 빠졌다 — 다시 읽는다 (이어서 그러면 멈춘다)
if (round.judged === 0 && run.stalled('nothing-judged')) break;
}
} catch (error) {
if (!run.cutOffAfterProgress(error)) throw error;
}
if (!finished) run.assertProgress('filter-run');
return {
scanned,
matched,
remaining: finished ? 0 : remaining,
cursor: finished ? null : this.cursorOf(position, request),
rounds,
};
}
/**
* 이어 할 자리 — 위 경계와 남긴 마지막 메일의 받은 시각이 있어야 한다. 없으면 엔진이 받은 시각을 주지 않은 것 — MAIL_001
* (끝까지 했으면 이어 할 자리가 필요 없어 묻지 않는다)
*/
private cursorOf(position: RunState, request: MailFilterRunRequest): string {
if (
position.before === null ||
(position.anchor !== null && position.anchorTime === null)
) {
throw new BusinessException('MAIL_001', {
context: { reason: 'received-at-missing' },
});
}
return encodeFilterRunCursor(
{
before: position.before,
anchor: position.anchor,
anchorTime: position.anchorTime,
},
request,
);
}
/**
* 한 차례의 메일 — 지난 차례에 받은편지함에 남긴 마지막 메일(anchor) 다음부터 위 경계 미만에서. 그 메일이 그사이 빠졌으면(anchorNotFound)
* 그 받은 시각의 메일부터(`before: anchorTime + 1초` 의 처음) 다시 읽는다 — 그 초의 메일을 다시 판정해도 건너뛰지는 않는다.
* anchor 가 없으면(아직 남긴 메일이 없다 · 빠진 anchor 뒤에 남긴 메일이 없다) 그 경계의 처음부터 읽는다 — 앞서 본 메일은 모두 옮겨 나갔다.
* 받은 시각을 모르면(엔진이 주지 않았다) 위 경계의 처음부터 — 다시 판정하는 메일이 늘 뿐 건너뛰지는 않는다
*/
private async readPage(
user: string,
inboxId: string,
from: RunState,
read: PageRead,
run: BulkRun,
): Promise<RoundPage> {
if (from.anchor !== null) {
const responses = await this.queryPage(
user,
inboxId,
{ before: from.before, anchor: from.anchor },
read,
run.callOptions(),
);
const query = findResult(responses, 'q', 'Email/query');
if (query.ok || query.error.type !== ANCHOR_NOT_FOUND) {
return this.toPage(responses, from, read);
}
}
const restart: RunState = { ...from, anchor: null };
const before =
restart.anchorTime !== null ? restart.anchorTime + 1 : restart.before;
const responses = await this.queryPage(
user,
inboxId,
{ before, fromTop: true },
read,
run.callOptions(),
);
return this.toPage(responses, restart, read);
}
private toPage(
responses: JmapInvocation[],
from: RunState,
read: PageRead,
): RoundPage {
const query = expectResult<JmapQueryResult>(responses, 'q', 'Email/query');
const found = expectResult<JmapGetResult<JmapEmail>>(
responses,
'g',
'Email/get',
);
const ids = query.ids ?? [];
const start = query.position ?? 0;
const known = typeof query.total === 'number' ? query.total : null;
return {
start,
ids,
total:
known !== null && known >= start + ids.length
? known
: start + ids.length + (ids.length === read.limit ? 1 : 0),
emails: new Map((found.list ?? []).map((email) => [email.id, email])),
state: isState(found.state) ? found.state : null,
from,
};
}
/**
* 조회 + 읽기를 한 요청으로 — 같은 순간의 받은편지함이다. 위 경계는 `before`(미만)로 건다.
* anchor 는 cursor 에서 왔을 수 있다 — 엔진이 그 모양을 해석하지 못해 요청 전체를 거부하면 깨진 cursor 와 같게 VAL_001
*/
private queryPage(
user: string,
inboxId: string,
start: PageStart,
read: PageRead,
options: JmapCallOptions,
): Promise<JmapInvocation[]> {
const filter: JmapEmailFilterCondition =
start.before === null
? { inMailbox: inboxId }
: { inMailbox: inboxId, before: utcDate(start.before) };
const byAnchor = 'anchor' in start;
return this.jmap.call(
user,
(accountId) => [
[
'Email/query',
{
accountId,
filter,
sort: NEWEST_FIRST,
...(byAnchor
? { anchor: start.anchor, anchorOffset: 1 }
: { position: 0 }),
limit: read.limit,
calculateTotal: true,
},
'q',
],
[
'Email/get',
{
accountId,
'#ids': { resultOf: 'q', name: 'Email/query', path: '/ids' },
properties: [...read.properties],
...(read.needsBody
? { bodyProperties: FILTER_RUN_PART_PROPERTIES }
: {}),
},
'g',
],
],
byAnchor ? { ...options, invalidRequestCode: 'VAL_001' } : options,
);
}
/**
* 한 차례 — 판정하고 맞은 메일에 할 일을 한다. 멈춰야 하면(상태가 계속 어긋남 · 엔진이 모두 거절했는데 앞 차례의 진행이 있음) null
*/
private async applyRound(
user: string,
request: MailFilterRunRequest,
plan: RunPlan,
page: RoundPage,
run: BulkRun,
): Promise<RoundResult | null> {
/** 판정 — 'gone' 은 받은편지함에서 빠진 메일(건너뛰고 세지 않는다) */
const verdicts = new Map<string, boolean | 'gone'>();
const undecided: JmapEmail[] = [];
const headerTexts = new Map<string, Omit<MailFilterTexts, 'body'>>();
for (const id of page.ids) {
const email = page.emails.get(id);
if (email?.mailboxIds?.[plan.inboxId] !== true) {
verdicts.set(id, 'gone');
continue;
}
const texts = headerTextsOf(email);
const verdict = judgeFilter(request, { ...texts, body: null });
if (verdict !== null) {
verdicts.set(id, verdict);
} else if (textPartsOf(email).length === 0) {
// 글자 부분이 없다 — 본문 값을 읽지 않고 빈 본문으로 판정한다
verdicts.set(id, judgeFilter(request, { ...texts, body: [] }) === true);
} else {
undecided.push(email);
headerTexts.set(id, texts);
}
}
// 머리글로 판정이 나지 않은 메일만 본문 값을 읽는다 — 한 통의 몫 · 어림한 크기로 나눠서
for (const batch of bodyBatches(undecided)) {
const bodies = await this.readBodies(
user,
batch.ids,
batch.quota,
run.callOptions(),
);
for (const id of batch.ids) {
const email = bodies.get(id);
const texts = headerTexts.get(id);
if (email?.mailboxIds?.[plan.inboxId] !== true || !texts) {
verdicts.set(id, 'gone');
continue;
}
verdicts.set(
id,
judgeFilter(request, { ...texts, body: bodyTextsOf(email) }) === true,
);
}
}
const patches = new Map<string, Record<string, unknown>>();
let unchanged = 0;
for (const id of page.ids) {
if (verdicts.get(id) !== true) continue;
const patch = emailPatch(plan, page.emails.get(id));
if (patch === null) unchanged += 1;
else patches.set(id, patch);
}
const applied: Applied | null =
patches.size === 0
? { updated: new Set(), gone: new Set() }
: await this.applyPatches(user, plan, page.state, patches, run);
if (applied === null) return null;
let judged = 0;
let lastStayed: string | null = null;
for (const id of page.ids) {
const verdict = verdicts.get(id);
if (verdict === undefined || verdict === 'gone') continue;
judged += 1;
const moved = plan.moveTo !== null && applied.updated.has(id);
if (!moved && !applied.gone.has(id)) lastStayed = id;
}
return {
judged,
matched: unchanged + applied.updated.size,
lastStayed:
lastStayed === null
? null
: {
id: lastStayed,
time: receivedSeconds(page.emails.get(lastStayed)),
},
};
}
/**
* 맞은 메일에 할 일을 한다 — 엔진 요청 한 번(Email/set update). 옮기기가 있으면 읽은 상태 그대로일 때만(ifInState):
* 어긋나면 맞은 메일의 메일함을 다시 읽고(그사이 받은편지함에서 빠진 것은 뺀다) 그 상태로 다시 한다 — 이어서 어긋나면 BulkRun 이 멈춘다.
* 표시만 할 때는 조건 없이 한다 (되돌릴 수 있는 표시 — 메일함 전체 읽음과 같다).
* 엔진이 모두 거절하면 — 앞 차례의 진행이 있으면 멈추고(null), 없으면 그 거절의 코드로 던진다 (클라이언트가 끝없이 다시 보내지 않게)
*/
private async applyPatches(
user: string,
plan: RunPlan,
state: string | null,
patches: ReadonlyMap<string, Record<string, unknown>>,
run: BulkRun,
): Promise<Applied | null> {
const gone = new Set<string>();
let pending = patches;
let ifInState = state;
for (;;) {
// 상태를 모르면 조건 없이 옮기지 않는다
if (plan.moveTo !== null && ifInState === null) {
throw new BusinessException('MAIL_001', {
context: { reason: 'inbox-state-unknown' },
});
}
const set = await this.updateEmails(
user,
pending,
plan.moveTo === null ? null : ifInState,
run.callOptions(),
);
if (set !== null) {
const updated = new Set(
Object.keys(set.updated ?? {}).filter((id) => pending.has(id)),
);
for (const [id, error] of Object.entries(set.notUpdated ?? {})) {
if (error?.type === NOT_FOUND) gone.add(id);
}
warnRefusals(
this.logger,
user,
set.notUpdated,
'이미 받은 메일에 필터를 적용하며 바꾸지',
);
const refusal = firstRefusal(set.notUpdated);
if (updated.size === 0 && refusal) {
if (run.processed > 0) return null;
throw jmapFailure(refusal, { method: 'Email/set' });
}
return { updated, gone };
}
if (run.stalled('state-mismatch')) return null;
const fresh = await this.readInboxMembership(
user,
plan.inboxId,
[...pending.keys()],
run.callOptions(),
);
ifInState = fresh.state;
const still = new Map<string, Record<string, unknown>>();
for (const [id, patch] of pending) {
if (fresh.inInbox.has(id)) still.set(id, patch);
else gone.add(id);
}
pending = still;
if (pending.size === 0) return { updated: new Set(), gone };
}
}
/**
* Email/set 의 고치기만 — 지우기(destroy)는 보내지 않는다. ifInState 가 있으면 어긋났을 때(stateMismatch) null.
* 그 밖의 메서드 오류는 그 코드로 던진다
*/
private async updateEmails(
user: string,
patches: ReadonlyMap<string, Record<string, unknown>>,
ifInState: string | null,
options: JmapCallOptions,
): Promise<JmapSetResult | null> {
const responses = await this.jmap.call(
user,
(accountId) => [
[
'Email/set',
{
accountId,
...(ifInState === null ? {} : { ifInState }),
update: Object.fromEntries(patches),
},
'u',
],
],
options,
);
const result = findResult(responses, 'u', 'Email/set');
if (!result.ok) {
if (ifInState !== null && result.error.type === STATE_MISMATCH) {
return null;
}
throw jmapFailure(result.error, { method: 'Email/set' });
}
return result.args;
}
/** 맞은 메일이 아직 받은편지함에 있는지와 그때의 상태 — 상태가 어긋난 뒤 다시 할 때 */
private async readInboxMembership(
user: string,
inboxId: string,
ids: readonly string[],
options: JmapCallOptions,
): Promise<{ readonly state: string | null; readonly inInbox: Set<string> }> {
const responses = await this.jmap.call(
user,
(accountId) => [
[
'Email/get',
{ accountId, ids: [...ids], properties: ['id', 'mailboxIds'] },
'g',
],
],
options,
);
const found = expectResult<JmapGetResult<JmapEmail>>(
responses,
'g',
'Email/get',
);
return {
state: isState(found.state) ? found.state : null,
inInbox: new Set(
(found.list ?? [])
.filter((email) => email.mailboxIds?.[inboxId] === true)
.map((email) => email.id),
),
};
}
/** 본문 값 — 글자 부분(text/*) 모두, 부분마다 그 몫까지 (fetchAllBodyValues — 텍스트 첨부도 온다) */
private async readBodies(
user: string,
ids: readonly string[],
quota: number,
options: JmapCallOptions,
): Promise<Map<string, JmapEmail>> {
const responses = await this.jmap.call(
user,
(accountId) => [
[
'Email/get',
{
accountId,
ids: [...ids],
properties: FILTER_RUN_BODY_PROPERTIES,
bodyProperties: ['partId', 'type'],
fetchAllBodyValues: true,
maxBodyValueBytes: quota,
},
'b',
],
],
options,
);
const { list = [] } = expectResult<JmapGetResult<JmapEmail>>(
responses,
'b',
'Email/get',
);
return new Map(list.map((email) => [email.id, email]));
}
}
@@ -317,6 +317,7 @@ describe('JmapFilterScriptStore — 필터 · 부재중 자동 응답의 저장
toDate: null,
subject: '4단계 부재중',
textBody: '휴가입니다.',
isActive: true,
});
expect(engine.recorded[0]).toEqual({
calls: [
@@ -336,11 +337,12 @@ describe('JmapFilterScriptStore — 필터 · 부재중 자동 응답의 저장
expect(engine.client.downloadBytes).not.toHaveBeenCalled();
});
it('한 번도 설정하지 않은 계정은 끈 부재중 · 빈 제목과 내용', async () => {
it('한 번도 설정하지 않은 계정은 끈 부재중 · 빈 제목과 내용 · 동작함(막힌 것이 없다)', async () => {
const engine = new FakeSieveEngine();
await expect(engine.store.getVacation(ARA)).resolves.toEqual(
NEVER_CONFIGURED_VACATION,
);
await expect(engine.store.getVacation(ARA)).resolves.toEqual({
...NEVER_CONFIGURED_VACATION,
isActive: true,
});
});
it('VacationResponse 의 null 제목 · 내용은 빈 문자열로 이어받는다 — 처음 저장하면 그 값이 우리 스크립트에 들어간다', async () => {
@@ -362,7 +364,10 @@ describe('JmapFilterScriptStore — 필터 · 부재중 자동 응답의 저장
textBody: '',
};
await expect(engine.store.getVacation(ARA)).resolves.toEqual(imported);
await expect(engine.store.getVacation(ARA)).resolves.toEqual({
...imported,
isActive: true,
});
await engine.store.setFilters(ARA, {
rules: [rule('r1')],
version: filterRulesVersion([]),
@@ -380,7 +385,10 @@ describe('JmapFilterScriptStore — 필터 · 부재중 자동 응답의 저장
version: filterRulesVersion(rules),
isActive: true,
});
await expect(engine.store.getVacation(ARA)).resolves.toEqual(vacationOn);
await expect(engine.store.getVacation(ARA)).resolves.toEqual({
...vacationOn,
isActive: true,
});
expect(engine.client.downloadBytes).toHaveBeenCalledWith(
ARA,
ours.blobId,
@@ -399,7 +407,11 @@ describe('JmapFilterScriptStore — 필터 · 부재중 자동 응답의 저장
version: filterRulesVersion(rules),
isActive: false,
});
await expect(engine.store.getVacation(ARA)).resolves.toEqual(vacationOn);
// 부재중 응답도 같은 isActive 를 알린다 — 켜 둔 값(isEnabled)은 저장한 그대로다 (7단계)
await expect(engine.store.getVacation(ARA)).resolves.toEqual({
...vacationOn,
isActive: false,
});
});
it('원본이 깨졌으면 규칙 없음 · 부재중 꺼짐 · isActive false — 켜져 있어도, 글이 너무 커도', async () => {
@@ -421,9 +433,10 @@ describe('JmapFilterScriptStore — 필터 · 부재중 자동 응답의 저장
version: filterRulesVersion([]),
isActive: false,
});
await expect(engine.store.getVacation(ARA)).resolves.toEqual(
NEVER_CONFIGURED_VACATION,
);
await expect(engine.store.getVacation(ARA)).resolves.toEqual({
...NEVER_CONFIGURED_VACATION,
isActive: false,
});
}
});
@@ -456,9 +469,10 @@ describe('JmapFilterScriptStore — 필터 · 부재중 자동 응답의 저장
version: filterRulesVersion([]),
isActive: false,
});
await expect(engine.store.getVacation(ARA)).resolves.toEqual(
NEVER_CONFIGURED_VACATION,
);
await expect(engine.store.getVacation(ARA)).resolves.toEqual({
...NEVER_CONFIGURED_VACATION,
isActive: false,
});
});
it('필터 저장 · 부재중 저장 모두 쓰지 않는다 — 503 MAIL_001(filter-source-newer), 글은 그대로 남는다', async () => {
@@ -932,13 +946,29 @@ describe('JmapFilterScriptStore — 필터 · 부재중 자동 응답의 저장
engine.addOurs({ rules, vacation: NEVER_CONFIGURED_VACATION });
const before = await engine.store.getFilters(ARA);
await expect(engine.store.setVacation(ARA, vacationOn)).resolves.toEqual(
vacationOn,
);
await expect(engine.store.setVacation(ARA, vacationOn)).resolves.toEqual({
...vacationOn,
isActive: true,
});
expect(engine.ourSource()).toEqual({ rules, vacation: vacationOn });
await expect(engine.store.getFilters(ARA)).resolves.toEqual(before);
});
it('엔진에서 꺼져 있던 우리 규칙은 부재중을 저장하면 다시 켜진다 — 응답의 isActive 도 true (7단계)', async () => {
const engine = new FakeSieveEngine();
engine.addOurs({ rules: [rule('r1')], vacation: vacationOn });
engine.addScript('vacation', 'require ["vacation"];\r\n', true);
await expect(engine.store.getVacation(ARA)).resolves.toMatchObject({
isActive: false,
});
await expect(engine.store.setVacation(ARA, vacationOn)).resolves.toEqual({
...vacationOn,
isActive: true,
});
expect(engine.ours()?.isActive).toBe(true);
});
it('우리 스크립트가 없으면 만든다 — 규칙 없음 + 부재중, 켠다', async () => {
const engine = new FakeSieveEngine();
engine.addScript('vacation', 'require ["vacation"];\r\n', true);
@@ -5,6 +5,7 @@ import type {
MailFilterSet,
MailFilterUpdate,
MailVacation,
MailVacationState,
} from '@/modules/mail/mail.types';
import { traceTag } from '@/shared/logger/request-context';
import { maskEmail } from '@/shared/utils/mask';
@@ -135,6 +136,11 @@ function toFilterSet(view: ScriptView): MailFilterSet {
};
}
/** 부재중 자동 응답의 응답 — 저장한 값 + 필터와 같은 isActive (계약 2절 "부재중 자동 응답"의 MailVacationState) */
function toVacationState(view: ScriptView): MailVacationState {
return { ...view.source.vacation, isActive: view.isActive };
}
/**
* 메일 필터와 부재중 자동 응답의 저장 자리 — 엔진의 Sieve 스크립트 하나(`arasystem`) (계약 2절 "메일 필터"의 저장 자리).
* Stalwart 0.16.27 은 계정마다 켜진 스크립트가 하나뿐이고 엔진의 VacationResponse 도 스크립트(`vacation`)라서, 둘을 따로 두면
@@ -183,21 +189,24 @@ export class JmapFilterScriptStore {
return toFilterSet(saved);
}
/** 부재중 자동 응답 — 우리 스크립트가 없으면 엔진의 VacationResponse, 원본이 깨졌으면 끈 상태 */
async getVacation(user: string): Promise<MailVacation> {
return (await this.snapshot(user)).source.vacation;
/**
* 부재중 자동 응답 — 우리 스크립트가 없으면 엔진의 VacationResponse, 원본이 깨졌으면 끈 상태.
* 동작하는지(isActive)는 필터의 것과 같은 값이다 — 같은 스크립트 하나를 읽어 안다 (7단계)
*/
async getVacation(user: string): Promise<MailVacationState> {
return toVacationState(await this.snapshot(user));
}
/** 부재중 자동 응답만 바꾼다 — 규칙은 다시 읽은 그대로 둔다 (지문을 보지 않는다). 저장한 값을 돌려준다 */
/** 부재중 자동 응답만 바꾼다 — 규칙은 다시 읽은 그대로 둔다 (지문을 보지 않는다). 저장한 값과 저장한 뒤 동작하는지를 돌려준다 */
async setVacation(
user: string,
vacation: MailVacation,
): Promise<MailVacation> {
): Promise<MailVacationState> {
const saved = await this.write(user, (current) => ({
rules: current.rules,
vacation,
}));
return saved.source.vacation;
return toVacationState(saved);
}
/**
@@ -25,6 +25,7 @@ import {
} from './jmap.client';
import type { JmapClient, JmapSession } from './jmap.client';
import { decodeCursor, encodeCursor } from './jmap-cursor';
import { JmapFilterRunner } from './jmap-filter-run';
import { JmapFilterScriptStore } from './jmap-filter-script';
import { WATCH_DEBOUNCE_MS, WATCH_START_TIMEOUT_MS } from './jmap-mail-watch';
import { JmapMailProvider, buildThreadHeaders } from './jmap-mail.provider';
@@ -53,7 +54,11 @@ const settings: MailSettings = {
gatewayPassword: 'gateway-secret',
attachmentMaxBytes: 20 * MB,
attachmentTotalMaxBytes: 25 * MB,
blockedAccounts: new Set(['admin@arasystem.test']),
blockedAccounts: new Set([
'admin@arasystem.test',
'mail-directory@arasystem.test',
]),
directoryAccount: 'mail-directory@arasystem.test',
};
/** 검증된 Stalwart 기본 메일함 (영어 이름 · sortOrder 0) */
@@ -176,6 +181,7 @@ function fakeClient(
cache,
overrides.settings ?? settings,
new JmapFilterScriptStore(client as unknown as JmapClient),
new JmapFilterRunner(client as unknown as JmapClient),
),
};
}
@@ -5569,7 +5575,11 @@ describe('JmapMailProvider', () => {
it('부재중은 우리 스크립트가 없으면 엔진의 VacationResponse 를 이어받는다 — 스크립트 목록과 한 요청으로 묻는다', async () => {
const { provider, recorded } = fakeClient([noScript]);
await expect(provider.getVacation(ARA)).resolves.toEqual(vacation);
// 저장한 적이 없으면 막힌 것이 없다 — isActive: true (7단계)
await expect(provider.getVacation(ARA)).resolves.toEqual({
...vacation,
isActive: true,
});
expect(recorded[0].calls.map(([method]) => method)).toEqual([
'SieveScript/get',
'VacationResponse/get',
@@ -5601,7 +5611,10 @@ describe('JmapMailProvider', () => {
});
const off = { ...vacation, isEnabled: false };
await expect(provider.setVacation(ARA, off)).resolves.toEqual(off);
await expect(provider.setVacation(ARA, off)).resolves.toEqual({
...off,
isActive: true,
});
expect((client.upload.mock.calls[0] as unknown[])[2]).toBe(
'application/sieve',
);
@@ -23,12 +23,16 @@ import type {
MailboxBulkResult,
MailboxCreate,
MailboxDeletion,
MailFilterRunContext,
MailFilterRunOutcome,
MailFilterRunRequest,
MailFilterSet,
MailFilterUpdate,
MailLiveState,
MailState,
MailUsage,
MailVacation,
MailVacationState,
MailWatch,
MailWatchListener,
MessageDeletionResult,
@@ -69,6 +73,7 @@ import {
type DeliveryResult,
} from './delivery-status';
import { decodeCursor, encodeCursor } from './jmap-cursor';
import { JmapFilterRunner } from './jmap-filter-run';
import { JmapFilterScriptStore } from './jmap-filter-script';
import {
JmapMailWatch,
@@ -79,15 +84,17 @@ import { PUSH_PING_SECONDS, PUSH_TYPES } from './jmap-push';
import {
expectResult,
findResult,
firstRefusal,
firstSetError,
isState,
warnRefusals,
} from './jmap-results';
import {
describeFailure,
isMissingAttachmentBlob,
jmapFailure,
mapJmapErrorType,
} from './jmap.errors';
import { BulkRun, CONDITIONAL_DESTROY_ATTEMPTS } from './bulk-run';
import {
BODY_PART_PROPERTIES,
DETAIL_PROPERTIES,
@@ -172,14 +179,6 @@ const DRAFT_DETAIL_PROPERTIES = [
const SENT_ADDRESS_PROPERTIES = ['to', 'cc', 'bcc', 'receivedAt'];
const RECEIVED_ADDRESS_PROPERTIES = ['from', 'receivedAt'];
/**
* 조건부 영구 삭제(ifInState)에서 상태가 어긋나면 다시 확인하고 지우는 횟수 — 지우기 요청을 모두 합쳐.
* 사용자 초안(교체 저장·보낸 뒤)과 휴지통에서 영구 삭제(DELETE /api/messages/{id} · POST /api/message-deletions)가 함께 쓴다.
* 계정의 메일이 하나라도 바뀌면(새 메일 도착·다른 메일의 읽음 표시 포함) 상태가 바뀌므로 바쁜 메일함에서는 어긋날 수 있다 —
* 그래도 이 횟수 안에 맞지 않으면 지우지 않는다.
*/
const CONDITIONAL_DESTROY_ATTEMPTS = 3;
/** JMAP 메서드 오류 — ifInState 가 지금 상태와 다르다 (RFC 8620 §5.3) */
const STATE_MISMATCH = 'stateMismatch';
@@ -198,13 +197,9 @@ const PARENT_ALLOWED_ROLES: ReadonlySet<string> = new Set(['inbox', 'archive']);
const EMPTYABLE_ROLES: ReadonlySet<string> = new Set(['trash', 'junk']);
/**
* 오래 걸리는 처리(메일함 전체 읽음 · 비우기 · 지우기) 한 요청의 시간 (계약 2절 "메일함 관리") — 실제 상한이다 (BulkRun).
* 이 안에서 끝내고 남은 수(remaining)를 돌려준다. 클라이언트가 남은 것이 없을 때까지 같은 요청을 다시 보낸다.
* 오래 걸리는 처리(메일함 전체 읽음 · 비우기 · 지우기 — 계약 2절 "메일함 관리")가 한 차례에 다루는 메일 수 —
* 엔진의 한 번 처리 한도(maxObjectsInGet · maxObjectsInSet 500). 한 요청의 시간 · 멈춘 차례는 BulkRun (bulk-run.ts)
*/
const BULK_REQUEST_BUDGET_MS = 25_000;
/** 남은 시간이 이보다 적으면 다음 차례를 시작하지 않는다 — 한 차례(엔진 호출 두 번)가 끝까지 시간 안에 들게 */
const BULK_ROUND_RESERVE_MS = 5_000;
/** 한 차례에 다루는 메일 수 — 엔진의 한 번 처리 한도(maxObjectsInGet · maxObjectsInSet 500) */
const BULK_BATCH_SIZE = 500;
/** 원문 내려받기 때 엔진에 요청하는 형식·이름 — 클라이언트에게 주는 이름·헤더는 서비스·컨트롤러가 정한다 */
@@ -455,74 +450,6 @@ function mailboxSetFailure(
return jmapFailure(error, { method });
}
/**
* 오래 걸리는 처리(메일함 전체 읽음 · 비우기 · 지우기)의 한 요청 — 시간 · 진행 · 멈춘 차례 (계약 2절 "메일함 관리").
* - 시간: 메일함 목록을 읽기 **전부터** BULK_REQUEST_BUDGET_MS 가 실제 상한이다. 다음 차례는 남은 시간이 BULK_ROUND_RESERVE_MS 보다
* 많을 때만 시작하고, 엔진 호출마다 제한 시간을 기본 제한 시간(JMAP_REQUEST_TIMEOUT_MS)과 남은 시간 중 짧은 쪽으로 준다 —
* 엔진이 느려도 한 요청이 앞단 nginx 의 시간 제한에 걸리지 않게
* - 멈춘 차례: 아무것도 바뀌지 않은 차례(어긋남 · 바뀐 것도 거절도 없음 · 비운 메일함을 지우는 사이 메일이 들어옴)가
* 이어서 CONDITIONAL_DESTROY_ATTEMPTS 번이면 멈춘다 — 엔진에 끝없이 묻지 않게. 진행한 차례가 있으면 처음부터 다시 센다
* - 일찍 멈췄을 때(멈춘 차례 · 남은 시간 · 남은 시간 때문에 끊긴 호출) 이 요청에서 처리한 것이 있으면 거기까지로 답하고, 없으면 MAIL_001
*/
class BulkRun {
private readonly deadline = Date.now() + BULK_REQUEST_BUDGET_MS;
private stalls = 0;
private lastStall: string | null = null;
/** 이번 요청에서 처리한 메일 수 */
processed = 0;
/** 다음 차례를 시작해도 되는가 */
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();
}
}
/** 조회의 전체 수 — 엔진이 세어 주지 않으면 받은 수로 (가득 찼으면 더 있는 것으로 하나 더) */
function queryTotal(result: JmapQueryResult, received: number): number {
if (typeof result.total === 'number' && result.total >= received) {
@@ -531,15 +458,6 @@ function queryTotal(result: JmapQueryResult, received: number): number {
return received === BULK_BATCH_SIZE ? received + 1 : received;
}
/** 메일마다의 거절 가운데 "그사이 없어짐(notFound)" 이 아닌 첫 오류 — 없으면 undefined */
function firstRefusal(
...errors: (Record<string, JmapError> | undefined)[]
): JmapError | undefined {
return errors
.flatMap((record) => Object.values(record ?? {}))
.find((error) => error?.type !== NOT_FOUND);
}
/**
* 새 메일 확인 값 — 메일함 상태와 메일 상태를 이어 붙인다 (클라이언트에게는 불투명).
* 각각 base64url 로 바꿔 '.' 으로 잇는다 — base64url 에는 '.' 이 없어 서로 다른 두 상태 쌍이 같은 값이 되지 않는다.
@@ -727,6 +645,8 @@ export class JmapMailProvider implements MailProvider {
@Inject(MAIL_SETTINGS) private readonly settings: MailSettings,
/** 필터 · 부재중 자동 응답의 저장 자리 — 엔진의 Sieve 스크립트 하나 (jmap-filter-script.ts) */
private readonly filterScripts: JmapFilterScriptStore,
/** 이미 받은 메일에 필터 적용 (jmap-filter-run.ts — 7단계) */
private readonly filterRuns: JmapFilterRunner,
) {}
verifyCredentials(email: string, password: string): Promise<string | null> {
@@ -1166,12 +1086,15 @@ export class JmapMailProvider implements MailProvider {
* 부재중 자동 응답 — 필터와 같은 엔진 규칙(`arasystem` 스크립트)에 둔다. 그 스크립트가 아직 없으면 엔진의 VacationResponse 값이다
* (계약 2절 "메일 필터"의 저장 자리 — jmap-filter-script.ts)
*/
getVacation(user: string): Promise<MailVacation> {
getVacation(user: string): Promise<MailVacationState> {
return this.filterScripts.getVacation(user);
}
/** 부재중 자동 응답만 바꾼다 — 같은 스크립트의 필터 규칙은 그대로 둔다. 저장한 값을 돌려준다 */
setVacation(user: string, vacation: MailVacation): Promise<MailVacation> {
setVacation(
user: string,
vacation: MailVacation,
): Promise<MailVacationState> {
return this.filterScripts.setVacation(user, vacation);
}
@@ -1188,6 +1111,15 @@ export class JmapMailProvider implements MailProvider {
return this.filterScripts.setFilters(user, update);
}
/** 받은편지함에 이미 있는 메일에 규칙 하나를 적용한다 — 차례 · 판정 · 이어 할 자리는 JmapFilterRunner (계약 2절 "이미 받은 메일에 적용") */
runMailFilter(
user: string,
request: MailFilterRunRequest,
context: MailFilterRunContext,
): Promise<MailFilterRunOutcome> {
return this.filterRuns.run(user, request, context);
}
async getUsage(user: string): Promise<MailUsage> {
const responses = await this.jmap.call(
user,
@@ -2896,27 +2828,13 @@ export class JmapMailProvider implements MailProvider {
);
}
/**
* 여러 통을 처리하다 엔진이 메일마다 거절한 것 — 종류별 개수만 경고로 남긴다 (id 목록은 남기지 않는다).
* 그사이 없어진 것(notFound)은 흔한 경합이라 남기지 않는다.
* @param doing 로그 문장 — '바꾸지' · '휴지통에서 지우지'
*/
/** 여러 통을 처리하다 엔진이 메일마다 거절한 것 — 종류별 개수만 경고로 (jmap-results.ts 의 warnRefusals) */
private warnRefusals(
user: string,
errors: Record<string, JmapError> | undefined,
doing: string,
): void {
const counts = new Map<string, number>();
for (const error of Object.values(errors ?? {})) {
const type = error?.type ?? 'unknown';
if (type === NOT_FOUND) continue;
counts.set(type, (counts.get(type) ?? 0) + 1);
}
for (const [type, count] of counts) {
this.logger.warn(
`[${mapJmapErrorType(type)}]${traceTag()} 엔진이 거절해 ${doing} 못한 메일이 있습니다: user=${maskEmail(user)}, jmapType=${type}, ${count}통`,
);
}
warnRefusals(this.logger, user, errors, doing);
}
/**
+42 -2
View File
@@ -1,12 +1,19 @@
import type { Logger } from '@nestjs/common';
import { BusinessException } from '@/common/errors/business.exception';
import { jmapFailure } from './jmap.errors';
import { traceTag } from '@/shared/logger/request-context';
import { maskEmail } from '@/shared/utils/mask';
import { jmapFailure, mapJmapErrorType } from './jmap.errors';
import type { JmapError, JmapInvocation } from './jmap.types';
/**
* JMAP 응답 읽기 — 어댑터(jmap-mail.provider.ts)와 엔진 규칙 저장(jmap-filter-script.ts)이 같이 쓴다.
* JMAP 응답 읽기 — 어댑터(jmap-mail.provider.ts) · 엔진 규칙 저장(jmap-filter-script.ts) · 필터 적용(jmap-filter-run.ts)이 같이 쓴다.
* 이 모듈의 타입 · 함수는 `modules/mail/jmap/` 밖으로 내보내지 않는다.
*/
/** SetError — 그 대상이 없다 (그사이 지워짐) */
const NOT_FOUND = 'notFound';
export type MethodResult =
| { ok: true; args: Record<string, unknown> }
| { ok: false; error: JmapError };
@@ -49,3 +56,36 @@ export function firstSetError(
export function isState(value: unknown): value is string {
return typeof value === 'string' && value !== '';
}
/** 메일마다의 거절 가운데 "그사이 없어짐(notFound)" 이 아닌 첫 오류 — 없으면 undefined */
export function firstRefusal(
...errors: (Record<string, JmapError> | undefined)[]
): JmapError | undefined {
return errors
.flatMap((record) => Object.values(record ?? {}))
.find((error) => error?.type !== NOT_FOUND);
}
/**
* 여러 통을 처리하다 엔진이 메일마다 거절한 것 — 종류별 개수만 경고로 남긴다 (id 목록은 남기지 않는다).
* 그사이 없어진 것(notFound)은 흔한 경합이라 남기지 않는다.
* @param doing 로그 문장 — '바꾸지' · '휴지통에서 지우지'
*/
export function warnRefusals(
logger: Logger,
user: string,
errors: Record<string, JmapError> | undefined,
doing: string,
): void {
const counts = new Map<string, number>();
for (const error of Object.values(errors ?? {})) {
const type = error?.type ?? 'unknown';
if (type === NOT_FOUND) continue;
counts.set(type, (counts.get(type) ?? 0) + 1);
}
for (const [type, count] of counts) {
logger.warn(
`[${mapJmapErrorType(type)}]${traceTag()} 엔진이 거절해 ${doing} 못한 메일이 있습니다: user=${maskEmail(user)}, jmapType=${type}, ${count}통`,
);
}
}
@@ -12,6 +12,7 @@ import {
expandTemplate,
toRelativeReference,
} from './jmap.client';
import type { JmapInvocation } from './jmap.types';
const settings: MailSettings = {
jmapBaseUrl: 'http://mail:8080',
@@ -20,7 +21,11 @@ const settings: MailSettings = {
gatewayPassword: 'gateway-secret',
attachmentMaxBytes: 20 * 1024 * 1024,
attachmentTotalMaxBytes: 25 * 1024 * 1024,
blockedAccounts: new Set(['admin@arasystem.test']),
blockedAccounts: new Set([
'admin@arasystem.test',
'mail-directory@arasystem.test',
]),
directoryAccount: 'mail-directory@arasystem.test',
};
const ARA = 'ara.kim@arasystem.test';
@@ -879,6 +884,168 @@ describe('JmapClient', () => {
});
});
describe('사내 목록 계정 호출 (callDirectory)', () => {
const DIRECTORY = 'mail-directory@arasystem.test';
/** 사내 목록 계정의 세션 — 메일 계정이 없고 계정 목록(principals) 계정만 있다 (2026-10-29 개발 엔진) */
const directorySession = {
username: DIRECTORY,
apiUrl: 'http://localhost:8180/jmap/',
primaryAccounts: {
'urn:ietf:params:jmap:principals': 'x',
'urn:stalwart:jmap': 'x',
},
accounts: { x: {} },
};
const principalCalls = (accountId: string): JmapInvocation[] => [
['Principal/query', { accountId }, 'q'],
];
it('<사내 목록 계정>%<게이트웨이> 로 세션을 열고, 계정 목록 계정 id 로 core · principals 만 선언해 부른다 — 막는 계정이어도 이 호출만은 된다', async () => {
const responses = [['Principal/query', { ids: ['a'] }, 'q']];
fetchMock
.mockResolvedValueOnce(json(directorySession))
.mockResolvedValueOnce(json({ methodResponses: responses }));
await expect(client.callDirectory(principalCalls)).resolves.toEqual(
responses,
);
const session = requestAt(0);
expect(session.url).toBe('http://mail:8080/jmap/session');
expect(decodeBasic(session.authorization)).toBe(
`${DIRECTORY}%mail-gateway@arasystem.test:gateway-secret`,
);
const call = requestAt(1);
expect(call.url).toBe('http://mail:8080/jmap/');
expect(decodeBasic(call.authorization)).toBe(
`${DIRECTORY}%mail-gateway@arasystem.test:gateway-secret`,
);
expect(JSON.parse(call.init.body as string)).toEqual({
using: ['urn:ietf:params:jmap:core', 'urn:ietf:params:jmap:principals'],
methodCalls: [['Principal/query', { accountId: 'x' }, 'q']],
});
// 직원 메일함으로는 대리 접근하지 않는다 (막는 계정)
expect(await codeOf(client.getSession(DIRECTORY))).toBe('AUTH_001');
expect(fetchMock).toHaveBeenCalledTimes(2);
});
it('세션은 직원 세션과 따로 캐시한다 — 두 번째 호출은 세션을 다시 묻지 않는다', async () => {
fetchMock
.mockResolvedValueOnce(json(directorySession))
.mockImplementation(() =>
Promise.resolve(json({ methodResponses: [] })),
);
await client.callDirectory(principalCalls);
await client.callDirectory(principalCalls);
expect(fetchMock).toHaveBeenCalledTimes(3);
expect(cache.set).toHaveBeenCalledWith(
'mail:jmap-directory-session',
expect.objectContaining({ account: DIRECTORY, accountId: 'x' }),
expect.any(Number),
);
});
it('계정 목록 계정이 세션에 없으면 첫 계정, 그것도 없으면 MAIL_001', async () => {
fetchMock
.mockResolvedValueOnce(
json({
...directorySession,
primaryAccounts: {},
accounts: { y: {} },
}),
)
.mockResolvedValueOnce(json({ methodResponses: [] }));
await client.callDirectory(principalCalls);
expect(
(
JSON.parse(requestAt(1).init.body as string) as {
methodCalls: JmapInvocation[];
}
).methodCalls[0]?.[1],
).toEqual({ accountId: 'y' });
await cache.del('mail:jmap-directory-session');
fetchMock.mockResolvedValueOnce(
json({ ...directorySession, primaryAccounts: {}, accounts: {} }),
);
const error = await client
.callDirectory(principalCalls)
.catch((caught: unknown) => caught);
expect(error).toMatchObject({
code: 'MAIL_001',
context: { reason: 'directory-session-incomplete' },
});
});
it('계정이 없음(401) · 로그인 권한 없음(403) · 게이트웨이 거절은 모두 MAIL_001 — AUTH_001 이 아니다(직원 세션과 상관없다), 캐시한 세션을 지운다', async () => {
const reasonOf = async () => {
const error = await client
.callDirectory(principalCalls)
.catch((caught: unknown) => caught);
expect(error).toBeInstanceOf(BusinessException);
expect((error as BusinessException).code).toBe('MAIL_001');
return (error as BusinessException).context?.reason;
};
// 계정 없음 — 게이트웨이로 직접 들어가 보면 된다
fetchMock
.mockResolvedValueOnce(json({}, 401))
.mockResolvedValueOnce(json({}, 200));
expect(await reasonOf()).toBe('directory-account-missing');
// 게이트웨이도 거부된다 — 메일 설정 전체의 문제
fetchMock
.mockResolvedValueOnce(json({}, 401))
.mockResolvedValueOnce(json({}, 401));
expect(await reasonOf()).toBe('gateway-rejected');
fetchMock.mockResolvedValueOnce(json({}, 403));
expect(await reasonOf()).toBe('directory-account-forbidden');
// 세션을 연 뒤 호출에서 거부돼도 같다
fetchMock
.mockResolvedValueOnce(json(directorySession))
.mockResolvedValueOnce(json({}, 403));
expect(await reasonOf()).toBe('directory-account-forbidden');
expect(cache.del).toHaveBeenCalledWith('mail:jmap-directory-session');
});
it('엔진 5xx 는 MAIL_001, 429 는 REQ_001, 형식이 깨진 응답은 MAIL_001', async () => {
fetchMock
.mockResolvedValueOnce(json(directorySession))
.mockResolvedValueOnce(json({}, 502));
expect(await codeOf(client.callDirectory(principalCalls))).toBe(
'MAIL_001',
);
fetchMock.mockResolvedValueOnce(json({}, 429));
expect(await codeOf(client.callDirectory(principalCalls))).toBe(
'REQ_001',
);
fetchMock.mockResolvedValueOnce(json({ unexpected: true }));
expect(await codeOf(client.callDirectory(principalCalls))).toBe(
'MAIL_001',
);
});
it('사내 목록 계정을 모르면(도메인 없음) 엔진에 가지 않고 MAIL_001', async () => {
const unconfigured = new JmapClient(
{ ...settings, directoryAccount: null },
cache,
);
const error = await unconfigured
.callDirectory(principalCalls)
.catch((caught: unknown) => caught);
expect(error).toMatchObject({
code: 'MAIL_001',
context: { reason: 'directory-not-configured' },
});
expect(fetchMock).not.toHaveBeenCalled();
});
});
describe('변화 소식 스트림 (openEventSource)', () => {
const OPTIONS = { types: ['Mailbox', 'Email'], pingSeconds: 30 } as const;
+147 -1
View File
@@ -163,6 +163,11 @@ const EVENT_STREAM_TYPE = /^text\/event-stream\s*(?:;|$)/i;
/** 직원별 JMAP 세션 캐시 — 계정 삭제가 늦게 반영되는 상한이기도 하다 */
const SESSION_CACHE_TTL_MS = 5 * 60_000;
/** JMAP Principals — 사내 목록 계정으로 엔진의 계정 목록을 읽는다 (계약 3절 "사내 목록") */
const PRINCIPALS_CAPABILITY = 'urn:ietf:params:jmap:principals';
/** 사내 목록 계정의 세션 캐시 키 — 직원 세션(sessionCacheKey)과 따로 둔다 */
const DIRECTORY_SESSION_CACHE_KEY = 'mail:jmap-directory-session';
const HTTP_UNAUTHORIZED = 401;
const HTTP_BAD_REQUEST = 400;
const HTTP_FORBIDDEN = 403;
@@ -189,6 +194,17 @@ export interface JmapSession {
readonly eventSourcePath?: string | null;
}
/**
* 사내 목록 계정의 세션 (캐시에 저장되므로 평범한 객체로 둔다) — 이 세션에는 메일 계정이 없고 계정 목록(principals) 계정만 있다
* (2026-10-29 개발 엔진에서 확인). 직원 세션(JmapSession)으로 쓰지 않는다
*/
interface JmapDirectorySession {
/** 이 세션을 연 사내 목록 계정 주소 — 설정이 바뀌면 다시 연다 */
readonly account: string;
readonly accountId: string;
readonly apiPath: string;
}
/** 엔진의 변화 소식 스트림(JMAP EventSource)을 열 때 정하는 것 */
export interface JmapEventSourceOptions {
/** 받을 종류 — Mailbox · Email 등 (RFC 8620 7.3 의 types) */
@@ -319,6 +335,7 @@ function parseContentLength(value: string | null): number | null {
* - 로그인 확인: `<직원 주소>:<비밀번호>` 로 세션 조회 (200 / 401)
* - 본인 비밀번호 바꾸기: 같은 본인 자격으로 `x:AccountPassword/set` (게이트웨이 대리 접근으로는 바꾸지 않는다)
* - 메일함 접근: 게이트웨이 대리 접근 `<직원 주소>%<게이트웨이 주소>` : `<게이트웨이 비밀번호>`
* - 사내 목록: 같은 대리 접근으로 사내 목록 계정 하나만 — 계정 목록(Principals) 읽기에만 쓴다 (callDirectory)
* - 세션 응답의 URL 은 공개 주소 기준이므로 경로만 떼어 MAIL_JMAP_URL 에 붙인다 (mail/README.md)
*
* 인증 헤더·비밀번호는 어떤 경우에도 로그에 남기지 않는다.
@@ -704,6 +721,50 @@ export class JmapClient {
);
}
/**
* 사내 목록 계정으로 계정 목록(JMAP Principals)을 부른다 (계약 3절 "사내 목록") — `using` 은 core · principals 뿐이다.
* 그 계정(`MAIL_DIRECTORY_LOCAL_PART@MAIL_DOMAIN`)은 비밀번호가 없고 로그인 · 계정 목록 읽기 권한만 있다 — 게이트웨이 대리 접근으로만 쓴다.
* 로그인 · 직원 메일함 대리 접근을 막는 목록(blockedAccounts)에 있으므로, 이 호출만 그 주소 하나를 대리 접근한다(직원 메일함 호출에는 쓰지 않는다).
* - 계정이 없음(세션 401) · 로그인 권한(authenticate)이 없음(403) · 게이트웨이 거절은 모두 MAIL_001 — 직원 세션과 상관없으므로 AUTH_001 로 답하지 않는다
* - 메서드 오류(읽기 권한이 없으면 forbidden)는 그대로 돌려준다 — 해석은 부른 쪽(jmap-directory.provider.ts)이 한다
*/
async callDirectory(
build: (accountId: string) => JmapInvocation[],
): Promise<JmapInvocation[]> {
const account = this.requireDirectoryAccount();
const session = await this.getDirectorySession(account);
const body = JSON.stringify({
using: [CORE_CAPABILITY, PRINCIPALS_CAPABILITY],
methodCalls: build(session.accountId),
});
return this.exchange(
session.apiPath,
{
method: 'POST',
headers: {
authorization: this.impersonation(account),
'content-type': 'application/json',
},
body,
},
REQUEST_TIMEOUT_MS,
'directory',
async (response) => {
await this.assertDirectoryAccepted(response);
await this.assertOk(response, 'directory');
const parsed = await readJson<JmapResponseBody>(response, 'directory');
if (!Array.isArray(parsed.methodResponses)) {
throw engineFailure({
reason: 'malformed-response',
call: 'directory',
});
}
return parsed.methodResponses;
},
);
}
/**
* 첨부를 업로드한다 → blob id.
* 본문은 Blob 으로 받는다 — 임시 파일을 연 Blob(fs.openAsBlob)을 넘기면 파일을 조금씩 읽어 흘려보내므로
@@ -959,7 +1020,92 @@ export class JmapClient {
return this.settings;
}
/** 막는 계정(관리자·MAIL_BLOCKED_ACCOUNTS)이면 AUTH_001 — 계정을 쓸 수 없는 것과 같게 다룬다 */
/** 사내 목록 계정 주소 — 메일 엔진 설정 · 도메인이 없으면 MAIL_001 */
private requireDirectoryAccount(): string {
const { directoryAccount } = this.requireConfigured();
if (!directoryAccount) {
throw engineFailure({ reason: 'directory-not-configured' });
}
return directoryAccount;
}
/**
* 사내 목록 계정의 세션 — 5분 캐시한다(직원 세션과 같은 시간). 계정 id 는 세션의 `primaryAccounts['urn:ietf:params:jmap:principals']`
* (없으면 세션의 첫 계정)
*/
private async getDirectorySession(
account: string,
): Promise<JmapDirectorySession> {
const cached = await this.cache.get<JmapDirectorySession>(
DIRECTORY_SESSION_CACHE_KEY,
);
if (cached && cached.account === account) return cached;
const session = await this.exchange(
SESSION_PATH,
{ headers: { authorization: this.impersonation(account) } },
REQUEST_TIMEOUT_MS,
'directory-session',
async (response): Promise<JmapDirectorySession> => {
await this.assertDirectoryAccepted(response);
await this.assertOk(response, 'directory-session');
const resource = await readJson<JmapSessionResource>(
response,
'directory-session',
);
const accountId =
resource.primaryAccounts?.[PRINCIPALS_CAPABILITY] ??
Object.keys(resource.accounts ?? {})[0];
if (!accountId || !resource.apiUrl) {
throw engineFailure({
reason: 'directory-session-incomplete',
call: 'directory-session',
});
}
return {
account,
accountId,
apiPath: toRelativeReference(resource.apiUrl),
};
},
);
await this.cache.set(
DIRECTORY_SESSION_CACHE_KEY,
session,
SESSION_CACHE_TTL_MS,
);
return session;
}
/**
* 사내 목록 계정의 대리 접근 거부 — 모두 MAIL_001 (사내 목록을 읽지 못함, 계약 3절):
* 403 은 그 계정의 역할에 로그인(authenticate)이 없거나 꺼짐, 401 은 계정이 없음 — 게이트웨이로 직접 들어가 보아
* 게이트웨이도 거부되면 gateway-rejected(전체 메일 설정 문제)로 남긴다
*/
private async assertDirectoryAccepted(response: Response): Promise<void> {
if (
response.status !== HTTP_UNAUTHORIZED &&
response.status !== HTTP_FORBIDDEN
) {
return;
}
await discard(response);
await this.cache.del(DIRECTORY_SESSION_CACHE_KEY);
if (response.status === HTTP_FORBIDDEN) {
throw engineFailure({
reason: 'directory-account-forbidden',
status: response.status,
});
}
throw engineFailure({
reason: (await this.gatewayAccepted())
? 'directory-account-missing'
: 'gateway-rejected',
status: response.status,
});
}
/** 막는 계정(관리자·사내 목록 계정·MAIL_BLOCKED_ACCOUNTS)이면 AUTH_001 — 계정을 쓸 수 없는 것과 같게 다룬다 */
private assertNotBlocked(address: string): void {
if (this.settings.blockedAccounts.has(address.trim().toLowerCase())) {
throw new BusinessException('AUTH_001', {
@@ -189,6 +189,8 @@ export interface JmapEmailFilterCondition {
/** 이 메일함들이 **아닌** 메일함에 하나라도 들어 있는 메일 — 휴지통·스팸메일함에만 있는 메일을 뺀다 */
inMailboxOtherThan?: string[];
notKeyword?: string;
/** 받은 시각이 이보다 앞선(미만) 메일 — UTCDate. 엔진은 받은 시각을 초 단위로 둔다 (2026-10-29 확인) */
before?: string;
/** 보낸 사람·받는 사람·참조·제목·본문 전문 검색 — 큰따옴표로 감싼 값은 구절로 찾는다 */
text?: string;
}
@@ -230,3 +232,39 @@ export interface JmapUploadResult {
type?: string;
size?: number;
}
/** JMAP for Contacts — AddressBook (계정마다 기본 주소록 하나 — isDefault, 2026-10-29 확인) */
export interface JmapAddressBook {
id: string;
name?: string;
isDefault?: boolean;
}
/**
* JMAP for Contacts — ContactCard (JSContact 카드, RFC 9553). 칸마다 모양이 여러 가지일 수 있어(다른 프로그램 · CardDAV 에서 온 카드)
* 모두 unknown 으로 받고 읽을 때 모양을 확인한다 (contact-card.ts)
*/
export interface JmapContactCard {
id: string;
uid?: unknown;
kind?: unknown;
name?: unknown;
emails?: unknown;
phones?: unknown;
organizations?: unknown;
titles?: unknown;
notes?: unknown;
members?: unknown;
}
/**
* JMAP Principals — 엔진의 계정 하나 (사내 목록 계정으로 읽는다 — 2026-10-29 확인: 주는 칸은 id · type · name(로그인 이름) ·
* description(표시 이름) · email 뿐이다)
*/
export interface JmapPrincipal {
id: string;
type?: unknown;
name?: unknown;
description?: unknown;
email?: unknown;
}
@@ -1,5 +1,7 @@
import { startCpuTimer } from '@/../test/cpu-time';
import {
base64TextBody,
decodeHeaderText,
encodeHeaderWords,
plainTextMimeEntity,
} from './mime-text';
@@ -94,3 +96,205 @@ describe('base64TextBody · plainTextMimeEntity — 부재중 내용', () => {
);
});
});
/**
* 원래 머리글 → 엔진의 Sieve 가 견주는 글 (이미 받은 메일에 필터 적용 — 계약 2절 "이미 받은 메일에 적용"의 판정).
* 원래 글의 모양은 엔진이 주는 그대로다 — 앞에 공백 한 칸, 접힌 줄은 CRLF + 공백, encoded-word 그대로 (2026-10-29 실배달 메일에서 받은 값)
*/
describe('decodeHeaderText — 원래 머리글을 푼다 (RFC 5322 접힌 줄 · RFC 2047)', () => {
it('앞뒤 공백을 빼고 따옴표 · 꺾쇠 · 주소는 그대로 둔다', () => {
expect(decodeHeaderText(' "Kim, Ara" <kimara@example.com>')).toBe(
'"Kim, Ara" <kimara@example.com>',
);
expect(decodeHeaderText(' <seojun.park@arasystem.test> ')).toBe(
'<seojun.park@arasystem.test>',
);
});
it('UTF-8 B 로 인코딩된 표시 이름 · 제목을 푼다 (실배달 메일의 From · Cc · Subject)', () => {
expect(
decodeHeaderText(' =?UTF-8?B?6rmA7JWE6528?= <ara.kim@arasystem.test>'),
).toBe('김아라 <ara.kim@arasystem.test>');
expect(
decodeHeaderText(' =?UTF-8?B?7LC47KGw7J24?= <p7smvs8xqfvcc@example.com>'),
).toBe('참조인 <p7smvs8xqfvcc@example.com>');
expect(
decodeHeaderText(
' =?UTF-8?B?SDQg7KO86rCEIO2ajOydmOulvCBwN3NtdnM4eHFmdg==?=',
),
).toBe('H4 주간 회의를 p7smvs8xqfv');
});
it('EUC-KR 은 그 문자셋으로 푼다 — ks_c_5601-1987 · cp949 이름도 같다 (실배달 메일의 EUC-KR 제목)', () => {
const encoded = 'SDUgsN/A+7ytIL/kw7sgcDdzbXZzOHhxZnY=';
for (const charset of ['euc-kr', 'EUC-KR', 'ks_c_5601-1987', 'cp949']) {
expect(decodeHeaderText(` =?${charset}?B?${encoded}?=`)).toBe(
'H5 견적서 요청 p7smvs8xqfv',
);
}
});
it('Q 인코딩 — _ 는 공백, =XX 는 그 바이트 (대소문자 무시)', () => {
expect(decodeHeaderText('=?utf-8?q?Kim_Ara=2C_=ED=95=9C?=')).toBe(
'Kim Ara, 한',
);
expect(decodeHeaderText('=?ISO-8859-1?Q?J=F6rg?= <j@example.com>')).toBe(
'Jörg <j@example.com>',
);
});
it('접힌 줄(줄바꿈 + 뒤의 공백 · 탭 전부)은 띄어쓰기 하나로 편다 — 접지 않은 탭 · 이어진 공백은 그대로 (엔진 판정 H3 · H4 · G1 ~ G3)', () => {
expect(
decodeHeaderText(
' a1@example.com,\r\n a2@example.com,\r\n\t<b@example.com>',
),
).toBe('a1@example.com, a2@example.com, <b@example.com>');
expect(
decodeHeaderText(
' "가나" <g@example.com>,\r\n "다라" <d@example.com>',
),
).toBe('"가나" <g@example.com>, "다라" <d@example.com>');
expect(decodeHeaderText(' a,\n \t b')).toBe('a, b');
expect(decodeHeaderText(' "가나"\t<g@example.com>')).toBe(
'"가나"\t<g@example.com>',
);
expect(decodeHeaderText(' "가나" <g@example.com>')).toBe(
'"가나" <g@example.com>',
);
});
it('인코딩된 글은 홀로 선 것만 푼다 — 앞이 처음 · 공백 · " · , 이고 뒤가 끝 · 공백 · " 일 때, 앞뒤가 다르다 (엔진 판정 H1 · H5 · H7 · G4 ~ G6 · G8 ~ G10)', () => {
const name = '=?UTF-8?B?6rmA7JWE6528?='; // 김아라
const title = '=?UTF-8?B?67aA7J6l?='; // 부장
expect(decodeHeaderText(` "${name}" <a@example.com>`)).toBe(
'"김아라" <a@example.com>',
);
expect(decodeHeaderText(` "${name} 부장" <a@example.com>`)).toBe(
'"김아라 부장" <a@example.com>',
);
expect(decodeHeaderText(` <g@example.com>,${name} <a@example.com>`)).toBe(
'<g@example.com>,김아라 <a@example.com>',
);
expect(decodeHeaderText(` a@example.com ( ${title} )`)).toBe(
'a@example.com ( 부장 )',
);
// 글자 · ( 뒤, 글자 · < · , 앞이면 풀지 않는다 — 앞의 , 는 풀고(위) 뒤의 , 는 풀지 않는다(G10)
for (const raw of [
`abc${name} <a@example.com>`,
`${name}abc <a@example.com>`,
`a@example.com (${title})`,
`${name}<a@example.com>`,
`<s@example.com>, ${name},"가나" <g@example.com>`,
]) {
expect(decodeHeaderText(` ${raw}`)).toBe(raw);
}
});
it('공백만 사이에 둔 인코딩된 글끼리는 공백을 버리고 붙인다(RFC 2047 §6.2) — 글이 사이에 있으면 공백도 남긴다 (엔진 판정 H8 · H9)', () => {
expect(decodeHeaderText('=?UTF-8?B?7ZWc?= \r\n =?UTF-8?B?6riA?=')).toBe(
'한글',
);
expect(
decodeHeaderText(
' =?UTF-8?B?6rmA?=\r\n =?UTF-8?B?7JWE6528?= <ara.kim@arasystem.test>',
),
).toBe('김아라 <ara.kim@arasystem.test>');
expect(decodeHeaderText('=?UTF-8?B?7ZWc?= 그리고 =?UTF-8?B?6riA?=')).toBe(
'한 그리고 글',
);
// 사이 없이 붙은 둘은 홀로 서지 않았다 — 풀지 않는다
expect(decodeHeaderText('=?UTF-8?B?7ZWc?==?UTF-8?B?6riA?=')).toBe(
'=?UTF-8?B?7ZWc?==?UTF-8?B?6riA?=',
);
// 풀지 않은 낱말이 사이에 있으면 이어 붙이지 않는다 (그 앞뒤 공백도 그대로)
expect(
decodeHeaderText('=?UTF-8?B?7ZWc?= a=?UTF-8?B?6riA?= =?UTF-8?B?6riA?='),
).toBe('한 a=?UTF-8?B?6riA?= 글');
});
it('한 글자를 두 encoded-word 에 나눈 메일도 읽는다 — 같은 문자셋이 붙어 있으면 바이트를 이어 푼다', () => {
// '한'(ED 95 9C) 을 ED · 95 9C 로 나눴다
expect(decodeHeaderText('=?UTF-8?B?7Q==?= =?UTF-8?B?lZw=?=')).toBe('한');
// 문자셋이 다르면 따로 푼다
expect(decodeHeaderText('=?UTF-8?B?7ZWc?= =?euc-kr?B?sN8=?=')).toBe('한견');
});
it('RFC 2231 의 언어 표시(문자셋*언어)는 떼고 푼다', () => {
expect(decodeHeaderText('=?UTF-8*ko?B?7ZWc6riA?=')).toBe('한글');
});
it('모르는 문자셋 · 모양이 틀린 encoded-word 는 풀지 않고 그대로 둔다', () => {
expect(decodeHeaderText('=?x-unknown?B?7ZWc?= <a@example.com>')).toBe(
'=?x-unknown?B?7ZWc?= <a@example.com>',
);
expect(decodeHeaderText('=?UTF-8?X?abc?= =?UTF-8?B?7ZWc')).toBe(
'=?UTF-8?X?abc?= =?UTF-8?B?7ZWc',
);
// 모르는 문자셋 뒤의 낱말과는 공백을 버리지 않는다
expect(decodeHeaderText('=?x-unknown?B?eA==?= =?UTF-8?B?7ZWc?=')).toBe(
'=?x-unknown?B?eA==?= 한',
);
});
it('처음 보는 문자셋 이름은 한 머리글에서 16개까지만 찾아본다 — 그 뒤의 처음 보는 이름은 모르는 문자셋, 이미 아는 이름은 그대로 푼다', () => {
// 모르는 이름을 찾아보는 일은 한 번에 10µs 남짓(TextDecoder 가 던진다) — 이름이 모두 다른 낱말을 잔뜩 넣은 머리글이 서버를 붙잡지 않게
const unknown = (prefix: string, count: number) =>
Array.from(
{ length: count },
(_, index) => `=?${prefix}${index}?B?eA==?=`,
).join(' ');
// 16개를 다 쓴 뒤의 windows-1250(이 테스트에서 처음 쓰는 이름)은 찾아보지 않는다
expect(
decodeHeaderText(`${unknown('budget-a', 16)} =?windows-1250?B?eA==?=`),
).toBe(`${unknown('budget-a', 16)} =?windows-1250?B?eA==?=`);
// 15개 뒤라면 찾아보고 푼다 — 그 뒤로는 기억해 두어 찾아보지 않고 푼다
expect(
decodeHeaderText(`${unknown('budget-b', 15)} =?windows-1250?B?eA==?=`),
).toBe(`${unknown('budget-b', 15)} x`);
expect(
decodeHeaderText(`${unknown('budget-c', 16)} =?windows-1250?B?eA==?=`),
).toBe(`${unknown('budget-c', 16)} x`);
// 이미 아는(앞에서 푼) UTF-8 도 그대로
expect(decodeHeaderText('=?UTF-8?B?7ZWc?=')).toBe('한');
expect(
decodeHeaderText(`${unknown('budget-d', 20)} =?UTF-8?B?7ZWc?=`),
).toBe(`${unknown('budget-d', 20)} 한`);
});
it('찾은 문자셋만 기억한다 — 모르는 이름은 다음 머리글에서 다시 찾아보고(그 머리글의 16개 안에 센다), 기억 칸을 차지하지 않는다', () => {
const unknown = (prefix: string, count: number) =>
Array.from(
{ length: count },
(_, index) => `=?${prefix}${index}?B?eA==?=`,
).join(' ');
// 모르는 이름을 기억했다면 다음 머리글에서는 찾아보지 않아 16개가 남는다 — 기억하지 않으므로 다시 16개를 쓴다
decodeHeaderText(unknown('forget', 16));
expect(
decodeHeaderText(`${unknown('forget', 16)} =?windows-1252?B?eA==?=`),
).toBe(`${unknown('forget', 16)} =?windows-1252?B?eA==?=`);
// 모르는 이름 128개를 찾아본 뒤에도 처음 보는 아는 이름은 기억한다 — 16개를 쓴 머리글에서도 찾아보지 않고 푼다
for (let round = 0; round < 8; round += 1) {
decodeHeaderText(unknown(`fill-${round}-`, 16));
}
expect(decodeHeaderText('=?iso-8859-2?B?eA==?=')).toBe('x');
expect(
decodeHeaderText(`${unknown('fill-x', 16)} =?iso-8859-2?B?eA==?=`),
).toBe(`${unknown('fill-x', 16)} x`);
});
it('입력 길이에 비례한다 (CPU 시간) — 닫히지 않은 encoded-word · =? 만 · 모르는 문자셋 · 이름이 모두 다른 문자셋이 이어진 1MB', () => {
// 한도는 넉넉히 — 혼자 돌면 다섯 개 합계 CPU 0.25초 안쪽이다(무거운 테스트 파일 다음이면 GC 가 끼어든다 — html-text.spec.ts).
// 막으려는 것은 역추적하는 정규식 · 낱말마다 문자셋을 찾다 던지는 경로(10µs 남짓 × 낱말 수)다
const cpuMs = startCpuTimer();
decodeHeaderText(`=?UTF-8?B?${'A'.repeat(1_000_000)}`);
decodeHeaderText('=?'.repeat(500_000));
decodeHeaderText('=?a?B?b?= '.repeat(100_000));
decodeHeaderText(
Array.from({ length: 70_000 }, (_, index) => `=?x${index}?B?eA==?=`).join(
' ',
),
);
decodeHeaderText('=?UTF-8?B?7ZWc?= '.repeat(60_000));
expect(cpuMs()).toBeLessThan(10_000);
});
});
+180 -3
View File
@@ -1,7 +1,9 @@
/**
* 부재중 자동 응답을 엔진 규칙(Sieve)에 넣을 때의 글 모양 (순수 함수) — 제목은 RFC 2047 encoded-word, 내용은 base64 MIME 본문.
* 둘 다 ASCII 로만 나오므로 Sieve 문자열 안에 따옴표 · 역슬래시 · `${` 가 생기지 않는다
* (엔진은 require 와 상관없이 문자열의 `${…}` 를 늘 변수로 펼친다 — 계약 2절 "만드는 Sieve", 2026-10-28 확인).
* 메일 머리글 · 본문의 글 모양 (순수 함수).
* - 쓰기: 부재중 자동 응답을 엔진 규칙(Sieve)에 넣을 때 — 제목은 RFC 2047 encoded-word, 내용은 base64 MIME 본문.
* 둘 다 ASCII 로만 나오므로 Sieve 문자열 안에 따옴표 · 역슬래시 · `${` 가 생기지 않는다
* (엔진은 require 와 상관없이 문자열의 `${…}` 를 늘 변수로 펼친다 — 계약 2절 "만드는 Sieve", 2026-10-28 확인).
* - 읽기: 이미 받은 메일에 필터를 적용할 때 원래 머리글을 엔진의 Sieve 가 보는 글로 푼다 (decodeHeaderText — filter-run-texts.ts)
*/
const CRLF = '\r\n';
@@ -81,3 +83,178 @@ export function plainTextMimeEntity(text: string): string {
'',
].join(CRLF);
}
/**
* encoded-word 하나 — `=?문자셋?B|Q?글?=` (RFC 2047 §2). 문자셋 · 글 칸에는 `?` · 공백이 없어 칸마다 다음 `?` 에서 멈춘다
* (되돌아가 볼 것이 없다 — 길이에 비례). 문자셋 뒤의 `*언어`(RFC 2231 §5)는 풀 때 뗀다
*/
const ENCODED_WORD = /=\?([^?\s]+)\?([BbQq])\?([^?\s]*)\?=/g;
/**
* 접힌 줄 — 줄바꿈과 그 뒤의 공백 · 탭 전부를 띄어쓰기 하나로 바꾼다. 엔진(Sieve 의 `header` 검사)이 그렇게 본다(2026-10-29 확인 — 탭으로 접은 줄 ·
* 공백 넷으로 접은 줄 모두 띄어쓰기 하나). 접지 않은 탭 · 이어진 공백은 그대로 둔다(엔진도 그대로 견준다)
*/
const FOLDED_LINE_BREAK = /\r?\n[ \t]+/g;
/** encoded-word 사이에 공백만 있다 — 그 공백은 버린다 (RFC 2047 §6.2) */
const LINEAR_SPACE_ONLY = /^[ \t]*$/;
/** Q 인코딩의 `=XX` */
const Q_ESCAPE = /^[0-9A-Fa-f]{2}$/;
const Q_UNDERSCORE = 0x5f;
const Q_SPACE = 0x20;
const Q_ESCAPE_MARK = 0x3d;
const ASCII_LIMIT = 0x80;
/**
* 웹 표준(WHATWG Encoding)의 이름표에 없는 한국어 문자셋 이름 — TextDecoder 가 모르면 이것으로 다시 찾는다.
* (ks_c_5601-1987 · windows-949 · euc-kr 은 이름표에 있다 — 모두 euc-kr(UHC 포함)로 읽는다)
*/
const CHARSET_ALIASES: ReadonlyMap<string, string> = new Map([
['cp949', 'euc-kr'],
['ms949', 'euc-kr'],
['uhc', 'euc-kr'],
['x-windows-949', 'euc-kr'],
]);
/**
* 문자셋 이름 → 디코더 — 메일에 쓰이는 문자셋은 몇 되지 않아 이만큼만 기억한다(넘으면 더 기억하지 않는다).
* 찾은 것만 기억한다 — 모르는 이름(null)까지 기억하면 바깥에서 온 메일의 아무 이름이나 자리를 차지해 진짜 문자셋을 밀어낸다.
* 디코더는 스트림 없이(decode 한 번에) 쓰므로 함께 써도 된다
*/
const DECODER_CACHE_LIMIT = 64;
const decoderCache = new Map<string, TextDecoder>();
/**
* 한 머리글에서 기억에 없는 문자셋 이름을 찾아보는 횟수 — 넘으면 모르는 문자셋으로 본다.
* 모르는 이름을 찾아보는 일(TextDecoder 가 던진다)은 한 번에 10µs 남짓이라, 이름이 다른 encoded-word 를 잔뜩 넣은 머리글이
* 서버를 붙잡지 않게 (한 머리글에 문자셋 이름은 많아야 두셋이다). 한 머리글 안에서는 찾아본 이름을 다시 찾지 않는다(모르는 이름 포함)
*/
const MAX_NEW_CHARSETS_PER_HEADER = 16;
/**
* 인코딩된 글이 홀로 섰는지 보는 앞 글자 · 뒤 글자 — 처음 · 끝(NaN)도 경계다. 앞뒤가 다르다(엔진 판정 G6 · G10, 2026-10-29):
* 앞은 공백 · 탭 · CR · LF · `"` · `,`, 뒤는 공백 · 탭 · CR · LF · `"` — 뒤에 `,` 가 붙은 인코딩된 글은 엔진이 풀지 않는다
*/
const WORD_START_BOUNDARY_CODES: ReadonlySet<number> = new Set([
0x20, 0x09, 0x0d, 0x0a, 0x22, 0x2c,
]);
const WORD_END_BOUNDARY_CODES: ReadonlySet<number> = new Set([
0x20, 0x09, 0x0d, 0x0a, 0x22,
]);
/** 이름표(WHATWG) · 한국어 이름으로 디코더를 만든다 — 모르면 null */
function createDecoder(charset: string): TextDecoder | null {
for (const name of [charset, CHARSET_ALIASES.get(charset)]) {
if (name === undefined) continue;
try {
return new TextDecoder(name);
} catch {
// 이름표에 없는 이름 — 다음 이름으로
}
}
return null;
}
/**
* 그 문자셋의 디코더 — 모르는 문자셋이면 null (그 낱말은 풀지 않고 그대로 둔다).
* looked 는 이 머리글에서 찾아본 이름(모르는 이름은 null) — MAX_NEW_CHARSETS_PER_HEADER 개까지 찾아본다
*/
function decoderFor(
label: string,
looked: Map<string, TextDecoder | null>,
): TextDecoder | null {
const charset = label.split('*')[0].trim().toLowerCase();
const cached = decoderCache.get(charset) ?? looked.get(charset);
if (cached !== undefined) return cached;
if (looked.size >= MAX_NEW_CHARSETS_PER_HEADER) return null;
const decoder = createDecoder(charset);
looked.set(charset, decoder);
if (decoder !== null && decoderCache.size < DECODER_CACHE_LIMIT) {
decoderCache.set(charset, decoder);
}
return decoder;
}
/** 그 글자가 인코딩된 글의 경계인가 — 처음 · 끝은 NaN (경계다) */
function isWordBoundary(
code: number,
boundaries: ReadonlySet<number>,
): boolean {
return Number.isNaN(code) || boundaries.has(code);
}
/** encoded-word 의 글 칸 → 바이트 (B: base64 · Q: `_` 는 공백, `=XX` 는 그 바이트) */
function encodedWordBytes(encoding: string, text: string): Buffer {
if (encoding === 'B' || encoding === 'b') return Buffer.from(text, 'base64');
const bytes: number[] = [];
for (let index = 0; index < text.length; index += 1) {
const code = text.charCodeAt(index);
const escape = text.slice(index + 1, index + 3);
if (code === Q_UNDERSCORE) {
bytes.push(Q_SPACE);
} else if (code === Q_ESCAPE_MARK && Q_ESCAPE.test(escape)) {
bytes.push(Number.parseInt(escape, 16));
index += 2;
} else if (code < ASCII_LIMIT) {
bytes.push(code);
} else {
// Q 글 칸에 올 수 없는 글자 — 그 글자의 UTF-8 바이트로 (깨진 메일도 읽을 만큼은 읽는다)
bytes.push(...Buffer.from(text[index], 'utf8'));
}
}
return Buffer.from(bytes);
}
/** 공백만 두고 이어진 같은 문자셋의 encoded-word 들 — 바이트를 이어 붙여 한 번에 푼다 (한 글자를 두 낱말에 나눈 메일도 읽게) */
interface EncodedRun {
readonly decoder: TextDecoder;
readonly bytes: Buffer[];
}
/**
* 원래 머리글(엔진이 주는 그대로 — 앞의 공백 · 접힌 줄 · encoded-word)을 엔진의 Sieve(`header :contains`)가 견주는 글로 푼다 —
* 2026-10-29 인증 발송 19통으로 엔진과 같은 판정을 확인한 규칙 (filter-run-texts.spec.ts 의 H · G 표):
* 1. 접힌 줄(줄바꿈 + 뒤의 공백 · 탭 전부)은 띄어쓰기 하나로 편다. 접지 않은 탭 · 이어진 공백은 그대로 둔다
* 2. 인코딩된 글은 **홀로 선 것만** 그 문자셋으로 푼다 — 앞이 처음 · 공백 · 탭 · CR · LF · `"` · `,` 이고 뒤가 끝 · 공백 · 탭 · CR · LF · `"` 일 때
* (앞뒤가 다르다 — `,=?…?= <a@b>` 는 풀고 `=?…?=,"가나"` 는 풀지 않는다).
* 글자 · `(` 뒤(`abc=?…?=` · `(=?…?=)`), 글자 · `<` · `,` 앞(`=?…?=abc` · `=?…?=<a@b>` · `=?…?=,`)이면 풀지 않고 그대로 둔다.
* 공백만 사이에 둔 인코딩된 글끼리는 공백을 버리고 붙인다(같은 문자셋이면 바이트를 이어 푼다)
* 3. 앞뒤 공백을 뺀다. 따옴표 · 꺾쇠 · 주소는 그대로 둔다 (`"Kim, Ara" <kim@…>` — 계약 2절 "이미 받은 메일에 적용"의 판정)
* 모르는 문자셋의 낱말 · 모양이 틀린 낱말도 풀지 않고 그대로 둔다
*/
export function decodeHeaderText(raw: string): string {
const unfolded = raw.replace(FOLDED_LINE_BREAK, ' ');
const looked = new Map<string, TextDecoder | null>();
let decoded = '';
let copied = 0;
let run: EncodedRun | null = null;
for (const match of unfolded.matchAll(ENCODED_WORD)) {
const [word, charset, encoding, text] = match;
const between = unfolded.slice(copied, match.index);
copied = match.index + word.length;
const standalone =
isWordBoundary(
unfolded.charCodeAt(match.index - 1),
WORD_START_BOUNDARY_CODES,
) &&
isWordBoundary(
unfolded.charCodeAt(match.index + word.length),
WORD_END_BOUNDARY_CODES,
);
const decoder = standalone ? decoderFor(charset, looked) : null;
if (decoder === null) {
if (run !== null) decoded += run.decoder.decode(Buffer.concat(run.bytes));
run = null;
decoded += between + word;
continue;
}
const bytes = encodedWordBytes(encoding, text);
const adjacent = run !== null && LINEAR_SPACE_ONLY.test(between);
if (run !== null && adjacent && run.decoder.encoding === decoder.encoding) {
run.bytes.push(bytes);
continue;
}
if (run !== null) decoded += run.decoder.decode(Buffer.concat(run.bytes));
if (!adjacent) decoded += between;
run = { decoder, bytes: [bytes] };
}
if (run !== null) decoded += run.decoder.decode(Buffer.concat(run.bytes));
return (decoded + unfolded.slice(copied)).trim();
}
@@ -32,6 +32,9 @@ import { encodeHeaderWords, plainTextMimeEntity } from './mime-text';
* - 대소문자는 ASCII 만 무시한다 (엔진 기본 비교)
* - 글은 모두 따옴표 문자열 — `\` 와 `"` 만 이스케이프한다. `${` 는 받지 않는다(요청 검증) — 엔진이 늘 변수로 펼친다
* - 줄바꿈은 CRLF. 규칙 글이 없으면(켠 규칙도 부재중도 없음) 머리 주석만 남는다
*
* 이미 받은 메일에 적용할 때(7단계)는 서버가 같은 판정을 한다 — **FIELD_TESTS 는 mail-filter-match.ts 의 판정과 짝이다**
* (견줄 곳 · 대소문자 · 포함하지 않음의 뜻 · 묶는 방법). 한쪽을 바꾸면 다른 쪽(과 견줄 글 뽑기 filter-run-texts.ts)도 맞춘다
*/
/**
@@ -69,7 +72,10 @@ const INDENT = ' ';
const DESCRIPTION_LINE =
'# ARA SYSTEM 웹메일의 필터 · 부재중 자동 응답 — 웹메일이 아래 원본(JSON)으로 만든다. 여기서 고친 것은 웹메일에서 저장할 때 덮어쓴다.';
/** 견줄 곳 → Sieve 검사 (찾을 글을 받는다) — 표시 이름 · 주소 · 인코딩된 한국어 제목 · 본문 모두 맞는다 (같은 날 확인) */
/**
* 견줄 곳 → Sieve 검사 (찾을 글을 받는다) — 표시 이름 · 주소 · 인코딩된 한국어 제목 · 본문 모두 맞는다 (같은 날 확인).
* 이미 받은 메일의 판정(mail-filter-match.ts · filter-run-texts.ts)과 짝이다 — 여기를 바꾸면 그쪽도 맞춘다
*/
const FIELD_TESTS: Record<MailFilterField, (value: string) => string> = {
from: (value) => `header :contains "from" ${value}`,
to: (value) => `header :contains ["to", "cc"] ${value}`,
@@ -0,0 +1,41 @@
import { Logger, Module } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { JmapClient } from './jmap/jmap.client';
import {
MAIL_SETTINGS,
type MailSettings,
buildMailSettings,
isMailEngineConfigured,
} from './mail.settings';
function createMailSettings(config: ConfigService): MailSettings {
const settings = buildMailSettings((key) => config.get<string>(key));
if (!isMailEngineConfigured(settings)) {
// 비운영에서만 여기까지 온다 (운영은 env 검증이 기동을 막는다) — CI e2e 가 이 경로다
new Logger('MailEngineModule').warn(
'메일 엔진 설정(MAIL_JMAP_URL·MAIL_DOMAIN·MAIL_GATEWAY_*)이 없어 로그인·메일·주소록 API 가 MAIL_001 을 돌려줍니다.',
);
}
return settings;
}
/**
* 메일 엔진 연결 — 설정(MAIL_SETTINGS)과 JMAP 클라이언트(JmapClient) 하나를 메일 모듈과 주소록 모듈이 함께 쓴다.
* 두 모듈이 서로를 가져오지 않게(메일 → 주소록 방향만) 여기로 뺐다 (8단계).
*
* ⚠️ JmapClient 는 엔진 어댑터(`modules/mail/jmap/` 의 구현)만 쓴다 — 서비스 · 컨트롤러는 포트(MAIL_PROVIDER · CONTACTS_PROVIDER ·
* DIRECTORY_PROVIDER)만 안다 (JMAP 타입 · 호출은 jmap/ 밖으로 내보내지 않는다)
*/
@Module({
providers: [
{
provide: MAIL_SETTINGS,
inject: [ConfigService],
useFactory: createMailSettings,
},
JmapClient,
],
exports: [MAIL_SETTINGS, JmapClient],
})
export class MailEngineModule {}
@@ -0,0 +1,149 @@
import {
asciiLowerCase,
filterFields,
judgeFilter,
type MailFilterTexts,
} from './mail-filter-match';
import type {
MailFilterCondition,
MailFilterCriteria,
MailFilterField,
} from './mail.types';
/** 견줄 글 — 주지 않은 곳은 빈 목록, 본문은 읽은 것으로(빈 목록) */
const texts = (overrides: Partial<MailFilterTexts> = {}): MailFilterTexts => ({
from: [],
to: [],
subject: [],
body: [],
...overrides,
});
const has = (field: MailFilterField, value: string): MailFilterCondition => ({
field,
operator: 'contains',
value,
});
const lacks = (field: MailFilterField, value: string): MailFilterCondition => ({
field,
operator: 'notContains',
value,
});
const all = (...conditions: MailFilterCondition[]): MailFilterCriteria => ({
match: 'all',
conditions,
});
const any = (...conditions: MailFilterCondition[]): MailFilterCriteria => ({
match: 'any',
conditions,
});
describe('judgeFilter — 엔진의 Sieve 와 같은 판정 (계약 2절 "이미 받은 메일에 적용")', () => {
const mail = texts({
from: ['"Kim, Ara" <kimara@hanbit-partners.co.kr>'],
to: ['seojun.park@arasystem.test', '참조인 <doyun.lee@arasystem.test>'],
subject: ['[견적] 10월 견적서 송부'],
body: ['안녕하세요.\n견적서를 보냅니다.', '첨부 메모'],
});
it('모든 메일(always)은 조건 없이 맞는다 — 견줄 글이 비어 있어도, 본문을 읽지 않았어도', () => {
const always: MailFilterCriteria = { match: 'always', conditions: [] };
expect(judgeFilter(always, texts())).toBe(true);
expect(judgeFilter(always, texts({ body: null }))).toBe(true);
});
it('포함 — 머리글 · 본문 부분 중 하나라도 그 글을 담으면 맞는다 (따옴표 · 꺾쇠까지 원래 글로 견준다)', () => {
expect(judgeFilter(all(has('from', '"Kim')), mail)).toBe(true);
expect(judgeFilter(all(has('from', '@hanbit-partners')), mail)).toBe(true);
expect(judgeFilter(all(has('to', 'doyun.lee@')), mail)).toBe(true);
expect(judgeFilter(all(has('to', '참조인 <doyun')), mail)).toBe(true);
expect(judgeFilter(all(has('subject', '견적서')), mail)).toBe(true);
expect(judgeFilter(all(has('body', '첨부 메모')), mail)).toBe(true);
// 부분을 이어 붙여 견주지 않는다 — 한 부분 안에서 맞아야 한다
expect(judgeFilter(all(has('body', '보냅니다.첨부')), mail)).toBe(false);
// 줄바꿈을 띄어쓰기로 보지 않는다
expect(judgeFilter(all(has('body', '안녕하세요. 견적서')), mail)).toBe(
false,
);
});
it('대소문자는 ASCII 만 무시한다 — 엔진 기본 비교(i;ascii-casemap). 라틴 확장 · 그리스 · 전각 영문은 그대로 견준다', () => {
expect(judgeFilter(all(has('from', 'HANBIT-Partners')), mail)).toBe(true);
const unicode = texts({ subject: ['Ärger σΣ abc'] });
expect(judgeFilter(all(has('subject', 'ärger')), unicode)).toBe(false);
expect(judgeFilter(all(has('subject', 'ÄRGER')), unicode)).toBe(true);
expect(judgeFilter(all(has('subject', 'σσ')), unicode)).toBe(false);
expect(judgeFilter(all(has('subject', 'ABC')), unicode)).toBe(false);
expect(asciiLowerCase('ABC 가나 Ä Σ Z')).toBe('abc 가나 Ä Σ Z');
});
it('포함하지 않음 — 그 글을 담은 머리글 · 부분이 하나도 없으면 맞는다. 머리글 · 본문이 아예 없어도 맞는다 (`not header …`)', () => {
expect(judgeFilter(all(lacks('from', '광고')), mail)).toBe(true);
expect(judgeFilter(all(lacks('to', 'doyun')), mail)).toBe(false);
const bare = texts();
for (const field of ['from', 'to', 'subject', 'body'] as const) {
expect(judgeFilter(all(lacks(field, 'x')), bare)).toBe(true);
expect(judgeFilter(all(has(field, 'x')), bare)).toBe(false);
}
});
it('모두(all) 는 조건이 모두 맞아야, 하나라도(any) 는 하나만 맞으면 된다', () => {
expect(
judgeFilter(all(has('from', 'hanbit'), has('subject', '견적')), mail),
).toBe(true);
expect(
judgeFilter(all(has('from', 'hanbit'), has('subject', '회의')), mail),
).toBe(false);
expect(
judgeFilter(any(has('from', '광고'), has('subject', '견적')), mail),
).toBe(true);
expect(
judgeFilter(any(has('from', '광고'), has('subject', '회의')), mail),
).toBe(false);
});
describe('본문을 아직 읽지 않았을 때 (texts.body 가 null)', () => {
const headersOnly = { ...mail, body: null };
it('본문 조건으로 판정이 갈리면 null — 어댑터가 본문을 읽어 다시 판정한다', () => {
expect(judgeFilter(all(has('body', '견적서')), headersOnly)).toBeNull();
expect(
judgeFilter(
all(has('from', 'hanbit'), has('body', '견적서')),
headersOnly,
),
).toBeNull();
expect(
judgeFilter(
any(has('from', '광고'), lacks('body', '견적서')),
headersOnly,
),
).toBeNull();
});
it('머리글로 판정이 나면 본문 없이 끝낸다 — 모두는 하나가 틀리면 false, 하나라도는 하나가 맞으면 true', () => {
expect(
judgeFilter(
all(has('body', '견적서'), has('from', '광고')),
headersOnly,
),
).toBe(false);
expect(
judgeFilter(
any(has('body', '견적서'), has('from', 'hanbit')),
headersOnly,
),
).toBe(true);
});
});
it('filterFields — 규칙이 견주는 곳만 (모든 메일이면 없음)', () => {
expect([
...filterFields(
all(has('from', 'a'), lacks('body', 'b'), has('from', 'c')),
),
]).toEqual(['from', 'body']);
expect(filterFields({ match: 'always', conditions: [] }).size).toBe(0);
});
});
@@ -0,0 +1,90 @@
import type {
MailFilterCondition,
MailFilterCriteria,
MailFilterField,
} from './mail.types';
/**
* 메일 필터의 판정 — 메일 한 통의 견줄 글이 규칙의 조건에 맞는가 (계약 2절 "이미 받은 메일에 적용"의 판정 — 7단계). 메일 엔진과 무관한 순수 함수.
*
* 새로 오는 메일은 엔진이 Sieve 로 판정한다 — **jmap/sieve-script.ts 의 FIELD_TESTS 와 짝이다** (한쪽을 바꾸면 다른 쪽도 맞춘다).
* 이미 받은 메일도 같은 판정이 되게 Sieve 의 규칙을 그대로 옮겼다 (2026-10-29 실배달 20통으로 엔진과 같은 판정을 확인 — jmap/filter-run-texts.spec.ts):
* - from → `header :contains "from"`: From 머리글마다 — 접힌 줄을 펴고 RFC 2047 을 풀고 앞뒤 공백을 뺀 원래 글(이름 · 따옴표 · 꺾쇠 · 주소)
* - to → `header :contains ["to", "cc"]`: To · Cc 머리글마다 — 하나라도 맞으면 맞다
* - subject → `header :contains "subject"`: 풀어 쓴 제목
* - body → `body :text :contains`: 글자 부분(text/* — 텍스트 첨부 포함)마다. HTML 은 글자만 (html/html-text.ts)
* - 대소문자는 ASCII 만 무시한다 — 엔진 기본 비교(i;ascii-casemap). 한글 · 전각 글자는 그대로 견준다
* - 포함하지 않음은 `not` — 그 머리글 · 본문이 하나도 없으면 참이다
* - 모두(all) = allof · 하나라도(any) = anyof · 모든 메일(always) = true
* 스팸 판정 표시는 보지 않는다 — 받은편지함에 있는 메일은 사용자가 이미 본 메일이다(계약). 견줄 글을 엔진 메일에서 뽑는 일은 어댑터가 한다
* (jmap/filter-run-texts.ts)
*/
/** 메일 한 통의 견줄 글 — 견줄 곳마다 글 목록 (머리글이 여럿이면 여럿) */
export interface MailFilterTexts {
readonly from: readonly string[];
readonly to: readonly string[];
/** 제목 — 없으면 빈 배열 */
readonly subject: readonly string[];
/** 본문의 글자 부분마다 — null 이면 아직 읽지 않았다(본문 조건의 판정을 미룬다 — judgeFilter 가 null) */
readonly body: readonly string[] | null;
}
/** ASCII 대문자만 소문자로 — 엔진의 i;ascii-casemap 과 같다 (한글 · 그리스 문자 · 전각 영문은 그대로) */
const ASCII_UPPERCASE = /[A-Z]+/g;
export function asciiLowerCase(text: string): string {
return text.replace(ASCII_UPPERCASE, (run) => run.toLowerCase());
}
/** 규칙이 견주는 곳 — 어댑터가 엔진에서 읽을 것을 고른다 (모든 메일이면 없음) */
export function filterFields(
criteria: MailFilterCriteria,
): ReadonlySet<MailFilterField> {
return new Set(criteria.conditions.map((condition) => condition.field));
}
/**
* 규칙에 맞는가 — 맞으면 true, 아니면 false. 본문을 아직 읽지 않았고(texts.body 가 null) 본문 조건으로 판정이 갈리면 null
* (모두 — 머리글 조건이 하나라도 틀리면 false · 하나라도 — 머리글 조건이 하나라도 맞으면 true 로 본문을 읽지 않고 끝낸다)
*/
export function judgeFilter(
criteria: MailFilterCriteria,
texts: MailFilterTexts,
): boolean | null {
if (criteria.match === 'always') return true;
const lowered = new Map<MailFilterField, readonly string[]>();
const loweredOf = (field: MailFilterField): readonly string[] | null => {
const known = lowered.get(field);
if (known) return known;
const values = texts[field];
if (values === null) return null;
const result = values.map(asciiLowerCase);
lowered.set(field, result);
return result;
};
let undecided = false;
for (const condition of criteria.conditions) {
const verdict = conditionVerdict(condition, loweredOf(condition.field));
if (verdict === null) {
undecided = true;
continue;
}
if (criteria.match === 'all' && !verdict) return false;
if (criteria.match === 'any' && verdict) return true;
}
if (undecided) return null;
return criteria.match === 'all';
}
/** 조건 하나 — 견줄 글(소문자로 바꾼 것)이 없으면(아직 읽지 않음) null */
function conditionVerdict(
condition: MailFilterCondition,
values: readonly string[] | null,
): boolean | null {
if (values === null) return null;
const needle = asciiLowerCase(condition.value);
const found = values.some((value) => value.includes(needle));
return condition.operator === 'contains' ? found : !found;
}
@@ -1,4 +1,13 @@
import { Body, Controller, Get, Put, UseGuards } from '@nestjs/common';
import {
Body,
Controller,
Get,
HttpCode,
HttpStatus,
Post,
Put,
UseGuards,
} from '@nestjs/common';
import { ApiCookieAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import {
@@ -12,6 +21,11 @@ import {
import { JwtAuthGuard } from '@/common/guards/jwt-auth.guard';
import { MailFilterSetDto, MailFilterUpdateDto } from './dto/mail-filter.dto';
import {
MailFilterRunDto,
MailFilterRunRequestDto,
} from './dto/mail-filter-run.dto';
import {
MAIL_FILTER_RUNS_ROUTE,
MAIL_FILTERS_ROUTE,
MAX_MAIL_FILTER_CONDITIONS,
MAX_MAIL_FILTER_NAME_LENGTH,
@@ -24,7 +38,8 @@ import { MailService } from './mail.service';
const APPLY_NOTE =
'새로 온 메일을 위의 규칙부터 차례로 견주고, 맞는 규칙의 할 일을 모두 한다(읽음 · 중요는 겹쳐 붙는다). ' +
'메일함으로 옮기는 규칙에 맞으면 거기서 멈춘다. 전달은 한 통에 한 곳(엔진 한도 — 두 번째는 엔진이 버린다)이고 사본을 남긴다. ' +
'스팸으로 판정된 메일에는 아무 규칙도 적용하지 않는다(부재중 응답도 하지 않는다). 꺼 둔 규칙 · 이미 받은 메일에는 적용하지 않는다. ' +
'스팸으로 판정된 메일에는 아무 규칙도 적용하지 않는다(부재중 응답도 하지 않는다). 꺼 둔 규칙에는 적용하지 않는다. ' +
'이미 받은 메일에는 저절로 적용하지 않는다 — 저장한 뒤 POST /api/mail-filter-runs 로 받은편지함에 적용한다. ' +
'필터와 부재중 자동 응답은 메일 엔진의 규칙 하나에 함께 저장된다.';
// 메일 필터 (계약 2절 "메일 필터", 6단계)
@@ -86,3 +101,50 @@ export class MailFilterController {
return this.mail.updateMailFilters(user, dto);
}
}
// 이미 받은 메일에 필터 적용 (계약 2절 "이미 받은 메일에 적용", 7단계) — 받은편지함만, 요청의 규칙 하나로
@ApiTags('Mail')
@ApiCookieAuth()
@UseGuards(JwtAuthGuard)
@ApiErrorResponse('AUTH_001', '로그인 필요 (쿠키 없음·만료)')
@Controller(MAIL_FILTER_RUNS_ROUTE)
export class MailFilterRunController {
constructor(private readonly mail: MailService) {}
@Post()
@HttpCode(HttpStatus.OK)
@ApiOperation({
summary: '이미 받은 메일에 필터 적용 (받은편지함)',
description:
'받은편지함에 이미 있는 메일에 규칙 하나를 적용한다 — 저장해 둔 필터를 읽지 않고 본문의 규칙을 쓴다(방금 저장한 그 규칙을 보낸다). ' +
'받은 시각이 최근인 메일부터, 새로 오는 메일의 엔진 규칙과 같은 판정으로 읽음 · 중요 표시를 붙이고 옮긴다(맞은 메일을 차례마다 엔진 요청 한 번으로). ' +
'확인하는 사이 받은편지함에서 빠진 메일은 건너뛴다. 옮길 메일함이 받은편지함이거나 없는 메일함(지워짐)이면 옮기지 않고 표시만 한다. ' +
'전달은 하지 않는다 · 영구 삭제는 하지 않는다(휴지통으로 옮기는 규칙도 옮기기다) · 스팸 판정 표시는 보지 않는다. ' +
`조건은 필터 규칙과 같은 검사다(all · any 는 1~${MAX_MAIL_FILTER_CONDITIONS}개, always 는 없음 · 찾을 글은 앞뒤 공백을 뺀 1~${MAX_MAIL_FILTER_VALUE_LENGTH}자 · 제어 문자 · "\${" 없음). ` +
'한 요청은 25초 안에 끝내고 cursor 를 돌려준다 — null 이 될 때까지 같은 본문에 받은 cursor 를 넣어 다시 보낸다. ' +
'같은 직원의 적용은 한 번에 하나다 — 진행 중이면 엔진에 묻지 않고 429 REQ_001.',
})
@ApiStandardResponse(MailFilterRunDto, {
description:
'이번 요청에서 확인한 수 · 적용한 수 · 남은 수(어림) · 이어 할 자리(끝이면 null)',
})
@ApiErrorResponse(
'VAL_001',
`형식 · 한도(조건 ${MAX_MAIL_FILTER_CONDITIONS}개 · 찾을 글 ${MAX_MAIL_FILTER_VALUE_LENGTH}자) · 묶는 방법과 조건 수가 맞지 않음 · 할 일 없음 · 제어 문자 · "\${" · ` +
'보낸편지함 · 임시보관함으로 옮김 · 깨진 cursor · 다른 규칙의 cursor · 모르는 필드(actions 의 forwardTo 포함) — 적용하지 않았다',
)
@ApiErrorResponse(
'REQ_001',
'같은 직원의 적용이 진행 중(다른 탭 · PC) — 엔진에 묻지 않았다',
)
@ApiErrorResponse(
'MAIL_001',
'메일 엔진 장애 · 하나도 확인하지 못하고 멈춤(시간 · 확인하는 사이 받은편지함이 계속 바뀜). 도중의 엔진 장애면 일부를 적용했을 수 있다 — 마지막으로 받은 cursor 로 다시 보내면 된다(다시 해도 같다)',
)
run(
@CurrentUser() user: AuthenticatedUser,
@Body() dto: MailFilterRunRequestDto,
) {
return this.mail.runMailFilter(user, dto);
}
}
@@ -4,6 +4,7 @@ import {
} from './mail.constants';
import {
findFilterRulesProblem,
findFilterRunProblem,
hasFilterAction,
hasSieveVariable,
} from './mail-filters';
@@ -183,3 +184,60 @@ describe('hasSieveVariable · hasFilterAction', () => {
).toBe(true);
});
});
describe('findFilterRunProblem — 이미 받은 메일에 적용 (계약 2절 "이미 받은 메일에 적용", 7단계)', () => {
const run = {
match: 'any' as const,
conditions: [
{
field: 'subject' as const,
operator: 'contains' as const,
value: '견적',
},
],
actions: { moveToMailboxId: 'j', markAsRead: false, flag: false },
};
it('규칙 저장과 같은 규칙 — 맞으면 null, 모든 메일은 조건 없이', () => {
expect(findFilterRunProblem(run)).toBeNull();
expect(
findFilterRunProblem({ ...run, match: 'always', conditions: [] }),
).toBeNull();
});
it('묶는 방법과 조건 수가 맞지 않음 · 조건 6개 · 찾을 글 · 할 일 없음(전달이 없어 옮기기 · 읽음 · 중요 가운데 하나) · 옮길 메일함 id', () => {
expect(findFilterRunProblem({ ...run, match: 'always' })).toBe(
'conditions-for-match',
);
expect(findFilterRunProblem({ ...run, conditions: [] })).toBe(
'conditions-for-match',
);
expect(
findFilterRunProblem({
...run,
conditions: Array.from(
{ length: MAX_MAIL_FILTER_CONDITIONS + 1 },
() => run.conditions[0],
),
}),
).toBe('too-many-conditions');
expect(
findFilterRunProblem({
...run,
conditions: [{ ...run.conditions[0], value: '광고${x}' }],
}),
).toBe('condition-value-variable');
expect(
findFilterRunProblem({
...run,
actions: { moveToMailboxId: null, markAsRead: false, flag: false },
}),
).toBe('no-action');
expect(
findFilterRunProblem({
...run,
actions: { ...run.actions, moveToMailboxId: '받은 편지함' },
}),
).toBe('move-mailbox-id');
});
});
+44 -16
View File
@@ -12,16 +12,20 @@ import {
import type {
MailFilterActions,
MailFilterCondition,
MailFilterCriteria,
MailFilterField,
MailFilterMatch,
MailFilterOperator,
MailFilterRule,
MailFilterRunActions,
MailFilterRunRequest,
} from './mail.types';
/**
* 메일 필터 규칙 (계약 2절 "메일 필터"의 타입) — 메일 엔진과 무관한 순수 규칙.
* 요청 검증(dto/mail-filter.dto.ts)이 칸마다 형식을 보고, 서비스가 칸 사이의 규칙을 이것으로 한 번 더 본다.
* 어댑터가 엔진에 저장해 둔 원본을 읽을 때(jmap/filter-source.ts)도 같은 규칙으로 본다 — 어긋나면 깨진 원본이다.
* 이미 받은 메일에 적용하는 요청(dto/mail-filter-run.dto.ts — 7단계)도 조건 · 할 일을 같은 규칙으로 본다 (findFilterRunProblem).
*/
/** 견줄 곳 · 견주는 방법 · 묶는 방법 — 요청 검증과 원본 읽기가 같이 쓴다 */
@@ -58,13 +62,15 @@ export function hasSieveVariable(value: string): boolean {
return value.includes(SIEVE_VARIABLE_MARKER);
}
/** 할 일이 하나라도 있는가 (옮기기 · 읽음 · 중요 · 전달) */
export function hasFilterAction(actions: MailFilterActions): boolean {
/** 할 일이 하나라도 있는가 (옮기기 · 읽음 · 중요 · 전달 — 이미 받은 메일에 적용할 때는 전달이 없다) */
export function hasFilterAction(
actions: MailFilterRunActions & Partial<Pick<MailFilterActions, 'forwardTo'>>,
): boolean {
return (
actions.moveToMailboxId !== null ||
actions.markAsRead ||
actions.flag ||
actions.forwardTo !== null
(actions.forwardTo ?? null) !== null
);
}
@@ -79,10 +85,16 @@ function conditionProblem(condition: MailFilterCondition): string | null {
return null;
}
/** 할 일 — 하나 이상, 옮길 메일함 id 형식, 전달 주소 모양 (사내 주소인지 · 자기 주소가 아닌지는 서비스가 본다) */
function actionsProblem(actions: MailFilterActions): string | null {
/**
* 할 일 — 하나 이상, 옮길 메일함 id 형식, 전달 주소 모양 (사내 주소인지 · 자기 주소가 아닌지는 서비스가 본다).
* 이미 받은 메일에 적용하는 요청에는 전달이 없다
*/
function actionsProblem(
actions: MailFilterRunActions & Partial<Pick<MailFilterActions, 'forwardTo'>>,
): string | null {
if (!hasFilterAction(actions)) return 'no-action';
const { moveToMailboxId, forwardTo } = actions;
const { moveToMailboxId } = actions;
const forwardTo = actions.forwardTo ?? null;
if (moveToMailboxId !== null && !MAIL_ID_PATTERN.test(moveToMailboxId)) {
return 'move-mailbox-id';
}
@@ -99,6 +111,20 @@ function actionsProblem(actions: MailFilterActions): string | null {
return null;
}
/** 묶는 방법에 맞는 조건 수(모든 메일은 0개, 나머지는 1~5개)와 조건마다의 찾을 글 — 어긋난 첫 까닭, 맞으면 null */
function criteriaProblem(criteria: MailFilterCriteria): string | null {
const count = criteria.conditions.length;
if (criteria.match === 'always' ? count !== 0 : count < 1) {
return 'conditions-for-match';
}
if (count > MAX_MAIL_FILTER_CONDITIONS) return 'too-many-conditions';
for (const condition of criteria.conditions) {
const problem = conditionProblem(condition);
if (problem) return problem;
}
return null;
}
/** 규칙 하나 — id · 이름 · 묶는 방법에 맞는 조건 수 · 조건 · 할 일 */
function ruleProblem(rule: MailFilterRule): string | null {
if (!MAIL_FILTER_RULE_ID_PATTERN.test(rule.id)) return 'rule-id';
@@ -108,16 +134,18 @@ function ruleProblem(rule: MailFilterRule): string | null {
) {
return 'rule-name';
}
const count = rule.conditions.length;
if (rule.match === 'always' ? count !== 0 : count < 1) {
return 'conditions-for-match';
}
if (count > MAX_MAIL_FILTER_CONDITIONS) return 'too-many-conditions';
for (const condition of rule.conditions) {
const problem = conditionProblem(condition);
if (problem) return problem;
}
return actionsProblem(rule.actions);
return criteriaProblem(rule) ?? actionsProblem(rule.actions);
}
/**
* 이미 받은 메일에 적용하는 요청이 계약에 맞는지 (계약 2절 "이미 받은 메일에 적용") — 어긋난 첫 까닭, 맞으면 null.
* 규칙 저장과 같은 규칙이다 — 묶는 방법에 맞는 조건 수 · 찾을 글 · 할 일 하나 이상(전달은 없다) · 옮길 메일함 id 형식.
* 옮길 메일함의 역할(보낸편지함 · 임시보관함이 아님)은 서비스가 메일함 목록으로 본다
*/
export function findFilterRunProblem(
request: Pick<MailFilterRunRequest, 'match' | 'conditions' | 'actions'>,
): string | null {
return criteriaProblem(request) ?? actionsProblem(request.actions);
}
/**
@@ -111,6 +111,18 @@ export const MAIL_FILTER_RULE_ID_PATTERN = /^[A-Za-z0-9_-]{1,64}$/;
/** 필터 지문(version) — 서버가 만든 값(base64url)을 그대로 돌려받는다 */
export const MAIL_FILTER_VERSION_PATTERN = /^[A-Za-z0-9_-]{1,128}$/;
/**
* 이미 받은 메일에 필터 적용 라우트 경로 (`/api/mail-filter-runs`) — 계약 2절 "이미 받은 메일에 적용" (7단계)
*/
export const MAIL_FILTER_RUNS_ROUTE = 'mail-filter-runs';
/**
* 이미 받은 메일에 필터 적용의 이어 할 자리(cursor) — 서버가 만든 값(base64url)을 그대로 돌려받는다.
* 서버가 만드는 값은 500자 안쪽이다(메일 id 255자까지 + 자리 + 규칙 지문 — jmap/filter-run-cursor.ts) — 넉넉히 1,024자까지 받는다
*/
export const MAX_MAIL_FILTER_RUN_CURSOR_LENGTH = 1_024;
export const MAIL_FILTER_RUN_CURSOR_PATTERN = /^[A-Za-z0-9_-]+$/;
/**
* 메일 엔진(Stalwart)이 규칙 글 안에서 변수로 펼치는 표시 — require 와 상관없이 늘 펼친다(2026-10-28 확인).
* 펼쳐져 빈 값이 되면 `:contains` 가 모든 메일에 맞으므로 찾을 글 · 전달 주소에 쓰지 않는다 (계약 2절 "만드는 Sieve")
+23 -31
View File
@@ -1,4 +1,4 @@
import { Logger, Module } from '@nestjs/common';
import { Module } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import {
MulterModule,
@@ -6,29 +6,29 @@ import {
} from '@nestjs/platform-express';
import { BusinessException } from '@/common/errors/business.exception';
import { ContactsModule } from '@/modules/contacts/contacts.module';
import { MailDirectoryModule } from '@/modules/mail-directory/mail-directory.module';
import { MailSignatureModule } from '@/modules/mail-signature/mail-signature.module';
import { isAllowedAttachment } from './attachment.policy';
import { DraftController } from './draft.controller';
import { JmapClient } from './jmap/jmap.client';
import { JmapFilterRunner } from './jmap/jmap-filter-run';
import { JmapFilterScriptStore } from './jmap/jmap-filter-script';
import { JmapMailProvider } from './jmap/jmap-mail.provider';
import { MailAttachmentController } from './mail-attachment.controller';
import { MailEventsController } from './mail-events.controller';
import { MailFilterController } from './mail-filter.controller';
import {
MailFilterController,
MailFilterRunController,
} from './mail-filter.controller';
import { MailEventsService } from './mail-events.service';
import { MailEngineModule } from './mail-engine.module';
import {
MailUploadTempCleanup,
mailUploadStorage,
} from './mail-upload-storage';
import { MAIL_PROVIDER } from './mail.provider';
import { MailService } from './mail.service';
import {
MAIL_SETTINGS,
type MailSettings,
buildMailSettings,
isMailEngineConfigured,
} from './mail.settings';
import { type MailSettings, buildMailSettings } from './mail.settings';
import {
MailboxController,
MailQuotaController,
@@ -44,17 +44,6 @@ function readMailSettings(config: ConfigService): MailSettings {
return buildMailSettings((key) => config.get<string>(key));
}
function createMailSettings(config: ConfigService): MailSettings {
const settings = readMailSettings(config);
if (!isMailEngineConfigured(settings)) {
// 비운영에서만 여기까지 온다 (운영은 env 검증이 기동을 막는다) — CI e2e 가 이 경로다
new Logger('MailModule').warn(
'메일 엔진 설정(MAIL_JMAP_URL·MAIL_DOMAIN·MAIL_GATEWAY_*)이 없어 로그인·메일 API 가 MAIL_001 을 돌려줍니다.',
);
}
return settings;
}
/**
* 메일 첨부 업로드 설정 — 임시 디스크에 받아 메일 엔진으로 흘려보내고 지운다 (mail-upload-storage.ts).
* 메모리에 통째로 올리지 않는다 (운영 메모리 한도 1GiB — 동시 업로드에 버티게).
@@ -88,10 +77,11 @@ function attachmentUploadOptions(config: ConfigService): MulterModuleOptions {
/**
* 메일 모듈 — 웹메일 API 와 메일 엔진 포트.
* 엔진을 바꿀 때는 `MAIL_PROVIDER` 의 구현(useClass)만 바꾼다.
* 인증 모듈이 직원 자격 확인에 쓰도록 포트와 설정을 내보낸다.
* 인증 모듈이 직원 자격 확인에 쓰도록 포트와 설정(MailEngineModule)을 내보낸다.
*
* 서명(그림 포함)은 MailSignatureModule, 받는 사람 추천의 사내 목록은 MailDirectoryModule 이 맡는다 —
* 두 모듈은 메일 모듈을 가져오지 않는다 (순환 없음).
* 서명(그림 포함)은 MailSignatureModule, 받는 사람 추천의 주소록 · 사내 목록은 ContactsModule(엔진의 주소록 · 계정 목록 — 8단계),
* 사내 목록을 읽지 못할 때 대신 쓰는 로그인 기록은 MailDirectoryModule 이 맡는다 — 세 모듈은 메일 모듈을 가져오지 않는다 (순환 없음).
* 엔진 연결(설정 · JmapClient)은 MailEngineModule 하나를 메일 · 주소록이 함께 쓴다.
*/
@Module({
imports: [
@@ -99,9 +89,13 @@ function attachmentUploadOptions(config: ConfigService): MulterModuleOptions {
inject: [ConfigService],
useFactory: attachmentUploadOptions,
}),
// 메일 엔진 설정 · JMAP 클라이언트 (주소록 모듈과 함께 쓴다)
MailEngineModule,
// 발송 때 서명 그림을 메일 안에 넣고, 임시 저장·발송 정화에 내 그림 id 를 쓴다 (계약 2절 "서명")
MailSignatureModule,
// 받는 사람 추천의 사내 목록 (계약 2절 "받는 사람 추천")
// 받는 사람 추천의 주소록 · 사내 목록 (계약 3절 "메일과 함께")
ContactsModule,
// 사내 목록을 읽지 못할 때 대신 쓰는 로그인 기록 (계약 2절 "받는 사람 추천")
MailDirectoryModule,
],
controllers: [
@@ -111,6 +105,7 @@ function attachmentUploadOptions(config: ConfigService): MulterModuleOptions {
MailEventsController,
MailVacationController,
MailFilterController,
MailFilterRunController,
MessageController,
MessageDeletionController,
MailAttachmentController,
@@ -118,14 +113,10 @@ function attachmentUploadOptions(config: ConfigService): MulterModuleOptions {
RecipientSuggestionController,
],
providers: [
{
provide: MAIL_SETTINGS,
inject: [ConfigService],
useFactory: createMailSettings,
},
JmapClient,
// 필터 · 부재중 자동 응답의 저장 자리(엔진의 Sieve 스크립트 하나) — 어댑터 안에서만 쓴다
JmapFilterScriptStore,
// 이미 받은 메일에 필터 적용(받은편지함을 차례로 판정 · 표시 · 옮기기 — 7단계) — 어댑터 안에서만 쓴다
JmapFilterRunner,
{ provide: MAIL_PROVIDER, useClass: JmapMailProvider },
MailService,
// 실시간 알림 — 직원마다 엔진 연결 하나를 나눠 쓰고, 서버 종료 때 열린 연결을 먼저 닫는다 (beforeApplicationShutdown)
@@ -133,6 +124,7 @@ function attachmentUploadOptions(config: ConfigService): MulterModuleOptions {
RecipientSuggestionService,
MailUploadTempCleanup,
],
exports: [MAIL_PROVIDER, MAIL_SETTINGS],
// 메일 엔진 설정(MAIL_SETTINGS)은 MailEngineModule 을 그대로 내보낸다 — 인증 모듈이 주소 정책에 쓴다
exports: [MAIL_PROVIDER, MailEngineModule],
})
export class MailModule {}
+22 -4
View File
@@ -9,11 +9,15 @@ import type {
MailboxBulkResult,
MailboxCreate,
MailboxDeletion,
MailFilterRunContext,
MailFilterRunOutcome,
MailFilterRunRequest,
MailFilterSet,
MailFilterUpdate,
MailState,
MailUsage,
MailVacation,
MailVacationState,
MailWatch,
MailWatchListener,
MessageDeletionResult,
@@ -106,15 +110,15 @@ export interface MailProvider {
/**
* 부재중 자동 응답 — 한 번도 설정하지 않았으면 끈 상태 · 빈 제목과 내용.
* 저장은 메일 필터와 같은 엔진 규칙에 둔다 (계약 2절 "메일 필터"의 저장 자리)
* 저장은 메일 필터와 같은 엔진 규칙에 둔다 (계약 2절 "메일 필터"의 저장 자리) — 그 규칙이 엔진에서 동작하는지(isActive)를 함께 (7단계)
*/
getVacation(user: string): Promise<MailVacation>;
getVacation(user: string): Promise<MailVacationState>;
/**
* 부재중 자동 응답을 통째로 바꾸고 저장한 값을 돌려준다 — 메일 필터 규칙은 그대로 둔다.
* 부재중 자동 응답을 통째로 바꾸고 저장한 값을 돌려준다 — 메일 필터 규칙은 그대로 둔다. 저장하면 다시 동작한다(isActive).
* 형식은 요청 검증이, 켤 때의 빈 값 · 길이 · 날짜 순서는 서비스가 본다. 함께 만든 엔진 규칙이 엔진 한도를 넘으면 MAIL_013
*/
setVacation(user: string, vacation: MailVacation): Promise<MailVacation>;
setVacation(user: string, vacation: MailVacation): Promise<MailVacationState>;
/** 메일 필터 (계약 2절 "메일 필터") — 규칙(차례대로) · 지금 규칙의 지문(version) · 동작하는지(isActive) */
getMailFilters(user: string): Promise<MailFilterSet>;
@@ -130,6 +134,20 @@ export interface MailProvider {
update: MailFilterUpdate,
): Promise<MailFilterSet>;
/**
* 받은편지함에 이미 있는 메일에 규칙 하나를 적용한다 (계약 2절 "이미 받은 메일에 적용" — 7단계) — 저장해 둔 필터가 아니라 요청의 규칙을 쓴다.
* 받은 시각이 최근인 메일부터, 새로 오는 메일의 엔진 규칙과 같은 판정으로(mail-filter-match.ts) 읽음 · 중요 표시를 붙이고 옮긴다.
* 한 요청의 시간 안에서 차례로 하고 이어 할 자리(cursor — 끝이면 null)를 돌려준다 — 메일함의 오래 걸리는 처리와 같은 상한 · 같은 규칙
* (진행한 것이 없이 멈추면 MAIL_001). 옮길 메일함이 받은편지함이거나 없으면 옮기지 않고 표시만 한다. 영구 삭제는 하지 않는다.
* cursor 가 깨졌거나 다른 규칙의 것이면 엔진에 묻기 전에 VAL_001. 규칙의 형식 · 내용 · 옮길 메일함의 역할은 서비스가 검사한 값이다.
* 받은편지함 · 옮길 메일함은 서비스가 읽은 메일함 목록(context.mailboxes)에서 찾고, 한 요청의 시간은 context.startedAt 부터 센다
*/
runMailFilter(
user: string,
request: MailFilterRunRequest,
context: MailFilterRunContext,
): Promise<MailFilterRunOutcome>;
/** 메일함 사용량과 한도 (한도가 없으면 limitBytes = null) */
getUsage(user: string): Promise<MailUsage>;
+280 -13
View File
@@ -6,6 +6,7 @@ import { BusinessException } from '@/common/errors/business.exception';
import { SIGNATURE_IMAGES_PER_SIGNATURE } from '@/modules/mail-signature/mail-signature.constants';
import type { MailSignatureService } from '@/modules/mail-signature/mail-signature.service';
import type { MailFilterUpdateDto } from './dto/mail-filter.dto';
import type { MailFilterRunRequestDto } from './dto/mail-filter-run.dto';
import type { DraftBodyDto, SendMessageDto } from './dto/send-message.dto';
import { MAX_HTML_PARSE_LENGTH } from './html/html-parse-budget';
import {
@@ -39,7 +40,11 @@ const settings: MailSettings = {
gatewayPassword: 'secret',
attachmentMaxBytes: 20 * 1024 * 1024,
attachmentTotalMaxBytes: 25 * 1024 * 1024,
blockedAccounts: new Set(['admin@arasystem.test']),
blockedAccounts: new Set([
'admin@arasystem.test',
'mail-directory@arasystem.test',
]),
directoryAccount: 'mail-directory@arasystem.test',
};
/** 포트의 각 메서드를 타입이 있는 jest.fn 속성으로 — 메서드가 아니라 속성이라 그대로 expect 에 넘길 수 있다 */
@@ -76,6 +81,7 @@ function fakeProvider(): ProviderMocks {
setVacation: mockOf<'setVacation'>(),
getMailFilters: mockOf<'getMailFilters'>(),
setMailFilters: mockOf<'setMailFilters'>(),
runMailFilter: mockOf<'runMailFilter'>(),
openMessageSource: mockOf<'openMessageSource'>(),
getUsage: mockOf<'getUsage'>(),
getAttachmentsTotalLimit:
@@ -648,12 +654,13 @@ describe('MailService', () => {
it('형식 규칙을 지나면 그대로 넘기고 엔진이 저장한 값을 돌려준다 — 로그에는 켬 · 끔만', async () => {
provider.setVacation.mockImplementation((_user, value) =>
Promise.resolve(value),
Promise.resolve({ ...value, isActive: true }),
);
await expect(service.updateVacation(user, vacation)).resolves.toEqual(
vacation,
);
await expect(service.updateVacation(user, vacation)).resolves.toEqual({
...vacation,
isActive: true,
});
expect(provider.setVacation).toHaveBeenCalledWith(user.email, vacation);
const [line] = logSpy.mock.calls[0] as [string];
expect(line).toContain('켬');
@@ -682,10 +689,13 @@ describe('MailService', () => {
subject: '',
textBody: '',
};
provider.setVacation.mockResolvedValue(neverConfigured);
provider.setVacation.mockResolvedValue({
...neverConfigured,
isActive: true,
});
await expect(
service.updateVacation(user, neverConfigured),
).resolves.toEqual(neverConfigured);
).resolves.toEqual({ ...neverConfigured, isActive: true });
});
it('켤 때 앞뒤 공백을 빼고 빈 제목 · 내용도 VAL_001 — 끈 채로는 받고, 저장은 쓴 그대로(공백을 지우지 않는다)', async () => {
@@ -702,7 +712,7 @@ describe('MailService', () => {
expect(provider.setVacation).not.toHaveBeenCalled();
provider.setVacation.mockImplementation((_user, value) =>
Promise.resolve(value),
Promise.resolve({ ...value, isActive: true }),
);
await service.updateVacation(user, {
...vacation,
@@ -758,7 +768,7 @@ describe('MailService', () => {
});
provider.setVacation.mockImplementation((_user, value) =>
Promise.resolve(value),
Promise.resolve({ ...value, isActive: true }),
);
// 한도까지는 켜도 받는다
const full = {
@@ -766,7 +776,10 @@ describe('MailService', () => {
subject: '가'.repeat(MAX_VACATION_SUBJECT_LENGTH),
textBody: '가'.repeat(MAX_VACATION_TEXT_LENGTH),
};
await expect(service.updateVacation(user, full)).resolves.toEqual(full);
await expect(service.updateVacation(user, full)).resolves.toEqual({
...full,
isActive: true,
});
// 꺼 두면 4단계에 저장해 둔 긴 글(영문 내용 1,000자 등)도 그대로 저장한다 — 끄기는 늘 된다
const off = {
...vacation,
@@ -774,7 +787,10 @@ describe('MailService', () => {
subject: 'x'.repeat(MAX_VACATION_SUBJECT_LENGTH + 1),
textBody: 'x'.repeat(1_000),
};
await expect(service.updateVacation(user, off)).resolves.toEqual(off);
await expect(service.updateVacation(user, off)).resolves.toEqual({
...off,
isActive: true,
});
expect(provider.setVacation).toHaveBeenCalledTimes(2);
});
@@ -791,7 +807,7 @@ describe('MailService', () => {
expect(provider.setVacation).not.toHaveBeenCalled();
provider.setVacation.mockImplementation((_user, value) =>
Promise.resolve(value),
Promise.resolve({ ...value, isActive: true }),
);
await service.updateVacation(user, {
...vacation,
@@ -816,7 +832,7 @@ describe('MailService', () => {
expect(provider.setVacation).not.toHaveBeenCalled();
provider.setVacation.mockImplementation((_user, value) =>
Promise.resolve(value),
Promise.resolve({ ...value, isActive: true }),
);
await service.updateVacation(user, { ...vacation, toDate: null });
await service.updateVacation(user, { ...vacation, fromDate: null });
@@ -1133,6 +1149,257 @@ describe('MailService', () => {
});
});
describe('이미 받은 메일에 필터 적용 (7단계)', () => {
const body = (overrides: object = {}) =>
({
match: 'any',
conditions: [
{
field: 'from',
operator: 'contains',
value: '비밀 거래처',
},
],
actions: { moveToMailboxId: 'j', markAsRead: true, flag: false },
cursor: null,
...overrides,
}) as unknown as MailFilterRunRequestDto;
const outcome = {
scanned: 500,
matched: 37,
remaining: 120,
cursor: 'next-cursor',
rounds: 2,
};
const mailbox = (id: string, role: Mailbox['role']): Mailbox => ({
id,
name: id,
role,
parentId: null,
sortOrder: 0,
totalCount: 0,
unreadCount: 0,
});
/** 손으로 끝내는 적용 — 끝낼 때까지 진행 중이다 */
function pending<T>(): {
promise: Promise<T>;
finish: (value: T) => void;
fail: (error: Error) => void;
} {
let finish!: (value: T) => void;
let fail!: (error: Error) => void;
const promise = new Promise<T>((resolve, reject) => {
finish = resolve;
fail = reject;
});
return { promise, finish, fail };
}
const mailboxes = [
mailbox('a', 'inbox'),
mailbox('e', 'sent'),
mailbox('d', 'drafts'),
mailbox('j', null),
];
beforeEach(() => {
provider.listMailboxes.mockResolvedValue(mailboxes);
provider.runMailFilter.mockResolvedValue(outcome);
});
it('검사를 지나면 본문의 규칙(묶는 방법 · 조건 · 할 일 · cursor)과 한 번 읽은 메일함 목록을 넘기고 결과를 돌려준다 — 차례 수는 로그에만', async () => {
await expect(service.runMailFilter(user, body())).resolves.toEqual({
scanned: 500,
matched: 37,
remaining: 120,
cursor: 'next-cursor',
});
expect(provider.listMailboxes).toHaveBeenCalledTimes(1);
expect(provider.runMailFilter).toHaveBeenCalledWith(
user.email,
{
match: 'any',
conditions: [
{ field: 'from', operator: 'contains', value: '비밀 거래처' },
],
actions: { moveToMailboxId: 'j', markAsRead: true, flag: false },
cursor: null,
},
{ mailboxes, startedAt: expect.any(Number) as number },
);
// 로그에는 수 · 차례 · 걸린 시간만 — 규칙의 글 · 주소 · cursor 는 남기지 않는다
const [line] = logSpy.mock.calls.at(-1) as [string];
expect(line).toContain('확인 500통 · 적용 37통 · 남음 120통');
expect(line).toContain('차례 2번');
expect(line).not.toContain('비밀 거래처');
expect(line).not.toContain('next-cursor');
expect(line).not.toContain(user.email);
});
it.each<[string, object, string]>([
[
'모든 메일인데 조건이 있음',
{ match: 'always' },
'conditions-for-match',
],
['하나라도인데 조건이 없음', { conditions: [] }, 'conditions-for-match'],
[
'할 일이 하나도 없음',
{
actions: { moveToMailboxId: null, markAsRead: false, flag: false },
},
'no-action',
],
])(
'칸 사이의 규칙이 어긋나면 엔진에 묻지 않고 VAL_001 — %s',
async (_label, overrides, problem) => {
let failure: unknown;
try {
await service.runMailFilter(user, body(overrides));
} catch (error) {
failure = error;
}
expect(failure).toBeInstanceOf(BusinessException);
expect((failure as BusinessException).context).toEqual({
reason: 'invalid-filter-run',
problem,
});
expect(provider.listMailboxes).not.toHaveBeenCalled();
expect(provider.runMailFilter).not.toHaveBeenCalled();
},
);
it('보낸편지함 · 임시보관함으로는 옮기지 않는다 — VAL_001 (규칙 저장과 같은 판정). 메일함 목록은 늘 한 번 읽어 넘긴다(어댑터가 받은편지함을 찾는다)', async () => {
for (const moveToMailboxId of ['e', 'd']) {
expect(
await failureOf(() =>
service.runMailFilter(
user,
body({
actions: { moveToMailboxId, markAsRead: false, flag: false },
}),
),
),
).toEqual({
code: 'VAL_001',
reason: 'filter-move-target-not-allowed',
});
}
expect(provider.runMailFilter).not.toHaveBeenCalled();
provider.listMailboxes.mockClear();
await service.runMailFilter(
user,
body({
actions: { moveToMailboxId: null, markAsRead: true, flag: true },
}),
);
expect(provider.listMailboxes).toHaveBeenCalledTimes(1);
// 없는 메일함은 받는다 — 어댑터가 옮기지 않고 표시만 한다
await service.runMailFilter(
user,
body({
actions: { moveToMailboxId: 'gone', markAsRead: true, flag: false },
}),
);
expect(provider.runMailFilter).toHaveBeenCalledTimes(2);
});
it('한 요청의 시간은 서비스에 들어온 때부터 — 메일함 목록을 읽는 시간도 그 안이다 (어댑터는 들어온 때를 받는다)', async () => {
let now = 1_000_000;
jest.spyOn(Date, 'now').mockImplementation(() => now);
provider.listMailboxes.mockImplementationOnce(() => {
now += 21_000;
return Promise.resolve(mailboxes);
});
await service.runMailFilter(user, body());
expect(provider.runMailFilter).toHaveBeenCalledWith(
user.email,
expect.any(Object),
{ mailboxes, startedAt: 1_000_000 },
);
});
it('같은 직원의 적용은 하나씩만 — 진행 중이면 엔진에 묻지 않고 바로 REQ_001(대소문자가 다른 주소도 같은 직원), 다른 직원은 동시에 된다, 끝나면(실패여도) 다시 받는다', async () => {
const first = pending<typeof outcome>();
provider.runMailFilter.mockReturnValueOnce(first.promise);
const running = service.runMailFilter(user, body());
// 메일함 확인(엔진)을 지나 어댑터에 들어갈 때까지 기다린다
await new Promise((resolve) => setImmediate(resolve));
expect(provider.runMailFilter).toHaveBeenCalledTimes(1);
provider.listMailboxes.mockClear();
for (const sameUser of [
user,
{ ...user, email: 'ARA.KIM@arasystem.test' },
]) {
expect(
await failureOf(() => service.runMailFilter(sameUser, body())),
).toEqual({ code: 'REQ_001', reason: 'filter-run-in-progress' });
}
expect(provider.listMailboxes).not.toHaveBeenCalled();
expect(provider.runMailFilter).toHaveBeenCalledTimes(1);
// 다른 직원 · 같은 직원의 메일함 처리(비우기 등)는 따로 센다
await expect(
service.runMailFilter(
{ email: 'doyun.lee@arasystem.test', name: '이도윤' },
body(),
),
).resolves.toMatchObject({ scanned: 500 });
provider.emptyMailbox.mockResolvedValueOnce({
processed: 0,
remaining: 0,
});
await expect(service.emptyMailbox(user, 'b')).resolves.toEqual({
processed: 0,
remaining: 0,
});
first.fail(new BusinessException('MAIL_001'));
expect(await failureOf(() => running)).toMatchObject({
code: 'MAIL_001',
});
await expect(service.runMailFilter(user, body())).resolves.toMatchObject({
scanned: 500,
});
});
it('어댑터의 실패는 그대로 — 깨진 cursor VAL_001 · 진행 없음 MAIL_001 (적용 로그 없음)', async () => {
for (const code of ['VAL_001', 'MAIL_001'] as const) {
provider.runMailFilter.mockRejectedValueOnce(
new BusinessException(code),
);
expect(
await codeOf(() =>
service.runMailFilter(user, body({ cursor: 'eyJ2IjoxfQ' })),
),
).toBe(code);
}
expect(logSpy).not.toHaveBeenCalled();
});
});
describe('부재중 자동 응답 읽기 (7단계 — isActive)', () => {
it('어댑터 그대로 — 저장한 값과 엔진에서 동작하는지', async () => {
const state = {
isEnabled: true,
fromDate: null,
toDate: null,
subject: '부재중입니다',
textBody: '휴가입니다.',
isActive: false,
};
provider.getVacation.mockResolvedValue(state);
await expect(service.getVacation(user)).resolves.toBe(state);
expect(provider.getVacation).toHaveBeenCalledWith(user.email);
});
});
describe('메일 상세', () => {
it('HTML 이 없으면 정화 없이 텍스트만 내려준다', async () => {
provider.getMessage.mockResolvedValue(source());
+128 -41
View File
@@ -24,6 +24,7 @@ import type {
UpdateMessagesDto,
} from './dto/bulk-messages.dto';
import type { MailFilterUpdateDto } from './dto/mail-filter.dto';
import type { MailFilterRunRequestDto } from './dto/mail-filter-run.dto';
import type { MailVacationDto } from './dto/mail-vacation.dto';
import type {
CreateMailboxDto,
@@ -62,7 +63,7 @@ import {
MAX_VACATION_SUBJECT_LENGTH,
MAX_VACATION_TEXT_LENGTH,
} from './mail.constants';
import { findFilterRulesProblem } from './mail-filters';
import { findFilterRulesProblem, findFilterRunProblem } from './mail-filters';
import { MAIL_PROVIDER, type MailProvider } from './mail.provider';
import { MAIL_SETTINGS, type MailSettings } from './mail.settings';
import type {
@@ -77,11 +78,15 @@ import type {
MailboxBulkResult,
MailboxDeletion,
MailboxRole,
MailFilterActions,
MailFilterRule,
MailFilterRun,
MailFilterRunRequest,
MailFilterSet,
MailQuota,
MailState,
MailVacation,
MailVacationState,
MessageAttachment,
MessageDeletionResult,
MessageDetail,
@@ -217,6 +222,27 @@ const FILTER_MOVE_FORBIDDEN_ROLES: ReadonlySet<MailboxRole> = new Set([
'drafts',
]);
/**
* 필터가 옮길 메일함 — 보낸편지함 · 임시보관함이면 VAL_001. 메일함 목록에 없는 id 는 받는다
* (지운 메일함을 가리키는 규칙이 남아 있을 수 있다 — 새 메일은 받은편지함에 들어오고, 이미 받은 메일은 옮기지 않는다).
* 규칙 저장(규칙마다의 할 일 — index 는 몇 번째 규칙)과 이미 받은 메일에 적용(할 일 하나)이 같이 쓴다
*/
function assertMoveTargetsAllowed(
mailboxes: readonly Mailbox[],
actions: readonly Pick<MailFilterActions, 'moveToMailboxId'>[],
): void {
const roles = new Map(mailboxes.map((mailbox) => [mailbox.id, mailbox.role]));
for (const [index, { moveToMailboxId }] of actions.entries()) {
if (moveToMailboxId === null) continue;
const role = roles.get(moveToMailboxId);
if (role && FILTER_MOVE_FORBIDDEN_ROLES.has(role)) {
throw new BusinessException('VAL_001', {
context: { reason: 'filter-move-target-not-allowed', role, index },
});
}
}
}
/** 서명 그림의 Content-ID — 같은 그림이면 늘 같은 값이다 (발송 지문이 cid 가 든 서식 본문을 본다) */
function signatureImageCid(imageId: string, sender: string): string {
const domain = sender.slice(sender.lastIndexOf('@') + 1).toLowerCase();
@@ -245,6 +271,13 @@ export class MailService {
*/
private readonly mailboxOperations = new Set<string>();
/**
* 진행 중인 "이미 받은 메일에 필터 적용"의 직원 — 한 직원에 하나씩만 (계약 2절 "이미 받은 메일에 적용" — 다른 탭 · PC 에서 함께 하면 429 REQ_001).
* 두 적용이 같은 받은편지함을 함께 읽으면 서로 옮긴 메일 때문에 자리가 어긋나고 엔진 호출이 그 수만큼 곱절로 는다.
* ⚠️ 프로세스 메모리다. 인스턴스를 늘리면 Redis 잠금(SET NX PX)으로 옮겨야 인스턴스 사이에서도 하나씩이 된다.
*/
private readonly filterRuns = new Set<string>();
constructor(
@Inject(MAIL_PROVIDER) private readonly provider: MailProvider,
@Inject(MAIL_SETTINGS) private readonly settings: MailSettings,
@@ -374,8 +407,8 @@ export class MailService {
return result;
}
/** 부재중 자동 응답 (계약 2절 "부재중 자동 응답") */
getVacation(user: AuthenticatedUser): Promise<MailVacation> {
/** 부재중 자동 응답 (계약 2절 "부재중 자동 응답") — 엔진에서 우리 규칙이 동작하는지(isActive)를 함께 (7단계) */
getVacation(user: AuthenticatedUser): Promise<MailVacationState> {
return this.provider.getVacation(user.email);
}
@@ -392,7 +425,7 @@ export class MailService {
async updateVacation(
user: AuthenticatedUser,
dto: MailVacationDto,
): Promise<MailVacation> {
): Promise<MailVacationState> {
const vacation: MailVacation = {
isEnabled: dto.isEnabled,
fromDate: dto.fromDate === null ? null : toWholeSecondIso(dto.fromDate),
@@ -468,7 +501,10 @@ export class MailService {
});
}
this.assertFilterForwardTargets(user, rules);
await this.assertFilterMoveTargets(user, rules);
await this.assertFilterMoveTargets(
user,
rules.map((rule) => rule.actions),
);
const saved = await this.provider.setMailFilters(user.email, {
rules,
@@ -481,6 +517,59 @@ export class MailService {
return saved;
}
/**
* 이미 받은 메일에 필터 적용 (계약 2절 "이미 받은 메일에 적용" — 7단계) — 받은편지함의 메일을 한 요청의 시간 안에서 차례로 판정하고
* 표시 · 옮기기를 한 뒤 이어 할 자리(cursor)를 돌려준다. 저장해 둔 필터가 아니라 본문의 규칙을 쓴다.
* - 칸 사이의 규칙이 어긋나면 엔진에 묻지 않고 VAL_001 — 규칙 저장과 같다(묶는 방법 ↔ 조건 수 · 할 일 하나 이상 — mail-filters.ts)
* - 같은 직원의 적용은 하나씩만 — 진행 중이면 엔진에 묻지 않고 REQ_001
* - 메일함 목록은 한 번만 읽는다 — 옮길 메일함이 보낸편지함 · 임시보관함이면 VAL_001 (규칙 저장과 같은 판정 — assertMoveTargetsAllowed),
* 없는 메일함은 받는다(옮기지 않는다). 어댑터는 그 목록에서 받은편지함 · 옮길 메일함을 찾는다
* - 한 요청의 시간(25초)은 여기 들어온 때부터 센다 — 메일함 목록을 읽는 것도 그 안이다
* 로그에는 확인 · 적용 · 남은 수와 차례 수 · 걸린 시간만 남긴다 — 규칙의 글(찾을 글)은 남기지 않는다 (필터 저장과 같다)
*/
async runMailFilter(
user: AuthenticatedUser,
dto: MailFilterRunRequestDto,
): Promise<MailFilterRun> {
const startedAt = Date.now();
const request: MailFilterRunRequest = {
match: dto.match,
conditions: dto.conditions,
actions: {
moveToMailboxId: dto.actions.moveToMailboxId,
markAsRead: dto.actions.markAsRead,
flag: dto.actions.flag,
},
cursor: dto.cursor,
};
const problem = findFilterRunProblem(request);
if (problem) {
throw new BusinessException('VAL_001', {
context: { reason: 'invalid-filter-run', problem },
});
}
return this.exclusive(
this.filterRuns,
user.email.toLowerCase(),
'filter-run-in-progress',
user,
async () => {
const mailboxes = await this.provider.listMailboxes(user.email);
assertMoveTargetsAllowed(mailboxes, [request.actions]);
const { rounds, ...result } = await this.provider.runMailFilter(
user.email,
request,
{ mailboxes, startedAt },
);
this.logger.log(
`${traceTag()} 이미 받은 메일에 필터를 적용했습니다: user=${maskEmail(user.email)}, 확인 ${result.scanned}통 · 적용 ${result.matched}통 · 남음 ${result.remaining}통${result.cursor === null ? '(끝)' : ''}, 차례 ${rounds}번, ${Date.now() - startedAt}ms`,
);
return result;
},
);
}
/** 받은 그대로의 메일 원문 — 파일 이름은 제목(없으면 "메일") + .eml (attachment.policy.ts 의 messageSourceFileName) */
async openMessageSource(
user: AuthenticatedUser,
@@ -739,59 +828,57 @@ export class MailService {
}
/**
* 필터가 옮길 메일함 — 보낸편지함 · 임시보관함이면 VAL_001. 메일함 목록에 없는 id 는 받는다
* (지운 메일함을 가리키는 규칙이 남아 있을 수 있다 — 그때는 받은편지함에 들어온다). 옮기는 규칙이 없으면 엔진에 묻지 않는다
* 필터 규칙이 옮길 메일함 — 판정은 assertMoveTargetsAllowed. 옮기는 규칙이 없으면 엔진에 묻지 않는다(메일함 목록을 읽지 않는다)
*/
private async assertFilterMoveTargets(
user: AuthenticatedUser,
rules: readonly MailFilterRule[],
actions: readonly Pick<MailFilterActions, 'moveToMailboxId'>[],
): Promise<void> {
const targets = rules.flatMap((rule, index) =>
rule.actions.moveToMailboxId === null
? []
: [{ index, mailboxId: rule.actions.moveToMailboxId }],
);
if (targets.length === 0) return;
const roles = new Map(
(await this.provider.listMailboxes(user.email)).map((mailbox) => [
mailbox.id,
mailbox.role,
]),
);
for (const { index, mailboxId } of targets) {
const role = roles.get(mailboxId);
if (role && FILTER_MOVE_FORBIDDEN_ROLES.has(role)) {
throw new BusinessException('VAL_001', {
context: { reason: 'filter-move-target-not-allowed', role, index },
});
}
if (actions.every(({ moveToMailboxId }) => moveToMailboxId === null)) {
return;
}
assertMoveTargetsAllowed(
await this.provider.listMailboxes(user.email),
actions,
);
}
/**
* 같은 직원 · 같은 메일함의 오래 걸리는 처리를 하나씩만 한다 — 진행 중이면 엔진에 묻지 않고 바로 REQ_001
* (로그인의 진행 중 시도와 같은 모양). 끝나면 성공 · 실패와 상관없이 푼다. 다른 메일함끼리는 동시에 된다
*/
private async runExclusive<T>(
/** 같은 직원 · 같은 메일함의 오래 걸리는 처리를 하나씩만 한다 — 다른 메일함끼리는 동시에 된다 (exclusive) */
private runExclusive<T>(
user: AuthenticatedUser,
mailboxId: string,
operation: () => Promise<T>,
): Promise<T> {
const key = `${user.email.toLowerCase()}\n${mailboxId}`;
if (this.mailboxOperations.has(key)) {
return this.exclusive(
this.mailboxOperations,
`${user.email.toLowerCase()}\n${mailboxId}`,
'mailbox-operation-in-progress',
user,
operation,
);
}
/**
* 같은 열쇠의 일을 하나씩만 한다 — 진행 중이면 엔진에 묻지 않고 바로 REQ_001 (로그인의 진행 중 시도와 같은 모양).
* 끝나면 성공 · 실패와 상관없이 푼다
*/
private async exclusive<T>(
running: Set<string>,
key: string,
reason: string,
user: AuthenticatedUser,
operation: () => Promise<T>,
): Promise<T> {
if (running.has(key)) {
throw new BusinessException('REQ_001', {
context: {
reason: 'mailbox-operation-in-progress',
user: maskEmail(user.email),
},
context: { reason, user: maskEmail(user.email) },
});
}
this.mailboxOperations.add(key);
running.add(key);
try {
return await operation();
} finally {
this.mailboxOperations.delete(key);
running.delete(key);
}
}
+41 -7
View File
@@ -1,6 +1,7 @@
import {
DEFAULT_ATTACHMENT_MAX_SIZE_MB,
DEFAULT_ATTACHMENT_TOTAL_MAX_SIZE_MB,
DEFAULT_MAIL_DIRECTORY_LOCAL_PART,
buildMailSettings,
isMailEngineConfigured,
parseAccountList,
@@ -100,7 +101,7 @@ describe('buildMailSettings', () => {
expect([
...buildMailSettings(reader({ MAIL_DOMAIN: 'AraSystem.Test' }))
.blockedAccounts,
]).toEqual(['admin@arasystem.test']);
]).toEqual(['admin@arasystem.test', 'mail-directory@arasystem.test']);
expect([
...buildMailSettings(
@@ -111,6 +112,7 @@ describe('buildMailSettings', () => {
).blockedAccounts,
]).toEqual([
'admin@arasystem.test',
'mail-directory@arasystem.test',
'ops@arasystem.test',
'audit@arasystem.test',
]);
@@ -127,6 +129,7 @@ describe('buildMailSettings', () => {
expect([...blocked]).toEqual([
'mail-admin@arasystem.test',
'mail-directory@arasystem.test',
'ops@arasystem.test',
]);
expect(blocked.has('admin@arasystem.test')).toBe(false);
@@ -137,14 +140,45 @@ describe('buildMailSettings', () => {
...buildMailSettings(
reader({ MAIL_DOMAIN: 'arasystem.test', MAIL_ADMIN_LOCAL_PART: ' ' }),
).blockedAccounts,
]).toEqual(['admin@arasystem.test']);
]).toEqual(['admin@arasystem.test', 'mail-directory@arasystem.test']);
});
it('도메인이 없으면 목록에 적은 계정만 막는다', () => {
expect([
...buildMailSettings(reader({ MAIL_BLOCKED_ACCOUNTS: 'ops@x.test' }))
.blockedAccounts,
]).toEqual(['ops@x.test']);
it(`사내 목록 계정은 기본 ${DEFAULT_MAIL_DIRECTORY_LOCAL_PART}@<도메인> — 넘어오지 않거나 빈 값이면 기본값, 로그인 · 대리 접근은 막는다`, () => {
const variants: Record<string, string>[] = [
{ MAIL_DOMAIN: 'AraSystem.Test' },
{ MAIL_DOMAIN: 'arasystem.test', MAIL_DIRECTORY_LOCAL_PART: '' },
{ MAIL_DOMAIN: 'arasystem.test', MAIL_DIRECTORY_LOCAL_PART: ' ' },
];
for (const values of variants) {
const settings = buildMailSettings(reader(values));
expect(settings.directoryAccount).toBe('mail-directory@arasystem.test');
expect(
settings.blockedAccounts.has('mail-directory@arasystem.test'),
).toBe(true);
}
});
it('사내 목록 계정 아이디를 MAIL_DIRECTORY_LOCAL_PART 로 바꾸면 그 주소를 쓰고 막는다 (소문자)', () => {
const settings = buildMailSettings(
reader({
MAIL_DOMAIN: 'arasystem.test',
MAIL_DIRECTORY_LOCAL_PART: ' Company-Directory ',
}),
);
expect(settings.directoryAccount).toBe('company-directory@arasystem.test');
expect([...settings.blockedAccounts]).toEqual([
'admin@arasystem.test',
'company-directory@arasystem.test',
]);
});
it('도메인이 없으면 목록에 적은 계정만 막는다 — 사내 목록 계정도 없다', () => {
const settings = buildMailSettings(
reader({ MAIL_BLOCKED_ACCOUNTS: 'ops@x.test' }),
);
expect([...settings.blockedAccounts]).toEqual(['ops@x.test']);
expect(settings.directoryAccount).toBeNull();
expect(buildMailSettings(reader({})).blockedAccounts.size).toBe(0);
});
});
+23 -2
View File
@@ -25,12 +25,20 @@ export interface MailSettings {
*/
readonly attachmentTotalMaxBytes: number;
/**
* 로그인·대리 접근을 막는 계정(소문자 주소) — 메일 관리자 `<MAIL_ADMIN_LOCAL_PART>@<도메인>`(기본 admin) + MAIL_BLOCKED_ACCOUNTS.
* 로그인·대리 접근을 막는 계정(소문자 주소) — 메일 관리자 `<MAIL_ADMIN_LOCAL_PART>@<도메인>`(기본 admin) +
* 사내 목록 계정 `<MAIL_DIRECTORY_LOCAL_PART>@<도메인>`(기본 mail-directory) + MAIL_BLOCKED_ACCOUNTS.
* 게이트웨이는 관리자 계정도 대리 접근할 수 있고, 그 세션은 **관리 권한**을 가진다(Stalwart 0.16 에서 확인).
* 웹메일이 관리자 계정을 다루지 않게 하고, 공개된 로그인 창이 관리자 비밀번호를 시험해 보는 창구가 되지 않게
* 메일 엔진에 묻기 **전에** 거절한다.
* 메일 엔진에 묻기 **전에** 거절한다. 사내 목록(계약 3절)도 같은 목록으로 거른다 (contacts/directory-policy.ts)
*/
readonly blockedAccounts: ReadonlySet<string>;
/**
* 사내 목록 계정 주소 전체 (소문자, 도메인이 없으면 null) — 서버만 쓰는 계정이다: 비밀번호가 없고, 역할에 로그인과 계정 목록 읽기
* (authenticate · jmapPrincipalGet · jmapPrincipalQuery)만 있다. 게이트웨이 대리 접근으로 엔진의 계정 목록을 읽는 데만 쓴다
* (jmap/jmap.client.ts 의 callDirectory — 계약 3절 "사내 목록"). 로그인 · 직원 메일함 대리 접근은 막는다(blockedAccounts).
* 메일 엔진 설정 스크립트(`mail/scripts/setup.mjs`)가 같은 env 로 이 계정을 만든다
*/
readonly directoryAccount: string | null;
}
/** 메일 엔진에 붙을 수 있는 설정 — 필수값이 모두 있는 상태 */
@@ -57,6 +65,12 @@ export const DEFAULT_ATTACHMENT_TOTAL_MAX_SIZE_MB = 25;
*/
export const DEFAULT_MAIL_ADMIN_LOCAL_PART = 'admin';
/**
* 사내 목록 계정 아이디의 기본값 — 루트 env `MAIL_DIRECTORY_LOCAL_PART`(선택)로 바꾼다. compose 가 넘기지 않거나 빈 값이면 이 값이다.
* 메일 엔진 설정 스크립트(`mail/scripts/setup.mjs`)가 같은 env 로 그 계정을 만든다 — 두 쪽이 한 값을 읽는다
*/
export const DEFAULT_MAIL_DIRECTORY_LOCAL_PART = 'mail-directory';
const MEGABYTE = 1024 * 1024;
function readTrimmed(
@@ -97,6 +111,11 @@ export function buildMailSettings(
const adminLocalPart = (
readTrimmed(read, 'MAIL_ADMIN_LOCAL_PART') ?? DEFAULT_MAIL_ADMIN_LOCAL_PART
).toLowerCase();
const directoryLocalPart = (
readTrimmed(read, 'MAIL_DIRECTORY_LOCAL_PART') ??
DEFAULT_MAIL_DIRECTORY_LOCAL_PART
).toLowerCase();
const directoryAccount = domain ? `${directoryLocalPart}@${domain}` : null;
return {
jmapBaseUrl: toOrigin(readTrimmed(read, 'MAIL_JMAP_URL')),
@@ -111,8 +130,10 @@ export function buildMailSettings(
),
blockedAccounts: new Set([
...(domain ? [`${adminLocalPart}@${domain}`] : []),
...(directoryAccount ? [directoryAccount] : []),
...parseAccountList(read('MAIL_BLOCKED_ACCOUNTS')),
]),
directoryAccount,
};
}
+62 -2
View File
@@ -1,5 +1,7 @@
import type { Readable } from 'stream';
import type { ContactEmailLabel } from '@/modules/contacts/contacts.types';
/**
* 메일 도메인 타입 — API 계약(docs/mail/api-contract.md 2절)과 같은 모양이다.
*
@@ -198,6 +200,15 @@ export interface MailVacation {
readonly textBody: string;
}
/** 부재중 자동 응답의 GET · PUT 응답 (계약 2절 "부재중 자동 응답"의 MailVacationState — 7단계) */
export interface MailVacationState extends MailVacation {
/**
* false = 엔진에서 우리 규칙(필터와 한 스크립트)이 꺼져 있어 켜 둔 자동 응답이 지금은 동작하지 않는다 — 저장하면 다시 켜진다.
* 메일 필터의 isActive 와 같은 값이다 (한 번도 저장하지 않은 계정은 true)
*/
readonly isActive: boolean;
}
/** 필터 조건을 견줄 곳 — 보낸 사람(이름 · 주소) · 받는 사람(받는 사람 · 참조의 이름 · 주소) · 제목 · 본문(글자 부분) */
export type MailFilterField = 'from' | 'to' | 'subject' | 'body';
@@ -253,6 +264,48 @@ export interface MailFilterUpdate {
readonly version: string;
}
/** 규칙의 조건 부분 — 묶는 방법과 조건 (규칙 저장 · 이미 받은 메일에 적용 · 판정이 같이 쓴다) */
export type MailFilterCriteria = Pick<MailFilterRule, 'match' | 'conditions'>;
/** 이미 받은 메일에 할 일 (계약 2절 "이미 받은 메일에 적용") — 규칙의 할 일에서 전달을 뺀 것 (이미 받은 메일은 전달하지 않는다) */
export type MailFilterRunActions = Omit<MailFilterActions, 'forwardTo'>;
/** 받은편지함에 이미 있는 메일에 규칙 하나를 적용하는 요청 (POST /api/mail-filter-runs — 7단계) — 서비스가 검사한 값이다 */
export interface MailFilterRunRequest extends MailFilterCriteria {
/** 하나 이상 — 옮길 메일함이 받은편지함이거나 없으면 옮기지 않는다(표시만 한다) */
readonly actions: MailFilterRunActions;
/** 처음은 null — 이어 할 때는 앞 결과의 cursor 그대로 (어댑터가 만들고 해석한다 — 다른 규칙의 것이면 VAL_001) */
readonly cursor: string | null;
}
/** 이미 받은 메일에 적용한 결과 (계약 2절의 MailFilterRun) — cursor 가 null 이 될 때까지 클라이언트가 같은 본문으로 다시 보낸다 */
export interface MailFilterRun {
/** 이번 요청에서 확인한 받은편지함 메일 수 */
readonly scanned: number;
/** 그중 조건에 맞아 할 일을 한 메일 수 (이미 읽음 · 중요였던 메일도 센다) */
readonly matched: number;
/** 아직 확인하지 않은 메일 수 — 어림 (그사이 들어오고 나간 메일이 있다) */
readonly remaining: number;
/** null = 끝 */
readonly cursor: string | null;
}
/**
* 이미 받은 메일에 적용할 때 서비스가 어댑터에 넘기는 것 — 이 요청에서 읽은 메일함 목록(받은편지함 · 옮길 메일함을 여기서 찾는다 — 다시 읽지 않는다)과
* 요청이 서비스에 들어온 때. 한 요청의 시간(25초)은 그때부터 센다 — 옮길 메일함 확인도 그 안이다 (계약 2절 "이미 받은 메일에 적용")
*/
export interface MailFilterRunContext {
readonly mailboxes: readonly Mailbox[];
/** Date.now() 의 값 (ms) */
readonly startedAt: number;
}
/** 어댑터가 돌려주는 적용 결과 — 응답(MailFilterRun)에 로그만 쓰는 값을 더했다 (서비스가 떼고 답한다) */
export interface MailFilterRunOutcome extends MailFilterRun {
/** 이번 요청에서 엔진에 물은 차례 수 */
readonly rounds: number;
}
/** 받은 그대로의 메일 원문 (GET /api/messages/{id}/source) — 파일 이름은 서비스가 제목으로 정한다 */
export interface MessageSource {
readonly stream: Readable;
@@ -463,17 +516,24 @@ export interface Correspondence {
readonly at: string;
}
/** 사내 목록의 한 사람 — 웹메일에 로그인한 적 있는 직원 */
/** 사내 목록의 한 사람 — 엔진의 사내 목록(8단계, 직원 · 공용 주소) 또는 그것을 읽지 못할 때 웹메일에 로그인한 적 있는 직원 */
export interface DirectoryContact {
readonly email: string;
readonly name: string;
}
/** 받는 사람 추천의 묶음 — contact 내 주소록(8단계) · directory 사내 · recent 최근 주고받은 주소 */
export type RecipientSuggestionSource = 'contact' | 'directory' | 'recent';
/** 받는 사람 추천 (계약 2절 "받는 사람 추천") */
export interface RecipientSuggestion {
readonly name: string | null;
readonly email: string;
readonly source: 'directory' | 'recent';
readonly source: RecipientSuggestionSource;
/** contact 만 — 그 연락처의 회사 (그 밖에는 null) */
readonly company: string | null;
/** contact 만 — 그 주소의 종류 (그 밖에는 null) */
readonly emailLabel: ContactEmailLabel | null;
}
/**
+12 -6
View File
@@ -30,7 +30,7 @@ import {
MailQuotaDto,
MailStateDto,
} from './dto/mail-response.dto';
import { MailVacationDto } from './dto/mail-vacation.dto';
import { MailVacationDto, MailVacationStateDto } from './dto/mail-vacation.dto';
import {
CreateMailboxDto,
MailboxMessagesPatchDto,
@@ -202,9 +202,12 @@ export class MailVacationController {
@ApiOperation({
summary: '부재중 자동 응답',
description:
'한 번도 설정하지 않았으면 { isEnabled: false, fromDate: null, toDate: null, subject: "", textBody: "" } 다.',
'한 번도 설정하지 않았으면 { isEnabled: false, fromDate: null, toDate: null, subject: "", textBody: "", isActive: true } 다. ' +
'isActive 는 메일 엔진에서 우리 규칙(메일 필터와 한 규칙)이 동작하는지다 — false 면 켜 두었어도 지금은 답하지 않는다(저장하면 다시 켜진다, 메일 필터의 isActive 와 같은 값).',
})
@ApiStandardResponse(MailVacationStateDto, {
description: '지금 설정 · 동작하는지',
})
@ApiStandardResponse(MailVacationDto, { description: '지금 설정' })
get(@CurrentUser() user: AuthenticatedUser) {
return this.mail.getVacation(user);
}
@@ -218,12 +221,15 @@ export class MailVacationController {
`꺼 둔 채로는 제목 ${MAX_VACATION_SUBJECT_LENGTH_WHEN_OFF}자 · 내용 ${MAX_VACATION_TEXT_LENGTH_WHEN_OFF.toLocaleString('ko-KR')}자까지 받는다 — 전에 저장한 긴 글이 켜져 있어도 끌 수 있게. ` +
'시작 · 끝이 둘 다 있으면 시작이 끝보다 앞이어야 한다 — 날짜는 소수 초를 버린 값으로 저장하고, 순서도 그 값으로 본다. ' +
'제목에는 제어 문자(탭 · 줄바꿈 포함)를, 내용에는 탭 · 줄바꿈 밖의 제어 문자를 쓸 수 없다. ' +
'꺼도 쓴 제목 · 내용은 남겨 둔다. 서식 본문은 쓰지 않는다. 저장한 값을 돌려준다. 메일 필터 규칙은 그대로 둔다.',
'꺼도 쓴 제목 · 내용은 남겨 둔다. 서식 본문은 쓰지 않는다. 저장한 값을 돌려준다. 메일 필터 규칙은 그대로 둔다. ' +
'저장하면 우리 규칙이 다시 켜진다(isActive: true — 꺼져 있던 메일 필터도 함께 다시 동작한다). 본문에 isActive 는 넣지 않는다(모르는 필드 — 400 VAL_001).',
})
@ApiStandardResponse(MailVacationStateDto, {
description: '저장한 설정 · 동작하는지',
})
@ApiStandardResponse(MailVacationDto, { description: '저장한 설정' })
@ApiErrorResponse(
'VAL_001',
`VAL_001 형식 · 길이(제목 ${MAX_VACATION_SUBJECT_LENGTH_WHEN_OFF}자 · 내용 ${MAX_VACATION_TEXT_LENGTH_WHEN_OFF.toLocaleString('ko-KR')}자, 켤 때는 제목 ${MAX_VACATION_SUBJECT_LENGTH}자 · 내용 ${MAX_VACATION_TEXT_LENGTH}자) · 제어 문자 · 켤 때 공백뿐인 제목 · 내용 · 시작이 끝보다 늦거나 같음(소수 초를 버린 값으로) · 모르는 필드 ` +
`VAL_001 형식 · 길이(제목 ${MAX_VACATION_SUBJECT_LENGTH_WHEN_OFF}자 · 내용 ${MAX_VACATION_TEXT_LENGTH_WHEN_OFF.toLocaleString('ko-KR')}자, 켤 때는 제목 ${MAX_VACATION_SUBJECT_LENGTH}자 · 내용 ${MAX_VACATION_TEXT_LENGTH}자) · 제어 문자 · 켤 때 공백뿐인 제목 · 내용 · 시작이 끝보다 늦거나 같음(소수 초를 버린 값으로) · 모르는 필드(isActive 포함) ` +
'/ MAIL_013 메일 필터와 함께 만든 엔진 규칙이 엔진 한도를 넘음 — 모두 400, 저장하지 않았다',
)
@ApiErrorResponse(
@@ -0,0 +1,156 @@
import { startCpuTimer } from '@/../test/cpu-time';
import {
recipientEmailMatcher,
recipientMatcher,
recipientNameMatcher,
} from './recipient-match';
/**
* 받는 사람 맞추기 표본 — [찾는 글(q), 이름(없으면 null), 주소, 맞는가] (계약 2절 "받는 사람 추천").
* frontend shared/utils/recipient-match.ts 의 matchName · matchEmail 과 **같은 표**다 (행과 순서까지 그대로 — 답은 frontend 함수로 만들었다).
* ⚠️ 맞추기를 바꾸면 두 파일(recipient-match.ts 두 쪽)과 두 표를 함께 고친다 — 서버가 다르게 맞추면 쓰기 화면이 받아 둔 것에서 찾은 것과 서버의 답이 어긋난다
*/
const RECIPIENT_MATCH_SAMPLES: readonly (readonly [
query: string,
name: string | null,
email: string,
matches: boolean,
])[] = [
['도윤', '이도윤', 'doyun.lee@arasystem.it.kr', true],
['DOYUN', '이도윤', 'doyun.lee@arasystem.it.kr', true],
['Lee', 'Doyun Lee', 'd.lee@example.com', true],
['lEE', 'Doyun Lee', 'd.lee@example.com', true],
['윤도', '이도윤', 'doyun.lee@arasystem.it.kr', false],
['박', '이도윤', 'doyun.lee@arasystem.it.kr', false],
['ㄷㅇ', '이도윤', 'doyun.lee@arasystem.it.kr', true],
['ㅇㄷㅇ', '이도윤', 'doyun.lee@arasystem.it.kr', true],
['ㅂㅈㅎ', '박지훈', 'jihoon.park@hanbit.co.kr', true],
['ㅈㅎ', '박지훈', 'jihoon.park@hanbit.co.kr', true],
['ㅎㅈ', '박지훈', 'jihoon.park@hanbit.co.kr', false],
['ㄲ', '꽃님', 'flower@example.com', true],
['ㄱ', '꽃님', 'flower@example.com', false],
['ㅃ', '빵집', 'bread@example.com', true],
['도ㅇ', '이도윤', 'doyun.lee@arasystem.it.kr', true],
['ㄷ윤', '이도윤', 'doyun.lee@arasystem.it.kr', true],
['이ㄷ', '이도윤', 'doyun.lee@arasystem.it.kr', true],
['도유', '이도윤', 'doyun.lee@arasystem.it.kr', true],
['이도유', '이도윤', 'doyun.lee@arasystem.it.kr', true],
['동', '이도윤', 'doyun.lee@arasystem.it.kr', true],
['이동', '이도윤', 'doyun.lee@arasystem.it.kr', true],
['돈', '이도윤', 'doyun.lee@arasystem.it.kr', false],
['도요', '이도윤', 'doyun.lee@arasystem.it.kr', false],
['유', '이도윤', 'doyun.lee@arasystem.it.kr', true],
['윤', '이도윤', 'doyun.lee@arasystem.it.kr', true],
['도윤이', '이도윤', 'doyun.lee@arasystem.it.kr', false],
['지후', '박지훈', 'jihoon.park@hanbit.co.kr', true],
['박지후', '박지훈', 'jihoon.park@hanbit.co.kr', true],
['박짛', '박지훈', 'jihoon.park@hanbit.co.kr', true],
['박지', '박지훈', 'jihoon.park@hanbit.co.kr', true],
['닭', '달걀', 'egg@example.com', true],
['넋', '너서', 'neo@example.com', false],
['값', '가비', 'gabi@example.com', false],
['값', '갑시다', 'gapsi@example.com', true],
['갑', '갑시다', 'gapsi@example.com', true],
['ㄳ', '넋', 'neok@example.com', false],
['ㄱ', '넋', 'neok@example.com', false],
['김 아', '김 아라', 'ara.kim@arasystem.it.kr', true],
['김아', '김 아라', 'ara.kim@arasystem.it.kr', false],
['ㄱ ㅇ', '김 아라', 'ara.kim@arasystem.it.kr', true],
['ㄱㅇ', '김 아라', 'ara.kim@arasystem.it.kr', false],
[' 도윤 ', '이도윤', 'doyun.lee@arasystem.it.kr', true],
['arasystem', '이도윤', 'doyun.lee@arasystem.it.kr', true],
['DOYUN.LEE@', '이도윤', 'doyun.lee@arasystem.it.kr', true],
['doyun.lee@arasystem.it.kr', '이도윤', 'doyun.lee@arasystem.it.kr', true],
['doyun.lee@other.example', '이도윤', 'doyun.lee@arasystem.it.kr', true],
['doyun.leee@other.example', '이도윤', 'doyun.lee@arasystem.it.kr', false],
['@arasystem', '', 'doyun.lee@arasystem.it.kr', true],
['lee@', 'Doyun', 'doyun.lee@arasystem.it.kr', true],
['a@b@c', '', 'a@x.example', true],
['Doyun.Lee@AraSystem.it.kr', null, 'doyun.lee@arasystem.it.kr', true],
['kr', null, 'doyun.lee@arasystem.it.kr', true],
['istanbul', null, 'İstanbul@example.com', false],
['example', null, 'İstanbul@example.com', false],
['도윤', null, 'doyun.lee@arasystem.it.kr', false],
['도윤', '', 'x@example.com', false],
['😀', '팀😀', 'team@example.com', true],
['팀😀', '팀😀', 'team@example.com', true],
['😀팀', '팀😀', 'team@example.com', false],
['𠀋', '𠀋田', 'kanji@example.com', true],
['ㅏ', '아라', 'ara@example.com', false],
['ㅇㅏ', '아라', 'ara@example.com', false],
['2팀', '영업2팀', 'sales2@example.com', true],
['(주)', '(주)한빛', 'hanbit@example.com', true],
['도윤'.normalize('NFD'), '이도윤', 'doyun.lee@arasystem.it.kr', false],
['도윤', '이도윤'.normalize('NFD'), 'x@example.com', false],
[
'ㄱ'.repeat(33),
'ㄱㄱㄱㄱㄱ가가가가가가가가가가가가가가가가가가가가가가가가가가가가가가가가가',
'long@example.com',
true,
],
['가'.repeat(32) + '나', '가'.repeat(40) + '나', 'long@example.com', true],
[
'가'.repeat(31) + '나다',
'가'.repeat(40) + '나다',
'long@example.com',
true,
],
[
'가'.repeat(31) + '나라',
'가'.repeat(40) + '나다',
'long@example.com',
false,
],
];
describe('받는 사람 맞추기 (recipient-match — 쓰기 화면과 같은 규칙)', () => {
it.each(RECIPIENT_MATCH_SAMPLES)(
'q=%j 이름=%j 주소=%j → %s',
(query, name, email, matches) => {
expect(recipientMatcher(query)({ name, email })).toBe(matches);
},
);
it('빈 글은 아무것에도 맞지 않는다 (거르지 않는 것은 부르는 쪽이 정한다)', () => {
expect(recipientNameMatcher('')('이도윤')).toBe(false);
expect(recipientNameMatcher(' ')('이도윤')).toBe(false);
expect(recipientEmailMatcher(' ')('a@b.example')).toBe(false);
});
it('한 쿼리로 여러 이름을 견줘도 답이 섞이지 않는다 (글자마다 기억한 표)', () => {
const matches = recipientNameMatcher('ㄷ윤');
expect(['이도윤', '박지훈', '도윤', '두윤', '도요'].map(matches)).toEqual([
true,
false,
true,
true,
false,
]);
});
it('긴 이름 × 긴 글도 이름 길이에 비례한다 — 제곱이 아니다 (CPU 시간)', () => {
// 시작 자리마다 다시 견주면(화면의 방법) 이름 100만 자 × 글 100자 — 1억 번 비교로 수십 초
const name = `${'가'.repeat(1_000_000)}나`;
const names = Array.from(
{ length: 3_000 },
(_, index) => `${'가'.repeat(99)}${index}`,
);
const cpuMs = startCpuTimer();
expect(recipientNameMatcher(`${'가'.repeat(99)}다`)(name)).toBe(false);
expect(recipientNameMatcher(`${'가'.repeat(99)}나`)(name)).toBe(true);
const matches = recipientNameMatcher(`${'가'.repeat(99)}다`);
expect(names.filter(matches)).toEqual([]);
// 지금 0.06~0.14초 — 한도는 10배쯤 (testing 규칙). 옛 방법(1억 번 비교)은 확실히 넘는다
expect(cpuMs()).toBeLessThan(1_500);
});
it('추천 줄 거르기 — 같은 이름은 한 번만 견준다 (연락처 하나의 주소 여러 줄)', () => {
const matches = recipientMatcher('ㄷㅇ');
const lines = Array.from({ length: 20 }, (_, index) => ({
name: '이도윤',
email: `doyun${index}@x.example`,
}));
expect(lines.every(matches)).toBe(true);
expect(matches({ name: '박지훈', email: 'jihoon@x.example' })).toBe(false);
});
});
+262
View File
@@ -0,0 +1,262 @@
/**
* 받는 사람 추천의 맞추기 (순수 함수) — 계약 2절 "받는 사람 추천" 의 `q`. **쓰기 화면과 같은 맞추기**다:
* 짝은 frontend `shared/utils/recipient-match.ts` 의 matchName · matchEmail 이고, 두 쪽 테스트가 같은 표본 표를 쓴다
* (recipient-match.spec.ts 의 RECIPIENT_MATCH_SAMPLES — 한쪽을 고치면 다른 쪽과 표도 함께).
*
* - 이름: 친 글자가 이름 어딘가에 이어서 들어 있으면 맞다(대소문자 무시 — `toLowerCase`). 친 글자가 초성(호환 자모)이면 그 초성의 글자에 맞는다
* (ㄷㅇ → 이"도윤", 도ㅇ → 이"도윤"). 마지막으로 친 글자는 아직 조합 중일 수 있다 — 받침 없는 글자(유)는 같은 초성 · 중성의 글자(윤)에,
* 받침이 있는 글자(동)는 그 받침이 다음 글자의 초성으로 넘어간 것(도 + ㅇ윤)에도 맞는다(겹받침은 뒤 자음이 넘어간다 — 닭 → 달 + ㄱ).
* 글자는 코드 포인트로 센다(서로게이트 쌍 하나가 한 글자).
* - 주소: 친 글자가 든 주소(대소문자 무시). 완전한 주소를 쳤으면 같은 아이디(@ 앞, @ 포함)의 주소도 맞다. 대소문자를 바꾸면 길이가 달라지는 글자가 든 주소는 맞추지 않는다.
*
* 화면은 받은 추천 100줄에서 이 규칙으로 고르고, 서버는 전체에서 고른다 — 같은 규칙이라 서버에 물어도 받아 둔 것에서 찾은 것이 그대로 나온다.
* 화면은 맞은 범위(굵게 보일 곳)를 찾지만 서버는 맞는지만 본다 — 이름은 비트 병렬(shift-and)로 한 번 훑어 이름 길이에 비례한다
* (화면처럼 시작 자리마다 다시 견주면 긴 이름 × 긴 글이 제곱이 된다 — 다른 프로그램이 넣은 긴 이름도 받는다).
*/
const SYLLABLE_START = 0xac00;
const SYLLABLE_END = 0xd7a3;
const MEDIAL_COUNT = 21;
const FINAL_COUNT = 28;
/** 초성 — 호환 자모 */
const INITIALS = Array.from('ㄱㄲㄴㄷㄸㄹㅁㅂㅃㅅㅆㅇㅈㅉㅊㅋㅌㅍㅎ');
const INITIAL_SET: ReadonlySet<string> = new Set(INITIALS);
/** 받침 — 0 은 받침 없음 */
const FINALS = [
'',
...Array.from('ㄱㄲㄳㄴㄵㄶㄷㄹㄺㄻㄼㄽㄾㄿㅀㅁㅂㅄㅅㅆㅇㅈㅊㅋㅌㅍㅎ'),
];
/** 겹받침을 나눈다 — 뒤 자음이 다음 글자의 초성으로 넘어가는 순간이 있다 (닭 → 달 + ㄱ) */
const SPLIT_FINALS: Readonly<
Record<string, readonly [rest: string, moved: string]>
> = {
ㄳ: ['ㄱ', 'ㅅ'],
ㄵ: ['ㄴ', 'ㅈ'],
ㄶ: ['ㄴ', 'ㅎ'],
ㄺ: ['ㄹ', 'ㄱ'],
ㄻ: ['ㄹ', 'ㅁ'],
ㄼ: ['ㄹ', 'ㅂ'],
ㄽ: ['ㄹ', 'ㅅ'],
ㄾ: ['ㄹ', 'ㅌ'],
ㄿ: ['ㄹ', 'ㅍ'],
ㅀ: ['ㄹ', 'ㅎ'],
ㅄ: ['ㅂ', 'ㅅ'],
};
/** 비트 병렬 상태의 한 칸 (32비트) */
const WORD_BITS = 32;
/** 글자마다 기억하는 비트 표의 수 — 한 요청 안에서만 쓰고, 넘으면 비운다(메모리 상한) */
const MASK_CACHE_LIMIT = 10_000;
interface Syllable {
readonly initial: number;
readonly medial: number;
readonly final: number;
}
function syllableOf(char: string): Syllable | null {
const code = char.codePointAt(0) ?? 0;
if (char.length !== 1 || code < SYLLABLE_START || code > SYLLABLE_END) {
return null;
}
const offset = code - SYLLABLE_START;
return {
initial: Math.floor(offset / (MEDIAL_COUNT * FINAL_COUNT)),
medial: Math.floor((offset % (MEDIAL_COUNT * FINAL_COUNT)) / FINAL_COUNT),
final: offset % FINAL_COUNT,
};
}
function composeSyllable(syllable: Syllable): string {
return String.fromCharCode(
SYLLABLE_START +
(syllable.initial * MEDIAL_COUNT + syllable.medial) * FINAL_COUNT +
syllable.final,
);
}
function initialOf(char: string): string | null {
const syllable = syllableOf(char);
return syllable ? (INITIALS[syllable.initial] ?? null) : null;
}
/** 친 글자 하나가 이름 글자 하나에 맞는가 — 같은 글자(대소문자 무시) 또는 초성 */
function matchesChar(typed: string, char: string): boolean {
if (typed.toLowerCase() === char.toLowerCase()) return true;
return INITIAL_SET.has(typed) && initialOf(char) === typed;
}
/**
* 마지막으로 친 글자 — 조합 중일 수 있다. 맞으면 이름에서 쓴 글자 수(1 · 2), 아니면 0.
* - 받침 없는 글자(유)는 같은 초성 · 중성의 글자(윤 · 율 …)에 맞는다
* - 받침이 있는 글자(동)는 받침이 다음 글자의 초성으로 넘어갈 수 있다 — "도" + 다음 글자 초성 "ㅇ"(도윤)
*/
function matchesLastChar(
typed: string,
char: string,
next: string | undefined,
): number {
if (matchesChar(typed, char)) return 1;
const typedSyllable = syllableOf(typed);
const syllable = syllableOf(char);
if (!typedSyllable || !syllable) return 0;
if (
typedSyllable.initial !== syllable.initial ||
typedSyllable.medial !== syllable.medial
) {
return 0;
}
if (typedSyllable.final === 0) return 1;
if (next === undefined) return 0;
const finalJamo = FINALS[typedSyllable.final] ?? '';
const [rest, moved] = SPLIT_FINALS[finalJamo] ?? ['', finalJamo];
const restSyllable = composeSyllable({
...typedSyllable,
final: FINALS.indexOf(rest),
});
return restSyllable === char && initialOf(next) === moved ? 2 : 0;
}
/**
* 마지막으로 친 글자의 맞추기를 글자마다 한 번만 — 다음 글자와 상관없는 몫(matchesLastChar 의 1)은 글자(코드 포인트)마다 기억하고,
* 받침이 다음 글자의 초성으로 넘어간 몫(2)은 그 글자가 "받침을 뗀 글자"일 때만 다음 글자의 초성을 본다
*/
function lastCharMatcher(
last: string,
): (point: number, next: number | undefined) => boolean {
const simple = new Map<number, boolean>();
const typedSyllable = syllableOf(last);
let carry: { readonly rest: number; readonly moved: string } | null = null;
if (typedSyllable && typedSyllable.final !== 0) {
const finalJamo = FINALS[typedSyllable.final] ?? '';
const [rest, moved] = SPLIT_FINALS[finalJamo] ?? ['', finalJamo];
carry = {
rest: composeSyllable({
...typedSyllable,
final: FINALS.indexOf(rest),
}).charCodeAt(0),
moved,
};
}
return (point, next) => {
let matched = simple.get(point);
if (matched === undefined) {
matched =
matchesLastChar(last, String.fromCodePoint(point), undefined) > 0;
if (simple.size >= MASK_CACHE_LIMIT) simple.clear();
simple.set(point, matched);
}
if (matched) return true;
if (carry === null || point !== carry.rest || next === undefined) {
return false;
}
return initialOf(String.fromCodePoint(next)) === carry.moved;
};
}
/**
* 이름 맞추기 — 친 글자(초성 · 조합 중 포함)가 이름 어딘가에 맞는가. 쿼리 하나로 여러 이름을 견주는 함수를 돌려준다.
* 마지막 글자 앞까지는 비트 병렬(shift-and — 상태의 k 번째 비트: 앞의 k+1 글자가 지금 자리에서 끝나게 맞았다)로 훑고,
* 그것이 다 맞은 다음 자리에서 마지막 글자를 견준다 — 화면의 matchName 과 같은 답이다.
* 이름은 코드 포인트로 한 번 훑는다(글자를 배열로 만들지 않는다) — 글자마다의 비트 표 · 마지막 글자 답은 쿼리 안에서 기억한다
*/
export function recipientNameMatcher(query: string): (name: string) => boolean {
const typed = Array.from(query.trim());
const last = typed.at(-1);
if (last === undefined) return () => false;
const lastMatches = lastCharMatcher(last);
const prefix = typed.slice(0, -1);
const prefixLower = prefix.map((char) => char.toLowerCase());
const length = prefix.length;
const words = Math.ceil(length / WORD_BITS);
const finalWord = Math.floor((length - 1) / WORD_BITS);
const finalBit = 1 << ((length - 1) % WORD_BITS);
const masks = new Map<number, Uint32Array>();
const maskOf = (point: number): Uint32Array => {
const known = masks.get(point);
if (known) return known;
const char = String.fromCodePoint(point);
const lower = char.toLowerCase();
const initial = initialOf(char);
const mask = new Uint32Array(words);
prefix.forEach((typedChar, index) => {
// matchesChar 와 같다 — 같은 글자(대소문자 무시) 또는 초성
if (
prefixLower[index] === lower ||
(INITIAL_SET.has(typedChar) && initial === typedChar)
) {
mask[Math.floor(index / WORD_BITS)] |= 1 << (index % WORD_BITS);
}
});
if (masks.size >= MASK_CACHE_LIMIT) masks.clear();
masks.set(point, mask);
return mask;
};
return (name: string) => {
const state = length === 0 ? null : new Uint32Array(words);
let at = 0;
while (at < name.length) {
const point = name.codePointAt(at) ?? 0;
const nextAt = at + (point > 0xffff ? 2 : 1);
const next = nextAt < name.length ? name.codePointAt(nextAt) : undefined;
// 앞 글자들이 바로 앞 자리에서 끝나게 맞았다(앞 글자가 없으면 늘) — 이 자리에서 마지막 글자를 견준다
const ready =
state === null || ((state[finalWord] ?? 0) & finalBit) !== 0;
if (ready && lastMatches(point, next)) return true;
if (state !== null) {
const mask = maskOf(point);
let carry = 1;
for (let word = 0; word < words; word += 1) {
const value = state[word] ?? 0;
state[word] = ((value << 1) | carry) & (mask[word] ?? 0);
carry = value >>> (WORD_BITS - 1);
}
}
at = nextAt;
}
return false;
};
}
/** 주소 맞추기 — 친 글자가 든 주소(대소문자 무시), 완전한 주소를 쳤으면 같은 아이디(@ 포함)의 주소도 */
export function recipientEmailMatcher(
query: string,
): (email: string) => boolean {
const typed = query.trim().toLowerCase();
const localEnd = typed.indexOf('@');
const local = localEnd > 0 ? typed.slice(0, localEnd + 1) : null;
return (email: string) => {
const target = email.toLowerCase();
// 대소문자를 바꾸며 길이가 달라지는 글자(드물다)면 화면이 범위를 믿을 수 없어 맞추지 않는다 — 같게
if (typed === '' || target.length !== email.length) return false;
if (target.includes(typed)) return true;
return local !== null && target.includes(local);
};
}
/**
* 추천 한 줄이 친 글자에 맞는가 — 이름(있으면) · 주소 가운데 하나라도.
* 이름의 답은 이 쿼리 안에서 기억한다 — 주소록 한 연락처의 주소 여러 줄은 이름이 같다(연락처 3,000 × 주소 20 = 6만 줄도 이름은 3,000번만 본다)
*/
export function recipientMatcher(
query: string,
): (suggestion: {
readonly name: string | null;
readonly email: string;
}) => boolean {
const name = recipientNameMatcher(query);
const email = recipientEmailMatcher(query);
const names = new Map<string, boolean>();
const nameMatches = (text: string): boolean => {
let matched = names.get(text);
if (matched === undefined) {
matched = name(text);
names.set(text, matched);
}
return matched;
};
return (suggestion) =>
(suggestion.name ? nameMatches(suggestion.name) : false) ||
email(suggestion.email);
}
@@ -1,8 +1,10 @@
import type { Correspondence } from './mail.types';
import type { ContactSuggestionLine } from '@/modules/contacts/contacts.types';
import type { Correspondence, RecipientSuggestion } from './mail.types';
import {
MAX_RECIPIENT_SUGGESTIONS,
filterRecipientSuggestions,
rankRecipientSuggestions,
withContactSuggestions,
} from './recipient-ranking';
const NOW = new Date('2026-10-27T12:00:00.000Z');
@@ -26,6 +28,28 @@ const sent = (
at: ago(days),
});
/** 사내 · 최근 줄 — 주소록 칸(company · emailLabel)은 null */
const directoryItem = (
name: string | null,
email: string,
): RecipientSuggestion => ({
name,
email,
source: 'directory',
company: null,
emailLabel: null,
});
const recentItem = (
name: string | null,
email: string,
): RecipientSuggestion => ({
name,
email,
source: 'recent',
company: null,
emailLabel: null,
});
const rank = (
correspondence: Correspondence[],
directory: { email: string; name: string }[] = [],
@@ -49,17 +73,9 @@ describe('rankRecipientSuggestions (받는 사람 추천 순서)', () => {
);
expect(result).toEqual([
{
name: '이도윤',
email: 'doyun.lee@arasystem.test',
source: 'directory',
},
{
name: '박서준',
email: 'seojun.park@arasystem.test',
source: 'directory',
},
{ name: null, email: 'partner@vendor.example', source: 'recent' },
directoryItem('이도윤', 'doyun.lee@arasystem.test'),
directoryItem('박서준', 'seojun.park@arasystem.test'),
recentItem(null, 'partner@vendor.example'),
]);
});
@@ -118,9 +134,7 @@ describe('rankRecipientSuggestions (받는 사람 추천 순서)', () => {
[{ email: 'admin@arasystem.test', name: '관리자' }],
);
expect(result).toEqual([
{ name: null, email: 'kept@service.example', source: 'recent' },
]);
expect(result).toEqual([recentItem(null, 'kept@service.example')]);
});
it('이름은 사내 목록의 이름, 없으면 가장 최근에 본 표시 이름을 쓴다', () => {
@@ -135,12 +149,8 @@ describe('rankRecipientSuggestions (받는 사람 추천 순서)', () => {
);
expect(result).toEqual([
{
name: '메일에 적힌 이름',
email: 'colleague@arasystem.test',
source: 'directory',
},
{ name: '새 이름', email: 'partner@vendor.example', source: 'recent' },
directoryItem('메일에 적힌 이름', 'colleague@arasystem.test'),
recentItem('새 이름', 'partner@vendor.example'),
]);
});
@@ -155,14 +165,100 @@ describe('rankRecipientSuggestions (받는 사람 추천 순서)', () => {
});
});
describe('withContactSuggestions (주소록 묶음을 앞에)', () => {
const line = (
name: string | null,
email: string,
overrides: Partial<ContactSuggestionLine> = {},
): ContactSuggestionLine => ({
name,
email,
company: null,
emailLabel: 'work',
...overrides,
});
it('주소록 → 사내 → 최근 — 주소록에 있는 주소는 뒤 묶음에서 빠진다(대소문자 무시), 회사 · 종류는 주소록 줄에만', () => {
const ranked = [
directoryItem('이도윤', 'doyun.lee@arasystem.test'),
directoryItem('박서준', 'seojun.park@arasystem.test'),
recentItem('한빛', 'jihoon.park@hanbit.example'),
recentItem(null, 'vendor@supply.example'),
];
const result = withContactSuggestions(
[
line('박지훈', 'Jihoon.Park@hanbit.example', { company: '한빛상사' }),
line('박지훈', 'jihoon.home@mail.example', {
company: '한빛상사',
emailLabel: 'personal',
}),
line('이도윤', 'doyun.lee@arasystem.test'),
],
ranked,
ME,
EXCLUDED,
);
expect(result).toEqual([
{
name: '박지훈',
email: 'Jihoon.Park@hanbit.example',
source: 'contact',
company: '한빛상사',
emailLabel: 'work',
},
{
name: '박지훈',
email: 'jihoon.home@mail.example',
source: 'contact',
company: '한빛상사',
emailLabel: 'personal',
},
{
name: '이도윤',
email: 'doyun.lee@arasystem.test',
source: 'contact',
company: null,
emailLabel: 'work',
},
directoryItem('박서준', 'seojun.park@arasystem.test'),
recentItem(null, 'vendor@supply.example'),
]);
});
it('같은 주소가 연락처 둘에 있으면 앞의 것 하나 — 나 · 시스템 계정(관리자 등)은 주소록에 있어도 빼고, noreply 류는 직원이 넣은 것이라 둔다', () => {
const result = withContactSuggestions(
[
line('거래처 A', 'sales@partner.example'),
line('거래처 B', 'SALES@partner.example'),
line('나', 'Ara.Kim@arasystem.test'),
line('관리자', 'admin@arasystem.test'),
line(null, 'no-reply@bank.example'),
line(' ', 'nameless@partner.example', { emailLabel: 'other' }),
],
[],
ME,
EXCLUDED,
);
expect(result.map((item) => [item.name, item.email])).toEqual([
['거래처 A', 'sales@partner.example'],
[null, 'no-reply@bank.example'],
[null, 'nameless@partner.example'],
]);
});
it('주소록이 비면 받은 목록 그대로', () => {
const ranked = [recentItem(null, 'vendor@supply.example')];
expect(withContactSuggestions([], ranked, ME, EXCLUDED)).toEqual(ranked);
});
});
describe('filterRecipientSuggestions (q 로 거르기)', () => {
const list = [
{
name: '이도윤',
email: 'doyun.lee@arasystem.test',
source: 'directory' as const,
},
{ name: null, email: 'Partner@Vendor.example', source: 'recent' as const },
directoryItem('이도윤', 'doyun.lee@arasystem.test'),
recentItem(null, 'Partner@Vendor.example'),
];
it('이름·주소에 그 글자가 든 것만 준다 (대소문자 무시, 앞뒤 공백 무시)', () => {
@@ -172,12 +268,21 @@ describe('filterRecipientSuggestions (q 로 거르기)', () => {
expect(filterRecipientSuggestions(list, '없는사람')).toEqual([]);
});
it('조합형(NFD)으로 친 한글도 찾는다 — 맥에서 붙여 넣은 글자', () => {
expect(filterRecipientSuggestions(list, '도윤'.normalize('NFD'))).toEqual([
list[0],
it('쓰기 화면과 같은 맞추기 — 초성 · 조합 중 글자 · 완전한 주소의 아이디 (recipient-match.ts)', () => {
expect(filterRecipientSuggestions(list, 'ㄷㅇ')).toEqual([list[0]]);
expect(filterRecipientSuggestions(list, '도유')).toEqual([list[0]]);
expect(filterRecipientSuggestions(list, '동')).toEqual([list[0]]);
expect(filterRecipientSuggestions(list, 'partner@other.example')).toEqual([
list[1],
]);
});
it('조합형(NFD)으로 친 한글은 같은 글자가 아니다 — 쓰기 화면과 같게 (받아 둔 것에서 찾은 것과 서버의 답이 같아야 한다)', () => {
expect(filterRecipientSuggestions(list, '도윤'.normalize('NFD'))).toEqual(
[],
);
});
it('q 가 없거나 공백뿐이면 그대로 준다', () => {
expect(filterRecipientSuggestions(list, undefined)).toBe(list);
expect(filterRecipientSuggestions(list, ' ')).toBe(list);
+69 -21
View File
@@ -1,3 +1,5 @@
import type { ContactSuggestionLine } from '@/modules/contacts/contacts.types';
import { recipientMatcher } from './recipient-match';
import type {
Correspondence,
DirectoryContact,
@@ -5,16 +7,20 @@ import type {
} from './mail.types';
/**
* 받는 사람 추천 순서 (순수 함수) — 계약 2절 "받는 사람 추천".
* 받는 사람 추천 순서 (순수 함수) — 계약 2절 "받는 사람 추천" · 3절 "메일과 함께".
*
* - 사내(웹메일에 로그인한 적 있는 직원) → 최근 주고받은 주소 순. 두 쪽에 다 있는 주소는 사내로 한 번만 나온다
* - 묶음 안에서는 주고받은 횟수와 최근성으로 — 주고받을 때마다 점수를 더하되 오래된 것일수록 적게 더한다(반감기)
* - 나·메일 엔진 관리자·게이트웨이·막은 계정(excluded)·noreply 류는 뺀다
* - 주소록(8단계) → 사내(엔진의 사내 목록 — 읽지 못하면 웹메일에 로그인한 적 있는 직원) → 최근 주고받은 주소 순.
* 여러 묶음에 있는 주소는 앞 묶음으로 한 번만 나온다
* - 주소록은 연락처의 이름 차례(주소록이 정한 차례 그대로 — 대표 주소가 먼저), 사내 · 최근은 주고받은 횟수와 최근성으로 —
* 주고받을 때마다 점수를 더하되 오래된 것일수록 적게 더한다(반감기)
* - 나·메일 엔진 관리자·게이트웨이·사내 목록 계정·막은 계정(excluded)은 어느 묶음에서도 뺀다. noreply 류는 사내 · 최근에서만 뺀다 —
* 주소록은 직원이 넣은 것이라 그대로 둔다 (계약 3절 "메일과 함께")
* - q 로 거르기는 쓰기 화면과 같은 맞추기(recipient-match.ts — 초성 · 조합 중 글자 · 완전한 주소의 아이디)
*/
/** 최근성 반감기 — 30일 전에 주고받은 한 번은 오늘의 한 번의 절반으로 센다 */
const RECENCY_HALF_LIFE_MS = 30 * 24 * 60 * 60 * 1000;
/** 한 사람에게 기억하는 추천 수 상한 */
/** 한 사람에게 기억하는 추천 수 상한 (사내 · 최근 — 주소록 묶음은 기억하지 않고 요청마다 붙인다) */
export const MAX_RECIPIENT_SUGGESTIONS = 1000;
/** 자동 발송 주소 — 답장을 받지 않는 주소는 추천하지 않는다 (noreply@ · no-reply+… · donotreply@ · mailer-daemon@) */
@@ -48,16 +54,25 @@ function cleanName(name: string | null | undefined): string | null {
return trimmed ? trimmed : null;
}
/** 추천할 수 있는 주소인가 — 형식(로컬@도메인), 나·제외 목록·noreply 류가 아닌지 */
export function isSuggestable(
/** 받는 사람이 될 수 있는 주소인가 — 형식(로컬@도메인), 나·제외 목록(시스템 계정)이 아닌지 */
function isAddressable(
email: string,
me: string,
excluded: ReadonlySet<string>,
): boolean {
const at = email.lastIndexOf('@');
if (at <= 0 || at === email.length - 1) return false;
if (email === me || excluded.has(email)) return false;
return !NOREPLY_LOCAL_PART.test(email.slice(0, at));
return email !== me && !excluded.has(email);
}
/** 추천할 수 있는 주소인가 (사내 · 최근) — isAddressable 이고 noreply 류가 아닌지 */
export function isSuggestable(
email: string,
me: string,
excluded: ReadonlySet<string>,
): boolean {
if (!isAddressable(email, me, excluded)) return false;
return !NOREPLY_LOCAL_PART.test(email.slice(0, email.lastIndexOf('@')));
}
function tally(
@@ -92,7 +107,7 @@ function tally(
const byName = new Intl.Collator('ko');
/** 사내 → 최근 순으로 줄 세운 추천 목록 (MAX_RECIPIENT_SUGGESTIONS 까지) */
/** 사내 → 최근 순으로 줄 세운 추천 목록 (MAX_RECIPIENT_SUGGESTIONS 까지) — 주소록 묶음은 withContactSuggestions 가 앞에 붙인다 */
export function rankRecipientSuggestions(
input: RankingInput,
): RecipientSuggestion[] {
@@ -113,7 +128,13 @@ export function rankRecipientSuggestions(
}
const colleagues: RecipientSuggestion[] = [...directory]
.map(([email, name]) => ({ name, email, source: 'directory' as const }))
.map(([email, name]) => ({
name,
email,
source: 'directory' as const,
company: null,
emailLabel: null,
}))
.sort(
(a, b) =>
scoreOf(b.email) - scoreOf(a.email) ||
@@ -136,26 +157,53 @@ export function rankRecipientSuggestions(
name: entry.name,
email,
source: 'recent' as const,
company: null,
emailLabel: null,
}));
return [...colleagues, ...recent].slice(0, MAX_RECIPIENT_SUGGESTIONS);
}
/** 대소문자·유니코드 정규형을 맞춘 비교용 글자 */
function folded(text: string): string {
return text.normalize('NFC').toLowerCase();
/**
* 주소록 묶음을 앞에 붙인다 — 연락처의 주소마다 한 줄(받은 차례 그대로 — 이름 차례, 대표 주소가 먼저).
* 주소록에 있는 주소는 뒤 묶음(사내 · 최근)에서 뺀다. 같은 주소가 연락처 둘에 있으면 앞의 것 하나만.
* 나와 시스템 계정(관리자 · 게이트웨이 · 사내 목록 계정 · 막은 계정 — excluded)만 주소록에 있어도 뺀다 — noreply 류는 직원이 넣은 것이라 그대로 둔다
*/
export function withContactSuggestions(
contacts: readonly ContactSuggestionLine[],
ranked: readonly RecipientSuggestion[],
me: string,
excluded: ReadonlySet<string>,
): RecipientSuggestion[] {
const self = normalizeEmail(me);
const seen = new Set<string>();
const lines: RecipientSuggestion[] = [];
for (const contact of contacts) {
const key = normalizeEmail(contact.email);
if (seen.has(key) || !isAddressable(key, self, excluded)) continue;
seen.add(key);
lines.push({
name: cleanName(contact.name),
email: contact.email.trim(),
source: 'contact',
company: contact.company,
emailLabel: contact.emailLabel,
});
}
if (lines.length === 0) return [...ranked];
return [
...lines,
...ranked.filter((item) => !seen.has(normalizeEmail(item.email))),
];
}
/** 이름·주소에 q 가 든 것만 (대소문자 무시). q 가 없으면 그대로 */
/** 이름·주소가 q 에 맞는 것만 — 쓰기 화면과 같은 맞추기(recipient-match.ts). q 가 없으면 그대로 */
export function filterRecipientSuggestions(
suggestions: readonly RecipientSuggestion[],
query: string | undefined,
): readonly RecipientSuggestion[] {
const q = query ? folded(query.trim()) : '';
const q = query?.trim() ?? '';
if (q === '') return suggestions;
return suggestions.filter(
(suggestion) =>
folded(suggestion.email).includes(q) ||
(suggestion.name !== null && folded(suggestion.name).includes(q)),
);
const matches = recipientMatcher(q);
return suggestions.filter(matches);
}
@@ -1,45 +1,19 @@
import { createHash } from 'crypto';
import { BusinessException } from '@/common/errors/business.exception';
import {
decodeOffsetCursor,
encodeOffsetCursor,
} from '@/shared/utils/offset-cursor';
/**
* 받는 사람 추천 목록의 커서 (클라이언트에게는 불투명 문자열) — 걸러진 목록에서 다음 항목의 위치.
* 다른 검색어(q)의 커서를 섞어 쓰지 못하도록 검색어의 해시를 함께 넣는다 (jmap-cursor.ts 와 같은 방식).
* 다른 검색어(q)의 커서를 섞어 쓰지 못하도록 검색어의 해시를 함께 넣는다 (shared/utils/offset-cursor.ts — 주소록 목록과 같은 형식).
* 목록은 서버가 10분 캐시하므로 그 안에서는 위치가 그대로다 — 다시 모으면 조금 어긋날 수 있다(추천이라 문제 삼지 않는다).
*/
const CURSOR_VERSION = 1;
const MAX_CURSOR_LENGTH = 256;
const SCOPE_HASH_LENGTH = 16;
interface EncodedCursor {
v: number;
o: number;
s: string;
}
function scopeHash(query: string | undefined): string {
return createHash('sha256')
.update(query ?? '')
.digest('base64url')
.slice(0, SCOPE_HASH_LENGTH);
}
function invalidCursor(reason: string): BusinessException {
return new BusinessException('VAL_001', {
context: { reason: `invalid-suggestion-cursor:${reason}` },
});
}
export function encodeSuggestionCursor(
offset: number,
query: string | undefined,
): string {
const payload: EncodedCursor = {
v: CURSOR_VERSION,
o: offset,
s: scopeHash(query),
};
return Buffer.from(JSON.stringify(payload)).toString('base64url');
return encodeOffsetCursor(offset, query ?? '');
}
/** 커서를 해석한다 — 형식이 틀리거나 다른 검색어의 커서면 VAL_001 */
@@ -47,23 +21,11 @@ export function decodeSuggestionCursor(
cursor: string,
query: string | undefined,
): number {
if (cursor.length > MAX_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 reading = decodeOffsetCursor(cursor, query ?? '');
if ('problem' in reading) {
throw new BusinessException('VAL_001', {
context: { reason: `invalid-suggestion-cursor:${reading.problem}` },
});
}
const { v, o, s } = (parsed ?? {}) as Partial<EncodedCursor>;
const valid =
v === CURSOR_VERSION &&
typeof o === 'number' &&
Number.isSafeInteger(o) &&
o >= 0 &&
typeof s === 'string';
if (!valid) throw invalidCursor('malformed');
if (s !== scopeHash(query)) throw invalidCursor('other-query');
return o;
return reading.offset;
}
@@ -14,7 +14,7 @@ import { RecipientSuggestionPageDto } from './dto/mail-response.dto';
import { RecipientSuggestionQueryDto } from './dto/recipient-suggestion-query.dto';
import { RecipientSuggestionService } from './recipient-suggestions.service';
// 받는 사람 추천 (계약 2절 "받는 사람 추천") — 요청한 직원 자신의 메일함과 사내 목록에서만 모은다.
// 받는 사람 추천 (계약 2절 "받는 사람 추천" · 3절 "메일과 함께") — 요청한 직원 자신의 주소록 · 메일함과 사내 목록에서만 모은다.
@ApiTags('Mail')
@ApiCookieAuth()
@UseGuards(JwtAuthGuard)
@@ -29,9 +29,14 @@ export class RecipientSuggestionController {
@ApiOperation({
summary: '받는 사람 추천 (커서 페이지네이션)',
description:
'사내(웹메일에 로그인한 적 있는 직원) → 최근 주고받은 주소(보낸편지함 최근 500통의 받는 사람·참조·숨은참조, 받은편지함 최근 500통의 보낸 사람) 순, ' +
'묶음 안에서는 주고받은 횟수와 최근성으로. 두 쪽에 다 있는 주소는 사내로 한 번만 나온다. 나·메일 엔진 관리자·게이트웨이·막은 계정·noreply 류는 뺀다. ' +
'q 가 있으면 이름·주소에 그 글자가 든 것만 (대소문자 무시). 서버는 사용자마다 10분 기억하고, 메일을 보내면 다시 모은다. Cache-Control: private, no-store.',
'내 주소록(source contact — 연락처의 주소마다 한 줄, 이름 차례 · 대표 주소가 먼저, company · emailLabel 포함) → ' +
'사내(엔진의 사내 목록의 직원 · 공용 주소 — 읽지 못하면 최근 90일 안에 웹메일에 로그인한 직원) → ' +
'최근 주고받은 주소(보낸편지함 최근 500통의 받는 사람·참조·숨은참조, 받은편지함 최근 500통의 보낸 사람) 순, ' +
'사내 · 최근 안에서는 주고받은 횟수와 최근성으로. 여러 묶음에 있는 주소는 앞 묶음으로 한 번만 나온다. 그룹은 넣지 않는다. ' +
'나·메일 엔진 관리자·게이트웨이·사내 목록 계정·막은 계정은 뺀다(noreply 류는 사내 · 최근에서만 — 주소록은 직원이 넣은 것이라 그대로). ' +
'q 가 있으면 쓰기 화면과 같은 맞추기 — 이름은 초성 · 조합 중 글자를 섞어도(ㄷㅇ · 도ㅇ · 도유 · 동 → 이도윤), 주소는 그 글자가 든 것(완전한 주소면 같은 아이디도), 대소문자 무시. ' +
'서버는 사내 · 최근을 사용자마다 10분 기억하고 메일을 보내면 다시 모은다 — ' +
'주소록은 요청마다 엔진 상태를 확인한 주소록에서 붙인다(연락처를 바꾸면 바로 보인다). Cache-Control: private, no-store.',
})
@ApiStandardResponse(RecipientSuggestionPageDto, {
description: '추천 목록 (total 은 걸러진 개수)',

Some files were not shown because too many files have changed in this diff Show More