chore(docker): 메일 엔진·네트워크 분리·같은 주소 배포와 Node 24 고정 #1

Merged
ttipo merged 52 commits from feat/mail-module into main 2026-10-30 12:50:48 +09:00
3 changed files with 24 additions and 14 deletions
Showing only changes of commit 2d9c6b87d6 - Show all commits
+1
View File
@@ -35,6 +35,7 @@ backend/src/common/errors/error-codes.ts ← 여기만 수정한다
| AUTH_002 | — | **클라이언트 전용** · 세션 만료 | "안전을 위해 로그아웃 되었습니다. 다시 로그인해 주세요." |
| AUTH_003 | 403 | 권한 부족 | "해당 메뉴나 기능에 접근할 수 있는 권한이 없습니다." |
| AUTH_004 | 401 | 로그인 자격 증명 불일치 (계정 없음·비밀번호 틀림을 구분하지 않음) | "이메일 또는 비밀번호가 올바르지 않습니다." |
| AUTH_005 | 400 | 로그인한 채 본인 확인 실패 — 비밀번호 변경의 지금 비밀번호가 틀림. **401 이 아니다** — 클라이언트는 401 이면 세션을 갱신하고 같은 요청을 다시 보낸다 | "지금 비밀번호가 올바르지 않습니다." |
| VAL_001 | 400 / 422 | 입력 검증 실패 | "입력하신 정보를 다시 확인해 주세요. (필수값 누락 또는 형식 오류)" |
| RES_001 | 404 | 리소스 없음 | "요청하신 정보나 페이지를 찾을 수 없습니다." |
| BIZ_001 | 400 | 그 외 비즈니스 규칙 위반 | "요청하신 작업을 완료하지 못했습니다. 다시 시도해 주세요." |
+12 -7
View File
@@ -28,8 +28,9 @@ description: NestJS 백엔드(backend/) 개발 규칙 — 컨트롤러/서비스
| **출처 검사(CSRF)** | `src/common/guards/origin.guard.ts` (APP_GUARD) | 상태 변경 요청은 `Sec-Fetch-Site`(same-origin·none) 또는 허용 목록(`CORS_ORIGIN`)의 `Origin` 이어야 통과, 아니면 `AUTH_003`. 둘 다 없으면 통과. 규칙 `shared/utils/request-origin.ts` |
| **인증 가드** | `src/common/guards/jwt-auth.guard.ts` | `JwtAuthGuard` — 액세스 토큰 쿠키 검증, 실패는 `AUTH_001`. 전략은 `modules/auth/jwt.strategy.ts` |
| 현재 사용자 데코레이터 | `src/common/decorators/current-user.decorator.ts` | `@CurrentUser()` → `AuthenticatedUser { email, name }` |
| 직원 인증 | `modules/auth` | 쿠키 JWT 로그인·`me`·`refresh`·`logout` (계약 `docs/mail/api-contract.md` 1절). 설정 `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`, 첨부 임시 디스크 저장 `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`) (계약 2절) |
| 길이 검사 데코레이터 | `src/common/decorators/code-units.decorator.ts` | `MaxCodeUnits` · `MinCodeUnits` — JS 문자열 길이(UTF-16 코드 단위)로 센다(웹의 `.length` 와 같다). class-validator 의 `MinLength`·`MaxLength` 는 서로게이트 쌍 · 이모지를 덜 세어 클라이언트가 센 길이와 어긋난다 — 클라이언트와 같은 한도를 거는 문자열은 이것을 쓴다 |
| 직원 인증 | `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`, 첨부 임시 디스크 저장 `mail-upload-storage.ts`, 임시 저장 `draft.controller.ts`(엔진 초안 — 새로 만들고 다시 읽은 뒤 옛것을 지운다), 서명 그림 주소 규칙 `html/signature-images.ts`(보낼 때 `cid:` 로), 본문 그림 판정 `html/inline-images.ts`, 받는 사람 추천 `recipient-suggestions.service.ts`(사용자별 10분 캐시) · 순서 `recipient-ranking.ts` · 커서 `recipient-suggestion-cursor.ts`, 검색어 → 엔진 조건 `jmap/search-terms.ts`(한국어 조사 표는 여기 한 곳 — 낱말 안 OR · 낱말끼리 AND, 따옴표 구절), 여러 통 `PATCH /api/messages`(엔진 요청 한 번) · 휴지통에서 영구 삭제 `message-deletion.controller.ts`(휴지통의 메일만, 조건부 지우기) — 본문 `dto/bulk-messages.dto.ts`, 새 메일 확인 `GET /api/mail-state`(`mailbox.controller.ts`), 메일함 관리 · 전체 읽음 · 비우기(`mailbox.controller.ts`, 본문 `dto/mailbox.dto.ts` — 오래 걸리는 처리는 어댑터(`BulkRun`)가 500통씩 · 한 요청 25초 안에서(엔진 호출마다 남은 시간만큼) 하고 남은 수를 돌려준다, 같은 직원 · 같은 메일함은 한 번에 하나(`mail.service.ts` 의 `mailboxOperations` — 진행 중이면 `REQ_001`), 지우기는 안의 메일을 휴지통으로 옮긴 뒤) · 부재중 자동 응답 `MailVacationController`(`dto/mail-vacation.dto.ts`) · 원문 `GET /api/messages/{id}/source`(이름 `attachment.policy.ts` 의 `messageSourceFileName`) · 스팸 표시 `isJunk` (계약 2절) |
| 메일 서명 | `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) 권한은 쓰지 않는다(일부러 막아 두었다) |
| 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 로 묶음, 로그인 실패 카운터와 같은 함수) |
@@ -226,7 +227,8 @@ SwaggerModule.setup('api-docs', app, document);
> Redis 가 실제로 필요해지는 시점(다중 인스턴스 운영, 세션·캐시 공유, 분산 락)에 연결한다.
> ⚠️ 인스턴스를 늘리면 **프로세스 메모리에 둔 한도·잠금**도 함께 옮겨야 한다 — 그대로 두면 인스턴스마다 따로 센다:
> 로그인 실패 카운터(`modules/auth/fixed-window-counter.ts` → `INCR`+`PEXPIRE` 를 한 Lua 스크립트로),
> 같은 주소의 진행 중 로그인·같은 `Idempotency-Key` 의 진행 중 발송(`SET NX PX` 잠금), `@nestjs/throttler` 저장소,
> 같은 주소의 진행 중 로그인·비밀번호 변경·같은 `Idempotency-Key` 의 진행 중 발송(`SET NX PX` 잠금), `@nestjs/throttler` 저장소,
> 같은 직원 · 같은 메일함의 오래 걸리는 처리(`mail.service.ts` 의 `mailboxOperations` — 그대로면 두 인스턴스가 같은 휴지통을 함께 비우며 서로 어긋난다 → 잠금),
> 사용자별 서명 그림 올리기 차례(`mail-signature.service.ts` 의 `uploadQueues` — 그대로면 동시 업로드가 보관 상한을 넘을 수 있다 → 잠금),
> 받는 사람 추천 캐시(`recipient-suggestions.service.ts` — 틀리지는 않지만 보낸 직후 다른 인스턴스가 10분까지 옛 목록을 준다 → 공유 캐시나 무효화 알림):
> 0. 컨테이너 활성화 — 루트 `.env` 에 `COMPOSE_PROFILES=redis` ([[docker]])
@@ -239,7 +241,7 @@ SwaggerModule.setup('api-docs', app, document);
- **Rate Limiting**: `@nestjs/throttler` 로 일원화 (`app.module.ts` 의 `APP_GUARD` = `ClientIpThrottlerGuard`). 초과 시 429 → `REQ_001`.
IP 를 세는 모든 곳은 `clientIpKey()` 를 쓴다 — IPv6 는 주소를 계속 바꿀 수 있어 원문 그대로 세면 IP 한도가 무력해진다.
라우트 단위 조정은 `@Throttle({ default: { ttl, limit } })`, 제외는 `@SkipThrottle()` (헬스체크에 적용되어 있다).
로그인 무차별 대입은 **실패 횟수**로 막는다 — `modules/auth/login-attempt.service.ts`.
로그인 무차별 대입은 **실패 횟수**로 막는다 — `modules/auth/login-attempt.service.ts` (비밀번호 변경의 지금 비밀번호 틀림도 같은 카운터로 센다).
메일 엔진에 묻기 **전에** 실패 한 번을 IP·주소에 원자적으로 차지하고(동시 요청으로 한도를 넘지 못하게), 같은 주소의 진행 중 시도는 기다리지 않고 `REQ_001`.
성공·엔진 장애는 차지한 자리를 돌려준다(성공은 세지 않는다). 요청 수 상한은 그와 별개로 넉넉하게 — 로그인 라우트의 `@Throttle`(IP 당 1분 60회, 사무실 NAT 고려).
**rate limiter 를 중복으로 추가하지 말 것** ([[security]])
@@ -361,9 +363,12 @@ dc exec backend npm uninstall <패키지>
중첩 깊이의 제곱으로 느려져 2MB 한 통에 수 분 동안 이벤트 루프를 멈춘다 (2026-10-27 리뷰). 정규식·변환도 입력 길이에 비례하게 둔다.
htmlparser2 판을 올리면 `html-parse-budget.spec.ts` 의 Parser 비교 테스트가 어긋남을 잡는다 — 실패하면 바뀐 Parser 규칙을 옮긴다.
**엔진의 `Email/set destroy` 는 메일함과 상관없이 영구 삭제다** — 사용자 메일을 영구 삭제하는 경로(임시 저장 교체의 옛 초안 · 보낸 뒤 `draftId` ·
휴지통에서 영구 삭제)는 모두 `jmap-mail.provider.ts` 의 조건부 지우기를 쓴다: 지울 대상인지 확인(임시보관함에만 있는 사용자 초안 / 휴지통에만 있는 메일)
→ 확인한 상태 그대로일 때만 `ifInState` 로 지우기 → 어긋나면 다시 확인(합계 `CONDITIONAL_DESTROY_ATTEMPTS`). 확인과 지우기 사이에
휴지통에서 영구 삭제 · 4단계의 휴지통 · 스팸메일함 비우기)는 모두 `jmap-mail.provider.ts` 의 조건부 지우기를 쓴다: 지울 대상인지 확인(임시보관함에만 있는 사용자 초안 /
휴지통(비우기는 그 메일함)에만 있는 메일 — 다른 메일함에도 있으면 그 메일함에서만 뺀다) → 확인한 상태 그대로일 때만 `ifInState` 로 지우기 → 어긋나면 다시 확인
(한 통 · 여러 통은 합계 `CONDITIONAL_DESTROY_ATTEMPTS`, 비우기는 500통 차례마다 이어서 그만큼). 확인과 지우기 사이에
다른 탭이 옮기거나 되돌린 메일까지 지우지 않게 하기 위해서다 (2026-10-27, Stalwart 0.16.27 이 `stateMismatch` 로 거절하는 것을 확인).
조건 없는 destroy 는 그 요청이 방금 만든 메일(실패한 저장의 되돌림)에만 쓴다.
`conditionalEmailSet` 은 `ifInState` 를 필수로 받는다 — 조건 없는 destroy 는 그 요청이 방금 만든 메일(실패한 저장의 되돌림)에만, 따로 쓴다.
같은 요청에서 만들기와 지우기를 함께 하지 않는다 — 엔진은 만들기가 실패해도 지우기를 한다.
**메일함 지우기(`Mailbox/set destroy`)는 `onDestroyRemoveEmails: false` 만 쓴다** — `true` 는 안의 메일을 영구 삭제한다. 안의 메일은 먼저 휴지통으로 옮기고,
그사이 들어온 메일이 있으면 엔진이 `mailboxHasEmail` 로 거절하므로 다시 옮긴다 (2026-10-28 확인).
- [ ] **env**: DB/Redis/CORS/포트는 루트 `.env` 에서 주입된다. 백엔드 고유 시크릿(`JWT_SECRET` 등)만 `backend/.env.{env}` 에 작성(compose `env_file` 로 로드).
+11 -7
View File
@@ -24,20 +24,20 @@ description: Vue 3 프론트엔드(frontend/) 개발 규칙 — Composition API
| 요청 단위 알림 끄기 | `useApi.ts` 의 `silent` 요청 옵션 | `true` 또는 코드 배열 (`api.get(url, { silent: ['AUTH_001'] })`). 세션 만료 대화상자는 끄지 않는다. 끈 코드를 나중에 알림으로 알려야 하면(그 칸이 사라진 뒤 등) `notifyApiError()` — 같은 `showErrorUI()` 를 거친다 |
| 페이지네이션 타입 | `src/shared/types/pagination.ts` | 목록 응답 공유 계약 ([[response-format]]) |
| 도메인 Composable 예제 | `src/composables/useUser.ts` · `useMail.ts` | 새 도메인 API 패턴 참고 (`useMail` 이 실제 모듈 예) |
| 인증 Composable | `src/composables/useAuth.ts` | 로그인·로그아웃·`restoreSession()`(me 가 AUTH_001 이면 refresh 1회, 알림 없음) — 쿠키 기반 |
| 인증 Composable | `src/composables/useAuth.ts` | 로그인·로그아웃·`restoreSession()`(me 가 AUTH_001 이면 refresh 1회, 알림 없음) — 쿠키 기반. 비밀번호 바꾸기 `changePassword()`(4단계 — 한도 30초 `PASSWORD_CHANGE_TIMEOUT_MS`, 지금 비밀번호 틀림은 400 `AUTH_005` 라 갱신 · 재시도를 타지 않는다) |
| Pinia 스토어 예제 | `src/stores/user.store.ts` · `mail.store.ts` | 새 스토어 패턴 참고 (`mail.store` 는 늦게 온 응답 무시 패턴 포함) |
| 인증 스토어 | `src/stores/auth.store.ts` | 로그인 사용자(email·name) · 세션 복원(`restore`) · 만료 상태(`expireSession`) · 로그아웃 상태(`clearAuth()` 가 켜는 `signedOut` — 다시 로그인할 때까지 늦게 온 갱신·복원 결과를 쓰지 않는다) |
| 공통 컴포넌트 예제 | `src/components/BaseButton.vue` | 재사용 컴포넌트 기준 패턴(타입 Props/Emits·scoped·rem·a11y) |
| 라우터 | `src/router/index.ts` | lazy import + 첫 네비게이션 전 세션 복원 + `requiresAuth` 가드(미인증 → `{ name: 'login', query: { redirect } }`) |
| 라우터 | `src/router/index.ts` · 라우트 표 `src/router/routes.ts`(라우터와 라우트 테스트가 함께 쓴다) | lazy import + 첫 네비게이션 전 세션 복원 + `requiresAuth` 가드(미인증 → `{ name: 'login', query: { redirect } }`) |
| 전역 셸 | `src/components/shell/AppShell.vue` | 앱 레일 + 상단 바 + 모듈 영역. 레일 모듈 목록은 `src/shared/constants/app-modules.ts` 하나 |
| 디자인 토큰·글꼴 | `src/styles/tokens.css` · `src/styles/base.css` | 색·간격·글자 크기 변수. 글꼴은 npm `pretendard` 자체 호스팅(`main.ts` 에서 import — CSP `font-src 'self'`) |
| 브랜드 자산 | `src/assets/brand/` · `src/shared/constants/brand.ts` | 흰 로고(전체·ARA 마크). 원본은 `docs/brand/` |
| 토스트 | `src/stores/toast.store.ts` · `src/components/feedback/ToastHost.vue` | 오류는 `useApi.ts` 의 `showErrorUI()`, 성공 안내는 `composables/useToast.ts`. 알림 안의 단추는 `{ label, run }`(예: 임시 저장본 [되돌리기]), 이동 링크는 지금처럼. 단추가 있는 알림은 10초(`TOAST_ACTION_DURATION_MS`), 마우스 · 초점이 있는 동안은 `hold`·`release` 로 멈춘다. **단추는 띄운 세션에서만 움직인다** — `useToast` 가 `authStore.generation` 에 묶고, 로그아웃 · 세션 만료 · 다른 계정 로그인 때 단추가 있는 알림을 닫는다(메일 id 는 계정마다 겹칠 수 있다) |
| 모달 대화상자 | `src/components/feedback/ModalDialog.vue` | 네이티브 `<dialog>`(초점·Esc·닫힘 처리)와 모양. 세션 만료(`SessionExpiredDialog`)·나가기 확인(공용 `components/feedback/LeaveConfirmDialog.vue` — 쓰기 화면 `ComposeLeaveDialog.vue`·서명 설정이 쓴다)이 쓴다 — 새 대화상자도 이것으로 만든다 |
| 토스트 | `src/stores/toast.store.ts` · `src/components/feedback/ToastHost.vue` | 오류는 `useApi.ts` 의 `showErrorUI()`, 성공 안내는 `composables/useToast.ts`. 알림 안의 단추는 `{ label, run }`(예: 임시 저장본 [되돌리기]), 이동 링크는 지금처럼. 단추가 있는 알림은 10초(`TOAST_ACTION_DURATION_MS`), 마우스 · 초점이 있는 동안은 `hold`·`release` 로 멈춘다. **단추는 띄운 세션에서만 움직인다** — `useToast` 가 `authStore.generation` 에 묶고, 로그아웃 · 세션 만료 · 다른 계정 로그인 때 단추가 있는 알림을 닫는다(메일 id 는 계정마다 겹칠 수 있다). 오래 걸리는 처리는 **진행 알림** `useToast().progress(글)` — 저절로 닫히지 않고(넘쳐도 다른 알림부터 닫는다) 글을 고치다가(`update`) 끝나면 그 자리에서 결과가 된다(`succeed` · `fail` — `toast.store` 의 `update`). 진행 알림도 세션이 바뀌면 닫는다 |
| 모달 대화상자 | `src/components/feedback/ModalDialog.vue` | 네이티브 `<dialog>`(초점·Esc·닫힘 처리)와 모양. 세션 만료(`SessionExpiredDialog`)·나가기 확인(공용 `components/feedback/LeaveConfirmDialog.vue` — 쓰기 화면 `ComposeLeaveDialog.vue`·서명 설정 · 부재중 설정이 쓴다) · 메일함 대화상자(`components/mail/Mailbox*Dialog.vue` — 기본 슬롯에 이름 칸)가 쓴다 — 새 대화상자도 이것으로 만든다. 되돌릴 수 없는 일의 확인은 처음 초점을 「취소」에 둔다 |
| 외부 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` (주석에 "밀도 예외") | 클릭 영역 44px 기준의 의도된 예외(목록 표의 별·보기 전환, 설정 화면의 미리 보기 전환 36(`.segmented`)·서명 그림 크기 줄 36(작게·보통·원본·그림 빼기), 편집기 툴바 버튼 40×44·메뉴 항목 40·색 견본 40·표 고르기 칸 22, 3단계의 목록 표 고르기 칸 32·분할 행 고르기 칸 폭 36·검색 칸 ✕ 40×38·새 메일 알림 줄 40·검색 범위 전환 36·이동 메뉴 항목 40)에만 쓴다 — 사용자 확정(캔버스). 그 밖의 `--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` (주석에 "밀도 예외") | 클릭 영역 44px 기준의 의도된 예외(목록 표의 별·보기 전환, 설정 화면의 미리 보기 전환 36(`.segmented`)·서명 그림 크기 줄 36(작게·보통·원본·그림 빼기), 편집기 툴바 버튼 40×44·메뉴 항목 40·색 견본 40·표 고르기 칸 22, 3단계의 목록 표 고르기 칸 32·분할 행 고르기 칸 폭 36·검색 칸 ✕ 40×38·새 메일 알림 줄 40·검색 범위 전환 36·이동 메뉴 항목 40, 4단계의 메일함 행 ⋯ 32×40 · ⋯ 메뉴 항목 40)에만 쓴다 — 사용자 확정(캔버스). 그 밖의 `--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절 "발송 본문") |
@@ -45,15 +45,19 @@ description: Vue 3 프론트엔드(frontend/) 개발 규칙 — Composition API
| 메일 본문 편집기 | `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`) |
| 임시 저장 | `src/composables/useDraftSaving.ts` · `components/mail/ComposeDraftStatus.vue` · `composables/useComposeDraftLink.ts`(주소 맞추기 · 옛 id 기록 · 보내지 못한 메일을 저장본으로) · `composables/useDraftTrash.ts`(휴지통 · 되돌리기) | 3초 멈춤 · 쉬지 않고 쓰면 30초 · 10초 간격 · 한 번에 하나(계약 2절 "임시 저장", 상수 `shared/constants/mail.ts` 의 `DRAFT_*`). `MAIL_008`·PUT 404 는 막지 않고 POST 로 새로. 찾지 못한 첨부가 남았거나 본문이 한도를 넘는 동안은 저장하지 않고, 실을 수 없는 첨부가 든 남의 저장본은 첫 저장을 POST 로(원본 보존). 보내기·버리기 전에는 `suspend`+`settle` — 보내기는 settle 뒤 첨부 상태를 다시 본다. 저장할 때마다 주소를 `?draft=<새 id>` 로 맞추고(떠나기 전 저장 제외), 옛 id 로 돌아오면 `mail.store` 의 `latestDraftId()` 로 마지막 저장본을 연다. 쓰기 화면 휴지통은 DELETE 가 아니라 PATCH(휴지통으로 이동) — 이미 휴지통인 메일은 DELETE 가 영구 삭제한다 |
| 받는 사람 자동 완성 | `src/composables/useRecipientSuggestions.ts` · `shared/utils/recipient-match.ts` · `components/mail/RecipientField.vue` · `RecipientSuggestionList.vue` | 검색어는 input · compositionupdate 의 `target.value`(한글 조합 중 글자 포함)로 따로 두고, 칩 확정은 v-model. Enter 등의 조합 판정은 `shared/utils/keyboard-shortcut.ts` 의 `isImeComposing()`(`isComposing` 또는 keyCode 229 — Safari · macOS) — 새 입력 칸의 Enter 처리도 이것을 쓴다. 쓰기 화면을 열 때 `limit=100` 한 번, 모자랄 때만 250ms 뒤 `q` 로(초성·자모만 친 글자는 묻지 않는다, 늦은 응답은 버린다). 초성 검색·맞은 글자 강조는 `recipient-match.ts`(강조는 `<strong>` 조각 — v-html 없음). combobox 접근성(aria-activedescendant · listbox · 조합 중 Enter 무시), 완전한 주소면 미리 고르지 않는다 |
| 메일 서명 | `src/stores/mail-signature.store.ts` · `components/mail/editor/signature/` · `shared/utils/mail-signature.ts` · `pages/settings/` | 서명은 메일 화면에 들어올 때 받아 두고 쓰기 화면이 넣는다(새 메일은 끝, 답장·전달은 인용 위 — 이어 쓰기는 다시 넣지 않는다). 설정 화면(`SettingsLayout.vue` 왼쪽 메뉴 + `MailSignaturePage.vue`)은 들어올 때마다 새로 받는다. 미리 보기는 편집기 스키마의 `DOMSerializer` 로 그린다. 그림 3장은 서명 칸 안의 그림 수로(서버 보관 상한 10장은 서버가 센다) |
| 메일 스토어 구성 | `src/stores/mail.store.ts`(조립 · 공개 API) + `src/stores/mail/` — `mailboxes.ts` · `list.ts` · `selection.ts` · `message.ts` · `bulk.ts` | Pinia 스토어는 `useMailStore` 하나이고 기능별 setup 모듈을 조립한다(스토어를 여러 개로 나누지 않는다 — 서로 부르는 고리가 생긴다). 목록 출처는 `shared/utils/mail-list-source.ts`(메일함 · 검색). **늦게 온 응답 버리기**: 목록 맞추기는 이 탭의 목록 변경 세대(`localGeneration` — 읽음 · 중요 · 빼기 · 옮기기마다 오른다)가 그사이 바뀌었으면 응답을 버리고 한 번 더 맞춘다. 여러 통 처리 · 한 통 삭제는 시작할 때의 출처 순번(`sourceSeq`)과 다르면 목록을 건드리지 않고 개수만 맞춘다 |
| 메일 서명 | `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절 "새 메일 확인" |
| 메일 검색 | `src/components/mail/MailSearchBox.vue`(상단 바의 search 슬롯) · `composables/useMailSearchNavigation.ts` · `MailSearchHeader.vue` · `MailSearchScope.vue` · `MailSearchEmpty.vue` · 라우트 `/mail/search` · `/mail/search/:messageId` | Enter 로만 찾는다(`isImeComposing()` 이면 무시), ✕ · Esc 는 검색을 시작한 메일함으로. 주소 `?q=…&in=<메일함 id \| all>` — 검색 화면 안의 다시 찾기 · 범위 바꾸기는 replace. 서버와 같은 한도(200자 · 낱말 8개)를 먼저 본다. 조사 처리 · 한계는 서버 몫(계약 2절 "검색") |
| 여러 통 처리 · 삭제 | `src/components/mail/MailCheckbox.vue` · `MailSelectionToolbar.vue`(+ `MailBulkButton` · `MailBulkMoveButton`) · `MailPurgeDialog.vue` · `MessageDeleteButton.vue` · `composables/useBulkMailActions.ts` · `useMessageRemoval.ts` · `useMailMoveFeedback.ts` · `shared/utils/mail-selection.ts` · `mail-bulk.ts` | 100통씩 차례로. 읽음 · 중요는 먼저 바꾸고 `failedIds` 만 되돌리고, 이동 · 삭제는 응답의 id 만 뺀다. **웹은 `DELETE /api/messages/{id}` 를 쓰지 않는다** — 휴지통 밖 삭제는 `PATCH` 이동(+ 되돌리기), 휴지통에서는 확인 대화상자(처음 초점 「취소」) 뒤 `POST /message-deletions`. 읽기 화면도 그 메일의 `mailboxIds` 로 같은 규칙을 탄다. 도구 줄은 role="toolbar" 로빙 tabindex(바쁜 · 꺼진 단추는 건너뛴다) — 계약 2절 "여러 통 한꺼번에" |
| 설정 › 비밀번호 · 부재중 (4단계) | `src/pages/settings/AccountPasswordPage.vue` · `MailVacationPage.vue` · `components/settings/`(`SettingsPageFrame` 공용 틀 · `PasswordChangeForm` · `PasswordField` · `Vacation*`) · `stores/mail-vacation.store.ts` · `shared/utils/password-change.ts` · `mail-vacation.ts` · 메뉴 `shared/constants/settings-menu.ts` | 메뉴는 묶음(계정 › 비밀번호 / 메일 › 서명 · 부재중 자동 응답), 라우트 이름은 `SETTINGS_ROUTES` 하나. 비밀번호는 서버와 같은 규칙을 먼저 보고, 서버 거절(`AUTH_005` · `VAL_001`)은 칸 아래에만(알림 없음) — 성공하면 세 칸을 비운다. 부재중은 날짜만 고르고 브라우저 시간대의 시작일 0시 · 끝나는 날 다음 날 0시를 UTC 로 보낸다(변환은 `mail-vacation.ts` 한 곳 — 되읽을 때는 1ms 앞 날짜). 켤 때만 검사하고 꺼 둔 채로는 빈 값도 저장한다. 부재중 상태는 메일 화면에 들어올 때 받고, 패널 안내 `components/mail/MailVacationNotice.vue` 가 1분마다 다시 계산한다(`useClock` · `VACATION_NOTICE_REFRESH_MS`). 저장 응답은 세션 세대(`authStore.generation`)가 바뀌었으면 쓰지 않는다 — 계약 1절 · 2절 "부재중 자동 응답" |
| 메일함 관리 · 오래 걸리는 처리 (4단계) | `src/components/mail/MailboxTree.vue` · `MailboxPanelRow.vue` · `MailboxMoreButton.vue`(⋯ 메뉴) · `MailboxDialogs.vue`(+ `Mailbox*Dialog.vue`) · `MailboxEmptyButton.vue`(목록 머리 「비우기」) · `composables/useMailboxActions.ts` · `stores/mail/mailbox-admin.ts` · `shared/utils/mailbox-operation.ts` · 상수 `shared/constants/mailbox-manage.ts` | 만들기 · 이름 바꾸기는 칸 안 실패(`MAIL_009` · `VAL_001`)를 알림 없이 칸 아래에. 모두 읽음 · 비우기 · 지우기는 남은 수가 0(지우기는 `deleted: true`)이 될 때까지 다시 보내며 진행 알림을 고친다 — 진행은 응답의 `processed`, 세션이 바뀌면 멈춘다(`epoch` — 늦게 온 만들기 · 이름 바꾸기 응답도 버린다), 같은 메일함의 같은 처리는 하나, **휴지통 비우기와 메일함 지우기는 함께 돌리지 않는다**(비우기는 그사이 휴지통에 들어온 메일도 지운다 — 판정은 스토어의 `isMailboxOperationBlocked` 하나, 메뉴 · 단추 · 대화상자 확인이 모두 이것을 본다). 끝난 뒤 누른 단추가 사라져 초점을 잃었으면 패널의 지금 메일함 항목으로 옮긴다. ⋯ 메뉴는 보이는 영역 안에 다 들게 연다(`shared/utils/popup-placement.ts` — 아래가 모자라면 위로). 되돌릴 수 없는 일은 확인 대화상자(처음 초점 「취소」) 뒤에, 확인한 그 메일함만 — 계약 2절 "메일함 관리" |
| 스팸 · 원문 (4단계) | `src/components/mail/MessageSpamButton.vue`(읽기 화면) · 도구 줄의 스팸 단추(`MailSelectionToolbar.vue`) · `MessageMoreMenu.vue`(⋯ › 원문 내려받기) | 스팸 신고 · 스팸 아님은 이동과 `isJunk` 를 한 요청에 보내고(`mailStore.moveMessages(…, { isJunk })`), [되돌리기]는 원래 메일함으로 반대 값을 보낸다. 원문(.eml)은 `useMail().downloadMessageSource()` — 첨부처럼 `confirmSession()` 을 먼저 거친다 — 계약 2절 "스팸 · 원문" |
| 시계 주입 | `src/composables/useClock.ts` · `src/test-utils/manual-clock.ts` | 타이머를 쓰는 로직(자동 저장 등)은 시계를 주입받는다 — 테스트는 손으로 돌리는 시계로 시간을 넘긴다(고정 대기 없이, [[testing]]) |
| 나가기 전 확인 | `src/composables/useLeaveConfirm.ts` · `src/shared/utils/compose-changes.ts` | 화면을 연 뒤(또는 마지막으로 저장한 뒤) 바꾼 것이 있으면 앱 안 이동(라우트 떠남·바뀜)은 확인 대화상자, 탭 닫기·새로고침은 `beforeunload`. 쓰기 화면은 떠나기 전에 먼저 저장하고 저장하지 못한 것이 있을 때만 묻는다(서명 설정은 저장하지 않은 서명이 있으면 묻는다). 가드가 도는 중은 `guarding`. 바꿈 여부는 연 직후(저장한 뒤에는 마지막으로 저장한 판)의 스냅숏과 비교한다(편집기는 `isChanged()`). 떠나기 전 저장은 5초까지만 기다린다. 보낸 뒤·보내는 중·세션이 끝났을 때는 묻지 않는다 — 보내는 중에 떠나면 결과는 토스트로만 알리고 떠난 양식·새 양식을 건드리지 않는다(양식 세대로 늦게 온 결과를 버린다, 디자인 README "나가기 전 확인") |
| 읽기 화면 본문 안내 | `src/shared/utils/mail-body-notices.ts` · `shared/constants/mail.ts` 의 `MAIL_BODY_NOTICES` | 서버의 `isHtmlBodyOmitted`·`isBodyTruncated`(계약 2절 "읽기 한도")를 외부 이미지 차단과 같은 안내 줄로 보인다(`.reader__notice[data-notice]`) |
| 쓰기 칸 포커스 | `src/styles/base.css` 의 `.compose-field` | 글자 입력 행(받는 사람·참조·숨은참조·제목·본문)은 아래 선 하나로 포커스를 보인다. 새 입력 행에도 이 클래스를 붙인다 — 칸마다 포커스 규칙을 따로 만들지 않는다 |
| 테두리 입력칸 | `src/styles/base.css` 의 `.form-input` · `.form-input__control` · `.form-message` | 설정 화면 · 메일함 이름 대화상자의 테두리 있는 칸(44)과 칸 아래 안내 · 오류(12.5px). 칸 안에 단추가 있는 상자(비밀번호 보기)는 상자에 `.form-input`, 입력 요소에 `.form-input__control` — 입력 요소에 초점이 있을 때만 상자에 초점을 보인다. 오류는 입력 요소의 `aria-invalid="true"`. 쓰기 화면의 밑줄 칸(`.compose-field`)과는 다른 규칙이다 |
| env 타입 선언 | `frontend/env.d.ts` | `VITE_API_BASE_URL` |
| dev API 프록시 | `vite.config.ts` 의 `server.proxy` | `/api` → 백엔드. 없으면 vite 가 SPA 폴백으로 **HTML 을 200 으로** 돌려줘 조용히 실패한다 |
| dev 서버 포트 | `vite.config.ts` 의 `loadEnv` | 루트 `.env.{mode}` 의 `FRONTEND_PORT`/`BACKEND_PORT` 를 읽는다. `strictPort` 로 포트 충돌 시 fail-fast ([[env-structure]]) |