From bd2f55ad5ab3557dfaf5d5006eca3534ee3e461e Mon Sep 17 00:00:00 2001 From: ttipo Date: Thu, 29 Oct 2026 14:16:53 +0900 Subject: [PATCH] =?UTF-8?q?docs(rules):=208=EB=8B=A8=EA=B3=84=20=EB=B6=80?= =?UTF-8?q?=ED=92=88=20=E2=80=94=20=EC=A3=BC=EC=86=8C=EB=A1=9D=20=EB=AA=A8?= =?UTF-8?q?=EB=93=88=20=C2=B7=20DTO=20=EA=B2=80=EC=82=AC=20=EC=B0=A8?= =?UTF-8?q?=EB=A1=80=20=C2=B7=20=EB=A7=9E=EC=B6=94=EA=B8=B0=20=EC=A7=9D=20?= =?UTF-8?q?=C2=B7=20=ED=99=95=EC=9E=A5=20=EB=95=8C=20=EC=98=AE=EA=B8=B8=20?= =?UTF-8?q?=EB=90=98=EB=8F=8C=EB=A6=AC=EA=B8=B0=20=ED=91=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit nestjs: 주소록 행(캐시 · 크기 한도 · PATCH 병합 · 되돌리기 표), 받는 사람 q 맞추기가 frontend 와 짝이라는 것, DTO 검사 차례(그냥 쌓으면 아래 것부터 돈다 — applyDecorators 로 형식 → 길이 → 모양), 엔진 사실(같은 id 를 한 요청의 destroy 에 두 번 넣지 않는다), 인스턴스를 늘릴 때 옮길 메모리 목록에 되돌리기 표. vue3: 주소록 · 메일과 함께 행, 받는 사람 자동 완성이 초성도 서버에 묻는 것, ModalDialog medium, 8단계의 밀도 예외(새 토큰 없이 다시 씀). Co-Authored-By: Claude Opus 5.5 --- .claude/skills/nestjs/SKILL.md | 19 ++++++++++++++++--- .claude/skills/vue3/SKILL.md | 8 +++++--- 2 files changed, 21 insertions(+), 6 deletions(-) diff --git a/.claude/skills/nestjs/SKILL.md b/.claude/skills/nestjs/SKILL.md index 71ed7ec..6f219c3 100644 --- a/.claude/skills/nestjs/SKILL.md +++ b/.claude/skills/nestjs/SKILL.md @@ -32,9 +32,10 @@ description: NestJS 백엔드(backend/) 개발 규칙 — 컨트롤러/서비스 | 길이 검사 데코레이터 | `src/common/decorators/code-units.decorator.ts` | `MaxCodeUnits` · `MinCodeUnits` — JS 문자열 길이(UTF-16 코드 단위)로 센다(웹의 `.length` 와 같다). class-validator 의 `MinLength`·`MaxLength` 는 서로게이트 쌍 · 이모지를 덜 세어 클라이언트가 센 길이와 어긋난다 — 클라이언트와 같은 한도를 거는 문자열은 이것을 쓴다 | | 공백 · 중복 데코레이터 | `src/common/decorators/trim-string.decorator.ts` · `array-distinct.decorator.ts` | `TrimString()` — 앞뒤 공백을 지운 값으로 검사하고 그 값으로 저장한다(메일함 이름 · 필터의 이름 · 찾을 글 · 전달 주소). `ArrayDistinct(options)` — 배열 항목이 겹치지 않는지 Set 으로 한 번 훑는다(선형 — 원시 값은 값으로, 객체는 같은 객체일 때만. 객체 배열의 id 중복은 서비스가 Set 으로 본다 — 예: 필터 규칙 `findFilterRulesProblem`). class-validator 의 `ArrayUnique` 는 `indexOf` 비교라 항목 수의 제곱이다 — **쓰지 않는다** | | 직원 인증 | `modules/auth` | 쿠키 JWT 로그인·`me`·`refresh`·`logout` (계약 `docs/mail/api-contract.md` 1절) · 비밀번호 변경 `PUT /api/auth/password`(4단계 — 직원 본인 자격으로 엔진의 `x:AccountPassword/set`, 로그인과 같은 실패 카운터 · 진행 중 잠금, 바뀌면 이 요청의 세션만 남기고 그 직원의 세션을 끊는다 — 결과를 모르는 실패에도 끊는다). 설정 `auth.settings.ts`, 쿠키 `auth-cookies.ts`, 로그인 실패 횟수 `login-attempt.service.ts`, **로그인 세션(DB)** `auth-session.entity.ts`·`auth-session.policy.ts`(1회용 회전·재사용 감지)·`auth-session.cleanup.ts`(끝난 세션 정리 cron). **메일 엔진 관리자 차단** — 정적 목록(`employee-address.ts` + `MAIL_BLOCKED_ACCOUNTS`)과 로그인·리프레시 때 엔진 권한 확인(`jmap/jmap.client.ts`) 두 겹 | -| 메일 (웹메일 API) | `modules/mail` | 엔진 포트 `mail.provider.ts`(`MAIL_PROVIDER`) + Stalwart JMAP 어댑터 `jmap/`, 받은 메일 HTML 정화 `html/sanitize-mail-html.ts`, 보내는 서식 본문 정화(허용 목록) `html/sanitize-outgoing-html.ts`, 두 정화 앞의 사전 검사(길이·파서 스택 비용, 선형 시간) `html/html-parse-budget.ts`, 첨부 형식 `attachment.policy.ts`, 설정 `mail.settings.ts`, 발송 중복 방지(`Idempotency-Key`) `send-idempotency.ts`, 발송의 받는 사람별 결과 `jmap/delivery-status.ts`(제출 다음에 따로 읽는다 — 일부 거절은 201 의 `rejectedRecipients`, 모두 거절이면 보낸 사본을 지우고 `MAIL_010` · `MAIL_011`, 계약 2절 "받는 사람별 결과"), 첨부 임시 디스크 저장 `mail-upload-storage.ts`, 임시 저장 `draft.controller.ts`(엔진 초안 — 새로 만들고 다시 읽은 뒤 옛것을 지운다), 서명 그림 주소 규칙 `html/signature-images.ts`(보낼 때 `cid:` 로), 본문 그림 판정 `html/inline-images.ts`, 받는 사람 추천 `recipient-suggestions.service.ts`(사용자별 10분 캐시) · 순서 `recipient-ranking.ts` · 커서 `recipient-suggestion-cursor.ts`, 검색어 → 엔진 조건 `jmap/search-terms.ts`(한국어 조사 표는 여기 한 곳 — 낱말 안 OR · 낱말끼리 AND, 따옴표 구절), 여러 통 `PATCH /api/messages`(엔진 요청 한 번) · 휴지통에서 영구 삭제 `message-deletion.controller.ts`(휴지통의 메일만, 조건부 지우기) — 본문 `dto/bulk-messages.dto.ts`, 새 메일 확인 `GET /api/mail-state`(`mailbox.controller.ts`), 메일함 관리 · 전체 읽음 · 비우기(`mailbox.controller.ts`, 본문 `dto/mailbox.dto.ts` — 오래 걸리는 처리는 어댑터(`BulkRun` — `jmap/bulk-run.ts`)가 500통씩 · 한 요청 25초 안에서(엔진 호출마다 남은 시간만큼) 하고 남은 수를 돌려준다, 같은 직원 · 같은 메일함은 한 번에 하나(`mail.service.ts` 의 `mailboxOperations` — 진행 중이면 `REQ_001`), 지우기는 안의 메일을 휴지통으로 옮긴 뒤) · 부재중 자동 응답 `MailVacationController`(`dto/mail-vacation.dto.ts` — 6단계부터 저장 자리는 아래 필터의 Sieve 스크립트. 응답의 `isActive`(7단계)는 엔진에서 그 스크립트가 켜져 있는지 — 본문으로는 받지 않는다) · 메일 필터 `mail-filter.controller.ts`(6단계 — `GET`·`PUT /api/mail-filters`, 본문 `dto/mail-filter.dto.ts`, 칸 사이 규칙 `mail-filters.ts`. 엔진 쪽은 Sieve 스크립트 하나(`arasystem` — 필터와 **부재중을 함께**, 엔진의 VacationResponse 는 쓰지 않고 처음 읽을 때만 이어받는다): 저장소 `jmap/jmap-filter-script.ts`(읽은 상태 그대로일 때만 쓰고 켠다 · 그사이 바뀌면 다시 읽기 · 같은 규칙이면 옛 version 이어도 저장), 글 만들기 `jmap/sieve-script.ts`, 머리 주석의 원본(JSON · 판 `FILTER_SOURCE_FORMAT`) `jmap/filter-source.ts`, 부재중 제목 · 본문 인코딩 `jmap/mime-text.ts`, JMAP 결과 읽기 `jmap/jmap-results.ts`) · 이미 받은 메일에 필터 적용 `POST /api/mail-filter-runs`(7단계 — `mail-filter.controller.ts`, 본문 `dto/mail-filter-run.dto.ts`. 저장한 필터가 아니라 본문의 규칙으로 받은편지함을 최근 메일부터 판정하고 표시 · 옮기기만 한다(전달 · 지우기 없음). 판정 `mail-filter-match.ts` 는 **`jmap/sieve-script.ts` 의 `FIELD_TESTS` 와 짝**이다 — 엔진 검색은 낱말 단위라 쓰지 않고 Sieve 의 판정을 그대로 옮겼다(한쪽을 바꾸면 다른 쪽도). 견줄 글 `jmap/filter-run-texts.ts`(원래 머리글 `header:From:all` 등 — `:asRaw` 는 붙이지 않는다(엔진이 응답의 이름에서 뗀다), 풀기 `jmap/mime-text.ts` 의 `decodeHeaderText` — 접힌 줄은 띄어쓰기 하나, 인코딩된 글은 홀로 선 것만), HTML 글자 `html/html-text.ts`(엔진의 `body :text` 와 같게 — 닫는 `p` · `br` 만 줄을 끊고 `style` · `script` · `head` · `template` 은 글째 빼며 그 밖의 태그는 빈자리 없이 뺀다. 속성이 붙은 태그는 엔진처럼 알아보지 않는다 — 2026-10-29 인증 발송 네 묶음으로 맞춤, 규칙은 계약 2절), 어댑터 `jmap/jmap-filter-run.ts`(머리글 500통 · 본문 100통 차례, 본문 값은 부분마다 256KiB · 한 통 1MiB — 바깥 메일 한 통이 메모리를 크게 쓰지 않게), 이어 할 자리 `jmap/filter-run-cursor.ts`(2판 — 위 경계 · anchor · 그 받은 시각 · 규칙 지문. 모든 조회에 `before` 를 걸어 시작한 뒤 온 메일은 보지 않고, 빠진 anchor 는 그 초부터 다시 읽는다). 한 요청의 시간은 메일함 처리와 같은 `BulkRun`, 같은 직원은 한 번에 하나(`mail.service.ts` 의 `filterRuns` — 진행 중이면 `REQ_001`)) · 원문 `GET /api/messages/{id}/source`(이름 `attachment.policy.ts` 의 `messageSourceFileName`) · 스팸 표시 `isJunk` (계약 2절) · 실시간 알림 `GET /api/mail-events`(5단계 — SSE, `mail-events.controller.ts` · 엔진을 모르는 `mail-events.service.ts`(직원마다 연결 5개 · 25초 ping · 토큰 만료 30초 전 `end` · 종료 때 `end`) · 엔진 쪽 `jmap/jmap-mail-watch.ts`(직원마다 엔진 EventSource 하나 · 75초 무소식이면 다시 잇기) · `jmap/jmap-push.ts`(스트림 읽기) · `jmap/new-mail.ts`(새 메일 거르기), SSE 형식 `shared/utils/sse.ts`. 머리글은 스트림을 열기로 정하면 바로(`flushHeaders`), 처리기에 닿기 전에 끊긴 응답(`res.destroyed`)도 정리한다) | +| 메일 (웹메일 API) | `modules/mail` | 엔진 포트 `mail.provider.ts`(`MAIL_PROVIDER`) + Stalwart JMAP 어댑터 `jmap/`, 받은 메일 HTML 정화 `html/sanitize-mail-html.ts`, 보내는 서식 본문 정화(허용 목록) `html/sanitize-outgoing-html.ts`, 두 정화 앞의 사전 검사(길이·파서 스택 비용, 선형 시간) `html/html-parse-budget.ts`, 첨부 형식 `attachment.policy.ts`, 설정 `mail.settings.ts`, 발송 중복 방지(`Idempotency-Key`) `send-idempotency.ts`, 발송의 받는 사람별 결과 `jmap/delivery-status.ts`(제출 다음에 따로 읽는다 — 일부 거절은 201 의 `rejectedRecipients`, 모두 거절이면 보낸 사본을 지우고 `MAIL_010` · `MAIL_011`, 계약 2절 "받는 사람별 결과"), 첨부 임시 디스크 저장 `mail-upload-storage.ts`, 임시 저장 `draft.controller.ts`(엔진 초안 — 새로 만들고 다시 읽은 뒤 옛것을 지운다), 서명 그림 주소 규칙 `html/signature-images.ts`(보낼 때 `cid:` 로), 본문 그림 판정 `html/inline-images.ts`, 받는 사람 추천 `recipient-suggestions.service.ts`(사용자별 10분 캐시) · 순서 `recipient-ranking.ts` · 커서 `recipient-suggestion-cursor.ts` · q 맞추기 `recipient-match.ts`(8단계 — **frontend `shared/utils/recipient-match.ts` 와 짝**, 같은 표본 표를 두 쪽 테스트에 둔다 — 한쪽을 고치면 둘 다. 이름은 코드 포인트로 한 번 훑는다 — 길이에 비례), 검색어 → 엔진 조건 `jmap/search-terms.ts`(한국어 조사 표는 여기 한 곳 — 낱말 안 OR · 낱말끼리 AND, 따옴표 구절), 여러 통 `PATCH /api/messages`(엔진 요청 한 번) · 휴지통에서 영구 삭제 `message-deletion.controller.ts`(휴지통의 메일만, 조건부 지우기) — 본문 `dto/bulk-messages.dto.ts`, 새 메일 확인 `GET /api/mail-state`(`mailbox.controller.ts`), 메일함 관리 · 전체 읽음 · 비우기(`mailbox.controller.ts`, 본문 `dto/mailbox.dto.ts` — 오래 걸리는 처리는 어댑터(`BulkRun` — `jmap/bulk-run.ts`)가 500통씩 · 한 요청 25초 안에서(엔진 호출마다 남은 시간만큼) 하고 남은 수를 돌려준다, 같은 직원 · 같은 메일함은 한 번에 하나(`mail.service.ts` 의 `mailboxOperations` — 진행 중이면 `REQ_001`), 지우기는 안의 메일을 휴지통으로 옮긴 뒤) · 부재중 자동 응답 `MailVacationController`(`dto/mail-vacation.dto.ts` — 6단계부터 저장 자리는 아래 필터의 Sieve 스크립트. 응답의 `isActive`(7단계)는 엔진에서 그 스크립트가 켜져 있는지 — 본문으로는 받지 않는다) · 메일 필터 `mail-filter.controller.ts`(6단계 — `GET`·`PUT /api/mail-filters`, 본문 `dto/mail-filter.dto.ts`, 칸 사이 규칙 `mail-filters.ts`. 엔진 쪽은 Sieve 스크립트 하나(`arasystem` — 필터와 **부재중을 함께**, 엔진의 VacationResponse 는 쓰지 않고 처음 읽을 때만 이어받는다): 저장소 `jmap/jmap-filter-script.ts`(읽은 상태 그대로일 때만 쓰고 켠다 · 그사이 바뀌면 다시 읽기 · 같은 규칙이면 옛 version 이어도 저장), 글 만들기 `jmap/sieve-script.ts`, 머리 주석의 원본(JSON · 판 `FILTER_SOURCE_FORMAT`) `jmap/filter-source.ts`, 부재중 제목 · 본문 인코딩 `jmap/mime-text.ts`, JMAP 결과 읽기 `jmap/jmap-results.ts`) · 이미 받은 메일에 필터 적용 `POST /api/mail-filter-runs`(7단계 — `mail-filter.controller.ts`, 본문 `dto/mail-filter-run.dto.ts`. 저장한 필터가 아니라 본문의 규칙으로 받은편지함을 최근 메일부터 판정하고 표시 · 옮기기만 한다(전달 · 지우기 없음). 판정 `mail-filter-match.ts` 는 **`jmap/sieve-script.ts` 의 `FIELD_TESTS` 와 짝**이다 — 엔진 검색은 낱말 단위라 쓰지 않고 Sieve 의 판정을 그대로 옮겼다(한쪽을 바꾸면 다른 쪽도). 견줄 글 `jmap/filter-run-texts.ts`(원래 머리글 `header:From:all` 등 — `:asRaw` 는 붙이지 않는다(엔진이 응답의 이름에서 뗀다), 풀기 `jmap/mime-text.ts` 의 `decodeHeaderText` — 접힌 줄은 띄어쓰기 하나, 인코딩된 글은 홀로 선 것만), HTML 글자 `html/html-text.ts`(엔진의 `body :text` 와 같게 — 닫는 `p` · `br` 만 줄을 끊고 `style` · `script` · `head` · `template` 은 글째 빼며 그 밖의 태그는 빈자리 없이 뺀다. 속성이 붙은 태그는 엔진처럼 알아보지 않는다 — 2026-10-29 인증 발송 네 묶음으로 맞춤, 규칙은 계약 2절), 어댑터 `jmap/jmap-filter-run.ts`(머리글 500통 · 본문 100통 차례, 본문 값은 부분마다 256KiB · 한 통 1MiB — 바깥 메일 한 통이 메모리를 크게 쓰지 않게), 이어 할 자리 `jmap/filter-run-cursor.ts`(2판 — 위 경계 · anchor · 그 받은 시각 · 규칙 지문. 모든 조회에 `before` 를 걸어 시작한 뒤 온 메일은 보지 않고, 빠진 anchor 는 그 초부터 다시 읽는다). 한 요청의 시간은 메일함 처리와 같은 `BulkRun`, 같은 직원은 한 번에 하나(`mail.service.ts` 의 `filterRuns` — 진행 중이면 `REQ_001`)) · 원문 `GET /api/messages/{id}/source`(이름 `attachment.policy.ts` 의 `messageSourceFileName`) · 스팸 표시 `isJunk` (계약 2절) · 실시간 알림 `GET /api/mail-events`(5단계 — SSE, `mail-events.controller.ts` · 엔진을 모르는 `mail-events.service.ts`(직원마다 연결 5개 · 25초 ping · 토큰 만료 30초 전 `end` · 종료 때 `end`) · 엔진 쪽 `jmap/jmap-mail-watch.ts`(직원마다 엔진 EventSource 하나 · 75초 무소식이면 다시 잇기) · `jmap/jmap-push.ts`(스트림 읽기) · `jmap/new-mail.ts`(새 메일 거르기), SSE 형식 `shared/utils/sse.ts`. 머리글은 스트림을 열기로 정하면 바로(`flushHeaders`), 처리기에 닿기 전에 끊긴 응답(`res.destroyed`)도 정리한다) | | 메일 서명 | `modules/mail-signature` | 사용자마다 서명 하나(DB) + 서명 그림. 그림 형식은 내용으로 판별(매직 바이트·머리의 가로·세로, 새 의존성 없음) `signature-image-format.ts`, 파일은 업로드 볼륨의 `mail-signatures/` (`signature-image-storage.ts`), 서명에 붙지 않은 그림 정리 cron `mail-signature.cleanup.ts`. 메일 모듈이 발송·임시 저장 때 `MailSignatureService` 로 그림을 쓴다 (계약 2절 "서명") | -| 사내 목록 | `modules/mail-directory` | 웹메일 로그인에 성공한 직원(관리자·게이트웨이·막은 계정 제외)을 `AuthService` 가 기록 → 받는 사람 추천의 "사내". 엔진의 계정 목록(Principal) 권한은 쓰지 않는다(일부러 막아 두었다) | +| 사내 목록(로그인 기록) | `modules/mail-directory` | 웹메일 로그인에 성공한 직원(관리자·게이트웨이·막은 계정 제외)을 `AuthService` 가 기록한다 — 8단계부터는 엔진의 사내 목록(아래 주소록)을 **읽지 못할 때만** 받는 사람 추천의 "사내"를 대신한다. 직원 역할에는 엔진의 계정 목록(Principal) 권한이 없다(일부러 막아 두었다) | +| 주소록 (8단계) | `modules/contacts` | 내 주소록(연락처 · 그룹)과 사내 목록 — 계약 3절. 컨트롤러 `contacts.controller.ts` · `contact-groups.controller.ts` · `directory-entries.controller.ts`, 서비스 `contacts.service.ts`(직원마다 주소록 캐시 `contact-book-cache.ts` — 엔진 state 로 확인(`ContactCard/get` 빈 ids, 3ms, 판은 읽기 전에 물은 것), 200명 · 모두 2,400만 · 한 사람 800만 · 30분, 같은 직원의 바꾸기는 차례로(`writeQueues`), 엔진에는 `ifInState` — 어긋남 · `serverUnavailable` 은 다시 읽어 세 번까지, 넘으면 `MAIL_001`. 쓰기의 크기 한도 800만(`MAX_ADDRESS_BOOK_WEIGHT` — 캐시의 한 사람 상한과 같은 값, 넘게 커지는 쓰기는 `CONTACT_002`). 고치기는 `PATCH`(보낸 칸만 검사) — 칸 안의 남의 속성은 어댑터 `contact-card.ts` 의 `contactPatch` 가 지금 카드와 합쳐 지킨다. 되돌리기 표 `contact-restore-store.ts`(지우기 직전에 읽은 카드 전체 — 5분 · 직원마다 20개 · 256KB · 모두 32MB) + `contact-restorations.controller.ts`) · `directory.service.ts`(사내 목록 10분 캐시, 거르기 `directory-policy.ts` — 로그인 막기와 같은 `isEmployeeAddress` + 설명의 `[setup.mjs]`). 순수 함수 `address-book.ts` · `contact-search.ts`(초성 · 겹자음 · 전화 숫자 · NFC) · `contact-paging.ts`(위치 커서 — `shared/utils/offset-cursor.ts`, 받는 사람 추천 커서도 이것), 경로 id `contact-id.pipe.ts`, 빈 글 → null `TrimStringOrNull()`. 포트 `contacts.provider.ts`(`CONTACTS_PROVIDER` · `DIRECTORY_PROVIDER`) — 엔진 어댑터는 `modules/mail/jmap/` 의 `jmap-contacts.provider.ts` · `contact-card.ts`(JSContact ↔ 도메인) · `jmap-directory.provider.ts`(`JmapClient.callDirectory()` — **사내 목록 계정만** 대리 접근). 엔진 교체는 `contacts.module.ts` 의 useClass 만. 모듈 순환을 막으려고 `MAIL_SETTINGS` · `JmapClient` 는 `modules/mail/mail-engine.module.ts` 에 둔다(메일 → 주소록 방향만, 인증 모듈은 이것을 가져온다). 사내 목록 계정(`MAIL_DIRECTORY_LOCAL_PART`, 기본 `mail-directory`)은 setup 이 복구 관리자로만 만든다 — `mail.settings.ts` 의 막는 목록에도 들어 있다 | | Rate Limiting | `app.module.ts` 의 `ClientIpThrottlerGuard`(APP_GUARD, `common/guards/client-ip-throttler.guard.ts`) | 초과 시 429 → `REQ_001`. 루트 `.env` 의 `THROTTLE_*` 로 조정. `ThrottlerGuard` 를 상속해 tracker 만 바꿨다 — IP 는 `shared/utils/client-ip.ts` 의 `clientIpKey()`(IPv6 는 /64 로 묶음, 로그인 실패 카운터와 같은 함수) | | 로깅 | `shared/logger/winston.config.ts` | `console.log` 대신 사용 ([[logging]]) | | env 검증 | `shared/config/env.validation.ts` | 잘못된 설정이면 **기동 실패**(fail-fast) ([[env-structure]]) | @@ -234,7 +235,10 @@ SwaggerModule.setup('api-docs', app, document); > 같은 직원의 이미 받은 메일에 필터 적용(`mail.service.ts` 의 `filterRuns` — 그대로면 두 인스턴스가 같은 받은편지함을 함께 읽으며 서로 옮긴 메일 때문에 자리가 어긋난다 → 잠금), > 사용자별 서명 그림 올리기 차례(`mail-signature.service.ts` 의 `uploadQueues` — 그대로면 동시 업로드가 보관 상한을 넘을 수 있다 → 잠금), > 받는 사람 추천 캐시(`recipient-suggestions.service.ts` — 틀리지는 않지만 보낸 직후 다른 인스턴스가 10분까지 옛 목록을 준다 → 공유 캐시나 무효화 알림), - > 실시간 알림의 직원당 연결 5개(`mail-events.service.ts` — 기능은 맞지만 인스턴스마다 따로 세고, 엔진 연결도 인스턴스마다 하나씩 생긴다 → 필요하면 공유 셈): + > 실시간 알림의 직원당 연결 5개(`mail-events.service.ts` — 기능은 맞지만 인스턴스마다 따로 세고, 엔진 연결도 인스턴스마다 하나씩 생긴다 → 필요하면 공유 셈), + > 주소록의 직원마다 캐시 · 바꾸기 차례 · 사내 목록 10분 캐시(`contacts.service.ts` · `directory.service.ts` — 캐시는 엔진 state 로 확인하고 쓰기는 `ifInState` 라 틀리지는 않는다. + > 인스턴스마다 따로 읽어 엔진 읽기가 늘고, 같은 직원의 동시 바꾸기가 어긋나 다시 하는 일이 잦아질 뿐 → 필요하면 잠금 · 공유 캐시), + > 주소록의 되돌리기 표(`contact-restore-store.ts` — 그대로면 다른 인스턴스로 간 [되돌리기]가 404 `RES_001` 이다 → 공유 저장소에 5분짜리 항목으로): > 0. 컨테이너 활성화 — 루트 `.env` 에 `COMPOSE_PROFILES=redis` ([[docker]]) > 1. Redis store 패키지 설치 (**컨테이너 안에서** — 위 의존성 주의 항목 참조) > 2. `app.module.ts` 의 `CacheModule.register` 를 `registerAsync` + Redis store 로 교체 @@ -353,6 +357,10 @@ dc exec backend npm uninstall <패키지> - [ ] **목록 API**: 페이지네이션 적용, 응답은 `{items, pageInfo}`. - [ ] **DTO**: 모든 입력 DTO 에 `class-validator` + 한국어 메시지 + `@ApiProperty()`. 배열 중복은 `ArrayDistinct`(`ArrayUnique` 금지 — 항목 수의 제곱). DTO spec 은 전역 파이프 옵션에서 `stopAtFirstError` 만 빼고 검사한다(걸린 규칙을 모두 보려고 — 통과 · 실패 판정은 같다). + **검사 차례**: class-validator 는 그냥 쌓은 데코레이터를 **아래 것부터** 돌린다 — 그래서 8단계 DTO 는 칸의 검사를 `applyDecorators(형식, 길이 · 개수, 모양)` 으로 묶어 **적은 차례대로** 돌게 한다 + (`modules/contacts/dto/contact.dto.ts`). 길이 · 개수가 비싼 검사(정규식 · `IsEmail`)보다 먼저 돌아 `stopAtFirstError` 가 긴 입력을 거기서 멈춘다. + 정규식은 입력 길이에 비례하게 쓴다 — 같은 글자를 나눠 가질 수 있는 `*` 둘(`^[a]*[0-9][a]*$` 꼴)은 제곱이다(2026-10-29 리뷰: 전화번호 정규식이 1MB 본문 하나로 24분). + 차례 · 비용을 보는 테스트는 `stopAtFirstError` 를 빼지 않고 전역 옵션 그대로(`createValidationPipe()`) 돌린다. - [ ] **Swagger**: 새 엔드포인트에 `@ApiTags`/`@ApiOperation` + `@ApiStandardResponse`/`@ApiErrorResponse`. - [ ] **경로 alias**: `@/` 사용 (`tsconfig.json` paths + jest `moduleNameMapper` 설정 완료). 깊은 상대경로 금지. - [ ] **lint/test**: 커밋 전 `npm run lint` (`--max-warnings 0`) + `npm run test` 통과. 자동 수정은 `npm run lint:fix`. @@ -378,6 +386,11 @@ dc exec backend npm uninstall <패키지> 같은 요청에서 만들기와 지우기를 함께 하지 않는다 — 엔진은 만들기가 실패해도 지우기를 한다. **메일함 지우기(`Mailbox/set destroy`)는 `onDestroyRemoveEmails: false` 만 쓴다** — `true` 는 안의 메일을 영구 삭제한다. 안의 메일은 먼저 휴지통으로 옮기고, 그사이 들어온 메일이 있으면 엔진이 `mailboxHasEmail` 로 거절하므로 다시 옮긴다 (2026-10-28 확인). + **주소록 카드**(8단계)는 만들 때 `uid`(`urn:uuid:…`)를 넣는다 — 엔진이 만든 뒤에는 바꾸지 못하게 하고, 없으면 그룹(`members` 는 uid 로 잇는다)에 넣을 수 없다. + 고치기는 우리가 다루는 칸만 패치하고, 그 칸 안에서도 남의 속성(label · 다른 contexts · fax 같은 features · pref)을 지킨다 — 같은 주소 · 번호로 짝지어 합친다(`contact-card.ts` 의 `contactPatch`). + 그룹 구성원은 `members` 를 통째로 쓴다(빈 그룹은 `members/` 패치를 받지 않는다). 되돌리기는 지울 때 읽은 카드 전체(`properties: null`)에서 id 만 빼고 다시 만든다(같은 주소록의 같은 uid 는 엔진이 거절 — 새 uid). + **한 요청의 `destroy` 에 같은 id 를 두 번 넣지 않는다** — 엔진이 요청 전체를 `serverUnavailable`(`store.assert-value-failed`)로 거절하고 다시 해도 같다(2026-10-29 확인). + 사내 목록은 `callDirectory()` 로만 읽는다 — 그 길로 다른 계정을 대리 접근하지 않는다. 연락처 내용(이름 · 주소 · 전화 · 메모)은 로그에 남기지 않는다. **Sieve 스크립트**(6단계 — 필터 · 부재중)는 `jmap/sieve-script.ts` 한 곳에서 만든다: 사용자 글은 `quote()` 만 거친다(`\` · `"` 이스케이프), `${` 는 받지 않는다 (엔진이 require 와 상관없이 변수로 펼친다 — `%{…}` 는 펼치지 않는다), 부재중 제목은 RFC 2047 · 본문은 `vacation :mime` base64, 만든 글이 90,000바이트를 넘으면 `MAIL_013`. **스팸 판정은 엔진의 분류 표시**(`X-Spam-Score` 가 `spam` 으로 시작)로 한다 — 점수 문턱을 따로 두지 않는다(`spamtest` 는 점수를 정수로 내린 값이라 10 으로 막다가 5~10점 스팸을 놓쳤다, 2026-10-28 실엔진). diff --git a/.claude/skills/vue3/SKILL.md b/.claude/skills/vue3/SKILL.md index 0e7384c..12db125 100644 --- a/.claude/skills/vue3/SKILL.md +++ b/.claude/skills/vue3/SKILL.md @@ -33,18 +33,18 @@ description: Vue 3 프론트엔드(frontend/) 개발 규칙 — Composition API | 디자인 토큰·글꼴 | `src/styles/tokens.css` · `src/styles/base.css` | 색·간격·글자 크기 변수. 글꼴은 npm `pretendard` 자체 호스팅(`main.ts` 에서 import — CSP `font-src 'self'`) | | 브랜드 자산 | `src/assets/brand/` · `src/shared/constants/brand.ts` | 흰 로고(전체·ARA 마크). 원본은 `docs/brand/` | | 토스트 | `src/stores/toast.store.ts` · `src/components/feedback/ToastHost.vue` | 오류는 `useApi.ts` 의 `showErrorUI()`, 성공 안내는 `composables/useToast.ts`(API 가 아닌 화면 쪽 실패 — 예: 브라우저가 알림을 막음 · 보냈지만 받는 사람 일부가 받지 못함(+ [보낸편지함 보기], 문장은 `shared/utils/mail-send-result.ts`) — 는 `useToast().error()`). 알림 안의 단추는 `{ label, run }`(예: 임시 저장본 [되돌리기]), 이동 링크는 지금처럼. 단추가 있는 알림은 10초(`TOAST_ACTION_DURATION_MS`), 마우스 · 초점이 있는 동안은 `hold`·`release` 로 멈춘다. **단추는 띄운 세션에서만 움직인다** — `useToast` 가 `authStore.generation` 에 묶고, 로그아웃 · 세션 만료 · 다른 계정 로그인 때 단추가 있는 알림을 닫는다(메일 id 는 계정마다 겹칠 수 있다). 오래 걸리는 처리는 **진행 알림** `useToast().progress(글)` — 저절로 닫히지 않고(넘쳐도 다른 알림부터 닫는다) 글을 고치다가(`update`) 끝나면 그 자리에서 결과가 된다(`succeed` · `fail` — `toast.store` 의 `update`). 결과에도 단추를 붙일 수 있다(`succeed(글, action)` · `fail(글, action)` — 7단계 필터 적용의 [{메일함} 보기] · [다시 시도], 시간 · 세션 묶기는 다른 단추와 같다). 진행 알림도 세션이 바뀌면 닫는다 | -| 모달 대화상자 | `src/components/feedback/ModalDialog.vue` | 네이티브 ``(초점·Esc·닫힘 처리)와 모양. 세션 만료(`SessionExpiredDialog`)·나가기 확인(공용 `components/feedback/LeaveConfirmDialog.vue` — 쓰기 화면 `ComposeLeaveDialog.vue`·서명 설정 · 부재중 설정 · 필터 고치기 창 위(7단계)가 쓴다) · 메일함 대화상자(`components/mail/Mailbox*Dialog.vue` — 기본 슬롯에 이름 칸) · 필터 고치기 창(`components/settings/MailFilterDialog.vue` — 칸이 많은 창은 `wide`: 폭 620)이 쓴다 — 새 대화상자도 이것으로 만든다. 되돌릴 수 없는 일의 확인은 처음 초점을 「취소」에 둔다. 초점을 지키려고 `aria-disabled` 로 막은 단추에는 마우스를 올린 모양이 걸리지 않는다. Esc 는 `cancel` 로 알리기만 한다 — 브라우저가 막지 못하고 스스로 닫았는데(Esc 를 거듭 누름) 쓰는 쪽이 닫지 않기로 했으면 `reshow()`(ref 로 부른다)로 다시 띄운다. 초점은 열린 동안 `focusin` 으로 기억한 창 안의 마지막 요소로 돌려준다(브라우저는 `close` 전에 초점을 창 밖으로 옮긴다) | +| 모달 대화상자 | `src/components/feedback/ModalDialog.vue` | 네이티브 ``(초점·Esc·닫힘 처리)와 모양. 세션 만료(`SessionExpiredDialog`)·나가기 확인(공용 `components/feedback/LeaveConfirmDialog.vue` — 쓰기 화면 `ComposeLeaveDialog.vue`·서명 설정 · 부재중 설정 · 필터 고치기 창 위(7단계)가 쓴다) · 메일함 대화상자(`components/mail/Mailbox*Dialog.vue` — 기본 슬롯에 이름 칸) · 필터 고치기 창(`components/settings/MailFilterDialog.vue` — 칸이 많은 창은 `wide`: 폭 620) · 주소록의 연락처 · 그룹 창(`medium`: 폭 400 — 8단계)이 쓴다 — 새 대화상자도 이것으로 만든다. 되돌릴 수 없는 일의 확인은 처음 초점을 「취소」에 둔다. 초점을 지키려고 `aria-disabled` 로 막은 단추에는 마우스를 올린 모양이 걸리지 않는다. Esc 는 `cancel` 로 알리기만 한다 — 브라우저가 막지 못하고 스스로 닫았는데(Esc 를 거듭 누름) 쓰는 쪽이 닫지 않기로 했으면 `reshow()`(ref 로 부른다)로 다시 띄운다. 초점은 열린 동안 `focusin` 으로 기억한 창 안의 마지막 요소로 돌려준다(브라우저는 `close` 전에 초점을 창 밖으로 옮긴다) | | 외부 HTML 격리 렌더링 | `src/components/mail/MailBodyFrame.vue` · `src/shared/utils/mail-html.ts` | 메일 본문 등 외부 HTML 은 이 컴포넌트로만 그린다 (sandbox·내부 CSP 근거는 파일 머리말). 보여 줄 HTML 고르기는 `displayedHtmlBody()` 하나다 — null 이거나 공백뿐이면 textBody (읽기 화면·인용 공용, 서버의 `isBodyTruncated` 와 같은 규칙) | | 메일 모듈 | `src/pages/mail/*` · `src/components/mail/*` | API 계약 `docs/mail/api-contract.md`, 디자인 원본 `docs/design/webmail/` | | 콘텐츠 열 토큰 | `src/styles/tokens.css` 의 `--content-*` · `--content-toolbar-start` | 읽기·쓰기 화면이 같은 최대 폭·여백·32px 시작선을 쓴다 (새 화면도 이 토큰으로) | -| 밀도 예외 크기 | `src/styles/tokens.css` 의 `--size-compact-control` · `--size-segment-item` · `--size-segment-item-lg` · `--size-editor-tool` · `--size-editor-menu-item` · `--size-editor-swatch-cell` · `--size-editor-table-cell` · `--table-col-check` · `--size-row-check` · `--size-search-clear-width`/`-height` · `--size-notice-bar` · `--size-mailbox-more` · `--size-notice-action` · `--size-filter-control` · `--size-filter-remove` (주석에 "밀도 예외") | 클릭 영역 44px 기준의 의도된 예외(목록 표의 별·보기 전환, 설정 화면의 미리 보기 전환 36(`.segmented`)·서명 그림 크기 줄 36(작게·보통·원본·그림 빼기), 편집기 툴바 버튼 40×44·메뉴 항목 40·색 견본 40·표 고르기 칸 22, 3단계의 목록 표 고르기 칸 32·분할 행 고르기 칸 폭 36·검색 칸 ✕ 40×38·새 메일 알림 줄 40·검색 범위 전환 36·이동 메뉴 항목 40, 4단계의 메일함 행 ⋯ 32×40 · ⋯ 메뉴 항목 40, 5단계의 목록 위 알림 권하기 줄 40 · 줄 안의 단추 36, 6단계의 필터 조건 칸 40 · 조건 ✕ 36 · 필터 행 ▲▼ 32×40 · 켜기 스위치 누르는 곳 40×44 · 고치기 창 안 [다시 불러오기] 36)에만 쓴다 — 사용자 확정(캔버스). 그 밖의 `--size-editor-*`(툴바 높이·글꼴 단추·본문 최소 높이 등)는 예외가 아니라 보통 크기다 | +| 밀도 예외 크기 | `src/styles/tokens.css` 의 `--size-compact-control` · `--size-segment-item` · `--size-segment-item-lg` · `--size-editor-tool` · `--size-editor-menu-item` · `--size-editor-swatch-cell` · `--size-editor-table-cell` · `--table-col-check` · `--size-row-check` · `--size-search-clear-width`/`-height` · `--size-notice-bar` · `--size-mailbox-more` · `--size-notice-action` · `--size-filter-control` · `--size-filter-remove` (주석에 "밀도 예외") | 클릭 영역 44px 기준의 의도된 예외(목록 표의 별·보기 전환, 설정 화면의 미리 보기 전환 36(`.segmented`)·서명 그림 크기 줄 36(작게·보통·원본·그림 빼기), 편집기 툴바 버튼 40×44·메뉴 항목 40·색 견본 40·표 고르기 칸 22, 3단계의 목록 표 고르기 칸 32·분할 행 고르기 칸 폭 36·검색 칸 ✕ 40×38·새 메일 알림 줄 40·검색 범위 전환 36·이동 메뉴 항목 40, 4단계의 메일함 행 ⋯ 32×40 · ⋯ 메뉴 항목 40, 5단계의 목록 위 알림 권하기 줄 40 · 줄 안의 단추 36, 6단계의 필터 조건 칸 40 · 조건 ✕ 36 · 필터 행 ▲▼ 32×40 · 켜기 스위치 누르는 곳 40×44 · 고치기 창 안 [다시 불러오기] 36, 8단계의 패널 그룹 줄 ⋯ 32×40 · 연락처 창의 줄 · 칸 40 · 줄 ✕ 36×40 · 구성원 넣기 창 전환 36 · 찾기 칸 40 — 새 토큰 없이 위 것을 다시 쓴다, tokens.css 주석)에만 쓴다 — 사용자 확정(캔버스). 그 밖의 `--size-editor-*`(툴바 높이·글꼴 단추·본문 최소 높이 등)는 예외가 아니라 보통 크기다 | | 사용자별 화면 설정 | `src/stores/mail-view.store.ts` | localStorage(키에 사용자 email, try/catch, 실패 시 기본값). 서버 설정 API 가 생기면 교체 | | 화면 이동 규칙 | `src/composables/useMailNavigation.ts` | 이전/다음은 replace, "목록으로"는 뒤로 가기 또는 replace (기록이 쌓이지 않게) | | 발송 키 규칙 | `src/shared/utils/idempotency-key.ts` 의 `shouldRenewSendKey()` | `Idempotency-Key` 는 성공과 `MAIL_005` 때만 바꾼다 — 그 밖의 실패 뒤 다시 보내기는 같은 키 (계약 2절 "발송 본문"). UUID 만들기는 `shared/utils/uuid.ts` 하나(필터 id 와 공용 — `crypto.randomUUID` 가 없으면 대체 경로) | | 첨부 사전 검사 | `src/shared/utils/mail-attachment.ts` 의 `precheckAttachment()` | 형식·파일당·합계 한도(`mail.store` 의 `attachmentMaxBytes`·`attachmentTotalMaxBytes`, 서버 값이 없으면 대체값). 넘는 파일은 올리지 않는다 | | 메일 본문 편집기 | `src/components/mail/editor/` — `MailEditor.vue` (쓰기 화면이 `defineAsyncComponent` 로 불러온다) | TipTap core (MIT 무료 패키지만, `@tiptap/*` 는 모두 같은 버전으로 고정). 툴바 배치·좁은 폭에서 접는 순서·단축키는 `docs/design/webmail/README.md` "메일 쓰기". 보낼 모양은 `mail-serialize.ts` — 서식 본문 `bodyHtml`(기본 글꼴 div, 인라인 style) + 텍스트 대체 본문 `body` (계약 2절 "서식 본문"). 답장·전달 초안 한도는 `fit-draft.ts`(처음 맞출 때 내 글 몫 5%를 남기고, 보내기 직전 `getContentForSend()` 가 다시 재서 인용을 더 줄인다 — 내 글만으로 넘으면 보내지 않고 칸 아래에 알린다), 붙여 넣거나 끌어다 놓은 파일은 첨부로(`shared/utils/editor-files.ts`, 화면 어디에 놓아도 — `composables/useWindowFileDrop.ts`). **읽어 들이는 style 값은 서버 정화와 같은 형식만** 받는다 — 규칙 `shared/utils/outgoing-html-rules.ts`(짝: backend `sanitize-outgoing-html.ts`, 같은 표본 표 `test-utils/outgoing-style-samples.ts` — 한쪽을 고치면 둘 다), 적용 `editor/mail-format-extensions.ts`. 편집기 청크를 못 받으면 `ComposeEditorLoadError.vue`("다시 시도"), 단축키 표기는 플랫폼별(`shared/utils/keyboard-shortcut.ts`). **그림은 서명 그림만** 받는다 — 노드 `editor/signature-image.ts`(쓰기 본문·서명 칸 공용, 규칙은 `outgoing-html-rules.ts` 의 그림 부분 — 짝: backend `html/signature-images.ts`·`keepSignatureImage`, 표본 `IMAGE_SAMPLES`). 메뉴(`editor/EditorMenu.vue` + `EditorPopup.vue` — 이동 ▾ · 메일함 ⋯ · 읽기 화면 ⋯ 도 같은 부품)의 꺼진 항목은 `disabled` 가 아니라 `aria-disabled` — 화살표로 가서 초점은 받되 고를 수 없다(WAI-ARIA 메뉴). 누를 수 없는 까닭은 항목의 `note`(이름 아래 작은 글, `aria-describedby`) | | 임시 저장 | `src/composables/useDraftSaving.ts` · `components/mail/ComposeDraftStatus.vue` · `composables/useComposeDraftLink.ts`(주소 맞추기 · 옛 id 기록 · 보내지 못한 메일을 저장본으로) · `composables/useDraftTrash.ts`(휴지통 · 되돌리기) | 3초 멈춤 · 쉬지 않고 쓰면 30초 · 10초 간격 · 한 번에 하나(계약 2절 "임시 저장", 상수 `shared/constants/mail.ts` 의 `DRAFT_*`). `MAIL_008`·PUT 404 는 막지 않고 POST 로 새로. 찾지 못한 첨부가 남았거나 본문이 한도를 넘는 동안은 저장하지 않고, 실을 수 없는 첨부가 든 남의 저장본은 첫 저장을 POST 로(원본 보존). 보내기·버리기 전에는 `suspend`+`settle` — 보내기는 settle 뒤 첨부 상태를 다시 본다. 저장할 때마다 주소를 `?draft=<새 id>` 로 맞추고(떠나기 전 저장 제외), 옛 id 로 돌아오면 `mail.store` 의 `latestDraftId()` 로 마지막 저장본을 연다. 쓰기 화면 휴지통은 DELETE 가 아니라 PATCH(휴지통으로 이동) — 이미 휴지통인 메일은 DELETE 가 영구 삭제한다 | -| 받는 사람 자동 완성 | `src/composables/useRecipientSuggestions.ts` · `shared/utils/recipient-match.ts` · `components/mail/RecipientField.vue` · `RecipientSuggestionList.vue` | 검색어는 input · compositionupdate 의 `target.value`(한글 조합 중 글자 포함)로 따로 두고, 칩 확정은 v-model. Enter 등의 조합 판정은 `shared/utils/keyboard-shortcut.ts` 의 `isImeComposing()`(`isComposing` 또는 keyCode 229 — Safari · macOS) — 새 입력 칸의 Enter 처리도 이것을 쓴다. 쓰기 화면을 열 때 `limit=100` 한 번, 모자랄 때만 250ms 뒤 `q` 로(초성·자모만 친 글자는 묻지 않는다, 늦은 응답은 버린다). 초성 검색·맞은 글자 강조는 `recipient-match.ts`(강조는 `` 조각 — v-html 없음). combobox 접근성(aria-activedescendant · listbox · 조합 중 Enter 무시), 완전한 주소면 미리 고르지 않는다 | +| 받는 사람 자동 완성 | `src/composables/useRecipientSuggestions.ts` · `shared/utils/recipient-match.ts` · `components/mail/RecipientField.vue` · `RecipientSuggestionList.vue` | 검색어는 input · compositionupdate 의 `target.value`(한글 조합 중 글자 포함)로 따로 두고, 칩 확정은 v-model. Enter 등의 조합 판정은 `shared/utils/keyboard-shortcut.ts` 의 `isImeComposing()`(`isComposing` 또는 keyCode 229 — Safari · macOS) — 새 입력 칸의 Enter 처리도 이것을 쓴다. 쓰기 화면을 열 때 `limit=100` 한 번, 모자랄 때만 250ms 뒤 `q` 로(8단계부터 초성 · 조합 중 글자도 묻는다 — 서버의 q 가 같은 맞추기다: 짝 backend `modules/mail/recipient-match.ts`, 같은 표본 표 `test-utils/recipient-match-samples.ts` — 한쪽을 고치면 둘 다. 늦은 응답은 버린다). 초성 검색·맞은 글자 강조는 `recipient-match.ts`(강조는 `` 조각 — v-html 없음). combobox 접근성(aria-activedescendant · listbox · 조합 중 Enter 무시), 완전한 주소면 미리 고르지 않는다 | | 메일 서명 | `src/stores/mail-signature.store.ts` · `components/mail/editor/signature/` · `shared/utils/mail-signature.ts` · `pages/settings/` | 서명은 메일 화면에 들어올 때 받아 두고 쓰기 화면이 넣는다(새 메일은 끝, 답장·전달은 인용 위 — 이어 쓰기는 다시 넣지 않는다). 설정 화면(`SettingsLayout.vue` 왼쪽 메뉴 — 묶음 · 항목은 `shared/constants/settings-menu.ts` + `MailSignaturePage.vue`)은 들어올 때마다 새로 받는다. 미리 보기는 편집기 스키마의 `DOMSerializer` 로 그린다. 그림 3장은 서명 칸 안의 그림 수로(서버 보관 상한 10장은 서버가 센다) | | 메일 스토어 구성 | `src/stores/mail.store.ts`(조립 · 공개 API) + `src/stores/mail/` — `mailboxes.ts` · `list.ts` · `selection.ts` · `message.ts` · `bulk.ts` · `mailbox-admin.ts`(4단계 메일함 관리) | Pinia 스토어는 `useMailStore` 하나이고 기능별 setup 모듈을 조립한다(스토어를 여러 개로 나누지 않는다 — 서로 부르는 고리가 생긴다). 목록 출처는 `shared/utils/mail-list-source.ts`(메일함 · 검색). **늦게 온 응답 버리기**: 목록 맞추기는 이 탭의 목록 변경 세대(`localGeneration` — 읽음 · 중요 · 빼기 · 옮기기마다 오른다)가 그사이 바뀌었으면 응답을 버리고 한 번 더 맞춘다. 여러 통 처리 · 한 통 삭제는 시작할 때의 출처 순번(`sourceSeq`)과 다르면 목록을 건드리지 않고 개수만 맞춘다 | | 새 메일 확인 · 탭 제목 | `src/composables/useMailStatePolling.ts`(메일 화면 `MailLayout` 이 시작) · `useListArrivalNotice.ts` + `components/mail/MailNewNotice.vue` · `useMailTabTitle.ts` + `shared/utils/document-title.ts` | 화면이 보이는 동안 60초마다 `GET /mail-state`, 탭으로 돌아오면 10초 지났을 때 바로, 한 번에 하나, 실패는 `silent`. 바뀌면 `syncCounts()` + 보이는 목록 맞추기(불러온 만큼 · 최대 100통, 검색 결과는 건너뜀). 목록을 내려 본 채면 "새 메일 N통 · 맨 위로" 줄. 받은편지함 안 읽음이 있으면 탭 제목 앞 `(N) ` (메일 라우트에서만) — 계약 2절 "새 메일 확인" | @@ -58,6 +58,8 @@ description: Vue 3 프론트엔드(frontend/) 개발 규칙 — Composition API | 스팸 · 원문 (4단계) | `src/components/mail/MessageSpamButton.vue`(읽기 화면) · 도구 줄의 스팸 단추(`MailSelectionToolbar.vue`) · `MessageMoreMenu.vue`(⋯ › 원문 내려받기) | 스팸 신고 · 스팸 아님은 이동과 `isJunk` 를 한 요청에 보내고(`mailStore.moveMessages(…, { isJunk })`), [되돌리기]는 원래 메일함으로 반대 값을 보낸다. 원문(.eml)은 `useMail().downloadMessageSource()` — 첨부처럼 `confirmSession()` 을 먼저 거친다 — 계약 2절 "스팸 · 원문" | | 메일 필터 (6단계) | `src/pages/settings/MailFilterPage.vue` · `components/settings/MailFilter*.vue`(목록 · 행 · 목록 위 줄 `MailFilterNotice` · 빈 상태 · 안내 상자 `MailFilterNotes` · 고치기 창 `MailFilterDialog` + 조건 `MailFilterConditionFields` · `MailFilterConditionRow` · 할 일 `MailFilterActionFields`) · `mail-filter-ui.ts` · `stores/mail-filter.store.ts` · `composables/useMailFilterActions.ts`(켜기 · 순서 · 지우기 · 되돌리기 알림) · `useMailFilterDialog.ts`(창 상태) · `shared/utils/mail-filter.ts`(검사 · 요약 · 옮길 곳 · 되돌릴 자리) · `shared/constants/mail-filter.ts` · 읽기 화면 ⋯ 「이런 메일로 필터 만들기」(`MessageMoreMenu.vue` → `/settings/mail/filters?from=`) | 바꾸는 일은 모두 `PUT /api/mail-filters` 전체 교체(받은 `version`)이고 **저장은 한 번에 하나**(`saving`). 켜기 · 순서는 화면을 먼저 바꾸고 실패하면(그사이 목록이 안 바뀌었으면) 되돌린다. 창의 저장은 그 순간 목록의 켜짐 값을 쓴다. [되돌리기]는 지운 필터만 앞 · 뒤 필터 기준 자리에 다시 넣고, 누른 차례대로 앞 저장이 끝난 뒤 보낸다(`restoreQueue` — 기다림과 `send()` 사이에 await 를 두지 않는다 — 2026-10-28 리뷰의 경합). `MAIL_012` 는 목록 위 줄 + 창 안 안내와 [다시 불러오기](쓴 내용을 지키고 다시 받은 목록 위에 저장), 늦게 온 409 는 보낼 때의 순번(seq)이 그대로일 때만 줄을 켠다. 검사는 서버와 같은 규칙(전달 주소의 @ 앞은 dot-atom · 64자로 서버보다 좁게)이고 거절은 칸 아래(`VAL_001` · `MAIL_013` 은 알림을 끈다). 필터 화면이 떠 있지 않을 때 실패한 되돌리기는 알림으로(`attachPage` · `pageVisible`). 세션이 바뀌면 늦은 응답을 버린다(`authStore.generation`) — 계약 2절 "메일 필터", 디자인 README "6단계" | | 이미 받은 메일에 필터 적용 (7단계) | `src/components/settings/MailFilterApplyField.vue`(고치기 창의 「받은편지함에 이미 있는 메일에도 적용」 — 창을 열 때마다 꺼짐) · `composables/useMailFilterRun.ts`(진행 · 결과 알림) · `stores/mail-filter-run.store.ts`(cursor 를 이어 보내기 — 탭마다 하나 `running`) · `shared/utils/mail-filter-run.ts`(할 일 판정 · 보낼 규칙 · 끝 알림의 메일함 · 문구) | 저장이 성공한 뒤에만 `POST /api/mail-filter-runs` 를 `cursor` 가 null 이 될 때까지 다시 보내며 진행 알림을 고친다(4단계 오래 걸리는 처리와 같은 결 — 전체는 확인 + `remaining`, 첫 응답 전에는 받은편지함의 메일 수). 전달은 보내지 않는다. 이미 받은 메일에 할 일이 없는 필터(옮길 메일함이 지워짐 · 받은편지함으로 옮기기만 · 전달만)는 칸을 끄고 시작하지도 않는다 — 판정은 `filterRunIdleReason` 하나(칸 `filterApplyBlock` · 시작 `filterRunRuleOf` 가 함께 쓴다), 이 탭에서 적용이 도는 중에도 칸을 끈다. 요청은 `silent` 로 보내고 실패는 응답이 온 때 세션이 그대로일 때만 스토어가 알린다(`REQ_001` — 다른 창 · PC 에서 적용 중 — 은 화면이 "다른 창에서 …"로). 멈추면 [다시 시도](받은 `cursor` 부터 — 수도 이어 센다), 나아가지 못한 응답이 거듭되면(`MAIL_FILTER_RUN_MAX_STALLS`) 멈춘다. 다른 화면으로 가도 이어 하고, 세션이 바뀌면 다음 요청을 보내지 않고 알림을 닫는다. 끝났거나 나아간 뒤 멈췄으면 개수 · 목록을 맞춘다 — 계약 2절 "이미 받은 메일에 적용", 디자인 README "7단계" | +| 주소록 (8단계) | `src/pages/contacts/`(`ContactsLayout.vue` — 패널 · 창 묶음 `ContactsDialogs` 의 자리 · `ContactsPage.vue`) · `components/contacts/`(패널 `ContactsPanel` · 그룹 줄 `ContactsGroupRow` · 목록 `ContactsList` · `ContactsListItem` · 상세 `ContactDetail` · 찾기 `ContactsSearchBox` · 연락처 창 `ContactDialog` + 줄 `ContactEntryRows` · 그룹 이름 창 · 구성원 넣기 창 `ContactGroupMembersDialog` · ⋯ `ContactMoreMenu` · `ContactAvatar` · `contacts-ui.ts`) · `stores/contacts.store.ts` · `composables/useContacts.ts`(API) · `useContactActions.ts`(지우기 · [되돌리기] · 그룹에 메일 쓰기 · 고치기 열기) · `useContactsDialogs.ts`(창 상태) · `useContactDuplicateCheck.ts`(같은 주소 경고) · `shared/utils/contacts.ts`(차례 `compareContacts` — **backend `contact-search.ts` 와 짝** · 칸 정리 · 바꾼 칸 `contactPatchOf` · 검사) · `contacts-route.ts` · `shared/constants/contacts.ts`(라우트 `CONTACTS_ROUTES` · 한도) · `shared/utils/clipboard.ts` | 레일의 주소록 모듈(`app-modules.ts`). 고치기는 창을 열 때 그 연락처를 새로 받고 **바꾼 칸만 `PATCH`** 한다(검사도 바꾼 칸만 — 손대지 않은 남의 값은 그대로, 바꾼 것이 없으면 보내지 않는다). 지우기는 확인 없이 하고 [되돌리기]는 지우기 응답의 `restoreToken` 으로 `POST /contact-restorations`(표가 없으면 단추 없이 알린다). 바꾼 연락처는 응답을 서버의 차례로 받은 쪽 안의 자리에 넣는다(스토어의 `placeContact` — 자리를 모를 때만 첫 쪽을 한 번 다시 받는다, 바꿀 때마다 불러온 쪽을 모두 다시 받지 않는다). 구성원 넣기 창은 `excludeGroupId` 로 받고, 주소로 연 사내 항목은 `GET /directory-entries/{id}` 로. 창은 주소록 틀(`ContactsLayout`)에 둔다 — 주소록 안에서 옮겨 다녀도 남고, 떠날 때는 나가기 확인(`useDialogLeaveConfirm`), 세션이 바뀌면 닫는다. 저장 중에 창을 닫았으면 창 안에 보일 거절도 `notifyApiError` 로. 이메일은 서버보다 좁게 본다(`mail-address.ts` 의 `isStrictEmail` — 필터의 전달 주소와 공용), 이메일 칸은 `type="text"` + `inputmode="email"`(브라우저가 한글 도메인을 punycode 로 바꾸지 않게). 전환은 `.segmented--outline`(base.css), 도구 단추 `components/mail/MailToolButton.vue` 의 `unavailable`(aria-disabled — 클릭 무시) · `busy`(aria-busy) · `describedby` — 계약 3절, 디자인 README "8단계" | +| 메일과 함께 (8단계) | `shared/utils/compose-prefill.ts` · `components/mail/MailContactDialogHost.vue` + `composables/useMailContactDialog.ts` · `MessageMoreMenu.vue`(⋯ 「보낸 사람을 주소록에 추가」 · 「주소록에서 보기」) · 받는 사람 칸의 그룹(`useRecipientSuggestions` 의 `optionsFor` · `expandGroup`, `recipient-match.ts` 의 `matchContactGroups` · `arrangeRecipientOptions`) | 쓰기 화면을 받는 사람을 채워 열 때는 라우터 상태(`composeTo` — 주소는 URL 에 싣지 않는다)에 연 직원의 주소를 함께 싣고, 지금 직원과 다르면 쓰지 않는다(같은 탭에서 다른 직원이 로그인한 뒤 뒤로 가기). 읽기 화면의 연락처 추가 창은 메일 레이아웃(`MailLayout` 이 provide)에 둔다 — 메일을 바꾸거나 목록으로 돌아가도 남는다(읽기 화면 안 `v-if="message"` 에 두면 사라진다). 받는 사람 추천은 주소록 → 그룹 → 사내 → 최근. 그룹은 쓰기 화면을 열 때 받아 둔 목록에서 이름으로 찾고, 고르면 구성원을 받아 대표 주소를 넣는다 — 그룹마다 따로 기다려 이어 골라도 앞 그룹이 빠지지 않는다 — 계약 3절 "메일과 함께" | | 시계 주입 | `src/composables/useClock.ts` · `src/test-utils/manual-clock.ts` | 타이머를 쓰는 로직(자동 저장 등)은 시계를 주입받는다 — 테스트는 손으로 돌리는 시계로 시간을 넘긴다(고정 대기 없이, [[testing]]) | | 나가기 전 확인 | `src/composables/useLeaveConfirm.ts` · `src/shared/utils/compose-changes.ts` | 화면을 연 뒤(또는 마지막으로 저장한 뒤) 바꾼 것이 있으면 앱 안 이동(라우트 떠남·바뀜)은 확인 대화상자, 탭 닫기·새로고침은 `beforeunload`. 쓰기 화면은 떠나기 전에 먼저 저장하고 저장하지 못한 것이 있을 때만 묻는다(서명 설정은 저장하지 않은 서명이 있으면 묻는다). 가드가 도는 중은 `guarding`. 바꿈 여부는 연 직후(저장한 뒤에는 마지막으로 저장한 판)의 스냅숏과 비교한다(편집기는 `isChanged()`). 떠나기 전 저장은 5초까지만 기다린다. 보낸 뒤·보내는 중·세션이 끝났을 때는 묻지 않는다 — 보내는 중에 떠나면 결과는 토스트로만 알리고 떠난 양식·새 양식을 건드리지 않는다(양식 세대로 늦게 온 결과를 버린다, 디자인 README "나가기 전 확인"). **고치던 모달 창**(필터 고치기 창 — 7단계)은 `composables/useDialogLeaveConfirm.ts` — 화면의 확인에 "창 닫기" 길을 더한다: [취소] · Esc 는 `asking: 'close'`([계속 고치기]면 창을 그대로 — 브라우저가 닫았으면 `reshow()`), 다른 화면은 `'route'`, 탭 닫기 · 새로고침은 `beforeunload`. 보내는 중 · 세션이 끝났을 때는 묻지 않는다 | | 읽기 화면 본문 안내 | `src/shared/utils/mail-body-notices.ts` · `shared/constants/mail.ts` 의 `MAIL_BODY_NOTICES` | 서버의 `isHtmlBodyOmitted`·`isBodyTruncated`(계약 2절 "읽기 한도")를 외부 이미지 차단과 같은 안내 줄로 보인다(`.reader__notice[data-notice]`) |