diff --git a/.claude/skills/nestjs/SKILL.md b/.claude/skills/nestjs/SKILL.md index fe8e325..ac8a079 100644 --- a/.claude/skills/nestjs/SKILL.md +++ b/.claude/skills/nestjs/SKILL.md @@ -32,16 +32,16 @@ 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절) · **앱 전용 토큰 경로** `app-auth.controller.ts`(앱 1단계 — `/api/auth/app/login`·`refresh`·`logout`, 계약 1절 "앱 로그인": 웹과 같은 `AuthService` 함수를 지나 실패 카운터 · 코드 가드 · 앱 비밀번호 거절 · 막는 계정이 같고, 로그인 요청 수 상한은 웹 로그인과 같은 칸(`auth.constants.ts` 의 `LOGIN_THROTTLE` — 키를 같은 이름으로). 토큰은 응답 본문 `tokens`(끝나는 때는 토큰의 exp · `private, no-store`), 앱 토큰 둘에 `cli: 'app'`, 세션 표 `client`(web · app — 마이그레이션 `AddAppSessionClient`, DB 기본값 web — 칸을 모르는 코드가 넣어도 웹), 갱신은 같은 클라이언트의 리프레시 토큰으로만(다르면 `AUTH_001`, 세션은 끊지 않는다) · **앱 세션의 30초 유예는 그 세션을 끝낸다**(사유 `app-refresh-grace` — 앱은 갱신을 한 번에 하나만 하므로 응답을 잃었거나 토큰이 두 번 쓰였다, 응답은 웹과 같은 200 `tokens: null`). 본문 경계: `refreshToken` 이 없거나 글이 아니면 400 `VAL_001`, 빈 글 · 2,048자(`MAX_REFRESH_TOKEN_LENGTH`) 넘음은 서명을 보기 전에 틀린 토큰(갱신 401 `AUTH_001`). 로그아웃은 본문의 리프레시 토큰 → 없거나 · 빈 글 · 길거나 · 틀리면 Bearer 의 sid — 본문 모양(글이 아님 · 모르는 칸)이 틀릴 때만 400, 그 밖은 200. 앱 표시가 있는데 sid 가 UUID 가 아닌 액세스 토큰은 가드가 `AUTH_001`. 앱 요청의 "지금 세션"은 리프레시 쿠키가 없어 액세스 토큰의 sid — `AuthService.requestSessionId`, 비밀번호 바꾸기 · 로그인된 기기가 쓴다) · 비밀번호 변경 `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` · 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`, 여러 메일함은 한꺼번에 잡는다), 지우기는 안의 메일을 휴지통으로 옮긴 뒤. 15단계 — **옮기기**는 `PATCH` 의 `parentId`(`UpdateMailboxDto` — 이름 · 상위 가운데 보낸 것만, 자리는 만들기와 같고 자기 아래는 서버가 먼저 막는다 · 엔진의 고리 거절은 같은 요청에서 다시 읽은 목록으로 가른다, 옮길 때만 그 메일함과 새 상위를 잡는다), **하위까지 지우기**는 `?withChildren=true`(`MailboxDeleteQueryDto` — 없으면 지금처럼 `VAL_001`): 서비스가 그 메일함을 먼저 잡고(처리 중이면 엔진에 묻지 않고 `REQ_001`) 목록을 읽어 하위 메일함까지 잡고, 어댑터가 나무의 메일을 한 조회(`inMailbox` OR)로 옮긴 뒤 깊은 것부터 한 요청에 지운다(엔진은 destroy 를 적은 차례대로 한다). 나무 계산은 순수 함수 `mailbox-tree.ts`) · 부재중 자동 응답 `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절 "서명") | +| 메일 (웹메일 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`, 여러 메일함은 한꺼번에 잡는다), 지우기는 안의 메일을 휴지통으로 옮긴 뒤. 15단계 — **옮기기**는 `PATCH` 의 `parentId`(`UpdateMailboxDto` — 이름 · 상위 가운데 보낸 것만, 자리는 만들기와 같고 자기 아래는 서버가 먼저 막는다 · 엔진의 고리 거절은 같은 요청에서 다시 읽은 목록으로 가른다, 옮길 때만 그 메일함과 새 상위를 잡는다), **하위까지 지우기**는 `?withChildren=true`(`MailboxDeleteQueryDto` — 없으면 지금처럼 `VAL_001`): 서비스가 그 메일함을 먼저 잡고(처리 중이면 엔진에 묻지 않고 `REQ_001`) 목록을 읽어 하위 메일함까지 잡고, 어댑터가 나무의 메일을 한 조회(`inMailbox` OR)로 옮긴 뒤 깊은 것부터 한 요청에 지운다(엔진은 destroy 를 적은 차례대로 한다). 나무 계산은 순수 함수 `mailbox-tree.ts`) · 부재중 자동 응답 `MailVacationController`(`dto/mail-vacation.dto.ts` — 6단계부터 저장 자리는 아래 필터의 Sieve 스크립트. 응답의 `isActive`(7단계)는 엔진에서 그 스크립트가 켜져 있는지 — 본문으로는 받지 않는다. 16단계 — 서식 본문 `htmlBody`(서명과 같은 정화 `html/sanitize-stored-html.ts` · 그림 없음 · 켤 때 정화 뒤 10,000자)와 회사 밖 응답 `external`(same · none · separate — 따로 써 둔 글은 고르기와 상관없이 남긴다, 새 칸이 없는 옛 요청은 글만 · 사내와 같은 응답): 받는 쪽에는 글 + 서식 두 벌(`jmap/mime-text.ts` 의 `alternativeMimeEntity`), 사내 · 회사 밖은 **봉투 보낸 사람의 도메인**(`MAIL_SETTINGS.domain` — `envelope :domain :is`, 회사 밖 응답만 `:handle` 을 따로), Sieve 문자열 하나 4,096바이트를 넘는 개체는 `variables` 조각(3,000바이트 · 줄 끝에서 끊음)으로 잇는다(`jmap/sieve-script.ts`), 원본 판 3 은 새 칸을 쓴 직원만(`filter-source.ts` 의 `usesRichVacation` — 판 3 의 서식은 읽을 때 다시 정화. 서버는 서식이 있는지 가리지 않는다 — 글만이면 화면이 `htmlBody: null` 을 보낸다는 전제)) · 메일 필터 `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` — 1 규칙 · 부재중, 2 + 차단, 3 + 부재중 서식 · 회사 밖, 쓸 때는 가장 낮은 판) `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` | 16단계부터 사용자마다 서명 여러 개(10개 · 이름 30자 — DB 표 `mail_signatures` 는 서명마다 한 줄, 마이그레이션 `MultipleMailSignatures` 가 옛 한 줄을 "기본 서명"으로 옮겼다 — 내용이 빈 서명은 옮기지 않음) + 서명 그림. 「메일에 넣을 서명」 둘(새 메일 · 답장과 전달)은 서명 줄의 표시 `is_new_message_default` · `is_reply_default`(한 사람에 하나 — 부분 유일 인덱스, 지우면 그 자리는 넣지 않음, 서명이 없던 직원의 첫 서명이 두 곳의 기본). 경로 `mail-signatures.controller.ts`(`/api/mail-signatures` · `/api/mail-signature-defaults`) · 옛 경로 `mail-signature.controller.ts`(`GET · PUT /api/mail-signature` — 「새 메일」 기본 서명을 15단계 모양으로(없으면 「답장 · 전달」 기본을 `isForNew: false` 로 — `legacyTargetOf`, PUT 도 같은 서명을 고친다), 앱 첫 판이 읽는다 · 그림 경로). 바꾸기는 모두 `MailSignatureRepository.change` 한 길(사용자 잠금 `pg_advisory_xact_lock` → 서비스가 스냅숏으로 정한 넣기 · 고치기 · 지우기 · 기본 → 그림 정리) — 그림 정리는 **모든 서명 HTML 의 합집합** 밖(막 올린 10분 안은 남김), 만든 시각은 사람마다 늘 늘린다(목록 = 만든 차례). 서명 · 부재중이 함께 쓰는 짧은 서식 정화 `modules/mail/html/sanitize-stored-html.ts`. 그림 형식은 내용으로 판별(매직 바이트·머리의 가로·세로, 새 의존성 없음) `signature-image-format.ts`, 파일은 업로드 볼륨의 `mail-signatures/` (`signature-image-storage.ts`, 한 사람 20장), 서명에 붙지 않은 그림 정리 cron `mail-signature.cleanup.ts`. 메일 모듈이 발송·임시 저장 때 `MailSignatureService` 로 그림을 쓴다 (계약 2절 "서명") | | 사내 목록(로그인 기록) | `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` 의 막는 목록에도 들어 있다 | +| 주소록 (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` 의 막는 목록에도 들어 있다. **16단계 가져오기 · 내보내기**(계약 3절 "가져오기 · 내보내기" — 파일은 화면이 읽는다): 컨트롤러 `contact-imports.controller.ts`(`POST /api/contact-import-checks` · `/api/contact-imports` · `/api/contact-deletions`) · `contact-exports.controller.ts`(`GET /api/contact-exports` — `@SkipTransform` 파일 응답), 본문 `dto/contact-import.dto.ts`(사람 = `ContactInputDto` + uid `urn:uuid:` 소문자 UUID, 1~100명) · `dto/contact-export.dto.ts`. 서비스 `checkImport` · `importContacts` · `removeContacts` · `exportContacts`: 넣기는 엔진 요청 하나(`ContactCard/set`) — 화면이 준 uid 로 만들고 같은 uid 가 있으면 그 카드(두 번 만들지 않는다), 연락처 수 · 크기는 묶음 전체로(`CONTACT_002`), 구성원 500 이 찬 그룹은 `skippedGroupIds`, 엔진이 일부만 받아도 던지지 않는다(포트의 `AddressBookWriteOptions.allowPartialCreate` → 결과 `refusedKeys` — 기억한 주소록은 받은 것만 옮긴다). 한꺼번에 지우기는 원본을 읽지 않고(`withoutOriginals` — id 만) 지울 연락처 + 고칠 그룹이 엔진 한 요청 상한(`MAX_CARD_CHANGES_PER_WRITE` 500 — `maxObjectsInSet`)을 넘으면 나눠 쓴다(`deletionChunkSize`). 순수 함수 `contact-import.ts`(이미 있음 규칙 — 이메일 하나라도 · 없으면 이름 + 전화 숫자 모음 — **짝: frontend `shared/utils/contact-import`**, 그룹 넣기 계획 · 지우기 몫) · `contact-export.ts`(vCard 3.0 — CRLF · 75바이트 접기 · 이스케이프 · FN 은 이름 → 첫 이메일 → 첫 전화 / CSV — BOM · 한국어 머리 · 수식 막기 `'`(`= + - @` · 탭 · CR) / 파일 이름 — 한국 날짜, CSV 머리를 바꾸면 frontend 가져오기도) | | 보내기 취소 · 예약 발송 (9단계) | `modules/mail/jmap/pending-send.ts` · `jmap/envelope.ts` · `scheduled-message.controller.ts` · `scheduled-messages.service.ts` | 기다리는 곳은 엔진이다 — 제출의 FUTURERELEASE(`HOLDFOR` · `HOLDUNTIL`, 엔진 한도 7일), 취소는 `undoStatus: canceled` 뒤 그 메일을 임시보관함으로(`$draft` + `$ara-canceled`, 다시 취소해도 200). `pending-send.ts` 는 붙잡기 매개변수 · 기다리는 제출 찾기(`EmailSubmission/query` 의 `filter.after: 지금` — `undoStatus` 조건은 모든 제출을 훑는다, 정렬 이름 `sentAt`) · `pendingSend` 판정(pending 이면서 보낼 시각이 아직)이다. **발송은 늘 envelope 을 직접 준다**(`envelope.ts`) — 엔진이 받지 않는 주소(모르는 최상위 도메인 등)가 있으면 제출 전체를 거절하고 첫 주소 하나만 알려 주므로, 어댑터가 그 주소를 not-found 로 빼고 같은 메일을 다시 제출한다(한 차례에 하나 — 거절 글의 따옴표 토큰과 정확히 같은 주소부터, 20차례까지 · 발송 55초 안). **붙잡힌 메일은 취소로만 보낸편지함을 떠난다** — 한 통 · 여러 통 `PATCH`(옮기기 · 스팸) · `DELETE` · 영구 삭제 · 메일함 지우기 · 비우기 · 필터 적용이 엔진에 먼저 묻고(`pending-send.ts` 의 `readHeldEmails` — 읽지 못하거나 다 받았는지 응답의 total 로 확인하지 못하면 바꾸지 않고 `MAIL_001`, 닫힌 쪽으로), 막으면 `MAIL_015` · `failedIds` · `skippedIds`. 예약 표시 키워드 `$ara-scheduled`. **지금 보내기**(15단계 — `POST /api/scheduled-messages/{id}/release`, 어댑터 `releaseScheduledSend`): 그 메일 · 제출(envelope · 발신 ID)을 한 요청으로 읽어 상태를 가르고(`releaseStateOf` — 기다리는 예약 · 이미 지금 보냄 · 끊김 · 이미 보냄 · 예약 아님), envelope 을 먼저 정한 뒤 예약 제출을 취소하고(확인된 것만) **다른 요청으로** 같은 메일을 원래 envelope 그대로 다시 낸다 — MAIL FROM 의 붙잡기만 보내기 취소 초(`send-hold.ts`)로, 성공하면 예약 표시를 뗀다(`submitCall` 의 `extraPatch`). 엔진이 분명히 거절하면(SetError · 메서드 오류 · `isRequestRefused`) 한 번 더, 그래도 거절이면 임시보관함으로 되돌린 **뒤에만** `ScheduledSendReturnedToDraftsException`(서비스가 대용량 링크를 취소한 발송처럼 둔다). 결과를 모르면(시간 초과 · 연결 끊김 · 5xx) 다시 내지도 되돌리지도 않는다 — 한 번 다시 읽어 새 제출이 보이면 그 상태, 아니면 그냥 `MAIL_001`(메일은 보낸편지함에 그대로 — 같은 요청을 다시 받으면 끊긴 지금 보내기로 이어 낸다). 제출마다 남은 시간이 `MIN_SEND_WINDOW_MS` 보다 적으면 내지 않는다(그냥 `MAIL_001`), 다 보내면 링크의 보낸 때만 앞당긴다(끝나는 날은 그대로 — 덩이가 적었다). 같은 메일의 취소 · 지금 보내기는 `ScheduledMessagesService` 의 `inProgress` 로 한 번에 하나 — 계약 2절 "보내기 취소 · 예약 발송" · "받는 사람별 결과" | | 외부 이미지 중계 (9단계) | `modules/mail/images/` | `GET /api/mail-images/{token}`. 표 `mail-image-token.ts`(AES-256-GCM — 직원 · 원래 주소 · 1시간, 키는 프로세스가 뜰 때 무작위. 표 모양은 **짝: frontend `shared/utils/mail-html.ts` 의 `PROXIED_IMAGE_PATTERN`**), 주소 검사 `mail-image-url.ts` + `shared/utils/public-address.ts`(모든 주소가 공인일 때만 · 점 없는 이름은 묻지 않고 막는다), 받기 `mail-image-fetcher.ts`(다시 가기 3번 · 10초 · 5MB — Content-Length 가 있으면 그 크기로 한 번 잡는다), 연결 `node-image-transport.ts`(푼 주소로 고정 연결 · Host 는 원래 이름 · 쿠키 · Referer 없음, 이름 풀기는 c-ares `dns.Resolver` — 80 · 443 고정, 다른 포트 · DNS 서버는 테스트의 `forTesting()` 으로만), 형식 `mail-image-format.ts`(앞 바이트로 PNG · JPEG · GIF · WebP), 차례 `mail-image-proxy.service.ts`(서버 16 · 직원 6, 30초 · 200개까지 기다림 — 줄은 직원마다이고 받는 수가 가장 적은 직원부터 든다, 자리는 응답이 끝날 때까지 `releaseWhenDone`), `mail-image.controller.ts`(실패는 모두 404 를 직접 쓴다 — debug 로그만, IP 마다 1분 600회). 본문의 `` 를 중계 주소로 바꾸는 것은 `html/sanitize-mail-html.ts`(`externalImageSrc`) — 계약 2절 "외부 이미지 보기" | | 보낸 사람 차단 · 보내기 · 읽기 설정 (9단계) | `modules/mail/mail-block.controller.ts` · `mail-block.service.ts` · `mail-blocks.ts` · `modules/mail-user-settings` | 차단은 필터 · 부재중과 같은 Sieve 스크립트의 맨 앞(`jmap/sieve-script.ts`, 원본 판 2 는 차단이 있을 때만 — `jmap/filter-source.ts`). 검사 `mail-blocks.ts`(`@` 앞은 dot-atom · 64자, 여러 사람이 쓰는 메일 서비스 30개는 `mail.constants.ts` — **짝: frontend `shared/constants/mail-block.ts`**, 같은 글자 · 같은 차례 — 한쪽을 고치면 둘 다). 이미 있는 주소의 `POST` 는 꺼진 규칙을 켠다. **받은편지함 정리**(15단계 — `POST /api/mail-block-runs`, `MailBlockRunController` · `MailService.runMailBlock` · 어댑터 `JmapFilterRunner.runBlock`): 필터 적용(7단계)과 같은 틀 — 차례 · cursor(`filter-run-cursor.ts` 의 `encodeBlockRunCursor` — 지문은 보낸 사람) · `BulkRun` · 같은 잠금 `filterRuns`, 요청이 도는 동안 그 직원의 스팸메일함도 `mailboxOperations` 로 잡는다(스팸메일함 비우기와 함께 돌지 않게 — 서로 `REQ_001`). 판정은 순수 함수 `mail-block-match.ts` — **짝: `jmap/sieve-script.ts` 의 `blockSection`**(엔진이 푼 From 주소 `header:From:asAddresses:all` 를 `address :all` · `:domain` 처럼, `i;ascii-casemap` — 엔진 검색은 넓게 잡아 쓰지 않는다), 할 일은 스팸메일함으로 + `$junk`. 설정은 DB(표 `mail_settings` — 마이그레이션 `AddMailSettings`, 직원 주소마다 한 줄 · 없으면 처음 값), `MailUserSettingsService` 가 프로세스마다 60초 기억하고 DB 장애면 처음 값(10초 · `ask`)으로 — 메일 모듈이 발송(`undoSendSeconds`) · 읽기(`externalImages`) 때 읽는다 — 계약 2절 "보낸 사람 차단" · "보내기 · 읽기 설정" | | 파일 저장 · 조각 올리기 (11단계) | `modules/file-storage` | `/api/file-uploads`(만들기 · 16MiB 조각 `PUT` · 끝내기 · 상태 · 지우기) · `/api/file-quota`. 조각은 `Content-Digest`(SHA-256)를 보고 `datasync` 뒤에 적는다, 같은 조각은 `ChunkGate`(프로세스 메모리)가 하나만 들인다, 조각 번호는 `file-id.pipe.ts`(매개변수 타입 `unknown` — 아래 점검 "경로 숫자"). 저장 포트 `FILE_BLOB_STORE` — 지금은 로컬 디스크 어댑터(named volume `/app/files`, 키는 서버가 만든 UUID 만 — `assertKey`). 보관량(직원 · 서버 총량 · 디스크 여유)은 만들기를 `pg_advisory_xact_lock` 안에서 센다(인스턴스를 늘려도 맞다). 끝내기는 `completion_started_at` · 상태 전이 결과를 본다. 악성 검사 `clamav-client.ts`(INSTREAM 직접 구현 — 판정 글은 `CLAMAV_REPLY` · `classifyClamavReply` 한 곳: 암호 · 압축 안 한도 → 검사하지 못함, 스트림 크기 한도 → 설정 오류로 검사 중 그대로 + error) · 검사 차례 `file-scan.service.ts`(DB pending · 동시 2) · 정리 cron `file-storage.cleanup.ts`. 확장자 허용 목록만 본다 — [[security]] "파일 업로드"의 예외. 계약 4절 | | 공개 링크 · 내려받기 (11단계) | `modules/file-sharing` · `modules/mail/mail-large-files.service.ts` · `large-file-block.ts` · `share-link.controller.ts` | 메일 한 통에 링크 하나(토큰 256비트 — DB 에는 SHA-256 만). 보낼 때 서버가 정화 · 지문 · 중복 확인 뒤 본문에 덩이를 넣는다(`large-file-block.ts` — 표시 `` · `data-ara-large-files` 는 서버만 넣고 두 정화기가 사용자 HTML 에서 지운다, 경계 `bodyQuoteStart`). 링크는 "확실한 실패"(`SendNotStartedException` — 형식으로 가른다)에서만 거둔다. 임시 저장은 머리글 `X-Ara-Large-Files`(보내는 메일에는 없다). 공개 경로 `/api/public-links/{token}`(로그인 없음) — 공개 호스트 미들웨어 `public-host.ts`(공개 호스트에서는 이 경로만 · 포털에서는 닫음, Host 를 스스로 정규화. 경로는 원문 · 라우터의 `req.path` · WHATWG URL · 퍼센트를 푼 원문 네 모양을 모두 본다 — 공개 호스트는 모두 공개 경로일 때만, 포털은 하나라도 공개 경로면 닫고, 읽지 못한 모양이 있으면 둘 다 닫는다. 라우터가 읽는 모양을 빼면 절대 주소 꼴 · `\` 요청이 막기를 지난다 — 2026-10-30), 파일 응답 `serveFile`(octet-stream · attachment · nosniff · CSP sandbox · no-store · Range `shared/utils/http-range.ts`), 받은 수는 조건부 UPDATE 한 문장, 종료 때 진행 중 응답을 `BeforeApplicationShutdown` 에서 끊는다. 읽기 화면의 `largeFiles` 는 표시 · 우리 출처 토큰 · 링크 주인 == From 이 모두 맞을 때만. 기간은 `shared/utils/kst-date.ts`(보낸 날부터 30일째 23:59:59 KST). 계약 4절 | -| 계정 보안 (12단계) | `modules/account-security` · `modules/auth` 의 세션 부분 · `modules/mail/jmap/jmap-account-security.provider.ts` | 2단계 인증(엔진 TOTP — 계약 5절): 등록 `two-factor-enrollments.ts`(비밀 160비트 · 메모리 10분 · 직원마다 하나) · 켜기 · 끄기 `two-factor.service.ts`(엔진이 기준 — `enabledAt` 만 표 `two_factor_settings`) · **코드 가드 `two-factor-code-guard.ts`** — 실패 차지 · 다시 쓴 코드 확인 · (주소, 코드) "확인 중" 차지를 await 없이 한 번에 하고(같은 코드의 동시 요청이 둘 다 지나가지 않게), 받으면 그 자리에서 2분 기억한다. 결과는 받음 · 거절 · 쓰지 않음(`unused` — 새 비밀번호 거절처럼 코드와 상관없는 실패는 세지도 기억하지도 않는다). 코드 실패 카운터는 `login-attempt.service.ts`(같은 주소 15분 5 · 24시간 20 + IP 의 로그인 실패 한도). TOTP · base32 는 `shared/utils/totp.ts`(RFC 6238 — 새 의존성 없음, ±1 단계 상수 시간 비교). 잠금 `two-factor-locks.ts`(같은 직원의 2단계 바꾸기는 한 번에 하나). 로그인 2단계는 `auth.service.ts` — 앱 비밀번호 모양 거절 → 실패 차지 → 본인 Basic → **402 면 대리 접근 프로필로 정식 주소**(막는 계정의 별칭은 `AUTH_004`) → 코드는 엔진 `POST /api/auth`(mfaToken). 앱 비밀번호 `app-passwords.*`(엔진 `x:AppPassword` — 권한 `Replace` 셋, 직원마다 만들기는 한 번에 하나, 마지막 사용은 표 `app_password_usages` — 번호 · createdAt 이 같을 때만 잇는다, 비밀번호를 바꾸면 모두 지운다). 로그인된 브라우저 `modules/auth/auth-sessions.*`(경로 `/api/auth/sessions` — 지금 세션은 `AuthService.requestSessionId`: 웹은 리프레시 쿠키 · 앱은 액세스 토큰의 sid) · **끊은 세션 기억 `revoked-sessions.ts`**(액세스 토큰의 `sid` — 1시간, 기동 때 DB 에서 채운다, 끊는 모든 길이 `revokeWhere` 한 곳을 지난다 — 가드가 막고 실시간 알림은 다음 ping 때 닫는다). User-Agent → 짧은 이름 `shared/utils/user-agent.ts`(앱 이름 `ARA SYSTEM 앱` 은 세션의 `client` · 로그인 기록의 `method` 가 app 일 때만 `describeAppUserAgent` 로 — 웹 세션이 앱 꼴 UA 를 보내면 브라우저 null. 운영체제는 앱 UA `ARASYSTEM-App/<판> (Android <판>; <기종>)` 의 첫 괄호 칸 — 짝: flutter `kUserAgentProduct`). 세션 목록 항목에 `client`(web · app). 로그인 기록 `login-events.*`(표 `login_events` — 방식 webmail · app · imap · smtp, 90일 정리 cron `login-events.cleanup.ts`, 위치 커서 `login-event-cursor.ts`). 엔진 웹훅 `mail-engine-events.*` — 본문을 받기 전에 소켓 주소(`APP_NETWORK_SUBNET` — `shared/utils/cidr.ts`)와 키 유무를 보고, 원문 HMAC 을 상수 시간으로, 이 경로만 JSON 한도를 따로(4MB · 배열 10,000 · 값 200,000), 겹침은 내용 지문(`dedupe_key` 유일 인덱스 — 엔진은 다시 보낼 때 id 를 새로 붙인다), 1,000행씩 `ON CONFLICT DO NOTHING`, DB 실패는 5xx(엔진이 다시 보낸다), 빈 묶음 200(setup 의 받는 곳 확인). 마이그레이션 `AddAccountSecurity` | +| 계정 보안 (12단계) | `modules/account-security` · `modules/auth` 의 세션 부분 · `modules/mail/jmap/jmap-account-security.provider.ts` | 2단계 인증(엔진 TOTP — 계약 5절): 등록 `two-factor-enrollments.ts`(비밀 160비트 · 메모리 10분 · 직원마다 하나) · 켜기 · 끄기 `two-factor.service.ts`(엔진이 기준 — `enabledAt` 만 표 `two_factor_settings`) · **코드 가드 `two-factor-code-guard.ts`** — 실패 차지 · 다시 쓴 코드 확인 · (주소, 코드) "확인 중" 차지를 await 없이 한 번에 하고(같은 코드의 동시 요청이 둘 다 지나가지 않게), 받으면 그 자리에서 2분 기억한다. 결과는 받음 · 거절 · 쓰지 않음(`unused` — 새 비밀번호 거절처럼 코드와 상관없는 실패는 세지도 기억하지도 않는다). 코드 실패 카운터는 `login-attempt.service.ts`(같은 주소 15분 5 · 24시간 20 + IP 의 로그인 실패 한도). TOTP · base32 는 `shared/utils/totp.ts`(RFC 6238 — 새 의존성 없음, ±1 단계 상수 시간 비교). 잠금 `two-factor-locks.ts`(같은 직원의 2단계 바꾸기는 한 번에 하나). 로그인 2단계는 `auth.service.ts` — 앱 비밀번호 모양 거절 → 실패 차지 → 본인 Basic → **402 면 대리 접근 프로필로 정식 주소**(막는 계정의 별칭은 `AUTH_004`) → 코드는 엔진 `POST /api/auth`(mfaToken). 앱 비밀번호 `app-passwords.*`(엔진 `x:AppPassword` — 권한 `Replace` 셋, 직원마다 만들기는 한 번에 하나, 마지막 사용은 표 `app_password_usages` — 번호 · createdAt 이 같을 때만 잇는다, 비밀번호를 바꾸면 모두 지운다). 로그인된 브라우저 `modules/auth/auth-sessions.*`(경로 `/api/auth/sessions` — 지금 세션은 `AuthService.requestSessionId`: 웹은 리프레시 쿠키 · 앱은 액세스 토큰의 sid) · **끊은 세션 기억 `revoked-sessions.ts`**(액세스 토큰의 `sid` — 1시간, 기동 때 DB 에서 채운다, 끊는 모든 길이 `revokeWhere` 한 곳을 지난다 — 가드가 막고 실시간 알림은 다음 ping 때 닫는다). User-Agent → 짧은 이름 `shared/utils/user-agent.ts`(앱 이름 `ARA SYSTEM 앱` 은 세션의 `client` · 로그인 기록의 `method` 가 app 일 때만 `describeAppUserAgent` 로 — 웹 세션이 앱 꼴 UA 를 보내면 브라우저 null. 운영체제는 앱 UA `ARASYSTEM-App/<판> (Android <판>; <기종>)` 의 첫 괄호 칸 — 짝: flutter `kUserAgentProduct`). 세션 목록 항목에 `client`(web · app). 로그인 기록 `login-events.*`(표 `login_events` — 방식 webmail · app · imap · smtp, 90일 정리 cron `login-events.cleanup.ts`, 위치 커서 `login-event-cursor.ts`). 엔진 웹훅 `mail-engine-events.*` — 본문을 받기 전에 소켓 주소(`APP_NETWORK_SUBNET` — `shared/utils/cidr.ts`)와 키 유무를 보고, 원문 HMAC 을 상수 시간으로, 이 경로만 JSON 한도를 따로(4MB · 배열 10,000 · 값 200,000), 겹침은 내용 지문(`dedupe_key` 유일 인덱스 — 엔진은 다시 보낼 때 id 를 새로 붙인다), 1,000행씩 `ON CONFLICT DO NOTHING`, DB 실패는 5xx(엔진이 다시 보낸다), 빈 묶음 200(setup 의 받는 곳 확인). 마이그레이션 `AddAccountSecurity`. **16단계 휴대폰 바꾸기**(계약 5절 "휴대폰 바꾸기"): 등록은 켜져 있어도 받는다(엔진에 묻지 않는다 — 켜진 채 받은 등록으로 켜기를 보내면 402 라 `AUTH_008`). ① `POST /api/two-factor/enrollments/{id}/verification`(`TwoFactorService.verifyEnrollment` — 새 휴대폰의 코드를 등록의 비밀로 코드 가드 안에서 확인하고 등록에 "확인됨"(`TwoFactorEnrollments.markVerified`), 경로 id 는 `two-factor-enrollment-id.pipe.ts` — UUID 아니면 `VAL_001`) ② `POST /api/two-factor/replacements`(`replace` — 확인한 등록만, 본인 Basic 402 여야(401 `AUTH_005` · 200 그사이 꺼짐 `AUTH_008`) → 코드 가드 안에서 포트 `replaceTwoFactor`(어댑터: 대리 접근 `x:AccountPassword/set` 의 `otpAuth { otpCode: 지금 코드, otpUrl: 새 주소 }` 한 요청 — forbidden 은 `AUTH_006`, 결과를 모르면 다시 읽지 않고 `MAIL_001` — otpUrl 이 가려져 가를 수 없다). 같은 `TwoFactorLocks` 안, 성공 · `AUTH_008` 에서 등록의 비밀을 지운다, `enabledAt` 은 그대로, 제한된 세션은 둘 다 403 `AUTH_010` | | 전환 준비 (13단계) | `common/decorators/allow-restricted-session.decorator.ts` · `JwtAuthGuard` · `modules/account-security/two-factor-policy.service.ts` · `modules/system-settings` · `modules/system-mail` · `src/cli/ops.ts` + `modules/ops` · `modules/mail/jmap/jmap-ops-engine.ts` · `jmap-system-mailbox.ts` | **제한된 세션**(계약 6절): 세션 표의 `restriction`(`passwordChange` · `twoFactorSetup`)을 액세스 토큰(`rst`)에도 싣고, `JwtAuthGuard`(Reflector)가 `@AllowRestrictedSession()` 을 단 핸들러(`/api/auth/*` · 2단계 켜기 셋)만 지나게 한다 — 그 밖은 403 `AUTH_010`. 제한이 실린 토큰만 요청마다 세션 표의 지금 값을 읽는다(PK 한 번). 푸는 요청은 그 세션(`sid`)만 다음 제한으로 바꾸고(`replaceRestriction` — 조건부 UPDATE), 2단계를 켜면 그 직원의 다른 `twoFactorSetup` 세션은 끊는다(`revokeRestrictedOf` — 사유 `two-factor-not-verified`, 갱신에서도). **비밀번호 만료 알아채기**: 게이트웨이 대리 접근 세션에 메일 계정이 없고 `/api/account` 권한이 다섯 안이면 `PasswordExpiredException`(`jmap.client.ts` 의 `isPasswordExpiredPermissions` — 로그인 · 갱신 때 엔진 기준, 풀렸으면 지움). **2단계 필수 정책 · 문의 연락처**: 표 `system_settings`(한 줄 — 프로세스마다 10초 기억), `GET /api/support-contact`(로그인 없음 · `{ text }`), `twoFactorDeadline` 은 유예 중이고 아직 안 켠 직원만. **받은편지함 알림 메일**: 대리 접근 `Email/import`(안 읽음 · 필터 · 스팸 안 거침), 보낸 사람 `ARA SYSTEM `, 글은 디자인 README 13단계(링크 없음 · 이름 · 파일 이름 · 연락처 이스케이프 · 보내는 정화 통과), 실패는 warn(원래 일은 성공), 위험 파일은 처음 판정 때 한 번(`markInfected`). **운영 도구**(`sudo docker exec -it -backend node dist/cli/ops.js`): Nest 앱 컨텍스트(ScheduleModule 없음 · `FILE_SCANNER` null · `migrationsRun` false), 한국어 메뉴 → 바뀔 것 → y/N → 실행 → `ops_audit_log`. **메일 이전**(2026-11-02 — 전환의 imapsync): 「입사」의 이전용 비밀번호는 엔진 비밀번호 만료를 30일 뒤로 건다(그때까지 IMAP 정상 — 임시 비밀번호의 지난 만료는 IMAP 이 끊긴다), 이전 중 = 만료가 미래(31일 안)이고 실행 기록(`ops_audit_log`)의 마지막 판정 기록이 이전용(`hire-migration` · `migration-password` — 이전용 명령은 failure 도 센다) — 판정은 `modules/ops/migration-state.ts` 하나(엔진 만료만으로는 기본 만료의 찌꺼기와 가를 수 없다), 「메일 이전」(`modules/ops/migration-commands.ts`)의 이전 마치기가 직원용 임시 비밀번호로 바꾼다. 이전용 비밀번호는 실행 한 번 안에서 같은 값 — `migration-password.ts` 의 `MigrationPasswordSession`(싱글턴 · 메모리에만 · `OpsCli.run()` 의 finally 에서 지움), 약함 거절의 다시 만들기는 `temp-password.ts` 의 `applyFreshPassword`. 관리자 비밀번호는 TTY 에서만 · 입력 기록 0(`historySize: 0`) — 자동 시험만 `OPS_ALLOW_NO_TTY=1`. 관리자 Basic 은 `x:Account` · `x:MailingList` 에만, 직원 메일함 · Sieve · 앱 비밀번호는 게이트웨이 대리 접근(관리자에 impersonate 가 없다). 명령 열세 개(12 · 13 은 휴대폰 앱 — 아래 "휴대폰 앱 받기" 행)와 차례는 계약 6절(재설정: 앱 비밀번호 → 세션 끊기 → 알림 → 만료 → 세션 한 번 더 / 잠금: Sieve → authenticate 빼기 → 세션 / 이전 마치기: 앱 비밀번호 → 세션 → 로그인 기록 → 임시 비밀번호 + 만료 → 세션 한 번 더(알림 없음) / 퇴사 마무리: 엔진 계정 → 주소 키 표 한 트랜잭션(세션 행은 남김) · 예외 목록 · MailingList 구성원 → 디스크). 끊은 세션 기억은 15초마다 DB 에서 채운다(`revoked-sessions.ts` — 운영 도구는 다른 프로세스). 마이그레이션 `AddCutoverPrep`(`auth_sessions.restriction` · `system_settings` · `ops_audit_log`). Swagger 는 가드를 건 컨트롤러에 `@ApiSessionErrors()`(AUTH_001 + AUTH_010) | | 메일 검색 색인 (14단계 1차 — 색인 바탕 · 2차 — 검색 · 강조 · 3차 — 대화) | `modules/mail-search` · `modules/mail/jmap/jmap-index-source.ts` · `shared/utils/search-text.ts` · `subject-base.ts` | 표 둘(마이그레이션 `AddMailSearchIndex`): `mail_search_messages`(직원 주소 + 엔진 메일 id 마다 한 줄 — 원문(NFC) · 접기 글 `fold_*` · 글자 두 개 묶음 `grams` · 메일함 · 답장 관계(GIN 둘 — 직원 주소(lower 식) · 묶음 · 메일함을 한 색인에 담은 찾기 GIN `IDX_mail_search_messages_user_grams_mailboxes` 와 refs GIN. 마이그레이션이 손으로 만들고 엔티티에는 `synchronize: false` 로 이름만)) · `mail_search_states`(직원마다 한 줄 — building · ready, 엔진 Email state, 채우기 자리, 멈춘 까닭, 실패 수). 포트 `MAIL_INDEX_SOURCE`(`mail-index-source.ts` — 엔진 교체는 `mail-search.module.ts` 의 useClass 만): 어댑터는 textBody 의 글자 부분만 읽는다(첨부 안의 글은 읽지 않는다) — 본문 값은 부분 256KiB · 한 통 1MiB, 한 요청의 메일 전체 크기 합 32MiB · 16MiB 넘는 메일은 혼자. 접기 `foldText`(NFC 뒤 코드 포인트 하나씩 — 전각 → 반각 · 소문자, 원문과 위치가 같다) · 묶음 `gramsOf` · 맞은 곳 `matchRanges` · 말머리 떼기 `subjectBase` — **짝: frontend `shared/utils/search-match.ts`**, 같은 표본 표 `backend/test/search-text-samples.ts`(한쪽을 고치면 둘 다). 상태 줄은 로그인(`AuthService`) · 비밀번호 바꾸기 · `GET /api/search-index` 가 만들고 멈춤을 푼다(로그인한 적 없는 계정은 색인하지 않는다). 맞추기 `mail-search-sync.service.ts`(Email/changes — 실시간 state 소식 300ms 모음 · 5분 cron · 검색 바로 전 1.5초, 직원마다 하나(`MailSearchRuns`) · 서버 전체 4(`ConcurrencyGate`), cannotCalculate → 전체 대조), 채우기 `mail-search-build.service.ts`(밤 창 KST 22:00~02:45 · 05:00~07:30 — 1분 cron, 최신부터 50통씩 anchor, 다 읽으면 새 차례의 처음에 따라잡기 → 전체 대조 → ready). **시간 끝은 실패가 아니다** — `IndexDeadlineError`(이어 하기 — 실패 수 · 경고 없음). 색인 쓰기는 상태 줄을 `FOR UPDATE` 로 잠근 한 트랜잭션 — 퇴사 마무리(`ops-data.service.ts`)는 상태 줄 → 색인 줄 차례로 지운다. 맞추고 채우는 직원은 사내 도메인(`MAIL_DOMAIN`) 주소뿐이다(같은 DB 의 e2e 줄은 건드리지 않는다). cron 은 `timeZone: 'Asia/Seoul'`. 묶음 크기 · 시간 한도는 `mail-search.constants.ts` 한 곳(잰 뒤 바꾼다). 로그는 코드 · 사유 · 가린 주소만(메일 글 · 제목 · 엔진 설명 문장 없음), 조건 검색 쿼리(`from` · `to` · `subject` · `body`)는 `maskUrlForLog` 가 길이만, 개발의 DB 쿼리 로그도 끈다(`database.config.ts`). **2차(검색 · 강조)**: `GET /api/messages` 의 조건 `from` · `to`(참조 포함) · `subject` · `body`(검색어와 같은 검사 — `dto/search-query-fields.ts` 의 `SearchTextField`) · `after` · `before`(UTC) · `hasAttachment=true`. 목록 · 검색은 `modules/mail/message-list.service.ts` — 색인이 준비됐으면(ready · 지금 판 `INDEX_FORMAT` — 판이 다르면 서버가 뜰 때 building 으로 되돌려 밤에 다시 채운다) 색인, 아니면 엔진(조건은 `jmap/search-terms.ts` 의 `searchFieldConditions`), 쪽을 넘기는 중에는 커서의 방식대로(`mail-search/search-cursor.ts` — 색인 커서 2판 `{v:2,m:'i'}`, 엔진 커서는 1판). 첫 쪽의 색인 찾기가 DB 장애면 엔진으로 찾는다. 색인 찾기: 계획 `search-plan.ts`(낱말 · 칸 · 한 글자 낱말 `SINGLE_CHAR_TERMS_SKIP_BODY` · 두 글자 낱말뿐이면 묶음만으로 `exactByGrams`) → SQL `search-sql.ts` → `mail-search-query.service.ts`. **후보는 찾기 GIN 하나에서만 거른다** — `IDX_mail_search_messages_user_grams_mailboxes`(마이그레이션 `SearchIndexUserGramsMailboxes`, 2026-11-06 실사용 시험)가 직원 · 묶음 · 메일함을 한 번에 맞춰 맞은 줄만 읽는다. 주소는 `lower("user_email")` 식(그대로면 btree 로 그 직원 줄을 다 읽고 묶음 배열(TOAST)을 풀었다 — 260~700ms → 10ms), 메일함은 `"mailbox_ids" @> $n` 그대로(`= ANY` 면 그 직원의 묶음 줄을 모두 읽는다). **메일함만 담은 GIN 을 다시 만들지 않는다** — 메일함 id 가 계정마다 같은 꼴이라 posting 이 회사 전체인데, 계획기가 그것만으로 메일함 줄을 다 읽고 묶음 배열을 줄마다(다른 직원 줄까지) 풀었다(1만 통 메일함 1.1초 · 남의 큰 메일함과 id 가 같은 빈 메일함 1초 — 고친 뒤 15ms · 1ms). e2e 가 EXPLAIN(그 색인 하나 · 색인 조건에 메일함)과 GIN 목록을 본다. 확인(strpos — 접은 본문을 풀어 한 줄 0.15~0.3ms)은 후보 id 의 줄만, 후보가 많으면 100줄부터 4배씩 쪽이 찰 때까지 · 한 요청 3,000줄까지(넘으면 이어 찾기 커서 — 쪽이 limit 보다 적어도 hasNext), total 은 첫 쪽에서 1,000까지(넘거나 다 세지 못하면 null). 상수는 `mail-search.constants.ts`. 강조: 목록 `search-highlight.ts`(제목 · 본문에서 맞은 곳 둘레의 미리 보기 — 색인 방식에서만. 미리 보기는 인용 줄(`>`) 밖의 맞은 곳 먼저 → 인용 안에서만 맞고 검색어 낱말이 모두 색인의 다른 칸(접은 칸 넷 — `highlightTextsSql`)에도 있으면 인용 밖 첫 글 → 아니면 `>` 를 뗀 인용 조각. 본문 강조는 두 글자 이상 낱말만 — `search-plan.ts` 의 `bodyHighlightOf`, 읽기 화면 `highlightTermsOf` 도 같은 계획에서 꺼낸다), 읽기 화면 `modules/mail/message-highlight.ts` + **정화한 뒤** HTML 의 글 조각에 `` 를 넣는 `html/html-mark.ts`(태그 · 속성 · 주석 · style 안은 그대로, 길이에 비례 · 1,000개까지). 위치는 NFC 글의 UTF-16 [시작, 끝) — 강조가 있으면 제목 · 첨부 이름 · textBody 를 NFC 로 싣는다. **3차(대화)**: 대화 키 — 색인 쓰기(`mail-search-index.repository.ts` 의 `linkThreads`)가 넣은 메일 M 의 refs 가 가리키는 메일 · M 을 가리키는 메일 가운데 말머리를 뗀 제목(`subject_base`)이 같은 것의 키를 가장 작은 키로 합친다(쌍 읽기 한 쿼리 + UPDATE 한 문장 · 합치기 `thread-keys.ts`, Message-ID 가 없으면 혼자, 넣기는 이미 있는 줄의 대화 키를 덮지 않는다). 같은 대화 `GET /api/messages/{id}/conversation` · 묶은 목록 `GET /api/conversations?mailboxId=` — `modules/mail/conversation.controller.ts` · `conversation.service.ts`(색인이 준비됐으면 색인 · 아니면 엔진의 스레드 — 응답의 `basis`, 커서가 방식을 잇는다 `mail-search/conversation-cursor.ts` — `m` 이 색인 방식이면 **입구에서 decode**: 꾸민 커서는 맞추기 · 엔진 · DB 전에 400). 색인 쿼리 `mail-search/conversation-sql.ts` · `mail-conversation-query.service.ts` — **늘 그 직원 주소의 btree 로 먼저 좁힌다**(메일함은 `$n = ANY("mailbox_ids")` 꼴 — 메일함 posting 은 회사 전체라 그 직원의 줄에서 거른다. 메일함 칸을 담은 GIN 은 검색의 찾기 GIN 하나뿐이고 대화 쿼리는 쓰지 않는다), 같은 대화는 그 메일의 키와 쪽을 한 문장(LEFT JOIN LATERAL — 색인에 없음과 빈 쪽을 가른다). 묶은 목록 줄: messageIds 최신부터 `CONVERSATION_MESSAGE_IDS_MAX`(500)까지 · 참여자는 **첫 보낸 사람 칸**(`from_email` · `from_name` — 마이그레이션 `AddMailSearchSender`, 색인 판 4. 보낸 사람 글을 다시 해석하지 않는다 — 이름 안의 `<주소>` 는 주소가 아니다)에서 주소마다 한 번 · 10명까지 DB 가 고른다(대화마다 LATERAL `DISTINCT ON`), `pendingSendIds`(messageIds 가운데 붙잡힌 메일 — 기다리는 제출은 쪽마다 한 번: 색인은 `getSummariesWithHeld`, 엔진은 목록 요청 안). 10명 흉내(10만 줄)에서 큰 메일함 첫 쪽 15~80ms · 자세히 · 참여자 1~2ms. 엔진 쪽은 `jmap/jmap-conversations.ts`(Thread/get · collapseThreads) — **거르기는 값싼 속성 · 요약 속성은 내보낼 메일만**, 상한 · 시간은 `mail-search.constants.ts` 의 `CONVERSATION_ENGINE_*`(같은 대화 한 요청 1,000통 — 그 이하 스레드만 total, 넘으면 커서 자리부터 · total null / 묶은 목록 스레드마다 최신 500 · 한 요청 2,000(`shareScan`) — 상한에 걸린 줄은 읽지 못한 메일을 거르지 않고 통 수에 더하고 나머지는 읽은 최신 쪽으로 / 한 요청 15초 — 훑는 호출은 마지막 요약 읽기 몫 3초를 뺀 제한 시간, 마지막 읽기만 남은 시간을 다, 남은 3초면 더 훑지 않고 시간에 끊긴 훑기는 읽은 데까지로 답한다. 참여자 이름은 색인과 같은 `participantName`(100 코드 포인트)). 엔진의 Thread/get emailIds 는 받은 시각 오래된 차례다(2026-11-06 개발 엔진 200 스레드 확인). 휴지통 · 스팸메일함 역할과 내부 발송 키워드는 `jmap/mail-scope.ts` 한 곳, 보이기 경로의 기다리는 제출 상한 경고는 `pending-send.ts` 의 `warnIfHeldQueryFull`. 설정 `groupByConversation`(`modules/mail-user-settings` — 처음 끔, 서버는 화면이 부르면 묶을 뿐 설정을 보지 않는다) | | 앱 새 메일 알림 (앱 1단계 — FCM) | `modules/push` · `modules/mail/new-mail-push.service.ts` · `MailEventsService.hold` · `common/guards/app-client.guard.ts` | 계약 1절 "앱 새 메일 알림 · 앱 설정". **등록** `PUT`·`DELETE /api/push-registrations/current` · `DELETE …/current/unseen`(`push-registrations.controller.ts` — `@UseGuards(JwtAuthGuard, AppClientGuard)` + `@ApiBearerAuth()`: 앱 토큰만, 웹 쿠키 세션은 403 `AUTH_003`, 제한된 세션 403 `AUTH_010`). 표 `push_registrations`(마이그레이션 `AddPushRegistrations` — 세션(sid)마다 한 줄 · FID 유일 · 세션 표와 외래 키 없음). PUT 은 한 트랜잭션(`push-registrations.repository.ts` — 세션이 살아 있는 · 제한 없는 앱 세션인지 → 같은 FID 의 다른 줄 지우기(같은 FID 는 `pg_advisory_xact_lock('PSH1', hashtext(fid))` 로 차례) → upsert(새 줄만 `seen_at` = 지금) → 직원마다 10줄). 보낼 대상 · 붙잡을 직원은 늘 세션 표와 함께 고른다(살아 있는 · 제한 없는 앱 세션 — 끊은 세션은 `revoked_at` 으로 바로 빠진다), 끝난 세션의 줄은 5분 정리 `push-cleanup.service.ts`(그 휴대폰을 직원이 건드리지 않고 끝난 세션 — 저절로 끝남 · 다른 곳의 비밀번호 바꾸기 · 앱 갱신 유예 · 토큰 재사용 감지 — 의 휴대폰에만 한 번 session-ended, 끊은 까닭 표는 `push.constants.ts` 의 `SESSION_ENDED_NOTICE` 한 곳(`Record` — 까닭을 더하면 거기서 정해야 컴파일된다) — `DELETE … RETURNING`). 등록이 바뀐 소식은 `PushRegistrationsService.onChanged`(메일 쪽이 듣는다 — 푸시 모듈은 메일을 모른다, 사내 도메인만 `MailEngineModule` 의 `MAIL_SETTINGS` 에서). **보내기** 포트 `PUSH_SENDER`(`push-sender.port.ts` — 알림은 종류 · 개수뿐) · 구현 `configured-push-sender.ts`(env `FCM_SERVICE_ACCOUNT_KEY` — 없으면 보내지 않고 기동은 한다) · `fcm/`: 키 읽기 `fcm-credentials.ts`(env 검사와 같은 함수 — 까닭에 값을 싣지 않는다), OAuth `fcm-auth.ts`(RS256 JWT → `oauth2.googleapis.com/token`, 만료 5분 전까지 기억 · 함께 부르면 한 번), 보내기 `fcm-sender.ts`(`message.fid` — 실패 표는 계약, 시도 3번 · 20초 · 서버 전체 4 · 429 는 `Retry-After` 가 있을 때만 그때 한 번 · 보내기 직전(다시 하기마다) 부른 쪽의 `stillValid` — 그 세션의 줄이 아직 그 FID 인가(`isDeliverable`) · 로그는 종류마다 한 시간에 한 번 · 서버 종료로 끊긴 것은 로그 없이), 메시지 `fcm-message.ts`(순수 함수 한 곳 — 글은 `push-text.ts`), HTTP `fcm-transport.ts`(전역 fetch · 두 주소 고정 · 응답 64KB — 시험만 `forTesting`). 새 의존성 없음. **새 메일** `NewMailPushService`(메일 모듈): 보낼 기기가 있는 사내 직원의 엔진 연결을 붙잡고(`MailEventsService.hold` — SSE 와 같은 연결 · 같은 판정, 신호만 받는다 · 연결 상한에 세지 않는다, 함께 여는 수 4, 실패하면 1 · 5 · 15 · 60분 — 계정 문제는 60분부터), 새 메일 신호 → 같은 직원 30초에 한 번(첫 알림 바로 · 끝에 한 번) → 기기마다 N(`MailProvider.countInboxUnreadSince` — 받은편지함 · 안 읽음 · 초안 아님 · `seen_at` 뒤, 한 요청 8개씩) → N ≥ 1 이면 보낸다 · 받지 않는 기기는 FID 가 그대로일 때만 지운다. 붙잡을 때마다 따라잡기(10분 안 · `seen_at` · `last_pushed_at` 의 **다음 초**부터 — 받은 시각은 초, N 은 `seen_at` 의 초부터 같은 초를 넣는다. 엔진의 `after` 는 그 초를 넣지 않아(0.16.27 — 2026-11-08 확인) 어댑터가 한 초 앞을 넘긴다. 붙잡은 뒤 신호가 먼저 오면 따라잡기는 건너뛰고, 따라잡기 뒤 첫 신호 보내기는 새로 받은 메일이 있는 기기에만 — 붙잡은 직후의 메일 하나에 한 번). 붙잡기가 끝나면(`ended` — 계정 문제 60분 · 그 밖 1분 뒤 다시, 서버 종료가 아닌데 허브가 닫혀도 알린다) 간격 끝 차례도 지운다, 등록이 바뀐 소식은 기다림을 풀고 바로 붙잡아 본다. 같은 직원의 보내기는 `KeyedQueue` 로 차례 | diff --git a/.claude/skills/vue3/SKILL.md b/.claude/skills/vue3/SKILL.md index 6f1c67a..4a4fda1 100644 --- a/.claude/skills/vue3/SKILL.md +++ b/.claude/skills/vue3/SKILL.md @@ -32,8 +32,8 @@ description: Vue 3 프론트엔드(frontend/) 개발 규칙 — Composition API | 전역 셸 | `src/components/shell/AppShell.vue` | 앱 레일 + 상단 바 + 모듈 영역. 레일 모듈 목록은 `src/shared/constants/app-modules.ts` 하나. 상단 바(`AppTopBar.vue` — 15단계): 머리글자 · [로그아웃]은 줄지 않고(nowrap) 이름 · 주소 칸만 줄어 "…"(title "이름 (주소)"), 화면 폭 480(30rem) 이하에서 주소를 감춘다 — 줄 수 · 차례는 그대로이고 검색 칸(search 슬롯 · 통합 검색)도 `min-width: 0` 으로 바 안쪽에 맞게 준다(360 에서 넘쳤다). 앱 틀은 잘못 놓은 파일을 무시한다(15단계 — `composables/useWindowFileDrop.ts` 의 `useIgnoredWindowFileDrop`: 받는 화면(`useWindowFileDrop` — 쓰기 화면 · 서명 설정)이 떠 있으면 그 화면이 받는다) | | 디자인 토큰·글꼴 | `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/`. 탭 · 홈 화면 아이콘(`favicon-16.png` · `favicon-32.png` · `apple-touch-icon.png`)은 `index.html` 이 `/src/assets/brand/…` 로 걸어 빌드가 `/assets/` 의 해시 파일로 바꾼다 — 배포 직후 새 아이콘이 보이고, 공개 내려받기 호스트도 `/assets/` 는 연다. `public/favicon.ico` 는 주소를 직접 묻는 클라이언트용. 모양 · 크기 규칙은 디자인 README 머리 | -| 토스트 | `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) · 주소록의 연락처 · 그룹 창(`medium`: 폭 400 — 8단계)이 쓴다 — 새 대화상자도 이것으로 만든다. 되돌릴 수 없는 일의 확인은 처음 초점을 「취소」에 둔다. 초점을 지키려고 `aria-disabled` 로 막은 단추에는 마우스를 올린 모양이 걸리지 않는다. Esc 는 `cancel` 로 알리기만 한다 — 브라우저가 막지 못하고 스스로 닫았는데(Esc 를 거듭 누름) 쓰는 쪽이 닫지 않기로 했으면 `reshow()`(ref 로 부른다)로 다시 띄운다. 초점은 열린 동안 `focusin` 으로 기억한 창 안의 마지막 요소로 돌려준다(브라우저는 `close` 전에 초점을 창 밖으로 옮긴다) | +| 토스트 | `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단계 필터 적용의 [{메일함} 보기] · [다시 시도], 시간 · 세션 묶기는 다른 단추와 같다). 진행 알림도 세션이 바뀌면 닫는다. **✕로 닫을 때까지 남는 알림**(`sticky` — `useToast().success/error(…, { sticky: true })` · 진행 알림의 `succeed(글, action, { sticky: true })` · `fail(…)`)은 저절로 닫히지 않고 넘쳐도 다른 알림부터 닫는다(진행 · 마감 알림과 같다). ToastHost 가 성공 알림에도 ✕ 를 단다(`toast__close--on-brand`). 지금은 주소록 가져오기의 결과 · 멈춤 알림만 쓴다(16단계) — 여러 명을 한꺼번에 되돌리는 길이 그 알림뿐이라서. 세션이 바뀌면 다른 단추 알림처럼 닫는다 | +| 모달 대화상자 | `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단계) · 서명 지우기 확인(`components/mail/editor/signature/SignatureDeleteDialog.vue` — 폭 360 · 처음 초점 「취소」) · 저장하지 않은 서명이 있을 때 다른 서명 고르기(`LeaveConfirmDialog` 의 `signature-switch` — 둘째 단추 [바꾼 내용 버리기])이 쓴다 — 새 대화상자도 이것으로 만든다. 되돌릴 수 없는 일의 확인은 처음 초점을 「취소」에 둔다. 초점을 지키려고 `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 시작선을 쓴다 (새 화면도 이 토큰으로) | @@ -42,10 +42,10 @@ description: Vue 3 프론트엔드(frontend/) 개발 규칙 — Composition 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`). 메뉴 · 팝오버의 좌우는 바라는 끝(`align`)으로 그린 뒤 보이는 영역 밖으로 넘치면 덜 넘치는 반대 끝에 맞춘다(15단계 — `EditorPopup` 이 그린 직후 · 초점을 주기 전에 스스로 잰다: `composables/usePopupPlacement.ts` 의 `measurePopupAlign` · 판단 `shared/utils/popup-placement.ts` 의 `popupAlignFor`). 항목은 들여쓰기(`depth` — 10 · 28 · 44 · 56)와 따로 읽힐 이름(`ariaLabel`)을 받는다 | +| 메일 본문 편집기 | `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`). 메뉴 · 팝오버의 좌우는 바라는 끝(`align`)으로 그린 뒤 보이는 영역 밖으로 넘치면 덜 넘치는 반대 끝에 맞춘다(15단계 — `EditorPopup` 이 그린 직후 · 초점을 주기 전에 스스로 잰다: `composables/usePopupPlacement.ts` 의 `measurePopupAlign` · 판단 `shared/utils/popup-placement.ts` 의 `popupAlignFor`). 항목은 들여쓰기(`depth` — 10 · 28 · 44 · 56)와 따로 읽힐 이름(`ariaLabel`)을 받는다. **서명 자리**(16단계 — `editor/mail-signature-block.ts`의 `mailSignature` 블록 노드): 보낼 때 · 저장할 때 빈 문단 뒤의 **속성 없는 `
`**(앱과 같은 경계 — 계약 2절 "앱이 만드는 모양")로 나가고, 다시 열 때 "맨 위 단계에서 빈 문단 바로 뒤의 속성 없는 div 이고 글이나 그림이 있다"로 되찾는다(원문 인용 안 · 꾸민 · 빈 div 는 아니다). 서명 자리는 하나 — 붙여 넣기로 하나 더 생기면 처음 것만 두고 푼다. **서명 자리 앞에는 늘 빈 줄** — 그 빈 줄에 쓰거나 지우면 다시 넣는다(같은 appendTransaction — 한글 조합 중(`composition` 표시)에는 미룬다, 화면에 붙인 편집기에서만 돈다 · 시험은 `editor.mount`), 보내고 저장하는 모양은 `withSignatureBoundary` 로 한 번 더(`MailEditor` 의 `contentOf`) — 리뷰 M3. | | 임시 저장 | `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` 로(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장은 서버가 센다) | +| 메일 서명 (16단계 — 여러 서명) | `src/stores/mail-signature.store.ts` · API `composables/useMailSignatures.ts` · 타입 `shared/types/mail-signature.ts` · `components/mail/editor/signature/`(`SignatureSettings` 화면 · `SignatureList` · `SignatureDefaultsField` · `SignatureForm` · `SignatureEmpty` · `SignatureDeleteDialog` · 공용 편집기 `SignatureEditor` + `SignatureToolbar`) · `shared/utils/mail-signature.ts` · `shared/constants/mail-signature.ts` · `pages/settings/MailSignaturePage.vue` · 쓰기 화면 [서명 ▾] `components/mail/ComposeSignatureMenu.vue`(ComposeFooter 의 `signature` 슬롯) · 서명 자리 `components/mail/editor/mail-signature-block.ts` | 서명은 직원마다 10개(만든 차례)와 기본 둘(새 메일 · 답장과 전달 — `GET · PUT /mail-signature-defaults`, null = 넣지 않음) — 계약 2절 "서명". 스토어는 목록과 기본 둘을 함께 받는다(`ensureLoaded` — 메일 화면에 들어올 때 · 쓰기 화면이 열 때, 실패는 알리지 않고 서명 없이). 만들기 · 고치기 · 지우기는 받아 둔 목록을 그 자리에서 고치고(첫 서명은 기본 둘 — 서버와 같게, 지운 서명의 기본은 null), 기본 고르기(`chooseDefault`)는 화면을 먼저 바꾸고 **저장은 한 번에 하나**(보내기 · 읽기 설정과 같은 결 — 끝내 실패하면 서버가 확인한 값으로). 저장 거절(`VAL_001`)은 `refresh()`(조용히 — 그사이 바꿨으면 버림)로 다른 창의 같은 이름을 가른다. 설정 화면은 들어올 때마다 `load` 하고 TipTap 청크(`SignatureSettings`)를 지연 로딩한다 — 목록 208 · 고치는 칸, 칸이 640 보다 좁으면 목록을 위로(컨테이너 쿼리 `signature-settings`). 고른 서명마다 `SignatureForm` 을 key 로 새로 만들되 새 서명을 처음 저장한 것은 같은 칸이다. 저장하지 않은 채 다른 서명 · [새 서명] · [취소]는 `LeaveConfirmDialog`(`signature-switch`), 다른 화면은 `useLeaveConfirm`(`signature-leave`). 이름 검사 `signatureNameProblem`(앞뒤 공백 · 영문 대소문자 무시 — 서버와 같다). 미리 보기는 편집기 스키마의 `DOMSerializer` 로 고른 서명을 늘 그린다. 그림 3장은 서명 칸 안의 그림 수로(한 사람 보관 20장은 서버가 센다 — 올리기 `VAL_001` 은 알림). **쓰기 화면**: 새 메일은 새 메일 기본을 끝에, 답장 · 전달은 답장과 전달 기본을 인용 위에 서명 자리(`signatureBlockHtml` — `data-mail-signature` 표시 div)로 넣고, 이어 쓰기는 다시 넣지 않는다. [서명 ▾](임시 저장 다음 · 44 · 편집기 메뉴 220 · `menuitemradio` ✓ · 키보드로 열면 첫 항목 — `EditorMenu` 의 `initialFocus`)는 `MailEditorHandle` 의 `replaceSignature`(그 자리만 · 실행 취소 됨 · 넣지 않음은 앞 빈 줄까지 · 없으면 처음 넣는 자리) · `signaturePlace` · `signatureFingerprint`(글 · 그림으로 견줘 ✓ — 고쳤으면 없음, 단추 이름 "서명: 대외" · "서명")를 쓰고 바꾼 뒤 role=status 에만 "서명을 ‘사내’로 바꿨습니다."(조사 `directionParticle`). 서명 관리는 설정 › 메일 › 서명(나가기 규칙대로) — 디자인 README "16단계" | | 메일 스토어 구성 | `src/stores/mail.store.ts`(조립 · 공개 API) + `src/stores/mail/` — `mailboxes.ts` · `list.ts` · `selection.ts` · `message.ts` · `bulk.ts` · `mailbox-admin.ts`(4단계 메일함 관리) · `conversation.ts`(14단계 같은 대화 칸) | Pinia 스토어는 `useMailStore` 하나이고 기능별 setup 모듈을 조립한다(스토어를 여러 개로 나누지 않는다 — 서로 부르는 고리가 생긴다). 목록 출처는 `shared/utils/mail-list-source.ts`(메일함 · 검색 · 대화로 묶은 메일함 `conversations` — 14단계). **목록에 든 메일인지 볼 때는 `messages` 대신 줄 기준 도우미**(`rowCount` · `rowIndexOfMessage` · `rowMessageIdAt` · `isListedMessage` · `listedSummaries`)를 쓴다 — 대화 목록에서는 `messages` 가 비어 있다. 목록 요청은 출처가 바뀌면 `listAbort`(AbortController)로 끊는다(끊긴 요청은 알리지 않는다). **늦게 온 응답 버리기**: 목록 맞추기는 이 탭의 목록 변경 세대(`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절 "새 메일 확인" | | 실시간 새 메일 (5단계) | `src/composables/useMailRealtime.ts`(`App.vue` 가 시작 — 로그인한 동안 앱 전체) · `stores/mail-realtime.store.ts` · `shared/utils/sse-parser.ts` · `mail-events.ts` · `mail-realtime.ts` · `browser-support.ts` | **브라우저마다 연결 하나** — Web Locks 잠금(이름에 사용자 주소)을 받은 탭만 `GET /api/mail-events` 를 읽고 BroadcastChannel 로 나눈다(메시지에 사용자 주소, 다른 계정 것은 버린다). `fetch` 로 읽는다 — `EventSource` 는 401 을 몰라 쓰지 않는다. 75초 무소식 감시는 요청 시작부터, 401 → 세션 갱신 한 번(그 바로 뒤의 시도만 "갱신 직후"), 다시 잇기 2 · 5 · 15 · 30 · 60초(±20%) — 간격은 연결이 **자리 잡은 뒤**(정상 ping 또는 20초)에만 처음부터 다시 세고, 자리 잡기 전의 `end` 는 실패처럼 기다린다(열리자마자 끝나는 연결에서 갱신 폭주를 막는다). 메일 화면(`useMailStatePolling`)은 이 소식을 받아 연결이 살아 있는 동안 1분 확인을 멈추고, `state` 가 바뀌면 보이는 탭은 맞추기(1초 모아서 — `MAIL_REALTIME_SYNC_DELAY_MS`) · 숨은 탭은 `inboxUnread` 로 탭 제목만(`setInboxUnread`) — 계약 2절 "실시간 알림" | @@ -56,7 +56,7 @@ description: Vue 3 프론트엔드(frontend/) 개발 규칙 — Composition API | 같은 대화 (14단계) | `src/components/mail/MessageConversation.vue`(읽기 화면 첨부 아래 칸) · `ConversationMessageRow.vue`(줄) · `ConversationMessagePanel.vue`(펼친 안) · `stores/mail/conversation.ts` · `composables/useConversationSync.ts`(언제 받을지 — 시계 주입) · `shared/utils/mail-conversation.ts`(이름 자리 · 쪽 맞추기 · 갈 곳) · `shared/constants/mail-conversation.ts` · `shared/utils/format.ts` 의 `formatConversationTime` | 받기 · 다시 받기는 계약 2절 "대화 — 14단계"의 클라이언트 줄(100통 · 이어 묻기 5번 · [더 보기], 실패는 칸 안 안내 — 404 면 칸 없음). **다시 받기 판정은 `mail.store` 의 `mailboxTotalsKey`**(메일함마다 `id:totalCount` — 바뀔 때만 `requestConversationRefresh()`, MailLayout 의 바뀜 소식에서는 부르지 않는다 — 읽음 · 중요 표시만 바뀐 소식으로 대화를 다시 받지 않게 — 칸의 안 읽은 줄은 목록처럼 굵게 그리고, 이 탭이 바꾼 읽음은 `list.ts` 의 `watchLocalReads` 로 바로 적는다: 받는 동안 바꾼 것은 `conversation.ts` 의 `withLocalReads` 가 받은 값보다 앞세우고, 다른 탭의 읽음은 다음 받기 때 — 2026-11-07), 간격은 받기가 끝난 때(`conversationBusy` 가 꺼질 때)부터 10초. 펼친 본문은 `MailBodyFrame` · 강조 쿼리 없이, 안 읽은 메일은 `markRead`(`Promise` — 같은 목록의 `pendingFlags` 에 같은 읽음이 있으면 건너뛴다)로. `closeMessage` 가 칸도 비운다. 초점은 누른 단추가 사라지면 줄의 펼침 단추 → 새 [다시 시도] → 칸 h3(`tabindex="-1"`) → 칸이 없어지면 읽기 화면에 `settled`. 상태 영역은 늘 두고 글만 바꾼다(다시 실패해도 읽게). [이 메일만 열기]는 분할 보기 push · 목록 보기 replace(layout prop). 줄 차례는 `orderHeldLast`(붙잡힌 메일은 끝 — 보낼 시각 차례), 미리 보기는 `previewWithoutOwnQuote`(우리 원문 인용 머리부터 잘라 냄 — `mail-conversation.ts`), 펼친 줄의 첨부 수 안내는 `ConversationMessagePanel`. 15단계: 칸 머리 오른쪽 [모두 펼치기] · [모두 접기](36 — `MAIL_CONVERSATION_TEXT.expandAll` · `collapseAll`, 판정 `conversationAllExpanded` — 하나씩 다 펼쳐도 [모두 접기]). 모두 펼친 줄의 본문은 화면에 들어온 줄부터 하나씩(`composables/useConversationPanelVisibility.ts` — IntersectionObserver 하나로 `[data-conversation-panel]` 을 보고 스토어의 `noteConversationPanelVisible` 에 알린다 · 차례 `detailQueue`, 차례 전에 화면 밖으로 나간 줄은 건너뛴다), 읽음은 본문이 화면에 보였을 때만(`lazyRows` — 손으로 펼친 줄은 지금처럼 받는 대로). 펼친 줄 머리 [답장](`data-conversation-head-reply` — 머리 틀 `.conversation-row__head` 안에 펼침 단추와 나란히, 임시 저장본 · 예약 · 지워진 메일엔 없다) | | 대화로 묶어 보기 (14단계) | `src/components/mail/MailConversationToggle.vue`(목록 머리 [대화로 묶기]) · `ConversationListItem.vue`(분할 보기 줄) · `ConversationTableRow.vue`(목록 보기 표 줄) · `composables/useConversationRows.ts` · `shared/utils/mail-conversation-list.ts`(응답 맞추기 · 할 일 id · 참여자 · 머리 글 · 표시 적용 · 맞추기 병합 · 삭제 뒤 갈 곳) · `stores/mail/list.ts`(출처 `conversations`) · 설정 `stores/mail-settings.store.ts` 의 `groupByConversation`(+ 설정 › 보내기 · 읽기 한 줄) · 글 `shared/constants/mail-conversation.ts` 의 `MAIL_CONVERSATION_LIST_TEXT` | 받기 · 맞추기 · 줄에 하는 일은 계약 2절 "대화 — 14단계"의 클라이언트 줄(20줄 · 맞추기는 불러온 줄 수만큼 최대 100줄 `silent` · 할 일 = `messageIds` − 붙잡힌 메일 · 줄 전체 표시는 모두 바꿨을 때만 · 옮기기 · 삭제 뒤 한 번 다시 받기). **첫 진입 짐작**은 localStorage `groupByConversationHintKey(주소)` — 설정을 받기 전 · 받지 못했으면 이 값, 받으면 서버 값, 읽기 · 쓰기가 던져도 끔. 로그아웃 · 세션 만료에 설정 스토어를 비워도 화면이 보는 값은 그대로 둔다(뒤집히면 watcher 가 떠나는 화면의 목록을 다시 부른다 — 2026-11-06 리뷰). **목록을 부르는 watcher 는 `authStore.canUseApp` 이 false 면 부르지 않는다**(소스에도 넣는다). 붙잡힌 메일이 든 줄은 고르기 칸 · 별이 `aria-disabled` + 까닭(`SEND_LATER_TEXT.heldNotSelectable`). 목록 줄의 화면 낭독 글("안 읽음, " · "중요 표시" · "첨부 있음")은 `shared/constants/mail.ts` 의 `MAIL_LIST_ROW_TEXT` — 메일 줄은 아직 직접 적은 글이라 함께 고친다. 읽기 화면 [이전] · [다음] · 위치는 대화 단위(`ListReadNav.vue`), 한 통 삭제 뒤 갈 곳은 `conversationNeighborAfterRemoval`. 분할 보기 머리의 줄인 개수 글 `listHeadShortCountText`(전체 글은 title · 숨긴 글) · 도구 줄 `selectedShort`(숨긴 글 "대화 N개 선택됨") · 표 머리 "참여자"(`participantsColumn`) · 줄 알림 `announceMove(…, { from })` → `MAIL_BULK_TEXT.movedFrom`("{메일함}에 있는 메일 N통을 …"). 영구 삭제는 `MailPurgeDialog` 의 `from` + `announcePurge(…, { from })` → `purgeTitleFrom` · `purgedFrom`(값은 `useBulkMailActions` 의 `conversationFrom` — 묶지 않은 목록 · 읽기 화면은 null). **설정 다시 받기** `composables/useMailSettingsRefresh.ts`(MailLayout — 탭이 보일 때 60초 `MAIL_SETTINGS_REFRESH_AFTER_MS` · `canUseApp`) + 스토어 `refresh()`(silent · 받는 동안 loading 으로 바꾸지 않음 · 저장 중이면 하지 않음 · 받는 사이 고른 것(`choiceSeq`) · 다시 받기(`loadSeq`) · 세션이 바뀌면 버림) · `fetchSeq` — 디자인 README "14단계 구현하며 정한 보완 — 목록 대화로 묶어 보기" | | 여러 통 처리 · 삭제 | `src/components/mail/MailCheckbox.vue` · `MailSelectionToolbar.vue`(+ `MailBulkButton` · `MailBulkMoveButton`) · `MailPurgeDialog.vue` · `MessageDeleteButton.vue` · `composables/useBulkMailActions.ts` · `useMessageRemoval.ts` · `useMailMoveFeedback.ts` · `shared/utils/mail-selection.ts` · `mail-bulk.ts` | 100통씩 차례로. 읽음 · 중요는 먼저 바꾸고 `failedIds` 만 되돌리고, 이동 · 삭제는 응답의 id 만 뺀다. **웹은 `DELETE /api/messages/{id}` 를 쓰지 않는다** — 휴지통 밖 삭제는 `PATCH` 이동(+ 되돌리기), 휴지통에서는 확인 대화상자(처음 초점 「취소」) 뒤 `POST /message-deletions`. 읽기 화면도 그 메일의 `mailboxIds` 로 같은 규칙을 탄다. 도구 줄은 role="toolbar" 로빙 tabindex(바쁜 · 꺼진 단추는 건너뛴다). [되돌리기] 뒤 알림은 한 메일함이면 그 이름(`restoredMessageText(…, mailboxName)` — "메일 N통을 받은편지함으로 되돌렸습니다.") — 계약 2절 "여러 통 한꺼번에". [이동 ▾]의 옮길 곳 · 스팸 표시 · 끌어다 놓기는 아래 「메일 옮기기 · 끌어다 놓기 · 좁은 읽기 칸 (15단계)」 행 | -| 설정 › 비밀번호 · 부재중 (4단계) | `src/pages/settings/AccountPasswordPage.vue` · `MailVacationPage.vue` · `components/settings/`(`SettingsPageFrame` 공용 틀 — 머리 오른쪽 단추는 `actions` 슬롯 · `PasswordChangeForm` · `PasswordField` · `Vacation*`) · `stores/mail-vacation.store.ts` · `shared/utils/password-change.ts` · `mail-vacation.ts` · 메뉴 `shared/constants/settings-menu.ts` | 메뉴는 묶음(계정 › 비밀번호 / 메일 › 서명 · 부재중 자동 응답 · 필터(6단계) · 알림(5단계)), 라우트 이름은 `SETTINGS_ROUTES` 하나. 비밀번호는 서버와 같은 규칙을 먼저 보고, 서버 거절(`AUTH_005` · `VAL_001`)은 칸 아래에만(알림 없음) — 성공하면 세 칸을 비운다. 부재중은 날짜만 고르고 브라우저 시간대의 시작일 0시 · 끝나는 날 다음 날 0시를 UTC 로 보낸다(변환은 `mail-vacation.ts` 한 곳 — 되읽을 때는 1ms 앞 날짜). 켤 때만 검사하고 꺼 둔 채로는 빈 값도 저장한다(길이 150 · 600 도 켤 때만 — 꺼 둔 채로는 4단계 한도까지, 끄기는 늘 저장되게). 부재중 상태는 메일 화면에 들어올 때 받고, 패널 안내 `components/mail/MailVacationNotice.vue` 가 1분마다 다시 계산한다(`useClock` · `VACATION_NOTICE_REFRESH_MS`). 저장 응답은 세션 세대(`authStore.generation`)가 바뀌었으면 쓰지 않는다. 저장한 값이 켜짐인데 엔진에서 규칙이 꺼져 있으면(응답의 `isActive: false` — 7단계) 머리 아래 노란 줄(`MailFilterNotice` 를 단추 없이 — `actionLabel` 을 주지 않는다). 칸을 고친 값이 아니라 저장한 값으로 보고, `isActive` 는 PUT 본문에 넣지 않는다(`useMail().saveVacation` 이 다섯 칸만) — 계약 1절 · 2절 "부재중 자동 응답" | +| 설정 › 비밀번호 · 부재중 (4단계) | `src/pages/settings/AccountPasswordPage.vue` · `MailVacationPage.vue` · `components/settings/`(`SettingsPageFrame` 공용 틀 — 머리 오른쪽 단추는 `actions` 슬롯 · `PasswordChangeForm` · `PasswordField` · `Vacation*`) · `stores/mail-vacation.store.ts` · `shared/utils/password-change.ts` · `mail-vacation.ts` · 메뉴 `shared/constants/settings-menu.ts` | 메뉴는 묶음(계정 › 비밀번호 / 메일 › 서명 · 부재중 자동 응답 · 필터(6단계) · 알림(5단계)), 라우트 이름은 `SETTINGS_ROUTES` 하나. 비밀번호는 서버와 같은 규칙을 먼저 보고, 서버 거절(`AUTH_005` · `VAL_001`)은 칸 아래에만(알림 없음) — 성공하면 세 칸을 비운다. 부재중은 날짜만 고르고 브라우저 시간대의 시작일 0시 · 끝나는 날 다음 날 0시를 UTC 로 보낸다(변환은 `mail-vacation.ts` 한 곳 — 되읽을 때는 1ms 앞 날짜). 켤 때만 검사하고 꺼 둔 채로는 빈 값도 저장한다(길이 150 · 600 도 켤 때만 — 꺼 둔 채로는 4단계 한도까지, 끄기는 늘 저장되게 — 16단계부터 내용은 편집기, 아래 「부재중 서식 본문 · 회사 밖 응답 (16단계)」 행). 부재중 상태는 메일 화면에 들어올 때 받고, 패널 안내 `components/mail/MailVacationNotice.vue` 가 1분마다 다시 계산한다(`useClock` · `VACATION_NOTICE_REFRESH_MS`). 저장 응답은 세션 세대(`authStore.generation`)가 바뀌었으면 쓰지 않는다. 저장한 값이 켜짐인데 엔진에서 규칙이 꺼져 있으면(응답의 `isActive: false` — 7단계) 머리 아래 노란 줄(`MailFilterNotice` 를 단추 없이 — `actionLabel` 을 주지 않는다). 칸을 고친 값이 아니라 저장한 값으로 보고, `isActive` 는 PUT 본문에 넣지 않는다(`useMail().saveVacation` 이 일곱 칸만 — 16단계의 `htmlBody` · `external` 까지) — 계약 1절 · 2절 "부재중 자동 응답" | | 메일함 관리 · 오래 걸리는 처리 (4단계) | `src/components/mail/MailboxTree.vue` · `MailboxPanelRow.vue` · `MailboxMoreButton.vue`(⋯ 메뉴) · `MailboxDialogs.vue`(+ `Mailbox*Dialog.vue`) · `MailboxEmptyButton.vue`(목록 머리 「비우기」) · `composables/useMailboxActions.ts` · `stores/mail/mailbox-admin.ts` · `shared/utils/mailbox-operation.ts` · 상수 `shared/constants/mailbox-manage.ts` | 만들기 · 이름 바꾸기는 칸 안 실패(`MAIL_009` · `VAL_001`)를 알림 없이 칸 아래에. 모두 읽음 · 비우기 · 지우기는 남은 수가 0(지우기는 `deleted: true`)이 될 때까지 다시 보내며 진행 알림을 고친다 — 진행은 응답의 `processed`, 세션이 바뀌면 멈춘다(`epoch` — 늦게 온 만들기 · 이름 바꾸기 응답도 버린다), 같은 메일함의 같은 처리는 하나, **휴지통 비우기와 메일함 지우기는 함께 돌리지 않는다**(비우기는 그사이 휴지통에 들어온 메일도 지운다 — 판정은 스토어의 `isMailboxOperationBlocked` 하나, 메뉴 · 단추 · 대화상자 확인이 모두 이것을 본다). 끝난 뒤 누른 단추가 사라져 초점을 잃었으면 패널의 지금 메일함 항목으로 옮긴다. ⋯ 메뉴는 보이는 영역 안에 다 들게 연다(`shared/utils/popup-placement.ts` — 아래가 모자라면 위로 · 좌우가 넘치면 반대 끝으로(15단계 — EditorPopup)). 되돌릴 수 없는 일은 확인 대화상자(처음 초점 「취소」) 뒤에, 확인한 그 메일함만 — 계약 2절 "메일함 관리" | | 스팸 · 원문 (4단계) | `src/components/mail/MessageSpamButton.vue`(읽기 화면) · 도구 줄의 스팸 단추(`MailSelectionToolbar.vue`) · `MessageMoreMenu.vue`(⋯ › 원문 내려받기) | 스팸 신고 · 스팸 아님은 이동과 `isJunk` 를 한 요청에 보내고(`mailStore.moveMessages(…, { isJunk })`), [되돌리기]는 원래 메일함으로 반대 값을 보낸다. 원문(.eml)은 `useMail().downloadMessageSource()` — 첨부처럼 `confirmSession()` 을 먼저 거친다 — 계약 2절 "스팸 · 원문" | | 메일 필터 (6단계) | `src/pages/settings/MailFilterPage.vue` · `components/settings/MailFilter*.vue`(목록 · 행 · 목록 위 줄 `MailFilterNotice` · 빈 상태 · 안내 상자 `MailFilterNotes` · 고치기 창 `MailFilterDialog` + 조건 `MailFilterConditionFields` · `MailFilterConditionRow` · 할 일 `MailFilterActionFields`) · `mail-filter-ui.ts` · `stores/mail-filter.store.ts` · `composables/useMailFilterActions.ts`(켜기 · 순서 · 지우기 · 되돌리기 알림) · `useMailFilterDialog.ts`(창 상태) · `shared/utils/mail-filter.ts`(검사 · 요약 · 옮길 곳 · 되돌릴 자리) · `shared/constants/mail-filter.ts` · 읽기 화면 ⋯ 「이런 메일로 필터 만들기」(`MessageMoreMenu.vue` → `/settings/mail/filters?from=`) | 바꾸는 일은 모두 `PUT /api/mail-filters` 전체 교체(받은 `version`)이고 **저장은 한 번에 하나**(`saving`). 켜기 · 순서는 화면을 먼저 바꾸고 실패하면(그사이 목록이 안 바뀌었으면) 되돌린다. 창의 저장은 그 순간 목록의 켜짐 값을 쓴다. [되돌리기]는 지운 필터만 앞 · 뒤 필터 기준 자리에 다시 넣고, 누른 차례대로 앞 저장이 끝난 뒤 보낸다(`restoreQueue` — 기다림과 `send()` 사이에 await 를 두지 않는다 — 2026-10-28 리뷰의 경합). `MAIL_012` 는 목록 위 줄 + 창 안 안내와 [다시 불러오기](쓴 내용을 지키고 다시 받은 목록 위에 저장), 늦게 온 409 는 보낼 때의 순번(seq)이 그대로일 때만 줄을 켠다. 검사는 서버와 같은 규칙(전달 주소의 @ 앞은 dot-atom · 64자로 서버보다 좁게)이고 거절은 칸 아래(`VAL_001` · `MAIL_013` 은 알림을 끈다). 필터 화면이 떠 있지 않을 때 실패한 되돌리기는 알림으로(`attachPage` · `pageVisible`). 세션이 바뀌면 늦은 응답을 버린다(`authStore.generation`) — 계약 2절 "메일 필터", 디자인 README "6단계" | @@ -67,11 +67,15 @@ description: Vue 3 프론트엔드(frontend/) 개발 규칙 — Composition API | 외부 이미지 · 인쇄 (9단계) | `src/components/mail/MailBodyFrame.vue`(`imagesShown`) · `shared/utils/mail-html.ts`(`mailFrameCspImagesShown` · `PROXIED_IMAGE_PATTERN`) · `mailStore.showImages()` · 인쇄 `pages/mail/MailPrintPage.vue` + `composables/useMailPrint.ts` · `shared/constants/mail-print.ts` | [이미지 보기]는 `GET /messages/{id}?images=show` 로 그 메일의 본문 칸만 다시 받는다. 본문 틀의 CSP 는 보일 때만 그림에 **우리 출처의 중계 경로**(`/api/mail-images/`)를 연다 — `'self'` 로 열면 정화를 빠져나간 CSS `url()` 이 다른 API 를 부를 수 있다(2026-10-29 리뷰). 중계 주소 모양은 짝: backend `images/mail-image-token.ts` 의 `TOKEN_PATTERN`. 인쇄는 새 탭(`window.open` 은 누름 처리 안에서 바로), A4 폭(210mm — @page 와 같은 값, 함께 고친다)에서 본문 높이를 다시 잰 뒤 `window.print()` 한 번. Ctrl+P 판정은 `keyboard-shortcut.ts`(조합 중 · 다른 수식키는 브라우저 인쇄, `code === 'KeyP'` 도) | | 보낸 사람 차단 · 보내기 · 읽기 설정 (9단계) | `src/composables/useSenderBlock.ts` · `components/mail/MessageBlockDialog.vue` · `stores/mail-block.store.ts` · `shared/utils/mail-block.ts` · `shared/constants/mail-block.ts`(`PUBLIC_MAIL_DOMAINS`) · `pages/settings/MailBlocksPage.vue` · `MailSendReadPage.vue` · `stores/mail-settings.store.ts` · `composables/useMailSettings.ts` · 설정 틀의 `title-aside` 슬롯(`SettingsPageFrame`) | 차단은 엔진의 Sieve(필터 · 부재중과 같은 스크립트 — 계약 2절 "보낸 사람 차단"). 여러 사람이 쓰는 메일 서비스 목록은 **짝: backend `mail.constants.ts`**(30개 · 같은 차례 — 한쪽을 고치면 둘 다). 차단 · 풀기가 성공하면 엔진 규칙이 켜진 것으로 본다(`isActive`). [되돌리기]는 원래 메일함으로. 설정은 고르는 대로 `PATCH` — **칸마다 저장은 한 번에 하나**(보내는 동안 고른 값은 끝난 뒤 다르면 보낸다, 끝내 실패하면 서버가 확인한 값으로 — 6단계 필터의 `saving` 과 같은 결) | | 대용량 첨부 (11단계) | 올리기 엔진 `src/composables/useLargeFileUploads.ts` · 쓰기 화면 잇기 `useComposeLargeFiles.ts` · 첨부 칸 `components/mail/ComposeAttachmentGroups.vue` · `ComposeLargeFileItem.vue` · 읽기 `MessageAttachments.vue`(+ `MessageLargeFileItem` · `MessageLargeFileLinkBar` · `LargeFileRevokeDialog`) · 검사 다시 묻기 `useLargeFileScanRefresh.ts` · 위험 알림 `useLargeFileAlerts.ts`(실시간 `large-file`) · 설정 `pages/settings/MailLargeFilesPage.vue` + `components/settings/LargeFile*.vue` · `stores/large-files.store.ts` · API `useLargeFiles.ts` · 공개 화면 `pages/share/SharePage.vue` + `usePublicShare.ts` · `shared/utils/large-file.ts` · `large-file-status.ts` · `kst-date.ts` · `share-links.ts` · `shared/constants/large-file.ts` | 20MB 넘는 파일 · 합계 25MB 를 넘기는 파일 · 메일에 못 담는 형식은 저절로 링크로(묻는 창 없음). **올리기**: 고르자마자 서버에 모두 만들고(임시 저장본에 바로 실림) 조각(16MiB · `Content-Digest`)은 파일 3개까지만 함께, 끊기면 2 · 5 · 15 · 30초로 다시, 1분 넘으면 멈춤 + [다시 시도], 404 는 저절로 다시 올리지 않는다, 새로고침 뒤 같은 파일(이름 · 크기 · 고친 시각 + 맨 앞 조각 SHA-256)을 다시 고르면 잇는다. 다 올릴 때까지 [보내기] · ▾ 는 `aria-disabled` + 까닭(누르면 role=status). **본문**: 대용량 파일이 있을 때만 첫 원문 인용 앞에 `
` 와 `bodyQuoteStart`(텍스트 인용 줄의 처음)를 보낸다 — 서버가 그 자리에 덩이를 넣는다. 다시 연 저장본의 원문 인용은 `mail-extensions.ts` 의 `isMailQuoteBlock`("원본 메일" 머리 문단 + 머리 표)으로 알아본다. **읽기**: 첨부 칸의 대용량 항목은 서버의 `largeFiles` 에서만 오고, 본문 덩이는 서버만 붙일 수 있는 `data-ara-large-files` 요소만 `