generated from ttipo/project-base
Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 32576b0ad1 | |||
| 5b6a97e5e1 | |||
| 192807f943 | |||
| 090ee361a3 | |||
| 7585b3bfac | |||
| 419b2009ab |
@@ -33,8 +33,9 @@
|
||||
- 비밀번호, 토큰(JWT·리프레시·API 키), 쿠키, `Authorization` 헤더
|
||||
- 주민등록번호·카드번호·계좌번호 등 개인 식별/금융 정보
|
||||
- 요청 본문 전체 덤프 (`JSON.stringify(req.body)` 금지 — 필요한 필드만 선택해 남긴다)
|
||||
- 사용자가 친 검색어(쿼리의 `q` — 메일 검색 · 받는 사람 추천) — 요청 URL 을 남길 때는 `shared/utils/mask.ts` 의 `maskUrlForLog()` 를 거친다(`q` 는 길이만).
|
||||
앞단 nginx 도 `/api` 접근 로그에 쿼리를 남기지 않는다 ([[docker]])
|
||||
- 사용자가 친 검색어(쿼리의 `q` — 메일 검색 · 받는 사람 추천) — 요청 URL 을 남길 때는 `shared/utils/mask.ts` 의 `maskUrlForLog()` 를 거친다(`q` · `cursor` 는 길이만 — 목록 커서에는 검색어의 지문이 들어 있어 후보 낱말을 대입해 볼 수 있다).
|
||||
앞단 nginx 도 접근 로그에 쿼리를 남기지 않는다 — `/api` 와 화면 요청 모두(메일 검색은 화면 주소 `/mail/search?q=…` 에도 검색어가 들어간다, [[docker]]).
|
||||
**예외**: nginx 오류 로그(413 · 502 · 504 등)는 장애 원인 줄을 잃지 않게 요청 줄 · referrer 를 쿼리째 남긴다 — 오류 로그를 볼 수 있는 사람을 운영 담당으로 한정한다
|
||||
|
||||
```typescript
|
||||
// ❌ 금지
|
||||
|
||||
@@ -72,15 +72,21 @@ sudo docker compose --env-file .env.production logs -f backend
|
||||
발송은 정확히 이 경로만이라 `/api/messages/{id}` 는 1m. 임시 저장은 `/api/drafts` 아래 전부 — 본문이 있는 것은 `POST`·`PUT` 뿐이다),
|
||||
그 밖은 1m(백엔드 JSON 파서 한도). 앞의 두 값은 루트 env 한 곳만 고치면 백엔드와 nginx 가 함께 바뀐다 (frontend 컨테이너만 다시 만들면 된다).
|
||||
JSON 한도(1m·4m)는 계약과 백엔드 코드의 고정값이라 env 로 바꾸지 않는다 — 바꾸려면 계약·백엔드 파서·`frontend/nginx.conf` 를 함께 고친다.
|
||||
- 시간 제한: `/api/messages`·`/api/drafts` 90s, `/api/mail-attachments` 150s (`proxy_read_timeout`).
|
||||
- 시간 제한: `/api/messages`·`/api/drafts`·`/api/message-deletions` 90s, `/api/mail-attachments` 150s, 그 밖의 `/api` 는 nginx 기본 60s (`proxy_read_timeout`).
|
||||
- 캐시: `index.html`(직접 요청·`/`·SPA 폴백 모두)은 `Cache-Control: no-cache` — 배포 뒤 옛 index.html 이 지워진 해시 자산을 부르지 않게.
|
||||
`/assets/`(Vite 산출물 — 파일명에 내용 해시)만 `max-age=30일, public, immutable` 이다.
|
||||
해시가 없는 공개 파일(`frontend/public` → 루트, 예: `favicon.ico`)은 같은 이름으로 바뀌므로 `no-cache`(매번 재검증, 안 바뀌면 304).
|
||||
보안 헤더는 어느 경우에도 붙는다(`add_header_inherit merge`).
|
||||
- 헤더: `X-Forwarded-For` 는 이어 붙이고, `X-Request-Id` 는 nginx 의 `$request_id` 로 덮어써 백엔드 traceId 와 nginx 로그 `rid=` 가 같다.
|
||||
- 로그: `/api` 의 접근 로그는 쿼리 없이 남긴다(`log_format api_rid` — 검색어 `q` 등이 요청마다 남지 않게, 오류용 이름 있는 location 에도 다시 지정).
|
||||
오류 로그(413·502·504 등)에는 nginx 가 요청 줄을 쿼리째 남긴다 — 장애 원인 줄을 잃지 않으려고 그대로 둔다. 헤더가 너무 커서 location 을 고르기 전에
|
||||
거부된 요청도 화면용 형식(쿼리 포함)으로 남는다(드문 경우).
|
||||
- 로그: 접근 로그는 화면·`/api` 모두 쿼리 없이 남긴다 — 검색어 `q` 가 `/api` 주소뿐 아니라 화면 주소(`/mail/search?q=…`)에도 들어간다 (계약 2절 "검색").
|
||||
`log_format main_rid` 하나를 server 단계에만 걸어, 오류용 이름 있는 location 과 location 을 고르기 전에 거부된 요청(헤더가 너무 큰 400)도 같은 형식이다.
|
||||
요청 줄은 쿼리만 뺀 원래 요청 줄(SPA 폴백 뒤에도 `/index.html` 이 아니라 부른 화면 경로)이고, Referer 도 `?` 앞까지만 남긴다.
|
||||
화면 응답은 `Referrer-Policy: strict-origin` 이라 브라우저가 같은 주소의 요청에도 origin 만 싣는다 — 화면 주소의 검색어·`?draft=` id 가
|
||||
Referer 로 오류 로그·외부 프록시에 가지 않게. 접근 로그의 Referer 자르기는 그대로 둔다(배포 전에 연 탭·브라우저가 아닌 클라이언트는 주소째 보낼 수 있다).
|
||||
Referer 를 읽는 코드는 없다 — 출처 검사(`OriginGuard`)는 `Sec-Fetch-Site`·`Origin` 을 본다.
|
||||
새 메일 확인 `GET /api/mail-state`(탭마다 1분에 한 번)는 성공(2xx·304)하면 남기지 않는다 — 로깅 규칙 "반복되는 정상 흐름".
|
||||
실패(401·502 등)는 남긴다 (server 단계 `access_log … if=$log_access` 하나 — `/api/` 블록을 나누지 않는다).
|
||||
오류 로그(413·502·504 등)에는 nginx 가 요청 줄·upstream·referrer 를 쿼리째 남긴다 — 장애 원인 줄을 잃지 않으려고 그대로 둔다.
|
||||
`Origin`·`Sec-Fetch-Site`·`Idempotency-Key`·`Cookie` 는 그대로 넘긴다. `/api` 응답에는 화면용 보안 헤더를 붙이지 않는다(백엔드 helmet 이 정한다).
|
||||
- 백엔드 주소는 요청마다 도커 DNS 로 다시 풀어(`resolver`), 백엔드를 다시 만들어도 nginx 를 재시작할 필요가 없다.
|
||||
- **nginx 가 직접 끝내는 `/api` 오류도 표준 실패 형식(JSON)이다** — 본문 한도 초과 413 `FILE_002`, 백엔드 응답 없음 502·시간 초과 504
|
||||
@@ -112,6 +118,9 @@ sudo docker compose --env-file .env.production logs -f backend
|
||||
사용자에게는 `FILE_002` 로 보인다 (프론트 nginx 는 자동으로 맞지만 외부 프록시는 직접 맞춘다).
|
||||
- **IPv6 를 받는지 확인한다** (운영 확인 항목). 백엔드는 IPv6 클라이언트를 /64 단위로 묶어 센다 — 외부 프록시가 IPv6 를 받는다면
|
||||
`X-Forwarded-For` 에 원래 IPv6 주소가 그대로 들어가는지 본다.
|
||||
- **접근 로그에 쿼리를 남기지 않는다** (운영 확인 항목). 화면 주소(`/mail/search?q=…` · `?draft=<id>`)와 API 주소(`/api/messages?q=…` ·
|
||||
`/api/recipient-suggestions?q=…`)에 검색어 · 임시 저장 id 가 들어간다 — 프론트 nginx 와 같은 기준([[logging]]).
|
||||
화면의 `Referrer-Policy: strict-origin` 덕분에 Referer 로는 새지 않지만, 새로고침 · 직접 들어온 요청 줄은 외부 프록시가 그대로 받는다.
|
||||
- 업로드 중 임시 디스크: 백엔드는 첨부를 `/tmp/arasystem-mail-uploads`(컨테이너 쓰기 계층)에, nginx 는 요청 본문을 자기 임시 폴더에 받는다.
|
||||
필요한 공간은 동시 업로드 수 × 20MB 정도. backend 를 `read_only` 로 바꾸면 그 경로에 **tmpfs 가 아닌 볼륨**을 붙인다 (tmpfs 는 메모리 한도에 잡힌다).
|
||||
- **업로드 파일은 named volume `<PROJECT_NAME>_uploads` 에 쌓인다** (`/api/files`, 메일 서명 그림은 그 안의 `mail-signatures/` — 서명 표와 함께 백업한다). 호스트 디렉터리(`./data/uploads`)를 쓰지 않는 이유 —
|
||||
|
||||
@@ -29,7 +29,7 @@ description: NestJS 백엔드(backend/) 개발 규칙 — 컨트롤러/서비스
|
||||
| **인증 가드** | `src/common/guards/jwt-auth.guard.ts` | `JwtAuthGuard` — 액세스 토큰 쿠키 검증, 실패는 `AUTH_001`. 전략은 `modules/auth/jwt.strategy.ts` |
|
||||
| 현재 사용자 데코레이터 | `src/common/decorators/current-user.decorator.ts` | `@CurrentUser()` → `AuthenticatedUser { email, name }` |
|
||||
| 직원 인증 | `modules/auth` | 쿠키 JWT 로그인·`me`·`refresh`·`logout` (계약 `docs/mail/api-contract.md` 1절). 설정 `auth.settings.ts`, 쿠키 `auth-cookies.ts`, 로그인 실패 횟수 `login-attempt.service.ts`, **로그인 세션(DB)** `auth-session.entity.ts`·`auth-session.policy.ts`(1회용 회전·재사용 감지)·`auth-session.cleanup.ts`(끝난 세션 정리 cron). **메일 엔진 관리자 차단** — 정적 목록(`employee-address.ts` + `MAIL_BLOCKED_ACCOUNTS`)과 로그인·리프레시 때 엔진 권한 확인(`jmap/jmap.client.ts`) 두 겹 |
|
||||
| 메일 (웹메일 API) | `modules/mail` | 엔진 포트 `mail.provider.ts`(`MAIL_PROVIDER`) + Stalwart JMAP 어댑터 `jmap/`, 받은 메일 HTML 정화 `html/sanitize-mail-html.ts`, 보내는 서식 본문 정화(허용 목록) `html/sanitize-outgoing-html.ts`, 두 정화 앞의 사전 검사(길이·파서 스택 비용, 선형 시간) `html/html-parse-budget.ts`, 첨부 형식 `attachment.policy.ts`, 설정 `mail.settings.ts`, 발송 중복 방지(`Idempotency-Key`) `send-idempotency.ts`, 첨부 임시 디스크 저장 `mail-upload-storage.ts`, 임시 저장 `draft.controller.ts`(엔진 초안 — 새로 만들고 다시 읽은 뒤 옛것을 지운다), 서명 그림 주소 규칙 `html/signature-images.ts`(보낼 때 `cid:` 로), 본문 그림 판정 `html/inline-images.ts`, 받는 사람 추천 `recipient-suggestions.service.ts`(사용자별 10분 캐시) · 순서 `recipient-ranking.ts` · 커서 `recipient-suggestion-cursor.ts` (계약 2절) |
|
||||
| 메일 (웹메일 API) | `modules/mail` | 엔진 포트 `mail.provider.ts`(`MAIL_PROVIDER`) + Stalwart JMAP 어댑터 `jmap/`, 받은 메일 HTML 정화 `html/sanitize-mail-html.ts`, 보내는 서식 본문 정화(허용 목록) `html/sanitize-outgoing-html.ts`, 두 정화 앞의 사전 검사(길이·파서 스택 비용, 선형 시간) `html/html-parse-budget.ts`, 첨부 형식 `attachment.policy.ts`, 설정 `mail.settings.ts`, 발송 중복 방지(`Idempotency-Key`) `send-idempotency.ts`, 첨부 임시 디스크 저장 `mail-upload-storage.ts`, 임시 저장 `draft.controller.ts`(엔진 초안 — 새로 만들고 다시 읽은 뒤 옛것을 지운다), 서명 그림 주소 규칙 `html/signature-images.ts`(보낼 때 `cid:` 로), 본문 그림 판정 `html/inline-images.ts`, 받는 사람 추천 `recipient-suggestions.service.ts`(사용자별 10분 캐시) · 순서 `recipient-ranking.ts` · 커서 `recipient-suggestion-cursor.ts`, 검색어 → 엔진 조건 `jmap/search-terms.ts`(한국어 조사 표는 여기 한 곳 — 낱말 안 OR · 낱말끼리 AND, 따옴표 구절), 여러 통 `PATCH /api/messages`(엔진 요청 한 번) · 휴지통에서 영구 삭제 `message-deletion.controller.ts`(휴지통의 메일만, 조건부 지우기) — 본문 `dto/bulk-messages.dto.ts`, 새 메일 확인 `GET /api/mail-state`(`mailbox.controller.ts`) (계약 2절) |
|
||||
| 메일 서명 | `modules/mail-signature` | 사용자마다 서명 하나(DB) + 서명 그림. 그림 형식은 내용으로 판별(매직 바이트·머리의 가로·세로, 새 의존성 없음) `signature-image-format.ts`, 파일은 업로드 볼륨의 `mail-signatures/` (`signature-image-storage.ts`), 서명에 붙지 않은 그림 정리 cron `mail-signature.cleanup.ts`. 메일 모듈이 발송·임시 저장 때 `MailSignatureService` 로 그림을 쓴다 (계약 2절 "서명") |
|
||||
| 사내 목록 | `modules/mail-directory` | 웹메일 로그인에 성공한 직원(관리자·게이트웨이·막은 계정 제외)을 `AuthService` 가 기록 → 받는 사람 추천의 "사내". 엔진의 계정 목록(Principal) 권한은 쓰지 않는다(일부러 막아 두었다) |
|
||||
| Rate Limiting | `app.module.ts` 의 `ClientIpThrottlerGuard`(APP_GUARD, `common/guards/client-ip-throttler.guard.ts`) | 초과 시 429 → `REQ_001`. 루트 `.env` 의 `THROTTLE_*` 로 조정. `ThrottlerGuard` 를 상속해 tracker 만 바꿨다 — IP 는 `shared/utils/client-ip.ts` 의 `clientIpKey()`(IPv6 는 /64 로 묶음, 로그인 실패 카운터와 같은 함수) |
|
||||
@@ -42,7 +42,7 @@ description: NestJS 백엔드(backend/) 개발 규칙 — 컨트롤러/서비스
|
||||
| 파일 업로드 | `modules/upload` | **로그인 필요**(`JwtAuthGuard`). 로컬 디스크 저장 + 형식·크기 검증 + 안전한 파일명 생성 ([[security]]). 한글 파일명은 multer `defParamCharset: 'utf8'` |
|
||||
| 헬스체크 | `modules/health` | `@SkipThrottle`+`@SkipTransform` — 기계가 읽는 엔드포인트 패턴 ([[response-format]]) |
|
||||
| 요청 컨텍스트 | `shared/logger/request-context.ts` | 요청마다 추적 ID 를 AsyncLocalStorage 에 둔다 — 필터를 거치지 않는 로그도 `traceTag()` 로 `[traceId]` 를 붙인다 ([[logging]]) |
|
||||
| 순수 유틸 | `shared/utils/` | 순수 함수 분리 위치 (예: `mask.ts` 로그용 이메일 마스킹·URL 의 검색어 가리기(`maskUrlForLog` — `q` 는 길이만), `settle.ts` 정해진 시간까지만 기다리기, `duration.ts` 기간 파싱, `content-disposition.ts` 다운로드 헤더, `cookies.ts`, `trust-proxy.ts`, `request-origin.ts`) |
|
||||
| 순수 유틸 | `shared/utils/` | 순수 함수 분리 위치 (예: `mask.ts` 로그용 이메일 마스킹·URL 의 검색어 가리기(`maskUrlForLog` — `q` 와 `cursor`(검색어 해시를 담는다)는 길이만), `settle.ts` 정해진 시간까지만 기다리기, `duration.ts` 기간 파싱, `content-disposition.ts` 다운로드 헤더, `cookies.ts`, `trust-proxy.ts`, `request-origin.ts`) |
|
||||
|
||||
> **확장 시 따르는 규칙** (기능이 필요해지면 규칙대로 구현):
|
||||
> - **보호 라우트**: 인증은 구현되어 있다 (`modules/auth` + `common/guards/jwt-auth.guard.ts`). 새 보호 라우트는 컨트롤러·핸들러에
|
||||
|
||||
@@ -32,12 +32,12 @@ description: Vue 3 프론트엔드(frontend/) 개발 규칙 — Composition API
|
||||
| 전역 셸 | `src/components/shell/AppShell.vue` | 앱 레일 + 상단 바 + 모듈 영역. 레일 모듈 목록은 `src/shared/constants/app-modules.ts` 하나 |
|
||||
| 디자인 토큰·글꼴 | `src/styles/tokens.css` · `src/styles/base.css` | 색·간격·글자 크기 변수. 글꼴은 npm `pretendard` 자체 호스팅(`main.ts` 에서 import — CSP `font-src 'self'`) |
|
||||
| 브랜드 자산 | `src/assets/brand/` · `src/shared/constants/brand.ts` | 흰 로고(전체·ARA 마크). 원본은 `docs/brand/` |
|
||||
| 토스트 | `src/stores/toast.store.ts` · `src/components/feedback/ToastHost.vue` | 오류는 `useApi.ts` 의 `showErrorUI()`, 성공 안내는 `composables/useToast.ts`. 알림 안의 단추는 `{ label, run }`(예: 임시 저장본 [되돌리기]), 이동 링크는 지금처럼. 단추가 있는 알림은 10초(`TOAST_ACTION_DURATION_MS`), 마우스 · 초점이 있는 동안은 `hold`·`release` 로 멈춘다 |
|
||||
| 토스트 | `src/stores/toast.store.ts` · `src/components/feedback/ToastHost.vue` | 오류는 `useApi.ts` 의 `showErrorUI()`, 성공 안내는 `composables/useToast.ts`. 알림 안의 단추는 `{ label, run }`(예: 임시 저장본 [되돌리기]), 이동 링크는 지금처럼. 단추가 있는 알림은 10초(`TOAST_ACTION_DURATION_MS`), 마우스 · 초점이 있는 동안은 `hold`·`release` 로 멈춘다. **단추는 띄운 세션에서만 움직인다** — `useToast` 가 `authStore.generation` 에 묶고, 로그아웃 · 세션 만료 · 다른 계정 로그인 때 단추가 있는 알림을 닫는다(메일 id 는 계정마다 겹칠 수 있다) |
|
||||
| 모달 대화상자 | `src/components/feedback/ModalDialog.vue` | 네이티브 `<dialog>`(초점·Esc·닫힘 처리)와 모양. 세션 만료(`SessionExpiredDialog`)·나가기 확인(공용 `components/feedback/LeaveConfirmDialog.vue` — 쓰기 화면 `ComposeLeaveDialog.vue`·서명 설정이 쓴다)이 쓴다 — 새 대화상자도 이것으로 만든다 |
|
||||
| 외부 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` (주석에 "밀도 예외") | 클릭 영역 44px 기준의 의도된 예외(목록 표의 별·보기 전환, 설정 화면의 미리 보기 전환 36(`.segmented`)·서명 그림 크기 줄 36(작게·보통·원본·그림 빼기), 편집기 툴바 버튼 40×44·메뉴 항목 40·색 견본 40·표 고르기 칸 22)에만 쓴다 — 사용자 확정(캔버스). 그 밖의 `--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` (주석에 "밀도 예외") | 클릭 영역 44px 기준의 의도된 예외(목록 표의 별·보기 전환, 설정 화면의 미리 보기 전환 36(`.segmented`)·서명 그림 크기 줄 36(작게·보통·원본·그림 빼기), 편집기 툴바 버튼 40×44·메뉴 항목 40·색 견본 40·표 고르기 칸 22, 3단계의 목록 표 고르기 칸 32·분할 행 고르기 칸 폭 36·검색 칸 ✕ 40×38·새 메일 알림 줄 40·검색 범위 전환 36·이동 메뉴 항목 40)에만 쓴다 — 사용자 확정(캔버스). 그 밖의 `--size-editor-*`(툴바 높이·글꼴 단추·본문 최소 높이 등)는 예외가 아니라 보통 크기다 |
|
||||
| 사용자별 화면 설정 | `src/stores/mail-view.store.ts` | localStorage(키에 사용자 email, try/catch, 실패 시 기본값). 서버 설정 API 가 생기면 교체 |
|
||||
| 화면 이동 규칙 | `src/composables/useMailNavigation.ts` | 이전/다음은 replace, "목록으로"는 뒤로 가기 또는 replace (기록이 쌓이지 않게) |
|
||||
| 발송 키 규칙 | `src/shared/utils/idempotency-key.ts` 의 `shouldRenewSendKey()` | `Idempotency-Key` 는 성공과 `MAIL_005` 때만 바꾼다 — 그 밖의 실패 뒤 다시 보내기는 같은 키 (계약 2절 "발송 본문") |
|
||||
@@ -46,6 +46,10 @@ description: Vue 3 프론트엔드(frontend/) 개발 규칙 — Composition API
|
||||
| 임시 저장 | `src/composables/useDraftSaving.ts` · `components/mail/ComposeDraftStatus.vue` · `composables/useComposeDraftLink.ts`(주소 맞추기 · 옛 id 기록 · 보내지 못한 메일을 저장본으로) · `composables/useDraftTrash.ts`(휴지통 · 되돌리기) | 3초 멈춤 · 쉬지 않고 쓰면 30초 · 10초 간격 · 한 번에 하나(계약 2절 "임시 저장", 상수 `shared/constants/mail.ts` 의 `DRAFT_*`). `MAIL_008`·PUT 404 는 막지 않고 POST 로 새로. 찾지 못한 첨부가 남았거나 본문이 한도를 넘는 동안은 저장하지 않고, 실을 수 없는 첨부가 든 남의 저장본은 첫 저장을 POST 로(원본 보존). 보내기·버리기 전에는 `suspend`+`settle` — 보내기는 settle 뒤 첨부 상태를 다시 본다. 저장할 때마다 주소를 `?draft=<새 id>` 로 맞추고(떠나기 전 저장 제외), 옛 id 로 돌아오면 `mail.store` 의 `latestDraftId()` 로 마지막 저장본을 연다. 쓰기 화면 휴지통은 DELETE 가 아니라 PATCH(휴지통으로 이동) — 이미 휴지통인 메일은 DELETE 가 영구 삭제한다 |
|
||||
| 받는 사람 자동 완성 | `src/composables/useRecipientSuggestions.ts` · `shared/utils/recipient-match.ts` · `components/mail/RecipientField.vue` · `RecipientSuggestionList.vue` | 검색어는 input · compositionupdate 의 `target.value`(한글 조합 중 글자 포함)로 따로 두고, 칩 확정은 v-model. Enter 등의 조합 판정은 `shared/utils/keyboard-shortcut.ts` 의 `isImeComposing()`(`isComposing` 또는 keyCode 229 — Safari · macOS) — 새 입력 칸의 Enter 처리도 이것을 쓴다. 쓰기 화면을 열 때 `limit=100` 한 번, 모자랄 때만 250ms 뒤 `q` 로(초성·자모만 친 글자는 묻지 않는다, 늦은 응답은 버린다). 초성 검색·맞은 글자 강조는 `recipient-match.ts`(강조는 `<strong>` 조각 — v-html 없음). combobox 접근성(aria-activedescendant · listbox · 조합 중 Enter 무시), 완전한 주소면 미리 고르지 않는다 |
|
||||
| 메일 서명 | `src/stores/mail-signature.store.ts` · `components/mail/editor/signature/` · `shared/utils/mail-signature.ts` · `pages/settings/` | 서명은 메일 화면에 들어올 때 받아 두고 쓰기 화면이 넣는다(새 메일은 끝, 답장·전달은 인용 위 — 이어 쓰기는 다시 넣지 않는다). 설정 화면(`SettingsLayout.vue` 왼쪽 메뉴 + `MailSignaturePage.vue`)은 들어올 때마다 새로 받는다. 미리 보기는 편집기 스키마의 `DOMSerializer` 로 그린다. 그림 3장은 서명 칸 안의 그림 수로(서버 보관 상한 10장은 서버가 센다) |
|
||||
| 메일 스토어 구성 | `src/stores/mail.store.ts`(조립 · 공개 API) + `src/stores/mail/` — `mailboxes.ts` · `list.ts` · `selection.ts` · `message.ts` · `bulk.ts` | Pinia 스토어는 `useMailStore` 하나이고 기능별 setup 모듈을 조립한다(스토어를 여러 개로 나누지 않는다 — 서로 부르는 고리가 생긴다). 목록 출처는 `shared/utils/mail-list-source.ts`(메일함 · 검색). **늦게 온 응답 버리기**: 목록 맞추기는 이 탭의 목록 변경 세대(`localGeneration` — 읽음 · 중요 · 빼기 · 옮기기마다 오른다)가 그사이 바뀌었으면 응답을 버리고 한 번 더 맞춘다. 여러 통 처리 · 한 통 삭제는 시작할 때의 출처 순번(`sourceSeq`)과 다르면 목록을 건드리지 않고 개수만 맞춘다 |
|
||||
| 새 메일 확인 · 탭 제목 | `src/composables/useMailStatePolling.ts`(메일 화면 `MailLayout` 이 시작) · `useListArrivalNotice.ts` + `components/mail/MailNewNotice.vue` · `useMailTabTitle.ts` + `shared/utils/document-title.ts` | 화면이 보이는 동안 60초마다 `GET /mail-state`, 탭으로 돌아오면 10초 지났을 때 바로, 한 번에 하나, 실패는 `silent`. 바뀌면 `syncCounts()` + 보이는 목록 맞추기(불러온 만큼 · 최대 100통, 검색 결과는 건너뜀). 목록을 내려 본 채면 "새 메일 N통 · 맨 위로" 줄. 받은편지함 안 읽음이 있으면 탭 제목 앞 `(N) ` (메일 라우트에서만) — 계약 2절 "새 메일 확인" |
|
||||
| 메일 검색 | `src/components/mail/MailSearchBox.vue`(상단 바의 search 슬롯) · `composables/useMailSearchNavigation.ts` · `MailSearchHeader.vue` · `MailSearchScope.vue` · `MailSearchEmpty.vue` · 라우트 `/mail/search` · `/mail/search/:messageId` | Enter 로만 찾는다(`isImeComposing()` 이면 무시), ✕ · Esc 는 검색을 시작한 메일함으로. 주소 `?q=…&in=<메일함 id \| all>` — 검색 화면 안의 다시 찾기 · 범위 바꾸기는 replace. 서버와 같은 한도(200자 · 낱말 8개)를 먼저 본다. 조사 처리 · 한계는 서버 몫(계약 2절 "검색") |
|
||||
| 여러 통 처리 · 삭제 | `src/components/mail/MailCheckbox.vue` · `MailSelectionToolbar.vue`(+ `MailBulkButton` · `MailBulkMoveButton`) · `MailPurgeDialog.vue` · `MessageDeleteButton.vue` · `composables/useBulkMailActions.ts` · `useMessageRemoval.ts` · `useMailMoveFeedback.ts` · `shared/utils/mail-selection.ts` · `mail-bulk.ts` | 100통씩 차례로. 읽음 · 중요는 먼저 바꾸고 `failedIds` 만 되돌리고, 이동 · 삭제는 응답의 id 만 뺀다. **웹은 `DELETE /api/messages/{id}` 를 쓰지 않는다** — 휴지통 밖 삭제는 `PATCH` 이동(+ 되돌리기), 휴지통에서는 확인 대화상자(처음 초점 「취소」) 뒤 `POST /message-deletions`. 읽기 화면도 그 메일의 `mailboxIds` 로 같은 규칙을 탄다. 도구 줄은 role="toolbar" 로빙 tabindex(바쁜 · 꺼진 단추는 건너뛴다) — 계약 2절 "여러 통 한꺼번에" |
|
||||
| 시계 주입 | `src/composables/useClock.ts` · `src/test-utils/manual-clock.ts` | 타이머를 쓰는 로직(자동 저장 등)은 시계를 주입받는다 — 테스트는 손으로 돌리는 시계로 시간을 넘긴다(고정 대기 없이, [[testing]]) |
|
||||
| 나가기 전 확인 | `src/composables/useLeaveConfirm.ts` · `src/shared/utils/compose-changes.ts` | 화면을 연 뒤(또는 마지막으로 저장한 뒤) 바꾼 것이 있으면 앱 안 이동(라우트 떠남·바뀜)은 확인 대화상자, 탭 닫기·새로고침은 `beforeunload`. 쓰기 화면은 떠나기 전에 먼저 저장하고 저장하지 못한 것이 있을 때만 묻는다(서명 설정은 저장하지 않은 서명이 있으면 묻는다). 가드가 도는 중은 `guarding`. 바꿈 여부는 연 직후(저장한 뒤에는 마지막으로 저장한 판)의 스냅숏과 비교한다(편집기는 `isChanged()`). 떠나기 전 저장은 5초까지만 기다린다. 보낸 뒤·보내는 중·세션이 끝났을 때는 묻지 않는다 — 보내는 중에 떠나면 결과는 토스트로만 알리고 떠난 양식·새 양식을 건드리지 않는다(양식 세대로 늦게 온 결과를 버린다, 디자인 README "나가기 전 확인") |
|
||||
| 읽기 화면 본문 안내 | `src/shared/utils/mail-body-notices.ts` · `shared/constants/mail.ts` 의 `MAIL_BODY_NOTICES` | 서버의 `isHtmlBodyOmitted`·`isBodyTruncated`(계약 2절 "읽기 한도")를 외부 이미지 차단과 같은 안내 줄로 보인다(`.reader__notice[data-notice]`) |
|
||||
|
||||
@@ -156,19 +156,19 @@ describe('HttpExceptionFilter', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('로그의 요청 주소에서 검색어(q)는 길이만 남긴다 — 나머지 쿼리는 그대로', () => {
|
||||
it('로그의 요청 주소에서 검색어(q)와 커서(cursor — 검색어 해시를 담는다)는 길이만 남긴다 — 나머지 쿼리는 그대로', () => {
|
||||
const warnLog = jest.spyOn(Logger.prototype, 'warn');
|
||||
warnLog.mockClear();
|
||||
|
||||
runFilter(
|
||||
new BusinessException('VAL_001'),
|
||||
'trace-q',
|
||||
'/api/recipient-suggestions?q=%EB%8F%84%EC%9C%A4&cursor=bad',
|
||||
'/api/recipient-suggestions?q=%EB%8F%84%EC%9C%A4&limit=20&cursor=bad',
|
||||
);
|
||||
|
||||
const logged = warnLog.mock.calls.map(([message]) => String(message));
|
||||
expect(logged).toEqual([
|
||||
'[VAL_001][trace-q] GET /api/recipient-suggestions?q=<2자>&cursor=bad',
|
||||
'[VAL_001][trace-q] GET /api/recipient-suggestions?q=<2자>&limit=20&cursor=<3자>',
|
||||
]);
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
import { plainToInstance } from 'class-transformer';
|
||||
import { validate } from 'class-validator';
|
||||
|
||||
import { MAX_BULK_MESSAGE_IDS } from '@/modules/mail/mail.constants';
|
||||
import { MessageDeletionDto, UpdateMessagesDto } from './bulk-messages.dto';
|
||||
|
||||
/** 전역 ValidationPipe 와 같은 옵션으로 검증한다 (app.setup.ts) */
|
||||
async function constraintsOf(
|
||||
type: typeof UpdateMessagesDto | typeof MessageDeletionDto,
|
||||
body: object,
|
||||
): Promise<string[]> {
|
||||
const errors = await validate(plainToInstance(type, body), {
|
||||
whitelist: true,
|
||||
forbidNonWhitelisted: true,
|
||||
});
|
||||
return errors.flatMap((error) => Object.keys(error.constraints ?? {}));
|
||||
}
|
||||
|
||||
const ids = (count: number) =>
|
||||
Array.from({ length: count }, (_, index) => `m${index}`);
|
||||
|
||||
describe('여러 통 요청 본문 (PATCH /api/messages · POST /api/message-deletions)', () => {
|
||||
it.each([
|
||||
['PATCH', UpdateMessagesDto, { isRead: true }],
|
||||
['영구 삭제', MessageDeletionDto, {}],
|
||||
] as const)(
|
||||
`%s — ids 는 1~${MAX_BULK_MESSAGE_IDS}개, 같은 id 두 번 · 형식이 틀린 id 는 형식 오류`,
|
||||
async (_name, type, changes) => {
|
||||
expect(await constraintsOf(type, { ids: ['m1'], ...changes })).toEqual(
|
||||
[],
|
||||
);
|
||||
expect(
|
||||
await constraintsOf(type, {
|
||||
ids: ids(MAX_BULK_MESSAGE_IDS),
|
||||
...changes,
|
||||
}),
|
||||
).toEqual([]);
|
||||
|
||||
expect(await constraintsOf(type, { ids: [], ...changes })).toEqual([
|
||||
'arrayMinSize',
|
||||
]);
|
||||
expect(
|
||||
await constraintsOf(type, {
|
||||
ids: ids(MAX_BULK_MESSAGE_IDS + 1),
|
||||
...changes,
|
||||
}),
|
||||
).toEqual(['arrayMaxSize']);
|
||||
expect(
|
||||
await constraintsOf(type, { ids: ['m1', 'm1'], ...changes }),
|
||||
).toEqual(['arrayUnique']);
|
||||
for (const bad of ['../m1', '', 'm 1', 1, null]) {
|
||||
expect(
|
||||
await constraintsOf(type, { ids: ['m1', bad], ...changes }),
|
||||
).toEqual(['matches']);
|
||||
}
|
||||
expect(await constraintsOf(type, { ...changes })).toContain('isArray');
|
||||
expect(await constraintsOf(type, { ids: 'm1', ...changes })).toContain(
|
||||
'isArray',
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
it('PATCH 의 바꿀 것은 한 통 PATCH 와 같은 규칙이다 — null 은 형식 오류, 모르는 필드는 거부', async () => {
|
||||
expect(
|
||||
await constraintsOf(UpdateMessagesDto, {
|
||||
ids: ['m1'],
|
||||
isRead: false,
|
||||
isFlagged: true,
|
||||
mailboxId: 'b',
|
||||
}),
|
||||
).toEqual([]);
|
||||
expect(
|
||||
await constraintsOf(UpdateMessagesDto, { ids: ['m1'], isRead: null }),
|
||||
).toEqual(['isBoolean']);
|
||||
expect(
|
||||
await constraintsOf(UpdateMessagesDto, {
|
||||
ids: ['m1'],
|
||||
mailboxId: '../b',
|
||||
}),
|
||||
).toEqual(['matches']);
|
||||
expect(
|
||||
await constraintsOf(MessageDeletionDto, { ids: ['m1'], isRead: true }),
|
||||
).toEqual(['whitelistValidation']);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,58 @@
|
||||
import { applyDecorators } from '@nestjs/common';
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import {
|
||||
ArrayMaxSize,
|
||||
ArrayMinSize,
|
||||
ArrayUnique,
|
||||
IsArray,
|
||||
Matches,
|
||||
} from 'class-validator';
|
||||
|
||||
import {
|
||||
MAIL_ID_PATTERN,
|
||||
MAX_BULK_MESSAGE_IDS,
|
||||
} from '@/modules/mail/mail.constants';
|
||||
import { UpdateMessageDto } from './update-message.dto';
|
||||
|
||||
/**
|
||||
* 여러 통을 고르는 id 목록 — 1~MAX_BULK_MESSAGE_IDS 개, 같은 id 두 번 금지, 형식이 틀리면 400 VAL_001
|
||||
* (계약 2절 "여러 통 한꺼번에"). 두 요청 본문이 같은 규칙을 쓴다.
|
||||
*/
|
||||
function MessageIdList(description: string) {
|
||||
return applyDecorators(
|
||||
ApiProperty({
|
||||
description: `${description} (1~${MAX_BULK_MESSAGE_IDS}개, 같은 id 를 두 번 넣지 않는다)`,
|
||||
type: [String],
|
||||
minItems: 1,
|
||||
maxItems: MAX_BULK_MESSAGE_IDS,
|
||||
uniqueItems: true,
|
||||
example: ['eaaaaab', 'eaaaaac'],
|
||||
}),
|
||||
IsArray({ message: '메일 목록 형식이 올바르지 않습니다.' }),
|
||||
ArrayMinSize(1, { message: '메일을 1통 이상 선택해 주세요.' }),
|
||||
ArrayMaxSize(MAX_BULK_MESSAGE_IDS, {
|
||||
message: `메일은 한 번에 ${MAX_BULK_MESSAGE_IDS}통까지 선택할 수 있습니다.`,
|
||||
}),
|
||||
ArrayUnique({ message: '같은 메일을 두 번 선택했습니다.' }),
|
||||
Matches(MAIL_ID_PATTERN, {
|
||||
each: true,
|
||||
message: '메일을 다시 선택해 주세요.',
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* PATCH /api/messages — 여러 통의 읽음 · 중요 표시 · 이동 (바꿀 것은 한 통 PATCH 와 같고, 하나 이상 — 없으면 서비스가 VAL_001)
|
||||
*/
|
||||
export class UpdateMessagesDto extends UpdateMessageDto {
|
||||
@MessageIdList('바꿀 메일 id')
|
||||
ids!: string[];
|
||||
}
|
||||
|
||||
/** POST /api/message-deletions — 휴지통에서 영구 삭제할 메일 */
|
||||
export class MessageDeletionDto {
|
||||
@MessageIdList(
|
||||
'휴지통에서 영구 삭제할 메일 id — 휴지통에 없는 메일은 건너뛴다',
|
||||
)
|
||||
ids!: string[];
|
||||
}
|
||||
@@ -9,8 +9,11 @@ import type {
|
||||
Mailbox,
|
||||
MailboxRole,
|
||||
MailQuota,
|
||||
MailState,
|
||||
MessageAttachment,
|
||||
MessageDeletionResult,
|
||||
MessageDetail,
|
||||
MessagesPatchResult,
|
||||
MessageSummary,
|
||||
RecipientSuggestion,
|
||||
SavedDraft,
|
||||
@@ -314,6 +317,52 @@ export class SentMessageDto {
|
||||
id!: string;
|
||||
}
|
||||
|
||||
/** 계약 2절 "새 메일 확인"의 MailState */
|
||||
export class MailStateDto implements MailState {
|
||||
@ApiProperty({
|
||||
description:
|
||||
'불투명 값 — 메일함이나 메일이 하나라도 바뀌면 달라진다. 해석하지 말고 지난 값과 같은지만 본다',
|
||||
example: 'c2Iy.c2Fj',
|
||||
})
|
||||
state!: string;
|
||||
}
|
||||
|
||||
/** 계약 2절 "여러 통 한꺼번에"의 MessagesPatched */
|
||||
export class MessagesPatchedDto implements MessagesPatchResult {
|
||||
@ApiProperty({
|
||||
description: '바꾼 메일 id (요청 순서)',
|
||||
type: [String],
|
||||
example: ['eaaaaab'],
|
||||
})
|
||||
updatedIds!: string[];
|
||||
|
||||
@ApiProperty({
|
||||
description:
|
||||
'바꾸지 못한 메일 id — 없음 · 그사이 지워짐 · 엔진이 거절함. 나머지는 그대로 처리했다',
|
||||
type: [String],
|
||||
example: ['eaaaaac'],
|
||||
})
|
||||
failedIds!: string[];
|
||||
}
|
||||
|
||||
/** 계약 2절 "여러 통 한꺼번에"의 MessageDeletionResult */
|
||||
export class MessageDeletionResultDto implements MessageDeletionResult {
|
||||
@ApiProperty({
|
||||
description:
|
||||
'지운 메일 id (요청 순서) — 영구 삭제했다(휴지통에만 있었다) · 휴지통에서만 뺐다(다른 메일함에도 있었다)',
|
||||
type: [String],
|
||||
example: ['eaaaaab'],
|
||||
})
|
||||
deletedIds!: string[];
|
||||
|
||||
@ApiProperty({
|
||||
description: '지우지 않은 메일 id — 휴지통에 없음 · 없음 · 엔진이 거절',
|
||||
type: [String],
|
||||
example: ['eaaaaac'],
|
||||
})
|
||||
skippedIds!: string[];
|
||||
}
|
||||
|
||||
/** 계약 2절 "임시 저장"의 Draft — 쓰기 화면이 이어 쓴다 */
|
||||
export class DraftDto implements Draft {
|
||||
@ApiProperty({
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
import { plainToInstance } from 'class-transformer';
|
||||
import { validate } from 'class-validator';
|
||||
|
||||
import {
|
||||
MAX_SEARCH_LENGTH,
|
||||
MAX_SEARCH_WORDS,
|
||||
} from '@/modules/mail/mail.constants';
|
||||
import { MessageListQueryDto } from './message-list-query.dto';
|
||||
|
||||
/** 전역 ValidationPipe 와 같은 옵션으로 변환·검증한다 (app.setup.ts — transform · whitelist · forbidNonWhitelisted) */
|
||||
async function check(
|
||||
query: Record<string, unknown>,
|
||||
): Promise<{ dto: MessageListQueryDto; constraints: string[] }> {
|
||||
const dto = plainToInstance(MessageListQueryDto, query);
|
||||
const errors = await validate(dto, {
|
||||
whitelist: true,
|
||||
forbidNonWhitelisted: true,
|
||||
});
|
||||
return {
|
||||
dto,
|
||||
constraints: errors.flatMap((error) =>
|
||||
Object.keys(error.constraints ?? {}),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
describe('MessageListQueryDto (GET /api/messages)', () => {
|
||||
it('mailboxId 는 선택이다 — 있으면 메일함 id 형식이어야 한다', async () => {
|
||||
expect((await check({})).constraints).toEqual([]);
|
||||
expect((await check({ mailboxId: 'a' })).constraints).toEqual([]);
|
||||
for (const mailboxId of ['', '../a', 'a b']) {
|
||||
expect((await check({ mailboxId })).constraints).toEqual(['matches']);
|
||||
}
|
||||
});
|
||||
|
||||
it('검색어는 앞뒤 공백을 지우고, 공백뿐이면 없는 것으로 본다', async () => {
|
||||
expect((await check({ q: ' 서버 이전 ' })).dto.q).toBe('서버 이전');
|
||||
|
||||
// 전각 공백(U+3000)도 공백이다
|
||||
const blank = await check({ q: ` ${String.fromCharCode(0x3000)} ` });
|
||||
expect(blank.dto.q).toBeUndefined();
|
||||
expect(blank.constraints).toEqual([]);
|
||||
});
|
||||
|
||||
it(`앞뒤 공백을 뺀 ${MAX_SEARCH_LENGTH}자까지 — 길이는 JS 문자열 길이(UTF-16)로 센다`, async () => {
|
||||
const longest = '가'.repeat(MAX_SEARCH_LENGTH);
|
||||
expect((await check({ q: ` ${longest} ` })).constraints).toEqual([]);
|
||||
expect((await check({ q: `${longest}가` })).constraints).toEqual([
|
||||
'maxCodeUnits',
|
||||
]);
|
||||
// 이모지 하나는 2자다 — 웹 클라이언트가 세는 수와 같다
|
||||
expect(
|
||||
(await check({ q: '😀'.repeat(MAX_SEARCH_LENGTH / 2 + 1) })).constraints,
|
||||
).toEqual(['maxCodeUnits']);
|
||||
});
|
||||
|
||||
it(`낱말(띄어쓰기로 나눈 것)은 ${MAX_SEARCH_WORDS}개까지 — 따옴표 안의 낱말도 센다`, async () => {
|
||||
const words = (count: number) =>
|
||||
Array.from({ length: count }, (_, index) => `낱말${index}`).join(' ');
|
||||
|
||||
expect((await check({ q: words(MAX_SEARCH_WORDS) })).constraints).toEqual(
|
||||
[],
|
||||
);
|
||||
expect(
|
||||
(await check({ q: words(MAX_SEARCH_WORDS + 1) })).constraints,
|
||||
).toEqual(['maxSearchWords']);
|
||||
expect(
|
||||
(await check({ q: `"${words(MAX_SEARCH_WORDS + 1)}"` })).constraints,
|
||||
).toEqual(['maxSearchWords']);
|
||||
});
|
||||
|
||||
it('검색어가 문자열이 아니면(같은 이름을 두 번 보냄) 형식 오류', async () => {
|
||||
expect((await check({ q: ['회의', '일정'] })).constraints).toContain(
|
||||
'isString',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,26 +1,101 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { IsOptional, IsString, Matches, MaxLength } from 'class-validator';
|
||||
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { Transform, type TransformFnParams } from 'class-transformer';
|
||||
import {
|
||||
IsOptional,
|
||||
IsString,
|
||||
Matches,
|
||||
ValidateBy,
|
||||
type ValidationOptions,
|
||||
} from 'class-validator';
|
||||
|
||||
import { CursorPaginationQueryDto } from '@/common/dto/pagination.dto';
|
||||
import {
|
||||
MAIL_ID_PATTERN,
|
||||
MAX_SEARCH_LENGTH,
|
||||
MAX_SEARCH_WORDS,
|
||||
SEARCH_WORD_SEPARATOR,
|
||||
} from '@/modules/mail/mail.constants';
|
||||
|
||||
/** GET /api/messages — 커서 페이지네이션 + 메일함 + 검색어 */
|
||||
export class MessageListQueryDto extends CursorPaginationQueryDto {
|
||||
@ApiProperty({ description: '메일함 id (필수)', example: 'a' })
|
||||
@Matches(MAIL_ID_PATTERN, { message: '메일함을 다시 선택해 주세요.' })
|
||||
mailboxId!: string;
|
||||
/**
|
||||
* 검색어 — 앞뒤 공백을 지우고, 공백뿐이면 없는 것으로 본다 (계약 2절 "검색").
|
||||
* 문자열이 아니면(같은 이름을 두 번 보낸 배열 등) 그대로 두어 IsString 이 막는다.
|
||||
*/
|
||||
function normalizeSearchQuery({ value }: TransformFnParams): unknown {
|
||||
const raw: unknown = value;
|
||||
if (typeof raw !== 'string') return raw;
|
||||
const trimmed = raw.trim();
|
||||
return trimmed === '' ? undefined : trimmed;
|
||||
}
|
||||
|
||||
/** 검색어의 낱말 수 — 띄어쓰기(공백 문자)로 나눈 조각 (따옴표와 상관없이 센다) */
|
||||
function countSearchWords(text: string): number {
|
||||
return text.split(SEARCH_WORD_SEPARATOR).filter((word) => word !== '').length;
|
||||
}
|
||||
|
||||
/**
|
||||
* 최대 길이 — JS 문자열 길이(UTF-16 코드 단위)로 센다. 웹 클라이언트가 세는 방법과 같다
|
||||
* (MaxLength(validator.js)는 서로게이트 쌍·이모지 표시 선택자를 덜 센다 — dto/send-message.dto.ts 와 같은 이유).
|
||||
*/
|
||||
function MaxCodeUnits(max: number, options: ValidationOptions) {
|
||||
return ValidateBy(
|
||||
{
|
||||
name: 'maxCodeUnits',
|
||||
constraints: [max],
|
||||
validator: {
|
||||
validate: (value: unknown) =>
|
||||
typeof value === 'string' && value.length <= max,
|
||||
},
|
||||
},
|
||||
options,
|
||||
);
|
||||
}
|
||||
|
||||
/** 최대 낱말 수 */
|
||||
function MaxSearchWords(max: number, options: ValidationOptions) {
|
||||
return ValidateBy(
|
||||
{
|
||||
name: 'maxSearchWords',
|
||||
constraints: [max],
|
||||
validator: {
|
||||
validate: (value: unknown) =>
|
||||
typeof value === 'string' && countSearchWords(value) <= max,
|
||||
},
|
||||
},
|
||||
options,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/messages — 커서 페이지네이션 + 메일함 + 검색어.
|
||||
* mailboxId 와 q 중 하나는 있어야 한다 — 둘 다 없으면 서비스가 400 VAL_001 (mail.service.ts 의 listMessages)
|
||||
*/
|
||||
export class MessageListQueryDto extends CursorPaginationQueryDto {
|
||||
@ApiPropertyOptional({
|
||||
description: `검색어 (보낸 사람·받는 사람·제목·본문, ${MAX_SEARCH_LENGTH}자 이하)`,
|
||||
example: '서버 이전',
|
||||
description:
|
||||
'메일함 id — 없으면 q 가 있어야 하고, 휴지통·스팸메일함을 뺀 모든 메일함에서 찾는다 (mailboxId 와 q 가 둘 다 없으면 400 VAL_001)',
|
||||
example: 'a',
|
||||
})
|
||||
@IsOptional()
|
||||
@Matches(MAIL_ID_PATTERN, { message: '메일함을 다시 선택해 주세요.' })
|
||||
mailboxId?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description:
|
||||
`검색어 — 앞뒤 공백을 뺀 1~${MAX_SEARCH_LENGTH}자(JS 문자열 길이), 낱말(띄어쓰기로 나눈 것) ${MAX_SEARCH_WORDS}개까지. 공백뿐이면 없는 것으로 본다. ` +
|
||||
'보낸 사람·받는 사람·참조·제목·본문에서 찾고, 여러 낱말이면 모두 들어간 메일만. 큰따옴표로 감싼 부분은 그 구절 그대로 찾는다. ' +
|
||||
'한글로 끝나는 낱말은 흔한 조사를 붙인 형태도 함께 찾는다(회의 → 회의를 · 회의에서 …). ' +
|
||||
'낱말 앞뒤의 문장부호는 떼고(회의, → 회의), 문자·숫자가 없는 낱말(- · … 등)은 빼고, 찾을 낱말이 하나도 없으면 빈 목록. 커서는 같은 검색어·범위로만 쓴다',
|
||||
example: '서버 이전',
|
||||
maxLength: MAX_SEARCH_LENGTH,
|
||||
})
|
||||
@Transform(normalizeSearchQuery)
|
||||
@IsOptional()
|
||||
@IsString({ message: '검색어 형식이 올바르지 않습니다.' })
|
||||
@MaxLength(MAX_SEARCH_LENGTH, {
|
||||
@MaxCodeUnits(MAX_SEARCH_LENGTH, {
|
||||
message: `검색어는 ${MAX_SEARCH_LENGTH}자 이하로 입력해 주세요.`,
|
||||
})
|
||||
@MaxSearchWords(MAX_SEARCH_WORDS, {
|
||||
message: `검색어는 낱말 ${MAX_SEARCH_WORDS}개까지 입력해 주세요.`,
|
||||
})
|
||||
q?: string;
|
||||
}
|
||||
|
||||
@@ -42,6 +42,40 @@ describe('메일 목록 커서', () => {
|
||||
).toBe('VAL_001');
|
||||
});
|
||||
|
||||
it('모든 메일함 검색(메일함 없음)과 메일함 검색의 커서는 서로 쓸 수 없다 — 검색어가 다르면 다른 목록이다', () => {
|
||||
const all = { text: '회의' };
|
||||
const cursor = encodeCursor({ anchor: 'eaaaaab', position: 20 }, all);
|
||||
|
||||
expect(decodeCursor(cursor, all)).toEqual({
|
||||
anchor: 'eaaaaab',
|
||||
position: 20,
|
||||
});
|
||||
expect(
|
||||
codeOf(() => decodeCursor(cursor, { mailboxId: 'a', text: '회의' })),
|
||||
).toBe('VAL_001');
|
||||
expect(codeOf(() => decodeCursor(cursor, { text: '일정' }))).toBe(
|
||||
'VAL_001',
|
||||
);
|
||||
// 메일함 커서도 모든 메일함 검색에는 쓸 수 없다
|
||||
const inboxSearch = encodeCursor(
|
||||
{ anchor: 'eaaaaab', position: 20 },
|
||||
{ mailboxId: 'a', text: '회의' },
|
||||
);
|
||||
expect(codeOf(() => decodeCursor(inboxSearch, all))).toBe('VAL_001');
|
||||
});
|
||||
|
||||
it('메일함 범위의 커서 값은 예전과 같다 — 배포 전에 받은 커서도 그대로 쓴다', () => {
|
||||
// 3단계 전의 범위 해시: sha256("<메일함 id>\n<검색어>") 의 앞 16자 (받은편지함 'a', 검색어 없음)
|
||||
const before = Buffer.from(
|
||||
JSON.stringify({ v: 1, a: 'eaaaaab', p: 20, s: 'h0KPxSKAPTEGXnvO' }),
|
||||
).toString('base64url');
|
||||
|
||||
expect(decodeCursor(before, inbox)).toEqual({
|
||||
anchor: 'eaaaaab',
|
||||
position: 20,
|
||||
});
|
||||
});
|
||||
|
||||
it('해석할 수 없는 값은 VAL_001 이다', () => {
|
||||
const forged = Buffer.from(
|
||||
JSON.stringify({ v: 1, a: '../x', p: -1, s: 'x' }),
|
||||
|
||||
@@ -20,8 +20,15 @@ const MAX_CURSOR_LENGTH = 512;
|
||||
/** 범위 해시 길이 (충돌보다는 "다른 목록의 커서" 감지가 목적이라 짧게 둔다) */
|
||||
const SCOPE_HASH_LENGTH = 16;
|
||||
|
||||
/**
|
||||
* "모든 메일함"(휴지통·스팸메일함 제외 — 검색) 범위의 표시 — 메일함 id 형식(MAIL_ID_PATTERN)에 없는 글자라
|
||||
* 실제 메일함 id 와 겹치지 않는다. 메일함 범위의 해시는 예전과 같다 (배포 전에 받은 커서도 그대로 쓴다).
|
||||
*/
|
||||
const ALL_MAILBOXES_SCOPE = '*';
|
||||
|
||||
export interface CursorScope {
|
||||
readonly mailboxId: string;
|
||||
/** 메일함 — 없으면 휴지통·스팸메일함을 뺀 모든 메일함 */
|
||||
readonly mailboxId?: string;
|
||||
readonly text?: string;
|
||||
}
|
||||
|
||||
@@ -41,7 +48,7 @@ interface EncodedCursor {
|
||||
|
||||
function scopeHash(scope: CursorScope): string {
|
||||
return createHash('sha256')
|
||||
.update(`${scope.mailboxId}\n${scope.text ?? ''}`)
|
||||
.update(`${scope.mailboxId ?? ALL_MAILBOXES_SCOPE}\n${scope.text ?? ''}`)
|
||||
.digest('base64url')
|
||||
.slice(0, SCOPE_HASH_LENGTH);
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { Logger } from '@nestjs/common';
|
||||
import type { Cache } from 'cache-manager';
|
||||
import { mkdtempSync, rmSync, writeFileSync } from 'fs';
|
||||
import { tmpdir } from 'os';
|
||||
@@ -560,7 +561,7 @@ describe('JmapMailProvider', () => {
|
||||
expect(page.items.map((m) => m.id)).toEqual(['m3']);
|
||||
});
|
||||
|
||||
it('검색어는 text 필터로 보낸다 (subject 필터는 단어 단위라 쓰지 않는다)', async () => {
|
||||
it('검색어는 text 조건으로 보낸다 — 메일함 조건 뒤에 낱말마다 조사를 붙인 형태의 OR 를 AND 로 (subject 필터는 쓰지 않는다)', async () => {
|
||||
const { provider, recorded } = fakeClient([queryResponses([], 0, 0)]);
|
||||
|
||||
await provider.listMessages(ARA, {
|
||||
@@ -569,8 +570,221 @@ describe('JmapMailProvider', () => {
|
||||
text: '서버 이전',
|
||||
});
|
||||
|
||||
const { filter } = argsOf(recorded[0], 1) as {
|
||||
filter: {
|
||||
operator: string;
|
||||
conditions: {
|
||||
operator?: string;
|
||||
conditions?: { text: string }[];
|
||||
}[];
|
||||
};
|
||||
};
|
||||
expect(filter.operator).toBe('AND');
|
||||
expect(filter.conditions[0]).toEqual({
|
||||
inMailbox: 'a',
|
||||
notKeyword: '$ara-sending',
|
||||
});
|
||||
expect(filter.conditions.slice(1).map((group) => group.operator)).toEqual(
|
||||
['OR', 'OR'],
|
||||
);
|
||||
const forms = filter.conditions[1].conditions?.map((form) => form.text);
|
||||
expect(forms).toEqual(
|
||||
expect.arrayContaining(['서버', '서버가', '서버를', '서버에서']),
|
||||
);
|
||||
// 받침 있는 '이전' 에는 받침 있을 때의 조사
|
||||
expect(filter.conditions[2].conditions?.map((form) => form.text)).toEqual(
|
||||
expect.arrayContaining(['이전', '이전이', '이전을']),
|
||||
);
|
||||
// 메일함 범위는 그 메일함을 함께 확인한다 (없으면 RES_001)
|
||||
expect(recorded[0].calls[0][0]).toBe('Mailbox/get');
|
||||
expect(recorded[0].options).toEqual({ invalidRequestCode: 'RES_001' });
|
||||
});
|
||||
|
||||
it('큰따옴표 구절은 조사 없이 구절 그대로 찾는다', async () => {
|
||||
const { provider, recorded } = fakeClient([queryResponses([], 0, 0)]);
|
||||
|
||||
await provider.listMessages(ARA, {
|
||||
mailboxId: 'a',
|
||||
limit: 20,
|
||||
text: '"회의를 잡겠습니다"',
|
||||
});
|
||||
|
||||
expect(argsOf(recorded[0], 1)).toMatchObject({
|
||||
filter: { inMailbox: 'a', text: '서버 이전' },
|
||||
filter: {
|
||||
operator: 'AND',
|
||||
conditions: [
|
||||
{ inMailbox: 'a', notKeyword: '$ara-sending' },
|
||||
{ text: '"회의를 잡겠습니다"' },
|
||||
],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('문자·숫자가 없는 낱말은 빼고 찾는다 — 남은 낱말만 조건이 된다', async () => {
|
||||
const { provider, recorded } = fakeClient([queryResponses([], 0, 0)]);
|
||||
|
||||
await provider.listMessages(ARA, {
|
||||
mailboxId: 'a',
|
||||
limit: 20,
|
||||
text: '회의 - …',
|
||||
});
|
||||
|
||||
const { filter } = argsOf(recorded[0], 1) as {
|
||||
filter: { conditions: unknown[] };
|
||||
};
|
||||
expect(filter.conditions).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('찾을 낱말이 하나도 없는 검색어는 엔진에 묻지 않고 빈 결과 — 메일함 범위 · 모든 메일함 모두 (범위 전체를 돌려주지 않는다)', async () => {
|
||||
for (const query of [
|
||||
{ mailboxId: 'a', limit: 20, text: '-' },
|
||||
{ mailboxId: 'zzzz', limit: 20, text: '"…"' },
|
||||
{ limit: 20, text: '""' },
|
||||
{ limit: 20, text: '- … !!' },
|
||||
]) {
|
||||
const { provider, client } = fakeClient([]);
|
||||
|
||||
await expect(provider.listMessages(ARA, query)).resolves.toEqual({
|
||||
items: [],
|
||||
hasNext: false,
|
||||
nextCursor: null,
|
||||
total: 0,
|
||||
});
|
||||
expect(client.call).not.toHaveBeenCalled();
|
||||
}
|
||||
});
|
||||
|
||||
it('찾을 낱말이 없어도 커서는 먼저 본다 — 다른 목록의 커서면 VAL_001', async () => {
|
||||
const cursor = encodeCursor(
|
||||
{ anchor: 'm2', position: 2 },
|
||||
{ mailboxId: 'a' },
|
||||
);
|
||||
const { provider, client } = fakeClient([]);
|
||||
|
||||
expect(
|
||||
await codeOf(
|
||||
provider.listMessages(ARA, {
|
||||
mailboxId: 'a',
|
||||
limit: 20,
|
||||
text: '-',
|
||||
cursor,
|
||||
}),
|
||||
),
|
||||
).toBe('VAL_001');
|
||||
expect(client.call).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
describe('메일함을 정하지 않은 검색 (휴지통 · 스팸메일함을 뺀 모든 메일함)', () => {
|
||||
/** 메일함 확인 없는 목록 응답 */
|
||||
const searchResponses = (ids: string[], total: number) =>
|
||||
queryResponses(ids, 0, total).filter(([, , id]) => id !== 'mb');
|
||||
const mailboxRoles: JmapInvocation[] = [
|
||||
[
|
||||
'Mailbox/get',
|
||||
{
|
||||
list: stalwartMailboxes.map(({ id, role }) => ({ id, role })),
|
||||
},
|
||||
'm',
|
||||
],
|
||||
];
|
||||
|
||||
it('휴지통 · 스팸메일함 id 를 먼저 읽어 inMailboxOtherThan 으로 뺀다 — 메일함 확인 없이, 커서는 "모든 메일함" 범위', async () => {
|
||||
const { provider, recorded } = fakeClient([
|
||||
mailboxRoles,
|
||||
searchResponses(['m1', 'm2'], 3),
|
||||
]);
|
||||
|
||||
const page = await provider.listMessages(ARA, {
|
||||
limit: 2,
|
||||
text: '회의',
|
||||
});
|
||||
|
||||
expect(argsOf(recorded[0])).toEqual({
|
||||
accountId: 'd',
|
||||
ids: null,
|
||||
properties: ['id', 'role'],
|
||||
});
|
||||
expect(recorded[1].calls.map(([method]) => method)).toEqual([
|
||||
'Email/query',
|
||||
'Email/get',
|
||||
]);
|
||||
expect(argsOf(recorded[1])).toMatchObject({
|
||||
filter: {
|
||||
operator: 'AND',
|
||||
conditions: [
|
||||
{ inMailboxOtherThan: ['b', 'c'], notKeyword: '$ara-sending' },
|
||||
{
|
||||
operator: 'OR',
|
||||
conditions: expect.arrayContaining([
|
||||
{ text: '회의' },
|
||||
{ text: '회의를' },
|
||||
]) as unknown,
|
||||
},
|
||||
],
|
||||
},
|
||||
sort: [{ property: 'receivedAt', isAscending: false }],
|
||||
calculateTotal: true,
|
||||
});
|
||||
// 사용자가 보낸 값은 검색어·커서뿐이다 — 엔진이 요청을 거부하면 VAL_001
|
||||
expect(recorded[1].options).toEqual({ invalidRequestCode: 'VAL_001' });
|
||||
expect(page.items.map((item) => item.id)).toEqual(['m1', 'm2']);
|
||||
expect(page.total).toBe(3);
|
||||
expect(
|
||||
decodeCursor(page.nextCursor as string, { text: '회의' }),
|
||||
).toEqual({ anchor: 'm2', position: 2 });
|
||||
});
|
||||
|
||||
it('다음 쪽도 같은 범위로 — 메일함 검색이나 다른 검색어의 커서는 엔진에 묻기 전에 VAL_001', async () => {
|
||||
const cursor = encodeCursor(
|
||||
{ anchor: 'm2', position: 2 },
|
||||
{ text: '회의' },
|
||||
);
|
||||
const next = fakeClient([mailboxRoles, searchResponses(['m3'], 3)]);
|
||||
await next.provider.listMessages(ARA, {
|
||||
limit: 2,
|
||||
text: '회의',
|
||||
cursor,
|
||||
});
|
||||
expect(argsOf(next.recorded[1])).toMatchObject({
|
||||
anchor: 'm2',
|
||||
anchorOffset: 1,
|
||||
});
|
||||
|
||||
for (const query of [
|
||||
{ mailboxId: 'a', limit: 2, text: '회의', cursor },
|
||||
{ limit: 2, text: '일정', cursor },
|
||||
]) {
|
||||
const other = fakeClient([]);
|
||||
expect(await codeOf(other.provider.listMessages(ARA, query))).toBe(
|
||||
'VAL_001',
|
||||
);
|
||||
expect(other.client.call).not.toHaveBeenCalled();
|
||||
}
|
||||
});
|
||||
|
||||
it('휴지통 · 스팸메일함이 없으면 모든 메일함에서 찾는다 (빼는 조건 없음)', async () => {
|
||||
const { provider, recorded } = fakeClient([
|
||||
[
|
||||
[
|
||||
'Mailbox/get',
|
||||
{
|
||||
list: [
|
||||
{ id: 'a', role: 'inbox' },
|
||||
{ id: 'x', role: null },
|
||||
],
|
||||
},
|
||||
'm',
|
||||
],
|
||||
],
|
||||
searchResponses([], 0),
|
||||
]);
|
||||
|
||||
await provider.listMessages(ARA, { limit: 20, text: 'ERP' });
|
||||
|
||||
const { filter } = argsOf(recorded[1]) as {
|
||||
filter: { conditions: unknown[] };
|
||||
};
|
||||
expect(filter.conditions[0]).toEqual({ notKeyword: '$ara-sending' });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1063,6 +1277,488 @@ describe('JmapMailProvider', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('새 메일 확인 (getMailState)', () => {
|
||||
const states = (
|
||||
mailboxState: unknown,
|
||||
emailState: unknown,
|
||||
): JmapInvocation[] => [
|
||||
['Mailbox/get', { state: mailboxState, list: [], notFound: [] }, 'm'],
|
||||
['Email/get', { state: emailState, list: [], notFound: [] }, 'e'],
|
||||
];
|
||||
|
||||
it('메일을 읽지 않는 조회(ids: []) 두 개를 한 요청으로 — 메일함 · 메일 상태를 이어 붙인 불투명 값', async () => {
|
||||
const { provider, recorded } = fakeClient([states('sb2', 'saj')]);
|
||||
|
||||
const { state } = await provider.getMailState(ARA);
|
||||
|
||||
expect(recorded).toHaveLength(1);
|
||||
expect(recorded[0].calls).toEqual([
|
||||
['Mailbox/get', { accountId: 'd', ids: [] }, 'm'],
|
||||
['Email/get', { accountId: 'd', ids: [] }, 'e'],
|
||||
]);
|
||||
expect(state).toMatch(/^[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$/);
|
||||
});
|
||||
|
||||
it('둘 중 하나만 바뀌어도 값이 바뀌고, 다른 두 상태가 같은 값이 되지 않는다', async () => {
|
||||
const read = async (mailboxState: string, emailState: string) =>
|
||||
(
|
||||
await fakeClient([
|
||||
states(mailboxState, emailState),
|
||||
]).provider.getMailState(ARA)
|
||||
).state;
|
||||
|
||||
const base = await read('sb2', 'saj');
|
||||
expect(await read('sb2', 'saj')).toBe(base);
|
||||
expect(await read('sb3', 'saj')).not.toBe(base);
|
||||
expect(await read('sb2', 'sak')).not.toBe(base);
|
||||
// 경계가 다른 두 쌍 ('ab' + 'c' 와 'a' + 'bc')
|
||||
expect(await read('ab', 'c')).not.toBe(await read('a', 'bc'));
|
||||
});
|
||||
|
||||
it('엔진이 상태를 알려 주지 않으면 MAIL_001 · 메서드 오류도 그 코드', async () => {
|
||||
for (const [mailboxState, emailState] of [
|
||||
[undefined, 's1'],
|
||||
['s1', null],
|
||||
['', 's1'],
|
||||
]) {
|
||||
const { provider } = fakeClient([states(mailboxState, emailState)]);
|
||||
expect(await codeOf(provider.getMailState(ARA))).toBe('MAIL_001');
|
||||
}
|
||||
|
||||
const failed = fakeClient([
|
||||
[
|
||||
['error', { type: 'serverFail' }, 'm'],
|
||||
['Email/get', { state: 's1', list: [] }, 'e'],
|
||||
],
|
||||
]);
|
||||
expect(await codeOf(failed.provider.getMailState(ARA))).toBe('MAIL_001');
|
||||
});
|
||||
});
|
||||
|
||||
describe('여러 통 바꾸기 (updateMessages)', () => {
|
||||
let warnSpy: jest.SpyInstance;
|
||||
|
||||
beforeEach(() => {
|
||||
warnSpy = jest
|
||||
.spyOn(Logger.prototype, 'warn')
|
||||
.mockImplementation(() => undefined);
|
||||
});
|
||||
|
||||
afterEach(() => warnSpy.mockRestore());
|
||||
|
||||
it('한 요청의 Email/set 하나로 — 메일마다 한 통 PATCH 와 같은 패치, 바꾸지 못한 것은 failedIds (요청 순서)', async () => {
|
||||
const { provider, recorded } = fakeClient([
|
||||
[
|
||||
[
|
||||
'Email/set',
|
||||
{
|
||||
updated: { m1: null, m3: null },
|
||||
notUpdated: { m2: { type: 'notFound' } },
|
||||
},
|
||||
'u',
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
const result = await provider.updateMessages(ARA, ['m1', 'm2', 'm3'], {
|
||||
isRead: true,
|
||||
isFlagged: false,
|
||||
});
|
||||
|
||||
expect(recorded).toHaveLength(1);
|
||||
expect(recorded[0].calls).toEqual([
|
||||
[
|
||||
'Email/set',
|
||||
{
|
||||
accountId: 'd',
|
||||
update: {
|
||||
m1: { 'keywords/$seen': true, 'keywords/$flagged': null },
|
||||
m2: { 'keywords/$seen': true, 'keywords/$flagged': null },
|
||||
m3: { 'keywords/$seen': true, 'keywords/$flagged': null },
|
||||
},
|
||||
},
|
||||
'u',
|
||||
],
|
||||
]);
|
||||
// id 는 요청 본문에서 왔다 — 엔진이 요청을 거부하면 VAL_001
|
||||
expect(recorded[0].options).toEqual({ invalidRequestCode: 'VAL_001' });
|
||||
expect(result).toEqual({ updatedIds: ['m1', 'm3'], failedIds: ['m2'] });
|
||||
// 그사이 지워진 것(notFound)은 흔한 경합이라 경고하지 않는다
|
||||
expect(warnSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('이동은 옮길 메일함 확인과 Email/set 을 같은 요청에 — 그 메일함 하나에만 속하게', async () => {
|
||||
const { provider, recorded } = fakeClient([
|
||||
[
|
||||
['Mailbox/get', { list: [{ id: 'b' }], notFound: [] }, 'mb'],
|
||||
['Email/set', { updated: { m1: null, m2: null } }, 'u'],
|
||||
],
|
||||
]);
|
||||
|
||||
const result = await provider.updateMessages(ARA, ['m1', 'm2'], {
|
||||
mailboxId: 'b',
|
||||
});
|
||||
|
||||
expect(recorded[0].calls).toEqual([
|
||||
[
|
||||
'Mailbox/get',
|
||||
{ accountId: 'd', ids: ['b'], properties: ['id'] },
|
||||
'mb',
|
||||
],
|
||||
[
|
||||
'Email/set',
|
||||
{
|
||||
accountId: 'd',
|
||||
update: {
|
||||
m1: { mailboxIds: { b: true } },
|
||||
m2: { mailboxIds: { b: true } },
|
||||
},
|
||||
},
|
||||
'u',
|
||||
],
|
||||
]);
|
||||
expect(result).toEqual({ updatedIds: ['m1', 'm2'], failedIds: [] });
|
||||
});
|
||||
|
||||
it('옮길 메일함이 없으면 404 RES_001 — 엔진이 메일마다 이동을 거절해 아무것도 바뀌지 않았다', async () => {
|
||||
const { provider, recorded } = fakeClient([
|
||||
[
|
||||
['Mailbox/get', { list: [], notFound: ['zzzz'] }, 'mb'],
|
||||
[
|
||||
'Email/set',
|
||||
{
|
||||
notUpdated: {
|
||||
m1: { type: 'invalidProperties', properties: ['mailboxIds'] },
|
||||
m2: { type: 'invalidProperties', properties: ['mailboxIds'] },
|
||||
},
|
||||
},
|
||||
'u',
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
expect(
|
||||
await codeOf(
|
||||
provider.updateMessages(ARA, ['m1', 'm2'], {
|
||||
isRead: true,
|
||||
mailboxId: 'zzzz',
|
||||
}),
|
||||
),
|
||||
).toBe('RES_001');
|
||||
// 읽음 표시도 같은 패치라 메일마다 통째로 거절된다 — 이동만 빠지고 읽음만 바뀌는 일은 없다
|
||||
const set = argsOf(recorded[0], 1) as {
|
||||
update: Record<string, Record<string, unknown>>;
|
||||
};
|
||||
expect(set.update.m1).toEqual({
|
||||
'keywords/$seen': true,
|
||||
mailboxIds: { zzzz: true },
|
||||
});
|
||||
expect(recorded).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('없는 메일함인데 엔진이 바꿨다고 답하면(있을 수 없는 경우) 사실대로 돌려주고 경고한다', async () => {
|
||||
const { provider } = fakeClient([
|
||||
[
|
||||
['Mailbox/get', { list: [], notFound: ['k'] }, 'mb'],
|
||||
['Email/set', { updated: { m1: null } }, 'u'],
|
||||
],
|
||||
]);
|
||||
|
||||
await expect(
|
||||
provider.updateMessages(ARA, ['m1'], { mailboxId: 'k' }),
|
||||
).resolves.toEqual({ updatedIds: ['m1'], failedIds: [] });
|
||||
expect(warnSpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('메일마다의 거절(notFound 밖)은 failedIds 로 — 종류와 개수만 경고한다 (id 는 남기지 않는다)', async () => {
|
||||
const { provider } = fakeClient([
|
||||
[
|
||||
[
|
||||
'Email/set',
|
||||
{
|
||||
updated: { m1: null },
|
||||
notUpdated: {
|
||||
m2: { type: 'forbidden' },
|
||||
m3: { type: 'forbidden' },
|
||||
m4: { type: 'notFound' },
|
||||
},
|
||||
},
|
||||
'u',
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
const result = await provider.updateMessages(
|
||||
ARA,
|
||||
['m1', 'm2', 'm3', 'm4'],
|
||||
{ isFlagged: true },
|
||||
);
|
||||
|
||||
expect(result).toEqual({
|
||||
updatedIds: ['m1'],
|
||||
failedIds: ['m2', 'm3', 'm4'],
|
||||
});
|
||||
expect(warnSpy).toHaveBeenCalledTimes(1);
|
||||
const [line] = warnSpy.mock.calls[0] as [string];
|
||||
expect(line).toContain('jmapType=forbidden');
|
||||
expect(line).toContain('2통');
|
||||
expect(line).not.toMatch(/m2|m3/);
|
||||
expect(line).not.toContain(ARA);
|
||||
});
|
||||
|
||||
it('엔진이 바꿨다고 알려 준 것만 updatedIds — 응답에 없는 것은 failedIds', async () => {
|
||||
const { provider } = fakeClient([
|
||||
[['Email/set', { updated: { m1: null } }, 'u']],
|
||||
]);
|
||||
|
||||
await expect(
|
||||
provider.updateMessages(ARA, ['m1', 'm2'], { isRead: false }),
|
||||
).resolves.toEqual({ updatedIds: ['m1'], failedIds: ['m2'] });
|
||||
});
|
||||
|
||||
it('메서드 단위 실패는 그 코드 — 엔진 장애 MAIL_001', async () => {
|
||||
const { provider } = fakeClient([
|
||||
[['error', { type: 'serverFail' }, 'u']],
|
||||
]);
|
||||
|
||||
expect(
|
||||
await codeOf(provider.updateMessages(ARA, ['m1'], { isRead: true })),
|
||||
).toBe('MAIL_001');
|
||||
});
|
||||
});
|
||||
|
||||
describe('휴지통에서 영구 삭제 (deleteFromTrash)', () => {
|
||||
let warnSpy: jest.SpyInstance;
|
||||
|
||||
beforeEach(() => {
|
||||
warnSpy = jest
|
||||
.spyOn(Logger.prototype, 'warn')
|
||||
.mockImplementation(() => undefined);
|
||||
});
|
||||
|
||||
afterEach(() => warnSpy.mockRestore());
|
||||
|
||||
/** 확인 요청이 돌려주는 메일 (id · 들어 있는 메일함) */
|
||||
type TrashCandidate = { id: string; mailboxIds: Record<string, boolean> };
|
||||
|
||||
/** 메일 + 메일함 확인 — 그때의 Email 상태 (null 이면 엔진이 상태를 알려 주지 않는다) */
|
||||
const lookup = (
|
||||
emails: TrashCandidate[],
|
||||
state: string | null = 's1',
|
||||
mailboxes: unknown[] = stalwartMailboxes,
|
||||
): JmapInvocation[] => [
|
||||
[
|
||||
'Email/get',
|
||||
{ ...(state === null ? {} : { state }), list: emails, notFound: [] },
|
||||
'g',
|
||||
],
|
||||
['Mailbox/get', { list: mailboxes }, 'm'],
|
||||
];
|
||||
const mismatch: JmapInvocation[] = [
|
||||
['error', { type: 'stateMismatch' }, 'd'],
|
||||
];
|
||||
/** 휴지통에만 (t1) · 휴지통과 받은편지함에 (t2) · 받은편지함에만 (i1) — 'gone' 은 없다 */
|
||||
const mixed: TrashCandidate[] = [
|
||||
{ id: 't1', mailboxIds: { b: true } },
|
||||
{ id: 't2', mailboxIds: { b: true, a: true } },
|
||||
{ id: 'i1', mailboxIds: { a: true } },
|
||||
];
|
||||
|
||||
it('휴지통에만 있으면 영구 삭제, 다른 메일함에도 있으면 휴지통에서만 뺀다 — 확인한 상태 그대로일 때 한 번에 (ifInState)', async () => {
|
||||
const { provider, recorded } = fakeClient([
|
||||
lookup(mixed),
|
||||
[['Email/set', { destroyed: ['t1'], updated: { t2: null } }, 'd']],
|
||||
]);
|
||||
|
||||
const result = await provider.deleteFromTrash(ARA, [
|
||||
't1',
|
||||
'gone',
|
||||
't2',
|
||||
'i1',
|
||||
]);
|
||||
|
||||
expect(recorded[0].calls).toEqual([
|
||||
[
|
||||
'Email/get',
|
||||
{
|
||||
accountId: 'd',
|
||||
ids: ['t1', 'gone', 't2', 'i1'],
|
||||
properties: ['id', 'mailboxIds'],
|
||||
},
|
||||
'g',
|
||||
],
|
||||
[
|
||||
'Mailbox/get',
|
||||
{ accountId: 'd', ids: null, properties: ['id', 'role'] },
|
||||
'm',
|
||||
],
|
||||
]);
|
||||
expect(recorded[0].options).toEqual({ invalidRequestCode: 'VAL_001' });
|
||||
expect(argsOf(recorded[1])).toEqual({
|
||||
accountId: 'd',
|
||||
ifInState: 's1',
|
||||
destroy: ['t1'],
|
||||
update: { t2: { 'mailboxIds/b': null } },
|
||||
});
|
||||
expect(result).toEqual({
|
||||
deletedIds: ['t1', 't2'],
|
||||
skippedIds: ['gone', 'i1'],
|
||||
});
|
||||
});
|
||||
|
||||
it('휴지통 밖의 메일은 옮기지 않는다 — 지울 것이 없으면 지우는 요청 없이 모두 건너뛴다', async () => {
|
||||
const { provider, recorded } = fakeClient([
|
||||
lookup([{ id: 'i1', mailboxIds: { a: true } }]),
|
||||
]);
|
||||
|
||||
await expect(
|
||||
provider.deleteFromTrash(ARA, ['i1', 'gone']),
|
||||
).resolves.toEqual({ deletedIds: [], skippedIds: ['i1', 'gone'] });
|
||||
expect(recorded).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('휴지통이 없으면 아무것도 지우지 않고 MAIL_001', async () => {
|
||||
const { provider, recorded } = fakeClient([
|
||||
lookup(
|
||||
[{ id: 't1', mailboxIds: { a: true } }],
|
||||
's1',
|
||||
stalwartMailboxes.filter((mailbox) => mailbox.role !== 'trash'),
|
||||
),
|
||||
]);
|
||||
|
||||
expect(await codeOf(provider.deleteFromTrash(ARA, ['t1']))).toBe(
|
||||
'MAIL_001',
|
||||
);
|
||||
expect(recorded).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('상태가 어긋나면(그사이 다른 창이 바꿈) 다시 확인해 그때의 대상 · 상태로 지운다', async () => {
|
||||
const { provider, recorded } = fakeClient([
|
||||
lookup(mixed),
|
||||
mismatch,
|
||||
// 다시 보니 t2 는 휴지통 밖으로 되돌려졌다 — 지우지 않는다
|
||||
lookup(
|
||||
[
|
||||
{ id: 't1', mailboxIds: { b: true } },
|
||||
{ id: 't2', mailboxIds: { a: true } },
|
||||
],
|
||||
's2',
|
||||
),
|
||||
[['Email/set', { destroyed: ['t1'] }, 'd']],
|
||||
]);
|
||||
|
||||
const result = await provider.deleteFromTrash(ARA, ['t1', 't2']);
|
||||
|
||||
expect(argsOf(recorded[3])).toEqual({
|
||||
accountId: 'd',
|
||||
ifInState: 's2',
|
||||
destroy: ['t1'],
|
||||
});
|
||||
expect(result).toEqual({ deletedIds: ['t1'], skippedIds: ['t2'] });
|
||||
});
|
||||
|
||||
it('세 번 모두 어긋나면 아무것도 지우지 않고 MAIL_001 — 다시 시도하면 된다', async () => {
|
||||
const { provider, recorded } = fakeClient([
|
||||
lookup(mixed),
|
||||
mismatch,
|
||||
lookup(mixed, 's2'),
|
||||
mismatch,
|
||||
lookup(mixed, 's3'),
|
||||
mismatch,
|
||||
]);
|
||||
|
||||
expect(await codeOf(provider.deleteFromTrash(ARA, ['t1', 't2']))).toBe(
|
||||
'MAIL_001',
|
||||
);
|
||||
expect(recorded).toHaveLength(6);
|
||||
// 지우는 요청은 모두 조건부였다 — 엔진이 거절해 아무것도 지워지지 않았다
|
||||
for (const index of [1, 3, 5]) {
|
||||
expect(argsOf(recorded[index])).toHaveProperty('ifInState');
|
||||
}
|
||||
});
|
||||
|
||||
it('지우는 순간 없어졌으면(notFound) 건너뛴 것으로 — 그 밖의 거절도 건너뛰고 종류 · 개수만 경고한다', async () => {
|
||||
const { provider } = fakeClient([
|
||||
lookup([
|
||||
{ id: 't1', mailboxIds: { b: true } },
|
||||
{ id: 't2', mailboxIds: { b: true } },
|
||||
{ id: 't3', mailboxIds: { b: true, a: true } },
|
||||
{ id: 't4', mailboxIds: { b: true } },
|
||||
]),
|
||||
[
|
||||
[
|
||||
'Email/set',
|
||||
{
|
||||
destroyed: ['t1'],
|
||||
notDestroyed: {
|
||||
t2: { type: 'notFound' },
|
||||
t4: { type: 'forbidden' },
|
||||
},
|
||||
notUpdated: { t3: { type: 'forbidden' } },
|
||||
},
|
||||
'd',
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
const result = await provider.deleteFromTrash(ARA, [
|
||||
't1',
|
||||
't2',
|
||||
't3',
|
||||
't4',
|
||||
]);
|
||||
|
||||
expect(result).toEqual({
|
||||
deletedIds: ['t1'],
|
||||
skippedIds: ['t2', 't3', 't4'],
|
||||
});
|
||||
expect(warnSpy).toHaveBeenCalledTimes(1);
|
||||
const [line] = warnSpy.mock.calls[0] as [string];
|
||||
expect(line).toContain('[AUTH_003]');
|
||||
expect(line).toContain('jmapType=forbidden');
|
||||
expect(line).toContain('2통');
|
||||
expect(line).not.toMatch(/t3|t4/);
|
||||
});
|
||||
|
||||
it('엔진이 돌려준 id 가 요청한 id 와 다르면(정규화) 지우지 않는다', async () => {
|
||||
const { provider, recorded } = fakeClient([
|
||||
lookup([{ id: 'eaaaaab', mailboxIds: { b: true } }]),
|
||||
]);
|
||||
|
||||
await expect(provider.deleteFromTrash(ARA, ['EAAAAAB'])).resolves.toEqual(
|
||||
{ deletedIds: [], skippedIds: ['EAAAAAB'] },
|
||||
);
|
||||
expect(recorded).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('상태를 모르면 조건 없이 지우지 않는다 (MAIL_001) — 지울 것이 없으면 묻지 않는다', async () => {
|
||||
const unknown = fakeClient([lookup(mixed, null)]);
|
||||
expect(await codeOf(unknown.provider.deleteFromTrash(ARA, ['t1']))).toBe(
|
||||
'MAIL_001',
|
||||
);
|
||||
expect(unknown.recorded).toHaveLength(1);
|
||||
|
||||
const nothing = fakeClient([
|
||||
lookup([{ id: 'i1', mailboxIds: { a: true } }], null),
|
||||
]);
|
||||
await expect(
|
||||
nothing.provider.deleteFromTrash(ARA, ['i1']),
|
||||
).resolves.toEqual({ deletedIds: [], skippedIds: ['i1'] });
|
||||
});
|
||||
|
||||
it('지우기의 그 밖의 메서드 오류는 그 코드 — 조건 없이 다시 지우지 않는다', async () => {
|
||||
const { provider, recorded } = fakeClient([
|
||||
lookup(mixed),
|
||||
[['error', { type: 'serverFail' }, 'd']],
|
||||
]);
|
||||
|
||||
expect(await codeOf(provider.deleteFromTrash(ARA, ['t1']))).toBe(
|
||||
'MAIL_001',
|
||||
);
|
||||
expect(recorded).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('발송 (sendMessage)', () => {
|
||||
const outgoing: OutgoingMessage = {
|
||||
to: [{ name: '이도윤', email: DOYUN }],
|
||||
|
||||
@@ -20,10 +20,13 @@ import type {
|
||||
MailAddress,
|
||||
MailAttachment,
|
||||
Mailbox,
|
||||
MailState,
|
||||
MailUsage,
|
||||
MessageDeletionResult,
|
||||
MessageDetailSource,
|
||||
MessageListQuery,
|
||||
MessagePage,
|
||||
MessagesPatchResult,
|
||||
MessageSummary,
|
||||
MessageUpdate,
|
||||
OutgoingAttachment,
|
||||
@@ -42,7 +45,11 @@ import {
|
||||
type JmapSession,
|
||||
} from './jmap.client';
|
||||
import { decodeCursor, encodeCursor } from './jmap-cursor';
|
||||
import { isMissingAttachmentBlob, jmapFailure } from './jmap.errors';
|
||||
import {
|
||||
isMissingAttachmentBlob,
|
||||
jmapFailure,
|
||||
mapJmapErrorType,
|
||||
} from './jmap.errors';
|
||||
import {
|
||||
BODY_PART_PROPERTIES,
|
||||
DETAIL_PROPERTIES,
|
||||
@@ -63,6 +70,8 @@ import {
|
||||
import type {
|
||||
JmapBlob,
|
||||
JmapEmail,
|
||||
JmapEmailFilter,
|
||||
JmapEmailFilterCondition,
|
||||
JmapError,
|
||||
JmapGetResult,
|
||||
JmapIdentity,
|
||||
@@ -72,6 +81,7 @@ import type {
|
||||
JmapQuota,
|
||||
JmapSetResult,
|
||||
} from './jmap.types';
|
||||
import { searchTextConditions, withSearchConditions } from './search-terms';
|
||||
|
||||
const QUOTA_CAPABILITY = 'urn:ietf:params:jmap:quota';
|
||||
/** Blob/get (RFC 9404) — 첨부 합계 크기를 발송 전에 잰다 */
|
||||
@@ -123,7 +133,7 @@ const RECEIVED_ADDRESS_PROPERTIES = ['from', 'receivedAt'];
|
||||
|
||||
/**
|
||||
* 조건부 영구 삭제(ifInState)에서 상태가 어긋나면 다시 확인하고 지우는 횟수 — 지우기 요청을 모두 합쳐.
|
||||
* 사용자 초안(교체 저장·보낸 뒤)과 휴지통 비우기(DELETE /api/messages/{id})가 함께 쓴다.
|
||||
* 사용자 초안(교체 저장·보낸 뒤)과 휴지통에서 영구 삭제(DELETE /api/messages/{id} · POST /api/message-deletions)가 함께 쓴다.
|
||||
* 계정의 메일이 하나라도 바뀌면(새 메일 도착·다른 메일의 읽음 표시 포함) 상태가 바뀌므로 바쁜 메일함에서는 어긋날 수 있다 —
|
||||
* 그래도 이 횟수 안에 맞지 않으면 지우지 않는다.
|
||||
*/
|
||||
@@ -132,6 +142,12 @@ const CONDITIONAL_DESTROY_ATTEMPTS = 3;
|
||||
/** JMAP 메서드 오류 — ifInState 가 지금 상태와 다르다 (RFC 8620 §5.3) */
|
||||
const STATE_MISMATCH = 'stateMismatch';
|
||||
|
||||
/** SetError — 그 대상이 없다 (그사이 지워짐) */
|
||||
const NOT_FOUND = 'notFound';
|
||||
|
||||
/** 메일함을 정하지 않은 검색에서 빼는 메일함 역할 — 휴지통 · 스팸메일함 (계약 2절 "검색") */
|
||||
const SEARCH_EXCLUDED_ROLES: ReadonlySet<string> = new Set(['trash', 'junk']);
|
||||
|
||||
/**
|
||||
* 발송 요청 하나가 엔진을 기다리는 전체 시간 — 클라이언트의 발송 시간 한도(60초)보다 짧게 둔다
|
||||
* (백엔드가 먼저 답해야 사용자가 같은 키로 다시 보낼 수 있다 — jmap.client.ts 의 JMAP_SEND_TIMEOUT_MS 주석).
|
||||
@@ -251,6 +267,80 @@ function isUserDraft(email: JmapEmail, draftsId: string): boolean {
|
||||
);
|
||||
}
|
||||
|
||||
/** 읽음 · 중요 표시 · 이동을 JMAP 패치로 — 한 통(updateMessage)과 여러 통(updateMessages)이 같은 패치를 쓴다 */
|
||||
function toEmailPatch(update: MessageUpdate): Record<string, unknown> {
|
||||
const patch: Record<string, unknown> = {};
|
||||
if (update.isRead !== undefined) {
|
||||
patch['keywords/$seen'] = update.isRead ? true : null;
|
||||
}
|
||||
if (update.isFlagged !== undefined) {
|
||||
patch['keywords/$flagged'] = update.isFlagged ? true : null;
|
||||
}
|
||||
if (update.mailboxId !== undefined) {
|
||||
// 이동 = 이 메일함 하나에만 속하게 한다
|
||||
patch.mailboxIds = { [update.mailboxId]: true };
|
||||
}
|
||||
return patch;
|
||||
}
|
||||
|
||||
/** 휴지통에서 지우는 방법 — 영구 삭제 · 휴지통에서만 빼기 */
|
||||
type TrashRemoval = 'destroy' | 'untrash';
|
||||
|
||||
/**
|
||||
* 휴지통의 메일을 어떻게 지울지 — 휴지통에만 있으면 영구 삭제, 휴지통과 다른 메일함에 함께 있으면 휴지통에서만 뺀다
|
||||
* (영구 삭제하면 다른 메일함의 사본까지 사라진다). 휴지통에 없으면 null — 지우지 않는다.
|
||||
* 한 통 삭제(purgeFromTrash)와 여러 통 영구 삭제(deleteFromTrash)가 같은 규칙을 쓴다.
|
||||
* @param mailboxIds 메일이 들어 있는 메일함 (inMailboxes)
|
||||
*/
|
||||
function trashRemoval(
|
||||
mailboxIds: readonly string[],
|
||||
trashId: string,
|
||||
): TrashRemoval | null {
|
||||
if (!mailboxIds.includes(trashId)) return null;
|
||||
return mailboxIds.every((mailboxId) => mailboxId === trashId)
|
||||
? 'destroy'
|
||||
: 'untrash';
|
||||
}
|
||||
|
||||
/**
|
||||
* 확인한 상태 그대로일 때만(ifInState) 휴지통에서 지우는 Email/set 인자 — 영구 삭제와 휴지통에서 빼기를 한 번에.
|
||||
* 상태가 어긋나면 엔진이 `stateMismatch` 로 거절하고 아무것도 하지 않는다 (2026-10-27 개발 엔진 확인).
|
||||
*/
|
||||
function trashRemovalSet(
|
||||
accountId: string,
|
||||
ifInState: string,
|
||||
trashId: string,
|
||||
destroy: readonly string[],
|
||||
untrash: readonly string[],
|
||||
): Record<string, unknown> {
|
||||
return {
|
||||
accountId,
|
||||
ifInState,
|
||||
...(destroy.length > 0 ? { destroy: [...destroy] } : {}),
|
||||
...(untrash.length > 0
|
||||
? {
|
||||
update: Object.fromEntries(
|
||||
untrash.map((id) => [id, { [`mailboxIds/${trashId}`]: null }]),
|
||||
),
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
/** 엔진이 알려 준 상태 문자열인가 (비었거나 없으면 아니다) */
|
||||
function isState(value: unknown): value is string {
|
||||
return typeof value === 'string' && value !== '';
|
||||
}
|
||||
|
||||
/**
|
||||
* 새 메일 확인 값 — 메일함 상태와 메일 상태를 이어 붙인다 (클라이언트에게는 불투명).
|
||||
* 각각 base64url 로 바꿔 '.' 으로 잇는다 — base64url 에는 '.' 이 없어 서로 다른 두 상태 쌍이 같은 값이 되지 않는다.
|
||||
*/
|
||||
function combineMailStates(mailboxState: string, emailState: string): string {
|
||||
const encode = (state: string) => Buffer.from(state).toString('base64url');
|
||||
return `${encode(mailboxState)}.${encode(emailState)}`;
|
||||
}
|
||||
|
||||
/** 메일에 함께 싣는 그림 — 엔진에 올린 blob */
|
||||
interface UploadedInlineImage {
|
||||
readonly blobId: string;
|
||||
@@ -420,37 +510,87 @@ export class JmapMailProvider implements MailProvider {
|
||||
return this.attachmentsTotalLimit(await this.jmap.getSession(user));
|
||||
}
|
||||
|
||||
/**
|
||||
* 새 메일 확인 — 메일함·메일의 상태 문자열만 읽는다 (`ids: []` — 메일을 읽지 않는 가벼운 조회, 요청 한 번).
|
||||
* 새 메일이 오거나 다른 창·메일 프로그램이 읽음·이동·삭제를 하면 둘 중 하나가 바뀐다 (2026-10-27 개발 엔진 확인).
|
||||
*/
|
||||
async getMailState(user: string): Promise<MailState> {
|
||||
const responses = await this.jmap.call(user, (accountId) => [
|
||||
['Mailbox/get', { accountId, ids: [] }, 'm'],
|
||||
['Email/get', { accountId, ids: [] }, 'e'],
|
||||
]);
|
||||
const mailboxes = expectResult<JmapGetResult<JmapMailbox>>(
|
||||
responses,
|
||||
'm',
|
||||
'Mailbox/get',
|
||||
);
|
||||
const emails = expectResult<JmapGetResult<JmapEmail>>(
|
||||
responses,
|
||||
'e',
|
||||
'Email/get',
|
||||
);
|
||||
if (!isState(mailboxes.state) || !isState(emails.state)) {
|
||||
throw new BusinessException('MAIL_001', {
|
||||
context: { reason: 'mail-state-missing' },
|
||||
});
|
||||
}
|
||||
return { state: combineMailStates(mailboxes.state, emails.state) };
|
||||
}
|
||||
|
||||
async listMessages(
|
||||
user: string,
|
||||
query: MessageListQuery,
|
||||
): Promise<MessagePage> {
|
||||
const scope = { mailboxId: query.mailboxId, text: query.text };
|
||||
// 커서는 엔진에 묻기 전에 본다 — 다른 메일함·검색어의 커서면 VAL_001
|
||||
const resume = query.cursor ? decodeCursor(query.cursor, scope) : null;
|
||||
|
||||
// 검색어에 찾을 낱말(문자·숫자)이 하나도 없으면(`-` · `…` 등) 엔진에 묻지 않고 빈 결과 — 범위 전체를 돌려주지 않는다.
|
||||
// 메일함 범위여도 그 메일함을 확인하지 않는다 (계약 2절 "검색")
|
||||
const textConditions =
|
||||
query.text !== undefined ? searchTextConditions(query.text) : [];
|
||||
if (query.text !== undefined && textConditions.length === 0) {
|
||||
return { items: [], hasNext: false, nextCursor: null, total: 0 };
|
||||
}
|
||||
|
||||
// 범위 — 메일함 하나, 또는 휴지통·스팸메일함을 뺀 모든 메일함(검색). 발송 중에 생긴 내부 초안은 목록에 보이지 않는다 —
|
||||
// 사용자의 임시 저장본과 섞이지 않게 (계약 2절 "임시 저장"). 보낸 사본은 제출할 때 키워드를 떼므로 보낸편지함에서는 그대로 보인다
|
||||
const range: JmapEmailFilterCondition =
|
||||
query.mailboxId !== undefined
|
||||
? { inMailbox: query.mailboxId }
|
||||
: await this.searchableMailboxes(user);
|
||||
const filter = withSearchConditions(
|
||||
{ ...range, notKeyword: INTERNAL_SEND_KEYWORD },
|
||||
textConditions,
|
||||
);
|
||||
|
||||
let responses = await this.queryMessages(
|
||||
user,
|
||||
query,
|
||||
filter,
|
||||
resume ? { anchor: resume.anchor } : { position: 0 },
|
||||
);
|
||||
|
||||
// 마지막으로 본 메일이 그 사이에 지워지거나 옮겨졌으면 위치로 이어 읽는다
|
||||
const first = findResult(responses, 'q', 'Email/query');
|
||||
if (resume && !first.ok && first.error.type === 'anchorNotFound') {
|
||||
responses = await this.queryMessages(user, query, {
|
||||
responses = await this.queryMessages(user, query, filter, {
|
||||
position: resume.position,
|
||||
});
|
||||
}
|
||||
|
||||
const mailbox = expectResult<JmapGetResult<JmapMailbox>>(
|
||||
responses,
|
||||
'mb',
|
||||
'Mailbox/get',
|
||||
);
|
||||
if (!mailbox.list?.length) {
|
||||
// 없는 메일함 — 계약 2절 "표에 없는 실패": 404 RES_001
|
||||
throw new BusinessException('RES_001', {
|
||||
context: { reason: 'mailbox-not-found' },
|
||||
});
|
||||
if (query.mailboxId !== undefined) {
|
||||
const mailbox = expectResult<JmapGetResult<JmapMailbox>>(
|
||||
responses,
|
||||
'mb',
|
||||
'Mailbox/get',
|
||||
);
|
||||
if (!mailbox.list?.length) {
|
||||
// 없는 메일함 — 계약 2절 "표에 없는 실패": 404 RES_001
|
||||
throw new BusinessException('RES_001', {
|
||||
context: { reason: 'mailbox-not-found' },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const result = expectResult<JmapQueryResult>(responses, 'q', 'Email/query');
|
||||
@@ -520,17 +660,7 @@ export class JmapMailProvider implements MailProvider {
|
||||
id: string,
|
||||
update: MessageUpdate,
|
||||
): Promise<MessageSummary> {
|
||||
const patch: Record<string, unknown> = {};
|
||||
if (update.isRead !== undefined) {
|
||||
patch['keywords/$seen'] = update.isRead ? true : null;
|
||||
}
|
||||
if (update.isFlagged !== undefined) {
|
||||
patch['keywords/$flagged'] = update.isFlagged ? true : null;
|
||||
}
|
||||
if (update.mailboxId !== undefined) {
|
||||
// 이동 = 이 메일함 하나에만 속하게 한다
|
||||
patch.mailboxIds = { [update.mailboxId]: true };
|
||||
}
|
||||
const patch = toEmailPatch(update);
|
||||
|
||||
const responses = await this.jmap.call(
|
||||
user,
|
||||
@@ -557,6 +687,73 @@ export class JmapMailProvider implements MailProvider {
|
||||
return toSummary(this.requireEmail(responses, 'g'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 여러 통에 같은 변경을 한다 — 엔진 요청 한 번(옮길 메일함 확인 + Email/set). 메일마다 패치는 한 통 PATCH 와 같다.
|
||||
* - 바꾸지 못한 메일(없음 · 그사이 지워짐 · 엔진이 거절)은 failedIds — 나머지는 그대로 처리한다
|
||||
* - 옮길 메일함이 없으면 404 RES_001 — 엔진은 없는 메일함으로의 이동을 메일마다 거절하므로(invalidProperties)
|
||||
* 같은 요청의 Email/set 도 아무것도 바꾸지 않는다. 메일 하나가 바뀐 것처럼 오면(있을 수 없는 경우) 사실대로 돌려준다
|
||||
* - 메서드 단위 실패는 그 코드(엔진 장애 MAIL_001) — 일부만 바뀌었을 수 있다 (같은 요청을 다시 보내도 결과가 같다)
|
||||
* id 는 요청 본문에서 왔다 — 엔진이 그 형식을 해석하지 못해 요청 전체를 거부하면 VAL_001 (아무것도 바꾸지 않았다)
|
||||
*/
|
||||
async updateMessages(
|
||||
user: string,
|
||||
ids: readonly string[],
|
||||
update: MessageUpdate,
|
||||
): Promise<MessagesPatchResult> {
|
||||
const patch = toEmailPatch(update);
|
||||
const { mailboxId } = update;
|
||||
|
||||
const responses = await this.jmap.call(
|
||||
user,
|
||||
(accountId) => [
|
||||
...(mailboxId !== undefined
|
||||
? [
|
||||
[
|
||||
'Mailbox/get',
|
||||
{ accountId, ids: [mailboxId], properties: ['id'] },
|
||||
'mb',
|
||||
] as JmapInvocation,
|
||||
]
|
||||
: []),
|
||||
[
|
||||
'Email/set',
|
||||
{
|
||||
accountId,
|
||||
update: Object.fromEntries(ids.map((id) => [id, patch])),
|
||||
},
|
||||
'u',
|
||||
],
|
||||
],
|
||||
{ invalidRequestCode: 'VAL_001' },
|
||||
);
|
||||
|
||||
const set = expectResult<JmapSetResult>(responses, 'u', 'Email/set');
|
||||
const updated = new Set(Object.keys(set.updated ?? {}));
|
||||
const updatedIds = ids.filter((id) => updated.has(id));
|
||||
const failedIds = ids.filter((id) => !updated.has(id));
|
||||
|
||||
if (mailboxId !== undefined) {
|
||||
const mailbox = expectResult<JmapGetResult<JmapMailbox>>(
|
||||
responses,
|
||||
'mb',
|
||||
'Mailbox/get',
|
||||
);
|
||||
if (!mailbox.list?.length) {
|
||||
if (updatedIds.length === 0) {
|
||||
throw new BusinessException('RES_001', {
|
||||
context: { reason: 'mailbox-not-found', requested: ids.length },
|
||||
});
|
||||
}
|
||||
this.logger.warn(
|
||||
`[MAIL_001]${traceTag()} 없는 메일함으로 옮긴 메일이 있다고 엔진이 답했습니다 — 결과를 그대로 돌려줍니다: user=${maskEmail(user)}, 바뀜=${updatedIds.length}통`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
this.warnRefusals(user, set.notUpdated, '바꾸지');
|
||||
return { updatedIds, failedIds };
|
||||
}
|
||||
|
||||
async deleteMessage(user: string, id: string): Promise<void> {
|
||||
const lookup = await this.jmap.call(
|
||||
user,
|
||||
@@ -666,20 +863,20 @@ export class JmapMailProvider implements MailProvider {
|
||||
snapshot = { state: found.state, mailboxIds: inMailboxes(email) };
|
||||
}
|
||||
|
||||
const onlyInTrash = snapshot.mailboxIds.every(
|
||||
(mailboxId) => mailboxId === trashId,
|
||||
);
|
||||
// 확인한 대로면 늘 휴지통에 있다 — 아니면 지우지 않는다
|
||||
const removal = trashRemoval(snapshot.mailboxIds, trashId);
|
||||
if (removal === null) return;
|
||||
const ifInState = snapshot.state;
|
||||
const responses = await this.jmap.call(user, (accountId) => [
|
||||
[
|
||||
'Email/set',
|
||||
onlyInTrash
|
||||
? { accountId, ifInState, destroy: [id] }
|
||||
: {
|
||||
accountId,
|
||||
ifInState,
|
||||
update: { [id]: { [`mailboxIds/${trashId}`]: null } },
|
||||
},
|
||||
trashRemovalSet(
|
||||
accountId,
|
||||
ifInState,
|
||||
trashId,
|
||||
removal === 'destroy' ? [id] : [],
|
||||
removal === 'untrash' ? [id] : [],
|
||||
),
|
||||
'd',
|
||||
],
|
||||
]);
|
||||
@@ -693,10 +890,10 @@ export class JmapMailProvider implements MailProvider {
|
||||
}
|
||||
const set = result.args as JmapSetResult;
|
||||
const failure = firstSetError(
|
||||
onlyInTrash ? set.notDestroyed : set.notUpdated,
|
||||
removal === 'destroy' ? set.notDestroyed : set.notUpdated,
|
||||
);
|
||||
// 지웠다 · 휴지통에서 뺐다 · 그사이 없어졌다
|
||||
if (!failure || failure.type === 'notFound') return;
|
||||
if (!failure || failure.type === NOT_FOUND) return;
|
||||
throw jmapFailure(failure, { method: 'Email/set' });
|
||||
}
|
||||
throw new BusinessException('MAIL_001', {
|
||||
@@ -704,6 +901,120 @@ export class JmapMailProvider implements MailProvider {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 휴지통에서 영구 삭제 (여러 통) — 휴지통에 있는 메일만 다룬다. 휴지통 밖의 메일을 휴지통으로 옮기지 않는다 (DELETE 한 통과 다르다).
|
||||
* 한 번의 시도: 메일·메일함 확인(요청 1) → 확인한 상태 그대로일 때만 한 번에 지우기(요청 2, ifInState).
|
||||
* - 휴지통에만 있으면 영구 삭제, 다른 메일함에도 있으면 휴지통에서만 뺀다 (trashRemoval — 한 통 삭제와 같은 규칙)
|
||||
* - 없음 · 휴지통에 없음 → skippedIds (확인한 뒤 다른 창이 휴지통 밖으로 되돌린 메일은 상태가 어긋나 지우지 않는다)
|
||||
* - 지우는 순간 메일마다의 거절: 없어졌으면(notFound) 건너뛴 것으로, 그 밖의 거절도 건너뛴 것으로(종류·개수만 경고)
|
||||
* - 상태가 어긋나면 다시 확인한다 — 합계 CONDITIONAL_DESTROY_ATTEMPTS 번이 모두 어긋나면 아무것도 지우지 않고 MAIL_001
|
||||
* - 휴지통이 없으면 MAIL_001 (지우지 않는다)
|
||||
* id 는 요청 본문에서 왔다 — 엔진이 그 형식을 해석하지 못해 확인 요청 전체를 거부하면 VAL_001 (지우지 않았다)
|
||||
*/
|
||||
async deleteFromTrash(
|
||||
user: string,
|
||||
ids: readonly string[],
|
||||
): Promise<MessageDeletionResult> {
|
||||
for (
|
||||
let attempt = 0;
|
||||
attempt < CONDITIONAL_DESTROY_ATTEMPTS;
|
||||
attempt += 1
|
||||
) {
|
||||
const lookup = await this.jmap.call(
|
||||
user,
|
||||
(accountId) => [
|
||||
[
|
||||
'Email/get',
|
||||
{ accountId, ids: [...ids], properties: ['id', 'mailboxIds'] },
|
||||
'g',
|
||||
],
|
||||
[
|
||||
'Mailbox/get',
|
||||
{ accountId, ids: null, properties: ['id', 'role'] },
|
||||
'm',
|
||||
],
|
||||
],
|
||||
{ invalidRequestCode: 'VAL_001' },
|
||||
);
|
||||
const found = expectResult<JmapGetResult<JmapEmail>>(
|
||||
lookup,
|
||||
'g',
|
||||
'Email/get',
|
||||
);
|
||||
const { list: mailboxes = [] } = expectResult<JmapGetResult<JmapMailbox>>(
|
||||
lookup,
|
||||
'm',
|
||||
'Mailbox/get',
|
||||
);
|
||||
const trashId = roleMailboxId(mailboxes, 'trash');
|
||||
if (!trashId) {
|
||||
throw new BusinessException('MAIL_001', {
|
||||
context: { reason: 'trash-mailbox-missing' },
|
||||
});
|
||||
}
|
||||
|
||||
// 요청한 id 그대로 찾는다 — 엔진이 돌려준 id 가 다르면(정규화) 지우지 않고 건너뛴다
|
||||
const byId = new Map(
|
||||
(found.list ?? []).map((email) => [email.id, email]),
|
||||
);
|
||||
const destroy: string[] = [];
|
||||
const untrash: string[] = [];
|
||||
for (const id of ids) {
|
||||
const email = byId.get(id);
|
||||
const removal = email
|
||||
? trashRemoval(inMailboxes(email), trashId)
|
||||
: null;
|
||||
if (removal === 'destroy') destroy.push(id);
|
||||
else if (removal === 'untrash') untrash.push(id);
|
||||
}
|
||||
if (destroy.length === 0 && untrash.length === 0) {
|
||||
return { deletedIds: [], skippedIds: [...ids] };
|
||||
}
|
||||
// 상태를 모르면 조건 없이 지우지 않는다
|
||||
if (!isState(found.state)) {
|
||||
throw new BusinessException('MAIL_001', {
|
||||
context: { reason: 'trash-state-unknown' },
|
||||
});
|
||||
}
|
||||
|
||||
const ifInState = found.state;
|
||||
const responses = await this.jmap.call(user, (accountId) => [
|
||||
[
|
||||
'Email/set',
|
||||
trashRemovalSet(accountId, ifInState, trashId, destroy, untrash),
|
||||
'd',
|
||||
],
|
||||
]);
|
||||
const result = findResult(responses, 'd', 'Email/set');
|
||||
if (!result.ok) {
|
||||
// 확인한 뒤 계정의 메일이 바뀌었다 — 아무것도 하지 않았으니 다시 확인한다
|
||||
if (result.error.type === STATE_MISMATCH) continue;
|
||||
throw jmapFailure(result.error, { method: 'Email/set' });
|
||||
}
|
||||
|
||||
const set = result.args as JmapSetResult;
|
||||
// 엔진이 지웠다 · 휴지통에서 뺐다고 답한 것 중 이 요청이 지우려던 것만
|
||||
const destroyed = new Set(set.destroyed ?? []);
|
||||
const untrashed = new Set(Object.keys(set.updated ?? {}));
|
||||
const deleted = new Set([
|
||||
...destroy.filter((id) => destroyed.has(id)),
|
||||
...untrash.filter((id) => untrashed.has(id)),
|
||||
]);
|
||||
this.warnRefusals(
|
||||
user,
|
||||
{ ...set.notDestroyed, ...set.notUpdated },
|
||||
'휴지통에서 지우지',
|
||||
);
|
||||
return {
|
||||
deletedIds: ids.filter((id) => deleted.has(id)),
|
||||
skippedIds: ids.filter((id) => !deleted.has(id)),
|
||||
};
|
||||
}
|
||||
throw new BusinessException('MAIL_001', {
|
||||
context: { reason: 'trash-delete-contended', requested: ids.length },
|
||||
});
|
||||
}
|
||||
|
||||
async sendMessage(
|
||||
user: string,
|
||||
message: OutgoingMessage,
|
||||
@@ -1354,31 +1665,58 @@ export class JmapMailProvider implements MailProvider {
|
||||
}
|
||||
|
||||
/**
|
||||
* 메일함 확인 + 목록 조회 + 요약 조회를 한 요청으로 보낸다.
|
||||
* 사용자가 보낸 mailboxId 가 필터에 들어가므로, 엔진이 해석하지 못하면 없는 메일함과 같게 RES_001 로 응답한다
|
||||
* 메일함을 정하지 않은 검색의 범위 — 휴지통·스팸메일함을 뺀 모든 메일함 (계약 2절 "검색").
|
||||
* 그 메일함 id 를 먼저 읽는다 (필터 안에서는 다른 메서드의 결과를 참조할 수 없다). 그런 역할의 메일함이 없으면 모든 메일함이다.
|
||||
* `inMailboxOtherThan` 은 그 밖의 메일함에 하나라도 들어 있는 메일이다 — 휴지통에만 있는 메일이 빠진다 (RFC 8621 §4.4.1).
|
||||
*/
|
||||
private async searchableMailboxes(
|
||||
user: string,
|
||||
): Promise<JmapEmailFilterCondition> {
|
||||
const lookup = await this.jmap.call(user, (accountId) => [
|
||||
[
|
||||
'Mailbox/get',
|
||||
{ accountId, ids: null, properties: ['id', 'role'] },
|
||||
'm',
|
||||
],
|
||||
]);
|
||||
const { list: mailboxes = [] } = expectResult<JmapGetResult<JmapMailbox>>(
|
||||
lookup,
|
||||
'm',
|
||||
'Mailbox/get',
|
||||
);
|
||||
const excluded = mailboxes
|
||||
.filter((mailbox) =>
|
||||
SEARCH_EXCLUDED_ROLES.has(mailbox.role?.toLowerCase() ?? ''),
|
||||
)
|
||||
.map((mailbox) => mailbox.id);
|
||||
return excluded.length > 0 ? { inMailboxOtherThan: excluded } : {};
|
||||
}
|
||||
|
||||
/**
|
||||
* (메일함 확인 +) 목록 조회 + 요약 조회를 한 요청으로 보낸다.
|
||||
* 메일함 범위면 사용자가 보낸 mailboxId 가 필터에 들어가므로, 엔진이 해석하지 못하면 없는 메일함과 같게 RES_001 로 응답한다
|
||||
* (Stalwart 는 필터의 id 형식이 틀리면 요청 전체를 notRequest 로 거부한다).
|
||||
* 모든 메일함 검색에서 사용자가 보낸 값은 검색어와 커서뿐이라 그때는 VAL_001 이다.
|
||||
*/
|
||||
private queryMessages(
|
||||
user: string,
|
||||
query: MessageListQuery,
|
||||
filter: JmapEmailFilter,
|
||||
start: { anchor: string } | { position: number },
|
||||
): Promise<JmapInvocation[]> {
|
||||
// 발송 중에 생긴 내부 초안은 목록에 보이지 않는다 — 사용자의 임시 저장본과 섞이지 않게 (계약 2절 "임시 저장").
|
||||
// 보낸 사본은 제출할 때 키워드를 떼므로 보낸편지함에서는 그대로 보인다
|
||||
const filter = {
|
||||
inMailbox: query.mailboxId,
|
||||
notKeyword: INTERNAL_SEND_KEYWORD,
|
||||
...(query.text ? { text: query.text } : {}),
|
||||
};
|
||||
|
||||
const { mailboxId } = query;
|
||||
return this.jmap.call(
|
||||
user,
|
||||
(accountId) => [
|
||||
[
|
||||
'Mailbox/get',
|
||||
{ accountId, ids: [query.mailboxId], properties: ['id'] },
|
||||
'mb',
|
||||
],
|
||||
...(mailboxId !== undefined
|
||||
? [
|
||||
[
|
||||
'Mailbox/get',
|
||||
{ accountId, ids: [mailboxId], properties: ['id'] },
|
||||
'mb',
|
||||
] as JmapInvocation,
|
||||
]
|
||||
: []),
|
||||
[
|
||||
'Email/query',
|
||||
{
|
||||
@@ -1405,10 +1743,33 @@ export class JmapMailProvider implements MailProvider {
|
||||
'g',
|
||||
],
|
||||
],
|
||||
{ invalidRequestCode: 'RES_001' },
|
||||
{ invalidRequestCode: mailboxId !== undefined ? 'RES_001' : 'VAL_001' },
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 여러 통을 처리하다 엔진이 메일마다 거절한 것 — 종류별 개수만 경고로 남긴다 (id 목록은 남기지 않는다).
|
||||
* 그사이 없어진 것(notFound)은 흔한 경합이라 남기지 않는다.
|
||||
* @param doing 로그 문장 — '바꾸지' · '휴지통에서 지우지'
|
||||
*/
|
||||
private warnRefusals(
|
||||
user: string,
|
||||
errors: Record<string, JmapError> | undefined,
|
||||
doing: string,
|
||||
): void {
|
||||
const counts = new Map<string, number>();
|
||||
for (const error of Object.values(errors ?? {})) {
|
||||
const type = error?.type ?? 'unknown';
|
||||
if (type === NOT_FOUND) continue;
|
||||
counts.set(type, (counts.get(type) ?? 0) + 1);
|
||||
}
|
||||
for (const [type, count] of counts) {
|
||||
this.logger.warn(
|
||||
`[${mapJmapErrorType(type)}]${traceTag()} 엔진이 거절해 ${doing} 못한 메일이 있습니다: user=${maskEmail(user)}, jmapType=${type}, ${count}통`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 첨부 합계 크기 확인 — 업로드한 파일과 전달하는 원본 첨부 모두 Blob/get 으로 잰다.
|
||||
* 한도는 MAIL_ATTACHMENT_TOTAL_MAX_SIZE_MB 와 엔진의 메일 한 통 첨부 한도 중 작은 값이다.
|
||||
|
||||
@@ -121,6 +121,28 @@ export interface JmapSetResult {
|
||||
notDestroyed?: Record<string, JmapError>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Email/query 의 FilterCondition 중 이 어댑터가 쓰는 것 (RFC 8621 §4.4.1) — 한 객체 안의 조건은 모두 맞아야 한다(AND).
|
||||
*/
|
||||
export interface JmapEmailFilterCondition {
|
||||
inMailbox?: string;
|
||||
/** 이 메일함들이 **아닌** 메일함에 하나라도 들어 있는 메일 — 휴지통·스팸메일함에만 있는 메일을 뺀다 */
|
||||
inMailboxOtherThan?: string[];
|
||||
notKeyword?: string;
|
||||
/** 보낸 사람·받는 사람·참조·제목·본문 전문 검색 — 큰따옴표로 감싼 값은 구절로 찾는다 */
|
||||
text?: string;
|
||||
}
|
||||
|
||||
/** FilterOperator (RFC 8620 §5.5) — 조건들을 AND · OR · NOT 으로 묶는다 */
|
||||
export interface JmapFilterOperator<T> {
|
||||
operator: 'AND' | 'OR' | 'NOT';
|
||||
conditions: (T | JmapFilterOperator<T>)[];
|
||||
}
|
||||
|
||||
export type JmapEmailFilter =
|
||||
| JmapEmailFilterCondition
|
||||
| JmapFilterOperator<JmapEmailFilterCondition>;
|
||||
|
||||
export interface JmapQueryResult {
|
||||
ids?: string[];
|
||||
position?: number;
|
||||
|
||||
@@ -0,0 +1,368 @@
|
||||
import {
|
||||
SEARCH_PARTICLES,
|
||||
parseSearchTerms,
|
||||
particleForms,
|
||||
searchTextConditions,
|
||||
withSearchConditions,
|
||||
} from './search-terms';
|
||||
import type { JmapEmailFilter } from './jmap.types';
|
||||
|
||||
const { any, withoutFinal, withFinal } = SEARCH_PARTICLES;
|
||||
|
||||
/** 낱말 하나의 OR 조건이 찾는 형태들 */
|
||||
function formsIn(condition: JmapEmailFilter): string[] {
|
||||
if ('operator' in condition) {
|
||||
return condition.conditions.map((child) =>
|
||||
'text' in child ? String(child.text) : '',
|
||||
);
|
||||
}
|
||||
return condition.text === undefined ? [] : [condition.text];
|
||||
}
|
||||
|
||||
describe('검색어 → 엔진 조건 (search-terms)', () => {
|
||||
describe('조사 표 (SEARCH_PARTICLES)', () => {
|
||||
it('모두 비어 있지 않은 한글 조사이고, 한 목록 안에 같은 조사가 없다', () => {
|
||||
for (const list of [any, withoutFinal, withFinal]) {
|
||||
expect(new Set(list).size).toBe(list.length);
|
||||
for (const particle of list) {
|
||||
expect(particle).toMatch(/^[가-힣]+$/);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("받침 있는 목록의 '으로' 꼴은 넷이다 — ㄹ 받침이면 '로' 꼴로 바꾼다", () => {
|
||||
expect(
|
||||
withFinal.filter((particle) => particle.startsWith('으로')),
|
||||
).toEqual(['으로', '으로는', '으로도', '으로서']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('낱말 하나의 형태 (particleForms)', () => {
|
||||
it('받침 없는 한글 — 낱말 그대로 + 공통 + 받침 없을 때의 조사', () => {
|
||||
const forms = particleForms('회의');
|
||||
|
||||
expect(forms[0]).toBe('회의');
|
||||
expect(forms).toEqual(
|
||||
expect.arrayContaining([
|
||||
'회의가',
|
||||
'회의를',
|
||||
'회의는',
|
||||
'회의에서',
|
||||
'회의입니다',
|
||||
'회의로',
|
||||
'회의와',
|
||||
'회의예요',
|
||||
'회의들을',
|
||||
]),
|
||||
);
|
||||
for (const wrong of [
|
||||
'회의이',
|
||||
'회의을',
|
||||
'회의은',
|
||||
'회의으로',
|
||||
'회의과',
|
||||
]) {
|
||||
expect(forms).not.toContain(wrong);
|
||||
}
|
||||
expect(forms).toHaveLength(1 + any.length + withoutFinal.length);
|
||||
});
|
||||
|
||||
it('받침 있는 한글 — 받침 있을 때의 조사 (으로 꼴 그대로)', () => {
|
||||
const forms = particleForms('일정');
|
||||
|
||||
expect(forms).toEqual(
|
||||
expect.arrayContaining([
|
||||
'일정',
|
||||
'일정이',
|
||||
'일정을',
|
||||
'일정은',
|
||||
'일정과',
|
||||
'일정으로',
|
||||
'일정이에요',
|
||||
'일정입니다',
|
||||
]),
|
||||
);
|
||||
for (const wrong of [
|
||||
'일정가',
|
||||
'일정를',
|
||||
'일정는',
|
||||
'일정로',
|
||||
'일정예요',
|
||||
]) {
|
||||
expect(forms).not.toContain(wrong);
|
||||
}
|
||||
expect(forms).toHaveLength(1 + any.length + withFinal.length);
|
||||
});
|
||||
|
||||
it("ㄹ 받침 — '으로' 대신 '로' (메일로 · 서울로), 나머지는 받침 있을 때와 같다", () => {
|
||||
for (const word of ['메일', '서울']) {
|
||||
const forms = particleForms(word);
|
||||
|
||||
expect(forms).toEqual(
|
||||
expect.arrayContaining([
|
||||
`${word}로`,
|
||||
`${word}로는`,
|
||||
`${word}로도`,
|
||||
`${word}로서`,
|
||||
`${word}이`,
|
||||
`${word}을`,
|
||||
`${word}은`,
|
||||
`${word}이나`,
|
||||
]),
|
||||
);
|
||||
expect(forms.some((form) => form.includes('으로'))).toBe(false);
|
||||
expect(forms).not.toContain(`${word}를`);
|
||||
expect(forms).toHaveLength(1 + any.length + withFinal.length);
|
||||
}
|
||||
});
|
||||
|
||||
it("겹받침(ㄺ 등)은 ㄹ 받침이 아니다 — '으로' 를 그대로 쓴다 (닭으로)", () => {
|
||||
const forms = particleForms('닭');
|
||||
|
||||
expect(forms).toContain('닭으로');
|
||||
expect(forms).not.toContain('닭로');
|
||||
});
|
||||
|
||||
it('영문·숫자로 끝나면 받침을 알 수 없어 두 가지를 다 붙인다', () => {
|
||||
for (const word of ['ERP', '2026', 'v2']) {
|
||||
const forms = particleForms(word);
|
||||
|
||||
expect(forms).toEqual(
|
||||
expect.arrayContaining([
|
||||
word,
|
||||
`${word}가`,
|
||||
`${word}이`,
|
||||
`${word}를`,
|
||||
`${word}을`,
|
||||
`${word}로`,
|
||||
`${word}으로`,
|
||||
`${word}에서`,
|
||||
]),
|
||||
);
|
||||
expect(forms).toHaveLength(
|
||||
1 + any.length + withoutFinal.length + withFinal.length,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it('끝 글자만 본다 — 영문이 섞여도 한글로 끝나면 한글 규칙 (ERP시스템 → 받침 ㅁ)', () => {
|
||||
const forms = particleForms('ERP시스템');
|
||||
|
||||
expect(forms).toContain('ERP시스템이');
|
||||
expect(forms).not.toContain('ERP시스템가');
|
||||
});
|
||||
|
||||
it('끝 글자가 한글 음절도 영문·숫자도 아니면 낱말 그대로 하나다 — 한글 자모 · 한자 · 악센트 있는 라틴 문자 · 전각 숫자', () => {
|
||||
// 검색어에서는 앞뒤 문장부호를 먼저 뗀다 (parseSearchTerms) — 여기까지 오는 낱말은 문자·숫자로 끝난다
|
||||
for (const word of ['ㅎㅇ', '東京', 'café', '2026']) {
|
||||
expect(particleForms(word)).toEqual([word]);
|
||||
}
|
||||
});
|
||||
|
||||
it('같은 형태는 한 번만 나온다', () => {
|
||||
for (const word of ['회의', '일정', '메일', 'ERP']) {
|
||||
const forms = particleForms(word);
|
||||
expect(new Set(forms).size).toBe(forms.length);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('검색어 나누기 (parseSearchTerms)', () => {
|
||||
it('띄어쓰기로 낱말을 나눈다 — 여러 칸 · 전각 공백 · 줄바꿈도 한 번의 띄어쓰기', () => {
|
||||
const ideographicSpace = String.fromCharCode(0x3000);
|
||||
expect(
|
||||
parseSearchTerms(` 서버 이전${ideographicSpace}일정\n공유 `),
|
||||
).toEqual([
|
||||
{ kind: 'word', text: '서버' },
|
||||
{ kind: 'word', text: '이전' },
|
||||
{ kind: 'word', text: '일정' },
|
||||
{ kind: 'word', text: '공유' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('큰따옴표 안은 구절 하나다 — 나온 순서를 지키고, 구절 안의 여러 칸 공백은 한 칸으로', () => {
|
||||
expect(parseSearchTerms('회의 "일정 공유" 서버')).toEqual([
|
||||
{ kind: 'word', text: '회의' },
|
||||
{ kind: 'phrase', text: '일정 공유' },
|
||||
{ kind: 'word', text: '서버' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('따옴표가 낱말에 붙어 있어도 따옴표에서 나눈다', () => {
|
||||
expect(parseSearchTerms('견적"2차 수정"본')).toEqual([
|
||||
{ kind: 'word', text: '견적' },
|
||||
{ kind: 'phrase', text: '2차 수정' },
|
||||
{ kind: 'word', text: '본' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('짝이 없는 마지막 따옴표는 없는 것으로 보고 뒤를 낱말로 나눈다', () => {
|
||||
expect(parseSearchTerms('회의 "일정 공유')).toEqual([
|
||||
{ kind: 'word', text: '회의' },
|
||||
{ kind: 'word', text: '일정' },
|
||||
{ kind: 'word', text: '공유' },
|
||||
]);
|
||||
expect(parseSearchTerms('"a b" "c')).toEqual([
|
||||
{ kind: 'phrase', text: 'a b' },
|
||||
{ kind: 'word', text: 'c' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('빈 따옴표는 버린다 — 찾을 것이 없으면 빈 목록', () => {
|
||||
expect(parseSearchTerms('""')).toEqual([]);
|
||||
expect(parseSearchTerms('" "')).toEqual([]);
|
||||
expect(parseSearchTerms('"')).toEqual([]);
|
||||
expect(parseSearchTerms('회의 ""')).toEqual([
|
||||
{ kind: 'word', text: '회의' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('문자·숫자가 하나도 없는 낱말 · 구절은 버린다 — 문장부호 · 말줄임표 · 그림 문자뿐인 것', () => {
|
||||
for (const query of ['-', '…', '- … !!', '"- …"', '😀', '" "', '"-" "']) {
|
||||
expect(parseSearchTerms(query)).toEqual([]);
|
||||
}
|
||||
expect(parseSearchTerms('회의 - "…" 2026 "- 일정"')).toEqual([
|
||||
{ kind: 'word', text: '회의' },
|
||||
{ kind: 'word', text: '2026' },
|
||||
// 문자가 하나라도 있으면 구절은 그대로 (안의 문장부호 포함)
|
||||
{ kind: 'phrase', text: '- 일정' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('문자·숫자가 하나라도 있으면 남긴다 — 한글 자모 · 다른 나라 글자 · 전각 숫자', () => {
|
||||
expect(
|
||||
parseSearchTerms('ㅎㅇ café 東京 2026').map((term) => term.text),
|
||||
).toEqual(['ㅎㅇ', 'café', '東京', '2026']);
|
||||
});
|
||||
|
||||
it('낱말 앞뒤의 문장부호 · 기호는 떼고 가운데 낱말로 찾는다 — 같은 가운데 낱말은 한 번만', () => {
|
||||
expect(
|
||||
parseSearchTerms(
|
||||
'회의, (회의) 회의. “회의” ...회의... [공지] 2026. 회의!',
|
||||
),
|
||||
).toEqual([
|
||||
{ kind: 'word', text: '회의' },
|
||||
{ kind: 'word', text: '공지' },
|
||||
{ kind: 'word', text: '2026' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('문장부호를 뗀 가운데 낱말에 받침에 맞는 조사를 붙인다 — "회의," 로도 "회의를" 을 찾는다', () => {
|
||||
const [condition] = searchTextConditions('회의,');
|
||||
|
||||
expect(formsIn(condition)).toEqual(particleForms('회의'));
|
||||
expect(formsIn(condition)).toContain('회의를');
|
||||
});
|
||||
|
||||
it('낱말 가운데의 문장부호 · 글자에 붙은 결합 문자는 그대로 둔다 — 끝의 그림 문자는 이체 선택자와 함께 뗀다', () => {
|
||||
// 태국어 'ที่' — 끝의 두 글자는 앞 글자에 붙는 결합 문자(성조·모음 기호)다
|
||||
const thai = String.fromCodePoint(0x0e17, 0x0e35, 0x0e48);
|
||||
// ❤️ = U+2764 + 이체 선택자 U+FE0F (결합 문자 범주)
|
||||
const heart = String.fromCodePoint(0x2764, 0xfe0f);
|
||||
|
||||
expect(
|
||||
parseSearchTerms(
|
||||
`서버-이전 don't ${thai} 확인${heart} ${heart}${heart}`,
|
||||
),
|
||||
).toEqual([
|
||||
{ kind: 'word', text: '서버-이전' },
|
||||
{ kind: 'word', text: "don't" },
|
||||
{ kind: 'word', text: thai },
|
||||
{ kind: 'word', text: '확인' },
|
||||
]);
|
||||
expect(particleForms('서버-이전')).toContain('서버-이전이');
|
||||
});
|
||||
|
||||
it('구절은 앞뒤 문장부호를 떼지 않는다', () => {
|
||||
expect(parseSearchTerms('"회의, 일정."')).toEqual([
|
||||
{ kind: 'phrase', text: '회의, 일정.' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('같은 낱말 · 같은 구절은 한 번만 — 낱말과 구절은 서로 다른 것이다', () => {
|
||||
expect(parseSearchTerms('회의 회의 "일정" "일정" 일정')).toEqual([
|
||||
{ kind: 'word', text: '회의' },
|
||||
{ kind: 'phrase', text: '일정' },
|
||||
{ kind: 'word', text: '일정' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('자모를 풀어 쓴(NFD) 한글도 완성형으로 읽어 받침에 맞는 조사를 붙인다', () => {
|
||||
const decomposed = '회의'.normalize('NFD');
|
||||
expect(decomposed).not.toBe('회의');
|
||||
|
||||
expect(parseSearchTerms(decomposed)).toEqual([
|
||||
{ kind: 'word', text: '회의' },
|
||||
]);
|
||||
expect(formsIn(searchTextConditions(decomposed)[0])).toContain('회의를');
|
||||
});
|
||||
});
|
||||
|
||||
describe('엔진 조건 (searchTextConditions · withSearchConditions)', () => {
|
||||
it('낱말은 형태들의 OR, 구절은 따옴표를 붙인 text 하나 (조사를 붙이지 않는다)', () => {
|
||||
const [word, phrase] = searchTextConditions('회의 "회의를 잡겠습니다"');
|
||||
|
||||
expect(word).toEqual({
|
||||
operator: 'OR',
|
||||
conditions: particleForms('회의').map((text) => ({ text })),
|
||||
});
|
||||
expect(phrase).toEqual({ text: '"회의를 잡겠습니다"' });
|
||||
});
|
||||
|
||||
it('형태가 하나뿐인 낱말은 OR 로 감싸지 않는다', () => {
|
||||
expect(searchTextConditions('東京')).toEqual([{ text: '東京' }]);
|
||||
expect(searchTextConditions('(東京)')).toEqual([{ text: '東京' }]);
|
||||
});
|
||||
|
||||
it('찾을 낱말이 없으면 조건도 없다 (빈 따옴표 · 문장부호뿐)', () => {
|
||||
for (const query of ['""', '"', '-', '… —', '"" -']) {
|
||||
expect(searchTextConditions(query)).toEqual([]);
|
||||
}
|
||||
});
|
||||
|
||||
it('범위 조건이 맨 앞에 오는 AND — 낱말끼리도 AND 다', () => {
|
||||
const scope = { inMailbox: 'a', notKeyword: '$ara-sending' };
|
||||
|
||||
const filter = withSearchConditions(
|
||||
scope,
|
||||
searchTextConditions('서버 이전'),
|
||||
);
|
||||
|
||||
expect(filter).toEqual({
|
||||
operator: 'AND',
|
||||
conditions: [
|
||||
scope,
|
||||
{
|
||||
operator: 'OR',
|
||||
conditions: particleForms('서버').map((text) => ({ text })),
|
||||
},
|
||||
{
|
||||
operator: 'OR',
|
||||
conditions: particleForms('이전').map((text) => ({ text })),
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('검색어 조건이 없으면(검색이 아닌 목록) 범위 조건 그대로', () => {
|
||||
const scope = { inMailboxOtherThan: ['b', 'c'], notKeyword: '$x' };
|
||||
|
||||
expect(withSearchConditions(scope, [])).toBe(scope);
|
||||
});
|
||||
|
||||
it('낱말 8개(영문 — 가장 많은 형태)도 조건 수가 낱말 수에 비례한다', () => {
|
||||
const words = Array.from({ length: 8 }, (_, index) => `word${index}`);
|
||||
|
||||
const conditions = searchTextConditions(words.join(' '));
|
||||
|
||||
expect(conditions).toHaveLength(8);
|
||||
const total = conditions.reduce(
|
||||
(sum, condition) => sum + formsIn(condition).length,
|
||||
0,
|
||||
);
|
||||
expect(total).toBe(
|
||||
8 * (1 + any.length + withoutFinal.length + withFinal.length),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,258 @@
|
||||
import { SEARCH_WORD_SEPARATOR } from '@/modules/mail/mail.constants';
|
||||
import type { JmapEmailFilter, JmapEmailFilterCondition } from './jmap.types';
|
||||
|
||||
/**
|
||||
* 검색어(q) → JMAP `Email/query` 의 text 조건 (순수 함수, 계약 2절 "검색").
|
||||
*
|
||||
* 엔진(Stalwart 0.16.27)의 전문 검색은 띄어쓰기·문장부호로 나눈 낱말이 **통째로 같을 때만** 맞는다 —
|
||||
* '회의'로는 '회의를'을 찾지 못하고 앞부분 검색('회의*')도 없다. 한 text 값에 낱말이 여럿이면 모두 맞아야 하고(AND),
|
||||
* 큰따옴표로 감싼 값은 구절로 찾는다 (2026-10-27 개발 엔진에서 확인).
|
||||
* 그래서 한글로 끝나는 낱말마다 흔한 조사를 받침에 맞게 붙인 형태를 함께 찾는다 — 낱말 안은 OR, 낱말끼리는 AND.
|
||||
* 큰따옴표로 감싼 부분은 구절 그대로 찾고 조사를 붙이지 않는다.
|
||||
*
|
||||
* 알려진 한계(엔진 색인 때문): 붙여 쓴 말의 일부('회의실'의 '회의'), 앞부분만 친 말, 끝이 바뀐 말('보냈습니다'의 '보내'),
|
||||
* 제목 맨 앞의 말머리('[공지]' · 'Re:' — 엔진이 제목을 정규화하며 색인에서 뺀다)는 찾지 못한다.
|
||||
*/
|
||||
|
||||
/**
|
||||
* 낱말 뒤에 붙여 함께 찾는 조사 — 서버 상수는 이 표 하나다 (계약 2절).
|
||||
* 한글로 끝나는 낱말 하나가 형태 44개(낱말 그대로 + 28 + 15), 영문·숫자로 끝나면 59개가 된다
|
||||
* (개발 엔진에서 낱말 하나에 형태 40개 안팎으로 한두 낱말 검색이 0.04~0.12초였다).
|
||||
*/
|
||||
export const SEARCH_PARTICLES = {
|
||||
/** 받침과 상관없이 붙는 것 */
|
||||
any: [
|
||||
'의',
|
||||
'에',
|
||||
'에서',
|
||||
'에게',
|
||||
'께',
|
||||
'한테',
|
||||
'도',
|
||||
'만',
|
||||
'까지',
|
||||
'부터',
|
||||
'마다',
|
||||
'보다',
|
||||
'처럼',
|
||||
'하고',
|
||||
'에는',
|
||||
'에서는',
|
||||
'에도',
|
||||
'에서도',
|
||||
'에게는',
|
||||
'까지는',
|
||||
'부터는',
|
||||
'들',
|
||||
'들이',
|
||||
'들을',
|
||||
'들은',
|
||||
'들의',
|
||||
'입니다',
|
||||
'입니까',
|
||||
],
|
||||
/** 받침이 없을 때 (회의 → 회의가 · 회의를) */
|
||||
withoutFinal: [
|
||||
'가',
|
||||
'를',
|
||||
'는',
|
||||
'와',
|
||||
'로',
|
||||
'로는',
|
||||
'로도',
|
||||
'로서',
|
||||
'랑',
|
||||
'나',
|
||||
'라고',
|
||||
'라는',
|
||||
'라도',
|
||||
'예요',
|
||||
'였습니다',
|
||||
],
|
||||
/** 받침이 있을 때 (일정 → 일정이 · 일정을) — ㄹ 받침이면 '으로…' 대신 '로…' (메일로 · 서울로) */
|
||||
withFinal: [
|
||||
'이',
|
||||
'을',
|
||||
'은',
|
||||
'과',
|
||||
'으로',
|
||||
'으로는',
|
||||
'으로도',
|
||||
'으로서',
|
||||
'이랑',
|
||||
'이나',
|
||||
'이라고',
|
||||
'이라는',
|
||||
'이라도',
|
||||
'이에요',
|
||||
'이었습니다',
|
||||
],
|
||||
} as const;
|
||||
|
||||
/** 한글 음절(가~힣) — 받침은 (코드 − 첫 음절) % 28 (0 = 받침 없음) */
|
||||
const HANGUL_SYLLABLE_FIRST = 0xac00;
|
||||
const HANGUL_SYLLABLE_LAST = 0xd7a3;
|
||||
const FINAL_CONSONANT_COUNT = 28;
|
||||
/** 받침 번호 — ㄹ 하나 (ㄺ·ㄻ 같은 겹받침은 '으로'를 그대로 쓴다: 닭으로) */
|
||||
const RIEUL_FINAL = 8;
|
||||
|
||||
/** ㄹ 받침 뒤에서는 '으로' 의 '으' 가 빠진다 */
|
||||
const EURO = '으로';
|
||||
|
||||
/** 영문·숫자로 끝나는 낱말 — 읽는 법(받침)을 알 수 없어 두 가지 조사를 다 붙인다 (ERP가 · ERP이) */
|
||||
const ASCII_ALNUM_END = /[A-Za-z0-9]$/;
|
||||
|
||||
/** 큰따옴표 — 감싼 부분은 구절로 찾는다 */
|
||||
const QUOTE = '"';
|
||||
|
||||
/**
|
||||
* 찾을 수 있는 글자 — 문자(\p{L}: 한글·자모·영문 등)나 숫자(\p{N}).
|
||||
* 이것이 하나도 없는 낱말·구절(`-` · `…` · 그림 문자뿐)은 버린다 — 엔진은 문장부호를 낱말로 색인하지 않는다
|
||||
*/
|
||||
const SEARCHABLE_CHARACTER = /[\p{L}\p{N}]/u;
|
||||
|
||||
/**
|
||||
* 낱말 앞뒤에 붙은, 문자·숫자가 아닌 글자 — 엔진은 문장부호에서 낱말을 나누므로 떼고 가운데 낱말로 찾는다
|
||||
* ('회의,' · '(회의)' · '회의.' · '“회의”' → '회의' — 그래야 받침을 읽어 '회의를' 도 찾는다). 낱말 가운데의 문장부호('서버-이전')는 그대로 둔다.
|
||||
* 뒤쪽은 "문자·숫자·결합 문자가 아닌 글자 + 그 뒤의 결합 문자" 를 뗀다 — 글자에 붙은 결합 문자(태국어 성조 등)는 남기고,
|
||||
* 그림 문자와 그 이체 선택자(❤️ = U+2764 U+FE0F)는 함께 뗀다.
|
||||
*/
|
||||
const WORD_EDGE_PUNCTUATION =
|
||||
/^[^\p{L}\p{N}]+|(?:[^\p{L}\p{N}\p{M}]\p{M}*)+$/gu;
|
||||
|
||||
/** 낱말 앞뒤의 문장부호·기호를 뗀 가운데 낱말 — 문자·숫자가 없으면 빈 문자열 */
|
||||
function coreWord(word: string): string {
|
||||
return word.replace(WORD_EDGE_PUNCTUATION, '');
|
||||
}
|
||||
|
||||
/** 낱말 끝의 종류 — 어떤 조사를 붙일지 정한다 */
|
||||
type WordEnding =
|
||||
/** 받침 없는 한글 */
|
||||
| 'no-final'
|
||||
/** 받침 있는 한글 (ㄹ 하나는 따로) */
|
||||
| 'final'
|
||||
/** ㄹ 받침 한글 */
|
||||
| 'rieul'
|
||||
/** 영문·숫자 — 받침을 알 수 없다 */
|
||||
| 'unknown'
|
||||
/** 그 밖 (문장부호·한글 자모·그림 문자 등) — 조사를 붙이지 않는다 */
|
||||
| 'other';
|
||||
|
||||
const toRieul = (particle: string): string =>
|
||||
particle.startsWith(EURO) ? particle.slice(1) : particle;
|
||||
|
||||
/** 끝의 종류마다 붙일 조사 — 같은 조사는 한 번만 */
|
||||
const PARTICLES_BY_ENDING: Readonly<Record<WordEnding, readonly string[]>> = {
|
||||
'no-final': unique([
|
||||
...SEARCH_PARTICLES.any,
|
||||
...SEARCH_PARTICLES.withoutFinal,
|
||||
]),
|
||||
final: unique([...SEARCH_PARTICLES.any, ...SEARCH_PARTICLES.withFinal]),
|
||||
rieul: unique([
|
||||
...SEARCH_PARTICLES.any,
|
||||
...SEARCH_PARTICLES.withFinal.map(toRieul),
|
||||
]),
|
||||
unknown: unique([
|
||||
...SEARCH_PARTICLES.any,
|
||||
...SEARCH_PARTICLES.withoutFinal,
|
||||
...SEARCH_PARTICLES.withFinal,
|
||||
]),
|
||||
other: [],
|
||||
};
|
||||
|
||||
function unique(values: readonly string[]): string[] {
|
||||
return [...new Set(values)];
|
||||
}
|
||||
|
||||
function endingOf(word: string): WordEnding {
|
||||
const code = word.charCodeAt(word.length - 1);
|
||||
if (code >= HANGUL_SYLLABLE_FIRST && code <= HANGUL_SYLLABLE_LAST) {
|
||||
const final = (code - HANGUL_SYLLABLE_FIRST) % FINAL_CONSONANT_COUNT;
|
||||
if (final === 0) return 'no-final';
|
||||
return final === RIEUL_FINAL ? 'rieul' : 'final';
|
||||
}
|
||||
return ASCII_ALNUM_END.test(word) ? 'unknown' : 'other';
|
||||
}
|
||||
|
||||
/**
|
||||
* 낱말 하나로 찾을 형태 — 낱말 그대로 + 끝에 맞는 조사를 붙인 것 (같은 형태는 한 번만).
|
||||
* 한글로 끝나지도 영문·숫자로 끝나지도 않으면 낱말 그대로 하나다.
|
||||
*/
|
||||
export function particleForms(word: string): string[] {
|
||||
if (word === '') return [];
|
||||
return unique([
|
||||
word,
|
||||
...PARTICLES_BY_ENDING[endingOf(word)].map((particle) => word + particle),
|
||||
]);
|
||||
}
|
||||
|
||||
/** 검색어를 나눈 조각 — 낱말(조사를 붙여 찾는다) · 구절(큰따옴표 안, 그대로 찾는다) */
|
||||
export interface SearchTerm {
|
||||
readonly kind: 'word' | 'phrase';
|
||||
readonly text: string;
|
||||
}
|
||||
|
||||
function splitWords(text: string): string[] {
|
||||
return text.split(SEARCH_WORD_SEPARATOR).filter((word) => word !== '');
|
||||
}
|
||||
|
||||
/**
|
||||
* 검색어를 낱말·구절로 나눈다 — 나온 순서대로, 같은 낱말·같은 구절은 한 번만.
|
||||
* - 큰따옴표 한 쌍 안은 구절이다 (안의 여러 칸 공백은 한 칸으로)
|
||||
* - 짝이 없는 마지막 큰따옴표는 없는 것으로 본다 — 그 뒤는 따옴표 밖처럼 낱말로 나눈다
|
||||
* - 낱말은 띄어쓰기(공백 문자)로 나눈다 — DTO 가 세는 "낱말 수"와 같은 기준 (SEARCH_WORD_SEPARATOR)
|
||||
* - 낱말 앞뒤의 문장부호·기호는 떼고 가운데 낱말로 찾는다 ('회의,' → '회의', WORD_EDGE_PUNCTUATION). 구절은 그대로 둔다
|
||||
* - 문자·숫자가 하나도 없는 낱말·구절(비어 있음 · `-` · `…` 등)은 버린다 (SEARCHABLE_CHARACTER) —
|
||||
* 남는 것이 없으면 빈 목록이고, 목록 조회는 엔진에 묻지 않고 빈 결과를 돌려준다 (범위 전체를 돌려주지 않는다)
|
||||
* - 유니코드 NFC(완성형)로 정규화한다 — 자모를 풀어 쓴(NFD) 한글을 붙여 넣어도 받침을 읽는다
|
||||
*/
|
||||
export function parseSearchTerms(query: string): SearchTerm[] {
|
||||
const segments = query.normalize('NFC').split(QUOTE);
|
||||
const terms: SearchTerm[] = [];
|
||||
const seen = new Set<string>();
|
||||
const add = (kind: SearchTerm['kind'], text: string) => {
|
||||
const key = `${kind}\n${text}`;
|
||||
if (!SEARCHABLE_CHARACTER.test(text) || seen.has(key)) return;
|
||||
seen.add(key);
|
||||
terms.push({ kind, text });
|
||||
};
|
||||
|
||||
segments.forEach((segment, index) => {
|
||||
// 홀수 번째 조각은 따옴표 안이다 — 뒤에 닫는 따옴표가 있을 때만 (마지막 조각이면 짝이 없다)
|
||||
const isPhrase = index % 2 === 1 && index < segments.length - 1;
|
||||
if (isPhrase) {
|
||||
add('phrase', splitWords(segment).join(' '));
|
||||
return;
|
||||
}
|
||||
for (const word of splitWords(segment)) add('word', coreWord(word));
|
||||
});
|
||||
return terms;
|
||||
}
|
||||
|
||||
/**
|
||||
* 검색어의 text 조건들 — 모두 맞아야 한다(AND 로 묶어 쓴다).
|
||||
* 구절은 `{ text: '"…"' }` 하나, 낱말은 형태들의 OR (형태가 하나뿐이면 그 조건 하나).
|
||||
* 찾을 낱말이 하나도 없으면(문장부호뿐 · 빈 따옴표 등) 빈 목록 — 호출한 쪽은 아무것도 찾지 않는다.
|
||||
*/
|
||||
export function searchTextConditions(query: string): JmapEmailFilter[] {
|
||||
return parseSearchTerms(query).map((term): JmapEmailFilter => {
|
||||
if (term.kind === 'phrase') return { text: `${QUOTE}${term.text}${QUOTE}` };
|
||||
const forms = particleForms(term.text);
|
||||
return forms.length === 1
|
||||
? { text: forms[0] }
|
||||
: { operator: 'OR', conditions: forms.map((text) => ({ text })) };
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 범위 조건(메일함·내부 초안 제외)에 검색어 조건(searchTextConditions)을 AND 로 더한다.
|
||||
* 검색어 조건이 없으면(검색이 아닌 목록) 범위 조건 그대로다.
|
||||
*/
|
||||
export function withSearchConditions(
|
||||
scope: JmapEmailFilterCondition,
|
||||
conditions: readonly JmapEmailFilter[],
|
||||
): JmapEmailFilter {
|
||||
return conditions.length > 0
|
||||
? { operator: 'AND', conditions: [scope, ...conditions] }
|
||||
: scope;
|
||||
}
|
||||
@@ -41,9 +41,24 @@ export const MESSAGES_ROUTE = 'messages';
|
||||
*/
|
||||
export const DRAFTS_ROUTE = 'drafts';
|
||||
|
||||
/** 검색어 최대 길이 */
|
||||
/**
|
||||
* 검색어 최대 길이 — 앞뒤 공백을 뺀 길이를 JS 문자열 길이(UTF-16 코드 단위)로 센다 (계약 2절 "검색").
|
||||
* 넘으면 400 VAL_001 (dto/message-list-query.dto.ts)
|
||||
*/
|
||||
export const MAX_SEARCH_LENGTH = 200;
|
||||
|
||||
/** 검색어의 낱말(띄어쓰기로 나눈 것) 최대 개수 — 낱말마다 조사를 붙인 형태 수십 개로 찾으므로 엔진 비용을 묶어 둔다 */
|
||||
export const MAX_SEARCH_WORDS = 8;
|
||||
|
||||
/** 검색어의 낱말을 나누는 기준 — 공백 문자(전각 공백·줄바꿈 포함). DTO 의 낱말 수와 엔진 조건(jmap/search-terms.ts)이 같이 쓴다 */
|
||||
export const SEARCH_WORD_SEPARATOR = /\s+/u;
|
||||
|
||||
/**
|
||||
* 여러 통을 한꺼번에 다루는 요청(PATCH /api/messages · POST /api/message-deletions)의 id 최대 개수 —
|
||||
* 엔진의 한 번 처리 한도(maxObjectsInGet · maxObjectsInSet 500)보다 작게 둔다 (계약 2절 "여러 통 한꺼번에")
|
||||
*/
|
||||
export const MAX_BULK_MESSAGE_IDS = 100;
|
||||
|
||||
/** 받는 사람 추천의 거르는 글자(q) 최대 길이 — 계약 2절 "받는 사람 추천" */
|
||||
export const MAX_SUGGESTION_QUERY_LENGTH = 100;
|
||||
|
||||
|
||||
@@ -25,7 +25,12 @@ import {
|
||||
buildMailSettings,
|
||||
isMailEngineConfigured,
|
||||
} from './mail.settings';
|
||||
import { MailboxController, MailQuotaController } from './mailbox.controller';
|
||||
import {
|
||||
MailboxController,
|
||||
MailQuotaController,
|
||||
MailStateController,
|
||||
} from './mailbox.controller';
|
||||
import { MessageDeletionController } from './message-deletion.controller';
|
||||
import { MessageController } from './message.controller';
|
||||
import { RecipientSuggestionController } from './recipient-suggestion.controller';
|
||||
import { RecipientSuggestionService } from './recipient-suggestions.service';
|
||||
@@ -97,7 +102,9 @@ function attachmentUploadOptions(config: ConfigService): MulterModuleOptions {
|
||||
controllers: [
|
||||
MailboxController,
|
||||
MailQuotaController,
|
||||
MailStateController,
|
||||
MessageController,
|
||||
MessageDeletionController,
|
||||
MailAttachmentController,
|
||||
DraftController,
|
||||
RecipientSuggestionController,
|
||||
|
||||
@@ -6,10 +6,13 @@ import type {
|
||||
MailAccountProfile,
|
||||
MailAttachment,
|
||||
Mailbox,
|
||||
MailState,
|
||||
MailUsage,
|
||||
MessageDeletionResult,
|
||||
MessageDetailSource,
|
||||
MessageListQuery,
|
||||
MessagePage,
|
||||
MessagesPatchResult,
|
||||
MessageSummary,
|
||||
MessageUpdate,
|
||||
OutgoingMessage,
|
||||
@@ -60,6 +63,17 @@ export interface MailProvider {
|
||||
*/
|
||||
getAttachmentsTotalLimit(user: string): Promise<number>;
|
||||
|
||||
/**
|
||||
* 새 메일 확인용 상태 값 — 메일함이나 메일이 하나라도 바뀌면 달라진다 (메일을 읽지 않는 가벼운 조회).
|
||||
* 엔진이 상태를 알려 주지 않으면 MAIL_001.
|
||||
*/
|
||||
getMailState(user: string): Promise<MailState>;
|
||||
|
||||
/**
|
||||
* 메일 목록 (받은 시각 내림차순). `mailboxId` 가 없으면 휴지통·스팸메일함을 뺀 모든 메일함에서 찾는다 (검색).
|
||||
* `text` 는 엔진의 낱말 검색 규칙에 맞춰 넓혀 찾는다 (한국어 조사 — 계약 2절 "검색"). 없는 메일함은 RES_001.
|
||||
* `text` 에 찾을 낱말(문자·숫자)이 하나도 없으면 엔진에 묻지 않고 빈 목록을 돌려준다 (메일함 범위여도 — 메일함을 확인하지 않는다).
|
||||
*/
|
||||
listMessages(user: string, query: MessageListQuery): Promise<MessagePage>;
|
||||
|
||||
/** 조회만 한다 — 읽음 처리하지 않는다 */
|
||||
@@ -71,9 +85,29 @@ export interface MailProvider {
|
||||
update: MessageUpdate,
|
||||
): Promise<MessageSummary>;
|
||||
|
||||
/**
|
||||
* 여러 통에 같은 변경(읽음 · 중요 표시 · 이동)을 한다 — 엔진 요청 한 번. 바꾸지 못한 메일(없음 · 엔진이 거절)은 failedIds 로.
|
||||
* 옮길 메일함이 없으면 아무것도 바꾸지 않고 RES_001.
|
||||
*/
|
||||
updateMessages(
|
||||
user: string,
|
||||
ids: readonly string[],
|
||||
update: MessageUpdate,
|
||||
): Promise<MessagesPatchResult>;
|
||||
|
||||
/** 휴지통이 아니면 휴지통으로 옮기고, 휴지통이면 영구 삭제한다 */
|
||||
deleteMessage(user: string, id: string): Promise<void>;
|
||||
|
||||
/**
|
||||
* 휴지통에 있는 메일만 영구 삭제한다 (여러 통) — 휴지통 밖의 메일은 옮기지 않고 건너뛴다.
|
||||
* 확인한 상태 그대로일 때만 한 번에 지운다(휴지통에만 있으면 영구 삭제, 다른 메일함에도 있으면 휴지통에서만 뺀다).
|
||||
* 확인하는 사이 계속 바뀌거나 휴지통이 없으면 아무것도 지우지 않고 MAIL_001.
|
||||
*/
|
||||
deleteFromTrash(
|
||||
user: string,
|
||||
ids: readonly string[],
|
||||
): Promise<MessageDeletionResult>;
|
||||
|
||||
/**
|
||||
* @returns 보낸편지함에 남은 메일 id
|
||||
* 첨부 합계가 한도(MAIL_ATTACHMENT_TOTAL_MAX_SIZE_MB 와 엔진 한도 중 작은 값)를 넘으면 FILE_002,
|
||||
|
||||
@@ -56,10 +56,13 @@ function fakeProvider(): ProviderMocks {
|
||||
getUsage: mockOf<'getUsage'>(),
|
||||
getAttachmentsTotalLimit:
|
||||
mockOf<'getAttachmentsTotalLimit'>().mockResolvedValue(25 * 1024 * 1024),
|
||||
getMailState: mockOf<'getMailState'>(),
|
||||
listMessages: mockOf<'listMessages'>(),
|
||||
getMessage: mockOf<'getMessage'>(),
|
||||
updateMessage: mockOf<'updateMessage'>(),
|
||||
updateMessages: mockOf<'updateMessages'>(),
|
||||
deleteMessage: mockOf<'deleteMessage'>(),
|
||||
deleteFromTrash: mockOf<'deleteFromTrash'>(),
|
||||
sendMessage,
|
||||
findSentMessage: mockOf<'findSentMessage'>().mockResolvedValue(null),
|
||||
uploadAttachment: mockOf<'uploadAttachment'>(),
|
||||
@@ -324,6 +327,105 @@ describe('MailService', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('검색 · 새 메일 확인 · 여러 통 (3단계)', () => {
|
||||
const emptyPage = {
|
||||
items: [],
|
||||
hasNext: false,
|
||||
nextCursor: null,
|
||||
total: 0,
|
||||
};
|
||||
|
||||
it('메일함 없이 검색어만 있으면 모든 메일함 검색으로 넘긴다 (휴지통·스팸메일함 제외는 어댑터)', async () => {
|
||||
provider.listMessages.mockResolvedValue(emptyPage);
|
||||
|
||||
await service.listMessages(user, { limit: 20, q: ' 회의 ' });
|
||||
|
||||
expect(provider.listMessages).toHaveBeenCalledWith(user.email, {
|
||||
mailboxId: undefined,
|
||||
limit: 20,
|
||||
cursor: undefined,
|
||||
text: '회의',
|
||||
});
|
||||
});
|
||||
|
||||
it('메일함도 검색어도 없으면(공백뿐인 검색어 포함) 엔진에 묻지 않고 VAL_001', async () => {
|
||||
for (const query of [{ limit: 20 }, { limit: 20, q: ' ' }]) {
|
||||
expect(await codeOf(() => service.listMessages(user, query))).toBe(
|
||||
'VAL_001',
|
||||
);
|
||||
}
|
||||
expect(provider.listMessages).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('새 메일 확인은 어댑터의 상태 값을 그대로 — 요청마다 로그를 남기지 않는다', async () => {
|
||||
provider.getMailState.mockResolvedValue({ state: 'c2Iy.c2Fq' });
|
||||
|
||||
await expect(service.getMailState(user)).resolves.toEqual({
|
||||
state: 'c2Iy.c2Fq',
|
||||
});
|
||||
expect(provider.getMailState).toHaveBeenCalledWith(user.email);
|
||||
expect(logSpy).not.toHaveBeenCalled();
|
||||
expect(warnSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('여러 통 PATCH — 바꿀 것이 하나 이상이어야 한다 (없으면 VAL_001, 엔진에 묻지 않는다)', async () => {
|
||||
expect(
|
||||
await codeOf(() => service.updateMessages(user, { ids: ['m1', 'm2'] })),
|
||||
).toBe('VAL_001');
|
||||
expect(provider.updateMessages).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('여러 통 PATCH — id 와 변경을 그대로 넘기고 결과를 돌려준다', async () => {
|
||||
provider.updateMessages.mockResolvedValue({
|
||||
updatedIds: ['m1'],
|
||||
failedIds: ['m2'],
|
||||
});
|
||||
|
||||
await expect(
|
||||
service.updateMessages(user, {
|
||||
ids: ['m1', 'm2'],
|
||||
isRead: true,
|
||||
mailboxId: 'b',
|
||||
}),
|
||||
).resolves.toEqual({ updatedIds: ['m1'], failedIds: ['m2'] });
|
||||
expect(provider.updateMessages).toHaveBeenCalledWith(
|
||||
user.email,
|
||||
['m1', 'm2'],
|
||||
{ isRead: true, isFlagged: undefined, mailboxId: 'b' },
|
||||
);
|
||||
});
|
||||
|
||||
it('휴지통에서 영구 삭제 — 지운 것이 있으면 개수만 남긴다 (주소·id 없이), 없으면 남기지 않는다', async () => {
|
||||
provider.deleteFromTrash.mockResolvedValueOnce({
|
||||
deletedIds: ['t1', 't2'],
|
||||
skippedIds: ['i1'],
|
||||
});
|
||||
|
||||
await expect(
|
||||
service.deleteFromTrash(user, { ids: ['t1', 't2', 'i1'] }),
|
||||
).resolves.toEqual({ deletedIds: ['t1', 't2'], skippedIds: ['i1'] });
|
||||
expect(provider.deleteFromTrash).toHaveBeenCalledWith(user.email, [
|
||||
't1',
|
||||
't2',
|
||||
'i1',
|
||||
]);
|
||||
expect(logSpy).toHaveBeenCalledTimes(1);
|
||||
const [line] = logSpy.mock.calls[0] as [string];
|
||||
expect(line).toContain('지움=2통');
|
||||
expect(line).toContain('건너뜀=1통');
|
||||
expect(line).not.toContain(user.email);
|
||||
expect(line).not.toMatch(/t1|t2|i1/);
|
||||
|
||||
logSpy.mockClear();
|
||||
provider.deleteFromTrash.mockResolvedValueOnce({
|
||||
deletedIds: [],
|
||||
skippedIds: ['i1'],
|
||||
});
|
||||
await service.deleteFromTrash(user, { ids: ['i1'] });
|
||||
expect(logSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('메일 상세', () => {
|
||||
it('HTML 이 없으면 정화 없이 텍스트만 내려준다', async () => {
|
||||
provider.getMessage.mockResolvedValue(source());
|
||||
|
||||
@@ -17,6 +17,10 @@ import {
|
||||
resolveAttachmentType,
|
||||
sanitizeFileName,
|
||||
} from './attachment.policy';
|
||||
import type {
|
||||
MessageDeletionDto,
|
||||
UpdateMessagesDto,
|
||||
} from './dto/bulk-messages.dto';
|
||||
import type { MessageListQueryDto } from './dto/message-list-query.dto';
|
||||
import type {
|
||||
ComposeContentDto,
|
||||
@@ -59,9 +63,13 @@ import type {
|
||||
MailAttachment,
|
||||
Mailbox,
|
||||
MailQuota,
|
||||
MailState,
|
||||
MessageAttachment,
|
||||
MessageDeletionResult,
|
||||
MessageDetail,
|
||||
MessagesPatchResult,
|
||||
MessageSummary,
|
||||
MessageUpdate,
|
||||
OutgoingAttachment,
|
||||
OutgoingInlineImage,
|
||||
OutgoingMessage,
|
||||
@@ -162,6 +170,21 @@ function assertHtmlBytes(html: string): void {
|
||||
}
|
||||
}
|
||||
|
||||
/** 읽음 · 중요 표시 · 이동 — 하나 이상이어야 한다 (빈 요청은 VAL_001). 한 통·여러 통 PATCH 가 같이 쓴다 */
|
||||
function toMessageUpdate(dto: UpdateMessageDto): MessageUpdate {
|
||||
const { isRead, isFlagged, mailboxId } = dto;
|
||||
if (
|
||||
isRead === undefined &&
|
||||
isFlagged === undefined &&
|
||||
mailboxId === undefined
|
||||
) {
|
||||
throw new BusinessException('VAL_001', {
|
||||
context: { reason: 'empty-update' },
|
||||
});
|
||||
}
|
||||
return { isRead, isFlagged, mailboxId };
|
||||
}
|
||||
|
||||
/** 서명 그림의 Content-ID — 같은 그림이면 늘 같은 값이다 (발송 지문이 cid 가 든 서식 본문을 본다) */
|
||||
function signatureImageCid(imageId: string, sender: string): string {
|
||||
const domain = sender.slice(sender.lastIndexOf('@') + 1).toLowerCase();
|
||||
@@ -221,16 +244,30 @@ export class MailService {
|
||||
};
|
||||
}
|
||||
|
||||
/** 새 메일 확인 (계약 2절 "새 메일 확인") — 클라이언트가 1분마다 묻는다. 요청마다 로그를 남기지 않는다 */
|
||||
getMailState(user: AuthenticatedUser): Promise<MailState> {
|
||||
return this.provider.getMailState(user.email);
|
||||
}
|
||||
|
||||
/**
|
||||
* 메일 목록 · 검색 — mailboxId 와 검색어(q) 중 하나는 있어야 한다 (둘 다 없으면 VAL_001).
|
||||
* mailboxId 가 없으면 휴지통·스팸메일함을 뺀 모든 메일함에서 찾는다 (계약 2절 "검색"). 검색어는 어떤 로그에도 남기지 않는다.
|
||||
*/
|
||||
async listMessages(
|
||||
user: AuthenticatedUser,
|
||||
query: MessageListQueryDto,
|
||||
): Promise<PaginatedResult<MessageSummary>> {
|
||||
const text = query.q?.trim();
|
||||
const text = query.q?.trim() || undefined;
|
||||
if (query.mailboxId === undefined && text === undefined) {
|
||||
throw new BusinessException('VAL_001', {
|
||||
context: { reason: 'mailbox-or-search-required' },
|
||||
});
|
||||
}
|
||||
const page = await this.provider.listMessages(user.email, {
|
||||
mailboxId: query.mailboxId,
|
||||
limit: query.limit,
|
||||
cursor: query.cursor,
|
||||
text: text ? text : undefined,
|
||||
text,
|
||||
});
|
||||
|
||||
return toPaginatedResult(page.items, {
|
||||
@@ -318,27 +355,45 @@ export class MailService {
|
||||
id: string,
|
||||
dto: UpdateMessageDto,
|
||||
): Promise<MessageSummary> {
|
||||
const { isRead, isFlagged, mailboxId } = dto;
|
||||
if (
|
||||
isRead === undefined &&
|
||||
isFlagged === undefined &&
|
||||
mailboxId === undefined
|
||||
) {
|
||||
throw new BusinessException('VAL_001', {
|
||||
context: { reason: 'empty-update' },
|
||||
});
|
||||
}
|
||||
return this.provider.updateMessage(user.email, id, {
|
||||
isRead,
|
||||
isFlagged,
|
||||
mailboxId,
|
||||
});
|
||||
return this.provider.updateMessage(user.email, id, toMessageUpdate(dto));
|
||||
}
|
||||
|
||||
/**
|
||||
* 여러 통의 읽음 · 중요 표시 · 이동 (계약 2절 "여러 통 한꺼번에") — 바꿀 것이 하나 이상이어야 한다(VAL_001).
|
||||
* 바꾸지 못한 메일은 failedIds 로 돌려준다 — 클라이언트는 그것만 되돌린다.
|
||||
*/
|
||||
async updateMessages(
|
||||
user: AuthenticatedUser,
|
||||
dto: UpdateMessagesDto,
|
||||
): Promise<MessagesPatchResult> {
|
||||
return this.provider.updateMessages(
|
||||
user.email,
|
||||
dto.ids,
|
||||
toMessageUpdate(dto),
|
||||
);
|
||||
}
|
||||
|
||||
deleteMessage(user: AuthenticatedUser, id: string): Promise<void> {
|
||||
return this.provider.deleteMessage(user.email, id);
|
||||
}
|
||||
|
||||
/**
|
||||
* 휴지통에서 영구 삭제 (여러 통) — 휴지통에 있는 메일만 지운다(휴지통 밖의 메일은 건너뛴다).
|
||||
* 되돌릴 수 없는 일이라 지운 것이 있으면 개수만 남긴다 (주소·id 목록 없이).
|
||||
*/
|
||||
async deleteFromTrash(
|
||||
user: AuthenticatedUser,
|
||||
dto: MessageDeletionDto,
|
||||
): Promise<MessageDeletionResult> {
|
||||
const result = await this.provider.deleteFromTrash(user.email, dto.ids);
|
||||
if (result.deletedIds.length > 0) {
|
||||
this.logger.log(
|
||||
`${traceTag()} 휴지통에서 영구 삭제했습니다: user=${maskEmail(user.email)}, 지움=${result.deletedIds.length}통, 건너뜀=${result.skippedIds.length}통`,
|
||||
);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 메일 발송. `idempotencyKey`(Idempotency-Key 헤더)가 있으면 중복 발송을 막는다 (계약 2절):
|
||||
* - 같은 사용자·같은 키로 이미 보낸 메일이 있으면 다시 보내지 않고 그 id 를 돌려준다
|
||||
|
||||
@@ -125,11 +125,12 @@ export interface MessageDetailSource extends Omit<
|
||||
}
|
||||
|
||||
export interface MessageListQuery {
|
||||
readonly mailboxId: string;
|
||||
/** 메일함 — 없으면 휴지통·스팸메일함을 뺀 모든 메일함에서 찾는다 (검색어가 있을 때만 — 계약 2절 "검색") */
|
||||
readonly mailboxId?: string;
|
||||
readonly limit: number;
|
||||
/** 불투명 커서 — 어댑터가 만들고 해석한다 */
|
||||
readonly cursor?: string;
|
||||
/** 자유 텍스트 검색어 */
|
||||
/** 자유 텍스트 검색어 (앞뒤 공백을 뺀 값) */
|
||||
readonly text?: string;
|
||||
}
|
||||
|
||||
@@ -147,6 +148,26 @@ export interface MessageUpdate {
|
||||
readonly mailboxId?: string;
|
||||
}
|
||||
|
||||
/** 새 메일 확인 (계약 2절 "새 메일 확인") — 메일함이나 메일이 하나라도 바뀌면 달라지는 불투명 값 */
|
||||
export interface MailState {
|
||||
readonly state: string;
|
||||
}
|
||||
|
||||
/** 여러 통을 바꾼 결과 (PATCH /api/messages) — 요청한 id 는 둘 중 한 곳에만 있다 */
|
||||
export interface MessagesPatchResult {
|
||||
readonly updatedIds: string[];
|
||||
/** 없음 · 그사이 지워짐 · 엔진이 거절함 */
|
||||
readonly failedIds: string[];
|
||||
}
|
||||
|
||||
/** 휴지통에서 영구 삭제한 결과 (POST /api/message-deletions) — 요청한 id 는 둘 중 한 곳에만 있다 */
|
||||
export interface MessageDeletionResult {
|
||||
/** 영구 삭제했다(휴지통에만 있었다) · 휴지통에서만 뺐다(다른 메일함에도 있었다) */
|
||||
readonly deletedIds: string[];
|
||||
/** 휴지통에 없음 · 없음 · 엔진이 거절 — 지우지 않았다 */
|
||||
readonly skippedIds: string[];
|
||||
}
|
||||
|
||||
export interface OutgoingAttachment {
|
||||
/** 업로드 응답의 id */
|
||||
readonly id: string;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Controller, Get, UseGuards } from '@nestjs/common';
|
||||
import { Controller, Get, Header, UseGuards } from '@nestjs/common';
|
||||
import { ApiCookieAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
|
||||
import {
|
||||
@@ -10,7 +10,11 @@ import {
|
||||
type AuthenticatedUser,
|
||||
} from '@/common/decorators/current-user.decorator';
|
||||
import { JwtAuthGuard } from '@/common/guards/jwt-auth.guard';
|
||||
import { MailboxPageDto, MailQuotaDto } from './dto/mail-response.dto';
|
||||
import {
|
||||
MailboxPageDto,
|
||||
MailQuotaDto,
|
||||
MailStateDto,
|
||||
} from './dto/mail-response.dto';
|
||||
import { MailService } from './mail.service';
|
||||
|
||||
@ApiTags('Mail')
|
||||
@@ -57,3 +61,32 @@ export class MailQuotaController {
|
||||
return this.mail.getQuota(user);
|
||||
}
|
||||
}
|
||||
|
||||
// 새 메일 확인 (계약 2절 "새 메일 확인") — 클라이언트가 화면이 보이는 동안 1분마다 묻는다.
|
||||
// 요청 수 한도는 다른 API 와 같다(SkipThrottle 하지 않는다). 요청마다 로그를 남기지 않는다.
|
||||
@ApiTags('Mail')
|
||||
@ApiCookieAuth()
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ApiErrorResponse('AUTH_001', '로그인 필요 (쿠키 없음·만료)')
|
||||
@ApiErrorResponse(
|
||||
'MAIL_001',
|
||||
'메일 엔진 장애 (엔진이 상태를 알려 주지 않음 포함)',
|
||||
)
|
||||
@Controller('mail-state')
|
||||
export class MailStateController {
|
||||
constructor(private readonly mail: MailService) {}
|
||||
|
||||
@Get()
|
||||
// 지난 값과 비교하는 값이라 브라우저·공유 캐시 어디에도 남기지 않는다
|
||||
@Header('Cache-Control', 'private, no-store')
|
||||
@ApiOperation({
|
||||
summary: '새 메일 확인 — 메일함·메일의 상태 값',
|
||||
description:
|
||||
'불투명 값이다 — 메일함이나 메일이 하나라도 바뀌면(새 메일 · 다른 창이나 메일 프로그램의 읽음 · 이동 · 삭제) 달라진다. ' +
|
||||
'해석하지 말고 지난 값과 같은지만 본다. 메일을 읽지 않는 가벼운 조회다. Cache-Control: private, no-store.',
|
||||
})
|
||||
@ApiStandardResponse(MailStateDto, { description: '지금 상태 값' })
|
||||
get(@CurrentUser() user: AuthenticatedUser) {
|
||||
return this.mail.getMailState(user);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
Post,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { ApiCookieAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
|
||||
import {
|
||||
ApiErrorResponse,
|
||||
ApiStandardResponse,
|
||||
} from '@/common/decorators/api-standard-response.decorator';
|
||||
import {
|
||||
CurrentUser,
|
||||
type AuthenticatedUser,
|
||||
} from '@/common/decorators/current-user.decorator';
|
||||
import { JwtAuthGuard } from '@/common/guards/jwt-auth.guard';
|
||||
import { MessageDeletionDto } from './dto/bulk-messages.dto';
|
||||
import { MessageDeletionResultDto } from './dto/mail-response.dto';
|
||||
import { MAX_BULK_MESSAGE_IDS } from './mail.constants';
|
||||
import { MailService } from './mail.service';
|
||||
|
||||
// 휴지통에서 영구 삭제 — 여러 통 (계약 2절 "여러 통 한꺼번에"). 휴지통 밖의 메일을 휴지통으로 옮기는 DELETE /api/messages/{id} 와 다르다 —
|
||||
// 휴지통에 있는 메일만 다루므로, 확인하는 사이 다른 창이 휴지통으로 옮긴 메일을 영구 삭제하지 않는다.
|
||||
@ApiTags('Mail')
|
||||
@ApiCookieAuth()
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ApiErrorResponse(
|
||||
'AUTH_001',
|
||||
'로그인 필요 (쿠키 없음·만료) — 클라이언트는 /auth/refresh 를 한 번 시도한다',
|
||||
)
|
||||
@Controller('message-deletions')
|
||||
export class MessageDeletionController {
|
||||
constructor(private readonly mail: MailService) {}
|
||||
|
||||
@Post()
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({
|
||||
summary: '휴지통에서 영구 삭제 (여러 통)',
|
||||
description:
|
||||
`ids 는 1~${MAX_BULK_MESSAGE_IDS}개. 휴지통에 있는 메일만 다룬다 — 휴지통에만 있으면 영구 삭제, 다른 메일함에도 있으면 휴지통에서만 뺀다. ` +
|
||||
'휴지통에 없는 메일 · 없는 메일 · 엔진이 거절한 메일은 지우지 않고 skippedIds 로 돌려준다. ' +
|
||||
'대상을 확인한 뒤 그 상태 그대로일 때만 한 번에 지운다(그사이 다른 창이 휴지통 밖으로 되돌린 메일을 지우지 않는다) — ' +
|
||||
'계속 어긋나면 아무것도 지우지 않고 503 MAIL_001 (다시 시도하면 된다).',
|
||||
})
|
||||
@ApiStandardResponse(MessageDeletionResultDto, {
|
||||
description: '지운 메일 · 지우지 않은 메일',
|
||||
})
|
||||
@ApiErrorResponse(
|
||||
'VAL_001',
|
||||
`ids 가 비었거나 ${MAX_BULK_MESSAGE_IDS}개 초과 · 같은 id 두 번 · 형식이 틀린 id(엔진이 해석하지 못하는 id 포함) — 지우지 않았다`,
|
||||
)
|
||||
@ApiErrorResponse(
|
||||
'MAIL_001',
|
||||
'확인하는 사이 계속 바뀜 · 휴지통 없음 — 지우지 않았다. 지우는 요청 중의 엔진 장애(시간 초과 등)면 일부만 지웠을 수 있다 — 다시 보내면 남은 것만 지운다',
|
||||
)
|
||||
remove(
|
||||
@CurrentUser() user: AuthenticatedUser,
|
||||
@Body() dto: MessageDeletionDto,
|
||||
) {
|
||||
return this.mail.deleteFromTrash(user, dto);
|
||||
}
|
||||
}
|
||||
@@ -40,6 +40,7 @@ import { buildAttachmentDisposition } from '@/shared/utils/content-disposition';
|
||||
import {
|
||||
MessageDetailDto,
|
||||
MessagePageDto,
|
||||
MessagesPatchedDto,
|
||||
MessageSummaryDto,
|
||||
SentMessageDto,
|
||||
} from './dto/mail-response.dto';
|
||||
@@ -48,9 +49,13 @@ import {
|
||||
MAX_BODY_HTML_LENGTH,
|
||||
MAX_BODY_LENGTH,
|
||||
MAX_BODY_VALUE_BYTES,
|
||||
MAX_BULK_MESSAGE_IDS,
|
||||
MAX_SEARCH_LENGTH,
|
||||
MAX_SEARCH_WORDS,
|
||||
MAX_SUBJECT_LENGTH,
|
||||
MESSAGES_ROUTE,
|
||||
} from './mail.constants';
|
||||
import { UpdateMessagesDto } from './dto/bulk-messages.dto';
|
||||
import { MessageListQueryDto } from './dto/message-list-query.dto';
|
||||
import { SendMessageDto } from './dto/send-message.dto';
|
||||
import { UpdateMessageDto } from './dto/update-message.dto';
|
||||
@@ -76,12 +81,21 @@ export class MessageController {
|
||||
|
||||
@Get()
|
||||
@ApiOperation({
|
||||
summary: '메일 목록 (커서 페이지네이션, 받은 시각 내림차순)',
|
||||
summary: '메일 목록 · 검색 (커서 페이지네이션, 받은 시각 내림차순)',
|
||||
description:
|
||||
'mailboxId 와 q 중 하나는 있어야 한다. q 만 있으면 휴지통·스팸메일함을 뺀 모든 메일함에서 찾는다. ' +
|
||||
'엔진의 전문 검색은 낱말이 통째로 같을 때만 맞으므로, 한글로 끝나는 낱말은 흔한 조사를 받침에 맞게 붙인 형태도 함께 찾는다 ' +
|
||||
'(낱말 안은 OR · 낱말끼리는 AND, 영문·숫자로 끝나면 두 가지를 다, 큰따옴표 구절에는 붙이지 않는다). ' +
|
||||
'낱말 앞뒤의 문장부호는 떼고 찾는다(회의, → 회의). 문자·숫자가 없는 낱말·구절(- · … 등)은 빼고 찾으며, 찾을 낱말이 하나도 없으면 빈 목록이다(범위 전체를 돌려주지 않는다). ' +
|
||||
'붙여 쓴 말의 일부 · 앞부분만 친 말 · 제목 맨 앞의 말머리([공지] · Re:)는 찾지 못한다. 발송 중인 내부 초안은 빠진다.',
|
||||
})
|
||||
@ApiStandardResponse(MessagePageDto, {
|
||||
description: '메일 목록 (total 포함)',
|
||||
})
|
||||
@ApiErrorResponse('VAL_001', 'mailboxId 누락·잘못된 커서·limit 범위 초과')
|
||||
@ApiErrorResponse(
|
||||
'VAL_001',
|
||||
`mailboxId 와 q 가 둘 다 없음 · q 가 ${MAX_SEARCH_LENGTH}자 초과 또는 낱말 ${MAX_SEARCH_WORDS}개 초과 · 다른 메일함·검색어의 커서 · limit 범위 초과`,
|
||||
)
|
||||
@ApiErrorResponse(
|
||||
'RES_001',
|
||||
'없는 메일함 (엔진이 해석하지 못하는 mailboxId 포함)',
|
||||
@@ -94,6 +108,33 @@ export class MessageController {
|
||||
return this.mail.listMessages(user, query);
|
||||
}
|
||||
|
||||
@Patch()
|
||||
@ApiOperation({
|
||||
summary: '여러 통의 읽음 · 중요 표시 · 메일함 이동 (하나 이상 지정)',
|
||||
description:
|
||||
`ids 는 1~${MAX_BULK_MESSAGE_IDS}개. 메일마다의 변경은 한 통 PATCH 와 같다(이동 = 그 메일함 하나에만). 엔진 요청 한 번으로 처리한다. ` +
|
||||
'바꾸지 못한 메일(없음 · 그사이 지워짐 · 엔진이 거절)은 failedIds 로 돌려주고 나머지는 그대로 처리한다. ' +
|
||||
'없는 mailboxId 면 아무것도 바꾸지 않고 404 RES_001. 503 MAIL_001 이면 일부만 바뀌었을 수 있다 — 같은 요청을 다시 보내도 결과가 같다(멱등).',
|
||||
})
|
||||
@ApiStandardResponse(MessagesPatchedDto, {
|
||||
description: '바꾼 메일 · 바꾸지 못한 메일',
|
||||
})
|
||||
@ApiErrorResponse(
|
||||
'VAL_001',
|
||||
`빈 변경 · ids 가 비었거나 ${MAX_BULK_MESSAGE_IDS}개 초과 · 같은 id 두 번 · 형식이 틀린 id(엔진이 해석하지 못하는 id 포함 — 아무것도 바꾸지 않았다)`,
|
||||
)
|
||||
@ApiErrorResponse('RES_001', '없는 메일함으로 이동 — 아무것도 바꾸지 않았다')
|
||||
@ApiErrorResponse(
|
||||
'MAIL_001',
|
||||
'메일 엔진 장애 — 일부만 바뀌었을 수 있다 (같은 요청을 다시 보내면 된다)',
|
||||
)
|
||||
updateMany(
|
||||
@CurrentUser() user: AuthenticatedUser,
|
||||
@Body() dto: UpdateMessagesDto,
|
||||
) {
|
||||
return this.mail.updateMessages(user, dto);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@ApiOperation({
|
||||
summary: '메일 상세 — 조회만으로 읽음 처리하지 않는다',
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { maskEmail, maskUrlForLog } from './mask';
|
||||
|
||||
describe('maskUrlForLog (로그에 남길 요청 주소)', () => {
|
||||
it('검색어(q)는 값 대신 디코딩한 글자 수만 남기고, 경로와 나머지 쿼리는 그대로 둔다', () => {
|
||||
it('검색어(q)와 커서(cursor)는 값 대신 디코딩한 글자 수만 남기고, 경로와 나머지 쿼리는 그대로 둔다', () => {
|
||||
expect(
|
||||
maskUrlForLog(
|
||||
'/api/recipient-suggestions?q=%EB%8F%84%EC%9C%A4&limit=20&cursor=eyJ2Ijox',
|
||||
),
|
||||
).toBe('/api/recipient-suggestions?q=<2자>&limit=20&cursor=eyJ2Ijox');
|
||||
).toBe('/api/recipient-suggestions?q=<2자>&limit=20&cursor=<8자>');
|
||||
// + 는 공백 · 주소 형태의 검색어
|
||||
expect(
|
||||
maskUrlForLog(
|
||||
@@ -15,22 +15,43 @@ describe('maskUrlForLog (로그에 남길 요청 주소)', () => {
|
||||
).toBe('/api/messages?mailboxId=a&q=<26자>');
|
||||
});
|
||||
|
||||
it('q 가 여럿이어도, 이름이 인코딩·대문자·배열 표기여도 모두 가린다', () => {
|
||||
it('커서는 검색어의 해시(범위 해시)를 담으므로 검색 없는 목록의 커서도 가린다 — 메일함 id 는 그대로', () => {
|
||||
expect(
|
||||
maskUrlForLog(
|
||||
'/api/messages?mailboxId=a&limit=20&cursor=eyJ2IjoxLCJhIjoibTIiLCJwIjoyLCJzIjoiaDBLUHhTS0FQVEVHWG52TyJ9',
|
||||
),
|
||||
).toBe('/api/messages?mailboxId=a&limit=20&cursor=<60자>');
|
||||
});
|
||||
|
||||
it('q · cursor 가 여럿이어도, 이름이 인코딩·대문자·배열 표기여도 모두 가린다', () => {
|
||||
expect(
|
||||
maskUrlForLog('/api/x?q=a&limit=1&q=%EA%B9%80%EC%95%84%EB%9D%BC'),
|
||||
).toBe('/api/x?q=<1자>&limit=1&q=<3자>');
|
||||
expect(
|
||||
maskUrlForLog('/api/x?%71=secret&Q=secret&q[]=secret&q%5B0%5D=secret'),
|
||||
).toBe('/api/x?%71=<6자>&Q=<6자>&q[]=<6자>&q%5B0%5D=<6자>');
|
||||
expect(
|
||||
maskUrlForLog(
|
||||
'/api/x?CURSOR=abc&cursor[]=abcd&%63ursor=ab&cursor%5B0%5D=a&cursor=x&cursor=yz',
|
||||
),
|
||||
).toBe(
|
||||
'/api/x?CURSOR=<3자>&cursor[]=<4자>&%63ursor=<2자>&cursor%5B0%5D=<1자>&cursor=<1자>&cursor=<2자>',
|
||||
);
|
||||
});
|
||||
|
||||
it('q 가 없거나 쿼리가 없으면 그대로 · 값이 비었거나 = 가 없으면 0자 · 잘못된 인코딩은 원문 길이', () => {
|
||||
it('q · cursor 가 없거나 쿼리가 없으면 그대로 · 값이 비었거나 = 가 없으면 0자 · 잘못된 인코딩은 원문 길이', () => {
|
||||
expect(maskUrlForLog('/api/users/1')).toBe('/api/users/1');
|
||||
expect(maskUrlForLog('/api/messages?mailboxId=a&limit=20')).toBe(
|
||||
'/api/messages?mailboxId=a&limit=20',
|
||||
);
|
||||
// q 로 시작하는 다른 이름은 가리지 않는다
|
||||
// q · cursor 로 시작하거나 끝나는 다른 이름은 가리지 않는다
|
||||
expect(maskUrlForLog('/api/x?query=1&qq=2')).toBe('/api/x?query=1&qq=2');
|
||||
expect(maskUrlForLog('/api/x?cursors=1&nextCursor=2&mycursor=3')).toBe(
|
||||
'/api/x?cursors=1&nextCursor=2&mycursor=3',
|
||||
);
|
||||
expect(maskUrlForLog('/api/x?cursor=&cursor')).toBe(
|
||||
'/api/x?cursor=<0자>&cursor=<0자>',
|
||||
);
|
||||
expect(maskUrlForLog('/api/x?q=&q')).toBe('/api/x?q=<0자>&q=<0자>');
|
||||
expect(maskUrlForLog('/api/x?q=%E0%A4%A')).toBe('/api/x?q=<8자>');
|
||||
// 값 안의 = 도 값이다
|
||||
|
||||
@@ -9,10 +9,13 @@
|
||||
const MASK = '***';
|
||||
|
||||
/**
|
||||
* 값을 남기지 않는 쿼리 이름 — 검색어. 받는 사람 추천의 q 에는 사람 이름·주소가 들어간다 (계약 2절 "로그에는 개수만").
|
||||
* 값을 남기지 않는 쿼리 이름 — 검색어(q)와 목록 커서(cursor).
|
||||
* - q: 메일 검색어 · 받는 사람 추천의 거르는 글자 — 사람 이름·주소가 들어간다 (계약 2절 "검색" · "로그에는 개수만")
|
||||
* - cursor: 다른 목록의 커서를 막으려고 검색어의 해시(범위 해시)를 담는다 — 로그를 읽는 사람이 후보 검색어를 대입해 맞춰 볼 수 있다
|
||||
* (modules/mail/jmap/jmap-cursor.ts · recipient-suggestion-cursor.ts)
|
||||
* 대소문자와 배열 표기(`q[]`·`q[0]`)도 같은 이름으로 본다 — 형식 오류(VAL_001)로 거절된 요청도 로그에는 남는다.
|
||||
*/
|
||||
const REDACTED_QUERY_NAME = /^q(?:\[[^\]]*\])*$/i;
|
||||
const REDACTED_QUERY_NAME = /^(?:q|cursor)(?:\[[^\]]*\])*$/i;
|
||||
|
||||
/** 쿼리 조각을 디코딩한다 (`+` 는 공백) — 잘못된 퍼센트 인코딩이면 원문 그대로 */
|
||||
function decodeQueryPart(part: string): string {
|
||||
@@ -39,10 +42,10 @@ export function maskEmail(email: string): string {
|
||||
}
|
||||
|
||||
/**
|
||||
* 로그에 남길 요청 주소 — 검색어(q)는 값 대신 길이만 남기고, 경로와 나머지 쿼리는 그대로 둔다.
|
||||
* 로그에 남길 요청 주소 — 검색어(q)와 커서(cursor)는 값 대신 길이만 남기고, 경로와 나머지 쿼리는 그대로 둔다.
|
||||
* 길이는 디코딩한 값의 JS 문자열 길이다 (클라이언트가 세는 방법과 같다).
|
||||
*
|
||||
* 예) '/api/recipient-suggestions?q=%EB%8F%84%EC%9C%A4&limit=20' → '/api/recipient-suggestions?q=<2자>&limit=20'
|
||||
* 예) '/api/recipient-suggestions?q=%EB%8F%84%EC%9C%A4&limit=20&cursor=eyJ2Ijox' → '/api/recipient-suggestions?q=<2자>&limit=20&cursor=<8자>'
|
||||
*/
|
||||
export function maskUrlForLog(url: string): string {
|
||||
const queryStart = url.indexOf('?');
|
||||
|
||||
@@ -17,6 +17,9 @@ import { AUTH_SETTINGS, type AuthSettings } from '@/modules/auth/auth.settings';
|
||||
import {
|
||||
MAX_BODY_HTML_LENGTH,
|
||||
MAX_BODY_LENGTH,
|
||||
MAX_BULK_MESSAGE_IDS,
|
||||
MAX_SEARCH_LENGTH,
|
||||
MAX_SEARCH_WORDS,
|
||||
MAX_SUBJECT_LENGTH,
|
||||
} from '@/modules/mail/mail.constants';
|
||||
import { MAIL_PROVIDER } from '@/modules/mail/mail.provider';
|
||||
@@ -25,6 +28,10 @@ import type {
|
||||
Correspondence,
|
||||
DraftContent,
|
||||
DraftSource,
|
||||
MessageDeletionResult,
|
||||
MessageListQuery,
|
||||
MessagesPatchResult,
|
||||
MessageUpdate,
|
||||
OutgoingMessage,
|
||||
SavedDraft,
|
||||
} from '@/modules/mail/mail.types';
|
||||
@@ -100,6 +107,15 @@ const SLOW_ACCOUNT = 'slow.target@e2e.arasystem.test';
|
||||
/** 대역 메일 엔진이 "다른 창·기기에서 먼저 바뀌었다"고 답하는 임시 저장본 id (409 MAIL_008 경로) */
|
||||
const DRAFT_CHANGED_ELSEWHERE = 'draftchangedelsewhere';
|
||||
|
||||
/** 대역 메일 엔진이 돌려주는 새 메일 확인 값 */
|
||||
const MAIL_STATE = 'c2Iy.c2Fq';
|
||||
|
||||
/** 대역 메일 엔진이 "없는 메일함"으로 답하는 이동 대상 (404 RES_001 경로) */
|
||||
const MISSING_MAILBOX = 'nomailbox';
|
||||
|
||||
/** 대역 메일 엔진이 "그사이 지워졌다"고 답하는 메일 id (failedIds · skippedIds 경로) */
|
||||
const GONE_MESSAGE = 'gonemessage';
|
||||
|
||||
/** 대역 메일 엔진이 로그인 때 알려 주는 이름 — 없으면 EMPLOYEE.name */
|
||||
const PROFILE_NAMES = new Map([
|
||||
['colleague.a@e2e.arasystem.test', '가나다'],
|
||||
@@ -191,6 +207,16 @@ describe('응답 계약 (e2e)', () => {
|
||||
/** 대역 엔진이 돌려줄 주고받은 주소 (직원별) */
|
||||
const correspondents = new Map<string, Correspondence[]>();
|
||||
|
||||
/** 대역 엔진이 받은 메일 목록 요청 (검색 범위·검색어 확인용) */
|
||||
const listQueries: { user: string; query: MessageListQuery }[] = [];
|
||||
/** 대역 엔진이 받은 여러 통 바꾸기 · 휴지통에서 영구 삭제 */
|
||||
const bulkUpdates: {
|
||||
user: string;
|
||||
ids: readonly string[];
|
||||
update: MessageUpdate;
|
||||
}[] = [];
|
||||
const trashDeletions: { user: string; ids: readonly string[] }[] = [];
|
||||
|
||||
/** 이 앱의 서명 키로 토큰을 만든다 (CI 처럼 JWT_SECRET 이 없으면 임시 키) */
|
||||
const sign = (payload: object, expiresIn: number) =>
|
||||
jwt.sign(payload, {
|
||||
@@ -199,6 +225,10 @@ describe('응답 계약 (e2e)', () => {
|
||||
expiresIn,
|
||||
});
|
||||
|
||||
/** 그 직원의 액세스 쿠키 (세션 없이 서명만 맞춘다 — 보호 라우트는 액세스 토큰만 본다) */
|
||||
const cookieOf = (email: string) =>
|
||||
`access_token=${sign({ sub: email, typ: 'access', name: '테스트 직원' }, 600)}`;
|
||||
|
||||
/** 로그인해서 두 쿠키 값을 받는다 (서버에 세션이 생긴다) */
|
||||
const login = async (email: string) => {
|
||||
const res = await request(server)
|
||||
@@ -301,6 +331,45 @@ describe('응답 계약 (e2e)', () => {
|
||||
},
|
||||
listCorrespondents: (user: string) =>
|
||||
Promise.resolve(correspondents.get(user) ?? []),
|
||||
// 3단계 — 엔진 쪽 판단(검색 조건 · 조건부 영구 삭제)은 jmap-mail.provider.spec.ts 가 본다. 여기서는 받은 값만 기록한다
|
||||
getMailState: () => Promise.resolve({ state: MAIL_STATE }),
|
||||
listMessages: (user: string, query: MessageListQuery) => {
|
||||
listQueries.push({ user, query });
|
||||
return Promise.resolve({
|
||||
items: [],
|
||||
hasNext: false,
|
||||
nextCursor: null,
|
||||
total: 0,
|
||||
});
|
||||
},
|
||||
updateMessages: (
|
||||
user: string,
|
||||
ids: readonly string[],
|
||||
update: MessageUpdate,
|
||||
): Promise<MessagesPatchResult> => {
|
||||
bulkUpdates.push({ user, ids, update });
|
||||
if (update.mailboxId === MISSING_MAILBOX) {
|
||||
return Promise.reject(
|
||||
new BusinessException('RES_001', {
|
||||
context: { reason: 'e2e-mailbox-not-found' },
|
||||
}),
|
||||
);
|
||||
}
|
||||
return Promise.resolve({
|
||||
updatedIds: ids.filter((id) => id !== GONE_MESSAGE),
|
||||
failedIds: ids.filter((id) => id === GONE_MESSAGE),
|
||||
});
|
||||
},
|
||||
deleteFromTrash: (
|
||||
user: string,
|
||||
ids: readonly string[],
|
||||
): Promise<MessageDeletionResult> => {
|
||||
trashDeletions.push({ user, ids });
|
||||
return Promise.resolve({
|
||||
deletedIds: ids.filter((id) => id !== GONE_MESSAGE),
|
||||
skippedIds: ids.filter((id) => id === GONE_MESSAGE),
|
||||
});
|
||||
},
|
||||
})
|
||||
.overrideProvider(MAIL_SETTINGS)
|
||||
.useValue(TEST_MAIL_SETTINGS)
|
||||
@@ -442,7 +511,7 @@ describe('응답 계약 (e2e)', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('다른 API 는 그대로 1MB — 같은 2MB 본문이 로그인·메일 수정(PATCH /api/messages/:id)에서는 413 FILE_002', async () => {
|
||||
it('다른 API 는 그대로 1MB — 같은 2MB 본문이 로그인·메일 수정(PATCH /api/messages/:id · 여러 통 PATCH)·영구 삭제에서는 413 FILE_002', async () => {
|
||||
const twoMegabytes = JSON.stringify({
|
||||
email: 'x',
|
||||
password: 'y'.repeat(2_000_000),
|
||||
@@ -451,6 +520,8 @@ describe('응답 계약 (e2e)', () => {
|
||||
for (const [method, url] of [
|
||||
['post', '/api/auth/login'],
|
||||
['patch', '/api/messages/eaaaaab'],
|
||||
['patch', '/api/messages'],
|
||||
['post', '/api/message-deletions'],
|
||||
] as const) {
|
||||
const res = await request(server)
|
||||
[method](url)
|
||||
@@ -677,6 +748,10 @@ describe('응답 계약 (e2e)', () => {
|
||||
'/api/mail-signature/images/3f1c0a2e-0f1b-4f2a-9a1e-6c9f0a1b2c3d',
|
||||
],
|
||||
['get', '/api/recipient-suggestions'],
|
||||
['get', '/api/mail-state'],
|
||||
['get', '/api/messages?q=%ED%9A%8C%EC%9D%98'],
|
||||
['patch', '/api/messages'],
|
||||
['post', '/api/message-deletions'],
|
||||
// 템플릿 업로드 예제도 로그인한 직원만 쓴다
|
||||
['post', '/api/files'],
|
||||
] as const)('쿠키 없이 %s %s 는 401 AUTH_001', async (method, path) => {
|
||||
@@ -763,9 +838,6 @@ describe('응답 계약 (e2e)', () => {
|
||||
* 엔진 쪽 판단(초안인지·옛것이 그사이 없어졌는지)은 대역 엔진이 흉내 낸다 — 여기서는 HTTP 형식·정화·DB·파일을 본다.
|
||||
*/
|
||||
describe('2단계 — 임시 저장 · 서명 · 받는 사람 추천', () => {
|
||||
/** 그 직원의 액세스 쿠키 (세션 없이 서명만 맞춘다 — 보호 라우트는 액세스 토큰만 본다) */
|
||||
const cookieOf = (email: string) =>
|
||||
`access_token=${sign({ sub: email, typ: 'access', name: '테스트 직원' }, 600)}`;
|
||||
const UUID =
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
|
||||
const DOYUN = { name: '이도윤', email: 'doyun.lee@e2e.arasystem.test' };
|
||||
@@ -1395,6 +1467,208 @@ describe('응답 계약 (e2e)', () => {
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* 3단계 — 새 메일 확인 · 검색 · 여러 통 한꺼번에 (계약 2절).
|
||||
* 엔진 쪽 판단(조사를 붙인 검색 조건 · 휴지통·스팸메일함 빼기 · 조건부 영구 삭제)은 jmap-mail.provider.spec.ts 가 본다 —
|
||||
* 여기서는 라우트 · 검증(VAL_001) · 상태 코드 · 응답 형식 · 헤더를 본다.
|
||||
*/
|
||||
describe('3단계 — 새 메일 확인 · 검색 · 여러 통', () => {
|
||||
const ME = 'bulk.me@e2e.arasystem.test';
|
||||
|
||||
describe('새 메일 확인 (GET /api/mail-state)', () => {
|
||||
it('불투명 상태 값을 표준 래퍼로 — Cache-Control: private, no-store', async () => {
|
||||
const res = await request(server)
|
||||
.get('/api/mail-state')
|
||||
.set('Cookie', cookieOf(ME))
|
||||
.expect(200);
|
||||
|
||||
expect(body(res)).toEqual({
|
||||
success: true,
|
||||
data: { state: MAIL_STATE },
|
||||
});
|
||||
expect(res.headers['cache-control']).toBe('private, no-store');
|
||||
});
|
||||
});
|
||||
|
||||
describe('검색 (GET /api/messages 의 q)', () => {
|
||||
const list = (params: Record<string, string | number>) =>
|
||||
request(server)
|
||||
.get('/api/messages')
|
||||
.query(params)
|
||||
.set('Cookie', cookieOf(ME));
|
||||
|
||||
it('q 만 있으면 메일함 없이(모든 메일함 검색) 앞뒤 공백을 뺀 검색어로 묻는다 — 목록 응답 형식 그대로', async () => {
|
||||
const before = listQueries.length;
|
||||
|
||||
const res = await list({ q: ' 회의 일정 ', limit: 20 }).expect(200);
|
||||
|
||||
expect(dataOf<unknown>(res)).toEqual({
|
||||
items: [],
|
||||
pageInfo: { hasNext: false, nextCursor: null, total: 0 },
|
||||
});
|
||||
expect(listQueries.slice(before)).toEqual([
|
||||
{
|
||||
user: ME,
|
||||
query: {
|
||||
mailboxId: undefined,
|
||||
limit: 20,
|
||||
cursor: undefined,
|
||||
text: '회의 일정',
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('메일함과 검색어를 함께 주면 그 메일함에서 찾는다 · 메일함만 주면 지금처럼 목록', async () => {
|
||||
const before = listQueries.length;
|
||||
|
||||
await list({ mailboxId: 'a', q: '"회의를 잡겠습니다"' }).expect(200);
|
||||
await list({ mailboxId: 'a' }).expect(200);
|
||||
|
||||
expect(
|
||||
listQueries
|
||||
.slice(before)
|
||||
.map(({ query }) => [query.mailboxId, query.text]),
|
||||
).toEqual([
|
||||
['a', '"회의를 잡겠습니다"'],
|
||||
['a', undefined],
|
||||
]);
|
||||
});
|
||||
|
||||
it(`메일함도 검색어도 없음 · 공백뿐인 검색어 · ${MAX_SEARCH_LENGTH}자 초과 · 낱말 ${MAX_SEARCH_WORDS}개 초과 · 같은 이름 두 번은 400 VAL_001 — 엔진에 묻지 않는다`, async () => {
|
||||
const before = listQueries.length;
|
||||
const words = (count: number) =>
|
||||
Array.from({ length: count }, (_, index) => `w${index}`).join(' ');
|
||||
|
||||
const rejected: Record<string, string>[] = [
|
||||
{},
|
||||
{ q: ' ' },
|
||||
{ q: '가'.repeat(MAX_SEARCH_LENGTH + 1) },
|
||||
{ q: words(MAX_SEARCH_WORDS + 1) },
|
||||
{ mailboxId: '../a', q: '회의' },
|
||||
];
|
||||
for (const params of rejected) {
|
||||
const res = await list(params).expect(400);
|
||||
expect(body(res).error?.code).toBe('VAL_001');
|
||||
}
|
||||
const twice = await request(server)
|
||||
.get('/api/messages?q=a&q=b')
|
||||
.set('Cookie', cookieOf(ME))
|
||||
.expect(400);
|
||||
expect(body(twice).error?.code).toBe('VAL_001');
|
||||
|
||||
// 한도 안은 묻는다 — 앞뒤 공백은 길이에 넣지 않는다
|
||||
await list({
|
||||
q: ` ${'가'.repeat(MAX_SEARCH_LENGTH)} `,
|
||||
}).expect(200);
|
||||
await list({ q: words(MAX_SEARCH_WORDS) }).expect(200);
|
||||
expect(listQueries.length - before).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('여러 통 바꾸기 (PATCH /api/messages)', () => {
|
||||
const patch = (payload: object) =>
|
||||
request(server)
|
||||
.patch('/api/messages')
|
||||
.set('Cookie', cookieOf(ME))
|
||||
.send(payload);
|
||||
|
||||
it('200 { updatedIds, failedIds } — 바꾸지 못한 메일은 failedIds, 한 통 PATCH 경로와 따로 논다', async () => {
|
||||
const res = await patch({
|
||||
ids: ['eaaaaab', GONE_MESSAGE],
|
||||
isRead: true,
|
||||
}).expect(200);
|
||||
|
||||
expect(body(res)).toEqual({
|
||||
success: true,
|
||||
data: { updatedIds: ['eaaaaab'], failedIds: [GONE_MESSAGE] },
|
||||
});
|
||||
expect(bulkUpdates.at(-1)).toEqual({
|
||||
user: ME,
|
||||
ids: ['eaaaaab', GONE_MESSAGE],
|
||||
update: { isRead: true, isFlagged: undefined, mailboxId: undefined },
|
||||
});
|
||||
});
|
||||
|
||||
it('없는 메일함으로 옮기면 404 RES_001', async () => {
|
||||
const res = await patch({
|
||||
ids: ['eaaaaab'],
|
||||
mailboxId: MISSING_MAILBOX,
|
||||
}).expect(404);
|
||||
|
||||
expect(body(res).error?.code).toBe('RES_001');
|
||||
});
|
||||
|
||||
it(`ids 없음 · 빈 ids · ${MAX_BULK_MESSAGE_IDS}개 초과 · 같은 id 두 번 · 형식이 틀린 id · 바꿀 것 없음 · 모르는 필드는 400 VAL_001 — 엔진에 묻지 않는다`, async () => {
|
||||
const before = bulkUpdates.length;
|
||||
const many = Array.from(
|
||||
{ length: MAX_BULK_MESSAGE_IDS + 1 },
|
||||
(_, index) => `m${index}`,
|
||||
);
|
||||
|
||||
for (const payload of [
|
||||
{ isRead: true },
|
||||
{ ids: [], isRead: true },
|
||||
{ ids: many, isRead: true },
|
||||
{ ids: ['eaaaaab', 'eaaaaab'], isRead: true },
|
||||
{ ids: ['../eaaaaab'], isRead: true },
|
||||
{ ids: ['eaaaaab'] },
|
||||
{ ids: ['eaaaaab'], isRead: null },
|
||||
{ ids: ['eaaaaab'], isRead: true, isDeleted: true },
|
||||
]) {
|
||||
const res = await patch(payload).expect(400);
|
||||
expect(body(res).error?.code).toBe('VAL_001');
|
||||
}
|
||||
expect(bulkUpdates).toHaveLength(before);
|
||||
});
|
||||
});
|
||||
|
||||
describe('휴지통에서 영구 삭제 (POST /api/message-deletions)', () => {
|
||||
const remove = (payload: object) =>
|
||||
request(server)
|
||||
.post('/api/message-deletions')
|
||||
.set('Cookie', cookieOf(ME))
|
||||
.send(payload);
|
||||
|
||||
it('201 이 아니라 200 { deletedIds, skippedIds }', async () => {
|
||||
const res = await remove({ ids: ['eaaaaab', GONE_MESSAGE] }).expect(
|
||||
200,
|
||||
);
|
||||
|
||||
expect(body(res)).toEqual({
|
||||
success: true,
|
||||
data: { deletedIds: ['eaaaaab'], skippedIds: [GONE_MESSAGE] },
|
||||
});
|
||||
expect(trashDeletions.at(-1)).toEqual({
|
||||
user: ME,
|
||||
ids: ['eaaaaab', GONE_MESSAGE],
|
||||
});
|
||||
});
|
||||
|
||||
it(`ids 없음 · 빈 ids · ${MAX_BULK_MESSAGE_IDS}개 초과 · 같은 id 두 번 · 형식이 틀린 id · 모르는 필드는 400 VAL_001 — 지우지 않는다`, async () => {
|
||||
const before = trashDeletions.length;
|
||||
|
||||
for (const payload of [
|
||||
{},
|
||||
{ ids: [] },
|
||||
{
|
||||
ids: Array.from(
|
||||
{ length: MAX_BULK_MESSAGE_IDS + 1 },
|
||||
(_, index) => `m${index}`,
|
||||
),
|
||||
},
|
||||
{ ids: ['eaaaaab', 'eaaaaab'] },
|
||||
{ ids: ['eaaaaab', 'a b'] },
|
||||
{ ids: ['eaaaaab'], permanent: true },
|
||||
]) {
|
||||
const res = await remove(payload).expect(400);
|
||||
expect(body(res).error?.code).toBe('VAL_001');
|
||||
}
|
||||
expect(trashDeletions).toHaveLength(before);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* 출처 검사(CSRF) — 상태를 바꾸는 요청은 Sec-Fetch-Site(same-origin·none) 또는 허용 목록의 Origin 이어야 한다.
|
||||
* 둘 다 없으면 브라우저가 아닌 클라이언트로 보고 통과시킨다 (docs/mail/api-contract.md 머리말).
|
||||
@@ -1419,7 +1693,9 @@ describe('응답 계약 (e2e)', () => {
|
||||
['post', '/api/auth/refresh'],
|
||||
['post', '/api/messages'],
|
||||
['patch', '/api/messages/eaaaaab'],
|
||||
['patch', '/api/messages'],
|
||||
['delete', '/api/messages/eaaaaab'],
|
||||
['post', '/api/message-deletions'],
|
||||
['post', '/api/mail-attachments'],
|
||||
] as const)(
|
||||
'Sec-Fetch-Site 없이 허용 목록에 없는 Origin 이면 %s %s 도 403 AUTH_003 (로그인 검사보다 먼저)',
|
||||
|
||||
@@ -0,0 +1,449 @@
|
||||
<!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}
|
||||
.ml-grid{display:grid;grid-template-columns:32px 32px 168px minmax(0,1fr) 84px 64px;column-gap:12px;align-items:center}
|
||||
.ml-inner{min-width:440px}
|
||||
.ml-row{position:relative}
|
||||
.ml-row:hover{background:#F3F8F8}
|
||||
.ml-row:focus-within{background:#E3F2F3}
|
||||
.ml-row.is-sel{background:#E8F4F5}
|
||||
.ml-row.is-sel:hover{background:#DDEFF0}
|
||||
.ml-link::after{content:"";position:absolute;inset:0}
|
||||
.ml-ctl{position:relative;z-index:1}
|
||||
.ghost{background:transparent;color:#344054}
|
||||
.ghost:hover{background:#F2F4F7;color:#171C26}
|
||||
.danger{background:transparent;color:#B42318}
|
||||
.danger:hover{background:#FEF3F2;color:#912018}
|
||||
.menu-item:hover{background:#F2F4F7}
|
||||
.sr{position:absolute;width:1px;height:1px;margin:-1px;padding:0;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap;border:0}
|
||||
@media (max-width:1099px){.ml-grid{grid-template-columns:32px 32px 168px minmax(0,1fr) 84px}.ml-size{display:none}.tb-label{display:none}}
|
||||
@media (max-width:599px){.ml-grid{grid-template-columns:32px 32px 112px minmax(0,1fr) 72px}}
|
||||
</style>
|
||||
</helmet>
|
||||
<div style="min-height: 100vh; display: flex; background: #FFFFFF; color: #171C26; font-family: Pretendard, -apple-system, 'Apple SD Gothic Neo', 'Malgun Gothic', system-ui, sans-serif; font-size: 13px; line-height: 1.5">
|
||||
|
||||
<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="Main.dc.html" 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="MainList.dc.html" 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"><rect x="2" y="7" width="20" height="14" rx="2" ry="2"></rect><path d="M16 21V5a2 2 0 0 0-2-2h-4a2 2 0 0 0-2 2v16"></path></svg>
|
||||
<span>ERP</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="Settings.dc.html" 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">
|
||||
<h1 style="margin: 0; font-size: 16px; font-weight: 700; letter-spacing: -0.01em">메일</h1>
|
||||
<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 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">
|
||||
</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-size: 13px; 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>
|
||||
<a href="Login.dc.html" style="display: inline-flex; align-items: center; gap: 6px; min-height: 44px; padding: 0 10px; border-radius: 8px; color: #4A5363; font-weight: 500; text-decoration: none">
|
||||
<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>
|
||||
</a>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div style="flex: 1 1 auto; display: flex; flex-wrap: wrap; align-items: stretch">
|
||||
|
||||
<nav aria-label="메일함" style="flex: 1 1 208px; box-sizing: border-box; display: flex; flex-direction: column; gap: 14px; padding: 14px 10px; background: #F8F9FB; border-right: 1px solid #E6E8EC">
|
||||
<a href="Compose.dc.html" 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>
|
||||
<ul style="list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 1px">
|
||||
<sc-for list="{{ boxes }}" as="b" hint-placeholder-count="5">
|
||||
<li>
|
||||
<button type="button" onClick="{{ b.pick }}" aria-current="{{ b.current }}" style="width: 100%; display: flex; align-items: center; gap: 10px; min-height: 40px; box-sizing: border-box; padding: 0 10px; border: 0; border-radius: 7px; background: {{ b.bg }}; color: {{ b.fg }}; font: inherit; font-weight: {{ b.fw }}; text-align: left; cursor: pointer">
|
||||
<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"><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"><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.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"><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"><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"><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">{{ b.name }}</span>
|
||||
<sc-if value="{{ b.hasUnread }}" hint-placeholder-val="{{ false }}"><span style="font-size: 12px; font-weight: 600; color: {{ b.countColor }}; font-variant-numeric: tabular-nums">{{ b.unread }}</span></sc-if>
|
||||
</button>
|
||||
</li>
|
||||
</sc-for>
|
||||
</ul>
|
||||
<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>
|
||||
|
||||
<main style="flex: 999 1 560px; min-width: 0; display: flex; flex-direction: column">
|
||||
<section aria-labelledby="list-title" style="display: flex; flex-direction: column">
|
||||
|
||||
<sc-if value="{{ noPick }}" hint-placeholder-val="{{ false }}">
|
||||
<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">
|
||||
<h2 id="list-title" style="margin: 0; font-size: 15px; font-weight: 700; letter-spacing: -0.01em">{{ boxName }}</h2>
|
||||
<span style="font-size: 12px; color: #667085; font-variant-numeric: tabular-nums">{{ countText }}</span>
|
||||
<div style="margin-left: auto; display: inline-flex; gap: 2px; padding: 2px; border: 1px solid #D0D5DD; border-radius: 8px; background: #FFFFFF">
|
||||
<a href="Main.dc.html" aria-label="분할 보기" 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; text-decoration: none">
|
||||
<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>
|
||||
</a>
|
||||
<button type="button" aria-pressed="true" aria-label="목록 보기" style="display: inline-flex; align-items: center; gap: 4px; min-height: 30px; padding: 0 8px; border: 0; border-radius: 6px; background: #E3F2F3; color: #0A5F67; font: inherit; font-size: 12px; 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="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>
|
||||
</button>
|
||||
</div>
|
||||
<button type="button" aria-label="새로고침" style="width: 40px; height: 44px; display: inline-flex; align-items: center; justify-content: center; border: 0; border-radius: 8px; background: transparent; color: #4A5363; cursor: pointer">
|
||||
<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>
|
||||
</button>
|
||||
</div>
|
||||
</sc-if>
|
||||
|
||||
<sc-if value="{{ hasPick }}" hint-placeholder-val="{{ true }}">
|
||||
<div role="toolbar" aria-label="고른 메일" style="position: relative; display: flex; flex-wrap: wrap; align-items: center; gap: 2px; min-height: 52px; box-sizing: border-box; padding: 4px 8px 4px 20px; border-bottom: 1px solid #E6E8EC; background: #F3F8F8">
|
||||
<h2 id="list-title" style="margin: 0 10px 0 0; font-size: 14px; font-weight: 700; font-variant-numeric: tabular-nums">{{ pickText }}</h2>
|
||||
<button type="button" class="ghost" onClick="{{ markRead }}" title="읽음 표시" style="display: inline-flex; align-items: center; gap: 6px; min-height: 44px; min-width: 40px; justify-content: center; padding: 0 10px; border: 0; border-radius: 8px; 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="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>
|
||||
<span class="tb-label">읽음 표시</span>
|
||||
</button>
|
||||
<button type="button" class="ghost" onClick="{{ markUnread }}" title="안 읽음 표시" style="display: inline-flex; align-items: center; gap: 6px; min-height: 44px; min-width: 40px; justify-content: center; padding: 0 10px; border: 0; border-radius: 8px; 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="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 class="tb-label">안 읽음 표시</span>
|
||||
</button>
|
||||
<button type="button" class="ghost" onClick="{{ flagAll }}" title="{{ flagLabel }}" style="display: inline-flex; align-items: center; gap: 6px; min-height: 44px; min-width: 40px; justify-content: center; padding: 0 10px; border: 0; border-radius: 8px; 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"><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 class="tb-label">{{ flagLabel }}</span>
|
||||
</button>
|
||||
<span aria-hidden="true" style="width: 1px; height: 20px; margin: 0 6px; background: #D0D5DD"></span>
|
||||
<div style="position: relative">
|
||||
<button type="button" class="ghost" onClick="{{ toggleMenu }}" aria-haspopup="menu" aria-expanded="{{ menuOpenStr }}" title="이동" style="display: inline-flex; align-items: center; gap: 6px; min-height: 44px; min-width: 40px; justify-content: center; padding: 0 8px 0 10px; border: 0; border-radius: 8px; 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="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 class="tb-label">이동</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>
|
||||
</button>
|
||||
<sc-if value="{{ menuOpen }}" hint-placeholder-val="{{ false }}">
|
||||
<div role="menu" aria-label="옮길 메일함" style="position: absolute; top: 46px; left: 0; z-index: 5; min-width: 200px; 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.14)">
|
||||
<sc-for list="{{ targets }}" as="t" hint-placeholder-count="3">
|
||||
<button type="button" role="menuitem" class="menu-item" onClick="{{ t.move }}" style="display: flex; align-items: center; gap: 10px; min-height: 40px; box-sizing: border-box; padding: 0 10px; border: 0; border-radius: 6px; background: transparent; color: #171C26; font: inherit; text-align: left; cursor: pointer">{{ t.name }}</button>
|
||||
</sc-for>
|
||||
</div>
|
||||
</sc-if>
|
||||
</div>
|
||||
<button type="button" class="danger" onClick="{{ deletePicked }}" title="{{ deleteLabel }}" style="display: inline-flex; align-items: center; gap: 6px; min-height: 44px; min-width: 40px; justify-content: center; padding: 0 10px; border: 0; border-radius: 8px; 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"><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 class="tb-label">{{ deleteLabel }}</span>
|
||||
</button>
|
||||
<button type="button" class="ghost" onClick="{{ clearPick }}" title="선택 해제 (Esc)" style="margin-left: auto; display: inline-flex; align-items: center; gap: 6px; min-height: 44px; padding: 0 10px; border: 0; border-radius: 8px; font: inherit; font-weight: 500; 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>
|
||||
<span>선택 해제</span>
|
||||
</button>
|
||||
</div>
|
||||
</sc-if>
|
||||
|
||||
<sc-if value="{{ hasItems }}" hint-placeholder-val="{{ true }}">
|
||||
<div style="overflow-x: auto">
|
||||
<div class="ml-inner">
|
||||
<div class="ml-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">
|
||||
<button type="button" role="checkbox" aria-checked="{{ allChecked }}" aria-label="불러온 메일 모두 고르기" onClick="{{ toggleAll }}" style="width: 32px; height: 31px; display: flex; align-items: center; justify-content: flex-start; padding: 0; border: 0; background: transparent; cursor: pointer">
|
||||
<sc-if value="{{ allNone }}" hint-placeholder-val="{{ false }}"><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>
|
||||
<sc-if value="{{ allSome }}" hint-placeholder-val="{{ true }}"><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><line x1="4.5" y1="8" x2="11.5" y2="8" stroke="#FFFFFF" stroke-width="2" stroke-linecap="round"></line></svg></sc-if>
|
||||
<sc-if value="{{ allFull }}" 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>
|
||||
</button>
|
||||
<span aria-hidden="true" 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 aria-hidden="true">{{ whoHeader }}</span>
|
||||
<span aria-hidden="true">제목</span>
|
||||
<span aria-hidden="true" style="text-align: right">날짜</span>
|
||||
<span aria-hidden="true" class="ml-size" style="text-align: right">크기</span>
|
||||
</div>
|
||||
<ul aria-label="{{ listLabel }}" style="list-style: none; margin: 0; padding: 0">
|
||||
<sc-for list="{{ items }}" as="item" hint-placeholder-count="15">
|
||||
<li class="{{ item.rowClass }}" style="height: 38px; box-sizing: border-box; padding: 0 8px 0 20px; border-bottom: 1px solid #EEF0F3">
|
||||
<button class="ml-ctl" type="button" role="checkbox" aria-checked="{{ item.pickedStr }}" aria-label="{{ item.pickLabel }}" onClick="{{ item.togglePick }}" style="width: 32px; height: 37px; display: flex; align-items: center; justify-content: flex-start; padding: 0; border: 0; background: transparent; cursor: pointer">
|
||||
<sc-if value="{{ item.notPicked }}" 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>
|
||||
<sc-if value="{{ item.picked }}" 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>
|
||||
</button>
|
||||
<button class="ml-ctl" type="button" onClick="{{ item.toggleStar }}" aria-label="{{ item.starLabel }}" aria-pressed="{{ item.starredStr }}" style="width: 32px; height: 37px; display: flex; align-items: center; justify-content: flex-start; padding: 0; border: 0; border-radius: 4px; background: transparent; color: {{ item.starColor }}; cursor: pointer">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="{{ item.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>
|
||||
<span style="min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-weight: {{ item.fw }}; color: {{ item.whoColor }}">
|
||||
<sc-if value="{{ item.unread }}" hint-placeholder-val="{{ false }}"><span class="sr">안 읽음, </span></sc-if>
|
||||
<span class="sr">{{ whoHeader }} </span>{{ item.who }}
|
||||
</span>
|
||||
<span style="min-width: 0; display: flex; align-items: center; gap: 6px">
|
||||
<a class="ml-link" href="MainListRead.dc.html" style="min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-weight: {{ item.subFw }}; color: #171C26; text-decoration: none">{{ item.subject }}</a>
|
||||
<sc-if value="{{ item.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: {{ item.timeFw }}; color: {{ item.timeColor }}; font-variant-numeric: tabular-nums"><span class="sr">날짜 </span>{{ item.date }}</span>
|
||||
<span class="ml-size" style="text-align: right; white-space: nowrap; font-size: 12px; color: #667085; font-variant-numeric: tabular-nums"><span class="sr">크기 </span>{{ item.size }}</span>
|
||||
</li>
|
||||
</sc-for>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<div style="display: flex; justify-content: center; padding: 10px 20px 20px">
|
||||
<button type="button" style="min-height: 40px; padding: 0 24px; border: 0; border-radius: 8px; background: transparent; color: #0E7C86; font: inherit; font-weight: 600; cursor: pointer">메일 더 보기</button>
|
||||
</div>
|
||||
</sc-if>
|
||||
|
||||
<sc-if value="{{ noItems }}" hint-placeholder-val="{{ false }}">
|
||||
<div style="display: flex; flex-direction: column; align-items: center; gap: 6px; padding: 96px 24px; text-align: center">
|
||||
<svg width="36" height="36" viewBox="0 0 24 24" fill="none" stroke="#98A2B3" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><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>
|
||||
<p style="margin: 6px 0 0; font-size: 14px; font-weight: 600">{{ emptyText }}</p>
|
||||
</div>
|
||||
</sc-if>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<sc-if value="{{ confirmOpen }}" hint-placeholder-val="{{ false }}">
|
||||
<div style="position: fixed; inset: 0; z-index: 30; display: flex; align-items: center; justify-content: center; padding: 24px; background: rgba(16, 24, 28, 0.32)">
|
||||
<div role="alertdialog" aria-modal="true" aria-labelledby="purge-title" aria-describedby="purge-text" style="width: min(400px, 100%); box-sizing: border-box; padding: 18px 18px 14px; border: 1px solid #E6E8EC; border-radius: 12px; background: #FFFFFF; color: #171C26; box-shadow: 0 14px 36px rgba(16, 24, 28, 0.14); display: flex; flex-direction: column; gap: 8px">
|
||||
<h3 id="purge-title" style="margin: 0; font-size: 15px; font-weight: 700">{{ confirmTitle }}</h3>
|
||||
<p id="purge-text" style="margin: 0; color: #4A5363">휴지통에서 지운 메일은 되돌릴 수 없습니다.</p>
|
||||
<div style="display: flex; justify-content: flex-end; gap: 8px; margin-top: 4px">
|
||||
<button type="button" onClick="{{ purge }}" style="min-height: 44px; padding: 0 16px; border: 0; border-radius: 8px; background: #B42318; color: #FFFFFF; font: inherit; font-weight: 600; cursor: pointer">영구 삭제</button>
|
||||
<button type="button" class="ghost" onClick="{{ cancelPurge }}" style="min-height: 44px; padding: 0 14px; border: 0; border-radius: 8px; font: inherit; font-weight: 500; cursor: pointer">취소</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</sc-if>
|
||||
|
||||
<sc-if value="{{ hasToast }}" hint-placeholder-val="{{ false }}">
|
||||
<div role="status" style="position: fixed; right: 20px; bottom: 20px; z-index: 20; width: min(400px, calc(100vw - 40px)); box-sizing: border-box; display: flex; align-items: center; gap: 10px; padding: 6px 6px 6px 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-height: 44px; display: flex; align-items: center; font-weight: 500">{{ toastText }}</span>
|
||||
<sc-if value="{{ canUndo }}" hint-placeholder-val="{{ true }}">
|
||||
<button type="button" class="on-dark" onClick="{{ undo }}" style="flex: 0 0 auto; min-height: 44px; padding: 0 10px; border: 0; border-radius: 6px; background: transparent; color: #9ED6DB; font: inherit; font-weight: 600; cursor: pointer">되돌리기</button>
|
||||
</sc-if>
|
||||
</div>
|
||||
</sc-if>
|
||||
</div>
|
||||
</x-dc>
|
||||
<script type="text/x-dc" data-dc-script data-props='{"$preview":{"width":1440,"height":900}}'>
|
||||
class Component extends DCLogic {
|
||||
state = { box: 'inbox', pick: { m2: true, m4: true, m5: true }, seen: {}, star: { m3: true }, moved: {}, gone: {}, menu: false, confirm: false, toast: null, undo: null };
|
||||
|
||||
messages() {
|
||||
return [
|
||||
{ id: 'm1', box: 'inbox', who: '이도윤', subject: 'Re: 메일 서버 이전 일정 공유', date: '오전 10:42', size: '62 KB', unread: true, att: true },
|
||||
{ id: 'm2', box: 'inbox', who: '인사팀', subject: '[인사팀] 11월 연차 사용 계획 제출 안내', date: '오전 9:15', size: '8 KB', unread: true, att: false },
|
||||
{ id: 'm3', box: 'inbox', who: '박준호', subject: '주간 인프라 점검 결과 (10/20~10/24)', date: '10.25', size: '318 KB', unread: false, att: true },
|
||||
{ id: 'm4', box: 'inbox', who: '(주)한빛솔루션', subject: '서버 증설 견적서 송부드립니다', date: '10.25', size: '190 KB', unread: true, att: true },
|
||||
{ id: 'm5', box: 'inbox', who: '보안팀', subject: '[공지] 하반기 정보보안 교육 이수 요청', date: '10.24', size: '9 KB', unread: false, att: false },
|
||||
{ id: 'm6', box: 'inbox', who: '최서연', subject: '10월 경비 정산 마감 안내', date: '10.23', size: '7 KB', unread: false, att: false },
|
||||
{ id: 'm8', box: 'inbox', who: '정하늘', subject: '신규 입사자 메일 계정 생성 요청 (11/2 입사)', date: '10.23', size: '11 KB', unread: false, att: false },
|
||||
{ id: 'm9', box: 'inbox', who: '총무팀', subject: '[총무팀] 사무실 공조 설비 점검 일정 안내', date: '10.23', size: '6 KB', unread: false, att: false },
|
||||
{ id: 'm7', box: 'inbox', who: '이도윤', subject: '메일 서버 이전 일정 공유', date: '10.22', size: '15 KB', unread: false, att: false },
|
||||
{ id: 'm10', box: 'inbox', who: '박준호', subject: 'Re: 백업 스토리지 증설 검토', date: '10.22', size: '96 KB', unread: false, att: true },
|
||||
{ id: 'm11', box: 'inbox', who: '김태윤', subject: '회의실 예약 시스템 저장 오류 문의', date: '10.21', size: '9 KB', unread: false, att: false },
|
||||
{ id: 'm13', box: 'inbox', who: '(주)한빛솔루션', subject: '유지보수 계약 갱신 안내', date: '10.20', size: '22 KB', unread: false, att: false },
|
||||
{ id: 'm12', box: 'inbox', who: '인사팀', subject: '[인사팀] 10월 사내 교육 일정 공유', date: '10.20', size: '140 KB', unread: false, att: true },
|
||||
{ id: 'm14', box: 'inbox', who: '보안팀', subject: '[보안] VPN 접속 프로그램 업데이트 안내', date: '10.19', size: '12 KB', unread: false, att: false },
|
||||
{ id: 'm15', box: 'inbox', who: '이서준', subject: '사내 위키 메일 서버 문서 정리 요청', date: '10.16', size: '10 KB', unread: false, att: false },
|
||||
{ id: 's1', box: 'sent', who: '박준호', subject: 'Re: 주간 인프라 점검 결과 (10/20~10/24)', date: '10.25', size: '6 KB', unread: false, att: false },
|
||||
{ id: 's2', box: 'sent', who: '이도윤', subject: 'Re: 메일 서버 이전 일정 공유', date: '10.23', size: '7 KB', unread: false, att: false },
|
||||
{ id: 'j1', box: 'junk', who: 'promo@deal.example', subject: '(광고) 오늘만 특가 — 서버 랙 90% 할인', date: '10.24', size: '31 KB', unread: true, att: false },
|
||||
{ id: 't1', box: 'trash', who: '호스팅 안내', subject: '(광고) 서버 호스팅 연말 할인 안내', date: '10.21', size: '24 KB', unread: false, att: false },
|
||||
{ id: 't2', box: 'trash', who: '세미나 사무국', subject: '[초대] 클라우드 보안 세미나 사전 등록', date: '10.18', size: '45 KB', unread: false, att: false },
|
||||
{ id: 't3', box: 'trash', who: '박준호', subject: '백업 스크립트 테스트 (무시하세요)', date: '10.15', size: '3 KB', unread: false, att: false },
|
||||
];
|
||||
}
|
||||
|
||||
renderVals() {
|
||||
const st = this.state;
|
||||
const BOXES = [
|
||||
{ id: 'inbox', name: '받은편지함' },
|
||||
{ id: 'sent', name: '보낸편지함' },
|
||||
{ id: 'drafts', name: '임시보관함' },
|
||||
{ id: 'junk', name: '스팸메일함' },
|
||||
{ id: 'trash', name: '휴지통' },
|
||||
];
|
||||
const nameOf = (id) => (BOXES.find((b) => b.id === id) || BOXES[0]).name;
|
||||
const all = this.messages()
|
||||
.filter((x) => !st.gone[x.id])
|
||||
.map((x) => ({ ...x, box: st.moved[x.id] || x.box }));
|
||||
const isUnread = (x) => (st.seen[x.id] === undefined ? x.unread : !st.seen[x.id]);
|
||||
const starOf = (id) => !!st.star[id];
|
||||
|
||||
const boxes = BOXES.map((b) => {
|
||||
const active = b.id === st.box;
|
||||
const unread = all.filter((x) => x.box === b.id && isUnread(x)).length;
|
||||
return {
|
||||
name: b.name,
|
||||
isInbox: b.id === 'inbox', isSent: b.id === 'sent', isDrafts: b.id === 'drafts', isJunk: b.id === 'junk', isTrash: b.id === 'trash',
|
||||
current: active ? 'page' : 'false',
|
||||
bg: active ? '#E3F2F3' : 'transparent',
|
||||
fg: active ? '#0A5F67' : '#344054',
|
||||
fw: active ? '600' : '500',
|
||||
countColor: active ? '#0A5F67' : '#171C26',
|
||||
unread: unread,
|
||||
hasUnread: unread > 0,
|
||||
pick: () => this.setState({ box: b.id, pick: {}, menu: false }),
|
||||
};
|
||||
});
|
||||
|
||||
const list = all.filter((x) => x.box === st.box);
|
||||
const pickedIds = list.filter((x) => st.pick[x.id]).map((x) => x.id);
|
||||
const n = pickedIds.length;
|
||||
const inTrash = st.box === 'trash';
|
||||
|
||||
const items = list.map((x) => {
|
||||
const picked = !!st.pick[x.id];
|
||||
const unread = isUnread(x);
|
||||
const starred = starOf(x.id);
|
||||
return {
|
||||
who: x.who,
|
||||
subject: x.subject,
|
||||
date: x.date,
|
||||
size: x.size,
|
||||
unread: unread,
|
||||
hasAtt: x.att,
|
||||
rowClass: picked ? 'ml-row ml-grid is-sel' : 'ml-row ml-grid',
|
||||
picked: picked,
|
||||
notPicked: !picked,
|
||||
pickedStr: picked ? 'true' : 'false',
|
||||
pickLabel: '고르기: ' + x.subject,
|
||||
togglePick: () => this.setState({ pick: { ...st.pick, [x.id]: !picked }, menu: false }),
|
||||
fw: unread ? '650' : '450',
|
||||
whoColor: unread ? '#171C26' : '#344054',
|
||||
subFw: unread ? '650' : '400',
|
||||
timeFw: unread ? '600' : '400',
|
||||
timeColor: unread ? '#171C26' : '#667085',
|
||||
starLabel: starred ? '중요 표시 해제' : '중요 표시',
|
||||
starredStr: starred ? 'true' : 'false',
|
||||
starColor: starred ? '#B7791F' : '#98A2B3',
|
||||
starFill: starred ? '#B7791F' : 'none',
|
||||
toggleStar: () => this.setState({ star: { ...st.star, [x.id]: !starred } }),
|
||||
};
|
||||
});
|
||||
|
||||
const setMany = (map, value) => ({ ...map, ...Object.fromEntries(pickedIds.map((id) => [id, value])) });
|
||||
const allFlagged = n > 0 && pickedIds.every(starOf);
|
||||
const moveTo = (target) => {
|
||||
const before = Object.fromEntries(pickedIds.map((id) => [id, st.moved[id] || null]));
|
||||
this.setState({
|
||||
moved: setMany(st.moved, target),
|
||||
pick: {},
|
||||
menu: false,
|
||||
toast: `메일 ${n}통을 ${nameOf(target)}으로 옮겼습니다.`,
|
||||
undo: before,
|
||||
});
|
||||
};
|
||||
const targets = BOXES
|
||||
.filter((b) => b.id !== st.box && b.id !== 'drafts' && b.id !== 'trash')
|
||||
.map((b) => ({ name: b.name, move: () => moveTo(b.id) }));
|
||||
|
||||
const box = BOXES.find((b) => b.id === st.box) || BOXES[0];
|
||||
const unreadInBox = list.filter(isUnread).length;
|
||||
|
||||
return {
|
||||
boxes: boxes,
|
||||
items: items,
|
||||
boxName: box.name,
|
||||
listLabel: box.name + ' 메일 목록',
|
||||
whoHeader: st.box === 'sent' ? '받는 사람' : '보낸 사람',
|
||||
countText: unreadInBox > 0 ? `전체 ${list.length} · 안 읽음 ${unreadInBox}` : `전체 ${list.length}`,
|
||||
hasItems: items.length > 0,
|
||||
noItems: items.length === 0,
|
||||
emptyText: box.name + '이 비어 있습니다',
|
||||
hasPick: n > 0,
|
||||
noPick: n === 0,
|
||||
pickText: `${n}통 선택됨`,
|
||||
allNone: n === 0,
|
||||
allSome: n > 0 && n < list.length,
|
||||
allFull: n > 0 && n === list.length,
|
||||
allChecked: n === 0 ? 'false' : n === list.length ? 'true' : 'mixed',
|
||||
toggleAll: () => this.setState({ pick: n === list.length ? {} : Object.fromEntries(list.map((x) => [x.id, true])), menu: false }),
|
||||
clearPick: () => this.setState({ pick: {}, menu: false }),
|
||||
markRead: () => this.setState({ seen: setMany(st.seen, true) }),
|
||||
markUnread: () => this.setState({ seen: setMany(st.seen, false) }),
|
||||
flagLabel: allFlagged ? '중요 해제' : '중요 표시',
|
||||
flagAll: () => this.setState({ star: setMany(st.star, !allFlagged) }),
|
||||
menuOpen: st.menu,
|
||||
menuOpenStr: st.menu ? 'true' : 'false',
|
||||
toggleMenu: () => this.setState({ menu: !st.menu }),
|
||||
targets: targets,
|
||||
deleteLabel: inTrash ? '영구 삭제' : '삭제',
|
||||
deletePicked: () => {
|
||||
if (inTrash) this.setState({ confirm: true, menu: false });
|
||||
else moveTo('trash');
|
||||
},
|
||||
confirmOpen: st.confirm,
|
||||
confirmTitle: `메일 ${n}통을 영구 삭제할까요?`,
|
||||
purge: () => this.setState({ gone: setMany(st.gone, true), pick: {}, confirm: false, toast: `메일 ${n}통을 영구 삭제했습니다.`, undo: null }),
|
||||
cancelPurge: () => this.setState({ confirm: false }),
|
||||
hasToast: !!st.toast,
|
||||
toastText: st.toast || '',
|
||||
canUndo: !!st.undo,
|
||||
undo: () => {
|
||||
const moved = { ...st.moved };
|
||||
Object.entries(st.undo || {}).forEach(([id, prev]) => {
|
||||
if (prev === null) delete moved[id];
|
||||
else moved[id] = prev;
|
||||
});
|
||||
this.setState({ moved: moved, toast: null, undo: null });
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,252 @@
|
||||
<!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}
|
||||
.ghost{background:transparent;color:#344054}
|
||||
.ghost:hover{background:#F2F4F7;color:#171C26}
|
||||
.st-grid{display:grid;grid-template-columns:32px 32px 168px 96px minmax(0,1fr) 84px 64px;column-gap:12px;align-items:center}
|
||||
.sr{position:absolute;width:1px;height:1px;margin:-1px;padding:0;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap;border:0}
|
||||
</style>
|
||||
</helmet>
|
||||
<div style="width: 1440px; min-height: 1560px; box-sizing: border-box; padding: 48px; display: flex; flex-direction: column; gap: 28px; 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="display: flex; flex-direction: column; gap: 6px; max-width: 860px">
|
||||
<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">3단계에서 목록에 더해지는 것들입니다. 분할 보기의 검색은 「메일 검색 · 분할 보기」, 목록 보기의 일괄 처리는 「여러 통 선택 · 목록 보기」 보드에서 눌러 볼 수 있습니다. 오류 문구는 에러 코드 사전에서 가져옵니다.</p>
|
||||
</div>
|
||||
|
||||
<div style="display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 20px; align-items: start">
|
||||
|
||||
<section style="display: flex; flex-direction: column; gap: 14px; padding: 20px; background: #FFFFFF; border: 1px solid #E6E8EC; border-radius: 12px">
|
||||
<div style="display: flex; flex-direction: column; gap: 2px">
|
||||
<h2 style="margin: 0; font-size: 14px; font-weight: 700">새 메일 · 목록을 내려 본 채일 때</h2>
|
||||
<p style="margin: 0; font-size: 12px; color: #667085">분할 · 목록 보기 공통</p>
|
||||
</div>
|
||||
<p style="margin: 0; font-size: 12.5px; color: #4A5363">화면이 보이는 동안 1분마다, 탭으로 돌아왔을 때 바로 한 번 바뀐 것이 있는지 확인합니다. 새 메일은 목록 맨 위에 생깁니다. 목록을 내려 본 채라면 보던 위치는 그대로 두고 머리 아래에 알림 줄을 띄웁니다 — 누르면 맨 위로 갑니다.</p>
|
||||
<div style="display: flex; flex-direction: column; border: 1px solid #E6E8EC; border-radius: 10px; overflow: hidden">
|
||||
<div style="display: flex; align-items: center; gap: 8px; min-height: 52px; box-sizing: border-box; padding: 4px 8px 4px 16px; border-bottom: 1px solid #E6E8EC">
|
||||
<h3 style="margin: 0; font-size: 15px; font-weight: 700">받은편지함</h3>
|
||||
<span style="font-size: 12px; color: #667085; font-variant-numeric: tabular-nums">전체 148 · 안 읽음 5</span>
|
||||
</div>
|
||||
<button type="button" style="display: flex; align-items: center; justify-content: center; gap: 6px; min-height: 40px; border: 0; border-bottom: 1px solid #E6E8EC; background: #F3F8F8; color: #0A5F67; font: inherit; font-weight: 600; 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="12" y1="19" x2="12" y2="5"></line><polyline points="5 12 12 5 19 12"></polyline></svg>
|
||||
<span>새 메일 2통 · 맨 위로</span>
|
||||
</button>
|
||||
<div style="display: flex; flex-direction: column; justify-content: center; gap: 1px; min-height: 50px; box-sizing: border-box; padding: 6px 16px; border-bottom: 1px solid #EEF0F3; line-height: 1.4">
|
||||
<span style="display: flex; justify-content: space-between; gap: 8px"><span style="color: #344054">총무팀</span><span style="font-size: 12px; color: #667085">10월 23일</span></span>
|
||||
<span>[총무팀] 사무실 공조 설비 점검 일정 안내</span>
|
||||
</div>
|
||||
<div style="display: flex; flex-direction: column; justify-content: center; gap: 1px; min-height: 50px; box-sizing: border-box; padding: 6px 16px; line-height: 1.4">
|
||||
<span style="display: flex; justify-content: space-between; gap: 8px"><span style="color: #344054">이도윤</span><span style="font-size: 12px; color: #667085">10월 22일</span></span>
|
||||
<span>메일 서버 이전 일정 공유</span>
|
||||
</div>
|
||||
</div>
|
||||
<p style="margin: 0; font-size: 12px; color: #667085">맨 위를 보고 있으면 알림 줄 없이 바로 나타납니다(굵은 글자 = 안 읽음). 검색 결과는 저절로 바꾸지 않습니다.</p>
|
||||
</section>
|
||||
|
||||
<section style="display: flex; flex-direction: column; gap: 14px; padding: 20px; background: #FFFFFF; border: 1px solid #E6E8EC; border-radius: 12px">
|
||||
<div style="display: flex; flex-direction: column; gap: 2px">
|
||||
<h2 style="margin: 0; font-size: 14px; font-weight: 700">탭 제목 · 메일함 숫자</h2>
|
||||
<p style="margin: 0; font-size: 12px; color: #667085">다른 탭에서 일할 때 알아보게</p>
|
||||
</div>
|
||||
<p style="margin: 0; font-size: 12.5px; color: #4A5363">받은편지함에 안 읽은 메일이 있으면 브라우저 탭 제목 앞에 그 수를 붙입니다. 메일함 패널의 숫자도 같은 확인에서 바뀝니다. 소리 · 데스크톱 알림은 하지 않습니다(브라우저 권한을 물어야 해서 다음 단계).</p>
|
||||
<dl style="margin: 0; display: flex; flex-direction: column; gap: 8px">
|
||||
<div style="display: flex; align-items: center; gap: 12px; min-height: 40px; box-sizing: border-box; padding: 0 12px; border: 1px solid #E6E8EC; border-radius: 8px">
|
||||
<dt style="flex: 0 0 72px; font-size: 12px; color: #667085">안 읽음 3통</dt>
|
||||
<dd style="margin: 0; font-weight: 500">(3) 메일 · ARA SYSTEM</dd>
|
||||
</div>
|
||||
<div style="display: flex; align-items: center; gap: 12px; min-height: 40px; box-sizing: border-box; padding: 0 12px; border: 1px solid #E6E8EC; border-radius: 8px">
|
||||
<dt style="flex: 0 0 72px; font-size: 12px; color: #667085">모두 읽음</dt>
|
||||
<dd style="margin: 0; font-weight: 500">메일 · ARA SYSTEM</dd>
|
||||
</div>
|
||||
<div style="display: flex; align-items: center; gap: 12px; min-height: 40px; box-sizing: border-box; padding: 0 12px; border: 1px solid #E6E8EC; border-radius: 8px">
|
||||
<dt style="flex: 0 0 72px; font-size: 12px; color: #667085">쓰기 화면</dt>
|
||||
<dd style="margin: 0; font-weight: 500">(3) 메일 쓰기 · ARA SYSTEM</dd>
|
||||
</div>
|
||||
</dl>
|
||||
<p style="margin: 0; font-size: 12px; color: #667085">확인이 실패해도 알리지 않고 다음 주기에 다시 묻습니다. 세션이 끝났으면 지금처럼 다시 로그인 창이 뜹니다.</p>
|
||||
</section>
|
||||
|
||||
<section style="display: flex; flex-direction: column; gap: 14px; padding: 20px; background: #FFFFFF; border: 1px solid #E6E8EC; border-radius: 12px">
|
||||
<div style="display: flex; flex-direction: column; gap: 2px">
|
||||
<h2 style="margin: 0; font-size: 14px; font-weight: 700">분할 보기에서 여러 통 고르기</h2>
|
||||
<p style="margin: 0; font-size: 12px; color: #667085">목록 칸 384px — 도구는 아이콘만(이름은 툴팁)</p>
|
||||
</div>
|
||||
<p style="margin: 0; font-size: 12.5px; color: #4A5363">행에 마우스를 올리거나 초점이 가면 왼쪽에 고르기 칸이 나타나고, 하나라도 고르면 모든 행에 보입니다. 행을 누르면 지금처럼 메일을 엽니다. Ctrl(⌘)+클릭은 하나씩, Shift+클릭은 범위로 고르고, Esc 는 선택을 풉니다.</p>
|
||||
<div style="display: flex; flex-direction: column; border: 1px solid #E6E8EC; border-radius: 10px; overflow: hidden">
|
||||
<div role="toolbar" aria-label="고른 메일" style="display: flex; align-items: center; gap: 0; min-height: 52px; box-sizing: border-box; padding: 4px 4px 4px 12px; border-bottom: 1px solid #E6E8EC; background: #F3F8F8">
|
||||
<span style="margin-right: 4px; font-size: 14px; font-weight: 700; white-space: nowrap; font-variant-numeric: tabular-nums">2통 선택됨</span>
|
||||
<button type="button" class="ghost" aria-label="읽음 표시" title="읽음 표시" style="width: 40px; height: 44px; display: inline-flex; align-items: center; justify-content: center; border: 0; border-radius: 8px; 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" class="ghost" aria-label="안 읽음 표시" title="안 읽음 표시" style="width: 40px; height: 44px; display: inline-flex; align-items: center; justify-content: center; border: 0; border-radius: 8px; 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" class="ghost" aria-label="중요 표시" title="중요 표시" style="width: 40px; height: 44px; display: inline-flex; align-items: center; justify-content: center; border: 0; border-radius: 8px; 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" class="ghost" 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; 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: #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" class="ghost" 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; 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>
|
||||
<ul style="list-style: none; margin: 0; padding: 0">
|
||||
<li style="display: flex; align-items: stretch; border-bottom: 1px solid #EEF0F3; background: #E8F4F5">
|
||||
<button type="button" role="checkbox" aria-checked="true" aria-label="고르기: [인사팀] 11월 연차 사용 계획 제출 안내" style="flex: 0 0 auto; width: 36px; display: flex; align-items: center; justify-content: flex-end; padding: 0; border: 0; background: transparent; cursor: pointer"><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></button>
|
||||
<span style="flex: 1 1 auto; min-width: 0; display: flex; flex-direction: column; justify-content: center; gap: 1px; min-height: 50px; padding: 6px 12px 6px 10px; line-height: 1.4">
|
||||
<span style="display: flex; justify-content: space-between; gap: 8px"><span style="font-weight: 650">인사팀</span><span style="font-size: 12px; font-weight: 600">오전 9:15</span></span>
|
||||
<span style="overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-weight: 600">[인사팀] 11월 연차 사용 계획 제출 안내</span>
|
||||
</span>
|
||||
</li>
|
||||
<li style="display: flex; align-items: stretch; border-bottom: 1px solid #EEF0F3; background: #FFFFFF">
|
||||
<button type="button" role="checkbox" aria-checked="false" aria-label="고르기: 주간 인프라 점검 결과 (10/20~10/24)" style="flex: 0 0 auto; width: 36px; display: flex; align-items: center; justify-content: flex-end; padding: 0; border: 0; background: transparent; cursor: pointer"><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></button>
|
||||
<span style="flex: 1 1 auto; min-width: 0; display: flex; flex-direction: column; justify-content: center; gap: 1px; min-height: 50px; padding: 6px 12px 6px 10px; line-height: 1.4">
|
||||
<span style="display: flex; justify-content: space-between; gap: 8px"><span style="color: #344054">박준호</span><span style="font-size: 12px; color: #667085">어제</span></span>
|
||||
<span style="overflow: hidden; text-overflow: ellipsis; white-space: nowrap">주간 인프라 점검 결과 (10/20~10/24)</span>
|
||||
</span>
|
||||
</li>
|
||||
<li style="display: flex; align-items: stretch; background: #E8F4F5">
|
||||
<button type="button" role="checkbox" aria-checked="true" aria-label="고르기: 서버 증설 견적서 송부드립니다" style="flex: 0 0 auto; width: 36px; display: flex; align-items: center; justify-content: flex-end; padding: 0; border: 0; background: transparent; cursor: pointer"><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></button>
|
||||
<span style="flex: 1 1 auto; min-width: 0; display: flex; flex-direction: column; justify-content: center; gap: 1px; min-height: 50px; padding: 6px 12px 6px 10px; line-height: 1.4">
|
||||
<span style="display: flex; justify-content: space-between; gap: 8px"><span style="font-weight: 650">(주)한빛솔루션</span><span style="font-size: 12px; font-weight: 600">어제</span></span>
|
||||
<span style="overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-weight: 600">서버 증설 견적서 송부드립니다</span>
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<p style="margin: 0; font-size: 12px; color: #667085">고르기 칸은 행 왼쪽 36px(16px 상자) — 고르지 않았고 마우스도 없을 때는 칸 없이 지금 행 그대로입니다. 분할 보기에서 여러 통을 고르는 동안 본문 칸은 열어 둔 메일을 그대로 보입니다.</p>
|
||||
</section>
|
||||
|
||||
<section style="grid-column: 1 / -1; display: flex; flex-direction: column; gap: 14px; padding: 20px; background: #FFFFFF; border: 1px solid #E6E8EC; border-radius: 12px">
|
||||
<div style="display: flex; flex-direction: column; gap: 2px">
|
||||
<h2 style="margin: 0; font-size: 14px; font-weight: 700">검색 결과 · 목록 보기 — 전체 메일함에서 찾았을 때</h2>
|
||||
<p style="margin: 0; font-size: 12px; color: #667085">칸 = 고르기 32 · 별 32 · 보낸 사람 168 · 메일함 96 · 제목(나머지) · 날짜 84 · 크기 64 (칸 사이 12) — 받은편지함 안에서 찾으면 메일함 칸이 없다(지금 표와 같다)</p>
|
||||
</div>
|
||||
<div style="display: flex; flex-direction: column; border: 1px solid #E6E8EC; border-radius: 10px; overflow: hidden">
|
||||
<div style="display: flex; align-items: center; gap: 6px; min-height: 52px; box-sizing: border-box; padding: 4px 8px 4px 8px; border-bottom: 1px solid #E6E8EC">
|
||||
<a href="MainList.dc.html" aria-label="검색 닫기" title="검색 닫기 — 받은편지함으로" style="width: 40px; height: 44px; display: inline-flex; align-items: center; justify-content: center; border-radius: 8px; color: #4A5363; text-decoration: none"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="19" y1="12" x2="5" y2="12"></line><polyline points="12 19 5 12 12 5"></polyline></svg></a>
|
||||
<h3 style="margin: 0; font-size: 15px; font-weight: 700">검색 결과</h3>
|
||||
<span style="font-size: 12px; color: #667085; font-variant-numeric: tabular-nums">‘회의’ · 6통</span>
|
||||
<span style="width: 16px"></span>
|
||||
<span style="font-size: 12.5px; font-weight: 600; color: #4A5363">찾을 곳</span>
|
||||
<span style="display: inline-flex; gap: 2px; padding: 2px; border-radius: 8px; background: #F2F4F7">
|
||||
<span style="display: inline-flex; align-items: center; min-height: 36px; padding: 0 12px; border-radius: 6px; color: #4A5363; font-weight: 600">받은편지함</span>
|
||||
<span style="display: inline-flex; align-items: center; min-height: 36px; padding: 0 12px; border-radius: 6px; background: #FFFFFF; color: #171C26; font-weight: 600; box-shadow: 0 1px 2px rgba(16,24,28,.14)">전체 메일함</span>
|
||||
</span>
|
||||
<span style="font-size: 12px; color: #667085">휴지통 · 스팸메일함은 빼고 찾습니다</span>
|
||||
</div>
|
||||
<div class="st-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" 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"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="#667085" 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>보낸 사람</span>
|
||||
<span>메일함</span>
|
||||
<span>제목</span>
|
||||
<span style="text-align: right">날짜</span>
|
||||
<span style="text-align: right">크기</span>
|
||||
</div>
|
||||
<sc-for list="{{ rows }}" as="r" hint-placeholder-count="6">
|
||||
<div class="st-grid" style="height: 38px; box-sizing: border-box; padding: 0 8px 0 20px; border-bottom: 1px solid #EEF0F3">
|
||||
<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"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="#98A2B3" 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; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-weight: {{ r.fw }}; color: {{ r.whoColor }}">{{ r.who }}</span>
|
||||
<span style="min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 12px; color: #667085">{{ r.box }}</span>
|
||||
<span style="min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-weight: {{ r.subFw }}">{{ r.subject }}</span>
|
||||
<span style="text-align: right; white-space: nowrap; font-size: 12px; color: {{ r.timeColor }}; font-weight: {{ r.timeFw }}; font-variant-numeric: tabular-nums">{{ r.date }}</span>
|
||||
<span style="text-align: right; white-space: nowrap; font-size: 12px; color: #667085; font-variant-numeric: tabular-nums">{{ r.size }}</span>
|
||||
</div>
|
||||
</sc-for>
|
||||
</div>
|
||||
<p style="margin: 0; font-size: 12px; color: #667085">보낸편지함 · 임시보관함의 메일은 보낸 사람 칸에 받는 사람을 「받는 사람: 이름」으로 보입니다. 임시보관함의 결과를 누르면 지금처럼 쓰기 화면에서 이어 씁니다. 검색 결과에서도 여러 통을 골라 처리할 수 있습니다.</p>
|
||||
</section>
|
||||
|
||||
<section style="display: flex; flex-direction: column; gap: 14px; padding: 20px; background: #FFFFFF; border: 1px solid #E6E8EC; border-radius: 12px">
|
||||
<div style="display: flex; flex-direction: column; gap: 2px">
|
||||
<h2 style="margin: 0; font-size: 14px; font-weight: 700">검색어 · 찾는 방법</h2>
|
||||
<p style="margin: 0; font-size: 12px; color: #667085">상단 검색 칸 — 메일 화면에서는 「메일 검색」</p>
|
||||
</div>
|
||||
<p style="margin: 0; font-size: 12.5px; color: #4A5363">Enter 로 찾습니다(치는 동안에는 찾지 않는다). 보낸 사람 · 받는 사람 · 제목 · 본문에서 찾고, 여러 낱말이면 모두 들어간 메일만 찾습니다. ✕ 나 Esc 는 검색을 닫고 보던 메일함으로 돌아갑니다. 뒤로 가기도 같습니다.</p>
|
||||
<ul style="margin: 0; padding: 0 0 0 18px; display: flex; flex-direction: column; gap: 6px; font-size: 12.5px; color: #344054">
|
||||
<li>‘회의’로 찾으면 ‘회의를’ · ‘회의는’ · ‘회의에서’ · ‘회의입니다’처럼 조사가 붙은 말도 함께 찾습니다.</li>
|
||||
<li>붙여 쓴 말의 일부(‘회의실’의 ‘회의’, ‘서버이전’의 ‘서버’)와 끝이 바뀐 말(‘보냈습니다’의 ‘보내’)은 찾지 못합니다.</li>
|
||||
<li>제목 맨 앞의 말머리([공지] · [긴급] · Re: · Fwd:)는 메일 엔진이 색인하지 않아 찾지 못합니다. 제목 가운데의 [결재] 는 찾습니다.</li>
|
||||
<li>따옴표로 감싸면 그 구절 그대로 찾습니다 — "회의를 잡겠습니다".</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section style="display: flex; flex-direction: column; gap: 14px; padding: 20px; background: #FFFFFF; border: 1px solid #E6E8EC; border-radius: 12px">
|
||||
<div style="display: flex; flex-direction: column; gap: 2px">
|
||||
<h2 style="margin: 0; font-size: 14px; font-weight: 700">결과 알림</h2>
|
||||
<p style="margin: 0; font-size: 12px; color: #667085">오른쪽 아래 · 되돌리기가 있는 알림은 10초</p>
|
||||
</div>
|
||||
<p style="margin: 0; font-size: 12.5px; color: #4A5363">읽음 · 안 읽음 · 중요 표시는 목록이 바로 바뀌므로 알리지 않습니다. 옮기거나 지운 것만 알리고, 「되돌리기」는 원래 메일함으로 다시 옮깁니다.</p>
|
||||
<div role="status" style="display: flex; align-items: center; gap: 10px; padding: 6px 6px 6px 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; font-weight: 500">메일 3통을 휴지통으로 옮겼습니다.</span>
|
||||
<button type="button" class="on-dark" style="flex: 0 0 auto; min-height: 44px; padding: 0 10px; border: 0; border-radius: 6px; background: transparent; color: #9ED6DB; font: inherit; font-weight: 600; cursor: pointer">되돌리기</button>
|
||||
</div>
|
||||
<div role="status" style="display: flex; align-items: center; gap: 10px; padding: 6px 6px 6px 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; padding: 6px 0; font-weight: 500">메일 2통을 보낸편지함으로 옮겼습니다. 1통은 그사이 옮겨졌거나 지워져 그대로 두었습니다.</span>
|
||||
<button type="button" class="on-dark" style="flex: 0 0 auto; min-height: 44px; padding: 0 10px; border: 0; border-radius: 6px; background: transparent; color: #9ED6DB; font: inherit; font-weight: 600; cursor: pointer">되돌리기</button>
|
||||
</div>
|
||||
<div role="status" style="display: flex; align-items: center; gap: 10px; min-height: 44px; box-sizing: border-box; padding: 6px 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; font-weight: 500">메일 3통을 영구 삭제했습니다.</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section style="display: flex; flex-direction: column; gap: 14px; padding: 20px; background: #FFFFFF; border: 1px solid #E6E8EC; border-radius: 12px">
|
||||
<div style="display: flex; flex-direction: column; gap: 2px">
|
||||
<h2 style="margin: 0; font-size: 14px; font-weight: 700">휴지통 · 영구 삭제 확인</h2>
|
||||
<p style="margin: 0; font-size: 12px; color: #667085">휴지통에서만 — 다른 메일함의 「삭제」는 휴지통으로 옮긴다</p>
|
||||
</div>
|
||||
<p style="margin: 0; font-size: 12.5px; color: #4A5363">휴지통에서 고르면 도구 줄의 「삭제」가 「영구 삭제」가 되고, 누르면 먼저 묻습니다. 대화상자가 열리면 「취소」에 초점이 갑니다. 「이동」으로 다른 메일함에 되돌릴 수 있습니다.</p>
|
||||
<div role="alertdialog" aria-labelledby="st-purge-title" aria-describedby="st-purge-text" style="padding: 18px 18px 14px; border: 1px solid #E6E8EC; border-radius: 12px; background: #FFFFFF; color: #171C26; box-shadow: 0 14px 36px rgba(16, 24, 28, 0.14); display: flex; flex-direction: column; gap: 8px">
|
||||
<h3 id="st-purge-title" style="margin: 0; font-size: 15px; font-weight: 700">메일 3통을 영구 삭제할까요?</h3>
|
||||
<p id="st-purge-text" style="margin: 0; color: #4A5363">휴지통에서 지운 메일은 되돌릴 수 없습니다.</p>
|
||||
<div style="display: flex; justify-content: flex-end; gap: 8px; margin-top: 4px">
|
||||
<button type="button" style="min-height: 44px; padding: 0 16px; border: 0; border-radius: 8px; background: #B42318; color: #FFFFFF; font: inherit; font-weight: 600; cursor: pointer">영구 삭제</button>
|
||||
<button type="button" class="ghost" style="min-height: 44px; padding: 0 14px; border: 0; border-radius: 8px; font: inherit; font-weight: 500; cursor: pointer">취소</button>
|
||||
</div>
|
||||
</div>
|
||||
<p style="margin: 0; font-size: 12px; color: #667085">그사이 다른 창에서 휴지통 밖으로 되돌린 메일은 지우지 않습니다(서버가 확인한 상태 그대로일 때만 지운다).</p>
|
||||
</section>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</x-dc>
|
||||
<script type="text/x-dc" data-dc-script data-props='{"$preview":{"width":1440,"height":1560}}'>
|
||||
class Component extends DCLogic {
|
||||
renderVals() {
|
||||
const R = [
|
||||
{ who: '이도윤', box: '받은편지함', subject: 'Re: 주간 회의 안건 공유', date: '오전 10:42', size: '58 KB', unread: true },
|
||||
{ who: '박준호', box: '받은편지함', subject: '11월 인프라 점검 일정 조율', date: '오전 9:05', size: '7 KB', unread: true },
|
||||
{ who: '받는 사람: 정하늘', box: '보낸편지함', subject: 'Re: 신규 입사자 교육 회의록', date: '10.26', size: '5 KB', unread: false },
|
||||
{ who: '최서연', box: '받은편지함', subject: '10월 경비 정산 일정 변경', date: '10.23', size: '7 KB', unread: false },
|
||||
{ who: '받는 사람: 운영팀', box: '임시보관함', subject: '11월 운영 회의 안건 (작성 중)', date: '10.22', size: '4 KB', unread: false },
|
||||
{ who: '인사팀', box: '받은편지함', subject: '[인사팀] 전사 회의 참석 안내', date: '10.19', size: '226 KB', unread: false },
|
||||
];
|
||||
return {
|
||||
rows: R.map((r) => ({
|
||||
who: r.who,
|
||||
box: r.box,
|
||||
subject: r.subject,
|
||||
date: r.date,
|
||||
size: r.size,
|
||||
fw: r.unread ? '650' : '450',
|
||||
whoColor: r.unread ? '#171C26' : '#344054',
|
||||
subFw: r.unread ? '650' : '400',
|
||||
timeFw: r.unread ? '600' : '400',
|
||||
timeColor: r.unread ? '#171C26' : '#667085',
|
||||
})),
|
||||
};
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,12 +1,13 @@
|
||||
# 웹메일 디자인 원본 (1·2단계)
|
||||
# 웹메일 디자인 원본 (1~3단계)
|
||||
|
||||
확정된 디자인 캔버스의 원본이다. 화면 구현의 기준으로 쓴다.
|
||||
|
||||
- 캔버스: https://claude.ai/artifact/WJvVsKvXepxn3p75CGJAr2 (확정본: 2026-10-27, 버전 19)
|
||||
- 캔버스: https://claude.ai/artifact/WJvVsKvXepxn3p75CGJAr2 (확정본: 2026-10-27, 버전 21)
|
||||
- 화면: `Login`(로그인) · `Main`(분할 보기: 메일함·목록·본문) · `MainList`(목록 보기: 게시판형 표) · `MainListRead`(목록 보기에서 연 메일)
|
||||
· `Compose`(메일 쓰기 — 서식 편집기) · `ComposeEditor`(메일 쓰기 · 편집기 상태: 포커스·툴바·메뉴·팝오버·인용·이미지 첨부)
|
||||
· `States`(오류·빈 상태·피드백 모음)
|
||||
· 2단계: `Settings`(설정 · 메일 서명) · `ComposeAssist`(메일 쓰기 · 임시 저장 · 받는 사람 자동 완성 · 서명) — 아래 "2단계"
|
||||
· 3단계: `Search`(메일 검색 · 분할 보기) · `BulkSelect`(여러 통 선택 · 목록 보기) · `ListStates`(새 메일 · 검색 · 여러 통 처리 상태) — 아래 "3단계"
|
||||
- 파일 형식은 캔버스 전용(`<x-dc>` 마크업 + `DCLogic` 클래스)이다. 그대로 쓰지 말고 **구조·간격·색·문구의 기준**으로만 본다.
|
||||
`Compose` 의 툴바·메뉴는 눌러 볼 수 있는 시안이다 — 버튼 상태만 바뀌고 본문 글자에 서식이 입혀지지는 않는다.
|
||||
|
||||
@@ -215,6 +216,52 @@
|
||||
화살표로 고른 뒤에만 추천이 들어간다. 엉뚱한 사람이 받는 사람에 들어가지 않게 하기 위해서다.
|
||||
- 쓰기 화면을 열 때 추천 목록을 한 번 받아 두고 치는 대로 바로 거른다 — 모자랄 때만 서버에 다시 묻는다.
|
||||
|
||||
## 3단계 — 새 메일 · 검색 · 여러 통 처리 (2026-10-27 확정 — `Search` · `BulkSelect` · `ListStates`)
|
||||
|
||||
API 와 서버 규칙은 계약 2절 "새 메일 확인" · "검색" · "여러 통 한꺼번에".
|
||||
|
||||
### 새 메일
|
||||
|
||||
- 화면이 보이는 동안 1분마다(탭으로 돌아오면 바로 한 번) 바뀐 것이 있는지 묻고, 바뀌었으면 메일함 숫자와 보이는 목록을 맞춘다 —
|
||||
고른 메일 · 열린 메일 · 스크롤 위치는 그대로다. 검색 결과는 저절로 바꾸지 않는다. 확인이 실패해도 알리지 않는다.
|
||||
- 맨 위를 보고 있으면 새 메일이 그냥 위에 생긴다(굵은 글자 = 안 읽음). 목록을 내려 본 채면 머리 아래에 알림 줄
|
||||
"새 메일 N통 · 맨 위로"(높이 40 · 바탕 #F3F8F8 · #0A5F67 굵게 · 위 화살표)를 띄운다 — 누르면 맨 위로 가고 줄은 사라진다.
|
||||
- 탭 제목: 받은편지함에 안 읽은 메일이 있으면 앞에 "(N) " — "(3) 메일 · ARA SYSTEM". 소리 · 데스크톱 알림은 하지 않는다.
|
||||
|
||||
### 검색
|
||||
|
||||
- 상단 검색 칸이 메일 화면에서는 "메일 검색"이다. Enter 로 찾고, 치는 동안에는 찾지 않는다.
|
||||
검색 중인 칸은 테두리가 강조색(1px 테두리 + 1px 그림자)이고 끝에 ✕(40 · "검색어 지우기" — Esc 와 같다).
|
||||
- 결과는 보던 보기 방식(분할 · 목록) 그대로 목록 자리에 보인다.
|
||||
- 머리(52): [←](40×44 · "검색 닫기") · "검색 결과"(15px 굵게) · "‘회의’ · 6통"(12px #667085) · 보기 전환.
|
||||
- 그 아래 범위 줄(48 · 바탕 #FBFBFC): "찾을 곳" + [그 메일함 | 전체 메일함](36 — 설정 화면의 전환과 같은 밀도 예외)
|
||||
+ 전체일 때 "휴지통 · 스팸메일함은 빼고 찾습니다"(12px #667085).
|
||||
- 전체 메일함에서 찾으면 행에 메일함을 보인다 — 분할 보기는 첫 줄 시각 앞에 "보낸편지함 ·"(12px #667085),
|
||||
목록 보기 표는 보낸 사람 뒤에 메일함 칸(96). 보낸편지함 · 임시보관함의 메일은 보낸 사람 자리에 "받는 사람: 이름".
|
||||
- 검색 결과에서 연 메일의 읽기 화면은 받는 사람 아래에 "메일함 받은편지함" 한 줄(12.5px #667085)을 더한다.
|
||||
- 결과 없음: 검색 아이콘 · "‘…’에 맞는 메일이 없습니다" · 한계 한 줄 "낱말 단위로 찾습니다. 붙여 쓴 말의 일부(‘서버이전’의 ‘서버’)나
|
||||
제목 맨 앞의 말머리([공지] · Re:)로는 찾지 못합니다." · 한 메일함에서 찾았으면 [전체 메일함에서 찾기].
|
||||
- 찾는 동안은 지금의 목록 뼈대를 보인다.
|
||||
|
||||
### 여러 통 처리
|
||||
|
||||
- **목록 보기 표**: 맨 앞에 고르기 칸(32 — 별 칸과 같은 밀도 예외). 상자 16px — 비었을 때 테두리 #8A94A3 1.5px, 고르면 #0E7C86 채움에 흰 체크.
|
||||
머리의 상자는 불러온 메일 모두 고르기 · 풀기(일부만 골랐으면 – 표시). 고른 행 바탕 #E8F4F5(마우스를 올리면 #DDEFF0).
|
||||
칸 = 고르기 32 · 별 32 · 보낸 사람 168 · 제목(나머지) · 날짜 84 · 크기 64 (칸 사이 12). 좁아질 때는 지금 규칙 그대로(1100 미만 크기 칸 숨김 · 600 미만 보낸 사람 112 · 날짜 72).
|
||||
- **분할 보기**: 행에 마우스를 올리거나 초점이 가면 왼쪽 36px 에 고르기 상자가 나타나고, 하나라도 고르면 모든 행에 보인다.
|
||||
행을 누르면 지금처럼 메일을 연다. Ctrl(⌘)+클릭은 하나씩, Shift+클릭은 범위, Esc 는 선택 풀기 (두 보기 공통).
|
||||
여러 통을 고르는 동안 본문 칸은 열어 둔 메일을 그대로 보인다.
|
||||
- **도구 줄**: 하나라도 고르면 목록 머리(52)가 도구 줄로 바뀐다(바탕 #F3F8F8) — "N통 선택됨"(14px 굵게) · 읽음 표시 · 안 읽음 표시 ·
|
||||
중요 표시(고른 것이 모두 중요면 "중요 해제") | 이동 ▾ · 삭제(휴지통에서는 "영구 삭제", #B42318) … 오른쪽 끝 "선택 해제"(✕).
|
||||
단추는 높이 44 · 아이콘 + 이름이고, 폭 1100 미만이거나 분할 보기의 목록 칸에서는 아이콘만(40×44 — 이름은 툴팁 · `aria-label`).
|
||||
- **이동 메뉴**(항목 높이 40 — 메뉴 항목 밀도 예외): 지금 메일함 · 임시보관함 · 휴지통을 뺀 메일함.
|
||||
- **알림**(오른쪽 아래 · 되돌리기가 있으면 10초): "메일 N통을 보낸편지함으로 옮겼습니다." [되돌리기] · "메일 N통을 휴지통으로 옮겼습니다." [되돌리기] ·
|
||||
"메일 N통을 영구 삭제했습니다." · 일부만 처리했으면 뒤에 "1통은 그사이 옮겨졌거나 지워져 그대로 두었습니다."
|
||||
(메일함 이름의 받침에 맞춰 '으로/로'). 읽음 · 중요 표시는 목록이 바로 바뀌므로 알리지 않는다.
|
||||
- **영구 삭제 확인**: 휴지통에서만. 제목 "메일 N통을 영구 삭제할까요?" · 글 "휴지통에서 지운 메일은 되돌릴 수 없습니다." ·
|
||||
[영구 삭제](#B42318 채움) [취소] — 열리면 「취소」에 초점이 간다.
|
||||
- 메일함을 바꾸거나 검색하면 선택을 푼다.
|
||||
|
||||
## 구현하며 정한 보완 (2026-10-26 — 캔버스에는 아직 없다)
|
||||
|
||||
- 찾지 못한 첨부(보관 시간이 지난 업로드·지워진 원본)는 실패로 표시하고, 전달 원본에는 "빼기"만 둔다(다시 올릴 수 없다).
|
||||
@@ -256,3 +303,20 @@
|
||||
- 설정 화면의 미리 보기 전환 단추와 그림 크기 줄(작게 · 보통 · 원본 · 그림 빼기, 36px)은 목록의 보기 전환처럼 밀도 예외다 (캔버스 확정값).
|
||||
- **받는 사람 자동 완성**: Tab 은 고른 추천을 넣고 그 칸에 머문다(고른 것이 없으면 원래 Tab). 화살표는 끝에서 처음으로 돌고, 마우스를 올린 줄이 골라진다.
|
||||
초성 · 자모만 친 글자는 서버에 묻지 않는다(받아 둔 목록에서만 거른다).
|
||||
|
||||
### 3단계 구현하며 정한 보완 (2026-10-27 — 캔버스에는 아직 없다)
|
||||
|
||||
- **읽기 화면의 삭제**: 휴지통 밖의 메일은 휴지통으로 **옮기고**(알림 "메일 1통을 휴지통으로 옮겼습니다." + [되돌리기]),
|
||||
휴지통의 메일은 단추가 「영구 삭제」가 되어 여러 통과 같은 확인 대화상자를 거친다 — 그사이 다른 창이 휴지통으로 옮긴 메일을 확인 없이 영구 삭제하지 않게
|
||||
(웹 화면은 삭제 요청 `DELETE` 를 쓰지 않는다 — 계약 2절 "여러 통 한꺼번에").
|
||||
- **검색**
|
||||
- 검색 화면의 탭 제목은 "메일 검색"이다 — "(3) 메일 검색 · ARA SYSTEM".
|
||||
- 낱말이 8개를 넘으면 찾지 않고 알린다(서버 한도와 같다). 같은 검색어로 다시 Enter 를 누르면 보이는 채로 다시 찾고 고른 것을 푼다.
|
||||
- 주소로 바로 들어와 검색을 시작한 곳을 모르면, 닫을 때 그 범위의 메일함으로 간다(범위가 전체면 받은편지함).
|
||||
- 검색 화면 안에서 다시 찾거나 범위를 바꾸면 방문 기록을 쌓지 않는다(메일함에서 시작한 첫 검색만 쌓인다).
|
||||
- 전체 메일함 결과에서 다른 메일함으로 옮긴 메일은 결과에 남기고 메일함 표시만 바꾼다(휴지통 · 스팸메일함으로 옮기면 뺀다).
|
||||
- **여러 통 처리**
|
||||
- 목록 보기의 「선택 해제」는 폭 1100 미만에서도 이름을 남긴다(분할 보기의 목록 칸에서는 아이콘만).
|
||||
- 요청이 통째로 실패하면 고른 것을 그대로 둔다(다시 시도할 수 있게). 열어 둔 메일이 옮겨져 목록에서 빠지면 목록 주소로 바꾼다.
|
||||
- 영구 삭제 확인은 공용 대화상자 폭(360)을 쓴다.
|
||||
- 「되돌리기」는 이 화면이 알던 메일함 한 곳으로 되돌리고, 로그아웃 · 다른 계정 로그인 뒤에는 움직이지 않는다 (계약 2절 "알려진 한계").
|
||||
|
||||
@@ -0,0 +1,436 @@
|
||||
<!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}
|
||||
.seg{background:transparent;color:#4A5363}
|
||||
.seg[aria-pressed="true"]{background:#FFFFFF;color:#171C26;box-shadow:0 1px 2px rgba(16,24,28,.14)}
|
||||
.ghost{background:transparent;color:#344054}
|
||||
.ghost:hover{background:#F2F4F7;color:#171C26}
|
||||
.sk{display:block;height:10px;border-radius:5px;background:#EEF0F3}
|
||||
</style>
|
||||
</helmet>
|
||||
<div style="min-height: 100vh; display: flex; background: #FFFFFF; color: #171C26; font-family: Pretendard, -apple-system, 'Apple SD Gothic Neo', 'Malgun Gothic', system-ui, sans-serif; font-size: 13px; line-height: 1.5">
|
||||
|
||||
<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="Main.dc.html" 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="Main.dc.html" 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"><rect x="2" y="7" width="20" height="14" rx="2" ry="2"></rect><path d="M16 21V5a2 2 0 0 0-2-2h-4a2 2 0 0 0-2 2v16"></path></svg>
|
||||
<span>ERP</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="Settings.dc.html" 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">
|
||||
<h1 style="margin: 0; font-size: 16px; font-weight: 700; letter-spacing: -0.01em">메일</h1>
|
||||
<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 0 0 12px; border: 1px solid #0E7C86; border-radius: 8px; background: #FFFFFF; box-shadow: 0 0 0 1px #0E7C86">
|
||||
<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="메일 검색" value="회의" style="flex: 1 1 auto; min-width: 0; border: 0; outline: none; background: transparent; font: inherit; color: #171C26">
|
||||
<a href="Main.dc.html" aria-label="검색어 지우기" title="검색어 지우기 (Esc)" style="flex: 0 0 auto; width: 40px; height: 38px; display: inline-flex; align-items: center; justify-content: center; border-radius: 0 7px 7px 0; color: #667085; text-decoration: none">
|
||||
<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>
|
||||
</a>
|
||||
</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-size: 13px; 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>
|
||||
<a href="Login.dc.html" style="display: inline-flex; align-items: center; gap: 6px; min-height: 44px; padding: 0 10px; border-radius: 8px; color: #4A5363; font-weight: 500; text-decoration: none">
|
||||
<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>
|
||||
</a>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div style="flex: 1 1 auto; display: flex; flex-wrap: wrap; align-items: stretch">
|
||||
|
||||
<nav aria-label="메일함" style="flex: 1 1 208px; box-sizing: border-box; display: flex; flex-direction: column; gap: 14px; padding: 14px 10px; background: #F8F9FB; border-right: 1px solid #E6E8EC">
|
||||
<a href="Compose.dc.html" 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>
|
||||
<ul style="list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 1px">
|
||||
<sc-for list="{{ boxes }}" as="b" hint-placeholder-count="5">
|
||||
<li>
|
||||
<a href="Main.dc.html" aria-current="{{ b.current }}" style="display: flex; align-items: center; gap: 10px; min-height: 40px; box-sizing: border-box; padding: 0 10px; border-radius: 7px; background: {{ b.bg }}; 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"><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"><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.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"><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"><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"><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">{{ b.name }}</span>
|
||||
<sc-if value="{{ b.hasUnread }}" hint-placeholder-val="{{ false }}"><span style="font-size: 12px; font-weight: 600; color: {{ b.countColor }}; font-variant-numeric: tabular-nums">{{ b.unread }}</span></sc-if>
|
||||
</a>
|
||||
</li>
|
||||
</sc-for>
|
||||
</ul>
|
||||
<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>
|
||||
|
||||
<main style="flex: 999 1 560px; min-width: 0; display: flex; flex-wrap: wrap; align-items: stretch">
|
||||
|
||||
<section aria-labelledby="result-title" style="flex: 1 1 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; gap: 6px; min-height: 52px; box-sizing: border-box; padding: 4px 4px 4px 4px; border-bottom: 1px solid #E6E8EC">
|
||||
<a href="Main.dc.html" aria-label="검색 닫기" title="검색 닫기 — 받은편지함으로" style="flex: 0 0 auto; width: 40px; height: 44px; display: inline-flex; align-items: center; justify-content: center; border-radius: 8px; color: #4A5363; text-decoration: none">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="19" y1="12" x2="5" y2="12"></line><polyline points="12 19 5 12 12 5"></polyline></svg>
|
||||
</a>
|
||||
<h2 id="result-title" style="margin: 0; font-size: 15px; font-weight: 700; letter-spacing: -0.01em">검색 결과</h2>
|
||||
<span style="min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 12px; color: #667085; font-variant-numeric: tabular-nums">{{ countText }}</span>
|
||||
<div style="margin-left: auto; display: inline-flex; gap: 2px; padding: 2px; border: 1px solid #D0D5DD; border-radius: 8px; background: #FFFFFF">
|
||||
<button type="button" aria-pressed="true" aria-label="분할 보기" style="display: inline-flex; align-items: center; gap: 4px; min-height: 30px; padding: 0 8px; border: 0; border-radius: 6px; background: #E3F2F3; color: #0A5F67; font: inherit; font-size: 12px; 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"><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>
|
||||
</button>
|
||||
<a href="ListStates.dc.html" aria-label="목록 보기" 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; text-decoration: none">
|
||||
<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>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="display: flex; flex-wrap: wrap; align-items: center; gap: 6px 10px; min-height: 48px; box-sizing: border-box; padding: 4px 12px 4px 16px; border-bottom: 1px solid #EEF0F3; background: #FBFBFC">
|
||||
<span id="scope-label" style="font-size: 12.5px; font-weight: 600; color: #4A5363">찾을 곳</span>
|
||||
<div role="group" aria-labelledby="scope-label" style="display: inline-flex; gap: 2px; padding: 2px; border-radius: 8px; background: #F2F4F7">
|
||||
<button type="button" class="seg" aria-pressed="{{ boxPressed }}" onClick="{{ scopeBox }}" style="min-height: 36px; padding: 0 12px; border: 0; border-radius: 6px; font: inherit; font-weight: 600; cursor: pointer">받은편지함</button>
|
||||
<button type="button" class="seg" aria-pressed="{{ allPressed }}" onClick="{{ scopeAll }}" style="min-height: 36px; padding: 0 12px; border: 0; border-radius: 6px; font: inherit; font-weight: 600; cursor: pointer">전체 메일함</button>
|
||||
</div>
|
||||
<sc-if value="{{ isAll }}" hint-placeholder-val="{{ false }}"><span style="font-size: 12px; color: #667085">휴지통 · 스팸메일함은 빼고 찾습니다</span></sc-if>
|
||||
</div>
|
||||
|
||||
<sc-if value="{{ showList }}" hint-placeholder-val="{{ true }}">
|
||||
<ul aria-label="검색 결과" style="list-style: none; margin: 0; padding: 0">
|
||||
<sc-for list="{{ items }}" as="item" hint-placeholder-count="5">
|
||||
<li style="display: flex; align-items: stretch; border-bottom: 1px solid #EEF0F3; background: {{ item.bg }}">
|
||||
<button type="button" onClick="{{ item.pick }}" 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 16px; border: 0; background: transparent; font: inherit; line-height: 1.4; color: inherit; text-align: left; cursor: pointer">
|
||||
<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: {{ item.fw }}; color: {{ item.whoColor }}">{{ item.who }}</span>
|
||||
<sc-if value="{{ item.showBox }}" hint-placeholder-val="{{ false }}"><span style="flex: 0 0 auto; font-size: 12px; color: #667085">{{ item.boxName }} ·</span></sc-if>
|
||||
<span style="flex: 0 0 auto; font-size: 12px; font-weight: {{ item.timeFw }}; color: {{ item.timeColor }}; font-variant-numeric: tabular-nums">{{ item.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: {{ item.subFw }}; color: #171C26">{{ item.subject }}</span>
|
||||
<sc-if value="{{ item.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>
|
||||
</button>
|
||||
<button type="button" onClick="{{ item.toggleStar }}" aria-label="{{ item.starLabel }}" aria-pressed="{{ item.starredStr }}" style="flex: 0 0 auto; width: 44px; display: flex; align-items: center; justify-content: center; border: 0; background: transparent; color: {{ item.starColor }}; cursor: pointer">
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="{{ item.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>
|
||||
</sc-if>
|
||||
|
||||
<sc-if value="{{ showLoading }}" hint-placeholder-val="{{ false }}">
|
||||
<ul aria-label="찾는 중" aria-busy="true" style="list-style: none; margin: 0; padding: 0">
|
||||
<sc-for list="{{ skeleton }}" as="s" hint-placeholder-count="6">
|
||||
<li style="display: flex; flex-direction: column; justify-content: center; gap: 8px; min-height: 50px; box-sizing: border-box; padding: 8px 16px; border-bottom: 1px solid #EEF0F3">
|
||||
<span style="display: flex; justify-content: space-between; gap: 24px"><span class="sk" style="width: {{ s.a }}"></span><span class="sk" style="width: 40px"></span></span>
|
||||
<span class="sk" style="width: {{ s.b }}"></span>
|
||||
</li>
|
||||
</sc-for>
|
||||
</ul>
|
||||
</sc-if>
|
||||
|
||||
<sc-if value="{{ showEmpty }}" hint-placeholder-val="{{ false }}">
|
||||
<div style="display: flex; flex-direction: column; align-items: center; gap: 6px; padding: 72px 28px; text-align: center">
|
||||
<svg width="36" height="36" viewBox="0 0 24 24" fill="none" stroke="#98A2B3" stroke-width="1.5" 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>
|
||||
<p style="margin: 6px 0 0; font-size: 14px; font-weight: 600">‘회의’에 맞는 메일이 없습니다</p>
|
||||
<p style="margin: 0; max-width: 300px; font-size: 12.5px; color: #667085">낱말 단위로 찾습니다. 붙여 쓴 말의 일부(‘서버이전’의 ‘서버’)나 제목 맨 앞의 말머리([공지] · Re:)로는 찾지 못합니다.</p>
|
||||
<sc-if value="{{ offerAll }}" hint-placeholder-val="{{ true }}">
|
||||
<button type="button" onClick="{{ scopeAll }}" style="margin-top: 8px; min-height: 44px; padding: 0 16px; border: 0; border-radius: 8px; background: transparent; color: #0E7C86; font: inherit; font-weight: 600; cursor: pointer">전체 메일함에서 찾기</button>
|
||||
</sc-if>
|
||||
</div>
|
||||
</sc-if>
|
||||
</section>
|
||||
|
||||
<div style="flex: 999 1 480px; min-width: 0; box-sizing: border-box; display: flex; flex-direction: column">
|
||||
<sc-if value="{{ hasSel }}" hint-placeholder-val="{{ true }}">
|
||||
<article aria-labelledby="mail-subject" style="display: flex; flex-direction: column">
|
||||
<div style="border-bottom: 1px solid #E6E8EC"><div 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">
|
||||
<a href="Compose.dc.html" 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; text-decoration: none">
|
||||
<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>
|
||||
</a>
|
||||
<a href="Compose.dc.html" 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; text-decoration: none">
|
||||
<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>
|
||||
</a>
|
||||
<a href="Compose.dc.html" 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; text-decoration: none">
|
||||
<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>
|
||||
</a>
|
||||
<span aria-hidden="true" style="width: 1px; height: 20px; margin: 0 6px; background: #E6E8EC"></span>
|
||||
<button type="button" style="display: inline-flex; align-items: center; gap: 6px; min-height: 44px; box-sizing: border-box; padding: 0 10px; border: 0; border-radius: 8px; background: transparent; color: #B42318; 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"><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>
|
||||
</button>
|
||||
</div></div>
|
||||
<div 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">
|
||||
<h1 id="mail-subject" style="flex: 1 1 auto; margin: 4px 0 0; font-size: 20px; line-height: 1.4; font-weight: 650; letter-spacing: -0.015em">{{ m.subject }}</h1>
|
||||
<button type="button" onClick="{{ toggleStarCur }}" aria-label="{{ m.starLabel }}" aria-pressed="{{ m.starredStr }}" style="flex: 0 0 auto; width: 44px; height: 44px; display: inline-flex; align-items: center; justify-content: center; border: 0; border-radius: 8px; background: transparent; color: {{ m.starColor }}; cursor: pointer">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="{{ m.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>
|
||||
</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">{{ m.initial }}</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">{{ m.fromName }}</span>
|
||||
<span style="font-size: 12.5px; color: #667085">{{ m.fromEmail }}</span>
|
||||
<span style="margin-left: auto; font-size: 12.5px; color: #667085; white-space: nowrap; font-variant-numeric: tabular-nums">{{ m.date }}</span>
|
||||
</div>
|
||||
<span style="font-size: 12.5px; color: #4A5363">받는 사람 {{ m.to }}</span>
|
||||
<span style="font-size: 12.5px; color: #667085">{{ m.boxLine }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div style="white-space: pre-line; font-size: 14px; line-height: 1.75; color: #1F2733; max-width: 680px">{{ m.body }}</div>
|
||||
<sc-if value="{{ m.hasAtt }}" hint-placeholder-val="{{ true }}">
|
||||
<section aria-labelledby="att-title" style="display: flex; flex-direction: column; gap: 8px; padding-top: 16px; border-top: 1px solid #EEF0F3">
|
||||
<h2 id="att-title" style="margin: 0; font-size: 12.5px; font-weight: 600; color: #4A5363">첨부 파일 {{ m.attCount }}개</h2>
|
||||
<ul style="list-style: none; margin: 0; padding: 0; display: grid; grid-template-columns: repeat(auto-fill, minmax(min(260px, 100%), 1fr)); gap: 8px">
|
||||
<sc-for list="{{ m.atts }}" as="att" hint-placeholder-count="1">
|
||||
<li style="display: flex; align-items: center; gap: 10px; 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>
|
||||
<div 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">{{ att.name }}</span>
|
||||
<span style="font-size: 12px; color: #667085">{{ att.size }}</span>
|
||||
</div>
|
||||
<button type="button" aria-label="{{ att.label }}" style="flex: 0 0 auto; width: 44px; height: 44px; display: inline-flex; align-items: center; justify-content: center; border: 0; border-radius: 8px; background: transparent; color: #344054; cursor: pointer">
|
||||
<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"><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>
|
||||
</button>
|
||||
</li>
|
||||
</sc-for>
|
||||
</ul>
|
||||
</section>
|
||||
</sc-if>
|
||||
</div>
|
||||
</article>
|
||||
</sc-if>
|
||||
|
||||
<sc-if value="{{ noSel }}" hint-placeholder-val="{{ false }}">
|
||||
<div style="flex: 1 1 auto; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 6px; min-height: 520px; padding: 24px; text-align: center">
|
||||
<svg width="36" height="36" viewBox="0 0 24 24" fill="none" stroke="#98A2B3" stroke-width="1.5" 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>
|
||||
<p style="margin: 6px 0 0; font-size: 14px; font-weight: 600">메일을 선택하세요</p>
|
||||
<p style="margin: 0; font-size: 12.5px; color: #667085">검색 결과에서 메일을 고르면 내용이 여기에 표시됩니다.</p>
|
||||
</div>
|
||||
</sc-if>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</x-dc>
|
||||
<script type="text/x-dc" data-dc-script data-props='{"variant":{"editor":"enum","options":["결과","찾는 중","결과 없음"],"default":"결과"},"$preview":{"width":1440,"height":900}}'>
|
||||
class Component extends DCLogic {
|
||||
state = { scope: 'box', sel: 'r1', read: {}, star: {} };
|
||||
|
||||
results() {
|
||||
return [
|
||||
{
|
||||
id: 'r1', box: 'inbox', who: '이도윤', fromName: '이도윤', fromEmail: 'doyun.lee@arasystem.it.kr',
|
||||
to: '김아라 <ara.kim@arasystem.it.kr>', subject: 'Re: 주간 회의 안건 공유', time: '오전 10:42',
|
||||
date: '2026. 10. 27. (화) 오전 10:42', unread: true,
|
||||
body: '김아라님, 안녕하세요.\n\n목요일 주간 회의 안건을 정리해 보냅니다.\n메일 서버 전환 일정과 백업 스토리지 증설 건을 먼저 다루면 좋겠습니다.\n\n이도윤 드림',
|
||||
atts: [{ name: '주간회의_안건_1029.docx', size: '36 KB' }],
|
||||
},
|
||||
{
|
||||
id: 'r2', box: 'inbox', who: '박준호', fromName: '박준호', fromEmail: 'junho.park@arasystem.it.kr',
|
||||
to: '인프라팀 <infra@arasystem.it.kr>', subject: '11월 인프라 점검 일정 조율', time: '오전 9:05',
|
||||
date: '2026. 10. 27. (화) 오전 9:05', unread: true,
|
||||
body: '인프라팀 여러분,\n\n11월 점검 일정을 정하려고 합니다.\n다음 주 화요일 오후에 짧게 회의를 잡겠습니다. 안 되시는 분은 알려 주세요.\n\n박준호',
|
||||
atts: [],
|
||||
},
|
||||
{
|
||||
id: 'r3', box: 'sent', who: '받는 사람: 정하늘', fromName: '김아라', fromEmail: 'ara.kim@arasystem.it.kr',
|
||||
to: '정하늘 <haneul.jung@arasystem.it.kr>', subject: 'Re: 신규 입사자 교육 회의록', time: '어제',
|
||||
date: '2026. 10. 26. (월) 오후 5:48', unread: false,
|
||||
body: '하늘님,\n\n회의록 잘 받았습니다. 메일 계정은 입사 전날까지 만들어 두겠습니다.\n\n김아라',
|
||||
atts: [],
|
||||
},
|
||||
{
|
||||
id: 'r4', box: 'inbox', who: '최서연', fromName: '최서연', fromEmail: 'seoyeon.choi@arasystem.it.kr',
|
||||
to: '전체 구성원 <all@arasystem.it.kr>', subject: '10월 경비 정산 일정 변경', time: '10월 23일',
|
||||
date: '2026. 10. 23. (금) 오후 4:20', unread: false,
|
||||
body: '안녕하세요, 재무팀 최서연입니다.\n\n오늘 회의에서 정한 대로 10월분 경비 마감을 11월 4일(수)로 미룹니다.\n증빙은 지금처럼 사내 시스템에 올려 주세요.',
|
||||
atts: [],
|
||||
},
|
||||
{
|
||||
id: 'r5', box: 'drafts', who: '받는 사람: 운영팀', fromName: '김아라', fromEmail: 'ara.kim@arasystem.it.kr',
|
||||
to: '운영팀 <ops@arasystem.it.kr>', subject: '11월 운영 회의 안건 (작성 중)', time: '10월 22일',
|
||||
date: '2026. 10. 22. (목) 오후 2:10', unread: false,
|
||||
body: '운영팀 여러분,\n\n11월 운영 회의는 둘째 주 목요일에 하려고 합니다.\n안건은 아래와 같습니다.',
|
||||
atts: [],
|
||||
},
|
||||
{
|
||||
id: 'r6', box: 'inbox', who: '인사팀', fromName: '인사팀', fromEmail: 'hr@arasystem.it.kr',
|
||||
to: '전체 구성원 <all@arasystem.it.kr>', subject: '[인사팀] 전사 회의 참석 안내', time: '10월 19일',
|
||||
date: '2026. 10. 19. (월) 오전 10:00', unread: false,
|
||||
body: '구성원 여러분, 인사팀입니다.\n\n10월 30일(금) 오후 4시 전사 회의에 참석해 주세요.\n장소와 순서는 첨부 안내문에 있습니다.',
|
||||
atts: [{ name: '전사회의_안내.pdf', size: '220 KB' }],
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
renderVals() {
|
||||
const st = this.state;
|
||||
const variant = this.props.variant ?? '결과';
|
||||
const BOXNAME = { inbox: '받은편지함', sent: '보낸편지함', drafts: '임시보관함' };
|
||||
const isAll = st.scope === 'all';
|
||||
const inScope = this.results().filter((x) => isAll || x.box === 'inbox');
|
||||
const list = variant === '결과' ? inScope : [];
|
||||
const isUnread = (x) => x.unread && !st.read[x.id];
|
||||
const starOf = (id) => !!st.star[id];
|
||||
|
||||
const PANEL = [
|
||||
{ id: 'inbox', name: '받은편지함', unread: 3 },
|
||||
{ id: 'sent', name: '보낸편지함', unread: 0 },
|
||||
{ id: 'drafts', name: '임시보관함', unread: 0 },
|
||||
{ id: 'junk', name: '스팸메일함', unread: 0 },
|
||||
{ id: 'trash', name: '휴지통', unread: 0 },
|
||||
];
|
||||
const boxes = PANEL.map((b) => {
|
||||
const active = !isAll && b.id === 'inbox';
|
||||
return {
|
||||
name: b.name,
|
||||
isInbox: b.id === 'inbox', isSent: b.id === 'sent', isDrafts: b.id === 'drafts', isJunk: b.id === 'junk', isTrash: b.id === 'trash',
|
||||
current: active ? 'page' : 'false',
|
||||
bg: active ? '#E3F2F3' : 'transparent',
|
||||
fg: active ? '#0A5F67' : '#344054',
|
||||
fw: active ? '600' : '500',
|
||||
countColor: active ? '#0A5F67' : '#171C26',
|
||||
unread: b.unread,
|
||||
hasUnread: b.unread > 0,
|
||||
};
|
||||
});
|
||||
|
||||
const items = list.map((x) => {
|
||||
const selected = x.id === st.sel;
|
||||
const unread = isUnread(x);
|
||||
const starred = starOf(x.id);
|
||||
return {
|
||||
who: x.who,
|
||||
subject: x.subject,
|
||||
time: x.time,
|
||||
hasAtt: x.atts.length > 0,
|
||||
showBox: isAll,
|
||||
boxName: BOXNAME[x.box],
|
||||
bg: selected ? '#E3F2F3' : '#FFFFFF',
|
||||
fw: unread ? '650' : '450',
|
||||
whoColor: unread ? '#171C26' : '#344054',
|
||||
subFw: unread ? '600' : '400',
|
||||
timeFw: unread ? '600' : '400',
|
||||
timeColor: unread ? '#171C26' : '#667085',
|
||||
starLabel: starred ? '중요 표시 해제' : '중요 표시',
|
||||
starredStr: starred ? 'true' : 'false',
|
||||
starColor: starred ? '#B7791F' : '#98A2B3',
|
||||
starFill: starred ? '#B7791F' : 'none',
|
||||
pick: () => this.setState({ sel: x.id, read: { ...st.read, [x.id]: true } }),
|
||||
toggleStar: () => this.setState({ star: { ...st.star, [x.id]: !starred } }),
|
||||
};
|
||||
});
|
||||
|
||||
const cur = list.find((x) => x.id === st.sel) || null;
|
||||
const curStarred = cur ? starOf(cur.id) : false;
|
||||
const base = cur || { subject: '', fromName: '', fromEmail: '', to: '', date: '', body: '', atts: [], box: 'inbox' };
|
||||
const m = {
|
||||
subject: base.subject,
|
||||
fromName: base.fromName,
|
||||
fromEmail: base.fromEmail,
|
||||
to: base.to,
|
||||
date: base.date,
|
||||
body: base.body,
|
||||
boxLine: cur ? '메일함 ' + BOXNAME[base.box] : '',
|
||||
initial: base.fromName ? base.fromName.slice(0, 1) : '',
|
||||
atts: base.atts.map((a) => ({ name: a.name, size: a.size, label: a.name + ' 다운로드' })),
|
||||
attCount: base.atts.length,
|
||||
hasAtt: base.atts.length > 0,
|
||||
starLabel: curStarred ? '중요 표시 해제' : '중요 표시',
|
||||
starredStr: curStarred ? 'true' : 'false',
|
||||
starColor: curStarred ? '#B7791F' : '#98A2B3',
|
||||
starFill: curStarred ? '#B7791F' : 'none',
|
||||
};
|
||||
|
||||
return {
|
||||
boxes: boxes,
|
||||
items: items,
|
||||
countText: variant === '찾는 중' ? '‘회의’ · 찾는 중' : `‘회의’ · ${list.length}통`,
|
||||
isAll: isAll,
|
||||
boxPressed: isAll ? 'false' : 'true',
|
||||
allPressed: isAll ? 'true' : 'false',
|
||||
scopeBox: () => this.setState({ scope: 'box', sel: 'r1' }),
|
||||
scopeAll: () => this.setState({ scope: 'all', sel: 'r1' }),
|
||||
showList: variant === '결과',
|
||||
showLoading: variant === '찾는 중',
|
||||
showEmpty: variant === '결과 없음',
|
||||
offerAll: !isAll,
|
||||
skeleton: [{ a: '38%', b: '72%' }, { a: '30%', b: '64%' }, { a: '44%', b: '80%' }, { a: '26%', b: '58%' }, { a: '36%', b: '70%' }, { a: '32%', b: '62%' }],
|
||||
hasSel: !!cur,
|
||||
noSel: !cur,
|
||||
m: m,
|
||||
toggleStarCur: () => {
|
||||
if (!cur) return;
|
||||
this.setState({ star: { ...st.star, [cur.id]: !curStarred } });
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -13,12 +13,16 @@
|
||||
"MainListRead.dc.html": { "x": 3040, "y": 1020, "w": 1440, "h": 900, "title": "목록 보기 · 메일 읽기", "expand": "fill", "is_interactive": true },
|
||||
"ComposeEditor.dc.html": { "x": 4560, "y": 0, "w": 1440, "h": 3576, "title": "메일 쓰기 · 편집기 상태" },
|
||||
"Settings.dc.html": { "x": 0, "y": 2480, "w": 1440, "h": 900, "title": "설정 · 메일 서명", "expand": "fill", "is_interactive": true },
|
||||
"ComposeAssist.dc.html": { "x": 1520, "y": 2480, "w": 1440, "h": 1400, "title": "메일 쓰기 · 임시 저장 · 자동 완성 · 서명" }
|
||||
"ComposeAssist.dc.html": { "x": 1520, "y": 2480, "w": 1440, "h": 1400, "title": "메일 쓰기 · 임시 저장 · 자동 완성 · 서명" },
|
||||
"Search.dc.html": { "x": 0, "y": 4480, "w": 1440, "h": 900, "title": "메일 검색 · 분할 보기", "expand": "fill", "is_interactive": true },
|
||||
"BulkSelect.dc.html": { "x": 1520, "y": 4480, "w": 1440, "h": 900, "title": "여러 통 선택 · 목록 보기", "expand": "fill", "is_interactive": true },
|
||||
"ListStates.dc.html": { "x": 3040, "y": 4480, "w": 1440, "h": 1560, "title": "새 메일 · 검색 · 여러 통 처리 상태" }
|
||||
},
|
||||
"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"],
|
||||
"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"],
|
||||
"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 }
|
||||
"phase2": { "x": 0, "y": 2180, "text": "2단계 화면 — 임시 저장 · 받는 사람 자동 완성 · 서명 (2026-10-27 확정)", "kind": "title1", "maxW": 2960 },
|
||||
"phase3": { "x": 0, "y": 4180, "text": "3단계 화면 — 새 메일 · 검색 · 여러 통 처리 (2026-10-27 확정)", "kind": "title1", "maxW": 4480 }
|
||||
},
|
||||
"designSystems": []
|
||||
}
|
||||
|
||||
+112
-6
@@ -176,10 +176,13 @@ HTML 은 정화하기 **전에** 길이(HTML 부분이 여럿이면 이어 붙
|
||||
| --- | --- | --- | --- |
|
||||
| `GET /api/mailboxes` | — | 200 `{ items: Mailbox[], pageInfo }` (hasNext=false) | `MAIL_001` |
|
||||
| `GET /api/mail-quota` | — | 200 `MailQuota` | `MAIL_001` |
|
||||
| `GET /api/messages` | `mailboxId`(필수) · `cursor` · `limit`(기본 20, 최대 100) · `q`(선택) | 200 `{ items: MessageSummary[], pageInfo }` — 받은 시각 내림차순, `total` 포함 | 400 `VAL_001`, `MAIL_001` |
|
||||
| `GET /api/mail-state` | — | 200 `MailState` (아래 "새 메일 확인") | 503 `MAIL_001` |
|
||||
| `GET /api/messages` | `mailboxId` · `cursor` · `limit`(기본 20, 최대 100) · `q` — `mailboxId` 와 `q` 중 하나는 있어야 한다(`q` 만 있으면 휴지통·스팸메일함을 뺀 모든 메일함에서 찾는다 — 아래 "검색") | 200 `{ items: MessageSummary[], pageInfo }` — 받은 시각 내림차순, `total` 포함 | 400 `VAL_001`, `MAIL_001` |
|
||||
| `GET /api/messages/{id}` | — | 200 `MessageDetail` — **조회만으로 읽음 처리하지 않는다** | 404 `RES_001` |
|
||||
| `PATCH /api/messages/{id}` | `{ isRead?, isFlagged?, mailboxId? }` (하나 이상) | 200 `MessageSummary` | 404 `RES_001`, 400 `VAL_001` |
|
||||
| `DELETE /api/messages/{id}` | — | 204 — 휴지통이 아니면 휴지통으로 이동, 휴지통에만 있으면 영구 삭제(확인한 상태 그대로일 때만 — 아래). 휴지통과 다른 메일함에 함께 있으면 휴지통에서만 뺀다 | 404 `RES_001`, 503 `MAIL_001`(확인하는 사이 계속 바뀜 — 지우지 않았다) |
|
||||
| `PATCH /api/messages` | `{ ids, isRead?, isFlagged?, mailboxId? }` — 여러 통 (아래 "여러 통 한꺼번에") | 200 `MessagesPatched` | 400 `VAL_001`, 404 `RES_001`(없는 `mailboxId` — 아무것도 바꾸지 않음), 503 `MAIL_001` |
|
||||
| `DELETE /api/messages/{id}` | — | 204 — 휴지통이 아니면 휴지통으로 이동, 휴지통에만 있으면 영구 삭제(확인한 상태 그대로일 때만 — 아래). 휴지통과 다른 메일함에 함께 있으면 휴지통에서만 뺀다. **웹 클라이언트는 쓰지 않는다**(3단계 — 아래 "여러 통 한꺼번에") | 404 `RES_001`, 503 `MAIL_001`(확인하는 사이 계속 바뀜 — 지우지 않았다) |
|
||||
| `POST /api/message-deletions` | `{ ids }` — 휴지통에서 영구 삭제, 여러 통 (아래 "여러 통 한꺼번에") | 200 `MessageDeletionResult` | 400 `VAL_001`, 503 `MAIL_001`(확인하는 사이 계속 바뀜 · 휴지통 없음 — 지우지 않았다) |
|
||||
| `POST /api/messages` | 아래 발송 본문 + `Idempotency-Key` 헤더 | 201 `{ id }` (보낸편지함의 메일 id) | 400 `MAIL_002`·`MAIL_003`·`MAIL_004`·`MAIL_006`·`MAIL_007`·`VAL_001`, 403 `AUTH_003`(보낼 권한 없음), 409 `MAIL_005`(같은 키·다른 내용), 413 `FILE_002`(첨부 합계 초과 포함), 429 `REQ_001`(엔진 발송 한도·같은 키 처리 중·엔진 장애 직후 30초), 503 `MAIL_001` |
|
||||
| `POST /api/mail-attachments` | multipart `file` 1개 | 201 `Attachment` | 400 `FILE_001`, 413 `FILE_002` |
|
||||
| `GET /api/messages/{id}/attachments/{attachmentId}` | — | 파일 (표준 래퍼 없음, `Content-Disposition: attachment`) | 404 `RES_001` |
|
||||
@@ -197,6 +200,7 @@ HTML 은 정화하기 **전에** 길이(HTML 부분이 여럿이면 이어 붙
|
||||
| 상황 | 응답 |
|
||||
| --- | --- |
|
||||
| 없는 `mailboxId`, 엔진이 해석하지 못하는 `mailboxId` | 404 `RES_001` |
|
||||
| 엔진이 해석하지 못하는 id — 여러 통 처리의 `ids`(`PATCH /api/messages` · `POST /api/message-deletions`), 메일함 없이 하는 검색 | 400 `VAL_001` (본문에서 온 값이라서 — 메일함 하나의 목록은 위처럼 404 `RES_001`) |
|
||||
| 형식이 틀린 경로 id | 404 `RES_001` |
|
||||
| 받는 사람 합계 100명 초과 (배열 하나가 100을 넘으면 `VAL_001`) | 400 `MAIL_002` |
|
||||
| 엔진에서 찾을 수 없는 첨부 id — 올려 두고 오래 지나 만료된 업로드, 그사이 지워진 원본 메일의 첨부 등 | 400 `MAIL_004` — 클라이언트는 해당 첨부를 실패로 표시한다. 올린 파일은 다시 올리게, 전달하는 원본 첨부는 빼게 한다 (원본은 다시 올릴 수 없다) |
|
||||
@@ -348,7 +352,8 @@ interface DraftSaved { id: string; attachments: Attachment[]; updatedAt: string
|
||||
- 옛것은 **확인한 뒤로 메일함이 바뀌지 않았을 때만** 지운다(JMAP `ifInState`, 엇갈리면 다시 확인 — 모두 세 번까지). 지우기는 되돌릴 수 없어서다.
|
||||
그사이 다른 곳에서 옮겼으면 지우지 않고, 계속 엇갈리면 옛것이 한 통 더 남는다 (저장은 성공).
|
||||
- **목록·버리기·되돌리기**는 기존 API 다 — 목록 `GET /api/messages?mailboxId=<임시보관함>`(보내기 중에 생긴 내부 초안은 나오지 않는다),
|
||||
버리기 `DELETE /api/messages/{id}`(휴지통으로), 되돌리기 `PATCH /api/messages/{id}` `{ mailboxId: <임시보관함> }`.
|
||||
버리기는 휴지통으로 옮기기 `PATCH /api/messages/{id}` `{ mailboxId: <휴지통> }`(웹은 `DELETE` 를 쓰지 않는다 — 이미 휴지통인 메일은 `DELETE` 가 영구 삭제한다),
|
||||
되돌리기 `PATCH /api/messages/{id}` `{ mailboxId: <임시보관함> }`.
|
||||
- **보내기**: 이어 쓴 초안은 발송 본문에 `draftId` 를 넣어 보낸다. 서버는 지금의 발송 경로(정화·지문·중복 방지 그대로)로 보내고, 성공한 뒤
|
||||
(같은 키 재시도로 처음 결과를 돌려줄 때 포함) 그 초안을 지운다 — 초안 자체를 그대로 제출하지 않는다. 지우기는 위 저장과 같은 조건(임시보관함의 초안 ·
|
||||
확인한 뒤 바뀌지 않음)일 때만 하고, 못 지우면 로그만 남긴다(발송은 성공). `draftId` 는 지문에 넣지 않는다.
|
||||
@@ -426,11 +431,112 @@ interface RecipientSuggestion {
|
||||
(퇴사자가 계속 맨 앞에 나와 반송되지 않게 — 웹메일 세션은 최대 30일이라 쓰는 사람은 그 안에 다시 로그인한다).
|
||||
- **알려진 한계**: 최근 주고받은 주소의 이름은 받은 메일의 보낸 사람 표시 이름에서 온다 — 바깥 사람이 동료 이름을 표시 이름으로 쓰면 그 이름으로 추천된다.
|
||||
추천 줄은 늘 주소를 함께 보이고, 사내와 최근 묶음을 나눠 보인다.
|
||||
- 응답은 `Cache-Control: private, no-store`. 서버는 사용자마다 10분 캐시하고(서버 메모리, 최대 1,000명), 그 사람이 메일을 보내면 다시 모은다.
|
||||
- 응답은 `Cache-Control: private, no-store`. 서버는 사용자마다 10분 캐시하고(서버 메모리, 최대 500명), 그 사람이 메일을 보내면 다시 모은다.
|
||||
같은 사람의 동시 요청은 한 번만 모은다. 로그에는 개수만 남긴다.
|
||||
- **클라이언트**: 쓰기 화면을 열 때 `q` 없이 `limit=100` 으로 한 번 받아 두고 치는 대로 거른다(초성 포함). 맞는 것이 모자랄 때만 `q` 로 다시 묻는다(250ms 기다린 뒤).
|
||||
|
||||
### 새 메일 확인 (`/api/mail-state`) — 3단계
|
||||
|
||||
```ts
|
||||
// GET /api/mail-state — 새 메일 · 다른 창(메일 프로그램)의 변화를 알아채는 데 쓴다
|
||||
interface MailState {
|
||||
state: string // 불투명 — 메일함이나 메일이 하나라도 바뀌면 달라진다. 해석하지 말고 지난 값과 같은지만 본다
|
||||
}
|
||||
```
|
||||
|
||||
- 서버는 엔진의 메일함 · 메일 상태 문자열을 이어 붙여 돌려준다 (JMAP `Mailbox/get` · `Email/get` 에 `ids: []` — 메일을 읽지 않는 가벼운 조회).
|
||||
새 메일이 오거나 다른 창 · 메일 프로그램이 읽음 · 이동 · 삭제를 하면 값이 바뀐다 (2026-10-27 개발 엔진에서 확인). 응답은 `Cache-Control: private, no-store`.
|
||||
- **클라이언트**: 로그인한 동안, 화면이 보이는 동안(`visibilityState === 'visible'`)만 60초마다 묻는다. 탭이 다시 보이면 마지막으로 물은 지 10초가 지났을 때 바로 한 번. 한 번에 하나씩.
|
||||
- 처음 받은 값은 기준으로만 쓴다. 값이 바뀌면 메일함 개수 · 사용량을 다시 받고, 보이는 메일함 목록을 **지금까지 불러온 만큼(최대 100통)** 다시 받아 맞춘다 —
|
||||
고른 메일 · 열린 메일 · 스크롤 위치는 그대로. 100통보다 많이 불러왔으면 첫 100통만 맞추고 그 뒤는 둔다.
|
||||
검색 결과는 다시 찾지 않는다(보던 결과가 저절로 바뀌지 않게).
|
||||
- 이 탭이 바꾼 것(읽음 · 이동 등)도 값을 바꾸므로 다음 확인에서 한 번 더 받게 된다 — 해롭지 않다.
|
||||
- 실패(`MAIL_001` · 네트워크)는 알리지 않고(`silent`) 다음 주기에 다시 묻는다. 401 은 지금의 세션 흐름(갱신 1회 → 만료 대화상자)을 그대로 탄다.
|
||||
- 받은편지함에 안 읽은 메일이 있으면 탭 제목 앞에 `(N) ` 을 붙인다 (디자인 README "3단계").
|
||||
- 요청 수 한도(`THROTTLE_LIMIT`, 기본 1분 100회)는 경로마다 · IP 마다 센다 — 사무실 전체가 한 IP 로 나가면 보이는 탭이 100개를 넘을 때
|
||||
1분 확인이 429 `REQ_001` 로 조용히 실패한다(다음 주기에 다시). 값이 바뀌었을 때의 맞추기(`GET /api/messages` · `/api/mailboxes` · `/api/mail-quota`)는
|
||||
평소 사용과 같은 칸을 쓴다 — `GET /api/messages` 는 대략 "쓰고 있는 탭 수 × 1분에 1번 + 평소 목록 · 더 보기"다. 운영 전환 때 직원 수에 맞춰 정한다.
|
||||
- 실시간 푸시는 이 단계에서 하지 않는다. 엔진은 JMAP EventSource 를 지원하므로(2026-10-27 확인) 1분이 모자라면 백엔드가 SSE 로 중계하는 방식으로 바꾼다 —
|
||||
그때는 nginx · 외부 프록시의 응답 버퍼링과 시간 제한도 함께 바꿔야 한다.
|
||||
|
||||
### 검색 (`GET /api/messages` 의 `q`) — 3단계
|
||||
|
||||
- `mailboxId` 가 있으면 그 메일함에서, **없으면 휴지통 · 스팸메일함을 뺀 모든 메일함**에서 찾는다. 둘 다 없으면 400 `VAL_001`.
|
||||
- `q` 는 앞뒤 공백을 뺀 1~200자(JS 문자열 길이 — UTF-16 단위), 낱말(띄어쓰기로 나눈 것) 8개까지(넘으면 400 `VAL_001`). 보낸 사람 · 받는 사람 · 참조 · 제목 · 본문에서 찾고,
|
||||
여러 낱말이면 모두 들어간 메일만 찾는다. 큰따옴표로 감싼 부분은 그 구절 그대로 찾는다.
|
||||
낱말 앞뒤의 문장부호는 떼고 찾는다('회의,' · '(회의)' → '회의' — 엔진도 문장부호에서 낱말을 나눈다, 가운데의 문장부호는 둔다).
|
||||
글자 · 숫자가 하나도 없는 낱말(`-` · 따옴표뿐 등)은 찾지 않고, 찾을 낱말이 하나도 남지 않으면 엔진에 묻지 않고 빈 결과(200)다.
|
||||
- **한국어 — 서버가 보완한다**: 엔진(Stalwart 0.16.27)의 전문 검색은 띄어쓰기 · 문장부호로 나눈 낱말이 **통째로 같을 때만** 맞는다 —
|
||||
'회의'로는 '회의를'을 찾지 못한다 (2026-10-27 개발 엔진에서 확인). 그래서 서버는 한글로 끝나는 낱말마다 흔한 조사를 받침에 맞게 붙인 형태를 함께 찾는다
|
||||
(회의 → 회의가 · 회의를 · 회의는 · 회의에서 · 회의입니다 … / 일정 → 일정이 · 일정을 · 일정은 …, 낱말 안에서는 OR · 낱말끼리는 AND).
|
||||
영문 · 숫자로 끝나는 낱말(ERP · 2026)은 받침을 알 수 없어 두 가지를 다 붙인다. 따옴표 구절에는 붙이지 않는다.
|
||||
조사 목록은 서버 상수 한 곳(`jmap/search-terms.ts`)에 둔다 — 한글로 끝나는 낱말은 형태 44개, 영문 · 숫자로 끝나면 59개가 되고,
|
||||
개발 엔진에서 낱말 8개(조건 472개)를 전체 메일함에서 찾는 데 0.16초였다.
|
||||
- **알려진 한계** (엔진 색인 때문 — 자체 검색 색인을 두기 전까지): 붙여 쓴 말의 일부('회의실'의 '회의', '서버이전'의 '서버'), 앞부분만 친 말('견적' → '견적서'),
|
||||
끝이 바뀐 말('보냈습니다'의 '보내'), **제목 맨 앞의 말머리**('[공지]' · '[긴급]' · 'Re:' · 'Fwd:' — 엔진이 제목을 정규화하며 색인에서 뺀다. 제목 가운데의 '[결재]'는 찾는다).
|
||||
화면은 결과가 없을 때 이 한계를 한 줄로 알린다.
|
||||
- 결과는 받은 시각 내림차순, `total` 포함. 커서는 그 검색어 · 범위로만 쓴다(다르면 400 `VAL_001`). 발송 중인 내부 초안은 지금처럼 빠진다.
|
||||
- 검색어는 접근 로그 · 백엔드 로그에 남기지 않는다 — 백엔드는 지금처럼 `maskUrlForLog()`(`q` · `cursor` 는 길이만). 화면 주소에도 검색어가 들어가므로 nginx 는 `/api` 뿐 아니라
|
||||
**화면 요청의 접근 로그도 쿼리 없이** 남기고(Referer 도 `?` 앞까지), 화면 응답의 `Referrer-Policy` 를 `strict-origin` 으로 둔다 —
|
||||
검색 화면에서 나가는 요청(1분 확인 등)의 Referer 에 검색어가 실리지 않게. 오류 로그는 장애 원인 줄을 잃지 않게 지금처럼 요청 줄을 쿼리째 남긴다 (docker 스킬 "로그").
|
||||
- **클라이언트**: 상단 검색 칸(메일 화면에서는 "메일 검색")에서 Enter 로 찾는다 — 치는 동안에는 묻지 않는다.
|
||||
서버와 같은 한도(200자 · 낱말 8개)를 먼저 보고, 낱말이 8개를 넘으면 보내지 않고 `VAL_001` 사전 문구로 알린다.
|
||||
검색 화면 안에서 다시 찾거나 범위를 바꾸면 방문 기록을 쌓지 않고 주소를 바꿔 끼운다(메일함에서 시작한 첫 검색만 기록에 쌓인다).
|
||||
주소는 `/mail/search?q=…&in=<메일함 id | all>`, 결과에서 연 메일은 `/mail/search/<메일 id>?q=…&in=…` (뒤로 가기: 메일 → 결과 → 검색 전 메일함).
|
||||
범위의 기본값은 검색을 시작한 메일함이고 결과 머리에서 "그 메일함 | 전체 메일함"으로 바꾼다(휴지통 · 스팸메일함에서 시작하면 그 안에서 찾는다).
|
||||
✕ · Esc · "검색 닫기"는 검색 전 메일함으로 돌아간다. 임시보관함의 결과를 누르면 지금처럼 쓰기 화면에서 이어 쓴다.
|
||||
|
||||
### 여러 통 한꺼번에 (`PATCH /api/messages` · `POST /api/message-deletions`) — 3단계
|
||||
|
||||
```ts
|
||||
// PATCH /api/messages — 읽음 · 중요 표시 · 이동을 여러 통에
|
||||
interface MessagesPatch {
|
||||
ids: string[] // 1~100, 같은 id 를 두 번 넣지 않는다
|
||||
isRead?: boolean
|
||||
isFlagged?: boolean
|
||||
mailboxId?: string // 이동 = 그 메일함 하나에만 속하게 (단건 PATCH 와 같다)
|
||||
} // 바꿀 것이 하나 이상
|
||||
interface MessagesPatched {
|
||||
updatedIds: string[]
|
||||
failedIds: string[] // 없음 · 그사이 지워짐 · 엔진이 거절함 — 나머지는 그대로 처리한다
|
||||
}
|
||||
|
||||
// POST /api/message-deletions — 휴지통에서 영구 삭제, 여러 통
|
||||
interface MessageDeletionRequest { ids: string[] } // 1~100, 같은 id 를 두 번 넣지 않는다
|
||||
interface MessageDeletionResult {
|
||||
deletedIds: string[] // 영구 삭제했다(휴지통에만 있었다) · 휴지통에서만 뺐다(다른 메일함에도 있었다)
|
||||
skippedIds: string[] // 휴지통에 없음 · 없음 · 엔진이 거절 — 지우지 않았다
|
||||
}
|
||||
```
|
||||
|
||||
- `PATCH /api/messages` 는 엔진 요청 한 번으로 처리한다. 없는 `mailboxId` 는 404 `RES_001` 이고 아무것도 바꾸지 않는다.
|
||||
503 `MAIL_001` 이면 일부만 바뀌었을 수 있다 — 같은 요청을 다시 보내도 결과가 같다(멱등).
|
||||
- `POST /api/message-deletions` 는 **휴지통에 있는 메일만** 다룬다 — `DELETE /api/messages/{id}` 와 달리 휴지통 밖의 메일을 휴지통으로 옮기지 않는다.
|
||||
대상을 확인한 뒤 확인한 상태 그대로일 때만 한 번에 지운다(`ifInState` — 그사이 다른 창이 휴지통 밖으로 되돌린 메일을 지우지 않는다).
|
||||
어긋나면 다시 확인해 합계 `CONDITIONAL_DESTROY_ATTEMPTS` 번, 계속 어긋나면 **아무것도 지우지 않고** 503 `MAIL_001`. 휴지통이 없어도 503 `MAIL_001`.
|
||||
지우는 요청이 엔진 시간 초과로 끝난 503 `MAIL_001` 만은 일부가 지워졌을 수 있다 — 같은 요청을 다시 보내면 이미 지운 메일은 `skippedIds` 로 온다.
|
||||
한 번에 지우기(destroy)와 휴지통에서 빼기(update)를 함께 보내고, 옛 상태면 엔진이 둘 다 거절한다 (2026-10-27 개발 엔진에서 확인).
|
||||
- 한 요청의 `ids` 는 100개까지다 (엔진의 한 번 처리 한도 500 보다 작게).
|
||||
- **클라이언트**:
|
||||
- 고를 수 있는 것은 불러온 메일까지다 (표 머리의 고르기 = 불러온 메일 모두). 100통이 넘으면 100통씩 차례로 보낸다.
|
||||
- 휴지통 밖의 「삭제」는 `PATCH { mailboxId: 휴지통 }` 이다 — 「되돌리기」는 원래 메일함별로 묶어 다시 `PATCH`.
|
||||
`POST /api/message-deletions` 는 휴지통에서 「영구 삭제」를 확인한 뒤에만 부른다.
|
||||
- **웹 클라이언트는 `DELETE /api/messages/{id}` 를 쓰지 않는다** — 그사이 다른 창이 휴지통으로 옮긴 메일을 확인 없이 영구 삭제하게 된다.
|
||||
읽기 화면의 「삭제」도 그 메일의 `mailboxIds` 를 보고 휴지통 밖이면 휴지통으로 `PATCH`(+ 되돌리기), 휴지통이면 확인 대화상자 뒤 `POST /api/message-deletions` 다.
|
||||
휴지통 목록 밖(검색 결과 등)에서는 **휴지통에만 있는** 메일만 「영구 삭제」다 — 휴지통과 다른 메일함에 함께 있는 메일은 「삭제」(휴지통으로 옮기기)로 다룬다
|
||||
(영구 삭제를 확인했는데 서버가 휴지통에서만 빼고 다른 메일함에 남기는 일이 없게).
|
||||
`mailboxIds` 가 낡았어도 해가 없다 — 이미 휴지통인 메일을 휴지통으로 옮기면 그대로이고, 휴지통 밖으로 돌아간 메일은 서버가 `skippedIds` 로 건너뛴다.
|
||||
- **알려진 한계**: 「되돌리기」는 이 화면이 알던 메일함 한 곳으로 되돌린다 — 그사이 다른 창이 옮긴 메일(저절로 바뀌지 않는 검색 결과 등)은 그 전 메일함으로,
|
||||
여러 메일함에 함께 있던 메일(다른 메일 프로그램의 복사)은 첫 메일함으로만 돌아간다.
|
||||
- 「되돌리기」 알림은 만든 세션에서만 움직인다 — 로그아웃 · 세션 만료 · 다른 계정 로그인 뒤에는 아무것도 하지 않는다(메일 id 는 계정마다 따로 매겨져 겹칠 수 있다).
|
||||
- 읽음 · 중요 표시는 화면을 먼저 바꾸고 `failedIds` 만 되돌린다. 이동 · 삭제는 응답을 받은 뒤 `updatedIds` · `deletedIds` 만 목록에서 빼고 개수를 다시 맞춘다
|
||||
(전체 메일함 검색 결과에서 범위 안의 다른 메일함으로 옮긴 메일은 여전히 찾은 메일이라 빼지 않고 메일함 표시만 바꾼다 — 휴지통 · 스팸메일함으로 옮기면 뺀다).
|
||||
요청이 통째로 실패해 하나도 처리하지 못하면 고른 것을 그대로 둔다(다시 시도할 수 있게).
|
||||
- `failedIds` · `skippedIds` 가 있으면 알림에 "N통은 그사이 옮겨졌거나 지워져 그대로 두었습니다"를 덧붙인다.
|
||||
- 메일함을 바꾸거나 검색하면 선택을 푼다. 새 메일 확인으로 목록을 맞출 때는 남아 있는 메일의 선택을 그대로 둔다.
|
||||
|
||||
### 이 단계에서 하지 않는 것
|
||||
|
||||
실시간 새 메일 알림(SSE), 검색 화면, 본문 안 이미지(인라인 cid — 붙여 넣은 이미지는 첨부로, 서명 그림만 예외), 외부 이미지 보기(이미지 중계), 다중 선택 일괄 처리,
|
||||
여러 개의 서명, 엔진 계정 목록 기반의 사내 주소록.
|
||||
실시간 푸시(SSE · 데스크톱 알림 · 소리), 본문 안 이미지(인라인 cid — 붙여 넣은 이미지는 첨부로, 서명 그림만 예외), 외부 이미지 보기(이미지 중계),
|
||||
여러 개의 서명, 엔진 계정 목록 기반의 사내 주소록, 자체 검색 색인(붙여 쓴 말 · 앞부분 · 제목 말머리로 찾기), 조건 검색(보낸 사람 · 기간 · 첨부 있음), 검색어 강조,
|
||||
불러오지 않은 메일까지 한꺼번에(메일함 전체 읽음 표시 · 휴지통 비우기), 끌어다 놓아 옮기기.
|
||||
|
||||
+50
-15
@@ -10,20 +10,46 @@
|
||||
# nginx 변수($uri 등)는 NGINX_ 로 시작하지 않아 그대로 남는다 (Dockerfile 의 NGINX_ENVSUBST_FILTER).
|
||||
# ==========================================
|
||||
|
||||
# 요청 추적 — rid 는 백엔드로 넘기는 X-Request-Id 이자 실패 응답의 error.traceId 와 같은 값이다
|
||||
log_format main_rid '$remote_addr - $remote_user [$time_local] "$request" $status $body_bytes_sent '
|
||||
'"$http_referer" "$http_user_agent" "$http_x_forwarded_for" rid=$request_id rt=$request_time';
|
||||
# 접근 로그 — 쿼리를 남기지 않는다. 검색어(q)가 /api 주소뿐 아니라 화면 주소(/mail/search?q=…)에도 들어간다
|
||||
# (계약 2절 "검색"·"받는 사람 추천", 로깅 규칙). 형식은 main_rid 하나이고 server 단계에만 건다 — 모든 location
|
||||
# (/api·오류용 이름 있는 location 포함)과 location 을 고르기 전에 거부된 요청(헤더가 너무 큰 400 등)이 물려받는다.
|
||||
# - 요청 줄: $request 에서 ? 부터 다음 빈칸 앞까지 뺀다 ("GET /mail/search?q=… HTTP/1.1" → "GET /mail/search HTTP/1.1").
|
||||
# $uri 로 다시 짜지 않는다 — SPA 폴백(try_files … /index.html) 뒤에는 $uri 가 /index.html 이라 어느 화면이었는지 사라진다.
|
||||
# - Referer: ? 앞까지만 남긴다. 화면 응답이 Referrer-Policy strict-origin 이라(아래 보안 헤더) 브라우저는 같은 주소의 요청에도
|
||||
# origin 만 보내지만, 배포 전에 연 탭은 옛 정책(보던 화면 주소를 쿼리째)으로 계속 보내고 브라우저가 아닌 클라이언트는 무엇이든 보낸다.
|
||||
# - 값이 없으면 지금처럼 "-" 다 (map 의 정규식은 빈 값에는 돌지 않아 "" 를 따로 적는다).
|
||||
# 오류 로그는 nginx 가 요청 줄·upstream·referrer 를 쿼리째 남긴다 — 장애 원인 줄을 잃지 않으려고 그대로 둔다 (docker 스킬 문서 "로그").
|
||||
map $request $log_request_line {
|
||||
"" "-";
|
||||
"~^(?<log_request_head>[^?]*)\?[^ ]*(?<log_request_tail>.*)" $log_request_head$log_request_tail;
|
||||
default $request;
|
||||
}
|
||||
|
||||
# /api 용 — main_rid 와 같되 요청 줄에서 쿼리를 뺀다: 검색어(q) 등이 요청마다 남지 않게 (계약 2절 "받는 사람 추천" — 로그에는 개수만)
|
||||
log_format api_rid '$remote_addr - $remote_user [$time_local] "$request_method $uri $server_protocol" $status $body_bytes_sent '
|
||||
'"$http_referer" "$http_user_agent" "$http_x_forwarded_for" rid=$request_id rt=$request_time';
|
||||
map $http_referer $log_referer {
|
||||
"" "-";
|
||||
"~^(?<log_referer_head>[^?]*)\?" $log_referer_head;
|
||||
default $http_referer;
|
||||
}
|
||||
|
||||
# 새 메일 확인(GET /api/mail-state — 화면이 보이는 동안 탭마다 1분에 한 번)은 성공(2xx·304)하면 접근 로그를 남기지 않는다 —
|
||||
# 로깅 규칙 "반복되는 정상 흐름(헬스체크, 폴링)은 로그를 남기지 않는다". 실패(401·5xx 등, 오류용 이름 있는 location 포함)는 남긴다.
|
||||
# 0 이면 server 단계 access_log 의 if= 가 그 줄을 쓰지 않는다. $uri 는 쿼리를 뺀 경로다 (이 요청은 내부 이동이 없다)
|
||||
map "$request_method:$uri:$status" $log_access {
|
||||
"~^GET:/api/mail-state:(?:2[0-9][0-9]|304)$" 0;
|
||||
default 1;
|
||||
}
|
||||
|
||||
# 요청 추적 — rid 는 백엔드로 넘기는 X-Request-Id 이자 실패 응답의 error.traceId 와 같은 값이다
|
||||
log_format main_rid '$remote_addr - $remote_user [$time_local] "$log_request_line" $status $body_bytes_sent '
|
||||
'"$log_referer" "$http_user_agent" "$http_x_forwarded_for" rid=$request_id rt=$request_time';
|
||||
|
||||
server {
|
||||
# 비-root(nginx-unprivileged) 구동이라 1024 미만 포트를 쓸 수 없다
|
||||
listen 8080;
|
||||
server_name localhost;
|
||||
|
||||
access_log /var/log/nginx/access.log main_rid;
|
||||
# 접근 로그 — 형식·조건은 위 map·log_format (쿼리 없음, 성공한 새 메일 확인은 남기지 않음). 모든 location 이 물려받는다
|
||||
access_log /var/log/nginx/access.log main_rid if=$log_access;
|
||||
|
||||
# 기본 요청 본문 한도 — 백엔드 JSON 파서 한도(1mb)와 같다. 업로드·발송·임시 저장 경로만 아래 /api 에서 늘린다
|
||||
client_max_body_size 1m;
|
||||
@@ -39,7 +65,11 @@ server {
|
||||
add_header_inherit merge;
|
||||
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||
# Referer 에는 같은 주소의 요청에도 origin 만 싣는다 (strict-origin) — 화면 주소에 검색어(/mail/search?q=…)와
|
||||
# 임시 저장 id(?draft=)가 들어가서, 주소째 보내면 그 화면에서 나가는 요청마다 nginx 오류 로그·외부 리버스 프록시 로그에 남는다.
|
||||
# Referer 를 읽는 코드는 없다 — 백엔드 출처 검사(OriginGuard)는 Sec-Fetch-Site·Origin 을 보고, 같은 주소 HTTPS 요청의 Origin 은
|
||||
# 이 정책과 상관없이 그대로 붙는다. 다른 사이트로 가는 요청은 전과 같다 (origin 만, HTTPS → HTTP 는 보내지 않는다).
|
||||
add_header Referrer-Policy "strict-origin" always;
|
||||
add_header Permissions-Policy "geolocation=(), microphone=(), camera=()" always;
|
||||
|
||||
# Content-Security-Policy — XSS 방어의 핵심 헤더
|
||||
@@ -136,8 +166,6 @@ server {
|
||||
# API 응답의 보안 헤더는 백엔드가 정한다 — 화면용 CSP 등을 겹쳐 붙이지 않는다
|
||||
add_header_inherit off;
|
||||
proxy_pass $backend_upstream;
|
||||
# 쿼리 없는 로그 형식 — 하위 location 은 물려받는다. 오류로 넘어가는 이름 있는 location(아래 @api_*)은 따로 적는다
|
||||
access_log /var/log/nginx/access.log api_rid;
|
||||
|
||||
# nginx 가 백엔드에 닿기 전에 끝내는 요청도 표준 실패 형식(JSON)으로 답한다 — 계약 머리말 "프록시가 직접 내는 오류"
|
||||
# (= 없이 쓰면 상태 코드는 원래 값 그대로다. 백엔드가 보낸 4xx·5xx 는 proxy_intercept_errors 가 꺼져 있어 손대지 않는다)
|
||||
@@ -176,13 +204,22 @@ server {
|
||||
|
||||
# 메일 발송(POST /api/messages)은 JSON 본문 4MB — 서식 본문·긴 인용 때문 (계약 2절 413 표, 백엔드도 이 경로와 아래 임시 저장만 4MB 파서).
|
||||
# 계약이 정한 고정값이라 env 로 바꾸지 않는다 (나머지 /api 의 1m 도 백엔드 JSON_BODY_LIMIT 고정값과 같다 — 값이 같아 여유를 두지 않는다).
|
||||
# 정확히 이 경로만 — /api/messages/{id}(PATCH·DELETE·첨부 내려받기)는 위 location 의 1m 그대로다. 같은 경로의 GET(목록)은 본문이 없다.
|
||||
# 정확히 이 경로만 — /api/messages/{id}(PATCH·DELETE·첨부 내려받기)는 위 location 의 1m 그대로다. 같은 경로의 GET(목록)은 본문이 없고,
|
||||
# 여러 통 PATCH(계약 2절 "여러 통 한꺼번에")는 본문이 작아(id 100개까지) 한도와 상관없다. 메서드는 가리지 않는다.
|
||||
location = /api/messages {
|
||||
client_max_body_size 4m;
|
||||
proxy_read_timeout 90s;
|
||||
proxy_pass $backend_upstream;
|
||||
}
|
||||
|
||||
# 여러 통 영구 삭제(POST /api/message-deletions, 계약 2절 "여러 통 한꺼번에") — 백엔드가 메일 엔진에 확인한 뒤 조건부로 지우고,
|
||||
# 어긋나면 다시 확인한다(합계 CONDITIONAL_DESTROY_ATTEMPTS). 엔진 응답을 여러 번 기다리므로 /api/messages·/api/drafts 와 같은 90s.
|
||||
# 본문 한도는 기본 1m 그대로다 (id 100개까지)
|
||||
location ^~ /api/message-deletions {
|
||||
proxy_read_timeout 90s;
|
||||
proxy_pass $backend_upstream;
|
||||
}
|
||||
|
||||
# 메일 임시 저장 — 처음 저장(POST /api/drafts)·바꿔 저장(PUT /api/drafts/{id})도 JSON 본문 4MB. 본문이 발송과 같은 필드·한도라서다
|
||||
# (계약 2절 413 표·"임시 저장", 백엔드도 이 두 요청은 발송과 같은 4MB 파서). 발송과 같은 이유로 env 로 바꾸지 않는다.
|
||||
# 조회(GET /api/drafts/{id})는 본문이 없어 같은 블록에 둔다. 저장도 메일 엔진에 만들고·다시 읽고·옛것을 지우는 요청이 이어져 90s 를 기다린다
|
||||
@@ -194,12 +231,11 @@ server {
|
||||
}
|
||||
|
||||
# /api 의 nginx 오류 응답 — message 는 비운다 (문구는 클라이언트가 code 로 사전에서 찾는다, 레지스트리와 겹치지 않게).
|
||||
# traceId 는 이 nginx 의 요청 ID(로그의 rid=)다. 이름 있는 location 은 server 단계라 화면용 헤더 상속을 끄고 로그 형식(api_rid)도 다시 정한다
|
||||
# (내부 이동 뒤에는 이 location 의 access_log 가 쓰인다).
|
||||
# traceId 는 이 nginx 의 요청 ID(로그의 rid=)다. 이름 있는 location 은 server 단계라 화면용 헤더 상속을 끈다
|
||||
# (접근 로그는 server 단계의 쿼리 없는 main_rid 를 그대로 물려받는다).
|
||||
# return 200 은 본문만 정한다 — error_page 를 = 없이 썼으므로 상태 코드는 원래 값(413·502·504 등)이 그대로 나간다 (2026-10-27 확인)
|
||||
location @api_payload_too_large {
|
||||
add_header_inherit off;
|
||||
access_log /var/log/nginx/access.log api_rid;
|
||||
default_type application/json;
|
||||
add_header Cache-Control "no-store" always;
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
@@ -209,7 +245,6 @@ server {
|
||||
|
||||
location @api_unavailable {
|
||||
add_header_inherit off;
|
||||
access_log /var/log/nginx/access.log api_rid;
|
||||
default_type application/json;
|
||||
add_header Cache-Control "no-store" always;
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
<script setup lang="ts">
|
||||
// 모달 대화상자 틀 — 브라우저 <dialog>(showModal)로 띄운다. 모양은 디자인 원본 States "세션 만료".
|
||||
// 쓰는 곳: SessionExpiredDialog(세션 만료) · LeaveConfirmDialog(떠나기 전 확인 — 쓰기 화면 · 설정 › 서명)
|
||||
// 쓰는 곳: SessionExpiredDialog(세션 만료) · LeaveConfirmDialog(떠나기 전 확인 — 쓰기 화면 · 설정 › 서명) · MailPurgeDialog(영구 삭제 확인)
|
||||
// - 모달이라 뒤의 화면을 조작할 수 없다. 처음 초점은 autofocus 를 붙인 단추 (없으면 브라우저가 첫 단추로)
|
||||
// - Esc(cancel)는 브라우저가 닫지 않게 막고 cancel 로 알린다 — 무엇을 할지는 쓰는 쪽이 정한다
|
||||
// - 닫기는 열기보다 먼저 한다 — 한 대화상자가 닫히고 다른 대화상자가 열리는 때(세션 만료) 둘이 겹치지 않게
|
||||
// 단추는 actions 슬롯에 둔다: class="modal-dialog__action" (보조 단추는 modal-dialog__action--secondary 를 더한다)
|
||||
// 단추는 actions 슬롯에 둔다: class="modal-dialog__action" (보조 단추는 modal-dialog__action--secondary, 되돌릴 수 없는 일은 --danger 를 더한다)
|
||||
import { onMounted, ref, watch } from 'vue'
|
||||
|
||||
interface Props {
|
||||
@@ -168,6 +168,15 @@ onMounted(() => {
|
||||
background: var(--color-brand-strong);
|
||||
}
|
||||
|
||||
/* 되돌릴 수 없는 일 — 빨강 채움 (영구 삭제, 디자인 원본 BulkSelect) */
|
||||
:slotted(.modal-dialog__action--danger) {
|
||||
background: var(--color-danger);
|
||||
}
|
||||
|
||||
:slotted(.modal-dialog__action--danger:hover) {
|
||||
background: var(--color-danger-strong);
|
||||
}
|
||||
|
||||
/* 보조 단추 — 바탕 없이 (쓰기 화면의 "취소"와 같은 모양) */
|
||||
:slotted(.modal-dialog__action--secondary) {
|
||||
background: transparent;
|
||||
|
||||
@@ -55,7 +55,7 @@ describe('ListReadNav (목록 보기에서 연 메일의 이동)', () => {
|
||||
await router.push(`/mail/inbox/${messageId}`)
|
||||
await router.isReady()
|
||||
const wrapper = mount(ListReadNav, {
|
||||
props: { mailboxId: 'inbox', messageId },
|
||||
props: { messageId },
|
||||
global: { plugins: [pinia, router] },
|
||||
})
|
||||
return { wrapper, router }
|
||||
@@ -114,7 +114,7 @@ describe('ListReadNav (목록 보기에서 연 메일의 이동)', () => {
|
||||
await router.push('/mail/inbox/m2')
|
||||
await router.isReady()
|
||||
const wrapper = mount(ListReadNav, {
|
||||
props: { mailboxId: 'inbox', messageId: 'm2' },
|
||||
props: { messageId: 'm2' },
|
||||
global: { plugins: [pinia, router] },
|
||||
})
|
||||
|
||||
@@ -134,4 +134,27 @@ describe('ListReadNav (목록 보기에서 연 메일의 이동)', () => {
|
||||
expect(router.currentRoute.value.fullPath).toBe('/mail/inbox')
|
||||
expect(useMailViewStore().takeReturnFocus()).toBe('m2')
|
||||
})
|
||||
|
||||
it('검색 결과에서 연 메일 — 이전/다음은 결과 안의 주소로, "목록으로"는 검색 결과로 돌아간다', async () => {
|
||||
const store = useMailStore()
|
||||
store.listSource = { kind: 'search', q: '회의', scope: 'all' }
|
||||
store.pageInfo = { hasNext: false, nextCursor: null, total: 3 }
|
||||
const query = `q=${encodeURIComponent('회의')}&in=all`
|
||||
const router = createTestRouter()
|
||||
await router.push(`/mail/search?${query}`)
|
||||
await router.push(`/mail/search/m2?${query}`)
|
||||
await router.isReady()
|
||||
const wrapper = mount(ListReadNav, { props: { messageId: 'm2' }, global: { plugins: [pinia, router] } })
|
||||
|
||||
expect(wrapper.get('a.tool-button').attributes('href')).toBe(`/mail/search?${query}`)
|
||||
expect(wrapper.get('.nav-position').text()).toBe('2 / 3')
|
||||
|
||||
await wrapper.get('button[aria-label="다음 메일"]').trigger('click')
|
||||
await flushPromises()
|
||||
expect(router.currentRoute.value.fullPath).toBe(`/mail/search/m3?${query}`)
|
||||
|
||||
await wrapper.get('a.tool-button').trigger('click', { button: 0 })
|
||||
await flushPromises()
|
||||
expect(router.currentRoute.value.fullPath).toBe(`/mail/search?${query}`)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
// 목록 보기에서 연 메일의 이동 도구 — 목록으로 · 이전/다음 · 위치 "n / 전체" (디자인 원본 MainListRead)
|
||||
// 본문 툴바의 앞쪽에 들어간다 (MessageReader 의 toolbar-start 슬롯).
|
||||
// 목록은 메일함 또는 검색 결과다 — 검색 결과에서 연 메일이면 "목록으로"는 결과로, 이전/다음은 결과 안에서 옮긴다.
|
||||
import { computed, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
@@ -9,11 +10,11 @@ import MailToolButton from '@/components/mail/MailToolButton.vue'
|
||||
import MailToolbarDivider from '@/components/mail/MailToolbarDivider.vue'
|
||||
import { useMailNavigation } from '@/composables/useMailNavigation'
|
||||
import { formatCount } from '@/shared/utils/format'
|
||||
import { listLocationOf } from '@/shared/utils/mail-list-source'
|
||||
import { useMailStore } from '@/stores/mail.store'
|
||||
import { useMailViewStore } from '@/stores/mail-view.store'
|
||||
|
||||
interface Props {
|
||||
mailboxId: string
|
||||
messageId: string
|
||||
}
|
||||
|
||||
@@ -22,17 +23,23 @@ const props = defineProps<Props>()
|
||||
const router = useRouter()
|
||||
const mailStore = useMailStore()
|
||||
const viewStore = useMailViewStore()
|
||||
const { mailboxLocation, backToList, showSibling } = useMailNavigation()
|
||||
const { backToSourceList, showSiblingIn } = useMailNavigation()
|
||||
|
||||
/** 다음 페이지를 불러오는 중 — 같은 버튼을 두 번 눌러도 한 번만 넘어간다 */
|
||||
const loadingNext = ref<boolean>(false)
|
||||
|
||||
const listHref = computed(() => router.resolve(mailboxLocation(props.mailboxId)).href)
|
||||
const listHref = computed(() => {
|
||||
const source = mailStore.listSource
|
||||
return source ? router.resolve(listLocationOf(source)).href : router.resolve({ name: 'mail' }).href
|
||||
})
|
||||
/** 불러온 목록에서의 위치 — 목록 밖의 메일(주소로 바로 연 경우 등)이면 -1 */
|
||||
const index = computed(() => mailStore.messages.findIndex((message) => message.id === props.messageId))
|
||||
/** 전체 — 서버가 준 total, 없으면 메일함 개수(메일함 목록일 때만), 그것도 없으면 불러온 수 */
|
||||
const total = computed(
|
||||
() =>
|
||||
mailStore.pageInfo?.total ?? mailStore.currentMailbox?.totalCount ?? mailStore.messages.length,
|
||||
mailStore.pageInfo?.total ??
|
||||
(mailStore.search ? null : mailStore.currentMailbox?.totalCount) ??
|
||||
mailStore.messages.length,
|
||||
)
|
||||
const position = computed(() =>
|
||||
index.value === -1 ? '' : `${formatCount(index.value + 1)} / ${formatCount(total.value)}`,
|
||||
@@ -46,7 +53,8 @@ const hasNext = computed(
|
||||
|
||||
async function goPrev() {
|
||||
const previous = mailStore.messages[index.value - 1]
|
||||
if (previous) await showSibling(props.mailboxId, previous.id)
|
||||
const source = mailStore.listSource
|
||||
if (previous && source) await showSiblingIn(source, previous.id)
|
||||
}
|
||||
|
||||
async function goNext() {
|
||||
@@ -61,7 +69,8 @@ async function goNext() {
|
||||
}
|
||||
}
|
||||
const next = mailStore.messages[current + 1]
|
||||
if (next) await showSibling(props.mailboxId, next.id)
|
||||
const source = mailStore.listSource
|
||||
if (next && source) await showSiblingIn(source, next.id)
|
||||
}
|
||||
|
||||
/** 목록으로 — 불러온 페이지·스크롤 위치는 그대로, 초점은 보던 메일의 행으로 돌려준다 */
|
||||
@@ -69,8 +78,10 @@ function onBackToList(event: MouseEvent) {
|
||||
// 새 탭·새 창으로 여는 동작(Ctrl·⌘·Shift·가운데 버튼)은 브라우저에 맡긴다
|
||||
if (event.ctrlKey || event.metaKey || event.shiftKey || event.altKey || event.button !== 0) return
|
||||
event.preventDefault()
|
||||
const source = mailStore.listSource
|
||||
if (!source) return
|
||||
viewStore.setReturnFocus(props.messageId)
|
||||
void backToList(props.mailboxId)
|
||||
void backToSourceList(source)
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
<script setup lang="ts">
|
||||
// 여러 통 처리 도구 줄의 단추 — 높이 44 · 아이콘 + 이름 (디자인 원본 BulkSelect · ListStates)
|
||||
// 이름은 labelMode 로 정한다: always(늘 보인다) · wide(폭 1100 이상에서만 — 목록 보기) · never(아이콘만 40×44 — 분할 보기의 목록 칸)
|
||||
// 이름을 숨겨도 aria-label · 툴팁(title)으로 읽힌다. data-tool · tabindex(로빙) · aria-haspopup 같은 속성은 단추에 그대로 붙는다.
|
||||
import { ref } from 'vue'
|
||||
|
||||
import AppIcon from '@/components/AppIcon.vue'
|
||||
import type { IconName } from '@/shared/constants/icons'
|
||||
|
||||
interface Props {
|
||||
icon: IconName
|
||||
label: string
|
||||
/** 툴팁 — 주지 않으면 이름 */
|
||||
title?: string
|
||||
labelMode?: 'always' | 'wide' | 'never'
|
||||
/** danger: 삭제 · 영구 삭제 (빨강) */
|
||||
tone?: 'default' | 'danger'
|
||||
/** 메뉴를 여는 단추 — 이름 뒤 ▾ (아이콘만일 때는 빼고) */
|
||||
menu?: boolean
|
||||
disabled?: boolean
|
||||
iconStrokeWidth?: number
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'click', event: MouseEvent): void
|
||||
}
|
||||
|
||||
withDefaults(defineProps<Props>(), {
|
||||
title: undefined,
|
||||
labelMode: 'wide',
|
||||
tone: 'default',
|
||||
menu: false,
|
||||
disabled: false,
|
||||
iconStrokeWidth: 1.75,
|
||||
})
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
const buttonRef = ref<HTMLButtonElement | null>(null)
|
||||
|
||||
defineExpose({
|
||||
/** 단추 요소 — 메뉴의 바깥 누름 판정 · 초점 되돌리기에 쓴다 */
|
||||
element: buttonRef,
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<button
|
||||
ref="buttonRef"
|
||||
type="button"
|
||||
:class="['bulk-button', `bulk-button--${tone}`, `bulk-button--label-${labelMode}`, { 'bulk-button--menu': menu }]"
|
||||
:aria-label="label"
|
||||
:title="title ?? label"
|
||||
:disabled="disabled"
|
||||
@click="emit('click', $event)"
|
||||
>
|
||||
<AppIcon
|
||||
:name="icon"
|
||||
:size="15"
|
||||
:stroke-width="iconStrokeWidth"
|
||||
/>
|
||||
<span class="bulk-button__label">{{ label }}</span>
|
||||
<AppIcon
|
||||
v-if="menu"
|
||||
name="chevron-down"
|
||||
:size="12"
|
||||
:stroke-width="2"
|
||||
class="bulk-button__chevron"
|
||||
/>
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.bulk-button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--space-6);
|
||||
min-width: var(--size-icon-button);
|
||||
min-height: var(--size-touch);
|
||||
padding: 0 var(--toolbar-button-inline);
|
||||
border: 0;
|
||||
border-radius: var(--radius-md);
|
||||
background: transparent;
|
||||
color: var(--color-text-strong);
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.bulk-button:hover:not(:disabled) {
|
||||
background: var(--color-surface-muted);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.bulk-button:disabled {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.bulk-button--menu {
|
||||
padding: 0 var(--space-8) 0 var(--toolbar-button-inline);
|
||||
}
|
||||
|
||||
.bulk-button--danger {
|
||||
color: var(--color-danger);
|
||||
}
|
||||
|
||||
.bulk-button--danger:hover:not(:disabled) {
|
||||
background: var(--color-danger-surface);
|
||||
color: var(--color-danger-strong);
|
||||
}
|
||||
|
||||
/* 아이콘만 40×44 (분할 보기의 목록 칸) */
|
||||
.bulk-button--label-never {
|
||||
width: var(--size-icon-button);
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.bulk-button--label-never .bulk-button__label,
|
||||
.bulk-button--label-never .bulk-button__chevron {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* 1100px 미만 — 아이콘만 (메뉴 단추는 ▾ 를 둔다 · tokens.css 화면 폭 기준) */
|
||||
@media (max-width: 68.6875rem) {
|
||||
.bulk-button--label-wide .bulk-button__label {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,106 @@
|
||||
<script setup lang="ts">
|
||||
// 이동 ▾ — 옮길 메일함 메뉴 (디자인 원본 BulkSelect "이동 메뉴" · README "3단계 › 여러 통 처리")
|
||||
// 메뉴는 편집기 메뉴(EditorMenu — 항목 높이 40, 메뉴 항목 밀도 예외)를 같이 쓴다. 키보드로 열면(Enter · Space · 아래 화살표) 첫 항목에 초점,
|
||||
// 마우스로 열면 메뉴 자체에 초점(EditorPopup). Esc 는 메뉴만 닫고 이 단추로 돌아간다 — EditorPopup 이 Esc 를 막아 목록 칸의 "선택 풀기"까지 가지 않는다.
|
||||
// 받은 속성(data-tool · tabindex — 도구 줄의 로빙)은 단추에 붙인다.
|
||||
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 { MAIL_BULK_TEXT } from '@/shared/constants/mail'
|
||||
import type { Mailbox } from '@/shared/types/mail'
|
||||
|
||||
interface Props {
|
||||
/** 옮길 수 있는 메일함 — 지금 메일함 · 임시보관함 · 휴지통을 뺀 메일함 */
|
||||
mailboxes: readonly Mailbox[]
|
||||
labelMode: 'always' | 'wide' | 'never'
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'move', mailbox: Mailbox): void
|
||||
}
|
||||
|
||||
defineOptions({ inheritAttrs: false })
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
disabled: false,
|
||||
})
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
const buttonRef = ref<InstanceType<typeof MailBulkButton> | null>(null)
|
||||
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 })))
|
||||
|
||||
function openMenu(keyboard: boolean) {
|
||||
if (props.disabled || items.value.length === 0) 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()
|
||||
}
|
||||
|
||||
function onSelect(id: string) {
|
||||
open.value = false
|
||||
trigger.value?.focus()
|
||||
const target = props.mailboxes.find((mailbox) => mailbox.id === id)
|
||||
if (target) emit('move', target)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="bulk-move">
|
||||
<MailBulkButton
|
||||
v-bind="$attrs"
|
||||
ref="buttonRef"
|
||||
icon="folder-move"
|
||||
:label="MAIL_BULK_TEXT.move"
|
||||
:label-mode="labelMode"
|
||||
menu
|
||||
aria-haspopup="menu"
|
||||
:aria-expanded="open"
|
||||
:disabled="disabled"
|
||||
@click="onClick"
|
||||
@keydown="onKeydown"
|
||||
/>
|
||||
<EditorMenu
|
||||
v-if="open"
|
||||
:label="MAIL_BULK_TEXT.moveMenu"
|
||||
:items="items"
|
||||
:trigger="trigger"
|
||||
:focus-content="!byKeyboard"
|
||||
@select="onSelect"
|
||||
@close="onClose"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* 메뉴는 이 단추 바로 아래 — 도구 줄 아래 선 밑에서 연다 (EditorPopup) */
|
||||
.bulk-move {
|
||||
position: relative;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,105 @@
|
||||
<script setup lang="ts">
|
||||
// 고르기 상자 — 여러 통 처리 (디자인 원본 BulkSelect · ListStates)
|
||||
// 상자 16px — 비었을 때 테두리 #8A94A3 1.5px, 고르면 #0E7C86 채움에 흰 체크, 일부만 골랐으면(표 머리) – 표시.
|
||||
// 누르는 영역(폭 · 높이)은 쓰는 쪽이 class 로 정한다 — 표의 고르기 칸 32 · 분할 보기 행의 고르기 칸 36 (밀도 예외, tokens.css).
|
||||
// 수정 글쇠(Shift — 범위)는 쓰는 쪽이 판단하도록 click 이벤트를 그대로 넘긴다.
|
||||
interface Props {
|
||||
checked: boolean
|
||||
/** 일부만 골랐다 — 표 머리의 상자 (aria-checked="mixed") */
|
||||
mixed?: boolean
|
||||
label: string
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'toggle', event: MouseEvent): void
|
||||
}
|
||||
|
||||
withDefaults(defineProps<Props>(), {
|
||||
mixed: false,
|
||||
})
|
||||
const emit = defineEmits<Emits>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<button
|
||||
type="button"
|
||||
role="checkbox"
|
||||
class="mail-checkbox"
|
||||
:aria-checked="mixed ? 'mixed' : checked"
|
||||
:aria-label="label"
|
||||
@click="emit('toggle', $event)"
|
||||
>
|
||||
<svg
|
||||
class="mail-checkbox__box"
|
||||
viewBox="0 0 16 16"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<rect
|
||||
v-if="!checked && !mixed"
|
||||
class="mail-checkbox__empty"
|
||||
x="1"
|
||||
y="1"
|
||||
width="14"
|
||||
height="14"
|
||||
rx="3.5"
|
||||
/>
|
||||
<template v-else>
|
||||
<rect
|
||||
class="mail-checkbox__fill"
|
||||
x="0.25"
|
||||
y="0.25"
|
||||
width="15.5"
|
||||
height="15.5"
|
||||
rx="4"
|
||||
/>
|
||||
<line
|
||||
v-if="mixed"
|
||||
class="mail-checkbox__mark"
|
||||
x1="4.5"
|
||||
y1="8"
|
||||
x2="11.5"
|
||||
y2="8"
|
||||
/>
|
||||
<polyline
|
||||
v-else
|
||||
class="mail-checkbox__mark"
|
||||
points="4.2 8.3 6.9 10.8 11.8 5.4"
|
||||
/>
|
||||
</template>
|
||||
</svg>
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.mail-checkbox {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.mail-checkbox__box {
|
||||
flex: 0 0 auto;
|
||||
width: var(--size-checkbox);
|
||||
height: var(--size-checkbox);
|
||||
}
|
||||
|
||||
.mail-checkbox__empty {
|
||||
fill: var(--color-surface);
|
||||
stroke: var(--color-border-input);
|
||||
stroke-width: 1.5;
|
||||
}
|
||||
|
||||
.mail-checkbox__fill {
|
||||
fill: var(--color-brand);
|
||||
}
|
||||
|
||||
.mail-checkbox__mark {
|
||||
fill: none;
|
||||
stroke: var(--color-on-brand);
|
||||
stroke-width: 2;
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,143 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { createPinia, setActivePinia, type Pinia } from 'pinia'
|
||||
|
||||
import type { Mailbox, MessageSummary } from '@/shared/types/mail'
|
||||
import { useAuthStore } from '@/stores/auth.store'
|
||||
import { useMailStore } from '@/stores/mail.store'
|
||||
import { flushPromises, installMockApi, ok, restoreApiAdapter } from '@/test-utils/mock-api'
|
||||
import { createTestRouter } from '@/test-utils/router'
|
||||
import MessageList from './MessageList.vue'
|
||||
|
||||
// 목록 틀 — 새 메일 알림 줄 · Esc (디자인 README "3단계 › 새 메일" · "여러 통 처리")
|
||||
|
||||
const INBOX: Mailbox = {
|
||||
id: 'inbox',
|
||||
name: '받은편지함',
|
||||
role: 'inbox',
|
||||
parentId: null,
|
||||
sortOrder: 0,
|
||||
totalCount: 3,
|
||||
unreadCount: 0,
|
||||
}
|
||||
|
||||
function summary(id: string, partial: Partial<MessageSummary> = {}): MessageSummary {
|
||||
return {
|
||||
id,
|
||||
threadId: `t-${id}`,
|
||||
mailboxIds: ['inbox'],
|
||||
from: { name: '이도윤', email: 'doyun.lee@arasystem.test' },
|
||||
to: [],
|
||||
subject: `제목 ${id}`,
|
||||
preview: '',
|
||||
receivedAt: '2026-10-27T01:00:00Z',
|
||||
isRead: true,
|
||||
isFlagged: false,
|
||||
hasAttachment: false,
|
||||
size: 100,
|
||||
...partial,
|
||||
}
|
||||
}
|
||||
|
||||
describe('MailListFrame (목록 칸의 틀)', () => {
|
||||
let pinia: Pinia
|
||||
let fresh: MessageSummary[]
|
||||
|
||||
beforeEach(() => {
|
||||
pinia = createPinia()
|
||||
setActivePinia(pinia)
|
||||
useAuthStore().markAuthenticated({ email: 'ara.kim@arasystem.test', name: '김아라' })
|
||||
fresh = []
|
||||
installMockApi(() => ok({ items: fresh, pageInfo: { hasNext: false, nextCursor: null, total: fresh.length } }))
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
restoreApiAdapter()
|
||||
document.body.innerHTML = ''
|
||||
})
|
||||
|
||||
async function mountList(messages: MessageSummary[]) {
|
||||
const store = useMailStore()
|
||||
store.mailboxes = [INBOX]
|
||||
store.currentMailboxId = 'inbox'
|
||||
store.messages = messages
|
||||
store.pageInfo = { hasNext: false, nextCursor: null, total: messages.length }
|
||||
store.listStatus = 'ready'
|
||||
const router = createTestRouter()
|
||||
await router.push('/mail/inbox')
|
||||
await router.isReady()
|
||||
const wrapper = mount(MessageList, { global: { plugins: [pinia, router] }, attachTo: document.body })
|
||||
return { wrapper, store, section: wrapper.get('section.message-list').element as HTMLElement }
|
||||
}
|
||||
|
||||
it('목록을 내려 본 채 새 메일이 위에 생기면 머리 아래에 "새 메일 N통 · 맨 위로" — 누르면 맨 위로 가고 사라진다', async () => {
|
||||
const old = [summary('m1'), summary('m2'), summary('m3')]
|
||||
const { wrapper, store, section } = await mountList(old)
|
||||
section.scrollTop = 240
|
||||
fresh = [summary('new1', { isRead: false }), summary('new2', { isRead: false }), ...old]
|
||||
|
||||
await store.syncVisibleList()
|
||||
await flushPromises()
|
||||
|
||||
const notice = wrapper.get('.new-mail-notice')
|
||||
expect(notice.text()).toBe('새 메일 2통 · 맨 위로')
|
||||
expect(wrapper.findAll('.message-row')).toHaveLength(5)
|
||||
expect(section.scrollTop).toBe(240)
|
||||
|
||||
await notice.trigger('click')
|
||||
expect(section.scrollTop).toBe(0)
|
||||
expect(wrapper.find('.new-mail-notice').exists()).toBe(false)
|
||||
expect(document.activeElement).toBe(wrapper.findAll('.message-row__main')[0]?.element)
|
||||
})
|
||||
|
||||
it('맨 위를 보고 있으면 알림 줄 없이 새 메일이 그냥 위에 생긴다', async () => {
|
||||
const old = [summary('m1')]
|
||||
const { wrapper, store } = await mountList(old)
|
||||
fresh = [summary('new1', { isRead: false }), ...old]
|
||||
|
||||
await store.syncVisibleList()
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.find('.new-mail-notice').exists()).toBe(false)
|
||||
expect(wrapper.findAll('.message-row')[0]?.classes()).toContain('is-unread')
|
||||
})
|
||||
|
||||
it('알림 줄은 스스로 맨 위로 올라가도 사라지고, 다른 메일함으로 바꿔도 사라진다', async () => {
|
||||
const old = [summary('m1')]
|
||||
const { wrapper, store, section } = await mountList(old)
|
||||
section.scrollTop = 300
|
||||
fresh = [summary('new1'), ...old]
|
||||
await store.syncVisibleList()
|
||||
await flushPromises()
|
||||
expect(wrapper.find('.new-mail-notice').exists()).toBe(true)
|
||||
|
||||
section.scrollTop = 0
|
||||
section.dispatchEvent(new Event('scroll'))
|
||||
await flushPromises()
|
||||
expect(wrapper.find('.new-mail-notice').exists()).toBe(false)
|
||||
|
||||
section.scrollTop = 300
|
||||
fresh = [summary('new2'), summary('new1'), ...old]
|
||||
await store.syncVisibleList()
|
||||
await flushPromises()
|
||||
expect(wrapper.get('.new-mail-notice').text()).toBe('새 메일 1통 · 맨 위로')
|
||||
|
||||
await store.openMailbox('sent')
|
||||
await flushPromises()
|
||||
expect(wrapper.find('.new-mail-notice').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('Esc 는 고른 것을 푼다 — 도구 줄에 있던 초점은 목록 칸으로 옮긴다', async () => {
|
||||
const { wrapper, store, section } = await mountList([summary('m1'), summary('m2')])
|
||||
store.toggleCheck('m1')
|
||||
await flushPromises()
|
||||
const clear = wrapper.get('[data-tool="markRead"]')
|
||||
;(clear.element as HTMLElement).focus()
|
||||
|
||||
await clear.trigger('keydown', { key: 'Escape' })
|
||||
await flushPromises()
|
||||
|
||||
expect(store.checkedCount).toBe(0)
|
||||
expect(document.activeElement).toBe(section)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,247 @@
|
||||
<script setup lang="ts">
|
||||
// 목록 칸의 틀 — 분할 보기(MessageList)와 목록 보기(MessageTable)가 함께 쓴다 (디자인 원본 Main · MainList · Search · BulkSelect · ListStates)
|
||||
// - 머리 묶음(스크롤해도 위에 남는다): 메일함 머리 · 검색 결과 머리 + 범위 줄 — 하나라도 고르면 머리 자리가 도구 줄로 바뀐다 —
|
||||
// 그 아래 "새 메일 N통 · 맨 위로"(useListArrivalNotice)
|
||||
// - 상태: 불러오는 중(뼈대) · 못 불러옴 · 행(slot) + 메일 더 보기 · 빈 메일함 · 검색 결과 없음
|
||||
// - Esc 는 고른 것을 푼다 — 이 칸 안의 키만 받는다 (전역 단축키가 아니다 · 메뉴 · 대화상자 안의 Esc 는 그쪽 것이다)
|
||||
// - 목록 보기는 메일을 열면 이 칸이 사라진다 — 스크롤을 기억해 "목록으로" 돌아오면 되살리고 보던 메일로 초점을 돌려준다
|
||||
import { computed, ref, watch } from 'vue'
|
||||
|
||||
import StateMessage from '@/components/feedback/StateMessage.vue'
|
||||
import MailListHeader from '@/components/mail/MailListHeader.vue'
|
||||
import MailLoadError from '@/components/mail/MailLoadError.vue'
|
||||
import MailNewNotice from '@/components/mail/MailNewNotice.vue'
|
||||
import MailSearchEmpty from '@/components/mail/MailSearchEmpty.vue'
|
||||
import MailSearchHeader from '@/components/mail/MailSearchHeader.vue'
|
||||
import MailSearchScope from '@/components/mail/MailSearchScope.vue'
|
||||
import MailSelectionToolbar from '@/components/mail/MailSelectionToolbar.vue'
|
||||
import MessageListSkeleton from '@/components/mail/MessageListSkeleton.vue'
|
||||
import { useListArrivalNotice } from '@/composables/useListArrivalNotice'
|
||||
import { useListPositionMemory } from '@/composables/useListPositionMemory'
|
||||
import { useMailNavigation } from '@/composables/useMailNavigation'
|
||||
import { useMailSearchNavigation } from '@/composables/useMailSearchNavigation'
|
||||
import { MAIL_SEARCH_TEXT, SEARCH_SCOPE_ALL } from '@/shared/constants/mail'
|
||||
import { isImeComposing } from '@/shared/utils/keyboard-shortcut'
|
||||
import { emptyMailboxText, mailboxIcon } from '@/shared/utils/mailbox'
|
||||
import { useMailStore } from '@/stores/mail.store'
|
||||
|
||||
interface Props {
|
||||
variant: 'split' | 'list'
|
||||
/** 목록 칸 제목의 id — 머리 · 검색 결과 머리 · 도구 줄 가운데 보이는 것이 이 id 를 단다 */
|
||||
titleId: string
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
|
||||
const mailStore = useMailStore()
|
||||
const { replaceWithMailbox } = useMailNavigation()
|
||||
const { setScope } = useMailSearchNavigation()
|
||||
|
||||
const sectionRef = ref<HTMLElement | null>(null)
|
||||
const topRef = ref<HTMLElement | null>(null)
|
||||
const { noticeCount, goTop } = useListArrivalNotice(sectionRef, topRef)
|
||||
// 분할 보기의 목록 칸은 메일을 열어도 사라지지 않는다 — 목록 보기의 표만 위치를 기억한다
|
||||
useListPositionMemory(sectionRef, () => props.variant === 'list')
|
||||
|
||||
const search = computed(() => mailStore.search)
|
||||
const selecting = computed(() => mailStore.checkedCount > 0)
|
||||
const busy = computed(() => mailStore.listStatus === 'loading' || mailStore.isRefreshing)
|
||||
const mailbox = computed(() => mailStore.currentMailbox)
|
||||
const empty = computed(() =>
|
||||
emptyMailboxText({ name: mailbox.value?.name ?? '메일함', role: mailbox.value?.role ?? null }),
|
||||
)
|
||||
const emptyIcon = computed(() => mailboxIcon({ role: mailbox.value?.role ?? null }))
|
||||
/** 없는 주소(RES_001)일 때 — 메일함은 받은편지함으로, 없는 메일함 안의 검색은 전체 메일함에서 */
|
||||
const leaveLabel = computed(() => (search.value ? MAIL_SEARCH_TEXT.searchAll : '받은편지함으로'))
|
||||
|
||||
function retry() {
|
||||
const current = search.value
|
||||
if (current) void mailStore.openSearch(current.q, current.scope, { force: true })
|
||||
else if (mailStore.currentMailboxId) void mailStore.openMailbox(mailStore.currentMailboxId)
|
||||
}
|
||||
|
||||
function leave() {
|
||||
if (search.value) void setScope(SEARCH_SCOPE_ALL)
|
||||
else void replaceWithMailbox(mailStore.defaultMailbox?.id ?? null)
|
||||
}
|
||||
|
||||
/** 초점이 사라진 도구 줄 대신 목록 칸에 둔다 — 키보드 사용자가 자리를 잃지 않게 */
|
||||
function focusSection() {
|
||||
sectionRef.value?.focus({ preventScroll: true })
|
||||
}
|
||||
|
||||
function onKeydown(event: KeyboardEvent) {
|
||||
if (event.key !== 'Escape' || !selecting.value || event.defaultPrevented || isImeComposing(event)) return
|
||||
const target = event.target instanceof Element ? event.target : null
|
||||
if (target?.closest('dialog, [data-editor-popup]')) return
|
||||
event.preventDefault()
|
||||
mailStore.clearChecked()
|
||||
}
|
||||
|
||||
// 고른 것을 다 풀면 도구 줄이 사라진다 — 초점이 그 안에 있었으면(갈 곳을 잃으면) 목록 칸으로
|
||||
watch(
|
||||
() => mailStore.checkedCount,
|
||||
(count, previous) => {
|
||||
if (count > 0 || previous === 0) return
|
||||
const active = document.activeElement
|
||||
if (active === null || active === document.body) focusSection()
|
||||
},
|
||||
{ flush: 'post' },
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section
|
||||
ref="sectionRef"
|
||||
:class="['mail-list-frame', `mail-list-frame--${variant}`]"
|
||||
:aria-labelledby="titleId"
|
||||
:aria-busy="busy"
|
||||
tabindex="-1"
|
||||
@keydown="onKeydown"
|
||||
>
|
||||
<div
|
||||
ref="topRef"
|
||||
class="mail-list-frame__top"
|
||||
>
|
||||
<MailSelectionToolbar
|
||||
v-if="selecting"
|
||||
:title-id="titleId"
|
||||
:variant="variant"
|
||||
/>
|
||||
<MailSearchHeader
|
||||
v-else-if="search"
|
||||
:title-id="titleId"
|
||||
:variant="variant"
|
||||
/>
|
||||
<MailListHeader
|
||||
v-else
|
||||
:title-id="titleId"
|
||||
:variant="variant"
|
||||
/>
|
||||
<MailSearchScope v-if="search" />
|
||||
<div
|
||||
class="mail-list-frame__notice"
|
||||
role="status"
|
||||
>
|
||||
<MailNewNotice
|
||||
v-if="noticeCount > 0"
|
||||
:count="noticeCount"
|
||||
@go-top="goTop"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<MessageListSkeleton
|
||||
v-if="mailStore.listStatus === 'loading'"
|
||||
:label="search ? MAIL_SEARCH_TEXT.loading : undefined"
|
||||
/>
|
||||
|
||||
<MailLoadError
|
||||
v-else-if="mailStore.listStatus === 'error' && mailStore.listError"
|
||||
:error="mailStore.listError"
|
||||
:leave-label="leaveLabel"
|
||||
@retry="retry"
|
||||
@leave="leave"
|
||||
/>
|
||||
|
||||
<template v-else-if="mailStore.messages.length > 0">
|
||||
<slot />
|
||||
<div
|
||||
v-if="mailStore.hasMore"
|
||||
:class="['mail-list-frame__more', `mail-list-frame__more--${variant}`]"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="mail-list-frame__more-button"
|
||||
:disabled="mailStore.isLoadingMore"
|
||||
@click="mailStore.loadMore()"
|
||||
>
|
||||
{{ mailStore.isLoadingMore ? '불러오는 중…' : '메일 더 보기' }}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<MailSearchEmpty
|
||||
v-else-if="mailStore.listStatus === 'ready' && search"
|
||||
:query="search.q"
|
||||
:offer-all="search.scope !== SEARCH_SCOPE_ALL"
|
||||
@search-all="setScope(SEARCH_SCOPE_ALL)"
|
||||
/>
|
||||
|
||||
<StateMessage
|
||||
v-else-if="mailStore.listStatus === 'ready'"
|
||||
:class="{ 'mail-list-frame__empty': variant === 'list' }"
|
||||
:icon="emptyIcon"
|
||||
:title="empty.title"
|
||||
:hint="empty.hint"
|
||||
/>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.mail-list-frame {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* 프로그램이 준 초점(도구 줄이 사라진 뒤)에는 고리를 그리지 않는다 — 칸 전체에 고리가 생기면 어지럽다 */
|
||||
.mail-list-frame:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
/* 머리 묶음 — 스크롤해도 위에 남는다 (넓은 화면은 칸, 좁은 화면은 창 기준) */
|
||||
.mail-list-frame__top {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 2;
|
||||
background: var(--color-surface);
|
||||
}
|
||||
|
||||
.mail-list-frame__more--split {
|
||||
padding: var(--space-8) var(--space-12) var(--space-16);
|
||||
}
|
||||
|
||||
.mail-list-frame__more--list {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: var(--space-10) var(--space-20) var(--space-20);
|
||||
}
|
||||
|
||||
.mail-list-frame__more-button {
|
||||
min-height: var(--size-row-min);
|
||||
padding: 0 var(--space-14);
|
||||
border: 0;
|
||||
border-radius: var(--radius-md);
|
||||
background: transparent;
|
||||
color: var(--color-brand);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.mail-list-frame__more--split .mail-list-frame__more-button {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.mail-list-frame__more--list .mail-list-frame__more-button {
|
||||
padding: 0 var(--space-24);
|
||||
}
|
||||
|
||||
.mail-list-frame__more-button:hover:not(:disabled) {
|
||||
background: var(--color-brand-subtle);
|
||||
}
|
||||
|
||||
.mail-list-frame__more-button:disabled {
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.mail-list-frame__empty {
|
||||
padding-block: calc(var(--space-80) + var(--space-16));
|
||||
}
|
||||
|
||||
/* 넓은 화면에서는 목록 칸만 따로 스크롤한다 (tokens.css 화면 폭 기준) */
|
||||
@media (min-width: 70rem) {
|
||||
.mail-list-frame {
|
||||
overflow-y: auto;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,6 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
// 목록 머리 — 메일함 이름 · 개수("전체 N · 안 읽음 M") · 보기 전환 · 새로고침
|
||||
// 분할 보기(MessageList)와 목록 보기(MessageTable)가 함께 쓴다. 안쪽 여백만 보기마다 다르다 (디자인 원본).
|
||||
// 스크롤해도 위에 남는 것은 목록 틀(MailListFrame)의 머리 묶음이 맡는다 — 메일을 고르면 이 자리가 도구 줄로 바뀐다.
|
||||
import { computed } from 'vue'
|
||||
|
||||
import AppIcon from '@/components/AppIcon.vue'
|
||||
@@ -54,9 +55,6 @@ const busy = computed(() => mailStore.listStatus === 'loading' || mailStore.isRe
|
||||
|
||||
<style scoped>
|
||||
.list-header {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 2;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-8);
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
<script setup lang="ts">
|
||||
// "새 메일 N통 · 맨 위로" — 목록을 내려 본 채 새 메일이 위에 생겼을 때 머리 아래에 (디자인 원본 ListStates · README "3단계 › 새 메일")
|
||||
// 높이 40(밀도 예외) · 바탕 #F3F8F8 · #0A5F67 굵게 · 위 화살표. 누르면 맨 위로 가고 줄은 사라진다 (목록 틀이 처리한다)
|
||||
import AppIcon from '@/components/AppIcon.vue'
|
||||
import { NEW_MAIL_NOTICE_TEXT } from '@/shared/constants/mail'
|
||||
import { formatCount } from '@/shared/utils/format'
|
||||
|
||||
interface Props {
|
||||
count: number
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'go-top'): void
|
||||
}
|
||||
|
||||
defineProps<Props>()
|
||||
const emit = defineEmits<Emits>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<button
|
||||
type="button"
|
||||
class="new-mail-notice"
|
||||
@click="emit('go-top')"
|
||||
>
|
||||
<AppIcon
|
||||
name="arrow-up"
|
||||
:size="14"
|
||||
:stroke-width="2"
|
||||
/>
|
||||
<span>{{ NEW_MAIL_NOTICE_TEXT(formatCount(count)) }}</span>
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.new-mail-notice {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--space-6);
|
||||
width: 100%;
|
||||
min-height: var(--size-notice-bar);
|
||||
padding: 0 var(--space-12);
|
||||
border: 0;
|
||||
border-bottom: var(--border-width) solid var(--color-border);
|
||||
background: var(--color-brand-tint);
|
||||
color: var(--color-brand-strong);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.new-mail-notice:hover {
|
||||
background: var(--color-row-checked);
|
||||
}
|
||||
|
||||
.new-mail-notice:focus-visible {
|
||||
outline-offset: calc(var(--focus-ring-width) * -1);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,56 @@
|
||||
<script setup lang="ts">
|
||||
// 영구 삭제 확인 — 휴지통에서만 (디자인 원본 BulkSelect · ListStates "휴지통 · 영구 삭제 확인")
|
||||
// 제목 "메일 N통을 영구 삭제할까요?" · 글 "휴지통에서 지운 메일은 되돌릴 수 없습니다." · [영구 삭제](빨강 채움) [취소]
|
||||
// 열리면 「취소」에 초점이 간다 — 실수로 Enter · Space 를 눌러도 지워지지 않게. Esc 는 취소와 같다.
|
||||
// 쓰는 곳: 여러 통 처리 도구 줄 · 읽기 화면(한 통) — 둘이 함께 보일 수 있어 id 앞부분(idPrefix)을 따로 받는다
|
||||
import ModalDialog from '@/components/feedback/ModalDialog.vue'
|
||||
import { MAIL_BULK_TEXT } from '@/shared/constants/mail'
|
||||
import { formatCount } from '@/shared/utils/format'
|
||||
|
||||
interface Props {
|
||||
open: boolean
|
||||
count: number
|
||||
/** 제목 · 글의 id 앞부분 — 화면에 하나뿐인 값 */
|
||||
idPrefix: string
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'confirm'): void
|
||||
(e: 'cancel'): void
|
||||
}
|
||||
|
||||
defineProps<Props>()
|
||||
const emit = defineEmits<Emits>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ModalDialog
|
||||
:open="open"
|
||||
role="alertdialog"
|
||||
:title="MAIL_BULK_TEXT.purgeTitle(formatCount(count))"
|
||||
:message="MAIL_BULK_TEXT.purgeMessage"
|
||||
:title-id="`${idPrefix}-title`"
|
||||
:message-id="`${idPrefix}-message`"
|
||||
@cancel="emit('cancel')"
|
||||
>
|
||||
<template #actions>
|
||||
<button
|
||||
type="button"
|
||||
class="modal-dialog__action modal-dialog__action--danger"
|
||||
data-purge-confirm
|
||||
@click="emit('confirm')"
|
||||
>
|
||||
{{ MAIL_BULK_TEXT.purgeConfirm }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="modal-dialog__action modal-dialog__action--secondary"
|
||||
data-purge-cancel
|
||||
autofocus
|
||||
@click="emit('cancel')"
|
||||
>
|
||||
{{ MAIL_BULK_TEXT.purgeCancel }}
|
||||
</button>
|
||||
</template>
|
||||
</ModalDialog>
|
||||
</template>
|
||||
@@ -0,0 +1,167 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { mount } 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 { useMailStore } from '@/stores/mail.store'
|
||||
import { useToastStore } from '@/stores/toast.store'
|
||||
import { flushPromises, installMockApi, ok, restoreApiAdapter } from '@/test-utils/mock-api'
|
||||
import { createTestRouter } from '@/test-utils/router'
|
||||
import MailSearchBox from './MailSearchBox.vue'
|
||||
|
||||
// 상단 검색 칸 (디자인 README "3단계 › 검색" · 계약 2절 "검색 › 클라이언트")
|
||||
|
||||
function mailbox(id: string, role: Mailbox['role'], name: string): Mailbox {
|
||||
return { id, name, role, parentId: null, sortOrder: 0, totalCount: 1, unreadCount: 0 }
|
||||
}
|
||||
|
||||
const MAILBOXES = [mailbox('inbox', 'inbox', '받은편지함'), mailbox('trash', 'trash', '휴지통')]
|
||||
const Q = encodeURIComponent('회의')
|
||||
|
||||
describe('MailSearchBox (메일 검색)', () => {
|
||||
let pinia: Pinia
|
||||
|
||||
beforeEach(() => {
|
||||
pinia = createPinia()
|
||||
setActivePinia(pinia)
|
||||
useAuthStore().markAuthenticated({ email: 'ara.kim@arasystem.test', name: '김아라' })
|
||||
useMailStore().mailboxes = MAILBOXES
|
||||
installMockApi(() => ok({ items: [], pageInfo: { hasNext: false, nextCursor: null, total: 0 } }))
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
useToastStore().clear()
|
||||
restoreApiAdapter()
|
||||
})
|
||||
|
||||
async function mountAt(path: string) {
|
||||
const router = createTestRouter()
|
||||
await router.push(path)
|
||||
await router.isReady()
|
||||
const wrapper = mount(MailSearchBox, { global: { plugins: [pinia, router] } })
|
||||
return { wrapper, router, input: wrapper.get('input') }
|
||||
}
|
||||
|
||||
it('이름 · 안내는 "메일 검색" — 검색 중이 아니면 ✕ 가 없다', async () => {
|
||||
const { wrapper, input } = await mountAt('/mail/inbox')
|
||||
|
||||
expect(input.attributes('aria-label')).toBe('메일 검색')
|
||||
expect(input.attributes('placeholder')).toBe('메일 검색')
|
||||
expect(wrapper.get('form').attributes('role')).toBe('search')
|
||||
expect(wrapper.find('button').exists()).toBe(false)
|
||||
expect(wrapper.get('form').classes()).not.toContain('is-active')
|
||||
})
|
||||
|
||||
it('치는 동안에는 찾지 않고, Enter 로 보던 메일함에서 찾는다 (기록을 쌓는다 — 뒤로 가기로 메일함)', async () => {
|
||||
const { router, input } = await mountAt('/mail/inbox/m7')
|
||||
const push = vi.spyOn(router, 'push')
|
||||
|
||||
await input.setValue(' 회의 ')
|
||||
await flushPromises()
|
||||
expect(router.currentRoute.value.fullPath).toBe('/mail/inbox/m7')
|
||||
|
||||
await input.trigger('keydown', { key: 'Enter' })
|
||||
await flushPromises()
|
||||
|
||||
expect(router.currentRoute.value.fullPath).toBe(`/mail/search?q=${Q}&in=inbox`)
|
||||
expect(push).toHaveBeenCalled()
|
||||
expect(useMailStore().searchOrigin).toEqual({ mailboxId: 'inbox', location: '/mail/inbox/m7' })
|
||||
})
|
||||
|
||||
it('휴지통에서 시작하면 그 안에서 찾는다', async () => {
|
||||
const { router, input } = await mountAt('/mail/trash')
|
||||
|
||||
await input.setValue('회의')
|
||||
await input.trigger('keydown', { key: 'Enter' })
|
||||
await flushPromises()
|
||||
|
||||
expect(router.currentRoute.value.fullPath).toBe(`/mail/search?q=${Q}&in=trash`)
|
||||
})
|
||||
|
||||
it('한글 조합 중의 Enter 는 글자 확정이다 — 찾지 않는다 (isComposing · Safari 의 keyCode 229)', async () => {
|
||||
const { router, input } = await mountAt('/mail/inbox')
|
||||
await input.setValue('회의')
|
||||
|
||||
await input.trigger('keydown', { key: 'Enter', isComposing: true })
|
||||
const safari = new KeyboardEvent('keydown', { key: 'Enter', keyCode: 229, cancelable: true })
|
||||
input.element.dispatchEvent(safari)
|
||||
await flushPromises()
|
||||
|
||||
expect(router.currentRoute.value.fullPath).toBe('/mail/inbox')
|
||||
expect(safari.defaultPrevented).toBe(false)
|
||||
})
|
||||
|
||||
it('낱말이 8개를 넘으면 찾지 않고 서버와 같은 코드(VAL_001)의 사전 문구로 알린다 — 친 글자는 그대로', async () => {
|
||||
const { router, input } = await mountAt('/mail/inbox')
|
||||
const words = '하나 둘 셋 넷 다섯 여섯 일곱 여덟 아홉'
|
||||
await input.setValue(words)
|
||||
|
||||
await input.trigger('keydown', { key: 'Enter' })
|
||||
await flushPromises()
|
||||
|
||||
expect(router.currentRoute.value.fullPath).toBe('/mail/inbox')
|
||||
expect(useToastStore().toasts.map((toast) => toast.message)).toEqual([ERROR_CODE_MESSAGES.VAL_001])
|
||||
expect((input.element as HTMLInputElement).value).toBe(words)
|
||||
})
|
||||
|
||||
it('빈 검색어의 Enter 는 아무것도 하지 않는다', async () => {
|
||||
const { router, input } = await mountAt('/mail/inbox')
|
||||
await input.setValue(' ')
|
||||
|
||||
await input.trigger('keydown', { key: 'Enter' })
|
||||
await flushPromises()
|
||||
|
||||
expect(router.currentRoute.value.fullPath).toBe('/mail/inbox')
|
||||
})
|
||||
|
||||
it('검색 화면 — 칸에 검색어를 보이고 테두리가 강조색, 끝에 ✕("검색어 지우기") — ✕ 는 검색 전 메일함으로', async () => {
|
||||
useMailStore().rememberSearchOrigin({ mailboxId: 'inbox', location: '/mail/inbox/m7' })
|
||||
const { wrapper, router, input } = await mountAt(`/mail/search?q=${Q}&in=inbox`)
|
||||
|
||||
expect((input.element as HTMLInputElement).value).toBe('회의')
|
||||
expect(wrapper.get('form').classes()).toContain('is-active')
|
||||
const clear = wrapper.get('button')
|
||||
expect(clear.attributes('aria-label')).toBe('검색어 지우기')
|
||||
expect(clear.attributes('title')).toBe('검색어 지우기 (Esc)')
|
||||
|
||||
await clear.trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(router.currentRoute.value.fullPath).toBe('/mail/inbox/m7')
|
||||
expect((input.element as HTMLInputElement).value).toBe('')
|
||||
})
|
||||
|
||||
it('검색 화면의 Esc 는 ✕ 와 같다 — 시작한 곳을 모르면(주소로 바로 들어옴) 그 범위의 메일함으로', async () => {
|
||||
const { router, input } = await mountAt(`/mail/search?q=${Q}&in=trash`)
|
||||
|
||||
await input.trigger('keydown', { key: 'Escape' })
|
||||
await flushPromises()
|
||||
|
||||
expect(router.currentRoute.value.fullPath).toBe('/mail/trash')
|
||||
})
|
||||
|
||||
it('검색 화면이 아니면 Esc 는 친 글자만 지운다', async () => {
|
||||
const { router, input } = await mountAt('/mail/inbox')
|
||||
await input.setValue('회의')
|
||||
|
||||
await input.trigger('keydown', { key: 'Escape' })
|
||||
|
||||
expect((input.element as HTMLInputElement).value).toBe('')
|
||||
expect(router.currentRoute.value.fullPath).toBe('/mail/inbox')
|
||||
})
|
||||
|
||||
it('검색 화면에서 새로 찾으면 지금 범위 그대로 바꿔 끼운다 (기록을 쌓지 않는다)', async () => {
|
||||
const { router, input } = await mountAt(`/mail/search?q=${Q}&in=all`)
|
||||
const push = vi.spyOn(router, 'push')
|
||||
|
||||
await input.setValue('일정')
|
||||
await input.trigger('keydown', { key: 'Enter' })
|
||||
await flushPromises()
|
||||
|
||||
expect(router.currentRoute.value.fullPath).toBe(`/mail/search?q=${encodeURIComponent('일정')}&in=all`)
|
||||
expect(push).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,157 @@
|
||||
<script setup lang="ts">
|
||||
// 상단 검색 칸 — 메일 화면에서는 "메일 검색" (디자인 원본 Search · BulkSelect 머리 · README "3단계 › 검색")
|
||||
// - Enter 로 찾는다. 치는 동안에는 찾지 않는다 — 한글 조합 중의 Enter 는 글자 확정이다(isImeComposing, Safari · macOS 의 keyCode 229 포함)
|
||||
// - 검색 중(검색 화면)에는 테두리가 강조색(1px 테두리 + 1px 그림자)이고 끝에 ✕(40×38 · "검색어 지우기" — 밀도 예외).
|
||||
// ✕ · Esc 는 검색을 닫고 검색 전 메일함으로 돌아간다. 검색 화면이 아니면 Esc 는 친 글자만 지운다
|
||||
// - 검색 화면에서는 칸에 지금 검색어를 보인다 (뒤로 가기로 결과에 돌아와도)
|
||||
import { ref, watch } from 'vue'
|
||||
|
||||
import AppIcon from '@/components/AppIcon.vue'
|
||||
import { useMailSearchNavigation } from '@/composables/useMailSearchNavigation'
|
||||
import { MAIL_SEARCH_MAX_LENGTH, MAIL_SEARCH_TEXT } from '@/shared/constants/mail'
|
||||
import { isImeComposing } from '@/shared/utils/keyboard-shortcut'
|
||||
|
||||
const { isSearching, routeSearch, startSearch, closeSearch } = useMailSearchNavigation()
|
||||
|
||||
const text = ref<string>('')
|
||||
|
||||
function onKeydown(event: KeyboardEvent) {
|
||||
if (isImeComposing(event)) return
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault()
|
||||
void startSearch(text.value)
|
||||
return
|
||||
}
|
||||
if (event.key !== 'Escape') return
|
||||
if (isSearching.value) {
|
||||
event.preventDefault()
|
||||
void closeSearch()
|
||||
} else if (text.value !== '') {
|
||||
event.preventDefault()
|
||||
text.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
// 검색 화면에서는 주소의 검색어를 보이고, 검색을 닫으면(검색 화면을 떠나면) 비운다
|
||||
watch(
|
||||
() => routeSearch.value?.q ?? null,
|
||||
(query, previous) => {
|
||||
if (query !== null) text.value = query
|
||||
else if (previous) text.value = ''
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<form
|
||||
role="search"
|
||||
:class="['mail-search', { 'is-active': isSearching }]"
|
||||
@submit.prevent
|
||||
>
|
||||
<AppIcon
|
||||
name="search"
|
||||
class="mail-search__icon"
|
||||
/>
|
||||
<input
|
||||
v-model="text"
|
||||
type="search"
|
||||
class="mail-search__input"
|
||||
:aria-label="MAIL_SEARCH_TEXT.box"
|
||||
:placeholder="MAIL_SEARCH_TEXT.box"
|
||||
:maxlength="MAIL_SEARCH_MAX_LENGTH"
|
||||
autocomplete="off"
|
||||
enterkeyhint="search"
|
||||
@keydown="onKeydown"
|
||||
>
|
||||
<button
|
||||
v-if="isSearching"
|
||||
type="button"
|
||||
class="mail-search__clear"
|
||||
:aria-label="MAIL_SEARCH_TEXT.clear"
|
||||
:title="MAIL_SEARCH_TEXT.clearTitle"
|
||||
@click="closeSearch"
|
||||
>
|
||||
<AppIcon
|
||||
name="x"
|
||||
:size="15"
|
||||
:stroke-width="1.9"
|
||||
/>
|
||||
</button>
|
||||
</form>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.mail-search {
|
||||
flex: 1 1 17.5rem;
|
||||
max-width: 32.5rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-8);
|
||||
height: var(--size-search-box);
|
||||
margin: 0;
|
||||
padding: 0 var(--space-12);
|
||||
border: var(--border-width) solid var(--color-border-input);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--color-surface);
|
||||
}
|
||||
|
||||
/* 검색 중 · 칸에 초점 — 강조색 1px 테두리 + 1px 그림자 (입력칸 자체의 고리 대신 칸 테두리로 초점을 보인다) */
|
||||
.mail-search.is-active,
|
||||
.mail-search:focus-within {
|
||||
border-color: var(--color-brand);
|
||||
box-shadow: 0 0 0 var(--border-width) var(--color-brand);
|
||||
}
|
||||
|
||||
/* 끝의 ✕ 가 칸 오른쪽 끝까지 닿는다 */
|
||||
.mail-search.is-active {
|
||||
padding-right: 0;
|
||||
}
|
||||
|
||||
.mail-search__icon {
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.mail-search__input {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.mail-search__input:focus,
|
||||
.mail-search__input:focus-visible {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
/* 브라우저의 지우기 단추 대신 ✕ 하나만 */
|
||||
.mail-search__input::-webkit-search-cancel-button {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
}
|
||||
|
||||
/* 40×38 — 칸(40) 안쪽 높이를 채운다 · 밀도 예외 (tokens.css) */
|
||||
.mail-search__clear {
|
||||
flex: 0 0 auto;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: var(--size-search-clear-width);
|
||||
height: var(--size-search-clear-height);
|
||||
padding: 0;
|
||||
border: 0;
|
||||
border-radius: 0 calc(var(--radius-md) - var(--border-width)) calc(var(--radius-md) - var(--border-width)) 0;
|
||||
background: transparent;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.mail-search__clear:hover {
|
||||
background: var(--color-surface-muted);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.mail-search__clear:focus-visible {
|
||||
outline-offset: calc(var(--focus-ring-width) * -1);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,63 @@
|
||||
<script setup lang="ts">
|
||||
// 검색 결과 없음 (디자인 원본 Search "결과 없음") — 검색 아이콘 · "‘…’에 맞는 메일이 없습니다" · 찾는 방법의 한계 한 줄
|
||||
// 한 메일함에서 찾았으면 [전체 메일함에서 찾기]
|
||||
import StateMessage from '@/components/feedback/StateMessage.vue'
|
||||
import { MAIL_SEARCH_TEXT } from '@/shared/constants/mail'
|
||||
|
||||
interface Props {
|
||||
query: string
|
||||
/** 한 메일함에서 찾았다 — 전체 메일함에서 찾기를 권한다 */
|
||||
offerAll: boolean
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'search-all'): void
|
||||
}
|
||||
|
||||
defineProps<Props>()
|
||||
const emit = defineEmits<Emits>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<StateMessage
|
||||
class="search-empty"
|
||||
icon="search"
|
||||
:title="MAIL_SEARCH_TEXT.emptyTitle(query)"
|
||||
:hint="MAIL_SEARCH_TEXT.emptyHint"
|
||||
>
|
||||
<button
|
||||
v-if="offerAll"
|
||||
type="button"
|
||||
class="search-empty__all"
|
||||
@click="emit('search-all')"
|
||||
>
|
||||
{{ MAIL_SEARCH_TEXT.searchAll }}
|
||||
</button>
|
||||
</StateMessage>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.search-empty {
|
||||
padding: var(--space-72) var(--space-28);
|
||||
}
|
||||
|
||||
/* 한계 안내는 좁게 두 줄로 (디자인 원본 300px) */
|
||||
.search-empty :deep(.state-message__hint) {
|
||||
max-width: 18.75rem;
|
||||
}
|
||||
|
||||
.search-empty__all {
|
||||
margin-top: var(--space-8);
|
||||
min-height: var(--size-touch);
|
||||
padding: 0 var(--space-16);
|
||||
border: 0;
|
||||
border-radius: var(--radius-md);
|
||||
background: transparent;
|
||||
color: var(--color-brand);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.search-empty__all:hover {
|
||||
background: var(--color-brand-subtle);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,125 @@
|
||||
<script setup lang="ts">
|
||||
// 검색 결과 머리(52) — [←] 검색 닫기(40×44) · "검색 결과"(15px 굵게) · "‘회의’ · 6통"(12px 흐린 글자) · 보기 전환
|
||||
// 디자인 원본 Search(분할 보기) · ListStates(목록 보기 표). 안쪽 여백만 보기마다 다르다.
|
||||
// 검색 닫기는 검색 전 메일함으로 돌아간다 — 툴팁에 돌아갈 메일함 이름("검색 닫기 — 받은편지함으로")
|
||||
import { computed } from 'vue'
|
||||
|
||||
import AppIcon from '@/components/AppIcon.vue'
|
||||
import ViewModeSwitch from '@/components/mail/ViewModeSwitch.vue'
|
||||
import { useMailSearchNavigation } from '@/composables/useMailSearchNavigation'
|
||||
import { MAIL_SEARCH_TEXT } from '@/shared/constants/mail'
|
||||
import { formatCount } from '@/shared/utils/format'
|
||||
import { directionParticle } from '@/shared/utils/korean'
|
||||
import { useMailStore } from '@/stores/mail.store'
|
||||
|
||||
interface Props {
|
||||
/** 제목(h1)의 id — 목록 칸이 aria-labelledby 로 가리킨다 */
|
||||
titleId: string
|
||||
variant?: 'split' | 'list'
|
||||
}
|
||||
|
||||
withDefaults(defineProps<Props>(), {
|
||||
variant: 'split',
|
||||
})
|
||||
|
||||
const mailStore = useMailStore()
|
||||
const { originMailbox, closeSearch } = useMailSearchNavigation()
|
||||
|
||||
const countText = computed(() => {
|
||||
const query = mailStore.search?.q
|
||||
if (!query) return ''
|
||||
if (mailStore.listStatus === 'loading') return MAIL_SEARCH_TEXT.searching(query)
|
||||
if (mailStore.listStatus !== 'ready') return MAIL_SEARCH_TEXT.quoted(query)
|
||||
const total = mailStore.pageInfo?.total ?? mailStore.messages.length
|
||||
return MAIL_SEARCH_TEXT.count(query, formatCount(total))
|
||||
})
|
||||
|
||||
const closeTitle = computed(() => {
|
||||
const name = originMailbox.value?.name
|
||||
return name ? MAIL_SEARCH_TEXT.closeTitle(`${name}${directionParticle(name)}`) : MAIL_SEARCH_TEXT.close
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="['search-header', `search-header--${variant}`]">
|
||||
<button
|
||||
type="button"
|
||||
class="search-header__close"
|
||||
:aria-label="MAIL_SEARCH_TEXT.close"
|
||||
:title="closeTitle"
|
||||
@click="closeSearch"
|
||||
>
|
||||
<AppIcon
|
||||
name="arrow-left"
|
||||
:stroke-width="1.9"
|
||||
/>
|
||||
</button>
|
||||
<h1
|
||||
:id="titleId"
|
||||
class="search-header__title"
|
||||
>
|
||||
{{ MAIL_SEARCH_TEXT.title }}
|
||||
</h1>
|
||||
<span class="search-header__count">{{ countText }}</span>
|
||||
<ViewModeSwitch class="search-header__switch" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.search-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-6);
|
||||
min-height: var(--size-header-min);
|
||||
padding: var(--space-4);
|
||||
border-bottom: var(--border-width) solid var(--color-border);
|
||||
background: var(--color-surface);
|
||||
}
|
||||
|
||||
/* 목록 보기 — 오른쪽 여백을 표 머리와 맞춘다 (디자인 원본 ListStates) */
|
||||
.search-header--list {
|
||||
padding: var(--space-4) var(--space-8);
|
||||
}
|
||||
|
||||
.search-header__close {
|
||||
flex: 0 0 auto;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: var(--size-icon-button);
|
||||
height: var(--size-touch);
|
||||
padding: 0;
|
||||
border: 0;
|
||||
border-radius: var(--radius-md);
|
||||
background: transparent;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.search-header__close:hover {
|
||||
background: var(--color-surface-muted);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.search-header__title {
|
||||
flex: 0 0 auto;
|
||||
margin: 0;
|
||||
font-size: var(--font-size-title-sm);
|
||||
font-weight: 700;
|
||||
letter-spacing: var(--letter-spacing-tight);
|
||||
}
|
||||
|
||||
.search-header__count {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: var(--font-size-small);
|
||||
color: var(--color-text-muted);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.search-header__switch {
|
||||
flex: 0 0 auto;
|
||||
margin-left: auto;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,78 @@
|
||||
<script setup lang="ts">
|
||||
// 검색 범위 줄(48 · 바탕 #FBFBFC) — "찾을 곳" + [그 메일함 | 전체 메일함] + 전체일 때 "휴지통 · 스팸메일함은 빼고 찾습니다"
|
||||
// 디자인 원본 Search · README "3단계 › 검색". 전환 단추는 설정 화면과 같은 .segmented(36 — 밀도 예외, base.css)
|
||||
// "그 메일함"은 검색을 시작한 메일함이다 (휴지통 · 스팸메일함에서 시작하면 그 안에서 찾는다) — 바꾸면 기록을 쌓지 않고 다시 찾는다
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { useMailSearchNavigation } from '@/composables/useMailSearchNavigation'
|
||||
import { MAIL_SEARCH_TEXT, SEARCH_SCOPE_ALL } from '@/shared/constants/mail'
|
||||
import type { Mailbox } from '@/shared/types/mail'
|
||||
import { useMailStore } from '@/stores/mail.store'
|
||||
|
||||
const mailStore = useMailStore()
|
||||
const { originMailbox, setScope } = useMailSearchNavigation()
|
||||
|
||||
const isAll = computed(() => mailStore.search?.scope === SEARCH_SCOPE_ALL)
|
||||
/** 그 메일함 — 한 메일함에서 찾는 중이면 그 메일함, 전체에서 찾는 중이면 검색을 시작한 메일함 */
|
||||
const boxMailbox = computed<Mailbox | null>(() => (isAll.value ? originMailbox.value : mailStore.currentMailbox))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="search-scope">
|
||||
<span
|
||||
id="search-scope-label"
|
||||
class="search-scope__label"
|
||||
>{{ MAIL_SEARCH_TEXT.scopeLabel }}</span>
|
||||
<div
|
||||
class="segmented"
|
||||
role="group"
|
||||
aria-labelledby="search-scope-label"
|
||||
>
|
||||
<button
|
||||
v-if="boxMailbox"
|
||||
type="button"
|
||||
class="segmented__item"
|
||||
:aria-pressed="!isAll"
|
||||
@click="setScope(boxMailbox.id)"
|
||||
>
|
||||
{{ boxMailbox.name }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="segmented__item"
|
||||
:aria-pressed="isAll"
|
||||
@click="setScope(SEARCH_SCOPE_ALL)"
|
||||
>
|
||||
{{ MAIL_SEARCH_TEXT.scopeAll }}
|
||||
</button>
|
||||
</div>
|
||||
<span
|
||||
v-if="isAll"
|
||||
class="search-scope__hint"
|
||||
>{{ MAIL_SEARCH_TEXT.scopeAllHint }}</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.search-scope {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: var(--space-6) var(--space-10);
|
||||
min-height: var(--space-48);
|
||||
padding: var(--space-4) var(--space-12) var(--space-4) var(--space-16);
|
||||
border-bottom: var(--border-width) solid var(--color-border-subtle);
|
||||
background: var(--color-surface-sunken);
|
||||
}
|
||||
|
||||
.search-scope__label {
|
||||
font-size: var(--font-size-label);
|
||||
font-weight: 600;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.search-scope__hint {
|
||||
font-size: var(--font-size-small);
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,379 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { createPinia, setActivePinia, type Pinia } from 'pinia'
|
||||
|
||||
import type { Mailbox, MessageSummary, MessagesPatch } from '@/shared/types/mail'
|
||||
import { useAuthStore } from '@/stores/auth.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 MessageList from './MessageList.vue'
|
||||
import MessageTable from './MessageTable.vue'
|
||||
|
||||
// 여러 통 처리 — 도구 줄 · 이동 메뉴 · 알림 · 되돌리기 · 영구 삭제 확인 (디자인 원본 BulkSelect · ListStates)
|
||||
|
||||
function mailbox(id: string, role: Mailbox['role'], name: string): Mailbox {
|
||||
return { id, name, role, parentId: null, sortOrder: 0, totalCount: 3, unreadCount: 0 }
|
||||
}
|
||||
|
||||
const MAILBOXES = [
|
||||
mailbox('inbox', 'inbox', '받은편지함'),
|
||||
mailbox('sent', 'sent', '보낸편지함'),
|
||||
mailbox('drafts', 'drafts', '임시보관함'),
|
||||
mailbox('junk', 'junk', '스팸메일함'),
|
||||
mailbox('trash', 'trash', '휴지통'),
|
||||
mailbox('archive', 'archive', '보관함'),
|
||||
]
|
||||
|
||||
function summary(id: string, partial: Partial<MessageSummary> = {}): MessageSummary {
|
||||
return {
|
||||
id,
|
||||
threadId: `t-${id}`,
|
||||
mailboxIds: ['inbox'],
|
||||
from: { name: '이도윤', email: 'doyun.lee@arasystem.test' },
|
||||
to: [],
|
||||
subject: `제목 ${id}`,
|
||||
preview: '',
|
||||
receivedAt: '2026-10-27T01:00:00Z',
|
||||
isRead: true,
|
||||
isFlagged: false,
|
||||
hasAttachment: false,
|
||||
size: 100,
|
||||
...partial,
|
||||
}
|
||||
}
|
||||
|
||||
type Handler = (request: MockRequest) => MockResult | Promise<MockResult> | undefined
|
||||
|
||||
describe('여러 통 처리 도구 줄', () => {
|
||||
let pinia: Pinia
|
||||
let requests: MockRequest[]
|
||||
|
||||
beforeEach(() => {
|
||||
pinia = createPinia()
|
||||
setActivePinia(pinia)
|
||||
useAuthStore().markAuthenticated({ email: 'ara.kim@arasystem.test', name: '김아라' })
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
useToastStore().clear()
|
||||
restoreApiAdapter()
|
||||
document.body.innerHTML = ''
|
||||
})
|
||||
|
||||
async function mountView(
|
||||
options: { view?: 'split' | 'list'; boxId?: string; messages?: MessageSummary[]; path?: string; handler?: Handler } = {},
|
||||
) {
|
||||
const boxId = options.boxId ?? 'inbox'
|
||||
const messages = options.messages ?? [summary('m1'), summary('m2'), summary('m3')]
|
||||
requests = installMockApi((request) => {
|
||||
const custom = options.handler?.(request)
|
||||
if (custom) return custom
|
||||
if (request.url === '/mailboxes') return ok({ items: MAILBOXES, pageInfo: { hasNext: false, nextCursor: null, total: 6 } })
|
||||
if (request.url === '/mail-quota') return ok({ usedBytes: 0, limitBytes: null, attachmentMaxBytes: 1, attachmentTotalMaxBytes: 1 })
|
||||
if (request.url === '/messages' && request.method === 'GET') {
|
||||
return ok({ items: messages, pageInfo: { hasNext: false, nextCursor: null, total: messages.length } })
|
||||
}
|
||||
if (request.method === 'PATCH') {
|
||||
const patch = request.data as MessagesPatch
|
||||
return ok({ updatedIds: patch.ids, failedIds: [] })
|
||||
}
|
||||
return ok({ deletedIds: (request.data as { ids: string[] }).ids, skippedIds: [] })
|
||||
})
|
||||
const store = useMailStore()
|
||||
store.mailboxes = MAILBOXES
|
||||
store.currentMailboxId = boxId
|
||||
store.messages = messages
|
||||
store.pageInfo = { hasNext: false, nextCursor: null, total: messages.length }
|
||||
store.listStatus = 'ready'
|
||||
const router = createTestRouter()
|
||||
await router.push(options.path ?? `/mail/${boxId}`)
|
||||
await router.isReady()
|
||||
const wrapper = mount(options.view === 'split' ? MessageList : MessageTable, {
|
||||
global: { plugins: [pinia, router] },
|
||||
attachTo: document.body,
|
||||
})
|
||||
return { wrapper, router, store }
|
||||
}
|
||||
|
||||
const toastTexts = () => useToastStore().toasts.map((toast) => toast.message)
|
||||
const bulkPatches = () =>
|
||||
requests.filter((request) => request.method === 'PATCH' && request.url === '/messages').map((request) => request.data)
|
||||
|
||||
it('도구 줄 — "N통 선택됨" · 읽음 · 안 읽음 · 중요 표시 | 이동 · 삭제 … 선택 해제 (목록 보기는 이름을 보인다)', async () => {
|
||||
const { wrapper, store } = await mountView()
|
||||
store.toggleCheck('m1')
|
||||
store.toggleCheck('m2')
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.get('.bulk-bar__count').text()).toBe('2통 선택됨')
|
||||
const labels = wrapper.findAll('[role="toolbar"] button').map((button) => button.attributes('aria-label'))
|
||||
expect(labels).toEqual(['읽음 표시', '안 읽음 표시', '중요 표시', '이동', '삭제', '선택 해제'])
|
||||
expect(wrapper.get('[data-tool="clear"]').attributes('title')).toBe('선택 해제 (Esc)')
|
||||
expect(wrapper.get('.bulk-bar').classes()).toContain('bulk-bar--list')
|
||||
expect(wrapper.get('[data-tool="markRead"]').text()).toBe('읽음 표시')
|
||||
})
|
||||
|
||||
it('읽음 · 안 읽음 · 중요 표시 — PATCH /messages 한 번, 목록이 바로 바뀌고 알리지 않는다 · 모두 중요면 "중요 해제"', async () => {
|
||||
const { wrapper, store } = await mountView({
|
||||
messages: [summary('m1', { isRead: false }), summary('m2', { isRead: false }), summary('m3')],
|
||||
})
|
||||
store.toggleCheck('m1')
|
||||
store.toggleCheck('m2')
|
||||
await flushPromises()
|
||||
|
||||
await wrapper.get('[data-tool="markRead"]').trigger('click')
|
||||
await flushPromises()
|
||||
expect(bulkPatches()).toEqual([{ ids: ['m1', 'm2'], isRead: true }])
|
||||
expect(wrapper.findAll('tbody tr.is-unread')).toHaveLength(0)
|
||||
|
||||
await wrapper.get('[data-tool="flag"]').trigger('click')
|
||||
await flushPromises()
|
||||
expect(bulkPatches()[1]).toEqual({ ids: ['m1', 'm2'], isFlagged: true })
|
||||
expect(wrapper.get('[data-tool="flag"]').attributes('aria-label')).toBe('중요 해제')
|
||||
expect(toastTexts()).toEqual([])
|
||||
})
|
||||
|
||||
it('이동 메뉴 — 지금 메일함 · 임시보관함 · 휴지통을 뺀 메일함, 아래 화살표로 열면 첫 항목에 초점 · Esc 는 메뉴만 닫는다', async () => {
|
||||
const { wrapper, store } = await mountView()
|
||||
store.toggleCheck('m1')
|
||||
await flushPromises()
|
||||
const move = wrapper.get('[data-tool="move"]')
|
||||
|
||||
await move.trigger('keydown', { key: 'ArrowDown' })
|
||||
await flushPromises()
|
||||
const items = wrapper.findAll('[role="menu"] [role="menuitem"]')
|
||||
expect(wrapper.get('[role="menu"]').attributes('aria-label')).toBe('옮길 메일함')
|
||||
expect(items.map((item) => item.text())).toEqual(['보낸편지함', '스팸메일함', '보관함'])
|
||||
expect(document.activeElement).toBe(items[0]?.element)
|
||||
expect(move.attributes('aria-expanded')).toBe('true')
|
||||
|
||||
await items[0]?.trigger('keydown', { key: 'Escape' })
|
||||
await flushPromises()
|
||||
expect(wrapper.find('[role="menu"]').exists()).toBe(false)
|
||||
expect(document.activeElement).toBe(wrapper.get('[data-tool="move"]').element)
|
||||
expect(store.checkedCount).toBe(1)
|
||||
})
|
||||
|
||||
it('옮기면 옮긴 메일만 빼고 "메일 N통을 보관함으로 옮겼습니다." + [되돌리기] — 되돌리면 원래 메일함으로 옮기고 알린다', async () => {
|
||||
const { wrapper, store } = await mountView()
|
||||
store.toggleCheck('m1')
|
||||
store.toggleCheck('m3')
|
||||
await flushPromises()
|
||||
|
||||
await wrapper.get('[data-tool="move"]').trigger('click')
|
||||
await flushPromises()
|
||||
const archive = wrapper.findAll('[role="menuitem"]').find((item) => item.text() === '보관함')
|
||||
await archive?.trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(bulkPatches()).toEqual([{ ids: ['m1', 'm3'], mailboxId: 'archive' }])
|
||||
expect(store.messages.map((message) => message.id)).toEqual(['m2'])
|
||||
expect(store.checkedCount).toBe(0)
|
||||
const [toast] = useToastStore().toasts
|
||||
expect(toast?.message).toBe('메일 2통을 보관함으로 옮겼습니다.')
|
||||
expect(toast?.action?.label).toBe('되돌리기')
|
||||
|
||||
if (toast?.action && 'run' in toast.action) toast.action.run()
|
||||
await flushPromises()
|
||||
expect(bulkPatches()[1]).toEqual({ ids: ['m1', 'm3'], mailboxId: 'inbox' })
|
||||
expect(toastTexts()).toContain('메일 2통을 되돌렸습니다.')
|
||||
})
|
||||
|
||||
it('일부만 옮겼으면 알림 뒤에 "N통은 그사이 옮겨졌거나 지워져 그대로 두었습니다."', async () => {
|
||||
const { wrapper, store } = await mountView({
|
||||
handler: (request) =>
|
||||
request.method === 'PATCH' ? ok({ updatedIds: ['m1', 'm2'], failedIds: ['m3'] }) : undefined,
|
||||
})
|
||||
store.toggleCheckAll()
|
||||
await flushPromises()
|
||||
|
||||
await wrapper.get('[data-tool="move"]').trigger('click')
|
||||
await flushPromises()
|
||||
await wrapper.findAll('[role="menuitem"]').find((item) => item.text() === '보낸편지함')?.trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(toastTexts()).toEqual([
|
||||
'메일 2통을 보낸편지함으로 옮겼습니다. 1통은 그사이 옮겨졌거나 지워져 그대로 두었습니다.',
|
||||
])
|
||||
})
|
||||
|
||||
it('휴지통 밖의 삭제는 휴지통으로 옮긴다 — DELETE 가 아니라 PATCH { mailboxId: 휴지통 }', async () => {
|
||||
const { wrapper, store } = await mountView()
|
||||
store.toggleCheck('m2')
|
||||
await flushPromises()
|
||||
|
||||
await wrapper.get('[data-tool="delete"]').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(bulkPatches()).toEqual([{ ids: ['m2'], mailboxId: 'trash' }])
|
||||
expect(requests.some((request) => request.method === 'DELETE')).toBe(false)
|
||||
expect(toastTexts()).toEqual(['메일 1통을 휴지통으로 옮겼습니다.'])
|
||||
})
|
||||
|
||||
it('요청이 실패하면 인터셉터 알림만 — 고른 것을 그대로 둔다', async () => {
|
||||
const { wrapper, store } = await mountView({
|
||||
handler: (request) => (request.method === 'PATCH' ? fail(503, 'MAIL_001') : undefined),
|
||||
})
|
||||
store.toggleCheck('m1')
|
||||
await flushPromises()
|
||||
|
||||
await wrapper.get('[data-tool="delete"]').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(store.messages).toHaveLength(3)
|
||||
expect(store.checkedCount).toBe(1)
|
||||
expect(useToastStore().toasts.map((toast) => toast.kind)).toEqual(['error'])
|
||||
})
|
||||
|
||||
describe('휴지통 — 영구 삭제 확인', () => {
|
||||
const trashed = () => [summary('t1', { mailboxIds: ['trash'] }), summary('t2', { mailboxIds: ['trash'] })]
|
||||
|
||||
it('"영구 삭제"를 누르면 먼저 묻는다 — 제목 · 글, 처음 초점은 「취소」 · 취소하면 지우지 않는다', async () => {
|
||||
const { wrapper, store } = await mountView({ boxId: 'trash', messages: trashed() })
|
||||
store.toggleCheckAll()
|
||||
await flushPromises()
|
||||
const purge = wrapper.get('[data-tool="delete"]')
|
||||
expect(purge.attributes('aria-label')).toBe('영구 삭제')
|
||||
|
||||
await purge.trigger('click')
|
||||
await flushPromises()
|
||||
const dialog = wrapper.get('dialog')
|
||||
expect(dialog.attributes('open')).toBeDefined()
|
||||
expect(dialog.get('#bulk-purge-title').text()).toBe('메일 2통을 영구 삭제할까요?')
|
||||
expect(dialog.get('#bulk-purge-message').text()).toBe('휴지통에서 지운 메일은 되돌릴 수 없습니다.')
|
||||
expect(document.activeElement).toBe(dialog.get('[data-purge-cancel]').element)
|
||||
|
||||
// 대화상자 안의 Esc 는 고른 것을 풀지 않는다 (목록 칸의 Esc 와 겹치지 않게)
|
||||
await dialog.get('[data-purge-cancel]').trigger('keydown', { key: 'Escape' })
|
||||
await dialog.get('[data-purge-cancel]').trigger('click')
|
||||
await flushPromises()
|
||||
expect(wrapper.get('dialog').attributes('open')).toBeUndefined()
|
||||
expect(store.checkedCount).toBe(2)
|
||||
expect(requests.some((request) => request.url === '/message-deletions')).toBe(false)
|
||||
})
|
||||
|
||||
it('「영구 삭제」를 누르면 POST /message-deletions — 지운 메일을 빼고 "메일 N통을 영구 삭제했습니다."', async () => {
|
||||
const { wrapper, store } = await mountView({ boxId: 'trash', messages: trashed() })
|
||||
store.toggleCheckAll()
|
||||
await flushPromises()
|
||||
|
||||
await wrapper.get('[data-tool="delete"]').trigger('click')
|
||||
await flushPromises()
|
||||
await wrapper.get('[data-purge-confirm]').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
const deletion = requests.find((request) => request.url === '/message-deletions')
|
||||
expect(deletion).toMatchObject({ method: 'POST', data: { ids: ['t1', 't2'] } })
|
||||
expect(store.messages).toEqual([])
|
||||
expect(toastTexts()).toEqual(['메일 2통을 영구 삭제했습니다.'])
|
||||
expect(requests.some((request) => request.method === 'DELETE')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
it('분할 보기 — 열어 둔 메일을 옮기면 목록 주소로 바꿔 끼운다', async () => {
|
||||
const { wrapper, router, store } = await mountView({ view: 'split', path: '/mail/inbox/m2' })
|
||||
store.selectedId = 'm2'
|
||||
store.toggleCheck('m2')
|
||||
await flushPromises()
|
||||
expect(wrapper.get('.bulk-bar').classes()).toContain('bulk-bar--split')
|
||||
|
||||
await wrapper.get('[data-tool="delete"]').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(router.currentRoute.value.fullPath).toBe('/mail/inbox')
|
||||
})
|
||||
|
||||
it('도구 줄 안에서는 화살표로 단추를 옮긴다 (로빙 tabindex) · 선택 해제는 고른 것을 푼다', async () => {
|
||||
const { wrapper, store } = await mountView()
|
||||
store.toggleCheck('m1')
|
||||
await flushPromises()
|
||||
const first = wrapper.get('[data-tool="markRead"]')
|
||||
expect(first.attributes('tabindex')).toBe('0')
|
||||
expect(wrapper.get('[data-tool="markUnread"]').attributes('tabindex')).toBe('-1')
|
||||
|
||||
await first.trigger('keydown', { key: 'ArrowRight' })
|
||||
expect(document.activeElement).toBe(wrapper.get('[data-tool="markUnread"]').element)
|
||||
await wrapper.get('[data-tool="markUnread"]').trigger('keydown', { key: 'End' })
|
||||
expect(document.activeElement).toBe(wrapper.get('[data-tool="clear"]').element)
|
||||
|
||||
await wrapper.get('[data-tool="clear"]').trigger('click')
|
||||
await flushPromises()
|
||||
expect(store.checkedCount).toBe(0)
|
||||
expect(wrapper.find('.bulk-bar').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('옮기는 동안 눌리지 않는 단추(이동 · 삭제)는 Tab 자리 · 화살표에서 건너뛰고, 초점이 그 단추에 있었으면 다음 단추로 옮긴다', async () => {
|
||||
let release: (result: MockResult) => void = () => undefined
|
||||
const { wrapper, store } = await mountView({
|
||||
handler: (request) =>
|
||||
request.method === 'PATCH'
|
||||
? new Promise<MockResult>((resolve) => {
|
||||
release = resolve
|
||||
})
|
||||
: undefined,
|
||||
})
|
||||
store.toggleCheck('m1')
|
||||
await flushPromises()
|
||||
const tool = (id: string) => wrapper.get(`[data-tool="${id}"]`)
|
||||
;(tool('delete').element as HTMLElement).focus()
|
||||
await flushPromises()
|
||||
expect(tool('delete').attributes('tabindex')).toBe('0')
|
||||
|
||||
await tool('delete').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(tool('delete').attributes('disabled')).toBeDefined()
|
||||
expect(tool('move').attributes('disabled')).toBeDefined()
|
||||
expect(tool('delete').attributes('tabindex')).toBe('-1')
|
||||
expect(tool('clear').attributes('tabindex')).toBe('0')
|
||||
expect(document.activeElement).toBe(tool('clear').element)
|
||||
|
||||
await tool('flag').trigger('keydown', { key: 'ArrowRight' })
|
||||
expect(document.activeElement).toBe(tool('clear').element)
|
||||
await tool('clear').trigger('keydown', { key: 'ArrowLeft' })
|
||||
expect(document.activeElement).toBe(tool('flag').element)
|
||||
|
||||
release(ok({ updatedIds: ['m1'], failedIds: [] }))
|
||||
await flushPromises()
|
||||
expect(store.checkedCount).toBe(0)
|
||||
})
|
||||
|
||||
it('초점이 도구 줄 밖에 있어도 Tab 자리는 누를 수 있는 단추로 옮긴다 — 초점은 빼앗지 않는다', async () => {
|
||||
let release: (result: MockResult) => void = () => undefined
|
||||
const { wrapper, store } = await mountView({
|
||||
handler: (request) =>
|
||||
request.method === 'PATCH'
|
||||
? new Promise<MockResult>((resolve) => {
|
||||
release = resolve
|
||||
})
|
||||
: undefined,
|
||||
})
|
||||
store.toggleCheck('m1')
|
||||
await flushPromises()
|
||||
const tool = (id: string) => wrapper.get(`[data-tool="${id}"]`)
|
||||
;(tool('delete').element as HTMLElement).focus()
|
||||
const outside = document.createElement('button')
|
||||
document.body.append(outside)
|
||||
outside.focus()
|
||||
await flushPromises()
|
||||
|
||||
await tool('delete').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(tool('delete').attributes('tabindex')).toBe('-1')
|
||||
expect(tool('clear').attributes('tabindex')).toBe('0')
|
||||
expect(document.activeElement).toBe(outside)
|
||||
release(ok({ updatedIds: ['m1'], failedIds: [] }))
|
||||
await flushPromises()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,274 @@
|
||||
<script setup lang="ts">
|
||||
// 여러 통 처리 도구 줄 — 하나라도 고르면 목록 머리(52) 자리에 (디자인 원본 BulkSelect · ListStates, 바탕 #F3F8F8)
|
||||
// "N통 선택됨"(14px 굵게) · 읽음 표시 · 안 읽음 표시 · 중요 표시(모두 중요면 "중요 해제") | 이동 ▾ · 삭제(휴지통에서는 "영구 삭제", 빨강)
|
||||
// … 오른쪽 끝 선택 해제(✕). 하는 일은 useBulkMailActions 가 맡는다.
|
||||
// - 단추(MailBulkButton)는 높이 44 · 아이콘 + 이름. 폭 1100 미만이거나 분할 보기의 목록 칸에서는 아이콘만(40×44 — 이름은 툴팁 · aria-label).
|
||||
// "선택 해제"는 목록 보기에서 이름을 늘 보인다 (캔버스 BulkSelect)
|
||||
// - role="toolbar": Tab 한 번에 들어와 화살표 · Home · End 로 옮긴다(로빙 tabindex). 이동 메뉴는 MailBulkMoveButton.
|
||||
// 옮기기 · 지우기를 하는 동안 눌리지 않는 단추(이동 · 삭제)는 건너뛰고, Tab 자리 · 초점이 그 단추에 있었으면 다음 누를 수 있는 단추로 옮긴다
|
||||
// - 영구 삭제는 확인 대화상자(MailPurgeDialog — 처음 초점은 「취소」)를 거친다
|
||||
import { computed, nextTick, ref, watch } from 'vue'
|
||||
|
||||
import MailBulkButton from '@/components/mail/MailBulkButton.vue'
|
||||
import MailBulkMoveButton from '@/components/mail/MailBulkMoveButton.vue'
|
||||
import MailPurgeDialog from '@/components/mail/MailPurgeDialog.vue'
|
||||
import { useBulkMailActions } from '@/composables/useBulkMailActions'
|
||||
import type { IconName } from '@/shared/constants/icons'
|
||||
import { MAIL_BULK_TEXT } from '@/shared/constants/mail'
|
||||
import { formatCount } from '@/shared/utils/format'
|
||||
import { useMailStore } from '@/stores/mail.store'
|
||||
|
||||
interface Props {
|
||||
/** "N통 선택됨"(h1)의 id — 목록 칸이 aria-labelledby 로 가리킨다 */
|
||||
titleId: string
|
||||
variant: 'split' | 'list'
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
|
||||
const mailStore = useMailStore()
|
||||
const bulk = useBulkMailActions()
|
||||
|
||||
type ToolId = 'markRead' | 'markUnread' | 'flag' | 'move' | 'delete' | 'clear'
|
||||
const TOOL_ORDER: readonly ToolId[] = ['markRead', 'markUnread', 'flag', 'move', 'delete', 'clear']
|
||||
|
||||
interface LeadingTool {
|
||||
id: Extract<ToolId, 'markRead' | 'markUnread' | 'flag'>
|
||||
icon: IconName
|
||||
label: string
|
||||
run: () => void
|
||||
}
|
||||
|
||||
const toolsRef = ref<HTMLElement | null>(null)
|
||||
/** 로빙 tabindex — 마지막으로 초점이 간 단추 (Tab 으로 들어올 단추는 rovingTool) */
|
||||
const activeTool = ref<ToolId>('markRead')
|
||||
const purgeOpen = ref<boolean>(false)
|
||||
/** 초점이 도구 줄 안의 어느 단추에 있었는지 — 그 단추가 눌리지 않게 되어 초점을 잃었을 때 되돌려 줄 곳을 찾는다 */
|
||||
let lastFocusedTool: ToolId | null = null
|
||||
|
||||
/** 지금 누를 수 없는 단추 — 옮기기 · 지우기를 하는 동안 */
|
||||
const disabledTools = computed<ReadonlySet<ToolId>>(() => new Set<ToolId>(bulk.busy.value ? ['move', 'delete'] : []))
|
||||
/** 화살표 · Home · End 로 옮겨 다닐 단추 — 누를 수 없는 단추는 건너뛴다 */
|
||||
const enabledTools = computed<ToolId[]>(() => TOOL_ORDER.filter((id) => !disabledTools.value.has(id)))
|
||||
/** Tab 으로 들어올 단추 — 마지막 단추를 누를 수 없게 되면 그다음(끝이면 처음부터) 누를 수 있는 단추 */
|
||||
const rovingTool = computed<ToolId>(() => {
|
||||
if (!disabledTools.value.has(activeTool.value)) return activeTool.value
|
||||
const start = TOOL_ORDER.indexOf(activeTool.value)
|
||||
for (let step = 1; step < TOOL_ORDER.length; step += 1) {
|
||||
const candidate = TOOL_ORDER[(start + step) % TOOL_ORDER.length]
|
||||
if (candidate && !disabledTools.value.has(candidate)) return candidate
|
||||
}
|
||||
return 'clear'
|
||||
})
|
||||
|
||||
/** 분할 보기의 목록 칸은 아이콘만, 목록 보기는 폭 1100 이상에서 이름까지 */
|
||||
const labelMode = computed(() => (props.variant === 'split' ? 'never' : 'wide'))
|
||||
const countText = computed(() => MAIL_BULK_TEXT.selected(formatCount(mailStore.checkedCount)))
|
||||
const deleteLabel = computed(() => (bulk.inTrash.value ? MAIL_BULK_TEXT.purge : MAIL_BULK_TEXT.delete))
|
||||
const leadingTools = computed<LeadingTool[]>(() => [
|
||||
{ id: 'markRead', icon: 'mail-open', label: MAIL_BULK_TEXT.markRead, run: () => void bulk.markRead(true) },
|
||||
{ id: 'markUnread', icon: 'mail', label: MAIL_BULK_TEXT.markUnread, run: () => void bulk.markRead(false) },
|
||||
{
|
||||
id: 'flag',
|
||||
icon: 'star',
|
||||
label: bulk.allFlagged.value ? MAIL_BULK_TEXT.unflag : MAIL_BULK_TEXT.flag,
|
||||
run: () => void bulk.toggleFlagged(),
|
||||
},
|
||||
])
|
||||
|
||||
function tabindexOf(id: ToolId): number {
|
||||
return rovingTool.value === id ? 0 : -1
|
||||
}
|
||||
|
||||
function focusTool(id: ToolId) {
|
||||
activeTool.value = id
|
||||
toolsRef.value?.querySelector<HTMLElement>(`[data-tool="${id}"]`)?.focus()
|
||||
}
|
||||
|
||||
function onDelete() {
|
||||
if (bulk.inTrash.value) {
|
||||
purgeOpen.value = true
|
||||
return
|
||||
}
|
||||
void bulk.moveToTrash()
|
||||
}
|
||||
|
||||
function onPurgeConfirm() {
|
||||
purgeOpen.value = false
|
||||
void bulk.purge()
|
||||
}
|
||||
|
||||
function onKeydown(event: KeyboardEvent) {
|
||||
const target = event.target instanceof HTMLElement ? event.target : null
|
||||
// 메뉴 · 대화상자 안의 키는 그쪽이 처리한다
|
||||
if (!target || target.closest('[data-editor-popup], dialog')) return
|
||||
const order = enabledTools.value
|
||||
const current = order.findIndex((id) => id === target.dataset.tool)
|
||||
if (current === -1) return
|
||||
const last = order.length - 1
|
||||
const moves: Record<string, number> = {
|
||||
ArrowRight: current === last ? 0 : current + 1,
|
||||
ArrowLeft: current === 0 ? last : current - 1,
|
||||
Home: 0,
|
||||
End: last,
|
||||
}
|
||||
const next = order[moves[event.key] ?? -1]
|
||||
if (!next) return
|
||||
event.preventDefault()
|
||||
focusTool(next)
|
||||
}
|
||||
|
||||
function onFocusIn(event: FocusEvent) {
|
||||
const element = event.target instanceof HTMLElement ? event.target : null
|
||||
const tool = TOOL_ORDER.find((id) => id === element?.dataset.tool)
|
||||
if (!tool) return
|
||||
activeTool.value = tool
|
||||
lastFocusedTool = tool
|
||||
}
|
||||
|
||||
/** 초점이 도구 줄 밖의 다른 곳으로 갔다 — 갈 곳 없이 사라진 것(단추가 눌리지 않게 됨)은 기억해 둔다 */
|
||||
function onFocusOut(event: FocusEvent) {
|
||||
const next = event.relatedTarget
|
||||
if (next instanceof Node && toolsRef.value?.contains(next)) return
|
||||
if (next !== null) lastFocusedTool = null
|
||||
}
|
||||
|
||||
// 초점이 있던 단추를 누를 수 없게 되면(옮기기 · 지우기 시작) 다음 누를 수 있는 단추로 초점을 옮긴다 — 키보드 사용자가 자리를 잃지 않게
|
||||
watch(disabledTools, (disabled) => {
|
||||
const focused = lastFocusedTool
|
||||
if (!focused || !disabled.has(focused)) return
|
||||
void nextTick(() => {
|
||||
const active = document.activeElement
|
||||
const lost =
|
||||
active === null ||
|
||||
active === document.body ||
|
||||
(active instanceof HTMLElement && active.dataset.tool === focused)
|
||||
if (lost) focusTool(rovingTool.value)
|
||||
})
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="['bulk-bar', `bulk-bar--${variant}`]">
|
||||
<h1
|
||||
:id="titleId"
|
||||
class="bulk-bar__count"
|
||||
>
|
||||
{{ countText }}
|
||||
</h1>
|
||||
<div
|
||||
ref="toolsRef"
|
||||
class="bulk-bar__tools"
|
||||
role="toolbar"
|
||||
:aria-label="MAIL_BULK_TEXT.toolbar"
|
||||
@keydown="onKeydown"
|
||||
@focusin="onFocusIn"
|
||||
@focusout="onFocusOut"
|
||||
>
|
||||
<MailBulkButton
|
||||
v-for="tool in leadingTools"
|
||||
:key="tool.id"
|
||||
:icon="tool.icon"
|
||||
:label="tool.label"
|
||||
:label-mode="labelMode"
|
||||
:data-tool="tool.id"
|
||||
:tabindex="tabindexOf(tool.id)"
|
||||
@click="tool.run"
|
||||
/>
|
||||
<span
|
||||
v-if="variant === 'list'"
|
||||
class="bulk-bar__divider"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<MailBulkMoveButton
|
||||
:mailboxes="bulk.moveTargets.value"
|
||||
:label-mode="labelMode"
|
||||
:disabled="bulk.busy.value"
|
||||
data-tool="move"
|
||||
:tabindex="tabindexOf('move')"
|
||||
@move="bulk.moveTo"
|
||||
/>
|
||||
<MailBulkButton
|
||||
icon="trash"
|
||||
:label="deleteLabel"
|
||||
:label-mode="labelMode"
|
||||
tone="danger"
|
||||
:disabled="bulk.busy.value"
|
||||
data-tool="delete"
|
||||
:tabindex="tabindexOf('delete')"
|
||||
@click="onDelete"
|
||||
/>
|
||||
<MailBulkButton
|
||||
class="bulk-bar__clear"
|
||||
icon="x"
|
||||
:label="MAIL_BULK_TEXT.clear"
|
||||
:title="MAIL_BULK_TEXT.clearTitle"
|
||||
:label-mode="variant === 'split' ? 'never' : 'always'"
|
||||
:icon-stroke-width="1.9"
|
||||
data-tool="clear"
|
||||
:tabindex="tabindexOf('clear')"
|
||||
@click="mailStore.clearChecked()"
|
||||
/>
|
||||
</div>
|
||||
<MailPurgeDialog
|
||||
:open="purgeOpen"
|
||||
:count="mailStore.checkedCount"
|
||||
id-prefix="bulk-purge"
|
||||
@confirm="onPurgeConfirm"
|
||||
@cancel="purgeOpen = false"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.bulk-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-height: var(--size-header-min);
|
||||
padding: var(--space-4) var(--space-8) var(--space-4) var(--space-20);
|
||||
border-bottom: var(--border-width) solid var(--color-border);
|
||||
background: var(--color-brand-tint);
|
||||
}
|
||||
|
||||
/* 분할 보기의 목록 칸(384) — 촘촘하게 (디자인 원본 ListStates) */
|
||||
.bulk-bar--split {
|
||||
padding: var(--space-4) var(--space-4) var(--space-4) var(--space-12);
|
||||
}
|
||||
|
||||
.bulk-bar__count {
|
||||
flex: 0 0 auto;
|
||||
margin: 0 var(--space-10) 0 0;
|
||||
font-size: var(--font-size-body);
|
||||
font-weight: 700;
|
||||
white-space: nowrap;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.bulk-bar--split .bulk-bar__count {
|
||||
margin-right: var(--space-4);
|
||||
}
|
||||
|
||||
.bulk-bar__tools {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.bulk-bar--split .bulk-bar__tools {
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.bulk-bar__divider {
|
||||
width: var(--space-1);
|
||||
height: var(--space-20);
|
||||
margin: 0 var(--space-6);
|
||||
background: var(--color-border-strong);
|
||||
}
|
||||
|
||||
.bulk-bar__clear {
|
||||
margin-left: auto;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,86 @@
|
||||
<script setup lang="ts">
|
||||
// 읽기 화면의 「삭제」 · 「영구 삭제」 — 열린 메일 한 통 (디자인 README "3단계 › 여러 통 처리" · useMessageRemoval)
|
||||
// - 「영구 삭제」인지는 그 메일의 메일함으로 정한다 — 휴지통 목록에서는 휴지통에 든 메일, 그 밖에서는 휴지통에만 든 메일 (useMessageRemoval)
|
||||
// · 「삭제」: 휴지통으로 옮긴다 — "메일 1통을 휴지통으로 옮겼습니다." + [되돌리기]
|
||||
// · 「영구 삭제」: 확인 대화상자(처음 초점은 「취소」)를 거쳐 영구 삭제 — "메일 1통을 영구 삭제했습니다."
|
||||
// - 지운 뒤: 분할 보기는 다음 메일(없으면 이전 메일, 그것도 없으면 목록), 목록 보기는 목록으로 (기록을 쌓지 않는다).
|
||||
// 그사이 다른 목록으로 옮겨 갔거나 서버가 그대로 두었으면 움직이지 않는다
|
||||
// - DELETE /api/messages/{id} 는 쓰지 않는다 (그사이 다른 창이 휴지통으로 옮긴 메일을 확인 없이 영구 삭제하게 된다)
|
||||
import { computed, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
import MailPurgeDialog from '@/components/mail/MailPurgeDialog.vue'
|
||||
import MailToolButton from '@/components/mail/MailToolButton.vue'
|
||||
import { useMailNavigation } from '@/composables/useMailNavigation'
|
||||
import { useMessageRemoval } from '@/composables/useMessageRemoval'
|
||||
import { MAIL_BULK_TEXT } from '@/shared/constants/mail'
|
||||
import type { MessageSummary } from '@/shared/types/mail'
|
||||
import { listLocationOf, messageLocationOf } from '@/shared/utils/mail-list-source'
|
||||
import { useMailStore } from '@/stores/mail.store'
|
||||
|
||||
interface Props {
|
||||
message: MessageSummary
|
||||
/** split: 지우면 다음 메일을 연다 · list: 지우면 목록으로 */
|
||||
layout: 'split' | 'list'
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
|
||||
const router = useRouter()
|
||||
const mailStore = useMailStore()
|
||||
const removal = useMessageRemoval()
|
||||
const { backToSourceList } = useMailNavigation()
|
||||
|
||||
const working = ref<boolean>(false)
|
||||
const confirmOpen = ref<boolean>(false)
|
||||
|
||||
/** 「영구 삭제」할 메일인가 — 휴지통 목록 밖에서는 휴지통에만 든 메일만 (useMessageRemoval) */
|
||||
const permanent = computed(() => removal.deletesPermanently(props.message))
|
||||
|
||||
async function run(kind: 'trash' | 'purge') {
|
||||
const target = props.message
|
||||
const source = mailStore.listSource
|
||||
working.value = true
|
||||
try {
|
||||
const outcome = kind === 'trash' ? await removal.moveToTrash(target) : await removal.purge(target)
|
||||
if (!outcome.removed || !source) return
|
||||
if (props.layout === 'list') {
|
||||
// 목록 보기에서는 지우면 목록으로 돌아간다 (디자인 원본 MainListRead)
|
||||
await backToSourceList(source)
|
||||
return
|
||||
}
|
||||
await router.replace(outcome.nextId ? messageLocationOf(source, outcome.nextId) : listLocationOf(source))
|
||||
} finally {
|
||||
working.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function onClick() {
|
||||
if (working.value) return
|
||||
if (permanent.value) confirmOpen.value = true
|
||||
else void run('trash')
|
||||
}
|
||||
|
||||
function onConfirm() {
|
||||
confirmOpen.value = false
|
||||
void run('purge')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<MailToolButton
|
||||
icon="trash"
|
||||
:label="permanent ? MAIL_BULK_TEXT.purge : MAIL_BULK_TEXT.delete"
|
||||
tone="danger"
|
||||
:disabled="working"
|
||||
data-reader-delete
|
||||
@click="onClick"
|
||||
/>
|
||||
<MailPurgeDialog
|
||||
:open="confirmOpen"
|
||||
:count="1"
|
||||
id-prefix="reader-purge"
|
||||
@confirm="onConfirm"
|
||||
@cancel="confirmOpen = false"
|
||||
/>
|
||||
</template>
|
||||
@@ -2,22 +2,25 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { createPinia, setActivePinia, type Pinia } from 'pinia'
|
||||
|
||||
import { SEARCH_SCOPE_ALL } from '@/shared/constants/mail'
|
||||
import type { Mailbox, MessageSummary } from '@/shared/types/mail'
|
||||
import { useAuthStore } from '@/stores/auth.store'
|
||||
import { useMailStore } from '@/stores/mail.store'
|
||||
import { restoreApiAdapter } from '@/test-utils/mock-api'
|
||||
import { flushPromises, restoreApiAdapter } from '@/test-utils/mock-api'
|
||||
import { createTestRouter } from '@/test-utils/router'
|
||||
import MessageList from './MessageList.vue'
|
||||
|
||||
const DRAFTS: Mailbox = {
|
||||
id: 'drafts',
|
||||
name: '임시보관함',
|
||||
role: 'drafts',
|
||||
const INBOX: Mailbox = {
|
||||
id: 'inbox',
|
||||
name: '받은편지함',
|
||||
role: 'inbox',
|
||||
parentId: null,
|
||||
sortOrder: 3,
|
||||
totalCount: 2,
|
||||
sortOrder: 0,
|
||||
totalCount: 3,
|
||||
unreadCount: 0,
|
||||
}
|
||||
const DRAFTS: Mailbox = { ...INBOX, id: 'drafts', name: '임시보관함', role: 'drafts', sortOrder: 3, totalCount: 2 }
|
||||
const SENT: Mailbox = { ...INBOX, id: 'sent', name: '보낸편지함', role: 'sent' }
|
||||
|
||||
function summary(id: string, partial: Partial<MessageSummary> = {}): MessageSummary {
|
||||
return {
|
||||
@@ -50,21 +53,22 @@ describe('MessageList (분할 보기 목록)', () => {
|
||||
restoreApiAdapter()
|
||||
})
|
||||
|
||||
async function mountList(mailbox: Mailbox, messages: MessageSummary[]) {
|
||||
async function mountList(mailbox: Mailbox, messages: MessageSummary[], path = `/mail/${mailbox.id}`) {
|
||||
const store = useMailStore()
|
||||
store.mailboxes = [mailbox]
|
||||
store.mailboxes = [INBOX, SENT, DRAFTS]
|
||||
store.currentMailboxId = mailbox.id
|
||||
store.messages = messages
|
||||
store.pageInfo = { hasNext: false, nextCursor: null, total: messages.length }
|
||||
store.listStatus = 'ready'
|
||||
const router = createTestRouter()
|
||||
await router.push(`/mail/${mailbox.id}`)
|
||||
await router.push(path)
|
||||
await router.isReady()
|
||||
return mount(MessageList, { props: { mailboxId: mailbox.id }, global: { plugins: [pinia, router] } })
|
||||
const wrapper = mount(MessageList, { global: { plugins: [pinia, router] } })
|
||||
return { wrapper, router, store }
|
||||
}
|
||||
|
||||
it('임시보관함 — 보낸 사람 자리에 받는 사람, 비었으면 흐린 "(받는 사람 없음)" · "(제목 없음)" — 누르면 쓰기 화면에서 이어 쓴다', async () => {
|
||||
const wrapper = await mountList(DRAFTS, [
|
||||
const { wrapper } = await mountList(DRAFTS, [
|
||||
summary('d1', { isRead: false, to: [{ name: '박준호', email: 'junho.park@arasystem.test' }, { name: '인프라팀', email: 'infra@arasystem.test' }] }),
|
||||
summary('d2', { to: [], subject: '' }),
|
||||
])
|
||||
@@ -80,11 +84,76 @@ describe('MessageList (분할 보기 목록)', () => {
|
||||
})
|
||||
|
||||
it('보낸편지함은 그대로 "받는 사람: …" 이고 읽기 화면으로 간다', async () => {
|
||||
const sent: Mailbox = { ...DRAFTS, id: 'sent', name: '보낸편지함', role: 'sent' }
|
||||
const wrapper = await mountList(sent, [summary('s1', { mailboxIds: ['sent'] })])
|
||||
const { wrapper } = await mountList(SENT, [summary('s1', { mailboxIds: ['sent'] })])
|
||||
const row = wrapper.get('.message-row')
|
||||
|
||||
expect(row.get('.message-row__who').text()).toBe('받는 사람: 이도윤')
|
||||
expect(row.get('.message-row__main').attributes('href')).toBe('/mail/sent/s1')
|
||||
})
|
||||
|
||||
describe('여러 통 고르기 (디자인 원본 ListStates "분할 보기에서 여러 통 고르기")', () => {
|
||||
const inboxRows = () => [
|
||||
summary('m1', { mailboxIds: ['inbox'] }),
|
||||
summary('m2', { mailboxIds: ['inbox'] }),
|
||||
summary('m3', { mailboxIds: ['inbox'] }),
|
||||
]
|
||||
|
||||
it('행마다 고르기 상자("고르기: 제목")가 있고, 하나라도 고르면 모든 행이 고르기 칸을 보인다', async () => {
|
||||
const { wrapper, store } = await mountList(INBOX, inboxRows())
|
||||
|
||||
expect(wrapper.findAll('.message-row.is-checking')).toHaveLength(0)
|
||||
const box = wrapper.findAll('.message-row [role="checkbox"]')[1]
|
||||
expect(box?.attributes('aria-label')).toBe('고르기: 제목 m2')
|
||||
|
||||
await box?.trigger('click')
|
||||
|
||||
expect(store.checkedIds.has('m2')).toBe(true)
|
||||
expect(wrapper.findAll('.message-row.is-checking')).toHaveLength(3)
|
||||
expect(wrapper.findAll('.message-row')[1]?.classes()).toContain('is-checked')
|
||||
// 머리 자리가 도구 줄로 — 분할 보기의 목록 칸에서는 아이콘만(이름은 aria-label · 툴팁)
|
||||
expect(wrapper.get('#message-list-title').text()).toBe('1통 선택됨')
|
||||
expect(wrapper.get('.bulk-bar').classes()).toContain('bulk-bar--split')
|
||||
expect(wrapper.get('[data-tool="markRead"]').attributes('aria-label')).toBe('읽음 표시')
|
||||
})
|
||||
|
||||
it('행을 누르면 지금처럼 메일을 열고, Ctrl(⌘)+클릭 · Shift+클릭은 열지 않고 고른다', async () => {
|
||||
const { wrapper, router, store } = await mountList(INBOX, inboxRows())
|
||||
const main = (index: number) => wrapper.findAll('.message-row__main')[index]
|
||||
|
||||
await main(0)?.trigger('click', { metaKey: true })
|
||||
await main(2)?.trigger('click', { shiftKey: true })
|
||||
await flushPromises()
|
||||
|
||||
expect(router.currentRoute.value.fullPath).toBe('/mail/inbox')
|
||||
expect([...store.checkedIds].sort()).toEqual(['m1', 'm2', 'm3'])
|
||||
|
||||
await main(1)?.trigger('click')
|
||||
await flushPromises()
|
||||
expect(router.currentRoute.value.fullPath).toBe('/mail/inbox/m2')
|
||||
expect(store.checkedCount).toBe(3)
|
||||
})
|
||||
})
|
||||
|
||||
it('전체 메일함 검색 결과 — 시각 앞에 "보낸편지함 ·", 보낸 메일은 "받는 사람: 이름", 누르면 검색 결과 안의 주소', async () => {
|
||||
const q = encodeURIComponent('회의')
|
||||
const { wrapper, store } = await mountList(
|
||||
INBOX,
|
||||
[
|
||||
summary('r1', { mailboxIds: ['inbox'], from: { name: '이도윤', email: 'doyun.lee@arasystem.test' } }),
|
||||
summary('r2', { mailboxIds: ['sent'], to: [{ name: '정하늘', email: 'haneul@arasystem.test' }] }),
|
||||
],
|
||||
`/mail/search?q=${q}&in=all`,
|
||||
)
|
||||
store.listSource = { kind: 'search', q: '회의', scope: SEARCH_SCOPE_ALL }
|
||||
await flushPromises()
|
||||
const rows = wrapper.findAll('.message-row')
|
||||
|
||||
expect(rows.map((row) => row.get('.message-row__who').text())).toEqual(['이도윤', '받는 사람: 정하늘'])
|
||||
expect(rows.map((row) => row.get('.message-row__mailbox').text())).toEqual(['받은편지함 ·', '보낸편지함 ·'])
|
||||
expect(rows[0]?.get('.message-row__main').attributes('href')).toBe(`/mail/search/r1?q=${q}&in=all`)
|
||||
// 머리 — 검색 결과 · ‘회의’ · 2통 · 범위 줄(전체일 때 안내)
|
||||
expect(wrapper.get('#message-list-title').text()).toBe('검색 결과')
|
||||
expect(wrapper.get('.search-header__count').text()).toBe('‘회의’ · 2통')
|
||||
expect(wrapper.get('.search-scope').text()).toContain('휴지통 · 스팸메일함은 빼고 찾습니다')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,111 +1,50 @@
|
||||
<script setup lang="ts">
|
||||
// 분할 보기의 목록 영역 — 머리(메일함 이름·개수·보기 전환·새로고침) + 2줄 행 + 메일 더 보기 (디자인 원본 Main 가운데)
|
||||
// 상태: 불러오는 중 · 메일 서버 장애(다시 시도) · 없는 메일함(받은편지함으로) · 빈 메일함 (디자인 원본 States)
|
||||
import { computed } from 'vue'
|
||||
|
||||
import StateMessage from '@/components/feedback/StateMessage.vue'
|
||||
import MailListHeader from '@/components/mail/MailListHeader.vue'
|
||||
import MailLoadError from '@/components/mail/MailLoadError.vue'
|
||||
// 분할 보기의 목록 칸 — 2줄 행 (디자인 원본 Main 가운데 · Search 왼쪽 · ListStates "분할 보기에서 여러 통 고르기")
|
||||
// 머리 · 상태(불러오는 중 · 못 불러옴 · 빈 메일함 · 검색 결과 없음) · 메일 더 보기는 목록 틀(MailListFrame)이 맡는다.
|
||||
// 목록은 메일함 또는 검색 결과다 — 행 규칙은 useMailListRows.
|
||||
import MailListFrame from '@/components/mail/MailListFrame.vue'
|
||||
import MessageListItem from '@/components/mail/MessageListItem.vue'
|
||||
import MessageListSkeleton from '@/components/mail/MessageListSkeleton.vue'
|
||||
import { useMailNavigation } from '@/composables/useMailNavigation'
|
||||
import { OUTGOING_MAILBOX_ROLES } from '@/shared/constants/mail'
|
||||
import { emptyMailboxText, mailboxIcon } from '@/shared/utils/mailbox'
|
||||
import { useMailListRows } from '@/composables/useMailListRows'
|
||||
import { useMailStore } from '@/stores/mail.store'
|
||||
|
||||
interface Props {
|
||||
mailboxId: string
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
|
||||
const mailStore = useMailStore()
|
||||
const { replaceWithMailbox } = useMailNavigation()
|
||||
const rows = useMailListRows()
|
||||
|
||||
const mailbox = computed(() => mailStore.currentMailbox)
|
||||
const showRecipients = computed(() => {
|
||||
const role = mailbox.value?.role
|
||||
return role ? OUTGOING_MAILBOX_ROLES.includes(role) : false
|
||||
})
|
||||
/** 임시보관함 — 행을 누르면 쓰기 화면에서 이어 쓴다 */
|
||||
const isDrafts = computed(() => mailbox.value?.role === 'drafts')
|
||||
const empty = computed(() =>
|
||||
emptyMailboxText({ name: mailbox.value?.name ?? '메일함', role: mailbox.value?.role ?? null }),
|
||||
)
|
||||
const emptyIcon = computed(() => mailboxIcon({ role: mailbox.value?.role ?? null }))
|
||||
const busy = computed(() => mailStore.listStatus === 'loading' || mailStore.isRefreshing)
|
||||
|
||||
function retry() {
|
||||
void mailStore.openMailbox(props.mailboxId)
|
||||
}
|
||||
|
||||
/** 없는 메일함(RES_001) — 받은편지함으로 바꿔 끼운다 */
|
||||
function goToInbox() {
|
||||
void replaceWithMailbox(mailStore.defaultMailbox?.id ?? null)
|
||||
/** Shift 면 범위, 아니면 하나 */
|
||||
function onCheck(id: string, range: boolean) {
|
||||
if (range) mailStore.checkRangeTo(id)
|
||||
else mailStore.toggleCheck(id)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section
|
||||
<MailListFrame
|
||||
class="message-list"
|
||||
aria-labelledby="message-list-title"
|
||||
:aria-busy="busy"
|
||||
variant="split"
|
||||
title-id="message-list-title"
|
||||
>
|
||||
<MailListHeader title-id="message-list-title" />
|
||||
|
||||
<MessageListSkeleton v-if="mailStore.listStatus === 'loading'" />
|
||||
|
||||
<MailLoadError
|
||||
v-else-if="mailStore.listStatus === 'error' && mailStore.listError"
|
||||
:error="mailStore.listError"
|
||||
leave-label="받은편지함으로"
|
||||
@retry="retry"
|
||||
@leave="goToInbox"
|
||||
/>
|
||||
|
||||
<template v-else-if="mailStore.messages.length > 0">
|
||||
<ul class="message-list__items">
|
||||
<MessageListItem
|
||||
v-for="message in mailStore.messages"
|
||||
:key="message.id"
|
||||
:message="message"
|
||||
:mailbox-id="mailboxId"
|
||||
:selected="message.id === mailStore.selectedId"
|
||||
:show-recipients="showRecipients"
|
||||
:draft="isDrafts"
|
||||
@toggle-flag="mailStore.toggleFlag"
|
||||
/>
|
||||
</ul>
|
||||
<div
|
||||
v-if="mailStore.hasMore"
|
||||
class="message-list__more"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="message-list__text-button message-list__more-button"
|
||||
:disabled="mailStore.isLoadingMore"
|
||||
@click="mailStore.loadMore()"
|
||||
>
|
||||
{{ mailStore.isLoadingMore ? '불러오는 중…' : '메일 더 보기' }}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<StateMessage
|
||||
v-else-if="mailStore.listStatus === 'ready'"
|
||||
:icon="emptyIcon"
|
||||
:title="empty.title"
|
||||
:hint="empty.hint"
|
||||
/>
|
||||
</section>
|
||||
<ul class="message-list__items">
|
||||
<MessageListItem
|
||||
v-for="message in mailStore.messages"
|
||||
:key="message.id"
|
||||
:message="message"
|
||||
:to="rows.locationOf(message)"
|
||||
:role="rows.roleOf(message)"
|
||||
:mixed="rows.mixed.value"
|
||||
:mailbox-name="rows.mailboxNameOf(message)"
|
||||
:selected="message.id === mailStore.selectedId"
|
||||
:checked="mailStore.checkedIds.has(message.id)"
|
||||
:checking="mailStore.checkedCount > 0"
|
||||
@toggle-flag="mailStore.toggleFlag"
|
||||
@check="onCheck"
|
||||
/>
|
||||
</ul>
|
||||
</MailListFrame>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.message-list {
|
||||
flex: 1 1 24rem;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border-right: var(--border-width) solid var(--color-border);
|
||||
}
|
||||
|
||||
@@ -114,38 +53,4 @@ function goToInbox() {
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.message-list__more {
|
||||
padding: var(--space-8) var(--space-12) var(--space-16);
|
||||
}
|
||||
|
||||
.message-list__text-button {
|
||||
min-height: var(--size-touch);
|
||||
padding: 0 var(--space-14);
|
||||
border: 0;
|
||||
border-radius: var(--radius-md);
|
||||
background: transparent;
|
||||
color: var(--color-brand);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.message-list__text-button:hover:not(:disabled) {
|
||||
background: var(--color-brand-subtle);
|
||||
}
|
||||
|
||||
.message-list__more-button {
|
||||
width: 100%;
|
||||
min-height: var(--size-row-min);
|
||||
}
|
||||
|
||||
.message-list__more-button:disabled {
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
/* 넓은 화면에서는 목록 칸만 따로 스크롤한다 (tokens.css 화면 폭 기준) */
|
||||
@media (min-width: 70rem) {
|
||||
.message-list {
|
||||
overflow-y: auto;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,73 +1,105 @@
|
||||
<script setup lang="ts">
|
||||
// 분할 보기 목록의 2줄 행 (디자인 원본 Main) — 안 읽음은 뱃지 없이 굵은 글자로만 구분한다
|
||||
// 임시보관함(draft)의 행은 보낸 사람 자리에 받는 사람(없으면 "(받는 사람 없음)")을 보이고, 누르면 쓰기 화면에서 이어 쓴다
|
||||
// (디자인 원본 ComposeAssist "임시보관함 목록" — 빈 값은 흐린 글자, 안 읽음 굵기는 쓰지 않는다)
|
||||
// 보낸 사람 자리 · 읽음 표시 · 누르면 갈 곳은 메일함 규칙을 따른다 (mail-list-row.ts — 임시보관함은 받는 사람 · 쓰기 화면에서 이어 쓰기)
|
||||
// 전체 메일함 검색 결과면 첫 줄 시각 앞에 "보낸편지함 ·" (디자인 원본 Search)
|
||||
// 여러 통 고르기 (디자인 원본 ListStates "분할 보기에서 여러 통 고르기"):
|
||||
// - 마우스를 올리거나 초점이 가면 왼쪽 36px 에 고르기 상자가 나타나고, 하나라도 고르면 모든 행에 보인다 (숨긴 동안에도 Tab 으로 닿는다)
|
||||
// - 행을 누르면 지금처럼 메일을 연다. Ctrl(⌘)+클릭은 하나씩, Shift+클릭은 범위로 고른다 (새 탭 · 새 창으로 열지 않는다)
|
||||
import { computed } from 'vue'
|
||||
import type { RouteLocationRaw } from 'vue-router'
|
||||
|
||||
import AppIcon from '@/components/AppIcon.vue'
|
||||
import { DRAFT_NO_RECIPIENTS, MAIL_NO_SUBJECT } from '@/shared/constants/mail'
|
||||
import type { MessageSummary } from '@/shared/types/mail'
|
||||
import MailCheckbox from '@/components/mail/MailCheckbox.vue'
|
||||
import { MAIL_BULK_TEXT, MAIL_NO_SUBJECT } from '@/shared/constants/mail'
|
||||
import type { MailboxRole, MessageSummary } from '@/shared/types/mail'
|
||||
import { formatMailListTime } from '@/shared/utils/format'
|
||||
import { displayName, draftRecipientNames, summarizeRecipients } from '@/shared/utils/mail-address'
|
||||
import { isDraftRole, isRowUnread, rowWho } from '@/shared/utils/mail-list-row'
|
||||
|
||||
interface Props {
|
||||
message: MessageSummary
|
||||
mailboxId: string
|
||||
/** 누르면 갈 곳 — 임시 저장본은 쓰기 화면, 그 밖은 읽기 화면 */
|
||||
to: RouteLocationRaw
|
||||
/** 이 행의 메일함 역할 — 보낸 사람 자리 · 읽음 표시 규칙 */
|
||||
role: MailboxRole | null
|
||||
/** 전체 메일함 검색 결과 — 보낸편지함 · 임시보관함의 메일은 "받는 사람: 이름" */
|
||||
mixed?: boolean
|
||||
/** 시각 앞에 보일 메일함 이름 (전체 메일함 검색 결과) */
|
||||
mailboxName?: string | null
|
||||
/** 지금 열려 있는 메일 */
|
||||
selected?: boolean
|
||||
/** 보낸편지함처럼 받는 사람을 보여 줄 메일함인지 */
|
||||
showRecipients?: boolean
|
||||
/** 임시보관함의 행 — 누르면 쓰기 화면에서 이어 쓴다 */
|
||||
draft?: boolean
|
||||
/** 고른 메일 (여러 통 처리) */
|
||||
checked?: boolean
|
||||
/** 하나라도 골랐다 — 모든 행에 고르기 상자를 보인다 */
|
||||
checking?: boolean
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'toggle-flag', id: string): void
|
||||
/** 고르기 · 풀기 — range 면 Shift(범위) */
|
||||
(e: 'check', id: string, range: boolean): void
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
mixed: false,
|
||||
mailboxName: null,
|
||||
selected: false,
|
||||
showRecipients: false,
|
||||
draft: false,
|
||||
checked: false,
|
||||
checking: false,
|
||||
})
|
||||
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
/** 임시 저장본의 받는 사람 — 없으면 빈 문자열 */
|
||||
const draftRecipients = computed(() => (props.draft ? draftRecipientNames(props.message.to) : ''))
|
||||
const who = computed(() => {
|
||||
if (props.draft) return draftRecipients.value || DRAFT_NO_RECIPIENTS
|
||||
if (props.showRecipients) {
|
||||
const recipients = summarizeRecipients(props.message.to)
|
||||
return recipients ? `받는 사람: ${recipients}` : '받는 사람 없음'
|
||||
}
|
||||
return displayName(props.message.from) || '(보낸 사람 없음)'
|
||||
})
|
||||
const who = computed(() => rowWho(props.message, { role: props.role, mixed: props.mixed, view: 'split' }))
|
||||
const subject = computed(() => props.message.subject || MAIL_NO_SUBJECT)
|
||||
const time = computed(() => formatMailListTime(props.message.receivedAt))
|
||||
/** 임시 저장본은 읽음 여부를 보이지 않는다 */
|
||||
const unread = computed(() => !props.draft && !props.message.isRead)
|
||||
const location = computed<RouteLocationRaw>(() =>
|
||||
props.draft
|
||||
? { name: 'mail-compose', query: { draft: props.message.id } }
|
||||
: { name: 'mail-message', params: { mailboxId: props.mailboxId, messageId: props.message.id } },
|
||||
)
|
||||
const unread = computed(() => isRowUnread(props.message, props.role))
|
||||
const draft = computed(() => isDraftRole(props.role))
|
||||
|
||||
/** Ctrl(⌘) · Shift + 클릭은 메일을 열지 않고 고른다 (RouterLink 는 수정 글쇠가 있으면 이동하지 않는다 — 브라우저의 새 탭도 막는다) */
|
||||
function onMainClick(event: MouseEvent) {
|
||||
if (!event.ctrlKey && !event.metaKey && !event.shiftKey) return
|
||||
event.preventDefault()
|
||||
emit('check', props.message.id, event.shiftKey)
|
||||
}
|
||||
|
||||
/** Shift + 누름이 글자를 고르지 않게 한다 */
|
||||
function onMouseDown(event: MouseEvent) {
|
||||
if (event.shiftKey) event.preventDefault()
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<li :class="['message-row', { 'is-selected': selected, 'is-unread': unread }]">
|
||||
<li
|
||||
:class="[
|
||||
'message-row',
|
||||
{ 'is-selected': selected, 'is-unread': unread, 'is-checked': checked, 'is-checking': checking },
|
||||
]"
|
||||
:data-row-id="message.id"
|
||||
@mousedown="onMouseDown"
|
||||
>
|
||||
<MailCheckbox
|
||||
class="message-row__check"
|
||||
:checked="checked"
|
||||
:label="MAIL_BULK_TEXT.selectOne(subject)"
|
||||
@toggle="(event: MouseEvent) => emit('check', message.id, event.shiftKey)"
|
||||
/>
|
||||
<RouterLink
|
||||
class="message-row__main"
|
||||
:to="location"
|
||||
:to="to"
|
||||
:data-message-id="message.id"
|
||||
@click="onMainClick"
|
||||
>
|
||||
<span class="message-row__top">
|
||||
<span :class="['message-row__who', { 'is-placeholder': draft && draftRecipients === '' }]">
|
||||
<span :class="['message-row__who', { 'is-placeholder': who.placeholder }]">
|
||||
<span
|
||||
v-if="unread"
|
||||
class="visually-hidden"
|
||||
>안 읽음, </span>{{ who }}
|
||||
>안 읽음, </span>{{ who.text }}
|
||||
</span>
|
||||
<span
|
||||
v-if="mixed && mailboxName"
|
||||
class="message-row__mailbox"
|
||||
>{{ mailboxName }} ·</span>
|
||||
<time
|
||||
class="message-row__time"
|
||||
:datetime="message.receivedAt"
|
||||
@@ -104,6 +136,7 @@ const location = computed<RouteLocationRaw>(() =>
|
||||
|
||||
<style scoped>
|
||||
.message-row {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
border-bottom: var(--border-width) solid var(--color-border-subtle);
|
||||
@@ -118,6 +151,45 @@ const location = computed<RouteLocationRaw>(() =>
|
||||
background: var(--color-brand-subtle);
|
||||
}
|
||||
|
||||
/* 고른 행 (디자인 원본 ListStates · BulkSelect) */
|
||||
.message-row.is-checked {
|
||||
background: var(--color-row-checked);
|
||||
}
|
||||
|
||||
.message-row.is-checked:hover {
|
||||
background: var(--color-row-checked-hover);
|
||||
}
|
||||
|
||||
/*
|
||||
* 고르기 칸 — 고르지 않았고 마우스 · 초점도 없으면 칸 없이 지금 행 그대로다 (디자인 원본 ListStates).
|
||||
* 숨긴 동안에도 화면 밖에 두어 Tab 으로 닿게 한다 — 초점이 오면 행에 초점이 있으므로 나타난다
|
||||
*/
|
||||
.message-row__check {
|
||||
position: absolute;
|
||||
width: var(--space-1);
|
||||
height: var(--space-1);
|
||||
overflow: hidden;
|
||||
clip-path: inset(50%);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* 마우스를 올리거나 초점이 가면 · 하나라도 고르면 — 왼쪽 36px(밀도 예외)에 16px 상자, 상자는 칸 오른쪽에 붙는다 */
|
||||
.message-row:hover .message-row__check,
|
||||
.message-row:focus-within .message-row__check,
|
||||
.message-row.is-checking .message-row__check {
|
||||
position: static;
|
||||
flex: 0 0 auto;
|
||||
justify-content: flex-end;
|
||||
width: var(--size-row-check);
|
||||
height: auto;
|
||||
overflow: visible;
|
||||
clip-path: none;
|
||||
}
|
||||
|
||||
.message-row__check:focus-visible {
|
||||
outline-offset: calc(var(--focus-ring-width) * -1);
|
||||
}
|
||||
|
||||
/* 2줄 행 약 50px — 보낸 사람·시간 / 제목 + 첨부 클립 (디자인 원본 Main) */
|
||||
.message-row__main {
|
||||
flex: 1 1 auto;
|
||||
@@ -133,6 +205,13 @@ const location = computed<RouteLocationRaw>(() =>
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
/* 고르기 칸이 보이면 칸 뒤 10px 에서 글이 시작한다 */
|
||||
.message-row:hover .message-row__main,
|
||||
.message-row:focus-within .message-row__main,
|
||||
.message-row.is-checking .message-row__main {
|
||||
padding-left: var(--space-10);
|
||||
}
|
||||
|
||||
.message-row__main:hover {
|
||||
color: inherit;
|
||||
}
|
||||
@@ -161,6 +240,13 @@ const location = computed<RouteLocationRaw>(() =>
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
/* 전체 메일함 검색 결과 — 시각 앞의 메일함 이름 (디자인 원본 Search) */
|
||||
.message-row__mailbox {
|
||||
flex: 0 0 auto;
|
||||
font-size: var(--font-size-small);
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.message-row__time {
|
||||
flex: 0 0 auto;
|
||||
font-size: var(--font-size-small);
|
||||
|
||||
@@ -1,18 +1,21 @@
|
||||
<script setup lang="ts">
|
||||
// 목록 불러오는 중 — 행 자리를 미리 잡아 화면이 튀지 않게 한다 (디자인 원본 States "불러오는 중")
|
||||
// 목록 불러오는 중 — 행 자리를 미리 잡아 화면이 튀지 않게 한다 (디자인 원본 States "불러오는 중" · Search "찾는 중")
|
||||
interface Props {
|
||||
rows?: number
|
||||
/** 스크린리더에 읽힐 이름 — 검색 중이면 "찾는 중" */
|
||||
label?: string
|
||||
}
|
||||
|
||||
withDefaults(defineProps<Props>(), {
|
||||
rows: 7,
|
||||
label: '메일 목록 불러오는 중',
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ul
|
||||
class="list-skeleton"
|
||||
aria-label="메일 목록 불러오는 중"
|
||||
:aria-label="label"
|
||||
aria-busy="true"
|
||||
>
|
||||
<li
|
||||
|
||||
@@ -5,10 +5,19 @@ import { h } from 'vue'
|
||||
|
||||
import { ERROR_CODE_MESSAGES } from '@/shared/constants/error-codes.generated'
|
||||
import { MAIL_BODY_NOTICES } from '@/shared/constants/mail'
|
||||
import type { MessageDetail } from '@/shared/types/mail'
|
||||
import type { Mailbox, MessageDetail, MessagesPatch } from '@/shared/types/mail'
|
||||
import type { MailListSource } from '@/shared/utils/mail-list-source'
|
||||
import { useAuthStore } from '@/stores/auth.store'
|
||||
import { useMailStore } from '@/stores/mail.store'
|
||||
import { useToastStore } from '@/stores/toast.store'
|
||||
import { flushPromises, installMockApi, ok, restoreApiAdapter } from '@/test-utils/mock-api'
|
||||
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'
|
||||
|
||||
@@ -270,3 +279,181 @@ describe('MessageReader 불러오기 실패', () => {
|
||||
expect(router.currentRoute.value.fullPath).toBe('/mail/inbox/missing')
|
||||
})
|
||||
})
|
||||
|
||||
describe('MessageReader 삭제 · 영구 삭제 — DELETE /messages/{id} 를 쓰지 않는다', () => {
|
||||
let pinia: Pinia
|
||||
let requests: MockRequest[]
|
||||
|
||||
const INBOX: Mailbox = { id: 'inbox', name: '받은편지함', role: 'inbox', parentId: null, sortOrder: 0, totalCount: 2, unreadCount: 0 }
|
||||
const TRASH: Mailbox = { ...INBOX, id: 'trash', name: '휴지통', role: 'trash' }
|
||||
|
||||
beforeEach(() => {
|
||||
pinia = createPinia()
|
||||
setActivePinia(pinia)
|
||||
useAuthStore().startSession({ email: 'ara.kim@arasystem.it.kr', name: '김아라' })
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
useToastStore().clear()
|
||||
restoreApiAdapter()
|
||||
document.body.innerHTML = ''
|
||||
})
|
||||
|
||||
async function mountWith(
|
||||
options: {
|
||||
mailboxIds?: string[]
|
||||
layout?: 'split' | 'list'
|
||||
handler?: (request: MockRequest) => MockResult | undefined
|
||||
source?: MailListSource
|
||||
} = {},
|
||||
) {
|
||||
requests = installMockApi((request) => {
|
||||
const custom = options.handler?.(request)
|
||||
if (custom) return custom
|
||||
if (request.url === '/mailboxes') return ok({ items: [INBOX, TRASH], pageInfo: { hasNext: false, nextCursor: null, total: 2 } })
|
||||
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 === '/message-deletions') {
|
||||
return ok({ deletedIds: (request.data as { ids: string[] }).ids, skippedIds: [] })
|
||||
}
|
||||
return ok({ usedBytes: 0, limitBytes: null, attachmentMaxBytes: 1, attachmentTotalMaxBytes: 1 })
|
||||
})
|
||||
const store = useMailStore()
|
||||
store.mailboxes = [INBOX, TRASH]
|
||||
store.listSource = options.source ?? { kind: 'mailbox', mailboxId: 'inbox' }
|
||||
const open = { ...DETAIL, mailboxIds: options.mailboxIds ?? ['inbox'] }
|
||||
store.messages = [open, { ...DETAIL, id: 'm2', subject: '다음 메일' }]
|
||||
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/inbox')
|
||||
await router.push('/mail/inbox/m1')
|
||||
await router.isReady()
|
||||
const wrapper = mount(MessageReader, {
|
||||
props: { layout: options.layout ?? 'split' },
|
||||
global: { plugins: [pinia, router] },
|
||||
attachTo: document.body,
|
||||
})
|
||||
return { wrapper, router, store }
|
||||
}
|
||||
|
||||
const deleteButton = (wrapper: Awaited<ReturnType<typeof mountWith>>['wrapper']) => wrapper.get('[data-reader-delete]')
|
||||
const usedDelete = () => requests.some((request) => request.method === 'DELETE')
|
||||
|
||||
it('휴지통 밖 — 휴지통으로 옮기고(PATCH) 다음 메일을 연다, "메일 1통을 휴지통으로 옮겼습니다." + [되돌리기]', async () => {
|
||||
const { wrapper, router, store } = await mountWith()
|
||||
expect(deleteButton(wrapper).text()).toBe('삭제')
|
||||
|
||||
await deleteButton(wrapper).trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(requests.find((request) => request.method === 'PATCH')).toMatchObject({
|
||||
url: '/messages',
|
||||
data: { ids: ['m1'], mailboxId: 'trash' },
|
||||
})
|
||||
expect(usedDelete()).toBe(false)
|
||||
expect(store.messages.map((message) => message.id)).toEqual(['m2'])
|
||||
expect(router.currentRoute.value.fullPath).toBe('/mail/inbox/m2')
|
||||
const [toast] = useToastStore().toasts
|
||||
expect(toast?.message).toBe('메일 1통을 휴지통으로 옮겼습니다.')
|
||||
|
||||
// 되돌리기 — 원래 메일함으로 (여러 통 처리와 같은 규칙)
|
||||
if (toast?.action && 'run' in toast.action) toast.action.run()
|
||||
await flushPromises()
|
||||
expect(requests.filter((request) => request.method === 'PATCH').at(-1)?.data).toEqual({ ids: ['m1'], mailboxId: 'inbox' })
|
||||
expect(useToastStore().toasts.map((item) => item.message)).toContain('메일 1통을 되돌렸습니다.')
|
||||
})
|
||||
|
||||
it('목록 보기에서는 지우면 목록으로 돌아간다', async () => {
|
||||
const { wrapper, router } = await mountWith({ layout: 'list' })
|
||||
|
||||
await deleteButton(wrapper).trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(router.currentRoute.value.fullPath).toBe('/mail/inbox')
|
||||
expect(usedDelete()).toBe(false)
|
||||
})
|
||||
|
||||
it('휴지통인지는 목록이 아니라 그 메일의 메일함으로 정한다 — 「영구 삭제」는 확인(처음 초점 「취소」)을 거쳐 POST /message-deletions', async () => {
|
||||
const { wrapper, router, store } = await mountWith({ mailboxIds: ['trash'] })
|
||||
expect(deleteButton(wrapper).text()).toBe('영구 삭제')
|
||||
|
||||
await deleteButton(wrapper).trigger('click')
|
||||
await flushPromises()
|
||||
const dialog = wrapper.get('dialog')
|
||||
expect(dialog.attributes('open')).toBeDefined()
|
||||
expect(dialog.get('#reader-purge-title').text()).toBe('메일 1통을 영구 삭제할까요?')
|
||||
expect(document.activeElement).toBe(dialog.get('[data-purge-cancel]').element)
|
||||
|
||||
await dialog.get('[data-purge-cancel]').trigger('click')
|
||||
await flushPromises()
|
||||
expect(requests.some((request) => request.url === '/message-deletions')).toBe(false)
|
||||
|
||||
await deleteButton(wrapper).trigger('click')
|
||||
await flushPromises()
|
||||
await wrapper.get('[data-purge-confirm]').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(requests.find((request) => request.url === '/message-deletions')).toMatchObject({
|
||||
method: 'POST',
|
||||
data: { ids: ['m1'] },
|
||||
})
|
||||
expect(usedDelete()).toBe(false)
|
||||
expect(useToastStore().toasts.map((toast) => toast.message)).toEqual(['메일 1통을 영구 삭제했습니다.'])
|
||||
expect(store.messages.map((message) => message.id)).toEqual(['m2'])
|
||||
expect(router.currentRoute.value.fullPath).toBe('/mail/inbox/m2')
|
||||
})
|
||||
|
||||
it('그사이 휴지통을 떠난 메일이면(skippedIds) 지우지 않았다고 알리고 목록을 다시 맞춘다 — 넘어가지 않는다', async () => {
|
||||
const { wrapper, router } = await mountWith({
|
||||
mailboxIds: ['trash'],
|
||||
handler: (request) =>
|
||||
request.url === '/message-deletions' ? ok({ deletedIds: [], skippedIds: ['m1'] }) : undefined,
|
||||
})
|
||||
|
||||
await deleteButton(wrapper).trigger('click')
|
||||
await flushPromises()
|
||||
await wrapper.get('[data-purge-confirm]').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(useToastStore().toasts.map((toast) => toast.message)).toEqual([
|
||||
'1통은 그사이 옮겨졌거나 지워져 그대로 두었습니다.',
|
||||
])
|
||||
expect(requests.filter((request) => request.method === 'GET' && request.url === '/messages')).toHaveLength(1)
|
||||
expect(router.currentRoute.value.fullPath).toBe('/mail/inbox/m1')
|
||||
expect(usedDelete()).toBe(false)
|
||||
})
|
||||
|
||||
it('휴지통 목록 밖에서 휴지통과 다른 메일함에 함께 든 메일은 「삭제」 — 휴지통으로 옮긴다(영구 삭제를 묻지 않는다)', async () => {
|
||||
const { wrapper, store } = await mountWith({
|
||||
mailboxIds: ['inbox', 'trash'],
|
||||
source: { kind: 'search', q: '회의', scope: 'all' },
|
||||
})
|
||||
expect(deleteButton(wrapper).text()).toBe('삭제')
|
||||
|
||||
await deleteButton(wrapper).trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.get('dialog').attributes('open')).toBeUndefined()
|
||||
expect(requests.some((request) => request.url === '/message-deletions')).toBe(false)
|
||||
expect(requests.find((request) => request.method === 'PATCH')?.data).toEqual({ ids: ['m1'], mailboxId: 'trash' })
|
||||
// 전체 메일함 검색에는 휴지통이 빠진다 — 결과에서 빠지고 알림은 "옮겼습니다"
|
||||
expect(store.messages.map((message) => message.id)).toEqual(['m2'])
|
||||
expect(useToastStore().toasts.map((toast) => toast.message)).toEqual(['메일 1통을 휴지통으로 옮겼습니다.'])
|
||||
expect(usedDelete()).toBe(false)
|
||||
})
|
||||
|
||||
it('휴지통 목록 밖에서 휴지통에만 든 메일은 「영구 삭제」, 휴지통 목록에서는 다른 메일함에 함께 들어도 「영구 삭제」 (지금처럼)', async () => {
|
||||
const onlyTrash = await mountWith({ mailboxIds: ['trash'], source: { kind: 'search', q: '회의', scope: 'all' } })
|
||||
expect(deleteButton(onlyTrash.wrapper).text()).toBe('영구 삭제')
|
||||
onlyTrash.wrapper.unmount()
|
||||
|
||||
const inTrashList = await mountWith({ mailboxIds: ['trash', 'inbox'], source: { kind: 'mailbox', mailboxId: 'trash' } })
|
||||
expect(deleteButton(inTrashList.wrapper).text()).toBe('영구 삭제')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
<script setup lang="ts">
|
||||
// 본문 영역 — 선택한 메일 (디자인 원본 Main 오른쪽 · MainListRead)
|
||||
// 본문 영역 — 선택한 메일 (디자인 원본 Main 오른쪽 · MainListRead · Search 오른쪽)
|
||||
// 본문 HTML 은 MailBodyFrame(격리된 iframe)에만 그린다.
|
||||
// 툴바와 본문은 콘텐츠 열(tokens.css --content-*)을 따른다 — 분할 보기 읽기 · 목록 보기 읽기 · 쓰기 공통.
|
||||
import { computed, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
// 검색 결과에서 연 메일은 받는 사람 아래에 "메일함 받은편지함" 한 줄을 더한다 (디자인 README "3단계 › 검색").
|
||||
// 「삭제」 · 「영구 삭제」는 MessageDeleteButton — 휴지통인지는 그 메일의 메일함으로 정한다.
|
||||
// 삭제 뒤 · 없는 메일에서 돌아가는 곳은 지금 목록(메일함 또는 검색 결과)이다.
|
||||
import { computed } from 'vue'
|
||||
|
||||
import AppIcon from '@/components/AppIcon.vue'
|
||||
import StateMessage from '@/components/feedback/StateMessage.vue'
|
||||
@@ -12,11 +14,14 @@ import MailLoadError from '@/components/mail/MailLoadError.vue'
|
||||
import MailToolButton from '@/components/mail/MailToolButton.vue'
|
||||
import MailToolbarDivider from '@/components/mail/MailToolbarDivider.vue'
|
||||
import MessageAttachments from '@/components/mail/MessageAttachments.vue'
|
||||
import MessageDeleteButton from '@/components/mail/MessageDeleteButton.vue'
|
||||
import { useMailNavigation } from '@/composables/useMailNavigation'
|
||||
import { MAIL_SEARCH_TEXT } from '@/shared/constants/mail'
|
||||
import { formatMailDateTime, initialOf } from '@/shared/utils/format'
|
||||
import { displayName, formatAddressList } from '@/shared/utils/mail-address'
|
||||
import { listedAttachments } from '@/shared/utils/mail-attachment'
|
||||
import { mailBodyNotices } from '@/shared/utils/mail-body-notices'
|
||||
import { mailboxOfMessage } from '@/shared/utils/mail-list-row'
|
||||
import { useMailStore } from '@/stores/mail.store'
|
||||
|
||||
interface Props {
|
||||
@@ -27,18 +32,19 @@ interface Props {
|
||||
layout?: 'split' | 'list'
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
withDefaults(defineProps<Props>(), {
|
||||
layout: 'split',
|
||||
})
|
||||
|
||||
const router = useRouter()
|
||||
const mailStore = useMailStore()
|
||||
const { mailboxLocation, messageLocation, backToList, replaceWithMailbox } = useMailNavigation()
|
||||
|
||||
const deleting = ref<boolean>(false)
|
||||
const { backToSourceList, replaceWithMailbox } = useMailNavigation()
|
||||
|
||||
const message = computed(() => mailStore.selected)
|
||||
const isTrash = computed(() => mailStore.currentMailbox?.role === 'trash')
|
||||
/** 검색 결과에서 연 메일의 메일함 이름 — 메일함 목록에서 열었으면 null */
|
||||
const searchMailboxName = computed(() => {
|
||||
if (!mailStore.search || !message.value) return null
|
||||
return mailboxOfMessage(message.value, mailStore.mailboxes, mailStore.currentMailboxId)?.name ?? null
|
||||
})
|
||||
const fromName = computed(() => displayName(message.value?.from) || '(보낸 사람 없음)')
|
||||
const fromEmail = computed(() => message.value?.from?.email ?? '')
|
||||
const initial = computed(() => initialOf(message.value?.from?.name, fromEmail.value))
|
||||
@@ -49,35 +55,14 @@ const bodyNotices = computed(() => (message.value ? mailBodyNotices(message.valu
|
||||
/** 첨부 목록 — 본문 안에 그림으로 보인 것(서명 그림 · 본문 그림, isInline)은 뺀다 */
|
||||
const attachments = computed(() => (message.value ? listedAttachments(message.value.attachments) : []))
|
||||
|
||||
async function onDelete() {
|
||||
const current = message.value
|
||||
const mailboxId = mailStore.currentMailboxId
|
||||
if (!current || !mailboxId || deleting.value) return
|
||||
|
||||
deleting.value = true
|
||||
try {
|
||||
const nextId = await mailStore.removeMessage(current.id)
|
||||
if (props.layout === 'list') {
|
||||
// 목록 보기에서는 삭제하면 목록으로 돌아간다 (디자인 원본 MainListRead)
|
||||
await backToList(mailboxId)
|
||||
return
|
||||
}
|
||||
await router.replace(nextId ? messageLocation(mailboxId, nextId) : mailboxLocation(mailboxId))
|
||||
} catch {
|
||||
// 알림은 인터셉터가 띄웠다 — 메일은 그대로 둔다
|
||||
} finally {
|
||||
deleting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function retry() {
|
||||
if (mailStore.selectedId) void mailStore.openMessage(mailStore.selectedId)
|
||||
}
|
||||
|
||||
/** 없는 메일(RES_001 — 지워졌거나 주소가 틀림) — 목록으로 돌아간다 (뒤로 가기 또는 바꿔 끼우기) */
|
||||
function leaveToList() {
|
||||
const mailboxId = mailStore.currentMailboxId
|
||||
void (mailboxId ? backToList(mailboxId) : replaceWithMailbox(null))
|
||||
const source = mailStore.listSource
|
||||
void (source ? backToSourceList(source) : replaceWithMailbox(null))
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -114,12 +99,9 @@ function leaveToList() {
|
||||
:to="{ name: 'mail-compose', query: { forward: message.id } }"
|
||||
/>
|
||||
<MailToolbarDivider v-if="layout === 'split'" />
|
||||
<MailToolButton
|
||||
icon="trash"
|
||||
:label="isTrash ? '영구 삭제' : '삭제'"
|
||||
tone="danger"
|
||||
:disabled="deleting"
|
||||
@click="onDelete"
|
||||
<MessageDeleteButton
|
||||
:message="message"
|
||||
:layout="layout"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
@@ -206,6 +188,13 @@ function leaveToList() {
|
||||
<span class="reader__recipients-label">참조</span>
|
||||
{{ formatAddressList(message.cc) }}
|
||||
</p>
|
||||
<p
|
||||
v-if="searchMailboxName"
|
||||
class="reader__mailbox"
|
||||
>
|
||||
<span class="reader__recipients-label">{{ MAIL_SEARCH_TEXT.mailboxLine }}</span>
|
||||
{{ searchMailboxName }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -255,7 +244,11 @@ function leaveToList() {
|
||||
v-else
|
||||
icon="mail"
|
||||
title="메일을 선택하세요"
|
||||
hint="목록에서 메일을 고르면 내용이 여기에 표시됩니다."
|
||||
:hint="
|
||||
mailStore.search
|
||||
? '검색 결과에서 메일을 고르면 내용이 여기에 표시됩니다.'
|
||||
: '목록에서 메일을 고르면 내용이 여기에 표시됩니다.'
|
||||
"
|
||||
fill
|
||||
/>
|
||||
</div>
|
||||
@@ -400,6 +393,13 @@ function leaveToList() {
|
||||
margin-right: var(--space-6);
|
||||
}
|
||||
|
||||
/* 검색 결과에서 연 메일 — "메일함 받은편지함" (12.5px #667085, 디자인 원본 Search) */
|
||||
.reader__mailbox {
|
||||
margin: 0;
|
||||
font-size: var(--font-size-label);
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.reader__notice {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
|
||||
@@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { createPinia, setActivePinia, type Pinia } from 'pinia'
|
||||
|
||||
import { SEARCH_SCOPE_ALL } from '@/shared/constants/mail'
|
||||
import type { Mailbox, MessageSummary } from '@/shared/types/mail'
|
||||
import { useAuthStore } from '@/stores/auth.store'
|
||||
import { useMailStore } from '@/stores/mail.store'
|
||||
@@ -20,6 +21,8 @@ const INBOX: Mailbox = {
|
||||
totalCount: 15,
|
||||
unreadCount: 2,
|
||||
}
|
||||
const SENT: Mailbox = { ...INBOX, id: 'sent', name: '보낸편지함', role: 'sent', unreadCount: 0 }
|
||||
const DRAFTS: Mailbox = { ...INBOX, id: 'drafts', name: '임시보관함', role: 'drafts', unreadCount: 0 }
|
||||
|
||||
function summary(id: string, partial: Partial<MessageSummary> = {}): MessageSummary {
|
||||
return {
|
||||
@@ -55,37 +58,38 @@ describe('MessageTable (목록 보기)', () => {
|
||||
})
|
||||
|
||||
async function mountTable(
|
||||
options: { mailbox?: Mailbox; messages?: MessageSummary[]; hasNext?: boolean } = {},
|
||||
options: { mailbox?: Mailbox; messages?: MessageSummary[]; hasNext?: boolean; path?: string } = {},
|
||||
) {
|
||||
const store = useMailStore()
|
||||
store.mailboxes = [options.mailbox ?? INBOX]
|
||||
store.mailboxes = [INBOX, SENT, DRAFTS]
|
||||
store.currentMailboxId = (options.mailbox ?? INBOX).id
|
||||
store.messages = options.messages ?? [summary('m1', { isRead: false, hasAttachment: true }), summary('m2')]
|
||||
store.pageInfo = { hasNext: options.hasNext ?? false, nextCursor: options.hasNext ? 'c2' : null, total: 15 }
|
||||
store.listStatus = 'ready'
|
||||
const router = createTestRouter()
|
||||
await router.push(`/mail/${store.currentMailboxId}`)
|
||||
await router.push(options.path ?? `/mail/${store.currentMailboxId}`)
|
||||
await router.isReady()
|
||||
const wrapper = mount(MessageTable, {
|
||||
props: { mailboxId: store.currentMailboxId ?? 'inbox' },
|
||||
global: { plugins: [pinia, router] },
|
||||
attachTo: document.body,
|
||||
})
|
||||
return { wrapper, router, store }
|
||||
}
|
||||
|
||||
it('진짜 <table> 이다 — 캡션 · 다섯 칸 colgroup · 머리 칸', async () => {
|
||||
it('진짜 <table> 이다 — 캡션 · 여섯 칸 colgroup(고르기 칸 포함) · 머리 칸', async () => {
|
||||
const { wrapper } = await mountTable()
|
||||
|
||||
expect(wrapper.get('table caption').text()).toBe('받은편지함 메일 목록')
|
||||
expect(wrapper.findAll('colgroup col')).toHaveLength(5)
|
||||
expect(wrapper.findAll('colgroup col')).toHaveLength(6)
|
||||
expect(wrapper.findAll('thead th').map((th) => th.text())).toEqual([
|
||||
'',
|
||||
'중요 표시',
|
||||
'보낸 사람',
|
||||
'제목',
|
||||
'날짜',
|
||||
'크기',
|
||||
])
|
||||
expect(wrapper.get('thead th [role="checkbox"]').attributes('aria-label')).toBe('불러온 메일 모두 고르기')
|
||||
expect(wrapper.findAll('thead th').every((th) => th.attributes('scope') === 'col')).toBe(true)
|
||||
})
|
||||
|
||||
@@ -144,26 +148,25 @@ describe('MessageTable (목록 보기)', () => {
|
||||
})
|
||||
|
||||
it('보낸편지함에서는 받는 사람 칸이 된다', async () => {
|
||||
const sent: Mailbox = { ...INBOX, id: 'sent', name: '보낸편지함', role: 'sent', unreadCount: 0 }
|
||||
const { wrapper } = await mountTable({ mailbox: sent, messages: [summary('s1')] })
|
||||
const { wrapper } = await mountTable({ mailbox: SENT, messages: [summary('s1', { mailboxIds: ['sent'] })] })
|
||||
|
||||
expect(wrapper.findAll('thead th')[1]?.text()).toBe('받는 사람')
|
||||
expect(wrapper.findAll('thead th')[2]?.text()).toBe('받는 사람')
|
||||
expect(wrapper.findAll('tbody .mail-table__who')[0]?.text()).toBe('박준호')
|
||||
})
|
||||
|
||||
it('임시보관함 — 받는 사람 칸에 받는 사람을 쉼표로, 비었으면 흐린 "(받는 사람 없음)" · "(제목 없음)" — 누르면 쓰기 화면에서 이어 쓴다', async () => {
|
||||
const drafts: Mailbox = { ...INBOX, id: 'drafts', name: '임시보관함', role: 'drafts', unreadCount: 0 }
|
||||
const { wrapper, router } = await mountTable({
|
||||
mailbox: drafts,
|
||||
mailbox: DRAFTS,
|
||||
messages: [
|
||||
summary('d1', {
|
||||
mailboxIds: ['drafts'],
|
||||
isRead: false,
|
||||
to: [
|
||||
{ name: '박준호', email: 'junho.park@arasystem.test' },
|
||||
{ name: null, email: 'infra@arasystem.test' },
|
||||
],
|
||||
}),
|
||||
summary('d2', { to: [], subject: '' }),
|
||||
summary('d2', { mailboxIds: ['drafts'], to: [], subject: '' }),
|
||||
],
|
||||
})
|
||||
const rows = wrapper.findAll('tbody tr')
|
||||
@@ -188,7 +191,7 @@ describe('MessageTable (목록 보기)', () => {
|
||||
)
|
||||
const { wrapper } = await mountTable({ hasNext: true })
|
||||
|
||||
await wrapper.get('.message-table__more-button').trigger('click')
|
||||
await wrapper.get('.mail-list-frame__more-button').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(requests[0]?.params).toMatchObject({ mailboxId: 'inbox', cursor: 'c2' })
|
||||
@@ -204,4 +207,103 @@ describe('MessageTable (목록 보기)', () => {
|
||||
expect(document.activeElement).toBe(wrapper.findAll('a.mail-table__link')[1]?.element)
|
||||
scrollTo.mockRestore()
|
||||
})
|
||||
|
||||
describe('여러 통 고르기 (디자인 원본 BulkSelect)', () => {
|
||||
const three = () => [summary('m1'), summary('m2'), summary('m3'), summary('m4')]
|
||||
|
||||
it('행의 고르기 상자 — 이름 "고르기: 제목", 고르면 행이 고른 바탕이 되고 머리가 도구 줄 "N통 선택됨"으로 바뀐다', async () => {
|
||||
const { wrapper, store } = await mountTable({ messages: three() })
|
||||
const box = wrapper.findAll('tbody [role="checkbox"]')[1]
|
||||
|
||||
expect(box?.attributes('aria-label')).toBe('고르기: 제목 m2')
|
||||
expect(box?.attributes('aria-checked')).toBe('false')
|
||||
await box?.trigger('click')
|
||||
|
||||
expect(store.checkedIds.has('m2')).toBe(true)
|
||||
expect(wrapper.findAll('tbody tr')[1]?.classes()).toContain('is-checked')
|
||||
expect(wrapper.findAll('tbody [role="checkbox"]')[1]?.attributes('aria-checked')).toBe('true')
|
||||
expect(wrapper.find('.list-header').exists()).toBe(false)
|
||||
expect(wrapper.get('#message-table-title').text()).toBe('1통 선택됨')
|
||||
expect(wrapper.get('[role="toolbar"]').attributes('aria-label')).toBe('고른 메일')
|
||||
})
|
||||
|
||||
it('머리의 상자 — 하나도 · 일부만(mixed) · 모두, 누르면 불러온 메일을 모두 고르고 모두 골랐으면 푼다', async () => {
|
||||
const { wrapper, store } = await mountTable({ messages: three() })
|
||||
const head = () => wrapper.get('thead [role="checkbox"]')
|
||||
|
||||
expect(head().attributes('aria-checked')).toBe('false')
|
||||
await wrapper.findAll('tbody [role="checkbox"]')[0]?.trigger('click')
|
||||
expect(head().attributes('aria-checked')).toBe('mixed')
|
||||
|
||||
await head().trigger('click')
|
||||
expect([...store.checkedIds]).toEqual(['m1', 'm2', 'm3', 'm4'])
|
||||
expect(head().attributes('aria-checked')).toBe('true')
|
||||
|
||||
await head().trigger('click')
|
||||
expect(store.checkedCount).toBe(0)
|
||||
})
|
||||
|
||||
it('Ctrl(⌘)+클릭은 메일을 열지 않고 하나씩, Shift+클릭은 범위로 고른다', async () => {
|
||||
const { wrapper, router, store } = await mountTable({ messages: three() })
|
||||
const rows = () => wrapper.findAll('tbody tr')
|
||||
|
||||
await rows()[0]?.get('.mail-table__who').trigger('click', { ctrlKey: true })
|
||||
await rows()[2]?.get('.mail-table__link').trigger('click', { shiftKey: true })
|
||||
await flushPromises()
|
||||
|
||||
expect(router.currentRoute.value.fullPath).toBe('/mail/inbox')
|
||||
expect([...store.checkedIds].sort()).toEqual(['m1', 'm2', 'm3'])
|
||||
|
||||
await rows()[1]?.get('.mail-table__date').trigger('click', { metaKey: true })
|
||||
expect([...store.checkedIds].sort()).toEqual(['m1', 'm3'])
|
||||
})
|
||||
|
||||
it('Esc 는 목록 칸 안에서 고른 것을 푼다', async () => {
|
||||
const { wrapper, store } = await mountTable({ messages: three() })
|
||||
await wrapper.findAll('tbody [role="checkbox"]')[0]?.trigger('click')
|
||||
|
||||
await wrapper.get('section.message-table').trigger('keydown', { key: 'Escape' })
|
||||
|
||||
expect(store.checkedCount).toBe(0)
|
||||
expect(wrapper.find('.list-header').exists()).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
it('전체 메일함 검색 결과 — 보낸 사람 뒤에 메일함 칸(96), 보낸편지함 · 임시보관함의 메일은 "받는 사람: 이름"', async () => {
|
||||
const store = useMailStore()
|
||||
const { wrapper } = await mountTable({
|
||||
path: `/mail/search?q=${encodeURIComponent('회의')}&in=all`,
|
||||
messages: [
|
||||
summary('r1', { mailboxIds: ['inbox'] }),
|
||||
summary('r2', { mailboxIds: ['sent'], to: [{ name: '정하늘', email: 'haneul@arasystem.test' }] }),
|
||||
summary('r3', { mailboxIds: ['drafts'], to: [{ name: '운영팀', email: 'ops@arasystem.test' }] }),
|
||||
],
|
||||
})
|
||||
store.listSource = { kind: 'search', q: '회의', scope: SEARCH_SCOPE_ALL }
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.findAll('colgroup col')).toHaveLength(7)
|
||||
expect(wrapper.findAll('thead th').map((th) => th.text())).toEqual([
|
||||
'',
|
||||
'중요 표시',
|
||||
'보낸 사람',
|
||||
'메일함',
|
||||
'제목',
|
||||
'날짜',
|
||||
'크기',
|
||||
])
|
||||
const rows = wrapper.findAll('tbody tr')
|
||||
expect(rows.map((row) => row.get('.mail-table__who').text())).toEqual([
|
||||
'이도윤',
|
||||
'받는 사람: 정하늘',
|
||||
'받는 사람: 운영팀',
|
||||
])
|
||||
expect(rows.map((row) => row.get('.mail-table__mailbox').text())).toEqual(['받은편지함', '보낸편지함', '임시보관함'])
|
||||
// 검색 결과에서 연 메일의 주소 · 임시 저장본은 지금처럼 쓰기 화면
|
||||
expect(rows[0]?.get('.mail-table__link').attributes('href')).toBe(
|
||||
`/mail/search/r1?q=${encodeURIComponent('회의')}&in=all`,
|
||||
)
|
||||
expect(rows[2]?.get('.mail-table__link').attributes('href')).toBe('/mail/compose?draft=r3')
|
||||
expect(wrapper.get('table caption').text()).toBe('검색 결과')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,279 +1,88 @@
|
||||
<script setup lang="ts">
|
||||
// 목록 보기 — 게시판형 표 (디자인 원본 MainList). 캔버스는 grid 로 그렸지만 구현은 진짜 <table> 이다.
|
||||
// 칸: 별 32 · 보낸 사람 168 · 제목(나머지) · 날짜 84 · 크기 64 (칸 사이 12) — tokens.css 의 --table-* 값.
|
||||
// 행 어디를 눌러도 메일이 열리고, 제목은 키보드로 갈 수 있는 링크다. 별 버튼은 따로 동작한다.
|
||||
// 임시보관함은 받는 사람 칸에 받는 사람(없으면 "(받는 사람 없음)")을 보이고, 행을 누르면 쓰기 화면에서 이어 쓴다
|
||||
// (디자인 원본 ComposeAssist "임시보관함 목록" — 빈 값은 흐린 글자, 안 읽음 굵기는 쓰지 않는다).
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
// 목록 보기 — 게시판형 표 (디자인 원본 MainList · BulkSelect · ListStates). 캔버스는 grid 로 그렸지만 구현은 진짜 <table> 이다.
|
||||
// 칸: 고르기 32 · 별 32 · 보낸 사람 168 · [메일함 96 — 전체 메일함 검색 결과] · 제목(나머지) · 날짜 84 · 크기 64 (칸 사이 12)
|
||||
// — tokens.css 의 --table-* 값. 머리 줄은 MessageTableHead, 행은 MessageTableRow.
|
||||
// 머리 · 상태 · 메일 더 보기 · 표 위치 기억(메일을 열었다 돌아올 때)은 목록 틀(MailListFrame)이 맡는다.
|
||||
import { computed } from 'vue'
|
||||
import { useRouter, type RouteLocationRaw } from 'vue-router'
|
||||
|
||||
import AppIcon from '@/components/AppIcon.vue'
|
||||
import StateMessage from '@/components/feedback/StateMessage.vue'
|
||||
import MailListHeader from '@/components/mail/MailListHeader.vue'
|
||||
import MailLoadError from '@/components/mail/MailLoadError.vue'
|
||||
import MessageListSkeleton from '@/components/mail/MessageListSkeleton.vue'
|
||||
import { useMailNavigation } from '@/composables/useMailNavigation'
|
||||
import { DRAFT_NO_RECIPIENTS, MAIL_NO_SUBJECT, OUTGOING_MAILBOX_ROLES } from '@/shared/constants/mail'
|
||||
import type { MessageSummary } from '@/shared/types/mail'
|
||||
import { formatFileSize, formatMailTableDate } from '@/shared/utils/format'
|
||||
import { displayName, draftRecipientNames, summarizeRecipients } from '@/shared/utils/mail-address'
|
||||
import { emptyMailboxText, mailboxIcon } from '@/shared/utils/mailbox'
|
||||
import MailListFrame from '@/components/mail/MailListFrame.vue'
|
||||
import MessageTableHead from '@/components/mail/MessageTableHead.vue'
|
||||
import MessageTableRow from '@/components/mail/MessageTableRow.vue'
|
||||
import { useMailListRows } from '@/composables/useMailListRows'
|
||||
import { MAIL_SEARCH_TEXT } from '@/shared/constants/mail'
|
||||
import { useMailStore } from '@/stores/mail.store'
|
||||
import { useMailViewStore } from '@/stores/mail-view.store'
|
||||
|
||||
interface Props {
|
||||
mailboxId: string
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
|
||||
const router = useRouter()
|
||||
const mailStore = useMailStore()
|
||||
const viewStore = useMailViewStore()
|
||||
const { messageLocation, replaceWithMailbox } = useMailNavigation()
|
||||
const rows = useMailListRows()
|
||||
|
||||
/** 넓은 화면에서 표 칸 자체가 스크롤된다 — 메일을 열었다 돌아올 때 위치를 되살린다 */
|
||||
const sectionRef = ref<HTMLElement | null>(null)
|
||||
|
||||
const mailbox = computed(() => mailStore.currentMailbox)
|
||||
const isOutgoing = computed(() => {
|
||||
const role = mailbox.value?.role
|
||||
return role ? OUTGOING_MAILBOX_ROLES.includes(role) : false
|
||||
})
|
||||
/** 임시보관함 — 행을 누르면 쓰기 화면에서 이어 쓴다 */
|
||||
const isDrafts = computed(() => mailbox.value?.role === 'drafts')
|
||||
const whoHeader = computed(() => (isOutgoing.value ? '받는 사람' : '보낸 사람'))
|
||||
const caption = computed(() => `${mailbox.value?.name ?? '메일함'} 메일 목록`)
|
||||
const empty = computed(() =>
|
||||
emptyMailboxText({ name: mailbox.value?.name ?? '메일함', role: mailbox.value?.role ?? null }),
|
||||
const caption = computed(() =>
|
||||
mailStore.search ? MAIL_SEARCH_TEXT.title : `${mailStore.currentMailbox?.name ?? '메일함'} 메일 목록`,
|
||||
)
|
||||
const emptyIcon = computed(() => mailboxIcon({ role: mailbox.value?.role ?? null }))
|
||||
const busy = computed(() => mailStore.listStatus === 'loading' || mailStore.isRefreshing)
|
||||
|
||||
function whoOf(message: MessageSummary): string {
|
||||
if (isDrafts.value) return draftRecipientNames(message.to) || DRAFT_NO_RECIPIENTS
|
||||
if (isOutgoing.value) return summarizeRecipients(message.to) || '받는 사람 없음'
|
||||
return displayName(message.from) || '(보낸 사람 없음)'
|
||||
/** Shift 면 범위, 아니면 하나 */
|
||||
function onCheck(id: string, range: boolean) {
|
||||
if (range) mailStore.checkRangeTo(id)
|
||||
else mailStore.toggleCheck(id)
|
||||
}
|
||||
|
||||
/** 임시 저장본의 받는 사람이 없다 — 흐린 글자로 */
|
||||
function hasNoDraftRecipients(message: MessageSummary): boolean {
|
||||
return isDrafts.value && draftRecipientNames(message.to) === ''
|
||||
function open(location: RouteLocationRaw) {
|
||||
void router.push(location)
|
||||
}
|
||||
|
||||
/** 임시 저장본은 읽음 여부를 보이지 않는다 */
|
||||
function isUnread(message: MessageSummary): boolean {
|
||||
return !isDrafts.value && !message.isRead
|
||||
}
|
||||
|
||||
/** 행이 여는 곳 — 임시 저장본은 쓰기 화면, 그 밖은 읽기 화면 */
|
||||
function rowLocation(message: MessageSummary): RouteLocationRaw {
|
||||
return isDrafts.value
|
||||
? { name: 'mail-compose', query: { draft: message.id } }
|
||||
: messageLocation(props.mailboxId, message.id)
|
||||
}
|
||||
|
||||
/** 행 어디를 눌러도 연다 — 링크·별 버튼을 누른 경우와 글자를 고르는 중이면 그대로 둔다 */
|
||||
function onRowClick(event: MouseEvent, message: MessageSummary) {
|
||||
const target = event.target instanceof Element ? event.target : null
|
||||
if (target?.closest('a, button')) return
|
||||
if (window.getSelection()?.toString()) return
|
||||
void router.push(rowLocation(message))
|
||||
}
|
||||
|
||||
function retry() {
|
||||
void mailStore.openMailbox(props.mailboxId)
|
||||
}
|
||||
|
||||
/** 없는 메일함(RES_001) — 받은편지함으로 바꿔 끼운다 */
|
||||
function goToInbox() {
|
||||
void replaceWithMailbox(mailStore.defaultMailbox?.id ?? null)
|
||||
}
|
||||
|
||||
// 메일을 열면 이 표가 사라진다 — 스크롤을 기억해 "목록으로" 돌아오면 되살린다 (불러온 페이지는 스토어에 그대로 있다)
|
||||
onBeforeUnmount(() => {
|
||||
viewStore.rememberListPosition({
|
||||
mailboxId: props.mailboxId,
|
||||
scrollTop: sectionRef.value?.scrollTop ?? 0,
|
||||
windowY: window.scrollY,
|
||||
})
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
const saved = viewStore.takeListPosition(props.mailboxId)
|
||||
const focusId = viewStore.takeReturnFocus()
|
||||
if (!saved && !focusId) return
|
||||
await nextTick()
|
||||
if (saved) {
|
||||
if (sectionRef.value) sectionRef.value.scrollTop = saved.scrollTop
|
||||
if (saved.windowY > 0) window.scrollTo({ top: saved.windowY })
|
||||
}
|
||||
if (focusId) {
|
||||
// 보던 메일의 제목 링크로 초점을 돌려준다 (키보드 사용자가 자리를 잃지 않게)
|
||||
const links = sectionRef.value?.querySelectorAll<HTMLElement>('[data-message-id]') ?? []
|
||||
const link = Array.from(links).find((element) => element.dataset.messageId === focusId)
|
||||
link?.focus({ preventScroll: Boolean(saved) })
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section
|
||||
ref="sectionRef"
|
||||
<MailListFrame
|
||||
class="message-table"
|
||||
aria-labelledby="message-table-title"
|
||||
:aria-busy="busy"
|
||||
variant="list"
|
||||
title-id="message-table-title"
|
||||
>
|
||||
<MailListHeader
|
||||
title-id="message-table-title"
|
||||
variant="list"
|
||||
/>
|
||||
|
||||
<MessageListSkeleton v-if="mailStore.listStatus === 'loading'" />
|
||||
|
||||
<MailLoadError
|
||||
v-else-if="mailStore.listStatus === 'error' && mailStore.listError"
|
||||
:error="mailStore.listError"
|
||||
leave-label="받은편지함으로"
|
||||
@retry="retry"
|
||||
@leave="goToInbox"
|
||||
/>
|
||||
|
||||
<template v-else-if="mailStore.messages.length > 0">
|
||||
<!-- 표 최소 폭(400px)보다 좁으면 가로로 스크롤한다 -->
|
||||
<div class="message-table__scroll">
|
||||
<table class="mail-table">
|
||||
<caption class="visually-hidden">
|
||||
{{ caption }}
|
||||
</caption>
|
||||
<colgroup>
|
||||
<col class="mail-table__col-star">
|
||||
<col class="mail-table__col-who">
|
||||
<col class="mail-table__col-subject">
|
||||
<col class="mail-table__col-date">
|
||||
<col class="mail-table__col-size">
|
||||
</colgroup>
|
||||
<thead>
|
||||
<tr>
|
||||
<th
|
||||
scope="col"
|
||||
class="mail-table__star"
|
||||
>
|
||||
<AppIcon
|
||||
name="star"
|
||||
:size="14"
|
||||
/>
|
||||
<span class="visually-hidden">중요 표시</span>
|
||||
</th>
|
||||
<th scope="col">
|
||||
{{ whoHeader }}
|
||||
</th>
|
||||
<th scope="col">
|
||||
제목
|
||||
</th>
|
||||
<th
|
||||
scope="col"
|
||||
class="mail-table__date"
|
||||
>
|
||||
날짜
|
||||
</th>
|
||||
<th
|
||||
scope="col"
|
||||
class="mail-table__size"
|
||||
>
|
||||
크기
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr
|
||||
v-for="message in mailStore.messages"
|
||||
:key="message.id"
|
||||
:class="['mail-table__row', { 'is-unread': isUnread(message) }]"
|
||||
@click="onRowClick($event, message)"
|
||||
>
|
||||
<td class="mail-table__star">
|
||||
<!-- 폭 32px · 행 높이를 채우는 별 — 44px 기준의 의도된 예외 (디자인 README, 사용자 확정) -->
|
||||
<button
|
||||
type="button"
|
||||
:class="['mail-table__star-button', { 'is-flagged': message.isFlagged }]"
|
||||
aria-label="중요 표시"
|
||||
:aria-pressed="message.isFlagged"
|
||||
@click="mailStore.toggleFlag(message.id)"
|
||||
>
|
||||
<AppIcon
|
||||
name="star"
|
||||
:size="14"
|
||||
:filled="message.isFlagged"
|
||||
/>
|
||||
</button>
|
||||
</td>
|
||||
<td :class="['mail-table__who', { 'is-placeholder': hasNoDraftRecipients(message) }]">
|
||||
<span
|
||||
v-if="isUnread(message)"
|
||||
class="visually-hidden"
|
||||
>안 읽음, </span>{{ whoOf(message) }}
|
||||
</td>
|
||||
<td class="mail-table__subject">
|
||||
<div class="mail-table__subject-line">
|
||||
<RouterLink
|
||||
:class="['mail-table__link', { 'is-placeholder': isDrafts && !message.subject }]"
|
||||
:to="rowLocation(message)"
|
||||
:data-message-id="message.id"
|
||||
>
|
||||
{{ message.subject || MAIL_NO_SUBJECT }}
|
||||
</RouterLink>
|
||||
<AppIcon
|
||||
v-if="message.hasAttachment"
|
||||
name="paperclip"
|
||||
:size="13"
|
||||
:stroke-width="2"
|
||||
label="첨부 있음"
|
||||
class="mail-table__clip"
|
||||
/>
|
||||
</div>
|
||||
</td>
|
||||
<td class="mail-table__date">
|
||||
<time :datetime="message.receivedAt">{{ formatMailTableDate(message.receivedAt) }}</time>
|
||||
</td>
|
||||
<td class="mail-table__size">
|
||||
{{ formatFileSize(message.size) }}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div
|
||||
v-if="mailStore.hasMore"
|
||||
class="message-table__more"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="message-table__text-button message-table__more-button"
|
||||
:disabled="mailStore.isLoadingMore"
|
||||
@click="mailStore.loadMore()"
|
||||
>
|
||||
{{ mailStore.isLoadingMore ? '불러오는 중…' : '메일 더 보기' }}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<StateMessage
|
||||
v-else-if="mailStore.listStatus === 'ready'"
|
||||
class="message-table__empty"
|
||||
:icon="emptyIcon"
|
||||
:title="empty.title"
|
||||
:hint="empty.hint"
|
||||
/>
|
||||
</section>
|
||||
<!-- 표 최소 폭(440px · 메일함 칸이 있으면 더 넓다)보다 좁으면 가로로 스크롤한다 -->
|
||||
<div class="message-table__scroll">
|
||||
<table :class="['mail-table', { 'mail-table--mixed': rows.mixed.value }]">
|
||||
<caption class="visually-hidden">
|
||||
{{ caption }}
|
||||
</caption>
|
||||
<colgroup>
|
||||
<col class="mail-table__col-check">
|
||||
<col class="mail-table__col-star">
|
||||
<col class="mail-table__col-who">
|
||||
<col
|
||||
v-if="rows.mixed.value"
|
||||
class="mail-table__col-mailbox"
|
||||
>
|
||||
<col class="mail-table__col-subject">
|
||||
<col class="mail-table__col-date">
|
||||
<col class="mail-table__col-size">
|
||||
</colgroup>
|
||||
<MessageTableHead
|
||||
:who-header="rows.whoHeader.value"
|
||||
:mixed="rows.mixed.value"
|
||||
/>
|
||||
<tbody>
|
||||
<MessageTableRow
|
||||
v-for="message in mailStore.messages"
|
||||
:key="message.id"
|
||||
:message="message"
|
||||
:to="rows.locationOf(message)"
|
||||
:role="rows.roleOf(message)"
|
||||
:mixed="rows.mixed.value"
|
||||
:mailbox-name="rows.mailboxNameOf(message)"
|
||||
:checked="mailStore.checkedIds.has(message.id)"
|
||||
@open="open"
|
||||
@toggle-flag="mailStore.toggleFlag"
|
||||
@check="onCheck"
|
||||
/>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</MailListFrame>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.message-table {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/*
|
||||
@@ -293,18 +102,31 @@ onMounted(async () => {
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
/* 전체 메일함 검색 결과 — 메일함 칸만큼 더 넓어야 칸이 찌그러지지 않는다 */
|
||||
.mail-table--mixed {
|
||||
min-width: calc(var(--table-min-width) + var(--table-col-mailbox) + var(--table-gap));
|
||||
}
|
||||
|
||||
/*
|
||||
* 칸 폭 = 디자인 칸 폭 + 칸 사이(12px). 첫 칸에는 행 왼쪽 여백(20px), 마지막 칸에는 행 오른쪽 여백(8px)을 더한다.
|
||||
* 칸 사이는 각 칸의 오른쪽 안쪽 여백으로 만든다.
|
||||
*/
|
||||
.mail-table__col-check {
|
||||
width: calc(var(--space-20) + var(--table-col-check) + var(--table-gap));
|
||||
}
|
||||
|
||||
.mail-table__col-star {
|
||||
width: calc(var(--space-20) + var(--table-col-star) + var(--table-gap));
|
||||
width: calc(var(--table-col-star) + var(--table-gap));
|
||||
}
|
||||
|
||||
.mail-table__col-who {
|
||||
width: calc(var(--table-col-who) + var(--table-gap));
|
||||
}
|
||||
|
||||
.mail-table__col-mailbox {
|
||||
width: calc(var(--table-col-mailbox) + var(--table-gap));
|
||||
}
|
||||
|
||||
.mail-table__col-date {
|
||||
width: calc(var(--table-col-date) + var(--table-gap));
|
||||
}
|
||||
@@ -313,8 +135,9 @@ onMounted(async () => {
|
||||
width: calc(var(--table-col-size) + var(--space-8));
|
||||
}
|
||||
|
||||
.mail-table th,
|
||||
.mail-table td {
|
||||
/* 칸 공통 — 머리 줄(MessageTableHead)과 행(MessageTableRow)의 칸에 함께 쓴다 */
|
||||
.mail-table :deep(th),
|
||||
.mail-table :deep(td) {
|
||||
padding: 0 var(--table-gap) 0 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
@@ -323,164 +146,35 @@ onMounted(async () => {
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.mail-table th:first-child,
|
||||
.mail-table td:first-child {
|
||||
.mail-table :deep(th:first-child),
|
||||
.mail-table :deep(td:first-child) {
|
||||
padding-left: var(--space-20);
|
||||
}
|
||||
|
||||
.mail-table th:last-child,
|
||||
.mail-table td:last-child {
|
||||
.mail-table :deep(th:last-child),
|
||||
.mail-table :deep(td:last-child) {
|
||||
padding-right: var(--space-8);
|
||||
}
|
||||
|
||||
.mail-table thead th {
|
||||
height: var(--size-table-head);
|
||||
border-bottom: var(--border-width) solid var(--color-border);
|
||||
background: var(--color-surface-sunken);
|
||||
color: var(--color-text-muted);
|
||||
font-size: var(--font-size-small);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.mail-table thead .mail-table__star {
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.mail-table__row {
|
||||
height: var(--size-table-row);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.mail-table__row td {
|
||||
border-bottom: var(--border-width) solid var(--color-border-subtle);
|
||||
}
|
||||
|
||||
.mail-table__row:hover {
|
||||
background: var(--color-row-hover);
|
||||
}
|
||||
|
||||
.mail-table__row:focus-within {
|
||||
background: var(--color-brand-subtle);
|
||||
}
|
||||
|
||||
.mail-table__star-button {
|
||||
width: var(--size-compact-control);
|
||||
height: calc(var(--size-table-row) - var(--border-width));
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
border-radius: var(--radius-xs);
|
||||
background: transparent;
|
||||
color: var(--color-icon-muted);
|
||||
}
|
||||
|
||||
.mail-table__star-button.is-flagged {
|
||||
color: var(--color-star);
|
||||
}
|
||||
|
||||
.mail-table__who {
|
||||
font-weight: 450;
|
||||
color: var(--color-text-strong);
|
||||
}
|
||||
|
||||
.mail-table__subject-line {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-6);
|
||||
}
|
||||
|
||||
.mail-table__link {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
color: var(--color-text);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.mail-table__link:hover {
|
||||
color: var(--color-text);
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.mail-table__clip {
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
/* 날짜·크기는 머리와 행 모두 오른쪽 정렬 — 위의 `.mail-table th/td { text-align: left }` 보다
|
||||
우선하도록 th·td 를 함께 적는다 (클래스만 쓰면 기본 규칙에 져서 행만 왼쪽으로 어긋난다) */
|
||||
.mail-table th.mail-table__date,
|
||||
.mail-table th.mail-table__size,
|
||||
.mail-table td.mail-table__date,
|
||||
.mail-table td.mail-table__size {
|
||||
/* 날짜·크기는 머리와 행 모두 오른쪽 정렬 — 위의 칸 공통 규칙보다 우선하도록 th·td 를 함께 적는다 */
|
||||
.mail-table :deep(th.mail-table__date),
|
||||
.mail-table :deep(th.mail-table__size),
|
||||
.mail-table :deep(td.mail-table__date),
|
||||
.mail-table :deep(td.mail-table__size) {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.mail-table__date,
|
||||
.mail-table__size {
|
||||
.mail-table :deep(.mail-table__date),
|
||||
.mail-table :deep(.mail-table__size) {
|
||||
font-size: var(--font-size-small);
|
||||
color: var(--color-text-muted);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
/* 비어 있는 값 — "(받는 사람 없음)" · "(제목 없음)" (디자인 원본 ComposeAssist #667085) */
|
||||
.mail-table__who.is-placeholder,
|
||||
.mail-table__link.is-placeholder {
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
/* 안 읽음 — 뱃지 대신 굵기·색으로만 구분 */
|
||||
.mail-table__row.is-unread .mail-table__who,
|
||||
.mail-table__row.is-unread .mail-table__link {
|
||||
font-weight: 650;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.mail-table__row.is-unread .mail-table__date {
|
||||
font-weight: 600;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.message-table__more {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: var(--space-10) var(--space-20) var(--space-20);
|
||||
}
|
||||
|
||||
.message-table__text-button {
|
||||
min-height: var(--size-touch);
|
||||
padding: 0 var(--space-14);
|
||||
border: 0;
|
||||
border-radius: var(--radius-md);
|
||||
background: transparent;
|
||||
color: var(--color-brand);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.message-table__text-button:hover:not(:disabled) {
|
||||
background: var(--color-brand-subtle);
|
||||
}
|
||||
|
||||
.message-table__more-button {
|
||||
min-height: var(--size-row-min);
|
||||
padding: 0 var(--space-24);
|
||||
}
|
||||
|
||||
.message-table__more-button:disabled {
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.message-table__empty {
|
||||
padding-block: calc(var(--space-80) + var(--space-16));
|
||||
}
|
||||
|
||||
/* 1100px 미만 — 크기 칸을 숨기고 날짜 칸이 마지막 칸이 된다 (tokens.css 화면 폭 기준) */
|
||||
@media (max-width: 68.6875rem) {
|
||||
.mail-table__col-size,
|
||||
.mail-table__size {
|
||||
.mail-table :deep(.mail-table__size) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@@ -488,13 +182,13 @@ onMounted(async () => {
|
||||
width: calc(var(--table-col-date) + var(--space-8));
|
||||
}
|
||||
|
||||
.mail-table th.mail-table__date,
|
||||
.mail-table td.mail-table__date {
|
||||
.mail-table :deep(th.mail-table__date),
|
||||
.mail-table :deep(td.mail-table__date) {
|
||||
padding-right: var(--space-8);
|
||||
}
|
||||
}
|
||||
|
||||
/* 600px 미만 — 보낸 사람 · 날짜 칸을 줄인다. 표 최소 폭(400px)보다 좁으면 가로 스크롤 */
|
||||
/* 600px 미만 — 보낸 사람 · 날짜 칸을 줄인다. 표 최소 폭보다 좁으면 가로 스크롤 */
|
||||
@media (max-width: 37.4375rem) {
|
||||
.mail-table__col-who {
|
||||
width: calc(var(--table-col-who-narrow) + var(--table-gap));
|
||||
@@ -504,11 +198,4 @@ onMounted(async () => {
|
||||
width: calc(var(--table-col-date-narrow) + var(--space-8));
|
||||
}
|
||||
}
|
||||
|
||||
/* 넓은 화면에서는 표 칸만 따로 스크롤한다 (tokens.css 화면 폭 기준) */
|
||||
@media (min-width: 70rem) {
|
||||
.message-table {
|
||||
overflow-y: auto;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
<script setup lang="ts">
|
||||
// 목록 보기 표의 머리 줄(32) — 고르기 · 별 · 보낸 사람(받는 사람) · [메일함] · 제목 · 날짜 · 크기 (디자인 원본 BulkSelect · ListStates)
|
||||
// 머리의 고르기 상자는 불러온 메일 모두 고르기 · 풀기 — 일부만 골랐으면 – 표시 (aria-checked="mixed")
|
||||
// 칸 폭 · 칸 사이 여백은 표(MessageTable)가 정한다.
|
||||
import AppIcon from '@/components/AppIcon.vue'
|
||||
import MailCheckbox from '@/components/mail/MailCheckbox.vue'
|
||||
import { MAIL_BULK_TEXT, MAIL_SEARCH_TEXT } from '@/shared/constants/mail'
|
||||
import { useMailStore } from '@/stores/mail.store'
|
||||
|
||||
interface Props {
|
||||
/** 보낸 사람 칸 이름 — 보낸편지함 · 임시보관함은 "받는 사람" */
|
||||
whoHeader: string
|
||||
/** 전체 메일함 검색 결과 — 메일함 칸이 생긴다 */
|
||||
mixed?: boolean
|
||||
}
|
||||
|
||||
withDefaults(defineProps<Props>(), {
|
||||
mixed: false,
|
||||
})
|
||||
|
||||
const mailStore = useMailStore()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<thead class="mail-table-head">
|
||||
<tr>
|
||||
<th
|
||||
scope="col"
|
||||
class="mail-table__check"
|
||||
>
|
||||
<!-- 폭 32 · 머리 줄 높이를 채우는 고르기 상자 — 44px 기준의 의도된 예외 (디자인 README "3단계", 별 칸과 같다) -->
|
||||
<MailCheckbox
|
||||
class="mail-table-head__check"
|
||||
:checked="mailStore.checkState === 'all'"
|
||||
:mixed="mailStore.checkState === 'some'"
|
||||
:label="MAIL_BULK_TEXT.selectAll"
|
||||
@toggle="mailStore.toggleCheckAll()"
|
||||
/>
|
||||
</th>
|
||||
<th
|
||||
scope="col"
|
||||
class="mail-table__star"
|
||||
>
|
||||
<AppIcon
|
||||
name="star"
|
||||
:size="14"
|
||||
/>
|
||||
<span class="visually-hidden">중요 표시</span>
|
||||
</th>
|
||||
<th scope="col">
|
||||
{{ whoHeader }}
|
||||
</th>
|
||||
<th
|
||||
v-if="mixed"
|
||||
scope="col"
|
||||
>
|
||||
{{ MAIL_SEARCH_TEXT.mailboxColumn }}
|
||||
</th>
|
||||
<th scope="col">
|
||||
제목
|
||||
</th>
|
||||
<th
|
||||
scope="col"
|
||||
class="mail-table__date"
|
||||
>
|
||||
날짜
|
||||
</th>
|
||||
<th
|
||||
scope="col"
|
||||
class="mail-table__size"
|
||||
>
|
||||
크기
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.mail-table-head th {
|
||||
height: var(--size-table-head);
|
||||
border-bottom: var(--border-width) solid var(--color-border);
|
||||
background: var(--color-surface-sunken);
|
||||
color: var(--color-text-muted);
|
||||
font-size: var(--font-size-small);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.mail-table-head__check {
|
||||
width: var(--table-col-check);
|
||||
height: calc(var(--size-table-head) - var(--border-width));
|
||||
justify-content: flex-start;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,242 @@
|
||||
<script setup lang="ts">
|
||||
// 목록 보기 표의 한 행(38) — 고르기 · 별 · 보낸 사람 · [메일함] · 제목(링크 + 첨부 클립) · 날짜 · 크기 (디자인 원본 MainList · BulkSelect)
|
||||
// - 행 어디를 눌러도 메일이 열리고(open), 제목은 키보드로 갈 수 있는 링크다. 고르기 · 별 버튼은 따로 동작한다
|
||||
// - Ctrl(⌘)+클릭은 하나씩, Shift+클릭은 범위로 고른다 — 메일을 열지 않고, 제목 링크를 눌러도 새 탭 · 새 창으로 열지 않는다
|
||||
// - 보낸 사람 자리 · 읽음 표시 · 누르면 갈 곳은 메일함 규칙을 따른다 (mail-list-row.ts)
|
||||
// 칸 폭 · 칸 공통 여백은 표(MessageTable)가 정한다.
|
||||
import { computed } from 'vue'
|
||||
import type { RouteLocationRaw } from 'vue-router'
|
||||
|
||||
import AppIcon from '@/components/AppIcon.vue'
|
||||
import MailCheckbox from '@/components/mail/MailCheckbox.vue'
|
||||
import { MAIL_BULK_TEXT, MAIL_NO_SUBJECT } from '@/shared/constants/mail'
|
||||
import type { MailboxRole, MessageSummary } from '@/shared/types/mail'
|
||||
import { formatFileSize, formatMailTableDate } from '@/shared/utils/format'
|
||||
import { isDraftRole, isRowUnread, rowWho } from '@/shared/utils/mail-list-row'
|
||||
|
||||
interface Props {
|
||||
message: MessageSummary
|
||||
to: RouteLocationRaw
|
||||
role: MailboxRole | null
|
||||
/** 전체 메일함 검색 결과 — 메일함 칸이 생기고, 보낸편지함 · 임시보관함의 메일은 "받는 사람: 이름" */
|
||||
mixed?: boolean
|
||||
mailboxName?: string | null
|
||||
checked?: boolean
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'open', location: RouteLocationRaw): void
|
||||
(e: 'toggle-flag', id: string): void
|
||||
/** 고르기 · 풀기 — range 면 Shift(범위) */
|
||||
(e: 'check', id: string, range: boolean): void
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
mixed: false,
|
||||
mailboxName: null,
|
||||
checked: false,
|
||||
})
|
||||
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
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))
|
||||
const draft = computed(() => isDraftRole(props.role))
|
||||
|
||||
/** 행 어디를 눌러도 연다 — 버튼을 누른 경우 · 제목 링크(스스로 연다) · 글자를 고르는 중이면 그대로 둔다 */
|
||||
function onRowClick(event: MouseEvent) {
|
||||
const target = event.target instanceof Element ? event.target : null
|
||||
if (target?.closest('button')) return
|
||||
if (event.ctrlKey || event.metaKey || event.shiftKey) {
|
||||
event.preventDefault()
|
||||
emit('check', props.message.id, event.shiftKey)
|
||||
return
|
||||
}
|
||||
if (target?.closest('a')) return
|
||||
if (window.getSelection()?.toString()) return
|
||||
emit('open', props.to)
|
||||
}
|
||||
|
||||
/** Shift + 누름이 글자를 고르지 않게 한다 */
|
||||
function onMouseDown(event: MouseEvent) {
|
||||
if (event.shiftKey) event.preventDefault()
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<tr
|
||||
:class="['mail-table__row', { 'is-unread': unread, 'is-checked': checked }]"
|
||||
:data-row-id="message.id"
|
||||
@click="onRowClick"
|
||||
@mousedown="onMouseDown"
|
||||
>
|
||||
<td class="mail-table__check">
|
||||
<!-- 폭 32px · 행 높이를 채우는 고르기 상자 — 44px 기준의 의도된 예외 (디자인 README "3단계", 별 칸과 같다) -->
|
||||
<MailCheckbox
|
||||
class="mail-table__check-button"
|
||||
:checked="checked"
|
||||
:label="MAIL_BULK_TEXT.selectOne(subject)"
|
||||
@toggle="(event: MouseEvent) => emit('check', message.id, event.shiftKey)"
|
||||
/>
|
||||
</td>
|
||||
<td class="mail-table__star">
|
||||
<!-- 폭 32px · 행 높이를 채우는 별 — 44px 기준의 의도된 예외 (디자인 README, 사용자 확정) -->
|
||||
<button
|
||||
type="button"
|
||||
:class="['mail-table__star-button', { 'is-flagged': message.isFlagged }]"
|
||||
aria-label="중요 표시"
|
||||
:aria-pressed="message.isFlagged"
|
||||
@click="emit('toggle-flag', message.id)"
|
||||
>
|
||||
<AppIcon
|
||||
name="star"
|
||||
:size="14"
|
||||
:filled="message.isFlagged"
|
||||
/>
|
||||
</button>
|
||||
</td>
|
||||
<td :class="['mail-table__who', { 'is-placeholder': who.placeholder }]">
|
||||
<span
|
||||
v-if="unread"
|
||||
class="visually-hidden"
|
||||
>안 읽음, </span>{{ who.text }}
|
||||
</td>
|
||||
<td
|
||||
v-if="mixed"
|
||||
class="mail-table__mailbox"
|
||||
>
|
||||
{{ mailboxName ?? '' }}
|
||||
</td>
|
||||
<td class="mail-table__subject">
|
||||
<div class="mail-table__subject-line">
|
||||
<RouterLink
|
||||
:class="['mail-table__link', { 'is-placeholder': draft && !message.subject }]"
|
||||
:to="to"
|
||||
:data-message-id="message.id"
|
||||
>
|
||||
{{ subject }}
|
||||
</RouterLink>
|
||||
<AppIcon
|
||||
v-if="message.hasAttachment"
|
||||
name="paperclip"
|
||||
:size="13"
|
||||
:stroke-width="2"
|
||||
label="첨부 있음"
|
||||
class="mail-table__clip"
|
||||
/>
|
||||
</div>
|
||||
</td>
|
||||
<td class="mail-table__date">
|
||||
<time :datetime="message.receivedAt">{{ formatMailTableDate(message.receivedAt) }}</time>
|
||||
</td>
|
||||
<td class="mail-table__size">
|
||||
{{ formatFileSize(message.size) }}
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.mail-table__row {
|
||||
height: var(--size-table-row);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.mail-table__row td {
|
||||
border-bottom: var(--border-width) solid var(--color-border-subtle);
|
||||
}
|
||||
|
||||
.mail-table__row:hover {
|
||||
background: var(--color-row-hover);
|
||||
}
|
||||
|
||||
.mail-table__row:focus-within {
|
||||
background: var(--color-brand-subtle);
|
||||
}
|
||||
|
||||
/* 고른 행 (디자인 원본 BulkSelect) */
|
||||
.mail-table__row.is-checked {
|
||||
background: var(--color-row-checked);
|
||||
}
|
||||
|
||||
.mail-table__row.is-checked:hover {
|
||||
background: var(--color-row-checked-hover);
|
||||
}
|
||||
|
||||
.mail-table__check-button {
|
||||
width: var(--table-col-check);
|
||||
height: calc(var(--size-table-row) - var(--border-width));
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.mail-table__star-button {
|
||||
width: var(--size-compact-control);
|
||||
height: calc(var(--size-table-row) - var(--border-width));
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
border-radius: var(--radius-xs);
|
||||
background: transparent;
|
||||
color: var(--color-icon-muted);
|
||||
}
|
||||
|
||||
.mail-table__star-button.is-flagged {
|
||||
color: var(--color-star);
|
||||
}
|
||||
|
||||
.mail-table__who {
|
||||
font-weight: 450;
|
||||
color: var(--color-text-strong);
|
||||
}
|
||||
|
||||
/* 전체 메일함 검색 결과의 메일함 칸 (디자인 원본 ListStates) */
|
||||
.mail-table__mailbox {
|
||||
font-size: var(--font-size-small);
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.mail-table__subject-line {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-6);
|
||||
}
|
||||
|
||||
.mail-table__link {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
color: var(--color-text);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.mail-table__link:hover {
|
||||
color: var(--color-text);
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.mail-table__clip {
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
/* 비어 있는 값 — "(받는 사람 없음)" · "(제목 없음)" (디자인 원본 ComposeAssist #667085) */
|
||||
.mail-table__who.is-placeholder,
|
||||
.mail-table__link.is-placeholder {
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
/* 안 읽음 — 뱃지 대신 굵기·색으로만 구분 */
|
||||
.mail-table__row.is-unread .mail-table__who,
|
||||
.mail-table__row.is-unread .mail-table__link {
|
||||
font-weight: 650;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.mail-table__row.is-unread .mail-table__date {
|
||||
font-weight: 600;
|
||||
color: var(--color-text);
|
||||
}
|
||||
</style>
|
||||
@@ -1,6 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
// 전역 셸 — 왼쪽 앱 레일 + 상단 바 + 모듈 영역(slot).
|
||||
// 모든 모듈 레이아웃은 이 셸로 감싼다: <AppShell module-id="mail"> … </AppShell>
|
||||
// 모듈의 검색 칸은 search 슬롯으로 상단 바에 넣는다: <template #search><MailSearchBox /></template> (없으면 비활성 통합 검색)
|
||||
import { computed } from 'vue'
|
||||
|
||||
import AppRail from './AppRail.vue'
|
||||
@@ -20,7 +21,14 @@ const moduleLabel = computed(() => findAppModule(props.moduleId).label)
|
||||
<div class="app-shell">
|
||||
<AppRail :active-module="moduleId" />
|
||||
<div class="app-shell__main">
|
||||
<AppTopBar :title="moduleLabel" />
|
||||
<AppTopBar :title="moduleLabel">
|
||||
<template
|
||||
v-if="$slots.search"
|
||||
#search
|
||||
>
|
||||
<slot name="search" />
|
||||
</template>
|
||||
</AppTopBar>
|
||||
<div class="app-shell__module">
|
||||
<slot />
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
// 상단 바 — 모듈 이름 · 통합 검색 · 로그인한 직원 · 로그아웃
|
||||
// 상단 바 — 모듈 이름 · 검색 칸 · 로그인한 직원 · 로그아웃
|
||||
// 검색 칸은 모듈이 search 슬롯으로 넣는다(메일: "메일 검색" — MailSearchBox). 넣지 않은 모듈은 비활성 "통합 검색"(준비 중)이다.
|
||||
import { computed, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
@@ -39,23 +40,25 @@ async function onLogout() {
|
||||
<header class="top-bar">
|
||||
<span class="top-bar__title">{{ title }}</span>
|
||||
|
||||
<!-- 통합 검색은 1단계 범위 밖이다 (docs/mail/api-contract.md "이 단계에서 하지 않는 것") -->
|
||||
<div
|
||||
class="top-bar__search"
|
||||
:title="MODULE_NOT_READY_LABEL"
|
||||
>
|
||||
<AppIcon
|
||||
name="search"
|
||||
class="top-bar__search-icon"
|
||||
/>
|
||||
<input
|
||||
type="search"
|
||||
class="top-bar__search-input"
|
||||
:aria-label="`통합 검색 (${MODULE_NOT_READY_LABEL})`"
|
||||
placeholder="통합 검색"
|
||||
disabled
|
||||
<slot name="search">
|
||||
<!-- 모듈을 가로지르는 통합 검색은 아직 없다 (docs/mail/api-contract.md "이 단계에서 하지 않는 것") -->
|
||||
<div
|
||||
class="top-bar__search"
|
||||
:title="MODULE_NOT_READY_LABEL"
|
||||
>
|
||||
</div>
|
||||
<AppIcon
|
||||
name="search"
|
||||
class="top-bar__search-icon"
|
||||
/>
|
||||
<input
|
||||
type="search"
|
||||
class="top-bar__search-input"
|
||||
:aria-label="`통합 검색 (${MODULE_NOT_READY_LABEL})`"
|
||||
placeholder="통합 검색"
|
||||
disabled
|
||||
>
|
||||
</div>
|
||||
</slot>
|
||||
|
||||
<div
|
||||
v-if="authStore.user"
|
||||
@@ -109,7 +112,7 @@ async function onLogout() {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-8);
|
||||
height: 2.5rem;
|
||||
height: var(--size-search-box);
|
||||
padding: 0 var(--space-12);
|
||||
border: var(--border-width) solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
import { computed, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
import { notifyApiError } from './useApi'
|
||||
import { useMailMoveFeedback } from './useMailMoveFeedback'
|
||||
import type { Mailbox } from '@/shared/types/mail'
|
||||
import { listLocationOf } from '@/shared/utils/mail-list-source'
|
||||
import { useMailStore, type MailBulkResult } from '@/stores/mail.store'
|
||||
|
||||
// 여러 통 처리 — 도구 줄의 단추가 하는 일 (계약 2절 "여러 통 한꺼번에" · 디자인 README "3단계 › 여러 통 처리")
|
||||
// - 읽음 · 안 읽음 · 중요 표시: 목록이 바로 바뀌므로 알리지 않는다 (서버가 바꾸지 못한 메일만 되돌린다)
|
||||
// - 이동 · 삭제(휴지통 밖 — 휴지통으로 옮긴다) · 영구 삭제(휴지통에서만, 확인 대화상자를 거친 뒤): 알림은 useMailMoveFeedback
|
||||
// - 열어 둔 메일을 옮기거나 지워 목록에서 빠졌으면 목록 주소로 바꿔 끼운다 (본문 칸이 없는 메일을 붙들지 않게).
|
||||
// 끝났을 때 다른 목록으로 옮겨 가 있으면(stale) 그 목록에 기대어 움직이지 않는다
|
||||
|
||||
export function useBulkMailActions() {
|
||||
const mailStore = useMailStore()
|
||||
const router = useRouter()
|
||||
const feedback = useMailMoveFeedback()
|
||||
|
||||
/** 옮기기 · 지우기를 하는 중 — 끝날 때까지 다시 누르지 못한다 (읽음 · 중요 표시는 막지 않는다) */
|
||||
const busy = ref<boolean>(false)
|
||||
|
||||
/** 고른 메일이 모두 중요 표시 — 단추가 "중요 해제"가 된다 */
|
||||
const allFlagged = computed<boolean>(
|
||||
() => mailStore.checkedMessages.length > 0 && mailStore.checkedMessages.every((message) => message.isFlagged),
|
||||
)
|
||||
/** 휴지통을 보는 중 — "삭제"가 "영구 삭제"가 된다 (휴지통 안의 검색 포함) */
|
||||
const inTrash = computed<boolean>(() => mailStore.currentMailbox?.role === 'trash')
|
||||
/** 이동 메뉴 — 지금 메일함 · 임시보관함 · 휴지통을 뺀 메일함 (메일함 패널 순서) */
|
||||
const moveTargets = computed<Mailbox[]>(() =>
|
||||
mailStore.orderedMailboxes
|
||||
.map((node) => node.mailbox)
|
||||
.filter(
|
||||
(mailbox) =>
|
||||
mailbox.id !== mailStore.currentMailboxId && mailbox.role !== 'drafts' && mailbox.role !== 'trash',
|
||||
),
|
||||
)
|
||||
|
||||
async function markRead(isRead: boolean): Promise<void> {
|
||||
await mailStore.patchChecked({ isRead })
|
||||
}
|
||||
|
||||
/** 중요 표시 — 모두 중요면 해제, 아니면 모두 중요로 */
|
||||
async function toggleFlagged(): Promise<void> {
|
||||
await mailStore.patchChecked({ isFlagged: !allFlagged.value })
|
||||
}
|
||||
|
||||
/** 열어 둔 메일을 옮기거나 지워 목록에서 빠졌으면 목록 주소로 (기록을 쌓지 않는다) */
|
||||
async function leaveRemovedMessage(result: MailBulkResult): Promise<void> {
|
||||
const openId = mailStore.selectedId
|
||||
const source = mailStore.listSource
|
||||
if (result.stale || !openId || !source || !result.doneIds.includes(openId)) return
|
||||
if (mailStore.messages.some((message) => message.id === openId)) return
|
||||
await router.replace(listLocationOf(source))
|
||||
}
|
||||
|
||||
async function moveTo(mailbox: Mailbox): Promise<void> {
|
||||
if (busy.value || mailStore.checkedCount === 0) return
|
||||
busy.value = true
|
||||
try {
|
||||
const result = await mailStore.moveChecked(mailbox.id)
|
||||
await leaveRemovedMessage(result)
|
||||
feedback.announceMove(result, mailbox.name)
|
||||
} finally {
|
||||
busy.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** 휴지통 밖의 삭제 — 휴지통으로 옮긴다 (DELETE 가 아니다 — 그사이 다른 창이 휴지통으로 옮긴 메일을 영구 삭제하지 않게) */
|
||||
async function moveToTrash(): Promise<void> {
|
||||
if (busy.value || mailStore.checkedCount === 0) return
|
||||
const trash = await mailStore.resolveMailboxByRole('trash')
|
||||
if ('error' in trash) {
|
||||
notifyApiError(trash.error)
|
||||
return
|
||||
}
|
||||
await moveTo(trash.mailbox)
|
||||
}
|
||||
|
||||
/** 영구 삭제 — 확인 대화상자에서 「영구 삭제」를 누른 뒤에만 부른다 */
|
||||
async function purge(): Promise<void> {
|
||||
if (busy.value || mailStore.checkedCount === 0) return
|
||||
busy.value = true
|
||||
try {
|
||||
const result = await mailStore.purgeChecked()
|
||||
await leaveRemovedMessage(result)
|
||||
feedback.announcePurge(result)
|
||||
} finally {
|
||||
busy.value = false
|
||||
}
|
||||
}
|
||||
|
||||
return { busy, allFlagged, inTrash, moveTargets, markRead, toggleFlagged, moveTo, moveToTrash, purge }
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { effectScope, nextTick, type EffectScope } from 'vue'
|
||||
|
||||
import { useDraftTrash } from '@/composables/useDraftTrash'
|
||||
import type { Mailbox, MessageSummary } from '@/shared/types/mail'
|
||||
import { useAuthStore } from '@/stores/auth.store'
|
||||
import { useMailStore } from '@/stores/mail.store'
|
||||
import { useToastStore, type ToastAction } from '@/stores/toast.store'
|
||||
import { flushPromises, installMockApi, ok, restoreApiAdapter, type MockRequest } from '@/test-utils/mock-api'
|
||||
|
||||
// 쓰기 화면의 휴지통 · 되돌리기 (디자인 README "2단계 › 임시 저장") — 되돌리기는 알림을 띄운 세션에서만 움직인다
|
||||
|
||||
const USER = { email: 'ara.kim@arasystem.test', name: '김아라' }
|
||||
|
||||
function mailbox(id: string, role: Mailbox['role'], name: string): Mailbox {
|
||||
return { id, name, role, parentId: null, sortOrder: 0, totalCount: 1, unreadCount: 0 }
|
||||
}
|
||||
|
||||
const MAILBOXES = [mailbox('drafts', 'drafts', '임시보관함'), mailbox('trash', 'trash', '휴지통')]
|
||||
|
||||
function summary(id: string): MessageSummary {
|
||||
return {
|
||||
id,
|
||||
threadId: `t-${id}`,
|
||||
mailboxIds: ['drafts'],
|
||||
from: null,
|
||||
to: [],
|
||||
subject: '',
|
||||
preview: '',
|
||||
receivedAt: '2026-10-27T01:00:00Z',
|
||||
isRead: true,
|
||||
isFlagged: false,
|
||||
hasAttachment: false,
|
||||
size: 1,
|
||||
}
|
||||
}
|
||||
|
||||
describe('useDraftTrash (쓰기 화면의 휴지통)', () => {
|
||||
let scope: EffectScope
|
||||
let requests: MockRequest[]
|
||||
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
useAuthStore().startSession(USER)
|
||||
scope = effectScope()
|
||||
requests = installMockApi(({ method, url, data }) => {
|
||||
if (url === '/mailboxes') return ok({ items: MAILBOXES, pageInfo: { hasNext: false, nextCursor: null, total: 2 } })
|
||||
if (method === 'PATCH') return ok({ ...summary('d1'), mailboxIds: [(data as { mailboxId: string }).mailboxId] })
|
||||
return ok({ usedBytes: 0, limitBytes: null, attachmentMaxBytes: 1, attachmentTotalMaxBytes: 1 })
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
scope.stop()
|
||||
useToastStore().clear()
|
||||
restoreApiAdapter()
|
||||
})
|
||||
|
||||
async function discard(): Promise<ToastAction> {
|
||||
useMailStore().mailboxes = MAILBOXES
|
||||
const trash = scope.run(() => useDraftTrash())
|
||||
if (!trash) throw new Error('scope')
|
||||
await trash.discard('d1')
|
||||
const action = useToastStore().toasts[0]?.action
|
||||
if (!action || !('run' in action)) throw new Error('되돌리기 단추가 없다')
|
||||
return action
|
||||
}
|
||||
|
||||
const patchesTo = (mailboxId: string) =>
|
||||
requests.filter(
|
||||
(request) => request.method === 'PATCH' && (request.data as { mailboxId?: string }).mailboxId === mailboxId,
|
||||
)
|
||||
|
||||
it('휴지통으로 옮기고(PATCH) [되돌리기] — 누르면 임시보관함으로 되돌린다', async () => {
|
||||
const action = await discard()
|
||||
expect(patchesTo('trash')).toHaveLength(1)
|
||||
expect(useToastStore().toasts[0]?.message).toBe('임시 저장한 메일을 휴지통으로 옮겼습니다.')
|
||||
|
||||
if ('run' in action) action.run()
|
||||
await flushPromises()
|
||||
|
||||
expect(patchesTo('drafts')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('그사이 로그아웃했으면(다시 로그인했어도) 되돌리기는 아무것도 하지 않고, 알림도 닫힌다', async () => {
|
||||
const action = await discard()
|
||||
|
||||
useAuthStore().clearAuth()
|
||||
useAuthStore().startSession(USER)
|
||||
await nextTick()
|
||||
expect(useToastStore().toasts).toEqual([])
|
||||
|
||||
if ('run' in action) action.run()
|
||||
await flushPromises()
|
||||
expect(patchesTo('drafts')).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
@@ -1,4 +1,4 @@
|
||||
import { getErrorMessage, notifyApiError, toApiError } from './useApi'
|
||||
import { notifyApiError, toApiError } from './useApi'
|
||||
import { useMail } from './useMail'
|
||||
import { useToast } from './useToast'
|
||||
import { DRAFT_DISCARDED_MESSAGE, DRAFT_RESTORED_MESSAGE, DRAFT_UNDO_LABEL } from '@/shared/constants/mail'
|
||||
@@ -21,18 +21,14 @@ export function useDraftTrash() {
|
||||
const toast = useToast()
|
||||
|
||||
async function moveToTrash(draftId: string): Promise<DraftTrashResult> {
|
||||
let trash = mailStore.mailboxByRole('trash')
|
||||
if (!trash) {
|
||||
// 메일함을 아직 못 받았다(받는 중 · 실패) — 보이는 패널은 그대로 둔 채 받아 본다 (스토어는 알리지 않는다)
|
||||
const loadError = await mailStore.loadMailboxes({ background: true })
|
||||
trash = mailStore.mailboxByRole('trash')
|
||||
if (!trash) {
|
||||
notifyApiError(loadError ?? { code: 'BIZ_001', message: getErrorMessage('BIZ_001') })
|
||||
return 'failed'
|
||||
}
|
||||
// 메일함을 아직 못 받았으면(받는 중 · 실패) 보이는 패널은 그대로 둔 채 받아 본다 (스토어는 알리지 않는다)
|
||||
const trash = await mailStore.resolveMailboxByRole('trash')
|
||||
if ('error' in trash) {
|
||||
notifyApiError(trash.error)
|
||||
return 'failed'
|
||||
}
|
||||
try {
|
||||
await updateMessage(draftId, { mailboxId: trash.id }, { silent: ['RES_001'] })
|
||||
await updateMessage(draftId, { mailboxId: trash.mailbox.id }, { silent: ['RES_001'] })
|
||||
return 'moved'
|
||||
} catch (error) {
|
||||
// 그 밖의 실패는 인터셉터가 알렸다
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
import { nextTick, onBeforeUnmount, onMounted, ref, watch, type Ref } from 'vue'
|
||||
|
||||
import { listKeyOf } from '@/shared/utils/mail-list-source'
|
||||
import { useMailStore } from '@/stores/mail.store'
|
||||
|
||||
// 새 메일 확인으로 목록을 맞출 때의 스크롤 · 알림 줄 (디자인 README "3단계 › 새 메일")
|
||||
// - 맨 위를 보고 있으면 새 메일이 그냥 위에 생긴다
|
||||
// - 목록을 내려 본 채면 보던 행을 같은 자리에 두고(위에 행이 생겨도 화면이 밀리지 않게), 머리 아래에 "새 메일 N통 · 맨 위로"를 띄운다
|
||||
// — 누르거나 스스로 맨 위로 올라가면 사라진다. 다른 목록으로 바꾸면 사라진다
|
||||
// 넓은 화면은 목록 칸이, 좁은 화면은 창 전체가 스크롤된다 (AppShell) — 둘 다 같은 규칙이다.
|
||||
// 행은 data-row-id 를 단다 (분할 보기 행 · 표 행).
|
||||
|
||||
/** 이만큼(px)보다 덜 내려갔으면 맨 위로 본다 */
|
||||
const SCROLL_TOP_TOLERANCE = 1
|
||||
|
||||
interface RowAnchor {
|
||||
id: string
|
||||
/** 화면에서의 위쪽 위치 */
|
||||
top: number
|
||||
}
|
||||
|
||||
export function useListArrivalNotice(sectionRef: Ref<HTMLElement | null>, topRef: Ref<HTMLElement | null>) {
|
||||
const mailStore = useMailStore()
|
||||
/** 알림 줄의 "N통" — 0 이면 줄이 없다 */
|
||||
const noticeCount = ref<number>(0)
|
||||
|
||||
function sectionScrolled(): boolean {
|
||||
return (sectionRef.value?.scrollTop ?? 0) > SCROLL_TOP_TOLERANCE
|
||||
}
|
||||
|
||||
/** 목록을 내려 본 채인지 — 목록 칸이 스크롤되었거나(넓은 화면) 창이 목록 위쪽을 지나 스크롤되었다(좁은 화면) */
|
||||
function isScrolledDown(): boolean {
|
||||
const section = sectionRef.value
|
||||
if (!section) return false
|
||||
return sectionScrolled() || section.getBoundingClientRect().top < -SCROLL_TOP_TOLERANCE
|
||||
}
|
||||
|
||||
function rows(): HTMLElement[] {
|
||||
return Array.from(sectionRef.value?.querySelectorAll<HTMLElement>('[data-row-id]') ?? [])
|
||||
}
|
||||
|
||||
/** 머리 묶음 바로 아래에 보이는 첫 행 — 새 행이 위에 생겨도 이 행이 같은 자리에 남게 한다 */
|
||||
function firstVisibleRow(): RowAnchor | null {
|
||||
const line = topRef.value?.getBoundingClientRect().bottom ?? 0
|
||||
for (const row of rows()) {
|
||||
const rect = row.getBoundingClientRect()
|
||||
if (rect.bottom > line && row.dataset.rowId) return { id: row.dataset.rowId, top: rect.top }
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function keepAnchor(anchor: RowAnchor, viaSection: boolean) {
|
||||
const row = rows().find((element) => element.dataset.rowId === anchor.id)
|
||||
if (!row) return
|
||||
const delta = row.getBoundingClientRect().top - anchor.top
|
||||
if (Math.abs(delta) < SCROLL_TOP_TOLERANCE) return
|
||||
const section = sectionRef.value
|
||||
if (viaSection && section) section.scrollTop += delta
|
||||
else window.scrollBy(0, delta)
|
||||
}
|
||||
|
||||
/** 맨 위로 — 알림 줄을 치우고 맨 위의 메일에 초점을 준다 (키보드 사용자가 새 메일부터 보게) */
|
||||
function goTop() {
|
||||
const section = sectionRef.value
|
||||
noticeCount.value = 0
|
||||
if (!section) return
|
||||
section.scrollTop = 0
|
||||
if (section.getBoundingClientRect().top < 0 && typeof section.scrollIntoView === 'function') {
|
||||
section.scrollIntoView({ block: 'start' })
|
||||
}
|
||||
section.querySelector<HTMLElement>('[data-row-id] [data-message-id]')?.focus({ preventScroll: true })
|
||||
}
|
||||
|
||||
function onScroll() {
|
||||
if (noticeCount.value > 0 && !isScrolledDown()) noticeCount.value = 0
|
||||
}
|
||||
|
||||
// 목록을 바꾸기 직전(sync — 화면은 아직 옛 목록)에 보던 행을 잡아 두고, 바뀐 뒤 그 행을 같은 자리에 되돌린다
|
||||
watch(
|
||||
() => mailStore.listSync,
|
||||
(sync) => {
|
||||
if (!sync) return
|
||||
const scrolled = isScrolledDown()
|
||||
const viaSection = sectionScrolled()
|
||||
const anchor = scrolled ? firstVisibleRow() : null
|
||||
void nextTick(() => {
|
||||
if (anchor) keepAnchor(anchor, viaSection)
|
||||
else if (sectionRef.value && !scrolled) sectionRef.value.scrollTop = 0
|
||||
if (scrolled && sync.addedAbove > 0) noticeCount.value += sync.addedAbove
|
||||
})
|
||||
},
|
||||
{ flush: 'sync' },
|
||||
)
|
||||
|
||||
// 다른 목록(메일함 · 검색)으로 바꾸면 알림 줄을 치운다
|
||||
watch(
|
||||
() => (mailStore.listSource ? listKeyOf(mailStore.listSource) : null),
|
||||
() => {
|
||||
noticeCount.value = 0
|
||||
},
|
||||
)
|
||||
|
||||
onMounted(() => {
|
||||
sectionRef.value?.addEventListener('scroll', onScroll, { passive: true })
|
||||
window.addEventListener('scroll', onScroll, { passive: true })
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
sectionRef.value?.removeEventListener('scroll', onScroll)
|
||||
window.removeEventListener('scroll', onScroll)
|
||||
})
|
||||
|
||||
return { noticeCount, goTop }
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { nextTick, onBeforeUnmount, onMounted, type Ref } from 'vue'
|
||||
|
||||
import { listKeyOf } from '@/shared/utils/mail-list-source'
|
||||
import { useMailStore } from '@/stores/mail.store'
|
||||
import { useMailViewStore } from '@/stores/mail-view.store'
|
||||
|
||||
/**
|
||||
* 목록 보기의 표 위치 기억 — 메일을 열면 표가 사라지므로, 사라질 때 스크롤을 기억했다가 "목록으로" 돌아오면 되살리고
|
||||
* 보던 메일의 제목 링크로 초점을 돌려준다(키보드 사용자가 자리를 잃지 않게). 메일함 · 검색 결과마다 따로 기억한다.
|
||||
* 불러온 페이지는 스토어에 그대로 있다. 넓은 화면은 칸(section)이, 좁은 화면은 창이 스크롤된다.
|
||||
*/
|
||||
export function useListPositionMemory(sectionRef: Ref<HTMLElement | null>, enabled: () => boolean) {
|
||||
const mailStore = useMailStore()
|
||||
const viewStore = useMailViewStore()
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
const source = mailStore.listSource
|
||||
if (!enabled() || !source) return
|
||||
viewStore.rememberListPosition({
|
||||
listKey: listKeyOf(source),
|
||||
scrollTop: sectionRef.value?.scrollTop ?? 0,
|
||||
windowY: window.scrollY,
|
||||
})
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
const source = mailStore.listSource
|
||||
if (!enabled() || !source) return
|
||||
const saved = viewStore.takeListPosition(listKeyOf(source))
|
||||
const focusId = viewStore.takeReturnFocus()
|
||||
if (!saved && !focusId) return
|
||||
await nextTick()
|
||||
if (saved) {
|
||||
if (sectionRef.value) sectionRef.value.scrollTop = saved.scrollTop
|
||||
if (saved.windowY > 0) window.scrollTo({ top: saved.windowY })
|
||||
}
|
||||
if (focusId) {
|
||||
const links = sectionRef.value?.querySelectorAll<HTMLElement>('[data-message-id]') ?? []
|
||||
const link = Array.from(links).find((element) => element.dataset.messageId === focusId)
|
||||
link?.focus({ preventScroll: Boolean(saved) })
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -13,10 +13,15 @@ import type {
|
||||
DraftSaved,
|
||||
MailQuota,
|
||||
MailSignature,
|
||||
MailState,
|
||||
Mailbox,
|
||||
MessageDeletionRequest,
|
||||
MessageDeletionResult,
|
||||
MessageDetail,
|
||||
MessageListQuery,
|
||||
MessageSummary,
|
||||
MessagesPatch,
|
||||
MessagesPatched,
|
||||
RecipientSuggestion,
|
||||
RecipientSuggestionQuery,
|
||||
SaveSignaturePayload,
|
||||
@@ -64,7 +69,7 @@ function startBrowserDownload(url: string): void {
|
||||
anchor.remove()
|
||||
}
|
||||
|
||||
// 메일 API Composable — docs/mail/api-contract.md 2절의 모든 엔드포인트
|
||||
// 메일 API Composable — docs/mail/api-contract.md 2절의 엔드포인트 (DELETE /messages/{id} 만 웹은 쓰지 않는다 — 아래 updateMessage 뒤 설명)
|
||||
// 인터셉터가 { success, data } 를 언래핑하므로 각 호출의 응답 타입(R)을 data 타입으로 지정한다.
|
||||
export function useMail() {
|
||||
const api = useApi()
|
||||
@@ -78,7 +83,18 @@ export function useMail() {
|
||||
return api.get<MailQuota, MailQuota>('/mail-quota', options)
|
||||
}
|
||||
|
||||
/** 받은 시각 내림차순. cursor 는 pageInfo.nextCursor 를 해석하지 말고 그대로 넘긴다 */
|
||||
/**
|
||||
* 새 메일 확인 — 메일함이나 메일이 하나라도 바뀌면 state 가 달라진다 (계약 2절 "새 메일 확인").
|
||||
* 메일을 읽지 않는 가벼운 조회라 화면이 보이는 동안 1분마다 부른다 (useMailStatePolling)
|
||||
*/
|
||||
function getMailState(options?: MailRequestOptions): Promise<MailState> {
|
||||
return api.get<MailState, MailState>('/mail-state', options)
|
||||
}
|
||||
|
||||
/**
|
||||
* 받은 시각 내림차순. cursor 는 pageInfo.nextCursor 를 해석하지 말고 그대로 넘긴다.
|
||||
* 검색(q)은 mailboxId 가 있으면 그 메일함, 없으면 휴지통 · 스팸메일함을 뺀 모든 메일함에서 찾는다
|
||||
*/
|
||||
function listMessages(
|
||||
query: MessageListQuery,
|
||||
options?: MailRequestOptions,
|
||||
@@ -102,9 +118,26 @@ export function useMail() {
|
||||
return api.patch<MessageSummary, MessageSummary>(`/messages/${segment(id)}`, changes, options)
|
||||
}
|
||||
|
||||
/** 휴지통이 아니면 휴지통으로 옮기고, 휴지통이면 영구 삭제한다 (204) */
|
||||
async function deleteMessage(id: string, options?: MailRequestOptions): Promise<void> {
|
||||
await api.delete(`/messages/${segment(id)}`, options)
|
||||
// DELETE /messages/{id} 는 부르지 않는다 — 휴지통이면 확인 없이 영구 삭제하므로, 그사이 다른 창이 휴지통으로 옮긴 메일을
|
||||
// 지울 수 있다. 삭제는 휴지통으로 옮기기(updateMessages { mailboxId }), 영구 삭제는 휴지통에서 확인한 뒤 deleteMessages 다.
|
||||
|
||||
/**
|
||||
* 여러 통의 읽음 · 중요 표시 · 이동 (PATCH /messages) — ids 는 100개까지 (부르는 쪽이 나눠 보낸다).
|
||||
* 503 MAIL_001 이면 일부만 바뀌었을 수 있다 — 같은 요청을 다시 보내도 결과가 같다(멱등)
|
||||
*/
|
||||
function updateMessages(patch: MessagesPatch, options?: MailRequestOptions): Promise<MessagesPatched> {
|
||||
return api.patch<MessagesPatched, MessagesPatched>('/messages', patch, options)
|
||||
}
|
||||
|
||||
/**
|
||||
* 휴지통에서 영구 삭제 (POST /message-deletions) — 휴지통에 있는 메일만 다룬다. ids 는 100개까지.
|
||||
* 확인한 상태 그대로일 때만 지우고, 계속 어긋나면 아무것도 지우지 않고 503 MAIL_001
|
||||
*/
|
||||
function deleteMessages(
|
||||
request: MessageDeletionRequest,
|
||||
options?: MailRequestOptions,
|
||||
): Promise<MessageDeletionResult> {
|
||||
return api.post<MessageDeletionResult, MessageDeletionResult>('/message-deletions', request, options)
|
||||
}
|
||||
|
||||
// ── 임시 저장 (계약 2절 "임시 저장") ─────────────────────
|
||||
@@ -218,10 +251,12 @@ export function useMail() {
|
||||
return {
|
||||
listMailboxes,
|
||||
getQuota,
|
||||
getMailState,
|
||||
listMessages,
|
||||
getMessage,
|
||||
updateMessage,
|
||||
deleteMessage,
|
||||
updateMessages,
|
||||
deleteMessages,
|
||||
sendMessage,
|
||||
uploadAttachment,
|
||||
attachmentDownloadUrl,
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import { computed } from 'vue'
|
||||
import type { RouteLocationRaw } from 'vue-router'
|
||||
|
||||
import type { Mailbox, MailboxRole, MessageSummary } from '@/shared/types/mail'
|
||||
import { isDraftRole, mailboxOfMessage, whoColumnLabel } from '@/shared/utils/mail-list-row'
|
||||
import { isAllMailboxSearch, messageLocationOf } from '@/shared/utils/mail-list-source'
|
||||
import { useMailStore } from '@/stores/mail.store'
|
||||
|
||||
/**
|
||||
* 보이는 목록의 행마다 필요한 값 — 분할 보기 행(MessageListItem)과 목록 보기 표(MessageTable)가 함께 쓴다.
|
||||
* - 메일함 목록 · 한 메일함 안의 검색: 모든 행이 그 메일함의 규칙(임시보관함 · 보낸편지함)을 따른다
|
||||
* - 전체 메일함 검색: 행마다 그 메일의 메일함 — 메일함 이름을 함께 보인다 (디자인 README "3단계 › 검색")
|
||||
* - 누르면 갈 곳: 임시 저장본은 쓰기 화면에서 이어 쓰고, 그 밖은 지금 목록 안의 메일 주소 (검색 결과면 /mail/search/<id>?…)
|
||||
*/
|
||||
export function useMailListRows() {
|
||||
const mailStore = useMailStore()
|
||||
|
||||
/** 전체 메일함에서 찾은 결과 — 행마다 메일함이 다르다 */
|
||||
const mixed = computed<boolean>(() => isAllMailboxSearch(mailStore.listSource))
|
||||
/** 보낸 사람 칸 머리 — 보낸편지함 · 임시보관함(한 메일함일 때)은 받는 사람 */
|
||||
const whoHeader = computed<string>(() => whoColumnLabel(mailStore.currentMailbox?.role ?? null, mixed.value))
|
||||
|
||||
function mailboxOf(message: MessageSummary): Mailbox | null {
|
||||
return mixed.value ? mailboxOfMessage(message, mailStore.mailboxes) : mailStore.currentMailbox
|
||||
}
|
||||
|
||||
function roleOf(message: MessageSummary): MailboxRole | null {
|
||||
return mailboxOf(message)?.role ?? null
|
||||
}
|
||||
|
||||
/** 행에 보일 메일함 이름 — 전체 메일함 검색 결과에서만 */
|
||||
function mailboxNameOf(message: MessageSummary): string | null {
|
||||
return mixed.value ? (mailboxOf(message)?.name ?? null) : null
|
||||
}
|
||||
|
||||
function locationOf(message: MessageSummary): RouteLocationRaw {
|
||||
if (isDraftRole(roleOf(message))) return { name: 'mail-compose', query: { draft: message.id } }
|
||||
const source = mailStore.listSource
|
||||
return source ? messageLocationOf(source, message.id) : { name: 'mail' }
|
||||
}
|
||||
|
||||
return { mixed, whoHeader, roleOf, mailboxNameOf, locationOf }
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { useToast } from './useToast'
|
||||
import { DRAFT_UNDO_LABEL } from '@/shared/constants/mail'
|
||||
import { movedMessageText, purgedMessageText, restoredMessageText, type BulkOrigin } from '@/shared/utils/mail-bulk'
|
||||
import { useMailStore, type BulkMoveResult, type MailBulkResult } from '@/stores/mail.store'
|
||||
|
||||
/**
|
||||
* 옮기기 · 영구 삭제의 결과 알림 (디자인 README "3단계 › 여러 통 처리") — 도구 줄과 읽기 화면의 「삭제」가 함께 쓴다.
|
||||
* - "메일 N통을 X으로 옮겼습니다." + [되돌리기](10초 — 원래 메일함별로 다시 옮기고 "메일 N통을 되돌렸습니다.")
|
||||
* - "메일 N통을 영구 삭제했습니다."
|
||||
* - 일부만 처리했으면 뒤에 "N통은 그사이 옮겨졌거나 지워져 그대로 두었습니다." (하나도 못 했으면 그 문장만)
|
||||
* - 하나도 보내지 못했으면(요청 실패 — 인터셉터가 알렸다) 알리지 않는다
|
||||
* 되돌리기는 알림을 띄운 세션에서만 움직인다 (useToast)
|
||||
*/
|
||||
export function useMailMoveFeedback() {
|
||||
const mailStore = useMailStore()
|
||||
const toast = useToast()
|
||||
|
||||
async function undo(origins: readonly BulkOrigin[]): Promise<void> {
|
||||
const result = await mailStore.restoreMoved(origins)
|
||||
const done = result.doneIds.length
|
||||
const skipped = result.skippedIds.length
|
||||
if (done > 0 || skipped > 0) toast.success(restoredMessageText(done, skipped))
|
||||
}
|
||||
|
||||
function announceMove(result: BulkMoveResult, mailboxName: string) {
|
||||
const done = result.doneIds.length
|
||||
const skipped = result.skippedIds.length
|
||||
if (done === 0 && skipped === 0) return
|
||||
const origins = result.origins
|
||||
toast.success(
|
||||
movedMessageText(done, mailboxName, skipped),
|
||||
origins.length > 0 ? { label: DRAFT_UNDO_LABEL, run: () => void undo(origins) } : undefined,
|
||||
)
|
||||
}
|
||||
|
||||
function announcePurge(result: MailBulkResult) {
|
||||
const done = result.doneIds.length
|
||||
const skipped = result.skippedIds.length
|
||||
if (done > 0 || skipped > 0) toast.success(purgedMessageText(done, skipped))
|
||||
}
|
||||
|
||||
return { announceMove, announcePurge }
|
||||
}
|
||||
@@ -1,34 +1,55 @@
|
||||
import type { RouteLocationRaw } from 'vue-router'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
import {
|
||||
listLocationOf,
|
||||
messageLocationOf,
|
||||
type MailListSource,
|
||||
} from '@/shared/utils/mail-list-source'
|
||||
|
||||
/**
|
||||
* 메일 화면 사이 이동 규칙 — 브라우저 기록(history)을 깔끔하게 유지한다.
|
||||
* - 이전·다음 메일은 기록을 바꿔 끼운다: 몇 통을 넘겨 봐도 "뒤로"는 목록으로 간다
|
||||
* - "목록으로"는 바로 앞 기록이 그 목록이면 뒤로 가기, 아니면 목록으로 바꿔 끼운다
|
||||
* - 없는 주소(404 RES_001)에서 다른 메일함으로 빠져나갈 때도 바꿔 끼운다: 뒤로 가기로 그 주소에 다시 오지 않게
|
||||
* 목록은 메일함 또는 검색 결과다 (mail-list-source.ts) — 검색 결과에서 연 메일은 /mail/search/<메일 id>?q=…&in=…
|
||||
*/
|
||||
export function useMailNavigation() {
|
||||
const router = useRouter()
|
||||
|
||||
function mailboxLocation(mailboxId: string): RouteLocationRaw {
|
||||
return { name: 'mail-mailbox', params: { mailboxId } }
|
||||
return listLocationOf({ kind: 'mailbox', mailboxId })
|
||||
}
|
||||
|
||||
function messageLocation(mailboxId: string, messageId: string): RouteLocationRaw {
|
||||
return { name: 'mail-message', params: { mailboxId, messageId } }
|
||||
return messageLocationOf({ kind: 'mailbox', mailboxId }, messageId)
|
||||
}
|
||||
|
||||
async function backToList(mailboxId: string): Promise<void> {
|
||||
const listPath = router.resolve(mailboxLocation(mailboxId)).fullPath
|
||||
if (router.options.history.state.back === listPath) {
|
||||
/** 바로 앞 기록이 그곳이면 뒤로 가기, 아니면 바꿔 끼운다 (기록을 쌓지 않는다) */
|
||||
async function backOrReplace(location: RouteLocationRaw): Promise<void> {
|
||||
const path = router.resolve(location).fullPath
|
||||
if (router.options.history.state.back === path) {
|
||||
router.back()
|
||||
return
|
||||
}
|
||||
await router.replace(mailboxLocation(mailboxId))
|
||||
await router.replace(location)
|
||||
}
|
||||
|
||||
/** 목록으로 — 메일함 목록 또는 검색 결과 */
|
||||
async function backToSourceList(source: MailListSource): Promise<void> {
|
||||
await backOrReplace(listLocationOf(source))
|
||||
}
|
||||
|
||||
async function backToList(mailboxId: string): Promise<void> {
|
||||
await backToSourceList({ kind: 'mailbox', mailboxId })
|
||||
}
|
||||
|
||||
async function showSiblingIn(source: MailListSource, messageId: string): Promise<void> {
|
||||
await router.replace(messageLocationOf(source, messageId))
|
||||
}
|
||||
|
||||
async function showSibling(mailboxId: string, messageId: string): Promise<void> {
|
||||
await router.replace(messageLocation(mailboxId, messageId))
|
||||
await showSiblingIn({ kind: 'mailbox', mailboxId }, messageId)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -39,5 +60,14 @@ export function useMailNavigation() {
|
||||
await router.replace(mailboxId ? mailboxLocation(mailboxId) : { name: 'mail' })
|
||||
}
|
||||
|
||||
return { mailboxLocation, messageLocation, backToList, showSibling, replaceWithMailbox }
|
||||
return {
|
||||
mailboxLocation,
|
||||
messageLocation,
|
||||
backOrReplace,
|
||||
backToList,
|
||||
backToSourceList,
|
||||
showSibling,
|
||||
showSiblingIn,
|
||||
replaceWithMailbox,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
import { computed } from 'vue'
|
||||
import { useRoute, useRouter, type RouteLocationRaw } from 'vue-router'
|
||||
|
||||
import { getErrorMessage, notifyApiError } from './useApi'
|
||||
import { useMailNavigation } from './useMailNavigation'
|
||||
import { SEARCH_SCOPE_ALL } from '@/shared/constants/mail'
|
||||
import type { Mailbox } from '@/shared/types/mail'
|
||||
import {
|
||||
exceedsSearchWords,
|
||||
isSearchRouteName,
|
||||
listLocationOf,
|
||||
normalizeSearchText,
|
||||
scopeMailboxIdOf,
|
||||
searchSourceFromQuery,
|
||||
type SearchListSource,
|
||||
} from '@/shared/utils/mail-list-source'
|
||||
import { useMailStore, type SearchOrigin } from '@/stores/mail.store'
|
||||
|
||||
/** 메일함 화면(목록 · 연 메일)의 라우트 */
|
||||
const MAILBOX_ROUTES: readonly string[] = ['mail-mailbox', 'mail-message']
|
||||
|
||||
function paramOf(value: unknown): string | null {
|
||||
return typeof value === 'string' && value !== '' ? value : null
|
||||
}
|
||||
|
||||
/**
|
||||
* 검색 화면 오가기 (계약 2절 "검색" · 디자인 README "3단계 › 검색")
|
||||
* - 상단 검색 칸의 Enter: 메일함에서 시작하면 그 메일함에서 찾고(휴지통 · 스팸메일함에서 시작해도 그 안에서), 검색 화면에서는 지금 범위 그대로
|
||||
* - 결과 머리의 범위 전환: "그 메일함 | 전체 메일함" — 기록을 쌓지 않고 바꿔 끼운다
|
||||
* - ✕ · Esc · "검색 닫기": 검색 전 메일함(화면)으로 — 바로 앞 기록이 그곳이면 뒤로 가기 (뒤로 가기: 메일 → 결과 → 검색 전 메일함)
|
||||
*/
|
||||
export function useMailSearchNavigation() {
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const mailStore = useMailStore()
|
||||
const { backOrReplace } = useMailNavigation()
|
||||
|
||||
const isSearching = computed<boolean>(() => isSearchRouteName(route.name))
|
||||
/** 지금 주소의 검색 — 검색 화면이 아니거나 검색어가 없으면 null */
|
||||
const routeSearch = computed<SearchListSource | null>(() =>
|
||||
isSearching.value ? searchSourceFromQuery(route.query) : null,
|
||||
)
|
||||
|
||||
/** 검색을 시작한 메일함 — 범위 줄의 "그 메일함". 모르면(주소로 바로 들어옴) 지금 범위, 그것도 없으면 기본 메일함 */
|
||||
const originMailbox = computed<Mailbox | null>(() => {
|
||||
const id = mailStore.searchOrigin?.mailboxId ?? scopeMailboxIdOf(routeSearch.value)
|
||||
const known = id ? mailStore.mailboxes.find((mailbox) => mailbox.id === id) : undefined
|
||||
return known ?? mailStore.defaultMailbox
|
||||
})
|
||||
|
||||
/** 지금 화면을 검색을 시작한 곳으로 — 쓰기 화면처럼 메일함 화면이 아니면 마지막으로 보던 메일함 화면 */
|
||||
function originOfCurrentScreen(): SearchOrigin {
|
||||
if (MAILBOX_ROUTES.includes(String(route.name))) {
|
||||
return { mailboxId: paramOf(route.params.mailboxId), location: route.fullPath }
|
||||
}
|
||||
const last = mailStore.lastBrowseLocation ? router.resolve(mailStore.lastBrowseLocation) : null
|
||||
if (last && MAILBOX_ROUTES.includes(String(last.name))) {
|
||||
return { mailboxId: paramOf(last.params.mailboxId), location: last.fullPath }
|
||||
}
|
||||
// 마지막으로 본 것이 검색 결과였다 — 그 검색을 시작한 곳
|
||||
return mailStore.searchOrigin ?? { mailboxId: mailStore.defaultMailbox?.id ?? null, location: null }
|
||||
}
|
||||
|
||||
/** 검색을 닫으면 갈 곳 */
|
||||
function returnLocation(): RouteLocationRaw {
|
||||
const saved = mailStore.searchOrigin?.location
|
||||
if (saved) return saved
|
||||
const id = originMailbox.value?.id
|
||||
return id ? { name: 'mail-mailbox', params: { mailboxId: id } } : { name: 'mail' }
|
||||
}
|
||||
|
||||
/**
|
||||
* Enter — 빈 검색어는 아무것도 하지 않는다. 같은 검색어로 다시 누르면 보이는 채로 다시 찾는다.
|
||||
* 낱말이 서버 한도(8개)를 넘으면 찾지 않고 서버와 같은 코드(VAL_001)의 사전 문구로 알린다 — 칸에 친 글자는 그대로 둔다
|
||||
*/
|
||||
async function startSearch(text: string): Promise<void> {
|
||||
const q = normalizeSearchText(text)
|
||||
if (!q) return
|
||||
if (exceedsSearchWords(q)) {
|
||||
notifyApiError({ code: 'VAL_001', message: getErrorMessage('VAL_001') })
|
||||
return
|
||||
}
|
||||
const current = routeSearch.value
|
||||
if (current) {
|
||||
if (current.q === q) {
|
||||
await mailStore.openSearch(q, current.scope, { force: true })
|
||||
return
|
||||
}
|
||||
await router.replace(listLocationOf({ ...current, q }))
|
||||
return
|
||||
}
|
||||
const origin = originOfCurrentScreen()
|
||||
mailStore.rememberSearchOrigin(origin)
|
||||
await router.push(listLocationOf({ kind: 'search', q, scope: origin.mailboxId ?? SEARCH_SCOPE_ALL }))
|
||||
}
|
||||
|
||||
/** 범위 전환 — 결과에서 연 메일이 있었으면 결과로 돌아간다 */
|
||||
async function setScope(scope: string): Promise<void> {
|
||||
const current = routeSearch.value
|
||||
if (!current) return
|
||||
if (current.scope === scope && route.name !== 'mail-search-message') return
|
||||
await router.replace(listLocationOf({ ...current, scope }))
|
||||
}
|
||||
|
||||
async function closeSearch(): Promise<void> {
|
||||
await backOrReplace(returnLocation())
|
||||
}
|
||||
|
||||
return { isSearching, routeSearch, originMailbox, startSearch, setScope, closeSearch }
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { effectScope, type EffectScope } from 'vue'
|
||||
|
||||
import { useMailStatePolling } from '@/composables/useMailStatePolling'
|
||||
import { MAIL_STATE_POLL_INTERVAL_MS, MAIL_STATE_RECHECK_GAP_MS } from '@/shared/constants/mail'
|
||||
import { useAuthStore } from '@/stores/auth.store'
|
||||
import { useToastStore } from '@/stores/toast.store'
|
||||
import { createManualClock, type ManualClock } from '@/test-utils/manual-clock'
|
||||
import {
|
||||
fail,
|
||||
flushPromises,
|
||||
installMockApi,
|
||||
ok,
|
||||
restoreApiAdapter,
|
||||
type MockRequest,
|
||||
type MockResult,
|
||||
} from '@/test-utils/mock-api'
|
||||
|
||||
// 새 메일 확인 (계약 2절 "새 메일 확인") — 손으로 돌리는 시계와 화면 보임 상태로 확인한다 (고정 대기 없이)
|
||||
|
||||
describe('useMailStatePolling (새 메일 확인)', () => {
|
||||
let scope: EffectScope
|
||||
let clock: ManualClock
|
||||
let visibility: DocumentVisibilityState
|
||||
let states: MockResult[]
|
||||
let requests: MockRequest[]
|
||||
const onChange = vi.fn()
|
||||
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
useAuthStore().markAuthenticated({ email: 'ara.kim@arasystem.test', name: '김아라' })
|
||||
scope = effectScope()
|
||||
clock = createManualClock()
|
||||
visibility = 'visible'
|
||||
Object.defineProperty(document, 'visibilityState', { configurable: true, get: () => visibility })
|
||||
onChange.mockReset()
|
||||
states = []
|
||||
requests = installMockApi(() => states.shift() ?? ok({ state: 'same' }))
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
scope.stop()
|
||||
Reflect.deleteProperty(document, 'visibilityState')
|
||||
useToastStore().clear()
|
||||
restoreApiAdapter()
|
||||
})
|
||||
|
||||
function start() {
|
||||
scope.run(() => useMailStatePolling({ onChange, clock }))
|
||||
}
|
||||
|
||||
function setVisibility(next: DocumentVisibilityState) {
|
||||
visibility = next
|
||||
document.dispatchEvent(new Event('visibilitychange'))
|
||||
}
|
||||
|
||||
const asked = () => requests.filter((request) => request.url === '/mail-state').length
|
||||
|
||||
async function tick(ms: number) {
|
||||
clock.advance(ms)
|
||||
await flushPromises()
|
||||
}
|
||||
|
||||
it('바로 한 번 물어 기준을 잡고(알리지 않는다), 1분마다 다시 묻다가 값이 바뀌면 알린다', async () => {
|
||||
states = [ok({ state: 's1' }), ok({ state: 's1' }), ok({ state: 's2' })]
|
||||
start()
|
||||
await flushPromises()
|
||||
|
||||
expect(asked()).toBe(1)
|
||||
expect(requests[0]?.config.silent).toBe(true)
|
||||
expect(onChange).not.toHaveBeenCalled()
|
||||
|
||||
await tick(MAIL_STATE_POLL_INTERVAL_MS - 1)
|
||||
expect(asked()).toBe(1)
|
||||
await tick(1)
|
||||
expect(asked()).toBe(2)
|
||||
expect(onChange).not.toHaveBeenCalled()
|
||||
|
||||
await tick(MAIL_STATE_POLL_INTERVAL_MS)
|
||||
expect(asked()).toBe(3)
|
||||
expect(onChange).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('화면이 안 보이는 동안은 묻지 않는다 — 돌아왔을 때 마지막으로 물은 지 10초가 지났으면 바로 한 번', async () => {
|
||||
start()
|
||||
await flushPromises()
|
||||
setVisibility('hidden')
|
||||
|
||||
await tick(MAIL_STATE_POLL_INTERVAL_MS * 3)
|
||||
expect(asked()).toBe(1)
|
||||
|
||||
setVisibility('visible')
|
||||
await flushPromises()
|
||||
expect(asked()).toBe(2)
|
||||
})
|
||||
|
||||
it('돌아왔을 때 마지막으로 물은 지 10초가 안 됐으면 원래 차례(1분)에 묻는다', async () => {
|
||||
start()
|
||||
await flushPromises()
|
||||
await tick(MAIL_STATE_RECHECK_GAP_MS - 4_000)
|
||||
setVisibility('hidden')
|
||||
setVisibility('visible')
|
||||
await flushPromises()
|
||||
expect(asked()).toBe(1)
|
||||
|
||||
await tick(MAIL_STATE_POLL_INTERVAL_MS - (MAIL_STATE_RECHECK_GAP_MS - 4_000) - 1)
|
||||
expect(asked()).toBe(1)
|
||||
await tick(1)
|
||||
expect(asked()).toBe(2)
|
||||
})
|
||||
|
||||
it('한 번에 하나씩 — 묻는 중에 탭으로 돌아와도 또 묻지 않는다', async () => {
|
||||
let release: (result: MockResult) => void = () => undefined
|
||||
requests = installMockApi(
|
||||
() =>
|
||||
new Promise<MockResult>((resolve) => {
|
||||
release = resolve
|
||||
}),
|
||||
)
|
||||
start()
|
||||
await flushPromises()
|
||||
await tick(MAIL_STATE_RECHECK_GAP_MS * 2)
|
||||
setVisibility('hidden')
|
||||
setVisibility('visible')
|
||||
await flushPromises()
|
||||
|
||||
expect(asked()).toBe(1)
|
||||
release(ok({ state: 's1' }))
|
||||
await flushPromises()
|
||||
expect(asked()).toBe(1)
|
||||
})
|
||||
|
||||
it('실패(MAIL_001 · 네트워크)는 알리지 않고 다음 차례에 다시 묻는다 — 지난 값은 그대로 기준이다', async () => {
|
||||
states = [ok({ state: 's1' }), fail(503, 'MAIL_001'), 'network', ok({ state: 's1' }), ok({ state: 's2' })]
|
||||
start()
|
||||
await flushPromises()
|
||||
|
||||
await tick(MAIL_STATE_POLL_INTERVAL_MS)
|
||||
await tick(MAIL_STATE_POLL_INTERVAL_MS)
|
||||
await tick(MAIL_STATE_POLL_INTERVAL_MS)
|
||||
expect(asked()).toBe(4)
|
||||
expect(onChange).not.toHaveBeenCalled()
|
||||
expect(useToastStore().toasts).toEqual([])
|
||||
|
||||
await tick(MAIL_STATE_POLL_INTERVAL_MS)
|
||||
expect(onChange).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('로그인하지 않았으면 묻지 않는다 — 세션이 끝나면 그만 묻는다', async () => {
|
||||
start()
|
||||
await flushPromises()
|
||||
useAuthStore().clearAuth()
|
||||
await flushPromises()
|
||||
|
||||
await tick(MAIL_STATE_POLL_INTERVAL_MS * 2)
|
||||
expect(asked()).toBe(1)
|
||||
})
|
||||
|
||||
it('화면이 사라지면(scope 종료) 그만 묻는다', async () => {
|
||||
start()
|
||||
await flushPromises()
|
||||
scope.stop()
|
||||
|
||||
await tick(MAIL_STATE_POLL_INTERVAL_MS * 2)
|
||||
setVisibility('hidden')
|
||||
setVisibility('visible')
|
||||
await flushPromises()
|
||||
expect(asked()).toBe(1)
|
||||
expect(clock.pendingCount()).toBe(0)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,115 @@
|
||||
import { onScopeDispose, watch } from 'vue'
|
||||
|
||||
import { systemClock, type Clock, type ClockTimer } from './useClock'
|
||||
import { useMail } from './useMail'
|
||||
import { MAIL_STATE_POLL_INTERVAL_MS, MAIL_STATE_RECHECK_GAP_MS } from '@/shared/constants/mail'
|
||||
import { useAuthStore } from '@/stores/auth.store'
|
||||
|
||||
// 새 메일 확인 (계약 2절 "새 메일 확인" · 디자인 README "3단계 › 새 메일")
|
||||
// - 로그인한 동안, 화면이 보이는 동안(visibilityState === 'visible')만 60초마다 GET /mail-state 를 묻는다
|
||||
// - 탭이 다시 보이면 마지막으로 물은 지 10초가 지났을 때 바로 한 번 (안 지났으면 원래 차례에)
|
||||
// - 한 번에 하나씩 — 묻는 중이면 새로 묻지 않는다
|
||||
// - 처음 받은 값은 기준으로만 쓰고, 값이 바뀌면 onChange 를 부른다 (메일함 개수 · 보이는 목록 맞추기는 부르는 쪽이)
|
||||
// - 실패(MAIL_001 · 네트워크)는 알리지 않고(silent) 다음 차례에 다시 묻는다. 401 은 지금의 세션 흐름(갱신 1회 → 만료 대화상자)을 탄다
|
||||
// 실시간 푸시가 아니라 1분 확인이다 — 모자라면 백엔드가 SSE 로 중계하는 방식으로 바꾼다 (계약 같은 절)
|
||||
|
||||
export interface MailStatePollingOptions {
|
||||
/** 지난 값과 달라졌을 때 */
|
||||
onChange: () => void
|
||||
/** 테스트는 손으로 돌리는 시계를 넘긴다 (test-utils/manual-clock.ts) */
|
||||
clock?: Clock
|
||||
}
|
||||
|
||||
export interface MailStatePollingHandle {
|
||||
/** 그만 묻는다 — 화면이 사라지면 저절로 부른다 */
|
||||
stop: () => void
|
||||
}
|
||||
|
||||
function isPageVisible(): boolean {
|
||||
return typeof document === 'undefined' || document.visibilityState === 'visible'
|
||||
}
|
||||
|
||||
export function useMailStatePolling(options: MailStatePollingOptions): MailStatePollingHandle {
|
||||
const { getMailState } = useMail()
|
||||
const authStore = useAuthStore()
|
||||
const clock = options.clock ?? systemClock
|
||||
|
||||
/** 지난 값 — 처음 받은 값은 기준으로만 쓴다 */
|
||||
let baseline: string | null = null
|
||||
let lastAskedAt: number | null = null
|
||||
let timer: ClockTimer | null = null
|
||||
let inFlight = false
|
||||
let stopped = false
|
||||
|
||||
function cancelTimer() {
|
||||
if (timer !== null) clock.clearTimeout(timer)
|
||||
timer = null
|
||||
}
|
||||
|
||||
function schedule(delay: number) {
|
||||
cancelTimer()
|
||||
if (stopped || !isPageVisible() || !authStore.isLoggedIn) return
|
||||
timer = clock.setTimeout(
|
||||
() => {
|
||||
timer = null
|
||||
void ask()
|
||||
},
|
||||
Math.max(0, delay),
|
||||
)
|
||||
}
|
||||
|
||||
async function ask(): Promise<void> {
|
||||
if (stopped || inFlight || !isPageVisible() || !authStore.isLoggedIn) return
|
||||
inFlight = true
|
||||
lastAskedAt = clock.now()
|
||||
const session = authStore.generation
|
||||
try {
|
||||
const { state } = await getMailState({ silent: true })
|
||||
// 묻는 사이 로그아웃 · 세션 만료 · 다른 계정 로그인이 있었으면 버린다
|
||||
if (stopped || authStore.generation !== session) return
|
||||
const changed = baseline !== null && state !== baseline
|
||||
baseline = state
|
||||
if (changed) options.onChange()
|
||||
} catch {
|
||||
// 알리지 않는다 — 다음 차례에 다시 묻는다
|
||||
} finally {
|
||||
inFlight = false
|
||||
schedule(MAIL_STATE_POLL_INTERVAL_MS)
|
||||
}
|
||||
}
|
||||
|
||||
function onVisibilityChange() {
|
||||
if (stopped) return
|
||||
if (!isPageVisible()) {
|
||||
cancelTimer()
|
||||
return
|
||||
}
|
||||
if (inFlight) return
|
||||
const elapsed = lastAskedAt === null ? Number.POSITIVE_INFINITY : clock.now() - lastAskedAt
|
||||
if (elapsed >= MAIL_STATE_RECHECK_GAP_MS) void ask()
|
||||
else schedule(MAIL_STATE_POLL_INTERVAL_MS - elapsed)
|
||||
}
|
||||
|
||||
function stop() {
|
||||
stopped = true
|
||||
cancelTimer()
|
||||
if (typeof document !== 'undefined') document.removeEventListener('visibilitychange', onVisibilityChange)
|
||||
}
|
||||
|
||||
// 세션이 바뀌면(다시 로그인 등) 지난 값을 버리고 새로 기준을 잡는다
|
||||
watch(
|
||||
() => authStore.generation,
|
||||
() => {
|
||||
baseline = null
|
||||
if (authStore.isLoggedIn) void ask()
|
||||
else cancelTimer()
|
||||
},
|
||||
)
|
||||
|
||||
if (typeof document !== 'undefined') document.addEventListener('visibilitychange', onVisibilityChange)
|
||||
// 바로 한 번 물어 기준을 잡는다 — 1분 뒤에 처음 물으면 그 사이에 온 메일은 2분 뒤에야 알아챈다
|
||||
void ask()
|
||||
onScopeDispose(stop)
|
||||
|
||||
return { stop }
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { createPinia, setActivePinia, type Pinia } from 'pinia'
|
||||
import { defineComponent, h, type FunctionalComponent } from 'vue'
|
||||
import { createMemoryHistory, createRouter } from 'vue-router'
|
||||
|
||||
import { useMailTabTitle } from '@/composables/useMailTabTitle'
|
||||
import type { Mailbox } from '@/shared/types/mail'
|
||||
import { documentTitle } from '@/shared/utils/document-title'
|
||||
import { useAuthStore } from '@/stores/auth.store'
|
||||
import { useMailStore } from '@/stores/mail.store'
|
||||
import { flushPromises } from '@/test-utils/mock-api'
|
||||
|
||||
// 탭 제목 — 받은편지함에 안 읽은 메일이 있으면 "(N) " (디자인 README "3단계 › 새 메일" · 디자인 원본 ListStates)
|
||||
|
||||
const Empty: FunctionalComponent = () => h('div')
|
||||
|
||||
/** 메일 모듈 레이아웃(MailLayout) 대신 — 이 composable 만 부른다 */
|
||||
const TitleHost = defineComponent({
|
||||
setup() {
|
||||
useMailTabTitle()
|
||||
return () => h('div')
|
||||
},
|
||||
})
|
||||
|
||||
function inbox(unreadCount: number): Mailbox {
|
||||
return { id: 'inbox', name: '받은편지함', role: 'inbox', parentId: null, sortOrder: 0, totalCount: 9, unreadCount }
|
||||
}
|
||||
|
||||
describe('useMailTabTitle (탭 제목)', () => {
|
||||
let pinia: Pinia
|
||||
|
||||
beforeEach(() => {
|
||||
pinia = createPinia()
|
||||
setActivePinia(pinia)
|
||||
useAuthStore().markAuthenticated({ email: 'ara.kim@arasystem.test', name: '김아라' })
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
document.title = ''
|
||||
})
|
||||
|
||||
async function mountHost() {
|
||||
const router = createRouter({
|
||||
history: createMemoryHistory(),
|
||||
routes: [
|
||||
{ path: '/mail/compose', name: 'mail-compose', component: Empty, meta: { title: '메일 쓰기' } },
|
||||
{ path: '/mail/:mailboxId', name: 'mail-mailbox', component: Empty, meta: { title: '메일' } },
|
||||
{ path: '/settings', name: 'settings', component: Empty, meta: { title: '설정' } },
|
||||
],
|
||||
})
|
||||
// 앱 라우터와 같다 — 이동할 때마다 기본 제목을 쓴다
|
||||
router.afterEach((to) => {
|
||||
document.title = documentTitle(to.meta.title)
|
||||
})
|
||||
await router.push('/mail/inbox')
|
||||
await router.isReady()
|
||||
mount(TitleHost, { global: { plugins: [pinia, router] } })
|
||||
await flushPromises()
|
||||
return router
|
||||
}
|
||||
|
||||
it('"(3) 메일 · ARA SYSTEM" — 모두 읽으면 "메일 · ARA SYSTEM"', async () => {
|
||||
useMailStore().mailboxes = [inbox(3)]
|
||||
await mountHost()
|
||||
expect(document.title).toBe('(3) 메일 · ARA SYSTEM')
|
||||
|
||||
useMailStore().mailboxes = [inbox(0)]
|
||||
await flushPromises()
|
||||
expect(document.title).toBe('메일 · ARA SYSTEM')
|
||||
})
|
||||
|
||||
it('쓰기 화면에서도 — "(3) 메일 쓰기 · ARA SYSTEM", 이동이 기본 제목을 써도 다시 붙인다', async () => {
|
||||
useMailStore().mailboxes = [inbox(3)]
|
||||
const router = await mountHost()
|
||||
|
||||
await router.push('/mail/compose')
|
||||
await flushPromises()
|
||||
expect(document.title).toBe('(3) 메일 쓰기 · ARA SYSTEM')
|
||||
|
||||
await router.push('/mail/inbox')
|
||||
await flushPromises()
|
||||
expect(document.title).toBe('(3) 메일 · ARA SYSTEM')
|
||||
})
|
||||
|
||||
it('메일 모듈 밖에서는 붙이지 않는다', async () => {
|
||||
useMailStore().mailboxes = [inbox(3)]
|
||||
const router = await mountHost()
|
||||
|
||||
await router.push('/settings')
|
||||
await flushPromises()
|
||||
expect(document.title).toBe('설정 · ARA SYSTEM')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,29 @@
|
||||
import { watch } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
|
||||
import { documentTitle } from '@/shared/utils/document-title'
|
||||
import { useMailStore } from '@/stores/mail.store'
|
||||
|
||||
/** 메일 모듈의 라우트 — 이름이 모두 mail 로 시작한다 (router/index.ts) */
|
||||
function isMailRouteName(name: unknown): boolean {
|
||||
return typeof name === 'string' && (name === 'mail' || name.startsWith('mail-'))
|
||||
}
|
||||
|
||||
/**
|
||||
* 메일 화면의 탭 제목 — 받은편지함에 안 읽은 메일이 있으면 앞에 "(N) " ("(3) 메일 · ARA SYSTEM" · "(3) 메일 쓰기 · ARA SYSTEM").
|
||||
* 디자인 README "3단계 › 새 메일". 이동할 때마다 router.afterEach 가 기본 제목을 쓰므로, 이동이 끝난 뒤(post)에 다시 맞춘다.
|
||||
* 메일 모듈 레이아웃(MailLayout)에서 부른다 — 다른 모듈로 가면 그 화면의 제목이 그대로 남는다
|
||||
*/
|
||||
export function useMailTabTitle() {
|
||||
const route = useRoute()
|
||||
const mailStore = useMailStore()
|
||||
|
||||
watch(
|
||||
[() => route.fullPath, () => route.meta.title, () => mailStore.inboxUnreadCount],
|
||||
() => {
|
||||
if (!isMailRouteName(route.name)) return
|
||||
document.title = documentTitle(route.meta.title, mailStore.inboxUnreadCount)
|
||||
},
|
||||
{ immediate: true, flush: 'post' },
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import { notifyApiError } from './useApi'
|
||||
import { useMailMoveFeedback } from './useMailMoveFeedback'
|
||||
import type { MessageSummary } from '@/shared/types/mail'
|
||||
import { useMailStore } from '@/stores/mail.store'
|
||||
|
||||
// 읽기 화면의 「삭제」 · 「영구 삭제」 — 열린 메일 한 통 (디자인 README "3단계 › 여러 통 처리" · 계약 2절 "여러 통 한꺼번에")
|
||||
// - 「영구 삭제」인지는 그 메일의 메일함(mailboxIds)으로 정한다 — 검색 결과 · 다른 창에서 옮긴 메일도 맞게 (deletesPermanently)
|
||||
// · 휴지통 목록(휴지통 안의 검색 포함)에서는 휴지통에 든 메일이면 「영구 삭제」 (지금까지와 같다)
|
||||
// · 그 밖의 목록에서는 휴지통에만 든 메일만 「영구 삭제」 — 휴지통과 다른 메일함에 함께 든 메일(다른 메일 프로그램이 복사)은
|
||||
// 영구 삭제가 휴지통에서만 빼고 끝나 목록 · 알림이 사실과 달라지므로 「삭제」(휴지통으로 옮기기)다
|
||||
// - 「삭제」: 휴지통으로 옮긴다(PATCH /messages { mailboxId: 휴지통 }) — "메일 1통을 휴지통으로 옮겼습니다." + [되돌리기]
|
||||
// - 「영구 삭제」: 확인 대화상자를 거친 뒤 POST /message-deletions — "메일 1통을 영구 삭제했습니다."
|
||||
// - DELETE /messages/{id} 는 쓰지 않는다 — 메일함 정보가 낡았으면(그사이 다른 창이 휴지통으로 옮겼으면) 확인 없이 영구 삭제하기 때문이다.
|
||||
// 이 길에서는 낡은 정보가 해롭지 않다: 이미 휴지통에 있는 메일을 휴지통으로 옮기면 그대로이고, 휴지통을 떠난 메일의 영구 삭제는
|
||||
// 서버가 그대로 둔다(skippedIds — 목록을 다시 맞춘다)
|
||||
|
||||
export interface MessageRemovalOutcome {
|
||||
/** 서버가 처리했고 지금 목록에서도 빠졌다 — 다음 메일(또는 목록)로 넘어간다 */
|
||||
removed: boolean
|
||||
/** 이어서 보여 줄 메일 — 다음 메일, 없으면 이전 메일 (목록 밖의 메일이었으면 null) */
|
||||
nextId: string | null
|
||||
}
|
||||
|
||||
const NOTHING: MessageRemovalOutcome = { removed: false, nextId: null }
|
||||
|
||||
export function useMessageRemoval() {
|
||||
const mailStore = useMailStore()
|
||||
const feedback = useMailMoveFeedback()
|
||||
|
||||
/**
|
||||
* 「영구 삭제」할 메일인가 (위 규칙) — 휴지통 메일함을 아직 모르면 아니라고 본다 (옮기기 쪽이 해롭지 않다)
|
||||
*/
|
||||
function deletesPermanently(message: Pick<MessageSummary, 'mailboxIds'>): boolean {
|
||||
const trashId = mailStore.mailboxByRole('trash')?.id
|
||||
if (!trashId || !message.mailboxIds.includes(trashId)) return false
|
||||
if (mailStore.currentMailbox?.role === 'trash') return true
|
||||
return message.mailboxIds.every((id) => id === trashId)
|
||||
}
|
||||
|
||||
function outcomeOf(id: string, nextId: string | null, done: boolean, stale: boolean): MessageRemovalOutcome {
|
||||
const removed = done && !stale && !mailStore.messages.some((message) => message.id === id)
|
||||
return { removed, nextId: removed ? nextId : null }
|
||||
}
|
||||
|
||||
/** 휴지통으로 옮긴다 */
|
||||
async function moveToTrash(message: MessageSummary): Promise<MessageRemovalOutcome> {
|
||||
const trash = await mailStore.resolveMailboxByRole('trash')
|
||||
if ('error' in trash) {
|
||||
notifyApiError(trash.error)
|
||||
return NOTHING
|
||||
}
|
||||
const nextId = mailStore.neighborAfterRemoval(message.id)
|
||||
const result = await mailStore.moveMessages([message], trash.mailbox.id)
|
||||
feedback.announceMove(result, trash.mailbox.name)
|
||||
return outcomeOf(message.id, nextId, result.doneIds.includes(message.id), result.stale)
|
||||
}
|
||||
|
||||
/** 영구 삭제 — 확인 대화상자에서 「영구 삭제」를 누른 뒤에만 부른다 */
|
||||
async function purge(message: MessageSummary): Promise<MessageRemovalOutcome> {
|
||||
const nextId = mailStore.neighborAfterRemoval(message.id)
|
||||
const result = await mailStore.purgeMessages([message.id])
|
||||
feedback.announcePurge(result)
|
||||
return outcomeOf(message.id, nextId, result.doneIds.includes(message.id), result.stale)
|
||||
}
|
||||
|
||||
return { deletesPermanently, moveToTrash, purge }
|
||||
}
|
||||
@@ -1,14 +1,30 @@
|
||||
import { useAuthStore } from '@/stores/auth.store'
|
||||
import { useToastStore, type ToastAction } from '@/stores/toast.store'
|
||||
|
||||
/**
|
||||
* 화면 알림(토스트) — 성공 안내처럼 화면이 직접 띄우는 알림에 쓴다.
|
||||
* API 오류 알림은 useApi 인터셉터가 이미 띄우므로 호출부에서 다시 띄우지 않는다.
|
||||
* 그 자리의 단추(되돌리기 등 — run)는 알림을 띄운 세션에서만 움직인다: 그사이 로그아웃 · 세션 만료 · 다른 계정 로그인이 있었으면
|
||||
* 눌러도 아무것도 하지 않는다 (세션이 바뀌면 알림도 닫힌다 — toast.store)
|
||||
*/
|
||||
export function useToast() {
|
||||
const store = useToastStore()
|
||||
const authStore = useAuthStore()
|
||||
|
||||
/** 띄운 세션에서만 움직이는 단추 */
|
||||
function sessionBound(action: ToastAction): ToastAction {
|
||||
if (!('run' in action)) return action
|
||||
const session = authStore.generation
|
||||
return {
|
||||
label: action.label,
|
||||
run: () => {
|
||||
if (authStore.generation === session) action.run()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function success(message: string, action?: ToastAction): number {
|
||||
return store.show({ kind: 'success', message, action })
|
||||
return store.show({ kind: 'success', message, action: action ? sessionBound(action) : undefined })
|
||||
}
|
||||
|
||||
return { success, dismiss: store.dismiss }
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
<script setup lang="ts">
|
||||
// 메일 모듈 레이아웃 — 전역 셸 + 메일함 패널 + 하위 화면(목록·본문 / 쓰기)
|
||||
// 메일 모듈 레이아웃 — 전역 셸 + 메일함 패널 + 하위 화면(목록·본문 / 검색 결과 / 쓰기)
|
||||
// 패널은 하위 화면이 바뀌어도 다시 그리지 않는다.
|
||||
// 메일 화면에 있는 동안: 상단 검색 칸은 "메일 검색", 1분마다 새 메일 확인, 탭 제목 앞에 받은편지함의 안 읽은 수 (디자인 README "3단계")
|
||||
import AppShell from '@/components/shell/AppShell.vue'
|
||||
import MailSearchBox from '@/components/mail/MailSearchBox.vue'
|
||||
import MailboxPanel from '@/components/mail/MailboxPanel.vue'
|
||||
import { useClock } from '@/composables/useClock'
|
||||
import { useMailStatePolling } from '@/composables/useMailStatePolling'
|
||||
import { useMailTabTitle } from '@/composables/useMailTabTitle'
|
||||
import { useMailSignatureStore } from '@/stores/mail-signature.store'
|
||||
import { useMailStore } from '@/stores/mail.store'
|
||||
|
||||
@@ -14,10 +19,23 @@ void mailStore.ensureMailboxes()
|
||||
void mailStore.ensureQuota()
|
||||
// 쓰기 화면이 열 때 넣을 서명 — 미리 받아 두면 편집기가 기다리지 않는다 (못 받으면 서명 없이 연다)
|
||||
void useMailSignatureStore().ensureSignature()
|
||||
|
||||
// 새 메일 확인 — 바뀌었으면 메일함 개수 · 사용량과 보이는 메일함 목록을 맞춘다 (검색 결과는 저절로 바꾸지 않는다 — 스토어가 거른다)
|
||||
useMailStatePolling({
|
||||
clock: useClock(),
|
||||
onChange: () => {
|
||||
mailStore.syncCounts()
|
||||
void mailStore.syncVisibleList()
|
||||
},
|
||||
})
|
||||
useMailTabTitle()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppShell module-id="mail">
|
||||
<template #search>
|
||||
<MailSearchBox />
|
||||
</template>
|
||||
<MailboxPanel />
|
||||
<RouterView />
|
||||
</AppShell>
|
||||
|
||||
@@ -0,0 +1,208 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { createPinia, setActivePinia, type Pinia } from 'pinia'
|
||||
|
||||
import type { Mailbox, MessageDetail, MessageSummary } from '@/shared/types/mail'
|
||||
import { useAuthStore } from '@/stores/auth.store'
|
||||
import { useMailStore } from '@/stores/mail.store'
|
||||
import { mailViewModeStorageKey } from '@/stores/mail-view.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 MailboxPage from './MailboxPage.vue'
|
||||
|
||||
// 검색 결과 화면 (디자인 원본 Search · ListStates · 계약 2절 "검색")
|
||||
|
||||
const USER = { email: 'ara.kim@arasystem.test', name: '김아라' }
|
||||
const Q = encodeURIComponent('회의')
|
||||
|
||||
function mailbox(id: string, role: Mailbox['role'], name: string): Mailbox {
|
||||
return { id, name, role, parentId: null, sortOrder: 0, totalCount: 5, unreadCount: 0 }
|
||||
}
|
||||
|
||||
const MAILBOXES = [mailbox('inbox', 'inbox', '받은편지함'), mailbox('sent', 'sent', '보낸편지함')]
|
||||
|
||||
function summary(id: string, partial: Partial<MessageSummary> = {}): MessageSummary {
|
||||
return {
|
||||
id,
|
||||
threadId: `t-${id}`,
|
||||
mailboxIds: ['inbox'],
|
||||
from: { name: '이도윤', email: 'doyun.lee@arasystem.test' },
|
||||
to: [{ name: '김아라', email: USER.email }],
|
||||
subject: `주간 회의 ${id}`,
|
||||
preview: '',
|
||||
receivedAt: '2026-10-27T01:00:00Z',
|
||||
isRead: true,
|
||||
isFlagged: false,
|
||||
hasAttachment: false,
|
||||
size: 100,
|
||||
...partial,
|
||||
}
|
||||
}
|
||||
|
||||
function detail(id: string): MessageDetail {
|
||||
return {
|
||||
...summary(id),
|
||||
cc: [],
|
||||
bcc: [],
|
||||
replyTo: [],
|
||||
sentAt: null,
|
||||
messageId: null,
|
||||
references: [],
|
||||
htmlBody: null,
|
||||
textBody: '본문',
|
||||
hasBlockedImages: false,
|
||||
attachments: [],
|
||||
}
|
||||
}
|
||||
|
||||
describe('MailboxPage — 검색 결과', () => {
|
||||
let pinia: Pinia
|
||||
let requests: MockRequest[]
|
||||
let results: MessageSummary[]
|
||||
|
||||
beforeEach(() => {
|
||||
window.localStorage.clear()
|
||||
pinia = createPinia()
|
||||
setActivePinia(pinia)
|
||||
useAuthStore().markAuthenticated(USER)
|
||||
useMailStore().mailboxes = MAILBOXES
|
||||
results = [summary('r1'), summary('r2')]
|
||||
requests = installMockApi(({ url }) => {
|
||||
if (url === '/messages') return ok({ items: results, pageInfo: { hasNext: false, nextCursor: null, total: results.length } })
|
||||
return ok(detail(url.split('/').pop() ?? 'r1'))
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
useToastStore().clear()
|
||||
restoreApiAdapter()
|
||||
})
|
||||
|
||||
async function mountAt(path: string) {
|
||||
const router = createTestRouter()
|
||||
await router.push(path)
|
||||
await router.isReady()
|
||||
const wrapper = mount(MailboxPage, { global: { plugins: [pinia, router] } })
|
||||
await flushPromises()
|
||||
return { wrapper, router }
|
||||
}
|
||||
|
||||
const listRequests = () => requests.filter((request) => request.url === '/messages')
|
||||
|
||||
it('주소의 검색어 · 범위로 찾는다 — 머리 "검색 결과" · "‘회의’ · 2통", 범위 줄 [받은편지함 | 전체 메일함]', async () => {
|
||||
const { wrapper } = await mountAt(`/mail/search?q=${Q}&in=inbox`)
|
||||
|
||||
expect(listRequests()[0]?.params).toEqual({ q: '회의', mailboxId: 'inbox', limit: 20 })
|
||||
expect(wrapper.get('#message-list-title').text()).toBe('검색 결과')
|
||||
expect(wrapper.get('.search-header__count').text()).toBe('‘회의’ · 2통')
|
||||
expect(wrapper.get('.search-header__close').attributes('aria-label')).toBe('검색 닫기')
|
||||
const scope = wrapper.findAll('.search-scope .segmented__item')
|
||||
expect(scope.map((button) => [button.text(), button.attributes('aria-pressed')])).toEqual([
|
||||
['받은편지함', 'true'],
|
||||
['전체 메일함', 'false'],
|
||||
])
|
||||
expect(wrapper.find('.search-scope__hint').exists()).toBe(false)
|
||||
expect(wrapper.findAll('.message-row')).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('결과가 없으면 한계 안내 한 줄과 [전체 메일함에서 찾기] — 누르면 전체에서 다시 찾는다(기록을 쌓지 않는다)', async () => {
|
||||
results = []
|
||||
const { wrapper, router } = await mountAt(`/mail/search?q=${Q}&in=inbox`)
|
||||
const push = vi.spyOn(router, 'push')
|
||||
|
||||
expect(wrapper.text()).toContain('‘회의’에 맞는 메일이 없습니다')
|
||||
expect(wrapper.text()).toContain(
|
||||
'낱말 단위로 찾습니다. 붙여 쓴 말의 일부(‘서버이전’의 ‘서버’)나 제목 맨 앞의 말머리([공지] · Re:)로는 찾지 못합니다.',
|
||||
)
|
||||
await wrapper.get('.search-empty__all').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(router.currentRoute.value.fullPath).toBe(`/mail/search?q=${Q}&in=all`)
|
||||
expect(push).not.toHaveBeenCalled()
|
||||
expect(listRequests().at(-1)?.params).toEqual({ q: '회의', limit: 20 })
|
||||
// 전체에서 찾았으면 더 넓힐 곳이 없다 — 안내 줄만
|
||||
expect(wrapper.find('.search-empty__all').exists()).toBe(false)
|
||||
expect(wrapper.get('.search-scope__hint').text()).toBe('휴지통 · 스팸메일함은 빼고 찾습니다')
|
||||
})
|
||||
|
||||
it('범위 줄에서 "전체 메일함"을 누르면 기록을 쌓지 않고 전체에서 다시 찾고, "받은편지함"을 누르면 돌아온다', async () => {
|
||||
useMailStore().rememberSearchOrigin({ mailboxId: 'inbox', location: '/mail/inbox' })
|
||||
const { wrapper, router } = await mountAt(`/mail/search?q=${Q}&in=inbox`)
|
||||
const push = vi.spyOn(router, 'push')
|
||||
const scopeButton = (label: string) =>
|
||||
wrapper.findAll('.search-scope .segmented__item').find((button) => button.text() === label)
|
||||
|
||||
await scopeButton('전체 메일함')?.trigger('click')
|
||||
await flushPromises()
|
||||
expect(router.currentRoute.value.fullPath).toBe(`/mail/search?q=${Q}&in=all`)
|
||||
expect(listRequests().at(-1)?.params).toEqual({ q: '회의', limit: 20 })
|
||||
expect(scopeButton('전체 메일함')?.attributes('aria-pressed')).toBe('true')
|
||||
// 메일함 패널은 전체 메일함에서 찾는 동안 현재 메일함을 표시하지 않는다
|
||||
expect(useMailStore().currentMailboxId).toBeNull()
|
||||
|
||||
await scopeButton('받은편지함')?.trigger('click')
|
||||
await flushPromises()
|
||||
expect(router.currentRoute.value.fullPath).toBe(`/mail/search?q=${Q}&in=inbox`)
|
||||
expect(listRequests()).toHaveLength(3)
|
||||
expect(push).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('결과에서 연 메일은 /mail/search/<id>?… — 다시 찾지 않고, 읽기 화면에 "메일함 받은편지함" 을 더한다', async () => {
|
||||
const { wrapper, router } = await mountAt(`/mail/search?q=${Q}&in=all`)
|
||||
const row = wrapper.findAll('.message-row__main')[1]
|
||||
expect(row?.attributes('href')).toBe(`/mail/search/r2?q=${Q}&in=all`)
|
||||
|
||||
await router.push(row?.attributes('href') ?? '')
|
||||
await flushPromises()
|
||||
|
||||
expect(listRequests()).toHaveLength(1)
|
||||
expect(wrapper.get('#reader-subject').text()).toBe('주간 회의 r2')
|
||||
expect(wrapper.get('.reader__mailbox').text()).toBe('메일함 받은편지함')
|
||||
expect(wrapper.findAll('.message-row')[1]?.classes()).toContain('is-selected')
|
||||
})
|
||||
|
||||
it('검색 닫기(←)는 검색을 시작한 화면으로 돌아간다', async () => {
|
||||
useMailStore().rememberSearchOrigin({ mailboxId: 'sent', location: '/mail/sent' })
|
||||
const { wrapper, router } = await mountAt(`/mail/search?q=${Q}&in=sent`)
|
||||
|
||||
expect(wrapper.get('.search-header__close').attributes('title')).toBe('검색 닫기 — 보낸편지함으로')
|
||||
await wrapper.get('.search-header__close').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(router.currentRoute.value.fullPath).toBe('/mail/sent')
|
||||
expect(useMailStore().search).toBeNull()
|
||||
})
|
||||
|
||||
it('검색어가 없는 주소(/mail/search)는 검색을 닫은 것으로 본다', async () => {
|
||||
const { router } = await mountAt('/mail/search')
|
||||
|
||||
expect(router.currentRoute.value.fullPath).toBe('/mail/inbox')
|
||||
expect(listRequests().some((request) => request.params.q !== undefined)).toBe(false)
|
||||
})
|
||||
|
||||
it('목록 보기 — 검색 결과도 표로, 전체 메일함이면 메일함 칸이 생긴다', async () => {
|
||||
window.localStorage.setItem(mailViewModeStorageKey(USER.email), 'list')
|
||||
results = [summary('r1'), summary('r2', { mailboxIds: ['sent'] })]
|
||||
const { wrapper } = await mountAt(`/mail/search?q=${Q}&in=all`)
|
||||
|
||||
expect(wrapper.find('table.mail-table').exists()).toBe(true)
|
||||
expect(wrapper.get('#message-table-title').text()).toBe('검색 결과')
|
||||
expect(wrapper.findAll('tbody .mail-table__mailbox').map((cell) => cell.text())).toEqual([
|
||||
'받은편지함',
|
||||
'보낸편지함',
|
||||
])
|
||||
})
|
||||
|
||||
it('검색하면 고른 것을 푼다', async () => {
|
||||
const store = useMailStore()
|
||||
const { router } = await mountAt('/mail/inbox')
|
||||
store.toggleCheck('r1')
|
||||
|
||||
await router.push(`/mail/search?q=${Q}&in=inbox`)
|
||||
await flushPromises()
|
||||
|
||||
expect(store.checkedCount).toBe(0)
|
||||
})
|
||||
})
|
||||
@@ -1,9 +1,10 @@
|
||||
<script setup lang="ts">
|
||||
// 메일함 화면 (/mail · /mail/:mailboxId · /mail/:mailboxId/:messageId)
|
||||
// 화면 상태는 주소가 기준이다: 주소의 메일함·메일이 바뀌면 스토어가 그에 맞춰 불러온다.
|
||||
// 메일함 · 검색 결과 화면 (/mail · /mail/:mailboxId · /mail/:mailboxId/:messageId · /mail/search?q=…&in=… · /mail/search/:messageId?…)
|
||||
// 화면 상태는 주소가 기준이다: 주소의 메일함·검색·메일이 바뀌면 스토어가 그에 맞춰 불러온다.
|
||||
// 보기 방식(사용자별 기억)에 따라 같은 주소를 다르게 그린다:
|
||||
// - 분할: 목록(2줄 행) + 본문 (디자인 원본 Main)
|
||||
// - 분할: 목록(2줄 행) + 본문 (디자인 원본 Main · Search)
|
||||
// - 목록: 메일을 열지 않았으면 게시판형 표(MainList), 열었으면 표 자리에 본문을 넓게(MainListRead)
|
||||
// 검색 결과는 메일함 목록과 같은 목록 · 표 · 본문을 쓴다 — 목록의 출처만 다르다 (mail-list-source.ts)
|
||||
import { computed, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
|
||||
@@ -13,6 +14,7 @@ import MessageList from '@/components/mail/MessageList.vue'
|
||||
import MessageListSkeleton from '@/components/mail/MessageListSkeleton.vue'
|
||||
import MessageReader from '@/components/mail/MessageReader.vue'
|
||||
import MessageTable from '@/components/mail/MessageTable.vue'
|
||||
import { useMailSearchNavigation } from '@/composables/useMailSearchNavigation'
|
||||
import { useMailStore } from '@/stores/mail.store'
|
||||
import { useMailViewStore } from '@/stores/mail-view.store'
|
||||
|
||||
@@ -20,14 +22,17 @@ const route = useRoute()
|
||||
const router = useRouter()
|
||||
const mailStore = useMailStore()
|
||||
const viewStore = useMailViewStore()
|
||||
const { isSearching, routeSearch, closeSearch } = useMailSearchNavigation()
|
||||
|
||||
function paramOf(value: string | string[] | undefined): string | null {
|
||||
return typeof value === 'string' && value !== '' ? value : null
|
||||
}
|
||||
|
||||
const mailboxId = computed(() => paramOf(route.params.mailboxId))
|
||||
const mailboxId = computed(() => (isSearching.value ? null : paramOf(route.params.mailboxId)))
|
||||
const messageId = computed(() => paramOf(route.params.messageId))
|
||||
const isListView = computed(() => viewStore.mode === 'list')
|
||||
/** 목록이 있는 화면 — 메일함 또는 검색 결과 */
|
||||
const hasList = computed(() => Boolean(mailboxId.value) || isSearching.value)
|
||||
|
||||
watch(
|
||||
mailboxId,
|
||||
@@ -37,6 +42,21 @@ watch(
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
// 검색어 · 범위가 바뀔 때만 다시 찾는다 — 결과에서 메일을 열고 닫는 것으로는 다시 찾지 않는다.
|
||||
// 검색어가 없는 주소(/mail/search)는 검색을 닫은 것으로 본다
|
||||
watch(
|
||||
[isSearching, () => routeSearch.value?.q ?? null, () => routeSearch.value?.scope ?? null],
|
||||
([searching, q, scope]) => {
|
||||
if (!searching) return
|
||||
if (q === null || scope === null) {
|
||||
void closeSearch()
|
||||
return
|
||||
}
|
||||
void mailStore.openSearch(q, scope)
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
watch(
|
||||
messageId,
|
||||
(id) => {
|
||||
@@ -46,20 +66,20 @@ watch(
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
// 쓰기를 마치거나 취소하면 이 화면으로 돌아온다 (보던 메일함·메일)
|
||||
// 쓰기를 마치거나 취소하면 이 화면으로 돌아온다 (보던 메일함·메일·검색 결과)
|
||||
watch(
|
||||
() => route.fullPath,
|
||||
(fullPath) => {
|
||||
if (mailboxId.value) mailStore.rememberBrowseLocation(fullPath)
|
||||
if (hasList.value) mailStore.rememberBrowseLocation(fullPath)
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
// /mail 로 들어오면 메일함을 불러온 뒤 받은편지함으로 보낸다
|
||||
watch(
|
||||
[mailboxId, () => mailStore.defaultMailbox],
|
||||
([id, fallback]) => {
|
||||
if (!id && fallback) {
|
||||
[hasList, () => mailStore.defaultMailbox],
|
||||
([listed, fallback]) => {
|
||||
if (!listed && fallback) {
|
||||
void router.replace({ name: 'mail-mailbox', params: { mailboxId: fallback.id } })
|
||||
}
|
||||
},
|
||||
@@ -69,26 +89,20 @@ watch(
|
||||
|
||||
<template>
|
||||
<main :class="['mail-view', { 'mail-view--list': isListView }]">
|
||||
<template v-if="mailboxId && isListView">
|
||||
<template v-if="hasList && isListView">
|
||||
<MessageReader
|
||||
v-if="messageId"
|
||||
layout="list"
|
||||
>
|
||||
<template #toolbar-start>
|
||||
<ListReadNav
|
||||
:mailbox-id="mailboxId"
|
||||
:message-id="messageId"
|
||||
/>
|
||||
<ListReadNav :message-id="messageId" />
|
||||
</template>
|
||||
</MessageReader>
|
||||
<MessageTable
|
||||
v-else
|
||||
:mailbox-id="mailboxId"
|
||||
/>
|
||||
<MessageTable v-else />
|
||||
</template>
|
||||
|
||||
<template v-else-if="mailboxId">
|
||||
<MessageList :mailbox-id="mailboxId" />
|
||||
<template v-else-if="hasList">
|
||||
<MessageList />
|
||||
<MessageReader />
|
||||
</template>
|
||||
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { createRouter, createWebHistory, type RouteRecordRaw } from 'vue-router'
|
||||
|
||||
import { useAuth } from '@/composables/useAuth'
|
||||
import { APP_NAME } from '@/shared/constants/brand'
|
||||
import { documentTitle } from '@/shared/utils/document-title'
|
||||
import { MAIL_SEARCH_MESSAGE_ROUTE, MAIL_SEARCH_ROUTE } from '@/shared/utils/mail-list-source'
|
||||
import { toSafeRedirect } from '@/shared/utils/redirect'
|
||||
import { useAuthStore } from '@/stores/auth.store'
|
||||
|
||||
@@ -44,6 +45,15 @@ const routes: RouteRecordRaw[] = [
|
||||
component: () => import('@/pages/mail/ComposePage.vue'),
|
||||
meta: { title: '메일 쓰기' },
|
||||
},
|
||||
// 검색 결과 /mail/search?q=…&in=<메일함 id | all> · 결과에서 연 메일 /mail/search/<메일 id>?q=…&in=… (계약 2절 "검색")
|
||||
// 고정 경로라 :mailboxId 보다 앞에 둔다 — 같은 화면(MailboxPage)이 목록 출처만 바꿔 그린다
|
||||
{ path: 'search', name: MAIL_SEARCH_ROUTE, component: MailboxPage, meta: { title: '메일 검색' } },
|
||||
{
|
||||
path: 'search/:messageId',
|
||||
name: MAIL_SEARCH_MESSAGE_ROUTE,
|
||||
component: MailboxPage,
|
||||
meta: { title: '메일 검색' },
|
||||
},
|
||||
{ path: ':mailboxId', name: 'mail-mailbox', component: MailboxPage },
|
||||
{ path: ':mailboxId/:messageId', name: 'mail-message', component: MailboxPage },
|
||||
],
|
||||
@@ -92,8 +102,9 @@ router.beforeEach(async (to) => {
|
||||
return true
|
||||
})
|
||||
|
||||
// 메일 화면에서는 MailLayout(useMailTabTitle)이 받은편지함의 안 읽은 수 "(N) " 를 앞에 다시 붙인다 — 이동이 끝난 뒤에 돈다
|
||||
router.afterEach((to) => {
|
||||
document.title = to.meta.title ? `${to.meta.title} · ${APP_NAME}` : APP_NAME
|
||||
document.title = documentTitle(to.meta.title)
|
||||
})
|
||||
|
||||
export default router
|
||||
|
||||
@@ -143,6 +143,19 @@ export const ICONS = {
|
||||
line(4, 18, 4.01, 18),
|
||||
],
|
||||
'arrow-left': [line(19, 12, 5, 12), polyline('12 19 5 12 12 5')],
|
||||
/** 새 메일 알림 줄 "맨 위로" (디자인 원본 ListStates) */
|
||||
'arrow-up': [line(12, 19, 12, 5), polyline('5 12 12 5 19 12')],
|
||||
/** 여러 통 처리 — 읽음 표시 (열린 봉투, 디자인 원본 BulkSelect) */
|
||||
'mail-open': [
|
||||
path('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('m22 10-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 10'),
|
||||
],
|
||||
/** 여러 통 처리 — 이동 (디자인 원본 BulkSelect) */
|
||||
'folder-move': [
|
||||
path('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('M2 13h10'),
|
||||
path('m9 16 3-3-3-3'),
|
||||
],
|
||||
'chevron-left': [polyline('15 18 9 12 15 6')],
|
||||
'chevron-right': [polyline('9 18 15 12 9 6')],
|
||||
'chevron-down': [polyline('6 9 12 15 18 9')],
|
||||
|
||||
@@ -190,6 +190,85 @@ export const DRAFT_RESTORED_MESSAGE = '임시보관함으로 되돌렸습니다.
|
||||
export const DRAFT_NO_RECIPIENTS = '(받는 사람 없음)'
|
||||
export const MAIL_NO_SUBJECT = '(제목 없음)'
|
||||
|
||||
// ── 새 메일 확인 (계약 2절 "새 메일 확인" · 디자인 README "3단계 › 새 메일") ─────────────
|
||||
/** 화면이 보이는 동안 이 간격으로 바뀐 것이 있는지 묻는다 */
|
||||
export const MAIL_STATE_POLL_INTERVAL_MS = 60_000
|
||||
/** 탭이 다시 보일 때 마지막으로 물은 지 이만큼 지났으면 바로 한 번 묻는다 */
|
||||
export const MAIL_STATE_RECHECK_GAP_MS = 10_000
|
||||
/** 바뀌었을 때 보이는 목록을 다시 받는 최대 개수 — 지금까지 불러온 만큼, 그보다 많이 불러왔으면 이만큼만 맞추고 그 뒤는 둔다 */
|
||||
export const MAIL_LIST_SYNC_MAX = 100
|
||||
/** 목록을 내려 본 채 새 메일이 위에 생겼을 때 머리 아래의 알림 줄 */
|
||||
export const NEW_MAIL_NOTICE_TEXT = (count: string) => `새 메일 ${count}통 · 맨 위로`
|
||||
|
||||
// ── 검색 (계약 2절 "검색" · 디자인 README "3단계 › 검색") ─────────────
|
||||
/** 검색 범위 "전체 메일함" — 주소의 in 값 (휴지통 · 스팸메일함은 서버가 뺀다) */
|
||||
export const SEARCH_SCOPE_ALL = 'all'
|
||||
/** 검색어 길이 — 앞뒤 공백을 뺀 1~200자, JS 문자열 길이(UTF-16) 기준 (넘으면 400 VAL_001) */
|
||||
export const MAIL_SEARCH_MAX_LENGTH = 200
|
||||
/** 검색어 낱말 수 — 띄어쓰기로 나눈 조각 8개까지 (넘으면 400 VAL_001) */
|
||||
export const MAIL_SEARCH_MAX_WORDS = 8
|
||||
|
||||
export const MAIL_SEARCH_TEXT = {
|
||||
/** 메일 화면에서 상단 검색 칸의 이름 · 안내 */
|
||||
box: '메일 검색',
|
||||
clear: '검색어 지우기',
|
||||
clearTitle: '검색어 지우기 (Esc)',
|
||||
close: '검색 닫기',
|
||||
/** "검색 닫기 — 받은편지함으로" — 돌아갈 메일함 이름과 조사는 부르는 쪽이 붙인다 */
|
||||
closeTitle: (place: string) => `검색 닫기 — ${place}`,
|
||||
title: '검색 결과',
|
||||
quoted: (query: string) => `‘${query}’`,
|
||||
count: (query: string, count: string) => `‘${query}’ · ${count}통`,
|
||||
searching: (query: string) => `‘${query}’ · 찾는 중`,
|
||||
scopeLabel: '찾을 곳',
|
||||
scopeAll: '전체 메일함',
|
||||
scopeAllHint: '휴지통 · 스팸메일함은 빼고 찾습니다',
|
||||
emptyTitle: (query: string) => `‘${query}’에 맞는 메일이 없습니다`,
|
||||
emptyHint:
|
||||
'낱말 단위로 찾습니다. 붙여 쓴 말의 일부(‘서버이전’의 ‘서버’)나 제목 맨 앞의 말머리([공지] · Re:)로는 찾지 못합니다.',
|
||||
searchAll: '전체 메일함에서 찾기',
|
||||
/** 결과 목록의 보이지 않는 이름 (불러오는 동안) */
|
||||
loading: '찾는 중',
|
||||
/** 검색 결과에서 연 메일의 읽기 화면 — "메일함 받은편지함" */
|
||||
mailboxLine: '메일함',
|
||||
/** 전체 메일함 결과에서 보낸편지함 · 임시보관함의 메일 — 보낸 사람 자리에 받는 사람 */
|
||||
recipientPrefix: '받는 사람: ',
|
||||
/** 결과 표의 메일함 칸 머리 */
|
||||
mailboxColumn: '메일함',
|
||||
} as const
|
||||
|
||||
// ── 여러 통 한꺼번에 (계약 2절 "여러 통 한꺼번에" · 디자인 README "3단계 › 여러 통 처리") ─────────────
|
||||
/** 요청 하나의 ids 한도 — 넘으면 이만큼씩 차례로 보낸다 */
|
||||
export const MAIL_BULK_MAX_IDS = 100
|
||||
|
||||
export const MAIL_BULK_TEXT = {
|
||||
toolbar: '고른 메일',
|
||||
selected: (count: string) => `${count}통 선택됨`,
|
||||
markRead: '읽음 표시',
|
||||
markUnread: '안 읽음 표시',
|
||||
flag: '중요 표시',
|
||||
unflag: '중요 해제',
|
||||
move: '이동',
|
||||
moveMenu: '옮길 메일함',
|
||||
delete: '삭제',
|
||||
purge: '영구 삭제',
|
||||
clear: '선택 해제',
|
||||
clearTitle: '선택 해제 (Esc)',
|
||||
/** 표 머리의 고르기 상자 */
|
||||
selectAll: '불러온 메일 모두 고르기',
|
||||
/** 행의 고르기 상자 */
|
||||
selectOne: (subject: string) => `고르기: ${subject}`,
|
||||
/** place 에는 메일함 이름과 조사를 붙여 넘긴다 ("보낸편지함으로") */
|
||||
moved: (count: string, place: string) => `메일 ${count}통을 ${place} 옮겼습니다.`,
|
||||
purged: (count: string) => `메일 ${count}통을 영구 삭제했습니다.`,
|
||||
restored: (count: string) => `메일 ${count}통을 되돌렸습니다.`,
|
||||
skipped: (count: string) => `${count}통은 그사이 옮겨졌거나 지워져 그대로 두었습니다.`,
|
||||
purgeTitle: (count: string) => `메일 ${count}통을 영구 삭제할까요?`,
|
||||
purgeMessage: '휴지통에서 지운 메일은 되돌릴 수 없습니다.',
|
||||
purgeConfirm: '영구 삭제',
|
||||
purgeCancel: '취소',
|
||||
} as const
|
||||
|
||||
// ── 읽기 화면의 본문 안내 (디자인 README "읽기 화면의 본문 안내" · 계약 2절 "읽기 한도") ─────────
|
||||
/**
|
||||
* 서버가 본문을 다 주지 못했을 때 본문 위에 두는 안내 — "외부 이미지를 차단했습니다"와 같은 모양.
|
||||
|
||||
@@ -95,9 +95,13 @@ export interface MailQuota {
|
||||
attachmentTotalMaxBytes: number
|
||||
}
|
||||
|
||||
/** GET /messages 쿼리 */
|
||||
/**
|
||||
* GET /messages 쿼리 — mailboxId 와 q 중 하나는 있어야 한다 (계약 2절 "검색").
|
||||
* q 만 있으면 휴지통 · 스팸메일함을 뺀 모든 메일함에서 찾는다
|
||||
*/
|
||||
export interface MessageListQuery extends CursorPaginationQuery {
|
||||
mailboxId: string
|
||||
mailboxId?: string
|
||||
/** 검색어 — 앞뒤 공백을 뺀 1~200자, 낱말 8개까지 */
|
||||
q?: string
|
||||
}
|
||||
|
||||
@@ -108,6 +112,37 @@ export interface UpdateMessagePayload {
|
||||
mailboxId?: string
|
||||
}
|
||||
|
||||
// ── 새 메일 확인 (계약 2절 "새 메일 확인" — 3단계) ──────────────────
|
||||
|
||||
/** GET /mail-state — 불투명 값. 해석하지 말고 지난 값과 같은지만 본다 */
|
||||
export interface MailState {
|
||||
state: string
|
||||
}
|
||||
|
||||
// ── 여러 통 한꺼번에 (계약 2절 "여러 통 한꺼번에" — 3단계) ─────────────
|
||||
|
||||
/** PATCH /messages 본문 — ids 1~100(같은 id 를 두 번 넣지 않는다), 바꿀 것이 하나 이상 */
|
||||
export interface MessagesPatch extends UpdateMessagePayload {
|
||||
ids: string[]
|
||||
}
|
||||
|
||||
/** PATCH /messages 응답 — failedIds: 없음 · 그사이 지워짐 · 엔진이 거절함 (나머지는 그대로 처리했다) */
|
||||
export interface MessagesPatched {
|
||||
updatedIds: string[]
|
||||
failedIds: string[]
|
||||
}
|
||||
|
||||
/** POST /message-deletions 본문 — 휴지통에서 영구 삭제, ids 1~100 */
|
||||
export interface MessageDeletionRequest {
|
||||
ids: string[]
|
||||
}
|
||||
|
||||
/** POST /message-deletions 응답 — skippedIds: 휴지통에 없음 · 없음 · 엔진이 거절 (지우지 않았다) */
|
||||
export interface MessageDeletionResult {
|
||||
deletedIds: string[]
|
||||
skippedIds: string[]
|
||||
}
|
||||
|
||||
/** 발송 본문에 싣는 첨부 — POST /mail-attachments 응답의 id */
|
||||
export interface AttachmentRef {
|
||||
id: string
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { documentTitle } from './document-title'
|
||||
|
||||
describe('documentTitle (탭 제목)', () => {
|
||||
it('받은편지함에 안 읽은 메일이 있으면 앞에 "(N) " (디자인 원본 ListStates)', () => {
|
||||
expect(documentTitle('메일', 3)).toBe('(3) 메일 · ARA SYSTEM')
|
||||
expect(documentTitle('메일 쓰기', 3)).toBe('(3) 메일 쓰기 · ARA SYSTEM')
|
||||
expect(documentTitle('메일', 1_204)).toBe('(1,204) 메일 · ARA SYSTEM')
|
||||
})
|
||||
|
||||
it('모두 읽었으면 그대로 — 제목이 없으면 앱 이름만', () => {
|
||||
expect(documentTitle('메일', 0)).toBe('메일 · ARA SYSTEM')
|
||||
expect(documentTitle('설정')).toBe('설정 · ARA SYSTEM')
|
||||
expect(documentTitle(undefined)).toBe('ARA SYSTEM')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,10 @@
|
||||
// 브라우저 탭 제목 — "메일 · ARA SYSTEM", 받은편지함에 안 읽은 메일이 있으면 "(3) 메일 · ARA SYSTEM" (순수 함수만 둔다)
|
||||
// 디자인 README "3단계 › 새 메일" — 다른 탭에서 일할 때 알아보게. 소리 · 데스크톱 알림은 하지 않는다.
|
||||
|
||||
import { APP_NAME } from '@/shared/constants/brand'
|
||||
import { formatCount } from '@/shared/utils/format'
|
||||
|
||||
export function documentTitle(pageTitle: string | undefined, unreadCount = 0): string {
|
||||
const base = pageTitle ? `${pageTitle} · ${APP_NAME}` : APP_NAME
|
||||
return unreadCount > 0 ? `(${formatCount(unreadCount)}) ${base}` : base
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { subjectParticle } from './korean'
|
||||
import { directionParticle, subjectParticle } from './korean'
|
||||
|
||||
describe('subjectParticle (주격 조사 이/가)', () => {
|
||||
it.each([
|
||||
@@ -13,3 +13,25 @@ describe('subjectParticle (주격 조사 이/가)', () => {
|
||||
expect(subjectParticle(word)).toBe(particle)
|
||||
})
|
||||
})
|
||||
|
||||
describe('directionParticle (방향 조사 으로/로)', () => {
|
||||
it.each([
|
||||
// 받침 있음 → 으로
|
||||
['보낸편지함', '으로'],
|
||||
['휴지통', '으로'],
|
||||
['받은편지함', '으로'],
|
||||
['보관함', '으로'],
|
||||
// 받침 없음 → 로
|
||||
['프로젝트', '로'],
|
||||
['스팸메일함 ', '으로'],
|
||||
// ㄹ 받침 → 로
|
||||
['서울', '로'],
|
||||
['메일', '로'],
|
||||
// 한글로 끝나지 않으면 발음을 알 수 없다
|
||||
['Work', '(으)로'],
|
||||
['2026', '(으)로'],
|
||||
['', '(으)로'],
|
||||
])('%s → %s', (word, particle) => {
|
||||
expect(directionParticle(word)).toBe(particle)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -4,16 +4,36 @@ const HANGUL_SYLLABLE_START = 0xac00
|
||||
const HANGUL_SYLLABLE_END = 0xd7a3
|
||||
/** 한글 음절 하나당 받침 경우의 수 (받침 없음 포함) */
|
||||
const JONGSEONG_COUNT = 28
|
||||
/** 받침 번호 — 0 은 받침 없음, 8 은 ㄹ */
|
||||
const NO_JONGSEONG = 0
|
||||
const JONGSEONG_RIEUL = 8
|
||||
|
||||
/** 마지막 글자의 받침 번호 — 한글 음절로 끝나지 않으면(영문 · 숫자 · 빈 글자) null */
|
||||
function lastJongseong(word: string): number | null {
|
||||
const last = Array.from(word.trim()).pop()
|
||||
if (!last) return null
|
||||
|
||||
const code = last.charCodeAt(0)
|
||||
if (code < HANGUL_SYLLABLE_START || code > HANGUL_SYLLABLE_END) return null
|
||||
return (code - HANGUL_SYLLABLE_START) % JONGSEONG_COUNT
|
||||
}
|
||||
|
||||
/**
|
||||
* 주격 조사 "이/가" — 마지막 글자에 받침이 있으면 "이", 없으면 "가".
|
||||
* 한글로 끝나지 않는 이름(영문·숫자)은 발음을 알 수 없어 "이(가)" 를 쓴다.
|
||||
*/
|
||||
export function subjectParticle(word: string): '이' | '가' | '이(가)' {
|
||||
const last = Array.from(word.trim()).pop()
|
||||
if (!last) return '이(가)'
|
||||
|
||||
const code = last.charCodeAt(0)
|
||||
if (code < HANGUL_SYLLABLE_START || code > HANGUL_SYLLABLE_END) return '이(가)'
|
||||
return (code - HANGUL_SYLLABLE_START) % JONGSEONG_COUNT === 0 ? '가' : '이'
|
||||
const jongseong = lastJongseong(word)
|
||||
if (jongseong === null) return '이(가)'
|
||||
return jongseong === NO_JONGSEONG ? '가' : '이'
|
||||
}
|
||||
|
||||
/**
|
||||
* 방향 조사 "으로/로" — 받침이 있으면 "으로", 받침이 없거나 ㄹ 받침이면 "로" (보낸편지함으로 · 휴지통으로 · 보관함으로 · 서울로).
|
||||
* 한글로 끝나지 않는 이름(영문·숫자)은 발음을 알 수 없어 "(으)로" 를 쓴다.
|
||||
*/
|
||||
export function directionParticle(word: string): '으로' | '로' | '(으)로' {
|
||||
const jongseong = lastJongseong(word)
|
||||
if (jongseong === null) return '(으)로'
|
||||
return jongseong === NO_JONGSEONG || jongseong === JONGSEONG_RIEUL ? '로' : '으로'
|
||||
}
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
chunk,
|
||||
groupByOrigin,
|
||||
movedMessageText,
|
||||
purgedMessageText,
|
||||
restoredMessageText,
|
||||
sendInChunks,
|
||||
} from './mail-bulk'
|
||||
|
||||
describe('mail-bulk (여러 통 처리 보조)', () => {
|
||||
it('100통씩 나눈다 — 마지막 묶음은 남은 만큼', () => {
|
||||
const ids = Array.from({ length: 250 }, (_, index) => `m${index}`)
|
||||
const chunks = chunk(ids, 100)
|
||||
|
||||
expect(chunks.map((part) => part.length)).toEqual([100, 100, 50])
|
||||
expect(chunks.flat()).toEqual(ids)
|
||||
expect(chunk([], 100)).toEqual([])
|
||||
})
|
||||
|
||||
it('나눠 차례로 보낸다 — 처리한 메일 · 그대로 둔 메일을 모으고, 요청이 실패하면 그 묶음부터 보내지 않은 것으로 두고 멈춘다', async () => {
|
||||
const ids = Array.from({ length: 7 }, (_, index) => `m${index}`)
|
||||
const sent: string[][] = []
|
||||
|
||||
const result = await sendInChunks(ids, 3, async (chunkIds) => {
|
||||
sent.push(chunkIds)
|
||||
if (sent.length === 2) throw new Error('503')
|
||||
return { done: chunkIds.slice(1), skipped: chunkIds.slice(0, 1) }
|
||||
})
|
||||
|
||||
expect(sent).toEqual([
|
||||
['m0', 'm1', 'm2'],
|
||||
['m3', 'm4', 'm5'],
|
||||
])
|
||||
expect(result).toEqual({ doneIds: ['m1', 'm2'], skippedIds: ['m0'], unsentIds: ['m3', 'm4', 'm5', 'm6'] })
|
||||
})
|
||||
|
||||
it('되돌리기 — 원래 메일함별로 묶는다, 원래 메일함을 모르는 메일은 뺀다', () => {
|
||||
const origins = new Map([
|
||||
['m1', 'inbox'],
|
||||
['m2', 'project'],
|
||||
['m3', 'inbox'],
|
||||
])
|
||||
|
||||
expect(groupByOrigin(['m1', 'm2', 'm3', 'm4'], origins)).toEqual([
|
||||
{ mailboxId: 'inbox', ids: ['m1', 'm3'] },
|
||||
{ mailboxId: 'project', ids: ['m2'] },
|
||||
])
|
||||
})
|
||||
|
||||
it('옮긴 알림 — 메일함 이름의 받침에 맞춰 으로/로 (디자인 README "3단계 › 여러 통 처리")', () => {
|
||||
expect(movedMessageText(3, '보낸편지함')).toBe('메일 3통을 보낸편지함으로 옮겼습니다.')
|
||||
expect(movedMessageText(2, '휴지통')).toBe('메일 2통을 휴지통으로 옮겼습니다.')
|
||||
expect(movedMessageText(1, '프로젝트')).toBe('메일 1통을 프로젝트로 옮겼습니다.')
|
||||
expect(movedMessageText(1, '메일')).toBe('메일 1통을 메일로 옮겼습니다.')
|
||||
})
|
||||
|
||||
it('일부만 처리했으면 뒤에 덧붙이고, 하나도 못 했으면 그 문장만', () => {
|
||||
expect(movedMessageText(2, '보낸편지함', 1)).toBe(
|
||||
'메일 2통을 보낸편지함으로 옮겼습니다. 1통은 그사이 옮겨졌거나 지워져 그대로 두었습니다.',
|
||||
)
|
||||
expect(purgedMessageText(3)).toBe('메일 3통을 영구 삭제했습니다.')
|
||||
expect(purgedMessageText(0, 2)).toBe('2통은 그사이 옮겨졌거나 지워져 그대로 두었습니다.')
|
||||
expect(restoredMessageText(1_234)).toBe('메일 1,234통을 되돌렸습니다.')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,90 @@
|
||||
// 여러 통 처리 보조 — 나눠 보내기 · 되돌리기 묶음 · 알림 문구 (계약 2절 "여러 통 한꺼번에" — 순수 함수만 둔다)
|
||||
|
||||
import { MAIL_BULK_TEXT } from '@/shared/constants/mail'
|
||||
import { formatCount } from '@/shared/utils/format'
|
||||
import { directionParticle } from '@/shared/utils/korean'
|
||||
|
||||
/** size 개씩 나눈다 — 요청 하나의 ids 는 100개까지다 */
|
||||
export function chunk<T>(items: readonly T[], size: number): T[][] {
|
||||
const step = Math.max(1, Math.floor(size))
|
||||
const result: T[][] = []
|
||||
for (let start = 0; start < items.length; start += step) result.push(items.slice(start, start + step))
|
||||
return result
|
||||
}
|
||||
|
||||
/** 여러 통 처리의 결과 */
|
||||
export interface BulkResult {
|
||||
/** 처리한 메일 */
|
||||
doneIds: string[]
|
||||
/** 그사이 옮겨졌거나 지워져 그대로 둔 메일 — 서버의 failedIds · skippedIds */
|
||||
skippedIds: string[]
|
||||
/** 요청이 실패해 보내지 못한 메일 — 알림은 인터셉터가 띄웠다 */
|
||||
unsentIds: string[]
|
||||
}
|
||||
|
||||
export function emptyBulkResult(): BulkResult {
|
||||
return { doneIds: [], skippedIds: [], unsentIds: [] }
|
||||
}
|
||||
|
||||
/** 한 묶음을 보낸 결과 — 처리한 메일 · 그대로 둔 메일 */
|
||||
export type BulkChunkSender = (chunkIds: string[]) => Promise<{ done: readonly string[]; skipped: readonly string[] }>
|
||||
|
||||
/**
|
||||
* ids 를 size 통씩 차례로 보낸다 — 요청이 실패하면(알림은 인터셉터) 그 묶음과 남은 묶음은 보내지 않은 것으로 두고 멈춘다
|
||||
* (같은 오류가 이어질 것이고, 503 은 일부만 바뀌었을 수 있어 부르는 쪽이 목록을 다시 맞춘다)
|
||||
*/
|
||||
export async function sendInChunks(ids: readonly string[], size: number, send: BulkChunkSender): Promise<BulkResult> {
|
||||
const result = emptyBulkResult()
|
||||
const chunks = chunk(ids, size)
|
||||
for (const [index, chunkIds] of chunks.entries()) {
|
||||
try {
|
||||
const { done, skipped } = await send(chunkIds)
|
||||
result.doneIds.push(...done)
|
||||
result.skippedIds.push(...skipped)
|
||||
} catch {
|
||||
result.unsentIds.push(...chunks.slice(index).flat())
|
||||
break
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
/** 되돌리기 한 묶음 — 같은 메일함으로 돌아갈 메일 */
|
||||
export interface BulkOrigin {
|
||||
mailboxId: string
|
||||
ids: string[]
|
||||
}
|
||||
|
||||
/** 옮긴 메일을 원래 메일함별로 묶는다 (옮긴 순서대로). 원래 메일함을 모르는 메일은 뺀다 */
|
||||
export function groupByOrigin(ids: readonly string[], originOf: ReadonlyMap<string, string>): BulkOrigin[] {
|
||||
const groups = new Map<string, string[]>()
|
||||
for (const id of ids) {
|
||||
const origin = originOf.get(id)
|
||||
if (!origin) continue
|
||||
const group = groups.get(origin) ?? []
|
||||
group.push(id)
|
||||
groups.set(origin, group)
|
||||
}
|
||||
return [...groups].map(([mailboxId, groupIds]) => ({ mailboxId, ids: groupIds }))
|
||||
}
|
||||
|
||||
/** 일부를 처리하지 못했으면 "N통은 그사이 옮겨졌거나 지워져 그대로 두었습니다." 를 덧붙인다 — 하나도 못 했으면 그 문장만 */
|
||||
function withSkipped(text: string, done: number, skipped: number): string {
|
||||
if (skipped <= 0) return text
|
||||
const suffix = MAIL_BULK_TEXT.skipped(formatCount(skipped))
|
||||
return done > 0 ? `${text} ${suffix}` : suffix
|
||||
}
|
||||
|
||||
/** "메일 3통을 보낸편지함으로 옮겼습니다." — 메일함 이름의 받침에 맞춰 '으로/로' */
|
||||
export function movedMessageText(done: number, mailboxName: string, skipped = 0): string {
|
||||
const place = `${mailboxName}${directionParticle(mailboxName)}`
|
||||
return withSkipped(MAIL_BULK_TEXT.moved(formatCount(done), place), done, skipped)
|
||||
}
|
||||
|
||||
export function purgedMessageText(done: number, skipped = 0): string {
|
||||
return withSkipped(MAIL_BULK_TEXT.purged(formatCount(done)), done, skipped)
|
||||
}
|
||||
|
||||
export function restoredMessageText(done: number, skipped = 0): string {
|
||||
return withSkipped(MAIL_BULK_TEXT.restored(formatCount(done)), done, skipped)
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import type { MessageSummary } from '@/shared/types/mail'
|
||||
import type { PageInfo } from '@/shared/types/pagination'
|
||||
import { mergeRefreshedList } from './mail-list-merge'
|
||||
|
||||
function summary(id: string, partial: Partial<MessageSummary> = {}): MessageSummary {
|
||||
return {
|
||||
id,
|
||||
threadId: `t-${id}`,
|
||||
mailboxIds: ['inbox'],
|
||||
from: null,
|
||||
to: [],
|
||||
subject: id,
|
||||
preview: '',
|
||||
receivedAt: '2026-10-27T01:00:00Z',
|
||||
isRead: true,
|
||||
isFlagged: false,
|
||||
hasAttachment: false,
|
||||
size: 1,
|
||||
...partial,
|
||||
}
|
||||
}
|
||||
|
||||
const ids = (list: readonly MessageSummary[]) => list.map((message) => message.id)
|
||||
const range = (prefix: string, from: number, to: number) =>
|
||||
Array.from({ length: to - from + 1 }, (_, index) => summary(`${prefix}${from + index}`))
|
||||
const page = (hasNext: boolean, nextCursor: string | null, total: number | null): PageInfo => ({
|
||||
hasNext,
|
||||
nextCursor,
|
||||
total,
|
||||
})
|
||||
|
||||
describe('mergeRefreshedList (새 메일 확인으로 목록 맞추기)', () => {
|
||||
it('새 메일이 위에 생기면 그 수를 센다 — 다시 받은 범위는 서버 기준(읽음 · 다른 창에서 지운 메일)', () => {
|
||||
const current = [summary('a'), summary('b', { isRead: false }), summary('c'), summary('d')]
|
||||
const fetched = [summary('new1'), summary('new2'), summary('a'), summary('b', { isRead: true }), summary('d')]
|
||||
|
||||
const result = mergeRefreshedList({
|
||||
current,
|
||||
currentPageInfo: page(true, 'old-cursor', 40),
|
||||
fetched,
|
||||
fetchedPageInfo: page(true, 'new-cursor', 41),
|
||||
})
|
||||
|
||||
expect(ids(result.messages)).toEqual(['new1', 'new2', 'a', 'b', 'd'])
|
||||
expect(result.messages[3]?.isRead).toBe(true)
|
||||
expect(result.addedAbove).toBe(2)
|
||||
expect(result.pageInfo).toEqual(page(true, 'new-cursor', 41))
|
||||
})
|
||||
|
||||
it('새 메일이 와서 다시 받은 범위 밖으로 밀려난 옛 메일은 그대로 두고, "더 보기"는 그 뒤부터(옛 커서) 잇는다', () => {
|
||||
const current = range('m', 1, 20)
|
||||
const fetched = [summary('new1'), ...range('m', 1, 19)]
|
||||
|
||||
const result = mergeRefreshedList({
|
||||
current,
|
||||
currentPageInfo: page(true, 'after-m20', 50),
|
||||
fetched,
|
||||
fetchedPageInfo: page(true, 'after-m19', 51),
|
||||
})
|
||||
|
||||
expect(ids(result.messages)).toEqual(['new1', ...ids(range('m', 1, 20))])
|
||||
expect(result.pageInfo).toEqual(page(true, 'after-m20', 51))
|
||||
})
|
||||
|
||||
it('100통보다 많이 불러왔으면 첫 100통만 맞추고 그 뒤는 둔다', () => {
|
||||
const current = range('m', 1, 140)
|
||||
const fetched = [summary('new1'), ...range('m', 1, 99)]
|
||||
|
||||
const result = mergeRefreshedList({
|
||||
current,
|
||||
currentPageInfo: page(true, 'after-m140', 300),
|
||||
fetched,
|
||||
fetchedPageInfo: page(true, 'after-m99', 301),
|
||||
})
|
||||
|
||||
expect(result.messages).toHaveLength(141)
|
||||
expect(ids(result.messages).slice(0, 3)).toEqual(['new1', 'm1', 'm2'])
|
||||
expect(ids(result.messages).slice(-1)).toEqual(['m140'])
|
||||
expect(result.pageInfo.nextCursor).toBe('after-m140')
|
||||
expect(result.addedAbove).toBe(1)
|
||||
})
|
||||
|
||||
it('겹치는 메일이 없으면(새 메일이 다시 받은 범위보다 많다) 다시 받은 것만 쓴다 — 사이가 빈 목록을 만들지 않는다', () => {
|
||||
const result = mergeRefreshedList({
|
||||
current: range('m', 1, 20),
|
||||
currentPageInfo: page(true, 'after-m20', 50),
|
||||
fetched: range('new', 1, 20),
|
||||
fetchedPageInfo: page(true, 'after-new20', 70),
|
||||
})
|
||||
|
||||
expect(ids(result.messages)).toEqual(ids(range('new', 1, 20)))
|
||||
expect(result.pageInfo.nextCursor).toBe('after-new20')
|
||||
expect(result.addedAbove).toBe(20)
|
||||
})
|
||||
|
||||
it('달라진 것이 없으면 그대로 — 새 메일 0', () => {
|
||||
const current = range('m', 1, 3)
|
||||
const result = mergeRefreshedList({
|
||||
current,
|
||||
currentPageInfo: page(false, null, 3),
|
||||
fetched: range('m', 1, 3),
|
||||
fetchedPageInfo: page(false, null, 3),
|
||||
})
|
||||
|
||||
expect(ids(result.messages)).toEqual(['m1', 'm2', 'm3'])
|
||||
expect(result.addedAbove).toBe(0)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,49 @@
|
||||
// 새 메일 확인으로 보이는 목록을 다시 받아 맞출 때의 병합 (계약 2절 "새 메일 확인" — 순수 함수만 둔다)
|
||||
// - 지금까지 불러온 만큼(최대 100통)을 다시 받아 그 범위는 서버 기준으로 바꾼다 (새 메일 · 다른 창의 읽음 · 이동 · 삭제)
|
||||
// - 다시 받은 범위 밖의 옛 메일은 그대로 둔다 — 100통보다 많이 불러왔거나, 새 메일이 와서 범위 밖으로 밀려난 메일
|
||||
// (밀려난 메일을 버리면 보던 행이 사라지고 스크롤이 튄다). 겹치는 메일이 없으면(새 메일이 범위보다 많이 옴) 다시 받은 것만 쓴다
|
||||
|
||||
import type { MessageSummary } from '@/shared/types/mail'
|
||||
import type { PageInfo } from '@/shared/types/pagination'
|
||||
|
||||
export interface ListMergeInput {
|
||||
current: readonly MessageSummary[]
|
||||
currentPageInfo: PageInfo | null
|
||||
fetched: readonly MessageSummary[]
|
||||
fetchedPageInfo: PageInfo
|
||||
}
|
||||
|
||||
export interface ListMergeResult {
|
||||
messages: MessageSummary[]
|
||||
pageInfo: PageInfo
|
||||
/** 목록 맨 위에 새로 생긴 메일 수 — 전에 없던 메일이 맨 앞에 몇 통 이어지는지 ("새 메일 N통") */
|
||||
addedAbove: number
|
||||
}
|
||||
|
||||
export function mergeRefreshedList(input: ListMergeInput): ListMergeResult {
|
||||
const { current, fetched } = input
|
||||
const fetchedIds = new Set(fetched.map((message) => message.id))
|
||||
const currentIds = new Set(current.map((message) => message.id))
|
||||
|
||||
// 다시 받은 범위와 겹치는 마지막 옛 메일 — 그 뒤의 옛 메일은 다시 받지 않았다
|
||||
let lastOverlap = -1
|
||||
current.forEach((message, index) => {
|
||||
if (fetchedIds.has(message.id)) lastOverlap = index
|
||||
})
|
||||
const tail =
|
||||
lastOverlap === -1 ? [] : current.slice(lastOverlap + 1).filter((message) => !fetchedIds.has(message.id))
|
||||
|
||||
// 남겨 둔 옛 메일이 있으면 "더 보기"는 그 뒤부터 이어야 한다 — 옛 커서(마지막으로 받은 메일 다음)를 쓴다. 전체 개수는 새 값
|
||||
const pageInfo =
|
||||
tail.length > 0 && input.currentPageInfo
|
||||
? { ...input.currentPageInfo, total: input.fetchedPageInfo.total }
|
||||
: input.fetchedPageInfo
|
||||
|
||||
let addedAbove = 0
|
||||
for (const message of fetched) {
|
||||
if (currentIds.has(message.id)) break
|
||||
addedAbove += 1
|
||||
}
|
||||
|
||||
return { messages: [...fetched, ...tail], pageInfo, addedAbove }
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import type { Mailbox, MessageSummary } from '@/shared/types/mail'
|
||||
import { isRowUnread, mailboxOfMessage, rowWho, whoColumnLabel } from './mail-list-row'
|
||||
|
||||
const MAILBOXES: Mailbox[] = [
|
||||
{ id: 'inbox', name: '받은편지함', role: 'inbox', parentId: null, sortOrder: 0, totalCount: 0, unreadCount: 0 },
|
||||
{ id: 'sent', name: '보낸편지함', role: 'sent', parentId: null, sortOrder: 1, totalCount: 0, unreadCount: 0 },
|
||||
{ id: 'project', name: '프로젝트', role: null, parentId: null, sortOrder: 9, totalCount: 0, unreadCount: 0 },
|
||||
]
|
||||
|
||||
const MESSAGE: Pick<MessageSummary, 'from' | 'to' | 'isRead' | 'mailboxIds'> = {
|
||||
from: { name: '이도윤', email: 'doyun.lee@arasystem.test' },
|
||||
to: [
|
||||
{ name: '정하늘', email: 'haneul@arasystem.test' },
|
||||
{ name: null, email: 'ops@arasystem.test' },
|
||||
],
|
||||
isRead: false,
|
||||
mailboxIds: ['inbox'],
|
||||
}
|
||||
|
||||
describe('mail-list-row (목록 행의 표시)', () => {
|
||||
it('받은 메일 — 보낸 사람 (이름이 없으면 주소, 없으면 "(보낸 사람 없음)")', () => {
|
||||
expect(rowWho(MESSAGE, { role: 'inbox', mixed: false, view: 'split' })).toEqual({ text: '이도윤', placeholder: false })
|
||||
expect(rowWho({ ...MESSAGE, from: null }, { role: 'inbox', mixed: false, view: 'table' }).text).toBe(
|
||||
'(보낸 사람 없음)',
|
||||
)
|
||||
})
|
||||
|
||||
it('보낸편지함 — 분할 보기는 "받는 사람: …", 표는 머리가 받는 사람이라 이름만, 전체 검색 결과의 표는 "받는 사람: …"', () => {
|
||||
expect(rowWho(MESSAGE, { role: 'sent', mixed: false, view: 'split' }).text).toBe('받는 사람: 정하늘 외 1명')
|
||||
expect(rowWho(MESSAGE, { role: 'sent', mixed: false, view: 'table' }).text).toBe('정하늘 외 1명')
|
||||
expect(rowWho(MESSAGE, { role: 'sent', mixed: true, view: 'table' }).text).toBe('받는 사람: 정하늘 외 1명')
|
||||
expect(rowWho({ ...MESSAGE, to: [] }, { role: 'sent', mixed: false, view: 'split' }).text).toBe('받는 사람 없음')
|
||||
})
|
||||
|
||||
it('임시보관함 — 받는 사람을 쉼표로(전체 검색 결과는 "받는 사람: "), 없으면 흐린 "(받는 사람 없음)"', () => {
|
||||
expect(rowWho(MESSAGE, { role: 'drafts', mixed: false, view: 'split' })).toEqual({
|
||||
text: '정하늘, ops@arasystem.test',
|
||||
placeholder: false,
|
||||
})
|
||||
expect(rowWho(MESSAGE, { role: 'drafts', mixed: true, view: 'split' }).text).toBe(
|
||||
'받는 사람: 정하늘, ops@arasystem.test',
|
||||
)
|
||||
expect(rowWho({ ...MESSAGE, to: [] }, { role: 'drafts', mixed: true, view: 'table' })).toEqual({
|
||||
text: '(받는 사람 없음)',
|
||||
placeholder: true,
|
||||
})
|
||||
})
|
||||
|
||||
it('임시 저장본은 읽음 여부를 보이지 않는다', () => {
|
||||
expect(isRowUnread(MESSAGE, 'inbox')).toBe(true)
|
||||
expect(isRowUnread(MESSAGE, 'drafts')).toBe(false)
|
||||
expect(isRowUnread({ isRead: true }, null)).toBe(false)
|
||||
})
|
||||
|
||||
it('표 머리의 보낸 사람 칸 — 한 메일함의 보낸편지함 · 임시보관함만 "받는 사람"', () => {
|
||||
expect(whoColumnLabel('sent', false)).toBe('받는 사람')
|
||||
expect(whoColumnLabel('drafts', false)).toBe('받는 사람')
|
||||
expect(whoColumnLabel('sent', true)).toBe('보낸 사람')
|
||||
expect(whoColumnLabel('inbox', false)).toBe('보낸 사람')
|
||||
expect(whoColumnLabel(null, false)).toBe('보낸 사람')
|
||||
})
|
||||
|
||||
it('메일의 메일함 — 목록이 가리키는 메일함에 있으면 그것, 아니면 아는 첫 메일함, 모르면 null', () => {
|
||||
expect(mailboxOfMessage({ mailboxIds: ['unknown', 'project', 'inbox'] }, MAILBOXES)?.name).toBe('프로젝트')
|
||||
expect(mailboxOfMessage({ mailboxIds: ['project', 'inbox'] }, MAILBOXES, 'inbox')?.name).toBe('받은편지함')
|
||||
expect(mailboxOfMessage({ mailboxIds: ['project'] }, MAILBOXES, 'inbox')?.name).toBe('프로젝트')
|
||||
expect(mailboxOfMessage({ mailboxIds: ['unknown'] }, MAILBOXES)).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,77 @@
|
||||
// 목록 행의 표시 — 분할 보기(2줄 행)와 목록 보기(표)가 같은 규칙을 쓴다 (순수 함수만 둔다)
|
||||
// - 임시보관함: 보낸 사람 자리에 받는 사람(없으면 흐린 "(받는 사람 없음)"), 읽음 여부를 보이지 않고, 누르면 쓰기 화면에서 이어 쓴다
|
||||
// - 보낸편지함: 받는 사람 — 분할 보기는 "받는 사람: …", 표는 머리가 "받는 사람"이라 이름만
|
||||
// - 전체 메일함 검색 결과는 행마다 메일함이 다르다 — 보낸편지함 · 임시보관함의 메일은 "받는 사람: 이름" (디자인 README "3단계 › 검색")
|
||||
|
||||
import {
|
||||
DRAFT_NO_RECIPIENTS,
|
||||
MAIL_SEARCH_TEXT,
|
||||
OUTGOING_MAILBOX_ROLES,
|
||||
} from '@/shared/constants/mail'
|
||||
import type { Mailbox, MailboxRole, MessageSummary } from '@/shared/types/mail'
|
||||
import { displayName, draftRecipientNames, summarizeRecipients } from '@/shared/utils/mail-address'
|
||||
|
||||
const NO_RECIPIENTS = '받는 사람 없음'
|
||||
const NO_SENDER = '(보낸 사람 없음)'
|
||||
|
||||
export interface RowContext {
|
||||
/** 행의 메일함 역할 — 메일함 목록 · 한 메일함 안의 검색은 그 메일함, 전체 메일함 검색은 그 메일의 메일함 */
|
||||
role: MailboxRole | null
|
||||
/** 전체 메일함 검색 결과 — 행마다 메일함이 다르다 */
|
||||
mixed: boolean
|
||||
view: 'split' | 'table'
|
||||
}
|
||||
|
||||
export interface RowWho {
|
||||
text: string
|
||||
/** 비어 있는 값 — 흐린 글자로 */
|
||||
placeholder: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* 메일이 속한 메일함 — 목록이 가리키는 메일함(preferredId)에 있으면 그것, 아니면 메일의 메일함 가운데 아는 첫 메일함.
|
||||
* 메일함 목록을 아직 못 받았거나 모르는 메일함뿐이면 null
|
||||
*/
|
||||
export function mailboxOfMessage(
|
||||
message: Pick<MessageSummary, 'mailboxIds'>,
|
||||
mailboxes: readonly Mailbox[],
|
||||
preferredId: string | null = null,
|
||||
): Mailbox | null {
|
||||
const ids = preferredId && message.mailboxIds.includes(preferredId) ? [preferredId] : message.mailboxIds
|
||||
for (const id of ids) {
|
||||
const found = mailboxes.find((mailbox) => mailbox.id === id)
|
||||
if (found) return found
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export function isDraftRole(role: MailboxRole | null): boolean {
|
||||
return role === 'drafts'
|
||||
}
|
||||
|
||||
/** 임시 저장본은 읽음 여부를 보이지 않는다 */
|
||||
export function isRowUnread(message: Pick<MessageSummary, 'isRead'>, role: MailboxRole | null): boolean {
|
||||
return !isDraftRole(role) && !message.isRead
|
||||
}
|
||||
|
||||
/** 보낸 사람 자리 */
|
||||
export function rowWho(message: Pick<MessageSummary, 'from' | 'to'>, context: RowContext): RowWho {
|
||||
const { role, mixed, view } = context
|
||||
if (isDraftRole(role)) {
|
||||
const names = draftRecipientNames(message.to)
|
||||
if (!names) return { text: DRAFT_NO_RECIPIENTS, placeholder: true }
|
||||
return { text: mixed ? `${MAIL_SEARCH_TEXT.recipientPrefix}${names}` : names, placeholder: false }
|
||||
}
|
||||
if (role && OUTGOING_MAILBOX_ROLES.includes(role)) {
|
||||
const recipients = summarizeRecipients(message.to)
|
||||
if (!recipients) return { text: NO_RECIPIENTS, placeholder: false }
|
||||
const prefixed = view === 'split' || mixed
|
||||
return { text: prefixed ? `${MAIL_SEARCH_TEXT.recipientPrefix}${recipients}` : recipients, placeholder: false }
|
||||
}
|
||||
return { text: displayName(message.from) || NO_SENDER, placeholder: false }
|
||||
}
|
||||
|
||||
/** 표 머리의 보낸 사람 칸 이름 — 보낸편지함 · 임시보관함(한 메일함일 때)은 받는 사람 */
|
||||
export function whoColumnLabel(role: MailboxRole | null, mixed: boolean): string {
|
||||
return !mixed && role && OUTGOING_MAILBOX_ROLES.includes(role) ? '받는 사람' : '보낸 사람'
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user