Compare commits

...

5 Commits

Author SHA1 Message Date
ttipo 3c9b3727c6 docs: 저장소 소개를 ARA SYSTEM 의 지금 구성에 맞게 고치고 로드맵을 더한다
CI / Backend (lint · type-check · test · e2e · build) (pull_request) Successful in 13m49s
CI / Frontend (lint · type-check · test · build) (pull_request) Successful in 13m45s
CI / Infra (compose · Dockerfile · nginx · 운영 스크립트) (pull_request) Successful in 1m55s
CI / 에러 코드 SSOT 동기화 검사 (pull_request) Successful in 11m12s
루트 README · CLAUDE.md · 점검 목록이 아직 "풀스택 모노레포 템플릿"으로 소개하고 있었다 — 사내 그룹웨어(메일 운영 중)에 맞게
지금 상태 · 구성(메일 엔진 · 리졸버 · ClamAV · 운영 스크립트 포함) · 문서 지도 · 디렉터리 구조로 다시 쓰고,
백엔드를 호스트에서 npm ci 하라던 옛 안내(컨테이너 안에서만 — 규칙과 반대)를 뺐다.
docs/roadmap.md 에 메일 1~16단계 상태(16단계 첫 항목: 주소록 가져오기 · 내보내기) · 공통 기반 · 2부 · 앱 · 추후 후보 모음을 둔다.
backend · frontend README 는 프레임워크가 만든 영어 안내 대신 이 저장소의 실행 · 검사 · 참고 문서로,
규칙 · 스킬 · 역할 문서의 "템플릿" 표현("템플릿 기본 제공 골격" 등)은 "기본 골격"으로 바꿨다(nginx 설정 템플릿은 그대로).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 20:19:20 +09:00
ttipo 7f39aa4eb2 docs: 메일 15단계(메일 · 메일함 다루기)를 계약 · 디자인 README · 스킬에 적는다
계약 2절에 메일함 옮기기 · 하위까지 지우기 · 받은편지함 정리 · 지금 보내기(결과를 모르는 실패와 알려진 한계까지),
디자인 README 에 15단계 절과 구현하며 정한 보완, nestjs · vue3 스킬 표에 새 파일과 잠금(여러 대로 늘릴 때 옮길 것).
14단계 "미룬 것"에서 15단계에 한 것(대화째 끌기 · 모두 펼치기 · 줄 머리 답장)을 뺐다.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 18:38:11 +09:00
ttipo d02a983d57 feat(frontend): 읽기 화면 이동 · 끌어다 놓기 · 메일함 옮기기 · 차단할 때 받은편지함 정리 · 예약 메일 지금 보내기
- 읽기 화면 [이동 ▾](옮긴 뒤 다음 메일 · [되돌리기]), 도구 줄은 읽기 칸 폭(컨테이너 쿼리)으로 아이콘만
- [이동 ▾] 메뉴 들여쓰기 · 그 메일이 든 메일함에 하위가 있으면 흐린 줄 · 넘치면 좌우 바꾸기(EditorPopup 메뉴 모두),
  같은 이름이 다른 곳에도 있을 때만 알림에 "상위 › 이름"
- 목록 → 메일함 패널 끌어다 놓기(고른 것 · 대화 줄, 휴지통은 삭제), 메일 화면에 놓은 파일은 무시
- 스팸메일함으로 옮기는 세 길 같게 — 메뉴 · 끌기도 스팸 표시를 붙이고, 꺼내면 스팸 아님
- 메일함 ⋯ 옮기기 창(같은 이름이 있는 곳은 처음부터 막음) · 하위까지 지우기 확인 창
- 차단 창에 받은편지함 정리 칸(같은 탭의 필터 적용 · 스팸메일함 비우기와 함께 돌지 않음), 진행 알림 하나
- 예약 안내 줄 [지금 보내기] — 결과를 모르는 실패는 그 메일을 다시 받아 가르고 확인하지 못한 일은 단정하지 않는다
- 상단 바 좁은 폭에서 [로그아웃]이 꺾이지 않게, 같은 대화 [모두 펼치기] · 펼친 줄 머리 [답장]

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 18:37:55 +09:00
ttipo 1ca210dec4 feat(backend): 메일함 옮기기 · 하위까지 지우기 · 차단할 때 받은편지함 정리 · 예약 메일 지금 보내기
- PATCH /api/mailboxes/{id} 에 parentId — 맨 위(null) · 자기 하위로는 VAL_001 · 새 상위에 같은 이름(대소문자 무시)은 MAIL_009 · 역할 메일함 막기
- DELETE /api/mailboxes/{id}?withChildren=true — 나무를 깊은 것부터 지금 지우기와 같은 차례로(휴지통으로 → destroy)
- POST /api/mail-block-runs — 받은편지함에서 차단한 보낸 사람의 메일을 스팸메일함으로(Sieve 차단 규칙과 같은 판정,
  필터 적용과 같은 cursor · 잠금, 도는 동안 스팸메일함 잠금)
- POST /api/scheduled-messages/{id}/release — 예약 제출을 취소하고 원래 envelope 으로 다시 제출(보내기 취소 설정만큼 붙잡음).
  결과를 모르는 실패면 다시 내지 않고 503 — 같은 요청이 이어 낸다(두 통이 나가지 않게), 엔진이 분명히 거절했을 때만 임시보관함으로

엔진 사실은 개발 엔진(Stalwart 0.16.27)으로 확인했다: parentId 바꾸기 · 고리 거절, destroy 는 적은 차례대로,
from 검색은 비슷한 주소 · 표시 이름의 주소까지 넓게 잡힘, 취소 뒤 다시 제출하면 받는 사람에게 한 통.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 18:02:40 +09:00
ttipo 099faeaafc docs: 메일 15단계(메일 · 메일함 다루기) 보드 셋을 더한다
읽기 화면 [이동 ▾] · 예약 메일 지금 보내기 · 같은 대화 모두 펼치기(Reader15),
목록 끌어다 놓기 · 좁은 폭 상단 바(ListDrag15), 메일함 옮기기 · 하위까지 지우기 ·
차단할 때 받은편지함 정리(Mailbox15). 캔버스 v71 에서 확정 — 글은 화면 상수가 기준이다.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 13:56:00 +09:00
144 changed files with 15587 additions and 994 deletions
+1 -1
View File
@@ -26,7 +26,7 @@ tools: Read, Grep, Glob, Edit, Write, Bash, Skill
## 작업 순서
1. 만들기 전에 `/nestjs` 의 "템플릿 기본 제공 골격"에서 재사용할 패턴을 찾는다. 같은 역할을 새로 만들지 않는다.
1. 만들기 전에 `/nestjs` 의 "기본 골격"에서 재사용할 패턴을 찾는다. 같은 역할을 새로 만들지 않는다.
2. 실패 경로마다 에러 코드를 정한다. 코드 없는 실패는 만들지 않는다 ([[error-codes]]).
3. 목록 API 면 페이지네이션을 적용한다 ([[response-format]]).
4. Swagger 데코레이터를 함께 쓴다 ([[api-design]]).
+6 -6
View File
@@ -1,11 +1,11 @@
# 작업 점검 & 신규 프로젝트 셋업
# 작업 점검 & 새 환경 셋업
> 이 저장소는 **재사용 템플릿**이다. 클론해 어떤 프로젝트든 시작할 수 있도록, 여기에는 **언제나 유효한 공용 규칙**만 둔다(특정 시점의 구현 상태는 두지 않는다).
> 각 스택이 기본 제공하는 골격과 확장 규칙은 [[nestjs]] · [[vue3]] · [[flutter]] · [[docker]] 스킬 참조.
> 여기에는 **언제나 유효한 점검 규칙**만 둔다(특정 시점의 구현 상태는 두지 않는다 — 지금 상태는 각 스킬 · API 계약 · [로드맵](../../docs/roadmap.md)).
> 각 스택에 이미 있는 골격과 확장 규칙은 [[nestjs]] · [[vue3]] · [[flutter]] · [[docker]] 스킬 참조.
---
## 1. 신규 프로젝트 셋업 (클론 직후 1회)
## 1. 새 개발 환경 · 새 서버 셋업 (처음 한 번)
- [ ] 루트 `.env.{env}.example` → `.env.{env}` 복사 후 **placeholder 값 교체** (`change-me-*` 가 남아 있으면 안 된다, [[env-structure]])
운영은 `BACKEND_API_URL`·`CORS_ORIGIN` 을 **실제 도메인**으로 — `localhost`/`example.com` 이 남으면 기동 실패한다
@@ -85,10 +85,10 @@
## 6. 기능 확장 시 따르는 순서
새 기능은 만들기 전에, 해당 스킬의 **"템플릿 기본 제공 골격"** 에서 재사용할 패턴을 먼저 찾는다(같은 역할을 새로 만들지 말 것).
새 기능은 만들기 전에, 해당 스킬의 **"기본 골격"** 에서 재사용할 패턴을 먼저 찾는다(같은 역할을 새로 만들지 말 것).
1. 해당 디렉터리 스킬 규칙 확인 ([[nestjs]] / [[vue3]] / [[flutter]])
2. 기본 제공 골격(인터셉터·필터·에러 레지스트리·dio client·예제 모듈)에서 패턴 재사용
2. 기본 골격(인터셉터·필터·에러 레지스트리·dio client·예제 모듈)에서 패턴 재사용
3. 표준 응답/에러 흐름 준수 ([[response-format]] · [[error-codes]] · [[api-design]])
4. 실패 경로마다 에러 코드 지정 — `BusinessException('CODE')` 로 던진다
5. env 신규 값은 루트 `.env` 부터 ([[env-structure]])
+1 -1
View File
@@ -5,7 +5,7 @@ description: 환경변수 단일 진실 공급원(SSOT) 구조 — 루트 .env.{
# 환경변수 구조 (단일 진실 공급원: 루트 `.env`)
이 템플릿은 **루트 `.env.{APP_ENV}` 가 모든 설정의 단일 진실 공급원(SSOT)** 이다.
이 저장소는 **루트 `.env.{APP_ENV}` 가 모든 설정의 단일 진실 공급원(SSOT)** 이다.
값을 한 곳에서만 관리하기 위해, 서비스(backend/frontend)는 자체 env에 값을 중복 작성하지 않고 **루트 → docker-compose 가 주입한 값을 그대로 사용**한다.
```
+1 -1
View File
@@ -64,7 +64,7 @@
`img onerror` 같은 처리기가 실행된다. CSP 가 막아도 그것에만 기대지 않는다 ([[vue3]] 체크리스트 "외부 HTML")
### 파일 업로드
템플릿은 `modules/upload` 에 로컬 디스크 기준 골격을 제공한다. 새 업로드 엔드포인트는 이 패턴을 복제한다.
`modules/upload` 에 로컬 디스크 기준 골격이 있다. 새 업로드 엔드포인트는 이 패턴을 복제한다.
- **확장자와 MIME 을 둘 다** 검사한다 (`upload.config.ts`). 확장자만 보면 위장이 쉽고,
MIME 만 보면 클라이언트가 보낸 값이라 신뢰할 수 없다.
+1 -1
View File
@@ -5,7 +5,7 @@ description: 세 스택 공통 테스트 규칙 — 테스트 위치/네이밍,
# 테스트 규칙
> 템플릿은 각 스택에 테스트 러너가 준비되어 있다 — backend `jest`, frontend `vitest`, flutter `flutter_test`.
> 각 스택에 테스트 러너가 준비되어 있다 — backend `jest`, frontend `vitest`, flutter `flutter_test`.
> 새 로직을 추가하면 최소한의 테스트를 함께 작성한다.
## 공통 원칙
+3 -3
View File
@@ -10,9 +10,9 @@ description: Flutter 모바일 앱(flutter/) 개발 규칙 — Dart Sound Null S
---
## 템플릿 기본 제공 골격
## 기본 골격
> 이 템플릿은 네트워크 · 세션 · 라우터 · 모듈 틀을 **기본 제공**한다. 새 코드는 이 파일들의 패턴을 재사용한다(같은 역할을 새로 만들지 말 것).
> 네트워크 · 세션 · 라우터 · 모듈 틀이 이미 있다. 새 코드는 이 파일들의 패턴을 재사용한다(같은 역할을 새로 만들지 말 것).
> 앱 이름 "ARA SYSTEM", 식별자 `kr.it.arasystem.app`(Android applicationId · iOS bundle id — 직원 휴대폰에 설치한 뒤에는 못 바꾼다: 식별자가 다르면 다른 앱이라 그 위에 설치되지 않는다), 패키지 `arasystem_app`.
| 제공 항목 | 위치 | 비고 |
@@ -42,7 +42,7 @@ description: Flutter 모바일 앱(flutter/) 개발 규칙 — Dart Sound Null S
| **앱 설정 · 업데이트 화면** | `lib/core/app_config/`(`app_config.dart` — 판 비교 `AppVersion` · 모양, `app_config_repository.dart`, `app_config_controller.dart` — `appUpdateRequiredProvider` · `pushAvailableProvider`) · `core/router/session_redirect.dart`(`updateRequired` 가 세션보다 먼저) · `features/app_update/`(모듈이 아니다 — 화면 · 글) · `core/app_download/`([새 버전 받기] — 받기 화면 주소 `app_download_link.dart` · 런처 `AppDownloadLauncher` · 글, 업데이트 화면과 설정 › 앱 정보가 함께 쓴다) | `GET /api/app-config` 는 띄울 때 한 번 · 앞으로 올 때 마지막으로 받은 지 1시간이 지났으면(실패는 1분 간격으로 다시) — 알리지 않고 막지 않는다. 404 `RES_001` 은 옛 서버(최소 판 없음 · 알림 쓸 수 없음). 판은 숫자 1~3칸 · 칸마다 4자리까지(서버의 판 칸 `APP_VERSION_PATTERN` 과 같다 — `-` · `+` 뒤는 버린다), 이 앱의 판을 모르면 막지 않는다. 업데이트 화면으로 옮길 때 쓰기 화면은 마저 저장한다(`ComposeController.detach(saveRemaining: true)` — 화면이 밖에서 닫힌 때도). 앱은 스토어에 올리지 않고 사내에 직접 나눠 준다(2026-11-08 — 빌드 · 서명 · 올리기는 `flutter/README.md` "사내 배포"). [새 버전 받기]는 포털의 휴대폰 앱 받기 화면 `<API_URL 의 출처>/app`(`appDownloadPageUrlOf` — `/api` 를 뗀 출처 + 경로, 짝: 웹 `mobile-app.ts` 의 `MOBILE_APP_PATH`)을 바깥 브라우저로(`LaunchMode.externalApplication` — `canLaunchUrl` 은 쓰지 않는다) 연다. 열지 못하면 스킴 없는 주소를 담아 알린다(`openAppDownloadPage` — 두 화면이 같은 알림). 앱 설정 계약은 받는 곳 주소를 싣지 않는다 |
| **설정 탭 (`App1Shell` · `App1Push` ④)** | `lib/features/settings/` — `settings_routes.dart`(`/settings` · `/settings/licenses` · 모듈이 내놓은 절) · `presentation/pages/settings_page.dart` · `widgets/settings_notification_row.dart` · `settings_texts.dart` | 한 화면(내 정보 · 알림 · 모듈 절 · 앱 정보(앱 버전 · [새 버전 받기] — 끝 아이콘이 바깥으로 여는 아이콘, 업데이트 화면과 같은 런처 · 오픈소스 라이선스) · 문의 · [로그아웃]). 문의는 로그인 화면과 같은 저장소(`supportContactProvider`), [로그아웃]은 확인 창(`shared/widgets/confirm_dialog.dart` — 처음 초점 [취소]). 스위치 색은 테마의 `switchTheme`(보드 — 켬 청록 · 끔 테두리 · 누를 수 없음 회색) |
| 자리 화면 | `lib/shared/widgets/placeholder_page.dart` | 화면은 캔버스로 확인받은 뒤 만든다 — 그때까지 이름(과 갈 곳)만 보이는 자리 화면. 확정되면 라우트 묶음의 builder 를 바꾼다 |
| 예제 feature(템플릿) | `lib/features/user/` | 목록 페이지네이션(`{items, pageInfo}` — 단건 + 커서 목록) 예제로만 남긴 템플릿. 앱 API 에 `/users` 는 없다 |
| 예제 feature(참조용) | `lib/features/user/` | 목록 페이지네이션(`{items, pageInfo}` — 단건 + 커서 목록) 예제로만 남겼다. 앱 API 에 `/users` 는 없다 |
| 시험 도구 | `test/support/` — `fakes.dart` · `provider_harness.dart` | 가짜 서버(`FakeServer` — HttpClientAdapter) · 메모리 보안 저장소 · 손 시계(`ManualClock`) · 알림 기록(`RecordingNotifier`). 고정 대기 없이 Completer 로 차례를 맞춘다 ([[testing]]) |
> **확장 시 따르는 규칙** (아래 "프로젝트 구조"는 목표 구조 — 기능 추가 시 점진적으로 채운다):
File diff suppressed because one or more lines are too long
+10 -8
View File
@@ -9,9 +9,9 @@ description: Vue 3 프론트엔드(frontend/) 개발 규칙 — Composition API
---
## 템플릿 기본 제공 골격
## 기본 골격
> 이 템플릿은 아래를 **기본 제공**한다. 새 코드는 이 파일들의 패턴을 복제한다(같은 역할을 새로 만들지 말 것).
> 아래가 이미 있다. 새 코드는 이 파일들의 패턴을 복제한다(같은 역할을 새로 만들지 말 것).
| 제공 항목 | 위치 | 비고 |
| --- | --- | --- |
@@ -29,7 +29,7 @@ description: Vue 3 프론트엔드(frontend/) 개발 규칙 — Composition API
| 인증 스토어 | `src/stores/auth.store.ts` | 로그인 사용자(email·name) · 세션 복원(`restore`) · 만료 상태(`expireSession`) · 로그아웃 상태(`clearAuth()` 가 켜는 `signedOut` — 다시 로그인할 때까지 늦게 온 갱신·복원 결과를 쓰지 않는다) |
| 공통 컴포넌트 예제 | `src/components/BaseButton.vue` | 재사용 컴포넌트 기준 패턴(타입 Props/Emits·scoped·rem·a11y) |
| 라우터 | `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/components/shell/AppShell.vue` | 앱 레일 + 상단 바 + 모듈 영역. 레일 모듈 목록은 `src/shared/constants/app-modules.ts` 하나. 상단 바(`AppTopBar.vue` — 15단계): 머리글자 · [로그아웃]은 줄지 않고(nowrap) 이름 · 주소 칸만 줄어 "…"(title "이름 (주소)"), 화면 폭 480(30rem) 이하에서 주소를 감춘다 — 줄 수 · 차례는 그대로 |
| 디자인 토큰·글꼴 | `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단계 필터 적용의 [{메일함} 보기] · [다시 시도], 시간 · 세션 묶기는 다른 단추와 같다). 진행 알림도 세션이 바뀌면 닫는다 |
@@ -37,12 +37,12 @@ description: Vue 3 프론트엔드(frontend/) 개발 규칙 — Composition API
| 외부 HTML 격리 렌더링 | `src/components/mail/MailBodyFrame.vue` · `src/shared/utils/mail-html.ts` | 메일 본문 등 외부 HTML 은 이 컴포넌트로만 그린다 (sandbox·내부 CSP 근거는 파일 머리말). 보여 줄 HTML 고르기는 `displayedHtmlBody()` 하나다 — null 이거나 공백뿐이면 textBody (읽기 화면·인용 공용, 서버의 `isBodyTruncated` 와 같은 규칙) |
| 메일 모듈 | `src/pages/mail/*` · `src/components/mail/*` | API 계약 `docs/mail/api-contract.md`, 디자인 원본 `docs/design/webmail/` |
| 콘텐츠 열 토큰 | `src/styles/tokens.css` 의 `--content-*` · `--content-toolbar-start` | 읽기·쓰기 화면이 같은 최대 폭·여백·32px 시작선을 쓴다 (새 화면도 이 토큰으로) |
| 밀도 예외 크기 | `src/styles/tokens.css` 의 `--size-compact-control` · `--size-segment-item` · `--size-segment-item-lg` · `--size-editor-tool` · `--size-editor-menu-item` · `--size-editor-swatch-cell` · `--size-editor-table-cell` · `--table-col-check` · `--size-row-check` · `--size-search-clear-width`/`-height` · `--size-notice-bar` · `--size-mailbox-more` · `--size-notice-action` · `--size-filter-control` · `--size-filter-remove` (주석에 "밀도 예외") | 클릭 영역 44px 기준의 의도된 예외(목록 표의 별·보기 전환, 설정 화면의 미리 보기 전환 36(`.segmented`)·서명 그림 크기 줄 36(작게·보통·원본·그림 빼기), 편집기 툴바 버튼 40×44·메뉴 항목 40·색 견본 40·표 고르기 칸 22, 3단계의 목록 표 고르기 칸 32·분할 행 고르기 칸 폭 36·검색 칸 ✕ 40×38·새 메일 알림 줄 40·검색 범위 전환 36·이동 메뉴 항목 40, 4단계의 메일함 행 ⋯ 32×40 · ⋯ 메뉴 항목 40, 5단계의 목록 위 알림 권하기 줄 40 · 줄 안의 단추 36, 6단계의 필터 조건 칸 40 · 조건 ✕ 36 · 필터 행 ▲▼ 32×40 · 켜기 스위치 누르는 곳 40×44 · 고치기 창 안 [다시 불러오기] 36, 8단계의 패널 그룹 줄 ⋯ 32×40 · 연락처 창의 줄 · 칸 40 · 줄 ✕ 36×40 · 구성원 넣기 창 전환 36 · 찾기 칸 40 — 새 토큰 없이 위 것을 다시 쓴다, 9단계의 읽기 화면 안내 줄 단추([이미지 보기] · [차단 풀기] · [고치기] · [예약 취소]) · 차단한 주소 줄의 [차단 풀기] 36(`--size-notice-action`) — 역시 새 토큰 없음, 14단계의 검색 칸 [조건] 32(`--size-compact-control`) · 조건 패널의 칸 · 단추 40(`--size-filter-control`) · 조건 줄 40 · 줄 안 [조건 지우기] 36 · 같은 대화 펼친 줄의 [답장] · [이 메일만 열기] 36 · 목록 머리 [대화로 묶기] 36(`--size-notice-action`) — 역시 새 토큰 없음, tokens.css 주석)에만 쓴다 — 사용자 확정(캔버스). 그 밖의 `--size-editor-*`(툴바 높이·글꼴 단추·본문 최소 높이 등)는 예외가 아니라 보통 크기다 |
| 밀도 예외 크기 | `src/styles/tokens.css` 의 `--size-compact-control` · `--size-segment-item` · `--size-segment-item-lg` · `--size-editor-tool` · `--size-editor-menu-item` · `--size-editor-swatch-cell` · `--size-editor-table-cell` · `--table-col-check` · `--size-row-check` · `--size-search-clear-width`/`-height` · `--size-notice-bar` · `--size-mailbox-more` · `--size-notice-action` · `--size-filter-control` · `--size-filter-remove` (주석에 "밀도 예외") | 클릭 영역 44px 기준의 의도된 예외(목록 표의 별·보기 전환, 설정 화면의 미리 보기 전환 36(`.segmented`)·서명 그림 크기 줄 36(작게·보통·원본·그림 빼기), 편집기 툴바 버튼 40×44·메뉴 항목 40·색 견본 40·표 고르기 칸 22, 3단계의 목록 표 고르기 칸 32·분할 행 고르기 칸 폭 36·검색 칸 ✕ 40×38·새 메일 알림 줄 40·검색 범위 전환 36·이동 메뉴 항목 40, 4단계의 메일함 행 ⋯ 32×40 · ⋯ 메뉴 항목 40, 5단계의 목록 위 알림 권하기 줄 40 · 줄 안의 단추 36, 6단계의 필터 조건 칸 40 · 조건 ✕ 36 · 필터 행 ▲▼ 32×40 · 켜기 스위치 누르는 곳 40×44 · 고치기 창 안 [다시 불러오기] 36, 8단계의 패널 그룹 줄 ⋯ 32×40 · 연락처 창의 줄 · 칸 40 · 줄 ✕ 36×40 · 구성원 넣기 창 전환 36 · 찾기 칸 40 — 새 토큰 없이 위 것을 다시 쓴다, 9단계의 읽기 화면 안내 줄 단추([이미지 보기] · [차단 풀기] · [고치기] · [예약 취소]) · 차단한 주소 줄의 [차단 풀기] 36(`--size-notice-action`) — 역시 새 토큰 없음, 14단계의 검색 칸 [조건] 32(`--size-compact-control`) · 조건 패널의 칸 · 단추 40(`--size-filter-control`) · 조건 줄 40 · 줄 안 [조건 지우기] 36 · 같은 대화 펼친 줄의 [답장] · [이 메일만 열기] 36 · 목록 머리 [대화로 묶기] 36(`--size-notice-action`) — 역시 새 토큰 없음, 15단계의 같은 대화 [모두 펼치기] · 펼친 줄 머리 [답장] 36(`--size-notice-action`) — 역시 새 토큰 없음, tokens.css 주석)에만 쓴다 — 사용자 확정(캔버스). 그 밖의 `--size-editor-*`(툴바 높이·글꼴 단추·본문 최소 높이 등)는 예외가 아니라 보통 크기다 |
| 사용자별 화면 설정 | `src/stores/mail-view.store.ts` | localStorage(키에 사용자 email, try/catch, 실패 시 기본값). 서버 설정 API 가 생기면 교체 |
| 화면 이동 규칙 | `src/composables/useMailNavigation.ts` | 이전/다음은 replace, "목록으로"는 뒤로 가기 또는 replace (기록이 쌓이지 않게) |
| 발송 키 규칙 | `src/shared/utils/idempotency-key.ts` 의 `shouldRenewSendKey()` | `Idempotency-Key` 는 성공과 `MAIL_005` 때만 바꾼다 — 그 밖의 실패 뒤 다시 보내기는 같은 키 (계약 2절 "발송 본문"). UUID 만들기는 `shared/utils/uuid.ts` 하나(필터 id 와 공용 — `crypto.randomUUID` 가 없으면 대체 경로) |
| 첨부 사전 검사 | `src/shared/utils/mail-attachment.ts` 의 `precheckAttachment()` | 형식·파일당·합계 한도(`mail.store` 의 `attachmentMaxBytes`·`attachmentTotalMaxBytes`, 서버 값이 없으면 대체값). 넘는 파일은 올리지 않는다 |
| 메일 본문 편집기 | `src/components/mail/editor/` — `MailEditor.vue` (쓰기 화면이 `defineAsyncComponent` 로 불러온다) | TipTap core (MIT 무료 패키지만, `@tiptap/*` 는 모두 같은 버전으로 고정). 툴바 배치·좁은 폭에서 접는 순서·단축키는 `docs/design/webmail/README.md` "메일 쓰기". 보낼 모양은 `mail-serialize.ts` — 서식 본문 `bodyHtml`(기본 글꼴 div, 인라인 style) + 텍스트 대체 본문 `body` (계약 2절 "서식 본문"). 답장·전달 초안 한도는 `fit-draft.ts`(처음 맞출 때 내 글 몫 5%를 남기고, 보내기 직전 `getContentForSend()` 가 다시 재서 인용을 더 줄인다 — 내 글만으로 넘으면 보내지 않고 칸 아래에 알린다), 붙여 넣거나 끌어다 놓은 파일은 첨부로(`shared/utils/editor-files.ts`, 화면 어디에 놓아도 — `composables/useWindowFileDrop.ts`). **읽어 들이는 style 값은 서버 정화와 같은 형식만** 받는다 — 규칙 `shared/utils/outgoing-html-rules.ts`(짝: backend `sanitize-outgoing-html.ts`, 같은 표본 표 `test-utils/outgoing-style-samples.ts` — 한쪽을 고치면 둘 다), 적용 `editor/mail-format-extensions.ts`. 편집기 청크를 못 받으면 `ComposeEditorLoadError.vue`("다시 시도"), 단축키 표기는 플랫폼별(`shared/utils/keyboard-shortcut.ts`). **그림은 서명 그림만** 받는다 — 노드 `editor/signature-image.ts`(쓰기 본문·서명 칸 공용, 규칙은 `outgoing-html-rules.ts` 의 그림 부분 — 짝: backend `html/signature-images.ts`·`keepSignatureImage`, 표본 `IMAGE_SAMPLES`). 메뉴(`editor/EditorMenu.vue` + `EditorPopup.vue` — 이동 ▾ · 메일함 ⋯ · 읽기 화면 ⋯ 도 같은 부품)의 꺼진 항목은 `disabled` 가 아니라 `aria-disabled` — 화살표로 가서 초점은 받되 고를 수 없다(WAI-ARIA 메뉴). 누를 수 없는 까닭은 항목의 `note`(이름 아래 작은 글, `aria-describedby`) |
| 메일 본문 편집기 | `src/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/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`(강조는 `<strong>` 조각 — v-html 없음). combobox 접근성(aria-activedescendant · listbox · 조합 중 Enter 무시), 완전한 주소면 미리 고르지 않는다 |
| 메일 서명 | `src/stores/mail-signature.store.ts` · `components/mail/editor/signature/` · `shared/utils/mail-signature.ts` · `pages/settings/` | 서명은 메일 화면에 들어올 때 받아 두고 쓰기 화면이 넣는다(새 메일은 끝, 답장·전달은 인용 위 — 이어 쓰기는 다시 넣지 않는다). 설정 화면(`SettingsLayout.vue` 왼쪽 메뉴 — 묶음 · 항목은 `shared/constants/settings-menu.ts` + `MailSignaturePage.vue`)은 들어올 때마다 새로 받는다. 미리 보기는 편집기 스키마의 `DOMSerializer` 로 그린다. 그림 3장은 서명 칸 안의 그림 수로(서버 보관 상한 10장은 서버가 센다) |
@@ -53,11 +53,11 @@ description: Vue 3 프론트엔드(frontend/) 개발 규칙 — Composition API
| 실시간 테스트 도구 | `src/test-utils/event-stream.ts`(가짜 SSE 서버) · `notification.ts`(가짜 Notification) · `web-locks.ts`(가짜 `navigator.locks` · BroadcastChannel) | 실시간 · 알림 테스트는 이것들과 손으로 돌리는 시계(`manual-clock.ts`)로 — 진짜 타이머 · 고정 대기 없이 |
| 메일 검색 | `src/components/mail/MailSearchBox.vue`(상단 바의 search 슬롯) · `composables/useMailSearchNavigation.ts` · `MailSearchHeader.vue` · `MailSearchScope.vue` · `MailSearchEmpty.vue` · 라우트 `/mail/search` · `/mail/search/:messageId` | Enter 로만 찾는다(`isImeComposing()` 이면 무시), ✕ · Esc 는 검색을 시작한 메일함으로. 주소 `?q=…&in=<메일함 id \| all>` — 검색 화면 안의 다시 찾기 · 범위 바꾸기는 replace. 서버와 같은 한도(200자 · 낱말 8개)를 먼저 본다. 조사 처리 · 한계는 서버 몫(계약 2절 "검색") |
| 메일 검색 — 14단계 (조건 · 강조 · 준비 중) | `src/components/mail/MailSearchConditions.vue`(조건 패널 — 칸 `MailSearchConditionFields.vue` · 기간 `MailSearchPeriodField.vue` · [조건] `MailSearchConditionsToggle.vue`, 고치는 값 `composables/useSearchConditionsDraft.ts` · 열고 닫기 `useSearchConditionsPopup.ts`) · `MailSearchConditionLine.vue`(조건 줄) · `MailSearchNotices.vue`(준비 중 · 한 글자 안내) · `MailSearchMore.vue`([더 찾기]) · `HighlightedText.vue` · `shared/utils/mail-search.ts`(조건 ↔ 주소 ↔ API · 기간 · 조건 줄 글) · `search-highlight.ts` · `search-match.ts` · `shared/constants/mail-search.ts` | 조건 이름 · 화면 주소(`since` · `until` 은 브라우저 시간대 날짜 · 양 끝 포함, 첨부 `attachment=1`) · 이어 찾기(`MAIL_SEARCH_FILL_MAX_REQUESTS` 5번)는 계약 2절 "검색 — 14단계"의 클라이언트 줄. **`search-match.ts` 는 backend `shared/utils/search-query.ts` · `search-text.ts` · `subject-base.ts` 와 짝** — 같은 표본 표 `test-utils/search-text-samples.ts`(backend `test/search-text-samples.ts` 와 행 · 차례까지 같다, 보이지 않는 글자는 `\u` 로 적는다 — 한쪽을 고치면 둘 다). 맞은 말은 `HighlightedText` 가 글 조각 + `<mark>` 로 그린다(v-html 없음 — 위치가 글 밖 · 거꾸로 · 정수가 아니면 버리고, 서로게이트 쌍을 가르지 않는다). **강조가 든 응답은 보이기만 한다** — `openMessage(id, highlight)` 는 강조 쿼리가 다르면 다시 받고, 답장 · 전달 · 인쇄 · 예약 · 대용량 첨부 다시 받기는 강조 쿼리 없이 받는다. 손으로 고친 주소도 패널과 같은 검사(8낱말 넘으면 묻지 않고 "찾을 말이 너무 많습니다"). 강조 노랑은 `--color-search-mark` — 본문 틀(`mail-html.ts`)의 같은 값과 짝. 미리 보기 줄(회색 글) 안의 강조만 글자를 `--color-text-secondary` 로 진하게(노랑 위 대비 — `MessageListItem.vue`). 패널의 [조건 지우기]는 칸만 비운다 — 패널은 열린 채 · 찾지 않음, 걸린 조건은 [찾기] · 조건 줄의 [조건 지우기]가 뺀다(2026-11-07) |
| 같은 대화 (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<boolean>` — 같은 목록의 `pendingFlags` 에 같은 읽음이 있으면 건너뛴다)로. `closeMessage` 가 칸도 비운다. 초점은 누른 단추가 사라지면 줄의 펼침 단추 → 새 [다시 시도] → 칸 h3(`tabindex="-1"`) → 칸이 없어지면 읽기 화면에 `settled`. 상태 영역은 늘 두고 글만 바꾼다(다시 실패해도 읽게). [이 메일만 열기]는 분할 보기 push · 목록 보기 replace(layout prop). 줄 차례는 `orderHeldLast`(붙잡힌 메일은 끝 — 보낼 시각 차례), 미리 보기는 `previewWithoutOwnQuote`(우리 원문 인용 머리부터 잘라 냄 — `mail-conversation.ts`), 펼친 줄의 첨부 수 안내는 `ConversationMessagePanel` |
| 같은 대화 (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<boolean>` — 같은 목록의 `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절 "여러 통 한꺼번에" |
| 여러 통 처리 · 삭제 | `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/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/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단계" |
| 이미 받은 메일에 필터 적용 (7단계) | `src/components/settings/MailFilterApplyField.vue`(고치기 창의 「받은편지함에 이미 있는 메일도 정리하기」 — 창을 열 때마다 꺼짐) · `composables/useMailFilterRun.ts`(진행 · 결과 알림) · `stores/mail-filter-run.store.ts`(cursor 를 이어 보내기 — 탭마다 하나 `running`) · `shared/utils/mail-filter-run.ts`(할 일 판정 · 보낼 규칙 · 끝 알림의 메일함 · 문구) | 저장이 성공한 뒤에만 `POST /api/mail-filter-runs` 를 `cursor` 가 null 이 될 때까지 다시 보내며 진행 알림을 고친다(4단계 오래 걸리는 처리와 같은 결 — 전체는 확인 + `remaining`, 첫 응답 전에는 받은편지함의 메일 수). 전달은 보내지 않는다. 이미 받은 메일에 할 일이 없는 필터(옮길 메일함이 지워짐 · 받은편지함으로 옮기기만 · 전달만)는 칸을 끄고 시작하지도 않는다 — 판정은 `filterRunIdleReason` 하나(칸 `filterApplyBlock` · 시작 `filterRunRuleOf` 가 함께 쓴다), 이 탭에서 적용이 도는 중에도 칸을 끈다. 요청은 `silent` 로 보내고 실패는 응답이 온 때 세션이 그대로일 때만 스토어가 알린다(`REQ_001` — 다른 창 · PC 에서 적용 중 — 은 화면이 "다른 창에서 …"로). 멈추면 [다시 시도](받은 `cursor` 부터 — 수도 이어 센다), 나아가지 못한 응답이 거듭되면(`MAIL_FILTER_RUN_MAX_STALLS`) 멈춘다. 다른 화면으로 가도 이어 하고, 세션이 바뀌면 다음 요청을 보내지 않고 알림을 닫는다. 끝났거나 나아간 뒤 멈췄으면 개수 · 목록을 맞춘다 — 계약 2절 "이미 받은 메일에 적용", 디자인 README "7단계" |
@@ -70,6 +70,8 @@ description: Vue 3 프론트엔드(frontend/) 개발 규칙 — Composition API
| 계정 보안 (12단계) | 로그인 코드 단계 `src/components/auth/LoginCodeStep.vue` · `composables/useLoginChallenge.ts` · 설정 `pages/settings/AccountTwoFactorPage.vue` · `AccountAppPasswordsPage.vue` · `AccountLoginHistoryPage.vue`(+ 비밀번호 화면의 코드 칸 `PasswordChangeForm`) · `components/settings/`(`OtpCodeField` · `TwoFactorEnableDialog` + `TwoFactorEnrollmentScan` · `TwoFactorDisableDialog` · `AppPassword*` · `MailClientSettings` · `LoginSessionList` · `LoginEventTable` · `EndOtherSessionsDialog`) · QR `components/QrCodeImage.vue`(`uqr` 0.1.3 — SVG `<rect>` v-for, v-html 없음) · API `composables/useAccountSecurity.ts` · 스토어 `two-factor` · `app-passwords` · `login-history` · 상수 `shared/constants/account-security.ts`(상한 5 · 이름 40자 · 10분 · `MAIL_CLIENT_SETTINGS`) | **비밀은 화면 메모리에만** — 코드 단계의 비밀번호는 반응형이 아닌 클로저(`useLoginChallenge` — 성공 · [다른 계정으로 로그인] · 떠날 때 · 10분(타이머와 시계 `startedAt` 둘 다)에 지운다, 화면이 사라진 뒤의 늦은 `twoFactorRequired` 로는 시작하지 않는다), 2단계 키 · otpauth 주소 · 앱 비밀번호 값은 창 안에만 두고 닫을 때 지운다. **비밀이 드는 요청은 Pinia 액션에서 부르지 않는다** — devtools 타임라인이 액션의 인자 · 결과를 기록한다(창이 `useAccountSecurity` 로 보내고 스토어에는 비밀 없는 결과만 — `apply(status)` · `add(값을 뺀 줄)`). 세션 세대(`authStore.generation`)가 바뀌면 비밀이 든 창을 모두 닫는다(12단계부터 다른 기기의 로그아웃이 이 세션을 바로 끊는다). 창은 "몇 번째로 연 창인가"로 늦은 응답을 거른다 — 단 서버가 이미 켰거나 만든 결과는 반영한다. `login()` 은 `{ status }` 를 돌려준다. 코드로 나눈다: `AUTH_006` 칸 아래 · `AUTH_007` 코드 칸 보이기(6자리가 아닐 때도 이 문구) · `AUTH_008` 처음부터(새 키) · `AUTH_009` 상한. 지금 브라우저 줄이 없으면 [다른 곳 모두 로그아웃]은 "이 브라우저를 포함해"로 묻는다. **로그인된 기기**(2026-11-06 — 앱 세션이 함께 보인다): 웹 · 앱은 세션의 `client` · 로그인 기록의 `method` 로만 가른다(User-Agent 이름으로 짐작하지 않는다 — `shared/utils/account-security.ts` 의 `sessionCounts` · `isMobileSession`), 앱 이름은 `MOBILE_APP_NAME`(짝: backend `APP_BROWSER_NAME`) — 계약 5절, 디자인 README "12단계" |
| 전환 준비 (13단계) | 제한된 세션 `stores/auth.store.ts`(`restriction` · `twoFactorDeadline` · `restrictionChecks` · `canUseApp`) · `shared/utils/auth-session.ts` · `router/index.ts` 가드 + `composables/useSessionRestriction.ts`(App) · `useApi.ts` 의 `recheckRestriction` · 로그인 틀 단계 `components/auth/LoginPasswordChangeStep.vue` · `LoginTwoFactorSetupStep.vue` · `LoginTwoFactorScan.vue`(QR 148 — `QrCodeImage` 의 `compact`) · `LoginAccountRow.vue` · `LoginSupportLine.vue` · `composables/useLoginSecret.ts` · `shared/constants/session-restriction.ts` · 문의 연락처 `stores/support-contact.store.ts` · `shared/utils/support-contact.ts` · 유예 줄 `components/mail/TwoFactorGraceNotice.vue`(AppShell 의 `notice` 슬롯 — MailLayout 에만) · 메일 프로그램 연결 `pages/settings/AccountMailClientsPage.vue` + `components/settings/MailClientGuideList.vue` · `shared/constants/mail-clients.ts` · 30일 줄 `MailListFrame`(`MAILBOX_RETENTION_NOTICES` · `MAILBOX_EMPTY_HINTS`) | **제한된 세션**(계약 6절): login · me · refresh 의 `restriction` 이 있으면 보호 화면 대신 로그인 틀의 그 단계(새 비밀번호 → 2단계 켜기)로 — 403 `AUTH_010` 은 알림 없이 me 를 다시 받고(제한 없음이면 refresh 후 원래 요청 한 번만), 서버가 제한을 확인할 때마다(`restrictionChecks`) 다시 옮긴다. 제한된 동안은 `canUseApp` 이 false 라 나가기 확인을 묻지 않고(세션이 끝난 것처럼 — 2026-11-02 리뷰: 확인에서 이동을 취소하면 메일 화면에 갇혔다) 실시간 알림 · 새 메일 확인을 열지 않는다. 모르는 제한 이름은 `unknown` — 갱신 · 다시 보내기 없이 사전 문구 + [다른 계정으로 로그인]만. **비밀**: 로그인 때 넣은 비밀번호는 반응형이 아닌 클로저에 10분(서버가 마지막으로 확인한 때부터) — 성공 · 다른 계정 · 떠남 · `isLoggedIn` false 때 지운다, 거절(`AUTH_005`)되면 지우고 칸을 보인다. 성공한 로그인 코드도 지운다(`resetCodeStep`). 단계를 마친 뒤 refresh 가 장애면 me 를 먼저 묻는다(제한이 있으면 그 단계 · 없으면 이어 감 · me 도 실패면 위쪽 상자 + [다시 시도]). 2단계를 켠 직원의 새 비밀번호 단계는 다음 코드를 받는다(로그인 코드는 2분 다시 못 쓴다). **유예 줄**: 높이 40 · [지금 켜기] 36(안내 줄 밀도 예외 재사용), 날짜는 `twoFactorDeadline` 의 1ms 앞(올해가 아니면 해), 닫기 없음, 마감이 있으면 `GET /two-factor` 를 한 번 물어 이미 켠 직원에게는 숨긴다. 설정 › 2단계 인증은 `isRequired` · `deadline` — 필수 · 켜짐이면 끄기 대신 글. **메일 프로그램 연결**: 펼침 줄 52(`--size-header-min` 재사용 — 예외 아님) · 한 번에 하나 · 처음 모두 접힘(h3 > button · `aria-expanded` · region), 글은 상수가 원본(디자인 README 13단계), 앱 비밀번호 화면에 바로가기 줄, 아이콘 `monitor-smartphone`. `PasswordField` · `OtpCodeField` 의 `variant="login"`(로그인 틀 칸 모양), `PasswordField` 의 `readonly`. 12단계 코드 단계의 도움말은 문의 줄로 바뀌었다(`LOGIN_TWO_FACTOR_TEXT.help` 삭제) — 계약 6절, 디자인 README "13단계" |
| 휴대폰 앱 받기 (2026-11-08) | 설정 › 계정 › 휴대폰 앱 `src/pages/settings/AccountMobileAppPage.vue` · 앱 틀 밖 받기 화면 `pages/mobile-app/MobileAppPage.vue`(`/app` — 인쇄 화면처럼 `meta.requiresAuth` 만) · `components/mobile-app/`(`MobileAppDownloadLink` · `MobileAppIcon` · `MobileAppSteps` · `MobileAppIphoneNote`) · `stores/app-release.store.ts` · API `composables/useAppReleases.ts` · `shared/utils/app-release.ts` · `shared/types/app-release.ts` · 글 `shared/constants/mobile-app.ts`(`MOBILE_APP_PATH` · `MOBILE_APP_ROUTE` — 글은 이 상수가 원본) | 안드로이드 APK 를 로그인한 직원만 받는다(스토어 없음). 판 정보 `GET /api/app-releases/android/latest` 는 들어올 때마다 `silent` 로 받는다 — 404 `RES_001` 은 "받을 앱 없음"(알림 없음 · 안내와 문의 줄만), 그 밖의 실패는 칸 자리에 `MailLoadError`(사전 문구 + [다시 시도]), 세션 세대(`authStore.generation`)가 바뀌면 늦은 응답을 버리고 값을 지운다. [앱 내려받기]는 첨부처럼 `<a href download>` + 이동 직전 `confirmSession()`(`…/latest/file`). QR(`QrCodeImage`)은 `location.origin + '/app'` 만 담는다(비밀 아님) — `/app` 은 앱의 [새 버전 받기]도 여는 약속이라 경로를 바꾸면 앱도 함께. 문의 줄은 `LoginSupportLine`(`/app` 은 `size="base"` 13). 올린 날은 브라우저 시간대(`formatLongDate`). 설정은 좁은 폭(480 이하)에서 QR 줄을 빼고 [앱 내려받기]를 전체 폭으로, `/app` 은 휴대폰 폭이 먼저 · 480 초과는 띠 전체 폭 + 가운데 칸 480 · [앱 내려받기] 48(밀도 예외 아님). 글자 17 토큰 `--font-size-title-md` — 디자인 README "휴대폰 앱 받기" |
| 메일함 옮기기 · 하위까지 지우기 · 차단 정리 · 지금 보내기 (15단계) | 메일함 나무 도우미 `src/shared/utils/mailbox.ts`(`mailboxTree` · `mailboxSpokenName` · `mailboxDisplayName` · `mailboxMoveTargets` · `canHoldChildMailbox` · `sameMailboxName` · `descendantMailboxes`) · 옮기기 창 `components/mail/MailboxMoveDialog.vue`(+ `MailboxDialogs.vue` · `mailbox-dialog.ts` 의 `move`) · 지우기 확인 `MailboxDeleteDialog.vue`(`childCount`) · `composables/useMailboxActions.ts`(`move` · `remove(…, { withChildren })`) · `stores/mail/mailbox-admin.ts`(`moveMailbox` · `runMailboxOperation` 의 `withChildren`) · 글 `shared/utils/mailbox-operation.ts`(`mailboxMoveText`) · `shared/constants/mailbox-manage.ts`(`MAILBOX_MOVE_TEXT` · `MAILBOX_PATH_TEXT`) · 차단 창 `MessageBlockDialog.vue`(새 칸) · `MessageMoreMenu.vue` · `composables/useSenderBlock.ts`(`cleanInbox`) · `useSenderBlockRun.ts`(진행 · 결과 알림) · `stores/mail-block-run.store.ts`(`inboxBusy` · `startBlock`) · 되풀이 `stores/inbox-cleanup-run.ts`(필터 적용 · 차단 정리 공용) · 글 `shared/utils/mail-block.ts` 의 `mailBlockRunCopy` · `shared/constants/mail-filter.ts` 의 `INBOX_CLEANUP_TEXT` · 지금 보내기 `MessageReaderNotices.vue`([지금 보내기]) · `composables/useScheduledMessageActions.ts`(`sendNow`) · `stores/mail/scheduled.ts`(`releasePendingSend`) · API `composables/useMail.ts`(`moveMailbox` · `deleteMailbox` 의 `withChildren` · `runSenderBlock` · `releaseScheduledMessage`) | 계약 2절 "메일함 관리" · "보낸 사람 차단" · "보내기 취소 · 예약 발송". **나무 도우미는 한 곳** — [이동 ▾] 들여쓰기 · 끌어다 놓기 알림 · 옮기기 창이 함께 쓴다(알림 이름은 같은 이름이 다른 곳에도 있을 때만 "거래처 › 2025", 같은 자리 겹침은 대소문자 무시 — 엔진과 같다). 옮기기는 `PATCH /mailboxes/{id}` 의 `parentId`(null 은 맨 위) — 옮길 곳은 「하위 메일함 만들기」가 있는 곳과 같고, 자기 · 하위 · 지금 있는 곳 · 같은 이름이 있는 곳은 처음부터 고를 수 없다(radiogroup — 꺼진 줄에도 초점은 간다). `MAIL_009` 만 창 안, 결과 알림 + [되돌리기](원래 상위로). 하위까지 지우기는 확인 창이 물은 뒤에만 `?withChildren=true` — 나무 전체를 "지우는 중"으로 두고 수도 나무 전체로 센다, 끝나면 나무째 뺀다. 차단 창의 새 칸(처음 꺼짐)을 켜면 받은편지함의 이 메일은 첫 칸을 켠 채 잠그고(aria-disabled) 먼저 옮긴 뒤 `POST /mail-block-runs` 를 cursor 가 null 이 될 때까지 — 차단 알림 대신 진행 알림 하나가 결과로 바뀐다([스팸메일함 보기] · [다시 시도] — 다른 창에서 하는 중이어도 단추, [되돌리기] 없음). **받은편지함 정리는 한 직원에 한 번에 하나** — 이 탭의 필터 적용 · 다른 차단 정리가 도는 동안(`inboxBusy`)과 이 탭에서 스팸메일함을 비우는 동안 새 칸을 끈다(`startBlock` — 까닭 글이 다르다, 필터 적용은 스팸메일함 비우기를 보지 않는다), 다른 창은 서버가 429 `REQ_001`(차단 정리는 "다른 창에서 받은편지함이나 스팸메일함을 정리하는 중입니다. …" — 필터 적용은 "…받은편지함을 정리하는 중입니다. …"). [지금 보내기]는 `POST /scheduled-messages/{id}/release` — 응답의 `pendingSend` 가 undo 면 쓰기 화면과 같은 마감 알림 + [보내기 취소](`undoToastDeadline`), null 이면 "메일을 보냈습니다." + [보낸편지함 보기], 예약됨에서 빠진 뒤의 이동은 [예약 취소]와 같다. 503 `MAIL_001` · 결과를 모르는 실패(`SYS_001` — 앞단 502 · 504 · 시간 초과 · 연결 끊김)는 그 메일을 다시 받아 가른다(`shared/utils/pending-send.ts` 의 `unsentReleaseState`, 스토어의 `ReleaseOutcome`): 임시보관함 [임시보관함 보기] · 예약 그대로(확인함) "지금 보내지 못했습니다. …" + [다시 시도](안내 줄 그대로) · 예약만 풀림 · 다시 받지 못함은 이미 나갔을 수 있어 못 보냈다고 단정하지 않는다 — "메일을 보냈는지 확인하지 못했습니다. …" + [다시 시도](예약만 풀림은 안내 줄을 내리고, 다시 받지 못함은 마지막으로 안 그대로. [다시 시도]는 이미 보낸 메일이면 서버가 200) · 다시 낸 발송이 기다림은 보낸 것과 같다. 차단 정리와 이 탭의 스팸메일함 비우기는 서로 막는다(`isMailboxOperationBlocked` · `startBlock` — 다른 창끼리는 서버 잠금이 요청 하나씩이라 엇갈릴 수 있다, 알려진 한계) — 디자인 README "15단계" |
| 메일 옮기기 · 끌어다 놓기 · 좁은 읽기 칸 (15단계) | 읽기 화면 [이동 ▾] `src/components/mail/MessageMoveButton.vue` · 목록 [이동 ▾] `MailBulkMoveButton.vue` · 메뉴 항목 `components/mail/mail-move-menu.ts` · 옮길 곳 규칙 `shared/utils/mail-move-targets.ts`(`mailMoveTargets` · `hasMoveTarget` · `canDropMailOn` · `hereMailboxIds` · `junkMarkForMove`) · 끌기 `composables/useMailDrag.ts`(`useMailRowDrag` · `activeMailDrag` · `takeMailDrop` · `mailDragging` — 목록 줄 넷이 쓴다) · 끄는 표 `components/mail/MailDragChip.vue`(MailLayout) · 놓기 `MailboxPanelRow.vue`(모양 · 받을지) → `MailboxTree.vue` → `useBulkMailActions` 의 `moveDragged` · 알림 이름 `useMailMoveFeedback` 의 `placeName` · 좁은 읽기 칸 `MessageReader.vue`(컨테이너 쿼리) + `MailToolButton.vue`(`collapsible` · `menu`) · 메일 화면 파일 놓기 무시 `composables/useWindowFileDrop.ts` 의 `useIgnoredWindowFileDrop`(MailLayout) · 시험 도구 `src/test-utils/drag.ts` | 옮길 곳은 메일함 패널 차례 · 깊이(위 「메일함 옮기기 …」 행의 나무 도우미)이고 메일이 든 메일함 · 임시보관함 · 휴지통은 빼되 하위가 있으면 고를 수 없는 줄(읽기 "이 메일이 있는 메일함" · 목록 "고른 메일이 있는 메일함")로 남긴다. 들여 쓴 항목은 `EditorMenuEntry` 의 `depth` · `ariaLabel`. **스팸메일함으로 옮기는 세 길 같게**: 메뉴 · 끌기로 스팸메일함에 넣으면 `isJunk: true`, 스팸메일함에서 휴지통 밖으로 꺼내면 `isJunk: false`(`junkMarkForMove` — [스팸 신고] · [스팸 아님]과 같은 알림 · 되돌리기). 알림의 메일함 이름은 `placeName`(나무 도우미의 `mailboxDisplayName` — 옮김 · 되돌림 · 대화 목록의 "{메일함}에 있는 …" 모두). 끌기는 우리 자료(`MAIL_DRAG_TYPE` + 이 끌기의 표)만 싣고 링크의 주소는 지운다(`clearData`) — 끄는 동안의 자료는 탭 메모리(dragover 에서는 값을 읽지 못한다), 놓을 때 표가 같아야 받는다. 줄은 `:draggable="!held"` · 끌 수 없는 줄에서 시작한 링크 끌기는 dragstart 에서 막는다. 놓을 수 있는 줄만 dragover 를 받는다(나머지는 브라우저 금지 커서) · 들어온 요소 수로 점선을 켜고 끄며 끌기가 끝나면(`mailDragging`) 끈다. 파일 끌기는 받지 않는다. 메일 화면에 놓은 파일은 무시하되 받는 화면(`useWindowFileDrop`)이 떠 있으면 그 화면에 맡긴다. **좁은 읽기 칸**: 도구 줄 띠가 컨테이너(`container: reader-tools / inline-size`) — 분할 보기 570 · 휴지통 595 · 목록 보기 850 · 875 보다 좁으면 이름을 숨긴다(목록 보기는 단추 줄 최대 폭을 푼다, 화면 폭 기준은 tokens.css 머리). 접히는 단추는 `collapsible`(aria-label · title · `.tool-button__label`) — 계약 2절, 디자인 README "15단계" |
| 시계 주입 | `src/composables/useClock.ts` · `src/test-utils/manual-clock.ts` | 타이머를 쓰는 로직(자동 저장 등)은 시계를 주입받는다 — 테스트는 손으로 돌리는 시계로 시간을 넘긴다(고정 대기 없이, [[testing]]) |
| 나가기 전 확인 | `src/composables/useLeaveConfirm.ts` · `src/shared/utils/compose-changes.ts` | 화면을 연 뒤(또는 마지막으로 저장한 뒤) 바꾼 것이 있으면 앱 안 이동(라우트 떠남·바뀜)은 확인 대화상자, 탭 닫기·새로고침은 `beforeunload`. 쓰기 화면은 떠나기 전에 먼저 저장하고 저장하지 못한 것이 있을 때만 묻는다(서명 설정은 저장하지 않은 서명이 있으면 묻는다). 가드가 도는 중은 `guarding`. 바꿈 여부는 연 직후(저장한 뒤에는 마지막으로 저장한 판)의 스냅숏과 비교한다(편집기는 `isChanged()`). 떠나기 전 저장은 5초까지만 기다린다. 보낸 뒤·보내는 중·세션이 끝났을 때는 묻지 않는다 — 보내는 중에 떠나면 결과는 토스트로만 알리고 떠난 양식·새 양식을 건드리지 않는다(양식 세대로 늦게 온 결과를 버린다, 디자인 README "나가기 전 확인"). **고치던 모달 창**(필터 고치기 창 — 7단계)은 `composables/useDialogLeaveConfirm.ts` — 화면의 확인에 "창 닫기" 길을 더한다: [취소] · Esc 는 `asking: 'close'`([계속 고치기]면 창을 그대로 — 브라우저가 닫았으면 `reshow()`), 다른 화면은 `'route'`, 탭 닫기 · 새로고침은 `beforeunload`. 보내는 중 · 세션이 끝났을 때는 묻지 않는다 |
| 읽기 화면 본문 안내 | `src/shared/utils/mail-body-notices.ts` · `shared/constants/mail.ts` 의 `MAIL_BODY_NOTICES` | 서버의 `isHtmlBodyOmitted`·`isBodyTruncated`(계약 2절 "읽기 한도")를 외부 이미지 차단과 같은 안내 줄로 보인다(`.reader__notice[data-notice]`) |
+2 -1
View File
@@ -1,6 +1,7 @@
# 프로젝트 개요
이 저장소는 **풀스택 모노레포 기본 템플릿**이다. 누구나 클론하여 바로 개발을 시작할 수 있도록 공통 규칙과 언어별 규칙을 정의한다.
이 저장소는 **ARA SYSTEM** — 사내 그룹웨어(첫 모듈은 메일: 웹메일 · 휴대폰 앱)다. 백엔드 · 웹 · 앱과 메일 엔진 · 운영 설정을 한 저장소에서 다루고, 여기에 공통 규칙과 스택별 규칙을 둔다.
프로젝트 소개 · 문서 지도는 [`README.md`](README.md), 작업 차례는 [`docs/roadmap.md`](docs/roadmap.md).
| 디렉터리 | 스택 | 역할 | 개발 규칙 (스킬) |
| ----------- | -------------- | ------------------------ | ----------------------------------------- |
+108 -143
View File
@@ -1,109 +1,97 @@
# 풀스택 모노레포 기본 템플릿
# ARA SYSTEM
NestJS(백엔드) · Vue 3(웹) · Flutter(모바일)를 하나의 저장소에서 운영하는 풀스택 템플릿입니다.
세 클라이언트는 **동일한 표준 응답 포맷**과 **공통 에러 코드 맵**을 공유하며, Docker Compose로 한 번에 구동됩니다.
사내 그룹웨어다. 첫 모듈은 **메일** — 웹메일(포털)과 휴대폰 앱(안드로이드)이다.
메일 엔진(Stalwart)이 메일을 주고받고, 우리 서버(NestJS)가 엔진을 감싸 웹과 앱에 같은 API 를 준다. 화면은 직접 만든다(Vue 3 · Flutter).
`@arasystem.it.kr` 메일은 2026-11-05 옛 서버(mailcow)에서 옮겨 와 전 직원이 쓰고 있다.
## 지금
- 운영: 메일 1~13단계. 메일 14단계(자체 검색 색인 · 같은 대화)와 앱 첫 판 · 휴대폰 앱 받기는 main 에 합쳤고 서버 배포를 기다린다.
- 개발 중: 메일 15단계(메일 · 메일함 다루기). 다음은 16단계(개인 설정 — 주소록 가져오기 · 여러 서명 등).
- 단계별 내용 · 다음 차례 · 미뤄 둔 후보는 [`docs/roadmap.md`](./docs/roadmap.md).
## 구성
| 디렉터리 | 스택 | 역할 | 포트(기본) |
| ----------- | -------------- | ------------------------ | ---------- |
| `backend/` | NestJS + TypeORM | REST API 서버 | 3000 |
| `frontend/` | Vue 3 + Vite | 웹 프론트엔드 | 5173 |
| `flutter/` | Flutter + Riverpod | 모바일 앱 | - |
| `db` | PostgreSQL | 데이터베이스 | 5432 |
| `redis` | Redis | 캐시 / 세션 (**기본 비활성**) | 6379 |
| `mail` | Stalwart (JMAP) | 사내 메일 엔진 — 직원 로그인 = 메일 계정 (profile `mail`, [`mail/README.md`](./mail/README.md)) | 운영 25·465·993·443 / 개발 관리 8180 |
| `mailpit` | Mailpit | **개발 전용** — 사내 도메인 밖으로 보낸 메일 수집 | 8025 |
| 이름 | 스택 | 하는 일 | 포트(개발) |
| --- | --- | --- | --- |
| `backend/` | NestJS · TypeORM | 웹 · 앱이 함께 쓰는 REST API — 메일 엔진 중계, 로그인 세션, 검색 색인, 앱 알림, 대용량 첨부 | 3000 |
| `frontend/` | Vue 3 · Vite | 웹메일 화면. 운영은 nginx 가 화면과 같은 주소의 `/api` 를 백엔드로 넘긴다 | 5173 |
| `flutter/` | Flutter · Riverpod | 휴대폰 앱(안드로이드 — 스토어 없이 사내 배포) | - |
| `db` | PostgreSQL 15 | 로그인 세션 · 설정 · 검색 색인 · 대용량 첨부 기록 등 | 5432 |
| `mail` | Stalwart | 메일 엔진(JMAP · IMAP · SMTP) — 직원 로그인 = 메일 계정 | 관리 8180(운영은 25 · 465 · 993 · 443) |
| `mail-resolver` | unbound | 메일 엔진 전용 DNS(스팸 차단 목록 · DNSSEC) | - |
| `clamav` | ClamAV | 대용량 첨부 악성 검사(운영은 늘, 개발은 profile `clamav`) | - |
| `mailpit` | Mailpit | 개발 전용 — 바깥 주소로 보낸 메일을 모은다 | 8025 |
| `redis` | Redis | 기본 꺼짐 — 서버를 여러 대로 늘릴 때의 자리 | 6379 |
| `ops/` | 셸 · systemd | 운영 서버의 백업(restic) · 15분 점검 · 부팅 때 되살리기 | - |
| `ci/` | Gitea Actions 러너 | PR 마다 lint · 시험 · 빌드 · 인프라 검사 | - |
## 문서 지도
| 알고 싶은 것 | 문서 |
| --- | --- |
| 서버 · 웹 · 앱이 함께 지키는 API 약속 | [`docs/mail/api-contract.md`](./docs/mail/api-contract.md) |
| 웹 화면 디자인 · 화면 문구 규칙 · 단계별 화면 | [`docs/design/webmail/README.md`](./docs/design/webmail/README.md) — 디자인 원본은 같은 폴더의 `*.dc.html`(캔버스 보드) |
| 앱 화면 | [`docs/design/app/README.md`](./docs/design/app/README.md) |
| 메일 엔진 설정 · 운영 배포 · 백업 · 감시 · 전환 | [`mail/README.md`](./mail/README.md) |
| 앱 개발 실행 · 사내 배포(서명 키 · APK 올리기) | [`flutter/README.md`](./flutter/README.md) |
| CI 러너 | [`ci/README.md`](./ci/README.md) |
| 개발 규칙(공통 · 스택별 · 역할) | [`CLAUDE.md`](./CLAUDE.md) · 아래 "개발 규칙" |
| 로드맵 | [`docs/roadmap.md`](./docs/roadmap.md) |
| 로고 · 색 | [`docs/brand/`](./docs/brand/) |
## 사전 요구사항
- Docker / Docker Compose — backend·frontend·db·redis 구동 (컨테이너 안 런타임은 Node 24 LTS — `node:24.22.1-alpine3.24` 고정, 갱신 규칙은 docker 스킬 "Node 버전")
- Node.js **24 LTS 권장** — 호스트에서 프론트엔드 dev 서버·메일 setup 스크립트를 실행할 때 필요하다.
프론트 `engines` 는 `^20.19.0 || >=22.12.0` 이라 20.19 이상이면 돌아가지만, 이미지·CI(24)와 맞추는 것을 권장한다
(메일 setup 스크립트는 `--env-file` 때문에 20.6 이상)
- Flutter SDK `^3.11` — 모바일 앱 실행용 (compose 대상 아님)
- Docker / Docker Compose — 백엔드 · DB · 메일 엔진(컨테이너 안 Node 는 24 LTS 로 고정 — docker 스킬 "Node 버전")
- Node.js 24 LTS — 호스트에서 웹 개발 서버 · 메일 setup 스크립트를 돌릴 때
- Flutter 3.41.9(Dart 3.11.5) — 앱(compose 대상이 아니다 — [`flutter/README.md`](./flutter/README.md))
## 빠른 시작 (Docker)
## 빠른 시작 (개발)
```bash
# 1) 환경변수 파일 생성 (예제 복사)
# 실제 설정값은 루트 .env.development 한 곳에서 관리한다 (SSOT).
# backend/frontend 는 docker-compose 가 값을 주입하므로 예제만 복사하면 된다.
cp .env.development.example .env.development # ← 여기에만 값 입력
cp backend/.env.development.example backend/.env.development # (존재 필요: JWT 등 고유 시크릿)
# frontend 는 compose 가 빌드 시 값을 주입하므로 자체 env 복사 불필요
# 1) 환경변수 파일 — 값은 루트 .env.development 한 곳에 쓴다(SSOT)
cp .env.development.example .env.development
cp backend/.env.development.example backend/.env.development # 있어야 한다(JWT 등 백엔드 고유 비밀)
# 2) 백엔드 스택 기동 (backend + db, 기본 COMPOSE_PROFILES=mail 이면 mail + mailpit 포함)
# 2) 백엔드 스택 — backend + db, 기본 COMPOSE_PROFILES=mail 이면 mail · mail-resolver · mailpit 까지
docker compose -f docker-compose.yml -f docker-compose.dev.yml --env-file .env.development up -d
# 2-1) 메일 엔진 초기화 — 처음 한 번 (여러 번 실행해도 안전, mail/README.md)
# 3) 메일 엔진 초기화 — 처음 한 번(여러 번 돌려도 안전하다). 개발 시험 계정도 여기서 만든다(mail/README.md)
node --env-file=.env.development mail/scripts/setup.mjs
# 3) 의존성 변경 시에만 --build
docker compose -f docker-compose.yml -f docker-compose.dev.yml --env-file .env.development up -d --build
# 4) 프론트엔드는 호스트에서 직접 띄운다 (컨테이너보다 수십 배 빠르다)
# 4) 웹은 호스트에서 띄운다 — /api 는 vite 프록시가 백엔드 컨테이너로 넘긴다
cd frontend && npm install && npm run dev
```
기동 후:
- 웹 <http://localhost:5173> · API <http://localhost:3000/api> · Swagger <http://localhost:3000/api-docs>
- 의존성을 바꿨을 때만 `up -d --build`(백엔드는 `--renew-anon-volumes` 도 — docker 스킬).
- 백엔드를 처음 띄우면 watch 빌드에 수십 분이 걸릴 수 있다(Windows 바인드 마운트). 브랜치를 바꿨다고 다시 시작하지 않는다.
- 웹을 컨테이너로 띄워야 하면 `--profile frontend`(느리다 — 호스트 vite 와 함께 띄우지 않는다). 운영(`docker-compose.yml` 단독)은 nginx 정적 호스팅이다.
- 포트는 루트 `.env.{env}` 를 따른다(`FRONTEND_PORT` · `BACKEND_PORT` — `vite.config.ts` 가 직접 읽는다). 포트가 이미 쓰이면 다른 포트로 옮기지 않고 실패한다.
- Redis 는 뜨지 않는다. 필요해지면 `COMPOSE_PROFILES` 에 `redis` 를 더한다.
- 웹: <http://localhost:5173>
- API: <http://localhost:3000/api>
- Swagger 문서: <http://localhost:3000/api-docs>
전체 실행 · 배포 명령은 [`.claude/skills/docker/SKILL.md`](./.claude/skills/docker/SKILL.md).
> **개발 환경에서 프론트엔드는 컨테이너로 뜨지 않습니다.**
> vite 를 컨테이너에서 돌리면 소스가 Windows 바인드 마운트 너머에 있어 첫 서빙까지 수 분이 걸립니다.
> 호스트에서 `npm run dev` 로 띄우면 1~2초이고, `/api` 요청은 vite 프록시가 백엔드 컨테이너로 넘깁니다.
> 컨테이너로 띄워야 한다면 `--profile frontend` 를 붙이세요 (아래 "프론트엔드 실행 방식" 참조).
> 운영(`docker-compose.yml` 단독)은 그대로 nginx 정적 호스팅으로 동작합니다.
> Redis 는 기본적으로 뜨지 않습니다. 필요해지면 루트 `.env.{env}` 에 `COMPOSE_PROFILES=redis` 를 설정하세요.
### 프론트엔드 실행 방식
| 방식 | 명령 | 첫 서빙 | 용도 |
| --- | --- | --- | --- |
| **호스트 vite (개발 기본)** | `cd frontend && npm run dev` | 1~2초 (최초 의존성 사전번들 10초 내외) | 일상 개발 |
| 컨테이너 vite (선택) | `docker compose ... --profile frontend up -d` | 수 분 (Windows 바인드 마운트) | 호스트에 Node 를 둘 수 없을 때 |
| nginx 정적 (운영) | `docker compose --env-file .env.production up -d --build` | - | 배포 |
> 컨테이너 방식을 상시 사용하려면 루트 `.env.development` 에 `COMPOSE_PROFILES=frontend` 를 넣습니다.
> (redis 와 함께 켤 때는 `COMPOSE_PROFILES=frontend,redis`)
> 호스트 vite 와 컨테이너 vite 는 같은 호스트 포트를 쓰므로 **동시에 띄우지 마세요.**
> **포트는 루트 `.env.{env}` 를 따릅니다.** 호스트에서 띄우는 vite 도 `FRONTEND_PORT` 로 열리고,
> `/api` 프록시 대상은 `BACKEND_PORT` 에서 나옵니다 — `vite.config.ts` 가 루트 env 를 직접 읽습니다.
> 포트가 이미 사용 중이면 조용히 다른 포트로 옮기지 않고 **실패**합니다
> (`CORS_ORIGIN` 과 compose 포트 매핑이 그 값에 묶여 있어, 말없이 옮겨가면 원인 모르게 어긋납니다).
전체 실행/배포 명령어는 [`.claude/skills/docker/SKILL.md`](./.claude/skills/docker/SKILL.md) 참조.
## 모바일 앱 실행 (Flutter)
Flutter 는 Docker Compose 대상이 아니므로 로컬에서 직접 실행합니다.
## 앱 실행 (Flutter)
```bash
cd flutter && cp .env.example .env && flutter pub get && flutter run
```
> `flutter/.env` 의 `API_URL` 을 백엔드 주소로 설정합니다. (Android 에뮬레이터는 `http://10.0.2.2:3000/api`)
`flutter/.env` 의 `API_URL` 을 백엔드 주소로 둔다(Android 에뮬레이터는 `http://10.0.2.2:3000/api`). 실기기 · 새 메일 알림 · 사내 배포는 [`flutter/README.md`](./flutter/README.md).
## 환경변수
`.env*` 는 커밋하지 않습니다. 각 디렉터리의 `*.example` 파일을 복사해 사용하세요.
`.env*` 는 커밋하지 않는다. 각 자리의 `*.example` 을 복사해 쓴다. **루트 `.env.{env}` 가 단일 진실 공급원(SSOT)**이다 — backend · frontend 는 compose 가 루트 값을 넣어 준다
(구조는 [`.claude/rules/env-structure.md`](./.claude/rules/env-structure.md)).
**루트 `.env.{env}` 가 단일 진실 공급원(SSOT)** 입니다. backend·frontend 는 docker-compose 가
루트 값을 주입하므로 자체 env 에 값을 중복 작성하지 않습니다. (자세한 위임 구조는 [`CLAUDE.md`](./CLAUDE.md) 의 "환경변수 구조" 참조)
| 파일 | 용도 | 값을 쓰는 곳 |
| --- | --- | --- |
| `.env.{env}.example` | 루트 — 모든 설정의 SSOT(compose 가 넣어 준다) | 여기 |
| `backend/.env.{env}.example` | 백엔드 고유 비밀(JWT · 앱 알림 키 등)만 | 최소(파일은 있어야 한다) |
| `flutter/.env.example` | 앱 — `API_URL`(compose 대상이 아니다) | 따로 |
| 파일 | 용도 | 값 작성 위치 |
| ----------------------------- | ---------------------------------------- | --------------------- |
| `.env.{env}.example` | 루트 — 모든 설정의 SSOT (compose 주입) | ✅ 여기에 작성 |
| `backend/.env.{env}.example` | 백엔드 — 고유 시크릿(JWT 등)만 | 최소 (존재 필수) |
| `frontend/.env.{env}.example` | 프론트 — API 주소는 같은 출처의 `/api` 고정(빌드 arg) | 작성 불필요 |
| `flutter/.env.example` | 모바일 — `API_URL` (compose 비대상) | ✅ 독립 관리 |
`{env}` = `development` | `production`
`{env}` = `development` | `production`. 운영 서버의 백업 · 점검 설정은 `/etc/arasystem/`(`ops/ops.env.example` — mail/README "백업").
## 표준 응답 포맷
@@ -114,97 +102,74 @@ cd flutter && cp .env.example .env && flutter pub get && flutter run
// 실패 — 사용자에게 보이는 모든 실패는 에러 코드를 가진다
{ "success": false, "error": { "code": "VAL_001", "message": "...", "traceId": "3f1c0a2e-..." } }
// 목록 — data 안의 형태가 고정되어 있다
// 목록 — data 안의 꼴이 정해져 있다
{ "success": true, "data": { "items": [], "pageInfo": { "hasNext": false, "nextCursor": null, "total": 0 } } }
```
`traceId` 는 요청마다 발급되는 추적 ID(`x-request-id` 헤더와 동일)로, 서버 로그에 같은 값이 남습니다.
사용자 문의가 들어오면 이 값으로 로그를 검색하면 됩니다.
`traceId` 는 요청마다 붙는 추적 ID(`x-request-id` 헤더와 같다)라 서버 로그에 같은 값이 남는다 — 문의가 오면 이 값으로 로그를 찾는다.
`/api/health` 는 예외다(모니터링 도구가 읽는 원본 꼴).
> `/api/health` 는 예외입니다. 모니터링 도구가 읽는 엔드포인트라 래퍼 없이 원본 형식을 반환합니다.
### 에러 코드는 한 곳에서 관리됩니다
### 에러 코드는 한 곳에서
```
backend/src/common/errors/error-codes.ts ← 여기만 수정
backend/src/common/errors/error-codes.ts ← 여기만 고친다
│ cd backend && npm run sync:error-codes
├─▶ frontend/src/shared/constants/error-codes.generated.ts (자동 생성)
└─▶ flutter/lib/core/api/error_lexicon.g.dart (자동 생성)
```
생성 파일은 직접 수정하지 마세요. CI 가 동기화 여부를 검사합니다.
자세한 규칙은 [`.claude/rules/error-codes.md`](./.claude/rules/error-codes.md) 참조.
생성 파일은 직접 고치지 않는다. CI 가 맞는지 본다 — [`.claude/rules/error-codes.md`](./.claude/rules/error-codes.md).
## 로컬 개발 (Docker 없이)
DB 는 컨테이너로 띄우고 앱만 호스트에서 실행하는 방식이 가장 간단합니다.
## 커밋 전 검사
```bash
# 1) DB 만 컨테이너로 기동 (127.0.0.1:5432 로 노출된다)
docker compose -f docker-compose.yml -f docker-compose.dev.yml --env-file .env.development up -d db
# 2) backend — DB_HOST 기본값이 localhost 라 그대로 붙는다
cd backend && npm ci && npm run start:dev
# 3) frontend — vite dev 서버가 /api 를 localhost:3000 으로 프록시한다 (개발 기본 방식)
cd frontend && npm ci && npm run dev
# 4) flutter
cd flutter && flutter pub get && flutter run
```
> 프론트엔드의 API 요청은 `/api` 상대경로로 나가고 vite dev 프록시가 백엔드로 넘깁니다.
> 백엔드를 다른 주소에서 띄웠다면 `VITE_DEV_API_PROXY` 로 대상을 바꾸세요.
### 커밋 전 검사
```bash
cd backend && npm run lint && npm run test && npm run build
cd frontend && npm run lint && npm run type-check && npm run test:unit
# backend — 컨테이너 안에서(의존성 · lock 이 Linux 기준이라 호스트에서 npm install 하지 않는다)
docker exec arasystem-backend npm run lint
docker exec arasystem-backend npm run test # 메모리가 빠듯하면 -- --maxWorkers=2
docker exec arasystem-backend npx jest --config ./test/jest-e2e.json # e2e — DB · 메일 엔진이 떠 있어야 한다
# frontend · flutter — 호스트에서
cd frontend && npm run lint && npm run type-check && npm run test:unit # 시간대가 걸린 화면을 고쳤으면 TZ=UTC 로도
cd flutter && flutter analyze && flutter test
```
lint 는 `--max-warnings 0` 이라 경고 하나만 있어도 실패합니다. 자동 수정은 `npm run lint:fix`.
같은 검사를 CI([`.github/workflows/ci.yml`](./.github/workflows/ci.yml))가 PR 마다 수행합니다.
컨테이너 이름은 `<PROJECT_NAME>-backend`(개발 기본 `arasystem`). lint 는 `--max-warnings 0` 이라 경고 하나도 실패다. 같은 검사를 CI(`.github/workflows/ci.yml` · `ci-flutter.yml`)가 PR 마다 한다.
> ⚠️ **의존성을 추가·제거할 때는 컨테이너 안에서** 실행하세요.
> Windows 에서 `npm install` 하면 Linux 전용 패키지가 lock 에서 잘려 Docker 빌드가 깨집니다.
> ```bash
> docker compose -f docker-compose.yml -f docker-compose.dev.yml --env-file .env.development exec backend npm install <패키지>
> ```
> ⚠️ **백엔드 의존성은 컨테이너 안에서만 바꾼다** — Windows 에서 `npm install` 하면 Linux 전용 패키지가 lock 에서 빠져 Docker 빌드가 깨진다.
> `docker compose -f docker-compose.yml -f docker-compose.dev.yml --env-file .env.development exec backend npm install <패키지>`
## 개발 규칙
| 영역 | 문서 |
| --------- | ------------------------------------------------- |
| 공통/보안 | [`CLAUDE.md`](./CLAUDE.md) · [`.claude/rules/`](./.claude/rules/) |
| 응답·에러 | [`response-format.md`](./.claude/rules/response-format.md) · [`error-codes.md`](./.claude/rules/error-codes.md) |
| API 설계 | [`api-design.md`](./.claude/rules/api-design.md) |
| 로깅 | [`logging.md`](./.claude/rules/logging.md) |
| 체크리스트 | [`checklist.md`](./.claude/rules/checklist.md) |
| NestJS | [`.claude/skills/nestjs/SKILL.md`](./.claude/skills/nestjs/SKILL.md) |
| Vue 3 | [`.claude/skills/vue3/SKILL.md`](./.claude/skills/vue3/SKILL.md) |
| Flutter | [`.claude/skills/flutter/SKILL.md`](./.claude/skills/flutter/SKILL.md) |
| Docker | [`.claude/skills/docker/SKILL.md`](./.claude/skills/docker/SKILL.md) |
| 역할 분담 | [`.claude/agents/README.md`](./.claude/agents/README.md) |
| 영역 | 문서 |
| --- | --- |
| 입구 · 공통 | [`CLAUDE.md`](./CLAUDE.md) · [`.claude/rules/`](./.claude/rules/) |
| 응답 · 에러 | [`response-format.md`](./.claude/rules/response-format.md) · [`error-codes.md`](./.claude/rules/error-codes.md) |
| API 설계 | [`api-design.md`](./.claude/rules/api-design.md) |
| 로깅 · 보안 | [`logging.md`](./.claude/rules/logging.md) · [`security.md`](./.claude/rules/security.md) |
| 점검 목록 | [`checklist.md`](./.claude/rules/checklist.md) |
| NestJS | [`.claude/skills/nestjs/SKILL.md`](./.claude/skills/nestjs/SKILL.md) |
| Vue 3 | [`.claude/skills/vue3/SKILL.md`](./.claude/skills/vue3/SKILL.md) |
| Flutter | [`.claude/skills/flutter/SKILL.md`](./.claude/skills/flutter/SKILL.md) |
| Docker · 운영 | [`.claude/skills/docker/SKILL.md`](./.claude/skills/docker/SKILL.md) |
| 역할 분담 | [`.claude/agents/README.md`](./.claude/agents/README.md) |
새 화면은 캔버스 보드로 먼저 확인받은 뒤 만든다(디자인 README). 화면 · 알림 글은 디자인 README 「화면 문구 — 사용자에게 말하듯」을 따른다.
## 디렉터리 구조
```
.
├── backend/ # NestJS API (Controller / Service / DTO / 공통 필터·인터셉터)
├── frontend/ # Vue 3 (pages / components / composables / stores / router)
├── flutter/ # Flutter (core / features / shared)
├── docker-compose.yml # 베이스 정의
├── docker-compose.dev.yml # 개발 오버라이드 (핫리로드, 포트 노출)
├── CLAUDE.md # 프로젝트 개요 + 공통 규칙 @import
├── .github/
│ ├── workflows/ci.yml # lint·test·build + 에러 코드 동기화 + 인프라(compose·Dockerfile·nginx) 검사
│ └── pull_request_template.md
├── ci/ # CI 러너 (Gitea Actions) — 앱 스택과 분리
└── .claude/
├── settings.json # Claude Code 권한·환경 설정
├── rules/ # 공통 규칙 (코딩·응답 포맷·에러 코드·API 설계·로깅·보안·환경변수·테스트)
├── skills/ # 도메인별 개발 규칙 (nestjs·vue3·flutter·docker)
└── agents/ # 역할별 규칙 (개발 3종·테스터·리뷰어·인프라·사용자/관리자 관점)
├── backend/ # NestJS API — modules/(auth · mail · contacts · file-storage · push · ops 등) · common · shared
├── frontend/ # Vue 3 웹메일 — pages · components · composables · stores · router, 운영 nginx 설정
├── flutter/ # 휴대폰 앱 — core · features · shared
├── mail/ # 메일 엔진 setup 스크립트 · DNS 리졸버 · ClamAV 설정 · 운영 문서(README)
├── ops/ # 운영 서버의 백업 · 점검 · 부팅 되살리기(systemd)
├── docs/ # API 계약 · 화면 디자인(캔버스 보드) · 브랜드 · 로드맵
├── ci/ # CI 러너(Gitea Actions) — 앱 스택과 따로 띄운다
├── data/ # 개발용 바인드 마운트 자리(내용은 커밋하지 않는다)
├── docker-compose.yml # 운영 · 개발 공통 정의
├── docker-compose.dev.yml # 개발 오버라이드(핫리로드 · 127.0.0.1 포트)
├── docker-compose.remote-proxy.yml # 리버스 프록시가 다른 서버에 있을 때(운영 선택)
├── CLAUDE.md # 개발 규칙의 입구(.claude/ 의 규칙을 불러온다)
├── .github/ # CI 워크플로(ci.yml · ci-flutter.yml) · PR 양식
└── .claude/ # rules(공통 규칙) · skills(스택별 규칙) · agents(역할) · 설정
```
+22 -84
View File
@@ -1,98 +1,36 @@
<p align="center">
<a href="http://nestjs.com/" target="blank"><img src="https://nestjs.com/img/logo-small.svg" width="120" alt="Nest Logo" /></a>
</p>
# backend — ARA SYSTEM API (NestJS)
[circleci-image]: https://img.shields.io/circleci/build/github/nestjs/nest/master?token=abc123def456
[circleci-url]: https://circleci.com/gh/nestjs/nest
웹 · 앱이 함께 쓰는 REST API 다(`/api`). 메일 엔진(Stalwart)을 JMAP 으로 감싸고, 로그인 세션 · 설정 · 검색 색인 · 앱 알림 · 대용량 첨부 · 운영 도구를 맡는다.
<p align="center">A progressive <a href="http://nodejs.org" target="_blank">Node.js</a> framework for building efficient and scalable server-side applications.</p>
<p align="center">
<a href="https://www.npmjs.com/~nestjscore" target="_blank"><img src="https://img.shields.io/npm/v/@nestjs/core.svg" alt="NPM Version" /></a>
<a href="https://www.npmjs.com/~nestjscore" target="_blank"><img src="https://img.shields.io/npm/l/@nestjs/core.svg" alt="Package License" /></a>
<a href="https://www.npmjs.com/~nestjscore" target="_blank"><img src="https://img.shields.io/npm/dm/@nestjs/common.svg" alt="NPM Downloads" /></a>
<a href="https://circleci.com/gh/nestjs/nest" target="_blank"><img src="https://img.shields.io/circleci/build/github/nestjs/nest/master" alt="CircleCI" /></a>
<a href="https://discord.gg/G7Qnnhy" target="_blank"><img src="https://img.shields.io/badge/discord-online-brightgreen.svg" alt="Discord"/></a>
<a href="https://opencollective.com/nest#backer" target="_blank"><img src="https://opencollective.com/nest/backers/badge.svg" alt="Backers on Open Collective" /></a>
<a href="https://opencollective.com/nest#sponsor" target="_blank"><img src="https://opencollective.com/nest/sponsors/badge.svg" alt="Sponsors on Open Collective" /></a>
<a href="https://paypal.me/kamilmysliwiec" target="_blank"><img src="https://img.shields.io/badge/Donate-PayPal-ff3f59.svg" alt="Donate us"/></a>
<a href="https://opencollective.com/nest#sponsor" target="_blank"><img src="https://img.shields.io/badge/Support%20us-Open%20Collective-41B883.svg" alt="Support us"></a>
<a href="https://twitter.com/nestframework" target="_blank"><img src="https://img.shields.io/twitter/follow/nestframework.svg?style=social&label=Follow" alt="Follow us on Twitter"></a>
</p>
<!--[![Backers on Open Collective](https://opencollective.com/nest/backers/badge.svg)](https://opencollective.com/nest#backer)
[![Sponsors on Open Collective](https://opencollective.com/nest/sponsors/badge.svg)](https://opencollective.com/nest#sponsor)-->
- 모듈 지도 · 개발 규칙: [`.claude/skills/nestjs/SKILL.md`](../.claude/skills/nestjs/SKILL.md)
- API 약속(웹 · 앱과 함께): [`docs/mail/api-contract.md`](../docs/mail/api-contract.md)
- 공통 규칙(응답 꼴 · 에러 코드 · 로그 · 보안): [`CLAUDE.md`](../CLAUDE.md)
## Description
## 실행 — 컨테이너로
[Nest](https://github.com/nestjs/nest) framework TypeScript starter repository.
## Project setup
백엔드는 늘 컨테이너로 띄운다. 의존성 · lock 이 Linux 기준이라 **호스트에서 `npm install` 하지 않는다**(바꿀 때도 컨테이너 안에서 — nestjs 스킬 "의존성 추가/제거").
```bash
$ npm install
# 저장소 루트에서 — 개발 스택(backend + db + 메일 엔진)
docker compose -f docker-compose.yml -f docker-compose.dev.yml --env-file .env.development up -d
docker compose -f docker-compose.yml -f docker-compose.dev.yml --env-file .env.development logs -f backend
```
## Compile and run the project
- 처음 띄우면 마이그레이션을 한 번 돌리고 watch 빌드를 시작한다(Windows 바인드 마운트라 수십 분 걸릴 수 있다). 코드를 고치면 저절로 다시 뜬다.
- Swagger: <http://localhost:3000/api-docs>
## 시험 · 검사
```bash
# development
$ npm run start
# watch mode
$ npm run start:dev
# production mode
$ npm run start:prod
docker exec arasystem-backend npm run lint # 경고 0
docker exec arasystem-backend npm run test # 단위 — 메모리가 빠듯하면 -- --maxWorkers=2
docker exec arasystem-backend npx jest --config ./test/jest-e2e.json # e2e — DB · 메일 엔진 필요
```
## Run tests
컨테이너 이름은 `<PROJECT_NAME>-backend`(개발 기본 `arasystem`). Git Bash 에서 컨테이너 안 경로를 줄 때는 `MSYS_NO_PATHCONV=1` 을 붙인다.
```bash
# unit tests
$ npm run test
## 자주 하는 일
# e2e tests
$ npm run test:e2e
# test coverage
$ npm run test:cov
```
## Deployment
When you're ready to deploy your NestJS application to production, there are some key steps you can take to ensure it runs as efficiently as possible. Check out the [deployment documentation](https://docs.nestjs.com/deployment) for more information.
If you are looking for a cloud-based platform to deploy your NestJS application, check out [Mau](https://mau.nestjs.com), our official platform for deploying NestJS applications on AWS. Mau makes deployment straightforward and fast, requiring just a few simple steps:
```bash
$ npm install -g @nestjs/mau
$ mau deploy
```
With Mau, you can deploy your application in just a few clicks, allowing you to focus on building features rather than managing infrastructure.
## Resources
Check out a few resources that may come in handy when working with NestJS:
- Visit the [NestJS Documentation](https://docs.nestjs.com) to learn more about the framework.
- For questions and support, please visit our [Discord channel](https://discord.gg/G7Qnnhy).
- To dive deeper and get more hands-on experience, check out our official video [courses](https://courses.nestjs.com/).
- Deploy your application to AWS with the help of [NestJS Mau](https://mau.nestjs.com) in just a few clicks.
- Visualize your application graph and interact with the NestJS application in real-time using [NestJS Devtools](https://devtools.nestjs.com).
- Need help with your project (part-time to full-time)? Check out our official [enterprise support](https://enterprise.nestjs.com).
- To stay in the loop and get updates, follow us on [X](https://x.com/nestframework) and [LinkedIn](https://linkedin.com/company/nestjs).
- Looking for a job, or have a job to offer? Check out our official [Jobs board](https://jobs.nestjs.com).
## Support
Nest is an MIT-licensed open source project. It can grow thanks to the sponsors and support by the amazing backers. If you'd like to join them, please [read more here](https://docs.nestjs.com/support).
## Stay in touch
- Author - [Kamil Myśliwiec](https://twitter.com/kammysliwiec)
- Website - [https://nestjs.com](https://nestjs.com/)
- Twitter - [@nestframework](https://twitter.com/nestframework)
## License
Nest is [MIT licensed](https://github.com/nestjs/nest/blob/master/LICENSE).
- **에러 코드**: `src/common/errors/error-codes.ts` 만 고치고 호스트에서 `cd backend && npm run sync:error-codes`(웹 · 앱 사전이 함께 바뀐다).
- **DB 스키마**: 엔티티를 고친 뒤 `docker exec arasystem-backend npm run migration:generate -- src/migrations/<이름>` → 만든 SQL 을 눈으로 확인 → 엔티티와 함께 커밋(nestjs 스킬 "DB 마이그레이션").
- **운영 도구**(운영 서버 안 명령 — 입사 · 비밀번호 재설정 · 휴대폰 앱 올리기 등): `sudo docker exec -it <PROJECT_NAME>-backend node dist/cli/ops.js` — 계약 6절 · mail/README.
@@ -97,6 +97,19 @@ export class FileSharingRepository {
await this.links.update({ id }, { sentEmailId });
}
/** 보낸 때를 앞당긴다 — 취소하지 않았고 지금 보낸 때가 더 늦을 때만, 한 문장으로 (true = 바꿨다) */
async moveSentAtEarlier(id: string, sentAt: Date): Promise<boolean> {
const result = await this.links
.createQueryBuilder()
.update(ShareLinkEntity)
.set({ sentAt })
.where('id = :id', { id })
.andWhere('canceled_at IS NULL')
.andWhere('sent_at > :sentAt', { sentAt })
.execute();
return (result.affected ?? 0) > 0;
}
/** 취소한 링크로 — 이미 취소했으면 그대로 (true = 이번에 바꿨다) */
async cancel(
id: string,
@@ -91,6 +91,16 @@ class FakeRepository {
return Promise.resolve();
}
/** 실제 표처럼 — 취소하지 않았고 지금 보낸 때가 더 늦을 때만 (조건부 UPDATE 한 문장) */
moveSentAtEarlier(id: string, sentAt: Date) {
const link = this.records.find((item) => item.link.id === id)?.link;
if (!link || link.canceledAt !== null || link.sentAt <= sentAt) {
return Promise.resolve(false);
}
link.sentAt = sentAt;
return Promise.resolve(true);
}
cancel(id: string, reason: 'send-failed' | 'send-canceled', now: Date) {
const link = this.records.find((item) => item.link.id === id)?.link;
if (!link || link.canceledAt !== null) return Promise.resolve(false);
@@ -314,6 +324,34 @@ describe('FileSharingService — 공유 링크 · 공개 내려받기 (11단계)
expect(await codeOf(() => service.publicLink(token))).toBe('RES_001');
});
it('예약 메일을 지금 보냈으면(메일 15단계) 보낸 때를 앞당긴다 — 끝나는 때는 그대로(덩이가 적은 날짜), 취소한 링크 · 더 늦은 시각은 바꾸지 않는다', async () => {
const { service, repository } = setup();
const scheduled = new Date(Date.now() + 5 * 24 * 60 * 60_000);
const created = await service.createMailLink({
ownerEmail: ARA,
senderName: '김아라',
files: [{ id: FILE_A, name: '설계도.dwg', size: CONTENT.length }],
sentAt: scheduled,
messageId: 'ara.release@arasystem.test',
});
const { link } = created.record;
const expiresAt = link.expiresAt.getTime();
const now = new Date();
await expect(service.markReleased(link.id, now)).resolves.toBe(true);
expect(link.sentAt).toEqual(now);
expect(link.expiresAt.getTime()).toBe(expiresAt);
// 다시 와도(응답을 받지 못한 재시도) 더 늦은 시각으로 되돌리지 않는다
await expect(service.markReleased(link.id, scheduled)).resolves.toBe(false);
expect(link.sentAt).toEqual(now);
await service.cancel(created.record, 'send-canceled');
await expect(
service.markReleased(link.id, new Date(now.getTime() - 1_000)),
).resolves.toBe(false);
expect(repository.records).toHaveLength(1);
});
describe('파일 응답 (Range · HEAD · 횟수)', () => {
it('전체 200 — 내려받기 머리글(형식 · 이름 · nosniff · sandbox · no-store · ETag · Accept-Ranges · X-Accel-Buffering)', async () => {
const { service } = setup();
@@ -199,6 +199,15 @@ export class FileSharingService implements BeforeApplicationShutdown {
await this.repository.markSent(linkId, sentEmailId);
}
/**
* 예약한 메일을 지금 보냈다(메일 15단계 — 계약 2절 "보내기 취소 · 예약 발송") — 보낸 때를 실제로 보낸 때로 앞당긴다(보낸 링크 목록 · 공개 화면의 보낸 때).
* **끝나는 때는 그대로 둔다** — 보낸 메일의 덩이가 예약 시각으로 센 그 날짜를 이미 적었다. 앞당기면 받는 사람이 본 날짜보다 먼저 끝난다(파일 기간도 그대로라 다시 세지 않는다).
* 취소한 링크 · 이미 그보다 앞선 보낸 때는 바꾸지 않는다 (true = 바꿨다)
*/
async markReleased(linkId: string, sentAt: Date): Promise<boolean> {
return this.repository.moveSentAtEarlier(linkId, sentAt);
}
/**
* 같은 Idempotency-Key 의 재시도가 처음 메일을 찾았다 — 그 발송이 만든 링크(보낸 메일을 모르는 것)에 보낸 사본을 잇는다.
* 결과를 모르게 끝난 첫 시도의 링크가 이렇게 제자리를 찾는다 (계약 4절 "링크를 만드는 때")
@@ -0,0 +1,86 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsString, Matches, MaxLength, ValidateIf } from 'class-validator';
import {
MAIL_FILTER_RUN_CURSOR_PATTERN,
MAX_EMAIL_LENGTH,
MAX_MAIL_FILTER_RUN_CURSOR_LENGTH,
} from '@/modules/mail/mail.constants';
import type {
MailBlockRunRequest,
MailFilterRun,
} from '@/modules/mail/mail.types';
import { BlockAddress } from './mail-block.dto';
/**
* 차단할 때 받은편지함 정리 — POST /api/mail-block-runs 의 본문과 응답 (계약 2절 "보낸 사람 차단" — 15단계).
* 보낸 사람은 차단하기와 같은 검사(주소 · `@도메인` 모양 · 254자 — mail-blocks.ts), 차단 목록에 있는지는 서비스가 본다(없으면 404 RES_001).
* cursor 는 이미 받은 메일에 필터 적용(dto/mail-filter-run.dto.ts)과 같은 검사다
*/
/** null 은 "처음부터"로 받는다 — 키가 없으면(undefined) 형식 오류다 (늘 보낸다) */
const unlessNull = (_object: object, value: unknown) => value !== null;
/** POST /api/mail-block-runs — 정리할 보낸 사람(차단 목록의 값)과 이어 할 자리 */
export class MailBlockRunRequestDto implements MailBlockRunRequest {
@ApiProperty({
description:
'차단 목록의 값 그대로 — 소문자 주소("kim@spam.example") 또는 도메인("@spam.example"). 차단한 뒤에만 부른다 — 차단 목록에 없으면 404 RES_001',
example: '@spam.example',
maxLength: MAX_EMAIL_LENGTH,
})
@BlockAddress()
sender!: string;
@ApiProperty({
description:
'이어 할 자리 — 처음은 null, 이어 할 때는 앞 응답의 cursor 그대로(해석하지 않는다). 늘 보낸다. ' +
'다른 보낸 사람의 cursor · 깨진 cursor 는 400 VAL_001',
nullable: true,
type: String,
maxLength: MAX_MAIL_FILTER_RUN_CURSOR_LENGTH,
example: null,
})
@ValidateIf(unlessNull)
@IsString({ message: '이어 할 자리를 다시 받아 주세요.' })
@MaxLength(MAX_MAIL_FILTER_RUN_CURSOR_LENGTH, {
message: '이어 할 자리를 다시 받아 주세요.',
})
@Matches(MAIL_FILTER_RUN_CURSOR_PATTERN, {
message: '이어 할 자리를 다시 받아 주세요.',
})
cursor!: string | null;
}
/** POST /api/mail-block-runs 의 응답 — 이미 받은 메일에 필터 적용(MailFilterRun)과 같은 꼴 */
export class MailBlockRunDto implements MailFilterRun {
@ApiProperty({
description: '이번 요청에서 확인한 받은편지함 메일 수',
example: 500,
})
scanned!: number;
@ApiProperty({
description:
'그중 그 보낸 사람의 메일이라 스팸메일함으로 옮긴 수 (스팸 표시도 붙였다). 보내기를 기다리는 메일은 옮기지 않아 세지 않는다',
example: 37,
})
matched!: number;
@ApiProperty({
description:
'아직 확인하지 않은 메일 수 — 어림(그사이 들어오고 나간 메일이 있다). 진행의 전체는 지금까지 확인한 수 + 이 값',
example: 4_300,
})
remaining!: number;
@ApiProperty({
description:
'null = 끝. 아니면 같은 본문에 이 cursor 를 넣어 다시 보낸다 (해석하지 않는다)',
nullable: true,
type: String,
example:
'eyJ2IjoyLCJiIjoxNzkzMjY0NDAxLCJhIjoic2VhYWFhZXIiLCJ0IjoxNzkzMjY0MzQwLCJyIjoiZDJWM2R0ejdOMmdCVVRyZyJ9',
})
cursor!: string | null;
}
@@ -32,7 +32,8 @@ function IsBlockTarget() {
);
}
function BlockAddress(): PropertyDecorator {
/** 차단 대상 칸 — 차단하기(POST /api/mail-blocks) · 받은편지함 정리(POST /api/mail-block-runs — 15단계)가 같이 쓴다 */
export function BlockAddress(): PropertyDecorator {
return applyDecorators(
TrimString(),
IsString({ message: '메일 주소나 @도메인 형식으로 써 주세요.' }),
@@ -4,8 +4,9 @@ import { validate } from 'class-validator';
import { MAX_MAILBOX_NAME_LENGTH } from '@/modules/mail/mail.constants';
import {
CreateMailboxDto,
MailboxDeleteQueryDto,
MailboxMessagesPatchDto,
RenameMailboxDto,
UpdateMailboxDto,
} from './mailbox.dto';
/** 전역 ValidationPipe 와 같은 옵션으로 검증한다 (app.setup.ts — stopAtFirstError 만 빼고 — 걸린 규칙을 모두 본다, 통과 · 실패는 같다) — 변환한 값과 걸린 규칙 이름 */
@@ -29,7 +30,7 @@ async function check<T extends object>(
describe('메일함 요청 본문 (계약 2절 "메일함 관리")', () => {
it.each([
['만들기', CreateMailboxDto],
['이름 바꾸기', RenameMailboxDto],
['바꾸기', UpdateMailboxDto],
] as const)(
`%s — 이름은 앞뒤 공백을 뺀 1~${MAX_MAILBOX_NAME_LENGTH}자 (JS 문자열 길이)`,
async (_name, type) => {
@@ -65,7 +66,7 @@ describe('메일함 요청 본문 (계약 2절 "메일함 관리")', () => {
it.each([
['만들기', CreateMailboxDto],
['이름 바꾸기', RenameMailboxDto],
['바꾸기', UpdateMailboxDto],
] as const)(
'%s — "/" 와 제어 문자(줄바꿈 · 탭 등)는 쓸 수 없다, 문자열이 아니면 형식 오류',
async (_name, type) => {
@@ -87,31 +88,72 @@ describe('메일함 요청 본문 (계약 2절 "메일함 관리")', () => {
expect((await check(type, { name: 7 })).constraints).toEqual(
expect.arrayContaining(['isString']),
);
expect((await check(type, {})).constraints).toEqual(
expect((await check(type, { name: null })).constraints).toEqual(
expect.arrayContaining(['isString']),
);
},
);
it('만들기의 상위는 없거나 null(맨 위) · 메일함 id 형식 — 이름 바꾸기에는 상위가 없다', async () => {
it('만들기는 이름이 없으면 형식 오류 — 바꾸기는 이름을 보내지 않아도 된다(빈 본문은 서비스가 VAL_001)', async () => {
expect((await check(CreateMailboxDto, {})).constraints).toEqual(
expect.arrayContaining(['isString']),
);
expect((await check(UpdateMailboxDto, {})).constraints).toEqual([]);
});
it.each([
['만들기', CreateMailboxDto, { name: '거래처' }],
['바꾸기(옮기기 — 15단계)', UpdateMailboxDto, {}],
] as const)(
'%s의 상위는 없거나 null(맨 위) · 메일함 id 형식',
async (_name, type, base) => {
expect((await check(type, base)).constraints).toEqual([]);
expect(
(await check(type, { ...base, parentId: null })).constraints,
).toEqual([]);
expect(
(await check(type, { ...base, parentId: 'a' })).constraints,
).toEqual([]);
for (const parentId of ['../a', '', 'a b', 7]) {
expect((await check(type, { ...base, parentId })).constraints).toEqual([
'matches',
]);
}
},
);
it('바꾸기는 이름 · 상위를 함께 받고, 보낸 값을 그대로 둔다 — null 상위는 null(맨 위), 보내지 않은 칸은 undefined', async () => {
const both = await check(UpdateMailboxDto, {
name: ' 2025 ',
parentId: 'u1',
});
expect(both.constraints).toEqual([]);
expect(both.dto).toMatchObject({ name: '2025', parentId: 'u1' });
const top = await check(UpdateMailboxDto, { parentId: null });
expect(top.constraints).toEqual([]);
expect(top.dto.parentId).toBeNull();
expect(top.dto.name).toBeUndefined();
expect(
(await check(CreateMailboxDto, { name: '거래처' })).constraints,
).toEqual([]);
(await check(UpdateMailboxDto, { name: '거래처', role: 'inbox' }))
.constraints,
).toEqual(['whitelistValidation']);
});
it('지우기의 쿼리는 withChildren=true 만 받는다 (15단계 — 하위 메일함까지)', async () => {
expect((await check(MailboxDeleteQueryDto, {})).constraints).toEqual([]);
expect(
(await check(CreateMailboxDto, { name: '거래처', parentId: null }))
(await check(MailboxDeleteQueryDto, { withChildren: 'true' }))
.constraints,
).toEqual([]);
for (const withChildren of ['false', 'TRUE', '1', '', true]) {
expect(
(await check(MailboxDeleteQueryDto, { withChildren })).constraints,
).toEqual(['isIn']);
}
expect(
(await check(CreateMailboxDto, { name: '거래처', parentId: 'a' }))
.constraints,
).toEqual([]);
expect(
(await check(CreateMailboxDto, { name: '거래처', parentId: '../a' }))
.constraints,
).toEqual(['matches']);
expect(
(await check(RenameMailboxDto, { name: '거래처', parentId: 'a' }))
.constraints,
(await check(MailboxDeleteQueryDto, { recursive: 'true' })).constraints,
).toEqual(['whitelistValidation']);
});
+70 -13
View File
@@ -1,6 +1,13 @@
import { applyDecorators } from '@nestjs/common';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { Equals, IsOptional, IsString, Matches } from 'class-validator';
import {
Equals,
IsIn,
IsOptional,
IsString,
Matches,
ValidateIf,
} from 'class-validator';
import {
MaxCodeUnits,
@@ -16,15 +23,32 @@ import {
/** 메일함 이름에 쓸 수 없는 '/' — 경로 구분자로 읽는 메일 프로그램이 있다 (계약 2절 "메일함 관리") */
const NO_SLASH_PATTERN = /^[^/]*$/;
/** 메일함 이름 — 앞뒤 공백을 뺀 1~MAX_MAILBOX_NAME_LENGTH 자(JS 문자열 길이), `/` · 제어 문자 없음. 만들기 · 이름 바꾸기가 같이 쓴다 */
function MailboxName() {
/** 메일함 이름 칸의 설명 */
const MAILBOX_NAME_DESCRIPTION = `메일함 이름 — 앞뒤 공백을 뺀 1~${MAX_MAILBOX_NAME_LENGTH}자(JS 문자열 길이). "/" 와 제어 문자(줄바꿈 · 탭 등)는 쓸 수 없다. 같은 상위 안에 같은 이름이 있으면 409 MAIL_009`;
/** 보낸 칸만 검사한다 — 없으면(undefined) 건너뛰고, null 은 검사해 형식 오류로 막는다 (이름 · 상위 가운데 바꿀 것만 보내는 PATCH) */
const unlessAbsent = (_object: object, value: unknown) => value !== undefined;
/**
* 메일함 이름 — 앞뒤 공백을 뺀 1~MAX_MAILBOX_NAME_LENGTH 자(JS 문자열 길이), `/` · 제어 문자 없음. 만들기 · 바꾸기가 같이 쓴다.
* `optional` 이면(바꾸기 — 15단계) 보내지 않아도 된다 — 보낸 값은 같은 검사를 한다(null 도 형식 오류)
*/
function MailboxName({ optional = false }: { optional?: boolean } = {}) {
return applyDecorators(
ApiProperty({
description: `메일함 이름 — 앞뒤 공백을 뺀 1~${MAX_MAILBOX_NAME_LENGTH}자(JS 문자열 길이). "/" 와 제어 문자(줄바꿈 · 탭 등)는 쓸 수 없다. 같은 상위 안에 같은 이름이 있으면 409 MAIL_009`,
example: '프로젝트',
minLength: 1,
maxLength: MAX_MAILBOX_NAME_LENGTH,
}),
optional
? ApiPropertyOptional({
description: `${MAILBOX_NAME_DESCRIPTION}. 보내지 않으면 이름은 그대로`,
example: '프로젝트',
minLength: 1,
maxLength: MAX_MAILBOX_NAME_LENGTH,
})
: ApiProperty({
description: MAILBOX_NAME_DESCRIPTION,
example: '프로젝트',
minLength: 1,
maxLength: MAX_MAILBOX_NAME_LENGTH,
}),
...(optional ? [ValidateIf(unlessAbsent)] : []),
TrimString(),
IsString({ message: '메일함 이름 형식이 올바르지 않습니다.' }),
MinCodeUnits(1, { message: '메일함 이름을 입력해 주세요.' }),
@@ -59,10 +83,43 @@ export class CreateMailboxDto {
parentId?: string | null;
}
/** PATCH /api/mailboxes/{id} — 이름만 바꾼다 (다른 상위로 옮기기는 이 단계에서 하지 않는다) */
export class RenameMailboxDto {
@MailboxName()
name!: string;
/**
* PATCH /api/mailboxes/{id} — 이름 · 상위(옮기기 — 15단계) 가운데 바꿀 것만. 둘 다 없으면(빈 본문) 서비스가 400 VAL_001.
* 상위의 자리 규칙(만들기와 같다) · 자기 자신 · 하위 아래로 옮기기는 어댑터가 본다
*/
export class UpdateMailboxDto {
@MailboxName({ optional: true })
name?: string;
@ApiPropertyOptional({
description:
'새 상위 메일함 id — null 이면 맨 위, 보내지 않으면 상위는 그대로. 만들기와 같은 자리만(받은편지함 · 보관함 · 내 메일함 아래 — 그 밖 400 VAL_001, 없는 id 는 404 RES_001). ' +
'자기 자신 · 자기 하위 아래로는 400 VAL_001. 하위 메일함과 안의 메일은 함께 간다',
nullable: true,
type: String,
example: 'a',
})
@IsOptional()
@Matches(MAIL_ID_PATTERN, { message: '옮길 곳을 다시 선택해 주세요.' })
parentId?: string | null;
}
/** 하위 메일함까지 지우기의 값 — 이것만 받는다 (15단계) */
export const WITH_CHILDREN = 'true';
/** DELETE /api/mailboxes/{id} 의 쿼리 — withChildren=true 면 하위 메일함까지 지운다 (15단계). 모르는 쿼리는 400 VAL_001 */
export class MailboxDeleteQueryDto {
@ApiPropertyOptional({
description:
'true 면 하위 메일함이 있어도 그 메일함과 하위 모두를 지운다(깊은 것부터 — 안의 메일은 휴지통으로). 없으면 하위가 있을 때 400 VAL_001(옛 화면이 모르고 하위를 지우지 않게). 다른 값은 400 VAL_001',
enum: [WITH_CHILDREN],
example: WITH_CHILDREN,
})
@IsOptional()
@IsIn([WITH_CHILDREN], {
message: '하위 메일함 지우기 값이 올바르지 않습니다.',
})
withChildren?: typeof WITH_CHILDREN;
}
/** PATCH /api/mailboxes/{id}/messages — 그 메일함 전체를 읽음으로 (true 만 받는다) */
@@ -1,7 +1,9 @@
import { BusinessException } from '@/common/errors/business.exception';
import type { MailFilterRunRequest } from '@/modules/mail/mail.types';
import {
decodeBlockRunCursor,
decodeFilterRunCursor,
encodeBlockRunCursor,
encodeFilterRunCursor,
MAX_FILTER_RUN_SECONDS,
type FilterRunPosition,
@@ -172,3 +174,62 @@ describe('이미 받은 메일에 필터 적용의 cursor (계약 2절 "이미
).toEqual({ before: NINE, anchor: null, anchorTime: null });
});
});
describe('차단할 때 받은편지함 정리의 cursor (15단계 — 계약 2절 "보낸 사람 차단")', () => {
const position: FilterRunPosition = {
before: NINE + 1,
anchor: 'seaaaaer',
anchorTime: NINE - 60,
};
it('자리는 필터 적용과 같은 모양으로 되돌린다 — 지문은 그 보낸 사람', () => {
for (const sender of ['kim@spam.example', '@spam.example']) {
expect(
decodeBlockRunCursor(encodeBlockRunCursor(position, sender), sender),
).toEqual(position);
}
expect(encodeBlockRunCursor(position, '@spam.example')).toMatch(
/^[A-Za-z0-9_-]+$/,
);
});
it('다른 보낸 사람의 cursor · 필터 적용의 cursor 는 VAL_001 — 거꾸로 차단 정리의 cursor 로 필터를 이어 하지도 못한다', () => {
const cursor = encodeBlockRunCursor(position, 'kim@spam.example');
for (const other of ['lee@spam.example', '@spam.example']) {
expect(failureOf(() => decodeBlockRunCursor(cursor, other))).toEqual({
code: 'VAL_001',
reason: 'invalid-mail-block-run-cursor:other-sender',
});
}
expect(
failureOf(() =>
decodeBlockRunCursor(
encodeFilterRunCursor(position, rule),
'kim@spam.example',
),
),
).toEqual({
code: 'VAL_001',
reason: 'invalid-mail-block-run-cursor:other-sender',
});
expect(failureOf(() => decodeFilterRunCursor(cursor, rule))).toEqual({
code: 'VAL_001',
reason: 'invalid-filter-run-cursor:other-rule',
});
});
it('깨진 cursor 는 필터 적용과 같은 검사로 VAL_001', () => {
for (const [cursor, reason] of [
['%%%', 'not-json'],
[forged({ v: 1, b: NINE }), 'shape'],
['a'.repeat(1_025), 'too-long'],
] as const) {
expect(
failureOf(() => decodeBlockRunCursor(cursor, 'kim@spam.example')),
).toEqual({
code: 'VAL_001',
reason: `invalid-mail-block-run-cursor:${reason}`,
});
}
});
});
@@ -17,7 +17,10 @@ import type { MailFilterRunRequest } from '@/modules/mail/mail.types';
* - anchorTime: 그 메일의 받은 시각(초) — 그 메일이 그사이 받은편지함에서 빠졌으면(`anchorNotFound`) `before: anchorTime + 1초` 의 처음부터 읽는다
* (그 초의 메일을 다시 판정한다 — 건너뛰지 않는다). 그렇게 읽은 차례에 남긴 메일이 없으면 anchor 는 없고 anchorTime 만 남는다
* - 규칙의 지문: 묶는 방법 · 조건 · 할 일 — 다른 규칙의 cursor 를 섞어 쓰지 못하게 (충돌보다는 "다른 규칙" 감지가 목적이라 짧게 둔다)
* 시각은 UTC 의 초(epoch)다 — 해석할 때 범위(0 ~ 9999-12-31)를 본다. 깨졌거나 다른 규칙의 것이면 400 VAL_001 — 엔진에 묻기 전에 본다
* 시각은 UTC 의 초(epoch)다 — 해석할 때 범위(0 ~ 9999-12-31)를 본다. 깨졌거나 다른 규칙의 것이면 400 VAL_001 — 엔진에 묻기 전에 본다.
*
* 차단할 때 받은편지함 정리(15단계 — 계약 2절 "보낸 사람 차단")도 같은 자리 · 같은 모양을 쓴다 — 지문만 그 보낸 사람(차단 목록의 값)이다.
* 지문의 재료가 달라(필터는 규칙의 세 칸, 차단은 표시 글 + 보낸 사람) 필터 적용의 cursor 를 차단 정리에 쓰면 다른 주인의 것으로 막힌다
*/
const CURSOR_VERSION = 2;
@@ -51,9 +54,20 @@ interface EncodedFilterRunCursor {
r: string;
}
/** 차단 정리 cursor 의 지문 재료 앞에 두는 표시 — 필터 규칙의 재료(첫 칸이 묶는 방법)와 섞이지 않게 */
const MAIL_BLOCK_SCOPE = 'mail-block';
/** 정해진 차례의 JSON 을 SHA-256 으로 — 짧게 자른다 */
function scopeHash(canonical: unknown[]): string {
return createHash('sha256')
.update(JSON.stringify(canonical), 'utf8')
.digest('base64url')
.slice(0, RULE_HASH_LENGTH);
}
/** 규칙의 지문 — 정해진 차례의 JSON 을 SHA-256 으로 (들어온 객체의 키 순서 · 다른 속성과 상관없다) */
function ruleHash(rule: FilterRunRule): string {
const canonical = JSON.stringify([
return scopeHash([
rule.match,
rule.conditions.map(({ field, operator, value }) => [
field,
@@ -62,15 +76,19 @@ function ruleHash(rule: FilterRunRule): string {
]),
[rule.actions.moveToMailboxId, rule.actions.markAsRead, rule.actions.flag],
]);
return createHash('sha256')
.update(canonical, 'utf8')
.digest('base64url')
.slice(0, RULE_HASH_LENGTH);
}
function invalidCursor(reason: string): BusinessException {
/** 차단 정리의 지문 — 보낸 사람(차단 목록의 값 — 소문자) */
function senderHash(sender: string): string {
return scopeHash([MAIL_BLOCK_SCOPE, sender]);
}
/** 깨진 cursor 의 로그 까닭 — 필터 적용 · 차단 정리를 가른다 */
type CursorKind = 'filter-run' | 'mail-block-run';
function invalidCursor(kind: CursorKind, reason: string): BusinessException {
return new BusinessException('VAL_001', {
context: { reason: `invalid-filter-run-cursor:${reason}` },
context: { reason: `invalid-${kind}-cursor:${reason}` },
});
}
@@ -84,50 +102,85 @@ function isSeconds(value: unknown): value is number {
);
}
export function encodeFilterRunCursor(
position: FilterRunPosition,
rule: FilterRunRule,
): string {
/** 자리와 지문을 cursor 로 */
function encodeRunCursor(position: FilterRunPosition, hash: string): string {
const payload: EncodedFilterRunCursor = {
v: CURSOR_VERSION,
b: position.before,
a: position.anchor,
t: position.anchorTime,
r: ruleHash(rule),
r: hash,
};
return Buffer.from(JSON.stringify(payload)).toString('base64url');
}
/** cursor 를 해석한다 — 형식 · 범위가 틀리거나 지문이 다르면(다른 주인의 cursor) VAL_001 */
function decodeRunCursor(
cursor: string,
hash: string,
kind: CursorKind,
): FilterRunPosition {
if (cursor.length > MAX_MAIL_FILTER_RUN_CURSOR_LENGTH) {
throw invalidCursor(kind, 'too-long');
}
let parsed: unknown;
try {
parsed = JSON.parse(Buffer.from(cursor, 'base64url').toString('utf8'));
} catch {
throw invalidCursor(kind, 'not-json');
}
const { v, b, a, t, r } = (parsed ?? {}) as Partial<EncodedFilterRunCursor>;
if (v !== CURSOR_VERSION || !isSeconds(b) || b === 0) {
throw invalidCursor(kind, 'shape');
}
if (!(a === null || (typeof a === 'string' && MAIL_ID_PATTERN.test(a)))) {
throw invalidCursor(kind, 'shape');
}
// anchor 가 있으면 그 받은 시각도 있다 — 위 경계보다 앞선다
if (!(t === null || (isSeconds(t) && t < b)) || (a !== null && t === null)) {
throw invalidCursor(kind, 'shape');
}
if (typeof r !== 'string') throw invalidCursor(kind, 'shape');
if (r !== hash) {
throw invalidCursor(
kind,
kind === 'filter-run' ? 'other-rule' : 'other-sender',
);
}
return { before: b, anchor: a, anchorTime: t };
}
export function encodeFilterRunCursor(
position: FilterRunPosition,
rule: FilterRunRule,
): string {
return encodeRunCursor(position, ruleHash(rule));
}
/** cursor 를 해석한다 — 형식 · 범위가 틀리거나 다른 규칙의 것이면 VAL_001 */
export function decodeFilterRunCursor(
cursor: string,
rule: FilterRunRule,
): FilterRunPosition {
if (cursor.length > MAX_MAIL_FILTER_RUN_CURSOR_LENGTH) {
throw invalidCursor('too-long');
}
let parsed: unknown;
try {
parsed = JSON.parse(Buffer.from(cursor, 'base64url').toString('utf8'));
} catch {
throw invalidCursor('not-json');
}
const { v, b, a, t, r } = (parsed ?? {}) as Partial<EncodedFilterRunCursor>;
if (v !== CURSOR_VERSION || !isSeconds(b) || b === 0) {
throw invalidCursor('shape');
}
if (!(a === null || (typeof a === 'string' && MAIL_ID_PATTERN.test(a)))) {
throw invalidCursor('shape');
}
// anchor 가 있으면 그 받은 시각도 있다 — 위 경계보다 앞선다
if (!(t === null || (isSeconds(t) && t < b)) || (a !== null && t === null)) {
throw invalidCursor('shape');
}
if (typeof r !== 'string') throw invalidCursor('shape');
if (r !== ruleHash(rule)) throw invalidCursor('other-rule');
return { before: b, anchor: a, anchorTime: t };
return decodeRunCursor(cursor, ruleHash(rule), 'filter-run');
}
/** 차단 정리의 cursor (15단계) — 자리는 필터 적용과 같고 지문은 그 보낸 사람 */
export function encodeBlockRunCursor(
position: FilterRunPosition,
sender: string,
): string {
return encodeRunCursor(position, senderHash(sender));
}
/** 차단 정리의 cursor 를 해석한다 — 형식 · 범위가 틀리거나 다른 보낸 사람(또는 필터 적용)의 것이면 VAL_001 */
export function decodeBlockRunCursor(
cursor: string,
sender: string,
): FilterRunPosition {
return decodeRunCursor(cursor, senderHash(sender), 'mail-block-run');
}
@@ -83,6 +83,33 @@ function decodedHeaders(email: JmapEmail, names: readonly string[]): string[] {
);
}
/**
* 차단할 때 받은편지함 정리(15단계)가 읽는 보낸 사람 — 엔진이 푼 From 머리글의 주소 목록, 같은 이름의 머리글이 여럿이면 모두(머리글마다 배열).
* Sieve 의 `address` 판정(jmap/sieve-script.ts 의 blockSection)이 견주는 것과 같다 — 표시 이름에 쓴 주소는 들지 않는다.
* 엔진은 물은 이름 그대로 답한다(Stalwart 0.16.27 — 2026-11-09 확인: `[[{ name, email }]]`)
*/
export const FROM_ADDRESSES_PROPERTY = 'header:From:asAddresses:all';
/** From 머리글들의 주소 — 물은 이름으로 찾되 대소문자가 달라도 찾는다. 모양이 틀린 칸은 건너뛴다 */
export function fromAddressesOf(email: JmapEmail): string[] {
const wanted = FROM_ADDRESSES_PROPERTY.toLowerCase();
for (const [key, value] of Object.entries(email)) {
if (key.toLowerCase() !== wanted || !Array.isArray(value)) continue;
return value.flatMap((header: unknown) =>
Array.isArray(header)
? header.flatMap((address: unknown) => {
const found =
typeof address === 'object' && address !== null
? (address as { email?: unknown }).email
: undefined;
return typeof found === 'string' ? [found] : [];
})
: [],
);
}
return [];
}
/** 머리글의 견줄 글 — 보낸 사람 · 받는 사람 · 제목 (본문은 bodyTextsOf) */
export function headerTextsOf(email: JmapEmail): Omit<MailFilterTexts, 'body'> {
const subject = email.subject;
@@ -11,6 +11,7 @@ import type {
} from '@/modules/mail/mail.types';
import {
decodeFilterRunCursor,
encodeBlockRunCursor,
encodeFilterRunCursor,
} from './filter-run-cursor';
import type { JmapClient } from './jmap.client';
@@ -1180,6 +1181,278 @@ describe('JmapFilterRunner — 이미 받은 메일에 필터 적용 (계약 2
).toMatchObject({ code: 'MAIL_001', reason: 'timeout' });
});
});
describe('차단할 때 받은편지함 정리 (runBlock — 15단계)', () => {
const JUNK = 'k';
const BLOCK_MAILBOXES: readonly Mailbox[] = [
mailbox(INBOX, 'inbox', 3),
mailbox(TARGET, null),
mailbox(JUNK, 'junk'),
];
const blockContext = (
overrides: Partial<MailFilterRunContext> = {},
): MailFilterRunContext => ({
mailboxes: BLOCK_MAILBOXES,
startedAt: Date.now(),
...overrides,
});
/** 받은편지함의 메일 — 엔진이 푼 From 머리글의 주소(머리글마다 배열) */
const fromMail = (
id: string,
...headers: { name: string | null; email: string }[][]
) => ({
id,
mailboxIds: { [INBOX]: true },
receivedAt: utc(T0),
'header:From:asAddresses:all': headers,
});
const KIM = { name: null, email: 'kim@spam.example' };
const junkPatch = {
[`mailboxIds/${INBOX}`]: null,
[`mailboxIds/${JUNK}`]: true,
'keywords/$junk': true,
'keywords/$notjunk': null,
};
it('받은편지함을 받은 시각 내림차순으로 읽어 엔진이 푼 From 주소로 판정하고, 맞은 메일을 스팸메일함으로 옮기며 스팸 표시 — 옮기기는 읽은 상태 그대로일 때만', async () => {
const { runner, recorded } = fakeRunner([
page(
['m1', 'm2', 'm3'],
[
fromMail('m1', [KIM]),
fromMail('m2', [{ name: null, email: 'lee@spam.example' }]),
fromMail('m3', [{ name: 'Kim', email: 'KIM@Spam.Example' }]),
],
),
updated(['m1', 'm3']),
]);
await expect(
runner.runBlock(
ARA,
{ sender: 'kim@spam.example', cursor: null },
blockContext(),
),
).resolves.toEqual({
scanned: 3,
matched: 2,
remaining: 0,
cursor: null,
rounds: 1,
});
expect(argsOf(recorded[0])).toMatchObject({
filter: { inMailbox: INBOX },
sort: [{ property: 'receivedAt', isAscending: false }],
limit: 500,
calculateTotal: true,
});
expect(argsOf(recorded[0], 1)).toMatchObject({
properties: [
'id',
'mailboxIds',
'receivedAt',
'header:From:asAddresses:all',
],
});
// 붙잡힌 메일을 같은 요청에서 묻는다 (9단계 — 옮기지 않는다)
expect(methodsOf([recorded[0]])).toEqual([
'Email/query',
'Email/get',
'EmailSubmission/query',
'EmailSubmission/get',
]);
expect(argsOf(recorded[1])).toEqual({
accountId: 'd',
ifInState: 's1',
update: { m1: junkPatch, m3: junkPatch },
});
});
it('판정은 차단 규칙(Sieve 의 address)과 같다 — 주소 전체가 같을 때 · From 이 여럿이면 하나라도 · 표시 이름의 주소 · 비슷한 주소는 아니다 (대소문자는 ASCII 만)', async () => {
const cases: [string, ReturnType<typeof fromMail>][] = [
['맞음', fromMail('m1', [KIM])],
[
'맞음',
fromMail('m2', [
{ name: 'First', email: 'first@example.net' },
{ name: 'Kim', email: 'kim@spam.example' },
]),
],
[
'맞음',
fromMail('m3', [{ name: null, email: 'first@example.net' }], [KIM]),
],
[
'아님',
fromMail('m4', [
{ name: 'kim@spam.example', email: 'other@example.net' },
]),
],
[
'아님',
fromMail('m5', [{ name: null, email: 'kim.lee@spam.example' }]),
],
[
'아님',
fromMail('m6', [{ name: null, email: 'kim@sub.spam.example' }]),
],
[
'아님',
fromMail('m7', [{ name: null, email: 'akim@spam.example' }]),
],
['아님', fromMail('m8')],
];
const { runner, recorded } = fakeRunner([
page(
cases.map(([, mail]) => mail.id),
cases.map(([, mail]) => mail),
),
updated(['m1', 'm2', 'm3']),
]);
await runner.runBlock(
ARA,
{ sender: 'kim@spam.example', cursor: null },
blockContext(),
);
expect(Object.keys(argsOf(recorded[1]).update as object)).toEqual(
cases
.filter(([verdict]) => verdict === '맞음')
.map(([, mail]) => mail.id),
);
});
it('도메인 차단(@도메인)은 From 주소의 @ 뒤가 그 도메인일 때만 — 하위 도메인 · 더 긴 도메인 · 표시 이름은 아니다', async () => {
const mails = [
fromMail('m1', [{ name: null, email: 'kim@spam.example' }]),
fromMail('m2', [{ name: null, email: 'Lee@SPAM.example' }]),
fromMail('m3', [{ name: null, email: 'kim@sub.spam.example' }]),
fromMail('m4', [{ name: null, email: 'kim@spam.example.net' }]),
fromMail('m5', [
{ name: '@spam.example', email: 'kim@other.example' },
]),
];
const { runner, recorded } = fakeRunner([
page(
mails.map(({ id }) => id),
mails,
),
updated(['m1', 'm2']),
]);
await runner.runBlock(
ARA,
{ sender: '@spam.example', cursor: null },
blockContext(),
);
expect(Object.keys(argsOf(recorded[1]).update as object)).toEqual([
'm1',
'm2',
]);
});
it('붙잡힌 메일(9단계)은 맞아도 옮기지 않는다 — 받은편지함에 남긴 것으로 세고 matched 에 넣지 않는다', async () => {
const later = utc(Math.floor(Date.now() / 1_000) + 3_600);
const { runner, recorded } = fakeRunner([
page(['m1', 'm2'], [fromMail('m1', [KIM]), fromMail('m2', [KIM])], {
held: [
{
id: 'sub1',
emailId: 'm2',
undoStatus: 'pending',
sendAt: later,
},
],
}),
updated(['m1']),
]);
await expect(
runner.runBlock(
ARA,
{ sender: 'kim@spam.example', cursor: null },
blockContext(),
),
).resolves.toMatchObject({ scanned: 2, matched: 1 });
expect(argsOf(recorded[1])).toMatchObject({
update: { m1: junkPatch },
});
expect(argsOf(recorded[1]).update).not.toHaveProperty('m2');
});
it('스팸메일함이 없으면 엔진에 묻지 않고 MAIL_001 · 받은편지함이 없으면 할 메일이 없다', async () => {
const noJunk = fakeRunner([]);
expect(
await failureOf(
noJunk.runner.runBlock(
ARA,
{ sender: 'kim@spam.example', cursor: null },
blockContext({
mailboxes: BLOCK_MAILBOXES.filter(
({ role }) => role !== 'junk',
),
}),
),
),
).toMatchObject({ code: 'MAIL_001', reason: 'junk-mailbox-missing' });
expect(noJunk.recorded).toHaveLength(0);
const noInbox = fakeRunner([]);
await expect(
noInbox.runner.runBlock(
ARA,
{ sender: 'kim@spam.example', cursor: null },
blockContext({
mailboxes: BLOCK_MAILBOXES.filter(({ role }) => role !== 'inbox'),
}),
),
).resolves.toEqual({
scanned: 0,
matched: 0,
remaining: 0,
cursor: null,
rounds: 0,
});
});
it('이어 할 자리 — 다른 보낸 사람의 cursor · 필터 적용의 cursor · 깨진 cursor 는 엔진에 묻기 전에 VAL_001', async () => {
const position = { before: T0 + 1, anchor: 'm1', anchorTime: T0 };
const others = [
encodeBlockRunCursor(position, 'lee@spam.example'),
encodeFilterRunCursor(position, request()),
'not-a-cursor',
];
for (const cursor of others) {
const fake = fakeRunner([]);
expect(
await failureOf(
fake.runner.runBlock(
ARA,
{ sender: 'kim@spam.example', cursor },
blockContext(),
),
),
).toMatchObject({ code: 'VAL_001' });
expect(fake.recorded).toHaveLength(0);
}
// 같은 보낸 사람의 cursor 는 그 자리부터 — anchor 다음 · 위 경계 미만
const fake = fakeRunner([page([], [])]);
await fake.runner.runBlock(
ARA,
{
sender: 'kim@spam.example',
cursor: encodeBlockRunCursor(position, 'kim@spam.example'),
},
blockContext(),
);
expect(argsOf(fake.recorded[0])).toMatchObject({
filter: { inMailbox: INBOX, before: utc(T0 + 1) },
anchor: 'm1',
anchorOffset: 1,
});
});
});
});
/**
@@ -1369,6 +1642,10 @@ describe('JmapFilterRunner — 이미 받은 메일에 필터 적용 (계약 2
),
receivedAt: utc(email.receivedAt),
'header:From:all': [` ${email.from}`],
// 엔진이 푼 주소 — 꺾쇠 안 (차단 정리 — 15단계)
'header:From:asAddresses:all': [
[{ name: null, email: /<([^>]+)>/.exec(email.from)?.[1] ?? '' }],
],
};
}
@@ -1706,5 +1983,68 @@ describe('JmapFilterRunner — 이미 받은 메일에 필터 적용 (계약 2
}
expect(engine.sorted(TARGET)).toHaveLength(700 - kept.length);
});
it('차단 정리(15단계)도 같은 틀로 끝까지 — 그 보낸 사람의 메일만 스팸메일함으로(스팸 표시) · 요청 사이에 온 새 메일은 건드리지 않는다', async () => {
const JUNK = 'k';
const engine = new FakeEngine();
fill(engine, 1_200);
let now = 1_000_000;
jest.spyOn(Date, 'now').mockImplementation(() => now);
const client = {
call: jest.fn(
(_user: string, build: (accountId: string) => JmapInvocation[]) => {
const calls = build('d');
if (calls[0][0] === 'Email/query') now += 21_000;
return Promise.resolve(engine.handle(calls));
},
),
};
const runner = new JmapFilterRunner(client as unknown as JmapClient);
const sender = 'sales@hanbit-partners.co.kr';
const wanted = engine
.sorted(INBOX)
.filter(({ from }) => from.includes(sender)).length;
let cursor: string | null = null;
let requests = 0;
let matched = 0;
do {
// 요청 사이에 그 보낸 사람의 새 메일이 온다 — 시작한 뒤 온 메일은 보지 않는다 (새 메일은 Sieve 가 이미 판정했다)
if (cursor !== null) engine.add(T0 + 10_000 + requests, HANBIT_FROM);
const result = await runner.runBlock(
ARA,
{ sender, cursor },
{
mailboxes: [
mailbox(INBOX, 'inbox', engine.sorted(INBOX).length),
mailbox(JUNK, 'junk'),
],
startedAt: now,
},
);
matched += result.matched;
cursor = result.cursor;
requests += 1;
now += 100_000;
} while (cursor !== null && requests < 100);
expect(cursor).toBeNull();
expect(requests).toBeGreaterThan(1);
expect(matched).toBe(wanted);
const moved = engine.sorted(JUNK);
expect(moved).toHaveLength(wanted);
for (const email of moved) {
expect(email.keywords.has('$junk')).toBe(true);
expect(email.receivedAt).toBeLessThan(T0 + 10_000);
}
// 받은편지함에 남은 그 보낸 사람의 메일은 요청 사이에 온 새 메일뿐
const left = engine
.sorted(INBOX)
.filter(({ from }) => from.includes(sender));
expect(left).toHaveLength(requests - 1);
expect(left.every(({ receivedAt }) => receivedAt >= T0 + 10_000)).toBe(
true,
);
});
});
});
+262 -38
View File
@@ -1,6 +1,7 @@
import { Injectable, Logger } from '@nestjs/common';
import { BusinessException } from '@/common/errors/business.exception';
import { isBlockedSenderMail } from '@/modules/mail/mail-block-match';
import {
filterFields,
judgeFilter,
@@ -8,6 +9,7 @@ import {
} from '@/modules/mail/mail-filter-match';
import type {
Mailbox,
MailBlockRunRequest,
MailFilterRunContext,
MailFilterRunOutcome,
MailFilterRunRequest,
@@ -15,15 +17,20 @@ import type {
import { BulkRun } from './bulk-run';
import {
MAX_FILTER_RUN_SECONDS,
decodeBlockRunCursor,
decodeFilterRunCursor,
encodeBlockRunCursor,
encodeFilterRunCursor,
type FilterRunPosition,
} from './filter-run-cursor';
import {
FILTER_RUN_BODY_PROPERTIES,
FILTER_RUN_PART_PROPERTIES,
FROM_ADDRESSES_PROPERTY,
bodyTextsOf,
estimatedBodyBytes,
filterRunProperties,
fromAddressesOf,
headerTextsOf,
textPartsOf,
} from './filter-run-texts';
@@ -97,6 +104,32 @@ const HELD_GUARD_CALLS = 'h';
/** 붙잡힌 메일이 없다 — 옮기지 않는 규칙 · 읽은 메일이 없는 쪽 */
const NO_HELD: HeldEmails = { ids: new Set(), complete: true };
/** 처음부터 — 위 경계 · 남긴 메일 · 그 받은 시각을 아직 모른다 (첫 차례가 위 경계를 잡는다) */
const FROM_TOP: RunState = { before: null, anchor: null, anchorTime: null };
/** 바꾼 것이 없다 — 맞은 메일이 없거나 모두 바꿀 것이 없는 차례 */
const NOTHING_APPLIED: Applied = { updated: new Set(), gone: new Set() };
/**
* 차단 정리(15단계)가 차례마다 읽는 속성 — 받은편지함에 아직 있는지 · 받은 시각(이어 할 자리) · 엔진이 푼 From 머리글의 주소(filter-run-texts.ts).
* 표시는 읽지 않는다 — 맞은 메일은 늘 옮기고 스팸 표시를 붙인다(이미 붙어 있어도 같은 패치)
*/
const BLOCK_RUN_PROPERTIES = [
'id',
'mailboxIds',
'receivedAt',
FROM_ADDRESSES_PROPERTY,
];
/** 받은편지함이 없는 계정 — 할 메일이 없다 (필터 적용 · 차단 정리 모두) */
const NOTHING_TO_RUN: MailFilterRunOutcome = {
scanned: 0,
matched: 0,
remaining: 0,
cursor: null,
rounds: 0,
};
/**
* 한 통의 본문을 부분마다 읽는 몫 (바이트) — 한 부분은 FILTER_RUN_BODY_VALUE_BYTES 까지, 한 통은 모두 합쳐 FILTER_RUN_MESSAGE_BODY_BYTES 까지.
* 글자 부분이 많으면 같은 몫씩 나눈다 (적어도 1바이트). 엔진의 maxBodyValueBytes 는 요청 단위라 몫이 다른 메일은 따로 읽는다
@@ -185,6 +218,37 @@ interface BodyBatch {
readonly ids: readonly string[];
}
/** 한 차례를 판정하고 할 일을 한다 — 필터 적용(applyRound) · 차단 정리(applyBlockRound). 멈춰야 하면 null */
type RoundRunner = (page: RoundPage) => Promise<RoundResult | null>;
/** 받은편지함을 차례로 훑은 결과 (sweep) */
interface SweepResult {
readonly scanned: number;
readonly matched: number;
readonly remaining: number;
/** 끝까지 했다 — 이어 할 자리가 없다 */
readonly finished: boolean;
/** 마지막으로 마친 차례까지의 자리 */
readonly position: RunState;
readonly rounds: number;
}
/** 판정 — true 맞음 · false 안 맞음 · 'gone' 받은편지함에서 빠진 메일(건너뛰고 세지 않는다) */
type Verdict = boolean | 'gone';
/**
* 차단 정리(15단계)의 패치 — 받은편지함에서 빼고 스팸메일함에 넣고 스팸 표시(`$junk` 를 붙이고 `$notjunk` 를 뗀다 — 스팸 신고 `isJunk: true` 와 같다,
* jmap-mail.provider.ts 의 toEmailPatch)
*/
function blockPatch(inboxId: string, junkId: string): Record<string, unknown> {
return {
[`mailboxIds/${inboxId}`]: null,
[`mailboxIds/${junkId}`]: true,
'keywords/$junk': true,
'keywords/$notjunk': null,
};
}
/** 옮길 메일함 — 규칙에 없거나 · 받은편지함이거나 · 메일함 목록에 없으면(지워짐) 옮기지 않는다 (계약 — 표시는 한다) */
function runPlan(
request: MailFilterRunRequest,
@@ -242,6 +306,41 @@ function utcDate(seconds: number): string {
return toJmapUtcDate(new Date(seconds * MS_PER_SECOND).toISOString());
}
/**
* 한 차례를 마친 결과 — 판정한 수(받은편지함에서 빠진 메일은 빼고)와 받은편지함에 남긴 마지막 메일(다음 차례의 anchor).
* 옮긴 메일(moving 가운데 바뀐 것)과 그사이 없어진 메일은 남긴 것이 아니다
* @param moving 옮기기가 든 패치를 보낸 메일 — 바뀌면 받은편지함을 떠난다
* @param matched 이 차례에서 할 일을 한 수 (필터 적용은 이미 표시가 있던 메일 포함 · 차단 정리는 옮긴 수)
*/
function roundResult(
page: RoundPage,
verdicts: ReadonlyMap<string, Verdict>,
moving: ReadonlySet<string>,
applied: Applied,
matched: number,
): RoundResult {
let judged = 0;
let lastStayed: string | null = null;
for (const id of page.ids) {
const verdict = verdicts.get(id);
if (verdict === undefined || verdict === 'gone') continue;
judged += 1;
const moved = moving.has(id) && applied.updated.has(id);
if (!moved && !applied.gone.has(id)) lastStayed = id;
}
return {
judged,
matched,
lastStayed:
lastStayed === null
? null
: {
id: lastStayed,
time: receivedSeconds(page.emails.get(lastStayed)),
},
};
}
/** 차례에서 가장 최근의 받은 시각(초) — 받은편지함 맨 위 메일 (읽은 메일이 없으면 null) */
function newestSecond(page: RoundPage): number | null {
let newest: number | null = null;
@@ -305,6 +404,9 @@ function bodyBatches(emails: readonly JmapEmail[]): BodyBatch[] {
* 진행(processed)은 판정한 메일 수다 — 판정한 것이 없는 차례는 멈춘 차례로 센다
* - 이어 할 자리는 cursor — 한 차례를 다 마친 곳까지만 (도중에 멈춘 차례는 다음 요청이 다시 한다 — 할 일은 다시 해도 같다)
* - 받은편지함 · 옮길 메일함은 서비스가 읽은 메일함 목록(context.mailboxes)에서 찾는다 — 다시 읽지 않는다
*
* 차단할 때 받은편지함 정리(runBlock — 15단계, 계약 2절 "보낸 사람 차단")도 같은 틀이다 — 차례 · 위 경계 · 자리 · 시간 · 멈춤 · 옮기기의 조건(ifInState) ·
* 붙잡힌 메일 막기는 그대로(sweep · readPage · applyPatches), 판정만 차단 규칙(Sieve 의 `address` — mail-block-match.ts)이고 할 일은 스팸메일함으로 옮기기 + 스팸 표시다
*/
@Injectable()
export class JmapFilterRunner {
@@ -318,17 +420,15 @@ export class JmapFilterRunner {
context: MailFilterRunContext,
): Promise<MailFilterRunOutcome> {
// 이어 할 자리는 엔진에 묻기 전에 본다 — 다른 규칙 · 깨진 cursor 면 VAL_001
let position: RunState =
const start: RunState =
request.cursor === null
? { before: null, anchor: null, anchorTime: null }
? FROM_TOP
: decodeFilterRunCursor(request.cursor, request);
const run = new BulkRun(context.startedAt);
const inbox = context.mailboxes.find((mailbox) => mailbox.role === 'inbox');
// 받은편지함이 없는 계정 — 적용할 메일이 없다
if (!inbox) {
return { scanned: 0, matched: 0, remaining: 0, cursor: null, rounds: 0 };
}
if (!inbox) return NOTHING_TO_RUN;
const plan = runPlan(request, inbox.id, context.mailboxes);
const fields = filterFields(request);
@@ -341,6 +441,78 @@ export class JmapFilterRunner {
withHeld: plan.moveTo !== null,
};
const swept = await this.sweep(user, inbox, start, read, run, (page) =>
this.applyRound(user, request, plan, page, run),
);
if (!swept.finished) run.assertProgress('filter-run');
return this.outcomeOf(swept, (position) =>
encodeFilterRunCursor(position, request),
);
}
/**
* 차단할 때 받은편지함 정리 (계약 2절 "보낸 사람 차단" — 15단계) — 받은편지함의 그 보낸 사람 메일을 스팸메일함으로 옮기고 스팸 표시를 붙인다.
* 판정은 엔진이 푼 From 머리글의 주소로 차단 규칙과 같게(mail-block-match.ts) — 머리글 원문을 읽지 않는다(표시 이름에 쓴 주소가 걸리지 않게).
* 맞은 메일은 늘 옮긴다(옮기기라 읽은 상태 그대로일 때만 — ifInState). 붙잡힌 메일(9단계)은 옮기지 않는다(받은편지함에는 없지만 지금 규칙대로).
* 스팸메일함이 없으면 엔진에 묻기 전에 MAIL_001, cursor 가 깨졌거나 다른 보낸 사람의 것이면 그 앞에 VAL_001.
* 응답의 matched 는 스팸메일함으로 옮긴 수다
*/
async runBlock(
user: string,
request: MailBlockRunRequest,
context: MailFilterRunContext,
): Promise<MailFilterRunOutcome> {
const start: RunState =
request.cursor === null
? FROM_TOP
: decodeBlockRunCursor(request.cursor, request.sender);
const run = new BulkRun(context.startedAt);
const inbox = context.mailboxes.find((mailbox) => mailbox.role === 'inbox');
if (!inbox) return NOTHING_TO_RUN;
const junk = context.mailboxes.find((mailbox) => mailbox.role === 'junk');
if (!junk) {
throw new BusinessException('MAIL_001', {
context: { reason: 'junk-mailbox-missing' },
});
}
const plan: RunPlan = {
inboxId: inbox.id,
moveTo: junk.id,
markAsRead: false,
flag: false,
};
const read: PageRead = {
properties: BLOCK_RUN_PROPERTIES,
needsBody: false,
limit: HEADER_ROUND_SIZE,
withHeld: true,
};
const swept = await this.sweep(user, inbox, start, read, run, (page) =>
this.applyBlockRound(user, request.sender, plan, junk.id, page, run),
);
if (!swept.finished) run.assertProgress('mail-block-run');
return this.outcomeOf(swept, (position) =>
encodeBlockRunCursor(position, request.sender),
);
}
/**
* 받은편지함을 받은 시각이 최근인 메일부터 차례로 훑는다 — 차례마다 읽고(readPage) 판정 · 할 일(applyRound)을 한 뒤 자리를 옮긴다.
* 한 요청의 시간 · 멈춤은 BulkRun. 처음 차례가 맨 위 메일의 받은 시각 + 1초를 위 경계로 잡는다(그 뒤에 온 메일은 보지 않는다).
* 남은 시간 때문에 끊긴 호출은 진행한 것이 있으면 거기까지로 답한다(cutOffAfterProgress)
*/
private async sweep(
user: string,
inbox: Mailbox,
start: RunState,
read: PageRead,
run: BulkRun,
applyRound: RoundRunner,
): Promise<SweepResult> {
let position = start;
let scanned = 0;
let matched = 0;
let rounds = 0;
@@ -359,7 +531,7 @@ export class JmapFilterRunner {
const newest = position.before === null ? newestSecond(page) : null;
if (newest !== null) position = { ...position, before: newest + 1 };
const round = await this.applyRound(user, request, plan, page, run);
const round = await applyRound(page);
// 멈춘다 — 이 차례는 하지 않은 것으로 (다음 요청이 같은 자리부터 다시 한다)
if (round === null) break;
@@ -387,22 +559,38 @@ export class JmapFilterRunner {
} catch (error) {
if (!run.cutOffAfterProgress(error)) throw error;
}
if (!finished) run.assertProgress('filter-run');
return {
scanned,
matched,
remaining: finished ? 0 : remaining,
cursor: finished ? null : this.cursorOf(position, request),
finished,
position,
rounds,
};
}
/** 훑은 결과 → 응답 — 끝까지 했으면 cursor 없음, 아니면 마친 자리의 cursor (cursorOf) */
private outcomeOf(
swept: SweepResult,
encode: (position: FilterRunPosition) => string,
): MailFilterRunOutcome {
return {
scanned: swept.scanned,
matched: swept.matched,
remaining: swept.remaining,
cursor: swept.finished ? null : this.cursorOf(swept.position, encode),
rounds: swept.rounds,
};
}
/**
* 이어 할 자리 — 위 경계와 남긴 마지막 메일의 받은 시각이 있어야 한다. 없으면 엔진이 받은 시각을 주지 않은 것 — MAIL_001
* (끝까지 했으면 이어 할 자리가 필요 없어 묻지 않는다)
*/
private cursorOf(position: RunState, request: MailFilterRunRequest): string {
private cursorOf(
position: RunState,
encode: (position: FilterRunPosition) => string,
): string {
if (
position.before === null ||
(position.anchor !== null && position.anchorTime === null)
@@ -411,14 +599,11 @@ export class JmapFilterRunner {
context: { reason: 'received-at-missing' },
});
}
return encodeFilterRunCursor(
{
before: position.before,
anchor: position.anchor,
anchorTime: position.anchorTime,
},
request,
);
return encode({
before: position.before,
anchor: position.anchor,
anchorTime: position.anchorTime,
});
}
/**
@@ -571,7 +756,7 @@ export class JmapFilterRunner {
run: BulkRun,
): Promise<RoundResult | null> {
/** 판정 — 'gone' 은 받은편지함에서 빠진 메일(건너뛰고 세지 않는다) */
const verdicts = new Map<string, boolean | 'gone'>();
const verdicts = new Map<string, Verdict>();
const undecided: JmapEmail[] = [];
const headerTexts = new Map<string, Omit<MailFilterTexts, 'body'>>();
for (const id of page.ids) {
@@ -641,30 +826,69 @@ export class JmapFilterRunner {
const applied: Applied | null =
patches.size === 0
? { updated: new Set(), gone: new Set() }
? NOTHING_APPLIED
: await this.applyPatches(user, plan, page.state, patches, run);
if (applied === null) return null;
let judged = 0;
let lastStayed: string | null = null;
return roundResult(
page,
verdicts,
moving,
applied,
unchanged + applied.updated.size,
);
}
/**
* 차단 정리의 한 차례 (15단계) — 엔진이 푼 From 주소로 판정하고(mail-block-match.ts) 맞은 메일을 스팸메일함으로 옮긴다(스팸 표시와 함께).
* 붙잡힌 메일(9단계)은 옮기지 않는다 — 받은편지함에 남은 것으로 센다. matched 는 옮긴 수다. 멈춰야 하면 null (applyPatches)
*/
private async applyBlockRound(
user: string,
sender: string,
plan: RunPlan,
junkId: string,
page: RoundPage,
run: BulkRun,
): Promise<RoundResult | null> {
const verdicts = new Map<string, Verdict>();
for (const id of page.ids) {
const verdict = verdicts.get(id);
if (verdict === undefined || verdict === 'gone') continue;
judged += 1;
const moved = moving.has(id) && applied.updated.has(id);
if (!moved && !applied.gone.has(id)) lastStayed = id;
const email = page.emails.get(id);
verdicts.set(
id,
email?.mailboxIds?.[plan.inboxId] !== true
? 'gone'
: isBlockedSenderMail(fromAddressesOf(email), sender),
);
}
return {
judged,
matched: unchanged + applied.updated.size,
lastStayed:
lastStayed === null
? null
: {
id: lastStayed,
time: receivedSeconds(page.emails.get(lastStayed)),
},
};
const matchedIds = page.ids.filter((id) => verdicts.get(id) === true);
const held =
matchedIds.length === 0
? new Set<string>()
: await heldAmong(
page.held,
matchedIds,
this.heldLookup(user, run.callOptions()),
);
const patches = new Map<string, Record<string, unknown>>();
for (const id of matchedIds) {
if (!held.has(id)) patches.set(id, blockPatch(plan.inboxId, junkId));
}
const applied: Applied | null =
patches.size === 0
? NOTHING_APPLIED
: await this.applyPatches(user, plan, page.state, patches, run);
if (applied === null) return null;
return roundResult(
page,
verdicts,
new Set(patches.keys()),
applied,
applied.updated.size,
);
}
/**
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -150,6 +150,8 @@ export interface JmapEmailSubmission {
id: string;
/** 이 제출이 보내는 메일 */
emailId?: string;
/** 이 제출의 발신 ID — 예약 메일 지금 보내기(15단계)가 같은 발신 ID 로 다시 제출한다 */
identityId?: string;
/**
* pending — 아직 붙잡고 있다(취소할 수 있다) · final — 보냈다 · canceled — 취소했다 (RFC 8621 §7).
* 붙잡지 않은 제출도 대기열에서 배달하는 동안 잠깐 pending 이다 — 붙잡은 것은 보낼 시각(sendAt)이 아직 오지 않은 것으로 가린다 (9단계)
@@ -0,0 +1,52 @@
import { asciiCasemap, isBlockedSenderMail } from './mail-block-match';
/**
* 차단할 때 받은편지함 정리(15단계)의 판정 — 새로 오는 메일의 차단 규칙(jmap/sieve-script.ts 의 blockSection — Sieve `address :all :is` ·
* `address :domain :is`, `i;ascii-casemap`)과 같은지. 표의 "엔진이 넓게 잡은 것"은 2026-11-09 개발 엔진의 `Email/query` from 조건이 잡은 메일이다(phase15 E3)
*/
describe('차단 정리의 판정 (mail-block-match — Sieve 의 address 와 짝)', () => {
it('ASCII 글자만 소문자로 — 그 밖의 글자는 그대로 (i;ascii-casemap)', () => {
expect(asciiCasemap('Kim@Spam.EXAMPLE')).toBe('kim@spam.example');
expect(asciiCasemap('ÄBC@Ü.example')).toBe('Äbc@Ü.example');
});
it.each<[string, string[], boolean]>([
['정확한 주소', ['kim@spam.example'], true],
['대문자 주소', ['KIM@SPAM.EXAMPLE'], true],
[
'보낸 사람 둘 가운데 둘째',
['first@example.net', 'kim@spam.example'],
true,
],
// 엔진 검색(from)은 잡지만 차단 규칙은 맞지 않는 것
['점이 더 붙은 주소', ['kim.lee@spam.example'], false],
['하위 도메인', ['kim@sub.spam.example'], false],
['더 긴 도메인', ['kim@spam.example.example.net'], false],
['앞에 글자가 더', ['akim@spam.example'], false],
['다른 도메인', ['kim@spam.example.net'], false],
['From 이 없음', [], false],
])('주소 차단 — %s', (_label, addresses, expected) => {
expect(isBlockedSenderMail(addresses, 'kim@spam.example')).toBe(expected);
});
it('주소 앞부분의 ASCII 밖 글자는 대소문자를 가린다 — 엔진 규칙과 같은 한계', () => {
expect(isBlockedSenderMail(['ü@spam.example'], 'ü@spam.example')).toBe(
true,
);
expect(isBlockedSenderMail(['Ü@spam.example'], 'ü@spam.example')).toBe(
false,
);
});
it.each<[string, string[], boolean]>([
['그 도메인', ['kim@spam.example'], true],
['대문자 도메인', ['Lee@SPAM.Example'], true],
['둘째 보낸 사람이 그 도메인', ['a@example.net', 'b@spam.example'], true],
['하위 도메인', ['kim@sub.spam.example'], false],
['더 긴 도메인', ['kim@spam.example.net'], false],
['도메인이 이름 끝에만', ['kim@notspam.example'], false],
['@ 가 없는 주소', ['spam.example'], false],
])('도메인 차단 — %s', (_label, addresses, expected) => {
expect(isBlockedSenderMail(addresses, '@spam.example')).toBe(expected);
});
});
@@ -0,0 +1,40 @@
/**
* 차단할 때 받은편지함 정리(계약 2절 "보낸 사람 차단" — 15단계)의 판정 — 순수 함수.
* **짝: jmap/sieve-script.ts 의 blockSection**(새로 오는 메일의 차단 규칙) — `address :all :is "from" [주소들]` ·
* `address :domain :is "from" [도메인들]`, 비교는 엔진 기본 `i;ascii-casemap`(ASCII 대소문자만 무시). 한쪽을 바꾸면 다른 쪽도.
*
* 견주는 것은 엔진이 푼 From 머리글의 주소다(같은 이름의 머리글이 여럿이면 모두 — 표시 이름은 보지 않는다, jmap/filter-run-texts.ts 의 fromAddressesOf).
* 엔진의 검색(`Email/query` 의 from)은 쓰지 않는다 — 낱말 단위라 비슷한 주소(`kim.lee@` · `@sub.` · `.example.net`)와
* 표시 이름에만 그 주소를 쓴 남의 메일까지 잡는다(2026-11-09 개발 엔진 확인)
*/
/** ASCII 글자만 소문자로 — 엔진의 `i;ascii-casemap` (그 밖의 글자는 그대로 견준다) */
export function asciiCasemap(value: string): string {
return value.replace(/[A-Z]/g, (char) => char.toLowerCase());
}
/** 주소의 도메인 — 마지막 `@` 뒤 (없으면 빈 글) */
function domainOf(address: string): string {
const at = address.lastIndexOf('@');
return at < 0 ? '' : address.slice(at + 1);
}
/**
* 그 메일의 From 주소 가운데 하나라도 차단 대상에 맞는가 — 주소 차단(`kim@spam.example`)은 주소 전체가 같을 때,
* 도메인 차단(`@spam.example`)은 주소의 도메인이 같을 때(하위 도메인 · 더 긴 도메인은 아님)
* @param fromAddresses 엔진이 푼 From 머리글의 주소들
* @param sender 차단 목록의 값 (소문자 주소 또는 `@도메인` — BlockedSender.address)
*/
export function isBlockedSenderMail(
fromAddresses: readonly string[],
sender: string,
): boolean {
const wanted = asciiCasemap(sender);
if (wanted.startsWith('@')) {
const domain = wanted.slice(1);
return fromAddresses.some(
(address) => asciiCasemap(domainOf(address)) === domain,
);
}
return fromAddresses.some((address) => asciiCasemap(address) === wanted);
}
@@ -3,6 +3,7 @@ import {
Controller,
Delete,
Get,
HttpCode,
HttpStatus,
Param,
Post,
@@ -28,17 +29,23 @@ import {
MailBlockListDto,
MailBlockRemovedDto,
} from './dto/mail-block.dto';
import {
MailBlockRunDto,
MailBlockRunRequestDto,
} from './dto/mail-block-run.dto';
import { MailBlockService } from './mail-block.service';
import {
MAIL_BLOCK_RUNS_ROUTE,
MAIL_BLOCKS_ROUTE,
MAX_EMAIL_LENGTH,
MAX_MAIL_BLOCKS,
} from './mail.constants';
import { MailService } from './mail.service';
/** 차단의 효과 — 세 엔드포인트의 설명에 함께 쓴다 (계약 2절 "보낸 사람 차단") */
const BLOCK_NOTE =
'차단한 보낸 사람(머리글 From 의 주소 · 도메인 — 대소문자 무시)의 메일은 스팸메일함으로 간다. 필터 · 부재중보다 먼저 돌고 멈추므로 필터는 돌지 않고 부재중 응답도 나가지 않는다. ' +
'이미 받은 메일은 옮기지 않는다. 저장 자리는 필터 · 부재중과 같은 엔진 규칙(Sieve 스크립트)이다 — 필터의 version 은 묻지 않는다.';
'이미 받은 메일은 저절로 옮기지 않는다 — 받은편지함에 있는 것은 POST /api/mail-block-runs 로 옮긴다(15단계). 저장 자리는 필터 · 부재중과 같은 엔진 규칙(Sieve 스크립트)이다 — 필터의 version 은 묻지 않는다.';
// 보낸 사람 차단 (계약 2절 "보낸 사람 차단", 9단계)
@ApiTags('Mail')
@@ -109,3 +116,52 @@ export class MailBlockController {
return this.blocks.remove(user, address);
}
}
// 차단할 때 받은편지함 정리 (계약 2절 "보낸 사람 차단", 15단계) — 이미 받은 메일에 필터 적용(7단계)과 같은 틀 · 같은 잠금
@ApiTags('Mail')
@ApiCookieAuth()
@UseGuards(JwtAuthGuard)
@ApiSessionErrors('로그인 필요 (쿠키 없음·만료)')
@Controller(MAIL_BLOCK_RUNS_ROUTE)
export class MailBlockRunController {
constructor(private readonly mail: MailService) {}
@Post()
@HttpCode(HttpStatus.OK)
@ApiOperation({
summary: '차단한 보낸 사람의 메일을 받은편지함에서 스팸메일함으로',
description:
'받은편지함에 이미 있는 그 보낸 사람의 메일을 스팸메일함으로 옮기고 스팸 표시를 붙인다($junk — 스팸 신고와 같다). 받은편지함만 본다(정리해 둔 메일함은 건드리지 않는다). ' +
'판정은 새로 오는 메일의 차단 규칙과 같다 — 주소는 머리글 From 의 주소 가운데 하나가 같을 때, 도메인은 From 주소의 @ 뒤가 그 도메인과 같을 때(하위 도메인은 아님), 대소문자는 ASCII 만 무시. ' +
'표시 이름에 쓴 주소 · 비슷한 주소는 맞지 않는다. 보내기를 기다리는 메일은 옮기지 않는다. 되돌리기는 없다(스팸메일함에서 스팸 아님으로). ' +
'받은 시각이 최근인 메일부터, 첫 요청 뒤에 온 메일은 보지 않는다. 한 요청은 25초 안에 끝내고 cursor 를 돌려준다 — null 이 될 때까지 같은 본문에 받은 cursor 를 넣어 다시 보낸다. ' +
'같은 직원의 받은편지함 정리(이것 · POST /api/mail-filter-runs)는 한 번에 하나다 — 진행 중이면 엔진에 묻지 않고 429 REQ_001. ' +
'요청이 도는 동안 그 직원의 스팸메일함을 잡는다 — 그동안 스팸메일함 비우기 · 전체 읽음은 429, 비우는 중이면 이 요청이 메일함 목록을 읽은 뒤 429(받은편지함은 건드리지 않았다).',
})
@ApiStandardResponse(MailBlockRunDto, {
description:
'이번 요청에서 확인한 수 · 스팸메일함으로 옮긴 수 · 남은 수(어림) · 이어 할 자리(끝이면 null)',
})
@ApiErrorResponse(
'VAL_001',
`형식(메일 주소나 @도메인 · ${MAX_EMAIL_LENGTH}자) · 깨진 cursor · 다른 보낸 사람의 cursor · 모르는 필드 — 옮기지 않았다`,
)
@ApiErrorResponse(
'RES_001',
'차단 목록에 없는 보낸 사람(차단한 뒤에만 부른다 · 그사이 풀었다) — 옮기지 않았다',
)
@ApiErrorResponse(
'REQ_001',
'같은 직원의 받은편지함 정리(필터 적용 포함)가 진행 중(다른 탭 · PC) — 엔진에 묻지 않았다 · 스팸메일함 비우기가 진행 중 — 옮기지 않았다',
)
@ApiErrorResponse(
'MAIL_001',
'메일 엔진 장애 · 스팸메일함이 없음 · 하나도 확인하지 못하고 멈춤(시간 · 확인하는 사이 받은편지함이 계속 바뀜). 도중의 엔진 장애면 일부를 옮겼을 수 있다 — 마지막으로 받은 cursor 로 다시 보내면 된다',
)
run(
@CurrentUser() user: AuthenticatedUser,
@Body() dto: MailBlockRunRequestDto,
) {
return this.mail.runMailBlock(user, dto);
}
}
@@ -6,19 +6,14 @@ import { traceTag } from '@/shared/logger/request-context';
import { maskEmail } from '@/shared/utils/mask';
import type { MailBlockCreateDto } from './dto/mail-block.dto';
import {
describeBlockTarget,
findBlockProblem,
parseBlockTarget,
type BlockTarget,
} from './mail-blocks';
import { MAIL_PROVIDER, type MailProvider } from './mail.provider';
import { MAIL_SETTINGS, type MailSettings } from './mail.settings';
import type { MailBlockAdded, MailBlockList } from './mail.types';
/** 로그에 남기는 차단 대상 — 주소는 가리고(maskEmail), 도메인 차단은 도메인만 (계약 2절 "보낸 사람 차단"의 로그) */
function maskedTarget(target: BlockTarget): string {
return target.kind === 'domain' ? target.value : maskEmail(target.value);
}
/**
* 보낸 사람 차단 (계약 2절 "보낸 사람 차단" — 9단계) — 엔진과 무관한 규칙(막을 수 없는 대상)은 여기서, 저장(엔진 규칙)은 포트가 한다.
* - 모양은 요청 검증이 봤다 — 여기서는 내 주소 · 사내 도메인(MAIL_DOMAIN 과 그 하위) · 여러 사람이 쓰는 메일 서비스의 도메인을 막는다(VAL_001)
@@ -65,7 +60,7 @@ export class MailBlockService {
});
if (result.created) {
this.logger.log(
`${traceTag()} 보낸 사람을 차단했습니다: user=${maskEmail(user.email)}, ${target.kind === 'domain' ? '도메인' : '주소'}=${maskedTarget(target)}`,
`${traceTag()} 보낸 사람을 차단했습니다: user=${maskEmail(user.email)}, ${describeBlockTarget(target)}`,
);
}
return result;
@@ -84,7 +79,7 @@ export class MailBlockService {
}
await this.provider.removeMailBlock(user.email, target.value);
this.logger.log(
`${traceTag()} 보낸 사람 차단을 풀었습니다: user=${maskEmail(user.email)}, ${target.kind === 'domain' ? '도메인' : '주소'}=${maskedTarget(target)}`,
`${traceTag()} 보낸 사람 차단을 풀었습니다: user=${maskEmail(user.email)}, ${describeBlockTarget(target)}`,
);
return { address: target.value };
}
+11
View File
@@ -1,6 +1,7 @@
import { isEmail } from 'class-validator';
import { isIP } from 'net';
import { maskEmail } from '@/shared/utils/mask';
import {
MAX_EMAIL_LENGTH,
PUBLIC_MAIL_DOMAINS,
@@ -88,6 +89,16 @@ export function blockTargetDomain(target: BlockTarget): string {
return target.value.slice(target.value.lastIndexOf('@') + 1);
}
/**
* 로그에 남기는 차단 대상 — `주소=k***@spam.example` · `도메인=@spam.example` (계약 2절 "보낸 사람 차단"의 로그 — 주소는 가리고(maskEmail),
* 도메인 차단은 도메인만). 차단 · 풀기 · 받은편지함 정리(15단계)가 같이 쓴다
*/
export function describeBlockTarget(target: BlockTarget): string {
return target.kind === 'domain'
? `도메인=${target.value}`
: `주소=${maskEmail(target.value)}`;
}
/** 사내 도메인이거나 그 하위 도메인인가 */
function isInternalDomain(domain: string, internal: string): boolean {
return domain === internal || domain.endsWith(`.${internal}`);
@@ -147,6 +147,7 @@ function setup() {
url: shareUrl(ORIGIN, TOKEN),
}),
markSent: jest.fn().mockResolvedValue(undefined),
markReleased: jest.fn().mockResolvedValue(true),
cancel: jest.fn().mockResolvedValue(undefined),
attachRetry: jest.fn().mockResolvedValue(undefined),
findBySentEmail: jest.fn().mockResolvedValue(null),
@@ -389,6 +390,31 @@ describe('MailLargeFilesService — 메일과 대용량 첨부 (11단계)', () =
FILE_B,
]);
});
it('예약 메일을 지금 보냈으면(15단계) 그 링크의 보낸 때를 앞당긴다 — 링크가 없거나 취소한 링크면 그대로, 실패해도 던지지 않는다', async () => {
const { service, sharing } = setup();
const now = new Date('2026-11-09T05:00:00.000Z');
sharing.findBySentEmail.mockResolvedValue(record());
await service.onScheduledSendReleased(ARA, 'sent-1', now);
expect(sharing.findBySentEmail).toHaveBeenCalledWith(ARA.email, 'sent-1');
expect(sharing.markReleased).toHaveBeenCalledWith('link-1', now);
sharing.markReleased.mockClear();
for (const found of [
null,
record({ canceledAt: new Date('2026-11-09T04:00:00.000Z') }),
]) {
sharing.findBySentEmail.mockResolvedValueOnce(found);
await service.onScheduledSendReleased(ARA, 'sent-1', now);
}
expect(sharing.markReleased).not.toHaveBeenCalled();
sharing.findBySentEmail.mockRejectedValueOnce(new Error('db down'));
await expect(
service.onScheduledSendReleased(ARA, 'sent-1', now),
).resolves.toBeUndefined();
});
});
describe('읽기 — 덩이 알아보기', () => {
@@ -303,6 +303,29 @@ export class MailLargeFilesService implements OnModuleInit, OnModuleDestroy {
}
}
/**
* 예약 메일을 지금 보냈다(15단계 — 엔진에서 다시 제출한 뒤) — 그 발송이 만든 링크의 보낸 때를 실제로 보낸 때로 앞당긴다.
* 끝나는 때는 그대로 둔다 — 보낸 메일의 덩이가 그 날짜(예약 시각 기준)를 이미 적었다(FileSharingService.markReleased). 링크가 없거나 취소한 링크면 그대로.
* 실패해도 보내기는 된 것이다 — 경고만
*/
async onScheduledSendReleased(
user: AuthenticatedUser,
sentId: string,
sentAt: Date,
): Promise<void> {
try {
const record = await this.sharing.findBySentEmail(user.email, sentId);
if (!record || record.link.canceledAt !== null) return;
await this.sharing.markReleased(record.link.id, sentAt);
} catch (error) {
this.warn(
'지금 보낸 예약 메일의 링크에 보낸 때를 적지 못했습니다',
user,
error,
);
}
}
// ── 임시 저장 ────────────────────────────────────────────────────────────
/** 저장할 파일 확인 — 내 것이면 올리는 중 · 위험 판정이어도 된다. 없음 · 남의 것 · 지움 · 기간 지남은 400 MAIL_016 */
@@ -151,6 +151,9 @@ export const SCHEDULED_SEND_MAX_LEAD_MS = 7 * 24 * 60 * 60_000;
/** 보낸 사람 차단 라우트 경로 (`/api/mail-blocks`) — 계약 2절 "보낸 사람 차단" (9단계) */
export const MAIL_BLOCKS_ROUTE = 'mail-blocks';
/** 차단할 때 받은편지함 정리 라우트 경로 (`/api/mail-block-runs`) — 계약 2절 "보낸 사람 차단" (15단계) */
export const MAIL_BLOCK_RUNS_ROUTE = 'mail-block-runs';
/** 차단할 수 있는 주소 · 도메인 수 — 넘으면 400 VAL_001 (계약 2절 "보낸 사람 차단") */
export const MAX_MAIL_BLOCKS = 500;
+6 -1
View File
@@ -34,7 +34,10 @@ import { JmapFilterRunner } from './jmap/jmap-filter-run';
import { JmapFilterScriptStore } from './jmap/jmap-filter-script';
import { JmapMailProvider } from './jmap/jmap-mail.provider';
import { MailAttachmentController } from './mail-attachment.controller';
import { MailBlockController } from './mail-block.controller';
import {
MailBlockController,
MailBlockRunController,
} from './mail-block.controller';
import { MailBlockService } from './mail-block.service';
import { MailEventsController } from './mail-events.controller';
import {
@@ -148,6 +151,8 @@ function attachmentUploadOptions(config: ConfigService): MulterModuleOptions {
MailFilterController,
MailFilterRunController,
MailBlockController,
// 차단할 때 받은편지함 정리 (15단계 — 필터 적용과 같은 잠금)
MailBlockRunController,
MessageController,
// 같은 대화 · 대화로 묶은 목록 (14단계)
ConversationController,
+61 -3
View File
@@ -13,9 +13,12 @@ import type {
MailAttachment,
MailBlockAdded,
MailBlockList,
MailBlockRunRequest,
Mailbox,
MailboxBulkResult,
MailboxChange,
MailboxCreate,
MailboxDeleteOptions,
MailboxDeletion,
MailFilterRunContext,
MailFilterRunOutcome,
@@ -40,7 +43,9 @@ import type {
PasswordChangeOutcome,
RecipientFields,
RejectedRecipient,
ReleasedScheduledSend,
SavedDraft,
SendHold,
SentMessage,
SentMessageRecord,
SummariesWithHeld,
@@ -68,6 +73,14 @@ export const MAIL_PROVIDER = Symbol('MAIL_PROVIDER');
*/
export class SendNotStartedException extends BusinessException {}
/**
* 예약 메일 지금 보내기(15단계)가 끝내 보내지 못해 그 메일을 임시보관함의 초안으로 **되돌렸다** — 예약 취소(DELETE /api/scheduled-messages/{id})와 같은 자리다.
* 되돌리기까지 된 때만 이 형식이다(엔진이 다시 제출을 거듭 거절함 MAIL_001 · 받는 사람이 모두 받지 않음 MAIL_010 · MAIL_011). 코드 · 응답은 BusinessException 그대로다.
* 결과를 모르는 실패 · 남은 시간 모자람 · 되돌리기 실패는 그냥 BusinessException(MAIL_001)이다 — 메일은 보낸편지함에 그대로다.
* 서비스는 이 형식으로 갈라 대용량 첨부 링크를 취소한 발송처럼 둔다(되돌린 초안이 그 파일을 쓴다) — context 는 로그 전용이라 갈래에 쓰지 않는다
*/
export class ScheduledSendReturnedToDraftsException extends BusinessException {}
/**
* 그 직원의 비밀번호가 만료됐다 (13단계 — 계약 6절 "비밀번호 바꾸기 강제") — 엔진은 그 계정의 권한을 비밀번호 바꾸기만으로 줄여 메일 기능이 없다
* (본인 Basic · 게이트웨이 대리 접근 모두 — 2026-11-02 개발 엔진 확인). 프로필 · 메일 호출(엔진 세션을 열거나 메일 메서드가 forbidden)이 이 예외로 던진다.
@@ -148,16 +161,30 @@ export interface MailProvider {
*/
createMailbox(user: string, input: MailboxCreate): Promise<Mailbox>;
/** 메일함 이름을 바꾼다 — 없으면 RES_001, 역할이 있는 메일함(기본 메일함)이면 VAL_001, 같은 이름이 있으면 MAIL_009 */
renameMailbox(user: string, id: string, name: string): Promise<Mailbox>;
/**
* 메일함의 이름 · 상위를 바꾼다 (15단계 — 옮기기) — 보내지 않은 칸은 그대로. 없으면 RES_001, 역할이 있는 메일함(기본 메일함)이면 VAL_001,
* 새 상위가 없으면 RES_001 · 만들 수 없는 자리(만들기와 같다)이거나 자기 자신 · 자기 하위면 VAL_001, 새 자리에 같은 이름이 있으면 MAIL_009.
* 이름 · 상위가 지금과 같으면 엔진에 묻지 않고 그대로 돌려준다. 하위 메일함과 안의 메일은 함께 간다(메일함 id 는 그대로다)
*/
updateMailbox(
user: string,
id: string,
change: MailboxChange,
): Promise<Mailbox>;
/**
* 메일함을 지운다 — 안의 메일을 휴지통으로 옮긴 뒤(다른 메일함에도 있는 메일은 이 메일함에서만 뺀다) 빈 메일함을 지운다.
* 메일을 영구 삭제하지 않는다. 한 요청에 다 옮기지 못하면 deleted: false (클라이언트가 다시 보낸다).
* 없으면 RES_001, 역할이 있는 메일함 · 하위 메일함이 있으면 VAL_001.
* `withChildren` 이면(15단계) 하위가 있어도 나무 전체(그 메일함과 하위 모두)를 같은 규칙으로 — 메일을 옮긴 뒤 깊은 것부터 지운다.
* 하위에 역할이 있는 메일함이 있으면 VAL_001. 수(processed · movedToTrash · remaining)는 나무 전체의 것이다.
* 붙잡힌 메일(보내기 취소 · 예약을 기다림 — 9단계)은 옮기지 않는다 — 남은 것이 그것뿐이고 이 요청에서 옮긴 것이 없으면 MAIL_015
*/
deleteMailbox(user: string, id: string): Promise<MailboxDeletion>;
deleteMailbox(
user: string,
id: string,
options?: MailboxDeleteOptions,
): Promise<MailboxDeletion>;
/** 메일함의 안 읽은 메일을 읽음으로 바꾼다 (한 요청의 시간 안에서 차례로) — 없으면 RES_001 */
markMailboxRead(user: string, id: string): Promise<MailboxBulkResult>;
@@ -224,6 +251,18 @@ export interface MailProvider {
context: MailFilterRunContext,
): Promise<MailFilterRunOutcome>;
/**
* 차단할 때 받은편지함 정리 (계약 2절 "보낸 사람 차단" — 15단계) — 받은편지함에 이미 있는 그 보낸 사람의 메일을 스팸메일함으로 옮기고 스팸 표시를 한다.
* 판정은 새로 오는 메일의 차단 규칙(Sieve 의 `address` 판정 — mail-block-match.ts)과 같다. 차례 · 시간 · cursor 는 runMailFilter 와 같은 규칙이다
* (matched = 스팸메일함으로 옮긴 수). 스팸메일함이 없으면 MAIL_001, cursor 가 깨졌거나 다른 보낸 사람의 것이면 엔진에 묻기 전에 VAL_001.
* 차단 목록에 있는지는 서비스가 본 값이다. 받은편지함 · 스팸메일함은 context.mailboxes 에서 찾는다
*/
runMailBlock(
user: string,
request: MailBlockRunRequest,
context: MailFilterRunContext,
): Promise<MailFilterRunOutcome>;
/** 메일함 사용량과 한도 (한도가 없으면 limitBytes = null) */
getUsage(user: string): Promise<MailUsage>;
@@ -391,6 +430,25 @@ export interface MailProvider {
*/
cancelPendingSend(user: string, id: string): Promise<void>;
/**
* 예약한 메일을 지금 보낸다 (계약 2절 "보내기 취소 · 예약 발송" — 15단계) — `id` 는 보낸 사본의 메일 id.
* 기다리는 예약 제출을 취소하고(엔진이 확인한 뒤에만) 같은 메일을 그 제출의 envelope 그대로 다시 제출한다 — `hold` 만큼 붙잡는다(보내기 취소 설정 — null 이면 바로).
* 예약 표시를 떼고, 받는 사람별 결과는 발송과 같은 규칙으로 읽는다.
* - 예약이 아닌 메일(보내기 취소를 기다리는 메일 · 취소해 임시보관함으로 간 메일 · 제출이 없음) · 없는 메일 → RES_001 (아무것도 바꾸지 않는다)
* - 이미 보냈다(예약 시각이 막 지남 — 제출이 final · 취소하려니 엔진이 받지 않음) → 오류가 아니다: pendingSend null (releasedAt null)
* - 이미 지금 보내기를 한 메일(취소한 예약 제출 + 그 뒤 새 제출 — 다시 누름 · 응답이 끊겨 다시 보냄) → 그 새 제출의 지금 상태
* - 엔진이 다시 제출을 분명히 거절하면 한 번 더 — 그래도 거절이면 그 메일을 임시보관함으로 되돌린 뒤 ScheduledSendReturnedToDraftsException(MAIL_001).
* 받는 사람이 모두 받지 않았어도 임시보관함으로 되돌린 뒤 같은 예외(MAIL_010 · MAIL_011). 되돌리지 못하면 그냥 MAIL_001
* - 다시 제출의 결과를 모르면(시간 초과 · 연결 끊김 · 5xx) 다시 내지도 · 되돌리지도 않는다 — 다시 읽어 새 제출이 보이면 그 상태, 아니면 그냥 MAIL_001.
* 남은 시간이 모자라 내지 않았어도 그냥 MAIL_001 — 둘 다 메일은 예약이 취소된 채 보낸편지함에 있고, 같은 요청을 다시 받으면 이어 낸다
* - 예약 제출의 envelope · 발신 ID 를 읽지 못하거나 엔진이 취소를 확인해 주지 않으면 MAIL_001 — 취소하지 않았거나 취소를 확인하지 못해 다시 제출하지 않았다
*/
releaseScheduledSend(
user: string,
id: string,
hold: SendHold | null,
): Promise<ReleasedScheduledSend>;
/**
* 이미 보낸 메일의 받지 않은 사람 (같은 키의 재시도 — 계약 2절 "받는 사람별 결과") — 그 메일의 제출 기록을
* 이 요청의 받는 사람(to · cc · bcc)과 맞춰 다시 만든다. 제출 기록이 없거나 읽지 못하면 빈 배열 — 던지지 않는다 (메일은 이미 보냈다)
+358 -8
View File
@@ -9,6 +9,7 @@ import type { MailUserSettingsService } from '@/modules/mail-user-settings/mail-
import type { MailUserSettings } from '@/modules/mail-user-settings/mail-user-settings.types';
import type { ContactsService } from '@/modules/contacts/contacts.service';
import type { DirectoryService } from '@/modules/contacts/directory.service';
import type { MailBlockRunRequestDto } from './dto/mail-block-run.dto';
import type { MailFilterUpdateDto } from './dto/mail-filter.dto';
import type { MailFilterRunRequestDto } from './dto/mail-filter-run.dto';
import type { DraftBodyDto, SendMessageDto } from './dto/send-message.dto';
@@ -82,7 +83,7 @@ function fakeProvider(): ProviderMocks {
getProfile: mockOf<'getProfile'>(),
listMailboxes: mockOf<'listMailboxes'>(),
createMailbox: mockOf<'createMailbox'>(),
renameMailbox: mockOf<'renameMailbox'>(),
updateMailbox: mockOf<'updateMailbox'>(),
deleteMailbox: mockOf<'deleteMailbox'>(),
markMailboxRead: mockOf<'markMailboxRead'>(),
emptyMailbox: mockOf<'emptyMailbox'>(),
@@ -91,6 +92,7 @@ function fakeProvider(): ProviderMocks {
getMailFilters: mockOf<'getMailFilters'>(),
setMailFilters: mockOf<'setMailFilters'>(),
runMailFilter: mockOf<'runMailFilter'>(),
runMailBlock: mockOf<'runMailBlock'>(),
openMessageSource: mockOf<'openMessageSource'>(),
getMessageSubjects: mockOf<'getMessageSubjects'>(),
getUsage: mockOf<'getUsage'>(),
@@ -132,6 +134,7 @@ function fakeProvider(): ProviderMocks {
removeMailBlock: mockOf<'removeMailBlock'>(),
listScheduledMessages: mockOf<'listScheduledMessages'>(),
cancelPendingSend: mockOf<'cancelPendingSend'>(),
releaseScheduledSend: mockOf<'releaseScheduledSend'>(),
};
}
@@ -544,7 +547,7 @@ describe('MailService', () => {
it('메일함 만들기 — 상위가 없으면 맨 위(null)로 넘기고, 이름 바꾸기와 함께 표시 규칙을 거친 메일함을 돌려준다', async () => {
provider.createMailbox.mockResolvedValue(mailbox);
provider.renameMailbox.mockResolvedValue({
provider.updateMailbox.mockResolvedValue({
...mailbox,
id: 'a',
name: 'Inbox',
@@ -562,13 +565,34 @@ describe('MailService', () => {
// (어댑터가 막지만) 역할이 있는 메일함은 한국어 표준 이름으로 보인다
await expect(
service.renameMailbox(user, 'a', { name: '새 이름' }),
service.updateMailbox(user, 'a', { name: '새 이름' }),
).resolves.toMatchObject({ name: '받은편지함' });
expect(provider.renameMailbox).toHaveBeenCalledWith(
user.email,
'a',
'새 이름',
);
expect(provider.updateMailbox).toHaveBeenCalledWith(user.email, 'a', {
name: '새 이름',
});
});
it('메일함 바꾸기(15단계) — 이름 · 상위 가운데 보낸 것만 넘긴다, 둘 다 없으면 엔진에 묻지 않고 VAL_001', async () => {
provider.updateMailbox.mockResolvedValue(mailbox);
expect(
await failureOf(() => service.updateMailbox(user, 'n1', {})),
).toEqual({
code: 'VAL_001',
reason: 'empty-mailbox-update',
});
expect(provider.updateMailbox).not.toHaveBeenCalled();
await service.updateMailbox(user, 'n1', { parentId: null });
await service.updateMailbox(user, 'n1', { parentId: 'u1' });
await service.updateMailbox(user, 'n1', { name: '2025', parentId: 'u1' });
expect(provider.updateMailbox.mock.calls).toEqual([
[user.email, 'n1', { parentId: null }],
[user.email, 'n1', { parentId: 'u1' }],
[user.email, 'n1', { name: '2025', parentId: 'u1' }],
]);
// 옮긴 로그는 어댑터가 실제로 옮겼을 때만 남긴다 (지금 자리 그대로면 남기지 않게 — jmap-mail.provider.spec.ts)
expect(logSpy).not.toHaveBeenCalled();
});
it('메일함 지우기 — 지웠을 때만 개수를 남긴다 (메일함 이름 · 주소 없이)', async () => {
@@ -598,6 +622,37 @@ describe('MailService', () => {
expect(line).toContain('휴지통으로 옮김=7통');
expect(line).toContain('이 메일함에서만 뺌=2통');
expect(line).not.toContain(user.email);
// 하위까지가 아니면 메일함 목록을 읽지 않고, 한 요청의 시간은 서비스가 받은 때부터 (startedAt)
expect(provider.listMailboxes).not.toHaveBeenCalled();
expect(provider.deleteMailbox).toHaveBeenLastCalledWith(
user.email,
'u4',
{
startedAt: expect.any(Number) as number,
},
);
});
it('하위 메일함까지 지우기(15단계 — withChildren=true)는 메일함 목록으로 나무를 정해 넘긴다', async () => {
provider.listMailboxes.mockResolvedValue([
{ ...mailbox, id: 'u1' },
{ ...mailbox, id: 'u2', parentId: 'u1' },
{ ...mailbox, id: 'u3' },
]);
provider.deleteMailbox.mockResolvedValue({
deleted: true,
processed: 3,
movedToTrash: 3,
remaining: 0,
});
await service.deleteMailbox(user, 'u1', { withChildren: 'true' });
expect(provider.deleteMailbox).toHaveBeenCalledWith(user.email, 'u1', {
withChildren: true,
startedAt: expect.any(Number) as number,
});
const [line] = logSpy.mock.calls.at(-1) as [string];
expect(line).toContain('하위까지(메일함 2개)');
});
it('전체 읽음은 { isRead: true } 를 어댑터의 전체 읽음으로 — 로그를 남기지 않는다', async () => {
@@ -712,6 +767,103 @@ describe('MailService', () => {
provider.emptyMailbox.mockResolvedValueOnce(emptied);
await expect(service.emptyMailbox(user, 'b')).resolves.toEqual(emptied);
});
it('하위까지 지우기는 나무의 메일함 모두를 잡는다 — 하위 메일함의 처리 · 그 안으로 옮기기는 REQ_001, 나무 밖은 된다 (15단계)', async () => {
const deletion = {
deleted: true,
processed: 0,
movedToTrash: 0,
remaining: 0,
};
const first = pending<typeof deletion>();
provider.listMailboxes.mockResolvedValue([
{ ...mailbox, id: 'u1' },
{ ...mailbox, id: 'u2', parentId: 'u1' },
{ ...mailbox, id: 'u3' },
]);
provider.deleteMailbox.mockReturnValueOnce(first.promise);
provider.markMailboxRead.mockResolvedValue({
processed: 0,
remaining: 0,
});
provider.updateMailbox.mockResolvedValue(mailbox);
const running = service.deleteMailbox(user, 'u1', {
withChildren: 'true',
});
// 메일함 목록(엔진)을 지나 어댑터에 들어갈 때까지 기다린다
await new Promise((resolve) => setImmediate(resolve));
expect(provider.deleteMailbox).toHaveBeenCalledTimes(1);
for (const attempt of [
() => service.updateMailboxMessages(user, 'u2', { isRead: true }),
() => service.emptyMailbox(user, 'u2'),
// 지우는 나무 안으로 옮기기 · 지우는 나무의 메일함을 빼 가기
() => service.updateMailbox(user, 'u3', { parentId: 'u2' }),
() => service.updateMailbox(user, 'u2', { parentId: null }),
]) {
expect(await failureOf(attempt)).toEqual({
code: 'REQ_001',
reason: 'mailbox-operation-in-progress',
});
}
expect(provider.updateMailbox).not.toHaveBeenCalled();
// 나무 밖은 된다 — 이름만 바꾸기는 잠그지 않는다(지금처럼)
await service.updateMailboxMessages(user, 'u3', { isRead: true });
await service.updateMailbox(user, 'u2', { name: '새 이름' });
expect(provider.updateMailbox).toHaveBeenCalledTimes(1);
first.finish(deletion);
await running;
await service.updateMailbox(user, 'u3', { parentId: 'u1' });
expect(provider.updateMailbox).toHaveBeenCalledTimes(2);
});
it('하위까지 지우기는 그 메일함을 먼저 잡는다 — 그 메일함이 처리 중이면 목록을 읽지 않고 REQ_001, 하위가 처리 중이면 목록만 읽고 REQ_001 (15단계)', async () => {
provider.listMailboxes.mockResolvedValue([
{ ...mailbox, id: 'u1' },
{ ...mailbox, id: 'u2', parentId: 'u1' },
]);
const busy = pending<{ processed: number; remaining: number }>();
provider.markMailboxRead.mockReturnValueOnce(busy.promise);
// 그 메일함이 처리 중 — 엔진에 묻지 않는다
const rootBusy = service.updateMailboxMessages(user, 'u1', {
isRead: true,
});
expect(
await failureOf(() =>
service.deleteMailbox(user, 'u1', { withChildren: 'true' }),
),
).toEqual({ code: 'REQ_001', reason: 'mailbox-operation-in-progress' });
expect(provider.listMailboxes).not.toHaveBeenCalled();
busy.finish({ processed: 0, remaining: 0 });
await rootBusy;
// 하위가 처리 중 — 목록만 읽고 지우지 않는다, 그 메일함의 잠금은 푼다
const child = pending<{ processed: number; remaining: number }>();
provider.markMailboxRead.mockReturnValueOnce(child.promise);
const childBusy = service.updateMailboxMessages(user, 'u2', {
isRead: true,
});
expect(
await failureOf(() =>
service.deleteMailbox(user, 'u1', { withChildren: 'true' }),
),
).toEqual({ code: 'REQ_001', reason: 'mailbox-operation-in-progress' });
expect(provider.listMailboxes).toHaveBeenCalledTimes(1);
expect(provider.deleteMailbox).not.toHaveBeenCalled();
provider.markMailboxRead.mockResolvedValueOnce({
processed: 0,
remaining: 0,
});
await expect(
service.updateMailboxMessages(user, 'u1', { isRead: true }),
).resolves.toEqual({ processed: 0, remaining: 0 });
child.finish({ processed: 0, remaining: 0 });
await childBusy;
});
});
describe('부재중 자동 응답 저장', () => {
@@ -1456,6 +1608,204 @@ describe('MailService', () => {
});
});
describe('차단할 때 받은편지함 정리 (15단계)', () => {
const outcome = {
scanned: 500,
matched: 12,
remaining: 40,
cursor: 'next-cursor',
rounds: 1,
};
const blocked = (address: string) => ({
address,
blockedAt: '2026-11-09T01:00:00.000Z',
});
const mailboxes: Mailbox[] = [
{
id: 'a',
name: 'Inbox',
role: 'inbox',
parentId: null,
sortOrder: 0,
totalCount: 540,
unreadCount: 0,
},
{
id: 'c',
name: 'Junk Mail',
role: 'junk',
parentId: null,
sortOrder: 0,
totalCount: 0,
unreadCount: 0,
},
];
const body = (
sender: string,
cursor: string | null = null,
): MailBlockRunRequestDto => ({ sender, cursor });
/** 손으로 끝내는 정리 — 끝낼 때까지 진행 중이다 */
function pending<T>(): {
promise: Promise<T>;
finish: (value: T) => void;
} {
let finish!: (value: T) => void;
const promise = new Promise<T>((resolve) => {
finish = resolve;
});
return { promise, finish };
}
beforeEach(() => {
provider.getMailBlocks.mockResolvedValue({
senders: [blocked('kim@spam.example'), blocked('@spam.example')],
isActive: true,
});
provider.listMailboxes.mockResolvedValue(mailboxes);
provider.runMailBlock.mockResolvedValue(outcome);
});
it('차단 목록에 있는 보낸 사람이면 메일함 목록과 함께 넘기고 결과를 돌려준다 — 대소문자 · 앞뒤 공백은 맞춘다, 로그에는 가린 주소 · 수만', async () => {
await expect(
service.runMailBlock(user, body(' Kim@Spam.Example ')),
).resolves.toEqual({
scanned: 500,
matched: 12,
remaining: 40,
cursor: 'next-cursor',
});
expect(provider.runMailBlock).toHaveBeenCalledWith(
user.email,
{ sender: 'kim@spam.example', cursor: null },
{ mailboxes, startedAt: expect.any(Number) as number },
);
const [line] = logSpy.mock.calls.at(-1) as [string];
expect(line).toContain('확인 500통 · 옮김 12통 · 남음 40통');
expect(line).not.toContain('kim@spam.example');
expect(line).not.toContain('next-cursor');
expect(line).not.toContain(user.email);
// 도메인 차단은 도메인만 남긴다
await service.runMailBlock(user, body('@spam.example', 'next-cursor'));
expect(provider.runMailBlock).toHaveBeenLastCalledWith(
user.email,
{ sender: '@spam.example', cursor: 'next-cursor' },
expect.any(Object),
);
const [domainLine] = logSpy.mock.calls.at(-1) as [string];
expect(domainLine).toContain('도메인=@spam.example');
});
it('차단 목록에 없으면 RES_001 — 받은편지함을 읽지 않는다 · 모양이 틀리면 VAL_001 (엔진에 묻지 않는다)', async () => {
expect(
await failureOf(() =>
service.runMailBlock(user, body('lee@spam.example')),
),
).toEqual({ code: 'RES_001', reason: 'mail-block-not-found' });
expect(provider.listMailboxes).not.toHaveBeenCalled();
expect(provider.runMailBlock).not.toHaveBeenCalled();
provider.getMailBlocks.mockClear();
expect(
await failureOf(() =>
service.runMailBlock(user, body('not an address')),
),
).toEqual({ code: 'VAL_001', reason: 'malformed-mail-block-run-sender' });
expect(provider.getMailBlocks).not.toHaveBeenCalled();
});
it('차단 정리가 도는 동안 그 직원의 스팸메일함을 잡는다 — 비우기 · 전체 읽음은 REQ_001, 비우는 중이면 차단 정리가 목록을 읽은 뒤 REQ_001 (받은편지함 정리 잠금도 푼다)', async () => {
const first = pending<typeof outcome>();
provider.runMailBlock.mockReturnValueOnce(first.promise);
provider.emptyMailbox.mockResolvedValue({ processed: 0, remaining: 0 });
provider.markMailboxRead.mockResolvedValue({
processed: 0,
remaining: 0,
});
const running = service.runMailBlock(user, body('kim@spam.example'));
await new Promise((resolve) => setImmediate(resolve));
expect(provider.runMailBlock).toHaveBeenCalledTimes(1);
for (const attempt of [
() => service.emptyMailbox(user, 'c'),
() => service.updateMailboxMessages(user, 'c', { isRead: true }),
]) {
expect(await failureOf(attempt)).toEqual({
code: 'REQ_001',
reason: 'mailbox-operation-in-progress',
});
}
expect(provider.emptyMailbox).not.toHaveBeenCalled();
// 다른 메일함은 된다
await expect(
service.updateMailboxMessages(user, 'b', { isRead: true }),
).resolves.toEqual({ processed: 0, remaining: 0 });
first.finish(outcome);
await running;
// 스팸메일함을 비우는 중이면 차단 정리가 REQ_001 — 받은편지함을 건드리지 않는다
const emptying = pending<{ processed: number; remaining: number }>();
provider.emptyMailbox.mockReturnValueOnce(emptying.promise);
const empty = service.emptyMailbox(user, 'c');
provider.runMailBlock.mockClear();
expect(
await failureOf(() =>
service.runMailBlock(user, body('kim@spam.example')),
),
).toEqual({ code: 'REQ_001', reason: 'mailbox-operation-in-progress' });
expect(provider.runMailBlock).not.toHaveBeenCalled();
// 받은편지함 정리 잠금(filterRuns)은 풀렸다 — 필터 적용은 된다
provider.runMailFilter.mockResolvedValueOnce(outcome);
await expect(
service.runMailFilter(user, {
match: 'always',
conditions: [],
actions: { moveToMailboxId: null, markAsRead: true, flag: false },
cursor: null,
} as unknown as MailFilterRunRequestDto),
).resolves.toMatchObject({ matched: 12 });
emptying.finish({ processed: 0, remaining: 0 });
await empty;
await expect(
service.runMailBlock(user, body('kim@spam.example')),
).resolves.toMatchObject({ matched: 12 });
});
it('같은 직원의 받은편지함 정리(필터 적용 · 차단 정리)는 한 번에 하나 — 진행 중이면 서로 REQ_001', async () => {
const first = pending<typeof outcome>();
provider.runMailBlock.mockReturnValueOnce(first.promise);
provider.runMailFilter.mockResolvedValue(outcome);
const running = service.runMailBlock(user, body('kim@spam.example'));
await new Promise((resolve) => setImmediate(resolve));
expect(provider.runMailBlock).toHaveBeenCalledTimes(1);
expect(
await failureOf(() =>
service.runMailBlock(user, body('@spam.example')),
),
).toMatchObject({ code: 'REQ_001' });
expect(
await failureOf(() =>
service.runMailFilter(user, {
match: 'always',
conditions: [],
actions: { moveToMailboxId: null, markAsRead: true, flag: false },
cursor: null,
} as unknown as MailFilterRunRequestDto),
),
).toMatchObject({ code: 'REQ_001' });
expect(provider.runMailFilter).not.toHaveBeenCalled();
first.finish(outcome);
await running;
await expect(
service.runMailBlock(user, body('kim@spam.example')),
).resolves.toMatchObject({ matched: 12 });
});
});
describe('부재중 자동 응답 읽기 (7단계 — isActive)', () => {
it('어댑터 그대로 — 저장한 값과 엔진에서 동작하는지', async () => {
const state = {
+138 -28
View File
@@ -30,13 +30,16 @@ import type {
MessageDeletionDto,
UpdateMessagesDto,
} from './dto/bulk-messages.dto';
import type { MailBlockRunRequestDto } from './dto/mail-block-run.dto';
import type { MailFilterUpdateDto } from './dto/mail-filter.dto';
import type { MailFilterRunRequestDto } from './dto/mail-filter-run.dto';
import type { MailVacationDto } from './dto/mail-vacation.dto';
import type {
CreateMailboxDto,
MailboxMessagesPatchDto,
RenameMailboxDto,
import {
WITH_CHILDREN,
type CreateMailboxDto,
type MailboxDeleteQueryDto,
type MailboxMessagesPatchDto,
type UpdateMailboxDto,
} from './dto/mailbox.dto';
import {
SHOW_IMAGES,
@@ -80,6 +83,7 @@ import {
SCHEDULED_SEND_MAX_LEAD_MS,
SCHEDULED_SEND_MIN_LEAD_MS,
} from './mail.constants';
import { describeBlockTarget, parseBlockTarget } from './mail-blocks';
import { findFilterRulesProblem, findFilterRunProblem } from './mail-filters';
import { MAIL_PROVIDER, type MailProvider } from './mail.provider';
import { MAIL_SETTINGS, type MailSettings } from './mail.settings';
@@ -118,6 +122,7 @@ import type {
SentMessage,
} from './mail.types';
import { presentMailbox, presentMailboxes } from './mailbox.presenter';
import { subtreeIds } from './mailbox-tree';
import { highlightDetail } from './message-highlight';
import { RecipientSuggestionService } from './recipient-suggestions.service';
import {
@@ -131,6 +136,7 @@ import {
type LargeFilePlan,
type RecognizedLargeFiles,
} from './mail-large-files.service';
import { undoSendHold } from './send-hold';
/**
* 엔진 장애(MAIL_001)로 끝난 발송의 키를 잠가 두는 시간.
@@ -336,13 +342,15 @@ export class MailService {
/**
* 진행 중인 오래 걸리는 처리(전체 읽음 · 비우기 · 지우기)의 (사용자, 메일함) — 같은 메일함에는 하나씩만, 종류가 달라도 막는다.
* 두 탭이 같은 휴지통을 비우면 서로 상태가 어긋나 둘 다 멈추고, 동시 요청이 많으면 엔진 호출이 그 수만큼 곱절로 는다.
* 15단계부터 하위까지 지우기는 나무의 메일함 모두를, 옮기기는 그 메일함과 새 상위를, 차단 정리는 그 직원의 스팸메일함을 잡는다.
* ⚠️ 프로세스 메모리다. 인스턴스를 늘리면 Redis 잠금(SET NX PX)으로 옮겨야 인스턴스 사이에서도 하나씩이 된다.
*/
private readonly mailboxOperations = new Set<string>();
/**
* 진행 중인 "이미 받은 메일에 필터 적용"의 직원 — 한 직원에 하나씩만 (계약 2절 "이미 받은 메일에 적용" — 다른 탭 · PC 에서 함께 하면 429 REQ_001).
* 두 적용이 같은 받은편지함을 함께 읽으면 서로 옮긴 메일 때문에 자리가 어긋나고 엔진 호출이 그 수만큼 곱절로 는다.
* 진행 중인 받은편지함 정리 — "이미 받은 메일에 필터 적용"(7단계)과 차단할 때 받은편지함 정리(15단계)의 직원. 한 직원에 하나씩만, 종류가 달라도 막는다
* (계약 2절 "이미 받은 메일에 적용" · "보낸 사람 차단" — 다른 탭 · PC 에서 함께 하면 429 REQ_001).
* 두 정리가 같은 받은편지함을 함께 읽으면 서로 옮긴 메일 때문에 자리가 어긋나고 엔진 호출이 그 수만큼 곱절로 는다.
* ⚠️ 프로세스 메모리다. 인스턴스를 늘리면 Redis 잠금(SET NX PX)으로 옮겨야 인스턴스 사이에서도 하나씩이 된다.
*/
private readonly filterRuns = new Set<string>();
@@ -415,32 +423,75 @@ export class MailService {
);
}
/** 메일함 이름 바꾸기 — 기본 메일함(역할 있음)은 VAL_001 */
async renameMailbox(
/**
* 메일함 이름 바꾸기 · 옮기기 (15단계) — 이름 · 상위 가운데 보낸 것만 바꾼다. 둘 다 없으면 엔진에 묻지 않고 VAL_001.
* 기본 메일함(역할 있음) · 만들 수 없는 자리 · 자기 하위 아래는 VAL_001, 없는 새 상위는 RES_001, 같은 이름은 MAIL_009 (어댑터).
* 옮길 때(parentId 가 있을 때)는 그 메일함과 새 상위의 오래 걸리는 처리와 함께 하지 않는다 — 진행 중이면 REQ_001 (runExclusive — 지우는 나무 안으로 옮기거나 지우는 메일함을 빼 가지 않게).
* 옮긴 로그는 어댑터가 남긴다 — 실제로 옮겼을 때만(지금 자리 그대로면 엔진에 묻지 않아 남기지 않는다), 메일함 id 만
*/
async updateMailbox(
user: AuthenticatedUser,
id: string,
dto: RenameMailboxDto,
dto: UpdateMailboxDto,
): Promise<Mailbox> {
const { name, parentId } = dto;
if (name === undefined && parentId === undefined) {
throw new BusinessException('VAL_001', {
context: { reason: 'empty-mailbox-update' },
});
}
if (parentId === undefined) {
return presentMailbox(
await this.provider.updateMailbox(user.email, id, { name }),
);
}
const keys = parentId === null ? [id] : [id, parentId];
return presentMailbox(
await this.provider.renameMailbox(user.email, id, dto.name),
await this.runExclusive(user, keys, () =>
this.provider.updateMailbox(user.email, id, {
...(name === undefined ? {} : { name }),
parentId,
}),
),
);
}
/**
* 메일함 지우기 — 안의 메일을 휴지통으로 옮긴 뒤 지운다(영구 삭제하지 않는다). 한 요청에 끝나지 않으면 deleted: false.
* 같은 메일함의 오래 걸리는 처리는 하나씩만 (진행 중이면 REQ_001 — runExclusive).
* 같은 메일함의 오래 걸리는 처리는 하나씩만 (진행 중이면 엔진에 묻지 않고 REQ_001 — runExclusive). 한 요청의 시간(25초)은 여기 들어온 때부터 센다.
* 하위 메일함까지 지우기(withChildren=true — 15단계)는 나무(그 메일함과 하위 모두)를 지운다 — 그 메일함을 먼저 잡고(진행 중이면 엔진에 묻지 않고 REQ_001)
* 메일함 목록을 읽어 하위 메일함까지 잡는다(하위가 처리 중이면 목록만 읽고 REQ_001 — 아무것도 바꾸지 않는다).
* 지웠으면 info 로그에 개수만 남긴다 (메일함 이름 없이 — 사용자가 정한 글이다)
*/
async deleteMailbox(
user: AuthenticatedUser,
id: string,
query: MailboxDeleteQueryDto = {},
): Promise<MailboxDeletion> {
const result = await this.runExclusive(user, id, () =>
this.provider.deleteMailbox(user.email, id),
);
const startedAt = Date.now();
const withChildren = query.withChildren === WITH_CHILDREN;
let treeSize = 1;
const result = await this.runExclusive(user, [id], async () => {
if (!withChildren) {
return this.provider.deleteMailbox(user.email, id, { startedAt });
}
// 나무의 하위 메일함 — 없는 메일함이면 없음(어댑터가 RES_001)
const descendants = subtreeIds(
await this.provider.listMailboxes(user.email),
id,
).filter((mailboxId) => mailboxId !== id);
treeSize = descendants.length + 1;
return this.runExclusive(user, descendants, () =>
this.provider.deleteMailbox(user.email, id, {
withChildren,
startedAt,
}),
);
});
if (result.deleted) {
this.logger.log(
`${traceTag()} 메일함을 지웠습니다: user=${maskEmail(user.email)}, 이번 요청에서 휴지통으로 옮김=${result.movedToTrash}통 · 이 메일함에서만 뺌=${result.processed - result.movedToTrash}통`,
`${traceTag()} 메일함을 지웠습니다: user=${maskEmail(user.email)}, ${withChildren ? `하위까지(메일함 ${treeSize}개), ` : ''}이번 요청에서 휴지통으로 옮김=${result.movedToTrash}통 · 이 메일함에서만 뺌=${result.processed - result.movedToTrash}통`,
);
}
return result;
@@ -461,7 +512,7 @@ export class MailService {
context: { reason: 'unsupported-mailbox-messages-change' },
});
}
return this.runExclusive(user, id, () =>
return this.runExclusive(user, [id], () =>
this.provider.markMailboxRead(user.email, id),
);
}
@@ -474,7 +525,7 @@ export class MailService {
user: AuthenticatedUser,
id: string,
): Promise<MailboxBulkResult> {
const result = await this.runExclusive(user, id, () =>
const result = await this.runExclusive(user, [id], () =>
this.provider.emptyMailbox(user.email, id),
);
if (result.processed > 0) {
@@ -648,6 +699,61 @@ export class MailService {
);
}
/**
* 차단할 때 받은편지함 정리 (계약 2절 "보낸 사람 차단" — 15단계) — 받은편지함에 이미 있는 그 보낸 사람의 메일을 스팸메일함으로 옮긴다.
* 이미 받은 메일에 필터 적용(7단계)과 같은 틀이다 — 한 요청의 시간(25초)은 여기 들어온 때부터, 이어 할 자리(cursor)는 어댑터가 만든다.
* - 보낸 사람은 차단 목록의 모양(주소 · `@도메인`)이어야 한다 — 아니면 엔진에 묻지 않고 VAL_001
* - **같은 직원의 받은편지함 정리(필터 적용 · 이것)는 한 번에 하나** — 같은 잠금(filterRuns), 진행 중이면 엔진에 묻지 않고 REQ_001
* - 차단 목록에 없으면(차단한 뒤에만 부른다 · 그사이 풀었다) RES_001 — 요청마다 본다(도중에 풀면 다음 요청에서 멈춘다)
* - 요청이 도는 동안 그 직원의 스팸메일함을 메일함 처리 잠금(mailboxOperations)으로 잡는다 — 옮겨 넣는 동안 스팸메일함 비우기 · 모두 읽음이 함께 돌지 않게.
* 비우는 중이면 메일함 목록을 읽은 뒤 REQ_001 (받은편지함은 건드리지 않았다)
* 로그에는 가린 보낸 사람(도메인 차단은 도메인만) · 확인 · 옮긴 수 · 차례 수 · 걸린 시간만 남긴다
*/
async runMailBlock(
user: AuthenticatedUser,
dto: MailBlockRunRequestDto,
): Promise<MailFilterRun> {
const startedAt = Date.now();
const target = parseBlockTarget(dto.sender);
if (target === null) {
throw new BusinessException('VAL_001', {
context: { reason: 'malformed-mail-block-run-sender' },
});
}
return this.exclusive(
this.filterRuns,
user.email.toLowerCase(),
'mail-block-run-in-progress',
user,
async () => {
const { senders } = await this.provider.getMailBlocks(user.email);
if (!senders.some(({ address }) => address === target.value)) {
throw new BusinessException('RES_001', {
context: { reason: 'mail-block-not-found', kind: target.kind },
});
}
const mailboxes = await this.provider.listMailboxes(user.email);
// 스팸메일함이 없으면 잡을 것이 없다 — 어댑터가 MAIL_001 로 멈춘다
const junk = mailboxes.find((mailbox) => mailbox.role === 'junk');
const { rounds, ...result } = await this.runExclusive(
user,
junk ? [junk.id] : [],
() =>
this.provider.runMailBlock(
user.email,
{ sender: target.value, cursor: dto.cursor },
{ mailboxes, startedAt },
),
);
this.logger.log(
`${traceTag()} 차단한 보낸 사람의 메일을 받은편지함에서 스팸메일함으로 옮겼습니다: user=${maskEmail(user.email)}, ${describeBlockTarget(target)}, 확인 ${result.scanned}통 · 옮김 ${result.matched}통 · 남음 ${result.remaining}통${result.cursor === null ? '(끝)' : ''}, 차례 ${rounds}번, ${Date.now() - startedAt}ms`,
);
return result;
},
);
}
/** 받은 그대로의 메일 원문 — 파일 이름은 제목(없으면 "메일") + .eml (attachment.policy.ts 의 messageSourceFileName) */
async openMessageSource(
user: AuthenticatedUser,
@@ -974,12 +1080,10 @@ export class MailService {
);
}
/** 보내기 취소를 기다리는 붙잡기 — 그 직원 설정의 초(0 이면 붙잡지 않는다) */
/** 보내기 취소를 기다리는 붙잡기 — 그 직원 설정의 초(0 이면 붙잡지 않는다 — send-hold.ts) */
private async undoHold(user: AuthenticatedUser): Promise<SendHold | null> {
const { undoSendSeconds } = await this.userSettingsOf(user);
return undoSendSeconds > 0
? { kind: 'undo', seconds: undoSendSeconds }
: null;
return undoSendHold(undoSendSeconds);
}
/** 임시 저장 — 처음 저장 (계약 2절 "임시 저장") */
@@ -1091,15 +1195,19 @@ export class MailService {
);
}
/** 같은 직원 · 같은 메일함의 오래 걸리는 처리를 하나씩만 한다 — 다른 메일함끼리는 동시에 된다 (exclusive) */
/**
* 같은 직원 · 같은 메일함의 오래 걸리는 처리를 하나씩만 한다 — 다른 메일함끼리는 동시에 된다 (exclusive).
* 메일함 여럿(하위까지 지우기의 나무 · 옮기기의 그 메일함과 새 상위 — 15단계)은 모두 비어 있을 때만 한꺼번에 잡는다
*/
private runExclusive<T>(
user: AuthenticatedUser,
mailboxId: string,
mailboxIds: readonly string[],
operation: () => Promise<T>,
): Promise<T> {
const owner = user.email.toLowerCase();
return this.exclusive(
this.mailboxOperations,
`${user.email.toLowerCase()}\n${mailboxId}`,
mailboxIds.map((mailboxId) => `${owner}\n${mailboxId}`),
'mailbox-operation-in-progress',
user,
operation,
@@ -1108,25 +1216,27 @@ export class MailService {
/**
* 같은 열쇠의 일을 하나씩만 한다 — 진행 중이면 엔진에 묻지 않고 바로 REQ_001 (로그인의 진행 중 시도와 같은 모양).
* 열쇠가 여럿이면 하나라도 잡혀 있을 때 REQ_001 이고, 모두 비었으면 await 없이 한꺼번에 잡는다(사이에 다른 요청이 끼지 않는다).
* 끝나면 성공 · 실패와 상관없이 푼다
*/
private async exclusive<T>(
running: Set<string>,
key: string,
keys: string | readonly string[],
reason: string,
user: AuthenticatedUser,
operation: () => Promise<T>,
): Promise<T> {
if (running.has(key)) {
const held = [...new Set(typeof keys === 'string' ? [keys] : keys)];
if (held.some((key) => running.has(key))) {
throw new BusinessException('REQ_001', {
context: { reason, user: maskEmail(user.email) },
});
}
running.add(key);
for (const key of held) running.add(key);
try {
return await operation();
} finally {
running.delete(key);
for (const key of held) running.delete(key);
}
}
+47 -3
View File
@@ -285,18 +285,38 @@ export interface MailboxCreate {
readonly parentId: string | null;
}
/**
* 메일함 바꾸기 (PATCH /api/mailboxes/{id} — 계약 2절 "메일함 관리") — 이름 · 상위(15단계 — 옮기기). 보내지 않은 칸은 그대로다.
* 서비스가 하나 이상인지 · 이름 형식을 본 값이다
*/
export interface MailboxChange {
/** 앞뒤 공백을 뺀 1~64자, `/` 없음 — 없으면 이름은 그대로 */
readonly name?: string;
/** 새 상위 메일함 — null 이면 맨 위, 없으면(undefined) 상위는 그대로 */
readonly parentId?: string | null;
}
/** 메일함 지우기의 방법 (DELETE /api/mailboxes/{id} — 15단계 "하위 메일함까지 지우기") */
export interface MailboxDeleteOptions {
/** true 면 하위 메일함이 있어도 깊은 것부터 모두 지운다 — 없거나 false 면 하위가 있을 때 VAL_001 (지금처럼) */
readonly withChildren?: boolean;
/** 요청이 서비스에 들어온 때(Date.now() 의 ms) — 한 요청의 시간(25초)을 이때부터 센다. 없으면 어댑터가 시작한 때부터 */
readonly startedAt?: number;
}
/**
* 메일함 지우기 결과 (DELETE /api/mailboxes/{id}) — 안의 메일을 휴지통으로 옮긴 뒤 빈 메일함을 지운다.
* 한 요청에 다 옮기지 못하면 deleted: false — 클라이언트가 같은 요청을 다시 보낸다.
* 하위 메일함까지 지우면(15단계 — withChildren) 수는 나무 전체(그 메일함과 하위 모두)의 것이다
*/
export interface MailboxDeletion {
/** true = 지웠다 */
/** true = 지웠다 (하위까지 지울 때는 나무 전체를 지웠다) */
readonly deleted: boolean;
/** 이번 요청에서 이 메일함에서 빠진 메일 수 — 휴지통으로 옮긴 것 + 다른 메일함에도 있어 이 메일함에서만 뺀 것 (진행 수) */
/** 이번 요청에서 이 메일함(나무)에서 빠진 메일 수 — 휴지통으로 옮긴 것 + 다른 메일함에도 있어 이 메일함(나무)에서만 뺀 것 (진행 수) */
readonly processed: number;
/** 이번 요청에서 휴지통으로 옮긴 메일 수 (다른 메일함에도 있어 이 메일함에서만 뺀 메일은 세지 않는다) */
readonly movedToTrash: number;
/** 아직 이 메일함에 남은 메일 수 */
/** 아직 이 메일함(나무)에 남은 메일 수 */
readonly remaining: number;
}
@@ -428,6 +448,18 @@ export interface MailFilterRunOutcome extends MailFilterRun {
readonly rounds: number;
}
/**
* 차단할 때 받은편지함 정리 (POST /api/mail-block-runs — 계약 2절 "보낸 사람 차단" · 15단계) — 서비스가 차단 목록에 있는지 본 값이다.
* 이미 받은 메일에 필터 적용(7단계)과 같은 틀이다 — 응답 · 서비스가 넘기는 것(MailFilterRunContext) · 어댑터의 결과(MailFilterRunOutcome)는 그것을 함께 쓴다
* (matched 는 스팸메일함으로 옮긴 수)
*/
export interface MailBlockRunRequest {
/** 차단 목록의 값 — 소문자 주소 또는 `@도메인` (BlockedSender.address) */
readonly sender: string;
/** 처음은 null — 이어 할 때는 앞 결과의 cursor 그대로 (어댑터가 만들고 해석한다 — 다른 보낸 사람의 것이면 VAL_001) */
readonly cursor: string | null;
}
/** 받은 그대로의 메일 원문 (GET /api/messages/{id}/source) — 파일 이름은 서비스가 제목으로 정한다 */
export interface MessageSource {
readonly stream: Readable;
@@ -738,6 +770,18 @@ export interface SentMessage {
readonly pendingSend: PendingSend | null;
}
/**
* 예약 메일 지금 보내기의 결과 (POST /api/scheduled-messages/{id}/release — 15단계) — 응답은 발송 결과(SentMessage)와 같은 칸이고,
* 대용량 첨부 링크에 적을 보낸 때를 더했다 (서비스가 떼고 답한다)
*/
export interface ReleasedScheduledSend extends SentMessage {
/**
* 지금 보내기로 다시 제출한 때(ISO 8601 UTC — 이 요청이나 응답을 받지 못한 앞 요청) — 대용량 첨부 링크의 "보낸 때"로 적는다.
* 예약 시각에 이미 보낸 메일(지금 보내기가 아니다)이면 null — 링크를 그대로 둔다
*/
readonly releasedAt: string | null;
}
/**
* 같은 Message-ID 로 이미 발송한 메일 — 받지 않은 사람은 지문이 맞을 때만 따로 다시 만든다
* (MailProvider.getRejectedRecipients — 내용이 달라 MAIL_005 로 끝날 때 제출 기록을 헛되이 읽지 않게)
@@ -0,0 +1,72 @@
import { startCpuTimer } from '@/../test/cpu-time';
import {
deepestFirstIds,
descendantsOf,
isSelfOrDescendant,
subtreeIds,
} from './mailbox-tree';
/** 거래처 › 2025 › 1분기 · 거래처 › 2026, 프로젝트(맨 위), 받은편지함 › 프로젝트 */
const mailboxes = [
{ id: 'a', parentId: null },
{ id: 'u1', parentId: null },
{ id: 'u2', parentId: 'u1' },
{ id: 'u3', parentId: 'u2' },
{ id: 'u4', parentId: 'u1' },
{ id: 'p', parentId: undefined },
{ id: 'q', parentId: 'a' },
];
describe('메일함 나무 (mailbox-tree — 15단계 옮기기 · 하위까지 지우기)', () => {
it('하위 메일함 모두 — 깊이와 함께 가까운 것부터, 그 메일함 자신은 넣지 않는다', () => {
expect(
descendantsOf(mailboxes, 'u1').map(({ mailbox, depth }) => [
mailbox.id,
depth,
]),
).toEqual([
['u2', 1],
['u4', 1],
['u3', 2],
]);
expect(descendantsOf(mailboxes, 'p')).toEqual([]);
expect(descendantsOf(mailboxes, 'nope')).toEqual([]);
});
it('잠글 메일함(그 메일함이 맨 앞) · 지울 차례(깊은 것부터, 그 메일함은 맨 끝)', () => {
expect(subtreeIds(mailboxes, 'u1')).toEqual(['u1', 'u2', 'u4', 'u3']);
expect(deepestFirstIds(mailboxes, 'u1')).toEqual(['u3', 'u2', 'u4', 'u1']);
expect(deepestFirstIds(mailboxes, 'u3')).toEqual(['u3']);
});
it('자기 자신 · 자기 하위(손주까지)인가 — 위 · 옆 메일함은 아니다', () => {
for (const candidate of ['u1', 'u2', 'u3', 'u4']) {
expect(isSelfOrDescendant(mailboxes, 'u1', candidate)).toBe(true);
}
for (const candidate of ['a', 'p', 'q']) {
expect(isSelfOrDescendant(mailboxes, 'u1', candidate)).toBe(false);
}
expect(isSelfOrDescendant(mailboxes, 'u2', 'u1')).toBe(false);
});
it('읽은 목록에 고리가 있어도 끝난다 — 한 번 본 메일함은 다시 보지 않는다', () => {
const looped = [
{ id: 'x', parentId: 'z' },
{ id: 'y', parentId: 'x' },
{ id: 'z', parentId: 'y' },
];
expect(subtreeIds(looped, 'x')).toEqual(['x', 'y', 'z']);
expect(deepestFirstIds(looped, 'x')).toEqual(['z', 'y', 'x']);
});
it('메일함 수에 비례한다 — 깊이 3,000 의 한 줄 나무도 바로 (CPU 시간)', () => {
const chain = Array.from({ length: 3_000 }, (_, index) => ({
id: `m${index}`,
parentId: index === 0 ? null : `m${index - 1}`,
}));
const elapsed = startCpuTimer();
expect(deepestFirstIds(chain, 'm0')).toHaveLength(3_000);
expect(isSelfOrDescendant(chain, 'm0', 'm2999')).toBe(true);
expect(elapsed()).toBeLessThan(500);
});
});
+95
View File
@@ -0,0 +1,95 @@
/**
* 메일함 나무 (순수 함수 — 계약 2절 "메일함 관리"의 15단계: 옮기기 · 하위 메일함까지 지우기) — 메일함은 parentId 로 잇는다.
* 엔진의 메일함(JmapMailbox)과 도메인의 메일함(Mailbox)이 함께 쓴다 — id · parentId 만 본다.
* 엔진이 고리를 막지만(옮기기의 invalidProperties — 2026-11-09 확인) 읽은 목록에 고리가 있어도 끝나게, 한 번 본 메일함은 다시 보지 않는다.
* 메일함 수에 비례한다(부모 → 자식 표를 한 번 만든다)
*/
/** 나무를 만드는 데 쓰는 칸 */
interface TreeNode {
readonly id: string;
readonly parentId?: string | null;
}
/** 하위 메일함 하나와 그 깊이 (그 메일함의 바로 아래가 1) */
export interface MailboxTreeEntry<T> {
readonly mailbox: T;
readonly depth: number;
}
/**
* 그 메일함의 하위 메일함 모두 — 깊이와 함께, 위에서부터(가까운 것부터) 차례. 그 메일함 자신은 넣지 않는다.
* 그 메일함이 목록에 없어도 그 id 를 상위로 둔 메일함을 찾는다(부르는 쪽이 먼저 있는지 본다)
*/
export function descendantsOf<T extends TreeNode>(
mailboxes: readonly T[],
rootId: string,
): MailboxTreeEntry<T>[] {
const children = new Map<string, T[]>();
for (const mailbox of mailboxes) {
const parentId = mailbox.parentId ?? null;
if (parentId === null) continue;
const siblings = children.get(parentId);
if (siblings) siblings.push(mailbox);
else children.set(parentId, [mailbox]);
}
const seen = new Set<string>([rootId]);
const found: MailboxTreeEntry<T>[] = [];
let level = [rootId];
for (let depth = 1; level.length > 0; depth += 1) {
const next: string[] = [];
for (const id of level) {
for (const child of children.get(id) ?? []) {
if (seen.has(child.id)) continue;
seen.add(child.id);
found.push({ mailbox: child, depth });
next.push(child.id);
}
}
level = next;
}
return found;
}
/** 그 메일함과 하위 모두의 id — 그 메일함이 맨 앞, 하위는 가까운 것부터 (잠금 · 메일 찾기) */
export function subtreeIds<T extends TreeNode>(
mailboxes: readonly T[],
rootId: string,
): string[] {
return [
rootId,
...descendantsOf(mailboxes, rootId).map(({ mailbox }) => mailbox.id),
];
}
/**
* 지울 차례 — 깊은 것부터, 그 메일함은 맨 끝. 엔진은 한 요청의 destroy 를 적은 차례대로 하므로(E2 — 2026-11-09 확인)
* 이 차례면 빈 나무를 요청 하나로 지운다. 깊이가 같으면 찾은 차례 그대로다
*/
export function deepestFirstIds<T extends TreeNode>(
mailboxes: readonly T[],
rootId: string,
): string[] {
const descendants = descendantsOf(mailboxes, rootId);
return [
...[...descendants]
.sort((a, b) => b.depth - a.depth)
.map(({ mailbox }) => mailbox.id),
rootId,
];
}
/** candidateId 가 그 메일함 자신이거나 그 하위인가 — 옮기기의 고리(자기 아래로 옮기기) 막기 */
export function isSelfOrDescendant<T extends TreeNode>(
mailboxes: readonly T[],
rootId: string,
candidateId: string,
): boolean {
return (
candidateId === rootId ||
descendantsOf(mailboxes, rootId).some(
({ mailbox }) => mailbox.id === candidateId,
)
);
}
+38 -14
View File
@@ -9,6 +9,7 @@ import {
Patch,
Post,
Put,
Query,
UseGuards,
} from '@nestjs/common';
import { ApiCookieAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
@@ -34,8 +35,9 @@ import {
import { MailVacationDto, MailVacationStateDto } from './dto/mail-vacation.dto';
import {
CreateMailboxDto,
MailboxDeleteQueryDto,
MailboxMessagesPatchDto,
RenameMailboxDto,
UpdateMailboxDto,
} from './dto/mailbox.dto';
import { MailIdPipe } from './mail-id.pipe';
import {
@@ -51,7 +53,8 @@ import { MailService } from './mail.service';
const BULK_NOTE =
'한 요청을 25초 안에 끝내고 남은 수를 돌려준다 — 클라이언트는 남은 것이 없을 때까지 같은 요청을 다시 보낸다. ' +
'도중에 실패하면 거기까지 처리했다 — 다시 보내면 남은 것만 처리한다. ' +
'같은 직원 · 같은 메일함에는 하나씩만 — 처리 중이면(종류가 달라도) 엔진에 묻지 않고 429 REQ_001. 다른 메일함은 동시에 된다.';
'같은 직원 · 같은 메일함에는 하나씩만 — 처리 중이면(종류가 달라도) 엔진에 묻지 않고 429 REQ_001. 다른 메일함은 동시에 된다. ' +
'차단할 때 받은편지함 정리(POST /api/mail-block-runs — 15단계)가 도는 동안은 스팸메일함도 처리 중이다.';
/** 오래 걸리는 처리가 진행 중일 때의 안내 */
const BULK_IN_PROGRESS =
@@ -108,20 +111,36 @@ export class MailboxController {
@Patch(':id')
@ApiOperation({
summary: '메일함 이름 바꾸기',
summary: '메일함 이름 바꾸기 · 옮기기',
description:
'기본 메일함(역할이 있는 메일함 — 받은편지함 · 보낸편지함 등)은 바꿀 수 없다. 다른 상위로 옮기기는 하지 않는다.',
'이름(name) · 상위(parentId — 15단계 "메일함 옮기기") 가운데 바꿀 것만 보낸다 — 하나 이상(빈 본문은 400 VAL_001). parentId: null 은 맨 위다. ' +
'기본 메일함(역할이 있는 메일함 — 받은편지함 · 보낸편지함 등)은 바꿀 수 없다. 새 상위는 만들기와 같은 자리만(맨 위 · 받은편지함 · 보관함 · 내 메일함), ' +
'자기 자신 · 자기 하위 아래로는 옮길 수 없다. 하위 메일함과 안의 메일은 함께 가고 메일함 id 는 그대로다(그 메일함으로 옮기는 필터도 그대로 돈다). ' +
'이름 · 상위가 지금과 같으면 엔진에 묻지 않고 그대로 돌려준다. 옮길 때(parentId 가 있을 때) 그 메일함이나 새 상위의 오래 걸리는 처리가 진행 중이면 429 REQ_001.',
})
@ApiStandardResponse(MailboxDto, { description: '바뀐 메일함' })
@ApiErrorResponse('VAL_001', '기본 메일함 · 이름 형식 · 모르는 필드')
@ApiErrorResponse('RES_001', '없는 메일함 (형식이 틀린 id 포함)')
@ApiErrorResponse('MAIL_009', '같은 상위 안에 같은 이름의 메일함이 있다')
rename(
@ApiErrorResponse(
'VAL_001',
'빈 본문 · 기본 메일함 · 이름 형식 · 만들 수 없는 자리(보낸편지함 · 임시보관함 · 스팸메일함 · 휴지통 아래) · 자기 자신 · 자기 하위 아래 · 모르는 필드',
)
@ApiErrorResponse(
'RES_001',
'없는 메일함 · 없는 새 상위 (형식이 틀린 id 포함)',
)
@ApiErrorResponse(
'MAIL_009',
'새 자리(같은 상위 안)에 같은 이름의 메일함이 있다 — 대소문자는 가리지 않는다',
)
@ApiErrorResponse(
'REQ_001',
'옮길 때 — 그 메일함이나 새 상위의 오래 걸리는 처리(전체 읽음 · 비우기 · 지우기)가 진행 중 — 엔진에 묻지 않았다',
)
update(
@CurrentUser() user: AuthenticatedUser,
@Param('id', MailIdPipe) id: string,
@Body() dto: RenameMailboxDto,
@Body() dto: UpdateMailboxDto,
) {
return this.mail.renameMailbox(user, id, dto);
return this.mail.updateMailbox(user, id, dto);
}
@Delete(':id')
@@ -129,13 +148,17 @@ export class MailboxController {
summary: '메일함 지우기 — 안의 메일을 휴지통으로 옮긴 뒤 지운다',
description:
'메일을 영구 삭제하지 않는다 — 이 메일함에만 있는 메일은 휴지통으로 옮기고, 다른 메일함에도 있는 메일은 이 메일함에서만 뺀 뒤 빈 메일함을 지운다. ' +
`옮기는 동안 들어온 메일도 옮긴다. ${BULK_NOTE} (deleted: false 면 다시 보낸다)`,
'하위 메일함이 있으면 withChildren=true 일 때만 지운다(15단계) — 그 메일함과 하위 모두(나무)의 메일을 같은 규칙으로 옮긴 뒤 깊은 것부터 지운다. 수는 나무 전체의 것이다. ' +
`옮기는 동안 들어온 메일도 옮긴다. ${BULK_NOTE} 하위까지 지울 때는 그 메일함을 먼저 잡고 목록을 읽어 하위 메일함까지 잡는다 — 하위가 처리 중이면 목록만 읽고 429. (deleted: false 면 다시 보낸다)`,
})
@ApiStandardResponse(MailboxDeletionDto, {
description:
'지웠다(deleted: true) · 아직 옮길 메일이 남았다(deleted: false)',
'지웠다(deleted: true — 하위까지면 나무 전체) · 아직 옮길 메일이 남았다(deleted: false)',
})
@ApiErrorResponse('VAL_001', '기본 메일함 · 하위 메일함이 있음')
@ApiErrorResponse(
'VAL_001',
'기본 메일함 · 하위 메일함이 있는데 withChildren=true 가 아님 · 하위에 기본 메일함이 있음 · withChildren 의 다른 값 · 모르는 쿼리',
)
@ApiErrorResponse('RES_001', '없는 메일함 (형식이 틀린 id 포함)')
@ApiErrorResponse(
'MAIL_015',
@@ -146,8 +169,9 @@ export class MailboxController {
remove(
@CurrentUser() user: AuthenticatedUser,
@Param('id', MailIdPipe) id: string,
@Query() query: MailboxDeleteQueryDto,
) {
return this.mail.deleteMailbox(user, id);
return this.mail.deleteMailbox(user, id, query);
}
@Patch(':id/messages')
@@ -2,7 +2,10 @@ import {
Controller,
Delete,
Get,
HttpCode,
HttpStatus,
Param,
Post,
Query,
UseGuards,
} from '@nestjs/common';
@@ -22,6 +25,7 @@ import { JwtAuthGuard } from '@/common/guards/jwt-auth.guard';
import {
ScheduledMessageCanceledDto,
ScheduledMessagePageDto,
SentMessageDto,
} from './dto/mail-response.dto';
import { MailIdPipe } from './mail-id.pipe';
import { SCHEDULED_MESSAGES_ROUTE } from './mail.constants';
@@ -72,10 +76,54 @@ export class ScheduledMessageController {
'MAIL_014',
'이미 보냈다(메일 서버가 풀었음 — 보낸 제출이 있음 · 배달 중) · 붙잡은 적이 없는 메일이다 — 아무것도 바꾸지 않았다',
)
@ApiErrorResponse(
'REQ_001',
'같은 메일의 지금 보내기가 처리 중(15단계) — 엔진에 묻지 않았다',
)
cancel(
@CurrentUser() user: AuthenticatedUser,
@Param('id', MailIdPipe) id: string,
) {
return this.scheduled.cancel(user, id);
}
@Post(':id/release')
@HttpCode(HttpStatus.OK)
@ApiOperation({
summary: '예약한 메일 지금 보내기 (15단계)',
description:
'id 는 보낸 사본의 메일 id 다. 본문 없음. 기다리는 예약 제출을 메일 서버에서 취소한 **뒤에** 같은 메일을 그 제출의 받는 사람(envelope — 숨은참조 포함) 그대로 다시 제출한다 — ' +
'[보내기]를 누른 것처럼 그 직원의 보내기 취소 설정 초만큼 붙잡았다 보낸다(0초면 바로 — 응답 pendingSend). 예약 표시를 떼서 이제 보내기 취소를 기다리는 메일이다(취소는 DELETE /api/scheduled-messages/{id}). ' +
'대용량 첨부 링크의 보낸 때는 지금으로 바꾸고 끝나는 날은 그대로 둔다(본문에 적힌 날짜). 받는 사람별 결과는 발송과 같다(rejectedRecipients). ' +
'이미 보낸 메일(예약 시각이 막 지남)은 오류가 아니다 — 200 pendingSend: null. 이미 지금 보내기를 한 메일(다시 누름 · 응답을 받지 못한 재시도)도 그 발송의 지금 상태로 200. ' +
'메일 서버가 다시 제출을 거절하면 한 번 더 하고, 그래도 거절이면 그 메일을 임시보관함으로 되돌리고(예약 취소와 같은 자리) 503 MAIL_001. ' +
'결과를 모르게 끝나면(시간 초과 · 연결 끊김) 다시 내지도 되돌리지도 않고 503 MAIL_001 — 메일은 보낸편지함에 그대로라 같은 요청을 다시 보내면 이어 낸다 ' +
'(503 뒤에는 그 메일을 다시 읽어 임시보관함인지 보낸편지함인지로 가른다). 54초 남짓 안에 답한다(정리 몫 포함 — 앞단 · 클라이언트 60초 안).',
})
@ApiStandardResponse(SentMessageDto, {
description:
'보냈다 · 보내기 취소를 기다린다(pendingSend.kind = undo) — 발송 응답과 같은 칸 (id 는 같은 메일 id)',
})
@ApiErrorResponse(
'RES_001',
'없는 메일 · 예약이 아닌 메일(보내기 취소를 기다리는 메일 · 취소해 임시보관함으로 간 메일 · 제출이 없음) — 형식이 틀린 id 포함, 아무것도 바꾸지 않았다',
)
@ApiErrorResponse(
'MAIL_010',
'받는 사람 모두 거절(없는 주소 등) — 보내지 않고 임시보관함으로 되돌렸다',
)
@ApiErrorResponse(
'MAIL_011',
'받는 사람 모두 지금은 받을 수 없음 — 보내지 않고 임시보관함으로 되돌렸다',
)
@ApiErrorResponse(
'REQ_001',
'같은 메일의 예약 취소 · 지금 보내기가 처리 중 — 엔진에 묻지 않았다',
)
release(
@CurrentUser() user: AuthenticatedUser,
@Param('id', MailIdPipe) id: string,
) {
return this.scheduled.release(user, id);
}
}
@@ -1,8 +1,15 @@
import { Logger } from '@nestjs/common';
import { BusinessException } from '@/common/errors/business.exception';
import type { MailUserSettingsService } from '@/modules/mail-user-settings/mail-user-settings.service';
import { DEFAULT_MAIL_USER_SETTINGS } from '@/modules/mail-user-settings/mail-user-settings.types';
import { encodeOffsetCursor } from '@/shared/utils/offset-cursor';
import type { MailLargeFilesService } from './mail-large-files.service';
import type { MailProvider } from './mail.provider';
import type { MessageSummary } from './mail.types';
import {
ScheduledSendReturnedToDraftsException,
type MailProvider,
} from './mail.provider';
import type { MessageSummary, ReleasedScheduledSend } from './mail.types';
import { ScheduledMessagesService } from './scheduled-messages.service';
const user = { email: 'ara.kim@arasystem.test', name: '김아라' };
@@ -44,24 +51,48 @@ describe('ScheduledMessagesService — 예약한 메일 · 붙잡은 발송 취
let provider: {
listScheduledMessages: jest.Mock;
cancelPendingSend: jest.Mock;
releaseScheduledSend: jest.Mock;
};
let service: ScheduledMessagesService;
let largeFiles: { onPendingSendCanceled: jest.Mock };
let largeFiles: {
onPendingSendCanceled: jest.Mock;
onScheduledSendReleased: jest.Mock;
};
let userSettings: { get: jest.Mock };
/** 대역 엔진의 지금 보내기 결과 — 보내기 취소를 기다린다 */
const released: ReleasedScheduledSend = {
id: 'm1',
rejectedRecipients: [],
pendingSend: { kind: 'undo', sendAt: '2026-11-09T05:00:10.000Z' },
releasedAt: '2026-11-09T05:00:00.000Z',
};
beforeEach(() => {
provider = {
listScheduledMessages: jest.fn().mockResolvedValue(all),
cancelPendingSend: jest.fn().mockResolvedValue(undefined),
releaseScheduledSend: jest.fn().mockResolvedValue(released),
};
largeFiles = {
onPendingSendCanceled: jest.fn().mockResolvedValue(undefined),
onScheduledSendReleased: jest.fn().mockResolvedValue(undefined),
};
userSettings = {
get: jest.fn().mockResolvedValue({
...DEFAULT_MAIL_USER_SETTINGS,
undoSendSeconds: 20,
}),
};
service = new ScheduledMessagesService(
provider as unknown as MailProvider,
largeFiles as unknown as MailLargeFilesService,
userSettings as unknown as MailUserSettingsService,
);
});
afterEach(() => jest.restoreAllMocks());
it('목록 — 엔진이 준 차례(보낼 시각)대로 한 쪽, total 은 전체 수 · 커서로 이어 받는다', async () => {
const first = await service.list(user, { limit: 2 });
expect(first.items.map(({ id }) => id)).toEqual(['s1', 's2']);
@@ -138,4 +169,123 @@ describe('ScheduledMessagesService — 예약한 메일 · 붙잡은 발송 취
await failureOf(() => service.cancel(user, 'm2'));
expect(largeFiles.onPendingSendCanceled).not.toHaveBeenCalled();
});
describe('지금 보내기 (15단계)', () => {
it('그 직원의 보내기 취소 초만큼 붙잡아 다시 내게 하고, 발송과 같은 칸으로 답한다 — 다시 냈으면 대용량 링크의 보낸 때를 그 시각으로', async () => {
await expect(service.release(user, 'm1')).resolves.toEqual({
id: 'm1',
rejectedRecipients: [],
pendingSend: { kind: 'undo', sendAt: '2026-11-09T05:00:10.000Z' },
});
expect(provider.releaseScheduledSend).toHaveBeenCalledWith(
user.email,
'm1',
{ kind: 'undo', seconds: 20 },
);
expect(largeFiles.onScheduledSendReleased).toHaveBeenCalledWith(
user,
'm1',
new Date('2026-11-09T05:00:00.000Z'),
);
});
it('보내기 취소를 0초로 두었으면 붙잡지 않는다(null) · 설정을 읽지 못하면 처음 값(10초)으로 — 경고만', async () => {
userSettings.get.mockResolvedValueOnce({
...DEFAULT_MAIL_USER_SETTINGS,
undoSendSeconds: 0,
});
await service.release(user, 'm1');
expect(provider.releaseScheduledSend).toHaveBeenLastCalledWith(
user.email,
'm1',
null,
);
const warn = jest
.spyOn(Logger.prototype, 'warn')
.mockImplementation(() => undefined);
userSettings.get.mockRejectedValueOnce(new Error('db down'));
await service.release(user, 'm1');
expect(provider.releaseScheduledSend).toHaveBeenLastCalledWith(
user.email,
'm1',
{ kind: 'undo', seconds: 10 },
);
expect(warn).toHaveBeenCalledTimes(1);
expect(String(warn.mock.calls[0][0])).not.toContain(user.email);
});
it('이미 보낸 메일(releasedAt null)은 링크를 그대로 둔다 — 응답은 pendingSend null', async () => {
provider.releaseScheduledSend.mockResolvedValueOnce({
...released,
pendingSend: null,
releasedAt: null,
});
await expect(service.release(user, 'm1')).resolves.toMatchObject({
pendingSend: null,
});
expect(largeFiles.onScheduledSendReleased).not.toHaveBeenCalled();
});
it('끝내 보내지 못해 임시보관함으로 되돌렸으면 링크를 취소한 발송처럼 둔다 — 그 밖의 실패는 링크를 건드리지 않는다 (코드는 그대로)', async () => {
provider.releaseScheduledSend.mockRejectedValueOnce(
new ScheduledSendReturnedToDraftsException('MAIL_010', {
context: { reason: 'x' },
}),
);
expect(await failureOf(() => service.release(user, 'm1'))).toMatchObject({
code: 'MAIL_010',
});
expect(largeFiles.onPendingSendCanceled).toHaveBeenCalledWith(user, 'm1');
largeFiles.onPendingSendCanceled.mockClear();
for (const code of ['RES_001', 'MAIL_001'] as const) {
provider.releaseScheduledSend.mockRejectedValueOnce(
new BusinessException(code, { context: { reason: 'x' } }),
);
expect(
await failureOf(() => service.release(user, 'm1')),
).toMatchObject({
code,
});
}
expect(largeFiles.onPendingSendCanceled).not.toHaveBeenCalled();
expect(largeFiles.onScheduledSendReleased).not.toHaveBeenCalled();
});
it('같은 직원 · 같은 메일의 예약 취소 · 지금 보내기는 한 번에 하나 — 진행 중이면 엔진에 묻지 않고 REQ_001, 다른 메일은 된다, 끝나면 다시 받는다', async () => {
let finish!: (value: ReleasedScheduledSend) => void;
provider.releaseScheduledSend.mockReturnValueOnce(
new Promise<ReleasedScheduledSend>((resolve) => {
finish = resolve;
}),
);
const running = service.release(user, 'm1');
await new Promise((resolve) => setImmediate(resolve));
expect(provider.releaseScheduledSend).toHaveBeenCalledTimes(1);
for (const attempt of [
() => service.release(user, 'm1'),
() => service.cancel(user, 'm1'),
() =>
service.release({ ...user, email: 'ARA.KIM@arasystem.test' }, 'm1'),
]) {
expect(await failureOf(attempt)).toEqual({
code: 'REQ_001',
reason: 'scheduled-send-in-progress',
});
}
expect(provider.cancelPendingSend).not.toHaveBeenCalled();
await expect(service.cancel(user, 'm2')).resolves.toEqual({
draftId: 'm2',
});
finish(released);
await running;
await expect(service.cancel(user, 'm1')).resolves.toEqual({
draftId: 'm1',
});
});
});
});
@@ -1,4 +1,4 @@
import { Inject, Injectable } from '@nestjs/common';
import { Inject, Injectable, Logger } from '@nestjs/common';
import type { AuthenticatedUser } from '@/common/decorators/current-user.decorator';
import {
@@ -7,28 +7,48 @@ import {
toPaginatedResult,
} from '@/common/dto/pagination.dto';
import { BusinessException } from '@/common/errors/business.exception';
import { MailUserSettingsService } from '@/modules/mail-user-settings/mail-user-settings.service';
import { DEFAULT_MAIL_USER_SETTINGS } from '@/modules/mail-user-settings/mail-user-settings.types';
import { traceTag } from '@/shared/logger/request-context';
import { maskEmail } from '@/shared/utils/mask';
import {
decodeOffsetCursor,
encodeOffsetCursor,
} from '@/shared/utils/offset-cursor';
import { MailLargeFilesService } from './mail-large-files.service';
import { MAIL_PROVIDER, type MailProvider } from './mail.provider';
import type { MessageSummary } from './mail.types';
import {
MAIL_PROVIDER,
ScheduledSendReturnedToDraftsException,
type MailProvider,
} from './mail.provider';
import type { MessageSummary, SendHold, SentMessage } from './mail.types';
import { undoSendHold } from './send-hold';
/** 예약한 메일 목록의 커서 범위 — 다른 목록의 커서를 섞어 쓰지 못하게 (shared/utils/offset-cursor.ts) */
const SCHEDULED_SCOPE = 'scheduled-messages';
/**
* 붙잡아 둔 발송 (계약 2절 "보내기 취소 · 예약 발송" — 9단계) — 예약한 메일 목록과 취소.
* 붙잡아 둔 발송 (계약 2절 "보내기 취소 · 예약 발송" — 9단계) — 예약한 메일 목록 · 취소 · 지금 보내기(15단계).
* 우리 서버는 붙잡은 발송을 따로 기억하지 않는다 — 목록 · 상태는 늘 엔진의 기다리는 제출에 묻는다(붙잡은 메일은 엔진을 다시 띄워도 남는다).
* 보내기(붙잡는 시간을 정하는 것)는 MailService.sendMessage 다
*/
@Injectable()
export class ScheduledMessagesService {
private readonly logger = new Logger(ScheduledMessagesService.name);
/**
* 진행 중인 (직원, 메일)의 취소 · 지금 보내기 — 같은 메일에는 하나씩만(종류가 달라도 막는다 — 계약 2절, 진행 중이면 429 REQ_001).
* 취소와 지금 보내기가 같은 예약 제출을 함께 다루면 한쪽이 취소한 뒤 다른 쪽이 다시 내거나 임시보관함으로 옮긴다.
* ⚠️ 프로세스 메모리다. 인스턴스를 늘리면 Redis 잠금(SET NX PX)으로 옮겨야 인스턴스 사이에서도 하나씩이 된다
*/
private readonly inProgress = new Set<string>();
constructor(
@Inject(MAIL_PROVIDER) private readonly provider: MailProvider,
/** 취소한 발송의 대용량 첨부 링크를 취소한 링크로 (11단계) */
/** 취소한 발송의 대용량 첨부 링크를 취소한 링크로 · 지금 보낸 예약의 링크에 보낸 때를 (11단계 · 15단계) */
private readonly largeFiles: MailLargeFilesService,
/** 지금 보내기의 보내기 취소 초 (15단계 — [보내기]를 누른 것처럼) */
private readonly userSettings: MailUserSettingsService,
) {}
/**
@@ -64,14 +84,88 @@ export class ScheduledMessagesService {
/**
* 붙잡은 발송을 취소하고 그 메일을 임시보관함으로 되돌린다 — 쓰기 화면이 같은 메일 id 로 이어 쓴다 (draftId).
* 없는 메일 RES_001, 이미 보냄 · 기다리는 발송이 아님 MAIL_014, 취소를 확인하지 못함 · 옮기지 못함 MAIL_001 (어댑터 — 취소 · 예약의 로그도 어댑터가 남긴다).
* 엔진에서 취소가 확인된 **뒤에** 그 발송이 만든 대용량 첨부 링크를 취소한 링크로 둔다 (11단계 — 파일은 되돌린 초안이 쓴다)
* 엔진에서 취소가 확인된 **뒤에** 그 발송이 만든 대용량 첨부 링크를 취소한 링크로 둔다 (11단계 — 파일은 되돌린 초안이 쓴다).
* 같은 메일의 지금 보내기가 진행 중이면 REQ_001 (15단계)
*/
async cancel(
user: AuthenticatedUser,
id: string,
): Promise<{ draftId: string }> {
await this.provider.cancelPendingSend(user.email, id);
await this.largeFiles.onPendingSendCanceled(user, id);
return { draftId: id };
return this.exclusive(user, id, async () => {
await this.provider.cancelPendingSend(user.email, id);
await this.largeFiles.onPendingSendCanceled(user, id);
return { draftId: id };
});
}
/**
* 예약한 메일을 지금 보낸다 (계약 2절 "보내기 취소 · 예약 발송" — 15단계) — [보내기]를 누른 것처럼 그 직원의 보내기 취소 초만큼 붙잡았다 보낸다(0 이면 바로).
* 응답은 발송 결과(SentMessage)와 같은 칸이다 — 예약 시각이 막 지나 이미 보냈으면 pendingSend null(오류가 아니다).
* - 예약이 아닌 메일 · 없는 메일 RES_001, 다시 제출하지 못함 MAIL_001 · 받는 사람 모두 거절 MAIL_010 · MAIL_011 (어댑터 — 로그도 어댑터가 남긴다)
* - 다시 제출한 뒤 그 발송이 만든 대용량 첨부 링크의 보낸 때를 지금으로 (끝나는 때는 그대로 — 덩이가 이미 적었다)
* - 끝내 보내지 못해 임시보관함으로 **되돌렸을 때만**(ScheduledSendReturnedToDraftsException) 링크를 취소한 발송처럼 둔다(되돌린 초안이 쓴다).
* 그 밖의 MAIL_001(결과를 모름 · 남은 시간 모자람 · 되돌리기 실패)은 링크를 건드리지 않는다 — 메일은 보낸편지함에 그대로고 다시 부르면 이어 낸다
* - 같은 메일의 취소 · 지금 보내기가 진행 중이면 REQ_001
*/
async release(user: AuthenticatedUser, id: string): Promise<SentMessage> {
return this.exclusive(user, id, async () => {
const hold = await this.undoHold(user);
try {
const { releasedAt, ...sent } =
await this.provider.releaseScheduledSend(user.email, id, hold);
if (releasedAt !== null) {
await this.largeFiles.onScheduledSendReleased(
user,
id,
new Date(releasedAt),
);
}
return sent;
} catch (error) {
if (error instanceof ScheduledSendReturnedToDraftsException) {
await this.largeFiles.onPendingSendCanceled(user, id);
}
throw error;
}
});
}
/**
* 지금 보내기의 붙잡기 — 그 직원 설정의 보내기 취소 초(send-hold.ts). 설정을 읽지 못하면(DB 장애) 처음 값으로 보고 경고만 —
* 발송과 같다(MailService.userSettingsOf)
*/
private async undoHold(user: AuthenticatedUser): Promise<SendHold | null> {
try {
const { undoSendSeconds } = await this.userSettings.get(user.email);
return undoSendHold(undoSendSeconds);
} catch (error) {
this.logger.warn(
`[SYS_001]${traceTag()} 보내기 · 읽기 설정을 읽지 못해 처음 값으로 봅니다: user=${maskEmail(user.email)}, reason=${error instanceof Error ? error.message : String(error)}`,
);
return undoSendHold(DEFAULT_MAIL_USER_SETTINGS.undoSendSeconds);
}
}
/** 같은 직원 · 같은 메일의 취소 · 지금 보내기를 하나씩만 — 진행 중이면 엔진에 묻지 않고 REQ_001. 끝나면 성공 · 실패와 상관없이 푼다 */
private async exclusive<T>(
user: AuthenticatedUser,
id: string,
operation: () => Promise<T>,
): Promise<T> {
const key = `${user.email.toLowerCase()}\n${id}`;
if (this.inProgress.has(key)) {
throw new BusinessException('REQ_001', {
context: {
reason: 'scheduled-send-in-progress',
user: maskEmail(user.email),
},
});
}
this.inProgress.add(key);
try {
return await operation();
} finally {
this.inProgress.delete(key);
}
}
}
+11
View File
@@ -0,0 +1,11 @@
import type { SendHold } from './mail.types';
/**
* 보내기 취소를 기다리는 붙잡기 (계약 2절 "보내기 취소 · 예약 발송") — 그 직원 설정의 초, 0 이면 붙잡지 않는다(null — 바로 보낸다).
* 발송(MailService.sendMessage)과 예약 메일 지금 보내기(ScheduledMessagesService.release — 15단계)가 같이 쓴다 — [보내기]를 누른 것처럼 붙잡는다
*/
export function undoSendHold(undoSendSeconds: number): SendHold | null {
return undoSendSeconds > 0
? { kind: 'undo', seconds: undoSendSeconds }
: null;
}
+577 -20
View File
@@ -84,6 +84,7 @@ import {
AccountLoginDisabledException,
MAIL_PROVIDER,
PasswordExpiredException,
ScheduledSendReturnedToDraftsException,
} from '@/modules/mail/mail.provider';
import { MAIL_SETTINGS, type MailSettings } from '@/modules/mail/mail.settings';
import type {
@@ -93,10 +94,13 @@ import type {
DraftSource,
Mailbox,
MailboxBulkResult,
MailboxChange,
MailboxCreate,
MailboxDeleteOptions,
MailboxDeletion,
MailBlockAdded,
MailBlockList,
MailBlockRunRequest,
MailFilterRule,
MailFilterRunOutcome,
MailFilterRunRequest,
@@ -118,7 +122,9 @@ import type {
PasswordChangeOutcome,
PendingSend,
RecipientRejectionReason,
ReleasedScheduledSend,
SavedDraft,
SendHold,
SentMessage,
} from '@/modules/mail/mail.types';
import {
@@ -326,6 +332,9 @@ const NEVER_CONFIGURED_VACATION: MailVacation = {
/** 9단계 — 대역 메일 엔진이 "이미 보냈다(엔진이 풀었다)"고 답하는 메일 id (409 MAIL_014 경로) */
const ALREADY_RELEASED_MESSAGE = 'alreadyreleased';
/** 15단계 — 대역 메일 엔진의 지금 보내기가 받는 사람을 모두 받지 않아 임시보관함으로 되돌리는 메일 id (400 MAIL_010 경로) */
const RELEASE_REJECTED_MESSAGE = 'releaserejected';
/**
* 9단계 — 대역 메일 엔진이 붙잡고 있는(보내기 취소 · 예약을 기다리는) 메일 id — 옮기기 · 스팸 · 지우기는 막힌다 (409 MAIL_015 · failedIds 경로).
* 붙잡힘 판정(보낼 시각이 지금 뒤인 pending 제출 · fail closed)은 jmap-mail.provider.spec.ts 가 본다
@@ -860,6 +869,12 @@ describe('응답 계약 (e2e)', () => {
update: MessageUpdate;
}[] = [];
/** 15단계 — 대역 엔진이 받은 차단 정리 · 지금 보내기, 직원마다 더한 메일함(하위 메일함이 있는 나무) · 지금 보내기를 붙잡는 문 */
const blockRuns: { user: string; request: MailBlockRunRequest }[] = [];
const releases: { user: string; id: string; hold: SendHold | null }[] = [];
const extraMailboxes = new Map<string, Mailbox[]>();
let slowReleaseGate: Promise<void> | null = null;
/** 9단계 — 대역 엔진의 차단 목록(직원마다 — 최근 것부터) · 예약한 메일 · 취소한 발송 · 바깥 이미지 요청 */
const mailBlocks = new Map<string, BlockedSender[]>();
const scheduledMessages = new Map<string, MessageSummary[]>();
@@ -1588,30 +1603,57 @@ describe('응답 계약 (e2e)', () => {
unreadCount: 0,
});
},
renameMailbox: (
// 15단계 — 이름 · 상위 바꾸기. 상위의 자리 · 고리 판정은 jmap-mail.provider.spec.ts 가 본다 (여기서는 엔진의 답만 흉내 낸다)
updateMailbox: (
user: string,
id: string,
name: string,
change: MailboxChange,
): Promise<Mailbox> => {
mailboxCalls.push({ user, action: 'rename', args: [id, name] });
mailboxCalls.push({ user, action: 'update', args: [id, change] });
if (change.parentId === MISSING_MAILBOX) {
return Promise.reject(
new BusinessException('RES_001', {
context: { reason: 'e2e-parent-not-found' },
}),
);
}
if (change.parentId === id) {
return Promise.reject(
new BusinessException('VAL_001', {
context: { reason: 'e2e-mailbox-move-into-itself' },
}),
);
}
if (change.name === TAKEN_MAILBOX_NAME) {
return Promise.reject(
new BusinessException('MAIL_009', {
context: { reason: 'e2e-mailbox-name-taken' },
}),
);
}
return Promise.resolve({
id,
name,
name: change.name ?? id,
role: null,
parentId: null,
parentId: change.parentId ?? null,
sortOrder: 0,
totalCount: 3,
unreadCount: 1,
});
},
deleteMailbox: (user: string, id: string): Promise<MailboxDeletion> => {
mailboxCalls.push({ user, action: 'delete', args: [id] });
return Promise.resolve({
deleteMailbox: async (
user: string,
id: string,
options?: MailboxDeleteOptions,
): Promise<MailboxDeletion> => {
mailboxCalls.push({ user, action: 'delete', args: [id, options] });
if (id === SLOW_MAILBOX && slowMailboxGate) await slowMailboxGate;
return {
deleted: true,
processed: 4,
movedToTrash: 3,
remaining: 0,
});
};
},
markMailboxRead: (
user: string,
@@ -1646,7 +1688,11 @@ describe('응답 계약 (e2e)', () => {
inactiveScripts.delete(user);
return Promise.resolve({ ...vacation, isActive: true });
},
listMailboxes: (): Promise<Mailbox[]> => Promise.resolve(E2E_MAILBOXES),
listMailboxes: (user: string): Promise<Mailbox[]> =>
Promise.resolve([
...E2E_MAILBOXES,
...(extraMailboxes.get(user) ?? []),
]),
// 6단계 — 엔진 쪽 판단(지문 비교 · 한도)만 흉내 낸다. 만드는 Sieve · 조건부 쓰기는 jmap/*.spec.ts 가 본다
getMailFilters: (user: string): Promise<MailFilterSet> => {
const rules = mailScripts.get(user)?.rules ?? [];
@@ -1724,6 +1770,34 @@ describe('응답 계약 (e2e)', () => {
rounds: 1,
};
},
// 15단계 — 차단 정리. 판정 · 차례 · 이어 할 자리는 jmap-filter-run.spec.ts 가 본다. 필터 적용과 같은 문으로 붙잡는다(같은 잠금 확인)
runMailBlock: async (
user: string,
request: MailBlockRunRequest,
): Promise<MailFilterRunOutcome> => {
blockRuns.push({ user, request });
if (request.cursor === OTHER_RULE_CURSOR) {
throw new BusinessException('VAL_001', {
context: { reason: 'e2e-mail-block-run-other-sender' },
});
}
if (slowFilterRunGate) await slowFilterRunGate;
return request.cursor === null
? {
scanned: 500,
matched: 7,
remaining: 40,
cursor: 'e2eblockcursor',
rounds: 1,
}
: {
scanned: 40,
matched: 1,
remaining: 0,
cursor: null,
rounds: 1,
};
},
openMessageSource: (
_user: string,
id: string,
@@ -1905,6 +1979,46 @@ describe('응답 계약 (e2e)', () => {
canceledSends.push({ user, id });
return Promise.resolve();
},
// 15단계 — 지금 보내기 (취소 → 다시 제출 · 상태 가르기 · 되돌리기는 jmap-mail.provider.spec.ts 가 본다)
releaseScheduledSend: async (
user: string,
id: string,
hold: SendHold | null,
): Promise<ReleasedScheduledSend> => {
releases.push({ user, id, hold });
if (slowReleaseGate) await slowReleaseGate;
if (id === GONE_MESSAGE) {
throw new BusinessException('RES_001', {
context: { reason: 'e2e-not-a-scheduled-send' },
});
}
if (id === RELEASE_REJECTED_MESSAGE) {
throw new ScheduledSendReturnedToDraftsException('MAIL_010', {
context: { reason: 'e2e-release-all-recipients-rejected' },
});
}
if (id === ALREADY_RELEASED_MESSAGE) {
return {
id,
rejectedRecipients: [],
pendingSend: null,
releasedAt: null,
};
}
const now = Date.now();
return {
id,
rejectedRecipients: [],
pendingSend:
hold?.kind === 'undo'
? {
kind: 'undo',
sendAt: new Date(now + hold.seconds * 1_000).toISOString(),
}
: null,
releasedAt: new Date(now).toISOString(),
};
},
})
.overrideProvider(MAIL_SETTINGS)
.useValue(TEST_MAIL_SETTINGS)
@@ -2449,6 +2563,10 @@ describe('응답 계약 (e2e)', () => {
['get', '/api/mail-filters'],
['put', '/api/mail-filters'],
['post', '/api/mail-filter-runs'],
// 15단계 — 차단 정리 · 지금 보내기 (옮기기 · 하위까지 지우기는 위의 PATCH · DELETE 와 같은 경로)
['post', '/api/mail-block-runs'],
['post', '/api/scheduled-messages/eaaaaab/release'],
['delete', '/api/mailboxes/u1?withChildren=true'],
['get', '/api/messages/eaaaaab/source'],
// 템플릿 업로드 예제도 로그인한 직원만 쓴다
['post', '/api/files'],
@@ -3638,27 +3756,21 @@ describe('응답 계약 (e2e)', () => {
expect(body(orphan).error?.code).toBe('RES_001');
});
it('이름 바꾸기는 200 Mailbox — 형식이 틀린 id 는 404 RES_001, 상위는 바꾸지 않는다(모르는 필드)', async () => {
it('이름 바꾸기는 200 Mailbox — 형식이 틀린 id 는 404 RES_001, 이름만 보내면 상위는 그대로 (15단계부터 상위도 받는다 — 아래 "15단계")', async () => {
const res = await send('patch', '/api/mailboxes/u1', {
name: ' 새 이름 ',
}).expect(200);
expect(dataOf<{ name: string }>(res).name).toBe('새 이름');
expect(mailboxCalls.at(-1)).toEqual({
user: ME,
action: 'rename',
args: ['u1', '새 이름'],
action: 'update',
args: ['u1', { name: '새 이름' }],
});
const malformed = await send('patch', '/api/mailboxes/a%20b', {
name: '새 이름',
}).expect(404);
expect(body(malformed).error?.code).toBe('RES_001');
const move = await send('patch', '/api/mailboxes/u1', {
name: '새 이름',
parentId: 'a',
}).expect(400);
expect(body(move).error?.code).toBe('VAL_001');
});
it('지우기는 204 가 아니라 200 { deleted, processed, movedToTrash, remaining }', async () => {
@@ -3671,7 +3783,7 @@ describe('응답 계약 (e2e)', () => {
expect(mailboxCalls.at(-1)).toEqual({
user: ME,
action: 'delete',
args: ['u1'],
args: ['u1', { startedAt: expect.any(Number) as number }],
});
});
@@ -6201,6 +6313,31 @@ describe('응답 계약 (e2e)', () => {
return { sentId, message, token, link };
};
it('예약 메일을 지금 보내면(15단계) 그 링크의 보낸 때를 지금으로 앞당기고 끝나는 날은 그대로 둔다 — 덩이가 적은 날짜 (실제 DB 의 조건부 UPDATE)', async () => {
const ME = 'lf.release@e2e.arasystem.test';
const file = await uploadReady(ME, '지금 보내기 시험', '도면.zip');
const sendAt = new Date(
Math.floor((Date.now() + 3 * 24 * 60 * 60_000) / 1_000) * 1_000,
).toISOString();
const { sentId, link } = await sendAndShare(ME, [file.id], { sendAt });
expect(link?.sentAt).toBe(sendAt);
const expiresAt = link?.expiresAt;
const before = Date.now();
await client(
request(server).post(`/api/scheduled-messages/${sentId}/release`),
ME,
).expect(200);
const after = (await shareLinksOf(ME)).items.find(
(item) => item.sentEmailId === sentId,
);
expect(Date.parse(after?.sentAt ?? '')).toBeGreaterThanOrEqual(
Math.floor(before / 1_000) * 1_000,
);
expect(Date.parse(after?.sentAt ?? '')).toBeLessThan(Date.parse(sendAt));
expect(after?.expiresAt).toBe(expiresAt);
});
it('로그인하지 않았으면 401 AUTH_001 — 공개 경로 밖은 모두', async () => {
const id = randomUUID();
for (const res of [
@@ -11119,6 +11256,426 @@ describe('응답 계약 (e2e)', () => {
});
});
/**
* 15단계 — 메일함 옮기기 · 하위 메일함까지 지우기 · 차단할 때 받은편지함 정리 · 예약 메일 지금 보내기 (계약 2절).
* 엔진 쪽 판단(상위의 자리 · 고리 · 깊은 것부터 지우기 · Sieve 와 같은 판정 · 취소 → 다시 제출)은 jmap-*.spec.ts 가 본다 — 여기서는 HTTP 형식 · 검사 · 잠금 · 응답 꼴을 본다
*/
describe('15단계 — 메일함 옮기기 · 하위까지 지우기 · 차단 정리 · 지금 보내기', () => {
/** 보내기 · 읽기 설정 — 지금 보내기의 보내기 취소 초 (9단계 "보내기 · 읽기 설정") */
const patchSettings = (email: string, payload: object) =>
request(server)
.patch('/api/mail-settings')
.set('Cookie', cookieOf(email))
.send(payload);
describe('메일함 옮기기 (PATCH /api/mailboxes/{id} 의 parentId)', () => {
const ME = 'move.me@e2e.arasystem.test';
const patch = (id: string, payload: object) =>
request(server)
.patch(`/api/mailboxes/${id}`)
.set('Cookie', cookieOf(ME))
.send(payload);
it('parentId 로 옮긴다 — null 은 맨 위, 이름과 함께도 된다 · 보낸 칸만 엔진에 넘긴다', async () => {
const res = await patch('u1', { parentId: 'a' }).expect(200);
expect(body(res)).toEqual({
success: true,
data: {
id: 'u1',
name: 'u1',
role: null,
parentId: 'a',
sortOrder: 0,
totalCount: 3,
unreadCount: 1,
},
});
expect(mailboxCalls.at(-1)).toEqual({
user: ME,
action: 'update',
args: ['u1', { parentId: 'a' }],
});
await patch('u1', { parentId: null }).expect(200);
expect(mailboxCalls.at(-1)?.args).toEqual(['u1', { parentId: null }]);
await patch('u1', { name: ' 2025 ', parentId: 'j' }).expect(200);
expect(mailboxCalls.at(-1)?.args).toEqual([
'u1',
{ name: '2025', parentId: 'j' },
]);
});
it('빈 본문 · 상위 id 형식 · null 이름 · 모르는 필드는 400 VAL_001 — 엔진에 묻지 않는다', async () => {
const before = mailboxCalls.length;
for (const payload of [
{},
{ parentId: '../a' },
{ parentId: 7 },
{ name: null },
{ parentId: 'a', role: 'inbox' },
]) {
const res = await patch('u1', payload).expect(400);
expect(body(res).error?.code).toBe('VAL_001');
}
expect(mailboxCalls).toHaveLength(before);
});
it('엔진의 답 그대로 — 없는 상위 404 RES_001 · 자기 아래 400 VAL_001 · 같은 이름 409 MAIL_009', async () => {
const orphan = await patch('u1', { parentId: MISSING_MAILBOX }).expect(
404,
);
expect(body(orphan).error?.code).toBe('RES_001');
const self = await patch('u1', { parentId: 'u1' }).expect(400);
expect(body(self).error?.code).toBe('VAL_001');
const taken = await patch('u1', {
name: TAKEN_MAILBOX_NAME,
parentId: 'a',
}).expect(409);
expect(body(taken).error?.code).toBe('MAIL_009');
});
});
describe('하위 메일함까지 지우기 (DELETE /api/mailboxes/{id}?withChildren=true)', () => {
const ME = 'tree.me@e2e.arasystem.test';
const CHILD = 'slowchild';
const remove = (id: string, query = '') =>
request(server)
.delete(`/api/mailboxes/${id}${query}`)
.set('Cookie', cookieOf(ME));
beforeAll(() => {
// 대역 엔진의 나무 — SLOW_MAILBOX › CHILD
extraMailboxes.set(ME, [
{
id: SLOW_MAILBOX,
name: '거래처',
role: null,
parentId: null,
sortOrder: 0,
totalCount: 0,
unreadCount: 0,
},
{
id: CHILD,
name: '2025',
role: null,
parentId: SLOW_MAILBOX,
sortOrder: 0,
totalCount: 0,
unreadCount: 0,
},
]);
});
it('withChildren=true 면 하위까지 지우기를 넘기고 200 MailboxDeletion — 다른 값 · 모르는 쿼리는 400 VAL_001 (엔진에 묻지 않는다)', async () => {
const res = await remove('u1', '?withChildren=true').expect(200);
expect(body(res)).toEqual({
success: true,
data: { deleted: true, processed: 4, movedToTrash: 3, remaining: 0 },
});
expect(mailboxCalls.at(-1)).toEqual({
user: ME,
action: 'delete',
args: [
'u1',
{ withChildren: true, startedAt: expect.any(Number) as number },
],
});
const before = mailboxCalls.length;
for (const query of [
'?withChildren=false',
'?withChildren=TRUE',
'?withChildren=1',
'?recursive=true',
]) {
const rejected = await remove('u1', query).expect(400);
expect(body(rejected).error?.code).toBe('VAL_001');
}
expect(mailboxCalls).toHaveLength(before);
});
it(
'나무를 지우는 동안 나무의 메일함 모두가 처리 중이다 — 하위의 전체 읽음 · 나무 안으로 옮기기는 429 REQ_001, 나무 밖은 된다',
async () => {
const httpServer = app.getHttpServer();
const startedServer = !httpServer.listening;
if (startedServer) await listenOnAnyPort(httpServer);
const gate = createGate();
slowMailboxGate = gate.opened;
const before = mailboxCalls.length;
const first = remove(SLOW_MAILBOX, '?withChildren=true').then(
(res) => res,
);
try {
await waitUntil(
() => mailboxCalls.length > before,
GATE_DEADLINE_MS,
);
for (const [method, path, payload] of [
['patch', `/api/mailboxes/${CHILD}/messages`, { isRead: true }],
['delete', `/api/mailboxes/${CHILD}`, undefined],
['patch', '/api/mailboxes/j', { parentId: CHILD }],
] as const) {
const req = request(server)
[method](path)
.set('Cookie', cookieOf(ME));
const busy = await (
payload === undefined ? req : req.send(payload)
).expect(429);
expect(body(busy).error?.code).toBe('REQ_001');
}
expect(mailboxCalls).toHaveLength(before + 1);
// 나무 밖은 된다 · 이름만 바꾸기는 잠그지 않는다(지금처럼)
await request(server)
.patch('/api/mailboxes/a/messages')
.set('Cookie', cookieOf(ME))
.send({ isRead: true })
.expect(200);
await request(server)
.patch(`/api/mailboxes/${CHILD}`)
.set('Cookie', cookieOf(ME))
.send({ name: '새 이름' })
.expect(200);
} finally {
gate.open();
slowMailboxGate = null;
await Promise.allSettled([first]);
if (startedServer) await closeServer(httpServer);
}
expect((await first).status).toBe(200);
},
GATED_TEST_TIMEOUT_MS,
);
});
describe('차단할 때 받은편지함 정리 (POST /api/mail-block-runs)', () => {
const ME = 'blockrun.me@e2e.arasystem.test';
const SENDER = 'kim@spam.e2e.example';
const run = (payload: object) =>
request(server)
.post('/api/mail-block-runs')
.set('Cookie', cookieOf(ME))
.send(payload);
beforeAll(() => {
mailBlocks.set(ME, [
{ address: SENDER, blockedAt: '2026-11-09T01:00:00.000Z' },
{
address: '@spam.e2e.example',
blockedAt: '2026-11-09T01:00:00.000Z',
},
]);
});
it('200 { scanned, matched, remaining, cursor } — cursor 로 이어 하고 null 이면 끝 (차례 수는 응답에 없다)', async () => {
const first = await run({ sender: SENDER, cursor: null }).expect(200);
expect(body(first)).toEqual({
success: true,
data: {
scanned: 500,
matched: 7,
remaining: 40,
cursor: 'e2eblockcursor',
},
});
expect(blockRuns.at(-1)).toEqual({
user: ME,
request: { sender: SENDER, cursor: null },
});
const second = await run({
sender: '@SPAM.e2e.example',
cursor: 'e2eblockcursor',
}).expect(200);
expect(dataOf<{ cursor: string | null }>(second).cursor).toBeNull();
// 차단 목록의 모양(소문자)으로 넘긴다
expect(blockRuns.at(-1)?.request.sender).toBe('@spam.e2e.example');
});
it('차단 목록에 없으면 404 RES_001 · 형식 · cursor 키 없음 · 모르는 필드는 400 VAL_001 · 어댑터의 다른 보낸 사람 cursor 도 400 — 받은편지함을 읽지 않는다', async () => {
const before = blockRuns.length;
const missing = await run({
sender: 'lee@spam.e2e.example',
cursor: null,
}).expect(404);
expect(body(missing).error?.code).toBe('RES_001');
for (const payload of [
{ sender: SENDER },
{ sender: 'not an address', cursor: null },
{ sender: SENDER, cursor: 7 },
{ sender: SENDER, cursor: 'a b' },
{ sender: SENDER, cursor: null, mailboxId: 'a' },
]) {
const rejected = await run(payload).expect(400);
expect(body(rejected).error?.code).toBe('VAL_001');
}
expect(blockRuns).toHaveLength(before);
const other = await run({
sender: SENDER,
cursor: OTHER_RULE_CURSOR,
}).expect(400);
expect(body(other).error?.code).toBe('VAL_001');
});
it(
'같은 직원의 받은편지함 정리(필터 적용 · 차단 정리)는 한 번에 하나 — 진행 중이면 서로 429 REQ_001',
async () => {
const httpServer = app.getHttpServer();
const startedServer = !httpServer.listening;
if (startedServer) await listenOnAnyPort(httpServer);
const gate = createGate();
slowFilterRunGate = gate.opened;
const before = blockRuns.length;
const first = run({ sender: SENDER, cursor: null }).then(
(res) => res,
);
try {
await waitUntil(() => blockRuns.length > before, GATE_DEADLINE_MS);
const again = await run({ sender: SENDER, cursor: null }).expect(
429,
);
expect(body(again).error?.code).toBe('REQ_001');
const filter = await request(server)
.post('/api/mail-filter-runs')
.set('Cookie', cookieOf(ME))
.send({
match: 'always',
conditions: [],
actions: {
moveToMailboxId: null,
markAsRead: true,
flag: false,
},
cursor: null,
})
.expect(429);
expect(body(filter).error?.code).toBe('REQ_001');
// 차단 정리가 도는 동안 그 직원의 스팸메일함(c)도 잡혀 있다 — 비우기는 429 (엔진에 묻지 않는다)
const emptyCalls = mailboxCalls.length;
const empty = await request(server)
.delete('/api/mailboxes/c/messages')
.set('Cookie', cookieOf(ME))
.expect(429);
expect(body(empty).error?.code).toBe('REQ_001');
expect(mailboxCalls).toHaveLength(emptyCalls);
} finally {
gate.open();
slowFilterRunGate = null;
await Promise.allSettled([first]);
if (startedServer) await closeServer(httpServer);
}
expect((await first).status).toBe(200);
},
GATED_TEST_TIMEOUT_MS,
);
});
describe('예약 메일 지금 보내기 (POST /api/scheduled-messages/{id}/release)', () => {
const ME = 'release.me@e2e.arasystem.test';
const release = (id: string) =>
request(server)
.post(`/api/scheduled-messages/${id}/release`)
.set('Cookie', cookieOf(ME));
it('200 — 발송 응답과 같은 칸 { id, rejectedRecipients, pendingSend }, 보내기 취소 설정 초만큼 붙잡게 넘긴다(0초면 null)', async () => {
const res = await release('escheduled1').expect(200);
expect(body(res)).toEqual({
success: true,
data: {
id: 'escheduled1',
rejectedRecipients: [],
pendingSend: {
kind: 'undo',
sendAt: expect.any(String) as string,
},
},
});
expect(releases.at(-1)).toEqual({
user: ME,
id: 'escheduled1',
hold: { kind: 'undo', seconds: 10 },
});
await patchSettings(ME, { undoSendSeconds: 0 }).expect(200);
const now = await release('escheduled2').expect(200);
expect(
dataOf<{ pendingSend: PendingSend | null }>(now).pendingSend,
).toBeNull();
expect(releases.at(-1)?.hold).toBeNull();
await patchSettings(ME, { undoSendSeconds: 10 }).expect(200);
});
it('이미 보낸 메일은 오류가 아니다 — 200 pendingSend: null', async () => {
const res = await release(ALREADY_RELEASED_MESSAGE).expect(200);
expect(body(res)).toEqual({
success: true,
data: {
id: ALREADY_RELEASED_MESSAGE,
rejectedRecipients: [],
pendingSend: null,
},
});
});
it('예약이 아닌 메일 · 형식이 틀린 id 는 404 RES_001 · 받는 사람이 모두 받지 않으면 400 MAIL_010 (임시보관함으로 되돌렸다)', async () => {
const gone = await release(GONE_MESSAGE).expect(404);
expect(body(gone).error?.code).toBe('RES_001');
const before = releases.length;
const malformed = await release('a"b').expect(404);
expect(body(malformed).error?.code).toBe('RES_001');
expect(releases).toHaveLength(before);
const rejected = await release(RELEASE_REJECTED_MESSAGE).expect(400);
expect(body(rejected).error?.code).toBe('MAIL_010');
});
it(
'같은 메일의 지금 보내기 · 예약 취소는 한 번에 하나 — 진행 중이면 429 REQ_001, 다른 메일은 된다',
async () => {
const httpServer = app.getHttpServer();
const startedServer = !httpServer.listening;
if (startedServer) await listenOnAnyPort(httpServer);
const gate = createGate();
slowReleaseGate = gate.opened;
const before = releases.length;
const first = release('eslowrelease').then((res) => res);
try {
await waitUntil(() => releases.length > before, GATE_DEADLINE_MS);
const again = await release('eslowrelease').expect(429);
expect(body(again).error?.code).toBe('REQ_001');
const cancel = await request(server)
.delete('/api/scheduled-messages/eslowrelease')
.set('Cookie', cookieOf(ME))
.expect(429);
expect(body(cancel).error?.code).toBe('REQ_001');
// 다른 메일의 취소는 된다
await request(server)
.delete('/api/scheduled-messages/eotherheld')
.set('Cookie', cookieOf(ME))
.expect(200);
} finally {
gate.open();
slowReleaseGate = null;
await Promise.allSettled([first]);
if (startedServer) await closeServer(httpServer);
}
expect((await first).status).toBe(200);
},
GATED_TEST_TIMEOUT_MS,
);
});
});
describe('출처 검사 (상태 변경 요청)', () => {
const EVIL_ORIGIN = 'https://evil.example';
+843
View File
@@ -0,0 +1,843 @@
<!doctype html>
<html lang="ko">
<head>
<meta charset="utf-8">
<title>끌어다 놓기 · 스팸메일함으로 옮기기 · 좁은 폭 상단 바</title>
<script src="./support.js"></script>
</head>
<body>
<x-dc>
<helmet>
<style>
@font-face{font-family:'Pretendard';src:url('/_blob/16067b9aa5137abf808e3dbef30c42e0') format('woff2-variations'),url('/_blob/16067b9aa5137abf808e3dbef30c42e0') format('woff2');font-weight:45 920;font-style:normal;font-display:swap}
body{margin:0}
a{color:#0E7C86}a:hover{color:#0A5F67}
:focus-visible{outline:2px solid #0E7C86;outline-offset:2px}
.on-dark:focus-visible{outline-color:#9ED6DB}
.cv-grid{display:grid;grid-template-columns:32px 32px 168px minmax(0,1fr) 84px;column-gap:12px;align-items:center}
</style>
</helmet>
<div style="width: 1440px; height: 3845px; box-sizing: border-box; display: flex; flex-direction: column; background: #F6F7F9; color: #171C26; font-family: Pretendard, -apple-system, 'Apple SD Gothic Neo', 'Malgun Gothic', system-ui, sans-serif; font-size: 13px; line-height: 1.5">
<div style="flex: 0 0 auto; box-sizing: border-box; padding: 40px 48px 0; display: flex; flex-direction: column; gap: 6px; max-width: 1200px">
<h1 style="margin: 0; font-size: 24px; font-weight: 700; letter-spacing: -0.02em">끌어다 놓기 · 스팸메일함으로 옮기기 · 좁은 폭 상단 바</h1>
<p style="margin: 0; font-size: 13.5px; color: #4A5363; word-break: keep-all">메일 15단계(메일 · 메일함 다루기)에서 정한 것 가운데 3 · 4 · 9의 화면입니다. 목록에서 고른 메일이나 대화 줄을 메일함 패널로 끌어다 놓아 옮기고, 스팸메일함으로 옮기는 세 길(단추, 메뉴, 끌기)을 같게 맞추고, 휴대폰 폭에서 상단 바의 [로그아웃]이 꺾이지 않게 합니다. 1의 단추로 마우스가 있는 곳을 바꿔 볼 수 있습니다. 초안이며 사용자 확인 전입니다(2026-11-09).</p>
</div>
<section aria-labelledby="sec1-h" style="flex: 0 0 auto; box-sizing: border-box; padding: 32px 48px 16px; display: flex; flex-direction: column; gap: 12px">
<div style="display: flex; align-items: baseline; gap: 16px">
<h2 id="sec1-h" style="margin: 0; font-size: 19px; line-height: 28px; font-weight: 700; color: #0B3640; white-space: nowrap">1. 분할 보기 (1440) · 고른 메일 3통을 끌어다 놓는 중</h2>
<p style="margin: 0; font-size: 13.5px; color: #4A5363; word-break: keep-all">받은편지함에서 메일 3통을 고른 채 그중 한 줄(박지훈)을 눌러 메일함 패널로 끌고 있습니다. 마우스 오른쪽 아래에 메일 수를 적은 표가 따라옵니다.</p>
</div>
<div style="display: flex; align-items: center; flex-wrap: wrap; gap: 8px 16px">
<span id="over-label" style="font-size: 13.5px; font-weight: 700; color: #344054">마우스가 있는 곳</span>
<div role="group" aria-labelledby="over-label" style="display: flex; flex-wrap: wrap; gap: 8px">
<sc-for list="{{ overModes }}" as="s" hint-placeholder-count="6">
<button type="button" aria-pressed="{{ s.pressed }}" onClick="{{ s.pick }}" style="flex: 0 0 auto; min-width: 112px; min-height: 44px; box-sizing: border-box; padding: 0 14px; border: 1px solid {{ s.border }}; border-radius: 10px; background: {{ s.bg }}; color: {{ s.fg }}; font: inherit; font-size: 14px; font-weight: 600; cursor: pointer">{{ s.label }}</button>
</sc-for>
</div>
</div>
<div style="display: flex; flex-direction: column; gap: 1px">
<h3 style="margin: 0; font-size: 13px; font-weight: 700; color: #344054">{{ overTitle }}</h3>
<p style="margin: 0; font-size: 12.5px; color: #667085; word-break: keep-all">{{ overNote }}</p>
</div>
</section>
<div id="board-split" role="region" aria-label="{{ splitRegionLabel }}" style="flex: 0 0 auto; height: 724px; box-sizing: border-box; display: flex; background: #FFFFFF; border-top: 1px solid #E6E8EC; border-bottom: 1px solid #E6E8EC; overflow: hidden">
<nav aria-label="사내 시스템" style="flex: 0 0 72px; box-sizing: border-box; display: flex; flex-direction: column; align-items: center; gap: 14px; padding: 10px 0 12px; background: #0B3640">
<a href="#" style="display: inline-flex; align-items: center; justify-content: center; width: 60px; min-height: 44px; border-radius: 10px; text-decoration: none">
<img src="/_blob/2b9ef4349333debc969432c859eaf1bf" alt="ARA SYSTEM" width="48" height="17" style="display: block; width: 48px; height: auto">
</a>
<ul style="list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 4px">
<li>
<a href="#" aria-current="page" style="display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 3px; width: 60px; min-height: 56px; border-radius: 10px; background: #155A64; color: #FFFFFF; font-size: 11px; font-weight: 600; text-decoration: none">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z"></path><polyline points="22,6 12,13 2,6"></polyline></svg>
<span>메일</span>
</a>
</li>
<li>
<a href="#" style="display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 3px; width: 60px; min-height: 56px; border-radius: 10px; color: #A3C2C6; font-size: 11px; font-weight: 500; text-decoration: none">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="4" width="18" height="18" rx="2" ry="2"></rect><line x1="16" y1="2" x2="16" y2="6"></line><line x1="8" y1="2" x2="8" y2="6"></line><line x1="3" y1="10" x2="21" y2="10"></line></svg>
<span>캘린더</span>
</a>
</li>
<li>
<a href="#" style="display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 3px; width: 60px; min-height: 56px; border-radius: 10px; color: #A3C2C6; font-size: 11px; font-weight: 500; text-decoration: none">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"></path></svg>
<span>파일</span>
</a>
</li>
<li>
<a href="#" style="display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 3px; width: 60px; min-height: 56px; border-radius: 10px; color: #A3C2C6; font-size: 11px; font-weight: 500; text-decoration: none">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"></path><circle cx="9" cy="7" r="4"></circle><path d="M23 21v-2a4 4 0 0 0-3-3.87"></path><path d="M16 3.13a4 4 0 0 1 0 7.75"></path></svg>
<span>주소록</span>
</a>
</li>
</ul>
<a href="#" style="margin-top: auto; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 3px; width: 60px; min-height: 56px; border-radius: 10px; color: #A3C2C6; font-size: 11px; font-weight: 500; text-decoration: none">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="3"></circle><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"></path></svg>
<span>설정</span>
</a>
</nav>
<div style="flex: 1 1 auto; min-width: 0; display: flex; flex-direction: column">
<header style="display: flex; align-items: center; flex-wrap: wrap; gap: 8px 16px; box-sizing: border-box; min-height: 56px; padding: 6px 12px 6px 20px; border-bottom: 1px solid #E6E8EC">
<span style="font-size: 16px; font-weight: 700; letter-spacing: -0.01em">메일</span>
<form role="search" style="flex: 1 1 280px; max-width: 520px; display: flex; align-items: center; gap: 8px; box-sizing: border-box; height: 40px; margin: 0; padding: 0 4px 0 12px; border: 1px solid #8A94A3; border-radius: 8px; background: #FFFFFF">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#667085" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="11" cy="11" r="8"></circle><line x1="21" y1="21" x2="16.65" y2="16.65"></line></svg>
<input type="search" aria-label="메일 검색" placeholder="메일 검색" style="flex: 1 1 auto; min-width: 0; border: 0; outline: none; background: transparent; font: inherit; color: #171C26">
<button type="button" title="조건으로 찾기" style="flex: 0 0 auto; display: inline-flex; align-items: center; gap: 4px; height: 32px; padding: 0 9px; border: 0; border-radius: 6px; background: transparent; color: #4A5363; font: inherit; font-size: 12.5px; font-weight: 600; cursor: pointer">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="4" y1="21" x2="4" y2="14"></line><line x1="4" y1="10" x2="4" y2="3"></line><line x1="12" y1="21" x2="12" y2="12"></line><line x1="12" y1="8" x2="12" y2="3"></line><line x1="20" y1="21" x2="20" y2="16"></line><line x1="20" y1="12" x2="20" y2="3"></line><line x1="1" y1="14" x2="7" y2="14"></line><line x1="9" y1="8" x2="15" y2="8"></line><line x1="17" y1="16" x2="23" y2="16"></line></svg>
<span>조건</span>
</button>
</form>
<div style="margin-left: auto; display: flex; align-items: center; gap: 10px">
<span aria-hidden="true" style="display: inline-flex; align-items: center; justify-content: center; width: 32px; height: 32px; border-radius: 50%; background: #E3F2F3; color: #0A5F67; font-weight: 600">김</span>
<div style="display: flex; flex-direction: column; line-height: 1.3">
<span style="font-weight: 600">김아라</span>
<span style="font-size: 12px; color: #667085">ara.kim@arasystem.it.kr</span>
</div>
<button type="button" style="display: inline-flex; align-items: center; gap: 6px; min-height: 44px; padding: 0 10px; border: 0; border-radius: 8px; background: transparent; color: #4A5363; font: inherit; font-weight: 500; cursor: pointer">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"></path><polyline points="16 17 21 12 16 7"></polyline><line x1="21" y1="12" x2="9" y2="12"></line></svg>
<span>로그아웃</span>
</button>
</div>
</header>
<div style="flex: 1 1 auto; min-height: 0; display: flex; align-items: stretch">
<nav aria-label="메일함" style="flex: 0 0 208px; box-sizing: border-box; display: flex; flex-direction: column; gap: 14px; padding: 14px 10px; background: #F8F9FB; border-right: 1px solid #E6E8EC">
<a href="#" style="display: flex; align-items: center; justify-content: center; gap: 8px; min-height: 44px; border-radius: 8px; background: #0E7C86; color: #FFFFFF; font-size: 13.5px; font-weight: 600; text-decoration: none">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 20h9"></path><path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z"></path></svg>
<span>메일 쓰기</span>
</a>
<div style="display: flex; flex-direction: column; gap: 1px">
<ul style="list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 1px">
<sc-for list="{{ sys1 }}" as="b" hint-placeholder-count="6">
<li style="position: relative; display: flex; align-items: center; border-radius: 7px; background: {{ b.bg }}; outline: {{ b.outline }}; outline-offset: -2px">
<a href="#" aria-current="{{ b.current }}" style="flex: 1 1 auto; min-width: 0; display: flex; align-items: center; gap: 10px; min-height: 40px; box-sizing: border-box; padding: 0 36px 0 {{ b.indent }}px; border-radius: 7px; color: {{ b.fg }}; font-weight: {{ b.fw }}; text-decoration: none">
<sc-if value="{{ b.isInbox }}" hint-placeholder-val="{{ false }}"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" style="flex: 0 0 auto"><polyline points="22 12 16 12 14 15 10 15 8 12 2 12"></polyline><path d="M5.45 5.11 2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.45-6.89A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z"></path></svg></sc-if>
<sc-if value="{{ b.isSent }}" hint-placeholder-val="{{ false }}"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" style="flex: 0 0 auto"><line x1="22" y1="2" x2="11" y2="13"></line><polygon points="22 2 15 22 11 13 2 9 22 2"></polygon></svg></sc-if>
<sc-if value="{{ b.isClock }}" hint-placeholder-val="{{ false }}"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" style="flex: 0 0 auto"><circle cx="12" cy="12" r="10"></circle><polyline points="12 6 12 12 16 14"></polyline></svg></sc-if>
<sc-if value="{{ b.isDrafts }}" hint-placeholder-val="{{ false }}"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" style="flex: 0 0 auto"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"></path><polyline points="14 2 14 8 20 8"></polyline><line x1="16" y1="13" x2="8" y2="13"></line><line x1="16" y1="17" x2="8" y2="17"></line></svg></sc-if>
<sc-if value="{{ b.isJunk }}" hint-placeholder-val="{{ false }}"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" style="flex: 0 0 auto"><polygon points="7.86 2 16.14 2 22 7.86 22 16.14 16.14 22 7.86 22 2 16.14 2 7.86 7.86 2"></polygon><line x1="12" y1="8" x2="12" y2="12"></line><line x1="12" y1="16" x2="12.01" y2="16"></line></svg></sc-if>
<sc-if value="{{ b.isTrash }}" hint-placeholder-val="{{ false }}"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" style="flex: 0 0 auto"><polyline points="3 6 5 6 21 6"></polyline><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path></svg></sc-if>
<sc-if value="{{ b.isFolder }}" hint-placeholder-val="{{ false }}"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" style="flex: 0 0 auto"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"></path></svg></sc-if>
<span style="flex: 1 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap">{{ b.name }}</span>
<sc-if value="{{ b.hasCount }}" hint-placeholder-val="{{ false }}"><span style="font-size: 12px; font-weight: 600; color: {{ b.countColor }}; font-variant-numeric: tabular-nums">{{ b.count }}</span></sc-if>
</a>
<sc-if value="{{ b.pointerHere }}" hint-placeholder-val="{{ false }}">
<span aria-hidden="true" style="position: absolute; left: {{ b.pointerX }}px; top: 13px; z-index: 6; width: 0; height: 0; pointer-events: none">
<sc-if value="{{ b.dropOk }}" hint-placeholder-val="{{ true }}"><svg width="16" height="22" viewBox="0 0 16 22" style="position: absolute; left: -1px; top: -1px; display: block"><path d="M1.5 1.5 L1.5 17.5 L5.4 13.9 L8 20 L10.8 18.8 L8.3 12.9 L13.6 12.9 Z" fill="#FFFFFF" stroke="#171C26" stroke-width="1.3" stroke-linejoin="round"></path></svg></sc-if>
<sc-if value="{{ b.dropNo }}" hint-placeholder-val="{{ false }}"><svg width="24" height="24" viewBox="0 0 24 24" style="position: absolute; left: -12px; top: -12px; display: block"><circle cx="12" cy="12" r="8.5" fill="none" stroke="#FFFFFF" stroke-width="5"></circle><line x1="6" y1="6" x2="18" y2="18" stroke="#FFFFFF" stroke-width="5"></line><circle cx="12" cy="12" r="8.5" fill="none" stroke="#171C26" stroke-width="2.4"></circle><line x1="6" y1="6" x2="18" y2="18" stroke="#171C26" stroke-width="2.4"></line></svg></sc-if>
<span style="position: absolute; left: 16px; top: 22px; display: inline-flex; align-items: center; gap: 6px; padding: 5px 10px 5px 8px; border-radius: 8px; background: #0B3640; color: #FFFFFF; font-size: 12.5px; font-weight: 600; line-height: 1.4; white-space: nowrap; box-shadow: 0 6px 16px rgba(16, 24, 28, 0.28)">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="#9ED6DB" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" style="flex: 0 0 auto"><path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z"></path><polyline points="22,6 12,13 2,6"></polyline></svg>
<span>{{ chip1 }}</span>
</span>
</span>
</sc-if>
</li>
</sc-for>
</ul>
<p style="margin: 12px 10px 4px; font-size: 12px; font-weight: 600; color: #667085">내 메일함</p>
<ul style="list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 1px">
<sc-for list="{{ mine1 }}" as="b" hint-placeholder-count="4">
<li style="position: relative; display: flex; align-items: center; border-radius: 7px; background: {{ b.bg }}; outline: {{ b.outline }}; outline-offset: -2px">
<a href="#" aria-current="{{ b.current }}" style="flex: 1 1 auto; min-width: 0; display: flex; align-items: center; gap: 10px; min-height: 40px; box-sizing: border-box; padding: 0 36px 0 {{ b.indent }}px; border-radius: 7px; color: {{ b.fg }}; font-weight: {{ b.fw }}; text-decoration: none">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" style="flex: 0 0 auto"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"></path></svg>
<span style="flex: 1 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap">{{ b.name }}</span>
<sc-if value="{{ b.hasCount }}" hint-placeholder-val="{{ false }}"><span style="font-size: 12px; font-weight: 600; color: {{ b.countColor }}; font-variant-numeric: tabular-nums">{{ b.count }}</span></sc-if>
</a>
<sc-if value="{{ b.pointerHere }}" hint-placeholder-val="{{ false }}">
<span aria-hidden="true" style="position: absolute; left: {{ b.pointerX }}px; top: 13px; z-index: 6; width: 0; height: 0; pointer-events: none">
<sc-if value="{{ b.dropOk }}" hint-placeholder-val="{{ true }}"><svg width="16" height="22" viewBox="0 0 16 22" style="position: absolute; left: -1px; top: -1px; display: block"><path d="M1.5 1.5 L1.5 17.5 L5.4 13.9 L8 20 L10.8 18.8 L8.3 12.9 L13.6 12.9 Z" fill="#FFFFFF" stroke="#171C26" stroke-width="1.3" stroke-linejoin="round"></path></svg></sc-if>
<sc-if value="{{ b.dropNo }}" hint-placeholder-val="{{ false }}"><svg width="24" height="24" viewBox="0 0 24 24" style="position: absolute; left: -12px; top: -12px; display: block"><circle cx="12" cy="12" r="8.5" fill="none" stroke="#FFFFFF" stroke-width="5"></circle><line x1="6" y1="6" x2="18" y2="18" stroke="#FFFFFF" stroke-width="5"></line><circle cx="12" cy="12" r="8.5" fill="none" stroke="#171C26" stroke-width="2.4"></circle><line x1="6" y1="6" x2="18" y2="18" stroke="#171C26" stroke-width="2.4"></line></svg></sc-if>
<span style="position: absolute; left: 16px; top: 22px; display: inline-flex; align-items: center; gap: 6px; padding: 5px 10px 5px 8px; border-radius: 8px; background: #0B3640; color: #FFFFFF; font-size: 12.5px; font-weight: 600; line-height: 1.4; white-space: nowrap; box-shadow: 0 6px 16px rgba(16, 24, 28, 0.28)">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="#9ED6DB" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" style="flex: 0 0 auto"><path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z"></path><polyline points="22,6 12,13 2,6"></polyline></svg>
<span>{{ chip1 }}</span>
</span>
</span>
</sc-if>
</li>
</sc-for>
</ul>
<span style="display: flex; align-items: center; gap: 10px; min-height: 44px; margin-top: 2px; padding: 0 10px; border-radius: 7px; color: #344054; font-weight: 500">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="12" y1="5" x2="12" y2="19"></line><line x1="5" y1="12" x2="19" y2="12"></line></svg>
<span>새 메일함</span>
</span>
</div>
<div style="margin-top: auto; display: flex; flex-direction: column; gap: 6px; padding: 12px 10px 2px; border-top: 1px solid #E6E8EC">
<div style="display: flex; justify-content: space-between; gap: 8px; font-size: 12px; color: #667085">
<span>메일함 사용량</span>
<span style="font-variant-numeric: tabular-nums">0.9 / 5 GB</span>
</div>
<div style="height: 4px; border-radius: 2px; background: #E4E7EC">
<div style="width: 18%; height: 4px; border-radius: 2px; background: #0E7C86"></div>
</div>
</div>
</nav>
<section aria-labelledby="split-count" style="flex: 0 0 384px; min-width: 0; box-sizing: border-box; display: flex; flex-direction: column; border-right: 1px solid #E6E8EC">
<div style="display: flex; align-items: center; min-height: 52px; box-sizing: border-box; padding: 4px 4px 4px 12px; border-bottom: 1px solid #E6E8EC; background: #F3F8F8">
<h2 id="split-count" style="flex: 0 0 auto; margin: 0 4px 0 0; font-size: 14px; font-weight: 700; white-space: nowrap; font-variant-numeric: tabular-nums">3통 선택됨</h2>
<div role="toolbar" aria-label="고른 메일" style="flex: 1 1 auto; min-width: 0; display: flex; align-items: center">
<button type="button" aria-label="읽음 표시" title="읽음 표시" style="width: 40px; height: 44px; display: inline-flex; align-items: center; justify-content: center; border: 0; border-radius: 8px; background: transparent; color: #344054; cursor: pointer"><svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21.2 8.4c.5.38.8.97.8 1.6v10a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V10a2 2 0 0 1 .8-1.6l8-6a2 2 0 0 1 2.4 0l8 6Z"></path><path d="m22 10-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 10"></path></svg></button>
<button type="button" aria-label="안 읽음 표시" title="안 읽음 표시" style="width: 40px; height: 44px; display: inline-flex; align-items: center; justify-content: center; border: 0; border-radius: 8px; background: transparent; color: #344054; cursor: pointer"><svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z"></path><polyline points="22,6 12,13 2,6"></polyline></svg></button>
<button type="button" aria-label="중요 표시" title="중요 표시" style="width: 40px; height: 44px; display: inline-flex; align-items: center; justify-content: center; border: 0; border-radius: 8px; background: transparent; color: #344054; cursor: pointer"><svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"></polygon></svg></button>
<button type="button" aria-label="이동" title="이동" aria-haspopup="menu" style="width: 40px; height: 44px; display: inline-flex; align-items: center; justify-content: center; border: 0; border-radius: 8px; background: transparent; color: #344054; cursor: pointer"><svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M2 9V5a2 2 0 0 1 2-2h3.9a2 2 0 0 1 1.69.9l.81 1.2a2 2 0 0 0 1.67.9H20a2 2 0 0 1 2 2v10a2 2 0 0 1-2 2H4a2 2 0 0 1-2-1.5"></path><path d="M2 13h10"></path><path d="m9 16 3-3-3-3"></path></svg></button>
<button type="button" aria-label="스팸 신고" title="스팸 신고" style="width: 40px; height: 44px; display: inline-flex; align-items: center; justify-content: center; border: 0; border-radius: 8px; background: transparent; color: #344054; cursor: pointer"><svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polygon points="7.86 2 16.14 2 22 7.86 22 16.14 16.14 22 7.86 22 2 16.14 2 7.86 7.86 2"></polygon><line x1="12" y1="8" x2="12" y2="12"></line><line x1="12" y1="16" x2="12.01" y2="16"></line></svg></button>
<button type="button" aria-label="삭제" title="삭제" style="width: 40px; height: 44px; display: inline-flex; align-items: center; justify-content: center; border: 0; border-radius: 8px; background: transparent; color: #B42318; cursor: pointer"><svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="3 6 5 6 21 6"></polyline><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path></svg></button>
<button type="button" aria-label="선택 해제" title="선택 해제 (Esc)" style="margin-left: auto; width: 40px; height: 44px; display: inline-flex; align-items: center; justify-content: center; border: 0; border-radius: 8px; background: transparent; color: #344054; cursor: pointer"><svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line></svg></button>
</div>
</div>
<ul aria-label="받은편지함 메일 목록" style="list-style: none; margin: 0; padding: 0">
<sc-for list="{{ items1 }}" as="r" hint-placeholder-count="10">
<li style="display: flex; align-items: stretch; border-bottom: 1px solid #EEF0F3; background: {{ r.bg }}">
<button type="button" role="checkbox" aria-checked="{{ r.checkedStr }}" aria-label="{{ r.pickLabel }}" style="flex: 0 0 auto; width: 36px; display: flex; align-items: center; justify-content: flex-end; padding: 0; border: 0; background: transparent; cursor: pointer">
<sc-if value="{{ r.checked }}" hint-placeholder-val="{{ false }}"><svg width="16" height="16" viewBox="0 0 16 16" aria-hidden="true"><rect x="0.25" y="0.25" width="15.5" height="15.5" rx="4" fill="#0E7C86"></rect><polyline points="4.2 8.3 6.9 10.8 11.8 5.4" fill="none" stroke="#FFFFFF" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"></polyline></svg></sc-if>
<sc-if value="{{ r.notChecked }}" hint-placeholder-val="{{ true }}"><svg width="16" height="16" viewBox="0 0 16 16" aria-hidden="true"><rect x="1" y="1" width="14" height="14" rx="3.5" fill="#FFFFFF" stroke="#8A94A3" stroke-width="1.5"></rect></svg></sc-if>
</button>
<a href="#" style="flex: 1 1 auto; min-width: 0; min-height: 50px; box-sizing: border-box; display: flex; flex-direction: column; justify-content: center; gap: 1px; padding: 6px 2px 6px 10px; line-height: 1.4; color: inherit; text-decoration: none">
<span style="display: flex; align-items: center; gap: 6px">
<span style="flex: 1 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-weight: {{ r.whoFw }}; color: {{ r.whoColor }}">{{ r.who }}</span>
<span style="flex: 0 0 auto; font-size: 12px; font-weight: {{ r.timeFw }}; color: {{ r.timeColor }}; font-variant-numeric: tabular-nums">{{ r.time }}</span>
</span>
<span style="display: flex; align-items: center; gap: 6px; min-width: 0">
<span style="min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-weight: {{ r.subFw }}; color: #171C26">{{ r.subject }}</span>
<sc-if value="{{ r.hasAtt }}" hint-placeholder-val="{{ false }}"><svg role="img" aria-label="첨부 있음" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="#667085" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="flex: 0 0 auto"><path d="M21.44 11.05l-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48"></path></svg></sc-if>
</span>
</a>
<button type="button" aria-label="중요 표시" aria-pressed="{{ r.starredStr }}" style="flex: 0 0 auto; width: 44px; display: flex; align-items: center; justify-content: center; border: 0; background: transparent; color: {{ r.starColor }}; cursor: pointer">
<svg width="15" height="15" viewBox="0 0 24 24" fill="{{ r.starFill }}" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"></polygon></svg>
</button>
</li>
</sc-for>
</ul>
</section>
<div style="flex: 1 1 auto; min-width: 0; display: flex; flex-direction: column">
<div style="border-bottom: 1px solid #E6E8EC">
<div role="group" aria-label="이 메일로 할 일" style="display: flex; flex-wrap: wrap; align-items: center; gap: 2px; min-height: 52px; max-width: 800px; box-sizing: border-box; padding: 4px 32px 4px 22px">
<span style="display: inline-flex; align-items: center; gap: 6px; min-height: 44px; box-sizing: border-box; padding: 0 10px; border-radius: 8px; color: #344054; font-weight: 500">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="9 14 4 9 9 4"></polyline><path d="M20 20v-7a4 4 0 0 0-4-4H4"></path></svg>
<span>답장</span>
</span>
<span style="display: inline-flex; align-items: center; gap: 6px; min-height: 44px; box-sizing: border-box; padding: 0 10px; border-radius: 8px; color: #344054; font-weight: 500">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="7 17 2 12 7 7"></polyline><polyline points="12 17 7 12 12 7"></polyline><path d="M22 18v-2a4 4 0 0 0-4-4H7"></path></svg>
<span>전체 답장</span>
</span>
<span style="display: inline-flex; align-items: center; gap: 6px; min-height: 44px; box-sizing: border-box; padding: 0 10px; border-radius: 8px; color: #344054; font-weight: 500">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="15 14 20 9 15 4"></polyline><path d="M4 20v-7a4 4 0 0 1 4-4h12"></path></svg>
<span>전달</span>
</span>
<span aria-hidden="true" style="width: 1px; height: 20px; margin: 0 6px; background: #E6E8EC"></span>
<span style="display: inline-flex; align-items: center; gap: 6px; min-height: 44px; box-sizing: border-box; padding: 0 8px 0 10px; border-radius: 8px; color: #344054; font-weight: 500">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M2 9V5a2 2 0 0 1 2-2h3.9a2 2 0 0 1 1.69.9l.81 1.2a2 2 0 0 0 1.67.9H20a2 2 0 0 1 2 2v10a2 2 0 0 1-2 2H4a2 2 0 0 1-2-1.5"></path><path d="M2 13h10"></path><path d="m9 16 3-3-3-3"></path></svg>
<span>이동</span>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="6 9 12 15 18 9"></polyline></svg>
</span>
<span style="display: inline-flex; align-items: center; gap: 6px; min-height: 44px; box-sizing: border-box; padding: 0 10px; border-radius: 8px; color: #344054; font-weight: 500">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polygon points="7.86 2 16.14 2 22 7.86 22 16.14 16.14 22 7.86 22 2 16.14 2 7.86 7.86 2"></polygon><line x1="12" y1="8" x2="12" y2="12"></line><line x1="12" y1="16" x2="12.01" y2="16"></line></svg>
<span>스팸 신고</span>
</span>
<span style="display: inline-flex; align-items: center; gap: 6px; min-height: 44px; box-sizing: border-box; padding: 0 10px; border-radius: 8px; color: #B42318; font-weight: 500">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="3 6 5 6 21 6"></polyline><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path></svg>
<span>삭제</span>
</span>
<span aria-label="더 보기" style="width: 40px; height: 44px; display: inline-flex; align-items: center; justify-content: center; border-radius: 8px; color: #344054"><svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><circle cx="5" cy="12" r="1.6"></circle><circle cx="12" cy="12" r="1.6"></circle><circle cx="19" cy="12" r="1.6"></circle></svg></span>
</div>
</div>
<article aria-labelledby="split-subject" style="display: flex; flex-direction: column; gap: 18px; max-width: 800px; box-sizing: border-box; padding: 20px 32px 36px">
<div style="display: flex; align-items: flex-start; gap: 8px">
<h3 id="split-subject" style="flex: 1 1 auto; margin: 4px 0 0; font-size: 20px; line-height: 1.4; font-weight: 650; letter-spacing: -0.015em">Re: 유지보수 계약 갱신 건</h3>
<span aria-hidden="true" style="flex: 0 0 auto; width: 44px; height: 44px; display: inline-flex; align-items: center; justify-content: center; color: #98A2B3"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"></polygon></svg></span>
</div>
<div style="display: flex; align-items: flex-start; gap: 12px">
<span aria-hidden="true" style="flex: 0 0 auto; display: inline-flex; align-items: center; justify-content: center; width: 36px; height: 36px; border-radius: 50%; background: #E3F2F3; color: #0A5F67; font-size: 14px; font-weight: 600">이</span>
<div style="flex: 1 1 auto; min-width: 0; display: flex; flex-direction: column; gap: 1px">
<div style="display: flex; flex-wrap: wrap; align-items: baseline; gap: 2px 8px">
<span style="font-size: 13.5px; font-weight: 600">이도윤</span>
<span style="font-size: 12.5px; color: #667085">doyun.lee@arasystem.it.kr</span>
<span style="margin-left: auto; font-size: 12.5px; color: #667085; white-space: nowrap; font-variant-numeric: tabular-nums">2026. 11. 9. (월) 오전 10:42</span>
</div>
<span style="font-size: 12.5px; color: #4A5363">받는 사람 김아라 &lt;ara.kim@arasystem.it.kr&gt;</span>
</div>
</div>
<div style="font-size: 14px; line-height: 1.75; color: #1F2733; max-width: 680px">
<p style="margin: 0">김아라님, 안녕하세요.</p>
<p style="margin: 14px 0 0">말씀하신 조건으로 계약서 초안을 고쳐 첨부했습니다.<br>기간은 1년이고, 단가는 작년과 같습니다.</p>
<p style="margin: 14px 0 0">이도윤 드림</p>
</div>
<section aria-labelledby="split-att" style="display: flex; flex-direction: column; gap: 8px; padding-top: 16px; border-top: 1px solid #EEF0F3">
<h4 id="split-att" style="margin: 0; font-size: 12.5px; font-weight: 600; color: #4A5363">첨부 파일 1개</h4>
<div style="display: flex; align-items: center; gap: 10px; max-width: 320px; padding: 4px 2px 4px 8px; border: 1px solid #E6E8EC; border-radius: 8px">
<span aria-hidden="true" style="flex: 0 0 auto; display: inline-flex; align-items: center; justify-content: center; width: 32px; height: 32px; border-radius: 6px; background: #F2F4F7; color: #4A5363"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M13 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V9z"></path><polyline points="13 2 13 9 20 9"></polyline></svg></span>
<span style="flex: 1 1 auto; min-width: 0; display: flex; flex-direction: column; line-height: 1.35">
<span style="overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-weight: 500">유지보수_계약서_초안.docx</span>
<span style="font-size: 12px; color: #667085">64 KB</span>
</span>
<span aria-hidden="true" style="flex: 0 0 auto; width: 44px; height: 44px; display: inline-flex; align-items: center; justify-content: center; color: #344054"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path><polyline points="7 10 12 15 17 10"></polyline><line x1="12" y1="15" x2="12" y2="3"></line></svg></span>
</div>
</section>
</article>
</div>
</div>
</div>
</div>
<p style="flex: 0 0 auto; margin: 0; padding: 12px 48px 0; font-size: 12.5px; color: #667085; word-break: keep-all">고르지 않은 줄을 끌면 그 줄만 옮기고, 고른 메일은 그대로 둡니다. 보내기를 기다리는 메일의 줄은 끌 수 없습니다. 읽기 화면 도구 줄의 [이동 ▾]는 정한 것 1의 모습입니다.</p>
<section aria-labelledby="sec2-h" style="flex: 0 0 auto; box-sizing: border-box; padding: 44px 48px 16px; display: flex; flex-direction: column; gap: 8px">
<div style="display: flex; align-items: baseline; gap: 16px">
<h2 id="sec2-h" style="margin: 0; font-size: 19px; line-height: 28px; font-weight: 700; color: #0B3640; white-space: nowrap">2. 목록 보기 (1440) · 대화 줄 하나를 끌어다 놓는 중</h2>
<p style="margin: 0; font-size: 13.5px; color: #4A5363; word-break: keep-all">대화로 묶은 받은편지함에서 고르지 않은 줄(박지훈, 나 · 3통)을 끌어 견적 메일함 위에 두었습니다. 따라오는 표에는 대화 수를 적습니다.</p>
</div>
</section>
<div id="board-list" role="region" aria-label="화면: 목록 보기, 대화로 묶은 받은편지함에서 대화 줄을 견적 메일함으로 끌어다 놓는 중" style="flex: 0 0 auto; height: 680px; box-sizing: border-box; display: flex; background: #FFFFFF; border-top: 1px solid #E6E8EC; border-bottom: 1px solid #E6E8EC; overflow: hidden">
<nav aria-label="사내 시스템" style="flex: 0 0 72px; box-sizing: border-box; display: flex; flex-direction: column; align-items: center; gap: 14px; padding: 10px 0 12px; background: #0B3640">
<a href="#" style="display: inline-flex; align-items: center; justify-content: center; width: 60px; min-height: 44px; border-radius: 10px; text-decoration: none">
<img src="/_blob/2b9ef4349333debc969432c859eaf1bf" alt="ARA SYSTEM" width="48" height="17" style="display: block; width: 48px; height: auto">
</a>
<ul style="list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 4px">
<li>
<a href="#" aria-current="page" style="display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 3px; width: 60px; min-height: 56px; border-radius: 10px; background: #155A64; color: #FFFFFF; font-size: 11px; font-weight: 600; text-decoration: none">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z"></path><polyline points="22,6 12,13 2,6"></polyline></svg>
<span>메일</span>
</a>
</li>
<li>
<a href="#" style="display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 3px; width: 60px; min-height: 56px; border-radius: 10px; color: #A3C2C6; font-size: 11px; font-weight: 500; text-decoration: none">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="4" width="18" height="18" rx="2" ry="2"></rect><line x1="16" y1="2" x2="16" y2="6"></line><line x1="8" y1="2" x2="8" y2="6"></line><line x1="3" y1="10" x2="21" y2="10"></line></svg>
<span>캘린더</span>
</a>
</li>
<li>
<a href="#" style="display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 3px; width: 60px; min-height: 56px; border-radius: 10px; color: #A3C2C6; font-size: 11px; font-weight: 500; text-decoration: none">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"></path></svg>
<span>파일</span>
</a>
</li>
<li>
<a href="#" style="display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 3px; width: 60px; min-height: 56px; border-radius: 10px; color: #A3C2C6; font-size: 11px; font-weight: 500; text-decoration: none">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"></path><circle cx="9" cy="7" r="4"></circle><path d="M23 21v-2a4 4 0 0 0-3-3.87"></path><path d="M16 3.13a4 4 0 0 1 0 7.75"></path></svg>
<span>주소록</span>
</a>
</li>
</ul>
<a href="#" style="margin-top: auto; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 3px; width: 60px; min-height: 56px; border-radius: 10px; color: #A3C2C6; font-size: 11px; font-weight: 500; text-decoration: none">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="3"></circle><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"></path></svg>
<span>설정</span>
</a>
</nav>
<div style="flex: 1 1 auto; min-width: 0; display: flex; flex-direction: column">
<header style="display: flex; align-items: center; flex-wrap: wrap; gap: 8px 16px; box-sizing: border-box; min-height: 56px; padding: 6px 12px 6px 20px; border-bottom: 1px solid #E6E8EC">
<span style="font-size: 16px; font-weight: 700; letter-spacing: -0.01em">메일</span>
<form role="search" style="flex: 1 1 280px; max-width: 520px; display: flex; align-items: center; gap: 8px; box-sizing: border-box; height: 40px; margin: 0; padding: 0 4px 0 12px; border: 1px solid #8A94A3; border-radius: 8px; background: #FFFFFF">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#667085" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="11" cy="11" r="8"></circle><line x1="21" y1="21" x2="16.65" y2="16.65"></line></svg>
<input type="search" aria-label="메일 검색" placeholder="메일 검색" style="flex: 1 1 auto; min-width: 0; border: 0; outline: none; background: transparent; font: inherit; color: #171C26">
<button type="button" title="조건으로 찾기" style="flex: 0 0 auto; display: inline-flex; align-items: center; gap: 4px; height: 32px; padding: 0 9px; border: 0; border-radius: 6px; background: transparent; color: #4A5363; font: inherit; font-size: 12.5px; font-weight: 600; cursor: pointer">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="4" y1="21" x2="4" y2="14"></line><line x1="4" y1="10" x2="4" y2="3"></line><line x1="12" y1="21" x2="12" y2="12"></line><line x1="12" y1="8" x2="12" y2="3"></line><line x1="20" y1="21" x2="20" y2="16"></line><line x1="20" y1="12" x2="20" y2="3"></line><line x1="1" y1="14" x2="7" y2="14"></line><line x1="9" y1="8" x2="15" y2="8"></line><line x1="17" y1="16" x2="23" y2="16"></line></svg>
<span>조건</span>
</button>
</form>
<div style="margin-left: auto; display: flex; align-items: center; gap: 10px">
<span aria-hidden="true" style="display: inline-flex; align-items: center; justify-content: center; width: 32px; height: 32px; border-radius: 50%; background: #E3F2F3; color: #0A5F67; font-weight: 600">김</span>
<div style="display: flex; flex-direction: column; line-height: 1.3">
<span style="font-weight: 600">김아라</span>
<span style="font-size: 12px; color: #667085">ara.kim@arasystem.it.kr</span>
</div>
<button type="button" style="display: inline-flex; align-items: center; gap: 6px; min-height: 44px; padding: 0 10px; border: 0; border-radius: 8px; background: transparent; color: #4A5363; font: inherit; font-weight: 500; cursor: pointer">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"></path><polyline points="16 17 21 12 16 7"></polyline><line x1="21" y1="12" x2="9" y2="12"></line></svg>
<span>로그아웃</span>
</button>
</div>
</header>
<div style="flex: 1 1 auto; min-height: 0; display: flex; align-items: stretch">
<nav aria-label="메일함" style="flex: 0 0 208px; box-sizing: border-box; display: flex; flex-direction: column; gap: 14px; padding: 14px 10px; background: #F8F9FB; border-right: 1px solid #E6E8EC">
<a href="#" style="display: flex; align-items: center; justify-content: center; gap: 8px; min-height: 44px; border-radius: 8px; background: #0E7C86; color: #FFFFFF; font-size: 13.5px; font-weight: 600; text-decoration: none">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 20h9"></path><path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z"></path></svg>
<span>메일 쓰기</span>
</a>
<div style="display: flex; flex-direction: column; gap: 1px">
<ul style="list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 1px">
<sc-for list="{{ sys2 }}" as="b" hint-placeholder-count="6">
<li style="position: relative; display: flex; align-items: center; border-radius: 7px; background: {{ b.bg }}; outline: {{ b.outline }}; outline-offset: -2px">
<a href="#" aria-current="{{ b.current }}" style="flex: 1 1 auto; min-width: 0; display: flex; align-items: center; gap: 10px; min-height: 40px; box-sizing: border-box; padding: 0 36px 0 {{ b.indent }}px; border-radius: 7px; color: {{ b.fg }}; font-weight: {{ b.fw }}; text-decoration: none">
<sc-if value="{{ b.isInbox }}" hint-placeholder-val="{{ false }}"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" style="flex: 0 0 auto"><polyline points="22 12 16 12 14 15 10 15 8 12 2 12"></polyline><path d="M5.45 5.11 2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.45-6.89A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z"></path></svg></sc-if>
<sc-if value="{{ b.isSent }}" hint-placeholder-val="{{ false }}"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" style="flex: 0 0 auto"><line x1="22" y1="2" x2="11" y2="13"></line><polygon points="22 2 15 22 11 13 2 9 22 2"></polygon></svg></sc-if>
<sc-if value="{{ b.isClock }}" hint-placeholder-val="{{ false }}"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" style="flex: 0 0 auto"><circle cx="12" cy="12" r="10"></circle><polyline points="12 6 12 12 16 14"></polyline></svg></sc-if>
<sc-if value="{{ b.isDrafts }}" hint-placeholder-val="{{ false }}"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" style="flex: 0 0 auto"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"></path><polyline points="14 2 14 8 20 8"></polyline><line x1="16" y1="13" x2="8" y2="13"></line><line x1="16" y1="17" x2="8" y2="17"></line></svg></sc-if>
<sc-if value="{{ b.isJunk }}" hint-placeholder-val="{{ false }}"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" style="flex: 0 0 auto"><polygon points="7.86 2 16.14 2 22 7.86 22 16.14 16.14 22 7.86 22 2 16.14 2 7.86 7.86 2"></polygon><line x1="12" y1="8" x2="12" y2="12"></line><line x1="12" y1="16" x2="12.01" y2="16"></line></svg></sc-if>
<sc-if value="{{ b.isTrash }}" hint-placeholder-val="{{ false }}"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" style="flex: 0 0 auto"><polyline points="3 6 5 6 21 6"></polyline><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path></svg></sc-if>
<span style="flex: 1 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap">{{ b.name }}</span>
<sc-if value="{{ b.hasCount }}" hint-placeholder-val="{{ false }}"><span style="font-size: 12px; font-weight: 600; color: {{ b.countColor }}; font-variant-numeric: tabular-nums">{{ b.count }}</span></sc-if>
</a>
</li>
</sc-for>
</ul>
<p style="margin: 12px 10px 4px; font-size: 12px; font-weight: 600; color: #667085">내 메일함</p>
<ul style="list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 1px">
<sc-for list="{{ mine2 }}" as="b" hint-placeholder-count="4">
<li style="position: relative; display: flex; align-items: center; border-radius: 7px; background: {{ b.bg }}; outline: {{ b.outline }}; outline-offset: -2px">
<a href="#" aria-current="{{ b.current }}" style="flex: 1 1 auto; min-width: 0; display: flex; align-items: center; gap: 10px; min-height: 40px; box-sizing: border-box; padding: 0 36px 0 {{ b.indent }}px; border-radius: 7px; color: {{ b.fg }}; font-weight: {{ b.fw }}; text-decoration: none">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" style="flex: 0 0 auto"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"></path></svg>
<span style="flex: 1 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap">{{ b.name }}</span>
<sc-if value="{{ b.hasCount }}" hint-placeholder-val="{{ false }}"><span style="font-size: 12px; font-weight: 600; color: {{ b.countColor }}; font-variant-numeric: tabular-nums">{{ b.count }}</span></sc-if>
</a>
<sc-if value="{{ b.pointerHere }}" hint-placeholder-val="{{ false }}">
<span aria-hidden="true" style="position: absolute; left: {{ b.pointerX }}px; top: 13px; z-index: 6; width: 0; height: 0; pointer-events: none">
<svg width="16" height="22" viewBox="0 0 16 22" style="position: absolute; left: -1px; top: -1px; display: block"><path d="M1.5 1.5 L1.5 17.5 L5.4 13.9 L8 20 L10.8 18.8 L8.3 12.9 L13.6 12.9 Z" fill="#FFFFFF" stroke="#171C26" stroke-width="1.3" stroke-linejoin="round"></path></svg>
<span style="position: absolute; left: 16px; top: 22px; display: inline-flex; align-items: center; gap: 6px; padding: 5px 10px 5px 8px; border-radius: 8px; background: #0B3640; color: #FFFFFF; font-size: 12.5px; font-weight: 600; line-height: 1.4; white-space: nowrap; box-shadow: 0 6px 16px rgba(16, 24, 28, 0.28)">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="#9ED6DB" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" style="flex: 0 0 auto"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"></path></svg>
<span>{{ chip2 }}</span>
</span>
</span>
</sc-if>
</li>
</sc-for>
</ul>
<span style="display: flex; align-items: center; gap: 10px; min-height: 44px; margin-top: 2px; padding: 0 10px; border-radius: 7px; color: #344054; font-weight: 500">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="12" y1="5" x2="12" y2="19"></line><line x1="5" y1="12" x2="19" y2="12"></line></svg>
<span>새 메일함</span>
</span>
</div>
<div style="margin-top: auto; display: flex; flex-direction: column; gap: 6px; padding: 12px 10px 2px; border-top: 1px solid #E6E8EC">
<div style="display: flex; justify-content: space-between; gap: 8px; font-size: 12px; color: #667085">
<span>메일함 사용량</span>
<span style="font-variant-numeric: tabular-nums">0.9 / 5 GB</span>
</div>
<div style="height: 4px; border-radius: 2px; background: #E4E7EC">
<div style="width: 18%; height: 4px; border-radius: 2px; background: #0E7C86"></div>
</div>
</div>
</nav>
<section aria-labelledby="list-title" style="flex: 1 1 auto; min-width: 0; display: flex; flex-direction: column">
<div style="display: flex; align-items: center; gap: 8px; min-height: 52px; box-sizing: border-box; padding: 4px 8px 4px 20px; border-bottom: 1px solid #E6E8EC">
<div style="flex: 1 1 auto; min-width: 0; display: flex; align-items: center; gap: 8px">
<h2 id="list-title" style="margin: 0; font-size: 15px; font-weight: 700; letter-spacing: -0.01em; white-space: nowrap">받은편지함</h2>
<span style="font-size: 12px; color: #667085; white-space: nowrap; font-variant-numeric: tabular-nums">대화 7개, 안 읽은 메일 2통</span>
</div>
<div style="flex: 0 0 auto; display: flex; align-items: center; gap: 8px">
<button type="button" aria-pressed="true" style="display: inline-flex; align-items: center; gap: 6px; min-height: 36px; box-sizing: border-box; padding: 0 10px; border: 1px solid #D0D5DD; border-radius: 8px; background: #E3F2F3; color: #0A5F67; font: inherit; font-size: 12.5px; font-weight: 600; white-space: nowrap; cursor: pointer">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"></path></svg>
<span>대화로 묶기</span>
</button>
<div role="group" aria-label="보기 방식" style="display: inline-flex; gap: 2px; padding: 2px; border: 1px solid #D0D5DD; border-radius: 8px; background: #FFFFFF">
<span style="display: inline-flex; align-items: center; gap: 4px; min-height: 30px; padding: 0 8px; border-radius: 6px; color: #344054; font-size: 12px; font-weight: 500">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="4" width="18" height="16" rx="2" ry="2"></rect><line x1="10" y1="4" x2="10" y2="20"></line></svg>
<span>분할</span>
</span>
<span style="display: inline-flex; align-items: center; gap: 4px; min-height: 30px; padding: 0 8px; border-radius: 6px; background: #E3F2F3; color: #0A5F67; font-size: 12px; font-weight: 600">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="9" y1="6" x2="20" y2="6"></line><line x1="9" y1="12" x2="20" y2="12"></line><line x1="9" y1="18" x2="20" y2="18"></line><line x1="4" y1="6" x2="4.01" y2="6"></line><line x1="4" y1="12" x2="4.01" y2="12"></line><line x1="4" y1="18" x2="4.01" y2="18"></line></svg>
<span>목록</span>
</span>
</div>
<span aria-label="새로고침" style="width: 40px; height: 44px; display: inline-flex; align-items: center; justify-content: center; border-radius: 8px; color: #4A5363">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="23 4 23 10 17 10"></polyline><polyline points="1 20 1 14 7 14"></polyline><path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"></path></svg>
</span>
</div>
</div>
<div aria-hidden="true" class="cv-grid" style="height: 32px; box-sizing: border-box; padding: 0 8px 0 20px; background: #FBFBFC; border-bottom: 1px solid #E6E8EC; font-size: 12px; font-weight: 600; color: #667085">
<span style="display: flex; align-items: center"><svg width="16" height="16" viewBox="0 0 16 16"><rect x="1" y="1" width="14" height="14" rx="3.5" fill="#FFFFFF" stroke="#8A94A3" stroke-width="1.5"></rect></svg></span>
<span style="display: flex; align-items: center"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="#667085" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"></polygon></svg></span>
<span>참여자</span>
<span>제목</span>
<span style="text-align: right">날짜</span>
</div>
<ul aria-label="받은편지함 대화 목록" style="list-style: none; margin: 0; padding: 0">
<sc-for list="{{ convRows }}" as="c" hint-placeholder-count="7">
<li class="cv-grid" style="height: 38px; box-sizing: border-box; padding: 0 8px 0 20px; border-bottom: 1px solid #EEF0F3; background: {{ c.bg }}">
<span style="display: flex; align-items: center"><svg width="16" height="16" viewBox="0 0 16 16" aria-hidden="true"><rect x="1" y="1" width="14" height="14" rx="3.5" fill="#FFFFFF" stroke="#8A94A3" stroke-width="1.5"></rect></svg></span>
<span style="display: flex; align-items: center; color: {{ c.starColor }}"><svg width="14" height="14" viewBox="0 0 24 24" fill="{{ c.starFill }}" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"></polygon></svg></span>
<span style="min-width: 0; display: flex; align-items: center; gap: 6px">
<span style="min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-weight: {{ c.fw }}; color: {{ c.whoColor }}">{{ c.who }}</span>
<sc-if value="{{ c.hasCount }}" hint-placeholder-val="{{ false }}"><span style="flex: 0 0 auto; font-size: 12px; font-weight: 500; color: #667085; font-variant-numeric: tabular-nums">{{ c.count }}</span></sc-if>
</span>
<span style="min-width: 0; display: flex; align-items: center; gap: 6px">
<span style="min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-weight: {{ c.fw }}; color: #171C26">{{ c.subject }}</span>
<sc-if value="{{ c.hasAtt }}" hint-placeholder-val="{{ false }}"><svg role="img" aria-label="첨부 있음" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="#667085" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="flex: 0 0 auto"><path d="M21.44 11.05l-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48"></path></svg></sc-if>
</span>
<span style="text-align: right; white-space: nowrap; font-size: 12px; font-weight: {{ c.dateFw }}; color: {{ c.dateColor }}; font-variant-numeric: tabular-nums">{{ c.date }}</span>
</li>
</sc-for>
</ul>
<div style="display: flex; justify-content: center; padding: 10px 20px 20px">
<span style="display: inline-flex; align-items: center; min-height: 40px; padding: 0 24px; border-radius: 8px; color: #0E7C86; font-weight: 600">메일 더 보기</span>
</div>
</section>
</div>
</div>
</div>
<p style="flex: 0 0 auto; margin: 0; padding: 12px 48px 0; font-size: 12.5px; color: #667085; word-break: keep-all">대화 줄을 끌면 그 대화에서 지금 메일함(받은편지함)에 있는 메일만 옮깁니다. [이동 ▾]와 같이 보내기를 기다리는 메일은 빼고, 놓은 뒤 알림은 3e입니다.</p>
<section aria-labelledby="sec3-h" style="flex: 0 0 auto; box-sizing: border-box; padding: 44px 48px 0; display: flex; flex-direction: column; gap: 16px">
<div style="display: flex; align-items: baseline; gap: 16px">
<h2 id="sec3-h" style="margin: 0; font-size: 19px; line-height: 28px; font-weight: 700; color: #0B3640; white-space: nowrap">3. 놓은 뒤 알림</h2>
<p style="margin: 0; font-size: 13.5px; color: #4A5363; word-break: keep-all">메뉴로 옮길 때와 같은 알림이고, 화면 오른쪽 아래에 뜹니다. [되돌리기]가 있는 알림은 10초 동안 남습니다.</p>
</div>
<div style="display: grid; grid-template-columns: repeat(6, minmax(0, 1fr)); gap: 20px; align-items: stretch">
<sc-for list="{{ toasts }}" as="t" hint-placeholder-count="5">
<section style="grid-column: {{ t.span }}; display: flex; flex-direction: column; gap: 12px; box-sizing: border-box; padding: 18px 16px 18px; background: #FFFFFF; border: 1px solid #E6E8EC; border-radius: 12px">
<div style="display: flex; flex-direction: column; gap: 2px">
<h3 style="margin: 0; font-size: 14px; font-weight: 700; word-break: keep-all">{{ t.title }}</h3>
<p style="margin: 0; font-size: 12.5px; color: #667085; word-break: keep-all">{{ t.note }}</p>
</div>
<div role="status" style="width: 100%; max-width: 400px; box-sizing: border-box; display: flex; align-items: center; gap: 10px; padding: 8px 6px 8px 14px; border-radius: 10px; background: #0B3640; color: #FFFFFF; box-shadow: 0 10px 28px rgba(16, 24, 28, 0.22)">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#9FE0B5" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" style="flex: 0 0 auto"><polyline points="20 6 9 17 4 12"></polyline></svg>
<span style="flex: 1 1 auto; min-width: 0; font-weight: 500; word-break: keep-all; overflow-wrap: anywhere">{{ t.text }}</span>
<button type="button" class="on-dark" style="flex: 0 0 auto; display: inline-flex; align-items: center; min-height: 44px; padding: 0 10px; border: 0; border-radius: 6px; background: transparent; color: #9ED6DB; font: inherit; font-weight: 600; cursor: pointer">되돌리기</button>
</div>
</section>
</sc-for>
</div>
<p style="margin: 0; font-size: 12.5px; color: #667085; word-break: keep-all">끌어다 놓기는 마우스로만 합니다. 키보드와 터치 화면에서는 지금처럼 [이동 ▾]로 옮기고, 옮길 곳과 알림은 같습니다.</p>
</section>
<section aria-labelledby="sec4-h" style="flex: 0 0 auto; box-sizing: border-box; padding: 44px 48px 0; display: flex; flex-direction: column; gap: 16px">
<div style="display: flex; align-items: baseline; gap: 16px">
<h2 id="sec4-h" style="margin: 0; font-size: 19px; line-height: 28px; font-weight: 700; color: #0B3640; white-space: nowrap">4. 상단 바 · 휴대폰 폭 360 (2배로 확대)</h2>
<p style="margin: 0; font-size: 13.5px; color: #4A5363; word-break: keep-all">레일 72와 상단 바만 잘라 2배로 키웠습니다. 바 안쪽 폭은 256입니다(360에서 레일 72와 바의 좌우 여백 32를 뺀 폭). 넓은 화면은 달라지지 않습니다.</p>
</div>
<div style="display: flex; align-items: flex-start; gap: 32px">
<div style="flex: 0 0 auto; width: 720px; height: 400px; box-sizing: content-box; overflow: hidden; border: 1px solid #D0D5DD; border-radius: 12px; background: #FFFFFF">
<div style="width: 360px; height: 200px; transform: scale(2); transform-origin: 0 0; display: flex; overflow: hidden; background: #FFFFFF">
<div style="flex: 0 0 72px; box-sizing: border-box; display: flex; flex-direction: column; align-items: center; gap: 14px; padding: 10px 0 12px; background: #0B3640">
<span style="display: inline-flex; align-items: center; justify-content: center; width: 60px; min-height: 44px"><img src="/_blob/2b9ef4349333debc969432c859eaf1bf" alt="" width="48" height="17" style="display: block; width: 48px; height: auto"></span>
<span style="display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 3px; width: 60px; min-height: 56px; border-radius: 10px; background: #155A64; color: #FFFFFF; font-size: 11px; font-weight: 600">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z"></path><polyline points="22,6 12,13 2,6"></polyline></svg>
<span>메일</span>
</span>
<span style="display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 3px; width: 60px; min-height: 56px; border-radius: 10px; color: #A3C2C6; font-size: 11px; font-weight: 500">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="4" width="18" height="18" rx="2" ry="2"></rect><line x1="16" y1="2" x2="16" y2="6"></line><line x1="8" y1="2" x2="8" y2="6"></line><line x1="3" y1="10" x2="21" y2="10"></line></svg>
<span>캘린더</span>
</span>
</div>
<div style="flex: 1 1 auto; min-width: 0; display: flex; flex-direction: column">
<div style="display: flex; flex-wrap: wrap; align-items: center; gap: 8px 16px; box-sizing: border-box; min-height: 56px; padding: 6px 12px 6px 20px; border-bottom: 1px solid #E6E8EC; background: #FFFFFF">
<span style="font-size: 16px; font-weight: 700; letter-spacing: -0.01em">메일</span>
<div style="flex: 1 1 280px; max-width: 520px; display: flex; align-items: center; gap: 8px; box-sizing: border-box; height: 40px; padding: 0 4px 0 12px; border: 1px solid #8A94A3; border-radius: 8px; background: #FFFFFF">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#667085" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" style="flex: 0 0 auto"><circle cx="11" cy="11" r="8"></circle><line x1="21" y1="21" x2="16.65" y2="16.65"></line></svg>
<span style="flex: 1 1 auto; min-width: 0; overflow: hidden; white-space: nowrap; color: #757D8A">메일 검색</span>
<span style="flex: 0 0 auto; display: inline-flex; align-items: center; gap: 4px; height: 32px; padding: 0 9px; border-radius: 6px; color: #4A5363; font-size: 12.5px; font-weight: 600">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="4" y1="21" x2="4" y2="14"></line><line x1="4" y1="10" x2="4" y2="3"></line><line x1="12" y1="21" x2="12" y2="12"></line><line x1="12" y1="8" x2="12" y2="3"></line><line x1="20" y1="21" x2="20" y2="16"></line><line x1="20" y1="12" x2="20" y2="3"></line><line x1="1" y1="14" x2="7" y2="14"></line><line x1="9" y1="8" x2="15" y2="8"></line><line x1="17" y1="16" x2="23" y2="16"></line></svg>
<span>조건</span>
</span>
</div>
<div style="margin-left: auto; display: flex; align-items: center; gap: 10px">
<span aria-hidden="true" style="display: inline-flex; align-items: center; justify-content: center; width: 32px; height: 32px; border-radius: 50%; background: #E3F2F3; color: #0A5F67; font-weight: 600">김</span>
<div style="display: flex; flex-direction: column; line-height: 1.3">
<span style="font-weight: 600">김아라</span>
<span style="font-size: 12px; color: #667085">ara.kim@arasystem.it.kr</span>
</div>
<span style="display: inline-flex; align-items: center; gap: 6px; min-height: 44px; padding: 0 10px; border-radius: 8px; color: #4A5363; font-weight: 500">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" style="flex: 0 0 auto"><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"></path><polyline points="16 17 21 12 16 7"></polyline><line x1="21" y1="12" x2="9" y2="12"></line></svg>
<span>로그아웃</span>
</span>
</div>
</div>
<div style="flex: 1 1 auto; box-sizing: border-box; padding: 14px 10px; background: #F8F9FB">
<span style="display: flex; align-items: center; justify-content: center; gap: 8px; min-height: 44px; border-radius: 8px; background: #0E7C86; color: #FFFFFF; font-size: 13.5px; font-weight: 600">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 20h9"></path><path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z"></path></svg>
<span>메일 쓰기</span>
</span>
</div>
</div>
</div>
</div>
<div style="flex: 1 1 auto; min-width: 0; display: flex; flex-direction: column; gap: 10px; padding-top: 4px">
<h3 style="margin: 0; font-size: 15px; font-weight: 700; color: #B42318">4a. 지금</h3>
<ul style="margin: 0; padding-left: 18px; display: flex; flex-direction: column; gap: 6px; color: #344054; word-break: keep-all">
<li>검색 칸(기본 폭 280)이 둘째 줄로, 계정 칸이 셋째 줄로 내려갑니다.</li>
<li>계정 칸은 아바타 32, 이름과 주소, [로그아웃] 순서입니다. 주소는 줄지 않아 [로그아웃]만 줄어들고 글자가 두 줄로 꺾입니다.</li>
<li>주소가 더 긴 직원은 [로그아웃]이 한 글자씩 꺾여 셋째 줄이 더 높아집니다.</li>
</ul>
</div>
</div>
<div style="display: flex; align-items: flex-start; gap: 32px">
<div style="flex: 0 0 auto; width: 720px; height: 400px; box-sizing: content-box; overflow: hidden; border: 1px solid #D0D5DD; border-radius: 12px; background: #FFFFFF">
<div style="width: 360px; height: 200px; transform: scale(2); transform-origin: 0 0; display: flex; overflow: hidden; background: #FFFFFF">
<div style="flex: 0 0 72px; box-sizing: border-box; display: flex; flex-direction: column; align-items: center; gap: 14px; padding: 10px 0 12px; background: #0B3640">
<span style="display: inline-flex; align-items: center; justify-content: center; width: 60px; min-height: 44px"><img src="/_blob/2b9ef4349333debc969432c859eaf1bf" alt="" width="48" height="17" style="display: block; width: 48px; height: auto"></span>
<span style="display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 3px; width: 60px; min-height: 56px; border-radius: 10px; background: #155A64; color: #FFFFFF; font-size: 11px; font-weight: 600">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z"></path><polyline points="22,6 12,13 2,6"></polyline></svg>
<span>메일</span>
</span>
<span style="display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 3px; width: 60px; min-height: 56px; border-radius: 10px; color: #A3C2C6; font-size: 11px; font-weight: 500">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="4" width="18" height="18" rx="2" ry="2"></rect><line x1="16" y1="2" x2="16" y2="6"></line><line x1="8" y1="2" x2="8" y2="6"></line><line x1="3" y1="10" x2="21" y2="10"></line></svg>
<span>캘린더</span>
</span>
</div>
<div style="flex: 1 1 auto; min-width: 0; display: flex; flex-direction: column">
<div style="display: flex; flex-wrap: wrap; align-items: center; gap: 8px 16px; box-sizing: border-box; min-height: 56px; padding: 6px 12px 6px 20px; border-bottom: 1px solid #E6E8EC; background: #FFFFFF">
<span style="font-size: 16px; font-weight: 700; letter-spacing: -0.01em">메일</span>
<div style="flex: 1 1 280px; max-width: 520px; display: flex; align-items: center; gap: 8px; box-sizing: border-box; height: 40px; padding: 0 4px 0 12px; border: 1px solid #8A94A3; border-radius: 8px; background: #FFFFFF">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#667085" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" style="flex: 0 0 auto"><circle cx="11" cy="11" r="8"></circle><line x1="21" y1="21" x2="16.65" y2="16.65"></line></svg>
<span style="flex: 1 1 auto; min-width: 0; overflow: hidden; white-space: nowrap; color: #757D8A">메일 검색</span>
<span style="flex: 0 0 auto; display: inline-flex; align-items: center; gap: 4px; height: 32px; padding: 0 9px; border-radius: 6px; color: #4A5363; font-size: 12.5px; font-weight: 600">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="4" y1="21" x2="4" y2="14"></line><line x1="4" y1="10" x2="4" y2="3"></line><line x1="12" y1="21" x2="12" y2="12"></line><line x1="12" y1="8" x2="12" y2="3"></line><line x1="20" y1="21" x2="20" y2="16"></line><line x1="20" y1="12" x2="20" y2="3"></line><line x1="1" y1="14" x2="7" y2="14"></line><line x1="9" y1="8" x2="15" y2="8"></line><line x1="17" y1="16" x2="23" y2="16"></line></svg>
<span>조건</span>
</span>
</div>
<div style="margin-left: auto; min-width: 0; max-width: 100%; display: flex; align-items: center; gap: 10px">
<span aria-hidden="true" style="flex: 0 0 auto; display: inline-flex; align-items: center; justify-content: center; width: 32px; height: 32px; border-radius: 50%; background: #E3F2F3; color: #0A5F67; font-weight: 600">김</span>
<div style="min-width: 0; display: flex; flex-direction: column; line-height: 1.3">
<span title="김아라 (ara.kim@arasystem.it.kr)" style="overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-weight: 600">김아라</span>
</div>
<span style="flex: 0 0 auto; display: inline-flex; align-items: center; gap: 6px; min-height: 44px; padding: 0 10px; border-radius: 8px; color: #4A5363; font-weight: 500; white-space: nowrap">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" style="flex: 0 0 auto"><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"></path><polyline points="16 17 21 12 16 7"></polyline><line x1="21" y1="12" x2="9" y2="12"></line></svg>
<span>로그아웃</span>
</span>
</div>
</div>
<div style="flex: 1 1 auto; box-sizing: border-box; padding: 14px 10px; background: #F8F9FB">
<span style="display: flex; align-items: center; justify-content: center; gap: 8px; min-height: 44px; border-radius: 8px; background: #0E7C86; color: #FFFFFF; font-size: 13.5px; font-weight: 600">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 20h9"></path><path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z"></path></svg>
<span>메일 쓰기</span>
</span>
</div>
</div>
</div>
</div>
<div style="flex: 1 1 auto; min-width: 0; display: flex; flex-direction: column; gap: 10px; padding-top: 4px">
<h3 style="margin: 0; font-size: 15px; font-weight: 700; color: #0A5F67">4b. 고친 뒤</h3>
<ul style="margin: 0; padding-left: 18px; display: flex; flex-direction: column; gap: 6px; color: #344054; word-break: keep-all">
<li>[로그아웃]은 줄지도 꺾이지도 않습니다. 늘 한 줄입니다.</li>
<li>화면 폭이 480 이하면 주소를 감추고 이름만 둡니다.</li>
<li>이름이나 주소가 아주 길어 넘치면 끝을 "…"로 줄이고, 마우스를 올리면 다 보입니다.</li>
<li>줄 수와 차례(메일, 검색 칸, 계정)는 지금과 같습니다.</li>
</ul>
</div>
</div>
</section>
<div style="flex: 0 0 auto; box-sizing: border-box; padding: 44px 48px 48px; display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 20px; align-items: start">
<section style="display: flex; flex-direction: column; gap: 10px; padding: 20px; background: #FFFFFF; border: 1px solid #E6E8EC; border-radius: 12px">
<h2 style="margin: 0; font-size: 14px; font-weight: 700">정한 것 · 끌 수 있는 것과 놓을 곳</h2>
<ul style="margin: 0; padding-left: 18px; display: flex; flex-direction: column; gap: 6px; color: #344054; word-break: keep-all">
<li>고른 줄을 끌면 고른 메일을 모두, 고르지 않은 줄을 끌면 그 줄만 옮깁니다. 고른 것은 그대로 둡니다.</li>
<li>대화로 묶은 목록의 줄은 그 대화에서 지금 메일함에 있는 메일을 옮깁니다. 보내기를 기다리는 메일은 빼고([이동 ▾]와 같다), 그런 메일뿐인 줄은 끌 수 없습니다.</li>
<li>놓을 곳은 [이동 ▾]의 옮길 곳에 휴지통을 더한 곳입니다. 지금 메일함, 임시보관함, 예약됨에는 놓을 수 없습니다.</li>
<li>휴지통에 놓으면 [삭제], 스팸메일함에 놓으면 [스팸 신고]와 같고, 그 밖은 [이동 ▾]와 같습니다. 알림과 [되돌리기]도 같습니다.</li>
</ul>
</section>
<section style="display: flex; flex-direction: column; gap: 10px; padding: 20px; background: #FFFFFF; border: 1px solid #E6E8EC; border-radius: 12px">
<h2 style="margin: 0; font-size: 14px; font-weight: 700">끌어다 놓는 동안의 모양</h2>
<ul style="margin: 0; padding-left: 18px; display: flex; flex-direction: column; gap: 6px; color: #344054; word-break: keep-all">
<li>마우스를 따라오는 표는 커서 오른쪽 아래에 붙습니다. "메일 N통" 또는 "대화 N개"이고, 알림과 같은 어두운 바탕(#0B3640)에 12.5px 굵은 흰 글자, 봉투나 말풍선 아이콘입니다.</li>
<li>놓을 수 있는 메일함 위에서는 그 줄에 2px 청록 점선과 옅은 청록 바탕이 생깁니다. 쓰기 화면에 파일을 끌어다 놓을 때와 같은 모양이라 초점 선(실선)과 헷갈리지 않습니다.</li>
<li>놓을 수 없는 곳 위에서는 줄이 그대로이고 마우스 커서만 금지 모양(⊘)이 됩니다. 메일함 줄마다 금지 표시를 그리지 않습니다.</li>
<li>끌기 시작한 줄은 흐리게 하지 않습니다. 고른 줄은 고른 바탕 그대로입니다.</li>
</ul>
</section>
<section style="display: flex; flex-direction: column; gap: 10px; padding: 20px; background: #FFFFFF; border: 1px solid #E6E8EC; border-radius: 12px">
<h2 style="margin: 0; font-size: 14px; font-weight: 700">정한 것 · 스팸메일함으로 옮기는 길</h2>
<ul style="margin: 0; padding-left: 18px; display: flex; flex-direction: column; gap: 6px; color: #344054; word-break: keep-all">
<li>[스팸 신고], [이동 ▾]에서 스팸메일함 고르기, 스팸메일함에 끌어다 놓기는 모두 스팸 표시를 붙여 옮깁니다(3c). 지금은 [이동 ▾]만 표시 없이 옮깁니다.</li>
<li>스팸메일함에서 휴지통이 아닌 메일함으로 옮기면(메뉴, 끌기) [스팸 아님]과 같이 스팸 아님 표시를 붙입니다(3d). 휴지통으로 옮길 때는 표시를 바꾸지 않습니다.</li>
<li>[되돌리기]는 원래 메일함으로 옮기며 표시도 되돌립니다. 지금의 [스팸 신고], [스팸 아님]과 같습니다.</li>
</ul>
</section>
<section style="display: flex; flex-direction: column; gap: 10px; padding: 20px; background: #FFFFFF; border: 1px solid #E6E8EC; border-radius: 12px">
<h2 style="margin: 0; font-size: 14px; font-weight: 700">구현 때 맞출 것</h2>
<ul style="margin: 0; padding-left: 18px; display: flex; flex-direction: column; gap: 6px; color: #344054; word-break: keep-all">
<li>HTML5 끌어다 놓기로 만들고, 따라오는 표는 setDragImage 로 붙입니다. 놓을 수 없는 곳에서는 dropEffect 를 none 으로 두어 브라우저가 금지 커서를 보입니다. 커서 모양과 따라오는 표의 투명도는 운영체제와 브라우저마다 조금 다릅니다.</li>
<li>목록 줄은 링크라 지금도 끌면 그 메일 화면의 주소(URL)가 끌려 나갑니다. 우리 끌기 자료로 바꾸고, 다른 창이나 바깥 프로그램에 놓아도 아무 일도 일어나지 않게 합니다.</li>
<li>메일함 패널은 쓰기 화면에도 보이므로 파일 끌기는 받지 않습니다(첨부 놓기와 겹치지 않게). 메일 화면에 파일을 잘못 놓으면 지금은 브라우저가 그 파일을 열어 웹메일을 떠나는데, 앞으로는 무시합니다.</li>
<li>패널의 메일함이 많아 아래가 가려져 있으면 패널 끝에서 저절로 스크롤되는지 확인합니다.</li>
<li>상단 바는 아바타와 [로그아웃]을 줄지 않게 하고 이름 칸만 줄게 합니다. 480 이하에서 주소를 감추는 것은 tokens.css 의 화면 폭 기준에 한 줄을 더합니다.</li>
</ul>
</section>
</div>
</div>
</x-dc>
<script type="text/x-dc" data-dc-script data-props='{"$preview":{"width":1440,"height":3845}}'>
class Component extends DCLogic {
state = { over: null };
// 상태 고르기 단추 — 다른 보드(SettingsMobileApp)와 같은 모양
modes(list, current, choose) {
return list.map((x) => {
const on = x.id === current;
return {
label: x.label,
pressed: on ? 'true' : 'false',
pick: () => choose(x.id),
bg: on ? '#E3F2F3' : '#FFFFFF',
fg: on ? '#0A5F67' : '#344054',
border: on ? '#0E7C86' : '#D0D5DD',
};
});
}
// 메일함 패널 — target 은 마우스가 있는 메일함. 지금 메일함(받은편지함) · 임시보관함 · 예약됨에는 놓을 수 없다
panel(target) {
const SYSTEM = [
{ id: 'inbox', name: '받은편지함', icon: 'inbox', count: '2' },
{ id: 'sent', name: '보낸편지함', icon: 'sent' },
{ id: 'scheduled', name: '예약됨', icon: 'clock', count: '1' },
{ id: 'drafts', name: '임시보관함', icon: 'drafts' },
{ id: 'junk', name: '스팸메일함', icon: 'junk' },
{ id: 'trash', name: '휴지통', icon: 'trash' },
];
const MINE = [
{ id: 'clients', name: '거래처', icon: 'folder', count: '1' },
{ id: 'project', name: '프로젝트', icon: 'folder', count: '2' },
{ id: 'quotes', name: '견적', icon: 'folder', depth: 1 },
{ id: 'partners', name: '협력사', icon: 'folder' },
];
const BLOCKED = ['inbox', 'drafts', 'scheduled'];
const row = (b) => {
const current = b.id === 'inbox';
const here = b.id === target;
const ok = here && BLOCKED.indexOf(b.id) < 0;
const strong = current || ok;
const indent = b.depth ? 28 : 10;
return {
name: b.name,
isInbox: b.icon === 'inbox',
isSent: b.icon === 'sent',
isClock: b.icon === 'clock',
isDrafts: b.icon === 'drafts',
isJunk: b.icon === 'junk',
isTrash: b.icon === 'trash',
isFolder: b.icon === 'folder',
hasCount: !!b.count,
count: b.count || '',
indent: indent,
current: current ? 'page' : 'false',
bg: ok ? '#F3F8F8' : current ? '#E3F2F3' : 'transparent',
outline: ok ? '2px dashed #0E7C86' : 'none',
fg: strong ? '#0A5F67' : '#344054',
fw: strong ? '600' : '500',
countColor: strong ? '#0A5F67' : '#171C26',
pointerHere: here,
dropOk: ok,
dropNo: here && !ok,
pointerX: indent + 56,
};
};
return { system: SYSTEM.map(row), mine: MINE.map(row) };
}
renderVals() {
const st = this.state;
const over = st.over ?? 'clients';
const OVER = [
{ id: 'clients', label: '거래처', title: '1a. 거래처 위 · 놓을 수 있음', note: '줄에 청록 점선과 옅은 바탕이 생깁니다. 놓으면 메일 3통을 거래처로 옮기고, 알림에 [되돌리기]를 붙입니다(3a).' },
{ id: 'trash', label: '휴지통', title: '1b. 휴지통 위 · 놓을 수 있음', note: '[삭제]와 같습니다. 휴지통으로 옮기고, 알림에 [되돌리기]를 붙입니다(3b).' },
{ id: 'junk', label: '스팸메일함', title: '1c. 스팸메일함 위 · 놓을 수 있음', note: '[스팸 신고]와 같습니다. 스팸 표시를 붙여 옮기고, [되돌리기]는 표시도 되돌립니다(3c).' },
{ id: 'drafts', label: '임시보관함', title: '1d. 임시보관함 위 · 놓을 수 없음', note: '줄은 밝아지지 않고 마우스 커서만 금지 모양이 됩니다. 여기서 놓으면 아무 일도 일어나지 않습니다.' },
{ id: 'scheduled', label: '예약됨', title: '1e. 예약됨 위 · 놓을 수 없음', note: '예약한 메일을 모아 보는 곳이라 놓을 수 없습니다. 줄은 그대로이고 커서만 금지 모양입니다.' },
{ id: 'inbox', label: '받은편지함', title: '1f. 받은편지함 위 · 놓을 수 없음', note: '지금 보고 있는 메일함입니다. 지금 메일함 바탕 그대로이고 커서만 금지 모양입니다.' },
];
const cur = OVER.find((x) => x.id === over) || OVER[0];
const p1 = this.panel(over);
const p2 = this.panel('quotes');
// 분할 보기 목록 — 고른 줄 셋(박지훈 줄에서 끌기 시작), 연 메일은 맨 위 줄
const MAILS = [
{ who: '이도윤', time: '오전 10:42', subject: 'Re: 유지보수 계약 갱신 건', unread: true, att: true, open: true },
{ who: '(주)한빛솔루션', time: '오전 9:31', subject: '서버 증설 견적서 송부드립니다', att: true, checked: true },
{ who: '인사팀', time: '오전 9:05', subject: '[공지] 계약직 채용 일정 안내', unread: true },
{ who: '박지훈', time: '어제', subject: 'Re: 견적 조건 문의드립니다', checked: true, grabbed: true },
{ who: '박준호', time: '어제', subject: '11월 인프라 점검 일정 조율' },
{ who: '(주)한빛솔루션', time: '11월 6일', subject: '유지보수 계약 갱신 안내', checked: true },
{ who: '정하늘', time: '11월 6일', subject: 'Re: 신규 입사자 교육 회의록' },
{ who: '최서연', time: '11월 5일', subject: '11월 비용 정산 안내', star: true },
{ who: '보안팀', time: '11월 4일', subject: '[공지] 하반기 정보보안 교육 이수 요청' },
{ who: '김민수', time: '11월 3일', subject: 'Re: 전사 회의 참석 안내', att: true },
];
const items1 = MAILS.map((m) => ({
who: m.who,
time: m.time,
subject: m.subject,
hasAtt: !!m.att,
checked: !!m.checked,
notChecked: !m.checked,
checkedStr: m.checked ? 'true' : 'false',
pickLabel: '고르기: ' + m.subject,
bg: m.grabbed ? '#DDEFF0' : m.checked ? '#E8F4F5' : m.open ? '#E3F2F3' : '#FFFFFF',
whoFw: m.unread ? '650' : '450',
whoColor: m.unread ? '#171C26' : '#344054',
subFw: m.unread ? '600' : '400',
timeFw: m.unread ? '600' : '400',
timeColor: m.unread ? '#171C26' : '#667085',
starredStr: m.star ? 'true' : 'false',
starColor: m.star ? '#B7791F' : '#98A2B3',
starFill: m.star ? '#B7791F' : 'none',
}));
// 목록 보기 · 대화로 묶은 받은편지함 — 박지훈, 나 줄을 끌어다 놓는 중(끌기 시작한 줄은 마우스를 올린 바탕 그대로)
const CONVS = [
{ who: '이도윤, 나', count: '4통', subject: 'Re: 유지보수 계약 갱신 건', date: '오전 10:42', unread: true, att: true },
{ who: '인사팀', subject: '[공지] 계약직 채용 일정 안내', date: '오전 9:05', unread: true },
{ who: '박지훈, 나', count: '3통', subject: 'Re: 견적 조건 문의드립니다', date: '11.08', grabbed: true },
{ who: '정하늘, 나', count: '3통', subject: 'Re: 신규 입사자 교육 회의록', date: '11.08' },
{ who: '박준호', subject: '11월 인프라 점검 일정 조율', date: '11.06' },
{ who: '최서연', subject: '11월 비용 정산 안내', date: '11.05', star: true },
{ who: '인사팀, 김민수, 나', count: '6통', subject: 'Re: 전사 회의 참석 안내', date: '11.03', att: true },
];
const convRows = CONVS.map((c) => ({
who: c.who,
count: c.count || '',
hasCount: !!c.count,
subject: c.subject,
date: c.date,
hasAtt: !!c.att,
bg: c.grabbed ? '#F3F8F8' : '#FFFFFF',
fw: c.unread ? '650' : '450',
whoColor: c.unread ? '#171C26' : '#344054',
dateFw: c.unread ? '600' : '400',
dateColor: c.unread ? '#171C26' : '#667085',
starColor: c.star ? '#B7791F' : '#98A2B3',
starFill: c.star ? '#B7791F' : 'none',
}));
// 놓은 뒤 알림 — 글은 화면 상수 그대로 (MAIL_BULK_TEXT.moved · movedFrom · MAIL_SPAM_TEXT.notSpamMoved)
const toasts = [
{ span: 'span 2', title: '3a. 내 메일함(거래처)에 놓았을 때', note: '[이동 ▾]로 옮길 때와 같습니다.', text: '메일 3통을 거래처로 옮겼습니다.' },
{ span: 'span 2', title: '3b. 휴지통에 놓았을 때', note: '[삭제]와 같습니다. 영구 삭제하지 않고 휴지통으로 옮깁니다.', text: '메일 3통을 휴지통으로 옮겼습니다.' },
{ span: 'span 2', title: '3c. 스팸메일함에 놓았을 때', note: '[스팸 신고]와 같습니다. [이동 ▾]에서 스팸메일함을 골라도 같습니다.', text: '메일 3통을 스팸메일함으로 옮겼습니다.' },
{ span: 'span 3', title: '3d. 스팸메일함에서 다른 메일함으로 옮겼을 때', note: '[스팸 아님]과 같습니다. 끌어다 놓기와 [이동 ▾]가 같고, 휴지통으로 옮길 때는 3b와 같습니다.', text: '메일 1통을 스팸이 아닌 메일로 표시해 거래처로 옮겼습니다.' },
{ span: 'span 3', title: '3e. 대화 줄을 견적에 놓았을 때(2)', note: '줄의 3통은 대화 전체라서, 옮긴 메일이 받은편지함에 있던 것임을 밝힙니다. 대화 줄에 [이동 ▾]를 쓸 때와 같습니다.', text: '받은편지함에 있는 메일 2통을 견적으로 옮겼습니다.' },
];
return {
overModes: this.modes(OVER, over, (id) => this.setState({ over: id })),
overTitle: cur.title,
overNote: cur.note,
splitRegionLabel: '화면: 분할 보기, 고른 메일 3통을 ' + cur.label + ' 위로 끌어다 놓는 중',
sys1: p1.system,
mine1: p1.mine,
sys2: p2.system,
mine2: p2.mine,
chip1: '메일 3통',
chip2: '대화 1개',
items1: items1,
convRows: convRows,
toasts: toasts,
};
}
}
</script>
</body>
</html>
+765
View File
@@ -0,0 +1,765 @@
<!doctype html>
<html lang="ko">
<head>
<meta charset="utf-8">
<title>메일함 옮기기 · 하위 메일함까지 지우기 · 차단할 때 받은 메일 옮기기</title>
<script src="./support.js"></script>
</head>
<body>
<x-dc>
<helmet>
<style>
@font-face{font-family:'Pretendard';src:url('/_blob/16067b9aa5137abf808e3dbef30c42e0') format('woff2-variations'),url('/_blob/16067b9aa5137abf808e3dbef30c42e0') format('woff2');font-weight:45 920;font-style:normal;font-display:swap}
body{margin:0}
a{color:#0E7C86}a:hover{color:#0A5F67}
:focus-visible{outline:2px solid #0E7C86;outline-offset:2px}
.on-dark:focus-visible{outline-color:#9ED6DB}
.primary{background:#0E7C86;color:#FFFFFF}
.primary:hover{background:#0A5F67}
.ghost{background:transparent;color:#344054}
.ghost:hover{background:#F2F4F7;color:#171C26}
.danger-solid{background:#B42318;color:#FFFFFF}
.danger-solid:hover{background:#912018}
.menu-item{background:transparent;color:#171C26}
.menu-item:hover,.menu-item.is-hover{background:#F2F4F7}
.menu-item.is-danger{color:#B42318}
.menu-item.is-danger:hover{background:#FEF3F2}
.dest-row{background:transparent}
.dest-row:hover{background:#F2F4F7}
.dest-row.is-on,.dest-row.is-on:hover{background:#E3F2F3}
.dest-row.is-off:hover{background:transparent}
.toast-action{background:transparent;color:#9ED6DB;text-decoration:none}
.toast-action:hover{color:#FFFFFF}
.toast-link{background:transparent;color:#0E7C86}
.toast-link:hover{color:#0A5F67}
.toast-close{background:transparent;color:#4A5363}
.toast-close:hover{background:#F2F4F7}
</style>
</helmet>
<div style="width: 1440px; height: 4937px; box-sizing: border-box; display: flex; flex-direction: column; padding-bottom: 48px; background: #F6F7F9; color: #171C26; font-family: Pretendard, -apple-system, 'Apple SD Gothic Neo', 'Malgun Gothic', system-ui, sans-serif; font-size: 13px; line-height: 1.5; word-break: keep-all; overflow-wrap: anywhere">
<div style="flex: 0 0 auto; box-sizing: border-box; padding: 40px 48px 0; display: flex; flex-direction: column; gap: 6px">
<h1 style="margin: 0; max-width: 1200px; font-size: 24px; font-weight: 700; letter-spacing: -0.02em">메일 15단계 · 메일함 옮기기 · 하위 메일함까지 지우기 · 차단할 때 받은 메일 옮기기</h1>
<p style="margin: 0; max-width: 1100px; font-size: 13.5px; color: #4A5363">메일함 패널의 ⋯ 메뉴와 보낸 사람 차단 창에 더하는 것입니다. 메일함을 다른 메일함 안이나 맨 위로 옮기고, 하위 메일함이 있는 메일함도 한 번에 지우고, 보낸 사람을 차단할 때 받은편지함에 이미 있는 그 사람의 메일까지 스팸메일함으로 옮깁니다. 2의 목록은 줄을 눌러 고를 수 있습니다. 예시의 보낸 사람은 news@example.com입니다. 초안이며 사용자 확인 전입니다(2026-11-09).</p>
</div>
<section aria-labelledby="s1-h" style="flex: 0 0 auto; box-sizing: border-box; padding: 40px 48px 0; display: flex; flex-direction: column; gap: 14px">
<div style="display: flex; align-items: baseline; gap: 16px">
<h2 id="s1-h" style="margin: 0; font-size: 19px; line-height: 28px; font-weight: 700; color: #0B3640; white-space: nowrap">1. 메일함 ⋯ 메뉴</h2>
<p style="margin: 0; font-size: 13.5px; color: #4A5363">내 메일함의 ⋯ 메뉴에 [옮기기]가 생깁니다. 이름 바꾸기 다음, 지우기 앞입니다. 받은편지함 같은 기본 메일함에는 없습니다.</p>
</div>
<div style="display: flex; gap: 24px; align-items: flex-start">
<div style="flex: 0 0 210px; display: flex; flex-direction: column; gap: 10px">
<div style="display: flex; flex-direction: column; gap: 1px">
<h3 style="margin: 0; font-size: 13px; font-weight: 700; color: #344054">내 메일함의 ⋯</h3>
<p style="margin: 0; font-size: 12.5px; color: #667085">거래처 안의 2025</p>
</div>
<nav aria-label="메일함" style="box-sizing: border-box; width: 210px; display: flex; flex-direction: column; gap: 14px; padding: 14px 10px; background: #F8F9FB; border: 1px solid #E6E8EC; border-radius: 12px">
<span style="display: flex; align-items: center; justify-content: center; gap: 8px; min-height: 44px; border-radius: 8px; background: #0E7C86; color: #FFFFFF; font-size: 13.5px; font-weight: 600">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 20h9"></path><path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z"></path></svg>
<span>메일 쓰기</span>
</span>
<div style="display: flex; flex-direction: column; gap: 1px">
<ul style="list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 1px">
<sc-for list="{{ f1Roles }}" as="m" hint-placeholder-count="5">
<li style="position: relative; display: flex; align-items: center; border-radius: 7px; background: {{ m.bg }}">
<span style="flex: 1 1 auto; min-width: 0; display: flex; align-items: center; gap: 10px; min-height: 40px; box-sizing: border-box; padding: 0 4px 0 {{ m.indent }}px; color: {{ m.fg }}; font-weight: {{ m.fw }}">
<sc-if value="{{ m.isInbox }}" hint-placeholder-val="{{ false }}"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" style="flex: 0 0 auto"><polyline points="22 12 16 12 14 15 10 15 8 12 2 12"></polyline><path d="M5.45 5.11 2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.45-6.89A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z"></path></svg></sc-if>
<sc-if value="{{ m.isSent }}" hint-placeholder-val="{{ false }}"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" style="flex: 0 0 auto"><line x1="22" y1="2" x2="11" y2="13"></line><polygon points="22 2 15 22 11 13 2 9 22 2"></polygon></svg></sc-if>
<sc-if value="{{ m.isDrafts }}" hint-placeholder-val="{{ false }}"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" style="flex: 0 0 auto"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"></path><polyline points="14 2 14 8 20 8"></polyline><line x1="16" y1="13" x2="8" y2="13"></line><line x1="16" y1="17" x2="8" y2="17"></line></svg></sc-if>
<sc-if value="{{ m.isJunk }}" hint-placeholder-val="{{ false }}"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" style="flex: 0 0 auto"><polygon points="7.86 2 16.14 2 22 7.86 22 16.14 16.14 22 7.86 22 2 16.14 2 7.86 7.86 2"></polygon><line x1="12" y1="8" x2="12" y2="12"></line><line x1="12" y1="16" x2="12.01" y2="16"></line></svg></sc-if>
<sc-if value="{{ m.isTrash }}" hint-placeholder-val="{{ false }}"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" style="flex: 0 0 auto"><polyline points="3 6 5 6 21 6"></polyline><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path></svg></sc-if>
<span style="flex: 1 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap">{{ m.name }}</span>
<sc-if value="{{ m.hasCount }}" hint-placeholder-val="{{ false }}"><span style="flex: 0 0 auto; font-size: 12px; font-weight: 600; color: {{ m.countColor }}; font-variant-numeric: tabular-nums">{{ m.count }}</span></sc-if>
</span>
<sc-if value="{{ m.hasMore }}" hint-placeholder-val="{{ true }}">
<button type="button" aria-label="{{ m.moreLabel }}" aria-haspopup="menu" aria-expanded="{{ m.openStr }}" style="flex: 0 0 auto; width: 32px; height: 40px; display: inline-flex; align-items: center; justify-content: center; padding: 0; border: 0; border-radius: 6px; background: {{ m.moreBg }}; color: #4A5363; opacity: {{ m.moreOpacity }}; cursor: pointer"><svg width="15" height="15" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><circle cx="5" cy="12" r="1.6"></circle><circle cx="12" cy="12" r="1.6"></circle><circle cx="19" cy="12" r="1.6"></circle></svg></button>
</sc-if>
</li>
</sc-for>
</ul>
<p style="margin: 12px 10px 4px; font-size: 12px; font-weight: 600; color: #667085">내 메일함</p>
<ul style="list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 1px">
<sc-for list="{{ f1Mine }}" as="n" hint-placeholder-count="7">
<li style="position: relative; display: flex; align-items: center; border-radius: 7px; background: {{ n.bg }}">
<span style="flex: 1 1 auto; min-width: 0; display: flex; align-items: center; gap: 10px; min-height: 40px; box-sizing: border-box; padding: 0 4px 0 {{ n.indent }}px; color: {{ n.fg }}; font-weight: {{ n.fw }}">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" style="flex: 0 0 auto"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"></path></svg>
<span style="flex: 1 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap">{{ n.name }}</span>
<sc-if value="{{ n.hasCount }}" hint-placeholder-val="{{ false }}"><span style="flex: 0 0 auto; font-size: 12px; font-weight: 600; color: {{ n.countColor }}; font-variant-numeric: tabular-nums">{{ n.count }}</span></sc-if>
</span>
<button type="button" aria-label="{{ n.moreLabel }}" aria-haspopup="menu" aria-expanded="{{ n.openStr }}" style="flex: 0 0 auto; width: 32px; height: 40px; display: inline-flex; align-items: center; justify-content: center; padding: 0; border: 0; border-radius: 6px; background: {{ n.moreBg }}; color: #4A5363; opacity: {{ n.moreOpacity }}; cursor: pointer"><svg width="15" height="15" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><circle cx="5" cy="12" r="1.6"></circle><circle cx="12" cy="12" r="1.6"></circle><circle cx="19" cy="12" r="1.6"></circle></svg></button>
<sc-if value="{{ n.open }}" hint-placeholder-val="{{ false }}">
<div role="menu" aria-label="{{ n.menuLabel }}" style="position: absolute; top: 44px; right: 0; z-index: 6; min-width: 196px; box-sizing: border-box; display: flex; flex-direction: column; padding: 6px; border: 1px solid #E6E8EC; border-radius: 10px; background: #FFFFFF; box-shadow: 0 10px 28px rgba(16, 24, 28, 0.12)">
<sc-for list="{{ n.items }}" as="it" hint-placeholder-count="5">
<button type="button" role="menuitem" class="{{ it.cls }}" style="display: flex; align-items: center; width: 100%; min-height: 40px; box-sizing: border-box; padding: 0 10px; border: 0; border-radius: 6px; font: inherit; text-align: left; white-space: nowrap; cursor: pointer">{{ it.label }}</button>
</sc-for>
</div>
</sc-if>
</li>
</sc-for>
</ul>
<span style="display: flex; align-items: center; gap: 10px; min-height: 44px; margin-top: 2px; box-sizing: border-box; padding: 0 10px; border-radius: 7px; color: #344054; font-weight: 500">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="12" y1="5" x2="12" y2="19"></line><line x1="5" y1="12" x2="19" y2="12"></line></svg>
<span>새 메일함</span>
</span>
</div>
</nav>
</div>
<div style="flex: 0 0 210px; display: flex; flex-direction: column; gap: 10px">
<div style="display: flex; flex-direction: column; gap: 1px">
<h3 style="margin: 0; font-size: 13px; font-weight: 700; color: #344054">받은편지함의 ⋯</h3>
<p style="margin: 0; font-size: 12.5px; color: #667085">기본 메일함에는 옮기기가 없습니다</p>
</div>
<nav aria-label="메일함" style="box-sizing: border-box; width: 210px; display: flex; flex-direction: column; gap: 14px; padding: 14px 10px; background: #F8F9FB; border: 1px solid #E6E8EC; border-radius: 12px">
<span style="display: flex; align-items: center; justify-content: center; gap: 8px; min-height: 44px; border-radius: 8px; background: #0E7C86; color: #FFFFFF; font-size: 13.5px; font-weight: 600">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 20h9"></path><path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z"></path></svg>
<span>메일 쓰기</span>
</span>
<ul style="list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 1px">
<sc-for list="{{ f1RolesB }}" as="m" hint-placeholder-count="5">
<li style="position: relative; display: flex; align-items: center; border-radius: 7px; background: {{ m.bg }}">
<span style="flex: 1 1 auto; min-width: 0; display: flex; align-items: center; gap: 10px; min-height: 40px; box-sizing: border-box; padding: 0 4px 0 {{ m.indent }}px; color: {{ m.fg }}; font-weight: {{ m.fw }}">
<sc-if value="{{ m.isInbox }}" hint-placeholder-val="{{ false }}"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" style="flex: 0 0 auto"><polyline points="22 12 16 12 14 15 10 15 8 12 2 12"></polyline><path d="M5.45 5.11 2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.45-6.89A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z"></path></svg></sc-if>
<sc-if value="{{ m.isSent }}" hint-placeholder-val="{{ false }}"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" style="flex: 0 0 auto"><line x1="22" y1="2" x2="11" y2="13"></line><polygon points="22 2 15 22 11 13 2 9 22 2"></polygon></svg></sc-if>
<sc-if value="{{ m.isDrafts }}" hint-placeholder-val="{{ false }}"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" style="flex: 0 0 auto"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"></path><polyline points="14 2 14 8 20 8"></polyline><line x1="16" y1="13" x2="8" y2="13"></line><line x1="16" y1="17" x2="8" y2="17"></line></svg></sc-if>
<sc-if value="{{ m.isJunk }}" hint-placeholder-val="{{ false }}"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" style="flex: 0 0 auto"><polygon points="7.86 2 16.14 2 22 7.86 22 16.14 16.14 22 7.86 22 2 16.14 2 7.86 7.86 2"></polygon><line x1="12" y1="8" x2="12" y2="12"></line><line x1="12" y1="16" x2="12.01" y2="16"></line></svg></sc-if>
<sc-if value="{{ m.isTrash }}" hint-placeholder-val="{{ false }}"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" style="flex: 0 0 auto"><polyline points="3 6 5 6 21 6"></polyline><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path></svg></sc-if>
<span style="flex: 1 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap">{{ m.name }}</span>
<sc-if value="{{ m.hasCount }}" hint-placeholder-val="{{ false }}"><span style="flex: 0 0 auto; font-size: 12px; font-weight: 600; color: {{ m.countColor }}; font-variant-numeric: tabular-nums">{{ m.count }}</span></sc-if>
</span>
<sc-if value="{{ m.hasMore }}" hint-placeholder-val="{{ true }}">
<button type="button" aria-label="{{ m.moreLabel }}" aria-haspopup="menu" aria-expanded="{{ m.openStr }}" style="flex: 0 0 auto; width: 32px; height: 40px; display: inline-flex; align-items: center; justify-content: center; padding: 0; border: 0; border-radius: 6px; background: {{ m.moreBg }}; color: #4A5363; opacity: {{ m.moreOpacity }}; cursor: pointer"><svg width="15" height="15" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><circle cx="5" cy="12" r="1.6"></circle><circle cx="12" cy="12" r="1.6"></circle><circle cx="19" cy="12" r="1.6"></circle></svg></button>
</sc-if>
<sc-if value="{{ m.open }}" hint-placeholder-val="{{ false }}">
<div role="menu" aria-label="{{ m.menuLabel }}" style="position: absolute; top: 44px; right: 0; z-index: 6; min-width: 196px; box-sizing: border-box; display: flex; flex-direction: column; padding: 6px; border: 1px solid #E6E8EC; border-radius: 10px; background: #FFFFFF; box-shadow: 0 10px 28px rgba(16, 24, 28, 0.12)">
<sc-for list="{{ m.items }}" as="it" hint-placeholder-count="2">
<button type="button" role="menuitem" class="{{ it.cls }}" style="display: flex; align-items: center; width: 100%; min-height: 40px; box-sizing: border-box; padding: 0 10px; border: 0; border-radius: 6px; font: inherit; text-align: left; white-space: nowrap; cursor: pointer">{{ it.label }}</button>
</sc-for>
</div>
</sc-if>
</li>
</sc-for>
</ul>
</nav>
</div>
<div style="flex: 1 1 auto; min-width: 0; display: flex; flex-direction: column; gap: 10px; padding: 20px; background: #FFFFFF; border: 1px solid #E6E8EC; border-radius: 12px">
<h3 style="margin: 0; font-size: 14px; font-weight: 700">정한 것</h3>
<ul style="margin: 0; padding-left: 18px; display: flex; flex-direction: column; gap: 6px; color: #344054">
<li>항목 차례는 모두 읽음으로 표시, 하위 메일함 만들기, 이름 바꾸기, 옮기기, 지우기입니다. 안 읽은 메일이 없으면 첫 항목이 빠지는 것은 지금과 같습니다.</li>
<li>옮기기는 내 메일함에만 있습니다. 받은편지함, 보낸편지함 같은 기본 메일함은 옮기지 않습니다.</li>
<li>하위 메일함이 있어도 옮길 수 있습니다. 하위 메일함과 안의 메일이 함께 가고, 그 메일함으로 옮기는 필터도 그대로 돕니다.</li>
<li>창을 여는 다른 항목(하위 메일함 만들기, 이름 바꾸기)처럼 이름 뒤에 …를 붙이지 않았습니다.</li>
<li>메일함을 지우는 동안에는 지금처럼 모든 항목을 누를 수 없습니다. 항목 높이 40은 지금 메뉴와 같습니다.</li>
</ul>
</div>
</div>
</section>
<section aria-labelledby="s2-h" style="flex: 0 0 auto; box-sizing: border-box; padding: 44px 48px 0; display: flex; flex-direction: column; gap: 14px">
<div style="display: flex; align-items: baseline; gap: 16px">
<h2 id="s2-h" style="margin: 0; font-size: 19px; line-height: 28px; font-weight: 700; color: #0B3640; white-space: nowrap">2. 옮기기 창</h2>
<p style="margin: 0; font-size: 13.5px; color: #4A5363">옮길 곳은 맨 위, 받은편지함, 내 메일함 가운데서 고릅니다. 목록의 줄을 눌러 고를 수 있습니다.</p>
</div>
<div style="display: flex; gap: 24px; align-items: flex-start">
<div style="flex: 0 0 640px; box-sizing: border-box; display: flex; justify-content: center; padding: 36px 0; border-radius: 12px; background: rgba(11, 54, 64, 0.32)">
<div role="dialog" aria-modal="true" aria-labelledby="f2-title" aria-describedby="f2-msg" style="width: 400px; box-sizing: border-box; display: flex; flex-direction: column; gap: 8px; padding: 18px 20px 14px; border: 1px solid #E6E8EC; border-radius: 12px; background: #FFFFFF; box-shadow: 0 14px 36px rgba(16, 24, 28, 0.14)">
<h3 id="f2-title" style="margin: 0; font-size: 15px; font-weight: 700">‘2025’ 메일함을 어디로 옮길까요?</h3>
<p id="f2-msg" style="margin: 0; color: #4A5363">하위 메일함과 안의 메일도 함께 옮깁니다.</p>
<div role="radiogroup" aria-label="옮길 곳" style="display: flex; flex-direction: column; gap: 1px; margin-top: 4px; padding: 4px; border: 1px solid #E6E8EC; border-radius: 8px">
<sc-for list="{{ f2Rows }}" as="r" hint-placeholder-count="10">
<sc-if value="{{ r.isHeading }}" hint-placeholder-val="{{ false }}">
<p style="margin: 8px 10px 2px; font-size: 12px; font-weight: 600; color: #667085">{{ r.name }}</p>
</sc-if>
<sc-if value="{{ r.isRow }}" hint-placeholder-val="{{ true }}">
<button type="button" role="radio" aria-checked="{{ r.checked }}" aria-disabled="{{ r.off }}" class="{{ r.cls }}" onClick="{{ r.pick }}" style="display: flex; align-items: center; gap: 10px; width: 100%; min-height: 40px; box-sizing: border-box; padding: 0 10px 0 {{ r.indent }}px; border: 0; border-radius: 7px; color: {{ r.fg }}; font: inherit; font-weight: {{ r.fw }}; text-align: left; cursor: {{ r.cursor }}">
<sc-if value="{{ r.isTop }}" hint-placeholder-val="{{ false }}"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" style="flex: 0 0 auto; opacity: {{ r.dim }}"><line x1="12" y1="19" x2="12" y2="5"></line><polyline points="5 12 12 5 19 12"></polyline></svg></sc-if>
<sc-if value="{{ r.isInbox }}" hint-placeholder-val="{{ false }}"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" style="flex: 0 0 auto; opacity: {{ r.dim }}"><polyline points="22 12 16 12 14 15 10 15 8 12 2 12"></polyline><path d="M5.45 5.11 2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.45-6.89A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z"></path></svg></sc-if>
<sc-if value="{{ r.isFolder }}" hint-placeholder-val="{{ true }}"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" style="flex: 0 0 auto; opacity: {{ r.dim }}"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"></path></svg></sc-if>
<span style="flex: 1 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; opacity: {{ r.dim }}">{{ r.name }}</span>
<sc-if value="{{ r.hasNote }}" hint-placeholder-val="{{ false }}"><span style="flex: 0 0 auto; font-size: 12px; font-weight: 400; color: #667085">{{ r.note }}</span></sc-if>
<sc-if value="{{ r.isChecked }}" hint-placeholder-val="{{ false }}"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#0A5F67" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" style="flex: 0 0 auto"><polyline points="20 6 9 17 4 12"></polyline></svg></sc-if>
</button>
</sc-if>
</sc-for>
</div>
<div style="display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 8px; margin-top: 4px">
<button type="button" class="primary" style="min-height: 44px; padding: 0 16px; border: 0; border-radius: 8px; font: inherit; font-weight: 600; opacity: {{ f2MoveOpacity }}; cursor: pointer">옮기기</button>
<button type="button" class="ghost" style="min-height: 44px; padding: 0 16px; border: 0; border-radius: 8px; font: inherit; font-weight: 500; cursor: pointer">취소</button>
</div>
</div>
</div>
<div style="flex: 1 1 auto; min-width: 0; display: flex; flex-direction: column; gap: 10px; padding: 20px; background: #FFFFFF; border: 1px solid #E6E8EC; border-radius: 12px">
<h3 style="margin: 0; font-size: 14px; font-weight: 700">정한 것</h3>
<ul style="margin: 0; padding-left: 18px; display: flex; flex-direction: column; gap: 6px; color: #344054">
<li>창 폭은 400입니다(주소록의 그룹 창과 같습니다). 처음에는 아무 곳도 고르지 않은 채 열리고, 고르기 전에는 [옮기기]를 누를 수 없습니다.</li>
<li>목록은 메일함 패널과 같은 차례와 들여쓰기이고 줄 높이는 40입니다. 고른 줄은 패널의 지금 메일함처럼 청록 바탕에 굵은 글자이고, 오른쪽에 체크 표시가 붙습니다.</li>
<li>옮기는 메일함(거래처 안의 2025)과 그 안의 정산은 흐리게 두고 고를 수 없습니다. 지금 있는 곳(거래처)도 고를 수 없고 지금 있는 곳 표시를 붙입니다. 맨 위에 있는 메일함이면 맨 위 줄에 붙습니다.</li>
<li>바로 아래에 같은 이름의 메일함이 있는 곳(보관 안의 2025)도 처음부터 고를 수 없게 하고, 같은 자리에 같은 이름의 메일함이 있습니다 표시를 붙입니다. 대소문자는 가리지 않고 견줍니다. 서버에 묻기 전에 화면이 하는 검사라 화면 상수 글입니다.</li>
<li>옮길 곳에 보낸편지함, 임시보관함, 스팸메일함, 휴지통은 없습니다. 하위 메일함을 만들 수 있는 곳과 같습니다.</li>
<li>하위 메일함이 없으면 제목 아래 글은 "안의 메일도 함께 옮깁니다."입니다.</li>
<li>메일함이 많으면 목록 칸 안에서 스크롤합니다. 화살표 키로 줄을 옮기고, Enter는 [옮기기], Esc는 [취소]와 같습니다.</li>
</ul>
</div>
</div>
</section>
<section aria-labelledby="s3-h" style="flex: 0 0 auto; box-sizing: border-box; padding: 44px 48px 0; display: flex; flex-direction: column; gap: 14px">
<div style="display: flex; align-items: baseline; gap: 16px">
<h2 id="s3-h" style="margin: 0; font-size: 19px; line-height: 28px; font-weight: 700; color: #0B3640; white-space: nowrap">3. 그사이 같은 이름이 생겼을 때</h2>
<p style="margin: 0; font-size: 13.5px; color: #4A5363">드문 경우입니다. 창을 연 사이 다른 창에서 프로젝트 안에 2025를 만들어, 프로젝트를 고르고 [옮기기]를 누르자 서버가 거절한 모습입니다.</p>
</div>
<div style="display: flex; gap: 24px; align-items: flex-start">
<div style="flex: 0 0 640px; box-sizing: border-box; display: flex; justify-content: center; padding: 36px 0; border-radius: 12px; background: rgba(11, 54, 64, 0.32)">
<div role="dialog" aria-modal="true" aria-labelledby="f3-title" aria-describedby="f3-msg" style="width: 400px; box-sizing: border-box; display: flex; flex-direction: column; gap: 8px; padding: 18px 20px 14px; border: 1px solid #E6E8EC; border-radius: 12px; background: #FFFFFF; box-shadow: 0 14px 36px rgba(16, 24, 28, 0.14)">
<h3 id="f3-title" style="margin: 0; font-size: 15px; font-weight: 700">‘2025’ 메일함을 어디로 옮길까요?</h3>
<p id="f3-msg" style="margin: 0; color: #4A5363">하위 메일함과 안의 메일도 함께 옮깁니다.</p>
<div role="radiogroup" aria-label="옮길 곳" aria-describedby="f3-err" style="display: flex; flex-direction: column; gap: 1px; margin-top: 4px; padding: 4px; border: 1px solid #E6E8EC; border-radius: 8px">
<sc-for list="{{ f3Rows }}" as="r" hint-placeholder-count="10">
<sc-if value="{{ r.isHeading }}" hint-placeholder-val="{{ false }}">
<p style="margin: 8px 10px 2px; font-size: 12px; font-weight: 600; color: #667085">{{ r.name }}</p>
</sc-if>
<sc-if value="{{ r.isRow }}" hint-placeholder-val="{{ true }}">
<button type="button" role="radio" aria-checked="{{ r.checked }}" aria-disabled="{{ r.off }}" class="{{ r.cls }}" onClick="{{ r.pick }}" style="display: flex; align-items: center; gap: 10px; width: 100%; min-height: 40px; box-sizing: border-box; padding: 0 10px 0 {{ r.indent }}px; border: 0; border-radius: 7px; color: {{ r.fg }}; font: inherit; font-weight: {{ r.fw }}; text-align: left; cursor: {{ r.cursor }}">
<sc-if value="{{ r.isTop }}" hint-placeholder-val="{{ false }}"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" style="flex: 0 0 auto; opacity: {{ r.dim }}"><line x1="12" y1="19" x2="12" y2="5"></line><polyline points="5 12 12 5 19 12"></polyline></svg></sc-if>
<sc-if value="{{ r.isInbox }}" hint-placeholder-val="{{ false }}"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" style="flex: 0 0 auto; opacity: {{ r.dim }}"><polyline points="22 12 16 12 14 15 10 15 8 12 2 12"></polyline><path d="M5.45 5.11 2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.45-6.89A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z"></path></svg></sc-if>
<sc-if value="{{ r.isFolder }}" hint-placeholder-val="{{ true }}"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" style="flex: 0 0 auto; opacity: {{ r.dim }}"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"></path></svg></sc-if>
<span style="flex: 1 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; opacity: {{ r.dim }}">{{ r.name }}</span>
<sc-if value="{{ r.hasNote }}" hint-placeholder-val="{{ false }}"><span style="flex: 0 0 auto; font-size: 12px; font-weight: 400; color: #667085">{{ r.note }}</span></sc-if>
<sc-if value="{{ r.isChecked }}" hint-placeholder-val="{{ false }}"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#0A5F67" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" style="flex: 0 0 auto"><polyline points="20 6 9 17 4 12"></polyline></svg></sc-if>
</button>
</sc-if>
</sc-for>
</div>
<p id="f3-err" role="alert" style="display: flex; align-items: flex-start; gap: 6px; margin: 0; font-size: 12.5px; color: #B42318">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" style="flex: 0 0 auto; margin-top: 2px"><circle cx="12" cy="12" r="10"></circle><line x1="12" y1="8" x2="12" y2="12"></line><line x1="12" y1="16" x2="12.01" y2="16"></line></svg>
<span>같은 이름의 메일함이 이미 있습니다.</span>
</p>
<div style="display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 8px; margin-top: 4px">
<button type="button" class="primary" style="min-height: 44px; padding: 0 16px; border: 0; border-radius: 8px; font: inherit; font-weight: 600; cursor: pointer">옮기기</button>
<button type="button" class="ghost" style="min-height: 44px; padding: 0 16px; border: 0; border-radius: 8px; font: inherit; font-weight: 500; cursor: pointer">취소</button>
</div>
</div>
</div>
<div style="flex: 1 1 auto; min-width: 0; display: flex; flex-direction: column; gap: 10px; padding: 20px; background: #FFFFFF; border: 1px solid #E6E8EC; border-radius: 12px">
<h3 style="margin: 0; font-size: 14px; font-weight: 700">정한 것</h3>
<ul style="margin: 0; padding-left: 18px; display: flex; flex-direction: column; gap: 6px; color: #344054">
<li>같은 이름은 보통 2의 표시로 미리 막습니다. 이 그림은 창의 목록이 연 때의 것이라 막지 못한 경우입니다.</li>
<li>글은 오류 사전의 MAIL_009를 그대로 씁니다. 알림은 띄우지 않고 창을 그대로 둡니다(이름 바꾸기 창과 같습니다). 다른 곳을 고르면 글이 사라집니다.</li>
<li>대소문자만 다른 이름(Report와 report)도 같은 이름으로 봅니다. 미리 막는 검사도 같은 규칙입니다.</li>
<li>그사이 메일함이 사라졌으면 RES_001 알림을 띄우고 창을 닫은 뒤 메일함 목록을 다시 받습니다(이름 바꾸기 창과 같습니다).</li>
<li>옮기는 동안에는 [옮기기]를 다시 누를 수 없습니다.</li>
</ul>
</div>
</div>
</section>
<section aria-labelledby="s4-h" style="flex: 0 0 auto; box-sizing: border-box; padding: 44px 48px 0; display: flex; flex-direction: column; gap: 14px">
<div style="display: flex; align-items: baseline; gap: 16px">
<h2 id="s4-h" style="margin: 0; font-size: 19px; line-height: 28px; font-weight: 700; color: #0B3640; white-space: nowrap">4. 옮긴 뒤 알림</h2>
<p style="margin: 0; font-size: 13.5px; color: #4A5363">메일함 패널에 바로 나타나고, 알림의 [되돌리기]로 원래 있던 곳에 되돌립니다.</p>
</div>
<div style="display: flex; gap: 24px; align-items: flex-start">
<div style="flex: 0 0 210px; display: flex; flex-direction: column; gap: 10px">
<div style="display: flex; flex-direction: column; gap: 1px">
<h3 style="margin: 0; font-size: 13px; font-weight: 700; color: #344054">옮긴 뒤 메일함 패널</h3>
<p style="margin: 0; font-size: 12.5px; color: #667085">2025가 정산과 함께 프로젝트 안으로</p>
</div>
<nav aria-label="메일함" style="box-sizing: border-box; width: 210px; display: flex; flex-direction: column; gap: 1px; padding: 2px 10px 10px; background: #F8F9FB; border: 1px solid #E6E8EC; border-radius: 12px">
<p style="margin: 12px 10px 4px; font-size: 12px; font-weight: 600; color: #667085">내 메일함</p>
<ul style="list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 1px">
<sc-for list="{{ f4Mine }}" as="n" hint-placeholder-count="7">
<li style="position: relative; display: flex; align-items: center; border-radius: 7px; background: {{ n.bg }}">
<span style="flex: 1 1 auto; min-width: 0; display: flex; align-items: center; gap: 10px; min-height: 40px; box-sizing: border-box; padding: 0 4px 0 {{ n.indent }}px; color: {{ n.fg }}; font-weight: {{ n.fw }}">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" style="flex: 0 0 auto"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"></path></svg>
<span style="flex: 1 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap">{{ n.name }}</span>
<sc-if value="{{ n.hasCount }}" hint-placeholder-val="{{ false }}"><span style="flex: 0 0 auto; font-size: 12px; font-weight: 600; color: {{ n.countColor }}; font-variant-numeric: tabular-nums">{{ n.count }}</span></sc-if>
</span>
<button type="button" aria-label="{{ n.moreLabel }}" aria-haspopup="menu" aria-expanded="{{ n.openStr }}" style="flex: 0 0 auto; width: 32px; height: 40px; display: inline-flex; align-items: center; justify-content: center; padding: 0; border: 0; border-radius: 6px; background: {{ n.moreBg }}; color: #4A5363; opacity: {{ n.moreOpacity }}; cursor: pointer"><svg width="15" height="15" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><circle cx="5" cy="12" r="1.6"></circle><circle cx="12" cy="12" r="1.6"></circle><circle cx="19" cy="12" r="1.6"></circle></svg></button>
</li>
</sc-for>
</ul>
</nav>
</div>
<div style="flex: 0 0 400px; display: flex; flex-direction: column; gap: 18px">
<div style="display: flex; flex-direction: column; gap: 8px">
<h3 style="margin: 0; font-size: 13px; font-weight: 700; color: #344054">다른 메일함 안으로 옮겼을 때</h3>
<div role="status" style="width: 400px; box-sizing: border-box; display: flex; align-items: center; gap: 10px; padding: 8px 6px 8px 14px; border-radius: 10px; background: #0B3640; color: #FFFFFF; box-shadow: 0 10px 28px rgba(16, 24, 28, 0.22)">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#9FE0B5" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" style="flex: 0 0 auto"><polyline points="20 6 9 17 4 12"></polyline></svg>
<span style="flex: 1 1 auto; min-width: 0; font-weight: 500">‘2025’ 메일함을 ‘프로젝트’ 안으로 옮겼습니다.</span>
<button type="button" class="toast-action on-dark" style="flex: 0 0 auto; display: inline-flex; align-items: center; min-height: 44px; padding: 0 10px; border: 0; border-radius: 6px; font: inherit; font-weight: 600; cursor: pointer">되돌리기</button>
</div>
</div>
<div style="display: flex; flex-direction: column; gap: 8px">
<h3 style="margin: 0; font-size: 13px; font-weight: 700; color: #344054">맨 위로 옮겼을 때</h3>
<div role="status" style="width: 400px; box-sizing: border-box; display: flex; align-items: center; gap: 10px; padding: 8px 6px 8px 14px; border-radius: 10px; background: #0B3640; color: #FFFFFF; box-shadow: 0 10px 28px rgba(16, 24, 28, 0.22)">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#9FE0B5" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" style="flex: 0 0 auto"><polyline points="20 6 9 17 4 12"></polyline></svg>
<span style="flex: 1 1 auto; min-width: 0; font-weight: 500">‘2025’ 메일함을 다른 메일함 밖으로 옮겼습니다.</span>
<button type="button" class="toast-action on-dark" style="flex: 0 0 auto; display: inline-flex; align-items: center; min-height: 44px; padding: 0 10px; border: 0; border-radius: 6px; font: inherit; font-weight: 600; cursor: pointer">되돌리기</button>
</div>
</div>
<div style="display: flex; flex-direction: column; gap: 8px">
<h3 style="margin: 0; font-size: 13px; font-weight: 700; color: #344054">[되돌리기]를 누른 뒤</h3>
<div role="status" style="width: 400px; box-sizing: border-box; display: flex; align-items: center; gap: 10px; padding: 8px 6px 8px 14px; border-radius: 10px; background: #0B3640; color: #FFFFFF; box-shadow: 0 10px 28px rgba(16, 24, 28, 0.22)">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#9FE0B5" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" style="flex: 0 0 auto"><polyline points="20 6 9 17 4 12"></polyline></svg>
<span style="flex: 1 1 auto; min-width: 0; font-weight: 500">‘2025’ 메일함을 ‘거래처’ 안으로 되돌렸습니다.</span>
</div>
</div>
</div>
<div style="flex: 1 1 auto; min-width: 0; display: flex; flex-direction: column; gap: 10px; padding: 20px; background: #FFFFFF; border: 1px solid #E6E8EC; border-radius: 12px">
<h3 style="margin: 0; font-size: 14px; font-weight: 700">정한 것</h3>
<ul style="margin: 0; padding-left: 18px; display: flex; flex-direction: column; gap: 6px; color: #344054">
<li>메일함 패널에 바로 나타납니다. 보던 메일함을 옮겨도 그 메일함을 계속 봅니다.</li>
<li>[되돌리기]는 원래 있던 곳으로 다시 옮깁니다. 단추가 있는 알림은 지금처럼 10초 동안 남습니다.</li>
<li>받은편지함 안으로 옮겼으면 "‘2025’ 메일함을 받은편지함 안으로 옮겼습니다."처럼 받은편지함에는 따옴표를 붙이지 않습니다.</li>
</ul>
</div>
</div>
</section>
<section aria-labelledby="s5-h" style="flex: 0 0 auto; box-sizing: border-box; padding: 44px 48px 0; display: flex; flex-direction: column; gap: 14px">
<div style="display: flex; align-items: baseline; gap: 16px">
<h2 id="s5-h" style="margin: 0; font-size: 19px; line-height: 28px; font-weight: 700; color: #0B3640; white-space: nowrap">5. 하위 메일함까지 지우기</h2>
<p style="margin: 0; font-size: 13.5px; color: #4A5363">하위 메일함이 있는 메일함을 지우려 하면 지금은 지울 수 없다고만 알립니다. 15단계부터는 하위 메일함까지 함께 지울지 묻습니다.</p>
</div>
<div style="display: flex; gap: 24px; align-items: flex-start">
<div style="flex: 0 0 408px; display: flex; flex-direction: column; gap: 10px">
<div style="display: flex; flex-direction: column; gap: 1px">
<h3 style="margin: 0; font-size: 13px; font-weight: 700; color: #344054">지금</h3>
<p style="margin: 0; font-size: 12.5px; color: #667085">하위 메일함을 먼저 지우라고만 합니다</p>
</div>
<div style="box-sizing: border-box; height: 280px; display: flex; align-items: center; justify-content: center; border-radius: 12px; background: rgba(11, 54, 64, 0.32)">
<div role="alertdialog" aria-modal="true" aria-labelledby="f5a-title" aria-describedby="f5a-msg" style="width: 360px; box-sizing: border-box; display: flex; flex-direction: column; gap: 8px; padding: 18px 18px 14px; border: 1px solid #E6E8EC; border-radius: 12px; background: #FFFFFF; box-shadow: 0 14px 36px rgba(16, 24, 28, 0.14)">
<h3 id="f5a-title" style="margin: 0; font-size: 15px; font-weight: 700">‘거래처’ 메일함을 지울 수 없습니다</h3>
<p id="f5a-msg" style="margin: 0; color: #4A5363">하위 메일함이 있습니다. 하위 메일함을 먼저 지워 주세요.</p>
<div style="display: flex; justify-content: flex-end; gap: 8px; margin-top: 4px">
<button type="button" class="primary" style="min-height: 44px; padding: 0 16px; border: 0; border-radius: 8px; font: inherit; font-weight: 600; cursor: pointer">확인</button>
</div>
</div>
</div>
</div>
<div style="flex: 0 0 408px; display: flex; flex-direction: column; gap: 10px">
<div style="display: flex; flex-direction: column; gap: 1px">
<h3 style="margin: 0; font-size: 13px; font-weight: 700; color: #344054">15단계</h3>
<p style="margin: 0; font-size: 12.5px; color: #667085">하위 메일함까지 지울지 묻습니다 · 처음 초점은 [취소]</p>
</div>
<div style="box-sizing: border-box; height: 280px; display: flex; align-items: center; justify-content: center; border-radius: 12px; background: rgba(11, 54, 64, 0.32)">
<div role="alertdialog" aria-modal="true" aria-labelledby="f5b-title" aria-describedby="f5b-msg" style="width: 360px; box-sizing: border-box; display: flex; flex-direction: column; gap: 8px; padding: 18px 18px 14px; border: 1px solid #E6E8EC; border-radius: 12px; background: #FFFFFF; box-shadow: 0 14px 36px rgba(16, 24, 28, 0.14)">
<h3 id="f5b-title" style="margin: 0; font-size: 15px; font-weight: 700">‘거래처’ 메일함과 하위 메일함 2개를 지울까요?</h3>
<p id="f5b-msg" style="margin: 0; color: #4A5363">하위 메일함에 든 메일까지 모두 40통을 휴지통으로 옮깁니다. 휴지통에 들어간 메일은 30일 뒤 자동으로 지워집니다. 지운 메일함은 되돌릴 수 없습니다.</p>
<div style="display: flex; justify-content: flex-end; gap: 8px; margin-top: 4px">
<button type="button" class="danger-solid" style="min-height: 44px; padding: 0 16px; border: 0; border-radius: 8px; font: inherit; font-weight: 600; cursor: pointer">지우기</button>
<button type="button" class="ghost" style="min-height: 44px; padding: 0 16px; border: 0; border-radius: 8px; outline: 2px solid #0E7C86; outline-offset: 2px; font: inherit; font-weight: 500; cursor: pointer">취소</button>
</div>
</div>
</div>
</div>
<div style="flex: 1 1 auto; min-width: 0; display: flex; flex-direction: column; gap: 10px; padding: 20px; background: #FFFFFF; border: 1px solid #E6E8EC; border-radius: 12px">
<h3 style="margin: 0; font-size: 14px; font-weight: 700">정한 것</h3>
<ul style="margin: 0; padding-left: 18px; display: flex; flex-direction: column; gap: 6px; color: #344054">
<li>하위 메일함 수는 아래의 모든 깊이를 셉니다(2025와 그 안의 정산으로 2개). 메일 수도 하위 메일함까지 더한 수입니다.</li>
<li>메일이 하나도 없으면 둘째 글은 "하위 메일함까지 모두 비어 있습니다. 지운 메일함은 되돌릴 수 없습니다."입니다.</li>
<li>처음 초점은 [취소]이고, 휴지통을 비우는 동안에는 [지우기]를 누를 수 없습니다. 둘 다 지금 지우기 창과 같습니다.</li>
</ul>
</div>
</div>
<p style="margin: 0; font-size: 12.5px; color: #4A5363">진행과 결과는 지금 메일함 지우기와 같습니다. "‘거래처’ 메일함을 지우는 중 · 12 / 40통" 알림이 끝나면 "‘거래처’ 메일함을 지웠습니다. 안의 메일 40통은 휴지통에 있습니다."로 바뀝니다.</p>
</section>
<section aria-labelledby="s6-h" style="flex: 0 0 auto; box-sizing: border-box; padding: 44px 48px 0; display: flex; flex-direction: column; gap: 14px">
<div style="display: flex; align-items: baseline; gap: 16px">
<h2 id="s6-h" style="margin: 0; font-size: 19px; line-height: 28px; font-weight: 700; color: #0B3640; white-space: nowrap">6. 보낸 사람 차단 창</h2>
<p style="margin: 0; font-size: 13.5px; color: #4A5363">지금 창에 칸 하나를 더해, 받은편지함에 이미 있는 같은 보낸 사람의 메일까지 스팸메일함으로 옮깁니다. 많을 수 있고 한꺼번에 되돌릴 수 없어서 처음에는 꺼져 있습니다.</p>
</div>
<div style="display: flex; gap: 12px; align-items: stretch">
<div style="flex: 0 0 440px; display: flex; flex-direction: column; gap: 10px">
<div style="display: flex; flex-direction: column; gap: 1px">
<h3 style="margin: 0; font-size: 13px; font-weight: 700; color: #344054">처음 모습</h3>
<p style="margin: 0; font-size: 12.5px; color: #667085">이 주소만 · 새 칸은 꺼져 있습니다</p>
</div>
<div style="flex: 1 1 auto; box-sizing: border-box; display: flex; align-items: flex-start; justify-content: center; padding: 20px 0; border-radius: 12px; background: rgba(11, 54, 64, 0.32)">
<div role="dialog" aria-modal="true" aria-labelledby="f6a-title" aria-describedby="f6a-msg" style="width: 400px; box-sizing: border-box; display: flex; flex-direction: column; gap: 8px; padding: 18px 20px 14px; border: 1px solid #E6E8EC; border-radius: 12px; background: #FFFFFF; box-shadow: 0 14px 36px rgba(16, 24, 28, 0.14)">
<h3 id="f6a-title" style="margin: 0; font-size: 15px; font-weight: 700">보낸 사람 차단</h3>
<p id="f6a-msg" style="margin: 0; color: #4A5363">앞으로 오는 메일은 스팸메일함으로 옮겨지고, 부재중 자동 응답도 보내지 않습니다.</p>
<div style="display: flex; flex-direction: column; gap: 14px; margin-top: 6px">
<div role="radiogroup" aria-label="차단할 범위" style="display: flex; flex-direction: column; gap: 4px">
<label style="display: flex; align-items: flex-start; gap: 10px; min-height: 44px; box-sizing: border-box; padding: 10px; border: 1px solid #0E7C86; border-radius: 8px; background: #F3F8F8; cursor: pointer">
<input type="radio" name="f6a-scope" checked style="flex: 0 0 auto; width: 18px; height: 18px; margin: 1px 0 0; accent-color: #0E7C86">
<span style="display: flex; flex-direction: column; min-width: 0; line-height: 1.4"><span style="font-weight: 600">이 주소만</span><span style="font-size: 12.5px; color: #4A5363">news@example.com</span></span>
</label>
<label style="display: flex; align-items: flex-start; gap: 10px; min-height: 44px; box-sizing: border-box; padding: 10px; border: 1px solid #D0D5DD; border-radius: 8px; cursor: pointer">
<input type="radio" name="f6a-scope" style="flex: 0 0 auto; width: 18px; height: 18px; margin: 1px 0 0; accent-color: #0E7C86">
<span style="display: flex; flex-direction: column; min-width: 0; line-height: 1.4"><span style="font-weight: 600">이 도메인 전체</span><span style="font-size: 12.5px; color: #4A5363">@example.com에서 오는 메일 모두</span></span>
</label>
</div>
<div style="display: flex; flex-direction: column">
<label style="display: flex; align-items: flex-start; gap: 10px; min-height: 44px; box-sizing: border-box; padding-top: 12px; cursor: pointer">
<input type="checkbox" checked style="flex: 0 0 auto; width: 18px; height: 18px; margin: 1px 0 0; accent-color: #0E7C86">
<span>이 메일도 스팸메일함으로 옮기기</span>
</label>
<label style="display: flex; align-items: flex-start; gap: 10px; min-height: 44px; box-sizing: border-box; padding-top: 12px; cursor: pointer">
<input type="checkbox" aria-describedby="f6a-note" style="flex: 0 0 auto; width: 18px; height: 18px; margin: 1px 0 0; accent-color: #0E7C86">
<span>받은편지함에 있는 이 보낸 사람의 메일도 스팸메일함으로 옮기기</span>
</label>
<p id="f6a-note" style="margin: 2px 0 0 28px; font-size: 12.5px; color: #667085">옮긴 메일은 한꺼번에 되돌릴 수 없습니다.</p>
</div>
<p style="margin: 0; font-size: 12.5px; color: #667085">차단은 [설정] → [차단한 주소]에서 풀 수 있습니다.</p>
</div>
<div style="display: flex; justify-content: flex-end; gap: 8px; margin-top: 4px">
<button type="button" class="primary" style="min-height: 44px; padding: 0 16px; border: 0; border-radius: 8px; font: inherit; font-weight: 600; cursor: pointer">차단</button>
<button type="button" class="ghost" style="min-height: 44px; padding: 0 16px; border: 0; border-radius: 8px; font: inherit; font-weight: 500; cursor: pointer">취소</button>
</div>
</div>
</div>
</div>
<div style="flex: 0 0 440px; display: flex; flex-direction: column; gap: 10px">
<div style="display: flex; flex-direction: column; gap: 1px">
<h3 style="margin: 0; font-size: 13px; font-weight: 700; color: #344054">이 도메인 전체를 고르고 새 칸을 켬</h3>
<p style="margin: 0; font-size: 12.5px; color: #667085">이 메일이 받은편지함에 있어 첫 칸이 켜진 채 잠깁니다</p>
</div>
<div style="flex: 1 1 auto; box-sizing: border-box; display: flex; align-items: flex-start; justify-content: center; padding: 20px 0; border-radius: 12px; background: rgba(11, 54, 64, 0.32)">
<div role="dialog" aria-modal="true" aria-labelledby="f6b-title" aria-describedby="f6b-msg" style="width: 400px; box-sizing: border-box; display: flex; flex-direction: column; gap: 8px; padding: 18px 20px 14px; border: 1px solid #E6E8EC; border-radius: 12px; background: #FFFFFF; box-shadow: 0 14px 36px rgba(16, 24, 28, 0.14)">
<h3 id="f6b-title" style="margin: 0; font-size: 15px; font-weight: 700">보낸 사람 차단</h3>
<p id="f6b-msg" style="margin: 0; color: #4A5363">앞으로 오는 메일은 스팸메일함으로 옮겨지고, 부재중 자동 응답도 보내지 않습니다.</p>
<div style="display: flex; flex-direction: column; gap: 14px; margin-top: 6px">
<div role="radiogroup" aria-label="차단할 범위" style="display: flex; flex-direction: column; gap: 4px">
<label style="display: flex; align-items: flex-start; gap: 10px; min-height: 44px; box-sizing: border-box; padding: 10px; border: 1px solid #D0D5DD; border-radius: 8px; cursor: pointer">
<input type="radio" name="f6b-scope" style="flex: 0 0 auto; width: 18px; height: 18px; margin: 1px 0 0; accent-color: #0E7C86">
<span style="display: flex; flex-direction: column; min-width: 0; line-height: 1.4"><span style="font-weight: 600">이 주소만</span><span style="font-size: 12.5px; color: #4A5363">news@example.com</span></span>
</label>
<label style="display: flex; align-items: flex-start; gap: 10px; min-height: 44px; box-sizing: border-box; padding: 10px; border: 1px solid #0E7C86; border-radius: 8px; background: #F3F8F8; cursor: pointer">
<input type="radio" name="f6b-scope" checked style="flex: 0 0 auto; width: 18px; height: 18px; margin: 1px 0 0; accent-color: #0E7C86">
<span style="display: flex; flex-direction: column; min-width: 0; line-height: 1.4"><span style="font-weight: 600">이 도메인 전체</span><span style="font-size: 12.5px; color: #4A5363">@example.com에서 오는 메일 모두</span></span>
</label>
</div>
<div style="display: flex; flex-direction: column">
<label style="display: flex; align-items: flex-start; gap: 10px; min-height: 44px; box-sizing: border-box; padding-top: 12px; cursor: default">
<input type="checkbox" checked aria-disabled="true" aria-describedby="f6b-lock" style="flex: 0 0 auto; width: 18px; height: 18px; margin: 1px 0 0; accent-color: #0E7C86; opacity: 0.5">
<span style="opacity: 0.5">이 메일도 스팸메일함으로 옮기기</span>
</label>
<p id="f6b-lock" style="margin: -10px 0 0 28px; font-size: 12.5px; color: #667085">받은편지함의 메일을 옮길 때 함께 옮깁니다.</p>
<label style="display: flex; align-items: flex-start; gap: 10px; min-height: 44px; box-sizing: border-box; padding-top: 12px; cursor: pointer">
<input type="checkbox" checked aria-describedby="f6b-note" style="flex: 0 0 auto; width: 18px; height: 18px; margin: 1px 0 0; accent-color: #0E7C86">
<span>받은편지함에 있는 이 도메인의 메일도 스팸메일함으로 옮기기</span>
</label>
<p id="f6b-note" style="margin: 2px 0 0 28px; font-size: 12.5px; color: #667085">옮긴 메일은 한꺼번에 되돌릴 수 없습니다.</p>
</div>
<p style="margin: 0; font-size: 12.5px; color: #667085">차단은 [설정] → [차단한 주소]에서 풀 수 있습니다.</p>
</div>
<div style="display: flex; justify-content: flex-end; gap: 8px; margin-top: 4px">
<button type="button" class="primary" style="min-height: 44px; padding: 0 16px; border: 0; border-radius: 8px; font: inherit; font-weight: 600; cursor: pointer">차단</button>
<button type="button" class="ghost" style="min-height: 44px; padding: 0 16px; border: 0; border-radius: 8px; font: inherit; font-weight: 500; cursor: pointer">취소</button>
</div>
</div>
</div>
</div>
<div style="flex: 0 0 440px; display: flex; flex-direction: column; gap: 10px">
<div style="display: flex; flex-direction: column; gap: 1px">
<h3 style="margin: 0; font-size: 13px; font-weight: 700; color: #344054">스팸메일함의 메일에서 차단할 때</h3>
<p style="margin: 0; font-size: 12.5px; color: #667085">첫 칸은 지금처럼 없고 새 칸은 남습니다</p>
</div>
<div style="flex: 1 1 auto; box-sizing: border-box; display: flex; align-items: flex-start; justify-content: center; padding: 20px 0; border-radius: 12px; background: rgba(11, 54, 64, 0.32)">
<div role="dialog" aria-modal="true" aria-labelledby="f6c-title" aria-describedby="f6c-msg" style="width: 400px; box-sizing: border-box; display: flex; flex-direction: column; gap: 8px; padding: 18px 20px 14px; border: 1px solid #E6E8EC; border-radius: 12px; background: #FFFFFF; box-shadow: 0 14px 36px rgba(16, 24, 28, 0.14)">
<h3 id="f6c-title" style="margin: 0; font-size: 15px; font-weight: 700">보낸 사람 차단</h3>
<p id="f6c-msg" style="margin: 0; color: #4A5363">앞으로 오는 메일은 스팸메일함으로 옮겨지고, 부재중 자동 응답도 보내지 않습니다.</p>
<div style="display: flex; flex-direction: column; gap: 14px; margin-top: 6px">
<div role="radiogroup" aria-label="차단할 범위" style="display: flex; flex-direction: column; gap: 4px">
<label style="display: flex; align-items: flex-start; gap: 10px; min-height: 44px; box-sizing: border-box; padding: 10px; border: 1px solid #0E7C86; border-radius: 8px; background: #F3F8F8; cursor: pointer">
<input type="radio" name="f6c-scope" checked style="flex: 0 0 auto; width: 18px; height: 18px; margin: 1px 0 0; accent-color: #0E7C86">
<span style="display: flex; flex-direction: column; min-width: 0; line-height: 1.4"><span style="font-weight: 600">이 주소만</span><span style="font-size: 12.5px; color: #4A5363">news@example.com</span></span>
</label>
<label style="display: flex; align-items: flex-start; gap: 10px; min-height: 44px; box-sizing: border-box; padding: 10px; border: 1px solid #D0D5DD; border-radius: 8px; cursor: pointer">
<input type="radio" name="f6c-scope" style="flex: 0 0 auto; width: 18px; height: 18px; margin: 1px 0 0; accent-color: #0E7C86">
<span style="display: flex; flex-direction: column; min-width: 0; line-height: 1.4"><span style="font-weight: 600">이 도메인 전체</span><span style="font-size: 12.5px; color: #4A5363">@example.com에서 오는 메일 모두</span></span>
</label>
</div>
<div style="display: flex; flex-direction: column">
<label style="display: flex; align-items: flex-start; gap: 10px; min-height: 44px; box-sizing: border-box; padding-top: 12px; cursor: pointer">
<input type="checkbox" aria-describedby="f6c-note" style="flex: 0 0 auto; width: 18px; height: 18px; margin: 1px 0 0; accent-color: #0E7C86">
<span>받은편지함에 있는 이 보낸 사람의 메일도 스팸메일함으로 옮기기</span>
</label>
<p id="f6c-note" style="margin: 2px 0 0 28px; font-size: 12.5px; color: #667085">옮긴 메일은 한꺼번에 되돌릴 수 없습니다.</p>
</div>
<p style="margin: 0; font-size: 12.5px; color: #667085">차단은 [설정] → [차단한 주소]에서 풀 수 있습니다.</p>
</div>
<div style="display: flex; justify-content: flex-end; gap: 8px; margin-top: 4px">
<button type="button" class="primary" style="min-height: 44px; padding: 0 16px; border: 0; border-radius: 8px; font: inherit; font-weight: 600; cursor: pointer">차단</button>
<button type="button" class="ghost" style="min-height: 44px; padding: 0 16px; border: 0; border-radius: 8px; font: inherit; font-weight: 500; cursor: pointer">취소</button>
</div>
</div>
</div>
</div>
</div>
<div style="display: flex; gap: 12px; align-items: flex-start">
<div style="flex: 0 0 440px; display: flex; flex-direction: column; gap: 10px">
<div style="display: flex; flex-direction: column; gap: 1px">
<h3 style="margin: 0; font-size: 13px; font-weight: 700; color: #344054">이 탭에서 필터를 적용하는 중일 때</h3>
<p style="margin: 0; font-size: 12.5px; color: #667085">창 안의 두 칸만 그렸습니다 · 새 칸이 꺼지고 까닭이 붙습니다</p>
</div>
<div style="box-sizing: border-box; display: flex; flex-direction: column; padding: 2px 20px 16px; border: 1px solid #E6E8EC; border-radius: 12px; background: #FFFFFF">
<label style="display: flex; align-items: flex-start; gap: 10px; min-height: 44px; box-sizing: border-box; padding-top: 12px; cursor: pointer">
<input type="checkbox" checked style="flex: 0 0 auto; width: 18px; height: 18px; margin: 1px 0 0; accent-color: #0E7C86">
<span>이 메일도 스팸메일함으로 옮기기</span>
</label>
<label style="display: flex; align-items: flex-start; gap: 10px; min-height: 44px; box-sizing: border-box; padding-top: 12px; cursor: default">
<input type="checkbox" aria-disabled="true" aria-describedby="f6d-busy" style="flex: 0 0 auto; width: 18px; height: 18px; margin: 1px 0 0; accent-color: #0E7C86; opacity: 0.5">
<span style="opacity: 0.5">받은편지함에 있는 이 보낸 사람의 메일도 스팸메일함으로 옮기기</span>
</label>
<p id="f6d-busy" style="margin: 2px 0 0 28px; font-size: 12.5px; color: #667085">받은편지함을 정리하는 중이라 지금은 고를 수 없습니다.</p>
</div>
</div>
<div style="flex: 1 1 auto; min-width: 0; display: flex; flex-direction: column; gap: 10px; padding: 20px; background: #FFFFFF; border: 1px solid #E6E8EC; border-radius: 12px">
<h3 style="margin: 0; font-size: 14px; font-weight: 700">정한 것</h3>
<ul style="margin: 0; padding-left: 18px; display: flex; flex-direction: column; gap: 6px; color: #344054">
<li>새 칸은 창을 열 때마다 꺼진 채 시작합니다. 첫 칸은 지금처럼 켜진 채 시작합니다.</li>
<li>새 칸을 켰는데 이 메일이 받은편지함에 있으면 첫 칸은 켜진 채 잠기고(aria-disabled) 칸 아래에 까닭이 붙습니다. 새 칸을 끄면 첫 칸은 원래대로 돌아옵니다. 이 메일이 다른 메일함에 있으면 잠그지 않습니다.</li>
<li>이 도메인 전체를 고르면 새 칸의 이 보낸 사람이 이 도메인으로 바뀝니다. 차단처럼 그 도메인만 보고 하위 도메인은 보지 않습니다.</li>
<li>받은편지함에 있는 메일만 옮깁니다. 직원이 정리해 둔 다른 메일함은 그대로 둡니다(필터 적용과 같습니다).</li>
<li>이 탭에서 필터를 받은편지함에 적용하는 동안에는 새 칸을 끄고(aria-disabled) 칸 아래 글을 까닭으로 바꿉니다. 7단계 필터 창의 칸과 같습니다.</li>
<li>두 칸은 붙여 두고, 칸 글이 두 줄이면 고르기 상자를 첫 줄에 맞춥니다. 칸 아래 글은 글 선(28)에 맞춰 들여 씁니다.</li>
</ul>
</div>
</div>
</section>
<section aria-labelledby="s7-h" style="flex: 0 0 auto; box-sizing: border-box; padding: 44px 48px 0; display: flex; flex-direction: column; gap: 14px">
<div style="display: flex; align-items: baseline; gap: 16px">
<h2 id="s7-h" style="margin: 0; font-size: 19px; line-height: 28px; font-weight: 700; color: #0B3640; white-space: nowrap">7. 차단한 뒤 알림</h2>
<p style="margin: 0; font-size: 13.5px; color: #4A5363">새 칸을 켜고 차단하면 차단 알림을 따로 띄우지 않습니다. 알림 하나가 차단했다는 글로 시작해 진행 숫자를 보이다가, 같은 자리에서 결과로 바뀝니다. 7단계 필터 적용의 진행 알림과 같은 결입니다.</p>
</div>
<div style="display: flex; gap: 32px; align-items: flex-start">
<div style="flex: 0 0 400px; display: flex; flex-direction: column; gap: 10px">
<div style="display: flex; flex-direction: column; gap: 1px">
<h3 style="margin: 0; font-size: 13px; font-weight: 700; color: #344054">새 칸을 켜지 않았을 때</h3>
<p style="margin: 0; font-size: 12.5px; color: #667085">지금 차단 알림 그대로</p>
</div>
<div role="status" style="width: 400px; box-sizing: border-box; display: flex; align-items: center; gap: 10px; padding: 8px 6px 8px 14px; border-radius: 10px; background: #0B3640; color: #FFFFFF; box-shadow: 0 10px 28px rgba(16, 24, 28, 0.22)">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#9FE0B5" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" style="flex: 0 0 auto"><polyline points="20 6 9 17 4 12"></polyline></svg>
<span style="flex: 1 1 auto; min-width: 0; font-weight: 500">‘news@example.com’을(를) 차단하고 이 메일을 스팸메일함으로 옮겼습니다.</span>
<button type="button" class="toast-action on-dark" style="flex: 0 0 auto; display: inline-flex; align-items: center; min-height: 44px; padding: 0 10px; border: 0; border-radius: 6px; font: inherit; font-weight: 600; cursor: pointer">되돌리기</button>
</div>
</div>
<div style="flex: 0 0 400px; display: flex; flex-direction: column; gap: 10px">
<div style="display: flex; flex-direction: column; gap: 1px">
<h3 style="margin: 0; font-size: 13px; font-weight: 700; color: #344054">새 칸을 켰을 때</h3>
<p style="margin: 0; font-size: 12.5px; color: #667085">알림 하나가 진행을 보이다가 같은 자리에서 결과로</p>
</div>
<div role="status" style="width: 400px; box-sizing: border-box; display: flex; align-items: center; gap: 10px; min-height: 44px; padding: 8px 14px; border-radius: 10px; background: #0B3640; color: #FFFFFF; box-shadow: 0 10px 28px rgba(16, 24, 28, 0.22)">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#9ED6DB" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" style="flex: 0 0 auto"><path d="M21 12a9 9 0 1 1-6.22-8.56"></path></svg>
<span style="flex: 1 1 auto; min-width: 0; font-weight: 500; font-variant-numeric: tabular-nums">‘news@example.com’을(를) 차단했습니다. 받은편지함의 메일을 스팸메일함으로 옮기는 중 · 1,200 / 4,800통 확인</span>
</div>
<div role="status" style="width: 400px; box-sizing: border-box; display: flex; align-items: center; gap: 10px; padding: 8px 6px 8px 14px; border-radius: 10px; background: #0B3640; color: #FFFFFF; box-shadow: 0 10px 28px rgba(16, 24, 28, 0.22)">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#9FE0B5" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" style="flex: 0 0 auto"><polyline points="20 6 9 17 4 12"></polyline></svg>
<span style="flex: 1 1 auto; min-width: 0; font-weight: 500">‘news@example.com’을(를) 차단하고 받은편지함의 메일 37통을 스팸메일함으로 옮겼습니다.</span>
<a href="#" class="toast-action on-dark" style="flex: 0 0 auto; display: inline-flex; align-items: center; min-height: 44px; padding: 0 10px; border-radius: 6px; font-weight: 600">스팸메일함 보기</a>
</div>
<div role="status" style="width: 400px; box-sizing: border-box; display: flex; align-items: center; gap: 10px; padding: 8px 6px 8px 14px; border-radius: 10px; background: #0B3640; color: #FFFFFF; box-shadow: 0 10px 28px rgba(16, 24, 28, 0.22)">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#9FE0B5" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" style="flex: 0 0 auto"><polyline points="20 6 9 17 4 12"></polyline></svg>
<span style="flex: 1 1 auto; min-width: 0; font-weight: 500">‘news@example.com’을(를) 차단했습니다. 받은편지함에는 옮길 메일이 없습니다.</span>
</div>
</div>
<div style="flex: 0 0 400px; display: flex; flex-direction: column; gap: 10px">
<div style="display: flex; flex-direction: column; gap: 1px">
<h3 style="margin: 0; font-size: 13px; font-weight: 700; color: #344054">새 칸을 켰는데 멈췄을 때</h3>
<p style="margin: 0; font-size: 12.5px; color: #667085">도중에 멈춤 · 처음부터 실패 · 다른 창에서 하는 중</p>
</div>
<div role="alert" style="width: 400px; box-sizing: border-box; display: flex; align-items: flex-start; gap: 10px; padding: 12px 6px 12px 14px; border: 1px solid #E6E8EC; border-radius: 10px; background: #FFFFFF; box-shadow: 0 10px 28px rgba(16, 24, 28, 0.12)">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="#B42318" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" style="flex: 0 0 auto; margin-top: 1px"><circle cx="12" cy="12" r="10"></circle><line x1="12" y1="8" x2="12" y2="12"></line><line x1="12" y1="16" x2="12.01" y2="16"></line></svg>
<span style="flex: 1 1 auto; min-width: 0; font-weight: 500">‘news@example.com’을(를) 차단했습니다. 받은편지함은 4,800통 가운데 1,200통까지 확인해 12통을 옮긴 뒤 멈췄습니다. 다시 시도해 주세요.</span>
<button type="button" class="toast-link" style="flex: 0 0 auto; align-self: center; display: inline-flex; align-items: center; min-height: 44px; padding: 0 10px; border: 0; border-radius: 6px; font: inherit; font-weight: 600; cursor: pointer">다시 시도</button>
<button type="button" class="toast-close" aria-label="알림 닫기" style="flex: 0 0 auto; width: 36px; height: 36px; display: inline-flex; align-items: center; justify-content: center; padding: 0; border: 0; border-radius: 6px; cursor: pointer"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line></svg></button>
</div>
<div role="alert" style="width: 400px; box-sizing: border-box; display: flex; align-items: flex-start; gap: 10px; padding: 12px 6px 12px 14px; border: 1px solid #E6E8EC; border-radius: 10px; background: #FFFFFF; box-shadow: 0 10px 28px rgba(16, 24, 28, 0.12)">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="#B42318" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" style="flex: 0 0 auto; margin-top: 1px"><circle cx="12" cy="12" r="10"></circle><line x1="12" y1="8" x2="12" y2="12"></line><line x1="12" y1="16" x2="12.01" y2="16"></line></svg>
<span style="flex: 1 1 auto; min-width: 0; font-weight: 500">‘news@example.com’을(를) 차단했지만 받은편지함의 메일은 옮기지 못했습니다. 다시 시도해 주세요.</span>
<button type="button" class="toast-link" style="flex: 0 0 auto; align-self: center; display: inline-flex; align-items: center; min-height: 44px; padding: 0 10px; border: 0; border-radius: 6px; font: inherit; font-weight: 600; cursor: pointer">다시 시도</button>
<button type="button" class="toast-close" aria-label="알림 닫기" style="flex: 0 0 auto; width: 36px; height: 36px; display: inline-flex; align-items: center; justify-content: center; padding: 0; border: 0; border-radius: 6px; cursor: pointer"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line></svg></button>
</div>
<div role="alert" style="width: 400px; box-sizing: border-box; display: flex; align-items: flex-start; gap: 10px; padding: 12px 6px 12px 14px; border: 1px solid #E6E8EC; border-radius: 10px; background: #FFFFFF; box-shadow: 0 10px 28px rgba(16, 24, 28, 0.12)">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="#B42318" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" style="flex: 0 0 auto; margin-top: 1px"><circle cx="12" cy="12" r="10"></circle><line x1="12" y1="8" x2="12" y2="12"></line><line x1="12" y1="16" x2="12.01" y2="16"></line></svg>
<span style="flex: 1 1 auto; min-width: 0; font-weight: 500">‘news@example.com’을(를) 차단했습니다. 다른 창에서 받은편지함을 정리하는 중입니다. 끝난 뒤 다시 시도해 주세요.</span>
<button type="button" class="toast-link" style="flex: 0 0 auto; align-self: center; display: inline-flex; align-items: center; min-height: 44px; padding: 0 10px; border: 0; border-radius: 6px; font: inherit; font-weight: 600; cursor: pointer">다시 시도</button>
<button type="button" class="toast-close" aria-label="알림 닫기" style="flex: 0 0 auto; width: 36px; height: 36px; display: inline-flex; align-items: center; justify-content: center; padding: 0; border: 0; border-radius: 6px; cursor: pointer"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line></svg></button>
</div>
</div>
</div>
<div style="display: flex; flex-direction: column; gap: 10px; padding: 20px; background: #FFFFFF; border: 1px solid #E6E8EC; border-radius: 12px">
<h3 style="margin: 0; font-size: 14px; font-weight: 700">정한 것</h3>
<ul style="margin: 0; padding-left: 18px; display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 6px 40px; color: #344054">
<li>새 칸을 켠 차단은 알림이 하나라 [되돌리기]가 없습니다. 차단은 [설정] → [차단한 주소]에서 풉니다. 새 칸을 켜지 않았으면 지금 알림이 [되돌리기]와 함께 그대로 뜹니다.</li>
<li>결과와 멈춤 글은 모두 차단했다는 말로 시작합니다. 차단은 이미 끝났고, 받은편지함의 메일 옮기기만 멈춘 것입니다.</li>
<li>숫자는 확인한 메일 / 받은편지함의 메일 수이고, 최근 메일부터 확인합니다. 첫 응답 전에는 받은편지함의 메일 수를 씁니다.</li>
<li>[스팸메일함 보기]는 스팸메일함 목록으로 갑니다. 옮긴 메일이 없으면 단추가 없습니다. 잘못 옮긴 메일은 스팸메일함에서 [스팸 아님]으로 돌려놓습니다.</li>
<li>[다시 시도]는 멈춘 자리부터, 처음부터 실패했으면 처음부터 합니다. 차단한 뒤에는 ⋯ 메뉴가 차단 풀기로 바뀌어 다시 고를 길이 없으므로, 다른 창에서 하는 중일 때도 단추를 둡니다.</li>
<li>다른 창에서 하는 중 글은 필터 적용과 함께 씁니다. 지금 필터 글(다른 창에서 필터로 받은편지함을 정리하는 중입니다.)도 구현 때 이 글로 바꿉니다.</li>
<li>그동안 다른 화면으로 가도 이어 하고, 탭을 닫거나 로그아웃하면 멈추고 알림을 닫습니다. 필터 적용과 이 일은 한 직원에 한 번에 하나만 돕니다.</li>
</ul>
</div>
</section>
</div>
</x-dc>
<script type="text/x-dc" data-dc-script data-props='{"$preview":{"width":1440,"height":4937}}'>
class Component extends DCLogic {
state = { dest: null };
// 메일함 패널의 한 줄 — MailboxPanelRow 와 같은 값(줄 40 · 들여쓰기 10 · 28 · 44 · 56 · ⋯ 32×40)
panelRow(m, extra) {
const x = extra || {};
const INDENT = [10, 28, 44, 56];
const active = Boolean(x.active);
const open = Array.isArray(x.items);
return {
name: m.name,
count: m.count ? String(m.count) : '',
hasCount: Boolean(m.count),
indent: INDENT[Math.min(m.depth || 0, 3)],
isInbox: m.kind === 'inbox',
isSent: m.kind === 'sent',
isDrafts: m.kind === 'drafts',
isJunk: m.kind === 'junk',
isTrash: m.kind === 'trash',
bg: active ? '#E3F2F3' : open ? '#F2F4F7' : 'transparent',
fg: active ? '#0A5F67' : '#344054',
fw: active ? '600' : '500',
countColor: active ? '#0A5F67' : '#171C26',
hasMore: m.more !== false,
open: open,
openStr: open ? 'true' : 'false',
moreOpacity: open ? '1' : '0',
moreBg: open ? '#E6E8EC' : 'transparent',
moreLabel: m.name + ' 메일함 메뉴',
menuLabel: m.name + ' 메일함',
items: x.items || [],
};
}
// 옮기기 창의 줄 — 패널과 같은 차례 · 들여쓰기, 고를 수 없는 줄은 이름 · 아이콘만 흐리게(까닭 글은 그대로)
destRows(chosen, pickable) {
const INDENT = [10, 28, 44, 56];
const LIST = [
{ id: 'top', kind: 'top', name: '맨 위', depth: 0, note: '다른 메일함 안에 두지 않습니다', can: true },
{ id: 'inbox', kind: 'inbox', name: '받은편지함', depth: 0, can: true },
{ id: 'mine', kind: 'heading', name: '내 메일함' },
{ id: 'c', kind: 'folder', name: '거래처', depth: 0, note: '지금 있는 곳', can: false },
{ id: 'c25', kind: 'folder', name: '2025', depth: 1, can: false },
{ id: 'c25s', kind: 'folder', name: '정산', depth: 2, can: false },
{ id: 'b', kind: 'folder', name: '보관', depth: 0, note: '같은 이름의 메일함이 있습니다', can: false },
{ id: 'b25', kind: 'folder', name: '2025', depth: 1, can: true },
{ id: 'p', kind: 'folder', name: '프로젝트', depth: 0, can: true },
{ id: 'pq', kind: 'folder', name: '견적', depth: 1, can: true },
];
return LIST.map((x) => {
const heading = x.kind === 'heading';
const on = !heading && x.can && x.id === chosen;
const off = !heading && !x.can;
return {
isHeading: heading,
isRow: !heading,
isTop: x.kind === 'top',
isInbox: x.kind === 'inbox',
isFolder: x.kind === 'folder',
name: x.name,
note: x.note || '',
hasNote: Boolean(x.note),
indent: INDENT[Math.min(x.depth || 0, 3)],
checked: on ? 'true' : 'false',
isChecked: on,
off: off ? 'true' : 'false',
cls: on ? 'dest-row is-on' : off ? 'dest-row is-off' : 'dest-row',
fg: on ? '#0A5F67' : '#344054',
fw: on ? '600' : '500',
dim: off ? '0.5' : '1',
cursor: off ? 'default' : 'pointer',
pick: () => {
if (pickable && !off && !heading) this.setState({ dest: x.id });
},
};
});
}
renderVals() {
const st = this.state;
const dest = st.dest ?? 'p';
const item = (label, cls) => ({ label: label, cls: cls || 'menu-item' });
const ROLES = [
{ kind: 'inbox', name: '받은편지함', count: 3 },
{ kind: 'sent', name: '보낸편지함', more: false },
{ kind: 'drafts', name: '임시보관함', more: false },
{ kind: 'junk', name: '스팸메일함', count: 1 },
{ kind: 'trash', name: '휴지통' },
];
// 옮기기 전 — 거래처 안에 2025(그 안에 정산), 보관 안에도 2025 가 있다
const BEFORE = [
{ id: 'c', name: '거래처', depth: 0, count: 1 },
{ id: 'c25', name: '2025', depth: 1, count: 4 },
{ id: 'c25s', name: '정산', depth: 2 },
{ id: 'b', name: '보관', depth: 0 },
{ id: 'b25', name: '2025', depth: 1 },
{ id: 'p', name: '프로젝트', depth: 0, count: 2 },
{ id: 'pq', name: '견적', depth: 1 },
];
// 옮긴 뒤 — 2025 가 정산과 함께 프로젝트 안으로 (같은 상위 안은 이름 차례 — 숫자가 먼저)
const AFTER = [
{ id: 'c', name: '거래처', depth: 0, count: 1 },
{ id: 'b', name: '보관', depth: 0 },
{ id: 'b25', name: '2025', depth: 1 },
{ id: 'p', name: '프로젝트', depth: 0, count: 2 },
{ id: 'c25', name: '2025', depth: 1, count: 4 },
{ id: 'c25s', name: '정산', depth: 2 },
{ id: 'pq', name: '견적', depth: 1 },
];
const mineMenu = [
item('모두 읽음으로 표시'),
item('하위 메일함 만들기'),
item('이름 바꾸기'),
item('옮기기', 'menu-item is-hover'),
item('지우기', 'menu-item is-danger'),
];
const inboxMenu = [item('모두 읽음으로 표시'), item('하위 메일함 만들기')];
const f2Rows = this.destRows(dest, true);
const canMove = f2Rows.some((r) => r.isChecked);
return {
f1Roles: ROLES.map((m) => this.panelRow(m, { active: m.kind === 'inbox' })),
f1Mine: BEFORE.map((m) => this.panelRow(m, m.id === 'c25' ? { items: mineMenu } : {})),
f1RolesB: ROLES.map((m) => this.panelRow(m, m.kind === 'inbox' ? { active: true, items: inboxMenu } : {})),
f4Mine: AFTER.map((m) => this.panelRow(m)),
f2Rows: f2Rows,
f2MoveOpacity: canMove ? '1' : '0.5',
f3Rows: this.destRows('p', false),
};
}
}
</script>
</body>
</html>
+174 -2
View File
@@ -1088,8 +1088,8 @@ API 와 서버 규칙은 계약 2절 "실시간 알림".
### 처음 판에서 미룬 것
본문 강조의 다음 · 이전 이동, 검색어 줄임말(from: 같은 꼴), 대화째 끌어다 놓기, 첨부 파일 안의 글 찾기, 끝이 바뀐 말 찾기(형태소 분석).
같은 대화 칸 머리의 [모두 펼치기]와 펼친 줄 머리의 [답장]도 다음 후보로 둔다(2026-11-07 사용자 관점 점검에서 나온 제안 — 사용자가 미루기로 정함). 지금은 줄마다 펼치고, [답장]은 펼친 본문 아래에만 있다.
본문 강조의 다음 · 이전 이동, 검색어 줄임말(from: 같은 꼴), 첨부 파일 안의 글 찾기, 끝이 바뀐 말 찾기(형태소 분석).
대화째 끌어다 놓기와 같은 대화 칸 머리의 [모두 펼치기] · 펼친 줄 머리의 [답장](2026-11-07 사용자 관점 점검에서 나와 미뤘던 것)은 15단계에서 했다(아래 "15단계").
## 휴대폰 앱 받기 (2026-11-08 확정 — `SettingsMobileApp`)
@@ -1164,6 +1164,114 @@ PC 에서는 설정 › 계정 › 휴대폰 앱(A)이 QR 코드로 휴대폰에
- 서명 키가 다른 판(시험판)이 깔린 휴대폰은 새 판을 덮어 설치할 수 없다 — 그때는 앱을 지운 뒤 설치하고 로그인을 다시 한다.
- 미룬 것: 앱 안에서 받아 바로 설치, 최소 판이 아닌 새 판 알림, 아이폰(Ad Hoc · TestFlight), Google 개발자 인증(2027 전 세계 — 그 전에 등록할지 정한다).
## 15단계 — 메일 · 메일함 다루기 (2026-11-09 확정 — `Reader15` · `ListDrag15` · `Mailbox15`)
메일과 메일함을 옮기고 정리하는 일을 더한다 — 읽기 화면 [이동 ▾] · 끌어다 놓기 · 메일함 옮기기 · 하위 메일함까지 지우기 · 차단할 때 받은 메일 옮기기 · 예약 메일 [지금 보내기].
서버 약속은 계약 2절("메일함 관리" · "보낸 사람 차단" · "보내기 취소 · 예약 발송")이다. 글의 원본은 화면 상수(`shared/constants/mailbox-manage.ts` · `mail-block.ts` · `mail-send-later.ts` · `mail-filter.ts`)이고 아래는 옮겨 적은 것이다.
새 크기 토큰 · 밀도 예외는 없다 — 옮기기 창의 줄 40 은 메일함 패널의 줄(`--size-row-min`), [지금 보내기] 36 은 안내 줄 단추(`--size-notice-action`)를 다시 쓴다. 문구는 위 「화면 문구 — 사용자에게 말하듯」을 따른다.
### 읽기 화면 · 목록 · 상단 바 (`Reader15` 1~3 · 6 · `ListDrag15`)
- **읽기 화면 [이동 ▾]**: 도구 줄 답장 · 전체 답장 · 전달 | **이동 ▾** · 스팸 신고 · 삭제 · ⋯ — 메일이 어디로 가는지를 한 묶음에 둔다(목록 보기는 지금처럼 구분선 없이 [전달] 뒤).
메뉴는 목록 도구 줄의 [이동 ▾]와 같은 부품(항목 40) · 같은 옮길 곳이다. 그 메일이 든 메일함(검색 결과에서 연 메일도 그 메일이 든 메일함) · 임시보관함 · 휴지통은 없다 — 휴지통으로는 [삭제]로 옮긴다.
보내기를 기다리는 메일(보내는 중 · 예약)에는 [스팸 신고]처럼 없다. 키보드는 목록의 [이동 ▾]와 같다(Enter · Space · 아래 화살표로 열면 첫 항목에 초점, Esc 는 메뉴만 닫고 [이동 ▾]으로).
- **옮긴 뒤**: [삭제]와 같은 길 — 분할 보기는 목록의 다음 메일을 열고 목록 보기는 목록으로 돌아간다. 알림 "메일 1통을 거래처 › 2025로 옮겼습니다." + [되돌리기](10초 — 되돌리면 "메일 1통을 받은편지함으로 되돌렸습니다.").
- **옮길 메일함은 메일함 패널처럼**: 같은 차례로 깊이만큼 들여 쓰고(10 · 28 · 44 · 56, 3단계까지) 앞에 패널과 같은 아이콘, 기본 메일함과 내 메일함 사이에 가는 선.
들여 쓴 메일함은 화면 낭독이 위 메일함부터 읽는다("거래처 안의 2025"). 목록의 [이동 ▾]도 같은 모양이다.
- **그 메일이 든 메일함에 하위 메일함이 있으면** 빼지 않고 고를 수 없는 흐린 줄(aria-disabled)로 남기고 이름 아래에 "이 메일이 있는 메일함" — 하위 메일함이 누구 밑에 있는지 보이게.
하위 메일함이 없으면 지금처럼 뺀다. 목록의 [이동 ▾]는 같은 규칙에 글만 "고른 메일이 있는 메일함"이다.
- **알림의 메일함 이름**: 같은 이름의 메일함이 다른 곳에도 있을 때만 위 메일함 이름을 붙인다("거래처 › 2025로" — 정하는 규칙은 아래 "15단계 구현하며 정한 보완"의 메일함 나무 도우미).
목록의 [이동 ▾] · 끌어다 놓기 · 되돌리기 알림도 같다.
- **메뉴 자리**: 화면(보이는 영역) 밖으로 넘치면 반대 끝에 맞춘다 — 단추 왼쪽 끝에 맞춘 메뉴가 오른쪽 밖으로 넘치면 메뉴 오른쪽 끝을 단추 오른쪽 끝에. 위아래만 바꾸던 메뉴 자리 정하기에 좌우를 더해 같은 부품의 모든 메뉴에 걸린다.
- **좁은 읽기 칸**: 도구 줄의 이름은 화면 폭이 아니라 읽기 칸 폭(컨테이너 쿼리)으로 숨긴다 — 이름을 한 줄에 다 보일 폭 570(도구 줄 좌우 여백 포함 · 휴지통은 [영구 삭제] 때문에 595)보다 좁으면 아이콘만 40×44([이동 ▾]는 ▾ 까지).
이름은 마우스를 올리면 뜨는 글(title)과 화면 낭독(aria-label)으로 읽힌다. 분할 보기 1144~1233px 에서 두 줄로 꺾이던 일이 없고, 360 에서도 두 줄로 끝난다.
- **끌어다 놓기**(목록 → 메일함 패널): 고른 줄을 끌면 고른 것 모두, 고르지 않은 줄을 끌면 그 줄만(고른 것은 그대로). 대화로 묶은 목록의 줄은 그 대화에서 지금 메일함에 든 메일(보내기를 기다리는 메일은 빼고 — [이동 ▾]와 같다),
보내기를 기다리는 메일의 줄 · 그런 메일뿐인 대화 줄은 끌 수 없다.
- 끄는 동안 마우스 오른쪽 아래에 "메일 N통" · "대화 N개" 표(알림과 같은 어두운 바탕 · 12.5 굵은 흰 글자 · 봉투 · 말풍선 아이콘). 끈 줄은 흐리게 하지 않는다.
- 놓을 수 있는 메일함 위에서는 그 줄에 2px 청록 점선 · 옅은 청록 바탕(쓰기 화면 파일 놓기와 같은 모양 — 초점 선과 헷갈리지 않게). 놓을 수 없는 곳(지금 메일함 · 임시보관함 · 「예약됨」)은 줄이 그대로이고 마우스 커서만 금지 모양이다.
- 놓을 곳은 [이동 ▾]의 옮길 곳 + 휴지통. 휴지통에 놓으면 [삭제], 스팸메일함에 놓으면 [스팸 신고]와 같고 그 밖은 [이동 ▾]와 같다(알림 · [되돌리기]도 같다).
- 마우스로만 한다 — 키보드 · 터치는 [이동 ▾]. 메일함 패널은 쓰기 화면에도 보이므로 파일 끌기는 받지 않는다(첨부 놓기와 겹치지 않게). 줄의 링크가 끌어 가던 메일 주소(URL)는 싣지 않는다 — 다른 창 · 바깥 프로그램에 놓아도 아무 일 없다.
- **스팸메일함으로 옮기는 세 길 같게**: [스팸 신고] · [이동 ▾]에서 스팸메일함 · 스팸메일함에 끌어다 놓기는 모두 스팸 표시를 붙여 옮긴다. 스팸메일함에서 휴지통이 아닌 메일함으로 옮기면(메뉴 · 끌기)
[스팸 아님]과 같이 스팸 아님 표시를 붙인다 — "메일 1통을 스팸이 아닌 메일로 표시해 거래처로 옮겼습니다." 휴지통으로는 표시를 바꾸지 않는다. [되돌리기]는 표시도 되돌린다.
- **메일 화면에 파일을 놓으면**: 지금은 브라우저가 그 파일을 열어 웹메일을 떠났다 — 메일 화면에서는 무시한다(놓는 동안 금지 커서). 쓰기 화면은 지금처럼 어디에 놓아도 첨부로 받는다.
- **상단 바 좁은 폭**(360 — 바 안쪽 256): [로그아웃]은 줄지도 꺾이지도 않는다. 이름 · 주소는 넘치면 끝을 "…"로 줄이고 마우스를 올리면 다 보인다(title "김아라 (ara.kim@arasystem.it.kr)").
화면 폭 480 이하에서는 주소를 감추고 이름만 둔다. 줄 수와 차례(메일 · 검색 칸 · 계정)는 지금과 같다 — 계정 줄을 첫 줄로 올리지 않는다(Tab 차례).
- **같은 대화 [모두 펼치기]**: 칸 머리 오른쪽의 글자 단추(36 — 펼친 줄의 [답장]과 같은 크기, 안내 줄 단추 `--size-notice-action` 재사용). 불러온 줄(지금 보는 메일 빼고)을 모두 펼치고 [모두 접기]로 바뀐다 —
줄을 하나씩 다 펼쳐도 [모두 접기]이고, 하나라도 접혀 있으면 [모두 펼치기]. [더 보기]로 더 받은 줄은 접힌 채로 온다.
본문은 한꺼번에 받지 않는다 — 화면에 들어오는 줄부터 하나씩(요청 수 한도와 화면 무게), 아직 화면 밖인 줄은 받는 중과 같은 자리만 둔다.
읽음은 지금 규칙 그대로 — 본문이 화면에 보인 메일만 읽음이 되고 펼쳤어도 화면 밖이면 그대로다.
- **펼친 줄 머리 [답장]**: 펼친 줄 머리 오른쪽(36) — 긴 메일을 끝까지 내리지 않고 답장한다. 줄 머리 전체가 펼침 단추라 머리를 감싸는 틀을 두고 펼침 단추와 [답장]을 나란히 놓는다(마우스를 올린 바탕은 틀 전체).
아래쪽 [답장] · [이 메일만 열기]는 그대로이고, 임시 저장본 · 예약한 메일의 줄에는 없다(지금과 같다).
### 메일함 ⋯ 「옮기기」 · 옮기기 창 (`Mailbox15` 1~4)
- **⋯ 메뉴**: 내 메일함(역할 없는 메일함 — 받은편지함 아래에 만든 메일함 포함)에만 「옮기기」. 차례는 모두 읽음으로 표시 · 하위 메일함 만들기 · 이름 바꾸기 · **옮기기** · 지우기.
창을 여는 다른 항목처럼 이름 뒤에 …를 붙이지 않는다. 메일함을 지우는 동안에는 지금처럼 모든 항목을 누를 수 없다.
- **옮기기 창**(모달 400): 제목 "‘2025’ 메일함을 어디로 옮길까요?" · 글 "하위 메일함과 안의 메일도 함께 옮깁니다."(하위가 없으면 "안의 메일도 함께 옮깁니다.") → 옮길 곳 목록 → [옮기기] [취소].
- 목록(테두리 칸 · 줄 40 · 메일함 패널과 같은 차례 · 들여쓰기 10 · 28 · 44 · 56 · 패널 아이콘): 「맨 위」(위 화살표 — "다른 메일함 안에 두지 않습니다") · 받은편지함(과 그 아래) · "내 메일함" 묶음.
보낸편지함 · 임시보관함 · 스팸메일함 · 휴지통은 없다(하위 메일함을 만들 수 있는 곳과 같다). 메일함이 많으면 목록 칸 안에서 스크롤한다.
- 처음에는 아무 곳도 고르지 않은 채 열리고 [옮기기]는 꺼져 있다. 고른 줄은 패널의 지금 메일함처럼 청록 바탕 · 굵은 글자 · 오른쪽 체크.
- 고를 수 없는 줄은 이름 · 아이콘만 흐리게(까닭 글은 그대로): 옮기는 메일함과 그 하위(까닭 없음) · 지금 있는 곳("지금 있는 곳" — 맨 위의 메일함이면 맨 위 줄) ·
바로 아래에 같은 이름의 메일함이 있는 곳("같은 이름의 메일함이 있습니다" — 대소문자를 가리지 않는다, 서버에 묻기 전에 화면이 하는 검사).
- 줄은 radiogroup 의 radio 다. 화살표로 줄을 옮기면 고를 수 있는 줄은 바로 고르고, Enter 는 [옮기기], Esc 는 [취소]와 같다. 들여 쓴 줄은 위 메일함부터 읽는다("거래처 안의 2025").
- **그사이 같은 이름이 생겼을 때**(드묾 — 409 `MAIL_009`): 목록 아래 빨간 사전 문구 "같은 이름의 메일함이 이미 있습니다." — 알림 없이 창을 둔다. 다른 곳을 고르면 사라진다.
그사이 메일함이 사라졌으면(`RES_001`) 알림을 띄우고 창을 닫은 뒤 메일함 목록을 다시 받는다(이름 바꾸기 창과 같다). 옮기는 동안에는 [옮기기]를 다시 누를 수 없다.
- **옮긴 뒤**: 메일함 패널에 바로 나타나고(하위 메일함과 안의 메일이 함께 간다 · 그 메일함으로 옮기는 필터도 그대로 돈다), 보던 메일함이면 그 메일함을 계속 본다.
알림 "‘2025’ 메일함을 ‘프로젝트’ 안으로 옮겼습니다."(받은편지함은 따옴표 없이 "…받은편지함 안으로 …") · 맨 위로는 "‘2025’ 메일함을 다른 메일함 밖으로 옮겼습니다." + [되돌리기](원래 있던 곳으로 —
"‘2025’ 메일함을 ‘거래처’ 안으로 되돌렸습니다.").
### 하위 메일함까지 지우기 (`Mailbox15` 5)
- 하위 메일함이 있는 메일함의 「지우기」는 지금까지의 "지울 수 없습니다" 창 대신 확인 창(처음 초점 [취소]): "‘거래처’ 메일함과 하위 메일함 2개를 지울까요?" ·
"하위 메일함에 든 메일까지 모두 40통을 휴지통으로 옮깁니다. 휴지통에 들어간 메일은 30일 뒤 자동으로 지워집니다. 지운 메일함은 되돌릴 수 없습니다." [지우기] [취소].
메일이 하나도 없으면 둘째 글은 "하위 메일함까지 모두 비어 있습니다. 지운 메일함은 되돌릴 수 없습니다." 하위 메일함 수 · 메일 수는 모든 깊이를 센다.
- 확인하면 같은 지우기를 하위까지(`?withChildren=true`) 보낸다 — 진행 · 결과는 지금 메일함 지우기 그대로("‘거래처’ 메일함을 지우는 중 · 12 / 40통" → "‘거래처’ 메일함을 지웠습니다. 안의 메일 40통은 휴지통에 있습니다.").
휴지통을 비우는 동안에는 [지우기]를 누를 수 없다(지금과 같다).
### 보낸 사람 차단 창 — 받은편지함의 메일도 옮기기 (`Mailbox15` 6~7)
- 「이 메일도 스팸메일함으로 옮기기」 아래에 「받은편지함에 있는 이 보낸 사람의 메일도 스팸메일함으로 옮기기」 + "옮긴 메일은 한꺼번에 되돌릴 수 없습니다." — 창을 열 때마다 **꺼진 채** 시작한다.
「이 도메인 전체」를 고르면 "…이 도메인의 메일도 …"(그 도메인만 — 하위 도메인은 보지 않는다). 두 칸은 붙여 두고, 칸 글이 두 줄이면 고르기 상자를 첫 줄에 맞춘다. 칸 아래 글은 글 선(28)에 맞춘다.
- 새 칸을 켰는데 이 메일이 받은편지함에 있으면 첫 칸은 켜진 채 잠기고(aria-disabled) 아래에 "받은편지함의 메일을 옮길 때 함께 옮깁니다." — 새 칸을 끄면 첫 칸은 원래 값으로 돌아온다.
이 메일이 다른 메일함에 있으면 잠그지 않는다. 스팸메일함의 메일이면 첫 칸은 지금처럼 없고 새 칸은 남는다.
- 이 탭에서 받은편지함을 정리하는 동안(필터 적용 · 다른 차단 정리)은 새 칸을 끄고(aria-disabled) 칸 아래 글을 "받은편지함을 정리하는 중이라 지금은 고를 수 없습니다."로 바꾼다.
이 탭에서 스팸메일함을 비우는 동안도 끄고 "스팸메일함을 비우는 중이라 지금은 고를 수 없습니다."로 바꾼다(보드에 없다 — 아래 "15단계 구현하며 정한 보완").
- **차단한 뒤**: 새 칸을 켜지 않았으면 지금 알림 그대로(+ [되돌리기]). 켰으면 차단 알림을 따로 띄우지 않고 알림 하나가 진행을 보이다 같은 자리에서 결과로 바뀐다 — [되돌리기]는 없다(차단은 설정에서 푼다):
"‘news@example.com’을(를) 차단했습니다. 받은편지함의 메일을 스팸메일함으로 옮기는 중 · 1,200 / 4,800통 확인"(확인한 메일 / 받은편지함의 메일 수 — 첫 응답 전에는 받은편지함의 메일 수) →
"‘news@example.com’을(를) 차단하고 받은편지함의 메일 37통을 스팸메일함으로 옮겼습니다." + [스팸메일함 보기] · 옮길 메일이 없으면 "…차단했습니다. 받은편지함에는 옮길 메일이 없습니다."(단추 없음).
- 멈춤: "…받은편지함은 4,800통 가운데 1,200통까지 확인해 12통을 옮긴 뒤 멈췄습니다. 다시 시도해 주세요." + [다시 시도](멈춘 자리부터 · 수는 이어 센다)
- 처음부터 실패: "…차단했지만 받은편지함의 메일은 옮기지 못했습니다. 다시 시도해 주세요." + [다시 시도](처음부터)
- 다른 창에서 하는 중: "…차단했습니다. 다른 창에서 받은편지함이나 스팸메일함을 정리하는 중입니다. 끝난 뒤 다시 시도해 주세요." + [다시 시도] — 차단한 뒤에는 ⋯ 메뉴가 「차단 풀기」라 다시 고를 길이 이것뿐이다.
서버의 차단 정리는 받은편지함 정리(필터 적용)뿐 아니라 스팸메일함 비우기와도 잠금을 나누어 두 곳을 함께 말한다(2026-11-09 재리뷰).
필터 적용의 429 글은 "다른 창에서 받은편지함을 정리하는 중입니다. 끝난 뒤 다시 시도해 주세요." 그대로다(7단계의 "다른 창에서 필터로 …"를 이 글로 바꿨다).
- 그동안 다른 화면으로 가도 이어 하고, 탭을 닫거나 로그아웃하면 멈추고 알림을 닫는다. 필터 적용과 이 일은 한 직원에 한 번에 하나다. 잘못 옮긴 메일은 스팸메일함에서 [스팸 아님]으로 돌려놓는다.
### 예약한 메일 [지금 보내기] (`Reader15` 4~5)
- 예약한 메일의 청록 안내 줄 단추가 [지금 보내기] [고치기] [예약 취소] 차례다(같은 흰 단추 36 — 누르는 동안 세 단추를 함께 막는다). 예약됨 목록 줄 · 휴대폰 앱에는 두지 않는다.
- 누르면 [보내기]를 누른 것과 같다 — 보내기 취소 설정의 초만큼 기다렸다 보내고, 그동안 "메일을 보냈습니다." + [보내기 취소] + 남은 시간 막대(9단계 마감 알림 그대로). 확인 창은 두지 않는다.
끔으로 두었거나 예약 시각이 막 지나 이미 보낸 메일이면 오류가 아니라 "메일을 보냈습니다." + [보낸편지함 보기]. 받는 사람 일부가 받지 못하면 쓰기 화면과 같은 문장이다.
- 알림의 [보내기 취소]는 지금의 [보내기 취소]와 같다(그 메일을 쓰기 화면으로 — 다시 예약하려면 [보내기 ▾] → [예약 발송…]).
- 보낸 메일은 예약됨에서 빠지므로 분할 보기는 다음 예약 메일을 열고 목록 보기는 예약됨 목록으로 간다([예약 취소]와 같다). 예약됨 수를 다시 맞춘다. 보낸편지함에서 연 메일이면 그 자리에 두고 안내 줄만 사라진다(날짜 칸은 보내는 동안 "보내는 중").
- 예약 시각보다 먼저 보내므로 대용량 첨부 링크의 기간은 실제로 보낸 날부터 센다(서버).
- **보내지 못했거나 결과를 모를 때**(서버가 503 `MAIL_001` 로 답함 — 결과를 모르는 실패와 엔진이 거절해 임시보관함으로 되돌린 실패가 같은 꼴이다.
앞단의 502 · 504 · 시간 초과 · 연결 끊김(`SYS_001`)도 결과를 모르는 실패라 같은 갈래다): 사전 문구 알림은 끄고, 화면이 그 메일을 다시 받아 붙잡은 발송과 메일함으로 가른다(2026-11-09 15단계 리뷰 · 재리뷰).
- 임시보관함: "지금 보내지 못해 임시보관함으로 옮겼습니다." + [임시보관함 보기] — 예약됨을 떠났으니 안내 줄을 내리고 이동은 [예약 취소]와 같다.
- 예약이 그대로(예약을 거두기 전에 멈췄다 — 예약한 시각에 나간다): "지금 보내지 못했습니다. 다시 시도해 주세요." + [다시 시도](같은 요청 — 같은 메일은 요청 하나로 묶는다). 안내 줄은 그대로 둔다.
- 예약만 풀렸다(보낸편지함에 있고 기다리는 발송이 없다 — 예약한 시각에 따로 나가지는 않는다): "메일을 보냈는지 확인하지 못했습니다. [다시 시도]를 누르면 아직 보내지 않았을 때만 보냅니다." + [다시 시도].
이미 나갔을 수 있어(보내기 취소 0초로 바로 나감 · 응답만 잃음 · 그사이 예약 시각이 됨) 못 보냈다고 단정하지 않는다 — 그 글을 믿고 손으로 다시 보내면 두 통이 간다(2026-11-09 마지막 확인).
[다시 시도]는 같은 요청이라 안전하다(이미 보낸 메일이면 서버가 200 으로 답한다). 예약이 아닌 것은 확인했으니 "…에 보낼 메일입니다." 안내 줄은 내린다 — 예약됨 · 보낸편지함 · 검색 어디서 열었든.
예약됨에서는 빠지므로 이동은 [예약 취소]와 같고, 보낸편지함 · 검색에서 연 메일은 그 자리에 둔다.
- 다시 낸 발송이 보내기 취소를 기다린다: 보낸 것과 같다("메일을 보냈습니다." + [보내기 취소]).
- 그 메일을 다시 받지 못했다: 위와 같은 "메일을 보냈는지 확인하지 못했습니다. …" + [다시 시도] — 보냈다 · 못 보냈다 · 되돌렸다고 말하지 않는다(닫힌 쪽).
안내 줄은 마지막으로 안 상태 그대로 두고, 다음 다시 맞추기에서 바로잡힌다.
- 예약됨 수와 목록은 어느 쪽이든 다시 맞춘다. 받는 사람이 모두 받지 않으면(400 `MAIL_010` · 503 `MAIL_011` — 그 메일은 임시보관함에) 사전 문구 그대로다.
- **알려진 한계**: 드물게 [다시 시도]를 누르지 않고 알림을 닫으면 그 메일은 보내지 않은 채 보낸편지함에 남는다 — 9단계 예약 취소와 같은 한계다.
### 처음 판에서 미룬 것
메일함을 끌어 옮기기 · 순서 바꾸기(옮기기 창으로), 차단할 때 옮긴 메일의 한꺼번에 되돌리기, 예약됨 목록 줄의 단추, 옮기기 단축키, 휴대폰 앱의 [지금 보내기].
## 구현하며 정한 보완 (2026-10-26 — 캔버스에는 아직 없다)
- 찾지 못한 첨부(보관 시간이 지난 업로드·지워진 원본)는 실패로 표시하고, 전달 원본에는 "빼기"만 둔다(다시 올릴 수 없다).
@@ -1574,3 +1682,67 @@ PC 에서는 설정 › 계정 › 휴대폰 앱(A)이 QR 코드로 휴대폰에
- **앱 그림**의 모서리는 한 변의 1/4 이다(48 은 12, 52 는 13 — 보드 값).
- **폭 기준**: 설정의 QR 줄을 빼는 좁은 폭은 480 이하(공개 내려받기 화면의 좁은 폭과 같은 값), `/app` 의 넓은 화면은 그 반대(481 이상)다.
- 글자 크기 17(`/app` 의 앱 이름 · 받을 앱이 없을 때의 제목)은 토큰 `--font-size-title-md` 를 더했다(크기 · 밀도 예외 토큰은 아니다).
### 15단계 구현하며 정한 보완 (2026-11-09 — 캔버스에는 아직 없다)
메일함 · 차단 · 예약 쪽을 먼저, 읽기 화면 · 목록 · 상단 바 쪽을 뒤에 적는다.
- **메일함 나무 도우미**(`shared/utils/mailbox.ts`): 패널 차례로 편 나무 `mailboxTree`(깊이 · 위 메일함 · 하위가 있는지 · 묶음), 화면 낭독 이름 `mailboxSpokenName`("거래처 안의 2025" —
더 깊으면 "거래처 안의 2025 안의 정산"), 알림 이름 `mailboxDisplayName`(같은 이름이 다른 곳에도 있을 때만 "거래처 › 2025" — 위 메일함까지 같으면 가를 수 있을 때까지 올라간다, 맨 위의 메일함은 이름 그대로).
[이동 ▾] · 끌어다 놓기 · 옮기기 창이 함께 쓴다. 알림의 이름은 글자 모양으로 견주고(NFC — 대소문자는 가린다), 같은 자리 겹침은 대소문자를 가리지 않는다(엔진과 같다).
- **옮기기 창**
- 옮길 곳은 ⋯ 「하위 메일함 만들기」가 있는 메일함(받은편지함 · 내 메일함)과 맨 위다. 하위를 둘 수 없는 메일함(보낸편지함 등 · 보관함) 아래의 메일함은 그 위까지 모두 둘 수 있는 곳일 때만 넣는다 —
다른 메일 프로그램이 보낸편지함 아래에 만든 메일함은 옮길 곳에 없다.
- 화살표는 꺼진 줄에도 초점을 옮긴다(까닭을 읽게 — 고른 것은 그대로). 처음과 끝에서 돌고, Home · End 도 된다. 처음 초점은 고를 수 있는 첫 줄이다(로빙 tabindex).
- 맨 위에서 옮긴 것을 되돌린 글은 "‘2025’ 메일함을 다른 메일함 밖으로 되돌렸습니다."(보드에 없다 — 맨 위로 옮긴 글과 같은 결). [되돌리기]가 실패하면(그사이 원래 자리에 같은 이름 · 원래 상위가 지워짐) 사전 문구 알림만.
- 옮길 수 없는 자리였으면(400 `VAL_001` — 그사이 나무가 바뀜) 알림을 본 뒤 메일함 목록을 다시 받고 창은 둔다(다시 고를 수 있게). `REQ_001`(그 메일함을 정리하는 중) 등도 알림만 · 창은 그대로.
- 옮기는 동안 [옮기기]는 `aria-disabled` + `aria-busy`(누른 단추가 초점을 잃지 않게), 고르기 전에는 `disabled`.
- **하위까지 지우기**: 지우는 동안 나무의 모든 메일함(하위 포함)을 "지우는 중"으로 둔다 — 그 메뉴들이 꺼지고, 나무 안의 메일함을 따로 지우기 시작하지 않는다.
하위 메일함을 보고 있었어도 끝나면 받은편지함으로 간다. 하위가 없는 메일함은 지금처럼 `withChildren` 없이 보낸다.
- **차단할 때 받은 메일 옮기기**
- 「이 메일도 …」가 켜져 있으면 그 메일을 먼저 옮긴 뒤(지금 길 그대로 — 읽기 화면은 삭제처럼 바로 다음 메일로) 받은편지함 정리를 시작한다. 그 메일이 받은편지함에 있었으면 옮긴 수에 넣는다.
- 이 탭에서 받은편지함을 정리하는 중에 [다시 시도]를 누르면 "‘…’을(를) 차단했습니다. 받은편지함을 정리하는 중입니다. 끝난 뒤 다시 시도해 주세요." + 같은 [다시 시도](보드에 없다 — 필터 적용의 busy 와 같은 결).
- 그사이 차단을 풀었으면(404 `RES_001`) 진행 알림을 닫고 사전 문구만 보인다.
- 끝났거나 나아간 뒤 멈췄으면 개수를 다시 받고, 받은편지함 · 스팸메일함 목록을 보고 있으면 그 목록도 맞춘다(필터 적용과 같다).
- 되풀이(cursor · 25초 · 나아가지 못한 응답 3번)는 필터 적용과 함께 쓴다(`stores/inbox-cleanup-run.ts`).
- 이 탭에서 받은편지함 정리(필터 적용 · 차단 정리)가 하나라도 도는 동안은 필터 고치기 창의 「받은편지함에 이미 있는 메일도 정리하기」도 끈다 — 차단 창의 새 칸과 같은 규칙(`inboxBusy`)이다.
두 칸의 까닭 글은 같은 공용 글 "받은편지함을 정리하는 중이라 지금은 고를 수 없습니다."(Mailbox15 보드의 차단 칸 글)이고, 7단계 칸 글 "다른 필터로 받은편지함을 정리하는 중입니다. 끝난 뒤에 고를 수 있습니다."도
이 글로 바꿨다 — 칸을 끈 까닭이 필터가 아니라 차단 정리일 수도 있어서다(메인이 정함).
같은 까닭으로 필터 적용 알림의 [다시 시도]도 이 탭의 차단 정리를 보고(`inboxBusy`), 그때 글은 공용 글 "받은편지함을 정리하는 중입니다. 끝난 뒤 다시 시도해 주세요."다(7단계 "다른 필터로 …"에서 바꿨다).
- 이 탭에서 차단 정리가 도는 동안은 스팸메일함 비우기를 시작할 수 없다(메뉴 · 목록 머리 [비우기] · 확인 단추가 같은 판정 — 휴지통 비우기와 메일함 지우기를 함께 돌리지 않는 규칙과 같은 결).
옮겨 온 메일이 들어오자마자 영구 삭제되지 않게다. 반대로 이 탭에서 스팸메일함을 비우는 동안은 차단 창의 새 칸을 끄고(까닭 "스팸메일함을 비우는 중이라 지금은 고를 수 없습니다.") 차단 정리를 시작하지 않는다 —
알림의 [다시 시도]를 누르면 "‘…’을(를) 차단했습니다. 스팸메일함을 비우는 중입니다. 끝난 뒤 다시 시도해 주세요." + 같은 [다시 시도](2026-11-09 재리뷰).
필터 적용은 스팸메일함에 넣지 않으므로 막지 않는다(`inboxBusy` 는 그대로이고 차단 정리만 `mail-block-run.store` 의 `startBlock` 을 본다).
다른 창과는 서버가 요청마다 스팸메일함 처리 잠금을 잡아 겹친 요청을 429 로 막지만, 잠금은 요청 하나씩이라 두 창의 비우기와 차단 정리가 요청 사이에 번갈아 돌 수 있다 —
차단 정리가 옮겨 넣은 메일을 다른 창의 다음 비우기 요청이 지울 수 있다(알려진 한계 — 다른 창끼리의 휴지통 비우기와 메일함 지우기와 같다, 계약 2절 "보낸 사람 차단").
- **지금 보내기**
- 서버가 혹시 409 `MAIL_014`(이미 보냄)로 답해도 보낸 것으로 다룬다("메일을 보냈습니다." — 약속은 200 이다).
- 503 `MAIL_001` · `SYS_001` 의 갈래(임시보관함 · 예약 그대로 · 예약만 풀림 · 다시 낸 발송이 기다림 · 다시 받지 못함)는 위 "예약한 메일 [지금 보내기]"에 적었다 — 사전 문구 알림은 끄고 화면 글 하나로 알린다.
판정은 `shared/utils/pending-send.ts` 의 `unsentReleaseState` 하나다(임시보관함을 먼저 보고, 다음으로 기다리는 발송의 종류). 다시 낸 발송이 보내기 취소를 기다리는 갈래는 리뷰에 없던 것이라 구현하며 정했다 —
같은 요청을 다시 보내도 서버가 그 상태로 200 을 주므로 보낸 것과 같게 알린다(받는 사람별 결과는 모르니 일부가 받지 못한 문장은 없다).
받는 사람이 모두 받지 않았거나(400 `MAIL_010` · 503 `MAIL_011`) 예약이 그사이 사라졌으면(404) 사전 문구 알림만 보이고 그 자리에 두며, 예약 표시 · 예약됨 수 · 보이는 목록을 서버 기준으로 다시 맞춘다.
- 보내기 취소 알림의 마감은 쓰기 화면과 같은 규칙이다(설정한 초를 알면 "지금 + 초 − 1초", 모르면 보낼 시각까지 · 30초를 넘지 않게 — `undoToastDeadline`).
- 안내 줄이 좁으면(글이 12rem 보다 좁아질 때) 단추 셋을 묶음째 다음 줄 오른쪽으로 보낸다 — 단추가 셋이 되며 360 폭에서 글이 한 글자 폭으로 눌리지 않게(보드는 넓은 폭만 그렸다).
**읽기 화면 · 목록 · 상단 바**
- **좁은 읽기 칸의 기준(보드에 없는 것)**: 컨테이너는 도구 줄 띠(`.reader__toolbar-bar` — 칸 전체 폭)다. 목록 보기는 앞에 [목록으로] · 이전/다음이 함께 놓여 이름이 다 보이는 줄이 800(콘텐츠 열)보다 조금 넓다 —
그래서 목록 보기만 단추 줄의 최대 폭을 풀고 접는 폭을 850 · 휴지통 875 로 두었다(570 실측에 [목록으로] · 이전/다음 · 위치 글을 더한 어림 — 브라우저에서 다시 잰다). 화면 폭 기준은 tokens.css 머리에 한 줄씩 적었다.
- **메뉴 폭**: 옮길 메일함 메뉴는 이름 길이만큼(200 부터 320 까지 — 좁은 화면은 화면 폭 − 80 까지) 넓어지고 그보다 긴 이름은 줄을 바꾼다(보드는 줄을 바꾸지 않는 짧은 이름만 그렸다).
- **좌우 바꾸기**: 오른쪽 끝에 맞춘 메뉴(⋯)가 왼쪽 밖으로 넘칠 때도 같은 규칙으로 단추 왼쪽 끝에 맞춘다(360 에서 ⋯ 가 둘째 줄 맨 앞으로 내려간다). 양쪽 다 넘치면 덜 넘치는 쪽이다(위아래와 같은 결).
- **흐린 줄 더**: 하위 메일함이 있는 휴지통 · 임시보관함(다른 메일 프로그램이 지운 폴더를 휴지통 아래에 둔다)도 까닭 글 없이 흐린 줄로 남겨 그 하위 메일함을 고를 수 있게 한다.
흐린 줄 아래에 보일 하위 메일함이 하나도 없으면 그 줄도 뺀다.
- **전체 메일함 검색에서 [이동 ▾] · 끌기**: 고른 메일이 모두 든 메일함만 "메일이 있는 메일함"으로 친다(메일마다 다르면 아무 데나 옮길 수 있다 — 지금과 같다).
- **끌어다 놓기**
- 줄 전체가 끌린다(링크만이 아니라) — 목록 보기 표의 글은 마우스로 끌어 고를 수 없게 된다(두 번 눌러 고르기는 된다).
- 끄는 표는 메일 화면에 늘 그려 두는 화면 밖 요소를 브라우저가 찍은 것이다(setDragImage) — 운영체제 · 브라우저마다 투명도 · 커서 모양이 조금 다르다.
- 이 탭에서 시작한 끌기만 받는다(다른 탭 · 바깥에서 끌어 온 같은 꼴의 자료는 놓을 수 없다).
- 열어 둔 메일을 끌어 옮겼으면 목록 도구 줄로 옮길 때처럼 목록 주소로 바꿔 끼운다.
- 패널의 메일함이 많아 아래가 가려져 있으면 패널 끝에서 저절로 스크롤되는지는 브라우저가 한다 — 브라우저에서 확인한다.
- **메일 화면 파일 놓기 무시**: 쓰기 화면도 메일 화면 안이라 두 창 처리기가 함께 걸린다 — 파일을 받는 화면(쓰기 화면 · 서명 설정)이 떠 있으면 무시하는 쪽이 아무것도 하지 않는다(어느 쪽이 먼저 걸렸는지와 상관없이 쓰기 화면이 받는다).
- **상단 바**: 줄어든 이름 · 주소의 title 은 이름 · 주소 칸 전체에 둔다(넓은 폭에서 주소가 줄어도 같은 글).
- **같은 대화 [모두 펼치기]**
- 손으로 펼친 줄은 지금처럼 누른 그 자리에서 본문을 받고 받는 대로 읽음이다. [모두 펼치기]로 펼친 줄만 화면에 들어올 때 차례로 받는다 — 차례가 오기 전에 화면 밖으로 나간 줄은 건너뛰고 다시 들어오면 다시 줄을 선다.
- 받는 사이 화면 밖으로 나갔으면 읽음으로 바꾸지 않고, 다시 들어올 때 읽음이다(읽음이 실패해도 드나들 때마다 다시 묻지 않는다).
- 화면에 들어왔는지 알 수 없는 브라우저(IntersectionObserver 없음)는 펼친 줄이 생기면 들어온 것으로 본다 — 그래도 하나씩 받는다.
- 펼칠 줄이 없으면(지금 메일만 받고 나머지를 받는 중) 단추가 없다. 그사이 지워진 메일(본문이 404)의 줄에는 머리 [답장]도 없다(아래 [답장]과 같다).
File diff suppressed because it is too large Load Diff
+7 -3
View File
@@ -59,9 +59,12 @@
"App1SearchStates.dc.html": { "x": 3178, "y": 33450, "w": 2660, "h": 1028, "title": "앱 · 검색 (상태 · 결과에서 연 메일)", "is_interactive": true },
"App1SearchConditions.dc.html": { "x": 5918, "y": 33450, "w": 1784, "h": 1028, "title": "앱 · 검색 2차 (조건으로 찾기)", "is_interactive": true },
"App1Push.dc.html": { "x": 0, "y": 34900, "w": 2222, "h": 3270, "title": "앱 · 새 메일 알림 (푸시)", "is_interactive": true },
"SettingsMobileApp.dc.html": { "x": 0, "y": 38770, "w": 1440, "h": 3209, "title": "설정 · 휴대폰 앱 · 앱 받기 화면", "is_interactive": true }
"SettingsMobileApp.dc.html": { "x": 0, "y": 38770, "w": 1440, "h": 3209, "title": "설정 · 휴대폰 앱 · 앱 받기 화면", "is_interactive": true },
"Reader15.dc.html": { "x": 0, "y": 42600, "w": 1440, "h": 8043, "title": "읽기 화면 · 이동 · 지금 보내기 · 같은 대화", "is_interactive": true },
"ListDrag15.dc.html": { "x": 1520, "y": 42600, "w": 1440, "h": 3845, "title": "목록 · 끌어다 놓기 · 상단 바 좁은 폭", "is_interactive": true },
"Mailbox15.dc.html": { "x": 3040, "y": 42600, "w": 1440, "h": 4937, "title": "메일함 옮기기 · 하위까지 지우기 · 차단할 때 받은 메일", "is_interactive": true }
},
"order": ["Main.dc.html", "Login.dc.html", "Compose.dc.html", "States.dc.html", "MainList.dc.html", "MainListRead.dc.html", "ComposeEditor.dc.html", "Settings.dc.html", "ComposeAssist.dc.html", "Search.dc.html", "BulkSelect.dc.html", "ListStates.dc.html", "SettingsAccount.dc.html", "SettingsVacation.dc.html", "MailboxManage.dc.html", "MailActions4.dc.html", "SettingsNotify.dc.html", "MailRealtime.dc.html", "SettingsFilter.dc.html", "MailFilterStates.dc.html", "MailFilterApply.dc.html", "MailFilterApplyStates.dc.html", "Contacts.dc.html", "ContactsEdit.dc.html", "ContactsMail.dc.html", "MailReadTools.dc.html", "MailPrint.dc.html", "MailSendLater.dc.html", "SettingsSendRead.dc.html", "ComposeLargeFiles.dc.html", "LargeFileBlock.dc.html", "MailReadLargeFiles.dc.html", "SharePage.dc.html", "SettingsLargeFiles.dc.html", "LoginTwoFactor.dc.html", "SettingsTwoFactor.dc.html", "SettingsAppPasswords.dc.html", "SettingsLoginHistory.dc.html", "LoginPasswordChange.dc.html", "LoginTwoFactorSetup.dc.html", "SettingsMailClients.dc.html", "MailNoticesCutover.dc.html", "Search14.dc.html", "Thread14.dc.html", "ListThreads14.dc.html", "App1Login.dc.html", "App1MailList.dc.html", "App1Reader.dc.html", "App1Compose.dc.html", "App1Shell.dc.html", "App1Search.dc.html", "App1SearchStates.dc.html", "App1SearchConditions.dc.html", "App1Push.dc.html", "SettingsMobileApp.dc.html"],
"order": ["Main.dc.html", "Login.dc.html", "Compose.dc.html", "States.dc.html", "MainList.dc.html", "MainListRead.dc.html", "ComposeEditor.dc.html", "Settings.dc.html", "ComposeAssist.dc.html", "Search.dc.html", "BulkSelect.dc.html", "ListStates.dc.html", "SettingsAccount.dc.html", "SettingsVacation.dc.html", "MailboxManage.dc.html", "MailActions4.dc.html", "SettingsNotify.dc.html", "MailRealtime.dc.html", "SettingsFilter.dc.html", "MailFilterStates.dc.html", "MailFilterApply.dc.html", "MailFilterApplyStates.dc.html", "Contacts.dc.html", "ContactsEdit.dc.html", "ContactsMail.dc.html", "MailReadTools.dc.html", "MailPrint.dc.html", "MailSendLater.dc.html", "SettingsSendRead.dc.html", "ComposeLargeFiles.dc.html", "LargeFileBlock.dc.html", "MailReadLargeFiles.dc.html", "SharePage.dc.html", "SettingsLargeFiles.dc.html", "LoginTwoFactor.dc.html", "SettingsTwoFactor.dc.html", "SettingsAppPasswords.dc.html", "SettingsLoginHistory.dc.html", "LoginPasswordChange.dc.html", "LoginTwoFactorSetup.dc.html", "SettingsMailClients.dc.html", "MailNoticesCutover.dc.html", "Search14.dc.html", "Thread14.dc.html", "ListThreads14.dc.html", "App1Login.dc.html", "App1MailList.dc.html", "App1Reader.dc.html", "App1Compose.dc.html", "App1Shell.dc.html", "App1Search.dc.html", "App1SearchStates.dc.html", "App1SearchConditions.dc.html", "App1Push.dc.html", "SettingsMobileApp.dc.html", "Reader15.dc.html", "ListDrag15.dc.html", "Mailbox15.dc.html"],
"notes": {
"flow": { "x": 0, "y": -300, "text": "arasystem 웹메일 — 1단계 화면 (로그인 → 메일함 → 쓰기)", "kind": "title1", "maxW": 4480 },
"phase2": { "x": 0, "y": 2180, "text": "2단계 화면 — 임시 저장 · 받는 사람 자동 완성 · 서명 (2026-10-27 확정)", "kind": "title1", "maxW": 2960 },
@@ -80,7 +83,8 @@
"app1search": { "x": 0, "y": 33150, "text": "앱 1단계 검색 화면 — 찾기 · 결과 · 강조 · 이어 찾기 · 안내 — 2026-11-07 확정(글은 화면 상수가 기준)", "kind": "title1", "maxW": 5838 },
"app1search2": { "x": 5918, "y": 33150, "text": "앱 검색 2차 — 조건으로 찾기 · 날짜 · 조건 줄 — 2026-11-07 확정(글은 화면 상수가 기준)", "kind": "title1", "maxW": 1784 },
"app1push": { "x": 0, "y": 34600, "text": "앱 새 메일 알림 — 허락 묻기 · 설정 · 휴대폰 알림 · 업데이트 · 앱 아이콘 — 2026-11-07 확정(글은 화면 상수가 기준) · 11-08 업데이트 화면 · 앱 정보를 사내 배포에 맞게 바꿈(확정)", "kind": "title1", "maxW": 2222 },
"appdist": { "x": 0, "y": 38470, "text": "휴대폰 앱 받기 — 설정 › 휴대폰 앱 · 휴대폰 받기 화면(로그인한 직원만) — 2026-11-08 확정(글은 화면 상수가 기준)", "kind": "title1", "maxW": 1440 }
"appdist": { "x": 0, "y": 38470, "text": "휴대폰 앱 받기 — 설정 › 휴대폰 앱 · 휴대폰 받기 화면(로그인한 직원만) — 2026-11-08 확정(글은 화면 상수가 기준)", "kind": "title1", "maxW": 1440 },
"phase15": { "x": 0, "y": 42300, "text": "15단계 화면 — 메일 · 메일함 다루기(이동 · 끌어다 놓기 · 메일함 옮기기 · 지금 보내기 · 같은 대화) — 2026-11-09 확정(글은 화면 상수가 기준)", "kind": "title1", "maxW": 4480 }
},
"designSystems": []
}
+107 -11
View File
@@ -411,8 +411,8 @@ HTML 은 정화하기 **전에** 길이(HTML 부분이 여럿이면 이어 붙
| --- | --- | --- | --- |
| `GET /api/mailboxes` | — | 200 `{ items: Mailbox[], pageInfo }` (hasNext=false) | `MAIL_001` |
| `POST /api/mailboxes` | `{ name, parentId? }` (아래 "메일함 관리") | 201 `Mailbox` | 400 `VAL_001`, 404 `RES_001`(없는 parentId), 409 `MAIL_009`(같은 자리에 같은 이름), 503 `MAIL_001` |
| `PATCH /api/mailboxes/{id}` | `{ name }` | 200 `Mailbox` | 400 `VAL_001`(기본 메일함), 404 `RES_001`, 409 `MAIL_009`, 503 `MAIL_001` |
| `DELETE /api/mailboxes/{id}` | — | 200 `MailboxDeletion` — 안의 메일을 휴지통으로 옮긴 뒤 지운다 | 400 `VAL_001`(기본 메일함 · 하위 메일함 있음), 404 `RES_001`, 409 `MAIL_015`(남은 메일이 붙잡힌 메일뿐 — 9단계 "보내기 취소 · 예약 발송"), 429 `REQ_001`(같은 메일함의 처리가 진행 중), 503 `MAIL_001` |
| `PATCH /api/mailboxes/{id}` | `{ name?, parentId? }` — 하나 이상(`parentId` 는 15단계 "메일함 옮기기" — `null` 은 맨 위) | 200 `Mailbox` | 400 `VAL_001`(빈 본문 · 기본 메일함 · 만들 수 없는 자리 · 자기 자신 · 자기 하위 아래), 404 `RES_001`(없는 메일함 · 없는 새 상위), 409 `MAIL_009`, 429 `REQ_001`(옮길 때 — 그 메일함 · 새 상위의 처리가 진행 중), 503 `MAIL_001` |
| `DELETE /api/mailboxes/{id}` | `withChildren=true`(선택 — 15단계 "하위 메일함까지 지우기") | 200 `MailboxDeletion` — 안의 메일을 휴지통으로 옮긴 뒤 지운다(하위까지면 나무 전체) | 400 `VAL_001`(기본 메일함 · 하위 메일함이 있는데 `withChildren=true` 가 아님 · 하위에 기본 메일함 · 다른 값), 404 `RES_001`, 409 `MAIL_015`(남은 메일이 붙잡힌 메일뿐 — 9단계 "보내기 취소 · 예약 발송"), 429 `REQ_001`(같은 메일함(하위까지면 나무의 메일함)의 처리가 진행 중), 503 `MAIL_001` |
| `PATCH /api/mailboxes/{id}/messages` | `{ isRead: true }` — 그 메일함 전체를 읽음으로 | 200 `MailboxBulkResult` | 400 `VAL_001`, 404 `RES_001`, 429 `REQ_001`(같은 메일함의 처리가 진행 중), 503 `MAIL_001` |
| `DELETE /api/mailboxes/{id}/messages` | — 휴지통 · 스팸메일함 비우기(영구 삭제) | 200 `MailboxBulkResult` | 400 `VAL_001`(휴지통 · 스팸메일함이 아님), 404 `RES_001`, 409 `MAIL_015`(남은 메일이 붙잡힌 메일뿐 — 9단계), 429 `REQ_001`(같은 메일함의 처리가 진행 중), 503 `MAIL_001` |
| `GET /api/mail-vacation` | — | 200 `MailVacationState` (아래 "부재중 자동 응답" — `MailVacation` + 7단계의 `isActive`) | 503 `MAIL_001` |
@@ -446,13 +446,15 @@ HTML 은 정화하기 **전에** 길이(HTML 부분이 여럿이면 이어 붙
| `GET /api/mail-signature/images/{id}` | — | 그림 파일 (표준 래퍼 없음, 본인 그림만) | 404 `RES_001` |
| `GET /api/recipient-suggestions` | `q`(선택, 1~100자) · `limit`(기본 20, 최대 100) · `cursor` | 200 `{ items: RecipientSuggestion[], pageInfo }` (아래 "받는 사람 추천") | 400 `VAL_001`, 503 `MAIL_001` |
| `GET /api/scheduled-messages` | `cursor` · `limit`(기본 20, 최대 100) | 200 `{ items: MessageSummary[], pageInfo }` — 예약한 메일, 보낼 시각 차례, `total` 포함 (아래 "보내기 취소 · 예약 발송", 9단계) | 400 `VAL_001`, 503 `MAIL_001` |
| `DELETE /api/scheduled-messages/{id}` | — (`id` 는 보낸 사본의 메일 id) | 200 `{ draftId }` — 붙잡은 발송을 취소하고 임시보관함으로(보내기 취소 · 예약 취소). 이미 취소한 발송이면 다시 200(같은 id) | 404 `RES_001`(형식이 틀린 id 포함), 409 `MAIL_014`(이미 보냄 · 기다리는 발송이 아님), 503 `MAIL_001` |
| `DELETE /api/scheduled-messages/{id}` | — (`id` 는 보낸 사본의 메일 id) | 200 `{ draftId }` — 붙잡은 발송을 취소하고 임시보관함으로(보내기 취소 · 예약 취소). 이미 취소한 발송이면 다시 200(같은 id) | 404 `RES_001`(형식이 틀린 id 포함), 409 `MAIL_014`(이미 보냄 · 기다리는 발송이 아님), 429 `REQ_001`(같은 메일의 지금 보내기가 진행 중 — 15단계), 503 `MAIL_001` |
| `POST /api/scheduled-messages/{id}/release` | — 본문 없음(`id` 는 보낸 사본의 메일 id) | 200 `ReleasedSend` — 예약한 메일을 지금 보낸다(15단계, 아래 "보내기 취소 · 예약 발송"의 "지금 보내기"). 이미 보냈으면 200 `pendingSend: null` | 400 `MAIL_010`(받는 사람 모두 거절 — 임시보관함으로 되돌렸다), 404 `RES_001`(예약이 아닌 메일 · 형식이 틀린 id 포함), 429 `REQ_001`(같은 메일의 예약 취소 · 지금 보내기가 진행 중), 503 `MAIL_001`(임시보관함으로 되돌렸거나 · 결과를 몰라 보낸편지함에 그대로 — 그 메일을 다시 읽어 가른다) · `MAIL_011` |
| `GET /api/mail-images/{token}` | — | 바깥 이미지 (표준 래퍼 없음 — 아래 "외부 이미지 보기", 9단계) | 404 `RES_001`(까닭을 가리지 않는다) |
| `GET /api/mail-settings` | — | 200 `MailSettings` (아래 "보내기 · 읽기 설정", 9단계) | — |
| `PATCH /api/mail-settings` | `Partial<MailSettings>` — 하나 이상 | 200 `MailSettings` | 400 `VAL_001` |
| `GET /api/mail-blocks` | — | 200 `MailBlockList` (아래 "보낸 사람 차단", 9단계) | 503 `MAIL_001` |
| `POST /api/mail-blocks` | `{ address }` — 주소 또는 `@도메인` | 201 `BlockedSender` (이미 있으면 200 — 꺼져 있던 규칙도 켠다) | 400 `VAL_001` · `MAIL_013`, 503 `MAIL_001` |
| `DELETE /api/mail-blocks/{address}` | — (URL 인코딩) | 200 `{ address }` | 404 `RES_001`(차단하지 않은 주소 · 틀린 모양), 503 `MAIL_001` |
| `POST /api/mail-block-runs` | `{ sender, cursor }` — 받은편지함에 이미 있는 그 보낸 사람의 메일을 스팸메일함으로(15단계, 아래 "보낸 사람 차단"의 "받은편지함 정리") | 200 `MailBlockRun` | 400 `VAL_001`(형식 · 깨진 cursor · 다른 보낸 사람의 cursor), 404 `RES_001`(차단 목록에 없음), 429 `REQ_001`(같은 직원의 받은편지함 정리(필터 적용 포함) · 스팸메일함 비우기가 진행 중), 503 `MAIL_001`(스팸메일함 없음 포함) |
표에 없는 실패 (구현에서 확정):
@@ -1082,12 +1084,12 @@ interface MessageDeletionResult {
```ts
interface MailboxCreate { name: string; parentId?: string | null } // parentId 없음 = 맨 위
interface MailboxRename { name: string }
interface MailboxUpdate { name?: string; parentId?: string | null } // 하나 이상 — 보내지 않은 칸은 그대로. parentId: null = 맨 위 (15단계 — 4단계의 MailboxRename { name } 을 넓혔다)
interface MailboxDeletion {
deleted: boolean // true = 지웠다. false = 아직 옮길 메일이 남았다 — 같은 요청을 다시 보낸다
processed: number // 이번 요청에서 이 메일함에서 빠진 메일 수 — 휴지통으로 옮김 + 다른 메일함에도 있어 이 메일함에서만 뺌 (진행 표시용)
deleted: boolean // true = 지웠다(하위까지면 나무 전체). false = 아직 옮길 메일이 남았다 — 같은 요청을 다시 보낸다
processed: number // 이번 요청에서 이 메일함(하위까지면 나무)에서 빠진 메일 수 — 휴지통으로 옮김 + 다른 메일함에도 있어 이 메일함에서만 뺌 (진행 표시용)
movedToTrash: number // 그중 휴지통으로 옮긴 수 (끝난 알림의 "안의 메일 N통은 휴지통에 있습니다")
remaining: number // 아직 남은 메일 수
remaining: number // 아직 남은 메일 수 (하위까지면 나무 전체에 — 여러 메일함에 든 메일도 한 번)
}
interface MailboxBulkResult {
processed: number // 이번 요청에서 읽음으로 바꾼 · 지운 메일 수
@@ -1102,14 +1104,35 @@ interface MailboxBulkResult {
- **만들 수 있는 자리**: 맨 위, 받은편지함 · 보관함 · 내 메일함(역할 없음) 아래. 보낸편지함 · 임시보관함 · 스팸메일함 · 휴지통 아래는 400 `VAL_001`.
웹 화면은 받은편지함 · 내 메일함 아래만 메뉴에 둔다(보관함 아래는 다른 메일 프로그램이 만든 것을 그대로 보인다).
- **기본 메일함**(역할이 있는 메일함)은 이름을 바꾸거나 지울 수 없다 — 400 `VAL_001`. 화면은 메뉴에 그 항목을 두지 않는다.
- **지우기**: 하위 메일함이 있으면 400 `VAL_001`(화면이 먼저 막는다). 안의 메일은 **휴지통으로 옮긴 뒤**(다른 메일함에도 있는 메일은 이 메일함에서만 뺀다)
- **지우기**: 하위 메일함이 있으면 400 `VAL_001`(화면이 먼저 막는다 — 15단계부터는 아래 "하위 메일함까지 지우기"). 안의 메일은 **휴지통으로 옮긴 뒤**(다른 메일함에도 있는 메일은 이 메일함에서만 뺀다)
빈 메일함을 지운다 — 엔진은 메일이 든 메일함을 `mailboxHasEmail` 로 거절하므로(`onDestroyRemoveEmails: false` — 메일을 영구 삭제하는 옵션은 쓰지 않는다) 그사이 들어온 메일도 남지 않는다.
메일이 많으면 한 요청에 다 옮기지 못한다 — `deleted: false` 면 클라이언트가 같은 요청을 다시 보낸다.
- **옮기기**(15단계 — `PATCH /api/mailboxes/{id}` 의 `parentId`): `{ name?, parentId? }` 가운데 바꿀 것만 보낸다 — 둘 다 없으면 400 `VAL_001`, `parentId: null` 은 맨 위.
- 새 상위는 **만들기와 같은 자리**다 — 맨 위 · 받은편지함 · 보관함 · 내 메일함 아래(그 밖은 400 `VAL_001`, 없는 상위는 404 `RES_001`). 기본 메일함은 옮길 수 없다(400 `VAL_001`).
- 자기 자신 · 자기 하위(손주까지) 아래로는 400 `VAL_001` — 서버가 엔진에 묻기 전에 메일함 목록으로 본다. 엔진도 고리를 `invalidProperties`(parentId)로 막는다(2026-11-09 개발 엔진 확인) —
그 거절이 오면 같은 요청에서 다시 읽은 목록에 새 상위가 있으면 400 `VAL_001`(그사이 생긴 고리), 없으면 404 `RES_001`(그사이 지워짐)이다.
- 새 자리에 같은 이름이 있으면 409 `MAIL_009` — **엔진은 대소문자를 가리지 않는다**(`p15-자식` 도 `P15-자식` 과 겹친다 — 같은 날 확인). 화면은 같은 이름이 있는 곳을 처음부터 고르지 못하게 한다(같은 판정).
- 하위 메일함과 안의 메일은 함께 간다 — 메일함 id 는 그대로라 그 메일함으로 옮기는 필터도 그대로 돈다. 깊이 한도는 두지 않는다(만들기와 같다 — 엔진은 15칸까지 막지 않았다).
- 이름 · 상위가 지금과 같으면 엔진에 묻지 않고 200(그대로). 응답은 지금처럼 `Mailbox`(새 `parentId`).
- 옮길 때(`parentId` 를 보낼 때) 그 메일함이나 새 상위의 오래 걸리는 처리(아래)가 진행 중이면 엔진에 묻지 않고 429 `REQ_001` — 지우는 나무 안으로 옮기거나 지우는 메일함을 빼 가지 않게. 이름만 바꾸기는 지금처럼 잡지 않는다.
- 로그: 실제로 옮겼을 때만, 메일함 id 와 새 상위 id 만(이름 없이 — 사용자가 정한 글이다).
- **하위 메일함까지 지우기**(15단계 — `DELETE /api/mailboxes/{id}?withChildren=true`): `withChildren=true` 일 때만 하위가 있어도 지운다 — 없으면 지금처럼 400 `VAL_001`(옛 화면이 모르고 하위를 지우지 않게).
값은 `true` 만 받는다(그 밖 · 모르는 쿼리는 400 `VAL_001`).
- 나무(그 메일함과 하위 모두)를 지금 지우기와 같은 규칙으로 — 나무의 메일을 한 조회(`inMailbox` 의 OR — 엔진이 겹치지 않게 센다)로 500통씩 읽어, 나무 안에만 있는 메일은 휴지통으로,
나무 밖의 메일함에도 있는 메일은 나무의 메일함에서만 뺀다(확인한 상태 그대로일 때만 — `ifInState`). 붙잡힌 메일은 그대로 둔다(9단계 — 남은 것이 그것뿐이면 `MAIL_015`).
- 비었으면 빈 메일함을 **깊은 것부터 한 요청에** 지운다(`onDestroyRemoveEmails: false`) — 엔진은 한 요청의 destroy 를 적은 차례대로 한다(2026-11-09 확인 — 위에서부터 적으면 맨 아래 하나만 지워진다).
그사이 메일이 들어와 거절되면 지운 하위는 나무에서 빼고 남은 나무의 메일을 다시 옮긴다. 그사이 나무 밖의 하위 메일함이 생기면(`mailboxHasChild`) 목록을 다시 읽어 그 메일함까지 지운다.
- 하위에 기본 메일함(역할이 있는 메일함 — 다른 프로그램이 만든 이상한 나무)이 있으면 400 `VAL_001`.
- 응답은 지금 `MailboxDeletion` 그대로 — 수는 나무 전체의 것이다(`deleted: true` 까지 다시 보낸다). 그 밖의 오류는 지금 지우기와 같다.
- 잠금: 나무의 메일함 모두(아래 "같은 직원 · 같은 메일함의 처리는 한 번에 하나") — 서버는 그 메일함을 먼저 잡고(처리 중이면 엔진에 묻지 않고 429 `REQ_001`)
메일함 목록을 읽어 하위 메일함까지 잡는다(하위가 처리 중이면 목록만 읽고 429 — 아무것도 바꾸지 않는다). 휴지통 비우기와 함께 돌지 않는 것은 지금처럼 화면이 막는다.
- **전체 읽음**(`PATCH /api/mailboxes/{id}/messages {isRead:true}`): 안 읽은 메일을 500통씩 읽음으로 바꾼다. **비우기**(`DELETE /api/mailboxes/{id}/messages`): 휴지통 · 스팸메일함만,
500통씩 확인한 상태 그대로일 때만(`ifInState`) 영구 삭제한다 — 그 메일함에만 있는 메일은 지우고, 다른 메일함에도 있는 메일은 그 메일함에서만 뺀다(`POST /api/message-deletions` 와 같은 규칙).
- 오래 걸리는 처리(전체 읽음 · 비우기 · 메일함 지우기)는 한 요청을 25초 안에 끝내고(엔진 호출마다 남은 시간만큼만 기다린다) `remaining` 을 돌려준다.
클라이언트는 0(또는 `deleted: true`)이 될 때까지 다시 보내며 진행을 알림으로 보인다("…하는 중 · 600 / 1,234통"). 도중에 실패하면 거기까지 처리한 수를 알리고, 다시 하면 남은 것만 처리한다.
- 같은 직원 · 같은 메일함의 처리는 한 번에 하나다 — 진행 중이면 엔진에 묻지 않고 429 `REQ_001`(다른 탭에서 같은 메일함을 처리할 때). 다른 메일함끼리는 함께 된다.
하위까지 지우기는 나무의 메일함 모두를(하위는 목록을 읽은 뒤), 옮기기는 그 메일함과 새 상위를 함께 잡는다(15단계 — 하나라도 처리 중이면 429).
차단할 때 받은편지함 정리(15단계 — 아래 "보낸 사람 차단")가 도는 동안은 그 직원의 스팸메일함도 잡혀 있다 — 그동안 스팸메일함 비우기 · 전체 읽음은 429.
- 한 요청의 상한 25초는 메일함 목록을 읽기 전부터 잰다(엔진의 세션 조회 포함). 다음 500통은 남은 시간이 5초보다 많을 때만 시작한다.
- 멈추는 경우 — 진행한 것이 있으면 거기까지 200(`remaining` > 0 · `deleted: false`), 하나도 처리하지 못했으면 503 `MAIL_001`(클라이언트가 끝없이 다시 보내지 않게):
아무것도 바뀌지 않는 차례(확인하는 사이 계속 바뀜 · 바뀐 것도 거절도 없음 · 비운 메일함을 지우는 사이 메일이 들어옴)가 이어서 3번, 남은 시간 때문에 엔진 호출이 끊김.
@@ -1480,6 +1503,45 @@ interface PendingSend {
- 서명 그림 부분 빼기 · `cid:sig-…` 되돌리기는 취소 표시(`$ara-canceled`)가 있는 초안에만 한다(다른 메일 프로그램이 만든 초안의 그림은 건드리지 않는다).
- 취소와 옮기기는 다른 요청이다(같은 요청에 두면 취소가 실패해도 옮겨진다). 취소는 됐는데 옮기지 못하면(엔진 오류) 한 번 더 하고, 그래도 못 하면 503 `MAIL_001` —
그 메일은 보내지 않은 채 보낸편지함에 남는다(드묾 — 알려진 한계, warn 로그).
- 같은 메일의 지금 보내기(아래)가 진행 중이면 엔진에 묻지 않고 429 `REQ_001`(15단계).
- **지금 보내기**(15단계 — `POST /api/scheduled-messages/{id}/release`, `id` 는 보낸 사본의 메일 id, 본문 없음): 예약한 메일을 [보내기]를 누른 것처럼 보낸다.
엔진 사실(2026-11-09 개발 엔진): 기다리는 예약 제출을 취소(`undoStatus: canceled`)하고 같은 메일을 바로 다시 제출하면 받는 사람에게 **정확히 한 통** 간다 —
취소가 된 뒤에만 다시 내므로 두 번 나갈 틈이 없다(그 전에 엔진이 보냈으면 취소가 `cannotUnsend`). 이미 보냈거나 이미 취소한 제출을 취소해도 `cannotUnsend` 라 서버는 제출의 `undoStatus` 를 다시 읽어 가른다.
```ts
interface ReleasedSend { // 발송 응답(SentMessage)과 같은 칸
id: string // 같은 메일 id (보낸 사본)
rejectedRecipients: RejectedRecipient[] // 받는 사람별 결과 — 발송과 같은 규칙(아래 "받는 사람별 결과")
pendingSend: PendingSend | null // { kind: 'undo', sendAt } — 보내기 취소를 기다린다 · null — 바로 보냈다 · 이미 보냈다
}
```
- 차례: 그 메일 · 메일함 · 그 메일의 제출(envelope · 발신 ID · 결과까지)을 한 요청으로 읽고 → 다시 낼 envelope 을 먼저 정한 뒤(읽지 못하면 취소하지 않고 503 `MAIL_001`) →
기다리는 예약 제출을 취소한다(엔진이 취소를 확인한 것만 — 확인이 없거나 결과를 모르면 다시 읽어 취소됐을 때만 잇고, 아직 기다리면 다시 내지 않고 503 `MAIL_001`) →
**다른 요청으로** 같은 메일을 그 제출의 envelope 그대로(받는 사람 · 참조 · 숨은참조 · envelope 에서 뺀 주소까지 원래 제출의 것 — 머리글에서 다시 만들지 않는다) 다시 제출한다.
MAIL FROM 의 붙잡기 매개변수만 바꾼다 — 그 직원의 보내기 취소 설정(`undoSendSeconds`)만큼 `HOLDFOR`(0 이면 붙잡지 않는다 — 설정을 읽지 못하면 처음 값 10초).
성공하면 같은 요청에서 예약 표시(`$ara-scheduled`)를 뗀다 — 이제 보내기 취소를 기다리는 메일이다(취소는 지금처럼 `DELETE` — 쓰기 화면으로).
- **이미 보낸 메일**(예약 시각이 막 지나 제출이 `final` · 보낼 시각이 지나 배달 중 · 취소하려니 엔진이 `cannotUnsend`)은 오류가 아니다 → 200 `pendingSend: null`(보낸 것으로).
- **이미 지금 보내기를 한 메일**(취소한 예약 제출 + 그 뒤 새 제출 — 다시 누름 · 응답을 받지 못한 재시도)도 다시 내지 않고 그 새 제출의 지금 상태로 200(기다리면 `pendingSend: { kind: 'undo' }`).
- 예약이 아닌 메일(보내기 취소를 기다리는 메일 · 취소해 임시보관함으로 간 메일(`$ara-canceled`) · 제출이 없음 · 예약하지 않고 보낸 메일) · 없는 메일 · 형식이 틀린 id 는 404 `RES_001` — 아무것도 바꾸지 않는다.
- 앞 요청이 예약을 취소한 뒤 끊겼으면(예약 표시가 있고 취소 표시는 없는데 제출이 모두 `canceled`) 취소를 건너뛰고 다시 제출만 한다 — 보내지지도 예약돼 있지도 않은 메일이 남지 않게(구현하며 정함).
- 다시 제출이 실패하면 둘로 가른다(처음 발송과 같은 결 — 위 "발송 본문"의 "엔진이 받지 않는 주소"에서 요청째 거절과 결과를 모르는 실패를 가르는 것과 같다):
- **엔진이 분명히 거절했다**(SetError · 메서드 오류 · 요청을 실행하지 않았다는 4xx · 429 — 아무것도 나가지 않았다): 한 번 더 낸다. 그래도 거절이면 그 메일을 임시보관함으로 되돌리고
(예약 취소와 같은 되돌리기 — `$draft` · `$ara-canceled`, 대용량 첨부 링크도 취소한 발송처럼) 503 `MAIL_001`(warn — 메일은 임시보관함에 있다).
- **결과를 모른다**(시간 초과 · 연결 끊김 · 5xx · 해석하지 못한 응답 — 나갔을 수 있다): 다시 내지도 · 임시보관함으로 옮기지도 않는다. 제출을 한 번 다시 읽어(남은 시간이 없어도 3초)
새 제출이 보이면 그 상태로 200, 아니면 503 `MAIL_001` — 메일은 예약이 취소된 채 보낸편지함에 그대로다(링크도 그대로). 같은 요청을 다시 보내면 위의 "끊긴 지금 보내기"로 이어 낸다
(그때 새 제출이 보이면 "이미 지금 보내기를 한 메일"로 200).
- 제출마다 먼저 남은 시간을 본다 — 10초보다 적으면 내지 않고 503 `MAIL_001`(결과를 모를 때와 같다 — 메일은 보낸편지함에 그대로, 다시 보내면 이어 낸다).
- 임시보관함으로 되돌리기마저 실패하면 503 `MAIL_001` — 메일은 예약이 취소된 채 보낸편지함에 남는다(위 예약 취소의 "옮기지 못하면"과 같다).
- 503 `MAIL_001` 은 응답만으로는 어느 쪽인지 모른다(응답 꼴은 그대로) — **클라이언트는 503 뒤에 그 메일을 다시 읽어**(`GET /api/messages/{id}`) 임시보관함에 있으면 되돌린 것으로,
보낸편지함에 있으면 [다시 시도](같은 `POST` — 이어 낸다)를 보인다.
- **알려진 한계**: 결과를 모르는 503 뒤에 다시 보내지 않으면 그 메일은 보내지 않은 채(예약도 취소된 채) 보낸편지함에 남는다 — 9단계 예약 취소의 "옮기지 못하면"과 같은 한계다(드묾, warn 로그).
- 받는 사람이 **모두** 받지 않으면(발송의 `MAIL_010` · `MAIL_011` 과 같은 판정) 보내지 않은 것이다 — 기다리는 새 제출을 취소하고 그 메일을 임시보관함으로 되돌린 뒤
400 `MAIL_010` · 503 `MAIL_011`(구현하며 정함 — 발송처럼 사본을 지우지 않는다: 쓰기 화면이 없는 메일이라 잃지 않게). 일부만 받지 않았으면 200 `rejectedRecipients`.
- 대용량 첨부(4절): 다시 제출한 뒤 그 발송이 만든 링크의 **보낸 때를 지금으로 앞당기고 끝나는 날은 그대로 둔다** — 보낸 메일의 덩이가 예약 시각으로 센 끝나는 날을 이미 적었다(앞당기면 받는 사람이 본 날짜보다 먼저 끝난다).
이미 보낸 메일이면 링크를 건드리지 않는다.
- 같은 직원 · 같은 메일의 예약 취소 · 지금 보내기는 한 번에 하나 — 진행 중이면 엔진에 묻지 않고 429 `REQ_001`.
서버는 54초 남짓 안에 답한다 — 엔진 호출은 45초 안에서 남은 시간만큼, 정리(새 제출 취소 · 임시보관함으로 되돌리기 · 결과를 모를 때 다시 읽기)는 남은 시간이 없어도 3초씩 더 쓴다.
앞단 nginx · 클라이언트의 60초 안이다(클라이언트는 발송과 같은 시간 한도로 부른다).
- 로그: 메일 id · 예약했던 시각 · 보낼 시각만(받는 사람 · 제목 없음 — 위 "로그"와 같다).
- **중복 발송 방지와 함께**: 취소한 발송의 `Idempotency-Key` 로 다시 보내면 409 `MAIL_005`(보내지 않는다) — 클라이언트는 취소 뒤 새 키로 보낸다.
서버는 취소한 발송의 메일을 "보낸 것"으로도, "끊긴 발송이 남긴 초안"으로도 보지 않는다(지우지 않는다). 취소 표시(`$ara-canceled`)가 있으면 제출 기록과 상관없이 취소한 발송이다.
붙잡는 시간은 지문에 넣지 않는다 — 같은 키로 다시 보내면 `sendAt` 이 달라도 처음 결과(그때의 `pendingSend`)를 돌려준다.
@@ -1547,7 +1609,8 @@ interface MailBlockList {
```
- **효과**: 차단한 보낸 사람(머리글 From 의 주소 · 도메인 — 대소문자 무시)의 메일은 스팸메일함으로 간다. 필터 · 부재중보다 먼저 돌고 멈추므로 필터는 돌지 않고 부재중 응답도 나가지 않는다.
이미 받은 메일은 옮기지 않는다 — 읽기 화면의 차단 창은 그 메일만 함께 스팸메일함으로 옮긴다(`PATCH /api/messages/{id}` 의 `isJunk`).
이미 받은 메일은 저절로 옮기지 않는다 — 읽기 화면의 차단 창은 그 메일만 함께 스팸메일함으로 옮기고(`PATCH /api/messages/{id}` 의 `isJunk`),
받은편지함에 있는 그 보낸 사람의 다른 메일은 고르면 아래 "받은편지함 정리"(15단계)로 옮긴다.
- **검사**: 주소는 발송의 주소와 같은 규칙에 `@` 앞은 dot-atom · 64자까지(따옴표 앞부분은 받지 않는다 — 클라이언트의 `isStrictEmail` 과 같은 폭, 2026-10-29 리뷰),
도메인은 `@` + 호스트 이름(점이 하나 이상 · 253자 · IP 주소 아님 · ASCII 만 — 국제화 도메인은 `xn--` 꼴로, `${` 는 받지 않는다).
이미 차단한 주소를 다시 `POST` 하면 200 — 엔진에서 규칙이 꺼져 있었으면 같은 원본을 다시 써서 켠다. 그래서 `POST` · `DELETE` 가 성공한 뒤에는 늘 켜져 있다(`isActive: true`).
@@ -1568,6 +1631,34 @@ interface MailBlockList {
- **클라이언트**: 읽기 화면 ⋯ 「보낸 사람 차단」(사내 직원 · 내 주소면 없고, 이미 차단했으면 「보낸 사람 차단 풀기」, 500개면 꺼짐 + 까닭) → 확인 창(이 주소만 · 이 도메인 전체,
「이 메일도 스팸메일함으로 옮기기」) → `POST`(+ 옮기기) → 알림 + [되돌리기](차단을 풀고 메일을 원래 메일함으로 — 구현하며 정함). 설정 › 메일 › 차단한 주소에서 더하고 푼다.
스팸메일함에서 차단한 보낸 사람의 메일을 열면 안내 줄 + [차단 풀기]. 차단 목록은 메일 화면에 들어올 때 받아 둔다.
- **받은편지함 정리**(15단계 — `POST /api/mail-block-runs`): 차단한 뒤 받은편지함에 이미 있는 그 보낸 사람의 메일을 스팸메일함으로 옮기고 스팸 표시를 붙인다(`$junk` 를 붙이고 `$notjunk` 를 뗀다 — 스팸 신고 `isJunk: true` 와 같다).
```ts
interface MailBlockRunRequest {
sender: string // 차단 목록의 값 그대로 — 소문자 주소 또는 "@도메인" (BlockedSender.address — 대소문자 · 앞뒤 공백은 서버가 맞춘다)
cursor: string | null // 처음은 null, 이어 할 때는 앞 응답의 cursor 그대로 — 늘 보낸다
}
interface MailBlockRun { // 이미 받은 메일에 필터 적용(MailFilterRun)과 같은 꼴
scanned: number // 이번 요청에서 확인한 받은편지함 메일 수
matched: number // 그중 스팸메일함으로 옮긴 수 (붙잡힌 메일은 옮기지 않아 세지 않는다)
remaining: number // 아직 확인하지 않은 수 — 어림
cursor: string | null // null = 끝
}
```
- 차단 목록에 없으면(차단한 뒤에만 부른다 · 그사이 풀었다) 404 `RES_001` — 요청마다 본다(도중에 풀면 다음 요청이 멈춘다). 보낸 사람의 모양은 차단하기와 같은 검사(400 `VAL_001`).
- 틀은 위 "이미 받은 메일에 적용"(7단계)과 같다: 받은편지함만(정리해 둔 메일함은 건드리지 않는다) · 받은 시각이 최근인 메일부터 · 첫 요청의 위 경계 뒤에 온 메일은 보지 않는다(새로 온 메일은 차단 규칙이 이미 옮겼다) ·
`cursor`(위 경계 · 마지막으로 남긴 메일과 그 받은 시각 · 보낸 사람의 지문 — 다른 보낸 사람의 cursor · 필터 적용의 cursor · 깨진 cursor 는 400 `VAL_001`) ·
한 요청 25초 · 다음 차례는 남은 시간 5초 넘을 때만 · 나아감 없는 차례 3번이면 멈춤 · 하나도 확인하지 못하면 503 `MAIL_001`. 옮기기는 읽은 상태 그대로일 때만(`ifInState`).
- **같은 직원의 받은편지함 정리(필터 적용 · 이것)는 한 번에 하나** — 같은 잠금, 진행 중이면 엔진에 묻지 않고 429 `REQ_001`.
- 요청이 도는 동안 그 직원의 **스팸메일함**을 메일함 처리 잠금(위 "메일함 관리" — 같은 메일함의 처리는 한 번에 하나)으로 잡는다 — 옮겨 넣는 동안 스팸메일함 비우기 · 전체 읽음이 함께 돌지 않게(그동안 그쪽이 429).
스팸메일함을 비우는 중이면 메일함 목록을 읽은 뒤 429 `REQ_001` — 받은편지함은 건드리지 않았다(비우기가 끝난 뒤 같은 `cursor` 로 다시 보낸다).
잠금은 **요청 하나씩**이다 — 다른 창에서 비우기와 차단 정리가 함께 진행되면 요청 사이에 번갈아 돌 수 있어, 차단 정리가 옮겨 넣은 메일을 다음 비우기 요청이 지울 수 있다
(알려진 한계 — 휴지통 비우기와 메일함 지우기의 관계와 같다. 같은 탭에서는 화면이 둘을 함께 돌리지 않는다).
- **판정은 차단 규칙(위 "만든 Sieve")과 같다** — 주소는 From 의 주소 가운데 하나가 같을 때(`address :all :is`), 도메인은 From 주소의 `@` 뒤가 그 도메인과 같을 때(`address :domain :is` — 하위 도메인 · 더 긴 도메인은 아님),
대소문자는 ASCII 만 무시(`i;ascii-casemap`). 견주는 것은 **엔진이 푼 From 머리글의 주소**(`header:From:asAddresses:all` — 같은 이름의 머리글이 여럿이면 모두)다 — 표시 이름에 그 주소를 쓴 남의 메일은 걸리지 않는다.
엔진 검색(`Email/query` 의 `from`)은 쓰지 않는다 — 낱말 단위라 넓게 잡는다(2026-11-09 개발 엔진: `kim.lee@` · `@sub.` · `.example.net` · 표시 이름에만 그 주소를 쓴 남의 메일까지).
- 붙잡힌 메일(9단계)은 옮기지 않는다(받은편지함에는 없지만 지금 규칙대로). 스팸메일함이 없으면 503 `MAIL_001`. 되돌리기는 없다(스팸메일함에서 [스팸 아님]으로).
- 로그: 보낸 사람은 가린다(`maskEmail` — 도메인 차단은 도메인만). 확인 · 옮긴 수 · 차례 · 시간만.
- **클라이언트**: 차단 창의 「받은편지함에 있는 이 보낸 사람의 다른 메일도 옮기기」(처음 꺼짐)를 켜면 차단(`POST /api/mail-blocks`)이 성공한 뒤 이 요청을 `cursor` 가 null 이 될 때까지 보낸다 — 진행 · 결과 알림은 디자인 README "15단계".
### 보내기 · 읽기 설정 (`/api/mail-settings`) — 9단계
@@ -1594,8 +1685,10 @@ interface MailSettings {
탭을 모두 닫아도 오는 알림(웹 푸시 — 엔진은 `webpush-vapid` 를 지원한다), 알림 소리(운영체제 설정을 따른다), 휴대폰 푸시, 본문 안 이미지(인라인 cid — 붙여 넣은 이미지는 첨부로, 서명 그림만 예외), 메일 안의 배경 그림 보이기 · 보낸 사람마다 고르는 "이미지 늘 보기" 목록(9단계는 설정 세 가지로),
여러 개의 서명(자체 검색 색인 · 조건 검색 · 검색어 강조는 14단계에서 했다 — 위 "검색 — 14단계"),
끌어다 놓아 옮기기, 메일함 옮기기(다른 상위로) · 하위 메일함까지 한꺼번에 지우기, 앱 비밀번호 · 2단계 인증(엔진은 지원한다), 부재중 응답의 서식 본문 · 사내/외부 구분,
예약한 메일의 「지금 보내기」 · 7일 넘는 예약(엔진 한도) · 차단할 때 이미 받은 그 보낸 사람의 메일 모두 옮기기(9단계는 그 메일만).
메일함을 끌어 옮기기 · 메일함 순서 바꾸기(15단계는 ⋯ 메뉴 「옮기기」 창으로 — 메일을 끌어다 메일함에 놓기는 15단계에서 했다, 서버는 지금 `PATCH /api/messages` 그대로), 옮기기 단축키,
부재중 응답의 서식 본문 · 사내/외부 구분(앱 비밀번호 · 2단계 인증은 12단계에서 했다), 7일 넘는 예약(엔진 한도), 예약됨 목록 줄의 [지금 보내기](15단계는 읽기 화면의 예약 안내 줄에만),
차단 정리의 되돌리기 · 받은편지함 밖 메일함의 정리(15단계는 받은편지함만 — 위 "보낸 사람 차단"의 "받은편지함 정리").
15단계에 한 것: 메일함 옮기기 · 하위 메일함까지 지우기(위 "메일함 관리"), 차단할 때 받은편지함 정리(위 "보낸 사람 차단"), 예약한 메일 지금 보내기(위 "보내기 취소 · 예약 발송").
---
@@ -2128,6 +2221,9 @@ interface FileUploadCreate {
- 덩이는 제출할 때 메일 안에 들어 있다 — 엔진이 붙잡았다가 그대로 보낸다.
- 링크의 보낸 때: 예약은 `sendAt`(끝나는 날도 그 날짜로 센다 — 예약 7일 + 30일이면 37일까지 보관), 보내기 취소를 기다리는 발송은 요청한 때(몇 초 차이는 무시).
예약 링크는 보낼 시각 전에도 열린다(주소를 아는 사람은 보낸 사람뿐이다).
- 예약한 메일을 **지금 보내면**(15단계 — 2절 "보내기 취소 · 예약 발송"의 "지금 보내기") 보낸 때를 다시 제출한 때로 앞당긴다(조건부 UPDATE 한 문장 — 취소한 링크 · 이미 그보다 앞선 보낸 때는 그대로).
**끝나는 날은 그대로 둔다** — 보낸 메일의 덩이가 예약 시각으로 센 날짜를 이미 적었다(메일은 고칠 수 없다 — 앞당기면 받는 사람이 본 날짜보다 먼저 끝난다). 파일의 보관 기간도 그대로다.
끝내 보내지 못해 임시보관함으로 **되돌렸을 때만** 예약 취소처럼 그 링크를 취소한 링크로 둔다 — 결과를 몰라 보낸편지함에 그대로 둔 503 은 링크도 그대로다(다시 보내면 이어 낸다).
- 취소(`DELETE /api/scheduled-messages/{id}`)가 엔진에서 확인된 뒤 그 발송이 만든 링크를 취소한 링크로 둔다. 파일은 남는다(되돌린 초안이 쓴다 — 임시 저장 기간으로, 보내지 않은 파일 목록에 다시 나온다).
- 되돌린 초안(취소 표시 `$ara-canceled`)을 열면 서버가 원문 HTML 의 표시 사이(덩이)를 정화 **전에** 떼고(`mail.service.ts:1282-1304` 의 `draftHtml` — 취소한 발송의 서명 그림을 되돌리는 그 자리),
텍스트 본문의 덩이도 뗀다(서버가 넣은 글 그대로일 때 — 손대면 남는다). 그 링크의 파일을 `largeFiles` 로 돌려준다 → 쓰기 화면은 대용량 목록으로 보이고, 다시 보내면 새 링크다.
+73
View File
@@ -0,0 +1,73 @@
# ARA SYSTEM 로드맵
그룹웨어 전체의 작업 차례다. 단계 번호는 모듈마다 따로 센다 — 메일의 "N단계"는 API 계약([`docs/mail/api-contract.md`](./mail/api-contract.md))과
화면 디자인([`docs/design/webmail/README.md`](./design/webmail/README.md))의 "N단계"와 같다. 단계마다 **조사 → 캔버스(화면 보드) → 확정 → 구현 → 배포** 차례로 한다.
마지막 정리: 2026-11-09
## 지금
- **운영**: 메일 1~13단계. 2026-11-05 옛 메일 서버(mailcow)에서 전 직원이 옮겨 왔다(메일만 옮겼다 — 주소록 · 달력 · 필터 · 부재중 · 서명은 직원이 새로).
- **배포를 기다림**: 메일 14단계 · 앱 서버(앱 로그인 · 새 메일 알림) · 휴대폰 앱 받기 — main 에 합쳤고, 전환 뒤 안정화 1~2주를 지나 11월 셋째 주에 운영에 올린다.
- **개발 중**: 메일 15단계(메일 · 메일함 다루기).
- **다음**: 메일 16단계(개인 설정).
## 1부 — 메일
| 단계 | 내용 | 상태 |
| --- | --- | --- |
| 1 | 웹메일 기본 — 로그인 · 메일함 · 목록 · 읽기 · 쓰기(서식 편집기 · 첨부) | 운영 |
| 2 | 임시 저장 · 서명 · 받는 사람 자동 완성 | 운영 |
| 3 | 새 메일 확인 · 검색 · 여러 통 한꺼번에(읽음 · 중요 · 이동 · 삭제) | 운영 |
| 4 | 메일함 관리 · 비밀번호 바꾸기 · 부재중 자동 응답 · 스팸 · 원문 | 운영 |
| 5 | 실시간 새 메일 · 바탕화면 알림 | 운영 |
| 6 | 메일 필터 | 운영 |
| 7 | 이미 받은 메일에 필터 적용 | 운영 |
| 8 | 주소록(내 연락처 · 그룹 · 사내 목록) | 운영 |
| 9 | 보내기 취소 · 예약 발송 · 외부 이미지 보기 · 인쇄 · 보낸 사람 차단 · 보내기 · 읽기 설정 | 운영 |
| 10 | 파일럿 공존 — 옛 서버와 같은 도메인을 함께 쓰며 먼저 써 보기 | 운영(전환으로 끝남) |
| 11 | 대용량 첨부 — 링크로 보내기 · 조각 올리기 · 30일 보관 · 로그인 없는 내려받기 화면 · 악성 검사 | 운영 |
| 12 | 계정 보안 — 2단계 인증 · 앱 비밀번호 · 로그인 기록 · 로그인된 기기 | 운영 |
| 13 | 전환 준비 — 새 비밀번호 · 2단계 필수 · 메일 프로그램 연결 안내 · 운영 도구 · 백업 · 감시 · 휴지통 · 스팸 30일 | 운영 |
| 14 | 자체 검색 색인 · 조건 검색 · 검색어 강조 · 같은 대화 · 대화로 묶어 보기 | main 에 합침 — 배포 기다림 |
| 15 | 메일 · 메일함 다루기 — 읽기 화면 [이동] · 끌어다 놓기 · 메일함 옮기기 · 하위까지 지우기 · 차단할 때 받은편지함 정리 · 예약 메일 [지금 보내기] · 같은 대화 [모두 펼치기] | 개발 중 |
| 16 | 개인 설정 — **주소록 가져오기 · 내보내기**(vCard · CSV) · 여러 서명(사내 · 대외 · 영문) · 부재중 응답의 서식 본문과 사내/외부 구분 · 2단계 인증 휴대폰 직접 바꾸기 | 다음 |
- 15단계부터는 직원이 쓰며 바라는 것을 먼저 본다 — 16단계가 끝날 즈음 아래 "추후 후보" 가운데 요청이 실제로 있는 것을 골라 다음 단계로 넣는다.
- 2026-11-09 계획에서 뺀 것: 수신 확인 · 사내 발송 취소(사내 받는 사람에게만 되고, 흔한 실수는 보내기 취소가 막는다 — 아래 후보로).
## 공통 기반 — 조직 · 권한
부서 · 관리자 화면 · 모듈 사이 권한. 파일 공유 · 일정 공유 · 사내 AI 의 데이터 범위가 모두 "누가 무엇을 볼 수 있나"에 달려 2부보다 먼저 한다.
## 2부 — 다른 웹 모듈
파일 저장소 → 캘린더(일정 · 초대) → 사내 AI(메일 · 파일 · 일정을 쓰는 도우미) → 그 밖. 파일 저장소는 11단계 대용량 첨부의 저장 · 올리기를 바탕으로 한다.
## 3부 — 휴대폰 앱 (웹과 나란히)
| 판 | 내용 | 상태 |
| --- | --- | --- |
| 앱 1단계 | 로그인(코드 · 새 비밀번호 · 2단계 켜기) · 메일(목록 · 읽기 · 쓰기 · 첨부 · 검색 · 이동 · 삭제) · 새 메일 알림(안드로이드) | main 에 합침 — 서버 배포 뒤 직원 배포 |
| 사내 배포 | 스토어 없이 우리 키로 서명한 APK 를 포털의 휴대폰 앱 받기 화면에서 | main 에 합침 — 서명 키 준비됨 |
| 다음 판 후보 | 주소록 · 대용량 첨부 · 필터 · 서식 편집 · 예약 메일 [지금 보내기] | 첫 판 반응을 보고 고른다 |
## 추후 후보 (필요해지면)
단계마다 "처음 판에서 미룬 것" · "이 단계에서 하지 않는 것"을 한데 모은 것이다. 직원 요청이 있거나 다른 모듈을 만들 때 함께 본다.
- **메일 쓰기 · 읽기**: 수신 확인 · 사내 발송 취소, 본문 안에 그림 넣기(지금은 붙여 넣으면 첨부), 메일 안의 배경 그림, 보낸 사람마다 "이미지 늘 보기", 탭을 모두 닫아도 오는 알림(웹 푸시),
7일 넘는 예약(메일 엔진 한도라 어렵다).
- **검색**(14단계에서 미룸): 강조한 곳 사이 다음 · 이전 이동, 검색어 줄임말(`from:` 꼴), 첨부 파일 안의 글 찾기, 끝이 바뀐 말 찾기(형태소 분석).
- **메일함 정리**(15단계에서 미룸): 메일함을 끌어 옮기기 · 순서 바꾸기, 옮기기 단축키, 예약됨 목록 줄의 [지금 보내기], 차단할 때 옮긴 메일 한꺼번에 되돌리기 · 받은편지함 밖 메일함 정리.
- **대용량 첨부**(11단계에서 미룸 — 파일 저장소 때 함께): 보관 기간 고르기 · 연장, 다시 보내기, 받는 사람 확인 코드 · 비밀번호, 사내 직원만 받기, 받는 사람별 기록,
처음 받음 · 만료 전 알림, 미리 보기, 모두 받기(zip), 폴더 올리기, 링크 → 메일에 담기 되돌리기.
- **계정 · 보안**(12 · 13단계에서 미룸 — 공통 기반 때 함께): 복구 코드, 이 브라우저 기억, 패스키, 새 기기 로그인 알림 메일, 계정 변경 기록, 로그인 기록의 나라 · 지역,
앱 비밀번호 이름 바꾸기 · 만료, 비밀번호 스스로 재설정, 공용 주소로 보내기 · 공유 메일함, 웹메일 관리자 화면.
- **앱**: 앱 안에서 받아 바로 설치, 최소 판이 아닌 새 판 알림, 아이폰, Google 개발자 인증(2027년 전 세계 — 그 전에 등록할지 정한다).
## 틈날 때 (기능이 아닌 것)
- 작은 정리: 메일 첨부 받기 경로의 긴 한글 파일 이름(nginx 머리글 버퍼), 백엔드 대소문자 라우팅에 대한 옛 주석, 앱 APK 확인을 서명 지문 대조로.
- 기술 정리(9단계에서 나온 것): 외부 이미지 중계의 접근 로그 양, 실시간 알림에 발송 상태 싣기, 예약 시각 범위의 오류 코드, 모두 4xx 로 붙잡힌 발송의 엔진 동작 실측.
- 의존성 갱신(배포 2주가 지난 판 — 보안 릴리스는 바로)과 코드 정리(쓰기 화면 쪼개기, 운영 이미지의 시험 파일).
+20 -36
View File
@@ -1,48 +1,32 @@
# frontend
# frontend — ARA SYSTEM 웹메일 (Vue 3)
This template should help get you started developing with Vue 3 in Vite.
웹메일 화면이다. 개발에서는 호스트의 vite 로, 운영에서는 nginx 가 화면을 내주고 같은 주소의 `/api` 를 백엔드로 넘긴다.
## Recommended IDE Setup
- 구조 · 개발 규칙(골격 · 스토어 · 컴포저블 · 화면별 파일): [`.claude/skills/vue3/SKILL.md`](../.claude/skills/vue3/SKILL.md)
- 화면 디자인 · 화면 문구 규칙: [`docs/design/webmail/README.md`](../docs/design/webmail/README.md)
- API 약속: [`docs/mail/api-contract.md`](../docs/mail/api-contract.md)
[VS Code](https://code.visualstudio.com/) + [Vue (Official)](https://marketplace.visualstudio.com/items?itemName=Vue.volar) (and disable Vetur).
## 개발 서버
## Recommended Browser Setup
- Chromium-based browsers (Chrome, Edge, Brave, etc.):
- [Vue.js devtools](https://chromewebstore.google.com/detail/vuejs-devtools/nhdogjmejiglipccpnnnanhbledajbpd)
- [Turn on Custom Object Formatter in Chrome DevTools](http://bit.ly/object-formatters)
- Firefox:
- [Vue.js devtools](https://addons.mozilla.org/en-US/firefox/addon/vue-js-devtools/)
- [Turn on Custom Object Formatter in Firefox DevTools](https://fxdx.dev/firefox-devtools-custom-object-formatters/)
## Type Support for `.vue` Imports in TS
TypeScript cannot handle type information for `.vue` imports by default, so we replace the `tsc` CLI with `vue-tsc` for type checking. In editors, we need [Volar](https://marketplace.visualstudio.com/items?itemName=Vue.volar) to make the TypeScript language service aware of `.vue` types.
## Customize configuration
See [Vite Configuration Reference](https://vite.dev/config/).
## Project Setup
```sh
npm install
```bash
cd frontend && npm install && npm run dev # http://localhost:5173 — /api 는 vite 프록시가 백엔드 컨테이너로
```
### Compile and Hot-Reload for Development
백엔드 · DB · 메일 엔진은 루트의 개발 스택으로 먼저 띄운다(루트 README "빠른 시작"). 포트는 루트 `.env.{mode}` 의 `FRONTEND_PORT` · `BACKEND_PORT` 를 따른다.
API 응답이 JSON 이 아니라 HTML 이면 vite 프록시가 백엔드에 닿지 못한 것이다(백엔드 컨테이너 · 포트를 본다).
```sh
npm run dev
## 검사
```bash
npm run lint # 경고 0
npm run type-check
npm run test:unit # 시간대가 걸린 화면을 고쳤으면 Git Bash 에서 TZ=UTC npm run test:unit -- --run 도
```
### Type-Check, Compile and Minify for Production
## 운영 빌드
```sh
npm run build
```
운영은 이미지(nginx)로 만든다 — `docker compose --env-file .env.production up -d --build`(docker 스킬 "운영 환경" · "같은 주소 배포"). `VITE_API_BASE_URL` 은 `/api` 로 고정이다.
### Run Unit Tests with [Vitest](https://vitest.dev/)
## 편집기
```sh
npm run test:unit
```
VS Code + [Vue (Official)](https://marketplace.visualstudio.com/items?itemName=Vue.volar). 형 검사는 `vue-tsc` 다.
@@ -5,11 +5,14 @@
// - 줄의 메일이 모두 보내기를 기다리는 메일이면 고르기 · 별을 쓸 수 없다(aria-disabled + 까닭은 title — 메일 줄의 고르기 칸과 같은 결)
// - 마지막 메일이 붙잡힌 메일이면 시각 자리가 "보내는 중" · "예약 · …"(청록 굵게 — 메일 목록과 같다)
// - 임시보관함의 제목 없는 저장본은 메일 줄처럼 흐린 "(제목 없음)" (리뷰 L5)
// 끌어다 놓기(15단계 — 디자인 원본 ListDrag15): 줄을 메일함 패널로 끌어 옮긴다 — 고른 줄이면 고른 것 모두, 아니면 이 줄만.
// 그 대화에서 이 메일함에 든 메일을 옮기고(붙잡힌 메일은 빼고), 그런 메일뿐인 줄은 끌 수 없다. 줄의 링크가 끌어 가던 메일 주소 대신 우리 끌기 자료만 싣는다 (useMailRowDrag)
import { computed } from 'vue'
import type { RouteLocationRaw } from 'vue-router'
import AppIcon from '@/components/AppIcon.vue'
import MailCheckbox from '@/components/mail/MailCheckbox.vue'
import { useMailRowDrag } from '@/composables/useMailDrag'
import { MAIL_BULK_TEXT, MAIL_LIST_ROW_TEXT, MAIL_NO_SUBJECT } from '@/shared/constants/mail'
import { MAIL_CONVERSATION_LIST_TEXT } from '@/shared/constants/mail-conversation'
import { SEND_LATER_TEXT } from '@/shared/constants/mail-send-later'
@@ -55,6 +58,8 @@ const props = withDefaults(defineProps<Props>(), {
const emit = defineEmits<Emits>()
const drag = useMailRowDrag()
const subject = computed(() => props.row.latest.subject || MAIL_NO_SUBJECT)
const countText = computed(() => conversationCountText(props.row))
/** 마지막 메일이 붙잡아 둔 발송이면 "예약 · …" · "보내는 중" (9단계) */
@@ -85,7 +90,10 @@ function onStar() {
{ 'is-selected': selected, 'is-unread': unread, 'is-checked': checked, 'is-checking': checking },
]"
:data-row-id="row.id"
:draggable="!held"
@mousedown="onMouseDown"
@dragstart="(event: DragEvent) => drag.start(event, row.id)"
@dragend="drag.end"
>
<MailCheckbox
class="thread-row__check"
@@ -10,6 +10,8 @@
// - [답장]은 읽기 화면의 [답장]과 같은 쓰기 화면이고, 임시 저장본 · 예약한 메일에는 없다. [이 메일만 열기]는 그 메일을 읽기 화면으로 연다(mail-conversation.ts) —
// 분할 보기는 기록에 쌓고, 목록 보기는 이전 · 다음처럼 바꿔 끼운다([목록으로]가 쌓인 기록으로 돌아가지 않게)
// - 본문 안에서 누른 Ctrl+P(맥 ⌘P)는 이 메일의 인쇄 화면을 연다(읽기 화면과 같은 판정 — useMailPrint)
// - [모두 펼치기](15단계)로 펼친 줄은 본문을 바로 받지 않는다 — 이 안(data-conversation-panel)이 화면에 들어오면 칸이 스토어에 알려 차례로 받는다.
// 받기 전에는 받는 중과 같은 뼈대(aria-busy)다 (useConversationPanelVisibility)
import { computed } from 'vue'
import AppIcon from '@/components/AppIcon.vue'
@@ -70,6 +72,7 @@ function onFrameKeydown(event: KeyboardEvent) {
<div
:id="panelId"
class="conversation-panel"
:data-conversation-panel="row.message.id"
>
<p
v-if="recipients"
@@ -6,6 +6,9 @@
// - 붙잡힌 메일(보내기 취소 · 예약 — 9단계)은 시각 자리에 목록과 같은 "보내는 중" · "예약 · …"(청록 굵게)
// - 안 읽은 메일(row.unread — 2026-11-07)은 목록 줄(MessageListItem)과 같은 굵기 · 색: 이름 650, 미리 보기 · 시각 600(목록 줄의 제목 · 시각 자리).
// 화면 낭독은 이름 앞에 목록과 같은 "안 읽음, "(MAIL_LIST_ROW_TEXT). 지금 보는 메일의 줄도 같다
// - 펼친 줄 머리 오른쪽 [답장](15단계 — 디자인 원본 Reader15 "6", 36 — 안내 줄 단추 크기): 긴 메일을 끝까지 내리지 않고 답장한다.
// 줄 머리 전체가 펼침 단추라 머리를 감싸는 틀(마우스를 올린 바탕은 틀에) 안에 펼침 단추와 [답장]을 나란히 둔다(단추 안에 단추를 넣지 않는다).
// 펼친 안 아래의 [답장] · [이 메일만 열기]는 그대로다. 임시 저장본 · 예약한 메일 · 그사이 지워진 메일의 줄에는 없다(아래 [답장]과 같다)
// 뱃지 · 알약 없이 글로만 보인다. 검색 강조는 넣지 않는다
import { computed } from 'vue'
@@ -39,6 +42,13 @@ const emit = defineEmits<Emits>()
/** 펼친 안의 id — 메일 id 는 서버의 불투명한 글이라 그대로 붙인다 */
const panelId = computed(() => `conversation-panel-${props.row.message.id}`)
/** 펼친 줄 머리의 [답장] — 답장할 수 있는 메일이고(row.replyLocation) 그사이 지워지지 않았을 때 (펼친 안 아래 [답장]과 같은 조건) */
const headReply = computed(() => {
if (!props.expanded || !props.row.replyLocation) return null
const detail = props.detail
if (detail?.status === 'error' && detail.error.code === 'RES_001') return null
return props.row.replyLocation
})
</script>
<template>
@@ -69,39 +79,49 @@ const panelId = computed(() => `conversation-panel-${props.row.message.id}`)
</div>
<template v-else>
<button
type="button"
class="conversation-row__line"
:aria-expanded="expanded"
:aria-controls="expanded ? panelId : undefined"
data-conversation-toggle
@click="emit('toggle', row.message.id)"
>
<AppIcon
:name="expanded ? 'chevron-down' : 'chevron-right'"
:stroke-width="2"
class="conversation-row__chevron"
/>
<span
:class="['conversation-row__avatar', { 'conversation-row__avatar--mine': row.sender.mine }]"
aria-hidden="true"
>{{ row.sender.initial }}</span>
<span class="conversation-row__who">
<div :class="['conversation-row__head', { 'conversation-row__head--reply': headReply }]">
<button
type="button"
class="conversation-row__line"
:aria-expanded="expanded"
:aria-controls="expanded ? panelId : undefined"
data-conversation-toggle
@click="emit('toggle', row.message.id)"
>
<AppIcon
:name="expanded ? 'chevron-down' : 'chevron-right'"
:stroke-width="2"
class="conversation-row__chevron"
/>
<span
v-if="row.unread"
class="visually-hidden"
>{{ MAIL_LIST_ROW_TEXT.unreadPrefix }}</span>{{ row.sender.name }}
</span>
<span
v-if="row.sender.mailboxName"
class="conversation-row__mailbox"
>{{ row.sender.mailboxName }}</span>
<span class="conversation-row__preview">{{ row.preview }}</span>
<time
:class="['conversation-row__time', { 'is-pending': row.pending }]"
:datetime="row.timeValue"
>{{ row.time }}</time>
</button>
:class="['conversation-row__avatar', { 'conversation-row__avatar--mine': row.sender.mine }]"
aria-hidden="true"
>{{ row.sender.initial }}</span>
<span class="conversation-row__who">
<span
v-if="row.unread"
class="visually-hidden"
>{{ MAIL_LIST_ROW_TEXT.unreadPrefix }}</span>{{ row.sender.name }}
</span>
<span
v-if="row.sender.mailboxName"
class="conversation-row__mailbox"
>{{ row.sender.mailboxName }}</span>
<span class="conversation-row__preview">{{ row.preview }}</span>
<time
:class="['conversation-row__time', { 'is-pending': row.pending }]"
:datetime="row.timeValue"
>{{ row.time }}</time>
</button>
<RouterLink
v-if="headReply"
class="conversation-row__reply"
:to="headReply"
data-conversation-head-reply
>
{{ MAIL_CONVERSATION_TEXT.reply }}
</RouterLink>
</div>
<ConversationMessagePanel
v-if="expanded"
:panel-id="panelId"
@@ -132,10 +152,47 @@ const panelId = computed(() => `conversation-panel-${props.row.message.id}`)
text-align: left;
}
button.conversation-row__line:hover {
/* 다른 메일의 머리 — 펼침 단추와 [답장]을 감싸고, 마우스를 올린 바탕은 머리 전체에 (디자인 원본 Reader15 "6") */
.conversation-row__head {
display: flex;
align-items: center;
background: var(--color-surface);
}
.conversation-row__head:hover {
background: var(--color-surface-panel);
}
.conversation-row__head .conversation-row__line {
flex: 1 1 auto;
min-width: 0;
background: transparent;
}
/* [답장]이 있으면 단추 오른쪽 안쪽을 줄인다 — [답장]의 안쪽 여백이 시각과 사이를 둔다 */
.conversation-row__head--reply .conversation-row__line {
padding-right: var(--space-6);
}
/* 펼친 줄 머리의 [답장] — 36 글자 단추(안내 줄 단추의 밀도 예외 재사용), 펼친 안 아래의 [답장]과 같은 모양 */
.conversation-row__reply {
flex: 0 0 auto;
display: inline-flex;
align-items: center;
min-height: var(--size-notice-action);
margin-right: var(--space-4);
padding: 0 var(--space-10);
border-radius: var(--radius-md);
color: var(--color-brand);
font-weight: 600;
text-decoration: none;
}
.conversation-row__reply:hover {
background: var(--color-brand-subtle);
color: var(--color-brand-strong);
}
/* 상자가 넘치는 것을 자르므로(모서리) 고리를 안쪽에 그린다 */
button.conversation-row__line:focus-visible {
outline-offset: calc(var(--focus-ring-width) * -1);
@@ -6,11 +6,14 @@
// - 줄의 메일이 모두 보내기를 기다리는 메일이면 고르기 · 별을 쓸 수 없다(aria-disabled + 까닭은 title)
// - 임시보관함의 제목 없는 저장본은 메일 표처럼 흐린 "(제목 없음)" (리뷰 L5)
// 칸 폭 · 칸 공통 여백은 표(MessageTable)가 정한다.
// 끌어다 놓기(15단계 — 디자인 원본 ListDrag15): 줄을 메일함 패널로 끌어 옮긴다 — 고른 줄이면 고른 것 모두, 아니면 이 줄만.
// 그 대화에서 이 메일함에 든 메일을 옮기고(붙잡힌 메일은 빼고), 그런 메일뿐인 줄은 끌 수 없다. 줄의 링크가 끌어 가던 메일 주소 대신 우리 끌기 자료만 싣는다 (useMailRowDrag)
import { computed } from 'vue'
import type { RouteLocationRaw } from 'vue-router'
import AppIcon from '@/components/AppIcon.vue'
import MailCheckbox from '@/components/mail/MailCheckbox.vue'
import { useMailRowDrag } from '@/composables/useMailDrag'
import { MAIL_BULK_TEXT, MAIL_LIST_ROW_TEXT, MAIL_NO_SUBJECT } from '@/shared/constants/mail'
import { MAIL_CONVERSATION_LIST_TEXT } from '@/shared/constants/mail-conversation'
import { SEND_LATER_TEXT } from '@/shared/constants/mail-send-later'
@@ -47,6 +50,8 @@ const props = withDefaults(defineProps<Props>(), {
const emit = defineEmits<Emits>()
const drag = useMailRowDrag()
const subject = computed(() => props.row.latest.subject || MAIL_NO_SUBJECT)
const countText = computed(() => conversationCountText(props.row))
/** 마지막 메일이 붙잡아 둔 발송이면 "예약 · …" · "보내는 중" (9단계) */
@@ -80,8 +85,11 @@ function onStar() {
<tr
:class="['thread-table-row', { 'is-unread': unread, 'is-checked': checked }]"
:data-row-id="row.id"
:draggable="!held"
@click="onRowClick"
@mousedown="onMouseDown"
@dragstart="(event: DragEvent) => drag.start(event, row.id)"
@dragend="drag.end"
>
<td class="mail-table__check">
<!-- 폭 32px · 행 높이를 채우는 고르기 상자 — 44px 기준의 의도된 예외 (메일 표와 같다) -->
@@ -3,17 +3,21 @@
// 메뉴는 편집기 메뉴(EditorMenu — 항목 높이 40, 메뉴 항목 밀도 예외)를 같이 쓴다. 키보드로 열면(Enter · Space · 아래 화살표) 첫 항목에 초점,
// 마우스로 열면 메뉴 자체에 초점(EditorPopup). Esc 는 메뉴만 닫고 이 단추로 돌아간다 — EditorPopup 이 Esc 를 막아 목록 칸의 "선택 풀기"까지 가지 않는다.
// 받은 속성(data-tool · tabindex — 도구 줄의 로빙)은 단추에 붙인다.
// 15단계(디자인 원본 Reader15): 옮길 곳은 메일함 패널처럼 들여 쓴 나무(mail-move-menu.ts — 읽기 화면의 [이동 ▾]와 같은 모양 · 같은 규칙).
// 고른 메일이 든 메일함에 하위 메일함이 있으면 고를 수 없는 흐린 줄로 남긴다("고른 메일이 있는 메일함"). 메뉴가 화면 밖으로 넘치면 좌우를 바꿔 연다(EditorPopup)
import { computed, ref } from 'vue'
import EditorMenu from '@/components/mail/editor/EditorMenu.vue'
import type { EditorMenuItem } from '@/components/mail/editor/editor-menu'
import MailBulkButton from '@/components/mail/MailBulkButton.vue'
import { moveMenuItems } from '@/components/mail/mail-move-menu'
import { MAIL_BULK_TEXT } from '@/shared/constants/mail'
import type { Mailbox } from '@/shared/types/mail'
import { hasMoveTarget, type MailMoveTarget } from '@/shared/utils/mail-move-targets'
interface Props {
/** 옮길 수 있는 메일함 — 지금 메일함 · 임시보관함 · 휴지통을 뺀 메일함 */
mailboxes: readonly Mailbox[]
/** 옮길 곳 — 메일함 패널 차례 · 깊이, 고른 메일이 든 메일함 · 임시보관함 · 휴지통은 빼거나 고를 수 없는 줄로 (mail-move-targets.ts) */
mailboxes: readonly MailMoveTarget[]
labelMode: 'always' | 'wide' | 'never'
disabled?: boolean
}
@@ -35,10 +39,10 @@ const open = ref<boolean>(false)
const byKeyboard = ref<boolean>(false)
const trigger = computed<HTMLElement | null>(() => buttonRef.value?.element ?? null)
const items = computed<EditorMenuItem[]>(() => props.mailboxes.map((mailbox) => ({ id: mailbox.id, label: mailbox.name })))
const items = computed<EditorMenuItem[]>(() => moveMenuItems(props.mailboxes, MAIL_BULK_TEXT.moveHereChecked))
function openMenu(keyboard: boolean) {
if (props.disabled || items.value.length === 0) return
if (props.disabled || !hasMoveTarget(props.mailboxes)) return
byKeyboard.value = keyboard
open.value = true
}
@@ -64,10 +68,11 @@ function onClose(restoreFocus: boolean) {
}
function onSelect(id: string) {
const target = props.mailboxes.find((row) => row.mailbox.id === id && !row.disabled)
if (!target) return
open.value = false
trigger.value?.focus()
const target = props.mailboxes.find((mailbox) => mailbox.id === id)
if (target) emit('move', target)
emit('move', target.mailbox)
}
</script>
@@ -88,6 +93,7 @@ function onSelect(id: string) {
/>
<EditorMenu
v-if="open"
class="bulk-move__menu"
:label="MAIL_BULK_TEXT.moveMenu"
:items="items"
:trigger="trigger"
@@ -103,4 +109,13 @@ function onSelect(id: string) {
.bulk-move {
position: relative;
}
/* 옮길 메일함 메뉴 — 이름 길이만큼(폭 200 부터 320 까지), 그보다 긴 이름은 줄을 바꾼다 (디자인 원본 Reader15) */
.bulk-move__menu {
width: max-content;
min-width: 12.5rem;
/* 좁은 화면에서는 단추의 어느 끝에 맞춰도 화면 안에 들게 — 폭 360 이면 280 까지 */
max-width: min(20rem, calc(100vw - 5rem));
overflow-wrap: anywhere;
}
</style>
@@ -0,0 +1,82 @@
<script setup lang="ts">
// 목록 줄을 끄는 동안 마우스를 따라오는 표 — "메일 3통" · "대화 1개" (15단계 — 디자인 원본 ListDrag15 "끌어다 놓는 동안의 모양")
// 알림과 같은 어두운 바탕(#0B3640)에 12.5px 굵은 흰 글자, 봉투(메일) · 말풍선(대화) 아이콘. 마우스 오른쪽 아래에 붙는다.
// 브라우저는 끌기를 시작하는 순간 그려진 요소의 모습을 찍는다(setDragImage) — 그래서 메일 화면(MailLayout)에 늘 그려 두되 화면 밖에 두고,
// 수 · 아이콘은 끌기를 시작할 때 useMailDrag 가 이 요소에 바로 적는다(화면 갱신을 기다리면 늦다). 화면 낭독에는 읽히지 않는다(aria-hidden)
import { onBeforeUnmount, onMounted, ref } from 'vue'
import AppIcon from '@/components/AppIcon.vue'
import { registerMailDragChip } from '@/composables/useMailDrag'
const hostRef = ref<HTMLElement | null>(null)
onMounted(() => {
registerMailDragChip(hostRef.value)
})
onBeforeUnmount(() => {
registerMailDragChip(null)
})
</script>
<template>
<div
ref="hostRef"
class="drag-chip-host"
aria-hidden="true"
data-kind="messages"
data-mail-drag-chip
>
<span class="drag-chip">
<AppIcon
name="mail"
:size="14"
:stroke-width="2"
class="drag-chip__icon drag-chip__icon--messages"
/>
<AppIcon
name="message-square"
:size="14"
:stroke-width="2"
class="drag-chip__icon drag-chip__icon--conversations"
/>
<span data-drag-chip-text />
</span>
</div>
</template>
<style scoped>
/*
* 화면 밖에 그려 둔다 — 보이지 않아야 하지만 그려져 있어야 브라우저가 모습을 찍는다(display: none 이면 찍히지 않는다).
* 요소의 왼쪽 위가 마우스 자리다 — 표는 그 안에서 오른쪽 16 · 아래 22 만큼 비켜 둔다 (디자인 원본 ListDrag15)
*/
.drag-chip-host {
position: fixed;
top: 0;
left: -100rem;
padding: var(--space-22) 0 0 var(--space-16);
pointer-events: none;
}
.drag-chip {
display: inline-flex;
align-items: center;
gap: var(--space-6);
/* 위아래 5 · 오른쪽 10 · 왼쪽 8 (디자인 원본 ListDrag15) */
padding: 0.3125rem var(--space-10) 0.3125rem var(--space-8);
border-radius: var(--radius-md);
background: var(--color-brand-surface);
color: var(--color-surface);
font-size: var(--font-size-label);
font-weight: 600;
line-height: var(--line-height-tight);
white-space: nowrap;
box-shadow: var(--shadow-toast-strong);
}
/* 끄는 것에 맞는 아이콘만 — 메일 줄은 봉투, 대화 줄은 말풍선 */
.drag-chip-host[data-kind='messages'] .drag-chip__icon--conversations,
.drag-chip-host[data-kind='conversations'] .drag-chip__icon--messages {
display: none;
}
</style>
@@ -0,0 +1,422 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { mount, type DOMWrapper, type VueWrapper } from '@vue/test-utils'
import { createPinia, setActivePinia, type Pinia } from 'pinia'
import { defineComponent, h } from 'vue'
import { endMailDrag, MAIL_DRAG_TYPE } from '@/composables/useMailDrag'
import type { ConversationSummary, Mailbox, MessagesPatch, MessageSummary } from '@/shared/types/mail'
import { useAuthStore } from '@/stores/auth.store'
import { useMailStore } from '@/stores/mail.store'
import { useToastStore } from '@/stores/toast.store'
import { dragEvent, fakeDataTransfer, type FakeDataTransfer } from '@/test-utils/drag'
import { flushPromises, installMockApi, ok, restoreApiAdapter, type MockRequest } from '@/test-utils/mock-api'
import { createTestRouter } from '@/test-utils/router'
import MailboxPanel from './MailboxPanel.vue'
import MailDragChip from './MailDragChip.vue'
import MessageList from './MessageList.vue'
import MessageTable from './MessageTable.vue'
// 15단계 — 목록 줄을 메일함 패널로 끌어다 놓기 · 목록 [이동 ▾]의 들여 쓴 나무 · 스팸메일함으로 옮기는 세 길 (디자인 원본 ListDrag15 · Reader15)
function box(id: string, role: Mailbox['role'], name: string, partial: Partial<Mailbox> = {}): Mailbox {
return { id, name, role, parentId: null, sortOrder: 0, totalCount: 3, unreadCount: 0, ...partial }
}
const MAILBOXES: Mailbox[] = [
box('inbox', 'inbox', '받은편지함'),
box('sent', 'sent', '보낸편지함'),
box('drafts', 'drafts', '임시보관함'),
box('junk', 'junk', '스팸메일함'),
box('trash', 'trash', '휴지통'),
box('clients', null, '거래처'),
box('quotes', null, '견적', { parentId: 'clients' }),
box('project', null, '프로젝트', { sortOrder: 1 }),
box('project-quotes', null, '견적', { parentId: 'project' }),
]
function summary(id: string, mailboxId = 'inbox', partial: Partial<MessageSummary> = {}): MessageSummary {
return {
id,
threadId: `t-${id}`,
mailboxIds: [mailboxId],
from: { name: '박지훈', email: 'jihun.park@example.com' },
to: [],
subject: `견적 조건 문의드립니다 ${id}`,
preview: '',
receivedAt: '2026-11-08T01:00:00Z',
isRead: true,
isFlagged: false,
hasAttachment: false,
size: 100,
pendingSend: null,
...partial,
}
}
function conversation(id: string, messageIds: string[], partial: Partial<ConversationSummary> = {}): ConversationSummary {
return {
id,
latest: summary(messageIds[0] ?? id),
participants: [{ name: '박지훈', email: 'jihun.park@example.com' }],
messageCount: 3,
messageIds,
pendingSendIds: [],
isRead: true,
isFlagged: false,
hasAttachment: false,
...partial,
}
}
describe('목록 줄 끌어다 놓기 · 목록 [이동 ▾] (15단계)', () => {
let pinia: Pinia
let requests: MockRequest[]
const mounted: VueWrapper[] = []
beforeEach(() => {
pinia = createPinia()
setActivePinia(pinia)
useAuthStore().markAuthenticated({ email: 'ara.kim@arasystem.it.kr', name: '김아라' })
})
afterEach(() => {
for (const wrapper of mounted.splice(0)) wrapper.unmount()
// 끌기 자료는 탭(모듈)에 하나다 — 놓지 않고 끝난 시험의 끌기가 다음 시험에 남지 않게
endMailDrag()
useToastStore().clear()
restoreApiAdapter()
document.body.innerHTML = ''
})
function serve(messages: MessageSummary[], conversations: ConversationSummary[] = []) {
requests = installMockApi((request) => {
if (request.url === '/mailboxes') return ok({ items: MAILBOXES, pageInfo: { hasNext: false, nextCursor: null, total: 9 } })
if (request.url === '/messages' && request.method === 'GET') {
return ok({ items: messages, pageInfo: { hasNext: false, nextCursor: null, total: messages.length } })
}
if (request.url === '/conversations') {
return ok({
items: conversations,
pageInfo: { hasNext: false, nextCursor: null, total: conversations.length },
basis: 'index',
})
}
if (request.method === 'PATCH' && request.url === '/messages') {
return ok({ updatedIds: (request.data as MessagesPatch).ids, failedIds: [] })
}
return ok({ usedBytes: 0, limitBytes: null, attachmentMaxBytes: 1, attachmentTotalMaxBytes: 1 })
})
}
/** 메일 화면처럼 — 메일함 패널 · 목록 · 끄는 동안의 표 */
async function mountScreen(
options: {
boxId?: string
messages?: MessageSummary[]
conversations?: ConversationSummary[]
view?: 'split' | 'list'
} = {},
) {
const boxId = options.boxId ?? 'inbox'
const messages = options.messages ?? [summary('m1'), summary('m2'), summary('m3')]
serve(messages, options.conversations)
const store = useMailStore()
store.mailboxes = MAILBOXES
store.mailboxesStatus = 'ready'
if (options.conversations) {
store.listSource = { kind: 'conversations', mailboxId: boxId }
store.conversations = options.conversations
} else {
store.currentMailboxId = boxId
store.messages = messages
}
store.pageInfo = { hasNext: false, nextCursor: null, total: options.conversations?.length ?? messages.length }
store.listStatus = 'ready'
const router = createTestRouter()
await router.push(`/mail/${boxId}`)
await router.isReady()
const List = options.view === 'list' ? MessageTable : MessageList
const Screen = defineComponent({
setup: () => () => h('div', [h(MailboxPanel), h(List), h(MailDragChip)]),
})
const wrapper = mount(Screen, { global: { plugins: [pinia, router] }, attachTo: document.body })
mounted.push(wrapper)
await flushPromises()
return { wrapper, store, router }
}
const patches = () =>
requests.filter((request) => request.method === 'PATCH' && request.url === '/messages').map((request) => request.data)
const toastTexts = () => useToastStore().toasts.map((toast) => toast.message)
function panelRow(wrapper: VueWrapper, name: string, index = 0): DOMWrapper<Element> {
const rows = wrapper.findAll('.mailbox-row').filter((row) => row.get('.mailbox-row__name').text() === name)
const row = rows[index]
if (!row) throw new Error(`${name} 줄이 없다`)
return row
}
function listRow(wrapper: VueWrapper, id: string): DOMWrapper<Element> {
const row = wrapper.find(`[data-row-id="${id}"]`)
if (!row.exists()) throw new Error(`${id} 줄이 없다`)
return row
}
/** 줄을 끌기 시작한다 — 줄 안의 링크에서 시작해도 줄이 받는다 (브라우저는 링크의 주소를 먼저 실어 둔다) */
function startDrag(row: DOMWrapper<Element>): FakeDataTransfer {
const transfer = fakeDataTransfer()
transfer.setData('text/uri-list', 'http://localhost/mail/inbox/m2')
transfer.setData('text/plain', 'http://localhost/mail/inbox/m2')
const origin = row.find('a').exists() ? row.get('a').element : row.element
origin.dispatchEvent(dragEvent('dragstart', transfer))
return transfer
}
/** 패널 줄 위로 들어와 머문다 — dragover 를 받았는가(놓을 수 있는가) */
function hover(row: DOMWrapper<Element>, transfer: FakeDataTransfer): boolean {
row.element.dispatchEvent(dragEvent('dragenter', transfer))
const over = dragEvent('dragover', transfer)
row.element.dispatchEvent(over)
return over.defaultPrevented
}
async function dropOn(row: DOMWrapper<Element>, transfer: FakeDataTransfer) {
hover(row, transfer)
row.element.dispatchEvent(dragEvent('drop', transfer))
await flushPromises()
}
describe('끄는 것과 끄는 동안의 모양', () => {
it('줄을 끌면 우리 끌기 자료만 싣는다 — 링크의 메일 주소는 지우고, 마우스를 따라오는 표는 "메일 1통"', async () => {
const { wrapper } = await mountScreen()
const transfer = startDrag(listRow(wrapper, 'm2'))
expect(transfer.types).toEqual([MAIL_DRAG_TYPE])
expect(transfer.getData(MAIL_DRAG_TYPE)).not.toBe('')
expect(transfer.effectAllowed).toBe('move')
const chip = wrapper.get('[data-mail-drag-chip]')
expect(transfer.dragImage).toBe(chip.element)
expect(chip.text()).toBe('메일 1통')
expect(chip.attributes('data-kind')).toBe('messages')
expect(chip.attributes('aria-hidden')).toBe('true')
// 끈 줄은 흐리게 하지 않는다 — 줄은 그대로
expect(listRow(wrapper, 'm2').classes()).toEqual(['message-row'])
})
it('놓을 수 있는 메일함 위에서는 점선 · 옅은 청록(is-drop-target), 나가면 지운다 — 지금 메일함 · 임시보관함 · 「예약됨」은 받지 않는다', async () => {
const { wrapper, store } = await mountScreen()
store.scheduledCount = 1
await flushPromises()
const transfer = startDrag(listRow(wrapper, 'm2'))
const clients = panelRow(wrapper, '거래처')
expect(hover(clients, transfer)).toBe(true)
expect(transfer.dropEffect).toBe('move')
await flushPromises()
expect(clients.classes()).toContain('is-drop-target')
clients.element.dispatchEvent(dragEvent('dragleave', transfer))
await flushPromises()
expect(clients.classes()).not.toContain('is-drop-target')
for (const name of ['받은편지함', '임시보관함']) {
const row = panelRow(wrapper, name)
expect(hover(row, transfer)).toBe(false)
await flushPromises()
expect(row.classes()).not.toContain('is-drop-target')
}
const scheduled = wrapper.get('[data-scheduled-mailbox]')
const over = dragEvent('dragover', transfer)
scheduled.element.dispatchEvent(over)
expect(over.defaultPrevented).toBe(false)
// 휴지통은 놓을 수 있다 — [삭제]와 같다
expect(hover(panelRow(wrapper, '휴지통'), transfer)).toBe(true)
})
it('파일 끌기는 메일함 패널이 받지 않는다 (쓰기 화면의 첨부 놓기와 겹치지 않게)', async () => {
const { wrapper } = await mountScreen()
const files = fakeDataTransfer([new File(['x'], '보고서.pdf')])
expect(hover(panelRow(wrapper, '거래처'), files)).toBe(false)
})
it('보내기를 기다리는 메일의 줄은 끌 수 없다 — draggable 이 아니고 링크를 끌어도 막는다', async () => {
const held = summary('m9', 'sent', { pendingSend: { kind: 'scheduled', sendAt: '2026-11-10T00:00:00Z' } })
const { wrapper } = await mountScreen({ boxId: 'sent', messages: [summary('m1', 'sent'), held] })
expect(listRow(wrapper, 'm9').attributes('draggable')).toBe('false')
expect(listRow(wrapper, 'm1').attributes('draggable')).toBe('true')
const transfer = fakeDataTransfer()
const start = dragEvent('dragstart', transfer)
listRow(wrapper, 'm9').get('a').element.dispatchEvent(start)
expect(start.defaultPrevented).toBe(true)
expect(transfer.types).toEqual([])
})
})
describe('놓으면', () => {
it('내 메일함에 놓으면 [이동 ▾]와 같다 — "메일 1통을 거래처로 옮겼습니다." + [되돌리기]', async () => {
const { wrapper, store } = await mountScreen()
await dropOn(panelRow(wrapper, '거래처'), startDrag(listRow(wrapper, 'm2')))
expect(patches()).toEqual([{ ids: ['m2'], mailboxId: 'clients' }])
expect(store.messages.map((message) => message.id)).toEqual(['m1', 'm3'])
const [toast] = useToastStore().toasts
expect(toast?.message).toBe('메일 1통을 거래처로 옮겼습니다.')
expect(toast?.action && 'label' in toast.action ? toast.action.label : null).toBe('되돌리기')
expect(panelRow(wrapper, '거래처').classes()).not.toContain('is-drop-target')
})
it('같은 이름의 메일함이 또 있으면 위 메일함 이름을 붙인다 — "메일 1통을 프로젝트 › 견적으로 옮겼습니다."', async () => {
const { wrapper } = await mountScreen()
await dropOn(panelRow(wrapper, '견적', 1), startDrag(listRow(wrapper, 'm1')))
expect(patches()).toEqual([{ ids: ['m1'], mailboxId: 'project-quotes' }])
expect(toastTexts()).toEqual(['메일 1통을 프로젝트 › 견적으로 옮겼습니다.'])
})
it('고른 줄을 끌면 고른 것 모두 — 표는 "메일 2통", 휴지통에 놓으면 [삭제]와 같고 고른 것을 푼다', async () => {
const { wrapper, store } = await mountScreen()
store.toggleCheck('m1')
store.toggleCheck('m3')
await flushPromises()
const transfer = startDrag(listRow(wrapper, 'm3'))
expect(wrapper.get('[data-mail-drag-chip]').text()).toBe('메일 2통')
await dropOn(panelRow(wrapper, '휴지통'), transfer)
expect(patches()).toEqual([{ ids: ['m1', 'm3'], mailboxId: 'trash' }])
expect(toastTexts()).toEqual(['메일 2통을 휴지통으로 옮겼습니다.'])
expect(store.checkedCount).toBe(0)
})
it('고르지 않은 줄을 끌면 그 줄만 — 고른 것은 그대로 둔다', async () => {
const { wrapper, store } = await mountScreen()
store.toggleCheck('m1')
await flushPromises()
await dropOn(panelRow(wrapper, '거래처'), startDrag(listRow(wrapper, 'm2')))
expect(patches()).toEqual([{ ids: ['m2'], mailboxId: 'clients' }])
expect([...store.checkedIds]).toEqual(['m1'])
})
it('스팸메일함에 놓으면 [스팸 신고]와 같다 — 스팸 표시를 함께, [되돌리기]는 표시도 되돌린다', async () => {
const { wrapper } = await mountScreen({ view: 'list' })
await dropOn(panelRow(wrapper, '스팸메일함'), startDrag(listRow(wrapper, 'm2')))
expect(patches()).toEqual([{ ids: ['m2'], mailboxId: 'junk', isJunk: true }])
const [toast] = useToastStore().toasts
expect(toast?.message).toBe('메일 1통을 스팸메일함으로 옮겼습니다.')
if (toast?.action && 'run' in toast.action) toast.action.run()
await flushPromises()
expect(patches().at(-1)).toEqual({ ids: ['m2'], mailboxId: 'inbox', isJunk: false })
})
it('스팸메일함에서 꺼내 놓으면 [스팸 아님]과 같다 — 휴지통에 놓으면 표시는 그대로', async () => {
const { wrapper } = await mountScreen({ boxId: 'junk', messages: [summary('m1', 'junk'), summary('m2', 'junk')] })
await dropOn(panelRow(wrapper, '거래처'), startDrag(listRow(wrapper, 'm1')))
expect(patches()).toEqual([{ ids: ['m1'], mailboxId: 'clients', isJunk: false }])
expect(toastTexts()).toEqual(['메일 1통을 스팸이 아닌 메일로 표시해 거래처로 옮겼습니다.'])
await dropOn(panelRow(wrapper, '휴지통'), startDrag(listRow(wrapper, 'm2')))
expect(patches().at(-1)).toEqual({ ids: ['m2'], mailboxId: 'trash' })
})
it('대화로 묶은 목록의 줄은 그 대화에서 이 메일함에 든 메일 — 표는 "대화 1개", 알림은 "받은편지함에 있는 메일 2통을 …"', async () => {
const rows = [
conversation('c1', ['m6', 'm5']),
conversation('c2', ['m4', 'm3'], { pendingSendIds: ['m3'] }),
conversation('c3', ['m2'], { pendingSendIds: ['m2'] }),
]
const { wrapper } = await mountScreen({ conversations: rows })
// 보내기를 기다리는 메일뿐인 줄은 끌 수 없다
expect(listRow(wrapper, 'c3').attributes('draggable')).toBe('false')
const transfer = startDrag(listRow(wrapper, 'c1'))
const chip = wrapper.get('[data-mail-drag-chip]')
expect(chip.text()).toBe('대화 1개')
expect(chip.attributes('data-kind')).toBe('conversations')
await dropOn(panelRow(wrapper, '견적'), transfer)
expect(patches()).toEqual([{ ids: ['m6', 'm5'], mailboxId: 'quotes' }])
expect(toastTexts()).toEqual(['받은편지함에 있는 메일 2통을 거래처 › 견적으로 옮겼습니다.'])
// 붙잡힌 메일은 빼고 옮긴다 — [이동 ▾]와 같다
await dropOn(panelRow(wrapper, '거래처'), startDrag(listRow(wrapper, 'c2')))
expect(patches().at(-1)).toEqual({ ids: ['m4'], mailboxId: 'clients' })
})
it('다른 탭 · 바깥에서 끌어 온 같은 종류의 자료는 받지 않는다 (이 탭에서 시작한 끌기만)', async () => {
const { wrapper } = await mountScreen()
const foreign = fakeDataTransfer()
foreign.setData(MAIL_DRAG_TYPE, 'other-tab')
expect(hover(panelRow(wrapper, '거래처'), foreign)).toBe(false)
panelRow(wrapper, '거래처').element.dispatchEvent(dragEvent('drop', foreign))
await flushPromises()
expect(patches()).toEqual([])
})
})
describe('목록 [이동 ▾] — 읽기 화면과 같은 나무 · 같은 규칙', () => {
async function openMoveMenu(wrapper: VueWrapper) {
await wrapper.get('[data-tool="move"]').trigger('keydown', { key: 'ArrowDown' })
await flushPromises()
return wrapper.get('[role="menu"]')
}
it('메일함 패널처럼 들여 쓰고 화면 낭독은 위 메일함부터 — 고른 메일이 든 메일함에 하위가 있으면 흐린 줄 "고른 메일이 있는 메일함"', async () => {
const { wrapper, store } = await mountScreen({
boxId: 'clients',
messages: [summary('m1', 'clients'), summary('m2', 'clients')],
view: 'list',
})
store.toggleCheck('m1')
await flushPromises()
const menu = await openMoveMenu(wrapper)
const items = menu.findAll('[role="menuitem"]')
expect(items.map((item) => item.attributes('aria-label') ?? item.find('.menu__label').text())).toEqual([
'받은편지함',
'보낸편지함',
'스팸메일함',
'거래처',
'거래처 안의 견적',
'프로젝트',
'프로젝트 안의 견적',
])
const here = items[3]
expect(here?.attributes('aria-disabled')).toBe('true')
expect(here?.get('.menu__note').text()).toBe('고른 메일이 있는 메일함')
expect(menu.findAll('hr')).toHaveLength(1)
})
it('스팸메일함을 고르면 [스팸 신고]와 같다 — 스팸 표시를 함께 · 알림의 메일함 이름은 같은 이름이 있을 때만 위 메일함까지', async () => {
const { wrapper, store } = await mountScreen({ view: 'list' })
store.toggleCheck('m1')
await flushPromises()
let menu = await openMoveMenu(wrapper)
await menu.findAll('[role="menuitem"]').find((item) => item.text() === '스팸메일함')?.trigger('click')
await flushPromises()
expect(patches()).toEqual([{ ids: ['m1'], mailboxId: 'junk', isJunk: true }])
expect(toastTexts()).toEqual(['메일 1통을 스팸메일함으로 옮겼습니다.'])
store.toggleCheck('m2')
await flushPromises()
menu = await openMoveMenu(wrapper)
await menu
.findAll('[role="menuitem"]')
.find((item) => item.attributes('aria-label') === '거래처 안의 견적')
?.trigger('click')
await flushPromises()
expect(patches().at(-1)).toEqual({ ids: ['m2'], mailboxId: 'quotes' })
expect(toastTexts().at(-1)).toBe('메일 1통을 거래처 › 견적으로 옮겼습니다.')
})
})
})
@@ -4,6 +4,11 @@
// 사용 예: <MailToolButton icon="reply" label="답장" :to="{ name: 'mail-compose', query: { replyTo: id } }" />
// 누를 수 없는 까닭을 알려야 하면 disabled 대신 unavailable(aria-disabled — 초점은 받고 누르면 아무것도 하지 않는다) + describedby(까닭 글의 id).
// 하는 중이면 busy(aria-busy — 누르면 아무것도 하지 않는다)
// 15단계(디자인 원본 Reader15 "2"):
// - collapsible: 읽기 칸이 좁으면 이름을 숨기고 아이콘만(40×44) 둘 수 있는 단추 — 숨기는 때는 읽기 화면이 정한다(MessageReader 의 컨테이너 쿼리).
// 이름은 숨겨도 화면 낭독(aria-label)과 마우스를 올리면 뜨는 글(title)로 읽힌다
// - menu: 메뉴를 여는 단추 — 이름 뒤 ▾ (아이콘만일 때도 ▾ 는 남긴다 — [이동 ▾])
import { ref } from 'vue'
import type { RouteLocationRaw } from 'vue-router'
import AppIcon from '@/components/AppIcon.vue'
@@ -26,6 +31,10 @@ interface Props {
/** 까닭 · 설명 글의 id — aria-describedby */
describedby?: string
iconStrokeWidth?: number
/** 좁은 읽기 칸에서 아이콘만 둘 수 있다 (15단계) — 이름은 aria-label · title 로도 */
collapsible?: boolean
/** 메뉴를 여는 단추 — 이름 뒤 ▾ */
menu?: boolean
}
interface Emits {
@@ -41,33 +50,46 @@ const props = withDefaults(defineProps<Props>(), {
busy: false,
describedby: undefined,
iconStrokeWidth: 1.75,
collapsible: false,
menu: false,
})
const emit = defineEmits<Emits>()
const buttonRef = ref<HTMLButtonElement | null>(null)
function onButtonClick(event: MouseEvent) {
if (props.unavailable || props.busy) return
emit('click', event)
}
defineExpose({
/** 단추 요소(단추일 때) — 메뉴의 바깥 누름 판정 · 초점 되돌리기에 쓴다 */
element: buttonRef,
})
</script>
<template>
<RouterLink
v-if="to"
:to="to"
:class="['tool-button', `tool-button--${tone}`]"
:class="['tool-button', `tool-button--${tone}`, { 'tool-button--collapsible': collapsible }]"
:aria-label="collapsible ? label : undefined"
:title="collapsible ? label : undefined"
>
<AppIcon
:name="icon"
:size="15"
:stroke-width="iconStrokeWidth"
/>
<span>{{ label }}</span>
<span class="tool-button__label">{{ label }}</span>
</RouterLink>
<a
v-else-if="href"
:href="href"
:class="['tool-button', `tool-button--${tone}`]"
:class="['tool-button', `tool-button--${tone}`, { 'tool-button--collapsible': collapsible }]"
:aria-label="collapsible ? label : undefined"
:title="collapsible ? label : undefined"
@click="emit('click', $event)"
>
<AppIcon
@@ -75,16 +97,23 @@ function onButtonClick(event: MouseEvent) {
:size="15"
:stroke-width="iconStrokeWidth"
/>
<span>{{ label }}</span>
<span class="tool-button__label">{{ label }}</span>
</a>
<button
v-else
ref="buttonRef"
type="button"
:class="['tool-button', `tool-button--${tone}`]"
:class="[
'tool-button',
`tool-button--${tone}`,
{ 'tool-button--collapsible': collapsible, 'tool-button--menu': menu },
]"
:disabled="disabled"
:aria-disabled="unavailable ? 'true' : undefined"
:aria-busy="busy ? 'true' : undefined"
:aria-describedby="describedby"
:aria-label="collapsible ? label : undefined"
:title="collapsible ? label : undefined"
@click="onButtonClick"
>
<AppIcon
@@ -92,7 +121,14 @@ function onButtonClick(event: MouseEvent) {
:size="15"
:stroke-width="iconStrokeWidth"
/>
<span>{{ label }}</span>
<span class="tool-button__label">{{ label }}</span>
<AppIcon
v-if="menu"
name="chevron-down"
:size="12"
:stroke-width="2"
class="tool-button__chevron"
/>
</button>
</template>
@@ -125,6 +161,13 @@ function onButtonClick(event: MouseEvent) {
cursor: progress;
}
/* 메뉴를 여는 단추 — ▾ 쪽 안쪽을 줄인다 (디자인 원본 Reader15 [이동 ▾] — 왼쪽 10 · 오른쪽 8) */
.tool-button--menu {
justify-content: center;
min-width: var(--size-icon-button);
padding-right: var(--space-8);
}
.tool-button--accent {
color: var(--color-brand-strong);
font-weight: 600;
@@ -1,8 +1,10 @@
<script setup lang="ts">
// 메일함 지우기 확인 (디자인 원본 MailboxManage, 공용 모양 ModalDialog — role=alertdialog)
// - 하위 메일함이 있으면 지울 수 없다: "‘프로젝트’ 메일함을 지울 수 없습니다" · "하위 메일함이 있습니다. 하위 메일함을 먼저 지워 주세요." · [확인]
// - 아니면: "‘거래처’ 메일함을 지울까요?" · "안의 메일 12통은 휴지통으로 옮깁니다. 지운 메일함은 되돌릴 수 없습니다."
// 메일함 지우기 확인 (디자인 원본 MailboxManage · Mailbox15, 공용 모양 ModalDialog — role=alertdialog)
// - "‘거래처’ 메일함을 지울까요?" · "안의 메일 12통은 휴지통으로 옮깁니다. 지운 메일함은 되돌릴 수 없습니다."
// (빈 메일함은 "빈 메일함입니다. …") · [지우기](빨강 채움) [취소] — 열리면 「취소」에 초점 (실수로 Enter 를 눌러도 지워지지 않게)
// - 하위 메일함이 있으면 하위까지 지울지 묻는다 (15단계 — 지금까지의 "지울 수 없습니다" 창을 대신한다):
// "‘거래처’ 메일함과 하위 메일함 2개를 지울까요?" · "하위 메일함에 든 메일까지 모두 40통을 휴지통으로 옮깁니다. 휴지통에 들어간 메일은 30일 뒤 …"
// (메일이 하나도 없으면 "하위 메일함까지 모두 비어 있습니다. …"). 하위 메일함 수 · 메일 수는 모든 깊이를 센다(부르는 쪽 — MailboxDialogs)
// - 휴지통을 비우는 동안은 [지우기]를 누를 수 없다 (blocked — 비우기가 옮겨 간 메일까지 영구 삭제하지 않게)
// 고정 id — #mailbox-delete-dialog · #mailbox-delete-title · #mailbox-delete-message · #mailbox-delete-confirm · #mailbox-delete-cancel
import { computed } from 'vue'
@@ -14,10 +16,10 @@ import { formatCount } from '@/shared/utils/format'
interface Props {
open: boolean
mailboxName: string
/** 안의 메일 수 */
/** 휴지통으로 옮길 메일 수 — 하위 메일함이 있으면 하위까지 더한 수 */
messageCount: number
/** 하위 메일함이 있다 — 지울 수 없다고 알린다 */
hasChildren: boolean
/** 모든 깊이의 하위 메일함 수 — 있으면 하위까지 지울지 묻는다 (15단계) */
childCount?: number
/** 지금은 시작할 수 없다 (휴지통을 비우는 중) — [지우기]를 끈다 */
blocked?: boolean
}
@@ -28,15 +30,22 @@ interface Emits {
}
const props = withDefaults(defineProps<Props>(), {
childCount: 0,
blocked: false,
})
const emit = defineEmits<Emits>()
const title = computed(() =>
props.hasChildren ? MAILBOX_DIALOG_TEXT.blockedTitle(props.mailboxName) : MAILBOX_DIALOG_TEXT.deleteTitle(props.mailboxName),
props.childCount > 0
? MAILBOX_DIALOG_TEXT.deleteTreeTitle(props.mailboxName, formatCount(props.childCount))
: MAILBOX_DIALOG_TEXT.deleteTitle(props.mailboxName),
)
const message = computed(() => {
if (props.hasChildren) return MAILBOX_DIALOG_TEXT.blockedMessage
if (props.childCount > 0) {
return props.messageCount > 0
? MAILBOX_DIALOG_TEXT.deleteTreeMessage(formatCount(props.messageCount))
: MAILBOX_DIALOG_TEXT.deleteTreeEmptyMessage
}
return props.messageCount > 0
? MAILBOX_DIALOG_TEXT.deleteMessage(formatCount(props.messageCount))
: MAILBOX_DIALOG_TEXT.deleteEmptyMessage
@@ -56,35 +65,23 @@ const message = computed(() => {
>
<template #actions>
<button
v-if="hasChildren"
id="mailbox-delete-confirm"
type="button"
class="modal-dialog__action modal-dialog__action--danger"
:disabled="blocked"
@click="emit('confirm')"
>
{{ MAILBOX_DIALOG_TEXT.deleteConfirm }}
</button>
<button
id="mailbox-delete-cancel"
type="button"
class="modal-dialog__action"
class="modal-dialog__action modal-dialog__action--secondary"
autofocus
@click="emit('cancel')"
>
{{ MAILBOX_DIALOG_TEXT.blockedConfirm }}
{{ MAILBOX_DIALOG_TEXT.cancel }}
</button>
<template v-else>
<button
id="mailbox-delete-confirm"
type="button"
class="modal-dialog__action modal-dialog__action--danger"
:disabled="blocked"
@click="emit('confirm')"
>
{{ MAILBOX_DIALOG_TEXT.deleteConfirm }}
</button>
<button
id="mailbox-delete-cancel"
type="button"
class="modal-dialog__action modal-dialog__action--secondary"
autofocus
@click="emit('cancel')"
>
{{ MAILBOX_DIALOG_TEXT.cancel }}
</button>
</template>
</template>
</ModalDialog>
</template>
+86 -27
View File
@@ -1,21 +1,24 @@
<script setup lang="ts">
// 메일함 패널의 대화상자 — ⋯ 메뉴 · [+ 새 메일함]이 연다 (디자인 원본 MailboxManage · useMailboxActions)
// 메일함 패널의 대화상자 — ⋯ 메뉴 · [+ 새 메일함]이 연다 (디자인 원본 MailboxManage · Mailbox15 · useMailboxActions)
// - 새 메일함 · 하위 메일함 · 이름 바꾸기: 이름 대화상자. 같은 이름(MAIL_009) · 형식(VAL_001)은 칸 아래에 보이고 대화상자를 둔다.
// 메일함이 그사이 사라졌으면(RES_001 — 알림은 인터셉터) 닫고 메일함 목록을 다시 맞춘다. 그 밖의 실패는 알림을 보고 다시 누를 수 있게 둔다.
// 이름을 바꾸지 않았으면 보내지 않고 닫는다. 기다리는 사이 화면이 사라졌으면 칸 아래에 보일 실패를 알림으로 알린다 (notifyApiError)
// - 지우기: 하위 메일함이 있으면 지울 수 없다고 알린다. 확인하면 닫고 지우기를 시작한다 (진행은 알림으로)
// - 옮기기(15단계): 옮길 곳 창. 같은 이름(MAIL_009 — 그사이 다른 창에서 만듦)은 목록 아래에 보이고 창을 둔다. 메일함 · 옮길 곳이 사라졌으면(RES_001) ·
// 옮길 수 없는 자리였으면(VAL_001 — 그사이 나무가 바뀜) 알림(인터셉터)을 보고 목록을 다시 맞춘다 — RES_001 은 창을 닫는다. 지금 있는 곳은 고를 수 없다
// - 지우기: 하위 메일함이 있으면 하위까지 지울지 묻는다(15단계 — 하위 메일함 수 · 메일 수는 모든 깊이). 확인하면 닫고 지우기를 시작한다 (진행은 알림으로)
// - 비우기: 확인하면 닫고 비우기를 시작한다
// - 휴지통 비우기 · 메일함 지우기는 서로 도는 동안 시작할 수 없다 — 확인 단추를 끄고, 누르는 순간에도 다시 본다 (useMailboxActions 의 isBlocked)
import { computed, onBeforeUnmount, ref, watch } from 'vue'
import MailboxDeleteDialog from '@/components/mail/MailboxDeleteDialog.vue'
import MailboxEmptyDialog from '@/components/mail/MailboxEmptyDialog.vue'
import MailboxMoveDialog from '@/components/mail/MailboxMoveDialog.vue'
import MailboxNameDialog from '@/components/mail/MailboxNameDialog.vue'
import type { MailboxDialogRequest } from '@/components/mail/mailbox-dialog'
import { notifyApiError } from '@/composables/useApi'
import { useMailboxActions } from '@/composables/useMailboxActions'
import { useMailboxActions, type MailboxNameResult } from '@/composables/useMailboxActions'
import type { Mailbox } from '@/shared/types/mail'
import { hasChildMailbox } from '@/shared/utils/mailbox'
import { descendantMailboxes, hasChildMailbox, mailboxMoveTargets } from '@/shared/utils/mailbox'
import { useMailStore } from '@/stores/mail.store'
interface Props {
@@ -34,6 +37,8 @@ const actions = useMailboxActions()
const pending = ref<boolean>(false)
const nameError = ref<string | null>(null)
/** 옮기기 창 목록 아래의 거절 (MAIL_009) */
const moveError = ref<string | null>(null)
/** 화면(메일 모듈)이 사라졌다 — 칸 아래에 보일 곳이 없다 */
let unmounted = false
@@ -49,6 +54,16 @@ const target = computed<Mailbox | null>(() => {
})
const parentName = computed(() => (props.request?.kind === 'create' ? (props.request.parent?.name ?? null) : null))
const hasChildren = computed(() => (target.value ? hasChildMailbox(mailStore.mailboxes, target.value.id) : false))
/** 모든 깊이의 하위 메일함 — 지우기 확인 창의 "하위 메일함 2개" · 메일 수 (15단계) */
const descendants = computed(() => (target.value ? descendantMailboxes(mailStore.mailboxes, target.value.id) : []))
/** 지우면 휴지통으로 갈 메일 수 — 하위 메일함까지 더한다 */
const treeMessageCount = computed(() =>
[target.value, ...descendants.value].reduce((sum, mailbox) => sum + Math.max(0, mailbox?.totalCount ?? 0), 0),
)
/** 옮길 곳 — 옮기기 창이 열려 있을 때만 */
const moveTargets = computed(() =>
props.request?.kind === 'move' && target.value ? mailboxMoveTargets(mailStore.mailboxes, target.value) : [],
)
/** 지금은 시작할 수 없다 — 휴지통 비우기 ↔ 메일함 지우기 */
const deleteBlocked = computed(() => (target.value ? actions.isBlocked('delete', target.value) : false))
const emptyBlocked = computed(() => (target.value ? actions.isBlocked('empty', target.value) : false))
@@ -56,9 +71,44 @@ const emptyBlocked = computed(() => (target.value ? actions.isBlocked('empty', t
function close() {
pending.value = false
nameError.value = null
moveError.value = null
emit('close')
}
/**
* 이름 · 옮기기 창의 결과를 받는다 — 칸 안에 보일 실패(inline)는 보여 주고 창을 둔다, 그사이 사라졌으면(RES_001) 닫고 목록을 다시 맞춘다.
* 기다리는 사이 화면이 사라졌으면 칸 안에 보일 실패를 알림으로, 다른 창으로 바뀌었으면 건드리지 않는다
*/
function settleResult(
request: MailboxDialogRequest,
result: MailboxNameResult | null,
showInline: (message: string) => void,
) {
if (unmounted) {
// 칸 아래에 보일 곳이 사라졌다 — 끈 알림을 띄운다 (성공 알림은 이미 떴다)
if (result?.status === 'failed' && result.inline) notifyApiError(result.error)
return
}
// 기다리는 사이 닫았거나 다른 대화상자를 열었으면 그 대화상자를 건드리지 않는다 (결과 알림은 이미 떴다)
if (props.request !== request) return
pending.value = false
if (!result || result.status !== 'failed') {
close()
return
}
if (result.inline) {
showInline(result.error.message)
return
}
if (result.error.code === 'RES_001') {
void mailStore.loadMailboxes({ background: true })
close()
return
}
// 옮길 수 없는 자리(VAL_001 — 그사이 나무가 바뀜)는 알림을 본 뒤 바뀐 나무로 다시 고를 수 있게 목록만 다시 맞춘다
if (request.kind === 'move' && result.error.code === 'VAL_001') void mailStore.loadMailboxes({ background: true })
}
async function onSubmitName(name: string) {
const request = props.request
if (!request || pending.value) return
@@ -73,34 +123,31 @@ async function onSubmitName(name: string) {
: request.kind === 'create'
? await actions.create(name, request.parent?.id ?? null)
: null
if (unmounted) {
// 칸 아래에 보일 곳이 사라졌다 — 끈 알림을 띄운다 (성공 알림은 이미 떴다)
if (result?.status === 'failed' && result.inline) notifyApiError(result.error)
return
}
// 기다리는 사이 닫았거나 다른 대화상자를 열었으면 그 대화상자를 건드리지 않는다 (결과 알림은 이미 떴다)
if (props.request !== request) return
pending.value = false
if (!result || result.status !== 'failed') {
close()
return
}
if (result.inline) {
nameError.value = result.error.message
return
}
if (result.error.code === 'RES_001') {
void mailStore.loadMailboxes({ background: true })
close()
}
settleResult(request, result, (message) => {
nameError.value = message
})
}
async function onSubmitMove(parentId: string | null) {
const request = props.request
const mailbox = target.value
if (!request || request.kind !== 'move' || !mailbox || pending.value) return
pending.value = true
moveError.value = null
const result = await actions.move(mailbox, parentId)
settleResult(request, result, (message) => {
moveError.value = message
})
}
function onConfirmDelete() {
const mailbox = target.value
// 누르는 순간 다시 본다 — 그사이 휴지통 비우기가 시작됐으면 대화상자를 그대로 둔다
if (!mailbox || deleteBlocked.value) return
// 하위 메일함이 있으면 확인 창이 하위까지 지운다고 물었다 — 그때만 하위까지 지운다 (15단계)
const withChildren = hasChildren.value
close()
void actions.remove(mailbox)
void actions.remove(mailbox, { withChildren })
}
function onConfirmEmpty() {
@@ -115,6 +162,7 @@ watch(
() => props.request,
() => {
nameError.value = null
moveError.value = null
},
)
@@ -135,11 +183,22 @@ onBeforeUnmount(() => {
@cancel="close"
@clear-error="nameError = null"
/>
<MailboxMoveDialog
:open="request?.kind === 'move'"
:mailbox-name="target?.name ?? ''"
:has-children="hasChildren"
:targets="moveTargets"
:pending="pending"
:error="moveError"
@submit="onSubmitMove"
@cancel="close"
@clear-error="moveError = null"
/>
<MailboxDeleteDialog
:open="request?.kind === 'delete'"
:mailbox-name="target?.name ?? ''"
:message-count="target?.totalCount ?? 0"
:has-children="hasChildren"
:message-count="treeMessageCount"
:child-count="descendants.length"
:blocked="deleteBlocked"
@confirm="onConfirmDelete"
@cancel="close"
@@ -0,0 +1,371 @@
<script setup lang="ts">
// 메일함 옮기기 창 (15단계 — 디자인 원본 Mailbox15 "옮기기 창" · "그사이 같은 이름이 생겼을 때", 공용 모양 ModalDialog — 폭 400 medium)
// 제목 "‘2025’ 메일함을 어디로 옮길까요?" · 글 "하위 메일함과 안의 메일도 함께 옮깁니다."(하위가 없으면 "안의 메일도 함께 옮깁니다.") →
// 옮길 곳 목록(테두리 칸 · 줄 40 · 메일함 패널과 같은 차례 · 들여쓰기 10 · 28 · 44 · 56): 맨 위 · 받은편지함(과 그 아래) · "내 메일함" 묶음 → [옮기기] [취소]
// - 처음에는 아무 곳도 고르지 않은 채 열리고, 고르기 전에는 [옮기기]를 누를 수 없다. 고른 줄은 청록 바탕 · 굵은 글자 · 오른쪽 체크
// - 고를 수 없는 줄은 이름 · 아이콘만 흐리게(까닭 글은 그대로): 옮기는 메일함과 그 하위(까닭 없음) · 지금 있는 곳 · 같은 이름의 메일함이 있는 곳
// (대소문자 무시 — 서버에 묻기 전에 화면이 하는 검사). 맨 위 줄은 평소 "다른 메일함 안에 두지 않습니다"
// - 줄은 radiogroup 의 radio — 화살표 · Home · End 로 줄을 옮기면(꺼진 줄에도 초점은 간다 — 까닭을 읽게) 고를 수 있는 줄은 바로 고른다.
// Enter 는 [옮기기], Esc 는 [취소]와 같다. 들여 쓴 줄의 이름은 위 메일함부터 읽는다("거래처 안의 2025")
// - 그사이 같은 이름이 생겨 서버가 거절하면(MAIL_009) 목록 아래 빨간 사전 문구 — 알림 없이 창을 둔다. 다른 곳을 고르면 지운다(clear-error)
// - 옮기는 동안(pending)은 [옮기기]를 aria-disabled 로 막는다 — 누른 단추가 초점을 잃지 않게
// 고정 id — #mailbox-move-dialog · #mailbox-move-title · #mailbox-move-message · #mailbox-move-error · #mailbox-move-confirm · #mailbox-move-cancel
import { computed, nextTick, ref, watch } from 'vue'
import AppIcon from '@/components/AppIcon.vue'
import ModalDialog from '@/components/feedback/ModalDialog.vue'
import type { IconName } from '@/shared/constants/icons'
import { MAILBOX_MOVE_TEXT, MAILBOX_PANEL_TEXT } from '@/shared/constants/mailbox-manage'
import { mailboxIcon, mailboxSpokenName, type MailboxMoveTarget } from '@/shared/utils/mailbox'
interface Props {
open: boolean
/** 옮기는 메일함의 이름 */
mailboxName: string
/** 하위 메일함이 있다 — 제목 아래 글이 바뀐다 */
hasChildren: boolean
/** 옮길 곳 (mailbox.ts 의 mailboxMoveTargets) */
targets: readonly MailboxMoveTarget[]
/** 옮기는 중 — [옮기기]를 다시 누를 수 없다 */
pending?: boolean
/** 서버가 거절한 까닭(사전 문구 — MAIL_009). 다른 곳을 고르면 부모가 지운다(clear-error) */
error?: string | null
}
interface Emits {
/** parentId — 새 상위, 맨 위면 null */
(e: 'submit', parentId: string | null): void
(e: 'cancel'): void
(e: 'clear-error'): void
}
const props = withDefaults(defineProps<Props>(), {
pending: false,
error: null,
})
const emit = defineEmits<Emits>()
/** 들여쓰기는 3단계까지 (메일함 패널과 같다) */
const MAX_INDENT_DEPTH = 3
/** 맨 위 줄의 열쇠 — 메일함 id 는 비지 않는다 */
const TOP_KEY = ''
const ERROR_ID = 'mailbox-move-error'
interface MoveRow {
key: string
parentId: string | null
icon: IconName
name: string
/** 화면 낭독 이름 — 들여 쓴 줄은 위 메일함부터 */
spokenName: string
depth: number
/** 이름 오른쪽 작은 글 — 고를 수 없는 까닭 · 맨 위 줄의 설명 */
note: string | null
pickable: boolean
/** 이 줄 앞에 "내 메일함" 묶음 이름을 둔다 */
headingBefore: boolean
}
/** 고를 수 없는 까닭의 글 — 옮기는 메일함 자신 · 하위는 글이 없다 */
const BLOCK_NOTES = {
self: null,
current: MAILBOX_MOVE_TEXT.current,
sameName: MAILBOX_MOVE_TEXT.sameName,
} as const
const listRef = ref<HTMLElement | null>(null)
/** 고른 줄 — 처음에는 없다 */
const chosenKey = ref<string | null>(null)
/** 탭으로 들어올 줄 (로빙 tabindex) — 고른 줄, 없으면 첫 고를 수 있는 줄 */
const focusKey = ref<string>(TOP_KEY)
const message = computed(() => (props.hasChildren ? MAILBOX_MOVE_TEXT.messageWithChildren : MAILBOX_MOVE_TEXT.message))
const rows = computed<MoveRow[]>(() => {
let headingShown = false
return props.targets.map((target) => {
const entry = target.entry
const headingBefore = target.group === 'mine' && !headingShown
if (headingBefore) headingShown = true
const note = target.block ? BLOCK_NOTES[target.block] : entry ? null : MAILBOX_MOVE_TEXT.topNote
return {
key: target.parentId ?? TOP_KEY,
parentId: target.parentId,
icon: entry ? mailboxIcon(entry.mailbox) : 'arrow-up',
name: entry ? entry.mailbox.name : MAILBOX_MOVE_TEXT.top,
spokenName: entry ? mailboxSpokenName(entry) : MAILBOX_MOVE_TEXT.top,
depth: entry ? Math.min(entry.depth, MAX_INDENT_DEPTH) : 0,
note,
pickable: target.block === null,
headingBefore,
}
})
})
/** 고른 줄 — 목록이 바뀌어(다시 받음) 고를 수 없게 됐으면 고르지 않은 것으로 본다 */
const chosen = computed<MoveRow | null>(() => rows.value.find((row) => row.key === chosenKey.value && row.pickable) ?? null)
/** 줄마다 겹치지 않는 이름 — 맨 위 줄과 메일함 줄을 가른다 (메일함 id 가 "top" 이어도 겹치지 않게) */
function rowName(row: MoveRow): string {
return row.parentId === null ? 'top' : `m-${row.key}`
}
function noteId(row: MoveRow): string | undefined {
return row.note ? `mailbox-move-note-${rowName(row)}` : undefined
}
function rowElement(key: string): HTMLElement | null {
const list = listRef.value
if (!list) return null
return [...list.querySelectorAll<HTMLElement>('[data-move-row]')].find((element) => element.dataset.moveRow === key) ?? null
}
function choose(row: MoveRow) {
focusKey.value = row.key
if (!row.pickable || props.pending) return
if (chosenKey.value !== row.key && props.error) emit('clear-error')
chosenKey.value = row.key
}
/** 화살표 · Home · End — 줄을 옮기고 고를 수 있으면 고른다 (꺼진 줄에도 초점은 간다) */
function moveFocus(target: MoveRow | undefined) {
if (!target) return
choose(target)
void nextTick(() => rowElement(target.key)?.focus())
}
function onListKeydown(event: KeyboardEvent) {
const list = rows.value
const index = list.findIndex((row) => row.key === focusKey.value)
if (event.key === 'ArrowDown' || event.key === 'ArrowRight') {
event.preventDefault()
moveFocus(list[(index + 1) % list.length])
} else if (event.key === 'ArrowUp' || event.key === 'ArrowLeft') {
event.preventDefault()
moveFocus(list[(index - 1 + list.length) % list.length])
} else if (event.key === 'Home') {
event.preventDefault()
moveFocus(list[0])
} else if (event.key === 'End') {
event.preventDefault()
moveFocus(list[list.length - 1])
} else if (event.key === 'Enter') {
// Enter 는 [옮기기]와 같다 (줄 단추의 눌림으로 넘기지 않는다)
event.preventDefault()
submit()
}
}
function submit() {
const row = chosen.value
if (!row || props.pending) return
emit('submit', row.parentId)
}
/** 처음 초점 — 고를 수 있는 첫 줄 (없으면 첫 줄) */
function initialFocusKey(): string {
return (rows.value.find((row) => row.pickable) ?? rows.value[0])?.key ?? TOP_KEY
}
// 열 때마다 아무것도 고르지 않은 채로
watch(
() => props.open,
(open) => {
if (!open) return
chosenKey.value = null
focusKey.value = initialFocusKey()
},
{ immediate: true },
)
</script>
<template>
<ModalDialog
id="mailbox-move-dialog"
:open="open"
:title="MAILBOX_MOVE_TEXT.title(mailboxName)"
:message="message"
title-id="mailbox-move-title"
message-id="mailbox-move-message"
medium
@cancel="emit('cancel')"
>
<div
ref="listRef"
role="radiogroup"
class="mailbox-move__list"
:aria-label="MAILBOX_MOVE_TEXT.listLabel"
:aria-describedby="error ? ERROR_ID : undefined"
@keydown="onListKeydown"
>
<template
v-for="row in rows"
:key="rowName(row)"
>
<p
v-if="row.headingBefore"
class="mailbox-move__heading"
>
{{ MAILBOX_PANEL_TEXT.myMailboxes }}
</p>
<button
type="button"
role="radio"
:class="['mailbox-move__row', `mailbox-move__row--depth-${row.depth}`]"
:aria-checked="chosen?.key === row.key ? 'true' : 'false'"
:aria-disabled="row.pickable ? undefined : 'true'"
:aria-label="row.spokenName"
:aria-describedby="noteId(row)"
:tabindex="row.key === focusKey ? 0 : -1"
:autofocus="row.key === focusKey"
:data-move-row="row.key"
@click="choose(row)"
>
<AppIcon
:name="row.icon"
class="mailbox-move__icon"
/>
<span class="mailbox-move__name">{{ row.name }}</span>
<span
v-if="row.note"
:id="noteId(row)"
class="mailbox-move__note"
>{{ row.note }}</span>
<AppIcon
v-if="chosen?.key === row.key"
name="check"
:stroke-width="2"
class="mailbox-move__check"
/>
</button>
</template>
</div>
<p
v-if="error"
:id="ERROR_ID"
class="form-message form-message--error"
role="alert"
>
<AppIcon
name="alert-circle"
:size="14"
:stroke-width="2"
/>
<span>{{ error }}</span>
</p>
<template #actions>
<button
id="mailbox-move-confirm"
type="button"
class="modal-dialog__action"
:disabled="!chosen"
:aria-disabled="pending ? 'true' : undefined"
:aria-busy="pending"
@click="submit"
>
{{ MAILBOX_MOVE_TEXT.confirm }}
</button>
<button
id="mailbox-move-cancel"
type="button"
class="modal-dialog__action modal-dialog__action--secondary"
@click="emit('cancel')"
>
{{ MAILBOX_MOVE_TEXT.cancel }}
</button>
</template>
</ModalDialog>
</template>
<style scoped>
/* 옮길 곳 목록 — 테두리 칸 · 안쪽 4 · 줄 사이 1 (디자인 원본 Mailbox15). 메일함이 많으면 칸 안에서 스크롤한다 */
.mailbox-move__list {
display: flex;
flex-direction: column;
gap: var(--space-1);
max-height: min(20rem, 50vh);
overflow-y: auto;
margin-top: var(--space-4);
padding: var(--space-4);
border: var(--border-width) solid var(--color-border);
border-radius: var(--radius-md);
}
/* "내 메일함" 묶음 이름 — 패널과 같은 12 굵게 회색 */
.mailbox-move__heading {
margin: var(--space-8) var(--space-10) var(--space-2);
font-size: var(--font-size-small);
font-weight: 600;
color: var(--color-text-muted);
}
/* 줄 40 — 메일함 패널의 줄과 같은 높이 · 들여쓰기 */
.mailbox-move__row {
flex: 0 0 auto;
display: flex;
align-items: center;
gap: var(--space-10);
width: 100%;
min-height: var(--size-row-min);
padding: 0 var(--space-10);
border: 0;
border-radius: var(--radius-item);
background: transparent;
color: var(--color-text-strong);
font: inherit;
font-weight: 500;
text-align: left;
cursor: pointer;
}
.mailbox-move__row--depth-1 {
padding-left: var(--space-28);
}
.mailbox-move__row--depth-2 {
padding-left: var(--space-44);
}
.mailbox-move__row--depth-3 {
padding-left: var(--space-56);
}
.mailbox-move__row:hover:not([aria-disabled='true']):not([aria-checked='true']) {
background: var(--color-surface-muted);
}
/* 고른 줄 — 패널의 지금 메일함처럼 청록 바탕 · 굵은 글자 */
.mailbox-move__row[aria-checked='true'] {
background: var(--color-brand-subtle);
color: var(--color-brand-strong);
font-weight: 600;
}
/* 고를 수 없는 줄 — 이름 · 아이콘만 흐리게 (까닭 글은 그대로) */
.mailbox-move__row[aria-disabled='true'] {
cursor: default;
}
.mailbox-move__row[aria-disabled='true'] .mailbox-move__icon,
.mailbox-move__row[aria-disabled='true'] .mailbox-move__name {
opacity: 0.5;
}
.mailbox-move__icon,
.mailbox-move__check {
flex: 0 0 auto;
}
.mailbox-move__name {
flex: 1 1 auto;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.mailbox-move__note {
flex: 0 0 auto;
font-size: var(--font-size-small);
font-weight: 400;
color: var(--color-text-muted);
}
</style>
@@ -0,0 +1,437 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { mount, type VueWrapper } from '@vue/test-utils'
import { createPinia, setActivePinia, type Pinia } from 'pinia'
import { ERROR_CODE_MESSAGES } from '@/shared/constants/error-codes.generated'
import type { Mailbox } from '@/shared/types/mail'
import { useAuthStore } from '@/stores/auth.store'
import { useMailBlockRunStore } from '@/stores/mail-block-run.store'
import { useMailStore } from '@/stores/mail.store'
import { useToastStore } from '@/stores/toast.store'
import {
fail,
flushPromises,
installMockApi,
ok,
restoreApiAdapter,
type MockRequest,
type MockResult,
} from '@/test-utils/mock-api'
import { createTestRouter } from '@/test-utils/router'
import MailboxPanel from './MailboxPanel.vue'
// 메일함 패널 — 15단계 메일함 옮기기 · 하위 메일함까지 지우기 (디자인 원본 Mailbox15 · 계약 2절 "메일함 관리")
function box(id: string, role: Mailbox['role'], name: string, partial: Partial<Mailbox> = {}): Mailbox {
return { id, name, role, parentId: null, sortOrder: 0, totalCount: 0, unreadCount: 0, ...partial }
}
/** 보드의 예 — 거래처 안에 2025(그 안에 정산), 보관 안에도 2025, 프로젝트 안에 견적 */
const MAILBOXES: Mailbox[] = [
box('inbox', 'inbox', '받은편지함', { unreadCount: 3, totalCount: 148 }),
box('sent', 'sent', '보낸편지함', { totalCount: 64 }),
box('drafts', 'drafts', '임시보관함', { totalCount: 2 }),
box('junk', 'junk', '스팸메일함', { totalCount: 5 }),
box('trash', 'trash', '휴지통', { totalCount: 21 }),
box('c', null, '거래처', { totalCount: 1 }),
box('c25', null, '2025', { parentId: 'c', totalCount: 4 }),
box('c25s', null, '정산', { parentId: 'c25', totalCount: 35 }),
box('b', null, '보관'),
box('b25', null, '2025', { parentId: 'b' }),
box('p', null, '프로젝트', { totalCount: 2 }),
box('pq', null, '견적', { parentId: 'p' }),
]
interface Deferred {
promise: Promise<MockResult>
resolve: (result: MockResult) => void
}
function deferred(): Deferred {
let resolve: (result: MockResult) => void = () => undefined
const promise = new Promise<MockResult>((done) => {
resolve = done
})
return { promise, resolve }
}
type Reply = MockResult | Deferred
describe('MailboxPanel — 메일함 옮기기 · 하위까지 지우기 (15단계)', () => {
let pinia: Pinia
let requests: MockRequest[]
let replies: Map<string, Reply[]>
beforeEach(() => {
pinia = createPinia()
setActivePinia(pinia)
useAuthStore().markAuthenticated({ email: 'ara.kim@arasystem.it.kr', name: '김아라' })
replies = new Map()
})
afterEach(() => {
vi.restoreAllMocks()
useToastStore().clear()
restoreApiAdapter()
document.body.innerHTML = ''
})
function reply(key: string, ...results: Reply[]) {
replies.set(key, results)
}
async function mountPanel(mailboxes: Mailbox[] = MAILBOXES, viewing = 'inbox') {
requests = installMockApi((request) => {
const queue = replies.get(`${request.method} ${request.url}`)
if (queue && queue.length > 0) {
const next = queue.length > 1 ? queue.shift() : queue[0]
if (next) return typeof next === 'object' && 'promise' in next ? next.promise : next
}
if (request.url === '/mailboxes') {
return ok({ items: [...useMailStore().mailboxes], pageInfo: { hasNext: false, nextCursor: null, total: 12 } })
}
if (request.url === '/messages') return ok({ items: [], pageInfo: { hasNext: false, nextCursor: null, total: 0 } })
return ok(null)
})
const store = useMailStore()
store.mailboxes = mailboxes.map((mailbox) => ({ ...mailbox }))
store.mailboxesStatus = 'ready'
const router = createTestRouter()
await router.push(`/mail/${viewing}`)
await router.isReady()
store.currentMailboxId = viewing
const wrapper = mount(MailboxPanel, { attachTo: document.body, global: { plugins: [pinia, router] } })
await flushPromises()
return { wrapper, router, store }
}
/** 패널의 줄 — 같은 이름이 둘이면 몇 번째인지 */
const row = (wrapper: VueWrapper, name: string, nth = 0) => {
const found = wrapper.findAll('.mailbox-row').filter((item) => item.get('.mailbox-row__name').text() === name)[nth]
if (!found) throw new Error(`${name} 줄이 없다`)
return found
}
async function choose(wrapper: VueWrapper, name: string, label: string, nth = 0) {
await row(wrapper, name, nth).get('[data-mailbox-more]').trigger('click')
await flushPromises()
const item = wrapper.findAll('[role="menu"] [role^="menuitem"]').find((candidate) => candidate.text() === label)
if (!item) throw new Error(`${name} 메뉴에 ${label} 이 없다`)
await item.trigger('click')
await flushPromises()
}
const dialogOpen = (id: string) => document.getElementById(id)?.hasAttribute('open') ?? false
const toastTexts = () => useToastStore().toasts.map((toast) => toast.message)
const sent = (method: string, url: string) => requests.filter((request) => request.method === method && request.url === url)
const moveRows = (wrapper: VueWrapper) => wrapper.findAll('[data-move-row]')
const moveRow = (wrapper: VueWrapper, key: string) => wrapper.get(`[data-move-row="${key}"]`)
const panelNames = (wrapper: VueWrapper) =>
wrapper.findAll('.mailbox-tree__list')[1]?.findAll('.mailbox-row__name').map((name) => name.text())
/** 알림의 단추를 누른다 — 알림을 닫고 그 단추의 일을 한다 (ToastHost 와 같은 길 — takeAction) */
function runToastAction(index = 0) {
const toast = useToastStore().toasts[index]
const action = toast ? useToastStore().takeAction(toast.id) : null
if (!action || !('run' in action)) throw new Error('단추가 없다')
action.run()
}
describe('옮기기', () => {
it('⋯ 「옮기기」 → 창 — 맨 위 · 받은편지함 · 내 메일함 차례, 고를 수 없는 줄은 흐리게 · 까닭, 처음에는 아무것도 고르지 않고 [옮기기]가 꺼져 있다', async () => {
const { wrapper } = await mountPanel()
await choose(wrapper, '2025', '옮기기')
expect(dialogOpen('mailbox-move-dialog')).toBe(true)
expect(wrapper.get('#mailbox-move-title').text()).toBe('‘2025’ 메일함을 어디로 옮길까요?')
expect(wrapper.get('#mailbox-move-message').text()).toBe('하위 메일함과 안의 메일도 함께 옮깁니다.')
expect(wrapper.get('[role="radiogroup"]').attributes('aria-label')).toBe('옮길 곳')
expect(wrapper.get('.mailbox-move__heading').text()).toBe('내 메일함')
expect(
moveRows(wrapper).map((item) => [
item.get('.mailbox-move__name').text(),
item.find('.mailbox-move__note').exists() ? item.get('.mailbox-move__note').text() : null,
item.attributes('aria-disabled') ?? null,
]),
).toEqual([
['맨 위', '다른 메일함 안에 두지 않습니다', null],
['받은편지함', null, null],
['거래처', '지금 있는 곳', 'true'],
['2025', null, 'true'],
['정산', null, 'true'],
['보관', '같은 이름의 메일함이 있습니다', 'true'],
['2025', null, null],
['프로젝트', null, null],
['견적', null, null],
])
// 보낸편지함 · 임시보관함 · 스팸메일함 · 휴지통은 옮길 곳이 아니다
expect(moveRows(wrapper).some((item) => ['sent', 'drafts', 'junk', 'trash'].includes(item.attributes('data-move-row') ?? ''))).toBe(
false,
)
// 들여 쓴 줄은 위 메일함부터 읽는다 · 까닭 글은 설명으로
expect(moveRow(wrapper, 'b25').attributes('aria-label')).toBe('보관 안의 2025')
expect(moveRow(wrapper, 'c25s').attributes('aria-label')).toBe('거래처 안의 2025 안의 정산')
expect(moveRow(wrapper, 'c25s').classes()).toContain('mailbox-move__row--depth-2')
expect(moveRow(wrapper, 'c').attributes('aria-describedby')).toBe('mailbox-move-note-m-c')
expect(moveRows(wrapper).every((item) => item.attributes('aria-checked') === 'false')).toBe(true)
expect(wrapper.get('#mailbox-move-confirm').attributes('disabled')).toBeDefined()
// 처음 초점은 고를 수 있는 첫 줄 — 로빙 tabindex
expect(document.activeElement).toBe(moveRow(wrapper, '').element)
expect(moveRows(wrapper).filter((item) => item.attributes('tabindex') === '0')).toHaveLength(1)
})
it('줄을 눌러 고르고 [옮기기] → PATCH { parentId } · 패널에 바로 · "‘2025’ 메일함을 ‘프로젝트’ 안으로 옮겼습니다." + [되돌리기]는 원래 자리로', async () => {
const moved = box('c25', null, '2025', { parentId: 'p', totalCount: 4 })
reply('PATCH /mailboxes/c25', ok(moved), ok({ ...moved, parentId: 'c' }))
const { wrapper, store } = await mountPanel()
await choose(wrapper, '2025', '옮기기')
// 고를 수 없는 줄은 눌러도 고르지 않는다
await moveRow(wrapper, 'b').trigger('click')
expect(wrapper.get('#mailbox-move-confirm').attributes('disabled')).toBeDefined()
await moveRow(wrapper, 'p').trigger('click')
expect(moveRow(wrapper, 'p').attributes('aria-checked')).toBe('true')
expect(moveRow(wrapper, 'p').find('.mailbox-move__check').exists()).toBe(true)
await wrapper.get('#mailbox-move-confirm').trigger('click')
await flushPromises()
expect(sent('PATCH', '/mailboxes/c25').map((request) => request.data)).toEqual([{ parentId: 'p' }])
expect(dialogOpen('mailbox-move-dialog')).toBe(false)
expect(panelNames(wrapper)).toEqual(['거래처', '보관', '2025', '프로젝트', '2025', '정산', '견적'])
expect(toastTexts()).toEqual(['‘2025’ 메일함을 ‘프로젝트’ 안으로 옮겼습니다.'])
expect(useToastStore().toasts[0]?.action?.label).toBe('되돌리기')
runToastAction()
await flushPromises()
expect(sent('PATCH', '/mailboxes/c25').map((request) => request.data)).toEqual([{ parentId: 'p' }, { parentId: 'c' }])
expect(toastTexts()).toEqual(['‘2025’ 메일함을 ‘거래처’ 안으로 되돌렸습니다.'])
expect(store.mailboxes.find((mailbox) => mailbox.id === 'c25')?.parentId).toBe('c')
})
it('맨 위로는 parentId: null — "다른 메일함 밖으로", 받은편지함 안으로는 따옴표 없이', async () => {
reply(
'PATCH /mailboxes/pq',
ok(box('pq', null, '견적')),
ok(box('pq', null, '견적', { parentId: 'inbox' })),
)
const { wrapper } = await mountPanel()
await choose(wrapper, '견적', '옮기기')
// 하위 메일함이 없으면 글이 짧다
expect(wrapper.get('#mailbox-move-message').text()).toBe('안의 메일도 함께 옮깁니다.')
await moveRow(wrapper, '').trigger('click')
await wrapper.get('#mailbox-move-confirm').trigger('click')
await flushPromises()
expect(sent('PATCH', '/mailboxes/pq').map((request) => request.data)).toEqual([{ parentId: null }])
expect(toastTexts()).toEqual(['‘견적’ 메일함을 다른 메일함 밖으로 옮겼습니다.'])
await choose(wrapper, '견적', '옮기기')
// 맨 위에 있는 메일함 — 맨 위 줄이 지금 있는 곳
expect(moveRow(wrapper, '').get('.mailbox-move__note').text()).toBe('지금 있는 곳')
await moveRow(wrapper, 'inbox').trigger('click')
await wrapper.get('#mailbox-move-confirm').trigger('click')
await flushPromises()
expect(toastTexts()).toContain('‘견적’ 메일함을 받은편지함 안으로 옮겼습니다.')
})
it('키보드 — 화살표로 줄을 옮기며 고른다(꺼진 줄에도 초점은 간다 — 고른 것은 그대로) · Enter 는 [옮기기]', async () => {
reply('PATCH /mailboxes/c25', ok(box('c25', null, '2025', { parentId: 'inbox' })))
const { wrapper } = await mountPanel()
await choose(wrapper, '2025', '옮기기')
await moveRow(wrapper, '').trigger('keydown', { key: 'ArrowDown' })
await flushPromises()
expect(document.activeElement).toBe(moveRow(wrapper, 'inbox').element)
expect(moveRow(wrapper, 'inbox').attributes('aria-checked')).toBe('true')
expect(moveRow(wrapper, 'inbox').attributes('tabindex')).toBe('0')
await moveRow(wrapper, 'inbox').trigger('keydown', { key: 'ArrowDown' })
await flushPromises()
expect(document.activeElement).toBe(moveRow(wrapper, 'c').element)
expect(moveRow(wrapper, 'c').attributes('aria-checked')).toBe('false')
expect(moveRow(wrapper, 'inbox').attributes('aria-checked')).toBe('true')
await moveRow(wrapper, 'c').trigger('keydown', { key: 'End' })
await flushPromises()
expect(document.activeElement).toBe(moveRow(wrapper, 'pq').element)
await moveRow(wrapper, 'pq').trigger('keydown', { key: 'Home' })
await flushPromises()
expect(moveRow(wrapper, '').attributes('aria-checked')).toBe('true')
await moveRow(wrapper, '').trigger('keydown', { key: 'ArrowUp' })
await flushPromises()
// 처음에서 위로 가면 끝으로 돈다
expect(moveRow(wrapper, 'pq').attributes('aria-checked')).toBe('true')
await moveRow(wrapper, 'pq').trigger('keydown', { key: 'Home' })
await moveRow(wrapper, '').trigger('keydown', { key: 'ArrowDown' })
await moveRow(wrapper, 'inbox').trigger('keydown', { key: 'Enter' })
await flushPromises()
expect(sent('PATCH', '/mailboxes/c25').map((request) => request.data)).toEqual([{ parentId: 'inbox' }])
})
it('그사이 같은 이름(409 MAIL_009) — 알림 없이 목록 아래 빨간 사전 문구 · 창은 그대로, 다른 곳을 고르면 사라진다', async () => {
reply('PATCH /mailboxes/c25', fail(409, 'MAIL_009'))
const { wrapper } = await mountPanel()
await choose(wrapper, '2025', '옮기기')
await moveRow(wrapper, 'p').trigger('click')
await wrapper.get('#mailbox-move-confirm').trigger('click')
await flushPromises()
const error = wrapper.get('#mailbox-move-error')
expect(error.text()).toBe(ERROR_CODE_MESSAGES.MAIL_009)
expect(error.attributes('role')).toBe('alert')
expect(wrapper.get('[role="radiogroup"]').attributes('aria-describedby')).toBe('mailbox-move-error')
expect(dialogOpen('mailbox-move-dialog')).toBe(true)
expect(useToastStore().toasts).toEqual([])
await moveRow(wrapper, 'pq').trigger('click')
expect(wrapper.find('#mailbox-move-error').exists()).toBe(false)
})
it('메일함이 그사이 사라졌으면(404 RES_001) — 알림을 보고 창을 닫은 뒤 메일함 목록을 다시 받는다', async () => {
reply('PATCH /mailboxes/c25', fail(404, 'RES_001'))
const { wrapper } = await mountPanel()
await choose(wrapper, '2025', '옮기기')
const before = sent('GET', '/mailboxes').length
await moveRow(wrapper, 'p').trigger('click')
await wrapper.get('#mailbox-move-confirm').trigger('click')
await flushPromises()
expect(toastTexts()).toEqual([ERROR_CODE_MESSAGES.RES_001])
expect(dialogOpen('mailbox-move-dialog')).toBe(false)
expect(sent('GET', '/mailboxes').length).toBeGreaterThan(before)
})
it('옮기는 동안 [옮기기]는 aria-disabled · aria-busy — 다시 눌러도 한 번만 보낸다', async () => {
const pending = deferred()
reply('PATCH /mailboxes/c25', pending)
const { wrapper } = await mountPanel()
await choose(wrapper, '2025', '옮기기')
await moveRow(wrapper, 'p').trigger('click')
const confirm = wrapper.get('#mailbox-move-confirm')
await confirm.trigger('click')
await flushPromises()
expect(confirm.attributes('aria-disabled')).toBe('true')
expect(confirm.attributes('aria-busy')).toBe('true')
await confirm.trigger('click')
await flushPromises()
expect(sent('PATCH', '/mailboxes/c25')).toHaveLength(1)
pending.resolve(ok(box('c25', null, '2025', { parentId: 'p' })))
await flushPromises()
expect(dialogOpen('mailbox-move-dialog')).toBe(false)
})
})
describe('하위 메일함까지 지우기', () => {
it('확인(처음 초점 「취소」) 뒤 DELETE ?withChildren=true 를 deleted 가 될 때까지 — 진행 수는 나무 전체, 지우는 동안 하위 메일함의 메뉴도 꺼지고 끝나면 나무 전체가 빠진다', async () => {
const second = deferred()
reply(
'DELETE /mailboxes/c',
ok({ deleted: false, processed: 12, movedToTrash: 12, remaining: 28 }),
second,
ok({ deleted: true, processed: 0, movedToTrash: 0, remaining: 0 }),
)
const { wrapper, store } = await mountPanel()
await choose(wrapper, '거래처', '지우기')
expect(wrapper.get('#mailbox-delete-title').text()).toBe('‘거래처’ 메일함과 하위 메일함 2개를 지울까요?')
expect(wrapper.get('#mailbox-delete-message').text()).toBe(
'하위 메일함에 든 메일까지 모두 40통을 휴지통으로 옮깁니다. 휴지통에 들어간 메일은 30일 뒤 자동으로 지워집니다. 지운 메일함은 되돌릴 수 없습니다.',
)
expect(document.activeElement?.id).toBe('mailbox-delete-cancel')
await wrapper.get('#mailbox-delete-confirm').trigger('click')
await flushPromises()
expect(useToastStore().toasts.map((toast) => [toast.kind, toast.message])).toEqual([
['progress', '‘거래처’ 메일함을 지우는 중 · 12 / 40통'],
])
// 지우는 나무의 메일함은 모든 항목을 누를 수 없다
await row(wrapper, '정산').get('[data-mailbox-more]').trigger('click')
await flushPromises()
const items = wrapper.findAll('[role="menu"] [role^="menuitem"]')
expect(items.length).toBeGreaterThan(0)
expect(items.every((item) => item.attributes('aria-disabled') === 'true')).toBe(true)
await row(wrapper, '정산').get('[data-mailbox-more]').trigger('click')
second.resolve(ok({ deleted: false, processed: 28, movedToTrash: 28, remaining: 0 }))
await flushPromises()
expect(sent('DELETE', '/mailboxes/c').map((request) => request.params)).toEqual([
{ withChildren: true },
{ withChildren: true },
{ withChildren: true },
])
expect(toastTexts()).toEqual(['‘거래처’ 메일함을 지웠습니다. 안의 메일 40통은 휴지통에 있습니다.'])
expect(store.mailboxes.map((mailbox) => mailbox.id)).not.toEqual(expect.arrayContaining(['c']))
expect(store.mailboxes.some((mailbox) => ['c', 'c25', 'c25s'].includes(mailbox.id))).toBe(false)
expect(panelNames(wrapper)).toEqual(['보관', '2025', '프로젝트', '견적'])
})
it('하위 메일함을 보고 있었어도 지운 뒤 받은편지함으로 간다', async () => {
reply('DELETE /mailboxes/c', ok({ deleted: true, processed: 40, movedToTrash: 40, remaining: 0 }))
const { wrapper, router } = await mountPanel(MAILBOXES, 'c25s')
await choose(wrapper, '거래처', '지우기')
await wrapper.get('#mailbox-delete-confirm').trigger('click')
await flushPromises()
expect(router.currentRoute.value.fullPath).toBe('/mail/inbox')
})
it('하위 메일함까지 비어 있으면 "하위 메일함까지 모두 비어 있습니다." · 하위가 없는 메일함은 지금 지우기 그대로(withChildren 없이)', async () => {
reply('DELETE /mailboxes/b', ok({ deleted: true, processed: 0, movedToTrash: 0, remaining: 0 }))
reply('DELETE /mailboxes/pq', ok({ deleted: true, processed: 0, movedToTrash: 0, remaining: 0 }))
const { wrapper } = await mountPanel()
await choose(wrapper, '보관', '지우기')
expect(wrapper.get('#mailbox-delete-title').text()).toBe('‘보관’ 메일함과 하위 메일함 1개를 지울까요?')
expect(wrapper.get('#mailbox-delete-message').text()).toBe(
'하위 메일함까지 모두 비어 있습니다. 지운 메일함은 되돌릴 수 없습니다.',
)
await wrapper.get('#mailbox-delete-confirm').trigger('click')
await flushPromises()
expect(sent('DELETE', '/mailboxes/b').map((request) => request.params)).toEqual([{ withChildren: true }])
await choose(wrapper, '견적', '지우기')
expect(wrapper.get('#mailbox-delete-title').text()).toBe('‘견적’ 메일함을 지울까요?')
await wrapper.get('#mailbox-delete-confirm').trigger('click')
await flushPromises()
expect(sent('DELETE', '/mailboxes/pq').map((request) => request.params)).toEqual([{}])
})
})
describe('차단할 때 받은 메일 옮기기와 스팸메일함 비우기', () => {
it('이 탭에서 차단 정리가 도는 동안은 「스팸메일함 비우기」를 누를 수 없다 · 확인 단추도 꺼지고 스토어도 시작하지 않는다 — 끝나면 다시 켜진다', async () => {
const { wrapper, store } = await mountPanel()
const blockRun = useMailBlockRunStore()
const junk = store.mailboxes.find((mailbox) => mailbox.id === 'junk')
if (!junk) throw new Error('스팸메일함이 없다')
// 확인 창을 연 뒤에 정리가 시작돼도 누르는 순간 다시 본다
await choose(wrapper, '스팸메일함', '스팸메일함 비우기')
blockRun.running = true
await flushPromises()
expect(wrapper.get('#mailbox-empty-confirm').attributes('disabled')).toBeDefined()
await wrapper.get('#mailbox-empty-confirm').trigger('click')
await flushPromises()
expect(sent('DELETE', '/mailboxes/junk/messages')).toEqual([])
await wrapper.get('#mailbox-empty-cancel').trigger('click')
await flushPromises()
await row(wrapper, '스팸메일함').get('[data-mailbox-more]').trigger('click')
await flushPromises()
const item = wrapper.findAll('[role="menu"] [role^="menuitem"]').find((candidate) => candidate.text() === '스팸메일함 비우기')
expect(item?.attributes('aria-disabled')).toBe('true')
await row(wrapper, '스팸메일함').get('[data-mailbox-more]').trigger('click')
expect((await store.runMailboxOperation('empty', junk, () => undefined)).status).toBe('canceled')
// 휴지통 비우기는 상관없다
expect(store.isMailboxOperationBlocked('empty', { role: 'trash' })).toBe(false)
blockRun.running = false
await flushPromises()
expect(store.isMailboxOperationBlocked('empty', junk)).toBe(false)
})
})
})
@@ -169,15 +169,18 @@ describe('MailboxPanel — 메일함 관리', () => {
['스팸메일함 비우기', true],
])
expect(await labels('휴지통')).toEqual([['휴지통 비우기', true]])
// 내 메일함에는 「옮기기」 — 이름 바꾸기 다음 · 지우기 앞 (15단계)
expect(await labels('프로젝트')).toEqual([
['모두 읽음으로 표시', false],
['하위 메일함 만들기', false],
['이름 바꾸기', false],
['옮기기', false],
['지우기', true],
])
expect(await labels('견적')).toEqual([
['하위 메일함 만들기', false],
['이름 바꾸기', false],
['옮기기', false],
['지우기', true],
])
expect(row(wrapper, '보낸편지함').find('[data-mailbox-more]').exists()).toBe(false)
@@ -302,16 +305,17 @@ describe('MailboxPanel — 메일함 관리', () => {
})
describe('지우기 — 안의 메일은 휴지통으로', () => {
it('하위 메일함이 있으면 지울 수 없다 — [확인]만, 보내지 않는다', async () => {
it('하위 메일함이 있으면 하위까지 지울지 묻는다 (15단계) — [취소]면 보내지 않는다', async () => {
const { wrapper } = await mountPanel()
await choose(wrapper, '프로젝트', '지우기')
expect(dialogOpen('mailbox-delete-dialog')).toBe(true)
expect(wrapper.get('#mailbox-delete-dialog').attributes('role')).toBe('alertdialog')
expect(wrapper.get('#mailbox-delete-title').text()).toBe('‘프로젝트’ 메일함을 지울 수 없습니다')
expect(wrapper.get('#mailbox-delete-message').text()).toBe('하위 메일함이 있습니다. 하위 메일함을 먼저 지워 주세요.')
expect(wrapper.find('#mailbox-delete-confirm').exists()).toBe(false)
expect(wrapper.get('#mailbox-delete-cancel').text()).toBe('확인')
expect(wrapper.get('#mailbox-delete-title').text()).toBe('‘프로젝트’ 메일함과 하위 메일함 1개를 지울까요?')
expect(wrapper.get('#mailbox-delete-message').text()).toBe(
'하위 메일함에 든 메일까지 모두 24통을 휴지통으로 옮깁니다. 휴지통에 들어간 메일은 30일 뒤 자동으로 지워집니다. 지운 메일함은 되돌릴 수 없습니다.',
)
expect(document.activeElement?.id).toBe('mailbox-delete-cancel')
await wrapper.get('#mailbox-delete-cancel').trigger('click')
await flushPromises()
@@ -603,6 +607,7 @@ describe('MailboxPanel — 메일함 관리', () => {
['모두 읽음으로 표시', false],
['하위 메일함 만들기', false],
['이름 바꾸기', false],
['옮기기', false],
['지우기', true],
])
await row(wrapper, '거래처').get('[data-mailbox-more]').trigger('click')
@@ -1,7 +1,7 @@
<script setup lang="ts">
// 메일함 패널 — 메일 쓰기 + 메일함 목록 + 아래 안내 · 사용량 (디자인 원본 Main · Compose 왼쪽 · MailboxManage)
// - 메일함 목록은 두 묶음(기본 메일함 · "내 메일함")과 [+ 새 메일함] — MailboxTree
// - 메일함마다 ⋯ 메뉴(MailboxPanelRow) — 모두 읽음 · 하위 메일함 · 이름 바꾸기 · 지우기 · 비우기. 대화상자는 MailboxDialogs, 하는 일은 useMailboxActions
// - 메일함마다 ⋯ 메뉴(MailboxPanelRow) — 모두 읽음 · 하위 메일함 · 이름 바꾸기 · 옮기기(15단계) · 지우기 · 비우기. 대화상자는 MailboxDialogs, 하는 일은 useMailboxActions
// - 아래: 부재중 자동 응답이 켜져 있으면 안내(MailVacationNotice) → 사용량
// - 「예약됨」(9단계)은 예약한 메일이 있을 때만 보낸편지함 아래 — 수는 메일 화면이 받아 둔다(mail.store 의 scheduledCount)
// 안 읽은 개수는 뱃지가 아니라 오른쪽의 평범한 숫자로 보인다.
@@ -42,6 +42,7 @@ function onAction(action: MailboxAction, mailbox: Mailbox) {
}
if (action === 'createChild') dialog.value = { kind: 'create', parent: mailbox }
else if (action === 'rename') dialog.value = { kind: 'rename', mailbox }
else if (action === 'move') dialog.value = { kind: 'move', mailbox }
else if (action === 'delete') dialog.value = { kind: 'delete', mailbox }
else dialog.value = { kind: 'empty', mailbox }
}
@@ -4,11 +4,22 @@
// - ⋯ 는 마우스를 올리거나 초점이 가면 보이고, 메뉴가 열린 동안은 계속 보인다 (터치 화면은 늘 보인다). 할 일이 없는 메일함은 ⋯ 를 그리지 않는다
// - 메뉴 항목은 메일함마다 다르다 (shared/utils/mailbox.ts 의 mailboxActions). 같은 처리가 도는 동안은 그 항목을 끈다 —
// 휴지통을 비우는 동안의 「지우기」, 메일함을 지우는 동안의 「휴지통 비우기」도 끈다 (함께 돌 수 없다 — useMailboxActions 의 isBlocked)
import { computed, ref } from 'vue'
// - 목록 줄을 끌어다 놓는 곳(15단계 — 디자인 원본 ListDrag15): 놓을 수 있는 메일함 위에서는 줄에 2px 청록 점선 · 옅은 청록 바탕
// (쓰기 화면 파일 놓기와 같은 모양 — 초점 선(실선)과 헷갈리지 않게). 놓을 수 없는 곳(메일이 이미 든 메일함 · 임시보관함)은 줄이 그대로이고
// dragover 를 받지 않아 브라우저의 금지 커서가 뜬다. 우리 메일 끌기만 받는다 — 파일 끌기는 받지 않는다(쓰기 화면의 첨부 놓기와 겹치지 않게).
// 놓으면 drop-mail 로 알리고 옮기는 일은 메일함 나무(MailboxTree)가 한다
import { computed, ref, watch } from 'vue'
import AppIcon from '@/components/AppIcon.vue'
import MailboxMoreButton from '@/components/mail/MailboxMoreButton.vue'
import type { EditorMenuItem } from '@/components/mail/editor/editor-menu'
import {
activeMailDrag,
canDropDragOn,
mailDragging,
takeMailDrop,
type MailDragPayload,
} from '@/composables/useMailDrag'
import { MAILBOX_MENU_TEXT } from '@/shared/constants/mailbox-manage'
import type { Mailbox } from '@/shared/types/mail'
import { formatCount } from '@/shared/utils/format'
@@ -23,6 +34,8 @@ interface Props {
interface Emits {
(e: 'action', action: MailboxAction, mailbox: Mailbox): void
/** 목록 줄을 이 메일함에 끌어다 놓았다 (15단계) */
(e: 'drop-mail', mailbox: Mailbox, drag: MailDragPayload): void
}
const props = defineProps<Props>()
@@ -34,6 +47,9 @@ const mailStore = useMailStore()
const MAX_INDENT_DEPTH = 3
const menuOpen = ref<boolean>(false)
/** 놓을 수 있는 끌기가 이 줄 위에 있다 — 들어온 요소 수를 세어 줄 안의 링크 · 단추 사이를 지날 때 깜빡이지 않게 */
const dropDepth = ref<number>(0)
const dropOver = computed(() => dropDepth.value > 0)
/** 그 메일함에서 돌고 있는 처리 — 지우는 중이면 모든 항목을 끈다. 함께 돌 수 없는 처리가 도는 동안의 지우기 · 비우기도 끈다 */
function isDisabled(action: MailboxAction): boolean {
@@ -67,10 +83,52 @@ function onSelect(id: string) {
const action = mailboxActions(props.mailbox).find((candidate) => candidate === id)
if (action) emit('action', action, props.mailbox)
}
/** 이 메일함에 놓을 수 있는 끌기인가 — 우리 메일 끌기이고 놓을 수 있는 메일함일 때만 (아니면 null — 브라우저의 금지 커서) */
function droppableDrag(event: DragEvent): MailDragPayload | null {
const drag = activeMailDrag(event)
return drag && canDropDragOn(drag, props.mailbox) ? drag : null
}
function onDragEnter(event: DragEvent) {
if (!droppableDrag(event)) return
event.preventDefault()
dropDepth.value += 1
}
function onDragOver(event: DragEvent) {
if (!droppableDrag(event)) return
event.preventDefault()
if (event.dataTransfer) event.dataTransfer.dropEffect = 'move'
}
function onDragLeave(event: DragEvent) {
if (!droppableDrag(event)) return
dropDepth.value = Math.max(0, dropDepth.value - 1)
}
function onDrop(event: DragEvent) {
dropDepth.value = 0
if (!droppableDrag(event)) return
event.preventDefault()
const drag = takeMailDrop(event)
if (drag) emit('drop-mail', props.mailbox, drag)
}
// 끌기가 끝나면(다른 곳에 놓음 · Esc 로 그만둠) 점선을 끈다 — 마지막 dragleave 를 놓쳤어도 남지 않게
watch(mailDragging, (dragging) => {
if (!dragging) dropDepth.value = 0
})
</script>
<template>
<li :class="['mailbox-row', { 'is-active': active, 'is-open': menuOpen }]">
<li
:class="['mailbox-row', { 'is-active': active, 'is-open': menuOpen, 'is-drop-target': dropOver }]"
@dragenter="onDragEnter"
@dragover="onDragOver"
@dragleave="onDragLeave"
@drop="onDrop"
>
<RouterLink
:to="{ name: 'mail-mailbox', params: { mailboxId: mailbox.id } }"
:class="['mailbox-row__link', depthClass]"
@@ -166,6 +224,19 @@ function onSelect(id: string) {
color: var(--color-brand-strong);
}
/* 끌어다 놓을 수 있는 메일함 위 — 2px 청록 점선 · 옅은 청록 바탕 (쓰기 화면 파일 놓기와 같은 모양, 디자인 원본 ListDrag15) */
.mailbox-row.is-drop-target {
outline: var(--focus-ring-width) dashed var(--color-brand);
outline-offset: calc(var(--focus-ring-width) * -1);
background: var(--color-brand-tint);
}
.mailbox-row.is-drop-target .mailbox-row__link,
.mailbox-row.is-drop-target .mailbox-row__count {
color: var(--color-brand-strong);
font-weight: 600;
}
/* ⋯ — 마우스를 올리거나 초점이 가면, 메뉴가 열린 동안은 계속 보인다 */
.mailbox-row__more {
opacity: 0;
@@ -4,11 +4,15 @@
// "내 메일함" 이 비어도 묶음 이름과 [+ 새 메일함]은 둔다 — 첫 메일함을 만들 수 있게
// 「예약됨」(9단계 — 예약한 메일이 있을 때만)은 기본 메일함 묶음의 보낸편지함(과 그 하위) 바로 아래 — 보낸편지함이 없으면 묶음 끝.
// 예약됨을 보는 동안은 수가 0 이 되어도 남긴다 — 다 취소하거나 보낸 뒤에도 지금 있는 곳이 패널에 보이게
// 목록 줄을 메일함 줄에 끌어다 놓으면(15단계 — 디자인 원본 ListDrag15) 여기서 옮긴다 — 휴지통이면 [삭제], 스팸메일함이면 [스팸 신고]와 같고
// 그 밖은 [이동 ▾]와 같다(알림 · [되돌리기]도 같다 — useBulkMailActions 의 moveDragged). 「예약됨」에는 놓을 수 없다
import { computed } from 'vue'
import AppIcon from '@/components/AppIcon.vue'
import MailboxPanelRow from '@/components/mail/MailboxPanelRow.vue'
import MailboxScheduledRow from '@/components/mail/MailboxScheduledRow.vue'
import { useBulkMailActions } from '@/composables/useBulkMailActions'
import type { MailDragPayload } from '@/composables/useMailDrag'
import { MAILBOX_PANEL_TEXT } from '@/shared/constants/mailbox-manage'
import type { Mailbox } from '@/shared/types/mail'
import { splitMailboxGroups, type MailboxAction, type MailboxNode } from '@/shared/utils/mailbox'
@@ -34,6 +38,8 @@ const props = withDefaults(defineProps<Props>(), {
})
const emit = defineEmits<Emits>()
const bulk = useBulkMailActions()
const MY_GROUP_ID = 'mailbox-panel-mine'
/** 보낸편지함이 없을 때 — 「예약됨」을 기본 메일함 묶음 끝에 (메일함 id 는 비지 않는다) */
const AT_GROUP_END = ''
@@ -43,6 +49,11 @@ const groups = computed(() => splitMailboxGroups(props.nodes))
* 「예약됨」을 넣을 자리 — 기본 메일함 묶음에서 이 메일함 줄 뒤(보낸편지함 하위의 끝). 보낸편지함이 없으면 묶음 끝(AT_GROUP_END).
* 예약한 메일이 없으면 null (그리지 않는다) — 다만 예약됨을 보고 있으면 남긴다
*/
/** 끌어다 놓은 메일을 그 메일함으로 옮긴다 */
function onDropMail(mailbox: Mailbox, drag: MailDragPayload) {
void bulk.moveDragged(drag, mailbox)
}
const scheduledAfterId = computed<string | null>(() => {
if (props.scheduledCount <= 0 && !props.scheduledActive) return null
const system = groups.value.system
@@ -67,6 +78,7 @@ const scheduledAfterId = computed<string | null>(() => {
:depth="node.depth"
:active="node.mailbox.id === activeId"
@action="(action, mailbox) => emit('action', action, mailbox)"
@drop-mail="onDropMail"
/>
<MailboxScheduledRow
v-if="scheduledAfterId === node.mailbox.id"
@@ -98,6 +110,7 @@ const scheduledAfterId = computed<string | null>(() => {
:depth="node.depth"
:active="node.mailbox.id === activeId"
@action="(action, mailbox) => emit('action', action, mailbox)"
@drop-mail="onDropMail"
/>
</ul>
<button
@@ -0,0 +1,148 @@
import { afterEach, describe, expect, it } from 'vitest'
import { mount, type VueWrapper } from '@vue/test-utils'
import { nextTick } from 'vue'
import type { MailBlockRunBlock } from '@/stores/mail-block-run.store'
import MessageBlockDialog from './MessageBlockDialog.vue'
// 보낸 사람 차단 창의 15단계 새 칸 — 「받은편지함에 있는 이 보낸 사람의 메일도 스팸메일함으로 옮기기」
// (디자인 원본 Mailbox15 "보낸 사람 차단 창" · 디자인 README "15단계")
interface DialogProps {
open: boolean
senderEmail: string
inJunk?: boolean
inInbox?: boolean
cleanupBlock?: MailBlockRunBlock | null
}
const mounted: VueWrapper[] = []
afterEach(() => {
for (const wrapper of mounted.splice(0)) wrapper.unmount()
document.body.innerHTML = ''
})
function mountDialog(props: Partial<DialogProps> = {}) {
const wrapper = mount(MessageBlockDialog, {
props: { open: true, senderEmail: 'News@Example.com', inInbox: true, ...props },
attachTo: document.body,
})
mounted.push(wrapper)
return wrapper
}
const moveBox = (wrapper: VueWrapper) => wrapper.get<HTMLInputElement>('[data-block-move]')
const cleanBox = (wrapper: VueWrapper) => wrapper.get<HTMLInputElement>('[data-block-clean]')
const confirmed = (wrapper: VueWrapper) => wrapper.emitted('confirm') ?? []
describe('MessageBlockDialog — 받은편지함의 메일도 옮기기 (15단계)', () => {
it('처음에는 첫 칸 켜짐 · 새 칸 꺼짐 — 새 칸 아래 "옮긴 메일은 한꺼번에 되돌릴 수 없습니다." · [차단]은 지금처럼', async () => {
const wrapper = mountDialog()
expect(moveBox(wrapper).element.checked).toBe(true)
expect(moveBox(wrapper).attributes('aria-disabled')).toBeUndefined()
expect(cleanBox(wrapper).element.checked).toBe(false)
expect(cleanBox(wrapper).element.closest('label')?.textContent?.trim()).toBe(
'받은편지함에 있는 이 보낸 사람의 메일도 스팸메일함으로 옮기기',
)
expect(cleanBox(wrapper).attributes('aria-describedby')).toBe('block-dialog-clean-note')
expect(wrapper.get('#block-dialog-clean-note').text()).toBe('옮긴 메일은 한꺼번에 되돌릴 수 없습니다.')
await wrapper.get('[data-block-confirm]').trigger('click')
expect(confirmed(wrapper)).toEqual([['news@example.com', true, false]])
})
it('새 칸을 켜면 받은편지함의 메일이면 첫 칸은 켠 채 잠긴다(aria-disabled · 까닭) — 눌러도 그대로, 새 칸을 끄면 원래 값으로', async () => {
const wrapper = mountDialog()
// 먼저 첫 칸을 꺼 둔다
await moveBox(wrapper).setValue(false)
expect(moveBox(wrapper).element.checked).toBe(false)
await cleanBox(wrapper).setValue(true)
expect(moveBox(wrapper).element.checked).toBe(true)
expect(moveBox(wrapper).attributes('aria-disabled')).toBe('true')
expect(moveBox(wrapper).attributes('aria-describedby')).toBe('block-dialog-move-lock')
expect(wrapper.get('#block-dialog-move-lock').text()).toBe('받은편지함의 메일을 옮길 때 함께 옮깁니다.')
// 잠긴 칸은 눌러도 바뀌지 않는다
await moveBox(wrapper).trigger('click')
await nextTick()
expect(moveBox(wrapper).element.checked).toBe(true)
await wrapper.get('[data-block-confirm]').trigger('click')
expect(confirmed(wrapper).at(-1)).toEqual(['news@example.com', true, true])
await cleanBox(wrapper).setValue(false)
expect(moveBox(wrapper).element.checked).toBe(false)
expect(moveBox(wrapper).attributes('aria-disabled')).toBeUndefined()
expect(wrapper.find('#block-dialog-move-lock').exists()).toBe(false)
})
it('이 메일이 받은편지함이 아니면 잠그지 않는다 · 스팸메일함의 메일이면 첫 칸이 없고 새 칸만', async () => {
const elsewhere = mountDialog({ inInbox: false })
await cleanBox(elsewhere).setValue(true)
expect(moveBox(elsewhere).attributes('aria-disabled')).toBeUndefined()
await moveBox(elsewhere).setValue(false)
await elsewhere.get('[data-block-confirm]').trigger('click')
expect(confirmed(elsewhere)).toEqual([['news@example.com', false, true]])
const inJunk = mountDialog({ inInbox: false, inJunk: true })
expect(inJunk.find('[data-block-move]').exists()).toBe(false)
await cleanBox(inJunk).setValue(true)
await inJunk.get('[data-block-confirm]').trigger('click')
expect(confirmed(inJunk)).toEqual([['news@example.com', false, true]])
})
it('「이 도메인 전체」를 고르면 새 칸이 "이 도메인의 메일"로 바뀌고 @도메인으로 막는다', async () => {
const wrapper = mountDialog()
await wrapper.get('[data-block-scope="domain"]').setValue(true)
await cleanBox(wrapper).setValue(true)
expect(cleanBox(wrapper).element.closest('label')?.textContent?.trim()).toBe(
'받은편지함에 있는 이 도메인의 메일도 스팸메일함으로 옮기기',
)
await wrapper.get('[data-block-confirm]').trigger('click')
expect(confirmed(wrapper)).toEqual([['@example.com', true, true]])
})
it('이 탭에서 받은편지함을 정리하는 중이면 새 칸을 끄고(aria-disabled) 칸 아래 글을 까닭으로 — 눌러도 켜지지 않는다', async () => {
const wrapper = mountDialog({ cleanupBlock: 'inbox' })
expect(cleanBox(wrapper).attributes('aria-disabled')).toBe('true')
expect(wrapper.get('#block-dialog-clean-note').text()).toBe('받은편지함을 정리하는 중이라 지금은 고를 수 없습니다.')
await cleanBox(wrapper).trigger('click')
await nextTick()
expect(cleanBox(wrapper).element.checked).toBe(false)
await wrapper.get('[data-block-confirm]').trigger('click')
expect(confirmed(wrapper)).toEqual([['news@example.com', true, false]])
})
it('이 탭에서 스팸메일함을 비우는 중이면 새 칸을 끄고 칸 아래 글을 그 까닭으로 — 켜 둔 칸도 꺼진 것으로 보낸다, 끝나면 다시 고를 수 있다', async () => {
const wrapper = mountDialog()
await cleanBox(wrapper).setValue(true)
await wrapper.setProps({ cleanupBlock: 'junk' })
expect(cleanBox(wrapper).attributes('aria-disabled')).toBe('true')
expect(cleanBox(wrapper).element.checked).toBe(false)
expect(wrapper.get('#block-dialog-clean-note').text()).toBe('스팸메일함을 비우는 중이라 지금은 고를 수 없습니다.')
// 첫 칸의 잠금도 풀린다 — 새 칸이 꺼진 것과 같다
expect(moveBox(wrapper).attributes('aria-disabled')).toBeUndefined()
await wrapper.get('[data-block-confirm]').trigger('click')
expect(confirmed(wrapper)).toEqual([['news@example.com', true, false]])
await wrapper.setProps({ cleanupBlock: null })
expect(cleanBox(wrapper).attributes('aria-disabled')).toBeUndefined()
expect(wrapper.get('#block-dialog-clean-note').text()).toBe('옮긴 메일은 한꺼번에 되돌릴 수 없습니다.')
})
it('열 때마다 처음 값으로 — 새 칸은 꺼진 채 시작한다', async () => {
const wrapper = mountDialog()
await cleanBox(wrapper).setValue(true)
await wrapper.setProps({ open: false })
await wrapper.setProps({ open: true })
expect(cleanBox(wrapper).element.checked).toBe(false)
expect(moveBox(wrapper).element.checked).toBe(true)
})
})
@@ -1,56 +1,108 @@
<script setup lang="ts">
// 보낸 사람 차단 확인 창 (디자인 원본 MailReadTools · README "9단계 › 차단 확인 창") — 모달 400(medium)
// 보낸 사람 차단 확인 창 (디자인 원본 MailReadTools · Mailbox15 · README "9단계 › 차단 확인 창" · "15단계 › 보낸 사람 차단 창") — 모달 400(medium)
// 제목 "보낸 사람 차단" · 설명 → 고르기 둘(「이 주소만」 주소 · 「이 도메인 전체」 @도메인 에서 오는 메일 모두) →
// 체크 「이 메일도 스팸메일함으로 옮기기」(처음 켜짐 — 이미 스팸메일함의 메일이면 두지 않는다) → 안내 → [차단](처음 초점 — 되돌릴 수 있는 일) [취소]
// 체크 둘(붙여 둔다 — 칸 글이 두 줄이면 고르기 상자를 첫 줄에 맞춘다):
// 「이 메일도 스팸메일함으로 옮기기」(처음 켜짐 — 이미 스팸메일함의 메일이면 두지 않는다)
// 「받은편지함에 있는 이 보낸 사람의 메일도 스팸메일함으로 옮기기」(15단계 — 처음 꺼짐, 「이 도메인 전체」를 고르면 "이 도메인의 메일") +
// 그 아래 "옮긴 메일은 한꺼번에 되돌릴 수 없습니다."
// → 안내 → [차단](처음 초점 — 되돌릴 수 있는 일) [취소]
// - 새 칸을 켰는데 이 메일이 받은편지함에 있으면 첫 칸은 켜진 채 잠그고(aria-disabled) 아래에 "받은편지함의 메일을 옮길 때 함께 옮깁니다." —
// 새 칸을 끄면 첫 칸은 원래 값으로 돌아온다. 이 메일이 다른 메일함에 있으면 잠그지 않는다
// - 이 탭에서 받은편지함을 정리하거나(필터 적용 · 다른 차단 정리) 스팸메일함을 비우는 중이면(cleanupBlock — 15단계 리뷰) 새 칸을 끄고(aria-disabled)
// 칸 아래 글을 그 까닭으로 바꾼다
// 여러 사람이 쓰는 메일 서비스(gmail.com · naver.com 등 — 서버와 같은 목록)는 「이 도메인 전체」를 끄고 그 아래에 까닭을 둔다.
// 열 때마다 「이 주소만」 · 옮기기 켜짐으로 시작한다. Esc 는 [취소]와 같다
// 열 때마다 「이 주소만」 · 첫 칸 켜짐 · 새 칸 꺼짐으로 시작한다. Esc 는 [취소]와 같다
// 칸 아래 글은 고르기 상자 글 선(28)에 맞춰 들여 쓴다 — 고정 id #block-dialog-move-lock · #block-dialog-clean-note
import { computed, ref, watch } from 'vue'
import ModalDialog from '@/components/feedback/ModalDialog.vue'
import { MAIL_BLOCK_TEXT } from '@/shared/constants/mail-block'
import { normalizeEmail } from '@/shared/utils/mail-address'
import { domainBlockOf, emailDomainOf, isPublicMailDomain } from '@/shared/utils/mail-block'
import type { MailBlockRunBlock } from '@/stores/mail-block-run.store'
interface Props {
open: boolean
/** 보낸 사람 주소 */
senderEmail: string
/** 이 메일이 이미 스팸메일함에 있다 — 옮기기 칸을 두지 않는다 */
/** 이 메일이 이미 스팸메일함에 있다 — 첫 칸을 두지 않는다 */
inJunk?: boolean
/** 이 메일이 받은편지함에 있다 — 새 칸을 켜면 첫 칸을 켠 채 잠근다 (15단계) */
inInbox?: boolean
/** 새 칸을 지금 고를 수 없는 까닭 — 이 탭에서 받은편지함을 정리하는 중(inbox) · 스팸메일함을 비우는 중(junk), 없으면 null (15단계) */
cleanupBlock?: MailBlockRunBlock | null
}
interface Emits {
/** target 은 주소 또는 "@도메인" (소문자) */
(e: 'confirm', target: string, moveToJunk: boolean): void
/** target 은 주소 또는 "@도메인" (소문자) · cleanInbox 는 받은편지함의 그 보낸 사람 메일도 옮긴다 (15단계) */
(e: 'confirm', target: string, moveToJunk: boolean, cleanInbox: boolean): void
(e: 'cancel'): void
}
const props = withDefaults(defineProps<Props>(), {
inJunk: false,
inInbox: false,
cleanupBlock: null,
})
const emit = defineEmits<Emits>()
const LOCK_NOTE_ID = 'block-dialog-move-lock'
const CLEAN_NOTE_ID = 'block-dialog-clean-note'
const scope = ref<'address' | 'domain'>('address')
/** 첫 칸에 고른 값 — 잠긴 동안에도 기억했다가 새 칸을 끄면 돌아온다 */
const moveToJunk = ref<boolean>(true)
const cleanInbox = ref<boolean>(false)
/** 새 칸을 고를 수 없다 */
const cleanupBusy = computed(() => props.cleanupBlock !== null)
const address = computed(() => normalizeEmail(props.senderEmail))
const domain = computed(() => emailDomainOf(props.senderEmail))
const domainTarget = computed(() => domainBlockOf(domain.value))
/** 「이 도메인 전체」를 고를 수 없다 — 여러 사람이 쓰는 메일 서비스 · 도메인이 없는 주소 */
const domainDisabled = computed(() => domain.value === '' || isPublicMailDomain(domain.value))
const blocksDomain = computed(() => scope.value === 'domain' && !domainDisabled.value)
/** 새 칸이 켜져 있다 — 고를 수 없는 동안은 꺼진 것으로 본다 */
const cleaning = computed(() => cleanInbox.value && !cleanupBusy.value)
/** 첫 칸을 켠 채 잠근다 — 받은편지함의 메일을 옮길 때 이 메일도 함께 옮긴다 */
const moveLocked = computed(() => cleaning.value && props.inInbox && !props.inJunk)
const moveChecked = computed(() => moveLocked.value || moveToJunk.value)
const cleanLabel = computed(() => (blocksDomain.value ? MAIL_BLOCK_TEXT.cleanInboxDomain : MAIL_BLOCK_TEXT.cleanInboxSender))
const cleanNote = computed(() => {
if (props.cleanupBlock === 'junk') return MAIL_BLOCK_TEXT.cleanJunkBusy
return props.cleanupBlock === 'inbox' ? MAIL_BLOCK_TEXT.cleanInboxBusy : MAIL_BLOCK_TEXT.cleanInboxNote
})
function confirm() {
const target = scope.value === 'domain' && !domainDisabled.value ? domainTarget.value : address.value
emit('confirm', target, moveToJunk.value && !props.inJunk)
/** 잠긴 칸 · 고를 수 없는 칸은 눌러도 바꾸지 않는다 (aria-disabled — 초점은 받는다) */
function onMoveClick(event: MouseEvent) {
if (moveLocked.value) event.preventDefault()
}
// 열 때마다 처음 값으로 — 「이 주소만」 · 옮기기 켜짐
function onMoveChange(event: Event) {
moveToJunk.value = (event.target as HTMLInputElement).checked
}
function onCleanClick(event: MouseEvent) {
if (cleanupBusy.value) event.preventDefault()
}
function onCleanChange(event: Event) {
cleanInbox.value = (event.target as HTMLInputElement).checked
}
function confirm() {
const target = blocksDomain.value ? domainTarget.value : address.value
emit('confirm', target, moveChecked.value && !props.inJunk, cleaning.value)
}
// 열 때마다 처음 값으로 — 「이 주소만」 · 첫 칸 켜짐 · 새 칸 꺼짐
watch(
() => props.open,
(open) => {
if (!open) return
scope.value = 'address'
moveToJunk.value = true
cleanInbox.value = false
},
)
</script>
@@ -117,18 +169,49 @@ watch(
{{ MAIL_BLOCK_TEXT.scopeDomainPublic }}
</p>
</div>
<label
v-if="!inJunk"
class="block-dialog__move"
>
<input
v-model="moveToJunk"
type="checkbox"
class="block-dialog__input"
data-block-move
<div class="block-dialog__checks">
<template v-if="!inJunk">
<label :class="['block-dialog__check', { 'is-locked': moveLocked }]">
<input
type="checkbox"
class="block-dialog__input"
:checked="moveChecked"
:aria-disabled="moveLocked ? 'true' : undefined"
:aria-describedby="moveLocked ? LOCK_NOTE_ID : undefined"
data-block-move
@click="onMoveClick"
@change="onMoveChange"
>
<span class="block-dialog__check-label">{{ MAIL_BLOCK_TEXT.moveToJunk }}</span>
</label>
<p
v-if="moveLocked"
:id="LOCK_NOTE_ID"
class="form-message block-dialog__note block-dialog__note--lock"
>
{{ MAIL_BLOCK_TEXT.moveToJunkLocked }}
</p>
</template>
<label :class="['block-dialog__check', { 'is-disabled': cleanupBusy }]">
<input
type="checkbox"
class="block-dialog__input"
:checked="cleaning"
:aria-disabled="cleanupBusy ? 'true' : undefined"
:aria-describedby="CLEAN_NOTE_ID"
data-block-clean
@click="onCleanClick"
@change="onCleanChange"
>
<span class="block-dialog__check-label">{{ cleanLabel }}</span>
</label>
<p
:id="CLEAN_NOTE_ID"
class="form-message block-dialog__note block-dialog__note--clean"
>
<span>{{ MAIL_BLOCK_TEXT.moveToJunk }}</span>
</label>
{{ cleanNote }}
</p>
</div>
<p class="form-message">
{{ MAIL_BLOCK_TEXT.settingsHint }}
</p>
@@ -220,20 +303,45 @@ watch(
color: var(--color-icon-muted);
}
/* 「이 도메인 전체」를 끈 까닭 — 고르기 글자 선(28)에 맞춰 들여 쓴다 */
/* 칸 아래 까닭 · 안내 — 고르기 글자 선(28)에 맞춰 들여 쓴다 */
.block-dialog__note {
margin-left: var(--space-28);
}
.block-dialog__move {
/* 체크 둘은 붙여 둔다 (디자인 원본 Mailbox15 — 줄 44 · 위 12, 칸 글이 두 줄이면 고르기 상자를 첫 줄에 맞춘다) */
.block-dialog__checks {
display: flex;
align-items: center;
flex-direction: column;
}
.block-dialog__check {
display: flex;
align-items: flex-start;
gap: var(--space-10);
min-height: var(--size-touch);
padding-top: var(--space-12);
cursor: pointer;
}
.block-dialog__move .block-dialog__input {
margin: 0;
/* 잠긴 첫 칸 · 고를 수 없는 새 칸 — 상자와 이름을 흐리게 (aria-disabled — 초점은 받는다) */
.block-dialog__check.is-locked,
.block-dialog__check.is-disabled {
cursor: default;
}
.block-dialog__check.is-locked .block-dialog__input,
.block-dialog__check.is-locked .block-dialog__check-label,
.block-dialog__check.is-disabled .block-dialog__input,
.block-dialog__check.is-disabled .block-dialog__check-label {
opacity: 0.5;
}
/* 잠긴 첫 칸의 까닭은 그 칸 바로 아래로 당긴다 (보드 -10) */
.block-dialog__note--lock {
margin-top: calc(var(--space-10) * -1);
}
.block-dialog__note--clean {
margin-top: var(--space-2);
}
</style>
@@ -0,0 +1,354 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { mount, type VueWrapper } from '@vue/test-utils'
import { createPinia, setActivePinia, type Pinia } from 'pinia'
import { CLOCK } from '@/composables/useClock'
import type { Mailbox, MessageDetail, MessageSummary } from '@/shared/types/mail'
import { useAuthStore } from '@/stores/auth.store'
import { useMailStore } from '@/stores/mail.store'
import { useToastStore } from '@/stores/toast.store'
import { createManualClock, type ManualClock } from '@/test-utils/manual-clock'
import { flushPromises, installMockApi, ok, restoreApiAdapter, type MockRequest, type MockResult } from '@/test-utils/mock-api'
import { createTestRouter } from '@/test-utils/router'
import MessageReader from './MessageReader.vue'
// 15단계 — 같은 대화 칸의 [모두 펼치기] · [모두 접기]와 펼친 줄 머리의 [답장] (디자인 원본 Reader15 "6")
// - [모두 펼치기]는 불러온 줄을 모두 펼친다 — 본문은 펼친 안이 화면에 들어오는 줄부터 하나씩 받는다(한꺼번에 부르지 않는다)
// - 읽음은 지금 규칙 그대로 — 본문이 화면에 보인 메일만 읽음이다(펼쳤어도 화면 밖이면 그대로)
// 화면에 들어오고 나가는 것은 가짜 IntersectionObserver 로 흉내 낸다 (jsdom 에는 없다)
const USER = { email: 'ara.kim@arasystem.test', name: '김아라' }
const INBOX: Mailbox = { id: 'inbox', name: '받은편지함', role: 'inbox', parentId: null, sortOrder: 0, totalCount: 3, unreadCount: 2 }
const SENT: Mailbox = { ...INBOX, id: 'sent', name: '보낸편지함', role: 'sent', unreadCount: 0 }
const DRAFTS: Mailbox = { ...INBOX, id: 'drafts', name: '임시보관함', role: 'drafts', unreadCount: 0 }
const MAILBOXES = [INBOX, SENT, DRAFTS]
const NOW = new Date(2026, 10, 9, 10).getTime()
const at = (...parts: [number, number, number, number, number]) => new Date(...parts).toISOString()
function summary(id: string, partial: Partial<MessageSummary> = {}): MessageSummary {
return {
id,
threadId: 't1',
mailboxIds: ['inbox'],
from: { name: '이도윤', email: 'doyun.lee@arasystem.test' },
to: [{ name: '김아라', email: USER.email }],
subject: 'Re: 유지보수 계약 갱신 건',
preview: '네, 확인했습니다.',
receivedAt: at(2026, 10, 3, 9, 30),
isRead: true,
isFlagged: false,
hasAttachment: false,
size: 100,
pendingSend: null,
...partial,
}
}
function detail(base: MessageSummary): MessageDetail {
return {
...base,
cc: [],
bcc: [],
replyTo: [],
sentAt: null,
messageId: null,
references: [],
htmlBody: null,
textBody: `${base.id} 본문`,
hasBlockedImages: false,
attachments: [],
}
}
const FIRST = summary('c1', {
mailboxIds: ['sent'],
from: { name: '김아라', email: USER.email },
receivedAt: at(2026, 10, 3, 14, 10),
})
const SECOND = summary('c2', { isRead: false, receivedAt: at(2026, 10, 5, 9, 30) })
const THIRD = summary('c3', { isRead: false, receivedAt: at(2026, 10, 5, 13, 5) })
const DRAFT = summary('c5', { mailboxIds: ['drafts'], from: USER, receivedAt: at(2026, 10, 9, 9, 52) })
const CURRENT = summary('c4', { receivedAt: at(2026, 10, 9, 9, 30) })
const THREAD = [FIRST, SECOND, THIRD, CURRENT, DRAFT]
function page(items: MessageSummary[], nextCursor: string | null = null, total: number | null = items.length): MockResult {
return ok({ items, pageInfo: { hasNext: nextCursor !== null, nextCursor, total }, basis: 'index' })
}
function deferred() {
let resolve: (result: MockResult) => void = () => undefined
const promise = new Promise<MockResult>((settle) => {
resolve = settle
})
return { promise, resolve }
}
/** 가짜 IntersectionObserver — 테스트가 요소를 화면에 넣고 뺀다 */
class FakeIntersectionObserver {
static instances: FakeIntersectionObserver[] = []
readonly targets = new Set<Element>()
constructor(private readonly callback: IntersectionObserverCallback) {
FakeIntersectionObserver.instances.push(this)
}
observe(element: Element) {
this.targets.add(element)
}
unobserve(element: Element) {
this.targets.delete(element)
}
disconnect() {
this.targets.clear()
}
takeRecords(): IntersectionObserverEntry[] {
return []
}
/** 그 요소들이 화면에 들어왔다 · 나갔다 — 보고 있는 것만 알린다 */
static set(elements: Element[], visible: boolean) {
for (const instance of FakeIntersectionObserver.instances) {
const entries = elements
.filter((element) => instance.targets.has(element))
.map((target) => ({ target, isIntersecting: visible }) as IntersectionObserverEntry)
if (entries.length > 0) instance.callback(entries, instance as unknown as IntersectionObserver)
}
}
}
describe('MessageConversation — [모두 펼치기] · 펼친 줄 머리의 [답장] (15단계)', () => {
let pinia: Pinia
let clock: ManualClock
let requests: MockRequest[]
let details: Map<string, ReturnType<typeof deferred>>
const mounted: VueWrapper[] = []
beforeEach(() => {
pinia = createPinia()
setActivePinia(pinia)
useAuthStore().markAuthenticated(USER)
clock = createManualClock(NOW)
FakeIntersectionObserver.instances = []
vi.stubGlobal('IntersectionObserver', FakeIntersectionObserver)
details = new Map()
})
afterEach(() => {
for (const wrapper of mounted.splice(0)) {
if (!wrapper.vm.$.isUnmounted) wrapper.unmount()
}
vi.unstubAllGlobals()
vi.restoreAllMocks()
useToastStore().clear()
restoreApiAdapter()
document.body.innerHTML = ''
})
/** 본문은 테스트가 풀어 줄 때 온다 (deferredDetails) — 아니면 바로 */
function serve(options: { conversation?: (request: MockRequest) => MockResult | Promise<MockResult>; hold?: boolean } = {}) {
requests = installMockApi((request) => {
if (request.url.endsWith('/conversation')) return options.conversation?.(request) ?? page(THREAD)
if (request.method === 'PATCH') return ok({ ...SECOND, isRead: true })
if (request.url === '/mailboxes') return ok({ items: MAILBOXES, pageInfo: { hasNext: false, nextCursor: null, total: 3 } })
if (request.url.startsWith('/messages/')) {
const id = request.url.split('/').pop() ?? ''
const body = ok(detail(THREAD.find((item) => item.id === id) ?? summary(id)))
if (!options.hold) return body
const wait = deferred()
details.set(id, wait)
return wait.promise.then(() => body)
}
return ok(null)
})
}
async function mountReader() {
const store = useMailStore()
store.mailboxes = MAILBOXES
store.listSource = { kind: 'mailbox', mailboxId: 'inbox' }
store.messages = [SECOND, THIRD, CURRENT]
store.pageInfo = { hasNext: false, nextCursor: null, total: 3 }
store.listStatus = 'ready'
store.selected = detail(CURRENT)
store.selectedId = CURRENT.id
store.selectedStatus = 'ready'
const router = createTestRouter()
await router.push(`/mail/inbox/${CURRENT.id}`)
await router.isReady()
const wrapper = mount(MessageReader, {
props: { layout: 'split' },
attachTo: document.body,
global: { plugins: [pinia, router], provide: { [CLOCK as symbol]: clock } },
})
mounted.push(wrapper)
await flushPromises()
return { wrapper, store }
}
const detailGets = () =>
requests
.filter((request) => request.method === 'GET' && /^\/messages\/[^/]+$/.test(request.url))
.map((request) => request.url.split('/').pop())
const patches = () => requests.filter((request) => request.method === 'PATCH').map((request) => request.url)
const rowOf = (wrapper: VueWrapper, id: string) => wrapper.get(`[data-conversation-row="${id}"]`)
const toggleAll = (wrapper: VueWrapper) => wrapper.get('[data-conversation-toggle-all]')
const panelOf = (wrapper: VueWrapper, id: string) => wrapper.get(`[data-conversation-panel="${id}"]`).element
async function show(wrapper: VueWrapper, ids: string[], visible = true) {
FakeIntersectionObserver.set(
ids.map((id) => panelOf(wrapper, id)),
visible,
)
await flushPromises()
}
it('칸 머리 오른쪽에 [모두 펼치기] — 누르면 불러온 줄(지금 메일 빼고)을 모두 펼치고 [모두 접기]가 된다', async () => {
serve()
const { wrapper } = await mountReader()
const button = toggleAll(wrapper)
expect(button.text()).toBe('모두 펼치기')
expect(button.element.tagName).toBe('BUTTON')
// 칸 제목과 같은 머리 줄
expect(button.element.parentElement?.querySelector('h3')?.textContent?.trim()).toBe('같은 대화 5통')
await button.trigger('click')
expect(toggleAll(wrapper).text()).toBe('모두 접기')
for (const id of ['c1', 'c2', 'c3', 'c5']) {
expect(rowOf(wrapper, id).get('[data-conversation-toggle]').attributes('aria-expanded')).toBe('true')
}
// 지금 보는 메일의 줄은 지금처럼 펼치지 않는다
expect(rowOf(wrapper, 'c4').find('[data-conversation-toggle]').exists()).toBe(false)
// 누른 것만으로는 본문을 받지 않는다 — 화면에 들어온 줄부터
expect(detailGets()).toEqual([])
expect(rowOf(wrapper, 'c3').find('[data-conversation-detail-loading]').attributes('aria-busy')).toBe('true')
await toggleAll(wrapper).trigger('click')
expect(toggleAll(wrapper).text()).toBe('모두 펼치기')
for (const id of ['c1', 'c2', 'c3', 'c5']) {
expect(rowOf(wrapper, id).get('[data-conversation-toggle]').attributes('aria-expanded')).toBe('false')
}
expect(wrapper.find('[data-conversation-panel]').exists()).toBe(false)
})
it('본문은 화면에 들어온 줄부터 하나씩 받는다 — 앞 줄을 받는 동안 다음 줄은 기다리고, 차례 전에 화면 밖으로 나간 줄은 건너뛴다', async () => {
serve({ hold: true })
const { wrapper } = await mountReader()
await toggleAll(wrapper).trigger('click')
await flushPromises()
await show(wrapper, ['c1', 'c2', 'c3'])
expect(detailGets()).toEqual(['c1'])
// c3 는 차례가 오기 전에 화면 밖으로 나갔다
await show(wrapper, ['c3'], false)
details.get('c1')?.resolve(ok(null))
await flushPromises()
expect(detailGets()).toEqual(['c1', 'c2'])
details.get('c2')?.resolve(ok(null))
await flushPromises()
expect(detailGets()).toEqual(['c1', 'c2'])
expect(rowOf(wrapper, 'c1').find('iframe').exists()).toBe(true)
expect(rowOf(wrapper, 'c3').find('iframe').exists()).toBe(false)
// 다시 들어오면 받는다 · 한 번 받은 줄은 다시 들어와도 다시 받지 않는다
await show(wrapper, ['c3', 'c1'])
expect(detailGets()).toEqual(['c1', 'c2', 'c3'])
})
it('읽음은 본문이 화면에 보인 메일만 — 받는 사이 화면 밖으로 나갔으면 들어올 때 읽음으로', async () => {
serve({ hold: true })
const { wrapper, store } = await mountReader()
await toggleAll(wrapper).trigger('click')
await flushPromises()
await show(wrapper, ['c2'])
expect(detailGets()).toEqual(['c2'])
// 받는 사이 내려서 화면 밖으로
await show(wrapper, ['c2'], false)
details.get('c2')?.resolve(ok(null))
await flushPromises()
expect(patches()).toEqual([])
expect(store.messages.find((message) => message.id === 'c2')?.isRead).toBe(false)
await show(wrapper, ['c2'])
expect(patches()).toEqual(['/messages/c2'])
expect(store.messages.find((message) => message.id === 'c2')?.isRead).toBe(true)
// 다시 드나들어도 다시 묻지 않는다
await show(wrapper, ['c2'], false)
await show(wrapper, ['c2'])
expect(patches()).toEqual(['/messages/c2'])
})
it('하나씩 다 펼쳐도 [모두 접기] — 하나라도 접으면 다시 [모두 펼치기]', async () => {
serve()
const { wrapper } = await mountReader()
for (const id of ['c1', 'c2', 'c3', 'c5']) await rowOf(wrapper, id).get('[data-conversation-toggle]').trigger('click')
await flushPromises()
expect(toggleAll(wrapper).text()).toBe('모두 접기')
await rowOf(wrapper, 'c1').get('[data-conversation-toggle]').trigger('click')
expect(toggleAll(wrapper).text()).toBe('모두 펼치기')
})
it('[더 보기]로 더 받은 줄은 접힌 채로 오고 단추는 다시 [모두 펼치기]', async () => {
const filler = Array.from({ length: 95 }, (_, index) =>
summary(`p${index}`, { receivedAt: at(2026, 9, 1, 9, index % 60) }),
)
const later = summary('c9', { receivedAt: at(2026, 10, 9, 9, 59) })
serve({
conversation: ({ params }) =>
params.cursor ? page([later], null, null) : page([...filler, ...THREAD], 'next', null),
})
const { wrapper } = await mountReader()
await toggleAll(wrapper).trigger('click')
expect(toggleAll(wrapper).text()).toBe('모두 접기')
await wrapper.get('[data-conversation-more]').trigger('click')
await flushPromises()
expect(rowOf(wrapper, 'c9').get('[data-conversation-toggle]').attributes('aria-expanded')).toBe('false')
expect(rowOf(wrapper, 'c2').get('[data-conversation-toggle]').attributes('aria-expanded')).toBe('true')
expect(toggleAll(wrapper).text()).toBe('모두 펼치기')
})
it('펼칠 줄이 없으면(지금 메일만 받았고 나머지를 받는 중) [모두 펼치기]가 없다', async () => {
const rest = deferred()
serve({ conversation: ({ params }) => (params.cursor ? rest.promise : page([CURRENT], 'next', 3)) })
const { wrapper } = await mountReader()
expect(wrapper.find('[data-conversation]').exists()).toBe(true)
expect(wrapper.find('[data-conversation-toggle-all]').exists()).toBe(false)
rest.resolve(page([FIRST, SECOND], null, 3))
await flushPromises()
expect(toggleAll(wrapper).text()).toBe('모두 펼치기')
})
it('펼친 줄 머리 오른쪽에 [답장](펼침 단추와 따로 놓인 링크) — 접힌 줄 · 임시 저장본의 줄에는 없다', async () => {
serve()
const { wrapper } = await mountReader()
expect(rowOf(wrapper, 'c2').find('[data-conversation-head-reply]').exists()).toBe(false)
await rowOf(wrapper, 'c2').get('[data-conversation-toggle]').trigger('click')
await rowOf(wrapper, 'c5').get('[data-conversation-toggle]').trigger('click')
await flushPromises()
const reply = rowOf(wrapper, 'c2').get('[data-conversation-head-reply]')
expect(reply.text()).toBe('답장')
expect(reply.attributes('href')).toBe('/mail/compose?replyTo=c2')
// 단추 안에 넣지 않고 머리 틀 안에 나란히 둔다
expect(reply.element.closest('button')).toBeNull()
expect(reply.element.parentElement?.querySelector('[data-conversation-toggle]')).not.toBeNull()
// 아래쪽 [답장] · [이 메일만 열기]는 그대로
expect(rowOf(wrapper, 'c2').find('[data-conversation-reply]').exists()).toBe(true)
expect(rowOf(wrapper, 'c2').find('[data-conversation-open]').exists()).toBe(true)
expect(rowOf(wrapper, 'c5').find('[data-conversation-head-reply]').exists()).toBe(false)
})
})
@@ -11,11 +11,15 @@
// 다시 받아 빠진 줄) 그 줄이 남았으면 그 줄의 펼침 단추로, 아니면 칸 제목(h3 — tabindex -1)으로. 받는 중이면 다 받을 때까지 기다리고,
// 칸이 없어졌으면 읽기 화면에 맡긴다(settled — 본문으로). 초점이 칸 밖에 있었으면 건드리지 않는다
// 받는 때(메일이 바뀜 · 쓰기 화면에서 돌아옴 · 메일함들의 통 수가 바뀜을 모아서)는 useConversationSync 가 정한다
// - 칸 머리 오른쪽 [모두 펼치기](15단계 — 디자인 원본 Reader15 "6" · 36 글자 단추): 불러온 줄을 모두 펼치고 [모두 접기]가 된다(하나씩 다 펼쳐도).
// 본문은 펼친 안이 화면에 들어오는 줄부터 하나씩 받는다(useConversationPanelVisibility → 스토어의 noteConversationPanelVisible).
// 펼칠 줄이 없으면(지금 메일뿐 — 나머지를 받는 중) 단추가 없다
import { computed, nextTick, ref, watch } from 'vue'
import AppIcon from '@/components/AppIcon.vue'
import ConversationMessageRow from '@/components/mail/ConversationMessageRow.vue'
import { useClock } from '@/composables/useClock'
import { useConversationPanelVisibility } from '@/composables/useConversationPanelVisibility'
import { useConversationSync } from '@/composables/useConversationSync'
import { MAIL_CONVERSATION_TEXT } from '@/shared/constants/mail-conversation'
import { formatCount, formatTraceCode } from '@/shared/utils/format'
@@ -77,6 +81,15 @@ function loadMore() {
if (!mailStore.conversationLoadingMore) void mailStore.loadMoreConversation()
}
/** 펼칠 줄이 있다 — [모두 펼치기] · [모두 접기]를 둔다 */
const canExpandAll = computed(() => mailStore.conversationExpandableIds.length > 0)
/** [모두 펼치기] ↔ [모두 접기] — 하나라도 접혀 있으면 모두 펼친다 */
function toggleAll() {
if (mailStore.conversationAllExpanded) mailStore.collapseAllConversation()
else mailStore.expandAllConversation()
}
// ── 초점 지키기 ─────────────────────────────────────────
/** 다시 그릴 때 바뀌는 것 — 칸 · 안내 · [더 보기] · 줄 · 펼친 줄과 그 본문의 상태 */
@@ -146,6 +159,13 @@ watch(
{ flush: 'pre' },
)
// 펼친 안이 화면에 들어오고 나가는 것을 스토어에 알린다 — [모두 펼치기]의 본문은 화면에 들어온 줄부터 하나씩 (15단계)
useConversationPanelVisibility({
root: sectionRef,
watchKey: () => renderKey.value,
onChange: (id, inView) => mailStore.noteConversationPanelVisible(id, inView),
})
// 받지 못했을 때만 읽어 준다 — 비웠다가 넣어 다시 못 받아도 다시 읽게
watch(failed, async (isFailed) => {
announcement.value = ''
@@ -163,14 +183,25 @@ watch(failed, async (isFailed) => {
aria-labelledby="reader-conversation-title"
data-conversation
>
<h3
id="reader-conversation-title"
ref="titleRef"
class="conversation__title"
tabindex="-1"
>
{{ title }}
</h3>
<div class="conversation__head">
<h3
id="reader-conversation-title"
ref="titleRef"
class="conversation__title"
tabindex="-1"
>
{{ title }}
</h3>
<button
v-if="canExpandAll"
type="button"
class="conversation__toggle-all"
data-conversation-toggle-all
@click="toggleAll"
>
{{ mailStore.conversationAllExpanded ? MAIL_CONVERSATION_TEXT.collapseAll : MAIL_CONVERSATION_TEXT.expandAll }}
</button>
</div>
<ol class="conversation__list">
<ConversationMessageRow
v-for="row in rows"
@@ -245,6 +276,33 @@ watch(failed, async (isFailed) => {
border-top: var(--border-width) solid var(--color-border-subtle);
}
/* 칸 머리 — 제목 · 오른쪽 [모두 펼치기] (디자인 원본 Reader15 "6" — 높이 36) */
.conversation__head {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-8);
min-height: var(--size-notice-action);
}
/* [모두 펼치기] · [모두 접기] — 36 글자 단추, 펼친 줄 머리의 [답장]과 같은 크기(안내 줄 단추의 밀도 예외 재사용). 글자 끝을 상자 끝에 맞춘다 */
.conversation__toggle-all {
flex: 0 0 auto;
min-height: var(--size-notice-action);
margin-right: calc(var(--space-10) * -1);
padding: 0 var(--space-10);
border: 0;
border-radius: var(--radius-md);
background: transparent;
color: var(--color-brand);
font-weight: 600;
}
.conversation__toggle-all:hover {
background: var(--color-brand-subtle);
color: var(--color-brand-strong);
}
.conversation__title {
margin: 0;
outline-offset: var(--focus-ring-offset);
@@ -9,6 +9,7 @@
// - 지운 뒤: 분할 보기는 다음 메일(없으면 이전 메일, 그것도 없으면 목록), 목록 보기는 목록으로 (useMessageRemoval 의 leaveRemoved).
// 그사이 다른 목록 · 다른 메일로 옮겨 갔거나 서버가 그대로 두었으면 움직이지 않는다
// - DELETE /api/messages/{id} 는 쓰지 않는다 (그사이 다른 창이 휴지통으로 옮긴 메일을 확인 없이 영구 삭제하게 된다)
// - 읽기 칸이 좁으면 아이콘만 (15단계 — MailToolButton 의 collapsible · MessageReader 의 컨테이너 쿼리 — 「영구 삭제」는 이름이 길어 더 넓은 폭에서 접는다)
import { computed, ref, watch } from 'vue'
import ModalDialog from '@/components/feedback/ModalDialog.vue'
@@ -95,6 +96,7 @@ watch([held, permanent, () => props.message.id], () => {
:label="permanent ? MAIL_BULK_TEXT.purge : MAIL_BULK_TEXT.delete"
tone="danger"
:disabled="working"
collapsible
data-reader-delete
@click="onClick"
/>
@@ -10,12 +10,15 @@
// - 행을 누르면 지금처럼 메일을 연다. Ctrl(⌘)+클릭은 하나씩, Shift+클릭은 범위로 고른다 (새 탭 · 새 창으로 열지 않는다)
// - 아직 보내지 않은 메일(9단계)은 고를 수 없다 — 고르기 상자는 꺼진 모양(aria-disabled · 까닭은 title), Ctrl · Shift + 클릭도 고르지 않는다
// (옮기거나 지워도 엔진은 그 시각에 보낸다 — 열어서 취소하거나 삭제한다. 스토어도 건너뛴다 — selection.ts)
// 끌어다 놓기(15단계 — 디자인 원본 ListDrag15): 줄을 메일함 패널로 끌어 옮긴다 — 고른 줄이면 고른 것 모두, 아니면 이 줄만.
// 보내기를 기다리는 메일의 줄은 끌 수 없다. 줄의 링크가 끌어 가던 메일 주소 대신 우리 끌기 자료만 싣는다 (useMailRowDrag)
import { computed } from 'vue'
import type { RouteLocationRaw } from 'vue-router'
import AppIcon from '@/components/AppIcon.vue'
import HighlightedText from '@/components/mail/HighlightedText.vue'
import MailCheckbox from '@/components/mail/MailCheckbox.vue'
import { useMailRowDrag } from '@/composables/useMailDrag'
import { MAIL_BULK_TEXT, MAIL_NO_SUBJECT } from '@/shared/constants/mail'
import { SEND_LATER_TEXT } from '@/shared/constants/mail-send-later'
import type { MailboxRole, MessageListItem } from '@/shared/types/mail'
@@ -60,6 +63,8 @@ const props = withDefaults(defineProps<Props>(), {
const emit = defineEmits<Emits>()
const drag = useMailRowDrag()
const who = computed(() => rowWho(props.message, { role: props.role, mixed: props.mixed, view: 'split' }))
const subject = computed(() => props.message.subject || MAIL_NO_SUBJECT)
/** 붙잡아 둔 발송이면 "예약 · …" · "보내는 중" (9단계) */
@@ -104,7 +109,10 @@ function onMouseDown(event: MouseEvent) {
},
]"
:data-row-id="message.id"
:draggable="!held"
@mousedown="onMouseDown"
@dragstart="(event: DragEvent) => drag.start(event, message.id)"
@dragend="drag.end"
>
<MailCheckbox
class="message-row__check"
@@ -44,6 +44,7 @@ import { isHeldMessage } from '@/shared/utils/pending-send'
import { useAuthStore } from '@/stores/auth.store'
import { useContactsStore } from '@/stores/contacts.store'
import { useMailBlockStore } from '@/stores/mail-block.store'
import { useMailBlockRunStore } from '@/stores/mail-block-run.store'
import { useMailFilterStore } from '@/stores/mail-filter.store'
import { useMailStore } from '@/stores/mail.store'
@@ -63,6 +64,8 @@ const mailStore = useMailStore()
const filterStore = useMailFilterStore()
const contactsStore = useContactsStore()
const blockStore = useMailBlockStore()
/** 차단 창의 새 칸을 지금 고를 수 없는 까닭 — 이 탭에서 받은편지함을 정리하거나 스팸메일함을 비우는 중 (15단계 — startBlock) */
const blockRunStore = useMailBlockRunStore()
const contactDialog = useMailContactDialog()
const print = useMailPrint()
const senderBlock = useSenderBlock()
@@ -93,6 +96,11 @@ const blockable = computed(
/** 이미 막고 있는 차단 (주소 또는 도메인) */
const senderBlockEntry = computed(() => (blockable.value ? blockStore.blockOf(sender.value) : null))
const inJunk = computed(() => removal.isInJunk(props.message))
/** 받은편지함의 메일이다 — 차단 창의 새 칸을 켜면 「이 메일도 …」를 켠 채 잠근다 (15단계) */
const inInbox = computed(() => {
const inboxId = mailStore.mailboxByRole('inbox')?.id
return inboxId !== undefined && props.message.mailboxIds.includes(inboxId)
})
const items = computed<EditorMenuItem[]>(() => {
const list: EditorMenuItem[] = [
@@ -206,14 +214,15 @@ function focusButton() {
/**
* 차단 확인 창의 [차단] — 창을 닫고 막는다. 이 메일을 스팸메일함으로 옮겼으면 삭제처럼 다음 메일 · 목록으로.
* 받은편지함의 그 보낸 사람 메일도 옮기기로 했으면(cleanInbox — 15단계) 정리는 알림이 이어 보인다(기다리지 않는다).
* 기다리는 사이 다른 목록 · 다른 메일로 옮겨 갔으면(stale · leaveRemoved 의 stillShowing) 움직이지 않는다
*/
async function onBlock(target: string, moveToJunk: boolean) {
async function onBlock(target: string, moveToJunk: boolean, cleanInbox: boolean) {
blockDialogOpen.value = false
const message = props.message
const source = mailStore.listSource
const nextId = mailStore.neighborAfterRemoval(message.id)
const outcome = await senderBlock.block(message, target, moveToJunk)
const outcome = await senderBlock.block(message, target, moveToJunk, cleanInbox)
if (!outcome.moved || outcome.stale) return
const removed = !mailStore.isListedMessage(message.id)
await removal.leaveRemoved({ removed, nextId: removed ? nextId : null }, source, props.layout, message.id)
@@ -269,6 +278,8 @@ watch(
:open="blockDialogOpen"
:sender-email="sender"
:in-junk="inJunk"
:in-inbox="inInbox"
:cleanup-block="blockRunStore.startBlock"
@confirm="onBlock"
@cancel="blockDialogOpen = false"
/>
@@ -0,0 +1,138 @@
<script setup lang="ts">
// 읽기 화면의 「이동 ▾」 — 열린 메일 한 통을 고른 메일함으로 (15단계 — 디자인 원본 Reader15 "1 · 2 · 3", README "15단계")
// - 자리: 답장 · 전체 답장 · 전달 | 이동 ▾ · 스팸 신고 · 삭제 · ⋯ — 메일이 어디로 가는지를 한 묶음에 둔다
// - 메뉴는 목록 도구 줄의 [이동 ▾]와 같은 부품 · 같은 목록(mail-move-menu.ts): 메일함 패널처럼 들여 쓴 나무에서 그 메일이 든 메일함
// (검색 결과에서 연 메일도 그 메일이 든 메일함) · 임시보관함 · 휴지통을 뺀다. 그 메일이 든 메일함에 하위 메일함이 있으면 고를 수 없는
// 흐린 줄로 남기고 이름 아래에 "이 메일이 있는 메일함". 휴지통으로는 [삭제]로 옮긴다
// - 옮긴 뒤는 [삭제]와 같은 길: 분할 보기는 다음 메일, 목록 보기는 목록으로 + "메일 1통을 거래처 › 2025로 옮겼습니다." + [되돌리기]
// (같은 이름이 다른 곳에도 있을 때만 위 메일함 이름 — useMailMoveFeedback). 스팸메일함으로 옮기면 [스팸 신고], 스팸메일함에서 꺼내면 [스팸 아님]과 같다
// - 보내기를 기다리는 메일(보내는 중 · 예약)에는 없다 — 읽기 화면이 그리지 않는다(취소해야만 보낸편지함을 떠난다)
// - 키보드는 목록의 [이동 ▾]와 같다: Enter · Space · 아래 화살표로 열면 첫 항목에 초점, Esc 는 메뉴만 닫고 이 단추로 돌아간다.
// 마우스로 열면 메뉴 자체에 초점(EditorPopup). 메뉴가 화면 밖으로 넘치면 단추 오른쪽 끝에 맞춘다(EditorPopup 의 좌우 바꾸기)
// - 읽기 칸이 좁으면 이름을 숨기고 아이콘 + ▾ 만(MailToolButton 의 collapsible — MessageReader 의 컨테이너 쿼리)
import { computed, ref } from 'vue'
import EditorMenu from '@/components/mail/editor/EditorMenu.vue'
import type { EditorMenuItem } from '@/components/mail/editor/editor-menu'
import MailToolButton from '@/components/mail/MailToolButton.vue'
import { moveMenuItems } from '@/components/mail/mail-move-menu'
import { useMessageRemoval } from '@/composables/useMessageRemoval'
import { MAIL_BULK_TEXT } from '@/shared/constants/mail'
import type { MessageSummary } from '@/shared/types/mail'
import { hasMoveTarget, mailMoveTargets } from '@/shared/utils/mail-move-targets'
import { useMailStore } from '@/stores/mail.store'
interface Props {
message: MessageSummary
/** split: 옮기면 다음 메일을 연다 · list: 옮기면 목록으로 */
layout: 'split' | 'list'
}
const props = defineProps<Props>()
const mailStore = useMailStore()
const removal = useMessageRemoval()
const buttonRef = ref<InstanceType<typeof MailToolButton> | null>(null)
const open = ref<boolean>(false)
/** 키보드로 열었다 — 첫 항목에 초점 */
const byKeyboard = ref<boolean>(false)
const working = ref<boolean>(false)
const trigger = computed<HTMLElement | null>(() => buttonRef.value?.element ?? null)
/** 옮길 곳 — 그 메일이 든 메일함은 빼거나(하위가 있으면) 고를 수 없는 줄로 */
const targets = computed(() => mailMoveTargets(mailStore.mailboxes, new Set(props.message.mailboxIds)))
const items = computed<EditorMenuItem[]>(() => moveMenuItems(targets.value, MAIL_BULK_TEXT.moveHere))
/** 옮길 곳이 하나도 없으면(메일함 목록을 아직 못 받음) 그리지 않는다 */
const available = computed(() => hasMoveTarget(targets.value))
function openMenu(keyboard: boolean) {
if (working.value || !available.value) return
byKeyboard.value = keyboard
open.value = true
}
function onClick(event: MouseEvent) {
if (open.value) {
open.value = false
return
}
// detail 0 — Enter · Space 로 눌렀다
openMenu(event.detail === 0)
}
function onKeydown(event: KeyboardEvent) {
if (event.key !== 'ArrowDown' || open.value) return
event.preventDefault()
openMenu(true)
}
function onClose(restoreFocus: boolean) {
open.value = false
if (restoreFocus) trigger.value?.focus()
}
async function onSelect(id: string) {
const target = targets.value.find((row) => row.mailbox.id === id && !row.disabled)
if (!target || working.value) return
open.value = false
trigger.value?.focus()
const message = props.message
const source = mailStore.listSource
working.value = true
try {
const outcome = await removal.moveTo(message, target.mailbox)
await removal.leaveRemoved(outcome, source, props.layout, message.id)
} finally {
working.value = false
}
}
</script>
<template>
<div
v-if="available"
class="reader-move"
>
<MailToolButton
ref="buttonRef"
icon="folder-move"
:label="MAIL_BULK_TEXT.move"
collapsible
menu
aria-haspopup="menu"
:aria-expanded="open"
:disabled="working"
data-reader-move
@click="onClick"
@keydown="onKeydown"
/>
<EditorMenu
v-if="open"
class="reader-move__menu"
:label="MAIL_BULK_TEXT.moveMenu"
:items="items"
:trigger="trigger"
:focus-content="!byKeyboard"
@select="onSelect"
@close="onClose"
/>
</div>
</template>
<style scoped>
/* 메뉴는 이 단추 바로 아래 — 도구 줄 아래 선 밑에서 연다 (EditorPopup) */
.reader-move {
position: relative;
display: inline-flex;
}
/* 옮길 메일함 메뉴 — 목록의 [이동 ▾]와 같은 폭 (MailBulkMoveButton) */
.reader-move__menu {
width: max-content;
min-width: 12.5rem;
/* 좁은 화면에서는 단추의 어느 끝에 맞춰도 화면 안에 들게 — 폭 360 이면 280 까지 */
max-width: min(20rem, calc(100vw - 5rem));
overflow-wrap: anywhere;
}
</style>
@@ -0,0 +1,606 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { mount, type VueWrapper } from '@vue/test-utils'
import { createPinia, setActivePinia, type Pinia } from 'pinia'
import { ERROR_CODE_MESSAGES } from '@/shared/constants/error-codes.generated'
import type { Mailbox, MessageDetail, MessagesPatch, MessageSummary } from '@/shared/types/mail'
import type { MailListSource } from '@/shared/utils/mail-list-source'
import { useAuthStore } from '@/stores/auth.store'
import { useMailFilterRunStore } from '@/stores/mail-filter-run.store'
import { useMailSettingsStore } from '@/stores/mail-settings.store'
import { useMailStore } from '@/stores/mail.store'
import { useToastStore } from '@/stores/toast.store'
import { fail, flushPromises, installMockApi, ok, restoreApiAdapter, type MockRequest, type MockResult } from '@/test-utils/mock-api'
import { createTestRouter } from '@/test-utils/router'
import MessageReader from './MessageReader.vue'
// 읽기 화면의 15단계 — 예약 안내 줄 [지금 보내기] · 보낸 사람 차단 창의 「받은편지함에 있는 이 보낸 사람의 메일도 …」
// (디자인 원본 Reader15 "지금 보내기" · Mailbox15 "보낸 사람 차단 창" · 계약 2절 "보내기 취소 · 예약 발송" · "보낸 사람 차단")
const USER = { email: 'ara.kim@arasystem.it.kr', name: '김아라' }
const INBOX: Mailbox = { id: 'inbox', name: '받은편지함', role: 'inbox', parentId: null, sortOrder: 0, totalCount: 120, unreadCount: 0 }
const SENT: Mailbox = { ...INBOX, id: 'sent', name: '보낸편지함', role: 'sent', totalCount: 2 }
const DRAFTS: Mailbox = { ...INBOX, id: 'drafts', name: '임시보관함', role: 'drafts', totalCount: 0 }
const JUNK: Mailbox = { ...INBOX, id: 'junk', name: '스팸메일함', role: 'junk', totalCount: 0 }
const TRASH: Mailbox = { ...INBOX, id: 'trash', name: '휴지통', role: 'trash', totalCount: 0 }
const MAILBOXES = [INBOX, SENT, DRAFTS, JUNK, TRASH]
const NEWSLETTER: MessageDetail = {
id: 'm1',
threadId: 't1',
mailboxIds: ['inbox'],
from: { name: '클라우드 브리프', email: 'news@cloudbrief.example' },
to: [USER],
subject: '[클라우드 브리프] 11월 둘째 주',
preview: '',
receivedAt: '2026-11-09T00:00:00Z',
isRead: true,
isFlagged: false,
hasAttachment: false,
size: 100,
pendingSend: null,
cc: [],
bcc: [],
replyTo: [],
sentAt: null,
messageId: null,
references: [],
htmlBody: null,
textBody: '이번 주 소식입니다.',
hasBlockedImages: false,
imagesShown: false,
attachments: [],
}
const SEND_AT = '2026-11-10T00:00:00.000Z'
/** 내가 예약한 메일 — 보낸편지함 · 예약됨 */
const SCHEDULED: MessageDetail = {
...NEWSLETTER,
id: 's1',
mailboxIds: ['sent'],
from: USER,
to: [{ name: '이도윤', email: 'doyun.lee@arasystem.it.kr' }],
subject: '11월 셋째 주 배포 일정 확인 부탁드립니다',
pendingSend: { kind: 'scheduled', sendAt: SEND_AT },
}
function deferred() {
let resolve: (result: MockResult) => void = () => undefined
const promise = new Promise<MockResult>((settle) => {
resolve = settle
})
return { promise, resolve }
}
interface MountOptions {
message?: MessageDetail
source?: MailListSource
layout?: 'split' | 'list'
extra?: (request: MockRequest) => MockResult | Promise<MockResult> | undefined
}
describe('MessageReader — 15단계 지금 보내기 · 차단할 때 받은 메일 옮기기', () => {
let pinia: Pinia
let requests: MockRequest[]
const mounted: VueWrapper[] = []
beforeEach(() => {
pinia = createPinia()
setActivePinia(pinia)
useAuthStore().startSession(USER)
vi.spyOn(window, 'open').mockImplementation(() => null)
})
afterEach(() => {
for (const wrapper of mounted.splice(0)) {
if (!wrapper.vm.$.isUnmounted) wrapper.unmount()
}
vi.restoreAllMocks()
useToastStore().clear()
restoreApiAdapter()
document.body.innerHTML = ''
})
async function mountWith(options: MountOptions = {}) {
const message = options.message ?? NEWSLETTER
const source = options.source ?? { kind: 'mailbox', mailboxId: message.mailboxIds[0] ?? 'inbox' }
requests = installMockApi((request) => {
const handled = options.extra?.(request)
if (handled) return handled
if (request.url === '/mail-filters') return ok({ rules: [], version: 'v1', isActive: true })
if (request.url === '/contacts') return ok({ items: [], pageInfo: { hasNext: false, nextCursor: null, total: 0 } })
if (request.url === '/mail-blocks' && request.method === 'GET') return ok({ senders: [], isActive: true })
if (request.url === '/mail-blocks' && request.method === 'POST') {
return ok({ address: (request.data as { address: string }).address, blockedAt: '2026-11-09T05:00:00.000Z' }, 201)
}
if (request.url === '/mailboxes') return ok({ items: MAILBOXES, pageInfo: { hasNext: false, nextCursor: null, total: 5 } })
if (request.url === '/messages' && request.method === 'GET') {
return ok({ items: [], pageInfo: { hasNext: false, nextCursor: null, total: 0 } })
}
if (request.url === '/messages') return ok({ updatedIds: (request.data as MessagesPatch).ids, failedIds: [] })
if (request.url === '/scheduled-messages') {
return ok({ items: [{ ...SCHEDULED, id: 'm2' }], pageInfo: { hasNext: false, nextCursor: null, total: 1 } })
}
return ok({ usedBytes: 0, limitBytes: null, attachmentMaxBytes: 1, attachmentTotalMaxBytes: 1 })
})
const store = useMailStore()
store.mailboxes = MAILBOXES
store.listSource = source
store.messages = [message, { ...message, id: 'm2', subject: '다음 메일', pendingSend: message.pendingSend }] as MessageSummary[]
store.pageInfo = { hasNext: false, nextCursor: null, total: 2 }
store.listStatus = 'ready'
store.selected = message
store.selectedId = message.id
store.selectedStatus = 'ready'
const router = createTestRouter()
const listPath = source.kind === 'scheduled' ? '/mail/scheduled' : `/mail/${message.mailboxIds[0] ?? 'inbox'}`
await router.push(listPath)
await router.push(`${listPath}/${message.id}`)
await router.isReady()
const wrapper = mount(MessageReader, {
props: { layout: options.layout ?? 'split' },
global: { plugins: [pinia, router] },
attachTo: document.body,
})
mounted.push(wrapper)
await flushPromises()
return { wrapper, router, store }
}
const releases = () => requests.filter((request) => request.method === 'POST' && request.url.endsWith('/release'))
const toasts = () => useToastStore().toasts
/** 설정 › 보내기 취소 10초를 받아 두었다 (메일 화면에 들어올 때 받는다 — MailLayout) */
function knowUndoSeconds() {
const settings = useMailSettingsStore()
settings.settings = { undoSendSeconds: 10, externalImages: 'ask', groupByConversation: false }
settings.status = 'ready'
}
describe('예약 안내 줄 [지금 보내기]', () => {
it('POST …/release(본문 없음) → "메일을 보냈습니다." + [보내기 취소](설정한 초로 센 마감) · 예약됨에서 빠지고 다음 예약 메일 · 예약됨 수를 다시 묻는다', async () => {
knowUndoSeconds()
const { wrapper, router, store } = await mountWith({
message: SCHEDULED,
source: { kind: 'scheduled' },
extra: (request) =>
request.url === '/scheduled-messages/s1/release'
? ok({ id: 's1', rejectedRecipients: [], pendingSend: { kind: 'undo', sendAt: new Date(Date.now() + 10_000).toISOString() } })
: undefined,
})
const countsBefore = requests.filter((request) => request.url === '/scheduled-messages').length
await wrapper.get('[data-scheduled-send]').trigger('click')
await flushPromises()
expect(releases().map((request) => [request.url, request.data])).toEqual([['/scheduled-messages/s1/release', undefined]])
expect(store.messages.map((message) => message.id)).toEqual(['m2'])
expect(router.currentRoute.value.fullPath).toBe('/mail/scheduled/m2')
expect(requests.filter((request) => request.url === '/scheduled-messages').length).toBeGreaterThan(countsBefore)
const [toast] = toasts()
expect(toasts()).toHaveLength(1)
expect(toast).toMatchObject({ kind: 'success', message: '메일을 보냈습니다.', action: { label: '보내기 취소' } })
// 마감은 설정한 10초에서 1초를 뺀 때 — 이 PC 시계가 서버와 어긋나도 알림이 늘어나지 않게
const span = (toast?.expiresAt ?? 0) - (toast?.shownAt ?? 0)
expect(span).toBeGreaterThan(8_900)
expect(span).toBeLessThanOrEqual(9_000)
})
it('누르는 동안 세 단추를 함께 막는다(aria-disabled) — [지금 보내기]는 aria-busy, 다시 눌러도 한 번만 보낸다', async () => {
const pending = deferred()
const { wrapper } = await mountWith({
message: SCHEDULED,
source: { kind: 'scheduled' },
extra: (request) => (request.url === '/scheduled-messages/s1/release' ? pending.promise : undefined),
})
const notice = wrapper.get('[data-notice="scheduled"]')
await notice.get('[data-scheduled-send]').trigger('click')
expect(notice.findAll('button').map((button) => button.attributes('aria-disabled'))).toEqual(['true', 'true', 'true'])
expect(notice.get('[data-scheduled-send]').attributes('aria-busy')).toBe('true')
await notice.get('[data-scheduled-send]').trigger('click')
await notice.get('[data-scheduled-cancel]').trigger('click')
await flushPromises()
expect(releases()).toHaveLength(1)
expect(requests.some((request) => request.method === 'DELETE')).toBe(false)
pending.resolve(ok({ id: 's1', rejectedRecipients: [], pendingSend: null }))
await flushPromises()
})
it('바로 보냈거나 예약 시각이 막 지나 이미 보낸 메일(pendingSend: null) — "메일을 보냈습니다." + [보낸편지함 보기]', async () => {
const { wrapper } = await mountWith({
message: SCHEDULED,
source: { kind: 'scheduled' },
extra: (request) =>
request.url === '/scheduled-messages/s1/release' ? ok({ id: 's1', rejectedRecipients: [], pendingSend: null }) : undefined,
})
await wrapper.get('[data-scheduled-send]').trigger('click')
await flushPromises()
expect(toasts()).toMatchObject([
{
kind: 'success',
message: '메일을 보냈습니다.',
expiresAt: null,
action: { label: '보낸편지함 보기', to: { name: 'mail-mailbox', params: { mailboxId: 'sent' } } },
},
])
})
it('받는 사람 일부가 받지 못하면 그 문장 — 보내기 취소를 기다리는 동안이면 [보내기 취소]와 함께', async () => {
const { wrapper } = await mountWith({
message: SCHEDULED,
source: { kind: 'scheduled' },
extra: (request) =>
request.url === '/scheduled-messages/s1/release'
? ok({
id: 's1',
rejectedRecipients: [{ email: 'nobody@arasystem.it.kr', reason: 'not-found' }],
pendingSend: { kind: 'undo', sendAt: new Date(Date.now() + 10_000).toISOString() },
})
: undefined,
})
await wrapper.get('[data-scheduled-send]').trigger('click')
await flushPromises()
expect(toasts()).toMatchObject([
{ kind: 'error', message: '보냈지만 1명은 받지 못합니다. nobody@arasystem.it.kr (없는 주소)', action: { label: '보내기 취소' } },
])
})
it('보낸편지함에서 연 예약 메일 — 그 자리에 남고 안내 줄만 사라진다(초점은 본문으로) · 붙잡힌 발송은 보내기 취소를 기다리는 것으로 바뀐다', async () => {
const sendAt = new Date(Date.now() + 10_000).toISOString()
const { wrapper, router, store } = await mountWith({
message: SCHEDULED,
source: { kind: 'mailbox', mailboxId: 'sent' },
extra: (request) =>
request.url === '/scheduled-messages/s1/release'
? ok({ id: 's1', rejectedRecipients: [], pendingSend: { kind: 'undo', sendAt } })
: undefined,
})
await wrapper.get('[data-scheduled-send]').trigger('click')
await flushPromises()
expect(router.currentRoute.value.fullPath).toBe('/mail/sent/s1')
expect(store.selected?.pendingSend).toEqual({ kind: 'undo', sendAt })
expect(wrapper.find('[data-notice="scheduled"]').exists()).toBe(false)
expect(document.activeElement).toBe(wrapper.get('iframe').element)
})
describe('보내지 못했거나 결과를 모를 때(503 MAIL_001 · SYS_001) — 그 메일을 다시 받아 붙잡은 발송 · 메일함으로 가른다', () => {
/** 예약은 풀렸는데 다시 내지 못한 메일 — 서버가 결과를 모를 때 남기는 꼴(보낸편지함 · 붙잡은 발송 없음) */
const UNSCHEDULED: MessageDetail = { ...SCHEDULED, pendingSend: null }
/** 보낸편지함 목록 — 다시 맞추면 그 메일은 남고(예약만 풀림) 다음 예약 메일도 있다 */
const sentList = (first: MessageDetail) =>
ok({ items: [first, { ...SCHEDULED, id: 'm2', subject: '다음 메일' }], pageInfo: { hasNext: false, nextCursor: null, total: 2 } })
it('임시보관함으로 되돌아갔으면 "지금 보내지 못해 임시보관함으로 옮겼습니다." + [임시보관함 보기] · 예약됨을 떠났으니 안내 줄을 내리고 다음 예약 메일', async () => {
const { wrapper, router } = await mountWith({
message: SCHEDULED,
source: { kind: 'scheduled' },
extra: (request) => {
if (request.url === '/scheduled-messages/s1/release') return fail(503, 'MAIL_001')
// 엔진이 거절해 서버가 그 메일을 임시보관함으로 되돌렸다
if (request.method === 'GET' && request.url === '/messages/s1') {
return ok({ ...SCHEDULED, mailboxIds: ['drafts'], pendingSend: null })
}
return undefined
},
})
const listsBefore = requests.filter((request) => request.url === '/scheduled-messages').length
await wrapper.get('[data-scheduled-send]').trigger('click')
await flushPromises()
// 사전 문구(MAIL_001) 대신 화면 글 하나
expect(toasts()).toMatchObject([
{
kind: 'error',
message: '지금 보내지 못해 임시보관함으로 옮겼습니다.',
action: { label: '임시보관함 보기', to: { name: 'mail-mailbox', params: { mailboxId: 'drafts' } } },
},
])
expect(router.currentRoute.value.fullPath).toBe('/mail/scheduled/m2')
expect(requests.filter((request) => request.url === '/scheduled-messages').length).toBeGreaterThan(listsBefore)
})
it('예약이 그대로면(예약을 거두기 전에 멈춤) "지금 보내지 못했습니다. 다시 시도해 주세요." + [다시 시도] · 안내 줄은 그대로 — [다시 시도]는 같은 요청을 보낸다', async () => {
let attempt = 0
const { wrapper, router, store } = await mountWith({
message: SCHEDULED,
source: { kind: 'scheduled' },
extra: (request) => {
if (request.url === '/scheduled-messages/s1/release') {
attempt += 1
return attempt === 1 ? fail(503, 'MAIL_001') : ok({ id: 's1', rejectedRecipients: [], pendingSend: null })
}
// 예약 제출이 아직 기다린다 — 예약 표시 그대로
if (request.method === 'GET' && request.url === '/messages/s1') return ok(SCHEDULED)
return undefined
},
})
await wrapper.get('[data-scheduled-send]').trigger('click')
await flushPromises()
const [failed] = toasts()
expect(toasts()).toMatchObject([
{ kind: 'error', message: '지금 보내지 못했습니다. 다시 시도해 주세요.', action: { label: '다시 시도' } },
])
expect(router.currentRoute.value.fullPath).toBe('/mail/scheduled/s1')
expect(store.selected?.pendingSend).toEqual(SCHEDULED.pendingSend)
expect(wrapper.find('[data-notice="scheduled"]').exists()).toBe(true)
expect(wrapper.get('[data-scheduled-send]').attributes('aria-disabled')).toBeUndefined()
const action = failed ? useToastStore().takeAction(failed.id) : null
if (!action || !('run' in action)) throw new Error('단추가 없다')
action.run()
await flushPromises()
expect(releases()).toHaveLength(2)
expect(toasts().map((toast) => toast.message)).toEqual(['메일을 보냈습니다.'])
})
it('예약만 풀렸으면(보낸편지함 · 기다리는 발송 없음 — 이미 나갔을 수 있다) 예약됨에서 연 메일: "보냈는지 확인하지 못했습니다 …" + [다시 시도] · 예약됨에서 빠지고 다음 예약 메일', async () => {
let attempt = 0
const { wrapper, router, store } = await mountWith({
message: SCHEDULED,
source: { kind: 'scheduled' },
extra: (request) => {
if (request.url === '/scheduled-messages/s1/release') {
attempt += 1
return attempt === 1 ? fail(503, 'MAIL_001') : ok({ id: 's1', rejectedRecipients: [], pendingSend: null })
}
if (request.method === 'GET' && request.url === '/messages/s1') return ok(UNSCHEDULED)
return undefined
},
})
await wrapper.get('[data-scheduled-send]').trigger('click')
await flushPromises()
const [failed] = toasts()
expect(toasts()).toMatchObject([
{ kind: 'error', message: '메일을 보냈는지 확인하지 못했습니다. [다시 시도]를 누르면 아직 보내지 않았을 때만 보냅니다.', action: { label: '다시 시도' } },
])
expect(store.messages.map((message) => message.id)).toEqual(['m2'])
expect(router.currentRoute.value.fullPath).toBe('/mail/scheduled/m2')
// 분할 보기 화면이 다음 메일을 연다 (이 시험은 읽기 화면만 띄운다)
store.selectedId = 'm2'
// [다시 시도]는 같은 요청 — 서버가 이어 낸다. 다른 메일을 보고 있으니 그 자리에 둔다
const action = failed ? useToastStore().takeAction(failed.id) : null
if (!action || !('run' in action)) throw new Error('단추가 없다')
action.run()
await flushPromises()
expect(releases()).toHaveLength(2)
expect(toasts().map((toast) => toast.message)).toEqual(['메일을 보냈습니다.'])
expect(router.currentRoute.value.fullPath).toBe('/mail/scheduled/m2')
})
it('예약만 풀렸으면 보낸편지함에서 연 메일도 안내 줄을 내린다 — 그 자리에 남고(목록에도 남는다) 초점은 본문으로', async () => {
const { wrapper, router, store } = await mountWith({
message: SCHEDULED,
source: { kind: 'mailbox', mailboxId: 'sent' },
extra: (request) => {
if (request.url === '/scheduled-messages/s1/release') return fail(503, 'MAIL_001')
if (request.method === 'GET' && request.url === '/messages/s1') return ok(UNSCHEDULED)
if (request.method === 'GET' && request.url === '/messages') return sentList(UNSCHEDULED)
return undefined
},
})
await wrapper.get('[data-scheduled-send]').trigger('click')
await flushPromises()
expect(toasts()).toMatchObject([
{ kind: 'error', message: '메일을 보냈는지 확인하지 못했습니다. [다시 시도]를 누르면 아직 보내지 않았을 때만 보냅니다.', action: { label: '다시 시도' } },
])
expect(router.currentRoute.value.fullPath).toBe('/mail/sent/s1')
expect(store.selected?.pendingSend).toBeNull()
expect(store.selected?.mailboxIds).toEqual(['sent'])
expect(store.messages.map((message) => message.id)).toEqual(['s1', 'm2'])
expect(wrapper.find('[data-notice="scheduled"]').exists()).toBe(false)
expect(document.activeElement).toBe(wrapper.get('iframe').element)
})
it('결과를 모르는 실패(504 SYS_001 — 앞단이 기다리다 끊음)도 같은 갈래 — 사전 문구 알림 없이 다시 받아 가른다', async () => {
const { wrapper, store } = await mountWith({
message: SCHEDULED,
source: { kind: 'mailbox', mailboxId: 'sent' },
extra: (request) => {
if (request.url === '/scheduled-messages/s1/release') return fail(504, 'SYS_001')
if (request.method === 'GET' && request.url === '/messages/s1') return ok(UNSCHEDULED)
if (request.method === 'GET' && request.url === '/messages') return sentList(UNSCHEDULED)
return undefined
},
})
await wrapper.get('[data-scheduled-send]').trigger('click')
await flushPromises()
expect(requests.filter((request) => request.method === 'GET' && request.url === '/messages/s1')).toHaveLength(1)
expect(toasts().map((toast) => toast.message)).toEqual(['메일을 보냈는지 확인하지 못했습니다. [다시 시도]를 누르면 아직 보내지 않았을 때만 보냅니다.'])
expect(store.selected?.pendingSend).toBeNull()
expect(wrapper.find('[data-notice="scheduled"]').exists()).toBe(false)
})
it('연결이 끊겼는데(SYS_001) 다시 받아 보니 다시 낸 발송이 보내기 취소를 기다리면 보낸 것과 같다 — "메일을 보냈습니다." + [보내기 취소]', async () => {
knowUndoSeconds()
const sendAt = new Date(Date.now() + 10_000).toISOString()
const { wrapper, router, store } = await mountWith({
message: SCHEDULED,
source: { kind: 'scheduled' },
extra: (request) => {
if (request.url === '/scheduled-messages/s1/release') return 'network'
if (request.method === 'GET' && request.url === '/messages/s1') {
return ok({ ...SCHEDULED, pendingSend: { kind: 'undo', sendAt } })
}
return undefined
},
})
await wrapper.get('[data-scheduled-send]').trigger('click')
await flushPromises()
expect(toasts()).toMatchObject([{ kind: 'success', message: '메일을 보냈습니다.', action: { label: '보내기 취소' } }])
expect(store.messages.map((message) => message.id)).toEqual(['m2'])
expect(router.currentRoute.value.fullPath).toBe('/mail/scheduled/m2')
})
it('그 메일을 다시 받지 못하면 보냈다 · 못 보냈다 · 되돌렸다고 하지 않는다 — "보냈는지 확인하지 못했습니다 …" + [다시 시도] · 안내 줄은 마지막으로 안 그대로', async () => {
const { wrapper, store } = await mountWith({
message: SCHEDULED,
source: { kind: 'scheduled' },
extra: (request) => {
if (request.url === '/scheduled-messages/s1/release') return fail(503, 'MAIL_001')
if (request.method === 'GET' && request.url === '/messages/s1') return 'network'
return undefined
},
})
await wrapper.get('[data-scheduled-send]').trigger('click')
await flushPromises()
expect(toasts()).toMatchObject([{ kind: 'error', message: '메일을 보냈는지 확인하지 못했습니다. [다시 시도]를 누르면 아직 보내지 않았을 때만 보냅니다.', action: { label: '다시 시도' } }])
expect(store.selected?.pendingSend).toEqual(SCHEDULED.pendingSend)
expect(wrapper.find('[data-notice="scheduled"]').exists()).toBe(true)
})
it('받는 사람이 모두 받지 않으면(400 MAIL_010 — 임시보관함으로) 지금처럼 사전 문구 · 예약 표시를 서버 기준으로 다시 맞춘다', async () => {
const { wrapper, store } = await mountWith({
message: SCHEDULED,
source: { kind: 'scheduled' },
extra: (request) => {
if (request.url === '/scheduled-messages/s1/release') return fail(400, 'MAIL_010')
if (request.method === 'GET' && request.url === '/messages/s1') {
return ok({ ...SCHEDULED, mailboxIds: ['drafts'], pendingSend: null })
}
return undefined
},
})
await wrapper.get('[data-scheduled-send]').trigger('click')
await flushPromises()
expect(toasts().map((toast) => toast.message)).toEqual([ERROR_CODE_MESSAGES.MAIL_010])
expect(store.selected?.pendingSend).toBeNull()
expect(wrapper.find('[data-notice="scheduled"]').exists()).toBe(false)
})
})
it('혹시 409 MAIL_014(이미 보냄)로 와도 오류가 아니다 — "메일을 보냈습니다."', async () => {
const { wrapper } = await mountWith({
message: SCHEDULED,
source: { kind: 'scheduled' },
extra: (request) => (request.url === '/scheduled-messages/s1/release' ? fail(409, 'MAIL_014') : undefined),
})
await wrapper.get('[data-scheduled-send]').trigger('click')
await flushPromises()
expect(toasts().map((toast) => toast.message)).toEqual(['메일을 보냈습니다.'])
})
it('알림의 [보내기 취소]는 지금의 보내기 취소와 같다 — DELETE 로 취소하고 그 메일로 쓰기 화면', async () => {
knowUndoSeconds()
const { wrapper, router } = await mountWith({
message: SCHEDULED,
source: { kind: 'scheduled' },
extra: (request) => {
if (request.url === '/scheduled-messages/s1/release') {
return ok({ id: 's1', rejectedRecipients: [], pendingSend: { kind: 'undo', sendAt: new Date(Date.now() + 10_000).toISOString() } })
}
if (request.method === 'DELETE' && request.url === '/scheduled-messages/s1') return ok({ draftId: 's1' })
return undefined
},
})
await wrapper.get('[data-scheduled-send]').trigger('click')
await flushPromises()
const [toast] = toasts()
const action = toast ? useToastStore().takeAction(toast.id) : null
if (!action || !('run' in action)) throw new Error('단추가 없다')
action.run()
await flushPromises()
expect(requests.filter((request) => request.method === 'DELETE').map((request) => request.url)).toEqual([
'/scheduled-messages/s1',
])
expect(router.currentRoute.value.fullPath).toBe('/mail/compose?draft=s1')
})
})
describe('차단 창 — 받은편지함에 있는 이 보낸 사람의 메일도 옮기기', () => {
async function openBlockDialog(wrapper: VueWrapper) {
await wrapper.get('[data-reader-more]').trigger('click')
await flushPromises()
await wrapper.findAll('[role="menuitem"]').at(-1)?.trigger('click')
await flushPromises()
return wrapper.get('[data-block-dialog]')
}
it('새 칸을 켜고 [차단] — 이 메일을 먼저 옮기고 받은편지함 정리를 시작한다 · 차단 알림 대신 알림 하나가 결과(이 메일까지 센 수) + [스팸메일함 보기]', async () => {
const { wrapper, router } = await mountWith({
extra: (request) =>
request.url === '/mail-block-runs' ? ok({ scanned: 120, matched: 3, remaining: 0, cursor: null }) : undefined,
})
const dialog = await openBlockDialog(wrapper)
await dialog.get('[data-block-clean]').setValue(true)
// 받은편지함의 메일이라 첫 칸은 켠 채 잠긴다
expect(dialog.get('[data-block-move]').attributes('aria-disabled')).toBe('true')
await wrapper.get('[data-block-confirm]').trigger('click')
await flushPromises()
const order = requests
.filter((request) => request.method !== 'GET')
.map((request) => `${request.method} ${request.url}`)
expect(order).toEqual(['POST /mail-blocks', 'PATCH /messages', 'POST /mail-block-runs'])
expect(requests.find((request) => request.method === 'PATCH')?.data).toEqual({ ids: ['m1'], mailboxId: 'junk', isJunk: true })
expect(requests.find((request) => request.url === '/mail-block-runs')?.data).toEqual({
sender: 'news@cloudbrief.example',
cursor: null,
})
expect(toasts()).toMatchObject([
{
kind: 'success',
message: '‘news@cloudbrief.example’을(를) 차단하고 받은편지함의 메일 4통을 스팸메일함으로 옮겼습니다.',
action: { label: '스팸메일함 보기' },
},
])
// 옮겼으니 삭제처럼 다음 메일을 연다
expect(router.currentRoute.value.fullPath).toBe('/mail/inbox/m2')
})
it('이 탭에서 필터를 적용하는 중이면 새 칸이 꺼져 있다 — 까닭 글', async () => {
const { wrapper } = await mountWith()
useMailFilterRunStore().running = true
const dialog = await openBlockDialog(wrapper)
expect(dialog.get('[data-block-clean]').attributes('aria-disabled')).toBe('true')
expect(dialog.get('#block-dialog-clean-note').text()).toBe('받은편지함을 정리하는 중이라 지금은 고를 수 없습니다.')
})
it('이 탭에서 스팸메일함을 비우는 중이면 새 칸이 꺼져 있다 — 그 까닭 글, 비우기가 끝나면 다시 고를 수 있다', async () => {
const emptying = deferred()
const { wrapper, store } = await mountWith({
extra: (request) =>
request.method === 'DELETE' && request.url === '/mailboxes/junk/messages' ? emptying.promise : undefined,
})
const empty = store.runMailboxOperation('empty', JUNK, () => undefined)
const dialog = await openBlockDialog(wrapper)
expect(dialog.get('[data-block-clean]').attributes('aria-disabled')).toBe('true')
expect(dialog.get('#block-dialog-clean-note').text()).toBe('스팸메일함을 비우는 중이라 지금은 고를 수 없습니다.')
emptying.resolve(ok({ processed: 0, remaining: 0 }))
await empty
await flushPromises()
expect(dialog.get('[data-block-clean]').attributes('aria-disabled')).toBeUndefined()
expect(dialog.get('#block-dialog-clean-note').text()).toBe('옮긴 메일은 한꺼번에 되돌릴 수 없습니다.')
})
})
})
@@ -565,7 +565,7 @@ describe('MessageReader 스팸 신고 · 원문 내려받기 (4단계 — 디자
const patches = () => requests.filter((request) => request.method === 'PATCH').map((request) => request.data)
it('도구 줄 — 답장 · 전체 답장 · 전달 | 스팸 신고 · 삭제 · ⋯(더 보기)', async () => {
it('도구 줄 — 답장 · 전체 답장 · 전달 | 이동 ▾(15단계) · 스팸 신고 · 삭제 · ⋯(더 보기)', async () => {
const { wrapper } = await mountWith()
const toolbar = wrapper.get('.reader__toolbar')
@@ -573,6 +573,7 @@ describe('MessageReader 스팸 신고 · 원문 내려받기 (4단계 — 디자
'답장',
'전체 답장',
'전달',
'이동',
'스팸 신고',
'삭제',
])
@@ -0,0 +1,309 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { mount, type VueWrapper } from '@vue/test-utils'
import { createPinia, setActivePinia, type Pinia } from 'pinia'
import { h } from 'vue'
import type { Mailbox, MessageDetail, MessagesPatch, MessageSummary } from '@/shared/types/mail'
import { useAuthStore } from '@/stores/auth.store'
import { useMailStore } from '@/stores/mail.store'
import { useToastStore } from '@/stores/toast.store'
import { flushPromises, installMockApi, ok, restoreApiAdapter, type MockRequest } from '@/test-utils/mock-api'
import { createTestRouter } from '@/test-utils/router'
import MessageReader from './MessageReader.vue'
// 15단계 — 읽기 화면의 [이동 ▾] · 좁은 읽기 칸의 아이콘만 도구 줄 (디자인 원본 Reader15 "1 · 2 · 3")
function box(id: string, role: Mailbox['role'], name: string, partial: Partial<Mailbox> = {}): Mailbox {
return { id, name, role, parentId: null, sortOrder: 0, totalCount: 3, unreadCount: 0, ...partial }
}
const MAILBOXES: Mailbox[] = [
box('inbox', 'inbox', '받은편지함'),
box('sent', 'sent', '보낸편지함'),
box('drafts', 'drafts', '임시보관함'),
box('junk', 'junk', '스팸메일함'),
box('trash', 'trash', '휴지통'),
box('clients', null, '거래처'),
box('clients-2025', null, '2025', { parentId: 'clients' }),
box('quotes', null, '견적', { parentId: 'clients' }),
box('project', null, '프로젝트', { sortOrder: 1 }),
box('project-2025', null, '2025', { parentId: 'project' }),
]
function message(id: string, mailboxId: string, partial: Partial<MessageSummary> = {}): MessageDetail {
return {
id,
threadId: `t-${id}`,
mailboxIds: [mailboxId],
from: { name: '(주)한빛솔루션 영업팀', email: 'sales@example.com' },
to: [{ name: '김아라', email: 'ara.kim@arasystem.it.kr' }],
subject: `2025년 유지보수 정산 자료 ${id}`,
preview: '',
receivedAt: '2026-11-09T01:42:00Z',
isRead: true,
isFlagged: false,
hasAttachment: false,
size: 100,
pendingSend: null,
cc: [],
bcc: [],
replyTo: [],
sentAt: null,
messageId: null,
references: [],
htmlBody: null,
textBody: '본문',
hasBlockedImages: false,
attachments: [],
...partial,
}
}
describe('MessageReader — [이동 ▾] (15단계)', () => {
let pinia: Pinia
let requests: MockRequest[]
const mounted: VueWrapper[] = []
beforeEach(() => {
pinia = createPinia()
setActivePinia(pinia)
useAuthStore().markAuthenticated({ email: 'ara.kim@arasystem.it.kr', name: '김아라' })
})
afterEach(() => {
for (const wrapper of mounted.splice(0)) wrapper.unmount()
useToastStore().clear()
restoreApiAdapter()
document.body.innerHTML = ''
})
async function mountReader(
options: { box?: string; layout?: 'split' | 'list'; open?: Partial<MessageSummary> } = {},
) {
const boxId = options.box ?? 'inbox'
requests = installMockApi((request) => {
if (request.url === '/mailboxes') return ok({ items: MAILBOXES, pageInfo: { hasNext: false, nextCursor: null, total: 10 } })
if (request.url === '/messages' && request.method === 'GET') {
return ok({ items: [], pageInfo: { hasNext: false, nextCursor: null, total: 0 } })
}
if (request.url === '/messages') return ok({ updatedIds: (request.data as MessagesPatch).ids, failedIds: [] })
return ok({ usedBytes: 0, limitBytes: null, attachmentMaxBytes: 1, attachmentTotalMaxBytes: 1 })
})
const store = useMailStore()
store.mailboxes = MAILBOXES
store.listSource = { kind: 'mailbox', mailboxId: boxId }
const open = message('m1', boxId, options.open)
store.messages = [open, message('m2', boxId)] as MessageSummary[]
store.pageInfo = { hasNext: false, nextCursor: null, total: 2 }
store.listStatus = 'ready'
store.selected = open
store.selectedId = open.id
store.selectedStatus = 'ready'
const router = createTestRouter()
await router.push(`/mail/${boxId}/m1`)
await router.isReady()
const wrapper = mount(MessageReader, {
props: { layout: options.layout ?? 'split' },
slots: options.layout === 'list' ? { 'toolbar-start': () => h('a', { href: `/mail/${boxId}` }, '목록으로') } : {},
global: { plugins: [pinia, router] },
attachTo: document.body,
})
mounted.push(wrapper)
return { wrapper, router, store }
}
const patches = () =>
requests.filter((request) => request.method === 'PATCH' && request.url === '/messages').map((request) => request.data)
const toastTexts = () => useToastStore().toasts.map((toast) => toast.message)
async function openMenu(wrapper: VueWrapper) {
await wrapper.get('[data-reader-move]').trigger('keydown', { key: 'ArrowDown' })
await flushPromises()
return wrapper.get('[role="menu"]')
}
async function pick(wrapper: VueWrapper, label: string) {
const menu = await openMenu(wrapper)
const item = menu.findAll('[role="menuitem"]').find((entry) => (entry.attributes('aria-label') ?? entry.text()) === label)
if (!item) throw new Error(`${label} 항목이 없다`)
await item.trigger('click')
await flushPromises()
}
it('[전달] 뒤 구분선 다음, [스팸 신고] 앞 — 메뉴를 여는 단추(▾)이고 키보드로 열면 첫 항목에 초점', async () => {
const { wrapper } = await mountReader()
const toolbar = wrapper.get('.reader__toolbar')
const move = wrapper.get('[data-reader-move]')
const order = [...toolbar.element.children].map((child) =>
child.matches('.toolbar-divider') ? '|' : (child.textContent ?? '').trim() || (child.getAttribute('aria-label') ?? ''),
)
expect(order.slice(0, 7)).toEqual(['답장', '전체 답장', '전달', '|', '이동', '스팸 신고', '삭제'])
expect(move.attributes('aria-haspopup')).toBe('menu')
expect(move.attributes('aria-expanded')).toBe('false')
expect(move.find('.tool-button__chevron').exists()).toBe(true)
const menu = await openMenu(wrapper)
expect(menu.attributes('aria-label')).toBe('옮길 메일함')
expect(document.activeElement).toBe(menu.find('[role="menuitem"]').element)
expect(move.attributes('aria-expanded')).toBe('true')
})
it('옮길 곳은 메일함 패널처럼 들여 쓴다 — 그 메일이 든 받은편지함 · 임시보관함 · 휴지통은 없고, 기본 메일함과 내 메일함 사이에 선', async () => {
const { wrapper } = await mountReader()
const menu = await openMenu(wrapper)
const entries = [...menu.element.children].map((child) => {
if (child.tagName === 'HR') return '---'
const depth = [...child.classList].find((name) => name.startsWith('menu__item--depth-'))
return `${child.textContent?.trim()}${depth ? ` (${depth.slice(-1)})` : ''}`
})
expect(entries).toEqual(['보낸편지함', '스팸메일함', '---', '거래처', '2025 (1)', '견적 (1)', '프로젝트', '2025 (1)'])
// 들여 쓴 메일함은 위 메일함부터 읽는다 — 같은 이름이 둘이어도 가른다
const labels = menu.findAll('[role="menuitem"]').map((item) => item.attributes('aria-label') ?? null)
expect(labels).toEqual([null, null, null, '거래처 안의 2025', '거래처 안의 견적', null, '프로젝트 안의 2025'])
// 앞에 패널과 같은 아이콘
expect(menu.findAll('[role="menuitem"] .menu__icon')).toHaveLength(7)
})
it('그 메일이 든 메일함에 하위 메일함이 있으면 고를 수 없는 흐린 줄 "이 메일이 있는 메일함" — 눌러도 옮기지 않는다', async () => {
const { wrapper } = await mountReader({ box: 'clients' })
const menu = await openMenu(wrapper)
const here = menu.findAll('[role="menuitem"]').find((item) => item.text().startsWith('거래처'))
expect(here?.attributes('aria-disabled')).toBe('true')
expect(here?.get('.menu__note').text()).toBe('이 메일이 있는 메일함')
expect(here?.attributes('aria-describedby')).toBe(here?.get('.menu__note').attributes('id'))
// 받은편지함이 처음으로 든다 (그 메일은 거래처에 있다)
expect(menu.findAll('[role="menuitem"]')[0]?.text()).toBe('받은편지함')
await here?.trigger('click')
await flushPromises()
expect(patches()).toEqual([])
expect(wrapper.find('[role="menu"]').exists()).toBe(true)
})
it('옮기면 [삭제]와 같은 길 — 다음 메일을 열고 "메일 1통을 거래처 › 2025로 옮겼습니다."(같은 이름이 또 있어서) + [되돌리기]', async () => {
const { wrapper, router, store } = await mountReader()
await pick(wrapper, '거래처 안의 2025')
expect(patches()).toEqual([{ ids: ['m1'], mailboxId: 'clients-2025' }])
expect(store.messages.map((item) => item.id)).toEqual(['m2'])
expect(router.currentRoute.value.fullPath).toBe('/mail/inbox/m2')
const [toast] = useToastStore().toasts
expect(toast?.message).toBe('메일 1통을 거래처 › 2025로 옮겼습니다.')
if (toast?.action && 'run' in toast.action) toast.action.run()
await flushPromises()
expect(patches().at(-1)).toEqual({ ids: ['m1'], mailboxId: 'inbox' })
expect(toastTexts().at(-1)).toBe('메일 1통을 받은편지함으로 되돌렸습니다.')
})
it('같은 이름이 하나뿐이면 이름만 — "메일 1통을 견적으로 옮겼습니다." · 목록 보기는 목록으로 돌아간다', async () => {
const { wrapper, router } = await mountReader({ layout: 'list' })
await pick(wrapper, '거래처 안의 견적')
expect(toastTexts()).toEqual(['메일 1통을 견적으로 옮겼습니다.'])
expect(router.currentRoute.value.fullPath).toBe('/mail/inbox')
})
it('스팸메일함을 고르면 [스팸 신고]와 같다 — 스팸 표시를 함께 보내고 [되돌리기]는 표시도 되돌린다', async () => {
const { wrapper } = await mountReader()
await pick(wrapper, '스팸메일함')
expect(patches()).toEqual([{ ids: ['m1'], mailboxId: 'junk', isJunk: true }])
const [toast] = useToastStore().toasts
expect(toast?.message).toBe('메일 1통을 스팸메일함으로 옮겼습니다.')
if (toast?.action && 'run' in toast.action) toast.action.run()
await flushPromises()
expect(patches().at(-1)).toEqual({ ids: ['m1'], mailboxId: 'inbox', isJunk: false })
})
it('스팸메일함에서 휴지통 밖으로 꺼내면 [스팸 아님]과 같다 — "스팸이 아닌 메일로 표시해 …로 옮겼습니다."', async () => {
const { wrapper } = await mountReader({ box: 'junk' })
await pick(wrapper, '프로젝트')
expect(patches()).toEqual([{ ids: ['m1'], mailboxId: 'project', isJunk: false }])
expect(toastTexts()).toEqual(['메일 1통을 스팸이 아닌 메일로 표시해 프로젝트로 옮겼습니다.'])
})
it('보내기를 기다리는 메일(보내는 중 · 예약)에는 [이동 ▾]가 없다 — [스팸 신고]처럼', async () => {
const sendAt = '2026-11-10T00:00:00Z'
const { wrapper } = await mountReader({ box: 'sent', open: { pendingSend: { kind: 'undo', sendAt } } })
expect(wrapper.find('[data-reader-move]').exists()).toBe(false)
expect(wrapper.find('[data-reader-spam]').exists()).toBe(false)
})
})
describe('MessageReader — 좁은 읽기 칸의 아이콘만 도구 줄 (15단계 — 디자인 원본 Reader15 "2")', () => {
let pinia: Pinia
beforeEach(() => {
pinia = createPinia()
setActivePinia(pinia)
useAuthStore().markAuthenticated({ email: 'ara.kim@arasystem.it.kr', name: '김아라' })
installMockApi(() => ok({ items: [], pageInfo: { hasNext: false, nextCursor: null, total: 0 } }))
})
afterEach(() => {
restoreApiAdapter()
document.body.innerHTML = ''
})
async function mountWith(boxId: string, layout: 'split' | 'list' = 'split') {
const store = useMailStore()
store.mailboxes = MAILBOXES
store.listSource = { kind: 'mailbox', mailboxId: boxId }
const open = message('m1', boxId)
store.selected = open
store.selectedId = open.id
store.selectedStatus = 'ready'
const router = createTestRouter()
await router.push(`/mail/${boxId}/m1`)
await router.isReady()
return mount(MessageReader, { props: { layout }, global: { plugins: [pinia, router] } })
}
it('도구 줄의 이름 단추는 이름을 숨겨도 읽히게 aria-label · title 을 둔다 (숨기는 때는 읽기 칸 폭의 컨테이너 쿼리)', async () => {
const wrapper = await mountWith('inbox')
const buttons = wrapper.findAll('.reader__toolbar .tool-button--collapsible')
expect(buttons.map((button) => [button.attributes('aria-label'), button.attributes('title')])).toEqual([
['답장', '답장'],
['전체 답장', '전체 답장'],
['전달', '전달'],
['이동', '이동'],
['스팸 신고', '스팸 신고'],
['삭제', '삭제'],
])
// 이름은 따로 감싼 글 — 좁으면 이것만 숨긴다
expect(buttons.map((button) => button.get('.tool-button__label').text())).toEqual([
'답장',
'전체 답장',
'전달',
'이동',
'스팸 신고',
'삭제',
])
})
it('접는 폭을 가르는 모양 — 분할 보기 · 목록 보기, 「영구 삭제」가 있는 휴지통은 따로', async () => {
const inbox = await mountWith('inbox')
expect(inbox.get('.reader__toolbar').classes()).toEqual(['reader__toolbar', 'reader__toolbar--split'])
inbox.unmount()
const trash = await mountWith('trash')
expect(trash.get('.reader__toolbar').classes()).toContain('reader__toolbar--long')
expect(trash.get('[data-reader-delete]').text()).toBe('영구 삭제')
trash.unmount()
const list = await mountWith('inbox', 'list')
expect(list.get('.reader__toolbar').classes()).toContain('reader__toolbar--list')
list.unmount()
})
})
@@ -289,12 +289,12 @@ describe('MessageReader — 9단계', () => {
describe('예약한 메일 — 청록 안내 줄 · 답장 · 전달 · 스팸 신고 없음 · 삭제는 예약 취소 뒤 휴지통 (디자인 원본 MailSendLater)', () => {
const cancels = () => requests.filter((request) => request.method === 'DELETE' && request.url.startsWith('/scheduled-messages/'))
it('머리 아래 "10월 30일 (금) 오전 9:00에 보낼 메일입니다."(조사는 붙여 쓴다) + [고치기] [예약 취소] — 도구 줄은 삭제 · ⋯ 뿐이다', async () => {
it('머리 아래 "10월 30일 (금) 오전 9:00에 보낼 메일입니다."(조사는 붙여 쓴다) + [지금 보내기](15단계) [고치기] [예약 취소] — 도구 줄은 삭제 · ⋯ 뿐이다', async () => {
const { wrapper } = await mountWith({ message: SCHEDULED, source: { kind: 'scheduled' } })
const notice = wrapper.get('[data-notice="scheduled"]')
expect(notice.get('.reader__notice-text').text()).toBe(`${formatScheduleTime(SEND_AT)}에 보낼 메일입니다.`)
expect(notice.findAll('button').map((button) => button.text())).toEqual(['고치기', '예약 취소'])
expect(notice.findAll('button').map((button) => button.text())).toEqual(['지금 보내기', '고치기', '예약 취소'])
expect(wrapper.findAll('.reader__toolbar .tool-button').map((button) => button.text())).toEqual(['삭제'])
expect(wrapper.find('[data-reader-more]').exists()).toBe(true)
})
+107 -12
View File
@@ -3,10 +3,13 @@
// 본문 HTML 은 MailBodyFrame(격리된 iframe)에만 그린다.
// 툴바와 본문은 콘텐츠 열(tokens.css --content-*)을 따른다 — 분할 보기 읽기 · 목록 보기 읽기 · 쓰기 공통.
// 검색 결과에서 연 메일은 받는 사람 아래에 "메일함 받은편지함" 한 줄을 더한다 (디자인 README "3단계 › 검색").
// 도구 줄: 답장 · 전체 답장 · 전달 | 스팸 신고(스팸메일함에서는 스팸 아님) · 삭제 · ⋯(더 보기 — 인쇄(9단계) · 원문 내려받기 ·
// 도구 줄: 답장 · 전체 답장 · 전달 | 이동 ▾(15단계) · 스팸 신고(스팸메일함에서는 스팸 아님) · 삭제 · ⋯(더 보기 — 인쇄(9단계) · 원문 내려받기 ·
// 보낸 사람을 주소록에 추가(8단계) · 이런 메일로 필터 만들기 · 보낸 사람 차단(9단계))
// (디자인 README "4단계 › 스팸 · 원문" · "6단계 › 상태 · 들어오는 길" · "9단계 › 읽기 화면").
// 아직 보내지 않은 메일(9단계 — 보내는 중 · 예약)은 스팸 신고가 없다 — 옮기거나 지워도 엔진은 그 시각에 보낸다. 「삭제」는 확인 창 뒤 발송을 취소하고 휴지통으로.
// (디자인 README "4단계 › 스팸 · 원문" · "6단계 › 상태 · 들어오는 길" · "9단계 › 읽기 화면" · "15단계").
// 아직 보내지 않은 메일(9단계 — 보내는 중 · 예약)은 이동 · 스팸 신고가 없다 — 옮기거나 지워도 엔진은 그 시각에 보낸다. 「삭제」는 확인 창 뒤 발송을 취소하고 휴지통으로.
// 읽기 칸이 좁으면 도구 줄의 이름을 숨기고 아이콘만 둔다(15단계 — 디자인 원본 Reader15 "2"). 기준은 화면 폭이 아니라 읽기 칸 폭(컨테이너 쿼리 —
// 도구 줄 띠가 그 칸이다): 이름을 한 줄에 다 보일 폭 570(도구 줄 좌우 여백 포함), 「영구 삭제」가 있으면 595. 목록 보기는 앞에 [목록으로] · 이전/다음이
// 함께 놓여 850 · 875 이고, 그 폭에서 한 줄에 들도록 단추 줄의 최대 폭(콘텐츠 열 800)을 풀어 둔다. 이름은 aria-label · title 로 읽힌다 (MailToolButton 의 collapsible)
// 예약한 메일은 답장 · 전체 답장 · 전달도 없고, 머리 아래 청록 안내 줄([고치기] [예약 취소])이 있다.
// 「삭제」 · 「영구 삭제」는 MessageDeleteButton — 휴지통인지는 그 메일의 메일함으로 정한다. 「스팸 신고」는 MessageSpamButton, ⋯ 는 MessageMoreMenu.
// 머리 아래 안내 줄(예약 · 차단한 보낸 사람 · 본문 한도 · 외부 이미지 [이미지 보기])은 MessageReaderNotices.
@@ -29,10 +32,12 @@ import MessageAttachments from '@/components/mail/MessageAttachments.vue'
import MessageConversation from '@/components/mail/MessageConversation.vue'
import MessageDeleteButton from '@/components/mail/MessageDeleteButton.vue'
import MessageMoreMenu from '@/components/mail/MessageMoreMenu.vue'
import MessageMoveButton from '@/components/mail/MessageMoveButton.vue'
import MessageReaderNotices from '@/components/mail/MessageReaderNotices.vue'
import MessageSpamButton from '@/components/mail/MessageSpamButton.vue'
import { useMailNavigation } from '@/composables/useMailNavigation'
import { useMailPrint } from '@/composables/useMailPrint'
import { useMessageRemoval } from '@/composables/useMessageRemoval'
import { MAIL_NO_SENDER, MAIL_SEARCH_TEXT } from '@/shared/constants/mail'
import { formatMailDateTime, initialOf } from '@/shared/utils/format'
import { displayName, formatAddressList } from '@/shared/utils/mail-address'
@@ -49,21 +54,30 @@ interface Props {
layout?: 'split' | 'list'
}
withDefaults(defineProps<Props>(), {
const props = withDefaults(defineProps<Props>(), {
layout: 'split',
})
const mailStore = useMailStore()
const { backToSourceList, replaceWithMailbox } = useMailNavigation()
const print = useMailPrint()
const removal = useMessageRemoval()
const bodyFrameRef = ref<InstanceType<typeof MailBodyFrame> | null>(null)
const message = computed(() => mailStore.selected)
/** 예약한 메일 — 답장 · 전달이 없다 (9단계) */
const scheduled = computed(() => isScheduledMessage(message.value))
/** 아직 보내지 않은 메일(보내는 중 · 예약) — 스팸 신고(옮기기)가 없다 */
/** 아직 보내지 않은 메일(보내는 중 · 예약) — 이동 · 스팸 신고(옮기기)가 없다 */
const held = computed(() => isHeldMessage(message.value))
/** 도구 줄이 「영구 삭제」를 보인다 — 이름이 길어 더 넓은 폭에서 아이콘만으로 접는다 (MessageDeleteButton 과 같은 판정) */
const longDelete = computed(() => Boolean(message.value && !held.value && removal.deletesPermanently(message.value)))
/** 도구 줄의 모양 — 보기 방식(목록 보기는 앞에 [목록으로] · 이전/다음)과 「영구 삭제」에 따라 아이콘만으로 접는 폭이 다르다 */
const toolbarClass = computed(() => [
'reader__toolbar',
`reader__toolbar--${props.layout}`,
{ 'reader__toolbar--long': longDelete.value },
])
/** 검색 결과에서 연 메일의 메일함 이름 — 메일함 목록에서 열었으면 null */
const searchMailboxName = computed(() => {
if (!mailStore.search || !message.value) return null
@@ -128,7 +142,7 @@ onBeforeUnmount(() => {
class="reader__toolbar-bar"
>
<div
class="reader__toolbar"
:class="toolbarClass"
role="group"
aria-label="이 메일로 할 일"
>
@@ -138,24 +152,32 @@ onBeforeUnmount(() => {
<MailToolButton
icon="reply"
label="답장"
collapsible
:to="{ name: 'mail-compose', query: { replyTo: message.id } }"
/>
<MailToolButton
icon="reply-all"
label="전체 답장"
collapsible
:to="{ name: 'mail-compose', query: { replyTo: message.id, all: '1' } }"
/>
<MailToolButton
icon="forward"
label="전달"
collapsible
:to="{ name: 'mail-compose', query: { forward: message.id } }"
/>
<MailToolbarDivider v-if="layout === 'split'" />
<MessageSpamButton
v-if="!held"
:message="message"
:layout="layout"
/>
<template v-if="!held">
<MessageMoveButton
:message="message"
:layout="layout"
/>
<MessageSpamButton
:message="message"
:layout="layout"
/>
</template>
</template>
<MessageDeleteButton
:message="message"
@@ -325,11 +347,15 @@ onBeforeUnmount(() => {
flex-direction: column;
}
/* 툴바 띠 — 칸 전체 폭으로 아래 선을 긋고, 스크롤해도 위에 남는다 */
/*
* 툴바 띠 — 칸 전체 폭으로 아래 선을 긋고, 스크롤해도 위에 남는다.
* 띠가 읽기 칸 폭의 컨테이너다(15단계) — 단추 이름을 숨길지 이 폭으로 정한다 (아래 @container). 크기 봉쇄는 가로뿐이라 메뉴는 띠 밖으로 나온다
*/
.reader__toolbar-bar {
position: sticky;
top: 0;
z-index: 1;
container: reader-tools / inline-size;
border-bottom: var(--border-width) solid var(--color-border);
background: var(--color-surface);
}
@@ -345,6 +371,75 @@ onBeforeUnmount(() => {
padding: var(--space-4) var(--content-inline) var(--space-4) var(--content-toolbar-start);
}
/* 목록 보기 — 앞의 [목록으로] · 이전/다음까지 이름이 다 보이는 줄은 콘텐츠 열(800)보다 조금 넓다. 줄의 최대 폭을 풀어 넓은 칸에서는 한 줄에 둔다 */
.reader__toolbar--list {
max-width: none;
}
/*
* 읽기 칸이 단추 이름을 한 줄에 다 보일 폭보다 좁으면 — 이름을 숨기고 아이콘만 40×44 ([이동 ▾]는 ▾ 까지 남긴다).
* 폭(15단계 — 디자인 원본 Reader15 "2" 실측, 도구 줄 좌우 여백 포함): 분할 보기 570 · 「영구 삭제」 595, 목록 보기는 앞의 [목록으로] · 이전/다음을 더해 850 · 875
*/
@container reader-tools (width < 35.625rem) {
.reader__toolbar :deep(.tool-button--collapsible) {
justify-content: center;
min-width: var(--size-icon-button);
}
.reader__toolbar :deep(.tool-button--collapsible:not(.tool-button--menu)) {
padding: 0;
}
.reader__toolbar :deep(.tool-button--collapsible .tool-button__label) {
display: none;
}
}
@container reader-tools (width < 37.1875rem) {
.reader__toolbar--long :deep(.tool-button--collapsible) {
justify-content: center;
min-width: var(--size-icon-button);
}
.reader__toolbar--long :deep(.tool-button--collapsible:not(.tool-button--menu)) {
padding: 0;
}
.reader__toolbar--long :deep(.tool-button--collapsible .tool-button__label) {
display: none;
}
}
@container reader-tools (width < 53.125rem) {
.reader__toolbar--list :deep(.tool-button--collapsible) {
justify-content: center;
min-width: var(--size-icon-button);
}
.reader__toolbar--list :deep(.tool-button--collapsible:not(.tool-button--menu)) {
padding: 0;
}
.reader__toolbar--list :deep(.tool-button--collapsible .tool-button__label) {
display: none;
}
}
@container reader-tools (width < 54.6875rem) {
.reader__toolbar--list.reader__toolbar--long :deep(.tool-button--collapsible) {
justify-content: center;
min-width: var(--size-icon-button);
}
.reader__toolbar--list.reader__toolbar--long :deep(.tool-button--collapsible:not(.tool-button--menu)) {
padding: 0;
}
.reader__toolbar--list.reader__toolbar--long :deep(.tool-button--collapsible .tool-button__label) {
display: none;
}
}
/* 콘텐츠 열 — 최대 800px · 좌우 32px · 위 20px · 아래 36px, 영역 안 왼쪽 정렬 */
.reader__content {
display: flex;
@@ -1,11 +1,11 @@
<script setup lang="ts">
// 읽기 화면 머리 아래의 안내 줄 (디자인 원본 MailReadTools · MailSendLater · README "9단계" · "읽기 화면의 본문 안내")
// 차례: 예약한 메일(청록 — [고치기] [예약 취소]) → 차단한 보낸 사람의 메일(회색 — 스팸메일함에서만, [차단 풀기]) →
// 차례: 예약한 메일(청록 — [지금 보내기](15단계) [고치기] [예약 취소]) → 차단한 보낸 사람의 메일(회색 — 스팸메일함에서만, [차단 풀기]) →
// 본문이 다 보이는지(생략 → 잘림 — 계약 2절 "읽기 한도") → 외부 이미지 차단(노랑 — [이미지 보기]).
// - [이미지 보기](36 — 밀도 예외 --size-notice-action): 그 메일만 `images=show` 로 다시 받아 본문만 바꾼다(스토어의 showImages — 늦은 응답은 버린다).
// 보이면(imagesShown — 설정으로 처음부터 보인 것 포함) 이 줄은 없다. 다시 열면 설정대로다
// - 예약 안내 줄의 단추는 붙잡은 발송 취소(useScheduledMessageActions) — 하는 동안은 두 단추를 함께 막는다(aria-disabled · aria-busy)
// - 단추를 누른 뒤 줄이 사라지면 초점을 본문으로 돌려 달라고 알린다(settled — 읽기 화면이 옮긴다. [이미지 보기] · [차단 풀기] · [고치기] · [예약 취소])
// - 예약 안내 줄의 단추는 지금 보내기 · 붙잡은 발송 취소(useScheduledMessageActions) — 하는 동안은 세 단추를 함께 막는다(aria-disabled · aria-busy)
// - 단추를 누른 뒤 줄이 사라지면 초점을 본문으로 돌려 달라고 알린다(settled — 읽기 화면이 옮긴다. [이미지 보기] · [차단 풀기] · [지금 보내기] · [고치기] · [예약 취소])
// 줄 안의 단추는 disabled 대신 aria-disabled — 초점이 그대로 남게 (누르면 아무것도 하지 않는다)
import { computed, ref } from 'vue'
@@ -42,7 +42,7 @@ const scheduledActions = useScheduledMessageActions()
const senderBlock = useSenderBlock()
/** 예약 줄 · 차단 줄의 단추가 하는 중 */
const working = ref<'edit' | 'cancel' | 'unblock' | null>(null)
const working = ref<'send' | 'edit' | 'cancel' | 'unblock' | null>(null)
const pending = computed(() => messagePendingSend(props.message))
const scheduledTime = computed(() =>
@@ -63,16 +63,18 @@ async function showImages() {
if (await mailStore.showImages()) emit('settled')
}
/** [고치기] · [예약 취소] — 취소했으면 이 줄이 단추와 함께 사라진다(초점을 옮겨 달라 — 다른 화면 · 메일로 갔으면 읽기 화면이 그대로 둔다) */
async function runScheduled(kind: 'edit' | 'cancel') {
/**
* [지금 보내기] · [고치기] · [예약 취소] — 했으면 이 줄이 단추와 함께 사라진다(초점을 옮겨 달라 — 다른 화면 · 메일로 갔으면 읽기 화면이 그대로 둔다).
* 지금 보내기(15단계)는 예약을 취소하지 않고 보낸다 — 보내기 취소를 기다리는 동안은 보낸편지함 날짜 칸이 "보내는 중"이다
*/
async function runScheduled(kind: 'send' | 'edit' | 'cancel') {
if (working.value) return
working.value = kind
let done = false
try {
done =
kind === 'edit'
? await scheduledActions.editScheduled(props.message.id)
: await scheduledActions.cancelScheduled(props.message, props.layout)
if (kind === 'send') done = await scheduledActions.sendNow(props.message, props.layout)
else if (kind === 'edit') done = await scheduledActions.editScheduled(props.message.id)
else done = await scheduledActions.cancelScheduled(props.message, props.layout)
} finally {
working.value = null
}
@@ -103,26 +105,39 @@ async function unblock() {
class="reader__notice-icon"
/>
<span class="reader__notice-text reader__notice-text--strong">{{ SEND_LATER_TEXT.notice(scheduledTime) }}</span>
<button
type="button"
class="reader__notice-action"
:aria-disabled="working ? 'true' : undefined"
:aria-busy="working === 'edit' ? 'true' : undefined"
data-scheduled-edit
@click="runScheduled('edit')"
>
{{ SEND_LATER_TEXT.edit }}
</button>
<button
type="button"
class="reader__notice-action"
:aria-disabled="working ? 'true' : undefined"
:aria-busy="working === 'cancel' ? 'true' : undefined"
data-scheduled-cancel
@click="runScheduled('cancel')"
>
{{ SEND_LATER_TEXT.cancelSchedule }}
</button>
<!-- 단추 셋은 한 묶음 — 줄이 좁으면 묶음째 다음 줄 오른쪽으로 (15단계 — 글이 한 글자 폭으로 눌리지 않게) -->
<span class="reader__notice-actions">
<button
type="button"
class="reader__notice-action"
:aria-disabled="working ? 'true' : undefined"
:aria-busy="working === 'send' ? 'true' : undefined"
data-scheduled-send
@click="runScheduled('send')"
>
{{ SEND_LATER_TEXT.sendNow }}
</button>
<button
type="button"
class="reader__notice-action"
:aria-disabled="working ? 'true' : undefined"
:aria-busy="working === 'edit' ? 'true' : undefined"
data-scheduled-edit
@click="runScheduled('edit')"
>
{{ SEND_LATER_TEXT.edit }}
</button>
<button
type="button"
class="reader__notice-action"
:aria-disabled="working ? 'true' : undefined"
:aria-busy="working === 'cancel' ? 'true' : undefined"
data-scheduled-cancel
@click="runScheduled('cancel')"
>
{{ SEND_LATER_TEXT.cancelSchedule }}
</button>
</span>
</div>
<div
@@ -216,6 +231,7 @@ async function unblock() {
/* 예약한 메일 — 청록 (디자인 원본 MailSendLater: 바탕 #E3F2F3 · 글 #0A5F67 · 단추 테두리 #9ED6DB) */
.reader__notice--brand {
flex-wrap: wrap;
gap: var(--space-8);
background: var(--color-brand-subtle);
color: var(--color-brand-strong);
@@ -223,6 +239,19 @@ async function unblock() {
--reader-notice-button-hover: var(--color-brand-tint);
}
/* 단추 셋 — [지금 보내기] [고치기] [예약 취소] (15단계). 넓으면 글 오른쪽 한 줄, 좁으면 묶음째 다음 줄 오른쪽 */
.reader__notice-actions {
flex: 0 0 auto;
display: flex;
gap: var(--space-8);
margin-left: auto;
}
/* 글이 이 폭보다 좁아지면 단추 묶음이 다음 줄로 간다 */
.reader__notice--brand .reader__notice-text {
flex-basis: 12rem;
}
/* 차단한 보낸 사람 — 회색 (디자인 원본 MailReadTools: 바탕 #F2F4F7 · 글 #344054 · 단추 테두리 #D0D5DD) */
.reader__notice--muted {
background: var(--color-surface-muted);
@@ -4,6 +4,7 @@
// - 그 밖은 「스팸 신고」 — 스팸메일함으로 옮기고 스팸 표시. 알림에 [되돌리기](원래 메일함 · 원래 표시로)
// - 옮길 메일함(스팸메일함 · 받은편지함)을 모르면(메일함 목록을 아직 못 받음) 그리지 않는다
// - 옮긴 뒤: 삭제와 같다 — 분할 보기는 다음 메일, 목록 보기는 목록으로 (leaveRemoved)
// - 읽기 칸이 좁으면 아이콘만 (15단계 — MailToolButton 의 collapsible · MessageReader 의 컨테이너 쿼리)
import { computed, ref } from 'vue'
import MailToolButton from '@/components/mail/MailToolButton.vue'
@@ -49,6 +50,7 @@ async function onClick() {
:label="inJunk ? MAIL_SPAM_TEXT.notSpam : MAIL_SPAM_TEXT.report"
:tone="inJunk ? 'accent' : 'default'"
:disabled="working"
collapsible
data-reader-spam
@click="onClick"
/>
@@ -7,12 +7,15 @@
// 고를 수 없다 — 고르기 상자는 꺼진 모양(aria-disabled · 까닭은 title), Ctrl · Shift + 클릭도 고르지 않는다 (옮기거나 지워도 엔진은 그 시각에 보낸다)
// - 검색 결과(14단계)는 제목의 맞은 말을 <mark>(HighlightedText — searchHighlight 의 위치)로 그린다. 행 높이(38)는 그대로 — 미리 보기 줄은 분할 보기만
// 칸 폭 · 칸 공통 여백은 표(MessageTable)가 정한다.
// 끌어다 놓기(15단계 — 디자인 원본 ListDrag15): 줄을 메일함 패널로 끌어 옮긴다 — 고른 줄이면 고른 것 모두, 아니면 이 줄만.
// 보내기를 기다리는 메일의 줄은 끌 수 없다. 줄의 링크가 끌어 가던 메일 주소 대신 우리 끌기 자료만 싣는다 (useMailRowDrag)
import { computed } from 'vue'
import type { RouteLocationRaw } from 'vue-router'
import AppIcon from '@/components/AppIcon.vue'
import HighlightedText from '@/components/mail/HighlightedText.vue'
import MailCheckbox from '@/components/mail/MailCheckbox.vue'
import { useMailRowDrag } from '@/composables/useMailDrag'
import { MAIL_BULK_TEXT, MAIL_NO_SUBJECT } from '@/shared/constants/mail'
import { SEND_LATER_TEXT } from '@/shared/constants/mail-send-later'
import type { MailboxRole, MessageListItem } from '@/shared/types/mail'
@@ -45,6 +48,8 @@ const props = withDefaults(defineProps<Props>(), {
const emit = defineEmits<Emits>()
const drag = useMailRowDrag()
const who = computed(() => rowWho(props.message, { role: props.role, mixed: props.mixed, view: 'table' }))
const subject = computed(() => props.message.subject || MAIL_NO_SUBJECT)
const unread = computed(() => isRowUnread(props.message, props.role))
@@ -80,8 +85,11 @@ function onMouseDown(event: MouseEvent) {
<tr
:class="['mail-table__row', { 'is-unread': unread, 'is-checked': checked }]"
:data-row-id="message.id"
:draggable="!held"
@click="onRowClick"
@mousedown="onMouseDown"
@dragstart="(event: DragEvent) => drag.start(event, message.id)"
@dragend="drag.end"
>
<td class="mail-table__check">
<!-- 폭 32px · 행 높이를 채우는 고르기 상자 — 44px 기준의 의도된 예외 (디자인 README "3단계", 별 칸과 같다) -->
@@ -4,6 +4,7 @@
// 같은 모양을 쓰는 곳: 여러 통 처리의 이동 ▾ · 메일함 패널의 ⋯ (되돌릴 수 없는 항목은 tone 'danger' — 빨간 글자) · 읽기 화면의 ⋯
// 꺼진 항목(disabled)은 disabled 가 아니라 aria-disabled — 화살표로 가서 초점은 받되 고를 수 없다(WAI-ARIA 메뉴 권고).
// 누를 수 없는 까닭은 이름 아래 작은 글(note)이고 항목이 aria-describedby 로 가리킨다 — 꺼진 항목이어도 그 글은 흐리지 않는다
// 들여 쓴 항목(depth — 15단계 [이동 ▾]의 메일함 나무)은 메일함 패널과 같은 10 · 28 · 44 · 56 에서 시작하고, 화면 낭독 이름이 따로 있으면(ariaLabel) 그것으로 읽힌다
import { useId } from 'vue'
import AppIcon from '@/components/AppIcon.vue'
@@ -43,6 +44,14 @@ function noteId(itemId: string): string {
return `${menuId}-${itemId}-note`
}
/** 들여쓰기는 3단계까지 — 그 아래는 같은 깊이로 (메일함 패널과 같다) */
const MAX_MENU_DEPTH = 3
function depthClass(depth: number | undefined): string | undefined {
if (!depth || depth <= 0) return undefined
return `menu__item--depth-${Math.min(depth, MAX_MENU_DEPTH)}`
}
/** 꺼진 항목은 골라도 아무것도 하지 않는다 (메뉴도 닫지 않는다) */
function select(item: EditorMenuItem, event: MouseEvent) {
if (item.separator || item.disabled) return
@@ -70,8 +79,13 @@ function select(item: EditorMenuItem, event: MouseEvent) {
<button
v-else
type="button"
:class="['menu__item', { 'menu__item--danger': item.tone === 'danger', 'menu__item--noted': item.note }]"
:class="[
'menu__item',
depthClass(item.depth),
{ 'menu__item--danger': item.tone === 'danger', 'menu__item--noted': item.note },
]"
:role="item.role ?? 'menuitem'"
:aria-label="item.ariaLabel"
:aria-checked="isToggle(item.role) ? Boolean(item.checked) : undefined"
:aria-haspopup="item.opens"
:aria-disabled="item.disabled ? 'true' : undefined"
@@ -154,6 +168,19 @@ function select(item: EditorMenuItem, event: MouseEvent) {
background: var(--color-surface-muted);
}
/* 들여 쓴 항목 — 메일함 패널과 같은 왼쪽 안쪽 (MailboxPanelRow) */
.menu__item--depth-1 {
padding-left: var(--space-28);
}
.menu__item--depth-2 {
padding-left: var(--space-44);
}
.menu__item--depth-3 {
padding-left: var(--space-56);
}
.menu__item[aria-disabled='true'] {
opacity: 0.5;
cursor: default;
@@ -0,0 +1,87 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { mount, type VueWrapper } from '@vue/test-utils'
import { defineComponent, h, nextTick, ref } from 'vue'
import EditorMenu from './EditorMenu.vue'
// 15단계 — 메뉴 자리의 좌우 바꾸기 (디자인 원본 Reader15 2-4): 바라는 끝에 맞춰 그린 메뉴가 보이는 영역 밖으로 넘치면 반대 끝에 맞춘다.
// EditorPopup 을 쓰는 메뉴 · 팝오버 모두에 걸린다 — 여기서는 EditorMenu 로 본다
function rect(left: number, width: number, top = 0, height = 40): DOMRect {
return { left, right: left + width, width, top, bottom: top + height, height, x: left, y: top, toJSON: () => ({}) } as DOMRect
}
/** 단추와 메뉴를 감싼 칸 — 실제 쓰는 곳(MailBulkMoveButton 등)과 같은 모양 */
function host(align: 'start' | 'end') {
return defineComponent({
setup() {
const trigger = ref<HTMLButtonElement | null>(null)
return () =>
h('div', { class: 'host' }, [
h('button', { ref: trigger, type: 'button', class: 'trigger' }, '이동'),
trigger.value
? h(EditorMenu, {
label: '옮길 메일함',
items: [
{ id: 'a', label: '보낸편지함' },
{ id: 'b', label: '스팸메일함' },
],
trigger: trigger.value,
align,
})
: null,
])
},
})
}
describe('EditorPopup — 좌우 자리', () => {
let wrapper: VueWrapper | null = null
afterEach(() => {
wrapper?.unmount()
wrapper = null
vi.restoreAllMocks()
document.body.innerHTML = ''
})
/** 화면 폭 360 — 단추(40)는 triggerLeft, 메뉴 폭 200 */
function layout(triggerLeft: number) {
vi.spyOn(window, 'innerWidth', 'get').mockReturnValue(360)
vi.spyOn(Element.prototype, 'getBoundingClientRect').mockImplementation(function (this: Element) {
if (this.matches('.trigger')) return rect(triggerLeft, 40)
if (this.matches('[role="menu"]')) return rect(triggerLeft, 200, 44, 120)
return rect(0, 0)
})
}
async function open(align: 'start' | 'end') {
wrapper = mount(host(align), { attachTo: document.body })
// 첫 그리기에서 단추 ref 를 받고, 다음 그리기에서 메뉴를 그린다
await nextTick()
await nextTick()
return wrapper.get('[role="menu"]')
}
it('왼쪽 끝에 맞춘 메뉴가 화면 오른쪽 밖으로 넘치면 단추 오른쪽 끝에 맞춘다', async () => {
layout(240)
const menu = await open('start')
expect(menu.classes()).toContain('editor-popup--end')
expect(menu.classes()).not.toContain('editor-popup--start')
})
it('다 들어가면 바라는 끝 그대로', async () => {
layout(60)
const menu = await open('start')
expect(menu.classes()).toContain('editor-popup--start')
})
it('오른쪽 끝에 맞춘 메뉴(⋯)가 화면 왼쪽 밖으로 넘치면 단추 왼쪽 끝에 맞춘다', async () => {
layout(80)
const menu = await open('end')
expect(menu.classes()).toContain('editor-popup--start')
})
})
@@ -5,8 +5,13 @@
// - 메뉴(role=menu): 위·아래 화살표 · Home · End 로 항목을 옮기고, Tab 은 메뉴를 닫는다
// - 마우스로 열었으면(focusContent) 항목이 아니라 팝오버 자체에 초점을 둔다 — 고른 항목에 키보드 고리가 뜨지 않게.
// 화살표를 누르면 그때 항목으로 들어간다 (키보드로 열면 처음부터 항목에 초점)
// - 맞출 끝(align)은 바라는 쪽이다 — 그린 직후(초점을 주기 전) 재어, 보이는 영역 밖으로 넘치면 반대 끝에 맞춘다
// (15단계 — 좁은 폭의 읽기 화면 [이동 ▾]처럼 왼쪽 끝에 맞춘 메뉴가 화면 오른쪽 밖으로 넘치면 단추 오른쪽 끝에. usePopupPlacement 의 measurePopupAlign)
import { nextTick, onBeforeUnmount, onMounted, ref } from 'vue'
import { measurePopupAlign } from '@/composables/usePopupPlacement'
import type { PopupAlign } from '@/shared/utils/popup-placement'
interface Props {
role: 'menu' | 'dialog'
label: string
@@ -32,6 +37,8 @@ const props = withDefaults(defineProps<Props>(), {
const emit = defineEmits<Emits>()
const rootRef = ref<HTMLElement | null>(null)
/** 실제로 맞춘 끝 — 바라는 끝(align)으로 그린 뒤 넘치면 반대 끝 */
const placedAlign = ref<PopupAlign>(props.align)
/**
* 화살표로 오가는 항목 — 꺼진 메뉴 항목은 aria-disabled 라 여기에 든다(초점은 받되 고를 수 없다 — WAI-ARIA 메뉴, EditorMenu).
@@ -105,8 +112,16 @@ function onFocusOut(event: FocusEvent) {
if (isOutside(event.relatedTarget)) emit('close', false)
}
/** 바라는 끝으로 그린 팝오버를 재어 넘치면 반대 끝으로 — 초점을 주기 전에 (넘친 자리에 초점이 가 화면이 옆으로 밀리지 않게) */
function placeSide() {
const root = rootRef.value
if (!root || !props.trigger) return
placedAlign.value = measurePopupAlign(props.trigger, root, props.align)
}
onMounted(() => {
document.addEventListener('pointerdown', onDocumentPointerDown, true)
placeSide()
void nextTick(focusInitial)
})
@@ -120,7 +135,7 @@ onBeforeUnmount(() => {
ref="rootRef"
:role="role"
:aria-label="label"
:class="['editor-popup', `editor-popup--${align}`, `editor-popup--${role}`]"
:class="['editor-popup', `editor-popup--${placedAlign}`, `editor-popup--${role}`]"
tabindex="-1"
data-editor-popup
@keydown="onKeydown"
@@ -18,6 +18,12 @@ export interface EditorMenuEntry {
disabled?: boolean
/** 이름 아래 작은 글 — 누를 수 없는 까닭 등 (읽기 화면 ⋯ 「이런 메일로 필터 만들기」 — 필터 50개). 꺼져도 흐리지 않는다 */
note?: string
/**
* 화면 낭독 이름 — 보이는 이름과 다르게 읽힐 때만 (15단계 [이동 ▾]의 들여 쓴 메일함 "거래처 안의 2025" — 같은 이름이 둘이어도 가르게)
*/
ariaLabel?: string
/** 들여쓰기 단계 — 메일함 패널처럼 왼쪽 안쪽 10 · 28 · 44 · 56 (3단계까지, 15단계 [이동 ▾]의 메일함 나무) */
depth?: number
/** danger: 되돌릴 수 없는 일 — 빨간 글자 (메일함 ⋯ 메뉴의 지우기 · 비우기) */
tone?: 'danger'
/** 한 단계 더 여는 항목 (›) — 메뉴인지 대화상자인지 */
@@ -0,0 +1,29 @@
// [이동 ▾] 메뉴의 항목 — 목록 도구 줄(MailBulkMoveButton)과 읽기 화면(MessageMoveButton)이 같은 모양을 쓴다
// (15단계 — 디자인 원본 Reader15 "1 · 이 메일이 든 메일함에 하위 메일함이 있을 때"). 옮길 곳을 고르는 규칙은 shared/utils/mail-move-targets.ts
// - 메일함 패널처럼 깊이만큼 들여 쓰고(10 · 28 · 44 · 56) 앞에 패널과 같은 아이콘, 기본 메일함과 내 메일함 사이에 가는 선
// - 들여 쓴 메일함은 화면 낭독이 위 메일함부터 읽는다("거래처 안의 2025")
// - 메일이 든 메일함을 남긴 줄은 고를 수 없고(aria-disabled) 이름 아래에 까닭(hereNote)을 적는다
import { menuSeparator, type EditorMenuItem } from '@/components/mail/editor/editor-menu'
import type { MailMoveTarget } from '@/shared/utils/mail-move-targets'
import { mailboxIcon } from '@/shared/utils/mailbox'
/** 기본 메일함과 내 메일함 사이의 선 — 메뉴 안에 하나뿐이다(두 묶음은 이어져 있다) */
const GROUP_SEPARATOR_ID = 'move-menu-group-separator'
export function moveMenuItems(rows: readonly MailMoveTarget[], hereNote: string): EditorMenuItem[] {
const items: EditorMenuItem[] = []
rows.forEach((row, index) => {
const previous = rows[index - 1]
if (previous && previous.group !== row.group) items.push(menuSeparator(GROUP_SEPARATOR_ID))
items.push({
id: row.mailbox.id,
label: row.mailbox.name,
ariaLabel: row.spokenName === row.mailbox.name ? undefined : row.spokenName,
icon: mailboxIcon(row.mailbox),
depth: row.depth,
disabled: row.disabled,
note: row.here ? hereNote : undefined,
})
})
return items
}
@@ -5,5 +5,7 @@ export type MailboxDialogRequest =
/** 새 메일함 — parent 가 없으면 맨 위 ("내 메일함"), 있으면 그 안 */
| { kind: 'create'; parent: Mailbox | null }
| { kind: 'rename'; mailbox: Mailbox }
/** 메일함 옮기기 (15단계) — 옮길 곳을 고르는 창 */
| { kind: 'move'; mailbox: Mailbox }
| { kind: 'delete'; mailbox: Mailbox }
| { kind: 'empty'; mailbox: Mailbox }
@@ -0,0 +1,51 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { mount } from '@vue/test-utils'
import { createPinia, setActivePinia, type Pinia } from 'pinia'
import { useAuthStore } from '@/stores/auth.store'
import { restoreApiAdapter } from '@/test-utils/mock-api'
import { createTestRouter } from '@/test-utils/router'
import AppTopBar from './AppTopBar.vue'
// 상단 바 — 15단계 좁은 폭(디자인 원본 ListDrag15 "4. 상단 바 · 휴대폰 폭 360"): 이름 · 주소 칸만 줄고 넘치면 "…", 전체는 마우스를 올리면 보인다.
// 줄 · 숨김(480 이하에서 주소 감춤)은 CSS 라 여기서는 차례 · 이름 · title 만 본다
describe('AppTopBar — 계정 칸', () => {
let pinia: Pinia
beforeEach(() => {
pinia = createPinia()
setActivePinia(pinia)
useAuthStore().markAuthenticated({ email: 'ara.kim@arasystem.it.kr', name: '김아라' })
})
afterEach(() => {
restoreApiAdapter()
})
async function mountBar() {
const router = createTestRouter()
await router.push('/mail/inbox')
await router.isReady()
return mount(AppTopBar, { props: { title: '메일' }, global: { plugins: [pinia, router] } })
}
it('차례는 그대로 — 모듈 이름 · 검색 칸 · 계정(머리글자 · 이름과 주소 · [로그아웃])', async () => {
const wrapper = await mountBar()
const children = [...wrapper.get('.top-bar').element.children].map((child) => child.className)
expect(children).toEqual(['top-bar__title', 'top-bar__search', 'top-bar__account'])
const account = [...wrapper.get('.top-bar__account').element.children].map((child) => child.className)
expect(account).toEqual(['top-bar__avatar', 'top-bar__who', 'top-bar__logout'])
expect(wrapper.get('.top-bar__logout').text()).toBe('로그아웃')
})
it('이름 · 주소가 줄어 "…"가 되어도 마우스를 올리면 다 보인다 — "김아라 (ara.kim@arasystem.it.kr)"', async () => {
const wrapper = await mountBar()
const who = wrapper.get('.top-bar__who')
expect(who.attributes('title')).toBe('김아라 (ara.kim@arasystem.it.kr)')
expect(who.get('.top-bar__name').text()).toBe('김아라')
expect(who.get('.top-bar__email').text()).toBe('ara.kim@arasystem.it.kr')
})
})
+35 -1
View File
@@ -1,6 +1,9 @@
<script setup lang="ts">
// 상단 바 — 모듈 이름 · 검색 칸 · 로그인한 직원 · 로그아웃
// 검색 칸은 모듈이 search 슬롯으로 넣는다(메일: "메일 검색" — MailSearchBox). 넣지 않은 모듈은 비활성 "통합 검색"(준비 중)이다.
// 좁은 폭(15단계 — 디자인 원본 ListDrag15 "4. 상단 바 · 휴대폰 폭 360"): [로그아웃] · 머리글자는 줄지도 꺾이지도 않고 이름 · 주소 칸만 준다 —
// 넘치면 끝을 "…"로 줄이고 전체는 마우스를 올리면 보인다(title). 화면 폭 480 이하에서는 주소를 감추고 이름만 둔다.
// 줄 수와 차례(모듈 이름 · 검색 칸 · 계정)는 그대로다 — 계정 칸을 첫 줄로 올리지 않는다(Tab 차례)
import { computed, ref } from 'vue'
import { useRouter } from 'vue-router'
@@ -23,6 +26,11 @@ const { logout } = useAuth()
const loggingOut = ref<boolean>(false)
const initial = computed(() => initialOf(authStore.user?.name, authStore.user?.email ?? ''))
/** 줄어든 이름 · 주소의 전체 — "김아라 (ara.kim@arasystem.it.kr)" */
const accountTitle = computed(() => {
const user = authStore.user
return user ? `${user.name} (${user.email})` : ''
})
async function onLogout() {
if (loggingOut.value) return
@@ -68,7 +76,10 @@ async function onLogout() {
class="top-bar__avatar"
aria-hidden="true"
>{{ initial }}</span>
<div class="top-bar__who">
<div
class="top-bar__who"
:title="accountTitle"
>
<span class="top-bar__name">{{ authStore.user.name }}</span>
<span class="top-bar__email">{{ authStore.user.email }}</span>
</div>
@@ -135,12 +146,16 @@ async function onLogout() {
.top-bar__account {
margin-left: auto;
/* 줄이 모자라면 이름 · 주소 칸이 준다 (줄을 넘지 않는다) */
min-width: 0;
max-width: 100%;
display: flex;
align-items: center;
gap: var(--space-10);
}
.top-bar__avatar {
flex: 0 0 auto;
display: inline-flex;
align-items: center;
justify-content: center;
@@ -153,11 +168,20 @@ async function onLogout() {
}
.top-bar__who {
min-width: 0;
display: flex;
flex-direction: column;
line-height: var(--line-height-tight);
}
/* 넘치면 끝을 "…"로 — 전체는 칸의 title */
.top-bar__name,
.top-bar__email {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.top-bar__name {
font-weight: 600;
}
@@ -167,7 +191,10 @@ async function onLogout() {
color: var(--color-text-muted);
}
/* [로그아웃]은 줄지도 꺾이지도 않는다 */
.top-bar__logout {
flex: 0 0 auto;
white-space: nowrap;
display: inline-flex;
align-items: center;
gap: var(--space-6);
@@ -183,4 +210,11 @@ async function onLogout() {
.top-bar__logout:hover {
background: var(--color-surface-muted);
}
/* 화면 폭 480 이하 — 주소를 감추고 이름만 (tokens.css 화면 폭 기준) */
@media (max-width: 30rem) {
.top-bar__email {
display: none;
}
}
</style>
+58 -17
View File
@@ -2,10 +2,12 @@ import { computed, ref } from 'vue'
import { useRouter } from 'vue-router'
import { notifyApiError } from './useApi'
import type { MailDragPayload } from './useMailDrag'
import { useMailMoveFeedback } from './useMailMoveFeedback'
import type { Mailbox } from '@/shared/types/mail'
import { listLocationOf } from '@/shared/utils/mail-list-source'
import { useMailStore, type MailBulkResult, type MoveOptions } from '@/stores/mail.store'
import { listLocationOf, scopeMailboxIdOf } from '@/shared/utils/mail-list-source'
import { hereMailboxIds, junkMarkForMove, mailMoveTargets, type MailMoveTarget } from '@/shared/utils/mail-move-targets'
import { useMailStore, type BulkMoveResult, type MailBulkResult, type MoveOptions } from '@/stores/mail.store'
// 여러 통 처리 — 도구 줄의 단추가 하는 일 (계약 2절 "여러 통 한꺼번에" · 디자인 README "3단계 › 여러 통 처리")
// - 읽음 · 안 읽음 · 중요 표시: 목록이 바로 바뀌므로 알리지 않는다 (서버가 바꾸지 못한 메일만 되돌린다)
@@ -16,6 +18,10 @@ import { useMailStore, type MailBulkResult, type MoveOptions } from '@/stores/ma
// - 대화로 묶은 메일함(14단계)은 고른 줄의 메일 모두에 한다(붙잡힌 메일은 빼고 — 스토어). "중요 해제"는 고른 줄이 모두 중요 표시가 있을 때.
// 옮기기 · 삭제 · 스팸 알림은 "받은편지함에 있는 메일 2통을 휴지통으로 옮겼습니다." — 줄의 "4통"은 대화 전체라서 (2026-11-06 실사용 시험).
// 휴지통의 영구 삭제도 확인 창 · 알림이 "휴지통에 있는 메일 2통을 영구 삭제할까요? · …했습니다." (2026-11-07 — conversationFrom)
// - 15단계: [이동 ▾]의 옮길 곳은 메일함 패널처럼 들여 쓴 나무(mail-move-targets.ts — 고른 메일이 든 메일함에 하위 메일함이 있으면 고를 수 없는 줄로 남긴다).
// 스팸메일함으로 옮기면 [스팸 신고]와 같이 스팸 표시를, 스팸메일함에서 휴지통 밖으로 꺼내면 [스팸 아님]과 같이 스팸 아님 표시를 함께 보낸다(junkMarkForMove).
// 목록 줄을 메일함 패널에 끌어다 놓은 것(moveDragged — 휴지통에 놓으면 [삭제])도 같은 길 · 같은 알림이다.
// 알림의 메일함 이름은 같은 이름이 다른 곳에도 있을 때만 "거래처 › 2025" (useMailMoveFeedback)
export function useBulkMailActions() {
const mailStore = useMailStore()
@@ -40,18 +46,28 @@ export function useBulkMailActions() {
* 대화로 묶은 목록이면 지금 메일함의 이름 — 줄의 통 수는 대화 전체라 알림 · 영구 삭제 확인이 그 메일함의 메일임을 밝힌다
* ("받은편지함에 있는 메일 2통을 …" · "휴지통에 있는 메일 2통을 영구 삭제할까요?"). 묶지 않은 목록이면 null (지금 글 그대로)
*/
const conversationFrom = computed<string | null>(() =>
mailStore.isConversationList ? (mailStore.currentMailbox?.name ?? null) : null,
)
/** 이동 메뉴 — 지금 메일함 · 임시보관함 · 휴지통을 뺀 메일함 (메일함 패널 순서) */
const moveTargets = computed<Mailbox[]>(() =>
mailStore.orderedMailboxes
.map((node) => node.mailbox)
.filter(
(mailbox) =>
mailbox.id !== mailStore.currentMailboxId && mailbox.role !== 'drafts' && mailbox.role !== 'trash',
),
)
const conversationFrom = computed<string | null>(() => {
const mailbox = mailStore.isConversationList ? mailStore.currentMailbox : null
return mailbox ? feedback.placeName(mailbox) : null
})
/**
* 이동 메뉴 — 메일함 패널 차례 · 깊이. 고른 메일이 든 메일함(목록이 가리키는 메일함 · 고른 메일이 모두 든 곳) · 임시보관함 · 휴지통은
* 빼고, 그중 하위 메일함이 있는 것은 고를 수 없는 줄로 남긴다 (mail-move-targets.ts)
*/
const moveTargets = computed<MailMoveTarget[]>(() => {
const scopeId = scopeMailboxIdOf(mailStore.listSource)
const here = mailStore.isConversationList
? hereMailboxIds([], scopeId)
: hereMailboxIds(mailStore.checkedMessages, scopeId)
return mailMoveTargets(mailStore.mailboxes, here)
})
/** 옮기며 함께 보낼 스팸 표시 — 부르는 쪽이 정했으면(스팸 신고 · 스팸 아님) 그대로, 아니면 옮길 곳과 지금 메일함으로 (junkMarkForMove) */
function junkOptionsFor(mailbox: Mailbox, options: MoveOptions): MoveOptions {
if (options.isJunk !== undefined) return options
const isJunk = junkMarkForMove(mailbox, inJunk.value)
return isJunk === undefined ? {} : { isJunk }
}
async function markRead(isRead: boolean): Promise<void> {
await mailStore.patchChecked({ isRead })
@@ -71,15 +87,39 @@ export function useBulkMailActions() {
await router.replace(listLocationOf(source))
}
/** 옮긴 뒤 — 열어 둔 메일이 빠졌으면 목록으로, 알림(+ 되돌리기) */
async function settleMove(result: BulkMoveResult, mailbox: Mailbox, from: string | null): Promise<void> {
await leaveRemovedMessage(result)
feedback.announceMove(result, mailbox, { from })
}
async function moveTo(mailbox: Mailbox, options: MoveOptions = {}): Promise<void> {
if (busy.value || mailStore.checkedCount === 0) return
// 대화로 묶은 목록은 줄의 통 수가 대화 전체라 알림이 지금 메일함의 메일임을 밝힌다 — 시작할 때의 메일함 (기다리는 사이 목록이 바뀔 수 있다)
const from = conversationFrom.value
const moveOptions = junkOptionsFor(mailbox, options)
busy.value = true
try {
const result = await mailStore.moveChecked(mailbox.id, options)
await leaveRemovedMessage(result)
feedback.announceMove(result, mailbox.name, { from })
const result = await mailStore.moveChecked(mailbox.id, moveOptions)
await settleMove(result, mailbox, from)
} finally {
busy.value = false
}
}
/**
* 목록 줄을 메일함 패널에 끌어다 놓았다 (15단계 — useMailDrag) — 놓은 메일함이 휴지통이면 [삭제], 스팸메일함이면 [스팸 신고]와 같고, 그 밖은 [이동 ▾]와 같다.
* 고른 것을 끌었으면 옮긴 뒤 고른 것을 푼다(하나라도 옮겼을 때 — 도구 줄과 같다). 고르지 않은 줄을 끌었으면 고른 것은 그대로 둔다
*/
async function moveDragged(drag: MailDragPayload, mailbox: Mailbox): Promise<void> {
if (busy.value || drag.targets.length === 0) return
const from = drag.kind === 'conversations' ? conversationFrom.value : null
const moveOptions = junkOptionsFor(mailbox, {})
busy.value = true
try {
const result = await mailStore.moveMessages(drag.targets, mailbox.id, moveOptions)
if (drag.fromSelection && !result.stale && result.doneIds.length > 0) mailStore.clearChecked()
await settleMove(result, mailbox, from)
} finally {
busy.value = false
}
@@ -134,6 +174,7 @@ export function useBulkMailActions() {
markRead,
toggleFlagged,
moveTo,
moveDragged,
moveToTrash,
moveSpam,
purge,
@@ -0,0 +1,71 @@
import { onBeforeUnmount, onMounted, watch, type Ref } from 'vue'
// 같은 대화 칸의 펼친 안([data-conversation-panel]) 가운데 화면에 들어온 것을 알린다
// (15단계 — 디자인 원본 Reader15 "6": [모두 펼치기]의 본문은 화면에 들어오는 줄부터 하나씩 받는다 — 받는 차례는 스토어의 noteConversationPanelVisible).
// - IntersectionObserver 하나로 칸 안의 펼친 안을 모두 본다. 칸을 다시 그릴 때마다(watchKey) 새로 생긴 안은 보기 시작하고, 사라진 안은 나간 것으로 알린다
// - 보이는 곳은 화면이다 — 읽기 칸처럼 스크롤되는 조상이 잘라 낸 곳은 보이지 않는 것으로 친다(IntersectionObserver 의 기본 뿌리가 그렇게 잰다)
// - IntersectionObserver 가 없는 브라우저 · 시험 환경은 펼친 안이 생기면 들어온 것으로 본다(받는 것은 그래도 하나씩이다)
// - 칸이 사라지면(읽기 화면을 떠남) 보던 안을 모두 나간 것으로 알린다
/** 펼친 안의 표시 — 값은 그 줄의 메일 id (ConversationMessagePanel) */
const PANEL_SELECTOR = '[data-conversation-panel]'
export interface ConversationPanelVisibilityOptions {
/** 칸 — 그 안의 펼친 안을 본다 (칸이 없으면 null) */
root: Ref<HTMLElement | null>
/** 다시 그릴 때 바뀌는 값 — 바뀌면 펼친 안을 다시 센다 */
watchKey: () => unknown
onChange: (id: string, visible: boolean) => void
}
export function useConversationPanelVisibility(options: ConversationPanelVisibilityOptions): void {
/** 보고 있는 안 — 요소마다 그 줄의 메일 id */
const observed = new Map<Element, string>()
let observer: IntersectionObserver | null = null
function report(entries: IntersectionObserverEntry[]) {
for (const entry of entries) {
const id = observed.get(entry.target)
if (id !== undefined) options.onChange(id, entry.isIntersecting)
}
}
function forget(element: Element, id: string) {
observed.delete(element)
observer?.unobserve(element)
options.onChange(id, false)
}
/** 지금 칸 안의 펼친 안과 맞춘다 — 사라진 안은 나간 것으로, 새 안은 보기 시작한다 */
function sync() {
const panels = new Map<Element, string>()
for (const element of options.root.value?.querySelectorAll<HTMLElement>(PANEL_SELECTOR) ?? []) {
const id = element.dataset.conversationPanel
if (id) panels.set(element, id)
}
for (const [element, id] of [...observed]) {
if (panels.get(element) !== id) forget(element, id)
}
for (const [element, id] of panels) {
if (observed.has(element)) continue
observed.set(element, id)
if (observer) observer.observe(element)
else options.onChange(id, true)
}
}
onMounted(() => {
if (typeof IntersectionObserver !== 'undefined') observer = new IntersectionObserver(report)
sync()
})
watch(options.watchKey, sync, { flush: 'post' })
onBeforeUnmount(() => {
observer?.disconnect()
observer = null
const left = [...observed.values()]
observed.clear()
for (const id of left) options.onChange(id, false)
})
}
+69
View File
@@ -192,3 +192,72 @@ describe('useMail 이미 받은 메일에 적용 — POST /mail-filter-runs (계
expect(requests[0]?.config.silent).toEqual(['REQ_001'])
})
})
describe('useMail — 15단계 메일 · 메일함 다루기 (계약 2절 "메일함 관리" · "보낸 사람 차단" · "보내기 취소 · 예약 발송")', () => {
beforeEach(() => {
setActivePinia(createPinia())
})
afterEach(() => {
restoreApiAdapter()
})
it('메일함 옮기기는 PATCH /mailboxes/{id} 에 parentId 만 — 맨 위는 null 을 그대로 싣는다', async () => {
const requests = installMockApi(() => ok({ id: 'c25' }))
await useMail().moveMailbox('c/25', { parentId: 'p' }, { silent: ['MAIL_009'] })
await useMail().moveMailbox('c25', { parentId: null })
expect(requests.map((item) => `${item.method} ${item.url}`)).toEqual(['PATCH /mailboxes/c%2F25', 'PATCH /mailboxes/c25'])
expect(requests.map((item) => item.data)).toEqual([{ parentId: 'p' }, { parentId: null }])
expect(requests[0]?.config.silent).toEqual(['MAIL_009'])
})
it('하위까지 지우기는 ?withChildren=true — 주지 않으면 쿼리 없이 (옛 화면처럼 하위가 있으면 서버가 막는다)', async () => {
const requests = installMockApi(() => ok({ deleted: true, processed: 0, movedToTrash: 0, remaining: 0 }))
await useMail().deleteMailbox('c', { withChildren: true, silent: ['RES_001'] })
await useMail().deleteMailbox('c')
expect(requests.map((item) => [item.method, item.url, item.params])).toEqual([
['DELETE', '/mailboxes/c', { withChildren: true }],
['DELETE', '/mailboxes/c', {}],
])
expect(requests[0]?.config.silent).toEqual(['RES_001'])
// withChildren 은 요청 설정으로 새지 않는다
expect(Object.keys(requests[0]?.config ?? {})).not.toContain('withChildren')
})
it('차단 정리는 POST /mail-block-runs { sender, cursor } — 필터 적용과 같은 시간 한도', async () => {
const run = { scanned: 1200, matched: 12, remaining: 3600, cursor: 'b1' }
const requests = installMockApi(() => ok(run))
await expect(useMail().runSenderBlock({ sender: '@example.com', cursor: null }, { silent: true })).resolves.toEqual(run)
expect(requests.map((item) => `${item.method} ${item.url}`)).toEqual(['POST /mail-block-runs'])
expect(requests[0]?.data).toEqual({ sender: '@example.com', cursor: null })
expect(requests[0]?.config.timeout).toBe(MAIL_FILTER_RUN_TIMEOUT_MS)
})
it.each([
['보내기 취소를 기다리는 중이면 그대로', { id: 's/1', pendingSend: { kind: 'undo', sendAt: '2026-11-09T05:00:10.000Z' }, rejectedRecipients: [] }],
['바로 보냈으면 null', { id: 's/1', pendingSend: null, rejectedRecipients: [NOBODY] }],
])('지금 보내기는 POST /scheduled-messages/{id}/release (본문 없음) — %s', async (_label, reply) => {
const requests = installMockApi(() => ok(reply))
await expect(useMail().releaseScheduledMessage('s/1')).resolves.toEqual(reply)
expect(requests.map((item) => `${item.method} ${item.url}`)).toEqual(['POST /scheduled-messages/s%2F1/release'])
expect(requests[0]?.data).toBeUndefined()
})
it.each([
['칸이 없는 응답', {}],
['모양이 틀린 칸', { id: 7, pendingSend: { kind: 'later', sendAt: '곧' }, rejectedRecipients: 'x' }],
['빈 응답', null],
])('지금 보내기 응답은 늘 값으로 맞춘다 — %s 은 빈 배열 · null · 요청한 id', async (_label, reply) => {
installMockApi(() => ok(reply))
await expect(useMail().releaseScheduledMessage('s1')).resolves.toEqual({ id: 's1', rejectedRecipients: [], pendingSend: null })
})
})
+71 -4
View File
@@ -10,6 +10,7 @@ import {
MAIL_SEND_TIMEOUT_MS,
MAIL_UPLOAD_TIMEOUT_MS,
} from '@/shared/constants/mail'
import { MAIL_BLOCK_RUN_TIMEOUT_MS } from '@/shared/constants/mail-block'
import { MAIL_CONVERSATION_TIMEOUT_MS } from '@/shared/constants/mail-conversation'
import type {
Attachment,
@@ -19,6 +20,8 @@ import type {
Draft,
DraftPayload,
DraftSaved,
MailBlockRun,
MailBlockRunRequest,
MailFilterRun,
MailFilterRunRequest,
MailFilterSet,
@@ -32,6 +35,7 @@ import type {
MailboxBulkResult,
MailboxCreatePayload,
MailboxDeletion,
MailboxMovePayload,
MailboxRenamePayload,
MessageDeletionRequest,
MessageDeletionResult,
@@ -46,6 +50,7 @@ import type {
RecipientSuggestionQuery,
SaveSignaturePayload,
ScheduledMessageCancel,
ReleasedSend,
SearchIndexState,
SendMessagePayload,
SentMessage,
@@ -70,6 +75,11 @@ export interface GetMessageOptions extends MailRequestOptions {
highlight?: MessageHighlightQuery | null
}
/** 메일함 지우기의 옵션 — withChildren 이면 하위 메일함까지 (15단계 — `?withChildren=true`) */
export interface DeleteMailboxOptions extends MailRequestOptions {
withChildren?: boolean
}
export interface SendMessageOptions extends MailRequestOptions {
/**
* 중복 발송 방지 키 (계약 2절) — 쓰기 화면마다 하나 만들고, 실패 뒤 다시 보낼 때도 같은 값을 넘긴다.
@@ -87,6 +97,12 @@ type SentMessageReply = Omit<SentMessage, 'rejectedRecipients' | 'pendingSend'>
pendingSend?: unknown
}
/**
* POST /scheduled-messages/{id}/release 가 실제로 주는 모양 (15단계 — 계약의 ReleasedSend). 모양이 틀리면(응답을 바꾼 게이트웨이 등)
* releaseScheduledMessage 가 빈 배열 · null · 요청한 id 로 맞춘다
*/
type ScheduledReleaseReply = { id?: unknown; rejectedRecipients?: unknown; pendingSend?: unknown } | null
/** GET · PUT /mail-vacation 이 실제로 주는 모양 — 7단계 앞의 서버는 isActive 가 없다 (vacationStateOf 가 맞춘다) */
type MailVacationReply = MailVacation & { isActive?: unknown }
@@ -153,13 +169,25 @@ export function useMail() {
}
/**
* 메일함 지우기 — 안의 메일을 휴지통으로 옮긴 뒤 지운다. 한 요청에 다 옮기지 못하면 deleted: false 다 (같은 요청을 다시 보낸다).
* 하위 메일함이 있으면 400 VAL_001 (화면이 먼저 막는다)
* 메일함 옮기기 (15단계) — 같은 PATCH 에 parentId 만 보낸다(null 은 맨 위). 하위 메일함과 안의 메일이 함께 간다.
* 새 상위 안에 같은 이름(대소문자 무시)이면 409 MAIL_009, 자기 자신 · 하위 아래 · 둘 수 없는 자리는 400 VAL_001, 없는 메일함 · 상위는 404 RES_001,
* 그 메일함의 오래 걸리는 처리가 진행 중이면 429 REQ_001
*/
function deleteMailbox(id: string, options?: MailRequestOptions): Promise<MailboxDeletion> {
function moveMailbox(id: string, payload: MailboxMovePayload, options?: MailRequestOptions): Promise<Mailbox> {
return api.patch<Mailbox, Mailbox>(`/mailboxes/${segment(id)}`, { parentId: payload.parentId }, options)
}
/**
* 메일함 지우기 — 안의 메일을 휴지통으로 옮긴 뒤 지운다. 한 요청에 다 옮기지 못하면 deleted: false 다 (같은 요청을 다시 보낸다).
* 하위 메일함이 있으면 withChildren(?withChildren=true — 15단계)일 때만 하위까지 깊은 것부터 지운다. 없으면 400 VAL_001
* (확인 창이 하위까지 지울지 물은 뒤에만 켠다 — 옛 화면이 모르고 하위를 지우지 않게)
*/
function deleteMailbox(id: string, options: DeleteMailboxOptions = {}): Promise<MailboxDeletion> {
const { withChildren, ...requestOptions } = options
return api.delete<MailboxDeletion, MailboxDeletion>(`/mailboxes/${segment(id)}`, {
timeout: MAIL_MAILBOX_OPERATION_TIMEOUT_MS,
...options,
...requestOptions,
...(withChildren ? { params: { withChildren: true } } : {}),
})
}
@@ -229,6 +257,20 @@ export function useMail() {
})
}
/**
* 차단한 보낸 사람(주소 · 도메인)의 메일 가운데 받은편지함에 이미 있는 것을 스팸메일함으로 옮긴다 (15단계 — 차단 창의 새 칸).
* 필터 적용과 같은 규칙이다: 한 요청은 25초 안에 끝나고 cursor 를 돌려준다 — null 이 될 때까지 같은 sender 에 그 cursor 를 넣어 다시 보낸다
* (부르는 쪽 — mail-block-run.store). 같은 직원의 받은편지함 정리(필터 적용 · 이것)가 다른 곳에서 진행 중이면 429 REQ_001,
* 차단 목록에 없는 sender 는 404 RES_001, cursor 가 틀리면 400 VAL_001, 하나도 확인하지 못했으면 503 MAIL_001
*/
function runSenderBlock(request: MailBlockRunRequest, options?: MailRequestOptions): Promise<MailBlockRun> {
return api.post<MailBlockRun, MailBlockRun>(
'/mail-block-runs',
{ sender: request.sender, cursor: request.cursor },
{ timeout: MAIL_BLOCK_RUN_TIMEOUT_MS, ...options },
)
}
/**
* 새 메일 확인 — 메일함이나 메일이 하나라도 바뀌면 state 가 달라진다 (계약 2절 "새 메일 확인").
* 메일을 읽지 않는 가벼운 조회라 화면이 보이는 동안 1분마다 부른다 (useMailStatePolling)
@@ -447,6 +489,28 @@ export function useMail() {
return api.delete<ScheduledMessageCancel, ScheduledMessageCancel>(`/scheduled-messages/${segment(id)}`, options)
}
/**
* 예약한 메일을 지금 보낸다 (15단계 — 읽기 화면 예약 안내 줄의 [지금 보내기]). id 는 보낸 사본의 메일 id(예약 취소와 같다), 본문 없음.
* 서버가 예약을 거두고 같은 메일을 다시 제출한다 — 보내기 취소 설정의 초만큼 붙잡았다 보내면 pendingSend { kind: 'undo' }, 바로 보냈으면 null.
* 예약 시각이 막 지나 이미 보낸 메일 · 이미 지금 보내기를 한 메일도 200 이다(오류가 아니다). 예약이 아닌 메일 · 없는 메일은 404 RES_001.
* 다시 제출하지 못하면 503 MAIL_001 — 엔진이 거절했으면 임시보관함으로 되돌렸고, 결과를 모르면 메일은 보낸편지함에 그대로이며 같은 요청을 다시 보내면
* 서버가 이어 낸다(어느 쪽인지는 그 메일을 다시 읽어 가른다 — stores/mail/scheduled.ts). 받는 사람이 모두 받지 않으면 400 MAIL_010 · 503 MAIL_011(임시보관함으로 되돌렸다),
* 같은 메일의 취소 · 지금 보내기가 진행 중이면 429 REQ_001. 발송처럼 한도는 MAIL_SEND_TIMEOUT_MS (서버는 54초 남짓 안에 답한다).
* 받는 사람별 결과(rejectedRecipients)와 pendingSend 는 발송과 같은 규칙으로 늘 값으로 맞춘다 — 없거나 모양이 틀리면 빈 배열 · null, id 가 없으면 요청한 id
*/
async function releaseScheduledMessage(id: string, options?: MailRequestOptions): Promise<ReleasedSend> {
const released = await api.post<ScheduledReleaseReply, ScheduledReleaseReply>(
`/scheduled-messages/${segment(id)}/release`,
undefined,
{ timeout: MAIL_SEND_TIMEOUT_MS, ...options },
)
return {
id: typeof released?.id === 'string' && released.id !== '' ? released.id : id,
rejectedRecipients: Array.isArray(released?.rejectedRecipients) ? released.rejectedRecipients : [],
pendingSend: pendingSendOf(released?.pendingSend),
}
}
/**
* 첨부 한 개 업로드 (multipart `file`).
* 형식·크기 오류(FILE_001·FILE_002)는 파일 목록에 직접 표시하므로 그 코드만 전역 알림을 끈다.
@@ -505,6 +569,7 @@ export function useMail() {
getQuota,
createMailbox,
renameMailbox,
moveMailbox,
deleteMailbox,
markMailboxRead,
emptyMailbox,
@@ -513,6 +578,7 @@ export function useMail() {
getFilters,
saveFilters,
runFilter,
runSenderBlock,
messageSourceUrl,
downloadMessageSource,
getMailState,
@@ -527,6 +593,7 @@ export function useMail() {
sendMessage,
listScheduledMessages,
cancelScheduledMessage,
releaseScheduledMessage,
uploadAttachment,
attachmentDownloadUrl,
downloadAttachment,
+169
View File
@@ -0,0 +1,169 @@
import { computed, shallowRef } from 'vue'
import { MAIL_BULK_TEXT } from '@/shared/constants/mail'
import type { Mailbox } from '@/shared/types/mail'
import { formatCount } from '@/shared/utils/format'
import { conversationActionIdsOf, isConversationHeld } from '@/shared/utils/mail-conversation-list'
import { scopeMailboxIdOf } from '@/shared/utils/mail-list-source'
import { canDropMailOn, hereMailboxIds } from '@/shared/utils/mail-move-targets'
import { isHeldMessage } from '@/shared/utils/pending-send'
import { createUuid } from '@/shared/utils/uuid'
import { useMailStore } from '@/stores/mail.store'
// 목록 줄을 메일함 패널로 끌어다 놓기 (15단계 — 디자인 원본 ListDrag15 · 디자인 README "15단계")
// - 끄는 것: 고른 줄을 끌면 고른 것 모두, 고르지 않은 줄을 끌면 그 줄만(고른 것은 그대로). 대화로 묶은 목록은 그 대화에서 지금 메일함에 든 메일
// (붙잡힌 메일은 빼고 — [이동 ▾]와 같다). 보내기를 기다리는 메일의 줄 · 그런 메일뿐인 대화 줄은 끌 수 없다
// - 끄는 동안 마우스 오른쪽 아래에 "메일 N통" · "대화 N개" 표(MailDragChip — setDragImage). 끈 줄은 흐리게 하지 않는다
// - 놓을 곳은 메일함 패널의 줄(MailboxPanelRow) — [이동 ▾]의 옮길 곳 + 휴지통([삭제]와 같다). 메일이 이미 든 메일함 · 임시보관함 · 「예약됨」은 놓을 수 없다
// (브라우저의 금지 커서 — dragover 를 받지 않는다)
// - 끄는 것은 우리 끌기 자료(MAIL_DRAG_TYPE)뿐이다 — 줄의 링크가 끌어 가던 메일 주소(URL)는 지운다. 다른 창 · 바깥 프로그램에 놓아도 아무 일 없다
// - 끄는 동안의 자료는 이 탭의 메모리에만 둔다(dragover 에서는 dataTransfer 의 값을 읽을 수 없다) — 놓을 때 dataTransfer 의 표와 견줘 같은 끌기인지 본다
// - 마우스로만 한다 — 키보드 · 터치는 [이동 ▾](옮길 곳 · 알림이 같다)
/** 우리 끌기 자료의 종류 — 값은 이 끌기의 표(token) */
export const MAIL_DRAG_TYPE = 'application/x-arasystem-mail'
/** 옮길 메일 하나 — id 와 되돌릴 메일함을 정할 메일함 (스토어의 moveMessages 와 같은 꼴) */
export interface MailDragTarget {
id: string
mailboxIds: string[]
}
/** 끄는 동안의 자료 */
export interface MailDragPayload {
/** 이 끌기의 표 — dataTransfer 에 실은 값과 같아야 놓기를 받는다 */
token: string
/** 끈 것 — 메일 줄 · 대화 줄 */
kind: 'messages' | 'conversations'
/** 표의 수 — 메일 수 또는 대화 수 */
count: number
targets: MailDragTarget[]
/** 놓을 수 없는 메일함 — 메일이 이미 든 곳 (mail-move-targets.ts 의 hereMailboxIds) */
hereIds: ReadonlySet<string>
/** 고른 것을 끌었다 — 옮기면 고른 것을 푼다 */
fromSelection: boolean
}
/** 지금 끄는 것 — 놓거나 그만두면 null */
const current = shallowRef<MailDragPayload | null>(null)
/** 마우스를 따라오는 표 — MailDragChip 이 그린다 (setDragImage 는 그려 둔 요소의 모습을 찍는다) */
let chipElement: HTMLElement | null = null
/** 메일을 끄는 중인가 — 끌기가 끝나면(놓음 · 그만둠) 메일함 줄이 켜 둔 점선을 끈다 (MailboxPanelRow) */
export const mailDragging = computed<boolean>(() => current.value !== null)
/** MailDragChip 이 표 요소를 알린다 (사라질 때 null) */
export function registerMailDragChip(element: HTMLElement | null): void {
chipElement = element
}
/** 지금 끄는 메일 — 우리 끌기이고 이 탭에서 시작했을 때만 (다른 탭 · 바깥에서 끌어 온 것은 null) */
export function activeMailDrag(event: DragEvent): MailDragPayload | null {
const types = event.dataTransfer?.types
if (!types || !Array.from(types).includes(MAIL_DRAG_TYPE)) return null
return current.value
}
/** 놓은 끌기 — 표가 같을 때만 자료를 돌려주고 끌기를 끝낸다 */
export function takeMailDrop(event: DragEvent): MailDragPayload | null {
const payload = activeMailDrag(event)
if (!payload || event.dataTransfer?.getData(MAIL_DRAG_TYPE) !== payload.token) return null
current.value = null
return payload
}
/** 끌기가 끝났다 — 놓았든 그만뒀든 (끈 줄의 dragend) */
export function endMailDrag(): void {
current.value = null
}
/** 그 메일함에 지금 끄는 메일을 놓을 수 있는가 */
export function canDropDragOn(payload: MailDragPayload, mailbox: Pick<Mailbox, 'id' | 'role'>): boolean {
return canDropMailOn(mailbox, payload.hereIds)
}
/** 표에 수 · 아이콘을 넣는다 — 끌기를 시작하는 그 순간에 그려져 있어야 해서 화면 갱신을 기다리지 않고 바로 고친다 */
function paintChip(chip: HTMLElement, payload: MailDragPayload) {
const count = formatCount(payload.count)
chip.dataset.kind = payload.kind
const label = chip.querySelector<HTMLElement>('[data-drag-chip-text]')
if (label) {
label.textContent =
payload.kind === 'conversations' ? MAIL_BULK_TEXT.dragConversations(count) : MAIL_BULK_TEXT.dragMessages(count)
}
}
/**
* 끌기를 시작한다 — 링크가 실은 주소를 지우고 우리 자료만 싣는다. 표가 있으면 마우스를 따라오게 한다
* (표 요소의 왼쪽 위가 마우스 자리다 — 표는 그 안에서 오른쪽 아래로 비켜 그려 둔다, MailDragChip)
*/
function beginMailDrag(event: DragEvent, payload: MailDragPayload) {
const transfer = event.dataTransfer
if (!transfer) return
transfer.clearData()
transfer.setData(MAIL_DRAG_TYPE, payload.token)
transfer.effectAllowed = 'move'
if (chipElement) {
paintChip(chipElement, payload)
transfer.setDragImage(chipElement, 0, 0)
}
current.value = payload
}
/**
* 목록 줄의 끌기 (MessageListItem · MessageTableRow · ConversationListItem · ConversationTableRow).
* 줄 요소에 :draggable="!held"(보내기를 기다리는 메일의 줄은 끌 수 없다) · @dragstart="drag.start($event, id)" · @dragend="drag.end" 를 단다.
* 끌 수 없는 줄에서 시작한 끌기(줄 안의 링크를 끈 것)는 dragstart 에서 막는다
*/
export function useMailRowDrag() {
const mailStore = useMailStore()
/** 끌 수 있는 줄인가 — 보내기를 기다리는 메일의 줄 · 그런 메일뿐인 대화 줄은 아니다 */
function canDrag(rowId: string): boolean {
if (mailStore.isConversationList) {
const row = mailStore.conversations.find((entry) => entry.id === rowId)
return Boolean(row && !isConversationHeld(row))
}
const message = mailStore.messages.find((entry) => entry.id === rowId)
return Boolean(message && !isHeldMessage(message))
}
/** 그 줄을 끌면 옮길 것 — 고른 줄이면 고른 것 모두 */
function payloadOf(rowId: string): MailDragPayload | null {
if (!canDrag(rowId)) return null
const fromSelection = mailStore.checkedIds.has(rowId)
const scopeId = scopeMailboxIdOf(mailStore.listSource)
let kind: MailDragPayload['kind']
let count: number
let targets: MailDragTarget[]
if (mailStore.isConversationList) {
const rows = fromSelection
? mailStore.checkedConversations
: mailStore.conversations.filter((entry) => entry.id === rowId)
kind = 'conversations'
count = rows.length
targets = conversationActionIdsOf(rows).map((id) => ({ id, mailboxIds: scopeId ? [scopeId] : [] }))
} else {
const messages = fromSelection
? mailStore.checkedMessages
: mailStore.messages.filter((entry) => entry.id === rowId && !isHeldMessage(entry))
kind = 'messages'
count = messages.length
targets = messages.map((message) => ({ id: message.id, mailboxIds: [...message.mailboxIds] }))
}
if (targets.length === 0) return null
return { token: createUuid(), kind, count, targets, hereIds: hereMailboxIds(targets, scopeId), fromSelection }
}
function start(event: DragEvent, rowId: string) {
const payload = payloadOf(rowId)
if (!payload) {
// 끌 수 없는 줄 — 링크의 주소 끌기도 막는다
event.preventDefault()
return
}
beginMailDrag(event, payload)
}
return { canDrag, start, end: endMailDrag }
}

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