generated from ttipo/project-base
Compare commits
31 Commits
ff1948b7df
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| b8a5114af9 | |||
| 198ac8f4f2 | |||
| b644e8b104 | |||
| bd916ccbed | |||
| 94ec659b78 | |||
| 487760bd9b | |||
| 37ffcfca2c | |||
| f4a85fda27 | |||
| 0ff1ec46af | |||
| f3586abcd7 | |||
| 6eba013416 | |||
| f0e9a50b01 | |||
| 5ce614cc0c | |||
| def96e833b | |||
| 1d01130c9d | |||
| 1b3f9ba7fe | |||
| cd958f3bf4 | |||
| abe4c1364c | |||
| 1962f07d79 | |||
| d6b804ba83 | |||
| 19dbb2babb | |||
| 907287021f | |||
| 0c3238936a | |||
| fceb6ed3e4 | |||
| 060265c8b0 | |||
| 071fe9a60f | |||
| 3c9b3727c6 | |||
| 7f39aa4eb2 | |||
| d02a983d57 | |||
| 1ca210dec4 | |||
| 099faeaafc |
@@ -26,7 +26,7 @@ tools: Read, Grep, Glob, Edit, Write, Bash, Skill
|
||||
|
||||
## 작업 순서
|
||||
|
||||
1. 만들기 전에 `/nestjs` 의 "템플릿 기본 제공 골격"에서 재사용할 패턴을 찾는다. 같은 역할을 새로 만들지 않는다.
|
||||
1. 만들기 전에 `/nestjs` 의 "기본 골격"에서 재사용할 패턴을 찾는다. 같은 역할을 새로 만들지 않는다.
|
||||
2. 실패 경로마다 에러 코드를 정한다. 코드 없는 실패는 만들지 않는다 ([[error-codes]]).
|
||||
3. 목록 API 면 페이지네이션을 적용한다 ([[response-format]]).
|
||||
4. Swagger 데코레이터를 함께 쓴다 ([[api-design]]).
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
# 작업 점검 & 신규 프로젝트 셋업
|
||||
# 작업 점검 & 새 환경 셋업
|
||||
|
||||
> 이 저장소는 **재사용 템플릿**이다. 클론해 어떤 프로젝트든 시작할 수 있도록, 여기에는 **언제나 유효한 공용 규칙**만 둔다(특정 시점의 구현 상태는 두지 않는다).
|
||||
> 각 스택이 기본 제공하는 골격과 확장 규칙은 [[nestjs]] · [[vue3]] · [[flutter]] · [[docker]] 스킬 참조.
|
||||
> 여기에는 **언제나 유효한 점검 규칙**만 둔다(특정 시점의 구현 상태는 두지 않는다 — 지금 상태는 각 스킬 · API 계약 · [로드맵](../../docs/roadmap.md)).
|
||||
> 각 스택에 이미 있는 골격과 확장 규칙은 [[nestjs]] · [[vue3]] · [[flutter]] · [[docker]] 스킬 참조.
|
||||
|
||||
---
|
||||
|
||||
## 1. 신규 프로젝트 셋업 (클론 직후 1회)
|
||||
## 1. 새 개발 환경 · 새 서버 셋업 (처음 한 번)
|
||||
|
||||
- [ ] 루트 `.env.{env}.example` → `.env.{env}` 복사 후 **placeholder 값 교체** (`change-me-*` 가 남아 있으면 안 된다, [[env-structure]])
|
||||
운영은 `BACKEND_API_URL`·`CORS_ORIGIN` 을 **실제 도메인**으로 — `localhost`/`example.com` 이 남으면 기동 실패한다
|
||||
@@ -85,10 +85,10 @@
|
||||
|
||||
## 6. 기능 확장 시 따르는 순서
|
||||
|
||||
새 기능은 만들기 전에, 해당 스킬의 **"템플릿 기본 제공 골격"** 에서 재사용할 패턴을 먼저 찾는다(같은 역할을 새로 만들지 말 것).
|
||||
새 기능은 만들기 전에, 해당 스킬의 **"기본 골격"** 에서 재사용할 패턴을 먼저 찾는다(같은 역할을 새로 만들지 말 것).
|
||||
|
||||
1. 해당 디렉터리 스킬 규칙 확인 ([[nestjs]] / [[vue3]] / [[flutter]])
|
||||
2. 기본 제공 골격(인터셉터·필터·에러 레지스트리·dio client·예제 모듈)에서 패턴 재사용
|
||||
2. 기본 골격(인터셉터·필터·에러 레지스트리·dio client·예제 모듈)에서 패턴 재사용
|
||||
3. 표준 응답/에러 흐름 준수 ([[response-format]] · [[error-codes]] · [[api-design]])
|
||||
4. 실패 경로마다 에러 코드 지정 — `BusinessException('CODE')` 로 던진다
|
||||
5. env 신규 값은 루트 `.env` 부터 ([[env-structure]])
|
||||
|
||||
@@ -18,6 +18,7 @@ description: 세 클라이언트(backend/frontend/flutter) 공통 코딩 규칙
|
||||
- 절대경로 alias 사용 (TS `@/`, Dart `package:`), 깊은 상대경로(`../../`) 지양
|
||||
— backend/frontend 모두 `@/` → `src/` 로 설정되어 있다 (backend 는 `tsconfig.json` paths + jest `moduleNameMapper`)
|
||||
- **자동 생성 파일은 직접 수정하지 않는다** — `*.generated.ts`, `*.g.dart` 는 생성 스크립트를 통해서만 바꾼다 ([[error-codes]])
|
||||
- 소스에 보이지 않는 글자(nbsp · U+2028 · U+FEFF 등)는 `\uXXXX` 이스케이프로 적되, **저장한 뒤 바이트로 확인한다** — 편집 도구가 U+00A0 이상의 이스케이프를 실제 글자로 풀어 저장한 일이 있다(2026-11-10 — 정규식 안의 U+2028 이 줄 끝이 되어 컴파일 실패, 16단계에서는 U+FEFF). 풀리면 `String.fromCharCode(0x2028)` 처럼 글자 코드로 만든다
|
||||
|
||||
## 네이밍 규약
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ description: 환경변수 단일 진실 공급원(SSOT) 구조 — 루트 .env.{
|
||||
|
||||
# 환경변수 구조 (단일 진실 공급원: 루트 `.env`)
|
||||
|
||||
이 템플릿은 **루트 `.env.{APP_ENV}` 가 모든 설정의 단일 진실 공급원(SSOT)** 이다.
|
||||
이 저장소는 **루트 `.env.{APP_ENV}` 가 모든 설정의 단일 진실 공급원(SSOT)** 이다.
|
||||
값을 한 곳에서만 관리하기 위해, 서비스(backend/frontend)는 자체 env에 값을 중복 작성하지 않고 **루트 → docker-compose 가 주입한 값을 그대로 사용**한다.
|
||||
|
||||
```
|
||||
|
||||
@@ -64,7 +64,7 @@
|
||||
`img onerror` 같은 처리기가 실행된다. CSP 가 막아도 그것에만 기대지 않는다 ([[vue3]] 체크리스트 "외부 HTML")
|
||||
### 파일 업로드
|
||||
|
||||
템플릿은 `modules/upload` 에 로컬 디스크 기준 골격을 제공한다. 새 업로드 엔드포인트는 이 패턴을 복제한다.
|
||||
`modules/upload` 에 로컬 디스크 기준 골격이 있다. 새 업로드 엔드포인트는 이 패턴을 복제한다.
|
||||
|
||||
- **확장자와 MIME 을 둘 다** 검사한다 (`upload.config.ts`). 확장자만 보면 위장이 쉽고,
|
||||
MIME 만 보면 클라이언트가 보낸 값이라 신뢰할 수 없다.
|
||||
|
||||
@@ -5,7 +5,7 @@ description: 세 스택 공통 테스트 규칙 — 테스트 위치/네이밍,
|
||||
|
||||
# 테스트 규칙
|
||||
|
||||
> 템플릿은 각 스택에 테스트 러너가 준비되어 있다 — backend `jest`, frontend `vitest`, flutter `flutter_test`.
|
||||
> 각 스택에 테스트 러너가 준비되어 있다 — backend `jest`, frontend `vitest`, flutter `flutter_test`.
|
||||
> 새 로직을 추가하면 최소한의 테스트를 함께 작성한다.
|
||||
|
||||
## 공통 원칙
|
||||
|
||||
@@ -106,7 +106,7 @@ sudo docker compose --env-file .env.production logs -f backend
|
||||
`nginx-api-errors.conf`(이름 있는 오류 location — `@api_not_found` 404 `RES_001` 포함). 공개 화면 `/s/` 는 `no-referrer`(주소에 토큰) · `noindex` · `no-cache`.
|
||||
- 받기 `GET /api/public-links/{token}/files/…` · `GET /api/messages/{id}/large-files/…` 는 SSE 처럼 버퍼링 · 압축을 끄고 `X-Accel-Buffering` 을 넘긴다
|
||||
(기본값이면 받는 사람마다 1GB 까지 임시 파일에 받아 둔다). Range · If-Range 는 그대로 — 206 · 416 은 백엔드가 만든다.
|
||||
**휴대폰 앱(APK) 받기 `GET /api/app-releases/android/latest/file`**(2026-11-08 — 계약 1절 "휴대폰 앱 받기", 포털 · 로그인한 직원, 판마다 30MB 남짓)도 같은 줄들이다 —
|
||||
**휴대폰 앱(APK) 받기 `GET /api/app-releases/android/latest/file`**(2026-11-08 — 계약 1절 "휴대폰 앱 받기", 포털 · 로그인한 직원, 판마다 65MB 안팎 — 기기 종류 셋(ABI)이 한 파일에 든다)도 같은 줄들이다 —
|
||||
머리글을 대용량 받기와 같은 도우미(`backend/src/shared/utils/download-headers.ts` — `X-Accel-Buffering: no`)가 만든다. 정보 `GET /api/app-releases/android/latest`(JSON)는 받기 location 에 들지 않고,
|
||||
공개 내려받기 server 에서는 그 밖의 `/api` 처럼 404 `RES_001` 이다.
|
||||
머리글 버퍼는 `proxy_buffer_size 16k` + `proxy_busy_buffers_size 16k`(둘이 함께 — 하나만이면 `nginx -t` 가 실패) — 한글 255자 이름이면 머리글이 3.5KB 라 기본 4KB 에 아슬아슬하다.
|
||||
@@ -223,7 +223,7 @@ sudo docker compose --env-file .env.production logs -f backend
|
||||
- 업로드 중 임시 디스크: 백엔드는 첨부를 `/tmp/arasystem-mail-uploads`(컨테이너 쓰기 계층)에, nginx 는 요청 본문을 자기 임시 폴더에 받는다.
|
||||
필요한 공간은 동시 업로드 수 × 20MB 정도. backend 를 `read_only` 로 바꾸면 그 경로에 **tmpfs 가 아닌 볼륨**을 붙인다 (tmpfs 는 메모리 한도에 잡힌다).
|
||||
- **업로드 파일은 named volume `<PROJECT_NAME>_uploads` 에 쌓인다** (`/api/files`, 메일 서명 그림은 그 안의 `mail-signatures/` — 서명 표와 함께 백업한다,
|
||||
휴대폰 앱 APK 는 그 안의 `app-releases/` — 판마다 30MB 남짓 · 지우는 명령이 없어 올린 판이 모두 남는다 · 매일 백업에 든다). 호스트 디렉터리(`./data/uploads`)를 쓰지 않는 이유 —
|
||||
휴대폰 앱 APK 는 그 안의 `app-releases/` — 판마다 65MB 안팎 · 지우는 명령이 없어 올린 판이 모두 남는다 · 매일 백업에 든다). 호스트 디렉터리(`./data/uploads`)를 쓰지 않는 이유 —
|
||||
`sudo docker compose` 가 없는 디렉터리를 root 소유로 만들어 backend(node, UID 1000)가 쓰지 못한다. 새 named volume 은 이미지의
|
||||
`/app/uploads`(node 소유) 소유자를 물려받는다 (2026-10-27 운영 이미지로 확인). 개발만 `./data/uploads` 바인드 마운트다.
|
||||
백업은 매일 백업(`ops/` — 아래 "백업 · 감시")에 든다.
|
||||
|
||||
@@ -10,9 +10,9 @@ description: Flutter 모바일 앱(flutter/) 개발 규칙 — Dart Sound Null S
|
||||
|
||||
---
|
||||
|
||||
## 템플릿 기본 제공 골격
|
||||
## 기본 골격
|
||||
|
||||
> 이 템플릿은 네트워크 · 세션 · 라우터 · 모듈 틀을 **기본 제공**한다. 새 코드는 이 파일들의 패턴을 재사용한다(같은 역할을 새로 만들지 말 것).
|
||||
> 네트워크 · 세션 · 라우터 · 모듈 틀이 이미 있다. 새 코드는 이 파일들의 패턴을 재사용한다(같은 역할을 새로 만들지 말 것).
|
||||
> 앱 이름 "ARA SYSTEM", 식별자 `kr.it.arasystem.app`(Android applicationId · iOS bundle id — 직원 휴대폰에 설치한 뒤에는 못 바꾼다: 식별자가 다르면 다른 앱이라 그 위에 설치되지 않는다), 패키지 `arasystem_app`.
|
||||
|
||||
| 제공 항목 | 위치 | 비고 |
|
||||
@@ -25,9 +25,9 @@ description: Flutter 모바일 앱(flutter/) 개발 규칙 — Dart Sound Null S
|
||||
| 앱 로그인 저장소 | `lib/features/auth/data/` | 계약 1절 "앱 로그인"(`/auth/app/login` · `refresh` · `logout`(리프레시 토큰 + Bearer) + Bearer `me`, 인터셉터 줄 밖의 세션 확인 `readSessionDirect`). 실패는 늘 `DioException`(`error` 에 `ApiException` — 응답 모양이 틀리면 `SYS_001`, `apiFailure()`). 모델은 손으로 쓴다(`AppLoginResult` · `AuthSessionInfo` · `SessionRestriction` — 모르는 제한은 `unknown`). 로그인 화면이 칸에 보이는 실패(`kLoginInlineCodes`)는 알리지 않는다 |
|
||||
| **로그인 화면 (네 단계 — `App1Login`)** | `lib/features/auth/presentation/pages/`(login · login_code · password_change · two_factor_setup · restricted_login) · `widgets/`(`login_frame` · `login_fields` · `login_parts`) · 글 `core/constants/login_texts.dart`(제목은 `screen_titles.dart`) · `data/`(`two_factor_repository` · `support_contact_repository` · `authenticator_launcher`) | 노티파이어의 제한 단계 API(`attempt` · `passedCodeStep` · `heldLoginListenable` · `changePassword` · `enableTwoFactor` · `finishRestrictedStep` → `RestrictedStepResult { done, needsPassword, stale }`). 로그인 비밀번호는 두 제한 단계에서만 10분 쥔다(모르는 제한 · 제한이 풀리면 지움). 칸 · 상자에 보이는 코드만 알림을 끈다(`kLoginInlineCodes` · `kLoginCodeInlineCodes` · `kPasswordChangeInlineCodes` · `kTwoFactorSetupInlineCodes` — 앱 디자인 README 의 표), `AUTH_002` 는 스낵바 없이 로그인 화면 상자만. 2단계 키 · otpauth 주소는 그 화면 State 에만. **url_launcher**: `canLaunchUrl` 은 쓰지 않는다(debug 판이 주소째 로그를 남긴다) — `launchUrl(externalNonBrowserApplication)` 만, 실패 예외에 주소가 들어 있어 삼킨다, `<queries>` 는 필요 없다. 한글 줄바꿈은 `shared/widgets/keep_all_text.dart`(시험은 `findCopy`). 보안 설정 요청 한도 `kSecuritySettingTimeout` 30초 |
|
||||
| **메일 목록 (`App1MailList`)** · 앱 틀 | `lib/features/mail/` — data(models · `mail_repository.dart`) · domain(`mailbox_tree` · `mail_list_merge` · `mail_bulk` — 순수 함수) · presentation(`pages/mail_list_page.dart` · providers · widgets), 틀 `core/router/app_shell.dart` · `shell_bar.dart`, `core/utils/`(`app_foreground` · `accessible_navigation` · `korean_format`), `shared/widgets/confirm_dialog.dart`, 시험 도구 `test/support/mail_fakes.dart` · `mail_harness.dart` | **틀은 Scaffold 가 아니다**(Column + 탭 막대 — 탭 안 화면마다 자기 Scaffold 를 둔다, 없으면 스낵바 · Material 조상이 없다). 탭 막대는 `MediaQuery.removePadding(removeTop)` 으로 감싸고, 화면이 숨기려면 `ShellBarHidden`(모듈마다 센다 — 보이는지는 TickerMode). Scaffold 는 넓은 단추 자리에 안전 영역을 넘기지 않으니 Scaffold 밖에서 읽어 넘긴다. 목록 상태는 메일함마다(`mailListProvider(String?)` autoDispose), 세션이 바뀌면 `mailAccountProvider` 로 처음부터. 알림은 두 갈래 — 단추 있는 `mailToastProvider`(한 번에 하나 · 화면 낭독기 켠 동안 닫지 않음) · 단추 없는 `mailNoticeSinkProvider`(전역 스낵바). 생명주기 안에서는 프로바이더를 바꾸지 않는다(그린 뒤 · 마이크로태스크), 다른 노티파이어 인스턴스를 든 노티파이어는 세션이 바뀐 뒤 다시 읽는다. 가짜 서버는 응답이 한 프레임 안에 오므로 "받는 중이 끝나는 순간"을 기다리는 위젯을 만들지 않는다 |
|
||||
| **메일 읽기 (`App1Reader`)** | `lib/features/mail/` — domain(`mail_html`(두 번째 정화 · 중첩 비용 · CSP 문서 · 중계 그림 바꿔 넣기) · `mail_images` · `mail_links` · `compose_request` · `mail_body_notices`) · `data/`(`models/message_detail.dart` · `mail_link_launcher.dart`) · presentation(`pages/message_reader_page.dart` · `providers/message_reader_controller.dart` · `attachment_downloads.dart` · widgets `mail_body_view` · `reader_*`), 시험 도구 `test/support/fake_webview.dart`(`WebViewPlatform.instance`) · `file_fakes.dart` | **WebView 에는 `buildMailDocument` 의 문서만 넣는다**(CSP 를 맨 앞 · JS 끔 · 처음 문서 말고는 탐색을 막음 · 안드로이드는 파일 · content 접근 · 위치 · 섞인 내용 끔, 아래 틀(iframe)의 탐색은 판정을 거치지 않으니 막는 것은 정화(내용째 버림)와 CSP 다). 받은 HTML 은 서버가 정화했어도 `sanitizeMailHtml` 을 한 번 더 지난다 — package:html 은 `parse().body`(`parseFragment` 는 맨 위 형제가 많으면 제곱), 그 전에 `htmlNestingCost`(선형 · 파서보다 깊게 센다)로 한도를 보고, 바로 할지 isolate 로 보낼지는 `mailBodyBuildPlaceOf` 하나로 고른다. 중계 그림은 Dio(Bearer)로 받아 `data:` 로 넣고 토큰은 WebView 에 가지 않는다 — 문서에 싣는 양은 **나온 횟수까지** 센다(`resolveProxiedImages`). 응답은 그 요청이 바꾼 칸만 지금 요약에 덮는다(`_applyResponse`). 메일 id 마다 autoDispose 프로바이더(라우트 `ValueKey(id)`) · 받기 세대 · 본문 세대 · 세션 세대로 늦은 응답을 버린다. 목록에는 `MailChange(updated, removedIds)` 로 알린다. 쓰기 화면을 여는 값은 웹과 같은 쿼리(`replyTo` · `all` · `forward` · `draft` — `composeIntentOf`), mailto 로 채울 값은 go_router `extra` 의 `ComposePrefill` |
|
||||
| **메일 읽기 (`App1Reader`)** | `lib/features/mail/` — domain(`mail_html`(두 번째 정화 · 중첩 비용 · CSP 문서 · 중계 그림 바꿔 넣기) · `mail_images` · `mail_links` · `compose_request` · `mail_body_notices`) · `data/`(`models/message_detail.dart` · `mail_link_launcher.dart`) · presentation(`pages/message_reader_page.dart` · `providers/message_reader_controller.dart` · `attachment_downloads.dart` · widgets `mail_body_view` · `reader_*`), 시험 도구 `test/support/fake_webview.dart`(`WebViewPlatform.instance`) · `file_fakes.dart` | **WebView 에는 `buildMailDocument` 의 문서만 넣는다**(CSP 를 맨 앞 · JS 끔 · 처음 문서 말고는 탐색을 막음 · 안드로이드는 파일 · content 접근 · 위치 · 섞인 내용 끔, 아래 틀(iframe)의 탐색은 판정을 거치지 않으니 막는 것은 정화(내용째 버림)와 CSP 다). **보이는 것이 없는 본문이면 본문 칸(WebView)을 두지 않는다**(2026-11-12 — 첨부만 있는 메일에서 아래 절반이 빈 칸으로 남고 첨부가 위 절반에서 잘렸다): 판정은 정화 길이 함께 낸다(`SanitizedMailBody.hasVisibleContent` — 남긴 글 가운데 `core/utils/visible_text.dart` 의 `hasVisibleText`(공백 · 제어 문자 · Default_Ignorable(제로폭 · 방향 표시 · 한글 채움 등) · 점자 빈칸은 보이지 않음)인 글, 주소가 남은 그림(`_attributesOf` 가 src 를 맨 앞에 쓰는 모양에 기댄다 — 외부 그림은 받기 전에도), 글 없이 그려지는 `hr` · `li` · `details` · `q`. 꾸밈(style · bgcolor · border)만은 치지 않는다. 고르는 본문(`displayedHtmlBody`)은 그대로) → 상태 `bodyEmpty` → 머리의 최대 높이가 화면 전체(`kReaderHeadMaxFraction` 대신 — 그림 받는 진행 막대 자리만 뺀다 · 0 아래로 내려가지 않게), 아래 막대가 없는 메일(예약)은 머리 스크롤에 시스템 아래 여백을 더한다. 문서를 만들기 전(`document == null`)은 지금처럼 본문 자리의 진행 줄. 받은 HTML 은 서버가 정화했어도 `sanitizeMailHtml` 을 한 번 더 지난다 — package:html 은 `parse().body`(`parseFragment` 는 맨 위 형제가 많으면 제곱), 그 전에 `htmlNestingCost`(선형 · 파서보다 깊게 센다)로 한도를 보고, 바로 할지 isolate 로 보낼지는 `mailBodyBuildPlaceOf` 하나로 고른다. 중계 그림은 Dio(Bearer)로 받아 `data:` 로 넣고 토큰은 WebView 에 가지 않는다 — 문서에 싣는 양은 **나온 횟수까지** 센다(`resolveProxiedImages`). 응답은 그 요청이 바꾼 칸만 지금 요약에 덮는다(`_applyResponse`). 메일 id 마다 autoDispose 프로바이더(라우트 `ValueKey(id)`) · 받기 세대 · 본문 세대 · 세션 세대로 늦은 응답을 버린다. 목록에는 `MailChange(updated, removedIds)` 로 알린다. 쓰기 화면을 여는 값은 웹과 같은 쿼리(`replyTo` · `all` · `forward` · `draft` — `composeIntentOf`), mailto 로 채울 값은 go_router `extra` 의 `ComposePrefill` |
|
||||
| 받은 파일 · 고른 파일 · 형식 | `lib/core/files/` — `downloaded_files.dart`(`DownloadedFileStore` · `CacheDirFileStore` · `FileOpener`) · `picked_files.dart`(`PickedFileStore` · `PlatformPickedFileStore` — 채널의 `pick`) · `file_names.dart`(`safeFileName` · `displayFileName`) · `file_types.dart`(`fileOpenTypeOf` · `isBlockedFileOpen`), 안드로이드 `MainActivity.kt`(채널 `kr.it.arasystem.app/files`) · `res/xml/downloaded_file_paths.xml` · 매니페스트의 FileProvider `${applicationId}.downloads` | 모듈 공통(메일 첨부 · 나중의 파일 모듈). 자리는 `cacheDir/downloads/<무작위 16자>/<정리한 이름>` 뿐 — **짝 셋**: Dart `kDownloadedFilesFolder` · `downloaded_file_paths.xml` 의 cache-path · `MainActivity.kt` 의 `DOWNLOADS_DIR`(채널 이름도 짝). Kotlin 은 canonical 경로로 그 폴더 안인지 다시 보고 읽기 권한을 한 번만 준다. 파일 여는 플러그인(open_filex 등)은 쓰지 않는다 — 저장소 권한 · 기기 전체 경로를 더한다. 열 형식은 확장자 먼저(`package:mime`), 보낸 쪽 형식은 확장자를 모를 때만 — 형식 글 모양 검사는 짝: Kotlin `MIME_PATTERN` · `GENERIC_TYPE`. 막는 형식(웹 문서 · svg · 설치 파일 · 인증서)은 받지도 않는다. **고른 파일**(메일 쓰기 2차): 고르는 창(`ACTION_GET_CONTENT` — 권한 없음 · `*/*` · 여러 개)으로 고른 파일을 `cacheDir/uploads/<무작위 16자>/<정리한 이름>` 에 복사본으로 둔다 — **짝 둘**: Dart `kPickedFilesFolder` · `MainActivity.kt` 의 `UPLOADS_DIR`(다른 앱에 건네지 않아 FileProvider xml 에는 없다). 채널 인자 · 답의 칸 · 값도 짝이다 — Dart `PlatformPickedFileStore.pick` · `PickResult.parse` · `PickedFile.fromChannel` ↔ Kotlin `ARG_*` · `KEY_*` · `OUTCOME_*` · `STATUS_*`. Kotlin 은 `content://` 만 읽고, Dart 사전 검사와 같은 차례(개수 → 형식 → 파일당 → 합계 — `copyPicked` ↔ `precheckPickedFile`)로 넣을 파일만 복사한다 — 미리 거르기일 뿐 판정은 Dart 가 다시 한다. 확장자를 떼는 규칙도 짝(Kotlin `displayFileName` · `extensionOf` · `isDartWhitespace` ↔ Dart `displayFileName` · `extensionOf` · `trim()`). 복사본 이름은 Kotlin `safeFileName`(Dart 와 같은 규칙), 보이고 보내는 이름은 `displayFileName` 이다. 복사 스레드는 Throwable 까지 잡아 한 번만 답한다. 고르기와 모두 지우기는 차례로 돌고, 지우기는 기호 링크를 푼 경로로 복사본 폴더 바로 아래 한 칸만 지운다. `pick` 은 그 차례 안에서 `kPickTimeout`(10분) 시한을 둔다 — 넘으면 `timedOut`(Dart 에만 있는 결과)으로 끝내고 차례를 넘긴다(늦게 온 복사본은 다음 정리에). 파일 고르는 플러그인은 쓰지 않는다(권한 · 기기 전체 경로) |
|
||||
| **메일 쓰기 (`App1Compose` — 1차 · 2차)** | `lib/core/people/`(사람 고르기 — 주소 칩 · 추천 맞추기 · 그룹, 모듈 공통. 표본 표 `test/core/people/recipient_match_samples.dart` 는 웹 · 백엔드와 **세 쪽 짝**) · `features/mail/domain/`(`compose_body` 직렬화 · `compose_fit`(`buildInitialQuote`) · `mail_quote` · `compose_draft_body`(`buildDraftBody` · `draftBodyPlaceOf`) · `compose_reply` · `compose_send` · `compose_attachments`(2차 — 형식 표 · 한도 · 사전 검사) · `compose_signature` · `html_nodes`) · `data/`(`compose_models` · `compose_payload` · `repositories/compose_repository.dart`) · presentation(`pages/message_compose_page.dart` · `providers/compose_controller.dart`(화면마다 하나 — `ComposeArgs.instance`, autoDispose) · `draft_autosave.dart` · `attachment_uploads.dart`(`AttachmentUploadQueue` — 둘씩 · 취소 · 다시 올리기) · `signature_image_provider.dart` · widgets `compose_*`) · `core/utils/utf16_length.dart` · 시험 도구 `test/support/compose_fakes.dart` | 본문 모양은 계약 2절 "앱이 만드는 모양". 웹 짝(`mail-address.ts` · `recipient-match.ts` · `MAIL_HTML_STYLES` · `docToText` · `fit-draft` · `DRAFT_*` · `shouldRenewSendKey`)은 각 파일 머리말에 적었다 — 한쪽을 고치면 둘 다. **package:html 의 `text` · `querySelectorAll` · `innerHtml` · `outerHtml` 은 깊이만큼 되풀이한다** — 쓰기 화면은 `html_nodes.dart`(되풀이 없는 글 모으기 · 찾기 · 직렬화)를 쓰고, 저장본 · 원문은 읽기 화면과 같은 한도 · 문턱(`mailBodyBuildPlaceOf`)으로 그 자리 · isolate · 읽지 않음을 고른다. **화면을 떠나도 끝까지 가야 하는 일(보내기)은 기다림을 시작하기 전에 `ref.keepAlive()` 를 잡고 try/finally 로 놓는다**(2026-11-07 리뷰 — 저장을 기다리는 사이 내려가 메일이 나가지 않았다). 임시 저장은 Timer 만 쓴다(시험은 `tester.pump` 로 시간을 넘긴다). 보내기 결과는 `mailToastProvider` 의 마감 알림(목록 · 읽기 화면이 그린다). 서버와 같은 한도를 거는 칸은 `Utf16LengthLimitFormatter`(UTF-16 단위). **첨부(2차)**: 형식 표 `kMailAttachmentTypes` 는 **짝: 백엔드 `attachment.policy.ts` 의 `ATTACHMENT_TYPES`** — 시험이 그 파일을 읽어 표와 `MAX_ATTACHMENTS_PER_MESSAGE` 를 맞춰 본다 (앱 CI 는 flutter/ 가 바뀐 커밋에서만 돈다 — 백엔드 표만 고친 커밋에서는 이 시험이 돌지 않으니 함께 고친다). 사전 검사는 웹 `precheckAttachment` 의 차례, 보내기 막기는 웹 `sendBlockOf` 의 차례(가져오는 중은 올리는 중 다음). 저장소 `uploadAttachment`(multipart `file` · 확장자의 정식 형식 · 보내기 5분 · 받기 60초 · 알림 끔 — 401 뒤에는 FormData 를 복제해 복사본을 처음부터 다시 읽는다) · `getAttachmentLimits`(`GET /api/mail-quota`). 다 올리면 서버의 이름 · 크기로 싣는다(이름 255자 — 웹과 같다, 2026-11-07 리뷰). `MAIL_004` 뒤 다시 올릴 수 없는 고른 파일은 `problem: missing` 으로 막는다(웹 `markMissing`). 시험 도구 `FakePickedFiles`(`test/support/file_fakes.dart`) · `FakeComposeBackend` 의 `uploads` · `uploadGates` · `quota` · `quotaGate` · `FakeServer.readRequestBodies` |
|
||||
| **메일 쓰기 (`App1Compose` — 1차 · 2차)** | `lib/core/people/`(사람 고르기 — 주소 칩 · 추천 맞추기 · 그룹, 모듈 공통. 표본 표 `test/core/people/recipient_match_samples.dart` 는 웹 · 백엔드와 **세 쪽 짝**) · `features/mail/domain/`(`compose_body` 직렬화 · `compose_fit`(`buildInitialQuote`) · `mail_quote` · `compose_draft_body`(`buildDraftBody` · `draftBodyPlaceOf`) · `compose_reply` · `compose_send` · `compose_attachments`(2차 — 형식 표 · 한도 · 사전 검사) · `compose_signature` · `html_nodes`) · `data/`(`compose_models` · `compose_payload` · `repositories/compose_repository.dart`) · presentation(`pages/message_compose_page.dart` · `providers/compose_controller.dart`(화면마다 하나 — `ComposeArgs.instance`, autoDispose) · `draft_autosave.dart` · `attachment_uploads.dart`(`AttachmentUploadQueue` — 둘씩 · 취소 · 다시 올리기) · `signature_image_provider.dart` · widgets `compose_*`) · `core/utils/utf16_length.dart` · 시험 도구 `test/support/compose_fakes.dart` | 본문 모양은 계약 2절 "앱이 만드는 모양". 웹 짝(`mail-address.ts` · `recipient-match.ts` · `MAIL_HTML_STYLES` · `docToText` · `fit-draft` · `DRAFT_*` · `shouldRenewSendKey`)은 각 파일 머리말에 적었다 — 한쪽을 고치면 둘 다. **package:html 의 `text` · `querySelectorAll` · `innerHtml` · `outerHtml` 은 깊이만큼 되풀이한다** — 쓰기 화면은 `html_nodes.dart`(되풀이 없는 글 모으기 · 찾기 · 직렬화)를 쓰고, 저장본 · 원문은 읽기 화면과 같은 한도 · 문턱(`mailBodyBuildPlaceOf`)으로 그 자리 · isolate · 읽지 않음을 고른다. **화면을 떠나도 끝까지 가야 하는 일(보내기)은 기다림을 시작하기 전에 `ref.keepAlive()` 를 잡고 try/finally 로 놓는다**(2026-11-07 리뷰 — 저장을 기다리는 사이 내려가 메일이 나가지 않았다). 임시 저장은 Timer 만 쓴다(시험은 `tester.pump` 로 시간을 넘긴다). 보내기 결과는 `mailToastProvider` 의 마감 알림(목록 · 읽기 화면이 그린다). 서버와 같은 한도를 거는 칸은 `Utf16LengthLimitFormatter`(UTF-16 단위). **첨부(2차)**: 형식 표 `kMailAttachmentTypes` 는 **짝: 백엔드 `attachment.policy.ts` 의 `ATTACHMENT_TYPES`** — 시험이 그 파일을 읽어 표와 `MAX_ATTACHMENTS_PER_MESSAGE` 를 맞춰 본다 (앱 CI 는 flutter/ 가 바뀐 커밋에서만 돈다 — 백엔드 표만 고친 커밋에서는 이 시험이 돌지 않으니 함께 고친다). 사전 검사는 웹 `precheckAttachment` 의 차례, 보내기 막기는 웹 `sendBlockOf` 의 차례(가져오는 중은 올리는 중 다음). 저장소 `uploadAttachment`(multipart `file` · 확장자의 정식 형식 · 보내기 5분 · 받기 60초 · 알림 끔 — 401 뒤에는 FormData 를 복제해 복사본을 처음부터 다시 읽는다) · `getAttachmentLimits`(`GET /api/mail-quota`). 다 올리면 서버의 이름 · 크기로 싣는다(이름 255자 — 웹과 같다, 2026-11-07 리뷰). `MAIL_004` 뒤 다시 올릴 수 없는 고른 파일은 `problem: missing` 으로 막는다(웹 `markMissing`). 시험 도구 `FakePickedFiles`(`test/support/file_fakes.dart`) · `FakeComposeBackend` 의 `uploads` · `uploadGates` · `quota` · `quotaGate` · `FakeServer.readRequestBodies`. **칸 줄**은 `ComposeFieldRow`(`widgets/compose_recipient_field.dart`) 하나 — 칸 이름 72 · 글자 15 · 간격 6 · 여백 4 4 4 16, 칸 이름 · 오른쪽 단추는 첫 줄(`ComposeFieldRow.lineHeight` 48)의 가운데(받는 사람은 `firstLineHeight` 로 칩 줄 40 을 넘겨 칩이 여러 줄이어도 첫 칩 줄에 맞춘다), 아래 선은 `foregroundDecoration` 이라 초점으로 굵어져도 칸이 움직이지 않는다. 입력칸은 `borderlessInputDecoration`(2026-11-12 — 아래 함정). 본문 칸 최소 높이는 `composeBodyMinHeightOf`(원본 메일이 있으면 150 · 없으면 240 — 서명과 상관없다, 보드 App1Compose) |
|
||||
| **메일 검색 (`App1Search` — 1차 · `App1SearchConditions` — 2차)** | domain `search_text`(**짝: 웹 `search-match.ts` · `mail-search.ts` · 백엔드 `search-text.ts`** — 표본 표는 시험이 `backend/test/search-text-samples.ts` 의 FOLD · GRAM 을 직접 읽는다 · `test/support/ts_table.dart`) · `search_highlight`(짝: 웹 `search-highlight.ts`) · `search_conditions`(2차 — **짝: 웹 `mail-search.ts` · `mail-vacation.ts`** — 조건 다듬기 · 합친 낱말 수 · 기간 · after/before(기기 시간대 0시 → UTC, 끝 날짜는 다음 날 0시) · 강조 쿼리 · "나") · `data/models/mail_search.dart` · `mail_repository`(`searchMessages` — 응답의 `Date` 로 서버 시각 · `searchIndexStatus` · `getMessage(highlight)`) · presentation(`pages/mail_search_page` · `providers/mail_search_controller`(화면마다 — `MailSearchArgs.instance`, autoDispose) · `mail_search_state`(`waitsForMore`) · `mail_search_view` · widgets `mail_search_bar`(+ 공용 `LineBreaksToSpacesFormatter`) · `mail_search_states` · `highlighted_text` · `mail_search_conditions_sheet`(2차)) · `core/utils/js_case.dart` | 탭 안 `/mail/search`(시작한 메일함은 `extra` 의 `MailSearchStart`, 검색어는 주소에 싣지 않는다). 이어 찾기는 한 차례 `kMailSearchFillMaxRequests`(5) · 20통, 모자라면 [더 찾기]로 멈춘다. 검색 세대 + `CancelToken`(새 검색 · 화면 닫기에 끊는다), 준비 상태는 따로 세대. 결과는 `MailChange` 와 붙잡힌 줄 다시 받기(보낼 시각 + 2초)만 반영한다. **강조 위치는 그 응답의 글에만**. 읽기 키는 `MessageReaderArgs(id, highlight)`, 결과에서 연 메일은 `MailSearchOpen`. 조건(2차)은 화면 상태 `MailSearchState.conditions` 로 이어 찾기 · 새로고침 · 다시 시도 · 찾을 곳 바꾸기에 이어지고, "나"는 `mailSelfProvider`. 시트 가림막은 테마의 바닥 시트 가림막(`AppColors.backdrop` — 옮기기 시트도), 날짜 창은 `showDatePicker(barrierColor:)` · 머리 22(`datePickerTheme`). 노랑 `AppColors.searchMark` 는 **짝: 웹 `--color-search-mark` · `mail_html.dart` 의 mark**. 시험 도구는 `FakeMailBackend` 의 `defaultSearch`(받은 시각 내림차순 · `searchCursorAfter` 는 그 줄 다음부터) · `move` · `searchResponder` · `searchGate` · `searchHighlights` · `highlightDetails` · `searchIndex` |
|
||||
| HTTP 클라이언트 | `lib/core/api/dio_client.dart` · `dio_provider.dart` | Dio 둘: 화면용(인터셉터 줄 `ResponseInterceptor` → `AuthInterceptor` → `ErrorNotifyInterceptor`)과 갱신 · 다시 보내기 · 로그인 없는 앱 설정용(언래핑만 — `publicDioProvider`. 인증 인터셉터는 요청을 한 줄로 세워 앞 요청의 미리 갱신을 기다리게 하므로, 앱을 띄우자마자 묻는 앱 설정은 줄 밖으로 보낸다). baseUrl=dotenv `API_URL`(`api_config.dart`), User-Agent `ARASYSTEM-App/<판> (Android <판>; <기종>)`(`user_agent.dart`). `LogInterceptor` 는 넣지 않는다(비밀번호 · 토큰). 시험은 `httpClientAdapterProvider` 로 가짜 서버를 끼운다 |
|
||||
| 언래핑 · 코드 정규화 | `lib/core/api/response_interceptor.dart` · `api_error_mapper.dart` | `{success,data}→data`(스트림 · 바이트는 그대로 — 그 **실패** 본문은 64KB 까지 읽어 `error.code` 를 꺼낸다(`decodeRawErrorBody`). 스트림을 받는 도중의 실패는 인터셉터를 거치지 않아 부르는 쪽이 `SYS_001` 로 알린다(`DownloadInterrupted`). 비동기 처리기는 무슨 일이 있어도 `handler.next` 로 끝낸다 — 던지면 요청이 매달린다), 200 + `success:false` 도 실패, 실패는 모두 `DioException.error` 에 `ApiException`. 상태 → 코드 폴백(웹 `resolveCodeByStatus` 와 같다) |
|
||||
@@ -36,13 +36,15 @@ description: Flutter 모바일 앱(flutter/) 개발 규칙 — Dart Sound Null S
|
||||
| **인증 인터셉터** | `lib/core/api/auth_interceptor.dart` · `token_refresher.dart` | `QueuedInterceptor`. Bearer 붙이기 · 액세스 토큰이 없거나 30초 안에 끝나면 미리 갱신 · 401 이면 갱신 한 번 → 원래 요청 한 번 다시(로그인 · 갱신 · 로그아웃 제외 — `ApiPaths.noRefresh`). 갱신은 `TokenRefresher` 가 **한 번에 하나**(동시 401 은 토큰 세대를 보고 묶는다). 갱신 401 · 유예 응답(`tokens: null`) → 토큰 지움 + `AUTH_002` + 세션 끝, 5xx · 연결 끊김은 세션을 두고 그 코드로 알림, 갱신 중 로그아웃이면 받은 토큰을 버리고 서버 세션도 끝낸다. 갱신의 일시 장애는 같은 토큰 세대에서 5초 동안 다시 쓴다(`kRefreshFailureReuse` — 연결이 끊긴 동안 줄 선 요청마다 갱신하지 않게). `AUTH_010` 은 세션 노티파이어에 다시 맞춰 달라고 하고(`requestRecheck` — 안전 한도 65초) renewed 면 한 번 다시 보낸다 · restricted · stale 은 알리지 않는다 · unknown 은 알린다(웹 `recheckRestriction` 과 같은 결). 같은 토큰으로 줄 선 `AUTH_010` 은 한 번만 묻는다(그사이 새 토큰이면 묻지 않고 다시 보내고, restricted 는 그 세대 동안 · unknown 은 5초 다시 쓴다). **다시 보낸 요청의 실패도 첫 요청과 같은 규칙**(401 · `AUTH_010` 마다 한 번씩 — `kAuthRetriedExtraKey` · `kRestrictionRetriedExtraKey`). **요청을 보낸 세션이 끝났으면**(세션 세대 `kSessionGenerationExtraKey` 가 다르다 — 로그아웃 · 세션 끝 · 그 뒤 다른 직원 로그인) 어떤 실패든 조용히 끝낸다 — 401 은 사용자가 로그아웃했으면 `AUTH_001` 그대로, 세션이 끝났으면 `AUTH_002`. 실패를 내보내기 전에 Bearer · 비밀 본문을 지운다(`withoutSecrets`). 처리기는 뜻밖의 예외에도 꼭 부른다(`SYS_001`). 이 인터셉터의 오류 처리 안에서 보내는 요청(갱신 · 세션 확인 · 다시 보내기)은 모두 안쪽 Dio — 같은 줄로 보내면 서로 기다린다 |
|
||||
| 오류 알림 | `lib/core/api/error_notifier.dart` | 전역 `ScaffoldMessenger` 키 하나로 스낵바(웹 `showErrorUI` 자리). 같은 코드는 3초 안에 다시 띄우지 않는다. 요청마다 끄기: `Options(extra: {kSilentExtraKey: true})` 또는 코드 목록(`ApiOptions.silentFor`) — `request_flags.dart`. **비밀이 든 요청**(비밀번호 · 코드 · 리프레시 토큰이 본문에)은 `ApiOptions.secret(...)` 으로 보낸다 — 실패 예외의 본문을 비워 던져 화면이 `AsyncValue` 로 상태에 두어도 된다 |
|
||||
| 토큰 보관 | `lib/core/storage/` — `token_store.dart` · `secure_store.dart` · `first_run_marker.dart` | 리프레시 토큰만 `flutter_secure_storage`(키 상수 `kRefreshTokenKey`), 액세스 토큰은 메모리. 새 토큰은 받자마자 저장(로그인 저장이 실패하면 그 세션을 끝내고 `SYS_001`, 갱신 저장이 실패하면 이번 실행은 메모리의 새 토큰으로 이어 가고 다음 읽기 · 저장 때 다시 쓴다). 보안 저장소 일은 부른 차례대로 하나씩(로그인 저장과 로그아웃 지우기가 엇갈리지 않게). 토큰 세대 `generation` · 세션 세대 `sessionGeneration`(지울 때만 오른다), 로그아웃은 `signOut()`(메모리를 곧바로 비우고 서버 logout 에 쓸 토큰을 돌려준다). 만료는 기기 시계로 보지 않는다 — 응답의 `Date`(서버 시각, `core/api/server_time.dart`)로 남은 시간을 재고 받은 때부터 센다. 첫 실행 표시(`shared_preferences` — 참 · 거짓 하나)가 없으면 보안 저장소를 모두 지운다(iOS Keychain 은 앱을 지워도 남는다). Android 는 백업을 껐다(`AndroidManifest` · `res/xml/data_extraction_rules.xml`) |
|
||||
| 테마 | `lib/core/theme/` | 색은 웹 `tokens.css` 를 `ColorScheme` 에 손으로 대응(`app_colors.dart` · `app_theme.dart` — 시드 생성 쓰지 않음, Material 에 없는 색은 `AppExtraColors`). 간격 · 모서리 `AppSpacing` · `AppRadius`, 누르는 곳 최소 48dp(`AppSize.touchMin`). 글자는 `app_typography.dart` 한 곳 — 화면 글 15 · 입력 칸 16 · 위 막대 제목 18 · 메일 본문 15/1.75 는 확정(`docs/design/app/README.md`), 그 밖의 역할은 임시 값 |
|
||||
| 테마 | `lib/core/theme/` | 색은 웹 `tokens.css` 를 `ColorScheme` 에 손으로 대응(`app_colors.dart` · `app_theme.dart` — 시드 생성 쓰지 않음, Material 에 없는 색은 `AppExtraColors`). 간격 · 모서리 `AppSpacing` · `AppRadius`, 누르는 곳 최소 48dp(`AppSize.touchMin`). 글자는 `app_typography.dart` 한 곳 — 화면 글 15 · 입력 칸 16 · 위 막대 제목 18 · 메일 본문 15/1.75 는 확정(`docs/design/app/README.md`), 그 밖의 역할은 임시 값. 테두리 없는 입력칸(칸 줄이 아래 선 · 초점을 그리는 쓰기 화면 · 검색 막대)은 `borderlessInputDecoration` — 테마의 입력칸 테두리를 물려받지 않는다 |
|
||||
| 글꼴 | `assets/fonts/` · `assets/licenses/pretendard-ofl.txt` · `core/theme/app_fonts.dart` | Pretendard 1.3.9 정적 OTF 넷(400 · 500 · 600 · 700 — 웹 npm `pretendard` 와 같은 파일). OFL 1.1 글을 `LicenseRegistry` 에 올린다(설정 › 앱 정보 › 오픈소스 라이선스) |
|
||||
| **새 메일 알림 (`App1Push`)** | `lib/core/push/` — 포트 `push_messaging.dart`(FCM 은 `firebase_push_messaging.dart` — 띄우지 못하면 `UnavailablePushMessaging`) · `notification_system.dart`(채널 · 알림 지우기 · 휴대폰 설정 열기 — 안드로이드 `NewMailNotifications.kt`, 채널 `kr.it.arasystem.app/notifications`) · `push_preferences.dart`(이 휴대폰의 값 — `shared_preferences`, 참 · 거짓과 FID 하나뿐) · `push_state.dart`(상태 · 설정 줄 모습 `pushRowStatusOf` · 권하기 줄 `showsPushOffer`) · `push_controller.dart`(앱에 하나 — `PushRegistrar` 를 구현) · `push_registration.dart`(서버 저장소 · `pushRegistrarProvider`) · `push_events.dart`(앞에서 받음 · 누름 — 라우터 `core/router/push_navigation.dart` 가 누른 알림을 늘 메일 받은편지함으로) · `push_target.dart` · 글 `push_texts.dart`. 화면 `features/mail/presentation/widgets/push_offer_line.dart` · `features/settings/presentation/widgets/settings_notification_row.dart` | **보낼 조건** = 받을 수 있는 휴대폰 ∧ 서버가 보낼 수 있음(app-config `pushAvailable` — 모르면 보낼 수 있는 것으로) ∧ 메일을 쓸 수 있는 세션(세션 노티파이어의 `register` · 제한 단계는 `pause`) ∧ 이 휴대폰에서 켬 ∧ 권한 ∧ 새 메일 채널. 맞추기 `_reconcile` 하나가 PUT · DELETE 를 고른다. **PUT** 은 세션이 열릴 때 한 번 · FID 가 바뀔 때 · 켤 때만(앞으로 올 때마다가 아니다), **DELETE** 는 끌 때와 권한 · 채널이 꺼진 것을 알았을 때만 — 로그아웃 · 세션 끝은 FCM `unregister()` 만(서버 줄은 서버가 세션과 함께 지운다, 풀지 못하면 `pendingUnregister` 로 다음 실행 · 앞으로 옴에 다시). **봤음** `DELETE …/unseen` 은 앞으로 올 때 · 앞에서 받은 새 메일 알림 때, 30초 간격 + 끝 한 번 — 앱이 앞에 있을 때만 보내고, 기다리는 동안 뒤로 가면 그 자리에서 보낸다(끝까지 기다리면 그사이 온 메일까지 본 것으로 센다). 앞에서 받은 알림 뒤의 봤음은 받은 때 + 1.5초(`kPushSeenAfterMessageDelay`)보다 일찍 보내지 않는다 — 서버는 seen_at 의 초부터 같은 초를 세고 받은 시각은 초 단위라, 곧바로 보내면 열지 않은 그 메일이 다음 알림에 다시 세어진다(2026-11-08 실기기). 켜기 · 끄기는 서버가 답한 뒤에 바꾼다(실패는 그 코드의 사전 문구 — 등록 소식이 없으면 `SYS_001`). **켜기가 실패하면 이 휴대폰의 값을 끔으로 둔다**(처음 값 켬에서 허락하고 켰어도 — 스위치는 꺼진 채, 저절로 다시 하지 않는다). 서버 일은 멈춘(`pause`) 바로 그 차례에도 보내지 않는다(`_canReachServer` 가 컨트롤러의 세션 값도 본다). FCM `register` · `unregister` 는 우리가 한 줄로 세운다(플러그인이 스레드 풀에서 돈다), `onRegistered` 는 방송이라 `register` 전에 듣는다. **풀기는 기다리기 전에 등록 세대를 올리고 등록을 내려 둔다** — 풀기가 실패하거나 오래 걸려도 같은 실행의 다음 로그인이 그 뒤에 다시 등록하고(같은 FID 의 PUT 이 서버의 앞 직원 줄을 지운다), 풀기 앞의 등록은 끝나도 마친 것으로 치지 않으며 "풀지 못한 등록" 표시도 지우지 않는다. 풀기가 줄에 있는 동안 온 등록 소식은 쓰지 않는다. 앱이 앞에 있으면 FCM 이 알림을 그리지 않는다 — 앞에서 받은 새 메일 알림은 메일 목록이 바로 새 메일을 확인한다(`mail_state_watcher`). 알림 데이터는 **모듈 이름과 종류**(`{ module: 'mail', kind: 'new-mail' }` · `session-ended`). FID · 주소 · 토큰을 로그 · 디버그 출력에 남기지 않는다. 시험 도구 `test/support/push_fakes.dart`(가짜 FCM · 알림창 · 값 · 서버) · `push_screen_harness.dart`(앱 전체 + 진짜 컨트롤러 — `ProviderHarness.recordPush = false`). 다른 시험대는 기기 등록을 기록하는 가짜(`RecordingPushRegistrar`)이고 컨트롤러는 FCM 이 없어 조용하다 |
|
||||
| **앱 설정 · 업데이트 화면** | `lib/core/app_config/`(`app_config.dart` — 판 비교 `AppVersion` · 모양, `app_config_repository.dart`, `app_config_controller.dart` — `appUpdateRequiredProvider` · `pushAvailableProvider`) · `core/router/session_redirect.dart`(`updateRequired` 가 세션보다 먼저) · `features/app_update/`(모듈이 아니다 — 화면 · 글) · `core/app_download/`([새 버전 받기] — 받기 화면 주소 `app_download_link.dart` · 런처 `AppDownloadLauncher` · 글, 업데이트 화면과 설정 › 앱 정보가 함께 쓴다) | `GET /api/app-config` 는 띄울 때 한 번 · 앞으로 올 때 마지막으로 받은 지 1시간이 지났으면(실패는 1분 간격으로 다시) — 알리지 않고 막지 않는다. 404 `RES_001` 은 옛 서버(최소 판 없음 · 알림 쓸 수 없음). 판은 숫자 1~3칸 · 칸마다 4자리까지(서버의 판 칸 `APP_VERSION_PATTERN` 과 같다 — `-` · `+` 뒤는 버린다), 이 앱의 판을 모르면 막지 않는다. 업데이트 화면으로 옮길 때 쓰기 화면은 마저 저장한다(`ComposeController.detach(saveRemaining: true)` — 화면이 밖에서 닫힌 때도). 앱은 스토어에 올리지 않고 사내에 직접 나눠 준다(2026-11-08 — 빌드 · 서명 · 올리기는 `flutter/README.md` "사내 배포"). [새 버전 받기]는 포털의 휴대폰 앱 받기 화면 `<API_URL 의 출처>/app`(`appDownloadPageUrlOf` — `/api` 를 뗀 출처 + 경로, 짝: 웹 `mobile-app.ts` 의 `MOBILE_APP_PATH`)을 바깥 브라우저로(`LaunchMode.externalApplication` — `canLaunchUrl` 은 쓰지 않는다) 연다. 열지 못하면 스킴 없는 주소를 담아 알린다(`openAppDownloadPage` — 두 화면이 같은 알림). 앱 설정 계약은 받는 곳 주소를 싣지 않는다 |
|
||||
| **설정 탭 (`App1Shell` · `App1Push` ④)** | `lib/features/settings/` — `settings_routes.dart`(`/settings` · `/settings/licenses` · 모듈이 내놓은 절) · `presentation/pages/settings_page.dart` · `widgets/settings_notification_row.dart` · `settings_texts.dart` | 한 화면(내 정보 · 알림 · 모듈 절 · 앱 정보(앱 버전 · [새 버전 받기] — 끝 아이콘이 바깥으로 여는 아이콘, 업데이트 화면과 같은 런처 · 오픈소스 라이선스) · 문의 · [로그아웃]). 문의는 로그인 화면과 같은 저장소(`supportContactProvider`), [로그아웃]은 확인 창(`shared/widgets/confirm_dialog.dart` — 처음 초점 [취소]). 스위치 색은 테마의 `switchTheme`(보드 — 켬 청록 · 끔 테두리 · 누를 수 없음 회색) |
|
||||
| **설정 탭 (`App1Shell` · `App1Push` ④)** | `lib/features/settings/` — `settings_routes.dart`(`/settings` · `/settings/licenses` · `/settings/release-notes`(+ `/:noteId`) · 모듈이 내놓은 절) · `presentation/pages/settings_page.dart` · `widgets/settings_notification_row.dart` · `settings_texts.dart` | 한 화면(내 정보 · 알림 · 모듈 절 · 앱 정보(앱 버전 · 업데이트 내역(목록 → 글, 아래 행) · [새 버전 받기] — 끝 아이콘이 바깥으로 여는 아이콘, 업데이트 화면과 같은 런처 · 오픈소스 라이선스) · 문의 · [로그아웃]). 문의는 로그인 화면과 같은 저장소(`supportContactProvider`), [로그아웃]은 확인 창(`shared/widgets/confirm_dialog.dart` — 처음 초점 [취소]). 스위치 색은 테마의 `switchTheme`(보드 — 켬 청록 · 끔 테두리 · 누를 수 없음 회색) |
|
||||
| **업데이트 내역 (`ReleaseNotes` B — 2026-11-10)** | `lib/features/settings/` — data(`models/release_note.dart` · `repositories/release_notes_repository.dart`) · presentation(`pages/release_notes_page.dart` · `release_note_page.dart` · providers `release_notes_providers.dart` · `release_notes_list_controller.dart` · `release_note_controller.dart` · widgets `release_notes_parts.dart` · 글 `release_notes_texts.dart`), 달력 날짜 `core/utils/calendar_date.dart`(`CalendarDate`) + `korean_format.dart` 의 `koreanCalendarDate`, 시험 도구 `test/support/release_notes_fakes.dart`(`FakeReleaseNotesBackend` · 보드의 네 글 `kBoardReleaseNotes`) | 계약 7절(`GET /api/release-notes?cursor=&limit=` · `GET /api/release-notes/{id}` — 올리는 API 없음, 글은 서버의 글 파일). 설정 › 앱 정보의 앱 버전 다음 줄 → 목록 `/settings/release-notes` → 글 `/settings/release-notes/:noteId`(둘 다 `parentNavigatorKey: rootNavigatorKey`, 글은 목록 라우트의 자식이라 주소로 바로 열어도 아래에 목록). 목록은 `releaseNotesListProvider`(autoDispose — 화면을 열 때마다 새로 받는다 · 첫 쪽 · [다시 시도]는 `silent`, [더 보기]는 알림 + 받은 줄 그대로 · 겹친 id 는 건너뜀), 글은 `releaseNoteProvider(id)`(autoDispose family · `silent` — 없는 글 404 `RES_001` 은 [목록으로], 그 밖은 [다시 시도]). 둘 다 `releaseNotesAccountProvider`(세션의 주소 — 메일의 `mailAccountProvider` 와 같은 규칙, 모듈끼리 import 하지 않아 따로)를 듣고 세션이 바뀌면 처음부터 · 세대(`_seq`)와 시작할 때의 Ref 로 늦은 응답을 버린다. 응답 모양이 틀리면 `ReleaseNotesMalformedResponse`(`SYS_001` — 인터셉터를 다 지난 뒤라 [더 보기]만 컨트롤러가 `errorNotifierProvider` 로 알린다, 메일 `DownloadInterrupted` 와 같은 결). **날짜는 `CalendarDate` 로만**(`YYYY-MM-DD` 를 글로 나눠 쥔다 — `DateTime` 으로 바꾸지 않는다, 없는 날은 모양 틀림). 다른 글 줄은 `context.pushReplacement`(쌓지 않는다 — 뒤로는 늘 목록, go_router 17 은 void 를 돌려준다). 글은 서버 글 그대로(마크다운 해석 · 링크 없음) · 한글 줄바꿈 `KeepAllText`(시험은 `findCopy`). [더 보기]는 받는 동안 누름만 무시한다(초점 지키기 — 메일 검색 [더 찾기]와 같다) — 도는 표시가 있어 시험은 `pumpAndSettle` 대신 `pump(100ms)` |
|
||||
| 불러오기 상태 틀 | `lib/shared/widgets/load_states.dart` · `load_state_texts.dart` | 가운데 상태 `StateLayout`(아이콘 원 72 · 제목 · 글 · 단추) · 받지 못함 `LoadErrorView`(사전 문구 · 문의 코드 · [다시 시도] — `RES_001` + `onLeave` 면 `leaveLabel` 단추, 둘은 함께 준다) · 뼈대 `LoadingSkeleton`(화면 낭독기 한 줄 + 막대는 읽지 않음) · `SkeletonBar`(메일은 모서리 4, 업데이트 내역은 `rounded`) · `traceCodeOf`. 2026-11-10 메일 목록(`mail_list_states.dart` 의 `MailStateLayout` · `MailListError` · `_SkeletonBar`)에서 모양 · 동작 그대로 옮겼다 — 메일 목록 · 검색 · 읽기 · 쓰기와 업데이트 내역이 쓴다. 메일 줄 꼴의 뼈대(`MailListSkeleton`) · 빈 메일함 · 목록 끝 · 30일 줄은 메일에 남았다. `MailListTexts.retry` · `traceCode` 는 `LoadStateTexts` 를 가리킨다 |
|
||||
| 자리 화면 | `lib/shared/widgets/placeholder_page.dart` | 화면은 캔버스로 확인받은 뒤 만든다 — 그때까지 이름(과 갈 곳)만 보이는 자리 화면. 확정되면 라우트 묶음의 builder 를 바꾼다 |
|
||||
| 예제 feature(템플릿) | `lib/features/user/` | 목록 페이지네이션(`{items, pageInfo}` — 단건 + 커서 목록) 예제로만 남긴 템플릿. 앱 API 에 `/users` 는 없다 |
|
||||
| 예제 feature(참조용) | `lib/features/user/` | 목록 페이지네이션(`{items, pageInfo}` — 단건 + 커서 목록) 예제로만 남겼다. 앱 API 에 `/users` 는 없다 |
|
||||
| 시험 도구 | `test/support/` — `fakes.dart` · `provider_harness.dart` | 가짜 서버(`FakeServer` — HttpClientAdapter) · 메모리 보안 저장소 · 손 시계(`ManualClock`) · 알림 기록(`RecordingNotifier`). 고정 대기 없이 Completer 로 차례를 맞춘다 ([[testing]]) |
|
||||
|
||||
> **확장 시 따르는 규칙** (아래 "프로젝트 구조"는 목표 구조 — 기능 추가 시 점진적으로 채운다):
|
||||
@@ -103,7 +105,7 @@ lib/
|
||||
│ ├── settings/ ← 모듈 — 주소 /settings/... (다른 모듈의 설정 절을 받아 붙인다)
|
||||
│ └── user/ ← 예제(참조용)
|
||||
└── shared/
|
||||
└── widgets/ ← 앱 공통 위젯 (자리 화면 등)
|
||||
└── widgets/ ← 앱 공통 위젯 (자리 화면 · 확인 창 · 불러오기 상태 틀 등)
|
||||
```
|
||||
|
||||
---
|
||||
@@ -310,6 +312,11 @@ await tokenStore.clear(byUser: true); // 로그아웃 (세션 끝은 byUser
|
||||
가짜 서버는 요청 본문을 읽지 않아 보내는 진행(onSendProgress)이 오지 않는다 — 진행을 보려면 `FakeServer.readRequestBodies` 를 켠다.
|
||||
InkWell 에 길게 누르기 처리가 없으면 길게 눌렀다 뗄 때 onTap 이 돈다 — 열지 않을 줄은 길게 누르기를 받아 두고 아무것도 하지 않는다(검색 결과 줄).
|
||||
두 줄 TextField 에도 Scrollable 이 있다 — 목록 스크롤은 ListView 아래에서 찾는다(`find.descendant`).
|
||||
go_router 17 의 `context.pushReplacement` · `replace` 는 void 다(`push` 만 Future) — 바꿔 끼울 화면은 `pushReplacement`(새 page key — 앞 화면의 PageStorage 스크롤 자리를 물려받지 않는다, 업데이트 내역의 다른 글 줄).
|
||||
짜임을 둘로 가르는 화면(읽기 화면의 본문 칸 있음 · 없음)은 두 짜임에 같은 자리의 같은 위젯을 두고 값(`ConstrainedBox` 의 maxHeight)만 바꾼다 — `Flexible` · `Expanded` 로 바꿔 끼우면 그 아래 State(펼친 받는 사람 등)가 새로 만들어진다. 시험은 위치로 본다(머리 스크롤 칸의 아래 끝 = 아래 막대의 위 끝).
|
||||
`InputDecoration.collapsed` 는 테두리 없는 칸이 아니다 — `border` 만 없애고 `enabledBorder` · `focusedBorder` 등을 비워 두면 TextField 가 그 칸을 테마의 `inputDecorationTheme`(둥근 테두리 · 초점은 청록 2)로 채우고, InputDecorator 는 `border` 보다 그 칸들을 먼저 쓴다(2026-11-12 — 쓰기 화면의 칸마다 글자를 감싼 테두리). 칸 줄이 선을 따로 그리는 칸은 `borderlessInputDecoration`(`core/theme/app_theme.dart` — 여섯 테두리 모두 없음 · 여백 0)을 쓴다. `lib/` 에 `InputDecoration.collapsed(` 가 들어오면 시험(`test/core/theme/borderless_input_decoration_test.dart`)이 실패한다. 칸의 테두리는 `test/support/input_borders.dart` 의 `expectBorderlessInput`(평소 · 초점 · 꺼짐마다 InputDecorator 가 받은 꾸밈)으로 본다.
|
||||
화면을 그림으로 견줄 때(임시 시험 — 저장소에 남기지 않는다): `setUpAll` 에서 `FontLoader('Pretendard')` 에 `assets/fonts/Pretendard-*.otf` 넷을, `FontLoader('MaterialIcons')` 에 SDK 의 `bin/cache/artifacts/material_fonts/materialicons-regular.otf` 를 올리고(그러지 않으면 글자가 네모), 맨 위 repaint boundary(RenderView)의 `OffsetLayer.toImage(paintBounds)` 를 `tester.runAsync` 안에서 PNG 로 쓴다. 화면 크기는 `MailAppHarness.pump(size:)`.
|
||||
화면 아래 끝까지 닿는 스크롤(읽기 화면의 빈 본문 + 아래 막대 없는 예약 메일 등)은 `MediaQuery.paddingOf(context).bottom` 을 스크롤 끝 여백으로 더한다 — Scaffold 는 bottomNavigationBar 가 있을 때만 몸통의 아래 padding 을 0 으로 만든다. 시험은 `tester.view.padding = FakeViewPadding(bottom: 48 * 3)`(물리 픽셀 — 시험대는 3배) + `addTearDown(tester.view.resetPadding)`.
|
||||
- [ ] **린트로 강제되는 항목**: `dynamic` 호출·`print`·`const` 누락·await 누락·작은따옴표·import 정렬 (`analysis_options.yaml`).
|
||||
- [ ] **env**: `flutter/.env.example` → `.env` 복사 후 `API_URL` 설정 (Android 에뮬레이터는 `10.0.2.2`).
|
||||
`.env` 는 `pubspec.yaml` 의 asset 이라 **없으면 `flutter analyze` 가 경고**한다.
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1,6 +1,7 @@
|
||||
# 프로젝트 개요
|
||||
|
||||
이 저장소는 **풀스택 모노레포 기본 템플릿**이다. 누구나 클론하여 바로 개발을 시작할 수 있도록 공통 규칙과 언어별 규칙을 정의한다.
|
||||
이 저장소는 **ARA SYSTEM** — 사내 그룹웨어(첫 모듈은 메일: 웹메일 · 휴대폰 앱)다. 백엔드 · 웹 · 앱과 메일 엔진 · 운영 설정을 한 저장소에서 다루고, 여기에 공통 규칙과 스택별 규칙을 둔다.
|
||||
프로젝트 소개 · 문서 지도는 [`README.md`](README.md), 작업 차례는 [`docs/roadmap.md`](docs/roadmap.md).
|
||||
|
||||
| 디렉터리 | 스택 | 역할 | 개발 규칙 (스킬) |
|
||||
| ----------- | -------------- | ------------------------ | ----------------------------------------- |
|
||||
|
||||
@@ -1,109 +1,97 @@
|
||||
# 풀스택 모노레포 기본 템플릿
|
||||
# ARA SYSTEM
|
||||
|
||||
NestJS(백엔드) · Vue 3(웹) · Flutter(모바일)를 하나의 저장소에서 운영하는 풀스택 템플릿입니다.
|
||||
세 클라이언트는 **동일한 표준 응답 포맷**과 **공통 에러 코드 맵**을 공유하며, Docker Compose로 한 번에 구동됩니다.
|
||||
사내 그룹웨어다. 첫 모듈은 **메일** — 웹메일(포털)과 휴대폰 앱(안드로이드)이다.
|
||||
메일 엔진(Stalwart)이 메일을 주고받고, 우리 서버(NestJS)가 엔진을 감싸 웹과 앱에 같은 API 를 준다. 화면은 직접 만든다(Vue 3 · Flutter).
|
||||
`@arasystem.it.kr` 메일은 2026-11-05 옛 서버(mailcow)에서 옮겨 와 전 직원이 쓰고 있다.
|
||||
|
||||
## 지금
|
||||
|
||||
- 운영: 메일 1~13단계. 메일 14단계(자체 검색 색인 · 같은 대화)와 앱 첫 판 · 휴대폰 앱 받기는 main 에 합쳤고 서버 배포를 기다린다.
|
||||
- 개발 중: 메일 15단계(메일 · 메일함 다루기). 다음은 16단계(개인 설정 — 주소록 가져오기 · 여러 서명 등).
|
||||
- 단계별 내용 · 다음 차례 · 미뤄 둔 후보는 [`docs/roadmap.md`](./docs/roadmap.md).
|
||||
|
||||
## 구성
|
||||
|
||||
| 디렉터리 | 스택 | 역할 | 포트(기본) |
|
||||
| ----------- | -------------- | ------------------------ | ---------- |
|
||||
| `backend/` | NestJS + TypeORM | REST API 서버 | 3000 |
|
||||
| `frontend/` | Vue 3 + Vite | 웹 프론트엔드 | 5173 |
|
||||
| `flutter/` | Flutter + Riverpod | 모바일 앱 | - |
|
||||
| `db` | PostgreSQL | 데이터베이스 | 5432 |
|
||||
| `redis` | Redis | 캐시 / 세션 (**기본 비활성**) | 6379 |
|
||||
| `mail` | Stalwart (JMAP) | 사내 메일 엔진 — 직원 로그인 = 메일 계정 (profile `mail`, [`mail/README.md`](./mail/README.md)) | 운영 25·465·993·443 / 개발 관리 8180 |
|
||||
| `mailpit` | Mailpit | **개발 전용** — 사내 도메인 밖으로 보낸 메일 수집 | 8025 |
|
||||
| 이름 | 스택 | 하는 일 | 포트(개발) |
|
||||
| --- | --- | --- | --- |
|
||||
| `backend/` | NestJS · TypeORM | 웹 · 앱이 함께 쓰는 REST API — 메일 엔진 중계, 로그인 세션, 검색 색인, 앱 알림, 대용량 첨부 | 3000 |
|
||||
| `frontend/` | Vue 3 · Vite | 웹메일 화면. 운영은 nginx 가 화면과 같은 주소의 `/api` 를 백엔드로 넘긴다 | 5173 |
|
||||
| `flutter/` | Flutter · Riverpod | 휴대폰 앱(안드로이드 — 스토어 없이 사내 배포) | - |
|
||||
| `db` | PostgreSQL 15 | 로그인 세션 · 설정 · 검색 색인 · 대용량 첨부 기록 등 | 5432 |
|
||||
| `mail` | Stalwart | 메일 엔진(JMAP · IMAP · SMTP) — 직원 로그인 = 메일 계정 | 관리 8180(운영은 25 · 465 · 993 · 443) |
|
||||
| `mail-resolver` | unbound | 메일 엔진 전용 DNS(스팸 차단 목록 · DNSSEC) | - |
|
||||
| `clamav` | ClamAV | 대용량 첨부 악성 검사(운영은 늘, 개발은 profile `clamav`) | - |
|
||||
| `mailpit` | Mailpit | 개발 전용 — 바깥 주소로 보낸 메일을 모은다 | 8025 |
|
||||
| `redis` | Redis | 기본 꺼짐 — 서버를 여러 대로 늘릴 때의 자리 | 6379 |
|
||||
| `ops/` | 셸 · systemd | 운영 서버의 백업(restic) · 15분 점검 · 부팅 때 되살리기 | - |
|
||||
| `ci/` | Gitea Actions 러너 | PR 마다 lint · 시험 · 빌드 · 인프라 검사 | - |
|
||||
|
||||
## 문서 지도
|
||||
|
||||
| 알고 싶은 것 | 문서 |
|
||||
| --- | --- |
|
||||
| 서버 · 웹 · 앱이 함께 지키는 API 약속 | [`docs/mail/api-contract.md`](./docs/mail/api-contract.md) |
|
||||
| 웹 화면 디자인 · 화면 문구 규칙 · 단계별 화면 | [`docs/design/webmail/README.md`](./docs/design/webmail/README.md) — 디자인 원본은 같은 폴더의 `*.dc.html`(캔버스 보드) |
|
||||
| 앱 화면 | [`docs/design/app/README.md`](./docs/design/app/README.md) |
|
||||
| 메일 엔진 설정 · 운영 배포 · 백업 · 감시 · 전환 | [`mail/README.md`](./mail/README.md) |
|
||||
| 앱 개발 실행 · 사내 배포(서명 키 · APK 올리기) | [`flutter/README.md`](./flutter/README.md) |
|
||||
| CI 러너 | [`ci/README.md`](./ci/README.md) |
|
||||
| 개발 규칙(공통 · 스택별 · 역할) | [`CLAUDE.md`](./CLAUDE.md) · 아래 "개발 규칙" |
|
||||
| 로드맵 | [`docs/roadmap.md`](./docs/roadmap.md) |
|
||||
| 로고 · 색 | [`docs/brand/`](./docs/brand/) |
|
||||
|
||||
## 사전 요구사항
|
||||
|
||||
- Docker / Docker Compose — backend·frontend·db·redis 구동 (컨테이너 안 런타임은 Node 24 LTS — `node:24.22.1-alpine3.24` 고정, 갱신 규칙은 docker 스킬 "Node 버전")
|
||||
- Node.js **24 LTS 권장** — 호스트에서 프론트엔드 dev 서버·메일 setup 스크립트를 실행할 때 필요하다.
|
||||
프론트 `engines` 는 `^20.19.0 || >=22.12.0` 이라 20.19 이상이면 돌아가지만, 이미지·CI(24)와 맞추는 것을 권장한다
|
||||
(메일 setup 스크립트는 `--env-file` 때문에 20.6 이상)
|
||||
- Flutter SDK `^3.11` — 모바일 앱 실행용 (compose 대상 아님)
|
||||
- Docker / Docker Compose — 백엔드 · DB · 메일 엔진(컨테이너 안 Node 는 24 LTS 로 고정 — docker 스킬 "Node 버전")
|
||||
- Node.js 24 LTS — 호스트에서 웹 개발 서버 · 메일 setup 스크립트를 돌릴 때
|
||||
- Flutter 3.41.9(Dart 3.11.5) — 앱(compose 대상이 아니다 — [`flutter/README.md`](./flutter/README.md))
|
||||
|
||||
## 빠른 시작 (Docker)
|
||||
## 빠른 시작 (개발)
|
||||
|
||||
```bash
|
||||
# 1) 환경변수 파일 생성 (예제 복사)
|
||||
# 실제 설정값은 루트 .env.development 한 곳에서 관리한다 (SSOT).
|
||||
# backend/frontend 는 docker-compose 가 값을 주입하므로 예제만 복사하면 된다.
|
||||
cp .env.development.example .env.development # ← 여기에만 값 입력
|
||||
cp backend/.env.development.example backend/.env.development # (존재 필요: JWT 등 고유 시크릿)
|
||||
# frontend 는 compose 가 빌드 시 값을 주입하므로 자체 env 복사 불필요
|
||||
# 1) 환경변수 파일 — 값은 루트 .env.development 한 곳에 쓴다(SSOT)
|
||||
cp .env.development.example .env.development
|
||||
cp backend/.env.development.example backend/.env.development # 있어야 한다(JWT 등 백엔드 고유 비밀)
|
||||
|
||||
# 2) 백엔드 스택 기동 (backend + db, 기본 COMPOSE_PROFILES=mail 이면 mail + mailpit 포함)
|
||||
# 2) 백엔드 스택 — backend + db, 기본 COMPOSE_PROFILES=mail 이면 mail · mail-resolver · mailpit 까지
|
||||
docker compose -f docker-compose.yml -f docker-compose.dev.yml --env-file .env.development up -d
|
||||
|
||||
# 2-1) 메일 엔진 초기화 — 처음 한 번 (여러 번 실행해도 안전, mail/README.md)
|
||||
# 3) 메일 엔진 초기화 — 처음 한 번(여러 번 돌려도 안전하다). 개발 시험 계정도 여기서 만든다(mail/README.md)
|
||||
node --env-file=.env.development mail/scripts/setup.mjs
|
||||
|
||||
# 3) 의존성 변경 시에만 --build
|
||||
docker compose -f docker-compose.yml -f docker-compose.dev.yml --env-file .env.development up -d --build
|
||||
|
||||
# 4) 프론트엔드는 호스트에서 직접 띄운다 (컨테이너보다 수십 배 빠르다)
|
||||
# 4) 웹은 호스트에서 띄운다 — /api 는 vite 프록시가 백엔드 컨테이너로 넘긴다
|
||||
cd frontend && npm install && npm run dev
|
||||
```
|
||||
|
||||
기동 후:
|
||||
- 웹 <http://localhost:5173> · API <http://localhost:3000/api> · Swagger <http://localhost:3000/api-docs>
|
||||
- 의존성을 바꿨을 때만 `up -d --build`(백엔드는 `--renew-anon-volumes` 도 — docker 스킬).
|
||||
- 백엔드를 처음 띄우면 watch 빌드에 수십 분이 걸릴 수 있다(Windows 바인드 마운트). 브랜치를 바꿨다고 다시 시작하지 않는다.
|
||||
- 웹을 컨테이너로 띄워야 하면 `--profile frontend`(느리다 — 호스트 vite 와 함께 띄우지 않는다). 운영(`docker-compose.yml` 단독)은 nginx 정적 호스팅이다.
|
||||
- 포트는 루트 `.env.{env}` 를 따른다(`FRONTEND_PORT` · `BACKEND_PORT` — `vite.config.ts` 가 직접 읽는다). 포트가 이미 쓰이면 다른 포트로 옮기지 않고 실패한다.
|
||||
- Redis 는 뜨지 않는다. 필요해지면 `COMPOSE_PROFILES` 에 `redis` 를 더한다.
|
||||
|
||||
- 웹: <http://localhost:5173>
|
||||
- API: <http://localhost:3000/api>
|
||||
- Swagger 문서: <http://localhost:3000/api-docs>
|
||||
전체 실행 · 배포 명령은 [`.claude/skills/docker/SKILL.md`](./.claude/skills/docker/SKILL.md).
|
||||
|
||||
> **개발 환경에서 프론트엔드는 컨테이너로 뜨지 않습니다.**
|
||||
> vite 를 컨테이너에서 돌리면 소스가 Windows 바인드 마운트 너머에 있어 첫 서빙까지 수 분이 걸립니다.
|
||||
> 호스트에서 `npm run dev` 로 띄우면 1~2초이고, `/api` 요청은 vite 프록시가 백엔드 컨테이너로 넘깁니다.
|
||||
> 컨테이너로 띄워야 한다면 `--profile frontend` 를 붙이세요 (아래 "프론트엔드 실행 방식" 참조).
|
||||
> 운영(`docker-compose.yml` 단독)은 그대로 nginx 정적 호스팅으로 동작합니다.
|
||||
|
||||
> Redis 는 기본적으로 뜨지 않습니다. 필요해지면 루트 `.env.{env}` 에 `COMPOSE_PROFILES=redis` 를 설정하세요.
|
||||
|
||||
### 프론트엔드 실행 방식
|
||||
|
||||
| 방식 | 명령 | 첫 서빙 | 용도 |
|
||||
| --- | --- | --- | --- |
|
||||
| **호스트 vite (개발 기본)** | `cd frontend && npm run dev` | 1~2초 (최초 의존성 사전번들 10초 내외) | 일상 개발 |
|
||||
| 컨테이너 vite (선택) | `docker compose ... --profile frontend up -d` | 수 분 (Windows 바인드 마운트) | 호스트에 Node 를 둘 수 없을 때 |
|
||||
| nginx 정적 (운영) | `docker compose --env-file .env.production up -d --build` | - | 배포 |
|
||||
|
||||
> 컨테이너 방식을 상시 사용하려면 루트 `.env.development` 에 `COMPOSE_PROFILES=frontend` 를 넣습니다.
|
||||
> (redis 와 함께 켤 때는 `COMPOSE_PROFILES=frontend,redis`)
|
||||
> 호스트 vite 와 컨테이너 vite 는 같은 호스트 포트를 쓰므로 **동시에 띄우지 마세요.**
|
||||
|
||||
> **포트는 루트 `.env.{env}` 를 따릅니다.** 호스트에서 띄우는 vite 도 `FRONTEND_PORT` 로 열리고,
|
||||
> `/api` 프록시 대상은 `BACKEND_PORT` 에서 나옵니다 — `vite.config.ts` 가 루트 env 를 직접 읽습니다.
|
||||
> 포트가 이미 사용 중이면 조용히 다른 포트로 옮기지 않고 **실패**합니다
|
||||
> (`CORS_ORIGIN` 과 compose 포트 매핑이 그 값에 묶여 있어, 말없이 옮겨가면 원인 모르게 어긋납니다).
|
||||
|
||||
전체 실행/배포 명령어는 [`.claude/skills/docker/SKILL.md`](./.claude/skills/docker/SKILL.md) 참조.
|
||||
|
||||
## 모바일 앱 실행 (Flutter)
|
||||
|
||||
Flutter 는 Docker Compose 대상이 아니므로 로컬에서 직접 실행합니다.
|
||||
## 앱 실행 (Flutter)
|
||||
|
||||
```bash
|
||||
cd flutter && cp .env.example .env && flutter pub get && flutter run
|
||||
```
|
||||
|
||||
> `flutter/.env` 의 `API_URL` 을 백엔드 주소로 설정합니다. (Android 에뮬레이터는 `http://10.0.2.2:3000/api`)
|
||||
`flutter/.env` 의 `API_URL` 을 백엔드 주소로 둔다(Android 에뮬레이터는 `http://10.0.2.2:3000/api`). 실기기 · 새 메일 알림 · 사내 배포는 [`flutter/README.md`](./flutter/README.md).
|
||||
|
||||
## 환경변수
|
||||
|
||||
`.env*` 는 커밋하지 않습니다. 각 디렉터리의 `*.example` 파일을 복사해 사용하세요.
|
||||
`.env*` 는 커밋하지 않는다. 각 자리의 `*.example` 을 복사해 쓴다. **루트 `.env.{env}` 가 단일 진실 공급원(SSOT)**이다 — backend · frontend 는 compose 가 루트 값을 넣어 준다
|
||||
(구조는 [`.claude/rules/env-structure.md`](./.claude/rules/env-structure.md)).
|
||||
|
||||
**루트 `.env.{env}` 가 단일 진실 공급원(SSOT)** 입니다. backend·frontend 는 docker-compose 가
|
||||
루트 값을 주입하므로 자체 env 에 값을 중복 작성하지 않습니다. (자세한 위임 구조는 [`CLAUDE.md`](./CLAUDE.md) 의 "환경변수 구조" 참조)
|
||||
| 파일 | 용도 | 값을 쓰는 곳 |
|
||||
| --- | --- | --- |
|
||||
| `.env.{env}.example` | 루트 — 모든 설정의 SSOT(compose 가 넣어 준다) | 여기 |
|
||||
| `backend/.env.{env}.example` | 백엔드 고유 비밀(JWT · 앱 알림 키 등)만 | 최소(파일은 있어야 한다) |
|
||||
| `flutter/.env.example` | 앱 — `API_URL`(compose 대상이 아니다) | 따로 |
|
||||
|
||||
| 파일 | 용도 | 값 작성 위치 |
|
||||
| ----------------------------- | ---------------------------------------- | --------------------- |
|
||||
| `.env.{env}.example` | 루트 — 모든 설정의 SSOT (compose 주입) | ✅ 여기에 작성 |
|
||||
| `backend/.env.{env}.example` | 백엔드 — 고유 시크릿(JWT 등)만 | 최소 (존재 필수) |
|
||||
| `frontend/.env.{env}.example` | 프론트 — API 주소는 같은 출처의 `/api` 고정(빌드 arg) | 작성 불필요 |
|
||||
| `flutter/.env.example` | 모바일 — `API_URL` (compose 비대상) | ✅ 독립 관리 |
|
||||
|
||||
`{env}` = `development` | `production`
|
||||
`{env}` = `development` | `production`. 운영 서버의 백업 · 점검 설정은 `/etc/arasystem/`(`ops/ops.env.example` — mail/README "백업").
|
||||
|
||||
## 표준 응답 포맷
|
||||
|
||||
@@ -114,97 +102,74 @@ cd flutter && cp .env.example .env && flutter pub get && flutter run
|
||||
// 실패 — 사용자에게 보이는 모든 실패는 에러 코드를 가진다
|
||||
{ "success": false, "error": { "code": "VAL_001", "message": "...", "traceId": "3f1c0a2e-..." } }
|
||||
|
||||
// 목록 — data 안의 형태가 고정되어 있다
|
||||
// 목록 — data 안의 꼴이 정해져 있다
|
||||
{ "success": true, "data": { "items": [], "pageInfo": { "hasNext": false, "nextCursor": null, "total": 0 } } }
|
||||
```
|
||||
|
||||
`traceId` 는 요청마다 발급되는 추적 ID(`x-request-id` 헤더와 동일)로, 서버 로그에 같은 값이 남습니다.
|
||||
사용자 문의가 들어오면 이 값으로 로그를 검색하면 됩니다.
|
||||
`traceId` 는 요청마다 붙는 추적 ID(`x-request-id` 헤더와 같다)라 서버 로그에 같은 값이 남는다 — 문의가 오면 이 값으로 로그를 찾는다.
|
||||
`/api/health` 는 예외다(모니터링 도구가 읽는 원본 꼴).
|
||||
|
||||
> `/api/health` 는 예외입니다. 모니터링 도구가 읽는 엔드포인트라 래퍼 없이 원본 형식을 반환합니다.
|
||||
|
||||
### 에러 코드는 한 곳에서 관리됩니다
|
||||
### 에러 코드는 한 곳에서
|
||||
|
||||
```
|
||||
backend/src/common/errors/error-codes.ts ← 여기만 수정
|
||||
backend/src/common/errors/error-codes.ts ← 여기만 고친다
|
||||
│ cd backend && npm run sync:error-codes
|
||||
├─▶ frontend/src/shared/constants/error-codes.generated.ts (자동 생성)
|
||||
└─▶ flutter/lib/core/api/error_lexicon.g.dart (자동 생성)
|
||||
```
|
||||
|
||||
생성 파일은 직접 수정하지 마세요. CI 가 동기화 여부를 검사합니다.
|
||||
자세한 규칙은 [`.claude/rules/error-codes.md`](./.claude/rules/error-codes.md) 참조.
|
||||
생성 파일은 직접 고치지 않는다. CI 가 맞는지 본다 — [`.claude/rules/error-codes.md`](./.claude/rules/error-codes.md).
|
||||
|
||||
## 로컬 개발 (Docker 없이)
|
||||
|
||||
DB 는 컨테이너로 띄우고 앱만 호스트에서 실행하는 방식이 가장 간단합니다.
|
||||
## 커밋 전 검사
|
||||
|
||||
```bash
|
||||
# 1) DB 만 컨테이너로 기동 (127.0.0.1:5432 로 노출된다)
|
||||
docker compose -f docker-compose.yml -f docker-compose.dev.yml --env-file .env.development up -d db
|
||||
|
||||
# 2) backend — DB_HOST 기본값이 localhost 라 그대로 붙는다
|
||||
cd backend && npm ci && npm run start:dev
|
||||
|
||||
# 3) frontend — vite dev 서버가 /api 를 localhost:3000 으로 프록시한다 (개발 기본 방식)
|
||||
cd frontend && npm ci && npm run dev
|
||||
|
||||
# 4) flutter
|
||||
cd flutter && flutter pub get && flutter run
|
||||
```
|
||||
|
||||
> 프론트엔드의 API 요청은 `/api` 상대경로로 나가고 vite dev 프록시가 백엔드로 넘깁니다.
|
||||
> 백엔드를 다른 주소에서 띄웠다면 `VITE_DEV_API_PROXY` 로 대상을 바꾸세요.
|
||||
|
||||
### 커밋 전 검사
|
||||
|
||||
```bash
|
||||
cd backend && npm run lint && npm run test && npm run build
|
||||
cd frontend && npm run lint && npm run type-check && npm run test:unit
|
||||
# backend — 컨테이너 안에서(의존성 · lock 이 Linux 기준이라 호스트에서 npm install 하지 않는다)
|
||||
docker exec arasystem-backend npm run lint
|
||||
docker exec arasystem-backend npm run test # 메모리가 빠듯하면 -- --maxWorkers=2
|
||||
docker exec arasystem-backend npx jest --config ./test/jest-e2e.json # e2e — DB · 메일 엔진이 떠 있어야 한다
|
||||
# frontend · flutter — 호스트에서
|
||||
cd frontend && npm run lint && npm run type-check && npm run test:unit # 시간대가 걸린 화면을 고쳤으면 TZ=UTC 로도
|
||||
cd flutter && flutter analyze && flutter test
|
||||
```
|
||||
|
||||
lint 는 `--max-warnings 0` 이라 경고 하나만 있어도 실패합니다. 자동 수정은 `npm run lint:fix`.
|
||||
같은 검사를 CI([`.github/workflows/ci.yml`](./.github/workflows/ci.yml))가 PR 마다 수행합니다.
|
||||
컨테이너 이름은 `<PROJECT_NAME>-backend`(개발 기본 `arasystem`). lint 는 `--max-warnings 0` 이라 경고 하나도 실패다. 같은 검사를 CI(`.github/workflows/ci.yml` · `ci-flutter.yml`)가 PR 마다 한다.
|
||||
|
||||
> ⚠️ **의존성을 추가·제거할 때는 컨테이너 안에서** 실행하세요.
|
||||
> Windows 에서 `npm install` 하면 Linux 전용 패키지가 lock 에서 잘려 Docker 빌드가 깨집니다.
|
||||
> ```bash
|
||||
> docker compose -f docker-compose.yml -f docker-compose.dev.yml --env-file .env.development exec backend npm install <패키지>
|
||||
> ```
|
||||
> ⚠️ **백엔드 의존성은 컨테이너 안에서만 바꾼다** — Windows 에서 `npm install` 하면 Linux 전용 패키지가 lock 에서 빠져 Docker 빌드가 깨진다.
|
||||
> `docker compose -f docker-compose.yml -f docker-compose.dev.yml --env-file .env.development exec backend npm install <패키지>`
|
||||
|
||||
## 개발 규칙
|
||||
|
||||
| 영역 | 문서 |
|
||||
| --------- | ------------------------------------------------- |
|
||||
| 공통/보안 | [`CLAUDE.md`](./CLAUDE.md) · [`.claude/rules/`](./.claude/rules/) |
|
||||
| 응답·에러 | [`response-format.md`](./.claude/rules/response-format.md) · [`error-codes.md`](./.claude/rules/error-codes.md) |
|
||||
| API 설계 | [`api-design.md`](./.claude/rules/api-design.md) |
|
||||
| 로깅 | [`logging.md`](./.claude/rules/logging.md) |
|
||||
| 체크리스트 | [`checklist.md`](./.claude/rules/checklist.md) |
|
||||
| NestJS | [`.claude/skills/nestjs/SKILL.md`](./.claude/skills/nestjs/SKILL.md) |
|
||||
| Vue 3 | [`.claude/skills/vue3/SKILL.md`](./.claude/skills/vue3/SKILL.md) |
|
||||
| Flutter | [`.claude/skills/flutter/SKILL.md`](./.claude/skills/flutter/SKILL.md) |
|
||||
| Docker | [`.claude/skills/docker/SKILL.md`](./.claude/skills/docker/SKILL.md) |
|
||||
| 역할 분담 | [`.claude/agents/README.md`](./.claude/agents/README.md) |
|
||||
| 영역 | 문서 |
|
||||
| --- | --- |
|
||||
| 입구 · 공통 | [`CLAUDE.md`](./CLAUDE.md) · [`.claude/rules/`](./.claude/rules/) |
|
||||
| 응답 · 에러 | [`response-format.md`](./.claude/rules/response-format.md) · [`error-codes.md`](./.claude/rules/error-codes.md) |
|
||||
| API 설계 | [`api-design.md`](./.claude/rules/api-design.md) |
|
||||
| 로깅 · 보안 | [`logging.md`](./.claude/rules/logging.md) · [`security.md`](./.claude/rules/security.md) |
|
||||
| 점검 목록 | [`checklist.md`](./.claude/rules/checklist.md) |
|
||||
| NestJS | [`.claude/skills/nestjs/SKILL.md`](./.claude/skills/nestjs/SKILL.md) |
|
||||
| Vue 3 | [`.claude/skills/vue3/SKILL.md`](./.claude/skills/vue3/SKILL.md) |
|
||||
| Flutter | [`.claude/skills/flutter/SKILL.md`](./.claude/skills/flutter/SKILL.md) |
|
||||
| Docker · 운영 | [`.claude/skills/docker/SKILL.md`](./.claude/skills/docker/SKILL.md) |
|
||||
| 역할 분담 | [`.claude/agents/README.md`](./.claude/agents/README.md) |
|
||||
|
||||
새 화면은 캔버스 보드로 먼저 확인받은 뒤 만든다(디자인 README). 화면 · 알림 글은 디자인 README 「화면 문구 — 사용자에게 말하듯」을 따른다.
|
||||
|
||||
## 디렉터리 구조
|
||||
|
||||
```
|
||||
.
|
||||
├── backend/ # NestJS API (Controller / Service / DTO / 공통 필터·인터셉터)
|
||||
├── frontend/ # Vue 3 (pages / components / composables / stores / router)
|
||||
├── flutter/ # Flutter (core / features / shared)
|
||||
├── docker-compose.yml # 베이스 정의
|
||||
├── docker-compose.dev.yml # 개발 오버라이드 (핫리로드, 포트 노출)
|
||||
├── CLAUDE.md # 프로젝트 개요 + 공통 규칙 @import
|
||||
├── .github/
|
||||
│ ├── workflows/ci.yml # lint·test·build + 에러 코드 동기화 + 인프라(compose·Dockerfile·nginx) 검사
|
||||
│ └── pull_request_template.md
|
||||
├── ci/ # CI 러너 (Gitea Actions) — 앱 스택과 분리
|
||||
└── .claude/
|
||||
├── settings.json # Claude Code 권한·환경 설정
|
||||
├── rules/ # 공통 규칙 (코딩·응답 포맷·에러 코드·API 설계·로깅·보안·환경변수·테스트)
|
||||
├── skills/ # 도메인별 개발 규칙 (nestjs·vue3·flutter·docker)
|
||||
└── agents/ # 역할별 규칙 (개발 3종·테스터·리뷰어·인프라·사용자/관리자 관점)
|
||||
├── backend/ # NestJS API — modules/(auth · mail · contacts · file-storage · push · ops 등) · common · shared
|
||||
├── frontend/ # Vue 3 웹메일 — pages · components · composables · stores · router, 운영 nginx 설정
|
||||
├── flutter/ # 휴대폰 앱 — core · features · shared
|
||||
├── mail/ # 메일 엔진 setup 스크립트 · DNS 리졸버 · ClamAV 설정 · 운영 문서(README)
|
||||
├── ops/ # 운영 서버의 백업 · 점검 · 부팅 되살리기(systemd)
|
||||
├── docs/ # API 계약 · 화면 디자인(캔버스 보드) · 브랜드 · 로드맵
|
||||
├── ci/ # CI 러너(Gitea Actions) — 앱 스택과 따로 띄운다
|
||||
├── data/ # 개발용 바인드 마운트 자리(내용은 커밋하지 않는다)
|
||||
├── docker-compose.yml # 운영 · 개발 공통 정의
|
||||
├── docker-compose.dev.yml # 개발 오버라이드(핫리로드 · 127.0.0.1 포트)
|
||||
├── docker-compose.remote-proxy.yml # 리버스 프록시가 다른 서버에 있을 때(운영 선택)
|
||||
├── CLAUDE.md # 개발 규칙의 입구(.claude/ 의 규칙을 불러온다)
|
||||
├── .github/ # CI 워크플로(ci.yml · ci-flutter.yml) · PR 양식
|
||||
└── .claude/ # rules(공통 규칙) · skills(스택별 규칙) · agents(역할) · 설정
|
||||
```
|
||||
|
||||
@@ -43,6 +43,8 @@ COPY package*.json ./
|
||||
RUN npm ci --omit=dev && npm cache clean --force
|
||||
|
||||
COPY --from=builder /app/dist ./dist
|
||||
# 업데이트 내역 글(글 파일 하나가 글 하나 — 계약 7절) — 서버가 기동 · 요청 때 /app/release-notes 를 읽기만 한다(modules/release-notes — 쓰지 않는다)
|
||||
COPY --from=builder /app/release-notes ./release-notes
|
||||
|
||||
# 업로드 저장 위치(compose 의 UPLOAD_DIR=/app/uploads)와 대용량 첨부 파일 위치(FILE_STORAGE_DIR=/app/files — 11단계) — node 소유로 만들어 둔다.
|
||||
# 운영 compose 는 여기에 named volume 을 붙이는데, 새 볼륨은 이 디렉터리의 소유자를 물려받아 node 가 쓸 수 있다.
|
||||
|
||||
+22
-84
@@ -1,98 +1,36 @@
|
||||
<p align="center">
|
||||
<a href="http://nestjs.com/" target="blank"><img src="https://nestjs.com/img/logo-small.svg" width="120" alt="Nest Logo" /></a>
|
||||
</p>
|
||||
# backend — ARA SYSTEM API (NestJS)
|
||||
|
||||
[circleci-image]: https://img.shields.io/circleci/build/github/nestjs/nest/master?token=abc123def456
|
||||
[circleci-url]: https://circleci.com/gh/nestjs/nest
|
||||
웹 · 앱이 함께 쓰는 REST API 다(`/api`). 메일 엔진(Stalwart)을 JMAP 으로 감싸고, 로그인 세션 · 설정 · 검색 색인 · 앱 알림 · 대용량 첨부 · 운영 도구를 맡는다.
|
||||
|
||||
<p align="center">A progressive <a href="http://nodejs.org" target="_blank">Node.js</a> framework for building efficient and scalable server-side applications.</p>
|
||||
<p align="center">
|
||||
<a href="https://www.npmjs.com/~nestjscore" target="_blank"><img src="https://img.shields.io/npm/v/@nestjs/core.svg" alt="NPM Version" /></a>
|
||||
<a href="https://www.npmjs.com/~nestjscore" target="_blank"><img src="https://img.shields.io/npm/l/@nestjs/core.svg" alt="Package License" /></a>
|
||||
<a href="https://www.npmjs.com/~nestjscore" target="_blank"><img src="https://img.shields.io/npm/dm/@nestjs/common.svg" alt="NPM Downloads" /></a>
|
||||
<a href="https://circleci.com/gh/nestjs/nest" target="_blank"><img src="https://img.shields.io/circleci/build/github/nestjs/nest/master" alt="CircleCI" /></a>
|
||||
<a href="https://discord.gg/G7Qnnhy" target="_blank"><img src="https://img.shields.io/badge/discord-online-brightgreen.svg" alt="Discord"/></a>
|
||||
<a href="https://opencollective.com/nest#backer" target="_blank"><img src="https://opencollective.com/nest/backers/badge.svg" alt="Backers on Open Collective" /></a>
|
||||
<a href="https://opencollective.com/nest#sponsor" target="_blank"><img src="https://opencollective.com/nest/sponsors/badge.svg" alt="Sponsors on Open Collective" /></a>
|
||||
<a href="https://paypal.me/kamilmysliwiec" target="_blank"><img src="https://img.shields.io/badge/Donate-PayPal-ff3f59.svg" alt="Donate us"/></a>
|
||||
<a href="https://opencollective.com/nest#sponsor" target="_blank"><img src="https://img.shields.io/badge/Support%20us-Open%20Collective-41B883.svg" alt="Support us"></a>
|
||||
<a href="https://twitter.com/nestframework" target="_blank"><img src="https://img.shields.io/twitter/follow/nestframework.svg?style=social&label=Follow" alt="Follow us on Twitter"></a>
|
||||
</p>
|
||||
<!--[](https://opencollective.com/nest#backer)
|
||||
[](https://opencollective.com/nest#sponsor)-->
|
||||
- 모듈 지도 · 개발 규칙: [`.claude/skills/nestjs/SKILL.md`](../.claude/skills/nestjs/SKILL.md)
|
||||
- API 약속(웹 · 앱과 함께): [`docs/mail/api-contract.md`](../docs/mail/api-contract.md)
|
||||
- 공통 규칙(응답 꼴 · 에러 코드 · 로그 · 보안): [`CLAUDE.md`](../CLAUDE.md)
|
||||
|
||||
## Description
|
||||
## 실행 — 컨테이너로
|
||||
|
||||
[Nest](https://github.com/nestjs/nest) framework TypeScript starter repository.
|
||||
|
||||
## Project setup
|
||||
백엔드는 늘 컨테이너로 띄운다. 의존성 · lock 이 Linux 기준이라 **호스트에서 `npm install` 하지 않는다**(바꿀 때도 컨테이너 안에서 — nestjs 스킬 "의존성 추가/제거").
|
||||
|
||||
```bash
|
||||
$ npm install
|
||||
# 저장소 루트에서 — 개발 스택(backend + db + 메일 엔진)
|
||||
docker compose -f docker-compose.yml -f docker-compose.dev.yml --env-file .env.development up -d
|
||||
docker compose -f docker-compose.yml -f docker-compose.dev.yml --env-file .env.development logs -f backend
|
||||
```
|
||||
|
||||
## Compile and run the project
|
||||
- 처음 띄우면 마이그레이션을 한 번 돌리고 watch 빌드를 시작한다(Windows 바인드 마운트라 수십 분 걸릴 수 있다). 코드를 고치면 저절로 다시 뜬다.
|
||||
- Swagger: <http://localhost:3000/api-docs>
|
||||
|
||||
## 시험 · 검사
|
||||
|
||||
```bash
|
||||
# development
|
||||
$ npm run start
|
||||
|
||||
# watch mode
|
||||
$ npm run start:dev
|
||||
|
||||
# production mode
|
||||
$ npm run start:prod
|
||||
docker exec arasystem-backend npm run lint # 경고 0
|
||||
docker exec arasystem-backend npm run test # 단위 — 메모리가 빠듯하면 -- --maxWorkers=2
|
||||
docker exec arasystem-backend npx jest --config ./test/jest-e2e.json # e2e — DB · 메일 엔진 필요
|
||||
```
|
||||
|
||||
## Run tests
|
||||
컨테이너 이름은 `<PROJECT_NAME>-backend`(개발 기본 `arasystem`). Git Bash 에서 컨테이너 안 경로를 줄 때는 `MSYS_NO_PATHCONV=1` 을 붙인다.
|
||||
|
||||
```bash
|
||||
# unit tests
|
||||
$ npm run test
|
||||
## 자주 하는 일
|
||||
|
||||
# e2e tests
|
||||
$ npm run test:e2e
|
||||
|
||||
# test coverage
|
||||
$ npm run test:cov
|
||||
```
|
||||
|
||||
## Deployment
|
||||
|
||||
When you're ready to deploy your NestJS application to production, there are some key steps you can take to ensure it runs as efficiently as possible. Check out the [deployment documentation](https://docs.nestjs.com/deployment) for more information.
|
||||
|
||||
If you are looking for a cloud-based platform to deploy your NestJS application, check out [Mau](https://mau.nestjs.com), our official platform for deploying NestJS applications on AWS. Mau makes deployment straightforward and fast, requiring just a few simple steps:
|
||||
|
||||
```bash
|
||||
$ npm install -g @nestjs/mau
|
||||
$ mau deploy
|
||||
```
|
||||
|
||||
With Mau, you can deploy your application in just a few clicks, allowing you to focus on building features rather than managing infrastructure.
|
||||
|
||||
## Resources
|
||||
|
||||
Check out a few resources that may come in handy when working with NestJS:
|
||||
|
||||
- Visit the [NestJS Documentation](https://docs.nestjs.com) to learn more about the framework.
|
||||
- For questions and support, please visit our [Discord channel](https://discord.gg/G7Qnnhy).
|
||||
- To dive deeper and get more hands-on experience, check out our official video [courses](https://courses.nestjs.com/).
|
||||
- Deploy your application to AWS with the help of [NestJS Mau](https://mau.nestjs.com) in just a few clicks.
|
||||
- Visualize your application graph and interact with the NestJS application in real-time using [NestJS Devtools](https://devtools.nestjs.com).
|
||||
- Need help with your project (part-time to full-time)? Check out our official [enterprise support](https://enterprise.nestjs.com).
|
||||
- To stay in the loop and get updates, follow us on [X](https://x.com/nestframework) and [LinkedIn](https://linkedin.com/company/nestjs).
|
||||
- Looking for a job, or have a job to offer? Check out our official [Jobs board](https://jobs.nestjs.com).
|
||||
|
||||
## Support
|
||||
|
||||
Nest is an MIT-licensed open source project. It can grow thanks to the sponsors and support by the amazing backers. If you'd like to join them, please [read more here](https://docs.nestjs.com/support).
|
||||
|
||||
## Stay in touch
|
||||
|
||||
- Author - [Kamil Myśliwiec](https://twitter.com/kammysliwiec)
|
||||
- Website - [https://nestjs.com](https://nestjs.com/)
|
||||
- Twitter - [@nestframework](https://twitter.com/nestframework)
|
||||
|
||||
## License
|
||||
|
||||
Nest is [MIT licensed](https://github.com/nestjs/nest/blob/master/LICENSE).
|
||||
- **에러 코드**: `src/common/errors/error-codes.ts` 만 고치고 호스트에서 `cd backend && npm run sync:error-codes`(웹 · 앱 사전이 함께 바뀐다).
|
||||
- **DB 스키마**: 엔티티를 고친 뒤 `docker exec arasystem-backend npm run migration:generate -- src/migrations/<이름>` → 만든 SQL 을 눈으로 확인 → 엔티티와 함께 커밋(nestjs 스킬 "DB 마이그레이션").
|
||||
- **운영 도구**(운영 서버 안 명령 — 입사 · 비밀번호 재설정 · 휴대폰 앱 올리기 등): `sudo docker exec -it <PROJECT_NAME>-backend node dist/cli/ops.js` — 계약 6절 · mail/README.
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
# 휴대폰 앱이 나왔고, 메일 검색과 여러 설정이 좋아졌습니다
|
||||
|
||||
- 11월 5일 새 웹메일로 옮긴 뒤 새로 생기거나 바뀐 것을 모았습니다. 앞으로 바뀌는 것도 이곳에서 알려 드리겠습니다.
|
||||
|
||||
## 휴대폰 앱
|
||||
- 안드로이드 휴대폰에서 쓰는 ARA SYSTEM 앱이 나왔습니다. 메일을 읽고 쓰고 찾을 수 있고, 답장과 전달, 첨부 파일 보내기도 됩니다.
|
||||
- 앱 받기: PC 웹메일에서 [설정] → [휴대폰 앱]을 열고 휴대폰 카메라로 QR 코드를 찍어 주세요.
|
||||
휴대폰 브라우저에서 웹메일에 로그인한 뒤 [앱 내려받기]를 누르고, 설치를 묻는 안내가 나오면 허용해 주세요.
|
||||
- 앱에는 웹메일과 같은 메일 주소와 비밀번호로 로그인합니다. 2단계 인증을 켜 두셨다면 인증 앱의 코드도 넣어 주세요.
|
||||
- 새 메일이 오면 휴대폰 알림으로 알려 드립니다. 앱의 [설정] → [새 메일 알림]에서 켜고 끌 수 있고,
|
||||
알림에는 새 메일이 몇 통인지만 나오며 보낸 사람이나 제목은 나오지 않습니다.
|
||||
- 아이폰용 앱은 아직 없습니다. 아이폰에서는 브라우저로 웹메일을 쓰거나 기본 메일 앱에 회사 메일을 연결해 주세요.
|
||||
연결 방법은 웹메일의 [설정] → [메일 프로그램 연결]에서 볼 수 있습니다.
|
||||
|
||||
## 웹메일과 휴대폰 앱
|
||||
- 메일 검색이 빨라졌고, 보낸 사람, 받는 사람, 제목, 본문, 기간, 첨부로 나눠 찾을 수도 있습니다.
|
||||
웹메일은 검색 칸의 [조건]을, 앱은 검색 화면의 [조건으로 찾기]를 눌러 주세요.
|
||||
- 찾은 말에는 노란 표시가 붙어 목록과 본문에서 금방 찾을 수 있습니다.
|
||||
- 메일을 읽다가 [이동]을 누르면 바로 다른 메일함으로 옮길 수 있습니다.
|
||||
- 업데이트 뒤 하루쯤은 새 검색을 준비하느라 검색 화면에 안내가 나올 수 있습니다. 그동안에도 찾기는 됩니다.
|
||||
|
||||
## 웹메일 · 메일 읽기와 정리
|
||||
- 메일을 열면 그 메일과 주고받은 메일이 아래에 대화로 모여 나옵니다.
|
||||
[모두 펼치기]로 한꺼번에 읽고, 펼친 메일의 [답장]으로 바로 답할 수 있습니다.
|
||||
- 목록 위의 [대화로 묶기]를 켜면 메일 목록도 대화별로 묶입니다.
|
||||
- 목록의 메일을 끌어다 왼쪽 메일함에 놓으면 그 메일함으로 옮겨집니다.
|
||||
- 메일함 이름 옆 [⋯] → [옮기기]로 메일함을 다른 메일함 아래로 옮길 수 있습니다.
|
||||
하위 메일함이 있는 메일함도 [⋯] → [지우기]로 한꺼번에 지울 수 있고, 안에 있던 메일은 휴지통으로 옮겨집니다.
|
||||
- 보낸 사람을 차단할 때 받은편지함에 있는 그 사람의 메일도 스팸메일함으로 함께 옮길 수 있습니다.
|
||||
메일을 읽다가 [⋯] → [보낸 사람 차단]을 누르고, 창에서 받은편지함의 메일도 옮기는 칸을 켜 주세요.
|
||||
- 예약한 메일을 기다리지 않고 바로 보낼 수 있습니다. 왼쪽의 [예약됨]에서 메일을 열고 [지금 보내기]를 눌러 주세요.
|
||||
|
||||
## 웹메일 · 주소록
|
||||
- 다른 메일이나 휴대폰에서 내보낸 연락처 파일을 한 번에 가져올 수 있습니다.
|
||||
[주소록]에서 [가져오기]를 누르고 파일을 고르면, 넣기 전에 몇 명이 들어가는지 먼저 확인할 수 있습니다.
|
||||
- vCard, CSV, 엑셀 파일을 넣을 수 있고, 네이버, Google, 아이폰, Outlook에서 내보낸 파일도 됩니다.
|
||||
- 이미 주소록에 있는 연락처는 건너뜁니다. 넣은 뒤 나오는 알림의 [되돌리기]를 누르면 이번에 넣은 연락처를 한꺼번에 지울 수 있습니다.
|
||||
- 내 주소록을 파일로 내보낼 수도 있습니다. [주소록]에서 [내보내기]를 누르고 vCard나 CSV를 골라 주세요. 그룹 하나만 내보낼 수도 있습니다.
|
||||
|
||||
## 웹메일 · 설정
|
||||
- 서명을 10개까지 만들어 두고 골라 쓸 수 있습니다. [설정] → [서명]에서 [새 서명]으로 만들고,
|
||||
[메일에 넣을 서명]에서 [새 메일]과 [답장과 전달]에 넣을 서명을 따로 고를 수 있습니다.
|
||||
- 메일을 쓰다가 아래의 [서명]을 누르면 다른 서명으로 바꿀 수 있습니다.
|
||||
- 부재중 자동 응답을 굵게, 색깔 같은 글자 모양으로 꾸며 쓸 수 있습니다. [설정] → [부재중 자동 응답]
|
||||
- 부재중 자동 응답에서 회사 밖에서 온 메일에는 [사내와 같은 응답], [보내지 않기], [따로 쓰기] 가운데 골라 답할 수 있습니다.
|
||||
- 휴대폰을 바꾸셨다면 2단계 인증을 새 휴대폰으로 직접 옮길 수 있습니다.
|
||||
[설정] → [2단계 인증] → [휴대폰 바꾸기]를 누르고 안내를 따라 주세요. 지금 휴대폰의 코드와 비밀번호가 필요합니다.
|
||||
|
||||
## 그 밖에
|
||||
- 화면의 안내 글을 더 알기 쉬운 말로 다듬었습니다.
|
||||
- 앞으로 새로 생기거나 바뀌는 것은 이 업데이트 내역에 올립니다. 웹메일과 앱 모두 [설정] → [업데이트 내역]에서 볼 수 있습니다.
|
||||
@@ -0,0 +1,41 @@
|
||||
# 업데이트 내역 글
|
||||
|
||||
직원이 웹메일과 휴대폰 앱의 설정에서 읽는 「업데이트 내역」의 글입니다. 글 하나가 파일 하나이고, 웹과 앱이 같은 글을 보여 줍니다. 올리는 화면은 없습니다.
|
||||
|
||||
## 글을 더하는 법
|
||||
|
||||
1. 이 폴더에 파일을 하나 더합니다. 파일 이름은 글의 날짜입니다(`2026-11-18.md`). 같은 날 두 번째 글은 `2026-11-18-2.md`로 씁니다.
|
||||
2. 커밋하고 배포합니다(백엔드를 다시 빌드). 웹과 앱에 함께 나옵니다. Gitea 웹 화면에서 파일을 더하거나 고쳐도 됩니다.
|
||||
|
||||
고치거나 지울 때도 그 파일만 다룹니다. 목록에는 최근 글이 위에 나옵니다(날짜 차례, 같은 날은 뒤 번호가 위).
|
||||
|
||||
## 파일 안
|
||||
|
||||
```
|
||||
# 메일 검색이 빨라지고 휴대폰 앱이 나왔습니다
|
||||
<!-- 배포 날짜가 정해지면 파일 이름을 그날로 바꿉니다 -->
|
||||
|
||||
## 휴대폰 앱
|
||||
- 안드로이드 휴대폰 앱이 나왔습니다. 웹메일의 [설정] → [휴대폰 앱]에서 받을 수 있습니다.
|
||||
- 앱에서 메일을 읽고 쓰고 찾을 수 있고, 새 메일이 오면 휴대폰으로 알려 드립니다.
|
||||
알림에는 새 메일이 몇 통인지만 나오고, 보낸 사람이나 제목은 나오지 않습니다.
|
||||
```
|
||||
|
||||
| 줄 | 뜻 |
|
||||
| --- | --- |
|
||||
| `# 제목` | 파일의 첫 줄이고 꼭 있어야 합니다. 목록의 한 줄과 글 화면의 제목이 됩니다(100자까지). |
|
||||
| `## 머리` | 묶음 머리입니다. 휴대폰 앱, 메일 검색처럼 어디 이야기인지 씁니다(40자까지). |
|
||||
| `- 한 줄` | 앞에 점이 붙은 한 줄입니다(500자까지). 쓴 글이 그대로 나옵니다. |
|
||||
| (빈칸 두 개) 글 | 앞 줄에 띄어쓰기 하나로 이어 붙습니다. 긴 줄을 파일에서 나눠 쓸 때 씁니다. |
|
||||
| `<!-- … -->` | 메모입니다. 화면에 나오지 않습니다. 줄 앞에서 시작합니다(앞에 빈칸은 있어도 되지만, 글 뒤에 붙여 쓰지는 않습니다). 여러 줄로 써도 됩니다. |
|
||||
| `- `(머리 없이) | 머리 없는 묶음입니다. 고친 것 한두 줄처럼 짧은 글에 씁니다. 제목 바로 아래에 씁니다. |
|
||||
|
||||
## 지킬 것
|
||||
|
||||
- 날짜는 파일 이름에서 읽으니 파일 안에 다시 쓰지 않습니다. 있는 날짜여야 합니다(`2026-02-30.md`는 안 됩니다).
|
||||
- 파일 이름은 글 주소가 됩니다. 날짜를 고치면 주소도 바뀌어, 그전 주소로 들어오면 없는 글 화면이 나옵니다.
|
||||
- 글 하나에 줄(`- `)이 하나는 있어야 하고, 묶음 머리 아래에도 줄이 하나는 있어야 합니다.
|
||||
- 굵게, 링크, 그림, 표, `###`, 하위 목록은 쓸 수 없습니다. 쓴 글이 그대로 나옵니다. 단추나 메뉴 이름은 `[설정] → [휴대폰 앱]`처럼 씁니다.
|
||||
- 이 폴더에는 이 안내(`README.md`)와 글 파일만 둡니다. 파일은 UTF-8로 저장합니다.
|
||||
- 형식이 틀린 글은 시험(CI)이 막습니다. 미리 보려면 backend 폴더에서 `npm run test -- release-notes`를 돌립니다. 그래도 배포되면 서버는 그 글만 건너뛰고 로그에 파일 이름과 줄 번호를 남깁니다. 서버는 그대로 뜹니다.
|
||||
- 앱은 옛 판이어도 새 글을 받으니, 앱 이야기를 쓸 때 새 판을 내지 않아도 됩니다.
|
||||
@@ -18,6 +18,7 @@ import { FileStorageModule } from '@/modules/file-storage/file-storage.module';
|
||||
import { HealthModule } from '@/modules/health/health.module';
|
||||
import { MailModule } from '@/modules/mail/mail.module';
|
||||
import { PushModule } from '@/modules/push/push.module';
|
||||
import { ReleaseNotesModule } from '@/modules/release-notes/release-notes.module';
|
||||
import { SystemSettingsModule } from '@/modules/system-settings/system-settings.module';
|
||||
import { UploadModule } from '@/modules/upload/upload.module';
|
||||
import { UserModule } from '@/modules/user/user.module';
|
||||
@@ -89,6 +90,8 @@ const THROTTLE_DEFAULT_LIMIT = 100; // 구간당 IP별 최대 요청 수
|
||||
AppConfigModule,
|
||||
// 휴대폰 앱 받기 (사내 배포) — 최신 판 정보 · APK 파일 (로그인 필요). 올리기는 운영 도구다
|
||||
AppReleasesModule,
|
||||
// 업데이트 내역 — 목록 · 글 (로그인 필요). 글은 저장소의 backend/release-notes 폴더(글 파일 하나가 글 하나), 올리는 API 는 없다
|
||||
ReleaseNotesModule,
|
||||
UploadModule,
|
||||
UserModule,
|
||||
],
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
/**
|
||||
* 여러 서명 (docs/mail/api-contract.md 2절 "서명" — 16단계).
|
||||
* mail_signatures 를 "한 사람에 한 줄(열쇠 = 주소)"에서 "서명마다 한 줄(열쇠 = id)"로 바꾼다 — 표를 새로 만들지 않고 고쳐 쓴다
|
||||
* (퇴사 마무리는 그대로 주소로 이 표의 줄을 지운다 — modules/ops/ops-data.service.ts).
|
||||
* - 지금 서명은 "기본 서명"이라는 이름의 서명 하나가 되고, 만든 시각은 저장한 시각이다. **내용이 비어 있던 서명(서식 · 텍스트가 모두 없거나 공백뿐)은 옮기지 않는다**(보드 결정)
|
||||
* - 두 체크(is_for_new · is_for_reply — 그 서명을 새 메일 · 답장과 전달에 넣는다)는 「메일에 넣을 서명」 둘의 표시가 된다 — 켠 곳의 기본이 그 서명, 끈 곳은 넣지 않음.
|
||||
* 칸 이름만 바꿔 값을 그대로 둔다(migration:generate 는 칸을 지우고 새로 만들어 값을 잃는다). 처음 값은 false(새로 만든 서명은 기본이 아니다)
|
||||
* - 한 사람에 기본 하나씩 — 부분 유일 인덱스 둘. 목록(만든 차례)은 (user_email, created_at) 인덱스
|
||||
* - 서명 그림 표(mail_signature_images)는 그대로 — 그림은 모든 서명이 함께 쓴다
|
||||
* 되돌리기(down): 한 사람에 한 줄만 남긴다 — 「새 메일」 기본 → 「답장 · 전달」 기본 → 먼저 만든 서명 차례로 하나, **나머지 서명은 지운다**.
|
||||
* 남긴 서명의 두 표시가 옛 두 체크가 된다. 옛 서버는 서명을 다음에 저장할 때 그 서명이 가리키지 않는 그림을 지운다.
|
||||
* (엔티티: modules/mail-signature/mail-signature.entity.ts — migration:generate 의 결과에서 이름(PK · 인덱스)은 그대로 두고 값을 지키게 고쳤다)
|
||||
*/
|
||||
export class MultipleMailSignatures1794269810995 implements MigrationInterface {
|
||||
name = 'MultipleMailSignatures1794269810995';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
// 내용이 비어 있던 서명은 옮기지 않는다 — 서식 · 텍스트 모두 없거나 공백뿐 (서비스는 빈 서식을 null 로 저장해 왔다)
|
||||
await queryRunner.query(
|
||||
`DELETE FROM "mail_signatures" WHERE ("body_html" IS NULL OR "body_html" !~ '\\S') AND ("body_text" IS NULL OR "body_text" !~ '\\S')`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "mail_signatures" ADD "id" uuid NOT NULL DEFAULT gen_random_uuid()`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "mail_signatures" ALTER COLUMN "id" DROP DEFAULT`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "mail_signatures" ADD "name" character varying(64) NOT NULL DEFAULT '기본 서명'`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "mail_signatures" ALTER COLUMN "name" DROP DEFAULT`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "mail_signatures" ADD "created_at" TIMESTAMP WITH TIME ZONE`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`UPDATE "mail_signatures" SET "created_at" = "updated_at"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "mail_signatures" ALTER COLUMN "created_at" SET NOT NULL`,
|
||||
);
|
||||
// 두 체크 → 「메일에 넣을 서명」 둘의 표시 (값은 그대로)
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "mail_signatures" RENAME COLUMN "is_for_new" TO "is_new_message_default"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "mail_signatures" ALTER COLUMN "is_new_message_default" SET DEFAULT false`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "mail_signatures" RENAME COLUMN "is_for_reply" TO "is_reply_default"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "mail_signatures" ALTER COLUMN "is_reply_default" SET DEFAULT false`,
|
||||
);
|
||||
// 열쇠 — 주소 → 서명 id
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "mail_signatures" DROP CONSTRAINT "PK_4846a94ce96c5f4fe400f449a7f"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "mail_signatures" ADD CONSTRAINT "PK_d210af8f68aec58b45e5f2ea02b" PRIMARY KEY ("id")`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE UNIQUE INDEX "UQ_mail_signatures_reply_default" ON "mail_signatures" ("user_email") WHERE "is_reply_default"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE UNIQUE INDEX "UQ_mail_signatures_new_message_default" ON "mail_signatures" ("user_email") WHERE "is_new_message_default"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_mail_signatures_user_email_created_at" ON "mail_signatures" ("user_email", "created_at") `,
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
// 한 사람에 한 줄만 — 「새 메일」 기본 → 「답장 · 전달」 기본 → 먼저 만든 서명 차례로 남기고 나머지는 지운다
|
||||
await queryRunner.query(
|
||||
`DELETE FROM "mail_signatures" AS "s" USING (SELECT "id", row_number() OVER (PARTITION BY "user_email" ORDER BY "is_new_message_default" DESC, "is_reply_default" DESC, "created_at" ASC, "id" ASC) AS "rank" FROM "mail_signatures") AS "ranked" WHERE "s"."id" = "ranked"."id" AND "ranked"."rank" > 1`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`DROP INDEX "public"."IDX_mail_signatures_user_email_created_at"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`DROP INDEX "public"."UQ_mail_signatures_new_message_default"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`DROP INDEX "public"."UQ_mail_signatures_reply_default"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "mail_signatures" DROP CONSTRAINT "PK_d210af8f68aec58b45e5f2ea02b"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "mail_signatures" ADD CONSTRAINT "PK_4846a94ce96c5f4fe400f449a7f" PRIMARY KEY ("user_email")`,
|
||||
);
|
||||
// 남긴 서명의 표시 → 옛 두 체크 (처음 값은 옛 그대로 true)
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "mail_signatures" RENAME COLUMN "is_reply_default" TO "is_for_reply"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "mail_signatures" ALTER COLUMN "is_for_reply" SET DEFAULT true`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "mail_signatures" RENAME COLUMN "is_new_message_default" TO "is_for_new"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "mail_signatures" ALTER COLUMN "is_for_new" SET DEFAULT true`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "mail_signatures" DROP COLUMN "created_at"`,
|
||||
);
|
||||
await queryRunner.query(`ALTER TABLE "mail_signatures" DROP COLUMN "name"`);
|
||||
await queryRunner.query(`ALTER TABLE "mail_signatures" DROP COLUMN "id"`);
|
||||
}
|
||||
}
|
||||
@@ -43,6 +43,14 @@ export type TwoFactorEnableResult = 'enabled' | 'refused' | 'unknown';
|
||||
*/
|
||||
export type TwoFactorDisableResult = 'disabled' | 'code-rejected' | 'unknown';
|
||||
|
||||
/**
|
||||
* 2단계 인증 휴대폰 바꾸기의 엔진 판단 (16단계 — 켠 채 새 비밀로) — 비밀번호는 부르는 쪽이 먼저 확인한다:
|
||||
* - replaced: 바꿨다 — 옛 비밀은 바로 죽는다
|
||||
* - code-rejected: 엔진이 받지 않았다 — 지금 휴대폰의 코드 틀림 (엔진은 비밀번호 틀림과 코드 틀림을 가르지 않는다)
|
||||
* - unknown: 결과를 알 수 없게 끝났다 — 엔진은 otpUrl 을 가려 보여 주므로 다시 읽어도 가를 수 없다
|
||||
*/
|
||||
export type TwoFactorReplaceResult = 'replaced' | 'code-rejected' | 'unknown';
|
||||
|
||||
export interface AccountSecurityProvider {
|
||||
/** 엔진에서 2단계 인증이 켜져 있는가 — 엔진이 기준이다 (계약 5절 "2단계 인증") */
|
||||
isTwoFactorEnabled(user: string): Promise<boolean>;
|
||||
@@ -64,6 +72,17 @@ export interface AccountSecurityProvider {
|
||||
otpCode: string,
|
||||
): Promise<TwoFactorDisableResult>;
|
||||
|
||||
/**
|
||||
* 2단계 인증을 켠 채 휴대폰(비밀)을 바꾼다 (16단계) — 지금 비밀번호 · 지금 휴대폰의 코드 · 새 otpauth 주소를 한 요청으로.
|
||||
* 2단계가 꺼지는 틈이 없다. 엔진은 새 비밀의 코드를 보지 않는다 — 새 휴대폰의 코드 확인은 부르는 쪽(TwoFactorService)이 먼저 한다
|
||||
*/
|
||||
replaceTwoFactor(
|
||||
user: string,
|
||||
currentPassword: string,
|
||||
otpCode: string,
|
||||
otpauthUrl: string,
|
||||
): Promise<TwoFactorReplaceResult>;
|
||||
|
||||
/** 앱 비밀번호 목록 (차례는 부르는 쪽이 정한다) */
|
||||
listAppPasswords(user: string): Promise<AppPasswordRecord[]>;
|
||||
|
||||
|
||||
@@ -101,6 +101,53 @@ export class TwoFactorActivationDto {
|
||||
currentPassword!: string;
|
||||
}
|
||||
|
||||
/** POST /api/two-factor/enrollments/{id}/verification (16단계 — 휴대폰 바꾸기 ①) */
|
||||
export class TwoFactorVerificationDto {
|
||||
@ApiProperty({
|
||||
description:
|
||||
'새 휴대폰 인증 앱의 숫자 6자리 — 등록의 비밀로 서버가 확인한다(앞뒤 30초까지)',
|
||||
example: '123456',
|
||||
})
|
||||
@OtpCodeField()
|
||||
otpCode!: string;
|
||||
}
|
||||
|
||||
/** POST /api/two-factor/enrollments/{id}/verification → 200 */
|
||||
export class TwoFactorVerifiedDto {
|
||||
@ApiProperty({
|
||||
description:
|
||||
'늘 true — 등록에 "확인됨"을 달았다(10분 안에 바꾸기를 보낸다)',
|
||||
example: true,
|
||||
})
|
||||
verified!: true;
|
||||
}
|
||||
|
||||
/** POST /api/two-factor/replacements (16단계 — 휴대폰 바꾸기 ②) */
|
||||
export class TwoFactorReplacementDto {
|
||||
@ApiProperty({
|
||||
description:
|
||||
'확인한 등록의 enrollmentId (POST /api/two-factor/enrollments → …/verification)',
|
||||
example: '1f0c2a5e-3c1d-4b8e-9f57-6e2d1b0a9c44',
|
||||
})
|
||||
@IsUUID('all', { message: '2단계 인증 등록 정보가 올바르지 않습니다.' })
|
||||
enrollmentId!: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: '지금 휴대폰 인증 앱의 숫자 6자리 — 엔진이 확인한다',
|
||||
example: '654321',
|
||||
})
|
||||
@OtpCodeField()
|
||||
currentOtpCode!: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: `지금 비밀번호 (${MAX_PASSWORD_LENGTH}자 이하, 앱 비밀번호는 받지 않는다)`,
|
||||
format: 'password',
|
||||
example: 'current-password',
|
||||
})
|
||||
@CurrentPasswordField()
|
||||
currentPassword!: string;
|
||||
}
|
||||
|
||||
/** POST /api/two-factor/deactivation */
|
||||
export class TwoFactorDeactivationDto {
|
||||
@ApiProperty({
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import { Injectable, PipeTransform } from '@nestjs/common';
|
||||
import { isUUID } from 'class-validator';
|
||||
|
||||
import { BusinessException } from '@/common/errors/business.exception';
|
||||
|
||||
/**
|
||||
* 경로의 2단계 인증 등록 id (16단계 — `/api/two-factor/enrollments/{id}/verification`) — UUID 가 아니면 400 VAL_001.
|
||||
* 켜기 · 바꾸기 본문의 enrollmentId(IsUUID)와 같은 규칙이다 (모양은 맞는데 없는 등록은 서비스가 409 AUTH_008)
|
||||
*/
|
||||
@Injectable()
|
||||
export class TwoFactorEnrollmentIdPipe implements PipeTransform<
|
||||
unknown,
|
||||
string
|
||||
> {
|
||||
transform(value: unknown): string {
|
||||
if (typeof value === 'string' && isUUID(value, 'all')) return value;
|
||||
throw new BusinessException('VAL_001', {
|
||||
context: { reason: 'malformed-two-factor-enrollment-id' },
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -12,13 +12,16 @@ export interface TwoFactorEnrollment {
|
||||
readonly secret: Buffer;
|
||||
/** 끝나는 때 (epoch ms) */
|
||||
readonly expiresAt: number;
|
||||
/** 새 휴대폰의 코드로 확인했다 — 휴대폰 바꾸기(16단계)는 확인한 등록으로만 한다 */
|
||||
readonly verified?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 기다리는 2단계 인증 등록 (계약 5절 "2단계 인증") — 직원마다 하나, 10분.
|
||||
* 새로 시작하면 앞의 것을 버린다. 켜면(또는 시간이 지나면) 지운다 — 켠 뒤의 비밀은 엔진에만 있다(서버 · DB · 로그 어디에도 두지 않는다).
|
||||
* 새로 시작하면 앞의 것을 버린다. 켜거나 휴대폰을 바꾸면(또는 시간이 지나면) 지운다 — 그 뒤의 비밀은 엔진에만 있다(서버 · DB · 로그 어디에도 두지 않는다).
|
||||
* 켜기와 휴대폰 바꾸기(16단계 — 켜져 있을 때의 등록)가 같은 자리를 쓴다.
|
||||
*
|
||||
* ⚠️ 프로세스 메모리다 — 인스턴스를 늘리면 공유 저장소로 옮긴다(10분짜리 항목). 그대로면 다른 인스턴스로 간 켜기가 409 AUTH_008 이다.
|
||||
* ⚠️ 프로세스 메모리다 — 인스턴스를 늘리면 공유 저장소로 옮긴다(10분짜리 항목). 그대로면 다른 인스턴스로 간 켜기 · 바꾸기가 409 AUTH_008 이다.
|
||||
*/
|
||||
@Injectable()
|
||||
export class TwoFactorEnrollments {
|
||||
@@ -45,6 +48,14 @@ export class TwoFactorEnrollments {
|
||||
return enrollment.id === id ? enrollment : null;
|
||||
}
|
||||
|
||||
/** 그 id 의 등록에 "확인됨"을 단다 (새 휴대폰의 코드가 맞았다) — 등록이 없거나 지났거나 바뀌었으면 false */
|
||||
markVerified(email: string, id: string, now: number = Date.now()): boolean {
|
||||
const enrollment = this.find(email, id, now);
|
||||
if (!enrollment) return false;
|
||||
this.pending.set(email, { ...enrollment, verified: true });
|
||||
return true;
|
||||
}
|
||||
|
||||
/** 등록을 버린다 — 비밀 바이트도 지운다 */
|
||||
forget(email: string): void {
|
||||
const enrollment = this.pending.get(email);
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
Ip,
|
||||
Param,
|
||||
Post,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
@@ -25,8 +26,12 @@ import {
|
||||
TwoFactorActivationDto,
|
||||
TwoFactorDeactivationDto,
|
||||
TwoFactorEnrollmentDto,
|
||||
TwoFactorReplacementDto,
|
||||
TwoFactorStatusDto,
|
||||
TwoFactorVerificationDto,
|
||||
TwoFactorVerifiedDto,
|
||||
} from './dto/two-factor.dto';
|
||||
import { TwoFactorEnrollmentIdPipe } from './two-factor-enrollment-id.pipe';
|
||||
import {
|
||||
TwoFactorService,
|
||||
type TwoFactorStatusView,
|
||||
@@ -50,7 +55,7 @@ function toStatusDto(status: TwoFactorStatusView): TwoFactorStatusDto {
|
||||
'AUTH_001',
|
||||
'로그인 필요 (쿠키 없음 · 만료 · 끊은 세션) · 웹메일로 다루지 않는 계정(메일 엔진 관리자 등)',
|
||||
)
|
||||
// 13단계 — 상태 · 등록 · 켜기는 제한된 세션(비밀번호 바꾸기 · 2단계 켜기 전)도 부른다(@AllowRestrictedSession). 끄기는 403 AUTH_010
|
||||
// 13단계 — 상태 · 등록 · 켜기는 제한된 세션(비밀번호 바꾸기 · 2단계 켜기 전)도 부른다(@AllowRestrictedSession). 끄기 · 휴대폰 바꾸기(16단계 — 확인 · 바꾸기)는 403 AUTH_010
|
||||
@Controller('two-factor')
|
||||
export class TwoFactorController {
|
||||
constructor(private readonly twoFactor: TwoFactorService) {}
|
||||
@@ -80,18 +85,19 @@ export class TwoFactorController {
|
||||
@AllowRestrictedSession()
|
||||
@NoStore()
|
||||
@ApiOperation({
|
||||
summary: '2단계 인증 등록 시작 — 비밀과 otpauth 주소 (꺼져 있을 때만)',
|
||||
summary:
|
||||
'2단계 인증 등록 시작 — 비밀과 otpauth 주소 (꺼져 있으면 켜기용 · 켜져 있으면 휴대폰 바꾸기용)',
|
||||
description:
|
||||
'서버가 160비트 비밀을 만들어 10분 동안 서버 메모리에만 둔다(켜면 엔진에만 남는다). 직원마다 기다리는 등록은 하나 — 새로 시작하면 앞의 것은 버린다. ' +
|
||||
'서버가 160비트 비밀을 만들어 10분 동안 서버 메모리에만 둔다(켜거나 바꾸면 엔진에만 남는다). 직원마다 기다리는 등록은 하나 — 새로 시작하면 앞의 것은 버린다. ' +
|
||||
'16단계부터 켜져 있어도 받는다(휴대폰 바꾸기 — …/verification → /api/two-factor/replacements). 켜진 채 이 등록으로 켜기를 보내면 409 AUTH_008 이다. ' +
|
||||
'응답은 Cache-Control: private, no-store.',
|
||||
})
|
||||
@ApiStandardResponse(TwoFactorEnrollmentDto, {
|
||||
status: 201,
|
||||
description: '등록 — 비밀 · otpauth 주소는 이 응답에만',
|
||||
})
|
||||
@ApiErrorResponse('AUTH_008', '이미 켜져 있음 — 화면은 상태를 다시 받는다')
|
||||
@ApiErrorResponse('REQ_001', '같은 직원의 2단계 인증 바꾸기가 처리 중')
|
||||
@ApiErrorResponse('MAIL_001', '메일 엔진 장애')
|
||||
@ApiErrorResponse('MAIL_001', '메일 엔진 설정 없음')
|
||||
async enroll(
|
||||
@CurrentUser() user: AuthenticatedUser,
|
||||
): Promise<TwoFactorEnrollmentDto> {
|
||||
@@ -104,6 +110,93 @@ export class TwoFactorController {
|
||||
};
|
||||
}
|
||||
|
||||
@Post('enrollments/:id/verification')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({
|
||||
summary: '휴대폰 바꾸기 ① — 새 휴대폰의 코드 확인 (16단계)',
|
||||
description:
|
||||
'새 휴대폰의 코드를 등록의 비밀로 확인하고(앞뒤 30초) 등록에 "확인됨"을 단다 — 엔진은 등록 때 코드를 보지 않는다. 엔진에는 아직 아무것도 바꾸지 않는다. ' +
|
||||
'받은 코드는 2분 동안 다시 받지 않는다(코드 실패 한도 · 같은 코드 막기는 켜기 · 끄기와 같다). 확인은 등록에 남는다(10분) — [이전]으로 돌아와도 다시 확인하지 않아도 된다. ' +
|
||||
'제한된 세션은 403 AUTH_010.',
|
||||
})
|
||||
@ApiStandardResponse(TwoFactorVerifiedDto, { description: '확인했다' })
|
||||
@ApiErrorResponse(
|
||||
'AUTH_006',
|
||||
'AUTH_006 코드 틀림 · 2분 안에 다시 쓴 코드 / VAL_001 코드 · 등록 id 형식 오류 (모두 400)',
|
||||
)
|
||||
@ApiErrorResponse(
|
||||
'AUTH_008',
|
||||
'등록이 없음 · 10분 지남 · 더 새 등록으로 바뀜 — 화면은 처음부터 다시(새 등록)',
|
||||
)
|
||||
@ApiErrorResponse(
|
||||
'AUTH_010',
|
||||
'제한된 세션 — 먼저 비밀번호를 바꾸거나 2단계 인증을 켠다 (13단계)',
|
||||
)
|
||||
@ApiErrorResponse(
|
||||
'REQ_001',
|
||||
'코드 실패 한도(같은 주소 15분 5회 · 24시간 20회) · 처리 중',
|
||||
)
|
||||
async verifyEnrollment(
|
||||
@CurrentUser() user: AuthenticatedUser,
|
||||
@Param('id', TwoFactorEnrollmentIdPipe) id: string,
|
||||
@Body() dto: TwoFactorVerificationDto,
|
||||
@Ip() ip: string,
|
||||
): Promise<TwoFactorVerifiedDto> {
|
||||
await this.twoFactor.verifyEnrollment(
|
||||
user.email,
|
||||
{ enrollmentId: id, otpCode: dto.otpCode },
|
||||
{ ip },
|
||||
);
|
||||
return { verified: true };
|
||||
}
|
||||
|
||||
@Post('replacements')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({
|
||||
summary:
|
||||
'휴대폰 바꾸기 ② — 지금 휴대폰의 코드와 지금 비밀번호로 바꾼다 (16단계)',
|
||||
description:
|
||||
'①에서 확인한 등록만 받는다. 지금 비밀번호를 먼저 확인하고(틀리면 AUTH_005 — 로그인 실패 한도로 센다, 그사이 2단계가 꺼졌으면 AUTH_008) ' +
|
||||
'지금 휴대폰의 코드와 새 비밀을 엔진 요청 하나로 보낸다 — 2단계가 꺼지는 틈이 없고 옛 휴대폰의 코드는 바로 막힌다. ' +
|
||||
'지금 코드가 틀리거나 2분 안에 다시 쓴 코드면 AUTH_006(코드 실패로 센다 — 등록은 남아 다시 보낼 수 있다). ' +
|
||||
'결과를 모르면 503 MAIL_001 — 엔진이 비밀을 가려 보여 주어 다시 읽어도 가를 수 없다(등록은 남는다). 켠 때(enabledAt)는 그대로, 로그인된 기기는 끊지 않는다. ' +
|
||||
'제한된 세션은 403 AUTH_010.',
|
||||
})
|
||||
@ApiStandardResponse(TwoFactorStatusDto, { description: '바꿨다' })
|
||||
@ApiErrorResponse(
|
||||
'AUTH_006',
|
||||
'AUTH_006 지금 코드 틀림 · 2분 안에 다시 쓴 코드 / AUTH_005 지금 비밀번호 틀림 · 앱 비밀번호 / VAL_001 형식 오류 (모두 400)',
|
||||
)
|
||||
@ApiErrorResponse(
|
||||
'AUTH_008',
|
||||
'등록이 없음 · 10분 지남 · 더 새 등록 · 확인(①)하지 않은 등록 · 그사이 다른 곳에서 꺼짐 — 화면은 처음부터 다시',
|
||||
)
|
||||
@ApiErrorResponse(
|
||||
'AUTH_010',
|
||||
'제한된 세션 — 먼저 비밀번호를 바꾸거나 2단계 인증을 켠다 (13단계)',
|
||||
)
|
||||
@ApiErrorResponse('REQ_001', '코드 실패 한도 · 비밀번호 실패 한도 · 처리 중')
|
||||
@ApiErrorResponse(
|
||||
'MAIL_001',
|
||||
'메일 엔진 장애 · 결과를 알 수 없음(가를 수 없다 — 다시 보내기 전에 새 휴대폰의 코드로 로그인되는지 본다)',
|
||||
)
|
||||
async replace(
|
||||
@CurrentUser() user: AuthenticatedUser,
|
||||
@Body() dto: TwoFactorReplacementDto,
|
||||
@Ip() ip: string,
|
||||
): Promise<TwoFactorStatusDto> {
|
||||
const status = await this.twoFactor.replace(
|
||||
user.email,
|
||||
{
|
||||
enrollmentId: dto.enrollmentId,
|
||||
currentOtpCode: dto.currentOtpCode,
|
||||
currentPassword: dto.currentPassword,
|
||||
},
|
||||
{ ip },
|
||||
);
|
||||
return toStatusDto(await this.twoFactor.withPolicy(user.email, status));
|
||||
}
|
||||
|
||||
@Post('activation')
|
||||
@AllowRestrictedSession()
|
||||
@HttpCode(HttpStatus.OK)
|
||||
|
||||
@@ -16,6 +16,7 @@ import type {
|
||||
AccountSecurityProvider,
|
||||
TwoFactorDisableResult,
|
||||
TwoFactorEnableResult,
|
||||
TwoFactorReplaceResult,
|
||||
} from './account-security.provider';
|
||||
import { TwoFactorCodeGuard } from './two-factor-code-guard';
|
||||
import { TwoFactorEnrollments } from './two-factor-enrollments';
|
||||
@@ -114,7 +115,10 @@ describe('TwoFactorService (2단계 인증 켜기 · 끄기 — 계약 5절)', (
|
||||
let provider: jest.Mocked<
|
||||
Pick<
|
||||
AccountSecurityProvider,
|
||||
'isTwoFactorEnabled' | 'enableTwoFactor' | 'disableTwoFactor'
|
||||
| 'isTwoFactorEnabled'
|
||||
| 'enableTwoFactor'
|
||||
| 'disableTwoFactor'
|
||||
| 'replaceTwoFactor'
|
||||
>
|
||||
>;
|
||||
let mail: { verifyCredentials: jest.Mock };
|
||||
@@ -160,6 +164,10 @@ describe('TwoFactorService (2단계 인증 켜기 · 끄기 — 계약 5절)', (
|
||||
engineEnabled = false;
|
||||
return Promise.resolve('disabled');
|
||||
}),
|
||||
replaceTwoFactor: jest.fn<
|
||||
Promise<TwoFactorReplaceResult>,
|
||||
[string, string, string, string]
|
||||
>(() => Promise.resolve('replaced')),
|
||||
};
|
||||
mail = {
|
||||
verifyCredentials: jest.fn(() =>
|
||||
@@ -376,7 +384,7 @@ describe('TwoFactorService (2단계 인증 켜기 · 끄기 — 계약 5절)', (
|
||||
).toBe('AUTH_008');
|
||||
});
|
||||
|
||||
it('그사이 다른 곳에서 켜졌으면 등록 · 켜기 모두 409 AUTH_008', async () => {
|
||||
it('그사이 다른 곳에서 켜졌으면 켜기는 409 AUTH_008 — 등록은 켜져 있어도 받는다(16단계 휴대폰 바꾸기용 · 엔진에 묻지 않는다)', async () => {
|
||||
const { enrollment, code } = await enroll();
|
||||
engineEnabled = true;
|
||||
|
||||
@@ -395,9 +403,11 @@ describe('TwoFactorService (2단계 인증 켜기 · 끄기 — 계약 5절)', (
|
||||
)
|
||||
)?.code,
|
||||
).toBe('AUTH_008');
|
||||
expect((await failureOf(service.startEnrollment(ARA)))?.code).toBe(
|
||||
'AUTH_008',
|
||||
);
|
||||
provider.isTwoFactorEnabled.mockClear();
|
||||
await expect(service.startEnrollment(ARA)).resolves.toMatchObject({
|
||||
secret: expect.stringMatching(/^[A-Z2-7]{32}$/) as unknown,
|
||||
});
|
||||
expect(provider.isTwoFactorEnabled).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('켜기의 결과를 모르면 엔진을 다시 읽어 켜졌으면 성공, 아니면 503 MAIL_001', async () => {
|
||||
@@ -579,6 +589,251 @@ describe('TwoFactorService (2단계 인증 켜기 · 끄기 — 계약 5절)', (
|
||||
}
|
||||
});
|
||||
|
||||
describe('휴대폰 바꾸기 (16단계 — 켠 채 새 비밀로)', () => {
|
||||
const OLD_CODE = '135790';
|
||||
const enabledAt = new Date('2026-10-31T00:12:00.000Z');
|
||||
|
||||
beforeEach(() => {
|
||||
engineEnabled = true;
|
||||
table.rows.set(ARA, { userEmail: ARA, enabledAt });
|
||||
// 엔진 대역 — 지금 휴대폰의 코드는 OLD_CODE 하나만 맞다
|
||||
provider.replaceTwoFactor.mockImplementation((_user, _password, code) =>
|
||||
Promise.resolve(code === OLD_CODE ? 'replaced' : 'code-rejected'),
|
||||
);
|
||||
});
|
||||
|
||||
/** 등록하고 새 휴대폰의 코드로 확인한다 */
|
||||
const verified = async () => {
|
||||
const { enrollment, code } = await enroll();
|
||||
const newCode = code();
|
||||
await service.verifyEnrollment(
|
||||
ARA,
|
||||
{ enrollmentId: enrollment.enrollmentId, otpCode: newCode },
|
||||
{ ip: IP },
|
||||
);
|
||||
return { enrollment, newCode, code };
|
||||
};
|
||||
|
||||
const replace = (
|
||||
enrollmentId: string,
|
||||
overrides: { currentOtpCode?: string; currentPassword?: string } = {},
|
||||
ip = IP,
|
||||
) =>
|
||||
service.replace(
|
||||
ARA,
|
||||
{
|
||||
enrollmentId,
|
||||
currentOtpCode: OLD_CODE,
|
||||
currentPassword: PASSWORD,
|
||||
...overrides,
|
||||
},
|
||||
{ ip },
|
||||
);
|
||||
|
||||
it('① 새 휴대폰의 코드를 등록의 비밀로 확인(엔진에 묻지 않는다) → ② 지금 비밀번호(본인 Basic 402) → 지금 코드와 새 주소를 엔진 한 요청 — 켠 때는 그대로, 등록은 지운다', async () => {
|
||||
const { enrollment, newCode } = await verified();
|
||||
expect(mail.verifyCredentials).not.toHaveBeenCalled();
|
||||
expect(provider.replaceTwoFactor).not.toHaveBeenCalled();
|
||||
expect(codes.isUsed(ARA, newCode)).toBe(true);
|
||||
|
||||
const status = await replace(enrollment.enrollmentId);
|
||||
|
||||
expect(status).toEqual({ isEnabled: true, enabledAt });
|
||||
expect(mail.verifyCredentials).toHaveBeenCalledWith(ARA, PASSWORD);
|
||||
expect(provider.replaceTwoFactor).toHaveBeenCalledWith(
|
||||
ARA,
|
||||
PASSWORD,
|
||||
OLD_CODE,
|
||||
enrollment.otpauthUrl,
|
||||
);
|
||||
expect(codes.isUsed(ARA, OLD_CODE)).toBe(true);
|
||||
expect(table.rows.get(ARA)?.enabledAt).toEqual(enabledAt);
|
||||
expect(logs).toContain(
|
||||
'2단계 인증 휴대폰을 바꿨습니다: a***@arasystem.test',
|
||||
);
|
||||
expect(logs.join('\n')).not.toMatch(
|
||||
new RegExp(`${OLD_CODE}|${newCode}|${PASSWORD}|otpauth`),
|
||||
);
|
||||
// 등록은 지웠다 — 같은 등록으로 다시 바꾸지 못한다
|
||||
expect((await failureOf(replace(enrollment.enrollmentId)))?.code).toBe(
|
||||
'AUTH_008',
|
||||
);
|
||||
});
|
||||
|
||||
it('확인하지 않은 등록은 409 AUTH_008 — 등록을 버린다(처음부터) · 없는 등록 · 다른 id 도 AUTH_008', async () => {
|
||||
const { enrollment, code } = await enroll();
|
||||
expect((await failureOf(replace(enrollment.enrollmentId)))?.code).toBe(
|
||||
'AUTH_008',
|
||||
);
|
||||
// 버렸다 — 확인도 할 수 없다
|
||||
expect(
|
||||
(
|
||||
await failureOf(
|
||||
service.verifyEnrollment(
|
||||
ARA,
|
||||
{ enrollmentId: enrollment.enrollmentId, otpCode: code() },
|
||||
{ ip: IP },
|
||||
),
|
||||
)
|
||||
)?.code,
|
||||
).toBe('AUTH_008');
|
||||
expect(
|
||||
(await failureOf(replace('00000000-0000-4000-8000-000000000000')))
|
||||
?.code,
|
||||
).toBe('AUTH_008');
|
||||
expect(mail.verifyCredentials).not.toHaveBeenCalled();
|
||||
expect(provider.replaceTwoFactor).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('① 새 휴대폰의 코드가 틀리면 400 AUTH_006 — 코드 실패로 센다(15분 5회 → REQ_001), 확인되지 않는다', async () => {
|
||||
const { enrollment, code } = await enroll();
|
||||
const wrong = code() === '000000' ? '111111' : '000000';
|
||||
for (let i = 0; i < TWO_FACTOR_CODE_ADDRESS_LIMIT; i += 1) {
|
||||
expect(
|
||||
(
|
||||
await failureOf(
|
||||
service.verifyEnrollment(
|
||||
ARA,
|
||||
{ enrollmentId: enrollment.enrollmentId, otpCode: wrong },
|
||||
{ ip: `198.51.100.${i}` },
|
||||
),
|
||||
)
|
||||
)?.code,
|
||||
).toBe('AUTH_006');
|
||||
}
|
||||
expect(
|
||||
(
|
||||
await failureOf(
|
||||
service.verifyEnrollment(
|
||||
ARA,
|
||||
{ enrollmentId: enrollment.enrollmentId, otpCode: code() },
|
||||
{ ip: IP },
|
||||
),
|
||||
)
|
||||
)?.code,
|
||||
).toBe('REQ_001');
|
||||
expect((await failureOf(replace(enrollment.enrollmentId)))?.code).toBe(
|
||||
'AUTH_008',
|
||||
);
|
||||
});
|
||||
|
||||
it('② 지금 비밀번호가 틀리면 400 AUTH_005(로그인 실패로 센다) · 앱 비밀번호 모양은 묻지 않고 AUTH_005 — 엔진에 보내지 않고 등록은 남는다', async () => {
|
||||
const { enrollment } = await verified();
|
||||
mail.verifyCredentials.mockResolvedValueOnce(INVALID);
|
||||
expect(
|
||||
(
|
||||
await failureOf(
|
||||
replace(enrollment.enrollmentId, { currentPassword: 'wrong' }),
|
||||
)
|
||||
)?.code,
|
||||
).toBe('AUTH_005');
|
||||
mail.verifyCredentials.mockClear();
|
||||
expect(
|
||||
(
|
||||
await failureOf(
|
||||
replace(enrollment.enrollmentId, {
|
||||
currentPassword: `app_${'a'.repeat(36)}`,
|
||||
}),
|
||||
)
|
||||
)?.context,
|
||||
).toMatchObject({ reason: 'app-password-shape' });
|
||||
expect(mail.verifyCredentials).not.toHaveBeenCalled();
|
||||
expect(provider.replaceTwoFactor).not.toHaveBeenCalled();
|
||||
// 등록은 남았다 — 비밀번호만 고쳐 다시 보낸다
|
||||
await expect(replace(enrollment.enrollmentId)).resolves.toMatchObject({
|
||||
isEnabled: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('② 그사이 다른 곳에서 꺼졌으면(본인 Basic 200) 409 AUTH_008 — 엔진에 보내지 않고 등록을 버린다', async () => {
|
||||
const { enrollment } = await verified();
|
||||
engineEnabled = false;
|
||||
expect((await failureOf(replace(enrollment.enrollmentId)))?.code).toBe(
|
||||
'AUTH_008',
|
||||
);
|
||||
expect(provider.replaceTwoFactor).not.toHaveBeenCalled();
|
||||
engineEnabled = true;
|
||||
expect((await failureOf(replace(enrollment.enrollmentId)))?.code).toBe(
|
||||
'AUTH_008',
|
||||
);
|
||||
});
|
||||
|
||||
it('② 엔진이 지금 코드를 받지 않으면(forbidden) 400 AUTH_006 — 코드 실패로 센다, 등록은 남아 맞는 코드로 다시 보낼 수 있다', async () => {
|
||||
const { enrollment, newCode } = await verified();
|
||||
expect(
|
||||
(
|
||||
await failureOf(
|
||||
replace(enrollment.enrollmentId, { currentOtpCode: '246800' }),
|
||||
)
|
||||
)?.code,
|
||||
).toBe('AUTH_006');
|
||||
// 다시 쓴 코드(①에서 쓴 새 휴대폰의 코드)는 엔진에 묻지 않고 AUTH_006
|
||||
provider.replaceTwoFactor.mockClear();
|
||||
expect(
|
||||
(
|
||||
await failureOf(
|
||||
replace(enrollment.enrollmentId, { currentOtpCode: newCode }),
|
||||
)
|
||||
)?.code,
|
||||
).toBe('AUTH_006');
|
||||
expect(provider.replaceTwoFactor).not.toHaveBeenCalled();
|
||||
|
||||
await expect(replace(enrollment.enrollmentId)).resolves.toMatchObject({
|
||||
isEnabled: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('② 결과를 모르면 503 MAIL_001 — 다시 읽지 않는다(가를 수 없다), 코드는 쓰지 않은 것으로, 등록은 남는다', async () => {
|
||||
const { enrollment } = await verified();
|
||||
provider.replaceTwoFactor.mockResolvedValueOnce('unknown');
|
||||
provider.isTwoFactorEnabled.mockClear();
|
||||
|
||||
const failure = await failureOf(replace(enrollment.enrollmentId));
|
||||
expect(failure?.code).toBe('MAIL_001');
|
||||
expect(failure?.context).toMatchObject({
|
||||
reason: 'two-factor-replace-outcome-unknown',
|
||||
});
|
||||
expect(provider.isTwoFactorEnabled).not.toHaveBeenCalled();
|
||||
expect(codes.isUsed(ARA, OLD_CODE)).toBe(false);
|
||||
await expect(replace(enrollment.enrollmentId)).resolves.toMatchObject({
|
||||
isEnabled: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('같은 직원의 바꾸기가 처리 중이면 확인 · 바꾸기 모두 429 REQ_001', async () => {
|
||||
const { enrollment, code } = await enroll();
|
||||
const release = locks.acquire(ARA);
|
||||
try {
|
||||
expect(
|
||||
(
|
||||
await failureOf(
|
||||
service.verifyEnrollment(
|
||||
ARA,
|
||||
{ enrollmentId: enrollment.enrollmentId, otpCode: code() },
|
||||
{ ip: IP },
|
||||
),
|
||||
)
|
||||
)?.code,
|
||||
).toBe('REQ_001');
|
||||
expect((await failureOf(replace(enrollment.enrollmentId)))?.code).toBe(
|
||||
'REQ_001',
|
||||
);
|
||||
} finally {
|
||||
release();
|
||||
}
|
||||
});
|
||||
|
||||
it('켠 때 기록을 읽지 못해도 바꾸기는 성공 — enabledAt null · error 로그', async () => {
|
||||
const { enrollment } = await verified();
|
||||
table.repository.findOneBy.mockRejectedValueOnce(new Error('db down'));
|
||||
await expect(replace(enrollment.enrollmentId)).resolves.toEqual({
|
||||
isEnabled: true,
|
||||
enabledAt: null,
|
||||
});
|
||||
expect(errors.join('\n')).toContain('켠 때 기록을 읽지 못했습니다');
|
||||
});
|
||||
});
|
||||
|
||||
describe('끄기', () => {
|
||||
beforeEach(() => {
|
||||
engineEnabled = true;
|
||||
|
||||
@@ -31,6 +31,7 @@ import { TwoFactorCodeGuard } from './two-factor-code-guard';
|
||||
import {
|
||||
TwoFactorEnrollments,
|
||||
enrollmentExpiry,
|
||||
type TwoFactorEnrollment,
|
||||
} from './two-factor-enrollments';
|
||||
import { TwoFactorLocks } from './two-factor-locks';
|
||||
import { TwoFactorPolicyService } from './two-factor-policy.service';
|
||||
@@ -74,7 +75,9 @@ function errorMessage(error: unknown): string {
|
||||
/**
|
||||
* 2단계 인증 켜기 · 끄기 (계약 5절 "2단계 인증") — **엔진이 기준이다**(켜짐은 엔진의 x:AccountPassword 로 본다).
|
||||
*
|
||||
* - 등록: 서버가 160비트 비밀을 만들어 10분 동안 메모리에만 둔다 (TwoFactorEnrollments). 이미 켜져 있으면 409 AUTH_008
|
||||
* - 등록: 서버가 160비트 비밀을 만들어 10분 동안 메모리에만 둔다 (TwoFactorEnrollments). 켜져 있어도 받는다 — 휴대폰 바꾸기용(16단계)
|
||||
* - 휴대폰 바꾸기(16단계): ① 새 휴대폰의 코드를 등록의 비밀로 확인해 등록에 "확인됨"을 단다(verifyEnrollment) ② 지금 비밀번호(본인 Basic 402) →
|
||||
* 지금 휴대폰의 코드와 새 otpauth 주소를 엔진 한 요청으로(replace — 2단계가 꺼지는 틈이 없다). 결과를 모르면 MAIL_001(다시 읽어도 가를 수 없다)
|
||||
* - 켜기: 받은 코드를 등록의 비밀로 **먼저** 확인한다(±1 단계 — 엔진은 등록 때 코드를 보지 않는다, 잘못 등록하면 직원이 끄지도 못한다).
|
||||
* 둘 다 틀렸으면 코드부터 알린다. 그다음 지금 비밀번호를 본인 Basic 으로 확인하고(틀리면 AUTH_005 — 로그인 실패 한도로 센다,
|
||||
* 402 면 그사이 다른 곳에서 켜짐 → AUTH_008) 대리 접근으로 엔진에 otpauth 주소를 넣는다.
|
||||
@@ -134,18 +137,13 @@ export class TwoFactorService {
|
||||
return this.reconcile(address, enabled);
|
||||
}
|
||||
|
||||
/** POST /api/two-factor/enrollments — 꺼져 있을 때만 */
|
||||
/**
|
||||
* POST /api/two-factor/enrollments — 꺼져 있으면 켜기용, 켜져 있으면 휴대폰 바꾸기용(16단계) — 같은 등록이다.
|
||||
* 엔진에 묻지 않는다: 켜기는 비밀번호 확인(402)이, 바꾸기는 엔진이 지금 코드로 가른다
|
||||
*/
|
||||
async startEnrollment(user: string): Promise<TwoFactorEnrollmentView> {
|
||||
const address = this.requireEmployee(user);
|
||||
return this.locks.run(address, async () => {
|
||||
if (await this.provider.isTwoFactorEnabled(address)) {
|
||||
throw new BusinessException('AUTH_008', {
|
||||
context: {
|
||||
reason: 'two-factor-already-enabled',
|
||||
email: maskEmail(address),
|
||||
},
|
||||
});
|
||||
}
|
||||
return this.locks.run(address, () => {
|
||||
const now = Date.now();
|
||||
const enrollment = {
|
||||
id: randomUUID(),
|
||||
@@ -154,15 +152,181 @@ export class TwoFactorService {
|
||||
};
|
||||
this.enrollments.put(address, enrollment);
|
||||
const secret = encodeBase32(enrollment.secret);
|
||||
return {
|
||||
return Promise.resolve({
|
||||
enrollmentId: enrollment.id,
|
||||
secret,
|
||||
otpauthUrl: this.otpauthUrl(address, secret),
|
||||
expiresAt: new Date(enrollment.expiresAt),
|
||||
};
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/two-factor/enrollments/{id}/verification — 새 휴대폰의 코드를 등록의 비밀로 확인하고 등록에 "확인됨"을 단다 (16단계 휴대폰 바꾸기 ①).
|
||||
* 엔진은 등록 때 코드를 보지 않으므로 서버가 먼저 본다. 코드 확인은 켜기 · 끄기와 같은 코드 가드(실패 한도 · 2분 다시 쓰기 막기 — 받은 코드는 기억한다).
|
||||
* 등록이 없음 · 10분 지남 · 더 새 등록이면 AUTH_008, 코드 틀림 AUTH_006. 엔진에 묻지 않는다
|
||||
*/
|
||||
async verifyEnrollment(
|
||||
user: string,
|
||||
input: { readonly enrollmentId: string; readonly otpCode: string },
|
||||
client: SecurityClient,
|
||||
): Promise<void> {
|
||||
const address = this.requireEmployee(user);
|
||||
const ip = client.ip || 'unknown';
|
||||
return this.locks.run(address, async () => {
|
||||
const enrollment = this.requireEnrollment(address, input.enrollmentId);
|
||||
await this.codes.check<undefined>(
|
||||
{ email: address, ip },
|
||||
input.otpCode,
|
||||
() =>
|
||||
Promise.resolve(
|
||||
verifyTotp(enrollment.secret, input.otpCode, Date.now()) === null
|
||||
? { kind: 'rejected' as const }
|
||||
: { kind: 'accepted' as const, value: undefined },
|
||||
),
|
||||
);
|
||||
if (!this.enrollments.markVerified(address, input.enrollmentId)) {
|
||||
// 코드를 보는 사이 10분이 지났다
|
||||
throw this.enrollmentMissing(address, 'two-factor-enrollment-expired');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/two-factor/replacements — 2단계 인증을 켠 채 휴대폰을 바꾼다 (16단계 ②). 확인한 등록 · 지금 휴대폰의 코드 · 지금 비밀번호.
|
||||
* 1. 등록이 확인됨이 아니면 AUTH_008(등록을 버린다 — 처음부터)
|
||||
* 2. 지금 비밀번호를 본인 Basic 으로 — 402 여야 한다(켜짐 · 비밀번호 맞음). 401 · 앱 비밀번호 모양은 AUTH_005(로그인 실패 한도로 센다 — 모양은 세지 않는다),
|
||||
* 200 이면 그사이 다른 곳에서 꺼졌다 → AUTH_008(등록을 버린다). 엔진은 비밀번호 틀림과 코드 틀림을 가르지 않아 비밀번호를 먼저 본다
|
||||
* 3. 다시 쓴 코드 · 코드 실패 차지(끄기와 같은 결) → 엔진 한 요청(지금 코드 + 새 otpauth 주소). forbidden 은 지금 코드 틀림 AUTH_006,
|
||||
* 결과를 모르면 MAIL_001(엔진은 otpUrl 을 가려 다시 읽어도 가를 수 없다 — 그 코드는 쓰지 않은 것으로, 등록은 남긴다)
|
||||
* 4. 바꾸면 등록의 비밀을 지운다. 켠 때(enabledAt)는 그대로. 로그인된 기기는 끊지 않는다(켜기 · 끄기와 같다)
|
||||
*/
|
||||
async replace(
|
||||
user: string,
|
||||
input: {
|
||||
readonly enrollmentId: string;
|
||||
readonly currentOtpCode: string;
|
||||
readonly currentPassword: string;
|
||||
},
|
||||
client: SecurityClient,
|
||||
): Promise<TwoFactorStatus> {
|
||||
const address = this.requireEmployee(user);
|
||||
const ip = client.ip || 'unknown';
|
||||
return this.locks.run(address, async () => {
|
||||
const enrollment = this.requireEnrollment(address, input.enrollmentId);
|
||||
if (enrollment.verified !== true) {
|
||||
this.enrollments.forget(address);
|
||||
throw this.enrollmentMissing(
|
||||
address,
|
||||
'two-factor-enrollment-unverified',
|
||||
);
|
||||
}
|
||||
if (isAppPasswordShape(input.currentPassword)) {
|
||||
throw this.currentPasswordRejected(address, 'app-password-shape');
|
||||
}
|
||||
const otpauthUrl = this.otpauthUrl(
|
||||
address,
|
||||
encodeBase32(enrollment.secret),
|
||||
);
|
||||
|
||||
const ticket = this.attempts.begin({ email: address, ip });
|
||||
try {
|
||||
const check = await this.mail.verifyCredentials(
|
||||
address,
|
||||
input.currentPassword,
|
||||
);
|
||||
if (check.kind === 'invalid') {
|
||||
ticket.fail();
|
||||
throw this.currentPasswordRejected(
|
||||
address,
|
||||
'current-password-rejected',
|
||||
);
|
||||
}
|
||||
if (check.kind === 'valid') {
|
||||
// 엔진에서 그사이 꺼졌다 — 바꿀 것이 없다. 처음부터(켜기)
|
||||
ticket.succeed();
|
||||
this.enrollments.forget(address);
|
||||
throw new BusinessException('AUTH_008', {
|
||||
context: {
|
||||
reason: 'two-factor-disabled-elsewhere',
|
||||
email: maskEmail(address),
|
||||
},
|
||||
});
|
||||
}
|
||||
// 402 — 켜져 있고 비밀번호가 맞다. 코드 단계가 끝날 때까지 같은 주소의 다른 시도는 처리 중이다
|
||||
ticket.verified();
|
||||
|
||||
await this.codes.check<undefined>(
|
||||
{ email: address, ip },
|
||||
input.currentOtpCode,
|
||||
async () => {
|
||||
const outcome = await this.provider.replaceTwoFactor(
|
||||
address,
|
||||
input.currentPassword,
|
||||
input.currentOtpCode,
|
||||
otpauthUrl,
|
||||
);
|
||||
if (outcome === 'code-rejected')
|
||||
return { kind: 'rejected' as const };
|
||||
if (outcome === 'unknown') {
|
||||
throw new BusinessException('MAIL_001', {
|
||||
context: {
|
||||
reason: 'two-factor-replace-outcome-unknown',
|
||||
email: maskEmail(address),
|
||||
},
|
||||
});
|
||||
}
|
||||
return { kind: 'accepted' as const, value: undefined };
|
||||
},
|
||||
);
|
||||
ticket.succeed();
|
||||
} finally {
|
||||
ticket.abort();
|
||||
}
|
||||
|
||||
// 엔진에서 바뀌었다 — 등록의 비밀을 지운다
|
||||
this.enrollments.forget(address);
|
||||
this.logger.log(`2단계 인증 휴대폰을 바꿨습니다: ${maskEmail(address)}`);
|
||||
return { isEnabled: true, enabledAt: await this.readEnabledAt(address) };
|
||||
});
|
||||
}
|
||||
|
||||
/** 기다리는 등록 — 없음 · 10분 지남 · 더 새 등록이면 AUTH_008 */
|
||||
private requireEnrollment(
|
||||
address: string,
|
||||
enrollmentId: string,
|
||||
): TwoFactorEnrollment {
|
||||
const enrollment = this.enrollments.find(address, enrollmentId);
|
||||
if (!enrollment) {
|
||||
throw this.enrollmentMissing(address, 'two-factor-enrollment-missing');
|
||||
}
|
||||
return enrollment;
|
||||
}
|
||||
|
||||
private enrollmentMissing(
|
||||
address: string,
|
||||
reason: string,
|
||||
): BusinessException {
|
||||
return new BusinessException('AUTH_008', {
|
||||
context: { reason, email: maskEmail(address) },
|
||||
});
|
||||
}
|
||||
|
||||
/** 켠 때 기록 — 읽지 못하면 error 로그만 남기고 null (엔진에서는 이미 바뀌었다) */
|
||||
private async readEnabledAt(address: string): Promise<Date | null> {
|
||||
try {
|
||||
return (
|
||||
(await this.settings.findOneBy({ userEmail: address }))?.enabledAt ??
|
||||
null
|
||||
);
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
`[SYS_001]${traceTag()} 2단계 인증 휴대폰을 바꿨지만 켠 때 기록을 읽지 못했습니다(엔진에서는 바뀌었다): user=${maskEmail(address)}, reason=${errorMessage(error)}`,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** POST /api/two-factor/activation */
|
||||
async activate(
|
||||
user: string,
|
||||
|
||||
@@ -320,12 +320,14 @@ export function weightAfterChanges(
|
||||
export interface WrittenChanges {
|
||||
readonly createdIds: ReadonlyMap<string, string>;
|
||||
readonly cards: ReadonlyMap<string, AddressBookCard>;
|
||||
/** 엔진이 받지 않은 만들기의 key — 가져오기(일부만 받음)에만 있다. 옮기지 않는다 */
|
||||
readonly refusedKeys?: ReadonlySet<string>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 엔진이 받은 바꾸기를 기억한 주소록에 옮긴다 — 판이 같을 때만 쓰므로(ifInState) 엔진의 새 내용과 같다.
|
||||
* 연락처의 고치기 · 만든 카드는 어댑터가 준 모양(칸 안의 남의 정보와 합친 결과)을, 그룹 고치기는 보낸 이름 · 구성원을 쓴다.
|
||||
* 만든 카드의 id 나 모양이 하나라도 없으면 null (옮길 수 없다 — 다시 읽는다)
|
||||
* 엔진이 받지 않은 만들기(refusedKeys)는 건너뛴다. 그 밖의 만든 카드의 id 나 모양이 하나라도 없으면 null (옮길 수 없다 — 다시 읽는다)
|
||||
*/
|
||||
export function applyAddressBookWrite(
|
||||
snapshot: AddressBookSnapshot,
|
||||
@@ -352,6 +354,7 @@ export function applyAddressBookWrite(
|
||||
.map((card) => applyGroupUpdate(card, updates.get(card.id)));
|
||||
|
||||
for (const card of changes.create) {
|
||||
if (written.refusedKeys?.has(card.key)) continue;
|
||||
const id = written.createdIds.get(card.key);
|
||||
const read = id === undefined ? undefined : written.cards.get(id);
|
||||
if (read === undefined) return null;
|
||||
|
||||
@@ -0,0 +1,291 @@
|
||||
import {
|
||||
contactExportContentType,
|
||||
contactExportFileName,
|
||||
contactsToCsv,
|
||||
contactsToVcard,
|
||||
CSV_HEADER,
|
||||
foldVcardLine,
|
||||
type ExportedContact,
|
||||
} from './contact-export';
|
||||
|
||||
const contact = (
|
||||
overrides: Partial<ExportedContact> = {},
|
||||
): ExportedContact => ({
|
||||
uid: 'urn:uuid:3f1c0a2e-0f1b-4f2a-9a1e-6c9f0a1b2c3d',
|
||||
name: '박지훈',
|
||||
emails: [{ address: 'jihoon.park@hanbit.example', label: 'work' }],
|
||||
phones: [{ number: '010-3192-5540', label: 'mobile' }],
|
||||
company: '한빛상사',
|
||||
department: '구매팀',
|
||||
title: '과장',
|
||||
note: '견적 담당',
|
||||
groupNames: ['거래처'],
|
||||
...overrides,
|
||||
});
|
||||
|
||||
/** 접힌 줄을 푼다 (CRLF + 공백 하나를 지운다 — RFC 2425 5.8.1) */
|
||||
const unfold = (text: string) => text.replace(/\r\n /g, '');
|
||||
|
||||
describe('내보내기 — vCard 3.0', () => {
|
||||
it('한 사람 — 줄 끝 CRLF, VERSION 은 BEGIN 바로 다음, 우리 칸만 (계약 3절 "내보내기")', () => {
|
||||
expect(contactsToVcard([contact()])).toBe(
|
||||
[
|
||||
'BEGIN:VCARD',
|
||||
'VERSION:3.0',
|
||||
'UID:urn:uuid:3f1c0a2e-0f1b-4f2a-9a1e-6c9f0a1b2c3d',
|
||||
'FN:박지훈',
|
||||
'N:박지훈;;;;',
|
||||
'EMAIL;TYPE=INTERNET,WORK:jihoon.park@hanbit.example',
|
||||
'TEL;TYPE=CELL:010-3192-5540',
|
||||
'ORG:한빛상사;구매팀',
|
||||
'TITLE:과장',
|
||||
'NOTE:견적 담당',
|
||||
'CATEGORIES:거래처',
|
||||
'END:VCARD',
|
||||
'',
|
||||
].join('\r\n'),
|
||||
);
|
||||
});
|
||||
|
||||
it('종류 — 이메일 WORK · HOME · INTERNET, 전화 CELL · WORK · HOME · VOICE', () => {
|
||||
const text = contactsToVcard([
|
||||
contact({
|
||||
emails: [
|
||||
{ address: 'a@x.example', label: 'work' },
|
||||
{ address: 'b@x.example', label: 'personal' },
|
||||
{ address: 'c@x.example', label: 'other' },
|
||||
],
|
||||
phones: [
|
||||
{ number: '1', label: 'mobile' },
|
||||
{ number: '2', label: 'work' },
|
||||
{ number: '3', label: 'home' },
|
||||
{ number: '4', label: 'other' },
|
||||
],
|
||||
}),
|
||||
]);
|
||||
expect(text).toContain('\r\nEMAIL;TYPE=INTERNET,WORK:a@x.example\r\n');
|
||||
expect(text).toContain('\r\nEMAIL;TYPE=INTERNET,HOME:b@x.example\r\n');
|
||||
expect(text).toContain('\r\nEMAIL;TYPE=INTERNET:c@x.example\r\n');
|
||||
expect(text).toContain(
|
||||
'\r\nTEL;TYPE=CELL:1\r\nTEL;TYPE=WORK:2\r\nTEL;TYPE=HOME:3\r\nTEL;TYPE=VOICE:4\r\n',
|
||||
);
|
||||
});
|
||||
|
||||
it('이스케이프 — \\ ; , 줄바꿈 (그룹 이름 안의 쉼표도)', () => {
|
||||
const text = contactsToVcard([
|
||||
contact({
|
||||
name: '김; 아라, 팀장',
|
||||
company: 'A;B',
|
||||
department: null,
|
||||
note: '첫 줄\n둘째 줄 C:\\temp',
|
||||
groupNames: ['가,나', '다'],
|
||||
}),
|
||||
]);
|
||||
expect(text).toContain('\r\nFN:김\\; 아라\\, 팀장\r\n');
|
||||
expect(text).toContain('\r\nN:김\\; 아라\\, 팀장;;;;\r\n');
|
||||
expect(text).toContain('\r\nORG:A\\;B;\r\n');
|
||||
expect(text).toContain('\r\nNOTE:첫 줄\\n둘째 줄 C:\\\\temp\r\n');
|
||||
expect(text).toContain('\r\nCATEGORIES:가\\,나,다\r\n');
|
||||
});
|
||||
|
||||
it('빈 칸 — 이름이 없으면 FN 은 첫 이메일(N 은 비운다), 없는 칸 · uid 는 줄을 넣지 않는다, 회사가 없고 부서만 있으면 ORG:;부서', () => {
|
||||
const text = contactsToVcard([
|
||||
contact({
|
||||
uid: null,
|
||||
name: null,
|
||||
emails: [
|
||||
{ address: 'first@x.example', label: 'work' },
|
||||
{ address: 'second@x.example', label: 'other' },
|
||||
],
|
||||
phones: [{ number: '010-3192-5540', label: 'mobile' }],
|
||||
company: null,
|
||||
department: '구매팀',
|
||||
title: null,
|
||||
note: null,
|
||||
groupNames: [],
|
||||
}),
|
||||
]);
|
||||
expect(text).toBe(
|
||||
[
|
||||
'BEGIN:VCARD',
|
||||
'VERSION:3.0',
|
||||
'FN:first@x.example',
|
||||
'N:;;;;',
|
||||
'EMAIL;TYPE=INTERNET,WORK:first@x.example',
|
||||
'EMAIL;TYPE=INTERNET:second@x.example',
|
||||
'TEL;TYPE=CELL:010-3192-5540',
|
||||
'ORG:;구매팀',
|
||||
'END:VCARD',
|
||||
'',
|
||||
].join('\r\n'),
|
||||
);
|
||||
});
|
||||
|
||||
it('이름 · 이메일이 없으면 FN 은 첫 전화번호, 그것도 없으면 빈 FN (N 은 늘 이름 칸 그대로)', () => {
|
||||
const phoneOnly = contactsToVcard([
|
||||
contact({
|
||||
name: null,
|
||||
emails: [],
|
||||
phones: [
|
||||
{ number: '02-555-0100', label: 'work' },
|
||||
{ number: '010-3192-5540', label: 'mobile' },
|
||||
],
|
||||
}),
|
||||
]);
|
||||
expect(phoneOnly).toContain('\r\nFN:02-555-0100\r\nN:;;;;\r\n');
|
||||
|
||||
const nothing = contactsToVcard([
|
||||
contact({ name: null, emails: [], phones: [] }),
|
||||
]);
|
||||
expect(nothing).toContain('\r\nFN:\r\nN:;;;;\r\n');
|
||||
// 이름이 있으면 이름 — 주소 · 번호로 채우지 않는다
|
||||
expect(contactsToVcard([contact()])).toContain('\r\nFN:박지훈\r\n');
|
||||
});
|
||||
|
||||
it('줄 접기 — 75바이트마다(이어지는 줄은 공백 하나로 시작해 74바이트), 한글(3바이트)을 가르지 않는다', () => {
|
||||
const line = `NOTE:${'가'.repeat(60)}`;
|
||||
const folded = foldVcardLine(line);
|
||||
const lines = folded.split('\r\n');
|
||||
expect(lines.length).toBeGreaterThan(1);
|
||||
for (const [index, part] of lines.entries()) {
|
||||
expect(Buffer.byteLength(part, 'utf8')).toBeLessThanOrEqual(75);
|
||||
if (index > 0) expect(part.startsWith(' ')).toBe(true);
|
||||
// 가른 곳이 글자 사이다 — 바이트로 바꿔도 깨진 글자(U+FFFD)가 없다
|
||||
expect(Buffer.from(part, 'utf8').toString('utf8')).not.toContain(
|
||||
'\uFFFD',
|
||||
);
|
||||
}
|
||||
expect(unfold(folded)).toBe(line);
|
||||
// 75바이트 이하는 그대로
|
||||
expect(foldVcardLine('A'.repeat(75))).toBe('A'.repeat(75));
|
||||
expect(foldVcardLine('A'.repeat(76))).toBe(`${'A'.repeat(75)}\r\n A`);
|
||||
// 이모지(4바이트 · 서로게이트 쌍)도 가르지 않는다
|
||||
const emoji = foldVcardLine(`FN:${'😀'.repeat(30)}`);
|
||||
expect(unfold(emoji)).toBe(`FN:${'😀'.repeat(30)}`);
|
||||
for (const part of emoji.split('\r\n')) {
|
||||
expect(Buffer.byteLength(part, 'utf8')).toBeLessThanOrEqual(75);
|
||||
expect(/[\uD800-\uDBFF]$/.test(part)).toBe(false);
|
||||
expect(/^ ?[\uDC00-\uDFFF]/.test(part)).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it('여러 명은 한 파일에 이어 붙인다 · 연락처가 없으면 빈 파일', () => {
|
||||
const text = contactsToVcard([contact(), contact({ name: '김아라' })]);
|
||||
expect(text.match(/BEGIN:VCARD/g)).toHaveLength(2);
|
||||
expect(text.endsWith('END:VCARD\r\n')).toBe(true);
|
||||
expect(contactsToVcard([])).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('내보내기 — CSV', () => {
|
||||
it('UTF-8 BOM · CRLF · 한국어 머리 — 같은 칸 여럿은 "; ", 이메일은 종류 없이 모두', () => {
|
||||
const text = contactsToCsv([
|
||||
contact({
|
||||
emails: [
|
||||
{ address: 'a@x.example', label: 'work' },
|
||||
{ address: 'b@x.example', label: 'personal' },
|
||||
],
|
||||
phones: [
|
||||
{ number: '010-1111-2222', label: 'mobile' },
|
||||
{ number: '010-3333-4444', label: 'mobile' },
|
||||
{ number: '02-555-0100', label: 'work' },
|
||||
{ number: '031-000-0000', label: 'home' },
|
||||
{ number: '070-1234-5678', label: 'other' },
|
||||
],
|
||||
groupNames: ['거래처', '협력사'],
|
||||
}),
|
||||
]);
|
||||
expect(text.startsWith('\uFEFF')).toBe(true);
|
||||
expect(text).toBe(
|
||||
'\uFEFF' +
|
||||
'이름,이메일,휴대폰,회사 전화,집 전화,기타 전화,회사,부서,직함,메모,그룹\r\n' +
|
||||
'박지훈,a@x.example; b@x.example,010-1111-2222; 010-3333-4444,02-555-0100,031-000-0000,070-1234-5678,한빛상사,구매팀,과장,견적 담당,거래처; 협력사\r\n',
|
||||
);
|
||||
expect(CSV_HEADER).toHaveLength(11);
|
||||
});
|
||||
|
||||
it("= + - @ 로 시작하는 칸은 앞에 ' (엑셀 수식 막기) · 쉼표 · 따옴표 · 줄바꿈은 따옴표로 감싼다", () => {
|
||||
const text = contactsToCsv([
|
||||
contact({
|
||||
name: '=HYPERLINK("http://x")',
|
||||
emails: [],
|
||||
phones: [{ number: '+82 10-1234-5678', label: 'mobile' }],
|
||||
company: '-주식회사',
|
||||
department: '@영업',
|
||||
title: '과장, 팀장',
|
||||
note: '첫 줄\n둘째 줄',
|
||||
groupNames: [],
|
||||
}),
|
||||
]);
|
||||
const row = text.split('\r\n')[1];
|
||||
expect(row).toBe(
|
||||
`"'=HYPERLINK(""http://x"")",,'+82 10-1234-5678,,,,'-주식회사,'@영업,"과장, 팀장","첫 줄\n둘째 줄",`,
|
||||
);
|
||||
});
|
||||
|
||||
it("탭 · CR 로 시작하는 칸도 앞에 ' (OWASP CSV Injection) — CR 이 든 칸은 따옴표로 감싼다", () => {
|
||||
const text = contactsToCsv([
|
||||
contact({
|
||||
name: '\t=1+1',
|
||||
emails: [],
|
||||
phones: [],
|
||||
company: '\r=1+1',
|
||||
department: null,
|
||||
title: '가\t나',
|
||||
note: null,
|
||||
groupNames: [],
|
||||
}),
|
||||
]);
|
||||
// 가운데의 탭은 그대로 — 첫 글자만 본다
|
||||
expect(text.slice(text.indexOf('\r\n') + 2)).toBe(
|
||||
`'\t=1+1,,,,,,"'\r=1+1",,가\t나,,\r\n`,
|
||||
);
|
||||
});
|
||||
|
||||
it('빈 칸은 비운다 · 연락처가 없으면 머리 줄만', () => {
|
||||
const text = contactsToCsv([
|
||||
contact({
|
||||
name: null,
|
||||
phones: [],
|
||||
company: null,
|
||||
department: null,
|
||||
title: null,
|
||||
note: null,
|
||||
groupNames: [],
|
||||
}),
|
||||
]);
|
||||
expect(text.split('\r\n')[1]).toBe(',jihoon.park@hanbit.example,,,,,,,,,');
|
||||
expect(contactsToCsv([])).toBe(`\uFEFF${CSV_HEADER.join(',')}\r\n`);
|
||||
});
|
||||
});
|
||||
|
||||
describe('내보내기 — 파일 이름 · 형식', () => {
|
||||
// 2026-11-10 15:30 UTC = 2026-11-11 00:30 KST
|
||||
const at = new Date('2026-11-10T15:30:00.000Z');
|
||||
|
||||
it('주소록-YYYY-MM-DD(한국 날짜) · 그룹이면 주소록-<그룹 이름>-YYYY-MM-DD', () => {
|
||||
expect(contactExportFileName('vcard', null, at)).toBe(
|
||||
'주소록-2026-11-11.vcf',
|
||||
);
|
||||
expect(contactExportFileName('csv', '거래처', at)).toBe(
|
||||
'주소록-거래처-2026-11-11.csv',
|
||||
);
|
||||
});
|
||||
|
||||
it('그룹 이름의 예약 글자는 _ · 앞뒤 공백과 점은 뗀다 · 남는 것이 없으면 그룹 칸을 빼고 · 50글자까지', () => {
|
||||
expect(contactExportFileName('csv', ' 팀/파트:A* ', at)).toBe(
|
||||
'주소록-팀_파트_A_-2026-11-11.csv',
|
||||
);
|
||||
expect(contactExportFileName('csv', ' .. ', at)).toBe(
|
||||
'주소록-2026-11-11.csv',
|
||||
);
|
||||
expect(contactExportFileName('vcard', '가'.repeat(80), at)).toBe(
|
||||
`주소록-${'가'.repeat(50)}-2026-11-11.vcf`,
|
||||
);
|
||||
});
|
||||
|
||||
it('Content-Type — text/vcard · text/csv (UTF-8)', () => {
|
||||
expect(contactExportContentType('vcard')).toBe('text/vcard; charset=utf-8');
|
||||
expect(contactExportContentType('csv')).toBe('text/csv; charset=utf-8');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,267 @@
|
||||
import { kstDateString } from '@/shared/utils/kst-date';
|
||||
import { MAX_CONTACT_GROUP_NAME_LENGTH } from './contacts.constants';
|
||||
import type {
|
||||
ContactEmailLabel,
|
||||
ContactExportFormat,
|
||||
ContactFields,
|
||||
ContactPhoneLabel,
|
||||
} from './contacts.types';
|
||||
|
||||
/**
|
||||
* 주소록 내보내기 파일 (순수 함수) — 계약 3절 "내보내기" (16단계). 우리 칸만 쓴다(엔진 카드의 다른 칸은 읽지 않는다).
|
||||
*
|
||||
* vCard 3.0 (RFC 2426): 줄 끝 CRLF · 한 줄 75바이트에서 접는다(UTF-8 글자를 가르지 않게 — 이어지는 줄은 공백 하나로 시작) ·
|
||||
* 글 값은 `\` `;` `,` 줄바꿈을 이스케이프. FN(이름 — 없으면 첫 이메일 → 첫 전화번호) · N(이름 전체를 성 칸에) · EMAIL · TEL · ORG(회사;부서) ·
|
||||
* TITLE · NOTE · CATEGORIES(그룹 이름) · UID.
|
||||
*
|
||||
* CSV: UTF-8 BOM · CRLF · 한국어 머리(아래 CSV_HEADER) — 이메일은 종류 없이 모두, 같은 칸에 여럿이면 `; ` 로 잇는다.
|
||||
* 칸이 `= + - @` · 탭 · CR 로 시작하면 앞에 `'` 를 붙인다(엑셀 수식 막기 — OWASP CSV Injection). 쉼표 · 따옴표 · 줄바꿈이 든 칸은 따옴표로 감싼다(RFC 4180).
|
||||
* 우리 가져오기(화면)가 이 두 모양을 다시 받는다 — 머리 이름 · 차례를 바꾸면 frontend shared/utils/contact-import 도 함께
|
||||
*/
|
||||
|
||||
/** 내보낼 연락처 하나 — 우리 칸 + 카드의 uid · 속한 그룹 이름(그룹 이름 차례) */
|
||||
export interface ExportedContact extends ContactFields {
|
||||
readonly uid: string | null;
|
||||
readonly groupNames: readonly string[];
|
||||
}
|
||||
|
||||
export const VCARD_CONTENT_TYPE = 'text/vcard; charset=utf-8';
|
||||
export const CSV_CONTENT_TYPE = 'text/csv; charset=utf-8';
|
||||
|
||||
const CRLF = '\r\n';
|
||||
/** vCard 한 줄의 바이트 상한 (줄 끝 제외 — 이어지는 줄은 앞의 공백까지 센다) */
|
||||
const VCARD_LINE_OCTETS = 75;
|
||||
|
||||
const VCARD_EMAIL_TYPES: Record<ContactEmailLabel, string> = {
|
||||
work: 'INTERNET,WORK',
|
||||
personal: 'INTERNET,HOME',
|
||||
other: 'INTERNET',
|
||||
};
|
||||
|
||||
const VCARD_PHONE_TYPES: Record<ContactPhoneLabel, string> = {
|
||||
mobile: 'CELL',
|
||||
work: 'WORK',
|
||||
home: 'HOME',
|
||||
other: 'VOICE',
|
||||
};
|
||||
|
||||
/** CSV 머리 — 칸 차례 (계약 3절 "내보내기") */
|
||||
export const CSV_HEADER = [
|
||||
'이름',
|
||||
'이메일',
|
||||
'휴대폰',
|
||||
'회사 전화',
|
||||
'집 전화',
|
||||
'기타 전화',
|
||||
'회사',
|
||||
'부서',
|
||||
'직함',
|
||||
'메모',
|
||||
'그룹',
|
||||
] as const;
|
||||
|
||||
/** 같은 칸에 여럿을 잇는 글 */
|
||||
const CSV_JOINER = '; ';
|
||||
/** 엑셀이 수식으로 읽는 첫 글자(OWASP CSV Injection — `= + - @` · 탭 · CR) — 앞에 `'` 를 붙인다 */
|
||||
const CSV_FORMULA_START = /^[=+\-@\t\r]/;
|
||||
/** 따옴표로 감쌀 칸 */
|
||||
const CSV_NEEDS_QUOTES = /[",\r\n]/;
|
||||
/** UTF-8 BOM(U+FEFF) — 엑셀이 한글을 UTF-8 로 읽게 (소스에 보이지 않는 글자를 두지 않는다) */
|
||||
const UTF8_BOM = String.fromCharCode(0xfeff);
|
||||
|
||||
/** 파일 이름 앞머리 · 형식마다 확장자 */
|
||||
const FILE_NAME_PREFIX = '주소록';
|
||||
const FILE_EXTENSIONS: Record<ContactExportFormat, string> = {
|
||||
vcard: '.vcf',
|
||||
csv: '.csv',
|
||||
};
|
||||
/** 파일 이름에 쓸 수 없는 글자(경로 구분자 · Windows 예약 글자) — '_' 로 */
|
||||
const FILE_NAME_RESERVED = /[\\/:*?"<>|]/g;
|
||||
/** 그룹 이름 앞뒤에서 떼는 글자 — 공백과 점 (앞의 점은 숨김 파일, Windows 는 끝의 점을 지운다) */
|
||||
const FILE_NAME_EDGE = /^[\s.]$/u;
|
||||
|
||||
/** vCard 글 값의 이스케이프 — `\` `;` `,` 줄바꿈(\n) (한 번 훑는다) */
|
||||
function escapeVcardText(value: string): string {
|
||||
return value.replace(/[\\;,]|\r\n|\r|\n/g, (match) => {
|
||||
if (match === '\\') return '\\\\';
|
||||
if (match === ';') return '\\;';
|
||||
if (match === ',') return '\\,';
|
||||
return '\\n';
|
||||
});
|
||||
}
|
||||
|
||||
/** UTF-8 바이트 수 — 코드 포인트 하나 */
|
||||
function utf8Length(codePoint: number): number {
|
||||
if (codePoint < 0x80) return 1;
|
||||
if (codePoint < 0x800) return 2;
|
||||
if (codePoint < 0x10000) return 3;
|
||||
return 4;
|
||||
}
|
||||
|
||||
/** 한 줄을 75바이트마다 접는다 — 글자(코드 포인트)를 가르지 않는다. 이어지는 줄은 공백 하나로 시작한다 */
|
||||
export function foldVcardLine(line: string): string {
|
||||
const parts: string[] = [];
|
||||
let current = '';
|
||||
let bytes = 0;
|
||||
let limit = VCARD_LINE_OCTETS;
|
||||
for (const char of line) {
|
||||
const size = utf8Length(char.codePointAt(0) ?? 0);
|
||||
if (bytes + size > limit) {
|
||||
parts.push(current);
|
||||
current = '';
|
||||
bytes = 0;
|
||||
// 이어지는 줄 앞의 공백 하나도 75바이트에 든다
|
||||
limit = VCARD_LINE_OCTETS - 1;
|
||||
}
|
||||
current += char;
|
||||
bytes += size;
|
||||
}
|
||||
parts.push(current);
|
||||
return parts.join(`${CRLF} `);
|
||||
}
|
||||
|
||||
/**
|
||||
* 보이는 이름(FN) — 이름, 없으면 첫 이메일, 그것도 없으면 첫 전화번호(vCard 3.0 은 FN 을 꼭 담는다 — 빈 FN 은 받는 쪽이 "이름 없음"으로 보인다).
|
||||
* N 은 이름 칸 그대로 둔다(이름이 없으면 빈 칸 — 다시 가져올 때 주소를 이름으로 읽지 않게)
|
||||
*/
|
||||
function displayNameOf(contact: ExportedContact): string {
|
||||
return (
|
||||
contact.name ??
|
||||
contact.emails[0]?.address ??
|
||||
contact.phones[0]?.number ??
|
||||
''
|
||||
);
|
||||
}
|
||||
|
||||
/** 연락처 하나의 vCard 3.0 */
|
||||
function vcardOf(contact: ExportedContact): string {
|
||||
const name = contact.name ?? '';
|
||||
const lines = ['BEGIN:VCARD', 'VERSION:3.0'];
|
||||
if (contact.uid !== null) lines.push(`UID:${escapeVcardText(contact.uid)}`);
|
||||
lines.push(`FN:${escapeVcardText(displayNameOf(contact))}`);
|
||||
lines.push(`N:${escapeVcardText(name)};;;;`);
|
||||
for (const email of contact.emails) {
|
||||
lines.push(
|
||||
`EMAIL;TYPE=${VCARD_EMAIL_TYPES[email.label]}:${escapeVcardText(email.address)}`,
|
||||
);
|
||||
}
|
||||
for (const phone of contact.phones) {
|
||||
lines.push(
|
||||
`TEL;TYPE=${VCARD_PHONE_TYPES[phone.label]}:${escapeVcardText(phone.number)}`,
|
||||
);
|
||||
}
|
||||
if (contact.company !== null || contact.department !== null) {
|
||||
lines.push(
|
||||
`ORG:${escapeVcardText(contact.company ?? '')};${escapeVcardText(contact.department ?? '')}`,
|
||||
);
|
||||
}
|
||||
if (contact.title !== null) {
|
||||
lines.push(`TITLE:${escapeVcardText(contact.title)}`);
|
||||
}
|
||||
if (contact.note !== null) {
|
||||
lines.push(`NOTE:${escapeVcardText(contact.note)}`);
|
||||
}
|
||||
if (contact.groupNames.length > 0) {
|
||||
lines.push(
|
||||
`CATEGORIES:${contact.groupNames.map(escapeVcardText).join(',')}`,
|
||||
);
|
||||
}
|
||||
lines.push('END:VCARD');
|
||||
return lines.map((line) => `${foldVcardLine(line)}${CRLF}`).join('');
|
||||
}
|
||||
|
||||
/** vCard 3.0 파일 — 한 파일에 여러 장 (연락처가 없으면 빈 파일) */
|
||||
export function contactsToVcard(contacts: readonly ExportedContact[]): string {
|
||||
return contacts.map(vcardOf).join('');
|
||||
}
|
||||
|
||||
/** CSV 한 칸 — 수식 막기 · 필요하면 따옴표 */
|
||||
function csvCell(value: string): string {
|
||||
const guarded = CSV_FORMULA_START.test(value) ? `'${value}` : value;
|
||||
return CSV_NEEDS_QUOTES.test(guarded)
|
||||
? `"${guarded.replace(/"/g, '""')}"`
|
||||
: guarded;
|
||||
}
|
||||
|
||||
/** 그 종류의 전화번호들 — 한 칸 */
|
||||
function phonesOf(contact: ExportedContact, label: ContactPhoneLabel): string {
|
||||
return contact.phones
|
||||
.filter((phone) => phone.label === label)
|
||||
.map((phone) => phone.number)
|
||||
.join(CSV_JOINER);
|
||||
}
|
||||
|
||||
function csvRow(contact: ExportedContact): string {
|
||||
return [
|
||||
contact.name ?? '',
|
||||
contact.emails.map((email) => email.address).join(CSV_JOINER),
|
||||
phonesOf(contact, 'mobile'),
|
||||
phonesOf(contact, 'work'),
|
||||
phonesOf(contact, 'home'),
|
||||
phonesOf(contact, 'other'),
|
||||
contact.company ?? '',
|
||||
contact.department ?? '',
|
||||
contact.title ?? '',
|
||||
contact.note ?? '',
|
||||
contact.groupNames.join(CSV_JOINER),
|
||||
]
|
||||
.map(csvCell)
|
||||
.join(',');
|
||||
}
|
||||
|
||||
/** CSV 파일 — BOM + 머리 줄 + 연락처마다 한 줄 (연락처가 없으면 머리 줄만) */
|
||||
export function contactsToCsv(contacts: readonly ExportedContact[]): string {
|
||||
const rows = [CSV_HEADER.join(','), ...contacts.map(csvRow)];
|
||||
return `${UTF8_BOM}${rows.map((row) => `${row}${CRLF}`).join('')}`;
|
||||
}
|
||||
|
||||
/** 앞뒤의 공백 · 점을 뗀다 — 반복 정규식 없이 한 번 훑는다(길이에 비례) */
|
||||
function trimEdges(chars: readonly string[]): string[] {
|
||||
let start = 0;
|
||||
let end = chars.length;
|
||||
while (start < end && FILE_NAME_EDGE.test(chars[start])) start += 1;
|
||||
while (end > start && FILE_NAME_EDGE.test(chars[end - 1])) end -= 1;
|
||||
return chars.slice(start, end);
|
||||
}
|
||||
|
||||
/** 파일 이름의 그룹 이름 — 예약 글자는 '_', 앞뒤 공백 · 점은 떼고 50글자(코드 포인트)까지. 남는 것이 없으면 null */
|
||||
function groupNamePart(name: string): string | null {
|
||||
const chars = trimEdges(Array.from(name.replace(FILE_NAME_RESERVED, '_')));
|
||||
const cleaned = trimEdges(chars.slice(0, MAX_CONTACT_GROUP_NAME_LENGTH)).join(
|
||||
'',
|
||||
);
|
||||
return cleaned === '' ? null : cleaned;
|
||||
}
|
||||
|
||||
/**
|
||||
* 내려받을 파일 이름 — `주소록-YYYY-MM-DD.vcf` · `주소록-<그룹 이름>-YYYY-MM-DD.csv` (날짜는 한국 날짜).
|
||||
* 헤더 인코딩은 shared/utils/content-disposition.ts 가 한다
|
||||
*/
|
||||
export function contactExportFileName(
|
||||
format: ContactExportFormat,
|
||||
groupName: string | null,
|
||||
at: Date,
|
||||
): string {
|
||||
const group = groupName === null ? null : groupNamePart(groupName);
|
||||
const parts = [
|
||||
FILE_NAME_PREFIX,
|
||||
...(group ? [group] : []),
|
||||
kstDateString(at),
|
||||
];
|
||||
return `${parts.join('-')}${FILE_EXTENSIONS[format]}`;
|
||||
}
|
||||
|
||||
/** 형식마다 Content-Type */
|
||||
export function contactExportContentType(format: ContactExportFormat): string {
|
||||
return format === 'vcard' ? VCARD_CONTENT_TYPE : CSV_CONTENT_TYPE;
|
||||
}
|
||||
|
||||
/** 형식마다 파일 내용 */
|
||||
export function contactExportContent(
|
||||
format: ContactExportFormat,
|
||||
contacts: readonly ExportedContact[],
|
||||
): string {
|
||||
return format === 'vcard'
|
||||
? contactsToVcard(contacts)
|
||||
: contactsToCsv(contacts);
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
HttpStatus,
|
||||
Query,
|
||||
Res,
|
||||
StreamableFile,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import {
|
||||
ApiCookieAuth,
|
||||
ApiOperation,
|
||||
ApiProduces,
|
||||
ApiResponse,
|
||||
ApiTags,
|
||||
} from '@nestjs/swagger';
|
||||
import type { Response } from 'express';
|
||||
|
||||
import {
|
||||
ApiErrorResponse,
|
||||
ApiSessionErrors,
|
||||
} from '@/common/decorators/api-standard-response.decorator';
|
||||
import {
|
||||
CurrentUser,
|
||||
type AuthenticatedUser,
|
||||
} from '@/common/decorators/current-user.decorator';
|
||||
import { SkipTransform } from '@/common/decorators/skip-transform.decorator';
|
||||
import { JwtAuthGuard } from '@/common/guards/jwt-auth.guard';
|
||||
import { buildAttachmentDisposition } from '@/shared/utils/content-disposition';
|
||||
import {
|
||||
DOWNLOAD_CACHE_CONTROL,
|
||||
DOWNLOAD_CSP,
|
||||
} from '@/shared/utils/download-headers';
|
||||
import { CSV_CONTENT_TYPE, VCARD_CONTENT_TYPE } from './contact-export';
|
||||
import { CONTACT_EXPORTS_ROUTE } from './contacts.constants';
|
||||
import { ContactsService } from './contacts.service';
|
||||
import { ContactExportQueryDto } from './dto/contact-export.dto';
|
||||
|
||||
// 내 주소록 — 내보내기 (계약 3절 "내보내기", 16단계). 파일 응답이라 표준 래퍼 없이 내려준다 (실패는 표준 실패 JSON)
|
||||
@ApiTags('Contacts')
|
||||
@ApiCookieAuth()
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ApiSessionErrors('로그인 필요 (쿠키 없음·만료)')
|
||||
@ApiErrorResponse('MAIL_001', '메일 엔진 장애')
|
||||
@Controller(CONTACT_EXPORTS_ROUTE)
|
||||
export class ContactExportsController {
|
||||
constructor(private readonly contacts: ContactsService) {}
|
||||
|
||||
@Get()
|
||||
@SkipTransform()
|
||||
@ApiOperation({
|
||||
summary: '주소록 내보내기 — vCard 3.0 · CSV 파일 (표준 래퍼 없음)',
|
||||
description:
|
||||
'내 주소록 전체 또는 그룹 하나(groupId)를 이름 차례로. 사내 목록은 넣지 않는다. 우리 칸만(엔진 카드의 다른 칸 — 주소 · 생일 · 사진 등은 넣지 않는다). ' +
|
||||
'vcard: vCard 3.0 · CRLF · 75바이트 줄 접기 · FN(이름 — 없으면 첫 이메일 → 첫 전화번호) · N(이름 전체를 성 칸에) · EMAIL(TYPE=INTERNET,WORK / INTERNET,HOME / INTERNET) · TEL(CELL / WORK / HOME / VOICE) · ' +
|
||||
'ORG(회사;부서) · TITLE · NOTE · CATEGORIES(그룹 이름) · UID. ' +
|
||||
'csv: UTF-8 BOM · CRLF · 머리 「이름,이메일,휴대폰,회사 전화,집 전화,기타 전화,회사,부서,직함,메모,그룹」 — 같은 칸에 여럿이면 "; " 로 잇고, ' +
|
||||
"= + - @ · 탭 · CR 로 시작하는 칸은 앞에 ' 를 붙인다(엑셀 수식 막기 — OWASP CSV Injection). " +
|
||||
'파일 이름 「주소록-YYYY-MM-DD.vcf」 · 「주소록-<그룹 이름>-YYYY-MM-DD.csv」(한국 날짜) — Content-Disposition: attachment. Cache-Control: private, no-store. ' +
|
||||
'화면은 첨부 받기처럼 이동 직전에 세션을 확인하고 <a href download> 로 받는다.',
|
||||
})
|
||||
@ApiProduces(VCARD_CONTENT_TYPE, CSV_CONTENT_TYPE)
|
||||
@ApiResponse({
|
||||
status: HttpStatus.OK,
|
||||
description: '내보낸 파일 (.vcf · .csv)',
|
||||
schema: { type: 'string', format: 'binary' },
|
||||
})
|
||||
@ApiErrorResponse('VAL_001', '모르는 형식 · 그룹 id 모양 · 모르는 쿼리')
|
||||
@ApiErrorResponse('RES_001', '없는 groupId')
|
||||
async export(
|
||||
@CurrentUser() user: AuthenticatedUser,
|
||||
@Query() query: ContactExportQueryDto,
|
||||
@Res({ passthrough: true }) res: Response,
|
||||
): Promise<StreamableFile> {
|
||||
const file = await this.contacts.exportContacts(
|
||||
user,
|
||||
query.format,
|
||||
query.groupId,
|
||||
);
|
||||
// 개인 정보 — 남기지 않는다. 브라우저가 열지 않고 내려받게(형식 짐작 막기 · 열어도 스크립트가 돌지 않게)
|
||||
res.setHeader('Cache-Control', DOWNLOAD_CACHE_CONTROL);
|
||||
res.setHeader('X-Content-Type-Options', 'nosniff');
|
||||
res.setHeader('Content-Security-Policy', DOWNLOAD_CSP);
|
||||
return new StreamableFile(file.content, {
|
||||
type: file.contentType,
|
||||
disposition: buildAttachmentDisposition(file.fileName),
|
||||
length: file.content.length,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
import {
|
||||
deletionChunkSize,
|
||||
importDuplicateIndex,
|
||||
isExistingContact,
|
||||
namePhonesKey,
|
||||
phoneDigitsKey,
|
||||
planGroupJoins,
|
||||
type GroupRoom,
|
||||
} from './contact-import';
|
||||
import type { ContactFields, ContactPhone } from './contacts.types';
|
||||
|
||||
const mobile = (number: string): ContactPhone => ({ number, label: 'mobile' });
|
||||
|
||||
const person = (overrides: Partial<ContactFields> = {}) => ({
|
||||
name: null,
|
||||
emails: [],
|
||||
phones: [],
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe('가져오기 — 이미 있는 연락처 (계약 3절 "가져오기")', () => {
|
||||
it('전화 숫자 모음 — 숫자만 남기고 겹침 · 차례를 무시한다, 숫자가 없는 번호는 뺀다', () => {
|
||||
expect(
|
||||
phoneDigitsKey([
|
||||
mobile('010-1234-5678'),
|
||||
{ number: '(02) 555-0100', label: 'work' },
|
||||
mobile('01012345678'),
|
||||
mobile('#*'),
|
||||
]),
|
||||
).toBe('01012345678,025550100');
|
||||
expect(phoneDigitsKey([])).toBe('');
|
||||
});
|
||||
|
||||
it('이름 + 전화 열쇠 — 이름은 앞뒤 공백만 뺀다(가운데 공백 · 대소문자는 그대로), 이름이 없으면 null', () => {
|
||||
expect(namePhonesKey({ name: ' 김아라 ', phones: [] })).toBe(
|
||||
namePhonesKey({ name: '김아라', phones: [] }),
|
||||
);
|
||||
expect(namePhonesKey({ name: '김 아라', phones: [] })).not.toBe(
|
||||
namePhonesKey({ name: '김아라', phones: [] }),
|
||||
);
|
||||
expect(namePhonesKey({ name: 'Ara', phones: [] })).not.toBe(
|
||||
namePhonesKey({ name: 'ara', phones: [] }),
|
||||
);
|
||||
expect(namePhonesKey({ name: null, phones: [mobile('010')] })).toBeNull();
|
||||
expect(namePhonesKey({ name: ' ', phones: [] })).toBeNull();
|
||||
});
|
||||
|
||||
it('이메일이 있는 항목 — 그 가운데 하나라도 주소록에 있으면(앞뒤 공백 · 대소문자 무시) 이미 있음', () => {
|
||||
const index = importDuplicateIndex([
|
||||
person({
|
||||
name: '박지훈',
|
||||
emails: [{ address: 'Jihoon@Hanbit.example', label: 'work' }],
|
||||
}),
|
||||
]);
|
||||
expect(
|
||||
isExistingContact(
|
||||
index,
|
||||
person({
|
||||
emails: [
|
||||
{ address: 'new@x.example', label: 'work' },
|
||||
{ address: ' jihoon@hanbit.EXAMPLE ', label: 'other' },
|
||||
],
|
||||
}),
|
||||
),
|
||||
).toBe(true);
|
||||
// 이메일이 있으면 이름 · 전화는 보지 않는다
|
||||
expect(
|
||||
isExistingContact(
|
||||
index,
|
||||
person({
|
||||
name: '박지훈',
|
||||
emails: [{ address: 'other@x.example', label: 'work' }],
|
||||
}),
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('이메일이 없는 항목 — 이름과 전화 숫자 모음이 모두 같은 연락처가 있으면 이미 있음 (주소록 쪽은 이메일이 있어도 된다)', () => {
|
||||
const index = importDuplicateIndex([
|
||||
person({
|
||||
name: '김철수',
|
||||
emails: [{ address: 'kim@x.example', label: 'work' }],
|
||||
phones: [
|
||||
mobile('010-1111-2222'),
|
||||
{ number: '02 555 0100', label: 'work' },
|
||||
],
|
||||
}),
|
||||
person({ name: '이영희' }),
|
||||
]);
|
||||
expect(
|
||||
isExistingContact(
|
||||
index,
|
||||
person({
|
||||
name: '김철수 ',
|
||||
phones: [
|
||||
{ number: '0255501 00', label: 'other' },
|
||||
mobile('01011112222'),
|
||||
],
|
||||
}),
|
||||
),
|
||||
).toBe(true);
|
||||
// 번호 하나만 같으면 다른 사람으로 본다
|
||||
expect(
|
||||
isExistingContact(
|
||||
index,
|
||||
person({ name: '김철수', phones: [mobile('010-1111-2222')] }),
|
||||
),
|
||||
).toBe(false);
|
||||
// 둘 다 전화가 없으면 이름만 같아도
|
||||
expect(isExistingContact(index, person({ name: '이영희' }))).toBe(true);
|
||||
expect(isExistingContact(index, person({ name: '이영희2' }))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('가져오기 — 그룹 넣기 계획', () => {
|
||||
const room = (members: string[], count = members.length): GroupRoom => ({
|
||||
memberUids: new Set(members),
|
||||
memberCount: count,
|
||||
});
|
||||
|
||||
it('보낸 차례대로 넣고, 한도에 닿은 그룹은 그 뒤 사람만 빼고(skipped) 나머지 그룹에는 넣는다', () => {
|
||||
const rooms = new Map([
|
||||
['g1', room(['u-old'], 2)],
|
||||
['g2', room([])],
|
||||
]);
|
||||
const plan = planGroupJoins(
|
||||
rooms,
|
||||
[
|
||||
{ uid: 'a', groupIds: ['g1', 'g2'] },
|
||||
{ uid: 'b', groupIds: ['g1', 'g2'] },
|
||||
{ uid: 'c', groupIds: ['g2'] },
|
||||
],
|
||||
3,
|
||||
);
|
||||
expect(plan.adds.get('g1')).toEqual(['a']);
|
||||
expect(plan.adds.get('g2')).toEqual(['a', 'b', 'c']);
|
||||
expect(plan.skipped.get('b')).toEqual(['g1']);
|
||||
expect(plan.skipped.has('a')).toBe(false);
|
||||
});
|
||||
|
||||
it('이미 구성원인 uid 는 세지도 넣지도 않는다 · 모르는 그룹은 건너뛴다', () => {
|
||||
const plan = planGroupJoins(
|
||||
new Map([['g1', room(['a'], 3)]]),
|
||||
[
|
||||
{ uid: 'a', groupIds: ['g1', 'nope'] },
|
||||
{ uid: 'b', groupIds: ['g1'] },
|
||||
],
|
||||
3,
|
||||
);
|
||||
expect(plan.adds.get('g1')).toBeUndefined();
|
||||
expect(plan.skipped.get('b')).toEqual(['g1']);
|
||||
expect(plan.skipped.has('a')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('한꺼번에 지우기 — 한 번 몫', () => {
|
||||
it('지울 연락처 수 + 고칠 그룹 수(겹침 없이)가 상한을 넘지 않게 앞에서부터', () => {
|
||||
expect(deletionChunkSize([[], [], []], 500)).toBe(3);
|
||||
expect(deletionChunkSize([['g1'], ['g1'], ['g2']], 4)).toBe(2);
|
||||
expect(deletionChunkSize([['g1'], ['g1'], ['g1']], 4)).toBe(3);
|
||||
expect(
|
||||
deletionChunkSize(
|
||||
Array.from({ length: 500 }, () => ['g1']),
|
||||
500,
|
||||
),
|
||||
).toBe(499);
|
||||
// 첫 사람이 홀로 넘어도 한 명은 지운다
|
||||
expect(deletionChunkSize([['a', 'b', 'c']], 2)).toBe(1);
|
||||
expect(deletionChunkSize([], 500)).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,138 @@
|
||||
import { emailKey } from './address-book';
|
||||
import type { ContactFields, ContactPhone } from './contacts.types';
|
||||
|
||||
/**
|
||||
* 가져오기 · 한꺼번에 지우기의 순수 함수 (계약 3절 "가져오기 · 내보내기" — 16단계).
|
||||
*
|
||||
* "이미 있는 연락처" 규칙 — 화면이 같은 파일 안의 겹침을 **같은 규칙**으로 본다(frontend shared/utils/contact-import — 한쪽을 고치면 둘 다):
|
||||
* - 이메일이 있는 항목: 그 이메일 가운데 하나라도(앞뒤 공백을 빼고 소문자 — emailKey) 내 주소록의 어느 연락처에 있으면 이미 있음
|
||||
* - 이메일이 없는 항목: 이름(앞뒤 공백을 뺀 글)이 같고 전화번호의 숫자 모음(숫자만 남긴 번호들 — 차례 · 겹침 무시)이 같은 연락처가 있으면 이미 있음
|
||||
* (주소록 쪽 연락처는 이메일이 있어도 된다. 둘 다 전화가 없으면 이름만 같아도 이미 있음)
|
||||
*/
|
||||
|
||||
const NON_DIGITS = /\D+/g;
|
||||
/** 이름과 전화 숫자 모음을 잇는 글 — 이름 칸에 올 수 없는 제어 문자 */
|
||||
const KEY_SEPARATOR = '\u0000';
|
||||
|
||||
/** 전화번호들의 숫자 모음 — 숫자만 남긴 번호를 겹침 없이 차례로 (숫자가 없는 번호는 뺀다) */
|
||||
export function phoneDigitsKey(phones: readonly ContactPhone[]): string {
|
||||
const digits = new Set<string>();
|
||||
for (const phone of phones) {
|
||||
const only = phone.number.replace(NON_DIGITS, '');
|
||||
if (only !== '') digits.add(only);
|
||||
}
|
||||
return [...digits].sort().join(',');
|
||||
}
|
||||
|
||||
/** 이메일이 없는 항목을 견주는 열쇠 — 이름(앞뒤 공백을 뺀 글) + 전화 숫자 모음. 이름이 없으면 null */
|
||||
export function namePhonesKey(
|
||||
fields: Pick<ContactFields, 'name' | 'phones'>,
|
||||
): string | null {
|
||||
const name = fields.name?.trim() ?? '';
|
||||
return name === ''
|
||||
? null
|
||||
: `${name}${KEY_SEPARATOR}${phoneDigitsKey(fields.phones)}`;
|
||||
}
|
||||
|
||||
/** 내 주소록의 "이미 있음" 찾기 자료 — 이메일 모음 · 이름 + 전화 열쇠 모음 */
|
||||
export interface ImportDuplicateIndex {
|
||||
readonly emails: ReadonlySet<string>;
|
||||
readonly namePhones: ReadonlySet<string>;
|
||||
}
|
||||
|
||||
/** 주소록의 연락처들로 찾기 자료를 만든다 — 연락처 수에 비례 */
|
||||
export function importDuplicateIndex(
|
||||
contacts: readonly Pick<ContactFields, 'name' | 'emails' | 'phones'>[],
|
||||
): ImportDuplicateIndex {
|
||||
const emails = new Set<string>();
|
||||
const namePhones = new Set<string>();
|
||||
for (const contact of contacts) {
|
||||
for (const email of contact.emails) emails.add(emailKey(email.address));
|
||||
const key = namePhonesKey(contact);
|
||||
if (key !== null) namePhones.add(key);
|
||||
}
|
||||
return { emails, namePhones };
|
||||
}
|
||||
|
||||
/** 이 항목이 내 주소록에 이미 있는가 (위 규칙) */
|
||||
export function isExistingContact(
|
||||
index: ImportDuplicateIndex,
|
||||
item: Pick<ContactFields, 'name' | 'emails' | 'phones'>,
|
||||
): boolean {
|
||||
if (item.emails.length > 0) {
|
||||
return item.emails.some((email) =>
|
||||
index.emails.has(emailKey(email.address)),
|
||||
);
|
||||
}
|
||||
const key = namePhonesKey(item);
|
||||
return key !== null && index.namePhones.has(key);
|
||||
}
|
||||
|
||||
/** 그룹 하나의 지금 자리 — 구성원 uid(이 주소록에 없는 카드를 가리키는 것 포함)와 센 구성원 수(이 주소록에 있는 연락처만) */
|
||||
export interface GroupRoom {
|
||||
readonly memberUids: ReadonlySet<string>;
|
||||
readonly memberCount: number;
|
||||
}
|
||||
|
||||
/** 그룹에 넣을 사람 하나 — uid 와 넣을 그룹 */
|
||||
export interface GroupJoin {
|
||||
readonly uid: string;
|
||||
readonly groupIds: readonly string[];
|
||||
}
|
||||
|
||||
export interface GroupJoinPlan {
|
||||
/** 그룹 id → 새로 넣을 uid (넣는 차례) */
|
||||
readonly adds: ReadonlyMap<string, readonly string[]>;
|
||||
/** uid → 구성원 수 한도가 차서 넣지 못한 그룹 id (보낸 차례) */
|
||||
readonly skipped: ReadonlyMap<string, readonly string[]>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 가져온 사람들을 그룹에 넣는 계획 — 보낸 차례대로 넣고, 그룹이 max 명에 닿으면 그 뒤 사람은 그 그룹만 빼고(skipped) 나머지 그룹에는 넣는다.
|
||||
* 이미 구성원인 uid 는 세지도 넣지도 않는다. 모르는 그룹 id 는 부르는 쪽이 먼저 막는다(여기서는 건너뛴다)
|
||||
*/
|
||||
export function planGroupJoins(
|
||||
rooms: ReadonlyMap<string, GroupRoom>,
|
||||
joins: readonly GroupJoin[],
|
||||
max: number,
|
||||
): GroupJoinPlan {
|
||||
const counts = new Map<string, number>();
|
||||
const adds = new Map<string, string[]>();
|
||||
const skipped = new Map<string, string[]>();
|
||||
for (const join of joins) {
|
||||
for (const groupId of join.groupIds) {
|
||||
const room = rooms.get(groupId);
|
||||
if (!room) continue;
|
||||
const added = adds.get(groupId) ?? [];
|
||||
if (room.memberUids.has(join.uid) || added.includes(join.uid)) continue;
|
||||
const count = counts.get(groupId) ?? room.memberCount;
|
||||
if (count >= max) {
|
||||
skipped.set(join.uid, [...(skipped.get(join.uid) ?? []), groupId]);
|
||||
continue;
|
||||
}
|
||||
counts.set(groupId, count + 1);
|
||||
added.push(join.uid);
|
||||
adds.set(groupId, added);
|
||||
}
|
||||
}
|
||||
return { adds, skipped };
|
||||
}
|
||||
|
||||
/**
|
||||
* 한꺼번에 지우기의 한 번 몫 — 앞에서부터 몇 명을 지울지. 지울 연락처 수 + 고칠 그룹 수(그 연락처가 든 그룹 — 겹침 없이)가 max 를 넘지 않게
|
||||
* (엔진 한 요청의 카드 수 상한). 첫 사람이 홀로 넘어도 한 명은 지운다(그런 주소록은 없다 — 그룹 100개)
|
||||
*/
|
||||
export function deletionChunkSize(
|
||||
groupsOf: readonly (readonly string[])[],
|
||||
max: number,
|
||||
): number {
|
||||
const groups = new Set<string>();
|
||||
let size = 0;
|
||||
for (const ids of groupsOf) {
|
||||
const fresh = ids.filter((id) => !groups.has(id));
|
||||
if (size > 0 && size + 1 + groups.size + fresh.length > max) break;
|
||||
for (const id of fresh) groups.add(id);
|
||||
size += 1;
|
||||
}
|
||||
return size;
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
Post,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { ApiCookieAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
|
||||
import {
|
||||
ApiErrorResponse,
|
||||
ApiSessionErrors,
|
||||
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_DELETIONS_ROUTE,
|
||||
CONTACT_IMPORT_CHECKS_ROUTE,
|
||||
CONTACT_IMPORTS_ROUTE,
|
||||
MAX_CONTACT_DELETIONS,
|
||||
MAX_CONTACT_GROUP_MEMBERS,
|
||||
MAX_CONTACT_IMPORT_BATCH,
|
||||
MAX_CONTACTS,
|
||||
} from './contacts.constants';
|
||||
import { ContactsService } from './contacts.service';
|
||||
import {
|
||||
ContactDeletionDto,
|
||||
ContactImportBatchDto,
|
||||
ContactImportCheckDto,
|
||||
ContactImportResultDto,
|
||||
ContactsDeletedDto,
|
||||
} from './dto/contact-import.dto';
|
||||
|
||||
const ITEM_RULES =
|
||||
`한 사람의 칸 규칙은 연락처 추가(POST /api/contacts)와 같고 uid(urn:uuid: + 소문자 UUID — 화면이 만든다)를 더한다. ` +
|
||||
`${MAX_CONTACT_IMPORT_BATCH}명까지 · 한 묶음 안의 같은 uid · 이름도 이메일도 없는 사람 · 같은 연락처 안의 같은 주소는 400 VAL_001(하나라도 걸리면 묶음 전체).`;
|
||||
|
||||
// 내 주소록 — 가져오기 확인 (계약 3절 "가져오기", 16단계). 파일은 화면이 읽는다 — 서버는 연락처 칸만 받는다
|
||||
@ApiTags('Contacts')
|
||||
@ApiCookieAuth()
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ApiSessionErrors('로그인 필요 (쿠키 없음·만료)')
|
||||
@ApiErrorResponse('MAIL_001', '메일 엔진 장애')
|
||||
@Controller(CONTACT_IMPORT_CHECKS_ROUTE)
|
||||
export class ContactImportChecksController {
|
||||
constructor(private readonly contacts: ContactsService) {}
|
||||
|
||||
@Post()
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({
|
||||
summary: '가져올 연락처가 이미 있는지 확인 (만들지 않는다)',
|
||||
description:
|
||||
'사람마다 new · exists — 같은 이메일(앞뒤 공백 · 대소문자 무시)이 내 주소록에 있으면, 이메일이 없으면 이름(앞뒤 공백을 뺀 글)과 전화번호의 숫자 모음이 모두 같은 연락처가 있으면, ' +
|
||||
`또는 같은 uid 의 연락처가 있으면 exists. remaining 은 더 넣을 수 있는 수(${MAX_CONTACTS.toLocaleString('en-US')} − 지금 연락처 수). ` +
|
||||
`같은 파일 안의 겹침은 화면이 같은 규칙으로 본다. groupIds 는 보지 않는다. ${ITEM_RULES}`,
|
||||
})
|
||||
@ApiStandardResponse(ContactImportCheckDto, {
|
||||
description: '사람마다 판정과 남은 자리',
|
||||
})
|
||||
@ApiErrorResponse(
|
||||
'VAL_001',
|
||||
'형식 · 길이 · 개수 · uid 모양 · 같은 uid 두 번 · 이름도 이메일도 없음 · 같은 주소 두 번 · 모르는 필드',
|
||||
)
|
||||
check(
|
||||
@CurrentUser() user: AuthenticatedUser,
|
||||
@Body() dto: ContactImportBatchDto,
|
||||
) {
|
||||
return this.contacts.checkImport(user, dto.contacts);
|
||||
}
|
||||
}
|
||||
|
||||
// 내 주소록 — 가져오기 넣기 (계약 3절 "가져오기", 16단계). 엔진 요청 하나에 묶음을 만든다
|
||||
@ApiTags('Contacts')
|
||||
@ApiCookieAuth()
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ApiSessionErrors('로그인 필요 (쿠키 없음·만료)')
|
||||
@ApiErrorResponse('MAIL_001', '메일 엔진 장애')
|
||||
@Controller(CONTACT_IMPORTS_ROUTE)
|
||||
export class ContactImportsController {
|
||||
constructor(private readonly contacts: ContactsService) {}
|
||||
|
||||
@Post()
|
||||
@ApiOperation({
|
||||
summary: '가져온 연락처 넣기 (100명씩)',
|
||||
description:
|
||||
'줄마다 보낸 uid 로 만든다 — 같은 uid 의 연락처가 이미 있으면(다시 보낸 묶음) 새로 만들지 않고 그 id 를 돌려준다(빠진 그룹만 마저 넣는다). ' +
|
||||
`연락처 ${MAX_CONTACTS.toLocaleString('en-US')}개 · 크기 한도는 묶음 전체로 미리 본다 — 넘으면 409 CONTACT_002, 아무것도 만들지 않는다. ` +
|
||||
`그룹 구성원 ${MAX_CONTACT_GROUP_MEMBERS}명을 넘기는 넣기는 그 그룹만 빼고 만들고 skippedGroupIds 에 적는다(실패가 아니다). ` +
|
||||
'메일 서버가 일부만 받으면 받은 것만 items 에 — 빠진 uid 는 넣지 못한 것이다. 겹침(같은 이메일 등)은 보지 않는다(확인 경로의 몫). ' +
|
||||
ITEM_RULES,
|
||||
})
|
||||
@ApiStandardResponse(ContactImportResultDto, {
|
||||
status: 201,
|
||||
description: '넣은 연락처(같은 uid 로 이미 있던 것 포함)',
|
||||
})
|
||||
@ApiErrorResponse(
|
||||
'VAL_001',
|
||||
'형식 · 길이 · 개수 · uid 모양 · 같은 uid 두 번 · 이름도 이메일도 없음 · 같은 주소 두 번 · 모르는 필드',
|
||||
)
|
||||
@ApiErrorResponse('RES_001', '없는 groupId')
|
||||
@ApiErrorResponse(
|
||||
'CONTACT_002',
|
||||
`연락처 ${MAX_CONTACTS.toLocaleString('en-US')}개 · 크기 한도 — 묶음 전체로 보고 아무것도 만들지 않았다`,
|
||||
)
|
||||
importContacts(
|
||||
@CurrentUser() user: AuthenticatedUser,
|
||||
@Body() dto: ContactImportBatchDto,
|
||||
) {
|
||||
return this.contacts.importContacts(user, dto.contacts);
|
||||
}
|
||||
}
|
||||
|
||||
// 내 주소록 — 연락처 한꺼번에 지우기 (가져오기 [되돌리기] — 계약 3절 "가져오기", 16단계)
|
||||
@ApiTags('Contacts')
|
||||
@ApiCookieAuth()
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ApiSessionErrors('로그인 필요 (쿠키 없음·만료)')
|
||||
@ApiErrorResponse('MAIL_001', '메일 엔진 장애')
|
||||
@Controller(CONTACT_DELETIONS_ROUTE)
|
||||
export class ContactDeletionsController {
|
||||
constructor(private readonly contacts: ContactsService) {}
|
||||
|
||||
@Post()
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({
|
||||
summary: '연락처 한꺼번에 지우기 (영구 삭제 · 되돌리기 표 없음)',
|
||||
description:
|
||||
`1~${MAX_CONTACT_DELETIONS}개. 지운 연락처를 가리키는 그룹 구성원도 함께 뺀다. 내 주소록에 없는 id(이미 지움 · 그룹 id)는 notFoundIds — 실패가 아니다. ` +
|
||||
'메일 서버 한 요청의 상한을 넘으면 서버가 나눠 지운다 — 중간에 실패하면(503) 앞 몫은 지워진 채다(같은 목록을 다시 보내면 notFoundIds 로 나온다).',
|
||||
})
|
||||
@ApiStandardResponse(ContactsDeletedDto, {
|
||||
description: '지운 id · 없던 id (보낸 차례)',
|
||||
})
|
||||
@ApiErrorResponse(
|
||||
'VAL_001',
|
||||
'형식 · 개수 · id 모양 · 같은 id 두 번 · 모르는 필드',
|
||||
)
|
||||
remove(
|
||||
@CurrentUser() user: AuthenticatedUser,
|
||||
@Body() dto: ContactDeletionDto,
|
||||
) {
|
||||
return this.contacts.removeContacts(user, dto.ids);
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,28 @@ export const CONTACT_GROUPS_ROUTE = 'contact-groups';
|
||||
export const CONTACT_RESTORATIONS_ROUTE = 'contact-restorations';
|
||||
export const DIRECTORY_ENTRIES_ROUTE = 'directory-entries';
|
||||
|
||||
/**
|
||||
* 16단계 라우트 — 가져오기의 확인 · 넣기, 한꺼번에 지우기(가져오기 [되돌리기]), 내보내기 (계약 3절 "가져오기 · 내보내기")
|
||||
* (`/api/contact-import-checks` · `/api/contact-imports` · `/api/contact-deletions` · `/api/contact-exports`)
|
||||
*/
|
||||
export const CONTACT_IMPORT_CHECKS_ROUTE = 'contact-import-checks';
|
||||
export const CONTACT_IMPORTS_ROUTE = 'contact-imports';
|
||||
export const CONTACT_DELETIONS_ROUTE = 'contact-deletions';
|
||||
export const CONTACT_EXPORTS_ROUTE = 'contact-exports';
|
||||
|
||||
/** 가져오기 · 확인 한 요청의 연락처 수 — 1~100 (화면은 100명씩 차례로 보낸다) */
|
||||
export const MAX_CONTACT_IMPORT_BATCH = 100;
|
||||
/** 한꺼번에 지우기 한 요청의 id 수 — 1~500 */
|
||||
export const MAX_CONTACT_DELETIONS = 500;
|
||||
/**
|
||||
* 엔진 한 번의 바꾸기(ContactCard/set)에 담는 카드 수 — 만들기 · 고치기 · 지우기를 모두 센다.
|
||||
* 엔진의 maxObjectsInSet(500 — 2026-11-10 세션 능력 확인)과 같다. 한꺼번에 지우기는 그룹 고치기와 합쳐 이것을 넘으면 나눠 쓴다
|
||||
*/
|
||||
export const MAX_CARD_CHANGES_PER_WRITE = 500;
|
||||
/** 가져오기 항목의 uid — `urn:uuid:` + 소문자 UUID (화면이 만든다 — 다시 보낸 요청이 두 번 들어가지 않게) */
|
||||
export const CONTACT_IMPORT_UID_PATTERN =
|
||||
/^urn:uuid:[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
|
||||
|
||||
/**
|
||||
* 주소록 한도 — 넘으면 409 CONTACT_002 (아무것도 바꾸지 않는다).
|
||||
* ⚠️ CONTACT_002 의 문구(common/errors/error-codes.ts)에 같은 숫자가 있다 — contacts.constants.spec.ts 가 맞는지 본다
|
||||
|
||||
@@ -3,7 +3,13 @@ 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 { ContactExportsController } from './contact-exports.controller';
|
||||
import { ContactGroupsController } from './contact-groups.controller';
|
||||
import {
|
||||
ContactDeletionsController,
|
||||
ContactImportChecksController,
|
||||
ContactImportsController,
|
||||
} from './contact-imports.controller';
|
||||
import { ContactRestorationsController } from './contact-restorations.controller';
|
||||
import { ContactsController } from './contacts.controller';
|
||||
import { CONTACTS_PROVIDER, DIRECTORY_PROVIDER } from './contacts.provider';
|
||||
@@ -25,6 +31,11 @@ import { DirectoryService } from './directory.service';
|
||||
ContactGroupsController,
|
||||
ContactRestorationsController,
|
||||
DirectoryEntriesController,
|
||||
// 16단계 — 가져오기 확인 · 넣기 · 한꺼번에 지우기 · 내보내기
|
||||
ContactImportChecksController,
|
||||
ContactImportsController,
|
||||
ContactDeletionsController,
|
||||
ContactExportsController,
|
||||
],
|
||||
providers: [
|
||||
{ provide: CONTACTS_PROVIDER, useClass: JmapContactsProvider },
|
||||
|
||||
@@ -103,16 +103,30 @@ export type AddressBookCardUpdate =
|
||||
readonly memberUids?: readonly string[];
|
||||
};
|
||||
|
||||
/** 한 번에 하는 바꾸기 — 엔진 요청 하나 (만들기 → 고치기 → 지우기 차례로 한다) */
|
||||
/**
|
||||
* 한 번에 하는 바꾸기 — 엔진 요청 하나 (만들기 → 고치기 → 지우기 차례로 한다).
|
||||
* 카드 수(만들기 + 고치기 + 지우기)는 서비스가 MAX_CARD_CHANGES_PER_WRITE(엔진의 한 요청 상한) 안으로 맞춘다
|
||||
*/
|
||||
export interface AddressBookChanges {
|
||||
readonly create: readonly NewAddressBookCard[];
|
||||
readonly update: readonly AddressBookCardUpdate[];
|
||||
readonly destroy: readonly string[];
|
||||
}
|
||||
|
||||
/** 바꾸기의 선택 (16단계 — 가져오기 · 한꺼번에 지우기) */
|
||||
export interface AddressBookWriteOptions {
|
||||
/**
|
||||
* 엔진이 받지 않은 **만들기**를 던지지 않고 결과(written 의 refusedKeys)에 적는다 — 가져오기.
|
||||
* 받은 만들기 · 고치기는 들어간다. 고치기 · 지우기의 거절과 동시 고치기 거절(busy)은 이 선택과 상관없이 지금처럼 다룬다
|
||||
*/
|
||||
readonly allowPartialCreate?: boolean;
|
||||
/** 지울 카드의 원본(되돌리기 표)을 읽지 않는다 — 한꺼번에 지우기. 그 판에 카드가 있는지는 그대로 본다(없으면 conflict) */
|
||||
readonly withoutOriginals?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 바꾸기의 결과
|
||||
* - written: 모두 했다 — 새 판(엔진이 알려 주지 않으면 null) · 만든 카드의 id(key → id, 만든 것은 모두 있다) ·
|
||||
* - written: 모두 했다 — 새 판(엔진이 알려 주지 않으면 null) · 만든 카드의 id(key → id, 만든 것은 모두 있다 — allowPartialCreate 면 refusedKeys 를 뺀 것) ·
|
||||
* 만들거나 고친 연락처 · 만든 그룹의 지금 모양(id → 카드 — 엔진에 보낸 카드를 읽은 것, 그룹 고치기는 넣지 않는다) · 지운 카드의 원본(id → 원본)
|
||||
* - conflict: 받은 판과 엔진의 지금 판이 달라 아무것도 하지 않았다 (그사이 다른 곳에서 바뀌었다 — 다시 읽고 다시 한다)
|
||||
* - busy: 엔진이 같은 카드의 동시 고치기를 거절했다(serverUnavailable) — 일부가 들어갔을 수 있다. 다시 읽고 다시 한다
|
||||
@@ -125,6 +139,8 @@ export type AddressBookWriteOutcome =
|
||||
readonly createdIds: ReadonlyMap<string, string>;
|
||||
readonly cards: ReadonlyMap<string, AddressBookCard>;
|
||||
readonly removed: ReadonlyMap<string, RemovedCardOriginal>;
|
||||
/** 엔진이 받지 않은 만들기의 key — allowPartialCreate 로 쓸 때만 있다(없으면 모두 받았다) */
|
||||
readonly refusedKeys?: ReadonlySet<string>;
|
||||
}
|
||||
| { readonly kind: 'conflict' }
|
||||
| {
|
||||
@@ -143,12 +159,13 @@ export interface ContactsProvider {
|
||||
* 바꾸기를 한 번에 — 받은 판(version)이 엔진의 지금 판과 같을 때만 한다(아니면 conflict, 아무것도 하지 않음).
|
||||
* 고칠 연락처 · 지울 카드는 그 판 그대로 먼저 읽는다(칸 안의 남의 정보를 지키고 · 되돌리기 원본을 남기려고 — 그사이 바뀌었으면 conflict).
|
||||
* 엔진이 동시 고치기를 거절하면 busy. 엔진이 일부를 받지 않으면 그 코드로 던진다(엔진 오류 종류에 따라 VAL_001 · MAIL_001 등 —
|
||||
* 받은 것은 이미 바뀌었을 수 있다). 주소록이 없는데 만들 카드가 있으면 MAIL_001
|
||||
* 받은 것은 이미 바뀌었을 수 있다 — options.allowPartialCreate 면 받지 않은 만들기만은 던지지 않고 결과에 적는다). 주소록이 없는데 만들 카드가 있으면 MAIL_001
|
||||
*/
|
||||
writeAddressBook(
|
||||
user: string,
|
||||
version: string,
|
||||
changes: AddressBookChanges,
|
||||
options?: AddressBookWriteOptions,
|
||||
): Promise<AddressBookWriteOutcome>;
|
||||
}
|
||||
|
||||
|
||||
@@ -2,11 +2,14 @@ import { Logger } from '@nestjs/common';
|
||||
|
||||
import type { AuthenticatedUser } from '@/common/decorators/current-user.decorator';
|
||||
import { BusinessException } from '@/common/errors/business.exception';
|
||||
import { kstDateString } from '@/shared/utils/kst-date';
|
||||
import { cardWeight } from './address-book';
|
||||
import {
|
||||
CONTACT_RESTORE_MAX_CARD_BYTES,
|
||||
CONTACT_RESTORE_TTL_MS,
|
||||
MAX_ADDRESS_BOOK_WEIGHT,
|
||||
MAX_CARD_CHANGES_PER_WRITE,
|
||||
MAX_CONTACT_DELETIONS,
|
||||
MAX_CONTACT_GROUP_MEMBERS,
|
||||
MAX_CONTACT_GROUPS,
|
||||
MAX_CONTACTS,
|
||||
@@ -17,6 +20,7 @@ import type {
|
||||
AddressBookContactCard,
|
||||
AddressBookGroupCard,
|
||||
AddressBookSnapshot,
|
||||
AddressBookWriteOptions,
|
||||
AddressBookWriteOutcome,
|
||||
ContactsProvider,
|
||||
RemovedCardOriginal,
|
||||
@@ -24,6 +28,7 @@ import type {
|
||||
import { ContactsService } from './contacts.service';
|
||||
import type {
|
||||
ContactFields,
|
||||
ContactImportItem,
|
||||
ContactInput,
|
||||
DirectoryEntry,
|
||||
} from './contacts.types';
|
||||
@@ -61,6 +66,10 @@ class FakeAddressBook implements ContactsProvider {
|
||||
failure: BusinessException | null = null;
|
||||
/** 다음 쓰기들이 busy — lost 는 아무것도 들어가지 않음, applied 는 들어간 뒤 busy (서비스는 결과를 모른다) */
|
||||
readonly busy: ('lost' | 'applied')[] = [];
|
||||
/** 엔진이 만들기를 받지 않을 uid (16단계 — 가져오기의 일부만 받기) */
|
||||
readonly refuseUids = new Set<string>();
|
||||
/** 쓰기마다 받은 선택 */
|
||||
readonly writeOptions: (AddressBookWriteOptions | undefined)[] = [];
|
||||
private nextId = 1;
|
||||
|
||||
readonly getAddressBookVersion = jest.fn(() =>
|
||||
@@ -77,7 +86,9 @@ class FakeAddressBook implements ContactsProvider {
|
||||
_user: string,
|
||||
version: string,
|
||||
changes: AddressBookChanges,
|
||||
options?: AddressBookWriteOptions,
|
||||
): Promise<AddressBookWriteOutcome> => {
|
||||
this.writeOptions.push(options);
|
||||
if (this.outsideChanges > 0) {
|
||||
this.outsideChanges -= 1;
|
||||
this.state += 1;
|
||||
@@ -91,14 +102,30 @@ class FakeAddressBook implements ContactsProvider {
|
||||
this.state += 1;
|
||||
return Promise.reject(failure);
|
||||
}
|
||||
const refusedKeys = new Set(
|
||||
changes.create
|
||||
.filter((card) => this.refuseUids.has(card.uid))
|
||||
.map((card) => card.key),
|
||||
);
|
||||
if (refusedKeys.size > 0 && !options?.allowPartialCreate) {
|
||||
// 엔진이 받지 않은 만들기 — 일부만 받기가 아니면 그 코드로 던진다(받은 것은 들어갔다)
|
||||
this.apply(changes, refusedKeys);
|
||||
return Promise.reject(
|
||||
new BusinessException('VAL_001', {
|
||||
context: { reason: 'address-book-change-refused' },
|
||||
}),
|
||||
);
|
||||
}
|
||||
const removed = new Map<string, RemovedCardOriginal>();
|
||||
for (const id of changes.destroy) {
|
||||
const card = this.contacts.get(id) ?? this.groups.get(id);
|
||||
if (card) removed.set(id, this.original(card));
|
||||
if (!options?.withoutOriginals) {
|
||||
for (const id of changes.destroy) {
|
||||
const card = this.contacts.get(id) ?? this.groups.get(id);
|
||||
if (card) removed.set(id, this.original(card));
|
||||
}
|
||||
}
|
||||
const mode = this.busy.shift();
|
||||
if (mode === 'lost') return Promise.resolve({ kind: 'busy', removed });
|
||||
const { createdIds, cards } = this.apply(changes);
|
||||
const { createdIds, cards } = this.apply(changes, refusedKeys);
|
||||
if (mode === 'applied') {
|
||||
return Promise.resolve({ kind: 'busy', removed });
|
||||
}
|
||||
@@ -108,6 +135,7 @@ class FakeAddressBook implements ContactsProvider {
|
||||
createdIds,
|
||||
cards,
|
||||
removed,
|
||||
...(options?.allowPartialCreate ? { refusedKeys } : {}),
|
||||
});
|
||||
},
|
||||
);
|
||||
@@ -165,7 +193,10 @@ class FakeAddressBook implements ContactsProvider {
|
||||
return { source, bytes: Buffer.byteLength(source, 'utf8') };
|
||||
}
|
||||
|
||||
private apply(changes: AddressBookChanges): {
|
||||
private apply(
|
||||
changes: AddressBookChanges,
|
||||
refusedKeys: ReadonlySet<string> = new Set(),
|
||||
): {
|
||||
createdIds: Map<string, string>;
|
||||
cards: Map<string, AddressBookCard>;
|
||||
} {
|
||||
@@ -173,6 +204,7 @@ class FakeAddressBook implements ContactsProvider {
|
||||
const createdIds = new Map<string, string>();
|
||||
const cards = new Map<string, AddressBookCard>();
|
||||
for (const card of changes.create) {
|
||||
if (refusedKeys.has(card.key)) continue;
|
||||
const id = `n${this.nextId++}`;
|
||||
createdIds.set(card.key, id);
|
||||
if (card.kind === 'contact') {
|
||||
@@ -1364,4 +1396,413 @@ describe('ContactsService (주소록)', () => {
|
||||
expect(book.contacts.size).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('16단계 — 가져오기 · 한꺼번에 지우기 · 내보내기 (계약 3절 "가져오기 · 내보내기")', () => {
|
||||
const uidOf = (n: number) =>
|
||||
`urn:uuid:00000000-0000-4000-8000-${n.toString(16).padStart(12, '0')}`;
|
||||
const item = (
|
||||
n: number,
|
||||
overrides: Partial<ContactImportItem> = {},
|
||||
): ContactImportItem => ({
|
||||
...emptyFields,
|
||||
name: `사람${n}`,
|
||||
emails: [{ address: `p${n}@import.example`, label: 'work' }],
|
||||
groupIds: [],
|
||||
uid: uidOf(n),
|
||||
...overrides,
|
||||
});
|
||||
const logged = () =>
|
||||
logSpy.mock.calls.map(([message]) => String(message)).join('\n');
|
||||
|
||||
describe('가져오기 확인 (checkImport)', () => {
|
||||
it('같은 이메일(대소문자 무시) · 이름 + 전화 숫자 · 같은 uid 는 exists, 나머지는 new — remaining 은 3,000 − 지금 수, 쓰지 않는다', async () => {
|
||||
book.seedContact('c1', {
|
||||
name: '박지훈',
|
||||
emails: [{ address: 'Jihoon@Hanbit.example', label: 'work' }],
|
||||
});
|
||||
book.seedContact('c2', {
|
||||
name: '김철수',
|
||||
phones: [{ number: '010-1111-2222', label: 'mobile' }],
|
||||
});
|
||||
|
||||
const result = await service.checkImport(ARA, [
|
||||
item(1, {
|
||||
emails: [{ address: ' jihoon@hanbit.EXAMPLE', label: 'personal' }],
|
||||
}),
|
||||
item(2, {
|
||||
name: '김철수',
|
||||
emails: [],
|
||||
phones: [{ number: '01011112222', label: 'other' }],
|
||||
}),
|
||||
item(3, { uid: 'urn:uuid:c1' }),
|
||||
item(4),
|
||||
item(5, { name: '김철수', emails: [], phones: [] }),
|
||||
]);
|
||||
|
||||
expect(result).toEqual({
|
||||
items: [
|
||||
{ uid: uidOf(1), status: 'exists' },
|
||||
{ uid: uidOf(2), status: 'exists' },
|
||||
{ uid: 'urn:uuid:c1', status: 'exists' },
|
||||
{ uid: uidOf(4), status: 'new' },
|
||||
{ uid: uidOf(5), status: 'new' },
|
||||
],
|
||||
remaining: MAX_CONTACTS - 2,
|
||||
});
|
||||
expect(book.writeAddressBook).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('remaining 은 0 아래로 내려가지 않는다 (다른 프로그램이 3,000명 넘게 넣은 주소록)', async () => {
|
||||
for (let index = 0; index < MAX_CONTACTS + 2; index += 1) {
|
||||
book.seedContact(`s${index}`);
|
||||
}
|
||||
await expect(
|
||||
service.checkImport(ARA, [item(1)]),
|
||||
).resolves.toMatchObject({ remaining: 0 });
|
||||
});
|
||||
|
||||
it('묶음 검사 — 0명 · 101명 · 같은 uid · 이름도 이메일도 없음 · 같은 주소 두 번은 엔진에 묻기 전에 VAL_001', async () => {
|
||||
const batches: ContactImportItem[][] = [
|
||||
[],
|
||||
Array.from({ length: 101 }, (_, n) => item(n)),
|
||||
[item(1), item(2, { uid: uidOf(1) })],
|
||||
[item(1, { name: null, emails: [] })],
|
||||
[
|
||||
item(1, {
|
||||
emails: [
|
||||
{ address: 'a@x.example', label: 'work' },
|
||||
{ address: 'A@X.example', label: 'other' },
|
||||
],
|
||||
}),
|
||||
],
|
||||
];
|
||||
for (const batch of batches) {
|
||||
expect((await failureOf(service.checkImport(ARA, batch))).code).toBe(
|
||||
'VAL_001',
|
||||
);
|
||||
expect(
|
||||
(await failureOf(service.importContacts(ARA, batch))).code,
|
||||
).toBe('VAL_001');
|
||||
}
|
||||
expect(book.getAddressBookVersion).not.toHaveBeenCalled();
|
||||
expect(book.writeAddressBook).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('가져오기 넣기 (importContacts)', () => {
|
||||
it('보낸 uid 로 엔진 요청 하나에 만들고 고른 그룹에 넣는다 — 응답은 보낸 차례, 다시 읽지 않고 바로 목록에 보인다', async () => {
|
||||
book.seedGroup('g1', '거래처');
|
||||
book.seedGroup('g2', '협력사');
|
||||
|
||||
const result = await service.importContacts(ARA, [
|
||||
item(2, { groupIds: ['g1', 'g2'] }),
|
||||
item(1, { groupIds: ['g1'] }),
|
||||
]);
|
||||
|
||||
expect(book.writeAddressBook).toHaveBeenCalledTimes(1);
|
||||
expect(book.writeOptions).toEqual([{ allowPartialCreate: true }]);
|
||||
const [write] = book.writes;
|
||||
expect(write.create.map((card) => card.uid)).toEqual([
|
||||
uidOf(2),
|
||||
uidOf(1),
|
||||
]);
|
||||
expect(result.items).toEqual([
|
||||
{
|
||||
uid: uidOf(2),
|
||||
id: expect.any(String) as unknown,
|
||||
skippedGroupIds: [],
|
||||
},
|
||||
{
|
||||
uid: uidOf(1),
|
||||
id: expect.any(String) as unknown,
|
||||
skippedGroupIds: [],
|
||||
},
|
||||
]);
|
||||
expect(book.groups.get('g1')?.memberUids).toEqual([uidOf(2), uidOf(1)]);
|
||||
expect(book.groups.get('g2')?.memberUids).toEqual([uidOf(2)]);
|
||||
|
||||
const reads = book.reads;
|
||||
const page = await service.list(ARA, listQuery());
|
||||
expect(page.items.map((contact) => contact.name)).toEqual([
|
||||
'사람1',
|
||||
'사람2',
|
||||
]);
|
||||
expect(page.items[1].groupIds).toEqual(['g1', 'g2']);
|
||||
expect(book.reads).toBe(reads);
|
||||
});
|
||||
|
||||
it('같은 묶음을 다시 보내면(응답을 잃음) 만들지 않고 같은 id 를 준다 — 빠진 그룹만 마저 넣는다', async () => {
|
||||
book.seedGroup('g1', '거래처');
|
||||
const first = await service.importContacts(ARA, [item(1), item(2)]);
|
||||
const writes = book.writes.length;
|
||||
|
||||
const again = await service.importContacts(ARA, [
|
||||
item(1),
|
||||
item(2, { groupIds: ['g1'] }),
|
||||
]);
|
||||
|
||||
expect(again.items.map((entry) => entry.id)).toEqual(
|
||||
first.items.map((entry) => entry.id),
|
||||
);
|
||||
expect(book.contacts.size).toBe(2);
|
||||
// 다시 보낸 쓰기에는 만들기가 없고 그룹 넣기만 있다
|
||||
expect(book.writes).toHaveLength(writes + 1);
|
||||
expect(book.writes.at(-1)?.create).toEqual([]);
|
||||
expect(book.groups.get('g1')?.memberUids).toEqual([uidOf(2)]);
|
||||
});
|
||||
|
||||
it(`구성원 ${MAX_CONTACT_GROUP_MEMBERS}명이 찬 그룹은 그 그룹만 빼고(skippedGroupIds) 만들고 다른 그룹에는 넣는다`, async () => {
|
||||
const members: string[] = [];
|
||||
for (let index = 0; index < MAX_CONTACT_GROUP_MEMBERS - 1; index += 1) {
|
||||
members.push(book.seedContact(`m${index}`).uid ?? '');
|
||||
}
|
||||
book.seedGroup('g1', '거의 참', members);
|
||||
book.seedGroup('g2', '여유');
|
||||
|
||||
const result = await service.importContacts(ARA, [
|
||||
item(1, { groupIds: ['g1', 'g2'] }),
|
||||
item(2, { groupIds: ['g1', 'g2'] }),
|
||||
]);
|
||||
|
||||
expect(result.items.map((entry) => entry.skippedGroupIds)).toEqual([
|
||||
[],
|
||||
['g1'],
|
||||
]);
|
||||
expect(book.groups.get('g1')?.memberUids).toHaveLength(
|
||||
MAX_CONTACT_GROUP_MEMBERS,
|
||||
);
|
||||
expect(book.groups.get('g2')?.memberUids).toEqual([uidOf(1), uidOf(2)]);
|
||||
expect(logged()).toContain('구성원 한도로 그룹에 넣지 못함 1건');
|
||||
});
|
||||
|
||||
it(`연락처가 ${MAX_CONTACTS}명을 넘게 되면 묶음 전체를 CONTACT_002 — 아무것도 만들지 않는다 · 없는 그룹은 RES_001`, async () => {
|
||||
for (let index = 0; index < MAX_CONTACTS - 1; index += 1) {
|
||||
book.seedContact(`s${index}`);
|
||||
}
|
||||
const failure = await failureOf(
|
||||
service.importContacts(ARA, [item(1), item(2)]),
|
||||
);
|
||||
expect(failure.code).toBe('CONTACT_002');
|
||||
expect(failure.context?.reason).toBe('contacts-limit');
|
||||
expect(
|
||||
(
|
||||
await failureOf(
|
||||
service.importContacts(ARA, [item(1, { groupIds: ['nope'] })]),
|
||||
)
|
||||
).code,
|
||||
).toBe('RES_001');
|
||||
expect(book.writeAddressBook).not.toHaveBeenCalled();
|
||||
|
||||
// 한 명은 들어간다
|
||||
await expect(
|
||||
service.importContacts(ARA, [item(1)]),
|
||||
).resolves.toMatchObject({ items: [{ uid: uidOf(1) }] });
|
||||
});
|
||||
|
||||
it(`크기 ${MAX_ADDRESS_BOOK_WEIGHT.toLocaleString('en-US')} 를 넘게 하는 묶음은 CONTACT_002 — 아무것도 만들지 않는다`, async () => {
|
||||
// 792만 남짓 — 한 명은 들어가고 100명 묶음(사람마다 1,100 남짓)은 넘는다
|
||||
const note = 'x'.repeat(990_000);
|
||||
for (let index = 0; index < 8; index += 1) {
|
||||
book.seedContact(`big${index}`, { note });
|
||||
}
|
||||
const failure = await failureOf(
|
||||
service.importContacts(
|
||||
ARA,
|
||||
Array.from({ length: 100 }, (_, n) =>
|
||||
item(n, { note: 'y'.repeat(1_000) }),
|
||||
),
|
||||
),
|
||||
);
|
||||
expect(failure.code).toBe('CONTACT_002');
|
||||
expect(failure.context?.reason).toBe('address-book-too-large');
|
||||
expect(book.writeAddressBook).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('엔진이 일부만 받으면 받은 것만 돌려준다 — 던지지 않고, 기억한 주소록도 엔진과 같다(다시 읽지 않는다)', async () => {
|
||||
book.refuseUids.add(uidOf(2));
|
||||
|
||||
const result = await service.importContacts(ARA, [
|
||||
item(1),
|
||||
item(2),
|
||||
item(3),
|
||||
]);
|
||||
|
||||
expect(result.items.map((entry) => entry.uid)).toEqual([
|
||||
uidOf(1),
|
||||
uidOf(3),
|
||||
]);
|
||||
expect(book.contacts.size).toBe(2);
|
||||
const reads = book.reads;
|
||||
const page = await service.list(ARA, listQuery());
|
||||
expect(page.items.map((contact) => contact.name)).toEqual([
|
||||
'사람1',
|
||||
'사람3',
|
||||
]);
|
||||
expect(book.reads).toBe(reads);
|
||||
expect(logged()).toContain('넣지 못함 1명');
|
||||
});
|
||||
|
||||
it('엔진이 바쁘다고 했지만 들어갔으면(applied) 다음 시도가 같은 uid 로 찾아 두 번 만들지 않는다', async () => {
|
||||
book.busy.push('applied');
|
||||
const result = await service.importContacts(ARA, [item(1), item(2)]);
|
||||
expect(result.items.map((entry) => entry.uid)).toEqual([
|
||||
uidOf(1),
|
||||
uidOf(2),
|
||||
]);
|
||||
expect(book.contacts.size).toBe(2);
|
||||
expect(book.writeAddressBook).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('그룹 카드가 쓰는 uid 는 만들지 않는다(엔진이 받지 않는다) — 응답에서 빠진다', async () => {
|
||||
book.seedGroup('g1', '거래처');
|
||||
const result = await service.importContacts(ARA, [
|
||||
item(1, { uid: 'urn:uuid:g1' }),
|
||||
item(2),
|
||||
]);
|
||||
expect(result.items.map((entry) => entry.uid)).toEqual([uidOf(2)]);
|
||||
expect(book.contacts.size).toBe(1);
|
||||
});
|
||||
|
||||
it('로그에는 개수만 — 이름 · 주소 · uid 는 남기지 않는다', async () => {
|
||||
await service.importContacts(ARA, [item(1, { note: '비밀 메모' })]);
|
||||
expect(logged()).toContain('연락처를 가져왔습니다');
|
||||
expect(logged()).not.toMatch(
|
||||
/사람1|import\.example|비밀 메모|urn:uuid|ara\.kim@/i,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('한꺼번에 지우기 (removeContacts)', () => {
|
||||
it('지우고 그룹 구성원에서도 뺀다 — 없는 id · 그룹 id 는 notFoundIds(보낸 차례), 되돌리기 원본은 읽지 않는다', async () => {
|
||||
book.seedContact('c1');
|
||||
book.seedContact('c2');
|
||||
book.seedContact('c3');
|
||||
book.seedGroup('g1', '거래처', ['urn:uuid:c1', 'urn:uuid:c3']);
|
||||
|
||||
const result = await service.removeContacts(ARA, [
|
||||
'c3',
|
||||
'gone',
|
||||
'c1',
|
||||
'g1',
|
||||
]);
|
||||
|
||||
expect(result).toEqual({
|
||||
deletedIds: ['c3', 'c1'],
|
||||
notFoundIds: ['gone', 'g1'],
|
||||
});
|
||||
expect(book.writeOptions).toEqual([{ withoutOriginals: true }]);
|
||||
expect([...book.contacts.keys()]).toEqual(['c2']);
|
||||
expect(book.groups.get('g1')?.memberUids).toEqual([]);
|
||||
expect(logged()).toContain('연락처를 한꺼번에 지웠습니다: 2명');
|
||||
});
|
||||
|
||||
it(`지울 연락처 + 고칠 그룹이 엔진 한 요청의 상한(${MAX_CARD_CHANGES_PER_WRITE})을 넘으면 앞에서부터 나눠 쓴다`, async () => {
|
||||
const ids: string[] = [];
|
||||
const uids: string[] = [];
|
||||
for (let index = 0; index < MAX_CONTACT_DELETIONS; index += 1) {
|
||||
const card = book.seedContact(`d${index}`);
|
||||
ids.push(card.id);
|
||||
uids.push(card.uid ?? '');
|
||||
}
|
||||
book.seedGroup('g1', '모두', uids);
|
||||
|
||||
const result = await service.removeContacts(ARA, ids);
|
||||
|
||||
expect(result.deletedIds).toHaveLength(MAX_CONTACT_DELETIONS);
|
||||
expect(result.notFoundIds).toEqual([]);
|
||||
expect(book.writes.map((write) => write.destroy.length)).toEqual([
|
||||
MAX_CARD_CHANGES_PER_WRITE - 1,
|
||||
1,
|
||||
]);
|
||||
for (const write of book.writes) {
|
||||
expect(
|
||||
write.destroy.length + write.update.length,
|
||||
).toBeLessThanOrEqual(MAX_CARD_CHANGES_PER_WRITE);
|
||||
}
|
||||
expect(book.contacts.size).toBe(0);
|
||||
expect(book.groups.get('g1')?.memberUids).toEqual([]);
|
||||
});
|
||||
|
||||
it('같은 uid 의 다른 카드가 남으면 그 uid 는 그룹에 그대로 둔다', async () => {
|
||||
book.seedContact('c1', { uid: 'urn:uuid:shared' });
|
||||
book.seedContact('c2', { uid: 'urn:uuid:shared' });
|
||||
book.seedGroup('g1', '거래처', ['urn:uuid:shared']);
|
||||
|
||||
await service.removeContacts(ARA, ['c1']);
|
||||
expect(book.groups.get('g1')?.memberUids).toEqual(['urn:uuid:shared']);
|
||||
expect(book.writes.at(-1)?.update).toEqual([]);
|
||||
});
|
||||
|
||||
it('바쁘다고 했지만 들어갔으면 — 다음 시도에서 없어진 id 도 지운 것으로 센다(notFound 가 아니다)', async () => {
|
||||
book.seedContact('c1');
|
||||
book.seedContact('c2');
|
||||
book.busy.push('applied');
|
||||
|
||||
await expect(
|
||||
service.removeContacts(ARA, ['c1', 'c2']),
|
||||
).resolves.toEqual({ deletedIds: ['c1', 'c2'], notFoundIds: [] });
|
||||
});
|
||||
|
||||
it('0개 · 501개 · 같은 id 두 번은 VAL_001 — 엔진에 묻지 않는다', async () => {
|
||||
for (const ids of [
|
||||
[],
|
||||
Array.from({ length: MAX_CONTACT_DELETIONS + 1 }, (_, n) => `x${n}`),
|
||||
['c1', 'c1'],
|
||||
]) {
|
||||
expect((await failureOf(service.removeContacts(ARA, ids))).code).toBe(
|
||||
'VAL_001',
|
||||
);
|
||||
}
|
||||
expect(book.getAddressBookVersion).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('내보내기 (exportContacts)', () => {
|
||||
beforeEach(() => {
|
||||
book.seedContact('c-pa', {
|
||||
name: '박지훈',
|
||||
emails: [{ address: 'jihoon@hanbit.example', label: 'work' }],
|
||||
});
|
||||
book.seedContact('c-ga', {
|
||||
name: '가나다',
|
||||
phones: [{ number: '010-1234-5678', label: 'mobile' }],
|
||||
});
|
||||
book.seedGroup('g1', '거래처', ['urn:uuid:c-pa']);
|
||||
});
|
||||
|
||||
it('vCard — 내 주소록 전체를 이름 차례로, 그룹 이름은 CATEGORIES · 파일 이름은 한국 날짜', async () => {
|
||||
const file = await service.exportContacts(ARA, 'vcard');
|
||||
const text = file.content.toString('utf8');
|
||||
|
||||
expect(file.fileName).toBe(
|
||||
`주소록-${kstDateString(new Date(now))}.vcf`,
|
||||
);
|
||||
expect(file.contentType).toBe('text/vcard; charset=utf-8');
|
||||
expect(file.count).toBe(2);
|
||||
expect(text.indexOf('FN:가나다')).toBeLessThan(
|
||||
text.indexOf('FN:박지훈'),
|
||||
);
|
||||
expect(text).toContain('UID:urn:uuid:c-pa\r\n');
|
||||
expect(text).toContain('CATEGORIES:거래처\r\n');
|
||||
expect(logged()).toContain(
|
||||
'주소록을 내보냈습니다: 형식=vcard, 연락처 2명',
|
||||
);
|
||||
expect(logged()).not.toMatch(/박지훈|가나다|hanbit|ara\.kim@/);
|
||||
});
|
||||
|
||||
it('CSV — 그룹 하나만(groupId), 파일 이름에 그룹 이름 · 없는 그룹은 RES_001', async () => {
|
||||
const file = await service.exportContacts(ARA, 'csv', 'g1');
|
||||
const rows = file.content.toString('utf8').split('\r\n');
|
||||
|
||||
expect(file.fileName).toBe(
|
||||
`주소록-거래처-${kstDateString(new Date(now))}.csv`,
|
||||
);
|
||||
expect(rows).toHaveLength(3);
|
||||
expect(rows[1]).toBe('박지훈,jihoon@hanbit.example,,,,,,,,,거래처');
|
||||
expect(
|
||||
(await failureOf(service.exportContacts(ARA, 'csv', 'nope'))).code,
|
||||
).toBe('RES_001');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -20,6 +20,19 @@ import {
|
||||
type WrittenChanges,
|
||||
} from './address-book';
|
||||
import { ContactBookCache } from './contact-book-cache';
|
||||
import {
|
||||
contactExportContent,
|
||||
contactExportContentType,
|
||||
contactExportFileName,
|
||||
type ExportedContact,
|
||||
} from './contact-export';
|
||||
import {
|
||||
deletionChunkSize,
|
||||
importDuplicateIndex,
|
||||
isExistingContact,
|
||||
planGroupJoins,
|
||||
type GroupRoom,
|
||||
} from './contact-import';
|
||||
import { cursorOffset, listScope, pageOf } from './contact-paging';
|
||||
import {
|
||||
ContactRestoreStore,
|
||||
@@ -38,8 +51,11 @@ import {
|
||||
CONTACT_RESTORE_TTL_MS,
|
||||
CONTACT_WRITE_ATTEMPTS,
|
||||
MAX_ADDRESS_BOOK_WEIGHT,
|
||||
MAX_CARD_CHANGES_PER_WRITE,
|
||||
MAX_CONTACT_DELETIONS,
|
||||
MAX_CONTACT_GROUP_MEMBERS,
|
||||
MAX_CONTACT_GROUPS,
|
||||
MAX_CONTACT_IMPORT_BATCH,
|
||||
MAX_CONTACTS,
|
||||
MAX_GROUP_MEMBERS_PER_REQUEST,
|
||||
} from './contacts.constants';
|
||||
@@ -48,6 +64,7 @@ import {
|
||||
type AddressBookCardUpdate,
|
||||
type AddressBookChanges,
|
||||
type AddressBookContactCard,
|
||||
type AddressBookWriteOptions,
|
||||
type ContactsProvider,
|
||||
type NewAddressBookCard,
|
||||
type RemovedCardOriginal,
|
||||
@@ -56,14 +73,20 @@ import type {
|
||||
Contact,
|
||||
ContactDeleted,
|
||||
ContactEmail,
|
||||
ContactExportFile,
|
||||
ContactExportFormat,
|
||||
ContactFields,
|
||||
ContactGroup,
|
||||
ContactGroupDeleted,
|
||||
ContactGroupMembersAdded,
|
||||
ContactImportCheck,
|
||||
ContactImportItem,
|
||||
ContactImportResult,
|
||||
ContactInput,
|
||||
ContactPatch,
|
||||
ContactPhone,
|
||||
ContactRestored,
|
||||
ContactsDeleted,
|
||||
ContactSuggestionLine,
|
||||
DirectoryEntry,
|
||||
} from './contacts.types';
|
||||
@@ -79,6 +102,8 @@ const NEW_CONTACT_KEY = 'contact';
|
||||
const NEW_GROUP_KEY = 'group';
|
||||
const NEW_MEMBER_KEY_PREFIX = 'member-';
|
||||
const RESTORED_KEY = 'restored';
|
||||
/** 가져오기의 한 사람 — 보낸 차례 번호를 붙인다 (import-0 …) */
|
||||
const IMPORT_KEY_PREFIX = 'import-';
|
||||
|
||||
const NO_CHANGES: AddressBookChanges = { create: [], update: [], destroy: [] };
|
||||
|
||||
@@ -105,6 +130,8 @@ interface ChangePlan<T> {
|
||||
readonly result: (after: BookView, written: WriteResult) => T;
|
||||
/** 바꾼 뒤 남기는 info 로그 (개수 · id 만 — 연락처의 내용은 남기지 않는다) */
|
||||
readonly log?: string;
|
||||
/** 엔진에 쓰는 선택 — 가져오기(일부만 받기) · 한꺼번에 지우기(원본 읽지 않기) (16단계) */
|
||||
readonly write?: AddressBookWriteOptions;
|
||||
}
|
||||
|
||||
/** 읽는 중인 주소록 — token 은 이 읽기의 표 (그사이 바꾸기가 끝나면 빠져서 옛 결과를 기억하지 않는다) */
|
||||
@@ -269,7 +296,9 @@ function joinGroups(groups: readonly BookGroup[], uid: string) {
|
||||
* 거절하면(busy) 다시 읽어 다시 정한다 (CONTACT_WRITE_ATTEMPTS 번 — 넘으면 MAIL_001). 한도(연락처 3,000 · 그룹 100 · 구성원 500 · 크기 800만 —
|
||||
* CONTACT_002) · 같은 이름의 그룹(CONTACT_001)도 그 판으로 본다. 쓴 뒤에는 같은 바꾸기를 기억한 주소록에 옮겨 다시 읽지 않는다 (address-book.ts)
|
||||
* - 지우기는 되돌리기 표를 준다 — 엔진 카드 전체를 5분 기억한다 (contact-restore-store.ts)
|
||||
* - 로그에는 개수 · id 만 — 이름 · 주소 · 전화번호 · 메모 · 되돌리기 표는 남기지 않는다
|
||||
* - 16단계 — 가져오기(확인 · 100명씩 넣기 — 화면이 준 uid 로 두 번 들어가지 않게, 엔진이 일부만 받아도 던지지 않는다 · contact-import.ts),
|
||||
* 한꺼번에 지우기(가져오기 [되돌리기] — 되돌리기 표 없음), 내보내기(vCard · CSV — contact-export.ts)
|
||||
* - 로그에는 개수 · id 만 — 이름 · 주소 · 전화번호 · 메모 · 되돌리기 표 · 파일 내용은 남기지 않는다
|
||||
*
|
||||
* ⚠️ 프로세스 메모리다(기억한 주소록 · 바꾸기 차례 · 되돌리기 표). 인스턴스를 늘려도 주소록은 틀리지 않는다 — 판이 그대로일 때만 쓴다.
|
||||
* 되돌리기 표는 공유 저장소로 옮겨야 한다(다른 인스턴스로 간 되돌리기는 404).
|
||||
@@ -288,12 +317,15 @@ export class ContactsService {
|
||||
private readonly loading = new Map<string, Loading>();
|
||||
/** 같은 직원의 바꾸기 차례 — 끝나면 빠진다 */
|
||||
private readonly writeQueues = new Map<string, Promise<void>>();
|
||||
/** 되돌리기 표의 시계 · 내보낸 파일 이름의 날짜 */
|
||||
private readonly now: () => number;
|
||||
|
||||
constructor(
|
||||
@Inject(CONTACTS_PROVIDER) private readonly provider: ContactsProvider,
|
||||
private readonly directory: DirectoryService,
|
||||
@Optional() @Inject(CONTACTS_CLOCK) clock?: () => number,
|
||||
) {
|
||||
this.now = clock ?? (() => Date.now());
|
||||
this.restorations = new ContactRestoreStore(
|
||||
{
|
||||
ttlMs: CONTACT_RESTORE_TTL_MS,
|
||||
@@ -302,7 +334,7 @@ export class ContactsService {
|
||||
maxTotalBytes: CONTACT_RESTORE_MAX_TOTAL_BYTES,
|
||||
tokenBytes: CONTACT_RESTORE_TOKEN_BYTES,
|
||||
},
|
||||
clock ?? (() => Date.now()),
|
||||
this.now,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -840,6 +872,261 @@ export class ContactsService {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/contact-import-checks — 가져올 연락처가 내 주소록에 이미 있는지 · 더 넣을 수 있는 수 (계약 3절 "가져오기" — 16단계). 만들지 않는다.
|
||||
* 이미 있음: 같은 uid 의 연락처(다시 보낸 묶음) 또는 contact-import.ts 의 규칙(이메일 · 이름 + 전화). 항목 검사(이름이나 이메일 · 같은 주소 · 같은 uid)는
|
||||
* 넣기와 같다 — 하나라도 걸리면 VAL_001. groupIds 는 보지 않는다(새 그룹은 넣기 직전에 만든다)
|
||||
*/
|
||||
async checkImport(
|
||||
user: AuthenticatedUser,
|
||||
items: readonly ContactImportItem[],
|
||||
): Promise<ContactImportCheck> {
|
||||
this.assertImportItems(items);
|
||||
const book = await this.freshBook(user.email);
|
||||
const index = importDuplicateIndex(
|
||||
book.contacts.map((entry) => entry.contact),
|
||||
);
|
||||
return {
|
||||
items: items.map((item) => ({
|
||||
uid: item.uid,
|
||||
status:
|
||||
book.contactsByUid.has(item.uid) || isExistingContact(index, item)
|
||||
? 'exists'
|
||||
: 'new',
|
||||
})),
|
||||
remaining: Math.max(0, MAX_CONTACTS - book.contacts.length),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/contact-imports — 가져온 연락처를 엔진 요청 하나로 만든다 (계약 3절 "가져오기" — 16단계).
|
||||
* - 줄마다 화면이 준 uid 로 만든다. 같은 uid 의 연락처가 이미 있으면(다시 보낸 묶음 · 앞 시도가 들어감) 만들지 않고 그 연락처를 돌려준다 — 빠진 그룹만 마저 넣는다
|
||||
* - 연락처 수 · 크기 한도는 묶음 전체로 본다 — 넘으면 CONTACT_002, 아무것도 만들지 않는다. 없는 그룹은 RES_001
|
||||
* - 그룹 구성원 500명을 넘기는 넣기는 그 그룹만 빼고(skippedGroupIds) 만든다 — 실패가 아니다
|
||||
* - 엔진이 일부만 받으면 받은 것만 돌려준다(빠진 uid 는 화면이 "넣지 못함"으로) — 던지지 않는다. 그룹 카드가 쓰는 uid 도 만들지 않는다(엔진이 받지 않는다)
|
||||
* - 겹침(같은 이메일 등)은 보지 않는다 — 확인(checkImport)의 몫
|
||||
*/
|
||||
async importContacts(
|
||||
actor: AuthenticatedUser,
|
||||
items: readonly ContactImportItem[],
|
||||
): Promise<ContactImportResult> {
|
||||
this.assertImportItems(items);
|
||||
const result = await this.change(actor, (book) => {
|
||||
const groups = new Map<string, BookGroup>();
|
||||
for (const item of items) {
|
||||
for (const groupId of item.groupIds) {
|
||||
if (!groups.has(groupId)) {
|
||||
groups.set(groupId, this.requireGroup(book, groupId));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 이미 있는 같은 uid 의 연락처 */
|
||||
const landed = new Map<string, BookContact>();
|
||||
/** uid → 이 바꾸기 안의 key */
|
||||
const keys = new Map<string, string>();
|
||||
items.forEach((item, index) => {
|
||||
const existing = book.contactsByUid.get(item.uid)?.[0];
|
||||
if (existing) landed.set(item.uid, existing);
|
||||
else if (!book.uidsInUse.has(item.uid)) {
|
||||
keys.set(item.uid, `${IMPORT_KEY_PREFIX}${index}`);
|
||||
}
|
||||
});
|
||||
if (book.contacts.length + keys.size > MAX_CONTACTS) {
|
||||
throw overLimit('contacts-limit', {
|
||||
contacts: book.contacts.length,
|
||||
creating: keys.size,
|
||||
});
|
||||
}
|
||||
|
||||
const rooms = new Map<string, GroupRoom>(
|
||||
[...groups].map(([id, group]) => [
|
||||
id,
|
||||
{
|
||||
memberUids: new Set(group.card.memberUids),
|
||||
memberCount: group.group.memberCount,
|
||||
},
|
||||
]),
|
||||
);
|
||||
const joins = planGroupJoins(
|
||||
rooms,
|
||||
items.filter((item) => landed.has(item.uid) || keys.has(item.uid)),
|
||||
MAX_CONTACT_GROUP_MEMBERS,
|
||||
);
|
||||
const update: AddressBookCardUpdate[] = [];
|
||||
for (const [groupId, group] of groups) {
|
||||
const adding = joins.adds.get(groupId);
|
||||
if (adding && adding.length > 0) {
|
||||
update.push({
|
||||
kind: 'group',
|
||||
id: groupId,
|
||||
memberUids: withMembers(group, adding),
|
||||
});
|
||||
}
|
||||
}
|
||||
return {
|
||||
changes: {
|
||||
create: items.flatMap((item): NewAddressBookCard[] => {
|
||||
const key = keys.get(item.uid);
|
||||
return key === undefined
|
||||
? []
|
||||
: [
|
||||
{
|
||||
kind: 'contact',
|
||||
key,
|
||||
uid: item.uid,
|
||||
fields: fieldsOf(item),
|
||||
},
|
||||
];
|
||||
}),
|
||||
update,
|
||||
destroy: [],
|
||||
},
|
||||
write: { allowPartialCreate: true },
|
||||
result: (_after, written) => ({
|
||||
items: items.flatMap((item) => {
|
||||
const key = keys.get(item.uid);
|
||||
const id =
|
||||
landed.get(item.uid)?.card.id ??
|
||||
(key === undefined ? undefined : written.createdIds.get(key));
|
||||
return id === undefined
|
||||
? []
|
||||
: [
|
||||
{
|
||||
uid: item.uid,
|
||||
id,
|
||||
skippedGroupIds: [...(joins.skipped.get(item.uid) ?? [])],
|
||||
},
|
||||
];
|
||||
}),
|
||||
}),
|
||||
};
|
||||
});
|
||||
const skipped = result.items.reduce(
|
||||
(sum, item) => sum + item.skippedGroupIds.length,
|
||||
0,
|
||||
);
|
||||
this.logger.log(
|
||||
`${traceTag()} 연락처를 가져왔습니다: 보낸 ${items.length}명 가운데 ${result.items.length}명(이미 있던 같은 uid 포함), 넣지 못함 ${items.length - result.items.length}명, 구성원 한도로 그룹에 넣지 못함 ${skipped}건 (user=${maskEmail(normalizeUser(actor.email))})`,
|
||||
);
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/contact-deletions — 연락처 여러 개를 영구 삭제한다 (가져오기 [되돌리기] — 계약 3절 "가져오기" — 16단계). 되돌리기 표는 주지 않는다.
|
||||
* 지운 연락처를 가리키는 그룹 구성원도 함께 뺀다(같은 uid 의 다른 카드가 남으면 그대로). 내 주소록에 없는 id(그룹 id 포함)는 notFoundIds.
|
||||
* 엔진 한 요청에 담는 카드 수(지울 연락처 + 고칠 그룹)가 상한을 넘으면 앞에서부터 나눠 쓴다 — 중간에 실패하면 앞 몫은 지워진 채다(다시 보내면 notFoundIds)
|
||||
*/
|
||||
async removeContacts(
|
||||
actor: AuthenticatedUser,
|
||||
ids: readonly string[],
|
||||
): Promise<ContactsDeleted> {
|
||||
if (ids.length < 1 || ids.length > MAX_CONTACT_DELETIONS) {
|
||||
throw new BusinessException('VAL_001', {
|
||||
context: { reason: 'contact-deletions-count', requested: ids.length },
|
||||
});
|
||||
}
|
||||
if (new Set(ids).size !== ids.length) {
|
||||
throw new BusinessException('VAL_001', {
|
||||
context: { reason: 'contact-deletions-duplicate' },
|
||||
});
|
||||
}
|
||||
const deleted = new Set<string>();
|
||||
let pending: readonly string[] = ids;
|
||||
while (pending.length > 0) {
|
||||
const batch = pending;
|
||||
/** 이 몫에서 지우려 했던 id — 엔진이 바쁘다고 했는데 들어갔으면 다음 시도에서 없어진 것을 지운 것으로 센다 */
|
||||
const tried = new Set<string>();
|
||||
const step = await this.change(actor, (book, attempt) => {
|
||||
const present = batch.filter((id) => book.contactsById.has(id));
|
||||
const landed = attempt.uncertain
|
||||
? batch.filter((id) => tried.has(id) && !book.contactsById.has(id))
|
||||
: [];
|
||||
const groupIdsByUid = new Map<string, string[]>();
|
||||
for (const group of book.groups) {
|
||||
for (const uid of group.card.memberUids) {
|
||||
const groupIds = groupIdsByUid.get(uid) ?? [];
|
||||
groupIds.push(group.card.id);
|
||||
groupIdsByUid.set(uid, groupIds);
|
||||
}
|
||||
}
|
||||
const size = deletionChunkSize(
|
||||
present.map((id) => {
|
||||
const uid = book.contactsById.get(id)?.card.uid ?? null;
|
||||
return uid === null ? [] : (groupIdsByUid.get(uid) ?? []);
|
||||
}),
|
||||
MAX_CARD_CHANGES_PER_WRITE,
|
||||
);
|
||||
const now = present.slice(0, size);
|
||||
for (const id of now) tried.add(id);
|
||||
const update = this.leavingGroups(book, now);
|
||||
return {
|
||||
changes: { create: [], update, destroy: now },
|
||||
write: { withoutOriginals: true },
|
||||
result: () => ({
|
||||
deleted: [...landed, ...now],
|
||||
rest: present.slice(size),
|
||||
}),
|
||||
log: `연락처를 한꺼번에 지웠습니다: ${now.length}명, 그룹에서 뺌 ${update.length}개`,
|
||||
};
|
||||
});
|
||||
for (const id of step.deleted) deleted.add(id);
|
||||
pending = step.rest;
|
||||
}
|
||||
return {
|
||||
deletedIds: ids.filter((id) => deleted.has(id)),
|
||||
notFoundIds: ids.filter((id) => !deleted.has(id)),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/contact-exports — 내 주소록 전체 또는 그룹 하나를 vCard 3.0 · CSV 로 (계약 3절 "내보내기" — 16단계).
|
||||
* 이름 차례, 우리 칸만(contact-export.ts). 사내 목록은 넣지 않는다. 없는 그룹은 RES_001. 로그에는 형식 · 개수 · 그룹 id 만
|
||||
*/
|
||||
async exportContacts(
|
||||
user: AuthenticatedUser,
|
||||
format: ContactExportFormat,
|
||||
groupId?: string,
|
||||
): Promise<ContactExportFile> {
|
||||
const book = await this.freshBook(user.email);
|
||||
const group =
|
||||
groupId === undefined ? null : this.requireGroup(book, groupId);
|
||||
const entries =
|
||||
group === null
|
||||
? book.contacts
|
||||
: group.memberIds.flatMap((id) => {
|
||||
const entry = book.contactsById.get(id);
|
||||
return entry ? [entry] : [];
|
||||
});
|
||||
const groupNames = new Map(
|
||||
book.groups.map((entry) => [entry.card.id, entry.card.name]),
|
||||
);
|
||||
const contacts = entries.map(
|
||||
(entry): ExportedContact => ({
|
||||
...fieldsOf(entry.contact),
|
||||
uid: entry.card.uid,
|
||||
groupNames: entry.contact.groupIds.flatMap((id) => {
|
||||
const name = groupNames.get(id);
|
||||
return name ? [name] : [];
|
||||
}),
|
||||
}),
|
||||
);
|
||||
const content = Buffer.from(contactExportContent(format, contacts), 'utf8');
|
||||
this.logger.log(
|
||||
`${traceTag()} 주소록을 내보냈습니다: 형식=${format}, 연락처 ${contacts.length}명, 범위=${group === null ? '전체' : `그룹 ${group.card.id}`} (user=${maskEmail(normalizeUser(user.email))})`,
|
||||
);
|
||||
return {
|
||||
fileName: contactExportFileName(
|
||||
format,
|
||||
group?.card.name ?? null,
|
||||
new Date(this.now()),
|
||||
),
|
||||
contentType: contactExportContentType(format),
|
||||
content,
|
||||
count: contacts.length,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/directory-entries — 사내 목록(directory.service.ts)에서 excludeGroupId 면 그 그룹(이 직원의 주소록) 구성원이 가진 주소를 뺀다.
|
||||
* 그룹은 커서를 확인한 뒤에 찾는다 — 없는 그룹이면 404 RES_001
|
||||
@@ -1071,6 +1358,7 @@ export class ContactsService {
|
||||
user,
|
||||
book.version,
|
||||
planned.changes,
|
||||
planned.write,
|
||||
);
|
||||
} catch (error) {
|
||||
// 일부만 바뀌었을 수 있다 — 기억한 것을 버리고 다음 요청은 다시 읽는다
|
||||
@@ -1175,6 +1463,57 @@ export class ContactsService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 가져오기 · 확인의 묶음 — 1~100명, 사람마다 연락처 추가와 같은 검사(이름이나 이메일 · 같은 주소), 묶음 안의 같은 uid 는 안 된다.
|
||||
* 하나라도 걸리면 엔진에 묻기 전에 VAL_001 (화면이 같은 규칙으로 미리 거른다)
|
||||
*/
|
||||
private assertImportItems(items: readonly ContactImportItem[]): void {
|
||||
if (items.length < 1 || items.length > MAX_CONTACT_IMPORT_BATCH) {
|
||||
throw new BusinessException('VAL_001', {
|
||||
context: { reason: 'contact-import-count', requested: items.length },
|
||||
});
|
||||
}
|
||||
const uids = new Set<string>();
|
||||
for (const item of items) {
|
||||
this.assertNameOrEmail(item);
|
||||
this.assertDistinctEmails(item.emails);
|
||||
if (uids.has(item.uid)) {
|
||||
throw new BusinessException('VAL_001', {
|
||||
context: { reason: 'contact-import-duplicate-uid' },
|
||||
});
|
||||
}
|
||||
uids.add(item.uid);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 연락처들을 지울 때 고칠 그룹 — 지우는 카드의 uid 를 구성원에서 뺀다. 같은 uid 의 다른 카드가 남으면(이번에 지우지 않는 카드) 그 uid 는 그대로 둔다.
|
||||
* 그룹 구성원을 한 번 훑는다
|
||||
*/
|
||||
private leavingGroups(
|
||||
book: BookView,
|
||||
ids: readonly string[],
|
||||
): AddressBookCardUpdate[] {
|
||||
const removing = new Set(ids);
|
||||
const uids = new Set<string>();
|
||||
for (const id of ids) {
|
||||
const uid = book.contactsById.get(id)?.card.uid ?? null;
|
||||
if (uid === null) continue;
|
||||
const owners = book.contactsByUid.get(uid) ?? [];
|
||||
if (owners.every((owner) => removing.has(owner.card.id))) uids.add(uid);
|
||||
}
|
||||
if (uids.size === 0) return [];
|
||||
return book.groups
|
||||
.filter((group) => group.card.memberUids.some((uid) => uids.has(uid)))
|
||||
.map(
|
||||
(group): AddressBookCardUpdate => ({
|
||||
kind: 'group',
|
||||
id: group.card.id,
|
||||
memberUids: withMembers(group, [], uids),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/** 크기 한도 — 이 바꾸기로 주소록이 800만을 넘게 커지면 CONTACT_002 (줄이는 쓰기 · 지우기는 늘 된다) */
|
||||
private assertWeight(book: BookView, changes: AddressBookChanges): void {
|
||||
const after = weightAfterChanges(book, changes);
|
||||
|
||||
@@ -104,6 +104,70 @@ export interface ContactGroupMembersAdded {
|
||||
readonly createdCount: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 가져오기의 한 사람 (계약 3절 ContactImportItem — 16단계) — 연락처 추가 본문(ContactInput)과 같은 칸 + 화면이 만든 uid.
|
||||
* 같은 uid 가 이미 주소록에 있으면(다시 보낸 요청) 새로 만들지 않고 그 연락처를 돌려준다
|
||||
*/
|
||||
export interface ContactImportItem extends ContactInput {
|
||||
/** `urn:uuid:` + 소문자 UUID */
|
||||
readonly uid: string;
|
||||
}
|
||||
|
||||
/** 가져오기 확인의 판정 — new 넣을 연락처 · exists 이미 있는 연락처(계약 3절 "가져오기") */
|
||||
export type ContactImportStatus = 'new' | 'exists';
|
||||
|
||||
export const CONTACT_IMPORT_STATUSES: readonly ContactImportStatus[] = [
|
||||
'new',
|
||||
'exists',
|
||||
];
|
||||
|
||||
/** POST /api/contact-import-checks 의 응답 */
|
||||
export interface ContactImportCheck {
|
||||
/** 보낸 차례 그대로 한 줄씩 */
|
||||
readonly items: readonly {
|
||||
readonly uid: string;
|
||||
readonly status: ContactImportStatus;
|
||||
}[];
|
||||
/** 더 넣을 수 있는 연락처 수 — 3,000 − 지금 연락처 수 (0 아래로는 내려가지 않는다) */
|
||||
readonly remaining: number;
|
||||
}
|
||||
|
||||
/** 가져온 연락처 하나 — 화면이 준 uid · 엔진의 카드 id · 구성원 500명이 차서 넣지 못한 그룹 */
|
||||
export interface ContactImported {
|
||||
readonly uid: string;
|
||||
readonly id: string;
|
||||
readonly skippedGroupIds: readonly string[];
|
||||
}
|
||||
|
||||
/** POST /api/contact-imports 의 응답 — 넣은(또는 이미 있던 같은 uid 의) 연락처만, 보낸 차례 그대로. 빠진 uid 는 엔진이 받지 않은 것 */
|
||||
export interface ContactImportResult {
|
||||
readonly items: readonly ContactImported[];
|
||||
}
|
||||
|
||||
/** POST /api/contact-deletions 의 응답 — 보낸 차례 그대로 */
|
||||
export interface ContactsDeleted {
|
||||
readonly deletedIds: readonly string[];
|
||||
/** 내 주소록에 없는 id (이미 지움 · 그룹 id 포함) */
|
||||
readonly notFoundIds: readonly string[];
|
||||
}
|
||||
|
||||
/** 내보내기 형식 — vCard 3.0 · CSV(UTF-8 BOM) */
|
||||
export type ContactExportFormat = 'vcard' | 'csv';
|
||||
|
||||
export const CONTACT_EXPORT_FORMATS: readonly ContactExportFormat[] = [
|
||||
'vcard',
|
||||
'csv',
|
||||
];
|
||||
|
||||
/** 내보낼 파일 — 이름 · 형식 · 내용 */
|
||||
export interface ContactExportFile {
|
||||
readonly fileName: string;
|
||||
readonly contentType: string;
|
||||
readonly content: Buffer;
|
||||
/** 담은 연락처 수 (로그) */
|
||||
readonly count: number;
|
||||
}
|
||||
|
||||
/** 사내 목록의 종류 — 직원(개인 계정) · 공용 주소(그룹 계정) */
|
||||
export type DirectoryEntryKind = 'person' | 'shared';
|
||||
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { IsIn } from 'class-validator';
|
||||
|
||||
import {
|
||||
CONTACT_EXPORT_FORMATS,
|
||||
type ContactExportFormat,
|
||||
} from '@/modules/contacts/contacts.types';
|
||||
import { ContactGroupIdQuery } from './contact.dto';
|
||||
|
||||
/** GET /api/contact-exports 의 쿼리 (계약 3절 "내보내기" — 16단계) — 모르는 형식 · 쿼리는 400 VAL_001 */
|
||||
export class ContactExportQueryDto {
|
||||
@ApiProperty({
|
||||
description:
|
||||
'vcard — vCard 3.0(.vcf, 다른 메일 · 휴대폰으로 옮길 때) · csv — CSV(UTF-8 BOM, 엑셀에서 볼 때)',
|
||||
enum: [...CONTACT_EXPORT_FORMATS],
|
||||
example: 'vcard',
|
||||
})
|
||||
@IsIn(CONTACT_EXPORT_FORMATS, {
|
||||
message: '내보낼 형식을 다시 골라 주세요.',
|
||||
})
|
||||
format!: ContactExportFormat;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description:
|
||||
'이 그룹의 구성원만 — 없으면 내 주소록 전체. 없는 그룹이면 404 RES_001',
|
||||
example: 'jy',
|
||||
})
|
||||
@ContactGroupIdQuery()
|
||||
groupId?: string;
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
import { plainToInstance } from 'class-transformer';
|
||||
import { validate, type ValidationError } from 'class-validator';
|
||||
|
||||
import { createValidationPipe } from '@/app.setup';
|
||||
import {
|
||||
MAX_CONTACT_DELETIONS,
|
||||
MAX_CONTACT_IMPORT_BATCH,
|
||||
} from '@/modules/contacts/contacts.constants';
|
||||
import { ContactExportQueryDto } from './contact-export.dto';
|
||||
import {
|
||||
ContactDeletionDto,
|
||||
ContactImportBatchDto,
|
||||
} from './contact-import.dto';
|
||||
|
||||
/** 걸린 규칙 이름 — 중첩된 칸까지 */
|
||||
function constraintNames(errors: ValidationError[]): string[] {
|
||||
return errors.flatMap((error) => [
|
||||
...Object.keys(error.constraints ?? {}),
|
||||
...constraintNames(error.children ?? []),
|
||||
]);
|
||||
}
|
||||
|
||||
/** 전역 ValidationPipe 와 같은 옵션으로 (stopAtFirstError 는 고른다 — 빼면 걸린 규칙을 모두 본다) */
|
||||
async function namesOf<T extends object>(
|
||||
type: new () => T,
|
||||
body: object,
|
||||
stopAtFirstError = false,
|
||||
): Promise<string[]> {
|
||||
const dto = plainToInstance(type, body);
|
||||
return constraintNames(
|
||||
await validate(dto, {
|
||||
whitelist: true,
|
||||
forbidNonWhitelisted: true,
|
||||
stopAtFirstError,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
const UID = 'urn:uuid:3f1c0a2e-0f1b-4f2a-9a1e-6c9f0a1b2c3d';
|
||||
|
||||
const person = (overrides: object = {}) => ({
|
||||
name: '박지훈',
|
||||
emails: [{ address: 'jihoon.park@hanbit.example', label: 'work' }],
|
||||
phones: [{ number: '010-3192-5540', label: 'mobile' }],
|
||||
company: '한빛상사',
|
||||
department: '구매팀',
|
||||
title: '과장',
|
||||
note: null,
|
||||
groupIds: [],
|
||||
uid: UID,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe('가져오기 본문 (POST /api/contact-import-checks · /api/contact-imports — 16단계)', () => {
|
||||
it('맞는 묶음 — 사람마다 연락처 추가 본문 + uid, 글은 앞뒤 공백을 지운다', async () => {
|
||||
expect(
|
||||
await namesOf(ContactImportBatchDto, { contacts: [person()] }),
|
||||
).toEqual([]);
|
||||
const dto = plainToInstance(ContactImportBatchDto, {
|
||||
contacts: [person({ name: ' 김아라 ', company: ' ' })],
|
||||
});
|
||||
expect(await validate(dto)).toEqual([]);
|
||||
expect(dto.contacts[0].name).toBe('김아라');
|
||||
expect(dto.contacts[0].company).toBeNull();
|
||||
});
|
||||
|
||||
it('uid — urn:uuid: + 소문자 UUID 만', async () => {
|
||||
for (const uid of [
|
||||
UID.toUpperCase(),
|
||||
UID.replace('urn:uuid:', ''),
|
||||
`${UID}0`,
|
||||
'urn:uuid:not-a-uuid',
|
||||
7,
|
||||
null,
|
||||
]) {
|
||||
expect(
|
||||
await namesOf(ContactImportBatchDto, { contacts: [person({ uid })] }),
|
||||
).not.toEqual([]);
|
||||
}
|
||||
const missing: Record<string, unknown> = person();
|
||||
delete missing.uid;
|
||||
expect(
|
||||
await namesOf(ContactImportBatchDto, { contacts: [missing] }),
|
||||
).not.toEqual([]);
|
||||
});
|
||||
|
||||
it(`묶음 — 배열 · 1~${MAX_CONTACT_IMPORT_BATCH}명, 사람마다 연락처 추가와 같은 칸 검사 · 모르는 칸은 안 된다`, async () => {
|
||||
for (const contacts of [
|
||||
null,
|
||||
'x',
|
||||
[],
|
||||
Array.from({ length: MAX_CONTACT_IMPORT_BATCH + 1 }, () => person()),
|
||||
[person({ emails: [{ address: 'not-an-email', label: 'work' }] })],
|
||||
[person({ phones: [{ number: '내선 12', label: 'work' }] })],
|
||||
[person({ note: '가'.repeat(1_001) })],
|
||||
[person({ extra: true })],
|
||||
]) {
|
||||
expect(await namesOf(ContactImportBatchDto, { contacts })).not.toEqual(
|
||||
[],
|
||||
);
|
||||
}
|
||||
expect(
|
||||
await namesOf(ContactImportBatchDto, {
|
||||
contacts: Array.from({ length: MAX_CONTACT_IMPORT_BATCH }, () =>
|
||||
person(),
|
||||
),
|
||||
}),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it('개수가 넘으면 사람마다의 검사를 하지 않는다 — 형식 → 개수 → 중첩 차례 (stopAtFirstError)', async () => {
|
||||
const names = await namesOf(
|
||||
ContactImportBatchDto,
|
||||
{
|
||||
contacts: Array.from({ length: MAX_CONTACT_IMPORT_BATCH + 1 }, () =>
|
||||
person({ emails: [{ address: 'bad', label: 'work' }] }),
|
||||
),
|
||||
},
|
||||
true,
|
||||
);
|
||||
expect(names).toEqual(['arrayMaxSize']);
|
||||
const pipe = createValidationPipe();
|
||||
await expect(
|
||||
pipe.transform(
|
||||
{ contacts: [] },
|
||||
{ type: 'body', metatype: ContactImportBatchDto },
|
||||
),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
});
|
||||
});
|
||||
|
||||
describe('한꺼번에 지우기 본문 (POST /api/contact-deletions — 16단계)', () => {
|
||||
it(`id 1~${MAX_CONTACT_DELETIONS}개 · id 모양 · 겹치지 않게`, async () => {
|
||||
expect(await namesOf(ContactDeletionDto, { ids: ['jw', 'jx'] })).toEqual(
|
||||
[],
|
||||
);
|
||||
for (const ids of [
|
||||
null,
|
||||
[],
|
||||
Array.from({ length: MAX_CONTACT_DELETIONS + 1 }, (_, n) => `c${n}`),
|
||||
['jw', 'jw'],
|
||||
['../x'],
|
||||
[7],
|
||||
]) {
|
||||
expect(await namesOf(ContactDeletionDto, { ids })).not.toEqual([]);
|
||||
}
|
||||
expect(
|
||||
await namesOf(ContactDeletionDto, {
|
||||
ids: Array.from({ length: MAX_CONTACT_DELETIONS }, (_, n) => `c${n}`),
|
||||
}),
|
||||
).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('내보내기 쿼리 (GET /api/contact-exports — 16단계)', () => {
|
||||
it('format 은 vcard · csv 만(없으면 안 된다) · groupId 는 id 모양 · 모르는 쿼리는 안 된다', async () => {
|
||||
expect(await namesOf(ContactExportQueryDto, { format: 'vcard' })).toEqual(
|
||||
[],
|
||||
);
|
||||
expect(
|
||||
await namesOf(ContactExportQueryDto, { format: 'csv', groupId: 'jy' }),
|
||||
).toEqual([]);
|
||||
for (const query of [
|
||||
{},
|
||||
{ format: 'xlsx' },
|
||||
{ format: 'VCARD' },
|
||||
{ format: ['vcard', 'csv'] },
|
||||
{ format: 'csv', groupId: '../x' },
|
||||
{ format: 'csv', limit: '10' },
|
||||
]) {
|
||||
expect(await namesOf(ContactExportQueryDto, query)).not.toEqual([]);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,202 @@
|
||||
import { applyDecorators } from '@nestjs/common';
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { Type } from 'class-transformer';
|
||||
import {
|
||||
ArrayMaxSize,
|
||||
ArrayMinSize,
|
||||
IsArray,
|
||||
IsString,
|
||||
Matches,
|
||||
ValidateNested,
|
||||
} from 'class-validator';
|
||||
|
||||
import { ArrayDistinct } from '@/common/decorators/array-distinct.decorator';
|
||||
import { MaxCodeUnits } from '@/common/decorators/code-units.decorator';
|
||||
import {
|
||||
CONTACT_ID_PATTERN,
|
||||
CONTACT_IMPORT_UID_PATTERN,
|
||||
MAX_CONTACT_DELETIONS,
|
||||
MAX_CONTACT_IMPORT_BATCH,
|
||||
} from '@/modules/contacts/contacts.constants';
|
||||
import {
|
||||
CONTACT_IMPORT_STATUSES,
|
||||
type ContactImportCheck,
|
||||
type ContactImported,
|
||||
type ContactImportItem,
|
||||
type ContactImportResult,
|
||||
type ContactImportStatus,
|
||||
type ContactsDeleted,
|
||||
} from '@/modules/contacts/contacts.types';
|
||||
import { ContactInputDto } from './contact.dto';
|
||||
|
||||
/**
|
||||
* 가져오기 · 한꺼번에 지우기 — 본문과 응답 (계약 3절 "가져오기 · 내보내기" — 16단계).
|
||||
* 한 사람(ContactImportItem)은 연락처 추가 본문(ContactInputDto)과 같은 검사에 uid 를 더한 것이다. 이름이나 이메일 · 같은 연락처 안의 같은 주소 ·
|
||||
* 묶음 안의 같은 uid 는 서비스가 본다(모두 400 VAL_001). 칸의 검사는 적은 차례대로 돈다(contact.dto.ts 머리말 — 형식 → 길이 · 개수 → 모양)
|
||||
*/
|
||||
|
||||
/** `urn:uuid:` + 소문자 UUID 의 길이 */
|
||||
const IMPORT_UID_LENGTH = 45;
|
||||
|
||||
function ImportUid(): PropertyDecorator {
|
||||
return applyDecorators(
|
||||
IsString({ message: '가져올 연락처의 표시가 올바르지 않습니다.' }),
|
||||
MaxCodeUnits(IMPORT_UID_LENGTH, {
|
||||
message: '가져올 연락처의 표시가 올바르지 않습니다.',
|
||||
}),
|
||||
Matches(CONTACT_IMPORT_UID_PATTERN, {
|
||||
message: '가져올 연락처의 표시가 올바르지 않습니다.',
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/** 가져올 연락처 목록 — 형식 → 개수(1~100) → 사람마다(중첩) 차례로 (개수가 넘으면 중첩 검사를 하지 않는다 — stopAtFirstError) */
|
||||
function ImportList(): PropertyDecorator {
|
||||
return applyDecorators(
|
||||
IsArray({ message: '가져올 연락처 목록 형식이 올바르지 않습니다.' }),
|
||||
ArrayMinSize(1, { message: '가져올 연락처를 하나 이상 보내 주세요.' }),
|
||||
ArrayMaxSize(MAX_CONTACT_IMPORT_BATCH, {
|
||||
message: `한 번에 ${MAX_CONTACT_IMPORT_BATCH}명까지 보낼 수 있습니다.`,
|
||||
}),
|
||||
ValidateNested({ each: true }),
|
||||
Type(() => ContactImportItemDto),
|
||||
);
|
||||
}
|
||||
|
||||
/** 지울 연락처 id 목록 — 형식 → 개수(1~500) → id 모양 → 겹침 */
|
||||
function DeletionIdList(): PropertyDecorator {
|
||||
return applyDecorators(
|
||||
IsArray({ message: '지울 연락처 목록 형식이 올바르지 않습니다.' }),
|
||||
ArrayMinSize(1, { message: '지울 연락처를 하나 이상 보내 주세요.' }),
|
||||
ArrayMaxSize(MAX_CONTACT_DELETIONS, {
|
||||
message: `한 번에 ${MAX_CONTACT_DELETIONS}명까지 지울 수 있습니다.`,
|
||||
}),
|
||||
Matches(CONTACT_ID_PATTERN, {
|
||||
each: true,
|
||||
message: '지울 연락처를 다시 골라 주세요.',
|
||||
}),
|
||||
ArrayDistinct({ message: '같은 연락처를 두 번 보낼 수 없습니다.' }),
|
||||
);
|
||||
}
|
||||
|
||||
export class ContactImportItemDto
|
||||
extends ContactInputDto
|
||||
implements ContactImportItem
|
||||
{
|
||||
@ApiProperty({
|
||||
description:
|
||||
'화면이 사람마다 만든 uid — `urn:uuid:` + 소문자 UUID. 엔진 카드의 uid 가 된다. 같은 uid 의 연락처가 이미 있으면(다시 보낸 묶음) 새로 만들지 않는다. 한 묶음 안에서 겹치면 400 VAL_001',
|
||||
example: 'urn:uuid:3f1c0a2e-0f1b-4f2a-9a1e-6c9f0a1b2c3d',
|
||||
maxLength: IMPORT_UID_LENGTH,
|
||||
})
|
||||
@ImportUid()
|
||||
uid!: string;
|
||||
}
|
||||
|
||||
/** 가져오기 · 확인의 본문 — 1~100명 */
|
||||
export class ContactImportBatchDto {
|
||||
@ApiProperty({
|
||||
type: [ContactImportItemDto],
|
||||
minItems: 1,
|
||||
maxItems: MAX_CONTACT_IMPORT_BATCH,
|
||||
description: `가져올 연락처 — 1~${MAX_CONTACT_IMPORT_BATCH}명. 칸 규칙은 연락처 추가(POST /api/contacts)와 같다`,
|
||||
})
|
||||
@ImportList()
|
||||
contacts!: ContactImportItemDto[];
|
||||
}
|
||||
|
||||
export class ContactImportCheckItemDto {
|
||||
@ApiProperty({
|
||||
description: '보낸 uid',
|
||||
example: 'urn:uuid:3f1c0a2e-0f1b-4f2a-9a1e-6c9f0a1b2c3d',
|
||||
})
|
||||
uid!: string;
|
||||
|
||||
@ApiProperty({
|
||||
description:
|
||||
'new 넣을 연락처 · exists 이미 있는 연락처 — 같은 이메일(앞뒤 공백 · 대소문자 무시)이 내 주소록에 있음, 이메일이 없으면 이름(앞뒤 공백을 뺀 글)과 전화번호의 숫자 모음이 모두 같은 연락처가 있음, 또는 같은 uid 의 연락처가 있음',
|
||||
enum: [...CONTACT_IMPORT_STATUSES],
|
||||
example: 'new',
|
||||
})
|
||||
status!: ContactImportStatus;
|
||||
}
|
||||
|
||||
/** POST /api/contact-import-checks → 200 */
|
||||
export class ContactImportCheckDto implements ContactImportCheck {
|
||||
@ApiProperty({
|
||||
type: [ContactImportCheckItemDto],
|
||||
description: '보낸 차례 그대로 한 줄씩',
|
||||
})
|
||||
items!: ContactImportCheckItemDto[];
|
||||
|
||||
@ApiProperty({
|
||||
description:
|
||||
'더 넣을 수 있는 연락처 수 — 3,000 − 지금 연락처 수 (0 아래로 내려가지 않는다). 화면은 이것으로 넣을 수를 미리 줄인다',
|
||||
example: 2_660,
|
||||
})
|
||||
remaining!: number;
|
||||
}
|
||||
|
||||
export class ContactImportedDto implements ContactImported {
|
||||
@ApiProperty({
|
||||
description: '보낸 uid',
|
||||
example: 'urn:uuid:3f1c0a2e-0f1b-4f2a-9a1e-6c9f0a1b2c3d',
|
||||
})
|
||||
uid!: string;
|
||||
|
||||
@ApiProperty({
|
||||
description:
|
||||
'연락처 id — 새로 만든 카드, 또는 같은 uid 로 이미 있던 카드(다시 보낸 묶음)',
|
||||
example: 'jw',
|
||||
})
|
||||
id!: string;
|
||||
|
||||
@ApiProperty({
|
||||
type: [String],
|
||||
description:
|
||||
'구성원 500명이 차서 넣지 못한 그룹 id (보낸 groupIds 차례) — 실패가 아니다. 연락처는 만들었고 나머지 그룹에는 넣었다',
|
||||
example: [],
|
||||
})
|
||||
skippedGroupIds!: string[];
|
||||
}
|
||||
|
||||
/** POST /api/contact-imports → 201 */
|
||||
export class ContactImportResultDto implements ContactImportResult {
|
||||
@ApiProperty({
|
||||
type: [ContactImportedDto],
|
||||
description:
|
||||
'넣은 연락처(같은 uid 로 이미 있던 것 포함) — 보낸 차례 그대로. 보낸 uid 가 빠져 있으면 메일 서버가 받지 않은 것이다(화면은 "넣지 못함"으로)',
|
||||
})
|
||||
items!: ContactImportedDto[];
|
||||
}
|
||||
|
||||
/** POST /api/contact-deletions 의 본문 — 1~500개 · 겹치지 않게 */
|
||||
export class ContactDeletionDto {
|
||||
@ApiProperty({
|
||||
type: [String],
|
||||
minItems: 1,
|
||||
maxItems: MAX_CONTACT_DELETIONS,
|
||||
description: `지울 연락처 id — 1~${MAX_CONTACT_DELETIONS}개, 겹치지 않게(겹치면 400 VAL_001)`,
|
||||
example: ['jw', 'jx'],
|
||||
})
|
||||
@DeletionIdList()
|
||||
ids!: string[];
|
||||
}
|
||||
|
||||
/** POST /api/contact-deletions → 200 */
|
||||
export class ContactsDeletedDto implements ContactsDeleted {
|
||||
@ApiProperty({
|
||||
type: [String],
|
||||
description: '지운 연락처 id — 보낸 차례 그대로',
|
||||
example: ['jw'],
|
||||
})
|
||||
deletedIds!: string[];
|
||||
|
||||
@ApiProperty({
|
||||
type: [String],
|
||||
description:
|
||||
'내 주소록에 없는 id — 이미 지움 · 그룹 id 등 (보낸 차례 그대로)',
|
||||
example: ['jx'],
|
||||
})
|
||||
notFoundIds!: string[];
|
||||
}
|
||||
@@ -97,6 +97,19 @@ export class FileSharingRepository {
|
||||
await this.links.update({ id }, { sentEmailId });
|
||||
}
|
||||
|
||||
/** 보낸 때를 앞당긴다 — 취소하지 않았고 지금 보낸 때가 더 늦을 때만, 한 문장으로 (true = 바꿨다) */
|
||||
async moveSentAtEarlier(id: string, sentAt: Date): Promise<boolean> {
|
||||
const result = await this.links
|
||||
.createQueryBuilder()
|
||||
.update(ShareLinkEntity)
|
||||
.set({ sentAt })
|
||||
.where('id = :id', { id })
|
||||
.andWhere('canceled_at IS NULL')
|
||||
.andWhere('sent_at > :sentAt', { sentAt })
|
||||
.execute();
|
||||
return (result.affected ?? 0) > 0;
|
||||
}
|
||||
|
||||
/** 취소한 링크로 — 이미 취소했으면 그대로 (true = 이번에 바꿨다) */
|
||||
async cancel(
|
||||
id: string,
|
||||
|
||||
@@ -91,6 +91,16 @@ class FakeRepository {
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
/** 실제 표처럼 — 취소하지 않았고 지금 보낸 때가 더 늦을 때만 (조건부 UPDATE 한 문장) */
|
||||
moveSentAtEarlier(id: string, sentAt: Date) {
|
||||
const link = this.records.find((item) => item.link.id === id)?.link;
|
||||
if (!link || link.canceledAt !== null || link.sentAt <= sentAt) {
|
||||
return Promise.resolve(false);
|
||||
}
|
||||
link.sentAt = sentAt;
|
||||
return Promise.resolve(true);
|
||||
}
|
||||
|
||||
cancel(id: string, reason: 'send-failed' | 'send-canceled', now: Date) {
|
||||
const link = this.records.find((item) => item.link.id === id)?.link;
|
||||
if (!link || link.canceledAt !== null) return Promise.resolve(false);
|
||||
@@ -314,6 +324,34 @@ describe('FileSharingService — 공유 링크 · 공개 내려받기 (11단계)
|
||||
expect(await codeOf(() => service.publicLink(token))).toBe('RES_001');
|
||||
});
|
||||
|
||||
it('예약 메일을 지금 보냈으면(메일 15단계) 보낸 때를 앞당긴다 — 끝나는 때는 그대로(덩이가 적은 날짜), 취소한 링크 · 더 늦은 시각은 바꾸지 않는다', async () => {
|
||||
const { service, repository } = setup();
|
||||
const scheduled = new Date(Date.now() + 5 * 24 * 60 * 60_000);
|
||||
const created = await service.createMailLink({
|
||||
ownerEmail: ARA,
|
||||
senderName: '김아라',
|
||||
files: [{ id: FILE_A, name: '설계도.dwg', size: CONTENT.length }],
|
||||
sentAt: scheduled,
|
||||
messageId: 'ara.release@arasystem.test',
|
||||
});
|
||||
const { link } = created.record;
|
||||
const expiresAt = link.expiresAt.getTime();
|
||||
const now = new Date();
|
||||
|
||||
await expect(service.markReleased(link.id, now)).resolves.toBe(true);
|
||||
expect(link.sentAt).toEqual(now);
|
||||
expect(link.expiresAt.getTime()).toBe(expiresAt);
|
||||
// 다시 와도(응답을 받지 못한 재시도) 더 늦은 시각으로 되돌리지 않는다
|
||||
await expect(service.markReleased(link.id, scheduled)).resolves.toBe(false);
|
||||
expect(link.sentAt).toEqual(now);
|
||||
|
||||
await service.cancel(created.record, 'send-canceled');
|
||||
await expect(
|
||||
service.markReleased(link.id, new Date(now.getTime() - 1_000)),
|
||||
).resolves.toBe(false);
|
||||
expect(repository.records).toHaveLength(1);
|
||||
});
|
||||
|
||||
describe('파일 응답 (Range · HEAD · 횟수)', () => {
|
||||
it('전체 200 — 내려받기 머리글(형식 · 이름 · nosniff · sandbox · no-store · ETag · Accept-Ranges · X-Accel-Buffering)', async () => {
|
||||
const { service } = setup();
|
||||
|
||||
@@ -199,6 +199,15 @@ export class FileSharingService implements BeforeApplicationShutdown {
|
||||
await this.repository.markSent(linkId, sentEmailId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 예약한 메일을 지금 보냈다(메일 15단계 — 계약 2절 "보내기 취소 · 예약 발송") — 보낸 때를 실제로 보낸 때로 앞당긴다(보낸 링크 목록 · 공개 화면의 보낸 때).
|
||||
* **끝나는 때는 그대로 둔다** — 보낸 메일의 덩이가 예약 시각으로 센 그 날짜를 이미 적었다. 앞당기면 받는 사람이 본 날짜보다 먼저 끝난다(파일 기간도 그대로라 다시 세지 않는다).
|
||||
* 취소한 링크 · 이미 그보다 앞선 보낸 때는 바꾸지 않는다 (true = 바꿨다)
|
||||
*/
|
||||
async markReleased(linkId: string, sentAt: Date): Promise<boolean> {
|
||||
return this.repository.moveSentAtEarlier(linkId, sentAt);
|
||||
}
|
||||
|
||||
/**
|
||||
* 같은 Idempotency-Key 의 재시도가 처음 메일을 찾았다 — 그 발송이 만든 링크(보낸 메일을 모르는 것)에 보낸 사본을 잇는다.
|
||||
* 결과를 모르게 끝난 첫 시도의 링크가 이렇게 제자리를 찾는다 (계약 4절 "링크를 만드는 때")
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
import { plainToInstance } from 'class-transformer';
|
||||
import { validate } from 'class-validator';
|
||||
|
||||
import { SIGNATURE_NAME_MAX_LENGTH } from '@/modules/mail-signature/mail-signature.constants';
|
||||
import {
|
||||
MailSignatureDefaultsDto,
|
||||
SaveMailSignatureDto,
|
||||
UpdateLegacyMailSignatureDto,
|
||||
} from './mail-signature.dto';
|
||||
|
||||
/** 전역 ValidationPipe 와 같은 옵션으로 검증한다 (app.setup.ts — stopAtFirstError 만 빼고 — 걸린 규칙을 모두 본다) — 걸린 규칙 이름만 */
|
||||
async function constraintsOf(
|
||||
type: new () => object,
|
||||
body: object,
|
||||
): Promise<string[]> {
|
||||
const errors = await validate(plainToInstance(type, body), {
|
||||
whitelist: true,
|
||||
forbidNonWhitelisted: true,
|
||||
});
|
||||
return errors.flatMap((error) => Object.keys(error.constraints ?? {}));
|
||||
}
|
||||
|
||||
describe('서명 만들기 · 고치기 본문 (POST /api/mail-signatures · PUT /api/mail-signatures/{id} — 16단계)', () => {
|
||||
const save = (body: object) => constraintsOf(SaveMailSignatureDto, body);
|
||||
|
||||
it('이름은 앞뒤 공백을 지운 값으로 1~30자(JS 문자열 길이) — 본문 칸은 보내지 않거나 null 이어도 된다', async () => {
|
||||
expect(await save({ name: '대외' })).toEqual([]);
|
||||
expect(
|
||||
await save({ name: '대외', bodyHtml: '<p>김아라</p>', body: '김아라' }),
|
||||
).toEqual([]);
|
||||
expect(await save({ name: '대외', bodyHtml: null, body: null })).toEqual(
|
||||
[],
|
||||
);
|
||||
expect(await save({ name: 'x'.repeat(SIGNATURE_NAME_MAX_LENGTH) })).toEqual(
|
||||
[],
|
||||
);
|
||||
expect(
|
||||
plainToInstance(SaveMailSignatureDto, { name: ' 대외 ' }).name,
|
||||
).toBe('대외');
|
||||
// 이모지 하나는 2자로 센다 — 화면의 .length 와 같다
|
||||
expect(
|
||||
await save({ name: '🌴'.repeat(SIGNATURE_NAME_MAX_LENGTH / 2) }),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it('이름이 비거나(공백뿐 포함) · 30자를 넘거나 · 제어 문자 · 글이 아니면 VAL_001', async () => {
|
||||
expect(await save({ name: '' })).toEqual(['minCodeUnits']);
|
||||
expect(await save({ name: ' ' })).toEqual(['minCodeUnits']);
|
||||
expect(
|
||||
await save({ name: 'x'.repeat(SIGNATURE_NAME_MAX_LENGTH + 1) }),
|
||||
).toEqual(['maxCodeUnits']);
|
||||
expect(
|
||||
await save({ name: `${'🌴'.repeat(SIGNATURE_NAME_MAX_LENGTH / 2)}x` }),
|
||||
).toEqual(['maxCodeUnits']);
|
||||
for (const name of ['사내\n영문', '사내\t영문', 'a\u0000']) {
|
||||
expect(await save({ name })).toEqual(['matches']);
|
||||
}
|
||||
// 앞뒤의 공백 · 줄바꿈은 지운 값으로 본다(trim)
|
||||
expect(await save({ name: '\t사내\n' })).toEqual([]);
|
||||
expect(await save({})).toEqual(expect.arrayContaining(['isString']));
|
||||
expect(await save({ name: 1 })).toEqual(
|
||||
expect.arrayContaining(['isString']),
|
||||
);
|
||||
});
|
||||
|
||||
it('본문 칸이 글이 아니거나 모르는 칸(옛 두 표시 포함)이면 VAL_001', async () => {
|
||||
expect(await save({ name: '대외', bodyHtml: 1 })).toEqual(['isString']);
|
||||
expect(await save({ name: '대외', body: [] })).toEqual(['isString']);
|
||||
expect(await save({ name: '대외', isForNew: true })).toEqual([
|
||||
'whitelistValidation',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('메일에 넣을 서명 (PUT /api/mail-signature-defaults — 16단계)', () => {
|
||||
const defaults = (body: object) =>
|
||||
constraintsOf(MailSignatureDefaultsDto, body);
|
||||
|
||||
it('두 키를 모두 보낸다 — 서명 id 또는 null(넣지 않음). 모양은 서비스가 본다(내 서명이 아니면 404)', async () => {
|
||||
const id = '0b6f7c1e-3a52-4c4e-9f0e-2d1f5a7b8c9d';
|
||||
expect(await defaults({ newMessageId: id, replyId: null })).toEqual([]);
|
||||
expect(await defaults({ newMessageId: null, replyId: null })).toEqual([]);
|
||||
expect(await defaults({ newMessageId: 'not-a-uuid', replyId: id })).toEqual(
|
||||
[],
|
||||
);
|
||||
});
|
||||
|
||||
it('빠진 키 · 글이 아닌 값 · 너무 긴 값 · 모르는 칸은 VAL_001', async () => {
|
||||
expect(await defaults({ newMessageId: null })).toEqual(
|
||||
expect.arrayContaining(['isString']),
|
||||
);
|
||||
expect(await defaults({ newMessageId: 1, replyId: null })).toEqual(
|
||||
expect.arrayContaining(['isString']),
|
||||
);
|
||||
expect(
|
||||
await defaults({ newMessageId: 'x'.repeat(65), replyId: null }),
|
||||
).toEqual(['maxCodeUnits']);
|
||||
expect(
|
||||
await defaults({ newMessageId: null, replyId: null, forwardId: null }),
|
||||
).toEqual(['whitelistValidation']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('옛 경로 저장 본문 (PUT /api/mail-signature — 16단계 전의 웹 탭)', () => {
|
||||
it('지금과 같은 네 칸 — 두 표시는 boolean 이어야 한다', async () => {
|
||||
expect(
|
||||
await constraintsOf(UpdateLegacyMailSignatureDto, {
|
||||
bodyHtml: '<p>김아라</p>',
|
||||
body: '김아라',
|
||||
isForNew: true,
|
||||
isForReply: false,
|
||||
}),
|
||||
).toEqual([]);
|
||||
expect(
|
||||
await constraintsOf(UpdateLegacyMailSignatureDto, {
|
||||
isForNew: 'true',
|
||||
isForReply: true,
|
||||
}),
|
||||
).toEqual(['isBoolean']);
|
||||
});
|
||||
});
|
||||
@@ -1,17 +1,35 @@
|
||||
import { applyDecorators } from '@nestjs/common';
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { IsBoolean, IsOptional, IsString } from 'class-validator';
|
||||
import {
|
||||
IsBoolean,
|
||||
IsOptional,
|
||||
IsString,
|
||||
Matches,
|
||||
ValidateIf,
|
||||
} from 'class-validator';
|
||||
|
||||
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 {
|
||||
SIGNATURES_MAX,
|
||||
SIGNATURE_HTML_MAX_LENGTH,
|
||||
SIGNATURE_IMAGES_PER_SIGNATURE,
|
||||
SIGNATURE_IMAGE_MAX_BYTES,
|
||||
SIGNATURE_NAME_MAX_LENGTH,
|
||||
SIGNATURE_TEXT_MAX_LENGTH,
|
||||
} from '@/modules/mail-signature/mail-signature.constants';
|
||||
import type {
|
||||
LegacyMailSignature,
|
||||
MailSignature,
|
||||
MailSignatureDefaults,
|
||||
SignatureImage,
|
||||
} from '@/modules/mail-signature/mail-signature.types';
|
||||
import type { SignatureImageType } from '@/modules/mail-signature/signature-image-format';
|
||||
import { SINGLE_LINE_TEXT_PATTERN } from '@/modules/mail/mail.constants';
|
||||
|
||||
const IMAGE_TYPES: SignatureImageType[] = [
|
||||
'image/png',
|
||||
@@ -19,24 +37,109 @@ const IMAGE_TYPES: SignatureImageType[] = [
|
||||
'image/gif',
|
||||
];
|
||||
|
||||
/** PUT /api/mail-signature — 계약 2절 "서명" */
|
||||
export class UpdateMailSignatureDto {
|
||||
/** 서명 id 칸의 길이 상한 — 서버가 만든 UUID(36자)라 넉넉히. 넘거나 형식이 틀린 id 는 없는 서명과 같다(404 RES_001) */
|
||||
const SIGNATURE_ID_MAX_LENGTH = 64;
|
||||
|
||||
/** null 은 "넣지 않음"으로 받는다 — 키가 없으면(undefined) 형식 오류다 (전체 교체라 두 키를 모두 보낸다) */
|
||||
const unlessNull = (_object: object, value: unknown) => value !== null;
|
||||
|
||||
const BODY_HTML_DESCRIPTION =
|
||||
`서명 서식 HTML — 서식 본문과 같은 허용 목록 + 내 서명 그림(<img src="/api/mail-signature/images/{id}" width="1~600" alt="">). ` +
|
||||
`정화한 뒤 ${SIGNATURE_HTML_MAX_LENGTH.toLocaleString('ko-KR')}자 이하, 서로 다른 그림 ${SIGNATURE_IMAGES_PER_SIGNATURE}장까지(넘으면 400 VAL_001). null·빈 문자열이면 서식 서명 없음. ` +
|
||||
'어느 서명도 더는 가리키지 않는 그림은 저장할 때 지운다';
|
||||
const BODY_HTML_EXAMPLE =
|
||||
'<p style="margin:0"><strong>김아라</strong> · 아라시스템</p><img src="/api/mail-signature/images/3f1c0a2e-0f1b-4f2a-9a1e-6c9f0a1b2c3d" width="144" alt="">';
|
||||
const BODY_DESCRIPTION = `텍스트 서명 — 텍스트 대체 본문에 넣는다. ${SIGNATURE_TEXT_MAX_LENGTH.toLocaleString('ko-KR')}자 이하(JS 문자열 길이, 넘으면 400 VAL_001)`;
|
||||
|
||||
/** 서명 이름 — 앞뒤 공백을 뺀 1~30자(JS 문자열 길이), 제어 문자 없음. 검사는 적은 차례대로(형식 → 길이 → 모양) */
|
||||
function SignatureName() {
|
||||
return applyDecorators(
|
||||
ApiProperty({
|
||||
description:
|
||||
`서명 이름 — 앞뒤 공백을 뺀 1~${SIGNATURE_NAME_MAX_LENGTH}자(JS 문자열 길이), 제어 문자(줄바꿈 · 탭 등)는 쓸 수 없다. ` +
|
||||
'내 다른 서명과 같은 이름(영문 대소문자 무시)이면 400 VAL_001',
|
||||
example: '대외',
|
||||
minLength: 1,
|
||||
maxLength: SIGNATURE_NAME_MAX_LENGTH,
|
||||
}),
|
||||
TrimString(),
|
||||
IsString({ message: '서명 이름 형식이 올바르지 않습니다.' }),
|
||||
MinCodeUnits(1, { message: '서명 이름을 입력해 주세요.' }),
|
||||
MaxCodeUnits(SIGNATURE_NAME_MAX_LENGTH, {
|
||||
message: `서명 이름은 ${SIGNATURE_NAME_MAX_LENGTH}자 이하로 입력해 주세요.`,
|
||||
}),
|
||||
Matches(SINGLE_LINE_TEXT_PATTERN, {
|
||||
message: '서명 이름에는 줄바꿈 같은 제어 문자를 쓸 수 없습니다.',
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/** 「메일에 넣을 서명」 칸 하나 — 내 서명 id 또는 null(넣지 않음). 키는 늘 보낸다 */
|
||||
function DefaultSignatureId(description: string) {
|
||||
return applyDecorators(
|
||||
ApiProperty({
|
||||
description: `${description} — 내 서명 id, null 이면 넣지 않음. 내 서명이 아닌 id(없음 · 남의 것 · 형식이 틀림)는 404 RES_001`,
|
||||
nullable: true,
|
||||
type: String,
|
||||
example: '3f1c0a2e-0f1b-4f2a-9a1e-6c9f0a1b2c3d',
|
||||
}),
|
||||
ValidateIf(unlessNull),
|
||||
IsString({ message: '서명을 다시 골라 주세요.' }),
|
||||
MaxCodeUnits(SIGNATURE_ID_MAX_LENGTH, {
|
||||
message: '서명을 다시 골라 주세요.',
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/** POST /api/mail-signatures · PUT /api/mail-signatures/{id} — 계약 2절 "서명" (16단계, 전체 교체) */
|
||||
export class SaveMailSignatureDto {
|
||||
@SignatureName()
|
||||
name!: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description:
|
||||
`서명 서식 HTML — 서식 본문과 같은 허용 목록 + 내 서명 그림(<img src="/api/mail-signature/images/{id}" width="1~600" alt="">). ` +
|
||||
`정화한 뒤 ${SIGNATURE_HTML_MAX_LENGTH.toLocaleString('ko-KR')}자 이하, 서로 다른 그림 ${SIGNATURE_IMAGES_PER_SIGNATURE}장까지(넘으면 400 VAL_001). null·빈 문자열이면 서식 서명 없음. ` +
|
||||
'서명 HTML 이 더는 가리키지 않는 그림은 저장할 때 지운다',
|
||||
description: BODY_HTML_DESCRIPTION,
|
||||
nullable: true,
|
||||
type: String,
|
||||
example:
|
||||
'<p style="margin:0"><strong>김아라</strong> · 아라시스템</p><img src="/api/mail-signature/images/3f1c0a2e-0f1b-4f2a-9a1e-6c9f0a1b2c3d" width="144" alt="">',
|
||||
example: BODY_HTML_EXAMPLE,
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString({ message: '서명 형식이 올바르지 않습니다.' })
|
||||
bodyHtml?: string | null;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: `텍스트 서명 — 텍스트 대체 본문에 넣는다. ${SIGNATURE_TEXT_MAX_LENGTH.toLocaleString('ko-KR')}자 이하(JS 문자열 길이, 넘으면 400 VAL_001)`,
|
||||
description: BODY_DESCRIPTION,
|
||||
nullable: true,
|
||||
type: String,
|
||||
example: '김아라 · 아라시스템',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString({ message: '텍스트 서명 형식이 올바르지 않습니다.' })
|
||||
body?: string | null;
|
||||
}
|
||||
|
||||
/** GET · PUT /api/mail-signature-defaults — 계약 2절 "서명"의 「메일에 넣을 서명」 (16단계) */
|
||||
export class MailSignatureDefaultsDto implements MailSignatureDefaults {
|
||||
@DefaultSignatureId('새 메일에 넣는 서명')
|
||||
newMessageId!: string | null;
|
||||
|
||||
@DefaultSignatureId('답장 · 전달에 넣는 서명(인용 위)')
|
||||
replyId!: string | null;
|
||||
}
|
||||
|
||||
/** PUT /api/mail-signature — 옛 경로(16단계 전의 웹 탭). 「새 메일」 기본 서명의 본문을 바꾸고 두 표시를 기본 둘로 옮긴다 */
|
||||
export class UpdateLegacyMailSignatureDto {
|
||||
@ApiPropertyOptional({
|
||||
description: BODY_HTML_DESCRIPTION,
|
||||
nullable: true,
|
||||
type: String,
|
||||
example: BODY_HTML_EXAMPLE,
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString({ message: '서명 형식이 올바르지 않습니다.' })
|
||||
bodyHtml?: string | null;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: BODY_DESCRIPTION,
|
||||
nullable: true,
|
||||
type: String,
|
||||
example: '김아라 · 아라시스템',
|
||||
@@ -45,11 +148,19 @@ export class UpdateMailSignatureDto {
|
||||
@IsString({ message: '텍스트 서명 형식이 올바르지 않습니다.' })
|
||||
body?: string | null;
|
||||
|
||||
@ApiProperty({ description: '새 메일에 넣는다', example: true })
|
||||
@ApiProperty({
|
||||
description:
|
||||
'true = 이 서명을 새 메일의 기본으로, false = 새 메일에 넣지 않음',
|
||||
example: true,
|
||||
})
|
||||
@IsBoolean({ message: '새 메일에 넣을지 여부를 다시 선택해 주세요.' })
|
||||
isForNew!: boolean;
|
||||
|
||||
@ApiProperty({ description: '답장·전달에 넣는다 (인용 위)', example: true })
|
||||
@ApiProperty({
|
||||
description:
|
||||
'true = 이 서명을 답장·전달의 기본으로, false = 이 서명이 기본이었으면 넣지 않음(다른 서명이 기본이면 그대로)',
|
||||
example: true,
|
||||
})
|
||||
@IsBoolean({ message: '답장·전달에 넣을지 여부를 다시 선택해 주세요.' })
|
||||
isForReply!: boolean;
|
||||
}
|
||||
@@ -82,26 +193,86 @@ export class SignatureImageDto implements SignatureImage {
|
||||
height!: number;
|
||||
}
|
||||
|
||||
/** 서명 하나 (16단계) */
|
||||
export class MailSignatureDto implements MailSignature {
|
||||
@ApiProperty({
|
||||
description: `서명 서식 HTML (정화한 값, ${SIGNATURE_HTML_MAX_LENGTH.toLocaleString('ko-KR')}자 이하) — 저장한 적 없거나 비었으면 null`,
|
||||
description: '서명 id (서버가 만든 UUID)',
|
||||
example: '0b6f7c1e-3a52-4c4e-9f0e-2d1f5a7b8c9d',
|
||||
})
|
||||
id!: string;
|
||||
|
||||
@ApiProperty({ description: '서명 이름 (1~30자)', example: '대외' })
|
||||
name!: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: `서명 서식 HTML (정화한 값, ${SIGNATURE_HTML_MAX_LENGTH.toLocaleString('ko-KR')}자 이하) — 없으면 null`,
|
||||
nullable: true,
|
||||
type: String,
|
||||
})
|
||||
bodyHtml!: string | null;
|
||||
|
||||
@ApiProperty({
|
||||
description: '텍스트 서명 — 저장한 적 없거나 비었으면 null',
|
||||
description: '텍스트 서명 — 없으면 null',
|
||||
nullable: true,
|
||||
type: String,
|
||||
})
|
||||
body!: string | null;
|
||||
|
||||
@ApiProperty({ description: '새 메일에 넣는다 (기본 true)', example: true })
|
||||
@ApiProperty({
|
||||
description: `서명 HTML 이 쓰는 그림 — HTML 에 나온 순서 (${SIGNATURE_IMAGES_PER_SIGNATURE}장까지)`,
|
||||
type: [SignatureImageDto],
|
||||
})
|
||||
images!: SignatureImageDto[];
|
||||
|
||||
@ApiProperty({
|
||||
description: '마지막으로 저장한 시각 (ISO 8601 UTC)',
|
||||
example: '2026-11-10T05:12:00.000Z',
|
||||
})
|
||||
updatedAt!: string;
|
||||
}
|
||||
|
||||
/** GET /api/mail-signatures — 내 서명 목록 */
|
||||
export class MailSignaturePageDto {
|
||||
@ApiProperty({
|
||||
type: [MailSignatureDto],
|
||||
description: `만든 차례 — ${SIGNATURES_MAX}개까지`,
|
||||
})
|
||||
items!: MailSignatureDto[];
|
||||
|
||||
@ApiProperty({
|
||||
type: PageInfoDto,
|
||||
description: '한 번에 모두 내려준다 (hasNext=false, total = 서명 수)',
|
||||
})
|
||||
pageInfo!: PageInfoDto;
|
||||
}
|
||||
|
||||
/** GET · PUT /api/mail-signature — 옛 경로의 서명 (앱 첫 판 · 16단계 전의 웹 탭) */
|
||||
export class LegacyMailSignatureDto implements LegacyMailSignature {
|
||||
@ApiProperty({
|
||||
description: `옛 경로가 보이는 서명(「새 메일」 기본, 없으면 「답장 · 전달」 기본)의 서식 HTML (정화한 값, ${SIGNATURE_HTML_MAX_LENGTH.toLocaleString('ko-KR')}자 이하) — 기본 서명이 둘 다 없거나 비었으면 null`,
|
||||
nullable: true,
|
||||
type: String,
|
||||
})
|
||||
bodyHtml!: string | null;
|
||||
|
||||
@ApiProperty({
|
||||
description:
|
||||
'옛 경로가 보이는 서명(「새 메일」 기본, 없으면 「답장 · 전달」 기본)의 텍스트 — 기본 서명이 둘 다 없거나 비었으면 null',
|
||||
nullable: true,
|
||||
type: String,
|
||||
})
|
||||
body!: string | null;
|
||||
|
||||
@ApiProperty({
|
||||
description:
|
||||
'「새 메일」 기본 서명이 있다 — 없으면 false (그때 본문이 있으면 「답장 · 전달」 기본 서명이다)',
|
||||
example: true,
|
||||
})
|
||||
isForNew!: boolean;
|
||||
|
||||
@ApiProperty({
|
||||
description: '답장·전달에 넣는다 — 인용 위 (기본 true)',
|
||||
description:
|
||||
'「답장 · 전달」 기본이 이 서명이다 — 「새 메일」 기본과 다른 서명이면 false (앱 첫 판은 그때 답장에 서명을 넣지 않는다)',
|
||||
example: true,
|
||||
})
|
||||
isForReply!: boolean;
|
||||
@@ -113,7 +284,7 @@ export class MailSignatureDto implements MailSignature {
|
||||
images!: SignatureImageDto[];
|
||||
|
||||
@ApiProperty({
|
||||
description: '저장한 시각 (ISO 8601 UTC) — 저장한 적 없으면 null',
|
||||
description: '저장한 시각 (ISO 8601 UTC) — 기본 서명이 둘 다 없으면 null',
|
||||
nullable: true,
|
||||
type: String,
|
||||
example: '2026-10-27T05:12:00.000Z',
|
||||
|
||||
@@ -20,15 +20,28 @@ export const SIGNATURE_IMAGE_MAX_BYTES = INLINE_IMAGE_MAX_BYTES;
|
||||
*/
|
||||
export const SIGNATURE_IMAGE_MAX_DIMENSION = 4096;
|
||||
|
||||
/** 서명 하나에 넣을 수 있는 서로 다른 그림 수 — 넘으면 서명 저장(PUT)이 400 VAL_001 */
|
||||
/** 서명 하나에 넣을 수 있는 서로 다른 그림 수 — 넘으면 서명 저장(POST · PUT)이 400 VAL_001 */
|
||||
export const SIGNATURE_IMAGES_PER_SIGNATURE = 3;
|
||||
|
||||
/**
|
||||
* 한 사람이 둘 수 있는 서명 그림 수 — 서명에 붙은 것 + 올리고 아직 저장하지 않은 것. 넘으면 올리기가 400 VAL_001.
|
||||
* 편집기에서 그림을 넣었다 빼기를 되풀이해도(빼도 저장 전·10분 유예 동안은 남는다) 막히지 않게 서명 한도보다 넉넉히 둔다.
|
||||
* 저장하지 않은 그림은 하루 뒤 정리 작업이, 서명이 더는 가리키지 않는 그림은 다음 저장이 지운다.
|
||||
* 한 사람이 둘 수 있는 서명 그림 수 — 모든 서명에 붙은 것 + 올리고 아직 저장하지 않은 것. 넘으면 올리기가 400 VAL_001.
|
||||
* 서명 여럿(16단계 — 10개 × 3장)이 함께 쓰는 칸이라 10 → 20 으로 늘렸다. 편집기에서 그림을 넣었다 빼기를 되풀이해도
|
||||
* (빼도 저장 전·10분 유예 동안은 남는다) 막히지 않게 서명 하나의 한도보다 넉넉히 둔다.
|
||||
* 저장하지 않은 그림은 하루 뒤 정리 작업이, 어느 서명도 더는 가리키지 않는 그림은 다음 저장이 지운다.
|
||||
*/
|
||||
export const SIGNATURE_IMAGES_STORED_MAX = 10;
|
||||
export const SIGNATURE_IMAGES_STORED_MAX = 20;
|
||||
|
||||
/** 한 사람이 둘 수 있는 서명 수 (16단계) — 11번째 만들기는 400 VAL_001 */
|
||||
export const SIGNATURES_MAX = 10;
|
||||
|
||||
/** 서명 이름의 길이 — 앞뒤 공백을 뺀 JS 문자열 길이 (16단계). 같은 이름(ASCII 대소문자 무시)은 400 VAL_001 */
|
||||
export const SIGNATURE_NAME_MAX_LENGTH = 30;
|
||||
|
||||
/**
|
||||
* 옛 서명(한 사람에 하나)을 옮긴 이름 — 마이그레이션 `MultipleMailSignatures` 와 옛 경로 `PUT /api/mail-signature` 가
|
||||
* 기본 서명(「새 메일」 · 「답장 · 전달」)이 둘 다 없을 때 만드는 서명의 이름이다 (계약 2절 "서명" — 16단계)
|
||||
*/
|
||||
export const LEGACY_SIGNATURE_NAME = '기본 서명';
|
||||
|
||||
/** 서명 서식(HTML) 최대 길이 — 정화한 뒤 JS 문자열 길이 */
|
||||
export const SIGNATURE_HTML_MAX_LENGTH = 10_000;
|
||||
|
||||
@@ -37,9 +37,9 @@ import { SkipTransform } from '@/common/decorators/skip-transform.decorator';
|
||||
import { BusinessException } from '@/common/errors/business.exception';
|
||||
import { JwtAuthGuard } from '@/common/guards/jwt-auth.guard';
|
||||
import {
|
||||
MailSignatureDto,
|
||||
LegacyMailSignatureDto,
|
||||
SignatureImageDto,
|
||||
UpdateMailSignatureDto,
|
||||
UpdateLegacyMailSignatureDto,
|
||||
} from './dto/mail-signature.dto';
|
||||
import {
|
||||
SIGNATURE_HTML_MAX_LENGTH,
|
||||
@@ -57,7 +57,8 @@ import { SignatureImageIdPipe } from './signature-image-id.pipe';
|
||||
const IMAGE_CONTENT_TYPES = ['image/png', 'image/jpeg', 'image/gif'];
|
||||
|
||||
// 메일 서명 (계약 2절 "서명") — 로그인한 직원 자신의 서명·그림만 다룬다.
|
||||
// 그림 업로드는 modules/upload 패턴을 따른다 (가드가 multer 보다 먼저 돌아 미인증 요청은 파일을 읽기 전에 막힌다).
|
||||
// 16단계부터 서명은 여럿이다(mail-signatures.controller.ts) — 여기의 GET · PUT 은 「새 메일」 기본 서명(없으면 「답장 · 전달」 기본)을 다루는 옛 경로(앱 첫 판 · 옛 웹 탭)이고,
|
||||
// 그림 경로는 모든 서명이 함께 쓴다. 그림 업로드는 modules/upload 패턴을 따른다 (가드가 multer 보다 먼저 돌아 미인증 요청은 파일을 읽기 전에 막힌다).
|
||||
@ApiTags('Mail')
|
||||
@ApiCookieAuth()
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@@ -68,33 +69,37 @@ export class MailSignatureController {
|
||||
|
||||
@Get()
|
||||
@ApiOperation({
|
||||
summary: '내 서명',
|
||||
summary: '「새 메일」 기본 서명 — 옛 경로 (앱 첫 판)',
|
||||
description:
|
||||
'저장한 적 없으면 기본값(bodyHtml·body null, isForNew·isForReply true, images [])을 돌려준다.',
|
||||
'16단계부터 서명은 여럿이다(/api/mail-signatures). 이 경로는 「새 메일」 기본 서명을 15단계까지의 모양으로 준다 — ' +
|
||||
'isForNew = 「새 메일」 기본 서명이 있음, isForReply = 「답장 · 전달」 기본이 같은 서명. ' +
|
||||
'「새 메일」 기본이 없으면 「답장 · 전달」 기본 서명을 isForNew false · isForReply true 로 준다. ' +
|
||||
'둘 다 없으면 { bodyHtml: null, body: null, isForNew: false, isForReply: false, images: [], updatedAt: null }.',
|
||||
})
|
||||
@ApiStandardResponse(MailSignatureDto, { description: '서명' })
|
||||
@ApiStandardResponse(LegacyMailSignatureDto, { description: '서명' })
|
||||
getSignature(@CurrentUser() user: AuthenticatedUser) {
|
||||
return this.signatures.getSignature(user.email);
|
||||
return this.signatures.getLegacySignature(user.email);
|
||||
}
|
||||
|
||||
@Put()
|
||||
@ApiOperation({
|
||||
summary: '서명 저장 (전체 교체)',
|
||||
summary: '「새 메일」 기본 서명 저장 — 옛 경로 (16단계 전의 웹 탭)',
|
||||
description:
|
||||
'옛 GET 이 보이는 서명(「새 메일」 기본, 없으면 「답장 · 전달」 기본, 둘 다 없으면 "기본 서명"이라는 이름의 서명 — 없으면 만든다)의 본문을 바꾸고, isForNew · isForReply 를 「메일에 넣을 서명」 둘로 옮긴다. ' +
|
||||
`bodyHtml 은 서식 본문 허용 목록 + 내 서명 그림으로 정화해 저장한다(정화 뒤 ${SIGNATURE_HTML_MAX_LENGTH.toLocaleString('ko-KR')}자 이하), ` +
|
||||
`body 는 ${SIGNATURE_TEXT_MAX_LENGTH.toLocaleString('ko-KR')}자 이하, 서로 다른 그림은 ${SIGNATURE_IMAGES_PER_SIGNATURE}장까지(같은 그림 두 번은 한 장). ` +
|
||||
'서명 HTML 이 더는 가리키지 않는 그림은 지운다 — 막 올리고(10분 안) 아직 넣지 않은 그림은 남긴다.',
|
||||
'어느 서명도 더는 가리키지 않는 그림은 지운다 — 막 올리고(10분 안) 아직 넣지 않은 그림은 남긴다.',
|
||||
})
|
||||
@ApiStandardResponse(MailSignatureDto, { description: '저장한 서명' })
|
||||
@ApiStandardResponse(LegacyMailSignatureDto, { description: '저장한 서명' })
|
||||
@ApiErrorResponse(
|
||||
'VAL_001',
|
||||
`형식 오류 · 텍스트 서명 2,000자 초과 · 정화한 서식 서명 10,000자 초과 · 서식 서명이 너무 깊게 중첩됨 · 그림 ${SIGNATURE_IMAGES_PER_SIGNATURE}장 초과`,
|
||||
`형식 오류 · 텍스트 서명 2,000자 초과 · 정화한 서식 서명 10,000자 초과 · 서식 서명이 너무 깊게 중첩됨 · 그림 ${SIGNATURE_IMAGES_PER_SIGNATURE}장 초과 · 서명을 새로 만들어야 하는데 이미 10개`,
|
||||
)
|
||||
saveSignature(
|
||||
@CurrentUser() user: AuthenticatedUser,
|
||||
@Body() dto: UpdateMailSignatureDto,
|
||||
@Body() dto: UpdateLegacyMailSignatureDto,
|
||||
) {
|
||||
return this.signatures.saveSignature(user.email, dto);
|
||||
return this.signatures.saveLegacySignature(user.email, dto);
|
||||
}
|
||||
|
||||
@Post('images')
|
||||
@@ -103,8 +108,8 @@ export class MailSignatureController {
|
||||
summary: '서명 그림 올리기 (파일 1개)',
|
||||
description:
|
||||
`PNG·JPEG·GIF 만 — 확장자·MIME·파일 앞부분(내용)을 모두 본다(WebP·SVG 는 받지 않는다). ` +
|
||||
`한 장 ${SIGNATURE_IMAGE_MAX_BYTES.toLocaleString('ko-KR')}바이트 이하. 한 사람이 둘 수 있는 그림은 ${SIGNATURE_IMAGES_STORED_MAX}장까지 — 서명에 붙은 것 + 올리고 아직 저장하지 않은 것 ` +
|
||||
`(서명 하나에는 ${SIGNATURE_IMAGES_PER_SIGNATURE}장 — PUT 이 본다). ` +
|
||||
`한 장 ${SIGNATURE_IMAGE_MAX_BYTES.toLocaleString('ko-KR')}바이트 이하. 한 사람이 둘 수 있는 그림은 ${SIGNATURE_IMAGES_STORED_MAX}장까지 — 모든 서명에 붙은 것 + 올리고 아직 저장하지 않은 것 ` +
|
||||
`(서명 하나에는 ${SIGNATURE_IMAGES_PER_SIGNATURE}장 — 서명 저장이 본다). ` +
|
||||
'서명을 저장하지 않으면 하루 뒤 지운다.',
|
||||
})
|
||||
@ApiConsumes('multipart/form-data')
|
||||
|
||||
@@ -1,35 +1,63 @@
|
||||
import { Column, Entity, PrimaryColumn } from 'typeorm';
|
||||
import { Column, Entity, Index, PrimaryColumn } from 'typeorm';
|
||||
|
||||
/** 메일 주소 최대 길이 (RFC 5321 경로 한도) — mail.constants 의 MAX_EMAIL_LENGTH 와 같다 */
|
||||
const EMAIL_COLUMN_LENGTH = 254;
|
||||
/**
|
||||
* 서명 이름 칸 — 이름은 30자(JS 문자열 길이 — SIGNATURE_NAME_MAX_LENGTH)까지라 넉넉히 둔다.
|
||||
* varchar 는 코드 포인트로 세어 JS 길이 30 이면 늘 들어간다 (서로게이트 쌍 하나가 JS 로는 2자 · 여기서는 1자)
|
||||
*/
|
||||
const NAME_COLUMN_LENGTH = 64;
|
||||
|
||||
/**
|
||||
* 메일 서명 (계약 2절 "서명") — 사용자마다 하나. 메일 엔진(JMAP Identity)이 아니라 우리 DB 에 둔다 —
|
||||
* 엔진을 바꿔도 남고, DB 백업에 함께 들어간다.
|
||||
* HTML 은 저장하기 전에 보내는 정화(서명 그림 포함)를 거친 값이다.
|
||||
* 메일 서명 (계약 2절 "서명") — 16단계부터 한 사람에 여러 개(10개까지)다. 메일 엔진(JMAP Identity)이 아니라 우리 DB 에 둔다 —
|
||||
* 엔진을 바꿔도 남고, DB 백업에 함께 들어간다. HTML 은 저장하기 전에 보내는 정화(서명 그림 포함)를 거친 값이다.
|
||||
*
|
||||
* 「메일에 넣을 서명」 둘(새 메일 · 답장과 전달)은 서명 줄의 표시로 둔다 — 한 사람에 하나씩만(부분 유일 인덱스).
|
||||
* 서명을 지우면 표시도 함께 없어져 그 자리는 "넣지 않음"이 된다. 퇴사 마무리는 주소로 이 표의 줄을 지운다(modules/ops/ops-data.service.ts).
|
||||
* 목록은 만든 차례(created_at, 같으면 id)다.
|
||||
*/
|
||||
@Entity({ name: 'mail_signatures' })
|
||||
@Index('IDX_mail_signatures_user_email_created_at', ['userEmail', 'createdAt'])
|
||||
@Index('UQ_mail_signatures_new_message_default', ['userEmail'], {
|
||||
unique: true,
|
||||
where: '"is_new_message_default"',
|
||||
})
|
||||
@Index('UQ_mail_signatures_reply_default', ['userEmail'], {
|
||||
unique: true,
|
||||
where: '"is_reply_default"',
|
||||
})
|
||||
export class MailSignatureEntity {
|
||||
@PrimaryColumn({
|
||||
/** 서버가 만든 UUID (소문자) */
|
||||
@PrimaryColumn({ type: 'uuid' })
|
||||
id!: string;
|
||||
|
||||
@Column({
|
||||
name: 'user_email',
|
||||
type: 'varchar',
|
||||
length: EMAIL_COLUMN_LENGTH,
|
||||
})
|
||||
userEmail!: string;
|
||||
|
||||
/** 앞뒤 공백을 뺀 1~30자 — 같은 사람 안에서 겹치지 않는다(ASCII 대소문자 무시 — 서비스가 사용자 잠금 안에서 본다) */
|
||||
@Column({ type: 'varchar', length: NAME_COLUMN_LENGTH })
|
||||
name!: string;
|
||||
|
||||
@Column({ name: 'body_html', type: 'text', nullable: true })
|
||||
bodyHtml!: string | null;
|
||||
|
||||
@Column({ name: 'body_text', type: 'text', nullable: true })
|
||||
bodyText!: string | null;
|
||||
|
||||
/** 새 메일에 넣는다 */
|
||||
@Column({ name: 'is_for_new', type: 'boolean', default: true })
|
||||
isForNew!: boolean;
|
||||
/** 「새 메일」에 넣는 서명 — 한 사람에 하나까지 */
|
||||
@Column({ name: 'is_new_message_default', type: 'boolean', default: false })
|
||||
isNewMessageDefault!: boolean;
|
||||
|
||||
/** 답장·전달에 넣는다 (인용 위) */
|
||||
@Column({ name: 'is_for_reply', type: 'boolean', default: true })
|
||||
isForReply!: boolean;
|
||||
/** 「답장 · 전달」에 넣는 서명(인용 위) — 한 사람에 하나까지 */
|
||||
@Column({ name: 'is_reply_default', type: 'boolean', default: false })
|
||||
isReplyDefault!: boolean;
|
||||
|
||||
@Column({ name: 'created_at', type: 'timestamptz' })
|
||||
createdAt!: Date;
|
||||
|
||||
@Column({ name: 'updated_at', type: 'timestamptz' })
|
||||
updatedAt!: Date;
|
||||
|
||||
@@ -12,11 +12,15 @@ import { MailSignatureController } from './mail-signature.controller';
|
||||
import { MailSignatureEntity } from './mail-signature.entity';
|
||||
import { MailSignatureRepository } from './mail-signature.repository';
|
||||
import { MailSignatureService } from './mail-signature.service';
|
||||
import {
|
||||
MailSignatureDefaultsController,
|
||||
MailSignaturesController,
|
||||
} from './mail-signatures.controller';
|
||||
import { SIGNATURE_IMAGE_TYPES_BY_EXTENSION } from './signature-image-format';
|
||||
import { SignatureImageStorage } from './signature-image-storage';
|
||||
|
||||
/**
|
||||
* 메일 서명 모듈 (계약 2절 "서명") — 서명·그림은 서버 DB 와 업로드 볼륨에 둔다.
|
||||
* 메일 서명 모듈 (계약 2절 "서명") — 서명·그림은 서버 DB 와 업로드 볼륨에 둔다. 16단계부터 한 사람에 서명 여러 개(10개까지).
|
||||
* 메일 모듈이 발송·임시 저장 때 그림을 쓰도록 서비스를 내보낸다 (이 모듈은 메일 모듈을 가져오지 않는다).
|
||||
*
|
||||
* 그림 업로드: 한 장 300KB 이하라 메모리에 받는다 — 파일 내용(앞부분)을 본 뒤에만 디스크에 서버가 만든 이름으로 쓴다.
|
||||
@@ -47,7 +51,13 @@ import { SignatureImageStorage } from './signature-image-storage';
|
||||
},
|
||||
}),
|
||||
],
|
||||
controllers: [MailSignatureController],
|
||||
controllers: [
|
||||
// 여러 서명 · 「메일에 넣을 서명」 둘 (16단계)
|
||||
MailSignaturesController,
|
||||
MailSignatureDefaultsController,
|
||||
// 옛 경로(「새 메일」 기본 서명 — 앱 첫 판) · 서명 그림
|
||||
MailSignatureController,
|
||||
],
|
||||
providers: [
|
||||
MailSignatureRepository,
|
||||
{
|
||||
|
||||
@@ -1,36 +1,75 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { DataSource, In, IsNull, LessThan, type Repository } from 'typeorm';
|
||||
import {
|
||||
DataSource,
|
||||
In,
|
||||
IsNull,
|
||||
LessThan,
|
||||
type EntityManager,
|
||||
type Repository,
|
||||
} from 'typeorm';
|
||||
|
||||
import { MailSignatureImageEntity } from './mail-signature-image.entity';
|
||||
import { MailSignatureEntity } from './mail-signature.entity';
|
||||
import type { MailSignatureDefaults } from './mail-signature.types';
|
||||
|
||||
/**
|
||||
* 서명 저장을 사용자별로 차례대로 — `pg_advisory_xact_lock(이 값, hashtext(주소))` (트랜잭션이 끝나면 풀린다).
|
||||
* 서명 행이 아직 없을 때도 잠긴다 (행 잠금 FOR UPDATE 는 없는 행을 잠그지 못한다). 첫 인자는 다른 잠금과 섞이지 않게 붙인 이름표 — 'SIG1' 의 ASCII.
|
||||
* 서명 줄이 아직 없을 때도 잠긴다 (행 잠금 FOR UPDATE 는 없는 행을 잠그지 못한다). 첫 인자는 다른 잠금과 섞이지 않게 붙인 이름표 — 'SIG1' 의 ASCII.
|
||||
*/
|
||||
const SIGNATURE_SAVE_LOCK = 0x53494731;
|
||||
|
||||
/** 서명 저장 — 잠근 뒤 본 그림으로 저장할 서명을 정한다 (서비스가 그사이 없어진 그림을 서명에서 뺀다) */
|
||||
export type PrepareSignature = (
|
||||
images: readonly MailSignatureImageEntity[],
|
||||
) => {
|
||||
readonly signature: MailSignatureEntity;
|
||||
readonly referencedImageIds: readonly string[];
|
||||
};
|
||||
/** 목록 차례 — 만든 차례, 같은 시각이면 id */
|
||||
const SIGNATURE_ORDER = { createdAt: 'ASC', id: 'ASC' } as const;
|
||||
|
||||
/** 서명 저장 결과 */
|
||||
export interface SavedSignature {
|
||||
readonly signature: MailSignatureEntity;
|
||||
/** 저장한 뒤 남은 그 사람의 그림 (올린 순서) */
|
||||
/** 잠근 뒤 본 그 사람의 서명 · 그림 */
|
||||
export interface SignatureSnapshot {
|
||||
/** 만든 차례 */
|
||||
readonly signatures: readonly MailSignatureEntity[];
|
||||
/** 올린 차례 */
|
||||
readonly images: readonly MailSignatureImageEntity[];
|
||||
}
|
||||
|
||||
/** 새로 넣을 서명 — 기본 표시는 plan 의 defaults 로만 바꾼다(넣을 때는 늘 꺼짐) */
|
||||
export type NewSignatureRow = Omit<
|
||||
MailSignatureEntity,
|
||||
'isNewMessageDefault' | 'isReplyDefault'
|
||||
>;
|
||||
|
||||
/** 고칠 서명 — 이름 · 본문 · 저장 시각만 */
|
||||
export type SignatureRowUpdate = Pick<
|
||||
MailSignatureEntity,
|
||||
'id' | 'name' | 'bodyHtml' | 'bodyText' | 'updatedAt'
|
||||
>;
|
||||
|
||||
/** 잠근 뒤 정한 바꿀 것 — 서비스가 스냅숏을 보고 정한다(한도 · 이름 겹침은 서비스가 던진다 — 그러면 아무것도 바꾸지 않는다) */
|
||||
export interface SignaturePlan {
|
||||
readonly insert?: NewSignatureRow;
|
||||
readonly update?: SignatureRowUpdate;
|
||||
readonly removeId?: string;
|
||||
/** 「메일에 넣을 서명」 둘을 이 값으로 — 없으면 그대로 (지운 서명의 표시는 줄과 함께 없어진다) */
|
||||
readonly defaults?: MailSignatureDefaults;
|
||||
/** 바꾼 뒤 모든 서명 HTML 이 가리키는 그림 (합집합) — 이 밖의 그림은 지운다(막 올린 것은 남긴다) */
|
||||
readonly referencedImageIds: readonly string[];
|
||||
}
|
||||
|
||||
export type PlanSignatureChange = (
|
||||
snapshot: SignatureSnapshot,
|
||||
) => SignaturePlan;
|
||||
|
||||
/** 서명 바꾸기 결과 */
|
||||
export interface SignatureChangeResult {
|
||||
/** 바꾼 뒤 그 사람의 서명 — 만든 차례, 기본 표시 포함 */
|
||||
readonly signatures: MailSignatureEntity[];
|
||||
/** 바꾼 뒤 남은 그 사람의 그림 (올린 차례) */
|
||||
readonly images: MailSignatureImageEntity[];
|
||||
/** 지운 그림 — 파일은 부르는 쪽이 커밋 뒤에 지운다 */
|
||||
readonly removed: MailSignatureImageEntity[];
|
||||
}
|
||||
|
||||
/**
|
||||
* 서명·서명 그림 표 접근 — 판단(정화·한도·누가 볼 수 있나)은 MailSignatureService 가 한다.
|
||||
* 그림을 찾을 때는 늘 주인(user_email)을 함께 조건에 넣는다 — 남의 그림 id 로는 아무것도 나오지 않는다.
|
||||
* 서명·서명 그림 표 접근 — 판단(정화·한도·이름 겹침·누가 볼 수 있나)은 MailSignatureService 가 한다.
|
||||
* 서명 · 그림을 찾을 때는 늘 주인(user_email)을 함께 조건에 넣는다 — 남의 id 로는 아무것도 나오지 않는다.
|
||||
*/
|
||||
@Injectable()
|
||||
export class MailSignatureRepository {
|
||||
@@ -42,8 +81,12 @@ export class MailSignatureRepository {
|
||||
private readonly dataSource: DataSource,
|
||||
) {}
|
||||
|
||||
findSignature(userEmail: string): Promise<MailSignatureEntity | null> {
|
||||
return this.signatures.findOneBy({ userEmail });
|
||||
/** 그 사람의 서명 — 만든 차례 */
|
||||
listSignatures(userEmail: string): Promise<MailSignatureEntity[]> {
|
||||
return this.signatures.find({
|
||||
where: { userEmail },
|
||||
order: SIGNATURE_ORDER,
|
||||
});
|
||||
}
|
||||
|
||||
/** 그 사람의 그림 — 올린 순서대로 */
|
||||
@@ -70,31 +113,62 @@ export class MailSignatureRepository {
|
||||
}
|
||||
|
||||
/**
|
||||
* 서명을 저장한다 — 한 트랜잭션에서, 사용자별로 차례대로 (두 탭이 동시에 저장해도 서로의 그림을 엉뚱하게 지우지 않게).
|
||||
* 1) 사용자 잠금 2) 그 사람의 그림을 잠가 읽는다(FOR UPDATE — 정리 작업이 그사이 지우지 못한다) 3) `prepare` 가 저장할 서명을 정한다
|
||||
* 4) 서명을 넣거나 고치고, 서명이 가리키는 그림은 붙은 것으로 표시하고, 가리키지 않는 그림은 지운다 —
|
||||
* 다만 한 번도 저장하지 않은 그림 중 `keepUploadedAfter` 뒤에 올린 것은 남긴다 (막 올린 그림 — SIGNATURE_IMAGE_UPLOAD_GRACE_MS).
|
||||
* 서명을 바꾼다(만들기 · 고치기 · 지우기 · 기본 바꾸기) — 한 트랜잭션에서, 사용자별로 차례대로 (두 탭이 동시에 저장해도 서로의 그림을 엉뚱하게 지우지 않게).
|
||||
* 1) 사용자 잠금 2) 그 사람의 서명과 그림을 읽는다(그림은 FOR UPDATE — 정리 작업이 그사이 지우지 못한다) 3) `plan` 이 바꿀 것을 정한다
|
||||
* 4) 넣기 · 고치기 · 지우기 → 기본 표시(끄고 나서 켠다 — 부분 유일 인덱스는 줄마다 바로 보므로) →
|
||||
* 모든 서명이 가리키는 그림은 붙은 것으로 표시하고, 어느 서명도 가리키지 않는 그림은 지운다 —
|
||||
* 다만 한 번도 저장하지 않은 그림 중 `keepUploadedAfter` 뒤에 올린 것은 남긴다 (막 올린 그림 — SIGNATURE_IMAGE_UPLOAD_GRACE_MS)
|
||||
* 5) 바꾼 뒤의 서명을 다시 읽어 돌려준다
|
||||
*/
|
||||
saveSignature(
|
||||
change(
|
||||
userEmail: string,
|
||||
prepare: PrepareSignature,
|
||||
plan: PlanSignatureChange,
|
||||
now: Date,
|
||||
keepUploadedAfter: Date,
|
||||
): Promise<SavedSignature> {
|
||||
): Promise<SignatureChangeResult> {
|
||||
return this.dataSource.transaction(async (manager) => {
|
||||
await manager.query('SELECT pg_advisory_xact_lock($1, hashtext($2))', [
|
||||
SIGNATURE_SAVE_LOCK,
|
||||
userEmail,
|
||||
]);
|
||||
const signatures = await manager.find(MailSignatureEntity, {
|
||||
where: { userEmail },
|
||||
order: SIGNATURE_ORDER,
|
||||
});
|
||||
const images = await manager.find(MailSignatureImageEntity, {
|
||||
where: { userEmail },
|
||||
order: { createdAt: 'ASC' },
|
||||
lock: { mode: 'pessimistic_write' },
|
||||
});
|
||||
const { signature, referencedImageIds } = prepare(images);
|
||||
await manager.upsert(MailSignatureEntity, signature, ['userEmail']);
|
||||
const next = plan({ signatures, images });
|
||||
|
||||
const referenced = new Set(referencedImageIds);
|
||||
if (next.insert) {
|
||||
await manager.insert(MailSignatureEntity, {
|
||||
...next.insert,
|
||||
userEmail,
|
||||
isNewMessageDefault: false,
|
||||
isReplyDefault: false,
|
||||
});
|
||||
}
|
||||
if (next.update) {
|
||||
const { id, name, bodyHtml, bodyText, updatedAt } = next.update;
|
||||
await manager.update(
|
||||
MailSignatureEntity,
|
||||
{ id, userEmail },
|
||||
{ name, bodyHtml, bodyText, updatedAt },
|
||||
);
|
||||
}
|
||||
if (next.removeId) {
|
||||
await manager.delete(MailSignatureEntity, {
|
||||
id: next.removeId,
|
||||
userEmail,
|
||||
});
|
||||
}
|
||||
if (next.defaults) {
|
||||
await this.applyDefaults(manager, userEmail, next.defaults);
|
||||
}
|
||||
|
||||
const referenced = new Set(next.referencedImageIds);
|
||||
const attach = images
|
||||
.filter(
|
||||
(image) => referenced.has(image.id) && image.attachedAt === null,
|
||||
@@ -105,7 +179,6 @@ export class MailSignatureRepository {
|
||||
!referenced.has(image.id) &&
|
||||
(image.attachedAt !== null || image.createdAt <= keepUploadedAfter),
|
||||
);
|
||||
|
||||
if (attach.length > 0) {
|
||||
await manager.update(
|
||||
MailSignatureImageEntity,
|
||||
@@ -118,9 +191,13 @@ export class MailSignatureRepository {
|
||||
id: In(stale.map((image) => image.id)),
|
||||
});
|
||||
}
|
||||
|
||||
const attached = new Set(attach);
|
||||
return {
|
||||
signature,
|
||||
signatures: await manager.find(MailSignatureEntity, {
|
||||
where: { userEmail },
|
||||
order: SIGNATURE_ORDER,
|
||||
}),
|
||||
images: images
|
||||
.filter((image) => !stale.includes(image))
|
||||
.map((image) =>
|
||||
@@ -131,6 +208,33 @@ export class MailSignatureRepository {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 기본 표시를 그 값으로 — 칸마다 먼저 다른 줄의 표시를 끄고 그 줄을 켠다(한 문장으로 바꾸면 줄마다 보는 유일 인덱스에 걸릴 수 있다).
|
||||
* id 는 서비스가 그 사람의 서명인지 본 값이다 (주인 조건도 함께 건다)
|
||||
*/
|
||||
private async applyDefaults(
|
||||
manager: EntityManager,
|
||||
userEmail: string,
|
||||
defaults: MailSignatureDefaults,
|
||||
): Promise<void> {
|
||||
const columns = [
|
||||
['is_new_message_default', defaults.newMessageId],
|
||||
['is_reply_default', defaults.replyId],
|
||||
] as const;
|
||||
for (const [column, id] of columns) {
|
||||
await manager.query(
|
||||
`UPDATE "mail_signatures" SET "${column}" = false WHERE "user_email" = $1 AND "${column}" AND "id" IS DISTINCT FROM $2::uuid`,
|
||||
[userEmail, id],
|
||||
);
|
||||
if (id !== null) {
|
||||
await manager.query(
|
||||
`UPDATE "mail_signatures" SET "${column}" = true WHERE "user_email" = $1 AND "id" = $2::uuid AND NOT "${column}"`,
|
||||
[userEmail, id],
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 서명에 붙지 않은 채 `before` 전에 올린 그림 (정리 작업용 — 부분 인덱스를 탄다) */
|
||||
findUnattachedBefore(before: Date): Promise<MailSignatureImageEntity[]> {
|
||||
return this.images.find({
|
||||
|
||||
@@ -8,6 +8,8 @@ import { BusinessException } from '@/common/errors/business.exception';
|
||||
import { MAX_BODY_HTML_LENGTH } from '@/modules/mail/mail.constants';
|
||||
import { MailSignatureImageEntity } from './mail-signature-image.entity';
|
||||
import {
|
||||
LEGACY_SIGNATURE_NAME,
|
||||
SIGNATURES_MAX,
|
||||
SIGNATURE_IMAGES_PER_SIGNATURE,
|
||||
SIGNATURE_IMAGES_STORED_MAX,
|
||||
SIGNATURE_IMAGE_MAX_BYTES,
|
||||
@@ -17,34 +19,37 @@ import {
|
||||
import { MailSignatureEntity } from './mail-signature.entity';
|
||||
import type {
|
||||
MailSignatureRepository,
|
||||
PrepareSignature,
|
||||
SavedSignature,
|
||||
PlanSignatureChange,
|
||||
SignatureChangeResult,
|
||||
SignaturePlan,
|
||||
} from './mail-signature.repository';
|
||||
import { MailSignatureService } from './mail-signature.service';
|
||||
import type { MailSignatureDefaults } from './mail-signature.types';
|
||||
import { SignatureImageStorage } from './signature-image-storage';
|
||||
|
||||
const ARA = 'ara.kim@arasystem.test';
|
||||
const DOYUN = 'doyun.lee@arasystem.test';
|
||||
const src = (id: string) => `/api/mail-signature/images/${id}`;
|
||||
const NOT_MINE = '11111111-1111-4111-8111-111111111111';
|
||||
|
||||
/**
|
||||
* 표 대역 — MailSignatureRepository 와 같은 판단(주인 조건·저장 때 지울 그림)을 메모리에서 한다.
|
||||
* 실제 SQL(트랜잭션·부분 인덱스)은 e2e 가 본다.
|
||||
* 표 대역 — MailSignatureRepository 와 같은 판단(주인 조건 · 기본 표시 · 바꾼 뒤 지울 그림)을 메모리에서 한다.
|
||||
* 실제 SQL(트랜잭션 · 사용자 잠금 · 부분 유일 인덱스)은 e2e 가 본다.
|
||||
*/
|
||||
class FakeRepository {
|
||||
readonly signatures = new Map<string, MailSignatureEntity>();
|
||||
signatures: MailSignatureEntity[] = [];
|
||||
images: MailSignatureImageEntity[] = [];
|
||||
/** 다음 insertImage 를 실패시킨다 */
|
||||
failNextInsert = false;
|
||||
/** 다음 저장이 잠그기 직전에 이 그림이 지워진다 (다른 탭의 저장·정리 작업이 먼저 끝난 경우) */
|
||||
deletedBeforeNextSave: string | null = null;
|
||||
readonly saveCalls: {
|
||||
referenced: readonly string[];
|
||||
/** 다음 바꾸기가 잠그기 직전에 이 그림이 지워진다 (다른 탭의 저장·정리 작업이 먼저 끝난 경우) */
|
||||
deletedBeforeNextChange: string | null = null;
|
||||
readonly changeCalls: {
|
||||
plan: SignaturePlan;
|
||||
keepUploadedAfter: Date;
|
||||
}[] = [];
|
||||
|
||||
findSignature(user: string): Promise<MailSignatureEntity | null> {
|
||||
return Promise.resolve(this.signatures.get(user) ?? null);
|
||||
listSignatures(user: string): Promise<MailSignatureEntity[]> {
|
||||
return Promise.resolve(this.mine(user).map((row) => ({ ...row })));
|
||||
}
|
||||
|
||||
listImages(user: string): Promise<MailSignatureImageEntity[]> {
|
||||
@@ -76,38 +81,91 @@ class FakeRepository {
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
saveSignature(
|
||||
change(
|
||||
user: string,
|
||||
prepare: PrepareSignature,
|
||||
plan: PlanSignatureChange,
|
||||
now: Date,
|
||||
keepUploadedAfter: Date,
|
||||
): Promise<SavedSignature> {
|
||||
if (this.deletedBeforeNextSave) {
|
||||
const gone = this.deletedBeforeNextSave;
|
||||
this.deletedBeforeNextSave = null;
|
||||
): Promise<SignatureChangeResult> {
|
||||
if (this.deletedBeforeNextChange) {
|
||||
const gone = this.deletedBeforeNextChange;
|
||||
this.deletedBeforeNextChange = null;
|
||||
this.images = this.images.filter((row) => row.id !== gone);
|
||||
}
|
||||
const mine = this.images.filter((row) => row.userEmail === user);
|
||||
const { signature, referencedImageIds: referenced } = prepare(mine);
|
||||
this.saveCalls.push({ referenced, keepUploadedAfter });
|
||||
this.signatures.set(signature.userEmail, signature);
|
||||
for (const row of mine) {
|
||||
if (referenced.includes(row.id) && row.attachedAt === null) {
|
||||
const images = this.images.filter((row) => row.userEmail === user);
|
||||
let next: SignaturePlan;
|
||||
try {
|
||||
next = plan({
|
||||
signatures: this.mine(user).map((row) => ({ ...row })),
|
||||
images,
|
||||
});
|
||||
} catch (error) {
|
||||
// 정한 것을 던지면(한도 · 이름 겹침) 아무것도 바꾸지 않는다 — 실제 표는 트랜잭션을 되돌린다
|
||||
return Promise.reject(
|
||||
error instanceof Error ? error : new Error(String(error)),
|
||||
);
|
||||
}
|
||||
this.changeCalls.push({ plan: next, keepUploadedAfter });
|
||||
|
||||
if (next.insert) {
|
||||
this.signatures.push({
|
||||
...next.insert,
|
||||
isNewMessageDefault: false,
|
||||
isReplyDefault: false,
|
||||
});
|
||||
}
|
||||
if (next.update) {
|
||||
const { id, name, bodyHtml, bodyText, updatedAt } = next.update;
|
||||
this.signatures = this.signatures.map((row) =>
|
||||
row.id === id && row.userEmail === user
|
||||
? { ...row, name, bodyHtml, bodyText, updatedAt }
|
||||
: row,
|
||||
);
|
||||
}
|
||||
if (next.removeId) {
|
||||
this.signatures = this.signatures.filter(
|
||||
(row) => !(row.id === next.removeId && row.userEmail === user),
|
||||
);
|
||||
}
|
||||
if (next.defaults) this.applyDefaults(user, next.defaults);
|
||||
|
||||
const referenced = new Set(next.referencedImageIds);
|
||||
for (const row of images) {
|
||||
if (referenced.has(row.id) && row.attachedAt === null) {
|
||||
row.attachedAt = now;
|
||||
}
|
||||
}
|
||||
const stale = mine.filter(
|
||||
const stale = images.filter(
|
||||
(row) =>
|
||||
!referenced.includes(row.id) &&
|
||||
!referenced.has(row.id) &&
|
||||
(row.attachedAt !== null || row.createdAt <= keepUploadedAfter),
|
||||
);
|
||||
this.images = this.images.filter((row) => !stale.includes(row));
|
||||
return Promise.resolve({
|
||||
signature,
|
||||
images: mine.filter((row) => !stale.includes(row)),
|
||||
signatures: this.mine(user).map((row) => ({ ...row })),
|
||||
images: images.filter((row) => !stale.includes(row)),
|
||||
removed: stale,
|
||||
});
|
||||
}
|
||||
|
||||
/** 만든 차례 (서비스가 사람마다 만든 시각을 늘 늘려 준다) */
|
||||
private mine(user: string): MailSignatureEntity[] {
|
||||
return this.signatures
|
||||
.filter((row) => row.userEmail === user)
|
||||
.sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime());
|
||||
}
|
||||
|
||||
private applyDefaults(user: string, defaults: MailSignatureDefaults): void {
|
||||
this.signatures = this.signatures.map((row) =>
|
||||
row.userEmail === user
|
||||
? {
|
||||
...row,
|
||||
isNewMessageDefault: row.id === defaults.newMessageId,
|
||||
isReplyDefault: row.id === defaults.replyId,
|
||||
}
|
||||
: row,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
describe('MailSignatureService', () => {
|
||||
@@ -139,7 +197,7 @@ describe('MailSignatureService', () => {
|
||||
): Promise<MailSignatureImageEntity> {
|
||||
const id =
|
||||
overrides.id ??
|
||||
`0000000${repository.images.length}-0000-4000-8000-000000000000`;
|
||||
`${String(repository.images.length).padStart(8, '0')}-0000-4000-8000-000000000000`;
|
||||
const row: MailSignatureImageEntity = {
|
||||
id,
|
||||
userEmail: ARA,
|
||||
@@ -157,6 +215,10 @@ describe('MailSignatureService', () => {
|
||||
return row;
|
||||
}
|
||||
|
||||
/** 서명 하나를 만든다 (이름 · 텍스트만) */
|
||||
const create = (name: string, extra: { bodyHtml?: string | null } = {}) =>
|
||||
service.createSignature(ARA, { name, body: `${name} 서명`, ...extra });
|
||||
|
||||
beforeEach(() => {
|
||||
dir = mkdtempSync(join(tmpdir(), 'signature-images-'));
|
||||
repository = new FakeRepository();
|
||||
@@ -175,110 +237,269 @@ describe('MailSignatureService', () => {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('저장한 적 없으면 기본값을 돌려준다', async () => {
|
||||
await expect(service.getSignature(ARA)).resolves.toEqual({
|
||||
bodyHtml: null,
|
||||
body: null,
|
||||
isForNew: true,
|
||||
isForReply: true,
|
||||
images: [],
|
||||
updatedAt: null,
|
||||
describe('여러 서명 (16단계)', () => {
|
||||
it('서명이 없으면 빈 목록 · 기본 둘 null — 첫 서명은 두 곳의 기본이 되고, 다음 서명은 기본을 바꾸지 않는다', async () => {
|
||||
await expect(service.listSignatures(ARA)).resolves.toEqual({
|
||||
items: [],
|
||||
pageInfo: { hasNext: false, nextCursor: null, total: 0 },
|
||||
});
|
||||
await expect(service.getDefaults(ARA)).resolves.toEqual({
|
||||
newMessageId: null,
|
||||
replyId: null,
|
||||
});
|
||||
|
||||
const first = await create('사내');
|
||||
await expect(service.getDefaults(ARA)).resolves.toEqual({
|
||||
newMessageId: first.id,
|
||||
replyId: first.id,
|
||||
});
|
||||
|
||||
const second = await create('대외');
|
||||
await expect(service.getDefaults(ARA)).resolves.toEqual({
|
||||
newMessageId: first.id,
|
||||
replyId: first.id,
|
||||
});
|
||||
const list = await service.listSignatures(` ${ARA.toUpperCase()} `);
|
||||
expect(list.items.map((item) => [item.id, item.name])).toEqual([
|
||||
[first.id, '사내'],
|
||||
[second.id, '대외'],
|
||||
]);
|
||||
expect(list.pageInfo).toEqual({
|
||||
hasNext: false,
|
||||
nextCursor: null,
|
||||
total: 2,
|
||||
});
|
||||
// 다른 사람의 서명은 따로다 — 그 사람의 첫 서명도 그 사람의 기본이 된다
|
||||
const doyun = await service.createSignature(DOYUN, { name: '사내' });
|
||||
await expect(service.getDefaults(DOYUN)).resolves.toEqual({
|
||||
newMessageId: doyun.id,
|
||||
replyId: doyun.id,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('저장', () => {
|
||||
it('서식 서명은 보내는 정화를 거쳐 저장한다 — 내 그림만 남고, 서명이 쓰는 그림을 함께 돌려준다', async () => {
|
||||
it('서식은 보내는 정화를 거쳐 저장한다 — 내 그림만 남고, 서명이 쓰는 그림 · 저장 시각을 함께 돌려준다', async () => {
|
||||
const mine = await storedImage();
|
||||
const html = `<p onclick="x()"><strong>김아라</strong><script>alert(1)</script></p><img src="${src(mine.id)}" width="144" alt="" style="border:0"><img src="${src('11111111-1111-4111-8111-111111111111')}">`;
|
||||
const html = `<p onclick="x()"><strong>김아라</strong><script>alert(1)</script></p><img src="${src(mine.id)}" width="144" alt="" style="border:0"><img src="${src(NOT_MINE)}">`;
|
||||
|
||||
const saved = await service.saveSignature(` ${ARA.toUpperCase()} `, {
|
||||
const saved = await service.createSignature(ARA, {
|
||||
name: '대외',
|
||||
bodyHtml: html,
|
||||
body: '김아라 · 아라시스템',
|
||||
isForNew: true,
|
||||
isForReply: false,
|
||||
});
|
||||
|
||||
expect(saved).toEqual({
|
||||
id: expect.stringMatching(/^[0-9a-f-]{36}$/) as string,
|
||||
name: '대외',
|
||||
bodyHtml: `<p><strong>김아라</strong></p><img src="${src(mine.id)}" width="144" alt="" />`,
|
||||
body: '김아라 · 아라시스템',
|
||||
isForNew: true,
|
||||
isForReply: false,
|
||||
images: [
|
||||
{ id: mine.id, type: 'image/png', size: 33, width: 10, height: 10 },
|
||||
],
|
||||
updatedAt: expect.any(String) as string,
|
||||
});
|
||||
// 주소는 소문자로 모은다 — 같은 사람의 서명이 둘로 나뉘지 않게
|
||||
expect(repository.signatures.has(ARA)).toBe(true);
|
||||
expect(repository.saveCalls[0].referenced).toEqual([mine.id]);
|
||||
await expect(service.getSignature(ARA)).resolves.toEqual(saved);
|
||||
expect(repository.changeCalls[0].plan.referencedImageIds).toEqual([
|
||||
mine.id,
|
||||
]);
|
||||
expect((await service.listSignatures(ARA)).items).toEqual([saved]);
|
||||
});
|
||||
|
||||
it('막 올린 그림(10분 안)은 서명에 없어도 남기도록 기준 시각을 넘긴다 — 올리기와 저장이 엇갈려도 편집기의 그림이 사라지지 않게', async () => {
|
||||
const before = Date.now();
|
||||
it(`${SIGNATURES_MAX}개까지 — 11번째는 400 VAL_001, 같은 이름(영문 대소문자 무시)도 VAL_001 — 아무것도 저장하지 않는다`, async () => {
|
||||
await create('Office');
|
||||
expect(await failureOf(() => create('office'))).toEqual({
|
||||
code: 'VAL_001',
|
||||
reason: 'signature-name-taken',
|
||||
});
|
||||
// 영문 밖의 글자는 대소문자를 가리지 않고 그대로 견준다
|
||||
await expect(create('ÖFFICE')).resolves.toMatchObject({ name: 'ÖFFICE' });
|
||||
for (let index = 3; index <= SIGNATURES_MAX; index += 1) {
|
||||
await create(`서명 ${index}`);
|
||||
}
|
||||
const before = repository.signatures.length;
|
||||
expect(await failureOf(() => create('열한 번째'))).toEqual({
|
||||
code: 'VAL_001',
|
||||
reason: 'too-many-signatures',
|
||||
});
|
||||
expect(repository.signatures).toHaveLength(before);
|
||||
});
|
||||
|
||||
await service.saveSignature(ARA, {
|
||||
bodyHtml: null,
|
||||
body: null,
|
||||
isForNew: true,
|
||||
isForReply: true,
|
||||
it('고치기는 이름 · 본문을 바꾸고 기본 표시는 그대로 — 다른 서명과 같은 이름은 VAL_001, 자기 이름(대소문자만 바꿈)은 된다', async () => {
|
||||
const a = await create('사내');
|
||||
const b = await create('대외');
|
||||
|
||||
const updated = await service.updateSignature(ARA, a.id.toUpperCase(), {
|
||||
name: '사내 (새)',
|
||||
body: '바꾼 서명',
|
||||
bodyHtml: '<p>바꾼 서명</p>',
|
||||
});
|
||||
expect(updated).toMatchObject({
|
||||
id: a.id,
|
||||
name: '사내 (새)',
|
||||
body: '바꾼 서명',
|
||||
bodyHtml: '<p>바꾼 서명</p>',
|
||||
});
|
||||
await expect(service.getDefaults(ARA)).resolves.toEqual({
|
||||
newMessageId: a.id,
|
||||
replyId: a.id,
|
||||
});
|
||||
|
||||
const { keepUploadedAfter } = repository.saveCalls[0];
|
||||
expect(
|
||||
await failureOf(() =>
|
||||
service.updateSignature(ARA, b.id, { name: '사내 (새)' }),
|
||||
),
|
||||
).toEqual({ code: 'VAL_001', reason: 'signature-name-taken' });
|
||||
await expect(
|
||||
service.updateSignature(ARA, b.id, { name: '대외' }),
|
||||
).resolves.toMatchObject({ name: '대외', body: null, bodyHtml: null });
|
||||
});
|
||||
|
||||
it('없는 서명 · 남의 서명은 RES_001 — 고치기 · 지우기 · 기본 고르기 모두, 아무것도 바꾸지 않는다', async () => {
|
||||
const mine = await create('사내');
|
||||
const doyuns = await service.createSignature(DOYUN, { name: '대외' });
|
||||
|
||||
for (const id of [doyuns.id, NOT_MINE, 'not-a-uuid']) {
|
||||
expect(
|
||||
await failureOf(() =>
|
||||
service.updateSignature(ARA, id, { name: '바꿈' }),
|
||||
),
|
||||
).toEqual({ code: 'RES_001', reason: 'signature-not-found' });
|
||||
expect(await failureOf(() => service.deleteSignature(ARA, id))).toEqual(
|
||||
{ code: 'RES_001', reason: 'signature-not-found' },
|
||||
);
|
||||
expect(
|
||||
await failureOf(() =>
|
||||
service.setDefaults(ARA, { newMessageId: mine.id, replyId: id }),
|
||||
),
|
||||
).toEqual({ code: 'RES_001', reason: 'signature-not-found' });
|
||||
}
|
||||
await expect(service.getDefaults(ARA)).resolves.toEqual({
|
||||
newMessageId: mine.id,
|
||||
replyId: mine.id,
|
||||
});
|
||||
expect(repository.signatures).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('기본 고르기 — 두 곳을 따로 · 같은 서명 · 넣지 않음(null)으로, 한 사람에 하나씩', async () => {
|
||||
const a = await create('사내');
|
||||
const b = await create('대외');
|
||||
|
||||
await expect(
|
||||
service.setDefaults(ARA, { newMessageId: b.id, replyId: a.id }),
|
||||
).resolves.toEqual({ newMessageId: b.id, replyId: a.id });
|
||||
await expect(
|
||||
service.setDefaults(ARA, {
|
||||
newMessageId: null,
|
||||
replyId: b.id.toUpperCase(),
|
||||
}),
|
||||
).resolves.toEqual({ newMessageId: null, replyId: b.id });
|
||||
expect(
|
||||
repository.signatures.filter((row) => row.isReplyDefault),
|
||||
).toHaveLength(1);
|
||||
await expect(service.getDefaults(ARA)).resolves.toEqual({
|
||||
newMessageId: null,
|
||||
replyId: b.id,
|
||||
});
|
||||
});
|
||||
|
||||
it('지우면 그 서명을 기본으로 쓰던 곳은 넣지 않음 — 다른 서명이 저절로 기본이 되지 않는다', async () => {
|
||||
const a = await create('사내');
|
||||
const b = await create('대외');
|
||||
await service.setDefaults(ARA, { newMessageId: a.id, replyId: b.id });
|
||||
|
||||
await service.deleteSignature(ARA, a.id);
|
||||
|
||||
await expect(service.getDefaults(ARA)).resolves.toEqual({
|
||||
newMessageId: null,
|
||||
replyId: b.id,
|
||||
});
|
||||
expect(
|
||||
(await service.listSignatures(ARA)).items.map((item) => item.id),
|
||||
).toEqual([b.id]);
|
||||
});
|
||||
|
||||
it('그림 정리는 모든 서명 HTML 의 합집합 — 다른 서명이 쓰는 그림은 남기고, 지운 서명에만 쓰던 그림은 표 · 파일에서 지운다', async () => {
|
||||
const shared = await storedImage();
|
||||
const onlyA = await storedImage();
|
||||
// 막 올린 그림 — 사내를 저장할 때는 아직 어느 서명에도 없지만 남는다(10분 안)
|
||||
const onlyB = await storedImage({ createdAt: new Date() });
|
||||
const img = (id: string) => `<img src="${src(id)}" width="96">`;
|
||||
const a = await create('사내', {
|
||||
bodyHtml: `${img(shared.id)}${img(onlyA.id)}`,
|
||||
});
|
||||
const b = await create('대외', {
|
||||
bodyHtml: `${img(shared.id)}${img(onlyB.id)}`,
|
||||
});
|
||||
expect(repository.images.map((row) => row.id)).toEqual([
|
||||
shared.id,
|
||||
onlyA.id,
|
||||
onlyB.id,
|
||||
]);
|
||||
|
||||
// a 에서 shared 를 빼도 b 가 쓰므로 남는다
|
||||
await service.updateSignature(ARA, a.id, {
|
||||
name: '사내',
|
||||
bodyHtml: img(onlyA.id),
|
||||
});
|
||||
expect(repository.images.map((row) => row.id)).toEqual([
|
||||
shared.id,
|
||||
onlyA.id,
|
||||
onlyB.id,
|
||||
]);
|
||||
|
||||
// a 를 지우면 a 에만 있던 그림이 사라진다
|
||||
await service.deleteSignature(ARA, a.id);
|
||||
expect(repository.images.map((row) => row.id)).toEqual([
|
||||
shared.id,
|
||||
onlyB.id,
|
||||
]);
|
||||
expect(existsSync(join(dir, onlyA.fileName))).toBe(false);
|
||||
expect(existsSync(join(dir, shared.fileName))).toBe(true);
|
||||
|
||||
// 남은 서명이 그림을 모두 빼면 둘 다 지운다
|
||||
await service.updateSignature(ARA, b.id, { name: '대외', body: '글만' });
|
||||
expect(repository.images).toEqual([]);
|
||||
});
|
||||
|
||||
it('막 올린 그림(10분 안)은 어느 서명에도 없어도 남기도록 기준 시각을 넘긴다 — 올리기와 저장이 엇갈려도 편집기의 그림이 사라지지 않게', async () => {
|
||||
const before = Date.now();
|
||||
const fresh = await storedImage({ createdAt: new Date() });
|
||||
|
||||
await create('사내');
|
||||
|
||||
const { keepUploadedAfter } = repository.changeCalls[0];
|
||||
expect(keepUploadedAfter.getTime()).toBeGreaterThanOrEqual(
|
||||
before - SIGNATURE_IMAGE_UPLOAD_GRACE_MS,
|
||||
);
|
||||
expect(keepUploadedAfter.getTime()).toBeLessThanOrEqual(
|
||||
Date.now() - SIGNATURE_IMAGE_UPLOAD_GRACE_MS,
|
||||
);
|
||||
expect(repository.images.map((row) => row.id)).toEqual([fresh.id]);
|
||||
});
|
||||
|
||||
it('잠그기 전에 다른 저장·정리 작업이 지운 그림은 서명에서 뺀다 — 없는 그림을 가리키는 서명을 남기지 않는다', async () => {
|
||||
const kept = await storedImage();
|
||||
const gone = await storedImage();
|
||||
repository.deletedBeforeNextSave = gone.id;
|
||||
repository.deletedBeforeNextChange = gone.id;
|
||||
|
||||
const saved = await service.saveSignature(ARA, {
|
||||
const saved = await create('사내', {
|
||||
bodyHtml: `<p>서명</p><img src="${src(kept.id)}" width="96"><img src="${src(gone.id)}" width="96">`,
|
||||
body: null,
|
||||
isForNew: true,
|
||||
isForReply: true,
|
||||
});
|
||||
|
||||
expect(saved.bodyHtml).toBe(
|
||||
`<p>서명</p><img src="${src(kept.id)}" width="96" />`,
|
||||
);
|
||||
expect(saved.images.map((image) => image.id)).toEqual([kept.id]);
|
||||
expect(repository.saveCalls[0].referenced).toEqual([kept.id]);
|
||||
|
||||
// 그림만 있던 서명에서 그림이 모두 사라지면 서식 서명이 없다
|
||||
repository.deletedBeforeNextSave = kept.id;
|
||||
const empty = await service.saveSignature(ARA, {
|
||||
repository.deletedBeforeNextChange = kept.id;
|
||||
const empty = await service.updateSignature(ARA, saved.id, {
|
||||
name: '사내',
|
||||
bodyHtml: `<img src="${src(kept.id)}">`,
|
||||
body: '텍스트 서명',
|
||||
isForNew: true,
|
||||
isForReply: true,
|
||||
});
|
||||
expect(empty.bodyHtml).toBeNull();
|
||||
expect(empty.body).toBe('텍스트 서명');
|
||||
});
|
||||
|
||||
it('서명이 더는 가리키지 않는 그림은 표와 파일에서 지운다', async () => {
|
||||
const kept = await storedImage({ attachedAt: new Date() });
|
||||
const dropped = await storedImage({ attachedAt: new Date() });
|
||||
|
||||
await service.saveSignature(ARA, {
|
||||
bodyHtml: `<img src="${src(kept.id)}">`,
|
||||
body: null,
|
||||
isForNew: true,
|
||||
isForReply: true,
|
||||
});
|
||||
|
||||
expect(repository.images.map((row) => row.id)).toEqual([kept.id]);
|
||||
expect(existsSync(join(dir, kept.fileName))).toBe(true);
|
||||
expect(existsSync(join(dir, dropped.fileName))).toBe(false);
|
||||
});
|
||||
|
||||
it('지운 그림의 파일을 지우지 못해도 저장은 끝난다 — 경고만 (남은 파일은 정리 작업이 지운다)', async () => {
|
||||
const dropped = await storedImage({ attachedAt: new Date() });
|
||||
jest
|
||||
@@ -286,12 +507,7 @@ describe('MailSignatureService', () => {
|
||||
.mockRejectedValue(new Error('EACCES: permission denied'));
|
||||
|
||||
await expect(
|
||||
service.saveSignature(ARA, {
|
||||
bodyHtml: '<p>그림 없음</p>',
|
||||
body: null,
|
||||
isForNew: true,
|
||||
isForReply: true,
|
||||
}),
|
||||
create('사내', { bodyHtml: '<p>그림 없음</p>' }),
|
||||
).resolves.toMatchObject({ bodyHtml: '<p>그림 없음</p>', images: [] });
|
||||
expect(repository.images).not.toContainEqual(dropped);
|
||||
expect(warnSpy).toHaveBeenCalledWith(
|
||||
@@ -300,12 +516,12 @@ describe('MailSignatureService', () => {
|
||||
});
|
||||
|
||||
it('빈 서식·텍스트, 정화해서 비는 서식은 null 로 저장한다', async () => {
|
||||
const row = await create('사내');
|
||||
for (const bodyHtml of ['', '<script>x()</script>', null, undefined]) {
|
||||
const saved = await service.saveSignature(ARA, {
|
||||
const saved = await service.updateSignature(ARA, row.id, {
|
||||
name: '사내',
|
||||
bodyHtml,
|
||||
body: '',
|
||||
isForNew: false,
|
||||
isForReply: true,
|
||||
});
|
||||
expect(saved.bodyHtml).toBeNull();
|
||||
expect(saved.body).toBeNull();
|
||||
@@ -322,27 +538,22 @@ describe('MailSignatureService', () => {
|
||||
images.push(await storedImage());
|
||||
}
|
||||
const img = (id: string) => `<img src="${src(id)}" width="96">`;
|
||||
const save = (bodyHtml: string) =>
|
||||
service.saveSignature(ARA, {
|
||||
bodyHtml,
|
||||
body: null,
|
||||
isForNew: true,
|
||||
isForReply: true,
|
||||
});
|
||||
|
||||
expect(
|
||||
await failureOf(() =>
|
||||
save(images.map((image) => img(image.id)).join('')),
|
||||
create('사내', {
|
||||
bodyHtml: images.map((image) => img(image.id)).join(''),
|
||||
}),
|
||||
),
|
||||
).toEqual({ code: 'VAL_001', reason: 'signature-too-many-images' });
|
||||
expect(repository.saveCalls).toHaveLength(0);
|
||||
expect(repository.changeCalls).toHaveLength(0);
|
||||
|
||||
// 세 장 + 같은 그림 한 번 더 + 남의 그림 — 서로 다른 내 그림은 세 장이다
|
||||
const [a, b, c] = images;
|
||||
const saved = await save(
|
||||
[a, b, c, a].map((image) => img(image.id)).join('') +
|
||||
img('11111111-1111-4111-8111-111111111111'),
|
||||
);
|
||||
const saved = await create('사내', {
|
||||
bodyHtml:
|
||||
[a, b, c, a].map((image) => img(image.id)).join('') + img(NOT_MINE),
|
||||
});
|
||||
expect(saved.images.map((image) => image.id)).toEqual([a.id, b.id, c.id]);
|
||||
});
|
||||
|
||||
@@ -363,24 +574,207 @@ describe('MailSignatureService', () => {
|
||||
for (const [input, reason] of cases) {
|
||||
expect(
|
||||
await failureOf(() =>
|
||||
service.saveSignature(ARA, {
|
||||
isForNew: true,
|
||||
isForReply: true,
|
||||
...input,
|
||||
}),
|
||||
service.createSignature(ARA, { name: '사내', ...input }),
|
||||
),
|
||||
).toEqual({ code: 'VAL_001', reason });
|
||||
}
|
||||
expect(repository.saveCalls).toHaveLength(0);
|
||||
expect(repository.changeCalls).toHaveLength(0);
|
||||
|
||||
// 경계 — 정확히 2,000자 · 정화한 결과 10,000자는 저장한다
|
||||
const saved = await service.saveSignature(ARA, {
|
||||
const saved = await service.createSignature(ARA, {
|
||||
name: '사내',
|
||||
body: '가'.repeat(2_000),
|
||||
bodyHtml: `<p>${'가'.repeat(10_000 - 7)}</p>`,
|
||||
});
|
||||
expect(saved.bodyHtml).toHaveLength(10_000);
|
||||
});
|
||||
});
|
||||
|
||||
describe('옛 경로 — 「새 메일」 기본 서명 (앱 첫 판 · 옛 웹 탭)', () => {
|
||||
it('「새 메일」 기본 서명을 네 칸으로(isForReply 는 답장 기본이 같은 서명일 때만) — 없으면 답장 기본 서명, 둘 다 없으면 본문 없음 · 두 표시 false', async () => {
|
||||
await expect(service.getLegacySignature(ARA)).resolves.toEqual({
|
||||
bodyHtml: null,
|
||||
body: null,
|
||||
isForNew: false,
|
||||
isForReply: false,
|
||||
images: [],
|
||||
updatedAt: null,
|
||||
});
|
||||
|
||||
const image = await storedImage();
|
||||
const a = await create('사내', {
|
||||
bodyHtml: `<p>사내</p><img src="${src(image.id)}" width="96">`,
|
||||
});
|
||||
const b = await create('대외');
|
||||
await expect(service.getLegacySignature(ARA)).resolves.toEqual({
|
||||
bodyHtml: a.bodyHtml,
|
||||
body: '사내 서명',
|
||||
isForNew: true,
|
||||
isForReply: true,
|
||||
images: a.images,
|
||||
updatedAt: a.updatedAt,
|
||||
});
|
||||
|
||||
await service.setDefaults(ARA, { newMessageId: a.id, replyId: b.id });
|
||||
await expect(service.getLegacySignature(ARA)).resolves.toMatchObject({
|
||||
body: '사내 서명',
|
||||
isForNew: true,
|
||||
isForReply: false,
|
||||
});
|
||||
// 「새 메일」 기본이 없으면 답장 기본 서명을 isForNew false 로 — 옛 두 체크가 새 메일 끔 · 답장 켬이던 직원이
|
||||
// 앱 첫 판의 답장에서 서명을 잃지 않게 (리뷰 L4)
|
||||
await service.setDefaults(ARA, { newMessageId: null, replyId: b.id });
|
||||
await expect(service.getLegacySignature(ARA)).resolves.toEqual({
|
||||
bodyHtml: null,
|
||||
body: '대외 서명',
|
||||
isForNew: false,
|
||||
isForReply: true,
|
||||
images: [],
|
||||
updatedAt: b.updatedAt,
|
||||
});
|
||||
// 둘 다 없으면 다시 본문 없음
|
||||
await service.setDefaults(ARA, { newMessageId: null, replyId: null });
|
||||
await expect(service.getLegacySignature(ARA)).resolves.toEqual({
|
||||
bodyHtml: null,
|
||||
body: null,
|
||||
isForNew: false,
|
||||
isForReply: false,
|
||||
images: [],
|
||||
updatedAt: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('저장 — 서명이 없으면 "기본 서명"을 만들고, 두 표시를 기본 둘로 옮긴다', async () => {
|
||||
const saved = await service.saveLegacySignature(ARA, {
|
||||
bodyHtml: '<p>김아라</p>',
|
||||
body: '김아라',
|
||||
isForNew: true,
|
||||
isForReply: false,
|
||||
});
|
||||
|
||||
expect(saved).toMatchObject({
|
||||
bodyHtml: '<p>김아라</p>',
|
||||
body: '김아라',
|
||||
isForNew: true,
|
||||
isForReply: false,
|
||||
});
|
||||
const [row] = (await service.listSignatures(ARA)).items;
|
||||
expect(row.name).toBe(LEGACY_SIGNATURE_NAME);
|
||||
await expect(service.getDefaults(ARA)).resolves.toEqual({
|
||||
newMessageId: row.id,
|
||||
replyId: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('저장 — 「새 메일」 기본 서명의 본문만 바꾸고(이름 그대로), 답장 기본이 다른 서명이면 isForReply false 로는 그대로 둔다', async () => {
|
||||
const a = await create('사내');
|
||||
const b = await create('대외');
|
||||
await service.setDefaults(ARA, { newMessageId: a.id, replyId: b.id });
|
||||
|
||||
await service.saveLegacySignature(ARA, {
|
||||
body: '바꾼 글',
|
||||
isForNew: true,
|
||||
isForReply: false,
|
||||
});
|
||||
const items = (await service.listSignatures(ARA)).items;
|
||||
expect(items.map((item) => [item.name, item.body])).toEqual([
|
||||
['사내', '바꾼 글'],
|
||||
['대외', '대외 서명'],
|
||||
]);
|
||||
await expect(service.getDefaults(ARA)).resolves.toEqual({
|
||||
newMessageId: a.id,
|
||||
replyId: b.id,
|
||||
});
|
||||
|
||||
// isForReply true → 답장 기본도 이 서명으로
|
||||
await service.saveLegacySignature(ARA, {
|
||||
body: '바꾼 글',
|
||||
isForNew: true,
|
||||
isForReply: true,
|
||||
});
|
||||
expect(saved.bodyHtml).toHaveLength(10_000);
|
||||
await expect(service.getDefaults(ARA)).resolves.toEqual({
|
||||
newMessageId: a.id,
|
||||
replyId: a.id,
|
||||
});
|
||||
});
|
||||
|
||||
it('저장 — 「새 메일」 기본이 없으면 옛 GET 이 보인 답장 기본 서명을 고친다 (새로 만들지 않는다)', async () => {
|
||||
await create('사내');
|
||||
const b = await create('대외');
|
||||
await service.setDefaults(ARA, { newMessageId: null, replyId: b.id });
|
||||
|
||||
const saved = await service.saveLegacySignature(ARA, {
|
||||
body: '바꾼 글',
|
||||
isForNew: false,
|
||||
isForReply: true,
|
||||
});
|
||||
|
||||
expect(saved).toMatchObject({
|
||||
body: '바꾼 글',
|
||||
isForNew: false,
|
||||
isForReply: true,
|
||||
});
|
||||
const items = (await service.listSignatures(ARA)).items;
|
||||
expect(items.map((item) => [item.name, item.body])).toEqual([
|
||||
['사내', '사내 서명'],
|
||||
['대외', '바꾼 글'],
|
||||
]);
|
||||
await expect(service.getDefaults(ARA)).resolves.toEqual({
|
||||
newMessageId: null,
|
||||
replyId: b.id,
|
||||
});
|
||||
});
|
||||
|
||||
it('저장 — 「새 메일」을 끈 채로 거듭 저장해도 "기본 서명"이 늘지 않는다 (답장 기본으로 · 두 표시를 끄면 이름으로 다시 찾는다)', async () => {
|
||||
for (let index = 0; index < 3; index += 1) {
|
||||
await service.saveLegacySignature(ARA, {
|
||||
body: `글 ${index}`,
|
||||
isForNew: false,
|
||||
isForReply: true,
|
||||
});
|
||||
}
|
||||
const items = (await service.listSignatures(ARA)).items;
|
||||
expect(items.map((item) => [item.name, item.body])).toEqual([
|
||||
[LEGACY_SIGNATURE_NAME, '글 2'],
|
||||
]);
|
||||
await expect(service.getDefaults(ARA)).resolves.toEqual({
|
||||
newMessageId: null,
|
||||
replyId: items[0].id,
|
||||
});
|
||||
|
||||
// 두 표시를 모두 끄면 기본 서명이 없어진다 — 그 뒤 저장은 이름으로 같은 서명을 다시 찾는다
|
||||
for (let index = 3; index < 5; index += 1) {
|
||||
await service.saveLegacySignature(ARA, {
|
||||
body: `글 ${index}`,
|
||||
isForNew: false,
|
||||
isForReply: false,
|
||||
});
|
||||
}
|
||||
const after = (await service.listSignatures(ARA)).items;
|
||||
expect(after.map((item) => [item.name, item.body])).toEqual([
|
||||
[LEGACY_SIGNATURE_NAME, '글 4'],
|
||||
]);
|
||||
await expect(service.getDefaults(ARA)).resolves.toEqual({
|
||||
newMessageId: null,
|
||||
replyId: null,
|
||||
});
|
||||
});
|
||||
|
||||
it(`저장 — 서명을 새로 만들어야 하는데 이미 ${SIGNATURES_MAX}개면 400 VAL_001`, async () => {
|
||||
for (let index = 1; index <= SIGNATURES_MAX; index += 1) {
|
||||
await create(`서명 ${index}`);
|
||||
}
|
||||
await service.setDefaults(ARA, { newMessageId: null, replyId: null });
|
||||
|
||||
expect(
|
||||
await failureOf(() =>
|
||||
service.saveLegacySignature(ARA, {
|
||||
body: '글',
|
||||
isForNew: true,
|
||||
isForReply: true,
|
||||
}),
|
||||
),
|
||||
).toEqual({ code: 'VAL_001', reason: 'too-many-signatures' });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -467,7 +861,8 @@ describe('MailSignatureService', () => {
|
||||
).resolves.toMatchObject({ size: SIGNATURE_IMAGE_MAX_BYTES });
|
||||
});
|
||||
|
||||
it(`한 사람이 둘 수 있는 그림은 ${SIGNATURE_IMAGES_STORED_MAX}장까지 — 넘으면 400 VAL_001. 동시에 올려도 넘지 않는다`, async () => {
|
||||
it(`한 사람이 둘 수 있는 그림은 ${SIGNATURE_IMAGES_STORED_MAX}장까지(서명 여럿이 함께 쓰는 칸) — 넘으면 400 VAL_001. 동시에 올려도 넘지 않는다`, async () => {
|
||||
expect(SIGNATURE_IMAGES_STORED_MAX).toBe(20);
|
||||
const results = await Promise.allSettled(
|
||||
Array.from({ length: SIGNATURE_IMAGES_STORED_MAX + 2 }, () =>
|
||||
upload(pngBytes(10, 10)),
|
||||
@@ -525,7 +920,7 @@ describe('MailSignatureService', () => {
|
||||
});
|
||||
for (const [user, id] of [
|
||||
[DOYUN, mine.id],
|
||||
[ARA, '11111111-1111-4111-8111-111111111111'],
|
||||
[ARA, NOT_MINE],
|
||||
]) {
|
||||
expect(await failureOf(() => service.openImage(user, id))).toEqual({
|
||||
code: 'RES_001',
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { randomUUID } from 'crypto';
|
||||
|
||||
import {
|
||||
type PaginatedResult,
|
||||
toPaginatedResult,
|
||||
} from '@/common/dto/pagination.dto';
|
||||
import { BusinessException } from '@/common/errors/business.exception';
|
||||
import { checkHtmlParseBudget } from '@/modules/mail/html/html-parse-budget';
|
||||
import { MAX_BODY_HTML_LENGTH } from '@/modules/mail/mail.constants';
|
||||
import { sanitizeOutgoingHtml } from '@/modules/mail/html/sanitize-outgoing-html';
|
||||
import { sanitizeStoredHtml } from '@/modules/mail/html/sanitize-stored-html';
|
||||
import {
|
||||
findSignatureImageIds,
|
||||
rewriteSignatureImages,
|
||||
@@ -14,6 +16,8 @@ import { traceTag } from '@/shared/logger/request-context';
|
||||
import { maskEmail } from '@/shared/utils/mask';
|
||||
import { MailSignatureImageEntity } from './mail-signature-image.entity';
|
||||
import {
|
||||
LEGACY_SIGNATURE_NAME,
|
||||
SIGNATURES_MAX,
|
||||
SIGNATURE_HTML_MAX_LENGTH,
|
||||
SIGNATURE_IMAGES_PER_SIGNATURE,
|
||||
SIGNATURE_IMAGES_STORED_MAX,
|
||||
@@ -22,9 +26,16 @@ import {
|
||||
SIGNATURE_TEXT_MAX_LENGTH,
|
||||
} from './mail-signature.constants';
|
||||
import { MailSignatureEntity } from './mail-signature.entity';
|
||||
import { MailSignatureRepository } from './mail-signature.repository';
|
||||
import {
|
||||
MailSignatureRepository,
|
||||
type SignaturePlan,
|
||||
type SignatureSnapshot,
|
||||
} from './mail-signature.repository';
|
||||
import type {
|
||||
LegacyMailSignature,
|
||||
LegacyMailSignatureInput,
|
||||
MailSignature,
|
||||
MailSignatureDefaults,
|
||||
MailSignatureInput,
|
||||
SignatureImage,
|
||||
SignatureImageUpload,
|
||||
@@ -39,18 +50,36 @@ import {
|
||||
} from './signature-image-format';
|
||||
import { SignatureImageStorage } from './signature-image-storage';
|
||||
|
||||
const DEFAULT_SIGNATURE: Omit<MailSignature, 'images'> = {
|
||||
/** 옛 경로 — 「새 메일」 · 「답장 · 전달」 기본 서명이 모두 없을 때 (앱 첫 판은 본문이 없으면 서명을 넣지 않는다) */
|
||||
const NO_LEGACY_SIGNATURE: LegacyMailSignature = {
|
||||
bodyHtml: null,
|
||||
body: null,
|
||||
isForNew: true,
|
||||
isForReply: true,
|
||||
isForNew: false,
|
||||
isForReply: false,
|
||||
images: [],
|
||||
updatedAt: null,
|
||||
};
|
||||
|
||||
/** 정화 · 길이를 지난 서명 본문 — 그림은 잠근 뒤 다시 맞춘다 */
|
||||
interface PreparedSignatureBody {
|
||||
readonly bodyHtml: string | null;
|
||||
readonly body: string | null;
|
||||
}
|
||||
|
||||
function normalizeUser(email: string): string {
|
||||
return email.trim().toLowerCase();
|
||||
}
|
||||
|
||||
/** 서명 id · 그림 id — 서버가 만든 UUID(소문자). 경로 파이프가 소문자로 바꾸지만 본문에서 온 id(기본 고르기)도 같게 */
|
||||
function normalizeId(id: string): string {
|
||||
return id.trim().toLowerCase();
|
||||
}
|
||||
|
||||
/** 이름 견주기 — ASCII 대소문자만 무시한다 (화면과 같다 — 한글 · 그 밖의 글자는 그대로 견준다) */
|
||||
function nameKey(name: string): string {
|
||||
return name.replace(/[A-Z]/g, (letter) => letter.toLowerCase());
|
||||
}
|
||||
|
||||
function toImage(row: MailSignatureImageEntity): SignatureImage {
|
||||
return {
|
||||
id: row.id,
|
||||
@@ -61,6 +90,67 @@ function toImage(row: MailSignatureImageEntity): SignatureImage {
|
||||
};
|
||||
}
|
||||
|
||||
/** 서명 HTML 이 쓰는 그림 — HTML 에 나온 순서 (표에 없는 그림은 뺀다) */
|
||||
function imagesOf(
|
||||
html: string | null,
|
||||
images: readonly MailSignatureImageEntity[],
|
||||
): SignatureImage[] {
|
||||
if (!html) return [];
|
||||
const byId = new Map(images.map((image) => [image.id, image]));
|
||||
return findSignatureImageIds(html).flatMap((id) => {
|
||||
const image = byId.get(id);
|
||||
return image ? [toImage(image)] : [];
|
||||
});
|
||||
}
|
||||
|
||||
function toSignature(
|
||||
row: MailSignatureEntity,
|
||||
images: readonly MailSignatureImageEntity[],
|
||||
): MailSignature {
|
||||
return {
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
bodyHtml: row.bodyHtml,
|
||||
body: row.bodyText,
|
||||
images: imagesOf(row.bodyHtml, images),
|
||||
updatedAt: row.updatedAt.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
function defaultsOf(
|
||||
signatures: readonly MailSignatureEntity[],
|
||||
): MailSignatureDefaults {
|
||||
return {
|
||||
newMessageId: signatures.find((row) => row.isNewMessageDefault)?.id ?? null,
|
||||
replyId: signatures.find((row) => row.isReplyDefault)?.id ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 새 서명의 만든 시각 — 그 사람의 마지막 서명보다 늦게(같은 밀리초면 1ms 뒤로). 목록은 만든 차례라
|
||||
* 잠근 차례대로 늘 뒤에 붙는다 (같은 시각이면 id 차례가 되어 들쭉날쭉하지 않게)
|
||||
*/
|
||||
function createdAfter(
|
||||
signatures: readonly MailSignatureEntity[],
|
||||
now: Date,
|
||||
): Date {
|
||||
const last = Math.max(0, ...signatures.map((row) => row.createdAt.getTime()));
|
||||
return new Date(Math.max(now.getTime(), last + 1));
|
||||
}
|
||||
|
||||
/** 모든 서명 HTML 이 가리키는 그림 — 합집합 (지울 그림을 정하는 기준) */
|
||||
function referencedImageIdsOf(
|
||||
signatures: readonly Pick<MailSignatureEntity, 'bodyHtml'>[],
|
||||
): string[] {
|
||||
return [
|
||||
...new Set(
|
||||
signatures.flatMap((row) =>
|
||||
row.bodyHtml ? findSignatureImageIds(row.bodyHtml) : [],
|
||||
),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
/** 올린 이름의 확장자 (소문자, 점 포함) */
|
||||
function extensionOf(name: string): string {
|
||||
const dot = name.lastIndexOf('.');
|
||||
@@ -68,15 +158,19 @@ function extensionOf(name: string): string {
|
||||
}
|
||||
|
||||
/**
|
||||
* 메일 서명 (계약 2절 "서명") — 사용자마다 하나, 서버 DB 에 둔다.
|
||||
* 메일 서명 (계약 2절 "서명") — 16단계부터 한 사람에 여러 개(10개까지), 서버 DB 에 둔다.
|
||||
* - 서명 HTML 은 보내는 정화(서명 그림 포함)를 거친 뒤 저장한다 — 서명은 쓰기 화면이 본문에 넣어 그대로 보내므로 같은 규칙이다
|
||||
* - 「메일에 넣을 서명」 둘(새 메일 · 답장과 전달)은 서명 줄의 표시다 — 서명이 하나도 없던 사람의 첫 서명이 두 곳의 기본이 된다
|
||||
* - 서명 그림은 모든 서명이 함께 쓰는 칸(한 사람 20장)이다 — 저장 · 지우기 때 **어느 서명도 가리키지 않는** 그림을 지운다(막 올린 것은 남긴다)
|
||||
* - 그림은 PNG·JPEG·GIF 만 — 확장자·MIME 은 multer 가, 파일 내용(앞부분)·크기는 여기서 본다
|
||||
* - 그림은 주인만 본다 — 남의 그림 id 는 없는 것과 같다 (RES_001)
|
||||
* - 서명 · 그림은 주인만 본다 — 남의 id 는 없는 것과 같다 (RES_001)
|
||||
* - 옛 경로(`GET · PUT /api/mail-signature` — 앱 첫 판 · 16단계 전의 웹 탭)는 「새 메일」 기본 서명을 15단계까지의 모양으로 다룬다
|
||||
* 서명 · 이름 · 글은 로그에 남기지 않는다 (사유 · 수 · 가린 주소만)
|
||||
*/
|
||||
@Injectable()
|
||||
export class MailSignatureService {
|
||||
private readonly logger = new Logger(MailSignatureService.name);
|
||||
/** 사용자별 그림 올리기 차례 — 보관 한도(10장)를 동시 업로드로 넘지 못하게 (프로세스 메모리 — 인스턴스를 늘리면 DB 잠금으로) */
|
||||
/** 사용자별 그림 올리기 차례 — 보관 한도(20장)를 동시 업로드로 넘지 못하게 (프로세스 메모리 — 인스턴스를 늘리면 DB 잠금으로) */
|
||||
private readonly uploadQueues = new Map<string, Promise<void>>();
|
||||
|
||||
constructor(
|
||||
@@ -84,76 +178,236 @@ export class MailSignatureService {
|
||||
private readonly storage: SignatureImageStorage,
|
||||
) {}
|
||||
|
||||
async getSignature(userEmail: string): Promise<MailSignature> {
|
||||
/** 내 서명 — 만든 차례, 한 번에 모두 (hasNext 늘 false) */
|
||||
async listSignatures(
|
||||
userEmail: string,
|
||||
): Promise<PaginatedResult<MailSignature>> {
|
||||
const user = normalizeUser(userEmail);
|
||||
const [row, images] = await Promise.all([
|
||||
this.repository.findSignature(user),
|
||||
const [rows, images] = await Promise.all([
|
||||
this.repository.listSignatures(user),
|
||||
this.repository.listImages(user),
|
||||
]);
|
||||
if (!row) return { ...DEFAULT_SIGNATURE, images: [] };
|
||||
return this.toSignature(row, images);
|
||||
return toPaginatedResult(
|
||||
rows.map((row) => toSignature(row, images)),
|
||||
{ hasNext: false, nextCursor: null, total: rows.length },
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 서명을 저장한다. 서명 HTML 이 더는 가리키지 않는 그림은 지운다(표와 파일) — 막 올린 그림(10분 안)은 남긴다
|
||||
* (올리기와 저장이 엇갈린 경우 — SIGNATURE_IMAGE_UPLOAD_GRACE_MS).
|
||||
* 한도 — 텍스트 2,000자·정화한 HTML 10,000자(JS 문자열 길이), 정화 전 비용, 서로 다른 그림 3장 — 를 넘으면 VAL_001.
|
||||
* 서명을 만든다 — 서명이 하나도 없던 사람이면 이 서명이 「새 메일」 · 「답장 · 전달」 두 곳의 기본이 된다.
|
||||
* 11번째 · 같은 이름(ASCII 대소문자 무시)이면 VAL_001 (아무것도 저장하지 않는다)
|
||||
*/
|
||||
async saveSignature(
|
||||
async createSignature(
|
||||
userEmail: string,
|
||||
input: MailSignatureInput,
|
||||
): Promise<MailSignature> {
|
||||
const user = normalizeUser(userEmail);
|
||||
const body = input.body ? input.body : null;
|
||||
if (body !== null && body.length > SIGNATURE_TEXT_MAX_LENGTH) {
|
||||
throw new BusinessException('VAL_001', {
|
||||
context: { reason: 'signature-text-too-long', length: body.length },
|
||||
});
|
||||
}
|
||||
|
||||
const bodyHtml = await this.sanitizeSignatureHtml(user, input.bodyHtml);
|
||||
// 정화한 HTML 에는 내 그림만 남는다 — 같은 그림을 두 번 넣으면 한 장으로 센다
|
||||
const referenced = bodyHtml ? findSignatureImageIds(bodyHtml) : [];
|
||||
if (referenced.length > SIGNATURE_IMAGES_PER_SIGNATURE) {
|
||||
throw new BusinessException('VAL_001', {
|
||||
context: {
|
||||
reason: 'signature-too-many-images',
|
||||
count: referenced.length,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
const saved = await this.repository.saveSignature(
|
||||
user,
|
||||
(images) => {
|
||||
// 잠근 뒤 본 그림 기준으로 — 정화한 뒤 그사이 지워진 그림(다른 탭의 저장·정리 작업)은 서명에서 뺀다 (없는 그림을 가리키지 않게)
|
||||
const existing = new Set(images.map((image) => image.id));
|
||||
const html =
|
||||
bodyHtml === null
|
||||
? null
|
||||
: rewriteSignatureImages(bodyHtml, (id) =>
|
||||
existing.has(id) ? signatureImagePath(id) : null,
|
||||
);
|
||||
const kept = html !== null && html.trim() !== '' ? html : null;
|
||||
return {
|
||||
signature: {
|
||||
userEmail: user,
|
||||
bodyHtml: kept,
|
||||
bodyText: body,
|
||||
isForNew: input.isForNew,
|
||||
isForReply: input.isForReply,
|
||||
updatedAt: now,
|
||||
const prepared = await this.prepareBody(user, input);
|
||||
const id = randomUUID();
|
||||
const result = await this.change(user, (snapshot, now) => {
|
||||
if (snapshot.signatures.length >= SIGNATURES_MAX) {
|
||||
throw new BusinessException('VAL_001', {
|
||||
context: {
|
||||
reason: 'too-many-signatures',
|
||||
count: snapshot.signatures.length,
|
||||
},
|
||||
referencedImageIds: kept ? findSignatureImageIds(kept) : [],
|
||||
};
|
||||
},
|
||||
now,
|
||||
new Date(now.getTime() - SIGNATURE_IMAGE_UPLOAD_GRACE_MS),
|
||||
);
|
||||
await this.removeFiles(saved.removed, user);
|
||||
});
|
||||
}
|
||||
this.assertNameFree(snapshot.signatures, input.name, null);
|
||||
const bodyHtml = this.keepExistingImages(prepared.bodyHtml, snapshot);
|
||||
const row = {
|
||||
id,
|
||||
userEmail: user,
|
||||
name: input.name,
|
||||
bodyHtml,
|
||||
bodyText: prepared.body,
|
||||
createdAt: createdAfter(snapshot.signatures, now),
|
||||
updatedAt: now,
|
||||
};
|
||||
return {
|
||||
insert: row,
|
||||
// 첫 서명은 두 곳의 기본 — 지금까지 서명이 처음에 두 곳에 들어가던 것과 같다
|
||||
...(snapshot.signatures.length === 0
|
||||
? { defaults: { newMessageId: id, replyId: id } }
|
||||
: {}),
|
||||
referencedImageIds: referencedImageIdsOf([...snapshot.signatures, row]),
|
||||
};
|
||||
});
|
||||
return this.signatureIn(result, id);
|
||||
}
|
||||
|
||||
return this.toSignature(saved.signature, saved.images);
|
||||
/**
|
||||
* 서명을 고친다(이름 · 본문 — 전체 교체). 기본 표시는 그대로. 없는 서명 · 남의 서명은 RES_001, 다른 서명과 같은 이름은 VAL_001.
|
||||
* 어느 서명도 더는 가리키지 않는 그림은 지운다 — 막 올린 그림(10분 안)은 남긴다 (올리기와 저장이 엇갈린 경우 — SIGNATURE_IMAGE_UPLOAD_GRACE_MS)
|
||||
*/
|
||||
async updateSignature(
|
||||
userEmail: string,
|
||||
signatureId: string,
|
||||
input: MailSignatureInput,
|
||||
): Promise<MailSignature> {
|
||||
const user = normalizeUser(userEmail);
|
||||
const id = normalizeId(signatureId);
|
||||
const prepared = await this.prepareBody(user, input);
|
||||
const result = await this.change(user, (snapshot, now) => {
|
||||
this.requireSignature(snapshot, id);
|
||||
this.assertNameFree(snapshot.signatures, input.name, id);
|
||||
const update = {
|
||||
id,
|
||||
name: input.name,
|
||||
bodyHtml: this.keepExistingImages(prepared.bodyHtml, snapshot),
|
||||
bodyText: prepared.body,
|
||||
updatedAt: now,
|
||||
};
|
||||
return {
|
||||
update,
|
||||
referencedImageIds: referencedImageIdsOf(
|
||||
snapshot.signatures.map((row) => (row.id === id ? update : row)),
|
||||
),
|
||||
};
|
||||
});
|
||||
return this.signatureIn(result, id);
|
||||
}
|
||||
|
||||
/**
|
||||
* 서명을 지운다 — 이 서명을 기본으로 쓰던 곳은 "넣지 않음"이 된다(다른 서명이 저절로 기본이 되지 않는다).
|
||||
* 이 서명에만 쓰던 그림은 함께 지운다. 없는 서명 · 남의 서명은 RES_001
|
||||
*/
|
||||
async deleteSignature(userEmail: string, signatureId: string): Promise<void> {
|
||||
const user = normalizeUser(userEmail);
|
||||
const id = normalizeId(signatureId);
|
||||
await this.change(user, (snapshot) => {
|
||||
this.requireSignature(snapshot, id);
|
||||
return {
|
||||
removeId: id,
|
||||
referencedImageIds: referencedImageIdsOf(
|
||||
snapshot.signatures.filter((row) => row.id !== id),
|
||||
),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/** 「메일에 넣을 서명」 둘 — null 은 넣지 않음 */
|
||||
async getDefaults(userEmail: string): Promise<MailSignatureDefaults> {
|
||||
return defaultsOf(
|
||||
await this.repository.listSignatures(normalizeUser(userEmail)),
|
||||
);
|
||||
}
|
||||
|
||||
/** 「메일에 넣을 서명」 둘을 바꾼다 — 내 서명이 아닌 id(없음 · 남의 것 · 형식이 틀림)는 RES_001 (아무것도 바꾸지 않는다) */
|
||||
async setDefaults(
|
||||
userEmail: string,
|
||||
defaults: MailSignatureDefaults,
|
||||
): Promise<MailSignatureDefaults> {
|
||||
const user = normalizeUser(userEmail);
|
||||
const wanted: MailSignatureDefaults = {
|
||||
newMessageId:
|
||||
defaults.newMessageId === null
|
||||
? null
|
||||
: normalizeId(defaults.newMessageId),
|
||||
replyId: defaults.replyId === null ? null : normalizeId(defaults.replyId),
|
||||
};
|
||||
const result = await this.change(user, (snapshot) => {
|
||||
for (const id of [wanted.newMessageId, wanted.replyId]) {
|
||||
if (id !== null) this.requireSignature(snapshot, id);
|
||||
}
|
||||
return {
|
||||
defaults: wanted,
|
||||
referencedImageIds: referencedImageIdsOf(snapshot.signatures),
|
||||
};
|
||||
});
|
||||
return defaultsOf(result.signatures);
|
||||
}
|
||||
|
||||
/**
|
||||
* 옛 경로 — 「새 메일」 기본 서명을 15단계까지의 모양으로 (앱 첫 판이 읽는다). 「새 메일」 기본이 없으면 「답장 · 전달」 기본 서명을
|
||||
* isForNew false · isForReply true 로, 둘 다 없으면 본문 없음 · 두 표시 false.
|
||||
* 「새 메일」 기본이 있을 때 isForReply 는 답장 기본이 같은 서명일 때만 true — 다른 서명이면 앱 첫 판은 답장에 서명을 넣지 않는다(알려진 한계)
|
||||
*/
|
||||
async getLegacySignature(userEmail: string): Promise<LegacyMailSignature> {
|
||||
const user = normalizeUser(userEmail);
|
||||
const [rows, images] = await Promise.all([
|
||||
this.repository.listSignatures(user),
|
||||
this.repository.listImages(user),
|
||||
]);
|
||||
return toLegacySignature(rows, images);
|
||||
}
|
||||
|
||||
/**
|
||||
* 옛 경로의 저장(16단계 전의 웹 탭) — 옛 GET 이 보이는 서명(「새 메일」 기본, 없으면 「답장 · 전달」 기본)의 본문을 바꾸고 두 표시를 기본 둘로 옮긴다.
|
||||
* 기본 서명이 둘 다 없으면 "기본 서명"이라는 이름의 서명(ASCII 대소문자 무시 — 있으면 그것, 없으면 새로 만든다)을 쓴다 —
|
||||
* 옛 탭이 두 표시를 끈 채 저장을 거듭해도 서명이 늘지 않게.
|
||||
* isForNew → 「새 메일」 기본을 이 서명으로(false 면 넣지 않음), isForReply → 「답장 · 전달」 기본을 이 서명으로
|
||||
* (false 면 이 서명이 기본이었을 때만 넣지 않음 — 다른 서명이 기본이면 그대로 둔다)
|
||||
*/
|
||||
async saveLegacySignature(
|
||||
userEmail: string,
|
||||
input: LegacyMailSignatureInput,
|
||||
): Promise<LegacyMailSignature> {
|
||||
const user = normalizeUser(userEmail);
|
||||
const prepared = await this.prepareBody(user, input);
|
||||
const newId = randomUUID();
|
||||
const result = await this.change(user, (snapshot, now) => {
|
||||
const current = defaultsOf(snapshot.signatures);
|
||||
const target =
|
||||
legacyTargetOf(snapshot.signatures) ??
|
||||
snapshot.signatures.find(
|
||||
(row) => nameKey(row.name) === nameKey(LEGACY_SIGNATURE_NAME),
|
||||
) ??
|
||||
null;
|
||||
const bodyHtml = this.keepExistingImages(prepared.bodyHtml, snapshot);
|
||||
const id = target?.id ?? newId;
|
||||
const defaults: MailSignatureDefaults = {
|
||||
newMessageId: input.isForNew
|
||||
? id
|
||||
: current.newMessageId === id
|
||||
? null
|
||||
: current.newMessageId,
|
||||
replyId: input.isForReply
|
||||
? id
|
||||
: current.replyId === id
|
||||
? null
|
||||
: current.replyId,
|
||||
};
|
||||
if (target) {
|
||||
const update = {
|
||||
id,
|
||||
name: target.name,
|
||||
bodyHtml,
|
||||
bodyText: prepared.body,
|
||||
updatedAt: now,
|
||||
};
|
||||
return {
|
||||
update,
|
||||
defaults,
|
||||
referencedImageIds: referencedImageIdsOf(
|
||||
snapshot.signatures.map((row) => (row.id === id ? update : row)),
|
||||
),
|
||||
};
|
||||
}
|
||||
if (snapshot.signatures.length >= SIGNATURES_MAX) {
|
||||
throw new BusinessException('VAL_001', {
|
||||
context: {
|
||||
reason: 'too-many-signatures',
|
||||
count: snapshot.signatures.length,
|
||||
},
|
||||
});
|
||||
}
|
||||
const row = {
|
||||
id,
|
||||
userEmail: user,
|
||||
name: LEGACY_SIGNATURE_NAME,
|
||||
bodyHtml,
|
||||
bodyText: prepared.body,
|
||||
createdAt: createdAfter(snapshot.signatures, now),
|
||||
updatedAt: now,
|
||||
};
|
||||
return {
|
||||
insert: row,
|
||||
defaults,
|
||||
referencedImageIds: referencedImageIdsOf([...snapshot.signatures, row]),
|
||||
};
|
||||
});
|
||||
return toLegacySignature(result.signatures, result.images);
|
||||
}
|
||||
|
||||
/** 서명 그림을 올린다 — 내용이 PNG·JPEG·GIF 이고 확장자·MIME 과 같은 형식이어야 한다 (아니면 FILE_001) */
|
||||
@@ -284,59 +538,125 @@ export class MailSignatureService {
|
||||
return loaded;
|
||||
}
|
||||
|
||||
/** 서명 HTML 정화 — 비었으면 null. 정화 전 비용·정화 뒤 길이 한도를 넘으면 VAL_001 */
|
||||
private async sanitizeSignatureHtml(
|
||||
/**
|
||||
* 서명 본문 — 텍스트 2,000자, 서식은 정화 전 비용 · 정화 뒤 10,000자(JS 문자열 길이), 서로 다른 그림 3장(정화가 남의 그림은 지웠다 —
|
||||
* 같은 그림 두 번은 한 장). 넘으면 VAL_001
|
||||
*/
|
||||
private async prepareBody(
|
||||
user: string,
|
||||
html: string | null | undefined,
|
||||
): Promise<string | null> {
|
||||
if (!html) return null;
|
||||
// 정화 전 — 쓰기 화면의 서식 본문과 같은 길이 한도와 파서 비용 (정화가 이벤트 루프를 오래 붙잡지 않게)
|
||||
if (html.length > MAX_BODY_HTML_LENGTH) {
|
||||
input: { readonly bodyHtml?: string | null; readonly body?: string | null },
|
||||
): Promise<PreparedSignatureBody> {
|
||||
const body = input.body ? input.body : null;
|
||||
if (body !== null && body.length > SIGNATURE_TEXT_MAX_LENGTH) {
|
||||
throw new BusinessException('VAL_001', {
|
||||
context: { reason: 'signature-html-too-long', length: html.length },
|
||||
});
|
||||
}
|
||||
const budget = checkHtmlParseBudget(html);
|
||||
if (!budget.ok) {
|
||||
throw new BusinessException('VAL_001', {
|
||||
context: {
|
||||
reason: 'signature-html-too-complex',
|
||||
detail: budget.reason,
|
||||
},
|
||||
context: { reason: 'signature-text-too-long', length: body.length },
|
||||
});
|
||||
}
|
||||
|
||||
const sanitized = sanitizeOutgoingHtml(html, {
|
||||
signatureImageIds: await this.imageIdsOf(user),
|
||||
const signatureImageIds = input.bodyHtml
|
||||
? await this.imageIdsOf(user)
|
||||
: undefined;
|
||||
const sanitized = sanitizeStoredHtml(input.bodyHtml, {
|
||||
maxLength: SIGNATURE_HTML_MAX_LENGTH,
|
||||
signatureImageIds,
|
||||
});
|
||||
if (sanitized.length > SIGNATURE_HTML_MAX_LENGTH) {
|
||||
if (!sanitized.ok) {
|
||||
throw new BusinessException('VAL_001', {
|
||||
context: {
|
||||
reason: 'signature-html-too-long',
|
||||
reason:
|
||||
sanitized.problem === 'too-complex'
|
||||
? 'signature-html-too-complex'
|
||||
: 'signature-html-too-long',
|
||||
length: sanitized.length,
|
||||
},
|
||||
});
|
||||
}
|
||||
return sanitized === '' ? null : sanitized;
|
||||
const referenced = sanitized.html
|
||||
? findSignatureImageIds(sanitized.html)
|
||||
: [];
|
||||
if (referenced.length > SIGNATURE_IMAGES_PER_SIGNATURE) {
|
||||
throw new BusinessException('VAL_001', {
|
||||
context: {
|
||||
reason: 'signature-too-many-images',
|
||||
count: referenced.length,
|
||||
},
|
||||
});
|
||||
}
|
||||
return { bodyHtml: sanitized.html, body };
|
||||
}
|
||||
|
||||
private toSignature(
|
||||
row: MailSignatureEntity,
|
||||
images: readonly MailSignatureImageEntity[],
|
||||
/**
|
||||
* 잠근 뒤 본 그림 기준으로 — 정화한 뒤 그사이 지워진 그림(다른 탭의 저장 · 정리 작업)은 서명에서 뺀다 (없는 그림을 가리키지 않게).
|
||||
* 남는 것이 없으면 null
|
||||
*/
|
||||
private keepExistingImages(
|
||||
html: string | null,
|
||||
snapshot: SignatureSnapshot,
|
||||
): string | null {
|
||||
if (html === null) return null;
|
||||
const existing = new Set(snapshot.images.map((image) => image.id));
|
||||
const kept = rewriteSignatureImages(html, (id) =>
|
||||
existing.has(id) ? signatureImagePath(id) : null,
|
||||
);
|
||||
return kept.trim() !== '' ? kept : null;
|
||||
}
|
||||
|
||||
/** 같은 이름(ASCII 대소문자 무시)의 다른 서명이 있으면 VAL_001 */
|
||||
private assertNameFree(
|
||||
signatures: readonly MailSignatureEntity[],
|
||||
name: string,
|
||||
exceptId: string | null,
|
||||
): void {
|
||||
const key = nameKey(name);
|
||||
if (
|
||||
signatures.some((row) => row.id !== exceptId && nameKey(row.name) === key)
|
||||
) {
|
||||
throw new BusinessException('VAL_001', {
|
||||
context: { reason: 'signature-name-taken' },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/** 그 사람의 서명이어야 한다 — 없으면(남의 것 · 형식이 틀린 id 포함) RES_001 */
|
||||
private requireSignature(snapshot: SignatureSnapshot, id: string): void {
|
||||
if (!snapshot.signatures.some((row) => row.id === id)) {
|
||||
throw new BusinessException('RES_001', {
|
||||
context: { reason: 'signature-not-found' },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/** 잠근 뒤 바꾸고, 지운 그림의 파일을 지운다 */
|
||||
private async change(
|
||||
user: string,
|
||||
plan: (snapshot: SignatureSnapshot, now: Date) => SignaturePlan,
|
||||
) {
|
||||
const now = new Date();
|
||||
const result = await this.repository.change(
|
||||
user,
|
||||
(snapshot) => plan(snapshot, now),
|
||||
now,
|
||||
new Date(now.getTime() - SIGNATURE_IMAGE_UPLOAD_GRACE_MS),
|
||||
);
|
||||
await this.removeFiles(result.removed, user);
|
||||
return result;
|
||||
}
|
||||
|
||||
/** 바꾼 뒤의 서명 하나 — 방금 넣거나 고친 줄은 늘 있다 */
|
||||
private signatureIn(
|
||||
result: {
|
||||
readonly signatures: readonly MailSignatureEntity[];
|
||||
readonly images: readonly MailSignatureImageEntity[];
|
||||
},
|
||||
id: string,
|
||||
): MailSignature {
|
||||
const byId = new Map(images.map((image) => [image.id, image]));
|
||||
const used = row.bodyHtml ? findSignatureImageIds(row.bodyHtml) : [];
|
||||
return {
|
||||
bodyHtml: row.bodyHtml,
|
||||
body: row.bodyText,
|
||||
isForNew: row.isForNew,
|
||||
isForReply: row.isForReply,
|
||||
images: used.flatMap((id) => {
|
||||
const image = byId.get(id);
|
||||
return image ? [toImage(image)] : [];
|
||||
}),
|
||||
updatedAt: row.updatedAt.toISOString(),
|
||||
};
|
||||
const row = result.signatures.find((candidate) => candidate.id === id);
|
||||
if (!row) {
|
||||
throw new BusinessException('SYS_001', {
|
||||
context: { reason: 'signature-missing-after-save' },
|
||||
});
|
||||
}
|
||||
return toSignature(row, result.images);
|
||||
}
|
||||
|
||||
/** 지운 그림의 파일을 지운다 — 실패해도 서명 저장은 끝났다 (남은 파일은 정리 작업이 지운다) */
|
||||
@@ -370,3 +690,38 @@ export class MailSignatureService {
|
||||
return run;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 옛 경로가 다루는 서명 — 「새 메일」 기본, 없으면 「답장 · 전달」 기본, 둘 다 없으면 null.
|
||||
* 답장 기본까지 보는 까닭: 옛 두 체크가 새 메일 끔 · 답장 켬이던 직원(옮기면 답장 기본만 있다)이 앱 첫 판의 답장에서 서명을 잃지 않게 (리뷰 L4).
|
||||
* 옛 PUT 도 이 서명을 고친다 — 옛 탭이 읽은 서명을 그대로 저장하게
|
||||
*/
|
||||
function legacyTargetOf(
|
||||
rows: readonly MailSignatureEntity[],
|
||||
): MailSignatureEntity | null {
|
||||
return (
|
||||
rows.find((candidate) => candidate.isNewMessageDefault) ??
|
||||
rows.find((candidate) => candidate.isReplyDefault) ??
|
||||
null
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 옛 경로의 모양 — 「새 메일」 기본 서명(isForNew true · isForReply 는 답장 기본도 그 서명일 때), 없으면 「답장 · 전달」 기본 서명
|
||||
* (isForNew false · isForReply true), 둘 다 없으면 본문 없음 · 두 표시 false
|
||||
*/
|
||||
function toLegacySignature(
|
||||
rows: readonly MailSignatureEntity[],
|
||||
images: readonly MailSignatureImageEntity[],
|
||||
): LegacyMailSignature {
|
||||
const row = legacyTargetOf(rows);
|
||||
if (!row) return NO_LEGACY_SIGNATURE;
|
||||
return {
|
||||
bodyHtml: row.bodyHtml,
|
||||
body: row.bodyText,
|
||||
isForNew: row.isNewMessageDefault,
|
||||
isForReply: row.isReplyDefault,
|
||||
images: imagesOf(row.bodyHtml, images),
|
||||
updatedAt: row.updatedAt.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { SignatureImageType } from './signature-image-format';
|
||||
|
||||
/**
|
||||
* 서명 도메인 타입 — 계약 2절 "서명"과 같은 모양이다.
|
||||
* 서명 도메인 타입 — 계약 2절 "서명"과 같은 모양이다 (16단계부터 한 사람에 여러 개).
|
||||
*/
|
||||
|
||||
export interface SignatureImage {
|
||||
@@ -14,21 +14,51 @@ export interface SignatureImage {
|
||||
readonly height: number;
|
||||
}
|
||||
|
||||
/** 서명 하나 — `GET · POST /api/mail-signatures` · `PUT /api/mail-signatures/{id}` (16단계) */
|
||||
export interface MailSignature {
|
||||
/** 서명 서식 — 서식 본문 규칙 + 서명 그림. 정화 뒤 10,000자 이하 */
|
||||
readonly id: string;
|
||||
/** 앞뒤 공백을 뺀 1~30자 — 같은 사람 안에서 겹치지 않는다(ASCII 대소문자 무시) */
|
||||
readonly name: string;
|
||||
/** 서명 서식 — 서식 본문 규칙 + 서명 그림. 정화 뒤 10,000자 이하. 없으면 null */
|
||||
readonly bodyHtml: string | null;
|
||||
/** 텍스트 서명 (텍스트 대체 본문에 넣는다) — 2,000자 이하. 없으면 null */
|
||||
readonly body: string | null;
|
||||
/** 서명 HTML 이 쓰는 그림 (HTML 에 나온 순서) */
|
||||
readonly images: SignatureImage[];
|
||||
/** 마지막으로 저장한 시각 (ISO 8601 UTC) */
|
||||
readonly updatedAt: string;
|
||||
}
|
||||
|
||||
/** 서명 만들기 · 고치기 본문 (전체 교체) */
|
||||
export interface MailSignatureInput {
|
||||
/** 앞뒤 공백을 뺀 값 (요청 검증이 지운다) */
|
||||
readonly name: string;
|
||||
readonly bodyHtml?: string | null;
|
||||
readonly body?: string | null;
|
||||
}
|
||||
|
||||
/** 「메일에 넣을 서명」 둘 — `GET · PUT /api/mail-signature-defaults` (16단계). null = 넣지 않음 */
|
||||
export interface MailSignatureDefaults {
|
||||
readonly newMessageId: string | null;
|
||||
readonly replyId: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 옛 경로의 서명 — `GET /api/mail-signature` (앱 첫 판 · 16단계 전의 웹 탭). 「새 메일」 기본 서명을 15단계까지의 네 칸으로 내준다
|
||||
* (없으면 「답장 · 전달」 기본 서명을 isForNew false 로). isForNew = 「새 메일」 기본 서명이 있음, isForReply = 「답장 · 전달」 기본이 이 서명
|
||||
*/
|
||||
export interface LegacyMailSignature {
|
||||
readonly bodyHtml: string | null;
|
||||
/** 텍스트 서명 (텍스트 대체 본문에 넣는다) — 2,000자 이하 */
|
||||
readonly body: string | null;
|
||||
readonly isForNew: boolean;
|
||||
readonly isForReply: boolean;
|
||||
/** 서명 HTML 이 쓰는 그림 (HTML 에 나온 순서) */
|
||||
readonly images: SignatureImage[];
|
||||
/** 저장한 적 없으면 null */
|
||||
/** 기본 서명이 둘 다 없으면 null */
|
||||
readonly updatedAt: string | null;
|
||||
}
|
||||
|
||||
/** PUT /api/mail-signature 본문 */
|
||||
export interface MailSignatureInput {
|
||||
/** 옛 경로의 저장 본문 — `PUT /api/mail-signature` */
|
||||
export interface LegacyMailSignatureInput {
|
||||
readonly bodyHtml?: string | null;
|
||||
readonly body?: string | null;
|
||||
readonly isForNew: boolean;
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
Param,
|
||||
Post,
|
||||
Put,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import {
|
||||
ApiCookieAuth,
|
||||
ApiOperation,
|
||||
ApiParam,
|
||||
ApiResponse,
|
||||
ApiTags,
|
||||
} from '@nestjs/swagger';
|
||||
|
||||
import {
|
||||
ApiErrorResponse,
|
||||
ApiSessionErrors,
|
||||
ApiStandardResponse,
|
||||
} from '@/common/decorators/api-standard-response.decorator';
|
||||
import {
|
||||
CurrentUser,
|
||||
type AuthenticatedUser,
|
||||
} from '@/common/decorators/current-user.decorator';
|
||||
import { SkipTransform } from '@/common/decorators/skip-transform.decorator';
|
||||
import { JwtAuthGuard } from '@/common/guards/jwt-auth.guard';
|
||||
import {
|
||||
MailSignatureDefaultsDto,
|
||||
MailSignatureDto,
|
||||
MailSignaturePageDto,
|
||||
SaveMailSignatureDto,
|
||||
} from './dto/mail-signature.dto';
|
||||
import {
|
||||
SIGNATURES_MAX,
|
||||
SIGNATURE_HTML_MAX_LENGTH,
|
||||
SIGNATURE_IMAGES_PER_SIGNATURE,
|
||||
SIGNATURE_NAME_MAX_LENGTH,
|
||||
SIGNATURE_TEXT_MAX_LENGTH,
|
||||
} from './mail-signature.constants';
|
||||
import { MailSignatureService } from './mail-signature.service';
|
||||
import { MailSignatureIdPipe } from './signature-image-id.pipe';
|
||||
|
||||
/** 서명 저장(만들기 · 고치기)의 400 VAL_001 사유 */
|
||||
const SAVE_REJECTIONS =
|
||||
`형식 오류 · 이름(앞뒤 공백을 뺀 1~${SIGNATURE_NAME_MAX_LENGTH}자 · 제어 문자 없음) · 같은 이름의 다른 서명(영문 대소문자 무시) · ` +
|
||||
`텍스트 서명 ${SIGNATURE_TEXT_MAX_LENGTH.toLocaleString('ko-KR')}자 초과 · 정화한 서식 서명 ${SIGNATURE_HTML_MAX_LENGTH.toLocaleString('ko-KR')}자 초과 · ` +
|
||||
`서식 서명이 너무 깊게 중첩됨 · 그림 ${SIGNATURE_IMAGES_PER_SIGNATURE}장 초과 · 모르는 필드`;
|
||||
|
||||
const SIGNATURE_ID_PARAM = {
|
||||
name: 'id',
|
||||
description: '서명 id (서버가 만든 UUID) — 형식이 틀리면 404 RES_001',
|
||||
example: '0b6f7c1e-3a52-4c4e-9f0e-2d1f5a7b8c9d',
|
||||
};
|
||||
|
||||
// 여러 서명 (계약 2절 "서명" — 16단계) — 로그인한 직원 자신의 서명만 다룬다. 서명 그림 경로는 그대로 /api/mail-signature/images (mail-signature.controller.ts)
|
||||
@ApiTags('Mail')
|
||||
@ApiCookieAuth()
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ApiSessionErrors('로그인 필요 (쿠키 없음·만료)')
|
||||
@Controller('mail-signatures')
|
||||
export class MailSignaturesController {
|
||||
constructor(private readonly signatures: MailSignatureService) {}
|
||||
|
||||
@Get()
|
||||
@ApiOperation({
|
||||
summary: '내 서명 목록',
|
||||
description: `만든 차례 · ${SIGNATURES_MAX}개까지 한 번에 모두 (hasNext=false). 「메일에 넣을 서명」은 /api/mail-signature-defaults`,
|
||||
})
|
||||
@ApiStandardResponse(MailSignaturePageDto, { description: '서명 목록' })
|
||||
list(@CurrentUser() user: AuthenticatedUser) {
|
||||
return this.signatures.listSignatures(user.email);
|
||||
}
|
||||
|
||||
@Post()
|
||||
@ApiOperation({
|
||||
summary: '서명 만들기',
|
||||
description:
|
||||
`${SIGNATURES_MAX}개까지. 서명이 하나도 없던 직원이면 이 서명이 「새 메일」 · 「답장 · 전달」 두 곳의 기본이 된다(그 밖에는 기본을 바꾸지 않는다). ` +
|
||||
'bodyHtml 은 서식 본문 허용 목록 + 내 서명 그림으로 정화해 저장한다. 어느 서명도 더는 가리키지 않는 그림은 지운다 — 막 올리고(10분 안) 아직 넣지 않은 그림은 남긴다.',
|
||||
})
|
||||
@ApiStandardResponse(MailSignatureDto, {
|
||||
status: HttpStatus.CREATED,
|
||||
description: '만든 서명',
|
||||
})
|
||||
@ApiErrorResponse(
|
||||
'VAL_001',
|
||||
`${SAVE_REJECTIONS} · 이미 ${SIGNATURES_MAX}개 — 저장하지 않았다`,
|
||||
)
|
||||
create(
|
||||
@CurrentUser() user: AuthenticatedUser,
|
||||
@Body() dto: SaveMailSignatureDto,
|
||||
) {
|
||||
return this.signatures.createSignature(user.email, dto);
|
||||
}
|
||||
|
||||
@Put(':id')
|
||||
@ApiOperation({
|
||||
summary: '서명 고치기 (이름 · 본문 — 전체 교체)',
|
||||
description:
|
||||
'「메일에 넣을 서명」 표시는 그대로다. 어느 서명도 더는 가리키지 않는 그림은 지운다 — 막 올리고(10분 안) 아직 넣지 않은 그림은 남긴다.',
|
||||
})
|
||||
@ApiParam(SIGNATURE_ID_PARAM)
|
||||
@ApiStandardResponse(MailSignatureDto, { description: '고친 서명' })
|
||||
@ApiErrorResponse('VAL_001', `${SAVE_REJECTIONS} — 저장하지 않았다`)
|
||||
@ApiErrorResponse('RES_001', '없는 서명 · 남의 서명 · 형식이 틀린 id')
|
||||
update(
|
||||
@CurrentUser() user: AuthenticatedUser,
|
||||
@Param('id', MailSignatureIdPipe) id: string,
|
||||
@Body() dto: SaveMailSignatureDto,
|
||||
) {
|
||||
return this.signatures.updateSignature(user.email, id, dto);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@HttpCode(HttpStatus.NO_CONTENT)
|
||||
@SkipTransform()
|
||||
@ApiOperation({
|
||||
summary: '서명 지우기',
|
||||
description:
|
||||
'이 서명을 기본으로 쓰던 곳(새 메일 · 답장과 전달)은 넣지 않음이 된다 — 다른 서명이 저절로 기본이 되지 않는다. ' +
|
||||
'이 서명에만 쓰던 그림은 함께 지운다. 이미 보냈거나 임시 저장한 메일에 들어간 서명 글은 그대로다.',
|
||||
})
|
||||
@ApiParam(SIGNATURE_ID_PARAM)
|
||||
@ApiResponse({
|
||||
status: HttpStatus.NO_CONTENT,
|
||||
description: '지웠다 — 본문 없음',
|
||||
})
|
||||
@ApiErrorResponse('RES_001', '없는 서명 · 남의 서명 · 형식이 틀린 id')
|
||||
async remove(
|
||||
@CurrentUser() user: AuthenticatedUser,
|
||||
@Param('id', MailSignatureIdPipe) id: string,
|
||||
): Promise<void> {
|
||||
await this.signatures.deleteSignature(user.email, id);
|
||||
}
|
||||
}
|
||||
|
||||
// 「메일에 넣을 서명」 둘 (계약 2절 "서명" — 16단계) — 새 메일 · 답장과 전달에 저절로 넣는 서명. 15단계까지의 두 체크(isForNew · isForReply)를 대신한다
|
||||
@ApiTags('Mail')
|
||||
@ApiCookieAuth()
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ApiSessionErrors('로그인 필요 (쿠키 없음·만료)')
|
||||
@Controller('mail-signature-defaults')
|
||||
export class MailSignatureDefaultsController {
|
||||
constructor(private readonly signatures: MailSignatureService) {}
|
||||
|
||||
@Get()
|
||||
@ApiOperation({
|
||||
summary: '메일에 넣을 서명',
|
||||
description:
|
||||
'새 메일 · 답장과 전달에 넣는 서명의 id — null 이면 넣지 않음. 서명이 하나도 없으면 둘 다 null',
|
||||
})
|
||||
@ApiStandardResponse(MailSignatureDefaultsDto, {
|
||||
description: '메일에 넣을 서명',
|
||||
})
|
||||
get(@CurrentUser() user: AuthenticatedUser) {
|
||||
return this.signatures.getDefaults(user.email);
|
||||
}
|
||||
|
||||
@Put()
|
||||
@ApiOperation({
|
||||
summary: '메일에 넣을 서명 바꾸기 (전체 교체)',
|
||||
description:
|
||||
'두 키를 모두 보낸다(null = 넣지 않음). 두 곳이 같은 서명이어도 된다. 바꾼 뒤의 값을 돌려준다',
|
||||
})
|
||||
@ApiStandardResponse(MailSignatureDefaultsDto, {
|
||||
description: '바꾼 뒤의 값',
|
||||
})
|
||||
@ApiErrorResponse('VAL_001', '형식 오류 · 빠진 키 · 모르는 필드')
|
||||
@ApiErrorResponse(
|
||||
'RES_001',
|
||||
'내 서명이 아닌 id(없음 · 남의 것 · 형식이 틀림) — 아무것도 바꾸지 않았다',
|
||||
)
|
||||
update(
|
||||
@CurrentUser() user: AuthenticatedUser,
|
||||
@Body() dto: MailSignatureDefaultsDto,
|
||||
) {
|
||||
return this.signatures.setDefaults(user.email, dto);
|
||||
}
|
||||
}
|
||||
@@ -2,10 +2,18 @@ import { Injectable, PipeTransform } from '@nestjs/common';
|
||||
|
||||
import { BusinessException } from '@/common/errors/business.exception';
|
||||
|
||||
/** 서명 그림 id — 서버가 만든 UUID */
|
||||
const SIGNATURE_IMAGE_ID =
|
||||
/** 서명 · 서명 그림 id — 서버가 만든 UUID */
|
||||
const SERVER_UUID =
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||
|
||||
/** 경로의 UUID 를 소문자로 — 형식이 틀리면 "그런 것이 없다"와 같으므로 RES_001 (그 사유로) */
|
||||
function serverUuid(value: unknown, reason: string): string {
|
||||
if (typeof value === 'string' && SERVER_UUID.test(value)) {
|
||||
return value.toLowerCase();
|
||||
}
|
||||
throw new BusinessException('RES_001', { context: { reason } });
|
||||
}
|
||||
|
||||
/**
|
||||
* 경로의 서명 그림 id 형식 확인 — 형식이 틀리면 "그런 그림이 없다"와 같으므로 RES_001 (DB 의 uuid 열에 넣기 전에 막는다 —
|
||||
* 형식이 틀린 값이 DB 오류(SYS_001)로 새어 나가지 않게).
|
||||
@@ -13,11 +21,14 @@ const SIGNATURE_IMAGE_ID =
|
||||
@Injectable()
|
||||
export class SignatureImageIdPipe implements PipeTransform<unknown, string> {
|
||||
transform(value: unknown): string {
|
||||
if (typeof value === 'string' && SIGNATURE_IMAGE_ID.test(value)) {
|
||||
return value.toLowerCase();
|
||||
}
|
||||
throw new BusinessException('RES_001', {
|
||||
context: { reason: 'malformed-signature-image-id' },
|
||||
});
|
||||
return serverUuid(value, 'malformed-signature-image-id');
|
||||
}
|
||||
}
|
||||
|
||||
/** 경로의 서명 id 형식 확인 (16단계 — `/api/mail-signatures/{id}`) — 그림 id 와 같은 규칙, 틀리면 RES_001 */
|
||||
@Injectable()
|
||||
export class MailSignatureIdPipe implements PipeTransform<unknown, string> {
|
||||
transform(value: unknown): string {
|
||||
return serverUuid(value, 'malformed-signature-id');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { IsString, Matches, MaxLength, ValidateIf } from 'class-validator';
|
||||
|
||||
import {
|
||||
MAIL_FILTER_RUN_CURSOR_PATTERN,
|
||||
MAX_EMAIL_LENGTH,
|
||||
MAX_MAIL_FILTER_RUN_CURSOR_LENGTH,
|
||||
} from '@/modules/mail/mail.constants';
|
||||
import type {
|
||||
MailBlockRunRequest,
|
||||
MailFilterRun,
|
||||
} from '@/modules/mail/mail.types';
|
||||
import { BlockAddress } from './mail-block.dto';
|
||||
|
||||
/**
|
||||
* 차단할 때 받은편지함 정리 — POST /api/mail-block-runs 의 본문과 응답 (계약 2절 "보낸 사람 차단" — 15단계).
|
||||
* 보낸 사람은 차단하기와 같은 검사(주소 · `@도메인` 모양 · 254자 — mail-blocks.ts), 차단 목록에 있는지는 서비스가 본다(없으면 404 RES_001).
|
||||
* cursor 는 이미 받은 메일에 필터 적용(dto/mail-filter-run.dto.ts)과 같은 검사다
|
||||
*/
|
||||
|
||||
/** null 은 "처음부터"로 받는다 — 키가 없으면(undefined) 형식 오류다 (늘 보낸다) */
|
||||
const unlessNull = (_object: object, value: unknown) => value !== null;
|
||||
|
||||
/** POST /api/mail-block-runs — 정리할 보낸 사람(차단 목록의 값)과 이어 할 자리 */
|
||||
export class MailBlockRunRequestDto implements MailBlockRunRequest {
|
||||
@ApiProperty({
|
||||
description:
|
||||
'차단 목록의 값 그대로 — 소문자 주소("kim@spam.example") 또는 도메인("@spam.example"). 차단한 뒤에만 부른다 — 차단 목록에 없으면 404 RES_001',
|
||||
example: '@spam.example',
|
||||
maxLength: MAX_EMAIL_LENGTH,
|
||||
})
|
||||
@BlockAddress()
|
||||
sender!: string;
|
||||
|
||||
@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-block-runs 의 응답 — 이미 받은 메일에 필터 적용(MailFilterRun)과 같은 꼴 */
|
||||
export class MailBlockRunDto implements MailFilterRun {
|
||||
@ApiProperty({
|
||||
description: '이번 요청에서 확인한 받은편지함 메일 수',
|
||||
example: 500,
|
||||
})
|
||||
scanned!: number;
|
||||
|
||||
@ApiProperty({
|
||||
description:
|
||||
'그중 그 보낸 사람의 메일이라 스팸메일함으로 옮긴 수 (스팸 표시도 붙였다). 보내기를 기다리는 메일은 옮기지 않아 세지 않는다',
|
||||
example: 37,
|
||||
})
|
||||
matched!: number;
|
||||
|
||||
@ApiProperty({
|
||||
description:
|
||||
'아직 확인하지 않은 메일 수 — 어림(그사이 들어오고 나간 메일이 있다). 진행의 전체는 지금까지 확인한 수 + 이 값',
|
||||
example: 4_300,
|
||||
})
|
||||
remaining!: number;
|
||||
|
||||
@ApiProperty({
|
||||
description:
|
||||
'null = 끝. 아니면 같은 본문에 이 cursor 를 넣어 다시 보낸다 (해석하지 않는다)',
|
||||
nullable: true,
|
||||
type: String,
|
||||
example:
|
||||
'eyJ2IjoyLCJiIjoxNzkzMjY0NDAxLCJhIjoic2VhYWFhZXIiLCJ0IjoxNzkzMjY0MzQwLCJyIjoiZDJWM2R0ejdOMmdCVVRyZyJ9',
|
||||
})
|
||||
cursor!: string | null;
|
||||
}
|
||||
@@ -32,7 +32,8 @@ function IsBlockTarget() {
|
||||
);
|
||||
}
|
||||
|
||||
function BlockAddress(): PropertyDecorator {
|
||||
/** 차단 대상 칸 — 차단하기(POST /api/mail-blocks) · 받은편지함 정리(POST /api/mail-block-runs — 15단계)가 같이 쓴다 */
|
||||
export function BlockAddress(): PropertyDecorator {
|
||||
return applyDecorators(
|
||||
TrimString(),
|
||||
IsString({ message: '메일 주소나 @도메인 형식으로 써 주세요.' }),
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { plainToInstance } from 'class-transformer';
|
||||
import { validate } from 'class-validator';
|
||||
import { validate, type ValidationError } from 'class-validator';
|
||||
|
||||
import {
|
||||
MAX_VACATION_SUBJECT_LENGTH,
|
||||
@@ -18,6 +18,22 @@ async function constraintsOf(body: object): Promise<string[]> {
|
||||
return errors.flatMap((error) => Object.keys(error.constraints ?? {}));
|
||||
}
|
||||
|
||||
/** 안쪽 객체(external — 16단계)까지 — `칸.규칙` 모양으로 모두 */
|
||||
async function allConstraintsOf(body: object): Promise<string[]> {
|
||||
const errors = await validate(plainToInstance(MailVacationDto, body), {
|
||||
whitelist: true,
|
||||
forbidNonWhitelisted: true,
|
||||
});
|
||||
const flatten = (list: ValidationError[], prefix: string): string[] =>
|
||||
list.flatMap((error) => [
|
||||
...Object.keys(error.constraints ?? {}).map(
|
||||
(name) => `${prefix}${error.property}.${name}`,
|
||||
),
|
||||
...flatten(error.children ?? [], `${prefix}${error.property}.`),
|
||||
]);
|
||||
return flatten(errors, '');
|
||||
}
|
||||
|
||||
describe('부재중 자동 응답 본문 (PUT /api/mail-vacation)', () => {
|
||||
const enabled = {
|
||||
isEnabled: true,
|
||||
@@ -72,7 +88,7 @@ describe('부재중 자동 응답 본문 (PUT /api/mail-vacation)', () => {
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it(`요청 검증의 길이 한도는 켬 · 끔과 상관없이 제목 ${MAX_VACATION_SUBJECT_LENGTH_WHEN_OFF}자 · 내용 ${MAX_VACATION_TEXT_LENGTH_WHEN_OFF}자 (JS 문자열 길이) — 서식 본문은 받지 않는다`, async () => {
|
||||
it(`요청 검증의 길이 한도는 켬 · 끔과 상관없이 제목 ${MAX_VACATION_SUBJECT_LENGTH_WHEN_OFF}자 · 내용 ${MAX_VACATION_TEXT_LENGTH_WHEN_OFF}자 (JS 문자열 길이) — 16단계부터 서식 본문(htmlBody)도 받는다`, async () => {
|
||||
for (const isEnabled of [false, true]) {
|
||||
expect(
|
||||
await constraintsOf({
|
||||
@@ -102,7 +118,7 @@ describe('부재중 자동 응답 본문 (PUT /api/mail-vacation)', () => {
|
||||
);
|
||||
expect(
|
||||
await constraintsOf({ ...enabled, htmlBody: '<p>휴가</p>' }),
|
||||
).toEqual(['whitelistValidation']);
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it(`꺼 둔 채로는 켤 때의 한도(제목 ${MAX_VACATION_SUBJECT_LENGTH}자 · 내용 ${MAX_VACATION_TEXT_LENGTH}자)를 넘는 글도 받는다 — 제목 151~200자 · 내용 601~4,000자 (켤 때의 한도는 서비스가 본다)`, async () => {
|
||||
@@ -212,6 +228,134 @@ describe('부재중 자동 응답 본문 (PUT /api/mail-vacation)', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('부재중 서식 본문 · 회사 밖 응답 (16단계)', () => {
|
||||
const enabled = {
|
||||
isEnabled: true,
|
||||
fromDate: null,
|
||||
toDate: null,
|
||||
subject: '부재중입니다',
|
||||
textBody: '11월 7일까지 휴가입니다.',
|
||||
};
|
||||
const external = {
|
||||
mode: 'separate',
|
||||
subject: 'Out of office',
|
||||
textBody: 'I am away until Nov 7.',
|
||||
htmlBody: '<p>I am away until <strong>Nov 7</strong>.</p>',
|
||||
};
|
||||
|
||||
it('htmlBody · external 은 보내지 않아도 된다(옛 요청) — 보내면 htmlBody 는 글 · null, external 은 네 키(글 칸은 null 가능) · null', async () => {
|
||||
expect(await allConstraintsOf(enabled)).toEqual([]);
|
||||
expect(
|
||||
await allConstraintsOf({
|
||||
...enabled,
|
||||
htmlBody: '<p>휴가</p>',
|
||||
external,
|
||||
}),
|
||||
).toEqual([]);
|
||||
expect(
|
||||
await allConstraintsOf({ ...enabled, htmlBody: null, external: null }),
|
||||
).toEqual([]);
|
||||
for (const mode of ['same', 'none', 'separate']) {
|
||||
expect(
|
||||
await allConstraintsOf({
|
||||
...enabled,
|
||||
external: { mode, subject: null, textBody: null, htmlBody: null },
|
||||
}),
|
||||
).toEqual([]);
|
||||
}
|
||||
});
|
||||
|
||||
it('형식이 틀리면 VAL_001 — 모르는 고르기 · 빠진 키 · 글이 아닌 값 · 모르는 키 · 객체가 아님', async () => {
|
||||
expect(await allConstraintsOf({ ...enabled, htmlBody: 1 })).toEqual([
|
||||
'htmlBody.isString',
|
||||
]);
|
||||
expect(
|
||||
await allConstraintsOf({
|
||||
...enabled,
|
||||
external: { ...external, mode: 'internal' },
|
||||
}),
|
||||
).toEqual(['external.mode.isIn']);
|
||||
// 빠진 키 — 전체 교체라 null 로 보낸다(이 검사는 stopAtFirstError 를 빼 걸린 규칙이 모두 보인다)
|
||||
expect(
|
||||
await allConstraintsOf({ ...enabled, external: { mode: 'same' } }),
|
||||
).toEqual(
|
||||
expect.arrayContaining([
|
||||
'external.subject.isString',
|
||||
'external.textBody.isString',
|
||||
'external.htmlBody.isString',
|
||||
]),
|
||||
);
|
||||
expect(
|
||||
await allConstraintsOf({
|
||||
...enabled,
|
||||
external: { ...external, subject: 1, htmlBody: [] },
|
||||
}),
|
||||
).toEqual(
|
||||
expect.arrayContaining([
|
||||
'external.subject.isString',
|
||||
'external.htmlBody.isString',
|
||||
]),
|
||||
);
|
||||
expect(
|
||||
await allConstraintsOf({
|
||||
...enabled,
|
||||
external: { ...external, isActive: true },
|
||||
}),
|
||||
).toEqual(['external.isActive.whitelistValidation']);
|
||||
for (const value of [[], 'same', 1]) {
|
||||
expect(await allConstraintsOf({ ...enabled, external: value })).toEqual(
|
||||
expect.arrayContaining(['external.isObject']),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it(`회사 밖 제목 · 내용은 사내 칸과 같은 형식 한도를 늘 본다 — ${MAX_VACATION_SUBJECT_LENGTH_WHEN_OFF}자 · ${MAX_VACATION_TEXT_LENGTH_WHEN_OFF}자 · 제어 문자 (켤 때의 150 · 600자는 서비스가)`, async () => {
|
||||
for (const isEnabled of [false, true]) {
|
||||
expect(
|
||||
await allConstraintsOf({
|
||||
...enabled,
|
||||
isEnabled,
|
||||
external: {
|
||||
...external,
|
||||
subject: 'x'.repeat(MAX_VACATION_SUBJECT_LENGTH_WHEN_OFF),
|
||||
textBody: 'x'.repeat(MAX_VACATION_TEXT_LENGTH_WHEN_OFF),
|
||||
},
|
||||
}),
|
||||
).toEqual([]);
|
||||
}
|
||||
expect(
|
||||
await allConstraintsOf({
|
||||
...enabled,
|
||||
external: {
|
||||
...external,
|
||||
subject: 'x'.repeat(MAX_VACATION_SUBJECT_LENGTH_WHEN_OFF + 1),
|
||||
textBody: 'x'.repeat(MAX_VACATION_TEXT_LENGTH_WHEN_OFF + 1),
|
||||
},
|
||||
}),
|
||||
).toEqual([
|
||||
'external.subject.maxCodeUnits',
|
||||
'external.textBody.maxCodeUnits',
|
||||
]);
|
||||
expect(
|
||||
await allConstraintsOf({
|
||||
...enabled,
|
||||
external: { ...external, subject: 'a\nb', textBody: 'a\u0000' },
|
||||
}),
|
||||
).toEqual(['external.subject.matches', 'external.textBody.matches']);
|
||||
// 켤 때의 한도(150 · 600)를 넘는 글도 형식으로는 맞다 — 켤 때 따로 쓰기면 서비스가 VAL_001
|
||||
expect(
|
||||
await allConstraintsOf({
|
||||
...enabled,
|
||||
external: {
|
||||
...external,
|
||||
subject: 'x'.repeat(MAX_VACATION_SUBJECT_LENGTH + 1),
|
||||
textBody: 'x'.repeat(MAX_VACATION_TEXT_LENGTH + 1),
|
||||
},
|
||||
}),
|
||||
).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isUtcDateTime', () => {
|
||||
it('JS toISOString 모양(소수 초 3자리까지)과 소수 초 없는 모양을 받는다', () => {
|
||||
expect(isUtcDateTime('2026-11-02T15:00:00.000Z')).toBe(true);
|
||||
|
||||
@@ -1,15 +1,22 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { ApiProperty, ApiPropertyOptional, OmitType } from '@nestjs/swagger';
|
||||
import { Type } from 'class-transformer';
|
||||
import {
|
||||
IsBoolean,
|
||||
IsIn,
|
||||
IsObject,
|
||||
IsOptional,
|
||||
IsString,
|
||||
Matches,
|
||||
ValidateBy,
|
||||
ValidateIf,
|
||||
ValidateNested,
|
||||
type ValidationOptions,
|
||||
} from 'class-validator';
|
||||
|
||||
import { MaxCodeUnits } from '@/common/decorators/code-units.decorator';
|
||||
import {
|
||||
MAIL_VACATION_EXTERNAL_MODES,
|
||||
MAX_VACATION_HTML_LENGTH,
|
||||
MAX_VACATION_SUBJECT_LENGTH,
|
||||
MAX_VACATION_SUBJECT_LENGTH_WHEN_OFF,
|
||||
MAX_VACATION_TEXT_LENGTH,
|
||||
@@ -18,7 +25,8 @@ import {
|
||||
SINGLE_LINE_TEXT_PATTERN,
|
||||
} from '@/modules/mail/mail.constants';
|
||||
import type {
|
||||
MailVacation,
|
||||
MailVacationExternal,
|
||||
MailVacationExternalMode,
|
||||
MailVacationState,
|
||||
} from '@/modules/mail/mail.types';
|
||||
|
||||
@@ -46,16 +54,84 @@ export function IsUtcDateTime(options: ValidationOptions) {
|
||||
);
|
||||
}
|
||||
|
||||
/** null 은 "날짜 없음"으로 받는다 — 키가 없으면(undefined) 형식 오류다 (전체 교체라 다섯 필드를 모두 보낸다) */
|
||||
/** null 은 "날짜 없음" · "쓴 적 없음"으로 받는다 — 키가 없으면(undefined) 형식 오류다 (전체 교체라 키를 모두 보낸다) */
|
||||
const unlessNull = (_object: object, value: unknown) => value !== null;
|
||||
|
||||
const HTML_BODY_DESCRIPTION =
|
||||
`서식 본문 — 서식 본문과 같은 허용 목록으로 정화해 저장한다(그림은 넣을 수 없다). 정화 뒤 ${MAX_VACATION_HTML_LENGTH.toLocaleString('ko-KR')}자까지(켤 때만 본다 — 넘으면 400 VAL_001). ` +
|
||||
'null · 빈 글이면 글만. 받는 사람에게는 글(textBody) + 서식 두 벌로 간다';
|
||||
|
||||
/**
|
||||
* 회사 밖 응답 (16단계) — 네 키를 모두 보낸다(글 칸은 null 가능). 따로 쓰기(separate)이고 켤 때는 제목 · 내용이 1자 이상 ·
|
||||
* 제목 150자 · 내용 600자 · 서식 10,000자(서비스가 본다). 다른 고르기여도 써 둔 글은 그대로 저장한다
|
||||
*/
|
||||
export class MailVacationExternalDto implements MailVacationExternal {
|
||||
@ApiProperty({
|
||||
description:
|
||||
'회사 밖(봉투 보낸 사람의 도메인이 회사 메일 도메인이 아님)에서 온 메일에 — same: 사내와 같은 응답(처음 값) · none: 보내지 않기 · separate: 아래 제목 · 내용으로',
|
||||
enum: MAIL_VACATION_EXTERNAL_MODES,
|
||||
example: 'separate',
|
||||
})
|
||||
@IsIn(MAIL_VACATION_EXTERNAL_MODES, {
|
||||
message: '회사 밖 응답을 다시 선택해 주세요.',
|
||||
})
|
||||
mode!: MailVacationExternalMode;
|
||||
|
||||
@ApiProperty({
|
||||
description: `회사 밖에 보낼 제목 — 켜고 따로 쓰기면 1~${MAX_VACATION_SUBJECT_LENGTH}자, 그 밖에는 ${MAX_VACATION_SUBJECT_LENGTH_WHEN_OFF}자까지. 빈 글은 null 로 저장한다. 제어 문자는 쓸 수 없다`,
|
||||
nullable: true,
|
||||
type: String,
|
||||
maxLength: MAX_VACATION_SUBJECT_LENGTH_WHEN_OFF,
|
||||
example: 'Out of office',
|
||||
})
|
||||
@ValidateIf(unlessNull)
|
||||
@IsString({ message: '회사 밖에 보낼 제목 형식이 올바르지 않습니다.' })
|
||||
@MaxCodeUnits(MAX_VACATION_SUBJECT_LENGTH_WHEN_OFF, {
|
||||
message: `회사 밖에 보낼 제목은 ${MAX_VACATION_SUBJECT_LENGTH_WHEN_OFF}자 이하로 입력해 주세요.`,
|
||||
})
|
||||
@Matches(SINGLE_LINE_TEXT_PATTERN, {
|
||||
message: '회사 밖에 보낼 제목에는 줄바꿈 같은 제어 문자를 쓸 수 없습니다.',
|
||||
})
|
||||
subject!: string | null;
|
||||
|
||||
@ApiProperty({
|
||||
description: `회사 밖에 보낼 내용(보이는 글) — 켜고 따로 쓰기면 1~${MAX_VACATION_TEXT_LENGTH}자, 그 밖에는 ${MAX_VACATION_TEXT_LENGTH_WHEN_OFF.toLocaleString('ko-KR')}자까지. 빈 글은 null 로 저장한다`,
|
||||
nullable: true,
|
||||
type: String,
|
||||
maxLength: MAX_VACATION_TEXT_LENGTH_WHEN_OFF,
|
||||
example: 'I am out of the office until November 7.',
|
||||
})
|
||||
@ValidateIf(unlessNull)
|
||||
@IsString({ message: '회사 밖에 보낼 내용 형식이 올바르지 않습니다.' })
|
||||
@MaxCodeUnits(MAX_VACATION_TEXT_LENGTH_WHEN_OFF, {
|
||||
message: `회사 밖에 보낼 내용은 ${MAX_VACATION_TEXT_LENGTH_WHEN_OFF.toLocaleString('ko-KR')}자 이하로 입력해 주세요.`,
|
||||
})
|
||||
@Matches(MULTI_LINE_TEXT_PATTERN, {
|
||||
message:
|
||||
'회사 밖에 보낼 내용에는 탭 · 줄바꿈 밖의 제어 문자를 쓸 수 없습니다.',
|
||||
})
|
||||
textBody!: string | null;
|
||||
|
||||
@ApiProperty({
|
||||
description: `회사 밖에 보낼 ${HTML_BODY_DESCRIPTION}`,
|
||||
nullable: true,
|
||||
type: String,
|
||||
example:
|
||||
'<p style="margin: 0">I am out of the office until <strong>November 7</strong>.</p>',
|
||||
})
|
||||
@ValidateIf(unlessNull)
|
||||
@IsString({ message: '회사 밖에 보낼 내용 형식이 올바르지 않습니다.' })
|
||||
htmlBody!: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 부재중 자동 응답 — PUT 본문(전체 교체). GET · PUT 응답은 이것에 isActive 를 더한 MailVacationStateDto 다 (계약 2절 "부재중 자동 응답", 7단계).
|
||||
* 켤 때 제목 · 내용이 비었는지 · 켤 때의 길이(150자 · 600자), 시작이 끝보다 앞인지는 서비스가 본다 (400 VAL_001).
|
||||
* 켤 때 제목 · 내용이 비었는지 · 켤 때의 길이(150자 · 600자 · 서식 10,000자), 시작이 끝보다 앞인지는 서비스가 본다 (400 VAL_001).
|
||||
* 여기서는 켬 · 끔과 상관없는 한도(4단계의 200자 · 4,000자)만 본다 — 같은 속성에 ValidateIf 를 걸면 그 속성의 검사가 모두 빠져
|
||||
* 켤 때만의 한도를 함께 걸 수 없다. 서식 본문은 받지 않는다.
|
||||
* 켤 때만의 한도를 함께 걸 수 없다.
|
||||
* 16단계의 htmlBody · external 은 보내지 않아도 된다 — 없으면 글만 · 사내와 같은 응답(회사 밖 글 칸 null)으로 저장한다(옛 화면이 보는 그대로)
|
||||
*/
|
||||
export class MailVacationDto implements MailVacation {
|
||||
export class MailVacationDto {
|
||||
@ApiProperty({
|
||||
description: `켜짐 — 켜면 제목 · 내용이 1자 이상, 제목 ${MAX_VACATION_SUBJECT_LENGTH}자 · 내용 ${MAX_VACATION_TEXT_LENGTH}자까지여야 한다 (아니면 400 VAL_001)`,
|
||||
example: true,
|
||||
@@ -102,8 +178,8 @@ export class MailVacationDto implements MailVacation {
|
||||
|
||||
@ApiProperty({
|
||||
description:
|
||||
`자동 응답 내용 — 글자만(서식 없음). 켤 때는 1~${MAX_VACATION_TEXT_LENGTH.toLocaleString('ko-KR')}자(엔진의 바이트 한도 안), ` +
|
||||
`꺼 둔 채로는 ${MAX_VACATION_TEXT_LENGTH_WHEN_OFF.toLocaleString('ko-KR')}자까지 (JS 문자열 길이). 서명은 붙지 않는다`,
|
||||
`자동 응답 내용(보이는 글 — 서식을 뺀 글). 켤 때는 1~${MAX_VACATION_TEXT_LENGTH.toLocaleString('ko-KR')}자(엔진의 바이트 한도 안), ` +
|
||||
`꺼 둔 채로는 ${MAX_VACATION_TEXT_LENGTH_WHEN_OFF.toLocaleString('ko-KR')}자까지 (JS 문자열 길이). 서식 본문(htmlBody)이 있으면 받는 쪽의 글 판이다. 서명은 붙지 않는다`,
|
||||
maxLength: MAX_VACATION_TEXT_LENGTH_WHEN_OFF,
|
||||
example: '11월 7일까지 휴가입니다. 급한 일은 이도윤 님께 연락해 주세요.',
|
||||
})
|
||||
@@ -115,16 +191,52 @@ export class MailVacationDto implements MailVacation {
|
||||
message: '자동 응답 내용에는 탭 · 줄바꿈 밖의 제어 문자를 쓸 수 없습니다.',
|
||||
})
|
||||
textBody!: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: `${HTML_BODY_DESCRIPTION} (16단계 — 보내지 않으면 null)`,
|
||||
nullable: true,
|
||||
type: String,
|
||||
example:
|
||||
'<p style="margin: 0">11월 7일까지 <strong>휴가</strong>입니다.</p>',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString({ message: '자동 응답 내용 형식이 올바르지 않습니다.' })
|
||||
htmlBody?: string | null;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description:
|
||||
'회사 밖에서 온 메일에 하는 응답 (16단계) — 보내지 않거나 null 이면 사내와 같은 응답(글 칸 null)으로 저장한다',
|
||||
type: MailVacationExternalDto,
|
||||
})
|
||||
@IsOptional()
|
||||
@IsObject({ message: '회사 밖 응답 형식이 올바르지 않습니다.' })
|
||||
@ValidateNested()
|
||||
@Type(() => MailVacationExternalDto)
|
||||
external?: MailVacationExternalDto | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* GET · PUT /api/mail-vacation 의 응답 (계약 2절의 MailVacationState — 7단계) — 저장한 값 + 지금 동작하는지.
|
||||
* PUT 본문에는 isActive 를 넣지 않는다 (넣으면 모르는 필드 — 400 VAL_001). 응답 문서용이라 검사 데코레이터를 더하지 않는다
|
||||
* 16단계의 htmlBody · external 은 응답에 늘 있다. PUT 본문에는 isActive 를 넣지 않는다 (넣으면 모르는 필드 — 400 VAL_001).
|
||||
* 응답 문서용이라 검사 데코레이터를 더하지 않는다
|
||||
*/
|
||||
export class MailVacationStateDto
|
||||
extends MailVacationDto
|
||||
extends OmitType(MailVacationDto, ['htmlBody', 'external'] as const)
|
||||
implements MailVacationState
|
||||
{
|
||||
@ApiProperty({
|
||||
description: `${HTML_BODY_DESCRIPTION} — 늘 있다(없으면 null)`,
|
||||
nullable: true,
|
||||
type: String,
|
||||
})
|
||||
htmlBody!: string | null;
|
||||
|
||||
@ApiProperty({
|
||||
description: '회사 밖에서 온 메일에 하는 응답 — 늘 있다',
|
||||
type: MailVacationExternalDto,
|
||||
})
|
||||
external!: MailVacationExternalDto;
|
||||
|
||||
@ApiProperty({
|
||||
description:
|
||||
'false = 메일 엔진에서 우리 규칙(메일 필터와 한 규칙)이 꺼져 있어 켜 둔 자동 응답이 지금은 동작하지 않는다 — 저장하면 다시 켜진다. ' +
|
||||
|
||||
@@ -4,8 +4,9 @@ import { validate } from 'class-validator';
|
||||
import { MAX_MAILBOX_NAME_LENGTH } from '@/modules/mail/mail.constants';
|
||||
import {
|
||||
CreateMailboxDto,
|
||||
MailboxDeleteQueryDto,
|
||||
MailboxMessagesPatchDto,
|
||||
RenameMailboxDto,
|
||||
UpdateMailboxDto,
|
||||
} from './mailbox.dto';
|
||||
|
||||
/** 전역 ValidationPipe 와 같은 옵션으로 검증한다 (app.setup.ts — stopAtFirstError 만 빼고 — 걸린 규칙을 모두 본다, 통과 · 실패는 같다) — 변환한 값과 걸린 규칙 이름 */
|
||||
@@ -29,7 +30,7 @@ async function check<T extends object>(
|
||||
describe('메일함 요청 본문 (계약 2절 "메일함 관리")', () => {
|
||||
it.each([
|
||||
['만들기', CreateMailboxDto],
|
||||
['이름 바꾸기', RenameMailboxDto],
|
||||
['바꾸기', UpdateMailboxDto],
|
||||
] as const)(
|
||||
`%s — 이름은 앞뒤 공백을 뺀 1~${MAX_MAILBOX_NAME_LENGTH}자 (JS 문자열 길이)`,
|
||||
async (_name, type) => {
|
||||
@@ -65,7 +66,7 @@ describe('메일함 요청 본문 (계약 2절 "메일함 관리")', () => {
|
||||
|
||||
it.each([
|
||||
['만들기', CreateMailboxDto],
|
||||
['이름 바꾸기', RenameMailboxDto],
|
||||
['바꾸기', UpdateMailboxDto],
|
||||
] as const)(
|
||||
'%s — "/" 와 제어 문자(줄바꿈 · 탭 등)는 쓸 수 없다, 문자열이 아니면 형식 오류',
|
||||
async (_name, type) => {
|
||||
@@ -87,31 +88,72 @@ describe('메일함 요청 본문 (계약 2절 "메일함 관리")', () => {
|
||||
expect((await check(type, { name: 7 })).constraints).toEqual(
|
||||
expect.arrayContaining(['isString']),
|
||||
);
|
||||
expect((await check(type, {})).constraints).toEqual(
|
||||
expect((await check(type, { name: null })).constraints).toEqual(
|
||||
expect.arrayContaining(['isString']),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
it('만들기의 상위는 없거나 null(맨 위) · 메일함 id 형식 — 이름 바꾸기에는 상위가 없다', async () => {
|
||||
it('만들기는 이름이 없으면 형식 오류 — 바꾸기는 이름을 보내지 않아도 된다(빈 본문은 서비스가 VAL_001)', async () => {
|
||||
expect((await check(CreateMailboxDto, {})).constraints).toEqual(
|
||||
expect.arrayContaining(['isString']),
|
||||
);
|
||||
expect((await check(UpdateMailboxDto, {})).constraints).toEqual([]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['만들기', CreateMailboxDto, { name: '거래처' }],
|
||||
['바꾸기(옮기기 — 15단계)', UpdateMailboxDto, {}],
|
||||
] as const)(
|
||||
'%s의 상위는 없거나 null(맨 위) · 메일함 id 형식',
|
||||
async (_name, type, base) => {
|
||||
expect((await check(type, base)).constraints).toEqual([]);
|
||||
expect(
|
||||
(await check(type, { ...base, parentId: null })).constraints,
|
||||
).toEqual([]);
|
||||
expect(
|
||||
(await check(type, { ...base, parentId: 'a' })).constraints,
|
||||
).toEqual([]);
|
||||
for (const parentId of ['../a', '', 'a b', 7]) {
|
||||
expect((await check(type, { ...base, parentId })).constraints).toEqual([
|
||||
'matches',
|
||||
]);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it('바꾸기는 이름 · 상위를 함께 받고, 보낸 값을 그대로 둔다 — null 상위는 null(맨 위), 보내지 않은 칸은 undefined', async () => {
|
||||
const both = await check(UpdateMailboxDto, {
|
||||
name: ' 2025 ',
|
||||
parentId: 'u1',
|
||||
});
|
||||
expect(both.constraints).toEqual([]);
|
||||
expect(both.dto).toMatchObject({ name: '2025', parentId: 'u1' });
|
||||
|
||||
const top = await check(UpdateMailboxDto, { parentId: null });
|
||||
expect(top.constraints).toEqual([]);
|
||||
expect(top.dto.parentId).toBeNull();
|
||||
expect(top.dto.name).toBeUndefined();
|
||||
|
||||
expect(
|
||||
(await check(CreateMailboxDto, { name: '거래처' })).constraints,
|
||||
).toEqual([]);
|
||||
(await check(UpdateMailboxDto, { name: '거래처', role: 'inbox' }))
|
||||
.constraints,
|
||||
).toEqual(['whitelistValidation']);
|
||||
});
|
||||
|
||||
it('지우기의 쿼리는 withChildren=true 만 받는다 (15단계 — 하위 메일함까지)', async () => {
|
||||
expect((await check(MailboxDeleteQueryDto, {})).constraints).toEqual([]);
|
||||
expect(
|
||||
(await check(CreateMailboxDto, { name: '거래처', parentId: null }))
|
||||
(await check(MailboxDeleteQueryDto, { withChildren: 'true' }))
|
||||
.constraints,
|
||||
).toEqual([]);
|
||||
for (const withChildren of ['false', 'TRUE', '1', '', true]) {
|
||||
expect(
|
||||
(await check(MailboxDeleteQueryDto, { withChildren })).constraints,
|
||||
).toEqual(['isIn']);
|
||||
}
|
||||
expect(
|
||||
(await check(CreateMailboxDto, { name: '거래처', parentId: 'a' }))
|
||||
.constraints,
|
||||
).toEqual([]);
|
||||
expect(
|
||||
(await check(CreateMailboxDto, { name: '거래처', parentId: '../a' }))
|
||||
.constraints,
|
||||
).toEqual(['matches']);
|
||||
expect(
|
||||
(await check(RenameMailboxDto, { name: '거래처', parentId: 'a' }))
|
||||
.constraints,
|
||||
(await check(MailboxDeleteQueryDto, { recursive: 'true' })).constraints,
|
||||
).toEqual(['whitelistValidation']);
|
||||
});
|
||||
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
import { applyDecorators } from '@nestjs/common';
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { Equals, IsOptional, IsString, Matches } from 'class-validator';
|
||||
import {
|
||||
Equals,
|
||||
IsIn,
|
||||
IsOptional,
|
||||
IsString,
|
||||
Matches,
|
||||
ValidateIf,
|
||||
} from 'class-validator';
|
||||
|
||||
import {
|
||||
MaxCodeUnits,
|
||||
@@ -16,15 +23,32 @@ import {
|
||||
/** 메일함 이름에 쓸 수 없는 '/' — 경로 구분자로 읽는 메일 프로그램이 있다 (계약 2절 "메일함 관리") */
|
||||
const NO_SLASH_PATTERN = /^[^/]*$/;
|
||||
|
||||
/** 메일함 이름 — 앞뒤 공백을 뺀 1~MAX_MAILBOX_NAME_LENGTH 자(JS 문자열 길이), `/` · 제어 문자 없음. 만들기 · 이름 바꾸기가 같이 쓴다 */
|
||||
function MailboxName() {
|
||||
/** 메일함 이름 칸의 설명 */
|
||||
const MAILBOX_NAME_DESCRIPTION = `메일함 이름 — 앞뒤 공백을 뺀 1~${MAX_MAILBOX_NAME_LENGTH}자(JS 문자열 길이). "/" 와 제어 문자(줄바꿈 · 탭 등)는 쓸 수 없다. 같은 상위 안에 같은 이름이 있으면 409 MAIL_009`;
|
||||
|
||||
/** 보낸 칸만 검사한다 — 없으면(undefined) 건너뛰고, null 은 검사해 형식 오류로 막는다 (이름 · 상위 가운데 바꿀 것만 보내는 PATCH) */
|
||||
const unlessAbsent = (_object: object, value: unknown) => value !== undefined;
|
||||
|
||||
/**
|
||||
* 메일함 이름 — 앞뒤 공백을 뺀 1~MAX_MAILBOX_NAME_LENGTH 자(JS 문자열 길이), `/` · 제어 문자 없음. 만들기 · 바꾸기가 같이 쓴다.
|
||||
* `optional` 이면(바꾸기 — 15단계) 보내지 않아도 된다 — 보낸 값은 같은 검사를 한다(null 도 형식 오류)
|
||||
*/
|
||||
function MailboxName({ optional = false }: { optional?: boolean } = {}) {
|
||||
return applyDecorators(
|
||||
ApiProperty({
|
||||
description: `메일함 이름 — 앞뒤 공백을 뺀 1~${MAX_MAILBOX_NAME_LENGTH}자(JS 문자열 길이). "/" 와 제어 문자(줄바꿈 · 탭 등)는 쓸 수 없다. 같은 상위 안에 같은 이름이 있으면 409 MAIL_009`,
|
||||
example: '프로젝트',
|
||||
minLength: 1,
|
||||
maxLength: MAX_MAILBOX_NAME_LENGTH,
|
||||
}),
|
||||
optional
|
||||
? ApiPropertyOptional({
|
||||
description: `${MAILBOX_NAME_DESCRIPTION}. 보내지 않으면 이름은 그대로`,
|
||||
example: '프로젝트',
|
||||
minLength: 1,
|
||||
maxLength: MAX_MAILBOX_NAME_LENGTH,
|
||||
})
|
||||
: ApiProperty({
|
||||
description: MAILBOX_NAME_DESCRIPTION,
|
||||
example: '프로젝트',
|
||||
minLength: 1,
|
||||
maxLength: MAX_MAILBOX_NAME_LENGTH,
|
||||
}),
|
||||
...(optional ? [ValidateIf(unlessAbsent)] : []),
|
||||
TrimString(),
|
||||
IsString({ message: '메일함 이름 형식이 올바르지 않습니다.' }),
|
||||
MinCodeUnits(1, { message: '메일함 이름을 입력해 주세요.' }),
|
||||
@@ -59,10 +83,43 @@ export class CreateMailboxDto {
|
||||
parentId?: string | null;
|
||||
}
|
||||
|
||||
/** PATCH /api/mailboxes/{id} — 이름만 바꾼다 (다른 상위로 옮기기는 이 단계에서 하지 않는다) */
|
||||
export class RenameMailboxDto {
|
||||
@MailboxName()
|
||||
name!: string;
|
||||
/**
|
||||
* PATCH /api/mailboxes/{id} — 이름 · 상위(옮기기 — 15단계) 가운데 바꿀 것만. 둘 다 없으면(빈 본문) 서비스가 400 VAL_001.
|
||||
* 상위의 자리 규칙(만들기와 같다) · 자기 자신 · 하위 아래로 옮기기는 어댑터가 본다
|
||||
*/
|
||||
export class UpdateMailboxDto {
|
||||
@MailboxName({ optional: true })
|
||||
name?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description:
|
||||
'새 상위 메일함 id — null 이면 맨 위, 보내지 않으면 상위는 그대로. 만들기와 같은 자리만(받은편지함 · 보관함 · 내 메일함 아래 — 그 밖 400 VAL_001, 없는 id 는 404 RES_001). ' +
|
||||
'자기 자신 · 자기 하위 아래로는 400 VAL_001. 하위 메일함과 안의 메일은 함께 간다',
|
||||
nullable: true,
|
||||
type: String,
|
||||
example: 'a',
|
||||
})
|
||||
@IsOptional()
|
||||
@Matches(MAIL_ID_PATTERN, { message: '옮길 곳을 다시 선택해 주세요.' })
|
||||
parentId?: string | null;
|
||||
}
|
||||
|
||||
/** 하위 메일함까지 지우기의 값 — 이것만 받는다 (15단계) */
|
||||
export const WITH_CHILDREN = 'true';
|
||||
|
||||
/** DELETE /api/mailboxes/{id} 의 쿼리 — withChildren=true 면 하위 메일함까지 지운다 (15단계). 모르는 쿼리는 400 VAL_001 */
|
||||
export class MailboxDeleteQueryDto {
|
||||
@ApiPropertyOptional({
|
||||
description:
|
||||
'true 면 하위 메일함이 있어도 그 메일함과 하위 모두를 지운다(깊은 것부터 — 안의 메일은 휴지통으로). 없으면 하위가 있을 때 400 VAL_001(옛 화면이 모르고 하위를 지우지 않게). 다른 값은 400 VAL_001',
|
||||
enum: [WITH_CHILDREN],
|
||||
example: WITH_CHILDREN,
|
||||
})
|
||||
@IsOptional()
|
||||
@IsIn([WITH_CHILDREN], {
|
||||
message: '하위 메일함 지우기 값이 올바르지 않습니다.',
|
||||
})
|
||||
withChildren?: typeof WITH_CHILDREN;
|
||||
}
|
||||
|
||||
/** PATCH /api/mailboxes/{id}/messages — 그 메일함 전체를 읽음으로 (true 만 받는다) */
|
||||
|
||||
@@ -775,4 +775,60 @@ describe('sanitizeOutgoingHtml (보내는 서식 본문)', () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* 쓰기 화면의 서명 자리(16단계 — 계약 2절 "앱이 만드는 모양") — 빈 문단 하나 뒤의 **속성 없는 div 하나**가 서명 경계다.
|
||||
* 웹 편집기도 같은 경계로 서명을 넣고 임시 저장 · 이어 쓰기에서 다시 찾으므로, 보내는 정화 · 임시 저장본 다시 읽기 · 받은 메일 정화를 지나도 남아야 한다
|
||||
*/
|
||||
describe('서명 자리 — 빈 문단 뒤 속성 없는 div (16단계)', () => {
|
||||
const IMAGE_ID = '3f1c0a2e-0f1b-4f2a-9a1e-6c9f0a1b2c3d';
|
||||
const signatureBlock =
|
||||
'<div><p style="margin: 0"><strong>김아라</strong> · 인프라팀</p>' +
|
||||
`<img src="/api/mail-signature/images/${IMAGE_ID}" width="144" alt=""></div>`;
|
||||
const composed =
|
||||
`<div style="font-family: ${DEFAULT_FONT}; font-size: 14px; line-height: 1.75">` +
|
||||
'<p style="margin: 0">안녕하세요.</p><p style="margin: 0"><br></p>' +
|
||||
signatureBlock +
|
||||
'<div data-ara-quote-start=""></div>' +
|
||||
'<blockquote style="margin: 0; padding-left: 12px; border-left: 2px solid #D0D5DD"><p style="margin: 0">원문</p></blockquote></div>';
|
||||
/** 정화한 서명 자리 — 빈 문단 바로 뒤의 속성 없는 div, 안은 서명 */
|
||||
const keptBoundary =
|
||||
'<p style="margin:0"><br /></p><div><p style="margin:0"><strong>김아라</strong> · 인프라팀</p>' +
|
||||
`<img src="/api/mail-signature/images/${IMAGE_ID}" width="144" alt="" /></div>`;
|
||||
|
||||
it('보낼 때 · 임시 저장할 때(인용 경계 표시를 남기는 정화)와 저장본을 다시 열 때(표시 없는 정화) 모두 경계 div 가 속성 없이 남는다', () => {
|
||||
const ids = new Set([IMAGE_ID]);
|
||||
for (const options of [
|
||||
{ signatureImageIds: ids, keepQuoteStart: true },
|
||||
{ signatureImageIds: ids },
|
||||
]) {
|
||||
const html = clean(composed, options);
|
||||
expect(html).toContain(keptBoundary);
|
||||
// 정화한 것을 다시 정화해도 같다 — 이어 쓰기를 거듭해도 경계가 그대로다
|
||||
expect(clean(html, { signatureImageIds: ids })).toBe(
|
||||
clean(composed, { signatureImageIds: ids }),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it('받은 메일 정화(보낸편지함 · 받는 사람이 다시 읽을 때)도 속성 없는 div 를 그대로 둔다', () => {
|
||||
const sent = clean(composed, { signatureImageIds: new Set([IMAGE_ID]) });
|
||||
const { html } = sanitizeMailHtml(sent);
|
||||
expect(html).toContain(
|
||||
'<p style="margin:0"><br /></p><div><p style="margin:0"><strong>김아라</strong> · 인프라팀</p>',
|
||||
);
|
||||
});
|
||||
|
||||
it('속성이 모두 지워진 div 도 속성 없는 div 가 된다 — 경계는 자리(빈 문단 바로 뒤)로 알아본다 (화면 · 앱이 함께 보는 알려진 모양)', () => {
|
||||
for (const div of [
|
||||
'<div style="">',
|
||||
'<div style="position: absolute">',
|
||||
'<div class="sig" data-x="1">',
|
||||
]) {
|
||||
expect(
|
||||
clean(`<p style="margin: 0"><br></p>${div}<p>서명</p></div>`),
|
||||
).toBe('<p style="margin:0"><br /></p><div><p>서명</p></div>');
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
import { MAX_BODY_HTML_LENGTH } from '@/modules/mail/mail.constants';
|
||||
import { sanitizeStoredHtml } from './sanitize-stored-html';
|
||||
|
||||
describe('sanitizeStoredHtml — 저장해 두는 짧은 서식 글(서명 · 부재중 응답)의 정화', () => {
|
||||
const IMAGE_ID = '3f1c0a2e-0f1b-4f2a-9a1e-6c9f0a1b2c3d';
|
||||
const image = `<img src="/api/mail-signature/images/${IMAGE_ID}" width="96">`;
|
||||
|
||||
it('보내는 정화를 거친다 — 스크립트 · 이벤트 속성은 지우고, 그림은 넘겨준 서명 그림만 남긴다', () => {
|
||||
expect(
|
||||
sanitizeStoredHtml(
|
||||
`<p onclick="x()"><strong>휴가</strong><script>x()</script></p>${image}`,
|
||||
{ maxLength: null },
|
||||
),
|
||||
).toEqual({ ok: true, html: '<p><strong>휴가</strong></p>' });
|
||||
expect(
|
||||
sanitizeStoredHtml(`<p>서명</p>${image}`, {
|
||||
maxLength: null,
|
||||
signatureImageIds: new Set([IMAGE_ID]),
|
||||
}),
|
||||
).toEqual({
|
||||
ok: true,
|
||||
html: `<p>서명</p><img src="/api/mail-signature/images/${IMAGE_ID}" width="96" />`,
|
||||
});
|
||||
});
|
||||
|
||||
it('없거나 비었거나 정화해서 남는 것이 없으면 null', () => {
|
||||
for (const html of [null, undefined, '', '<script>x()</script>', ' ']) {
|
||||
expect(sanitizeStoredHtml(html, { maxLength: 10 })).toEqual({
|
||||
ok: true,
|
||||
html: null,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it('정화 뒤 길이는 maxLength 일 때만 본다 — 정화 전 길이 · 파서 비용은 늘 본다', () => {
|
||||
const long = `<p>${'가'.repeat(10_000)}</p>`;
|
||||
expect(sanitizeStoredHtml(long, { maxLength: 10_000 })).toEqual({
|
||||
ok: false,
|
||||
problem: 'too-long',
|
||||
length: 10_007,
|
||||
});
|
||||
expect(sanitizeStoredHtml(long, { maxLength: null })).toEqual({
|
||||
ok: true,
|
||||
html: long,
|
||||
});
|
||||
expect(
|
||||
sanitizeStoredHtml(`<p>${'가'.repeat(10_000 - 7)}</p>`, {
|
||||
maxLength: 10_000,
|
||||
}),
|
||||
).toMatchObject({ ok: true });
|
||||
|
||||
expect(
|
||||
sanitizeStoredHtml('x'.repeat(MAX_BODY_HTML_LENGTH + 1), {
|
||||
maxLength: null,
|
||||
}),
|
||||
).toEqual({
|
||||
ok: false,
|
||||
problem: 'too-long',
|
||||
length: MAX_BODY_HTML_LENGTH + 1,
|
||||
});
|
||||
expect(
|
||||
sanitizeStoredHtml('<b>'.repeat(166_666), { maxLength: null }),
|
||||
).toMatchObject({ ok: false, problem: 'too-complex' });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,54 @@
|
||||
import { MAX_BODY_HTML_LENGTH } from '@/modules/mail/mail.constants';
|
||||
import { checkHtmlParseBudget } from './html-parse-budget';
|
||||
import { sanitizeOutgoingHtml } from './sanitize-outgoing-html';
|
||||
|
||||
/**
|
||||
* 저장해 두고 다시 쓰는 짧은 서식 글의 정화 (순수 함수) — 서명(계약 2절 "서명")과 부재중 응답의 서식 본문(16단계 — "부재중 자동 응답").
|
||||
* 쓰기 화면의 서식 본문과 같은 규칙이다:
|
||||
* 1. 정화 전 — 길이(MAX_BODY_HTML_LENGTH — 500,000자)와 파서 비용(html-parse-budget.ts — 정화가 이벤트 루프를 오래 붙잡지 않게)
|
||||
* 2. 보내는 정화(sanitize-outgoing-html.ts) — 그림은 `signatureImageIds` 에 든 그 사람의 서명 그림만 남는다(없으면 그림 없음 — 부재중)
|
||||
* 3. 정화 뒤 — `maxLength`(JS 문자열 길이)를 넘으면 실패. null 이면 보지 않는다(부재중을 꺼 둔 채 저장할 때)
|
||||
* 비었거나 정화해서 남는 것이 없으면 null 이다. 실패는 부르는 쪽이 400 VAL_001 로 던진다(사유만 — 글은 로그에 남기지 않는다)
|
||||
*/
|
||||
|
||||
/** too-long: 정화 전 · 뒤의 길이 한도 초과, too-complex: 파서 비용 한도 초과(깊은 중첩 등) */
|
||||
export type StoredHtmlProblem = 'too-long' | 'too-complex';
|
||||
|
||||
export type StoredHtmlResult =
|
||||
| { readonly ok: true; readonly html: string | null }
|
||||
| {
|
||||
readonly ok: false;
|
||||
readonly problem: StoredHtmlProblem;
|
||||
/** 걸린 글의 길이 (JS 문자열 길이) — 로그용 */
|
||||
readonly length: number;
|
||||
};
|
||||
|
||||
export interface StoredHtmlOptions {
|
||||
/** 정화한 뒤의 최대 길이 — null 이면 보지 않는다 */
|
||||
readonly maxLength: number | null;
|
||||
/** 남겨도 되는 서명 그림 id — 없으면 그림을 하나도 남기지 않는다 */
|
||||
readonly signatureImageIds?: ReadonlySet<string>;
|
||||
}
|
||||
|
||||
export function sanitizeStoredHtml(
|
||||
html: string | null | undefined,
|
||||
options: StoredHtmlOptions,
|
||||
): StoredHtmlResult {
|
||||
if (!html) return { ok: true, html: null };
|
||||
if (html.length > MAX_BODY_HTML_LENGTH) {
|
||||
return { ok: false, problem: 'too-long', length: html.length };
|
||||
}
|
||||
if (!checkHtmlParseBudget(html).ok) {
|
||||
return { ok: false, problem: 'too-complex', length: html.length };
|
||||
}
|
||||
const sanitized = sanitizeOutgoingHtml(
|
||||
html,
|
||||
options.signatureImageIds
|
||||
? { signatureImageIds: options.signatureImageIds }
|
||||
: {},
|
||||
);
|
||||
if (options.maxLength !== null && sanitized.length > options.maxLength) {
|
||||
return { ok: false, problem: 'too-long', length: sanitized.length };
|
||||
}
|
||||
return { ok: true, html: sanitized === '' ? null : sanitized };
|
||||
}
|
||||
@@ -1,7 +1,9 @@
|
||||
import { BusinessException } from '@/common/errors/business.exception';
|
||||
import type { MailFilterRunRequest } from '@/modules/mail/mail.types';
|
||||
import {
|
||||
decodeBlockRunCursor,
|
||||
decodeFilterRunCursor,
|
||||
encodeBlockRunCursor,
|
||||
encodeFilterRunCursor,
|
||||
MAX_FILTER_RUN_SECONDS,
|
||||
type FilterRunPosition,
|
||||
@@ -172,3 +174,62 @@ describe('이미 받은 메일에 필터 적용의 cursor (계약 2절 "이미
|
||||
).toEqual({ before: NINE, anchor: null, anchorTime: null });
|
||||
});
|
||||
});
|
||||
|
||||
describe('차단할 때 받은편지함 정리의 cursor (15단계 — 계약 2절 "보낸 사람 차단")', () => {
|
||||
const position: FilterRunPosition = {
|
||||
before: NINE + 1,
|
||||
anchor: 'seaaaaer',
|
||||
anchorTime: NINE - 60,
|
||||
};
|
||||
|
||||
it('자리는 필터 적용과 같은 모양으로 되돌린다 — 지문은 그 보낸 사람', () => {
|
||||
for (const sender of ['kim@spam.example', '@spam.example']) {
|
||||
expect(
|
||||
decodeBlockRunCursor(encodeBlockRunCursor(position, sender), sender),
|
||||
).toEqual(position);
|
||||
}
|
||||
expect(encodeBlockRunCursor(position, '@spam.example')).toMatch(
|
||||
/^[A-Za-z0-9_-]+$/,
|
||||
);
|
||||
});
|
||||
|
||||
it('다른 보낸 사람의 cursor · 필터 적용의 cursor 는 VAL_001 — 거꾸로 차단 정리의 cursor 로 필터를 이어 하지도 못한다', () => {
|
||||
const cursor = encodeBlockRunCursor(position, 'kim@spam.example');
|
||||
for (const other of ['lee@spam.example', '@spam.example']) {
|
||||
expect(failureOf(() => decodeBlockRunCursor(cursor, other))).toEqual({
|
||||
code: 'VAL_001',
|
||||
reason: 'invalid-mail-block-run-cursor:other-sender',
|
||||
});
|
||||
}
|
||||
expect(
|
||||
failureOf(() =>
|
||||
decodeBlockRunCursor(
|
||||
encodeFilterRunCursor(position, rule),
|
||||
'kim@spam.example',
|
||||
),
|
||||
),
|
||||
).toEqual({
|
||||
code: 'VAL_001',
|
||||
reason: 'invalid-mail-block-run-cursor:other-sender',
|
||||
});
|
||||
expect(failureOf(() => decodeFilterRunCursor(cursor, rule))).toEqual({
|
||||
code: 'VAL_001',
|
||||
reason: 'invalid-filter-run-cursor:other-rule',
|
||||
});
|
||||
});
|
||||
|
||||
it('깨진 cursor 는 필터 적용과 같은 검사로 VAL_001', () => {
|
||||
for (const [cursor, reason] of [
|
||||
['%%%', 'not-json'],
|
||||
[forged({ v: 1, b: NINE }), 'shape'],
|
||||
['a'.repeat(1_025), 'too-long'],
|
||||
] as const) {
|
||||
expect(
|
||||
failureOf(() => decodeBlockRunCursor(cursor, 'kim@spam.example')),
|
||||
).toEqual({
|
||||
code: 'VAL_001',
|
||||
reason: `invalid-mail-block-run-cursor:${reason}`,
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -17,7 +17,10 @@ import type { MailFilterRunRequest } from '@/modules/mail/mail.types';
|
||||
* - anchorTime: 그 메일의 받은 시각(초) — 그 메일이 그사이 받은편지함에서 빠졌으면(`anchorNotFound`) `before: anchorTime + 1초` 의 처음부터 읽는다
|
||||
* (그 초의 메일을 다시 판정한다 — 건너뛰지 않는다). 그렇게 읽은 차례에 남긴 메일이 없으면 anchor 는 없고 anchorTime 만 남는다
|
||||
* - 규칙의 지문: 묶는 방법 · 조건 · 할 일 — 다른 규칙의 cursor 를 섞어 쓰지 못하게 (충돌보다는 "다른 규칙" 감지가 목적이라 짧게 둔다)
|
||||
* 시각은 UTC 의 초(epoch)다 — 해석할 때 범위(0 ~ 9999-12-31)를 본다. 깨졌거나 다른 규칙의 것이면 400 VAL_001 — 엔진에 묻기 전에 본다
|
||||
* 시각은 UTC 의 초(epoch)다 — 해석할 때 범위(0 ~ 9999-12-31)를 본다. 깨졌거나 다른 규칙의 것이면 400 VAL_001 — 엔진에 묻기 전에 본다.
|
||||
*
|
||||
* 차단할 때 받은편지함 정리(15단계 — 계약 2절 "보낸 사람 차단")도 같은 자리 · 같은 모양을 쓴다 — 지문만 그 보낸 사람(차단 목록의 값)이다.
|
||||
* 지문의 재료가 달라(필터는 규칙의 세 칸, 차단은 표시 글 + 보낸 사람) 필터 적용의 cursor 를 차단 정리에 쓰면 다른 주인의 것으로 막힌다
|
||||
*/
|
||||
const CURSOR_VERSION = 2;
|
||||
|
||||
@@ -51,9 +54,20 @@ interface EncodedFilterRunCursor {
|
||||
r: string;
|
||||
}
|
||||
|
||||
/** 차단 정리 cursor 의 지문 재료 앞에 두는 표시 — 필터 규칙의 재료(첫 칸이 묶는 방법)와 섞이지 않게 */
|
||||
const MAIL_BLOCK_SCOPE = 'mail-block';
|
||||
|
||||
/** 정해진 차례의 JSON 을 SHA-256 으로 — 짧게 자른다 */
|
||||
function scopeHash(canonical: unknown[]): string {
|
||||
return createHash('sha256')
|
||||
.update(JSON.stringify(canonical), 'utf8')
|
||||
.digest('base64url')
|
||||
.slice(0, RULE_HASH_LENGTH);
|
||||
}
|
||||
|
||||
/** 규칙의 지문 — 정해진 차례의 JSON 을 SHA-256 으로 (들어온 객체의 키 순서 · 다른 속성과 상관없다) */
|
||||
function ruleHash(rule: FilterRunRule): string {
|
||||
const canonical = JSON.stringify([
|
||||
return scopeHash([
|
||||
rule.match,
|
||||
rule.conditions.map(({ field, operator, value }) => [
|
||||
field,
|
||||
@@ -62,15 +76,19 @@ function ruleHash(rule: FilterRunRule): string {
|
||||
]),
|
||||
[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 {
|
||||
/** 차단 정리의 지문 — 보낸 사람(차단 목록의 값 — 소문자) */
|
||||
function senderHash(sender: string): string {
|
||||
return scopeHash([MAIL_BLOCK_SCOPE, sender]);
|
||||
}
|
||||
|
||||
/** 깨진 cursor 의 로그 까닭 — 필터 적용 · 차단 정리를 가른다 */
|
||||
type CursorKind = 'filter-run' | 'mail-block-run';
|
||||
|
||||
function invalidCursor(kind: CursorKind, reason: string): BusinessException {
|
||||
return new BusinessException('VAL_001', {
|
||||
context: { reason: `invalid-filter-run-cursor:${reason}` },
|
||||
context: { reason: `invalid-${kind}-cursor:${reason}` },
|
||||
});
|
||||
}
|
||||
|
||||
@@ -84,50 +102,85 @@ function isSeconds(value: unknown): value is number {
|
||||
);
|
||||
}
|
||||
|
||||
export function encodeFilterRunCursor(
|
||||
position: FilterRunPosition,
|
||||
rule: FilterRunRule,
|
||||
): string {
|
||||
/** 자리와 지문을 cursor 로 */
|
||||
function encodeRunCursor(position: FilterRunPosition, hash: string): string {
|
||||
const payload: EncodedFilterRunCursor = {
|
||||
v: CURSOR_VERSION,
|
||||
b: position.before,
|
||||
a: position.anchor,
|
||||
t: position.anchorTime,
|
||||
r: ruleHash(rule),
|
||||
r: hash,
|
||||
};
|
||||
return Buffer.from(JSON.stringify(payload)).toString('base64url');
|
||||
}
|
||||
|
||||
/** cursor 를 해석한다 — 형식 · 범위가 틀리거나 지문이 다르면(다른 주인의 cursor) VAL_001 */
|
||||
function decodeRunCursor(
|
||||
cursor: string,
|
||||
hash: string,
|
||||
kind: CursorKind,
|
||||
): FilterRunPosition {
|
||||
if (cursor.length > MAX_MAIL_FILTER_RUN_CURSOR_LENGTH) {
|
||||
throw invalidCursor(kind, 'too-long');
|
||||
}
|
||||
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(Buffer.from(cursor, 'base64url').toString('utf8'));
|
||||
} catch {
|
||||
throw invalidCursor(kind, 'not-json');
|
||||
}
|
||||
|
||||
const { v, b, a, t, r } = (parsed ?? {}) as Partial<EncodedFilterRunCursor>;
|
||||
if (v !== CURSOR_VERSION || !isSeconds(b) || b === 0) {
|
||||
throw invalidCursor(kind, 'shape');
|
||||
}
|
||||
if (!(a === null || (typeof a === 'string' && MAIL_ID_PATTERN.test(a)))) {
|
||||
throw invalidCursor(kind, 'shape');
|
||||
}
|
||||
// anchor 가 있으면 그 받은 시각도 있다 — 위 경계보다 앞선다
|
||||
if (!(t === null || (isSeconds(t) && t < b)) || (a !== null && t === null)) {
|
||||
throw invalidCursor(kind, 'shape');
|
||||
}
|
||||
if (typeof r !== 'string') throw invalidCursor(kind, 'shape');
|
||||
|
||||
if (r !== hash) {
|
||||
throw invalidCursor(
|
||||
kind,
|
||||
kind === 'filter-run' ? 'other-rule' : 'other-sender',
|
||||
);
|
||||
}
|
||||
|
||||
return { before: b, anchor: a, anchorTime: t };
|
||||
}
|
||||
|
||||
export function encodeFilterRunCursor(
|
||||
position: FilterRunPosition,
|
||||
rule: FilterRunRule,
|
||||
): string {
|
||||
return encodeRunCursor(position, ruleHash(rule));
|
||||
}
|
||||
|
||||
/** 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 };
|
||||
return decodeRunCursor(cursor, ruleHash(rule), 'filter-run');
|
||||
}
|
||||
|
||||
/** 차단 정리의 cursor (15단계) — 자리는 필터 적용과 같고 지문은 그 보낸 사람 */
|
||||
export function encodeBlockRunCursor(
|
||||
position: FilterRunPosition,
|
||||
sender: string,
|
||||
): string {
|
||||
return encodeRunCursor(position, senderHash(sender));
|
||||
}
|
||||
|
||||
/** 차단 정리의 cursor 를 해석한다 — 형식 · 범위가 틀리거나 다른 보낸 사람(또는 필터 적용)의 것이면 VAL_001 */
|
||||
export function decodeBlockRunCursor(
|
||||
cursor: string,
|
||||
sender: string,
|
||||
): FilterRunPosition {
|
||||
return decodeRunCursor(cursor, senderHash(sender), 'mail-block-run');
|
||||
}
|
||||
|
||||
@@ -83,6 +83,33 @@ function decodedHeaders(email: JmapEmail, names: readonly string[]): string[] {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 차단할 때 받은편지함 정리(15단계)가 읽는 보낸 사람 — 엔진이 푼 From 머리글의 주소 목록, 같은 이름의 머리글이 여럿이면 모두(머리글마다 배열).
|
||||
* Sieve 의 `address` 판정(jmap/sieve-script.ts 의 blockSection)이 견주는 것과 같다 — 표시 이름에 쓴 주소는 들지 않는다.
|
||||
* 엔진은 물은 이름 그대로 답한다(Stalwart 0.16.27 — 2026-11-09 확인: `[[{ name, email }]]`)
|
||||
*/
|
||||
export const FROM_ADDRESSES_PROPERTY = 'header:From:asAddresses:all';
|
||||
|
||||
/** From 머리글들의 주소 — 물은 이름으로 찾되 대소문자가 달라도 찾는다. 모양이 틀린 칸은 건너뛴다 */
|
||||
export function fromAddressesOf(email: JmapEmail): string[] {
|
||||
const wanted = FROM_ADDRESSES_PROPERTY.toLowerCase();
|
||||
for (const [key, value] of Object.entries(email)) {
|
||||
if (key.toLowerCase() !== wanted || !Array.isArray(value)) continue;
|
||||
return value.flatMap((header: unknown) =>
|
||||
Array.isArray(header)
|
||||
? header.flatMap((address: unknown) => {
|
||||
const found =
|
||||
typeof address === 'object' && address !== null
|
||||
? (address as { email?: unknown }).email
|
||||
: undefined;
|
||||
return typeof found === 'string' ? [found] : [];
|
||||
})
|
||||
: [],
|
||||
);
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
/** 머리글의 견줄 글 — 보낸 사람 · 받는 사람 · 제목 (본문은 bodyTextsOf) */
|
||||
export function headerTextsOf(email: JmapEmail): Omit<MailFilterTexts, 'body'> {
|
||||
const subject = email.subject;
|
||||
|
||||
@@ -11,11 +11,14 @@ import type {
|
||||
import {
|
||||
FILTER_SOURCE_FORMAT,
|
||||
FILTER_SOURCE_FORMAT_WITH_BLOCKS,
|
||||
FILTER_SOURCE_FORMAT_WITH_RICH_VACATION,
|
||||
FILTER_SOURCE_MARKER,
|
||||
LATEST_FILTER_SOURCE_FORMAT,
|
||||
SAME_AS_INTERNAL_REPLY,
|
||||
filterRulesVersion,
|
||||
filterSourceLine,
|
||||
readFilterSource,
|
||||
usesRichVacation,
|
||||
type FilterScriptSource,
|
||||
} from './filter-source';
|
||||
|
||||
@@ -46,6 +49,8 @@ const vacation: MailVacation = {
|
||||
toDate: null,
|
||||
subject: '부재중 ${x} "따옴표"',
|
||||
textBody: '첫 줄\r\n둘째 줄\n\t탭',
|
||||
htmlBody: null,
|
||||
external: SAME_AS_INTERNAL_REPLY,
|
||||
};
|
||||
|
||||
const source: FilterScriptSource = {
|
||||
@@ -186,7 +191,9 @@ describe('filterSourceLine · readFilterSource — 스크립트 머리의 원본
|
||||
});
|
||||
|
||||
it('새 판(이 서버가 아는 가장 새 판보다 큰 정수)은 깨짐과 나눈다 — 나머지 모양은 보지 않는다', () => {
|
||||
expect(LATEST_FILTER_SOURCE_FORMAT).toBe(FILTER_SOURCE_FORMAT_WITH_BLOCKS);
|
||||
expect(LATEST_FILTER_SOURCE_FORMAT).toBe(
|
||||
FILTER_SOURCE_FORMAT_WITH_RICH_VACATION,
|
||||
);
|
||||
expect(
|
||||
readFilterSource(
|
||||
scriptWithJson(stored({ v: LATEST_FILTER_SOURCE_FORMAT + 1 })),
|
||||
@@ -422,6 +429,153 @@ describe('차단 목록(판 2 — 9단계 "보낸 사람 차단")', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('부재중의 서식 본문 · 회사 밖 응답(판 3 — 16단계)', () => {
|
||||
const rich: MailVacation = {
|
||||
...vacation,
|
||||
htmlBody:
|
||||
'<p style="margin:0">11월 7일까지 <strong>휴가</strong>입니다.</p>',
|
||||
external: {
|
||||
mode: 'separate',
|
||||
subject: 'Out of office',
|
||||
textBody: 'I am away until Nov 7.',
|
||||
htmlBody: '<p>I am away until <strong>Nov 7</strong>.</p>',
|
||||
},
|
||||
};
|
||||
const richSource: FilterScriptSource = { ...source, vacation: rich };
|
||||
const formatOf = (value: FilterScriptSource) =>
|
||||
(
|
||||
JSON.parse(
|
||||
filterSourceLine(value).slice(FILTER_SOURCE_MARKER.length),
|
||||
) as Record<string, unknown>
|
||||
).v;
|
||||
const storedV3 = (overrides: Record<string, unknown> = {}) =>
|
||||
stored({
|
||||
v: FILTER_SOURCE_FORMAT_WITH_RICH_VACATION,
|
||||
vacation: rich,
|
||||
blocked: [],
|
||||
...overrides,
|
||||
});
|
||||
|
||||
it('새 칸을 쓴 직원만 판 3 — 서식 본문 · 사내와 다른 회사 밖 응답 · 써 둔 회사 밖 글 가운데 하나라도. 아니면 판 1 · 2 그대로', () => {
|
||||
expect(LATEST_FILTER_SOURCE_FORMAT).toBe(
|
||||
FILTER_SOURCE_FORMAT_WITH_RICH_VACATION,
|
||||
);
|
||||
expect(formatOf(source)).toBe(FILTER_SOURCE_FORMAT);
|
||||
expect(usesRichVacation(vacation)).toBe(false);
|
||||
|
||||
for (const changed of [
|
||||
{ ...vacation, htmlBody: '<p>휴가</p>' },
|
||||
{ ...vacation, external: { ...SAME_AS_INTERNAL_REPLY, mode: 'none' } },
|
||||
{
|
||||
...vacation,
|
||||
external: { ...SAME_AS_INTERNAL_REPLY, mode: 'separate' },
|
||||
},
|
||||
// 사내와 같은 응답이어도 써 둔 회사 밖 글이 있으면 남겨야 한다
|
||||
{
|
||||
...vacation,
|
||||
external: { ...SAME_AS_INTERNAL_REPLY, textBody: '써 둔 글' },
|
||||
},
|
||||
] as MailVacation[]) {
|
||||
expect(usesRichVacation(changed)).toBe(true);
|
||||
expect(formatOf({ ...source, vacation: changed })).toBe(
|
||||
FILTER_SOURCE_FORMAT_WITH_RICH_VACATION,
|
||||
);
|
||||
}
|
||||
// 판 3 은 차단 목록 칸을 늘 담는다(비었어도) — 차단이 있으면 그대로
|
||||
const v3 = JSON.parse(
|
||||
filterSourceLine(richSource).slice(FILTER_SOURCE_MARKER.length),
|
||||
) as Record<string, unknown>;
|
||||
expect(v3.blocked).toEqual([]);
|
||||
expect(v3.vacation).toEqual(rich);
|
||||
});
|
||||
|
||||
it('판 3 을 그대로 읽는다 — 차단 목록도. 판 1 · 2 는 글만 · 사내와 같은 응답으로', () => {
|
||||
const blocked: BlockedSender[] = [
|
||||
{ address: 'spam@example.net', blockedAt: '2026-10-29T01:02:03.000Z' },
|
||||
];
|
||||
for (const value of [richSource, { ...richSource, blocked }]) {
|
||||
expect(readFilterSource(scriptWith(filterSourceLine(value)))).toEqual({
|
||||
status: 'ok',
|
||||
source: value,
|
||||
});
|
||||
}
|
||||
// 판 1 · 2 에 새 칸이 적혀 있어도 보지 않는다 — 그 판을 쓰는 서버는 그 칸을 모른다
|
||||
for (const v of [FILTER_SOURCE_FORMAT, FILTER_SOURCE_FORMAT_WITH_BLOCKS]) {
|
||||
expect(
|
||||
sourceOf(scriptWithJson(stored({ v, vacation: rich, blocked: [] })))
|
||||
?.vacation,
|
||||
).toEqual({
|
||||
...vacation,
|
||||
htmlBody: null,
|
||||
external: SAME_AS_INTERNAL_REPLY,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it('판 3 의 서식 본문은 읽을 때 보내는 정화를 다시 거친다 — 다른 프로그램이 고친 원본의 스크립트 · 이벤트 속성 · 그림은 화면에 가지 않는다', () => {
|
||||
const read = sourceOf(
|
||||
scriptWithJson(
|
||||
storedV3({
|
||||
vacation: {
|
||||
...rich,
|
||||
htmlBody:
|
||||
'<p onclick="x()">휴가<script>alert(1)</script></p><img src="https://evil.example/x.png">',
|
||||
external: {
|
||||
...rich.external,
|
||||
htmlBody: '<a href="javascript:x()">링크</a><b>굵게</b>',
|
||||
},
|
||||
},
|
||||
}),
|
||||
),
|
||||
);
|
||||
expect(read?.vacation.htmlBody).toBe('<p>휴가</p>');
|
||||
expect(read?.vacation.external.htmlBody).toBe(
|
||||
'<span>링크</span><b>굵게</b>',
|
||||
);
|
||||
// 정화해서 비면 null — 글만
|
||||
expect(
|
||||
sourceOf(
|
||||
scriptWithJson(
|
||||
storedV3({ vacation: { ...rich, htmlBody: '<script>x()</script>' } }),
|
||||
),
|
||||
)?.vacation.htmlBody,
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it.each<[string, Record<string, unknown>]>([
|
||||
['회사 밖 응답 없음', { ...rich, external: undefined }],
|
||||
['회사 밖 응답이 객체가 아님', { ...rich, external: 'same' }],
|
||||
[
|
||||
'모르는 고르기',
|
||||
{ ...rich, external: { ...rich.external, mode: 'internal' } },
|
||||
],
|
||||
[
|
||||
'회사 밖 제목이 글이 아님',
|
||||
{ ...rich, external: { ...rich.external, subject: 1 } },
|
||||
],
|
||||
[
|
||||
'회사 밖 글 칸 없음',
|
||||
{ ...rich, external: { mode: 'same', subject: null, htmlBody: null } },
|
||||
],
|
||||
['서식 본문이 글이 아님', { ...rich, htmlBody: 1 }],
|
||||
['서식 본문 칸 없음', { ...rich, htmlBody: undefined }],
|
||||
[
|
||||
'정화할 수 없는 서식(깊은 중첩)',
|
||||
{ ...rich, htmlBody: '<b>'.repeat(166_666) },
|
||||
],
|
||||
])('판 3 의 부재중이 계약에 맞지 않으면 깨진 원본 — %s', (_label, value) => {
|
||||
expect(
|
||||
readFilterSource(scriptWithJson(storedV3({ vacation: value }))),
|
||||
).toEqual(BROKEN);
|
||||
});
|
||||
|
||||
it('판 3 의 차단 목록은 판 2 와 같은 규칙 — 칸이 없으면 깨진 원본', () => {
|
||||
expect(
|
||||
readFilterSource(scriptWithJson(storedV3({ blocked: undefined }))),
|
||||
).toEqual(BROKEN);
|
||||
});
|
||||
});
|
||||
|
||||
describe('filterRulesVersion — 규칙의 지문(version)', () => {
|
||||
const rules = source.rules;
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { createHash } from 'crypto';
|
||||
|
||||
import { sanitizeStoredHtml } from '@/modules/mail/html/sanitize-stored-html';
|
||||
import { parseBlockTarget } from '@/modules/mail/mail-blocks';
|
||||
import {
|
||||
MAIL_FILTER_FIELDS,
|
||||
@@ -8,6 +9,7 @@ import {
|
||||
findFilterRulesProblem,
|
||||
} from '@/modules/mail/mail-filters';
|
||||
import {
|
||||
MAIL_VACATION_EXTERNAL_MODES,
|
||||
MAX_MAIL_BLOCKS,
|
||||
MAX_MAIL_FILTER_RULES,
|
||||
} from '@/modules/mail/mail.constants';
|
||||
@@ -17,6 +19,7 @@ import type {
|
||||
MailFilterCondition,
|
||||
MailFilterRule,
|
||||
MailVacation,
|
||||
MailVacationExternal,
|
||||
} from '@/modules/mail/mail.types';
|
||||
import { toUtcIso } from './jmap.mapper';
|
||||
|
||||
@@ -43,12 +46,19 @@ export const FILTER_SOURCE_MARKER = '# arasystem-source: ';
|
||||
* - 2: 1 + 차단 목록 `blocked` (9단계 "보낸 사람 차단") — **차단한 주소가 있을 때만** 쓴다. 판 2 를 아는 서버는 판 1 을 "차단 없음"으로 읽는다.
|
||||
* 판 2 를 모르는 서버로 되돌리면 차단한 주소가 있는 직원만 필터 · 부재중 저장이 503 MAIL_001 로 막힌다(계약 2절 — 되돌리기 계획:
|
||||
* 판 2 를 읽기만 하는 서버를 먼저 배포할 수 있게 둔다)
|
||||
* - 3: 2 + 부재중의 서식 본문 `vacation.htmlBody` · 회사 밖 응답 `vacation.external` (16단계) — **새 칸을 쓴 직원만** 쓴다
|
||||
* (usesRichVacation — 서식 본문이 있거나 · 회사 밖 응답이 사내와 같지 않거나 · 회사 밖 글을 써 둔 경우). 판 3 은 `blocked` 를 늘 담는다(비었어도).
|
||||
* 판 3 을 아는 서버는 판 1 · 2 를 "글만 · 사내와 같은 응답"으로 읽는다. 판 3 을 모르는 서버로 되돌리면 새 칸을 쓴 직원만
|
||||
* 필터 · 부재중 · 차단 저장이 503 MAIL_001 로 막힌다 — 엔진의 응답(Sieve)은 그대로 돈다(계약 2절 "부재중 자동 응답"의 되돌리기 계획)
|
||||
*/
|
||||
export const FILTER_SOURCE_FORMAT = 1;
|
||||
/** 차단 목록이 든 원본의 판 (9단계) */
|
||||
export const FILTER_SOURCE_FORMAT_WITH_BLOCKS = 2;
|
||||
/** 부재중의 서식 본문 · 회사 밖 응답이 든 원본의 판 (16단계) */
|
||||
export const FILTER_SOURCE_FORMAT_WITH_RICH_VACATION = 3;
|
||||
/** 이 서버가 아는 가장 새 판 — 이보다 크면 newer */
|
||||
export const LATEST_FILTER_SOURCE_FORMAT = FILTER_SOURCE_FORMAT_WITH_BLOCKS;
|
||||
export const LATEST_FILTER_SOURCE_FORMAT =
|
||||
FILTER_SOURCE_FORMAT_WITH_RICH_VACATION;
|
||||
|
||||
/**
|
||||
* 스크립트 글에서 원본을 읽은 결과
|
||||
@@ -69,6 +79,14 @@ export interface FilterScriptSource {
|
||||
readonly blocked: readonly BlockedSender[];
|
||||
}
|
||||
|
||||
/** 회사 밖 응답의 처음 값 — 사내와 같은 응답, 따로 써 둔 글 없음 (16단계 — 판 1 · 2 의 원본 · 새 칸이 없는 옛 요청도 이것) */
|
||||
export const SAME_AS_INTERNAL_REPLY: MailVacationExternal = {
|
||||
mode: 'same',
|
||||
subject: null,
|
||||
textBody: null,
|
||||
htmlBody: null,
|
||||
};
|
||||
|
||||
/** 한 번도 설정하지 않은 부재중 자동 응답 (계약 2절 "부재중 자동 응답") */
|
||||
export const NEVER_CONFIGURED_VACATION: MailVacation = {
|
||||
isEnabled: false,
|
||||
@@ -76,8 +94,25 @@ export const NEVER_CONFIGURED_VACATION: MailVacation = {
|
||||
toDate: null,
|
||||
subject: '',
|
||||
textBody: '',
|
||||
htmlBody: null,
|
||||
external: SAME_AS_INTERNAL_REPLY,
|
||||
};
|
||||
|
||||
/**
|
||||
* 16단계의 새 칸을 쓰는가 — 서식 본문이 있거나 · 회사 밖 응답이 사내와 같지 않거나 · 회사 밖 글을 써 둔 경우. 그때만 원본 판 3 으로 쓴다
|
||||
* (새 칸을 쓰지 않은 직원은 판 1 · 2 그대로 — 판 3 을 모르는 서버로 되돌려도 막히지 않는다)
|
||||
*/
|
||||
export function usesRichVacation(vacation: MailVacation): boolean {
|
||||
const { external } = vacation;
|
||||
return (
|
||||
vacation.htmlBody !== null ||
|
||||
external.mode !== 'same' ||
|
||||
external.subject !== null ||
|
||||
external.textBody !== null ||
|
||||
external.htmlBody !== null
|
||||
);
|
||||
}
|
||||
|
||||
/** 원본을 읽지 못할 때(깨짐 · 새 판) 보이는 것 — 규칙 없음 · 부재중 꺼짐 · 차단 없음 (깨진 원본은 저장하면 덮어쓴다 · 새 판은 쓰지 않는다) */
|
||||
export const EMPTY_FILTER_SOURCE: FilterScriptSource = {
|
||||
rules: [],
|
||||
@@ -102,7 +137,8 @@ function canonicalRule(rule: MailFilterRule): MailFilterRule {
|
||||
};
|
||||
}
|
||||
|
||||
function canonicalVacation(vacation: MailVacation): MailVacation {
|
||||
/** 판 1 · 2 의 부재중 — 다섯 칸 */
|
||||
function canonicalVacation(vacation: MailVacation) {
|
||||
return {
|
||||
isEnabled: vacation.isEnabled,
|
||||
fromDate: vacation.fromDate,
|
||||
@@ -112,6 +148,16 @@ function canonicalVacation(vacation: MailVacation): MailVacation {
|
||||
};
|
||||
}
|
||||
|
||||
/** 판 3 의 부재중 — 다섯 칸 + 서식 본문 · 회사 밖 응답 */
|
||||
function canonicalRichVacation(vacation: MailVacation): MailVacation {
|
||||
const { mode, subject, textBody, htmlBody } = vacation.external;
|
||||
return {
|
||||
...canonicalVacation(vacation),
|
||||
htmlBody: vacation.htmlBody,
|
||||
external: { mode, subject, textBody, htmlBody },
|
||||
};
|
||||
}
|
||||
|
||||
/** 같은 규칙이면 같은 사본 목록 — 저장 · 지문 · 응답이 이것을 쓴다 */
|
||||
export function canonicalRules(
|
||||
rules: readonly MailFilterRule[],
|
||||
@@ -130,24 +176,28 @@ export function filterRulesVersion(rules: readonly MailFilterRule[]): string {
|
||||
}
|
||||
|
||||
/**
|
||||
* 원본 줄 — 표지 + JSON 한 줄 (판 · 규칙 · 부재중 · 차단). 차단한 주소가 없으면 판 1 그대로(blocked 없이) — 판 2 를 모르는 서버도 읽는다
|
||||
* 원본 줄 — 표지 + JSON 한 줄 (판 · 규칙 · 부재중 · 차단). 가장 낮은 판으로 쓴다 — 부재중의 새 칸(16단계)을 쓰면 판 3,
|
||||
* 아니고 차단한 주소가 있으면 판 2, 둘 다 아니면 판 1(blocked 없이) — 낮은 판만 아는 서버도 읽는다
|
||||
*/
|
||||
export function filterSourceLine(source: FilterScriptSource): string {
|
||||
const base = {
|
||||
rules: canonicalRules(source.rules),
|
||||
vacation: canonicalVacation(source.vacation),
|
||||
};
|
||||
const rules = canonicalRules(source.rules);
|
||||
const blocked = source.blocked.map(({ address, blockedAt }) => ({
|
||||
address,
|
||||
blockedAt,
|
||||
}));
|
||||
if (usesRichVacation(source.vacation)) {
|
||||
return `${FILTER_SOURCE_MARKER}${JSON.stringify({
|
||||
v: FILTER_SOURCE_FORMAT_WITH_RICH_VACATION,
|
||||
rules,
|
||||
vacation: canonicalRichVacation(source.vacation),
|
||||
blocked,
|
||||
})}`;
|
||||
}
|
||||
const base = { rules, vacation: canonicalVacation(source.vacation) };
|
||||
return `${FILTER_SOURCE_MARKER}${JSON.stringify(
|
||||
source.blocked.length === 0
|
||||
blocked.length === 0
|
||||
? { v: FILTER_SOURCE_FORMAT, ...base }
|
||||
: {
|
||||
v: FILTER_SOURCE_FORMAT_WITH_BLOCKS,
|
||||
...base,
|
||||
blocked: source.blocked.map(({ address, blockedAt }) => ({
|
||||
address,
|
||||
blockedAt,
|
||||
})),
|
||||
},
|
||||
: { v: FILTER_SOURCE_FORMAT_WITH_BLOCKS, ...base, blocked },
|
||||
)}`;
|
||||
}
|
||||
|
||||
@@ -252,8 +302,9 @@ function readDate(raw: unknown): string | null | undefined {
|
||||
}
|
||||
|
||||
/**
|
||||
* 부재중 — 모양만 본다. 길이 한도는 보지 않는다: 저장해 둔 값은 4단계 요청 검증 · 엔진의 VacationResponse 한도 안에서 온 것이고
|
||||
* (VacationResponse 에서 가져온 값은 우리 한도보다 길 수 있다), Sieve 에는 base64 로 들어가 글이 문법을 깨지 않는다
|
||||
* 부재중(판 1 · 2) — 모양만 본다. 길이 한도는 보지 않는다: 저장해 둔 값은 4단계 요청 검증 · 엔진의 VacationResponse 한도 안에서 온 것이고
|
||||
* (VacationResponse 에서 가져온 값은 우리 한도보다 길 수 있다), Sieve 에는 base64 로 들어가 글이 문법을 깨지 않는다.
|
||||
* 16단계의 칸은 처음 값이다 — 글만 · 사내와 같은 응답
|
||||
*/
|
||||
function toVacation(raw: unknown): MailVacation | null {
|
||||
if (
|
||||
@@ -273,9 +324,52 @@ function toVacation(raw: unknown): MailVacation | null {
|
||||
toDate,
|
||||
subject: raw.subject,
|
||||
textBody: raw.textBody,
|
||||
htmlBody: null,
|
||||
external: SAME_AS_INTERNAL_REPLY,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 원본의 서식 본문 — null 이면 null, 글이면 보내는 정화(그림 없음)를 **다시** 거친 값(비면 null), 정화할 수 없는 글(길이 · 파서 비용)이면 undefined(깨짐).
|
||||
* 다시 정화하는 까닭: 다른 메일 프로그램(ManageSieve 등)이 고친 원본의 HTML 이 화면 · 받는 사람에게 그대로 가지 않게 — 우리가 쓴 값은 이미 정화한 값이라 그대로다
|
||||
*/
|
||||
function readVacationHtml(raw: unknown): string | null | undefined {
|
||||
if (raw === null) return null;
|
||||
if (typeof raw !== 'string') return undefined;
|
||||
const sanitized = sanitizeStoredHtml(raw, { maxLength: null });
|
||||
return sanitized.ok ? sanitized.html : undefined;
|
||||
}
|
||||
|
||||
/** 회사 밖 응답(판 3) — 고르기는 셋 가운데 하나, 글 칸은 글 또는 null. 어긋나면 null(깨짐) */
|
||||
function toVacationExternal(raw: unknown): MailVacationExternal | null {
|
||||
if (
|
||||
!isRecord(raw) ||
|
||||
!isOneOf(raw.mode, MAIL_VACATION_EXTERNAL_MODES) ||
|
||||
!isNullableString(raw.subject) ||
|
||||
!isNullableString(raw.textBody)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
const htmlBody = readVacationHtml(raw.htmlBody);
|
||||
if (htmlBody === undefined) return null;
|
||||
return {
|
||||
mode: raw.mode,
|
||||
subject: raw.subject,
|
||||
textBody: raw.textBody,
|
||||
htmlBody,
|
||||
};
|
||||
}
|
||||
|
||||
/** 부재중(판 3) — 판 1 · 2 의 다섯 칸 + 서식 본문 · 회사 밖 응답. 어긋나면 null(깨짐) */
|
||||
function toRichVacation(raw: unknown): MailVacation | null {
|
||||
const base = toVacation(raw);
|
||||
if (base === null || !isRecord(raw)) return null;
|
||||
const htmlBody = readVacationHtml(raw.htmlBody);
|
||||
const external = toVacationExternal(raw.external);
|
||||
if (htmlBody === undefined || external === null) return null;
|
||||
return { ...base, htmlBody, external };
|
||||
}
|
||||
|
||||
/**
|
||||
* 차단 목록 (판 2) — 500개까지 · 주소는 차단 대상 모양(소문자 — mail-blocks.ts)이고 겹치지 않음 · 차단한 시각은 읽을 수 있는 시각.
|
||||
* 어긋나면 null(깨짐) — 다른 메일 프로그램이 고친 원본으로 Sieve 를 만들면 엔진의 변수 펼침 · 한도에 걸릴 수 있다
|
||||
@@ -313,8 +407,9 @@ const BROKEN: FilterSourceReading = { status: 'broken' };
|
||||
/**
|
||||
* 스크립트 글에서 원본을 읽는다 (FilterSourceReading).
|
||||
* 판(`v`)이 이 서버가 아는 가장 새 판보다 큰 정수면 나머지를 보지 않고 newer 다 — 모양이 달라졌을 수 있다.
|
||||
* 아는 판이면 모양과 규칙을 계약대로 다시 본다(mail-filters.ts — 50개 · 겹친 id · 조건 수 · `${` 등, 판 2 는 차단 목록도) — 어긋나면 broken.
|
||||
* 판 1 은 차단 없음이다. 다시 보는 까닭: 다른 메일 프로그램(ManageSieve 등)이 고친 원본으로 Sieve 를 다시 만들면 엔진 한도 · 변수 펼침에 걸릴 수 있다
|
||||
* 아는 판이면 모양과 규칙을 계약대로 다시 본다(mail-filters.ts — 50개 · 겹친 id · 조건 수 · `${` 등, 판 2 · 3 은 차단 목록도,
|
||||
* 판 3 은 부재중의 서식 본문(다시 정화) · 회사 밖 응답도) — 어긋나면 broken.
|
||||
* 판 1 은 차단 없음, 판 1 · 2 는 부재중이 글만 · 사내와 같은 응답이다. 다시 보는 까닭: 다른 메일 프로그램(ManageSieve 등)이 고친 원본으로 Sieve 를 다시 만들면 엔진 한도 · 변수 펼침에 걸릴 수 있다
|
||||
*/
|
||||
export function readFilterSource(script: string): FilterSourceReading {
|
||||
const line = findSourceLine(script);
|
||||
@@ -337,7 +432,8 @@ export function readFilterSource(script: string): FilterSourceReading {
|
||||
}
|
||||
if (
|
||||
(format !== FILTER_SOURCE_FORMAT &&
|
||||
format !== FILTER_SOURCE_FORMAT_WITH_BLOCKS) ||
|
||||
format !== FILTER_SOURCE_FORMAT_WITH_BLOCKS &&
|
||||
format !== FILTER_SOURCE_FORMAT_WITH_RICH_VACATION) ||
|
||||
!Array.isArray(parsed.rules) ||
|
||||
parsed.rules.length > MAX_MAIL_FILTER_RULES
|
||||
) {
|
||||
@@ -353,12 +449,13 @@ export function readFilterSource(script: string): FilterSourceReading {
|
||||
if (findFilterRulesProblem(rules)) return BROKEN;
|
||||
|
||||
const blocked =
|
||||
format === FILTER_SOURCE_FORMAT_WITH_BLOCKS
|
||||
? toBlocked(parsed.blocked)
|
||||
: [];
|
||||
format === FILTER_SOURCE_FORMAT ? [] : toBlocked(parsed.blocked);
|
||||
if (blocked === null) return BROKEN;
|
||||
|
||||
const vacation = toVacation(parsed.vacation);
|
||||
const vacation =
|
||||
format === FILTER_SOURCE_FORMAT_WITH_RICH_VACATION
|
||||
? toRichVacation(parsed.vacation)
|
||||
: toVacation(parsed.vacation);
|
||||
return vacation
|
||||
? { status: 'ok', source: { rules, vacation, blocked } }
|
||||
: BROKEN;
|
||||
|
||||
@@ -190,6 +190,81 @@ describe('JmapAccountSecurityProvider (x:AccountPassword · x:AppPassword —
|
||||
expect(failure?.code).toBe('MAIL_001');
|
||||
expect(JSON.stringify(failure?.context)).not.toContain('secret details');
|
||||
});
|
||||
|
||||
it('휴대폰 바꾸기(16단계) — 켠 채 currentSecret + otpAuth {otpCode: 지금 코드, otpUrl: 새 주소} 한 요청, updated → replaced · forbidden → code-rejected · 결과를 모르면 unknown', async () => {
|
||||
callAccountChange.mockResolvedValueOnce(
|
||||
answered([
|
||||
'x:AccountPassword/set',
|
||||
{ updated: { singleton: null } },
|
||||
's',
|
||||
]),
|
||||
);
|
||||
await expect(
|
||||
provider.replaceTwoFactor(ARA, 'pw', '123456', OTPAUTH),
|
||||
).resolves.toBe('replaced');
|
||||
expect(lastCalls(callAccountChange)).toEqual([
|
||||
[
|
||||
'x:AccountPassword/set',
|
||||
{
|
||||
accountId: 'x1',
|
||||
update: {
|
||||
singleton: {
|
||||
currentSecret: 'pw',
|
||||
otpAuth: { otpCode: '123456', otpUrl: OTPAUTH },
|
||||
},
|
||||
},
|
||||
},
|
||||
's',
|
||||
],
|
||||
]);
|
||||
|
||||
callAccountChange.mockResolvedValueOnce(
|
||||
answered([
|
||||
'x:AccountPassword/set',
|
||||
{
|
||||
notUpdated: {
|
||||
singleton: {
|
||||
type: 'forbidden',
|
||||
description: 'Current secret is incorrect.',
|
||||
},
|
||||
},
|
||||
},
|
||||
's',
|
||||
]),
|
||||
);
|
||||
await expect(
|
||||
provider.replaceTwoFactor(ARA, 'pw', '000000', OTPAUTH),
|
||||
).resolves.toBe('code-rejected');
|
||||
|
||||
const warn = jest.spyOn(Logger.prototype, 'warn');
|
||||
callAccountChange.mockResolvedValueOnce({
|
||||
kind: 'unknown',
|
||||
detail: 'timeout',
|
||||
});
|
||||
await expect(
|
||||
provider.replaceTwoFactor(ARA, 'pw', '123456', OTPAUTH),
|
||||
).resolves.toBe('unknown');
|
||||
const logged = warn.mock.calls
|
||||
.map(([message]) => String(message))
|
||||
.join('\n');
|
||||
expect(logged).toContain('가를 수 없어 MAIL_001');
|
||||
expect(logged).not.toMatch(/123456|otpauth|pw\b/);
|
||||
|
||||
callAccountChange.mockResolvedValueOnce(
|
||||
answered([
|
||||
'x:AccountPassword/set',
|
||||
{ notUpdated: { singleton: { type: 'serverFail' } } },
|
||||
's',
|
||||
]),
|
||||
);
|
||||
expect(
|
||||
(await failureOf(provider.replaceTwoFactor(ARA, 'pw', '1', OTPAUTH)))
|
||||
?.context,
|
||||
).toMatchObject({
|
||||
reason: 'two-factor-replace-refused',
|
||||
jmapType: 'serverFail',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('앱 비밀번호', () => {
|
||||
|
||||
@@ -8,6 +8,7 @@ import type {
|
||||
CreatedAppPassword,
|
||||
TwoFactorDisableResult,
|
||||
TwoFactorEnableResult,
|
||||
TwoFactorReplaceResult,
|
||||
} from '@/modules/account-security/account-security.provider';
|
||||
import { traceTag } from '@/shared/logger/request-context';
|
||||
import { maskEmail } from '@/shared/utils/mask';
|
||||
@@ -59,6 +60,8 @@ function toIso(value: unknown): string | null {
|
||||
* - 켜기: `x:AccountPassword/set` 에 currentSecret + otpAuth.otpUrl — 엔진은 코드를 보지 않고 저장한다. 지금 비밀번호가 틀리거나
|
||||
* 이미 켜져 있으면(지금 코드를 요구) forbidden
|
||||
* - 끄기: currentSecret + otpAuth {otpCode, otpUrl: null} — 코드 틀림도 비밀번호 틀림과 같은 forbidden "Current secret is incorrect."
|
||||
* - 휴대폰 바꾸기(16단계): 켠 채 currentSecret + otpAuth {otpCode: 지금 코드, otpUrl: 새 주소} 한 요청 → updated(옛 비밀은 바로 죽는다).
|
||||
* 코드 없음 · 틀린 코드 · 틀린 비밀번호 모두 forbidden (2026-11-10 개발 엔진 확인)
|
||||
* - 앱 비밀번호: 값은 엔진이 만들어 만들 때 한 번만 준다(우리가 넣으면 거절), 상한(기본 5)을 넘으면 overQuota, 지운 번호를 다시 쓴다
|
||||
* 엔진 오류의 설명 문장 · 비밀번호 · 코드 · otpauth 주소 · 앱 비밀번호 값 · 이름은 로그 · 오류 context 에 남기지 않는다 (type 만)
|
||||
*/
|
||||
@@ -139,6 +142,33 @@ export class JmapAccountSecurityProvider implements AccountSecurityProvider {
|
||||
return outcome === 'forbidden' ? 'code-rejected' : 'unknown';
|
||||
}
|
||||
|
||||
async replaceTwoFactor(
|
||||
user: string,
|
||||
currentPassword: string,
|
||||
otpCode: string,
|
||||
otpauthUrl: string,
|
||||
): Promise<TwoFactorReplaceResult> {
|
||||
// 켠 채 지금 코드와 새 주소를 한 요청으로 — 엔진이 지금 코드를 확인하고 새 비밀로 바꾼다(옛 비밀은 바로 죽는다 — 2026-11-10 확인)
|
||||
const reply = await this.jmap.callAccountChange(user, (accountId) => [
|
||||
[
|
||||
PASSWORD_SET,
|
||||
{
|
||||
accountId,
|
||||
update: {
|
||||
[PASSWORD_OBJECT_ID]: {
|
||||
currentSecret: currentPassword,
|
||||
otpAuth: { otpCode, otpUrl: otpauthUrl },
|
||||
},
|
||||
},
|
||||
},
|
||||
's',
|
||||
],
|
||||
]);
|
||||
const outcome = this.passwordUpdate(user, reply, 'two-factor-replace');
|
||||
if (outcome === 'updated') return 'replaced';
|
||||
return outcome === 'forbidden' ? 'code-rejected' : 'unknown';
|
||||
}
|
||||
|
||||
async listAppPasswords(user: string): Promise<AppPasswordRecord[]> {
|
||||
const responses = await this.jmap.callAccount(user, (accountId) => [
|
||||
[
|
||||
@@ -251,7 +281,7 @@ export class JmapAccountSecurityProvider implements AccountSecurityProvider {
|
||||
): 'updated' | 'forbidden' | 'unknown' {
|
||||
if (reply.kind === 'unknown') {
|
||||
this.logger.warn(
|
||||
`[MAIL_001]${traceTag()} 2단계 인증 바꾸기의 결과를 알 수 없습니다 — 엔진을 다시 읽어 맞춥니다: user=${maskEmail(user)}, call=${call}, 원인=${reply.detail}`,
|
||||
`[MAIL_001]${traceTag()} 2단계 인증 바꾸기의 결과를 알 수 없습니다 — ${this.unknownFollowUp(call)}: user=${maskEmail(user)}, call=${call}, 원인=${reply.detail}`,
|
||||
);
|
||||
return 'unknown';
|
||||
}
|
||||
@@ -275,7 +305,7 @@ export class JmapAccountSecurityProvider implements AccountSecurityProvider {
|
||||
if (failure?.type === 'forbidden') return 'forbidden';
|
||||
if (!failure) {
|
||||
this.logger.warn(
|
||||
`[MAIL_001]${traceTag()} 2단계 인증 바꾸기 응답이 결과를 말하지 않습니다 — 엔진을 다시 읽어 맞춥니다: user=${maskEmail(user)}, call=${call}`,
|
||||
`[MAIL_001]${traceTag()} 2단계 인증 바꾸기 응답이 결과를 말하지 않습니다 — ${this.unknownFollowUp(call)}: user=${maskEmail(user)}, call=${call}`,
|
||||
);
|
||||
return 'unknown';
|
||||
}
|
||||
@@ -285,6 +315,13 @@ export class JmapAccountSecurityProvider implements AccountSecurityProvider {
|
||||
});
|
||||
}
|
||||
|
||||
/** 결과를 모를 때 부르는 쪽이 하는 일 — 켜기 · 끄기는 다시 읽어 맞추고, 휴대폰 바꾸기는 가릴 수 없어 MAIL_001 로 답한다 */
|
||||
private unknownFollowUp(call: string): string {
|
||||
return call === 'two-factor-replace'
|
||||
? '엔진이 otpUrl 을 가려 다시 읽어도 가를 수 없어 MAIL_001 로 답합니다'
|
||||
: '엔진을 다시 읽어 맞춥니다';
|
||||
}
|
||||
|
||||
/** 만들기 거절 → 코드 (상한 AUTH_009, 그 밖은 MAIL_001 — type 만 남긴다) */
|
||||
private createRefusal(error: JmapError): BusinessException {
|
||||
if (error.type === 'overQuota') {
|
||||
|
||||
@@ -674,6 +674,147 @@ describe('JmapContactsProvider (엔진 주소록)', () => {
|
||||
expect(logged).not.toMatch(/박지훈|jihoon|Invalid email|ara\.kim@/);
|
||||
});
|
||||
|
||||
it('가져오기(allowPartialCreate) — 받지 않은 만들기는 던지지 않고 refusedKeys 로, 받은 것만 createdIds · 카드 모양 (warn 로그는 개수 · 종류만)', async () => {
|
||||
engine.setReply = (args) => {
|
||||
const create = args.create as Record<string, Record<string, unknown>>;
|
||||
expect(Object.keys(create)).toEqual(['import-0', 'import-1']);
|
||||
return [
|
||||
'ContactCard/set',
|
||||
{
|
||||
newState: 's11',
|
||||
created: { 'import-0': { id: 'n7' } },
|
||||
notCreated: {
|
||||
'import-1': {
|
||||
type: 'invalidProperties',
|
||||
description: 'Invalid 박지훈 jihoon@x',
|
||||
},
|
||||
},
|
||||
updated: { g: null },
|
||||
},
|
||||
'w',
|
||||
];
|
||||
};
|
||||
|
||||
const outcome = await provider.writeAddressBook(
|
||||
ARA,
|
||||
version('book', 's10'),
|
||||
{
|
||||
create: [
|
||||
{
|
||||
kind: 'contact',
|
||||
key: 'import-0',
|
||||
uid: 'urn:uuid:a',
|
||||
fields: { ...newPerson, name: '받음' },
|
||||
},
|
||||
{
|
||||
kind: 'contact',
|
||||
key: 'import-1',
|
||||
uid: 'urn:uuid:b',
|
||||
fields: { ...newPerson, name: '박지훈' },
|
||||
},
|
||||
],
|
||||
update: [
|
||||
{
|
||||
kind: 'group',
|
||||
id: 'g',
|
||||
memberUids: ['urn:uuid:a', 'urn:uuid:b'],
|
||||
},
|
||||
],
|
||||
destroy: [],
|
||||
},
|
||||
{ allowPartialCreate: true },
|
||||
);
|
||||
|
||||
if (outcome.kind !== 'written') throw new Error('written 이 아니다');
|
||||
expect(outcome.createdIds).toEqual(new Map([['import-0', 'n7']]));
|
||||
expect(outcome.refusedKeys).toEqual(new Set(['import-1']));
|
||||
expect([...outcome.cards.keys()]).toEqual(['n7']);
|
||||
expect(outcome.version).toBe(version('book', 's11'));
|
||||
const logged = warnSpy.mock.calls
|
||||
.map(([message]) => String(message))
|
||||
.join('\n');
|
||||
expect(logged).toContain('받지 않음 1/2');
|
||||
expect(logged).toContain('jmapType=invalidProperties');
|
||||
expect(logged).not.toMatch(/박지훈|jihoon|Invalid|ara\.kim@/);
|
||||
});
|
||||
|
||||
it('가져오기라도 고치기 · 지우기의 거절은 지금처럼 던진다 · 모두 받으면 refusedKeys 는 비어 있다', async () => {
|
||||
engine.setReply = () => [
|
||||
'ContactCard/set',
|
||||
{
|
||||
newState: 's11',
|
||||
created: { 'import-0': { id: 'n7' } },
|
||||
notUpdated: { g: { type: 'invalidProperties' } },
|
||||
},
|
||||
'w',
|
||||
];
|
||||
const changes: AddressBookChanges = {
|
||||
create: [
|
||||
{
|
||||
kind: 'contact',
|
||||
key: 'import-0',
|
||||
uid: 'urn:uuid:a',
|
||||
fields: newPerson,
|
||||
},
|
||||
],
|
||||
update: [{ kind: 'group', id: 'g', memberUids: ['urn:uuid:a'] }],
|
||||
destroy: [],
|
||||
};
|
||||
const error = await failureOf(
|
||||
provider.writeAddressBook(ARA, version('book', 's10'), changes, {
|
||||
allowPartialCreate: true,
|
||||
}),
|
||||
);
|
||||
expect(error.code).toBe('VAL_001');
|
||||
|
||||
const outcome = await provider.writeAddressBook(
|
||||
ARA,
|
||||
version('book', 's10'),
|
||||
{ ...changes, update: [] },
|
||||
{ allowPartialCreate: true },
|
||||
);
|
||||
if (outcome.kind !== 'written') throw new Error('written 이 아니다');
|
||||
expect(outcome.refusedKeys).toEqual(new Set());
|
||||
expect(outcome.createdIds).toEqual(new Map([['import-0', 'n1']]));
|
||||
});
|
||||
|
||||
it('한꺼번에 지우기(withoutOriginals) — 지울 카드는 id 만 읽어 있는지 보고, 원본은 주지 않는다', async () => {
|
||||
engine.add({
|
||||
id: 'c1',
|
||||
uid: 'urn:uuid:c1',
|
||||
name: { full: '하나' },
|
||||
photos: { p: { href: 'https://x.example/p.jpg' } },
|
||||
} as JmapContactCard);
|
||||
|
||||
const outcome = await provider.writeAddressBook(
|
||||
ARA,
|
||||
version('book', 's10'),
|
||||
{ ...noChanges, destroy: ['c1'] },
|
||||
{ withoutOriginals: true },
|
||||
);
|
||||
|
||||
const [read] = engine.requests;
|
||||
expect(read?.calls).toEqual([
|
||||
[
|
||||
'ContactCard/get',
|
||||
{ accountId: 'acc', ids: ['c1'], properties: ['id'] },
|
||||
'd',
|
||||
],
|
||||
]);
|
||||
if (outcome.kind !== 'written') throw new Error('written 이 아니다');
|
||||
expect(outcome.removed.size).toBe(0);
|
||||
expect(engine.cardOf('c1')).toBeUndefined();
|
||||
|
||||
// 그 판에 없는 카드면 conflict (보내지 않는다)
|
||||
const missing = await provider.writeAddressBook(
|
||||
ARA,
|
||||
version('book', 's11'),
|
||||
{ ...noChanges, destroy: ['c1'] },
|
||||
{ withoutOriginals: true },
|
||||
);
|
||||
expect(missing).toEqual({ kind: 'conflict' });
|
||||
});
|
||||
|
||||
it('되돌리기 — 원본에서 id 만 빼고 uid · 기본 주소록을 바꿔 만든다. 엔진이 받지 않으면 MAIL_001(입력 오류가 아니다)', async () => {
|
||||
const original = {
|
||||
source: JSON.stringify({
|
||||
|
||||
@@ -7,6 +7,7 @@ import type {
|
||||
AddressBookContactCard,
|
||||
AddressBookGroupCard,
|
||||
AddressBookSnapshot,
|
||||
AddressBookWriteOptions,
|
||||
AddressBookWriteOutcome,
|
||||
ContactsProvider,
|
||||
RemovedCardOriginal,
|
||||
@@ -49,6 +50,9 @@ const READ_PAGE_SIZE = 500;
|
||||
*/
|
||||
export const ADDRESS_BOOK_READ_MAX_CARDS = 5_000;
|
||||
|
||||
/** 원본이 필요 없는 지우기(한꺼번에 지우기 — 16단계)에서 지울 카드가 있는지만 볼 때 읽는 칸 (id 는 늘 온다) */
|
||||
const EXISTENCE_PROPERTIES = ['id'];
|
||||
|
||||
/** 카드에서 읽는 칸 — 사진 등 큰 칸은 읽지 않는다 */
|
||||
const CARD_PROPERTIES = [
|
||||
'uid',
|
||||
@@ -118,6 +122,8 @@ function pickAddressBook(books: readonly JmapAddressBook[]): string | null {
|
||||
* 만들기 · 고치기 · 지우기를 담아 읽은 상태 그대로일 때만(ifInState) 한다. 어긋나면 stateMismatch → conflict, 동시 고치기 거절은 busy.
|
||||
* 엔진은 만든 뒤 uid 를 바꾸지 못하게 하고(invalidProperties), 구성원이 없는 그룹에 `members/<uid>` 패치를 받지 않는다 — 그래서 uid 는 만들 때
|
||||
* 넣고, 구성원은 통째로 바꾼다 (2026-10-29 확인)
|
||||
* - 가져오기(16단계 — allowPartialCreate): 받지 않은 만들기는 던지지 않고 refusedKeys 로 알린다(받은 것은 들어갔다). 한 요청에 300명 90ms(2026-11-10 확인),
|
||||
* 엔진의 한 요청 상한(maxObjectsInSet 500)은 서비스가 맞춘다. 한꺼번에 지우기(withoutOriginals)는 지울 카드를 id 만 읽는다
|
||||
* - 이름 · 주소 · 메모 등 카드 내용은 로그에 남기지 않는다 (개수 · 엔진 오류 종류만)
|
||||
*/
|
||||
@Injectable()
|
||||
@@ -211,6 +217,7 @@ export class JmapContactsProvider implements ContactsProvider {
|
||||
user: string,
|
||||
version: string,
|
||||
changes: AddressBookChanges,
|
||||
options: AddressBookWriteOptions = {},
|
||||
): Promise<AddressBookWriteOutcome> {
|
||||
const { addressBookId, state } = decodeVersion(version);
|
||||
if (changes.create.length > 0 && addressBookId === null) {
|
||||
@@ -230,6 +237,7 @@ export class JmapContactsProvider implements ContactsProvider {
|
||||
change.kind === 'contact' ? [change.id] : [],
|
||||
),
|
||||
changes.destroy,
|
||||
options.withoutOriginals === true,
|
||||
);
|
||||
if (current === null) return { kind: 'conflict' };
|
||||
const removed = current.originals;
|
||||
@@ -287,8 +295,14 @@ export class JmapContactsProvider implements ContactsProvider {
|
||||
this.warnBusy(user, 'set-error');
|
||||
return { kind: 'busy', removed };
|
||||
}
|
||||
// 가져오기(16단계) — 받지 않은 만들기는 던지지 않고 적는다 (고치기 · 지우기의 거절은 아래에서 지금처럼 던진다)
|
||||
const refusedKeys = options.allowPartialCreate
|
||||
? this.refusedCreates(user, set, changes)
|
||||
: new Set<string>();
|
||||
const refusal =
|
||||
firstSetError(set.notCreated) ??
|
||||
(options.allowPartialCreate
|
||||
? undefined
|
||||
: firstSetError(set.notCreated)) ??
|
||||
firstSetError(set.notUpdated) ??
|
||||
firstSetError(set.notDestroyed);
|
||||
if (refusal) {
|
||||
@@ -318,6 +332,7 @@ export class JmapContactsProvider implements ContactsProvider {
|
||||
|
||||
const createdIds = new Map<string, string>();
|
||||
for (const card of changes.create) {
|
||||
if (refusedKeys.has(card.key)) continue;
|
||||
const id = set.created?.[card.key]?.id;
|
||||
if (typeof id !== 'string' || id === '') {
|
||||
throw new BusinessException('MAIL_001', {
|
||||
@@ -337,11 +352,35 @@ export class JmapContactsProvider implements ContactsProvider {
|
||||
createdIds,
|
||||
cards,
|
||||
removed,
|
||||
...(options.allowPartialCreate ? { refusedKeys } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 엔진이 받지 않은 만들기의 key (가져오기 — 16단계). 우리가 보낸 key 만 센다. 있으면 warn 로그 — 개수 · 엔진 오류 종류만
|
||||
* (카드 내용 · 엔진 설명 문장은 남기지 않는다)
|
||||
*/
|
||||
private refusedCreates(
|
||||
user: string,
|
||||
set: JmapSetResult,
|
||||
changes: AddressBookChanges,
|
||||
): Set<string> {
|
||||
const sent = new Set(changes.create.map((card) => card.key));
|
||||
const refused = new Set(
|
||||
Object.keys(set.notCreated ?? {}).filter((key) => sent.has(key)),
|
||||
);
|
||||
const first = firstSetError(set.notCreated);
|
||||
if (refused.size > 0) {
|
||||
this.logger.warn(
|
||||
`[${mapJmapErrorType(first?.type)}]${traceTag()} 엔진이 가져오기의 일부 연락처를 받지 않았습니다 — 받은 것만 넣었습니다: user=${maskEmail(user)}, jmapType=${first?.type ?? 'unknown'}, 받지 않음 ${refused.size}/${changes.create.length}`,
|
||||
);
|
||||
}
|
||||
return refused;
|
||||
}
|
||||
|
||||
/**
|
||||
* 고칠 연락처(우리가 다루는 칸)와 지울 카드(카드 전체 — 되돌리기 원본)를 그 판(state)에서 읽는다 — 엔진 요청 하나.
|
||||
* withoutOriginals 면 지울 카드는 있는지만 본다(id 만 — 사진이 든 카드 500장을 통째로 읽지 않게, 원본은 주지 않는다).
|
||||
* 판이 달라졌거나 카드가 없으면 null (그사이 다른 곳에서 바뀌었다 — 서비스가 다시 읽고 다시 정한다)
|
||||
*/
|
||||
private async readCurrentCards(
|
||||
@@ -349,6 +388,7 @@ export class JmapContactsProvider implements ContactsProvider {
|
||||
state: string,
|
||||
updateIds: readonly string[],
|
||||
destroyIds: readonly string[],
|
||||
withoutOriginals: boolean,
|
||||
): Promise<{
|
||||
cards: ReadonlyMap<string, Record<string, unknown>>;
|
||||
originals: ReadonlyMap<string, RemovedCardOriginal>;
|
||||
@@ -370,8 +410,12 @@ export class JmapContactsProvider implements ContactsProvider {
|
||||
? [
|
||||
[
|
||||
'ContactCard/get',
|
||||
// 되돌리기 원본 — 다른 프로그램의 칸까지 모두
|
||||
{ accountId, ids: [...destroyIds], properties: null },
|
||||
// 되돌리기 원본 — 다른 프로그램의 칸까지 모두 (원본이 필요 없으면 있는지만)
|
||||
{
|
||||
accountId,
|
||||
ids: [...destroyIds],
|
||||
properties: withoutOriginals ? EXISTENCE_PROPERTIES : null,
|
||||
},
|
||||
'd',
|
||||
] as JmapInvocation,
|
||||
]
|
||||
@@ -396,9 +440,11 @@ export class JmapContactsProvider implements ContactsProvider {
|
||||
if (updating === null || destroying === null) return null;
|
||||
return {
|
||||
cards: updating,
|
||||
originals: new Map(
|
||||
[...destroying].map(([id, card]) => [id, cardOriginal(card)]),
|
||||
),
|
||||
originals: withoutOriginals
|
||||
? new Map()
|
||||
: new Map(
|
||||
[...destroying].map(([id, card]) => [id, cardOriginal(card)]),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ import type {
|
||||
} from '@/modules/mail/mail.types';
|
||||
import {
|
||||
decodeFilterRunCursor,
|
||||
encodeBlockRunCursor,
|
||||
encodeFilterRunCursor,
|
||||
} from './filter-run-cursor';
|
||||
import type { JmapClient } from './jmap.client';
|
||||
@@ -1180,6 +1181,278 @@ describe('JmapFilterRunner — 이미 받은 메일에 필터 적용 (계약 2
|
||||
).toMatchObject({ code: 'MAIL_001', reason: 'timeout' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('차단할 때 받은편지함 정리 (runBlock — 15단계)', () => {
|
||||
const JUNK = 'k';
|
||||
const BLOCK_MAILBOXES: readonly Mailbox[] = [
|
||||
mailbox(INBOX, 'inbox', 3),
|
||||
mailbox(TARGET, null),
|
||||
mailbox(JUNK, 'junk'),
|
||||
];
|
||||
const blockContext = (
|
||||
overrides: Partial<MailFilterRunContext> = {},
|
||||
): MailFilterRunContext => ({
|
||||
mailboxes: BLOCK_MAILBOXES,
|
||||
startedAt: Date.now(),
|
||||
...overrides,
|
||||
});
|
||||
/** 받은편지함의 메일 — 엔진이 푼 From 머리글의 주소(머리글마다 배열) */
|
||||
const fromMail = (
|
||||
id: string,
|
||||
...headers: { name: string | null; email: string }[][]
|
||||
) => ({
|
||||
id,
|
||||
mailboxIds: { [INBOX]: true },
|
||||
receivedAt: utc(T0),
|
||||
'header:From:asAddresses:all': headers,
|
||||
});
|
||||
const KIM = { name: null, email: 'kim@spam.example' };
|
||||
const junkPatch = {
|
||||
[`mailboxIds/${INBOX}`]: null,
|
||||
[`mailboxIds/${JUNK}`]: true,
|
||||
'keywords/$junk': true,
|
||||
'keywords/$notjunk': null,
|
||||
};
|
||||
|
||||
it('받은편지함을 받은 시각 내림차순으로 읽어 엔진이 푼 From 주소로 판정하고, 맞은 메일을 스팸메일함으로 옮기며 스팸 표시 — 옮기기는 읽은 상태 그대로일 때만', async () => {
|
||||
const { runner, recorded } = fakeRunner([
|
||||
page(
|
||||
['m1', 'm2', 'm3'],
|
||||
[
|
||||
fromMail('m1', [KIM]),
|
||||
fromMail('m2', [{ name: null, email: 'lee@spam.example' }]),
|
||||
fromMail('m3', [{ name: 'Kim', email: 'KIM@Spam.Example' }]),
|
||||
],
|
||||
),
|
||||
updated(['m1', 'm3']),
|
||||
]);
|
||||
|
||||
await expect(
|
||||
runner.runBlock(
|
||||
ARA,
|
||||
{ sender: 'kim@spam.example', cursor: null },
|
||||
blockContext(),
|
||||
),
|
||||
).resolves.toEqual({
|
||||
scanned: 3,
|
||||
matched: 2,
|
||||
remaining: 0,
|
||||
cursor: null,
|
||||
rounds: 1,
|
||||
});
|
||||
expect(argsOf(recorded[0])).toMatchObject({
|
||||
filter: { inMailbox: INBOX },
|
||||
sort: [{ property: 'receivedAt', isAscending: false }],
|
||||
limit: 500,
|
||||
calculateTotal: true,
|
||||
});
|
||||
expect(argsOf(recorded[0], 1)).toMatchObject({
|
||||
properties: [
|
||||
'id',
|
||||
'mailboxIds',
|
||||
'receivedAt',
|
||||
'header:From:asAddresses:all',
|
||||
],
|
||||
});
|
||||
// 붙잡힌 메일을 같은 요청에서 묻는다 (9단계 — 옮기지 않는다)
|
||||
expect(methodsOf([recorded[0]])).toEqual([
|
||||
'Email/query',
|
||||
'Email/get',
|
||||
'EmailSubmission/query',
|
||||
'EmailSubmission/get',
|
||||
]);
|
||||
expect(argsOf(recorded[1])).toEqual({
|
||||
accountId: 'd',
|
||||
ifInState: 's1',
|
||||
update: { m1: junkPatch, m3: junkPatch },
|
||||
});
|
||||
});
|
||||
|
||||
it('판정은 차단 규칙(Sieve 의 address)과 같다 — 주소 전체가 같을 때 · From 이 여럿이면 하나라도 · 표시 이름의 주소 · 비슷한 주소는 아니다 (대소문자는 ASCII 만)', async () => {
|
||||
const cases: [string, ReturnType<typeof fromMail>][] = [
|
||||
['맞음', fromMail('m1', [KIM])],
|
||||
[
|
||||
'맞음',
|
||||
fromMail('m2', [
|
||||
{ name: 'First', email: 'first@example.net' },
|
||||
{ name: 'Kim', email: 'kim@spam.example' },
|
||||
]),
|
||||
],
|
||||
[
|
||||
'맞음',
|
||||
fromMail('m3', [{ name: null, email: 'first@example.net' }], [KIM]),
|
||||
],
|
||||
[
|
||||
'아님',
|
||||
fromMail('m4', [
|
||||
{ name: 'kim@spam.example', email: 'other@example.net' },
|
||||
]),
|
||||
],
|
||||
[
|
||||
'아님',
|
||||
fromMail('m5', [{ name: null, email: 'kim.lee@spam.example' }]),
|
||||
],
|
||||
[
|
||||
'아님',
|
||||
fromMail('m6', [{ name: null, email: 'kim@sub.spam.example' }]),
|
||||
],
|
||||
[
|
||||
'아님',
|
||||
fromMail('m7', [{ name: null, email: 'akim@spam.example' }]),
|
||||
],
|
||||
['아님', fromMail('m8')],
|
||||
];
|
||||
const { runner, recorded } = fakeRunner([
|
||||
page(
|
||||
cases.map(([, mail]) => mail.id),
|
||||
cases.map(([, mail]) => mail),
|
||||
),
|
||||
updated(['m1', 'm2', 'm3']),
|
||||
]);
|
||||
|
||||
await runner.runBlock(
|
||||
ARA,
|
||||
{ sender: 'kim@spam.example', cursor: null },
|
||||
blockContext(),
|
||||
);
|
||||
expect(Object.keys(argsOf(recorded[1]).update as object)).toEqual(
|
||||
cases
|
||||
.filter(([verdict]) => verdict === '맞음')
|
||||
.map(([, mail]) => mail.id),
|
||||
);
|
||||
});
|
||||
|
||||
it('도메인 차단(@도메인)은 From 주소의 @ 뒤가 그 도메인일 때만 — 하위 도메인 · 더 긴 도메인 · 표시 이름은 아니다', async () => {
|
||||
const mails = [
|
||||
fromMail('m1', [{ name: null, email: 'kim@spam.example' }]),
|
||||
fromMail('m2', [{ name: null, email: 'Lee@SPAM.example' }]),
|
||||
fromMail('m3', [{ name: null, email: 'kim@sub.spam.example' }]),
|
||||
fromMail('m4', [{ name: null, email: 'kim@spam.example.net' }]),
|
||||
fromMail('m5', [
|
||||
{ name: '@spam.example', email: 'kim@other.example' },
|
||||
]),
|
||||
];
|
||||
const { runner, recorded } = fakeRunner([
|
||||
page(
|
||||
mails.map(({ id }) => id),
|
||||
mails,
|
||||
),
|
||||
updated(['m1', 'm2']),
|
||||
]);
|
||||
|
||||
await runner.runBlock(
|
||||
ARA,
|
||||
{ sender: '@spam.example', cursor: null },
|
||||
blockContext(),
|
||||
);
|
||||
expect(Object.keys(argsOf(recorded[1]).update as object)).toEqual([
|
||||
'm1',
|
||||
'm2',
|
||||
]);
|
||||
});
|
||||
|
||||
it('붙잡힌 메일(9단계)은 맞아도 옮기지 않는다 — 받은편지함에 남긴 것으로 세고 matched 에 넣지 않는다', async () => {
|
||||
const later = utc(Math.floor(Date.now() / 1_000) + 3_600);
|
||||
const { runner, recorded } = fakeRunner([
|
||||
page(['m1', 'm2'], [fromMail('m1', [KIM]), fromMail('m2', [KIM])], {
|
||||
held: [
|
||||
{
|
||||
id: 'sub1',
|
||||
emailId: 'm2',
|
||||
undoStatus: 'pending',
|
||||
sendAt: later,
|
||||
},
|
||||
],
|
||||
}),
|
||||
updated(['m1']),
|
||||
]);
|
||||
|
||||
await expect(
|
||||
runner.runBlock(
|
||||
ARA,
|
||||
{ sender: 'kim@spam.example', cursor: null },
|
||||
blockContext(),
|
||||
),
|
||||
).resolves.toMatchObject({ scanned: 2, matched: 1 });
|
||||
expect(argsOf(recorded[1])).toMatchObject({
|
||||
update: { m1: junkPatch },
|
||||
});
|
||||
expect(argsOf(recorded[1]).update).not.toHaveProperty('m2');
|
||||
});
|
||||
|
||||
it('스팸메일함이 없으면 엔진에 묻지 않고 MAIL_001 · 받은편지함이 없으면 할 메일이 없다', async () => {
|
||||
const noJunk = fakeRunner([]);
|
||||
expect(
|
||||
await failureOf(
|
||||
noJunk.runner.runBlock(
|
||||
ARA,
|
||||
{ sender: 'kim@spam.example', cursor: null },
|
||||
blockContext({
|
||||
mailboxes: BLOCK_MAILBOXES.filter(
|
||||
({ role }) => role !== 'junk',
|
||||
),
|
||||
}),
|
||||
),
|
||||
),
|
||||
).toMatchObject({ code: 'MAIL_001', reason: 'junk-mailbox-missing' });
|
||||
expect(noJunk.recorded).toHaveLength(0);
|
||||
|
||||
const noInbox = fakeRunner([]);
|
||||
await expect(
|
||||
noInbox.runner.runBlock(
|
||||
ARA,
|
||||
{ sender: 'kim@spam.example', cursor: null },
|
||||
blockContext({
|
||||
mailboxes: BLOCK_MAILBOXES.filter(({ role }) => role !== 'inbox'),
|
||||
}),
|
||||
),
|
||||
).resolves.toEqual({
|
||||
scanned: 0,
|
||||
matched: 0,
|
||||
remaining: 0,
|
||||
cursor: null,
|
||||
rounds: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it('이어 할 자리 — 다른 보낸 사람의 cursor · 필터 적용의 cursor · 깨진 cursor 는 엔진에 묻기 전에 VAL_001', async () => {
|
||||
const position = { before: T0 + 1, anchor: 'm1', anchorTime: T0 };
|
||||
const others = [
|
||||
encodeBlockRunCursor(position, 'lee@spam.example'),
|
||||
encodeFilterRunCursor(position, request()),
|
||||
'not-a-cursor',
|
||||
];
|
||||
for (const cursor of others) {
|
||||
const fake = fakeRunner([]);
|
||||
expect(
|
||||
await failureOf(
|
||||
fake.runner.runBlock(
|
||||
ARA,
|
||||
{ sender: 'kim@spam.example', cursor },
|
||||
blockContext(),
|
||||
),
|
||||
),
|
||||
).toMatchObject({ code: 'VAL_001' });
|
||||
expect(fake.recorded).toHaveLength(0);
|
||||
}
|
||||
|
||||
// 같은 보낸 사람의 cursor 는 그 자리부터 — anchor 다음 · 위 경계 미만
|
||||
const fake = fakeRunner([page([], [])]);
|
||||
await fake.runner.runBlock(
|
||||
ARA,
|
||||
{
|
||||
sender: 'kim@spam.example',
|
||||
cursor: encodeBlockRunCursor(position, 'kim@spam.example'),
|
||||
},
|
||||
blockContext(),
|
||||
);
|
||||
expect(argsOf(fake.recorded[0])).toMatchObject({
|
||||
filter: { inMailbox: INBOX, before: utc(T0 + 1) },
|
||||
anchor: 'm1',
|
||||
anchorOffset: 1,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
@@ -1369,6 +1642,10 @@ describe('JmapFilterRunner — 이미 받은 메일에 필터 적용 (계약 2
|
||||
),
|
||||
receivedAt: utc(email.receivedAt),
|
||||
'header:From:all': [` ${email.from}`],
|
||||
// 엔진이 푼 주소 — 꺾쇠 안 (차단 정리 — 15단계)
|
||||
'header:From:asAddresses:all': [
|
||||
[{ name: null, email: /<([^>]+)>/.exec(email.from)?.[1] ?? '' }],
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1706,5 +1983,68 @@ describe('JmapFilterRunner — 이미 받은 메일에 필터 적용 (계약 2
|
||||
}
|
||||
expect(engine.sorted(TARGET)).toHaveLength(700 - kept.length);
|
||||
});
|
||||
|
||||
it('차단 정리(15단계)도 같은 틀로 끝까지 — 그 보낸 사람의 메일만 스팸메일함으로(스팸 표시) · 요청 사이에 온 새 메일은 건드리지 않는다', async () => {
|
||||
const JUNK = 'k';
|
||||
const engine = new FakeEngine();
|
||||
fill(engine, 1_200);
|
||||
let now = 1_000_000;
|
||||
jest.spyOn(Date, 'now').mockImplementation(() => now);
|
||||
const client = {
|
||||
call: jest.fn(
|
||||
(_user: string, build: (accountId: string) => JmapInvocation[]) => {
|
||||
const calls = build('d');
|
||||
if (calls[0][0] === 'Email/query') now += 21_000;
|
||||
return Promise.resolve(engine.handle(calls));
|
||||
},
|
||||
),
|
||||
};
|
||||
const runner = new JmapFilterRunner(client as unknown as JmapClient);
|
||||
const sender = 'sales@hanbit-partners.co.kr';
|
||||
const wanted = engine
|
||||
.sorted(INBOX)
|
||||
.filter(({ from }) => from.includes(sender)).length;
|
||||
|
||||
let cursor: string | null = null;
|
||||
let requests = 0;
|
||||
let matched = 0;
|
||||
do {
|
||||
// 요청 사이에 그 보낸 사람의 새 메일이 온다 — 시작한 뒤 온 메일은 보지 않는다 (새 메일은 Sieve 가 이미 판정했다)
|
||||
if (cursor !== null) engine.add(T0 + 10_000 + requests, HANBIT_FROM);
|
||||
const result = await runner.runBlock(
|
||||
ARA,
|
||||
{ sender, cursor },
|
||||
{
|
||||
mailboxes: [
|
||||
mailbox(INBOX, 'inbox', engine.sorted(INBOX).length),
|
||||
mailbox(JUNK, 'junk'),
|
||||
],
|
||||
startedAt: now,
|
||||
},
|
||||
);
|
||||
matched += result.matched;
|
||||
cursor = result.cursor;
|
||||
requests += 1;
|
||||
now += 100_000;
|
||||
} while (cursor !== null && requests < 100);
|
||||
|
||||
expect(cursor).toBeNull();
|
||||
expect(requests).toBeGreaterThan(1);
|
||||
expect(matched).toBe(wanted);
|
||||
const moved = engine.sorted(JUNK);
|
||||
expect(moved).toHaveLength(wanted);
|
||||
for (const email of moved) {
|
||||
expect(email.keywords.has('$junk')).toBe(true);
|
||||
expect(email.receivedAt).toBeLessThan(T0 + 10_000);
|
||||
}
|
||||
// 받은편지함에 남은 그 보낸 사람의 메일은 요청 사이에 온 새 메일뿐
|
||||
const left = engine
|
||||
.sorted(INBOX)
|
||||
.filter(({ from }) => from.includes(sender));
|
||||
expect(left).toHaveLength(requests - 1);
|
||||
expect(left.every(({ receivedAt }) => receivedAt >= T0 + 10_000)).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
|
||||
import { BusinessException } from '@/common/errors/business.exception';
|
||||
import { isBlockedSenderMail } from '@/modules/mail/mail-block-match';
|
||||
import {
|
||||
filterFields,
|
||||
judgeFilter,
|
||||
@@ -8,6 +9,7 @@ import {
|
||||
} from '@/modules/mail/mail-filter-match';
|
||||
import type {
|
||||
Mailbox,
|
||||
MailBlockRunRequest,
|
||||
MailFilterRunContext,
|
||||
MailFilterRunOutcome,
|
||||
MailFilterRunRequest,
|
||||
@@ -15,15 +17,20 @@ import type {
|
||||
import { BulkRun } from './bulk-run';
|
||||
import {
|
||||
MAX_FILTER_RUN_SECONDS,
|
||||
decodeBlockRunCursor,
|
||||
decodeFilterRunCursor,
|
||||
encodeBlockRunCursor,
|
||||
encodeFilterRunCursor,
|
||||
type FilterRunPosition,
|
||||
} from './filter-run-cursor';
|
||||
import {
|
||||
FILTER_RUN_BODY_PROPERTIES,
|
||||
FILTER_RUN_PART_PROPERTIES,
|
||||
FROM_ADDRESSES_PROPERTY,
|
||||
bodyTextsOf,
|
||||
estimatedBodyBytes,
|
||||
filterRunProperties,
|
||||
fromAddressesOf,
|
||||
headerTextsOf,
|
||||
textPartsOf,
|
||||
} from './filter-run-texts';
|
||||
@@ -97,6 +104,32 @@ const HELD_GUARD_CALLS = 'h';
|
||||
/** 붙잡힌 메일이 없다 — 옮기지 않는 규칙 · 읽은 메일이 없는 쪽 */
|
||||
const NO_HELD: HeldEmails = { ids: new Set(), complete: true };
|
||||
|
||||
/** 처음부터 — 위 경계 · 남긴 메일 · 그 받은 시각을 아직 모른다 (첫 차례가 위 경계를 잡는다) */
|
||||
const FROM_TOP: RunState = { before: null, anchor: null, anchorTime: null };
|
||||
|
||||
/** 바꾼 것이 없다 — 맞은 메일이 없거나 모두 바꿀 것이 없는 차례 */
|
||||
const NOTHING_APPLIED: Applied = { updated: new Set(), gone: new Set() };
|
||||
|
||||
/**
|
||||
* 차단 정리(15단계)가 차례마다 읽는 속성 — 받은편지함에 아직 있는지 · 받은 시각(이어 할 자리) · 엔진이 푼 From 머리글의 주소(filter-run-texts.ts).
|
||||
* 표시는 읽지 않는다 — 맞은 메일은 늘 옮기고 스팸 표시를 붙인다(이미 붙어 있어도 같은 패치)
|
||||
*/
|
||||
const BLOCK_RUN_PROPERTIES = [
|
||||
'id',
|
||||
'mailboxIds',
|
||||
'receivedAt',
|
||||
FROM_ADDRESSES_PROPERTY,
|
||||
];
|
||||
|
||||
/** 받은편지함이 없는 계정 — 할 메일이 없다 (필터 적용 · 차단 정리 모두) */
|
||||
const NOTHING_TO_RUN: MailFilterRunOutcome = {
|
||||
scanned: 0,
|
||||
matched: 0,
|
||||
remaining: 0,
|
||||
cursor: null,
|
||||
rounds: 0,
|
||||
};
|
||||
|
||||
/**
|
||||
* 한 통의 본문을 부분마다 읽는 몫 (바이트) — 한 부분은 FILTER_RUN_BODY_VALUE_BYTES 까지, 한 통은 모두 합쳐 FILTER_RUN_MESSAGE_BODY_BYTES 까지.
|
||||
* 글자 부분이 많으면 같은 몫씩 나눈다 (적어도 1바이트). 엔진의 maxBodyValueBytes 는 요청 단위라 몫이 다른 메일은 따로 읽는다
|
||||
@@ -185,6 +218,37 @@ interface BodyBatch {
|
||||
readonly ids: readonly string[];
|
||||
}
|
||||
|
||||
/** 한 차례를 판정하고 할 일을 한다 — 필터 적용(applyRound) · 차단 정리(applyBlockRound). 멈춰야 하면 null */
|
||||
type RoundRunner = (page: RoundPage) => Promise<RoundResult | null>;
|
||||
|
||||
/** 받은편지함을 차례로 훑은 결과 (sweep) */
|
||||
interface SweepResult {
|
||||
readonly scanned: number;
|
||||
readonly matched: number;
|
||||
readonly remaining: number;
|
||||
/** 끝까지 했다 — 이어 할 자리가 없다 */
|
||||
readonly finished: boolean;
|
||||
/** 마지막으로 마친 차례까지의 자리 */
|
||||
readonly position: RunState;
|
||||
readonly rounds: number;
|
||||
}
|
||||
|
||||
/** 판정 — true 맞음 · false 안 맞음 · 'gone' 받은편지함에서 빠진 메일(건너뛰고 세지 않는다) */
|
||||
type Verdict = boolean | 'gone';
|
||||
|
||||
/**
|
||||
* 차단 정리(15단계)의 패치 — 받은편지함에서 빼고 스팸메일함에 넣고 스팸 표시(`$junk` 를 붙이고 `$notjunk` 를 뗀다 — 스팸 신고 `isJunk: true` 와 같다,
|
||||
* jmap-mail.provider.ts 의 toEmailPatch)
|
||||
*/
|
||||
function blockPatch(inboxId: string, junkId: string): Record<string, unknown> {
|
||||
return {
|
||||
[`mailboxIds/${inboxId}`]: null,
|
||||
[`mailboxIds/${junkId}`]: true,
|
||||
'keywords/$junk': true,
|
||||
'keywords/$notjunk': null,
|
||||
};
|
||||
}
|
||||
|
||||
/** 옮길 메일함 — 규칙에 없거나 · 받은편지함이거나 · 메일함 목록에 없으면(지워짐) 옮기지 않는다 (계약 — 표시는 한다) */
|
||||
function runPlan(
|
||||
request: MailFilterRunRequest,
|
||||
@@ -242,6 +306,41 @@ function utcDate(seconds: number): string {
|
||||
return toJmapUtcDate(new Date(seconds * MS_PER_SECOND).toISOString());
|
||||
}
|
||||
|
||||
/**
|
||||
* 한 차례를 마친 결과 — 판정한 수(받은편지함에서 빠진 메일은 빼고)와 받은편지함에 남긴 마지막 메일(다음 차례의 anchor).
|
||||
* 옮긴 메일(moving 가운데 바뀐 것)과 그사이 없어진 메일은 남긴 것이 아니다
|
||||
* @param moving 옮기기가 든 패치를 보낸 메일 — 바뀌면 받은편지함을 떠난다
|
||||
* @param matched 이 차례에서 할 일을 한 수 (필터 적용은 이미 표시가 있던 메일 포함 · 차단 정리는 옮긴 수)
|
||||
*/
|
||||
function roundResult(
|
||||
page: RoundPage,
|
||||
verdicts: ReadonlyMap<string, Verdict>,
|
||||
moving: ReadonlySet<string>,
|
||||
applied: Applied,
|
||||
matched: number,
|
||||
): RoundResult {
|
||||
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 = moving.has(id) && applied.updated.has(id);
|
||||
if (!moved && !applied.gone.has(id)) lastStayed = id;
|
||||
}
|
||||
return {
|
||||
judged,
|
||||
matched,
|
||||
lastStayed:
|
||||
lastStayed === null
|
||||
? null
|
||||
: {
|
||||
id: lastStayed,
|
||||
time: receivedSeconds(page.emails.get(lastStayed)),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** 차례에서 가장 최근의 받은 시각(초) — 받은편지함 맨 위 메일 (읽은 메일이 없으면 null) */
|
||||
function newestSecond(page: RoundPage): number | null {
|
||||
let newest: number | null = null;
|
||||
@@ -305,6 +404,9 @@ function bodyBatches(emails: readonly JmapEmail[]): BodyBatch[] {
|
||||
* 진행(processed)은 판정한 메일 수다 — 판정한 것이 없는 차례는 멈춘 차례로 센다
|
||||
* - 이어 할 자리는 cursor — 한 차례를 다 마친 곳까지만 (도중에 멈춘 차례는 다음 요청이 다시 한다 — 할 일은 다시 해도 같다)
|
||||
* - 받은편지함 · 옮길 메일함은 서비스가 읽은 메일함 목록(context.mailboxes)에서 찾는다 — 다시 읽지 않는다
|
||||
*
|
||||
* 차단할 때 받은편지함 정리(runBlock — 15단계, 계약 2절 "보낸 사람 차단")도 같은 틀이다 — 차례 · 위 경계 · 자리 · 시간 · 멈춤 · 옮기기의 조건(ifInState) ·
|
||||
* 붙잡힌 메일 막기는 그대로(sweep · readPage · applyPatches), 판정만 차단 규칙(Sieve 의 `address` — mail-block-match.ts)이고 할 일은 스팸메일함으로 옮기기 + 스팸 표시다
|
||||
*/
|
||||
@Injectable()
|
||||
export class JmapFilterRunner {
|
||||
@@ -318,17 +420,15 @@ export class JmapFilterRunner {
|
||||
context: MailFilterRunContext,
|
||||
): Promise<MailFilterRunOutcome> {
|
||||
// 이어 할 자리는 엔진에 묻기 전에 본다 — 다른 규칙 · 깨진 cursor 면 VAL_001
|
||||
let position: RunState =
|
||||
const start: RunState =
|
||||
request.cursor === null
|
||||
? { before: null, anchor: null, anchorTime: null }
|
||||
? FROM_TOP
|
||||
: 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 };
|
||||
}
|
||||
if (!inbox) return NOTHING_TO_RUN;
|
||||
|
||||
const plan = runPlan(request, inbox.id, context.mailboxes);
|
||||
const fields = filterFields(request);
|
||||
@@ -341,6 +441,78 @@ export class JmapFilterRunner {
|
||||
withHeld: plan.moveTo !== null,
|
||||
};
|
||||
|
||||
const swept = await this.sweep(user, inbox, start, read, run, (page) =>
|
||||
this.applyRound(user, request, plan, page, run),
|
||||
);
|
||||
if (!swept.finished) run.assertProgress('filter-run');
|
||||
return this.outcomeOf(swept, (position) =>
|
||||
encodeFilterRunCursor(position, request),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 차단할 때 받은편지함 정리 (계약 2절 "보낸 사람 차단" — 15단계) — 받은편지함의 그 보낸 사람 메일을 스팸메일함으로 옮기고 스팸 표시를 붙인다.
|
||||
* 판정은 엔진이 푼 From 머리글의 주소로 차단 규칙과 같게(mail-block-match.ts) — 머리글 원문을 읽지 않는다(표시 이름에 쓴 주소가 걸리지 않게).
|
||||
* 맞은 메일은 늘 옮긴다(옮기기라 읽은 상태 그대로일 때만 — ifInState). 붙잡힌 메일(9단계)은 옮기지 않는다(받은편지함에는 없지만 지금 규칙대로).
|
||||
* 스팸메일함이 없으면 엔진에 묻기 전에 MAIL_001, cursor 가 깨졌거나 다른 보낸 사람의 것이면 그 앞에 VAL_001.
|
||||
* 응답의 matched 는 스팸메일함으로 옮긴 수다
|
||||
*/
|
||||
async runBlock(
|
||||
user: string,
|
||||
request: MailBlockRunRequest,
|
||||
context: MailFilterRunContext,
|
||||
): Promise<MailFilterRunOutcome> {
|
||||
const start: RunState =
|
||||
request.cursor === null
|
||||
? FROM_TOP
|
||||
: decodeBlockRunCursor(request.cursor, request.sender);
|
||||
|
||||
const run = new BulkRun(context.startedAt);
|
||||
const inbox = context.mailboxes.find((mailbox) => mailbox.role === 'inbox');
|
||||
if (!inbox) return NOTHING_TO_RUN;
|
||||
const junk = context.mailboxes.find((mailbox) => mailbox.role === 'junk');
|
||||
if (!junk) {
|
||||
throw new BusinessException('MAIL_001', {
|
||||
context: { reason: 'junk-mailbox-missing' },
|
||||
});
|
||||
}
|
||||
|
||||
const plan: RunPlan = {
|
||||
inboxId: inbox.id,
|
||||
moveTo: junk.id,
|
||||
markAsRead: false,
|
||||
flag: false,
|
||||
};
|
||||
const read: PageRead = {
|
||||
properties: BLOCK_RUN_PROPERTIES,
|
||||
needsBody: false,
|
||||
limit: HEADER_ROUND_SIZE,
|
||||
withHeld: true,
|
||||
};
|
||||
|
||||
const swept = await this.sweep(user, inbox, start, read, run, (page) =>
|
||||
this.applyBlockRound(user, request.sender, plan, junk.id, page, run),
|
||||
);
|
||||
if (!swept.finished) run.assertProgress('mail-block-run');
|
||||
return this.outcomeOf(swept, (position) =>
|
||||
encodeBlockRunCursor(position, request.sender),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 받은편지함을 받은 시각이 최근인 메일부터 차례로 훑는다 — 차례마다 읽고(readPage) 판정 · 할 일(applyRound)을 한 뒤 자리를 옮긴다.
|
||||
* 한 요청의 시간 · 멈춤은 BulkRun. 처음 차례가 맨 위 메일의 받은 시각 + 1초를 위 경계로 잡는다(그 뒤에 온 메일은 보지 않는다).
|
||||
* 남은 시간 때문에 끊긴 호출은 진행한 것이 있으면 거기까지로 답한다(cutOffAfterProgress)
|
||||
*/
|
||||
private async sweep(
|
||||
user: string,
|
||||
inbox: Mailbox,
|
||||
start: RunState,
|
||||
read: PageRead,
|
||||
run: BulkRun,
|
||||
applyRound: RoundRunner,
|
||||
): Promise<SweepResult> {
|
||||
let position = start;
|
||||
let scanned = 0;
|
||||
let matched = 0;
|
||||
let rounds = 0;
|
||||
@@ -359,7 +531,7 @@ export class JmapFilterRunner {
|
||||
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);
|
||||
const round = await applyRound(page);
|
||||
// 멈춘다 — 이 차례는 하지 않은 것으로 (다음 요청이 같은 자리부터 다시 한다)
|
||||
if (round === null) break;
|
||||
|
||||
@@ -387,22 +559,38 @@ export class JmapFilterRunner {
|
||||
} 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),
|
||||
finished,
|
||||
position,
|
||||
rounds,
|
||||
};
|
||||
}
|
||||
|
||||
/** 훑은 결과 → 응답 — 끝까지 했으면 cursor 없음, 아니면 마친 자리의 cursor (cursorOf) */
|
||||
private outcomeOf(
|
||||
swept: SweepResult,
|
||||
encode: (position: FilterRunPosition) => string,
|
||||
): MailFilterRunOutcome {
|
||||
return {
|
||||
scanned: swept.scanned,
|
||||
matched: swept.matched,
|
||||
remaining: swept.remaining,
|
||||
cursor: swept.finished ? null : this.cursorOf(swept.position, encode),
|
||||
rounds: swept.rounds,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 이어 할 자리 — 위 경계와 남긴 마지막 메일의 받은 시각이 있어야 한다. 없으면 엔진이 받은 시각을 주지 않은 것 — MAIL_001
|
||||
* (끝까지 했으면 이어 할 자리가 필요 없어 묻지 않는다)
|
||||
*/
|
||||
private cursorOf(position: RunState, request: MailFilterRunRequest): string {
|
||||
private cursorOf(
|
||||
position: RunState,
|
||||
encode: (position: FilterRunPosition) => string,
|
||||
): string {
|
||||
if (
|
||||
position.before === null ||
|
||||
(position.anchor !== null && position.anchorTime === null)
|
||||
@@ -411,14 +599,11 @@ export class JmapFilterRunner {
|
||||
context: { reason: 'received-at-missing' },
|
||||
});
|
||||
}
|
||||
return encodeFilterRunCursor(
|
||||
{
|
||||
before: position.before,
|
||||
anchor: position.anchor,
|
||||
anchorTime: position.anchorTime,
|
||||
},
|
||||
request,
|
||||
);
|
||||
return encode({
|
||||
before: position.before,
|
||||
anchor: position.anchor,
|
||||
anchorTime: position.anchorTime,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -571,7 +756,7 @@ export class JmapFilterRunner {
|
||||
run: BulkRun,
|
||||
): Promise<RoundResult | null> {
|
||||
/** 판정 — 'gone' 은 받은편지함에서 빠진 메일(건너뛰고 세지 않는다) */
|
||||
const verdicts = new Map<string, boolean | 'gone'>();
|
||||
const verdicts = new Map<string, Verdict>();
|
||||
const undecided: JmapEmail[] = [];
|
||||
const headerTexts = new Map<string, Omit<MailFilterTexts, 'body'>>();
|
||||
for (const id of page.ids) {
|
||||
@@ -641,30 +826,69 @@ export class JmapFilterRunner {
|
||||
|
||||
const applied: Applied | null =
|
||||
patches.size === 0
|
||||
? { updated: new Set(), gone: new Set() }
|
||||
? NOTHING_APPLIED
|
||||
: await this.applyPatches(user, plan, page.state, patches, run);
|
||||
if (applied === null) return null;
|
||||
|
||||
let judged = 0;
|
||||
let lastStayed: string | null = null;
|
||||
return roundResult(
|
||||
page,
|
||||
verdicts,
|
||||
moving,
|
||||
applied,
|
||||
unchanged + applied.updated.size,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 차단 정리의 한 차례 (15단계) — 엔진이 푼 From 주소로 판정하고(mail-block-match.ts) 맞은 메일을 스팸메일함으로 옮긴다(스팸 표시와 함께).
|
||||
* 붙잡힌 메일(9단계)은 옮기지 않는다 — 받은편지함에 남은 것으로 센다. matched 는 옮긴 수다. 멈춰야 하면 null (applyPatches)
|
||||
*/
|
||||
private async applyBlockRound(
|
||||
user: string,
|
||||
sender: string,
|
||||
plan: RunPlan,
|
||||
junkId: string,
|
||||
page: RoundPage,
|
||||
run: BulkRun,
|
||||
): Promise<RoundResult | null> {
|
||||
const verdicts = new Map<string, Verdict>();
|
||||
for (const id of page.ids) {
|
||||
const verdict = verdicts.get(id);
|
||||
if (verdict === undefined || verdict === 'gone') continue;
|
||||
judged += 1;
|
||||
const moved = moving.has(id) && applied.updated.has(id);
|
||||
if (!moved && !applied.gone.has(id)) lastStayed = id;
|
||||
const email = page.emails.get(id);
|
||||
verdicts.set(
|
||||
id,
|
||||
email?.mailboxIds?.[plan.inboxId] !== true
|
||||
? 'gone'
|
||||
: isBlockedSenderMail(fromAddressesOf(email), sender),
|
||||
);
|
||||
}
|
||||
return {
|
||||
judged,
|
||||
matched: unchanged + applied.updated.size,
|
||||
lastStayed:
|
||||
lastStayed === null
|
||||
? null
|
||||
: {
|
||||
id: lastStayed,
|
||||
time: receivedSeconds(page.emails.get(lastStayed)),
|
||||
},
|
||||
};
|
||||
|
||||
const matchedIds = page.ids.filter((id) => verdicts.get(id) === true);
|
||||
const held =
|
||||
matchedIds.length === 0
|
||||
? new Set<string>()
|
||||
: await heldAmong(
|
||||
page.held,
|
||||
matchedIds,
|
||||
this.heldLookup(user, run.callOptions()),
|
||||
);
|
||||
const patches = new Map<string, Record<string, unknown>>();
|
||||
for (const id of matchedIds) {
|
||||
if (!held.has(id)) patches.set(id, blockPatch(plan.inboxId, junkId));
|
||||
}
|
||||
|
||||
const applied: Applied | null =
|
||||
patches.size === 0
|
||||
? NOTHING_APPLIED
|
||||
: await this.applyPatches(user, plan, page.state, patches, run);
|
||||
if (applied === null) return null;
|
||||
|
||||
return roundResult(
|
||||
page,
|
||||
verdicts,
|
||||
new Set(patches.keys()),
|
||||
applied,
|
||||
applied.updated.size,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -7,7 +7,10 @@ import type {
|
||||
MailVacation,
|
||||
} from '@/modules/mail/mail.types';
|
||||
import {
|
||||
FILTER_SOURCE_FORMAT_WITH_RICH_VACATION,
|
||||
FILTER_SOURCE_MARKER,
|
||||
NEVER_CONFIGURED_VACATION,
|
||||
SAME_AS_INTERNAL_REPLY,
|
||||
filterRulesVersion,
|
||||
readFilterSource,
|
||||
type FilterScriptSource,
|
||||
@@ -57,6 +60,8 @@ const vacationOn: MailVacation = {
|
||||
toDate: '2026-11-07T15:00:00.000Z',
|
||||
subject: '부재중입니다',
|
||||
textBody: '11월 7일까지 휴가입니다.',
|
||||
htmlBody: null,
|
||||
external: SAME_AS_INTERNAL_REPLY,
|
||||
};
|
||||
|
||||
interface FakeScript {
|
||||
@@ -139,8 +144,12 @@ class FakeSieveEngine {
|
||||
),
|
||||
};
|
||||
|
||||
/** 회사 메일 도메인 설정 (MAIL_SETTINGS 의 domain) — 부재중의 회사 밖 응답을 가를 때 */
|
||||
readonly settings: { domain: string | null } = { domain: 'arasystem.test' };
|
||||
|
||||
readonly store = new JmapFilterScriptStore(
|
||||
this.client as unknown as JmapClient,
|
||||
this.settings,
|
||||
);
|
||||
|
||||
/** 스크립트를 하나 둔다 (다른 메일 프로그램 · 앞선 저장이 만든 것) */
|
||||
@@ -341,12 +350,15 @@ describe('JmapFilterScriptStore — 필터 · 부재중 자동 응답의 저장
|
||||
version: filterRulesVersion([]),
|
||||
isActive: true,
|
||||
});
|
||||
// 엔진의 서식 본문은 가져오지 않는다 — 글만 · 사내와 같은 응답(16단계 칸의 처음 값)
|
||||
await expect(engine.store.getVacation(ARA)).resolves.toEqual({
|
||||
isEnabled: true,
|
||||
fromDate: '2026-11-02T15:00:00.000Z',
|
||||
toDate: null,
|
||||
subject: '4단계 부재중',
|
||||
textBody: '휴가입니다.',
|
||||
htmlBody: null,
|
||||
external: SAME_AS_INTERNAL_REPLY,
|
||||
isActive: true,
|
||||
});
|
||||
expect(engine.recorded[0]).toEqual({
|
||||
@@ -392,6 +404,8 @@ describe('JmapFilterScriptStore — 필터 · 부재중 자동 응답의 저장
|
||||
toDate: '2026-11-07T15:00:00.000Z',
|
||||
subject: '',
|
||||
textBody: '',
|
||||
htmlBody: null,
|
||||
external: SAME_AS_INTERNAL_REPLY,
|
||||
};
|
||||
|
||||
await expect(engine.store.getVacation(ARA)).resolves.toEqual({
|
||||
@@ -532,7 +546,7 @@ describe('JmapFilterScriptStore — 필터 · 부재중 자동 응답의 저장
|
||||
context: {
|
||||
reason: 'filter-source-newer',
|
||||
format: 99,
|
||||
supportedFormat: 2,
|
||||
supportedFormat: FILTER_SOURCE_FORMAT_WITH_RICH_VACATION,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -1060,6 +1074,55 @@ describe('JmapFilterScriptStore — 필터 · 부재중 자동 응답의 저장
|
||||
expect(engine.ours()?.isActive).toBe(true);
|
||||
});
|
||||
|
||||
it('회사 밖 따로 쓰기 · 서식 본문(16단계) — 회사 메일 도메인으로 봉투 보낸 사람을 가르는 글을 올리고, 원본은 판 3', async () => {
|
||||
const engine = new FakeSieveEngine();
|
||||
engine.settings.domain = 'arasystem.it.kr';
|
||||
const rich: MailVacation = {
|
||||
...vacationOn,
|
||||
htmlBody: '<p>11월 7일까지 <strong>휴가</strong>입니다.</p>',
|
||||
external: {
|
||||
mode: 'separate',
|
||||
subject: 'Out of office',
|
||||
textBody: 'I am away until Nov 7.',
|
||||
htmlBody: null,
|
||||
},
|
||||
};
|
||||
|
||||
await expect(engine.store.setVacation(ARA, rich)).resolves.toEqual({
|
||||
...rich,
|
||||
isActive: true,
|
||||
});
|
||||
const text = engine.blobs.get(engine.ours()!.blobId) ?? '';
|
||||
expect(text).toContain(
|
||||
'if envelope :domain :is "from" "arasystem.it.kr" {',
|
||||
);
|
||||
expect(text).toContain('multipart/alternative');
|
||||
expect(text).toContain(':handle "ara-external-');
|
||||
expect(text.split('\r\n')[1]).toContain(
|
||||
`${FILTER_SOURCE_MARKER}{"v":${FILTER_SOURCE_FORMAT_WITH_RICH_VACATION},`,
|
||||
);
|
||||
expect(engine.ourSource()?.vacation).toEqual(rich);
|
||||
await expect(engine.store.getVacation(ARA)).resolves.toEqual({
|
||||
...rich,
|
||||
isActive: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('회사 메일 도메인 설정이 없으면 직원 주소의 도메인으로 가른다 (로그인한 직원은 늘 그 도메인이다)', async () => {
|
||||
const engine = new FakeSieveEngine();
|
||||
engine.settings.domain = null;
|
||||
|
||||
await engine.store.setVacation(ARA, {
|
||||
...vacationOn,
|
||||
external: { ...SAME_AS_INTERNAL_REPLY, mode: 'none' },
|
||||
});
|
||||
const text = engine.blobs.get(engine.ours()!.blobId) ?? '';
|
||||
expect(text).toContain(
|
||||
'if envelope :domain :is "from" "arasystem.test" {',
|
||||
);
|
||||
expect(text).not.toContain('else');
|
||||
});
|
||||
|
||||
it('우리 스크립트가 없으면 만든다 — 규칙 없음 + 부재중, 켠다', async () => {
|
||||
const engine = new FakeSieveEngine();
|
||||
engine.addScript('vacation', 'require ["vacation"];\r\n', true);
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { Inject, Injectable, Logger } from '@nestjs/common';
|
||||
|
||||
import { BusinessException } from '@/common/errors/business.exception';
|
||||
import { MAX_MAIL_BLOCKS } from '@/modules/mail/mail.constants';
|
||||
import { MAIL_SETTINGS, type MailSettings } from '@/modules/mail/mail.settings';
|
||||
import type {
|
||||
BlockedSender,
|
||||
MailBlockAdded,
|
||||
@@ -121,7 +122,10 @@ interface ScriptSnapshot extends ScriptView {
|
||||
readonly newerFormat: number | null;
|
||||
}
|
||||
|
||||
/** 로그 · 오류 context 에 남기는 스크립트 요약 — 크기와 규칙 · 차단 수만 (규칙의 글 · 차단한 주소는 남기지 않는다) */
|
||||
/**
|
||||
* 로그 · 오류 context 에 남기는 스크립트 요약 — 크기와 규칙 · 차단 수, 부재중의 켬 · 서식 있음 · 회사 밖 고르기만
|
||||
* (규칙의 글 · 차단한 주소 · 부재중 글은 남기지 않는다)
|
||||
*/
|
||||
function scriptSummary(
|
||||
script: BuiltFilterScript,
|
||||
source: FilterScriptSource,
|
||||
@@ -131,10 +135,17 @@ function scriptSummary(
|
||||
rules: source.rules.length,
|
||||
enabledRules: source.rules.filter((rule) => rule.isEnabled).length,
|
||||
vacation: source.vacation.isEnabled,
|
||||
vacationHtml: source.vacation.htmlBody !== null,
|
||||
vacationExternal: source.vacation.external.mode,
|
||||
blocked: source.blocked.length,
|
||||
};
|
||||
}
|
||||
|
||||
/** 사용자 주소의 도메인 (소문자) — 회사 메일 도메인 설정이 없을 때만 쓴다(로그인한 직원은 늘 그 도메인이다) */
|
||||
function domainOf(user: string): string {
|
||||
return user.slice(user.lastIndexOf('@') + 1).toLowerCase();
|
||||
}
|
||||
|
||||
function toFilterSet(view: ScriptView): MailFilterSet {
|
||||
return {
|
||||
rules: canonicalRules(view.source.rules),
|
||||
@@ -175,7 +186,12 @@ function toBlockList(view: ScriptView): MailBlockList {
|
||||
export class JmapFilterScriptStore {
|
||||
private readonly logger = new Logger(JmapFilterScriptStore.name);
|
||||
|
||||
constructor(private readonly jmap: JmapClient) {}
|
||||
constructor(
|
||||
private readonly jmap: JmapClient,
|
||||
/** 회사 메일 도메인 — 부재중의 회사 밖 응답(16단계)을 봉투 보낸 사람의 도메인으로 가를 때 */
|
||||
@Inject(MAIL_SETTINGS)
|
||||
private readonly settings: Pick<MailSettings, 'domain'>,
|
||||
) {}
|
||||
|
||||
/** 지금 규칙 · 지문 · 동작하는지 */
|
||||
async getFilters(user: string): Promise<MailFilterSet> {
|
||||
@@ -407,7 +423,10 @@ export class JmapFilterScriptStore {
|
||||
if (next.blocked.length > 0 && junkMailbox === null) {
|
||||
junkMailbox = await this.junkMailbox(user);
|
||||
}
|
||||
const script = buildFilterScript(next, { junkMailbox });
|
||||
const script = buildFilterScript(next, {
|
||||
junkMailbox,
|
||||
internalDomain: this.settings.domain ?? domainOf(user),
|
||||
});
|
||||
if (
|
||||
script.bytes > FILTER_SCRIPT_MAX_BYTES ||
|
||||
script.longestStringBytes > SIEVE_STRING_MAX_BYTES
|
||||
@@ -429,7 +448,7 @@ export class JmapFilterScriptStore {
|
||||
const saved = await this.save(user, current, blobId, script, next);
|
||||
if (saved !== null) {
|
||||
this.logger.debug(
|
||||
`${traceTag()} 엔진 규칙(Sieve)을 저장했습니다: user=${maskEmail(user)}, ${script.bytes}바이트, 규칙 ${next.rules.length}개, 부재중 ${next.vacation.isEnabled ? '켬' : '끔'}, 차단 ${next.blocked.length}개, ${attempt}번째`,
|
||||
`${traceTag()} 엔진 규칙(Sieve)을 저장했습니다: user=${maskEmail(user)}, ${script.bytes}바이트, 규칙 ${next.rules.length}개, 부재중 ${next.vacation.isEnabled ? '켬' : '끔'}(서식 ${next.vacation.htmlBody !== null ? '있음' : '없음'}, 회사 밖 ${next.vacation.external.mode}), 차단 ${next.blocked.length}개, ${attempt}번째`,
|
||||
);
|
||||
return { source: next, isActive: saved.isActive };
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -133,6 +133,8 @@ describe('JmapOpsEngine — 관리 API · 대리 접근 (13단계)', () => {
|
||||
toDate: null,
|
||||
subject: '',
|
||||
textBody: '',
|
||||
htmlBody: null,
|
||||
external: { mode: 'same', subject: null, textBody: null, htmlBody: null },
|
||||
isActive: true,
|
||||
};
|
||||
filterScripts = {
|
||||
@@ -899,12 +901,15 @@ describe('JmapOpsEngine — 관리 API · 대리 접근 (13단계)', () => {
|
||||
match: 'always',
|
||||
actions: { forwardTo: `doyun.lee@${DOMAIN}` },
|
||||
});
|
||||
// 운영자의 안내 글은 글만 · 회사 안팎 모두에게 (16단계 칸은 처음 값)
|
||||
expect(filterScripts.setVacation).toHaveBeenCalledWith(ARA, {
|
||||
isEnabled: true,
|
||||
fromDate: null,
|
||||
toDate: null,
|
||||
subject: '부재 안내',
|
||||
textBody: '퇴사했습니다.',
|
||||
htmlBody: null,
|
||||
external: { mode: 'same', subject: null, textBody: null, htmlBody: null },
|
||||
});
|
||||
|
||||
filterScripts.getFilters.mockResolvedValue({
|
||||
@@ -912,12 +917,20 @@ describe('JmapOpsEngine — 관리 API · 대리 접근 (13단계)', () => {
|
||||
version: 'v2',
|
||||
isActive: true,
|
||||
});
|
||||
const external = {
|
||||
mode: 'separate' as const,
|
||||
subject: 'Left the company',
|
||||
textBody: 'Please contact doyun.',
|
||||
htmlBody: null,
|
||||
};
|
||||
filterScripts.getVacation.mockResolvedValue({
|
||||
isEnabled: true,
|
||||
fromDate: null,
|
||||
toDate: null,
|
||||
subject: '부재 안내',
|
||||
textBody: '퇴사했습니다.',
|
||||
htmlBody: '<p>퇴사했습니다.</p>',
|
||||
external,
|
||||
isActive: true,
|
||||
});
|
||||
await expect(engine.getLockRules(ARA)).resolves.toEqual({
|
||||
@@ -929,10 +942,16 @@ describe('JmapOpsEngine — 관리 API · 대리 접근 (13단계)', () => {
|
||||
rules: [saved.rules[1]],
|
||||
version: 'v2',
|
||||
});
|
||||
expect(filterScripts.setVacation).toHaveBeenLastCalledWith(
|
||||
ARA,
|
||||
expect.objectContaining({ isEnabled: false, subject: '부재 안내' }),
|
||||
);
|
||||
// 끄기만 한다 — 글 · 서식 · 회사 밖 응답은 그대로 (isActive 는 넘기지 않는다)
|
||||
expect(filterScripts.setVacation).toHaveBeenLastCalledWith(ARA, {
|
||||
isEnabled: false,
|
||||
fromDate: null,
|
||||
toDate: null,
|
||||
subject: '부재 안내',
|
||||
textBody: '퇴사했습니다.',
|
||||
htmlBody: '<p>퇴사했습니다.</p>',
|
||||
external,
|
||||
});
|
||||
});
|
||||
|
||||
it('필터가 이미 50개면 넘기기 규칙을 더하지 않는다 (too-many-rules)', async () => {
|
||||
|
||||
@@ -529,12 +529,20 @@ export class JmapOpsEngine implements OpsEngine {
|
||||
});
|
||||
}
|
||||
if (rules.autoReply !== null) {
|
||||
// 운영자의 안내 글 — 글만, 회사 안팎 모두에게 (16단계의 서식 본문 · 회사 밖 응답은 쓰지 않는다)
|
||||
await this.filterScripts.setVacation(email, {
|
||||
isEnabled: true,
|
||||
fromDate: null,
|
||||
toDate: null,
|
||||
subject: rules.autoReply.subject,
|
||||
textBody: rules.autoReply.body,
|
||||
htmlBody: null,
|
||||
external: {
|
||||
mode: 'same',
|
||||
subject: null,
|
||||
textBody: null,
|
||||
htmlBody: null,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -549,12 +557,15 @@ export class JmapOpsEngine implements OpsEngine {
|
||||
}
|
||||
const vacation = await this.filterScripts.getVacation(email);
|
||||
if (vacation.isEnabled) {
|
||||
// 끄기만 한다 — 글 · 서식 · 회사 밖 응답은 그대로 둔다
|
||||
await this.filterScripts.setVacation(email, {
|
||||
isEnabled: false,
|
||||
fromDate: vacation.fromDate,
|
||||
toDate: vacation.toDate,
|
||||
subject: vacation.subject,
|
||||
textBody: vacation.textBody,
|
||||
htmlBody: vacation.htmlBody,
|
||||
external: vacation.external,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -355,7 +355,8 @@ export function toUsage(quota: JmapQuota): MailUsage {
|
||||
* 엔진의 VacationResponse → 우리 모양 (계약 2절 "부재중 자동 응답") — 필터 · 부재중을 두는 우리 스크립트가 아직 없을 때 가져온다
|
||||
* (4단계에 저장한 값 이어받기 — jmap-filter-script.ts).
|
||||
* 엔진에 없는 값(null)의 제목·내용은 빈 문자열, 날짜는 ISO 8601 UTC 로 — 한 번도 설정하지 않은 계정은
|
||||
* `{ isEnabled: false, fromDate: null, toDate: null, subject: '', textBody: '' }` 가 된다. 서식 본문(htmlBody)은 쓰지 않는다.
|
||||
* `{ isEnabled: false, fromDate: null, toDate: null, subject: '', textBody: '', htmlBody: null, external: 사내와 같은 응답 }` 가 된다.
|
||||
* 엔진의 서식 본문(htmlBody)은 가져오지 않는다 — 글만 · 사내와 같은 응답(16단계 칸의 처음 값 — filter-source.ts 의 SAME_AS_INTERNAL_REPLY 와 같다)
|
||||
*/
|
||||
export function toVacation(
|
||||
response: JmapVacationResponse | undefined,
|
||||
@@ -366,6 +367,8 @@ export function toVacation(
|
||||
toDate: toUtcIso(response?.toDate),
|
||||
subject: response?.subject ?? '',
|
||||
textBody: response?.textBody ?? '',
|
||||
htmlBody: null,
|
||||
external: { mode: 'same', subject: null, textBody: null, htmlBody: null },
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -150,6 +150,8 @@ export interface JmapEmailSubmission {
|
||||
id: string;
|
||||
/** 이 제출이 보내는 메일 */
|
||||
emailId?: string;
|
||||
/** 이 제출의 발신 ID — 예약 메일 지금 보내기(15단계)가 같은 발신 ID 로 다시 제출한다 */
|
||||
identityId?: string;
|
||||
/**
|
||||
* pending — 아직 붙잡고 있다(취소할 수 있다) · final — 보냈다 · canceled — 취소했다 (RFC 8621 §7).
|
||||
* 붙잡지 않은 제출도 대기열에서 배달하는 동안 잠깐 pending 이다 — 붙잡은 것은 보낼 시각(sendAt)이 아직 오지 않은 것으로 가린다 (9단계)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { startCpuTimer } from '@/../test/cpu-time';
|
||||
import {
|
||||
alternativeMimeEntity,
|
||||
base64TextBody,
|
||||
decodeHeaderText,
|
||||
encodeHeaderWords,
|
||||
@@ -97,6 +98,68 @@ describe('base64TextBody · plainTextMimeEntity — 부재중 내용', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('alternativeMimeEntity — 부재중 서식 본문 (16단계, 글 + 서식 두 벌)', () => {
|
||||
/** 개체의 부분 — 경계로 나눠 머리 · base64 본문을 푼다 */
|
||||
const partsOf = (entity: string) => {
|
||||
const boundary = /boundary="([^"]+)"/.exec(entity)?.[1] ?? '';
|
||||
return entity
|
||||
.split(`--${boundary}`)
|
||||
.slice(1, -1)
|
||||
.map((part) => {
|
||||
const [head, body] = part.replace(/^\r\n/, '').split('\r\n\r\n');
|
||||
return {
|
||||
head,
|
||||
text: Buffer.from(body.replace(/\r\n/g, ''), 'base64').toString(
|
||||
'utf8',
|
||||
),
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
it('multipart/alternative — 글(text/plain) 다음 서식(text/html), 각 base64 · utf-8, 닫는 경계 뒤 줄바꿈 (엔진이 그대로 보낸 모양 — engine-facts E2)', () => {
|
||||
const entity = alternativeMimeEntity(
|
||||
'11월 7일까지 휴가입니다.\n급한 일은 이도윤 님께.',
|
||||
'<p style="margin:0">11월 7일까지 <strong>휴가</strong>입니다.</p>',
|
||||
);
|
||||
const lines = entity.split('\r\n');
|
||||
|
||||
expect(lines.slice(0, 3)).toEqual([
|
||||
'Content-Type: multipart/alternative; boundary="ara-vacation-alternative"',
|
||||
'',
|
||||
'--ara-vacation-alternative',
|
||||
]);
|
||||
expect(entity.endsWith('\r\n--ara-vacation-alternative--\r\n')).toBe(true);
|
||||
expect(partsOf(entity)).toEqual([
|
||||
{
|
||||
head: 'Content-Type: text/plain; charset="utf-8"\r\nContent-Transfer-Encoding: base64',
|
||||
text: '11월 7일까지 휴가입니다.\r\n급한 일은 이도윤 님께.',
|
||||
},
|
||||
{
|
||||
head: 'Content-Type: text/html; charset="utf-8"\r\nContent-Transfer-Encoding: base64',
|
||||
text: '<p style="margin:0">11월 7일까지 <strong>휴가</strong>입니다.</p>',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('ASCII 만 나오고 경계는 본문 줄과 겹치지 않는다 — 글 · 서식의 ${ · 역슬래시 · 따옴표 · 경계와 같은 글도 base64 안에만 있다', () => {
|
||||
const entity = alternativeMimeEntity(
|
||||
'${x} \\ "따옴표" --ara-vacation-alternative',
|
||||
'<p>${x} --ara-vacation-alternative</p>',
|
||||
);
|
||||
expect(entity).toMatch(/^[\x20-\x7e\r\n]*$/);
|
||||
expect(entity).not.toContain('${');
|
||||
expect(entity).not.toContain('\\');
|
||||
// 경계 줄은 셋뿐이다(여는 둘 · 닫는 하나)
|
||||
expect(
|
||||
entity.split('\r\n').filter((line) => line.startsWith('--')),
|
||||
).toEqual([
|
||||
'--ara-vacation-alternative',
|
||||
'--ara-vacation-alternative',
|
||||
'--ara-vacation-alternative--',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* 원래 머리글 → 엔진의 Sieve 가 견주는 글 (이미 받은 메일에 필터 적용 — 계약 2절 "이미 받은 메일에 적용"의 판정).
|
||||
* 원래 글의 모양은 엔진이 주는 그대로다 — 앞에 공백 한 칸, 접힌 줄은 CRLF + 공백, encoded-word 그대로 (2026-10-29 실배달 메일에서 받은 값)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* 메일 머리글 · 본문의 글 모양 (순수 함수).
|
||||
* - 쓰기: 부재중 자동 응답을 엔진 규칙(Sieve)에 넣을 때 — 제목은 RFC 2047 encoded-word, 내용은 base64 MIME 본문.
|
||||
* - 쓰기: 부재중 자동 응답을 엔진 규칙(Sieve)에 넣을 때 — 제목은 RFC 2047 encoded-word, 내용은 base64 MIME 본문(서식이 있으면 글 + 서식 두 벌 — 16단계).
|
||||
* 둘 다 ASCII 로만 나오므로 Sieve 문자열 안에 따옴표 · 역슬래시 · `${` 가 생기지 않는다
|
||||
* (엔진은 require 와 상관없이 문자열의 `${…}` 를 늘 변수로 펼친다 — 계약 2절 "만드는 Sieve", 2026-10-28 확인).
|
||||
* - 읽기: 이미 받은 메일에 필터를 적용할 때 원래 머리글을 엔진의 Sieve 가 보는 글로 푼다 (decodeHeaderText — filter-run-texts.ts)
|
||||
@@ -84,6 +84,36 @@ export function plainTextMimeEntity(text: string): string {
|
||||
].join(CRLF);
|
||||
}
|
||||
|
||||
/**
|
||||
* 글 + 서식 두 벌의 경계 (RFC 2046 §5.1.1) — base64 글자에 없는 `-` 가 들어 있어 본문 줄과 겹치지 않는다.
|
||||
* 늘 같은 값이라 같은 부재중이면 같은 스크립트 글이 된다 (다시 올리지 않고 쓴다 — jmap-filter-script.ts)
|
||||
*/
|
||||
const ALTERNATIVE_BOUNDARY = 'ara-vacation-alternative';
|
||||
|
||||
/**
|
||||
* 글 + 서식 두 벌의 MIME 개체 (16단계 — 부재중 서식 본문) — `multipart/alternative` 에 글(text/plain) · 서식(text/html) 차례로, 각 base64.
|
||||
* 서식을 읽지 못하는 메일 프로그램은 글만 보인다. 엔진이 이 개체를 그대로 보낸다(2026-11-10 개발 엔진 — engine-facts E2).
|
||||
* ASCII 로만 나오므로 Sieve 문자열에 넣어도 `${` · 따옴표 · 역슬래시가 생기지 않는다(경계의 따옴표는 Sieve 쪽이 이스케이프한다)
|
||||
*/
|
||||
export function alternativeMimeEntity(text: string, html: string): string {
|
||||
return [
|
||||
`Content-Type: multipart/alternative; boundary="${ALTERNATIVE_BOUNDARY}"`,
|
||||
'',
|
||||
`--${ALTERNATIVE_BOUNDARY}`,
|
||||
'Content-Type: text/plain; charset="utf-8"',
|
||||
'Content-Transfer-Encoding: base64',
|
||||
'',
|
||||
base64TextBody(text),
|
||||
`--${ALTERNATIVE_BOUNDARY}`,
|
||||
'Content-Type: text/html; charset="utf-8"',
|
||||
'Content-Transfer-Encoding: base64',
|
||||
'',
|
||||
base64TextBody(html),
|
||||
`--${ALTERNATIVE_BOUNDARY}--`,
|
||||
'',
|
||||
].join(CRLF);
|
||||
}
|
||||
|
||||
/**
|
||||
* encoded-word 하나 — `=?문자셋?B|Q?글?=` (RFC 2047 §2). 문자셋 · 글 칸에는 `?` · 공백이 없어 칸마다 다음 `?` 에서 멈춘다
|
||||
* (되돌아가 볼 것이 없다 — 길이에 비례). 문자셋 뒤의 `*언어`(RFC 2231 §5)는 풀 때 뗀다
|
||||
|
||||
@@ -4,13 +4,16 @@ import type {
|
||||
MailVacation,
|
||||
} from '@/modules/mail/mail.types';
|
||||
import {
|
||||
FILTER_SOURCE_FORMAT_WITH_RICH_VACATION,
|
||||
FILTER_SOURCE_MARKER,
|
||||
NEVER_CONFIGURED_VACATION,
|
||||
SAME_AS_INTERNAL_REPLY,
|
||||
filterSourceLine,
|
||||
readFilterSource,
|
||||
type FilterScriptSource,
|
||||
} from './filter-source';
|
||||
import { buildFilterScript } from './sieve-script';
|
||||
import { alternativeMimeEntity, plainTextMimeEntity } from './mime-text';
|
||||
import { SIEVE_STRING_MAX_BYTES, buildFilterScript } from './sieve-script';
|
||||
|
||||
const b64 = (text: string) => Buffer.from(text, 'utf8').toString('base64');
|
||||
|
||||
@@ -38,6 +41,39 @@ const vacationOn: MailVacation = {
|
||||
toDate: '2026-11-07T15:00:00.000Z',
|
||||
subject: '부재중입니다',
|
||||
textBody: '휴가입니다.\n급한 일은 이도윤 님께.',
|
||||
htmlBody: null,
|
||||
external: SAME_AS_INTERNAL_REPLY,
|
||||
};
|
||||
|
||||
/** 개발 엔진과 같은 회사 메일 도메인 */
|
||||
const DOMAIN = 'arasystem.test';
|
||||
|
||||
/** Sieve 따옴표 문자열을 푼다 — `\"` · `\\` 만 */
|
||||
const unquote = (quoted: string) => quoted.replace(/\\(["\\])/g, '$1');
|
||||
|
||||
/** 스크립트의 `set "이름" "값";` 들 — 이름 → 값 */
|
||||
const variablesOf = (text: string): Map<string, string> =>
|
||||
new Map(
|
||||
[...text.matchAll(/set "(part\d+)" "((?:[^"\\]|\\.)*)";/g)].map(
|
||||
([, name, value]) => [name, unquote(value)],
|
||||
),
|
||||
);
|
||||
|
||||
/** 스크립트의 vacation 명령들 — 태그 · MIME 개체(변수로 이었으면 이은 값) */
|
||||
const vacationCommandsOf = (text: string) => {
|
||||
const variables = variablesOf(text);
|
||||
return [
|
||||
...text.matchAll(
|
||||
/vacation :mime (?::handle "([^"]*)" )?(?::subject "([^"]*)" )?"((?:[^"\\]|\\.)*)";/g,
|
||||
),
|
||||
].map(([, handle, subject, argument]) => ({
|
||||
handle: handle ?? null,
|
||||
subject: subject ?? null,
|
||||
entity: unquote(argument).replace(
|
||||
/\$\{(part\d+)\}/g,
|
||||
(_whole, name: string) => variables.get(name) ?? '',
|
||||
),
|
||||
}));
|
||||
};
|
||||
|
||||
/** 대표 원본 — 하나라도(옮기기 · 표시 둘) · 꺼 둔 규칙 · 모든 메일(표시 · 전달) · 모두(받는 사람 · 본문 포함하지 않음, 없는 메일함) */
|
||||
@@ -296,6 +332,236 @@ describe('buildFilterScript — 규칙 · 부재중 → 엔진 규칙(Sieve)', (
|
||||
expect(sourceOf(text)?.vacation).toEqual(off);
|
||||
});
|
||||
|
||||
it('서식 본문이 있으면 글 + 서식 두 벌(multipart/alternative) — 사내와 같은 응답이면 봉투 검사 · :handle 없이 하나 (16단계)', () => {
|
||||
const html =
|
||||
'<p style="margin:0">휴가입니다. <strong>급한 일</strong>은 이도윤 님께.</p>';
|
||||
const { text } = buildFilterScript({
|
||||
rules: [],
|
||||
vacation: {
|
||||
...vacationOn,
|
||||
fromDate: null,
|
||||
toDate: null,
|
||||
htmlBody: html,
|
||||
},
|
||||
blocked: [],
|
||||
});
|
||||
|
||||
expect(linesOf(text)[2]).toBe('require ["vacation"];');
|
||||
expect(text).not.toContain('envelope');
|
||||
expect(text).not.toContain(':handle');
|
||||
expect(vacationCommandsOf(text)).toEqual([
|
||||
{
|
||||
handle: null,
|
||||
subject: `=?UTF-8?B?${b64('부재중입니다')}?=`,
|
||||
entity: alternativeMimeEntity(vacationOn.textBody, html),
|
||||
},
|
||||
]);
|
||||
// 원본은 판 3 — 서식 본문을 쓴 직원만
|
||||
expect(sourceOf(text)?.vacation.htmlBody).toBe(html);
|
||||
expect(linesOf(text)[1]).toContain(
|
||||
`"v":${FILTER_SOURCE_FORMAT_WITH_RICH_VACATION}`,
|
||||
);
|
||||
});
|
||||
|
||||
it('회사 밖 따로 쓰기 — 봉투 보낸 사람의 도메인으로 가르고(envelope :domain :is), 회사 밖 응답은 :handle 을 따로 둔다', () => {
|
||||
const source: FilterScriptSource = {
|
||||
rules: [],
|
||||
vacation: {
|
||||
...vacationOn,
|
||||
external: {
|
||||
mode: 'separate',
|
||||
subject: 'Out of office',
|
||||
textBody: 'I am away until Nov 7.',
|
||||
htmlBody: '<p>I am away until <strong>Nov 7</strong>.</p>',
|
||||
},
|
||||
},
|
||||
blocked: [],
|
||||
};
|
||||
const { text } = buildFilterScript(source, { internalDomain: DOMAIN });
|
||||
const lines = linesOf(text);
|
||||
|
||||
expect(lines[2]).toBe(
|
||||
'require ["date", "envelope", "relational", "vacation"];',
|
||||
);
|
||||
expect(lines.slice(3, 6)).toEqual([
|
||||
'if not header :matches "X-Spam-Score" "spam*" {',
|
||||
' if currentdate :zone "+0000" :value "ge" "iso8601" "2026-11-02T15:00:00Z" {',
|
||||
' if currentdate :zone "+0000" :value "le" "iso8601" "2026-11-07T15:00:00Z" {',
|
||||
]);
|
||||
expect(lines[6]).toBe(
|
||||
` if envelope :domain :is "from" "${DOMAIN}" {`,
|
||||
);
|
||||
expect(lines[7]).toMatch(/^ {8}vacation :mime :subject /);
|
||||
expect(lines).toContain(' } else {');
|
||||
expect(lines.slice(-5)).toEqual([' }', ' }', ' }', '}', '']);
|
||||
|
||||
const [internal, external] = vacationCommandsOf(text);
|
||||
expect(internal).toEqual({
|
||||
handle: null,
|
||||
subject: `=?UTF-8?B?${b64('부재중입니다')}?=`,
|
||||
entity: plainTextMimeEntity(vacationOn.textBody),
|
||||
});
|
||||
expect(external.subject).toBe(`=?UTF-8?B?${b64('Out of office')}?=`);
|
||||
expect(external.entity).toBe(
|
||||
alternativeMimeEntity(
|
||||
'I am away until Nov 7.',
|
||||
'<p>I am away until <strong>Nov 7</strong>.</p>',
|
||||
),
|
||||
);
|
||||
expect(external.handle).toMatch(/^ara-external-[A-Za-z0-9_-]{16}$/);
|
||||
expect(sourceOf(text)?.vacation).toEqual(source.vacation);
|
||||
});
|
||||
|
||||
it('회사 밖 :handle 은 응답 내용의 지문 — 같은 글이면 같고, 글이 바뀌면 바뀐다', () => {
|
||||
const handleOf = (subject: string) =>
|
||||
vacationCommandsOf(
|
||||
buildFilterScript(
|
||||
{
|
||||
rules: [],
|
||||
vacation: {
|
||||
...vacationOn,
|
||||
external: {
|
||||
mode: 'separate',
|
||||
subject,
|
||||
textBody: 'away',
|
||||
htmlBody: null,
|
||||
},
|
||||
},
|
||||
blocked: [],
|
||||
},
|
||||
{ internalDomain: DOMAIN },
|
||||
).text,
|
||||
)[1].handle;
|
||||
|
||||
expect(handleOf('Away')).toBe(handleOf('Away'));
|
||||
expect(handleOf('Away')).not.toBe(handleOf('Away!'));
|
||||
});
|
||||
|
||||
it('회사 밖 보내지 않기 — 사내 도메인일 때만 답한다(else 없음), 회사 밖에 써 둔 글은 원본에만', () => {
|
||||
const vacation: MailVacation = {
|
||||
...vacationOn,
|
||||
fromDate: null,
|
||||
toDate: null,
|
||||
external: {
|
||||
mode: 'none',
|
||||
subject: '써 둔 제목',
|
||||
textBody: '써 둔 글',
|
||||
htmlBody: null,
|
||||
},
|
||||
};
|
||||
const { text } = buildFilterScript(
|
||||
{ rules: [], vacation, blocked: [] },
|
||||
{ internalDomain: DOMAIN },
|
||||
);
|
||||
|
||||
expect(linesOf(text).slice(2)).toEqual([
|
||||
'require ["envelope", "vacation"];',
|
||||
'if not header :matches "X-Spam-Score" "spam*" {',
|
||||
` if envelope :domain :is "from" "${DOMAIN}" {`,
|
||||
expect.stringMatching(/^ {4}vacation :mime :subject /) as string,
|
||||
'Content-Transfer-Encoding: base64',
|
||||
'',
|
||||
b64('휴가입니다.\r\n급한 일은 이도윤 님께.'),
|
||||
'";',
|
||||
' }',
|
||||
'}',
|
||||
'',
|
||||
]);
|
||||
expect(text).not.toContain('else');
|
||||
expect(vacationCommandsOf(text)).toHaveLength(1);
|
||||
expect(sourceOf(text)?.vacation.external).toEqual(vacation.external);
|
||||
});
|
||||
|
||||
it('회사 밖 응답을 가르는데 회사 메일 도메인을 받지 못하면 만들지 않는다 — 꺼져 있거나 사내와 같으면 필요 없다', () => {
|
||||
const external = {
|
||||
mode: 'separate' as const,
|
||||
subject: 'x',
|
||||
textBody: 'y',
|
||||
htmlBody: null,
|
||||
};
|
||||
const source = (isEnabled: boolean): FilterScriptSource => ({
|
||||
rules: [],
|
||||
vacation: { ...vacationOn, isEnabled, external },
|
||||
blocked: [],
|
||||
});
|
||||
expect(() => buildFilterScript(source(true))).toThrow();
|
||||
expect(() =>
|
||||
buildFilterScript(source(true), { internalDomain: null }),
|
||||
).toThrow();
|
||||
expect(() => buildFilterScript(source(false))).not.toThrow();
|
||||
expect(() =>
|
||||
buildFilterScript({ rules: [], vacation: vacationOn, blocked: [] }),
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
it('MIME 개체가 문자열 하나 한도(4,096바이트)를 넘으면 variables 조각(3,000바이트 이하 · 줄 끝에서 끊음)으로 나눠 잇는다 — 이으면 원래 개체 그대로', () => {
|
||||
const textBody =
|
||||
'휴가 중이라 메일을 늦게 확인합니다. 급한 일은 담당자에게 연락해 주세요. '
|
||||
.repeat(14)
|
||||
.slice(0, 600);
|
||||
const html = `<div style="font-family: 'Malgun Gothic', sans-serif">${textBody
|
||||
.split('. ')
|
||||
.map(
|
||||
(sentence) =>
|
||||
`<p style="margin: 0"><strong>${sentence}.</strong> <a href="https://example.com/">담당자</a></p>`,
|
||||
)
|
||||
.join('')}</div>`;
|
||||
const built = buildFilterScript(
|
||||
{
|
||||
rules: [],
|
||||
vacation: {
|
||||
...vacationOn,
|
||||
textBody,
|
||||
htmlBody: html,
|
||||
external: {
|
||||
mode: 'separate',
|
||||
subject: 'Away',
|
||||
textBody,
|
||||
htmlBody: html,
|
||||
},
|
||||
},
|
||||
blocked: [],
|
||||
},
|
||||
{ internalDomain: DOMAIN },
|
||||
);
|
||||
const variables = variablesOf(built.text);
|
||||
|
||||
expect(linesOf(built.text)[2]).toBe(
|
||||
'require ["date", "envelope", "relational", "vacation", "variables"];',
|
||||
);
|
||||
// 사내 · 회사 밖 모두 나뉘었다 — 변수 이름은 스크립트 안에서 겹치지 않는다
|
||||
expect(variables.size).toBeGreaterThanOrEqual(4);
|
||||
expect([...variables.keys()]).toEqual(
|
||||
[...variables.keys()].map((_name, index) => `part${index}`),
|
||||
);
|
||||
for (const piece of variables.values()) {
|
||||
expect(Buffer.byteLength(piece, 'utf8')).toBeLessThanOrEqual(3_000);
|
||||
// 줄 끝(CRLF) 뒤에서만 끊는다 — 따옴표 문자열에 홀로 있는 CR · LF 가 없다
|
||||
expect(piece.endsWith('\r\n')).toBe(true);
|
||||
expect(piece.replace(/\r\n/g, '')).not.toMatch(/[\r\n]/);
|
||||
}
|
||||
const [internal, external] = vacationCommandsOf(built.text);
|
||||
expect(internal.entity).toBe(alternativeMimeEntity(textBody, html));
|
||||
expect(external.entity).toBe(alternativeMimeEntity(textBody, html));
|
||||
expect(built.longestStringBytes).toBeLessThanOrEqual(
|
||||
SIEVE_STRING_MAX_BYTES,
|
||||
);
|
||||
// 사용자 글의 ${ 는 Sieve 에 없다 — 우리 변수만 펼친다
|
||||
expect(
|
||||
[...built.text.matchAll(/\$\{([^}]*)\}/g)].map(([, name]) => name),
|
||||
).toEqual([...variables.keys()].map((name) => name));
|
||||
});
|
||||
|
||||
it('문자열 하나 한도 안이면 지금처럼 한 문자열 — 글만인 응답(한글 600자)은 나누지 않는다', () => {
|
||||
const built = buildFilterScript({
|
||||
rules: [],
|
||||
vacation: { ...vacationOn, textBody: '가'.repeat(600) },
|
||||
blocked: [],
|
||||
});
|
||||
expect(built.text).not.toContain('variables');
|
||||
expect(built.text).not.toContain('set "');
|
||||
});
|
||||
|
||||
it('제목 · 내용의 ${ · 따옴표 · 역슬래시는 Sieve 글에 그대로 나오지 않는다 — encoded-word · base64 (원본 주석에만)', () => {
|
||||
const { text } = buildFilterScript({
|
||||
rules: [],
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { createHash } from 'crypto';
|
||||
|
||||
import type {
|
||||
BlockedSender,
|
||||
MailFilterCondition,
|
||||
@@ -7,7 +9,11 @@ import type {
|
||||
} from '@/modules/mail/mail.types';
|
||||
import { filterSourceLine, type FilterScriptSource } from './filter-source';
|
||||
import { toJmapUtcDate } from './jmap.mapper';
|
||||
import { encodeHeaderWords, plainTextMimeEntity } from './mime-text';
|
||||
import {
|
||||
alternativeMimeEntity,
|
||||
encodeHeaderWords,
|
||||
plainTextMimeEntity,
|
||||
} from './mime-text';
|
||||
|
||||
/**
|
||||
* 메일 필터 · 부재중 자동 응답 → 엔진 규칙(Sieve 스크립트, RFC 5228) — 순수 함수. 계약 2절 "만드는 Sieve" (Stalwart 0.16.27, 2026-10-28 확인한 모양):
|
||||
@@ -22,10 +28,17 @@ import { encodeHeaderWords, plainTextMimeEntity } from './mime-text';
|
||||
* }
|
||||
* if not header :matches "X-Spam-Score" "spam*" { ← 엔진이 스팸으로 분류한 메일에는 아무것도 하지 않는다
|
||||
* 부재중 — 켜져 있으면 (기간은 currentdate, UTC)
|
||||
* 회사 밖 응답이 사내와 같으면 vacation 하나, 아니면(16단계) if envelope :domain :is "from" "<회사 도메인>" { 사내 } else { 회사 밖 } — 보내지 않기면 else 없음
|
||||
* 규칙 — 켠 것만 위에서부터: if <조건> { addflag → redirect :copy → fileinto :mailboxid + stop }
|
||||
* }
|
||||
* ```
|
||||
*
|
||||
* - 부재중의 사내 · 회사 밖은 **봉투 보낸 사람(return-path)의 도메인**으로 가른다(16단계) — 답장도 봉투 주소로만 가므로(엔진 확인 — engine-facts E2)
|
||||
* 머리글 From 을 사내로 꾸며도 사내용 글은 그 봉투 주소(회사 밖)로 가지 않는다. 도메인은 그 도메인만(`:is` — 하위 도메인은 회사 밖)
|
||||
* - 부재중 응답의 본문은 MIME 개체 하나(서식이 있으면 multipart/alternative — 글 + 서식, 각 base64)다. 엔진은 Sieve 문자열 하나를 4,096바이트로 막으므로
|
||||
* 그보다 길면 `variables` 로 3,000바이트 이하의 조각에 나눠 담고(`set "part0" "…";`) `vacation :mime "${part0}${part1}…"` 로 잇는다
|
||||
* (엔진 확인 — 이은 답장의 글 · 서식이 원래와 바이트까지 같다). 조각은 줄 끝(CRLF) 뒤에서만 끊는다 — 따옴표 문자열에 홀로 있는 CR · LF 는 문법 밖이다
|
||||
*
|
||||
* - 차단: 스팸 막기 감싸기 밖 · 부재중 앞 — 차단한 보낸 사람(머리글 From 의 주소 · 도메인, 대소문자 무시)의 메일은 스팸메일함으로 가고 멈춘다.
|
||||
* 필터는 돌지 않고 부재중 응답도 나가지 않는다. 도메인은 그 도메인만(`:domain :is` — 하위 도메인은 따로). 스팸메일함은 id 로 가리키고,
|
||||
* id 가 없으면 그 이름으로 들어간다(`:mailboxid` 의 대체 이름) — 엔진이 그대로 판정하는지 실제 배달로 맞췄다(계약 2절)
|
||||
@@ -90,6 +103,28 @@ export interface JunkMailboxTarget {
|
||||
export interface FilterScriptOptions {
|
||||
/** 차단한 보낸 사람이 있으면 있어야 한다 — 없으면 만들지 않는다(던진다) */
|
||||
readonly junkMailbox?: JunkMailboxTarget | null;
|
||||
/**
|
||||
* 회사 메일 도메인(`MAIL_DOMAIN`, 소문자) — 켠 부재중의 회사 밖 응답이 사내와 같지 않으면(16단계) 있어야 한다. 없으면 만들지 않는다(던진다)
|
||||
*/
|
||||
readonly internalDomain?: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 부재중 응답 MIME 개체를 나눌 조각 크기 (UTF-8 바이트) — 엔진의 문자열 하나 한도(SIEVE_STRING_MAX_BYTES)보다 넉넉히 작게.
|
||||
* 2026-11-10 개발 엔진: 3,000바이트 조각을 이은 응답이 원래와 바이트까지 같았다 (engine-facts E2)
|
||||
*/
|
||||
const VACATION_CHUNK_BYTES = 3_000;
|
||||
|
||||
/** 회사 밖 응답의 :handle 앞부분 — 뒤에 응답 내용의 지문을 붙인다(내용이 바뀌면 같은 사람에게도 새 응답을 다시 보낸다 — 엔진 기본과 같은 결) */
|
||||
const EXTERNAL_REPLY_HANDLE_PREFIX = 'ara-external-';
|
||||
/** :handle 지문의 길이 (base64url 글자) */
|
||||
const REPLY_HANDLE_HASH_LENGTH = 16;
|
||||
|
||||
/** 부재중 응답 하나 — 제목 · 보이는 글 · 서식(없으면 null) */
|
||||
interface VacationReply {
|
||||
readonly subject: string;
|
||||
readonly textBody: string;
|
||||
readonly htmlBody: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -108,23 +143,27 @@ type SieveExtension =
|
||||
| 'body'
|
||||
| 'copy'
|
||||
| 'date'
|
||||
| 'envelope'
|
||||
| 'fileinto'
|
||||
| 'imap4flags'
|
||||
| 'mailbox'
|
||||
| 'mailboxid'
|
||||
| 'relational'
|
||||
| 'vacation';
|
||||
| 'vacation'
|
||||
| 'variables';
|
||||
|
||||
const EXTENSION_ORDER: readonly SieveExtension[] = [
|
||||
'body',
|
||||
'copy',
|
||||
'date',
|
||||
'envelope',
|
||||
'fileinto',
|
||||
'imap4flags',
|
||||
'mailbox',
|
||||
'mailboxid',
|
||||
'relational',
|
||||
'vacation',
|
||||
'variables',
|
||||
];
|
||||
|
||||
/** 만든 스크립트 */
|
||||
@@ -136,14 +175,20 @@ export interface BuiltFilterScript {
|
||||
readonly longestStringBytes: number;
|
||||
}
|
||||
|
||||
/** 스크립트를 만드는 동안 쓴 확장 · 가장 긴 문자열을 모은다 */
|
||||
/** Sieve 따옴표 문자열 — `\` 와 `"` 만 이스케이프한다 (RFC 5228 §2.4.2) */
|
||||
function sieveQuoted(value: string): string {
|
||||
return `"${value.replace(/[\\"]/g, '\\$&')}"`;
|
||||
}
|
||||
|
||||
/** 스크립트를 만드는 동안 쓴 확장 · 가장 긴 문자열 · 변수 이름을 모은다 */
|
||||
class ScriptWriter {
|
||||
readonly extensions = new Set<SieveExtension>();
|
||||
longestStringBytes = 0;
|
||||
private variables = 0;
|
||||
|
||||
/** Sieve 따옴표 문자열 — `\` 와 `"` 만 이스케이프한다 (RFC 5228 §2.4.2) */
|
||||
quote(value: string): string {
|
||||
const quoted = `"${value.replace(/[\\"]/g, '\\$&')}"`;
|
||||
const quoted = sieveQuoted(value);
|
||||
this.longestStringBytes = Math.max(
|
||||
this.longestStringBytes,
|
||||
Buffer.byteLength(quoted, 'utf8'),
|
||||
@@ -154,6 +199,13 @@ class ScriptWriter {
|
||||
use(...extensions: SieveExtension[]): void {
|
||||
for (const extension of extensions) this.extensions.add(extension);
|
||||
}
|
||||
|
||||
/** 조각을 담을 새 변수 이름 — 스크립트 안에서 겹치지 않게 차례로 (part0, part1, …) */
|
||||
nextVariable(): string {
|
||||
const name = `part${this.variables}`;
|
||||
this.variables += 1;
|
||||
return name;
|
||||
}
|
||||
}
|
||||
|
||||
function indent(depth: number, line: string): string {
|
||||
@@ -219,20 +271,106 @@ function ruleBlock(
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* MIME 개체를 줄 끝(CRLF) 뒤에서 끊어 maxBytes 이하의 조각으로 — 이으면 원래 글이다. 한 줄이 maxBytes 보다 길면 그 줄만 글자 경계에서 자른다
|
||||
* (우리 개체는 ASCII — 머리 · base64 줄은 76자라 그럴 일이 없다)
|
||||
*/
|
||||
function splitAtLineEnds(text: string, maxBytes: number): string[] {
|
||||
const pieces: string[] = [];
|
||||
let current = '';
|
||||
let currentBytes = 0;
|
||||
for (const line of text.match(/[^\n]*\n|[^\n]+$/g) ?? []) {
|
||||
const lineBytes = Buffer.byteLength(line, 'utf8');
|
||||
if (currentBytes > 0 && currentBytes + lineBytes > maxBytes) {
|
||||
pieces.push(current);
|
||||
current = '';
|
||||
currentBytes = 0;
|
||||
}
|
||||
if (lineBytes > maxBytes) {
|
||||
for (const character of line) {
|
||||
const characterBytes = Buffer.byteLength(character, 'utf8');
|
||||
if (currentBytes + characterBytes > maxBytes) {
|
||||
pieces.push(current);
|
||||
current = '';
|
||||
currentBytes = 0;
|
||||
}
|
||||
current += character;
|
||||
currentBytes += characterBytes;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
current += line;
|
||||
currentBytes += lineBytes;
|
||||
}
|
||||
if (current !== '') pieces.push(current);
|
||||
return pieces;
|
||||
}
|
||||
|
||||
/** 회사 밖 응답의 :handle — 응답 내용(제목 · MIME 개체)의 지문. 사내 응답과 늘 다르다 */
|
||||
function externalReplyHandle(subject: string, entity: string): string {
|
||||
const digest = createHash('sha256')
|
||||
.update(`${subject}\n${entity}`, 'utf8')
|
||||
.digest('base64url')
|
||||
.slice(0, REPLY_HANDLE_HASH_LENGTH);
|
||||
return `${EXTERNAL_REPLY_HANDLE_PREFIX}${digest}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 응답 하나 — `vacation :mime [:handle "…"] [:subject "…"] "<MIME 개체>";`. 서식이 있으면 글 + 서식 두 벌(multipart/alternative), 없으면 글만(지금과 같다).
|
||||
* 개체가 문자열 하나 한도를 넘으면 앞에 조각 `set` 들을 두고 `"${part0}${part1}…"` 로 잇는다(variables).
|
||||
* 제목이 비었으면(VacationResponse 에서 가져온 값 등) `:subject` 를 빼 엔진이 정하게 둔다
|
||||
*/
|
||||
function vacationReplyLines(
|
||||
writer: ScriptWriter,
|
||||
reply: VacationReply,
|
||||
side: 'internal' | 'external',
|
||||
): string[] {
|
||||
writer.use('vacation');
|
||||
const subject = encodeHeaderWords(reply.subject);
|
||||
const entity =
|
||||
reply.htmlBody === null
|
||||
? plainTextMimeEntity(reply.textBody)
|
||||
: alternativeMimeEntity(reply.textBody, reply.htmlBody);
|
||||
|
||||
const lines: string[] = [];
|
||||
let argument: string;
|
||||
if (
|
||||
Buffer.byteLength(sieveQuoted(entity), 'utf8') <= SIEVE_STRING_MAX_BYTES
|
||||
) {
|
||||
argument = writer.quote(entity);
|
||||
} else {
|
||||
writer.use('variables');
|
||||
const names = splitAtLineEnds(entity, VACATION_CHUNK_BYTES).map((piece) => {
|
||||
const name = writer.nextVariable();
|
||||
lines.push(`set ${writer.quote(name)} ${writer.quote(piece)};`);
|
||||
return name;
|
||||
});
|
||||
argument = writer.quote(names.map((name) => `\${${name}}`).join(''));
|
||||
}
|
||||
// 사내 응답은 :handle 을 쓰지 않는다(엔진 기본 — 15단계까지와 같은 글이라 고르기를 바꿔도 같은 사람에게 다시 보내지 않는다)
|
||||
const handle =
|
||||
side === 'external'
|
||||
? `:handle ${writer.quote(externalReplyHandle(subject, entity))} `
|
||||
: '';
|
||||
lines.push(
|
||||
`vacation :mime ${handle}${subject ? `:subject ${writer.quote(subject)} ` : ''}${argument};`,
|
||||
);
|
||||
return lines;
|
||||
}
|
||||
|
||||
/**
|
||||
* 부재중 — 엔진이 VacationResponse 로 만들던 모양에 시간대만 더했다: 기간은 currentdate(시작 ge · 끝 le — 끝 시각 그 1초에도 답한다, 지금과 같다),
|
||||
* 견주는 시각은 UTC(`:zone "+0000"` — 기간 값도 UTC 다), `vacation :mime` 에 base64 본문, 제목은 encoded-word.
|
||||
* `:days` · `:handle` 은 쓰지 않는다(엔진 기본값 — 같은 사람에게 다시 답하는 간격).
|
||||
* 제목이 비었으면(VacationResponse 에서 가져온 값 등) `:subject` 를 빼 엔진이 정하게 둔다
|
||||
* `:days` 는 쓰지 않는다(엔진 기본값 — 같은 사람에게 다시 답하는 간격).
|
||||
* 16단계 — 회사 밖 응답이 사내와 같지 않으면 봉투 보낸 사람의 도메인으로 가른다: 회사 도메인이면 사내 응답, 아니면 따로 쓴 응답(separate) · 없음(none).
|
||||
* 회사 밖 응답은 `:handle` 을 따로 둔다(사내 응답과 같은 사람에게 다시 보내는 간격을 따로 센다)
|
||||
*/
|
||||
function vacationBlock(
|
||||
writer: ScriptWriter,
|
||||
vacation: MailVacation,
|
||||
depth: number,
|
||||
internalDomain: string | null,
|
||||
): string[] {
|
||||
writer.use('vacation');
|
||||
const subject = encodeHeaderWords(vacation.subject);
|
||||
const command = `vacation :mime ${subject ? `:subject ${writer.quote(subject)} ` : ''}${writer.quote(plainTextMimeEntity(vacation.textBody))};`;
|
||||
/** 기간 검사 하나 — 지금 시각(UTC)을 그 시각과 견준다 */
|
||||
const period = (relation: 'ge' | 'le', iso: string) =>
|
||||
`currentdate :zone ${writer.quote(VACATION_PERIOD_ZONE)} :value "${relation}" "iso8601" ${writer.quote(toJmapUtcDate(iso))}`;
|
||||
@@ -243,10 +381,54 @@ function vacationBlock(
|
||||
];
|
||||
if (periods.length > 0) writer.use('date', 'relational');
|
||||
|
||||
const inner = depth + periods.length;
|
||||
const internalReply: VacationReply = {
|
||||
subject: vacation.subject,
|
||||
textBody: vacation.textBody,
|
||||
htmlBody: vacation.htmlBody,
|
||||
};
|
||||
const { external } = vacation;
|
||||
const replies: string[] = [];
|
||||
if (external.mode === 'same') {
|
||||
replies.push(
|
||||
...vacationReplyLines(writer, internalReply, 'internal').map((line) =>
|
||||
indent(inner, line),
|
||||
),
|
||||
);
|
||||
} else {
|
||||
if (!internalDomain) {
|
||||
throw new Error('회사 밖 응답을 가르려면 회사 메일 도메인이 있어야 한다');
|
||||
}
|
||||
writer.use('envelope');
|
||||
replies.push(
|
||||
indent(
|
||||
inner,
|
||||
`if envelope :domain :is "from" ${writer.quote(internalDomain)} {`,
|
||||
),
|
||||
...vacationReplyLines(writer, internalReply, 'internal').map((line) =>
|
||||
indent(inner + 1, line),
|
||||
),
|
||||
);
|
||||
if (external.mode === 'separate') {
|
||||
const externalReply: VacationReply = {
|
||||
subject: external.subject ?? '',
|
||||
textBody: external.textBody ?? '',
|
||||
htmlBody: external.htmlBody,
|
||||
};
|
||||
replies.push(
|
||||
indent(inner, '} else {'),
|
||||
...vacationReplyLines(writer, externalReply, 'external').map((line) =>
|
||||
indent(inner + 1, line),
|
||||
),
|
||||
);
|
||||
}
|
||||
replies.push(indent(inner, '}'));
|
||||
}
|
||||
|
||||
const lines = periods.map((test, level) =>
|
||||
indent(depth + level, `if ${test} {`),
|
||||
);
|
||||
lines.push(indent(depth + periods.length, command));
|
||||
lines.push(...replies);
|
||||
for (let level = periods.length - 1; level >= 0; level -= 1) {
|
||||
lines.push(indent(depth + level, '}'));
|
||||
}
|
||||
@@ -295,7 +477,8 @@ function blockSection(
|
||||
|
||||
/**
|
||||
* 규칙 · 부재중 · 차단 원본으로 엔진 규칙 글을 만든다 — 크기 한도는 부르는 쪽이 본다(FILTER_SCRIPT_MAX_BYTES · SIEVE_STRING_MAX_BYTES).
|
||||
* 차단한 보낸 사람이 있는데 스팸메일함을 받지 못하면 던진다 (어댑터가 늘 넘긴다 — 없으면 그 앞에서 MAIL_001)
|
||||
* 차단한 보낸 사람이 있는데 스팸메일함을 받지 못하면 던진다 (어댑터가 늘 넘긴다 — 없으면 그 앞에서 MAIL_001).
|
||||
* 켠 부재중의 회사 밖 응답이 사내와 같지 않은데 회사 메일 도메인을 받지 못해도 던진다 (어댑터가 늘 넘긴다)
|
||||
*/
|
||||
export function buildFilterScript(
|
||||
source: FilterScriptSource,
|
||||
@@ -310,7 +493,14 @@ export function buildFilterScript(
|
||||
}
|
||||
const body: string[] = [];
|
||||
if (source.vacation.isEnabled) {
|
||||
body.push(...vacationBlock(writer, source.vacation, 1));
|
||||
body.push(
|
||||
...vacationBlock(
|
||||
writer,
|
||||
source.vacation,
|
||||
1,
|
||||
options.internalDomain ?? null,
|
||||
),
|
||||
);
|
||||
}
|
||||
for (const rule of source.rules) {
|
||||
if (rule.isEnabled) body.push(...ruleBlock(writer, rule, 1));
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import { asciiCasemap, isBlockedSenderMail } from './mail-block-match';
|
||||
|
||||
/**
|
||||
* 차단할 때 받은편지함 정리(15단계)의 판정 — 새로 오는 메일의 차단 규칙(jmap/sieve-script.ts 의 blockSection — Sieve `address :all :is` ·
|
||||
* `address :domain :is`, `i;ascii-casemap`)과 같은지. 표의 "엔진이 넓게 잡은 것"은 2026-11-09 개발 엔진의 `Email/query` from 조건이 잡은 메일이다(phase15 E3)
|
||||
*/
|
||||
describe('차단 정리의 판정 (mail-block-match — Sieve 의 address 와 짝)', () => {
|
||||
it('ASCII 글자만 소문자로 — 그 밖의 글자는 그대로 (i;ascii-casemap)', () => {
|
||||
expect(asciiCasemap('Kim@Spam.EXAMPLE')).toBe('kim@spam.example');
|
||||
expect(asciiCasemap('ÄBC@Ü.example')).toBe('Äbc@Ü.example');
|
||||
});
|
||||
|
||||
it.each<[string, string[], boolean]>([
|
||||
['정확한 주소', ['kim@spam.example'], true],
|
||||
['대문자 주소', ['KIM@SPAM.EXAMPLE'], true],
|
||||
[
|
||||
'보낸 사람 둘 가운데 둘째',
|
||||
['first@example.net', 'kim@spam.example'],
|
||||
true,
|
||||
],
|
||||
// 엔진 검색(from)은 잡지만 차단 규칙은 맞지 않는 것
|
||||
['점이 더 붙은 주소', ['kim.lee@spam.example'], false],
|
||||
['하위 도메인', ['kim@sub.spam.example'], false],
|
||||
['더 긴 도메인', ['kim@spam.example.example.net'], false],
|
||||
['앞에 글자가 더', ['akim@spam.example'], false],
|
||||
['다른 도메인', ['kim@spam.example.net'], false],
|
||||
['From 이 없음', [], false],
|
||||
])('주소 차단 — %s', (_label, addresses, expected) => {
|
||||
expect(isBlockedSenderMail(addresses, 'kim@spam.example')).toBe(expected);
|
||||
});
|
||||
|
||||
it('주소 앞부분의 ASCII 밖 글자는 대소문자를 가린다 — 엔진 규칙과 같은 한계', () => {
|
||||
expect(isBlockedSenderMail(['ü@spam.example'], 'ü@spam.example')).toBe(
|
||||
true,
|
||||
);
|
||||
expect(isBlockedSenderMail(['Ü@spam.example'], 'ü@spam.example')).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it.each<[string, string[], boolean]>([
|
||||
['그 도메인', ['kim@spam.example'], true],
|
||||
['대문자 도메인', ['Lee@SPAM.Example'], true],
|
||||
['둘째 보낸 사람이 그 도메인', ['a@example.net', 'b@spam.example'], true],
|
||||
['하위 도메인', ['kim@sub.spam.example'], false],
|
||||
['더 긴 도메인', ['kim@spam.example.net'], false],
|
||||
['도메인이 이름 끝에만', ['kim@notspam.example'], false],
|
||||
['@ 가 없는 주소', ['spam.example'], false],
|
||||
])('도메인 차단 — %s', (_label, addresses, expected) => {
|
||||
expect(isBlockedSenderMail(addresses, '@spam.example')).toBe(expected);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,40 @@
|
||||
/**
|
||||
* 차단할 때 받은편지함 정리(계약 2절 "보낸 사람 차단" — 15단계)의 판정 — 순수 함수.
|
||||
* **짝: jmap/sieve-script.ts 의 blockSection**(새로 오는 메일의 차단 규칙) — `address :all :is "from" [주소들]` ·
|
||||
* `address :domain :is "from" [도메인들]`, 비교는 엔진 기본 `i;ascii-casemap`(ASCII 대소문자만 무시). 한쪽을 바꾸면 다른 쪽도.
|
||||
*
|
||||
* 견주는 것은 엔진이 푼 From 머리글의 주소다(같은 이름의 머리글이 여럿이면 모두 — 표시 이름은 보지 않는다, jmap/filter-run-texts.ts 의 fromAddressesOf).
|
||||
* 엔진의 검색(`Email/query` 의 from)은 쓰지 않는다 — 낱말 단위라 비슷한 주소(`kim.lee@` · `@sub.` · `.example.net`)와
|
||||
* 표시 이름에만 그 주소를 쓴 남의 메일까지 잡는다(2026-11-09 개발 엔진 확인)
|
||||
*/
|
||||
|
||||
/** ASCII 글자만 소문자로 — 엔진의 `i;ascii-casemap` (그 밖의 글자는 그대로 견준다) */
|
||||
export function asciiCasemap(value: string): string {
|
||||
return value.replace(/[A-Z]/g, (char) => char.toLowerCase());
|
||||
}
|
||||
|
||||
/** 주소의 도메인 — 마지막 `@` 뒤 (없으면 빈 글) */
|
||||
function domainOf(address: string): string {
|
||||
const at = address.lastIndexOf('@');
|
||||
return at < 0 ? '' : address.slice(at + 1);
|
||||
}
|
||||
|
||||
/**
|
||||
* 그 메일의 From 주소 가운데 하나라도 차단 대상에 맞는가 — 주소 차단(`kim@spam.example`)은 주소 전체가 같을 때,
|
||||
* 도메인 차단(`@spam.example`)은 주소의 도메인이 같을 때(하위 도메인 · 더 긴 도메인은 아님)
|
||||
* @param fromAddresses 엔진이 푼 From 머리글의 주소들
|
||||
* @param sender 차단 목록의 값 (소문자 주소 또는 `@도메인` — BlockedSender.address)
|
||||
*/
|
||||
export function isBlockedSenderMail(
|
||||
fromAddresses: readonly string[],
|
||||
sender: string,
|
||||
): boolean {
|
||||
const wanted = asciiCasemap(sender);
|
||||
if (wanted.startsWith('@')) {
|
||||
const domain = wanted.slice(1);
|
||||
return fromAddresses.some(
|
||||
(address) => asciiCasemap(domainOf(address)) === domain,
|
||||
);
|
||||
}
|
||||
return fromAddresses.some((address) => asciiCasemap(address) === wanted);
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
Param,
|
||||
Post,
|
||||
@@ -28,17 +29,23 @@ import {
|
||||
MailBlockListDto,
|
||||
MailBlockRemovedDto,
|
||||
} from './dto/mail-block.dto';
|
||||
import {
|
||||
MailBlockRunDto,
|
||||
MailBlockRunRequestDto,
|
||||
} from './dto/mail-block-run.dto';
|
||||
import { MailBlockService } from './mail-block.service';
|
||||
import {
|
||||
MAIL_BLOCK_RUNS_ROUTE,
|
||||
MAIL_BLOCKS_ROUTE,
|
||||
MAX_EMAIL_LENGTH,
|
||||
MAX_MAIL_BLOCKS,
|
||||
} from './mail.constants';
|
||||
import { MailService } from './mail.service';
|
||||
|
||||
/** 차단의 효과 — 세 엔드포인트의 설명에 함께 쓴다 (계약 2절 "보낸 사람 차단") */
|
||||
const BLOCK_NOTE =
|
||||
'차단한 보낸 사람(머리글 From 의 주소 · 도메인 — 대소문자 무시)의 메일은 스팸메일함으로 간다. 필터 · 부재중보다 먼저 돌고 멈추므로 필터는 돌지 않고 부재중 응답도 나가지 않는다. ' +
|
||||
'이미 받은 메일은 옮기지 않는다. 저장 자리는 필터 · 부재중과 같은 엔진 규칙(Sieve 스크립트)이다 — 필터의 version 은 묻지 않는다.';
|
||||
'이미 받은 메일은 저절로 옮기지 않는다 — 받은편지함에 있는 것은 POST /api/mail-block-runs 로 옮긴다(15단계). 저장 자리는 필터 · 부재중과 같은 엔진 규칙(Sieve 스크립트)이다 — 필터의 version 은 묻지 않는다.';
|
||||
|
||||
// 보낸 사람 차단 (계약 2절 "보낸 사람 차단", 9단계)
|
||||
@ApiTags('Mail')
|
||||
@@ -109,3 +116,52 @@ export class MailBlockController {
|
||||
return this.blocks.remove(user, address);
|
||||
}
|
||||
}
|
||||
|
||||
// 차단할 때 받은편지함 정리 (계약 2절 "보낸 사람 차단", 15단계) — 이미 받은 메일에 필터 적용(7단계)과 같은 틀 · 같은 잠금
|
||||
@ApiTags('Mail')
|
||||
@ApiCookieAuth()
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ApiSessionErrors('로그인 필요 (쿠키 없음·만료)')
|
||||
@Controller(MAIL_BLOCK_RUNS_ROUTE)
|
||||
export class MailBlockRunController {
|
||||
constructor(private readonly mail: MailService) {}
|
||||
|
||||
@Post()
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({
|
||||
summary: '차단한 보낸 사람의 메일을 받은편지함에서 스팸메일함으로',
|
||||
description:
|
||||
'받은편지함에 이미 있는 그 보낸 사람의 메일을 스팸메일함으로 옮기고 스팸 표시를 붙인다($junk — 스팸 신고와 같다). 받은편지함만 본다(정리해 둔 메일함은 건드리지 않는다). ' +
|
||||
'판정은 새로 오는 메일의 차단 규칙과 같다 — 주소는 머리글 From 의 주소 가운데 하나가 같을 때, 도메인은 From 주소의 @ 뒤가 그 도메인과 같을 때(하위 도메인은 아님), 대소문자는 ASCII 만 무시. ' +
|
||||
'표시 이름에 쓴 주소 · 비슷한 주소는 맞지 않는다. 보내기를 기다리는 메일은 옮기지 않는다. 되돌리기는 없다(스팸메일함에서 스팸 아님으로). ' +
|
||||
'받은 시각이 최근인 메일부터, 첫 요청 뒤에 온 메일은 보지 않는다. 한 요청은 25초 안에 끝내고 cursor 를 돌려준다 — null 이 될 때까지 같은 본문에 받은 cursor 를 넣어 다시 보낸다. ' +
|
||||
'같은 직원의 받은편지함 정리(이것 · POST /api/mail-filter-runs)는 한 번에 하나다 — 진행 중이면 엔진에 묻지 않고 429 REQ_001. ' +
|
||||
'요청이 도는 동안 그 직원의 스팸메일함을 잡는다 — 그동안 스팸메일함 비우기 · 전체 읽음은 429, 비우는 중이면 이 요청이 메일함 목록을 읽은 뒤 429(받은편지함은 건드리지 않았다).',
|
||||
})
|
||||
@ApiStandardResponse(MailBlockRunDto, {
|
||||
description:
|
||||
'이번 요청에서 확인한 수 · 스팸메일함으로 옮긴 수 · 남은 수(어림) · 이어 할 자리(끝이면 null)',
|
||||
})
|
||||
@ApiErrorResponse(
|
||||
'VAL_001',
|
||||
`형식(메일 주소나 @도메인 · ${MAX_EMAIL_LENGTH}자) · 깨진 cursor · 다른 보낸 사람의 cursor · 모르는 필드 — 옮기지 않았다`,
|
||||
)
|
||||
@ApiErrorResponse(
|
||||
'RES_001',
|
||||
'차단 목록에 없는 보낸 사람(차단한 뒤에만 부른다 · 그사이 풀었다) — 옮기지 않았다',
|
||||
)
|
||||
@ApiErrorResponse(
|
||||
'REQ_001',
|
||||
'같은 직원의 받은편지함 정리(필터 적용 포함)가 진행 중(다른 탭 · PC) — 엔진에 묻지 않았다 · 스팸메일함 비우기가 진행 중 — 옮기지 않았다',
|
||||
)
|
||||
@ApiErrorResponse(
|
||||
'MAIL_001',
|
||||
'메일 엔진 장애 · 스팸메일함이 없음 · 하나도 확인하지 못하고 멈춤(시간 · 확인하는 사이 받은편지함이 계속 바뀜). 도중의 엔진 장애면 일부를 옮겼을 수 있다 — 마지막으로 받은 cursor 로 다시 보내면 된다',
|
||||
)
|
||||
run(
|
||||
@CurrentUser() user: AuthenticatedUser,
|
||||
@Body() dto: MailBlockRunRequestDto,
|
||||
) {
|
||||
return this.mail.runMailBlock(user, dto);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,19 +6,14 @@ import { traceTag } from '@/shared/logger/request-context';
|
||||
import { maskEmail } from '@/shared/utils/mask';
|
||||
import type { MailBlockCreateDto } from './dto/mail-block.dto';
|
||||
import {
|
||||
describeBlockTarget,
|
||||
findBlockProblem,
|
||||
parseBlockTarget,
|
||||
type BlockTarget,
|
||||
} from './mail-blocks';
|
||||
import { MAIL_PROVIDER, type MailProvider } from './mail.provider';
|
||||
import { MAIL_SETTINGS, type MailSettings } from './mail.settings';
|
||||
import type { MailBlockAdded, MailBlockList } from './mail.types';
|
||||
|
||||
/** 로그에 남기는 차단 대상 — 주소는 가리고(maskEmail), 도메인 차단은 도메인만 (계약 2절 "보낸 사람 차단"의 로그) */
|
||||
function maskedTarget(target: BlockTarget): string {
|
||||
return target.kind === 'domain' ? target.value : maskEmail(target.value);
|
||||
}
|
||||
|
||||
/**
|
||||
* 보낸 사람 차단 (계약 2절 "보낸 사람 차단" — 9단계) — 엔진과 무관한 규칙(막을 수 없는 대상)은 여기서, 저장(엔진 규칙)은 포트가 한다.
|
||||
* - 모양은 요청 검증이 봤다 — 여기서는 내 주소 · 사내 도메인(MAIL_DOMAIN 과 그 하위) · 여러 사람이 쓰는 메일 서비스의 도메인을 막는다(VAL_001)
|
||||
@@ -65,7 +60,7 @@ export class MailBlockService {
|
||||
});
|
||||
if (result.created) {
|
||||
this.logger.log(
|
||||
`${traceTag()} 보낸 사람을 차단했습니다: user=${maskEmail(user.email)}, ${target.kind === 'domain' ? '도메인' : '주소'}=${maskedTarget(target)}`,
|
||||
`${traceTag()} 보낸 사람을 차단했습니다: user=${maskEmail(user.email)}, ${describeBlockTarget(target)}`,
|
||||
);
|
||||
}
|
||||
return result;
|
||||
@@ -84,7 +79,7 @@ export class MailBlockService {
|
||||
}
|
||||
await this.provider.removeMailBlock(user.email, target.value);
|
||||
this.logger.log(
|
||||
`${traceTag()} 보낸 사람 차단을 풀었습니다: user=${maskEmail(user.email)}, ${target.kind === 'domain' ? '도메인' : '주소'}=${maskedTarget(target)}`,
|
||||
`${traceTag()} 보낸 사람 차단을 풀었습니다: user=${maskEmail(user.email)}, ${describeBlockTarget(target)}`,
|
||||
);
|
||||
return { address: target.value };
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { isEmail } from 'class-validator';
|
||||
import { isIP } from 'net';
|
||||
|
||||
import { maskEmail } from '@/shared/utils/mask';
|
||||
import {
|
||||
MAX_EMAIL_LENGTH,
|
||||
PUBLIC_MAIL_DOMAINS,
|
||||
@@ -88,6 +89,16 @@ export function blockTargetDomain(target: BlockTarget): string {
|
||||
return target.value.slice(target.value.lastIndexOf('@') + 1);
|
||||
}
|
||||
|
||||
/**
|
||||
* 로그에 남기는 차단 대상 — `주소=k***@spam.example` · `도메인=@spam.example` (계약 2절 "보낸 사람 차단"의 로그 — 주소는 가리고(maskEmail),
|
||||
* 도메인 차단은 도메인만). 차단 · 풀기 · 받은편지함 정리(15단계)가 같이 쓴다
|
||||
*/
|
||||
export function describeBlockTarget(target: BlockTarget): string {
|
||||
return target.kind === 'domain'
|
||||
? `도메인=${target.value}`
|
||||
: `주소=${maskEmail(target.value)}`;
|
||||
}
|
||||
|
||||
/** 사내 도메인이거나 그 하위 도메인인가 */
|
||||
function isInternalDomain(domain: string, internal: string): boolean {
|
||||
return domain === internal || domain.endsWith(`.${internal}`);
|
||||
|
||||
@@ -147,6 +147,7 @@ function setup() {
|
||||
url: shareUrl(ORIGIN, TOKEN),
|
||||
}),
|
||||
markSent: jest.fn().mockResolvedValue(undefined),
|
||||
markReleased: jest.fn().mockResolvedValue(true),
|
||||
cancel: jest.fn().mockResolvedValue(undefined),
|
||||
attachRetry: jest.fn().mockResolvedValue(undefined),
|
||||
findBySentEmail: jest.fn().mockResolvedValue(null),
|
||||
@@ -389,6 +390,31 @@ describe('MailLargeFilesService — 메일과 대용량 첨부 (11단계)', () =
|
||||
FILE_B,
|
||||
]);
|
||||
});
|
||||
|
||||
it('예약 메일을 지금 보냈으면(15단계) 그 링크의 보낸 때를 앞당긴다 — 링크가 없거나 취소한 링크면 그대로, 실패해도 던지지 않는다', async () => {
|
||||
const { service, sharing } = setup();
|
||||
const now = new Date('2026-11-09T05:00:00.000Z');
|
||||
|
||||
sharing.findBySentEmail.mockResolvedValue(record());
|
||||
await service.onScheduledSendReleased(ARA, 'sent-1', now);
|
||||
expect(sharing.findBySentEmail).toHaveBeenCalledWith(ARA.email, 'sent-1');
|
||||
expect(sharing.markReleased).toHaveBeenCalledWith('link-1', now);
|
||||
|
||||
sharing.markReleased.mockClear();
|
||||
for (const found of [
|
||||
null,
|
||||
record({ canceledAt: new Date('2026-11-09T04:00:00.000Z') }),
|
||||
]) {
|
||||
sharing.findBySentEmail.mockResolvedValueOnce(found);
|
||||
await service.onScheduledSendReleased(ARA, 'sent-1', now);
|
||||
}
|
||||
expect(sharing.markReleased).not.toHaveBeenCalled();
|
||||
|
||||
sharing.findBySentEmail.mockRejectedValueOnce(new Error('db down'));
|
||||
await expect(
|
||||
service.onScheduledSendReleased(ARA, 'sent-1', now),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('읽기 — 덩이 알아보기', () => {
|
||||
|
||||
@@ -303,6 +303,29 @@ export class MailLargeFilesService implements OnModuleInit, OnModuleDestroy {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 예약 메일을 지금 보냈다(15단계 — 엔진에서 다시 제출한 뒤) — 그 발송이 만든 링크의 보낸 때를 실제로 보낸 때로 앞당긴다.
|
||||
* 끝나는 때는 그대로 둔다 — 보낸 메일의 덩이가 그 날짜(예약 시각 기준)를 이미 적었다(FileSharingService.markReleased). 링크가 없거나 취소한 링크면 그대로.
|
||||
* 실패해도 보내기는 된 것이다 — 경고만
|
||||
*/
|
||||
async onScheduledSendReleased(
|
||||
user: AuthenticatedUser,
|
||||
sentId: string,
|
||||
sentAt: Date,
|
||||
): Promise<void> {
|
||||
try {
|
||||
const record = await this.sharing.findBySentEmail(user.email, sentId);
|
||||
if (!record || record.link.canceledAt !== null) return;
|
||||
await this.sharing.markReleased(record.link.id, sentAt);
|
||||
} catch (error) {
|
||||
this.warn(
|
||||
'지금 보낸 예약 메일의 링크에 보낸 때를 적지 못했습니다',
|
||||
user,
|
||||
error,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── 임시 저장 ────────────────────────────────────────────────────────────
|
||||
|
||||
/** 저장할 파일 확인 — 내 것이면 올리는 중 · 위험 판정이어도 된다. 없음 · 남의 것 · 지움 · 기간 지남은 400 MAIL_016 */
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { MailVacationExternalMode } from './mail.types';
|
||||
|
||||
/**
|
||||
* 메일 API 한도·형식 상수 — 계약(docs/mail/api-contract.md)의 숫자를 한 곳에 모은다.
|
||||
*/
|
||||
@@ -83,6 +85,20 @@ export const MAX_VACATION_TEXT_LENGTH = 600;
|
||||
export const MAX_VACATION_SUBJECT_LENGTH_WHEN_OFF = 200;
|
||||
export const MAX_VACATION_TEXT_LENGTH_WHEN_OFF = 4_000;
|
||||
|
||||
/**
|
||||
* 부재중 응답의 서식 본문(16단계) 최대 길이 — 보내는 정화(그림 없음)를 거친 뒤의 JS 문자열 길이. 서명과 같은 한도(SIGNATURE_HTML_MAX_LENGTH).
|
||||
* 켤 때만 본다(회사 밖 서식 본문은 따로 쓰기일 때만) — 넘으면 400 VAL_001. 정화 전 길이 · 파서 비용은 늘 본다(html/sanitize-stored-html.ts).
|
||||
* 엔진에는 base64 로 들어가고 Sieve 문자열 한 개 한도(4,096바이트)를 넘으면 variables 조각으로 나눈다 (jmap/sieve-script.ts)
|
||||
*/
|
||||
export const MAX_VACATION_HTML_LENGTH = 10_000;
|
||||
|
||||
/** 회사 밖 응답의 고르기 (16단계 — 계약 2절 "부재중 자동 응답") */
|
||||
export const MAIL_VACATION_EXTERNAL_MODES = [
|
||||
'same',
|
||||
'none',
|
||||
'separate',
|
||||
] as const satisfies readonly MailVacationExternalMode[];
|
||||
|
||||
/**
|
||||
* 한 줄 글자 — 제어 문자(C0 · DEL · C1 — 줄바꿈 · 탭 포함)가 없다. 메일함 이름 · 부재중 자동 응답 제목이 쓴다 (계약 2절)
|
||||
*/
|
||||
@@ -151,6 +167,9 @@ export const SCHEDULED_SEND_MAX_LEAD_MS = 7 * 24 * 60 * 60_000;
|
||||
/** 보낸 사람 차단 라우트 경로 (`/api/mail-blocks`) — 계약 2절 "보낸 사람 차단" (9단계) */
|
||||
export const MAIL_BLOCKS_ROUTE = 'mail-blocks';
|
||||
|
||||
/** 차단할 때 받은편지함 정리 라우트 경로 (`/api/mail-block-runs`) — 계약 2절 "보낸 사람 차단" (15단계) */
|
||||
export const MAIL_BLOCK_RUNS_ROUTE = 'mail-block-runs';
|
||||
|
||||
/** 차단할 수 있는 주소 · 도메인 수 — 넘으면 400 VAL_001 (계약 2절 "보낸 사람 차단") */
|
||||
export const MAX_MAIL_BLOCKS = 500;
|
||||
|
||||
|
||||
@@ -34,7 +34,10 @@ 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 { MailBlockController } from './mail-block.controller';
|
||||
import {
|
||||
MailBlockController,
|
||||
MailBlockRunController,
|
||||
} from './mail-block.controller';
|
||||
import { MailBlockService } from './mail-block.service';
|
||||
import { MailEventsController } from './mail-events.controller';
|
||||
import {
|
||||
@@ -148,6 +151,8 @@ function attachmentUploadOptions(config: ConfigService): MulterModuleOptions {
|
||||
MailFilterController,
|
||||
MailFilterRunController,
|
||||
MailBlockController,
|
||||
// 차단할 때 받은편지함 정리 (15단계 — 필터 적용과 같은 잠금)
|
||||
MailBlockRunController,
|
||||
MessageController,
|
||||
// 같은 대화 · 대화로 묶은 목록 (14단계)
|
||||
ConversationController,
|
||||
|
||||
@@ -13,9 +13,12 @@ import type {
|
||||
MailAttachment,
|
||||
MailBlockAdded,
|
||||
MailBlockList,
|
||||
MailBlockRunRequest,
|
||||
Mailbox,
|
||||
MailboxBulkResult,
|
||||
MailboxChange,
|
||||
MailboxCreate,
|
||||
MailboxDeleteOptions,
|
||||
MailboxDeletion,
|
||||
MailFilterRunContext,
|
||||
MailFilterRunOutcome,
|
||||
@@ -40,7 +43,9 @@ import type {
|
||||
PasswordChangeOutcome,
|
||||
RecipientFields,
|
||||
RejectedRecipient,
|
||||
ReleasedScheduledSend,
|
||||
SavedDraft,
|
||||
SendHold,
|
||||
SentMessage,
|
||||
SentMessageRecord,
|
||||
SummariesWithHeld,
|
||||
@@ -68,6 +73,14 @@ export const MAIL_PROVIDER = Symbol('MAIL_PROVIDER');
|
||||
*/
|
||||
export class SendNotStartedException extends BusinessException {}
|
||||
|
||||
/**
|
||||
* 예약 메일 지금 보내기(15단계)가 끝내 보내지 못해 그 메일을 임시보관함의 초안으로 **되돌렸다** — 예약 취소(DELETE /api/scheduled-messages/{id})와 같은 자리다.
|
||||
* 되돌리기까지 된 때만 이 형식이다(엔진이 다시 제출을 거듭 거절함 MAIL_001 · 받는 사람이 모두 받지 않음 MAIL_010 · MAIL_011). 코드 · 응답은 BusinessException 그대로다.
|
||||
* 결과를 모르는 실패 · 남은 시간 모자람 · 되돌리기 실패는 그냥 BusinessException(MAIL_001)이다 — 메일은 보낸편지함에 그대로다.
|
||||
* 서비스는 이 형식으로 갈라 대용량 첨부 링크를 취소한 발송처럼 둔다(되돌린 초안이 그 파일을 쓴다) — context 는 로그 전용이라 갈래에 쓰지 않는다
|
||||
*/
|
||||
export class ScheduledSendReturnedToDraftsException extends BusinessException {}
|
||||
|
||||
/**
|
||||
* 그 직원의 비밀번호가 만료됐다 (13단계 — 계약 6절 "비밀번호 바꾸기 강제") — 엔진은 그 계정의 권한을 비밀번호 바꾸기만으로 줄여 메일 기능이 없다
|
||||
* (본인 Basic · 게이트웨이 대리 접근 모두 — 2026-11-02 개발 엔진 확인). 프로필 · 메일 호출(엔진 세션을 열거나 메일 메서드가 forbidden)이 이 예외로 던진다.
|
||||
@@ -148,16 +161,30 @@ export interface MailProvider {
|
||||
*/
|
||||
createMailbox(user: string, input: MailboxCreate): Promise<Mailbox>;
|
||||
|
||||
/** 메일함 이름을 바꾼다 — 없으면 RES_001, 역할이 있는 메일함(기본 메일함)이면 VAL_001, 같은 이름이 있으면 MAIL_009 */
|
||||
renameMailbox(user: string, id: string, name: string): Promise<Mailbox>;
|
||||
/**
|
||||
* 메일함의 이름 · 상위를 바꾼다 (15단계 — 옮기기) — 보내지 않은 칸은 그대로. 없으면 RES_001, 역할이 있는 메일함(기본 메일함)이면 VAL_001,
|
||||
* 새 상위가 없으면 RES_001 · 만들 수 없는 자리(만들기와 같다)이거나 자기 자신 · 자기 하위면 VAL_001, 새 자리에 같은 이름이 있으면 MAIL_009.
|
||||
* 이름 · 상위가 지금과 같으면 엔진에 묻지 않고 그대로 돌려준다. 하위 메일함과 안의 메일은 함께 간다(메일함 id 는 그대로다)
|
||||
*/
|
||||
updateMailbox(
|
||||
user: string,
|
||||
id: string,
|
||||
change: MailboxChange,
|
||||
): Promise<Mailbox>;
|
||||
|
||||
/**
|
||||
* 메일함을 지운다 — 안의 메일을 휴지통으로 옮긴 뒤(다른 메일함에도 있는 메일은 이 메일함에서만 뺀다) 빈 메일함을 지운다.
|
||||
* 메일을 영구 삭제하지 않는다. 한 요청에 다 옮기지 못하면 deleted: false (클라이언트가 다시 보낸다).
|
||||
* 없으면 RES_001, 역할이 있는 메일함 · 하위 메일함이 있으면 VAL_001.
|
||||
* `withChildren` 이면(15단계) 하위가 있어도 나무 전체(그 메일함과 하위 모두)를 같은 규칙으로 — 메일을 옮긴 뒤 깊은 것부터 지운다.
|
||||
* 하위에 역할이 있는 메일함이 있으면 VAL_001. 수(processed · movedToTrash · remaining)는 나무 전체의 것이다.
|
||||
* 붙잡힌 메일(보내기 취소 · 예약을 기다림 — 9단계)은 옮기지 않는다 — 남은 것이 그것뿐이고 이 요청에서 옮긴 것이 없으면 MAIL_015
|
||||
*/
|
||||
deleteMailbox(user: string, id: string): Promise<MailboxDeletion>;
|
||||
deleteMailbox(
|
||||
user: string,
|
||||
id: string,
|
||||
options?: MailboxDeleteOptions,
|
||||
): Promise<MailboxDeletion>;
|
||||
|
||||
/** 메일함의 안 읽은 메일을 읽음으로 바꾼다 (한 요청의 시간 안에서 차례로) — 없으면 RES_001 */
|
||||
markMailboxRead(user: string, id: string): Promise<MailboxBulkResult>;
|
||||
@@ -224,6 +251,18 @@ export interface MailProvider {
|
||||
context: MailFilterRunContext,
|
||||
): Promise<MailFilterRunOutcome>;
|
||||
|
||||
/**
|
||||
* 차단할 때 받은편지함 정리 (계약 2절 "보낸 사람 차단" — 15단계) — 받은편지함에 이미 있는 그 보낸 사람의 메일을 스팸메일함으로 옮기고 스팸 표시를 한다.
|
||||
* 판정은 새로 오는 메일의 차단 규칙(Sieve 의 `address` 판정 — mail-block-match.ts)과 같다. 차례 · 시간 · cursor 는 runMailFilter 와 같은 규칙이다
|
||||
* (matched = 스팸메일함으로 옮긴 수). 스팸메일함이 없으면 MAIL_001, cursor 가 깨졌거나 다른 보낸 사람의 것이면 엔진에 묻기 전에 VAL_001.
|
||||
* 차단 목록에 있는지는 서비스가 본 값이다. 받은편지함 · 스팸메일함은 context.mailboxes 에서 찾는다
|
||||
*/
|
||||
runMailBlock(
|
||||
user: string,
|
||||
request: MailBlockRunRequest,
|
||||
context: MailFilterRunContext,
|
||||
): Promise<MailFilterRunOutcome>;
|
||||
|
||||
/** 메일함 사용량과 한도 (한도가 없으면 limitBytes = null) */
|
||||
getUsage(user: string): Promise<MailUsage>;
|
||||
|
||||
@@ -391,6 +430,25 @@ export interface MailProvider {
|
||||
*/
|
||||
cancelPendingSend(user: string, id: string): Promise<void>;
|
||||
|
||||
/**
|
||||
* 예약한 메일을 지금 보낸다 (계약 2절 "보내기 취소 · 예약 발송" — 15단계) — `id` 는 보낸 사본의 메일 id.
|
||||
* 기다리는 예약 제출을 취소하고(엔진이 확인한 뒤에만) 같은 메일을 그 제출의 envelope 그대로 다시 제출한다 — `hold` 만큼 붙잡는다(보내기 취소 설정 — null 이면 바로).
|
||||
* 예약 표시를 떼고, 받는 사람별 결과는 발송과 같은 규칙으로 읽는다.
|
||||
* - 예약이 아닌 메일(보내기 취소를 기다리는 메일 · 취소해 임시보관함으로 간 메일 · 제출이 없음) · 없는 메일 → RES_001 (아무것도 바꾸지 않는다)
|
||||
* - 이미 보냈다(예약 시각이 막 지남 — 제출이 final · 취소하려니 엔진이 받지 않음) → 오류가 아니다: pendingSend null (releasedAt null)
|
||||
* - 이미 지금 보내기를 한 메일(취소한 예약 제출 + 그 뒤 새 제출 — 다시 누름 · 응답이 끊겨 다시 보냄) → 그 새 제출의 지금 상태
|
||||
* - 엔진이 다시 제출을 분명히 거절하면 한 번 더 — 그래도 거절이면 그 메일을 임시보관함으로 되돌린 뒤 ScheduledSendReturnedToDraftsException(MAIL_001).
|
||||
* 받는 사람이 모두 받지 않았어도 임시보관함으로 되돌린 뒤 같은 예외(MAIL_010 · MAIL_011). 되돌리지 못하면 그냥 MAIL_001
|
||||
* - 다시 제출의 결과를 모르면(시간 초과 · 연결 끊김 · 5xx) 다시 내지도 · 되돌리지도 않는다 — 다시 읽어 새 제출이 보이면 그 상태, 아니면 그냥 MAIL_001.
|
||||
* 남은 시간이 모자라 내지 않았어도 그냥 MAIL_001 — 둘 다 메일은 예약이 취소된 채 보낸편지함에 있고, 같은 요청을 다시 받으면 이어 낸다
|
||||
* - 예약 제출의 envelope · 발신 ID 를 읽지 못하거나 엔진이 취소를 확인해 주지 않으면 MAIL_001 — 취소하지 않았거나 취소를 확인하지 못해 다시 제출하지 않았다
|
||||
*/
|
||||
releaseScheduledSend(
|
||||
user: string,
|
||||
id: string,
|
||||
hold: SendHold | null,
|
||||
): Promise<ReleasedScheduledSend>;
|
||||
|
||||
/**
|
||||
* 이미 보낸 메일의 받지 않은 사람 (같은 키의 재시도 — 계약 2절 "받는 사람별 결과") — 그 메일의 제출 기록을
|
||||
* 이 요청의 받는 사람(to · cc · bcc)과 맞춰 다시 만든다. 제출 기록이 없거나 읽지 못하면 빈 배열 — 던지지 않는다 (메일은 이미 보냈다)
|
||||
|
||||
@@ -9,6 +9,7 @@ import type { MailUserSettingsService } from '@/modules/mail-user-settings/mail-
|
||||
import type { MailUserSettings } from '@/modules/mail-user-settings/mail-user-settings.types';
|
||||
import type { ContactsService } from '@/modules/contacts/contacts.service';
|
||||
import type { DirectoryService } from '@/modules/contacts/directory.service';
|
||||
import type { MailBlockRunRequestDto } from './dto/mail-block-run.dto';
|
||||
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';
|
||||
@@ -82,7 +83,7 @@ function fakeProvider(): ProviderMocks {
|
||||
getProfile: mockOf<'getProfile'>(),
|
||||
listMailboxes: mockOf<'listMailboxes'>(),
|
||||
createMailbox: mockOf<'createMailbox'>(),
|
||||
renameMailbox: mockOf<'renameMailbox'>(),
|
||||
updateMailbox: mockOf<'updateMailbox'>(),
|
||||
deleteMailbox: mockOf<'deleteMailbox'>(),
|
||||
markMailboxRead: mockOf<'markMailboxRead'>(),
|
||||
emptyMailbox: mockOf<'emptyMailbox'>(),
|
||||
@@ -91,6 +92,7 @@ function fakeProvider(): ProviderMocks {
|
||||
getMailFilters: mockOf<'getMailFilters'>(),
|
||||
setMailFilters: mockOf<'setMailFilters'>(),
|
||||
runMailFilter: mockOf<'runMailFilter'>(),
|
||||
runMailBlock: mockOf<'runMailBlock'>(),
|
||||
openMessageSource: mockOf<'openMessageSource'>(),
|
||||
getMessageSubjects: mockOf<'getMessageSubjects'>(),
|
||||
getUsage: mockOf<'getUsage'>(),
|
||||
@@ -132,6 +134,7 @@ function fakeProvider(): ProviderMocks {
|
||||
removeMailBlock: mockOf<'removeMailBlock'>(),
|
||||
listScheduledMessages: mockOf<'listScheduledMessages'>(),
|
||||
cancelPendingSend: mockOf<'cancelPendingSend'>(),
|
||||
releaseScheduledSend: mockOf<'releaseScheduledSend'>(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -544,7 +547,7 @@ describe('MailService', () => {
|
||||
|
||||
it('메일함 만들기 — 상위가 없으면 맨 위(null)로 넘기고, 이름 바꾸기와 함께 표시 규칙을 거친 메일함을 돌려준다', async () => {
|
||||
provider.createMailbox.mockResolvedValue(mailbox);
|
||||
provider.renameMailbox.mockResolvedValue({
|
||||
provider.updateMailbox.mockResolvedValue({
|
||||
...mailbox,
|
||||
id: 'a',
|
||||
name: 'Inbox',
|
||||
@@ -562,13 +565,34 @@ describe('MailService', () => {
|
||||
|
||||
// (어댑터가 막지만) 역할이 있는 메일함은 한국어 표준 이름으로 보인다
|
||||
await expect(
|
||||
service.renameMailbox(user, 'a', { name: '새 이름' }),
|
||||
service.updateMailbox(user, 'a', { name: '새 이름' }),
|
||||
).resolves.toMatchObject({ name: '받은편지함' });
|
||||
expect(provider.renameMailbox).toHaveBeenCalledWith(
|
||||
user.email,
|
||||
'a',
|
||||
'새 이름',
|
||||
);
|
||||
expect(provider.updateMailbox).toHaveBeenCalledWith(user.email, 'a', {
|
||||
name: '새 이름',
|
||||
});
|
||||
});
|
||||
|
||||
it('메일함 바꾸기(15단계) — 이름 · 상위 가운데 보낸 것만 넘긴다, 둘 다 없으면 엔진에 묻지 않고 VAL_001', async () => {
|
||||
provider.updateMailbox.mockResolvedValue(mailbox);
|
||||
|
||||
expect(
|
||||
await failureOf(() => service.updateMailbox(user, 'n1', {})),
|
||||
).toEqual({
|
||||
code: 'VAL_001',
|
||||
reason: 'empty-mailbox-update',
|
||||
});
|
||||
expect(provider.updateMailbox).not.toHaveBeenCalled();
|
||||
|
||||
await service.updateMailbox(user, 'n1', { parentId: null });
|
||||
await service.updateMailbox(user, 'n1', { parentId: 'u1' });
|
||||
await service.updateMailbox(user, 'n1', { name: '2025', parentId: 'u1' });
|
||||
expect(provider.updateMailbox.mock.calls).toEqual([
|
||||
[user.email, 'n1', { parentId: null }],
|
||||
[user.email, 'n1', { parentId: 'u1' }],
|
||||
[user.email, 'n1', { name: '2025', parentId: 'u1' }],
|
||||
]);
|
||||
// 옮긴 로그는 어댑터가 실제로 옮겼을 때만 남긴다 (지금 자리 그대로면 남기지 않게 — jmap-mail.provider.spec.ts)
|
||||
expect(logSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('메일함 지우기 — 지웠을 때만 개수를 남긴다 (메일함 이름 · 주소 없이)', async () => {
|
||||
@@ -598,6 +622,37 @@ describe('MailService', () => {
|
||||
expect(line).toContain('휴지통으로 옮김=7통');
|
||||
expect(line).toContain('이 메일함에서만 뺌=2통');
|
||||
expect(line).not.toContain(user.email);
|
||||
// 하위까지가 아니면 메일함 목록을 읽지 않고, 한 요청의 시간은 서비스가 받은 때부터 (startedAt)
|
||||
expect(provider.listMailboxes).not.toHaveBeenCalled();
|
||||
expect(provider.deleteMailbox).toHaveBeenLastCalledWith(
|
||||
user.email,
|
||||
'u4',
|
||||
{
|
||||
startedAt: expect.any(Number) as number,
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it('하위 메일함까지 지우기(15단계 — withChildren=true)는 메일함 목록으로 나무를 정해 넘긴다', async () => {
|
||||
provider.listMailboxes.mockResolvedValue([
|
||||
{ ...mailbox, id: 'u1' },
|
||||
{ ...mailbox, id: 'u2', parentId: 'u1' },
|
||||
{ ...mailbox, id: 'u3' },
|
||||
]);
|
||||
provider.deleteMailbox.mockResolvedValue({
|
||||
deleted: true,
|
||||
processed: 3,
|
||||
movedToTrash: 3,
|
||||
remaining: 0,
|
||||
});
|
||||
|
||||
await service.deleteMailbox(user, 'u1', { withChildren: 'true' });
|
||||
expect(provider.deleteMailbox).toHaveBeenCalledWith(user.email, 'u1', {
|
||||
withChildren: true,
|
||||
startedAt: expect.any(Number) as number,
|
||||
});
|
||||
const [line] = logSpy.mock.calls.at(-1) as [string];
|
||||
expect(line).toContain('하위까지(메일함 2개)');
|
||||
});
|
||||
|
||||
it('전체 읽음은 { isRead: true } 를 어댑터의 전체 읽음으로 — 로그를 남기지 않는다', async () => {
|
||||
@@ -712,6 +767,103 @@ describe('MailService', () => {
|
||||
provider.emptyMailbox.mockResolvedValueOnce(emptied);
|
||||
await expect(service.emptyMailbox(user, 'b')).resolves.toEqual(emptied);
|
||||
});
|
||||
|
||||
it('하위까지 지우기는 나무의 메일함 모두를 잡는다 — 하위 메일함의 처리 · 그 안으로 옮기기는 REQ_001, 나무 밖은 된다 (15단계)', async () => {
|
||||
const deletion = {
|
||||
deleted: true,
|
||||
processed: 0,
|
||||
movedToTrash: 0,
|
||||
remaining: 0,
|
||||
};
|
||||
const first = pending<typeof deletion>();
|
||||
provider.listMailboxes.mockResolvedValue([
|
||||
{ ...mailbox, id: 'u1' },
|
||||
{ ...mailbox, id: 'u2', parentId: 'u1' },
|
||||
{ ...mailbox, id: 'u3' },
|
||||
]);
|
||||
provider.deleteMailbox.mockReturnValueOnce(first.promise);
|
||||
provider.markMailboxRead.mockResolvedValue({
|
||||
processed: 0,
|
||||
remaining: 0,
|
||||
});
|
||||
provider.updateMailbox.mockResolvedValue(mailbox);
|
||||
|
||||
const running = service.deleteMailbox(user, 'u1', {
|
||||
withChildren: 'true',
|
||||
});
|
||||
// 메일함 목록(엔진)을 지나 어댑터에 들어갈 때까지 기다린다
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
expect(provider.deleteMailbox).toHaveBeenCalledTimes(1);
|
||||
|
||||
for (const attempt of [
|
||||
() => service.updateMailboxMessages(user, 'u2', { isRead: true }),
|
||||
() => service.emptyMailbox(user, 'u2'),
|
||||
// 지우는 나무 안으로 옮기기 · 지우는 나무의 메일함을 빼 가기
|
||||
() => service.updateMailbox(user, 'u3', { parentId: 'u2' }),
|
||||
() => service.updateMailbox(user, 'u2', { parentId: null }),
|
||||
]) {
|
||||
expect(await failureOf(attempt)).toEqual({
|
||||
code: 'REQ_001',
|
||||
reason: 'mailbox-operation-in-progress',
|
||||
});
|
||||
}
|
||||
expect(provider.updateMailbox).not.toHaveBeenCalled();
|
||||
|
||||
// 나무 밖은 된다 — 이름만 바꾸기는 잠그지 않는다(지금처럼)
|
||||
await service.updateMailboxMessages(user, 'u3', { isRead: true });
|
||||
await service.updateMailbox(user, 'u2', { name: '새 이름' });
|
||||
expect(provider.updateMailbox).toHaveBeenCalledTimes(1);
|
||||
|
||||
first.finish(deletion);
|
||||
await running;
|
||||
await service.updateMailbox(user, 'u3', { parentId: 'u1' });
|
||||
expect(provider.updateMailbox).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('하위까지 지우기는 그 메일함을 먼저 잡는다 — 그 메일함이 처리 중이면 목록을 읽지 않고 REQ_001, 하위가 처리 중이면 목록만 읽고 REQ_001 (15단계)', async () => {
|
||||
provider.listMailboxes.mockResolvedValue([
|
||||
{ ...mailbox, id: 'u1' },
|
||||
{ ...mailbox, id: 'u2', parentId: 'u1' },
|
||||
]);
|
||||
const busy = pending<{ processed: number; remaining: number }>();
|
||||
provider.markMailboxRead.mockReturnValueOnce(busy.promise);
|
||||
|
||||
// 그 메일함이 처리 중 — 엔진에 묻지 않는다
|
||||
const rootBusy = service.updateMailboxMessages(user, 'u1', {
|
||||
isRead: true,
|
||||
});
|
||||
expect(
|
||||
await failureOf(() =>
|
||||
service.deleteMailbox(user, 'u1', { withChildren: 'true' }),
|
||||
),
|
||||
).toEqual({ code: 'REQ_001', reason: 'mailbox-operation-in-progress' });
|
||||
expect(provider.listMailboxes).not.toHaveBeenCalled();
|
||||
busy.finish({ processed: 0, remaining: 0 });
|
||||
await rootBusy;
|
||||
|
||||
// 하위가 처리 중 — 목록만 읽고 지우지 않는다, 그 메일함의 잠금은 푼다
|
||||
const child = pending<{ processed: number; remaining: number }>();
|
||||
provider.markMailboxRead.mockReturnValueOnce(child.promise);
|
||||
const childBusy = service.updateMailboxMessages(user, 'u2', {
|
||||
isRead: true,
|
||||
});
|
||||
expect(
|
||||
await failureOf(() =>
|
||||
service.deleteMailbox(user, 'u1', { withChildren: 'true' }),
|
||||
),
|
||||
).toEqual({ code: 'REQ_001', reason: 'mailbox-operation-in-progress' });
|
||||
expect(provider.listMailboxes).toHaveBeenCalledTimes(1);
|
||||
expect(provider.deleteMailbox).not.toHaveBeenCalled();
|
||||
provider.markMailboxRead.mockResolvedValueOnce({
|
||||
processed: 0,
|
||||
remaining: 0,
|
||||
});
|
||||
await expect(
|
||||
service.updateMailboxMessages(user, 'u1', { isRead: true }),
|
||||
).resolves.toEqual({ processed: 0, remaining: 0 });
|
||||
child.finish({ processed: 0, remaining: 0 });
|
||||
await childBusy;
|
||||
});
|
||||
});
|
||||
|
||||
describe('부재중 자동 응답 저장', () => {
|
||||
@@ -722,19 +874,37 @@ describe('MailService', () => {
|
||||
subject: '부재중입니다',
|
||||
textBody: '11월 7일까지 휴가입니다.',
|
||||
};
|
||||
|
||||
it('형식 규칙을 지나면 그대로 넘기고 엔진이 저장한 값을 돌려준다 — 로그에는 켬 · 끔만', async () => {
|
||||
/** 16단계 칸이 없는 옛 요청이 저장되는 값 — 글만 · 사내와 같은 응답 */
|
||||
const TEXT_ONLY = {
|
||||
htmlBody: null,
|
||||
external: {
|
||||
mode: 'same' as const,
|
||||
subject: null,
|
||||
textBody: null,
|
||||
htmlBody: null,
|
||||
},
|
||||
};
|
||||
const echo = () =>
|
||||
provider.setVacation.mockImplementation((_user, value) =>
|
||||
Promise.resolve({ ...value, isActive: true }),
|
||||
);
|
||||
|
||||
it('형식 규칙을 지나면 그대로 넘기고 엔진이 저장한 값을 돌려준다 — 새 칸이 없는 옛 요청은 글만 · 사내와 같은 응답, 로그에는 켬 · 끔 · 서식 · 회사 밖 고르기만', async () => {
|
||||
echo();
|
||||
|
||||
await expect(service.updateVacation(user, vacation)).resolves.toEqual({
|
||||
...vacation,
|
||||
...TEXT_ONLY,
|
||||
isActive: true,
|
||||
});
|
||||
expect(provider.setVacation).toHaveBeenCalledWith(user.email, vacation);
|
||||
expect(provider.setVacation).toHaveBeenCalledWith(user.email, {
|
||||
...vacation,
|
||||
...TEXT_ONLY,
|
||||
});
|
||||
const [line] = logSpy.mock.calls[0] as [string];
|
||||
expect(line).toContain('켬');
|
||||
expect(line).toContain('서식 없음');
|
||||
expect(line).toContain('회사 밖 same');
|
||||
expect(line).not.toContain(vacation.subject);
|
||||
expect(line).not.toContain(vacation.textBody);
|
||||
expect(line).not.toContain(user.email);
|
||||
@@ -759,6 +929,7 @@ describe('MailService', () => {
|
||||
toDate: null,
|
||||
subject: '',
|
||||
textBody: '',
|
||||
...TEXT_ONLY,
|
||||
};
|
||||
provider.setVacation.mockResolvedValue({
|
||||
...neverConfigured,
|
||||
@@ -782,9 +953,7 @@ describe('MailService', () => {
|
||||
}
|
||||
expect(provider.setVacation).not.toHaveBeenCalled();
|
||||
|
||||
provider.setVacation.mockImplementation((_user, value) =>
|
||||
Promise.resolve({ ...value, isActive: true }),
|
||||
);
|
||||
echo();
|
||||
await service.updateVacation(user, {
|
||||
...vacation,
|
||||
isEnabled: false,
|
||||
@@ -798,9 +967,10 @@ describe('MailService', () => {
|
||||
expect(
|
||||
provider.setVacation.mock.calls.map(([, value]) => value),
|
||||
).toEqual([
|
||||
{ ...vacation, isEnabled: false, subject: ' ' },
|
||||
{ ...vacation, ...TEXT_ONLY, isEnabled: false, subject: ' ' },
|
||||
{
|
||||
...vacation,
|
||||
...TEXT_ONLY,
|
||||
subject: ' 부재중입니다 ',
|
||||
textBody: '\n휴가입니다.\n',
|
||||
},
|
||||
@@ -838,9 +1008,7 @@ describe('MailService', () => {
|
||||
textLength: vacation.textBody.length,
|
||||
});
|
||||
|
||||
provider.setVacation.mockImplementation((_user, value) =>
|
||||
Promise.resolve({ ...value, isActive: true }),
|
||||
);
|
||||
echo();
|
||||
// 한도까지는 켜도 받는다
|
||||
const full = {
|
||||
...vacation,
|
||||
@@ -849,6 +1017,7 @@ describe('MailService', () => {
|
||||
};
|
||||
await expect(service.updateVacation(user, full)).resolves.toEqual({
|
||||
...full,
|
||||
...TEXT_ONLY,
|
||||
isActive: true,
|
||||
});
|
||||
// 꺼 두면 4단계에 저장해 둔 긴 글(영문 내용 1,000자 등)도 그대로 저장한다 — 끄기는 늘 된다
|
||||
@@ -860,6 +1029,7 @@ describe('MailService', () => {
|
||||
};
|
||||
await expect(service.updateVacation(user, off)).resolves.toEqual({
|
||||
...off,
|
||||
...TEXT_ONLY,
|
||||
isActive: true,
|
||||
});
|
||||
expect(provider.setVacation).toHaveBeenCalledTimes(2);
|
||||
@@ -877,9 +1047,7 @@ describe('MailService', () => {
|
||||
).toEqual({ code: 'VAL_001', reason: 'vacation-dates-reversed' });
|
||||
expect(provider.setVacation).not.toHaveBeenCalled();
|
||||
|
||||
provider.setVacation.mockImplementation((_user, value) =>
|
||||
Promise.resolve({ ...value, isActive: true }),
|
||||
);
|
||||
echo();
|
||||
await service.updateVacation(user, {
|
||||
...vacation,
|
||||
fromDate: '2026-11-02T15:00:00.999Z',
|
||||
@@ -887,6 +1055,7 @@ describe('MailService', () => {
|
||||
});
|
||||
expect(provider.setVacation).toHaveBeenCalledWith(user.email, {
|
||||
...vacation,
|
||||
...TEXT_ONLY,
|
||||
fromDate: '2026-11-02T15:00:00.000Z',
|
||||
toDate: '2026-11-02T15:00:01.000Z',
|
||||
});
|
||||
@@ -902,13 +1071,174 @@ describe('MailService', () => {
|
||||
}
|
||||
expect(provider.setVacation).not.toHaveBeenCalled();
|
||||
|
||||
provider.setVacation.mockImplementation((_user, value) =>
|
||||
Promise.resolve({ ...value, isActive: true }),
|
||||
);
|
||||
echo();
|
||||
await service.updateVacation(user, { ...vacation, toDate: null });
|
||||
await service.updateVacation(user, { ...vacation, fromDate: null });
|
||||
expect(provider.setVacation).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
describe('서식 본문 · 회사 밖 응답 (16단계)', () => {
|
||||
const external = {
|
||||
mode: 'separate' as const,
|
||||
subject: 'Out of office',
|
||||
textBody: 'I am away until Nov 7.',
|
||||
htmlBody: '<p>I am <strong>away</strong> until Nov 7.</p>',
|
||||
};
|
||||
|
||||
it('서식 본문은 보내는 정화(그림 없음)를 거쳐 넘긴다 — 정화해서 비면 null(글만), 회사 밖 칸도 같다. 로그에는 서식 있음 · 고르기만', async () => {
|
||||
echo();
|
||||
|
||||
const saved = await service.updateVacation(user, {
|
||||
...vacation,
|
||||
htmlBody:
|
||||
'<p onclick="x()">11월 7일까지 <strong>휴가</strong>입니다.<script>x()</script></p><img src="https://evil.example/x.png"><img src="/api/mail-signature/images/3f1c0a2e-0f1b-4f2a-9a1e-6c9f0a1b2c3d">',
|
||||
external: {
|
||||
...external,
|
||||
htmlBody: '<script>x()</script>',
|
||||
},
|
||||
});
|
||||
|
||||
expect(saved).toEqual({
|
||||
...vacation,
|
||||
htmlBody: '<p>11월 7일까지 <strong>휴가</strong>입니다.</p>',
|
||||
external: { ...external, htmlBody: null },
|
||||
isActive: true,
|
||||
});
|
||||
// 서식에 그림은 없다 — 서명 그림 주소도 정화가 지운다(그 사람의 그림 목록을 읽지 않는다)
|
||||
expect(signatures.imageIdsOf).not.toHaveBeenCalled();
|
||||
const [line] = logSpy.mock.calls[0] as [string];
|
||||
expect(line).toContain('서식 있음');
|
||||
expect(line).toContain('회사 밖 separate');
|
||||
expect(line).not.toContain('Out of office');
|
||||
expect(line).not.toContain('휴가');
|
||||
});
|
||||
|
||||
it('회사 밖 글 칸의 빈 글은 null 로 — 다른 고르기여도 써 둔 글은 그대로 넘긴다, external 이 null 이면 사내와 같은 응답', async () => {
|
||||
echo();
|
||||
|
||||
await service.updateVacation(user, {
|
||||
...vacation,
|
||||
external: { ...external, mode: 'none' },
|
||||
});
|
||||
await service.updateVacation(user, {
|
||||
...vacation,
|
||||
external: {
|
||||
mode: 'same',
|
||||
subject: '',
|
||||
textBody: 'x',
|
||||
htmlBody: '',
|
||||
},
|
||||
});
|
||||
await service.updateVacation(user, { ...vacation, external: null });
|
||||
|
||||
expect(
|
||||
provider.setVacation.mock.calls.map(([, value]) => value.external),
|
||||
).toEqual([
|
||||
{ ...external, mode: 'none' },
|
||||
{ mode: 'same', subject: null, textBody: 'x', htmlBody: null },
|
||||
TEXT_ONLY.external,
|
||||
]);
|
||||
});
|
||||
|
||||
it('따로 쓰기이고 켤 때만 회사 밖 제목 · 내용을 본다 — 비면 · 150 · 600자를 넘으면 VAL_001, 끈 채로 · 다른 고르기면 비어도 저장한다', async () => {
|
||||
for (const [changed, reason] of [
|
||||
[{ subject: null }, 'vacation-external-without-text'],
|
||||
[{ textBody: ' ' }, 'vacation-external-without-text'],
|
||||
[
|
||||
{ subject: 'x'.repeat(MAX_VACATION_SUBJECT_LENGTH + 1) },
|
||||
'vacation-external-text-too-long',
|
||||
],
|
||||
[
|
||||
{ textBody: 'x'.repeat(MAX_VACATION_TEXT_LENGTH + 1) },
|
||||
'vacation-external-text-too-long',
|
||||
],
|
||||
] as const) {
|
||||
expect(
|
||||
await failureOf(() =>
|
||||
service.updateVacation(user, {
|
||||
...vacation,
|
||||
external: { ...external, ...changed },
|
||||
}),
|
||||
),
|
||||
).toEqual({ code: 'VAL_001', reason });
|
||||
}
|
||||
expect(provider.setVacation).not.toHaveBeenCalled();
|
||||
|
||||
echo();
|
||||
const empty = { subject: null, textBody: null, htmlBody: null };
|
||||
await service.updateVacation(user, {
|
||||
...vacation,
|
||||
isEnabled: false,
|
||||
external: { mode: 'separate', ...empty },
|
||||
});
|
||||
for (const mode of ['same', 'none'] as const) {
|
||||
await service.updateVacation(user, {
|
||||
...vacation,
|
||||
external: { mode, ...empty },
|
||||
});
|
||||
}
|
||||
expect(provider.setVacation).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it('켤 때 정화한 서식은 10,000자까지 — 넘으면 VAL_001(회사 밖은 따로 쓰기일 때만), 정화 전 비용은 늘 본다', async () => {
|
||||
const long = `<p>${'가'.repeat(10_000)}</p>`;
|
||||
expect(
|
||||
await failureOf(() =>
|
||||
service.updateVacation(user, { ...vacation, htmlBody: long }),
|
||||
),
|
||||
).toEqual({ code: 'VAL_001', reason: 'vacation-html-too-long' });
|
||||
expect(
|
||||
await failureOf(() =>
|
||||
service.updateVacation(user, {
|
||||
...vacation,
|
||||
external: { ...external, htmlBody: long },
|
||||
}),
|
||||
),
|
||||
).toEqual({
|
||||
code: 'VAL_001',
|
||||
reason: 'vacation-external-html-too-long',
|
||||
});
|
||||
for (const isEnabled of [true, false]) {
|
||||
expect(
|
||||
await failureOf(() =>
|
||||
service.updateVacation(user, {
|
||||
...vacation,
|
||||
isEnabled,
|
||||
htmlBody: '<b>'.repeat(166_666),
|
||||
}),
|
||||
),
|
||||
).toEqual({ code: 'VAL_001', reason: 'vacation-html-too-complex' });
|
||||
}
|
||||
expect(
|
||||
await failureOf(() =>
|
||||
service.updateVacation(user, {
|
||||
...vacation,
|
||||
isEnabled: false,
|
||||
htmlBody: 'x'.repeat(MAX_BODY_HTML_LENGTH + 1),
|
||||
}),
|
||||
),
|
||||
).toEqual({ code: 'VAL_001', reason: 'vacation-html-too-long' });
|
||||
expect(provider.setVacation).not.toHaveBeenCalled();
|
||||
|
||||
echo();
|
||||
// 끈 채로는 · 다른 고르기의 회사 밖 서식은 10,000자를 넘어도 저장한다(켤 때 본다)
|
||||
await service.updateVacation(user, {
|
||||
...vacation,
|
||||
isEnabled: false,
|
||||
htmlBody: long,
|
||||
});
|
||||
await service.updateVacation(user, {
|
||||
...vacation,
|
||||
external: { ...external, mode: 'none', htmlBody: long },
|
||||
});
|
||||
// 경계 — 정화한 결과가 정확히 10,000자면 켜도 받는다
|
||||
await service.updateVacation(user, {
|
||||
...vacation,
|
||||
htmlBody: `<p>${'가'.repeat(10_000 - 7)}</p>`,
|
||||
});
|
||||
expect(provider.setVacation).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('스팸 표시만 바꾸는 PATCH 도 받는다 — 한 통 · 여러 통 모두 isJunk 를 그대로 넘긴다', async () => {
|
||||
@@ -1456,6 +1786,204 @@ describe('MailService', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('차단할 때 받은편지함 정리 (15단계)', () => {
|
||||
const outcome = {
|
||||
scanned: 500,
|
||||
matched: 12,
|
||||
remaining: 40,
|
||||
cursor: 'next-cursor',
|
||||
rounds: 1,
|
||||
};
|
||||
const blocked = (address: string) => ({
|
||||
address,
|
||||
blockedAt: '2026-11-09T01:00:00.000Z',
|
||||
});
|
||||
const mailboxes: Mailbox[] = [
|
||||
{
|
||||
id: 'a',
|
||||
name: 'Inbox',
|
||||
role: 'inbox',
|
||||
parentId: null,
|
||||
sortOrder: 0,
|
||||
totalCount: 540,
|
||||
unreadCount: 0,
|
||||
},
|
||||
{
|
||||
id: 'c',
|
||||
name: 'Junk Mail',
|
||||
role: 'junk',
|
||||
parentId: null,
|
||||
sortOrder: 0,
|
||||
totalCount: 0,
|
||||
unreadCount: 0,
|
||||
},
|
||||
];
|
||||
const body = (
|
||||
sender: string,
|
||||
cursor: string | null = null,
|
||||
): MailBlockRunRequestDto => ({ sender, cursor });
|
||||
|
||||
/** 손으로 끝내는 정리 — 끝낼 때까지 진행 중이다 */
|
||||
function pending<T>(): {
|
||||
promise: Promise<T>;
|
||||
finish: (value: T) => void;
|
||||
} {
|
||||
let finish!: (value: T) => void;
|
||||
const promise = new Promise<T>((resolve) => {
|
||||
finish = resolve;
|
||||
});
|
||||
return { promise, finish };
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
provider.getMailBlocks.mockResolvedValue({
|
||||
senders: [blocked('kim@spam.example'), blocked('@spam.example')],
|
||||
isActive: true,
|
||||
});
|
||||
provider.listMailboxes.mockResolvedValue(mailboxes);
|
||||
provider.runMailBlock.mockResolvedValue(outcome);
|
||||
});
|
||||
|
||||
it('차단 목록에 있는 보낸 사람이면 메일함 목록과 함께 넘기고 결과를 돌려준다 — 대소문자 · 앞뒤 공백은 맞춘다, 로그에는 가린 주소 · 수만', async () => {
|
||||
await expect(
|
||||
service.runMailBlock(user, body(' Kim@Spam.Example ')),
|
||||
).resolves.toEqual({
|
||||
scanned: 500,
|
||||
matched: 12,
|
||||
remaining: 40,
|
||||
cursor: 'next-cursor',
|
||||
});
|
||||
expect(provider.runMailBlock).toHaveBeenCalledWith(
|
||||
user.email,
|
||||
{ sender: 'kim@spam.example', cursor: null },
|
||||
{ mailboxes, startedAt: expect.any(Number) as number },
|
||||
);
|
||||
const [line] = logSpy.mock.calls.at(-1) as [string];
|
||||
expect(line).toContain('확인 500통 · 옮김 12통 · 남음 40통');
|
||||
expect(line).not.toContain('kim@spam.example');
|
||||
expect(line).not.toContain('next-cursor');
|
||||
expect(line).not.toContain(user.email);
|
||||
|
||||
// 도메인 차단은 도메인만 남긴다
|
||||
await service.runMailBlock(user, body('@spam.example', 'next-cursor'));
|
||||
expect(provider.runMailBlock).toHaveBeenLastCalledWith(
|
||||
user.email,
|
||||
{ sender: '@spam.example', cursor: 'next-cursor' },
|
||||
expect.any(Object),
|
||||
);
|
||||
const [domainLine] = logSpy.mock.calls.at(-1) as [string];
|
||||
expect(domainLine).toContain('도메인=@spam.example');
|
||||
});
|
||||
|
||||
it('차단 목록에 없으면 RES_001 — 받은편지함을 읽지 않는다 · 모양이 틀리면 VAL_001 (엔진에 묻지 않는다)', async () => {
|
||||
expect(
|
||||
await failureOf(() =>
|
||||
service.runMailBlock(user, body('lee@spam.example')),
|
||||
),
|
||||
).toEqual({ code: 'RES_001', reason: 'mail-block-not-found' });
|
||||
expect(provider.listMailboxes).not.toHaveBeenCalled();
|
||||
expect(provider.runMailBlock).not.toHaveBeenCalled();
|
||||
|
||||
provider.getMailBlocks.mockClear();
|
||||
expect(
|
||||
await failureOf(() =>
|
||||
service.runMailBlock(user, body('not an address')),
|
||||
),
|
||||
).toEqual({ code: 'VAL_001', reason: 'malformed-mail-block-run-sender' });
|
||||
expect(provider.getMailBlocks).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('차단 정리가 도는 동안 그 직원의 스팸메일함을 잡는다 — 비우기 · 전체 읽음은 REQ_001, 비우는 중이면 차단 정리가 목록을 읽은 뒤 REQ_001 (받은편지함 정리 잠금도 푼다)', async () => {
|
||||
const first = pending<typeof outcome>();
|
||||
provider.runMailBlock.mockReturnValueOnce(first.promise);
|
||||
provider.emptyMailbox.mockResolvedValue({ processed: 0, remaining: 0 });
|
||||
provider.markMailboxRead.mockResolvedValue({
|
||||
processed: 0,
|
||||
remaining: 0,
|
||||
});
|
||||
|
||||
const running = service.runMailBlock(user, body('kim@spam.example'));
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
expect(provider.runMailBlock).toHaveBeenCalledTimes(1);
|
||||
for (const attempt of [
|
||||
() => service.emptyMailbox(user, 'c'),
|
||||
() => service.updateMailboxMessages(user, 'c', { isRead: true }),
|
||||
]) {
|
||||
expect(await failureOf(attempt)).toEqual({
|
||||
code: 'REQ_001',
|
||||
reason: 'mailbox-operation-in-progress',
|
||||
});
|
||||
}
|
||||
expect(provider.emptyMailbox).not.toHaveBeenCalled();
|
||||
// 다른 메일함은 된다
|
||||
await expect(
|
||||
service.updateMailboxMessages(user, 'b', { isRead: true }),
|
||||
).resolves.toEqual({ processed: 0, remaining: 0 });
|
||||
first.finish(outcome);
|
||||
await running;
|
||||
|
||||
// 스팸메일함을 비우는 중이면 차단 정리가 REQ_001 — 받은편지함을 건드리지 않는다
|
||||
const emptying = pending<{ processed: number; remaining: number }>();
|
||||
provider.emptyMailbox.mockReturnValueOnce(emptying.promise);
|
||||
const empty = service.emptyMailbox(user, 'c');
|
||||
provider.runMailBlock.mockClear();
|
||||
expect(
|
||||
await failureOf(() =>
|
||||
service.runMailBlock(user, body('kim@spam.example')),
|
||||
),
|
||||
).toEqual({ code: 'REQ_001', reason: 'mailbox-operation-in-progress' });
|
||||
expect(provider.runMailBlock).not.toHaveBeenCalled();
|
||||
// 받은편지함 정리 잠금(filterRuns)은 풀렸다 — 필터 적용은 된다
|
||||
provider.runMailFilter.mockResolvedValueOnce(outcome);
|
||||
await expect(
|
||||
service.runMailFilter(user, {
|
||||
match: 'always',
|
||||
conditions: [],
|
||||
actions: { moveToMailboxId: null, markAsRead: true, flag: false },
|
||||
cursor: null,
|
||||
} as unknown as MailFilterRunRequestDto),
|
||||
).resolves.toMatchObject({ matched: 12 });
|
||||
emptying.finish({ processed: 0, remaining: 0 });
|
||||
await empty;
|
||||
await expect(
|
||||
service.runMailBlock(user, body('kim@spam.example')),
|
||||
).resolves.toMatchObject({ matched: 12 });
|
||||
});
|
||||
|
||||
it('같은 직원의 받은편지함 정리(필터 적용 · 차단 정리)는 한 번에 하나 — 진행 중이면 서로 REQ_001', async () => {
|
||||
const first = pending<typeof outcome>();
|
||||
provider.runMailBlock.mockReturnValueOnce(first.promise);
|
||||
provider.runMailFilter.mockResolvedValue(outcome);
|
||||
|
||||
const running = service.runMailBlock(user, body('kim@spam.example'));
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
expect(provider.runMailBlock).toHaveBeenCalledTimes(1);
|
||||
|
||||
expect(
|
||||
await failureOf(() =>
|
||||
service.runMailBlock(user, body('@spam.example')),
|
||||
),
|
||||
).toMatchObject({ code: 'REQ_001' });
|
||||
expect(
|
||||
await failureOf(() =>
|
||||
service.runMailFilter(user, {
|
||||
match: 'always',
|
||||
conditions: [],
|
||||
actions: { moveToMailboxId: null, markAsRead: true, flag: false },
|
||||
cursor: null,
|
||||
} as unknown as MailFilterRunRequestDto),
|
||||
),
|
||||
).toMatchObject({ code: 'REQ_001' });
|
||||
expect(provider.runMailFilter).not.toHaveBeenCalled();
|
||||
|
||||
first.finish(outcome);
|
||||
await running;
|
||||
await expect(
|
||||
service.runMailBlock(user, body('kim@spam.example')),
|
||||
).resolves.toMatchObject({ matched: 12 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('부재중 자동 응답 읽기 (7단계 — isActive)', () => {
|
||||
it('어댑터 그대로 — 저장한 값과 엔진에서 동작하는지', async () => {
|
||||
const state = {
|
||||
@@ -1464,6 +1992,13 @@ describe('MailService', () => {
|
||||
toDate: null,
|
||||
subject: '부재중입니다',
|
||||
textBody: '휴가입니다.',
|
||||
htmlBody: '<p>휴가입니다.</p>',
|
||||
external: {
|
||||
mode: 'none' as const,
|
||||
subject: null,
|
||||
textBody: null,
|
||||
htmlBody: null,
|
||||
},
|
||||
isActive: false,
|
||||
};
|
||||
provider.getVacation.mockResolvedValue(state);
|
||||
|
||||
@@ -30,13 +30,16 @@ import type {
|
||||
MessageDeletionDto,
|
||||
UpdateMessagesDto,
|
||||
} from './dto/bulk-messages.dto';
|
||||
import type { MailBlockRunRequestDto } from './dto/mail-block-run.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,
|
||||
MailboxMessagesPatchDto,
|
||||
RenameMailboxDto,
|
||||
import {
|
||||
WITH_CHILDREN,
|
||||
type CreateMailboxDto,
|
||||
type MailboxDeleteQueryDto,
|
||||
type MailboxMessagesPatchDto,
|
||||
type UpdateMailboxDto,
|
||||
} from './dto/mailbox.dto';
|
||||
import {
|
||||
SHOW_IMAGES,
|
||||
@@ -61,6 +64,7 @@ import { highlightTermsOf } from '@/modules/mail-search/search-plan';
|
||||
import { checkHtmlParseBudget } from './html/html-parse-budget';
|
||||
import { sanitizeMailHtml } from './html/sanitize-mail-html';
|
||||
import { sanitizeOutgoingHtml } from './html/sanitize-outgoing-html';
|
||||
import { sanitizeStoredHtml } from './html/sanitize-stored-html';
|
||||
import {
|
||||
SIGNATURE_IMAGE_PATH_PREFIX,
|
||||
findSignatureImageIds,
|
||||
@@ -75,11 +79,13 @@ import {
|
||||
MAX_BODY_LENGTH,
|
||||
MAX_BODY_VALUE_BYTES,
|
||||
MAX_RECIPIENTS,
|
||||
MAX_VACATION_HTML_LENGTH,
|
||||
MAX_VACATION_SUBJECT_LENGTH,
|
||||
MAX_VACATION_TEXT_LENGTH,
|
||||
SCHEDULED_SEND_MAX_LEAD_MS,
|
||||
SCHEDULED_SEND_MIN_LEAD_MS,
|
||||
} from './mail.constants';
|
||||
import { describeBlockTarget, parseBlockTarget } from './mail-blocks';
|
||||
import { findFilterRulesProblem, findFilterRunProblem } from './mail-filters';
|
||||
import { MAIL_PROVIDER, type MailProvider } from './mail.provider';
|
||||
import { MAIL_SETTINGS, type MailSettings } from './mail.settings';
|
||||
@@ -118,6 +124,7 @@ import type {
|
||||
SentMessage,
|
||||
} from './mail.types';
|
||||
import { presentMailbox, presentMailboxes } from './mailbox.presenter';
|
||||
import { subtreeIds } from './mailbox-tree';
|
||||
import { highlightDetail } from './message-highlight';
|
||||
import { RecipientSuggestionService } from './recipient-suggestions.service';
|
||||
import {
|
||||
@@ -131,6 +138,7 @@ import {
|
||||
type LargeFilePlan,
|
||||
type RecognizedLargeFiles,
|
||||
} from './mail-large-files.service';
|
||||
import { undoSendHold } from './send-hold';
|
||||
|
||||
/**
|
||||
* 엔진 장애(MAIL_001)로 끝난 발송의 키를 잠가 두는 시간.
|
||||
@@ -242,6 +250,34 @@ function assertHtmlBytes(html: string): void {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 부재중 응답의 서식 본문(16단계) — 보내는 정화(그림 없음)를 거친 값, 없거나 비면 null. 정화 전 길이 · 파서 비용은 늘 보고,
|
||||
* 정화 뒤 MAX_VACATION_HTML_LENGTH 는 `enforceLength` 일 때만(켤 때 — 회사 밖은 따로 쓰기일 때) 본다. 넘으면 VAL_001 (사유 · 길이만 — 글은 남기지 않는다)
|
||||
*/
|
||||
function vacationHtml(
|
||||
raw: string | null | undefined,
|
||||
enforceLength: boolean,
|
||||
reason: 'vacation-html' | 'vacation-external-html',
|
||||
): string | null {
|
||||
const sanitized = sanitizeStoredHtml(raw, {
|
||||
maxLength: enforceLength ? MAX_VACATION_HTML_LENGTH : null,
|
||||
});
|
||||
if (!sanitized.ok) {
|
||||
throw new BusinessException('VAL_001', {
|
||||
context: {
|
||||
reason: `${reason}-${sanitized.problem}`,
|
||||
length: sanitized.length,
|
||||
},
|
||||
});
|
||||
}
|
||||
return sanitized.html;
|
||||
}
|
||||
|
||||
/** 회사 밖 응답의 글 칸 — 빈 글은 "쓴 적 없음"(null) */
|
||||
function writtenOrNull(value: string | null | undefined): string | null {
|
||||
return value === undefined || value === null || value === '' ? null : value;
|
||||
}
|
||||
|
||||
/** 읽음 · 중요 표시 · 이동 · 스팸 표시 — 하나 이상이어야 한다 (빈 요청은 VAL_001). 한 통·여러 통 PATCH 가 같이 쓴다 */
|
||||
function toMessageUpdate(dto: UpdateMessageDto): MessageUpdate {
|
||||
const { isRead, isFlagged, mailboxId, isJunk } = dto;
|
||||
@@ -336,13 +372,15 @@ export class MailService {
|
||||
/**
|
||||
* 진행 중인 오래 걸리는 처리(전체 읽음 · 비우기 · 지우기)의 (사용자, 메일함) — 같은 메일함에는 하나씩만, 종류가 달라도 막는다.
|
||||
* 두 탭이 같은 휴지통을 비우면 서로 상태가 어긋나 둘 다 멈추고, 동시 요청이 많으면 엔진 호출이 그 수만큼 곱절로 는다.
|
||||
* 15단계부터 하위까지 지우기는 나무의 메일함 모두를, 옮기기는 그 메일함과 새 상위를, 차단 정리는 그 직원의 스팸메일함을 잡는다.
|
||||
* ⚠️ 프로세스 메모리다. 인스턴스를 늘리면 Redis 잠금(SET NX PX)으로 옮겨야 인스턴스 사이에서도 하나씩이 된다.
|
||||
*/
|
||||
private readonly mailboxOperations = new Set<string>();
|
||||
|
||||
/**
|
||||
* 진행 중인 "이미 받은 메일에 필터 적용"의 직원 — 한 직원에 하나씩만 (계약 2절 "이미 받은 메일에 적용" — 다른 탭 · PC 에서 함께 하면 429 REQ_001).
|
||||
* 두 적용이 같은 받은편지함을 함께 읽으면 서로 옮긴 메일 때문에 자리가 어긋나고 엔진 호출이 그 수만큼 곱절로 는다.
|
||||
* 진행 중인 받은편지함 정리 — "이미 받은 메일에 필터 적용"(7단계)과 차단할 때 받은편지함 정리(15단계)의 직원. 한 직원에 하나씩만, 종류가 달라도 막는다
|
||||
* (계약 2절 "이미 받은 메일에 적용" · "보낸 사람 차단" — 다른 탭 · PC 에서 함께 하면 429 REQ_001).
|
||||
* 두 정리가 같은 받은편지함을 함께 읽으면 서로 옮긴 메일 때문에 자리가 어긋나고 엔진 호출이 그 수만큼 곱절로 는다.
|
||||
* ⚠️ 프로세스 메모리다. 인스턴스를 늘리면 Redis 잠금(SET NX PX)으로 옮겨야 인스턴스 사이에서도 하나씩이 된다.
|
||||
*/
|
||||
private readonly filterRuns = new Set<string>();
|
||||
@@ -415,32 +453,75 @@ export class MailService {
|
||||
);
|
||||
}
|
||||
|
||||
/** 메일함 이름 바꾸기 — 기본 메일함(역할 있음)은 VAL_001 */
|
||||
async renameMailbox(
|
||||
/**
|
||||
* 메일함 이름 바꾸기 · 옮기기 (15단계) — 이름 · 상위 가운데 보낸 것만 바꾼다. 둘 다 없으면 엔진에 묻지 않고 VAL_001.
|
||||
* 기본 메일함(역할 있음) · 만들 수 없는 자리 · 자기 하위 아래는 VAL_001, 없는 새 상위는 RES_001, 같은 이름은 MAIL_009 (어댑터).
|
||||
* 옮길 때(parentId 가 있을 때)는 그 메일함과 새 상위의 오래 걸리는 처리와 함께 하지 않는다 — 진행 중이면 REQ_001 (runExclusive — 지우는 나무 안으로 옮기거나 지우는 메일함을 빼 가지 않게).
|
||||
* 옮긴 로그는 어댑터가 남긴다 — 실제로 옮겼을 때만(지금 자리 그대로면 엔진에 묻지 않아 남기지 않는다), 메일함 id 만
|
||||
*/
|
||||
async updateMailbox(
|
||||
user: AuthenticatedUser,
|
||||
id: string,
|
||||
dto: RenameMailboxDto,
|
||||
dto: UpdateMailboxDto,
|
||||
): Promise<Mailbox> {
|
||||
const { name, parentId } = dto;
|
||||
if (name === undefined && parentId === undefined) {
|
||||
throw new BusinessException('VAL_001', {
|
||||
context: { reason: 'empty-mailbox-update' },
|
||||
});
|
||||
}
|
||||
if (parentId === undefined) {
|
||||
return presentMailbox(
|
||||
await this.provider.updateMailbox(user.email, id, { name }),
|
||||
);
|
||||
}
|
||||
|
||||
const keys = parentId === null ? [id] : [id, parentId];
|
||||
return presentMailbox(
|
||||
await this.provider.renameMailbox(user.email, id, dto.name),
|
||||
await this.runExclusive(user, keys, () =>
|
||||
this.provider.updateMailbox(user.email, id, {
|
||||
...(name === undefined ? {} : { name }),
|
||||
parentId,
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 메일함 지우기 — 안의 메일을 휴지통으로 옮긴 뒤 지운다(영구 삭제하지 않는다). 한 요청에 끝나지 않으면 deleted: false.
|
||||
* 같은 메일함의 오래 걸리는 처리는 하나씩만 (진행 중이면 REQ_001 — runExclusive).
|
||||
* 같은 메일함의 오래 걸리는 처리는 하나씩만 (진행 중이면 엔진에 묻지 않고 REQ_001 — runExclusive). 한 요청의 시간(25초)은 여기 들어온 때부터 센다.
|
||||
* 하위 메일함까지 지우기(withChildren=true — 15단계)는 나무(그 메일함과 하위 모두)를 지운다 — 그 메일함을 먼저 잡고(진행 중이면 엔진에 묻지 않고 REQ_001)
|
||||
* 메일함 목록을 읽어 하위 메일함까지 잡는다(하위가 처리 중이면 목록만 읽고 REQ_001 — 아무것도 바꾸지 않는다).
|
||||
* 지웠으면 info 로그에 개수만 남긴다 (메일함 이름 없이 — 사용자가 정한 글이다)
|
||||
*/
|
||||
async deleteMailbox(
|
||||
user: AuthenticatedUser,
|
||||
id: string,
|
||||
query: MailboxDeleteQueryDto = {},
|
||||
): Promise<MailboxDeletion> {
|
||||
const result = await this.runExclusive(user, id, () =>
|
||||
this.provider.deleteMailbox(user.email, id),
|
||||
);
|
||||
const startedAt = Date.now();
|
||||
const withChildren = query.withChildren === WITH_CHILDREN;
|
||||
let treeSize = 1;
|
||||
const result = await this.runExclusive(user, [id], async () => {
|
||||
if (!withChildren) {
|
||||
return this.provider.deleteMailbox(user.email, id, { startedAt });
|
||||
}
|
||||
// 나무의 하위 메일함 — 없는 메일함이면 없음(어댑터가 RES_001)
|
||||
const descendants = subtreeIds(
|
||||
await this.provider.listMailboxes(user.email),
|
||||
id,
|
||||
).filter((mailboxId) => mailboxId !== id);
|
||||
treeSize = descendants.length + 1;
|
||||
return this.runExclusive(user, descendants, () =>
|
||||
this.provider.deleteMailbox(user.email, id, {
|
||||
withChildren,
|
||||
startedAt,
|
||||
}),
|
||||
);
|
||||
});
|
||||
if (result.deleted) {
|
||||
this.logger.log(
|
||||
`${traceTag()} 메일함을 지웠습니다: user=${maskEmail(user.email)}, 이번 요청에서 휴지통으로 옮김=${result.movedToTrash}통 · 이 메일함에서만 뺌=${result.processed - result.movedToTrash}통`,
|
||||
`${traceTag()} 메일함을 지웠습니다: user=${maskEmail(user.email)}, ${withChildren ? `하위까지(메일함 ${treeSize}개), ` : ''}이번 요청에서 휴지통으로 옮김=${result.movedToTrash}통 · 이 메일함에서만 뺌=${result.processed - result.movedToTrash}통`,
|
||||
);
|
||||
}
|
||||
return result;
|
||||
@@ -461,7 +542,7 @@ export class MailService {
|
||||
context: { reason: 'unsupported-mailbox-messages-change' },
|
||||
});
|
||||
}
|
||||
return this.runExclusive(user, id, () =>
|
||||
return this.runExclusive(user, [id], () =>
|
||||
this.provider.markMailboxRead(user.email, id),
|
||||
);
|
||||
}
|
||||
@@ -474,7 +555,7 @@ export class MailService {
|
||||
user: AuthenticatedUser,
|
||||
id: string,
|
||||
): Promise<MailboxBulkResult> {
|
||||
const result = await this.runExclusive(user, id, () =>
|
||||
const result = await this.runExclusive(user, [id], () =>
|
||||
this.provider.emptyMailbox(user.email, id),
|
||||
);
|
||||
if (result.processed > 0) {
|
||||
@@ -498,18 +579,35 @@ export class MailService {
|
||||
* - 켤 때는 제목 150자 · 내용 600자까지 (엔진의 바이트 한도 안 — mail.constants.ts). 끈 채로는 요청 검증의 200자 · 4,000자까지 받는다 —
|
||||
* 4단계에 저장해 둔 긴 글이 켜져 있어도 끌 수 있게 (계약 2절 "길이 한도의 근거")
|
||||
* - 날짜는 초 단위로 맞춰 저장한다(소수 초를 버린다 — 엔진 규칙의 기간은 초 단위다). 시작 · 끝이 둘 다 있으면 **맞춘 값으로** 시작 < 끝
|
||||
* 어긋나면 엔진에 묻지 않고 VAL_001. 형식(늘 보는 길이 · 제어 문자 · 날짜 모양)은 요청 검증이 본다. 로그에는 켬 · 끔만 (제목 · 내용 없이)
|
||||
* - 서식 본문(16단계)은 보내는 정화(그림 없음)를 거쳐 저장한다 — 켤 때는 정화 뒤 10,000자까지(정화 전 길이 · 파서 비용은 늘 본다)
|
||||
* - 회사 밖 응답(16단계) — 따로 쓰기이고 켤 때만 그 제목 · 내용을 사내와 같은 규칙(1자 이상 · 150 · 600자 · 서식 10,000자)으로 본다.
|
||||
* 사내와 같은 응답 · 보내지 않기면 비어 있어도 저장하고, 따로 써 둔 글은 고르기와 상관없이 그대로 저장한다(빈 글은 null)
|
||||
* - 새 칸이 없는 옛 요청은 글만 · 사내와 같은 응답으로 저장한다(옛 화면이 보는 그대로)
|
||||
* 어긋나면 엔진에 묻지 않고 VAL_001. 형식(늘 보는 길이 · 제어 문자 · 날짜 모양)은 요청 검증이 본다. 로그에는 켬 · 끔 · 서식 있음 · 회사 밖 고르기만 (제목 · 내용 없이)
|
||||
*/
|
||||
async updateVacation(
|
||||
user: AuthenticatedUser,
|
||||
dto: MailVacationDto,
|
||||
): Promise<MailVacationState> {
|
||||
const external = dto.external ?? null;
|
||||
const mode = external?.mode ?? 'same';
|
||||
const vacation: MailVacation = {
|
||||
isEnabled: dto.isEnabled,
|
||||
fromDate: dto.fromDate === null ? null : toWholeSecondIso(dto.fromDate),
|
||||
toDate: dto.toDate === null ? null : toWholeSecondIso(dto.toDate),
|
||||
subject: dto.subject,
|
||||
textBody: dto.textBody,
|
||||
htmlBody: vacationHtml(dto.htmlBody, dto.isEnabled, 'vacation-html'),
|
||||
external: {
|
||||
mode,
|
||||
subject: writtenOrNull(external?.subject),
|
||||
textBody: writtenOrNull(external?.textBody),
|
||||
htmlBody: vacationHtml(
|
||||
external?.htmlBody,
|
||||
dto.isEnabled && mode === 'separate',
|
||||
'vacation-external-html',
|
||||
),
|
||||
},
|
||||
};
|
||||
if (
|
||||
vacation.isEnabled &&
|
||||
@@ -533,6 +631,27 @@ export class MailService {
|
||||
},
|
||||
});
|
||||
}
|
||||
if (vacation.isEnabled && mode === 'separate') {
|
||||
const subject = vacation.external.subject ?? '';
|
||||
const textBody = vacation.external.textBody ?? '';
|
||||
if (subject.trim() === '' || textBody.trim() === '') {
|
||||
throw new BusinessException('VAL_001', {
|
||||
context: { reason: 'vacation-external-without-text' },
|
||||
});
|
||||
}
|
||||
if (
|
||||
subject.length > MAX_VACATION_SUBJECT_LENGTH ||
|
||||
textBody.length > MAX_VACATION_TEXT_LENGTH
|
||||
) {
|
||||
throw new BusinessException('VAL_001', {
|
||||
context: {
|
||||
reason: 'vacation-external-text-too-long',
|
||||
subjectLength: subject.length,
|
||||
textLength: textBody.length,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
if (
|
||||
vacation.fromDate !== null &&
|
||||
vacation.toDate !== null &&
|
||||
@@ -545,7 +664,7 @@ export class MailService {
|
||||
|
||||
const saved = await this.provider.setVacation(user.email, vacation);
|
||||
this.logger.log(
|
||||
`${traceTag()} 부재중 자동 응답을 저장했습니다: user=${maskEmail(user.email)}, ${saved.isEnabled ? '켬' : '끔'}`,
|
||||
`${traceTag()} 부재중 자동 응답을 저장했습니다: user=${maskEmail(user.email)}, ${saved.isEnabled ? '켬' : '끔'}, 서식 ${saved.htmlBody !== null ? '있음' : '없음'}, 회사 밖 ${saved.external.mode}`,
|
||||
);
|
||||
return saved;
|
||||
}
|
||||
@@ -648,6 +767,61 @@ export class MailService {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 차단할 때 받은편지함 정리 (계약 2절 "보낸 사람 차단" — 15단계) — 받은편지함에 이미 있는 그 보낸 사람의 메일을 스팸메일함으로 옮긴다.
|
||||
* 이미 받은 메일에 필터 적용(7단계)과 같은 틀이다 — 한 요청의 시간(25초)은 여기 들어온 때부터, 이어 할 자리(cursor)는 어댑터가 만든다.
|
||||
* - 보낸 사람은 차단 목록의 모양(주소 · `@도메인`)이어야 한다 — 아니면 엔진에 묻지 않고 VAL_001
|
||||
* - **같은 직원의 받은편지함 정리(필터 적용 · 이것)는 한 번에 하나** — 같은 잠금(filterRuns), 진행 중이면 엔진에 묻지 않고 REQ_001
|
||||
* - 차단 목록에 없으면(차단한 뒤에만 부른다 · 그사이 풀었다) RES_001 — 요청마다 본다(도중에 풀면 다음 요청에서 멈춘다)
|
||||
* - 요청이 도는 동안 그 직원의 스팸메일함을 메일함 처리 잠금(mailboxOperations)으로 잡는다 — 옮겨 넣는 동안 스팸메일함 비우기 · 모두 읽음이 함께 돌지 않게.
|
||||
* 비우는 중이면 메일함 목록을 읽은 뒤 REQ_001 (받은편지함은 건드리지 않았다)
|
||||
* 로그에는 가린 보낸 사람(도메인 차단은 도메인만) · 확인 · 옮긴 수 · 차례 수 · 걸린 시간만 남긴다
|
||||
*/
|
||||
async runMailBlock(
|
||||
user: AuthenticatedUser,
|
||||
dto: MailBlockRunRequestDto,
|
||||
): Promise<MailFilterRun> {
|
||||
const startedAt = Date.now();
|
||||
const target = parseBlockTarget(dto.sender);
|
||||
if (target === null) {
|
||||
throw new BusinessException('VAL_001', {
|
||||
context: { reason: 'malformed-mail-block-run-sender' },
|
||||
});
|
||||
}
|
||||
|
||||
return this.exclusive(
|
||||
this.filterRuns,
|
||||
user.email.toLowerCase(),
|
||||
'mail-block-run-in-progress',
|
||||
user,
|
||||
async () => {
|
||||
const { senders } = await this.provider.getMailBlocks(user.email);
|
||||
if (!senders.some(({ address }) => address === target.value)) {
|
||||
throw new BusinessException('RES_001', {
|
||||
context: { reason: 'mail-block-not-found', kind: target.kind },
|
||||
});
|
||||
}
|
||||
const mailboxes = await this.provider.listMailboxes(user.email);
|
||||
// 스팸메일함이 없으면 잡을 것이 없다 — 어댑터가 MAIL_001 로 멈춘다
|
||||
const junk = mailboxes.find((mailbox) => mailbox.role === 'junk');
|
||||
const { rounds, ...result } = await this.runExclusive(
|
||||
user,
|
||||
junk ? [junk.id] : [],
|
||||
() =>
|
||||
this.provider.runMailBlock(
|
||||
user.email,
|
||||
{ sender: target.value, cursor: dto.cursor },
|
||||
{ mailboxes, startedAt },
|
||||
),
|
||||
);
|
||||
this.logger.log(
|
||||
`${traceTag()} 차단한 보낸 사람의 메일을 받은편지함에서 스팸메일함으로 옮겼습니다: user=${maskEmail(user.email)}, ${describeBlockTarget(target)}, 확인 ${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,
|
||||
@@ -974,12 +1148,10 @@ export class MailService {
|
||||
);
|
||||
}
|
||||
|
||||
/** 보내기 취소를 기다리는 붙잡기 — 그 직원 설정의 초(0 이면 붙잡지 않는다) */
|
||||
/** 보내기 취소를 기다리는 붙잡기 — 그 직원 설정의 초(0 이면 붙잡지 않는다 — send-hold.ts) */
|
||||
private async undoHold(user: AuthenticatedUser): Promise<SendHold | null> {
|
||||
const { undoSendSeconds } = await this.userSettingsOf(user);
|
||||
return undoSendSeconds > 0
|
||||
? { kind: 'undo', seconds: undoSendSeconds }
|
||||
: null;
|
||||
return undoSendHold(undoSendSeconds);
|
||||
}
|
||||
|
||||
/** 임시 저장 — 처음 저장 (계약 2절 "임시 저장") */
|
||||
@@ -1091,15 +1263,19 @@ export class MailService {
|
||||
);
|
||||
}
|
||||
|
||||
/** 같은 직원 · 같은 메일함의 오래 걸리는 처리를 하나씩만 한다 — 다른 메일함끼리는 동시에 된다 (exclusive) */
|
||||
/**
|
||||
* 같은 직원 · 같은 메일함의 오래 걸리는 처리를 하나씩만 한다 — 다른 메일함끼리는 동시에 된다 (exclusive).
|
||||
* 메일함 여럿(하위까지 지우기의 나무 · 옮기기의 그 메일함과 새 상위 — 15단계)은 모두 비어 있을 때만 한꺼번에 잡는다
|
||||
*/
|
||||
private runExclusive<T>(
|
||||
user: AuthenticatedUser,
|
||||
mailboxId: string,
|
||||
mailboxIds: readonly string[],
|
||||
operation: () => Promise<T>,
|
||||
): Promise<T> {
|
||||
const owner = user.email.toLowerCase();
|
||||
return this.exclusive(
|
||||
this.mailboxOperations,
|
||||
`${user.email.toLowerCase()}\n${mailboxId}`,
|
||||
mailboxIds.map((mailboxId) => `${owner}\n${mailboxId}`),
|
||||
'mailbox-operation-in-progress',
|
||||
user,
|
||||
operation,
|
||||
@@ -1108,25 +1284,27 @@ export class MailService {
|
||||
|
||||
/**
|
||||
* 같은 열쇠의 일을 하나씩만 한다 — 진행 중이면 엔진에 묻지 않고 바로 REQ_001 (로그인의 진행 중 시도와 같은 모양).
|
||||
* 열쇠가 여럿이면 하나라도 잡혀 있을 때 REQ_001 이고, 모두 비었으면 await 없이 한꺼번에 잡는다(사이에 다른 요청이 끼지 않는다).
|
||||
* 끝나면 성공 · 실패와 상관없이 푼다
|
||||
*/
|
||||
private async exclusive<T>(
|
||||
running: Set<string>,
|
||||
key: string,
|
||||
keys: string | readonly string[],
|
||||
reason: string,
|
||||
user: AuthenticatedUser,
|
||||
operation: () => Promise<T>,
|
||||
): Promise<T> {
|
||||
if (running.has(key)) {
|
||||
const held = [...new Set(typeof keys === 'string' ? [keys] : keys)];
|
||||
if (held.some((key) => running.has(key))) {
|
||||
throw new BusinessException('REQ_001', {
|
||||
context: { reason, user: maskEmail(user.email) },
|
||||
});
|
||||
}
|
||||
running.add(key);
|
||||
for (const key of held) running.add(key);
|
||||
try {
|
||||
return await operation();
|
||||
} finally {
|
||||
running.delete(key);
|
||||
for (const key of held) running.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user