generated from ttipo/project-base
Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2d9c6b87d6 | |||
| af1e581be1 | |||
| 05fc3909dd | |||
| cbfccaa499 |
@@ -35,6 +35,7 @@ backend/src/common/errors/error-codes.ts ← 여기만 수정한다
|
||||
| AUTH_002 | — | **클라이언트 전용** · 세션 만료 | "안전을 위해 로그아웃 되었습니다. 다시 로그인해 주세요." |
|
||||
| AUTH_003 | 403 | 권한 부족 | "해당 메뉴나 기능에 접근할 수 있는 권한이 없습니다." |
|
||||
| AUTH_004 | 401 | 로그인 자격 증명 불일치 (계정 없음·비밀번호 틀림을 구분하지 않음) | "이메일 또는 비밀번호가 올바르지 않습니다." |
|
||||
| AUTH_005 | 400 | 로그인한 채 본인 확인 실패 — 비밀번호 변경의 지금 비밀번호가 틀림. **401 이 아니다** — 클라이언트는 401 이면 세션을 갱신하고 같은 요청을 다시 보낸다 | "지금 비밀번호가 올바르지 않습니다." |
|
||||
| VAL_001 | 400 / 422 | 입력 검증 실패 | "입력하신 정보를 다시 확인해 주세요. (필수값 누락 또는 형식 오류)" |
|
||||
| RES_001 | 404 | 리소스 없음 | "요청하신 정보나 페이지를 찾을 수 없습니다." |
|
||||
| BIZ_001 | 400 | 그 외 비즈니스 규칙 위반 | "요청하신 작업을 완료하지 못했습니다. 다시 시도해 주세요." |
|
||||
|
||||
@@ -28,8 +28,9 @@ description: NestJS 백엔드(backend/) 개발 규칙 — 컨트롤러/서비스
|
||||
| **출처 검사(CSRF)** | `src/common/guards/origin.guard.ts` (APP_GUARD) | 상태 변경 요청은 `Sec-Fetch-Site`(same-origin·none) 또는 허용 목록(`CORS_ORIGIN`)의 `Origin` 이어야 통과, 아니면 `AUTH_003`. 둘 다 없으면 통과. 규칙 `shared/utils/request-origin.ts` |
|
||||
| **인증 가드** | `src/common/guards/jwt-auth.guard.ts` | `JwtAuthGuard` — 액세스 토큰 쿠키 검증, 실패는 `AUTH_001`. 전략은 `modules/auth/jwt.strategy.ts` |
|
||||
| 현재 사용자 데코레이터 | `src/common/decorators/current-user.decorator.ts` | `@CurrentUser()` → `AuthenticatedUser { email, name }` |
|
||||
| 직원 인증 | `modules/auth` | 쿠키 JWT 로그인·`me`·`refresh`·`logout` (계약 `docs/mail/api-contract.md` 1절). 설정 `auth.settings.ts`, 쿠키 `auth-cookies.ts`, 로그인 실패 횟수 `login-attempt.service.ts`, **로그인 세션(DB)** `auth-session.entity.ts`·`auth-session.policy.ts`(1회용 회전·재사용 감지)·`auth-session.cleanup.ts`(끝난 세션 정리 cron). **메일 엔진 관리자 차단** — 정적 목록(`employee-address.ts` + `MAIL_BLOCKED_ACCOUNTS`)과 로그인·리프레시 때 엔진 권한 확인(`jmap/jmap.client.ts`) 두 겹 |
|
||||
| 메일 (웹메일 API) | `modules/mail` | 엔진 포트 `mail.provider.ts`(`MAIL_PROVIDER`) + Stalwart JMAP 어댑터 `jmap/`, 받은 메일 HTML 정화 `html/sanitize-mail-html.ts`, 보내는 서식 본문 정화(허용 목록) `html/sanitize-outgoing-html.ts`, 두 정화 앞의 사전 검사(길이·파서 스택 비용, 선형 시간) `html/html-parse-budget.ts`, 첨부 형식 `attachment.policy.ts`, 설정 `mail.settings.ts`, 발송 중복 방지(`Idempotency-Key`) `send-idempotency.ts`, 첨부 임시 디스크 저장 `mail-upload-storage.ts`, 임시 저장 `draft.controller.ts`(엔진 초안 — 새로 만들고 다시 읽은 뒤 옛것을 지운다), 서명 그림 주소 규칙 `html/signature-images.ts`(보낼 때 `cid:` 로), 본문 그림 판정 `html/inline-images.ts`, 받는 사람 추천 `recipient-suggestions.service.ts`(사용자별 10분 캐시) · 순서 `recipient-ranking.ts` · 커서 `recipient-suggestion-cursor.ts`, 검색어 → 엔진 조건 `jmap/search-terms.ts`(한국어 조사 표는 여기 한 곳 — 낱말 안 OR · 낱말끼리 AND, 따옴표 구절), 여러 통 `PATCH /api/messages`(엔진 요청 한 번) · 휴지통에서 영구 삭제 `message-deletion.controller.ts`(휴지통의 메일만, 조건부 지우기) — 본문 `dto/bulk-messages.dto.ts`, 새 메일 확인 `GET /api/mail-state`(`mailbox.controller.ts`) (계약 2절) |
|
||||
| 길이 검사 데코레이터 | `src/common/decorators/code-units.decorator.ts` | `MaxCodeUnits` · `MinCodeUnits` — JS 문자열 길이(UTF-16 코드 단위)로 센다(웹의 `.length` 와 같다). class-validator 의 `MinLength`·`MaxLength` 는 서로게이트 쌍 · 이모지를 덜 세어 클라이언트가 센 길이와 어긋난다 — 클라이언트와 같은 한도를 거는 문자열은 이것을 쓴다 |
|
||||
| 직원 인증 | `modules/auth` | 쿠키 JWT 로그인·`me`·`refresh`·`logout` (계약 `docs/mail/api-contract.md` 1절) · 비밀번호 변경 `PUT /api/auth/password`(4단계 — 직원 본인 자격으로 엔진의 `x:AccountPassword/set`, 로그인과 같은 실패 카운터 · 진행 중 잠금, 바뀌면 이 요청의 세션만 남기고 그 직원의 세션을 끊는다 — 결과를 모르는 실패에도 끊는다). 설정 `auth.settings.ts`, 쿠키 `auth-cookies.ts`, 로그인 실패 횟수 `login-attempt.service.ts`, **로그인 세션(DB)** `auth-session.entity.ts`·`auth-session.policy.ts`(1회용 회전·재사용 감지)·`auth-session.cleanup.ts`(끝난 세션 정리 cron). **메일 엔진 관리자 차단** — 정적 목록(`employee-address.ts` + `MAIL_BLOCKED_ACCOUNTS`)과 로그인·리프레시 때 엔진 권한 확인(`jmap/jmap.client.ts`) 두 겹 |
|
||||
| 메일 (웹메일 API) | `modules/mail` | 엔진 포트 `mail.provider.ts`(`MAIL_PROVIDER`) + Stalwart JMAP 어댑터 `jmap/`, 받은 메일 HTML 정화 `html/sanitize-mail-html.ts`, 보내는 서식 본문 정화(허용 목록) `html/sanitize-outgoing-html.ts`, 두 정화 앞의 사전 검사(길이·파서 스택 비용, 선형 시간) `html/html-parse-budget.ts`, 첨부 형식 `attachment.policy.ts`, 설정 `mail.settings.ts`, 발송 중복 방지(`Idempotency-Key`) `send-idempotency.ts`, 첨부 임시 디스크 저장 `mail-upload-storage.ts`, 임시 저장 `draft.controller.ts`(엔진 초안 — 새로 만들고 다시 읽은 뒤 옛것을 지운다), 서명 그림 주소 규칙 `html/signature-images.ts`(보낼 때 `cid:` 로), 본문 그림 판정 `html/inline-images.ts`, 받는 사람 추천 `recipient-suggestions.service.ts`(사용자별 10분 캐시) · 순서 `recipient-ranking.ts` · 커서 `recipient-suggestion-cursor.ts`, 검색어 → 엔진 조건 `jmap/search-terms.ts`(한국어 조사 표는 여기 한 곳 — 낱말 안 OR · 낱말끼리 AND, 따옴표 구절), 여러 통 `PATCH /api/messages`(엔진 요청 한 번) · 휴지통에서 영구 삭제 `message-deletion.controller.ts`(휴지통의 메일만, 조건부 지우기) — 본문 `dto/bulk-messages.dto.ts`, 새 메일 확인 `GET /api/mail-state`(`mailbox.controller.ts`), 메일함 관리 · 전체 읽음 · 비우기(`mailbox.controller.ts`, 본문 `dto/mailbox.dto.ts` — 오래 걸리는 처리는 어댑터(`BulkRun`)가 500통씩 · 한 요청 25초 안에서(엔진 호출마다 남은 시간만큼) 하고 남은 수를 돌려준다, 같은 직원 · 같은 메일함은 한 번에 하나(`mail.service.ts` 의 `mailboxOperations` — 진행 중이면 `REQ_001`), 지우기는 안의 메일을 휴지통으로 옮긴 뒤) · 부재중 자동 응답 `MailVacationController`(`dto/mail-vacation.dto.ts`) · 원문 `GET /api/messages/{id}/source`(이름 `attachment.policy.ts` 의 `messageSourceFileName`) · 스팸 표시 `isJunk` (계약 2절) |
|
||||
| 메일 서명 | `modules/mail-signature` | 사용자마다 서명 하나(DB) + 서명 그림. 그림 형식은 내용으로 판별(매직 바이트·머리의 가로·세로, 새 의존성 없음) `signature-image-format.ts`, 파일은 업로드 볼륨의 `mail-signatures/` (`signature-image-storage.ts`), 서명에 붙지 않은 그림 정리 cron `mail-signature.cleanup.ts`. 메일 모듈이 발송·임시 저장 때 `MailSignatureService` 로 그림을 쓴다 (계약 2절 "서명") |
|
||||
| 사내 목록 | `modules/mail-directory` | 웹메일 로그인에 성공한 직원(관리자·게이트웨이·막은 계정 제외)을 `AuthService` 가 기록 → 받는 사람 추천의 "사내". 엔진의 계정 목록(Principal) 권한은 쓰지 않는다(일부러 막아 두었다) |
|
||||
| Rate Limiting | `app.module.ts` 의 `ClientIpThrottlerGuard`(APP_GUARD, `common/guards/client-ip-throttler.guard.ts`) | 초과 시 429 → `REQ_001`. 루트 `.env` 의 `THROTTLE_*` 로 조정. `ThrottlerGuard` 를 상속해 tracker 만 바꿨다 — IP 는 `shared/utils/client-ip.ts` 의 `clientIpKey()`(IPv6 는 /64 로 묶음, 로그인 실패 카운터와 같은 함수) |
|
||||
@@ -226,7 +227,8 @@ SwaggerModule.setup('api-docs', app, document);
|
||||
> Redis 가 실제로 필요해지는 시점(다중 인스턴스 운영, 세션·캐시 공유, 분산 락)에 연결한다.
|
||||
> ⚠️ 인스턴스를 늘리면 **프로세스 메모리에 둔 한도·잠금**도 함께 옮겨야 한다 — 그대로 두면 인스턴스마다 따로 센다:
|
||||
> 로그인 실패 카운터(`modules/auth/fixed-window-counter.ts` → `INCR`+`PEXPIRE` 를 한 Lua 스크립트로),
|
||||
> 같은 주소의 진행 중 로그인·같은 `Idempotency-Key` 의 진행 중 발송(`SET NX PX` 잠금), `@nestjs/throttler` 저장소,
|
||||
> 같은 주소의 진행 중 로그인·비밀번호 변경·같은 `Idempotency-Key` 의 진행 중 발송(`SET NX PX` 잠금), `@nestjs/throttler` 저장소,
|
||||
> 같은 직원 · 같은 메일함의 오래 걸리는 처리(`mail.service.ts` 의 `mailboxOperations` — 그대로면 두 인스턴스가 같은 휴지통을 함께 비우며 서로 어긋난다 → 잠금),
|
||||
> 사용자별 서명 그림 올리기 차례(`mail-signature.service.ts` 의 `uploadQueues` — 그대로면 동시 업로드가 보관 상한을 넘을 수 있다 → 잠금),
|
||||
> 받는 사람 추천 캐시(`recipient-suggestions.service.ts` — 틀리지는 않지만 보낸 직후 다른 인스턴스가 10분까지 옛 목록을 준다 → 공유 캐시나 무효화 알림):
|
||||
> 0. 컨테이너 활성화 — 루트 `.env` 에 `COMPOSE_PROFILES=redis` ([[docker]])
|
||||
@@ -239,7 +241,7 @@ SwaggerModule.setup('api-docs', app, document);
|
||||
- **Rate Limiting**: `@nestjs/throttler` 로 일원화 (`app.module.ts` 의 `APP_GUARD` = `ClientIpThrottlerGuard`). 초과 시 429 → `REQ_001`.
|
||||
IP 를 세는 모든 곳은 `clientIpKey()` 를 쓴다 — IPv6 는 주소를 계속 바꿀 수 있어 원문 그대로 세면 IP 한도가 무력해진다.
|
||||
라우트 단위 조정은 `@Throttle({ default: { ttl, limit } })`, 제외는 `@SkipThrottle()` (헬스체크에 적용되어 있다).
|
||||
로그인 무차별 대입은 **실패 횟수**로 막는다 — `modules/auth/login-attempt.service.ts`.
|
||||
로그인 무차별 대입은 **실패 횟수**로 막는다 — `modules/auth/login-attempt.service.ts` (비밀번호 변경의 지금 비밀번호 틀림도 같은 카운터로 센다).
|
||||
메일 엔진에 묻기 **전에** 실패 한 번을 IP·주소에 원자적으로 차지하고(동시 요청으로 한도를 넘지 못하게), 같은 주소의 진행 중 시도는 기다리지 않고 `REQ_001`.
|
||||
성공·엔진 장애는 차지한 자리를 돌려준다(성공은 세지 않는다). 요청 수 상한은 그와 별개로 넉넉하게 — 로그인 라우트의 `@Throttle`(IP 당 1분 60회, 사무실 NAT 고려).
|
||||
**rate limiter 를 중복으로 추가하지 말 것** ([[security]])
|
||||
@@ -361,9 +363,12 @@ dc exec backend npm uninstall <패키지>
|
||||
중첩 깊이의 제곱으로 느려져 2MB 한 통에 수 분 동안 이벤트 루프를 멈춘다 (2026-10-27 리뷰). 정규식·변환도 입력 길이에 비례하게 둔다.
|
||||
htmlparser2 판을 올리면 `html-parse-budget.spec.ts` 의 Parser 비교 테스트가 어긋남을 잡는다 — 실패하면 바뀐 Parser 규칙을 옮긴다.
|
||||
**엔진의 `Email/set destroy` 는 메일함과 상관없이 영구 삭제다** — 사용자 메일을 영구 삭제하는 경로(임시 저장 교체의 옛 초안 · 보낸 뒤 `draftId` ·
|
||||
휴지통에서 영구 삭제)는 모두 `jmap-mail.provider.ts` 의 조건부 지우기를 쓴다: 지울 대상인지 확인(임시보관함에만 있는 사용자 초안 / 휴지통에만 있는 메일)
|
||||
→ 확인한 상태 그대로일 때만 `ifInState` 로 지우기 → 어긋나면 다시 확인(합계 `CONDITIONAL_DESTROY_ATTEMPTS`). 확인과 지우기 사이에
|
||||
휴지통에서 영구 삭제 · 4단계의 휴지통 · 스팸메일함 비우기)는 모두 `jmap-mail.provider.ts` 의 조건부 지우기를 쓴다: 지울 대상인지 확인(임시보관함에만 있는 사용자 초안 /
|
||||
휴지통(비우기는 그 메일함)에만 있는 메일 — 다른 메일함에도 있으면 그 메일함에서만 뺀다) → 확인한 상태 그대로일 때만 `ifInState` 로 지우기 → 어긋나면 다시 확인
|
||||
(한 통 · 여러 통은 합계 `CONDITIONAL_DESTROY_ATTEMPTS`, 비우기는 500통 차례마다 이어서 그만큼). 확인과 지우기 사이에
|
||||
다른 탭이 옮기거나 되돌린 메일까지 지우지 않게 하기 위해서다 (2026-10-27, Stalwart 0.16.27 이 `stateMismatch` 로 거절하는 것을 확인).
|
||||
조건 없는 destroy 는 그 요청이 방금 만든 메일(실패한 저장의 되돌림)에만 쓴다.
|
||||
`conditionalEmailSet` 은 `ifInState` 를 필수로 받는다 — 조건 없는 destroy 는 그 요청이 방금 만든 메일(실패한 저장의 되돌림)에만, 따로 쓴다.
|
||||
같은 요청에서 만들기와 지우기를 함께 하지 않는다 — 엔진은 만들기가 실패해도 지우기를 한다.
|
||||
**메일함 지우기(`Mailbox/set destroy`)는 `onDestroyRemoveEmails: false` 만 쓴다** — `true` 는 안의 메일을 영구 삭제한다. 안의 메일은 먼저 휴지통으로 옮기고,
|
||||
그사이 들어온 메일이 있으면 엔진이 `mailboxHasEmail` 로 거절하므로 다시 옮긴다 (2026-10-28 확인).
|
||||
- [ ] **env**: DB/Redis/CORS/포트는 루트 `.env` 에서 주입된다. 백엔드 고유 시크릿(`JWT_SECRET` 등)만 `backend/.env.{env}` 에 작성(compose `env_file` 로 로드).
|
||||
|
||||
@@ -24,20 +24,20 @@ description: Vue 3 프론트엔드(frontend/) 개발 규칙 — Composition API
|
||||
| 요청 단위 알림 끄기 | `useApi.ts` 의 `silent` 요청 옵션 | `true` 또는 코드 배열 (`api.get(url, { silent: ['AUTH_001'] })`). 세션 만료 대화상자는 끄지 않는다. 끈 코드를 나중에 알림으로 알려야 하면(그 칸이 사라진 뒤 등) `notifyApiError()` — 같은 `showErrorUI()` 를 거친다 |
|
||||
| 페이지네이션 타입 | `src/shared/types/pagination.ts` | 목록 응답 공유 계약 ([[response-format]]) |
|
||||
| 도메인 Composable 예제 | `src/composables/useUser.ts` · `useMail.ts` | 새 도메인 API 패턴 참고 (`useMail` 이 실제 모듈 예) |
|
||||
| 인증 Composable | `src/composables/useAuth.ts` | 로그인·로그아웃·`restoreSession()`(me 가 AUTH_001 이면 refresh 1회, 알림 없음) — 쿠키 기반 |
|
||||
| 인증 Composable | `src/composables/useAuth.ts` | 로그인·로그아웃·`restoreSession()`(me 가 AUTH_001 이면 refresh 1회, 알림 없음) — 쿠키 기반. 비밀번호 바꾸기 `changePassword()`(4단계 — 한도 30초 `PASSWORD_CHANGE_TIMEOUT_MS`, 지금 비밀번호 틀림은 400 `AUTH_005` 라 갱신 · 재시도를 타지 않는다) |
|
||||
| Pinia 스토어 예제 | `src/stores/user.store.ts` · `mail.store.ts` | 새 스토어 패턴 참고 (`mail.store` 는 늦게 온 응답 무시 패턴 포함) |
|
||||
| 인증 스토어 | `src/stores/auth.store.ts` | 로그인 사용자(email·name) · 세션 복원(`restore`) · 만료 상태(`expireSession`) · 로그아웃 상태(`clearAuth()` 가 켜는 `signedOut` — 다시 로그인할 때까지 늦게 온 갱신·복원 결과를 쓰지 않는다) |
|
||||
| 공통 컴포넌트 예제 | `src/components/BaseButton.vue` | 재사용 컴포넌트 기준 패턴(타입 Props/Emits·scoped·rem·a11y) |
|
||||
| 라우터 | `src/router/index.ts` | lazy import + 첫 네비게이션 전 세션 복원 + `requiresAuth` 가드(미인증 → `{ name: 'login', query: { redirect } }`) |
|
||||
| 라우터 | `src/router/index.ts` · 라우트 표 `src/router/routes.ts`(라우터와 라우트 테스트가 함께 쓴다) | lazy import + 첫 네비게이션 전 세션 복원 + `requiresAuth` 가드(미인증 → `{ name: 'login', query: { redirect } }`) |
|
||||
| 전역 셸 | `src/components/shell/AppShell.vue` | 앱 레일 + 상단 바 + 모듈 영역. 레일 모듈 목록은 `src/shared/constants/app-modules.ts` 하나 |
|
||||
| 디자인 토큰·글꼴 | `src/styles/tokens.css` · `src/styles/base.css` | 색·간격·글자 크기 변수. 글꼴은 npm `pretendard` 자체 호스팅(`main.ts` 에서 import — CSP `font-src 'self'`) |
|
||||
| 브랜드 자산 | `src/assets/brand/` · `src/shared/constants/brand.ts` | 흰 로고(전체·ARA 마크). 원본은 `docs/brand/` |
|
||||
| 토스트 | `src/stores/toast.store.ts` · `src/components/feedback/ToastHost.vue` | 오류는 `useApi.ts` 의 `showErrorUI()`, 성공 안내는 `composables/useToast.ts`. 알림 안의 단추는 `{ label, run }`(예: 임시 저장본 [되돌리기]), 이동 링크는 지금처럼. 단추가 있는 알림은 10초(`TOAST_ACTION_DURATION_MS`), 마우스 · 초점이 있는 동안은 `hold`·`release` 로 멈춘다. **단추는 띄운 세션에서만 움직인다** — `useToast` 가 `authStore.generation` 에 묶고, 로그아웃 · 세션 만료 · 다른 계정 로그인 때 단추가 있는 알림을 닫는다(메일 id 는 계정마다 겹칠 수 있다) |
|
||||
| 모달 대화상자 | `src/components/feedback/ModalDialog.vue` | 네이티브 `<dialog>`(초점·Esc·닫힘 처리)와 모양. 세션 만료(`SessionExpiredDialog`)·나가기 확인(공용 `components/feedback/LeaveConfirmDialog.vue` — 쓰기 화면 `ComposeLeaveDialog.vue`·서명 설정이 쓴다)이 쓴다 — 새 대화상자도 이것으로 만든다 |
|
||||
| 토스트 | `src/stores/toast.store.ts` · `src/components/feedback/ToastHost.vue` | 오류는 `useApi.ts` 의 `showErrorUI()`, 성공 안내는 `composables/useToast.ts`. 알림 안의 단추는 `{ label, run }`(예: 임시 저장본 [되돌리기]), 이동 링크는 지금처럼. 단추가 있는 알림은 10초(`TOAST_ACTION_DURATION_MS`), 마우스 · 초점이 있는 동안은 `hold`·`release` 로 멈춘다. **단추는 띄운 세션에서만 움직인다** — `useToast` 가 `authStore.generation` 에 묶고, 로그아웃 · 세션 만료 · 다른 계정 로그인 때 단추가 있는 알림을 닫는다(메일 id 는 계정마다 겹칠 수 있다). 오래 걸리는 처리는 **진행 알림** `useToast().progress(글)` — 저절로 닫히지 않고(넘쳐도 다른 알림부터 닫는다) 글을 고치다가(`update`) 끝나면 그 자리에서 결과가 된다(`succeed` · `fail` — `toast.store` 의 `update`). 진행 알림도 세션이 바뀌면 닫는다 |
|
||||
| 모달 대화상자 | `src/components/feedback/ModalDialog.vue` | 네이티브 `<dialog>`(초점·Esc·닫힘 처리)와 모양. 세션 만료(`SessionExpiredDialog`)·나가기 확인(공용 `components/feedback/LeaveConfirmDialog.vue` — 쓰기 화면 `ComposeLeaveDialog.vue`·서명 설정 · 부재중 설정이 쓴다) · 메일함 대화상자(`components/mail/Mailbox*Dialog.vue` — 기본 슬롯에 이름 칸)가 쓴다 — 새 대화상자도 이것으로 만든다. 되돌릴 수 없는 일의 확인은 처음 초점을 「취소」에 둔다 |
|
||||
| 외부 HTML 격리 렌더링 | `src/components/mail/MailBodyFrame.vue` · `src/shared/utils/mail-html.ts` | 메일 본문 등 외부 HTML 은 이 컴포넌트로만 그린다 (sandbox·내부 CSP 근거는 파일 머리말). 보여 줄 HTML 고르기는 `displayedHtmlBody()` 하나다 — null 이거나 공백뿐이면 textBody (읽기 화면·인용 공용, 서버의 `isBodyTruncated` 와 같은 규칙) |
|
||||
| 메일 모듈 | `src/pages/mail/*` · `src/components/mail/*` | API 계약 `docs/mail/api-contract.md`, 디자인 원본 `docs/design/webmail/` |
|
||||
| 콘텐츠 열 토큰 | `src/styles/tokens.css` 의 `--content-*` · `--content-toolbar-start` | 읽기·쓰기 화면이 같은 최대 폭·여백·32px 시작선을 쓴다 (새 화면도 이 토큰으로) |
|
||||
| 밀도 예외 크기 | `src/styles/tokens.css` 의 `--size-compact-control` · `--size-segment-item` · `--size-segment-item-lg` · `--size-editor-tool` · `--size-editor-menu-item` · `--size-editor-swatch-cell` · `--size-editor-table-cell` · `--table-col-check` · `--size-row-check` · `--size-search-clear-width`/`-height` · `--size-notice-bar` (주석에 "밀도 예외") | 클릭 영역 44px 기준의 의도된 예외(목록 표의 별·보기 전환, 설정 화면의 미리 보기 전환 36(`.segmented`)·서명 그림 크기 줄 36(작게·보통·원본·그림 빼기), 편집기 툴바 버튼 40×44·메뉴 항목 40·색 견본 40·표 고르기 칸 22, 3단계의 목록 표 고르기 칸 32·분할 행 고르기 칸 폭 36·검색 칸 ✕ 40×38·새 메일 알림 줄 40·검색 범위 전환 36·이동 메뉴 항목 40)에만 쓴다 — 사용자 확정(캔버스). 그 밖의 `--size-editor-*`(툴바 높이·글꼴 단추·본문 최소 높이 등)는 예외가 아니라 보통 크기다 |
|
||||
| 밀도 예외 크기 | `src/styles/tokens.css` 의 `--size-compact-control` · `--size-segment-item` · `--size-segment-item-lg` · `--size-editor-tool` · `--size-editor-menu-item` · `--size-editor-swatch-cell` · `--size-editor-table-cell` · `--table-col-check` · `--size-row-check` · `--size-search-clear-width`/`-height` · `--size-notice-bar` · `--size-mailbox-more` (주석에 "밀도 예외") | 클릭 영역 44px 기준의 의도된 예외(목록 표의 별·보기 전환, 설정 화면의 미리 보기 전환 36(`.segmented`)·서명 그림 크기 줄 36(작게·보통·원본·그림 빼기), 편집기 툴바 버튼 40×44·메뉴 항목 40·색 견본 40·표 고르기 칸 22, 3단계의 목록 표 고르기 칸 32·분할 행 고르기 칸 폭 36·검색 칸 ✕ 40×38·새 메일 알림 줄 40·검색 범위 전환 36·이동 메뉴 항목 40, 4단계의 메일함 행 ⋯ 32×40 · ⋯ 메뉴 항목 40)에만 쓴다 — 사용자 확정(캔버스). 그 밖의 `--size-editor-*`(툴바 높이·글꼴 단추·본문 최소 높이 등)는 예외가 아니라 보통 크기다 |
|
||||
| 사용자별 화면 설정 | `src/stores/mail-view.store.ts` | localStorage(키에 사용자 email, try/catch, 실패 시 기본값). 서버 설정 API 가 생기면 교체 |
|
||||
| 화면 이동 규칙 | `src/composables/useMailNavigation.ts` | 이전/다음은 replace, "목록으로"는 뒤로 가기 또는 replace (기록이 쌓이지 않게) |
|
||||
| 발송 키 규칙 | `src/shared/utils/idempotency-key.ts` 의 `shouldRenewSendKey()` | `Idempotency-Key` 는 성공과 `MAIL_005` 때만 바꾼다 — 그 밖의 실패 뒤 다시 보내기는 같은 키 (계약 2절 "발송 본문") |
|
||||
@@ -45,15 +45,19 @@ description: Vue 3 프론트엔드(frontend/) 개발 규칙 — Composition API
|
||||
| 메일 본문 편집기 | `src/components/mail/editor/` — `MailEditor.vue` (쓰기 화면이 `defineAsyncComponent` 로 불러온다) | TipTap core (MIT 무료 패키지만, `@tiptap/*` 는 모두 같은 버전으로 고정). 툴바 배치·좁은 폭에서 접는 순서·단축키는 `docs/design/webmail/README.md` "메일 쓰기". 보낼 모양은 `mail-serialize.ts` — 서식 본문 `bodyHtml`(기본 글꼴 div, 인라인 style) + 텍스트 대체 본문 `body` (계약 2절 "서식 본문"). 답장·전달 초안 한도는 `fit-draft.ts`(처음 맞출 때 내 글 몫 5%를 남기고, 보내기 직전 `getContentForSend()` 가 다시 재서 인용을 더 줄인다 — 내 글만으로 넘으면 보내지 않고 칸 아래에 알린다), 붙여 넣거나 끌어다 놓은 파일은 첨부로(`shared/utils/editor-files.ts`, 화면 어디에 놓아도 — `composables/useWindowFileDrop.ts`). **읽어 들이는 style 값은 서버 정화와 같은 형식만** 받는다 — 규칙 `shared/utils/outgoing-html-rules.ts`(짝: backend `sanitize-outgoing-html.ts`, 같은 표본 표 `test-utils/outgoing-style-samples.ts` — 한쪽을 고치면 둘 다), 적용 `editor/mail-format-extensions.ts`. 편집기 청크를 못 받으면 `ComposeEditorLoadError.vue`("다시 시도"), 단축키 표기는 플랫폼별(`shared/utils/keyboard-shortcut.ts`). **그림은 서명 그림만** 받는다 — 노드 `editor/signature-image.ts`(쓰기 본문·서명 칸 공용, 규칙은 `outgoing-html-rules.ts` 의 그림 부분 — 짝: backend `html/signature-images.ts`·`keepSignatureImage`, 표본 `IMAGE_SAMPLES`) |
|
||||
| 임시 저장 | `src/composables/useDraftSaving.ts` · `components/mail/ComposeDraftStatus.vue` · `composables/useComposeDraftLink.ts`(주소 맞추기 · 옛 id 기록 · 보내지 못한 메일을 저장본으로) · `composables/useDraftTrash.ts`(휴지통 · 되돌리기) | 3초 멈춤 · 쉬지 않고 쓰면 30초 · 10초 간격 · 한 번에 하나(계약 2절 "임시 저장", 상수 `shared/constants/mail.ts` 의 `DRAFT_*`). `MAIL_008`·PUT 404 는 막지 않고 POST 로 새로. 찾지 못한 첨부가 남았거나 본문이 한도를 넘는 동안은 저장하지 않고, 실을 수 없는 첨부가 든 남의 저장본은 첫 저장을 POST 로(원본 보존). 보내기·버리기 전에는 `suspend`+`settle` — 보내기는 settle 뒤 첨부 상태를 다시 본다. 저장할 때마다 주소를 `?draft=<새 id>` 로 맞추고(떠나기 전 저장 제외), 옛 id 로 돌아오면 `mail.store` 의 `latestDraftId()` 로 마지막 저장본을 연다. 쓰기 화면 휴지통은 DELETE 가 아니라 PATCH(휴지통으로 이동) — 이미 휴지통인 메일은 DELETE 가 영구 삭제한다 |
|
||||
| 받는 사람 자동 완성 | `src/composables/useRecipientSuggestions.ts` · `shared/utils/recipient-match.ts` · `components/mail/RecipientField.vue` · `RecipientSuggestionList.vue` | 검색어는 input · compositionupdate 의 `target.value`(한글 조합 중 글자 포함)로 따로 두고, 칩 확정은 v-model. Enter 등의 조합 판정은 `shared/utils/keyboard-shortcut.ts` 의 `isImeComposing()`(`isComposing` 또는 keyCode 229 — Safari · macOS) — 새 입력 칸의 Enter 처리도 이것을 쓴다. 쓰기 화면을 열 때 `limit=100` 한 번, 모자랄 때만 250ms 뒤 `q` 로(초성·자모만 친 글자는 묻지 않는다, 늦은 응답은 버린다). 초성 검색·맞은 글자 강조는 `recipient-match.ts`(강조는 `<strong>` 조각 — v-html 없음). combobox 접근성(aria-activedescendant · listbox · 조합 중 Enter 무시), 완전한 주소면 미리 고르지 않는다 |
|
||||
| 메일 서명 | `src/stores/mail-signature.store.ts` · `components/mail/editor/signature/` · `shared/utils/mail-signature.ts` · `pages/settings/` | 서명은 메일 화면에 들어올 때 받아 두고 쓰기 화면이 넣는다(새 메일은 끝, 답장·전달은 인용 위 — 이어 쓰기는 다시 넣지 않는다). 설정 화면(`SettingsLayout.vue` 왼쪽 메뉴 + `MailSignaturePage.vue`)은 들어올 때마다 새로 받는다. 미리 보기는 편집기 스키마의 `DOMSerializer` 로 그린다. 그림 3장은 서명 칸 안의 그림 수로(서버 보관 상한 10장은 서버가 센다) |
|
||||
| 메일 스토어 구성 | `src/stores/mail.store.ts`(조립 · 공개 API) + `src/stores/mail/` — `mailboxes.ts` · `list.ts` · `selection.ts` · `message.ts` · `bulk.ts` | Pinia 스토어는 `useMailStore` 하나이고 기능별 setup 모듈을 조립한다(스토어를 여러 개로 나누지 않는다 — 서로 부르는 고리가 생긴다). 목록 출처는 `shared/utils/mail-list-source.ts`(메일함 · 검색). **늦게 온 응답 버리기**: 목록 맞추기는 이 탭의 목록 변경 세대(`localGeneration` — 읽음 · 중요 · 빼기 · 옮기기마다 오른다)가 그사이 바뀌었으면 응답을 버리고 한 번 더 맞춘다. 여러 통 처리 · 한 통 삭제는 시작할 때의 출처 순번(`sourceSeq`)과 다르면 목록을 건드리지 않고 개수만 맞춘다 |
|
||||
| 메일 서명 | `src/stores/mail-signature.store.ts` · `components/mail/editor/signature/` · `shared/utils/mail-signature.ts` · `pages/settings/` | 서명은 메일 화면에 들어올 때 받아 두고 쓰기 화면이 넣는다(새 메일은 끝, 답장·전달은 인용 위 — 이어 쓰기는 다시 넣지 않는다). 설정 화면(`SettingsLayout.vue` 왼쪽 메뉴 — 묶음 · 항목은 `shared/constants/settings-menu.ts` + `MailSignaturePage.vue`)은 들어올 때마다 새로 받는다. 미리 보기는 편집기 스키마의 `DOMSerializer` 로 그린다. 그림 3장은 서명 칸 안의 그림 수로(서버 보관 상한 10장은 서버가 센다) |
|
||||
| 메일 스토어 구성 | `src/stores/mail.store.ts`(조립 · 공개 API) + `src/stores/mail/` — `mailboxes.ts` · `list.ts` · `selection.ts` · `message.ts` · `bulk.ts` · `mailbox-admin.ts`(4단계 메일함 관리) | Pinia 스토어는 `useMailStore` 하나이고 기능별 setup 모듈을 조립한다(스토어를 여러 개로 나누지 않는다 — 서로 부르는 고리가 생긴다). 목록 출처는 `shared/utils/mail-list-source.ts`(메일함 · 검색). **늦게 온 응답 버리기**: 목록 맞추기는 이 탭의 목록 변경 세대(`localGeneration` — 읽음 · 중요 · 빼기 · 옮기기마다 오른다)가 그사이 바뀌었으면 응답을 버리고 한 번 더 맞춘다. 여러 통 처리 · 한 통 삭제는 시작할 때의 출처 순번(`sourceSeq`)과 다르면 목록을 건드리지 않고 개수만 맞춘다 |
|
||||
| 새 메일 확인 · 탭 제목 | `src/composables/useMailStatePolling.ts`(메일 화면 `MailLayout` 이 시작) · `useListArrivalNotice.ts` + `components/mail/MailNewNotice.vue` · `useMailTabTitle.ts` + `shared/utils/document-title.ts` | 화면이 보이는 동안 60초마다 `GET /mail-state`, 탭으로 돌아오면 10초 지났을 때 바로, 한 번에 하나, 실패는 `silent`. 바뀌면 `syncCounts()` + 보이는 목록 맞추기(불러온 만큼 · 최대 100통, 검색 결과는 건너뜀). 목록을 내려 본 채면 "새 메일 N통 · 맨 위로" 줄. 받은편지함 안 읽음이 있으면 탭 제목 앞 `(N) ` (메일 라우트에서만) — 계약 2절 "새 메일 확인" |
|
||||
| 메일 검색 | `src/components/mail/MailSearchBox.vue`(상단 바의 search 슬롯) · `composables/useMailSearchNavigation.ts` · `MailSearchHeader.vue` · `MailSearchScope.vue` · `MailSearchEmpty.vue` · 라우트 `/mail/search` · `/mail/search/:messageId` | Enter 로만 찾는다(`isImeComposing()` 이면 무시), ✕ · Esc 는 검색을 시작한 메일함으로. 주소 `?q=…&in=<메일함 id \| all>` — 검색 화면 안의 다시 찾기 · 범위 바꾸기는 replace. 서버와 같은 한도(200자 · 낱말 8개)를 먼저 본다. 조사 처리 · 한계는 서버 몫(계약 2절 "검색") |
|
||||
| 여러 통 처리 · 삭제 | `src/components/mail/MailCheckbox.vue` · `MailSelectionToolbar.vue`(+ `MailBulkButton` · `MailBulkMoveButton`) · `MailPurgeDialog.vue` · `MessageDeleteButton.vue` · `composables/useBulkMailActions.ts` · `useMessageRemoval.ts` · `useMailMoveFeedback.ts` · `shared/utils/mail-selection.ts` · `mail-bulk.ts` | 100통씩 차례로. 읽음 · 중요는 먼저 바꾸고 `failedIds` 만 되돌리고, 이동 · 삭제는 응답의 id 만 뺀다. **웹은 `DELETE /api/messages/{id}` 를 쓰지 않는다** — 휴지통 밖 삭제는 `PATCH` 이동(+ 되돌리기), 휴지통에서는 확인 대화상자(처음 초점 「취소」) 뒤 `POST /message-deletions`. 읽기 화면도 그 메일의 `mailboxIds` 로 같은 규칙을 탄다. 도구 줄은 role="toolbar" 로빙 tabindex(바쁜 · 꺼진 단추는 건너뛴다) — 계약 2절 "여러 통 한꺼번에" |
|
||||
| 설정 › 비밀번호 · 부재중 (4단계) | `src/pages/settings/AccountPasswordPage.vue` · `MailVacationPage.vue` · `components/settings/`(`SettingsPageFrame` 공용 틀 · `PasswordChangeForm` · `PasswordField` · `Vacation*`) · `stores/mail-vacation.store.ts` · `shared/utils/password-change.ts` · `mail-vacation.ts` · 메뉴 `shared/constants/settings-menu.ts` | 메뉴는 묶음(계정 › 비밀번호 / 메일 › 서명 · 부재중 자동 응답), 라우트 이름은 `SETTINGS_ROUTES` 하나. 비밀번호는 서버와 같은 규칙을 먼저 보고, 서버 거절(`AUTH_005` · `VAL_001`)은 칸 아래에만(알림 없음) — 성공하면 세 칸을 비운다. 부재중은 날짜만 고르고 브라우저 시간대의 시작일 0시 · 끝나는 날 다음 날 0시를 UTC 로 보낸다(변환은 `mail-vacation.ts` 한 곳 — 되읽을 때는 1ms 앞 날짜). 켤 때만 검사하고 꺼 둔 채로는 빈 값도 저장한다. 부재중 상태는 메일 화면에 들어올 때 받고, 패널 안내 `components/mail/MailVacationNotice.vue` 가 1분마다 다시 계산한다(`useClock` · `VACATION_NOTICE_REFRESH_MS`). 저장 응답은 세션 세대(`authStore.generation`)가 바뀌었으면 쓰지 않는다 — 계약 1절 · 2절 "부재중 자동 응답" |
|
||||
| 메일함 관리 · 오래 걸리는 처리 (4단계) | `src/components/mail/MailboxTree.vue` · `MailboxPanelRow.vue` · `MailboxMoreButton.vue`(⋯ 메뉴) · `MailboxDialogs.vue`(+ `Mailbox*Dialog.vue`) · `MailboxEmptyButton.vue`(목록 머리 「비우기」) · `composables/useMailboxActions.ts` · `stores/mail/mailbox-admin.ts` · `shared/utils/mailbox-operation.ts` · 상수 `shared/constants/mailbox-manage.ts` | 만들기 · 이름 바꾸기는 칸 안 실패(`MAIL_009` · `VAL_001`)를 알림 없이 칸 아래에. 모두 읽음 · 비우기 · 지우기는 남은 수가 0(지우기는 `deleted: true`)이 될 때까지 다시 보내며 진행 알림을 고친다 — 진행은 응답의 `processed`, 세션이 바뀌면 멈춘다(`epoch` — 늦게 온 만들기 · 이름 바꾸기 응답도 버린다), 같은 메일함의 같은 처리는 하나, **휴지통 비우기와 메일함 지우기는 함께 돌리지 않는다**(비우기는 그사이 휴지통에 들어온 메일도 지운다 — 판정은 스토어의 `isMailboxOperationBlocked` 하나, 메뉴 · 단추 · 대화상자 확인이 모두 이것을 본다). 끝난 뒤 누른 단추가 사라져 초점을 잃었으면 패널의 지금 메일함 항목으로 옮긴다. ⋯ 메뉴는 보이는 영역 안에 다 들게 연다(`shared/utils/popup-placement.ts` — 아래가 모자라면 위로). 되돌릴 수 없는 일은 확인 대화상자(처음 초점 「취소」) 뒤에, 확인한 그 메일함만 — 계약 2절 "메일함 관리" |
|
||||
| 스팸 · 원문 (4단계) | `src/components/mail/MessageSpamButton.vue`(읽기 화면) · 도구 줄의 스팸 단추(`MailSelectionToolbar.vue`) · `MessageMoreMenu.vue`(⋯ › 원문 내려받기) | 스팸 신고 · 스팸 아님은 이동과 `isJunk` 를 한 요청에 보내고(`mailStore.moveMessages(…, { isJunk })`), [되돌리기]는 원래 메일함으로 반대 값을 보낸다. 원문(.eml)은 `useMail().downloadMessageSource()` — 첨부처럼 `confirmSession()` 을 먼저 거친다 — 계약 2절 "스팸 · 원문" |
|
||||
| 시계 주입 | `src/composables/useClock.ts` · `src/test-utils/manual-clock.ts` | 타이머를 쓰는 로직(자동 저장 등)은 시계를 주입받는다 — 테스트는 손으로 돌리는 시계로 시간을 넘긴다(고정 대기 없이, [[testing]]) |
|
||||
| 나가기 전 확인 | `src/composables/useLeaveConfirm.ts` · `src/shared/utils/compose-changes.ts` | 화면을 연 뒤(또는 마지막으로 저장한 뒤) 바꾼 것이 있으면 앱 안 이동(라우트 떠남·바뀜)은 확인 대화상자, 탭 닫기·새로고침은 `beforeunload`. 쓰기 화면은 떠나기 전에 먼저 저장하고 저장하지 못한 것이 있을 때만 묻는다(서명 설정은 저장하지 않은 서명이 있으면 묻는다). 가드가 도는 중은 `guarding`. 바꿈 여부는 연 직후(저장한 뒤에는 마지막으로 저장한 판)의 스냅숏과 비교한다(편집기는 `isChanged()`). 떠나기 전 저장은 5초까지만 기다린다. 보낸 뒤·보내는 중·세션이 끝났을 때는 묻지 않는다 — 보내는 중에 떠나면 결과는 토스트로만 알리고 떠난 양식·새 양식을 건드리지 않는다(양식 세대로 늦게 온 결과를 버린다, 디자인 README "나가기 전 확인") |
|
||||
| 읽기 화면 본문 안내 | `src/shared/utils/mail-body-notices.ts` · `shared/constants/mail.ts` 의 `MAIL_BODY_NOTICES` | 서버의 `isHtmlBodyOmitted`·`isBodyTruncated`(계약 2절 "읽기 한도")를 외부 이미지 차단과 같은 안내 줄로 보인다(`.reader__notice[data-notice]`) |
|
||||
| 쓰기 칸 포커스 | `src/styles/base.css` 의 `.compose-field` | 글자 입력 행(받는 사람·참조·숨은참조·제목·본문)은 아래 선 하나로 포커스를 보인다. 새 입력 행에도 이 클래스를 붙인다 — 칸마다 포커스 규칙을 따로 만들지 않는다 |
|
||||
| 테두리 입력칸 | `src/styles/base.css` 의 `.form-input` · `.form-input__control` · `.form-message` | 설정 화면 · 메일함 이름 대화상자의 테두리 있는 칸(44)과 칸 아래 안내 · 오류(12.5px). 칸 안에 단추가 있는 상자(비밀번호 보기)는 상자에 `.form-input`, 입력 요소에 `.form-input__control` — 입력 요소에 초점이 있을 때만 상자에 초점을 보인다. 오류는 입력 요소의 `aria-invalid="true"`. 쓰기 화면의 밑줄 칸(`.compose-field`)과는 다른 규칙이다 |
|
||||
| env 타입 선언 | `frontend/env.d.ts` | `VITE_API_BASE_URL` |
|
||||
| dev API 프록시 | `vite.config.ts` 의 `server.proxy` | `/api` → 백엔드. 없으면 vite 가 SPA 폴백으로 **HTML 을 200 으로** 돌려줘 조용히 실패한다 |
|
||||
| dev 서버 포트 | `vite.config.ts` 의 `loadEnv` | 루트 `.env.{mode}` 의 `FRONTEND_PORT`/`BACKEND_PORT` 를 읽는다. `strictPort` 로 포트 충돌 시 fail-fast ([[env-structure]]) |
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import { ValidateBy, type ValidationOptions } from 'class-validator';
|
||||
|
||||
/**
|
||||
* 문자열 길이 검사 — JS 문자열 길이(UTF-16 코드 단위)로 센다. 웹 클라이언트가 세는 방법(`value.length`)과 같다.
|
||||
* class-validator 의 MinLength·MaxLength(validator.js)는 서로게이트 쌍·이모지 표시 선택자를 덜 세어
|
||||
* 클라이언트가 센 길이와 어긋난다 (modules/mail/dto/send-message.dto.ts 의 본문 한도와 같은 이유).
|
||||
* 문자열이 아니면 실패한다 — 형식 오류는 IsString 이 함께 알린다.
|
||||
*/
|
||||
|
||||
/** 최대 길이 (JS 문자열 길이) */
|
||||
export function MaxCodeUnits(
|
||||
max: number,
|
||||
options: ValidationOptions,
|
||||
): PropertyDecorator {
|
||||
return ValidateBy(
|
||||
{
|
||||
name: 'maxCodeUnits',
|
||||
constraints: [max],
|
||||
validator: {
|
||||
validate: (value: unknown) =>
|
||||
typeof value === 'string' && value.length <= max,
|
||||
},
|
||||
},
|
||||
options,
|
||||
);
|
||||
}
|
||||
|
||||
/** 최소 길이 (JS 문자열 길이) */
|
||||
export function MinCodeUnits(
|
||||
min: number,
|
||||
options: ValidationOptions,
|
||||
): PropertyDecorator {
|
||||
return ValidateBy(
|
||||
{
|
||||
name: 'minCodeUnits',
|
||||
constraints: [min],
|
||||
validator: {
|
||||
validate: (value: unknown) =>
|
||||
typeof value === 'string' && value.length >= min,
|
||||
},
|
||||
},
|
||||
options,
|
||||
);
|
||||
}
|
||||
@@ -44,6 +44,16 @@ describe('에러 코드 레지스트리', () => {
|
||||
expect(resolveCodeByStatus(HttpStatus.BAD_GATEWAY)).toBe('SYS_001');
|
||||
});
|
||||
|
||||
it('지금 비밀번호 틀림(AUTH_005)은 401 이 아니라 400 이다 — 웹 클라이언트는 401 이면 세션을 갱신하고 같은 요청을 다시 보낸다', () => {
|
||||
expect(ERROR_CODES.AUTH_005.status).toBe(HttpStatus.BAD_REQUEST);
|
||||
// 400 의 역매핑은 그대로 VAL_001 이다 (AUTH_005 는 서버가 명시적으로만 던진다)
|
||||
expect(resolveCodeByStatus(HttpStatus.BAD_REQUEST)).toBe('VAL_001');
|
||||
});
|
||||
|
||||
it('같은 이름의 메일함(MAIL_009)은 409 다', () => {
|
||||
expect(ERROR_CODES.MAIL_009.status).toBe(HttpStatus.CONFLICT);
|
||||
});
|
||||
|
||||
it('클라이언트 전용 코드(AUTH_002)는 서버가 상태 역매핑으로 선택하지 않는다', () => {
|
||||
const clientOnlyCodes = Object.entries(ERROR_CODES)
|
||||
.filter(([, definition]) => 'clientOnly' in definition)
|
||||
|
||||
@@ -45,6 +45,12 @@ const COMMON_ERROR_CODES = {
|
||||
status: HttpStatus.UNAUTHORIZED,
|
||||
message: '이메일 또는 비밀번호가 올바르지 않습니다.',
|
||||
},
|
||||
// 비밀번호 바꾸기(PUT /api/auth/password)의 지금 비밀번호가 틀림 — 세션은 그대로다.
|
||||
// 401 이 아니라 400 인 까닭: 웹 클라이언트는 401 이면 세션을 갱신하고 같은 요청을 다시 보낸다 (계약 1절)
|
||||
AUTH_005: {
|
||||
status: HttpStatus.BAD_REQUEST,
|
||||
message: '지금 비밀번호가 올바르지 않습니다.',
|
||||
},
|
||||
VAL_001: {
|
||||
status: HttpStatus.BAD_REQUEST,
|
||||
message: '입력하신 정보를 다시 확인해 주세요. (필수값 누락 또는 형식 오류)',
|
||||
@@ -126,6 +132,11 @@ const DOMAIN_ERROR_CODES = {
|
||||
message:
|
||||
'임시 저장한 메일이 다른 곳에서 바뀌었거나 지워졌습니다. 지금 내용을 새 임시 저장본으로 남겨 주세요.',
|
||||
},
|
||||
// 메일함 만들기·이름 바꾸기 때 같은 상위 안에 같은 이름이 이미 있다 — 엔진이 alreadyExists 로 거절한다 (계약 2절 "메일함 관리")
|
||||
MAIL_009: {
|
||||
status: HttpStatus.CONFLICT,
|
||||
message: '같은 이름의 메일함이 이미 있습니다.',
|
||||
},
|
||||
} as const satisfies Record<string, ErrorCodeDefinition>;
|
||||
|
||||
export const ERROR_CODES = {
|
||||
|
||||
@@ -11,7 +11,11 @@ const REVOKED_REASON_LENGTH = 32;
|
||||
export type SessionRevokedReason =
|
||||
| 'logout'
|
||||
| 'refresh-token-reused'
|
||||
| 'account-unavailable';
|
||||
| 'account-unavailable'
|
||||
/** 비밀번호를 바꿨다 — 바꾼 세션 밖의 그 직원 세션 (계약 1절 "PUT /api/auth/password") */
|
||||
| 'password-changed'
|
||||
/** 비밀번호 바꾸기의 결과를 알 수 없다(엔진이 바꿨을 수 있다) — 바꾼 세션 밖의 그 직원 세션 */
|
||||
| 'password-change-uncertain';
|
||||
|
||||
/**
|
||||
* 로그인 세션 (계약 1절 "세션 규칙") — 리프레시 토큰의 1회용 회전과 폐기를 서버에서 판단한다.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { IsNull, MoreThan, type Repository } from 'typeorm';
|
||||
import { IsNull, MoreThan, Not, type Repository } from 'typeorm';
|
||||
|
||||
import {
|
||||
AuthSessionEntity,
|
||||
@@ -71,6 +71,30 @@ export class AuthSessionRepository {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 그 직원의 살아 있는 세션을 모두 끝낸다 — `keepId` 세션만 남긴다 (없으면 모두) → 끝낸 수.
|
||||
* 이미 끝난(만료·폐기) 세션은 건드리지 않는다 (끝난 사유를 덮어쓰지 않게). 비밀번호를 바꿨을 때 쓴다.
|
||||
* user_email 에는 인덱스가 없다 — 표가 작고(끝난 세션은 7일 뒤 정리) 드문 일이라 순차 조회로 충분하다.
|
||||
*/
|
||||
async revokeAllOf(
|
||||
userEmail: string,
|
||||
reason: SessionRevokedReason,
|
||||
now: Date,
|
||||
keepId: string | null,
|
||||
): Promise<number> {
|
||||
const result = await this.sessions.update(
|
||||
{
|
||||
userEmail,
|
||||
revokedAt: IsNull(),
|
||||
expiresAt: MoreThan(now),
|
||||
absoluteExpiresAt: MoreThan(now),
|
||||
...(keepId !== null ? { id: Not(keepId) } : {}),
|
||||
},
|
||||
{ revokedAt: now, revokedReason: reason },
|
||||
);
|
||||
return result.affected ?? 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* `before` 보다 먼저 끝난(만료·폐기) 세션을 지운다 → 지운 수.
|
||||
* 미사용 만료(expires_at)는 항상 절대 만료 이하로 저장하므로(nextIdleExpiry) 절대 만료를 따로 볼 필요가 없다.
|
||||
|
||||
@@ -70,3 +70,10 @@ export const LOGIN_REQUEST_WINDOW_MS = 60_000;
|
||||
|
||||
/** 비밀번호 최대 길이 — 아주 긴 값으로 메일 엔진의 해시 계산을 오래 붙잡지 못하게 한다 */
|
||||
export const MAX_PASSWORD_LENGTH = 256;
|
||||
|
||||
/**
|
||||
* 비밀번호 바꾸기의 새 비밀번호 (계약 1절 "PUT /api/auth/password") — 8~128자, 길이는 JS 문자열 길이(UTF-16 코드 단위)로 센다(웹 클라이언트와 같다).
|
||||
* 엔진도 8자 미만을 거절한다(2026-10-28 확인). 지금 비밀번호는 로그인과 같은 검사(MAX_PASSWORD_LENGTH) — 로그인보다 긴 비밀번호로는 들어올 수 없다
|
||||
*/
|
||||
export const NEW_PASSWORD_MIN_LENGTH = 8;
|
||||
export const NEW_PASSWORD_MAX_LENGTH = 128;
|
||||
|
||||
@@ -28,7 +28,9 @@ function fakeResponse() {
|
||||
}
|
||||
|
||||
/** 핸들러 함수 객체 — Nest 는 라우트 메타데이터(가드·한도)를 이 함수에 붙인다 */
|
||||
function handlerOf(name: 'login' | 'me' | 'refresh' | 'logout'): object {
|
||||
function handlerOf(
|
||||
name: 'login' | 'me' | 'refresh' | 'logout' | 'changePassword',
|
||||
): object {
|
||||
const descriptor = Object.getOwnPropertyDescriptor(
|
||||
AuthController.prototype,
|
||||
name,
|
||||
@@ -37,7 +39,12 @@ function handlerOf(name: 'login' | 'me' | 'refresh' | 'logout'): object {
|
||||
}
|
||||
|
||||
describe('AuthController', () => {
|
||||
let auth: { login: jest.Mock; refresh: jest.Mock; logout: jest.Mock };
|
||||
let auth: {
|
||||
login: jest.Mock;
|
||||
refresh: jest.Mock;
|
||||
logout: jest.Mock;
|
||||
changePassword: jest.Mock;
|
||||
};
|
||||
let controller: AuthController;
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -45,6 +52,7 @@ describe('AuthController', () => {
|
||||
login: jest.fn(),
|
||||
refresh: jest.fn(),
|
||||
logout: jest.fn().mockResolvedValue(undefined),
|
||||
changePassword: jest.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
controller = new AuthController(auth as unknown as AuthService, settings);
|
||||
});
|
||||
@@ -167,6 +175,38 @@ describe('AuthController', () => {
|
||||
expect(res.clearCookie).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('비밀번호 바꾸기는 로그인이 필요하고(JwtAuthGuard), 직원 · 두 비밀번호 · IP · 리프레시 쿠키를 서비스에 넘겨 null 을 돌려준다 — 쿠키는 굽지도 지우지도 않는다', async () => {
|
||||
expect(
|
||||
Reflect.getMetadata(GUARDS_METADATA, handlerOf('changePassword')),
|
||||
).toEqual([JwtAuthGuard]);
|
||||
// 요청 수 상한은 전역 기본값 — 무차별 대입은 로그인과 같은 실패 한도가 막는다
|
||||
expect(
|
||||
Reflect.getMetadata(
|
||||
'THROTTLER:LIMITdefault',
|
||||
handlerOf('changePassword'),
|
||||
),
|
||||
).toBeUndefined();
|
||||
|
||||
const user = { email: 'ara.kim@arasystem.test', name: '김아라' };
|
||||
const req = { cookies: { refresh_token: 'this.refresh' } };
|
||||
|
||||
await expect(
|
||||
controller.changePassword(
|
||||
user,
|
||||
{ currentPassword: 'old-password', newPassword: 'new-password-1' },
|
||||
'203.0.113.10',
|
||||
req as unknown as Request,
|
||||
),
|
||||
).resolves.toBeNull();
|
||||
expect(auth.changePassword).toHaveBeenCalledWith(
|
||||
user,
|
||||
'old-password',
|
||||
'new-password-1',
|
||||
'203.0.113.10',
|
||||
'this.refresh',
|
||||
);
|
||||
});
|
||||
|
||||
it('로그아웃은 리프레시 쿠키의 세션을 끊고, 항상 null 을 돌려주며 두 쿠키를 지운다', async () => {
|
||||
const res = fakeResponse();
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
Inject,
|
||||
Ip,
|
||||
Post,
|
||||
Put,
|
||||
Req,
|
||||
Res,
|
||||
UseGuards,
|
||||
@@ -35,6 +36,7 @@ import {
|
||||
import { AuthService } from './auth.service';
|
||||
import { AUTH_SETTINGS, type AuthSettings } from './auth.settings';
|
||||
import { AuthUserDto } from './dto/auth-user.dto';
|
||||
import { ChangePasswordDto } from './dto/change-password.dto';
|
||||
import { LoginDto } from './dto/login.dto';
|
||||
|
||||
/**
|
||||
@@ -132,6 +134,61 @@ export class AuthController {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 비밀번호 바꾸기 — 판단은 모두 AuthService 가 한다. 리프레시 쿠키(Path=/api/auth 라 이 경로에도 온다)로 이 세션을 알아
|
||||
* 그 세션만 남기고 그 직원의 다른 세션을 끊는다. 쿠키는 굽지도 지우지도 않는다 (이 세션은 그대로 이어 쓴다).
|
||||
* 요청 수 상한은 전역 기본값 — 무차별 대입은 로그인과 같은 실패 횟수 한도가 막는다.
|
||||
*/
|
||||
@Put('password')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ApiCookieAuth()
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({
|
||||
summary: '비밀번호 바꾸기 — 지금 비밀번호로 메일 엔진에 본인 변경을 보낸다',
|
||||
description:
|
||||
'메일 엔진에 그 직원 본인의 자격(지금 비밀번호)으로 보낸다 — 게이트웨이 대리 접근·관리자 권한을 쓰지 않는다. ' +
|
||||
'바뀌면 이 요청의 세션만 남기고 그 직원의 다른 로그인 세션을 모두 끊는다 (휴대폰·PC 메일 프로그램은 새 비밀번호를 다시 넣어야 한다). ' +
|
||||
'지금 비밀번호 틀림은 로그인 실패와 같은 한도(같은 IP 1분 20회 · 같은 주소 15분 10회)로 함께 센다. ' +
|
||||
'지금 비밀번호의 길이 한도는 로그인 비밀번호와 같다.',
|
||||
})
|
||||
@ApiStandardResponse(undefined, {
|
||||
description: '바꿨다 (data: null)',
|
||||
})
|
||||
// 같은 400 이라 한 항목에 함께 적는다 (Swagger 는 상태 코드마다 하나만 남긴다)
|
||||
@ApiErrorResponse(
|
||||
'AUTH_005',
|
||||
'AUTH_005 지금 비밀번호가 틀림 · 지금 비밀번호로 들어갔지만 메일 엔진이 바꾸기를 거절함(변경 권한 없음 등 — 이것은 실패로 세지 않는다) — 세션은 그대로다. ' +
|
||||
'401 이 아니다(클라이언트는 401 이면 세션을 갱신하고 같은 요청을 다시 보낸다) · ' +
|
||||
'VAL_001 새 비밀번호가 8자 미만 · 128자 초과(JS 문자열 길이) · 지금 비밀번호와 같음 · 메일 엔진 규칙에 어긋남, 형식 오류 (모두 400)',
|
||||
)
|
||||
@ApiErrorResponse(
|
||||
'REQ_001',
|
||||
'지금 비밀번호 틀림이 잦음(로그인 실패와 같은 한도) · 같은 주소의 로그인·변경이 처리 중',
|
||||
)
|
||||
@ApiErrorResponse(
|
||||
'AUTH_001',
|
||||
'로그인 필요 (쿠키 없음·만료) · 웹메일로 다루지 않는 계정(메일 엔진 관리자 등)',
|
||||
)
|
||||
@ApiErrorResponse(
|
||||
'MAIL_001',
|
||||
'메일 엔진 장애 (바뀌지 않았다) · 결과를 알 수 없게 끝남(시간 초과 · 연결 끊김 · 엔진 5xx — 엔진이 바꿨을 수 있어 이 세션만 남기고 다른 세션은 끊어 두었다)',
|
||||
)
|
||||
async changePassword(
|
||||
@CurrentUser() user: AuthenticatedUser,
|
||||
@Body() dto: ChangePasswordDto,
|
||||
@Ip() ip: string,
|
||||
@Req() req: Request,
|
||||
): Promise<null> {
|
||||
await this.auth.changePassword(
|
||||
user,
|
||||
dto.currentPassword,
|
||||
dto.newPassword,
|
||||
ip,
|
||||
readCookie(req, REFRESH_TOKEN_COOKIE),
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
@Post('logout')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({
|
||||
|
||||
@@ -95,6 +95,31 @@ class FakeSessions {
|
||||
},
|
||||
);
|
||||
|
||||
/** 실제 저장소와 같은 조건 — 그 직원의 살아 있는 세션 가운데 keepId 를 뺀 것만 끝낸다 */
|
||||
readonly revokeAllOf = jest.fn(
|
||||
(
|
||||
userEmail: string,
|
||||
reason: AuthSessionEntity['revokedReason'],
|
||||
now: Date,
|
||||
keepId: string | null,
|
||||
) => {
|
||||
let revoked = 0;
|
||||
for (const row of this.rows.values()) {
|
||||
const alive =
|
||||
row.userEmail === userEmail &&
|
||||
row.revokedAt === null &&
|
||||
row.expiresAt > now &&
|
||||
row.absoluteExpiresAt > now &&
|
||||
row.id !== keepId;
|
||||
if (alive) {
|
||||
Object.assign(row, { revokedAt: now, revokedReason: reason });
|
||||
revoked += 1;
|
||||
}
|
||||
}
|
||||
return Promise.resolve(revoked);
|
||||
},
|
||||
);
|
||||
|
||||
only(): AuthSessionEntity {
|
||||
const [row] = [...this.rows.values()];
|
||||
if (!row) throw new Error('세션이 없다');
|
||||
@@ -132,8 +157,22 @@ function countCodes(results: PromiseSettledResult<unknown>[]) {
|
||||
const delay = (ms: number) =>
|
||||
new Promise<void>((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
/** 조건이 될 때까지 짧게 다시 본다 — 고정 대기 대신, 기한은 넉넉하게 */
|
||||
async function until(
|
||||
condition: () => boolean,
|
||||
deadlineMs = 5_000,
|
||||
): Promise<void> {
|
||||
const deadline = Date.now() + deadlineMs;
|
||||
while (!condition()) {
|
||||
if (Date.now() > deadline) throw new Error('기한 안에 조건이 되지 않았다');
|
||||
await delay(1);
|
||||
}
|
||||
}
|
||||
|
||||
describe('AuthService', () => {
|
||||
let mail: jest.Mocked<Pick<MailProvider, 'verifyCredentials' | 'getProfile'>>;
|
||||
let mail: jest.Mocked<
|
||||
Pick<MailProvider, 'verifyCredentials' | 'getProfile' | 'changePassword'>
|
||||
>;
|
||||
let attempts: LoginAttemptService;
|
||||
let sessions: FakeSessions;
|
||||
let directory: { recordLogin: jest.Mock };
|
||||
@@ -184,6 +223,7 @@ describe('AuthService', () => {
|
||||
.mockImplementation((email: string) =>
|
||||
Promise.resolve({ email, name: '김아라' }),
|
||||
),
|
||||
changePassword: jest.fn().mockResolvedValue('changed'),
|
||||
};
|
||||
attempts = new LoginAttemptService();
|
||||
sessions = new FakeSessions();
|
||||
@@ -715,6 +755,373 @@ describe('AuthService', () => {
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* 비밀번호 바꾸기 (계약 1절 "PUT /api/auth/password") — 엔진 쪽 판단은 대역으로 두고(jmap.client.spec.ts 가 본다),
|
||||
* 한도 · 세션 끊기 · 로그를 실제 LoginAttemptService 와 세션 표 대역으로 확인한다.
|
||||
*/
|
||||
describe('비밀번호 바꾸기', () => {
|
||||
const NEW_PASSWORD = 'new-horse-battery-staple';
|
||||
const DOYUN = 'doyun.lee@arasystem.test';
|
||||
const me = { email: ARA, name: '김아라' };
|
||||
|
||||
const change = (
|
||||
currentPassword = PASSWORD,
|
||||
refreshToken?: string,
|
||||
ip = CLIENT_IP,
|
||||
) =>
|
||||
service.changePassword(
|
||||
me,
|
||||
currentPassword,
|
||||
NEW_PASSWORD,
|
||||
ip,
|
||||
refreshToken,
|
||||
);
|
||||
|
||||
beforeEach(() => {
|
||||
// 로그인한 주소 그대로 엔진이 확인해 준다 (다른 직원의 세션도 만들 수 있게)
|
||||
mail.verifyCredentials.mockImplementation((email: string) =>
|
||||
Promise.resolve(email),
|
||||
);
|
||||
mail.changePassword.mockResolvedValue('changed');
|
||||
});
|
||||
|
||||
it('본인 자격으로 엔진에 바꾸기를 보내고, 이 요청의 세션만 남기고 그 직원의 다른 세션을 모두 끊는다 — 다른 직원의 세션은 그대로', async () => {
|
||||
const current = await loginToken();
|
||||
const other = await loginToken();
|
||||
const colleague = (await service.login(DOYUN, PASSWORD, CLIENT_IP))
|
||||
.tokens!.refreshToken;
|
||||
|
||||
await expect(change(PASSWORD, current)).resolves.toBeUndefined();
|
||||
|
||||
expect(mail.changePassword).toHaveBeenCalledWith(
|
||||
ARA,
|
||||
PASSWORD,
|
||||
NEW_PASSWORD,
|
||||
);
|
||||
expect(sessions.revokeAllOf).toHaveBeenCalledWith(
|
||||
ARA,
|
||||
'password-changed',
|
||||
expect.any(Date),
|
||||
decode(current).sid,
|
||||
);
|
||||
expect(sessions.rows.get(decode(other).sid!)?.revokedReason).toBe(
|
||||
'password-changed',
|
||||
);
|
||||
// 이 세션은 그대로 이어 쓴다 — 다른 세션은 갱신할 수 없다
|
||||
await expect(rotateFrom(current)).resolves.toEqual(expect.any(String));
|
||||
expect((await failureOf(service.refresh(other)))?.code).toBe('AUTH_001');
|
||||
await expect(rotateFrom(colleague)).resolves.toEqual(expect.any(String));
|
||||
});
|
||||
|
||||
it('리프레시 쿠키가 없거나 · 위조거나 · 다른 직원의 것이면 이 세션을 알 수 없어 그 직원의 세션을 모두 끊는다', async () => {
|
||||
const colleague = (await service.login(DOYUN, PASSWORD, CLIENT_IP))
|
||||
.tokens!.refreshToken;
|
||||
|
||||
for (const refreshToken of [undefined, 'not-a-jwt', colleague]) {
|
||||
const mine = await loginToken();
|
||||
sessions.revokeAllOf.mockClear();
|
||||
|
||||
await change(PASSWORD, refreshToken);
|
||||
|
||||
expect(sessions.revokeAllOf).toHaveBeenCalledWith(
|
||||
ARA,
|
||||
'password-changed',
|
||||
expect.any(Date),
|
||||
null,
|
||||
);
|
||||
expect(sessions.rows.get(decode(mine).sid!)?.revokedAt).not.toBeNull();
|
||||
}
|
||||
// 다른 직원의 세션은 건드리지 않는다
|
||||
await expect(rotateFrom(colleague)).resolves.toEqual(expect.any(String));
|
||||
});
|
||||
|
||||
it('지금 비밀번호가 틀리면 400 AUTH_005 — 세션은 그대로, 로그인 실패와 같은 카운터로 센다 (주소 15분 10회를 넘으면 맞는 비밀번호 · 로그인도 REQ_001)', async () => {
|
||||
const current = await loginToken();
|
||||
mail.changePassword.mockResolvedValue('current-password-rejected');
|
||||
|
||||
for (let i = 0; i < LOGIN_ADDRESS_FAILURE_LIMIT; i += 1) {
|
||||
// IP 를 나눠 IP 한도(1분 20회)와 섞이지 않게 한다 — 주소 한도만 본다
|
||||
const failure = await failureOf(
|
||||
change('wrong', current, `198.51.100.${i}`),
|
||||
);
|
||||
expect(failure?.code).toBe('AUTH_005');
|
||||
expect(failure?.getStatus()).toBe(400);
|
||||
}
|
||||
expect(sessions.revokeAllOf).not.toHaveBeenCalled();
|
||||
|
||||
mail.changePassword.mockResolvedValue('changed');
|
||||
expect(
|
||||
(await failureOf(change(PASSWORD, current, '192.0.2.10')))?.code,
|
||||
).toBe('REQ_001');
|
||||
expect(
|
||||
(await failureOf(service.login(ARA, PASSWORD, '192.0.2.11')))?.code,
|
||||
).toBe('REQ_001');
|
||||
expect(mail.changePassword).toHaveBeenCalledTimes(
|
||||
LOGIN_ADDRESS_FAILURE_LIMIT,
|
||||
);
|
||||
});
|
||||
|
||||
it('로그인 실패와 한 카운터다 — 로그인 실패가 쌓인 주소는 비밀번호 바꾸기도 그만큼 덜 남는다 · IP 한도도 같다', async () => {
|
||||
mail.verifyCredentials.mockResolvedValue(null);
|
||||
for (let i = 0; i < LOGIN_ADDRESS_FAILURE_LIMIT - 1; i += 1) {
|
||||
await failureOf(service.login(ARA, 'wrong', `198.51.100.${i}`));
|
||||
}
|
||||
mail.changePassword.mockResolvedValue('current-password-rejected');
|
||||
expect(
|
||||
(await failureOf(change('wrong', undefined, '192.0.2.20')))?.code,
|
||||
).toBe('AUTH_005');
|
||||
expect(
|
||||
(await failureOf(change('wrong', undefined, '192.0.2.21')))?.code,
|
||||
).toBe('REQ_001');
|
||||
|
||||
// 같은 IP 의 로그인 실패 20회 — 다른 직원이라도 그 IP 의 비밀번호 바꾸기는 REQ_001
|
||||
for (let i = 0; i < LOGIN_IP_FAILURE_LIMIT; i += 1) {
|
||||
await failureOf(
|
||||
service.login(`guess${i}@arasystem.test`, 'wrong', '203.0.113.30'),
|
||||
);
|
||||
}
|
||||
const failure = await failureOf(
|
||||
service.changePassword(
|
||||
{ email: DOYUN, name: '이도윤' },
|
||||
PASSWORD,
|
||||
NEW_PASSWORD,
|
||||
'203.0.113.30',
|
||||
undefined,
|
||||
),
|
||||
);
|
||||
expect(failure?.code).toBe('REQ_001');
|
||||
expect(failure?.context).toMatchObject({ scope: 'ip' });
|
||||
});
|
||||
|
||||
it('바뀌면 그 주소의 실패 기록을 지운다 (로그인 성공과 같다)', async () => {
|
||||
mail.changePassword.mockResolvedValue('current-password-rejected');
|
||||
for (let i = 0; i < LOGIN_ADDRESS_FAILURE_LIMIT - 1; i += 1) {
|
||||
await failureOf(change('wrong', undefined, `198.51.100.${i}`));
|
||||
}
|
||||
mail.changePassword.mockResolvedValue('changed');
|
||||
await change(PASSWORD, undefined, '192.0.2.30');
|
||||
|
||||
mail.changePassword.mockResolvedValue('current-password-rejected');
|
||||
for (let i = 0; i < LOGIN_ADDRESS_FAILURE_LIMIT; i += 1) {
|
||||
expect(
|
||||
(await failureOf(change('wrong', undefined, `198.51.101.${i}`)))
|
||||
?.code,
|
||||
).toBe('AUTH_005');
|
||||
}
|
||||
});
|
||||
|
||||
it('새 비밀번호가 지금과 같으면 엔진에 묻지 않고 VAL_001 — 실패로 세지 않는다', async () => {
|
||||
for (let i = 0; i < LOGIN_ADDRESS_FAILURE_LIMIT + 2; i += 1) {
|
||||
const failure = await failureOf(
|
||||
service.changePassword(me, PASSWORD, PASSWORD, CLIENT_IP, undefined),
|
||||
);
|
||||
expect(failure?.code).toBe('VAL_001');
|
||||
}
|
||||
expect(mail.changePassword).not.toHaveBeenCalled();
|
||||
await expect(change()).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('엔진 규칙에 어긋난 새 비밀번호(VAL_001) · 엔진 장애(MAIL_001)는 실패로 세지 않는다 — 세션도 그대로', async () => {
|
||||
const current = await loginToken();
|
||||
const other = await loginToken();
|
||||
|
||||
mail.changePassword.mockResolvedValue('new-password-rejected');
|
||||
for (let i = 0; i < LOGIN_ADDRESS_FAILURE_LIMIT + 2; i += 1) {
|
||||
expect((await failureOf(change(PASSWORD, current)))?.code).toBe(
|
||||
'VAL_001',
|
||||
);
|
||||
}
|
||||
mail.changePassword.mockRejectedValue(new BusinessException('MAIL_001'));
|
||||
for (let i = 0; i < LOGIN_ADDRESS_FAILURE_LIMIT + 2; i += 1) {
|
||||
expect((await failureOf(change(PASSWORD, current)))?.code).toBe(
|
||||
'MAIL_001',
|
||||
);
|
||||
}
|
||||
|
||||
expect(sessions.revokeAllOf).not.toHaveBeenCalled();
|
||||
await expect(rotateFrom(other)).resolves.toEqual(expect.any(String));
|
||||
mail.changePassword.mockResolvedValue('changed');
|
||||
await expect(change(PASSWORD, current)).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('지금 비밀번호로 들어갔는데 엔진이 바꾸기를 거절하면(변경 권한 없음 등) 400 AUTH_005 — 실패로 세지 않고 세션도 그대로', async () => {
|
||||
const current = await loginToken();
|
||||
const other = await loginToken();
|
||||
mail.changePassword.mockResolvedValue('current-password-refused');
|
||||
|
||||
for (let i = 0; i < LOGIN_ADDRESS_FAILURE_LIMIT + 2; i += 1) {
|
||||
const failure = await failureOf(change(PASSWORD, current));
|
||||
expect(failure?.code).toBe('AUTH_005');
|
||||
expect(failure?.getStatus()).toBe(400);
|
||||
expect(failure?.context).toMatchObject({
|
||||
reason: 'current-password-refused-by-mail-engine',
|
||||
});
|
||||
}
|
||||
|
||||
expect(sessions.revokeAllOf).not.toHaveBeenCalled();
|
||||
await expect(rotateFrom(other)).resolves.toEqual(expect.any(String));
|
||||
// 세지 않았다 — 맞는 비밀번호의 로그인은 그대로 된다
|
||||
await expect(
|
||||
service.login(ARA, PASSWORD, '192.0.2.60'),
|
||||
).resolves.toMatchObject({ user: { email: ARA } });
|
||||
});
|
||||
|
||||
it('결과를 알 수 없게 끝나면(엔진이 바꿨을 수 있다) 다른 세션을 끊어 두고 503 MAIL_001 — 이 세션은 남기고, 실패로 세지 않는다', async () => {
|
||||
const current = await loginToken();
|
||||
const other = await loginToken();
|
||||
mail.changePassword.mockResolvedValue('outcome-unknown');
|
||||
|
||||
const failure = await failureOf(change(PASSWORD, current));
|
||||
|
||||
expect(failure?.code).toBe('MAIL_001');
|
||||
expect(failure?.getStatus()).toBe(503);
|
||||
expect(failure?.context).toMatchObject({
|
||||
reason: 'password-change-outcome-unknown',
|
||||
revokedOtherSessions: 1,
|
||||
});
|
||||
expect(sessions.revokeAllOf).toHaveBeenCalledWith(
|
||||
ARA,
|
||||
'password-change-uncertain',
|
||||
expect.any(Date),
|
||||
decode(current).sid,
|
||||
);
|
||||
expect(sessions.rows.get(decode(other).sid!)?.revokedReason).toBe(
|
||||
'password-change-uncertain',
|
||||
);
|
||||
await expect(rotateFrom(current)).resolves.toEqual(expect.any(String));
|
||||
expect((await failureOf(service.refresh(other)))?.code).toBe('AUTH_001');
|
||||
|
||||
for (let i = 0; i < LOGIN_ADDRESS_FAILURE_LIMIT + 2; i += 1) {
|
||||
expect((await failureOf(change(PASSWORD, current)))?.code).toBe(
|
||||
'MAIL_001',
|
||||
);
|
||||
}
|
||||
mail.changePassword.mockResolvedValue('changed');
|
||||
await expect(change(PASSWORD, current)).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('결과를 알 수 없고 세션도 끊지 못하면 error 로그를 남기고 그대로 MAIL_001 — 시도는 끝낸다 (그 주소가 막히지 않는다)', async () => {
|
||||
mail.changePassword.mockResolvedValue('outcome-unknown');
|
||||
sessions.revokeAllOf.mockRejectedValueOnce(new Error('db down'));
|
||||
|
||||
const failure = await failureOf(change(PASSWORD, await loginToken()));
|
||||
|
||||
expect(failure?.code).toBe('MAIL_001');
|
||||
expect(failure?.context).toMatchObject({ revokedOtherSessions: 0 });
|
||||
expect(logs.join('\n')).toMatch(/\[SYS_001\].*세션을 끊지 못했습니다/);
|
||||
await expect(
|
||||
service.login(ARA, PASSWORD, '192.0.2.61'),
|
||||
).resolves.toMatchObject({ user: { email: ARA } });
|
||||
});
|
||||
|
||||
it.each(['changed', 'outcome-unknown'] as const)(
|
||||
'다른 세션을 끊는 동안은 같은 주소의 로그인이 REQ_001 (%s) — 끊기가 끝난 뒤에 시도를 끝내, 그 뒤에 만든 세션은 끊기지 않는다',
|
||||
async (outcome) => {
|
||||
const current = await loginToken();
|
||||
mail.changePassword.mockResolvedValue(outcome);
|
||||
const revokeAll = sessions.revokeAllOf.getMockImplementation();
|
||||
if (!revokeAll) throw new Error('revokeAllOf 구현이 없다');
|
||||
let release!: () => void;
|
||||
sessions.revokeAllOf.mockImplementationOnce(
|
||||
(...args) =>
|
||||
new Promise<number>((resolve) => {
|
||||
release = () => resolve(revokeAll(...args));
|
||||
}),
|
||||
);
|
||||
|
||||
const running = failureOf(change(PASSWORD, current));
|
||||
await until(() => sessions.revokeAllOf.mock.calls.length === 1);
|
||||
expect(
|
||||
(await failureOf(service.login(ARA, PASSWORD, '192.0.2.70')))?.code,
|
||||
).toBe('REQ_001');
|
||||
|
||||
release();
|
||||
const expected = { changed: undefined, 'outcome-unknown': 'MAIL_001' };
|
||||
expect((await running)?.code).toBe(expected[outcome]);
|
||||
const fresh = (await service.login(ARA, PASSWORD, '192.0.2.71')).tokens!
|
||||
.refreshToken;
|
||||
await expect(rotateFrom(fresh)).resolves.toEqual(expect.any(String));
|
||||
},
|
||||
);
|
||||
|
||||
it('같은 주소의 바꾸기 · 로그인이 엔진에 가 있는 동안 다음 시도는 기다리지 않고 REQ_001', async () => {
|
||||
let release!: () => void;
|
||||
mail.changePassword.mockImplementationOnce(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
release = () => resolve('changed');
|
||||
}),
|
||||
);
|
||||
|
||||
const first = change();
|
||||
expect((await failureOf(change()))?.code).toBe('REQ_001');
|
||||
expect(
|
||||
(await failureOf(service.login(ARA, PASSWORD, '192.0.2.40')))?.code,
|
||||
).toBe('REQ_001');
|
||||
|
||||
release();
|
||||
await expect(first).resolves.toBeUndefined();
|
||||
expect(mail.changePassword).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('웹메일로 다루지 않는 계정(메일 엔진 관리자 · 막은 계정)은 엔진에 묻지 않고 AUTH_001 · 메일 설정이 없으면 MAIL_001', async () => {
|
||||
const admin = await failureOf(
|
||||
service.changePassword(
|
||||
{ email: 'Admin@AraSystem.test', name: '관리자' },
|
||||
PASSWORD,
|
||||
NEW_PASSWORD,
|
||||
CLIENT_IP,
|
||||
undefined,
|
||||
),
|
||||
);
|
||||
expect(admin?.code).toBe('AUTH_001');
|
||||
expect(mail.changePassword).not.toHaveBeenCalled();
|
||||
|
||||
service = build({ domain: null });
|
||||
expect((await failureOf(change()))?.code).toBe('MAIL_001');
|
||||
expect(mail.changePassword).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('비밀번호는 바뀌었는데 세션을 끊지 못하면 성공으로 답하고 error 로그를 남긴다 (실패로 답하면 옛 비밀번호로 다시 시도해 AUTH_005 가 된다)', async () => {
|
||||
sessions.revokeAllOf.mockRejectedValueOnce(new Error('db down'));
|
||||
|
||||
await expect(
|
||||
change(PASSWORD, await loginToken()),
|
||||
).resolves.toBeUndefined();
|
||||
expect(logs.join('\n')).toMatch(/\[SYS_001\].*세션을 끊지 못했습니다/);
|
||||
});
|
||||
|
||||
it('로그에는 가린 주소와 끊은 세션 수만 — 비밀번호는 어디에도 남지 않는다 (실패의 context 포함)', async () => {
|
||||
const current = await loginToken();
|
||||
await loginToken();
|
||||
await change(PASSWORD, current);
|
||||
|
||||
mail.changePassword.mockResolvedValue('current-password-rejected');
|
||||
const wrong = await failureOf(change('wrong-current-pw', current));
|
||||
mail.changePassword.mockResolvedValue('new-password-rejected');
|
||||
const rejected = await failureOf(change(PASSWORD, current));
|
||||
mail.changePassword.mockResolvedValue('current-password-refused');
|
||||
const refused = await failureOf(change(PASSWORD, current));
|
||||
mail.changePassword.mockResolvedValue('outcome-unknown');
|
||||
const unknown = await failureOf(change(PASSWORD, current));
|
||||
|
||||
const everything = [
|
||||
...logs,
|
||||
JSON.stringify(wrong?.context),
|
||||
JSON.stringify(rejected?.context),
|
||||
JSON.stringify(refused?.context),
|
||||
JSON.stringify(unknown?.context),
|
||||
].join('\n');
|
||||
expect(everything).toContain(
|
||||
'비밀번호 변경: a***@arasystem.test, 끊은 다른 세션 1개',
|
||||
);
|
||||
expect(everything).not.toContain(ARA);
|
||||
expect(everything).not.toContain(PASSWORD);
|
||||
expect(everything).not.toContain(NEW_PASSWORD);
|
||||
expect(everything).not.toContain('wrong-current-pw');
|
||||
});
|
||||
});
|
||||
|
||||
describe('로그아웃', () => {
|
||||
it('서버의 세션을 끊는다 — 그 뒤 리프레시는 AUTH_001', async () => {
|
||||
const token = await loginToken();
|
||||
|
||||
@@ -6,12 +6,18 @@ import type { AuthenticatedUser } from '@/common/decorators/current-user.decorat
|
||||
import { BusinessException } from '@/common/errors/business.exception';
|
||||
import { MAIL_PROVIDER, type MailProvider } from '@/modules/mail/mail.provider';
|
||||
import { MAIL_SETTINGS, type MailSettings } from '@/modules/mail/mail.settings';
|
||||
import type { MailAccountProfile } from '@/modules/mail/mail.types';
|
||||
import type {
|
||||
MailAccountProfile,
|
||||
PasswordChangeOutcome,
|
||||
} from '@/modules/mail/mail.types';
|
||||
import { MailDirectoryService } from '@/modules/mail-directory/mail-directory.service';
|
||||
import { traceTag } from '@/shared/logger/request-context';
|
||||
import { maskEmail } from '@/shared/utils/mask';
|
||||
import type { SessionTokens } from './auth-cookies';
|
||||
import type { AuthSessionEntity } from './auth-session.entity';
|
||||
import type {
|
||||
AuthSessionEntity,
|
||||
SessionRevokedReason,
|
||||
} from './auth-session.entity';
|
||||
import {
|
||||
classifyRefresh,
|
||||
hashRefreshToken,
|
||||
@@ -27,7 +33,10 @@ import {
|
||||
} from './auth.constants';
|
||||
import { AUTH_SETTINGS, type AuthSettings } from './auth.settings';
|
||||
import { isEmployeeAddress, normalizeEmail } from './employee-address';
|
||||
import { LoginAttemptService } from './login-attempt.service';
|
||||
import {
|
||||
LoginAttemptService,
|
||||
type LoginAttemptTicket,
|
||||
} from './login-attempt.service';
|
||||
|
||||
export interface AuthResult {
|
||||
readonly user: AuthenticatedUser;
|
||||
@@ -169,6 +178,186 @@ export class AuthService {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 비밀번호 바꾸기 (계약 1절 "PUT /api/auth/password") — 메일 엔진에 그 직원 **본인의 자격**(지금 비밀번호)으로 보낸다.
|
||||
*
|
||||
* - 새 비밀번호가 지금과 같으면 엔진에 묻지 않고 VAL_001
|
||||
* - 막는 계정(관리자 등 로그인할 수 없는 주소)은 엔진에 묻지 않고 AUTH_001 — 웹메일로 다루지 않는 계정이다
|
||||
* - 지금 비밀번호 틀림은 로그인 실패와 **같은 한도 · 같은 카운터**로 센다 — 엔진에 묻기 전에 실패 한 번을 IP·주소에 차지하고
|
||||
* (LoginAttemptService), 틀리면 남기고(400 AUTH_005 — 401 이 아니다: 클라이언트는 401 이면 갱신하고 다시 보낸다),
|
||||
* 바뀌면 로그인 성공처럼 돌려준다. 같은 주소의 로그인·변경이 처리 중이면 REQ_001
|
||||
* - 세지 않는 실패: 지금 비밀번호로 들어갔는데 엔진이 바꾸기를 거절함(400 AUTH_005 — 맞혀 보기가 아니다) · 엔진 규칙 거절(VAL_001) · 엔진 장애
|
||||
* - 바뀌면 그 직원의 **다른** 로그인 세션을 모두 끊는다 — 이 요청의 리프레시 쿠키(Path=/api/auth 라 함께 온다)가 가리키는 세션만 남긴다.
|
||||
* 쿠키로 이 세션을 알 수 없으면 모두 끊는다 (이 탭도 액세스 토큰이 끝나면 다시 로그인한다).
|
||||
* 결과를 알 수 없게 끝났어도(엔진이 바꿨을 수 있다) 다른 세션은 끊어 두고 503 MAIL_001 로 답한다.
|
||||
* 시도는 끊기가 끝난 뒤에 끝낸다 — 그동안 같은 주소의 로그인은 처리 중(REQ_001)이라 새 비밀번호로 막 만든 세션이 함께 끊기지 않는다
|
||||
* 비밀번호는 로그 · 오류 context 에 남기지 않는다. 바꾼 일은 info 로그에 가린 주소와 끊은 세션 수만.
|
||||
*/
|
||||
async changePassword(
|
||||
user: AuthenticatedUser,
|
||||
currentPassword: string,
|
||||
newPassword: string,
|
||||
clientIp: string,
|
||||
refreshToken: string | undefined,
|
||||
): Promise<void> {
|
||||
const address = normalizeEmail(user.email);
|
||||
if (newPassword === currentPassword) {
|
||||
throw new BusinessException('VAL_001', {
|
||||
context: { reason: 'new-password-same-as-current' },
|
||||
});
|
||||
}
|
||||
if (!this.mailSettings.domain) {
|
||||
throw new BusinessException('MAIL_001', {
|
||||
context: { reason: 'mail-not-configured' },
|
||||
});
|
||||
}
|
||||
if (!this.isLoginAddress(address)) {
|
||||
throw new BusinessException('AUTH_001', {
|
||||
context: {
|
||||
reason: 'password-change-not-allowed',
|
||||
email: maskEmail(address),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// 로그인과 같은 카운터 — 지금 비밀번호를 맞혀 보는 창구가 로그인 한도를 비켜 가지 못하게
|
||||
const ticket = this.attempts.begin({
|
||||
email: address,
|
||||
ip: clientIp || 'unknown',
|
||||
});
|
||||
|
||||
let outcome: PasswordChangeOutcome;
|
||||
try {
|
||||
outcome = await this.mail.changePassword(
|
||||
address,
|
||||
currentPassword,
|
||||
newPassword,
|
||||
);
|
||||
} catch (error) {
|
||||
// 결과가 확실한 엔진 장애(MAIL_001) · 엔진 한도(REQ_001) — 바뀌지 않았고 사용자 잘못도 아니다. 세지 않는다
|
||||
ticket.abort();
|
||||
throw error;
|
||||
}
|
||||
|
||||
switch (outcome) {
|
||||
case 'current-password-rejected':
|
||||
ticket.fail();
|
||||
throw new BusinessException('AUTH_005', {
|
||||
context: {
|
||||
reason: 'current-password-rejected',
|
||||
email: maskEmail(address),
|
||||
},
|
||||
});
|
||||
case 'current-password-refused':
|
||||
// 같은 값으로 엔진에 들어가기는 했다 — 맞혀 보기가 아니라 세지 않는다 (변경 권한을 빼면 맞는 비밀번호로 로그인까지 막히지 않게).
|
||||
// 엔진의 거절 종류는 어댑터가 warn 으로 남겼다
|
||||
ticket.abort();
|
||||
throw new BusinessException('AUTH_005', {
|
||||
context: {
|
||||
reason: 'current-password-refused-by-mail-engine',
|
||||
email: maskEmail(address),
|
||||
},
|
||||
});
|
||||
case 'new-password-rejected':
|
||||
// 지금 비밀번호는 맞았다 — 실패로 세지 않는다
|
||||
ticket.abort();
|
||||
throw new BusinessException('VAL_001', {
|
||||
context: {
|
||||
reason: 'new-password-rejected-by-mail-engine',
|
||||
email: maskEmail(address),
|
||||
},
|
||||
});
|
||||
case 'outcome-unknown': {
|
||||
// 엔진이 바꿨을 수 있다 — 탈취가 의심돼 바꾸는 중일 수 있으니 다른 세션은 끊어 두고 실패로 답한다 (이 세션은 남긴다)
|
||||
const revoked = await this.revokeBeforeSettling(
|
||||
ticket,
|
||||
'abort',
|
||||
address,
|
||||
refreshToken,
|
||||
'password-change-uncertain',
|
||||
);
|
||||
throw new BusinessException('MAIL_001', {
|
||||
context: {
|
||||
reason: 'password-change-outcome-unknown',
|
||||
email: maskEmail(address),
|
||||
revokedOtherSessions: revoked,
|
||||
},
|
||||
});
|
||||
}
|
||||
case 'changed': {
|
||||
const revoked = await this.revokeBeforeSettling(
|
||||
ticket,
|
||||
'succeed',
|
||||
address,
|
||||
refreshToken,
|
||||
'password-changed',
|
||||
);
|
||||
this.logger.log(
|
||||
`비밀번호 변경: ${maskEmail(address)}, 끊은 다른 세션 ${revoked}개`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 다른 세션을 끊은 **뒤에** 시도를 끝낸다 → 끊은 수. 끊는 동안은 같은 주소의 로그인이 진행 중(REQ_001)으로 막혀,
|
||||
* 새 비밀번호로 막 로그인한 세션이 함께 끊기지 않는다. 끊다 실패해도 시도는 반드시 끝낸다 (그 주소가 계속 막히지 않게)
|
||||
*/
|
||||
private async revokeBeforeSettling(
|
||||
ticket: LoginAttemptTicket,
|
||||
settle: 'succeed' | 'abort',
|
||||
address: string,
|
||||
refreshToken: string | undefined,
|
||||
reason: SessionRevokedReason,
|
||||
): Promise<number> {
|
||||
try {
|
||||
return await this.revokeOtherSessions(address, refreshToken, reason);
|
||||
} finally {
|
||||
if (settle === 'succeed') ticket.succeed();
|
||||
else ticket.abort();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 비밀번호를 바꾼(또는 바꿨을 수 있는) 뒤 그 직원의 다른 세션을 끊는다 → 끊은 수.
|
||||
* 여기서 실패해도 던지지 않는다 — 비밀번호는 이미 바뀌었을 수 있어, 실패로 답하면 사용자가 옛 비밀번호로 다시 시도해
|
||||
* AUTH_005 를 받는다. 대신 error 로그를 남긴다 (다른 세션이 남아 있다 — 운영자가 끊을 수 있게 가린 주소와 함께)
|
||||
*/
|
||||
private async revokeOtherSessions(
|
||||
address: string,
|
||||
refreshToken: string | undefined,
|
||||
reason: SessionRevokedReason,
|
||||
): Promise<number> {
|
||||
const keepId = await this.sessionIdOf(refreshToken, address);
|
||||
try {
|
||||
return await this.sessions.revokeAllOf(
|
||||
address,
|
||||
reason,
|
||||
new Date(),
|
||||
keepId,
|
||||
);
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
`[SYS_001]${traceTag()} 비밀번호 바꾸기 뒤 다른 로그인 세션을 끊지 못했습니다 — 남은 세션은 만료까지 쓸 수 있습니다: user=${maskEmail(address)}, reason=${errorMessage(error)}`,
|
||||
);
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
/** 리프레시 쿠키가 가리키는 그 직원의 세션 id — 없거나 · 위조거나 · 다른 직원의 토큰이면 null (만료는 따지지 않는다 — 고르기만 한다) */
|
||||
private async sessionIdOf(
|
||||
refreshToken: string | undefined,
|
||||
address: string,
|
||||
): Promise<string | null> {
|
||||
if (!refreshToken) return null;
|
||||
try {
|
||||
const claims = await this.verifyRefreshToken(refreshToken, true);
|
||||
return normalizeEmail(claims.sub) === address ? claims.sid : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 로그아웃 — 서버의 세션을 끊는다. 항상 성공으로 끝난다(계약: 항상 200):
|
||||
* 토큰이 없거나 이미 쓸 수 없어도, 세션 폐기에 실패해도(오류 로그만) 쿠키는 컨트롤러가 지운다.
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
import { plainToInstance } from 'class-transformer';
|
||||
import { validate } from 'class-validator';
|
||||
|
||||
import {
|
||||
MAX_PASSWORD_LENGTH,
|
||||
NEW_PASSWORD_MAX_LENGTH,
|
||||
NEW_PASSWORD_MIN_LENGTH,
|
||||
} from '@/modules/auth/auth.constants';
|
||||
import { ChangePasswordDto } from './change-password.dto';
|
||||
|
||||
/** 전역 ValidationPipe 와 같은 옵션으로 검증한다 (app.setup.ts) — 걸린 규칙 이름만 */
|
||||
async function constraintsOf(body: object): Promise<string[]> {
|
||||
const errors = await validate(plainToInstance(ChangePasswordDto, body), {
|
||||
whitelist: true,
|
||||
forbidNonWhitelisted: true,
|
||||
});
|
||||
return errors.flatMap((error) => Object.keys(error.constraints ?? {}));
|
||||
}
|
||||
|
||||
describe('비밀번호 바꾸기 요청 본문 (PUT /api/auth/password)', () => {
|
||||
const valid = { currentPassword: 'old-password', newPassword: 'new-pass' };
|
||||
|
||||
it(`새 비밀번호는 ${NEW_PASSWORD_MIN_LENGTH}~${NEW_PASSWORD_MAX_LENGTH}자 — 밖이면 형식 오류`, async () => {
|
||||
expect(await constraintsOf(valid)).toEqual([]);
|
||||
expect(
|
||||
await constraintsOf({
|
||||
...valid,
|
||||
newPassword: 'x'.repeat(NEW_PASSWORD_MAX_LENGTH),
|
||||
}),
|
||||
).toEqual([]);
|
||||
|
||||
expect(
|
||||
await constraintsOf({
|
||||
...valid,
|
||||
newPassword: 'x'.repeat(NEW_PASSWORD_MIN_LENGTH - 1),
|
||||
}),
|
||||
).toEqual(['minCodeUnits']);
|
||||
expect(
|
||||
await constraintsOf({
|
||||
...valid,
|
||||
newPassword: 'x'.repeat(NEW_PASSWORD_MAX_LENGTH + 1),
|
||||
}),
|
||||
).toEqual(['maxCodeUnits']);
|
||||
});
|
||||
|
||||
it('길이는 JS 문자열 길이(UTF-16 코드 단위)로 센다 — 웹 클라이언트와 같은 수 (이모지는 2자)', async () => {
|
||||
// 이모지 4개 = 8 코드 단위 — validator.js 의 MinLength 는 4자로 세어 막았을 것이다
|
||||
expect(await constraintsOf({ ...valid, newPassword: '🔒🔒🔒🔒' })).toEqual(
|
||||
[],
|
||||
);
|
||||
// 이모지 64개 + 1자 = 129 코드 단위 — 넘는다
|
||||
expect(
|
||||
await constraintsOf({ ...valid, newPassword: `${'🔒'.repeat(64)}x` }),
|
||||
).toEqual(['maxCodeUnits']);
|
||||
});
|
||||
|
||||
it(`지금 비밀번호는 로그인과 같은 한도(${MAX_PASSWORD_LENGTH}자) — 비었거나 길면 형식 오류, 문자열이 아니면 형식 오류`, async () => {
|
||||
expect(
|
||||
await constraintsOf({
|
||||
...valid,
|
||||
currentPassword: 'x'.repeat(MAX_PASSWORD_LENGTH),
|
||||
}),
|
||||
).toEqual([]);
|
||||
expect(await constraintsOf({ ...valid, currentPassword: '' })).toEqual([
|
||||
'isNotEmpty',
|
||||
]);
|
||||
expect(
|
||||
await constraintsOf({
|
||||
...valid,
|
||||
currentPassword: 'x'.repeat(MAX_PASSWORD_LENGTH + 1),
|
||||
}),
|
||||
).toEqual(['maxLength']);
|
||||
expect(
|
||||
await constraintsOf({ ...valid, currentPassword: 12345678 }),
|
||||
).toEqual(expect.arrayContaining(['isString']));
|
||||
expect(await constraintsOf({ newPassword: 'new-pass' })).toEqual(
|
||||
expect.arrayContaining(['isString']),
|
||||
);
|
||||
});
|
||||
|
||||
it('모르는 필드는 거부한다 (확인 칸은 화면만 본다)', async () => {
|
||||
expect(
|
||||
await constraintsOf({ ...valid, newPasswordConfirm: 'new-pass' }),
|
||||
).toEqual(['whitelistValidation']);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,47 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { IsNotEmpty, IsString, MaxLength } from 'class-validator';
|
||||
|
||||
import {
|
||||
MaxCodeUnits,
|
||||
MinCodeUnits,
|
||||
} from '@/common/decorators/code-units.decorator';
|
||||
import {
|
||||
MAX_PASSWORD_LENGTH,
|
||||
NEW_PASSWORD_MAX_LENGTH,
|
||||
NEW_PASSWORD_MIN_LENGTH,
|
||||
} from '@/modules/auth/auth.constants';
|
||||
|
||||
/**
|
||||
* PUT /api/auth/password — 계약 1절.
|
||||
* 지금 비밀번호는 로그인(dto/login.dto.ts)과 같은 검사 — 로그인할 수 있었던 비밀번호는 여기서도 통과하고, 그보다 긴 값은 들어올 수 없다.
|
||||
* 새 비밀번호의 길이는 JS 문자열 길이로 센다(웹 클라이언트와 같다). 지금 비밀번호와 같으면 서비스가 400 VAL_001 (엔진에 묻지 않는다).
|
||||
* 비밀번호는 저장하지 않고 로그에 남기지 않는다.
|
||||
*/
|
||||
export class ChangePasswordDto {
|
||||
@ApiProperty({
|
||||
description: `지금 비밀번호 — 메일 엔진이 확인한다 (로그인과 같은 한도, ${MAX_PASSWORD_LENGTH}자 이하)`,
|
||||
format: 'password',
|
||||
maxLength: MAX_PASSWORD_LENGTH,
|
||||
example: 'current-password',
|
||||
})
|
||||
@IsString({ message: '지금 비밀번호를 입력해 주세요.' })
|
||||
@IsNotEmpty({ message: '지금 비밀번호를 입력해 주세요.' })
|
||||
@MaxLength(MAX_PASSWORD_LENGTH, { message: '지금 비밀번호가 너무 깁니다.' })
|
||||
currentPassword!: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: `새 비밀번호 — ${NEW_PASSWORD_MIN_LENGTH}~${NEW_PASSWORD_MAX_LENGTH}자, 지금 비밀번호와 달라야 한다 (메일 엔진의 규칙에 어긋나면 400 VAL_001)`,
|
||||
format: 'password',
|
||||
minLength: NEW_PASSWORD_MIN_LENGTH,
|
||||
maxLength: NEW_PASSWORD_MAX_LENGTH,
|
||||
example: 'new-password-1234',
|
||||
})
|
||||
@IsString({ message: '새 비밀번호를 입력해 주세요.' })
|
||||
@MinCodeUnits(NEW_PASSWORD_MIN_LENGTH, {
|
||||
message: `새 비밀번호는 ${NEW_PASSWORD_MIN_LENGTH}자 이상이어야 합니다.`,
|
||||
})
|
||||
@MaxCodeUnits(NEW_PASSWORD_MAX_LENGTH, {
|
||||
message: `새 비밀번호는 ${NEW_PASSWORD_MAX_LENGTH}자 이하여야 합니다.`,
|
||||
})
|
||||
newPassword!: string;
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
MAX_ATTACHMENTS_PER_MESSAGE,
|
||||
allowedAttachmentExtensions,
|
||||
isAllowedAttachment,
|
||||
messageSourceFileName,
|
||||
normalizeMimeType,
|
||||
resolveAttachmentType,
|
||||
resolveAttachmentsTotalLimit,
|
||||
@@ -161,3 +162,47 @@ describe('sanitizeFileName', () => {
|
||||
expect(sanitizeFileName('dir/')).toBe('첨부파일');
|
||||
});
|
||||
});
|
||||
|
||||
describe('원문 파일 이름 (messageSourceFileName)', () => {
|
||||
it('제목 + .eml — 제목이 없거나 정리하고 비면 "메일"', () => {
|
||||
expect(messageSourceFileName('메일 서버 이전 일정 공유')).toBe(
|
||||
'메일 서버 이전 일정 공유.eml',
|
||||
);
|
||||
for (const subject of ['', ' ', '...', '\u0000\u202E']) {
|
||||
expect(messageSourceFileName(subject)).toBe('메일.eml');
|
||||
}
|
||||
});
|
||||
|
||||
it('경로 구분자와 Windows 에서 못 쓰는 문자는 _ 로, 제어 · 방향 제어 문자는 지우고, 공백(탭 · 줄바꿈)은 하나로', () => {
|
||||
expect(messageSourceFileName('Re: 견적서/수정본 <최종>?')).toBe(
|
||||
'Re_ 견적서_수정본 _최종__.eml',
|
||||
);
|
||||
expect(messageSourceFileName('..\\..\\secret|"x"*')).toBe(
|
||||
'_.._secret__x__.eml',
|
||||
);
|
||||
expect(messageSourceFileName('주간\t보고\r\n (10월)')).toBe(
|
||||
'주간 보고 (10월).eml',
|
||||
);
|
||||
expect(messageSourceFileName('계약서\u202Egpj.exe')).toBe(
|
||||
'계약서gpj.exe.eml',
|
||||
);
|
||||
});
|
||||
|
||||
it('앞뒤의 공백 · 점을 떼고 80글자(코드 포인트)까지 — 자른 끝의 공백 · 점도 뗀다', () => {
|
||||
expect(messageSourceFileName(' .숨김 파일. ')).toBe('숨김 파일.eml');
|
||||
expect(messageSourceFileName('가'.repeat(100))).toBe(
|
||||
`${'가'.repeat(80)}.eml`,
|
||||
);
|
||||
expect(messageSourceFileName('😀'.repeat(81))).toBe(
|
||||
`${'😀'.repeat(80)}.eml`,
|
||||
);
|
||||
expect(messageSourceFileName(`${'가'.repeat(79)} 끝`)).toBe(
|
||||
`${'가'.repeat(79)}.eml`,
|
||||
);
|
||||
});
|
||||
|
||||
it('아주 긴 제목도 입력 길이에 비례해 처리한다 (반복 정규식 없이)', () => {
|
||||
const long = `${' .'.repeat(200_000)}x${' .'.repeat(200_000)}`;
|
||||
expect(messageSourceFileName(long)).toBe('x.eml');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -238,6 +238,62 @@ export function sanitizeFileName(rawName: string): string {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 원문 파일 이름의 제목 부분 최대 글자 수(코드 포인트). 한글(UTF-8 3바이트)뿐이면 `.eml` 까지 240+4 바이트라
|
||||
* 파일 시스템의 이름 한도(255바이트) 안에 든다. 4바이트 문자(이모지 등)가 많으면 넘을 수 있는데,
|
||||
* 그때는 브라우저가 저장하면서 이름을 줄인다 (서버는 더 자르지 않는다)
|
||||
*/
|
||||
const SOURCE_NAME_MAX_CHARS = 80;
|
||||
/** 제목이 없거나 정리하고 비면 쓰는 이름 */
|
||||
const SOURCE_FALLBACK_NAME = '메일';
|
||||
/** 원문 파일의 확장자 */
|
||||
const SOURCE_EXTENSION = '.eml';
|
||||
/** 파일 이름에 쓸 수 없는 문자(경로 구분자와 Windows 예약 문자) — '_' 로 바꾼다 */
|
||||
const FILE_NAME_RESERVED_CHARS: ReadonlySet<string> = new Set([
|
||||
'\\',
|
||||
'/',
|
||||
':',
|
||||
'*',
|
||||
'?',
|
||||
'"',
|
||||
'<',
|
||||
'>',
|
||||
'|',
|
||||
]);
|
||||
/** 이름 앞뒤에서 떼는 문자 — 공백과 점(앞의 점은 숨김 파일, Windows 는 끝의 점을 지운다) */
|
||||
const NAME_EDGE_CHARS: ReadonlySet<string> = new Set([' ', '.']);
|
||||
const WHITESPACE = /\s/u;
|
||||
|
||||
/** 앞뒤의 공백·점을 뗀다 — 반복 정규식 없이 한 번 훑는다 (긴 제목에서도 입력 길이에 비례) */
|
||||
function trimNameEdges(chars: readonly string[]): string[] {
|
||||
let start = 0;
|
||||
let end = chars.length;
|
||||
while (start < end && NAME_EDGE_CHARS.has(chars[start])) start += 1;
|
||||
while (end > start && NAME_EDGE_CHARS.has(chars[end - 1])) end -= 1;
|
||||
return chars.slice(start, end);
|
||||
}
|
||||
|
||||
/**
|
||||
* 원문 내려받기 파일 이름 — 제목(없으면 "메일") + `.eml` (계약 2절 "스팸 · 원문").
|
||||
* 공백(탭 · 줄바꿈 포함)은 하나로 줄이고, 제어 · 방향 제어 문자는 지우고, 경로 구분자와 Windows 에서 못 쓰는 문자는 '_' 로 바꾼다.
|
||||
* 앞뒤의 공백 · 점을 떼고 SOURCE_NAME_MAX_CHARS 글자까지 자른다. 헤더 인코딩은 content-disposition.ts 가 한다
|
||||
*/
|
||||
export function messageSourceFileName(subject: string): string {
|
||||
const chars: string[] = [];
|
||||
for (const char of subject) {
|
||||
if (WHITESPACE.test(char)) {
|
||||
if (chars.at(-1) !== ' ') chars.push(' ');
|
||||
continue;
|
||||
}
|
||||
if (isControlOrBidiChar(char.codePointAt(0) ?? 0)) continue;
|
||||
chars.push(FILE_NAME_RESERVED_CHARS.has(char) ? '_' : char);
|
||||
}
|
||||
const base = trimNameEdges(
|
||||
trimNameEdges(chars).slice(0, SOURCE_NAME_MAX_CHARS),
|
||||
).join('');
|
||||
return `${base || SOURCE_FALLBACK_NAME}${SOURCE_EXTENSION}`;
|
||||
}
|
||||
|
||||
/** C0·C1 제어 문자, DEL, 방향 제어 문자 */
|
||||
function isControlOrBidiChar(codePoint: number): boolean {
|
||||
return (
|
||||
|
||||
@@ -82,4 +82,21 @@ describe('여러 통 요청 본문 (PATCH /api/messages · POST /api/message-del
|
||||
await constraintsOf(MessageDeletionDto, { ids: ['m1'], isRead: true }),
|
||||
).toEqual(['whitelistValidation']);
|
||||
});
|
||||
|
||||
it('스팸 표시(isJunk)도 한 통 PATCH 와 같다 — true · false, null · 문자열은 형식 오류', async () => {
|
||||
for (const isJunk of [true, false]) {
|
||||
expect(
|
||||
await constraintsOf(UpdateMessagesDto, {
|
||||
ids: ['m1'],
|
||||
mailboxId: 'c',
|
||||
isJunk,
|
||||
}),
|
||||
).toEqual([]);
|
||||
}
|
||||
for (const isJunk of [null, 'true']) {
|
||||
expect(
|
||||
await constraintsOf(UpdateMessagesDto, { ids: ['m1'], isJunk }),
|
||||
).toEqual(['isBoolean']);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -42,7 +42,7 @@ function MessageIdList(description: string) {
|
||||
}
|
||||
|
||||
/**
|
||||
* PATCH /api/messages — 여러 통의 읽음 · 중요 표시 · 이동 (바꿀 것은 한 통 PATCH 와 같고, 하나 이상 — 없으면 서비스가 VAL_001)
|
||||
* PATCH /api/messages — 여러 통의 읽음 · 중요 표시 · 이동 · 스팸 표시 (바꿀 것은 한 통 PATCH 와 같고, 하나 이상 — 없으면 서비스가 VAL_001)
|
||||
*/
|
||||
export class UpdateMessagesDto extends UpdateMessageDto {
|
||||
@MessageIdList('바꿀 메일 id')
|
||||
|
||||
@@ -7,6 +7,8 @@ import type {
|
||||
MailAddress,
|
||||
MailAttachment,
|
||||
Mailbox,
|
||||
MailboxBulkResult,
|
||||
MailboxDeletion,
|
||||
MailboxRole,
|
||||
MailQuota,
|
||||
MailState,
|
||||
@@ -135,6 +137,49 @@ export class MailboxPageDto {
|
||||
pageInfo!: PageInfoDto;
|
||||
}
|
||||
|
||||
/** 계약 2절 "메일함 관리"의 MailboxDeletion — DELETE /api/mailboxes/{id} */
|
||||
export class MailboxDeletionDto implements MailboxDeletion {
|
||||
@ApiProperty({
|
||||
description:
|
||||
'true = 지웠다. false = 한 요청(25초)에 다 옮기지 못했다 — 같은 요청을 다시 보낸다',
|
||||
example: true,
|
||||
})
|
||||
deleted!: boolean;
|
||||
|
||||
@ApiProperty({
|
||||
description:
|
||||
'이번 요청에서 이 메일함에서 빠진 메일 수 — 휴지통으로 옮긴 것 + 다른 메일함에도 있어 이 메일함에서만 뺀 것. 진행 수 · 멈춤 판정에 쓴다',
|
||||
example: 40,
|
||||
})
|
||||
processed!: number;
|
||||
|
||||
@ApiProperty({
|
||||
description:
|
||||
'이번 요청에서 휴지통으로 옮긴 메일 수 (다른 메일함에도 있어 이 메일함에서만 뺀 메일은 세지 않는다)',
|
||||
example: 37,
|
||||
})
|
||||
movedToTrash!: number;
|
||||
|
||||
@ApiProperty({ description: '아직 이 메일함에 남은 메일 수', example: 0 })
|
||||
remaining!: number;
|
||||
}
|
||||
|
||||
/** 계약 2절 "메일함 관리"의 MailboxBulkResult — 전체 읽음 · 비우기 */
|
||||
export class MailboxBulkResultDto implements MailboxBulkResult {
|
||||
@ApiProperty({
|
||||
description:
|
||||
'이번 요청에서 읽음으로 바꾼 · 지운(다른 메일함에도 있어 그 메일함에서만 뺀 것 포함) 메일 수',
|
||||
example: 500,
|
||||
})
|
||||
processed!: number;
|
||||
|
||||
@ApiProperty({
|
||||
description: '남은 수 — 0 이 될 때까지 같은 요청을 다시 보낸다',
|
||||
example: 734,
|
||||
})
|
||||
remaining!: number;
|
||||
}
|
||||
|
||||
export class MailQuotaDto implements MailQuota {
|
||||
@ApiProperty({ description: '사용량 (바이트)', example: 966367641 })
|
||||
usedBytes!: number;
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
import { plainToInstance } from 'class-transformer';
|
||||
import { validate } from 'class-validator';
|
||||
|
||||
import {
|
||||
MAX_VACATION_SUBJECT_LENGTH,
|
||||
MAX_VACATION_TEXT_LENGTH,
|
||||
} from '@/modules/mail/mail.constants';
|
||||
import { MailVacationDto, isUtcDateTime } from './mail-vacation.dto';
|
||||
|
||||
/** 전역 ValidationPipe 와 같은 옵션으로 검증한다 (app.setup.ts) — 걸린 규칙 이름만 */
|
||||
async function constraintsOf(body: object): Promise<string[]> {
|
||||
const errors = await validate(plainToInstance(MailVacationDto, body), {
|
||||
whitelist: true,
|
||||
forbidNonWhitelisted: true,
|
||||
});
|
||||
return errors.flatMap((error) => Object.keys(error.constraints ?? {}));
|
||||
}
|
||||
|
||||
describe('부재중 자동 응답 본문 (PUT /api/mail-vacation)', () => {
|
||||
const enabled = {
|
||||
isEnabled: true,
|
||||
fromDate: '2026-11-02T15:00:00.000Z',
|
||||
toDate: '2026-11-07T15:00:00.000Z',
|
||||
subject: '부재중입니다',
|
||||
textBody: '11월 7일까지 휴가입니다.',
|
||||
};
|
||||
/** 한 번도 설정하지 않은 계정의 GET 응답 — 그대로 PUT 해도 통과해야 한다 (계약 2절) */
|
||||
const neverConfigured = {
|
||||
isEnabled: false,
|
||||
fromDate: null,
|
||||
toDate: null,
|
||||
subject: '',
|
||||
textBody: '',
|
||||
};
|
||||
|
||||
it('다섯 필드 — 날짜는 null 이어도 된다. 한 번도 설정하지 않은 값(빈 제목 · 내용)도 형식은 맞다', async () => {
|
||||
expect(await constraintsOf(enabled)).toEqual([]);
|
||||
expect(await constraintsOf(neverConfigured)).toEqual([]);
|
||||
expect(
|
||||
await constraintsOf({ ...enabled, fromDate: null, toDate: null }),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it('전체 교체라 날짜 키가 없으면 형식 오류 — null 로 보낸다', async () => {
|
||||
const withoutToDate: Partial<typeof enabled> = { ...enabled };
|
||||
delete withoutToDate.toDate;
|
||||
expect(await constraintsOf(withoutToDate)).toEqual(['isUtcDateTime']);
|
||||
expect(await constraintsOf({ ...enabled, isEnabled: undefined })).toEqual([
|
||||
'isBoolean',
|
||||
]);
|
||||
});
|
||||
|
||||
it('날짜는 ISO 8601 UTC 시각만 — 날짜만 · 시간대 오프셋 · 없는 날짜 · 소문자 z 는 형식 오류', async () => {
|
||||
for (const fromDate of [
|
||||
'2026-11-02',
|
||||
'2026-11-02T15:00:00+09:00',
|
||||
'2026-11-02T15:00:00.000z',
|
||||
'2026-02-30T00:00:00.000Z',
|
||||
'2026-11-02T24:00:00Z',
|
||||
'2026-11-02 15:00:00Z',
|
||||
'내일',
|
||||
1_793_000_000_000,
|
||||
]) {
|
||||
expect(await constraintsOf({ ...enabled, fromDate })).toEqual([
|
||||
'isUtcDateTime',
|
||||
]);
|
||||
}
|
||||
expect(
|
||||
await constraintsOf({ ...enabled, fromDate: '2026-11-02T15:00:00Z' }),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it(`제목 ${MAX_VACATION_SUBJECT_LENGTH}자 · 내용 ${MAX_VACATION_TEXT_LENGTH}자까지 (JS 문자열 길이) — 서식 본문은 받지 않는다`, async () => {
|
||||
expect(
|
||||
await constraintsOf({
|
||||
...enabled,
|
||||
subject: 'x'.repeat(MAX_VACATION_SUBJECT_LENGTH),
|
||||
textBody: 'x'.repeat(MAX_VACATION_TEXT_LENGTH),
|
||||
}),
|
||||
).toEqual([]);
|
||||
expect(
|
||||
await constraintsOf({
|
||||
...enabled,
|
||||
subject: 'x'.repeat(MAX_VACATION_SUBJECT_LENGTH + 1),
|
||||
}),
|
||||
).toEqual(['maxCodeUnits']);
|
||||
expect(
|
||||
await constraintsOf({
|
||||
...enabled,
|
||||
textBody: `${'🌴'.repeat(MAX_VACATION_TEXT_LENGTH / 2)}x`,
|
||||
}),
|
||||
).toEqual(['maxCodeUnits']);
|
||||
expect(await constraintsOf({ ...enabled, subject: null })).toEqual(
|
||||
expect.arrayContaining(['isString']),
|
||||
);
|
||||
expect(
|
||||
await constraintsOf({ ...enabled, htmlBody: '<p>휴가</p>' }),
|
||||
).toEqual(['whitelistValidation']);
|
||||
});
|
||||
|
||||
it('제목에는 제어 문자(C0 — 탭 · 줄바꿈 포함, DEL, C1)를 쓸 수 없다 — 범위 바로 바깥 문자는 받는다', async () => {
|
||||
for (const subject of [
|
||||
'부재\n중',
|
||||
'부재\r중',
|
||||
'부재\t중',
|
||||
'a\u0000',
|
||||
'a\u001f',
|
||||
'a\u007f',
|
||||
'a\u0080',
|
||||
'a\u0085',
|
||||
'a\u009f',
|
||||
]) {
|
||||
expect(await constraintsOf({ ...enabled, subject })).toEqual(['matches']);
|
||||
}
|
||||
for (const subject of [
|
||||
'a b',
|
||||
'a~b',
|
||||
'a\u00a0b',
|
||||
'부재중 🌴 (11/3~11/7) · 휴가',
|
||||
]) {
|
||||
expect(await constraintsOf({ ...enabled, subject })).toEqual([]);
|
||||
}
|
||||
});
|
||||
|
||||
it('내용은 제어 문자 가운데 탭 · 줄바꿈(U+0009 · U+000A · U+000D)만 받는다', async () => {
|
||||
expect(
|
||||
await constraintsOf({
|
||||
...enabled,
|
||||
textBody:
|
||||
'안녕하세요.\n\n11월 7일까지 휴가입니다.\r\n급한 일은\t이도윤에게.',
|
||||
}),
|
||||
).toEqual([]);
|
||||
for (const textBody of [
|
||||
'a\u0000',
|
||||
'a\u0008',
|
||||
'a\u000b',
|
||||
'a\u000c',
|
||||
'a\u000e',
|
||||
'a\u001b',
|
||||
'a\u001f',
|
||||
'a\u007f',
|
||||
'a\u0080',
|
||||
'a\u0085',
|
||||
'a\u009f',
|
||||
]) {
|
||||
expect(await constraintsOf({ ...enabled, textBody })).toEqual([
|
||||
'matches',
|
||||
]);
|
||||
}
|
||||
});
|
||||
|
||||
it('꺼 둔 채로도 형식 검사는 같다 — 공백뿐인 제목 · 내용은 형식으로는 맞다 (켤 때 비는지는 서비스가 본다)', async () => {
|
||||
expect(
|
||||
await constraintsOf({ ...neverConfigured, subject: 'a\nb' }),
|
||||
).toEqual(['matches']);
|
||||
expect(
|
||||
await constraintsOf({ ...enabled, subject: ' ', textBody: ' \n ' }),
|
||||
).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isUtcDateTime', () => {
|
||||
it('JS toISOString 모양(소수 초 3자리까지)과 소수 초 없는 모양을 받는다', () => {
|
||||
expect(isUtcDateTime('2026-11-02T15:00:00.000Z')).toBe(true);
|
||||
expect(isUtcDateTime('2026-11-02T15:00:00Z')).toBe(true);
|
||||
expect(isUtcDateTime('2026-11-02T15:00:00.5Z')).toBe(true);
|
||||
expect(isUtcDateTime('2028-02-29T00:00:00Z')).toBe(true);
|
||||
});
|
||||
|
||||
it('없는 날짜 · 시각, 너무 긴 소수 초, 문자열이 아닌 값은 받지 않는다', () => {
|
||||
expect(isUtcDateTime('2027-02-29T00:00:00Z')).toBe(false);
|
||||
expect(isUtcDateTime('2026-13-01T00:00:00Z')).toBe(false);
|
||||
expect(isUtcDateTime('2026-11-02T15:60:00Z')).toBe(false);
|
||||
expect(isUtcDateTime('2026-11-02T15:00:00.0000Z')).toBe(false);
|
||||
expect(isUtcDateTime(null)).toBe(false);
|
||||
expect(isUtcDateTime(undefined)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,106 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import {
|
||||
IsBoolean,
|
||||
IsString,
|
||||
Matches,
|
||||
ValidateBy,
|
||||
ValidateIf,
|
||||
type ValidationOptions,
|
||||
} from 'class-validator';
|
||||
|
||||
import { MaxCodeUnits } from '@/common/decorators/code-units.decorator';
|
||||
import {
|
||||
MAX_VACATION_SUBJECT_LENGTH,
|
||||
MAX_VACATION_TEXT_LENGTH,
|
||||
MULTI_LINE_TEXT_PATTERN,
|
||||
SINGLE_LINE_TEXT_PATTERN,
|
||||
} from '@/modules/mail/mail.constants';
|
||||
import type { MailVacation } from '@/modules/mail/mail.types';
|
||||
|
||||
/** ISO 8601 UTC 시각 — 'Z' 로 끝나고 소수 초는 3자리까지 (JS toISOString 의 모양, 계약 머리말 "날짜·시각") */
|
||||
const UTC_DATE_TIME = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,3})?Z$/;
|
||||
|
||||
/**
|
||||
* 실제로 있는 UTC 시각인가 — 모양과 함께, 읽은 값을 다시 적었을 때 같은지 본다
|
||||
* (2026-02-30 처럼 없는 날짜를 Date 가 다음 달로 넘겨 읽는 것을 막는다)
|
||||
*/
|
||||
export function isUtcDateTime(value: unknown): boolean {
|
||||
if (typeof value !== 'string' || !UTC_DATE_TIME.test(value)) return false;
|
||||
const time = Date.parse(value);
|
||||
return (
|
||||
!Number.isNaN(time) &&
|
||||
new Date(time).toISOString().slice(0, 19) === value.slice(0, 19)
|
||||
);
|
||||
}
|
||||
|
||||
function IsUtcDateTime(options: ValidationOptions) {
|
||||
return ValidateBy(
|
||||
{ name: 'isUtcDateTime', validator: { validate: isUtcDateTime } },
|
||||
options,
|
||||
);
|
||||
}
|
||||
|
||||
/** null 은 "날짜 없음"으로 받는다 — 키가 없으면(undefined) 형식 오류다 (전체 교체라 다섯 필드를 모두 보낸다) */
|
||||
const unlessNull = (_object: object, value: unknown) => value !== null;
|
||||
|
||||
/**
|
||||
* 부재중 자동 응답 — GET 응답과 PUT 본문(전체 교체)이 같은 모양이다 (계약 2절 "부재중 자동 응답").
|
||||
* 켤 때 제목 · 내용이 비었는지, 시작이 끝보다 앞인지는 서비스가 본다 (400 VAL_001). 서식 본문은 받지 않는다.
|
||||
*/
|
||||
export class MailVacationDto implements MailVacation {
|
||||
@ApiProperty({
|
||||
description: '켜짐 — 켜면 제목 · 내용이 1자 이상이어야 한다',
|
||||
example: true,
|
||||
})
|
||||
@IsBoolean({ message: '부재중 자동 응답을 켤지 다시 선택해 주세요.' })
|
||||
isEnabled!: boolean;
|
||||
|
||||
@ApiProperty({
|
||||
description:
|
||||
'시작 시각 (ISO 8601 UTC) — null 이면 켠 때부터. 끝 시각도 있으면 그보다 앞이어야 한다 (아니면 400 VAL_001)',
|
||||
nullable: true,
|
||||
type: String,
|
||||
example: '2026-11-02T15:00:00.000Z',
|
||||
})
|
||||
@ValidateIf(unlessNull)
|
||||
@IsUtcDateTime({ message: '시작 날짜 형식이 올바르지 않습니다.' })
|
||||
fromDate!: string | null;
|
||||
|
||||
@ApiProperty({
|
||||
description: '끝 시각 (ISO 8601 UTC) — null 이면 끌 때까지',
|
||||
nullable: true,
|
||||
type: String,
|
||||
example: '2026-11-07T15:00:00.000Z',
|
||||
})
|
||||
@ValidateIf(unlessNull)
|
||||
@IsUtcDateTime({ message: '끝 날짜 형식이 올바르지 않습니다.' })
|
||||
toDate!: string | null;
|
||||
|
||||
@ApiProperty({
|
||||
description: `자동 응답 제목 — ${MAX_VACATION_SUBJECT_LENGTH}자까지(JS 문자열 길이), 켤 때는 1자 이상 (끈 채로는 빈 문자열도 저장된다)`,
|
||||
maxLength: MAX_VACATION_SUBJECT_LENGTH,
|
||||
example: '부재중입니다',
|
||||
})
|
||||
@IsString({ message: '자동 응답 제목 형식이 올바르지 않습니다.' })
|
||||
@MaxCodeUnits(MAX_VACATION_SUBJECT_LENGTH, {
|
||||
message: `자동 응답 제목은 ${MAX_VACATION_SUBJECT_LENGTH}자 이하로 입력해 주세요.`,
|
||||
})
|
||||
@Matches(SINGLE_LINE_TEXT_PATTERN, {
|
||||
message: '자동 응답 제목에는 줄바꿈 같은 제어 문자를 쓸 수 없습니다.',
|
||||
})
|
||||
subject!: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: `자동 응답 내용 — 글자만(서식 없음), ${MAX_VACATION_TEXT_LENGTH.toLocaleString('ko-KR')}자까지(JS 문자열 길이), 켤 때는 1자 이상. 서명은 붙지 않는다`,
|
||||
maxLength: MAX_VACATION_TEXT_LENGTH,
|
||||
example: '11월 7일까지 휴가입니다. 급한 일은 이도윤 님께 연락해 주세요.',
|
||||
})
|
||||
@IsString({ message: '자동 응답 내용 형식이 올바르지 않습니다.' })
|
||||
@MaxCodeUnits(MAX_VACATION_TEXT_LENGTH, {
|
||||
message: `자동 응답 내용은 ${MAX_VACATION_TEXT_LENGTH.toLocaleString('ko-KR')}자 이하로 입력해 주세요.`,
|
||||
})
|
||||
@Matches(MULTI_LINE_TEXT_PATTERN, {
|
||||
message: '자동 응답 내용에는 탭 · 줄바꿈 밖의 제어 문자를 쓸 수 없습니다.',
|
||||
})
|
||||
textBody!: string;
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
import { plainToInstance, type ClassConstructor } from 'class-transformer';
|
||||
import { validate } from 'class-validator';
|
||||
|
||||
import { MAX_MAILBOX_NAME_LENGTH } from '@/modules/mail/mail.constants';
|
||||
import {
|
||||
CreateMailboxDto,
|
||||
MailboxMessagesPatchDto,
|
||||
RenameMailboxDto,
|
||||
} from './mailbox.dto';
|
||||
|
||||
/** 전역 ValidationPipe 와 같은 옵션으로 검증한다 (app.setup.ts) — 변환한 값과 걸린 규칙 이름 */
|
||||
async function check<T extends object>(
|
||||
type: ClassConstructor<T>,
|
||||
body: object,
|
||||
): Promise<{ dto: Record<string, unknown>; constraints: string[] }> {
|
||||
const dto = plainToInstance(type, body);
|
||||
const errors = await validate(dto, {
|
||||
whitelist: true,
|
||||
forbidNonWhitelisted: true,
|
||||
});
|
||||
return {
|
||||
dto: dto as Record<string, unknown>,
|
||||
constraints: errors.flatMap((error) =>
|
||||
Object.keys(error.constraints ?? {}),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
describe('메일함 요청 본문 (계약 2절 "메일함 관리")', () => {
|
||||
it.each([
|
||||
['만들기', CreateMailboxDto],
|
||||
['이름 바꾸기', RenameMailboxDto],
|
||||
] as const)(
|
||||
`%s — 이름은 앞뒤 공백을 뺀 1~${MAX_MAILBOX_NAME_LENGTH}자 (JS 문자열 길이)`,
|
||||
async (_name, type) => {
|
||||
const trimmed = await check(type, { name: ' 거래처 ' });
|
||||
expect(trimmed.constraints).toEqual([]);
|
||||
expect(trimmed.dto.name).toBe('거래처');
|
||||
|
||||
expect(
|
||||
(await check(type, { name: 'x'.repeat(MAX_MAILBOX_NAME_LENGTH) }))
|
||||
.constraints,
|
||||
).toEqual([]);
|
||||
expect(
|
||||
(
|
||||
await check(type, {
|
||||
name: ` ${'가'.repeat(MAX_MAILBOX_NAME_LENGTH)} `,
|
||||
})
|
||||
).constraints,
|
||||
).toEqual([]);
|
||||
|
||||
expect((await check(type, { name: ' ' })).constraints).toEqual([
|
||||
'minCodeUnits',
|
||||
]);
|
||||
expect(
|
||||
(await check(type, { name: 'x'.repeat(MAX_MAILBOX_NAME_LENGTH + 1) }))
|
||||
.constraints,
|
||||
).toEqual(['maxCodeUnits']);
|
||||
// 이모지 32개 + 1자 = 65 코드 단위
|
||||
expect(
|
||||
(await check(type, { name: `${'📁'.repeat(32)}x` })).constraints,
|
||||
).toEqual(['maxCodeUnits']);
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
['만들기', CreateMailboxDto],
|
||||
['이름 바꾸기', RenameMailboxDto],
|
||||
] as const)(
|
||||
'%s — "/" 와 제어 문자(줄바꿈 · 탭 등)는 쓸 수 없다, 문자열이 아니면 형식 오류',
|
||||
async (_name, type) => {
|
||||
for (const name of [
|
||||
'a/b',
|
||||
'거래\n처',
|
||||
'거래\t처',
|
||||
'a\u0000',
|
||||
'a\u001f',
|
||||
'a\u007f',
|
||||
'a\u0085b',
|
||||
'a\u009f',
|
||||
]) {
|
||||
expect((await check(type, { name })).constraints).toEqual(['matches']);
|
||||
}
|
||||
for (const name of ['2026-보고 (최종) · 1차', 'a~b', 'a\u00a0b']) {
|
||||
expect((await check(type, { name })).constraints).toEqual([]);
|
||||
}
|
||||
expect((await check(type, { name: 7 })).constraints).toEqual(
|
||||
expect.arrayContaining(['isString']),
|
||||
);
|
||||
expect((await check(type, {})).constraints).toEqual(
|
||||
expect.arrayContaining(['isString']),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
it('만들기의 상위는 없거나 null(맨 위) · 메일함 id 형식 — 이름 바꾸기에는 상위가 없다', async () => {
|
||||
expect(
|
||||
(await check(CreateMailboxDto, { name: '거래처' })).constraints,
|
||||
).toEqual([]);
|
||||
expect(
|
||||
(await check(CreateMailboxDto, { name: '거래처', parentId: null }))
|
||||
.constraints,
|
||||
).toEqual([]);
|
||||
expect(
|
||||
(await check(CreateMailboxDto, { name: '거래처', parentId: 'a' }))
|
||||
.constraints,
|
||||
).toEqual([]);
|
||||
expect(
|
||||
(await check(CreateMailboxDto, { name: '거래처', parentId: '../a' }))
|
||||
.constraints,
|
||||
).toEqual(['matches']);
|
||||
expect(
|
||||
(await check(RenameMailboxDto, { name: '거래처', parentId: 'a' }))
|
||||
.constraints,
|
||||
).toEqual(['whitelistValidation']);
|
||||
});
|
||||
|
||||
it('메일함 메일 바꾸기는 { isRead: true } 만 받는다', async () => {
|
||||
expect(
|
||||
(await check(MailboxMessagesPatchDto, { isRead: true })).constraints,
|
||||
).toEqual([]);
|
||||
for (const body of [{ isRead: false }, { isRead: 'true' }, {}]) {
|
||||
expect((await check(MailboxMessagesPatchDto, body)).constraints).toEqual([
|
||||
'equals',
|
||||
]);
|
||||
}
|
||||
expect(
|
||||
(await check(MailboxMessagesPatchDto, { isRead: true, isFlagged: true }))
|
||||
.constraints,
|
||||
).toEqual(['whitelistValidation']);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,83 @@
|
||||
import { applyDecorators } from '@nestjs/common';
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { Transform, type TransformFnParams } from 'class-transformer';
|
||||
import { Equals, IsOptional, IsString, Matches } from 'class-validator';
|
||||
|
||||
import {
|
||||
MaxCodeUnits,
|
||||
MinCodeUnits,
|
||||
} from '@/common/decorators/code-units.decorator';
|
||||
import {
|
||||
MAIL_ID_PATTERN,
|
||||
MAX_MAILBOX_NAME_LENGTH,
|
||||
SINGLE_LINE_TEXT_PATTERN,
|
||||
} from '@/modules/mail/mail.constants';
|
||||
|
||||
/** 메일함 이름에 쓸 수 없는 '/' — 경로 구분자로 읽는 메일 프로그램이 있다 (계약 2절 "메일함 관리") */
|
||||
const NO_SLASH_PATTERN = /^[^/]*$/;
|
||||
|
||||
/** 앞뒤 공백을 지운다 — 문자열이 아니면 그대로 두어 IsString 이 막는다 */
|
||||
function trimString({ value }: TransformFnParams): unknown {
|
||||
const raw: unknown = value;
|
||||
return typeof raw === 'string' ? raw.trim() : raw;
|
||||
}
|
||||
|
||||
/** 메일함 이름 — 앞뒤 공백을 뺀 1~MAX_MAILBOX_NAME_LENGTH 자(JS 문자열 길이), `/` · 제어 문자 없음. 만들기 · 이름 바꾸기가 같이 쓴다 */
|
||||
function MailboxName() {
|
||||
return applyDecorators(
|
||||
ApiProperty({
|
||||
description: `메일함 이름 — 앞뒤 공백을 뺀 1~${MAX_MAILBOX_NAME_LENGTH}자(JS 문자열 길이). "/" 와 제어 문자(줄바꿈 · 탭 등)는 쓸 수 없다. 같은 상위 안에 같은 이름이 있으면 409 MAIL_009`,
|
||||
example: '프로젝트',
|
||||
minLength: 1,
|
||||
maxLength: MAX_MAILBOX_NAME_LENGTH,
|
||||
}),
|
||||
Transform(trimString),
|
||||
IsString({ message: '메일함 이름 형식이 올바르지 않습니다.' }),
|
||||
MinCodeUnits(1, { message: '메일함 이름을 입력해 주세요.' }),
|
||||
MaxCodeUnits(MAX_MAILBOX_NAME_LENGTH, {
|
||||
message: `메일함 이름은 ${MAX_MAILBOX_NAME_LENGTH}자 이하로 입력해 주세요.`,
|
||||
}),
|
||||
Matches(NO_SLASH_PATTERN, {
|
||||
message: '메일함 이름에는 / 를 쓸 수 없습니다.',
|
||||
}),
|
||||
// 제어 문자(C0 · DEL · C1 — 줄바꿈 · 탭 포함) — IMAP 폴더 이름이 깨지고 로그 줄이 나뉜다
|
||||
Matches(SINGLE_LINE_TEXT_PATTERN, {
|
||||
message: '메일함 이름에는 줄바꿈 같은 제어 문자를 쓸 수 없습니다.',
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/** POST /api/mailboxes — 계약 2절 "메일함 관리" */
|
||||
export class CreateMailboxDto {
|
||||
@MailboxName()
|
||||
name!: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description:
|
||||
'상위 메일함 id — 없거나 null 이면 맨 위. 받은편지함 · 보관함 · 내 메일함(역할 없음) 아래에만 만들 수 있다 ' +
|
||||
'(보낸편지함 · 임시보관함 · 스팸메일함 · 휴지통 아래는 400 VAL_001, 없는 id 는 404 RES_001)',
|
||||
nullable: true,
|
||||
type: String,
|
||||
example: 'a',
|
||||
})
|
||||
@IsOptional()
|
||||
@Matches(MAIL_ID_PATTERN, { message: '상위 메일함을 다시 선택해 주세요.' })
|
||||
parentId?: string | null;
|
||||
}
|
||||
|
||||
/** PATCH /api/mailboxes/{id} — 이름만 바꾼다 (다른 상위로 옮기기는 이 단계에서 하지 않는다) */
|
||||
export class RenameMailboxDto {
|
||||
@MailboxName()
|
||||
name!: string;
|
||||
}
|
||||
|
||||
/** PATCH /api/mailboxes/{id}/messages — 그 메일함 전체를 읽음으로 (true 만 받는다) */
|
||||
export class MailboxMessagesPatchDto {
|
||||
@ApiProperty({
|
||||
description: '읽음으로 — true 만 받는다',
|
||||
enum: [true],
|
||||
example: true,
|
||||
})
|
||||
@Equals(true, { message: '읽음 표시는 true 만 보낼 수 있습니다.' })
|
||||
isRead!: true;
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
type ValidationOptions,
|
||||
} from 'class-validator';
|
||||
|
||||
import { MaxCodeUnits } from '@/common/decorators/code-units.decorator';
|
||||
import { CursorPaginationQueryDto } from '@/common/dto/pagination.dto';
|
||||
import {
|
||||
MAIL_ID_PATTERN,
|
||||
@@ -32,24 +33,6 @@ 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(
|
||||
|
||||
@@ -6,7 +6,7 @@ import { MAIL_ID_PATTERN } from '@/modules/mail/mail.constants';
|
||||
/** 값이 왔을 때만 검사한다 — null 은 "보내지 않음"이 아니라 형식 오류로 막는다 */
|
||||
const isPresent = (_object: object, value: unknown) => value !== undefined;
|
||||
|
||||
/** PATCH /api/messages/{id} — 하나 이상 지정한다 (빈 요청은 VAL_001) */
|
||||
/** PATCH /api/messages/{id} — 읽음 · 중요 표시 · 이동 · 스팸 표시 중 하나 이상 지정한다 (빈 요청은 VAL_001) */
|
||||
export class UpdateMessageDto {
|
||||
@ApiPropertyOptional({ description: '읽음 여부', example: true })
|
||||
@ValidateIf(isPresent)
|
||||
@@ -25,4 +25,14 @@ export class UpdateMessageDto {
|
||||
@ValidateIf(isPresent)
|
||||
@Matches(MAIL_ID_PATTERN, { message: '메일함을 다시 선택해 주세요.' })
|
||||
mailboxId?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description:
|
||||
'스팸 신고(true — 키워드 $junk 를 붙이고 $notjunk 를 뗀다) · 스팸 아님(false — 반대). 메일함 이동과는 따로다 — ' +
|
||||
'화면은 스팸 신고 { mailboxId: <스팸메일함>, isJunk: true } · 스팸 아님 { mailboxId: <받은편지함>, isJunk: false } 로 함께 보낸다',
|
||||
example: true,
|
||||
})
|
||||
@ValidateIf(isPresent)
|
||||
@IsBoolean({ message: '스팸 표시는 true 또는 false 여야 합니다.' })
|
||||
isJunk?: boolean;
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -8,6 +8,7 @@ import {
|
||||
JmapClient,
|
||||
basicAuthorization,
|
||||
classifyManagementProbe,
|
||||
classifyPasswordChange,
|
||||
expandTemplate,
|
||||
toRelativeReference,
|
||||
} from './jmap.client';
|
||||
@@ -170,6 +171,265 @@ describe('JmapClient', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('본인 비밀번호 바꾸기 (changePassword)', () => {
|
||||
const CURRENT = 'old:secret-값';
|
||||
const NEXT = 'new-secret-1234';
|
||||
/** 본인 자격 세션 — 계정 관리 기능(urn:stalwart:jmap)의 계정 id 는 메일 계정 id 와 다를 수 있다 */
|
||||
const ownSession = {
|
||||
...sessionBody,
|
||||
primaryAccounts: {
|
||||
'urn:ietf:params:jmap:mail': 'd',
|
||||
'urn:stalwart:jmap': 'x1',
|
||||
},
|
||||
};
|
||||
const passwordAnswer = (invocation: unknown[]) =>
|
||||
json({ methodResponses: [invocation], sessionState: 's' });
|
||||
const notUpdated = (setError: object) =>
|
||||
passwordAnswer([
|
||||
'x:AccountPassword/set',
|
||||
{ notUpdated: { singleton: setError } },
|
||||
'p',
|
||||
]);
|
||||
let warnLog: jest.SpyInstance;
|
||||
|
||||
beforeEach(() => {
|
||||
const logger = Reflect.get(client, 'logger') as {
|
||||
warn: (message: string) => void;
|
||||
};
|
||||
warnLog = jest.spyOn(logger, 'warn').mockImplementation(() => undefined);
|
||||
});
|
||||
|
||||
/** warn 로그 전부 — 비밀번호가 없는지 볼 때 */
|
||||
const warned = () =>
|
||||
(warnLog.mock.calls as unknown[][]).map((call) => String(call[0]));
|
||||
|
||||
it('본인 자격(직원 주소:지금 비밀번호)으로 세션을 열고, 같은 자격으로 x:AccountPassword/set 에 지금·새 비밀번호를 보낸다 — 게이트웨이를 쓰지 않는다', async () => {
|
||||
fetchMock
|
||||
.mockResolvedValueOnce(json(ownSession))
|
||||
.mockResolvedValueOnce(
|
||||
passwordAnswer([
|
||||
'x:AccountPassword/set',
|
||||
{ updated: { singleton: null } },
|
||||
'p',
|
||||
]),
|
||||
);
|
||||
|
||||
await expect(client.changePassword(ARA, CURRENT, NEXT)).resolves.toBe(
|
||||
'changed',
|
||||
);
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
const opened = requestAt(0);
|
||||
expect(opened.url).toBe('http://mail:8080/jmap/session');
|
||||
expect(decodeBasic(opened.authorization)).toBe(`${ARA}:${CURRENT}`);
|
||||
|
||||
const { url, init, authorization } = requestAt(1);
|
||||
expect(url).toBe('http://mail:8080/jmap/');
|
||||
expect(init.method).toBe('POST');
|
||||
expect(decodeBasic(authorization)).toBe(`${ARA}:${CURRENT}`);
|
||||
expect(JSON.parse(init.body as string)).toEqual({
|
||||
using: ['urn:ietf:params:jmap:core', 'urn:stalwart:jmap'],
|
||||
methodCalls: [
|
||||
[
|
||||
'x:AccountPassword/set',
|
||||
{
|
||||
accountId: 'x1',
|
||||
update: {
|
||||
singleton: { currentSecret: CURRENT, secret: NEXT },
|
||||
},
|
||||
},
|
||||
'p',
|
||||
],
|
||||
],
|
||||
});
|
||||
for (const index of [0, 1]) {
|
||||
expect(decodeBasic(requestAt(index).authorization)).not.toContain(
|
||||
'mail-gateway',
|
||||
);
|
||||
}
|
||||
// 대리 접근 세션 캐시를 건드리지 않는다
|
||||
expect(cache.set).not.toHaveBeenCalled();
|
||||
expect(warnLog).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('지금 비밀번호가 틀리면 세션 요청이 401 — 바꾸기를 보내지 않고 current-password-rejected (403 도 로그인 확인처럼 같다)', async () => {
|
||||
for (const status of [401, 403]) {
|
||||
fetchMock.mockReset();
|
||||
fetchMock.mockResolvedValueOnce(json({}, status));
|
||||
|
||||
await expect(client.changePassword(ARA, 'wrong', NEXT)).resolves.toBe(
|
||||
'current-password-rejected',
|
||||
);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
}
|
||||
});
|
||||
|
||||
it('들어간 뒤 엔진이 바꾸기를 거절하면(forbidden · 지금 비밀번호 속성 거절 · 그사이 401) current-password-refused — warn 로그에는 가린 주소와 종류만', async () => {
|
||||
const answers: [Response, string][] = [
|
||||
[
|
||||
notUpdated({
|
||||
type: 'forbidden',
|
||||
description: 'Current secret must be provided',
|
||||
}),
|
||||
'forbidden',
|
||||
],
|
||||
[
|
||||
notUpdated({
|
||||
type: 'invalidProperties',
|
||||
properties: ['currentSecret'],
|
||||
}),
|
||||
'invalidProperties',
|
||||
],
|
||||
[json({}, 401), 'http-401'],
|
||||
];
|
||||
|
||||
for (const [answer, jmapType] of answers) {
|
||||
warnLog.mockClear();
|
||||
fetchMock
|
||||
.mockResolvedValueOnce(json(ownSession))
|
||||
.mockResolvedValueOnce(answer);
|
||||
|
||||
await expect(client.changePassword(ARA, CURRENT, NEXT)).resolves.toBe(
|
||||
'current-password-refused',
|
||||
);
|
||||
expect(warnLog).toHaveBeenCalledTimes(1);
|
||||
const [line] = warned();
|
||||
expect(line).toMatch(/^\[AUTH_005\]/);
|
||||
expect(line).toContain('a***@arasystem.test');
|
||||
expect(line).toContain(`jmapType=${jmapType}`);
|
||||
expect(line).not.toContain(ARA);
|
||||
expect(line).not.toContain(CURRENT);
|
||||
expect(line).not.toContain(NEXT);
|
||||
expect(line).not.toContain('Current secret must be provided');
|
||||
}
|
||||
});
|
||||
|
||||
it('새 비밀번호가 엔진 규칙에 어긋나면(invalidProperties secret) new-password-rejected', async () => {
|
||||
fetchMock
|
||||
.mockResolvedValueOnce(json(ownSession))
|
||||
.mockResolvedValueOnce(
|
||||
notUpdated({ type: 'invalidProperties', properties: ['secret'] }),
|
||||
);
|
||||
|
||||
await expect(client.changePassword(ARA, CURRENT, NEXT)).resolves.toBe(
|
||||
'new-password-rejected',
|
||||
);
|
||||
expect(warnLog).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('바뀌지 않은 것이 확실한 실패는 던진다 — 요청을 받지 않음(403 · 400) · 메서드 오류 · 그 밖의 SetError 는 MAIL_001, 429 는 REQ_001 (context 에 비밀번호 없음)', async () => {
|
||||
const answers: [Response, string][] = [
|
||||
[json({ type: 'forbidden' }, 403), 'MAIL_001'],
|
||||
[
|
||||
json({ type: 'urn:ietf:params:jmap:error:unknownCapability' }, 400),
|
||||
'MAIL_001',
|
||||
],
|
||||
[passwordAnswer(['error', { type: 'unknownMethod' }, 'p']), 'MAIL_001'],
|
||||
[notUpdated({ type: 'serverFail' }), 'MAIL_001'],
|
||||
[json({}, 429), 'REQ_001'],
|
||||
];
|
||||
|
||||
for (const [answer, code] of answers) {
|
||||
fetchMock
|
||||
.mockResolvedValueOnce(json(ownSession))
|
||||
.mockResolvedValueOnce(answer);
|
||||
const failure = await client.changePassword(ARA, CURRENT, NEXT).then(
|
||||
() => undefined,
|
||||
(error: unknown) => error as BusinessException,
|
||||
);
|
||||
|
||||
expect(failure?.code).toBe(code);
|
||||
const logged = JSON.stringify(failure?.context);
|
||||
expect(logged).not.toContain(CURRENT);
|
||||
expect(logged).not.toContain(NEXT);
|
||||
}
|
||||
expect(warnLog).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('바꾸기 요청의 결과를 알 수 없으면(5xx · 연결 끊김 · 해석할 수 없는 응답 · 결과 없는 응답) 던지지 않고 outcome-unknown — warn 로그에 원인', async () => {
|
||||
const answers: [() => Promise<Response>, string][] = [
|
||||
[() => Promise.resolve(json({}, 502)), 'http-502'],
|
||||
[() => Promise.reject(new TypeError('fetch failed')), 'network'],
|
||||
[
|
||||
() =>
|
||||
Promise.resolve(
|
||||
new Response('not json', {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
}),
|
||||
),
|
||||
'malformed-response',
|
||||
],
|
||||
[
|
||||
() =>
|
||||
Promise.resolve(passwordAnswer(['x:AccountPassword/set', {}, 'p'])),
|
||||
'no-result',
|
||||
],
|
||||
[() => Promise.resolve(json({ methodResponses: [] })), 'undefined'],
|
||||
];
|
||||
|
||||
for (const [answer, cause] of answers) {
|
||||
warnLog.mockClear();
|
||||
fetchMock
|
||||
.mockResolvedValueOnce(json(ownSession))
|
||||
.mockImplementationOnce(answer);
|
||||
|
||||
await expect(client.changePassword(ARA, CURRENT, NEXT)).resolves.toBe(
|
||||
'outcome-unknown',
|
||||
);
|
||||
const [line] = warned();
|
||||
expect(line).toMatch(/^\[MAIL_001\]/);
|
||||
expect(line).toContain(`원인=${cause}`);
|
||||
expect(line).toContain('a***@arasystem.test');
|
||||
expect(line).not.toContain(CURRENT);
|
||||
expect(line).not.toContain(NEXT);
|
||||
}
|
||||
});
|
||||
|
||||
it('바꾸기 요청이 시간 안에 답하지 않으면 outcome-unknown (엔진이 바꿨을 수 있다)', async () => {
|
||||
jest.useFakeTimers();
|
||||
fetchMock.mockResolvedValueOnce(json(ownSession)).mockImplementationOnce(
|
||||
(_url: string, init: RequestInit) =>
|
||||
new Promise((_resolve, reject) => {
|
||||
init.signal?.addEventListener('abort', () =>
|
||||
reject(new DOMException('aborted', 'AbortError')),
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
const pending = client.changePassword(ARA, CURRENT, NEXT);
|
||||
await jest.advanceTimersByTimeAsync(10_000);
|
||||
|
||||
await expect(pending).resolves.toBe('outcome-unknown');
|
||||
expect(warned()[0]).toContain('원인=timeout');
|
||||
});
|
||||
|
||||
it('세션을 열지 못한 실패는 바뀌지 않은 것이 확실하다 — 5xx · 연결 끊김은 MAIL_001, 429 는 REQ_001 · 계정 관리 기능이 없으면 바꾸기를 보내지 않고 MAIL_001', async () => {
|
||||
fetchMock.mockResolvedValueOnce(json({}, 502));
|
||||
expect(await codeOf(client.changePassword(ARA, CURRENT, NEXT))).toBe(
|
||||
'MAIL_001',
|
||||
);
|
||||
|
||||
fetchMock.mockRejectedValueOnce(new TypeError('fetch failed'));
|
||||
expect(await codeOf(client.changePassword(ARA, CURRENT, NEXT))).toBe(
|
||||
'MAIL_001',
|
||||
);
|
||||
|
||||
fetchMock.mockResolvedValueOnce(json({}, 429));
|
||||
expect(await codeOf(client.changePassword(ARA, CURRENT, NEXT))).toBe(
|
||||
'REQ_001',
|
||||
);
|
||||
|
||||
fetchMock.mockReset();
|
||||
fetchMock.mockResolvedValueOnce(json(sessionBody));
|
||||
expect(await codeOf(client.changePassword(ARA, CURRENT, NEXT))).toBe(
|
||||
'MAIL_001',
|
||||
);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
expect(warnLog).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('대리 접근 세션 (getSession)', () => {
|
||||
it('<직원>%<게이트웨이>:<게이트웨이 비밀번호> 로 인증하고, 공개 주소 URL 을 경로로 바꿔 둔다', async () => {
|
||||
fetchMock.mockResolvedValueOnce(json(sessionBody));
|
||||
@@ -512,6 +772,68 @@ describe('JmapClient', () => {
|
||||
expect(error.context).toMatchObject({ reason: 'timeout' });
|
||||
});
|
||||
|
||||
it('제한 시간을 받으면 세션 조회(캐시에 없을 때)까지 그 안에서 한다 — 세션 조회에 쓴 시간만큼 호출이 짧아진다', async () => {
|
||||
jest.useFakeTimers();
|
||||
// 이 describe 의 beforeEach 가 넣어 둔 세션 응답을 지운다 — 세션 조회도 아래 구현을 따르게
|
||||
fetchMock.mockReset();
|
||||
fetchMock.mockImplementation(
|
||||
(_url: string, init: RequestInit) =>
|
||||
new Promise((resolve, reject) => {
|
||||
if (!init.body) {
|
||||
// 세션 조회는 3초 뒤에 답한다
|
||||
setTimeout(() => resolve(json(sessionBody)), 3_000);
|
||||
return;
|
||||
}
|
||||
init.signal?.addEventListener('abort', () =>
|
||||
reject(new DOMException('aborted', 'AbortError')),
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
let settled = false;
|
||||
const pending = client
|
||||
.call(ARA, () => [], { timeoutMs: 5_000 })
|
||||
.catch((error: unknown) => error)
|
||||
.finally(() => {
|
||||
settled = true;
|
||||
});
|
||||
|
||||
await jest.advanceTimersByTimeAsync(4_999);
|
||||
expect(settled).toBe(false);
|
||||
|
||||
await jest.advanceTimersByTimeAsync(1);
|
||||
const error = (await pending) as BusinessException;
|
||||
expect(error.code).toBe('MAIL_001');
|
||||
expect(error.context).toMatchObject({ reason: 'timeout', call: 'api' });
|
||||
});
|
||||
|
||||
it('세션 조회가 받은 제한 시간을 넘기면 거기서 MAIL_001 (timeout) — 호출은 보내지 않는다', async () => {
|
||||
jest.useFakeTimers();
|
||||
// 이 describe 의 beforeEach 가 넣어 둔 세션 응답을 지운다 — 세션 조회도 아래 구현을 따르게
|
||||
fetchMock.mockReset();
|
||||
fetchMock.mockImplementation(
|
||||
(_url: string, init: RequestInit) =>
|
||||
new Promise((_resolve, reject) => {
|
||||
init.signal?.addEventListener('abort', () =>
|
||||
reject(new DOMException('aborted', 'AbortError')),
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
const pending = client
|
||||
.call(ARA, () => [], { timeoutMs: 2_000 })
|
||||
.catch((error: unknown) => error);
|
||||
await jest.advanceTimersByTimeAsync(2_000);
|
||||
|
||||
const error = (await pending) as BusinessException;
|
||||
expect(error.code).toBe('MAIL_001');
|
||||
expect(error.context).toMatchObject({
|
||||
reason: 'timeout',
|
||||
call: 'session',
|
||||
});
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('요청 자체가 거부되면(notRequest) 지정한 코드, 지정이 없으면 MAIL_001', async () => {
|
||||
const notRequest = {
|
||||
type: 'urn:ietf:params:jmap:error:notRequest',
|
||||
@@ -698,6 +1020,69 @@ describe('JMAP URL 유틸', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('비밀번호 바꾸기 응답 해석 (classifyPasswordChange)', () => {
|
||||
const notUpdated = (error: object) =>
|
||||
classifyPasswordChange([
|
||||
'x:AccountPassword/set',
|
||||
{ notUpdated: { singleton: error } },
|
||||
'p',
|
||||
]);
|
||||
|
||||
it('바꿨다고 답하면 changed', () => {
|
||||
expect(
|
||||
classifyPasswordChange([
|
||||
'x:AccountPassword/set',
|
||||
{ updated: { singleton: null } },
|
||||
'p',
|
||||
]),
|
||||
).toEqual({ kind: 'changed' });
|
||||
});
|
||||
|
||||
it('invalidProperties 는 새 비밀번호 쪽(지금 비밀번호 속성이면 들어간 뒤의 거절), forbidden 은 들어간 뒤의 거절', () => {
|
||||
expect(
|
||||
notUpdated({ type: 'invalidProperties', properties: ['secret'] }),
|
||||
).toEqual({ kind: 'new-password-rejected' });
|
||||
expect(notUpdated({ type: 'invalidProperties' })).toEqual({
|
||||
kind: 'new-password-rejected',
|
||||
});
|
||||
expect(
|
||||
notUpdated({ type: 'invalidProperties', properties: ['currentSecret'] }),
|
||||
).toEqual({
|
||||
kind: 'current-password-refused',
|
||||
detail: 'invalidProperties',
|
||||
});
|
||||
expect(notUpdated({ type: 'forbidden' })).toEqual({
|
||||
kind: 'current-password-refused',
|
||||
detail: 'forbidden',
|
||||
});
|
||||
});
|
||||
|
||||
it('그 밖의 SetError · 메서드 오류는 바뀌지 않은 것이 확실한 실패(failed)', () => {
|
||||
expect(notUpdated({ type: 'serverFail' })).toEqual({
|
||||
kind: 'failed',
|
||||
detail: 'notUpdated/serverFail',
|
||||
});
|
||||
expect(
|
||||
classifyPasswordChange(['error', { type: 'unknownMethod' }, 'p']),
|
||||
).toEqual({ kind: 'failed', detail: 'error/unknownMethod' });
|
||||
});
|
||||
|
||||
it('응답이 바꾸기 결과를 말하지 않으면 알 수 없음(unknown) — 엔진이 바꿨을 수 있다', () => {
|
||||
expect(classifyPasswordChange(['x:AccountPassword/set', {}, 'p'])).toEqual({
|
||||
kind: 'unknown',
|
||||
detail: 'no-result',
|
||||
});
|
||||
expect(classifyPasswordChange(['Other/set', {}, 'p'])).toEqual({
|
||||
kind: 'unknown',
|
||||
detail: 'Other/set',
|
||||
});
|
||||
expect(classifyPasswordChange(undefined)).toEqual({
|
||||
kind: 'unknown',
|
||||
detail: 'undefined',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('관리 권한 확인 응답 해석 (classifyManagementProbe)', () => {
|
||||
it('성공은 관리 권한 있음, forbidden 은 없음', () => {
|
||||
expect(
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
type ConfiguredMailSettings,
|
||||
type MailSettings,
|
||||
} from '@/modules/mail/mail.settings';
|
||||
import type { PasswordChangeOutcome } from '@/modules/mail/mail.types';
|
||||
import { traceTag } from '@/shared/logger/request-context';
|
||||
import { maskEmail } from '@/shared/utils/mask';
|
||||
import type {
|
||||
@@ -19,6 +20,7 @@ import type {
|
||||
JmapProblem,
|
||||
JmapResponseBody,
|
||||
JmapSessionResource,
|
||||
JmapSetResult,
|
||||
JmapUploadResult,
|
||||
} from './jmap.types';
|
||||
|
||||
@@ -64,8 +66,76 @@ export function classifyManagementProbe(
|
||||
return { kind: 'unclear', detail: String(name) };
|
||||
}
|
||||
|
||||
/** 일반 API 호출 제한 시간 — 응답 본문을 다 읽을 때까지 */
|
||||
const REQUEST_TIMEOUT_MS = 10_000;
|
||||
/**
|
||||
* Stalwart 의 계정 관리 기능 — 본인 비밀번호 바꾸기(x:AccountPassword)가 여기 있다.
|
||||
* 이 기능의 계정 id 는 세션의 `primaryAccounts` 에서 이 이름으로 찾는다 (2026-10-28 개발 엔진(0.16.27)에서 확인)
|
||||
*/
|
||||
const ACCOUNT_MANAGEMENT_CAPABILITY = 'urn:stalwart:jmap';
|
||||
/** 본인 비밀번호 바꾸기 — 지금 비밀번호(currentSecret)가 함께 와야 바꾼다 (없으면 forbidden, 확인함) */
|
||||
const PASSWORD_SET_METHOD = 'x:AccountPassword/set';
|
||||
/** 계정마다 하나인 비밀번호 객체의 id */
|
||||
const PASSWORD_OBJECT_ID = 'singleton';
|
||||
|
||||
/**
|
||||
* 비밀번호 바꾸기 응답을 해석한 것 (detail 은 로그용 요약 — 엔진 오류 종류 · 원인. 엔진의 설명 문구 · 비밀번호는 넣지 않는다)
|
||||
* - changed · new-password-rejected: 엔진의 판단 그대로
|
||||
* - current-password-refused: 지금 비밀번호로 들어가기는 했는데 엔진이 바꾸기를 거절했다
|
||||
* - failed: 바뀌지 않은 것이 확실한 엔진 오류 (메서드 오류 · 그 밖의 SetError)
|
||||
* - unknown: 응답으로는 바뀌었는지 알 수 없다 — 엔진이 바꿨을 수 있다
|
||||
*/
|
||||
export type PasswordChangeReply =
|
||||
| { readonly kind: 'changed' | 'new-password-rejected' }
|
||||
| {
|
||||
readonly kind: 'current-password-refused' | 'failed' | 'unknown';
|
||||
readonly detail: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* 비밀번호 바꾸기 호출(x:AccountPassword/set)의 메서드 응답을 해석한다 (순수 함수).
|
||||
* - 바꿨다고 답하면(updated 에 singleton) changed
|
||||
* - invalidProperties — 새 비밀번호가 엔진 규칙에 어긋난다(8자 미만이면 properties ['secret'], 확인함) → new-password-rejected.
|
||||
* 지금 비밀번호 쪽(properties 에 currentSecret)이면 current-password-refused
|
||||
* - forbidden — 엔진이 바꾸기를 받지 않았다(지금 비밀번호가 빠지면 이 오류다, 확인함) → current-password-refused.
|
||||
* 세션은 그 비밀번호로 열렸으므로 맞혀 보기가 아니다 — 계정 비밀번호가 아닌 값(앱 비밀번호 등)으로 들어왔거나 변경 권한이 없다
|
||||
* - 그 밖의 SetError · 메서드 오류는 failed (바뀌지 않았다), 응답이 바꾸기 결과를 말하지 않으면 unknown
|
||||
*/
|
||||
export function classifyPasswordChange(
|
||||
invocation: JmapInvocation | undefined,
|
||||
): PasswordChangeReply {
|
||||
const [name, args] = invocation ?? [];
|
||||
if (name === PASSWORD_SET_METHOD) {
|
||||
const result = args as JmapSetResult;
|
||||
if (
|
||||
result.updated &&
|
||||
Object.prototype.hasOwnProperty.call(result.updated, PASSWORD_OBJECT_ID)
|
||||
) {
|
||||
return { kind: 'changed' };
|
||||
}
|
||||
const failure =
|
||||
result.notUpdated?.[PASSWORD_OBJECT_ID] ??
|
||||
Object.values(result.notUpdated ?? {})[0];
|
||||
if (!failure) return { kind: 'unknown', detail: 'no-result' };
|
||||
if (failure.type === 'invalidProperties') {
|
||||
return (failure.properties ?? []).includes('currentSecret')
|
||||
? { kind: 'current-password-refused', detail: 'invalidProperties' }
|
||||
: { kind: 'new-password-rejected' };
|
||||
}
|
||||
if (failure.type === 'forbidden') {
|
||||
return { kind: 'current-password-refused', detail: 'forbidden' };
|
||||
}
|
||||
return { kind: 'failed', detail: `notUpdated/${String(failure.type)}` };
|
||||
}
|
||||
|
||||
if (name === 'error') {
|
||||
const type = (args as JmapProblem | undefined)?.type;
|
||||
return { kind: 'failed', detail: `error/${String(type)}` };
|
||||
}
|
||||
return { kind: 'unknown', detail: String(name) };
|
||||
}
|
||||
|
||||
/** 일반 API 호출 제한 시간 — 응답 본문을 다 읽을 때까지. 오래 걸리는 처리는 남은 시간이 이보다 적으면 그만큼만 준다 (jmap-mail.provider.ts) */
|
||||
export const JMAP_REQUEST_TIMEOUT_MS = 10_000;
|
||||
const REQUEST_TIMEOUT_MS = JMAP_REQUEST_TIMEOUT_MS;
|
||||
/**
|
||||
* 발송(초안 생성 + 제출) 제한 시간. 엔진이 첨부(최대 합계 25MB)를 붙여 메시지를 만드는 시간을 넉넉히 주되,
|
||||
* 클라이언트의 발송 시간 한도(60초)보다 짧게 둔다 — 백엔드가 먼저 MAIL_001 로 답해야 사용자가 같은 키로 다시 보낼 수 있다.
|
||||
@@ -92,6 +162,7 @@ const HTTP_FORBIDDEN = 403;
|
||||
const HTTP_NOT_FOUND = 404;
|
||||
const HTTP_PAYLOAD_TOO_LARGE = 413;
|
||||
const HTTP_TOO_MANY_REQUESTS = 429;
|
||||
const HTTP_SERVER_ERROR = 500;
|
||||
|
||||
/** 직원 메일함에 접근하는 데 필요한 세션 정보 (캐시에 저장되므로 평범한 객체로 둔다) */
|
||||
export interface JmapSession {
|
||||
@@ -218,6 +289,7 @@ function parseContentLength(value: string | null): number | null {
|
||||
* Stalwart 내부 JMAP 에 붙는 얇은 클라이언트 (Node 내장 fetch).
|
||||
*
|
||||
* - 로그인 확인: `<직원 주소>:<비밀번호>` 로 세션 조회 (200 / 401)
|
||||
* - 본인 비밀번호 바꾸기: 같은 본인 자격으로 `x:AccountPassword/set` (게이트웨이 대리 접근으로는 바꾸지 않는다)
|
||||
* - 메일함 접근: 게이트웨이 대리 접근 `<직원 주소>%<게이트웨이 주소>` : `<게이트웨이 비밀번호>`
|
||||
* - 세션 응답의 URL 은 공개 주소 기준이므로 경로만 떼어 MAIL_JMAP_URL 에 붙인다 (mail/README.md)
|
||||
*
|
||||
@@ -263,10 +335,176 @@ export class JmapClient {
|
||||
);
|
||||
}
|
||||
|
||||
/** 대리 접근 세션 — 직원별로 잠깐 캐시한다. `fresh` 면 캐시를 건너뛰고 계정 상태를 다시 확인한다 */
|
||||
/**
|
||||
* 직원 **본인의 자격으로** 비밀번호를 바꾼다 (계약 1절 "PUT /api/auth/password").
|
||||
* 게이트웨이 대리 접근·관리자 권한으로는 바꾸지 않는다 — 엔진은 본인 자격의 요청만 받는다 (2026-10-28 개발 엔진에서 확인).
|
||||
*
|
||||
* 1) `<직원 주소>:<지금 비밀번호>` 로 세션을 연다 — 지금 비밀번호가 틀리면 401(403 도 로그인 확인처럼 틀린 것으로)
|
||||
* → current-password-rejected. 그 밖의 실패는 바뀌지 않은 것이 확실하다 (MAIL_001 · REQ_001)
|
||||
* 2) 같은 자격으로 `x:AccountPassword/set` 에 지금 비밀번호(currentSecret)와 새 비밀번호(secret)를 보낸다
|
||||
* (계정 id 는 세션의 `primaryAccounts['urn:stalwart:jmap']`, `using` 에 그 기능을 넣는다)
|
||||
* - 엔진이 바꾸기를 거절함(forbidden · 지금 비밀번호 속성 거절 · 그사이 401) → current-password-refused (warn 로그: 가린 주소 · 종류)
|
||||
* - 요청을 받지 않음(그 밖의 4xx · 메서드 오류 · 그 밖의 SetError) → MAIL_001, 엔진 한도(429) → REQ_001 — 바뀌지 않았다
|
||||
* - 응답을 받기 전에 끝남(시간 초과 · 연결 끊김) · 5xx · 해석할 수 없는 응답 → outcome-unknown (warn 로그) — 엔진이 바꿨을 수 있다
|
||||
* 비밀번호·인증 헤더·엔진의 설명 문구는 어떤 경우에도 로그·오류 context 에 남기지 않는다.
|
||||
*/
|
||||
async changePassword(
|
||||
email: string,
|
||||
currentPassword: string,
|
||||
newPassword: string,
|
||||
): Promise<PasswordChangeOutcome> {
|
||||
const authorization = basicAuthorization(email, currentPassword);
|
||||
|
||||
const session = await this.exchange(
|
||||
SESSION_PATH,
|
||||
{ headers: { authorization } },
|
||||
REQUEST_TIMEOUT_MS,
|
||||
'password-session',
|
||||
async (response) => {
|
||||
if (
|
||||
response.status === HTTP_UNAUTHORIZED ||
|
||||
response.status === HTTP_FORBIDDEN
|
||||
) {
|
||||
await discard(response);
|
||||
return null;
|
||||
}
|
||||
await this.assertOk(response, 'password-session');
|
||||
return readJson<JmapSessionResource>(response, 'password-session');
|
||||
},
|
||||
);
|
||||
if (session === null) return 'current-password-rejected';
|
||||
|
||||
const accountId = session.primaryAccounts?.[ACCOUNT_MANAGEMENT_CAPABILITY];
|
||||
if (!accountId || !session.apiUrl) {
|
||||
// 엔진이 그 기능을 알려 주지 않는다 — 엔진 판이 바뀌었거나 계정에 그 권한이 없다
|
||||
throw engineFailure({
|
||||
reason: 'password-capability-missing',
|
||||
call: 'password-session',
|
||||
});
|
||||
}
|
||||
|
||||
const reply = await this.sendPasswordChange(
|
||||
toRelativeReference(session.apiUrl),
|
||||
authorization,
|
||||
JSON.stringify({
|
||||
using: [CORE_CAPABILITY, ACCOUNT_MANAGEMENT_CAPABILITY],
|
||||
methodCalls: [
|
||||
[
|
||||
PASSWORD_SET_METHOD,
|
||||
{
|
||||
accountId,
|
||||
update: {
|
||||
[PASSWORD_OBJECT_ID]: {
|
||||
currentSecret: currentPassword,
|
||||
secret: newPassword,
|
||||
},
|
||||
},
|
||||
},
|
||||
'p',
|
||||
],
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
switch (reply.kind) {
|
||||
case 'changed':
|
||||
case 'new-password-rejected':
|
||||
return reply.kind;
|
||||
case 'current-password-refused':
|
||||
this.logger.warn(
|
||||
`[AUTH_005]${traceTag()} 지금 비밀번호로 들어갔는데 메일 엔진이 비밀번호 바꾸기를 거절했습니다 — 계정의 비밀번호 변경 권한을 확인하세요: user=${maskEmail(email)}, jmapType=${reply.detail}`,
|
||||
);
|
||||
return 'current-password-refused';
|
||||
case 'unknown':
|
||||
this.logger.warn(
|
||||
`[MAIL_001]${traceTag()} 비밀번호 바꾸기 요청의 결과를 알 수 없습니다 — 메일 엔진이 바꿨을 수 있습니다: user=${maskEmail(email)}, 원인=${reply.detail}`,
|
||||
);
|
||||
return 'outcome-unknown';
|
||||
case 'failed':
|
||||
throw engineFailure({
|
||||
reason: 'password-change-failed',
|
||||
call: 'password-change',
|
||||
detail: reply.detail,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 바꾸기 요청(2단계)을 보내고 응답을 해석한다 — 바뀌었는지 알 수 없게 끝난 것(응답 전에 끊김 · 5xx · 해석할 수 없는 본문)은
|
||||
* 던지지 않고 unknown 으로 돌려준다. 바뀌지 않은 것이 확실한 실패만 던진다 (4xx → MAIL_001, 429 → REQ_001)
|
||||
*/
|
||||
private async sendPasswordChange(
|
||||
path: string,
|
||||
authorization: string,
|
||||
body: string,
|
||||
): Promise<PasswordChangeReply> {
|
||||
// 응답 머리를 받았는가 — 받기 전에 끝난 실패(시간 초과 · 연결 끊김)는 엔진에 닿았을 수 있다
|
||||
const progress = { answered: false };
|
||||
try {
|
||||
return await this.exchange(
|
||||
path,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { authorization, 'content-type': 'application/json' },
|
||||
body,
|
||||
},
|
||||
REQUEST_TIMEOUT_MS,
|
||||
'password-change',
|
||||
async (response): Promise<PasswordChangeReply> => {
|
||||
progress.answered = true;
|
||||
// 세션을 연 뒤 지금 비밀번호가 다른 곳에서 바뀌었다 — 맞혀 보기가 아니다
|
||||
if (response.status === HTTP_UNAUTHORIZED) {
|
||||
await discard(response);
|
||||
return { kind: 'current-password-refused', detail: 'http-401' };
|
||||
}
|
||||
if (response.status >= HTTP_SERVER_ERROR) {
|
||||
await discard(response);
|
||||
return { kind: 'unknown', detail: `http-${response.status}` };
|
||||
}
|
||||
// 그 밖의 4xx 는 요청을 받지 않았다 — 403 은 방금 그 자격으로 세션이 열렸으므로 비밀번호가 아니라 권한·설정 문제다
|
||||
if (!response.ok && response.status !== HTTP_TOO_MANY_REQUESTS) {
|
||||
const problem = await readProblem(response);
|
||||
throw engineFailure({
|
||||
reason: 'http-error',
|
||||
call: 'password-change',
|
||||
status: response.status,
|
||||
jmapType: problem?.type ?? 'unknown',
|
||||
});
|
||||
}
|
||||
await this.assertOk(response, 'password-change');
|
||||
let parsed: JmapResponseBody;
|
||||
try {
|
||||
parsed = (await response.json()) as JmapResponseBody;
|
||||
} catch {
|
||||
return { kind: 'unknown', detail: 'malformed-response' };
|
||||
}
|
||||
return classifyPasswordChange(parsed.methodResponses?.[0]);
|
||||
},
|
||||
);
|
||||
} catch (error) {
|
||||
if (
|
||||
!progress.answered &&
|
||||
error instanceof BusinessException &&
|
||||
error.code === 'MAIL_001'
|
||||
) {
|
||||
// exchange 가 붙인 사유 — timeout · network
|
||||
const reason: unknown = error.context?.reason;
|
||||
return {
|
||||
kind: 'unknown',
|
||||
detail: typeof reason === 'string' ? reason : 'no-response',
|
||||
};
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 대리 접근 세션 — 직원별로 잠깐 캐시한다. `fresh` 면 캐시를 건너뛰고 계정 상태를 다시 확인한다.
|
||||
* `timeoutMs` 는 캐시에 없어 엔진에 물을 때의 제한 시간 (기본 10초)
|
||||
*/
|
||||
async getSession(
|
||||
user: string,
|
||||
options: { fresh?: boolean } = {},
|
||||
options: { fresh?: boolean; timeoutMs?: number } = {},
|
||||
): Promise<JmapSession> {
|
||||
// 막는 계정(관리자 등)은 엔진에 대리 접근을 요청하지도 않는다
|
||||
this.assertNotBlocked(user);
|
||||
@@ -279,7 +517,7 @@ export class JmapClient {
|
||||
const session = await this.exchange(
|
||||
SESSION_PATH,
|
||||
{ headers: { authorization: this.impersonation(user) } },
|
||||
REQUEST_TIMEOUT_MS,
|
||||
options.timeoutMs ?? REQUEST_TIMEOUT_MS,
|
||||
'session',
|
||||
async (response) => {
|
||||
await this.assertImpersonationAccepted(response, user);
|
||||
@@ -372,13 +610,23 @@ export class JmapClient {
|
||||
/**
|
||||
* JMAP 메서드를 호출한다. accountId 는 세션에서 정해지므로 호출 목록을 함수로 받는다.
|
||||
* 메서드 단위 오류(`["error", …]`)는 그대로 돌려주고, 해석은 호출한 쪽이 한다.
|
||||
* 제한 시간(`timeoutMs`)을 받으면 세션 조회(캐시에 없을 때)까지 그 시간 안에서 한다 — 한 요청의 시간 상한
|
||||
* (발송 · 메일함의 오래 걸리는 처리)을 세션 조회가 넘기지 않게. 받지 않으면 세션 조회 · 호출이 각각 기본 10초다
|
||||
*/
|
||||
async call(
|
||||
user: string,
|
||||
build: (accountId: string) => JmapInvocation[],
|
||||
options: JmapCallOptions = {},
|
||||
): Promise<JmapInvocation[]> {
|
||||
const session = await this.getSession(user);
|
||||
const deadline =
|
||||
options.timeoutMs === undefined ? null : Date.now() + options.timeoutMs;
|
||||
const session = await this.getSession(user, {
|
||||
timeoutMs: options.timeoutMs,
|
||||
});
|
||||
const timeoutMs =
|
||||
deadline === null
|
||||
? REQUEST_TIMEOUT_MS
|
||||
: Math.max(1, deadline - Date.now());
|
||||
const body = JSON.stringify({
|
||||
using: [...JMAP_USING, ...(options.capabilities ?? [])],
|
||||
methodCalls: build(session.accountId),
|
||||
@@ -394,7 +642,7 @@ export class JmapClient {
|
||||
},
|
||||
body,
|
||||
},
|
||||
options.timeoutMs ?? REQUEST_TIMEOUT_MS,
|
||||
timeoutMs,
|
||||
'api',
|
||||
async (response) => {
|
||||
await this.assertImpersonationAccepted(response, user);
|
||||
|
||||
@@ -7,6 +7,7 @@ import type {
|
||||
Mailbox,
|
||||
MailboxRole,
|
||||
MailUsage,
|
||||
MailVacation,
|
||||
MessageDetailSource,
|
||||
MessageSummary,
|
||||
} from '@/modules/mail/mail.types';
|
||||
@@ -17,6 +18,7 @@ import type {
|
||||
JmapIdentity,
|
||||
JmapMailbox,
|
||||
JmapQuota,
|
||||
JmapVacationResponse,
|
||||
} from './jmap.types';
|
||||
|
||||
/**
|
||||
@@ -331,6 +333,49 @@ export function toUsage(quota: JmapQuota): MailUsage {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 부재중 자동 응답 → 우리 모양 (계약 2절 "부재중 자동 응답").
|
||||
* 엔진에 없는 값(null)의 제목·내용은 빈 문자열, 날짜는 ISO 8601 UTC 로 — 한 번도 설정하지 않은 계정은
|
||||
* `{ isEnabled: false, fromDate: null, toDate: null, subject: '', textBody: '' }` 가 된다. 서식 본문(htmlBody)은 쓰지 않는다.
|
||||
*/
|
||||
export function toVacation(
|
||||
response: JmapVacationResponse | undefined,
|
||||
): MailVacation {
|
||||
return {
|
||||
isEnabled: response?.isEnabled === true,
|
||||
fromDate: toUtcIso(response?.fromDate),
|
||||
toDate: toUtcIso(response?.toDate),
|
||||
subject: response?.subject ?? '',
|
||||
textBody: response?.textBody ?? '',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 날짜를 JMAP UTCDate 로 — 초 단위까지, 소수 초 없이 'Z' 로 끝낸다
|
||||
* (RFC 8620 §1.4: 소수 초가 0 이면 쓰지 않는다. 화면이 보내는 값은 0시라 잃는 것이 없다)
|
||||
*/
|
||||
export function toJmapUtcDate(iso: string): string {
|
||||
return new Date(Date.parse(iso)).toISOString().replace(/\.\d+Z$/, 'Z');
|
||||
}
|
||||
|
||||
/**
|
||||
* 우리 모양 → VacationResponse 전체 교체 값. 빈 제목·내용은 null(엔진의 "정하지 않음" — 끈 채로 저장할 때만 비어 있다).
|
||||
* 서식 본문은 늘 비운다 — 다른 메일 프로그램이 넣어 둔 것도 지운다 (글자만 보낸다, 계약 2절)
|
||||
*/
|
||||
export function toJmapVacation(
|
||||
vacation: MailVacation,
|
||||
): Record<string, unknown> {
|
||||
return {
|
||||
isEnabled: vacation.isEnabled,
|
||||
fromDate:
|
||||
vacation.fromDate === null ? null : toJmapUtcDate(vacation.fromDate),
|
||||
toDate: vacation.toDate === null ? null : toJmapUtcDate(vacation.toDate),
|
||||
subject: vacation.subject === '' ? null : vacation.subject,
|
||||
textBody: vacation.textBody === '' ? null : vacation.textBody,
|
||||
htmlBody: null,
|
||||
};
|
||||
}
|
||||
|
||||
/** 발신 ID — 직원 주소와 같은 것을 고르고, 없으면 첫 번째 */
|
||||
export function pickIdentity(
|
||||
identities: JmapIdentity[],
|
||||
|
||||
@@ -63,6 +63,8 @@ export interface JmapBodyValue {
|
||||
|
||||
export interface JmapEmail {
|
||||
id: string;
|
||||
/** 받은 그대로의 원문(message/rfc822) blob — 원문 내려받기에 쓴다 */
|
||||
blobId?: string;
|
||||
threadId?: string;
|
||||
mailboxIds?: Record<string, boolean>;
|
||||
keywords?: Record<string, boolean>;
|
||||
@@ -95,6 +97,18 @@ export interface JmapMailbox {
|
||||
unreadEmails?: number;
|
||||
}
|
||||
|
||||
/** RFC 8621 §8 VacationResponse — 계정마다 하나(id `singleton`) */
|
||||
export interface JmapVacationResponse {
|
||||
id?: string;
|
||||
isEnabled?: boolean;
|
||||
/** UTCDate 또는 null */
|
||||
fromDate?: string | null;
|
||||
toDate?: string | null;
|
||||
subject?: string | null;
|
||||
textBody?: string | null;
|
||||
htmlBody?: string | null;
|
||||
}
|
||||
|
||||
export interface JmapIdentity {
|
||||
id: string;
|
||||
name?: string;
|
||||
|
||||
@@ -59,6 +59,28 @@ export const SEARCH_WORD_SEPARATOR = /\s+/u;
|
||||
*/
|
||||
export const MAX_BULK_MESSAGE_IDS = 100;
|
||||
|
||||
/**
|
||||
* 메일함 이름 최대 길이 — 앞뒤 공백을 뺀 JS 문자열 길이 (계약 2절 "메일함 관리"). 넘으면 400 VAL_001 (dto/mailbox.dto.ts)
|
||||
*/
|
||||
export const MAX_MAILBOX_NAME_LENGTH = 64;
|
||||
|
||||
/** 부재중 자동 응답의 제목 · 내용 최대 길이 — JS 문자열 길이 (계약 2절 "부재중 자동 응답"). 켤 때는 둘 다 1자 이상 */
|
||||
export const MAX_VACATION_SUBJECT_LENGTH = 200;
|
||||
export const MAX_VACATION_TEXT_LENGTH = 4_000;
|
||||
|
||||
/**
|
||||
* 한 줄 글자 — 제어 문자(C0 · DEL · C1 — 줄바꿈 · 탭 포함)가 없다. 메일함 이름 · 부재중 자동 응답 제목이 쓴다 (계약 2절)
|
||||
*/
|
||||
// eslint-disable-next-line no-control-regex
|
||||
export const SINGLE_LINE_TEXT_PATTERN = /^[^\u0000-\u001f\u007f-\u009f]*$/;
|
||||
|
||||
/**
|
||||
* 여러 줄 글자 — 제어 문자 가운데 탭 · 줄바꿈(\t \n \r)만 있어도 된다 (C0 의 나머지 · DEL · C1 은 없다). 부재중 자동 응답 내용이 쓴다
|
||||
*/
|
||||
export const MULTI_LINE_TEXT_PATTERN =
|
||||
// eslint-disable-next-line no-control-regex
|
||||
/^[^\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f-\u009f]*$/;
|
||||
|
||||
/** 받는 사람 추천의 거르는 글자(q) 최대 길이 — 계약 2절 "받는 사람 추천" */
|
||||
export const MAX_SUGGESTION_QUERY_LENGTH = 100;
|
||||
|
||||
|
||||
@@ -29,6 +29,7 @@ import {
|
||||
MailboxController,
|
||||
MailQuotaController,
|
||||
MailStateController,
|
||||
MailVacationController,
|
||||
} from './mailbox.controller';
|
||||
import { MessageDeletionController } from './message-deletion.controller';
|
||||
import { MessageController } from './message.controller';
|
||||
@@ -103,6 +104,7 @@ function attachmentUploadOptions(config: ConfigService): MulterModuleOptions {
|
||||
MailboxController,
|
||||
MailQuotaController,
|
||||
MailStateController,
|
||||
MailVacationController,
|
||||
MessageController,
|
||||
MessageDeletionController,
|
||||
MailAttachmentController,
|
||||
|
||||
@@ -6,16 +6,22 @@ import type {
|
||||
MailAccountProfile,
|
||||
MailAttachment,
|
||||
Mailbox,
|
||||
MailboxBulkResult,
|
||||
MailboxCreate,
|
||||
MailboxDeletion,
|
||||
MailState,
|
||||
MailUsage,
|
||||
MailVacation,
|
||||
MessageDeletionResult,
|
||||
MessageDetailSource,
|
||||
MessageListQuery,
|
||||
MessagePage,
|
||||
MessageSource,
|
||||
MessagesPatchResult,
|
||||
MessageSummary,
|
||||
MessageUpdate,
|
||||
OutgoingMessage,
|
||||
PasswordChangeOutcome,
|
||||
SavedDraft,
|
||||
SentMessageRecord,
|
||||
UploadedFile,
|
||||
@@ -41,6 +47,18 @@ export interface MailProvider {
|
||||
*/
|
||||
verifyCredentials(email: string, password: string): Promise<string | null>;
|
||||
|
||||
/**
|
||||
* 직원 **본인의 자격으로**(지금 비밀번호) 비밀번호를 바꾼다 — 게이트웨이 대리 접근·관리자 권한을 쓰지 않는다 (계약 1절).
|
||||
* 엔진의 판단(틀림 · 거절 · 규칙 위반)과 결과를 알 수 없음(바꿨을 수 있다)은 결과로 돌려주고,
|
||||
* 결과가 확실한 엔진 장애(바뀌지 않았다)는 MAIL_001(한도는 REQ_001)로 던진다 (PasswordChangeOutcome).
|
||||
* 비밀번호는 저장하지 않고 어떤 로그에도 남기지 않는다.
|
||||
*/
|
||||
changePassword(
|
||||
email: string,
|
||||
currentPassword: string,
|
||||
newPassword: string,
|
||||
): Promise<PasswordChangeOutcome>;
|
||||
|
||||
/**
|
||||
* 직원 메일함 프로필(표시 이름). `fresh` 면 캐시를 건너뛰고 계정이 살아 있는지 다시 확인한다.
|
||||
* 계정이 사라졌거나, 로그인을 막는 계정(관리자 등)이거나, 메일 엔진의 관리 권한이 있는 계정이면 AUTH_001.
|
||||
@@ -53,6 +71,38 @@ export interface MailProvider {
|
||||
|
||||
listMailboxes(user: string): Promise<Mailbox[]>;
|
||||
|
||||
/**
|
||||
* 메일함을 만든다 (계약 2절 "메일함 관리") — 이름 형식은 요청 검증(dto/mailbox.dto.ts)이 본다.
|
||||
* 상위가 없으면 RES_001, 만들 수 없는 자리(보낸편지함 · 임시보관함 · 스팸메일함 · 휴지통 · 그 밖의 역할 메일함 아래)면 VAL_001,
|
||||
* 같은 상위 안에 같은 이름이 있으면 MAIL_009.
|
||||
*/
|
||||
createMailbox(user: string, input: MailboxCreate): Promise<Mailbox>;
|
||||
|
||||
/** 메일함 이름을 바꾼다 — 없으면 RES_001, 역할이 있는 메일함(기본 메일함)이면 VAL_001, 같은 이름이 있으면 MAIL_009 */
|
||||
renameMailbox(user: string, id: string, name: string): Promise<Mailbox>;
|
||||
|
||||
/**
|
||||
* 메일함을 지운다 — 안의 메일을 휴지통으로 옮긴 뒤(다른 메일함에도 있는 메일은 이 메일함에서만 뺀다) 빈 메일함을 지운다.
|
||||
* 메일을 영구 삭제하지 않는다. 한 요청에 다 옮기지 못하면 deleted: false (클라이언트가 다시 보낸다).
|
||||
* 없으면 RES_001, 역할이 있는 메일함 · 하위 메일함이 있으면 VAL_001.
|
||||
*/
|
||||
deleteMailbox(user: string, id: string): Promise<MailboxDeletion>;
|
||||
|
||||
/** 메일함의 안 읽은 메일을 읽음으로 바꾼다 (한 요청의 시간 안에서 차례로) — 없으면 RES_001 */
|
||||
markMailboxRead(user: string, id: string): Promise<MailboxBulkResult>;
|
||||
|
||||
/**
|
||||
* 휴지통 · 스팸메일함을 비운다 (영구 삭제) — 그 메일함에만 있는 메일은 지우고, 다른 메일함에도 있는 메일은 그 메일함에서만 뺀다.
|
||||
* 확인한 상태 그대로일 때만 지운다(deleteFromTrash 와 같은 규칙). 없으면 RES_001, 휴지통 · 스팸메일함이 아니면 VAL_001.
|
||||
*/
|
||||
emptyMailbox(user: string, id: string): Promise<MailboxBulkResult>;
|
||||
|
||||
/** 부재중 자동 응답 — 엔진 값이 없으면(null) 제목 · 내용은 빈 문자열 */
|
||||
getVacation(user: string): Promise<MailVacation>;
|
||||
|
||||
/** 부재중 자동 응답을 통째로 바꾸고(서식 본문은 비운다) 다시 읽은 값을 돌려준다 — 형식은 요청 검증이, 켤 때의 빈 값 · 날짜 순서는 서비스가 본다 */
|
||||
setVacation(user: string, vacation: MailVacation): Promise<MailVacation>;
|
||||
|
||||
/** 메일함 사용량과 한도 (한도가 없으면 limitBytes = null) */
|
||||
getUsage(user: string): Promise<MailUsage>;
|
||||
|
||||
@@ -126,6 +176,9 @@ export interface MailProvider {
|
||||
|
||||
uploadAttachment(user: string, file: UploadedFile): Promise<MailAttachment>;
|
||||
|
||||
/** 받은 그대로의 메일 원문(엔진의 원본)을 흘려보낸다 — 없는 메일은 RES_001 */
|
||||
openMessageSource(user: string, id: string): Promise<MessageSource>;
|
||||
|
||||
/** 그 메일에 실제로 있는 첨부만 내려준다 (스트리밍) */
|
||||
openAttachment(
|
||||
user: string,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Logger } from '@nestjs/common';
|
||||
import { Readable } from 'stream';
|
||||
|
||||
import { startCpuTimer } from '@/../test/cpu-time';
|
||||
import { BusinessException } from '@/common/errors/business.exception';
|
||||
@@ -51,8 +52,17 @@ function fakeProvider(): ProviderMocks {
|
||||
|
||||
return {
|
||||
verifyCredentials: mockOf<'verifyCredentials'>(),
|
||||
changePassword: mockOf<'changePassword'>(),
|
||||
getProfile: mockOf<'getProfile'>(),
|
||||
listMailboxes: mockOf<'listMailboxes'>(),
|
||||
createMailbox: mockOf<'createMailbox'>(),
|
||||
renameMailbox: mockOf<'renameMailbox'>(),
|
||||
deleteMailbox: mockOf<'deleteMailbox'>(),
|
||||
markMailboxRead: mockOf<'markMailboxRead'>(),
|
||||
emptyMailbox: mockOf<'emptyMailbox'>(),
|
||||
getVacation: mockOf<'getVacation'>(),
|
||||
setVacation: mockOf<'setVacation'>(),
|
||||
openMessageSource: mockOf<'openMessageSource'>(),
|
||||
getUsage: mockOf<'getUsage'>(),
|
||||
getAttachmentsTotalLimit:
|
||||
mockOf<'getAttachmentsTotalLimit'>().mockResolvedValue(25 * 1024 * 1024),
|
||||
@@ -426,6 +436,386 @@ describe('MailService', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('메일함 관리 · 부재중 자동 응답 · 스팸 · 원문 (4단계)', () => {
|
||||
const mailbox = {
|
||||
id: 'n1',
|
||||
name: '거래처',
|
||||
role: null,
|
||||
parentId: null,
|
||||
sortOrder: 0,
|
||||
totalCount: 0,
|
||||
unreadCount: 0,
|
||||
};
|
||||
|
||||
it('메일함 만들기 — 상위가 없으면 맨 위(null)로 넘기고, 이름 바꾸기와 함께 표시 규칙을 거친 메일함을 돌려준다', async () => {
|
||||
provider.createMailbox.mockResolvedValue(mailbox);
|
||||
provider.renameMailbox.mockResolvedValue({
|
||||
...mailbox,
|
||||
id: 'a',
|
||||
name: 'Inbox',
|
||||
role: 'inbox',
|
||||
});
|
||||
|
||||
await expect(
|
||||
service.createMailbox(user, { name: '거래처' }),
|
||||
).resolves.toEqual(mailbox);
|
||||
await service.createMailbox(user, { name: '거래처', parentId: 'a' });
|
||||
expect(provider.createMailbox.mock.calls).toEqual([
|
||||
[user.email, { name: '거래처', parentId: null }],
|
||||
[user.email, { name: '거래처', parentId: 'a' }],
|
||||
]);
|
||||
|
||||
// (어댑터가 막지만) 역할이 있는 메일함은 한국어 표준 이름으로 보인다
|
||||
await expect(
|
||||
service.renameMailbox(user, 'a', { name: '새 이름' }),
|
||||
).resolves.toMatchObject({ name: '받은편지함' });
|
||||
expect(provider.renameMailbox).toHaveBeenCalledWith(
|
||||
user.email,
|
||||
'a',
|
||||
'새 이름',
|
||||
);
|
||||
});
|
||||
|
||||
it('메일함 지우기 — 지웠을 때만 개수를 남긴다 (메일함 이름 · 주소 없이)', async () => {
|
||||
provider.deleteMailbox.mockResolvedValueOnce({
|
||||
deleted: false,
|
||||
processed: 500,
|
||||
movedToTrash: 500,
|
||||
remaining: 700,
|
||||
});
|
||||
await service.deleteMailbox(user, 'u4');
|
||||
expect(logSpy).not.toHaveBeenCalled();
|
||||
|
||||
provider.deleteMailbox.mockResolvedValueOnce({
|
||||
deleted: true,
|
||||
processed: 9,
|
||||
movedToTrash: 7,
|
||||
remaining: 0,
|
||||
});
|
||||
await expect(service.deleteMailbox(user, 'u4')).resolves.toEqual({
|
||||
deleted: true,
|
||||
processed: 9,
|
||||
movedToTrash: 7,
|
||||
remaining: 0,
|
||||
});
|
||||
expect(logSpy).toHaveBeenCalledTimes(1);
|
||||
const [line] = logSpy.mock.calls[0] as [string];
|
||||
expect(line).toContain('휴지통으로 옮김=7통');
|
||||
expect(line).toContain('이 메일함에서만 뺌=2통');
|
||||
expect(line).not.toContain(user.email);
|
||||
});
|
||||
|
||||
it('전체 읽음은 { isRead: true } 를 어댑터의 전체 읽음으로 — 로그를 남기지 않는다', async () => {
|
||||
provider.markMailboxRead.mockResolvedValue({
|
||||
processed: 3,
|
||||
remaining: 0,
|
||||
});
|
||||
|
||||
await expect(
|
||||
service.updateMailboxMessages(user, 'a', { isRead: true }),
|
||||
).resolves.toEqual({ processed: 3, remaining: 0 });
|
||||
expect(provider.markMailboxRead).toHaveBeenCalledWith(user.email, 'a');
|
||||
expect(logSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('비우기 — 지운 것이 있으면 개수만 남긴다 (되돌릴 수 없는 일)', async () => {
|
||||
provider.emptyMailbox.mockResolvedValueOnce({
|
||||
processed: 0,
|
||||
remaining: 0,
|
||||
});
|
||||
await service.emptyMailbox(user, 'b');
|
||||
expect(logSpy).not.toHaveBeenCalled();
|
||||
|
||||
provider.emptyMailbox.mockResolvedValueOnce({
|
||||
processed: 500,
|
||||
remaining: 12,
|
||||
});
|
||||
await service.emptyMailbox(user, 'b');
|
||||
const [line] = logSpy.mock.calls[0] as [string];
|
||||
expect(line).toContain('지움=500통');
|
||||
expect(line).toContain('남음=12통');
|
||||
expect(line).not.toContain(user.email);
|
||||
});
|
||||
|
||||
describe('같은 메일함의 오래 걸리는 처리는 하나씩만', () => {
|
||||
/** 손으로 끝내는 처리 — 끝낼 때까지 진행 중이다 */
|
||||
function pending<T>(): {
|
||||
promise: Promise<T>;
|
||||
finish: (value: T) => void;
|
||||
fail: (error: Error) => void;
|
||||
} {
|
||||
let finish!: (value: T) => void;
|
||||
let fail!: (error: Error) => void;
|
||||
const promise = new Promise<T>((resolve, reject) => {
|
||||
finish = resolve;
|
||||
fail = reject;
|
||||
});
|
||||
return { promise, finish, fail };
|
||||
}
|
||||
const emptied = { processed: 2, remaining: 0 };
|
||||
|
||||
it('진행 중이면 종류가 달라도(비우기 · 전체 읽음 · 지우기) 엔진에 묻지 않고 바로 REQ_001 — 끝나면 다시 받는다', async () => {
|
||||
const first = pending<{ processed: number; remaining: number }>();
|
||||
provider.emptyMailbox.mockReturnValueOnce(first.promise);
|
||||
|
||||
const running = service.emptyMailbox(user, 'b');
|
||||
for (const attempt of [
|
||||
() => service.emptyMailbox(user, 'b'),
|
||||
() => service.updateMailboxMessages(user, 'b', { isRead: true }),
|
||||
() => service.deleteMailbox(user, 'b'),
|
||||
]) {
|
||||
expect(await failureOf(attempt)).toEqual({
|
||||
code: 'REQ_001',
|
||||
reason: 'mailbox-operation-in-progress',
|
||||
});
|
||||
}
|
||||
expect(provider.emptyMailbox).toHaveBeenCalledTimes(1);
|
||||
expect(provider.markMailboxRead).not.toHaveBeenCalled();
|
||||
expect(provider.deleteMailbox).not.toHaveBeenCalled();
|
||||
|
||||
first.finish(emptied);
|
||||
await expect(running).resolves.toEqual(emptied);
|
||||
|
||||
provider.emptyMailbox.mockResolvedValueOnce(emptied);
|
||||
await expect(service.emptyMailbox(user, 'b')).resolves.toEqual(emptied);
|
||||
});
|
||||
|
||||
it('다른 메일함 · 다른 직원은 동시에 된다', async () => {
|
||||
const first = pending<{ processed: number; remaining: number }>();
|
||||
provider.emptyMailbox.mockReturnValueOnce(first.promise);
|
||||
provider.markMailboxRead.mockResolvedValue({
|
||||
processed: 1,
|
||||
remaining: 0,
|
||||
});
|
||||
|
||||
const running = service.emptyMailbox(user, 'b');
|
||||
await expect(
|
||||
service.updateMailboxMessages(user, 'a', { isRead: true }),
|
||||
).resolves.toEqual({ processed: 1, remaining: 0 });
|
||||
await expect(
|
||||
service.updateMailboxMessages(
|
||||
{ email: 'doyun.lee@arasystem.test', name: '이도윤' },
|
||||
'b',
|
||||
{ isRead: true },
|
||||
),
|
||||
).resolves.toEqual({ processed: 1, remaining: 0 });
|
||||
|
||||
first.finish(emptied);
|
||||
await running;
|
||||
});
|
||||
|
||||
it('실패로 끝나도 푼다', async () => {
|
||||
const first = pending<{ processed: number; remaining: number }>();
|
||||
provider.emptyMailbox.mockReturnValueOnce(first.promise);
|
||||
|
||||
const running = service.emptyMailbox(user, 'b');
|
||||
first.fail(new BusinessException('MAIL_001'));
|
||||
expect(await failureOf(() => running)).toMatchObject({
|
||||
code: 'MAIL_001',
|
||||
});
|
||||
|
||||
provider.emptyMailbox.mockResolvedValueOnce(emptied);
|
||||
await expect(service.emptyMailbox(user, 'b')).resolves.toEqual(emptied);
|
||||
});
|
||||
});
|
||||
|
||||
describe('부재중 자동 응답 저장', () => {
|
||||
const vacation = {
|
||||
isEnabled: true,
|
||||
fromDate: '2026-11-02T15:00:00.000Z',
|
||||
toDate: '2026-11-07T15:00:00.000Z',
|
||||
subject: '부재중입니다',
|
||||
textBody: '11월 7일까지 휴가입니다.',
|
||||
};
|
||||
|
||||
it('형식 규칙을 지나면 그대로 넘기고 엔진이 저장한 값을 돌려준다 — 로그에는 켬 · 끔만', async () => {
|
||||
provider.setVacation.mockImplementation((_user, value) =>
|
||||
Promise.resolve(value),
|
||||
);
|
||||
|
||||
await expect(service.updateVacation(user, vacation)).resolves.toEqual(
|
||||
vacation,
|
||||
);
|
||||
expect(provider.setVacation).toHaveBeenCalledWith(user.email, vacation);
|
||||
const [line] = logSpy.mock.calls[0] as [string];
|
||||
expect(line).toContain('켬');
|
||||
expect(line).not.toContain(vacation.subject);
|
||||
expect(line).not.toContain(vacation.textBody);
|
||||
expect(line).not.toContain(user.email);
|
||||
});
|
||||
|
||||
it('켤 때 제목 · 내용이 비면 VAL_001 — 끈 채로는 빈 값도 저장한다 (한 번도 설정하지 않은 값을 그대로 되돌려 놓을 수 있게)', async () => {
|
||||
for (const blank of [{ subject: '' }, { textBody: '' }]) {
|
||||
expect(
|
||||
await failureOf(() =>
|
||||
service.updateVacation(user, { ...vacation, ...blank }),
|
||||
),
|
||||
).toEqual({
|
||||
code: 'VAL_001',
|
||||
reason: 'vacation-enabled-without-text',
|
||||
});
|
||||
}
|
||||
expect(provider.setVacation).not.toHaveBeenCalled();
|
||||
|
||||
const neverConfigured = {
|
||||
isEnabled: false,
|
||||
fromDate: null,
|
||||
toDate: null,
|
||||
subject: '',
|
||||
textBody: '',
|
||||
};
|
||||
provider.setVacation.mockResolvedValue(neverConfigured);
|
||||
await expect(
|
||||
service.updateVacation(user, neverConfigured),
|
||||
).resolves.toEqual(neverConfigured);
|
||||
});
|
||||
|
||||
it('켤 때 앞뒤 공백을 빼고 빈 제목 · 내용도 VAL_001 — 끈 채로는 받고, 저장은 쓴 그대로(공백을 지우지 않는다)', async () => {
|
||||
for (const blank of [{ subject: ' ' }, { textBody: ' \n\t ' }]) {
|
||||
expect(
|
||||
await failureOf(() =>
|
||||
service.updateVacation(user, { ...vacation, ...blank }),
|
||||
),
|
||||
).toEqual({
|
||||
code: 'VAL_001',
|
||||
reason: 'vacation-enabled-without-text',
|
||||
});
|
||||
}
|
||||
expect(provider.setVacation).not.toHaveBeenCalled();
|
||||
|
||||
provider.setVacation.mockImplementation((_user, value) =>
|
||||
Promise.resolve(value),
|
||||
);
|
||||
await service.updateVacation(user, {
|
||||
...vacation,
|
||||
isEnabled: false,
|
||||
subject: ' ',
|
||||
});
|
||||
await service.updateVacation(user, {
|
||||
...vacation,
|
||||
subject: ' 부재중입니다 ',
|
||||
textBody: '\n휴가입니다.\n',
|
||||
});
|
||||
expect(
|
||||
provider.setVacation.mock.calls.map(([, value]) => value),
|
||||
).toEqual([
|
||||
{ ...vacation, isEnabled: false, subject: ' ' },
|
||||
{
|
||||
...vacation,
|
||||
subject: ' 부재중입니다 ',
|
||||
textBody: '\n휴가입니다.\n',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('날짜는 초 단위로 맞춰 넘기고, 순서도 맞춘 값으로 본다 — 같은 초 안의 두 시각은 VAL_001', async () => {
|
||||
expect(
|
||||
await failureOf(() =>
|
||||
service.updateVacation(user, {
|
||||
...vacation,
|
||||
fromDate: '2026-11-02T15:00:00.100Z',
|
||||
toDate: '2026-11-02T15:00:00.900Z',
|
||||
}),
|
||||
),
|
||||
).toEqual({ code: 'VAL_001', reason: 'vacation-dates-reversed' });
|
||||
expect(provider.setVacation).not.toHaveBeenCalled();
|
||||
|
||||
provider.setVacation.mockImplementation((_user, value) =>
|
||||
Promise.resolve(value),
|
||||
);
|
||||
await service.updateVacation(user, {
|
||||
...vacation,
|
||||
fromDate: '2026-11-02T15:00:00.999Z',
|
||||
toDate: '2026-11-02T15:00:01.000Z',
|
||||
});
|
||||
expect(provider.setVacation).toHaveBeenCalledWith(user.email, {
|
||||
...vacation,
|
||||
fromDate: '2026-11-02T15:00:00.000Z',
|
||||
toDate: '2026-11-02T15:00:01.000Z',
|
||||
});
|
||||
});
|
||||
|
||||
it('시작 · 끝이 둘 다 있으면 시작이 끝보다 앞이어야 한다 (같아도 VAL_001) — 한쪽만 있으면 따지지 않는다', async () => {
|
||||
for (const toDate of [vacation.fromDate, '2026-11-01T15:00:00.000Z']) {
|
||||
expect(
|
||||
await failureOf(() =>
|
||||
service.updateVacation(user, { ...vacation, toDate }),
|
||||
),
|
||||
).toEqual({ code: 'VAL_001', reason: 'vacation-dates-reversed' });
|
||||
}
|
||||
expect(provider.setVacation).not.toHaveBeenCalled();
|
||||
|
||||
provider.setVacation.mockImplementation((_user, value) =>
|
||||
Promise.resolve(value),
|
||||
);
|
||||
await service.updateVacation(user, { ...vacation, toDate: null });
|
||||
await service.updateVacation(user, { ...vacation, fromDate: null });
|
||||
expect(provider.setVacation).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
it('스팸 표시만 바꾸는 PATCH 도 받는다 — 한 통 · 여러 통 모두 isJunk 를 그대로 넘긴다', async () => {
|
||||
provider.updateMessage.mockResolvedValue({
|
||||
id: 'm1',
|
||||
threadId: 't1',
|
||||
mailboxIds: ['c'],
|
||||
from: null,
|
||||
to: [],
|
||||
subject: '',
|
||||
preview: '',
|
||||
receivedAt: '2026-10-28T01:00:00.000Z',
|
||||
isRead: true,
|
||||
isFlagged: false,
|
||||
hasAttachment: false,
|
||||
size: 1,
|
||||
});
|
||||
provider.updateMessages.mockResolvedValue({
|
||||
updatedIds: ['m1'],
|
||||
failedIds: [],
|
||||
});
|
||||
|
||||
await service.updateMessage(user, 'm1', { isJunk: true });
|
||||
await service.updateMessages(user, {
|
||||
ids: ['m1'],
|
||||
mailboxId: 'a',
|
||||
isJunk: false,
|
||||
});
|
||||
|
||||
expect(provider.updateMessage).toHaveBeenCalledWith(user.email, 'm1', {
|
||||
isJunk: true,
|
||||
});
|
||||
expect(provider.updateMessages).toHaveBeenCalledWith(user.email, ['m1'], {
|
||||
mailboxId: 'a',
|
||||
isJunk: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('원문 내려받기 — 파일 이름은 제목(없으면 "메일") + .eml, 흐름과 크기는 그대로', async () => {
|
||||
const stream = Readable.from([Buffer.from('From: a\r\n\r\nbody')]);
|
||||
provider.openMessageSource.mockResolvedValueOnce({
|
||||
stream,
|
||||
subject: 'Re: 견적서/수정본',
|
||||
size: 15,
|
||||
});
|
||||
|
||||
await expect(service.openMessageSource(user, 'm1')).resolves.toEqual({
|
||||
stream,
|
||||
name: 'Re_ 견적서_수정본.eml',
|
||||
size: 15,
|
||||
});
|
||||
expect(provider.openMessageSource).toHaveBeenCalledWith(user.email, 'm1');
|
||||
|
||||
provider.openMessageSource.mockResolvedValueOnce({
|
||||
stream,
|
||||
subject: '',
|
||||
size: null,
|
||||
});
|
||||
await expect(
|
||||
service.openMessageSource(user, 'm2'),
|
||||
).resolves.toMatchObject({ name: '메일.eml', size: null });
|
||||
});
|
||||
});
|
||||
|
||||
describe('메일 상세', () => {
|
||||
it('HTML 이 없으면 정화 없이 텍스트만 내려준다', async () => {
|
||||
provider.getMessage.mockResolvedValue(source());
|
||||
|
||||
@@ -10,10 +10,12 @@ import { SIGNATURE_IMAGES_PER_SIGNATURE } from '@/modules/mail-signature/mail-si
|
||||
import { MailSignatureService } from '@/modules/mail-signature/mail-signature.service';
|
||||
import { SIGNATURE_IMAGE_EXTENSIONS } from '@/modules/mail-signature/signature-image-format';
|
||||
import { traceTag } from '@/shared/logger/request-context';
|
||||
import { toWholeSecondIso } from '@/shared/utils/format';
|
||||
import { maskEmail } from '@/shared/utils/mask';
|
||||
import { settledWithin } from '@/shared/utils/settle';
|
||||
import {
|
||||
MAX_ATTACHMENTS_PER_MESSAGE,
|
||||
messageSourceFileName,
|
||||
resolveAttachmentType,
|
||||
sanitizeFileName,
|
||||
} from './attachment.policy';
|
||||
@@ -21,6 +23,12 @@ import type {
|
||||
MessageDeletionDto,
|
||||
UpdateMessagesDto,
|
||||
} from './dto/bulk-messages.dto';
|
||||
import type { MailVacationDto } from './dto/mail-vacation.dto';
|
||||
import type {
|
||||
CreateMailboxDto,
|
||||
MailboxMessagesPatchDto,
|
||||
RenameMailboxDto,
|
||||
} from './dto/mailbox.dto';
|
||||
import type { MessageListQueryDto } from './dto/message-list-query.dto';
|
||||
import type {
|
||||
ComposeContentDto,
|
||||
@@ -62,8 +70,11 @@ import type {
|
||||
MailAddress,
|
||||
MailAttachment,
|
||||
Mailbox,
|
||||
MailboxBulkResult,
|
||||
MailboxDeletion,
|
||||
MailQuota,
|
||||
MailState,
|
||||
MailVacation,
|
||||
MessageAttachment,
|
||||
MessageDeletionResult,
|
||||
MessageDetail,
|
||||
@@ -75,7 +86,7 @@ import type {
|
||||
OutgoingMessage,
|
||||
SavedDraft,
|
||||
} from './mail.types';
|
||||
import { presentMailboxes } from './mailbox.presenter';
|
||||
import { presentMailbox, presentMailboxes } from './mailbox.presenter';
|
||||
import { RecipientSuggestionService } from './recipient-suggestions.service';
|
||||
import {
|
||||
idempotentMessageId,
|
||||
@@ -170,19 +181,20 @@ function assertHtmlBytes(html: string): void {
|
||||
}
|
||||
}
|
||||
|
||||
/** 읽음 · 중요 표시 · 이동 — 하나 이상이어야 한다 (빈 요청은 VAL_001). 한 통·여러 통 PATCH 가 같이 쓴다 */
|
||||
/** 읽음 · 중요 표시 · 이동 · 스팸 표시 — 하나 이상이어야 한다 (빈 요청은 VAL_001). 한 통·여러 통 PATCH 가 같이 쓴다 */
|
||||
function toMessageUpdate(dto: UpdateMessageDto): MessageUpdate {
|
||||
const { isRead, isFlagged, mailboxId } = dto;
|
||||
const { isRead, isFlagged, mailboxId, isJunk } = dto;
|
||||
if (
|
||||
isRead === undefined &&
|
||||
isFlagged === undefined &&
|
||||
mailboxId === undefined
|
||||
mailboxId === undefined &&
|
||||
isJunk === undefined
|
||||
) {
|
||||
throw new BusinessException('VAL_001', {
|
||||
context: { reason: 'empty-update' },
|
||||
});
|
||||
}
|
||||
return { isRead, isFlagged, mailboxId };
|
||||
return { isRead, isFlagged, mailboxId, isJunk };
|
||||
}
|
||||
|
||||
/** 서명 그림의 Content-ID — 같은 그림이면 늘 같은 값이다 (발송 지문이 cid 가 든 서식 본문을 본다) */
|
||||
@@ -206,6 +218,13 @@ export class MailService {
|
||||
*/
|
||||
private readonly sendsInProgress = new Set<string>();
|
||||
|
||||
/**
|
||||
* 진행 중인 오래 걸리는 처리(전체 읽음 · 비우기 · 지우기)의 (사용자, 메일함) — 같은 메일함에는 하나씩만, 종류가 달라도 막는다.
|
||||
* 두 탭이 같은 휴지통을 비우면 서로 상태가 어긋나 둘 다 멈추고, 동시 요청이 많으면 엔진 호출이 그 수만큼 곱절로 는다.
|
||||
* ⚠️ 프로세스 메모리다. 인스턴스를 늘리면 Redis 잠금(SET NX PX)으로 옮겨야 인스턴스 사이에서도 하나씩이 된다.
|
||||
*/
|
||||
private readonly mailboxOperations = new Set<string>();
|
||||
|
||||
constructor(
|
||||
@Inject(MAIL_PROVIDER) private readonly provider: MailProvider,
|
||||
@Inject(MAIL_SETTINGS) private readonly settings: MailSettings,
|
||||
@@ -249,6 +268,153 @@ export class MailService {
|
||||
return this.provider.getMailState(user.email);
|
||||
}
|
||||
|
||||
/**
|
||||
* 메일함 만들기 (계약 2절 "메일함 관리") — 이름은 요청 검증이 앞뒤 공백을 빼고 형식을 본 값이다.
|
||||
* 상위가 없으면 RES_001, 만들 수 없는 자리면 VAL_001, 같은 이름이면 MAIL_009 (어댑터)
|
||||
*/
|
||||
async createMailbox(
|
||||
user: AuthenticatedUser,
|
||||
dto: CreateMailboxDto,
|
||||
): Promise<Mailbox> {
|
||||
return presentMailbox(
|
||||
await this.provider.createMailbox(user.email, {
|
||||
name: dto.name,
|
||||
parentId: dto.parentId ?? null,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/** 메일함 이름 바꾸기 — 기본 메일함(역할 있음)은 VAL_001 */
|
||||
async renameMailbox(
|
||||
user: AuthenticatedUser,
|
||||
id: string,
|
||||
dto: RenameMailboxDto,
|
||||
): Promise<Mailbox> {
|
||||
return presentMailbox(
|
||||
await this.provider.renameMailbox(user.email, id, dto.name),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 메일함 지우기 — 안의 메일을 휴지통으로 옮긴 뒤 지운다(영구 삭제하지 않는다). 한 요청에 끝나지 않으면 deleted: false.
|
||||
* 같은 메일함의 오래 걸리는 처리는 하나씩만 (진행 중이면 REQ_001 — runExclusive).
|
||||
* 지웠으면 info 로그에 개수만 남긴다 (메일함 이름 없이 — 사용자가 정한 글이다)
|
||||
*/
|
||||
async deleteMailbox(
|
||||
user: AuthenticatedUser,
|
||||
id: string,
|
||||
): Promise<MailboxDeletion> {
|
||||
const result = await this.runExclusive(user, id, () =>
|
||||
this.provider.deleteMailbox(user.email, id),
|
||||
);
|
||||
if (result.deleted) {
|
||||
this.logger.log(
|
||||
`${traceTag()} 메일함을 지웠습니다: user=${maskEmail(user.email)}, 이번 요청에서 휴지통으로 옮김=${result.movedToTrash}통 · 이 메일함에서만 뺌=${result.processed - result.movedToTrash}통`,
|
||||
);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 메일함의 메일을 한꺼번에 바꾼다 — 지금은 전체 읽음({ isRead: true })뿐이다 (요청 검증이 그 밖을 막는다).
|
||||
* 안 읽은 메일을 차례로 읽음으로 바꾸고 남은 수를 돌려준다 (요청마다 로그를 남기지 않는다 — 되돌릴 수 있는 표시다).
|
||||
* 같은 메일함의 오래 걸리는 처리는 하나씩만 (runExclusive)
|
||||
*/
|
||||
async updateMailboxMessages(
|
||||
user: AuthenticatedUser,
|
||||
id: string,
|
||||
dto: MailboxMessagesPatchDto,
|
||||
): Promise<MailboxBulkResult> {
|
||||
if (dto.isRead !== true) {
|
||||
throw new BusinessException('VAL_001', {
|
||||
context: { reason: 'unsupported-mailbox-messages-change' },
|
||||
});
|
||||
}
|
||||
return this.runExclusive(user, id, () =>
|
||||
this.provider.markMailboxRead(user.email, id),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 휴지통 · 스팸메일함 비우기(영구 삭제) — 되돌릴 수 없는 일이라 지운 것이 있으면 개수만 남긴다.
|
||||
* 같은 메일함의 오래 걸리는 처리는 하나씩만 (runExclusive)
|
||||
*/
|
||||
async emptyMailbox(
|
||||
user: AuthenticatedUser,
|
||||
id: string,
|
||||
): Promise<MailboxBulkResult> {
|
||||
const result = await this.runExclusive(user, id, () =>
|
||||
this.provider.emptyMailbox(user.email, id),
|
||||
);
|
||||
if (result.processed > 0) {
|
||||
this.logger.log(
|
||||
`${traceTag()} 메일함을 비웠습니다(영구 삭제): user=${maskEmail(user.email)}, 지움=${result.processed}통, 남음=${result.remaining}통`,
|
||||
);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/** 부재중 자동 응답 (계약 2절 "부재중 자동 응답") */
|
||||
getVacation(user: AuthenticatedUser): Promise<MailVacation> {
|
||||
return this.provider.getVacation(user.email);
|
||||
}
|
||||
|
||||
/**
|
||||
* 부재중 자동 응답 저장(전체 교체) — 엔진이 저장한 값을 다시 읽어 돌려준다.
|
||||
* - 켤 때는 제목 · 내용이 앞뒤 공백을 빼고 1자 이상 (저장은 쓴 그대로 — 지우지 않는다).
|
||||
* 끈 채로는 빈 값도 저장한다 — 한 번도 설정하지 않은 값을 그대로 되돌려 놓을 수 있게
|
||||
* - 날짜는 초 단위로 맞춰 엔진에 보낸다(소수 초를 버린다 — 엔진의 UTCDate). 시작 · 끝이 둘 다 있으면 **맞춘 값으로** 시작 < 끝
|
||||
* 어긋나면 엔진에 묻지 않고 VAL_001. 형식(길이 · 제어 문자 · 날짜 모양)은 요청 검증이 본다. 로그에는 켬 · 끔만 (제목 · 내용 없이)
|
||||
*/
|
||||
async updateVacation(
|
||||
user: AuthenticatedUser,
|
||||
dto: MailVacationDto,
|
||||
): Promise<MailVacation> {
|
||||
const vacation: MailVacation = {
|
||||
isEnabled: dto.isEnabled,
|
||||
fromDate: dto.fromDate === null ? null : toWholeSecondIso(dto.fromDate),
|
||||
toDate: dto.toDate === null ? null : toWholeSecondIso(dto.toDate),
|
||||
subject: dto.subject,
|
||||
textBody: dto.textBody,
|
||||
};
|
||||
if (
|
||||
vacation.isEnabled &&
|
||||
(vacation.subject.trim() === '' || vacation.textBody.trim() === '')
|
||||
) {
|
||||
throw new BusinessException('VAL_001', {
|
||||
context: { reason: 'vacation-enabled-without-text' },
|
||||
});
|
||||
}
|
||||
if (
|
||||
vacation.fromDate !== null &&
|
||||
vacation.toDate !== null &&
|
||||
Date.parse(vacation.fromDate) >= Date.parse(vacation.toDate)
|
||||
) {
|
||||
throw new BusinessException('VAL_001', {
|
||||
context: { reason: 'vacation-dates-reversed' },
|
||||
});
|
||||
}
|
||||
|
||||
const saved = await this.provider.setVacation(user.email, vacation);
|
||||
this.logger.log(
|
||||
`${traceTag()} 부재중 자동 응답을 저장했습니다: user=${maskEmail(user.email)}, ${saved.isEnabled ? '켬' : '끔'}`,
|
||||
);
|
||||
return saved;
|
||||
}
|
||||
|
||||
/** 받은 그대로의 메일 원문 — 파일 이름은 제목(없으면 "메일") + .eml (attachment.policy.ts 의 messageSourceFileName) */
|
||||
async openMessageSource(
|
||||
user: AuthenticatedUser,
|
||||
id: string,
|
||||
): Promise<AttachmentContent> {
|
||||
const source = await this.provider.openMessageSource(user.email, id);
|
||||
return {
|
||||
stream: source.stream,
|
||||
name: messageSourceFileName(source.subject),
|
||||
size: source.size,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 메일 목록 · 검색 — mailboxId 와 검색어(q) 중 하나는 있어야 한다 (둘 다 없으면 VAL_001).
|
||||
* mailboxId 가 없으면 휴지통·스팸메일함을 뺀 모든 메일함에서 찾는다 (계약 2절 "검색"). 검색어는 어떤 로그에도 남기지 않는다.
|
||||
@@ -464,6 +630,32 @@ export class MailService {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 같은 직원 · 같은 메일함의 오래 걸리는 처리를 하나씩만 한다 — 진행 중이면 엔진에 묻지 않고 바로 REQ_001
|
||||
* (로그인의 진행 중 시도와 같은 모양). 끝나면 성공 · 실패와 상관없이 푼다. 다른 메일함끼리는 동시에 된다
|
||||
*/
|
||||
private async runExclusive<T>(
|
||||
user: AuthenticatedUser,
|
||||
mailboxId: string,
|
||||
operation: () => Promise<T>,
|
||||
): Promise<T> {
|
||||
const key = `${user.email.toLowerCase()}\n${mailboxId}`;
|
||||
if (this.mailboxOperations.has(key)) {
|
||||
throw new BusinessException('REQ_001', {
|
||||
context: {
|
||||
reason: 'mailbox-operation-in-progress',
|
||||
user: maskEmail(user.email),
|
||||
},
|
||||
});
|
||||
}
|
||||
this.mailboxOperations.add(key);
|
||||
try {
|
||||
return await operation();
|
||||
} finally {
|
||||
this.mailboxOperations.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
/** 같은 키로는 한 번만 보낸다 — 처리 중이면 REQ_001, 이미 보냈으면 그 결과, 내용이 다르면 MAIL_005 */
|
||||
private async deliverOnce(
|
||||
user: AuthenticatedUser,
|
||||
|
||||
@@ -146,8 +146,82 @@ export interface MessageUpdate {
|
||||
readonly isFlagged?: boolean;
|
||||
/** 옮길 메일함 — 지정하면 이 메일함 하나에만 속하게 된다 */
|
||||
readonly mailboxId?: string;
|
||||
/**
|
||||
* 스팸 신고(true) · 스팸 아님(false) — 키워드 `$junk` · `$notjunk` 를 서로 반대로 둔다 (RFC 8621 4.1.1, 계약 2절 "스팸 · 원문").
|
||||
* 메일함 이동과는 따로다 — 화면이 mailboxId 와 함께 보낸다
|
||||
*/
|
||||
readonly isJunk?: boolean;
|
||||
}
|
||||
|
||||
/** 메일함 만들기 (계약 2절 "메일함 관리") — 이름은 서비스가 검사한 값이다 */
|
||||
export interface MailboxCreate {
|
||||
/** 앞뒤 공백을 뺀 1~64자, `/` 없음 */
|
||||
readonly name: string;
|
||||
/** 상위 메일함 — null 이면 맨 위 */
|
||||
readonly parentId: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 메일함 지우기 결과 (DELETE /api/mailboxes/{id}) — 안의 메일을 휴지통으로 옮긴 뒤 빈 메일함을 지운다.
|
||||
* 한 요청에 다 옮기지 못하면 deleted: false — 클라이언트가 같은 요청을 다시 보낸다.
|
||||
*/
|
||||
export interface MailboxDeletion {
|
||||
/** true = 지웠다 */
|
||||
readonly deleted: boolean;
|
||||
/** 이번 요청에서 이 메일함에서 빠진 메일 수 — 휴지통으로 옮긴 것 + 다른 메일함에도 있어 이 메일함에서만 뺀 것 (진행 수) */
|
||||
readonly processed: number;
|
||||
/** 이번 요청에서 휴지통으로 옮긴 메일 수 (다른 메일함에도 있어 이 메일함에서만 뺀 메일은 세지 않는다) */
|
||||
readonly movedToTrash: number;
|
||||
/** 아직 이 메일함에 남은 메일 수 */
|
||||
readonly remaining: number;
|
||||
}
|
||||
|
||||
/** 메일함 전체 읽음 · 비우기 결과 — remaining 이 0 이 될 때까지 클라이언트가 같은 요청을 다시 보낸다 */
|
||||
export interface MailboxBulkResult {
|
||||
/** 이번 요청에서 읽음으로 바꾼 · 지운(그 메일함에서 뺀 것 포함) 메일 수 */
|
||||
readonly processed: number;
|
||||
readonly remaining: number;
|
||||
}
|
||||
|
||||
/** 부재중 자동 응답 (계약 2절 "부재중 자동 응답") — 엔진의 VacationResponse 하나 */
|
||||
export interface MailVacation {
|
||||
readonly isEnabled: boolean;
|
||||
/** ISO 8601 UTC — null 이면 켠 때부터 */
|
||||
readonly fromDate: string | null;
|
||||
/** ISO 8601 UTC — null 이면 끌 때까지 */
|
||||
readonly toDate: string | null;
|
||||
/** 엔진에 없으면(null) 빈 문자열 */
|
||||
readonly subject: string;
|
||||
/** 글자만(서식 없음) — 엔진에 없으면(null) 빈 문자열 */
|
||||
readonly textBody: string;
|
||||
}
|
||||
|
||||
/** 받은 그대로의 메일 원문 (GET /api/messages/{id}/source) — 파일 이름은 서비스가 제목으로 정한다 */
|
||||
export interface MessageSource {
|
||||
readonly stream: Readable;
|
||||
/** 제목 — 없으면 빈 문자열 */
|
||||
readonly subject: string;
|
||||
/** 알 수 있으면 바이트 수 */
|
||||
readonly size: number | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 비밀번호 바꾸기의 엔진 판단 (계약 1절 "PUT /api/auth/password").
|
||||
* 결과가 확실한 엔진 장애(바뀌지 않았다)는 결과가 아니라 실패(MAIL_001 · REQ_001)로 던진다.
|
||||
* - changed: 바뀌었다
|
||||
* - current-password-rejected: 지금 비밀번호로 엔진에 들어가지 못했다 (틀림) — 맞혀 보기라 로그인 실패처럼 센다
|
||||
* - current-password-refused: 지금 비밀번호로 들어가기는 했는데 엔진이 바꾸기를 거절했다
|
||||
* (지금 비밀번호 확인 거절 · 변경 권한 없음 · 그사이 다른 곳에서 바뀜) — 맞혀 보기가 아니다
|
||||
* - new-password-rejected: 새 비밀번호가 엔진 규칙에 어긋난다 (8자 미만 등) — 지금 비밀번호는 맞았다
|
||||
* - outcome-unknown: 바꾸기 요청을 보낸 뒤 결과를 알 수 없게 끝났다 (시간 초과 · 연결 끊김 · 5xx · 해석할 수 없는 응답) — 엔진이 바꿨을 수 있다
|
||||
*/
|
||||
export type PasswordChangeOutcome =
|
||||
| 'changed'
|
||||
| 'current-password-rejected'
|
||||
| 'current-password-refused'
|
||||
| 'new-password-rejected'
|
||||
| 'outcome-unknown';
|
||||
|
||||
/** 새 메일 확인 (계약 2절 "새 메일 확인") — 메일함이나 메일이 하나라도 바뀌면 달라지는 불투명 값 */
|
||||
export interface MailState {
|
||||
readonly state: string;
|
||||
|
||||
@@ -1,4 +1,16 @@
|
||||
import { Controller, Get, Header, UseGuards } from '@nestjs/common';
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
Header,
|
||||
HttpStatus,
|
||||
Param,
|
||||
Patch,
|
||||
Post,
|
||||
Put,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { ApiCookieAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
|
||||
import {
|
||||
@@ -11,12 +23,37 @@ import {
|
||||
} from '@/common/decorators/current-user.decorator';
|
||||
import { JwtAuthGuard } from '@/common/guards/jwt-auth.guard';
|
||||
import {
|
||||
MailboxBulkResultDto,
|
||||
MailboxDeletionDto,
|
||||
MailboxDto,
|
||||
MailboxPageDto,
|
||||
MailQuotaDto,
|
||||
MailStateDto,
|
||||
} from './dto/mail-response.dto';
|
||||
import { MailVacationDto } from './dto/mail-vacation.dto';
|
||||
import {
|
||||
CreateMailboxDto,
|
||||
MailboxMessagesPatchDto,
|
||||
RenameMailboxDto,
|
||||
} from './dto/mailbox.dto';
|
||||
import { MailIdPipe } from './mail-id.pipe';
|
||||
import { MAX_MAILBOX_NAME_LENGTH } from './mail.constants';
|
||||
import { MailService } from './mail.service';
|
||||
|
||||
/** 오래 걸리는 처리(전체 읽음 · 비우기 · 지우기)의 안내 — 계약 2절 "메일함 관리" */
|
||||
const BULK_NOTE =
|
||||
'한 요청을 25초 안에 끝내고 남은 수를 돌려준다 — 클라이언트는 남은 것이 없을 때까지 같은 요청을 다시 보낸다. ' +
|
||||
'도중에 실패하면 거기까지 처리했다 — 다시 보내면 남은 것만 처리한다. ' +
|
||||
'같은 직원 · 같은 메일함에는 하나씩만 — 처리 중이면(종류가 달라도) 엔진에 묻지 않고 429 REQ_001. 다른 메일함은 동시에 된다.';
|
||||
|
||||
/** 오래 걸리는 처리가 진행 중일 때의 안내 */
|
||||
const BULK_IN_PROGRESS =
|
||||
'같은 메일함의 오래 걸리는 처리(전체 읽음 · 비우기 · 지우기)가 처리 중 — 엔진에 묻지 않았다';
|
||||
|
||||
/** 오래 걸리는 처리가 하나도 처리하지 못하고 멈췄을 때의 안내 */
|
||||
const BULK_NO_PROGRESS =
|
||||
'메일 엔진 장애 · 하나도 처리하지 못하고 멈춤 (확인하는 사이 계속 바뀜 · 엔진이 거절 없이 바꾸지 않음 · 25초 안에 끝나지 않음)';
|
||||
|
||||
@ApiTags('Mail')
|
||||
@ApiCookieAuth()
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@@ -38,6 +75,151 @@ export class MailboxController {
|
||||
list(@CurrentUser() user: AuthenticatedUser) {
|
||||
return this.mail.listMailboxes(user);
|
||||
}
|
||||
|
||||
@Post()
|
||||
@ApiOperation({
|
||||
summary: '메일함 만들기',
|
||||
description:
|
||||
`이름은 앞뒤 공백을 뺀 1~${MAX_MAILBOX_NAME_LENGTH}자, "/" · 제어 문자는 쓸 수 없다. ` +
|
||||
'맨 위, 받은편지함 · 보관함 · 내 메일함(역할 없음) 아래에 만들 수 있다. 다른 메일 프로그램(IMAP)에도 보이게 구독한 메일함으로 만든다.',
|
||||
})
|
||||
@ApiStandardResponse(MailboxDto, {
|
||||
status: HttpStatus.CREATED,
|
||||
description: '만든 메일함',
|
||||
})
|
||||
@ApiErrorResponse(
|
||||
'VAL_001',
|
||||
'이름 형식 · 만들 수 없는 자리(보낸편지함 · 임시보관함 · 스팸메일함 · 휴지통 아래) · 모르는 필드',
|
||||
)
|
||||
@ApiErrorResponse('RES_001', '없는 상위 메일함 (parentId)')
|
||||
@ApiErrorResponse('MAIL_009', '같은 상위 안에 같은 이름의 메일함이 있다')
|
||||
create(
|
||||
@CurrentUser() user: AuthenticatedUser,
|
||||
@Body() dto: CreateMailboxDto,
|
||||
) {
|
||||
return this.mail.createMailbox(user, dto);
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
@ApiOperation({
|
||||
summary: '메일함 이름 바꾸기',
|
||||
description:
|
||||
'기본 메일함(역할이 있는 메일함 — 받은편지함 · 보낸편지함 등)은 바꿀 수 없다. 다른 상위로 옮기기는 하지 않는다.',
|
||||
})
|
||||
@ApiStandardResponse(MailboxDto, { description: '바뀐 메일함' })
|
||||
@ApiErrorResponse('VAL_001', '기본 메일함 · 이름 형식 · 모르는 필드')
|
||||
@ApiErrorResponse('RES_001', '없는 메일함 (형식이 틀린 id 포함)')
|
||||
@ApiErrorResponse('MAIL_009', '같은 상위 안에 같은 이름의 메일함이 있다')
|
||||
rename(
|
||||
@CurrentUser() user: AuthenticatedUser,
|
||||
@Param('id', MailIdPipe) id: string,
|
||||
@Body() dto: RenameMailboxDto,
|
||||
) {
|
||||
return this.mail.renameMailbox(user, id, dto);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@ApiOperation({
|
||||
summary: '메일함 지우기 — 안의 메일을 휴지통으로 옮긴 뒤 지운다',
|
||||
description:
|
||||
'메일을 영구 삭제하지 않는다 — 이 메일함에만 있는 메일은 휴지통으로 옮기고, 다른 메일함에도 있는 메일은 이 메일함에서만 뺀 뒤 빈 메일함을 지운다. ' +
|
||||
`옮기는 동안 들어온 메일도 옮긴다. ${BULK_NOTE} (deleted: false 면 다시 보낸다)`,
|
||||
})
|
||||
@ApiStandardResponse(MailboxDeletionDto, {
|
||||
description:
|
||||
'지웠다(deleted: true) · 아직 옮길 메일이 남았다(deleted: false)',
|
||||
})
|
||||
@ApiErrorResponse('VAL_001', '기본 메일함 · 하위 메일함이 있음')
|
||||
@ApiErrorResponse('RES_001', '없는 메일함 (형식이 틀린 id 포함)')
|
||||
@ApiErrorResponse('REQ_001', BULK_IN_PROGRESS)
|
||||
@ApiErrorResponse('MAIL_001', BULK_NO_PROGRESS)
|
||||
remove(
|
||||
@CurrentUser() user: AuthenticatedUser,
|
||||
@Param('id', MailIdPipe) id: string,
|
||||
) {
|
||||
return this.mail.deleteMailbox(user, id);
|
||||
}
|
||||
|
||||
@Patch(':id/messages')
|
||||
@ApiOperation({
|
||||
summary: '메일함 전체를 읽음으로',
|
||||
description: `안 읽은 메일을 500통씩 읽음으로 바꾼다. ${BULK_NOTE}`,
|
||||
})
|
||||
@ApiStandardResponse(MailboxBulkResultDto, {
|
||||
description: '이번 요청에서 읽음으로 바꾼 수 · 남은 수',
|
||||
})
|
||||
@ApiErrorResponse('VAL_001', '본문이 { isRead: true } 가 아님')
|
||||
@ApiErrorResponse('RES_001', '없는 메일함 (형식이 틀린 id 포함)')
|
||||
@ApiErrorResponse('REQ_001', BULK_IN_PROGRESS)
|
||||
@ApiErrorResponse('MAIL_001', BULK_NO_PROGRESS)
|
||||
updateMessages(
|
||||
@CurrentUser() user: AuthenticatedUser,
|
||||
@Param('id', MailIdPipe) id: string,
|
||||
@Body() dto: MailboxMessagesPatchDto,
|
||||
) {
|
||||
return this.mail.updateMailboxMessages(user, id, dto);
|
||||
}
|
||||
|
||||
@Delete(':id/messages')
|
||||
@ApiOperation({
|
||||
summary: '휴지통 · 스팸메일함 비우기 (영구 삭제)',
|
||||
description:
|
||||
'500통씩 확인한 상태 그대로일 때만 영구 삭제한다 — 그 메일함에만 있는 메일은 지우고, 다른 메일함에도 있는 메일은 그 메일함에서만 뺀다 ' +
|
||||
`(POST /api/message-deletions 와 같은 규칙). 확인하는 사이 계속 바뀌어 하나도 지우지 못하면 503 MAIL_001 (지우지 않았다). ${BULK_NOTE}`,
|
||||
})
|
||||
@ApiStandardResponse(MailboxBulkResultDto, {
|
||||
description: '이번 요청에서 지운 수 · 남은 수',
|
||||
})
|
||||
@ApiErrorResponse('VAL_001', '휴지통 · 스팸메일함이 아님')
|
||||
@ApiErrorResponse('RES_001', '없는 메일함 (형식이 틀린 id 포함)')
|
||||
@ApiErrorResponse('REQ_001', BULK_IN_PROGRESS)
|
||||
@ApiErrorResponse('MAIL_001', BULK_NO_PROGRESS)
|
||||
empty(
|
||||
@CurrentUser() user: AuthenticatedUser,
|
||||
@Param('id', MailIdPipe) id: string,
|
||||
) {
|
||||
return this.mail.emptyMailbox(user, id);
|
||||
}
|
||||
}
|
||||
|
||||
// 부재중 자동 응답 (계약 2절 "부재중 자동 응답") — 엔진의 VacationResponse 하나를 그대로 쓴다
|
||||
@ApiTags('Mail')
|
||||
@ApiCookieAuth()
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ApiErrorResponse('AUTH_001', '로그인 필요 (쿠키 없음·만료)')
|
||||
@ApiErrorResponse('MAIL_001', '메일 엔진 장애')
|
||||
@Controller('mail-vacation')
|
||||
export class MailVacationController {
|
||||
constructor(private readonly mail: MailService) {}
|
||||
|
||||
@Get()
|
||||
@ApiOperation({
|
||||
summary: '부재중 자동 응답',
|
||||
description:
|
||||
'한 번도 설정하지 않았으면 { isEnabled: false, fromDate: null, toDate: null, subject: "", textBody: "" } 다.',
|
||||
})
|
||||
@ApiStandardResponse(MailVacationDto, { description: '지금 설정' })
|
||||
get(@CurrentUser() user: AuthenticatedUser) {
|
||||
return this.mail.getVacation(user);
|
||||
}
|
||||
|
||||
@Put()
|
||||
@ApiOperation({
|
||||
summary: '부재중 자동 응답 저장 (전체 교체)',
|
||||
description:
|
||||
'다섯 필드를 모두 보낸다(날짜는 null 가능). 켤 때는 제목 · 내용이 앞뒤 공백을 빼고 1자 이상(저장은 쓴 그대로), ' +
|
||||
'시작 · 끝이 둘 다 있으면 시작이 끝보다 앞이어야 한다 — 날짜는 소수 초를 버린 값으로 저장하고, 순서도 그 값으로 본다. ' +
|
||||
'제목에는 제어 문자(탭 · 줄바꿈 포함)를, 내용에는 탭 · 줄바꿈 밖의 제어 문자를 쓸 수 없다. ' +
|
||||
'꺼도 쓴 제목 · 내용은 남겨 둔다. 서식 본문은 쓰지 않는다. 엔진이 저장한 값을 다시 읽어 돌려준다.',
|
||||
})
|
||||
@ApiStandardResponse(MailVacationDto, { description: '저장한 설정' })
|
||||
@ApiErrorResponse(
|
||||
'VAL_001',
|
||||
'형식 · 길이(제목 200자 · 내용 4,000자) · 제어 문자 · 켤 때 공백뿐인 제목 · 내용 · 시작이 끝보다 늦거나 같음(소수 초를 버린 값으로) · 모르는 필드',
|
||||
)
|
||||
update(@CurrentUser() user: AuthenticatedUser, @Body() dto: MailVacationDto) {
|
||||
return this.mail.updateVacation(user, dto);
|
||||
}
|
||||
}
|
||||
|
||||
@ApiTags('Mail')
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { Mailbox, MailboxRole } from './mail.types';
|
||||
import { presentMailboxes } from './mailbox.presenter';
|
||||
import { presentMailbox, presentMailboxes } from './mailbox.presenter';
|
||||
|
||||
const box = (
|
||||
id: string,
|
||||
@@ -70,3 +70,13 @@ describe('presentMailboxes', () => {
|
||||
expect(input[0].name).toBe('Inbox');
|
||||
});
|
||||
});
|
||||
|
||||
describe('presentMailbox', () => {
|
||||
it('메일함 하나 — 역할이 있으면 한국어 표준 이름, 없으면 서버의 이름 그대로 (만들기 · 이름 바꾸기 응답)', () => {
|
||||
expect(presentMailbox(box('f', 'Archive', 'archive'))).toMatchObject({
|
||||
name: '보관함',
|
||||
});
|
||||
const mine = box('u1', '거래처', null);
|
||||
expect(presentMailbox(mine)).toBe(mine);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -36,11 +36,14 @@ function compareMailboxes(a: Mailbox, b: Mailbox): number {
|
||||
);
|
||||
}
|
||||
|
||||
/** 메일함 하나의 표시 이름 — 역할이 있으면 한국어 표준 이름 (만들기 · 이름 바꾸기 응답) */
|
||||
export function presentMailbox(mailbox: Mailbox): Mailbox {
|
||||
return mailbox.role
|
||||
? { ...mailbox, name: ROLE_NAMES[mailbox.role] }
|
||||
: mailbox;
|
||||
}
|
||||
|
||||
/** 표시 이름을 붙이고 정렬한다 (원본 배열은 바꾸지 않는다) */
|
||||
export function presentMailboxes(mailboxes: readonly Mailbox[]): Mailbox[] {
|
||||
return mailboxes
|
||||
.map((mailbox) =>
|
||||
mailbox.role ? { ...mailbox, name: ROLE_NAMES[mailbox.role] } : mailbox,
|
||||
)
|
||||
.sort(compareMailboxes);
|
||||
return mailboxes.map(presentMailbox).sort(compareMailboxes);
|
||||
}
|
||||
|
||||
@@ -61,11 +61,15 @@ import { SendMessageDto } from './dto/send-message.dto';
|
||||
import { UpdateMessageDto } from './dto/update-message.dto';
|
||||
import { MailIdPipe } from './mail-id.pipe';
|
||||
import { MailService } from './mail.service';
|
||||
import type { AttachmentContent } from './mail.types';
|
||||
import { IDEMPOTENCY_KEY_HEADER } from './send-idempotency';
|
||||
|
||||
/** 첨부는 항상 내려받게 한다 — 브라우저가 내용을 해석해 그리지 않도록 형식을 서버가 정한다 */
|
||||
const ATTACHMENT_CONTENT_TYPE = 'application/octet-stream';
|
||||
|
||||
/** 메일 원문의 형식 (계약 2절 "스팸 · 원문") — attachment 로 내려주므로 브라우저가 열어 그리지 않는다 */
|
||||
const MESSAGE_SOURCE_CONTENT_TYPE = 'message/rfc822';
|
||||
|
||||
@ApiTags('Mail')
|
||||
@ApiCookieAuth()
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@@ -110,9 +114,10 @@ export class MessageController {
|
||||
|
||||
@Patch()
|
||||
@ApiOperation({
|
||||
summary: '여러 통의 읽음 · 중요 표시 · 메일함 이동 (하나 이상 지정)',
|
||||
summary:
|
||||
'여러 통의 읽음 · 중요 표시 · 메일함 이동 · 스팸 표시 (하나 이상 지정)',
|
||||
description:
|
||||
`ids 는 1~${MAX_BULK_MESSAGE_IDS}개. 메일마다의 변경은 한 통 PATCH 와 같다(이동 = 그 메일함 하나에만). 엔진 요청 한 번으로 처리한다. ` +
|
||||
`ids 는 1~${MAX_BULK_MESSAGE_IDS}개. 메일마다의 변경은 한 통 PATCH 와 같다(이동 = 그 메일함 하나에만, isJunk = $junk · $notjunk). 엔진 요청 한 번으로 처리한다. ` +
|
||||
'바꾸지 못한 메일(없음 · 그사이 지워짐 · 엔진이 거절)은 failedIds 로 돌려주고 나머지는 그대로 처리한다. ' +
|
||||
'없는 mailboxId 면 아무것도 바꾸지 않고 404 RES_001. 503 MAIL_001 이면 일부만 바뀌었을 수 있다 — 같은 요청을 다시 보내도 결과가 같다(멱등).',
|
||||
})
|
||||
@@ -154,7 +159,11 @@ export class MessageController {
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
@ApiOperation({ summary: '읽음 · 중요 표시 · 메일함 이동 (하나 이상 지정)' })
|
||||
@ApiOperation({
|
||||
summary: '읽음 · 중요 표시 · 메일함 이동 · 스팸 표시 (하나 이상 지정)',
|
||||
description:
|
||||
'isJunk 는 키워드 $junk · $notjunk 를 서로 반대로 둔다(스팸 신고 true · 스팸 아님 false). 이동과 함께 보내면 같은 변경 하나로 처리한다.',
|
||||
})
|
||||
@ApiStandardResponse(MessageSummaryDto, { description: '바뀐 메일 요약' })
|
||||
@ApiErrorResponse('RES_001', '없는 메일')
|
||||
@ApiErrorResponse('VAL_001', '빈 요청·잘못된 값·없는 메일함으로 이동')
|
||||
@@ -263,10 +272,53 @@ export class MessageController {
|
||||
@Res({ passthrough: true }) res: Response,
|
||||
): Promise<StreamableFile> {
|
||||
const content = await this.mail.openAttachment(user, id, attachmentId);
|
||||
return this.streamDownload(res, content, ATTACHMENT_CONTENT_TYPE, '첨부');
|
||||
}
|
||||
|
||||
@Get(':id/source')
|
||||
@SkipTransform()
|
||||
@ApiOperation({
|
||||
summary: '받은 그대로의 메일 원문 내려받기 (표준 래퍼 없음)',
|
||||
description:
|
||||
'메일 엔진의 원본을 그대로 흘려보낸다 — message/rfc822 + Content-Disposition: attachment (파일 이름은 제목, 없으면 "메일" + .eml). ' +
|
||||
'Cache-Control: private, no-store. 화면은 첨부 내려받기처럼 이동 전에 세션을 확인한다.',
|
||||
})
|
||||
@ApiProduces(MESSAGE_SOURCE_CONTENT_TYPE)
|
||||
@ApiResponse({
|
||||
status: HttpStatus.OK,
|
||||
description: '메일 원문 (.eml)',
|
||||
schema: { type: 'string', format: 'binary' },
|
||||
})
|
||||
@ApiErrorResponse('RES_001', '없는 메일 (형식이 틀린 id 포함)')
|
||||
@ApiErrorResponse('MAIL_001', '메일 엔진 장애')
|
||||
async source(
|
||||
@CurrentUser() user: AuthenticatedUser,
|
||||
@Param('id', MailIdPipe) id: string,
|
||||
@Res({ passthrough: true }) res: Response,
|
||||
): Promise<StreamableFile> {
|
||||
const content = await this.mail.openMessageSource(user, id);
|
||||
return this.streamDownload(
|
||||
res,
|
||||
content,
|
||||
MESSAGE_SOURCE_CONTENT_TYPE,
|
||||
'원문',
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 메일 엔진에서 받은 파일을 내려받기로 흘려보낸다 (첨부 · 원문 공용) — 형식은 서버가 정하고 늘 attachment 로 내려준다.
|
||||
* @param what 로그 문장의 대상 — '첨부' · '원문'
|
||||
*/
|
||||
private streamDownload(
|
||||
res: Response,
|
||||
content: AttachmentContent,
|
||||
type: string,
|
||||
what: string,
|
||||
): StreamableFile {
|
||||
// 스트림 오류는 요청 처리가 끝난 뒤의 이벤트에서 올 수 있다 — 추적 ID 를 지금 잡아 둔다
|
||||
const traceId = currentRequestId() ?? 'unknown';
|
||||
|
||||
// 메일 첨부는 공유 캐시에 남기지 않는다
|
||||
// 메일 내용은 공유 캐시에 남기지 않는다
|
||||
res.setHeader('Cache-Control', 'private, no-store');
|
||||
// 클라이언트가 중간에 끊으면 메일 엔진에서 받던 스트림도 닫는다 (pipe 는 원천을 닫지 않는다)
|
||||
res.once('close', () => {
|
||||
@@ -274,14 +326,14 @@ export class MessageController {
|
||||
});
|
||||
|
||||
const file = new StreamableFile(content.stream, {
|
||||
type: ATTACHMENT_CONTENT_TYPE,
|
||||
type,
|
||||
disposition: buildAttachmentDisposition(content.name),
|
||||
...(content.size !== null ? { length: content.size } : {}),
|
||||
});
|
||||
// 기본 처리기는 오류 메시지를 본문으로 보낸다 — 내부 정보가 새지 않게 연결만 끊는다
|
||||
file.setErrorHandler((error) => {
|
||||
this.logger.error(
|
||||
`[MAIL_001][${traceId}] 첨부 전송 중단: ${error.message}`,
|
||||
`[MAIL_001][${traceId}] ${what} 전송 중단: ${error.message}`,
|
||||
);
|
||||
res.destroy();
|
||||
});
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import { toWholeSecondIso } from './format';
|
||||
|
||||
describe('toWholeSecondIso', () => {
|
||||
it('소수 초를 버리고 UTC ISO 문자열로 돌려준다 — 올리지 않는다', () => {
|
||||
expect(toWholeSecondIso('2026-11-02T15:00:00.999Z')).toBe(
|
||||
'2026-11-02T15:00:00.000Z',
|
||||
);
|
||||
expect(toWholeSecondIso('2026-11-02T15:00:00.5Z')).toBe(
|
||||
'2026-11-02T15:00:00.000Z',
|
||||
);
|
||||
expect(toWholeSecondIso('2026-11-02T15:00:00Z')).toBe(
|
||||
'2026-11-02T15:00:00.000Z',
|
||||
);
|
||||
expect(toWholeSecondIso('2026-11-02T15:00:01.000Z')).toBe(
|
||||
'2026-11-02T15:00:01.000Z',
|
||||
);
|
||||
});
|
||||
|
||||
it('1970년 이전 시각도 앞쪽으로 버린다 (음수 시각에서 다음 초로 올라가지 않게)', () => {
|
||||
expect(toWholeSecondIso('1969-12-31T23:59:59.500Z')).toBe(
|
||||
'1969-12-31T23:59:59.000Z',
|
||||
);
|
||||
});
|
||||
|
||||
it('시각으로 읽을 수 없는 값은 그대로 돌려준다 (형식 검사는 부르는 쪽 몫)', () => {
|
||||
expect(toWholeSecondIso('내일')).toBe('내일');
|
||||
});
|
||||
});
|
||||
@@ -11,3 +11,17 @@ export function formatDate(input: Date | string): string {
|
||||
const dd = String(date.getDate()).padStart(2, '0');
|
||||
return `${yyyy}-${mm}-${dd}`;
|
||||
}
|
||||
|
||||
/** 1초 (밀리초) */
|
||||
const SECOND_MS = 1_000;
|
||||
|
||||
/**
|
||||
* ISO 8601 시각을 초 단위로 맞춘다 — 소수 초를 버리고 UTC ISO 문자열('…:00.000Z')로 돌려준다.
|
||||
* 초 단위로만 받는 곳(메일 엔진의 UTCDate 등)에 보낼 값과, 그 값으로 하는 비교를 같게 하려고 쓴다.
|
||||
* 시각으로 읽을 수 없는 값은 그대로 돌려준다 (형식 검사는 부르는 쪽이 먼저 한다)
|
||||
*/
|
||||
export function toWholeSecondIso(iso: string): string {
|
||||
const time = Date.parse(iso);
|
||||
if (Number.isNaN(time)) return iso;
|
||||
return new Date(Math.floor(time / SECOND_MS) * SECOND_MS).toISOString();
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { createHash } from 'crypto';
|
||||
import { existsSync, unlinkSync } from 'fs';
|
||||
import type { Server } from 'http';
|
||||
import { basename, join } from 'path';
|
||||
import { Readable } from 'stream';
|
||||
import request from 'supertest';
|
||||
import type { Response } from 'supertest';
|
||||
import { App } from 'supertest/types';
|
||||
@@ -28,11 +29,19 @@ import type {
|
||||
Correspondence,
|
||||
DraftContent,
|
||||
DraftSource,
|
||||
Mailbox,
|
||||
MailboxBulkResult,
|
||||
MailboxCreate,
|
||||
MailboxDeletion,
|
||||
MailVacation,
|
||||
MessageDeletionResult,
|
||||
MessageListQuery,
|
||||
MessageSource,
|
||||
MessagesPatchResult,
|
||||
MessageSummary,
|
||||
MessageUpdate,
|
||||
OutgoingMessage,
|
||||
PasswordChangeOutcome,
|
||||
SavedDraft,
|
||||
} from '@/modules/mail/mail.types';
|
||||
import {
|
||||
@@ -43,6 +52,7 @@ import {
|
||||
} from '@/modules/mail-signature/mail-signature.constants';
|
||||
import { MailSignatureRepository } from '@/modules/mail-signature/mail-signature.repository';
|
||||
import { UPLOAD_DIR } from '@/modules/upload/upload.config';
|
||||
import { buildAttachmentDisposition } from '@/shared/utils/content-disposition';
|
||||
import { pngBytes } from './image-fixtures';
|
||||
|
||||
/**
|
||||
@@ -116,6 +126,35 @@ const MISSING_MAILBOX = 'nomailbox';
|
||||
/** 대역 메일 엔진이 "그사이 지워졌다"고 답하는 메일 id (failedIds · skippedIds 경로) */
|
||||
const GONE_MESSAGE = 'gonemessage';
|
||||
|
||||
/** 대역 메일 엔진이 "엔진 규칙에 어긋난다"고 답하는 새 비밀번호 (400 VAL_001 경로) */
|
||||
const ENGINE_REJECTED_PASSWORD = 'engine-rejects-this-one';
|
||||
|
||||
/** 대역 메일 엔진이 "지금 비밀번호로 들어갔지만 바꾸기는 거절했다"고 답하는 새 비밀번호 (400 AUTH_005 — 실패로 세지 않는다) */
|
||||
const ENGINE_REFUSED_PASSWORD = 'engine-refuses-this-change';
|
||||
|
||||
/** 대역 메일 엔진의 비밀번호 바꾸기가 결과를 알 수 없게 끝나는 새 비밀번호 (503 MAIL_001 — 다른 세션은 끊어 둔다) */
|
||||
const ENGINE_UNKNOWN_PASSWORD = 'engine-outcome-unknown';
|
||||
|
||||
/** 대역 메일 엔진이 "같은 상위 안에 같은 이름이 있다"고 답하는 메일함 이름 (409 MAIL_009 경로) */
|
||||
const TAKEN_MAILBOX_NAME = '이미 있는 이름';
|
||||
|
||||
/** 대역 메일 엔진의 비우기가 테스트가 문을 열 때까지 끝나지 않는 메일함 — 같은 메일함의 처리 중 잠금 확인용 */
|
||||
const SLOW_MAILBOX = 'slowbox';
|
||||
|
||||
/** 한 번도 설정하지 않은 계정의 부재중 자동 응답 (계약 2절) */
|
||||
const NEVER_CONFIGURED_VACATION: MailVacation = {
|
||||
isEnabled: false,
|
||||
fromDate: null,
|
||||
toDate: null,
|
||||
subject: '',
|
||||
textBody: '',
|
||||
};
|
||||
|
||||
/** 대역 메일 엔진이 돌려주는 메일 원문과 그 제목 */
|
||||
const RAW_MESSAGE_SUBJECT = 'Re: 견적서/10월';
|
||||
const RAW_MESSAGE =
|
||||
'From: doyun.lee@e2e.arasystem.test\r\nSubject: Re: 견적서/10월\r\n\r\n견적서를 보냅니다.\r\n';
|
||||
|
||||
/** 대역 메일 엔진이 로그인 때 알려 주는 이름 — 없으면 EMPLOYEE.name */
|
||||
const PROFILE_NAMES = new Map([
|
||||
['colleague.a@e2e.arasystem.test', '가나다'],
|
||||
@@ -194,6 +233,8 @@ describe('응답 계약 (e2e)', () => {
|
||||
* 열기 전에 온 확인은 모두 같은 문을 기다린다 (회귀로 여러 번 와도 한 번 열면 모두 풀린다).
|
||||
*/
|
||||
let slowEngineGate: Promise<void> | null = null;
|
||||
/** 대역 엔진의 SLOW_MAILBOX 비우기가 기다리는 문 — 테스트가 만들고 연다. null 이면 바로 답한다 */
|
||||
let slowMailboxGate: Promise<void> | null = null;
|
||||
/** 대역 엔진의 임시 저장본 (id → 주인·내용) — 계약 2절 "임시 저장" */
|
||||
const drafts = new Map<
|
||||
string,
|
||||
@@ -217,6 +258,17 @@ describe('응답 계약 (e2e)', () => {
|
||||
}[] = [];
|
||||
const trashDeletions: { user: string; ids: readonly string[] }[] = [];
|
||||
|
||||
/** 4단계 — 대역 엔진이 받은 비밀번호 바꾸기 · 메일함 관리 · 부재중 자동 응답 · 한 통 바꾸기 */
|
||||
const passwordChanges: { email: string; newPassword: string }[] = [];
|
||||
const mailboxCalls: { user: string; action: string; args: unknown[] }[] = [];
|
||||
const vacations = new Map<string, MailVacation>();
|
||||
const vacationSaves: { user: string; vacation: MailVacation }[] = [];
|
||||
const singleUpdates: {
|
||||
user: string;
|
||||
id: string;
|
||||
update: MessageUpdate;
|
||||
}[] = [];
|
||||
|
||||
/** 이 앱의 서명 키로 토큰을 만든다 (CI 처럼 JWT_SECRET 이 없으면 임시 키) */
|
||||
const sign = (payload: object, expiresIn: number) =>
|
||||
jwt.sign(payload, {
|
||||
@@ -370,6 +422,140 @@ describe('응답 계약 (e2e)', () => {
|
||||
skippedIds: ids.filter((id) => id === GONE_MESSAGE),
|
||||
});
|
||||
},
|
||||
// 4단계 — 엔진 쪽 판단(본인 자격의 비밀번호 바꾸기 · 조건부로 옮기기 · 비우기)은 jmap-*.spec.ts 가 본다. 여기서는 받은 값만 흉내 낸다
|
||||
changePassword: (
|
||||
email: string,
|
||||
currentPassword: string,
|
||||
newPassword: string,
|
||||
): Promise<PasswordChangeOutcome> => {
|
||||
passwordChanges.push({ email, newPassword });
|
||||
if (currentPassword !== CORRECT_PASSWORD) {
|
||||
return Promise.resolve('current-password-rejected');
|
||||
}
|
||||
const outcomes = new Map<string, PasswordChangeOutcome>([
|
||||
[ENGINE_REJECTED_PASSWORD, 'new-password-rejected'],
|
||||
[ENGINE_REFUSED_PASSWORD, 'current-password-refused'],
|
||||
[ENGINE_UNKNOWN_PASSWORD, 'outcome-unknown'],
|
||||
]);
|
||||
return Promise.resolve(outcomes.get(newPassword) ?? 'changed');
|
||||
},
|
||||
createMailbox: (
|
||||
user: string,
|
||||
input: MailboxCreate,
|
||||
): Promise<Mailbox> => {
|
||||
mailboxCalls.push({ user, action: 'create', args: [input] });
|
||||
if (input.name === TAKEN_MAILBOX_NAME) {
|
||||
return Promise.reject(
|
||||
new BusinessException('MAIL_009', {
|
||||
context: { reason: 'e2e-mailbox-name-taken' },
|
||||
}),
|
||||
);
|
||||
}
|
||||
if (input.parentId === MISSING_MAILBOX) {
|
||||
return Promise.reject(
|
||||
new BusinessException('RES_001', {
|
||||
context: { reason: 'e2e-parent-not-found' },
|
||||
}),
|
||||
);
|
||||
}
|
||||
return Promise.resolve({
|
||||
id: 'newbox',
|
||||
name: input.name,
|
||||
role: null,
|
||||
parentId: input.parentId,
|
||||
sortOrder: 0,
|
||||
totalCount: 0,
|
||||
unreadCount: 0,
|
||||
});
|
||||
},
|
||||
renameMailbox: (
|
||||
user: string,
|
||||
id: string,
|
||||
name: string,
|
||||
): Promise<Mailbox> => {
|
||||
mailboxCalls.push({ user, action: 'rename', args: [id, name] });
|
||||
return Promise.resolve({
|
||||
id,
|
||||
name,
|
||||
role: null,
|
||||
parentId: null,
|
||||
sortOrder: 0,
|
||||
totalCount: 3,
|
||||
unreadCount: 1,
|
||||
});
|
||||
},
|
||||
deleteMailbox: (user: string, id: string): Promise<MailboxDeletion> => {
|
||||
mailboxCalls.push({ user, action: 'delete', args: [id] });
|
||||
return Promise.resolve({
|
||||
deleted: true,
|
||||
processed: 4,
|
||||
movedToTrash: 3,
|
||||
remaining: 0,
|
||||
});
|
||||
},
|
||||
markMailboxRead: (
|
||||
user: string,
|
||||
id: string,
|
||||
): Promise<MailboxBulkResult> => {
|
||||
mailboxCalls.push({ user, action: 'mark-read', args: [id] });
|
||||
return Promise.resolve({ processed: 5, remaining: 0 });
|
||||
},
|
||||
emptyMailbox: async (
|
||||
user: string,
|
||||
id: string,
|
||||
): Promise<MailboxBulkResult> => {
|
||||
mailboxCalls.push({ user, action: 'empty', args: [id] });
|
||||
if (id === SLOW_MAILBOX && slowMailboxGate) await slowMailboxGate;
|
||||
return { processed: 2, remaining: 0 };
|
||||
},
|
||||
getVacation: (user: string): Promise<MailVacation> =>
|
||||
Promise.resolve(vacations.get(user) ?? NEVER_CONFIGURED_VACATION),
|
||||
setVacation: (
|
||||
user: string,
|
||||
vacation: MailVacation,
|
||||
): Promise<MailVacation> => {
|
||||
vacationSaves.push({ user, vacation });
|
||||
vacations.set(user, vacation);
|
||||
return Promise.resolve(vacation);
|
||||
},
|
||||
openMessageSource: (
|
||||
_user: string,
|
||||
id: string,
|
||||
): Promise<MessageSource> => {
|
||||
if (id === GONE_MESSAGE) {
|
||||
return Promise.reject(
|
||||
new BusinessException('RES_001', {
|
||||
context: { reason: 'e2e-message-not-found' },
|
||||
}),
|
||||
);
|
||||
}
|
||||
return Promise.resolve({
|
||||
stream: Readable.from([Buffer.from(RAW_MESSAGE, 'utf8')]),
|
||||
subject: RAW_MESSAGE_SUBJECT,
|
||||
size: Buffer.byteLength(RAW_MESSAGE, 'utf8'),
|
||||
});
|
||||
},
|
||||
updateMessage: (
|
||||
user: string,
|
||||
id: string,
|
||||
update: MessageUpdate,
|
||||
): Promise<MessageSummary> => {
|
||||
singleUpdates.push({ user, id, update });
|
||||
return Promise.resolve({
|
||||
id,
|
||||
threadId: id,
|
||||
mailboxIds: [update.mailboxId ?? 'a'],
|
||||
from: null,
|
||||
to: [],
|
||||
subject: '',
|
||||
preview: '',
|
||||
receivedAt: '2026-10-28T01:00:00.000Z',
|
||||
isRead: false,
|
||||
isFlagged: false,
|
||||
hasAttachment: false,
|
||||
size: 1,
|
||||
});
|
||||
},
|
||||
})
|
||||
.overrideProvider(MAIL_SETTINGS)
|
||||
.useValue(TEST_MAIL_SETTINGS)
|
||||
@@ -752,6 +938,15 @@ describe('응답 계약 (e2e)', () => {
|
||||
['get', '/api/messages?q=%ED%9A%8C%EC%9D%98'],
|
||||
['patch', '/api/messages'],
|
||||
['post', '/api/message-deletions'],
|
||||
['put', '/api/auth/password'],
|
||||
['post', '/api/mailboxes'],
|
||||
['patch', '/api/mailboxes/u1'],
|
||||
['delete', '/api/mailboxes/u1'],
|
||||
['patch', '/api/mailboxes/u1/messages'],
|
||||
['delete', '/api/mailboxes/u1/messages'],
|
||||
['get', '/api/mail-vacation'],
|
||||
['put', '/api/mail-vacation'],
|
||||
['get', '/api/messages/eaaaaab/source'],
|
||||
// 템플릿 업로드 예제도 로그인한 직원만 쓴다
|
||||
['post', '/api/files'],
|
||||
] as const)('쿠키 없이 %s %s 는 401 AUTH_001', async (method, path) => {
|
||||
@@ -1669,6 +1864,585 @@ describe('응답 계약 (e2e)', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('4단계 — 비밀번호 · 메일함 관리 · 부재중 자동 응답 · 스팸 · 원문', () => {
|
||||
describe('비밀번호 바꾸기 (PUT /api/auth/password)', () => {
|
||||
const NEW_PASSWORD = 'new-password-for-e2e';
|
||||
|
||||
/** 로그인한 두 쿠키로 보낸다 — 리프레시 쿠키(Path=/api/auth)가 이 세션을 알려 준다 */
|
||||
const changeWith = (
|
||||
session: { access: string; refresh: string },
|
||||
payload: object,
|
||||
ip: string,
|
||||
) =>
|
||||
request(server)
|
||||
.put('/api/auth/password')
|
||||
.set('X-Forwarded-For', ip)
|
||||
.set(
|
||||
'Cookie',
|
||||
`access_token=${session.access}; refresh_token=${session.refresh}`,
|
||||
)
|
||||
.send(payload);
|
||||
|
||||
/** 액세스 쿠키만으로 보낸다 (형식 검사 · 한도 확인용) */
|
||||
const changeAs = (email: string, payload: object, ip: string) =>
|
||||
request(server)
|
||||
.put('/api/auth/password')
|
||||
.set('X-Forwarded-For', ip)
|
||||
.set('Cookie', cookieOf(email))
|
||||
.send(payload);
|
||||
|
||||
/** 리프레시 토큰이 가리키는 세션의 끝난 사유 */
|
||||
const revokedReasonOf = async (refreshToken: string) => {
|
||||
const { sid } = jwt.decode<{ sid: string }>(refreshToken);
|
||||
const rows: { revoked_reason: string | null }[] =
|
||||
await dataSource.query(
|
||||
'SELECT revoked_reason FROM auth_sessions WHERE id = $1',
|
||||
[sid],
|
||||
);
|
||||
return rows[0]?.revoked_reason;
|
||||
};
|
||||
|
||||
it('바뀌면 200 data null — 이 세션만 남기고 그 직원의 다른 세션을 끊는다 (쿠키는 그대로)', async () => {
|
||||
const email = 'pw.change@e2e.arasystem.test';
|
||||
const mine = await login(email);
|
||||
const other = await login(email);
|
||||
|
||||
const res = await changeWith(
|
||||
mine,
|
||||
{ currentPassword: CORRECT_PASSWORD, newPassword: NEW_PASSWORD },
|
||||
'198.51.100.200',
|
||||
).expect(200);
|
||||
|
||||
expect(body(res)).toEqual({ success: true, data: null });
|
||||
expect(res.headers['set-cookie']).toBeUndefined();
|
||||
expect(passwordChanges.at(-1)).toEqual({
|
||||
email,
|
||||
newPassword: NEW_PASSWORD,
|
||||
});
|
||||
expect(await revokedReasonOf(other.refresh)).toBe('password-changed');
|
||||
expect(await revokedReasonOf(mine.refresh)).toBeNull();
|
||||
|
||||
await refreshWith(mine.refresh).expect(200);
|
||||
const cut = await refreshWith(other.refresh).expect(401);
|
||||
expect(body(cut).error?.code).toBe('AUTH_001');
|
||||
});
|
||||
|
||||
it('지금 비밀번호가 틀리면 401 이 아니라 400 AUTH_005 — 세션은 그대로 · 엔진 규칙에 어긋난 새 비밀번호는 400 VAL_001', async () => {
|
||||
const mine = await login('pw.wrong@e2e.arasystem.test');
|
||||
|
||||
const wrong = await changeWith(
|
||||
mine,
|
||||
{ currentPassword: 'wrong-password', newPassword: NEW_PASSWORD },
|
||||
'198.51.100.201',
|
||||
).expect(400);
|
||||
expect(body(wrong).error?.code).toBe('AUTH_005');
|
||||
|
||||
const rejected = await changeWith(
|
||||
mine,
|
||||
{
|
||||
currentPassword: CORRECT_PASSWORD,
|
||||
newPassword: ENGINE_REJECTED_PASSWORD,
|
||||
},
|
||||
'198.51.100.201',
|
||||
).expect(400);
|
||||
expect(body(rejected).error?.code).toBe('VAL_001');
|
||||
|
||||
expect(await revokedReasonOf(mine.refresh)).toBeNull();
|
||||
await refreshWith(mine.refresh).expect(200);
|
||||
});
|
||||
|
||||
it('지금 비밀번호로 들어갔는데 엔진이 바꾸기를 거절하면 400 AUTH_005 — 실패로 세지 않아 10번을 넘겨도 429 가 아니고, 세션은 그대로', async () => {
|
||||
const email = 'pw.refused@e2e.arasystem.test';
|
||||
const mine = await login(email);
|
||||
const other = await login(email);
|
||||
|
||||
for (let i = 0; i < 12; i += 1) {
|
||||
const res = await changeWith(
|
||||
mine,
|
||||
{
|
||||
currentPassword: CORRECT_PASSWORD,
|
||||
newPassword: ENGINE_REFUSED_PASSWORD,
|
||||
},
|
||||
'198.51.100.240',
|
||||
).expect(400);
|
||||
expect(body(res).error?.code).toBe('AUTH_005');
|
||||
}
|
||||
|
||||
expect(await revokedReasonOf(other.refresh)).toBeNull();
|
||||
await refreshWith(other.refresh).expect(200);
|
||||
});
|
||||
|
||||
it('결과를 알 수 없게 끝나면 503 MAIL_001 — 엔진이 바꿨을 수 있어 이 세션만 남기고 다른 세션은 끊어 둔다', async () => {
|
||||
const email = 'pw.unknown@e2e.arasystem.test';
|
||||
const mine = await login(email);
|
||||
const other = await login(email);
|
||||
|
||||
const res = await changeWith(
|
||||
mine,
|
||||
{
|
||||
currentPassword: CORRECT_PASSWORD,
|
||||
newPassword: ENGINE_UNKNOWN_PASSWORD,
|
||||
},
|
||||
'198.51.100.241',
|
||||
).expect(503);
|
||||
|
||||
expect(body(res).error?.code).toBe('MAIL_001');
|
||||
expect(await revokedReasonOf(other.refresh)).toBe(
|
||||
'password-change-uncertain',
|
||||
);
|
||||
expect(await revokedReasonOf(mine.refresh)).toBeNull();
|
||||
await refreshWith(mine.refresh).expect(200);
|
||||
const cut = await refreshWith(other.refresh).expect(401);
|
||||
expect(body(cut).error?.code).toBe('AUTH_001');
|
||||
});
|
||||
|
||||
it('형식 오류는 400 VAL_001 — 7자 · 129자 · 지금과 같음 · 빠진 필드 · 모르는 필드, 엔진에 묻지 않는다', async () => {
|
||||
const before = passwordChanges.length;
|
||||
|
||||
for (const payload of [
|
||||
{ currentPassword: CORRECT_PASSWORD, newPassword: 'x'.repeat(7) },
|
||||
{ currentPassword: CORRECT_PASSWORD, newPassword: 'x'.repeat(129) },
|
||||
{ currentPassword: CORRECT_PASSWORD, newPassword: CORRECT_PASSWORD },
|
||||
{ newPassword: NEW_PASSWORD },
|
||||
{ currentPassword: '', newPassword: NEW_PASSWORD },
|
||||
{
|
||||
currentPassword: CORRECT_PASSWORD,
|
||||
newPassword: NEW_PASSWORD,
|
||||
confirmPassword: NEW_PASSWORD,
|
||||
},
|
||||
]) {
|
||||
const res = await changeAs(
|
||||
'pw.format@e2e.arasystem.test',
|
||||
payload,
|
||||
'198.51.100.202',
|
||||
).expect(400);
|
||||
expect(body(res).error?.code).toBe('VAL_001');
|
||||
}
|
||||
expect(passwordChanges).toHaveLength(before);
|
||||
});
|
||||
|
||||
it('틀린 지금 비밀번호는 로그인 실패와 같은 한도 — 같은 주소로 10번이 넘으면 맞는 비밀번호도 429 REQ_001, 그 주소의 로그인도 막힌다', async () => {
|
||||
const email = 'pw.locked@e2e.arasystem.test';
|
||||
for (let i = 0; i < 10; i += 1) {
|
||||
const res = await changeAs(
|
||||
email,
|
||||
{ currentPassword: 'wrong-password', newPassword: NEW_PASSWORD },
|
||||
`198.51.100.${210 + i}`,
|
||||
).expect(400);
|
||||
expect(body(res).error?.code).toBe('AUTH_005');
|
||||
}
|
||||
|
||||
const locked = await changeAs(
|
||||
email,
|
||||
{ currentPassword: CORRECT_PASSWORD, newPassword: NEW_PASSWORD },
|
||||
'198.51.100.230',
|
||||
).expect(429);
|
||||
expect(body(locked).error?.code).toBe('REQ_001');
|
||||
|
||||
const login = await request(server)
|
||||
.post('/api/auth/login')
|
||||
.set('X-Forwarded-For', '198.51.100.231')
|
||||
.send({ email, password: CORRECT_PASSWORD })
|
||||
.expect(429);
|
||||
expect(body(login).error?.code).toBe('REQ_001');
|
||||
});
|
||||
});
|
||||
|
||||
describe('메일함 관리 (/api/mailboxes)', () => {
|
||||
const ME = 'box.me@e2e.arasystem.test';
|
||||
const send = (
|
||||
method: 'post' | 'patch' | 'delete',
|
||||
path: string,
|
||||
payload?: object,
|
||||
) => {
|
||||
const req = request(server)[method](path).set('Cookie', cookieOf(ME));
|
||||
return payload === undefined ? req : req.send(payload);
|
||||
};
|
||||
|
||||
it('만들기는 201 Mailbox — 이름은 앞뒤 공백을 빼고, 상위가 없으면 맨 위(null)로 엔진에 넘긴다', async () => {
|
||||
const res = await send('post', '/api/mailboxes', {
|
||||
name: ' 거래처 ',
|
||||
}).expect(201);
|
||||
|
||||
expect(body(res)).toEqual({
|
||||
success: true,
|
||||
data: {
|
||||
id: 'newbox',
|
||||
name: '거래처',
|
||||
role: null,
|
||||
parentId: null,
|
||||
sortOrder: 0,
|
||||
totalCount: 0,
|
||||
unreadCount: 0,
|
||||
},
|
||||
});
|
||||
expect(mailboxCalls.at(-1)).toEqual({
|
||||
user: ME,
|
||||
action: 'create',
|
||||
args: [{ name: '거래처', parentId: null }],
|
||||
});
|
||||
|
||||
await send('post', '/api/mailboxes', {
|
||||
name: '하위',
|
||||
parentId: 'a',
|
||||
}).expect(201);
|
||||
expect(mailboxCalls.at(-1)?.args).toEqual([
|
||||
{ name: '하위', parentId: 'a' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('이름 형식 · 상위 id 형식 · 모르는 필드는 400 VAL_001 — 엔진에 묻지 않는다', async () => {
|
||||
const before = mailboxCalls.length;
|
||||
|
||||
for (const payload of [
|
||||
{},
|
||||
{ name: '' },
|
||||
{ name: ' ' },
|
||||
{ name: 'x'.repeat(65) },
|
||||
{ name: '거래처/2026' },
|
||||
{ name: '거래\t처' },
|
||||
{ name: '거래처', parentId: '../a' },
|
||||
{ name: '거래처', role: 'inbox' },
|
||||
]) {
|
||||
const res = await send('post', '/api/mailboxes', payload).expect(400);
|
||||
expect(body(res).error?.code).toBe('VAL_001');
|
||||
}
|
||||
expect(mailboxCalls).toHaveLength(before);
|
||||
});
|
||||
|
||||
it('같은 상위 안에 같은 이름은 409 MAIL_009, 없는 상위는 404 RES_001', async () => {
|
||||
const taken = await send('post', '/api/mailboxes', {
|
||||
name: TAKEN_MAILBOX_NAME,
|
||||
}).expect(409);
|
||||
expect(body(taken).error?.code).toBe('MAIL_009');
|
||||
|
||||
const orphan = await send('post', '/api/mailboxes', {
|
||||
name: '거래처',
|
||||
parentId: MISSING_MAILBOX,
|
||||
}).expect(404);
|
||||
expect(body(orphan).error?.code).toBe('RES_001');
|
||||
});
|
||||
|
||||
it('이름 바꾸기는 200 Mailbox — 형식이 틀린 id 는 404 RES_001, 상위는 바꾸지 않는다(모르는 필드)', async () => {
|
||||
const res = await send('patch', '/api/mailboxes/u1', {
|
||||
name: ' 새 이름 ',
|
||||
}).expect(200);
|
||||
expect(dataOf<{ name: string }>(res).name).toBe('새 이름');
|
||||
expect(mailboxCalls.at(-1)).toEqual({
|
||||
user: ME,
|
||||
action: 'rename',
|
||||
args: ['u1', '새 이름'],
|
||||
});
|
||||
|
||||
const malformed = await send('patch', '/api/mailboxes/a%20b', {
|
||||
name: '새 이름',
|
||||
}).expect(404);
|
||||
expect(body(malformed).error?.code).toBe('RES_001');
|
||||
|
||||
const move = await send('patch', '/api/mailboxes/u1', {
|
||||
name: '새 이름',
|
||||
parentId: 'a',
|
||||
}).expect(400);
|
||||
expect(body(move).error?.code).toBe('VAL_001');
|
||||
});
|
||||
|
||||
it('지우기는 204 가 아니라 200 { deleted, processed, movedToTrash, remaining }', async () => {
|
||||
const res = await send('delete', '/api/mailboxes/u1').expect(200);
|
||||
|
||||
expect(body(res)).toEqual({
|
||||
success: true,
|
||||
data: { deleted: true, processed: 4, movedToTrash: 3, remaining: 0 },
|
||||
});
|
||||
expect(mailboxCalls.at(-1)).toEqual({
|
||||
user: ME,
|
||||
action: 'delete',
|
||||
args: ['u1'],
|
||||
});
|
||||
});
|
||||
|
||||
it('전체 읽음은 { isRead: true } 만 받는다 — 200 { processed, remaining }', async () => {
|
||||
const res = await send('patch', '/api/mailboxes/a/messages', {
|
||||
isRead: true,
|
||||
}).expect(200);
|
||||
expect(body(res)).toEqual({
|
||||
success: true,
|
||||
data: { processed: 5, remaining: 0 },
|
||||
});
|
||||
expect(mailboxCalls.at(-1)).toEqual({
|
||||
user: ME,
|
||||
action: 'mark-read',
|
||||
args: ['a'],
|
||||
});
|
||||
|
||||
const before = mailboxCalls.length;
|
||||
for (const payload of [
|
||||
{ isRead: false },
|
||||
{},
|
||||
{ isRead: true, isFlagged: true },
|
||||
]) {
|
||||
const rejected = await send(
|
||||
'patch',
|
||||
'/api/mailboxes/a/messages',
|
||||
payload,
|
||||
).expect(400);
|
||||
expect(body(rejected).error?.code).toBe('VAL_001');
|
||||
}
|
||||
expect(mailboxCalls).toHaveLength(before);
|
||||
});
|
||||
|
||||
it('비우기는 200 { processed, remaining }', async () => {
|
||||
const res = await send('delete', '/api/mailboxes/b/messages').expect(
|
||||
200,
|
||||
);
|
||||
|
||||
expect(body(res)).toEqual({
|
||||
success: true,
|
||||
data: { processed: 2, remaining: 0 },
|
||||
});
|
||||
expect(mailboxCalls.at(-1)).toEqual({
|
||||
user: ME,
|
||||
action: 'empty',
|
||||
args: ['b'],
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* 같은 메일함의 처리 중 잠금 — 대역 엔진의 SLOW_MAILBOX 비우기를 문으로 붙잡아 둔 채 다음 요청을 보낸다.
|
||||
* 붙잡힌 요청이 있는 동안 supertest 가 서버를 닫지 않게 서버를 미리 열어 둔다 (로그인 동시 시도 테스트와 같은 이유)
|
||||
*/
|
||||
it(
|
||||
'같은 직원 · 같은 메일함의 오래 걸리는 처리가 처리 중이면 종류가 달라도 엔진에 묻지 않고 429 REQ_001 — 다른 메일함은 그대로, 끝나면 다시 받는다',
|
||||
async () => {
|
||||
const httpServer = app.getHttpServer();
|
||||
const startedServer = !httpServer.listening;
|
||||
if (startedServer) await listenOnAnyPort(httpServer);
|
||||
|
||||
const gate = createGate();
|
||||
slowMailboxGate = gate.opened;
|
||||
const before = mailboxCalls.length;
|
||||
const first = send(
|
||||
'delete',
|
||||
`/api/mailboxes/${SLOW_MAILBOX}/messages`,
|
||||
).then((res) => res);
|
||||
|
||||
try {
|
||||
await waitUntil(
|
||||
() => mailboxCalls.length > before,
|
||||
GATE_DEADLINE_MS,
|
||||
);
|
||||
expect(mailboxCalls.slice(before)).toEqual([
|
||||
{ user: ME, action: 'empty', args: [SLOW_MAILBOX] },
|
||||
]);
|
||||
|
||||
for (const [method, path, payload] of [
|
||||
['delete', `/api/mailboxes/${SLOW_MAILBOX}/messages`, undefined],
|
||||
[
|
||||
'patch',
|
||||
`/api/mailboxes/${SLOW_MAILBOX}/messages`,
|
||||
{ isRead: true },
|
||||
],
|
||||
['delete', `/api/mailboxes/${SLOW_MAILBOX}`, undefined],
|
||||
] as const) {
|
||||
const busy = await send(method, path, payload).expect(429);
|
||||
expect(body(busy).error?.code).toBe('REQ_001');
|
||||
}
|
||||
expect(mailboxCalls).toHaveLength(before + 1);
|
||||
|
||||
// 같은 때 같은 IP 에서 다른 메일함은 된다 — 위의 429 는 요청 수 한도가 아니다
|
||||
await send('patch', '/api/mailboxes/a/messages', {
|
||||
isRead: true,
|
||||
}).expect(200);
|
||||
} finally {
|
||||
// 실패해도 문을 열고 붙잡힌 요청이 끝나기를 기다린다 — 다음 테스트 · 앱 종료가 멈추지 않게
|
||||
gate.open();
|
||||
slowMailboxGate = null;
|
||||
await Promise.allSettled([first]);
|
||||
if (startedServer) await closeServer(httpServer);
|
||||
}
|
||||
|
||||
expect((await first).status).toBe(200);
|
||||
await send(
|
||||
'delete',
|
||||
`/api/mailboxes/${SLOW_MAILBOX}/messages`,
|
||||
).expect(200);
|
||||
},
|
||||
GATED_TEST_TIMEOUT_MS,
|
||||
);
|
||||
});
|
||||
|
||||
describe('부재중 자동 응답 (/api/mail-vacation)', () => {
|
||||
const ME = 'vacation.me@e2e.arasystem.test';
|
||||
const vacation = {
|
||||
isEnabled: true,
|
||||
fromDate: '2026-11-02T15:00:00.000Z',
|
||||
toDate: '2026-11-07T15:00:00.000Z',
|
||||
subject: '부재중입니다',
|
||||
textBody: '11월 7일까지 휴가입니다.',
|
||||
};
|
||||
const put = (payload: object) =>
|
||||
request(server)
|
||||
.put('/api/mail-vacation')
|
||||
.set('Cookie', cookieOf(ME))
|
||||
.send(payload);
|
||||
|
||||
it('한 번도 설정하지 않았으면 끈 상태 · 빈 제목과 내용 — 받은 그대로 다시 저장해도 200', async () => {
|
||||
const res = await request(server)
|
||||
.get('/api/mail-vacation')
|
||||
.set('Cookie', cookieOf(ME))
|
||||
.expect(200);
|
||||
expect(body(res)).toEqual({
|
||||
success: true,
|
||||
data: NEVER_CONFIGURED_VACATION,
|
||||
});
|
||||
|
||||
const restored = await put(dataOf<object>(res)).expect(200);
|
||||
expect(dataOf<unknown>(restored)).toEqual(NEVER_CONFIGURED_VACATION);
|
||||
});
|
||||
|
||||
it('저장하면(전체 교체) 엔진이 저장한 값을 돌려주고, 다음 조회도 그 값이다', async () => {
|
||||
const res = await put(vacation).expect(200);
|
||||
expect(dataOf<unknown>(res)).toEqual(vacation);
|
||||
expect(vacationSaves.at(-1)).toEqual({ user: ME, vacation });
|
||||
|
||||
const read = await request(server)
|
||||
.get('/api/mail-vacation')
|
||||
.set('Cookie', cookieOf(ME))
|
||||
.expect(200);
|
||||
expect(dataOf<unknown>(read)).toEqual(vacation);
|
||||
});
|
||||
|
||||
it('켤 때 빈(공백뿐인) 제목 · 내용, 제어 문자, 시작이 끝보다 늦음 · 같음(같은 초 포함), 날짜 형식, 빠진 키, 길이 초과, 서식 본문은 400 VAL_001 — 저장하지 않는다', async () => {
|
||||
const before = vacationSaves.length;
|
||||
const withoutToDate = Object.fromEntries(
|
||||
Object.entries(vacation).filter(([key]) => key !== 'toDate'),
|
||||
);
|
||||
|
||||
for (const payload of [
|
||||
{ ...vacation, subject: '' },
|
||||
{ ...vacation, textBody: '' },
|
||||
{ ...vacation, toDate: vacation.fromDate },
|
||||
{ ...vacation, toDate: '2026-11-01T15:00:00.000Z' },
|
||||
{ ...vacation, fromDate: '2026-11-02' },
|
||||
{ ...vacation, fromDate: '2026-11-02T15:00:00+09:00' },
|
||||
withoutToDate,
|
||||
{ ...vacation, subject: 'x'.repeat(201) },
|
||||
{ ...vacation, textBody: 'x'.repeat(4_001) },
|
||||
{ ...vacation, htmlBody: '<p>휴가</p>' },
|
||||
{ ...vacation, subject: ' ' },
|
||||
{ ...vacation, textBody: ' \n\t ' },
|
||||
{ ...vacation, subject: '부재\r\n중' },
|
||||
{ ...vacation, textBody: '휴가\u0000' },
|
||||
{ ...vacation, textBody: '휴가\u001b[2J' },
|
||||
{
|
||||
...vacation,
|
||||
fromDate: '2026-11-02T15:00:00.100Z',
|
||||
toDate: '2026-11-02T15:00:00.900Z',
|
||||
},
|
||||
]) {
|
||||
const res = await put(payload).expect(400);
|
||||
expect(body(res).error?.code).toBe('VAL_001');
|
||||
}
|
||||
expect(vacationSaves).toHaveLength(before);
|
||||
});
|
||||
|
||||
it('꺼 둔 채로는 공백뿐인 제목도 저장한다 — 쓴 그대로(내용의 탭 · 줄바꿈 포함), 날짜는 소수 초를 버려 저장한다', async () => {
|
||||
const off = {
|
||||
...vacation,
|
||||
isEnabled: false,
|
||||
fromDate: '2026-11-02T15:00:00.750Z',
|
||||
toDate: '2026-11-07T15:00:00.250Z',
|
||||
subject: ' ',
|
||||
textBody: '안녕하세요.\r\n\t11월 7일까지 휴가입니다.\n',
|
||||
};
|
||||
|
||||
const res = await put(off).expect(200);
|
||||
|
||||
const saved = {
|
||||
...off,
|
||||
fromDate: '2026-11-02T15:00:00.000Z',
|
||||
toDate: '2026-11-07T15:00:00.000Z',
|
||||
};
|
||||
expect(dataOf<unknown>(res)).toEqual(saved);
|
||||
expect(vacationSaves.at(-1)).toEqual({ user: ME, vacation: saved });
|
||||
});
|
||||
});
|
||||
|
||||
describe('스팸 신고 · 스팸 아님 (isJunk)', () => {
|
||||
const ME = 'junk.me@e2e.arasystem.test';
|
||||
|
||||
it('한 통 PATCH 는 이동과 함께 isJunk 를 엔진에 넘긴다 — true · false 가 아니면 400 VAL_001', async () => {
|
||||
await request(server)
|
||||
.patch('/api/messages/eaaaaab')
|
||||
.set('Cookie', cookieOf(ME))
|
||||
.send({ mailboxId: 'c', isJunk: true })
|
||||
.expect(200);
|
||||
expect(singleUpdates.at(-1)).toEqual({
|
||||
user: ME,
|
||||
id: 'eaaaaab',
|
||||
update: { mailboxId: 'c', isJunk: true },
|
||||
});
|
||||
|
||||
const before = singleUpdates.length;
|
||||
for (const isJunk of ['yes', null, 1]) {
|
||||
const res = await request(server)
|
||||
.patch('/api/messages/eaaaaab')
|
||||
.set('Cookie', cookieOf(ME))
|
||||
.send({ isJunk })
|
||||
.expect(400);
|
||||
expect(body(res).error?.code).toBe('VAL_001');
|
||||
}
|
||||
expect(singleUpdates).toHaveLength(before);
|
||||
});
|
||||
|
||||
it('여러 통 PATCH 도 isJunk 만으로 바꿀 수 있다 (스팸 아님)', async () => {
|
||||
await request(server)
|
||||
.patch('/api/messages')
|
||||
.set('Cookie', cookieOf(ME))
|
||||
.send({ ids: ['eaaaaab'], mailboxId: 'a', isJunk: false })
|
||||
.expect(200);
|
||||
|
||||
expect(bulkUpdates.at(-1)).toEqual({
|
||||
user: ME,
|
||||
ids: ['eaaaaab'],
|
||||
update: { mailboxId: 'a', isJunk: false },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('원문 내려받기 (GET /api/messages/{id}/source)', () => {
|
||||
const ME = 'source.me@e2e.arasystem.test';
|
||||
|
||||
it('표준 래퍼 없이 message/rfc822 원문 — attachment(제목으로 만든 .eml) · private, no-store · nosniff', async () => {
|
||||
const res = await request(server)
|
||||
.get('/api/messages/eaaaaab/source')
|
||||
.set('Cookie', cookieOf(ME))
|
||||
.responseType('blob')
|
||||
.expect(200);
|
||||
|
||||
expect(res.headers['content-type']).toBe('message/rfc822');
|
||||
expect(res.headers['content-disposition']).toBe(
|
||||
buildAttachmentDisposition('Re_ 견적서_10월.eml'),
|
||||
);
|
||||
expect(res.headers['cache-control']).toBe('private, no-store');
|
||||
expect(res.headers['x-content-type-options']).toBe('nosniff');
|
||||
expect(res.headers['content-length']).toBe(
|
||||
String(Buffer.byteLength(RAW_MESSAGE, 'utf8')),
|
||||
);
|
||||
expect((res.body as Buffer).toString('utf8')).toBe(RAW_MESSAGE);
|
||||
});
|
||||
|
||||
it('없는 메일 · 형식이 틀린 id 는 표준 실패 형식의 404 RES_001', async () => {
|
||||
for (const id of [GONE_MESSAGE, 'a%20b']) {
|
||||
const res = await request(server)
|
||||
.get(`/api/messages/${id}/source`)
|
||||
.set('Cookie', cookieOf(ME))
|
||||
.expect(404);
|
||||
expect(body(res).error?.code).toBe('RES_001');
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* 출처 검사(CSRF) — 상태를 바꾸는 요청은 Sec-Fetch-Site(same-origin·none) 또는 허용 목록의 Origin 이어야 한다.
|
||||
* 둘 다 없으면 브라우저가 아닌 클라이언트로 보고 통과시킨다 (docs/mail/api-contract.md 머리말).
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
<!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}
|
||||
.danger{background:transparent;color:#B42318}
|
||||
.danger:hover{background:#FEF3F2;color:#912018}
|
||||
.menu-item{background:transparent;color:#171C26}
|
||||
.menu-item:hover{background:#F2F4F7}
|
||||
</style>
|
||||
</helmet>
|
||||
<div style="width: 1440px; min-height: 1320px; 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: 900px">
|
||||
<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">4단계에서 읽기 화면 · 도구 줄 · 알림에 더해지는 것들입니다. 메일함 만들기 · 이름 바꾸기 · 지우기와 휴지통 비우기는 「메일함 관리」 보드, 비밀번호와 부재중 자동 응답은 설정 보드에서 눌러 볼 수 있습니다.</p>
|
||||
</div>
|
||||
|
||||
<div style="display: grid; grid-template-columns: repeat(2, 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>
|
||||
<div style="position: relative; display: flex; flex-wrap: wrap; align-items: center; gap: 2px; min-height: 52px; box-sizing: border-box; padding: 4px 8px; border: 1px solid #E6E8EC; border-radius: 10px">
|
||||
<button type="button" class="ghost" style="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.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></button>
|
||||
<button type="button" class="ghost" style="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.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></button>
|
||||
<button type="button" class="ghost" style="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.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></button>
|
||||
<span aria-hidden="true" style="width: 1px; height: 20px; margin: 0 6px; background: #E6E8EC"></span>
|
||||
<button type="button" class="ghost" style="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.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polygon points="7.86 2 16.14 2 22 7.86 22 16.14 16.14 22 7.86 22 2 16.14 2 7.86 7.86 2"></polygon><line x1="12" y1="8" x2="12" y2="12"></line><line x1="12" y1="16" x2="12.01" y2="16"></line></svg><span>스팸 신고</span></button>
|
||||
<button type="button" class="danger" style="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.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 style="position: relative">
|
||||
<button type="button" class="ghost" aria-label="더 보기" aria-haspopup="menu" aria-expanded="true" title="더 보기" style="width: 40px; height: 44px; display: inline-flex; align-items: center; justify-content: center; border: 0; border-radius: 8px; cursor: pointer"><svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><circle cx="5" cy="12" r="1.6"></circle><circle cx="12" cy="12" r="1.6"></circle><circle cx="19" cy="12" r="1.6"></circle></svg></button>
|
||||
<div role="menu" aria-label="더 보기" style="position: absolute; top: 46px; right: 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)">
|
||||
<button type="button" role="menuitem" class="menu-item" style="display: flex; align-items: center; gap: 10px; min-height: 40px; padding: 0 10px; border: 0; border-radius: 6px; font: inherit; text-align: left; 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 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path><polyline points="7 10 12 15 17 10"></polyline><line x1="12" y1="15" x2="12" y2="3"></line></svg><span>원문 내려받기 (.eml)</span></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p style="margin: 0; padding-top: 52px; font-size: 12.5px; color: #4A5363">「스팸 신고」는 메일을 스팸메일함으로 옮기고 스팸 표시를 붙입니다(알림에 [되돌리기]). 「원문 내려받기」는 받은 그대로의 메일을 .eml 파일로 내려받습니다 — 다른 메일 프로그램에서 열거나 보관할 때 씁니다.</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>
|
||||
<div style="display: flex; flex-wrap: wrap; align-items: center; gap: 2px; min-height: 52px; box-sizing: border-box; padding: 4px 8px; border: 1px solid #E6E8EC; border-radius: 10px">
|
||||
<button type="button" class="ghost" style="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.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></button>
|
||||
<button type="button" class="ghost" style="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.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></button>
|
||||
<span aria-hidden="true" style="width: 1px; height: 20px; margin: 0 6px; background: #E6E8EC"></span>
|
||||
<button type="button" class="ghost" style="display: inline-flex; align-items: center; gap: 6px; min-height: 44px; padding: 0 10px; border: 0; border-radius: 8px; font: inherit; font-weight: 600; color: #0A5F67; 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="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><span>스팸 아님</span></button>
|
||||
<button type="button" class="danger" style="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.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>
|
||||
<p style="margin: 0; font-size: 12.5px; color: #4A5363">「스팸 아님」은 받은편지함으로 되돌리고 스팸 표시를 「스팸 아님」으로 바꿉니다(알림에 [되돌리기]).</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>
|
||||
<div role="toolbar" aria-label="고른 메일" style="display: flex; flex-wrap: wrap; align-items: center; gap: 2px; min-height: 52px; box-sizing: border-box; padding: 4px 8px 4px 14px; border: 1px solid #E6E8EC; border-radius: 10px; background: #F3F8F8">
|
||||
<span style="margin-right: 10px; font-size: 14px; font-weight: 700">3통 선택됨</span>
|
||||
<button type="button" class="ghost" style="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"><span>읽음 표시</span></button>
|
||||
<button type="button" class="ghost" style="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"><span>이동</span><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="6 9 12 15 18 9"></polyline></svg></button>
|
||||
<button type="button" class="ghost" style="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.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polygon points="7.86 2 16.14 2 22 7.86 22 16.14 16.14 22 7.86 22 2 16.14 2 7.86 7.86 2"></polygon><line x1="12" y1="8" x2="12" y2="12"></line><line x1="12" y1="16" x2="12.01" y2="16"></line></svg><span>스팸 신고</span></button>
|
||||
<button type="button" class="danger" style="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"><span>삭제</span></button>
|
||||
</div>
|
||||
<p style="margin: 0; font-size: 12.5px; color: #4A5363">스팸메일함에서 고르면 「스팸 신고」가 「스팸 아님」이 됩니다. 좁은 목록 칸에서는 지금처럼 아이콘만 보이고 이름은 툴팁으로 읽힙니다.</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>
|
||||
<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="#9ED6DB" stroke-width="2" stroke-linecap="round" aria-hidden="true" style="flex: 0 0 auto"><path d="M21 12a9 9 0 1 1-6.22-8.56"></path></svg>
|
||||
<span style="flex: 1 1 auto; font-weight: 500">받은편지함의 메일을 읽음으로 표시하는 중 · <span style="font-variant-numeric: tabular-nums">600 / 1,234통</span></span>
|
||||
</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">받은편지함의 메일 1,234통을 읽음으로 표시했습니다.</span>
|
||||
</div>
|
||||
<p style="margin: 0; font-size: 12.5px; color: #4A5363">처리하는 동안 다른 메일을 읽고 써도 됩니다. 도중에 실패하면 "1,234통 가운데 600통까지 처리했습니다 — 다시 시도해 주세요." 로 알리고, 다시 하면 남은 것만 처리합니다.</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">오른쪽 아래 · 되돌리기가 있으면 10초</p>
|
||||
</div>
|
||||
<div style="display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px">
|
||||
<div role="status" style="display: flex; align-items: center; gap: 10px; padding: 6px 6px 6px 14px; border-radius: 10px; background: #0B3640; color: #FFFFFF">
|
||||
<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">메일 2통을 스팸메일함으로 옮겼습니다.</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">
|
||||
<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">메일 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">
|
||||
<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">‘거래처’ 메일함을 지웠습니다. 안의 메일 12통은 휴지통에 있습니다.</span>
|
||||
</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">
|
||||
<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">휴지통을 비웠습니다 — 메일 21통.</span>
|
||||
</div>
|
||||
</div>
|
||||
<p style="margin: 0; font-size: 12.5px; color: #4A5363">메일함 만들기 · 이름 바꾸기는 메일함 패널에 바로 나타나므로 짧은 알림("‘협력사’ 메일함을 만들었습니다.")만 띄웁니다. 비우기 · 메일함 지우기는 되돌릴 수 없어 [되돌리기]가 없습니다.</p>
|
||||
</section>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</x-dc>
|
||||
<script type="text/x-dc" data-dc-script data-props='{"$preview":{"width":1440,"height":1320}}'>
|
||||
class Component extends DCLogic {
|
||||
renderVals() {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,426 @@
|
||||
<!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}
|
||||
.danger{background:transparent;color:#B42318}
|
||||
.danger:hover{background:#FEF3F2;color:#912018}
|
||||
.mb-row{position:relative}
|
||||
.mb-row .mb-more{opacity:0}
|
||||
.mb-row:hover .mb-more,.mb-row:focus-within .mb-more,.mb-row.is-open .mb-more{opacity:1}
|
||||
.mb-more:hover{background:#E6E8EC}
|
||||
.menu-item{background:transparent;color:#171C26}
|
||||
.menu-item:hover{background:#F2F4F7}
|
||||
.menu-item.is-danger{color:#B42318}
|
||||
.menu-item.is-danger:hover{background:#FEF3F2}
|
||||
.field{box-sizing:border-box;border:1px solid #D0D5DD;border-radius:8px;background:#FFFFFF;font:inherit;color:#171C26}
|
||||
.field:focus{outline:none;border-color:#0E7C86;box-shadow:0 0 0 1px #0E7C86}
|
||||
.field.is-error{border-color:#B42318}
|
||||
</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>
|
||||
<div style="display: flex; flex-direction: column; gap: 1px">
|
||||
<ul style="list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 1px">
|
||||
<sc-for list="{{ roleBoxes }}" as="b" hint-placeholder-count="5">
|
||||
<li class="{{ b.rowClass }}" style="display: flex; align-items: center; border-radius: 7px; background: {{ b.bg }}">
|
||||
<button type="button" onClick="{{ b.pick }}" aria-current="{{ b.current }}" style="flex: 1 1 auto; min-width: 0; display: flex; align-items: center; gap: 10px; min-height: 40px; box-sizing: border-box; padding: 0 4px 0 10px; border: 0; background: transparent; 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; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap">{{ 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>
|
||||
<button type="button" class="mb-more" onClick="{{ b.toggleMenu }}" aria-label="{{ b.moreLabel }}" aria-haspopup="menu" aria-expanded="{{ b.openStr }}" style="flex: 0 0 auto; width: 32px; height: 40px; display: inline-flex; align-items: center; justify-content: center; border: 0; border-radius: 6px; background: transparent; color: #4A5363; cursor: pointer">
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><circle cx="5" cy="12" r="1.6"></circle><circle cx="12" cy="12" r="1.6"></circle><circle cx="19" cy="12" r="1.6"></circle></svg>
|
||||
</button>
|
||||
<sc-if value="{{ b.open }}" hint-placeholder-val="{{ false }}">
|
||||
<div role="menu" aria-label="{{ b.menuLabel }}" style="position: absolute; top: 40px; right: 0; z-index: 6; min-width: 196px; 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="{{ b.items }}" as="it" hint-placeholder-count="2">
|
||||
<button type="button" role="menuitem" class="{{ it.cls }}" onClick="{{ it.run }}" style="display: flex; align-items: center; min-height: 40px; box-sizing: border-box; padding: 0 10px; border: 0; border-radius: 6px; font: inherit; text-align: left; cursor: pointer">{{ it.label }}</button>
|
||||
</sc-for>
|
||||
</div>
|
||||
</sc-if>
|
||||
</li>
|
||||
</sc-for>
|
||||
</ul>
|
||||
<p style="margin: 12px 10px 4px; font-size: 12px; font-weight: 600; color: #667085">내 메일함</p>
|
||||
<ul style="list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 1px">
|
||||
<sc-for list="{{ myBoxes }}" as="b" hint-placeholder-count="3">
|
||||
<li class="{{ b.rowClass }}" style="display: flex; align-items: center; border-radius: 7px; background: {{ b.bg }}">
|
||||
<button type="button" onClick="{{ b.pick }}" aria-current="{{ b.current }}" style="flex: 1 1 auto; min-width: 0; display: flex; align-items: center; gap: 10px; min-height: 40px; box-sizing: border-box; padding: 0 4px 0 {{ b.indent }}px; border: 0; background: transparent; color: {{ b.fg }}; font: inherit; font-weight: {{ b.fw }}; text-align: left; 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="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"></path></svg>
|
||||
<span style="flex: 1 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap">{{ b.name }}</span>
|
||||
<sc-if value="{{ b.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>
|
||||
<button type="button" class="mb-more" onClick="{{ b.toggleMenu }}" aria-label="{{ b.moreLabel }}" aria-haspopup="menu" aria-expanded="{{ b.openStr }}" style="flex: 0 0 auto; width: 32px; height: 40px; display: inline-flex; align-items: center; justify-content: center; border: 0; border-radius: 6px; background: transparent; color: #4A5363; cursor: pointer">
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><circle cx="5" cy="12" r="1.6"></circle><circle cx="12" cy="12" r="1.6"></circle><circle cx="19" cy="12" r="1.6"></circle></svg>
|
||||
</button>
|
||||
<sc-if value="{{ b.open }}" hint-placeholder-val="{{ false }}">
|
||||
<div role="menu" aria-label="{{ b.menuLabel }}" style="position: absolute; top: 40px; right: 0; z-index: 6; min-width: 196px; 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="{{ b.items }}" as="it" hint-placeholder-count="4">
|
||||
<button type="button" role="menuitem" class="{{ it.cls }}" onClick="{{ it.run }}" style="display: flex; align-items: center; min-height: 40px; box-sizing: border-box; padding: 0 10px; border: 0; border-radius: 6px; font: inherit; text-align: left; cursor: pointer">{{ it.label }}</button>
|
||||
</sc-for>
|
||||
</div>
|
||||
</sc-if>
|
||||
</li>
|
||||
</sc-for>
|
||||
</ul>
|
||||
<button type="button" class="ghost" onClick="{{ openNewTop }}" style="display: flex; align-items: center; gap: 10px; min-height: 44px; margin-top: 2px; box-sizing: border-box; padding: 0 10px; border: 0; border-radius: 7px; font: inherit; font-weight: 500; text-align: left; 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"><line x1="12" y1="5" x2="12" y2="19"></line><line x1="5" y1="12" x2="19" y2="12"></line></svg>
|
||||
<span>새 메일함</span>
|
||||
</button>
|
||||
</div>
|
||||
<div style="margin-top: auto; display: flex; flex-direction: column; gap: 10px">
|
||||
<a href="SettingsVacation.dc.html" style="display: flex; align-items: flex-start; gap: 8px; min-height: 44px; box-sizing: border-box; padding: 8px 10px; border-radius: 8px; background: #FFF7E6; color: #7A5200; 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" style="flex: 0 0 auto; margin-top: 2px"><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 style="display: flex; flex-direction: column; line-height: 1.4"><span style="font-weight: 600">부재중 자동 응답 켜짐</span><span style="font-size: 12px">11월 3일까지 · 설정</span></span>
|
||||
</a>
|
||||
<div style="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>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<main style="flex: 999 1 560px; min-width: 0; display: flex; flex-wrap: wrap; align-items: stretch">
|
||||
<section aria-labelledby="list-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: 8px; min-height: 52px; box-sizing: border-box; padding: 4px 4px 4px 16px; 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>
|
||||
<sc-if value="{{ canEmpty }}" hint-placeholder-val="{{ false }}">
|
||||
<button type="button" class="danger" onClick="{{ openEmpty }}" style="margin-left: auto; display: inline-flex; align-items: center; gap: 6px; min-height: 44px; padding: 0 12px; border: 0; border-radius: 8px; font: inherit; font-weight: 600; 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>{{ emptyLabel }}</span>
|
||||
</button>
|
||||
</sc-if>
|
||||
</div>
|
||||
<sc-if value="{{ hasItems }}" hint-placeholder-val="{{ true }}">
|
||||
<ul aria-label="{{ listLabel }}" style="list-style: none; margin: 0; padding: 0">
|
||||
<sc-for list="{{ items }}" as="item" hint-placeholder-count="4">
|
||||
<li 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="font-weight: {{ item.fw }}; color: {{ item.whoColor }}">{{ item.who }}</span><span style="font-size: 12px; color: #667085; font-variant-numeric: tabular-nums">{{ item.time }}</span></span>
|
||||
<span style="overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-weight: {{ item.subFw }}">{{ item.subject }}</span>
|
||||
</li>
|
||||
</sc-for>
|
||||
</ul>
|
||||
</sc-if>
|
||||
<sc-if value="{{ noItems }}" hint-placeholder-val="{{ false }}">
|
||||
<div style="display: flex; flex-direction: column; align-items: center; gap: 6px; padding: 80px 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>
|
||||
<div style="flex: 999 1 480px; min-width: 0; 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>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<sc-if value="{{ dialogOpen }}" 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="{{ dialogRole }}" aria-modal="true" aria-labelledby="mb-dialog-title" 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: 10px">
|
||||
<h3 id="mb-dialog-title" style="margin: 0; font-size: 15px; font-weight: 700">{{ dialogTitle }}</h3>
|
||||
<sc-if value="{{ dialogHasName }}" hint-placeholder-val="{{ false }}">
|
||||
<div style="display: flex; flex-direction: column; gap: 6px">
|
||||
<label for="mb-name" style="font-size: 12.5px; font-weight: 600; color: #4A5363">이름</label>
|
||||
<input id="mb-name" class="{{ nameClass }}" type="text" value="{{ nameValue }}" maxlength="64" aria-invalid="{{ nameInvalid }}" aria-describedby="mb-name-help" style="height: 44px; padding: 0 12px">
|
||||
<p id="mb-name-help" style="margin: 0; font-size: 12.5px; color: {{ nameHelpColor }}">{{ nameHelp }}</p>
|
||||
</div>
|
||||
</sc-if>
|
||||
<sc-if value="{{ dialogHasText }}" hint-placeholder-val="{{ false }}">
|
||||
<p style="margin: 0; color: #4A5363">{{ dialogText }}</p>
|
||||
</sc-if>
|
||||
<div style="display: flex; justify-content: flex-end; gap: 8px; margin-top: 4px">
|
||||
<sc-if value="{{ dialogHasConfirm }}" hint-placeholder-val="{{ true }}">
|
||||
<button type="button" onClick="{{ confirmDialog }}" style="min-height: 44px; padding: 0 16px; border: 0; border-radius: 8px; background: {{ confirmBg }}; color: #FFFFFF; font: inherit; font-weight: 600; cursor: pointer">{{ confirmLabel }}</button>
|
||||
</sc-if>
|
||||
<button type="button" class="ghost" onClick="{{ closeDialog }}" style="min-height: 44px; padding: 0 14px; border: 0; border-radius: 8px; font: inherit; font-weight: 500; cursor: pointer">{{ cancelLabel }}</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; min-height: 44px; 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">{{ toastText }}</span>
|
||||
</div>
|
||||
</sc-if>
|
||||
</div>
|
||||
</x-dc>
|
||||
<script type="text/x-dc" data-dc-script data-props='{"nameError":{"editor":"boolean","default":false},"$preview":{"width":1440,"height":900}}'>
|
||||
class Component extends DCLogic {
|
||||
state = {
|
||||
box: 'trash',
|
||||
menu: null,
|
||||
dialog: null,
|
||||
toast: null,
|
||||
read: {},
|
||||
emptied: {},
|
||||
mine: [
|
||||
{ id: 'p', name: '프로젝트', parent: null, unread: 2, count: 18 },
|
||||
{ id: 'pq', name: '견적', parent: 'p', unread: 0, count: 6 },
|
||||
{ id: 'c', name: '거래처', parent: null, unread: 1, count: 12 },
|
||||
],
|
||||
};
|
||||
|
||||
renderVals() {
|
||||
const st = this.state;
|
||||
const ROLES = [
|
||||
{ id: 'inbox', name: '받은편지함', unread: 3, count: 148 },
|
||||
{ id: 'sent', name: '보낸편지함', unread: 0, count: 64 },
|
||||
{ id: 'drafts', name: '임시보관함', unread: 0, count: 2 },
|
||||
{ id: 'junk', name: '스팸메일함', unread: 1, count: 5 },
|
||||
{ id: 'trash', name: '휴지통', unread: 0, count: 21 },
|
||||
];
|
||||
// 받침에 맞는 조사 (이/가 · 을/를)
|
||||
const hasFinal = (w) => { const c = w.charCodeAt(w.length - 1); return c >= 0xac00 && c <= 0xd7a3 && (c - 0xac00) % 28 !== 0; };
|
||||
const iga = (w) => w + (hasFinal(w) ? '이' : '가');
|
||||
const eulreul = (w) => w + (hasFinal(w) ? '을' : '를');
|
||||
const unreadOf = (b) => (st.read[b.id] ? 0 : b.unread);
|
||||
const countOf = (b) => (st.emptied[b.id] ? 0 : b.count);
|
||||
const close = () => this.setState({ menu: null });
|
||||
const markRead = (b) => this.setState({ menu: null, read: { ...st.read, [b.id]: true }, toast: `${b.name}의 메일 ${unreadOf(b)}통을 읽음으로 표시했습니다.` });
|
||||
const style = (b) => {
|
||||
const active = b.id === st.box;
|
||||
return {
|
||||
current: active ? 'page' : 'false',
|
||||
bg: active ? '#E3F2F3' : 'transparent',
|
||||
fg: active ? '#0A5F67' : '#344054',
|
||||
fw: active ? '600' : '500',
|
||||
countColor: active ? '#0A5F67' : '#171C26',
|
||||
};
|
||||
};
|
||||
const base = (b, items) => {
|
||||
const open = st.menu === b.id;
|
||||
return {
|
||||
name: b.name,
|
||||
unread: unreadOf(b),
|
||||
hasUnread: unreadOf(b) > 0,
|
||||
...style(b),
|
||||
open: open,
|
||||
openStr: open ? 'true' : 'false',
|
||||
rowClass: open ? 'mb-row is-open' : 'mb-row',
|
||||
moreLabel: `${b.name} 메일함 메뉴`,
|
||||
menuLabel: `${b.name} 메일함`,
|
||||
pick: () => this.setState({ box: b.id, menu: null }),
|
||||
toggleMenu: () => this.setState({ menu: open ? null : b.id }),
|
||||
items: items,
|
||||
};
|
||||
};
|
||||
const readItem = (b) => (unreadOf(b) > 0 ? [{ label: '모두 읽음으로 표시', cls: 'menu-item', run: () => markRead(b) }] : []);
|
||||
|
||||
const roleBoxes = ROLES.map((b) => {
|
||||
const items = [...readItem(b)];
|
||||
if (b.id === 'inbox') items.push({ label: '하위 메일함 만들기', cls: 'menu-item', run: () => this.setState({ menu: null, dialog: { kind: 'new', parent: 'inbox' } }) });
|
||||
if (b.id === 'trash' || b.id === 'junk') items.push({ label: b.id === 'trash' ? '휴지통 비우기' : '스팸메일함 비우기', cls: 'menu-item is-danger', run: () => this.setState({ menu: null, dialog: { kind: 'empty', id: b.id } }) });
|
||||
if (items.length === 0) items.push({ label: '메뉴 닫기', cls: 'menu-item', run: close });
|
||||
return {
|
||||
...base(b, items),
|
||||
isInbox: b.id === 'inbox', isSent: b.id === 'sent', isDrafts: b.id === 'drafts', isJunk: b.id === 'junk', isTrash: b.id === 'trash',
|
||||
};
|
||||
});
|
||||
|
||||
const ordered = [];
|
||||
const visit = (parent, depth) => st.mine.filter((m) => m.parent === parent).forEach((m) => { ordered.push({ m, depth }); visit(m.id, depth + 1); });
|
||||
visit(null, 0);
|
||||
const myBoxes = ordered.map(({ m, depth }) => ({
|
||||
...base(m, [
|
||||
...readItem(m),
|
||||
{ label: '하위 메일함 만들기', cls: 'menu-item', run: () => this.setState({ menu: null, dialog: { kind: 'new', parent: m.id } }) },
|
||||
{ label: '이름 바꾸기', cls: 'menu-item', run: () => this.setState({ menu: null, dialog: { kind: 'rename', id: m.id } }) },
|
||||
{ label: '지우기', cls: 'menu-item is-danger', run: () => this.setState({ menu: null, dialog: { kind: 'delete', id: m.id } }) },
|
||||
]),
|
||||
indent: 10 + depth * 16,
|
||||
}));
|
||||
|
||||
const all = [...ROLES, ...st.mine];
|
||||
const cur = all.find((b) => b.id === st.box) || ROLES[0];
|
||||
const curCount = countOf(cur);
|
||||
const SAMPLE = [
|
||||
{ who: '호스팅 안내', subject: '(광고) 서버 호스팅 연말 할인 안내', time: '10월 21일' },
|
||||
{ who: '세미나 사무국', subject: '[초대] 클라우드 보안 세미나 사전 등록', time: '10월 18일' },
|
||||
{ who: '박준호', subject: '백업 스크립트 테스트 (무시하세요)', time: '10월 15일' },
|
||||
{ who: '총무팀', subject: '[총무팀] 사무실 공조 설비 점검 일정 안내', time: '10월 14일' },
|
||||
];
|
||||
const items = curCount === 0 ? [] : SAMPLE.map((x, i) => ({ ...x, fw: i === 0 && unreadOf(cur) > 0 ? '650' : '450', whoColor: '#344054', subFw: '400' }));
|
||||
|
||||
// 대화상자
|
||||
const d = st.dialog;
|
||||
const target = d && d.id ? all.find((b) => b.id === d.id) : null;
|
||||
const parentBox = d && d.parent ? all.find((b) => b.id === d.parent) : null;
|
||||
const hasChild = target ? st.mine.some((m) => m.parent === target.id) : false;
|
||||
const nameError = Boolean(this.props.nameError);
|
||||
let dialog = { title: '', role: 'dialog', hasName: false, hasText: false, text: '', hasConfirm: true, confirmLabel: '', confirmBg: '#0E7C86', cancelLabel: '취소', confirm: () => {} };
|
||||
if (d && d.kind === 'new') {
|
||||
dialog = {
|
||||
...dialog,
|
||||
title: parentBox ? `‘${parentBox.name}’ 안에 새 메일함` : '새 메일함',
|
||||
hasName: true,
|
||||
confirmLabel: '만들기',
|
||||
confirm: () => this.setState({ dialog: null, mine: [...st.mine, { id: `n${st.mine.length}`, name: '협력사', parent: d.parent === 'inbox' ? null : d.parent, unread: 0, count: 0 }], toast: '‘협력사’ 메일함을 만들었습니다.' }),
|
||||
};
|
||||
} else if (d && d.kind === 'rename' && target) {
|
||||
dialog = { ...dialog, title: '메일함 이름 바꾸기', hasName: true, confirmLabel: '바꾸기', confirm: () => this.setState({ dialog: null, mine: st.mine.map((m) => (m.id === target.id ? { ...m, name: `${m.name} (바꿈)` } : m)), toast: '메일함 이름을 바꿨습니다.' }) };
|
||||
} else if (d && d.kind === 'delete' && target) {
|
||||
dialog = hasChild
|
||||
? { ...dialog, role: 'alertdialog', title: `‘${target.name}’ 메일함을 지울 수 없습니다`, hasText: true, text: '하위 메일함이 있습니다. 하위 메일함을 먼저 지워 주세요.', hasConfirm: false, cancelLabel: '확인' }
|
||||
: {
|
||||
...dialog,
|
||||
role: 'alertdialog',
|
||||
title: `‘${target.name}’ 메일함을 지울까요?`,
|
||||
hasText: true,
|
||||
text: countOf(target) > 0 ? `안의 메일 ${countOf(target)}통은 휴지통으로 옮깁니다. 지운 메일함은 되돌릴 수 없습니다.` : '빈 메일함입니다. 지운 메일함은 되돌릴 수 없습니다.',
|
||||
confirmLabel: '지우기',
|
||||
confirmBg: '#B42318',
|
||||
confirm: () => this.setState({ dialog: null, box: st.box === target.id ? 'inbox' : st.box, mine: st.mine.filter((m) => m.id !== target.id), toast: countOf(target) > 0 ? `‘${target.name}’ 메일함을 지웠습니다. 안의 메일 ${countOf(target)}통은 휴지통에 있습니다.` : `‘${target.name}’ 메일함을 지웠습니다.` }),
|
||||
};
|
||||
} else if (d && d.kind === 'empty' && target) {
|
||||
dialog = {
|
||||
...dialog,
|
||||
role: 'alertdialog',
|
||||
title: `${target.name}의 메일 ${countOf(target)}통을 모두 영구 삭제할까요?`,
|
||||
hasText: true,
|
||||
text: '지운 메일은 되돌릴 수 없습니다.',
|
||||
confirmLabel: '영구 삭제',
|
||||
confirmBg: '#B42318',
|
||||
confirm: () => this.setState({ dialog: null, emptied: { ...st.emptied, [target.id]: true }, toast: `${eulreul(target.name)} 비웠습니다 — 메일 ${countOf(target)}통.` }),
|
||||
};
|
||||
}
|
||||
|
||||
const curIsEmptiable = cur.id === 'trash' || cur.id === 'junk';
|
||||
return {
|
||||
roleBoxes: roleBoxes,
|
||||
myBoxes: myBoxes,
|
||||
openNewTop: () => this.setState({ menu: null, dialog: { kind: 'new', parent: null } }),
|
||||
boxName: cur.name,
|
||||
countText: `전체 ${curCount}`,
|
||||
listLabel: `${cur.name} 메일 목록`,
|
||||
items: items,
|
||||
hasItems: items.length > 0,
|
||||
noItems: items.length === 0,
|
||||
emptyText: `${iga(cur.name)} 비어 있습니다`,
|
||||
canEmpty: curIsEmptiable && curCount > 0,
|
||||
emptyLabel: cur.id === 'trash' ? '휴지통 비우기' : '스팸메일함 비우기',
|
||||
openEmpty: () => this.setState({ dialog: { kind: 'empty', id: cur.id } }),
|
||||
dialogOpen: Boolean(d),
|
||||
dialogTitle: dialog.title,
|
||||
dialogRole: dialog.role,
|
||||
dialogHasName: dialog.hasName,
|
||||
dialogHasText: dialog.hasText,
|
||||
dialogText: dialog.text,
|
||||
dialogHasConfirm: dialog.hasConfirm,
|
||||
confirmLabel: dialog.confirmLabel,
|
||||
confirmBg: dialog.confirmBg,
|
||||
cancelLabel: dialog.cancelLabel,
|
||||
confirmDialog: dialog.confirm,
|
||||
closeDialog: () => this.setState({ dialog: null }),
|
||||
nameValue: d && d.kind === 'rename' && target ? target.name : '협력사',
|
||||
nameClass: nameError ? 'field is-error' : 'field',
|
||||
nameInvalid: nameError ? 'true' : 'false',
|
||||
nameHelp: nameError ? '같은 이름의 메일함이 이미 있습니다.' : '64자까지. 같은 자리에 같은 이름은 쓸 수 없습니다.',
|
||||
nameHelpColor: nameError ? '#B42318' : '#667085',
|
||||
hasToast: Boolean(st.toast),
|
||||
toastText: st.toast || '',
|
||||
};
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,13 +1,14 @@
|
||||
# 웹메일 디자인 원본 (1~3단계)
|
||||
# 웹메일 디자인 원본 (1~4단계)
|
||||
|
||||
확정된 디자인 캔버스의 원본이다. 화면 구현의 기준으로 쓴다.
|
||||
|
||||
- 캔버스: https://claude.ai/artifact/WJvVsKvXepxn3p75CGJAr2 (확정본: 2026-10-27, 버전 21)
|
||||
- 캔버스: https://claude.ai/artifact/WJvVsKvXepxn3p75CGJAr2 (확정본: 2026-10-28, 버전 23)
|
||||
- 화면: `Login`(로그인) · `Main`(분할 보기: 메일함·목록·본문) · `MainList`(목록 보기: 게시판형 표) · `MainListRead`(목록 보기에서 연 메일)
|
||||
· `Compose`(메일 쓰기 — 서식 편집기) · `ComposeEditor`(메일 쓰기 · 편집기 상태: 포커스·툴바·메뉴·팝오버·인용·이미지 첨부)
|
||||
· `States`(오류·빈 상태·피드백 모음)
|
||||
· 2단계: `Settings`(설정 · 메일 서명) · `ComposeAssist`(메일 쓰기 · 임시 저장 · 받는 사람 자동 완성 · 서명) — 아래 "2단계"
|
||||
· 3단계: `Search`(메일 검색 · 분할 보기) · `BulkSelect`(여러 통 선택 · 목록 보기) · `ListStates`(새 메일 · 검색 · 여러 통 처리 상태) — 아래 "3단계"
|
||||
· 4단계: `SettingsAccount`(설정 · 비밀번호) · `SettingsVacation`(설정 · 부재중 자동 응답) · `MailboxManage`(메일함 관리 · 휴지통 비우기) · `MailActions4`(스팸 · 원문 · 오래 걸리는 처리) — 아래 "4단계"
|
||||
- 파일 형식은 캔버스 전용(`<x-dc>` 마크업 + `DCLogic` 클래스)이다. 그대로 쓰지 말고 **구조·간격·색·문구의 기준**으로만 본다.
|
||||
`Compose` 의 툴바·메뉴는 눌러 볼 수 있는 시안이다 — 버튼 상태만 바뀌고 본문 글자에 서식이 입혀지지는 않는다.
|
||||
|
||||
@@ -262,6 +263,52 @@ API 와 서버 규칙은 계약 2절 "새 메일 확인" · "검색" · "여러
|
||||
[영구 삭제](#B42318 채움) [취소] — 열리면 「취소」에 초점이 간다.
|
||||
- 메일함을 바꾸거나 검색하면 선택을 푼다.
|
||||
|
||||
## 4단계 — 계정 · 메일 설정 (2026-10-28 확정 — `SettingsAccount` · `SettingsVacation` · `MailboxManage` · `MailActions4`)
|
||||
|
||||
API 와 서버 규칙은 계약 1절 "`PUT /api/auth/password`", 2절 "메일함 관리" · "부재중 자동 응답" · "스팸 · 원문".
|
||||
|
||||
### 설정 메뉴
|
||||
|
||||
- 왼쪽 메뉴를 두 묶음으로: **계정** › 비밀번호 / **메일** › 서명 · 부재중 자동 응답. 묶음 이름은 12px #667085 굵게, 항목은 지금 서명 항목과 같은 모양(40 · 아이콘 16).
|
||||
|
||||
### 비밀번호
|
||||
|
||||
- 칸 셋(최대 폭 420): 지금 비밀번호 · 새 비밀번호 · 새 비밀번호 확인. 칸 높이 44, 앞의 두 칸 끝에 보기/숨기기 단추(44 · 눈 아이콘, `aria-pressed`).
|
||||
- 새 비밀번호 아래 안내 "8자 이상 · 지금 비밀번호와 달라야 합니다. 영문 · 숫자 · 기호를 섞으면 더 안전합니다." (12.5px #667085).
|
||||
- 오류는 그 칸 아래 빨간 글(#B42318, 아이콘 alert-circle) · 칸 테두리도 빨강 — "지금 비밀번호가 올바르지 않습니다." · "새 비밀번호가 서로 다릅니다."
|
||||
- [비밀번호 바꾸기] → "비밀번호를 바꿨습니다. 다른 기기 · 브라우저의 로그인은 끊었습니다."(#067647), 세 칸을 비운다.
|
||||
- 아래 안내 상자(바탕 #F3F8F8): 다른 기기의 웹메일 로그인이 끊기는 것과 메일 프로그램(IMAP)에 새 비밀번호를 다시 넣어야 하는 것.
|
||||
|
||||
### 부재중 자동 응답
|
||||
|
||||
- [x] 부재중 자동 응답 사용(44). 끄면 아래 칸을 흐리게(쓴 글은 남긴다).
|
||||
- 기간: (•) 끌 때까지 계속 / ( ) 기간 정하기 → 시작 · 끝 날짜 칸(44). "시작일 0시부터 끝나는 날 자정까지 답합니다 (이 브라우저의 시간대 기준)."
|
||||
- 제목(44, 최대 폭 520) · 내용(여러 줄, 글자만 — "글자만 보냅니다 (서식 · 그림 없음). 서명은 붙지 않습니다.").
|
||||
- [저장] → "저장했습니다. 10월 30일(금)부터 11월 3일(화)까지 자동으로 답합니다." / "끌 때까지 자동으로 답합니다." / 끄면 "자동 응답을 껐습니다 — 써 둔 글은 남겨 둡니다."
|
||||
- 미리 보기 "보낸 사람이 받는 답장"(보낸 사람 · 제목 · 내용) + "같은 사람에게 매번 보내지는 않습니다(메일 엔진 규칙)."
|
||||
- 켜져 있는 동안 메일 화면의 메일함 패널 아래(사용량 위)에 안내(바탕 #FFF7E6 · #7A5200, 높이 44 이상): "부재중 자동 응답 켜짐" / "11월 3일까지 · 설정" — 누르면 이 설정으로.
|
||||
|
||||
### 메일함 관리 (메일함 패널)
|
||||
|
||||
- 기본 메일함 아래에 "내 메일함"(12px #667085 굵게) 묶음 — 하위 메일함은 16px 씩 들여 쓴다(지금 규칙). 묶음 끝에 [+ 새 메일함](44).
|
||||
- 각 메일함 행 오른쪽에 ⋯ 단추(32×40 — 별 칸과 같은 밀도 예외). 마우스를 올리거나 초점이 가면 보이고, 메뉴가 열린 동안은 계속 보인다. 숫자(안 읽음)는 ⋯ 앞에 그대로.
|
||||
- ⋯ 메뉴(항목 40): 모두 읽음으로 표시(안 읽은 메일이 있을 때) · 하위 메일함 만들기(받은편지함 · 내 메일함) · 이름 바꾸기 · 지우기(빨강 — 내 메일함만) ·
|
||||
휴지통 비우기 / 스팸메일함 비우기(빨강 — 그 메일함에서만).
|
||||
- 대화상자(공용 모양): 새 메일함 · "‘프로젝트’ 안에 새 메일함" · 메일함 이름 바꾸기 — 이름 칸(44, 64자) + 안내 "64자까지. 같은 자리에 같은 이름은 쓸 수 없습니다."
|
||||
(같은 이름이면 그 자리에 빨강 "같은 이름의 메일함이 이미 있습니다."). 지우기 — "‘거래처’ 메일함을 지울까요?" "안의 메일 12통은 휴지통으로 옮깁니다. 지운 메일함은 되돌릴 수 없습니다." [지우기](빨강 채움) [취소].
|
||||
하위 메일함이 있으면 "‘프로젝트’ 메일함을 지울 수 없습니다" "하위 메일함이 있습니다. 하위 메일함을 먼저 지워 주세요." [확인].
|
||||
- 휴지통 · 스팸메일함 목록 머리 오른쪽에 [휴지통 비우기] / [스팸메일함 비우기](빨강 글자 · 44). 확인 "휴지통의 메일 21통을 모두 영구 삭제할까요?" "지운 메일은 되돌릴 수 없습니다." [영구 삭제] [취소].
|
||||
- 비우기 · 지우기 대화상자는 열리면 「취소」에 초점이 간다.
|
||||
|
||||
### 스팸 · 원문 · 오래 걸리는 처리
|
||||
|
||||
- 읽기 화면 도구 줄: 답장 · 전체 답장 · 전달 | 스팸 신고 · 삭제 · ⋯(더 보기 40×44) — ⋯ 메뉴에 "원문 내려받기 (.eml)". 스팸메일함에서는 「스팸 신고」 자리에 「스팸 아님」(#0A5F67 굵게, 받은편지함 아이콘).
|
||||
- 여러 통 도구 줄: 이동 ▾ 과 삭제 사이에 「스팸 신고」(스팸메일함에서는 「스팸 아님」).
|
||||
- 오래 걸리는 처리(모두 읽음 · 비우기 · 메일함 지우기): 알림이 "받은편지함의 메일을 읽음으로 표시하는 중 · 600 / 1,234통"(돌아가는 아이콘)으로 남았다가
|
||||
끝나면 "받은편지함의 메일 1,234통을 읽음으로 표시했습니다." 처리하는 동안 다른 일을 해도 된다.
|
||||
- 알림 문구: "메일 2통을 스팸메일함으로 옮겼습니다." [되돌리기] · "메일 1통을 받은편지함으로 옮겼습니다 (스팸 아님)." [되돌리기] ·
|
||||
"‘협력사’ 메일함을 만들었습니다." · "‘거래처’ 메일함을 지웠습니다. 안의 메일 12통은 휴지통에 있습니다." · "휴지통을 비웠습니다 — 메일 21통." (비우기 · 지우기는 되돌리기 없음)
|
||||
|
||||
## 구현하며 정한 보완 (2026-10-26 — 캔버스에는 아직 없다)
|
||||
|
||||
- 찾지 못한 첨부(보관 시간이 지난 업로드·지워진 원본)는 실패로 표시하고, 전달 원본에는 "빼기"만 둔다(다시 올릴 수 없다).
|
||||
@@ -320,3 +367,38 @@ API 와 서버 규칙은 계약 2절 "새 메일 확인" · "검색" · "여러
|
||||
- 요청이 통째로 실패하면 고른 것을 그대로 둔다(다시 시도할 수 있게). 열어 둔 메일이 옮겨져 목록에서 빠지면 목록 주소로 바꾼다.
|
||||
- 영구 삭제 확인은 공용 대화상자 폭(360)을 쓴다.
|
||||
- 「되돌리기」는 이 화면이 알던 메일함 한 곳으로 되돌리고, 로그아웃 · 다른 계정 로그인 뒤에는 움직이지 않는다 (계약 2절 "알려진 한계").
|
||||
|
||||
### 4단계 구현하며 정한 보완 (2026-10-28 — 캔버스에는 아직 없다)
|
||||
|
||||
- **설정**: `/settings` 는 계정 › 비밀번호로 연다(앱 레일의 "설정").
|
||||
- **비밀번호**
|
||||
- 8자 미만이거나 지금 비밀번호와 같으면 새 비밀번호 아래 안내 글이 그대로 빨간 오류로 바뀐다.
|
||||
- 서버의 거절은 칸 아래에만 보이고 알림은 띄우지 않는다 — 「지금 비밀번호가 올바르지 않습니다.」는 지금 비밀번호 칸, 엔진 규칙(`VAL_001`)은 새 비밀번호 칸.
|
||||
- 보기 단추는 이름을 바꾸지 않고 눌림 상태(`aria-pressed`)로 알린다(ARIA 권고).
|
||||
- 요청은 30초까지 기다린다(서버가 엔진을 두 번 부른다 — 계약 1절).
|
||||
- **부재중 자동 응답**
|
||||
- 날짜는 하나만 골라도 된다 — 시작만이면 그날부터 끌 때까지, 끝만이면 지금부터 그날까지.
|
||||
- 꺼 둔 채 저장할 때 기간이 맞지 않으면(흐린 칸이라 고칠 수 없다) 날짜를 비워 저장한다 — 끄기는 늘 저장되게. 다시 열면 "끌 때까지 계속"이다.
|
||||
- 저장 글은 올해가 아니면 연도를 붙인다("2027년 1월 3일(일)"). 날짜가 하나면 "…부터 자동으로 답합니다." · "…까지 자동으로 답합니다."
|
||||
- 메일함 패널 안내: 끝나는 시각이 지나면 숨긴다. 아직 시작 전이면 "10월 30일 ~ 11월 3일 · 설정"(끝이 없으면 "10월 30일부터 · 설정"),
|
||||
끝이 없으면 "끌 때까지 · 설정". 화면을 띄워 둔 채 시각이 지나도 1분 안에 다시 맞춘다.
|
||||
- 바꾸고 저장하지 않은 채 떠나면 묻는다 — 제목 "저장하지 않은 자동 응답이 있습니다", 글 "이 화면을 나가면 바꾼 내용이 사라집니다.", [계속 고치기] [나가기].
|
||||
저장하는 중에 떠났는데 서버가 거절하면 알림으로 알린다(저장 단추 옆 글이 보이지 않으므로).
|
||||
- **메일함 관리**
|
||||
- 할 일이 없는 메일함(보낸편지함, 안 읽은 메일이 없는 임시보관함, 빈 휴지통 등)에는 ⋯ 를 그리지 않는다. 빈 휴지통 · 스팸메일함에는 비우기 항목도 목록 머리 단추도 없다.
|
||||
- 하위 메일함은 받은편지함 · 내 메일함 아래에만 만든다(계약은 보관함 아래도 받는다 — 다른 메일 프로그램이 만든 것을 그대로 보인다).
|
||||
- 대화상자는 공용 폭 360(캔버스는 400). "지울 수 없습니다"의 [확인]은 단추가 하나라 주 단추 모양이다(캔버스는 보조 단추 — 세션 만료 대화상자와 같게).
|
||||
- 이름을 바꾸지 않고 [바꾸기]를 누르면 요청 없이 닫는다. 이름에는 `/` 와 제어 문자(줄바꿈 등)를 쓸 수 없다.
|
||||
- 분할 보기의 목록 머리(384)에서는 「휴지통 비우기」 · 「스팸메일함 비우기」가 아이콘만이다(40×44 — 이름은 툴팁). 보기 전환 · 새로고침과 함께 이름까지 넣으면 넘친다(3단계 도구 줄과 같은 규칙).
|
||||
- ⋯ 메뉴는 보이는 영역 안에 다 들게 연다 — 패널 아래쪽 행에서는 위로 연다.
|
||||
- 보던 메일함을 지우면 받은편지함으로 간다. 비우기 · 지우기가 끝나면 초점은 메일함 패널의 지금 메일함(지웠으면 받은편지함)으로 간다 — 누른 단추가 사라지므로.
|
||||
- **오래 걸리는 처리**
|
||||
- 하는 중의 글: "‘거래처’ 메일함을 지우는 중 · 12 / 40통" · "휴지통을 비우는 중 · 600 / 1,234통" (빈 메일함 지우기처럼 셀 메일이 없으면 수를 뺀다).
|
||||
- 도중에 실패하면 "1,234통 가운데 600통까지 처리했습니다 — 다시 시도해 주세요."로 바뀐다(까닭은 오류 알림으로 함께). 하나도 못 했으면 오류 알림만 남는다.
|
||||
- 다른 탭에서 먼저 끝냈으면 실패로 보이지 않는다 — "받은편지함에 안 읽은 메일이 없습니다." · "휴지통이 이미 비어 있습니다.", 이미 없는 메일함 지우기는 지운 것으로 본다.
|
||||
- 같은 메일함의 처리는 한 번에 하나다(다른 탭에서 같은 메일함을 처리하는 중이면 "요청이 너무 많습니다"). 진행 알림은 로그아웃 · 세션 만료 때 닫힌다.
|
||||
- **휴지통을 비우는 동안은 메일함을 지울 수 없고, 메일함을 지우는 동안은 휴지통을 비울 수 없다** — 비우기는 그동안 휴지통에 들어온 메일도 지우므로,
|
||||
지우는 메일함의 메일이 휴지통에 들어가자마자 영구 삭제되지 않게(계약 2절 "메일함 관리").
|
||||
- 목록 머리 「비우기」의 확인은 연 그 메일함만 비운다 — 확인 창이 열린 채 다른 메일함으로 가면(뒤로 가기 등) 창을 닫는다.
|
||||
- 서버가 처리 수 0 을 세 번 연달아 주면 멈추고 실패로 알린다(같은 요청을 끝없이 보내지 않게).
|
||||
- **스팸**: 「되돌리기」는 원래 메일함으로 옮기며 표시를 반대로 바꾼다 — 원래 아무 표시도 없던 메일은 "스팸 아님" 표시가 남는다(계약 2절 "스팸 · 원문").
|
||||
|
||||
@@ -0,0 +1,224 @@
|
||||
<!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}
|
||||
.pw-box:focus-within{border-color:#0E7C86 !important;box-shadow:0 0 0 1px #0E7C86}
|
||||
.pw-box.is-error{border-color:#B42318 !important}
|
||||
.pw-box.is-error:focus-within{box-shadow:0 0 0 1px #B42318}
|
||||
.pw-input{flex:1 1 auto;min-width:0;height:42px;border:0;outline:none;background:transparent;font:inherit;color:#171C26;padding:0 12px}
|
||||
.ghost{background:transparent;color:#4A5363}
|
||||
.ghost:hover{background:#F2F4F7;color:#171C26}
|
||||
.menu-link{color:#344054}
|
||||
.menu-link:hover{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" 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="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" aria-current="page" 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; 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"><circle cx="12" cy="12" r="3"></circle><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"></path></svg>
|
||||
<span>설정</span>
|
||||
</a>
|
||||
</nav>
|
||||
|
||||
<div style="flex: 1 1 auto; min-width: 0; display: flex; flex-direction: column">
|
||||
|
||||
<header style="display: flex; align-items: center; flex-wrap: wrap; gap: 8px 16px; box-sizing: border-box; min-height: 56px; padding: 6px 12px 6px 20px; border-bottom: 1px solid #E6E8EC">
|
||||
<span style="font-size: 16px; font-weight: 700; letter-spacing: -0.01em">설정</span>
|
||||
<div style="flex: 1 1 280px; max-width: 520px; display: flex; align-items: center; gap: 8px; box-sizing: border-box; height: 40px; 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; background: transparent; font: inherit; color: #171C26">
|
||||
</div>
|
||||
<div style="margin-left: auto; display: flex; align-items: center; gap: 10px">
|
||||
<span aria-hidden="true" style="display: inline-flex; align-items: center; justify-content: center; width: 32px; height: 32px; border-radius: 50%; background: #E3F2F3; color: #0A5F67; font-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: 4px; padding: 14px 10px; background: #F8F9FB; border-right: 1px solid #E6E8EC">
|
||||
<p style="margin: 4px 10px 4px; font-size: 12px; font-weight: 600; color: #667085">계정</p>
|
||||
<ul style="list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 1px">
|
||||
<li><a href="#" aria-current="page" style="display: flex; align-items: center; gap: 10px; min-height: 40px; box-sizing: border-box; padding: 0 10px; border-radius: 7px; background: #E3F2F3; color: #0A5F67; font-weight: 600; text-decoration: none">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="11" width="18" height="11" rx="2" ry="2"></rect><path d="M7 11V7a5 5 0 0 1 10 0v4"></path></svg>
|
||||
<span>비밀번호</span>
|
||||
</a></li>
|
||||
</ul>
|
||||
<p style="margin: 14px 10px 4px; font-size: 12px; font-weight: 600; color: #667085">메일</p>
|
||||
<ul style="list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 1px">
|
||||
<li><a href="Settings.dc.html" class="menu-link" style="display: flex; align-items: center; gap: 10px; min-height: 40px; box-sizing: border-box; padding: 0 10px; border-radius: 7px; font-weight: 500; text-decoration: none">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><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></li>
|
||||
<li><a href="SettingsVacation.dc.html" class="menu-link" style="display: flex; align-items: center; gap: 10px; min-height: 40px; box-sizing: border-box; padding: 0 10px; border-radius: 7px; font-weight: 500; text-decoration: none">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><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><line x1="10" y1="14" x2="14" y2="18"></line><line x1="14" y1="14" x2="10" y2="18"></line></svg>
|
||||
<span>부재중 자동 응답</span>
|
||||
</a></li>
|
||||
</ul>
|
||||
</nav>
|
||||
|
||||
<main style="flex: 999 1 560px; min-width: 0; box-sizing: border-box; display: flex; flex-direction: column">
|
||||
<div style="max-width: 800px; box-sizing: border-box; padding: 20px 32px 36px; display: flex; flex-direction: column; gap: 22px">
|
||||
|
||||
<div style="display: flex; flex-direction: column; gap: 4px">
|
||||
<h1 style="margin: 0; font-size: 18px; font-weight: 700; letter-spacing: -0.01em">비밀번호</h1>
|
||||
<p style="margin: 0; color: #4A5363">웹메일과 휴대폰 메일 앱(IMAP)이 함께 쓰는 메일 계정의 비밀번호입니다.</p>
|
||||
</div>
|
||||
|
||||
<form style="display: flex; flex-direction: column; gap: 18px; max-width: 420px; margin: 0">
|
||||
<div style="display: flex; flex-direction: column; gap: 6px">
|
||||
<label for="pw-current" style="font-weight: 600">지금 비밀번호</label>
|
||||
<div class="{{ currentBoxClass }}" style="display: flex; align-items: center; box-sizing: border-box; border: 1px solid #D0D5DD; border-radius: 8px; background: #FFFFFF">
|
||||
<input id="pw-current" class="pw-input" type="{{ currentType }}" autocomplete="current-password" value="{{ currentValue }}" aria-invalid="{{ currentInvalid }}" aria-describedby="pw-current-error">
|
||||
<button type="button" class="ghost" onClick="{{ toggleCurrent }}" aria-label="{{ currentToggleLabel }}" aria-pressed="{{ currentShownStr }}" style="flex: 0 0 auto; width: 44px; height: 42px; display: inline-flex; align-items: center; justify-content: center; border: 0; border-radius: 0 7px 7px 0; 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="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"></path><circle cx="12" cy="12" r="3"></circle></svg>
|
||||
</button>
|
||||
</div>
|
||||
<sc-if value="{{ showCurrentError }}" hint-placeholder-val="{{ false }}">
|
||||
<p id="pw-current-error" style="margin: 0; display: flex; align-items: center; gap: 6px; font-size: 12.5px; color: #B42318">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="10"></circle><line x1="12" y1="8" x2="12" y2="12"></line><line x1="12" y1="16" x2="12.01" y2="16"></line></svg>
|
||||
<span>지금 비밀번호가 올바르지 않습니다.</span>
|
||||
</p>
|
||||
</sc-if>
|
||||
</div>
|
||||
|
||||
<div style="display: flex; flex-direction: column; gap: 6px">
|
||||
<label for="pw-new" style="font-weight: 600">새 비밀번호</label>
|
||||
<div class="pw-box" style="display: flex; align-items: center; box-sizing: border-box; border: 1px solid #D0D5DD; border-radius: 8px; background: #FFFFFF">
|
||||
<input id="pw-new" class="pw-input" type="{{ newType }}" autocomplete="new-password" value="{{ newValue }}" aria-describedby="pw-new-hint">
|
||||
<button type="button" class="ghost" onClick="{{ toggleNew }}" aria-label="{{ newToggleLabel }}" aria-pressed="{{ newShownStr }}" style="flex: 0 0 auto; width: 44px; height: 42px; display: inline-flex; align-items: center; justify-content: center; border: 0; border-radius: 0 7px 7px 0; 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="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"></path><circle cx="12" cy="12" r="3"></circle></svg>
|
||||
</button>
|
||||
</div>
|
||||
<p id="pw-new-hint" style="margin: 0; font-size: 12.5px; color: #667085">8자 이상 · 지금 비밀번호와 달라야 합니다. 영문 · 숫자 · 기호를 섞으면 더 안전합니다.</p>
|
||||
</div>
|
||||
|
||||
<div style="display: flex; flex-direction: column; gap: 6px">
|
||||
<label for="pw-confirm" style="font-weight: 600">새 비밀번호 확인</label>
|
||||
<div class="{{ confirmBoxClass }}" style="display: flex; align-items: center; box-sizing: border-box; border: 1px solid #D0D5DD; border-radius: 8px; background: #FFFFFF">
|
||||
<input id="pw-confirm" class="pw-input" type="{{ newType }}" autocomplete="new-password" value="{{ confirmValue }}" aria-invalid="{{ confirmInvalid }}" aria-describedby="pw-confirm-error">
|
||||
</div>
|
||||
<sc-if value="{{ showConfirmError }}" hint-placeholder-val="{{ false }}">
|
||||
<p id="pw-confirm-error" style="margin: 0; display: flex; align-items: center; gap: 6px; font-size: 12.5px; color: #B42318">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="10"></circle><line x1="12" y1="8" x2="12" y2="12"></line><line x1="12" y1="16" x2="12.01" y2="16"></line></svg>
|
||||
<span>새 비밀번호가 서로 다릅니다.</span>
|
||||
</p>
|
||||
</sc-if>
|
||||
</div>
|
||||
|
||||
<div style="display: flex; flex-wrap: wrap; align-items: center; gap: 12px">
|
||||
<button type="button" onClick="{{ submit }}" style="display: inline-flex; align-items: center; min-height: 44px; padding: 0 22px; border: 0; border-radius: 8px; background: #0E7C86; color: #FFFFFF; font: inherit; font-size: 13.5px; font-weight: 600; cursor: pointer">비밀번호 바꾸기</button>
|
||||
<sc-if value="{{ showDone }}" hint-placeholder-val="{{ false }}">
|
||||
<span role="status" style="display: inline-flex; align-items: center; gap: 6px; color: #067647; font-weight: 500">
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="20 6 9 17 4 12"></polyline></svg>
|
||||
<span>비밀번호를 바꿨습니다. 다른 기기 · 브라우저의 로그인은 끊었습니다.</span>
|
||||
</span>
|
||||
</sc-if>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div style="display: flex; align-items: flex-start; gap: 10px; max-width: 620px; padding: 12px 14px; border-radius: 8px; background: #F3F8F8; color: #344054">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#0A5F67" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" style="flex: 0 0 auto; margin-top: 2px"><circle cx="12" cy="12" r="10"></circle><line x1="12" y1="16" x2="12" y2="12"></line><line x1="12" y1="8" x2="12.01" y2="8"></line></svg>
|
||||
<div style="display: flex; flex-direction: column; gap: 2px">
|
||||
<span>바꾸면 이 브라우저를 뺀 다른 기기 · 브라우저의 웹메일 로그인이 끊깁니다.</span>
|
||||
<span>휴대폰이나 PC 메일 프로그램(IMAP)에는 새 비밀번호를 다시 넣어 주세요.</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</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 = { showCurrent: false, showNew: false, submitted: null };
|
||||
|
||||
renderVals() {
|
||||
const st = this.state;
|
||||
const variant = st.submitted ?? this.props.variant ?? '입력';
|
||||
const done = variant === '바꿈';
|
||||
const currentWrong = variant === '지금 비밀번호 틀림';
|
||||
const mismatch = variant === '확인 불일치';
|
||||
const dots = (n) => '•'.repeat(n);
|
||||
return {
|
||||
currentType: st.showCurrent ? 'text' : 'password',
|
||||
newType: st.showNew ? 'text' : 'password',
|
||||
currentValue: done ? '' : (st.showCurrent ? 'old-pass-1234' : dots(13)),
|
||||
newValue: done ? '' : (st.showNew ? 'Ara!2026-mail' : dots(13)),
|
||||
confirmValue: done ? '' : (mismatch ? (st.showNew ? 'Ara!2026-mai' : dots(12)) : (st.showNew ? 'Ara!2026-mail' : dots(13))),
|
||||
currentToggleLabel: st.showCurrent ? '지금 비밀번호 숨기기' : '지금 비밀번호 보기',
|
||||
newToggleLabel: st.showNew ? '새 비밀번호 숨기기' : '새 비밀번호 보기',
|
||||
currentShownStr: st.showCurrent ? 'true' : 'false',
|
||||
newShownStr: st.showNew ? 'true' : 'false',
|
||||
toggleCurrent: () => this.setState({ showCurrent: !st.showCurrent }),
|
||||
toggleNew: () => this.setState({ showNew: !st.showNew }),
|
||||
currentBoxClass: currentWrong ? 'pw-box is-error' : 'pw-box',
|
||||
confirmBoxClass: mismatch ? 'pw-box is-error' : 'pw-box',
|
||||
currentInvalid: currentWrong ? 'true' : 'false',
|
||||
confirmInvalid: mismatch ? 'true' : 'false',
|
||||
showCurrentError: currentWrong,
|
||||
showConfirmError: mismatch,
|
||||
showDone: done,
|
||||
submit: () => this.setState({ submitted: '바꿈' }),
|
||||
};
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,223 @@
|
||||
<!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}
|
||||
.pw-box:focus-within{border-color:#0E7C86 !important;box-shadow:0 0 0 1px #0E7C86}
|
||||
.pw-box.is-error{border-color:#B42318 !important}
|
||||
.pw-box.is-error:focus-within{box-shadow:0 0 0 1px #B42318}
|
||||
.field{box-sizing:border-box;border:1px solid #D0D5DD;border-radius:8px;background:#FFFFFF;font:inherit;color:#171C26}
|
||||
.field:focus{outline:none;border-color:#0E7C86;box-shadow:0 0 0 1px #0E7C86}
|
||||
.field:disabled{background:#F8F9FB;color:#98A2B3}
|
||||
.ghost{background:transparent;color:#4A5363}
|
||||
.ghost:hover{background:#F2F4F7;color:#171C26}
|
||||
.menu-link{color:#344054}
|
||||
.menu-link:hover{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" 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="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" aria-current="page" 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; 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"><circle cx="12" cy="12" r="3"></circle><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"></path></svg>
|
||||
<span>설정</span>
|
||||
</a>
|
||||
</nav>
|
||||
|
||||
<div style="flex: 1 1 auto; min-width: 0; display: flex; flex-direction: column">
|
||||
|
||||
<header style="display: flex; align-items: center; flex-wrap: wrap; gap: 8px 16px; box-sizing: border-box; min-height: 56px; padding: 6px 12px 6px 20px; border-bottom: 1px solid #E6E8EC">
|
||||
<span style="font-size: 16px; font-weight: 700; letter-spacing: -0.01em">설정</span>
|
||||
<div style="flex: 1 1 280px; max-width: 520px; display: flex; align-items: center; gap: 8px; box-sizing: border-box; height: 40px; 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; background: transparent; font: inherit; color: #171C26">
|
||||
</div>
|
||||
<div style="margin-left: auto; display: flex; align-items: center; gap: 10px">
|
||||
<span aria-hidden="true" style="display: inline-flex; align-items: center; justify-content: center; width: 32px; height: 32px; border-radius: 50%; background: #E3F2F3; color: #0A5F67; font-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: 4px; padding: 14px 10px; background: #F8F9FB; border-right: 1px solid #E6E8EC">
|
||||
<p style="margin: 4px 10px 4px; font-size: 12px; font-weight: 600; color: #667085">계정</p>
|
||||
<ul style="list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 1px">
|
||||
<li><a href="SettingsAccount.dc.html" class="menu-link" style="display: flex; align-items: center; gap: 10px; min-height: 40px; box-sizing: border-box; padding: 0 10px; border-radius: 7px; font-weight: 500; text-decoration: none">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="11" width="18" height="11" rx="2" ry="2"></rect><path d="M7 11V7a5 5 0 0 1 10 0v4"></path></svg>
|
||||
<span>비밀번호</span>
|
||||
</a></li>
|
||||
</ul>
|
||||
<p style="margin: 14px 10px 4px; font-size: 12px; font-weight: 600; color: #667085">메일</p>
|
||||
<ul style="list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 1px">
|
||||
<li><a href="Settings.dc.html" class="menu-link" style="display: flex; align-items: center; gap: 10px; min-height: 40px; box-sizing: border-box; padding: 0 10px; border-radius: 7px; font-weight: 500; text-decoration: none">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><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></li>
|
||||
<li><a href="#" aria-current="page" style="display: flex; align-items: center; gap: 10px; min-height: 40px; box-sizing: border-box; padding: 0 10px; border-radius: 7px; background: #E3F2F3; color: #0A5F67; font-weight: 600; text-decoration: none">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><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><line x1="10" y1="14" x2="14" y2="18"></line><line x1="14" y1="14" x2="10" y2="18"></line></svg>
|
||||
<span>부재중 자동 응답</span>
|
||||
</a></li>
|
||||
</ul>
|
||||
</nav>
|
||||
|
||||
<main style="flex: 999 1 560px; min-width: 0; box-sizing: border-box; display: flex; flex-direction: column">
|
||||
<div style="max-width: 800px; box-sizing: border-box; padding: 20px 32px 36px; display: flex; flex-direction: column; gap: 22px">
|
||||
|
||||
<div style="display: flex; flex-direction: column; gap: 4px">
|
||||
<h1 style="margin: 0; font-size: 18px; font-weight: 700; letter-spacing: -0.01em">부재중 자동 응답</h1>
|
||||
<p style="margin: 0; color: #4A5363">자리를 비운 동안 받은 메일에 정해 둔 글로 자동으로 답합니다. 켜 두면 메일 화면의 메일함 패널 아래에 알립니다.</p>
|
||||
</div>
|
||||
|
||||
<label style="display: flex; align-items: center; gap: 10px; min-height: 44px; cursor: pointer">
|
||||
<input type="checkbox" checked="{{ on }}" onChange="{{ toggleOn }}" style="width: 18px; height: 18px; margin: 0; accent-color: #0E7C86; cursor: pointer">
|
||||
<span style="font-weight: 600">부재중 자동 응답 사용</span>
|
||||
</label>
|
||||
|
||||
<fieldset disabled="{{ off }}" style="display: flex; flex-direction: column; gap: 0; margin: 0; padding: 0; border: 0">
|
||||
<legend style="margin: 0 0 4px; padding: 0; font-weight: 600; color: #171C26">기간</legend>
|
||||
<label style="display: flex; align-items: center; gap: 10px; min-height: 44px; cursor: pointer">
|
||||
<input type="radio" name="vac-period" checked="{{ untilOff }}" onChange="{{ pickUntilOff }}" style="width: 18px; height: 18px; margin: 0; accent-color: #0E7C86; cursor: pointer">
|
||||
<span>끌 때까지 계속</span>
|
||||
</label>
|
||||
<label style="display: flex; align-items: center; gap: 10px; min-height: 44px; cursor: pointer">
|
||||
<input type="radio" name="vac-period" checked="{{ ranged }}" onChange="{{ pickRanged }}" style="width: 18px; height: 18px; margin: 0; accent-color: #0E7C86; cursor: pointer">
|
||||
<span>기간 정하기</span>
|
||||
</label>
|
||||
<sc-if value="{{ ranged }}" hint-placeholder-val="{{ true }}">
|
||||
<div style="display: flex; flex-wrap: wrap; align-items: center; gap: 8px 10px; padding: 4px 0 0 28px">
|
||||
<label for="vac-from" style="font-size: 12.5px; color: #4A5363">시작</label>
|
||||
<input id="vac-from" class="field" type="date" value="2026-10-30" style="height: 44px; padding: 0 10px">
|
||||
<span aria-hidden="true" style="color: #667085">~</span>
|
||||
<label for="vac-to" style="font-size: 12.5px; color: #4A5363">끝</label>
|
||||
<input id="vac-to" class="field" type="date" value="2026-11-03" style="height: 44px; padding: 0 10px">
|
||||
</div>
|
||||
<p style="margin: 6px 0 0 28px; font-size: 12.5px; color: #667085">시작일 0시부터 끝나는 날 자정까지 답합니다 (이 브라우저의 시간대 기준).</p>
|
||||
</sc-if>
|
||||
</fieldset>
|
||||
|
||||
<div style="display: flex; flex-direction: column; gap: 6px">
|
||||
<label for="vac-subject" style="font-weight: 600">제목</label>
|
||||
<input id="vac-subject" class="field" type="text" value="부재중 안내 — 11월 4일 복귀" disabled="{{ off }}" style="height: 44px; padding: 0 12px; max-width: 520px">
|
||||
</div>
|
||||
|
||||
<div style="display: flex; flex-direction: column; gap: 6px">
|
||||
<label for="vac-body" style="font-weight: 600">내용</label>
|
||||
<textarea id="vac-body" class="field" rows="6" disabled="{{ off }}" style="padding: 10px 12px; line-height: 1.7; resize: vertical; white-space: pre-line">안녕하세요, 김아라입니다.
|
||||
10월 30일부터 11월 3일까지 휴가라 메일 확인이 늦어집니다.
|
||||
급한 일은 인프라팀(infra@arasystem.it.kr)으로 연락해 주세요.</textarea>
|
||||
<p style="margin: 0; font-size: 12.5px; color: #667085">글자만 보냅니다 (서식 · 그림 없음). 서명은 붙지 않습니다.</p>
|
||||
</div>
|
||||
|
||||
<div style="display: flex; flex-wrap: wrap; align-items: center; gap: 12px">
|
||||
<button type="button" onClick="{{ save }}" style="display: inline-flex; align-items: center; min-height: 44px; padding: 0 22px; border: 0; border-radius: 8px; background: #0E7C86; color: #FFFFFF; font: inherit; font-size: 13.5px; font-weight: 600; cursor: pointer">저장</button>
|
||||
<sc-if value="{{ saved }}" hint-placeholder-val="{{ false }}">
|
||||
<span role="status" style="display: inline-flex; align-items: center; gap: 6px; color: #067647; font-weight: 500">
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="20 6 9 17 4 12"></polyline></svg>
|
||||
<span>{{ savedText }}</span>
|
||||
</span>
|
||||
</sc-if>
|
||||
</div>
|
||||
|
||||
<hr style="width: 100%; height: 1px; margin: 0; border: 0; background: #EEF0F3">
|
||||
|
||||
<section aria-labelledby="vac-preview-title" style="display: flex; flex-direction: column; gap: 12px">
|
||||
<h2 id="vac-preview-title" style="margin: 0; font-size: 14px; font-weight: 700">보낸 사람이 받는 답장</h2>
|
||||
<div style="display: flex; flex-direction: column; gap: 10px; box-sizing: border-box; padding: 16px 20px 20px; border: 1px solid #E6E8EC; border-radius: 10px; background: #FFFFFF">
|
||||
<dl style="margin: 0; display: grid; grid-template-columns: 72px minmax(0, 1fr); row-gap: 2px; font-size: 12.5px">
|
||||
<dt style="color: #667085">보낸 사람</dt><dd style="margin: 0">김아라 <ara.kim@arasystem.it.kr></dd>
|
||||
<dt style="color: #667085">제목</dt><dd style="margin: 0">부재중 안내 — 11월 4일 복귀</dd>
|
||||
</dl>
|
||||
<div style="white-space: pre-line; font-family: 'Malgun Gothic', '맑은 고딕', 'Apple SD Gothic Neo', sans-serif; font-size: 14px; line-height: 1.75; color: #1F2733">안녕하세요, 김아라입니다.
|
||||
10월 30일부터 11월 3일까지 휴가라 메일 확인이 늦어집니다.
|
||||
급한 일은 인프라팀(infra@arasystem.it.kr)으로 연락해 주세요.</div>
|
||||
</div>
|
||||
<p style="margin: 0; font-size: 12.5px; color: #667085">{{ previewNote }}</p>
|
||||
</section>
|
||||
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</x-dc>
|
||||
<script type="text/x-dc" data-dc-script data-props='{"$preview":{"width":1440,"height":900}}'>
|
||||
class Component extends DCLogic {
|
||||
state = { on: true, ranged: true, saved: false };
|
||||
|
||||
renderVals() {
|
||||
const st = this.state;
|
||||
return {
|
||||
on: st.on,
|
||||
off: !st.on,
|
||||
ranged: st.ranged,
|
||||
untilOff: !st.ranged,
|
||||
toggleOn: () => this.setState({ on: !st.on, saved: false }),
|
||||
pickRanged: () => this.setState({ ranged: true, saved: false }),
|
||||
pickUntilOff: () => this.setState({ ranged: false, saved: false }),
|
||||
save: () => this.setState({ saved: true }),
|
||||
saved: st.saved,
|
||||
savedText: !st.on
|
||||
? '저장했습니다. 자동 응답을 껐습니다 — 써 둔 글은 남겨 둡니다.'
|
||||
: st.ranged
|
||||
? '저장했습니다. 10월 30일(금)부터 11월 3일(화)까지 자동으로 답합니다.'
|
||||
: '저장했습니다. 끌 때까지 자동으로 답합니다.',
|
||||
previewNote: st.on ? '받은 메일의 보낸 사람에게 위 글로 답합니다. 같은 사람에게 매번 보내지는 않습니다(메일 엔진 규칙).' : '꺼져 있습니다 — 자동으로 답하지 않습니다.',
|
||||
};
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -16,13 +16,18 @@
|
||||
"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": "새 메일 · 검색 · 여러 통 처리 상태" }
|
||||
"ListStates.dc.html": { "x": 3040, "y": 4480, "w": 1440, "h": 1560, "title": "새 메일 · 검색 · 여러 통 처리 상태" },
|
||||
"SettingsAccount.dc.html": { "x": 0, "y": 6640, "w": 1440, "h": 900, "title": "설정 · 비밀번호", "expand": "fill", "is_interactive": true },
|
||||
"SettingsVacation.dc.html": { "x": 1520, "y": 6640, "w": 1440, "h": 1060, "title": "설정 · 부재중 자동 응답", "expand": "fill", "is_interactive": true },
|
||||
"MailboxManage.dc.html": { "x": 3040, "y": 6640, "w": 1440, "h": 900, "title": "메일함 관리 · 휴지통 비우기", "expand": "fill", "is_interactive": true },
|
||||
"MailActions4.dc.html": { "x": 4560, "y": 6640, "w": 1440, "h": 1320, "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", "Search.dc.html", "BulkSelect.dc.html", "ListStates.dc.html"],
|
||||
"order": ["Main.dc.html", "Login.dc.html", "Compose.dc.html", "States.dc.html", "MainList.dc.html", "MainListRead.dc.html", "ComposeEditor.dc.html", "Settings.dc.html", "ComposeAssist.dc.html", "Search.dc.html", "BulkSelect.dc.html", "ListStates.dc.html", "SettingsAccount.dc.html", "SettingsVacation.dc.html", "MailboxManage.dc.html", "MailActions4.dc.html"],
|
||||
"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 },
|
||||
"phase3": { "x": 0, "y": 4180, "text": "3단계 화면 — 새 메일 · 검색 · 여러 통 처리 (2026-10-27 확정)", "kind": "title1", "maxW": 4480 }
|
||||
"phase3": { "x": 0, "y": 4180, "text": "3단계 화면 — 새 메일 · 검색 · 여러 통 처리 (2026-10-27 확정)", "kind": "title1", "maxW": 4480 },
|
||||
"phase4": { "x": 0, "y": 6340, "text": "4단계 화면 — 계정 · 메일 설정 (비밀번호 · 부재중 응답 · 메일함 관리 · 스팸 · 원문) — 2026-10-28 확정", "kind": "title1", "maxW": 6000 }
|
||||
},
|
||||
"designSystems": []
|
||||
}
|
||||
|
||||
+115
-6
@@ -8,7 +8,7 @@
|
||||
|
||||
- 성공 `{ "success": true, "data": … }` / 실패 `{ "success": false, "error": { "code", "message", "traceId" } }`
|
||||
- 날짜·시각은 ISO 8601 UTC 문자열, 목록은 `{ items, pageInfo }`
|
||||
- 이 단계에서 쓰는 에러 코드: `AUTH_001` `AUTH_002`(클라이언트 전용) `AUTH_003` `AUTH_004` `VAL_001` `RES_001` `REQ_001` `FILE_001` `FILE_002` `MAIL_001` `MAIL_002` `MAIL_003` `MAIL_004` `MAIL_005` `MAIL_006` `MAIL_007` `MAIL_008` `SYS_001`
|
||||
- 이 단계에서 쓰는 에러 코드: `AUTH_001` `AUTH_002`(클라이언트 전용) `AUTH_003` `AUTH_004` `AUTH_005` `VAL_001` `RES_001` `REQ_001` `FILE_001` `FILE_002` `MAIL_001` `MAIL_002` `MAIL_003` `MAIL_004` `MAIL_005` `MAIL_006` `MAIL_007` `MAIL_008` `MAIL_009` `SYS_001`
|
||||
- **적용 범위: 1단계는 웹(브라우저) 전용이다.** 인증은 HttpOnly 쿠키뿐이고 토큰을 본문으로 주지 않는다.
|
||||
모바일 앱(Flutter)의 인증 방식(토큰 발급·`flutter_secure_storage`)은 앱 착수 때 이 문서에 추가한다.
|
||||
- **배포 형태: 화면과 API 는 같은 주소(origin)를 쓴다.** 운영은 프론트 nginx 가 `/api` 를 백엔드로 넘기고,
|
||||
@@ -43,8 +43,8 @@
|
||||
| --- | --- |
|
||||
| 계정 없음 · 비밀번호 틀림 · 사내 도메인이 아닌 주소 · 메일 엔진 관리자 계정(웹메일 로그인 대상이 아니다) (구분하지 않는다) | 401 `AUTH_004` |
|
||||
| 형식 오류 | 400 `VAL_001` |
|
||||
| 시도 과다 — 같은 IP 에서 1분에 **실패** 20회, 같은 주소로 15분에 **실패** 10회 (성공한 로그인은 세지 않는다 — 사무실처럼 여러 직원이 한 IP 를 쓰는 환경 때문). 이와 별도로 같은 IP 의 로그인 **요청**은 1분에 60회까지. "같은 IP" 는 IPv6 를 **/64 단위**로 묶는다(IPv4-mapped 주소는 IPv4 로). 사내 도메인이 아닌 주소는 IP 한도에만 센다 | 429 `REQ_001` |
|
||||
| 같은 주소의 로그인이 아직 처리 중 (버튼을 두 번 누른 경우 등) | 429 `REQ_001` |
|
||||
| 시도 과다 — 같은 IP 에서 1분에 **실패** 20회, 같은 주소로 15분에 **실패** 10회 (성공한 로그인은 세지 않는다 — 사무실처럼 여러 직원이 한 IP 를 쓰는 환경 때문). 이와 별도로 같은 IP 의 로그인 **요청**은 1분에 60회까지. "같은 IP" 는 IPv6 를 **/64 단위**로 묶는다(IPv4-mapped 주소는 IPv4 로). 사내 도메인이 아닌 주소는 IP 한도에만 센다. 비밀번호 변경(`PUT /api/auth/password`)의 지금 비밀번호 틀림도 같은 한도로 함께 센다 | 429 `REQ_001` |
|
||||
| 같은 주소의 로그인 · 비밀번호 변경이 아직 처리 중 (버튼을 두 번 누른 경우 등) | 429 `REQ_001` |
|
||||
| 대량 공격으로 서버가 기억하는 시도 기록이 상한에 닿았고 밀어낼 수 있는 기록이 없음 — 새 IP·주소의 로그인 (잠금 기록을 지키려고 막는 쪽을 택한다) | 429 `REQ_001` |
|
||||
| 메일 엔진 장애 | 503 `MAIL_001` |
|
||||
|
||||
@@ -76,6 +76,34 @@
|
||||
|
||||
항상 200 (`data: null`). 두 쿠키를 지우고 **서버의 세션을 폐기**한다.
|
||||
|
||||
### `PUT /api/auth/password` — 4단계
|
||||
|
||||
```jsonc
|
||||
// 요청 (로그인 필요)
|
||||
{ "currentPassword": "…", "newPassword": "…" }
|
||||
// 200 (data: null)
|
||||
```
|
||||
|
||||
| 실패 | 코드 |
|
||||
| --- | --- |
|
||||
| 지금 비밀번호가 틀림 | 400 `AUTH_005` — 세션은 그대로다. 401 이 아닌 까닭: 클라이언트는 401 이면 세션을 갱신하고 같은 요청을 다시 보낸다 |
|
||||
| 새 비밀번호가 8자 미만 · 128자 초과 · 지금 비밀번호와 같음, 엔진 규칙에 어긋남 · 지금 비밀번호가 비었거나 256자 초과(로그인과 같은 한도) — 길이는 JS 문자열 길이 | 400 `VAL_001` |
|
||||
| 지금 비밀번호 틀림이 잦음 — 로그인 실패와 **같은 한도**(같은 IP 1분 20회 · 같은 주소 15분 10회)를 함께 센다. 같은 주소의 로그인 · 변경이 처리 중 | 429 `REQ_001` |
|
||||
| 웹메일로 다루지 않는 계정(메일 엔진 관리자 · 게이트웨이 · 막은 계정 — 엔진에 묻지 않는다) | 401 `AUTH_001` |
|
||||
| 메일 엔진 장애 — 바꾸는 요청의 결과를 알 수 없는 실패(시간 초과 · 연결 끊김 · 5xx)도 | 503 `MAIL_001` — 결과를 모를 때는 바뀌었을 수 있으므로 **다른 세션을 끊어 둔다** |
|
||||
|
||||
- 서버는 메일 엔진에 **그 직원 본인의 자격으로**(지금 비밀번호) 본인 비밀번호 변경(Stalwart `x:AccountPassword/set` 에 `currentSecret` · `secret`)을 보낸다 —
|
||||
게이트웨이 대리 접근이나 관리자 권한을 쓰지 않는다. 엔진은 지금 비밀번호 없이는 바꾸지 않고 8자 미만을 거절한다 (2026-10-28 개발 엔진에서 확인).
|
||||
세션은 같은 값으로 열렸는데 엔진이 바꾸기를 거절하면(`forbidden` · `currentSecret` 의 `invalidProperties` · 바꾸기 요청의 401 — 앱 비밀번호를 넣은 경우 · 역할의 권한 설정)
|
||||
`AUTH_005` 로 답하되 실패 횟수로 세지 않고 세션도 끊지 않는다(추측이 아니라서). 결과를 알 수 없는 실패도 실패 횟수로 세지 않는다.
|
||||
- 바뀌면 그 직원의 **다른 로그인 세션을 모두 끊는다** — 이 요청의 리프레시 쿠키(Path=`/api/auth` 라 이 요청에도 온다)가 가리키는 세션만 남긴다.
|
||||
쿠키가 없거나 다른 직원 것이면 이 세션도 끊긴다(다음 갱신에서 로그인 화면으로). 끊긴 기기의 액세스 토큰은 만료(최대 1시간)까지 쓰인다(위 세션 규칙).
|
||||
휴대폰 · PC 메일 프로그램(IMAP)은 새 비밀번호를 다시 넣어야 한다.
|
||||
- 비밀번호는 바뀌었는데 세션 끊기가 실패하면 200 으로 답하고 오류 로그를 남긴다 — 실패로 답하면 사용자가 옛 비밀번호로 다시 시도하기 때문이다.
|
||||
- 비밀번호는 로그 · 응답에 남기지 않는다. 바꾼 일은 info 로그에 주소(가린 것)만 남긴다.
|
||||
- **클라이언트**: 같은 규칙(8자 이상 · 지금과 다름 · 확인 칸 일치)을 먼저 보고, `AUTH_005` 는 지금 비밀번호 칸 아래에 사전 문구로 보인다. 성공하면 세 칸을 비운다.
|
||||
요청 한도는 30초다 — 서버가 엔진을 두 번 부르고 세션을 끊으므로, 기본 한도(10초)로는 서버가 바꾼 뒤 화면만 실패로 볼 수 있다.
|
||||
|
||||
### 클라이언트 처리 규칙
|
||||
|
||||
- 보호 API 가 401 `AUTH_001` 을 주면 **한 번만** `/auth/refresh` 를 시도하고, 성공하면 원래 요청을 다시 보낸다.
|
||||
@@ -175,17 +203,25 @@ HTML 은 정화하기 **전에** 길이(HTML 부분이 여럿이면 이어 붙
|
||||
| 메서드 · 경로 | 요청 | 성공 | 주요 실패 |
|
||||
| --- | --- | --- | --- |
|
||||
| `GET /api/mailboxes` | — | 200 `{ items: Mailbox[], pageInfo }` (hasNext=false) | `MAIL_001` |
|
||||
| `POST /api/mailboxes` | `{ name, parentId? }` (아래 "메일함 관리") | 201 `Mailbox` | 400 `VAL_001`, 404 `RES_001`(없는 parentId), 409 `MAIL_009`(같은 자리에 같은 이름), 503 `MAIL_001` |
|
||||
| `PATCH /api/mailboxes/{id}` | `{ name }` | 200 `Mailbox` | 400 `VAL_001`(기본 메일함), 404 `RES_001`, 409 `MAIL_009`, 503 `MAIL_001` |
|
||||
| `DELETE /api/mailboxes/{id}` | — | 200 `MailboxDeletion` — 안의 메일을 휴지통으로 옮긴 뒤 지운다 | 400 `VAL_001`(기본 메일함 · 하위 메일함 있음), 404 `RES_001`, 429 `REQ_001`(같은 메일함의 처리가 진행 중), 503 `MAIL_001` |
|
||||
| `PATCH /api/mailboxes/{id}/messages` | `{ isRead: true }` — 그 메일함 전체를 읽음으로 | 200 `MailboxBulkResult` | 400 `VAL_001`, 404 `RES_001`, 429 `REQ_001`(같은 메일함의 처리가 진행 중), 503 `MAIL_001` |
|
||||
| `DELETE /api/mailboxes/{id}/messages` | — 휴지통 · 스팸메일함 비우기(영구 삭제) | 200 `MailboxBulkResult` | 400 `VAL_001`(휴지통 · 스팸메일함이 아님), 404 `RES_001`, 429 `REQ_001`(같은 메일함의 처리가 진행 중), 503 `MAIL_001` |
|
||||
| `GET /api/mail-vacation` | — | 200 `MailVacation` (아래 "부재중 자동 응답") | 503 `MAIL_001` |
|
||||
| `PUT /api/mail-vacation` | `MailVacation` (전체 교체) | 200 `MailVacation` | 400 `VAL_001`, 503 `MAIL_001` |
|
||||
| `GET /api/mail-quota` | — | 200 `MailQuota` | `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` |
|
||||
| `PATCH /api/messages` | `{ ids, isRead?, isFlagged?, mailboxId? }` — 여러 통 (아래 "여러 통 한꺼번에") | 200 `MessagesPatched` | 400 `VAL_001`, 404 `RES_001`(없는 `mailboxId` — 아무것도 바꾸지 않음), 503 `MAIL_001` |
|
||||
| `PATCH /api/messages/{id}` | `{ isRead?, isFlagged?, mailboxId?, isJunk? }` (하나 이상 — `isJunk` 는 4단계 "스팸 · 원문") | 200 `MessageSummary` | 404 `RES_001`, 400 `VAL_001` |
|
||||
| `PATCH /api/messages` | `{ ids, isRead?, isFlagged?, mailboxId?, isJunk? }` — 여러 통 (아래 "여러 통 한꺼번에") | 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` |
|
||||
| `GET /api/messages/{id}/source` | — | 받은 그대로의 메일 `message/rfc822` (표준 래퍼 없음, `Content-Disposition: attachment; filename="<제목>.eml"`) — 4단계 | 404 `RES_001`, 503 `MAIL_001` |
|
||||
| `GET /api/drafts/{id}` | — | 200 `Draft` (아래 "임시 저장") | 404 `RES_001`(없음·초안이 아님), 503 `MAIL_001` |
|
||||
| `POST /api/drafts` | 초안 본문 | 201 `DraftSaved` | 400 `VAL_001`·`MAIL_002`·`MAIL_003`·`MAIL_004`·`MAIL_006`·`MAIL_007`·`FILE_001`, 413 `FILE_002`, 503 `MAIL_001` |
|
||||
| `PUT /api/drafts/{id}` | 초안 본문 (전체 교체) | 200 `DraftSaved` — **새 id** | 위와 같음 + 404 `RES_001`(초안이 아닌 id — 지우지 않음) + 409 `MAIL_008`(다른 곳에서 이미 바뀜 — 저장 안 함) |
|
||||
@@ -535,8 +571,81 @@ interface MessageDeletionResult {
|
||||
- `failedIds` · `skippedIds` 가 있으면 알림에 "N통은 그사이 옮겨졌거나 지워져 그대로 두었습니다"를 덧붙인다.
|
||||
- 메일함을 바꾸거나 검색하면 선택을 푼다. 새 메일 확인으로 목록을 맞출 때는 남아 있는 메일의 선택을 그대로 둔다.
|
||||
|
||||
### 메일함 관리 (`/api/mailboxes`) — 4단계
|
||||
|
||||
```ts
|
||||
interface MailboxCreate { name: string; parentId?: string | null } // parentId 없음 = 맨 위
|
||||
interface MailboxRename { name: string }
|
||||
interface MailboxDeletion {
|
||||
deleted: boolean // true = 지웠다. false = 아직 옮길 메일이 남았다 — 같은 요청을 다시 보낸다
|
||||
processed: number // 이번 요청에서 이 메일함에서 빠진 메일 수 — 휴지통으로 옮김 + 다른 메일함에도 있어 이 메일함에서만 뺌 (진행 표시용)
|
||||
movedToTrash: number // 그중 휴지통으로 옮긴 수 (끝난 알림의 "안의 메일 N통은 휴지통에 있습니다")
|
||||
remaining: number // 아직 남은 메일 수
|
||||
}
|
||||
interface MailboxBulkResult {
|
||||
processed: number // 이번 요청에서 읽음으로 바꾼 · 지운 메일 수
|
||||
remaining: number // 남은 수 — 0 이 될 때까지 같은 요청을 다시 보낸다
|
||||
}
|
||||
```
|
||||
|
||||
- **이름**: 앞뒤 공백을 뺀 1~64자(JS 문자열 길이), `/` 는 쓰지 않는다(경로 구분자로 읽는 메일 프로그램이 있다). 제어 문자도 쓰지 않는다 —
|
||||
C0(U+0000–U+001F, 탭 · 줄바꿈 포함) · DEL(U+007F) · C1(U+0080–U+009F). 그 밖의 문자(NBSP · 이모지 등)는 받는다.
|
||||
같은 상위 안에 같은 이름은 409 `MAIL_009` (엔진이 `alreadyExists` 로 거절한다 — 2026-10-28 확인). 지금 이름으로 바꾸면 엔진에 묻지 않고 그대로 돌려준다.
|
||||
만든 메일함은 구독 상태(`isSubscribed`)라 IMAP 메일 프로그램에도 보인다.
|
||||
- **만들 수 있는 자리**: 맨 위, 받은편지함 · 보관함 · 내 메일함(역할 없음) 아래. 보낸편지함 · 임시보관함 · 스팸메일함 · 휴지통 아래는 400 `VAL_001`.
|
||||
웹 화면은 받은편지함 · 내 메일함 아래만 메뉴에 둔다(보관함 아래는 다른 메일 프로그램이 만든 것을 그대로 보인다).
|
||||
- **기본 메일함**(역할이 있는 메일함)은 이름을 바꾸거나 지울 수 없다 — 400 `VAL_001`. 화면은 메뉴에 그 항목을 두지 않는다.
|
||||
- **지우기**: 하위 메일함이 있으면 400 `VAL_001`(화면이 먼저 막는다). 안의 메일은 **휴지통으로 옮긴 뒤**(다른 메일함에도 있는 메일은 이 메일함에서만 뺀다)
|
||||
빈 메일함을 지운다 — 엔진은 메일이 든 메일함을 `mailboxHasEmail` 로 거절하므로(`onDestroyRemoveEmails: false` — 메일을 영구 삭제하는 옵션은 쓰지 않는다) 그사이 들어온 메일도 남지 않는다.
|
||||
메일이 많으면 한 요청에 다 옮기지 못한다 — `deleted: false` 면 클라이언트가 같은 요청을 다시 보낸다.
|
||||
- **전체 읽음**(`PATCH /api/mailboxes/{id}/messages {isRead:true}`): 안 읽은 메일을 500통씩 읽음으로 바꾼다. **비우기**(`DELETE /api/mailboxes/{id}/messages`): 휴지통 · 스팸메일함만,
|
||||
500통씩 확인한 상태 그대로일 때만(`ifInState`) 영구 삭제한다 — 그 메일함에만 있는 메일은 지우고, 다른 메일함에도 있는 메일은 그 메일함에서만 뺀다(`POST /api/message-deletions` 와 같은 규칙).
|
||||
- 오래 걸리는 처리(전체 읽음 · 비우기 · 메일함 지우기)는 한 요청을 25초 안에 끝내고(엔진 호출마다 남은 시간만큼만 기다린다) `remaining` 을 돌려준다.
|
||||
클라이언트는 0(또는 `deleted: true`)이 될 때까지 다시 보내며 진행을 알림으로 보인다("…하는 중 · 600 / 1,234통"). 도중에 실패하면 거기까지 처리한 수를 알리고, 다시 하면 남은 것만 처리한다.
|
||||
- 같은 직원 · 같은 메일함의 처리는 한 번에 하나다 — 진행 중이면 엔진에 묻지 않고 429 `REQ_001`(다른 탭에서 같은 메일함을 처리할 때). 다른 메일함끼리는 함께 된다.
|
||||
- 한 요청의 상한 25초는 메일함 목록을 읽기 전부터 잰다(엔진의 세션 조회 포함). 다음 500통은 남은 시간이 5초보다 많을 때만 시작한다.
|
||||
- 멈추는 경우 — 진행한 것이 있으면 거기까지 200(`remaining` > 0 · `deleted: false`), 하나도 처리하지 못했으면 503 `MAIL_001`(클라이언트가 끝없이 다시 보내지 않게):
|
||||
아무것도 바뀌지 않는 차례(확인하는 사이 계속 바뀜 · 바뀐 것도 거절도 없음 · 비운 메일함을 지우는 사이 메일이 들어옴)가 이어서 3번, 남은 시간 때문에 엔진 호출이 끊김.
|
||||
엔진이 한 차례를 모두 거절하면 진행한 것이 없을 때 그 거절의 코드(권한 없음 403 `AUTH_003` 등)다.
|
||||
- **비우기는 차례마다 그 시점의 메일함을 다시 읽는다** — 비우는 동안 들어온 메일(새 스팸, 다른 탭에서 휴지통으로 옮긴 메일)도 지운다(흔한 메일 프로그램과 같다).
|
||||
그래서 화면은 휴지통 비우기와 메일함 지우기(안의 메일이 휴지통으로 간다)를 함께 돌리지 않는다.
|
||||
- **클라이언트**: 메일함 패널의 각 메일함 ⋯ 메뉴 — 모두 읽음으로 표시(안 읽은 메일이 있을 때) · 하위 메일함 만들기 · 이름 바꾸기 · 지우기(내 메일함만) · 휴지통 / 스팸메일함 비우기.
|
||||
휴지통 · 스팸메일함 목록 머리에도 「비우기」. 비우기 · 지우기는 확인 대화상자(처음 초점 「취소」) 뒤에만 하고, 확인한 그 메일함만 처리한다.
|
||||
다른 탭에서 먼저 끝냈으면 실패로 보이지 않는다 — 모두 읽음 · 비우기의 0통은 "…에 안 읽은 메일이 없습니다" · "…이 이미 비어 있습니다", 지우기의 404 `RES_001` 은 지운 것으로 본다.
|
||||
메일함 지우기의 진행은 `processed` 로 센다(`movedToTrash` 는 휴지통으로 간 것만이라 다른 메일함에도 든 메일만 남으면 늘지 않는다).
|
||||
|
||||
### 부재중 자동 응답 (`/api/mail-vacation`) — 4단계
|
||||
|
||||
```ts
|
||||
interface MailVacation {
|
||||
isEnabled: boolean
|
||||
fromDate: string | null // ISO 8601 UTC — null 이면 켠 때부터
|
||||
toDate: string | null // null 이면 끌 때까지
|
||||
subject: string // 200자까지 — 켤 때(isEnabled true)는 공백이 아닌 글자 1자 이상. 제어 문자(메일함 이름과 같은 범위 — 탭 · 줄바꿈 포함)는 쓰지 않는다(한 줄짜리 머리글)
|
||||
textBody: string // 4,000자까지 — 켤 때는 공백이 아닌 글자 1자 이상. 글자만(서식 없음) — 제어 문자는 탭 · LF · CR 만. 서명은 붙지 않는다
|
||||
}
|
||||
```
|
||||
|
||||
- 한 번도 설정하지 않은 계정은 `{ isEnabled: false, fromDate: null, toDate: null, subject: "", textBody: "" }` 로 온다(엔진의 null 은 빈 문자열로). 끈 채로는 빈 제목 · 내용도 저장된다.
|
||||
제목 · 내용은 쓴 그대로 저장한다 — "공백이 아닌 글자" 검사만 앞뒤 공백을 뺀 값(JS `trim()` — NBSP · U+3000 포함)으로 한다. 제어 문자 검사는 꺼 둔 채로도 한다.
|
||||
|
||||
- 엔진의 JMAP `VacationResponse`(singleton)를 그대로 쓴다 — `htmlBody` 는 비운다(null). 저장은 전체 교체다. 꺼도 쓴 제목 · 내용은 남겨 둔다.
|
||||
- `fromDate` · `toDate` 는 `Z` 로 끝나는 UTC 시각이고, 두 키는 늘 보낸다(값은 null 가능). 엔진에는 초 단위로 저장한다(소수 초는 버린다).
|
||||
둘 다 있으면 버린 값으로 `fromDate < toDate`(아니면 400 `VAL_001`). 화면은 날짜만 고르고, 브라우저 시간대의 시작일 0시 · 끝나는 날 다음 날 0시를 UTC 로 보낸다.
|
||||
- 같은 사람에게 매번 답하지는 않는다 — JMAP 규격(RFC 8621 8절)이 "보낸 사람마다 적당한 기간에 한 번" 을 요구하고 엔진이 따른다.
|
||||
- **클라이언트**: 설정 › 메일 › 부재중 자동 응답. 켜져 있으면 메일 화면의 메일함 패널 아래(사용량 위)에 "부재중 자동 응답 켜짐 · 11월 3일까지"(누르면 설정)를 보인다 —
|
||||
메일 화면에 들어올 때 한 번 받고, 시간이 지나면 다시 계산한다(끝나는 시각이 지나면 숨기고, 시작 전이면 "10월 30일 ~ 11월 3일 · 설정").
|
||||
|
||||
### 스팸 · 원문 — 4단계
|
||||
|
||||
- **스팸 신고 · 스팸 아님**: `PATCH /api/messages/{id}` · `PATCH /api/messages` 의 `isJunk` — `true` 면 키워드 `$junk` 를 붙이고 `$notjunk` 를 뗀다, `false` 면 반대(RFC 8621 4.1.1).
|
||||
화면은 이동과 함께 보낸다 — 스팸 신고 `{ mailboxId: <스팸메일함>, isJunk: true }`, 스팸 아님 `{ mailboxId: <받은편지함>, isJunk: false }`. 엔진의 스팸 학습에 쓰이는지는 엔진 몫이다.
|
||||
「되돌리기」는 원래 메일함으로 옮기며 보낸 값의 반대를 보낸다 — 표시를 지우는 값은 두지 않아서, 원래 아무 표시도 없던 메일도 되돌린 뒤에는 `$notjunk`(스팸 아님)가 남는다.
|
||||
- **원문 내려받기**: `GET /api/messages/{id}/source` — 엔진의 원본(blob)을 그대로 흘려보낸다(`message/rfc822`, `Cache-Control: private, no-store`). 파일 이름은 제목(없으면 "메일")에
|
||||
`.eml` — 다운로드 헤더는 첨부와 같은 규칙(`shared/utils/content-disposition.ts`). 화면은 첨부 내려받기처럼 이동 전에 세션을 확인한다(`confirmSession()`).
|
||||
|
||||
### 이 단계에서 하지 않는 것
|
||||
|
||||
실시간 푸시(SSE · 데스크톱 알림 · 소리), 본문 안 이미지(인라인 cid — 붙여 넣은 이미지는 첨부로, 서명 그림만 예외), 외부 이미지 보기(이미지 중계),
|
||||
여러 개의 서명, 엔진 계정 목록 기반의 사내 주소록, 자체 검색 색인(붙여 쓴 말 · 앞부분 · 제목 말머리로 찾기), 조건 검색(보낸 사람 · 기간 · 첨부 있음), 검색어 강조,
|
||||
불러오지 않은 메일까지 한꺼번에(메일함 전체 읽음 표시 · 휴지통 비우기), 끌어다 놓아 옮기기.
|
||||
끌어다 놓아 옮기기, 메일함 옮기기(다른 상위로) · 하위 메일함까지 한꺼번에 지우기, 앱 비밀번호 · 2단계 인증(엔진은 지원한다), 부재중 응답의 서식 본문 · 사내/외부 구분, 인쇄.
|
||||
|
||||
@@ -7,6 +7,7 @@ const Map<String, String> kErrorCodeMessages = {
|
||||
'AUTH_002': '안전을 위해 로그아웃 되었습니다. 다시 로그인해 주세요.',
|
||||
'AUTH_003': '해당 메뉴나 기능에 접근할 수 있는 권한이 없습니다.',
|
||||
'AUTH_004': '이메일 또는 비밀번호가 올바르지 않습니다.',
|
||||
'AUTH_005': '지금 비밀번호가 올바르지 않습니다.',
|
||||
'VAL_001': '입력하신 정보를 다시 확인해 주세요. (필수값 누락 또는 형식 오류)',
|
||||
'RES_001': '요청하신 정보나 페이지를 찾을 수 없습니다.',
|
||||
'BIZ_001': '요청하신 작업을 완료하지 못했습니다. 다시 시도해 주세요.',
|
||||
@@ -20,6 +21,7 @@ const Map<String, String> kErrorCodeMessages = {
|
||||
'MAIL_006': '첨부는 한 메일에 최대 20개까지 보낼 수 있습니다.',
|
||||
'MAIL_007': '본문이 너무 길어 보낼 수 없습니다. 내용을 줄인 뒤 다시 보내 주세요.',
|
||||
'MAIL_008': '임시 저장한 메일이 다른 곳에서 바뀌었거나 지워졌습니다. 지금 내용을 새 임시 저장본으로 남겨 주세요.',
|
||||
'MAIL_009': '같은 이름의 메일함이 이미 있습니다.',
|
||||
};
|
||||
|
||||
/// 매핑 실패 시 사용하는 폴백 코드
|
||||
|
||||
@@ -1,19 +1,24 @@
|
||||
<script setup lang="ts">
|
||||
// 모달 대화상자 틀 — 브라우저 <dialog>(showModal)로 띄운다. 모양은 디자인 원본 States "세션 만료".
|
||||
// 쓰는 곳: SessionExpiredDialog(세션 만료) · LeaveConfirmDialog(떠나기 전 확인 — 쓰기 화면 · 설정 › 서명) · MailPurgeDialog(영구 삭제 확인)
|
||||
// 쓰는 곳: SessionExpiredDialog(세션 만료) · LeaveConfirmDialog(떠나기 전 확인 — 쓰기 화면 · 설정 › 서명 · 부재중 자동 응답) ·
|
||||
// MailPurgeDialog(영구 삭제 확인) · 메일함 관리(MailboxNameDialog — 이름 칸 · MailboxDeleteDialog · MailboxEmptyDialog)
|
||||
// - 모달이라 뒤의 화면을 조작할 수 없다. 처음 초점은 autofocus 를 붙인 단추 (없으면 브라우저가 첫 단추로)
|
||||
// - Esc(cancel)는 브라우저가 닫지 않게 막고 cancel 로 알린다 — 무엇을 할지는 쓰는 쪽이 정한다
|
||||
// - 닫기는 열기보다 먼저 한다 — 한 대화상자가 닫히고 다른 대화상자가 열리는 때(세션 만료) 둘이 겹치지 않게
|
||||
// 단추는 actions 슬롯에 둔다: class="modal-dialog__action" (보조 단추는 modal-dialog__action--secondary, 되돌릴 수 없는 일은 --danger 를 더한다)
|
||||
import { onMounted, ref, watch } from 'vue'
|
||||
// 글(message) 대신 · 글 아래에 입력 칸을 둘 때는 기본 슬롯에 넣는다 — 글이 없으면 describedBy 로 칸의 안내를 가리킨다
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
|
||||
interface Props {
|
||||
open: boolean
|
||||
title: string
|
||||
message: string
|
||||
/** 제목 아래 글 — 없으면 그리지 않는다 (입력 칸만 있는 대화상자) */
|
||||
message?: string
|
||||
/** 제목 · 글 문단의 id — aria-labelledby · aria-describedby 가 가리킨다 (화면에 하나뿐인 값) */
|
||||
titleId: string
|
||||
messageId: string
|
||||
messageId?: string
|
||||
/** 글이 없을 때 aria-describedby 가 가리킬 요소의 id (입력 칸의 안내 등) */
|
||||
describedBy?: string
|
||||
/** 사용자가 답해야 하는 확인이면 'alertdialog' — 주지 않으면 dialog 그대로 */
|
||||
role?: 'alertdialog'
|
||||
}
|
||||
@@ -23,9 +28,16 @@ interface Emits {
|
||||
(e: 'cancel'): void
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
message: '',
|
||||
messageId: undefined,
|
||||
describedBy: undefined,
|
||||
role: undefined,
|
||||
})
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
const descriptionId = computed(() => (props.message ? props.messageId : props.describedBy))
|
||||
|
||||
const dialogRef = ref<HTMLDialogElement | null>(null)
|
||||
/** 열기 전에 초점이 있던 곳 — 닫으면 돌려준다 (브라우저가 돌려주지 않는 환경에서도) */
|
||||
let returnFocus: HTMLElement | null = null
|
||||
@@ -88,7 +100,7 @@ onMounted(() => {
|
||||
class="modal-dialog"
|
||||
:role="role"
|
||||
:aria-labelledby="titleId"
|
||||
:aria-describedby="messageId"
|
||||
:aria-describedby="descriptionId"
|
||||
@cancel="onCancel"
|
||||
@close="onClose"
|
||||
>
|
||||
@@ -99,11 +111,13 @@ onMounted(() => {
|
||||
{{ title }}
|
||||
</h2>
|
||||
<p
|
||||
v-if="message"
|
||||
:id="messageId"
|
||||
class="modal-dialog__message"
|
||||
>
|
||||
{{ message }}
|
||||
</p>
|
||||
<slot />
|
||||
<div class="modal-dialog__actions">
|
||||
<slot name="actions" />
|
||||
</div>
|
||||
@@ -164,16 +178,25 @@ onMounted(() => {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
:slotted(.modal-dialog__action:hover) {
|
||||
:slotted(.modal-dialog__action:not(:disabled):hover) {
|
||||
background: var(--color-brand-strong);
|
||||
}
|
||||
|
||||
/*
|
||||
* 아직 누를 수 없는 단추 (메일함 이름을 비웠다 · 보내는 중 · 함께 돌 수 없는 처리가 도는 중) — 마우스를 올려도 그대로.
|
||||
* 마우스를 올린 모양은 모두 :not(:disabled) 로만 그린다 — 꺼진 빨강 · 보조 단추가 브랜드색으로 바뀌지 않게
|
||||
*/
|
||||
:slotted(.modal-dialog__action:disabled) {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* 되돌릴 수 없는 일 — 빨강 채움 (영구 삭제, 디자인 원본 BulkSelect) */
|
||||
:slotted(.modal-dialog__action--danger) {
|
||||
background: var(--color-danger);
|
||||
}
|
||||
|
||||
:slotted(.modal-dialog__action--danger:hover) {
|
||||
:slotted(.modal-dialog__action--danger:not(:disabled):hover) {
|
||||
background: var(--color-danger-strong);
|
||||
}
|
||||
|
||||
@@ -184,7 +207,7 @@ onMounted(() => {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
:slotted(.modal-dialog__action--secondary:hover) {
|
||||
:slotted(.modal-dialog__action--secondary:not(:disabled):hover) {
|
||||
background: var(--color-surface-muted);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
@@ -1,13 +1,22 @@
|
||||
<script setup lang="ts">
|
||||
// 화면 오른쪽 아래 알림 (디자인 원본 States "오류 알림" · "보내기 완료")
|
||||
// 화면 오른쪽 아래 알림 (디자인 원본 States "오류 알림" · "보내기 완료" · MailActions4 "오래 걸리는 처리")
|
||||
// 오류: 흰 카드 + 문의 코드(traceId 앞 8자리) + 닫기 / 성공: 브랜드 바탕 + 이동 링크 또는 그 자리의 단추(되돌리기 등)
|
||||
// 진행: 성공과 같은 바탕 + 돌아가는 아이콘 — 끝날 때까지 남았다가 그 자리에서 결과로 바뀐다 (toast.store 의 update)
|
||||
// 마우스가 올라가 있거나 초점이 알림 안에 있는 동안은 닫히지 않는다 (toast.store 의 hold · release — WCAG 2.2.1)
|
||||
import AppIcon from '@/components/AppIcon.vue'
|
||||
import type { IconName } from '@/shared/constants/icons'
|
||||
import { formatTraceCode } from '@/shared/utils/format'
|
||||
import { useToastStore, type Toast } from '@/stores/toast.store'
|
||||
import { useToastStore, type Toast, type ToastKind } from '@/stores/toast.store'
|
||||
|
||||
const toastStore = useToastStore()
|
||||
|
||||
/** 종류별 아이콘 — 오류 18 · 성공 ✓ 16 · 진행 원의 3/4 (돌아간다) */
|
||||
const TOAST_ICONS: Readonly<Record<ToastKind, { name: IconName; size: number; strokeWidth: number }>> = {
|
||||
error: { name: 'alert-circle', size: 18, strokeWidth: 1.75 },
|
||||
success: { name: 'check', size: 16, strokeWidth: 2.2 },
|
||||
progress: { name: 'loader', size: 16, strokeWidth: 2 },
|
||||
}
|
||||
|
||||
/** 초점이 알림 밖으로 나갔을 때만 놓는다 — 알림 안의 다른 단추로 옮긴 것은 그대로 붙잡는다 */
|
||||
function onFocusOut(event: FocusEvent, id: number) {
|
||||
const next = event.relatedTarget
|
||||
@@ -44,9 +53,9 @@ function runAction(toast: Toast) {
|
||||
@focusout="onFocusOut($event, toast.id)"
|
||||
>
|
||||
<AppIcon
|
||||
:name="toast.kind === 'error' ? 'alert-circle' : 'check'"
|
||||
:size="toast.kind === 'error' ? 18 : 16"
|
||||
:stroke-width="toast.kind === 'error' ? 1.75 : 2.2"
|
||||
:name="TOAST_ICONS[toast.kind].name"
|
||||
:size="TOAST_ICONS[toast.kind].size"
|
||||
:stroke-width="TOAST_ICONS[toast.kind].strokeWidth"
|
||||
class="toast__icon"
|
||||
/>
|
||||
<div class="toast__body">
|
||||
@@ -121,7 +130,8 @@ function runAction(toast: Toast) {
|
||||
box-shadow: var(--shadow-toast);
|
||||
}
|
||||
|
||||
.toast--success {
|
||||
.toast--success,
|
||||
.toast--progress {
|
||||
align-items: center;
|
||||
padding: var(--space-8) var(--space-6) var(--space-8) var(--space-14);
|
||||
border-radius: var(--radius-lg);
|
||||
@@ -143,6 +153,28 @@ function runAction(toast: Toast) {
|
||||
color: var(--color-on-brand-success);
|
||||
}
|
||||
|
||||
/* 진행 — 돌아가는 아이콘 · 숫자 폭을 고정해 "600 / 1,234통"이 바뀔 때 글자가 흔들리지 않게 */
|
||||
.toast--progress {
|
||||
min-height: var(--size-touch);
|
||||
padding-right: var(--space-14);
|
||||
}
|
||||
|
||||
.toast--progress .toast__icon {
|
||||
margin-top: 0;
|
||||
color: var(--color-on-brand-link);
|
||||
animation: toast-spin 0.9s linear infinite;
|
||||
}
|
||||
|
||||
.toast--progress .toast__message {
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
@keyframes toast-spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.toast__body {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
|
||||
@@ -13,8 +13,8 @@ interface Props {
|
||||
/** 툴팁 — 주지 않으면 이름 */
|
||||
title?: string
|
||||
labelMode?: 'always' | 'wide' | 'never'
|
||||
/** danger: 삭제 · 영구 삭제 (빨강) */
|
||||
tone?: 'default' | 'danger'
|
||||
/** danger: 삭제 · 영구 삭제 (빨강) · accent: 스팸 아님 (강조 글자 굵게) */
|
||||
tone?: 'default' | 'danger' | 'accent'
|
||||
/** 메뉴를 여는 단추 — 이름 뒤 ▾ (아이콘만일 때는 빼고) */
|
||||
menu?: boolean
|
||||
disabled?: boolean
|
||||
@@ -108,6 +108,16 @@ defineExpose({
|
||||
color: var(--color-danger-strong);
|
||||
}
|
||||
|
||||
/* 스팸 아님 — 읽기 화면과 같은 강조 글자 (디자인 원본 MailActions4) */
|
||||
.bulk-button--accent {
|
||||
color: var(--color-brand-strong);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.bulk-button--accent:hover:not(:disabled) {
|
||||
color: var(--color-brand-strong);
|
||||
}
|
||||
|
||||
/* 아이콘만 40×44 (분할 보기의 목록 칸) */
|
||||
.bulk-button--label-never {
|
||||
width: var(--size-icon-button);
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
<script setup lang="ts">
|
||||
// 목록 머리 — 메일함 이름 · 개수("전체 N · 안 읽음 M") · 보기 전환 · 새로고침
|
||||
// 목록 머리 — 메일함 이름 · 개수("전체 N · 안 읽음 M") · [휴지통 비우기 · 스팸메일함 비우기] · 보기 전환 · 새로고침
|
||||
// 분할 보기(MessageList)와 목록 보기(MessageTable)가 함께 쓴다. 안쪽 여백만 보기마다 다르다 (디자인 원본).
|
||||
// 비우기는 휴지통 · 스팸메일함에 메일이 있을 때만 (디자인 원본 MailboxManage — MailboxEmptyButton). 좁으면 이름 · 개수가 먼저 줄어든다.
|
||||
// 스크롤해도 위에 남는 것은 목록 틀(MailListFrame)의 머리 묶음이 맡는다 — 메일을 고르면 이 자리가 도구 줄로 바뀐다.
|
||||
import { computed } from 'vue'
|
||||
|
||||
import AppIcon from '@/components/AppIcon.vue'
|
||||
import MailboxEmptyButton from '@/components/mail/MailboxEmptyButton.vue'
|
||||
import ViewModeSwitch from '@/components/mail/ViewModeSwitch.vue'
|
||||
import { formatCount } from '@/shared/utils/format'
|
||||
import { isEmptiableMailbox } from '@/shared/utils/mailbox'
|
||||
import { useMailStore } from '@/stores/mail.store'
|
||||
|
||||
interface Props {
|
||||
@@ -29,6 +32,11 @@ const countText = computed(() => {
|
||||
return box.unreadCount > 0 ? `${total} · 안 읽음 ${formatCount(box.unreadCount)}` : total
|
||||
})
|
||||
const busy = computed(() => mailStore.listStatus === 'loading' || mailStore.isRefreshing)
|
||||
/** 비울 수 있는 메일함 — 휴지통 · 스팸메일함에 메일이 있을 때 */
|
||||
const emptiable = computed(() => {
|
||||
const box = mailStore.currentMailbox
|
||||
return box && isEmptiableMailbox(box) && box.totalCount > 0 ? box : null
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -40,16 +48,23 @@ const busy = computed(() => mailStore.listStatus === 'loading' || mailStore.isRe
|
||||
{{ title }}
|
||||
</h1>
|
||||
<span class="list-header__count">{{ countText }}</span>
|
||||
<ViewModeSwitch class="list-header__switch" />
|
||||
<button
|
||||
type="button"
|
||||
class="list-header__refresh"
|
||||
aria-label="새로고침"
|
||||
:disabled="busy"
|
||||
@click="mailStore.refresh()"
|
||||
>
|
||||
<AppIcon name="refresh" />
|
||||
</button>
|
||||
<div class="list-header__end">
|
||||
<MailboxEmptyButton
|
||||
v-if="emptiable"
|
||||
:mailbox="emptiable"
|
||||
:variant="variant"
|
||||
/>
|
||||
<ViewModeSwitch />
|
||||
<button
|
||||
type="button"
|
||||
class="list-header__refresh"
|
||||
aria-label="새로고침"
|
||||
:disabled="busy"
|
||||
@click="mailStore.refresh()"
|
||||
>
|
||||
<AppIcon name="refresh" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -70,19 +85,33 @@ const busy = computed(() => mailStore.listStatus === 'loading' || mailStore.isRe
|
||||
}
|
||||
|
||||
.list-header__title {
|
||||
min-width: 0;
|
||||
margin: 0;
|
||||
overflow: hidden;
|
||||
font-size: var(--font-size-title-sm);
|
||||
font-weight: 700;
|
||||
letter-spacing: var(--letter-spacing-tight);
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* 좁으면 이름보다 먼저 줄어든다 */
|
||||
.list-header__count {
|
||||
flex: 0 100 auto;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
font-size: var(--font-size-small);
|
||||
color: var(--color-text-muted);
|
||||
font-variant-numeric: tabular-nums;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.list-header__switch {
|
||||
.list-header__end {
|
||||
flex: 0 0 auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-8);
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
|
||||
@@ -109,7 +109,7 @@ describe('여러 통 처리 도구 줄', () => {
|
||||
const bulkPatches = () =>
|
||||
requests.filter((request) => request.method === 'PATCH' && request.url === '/messages').map((request) => request.data)
|
||||
|
||||
it('도구 줄 — "N통 선택됨" · 읽음 · 안 읽음 · 중요 표시 | 이동 · 삭제 … 선택 해제 (목록 보기는 이름을 보인다)', async () => {
|
||||
it('도구 줄 — "N통 선택됨" · 읽음 · 안 읽음 · 중요 표시 | 이동 · 스팸 신고 · 삭제 … 선택 해제 (목록 보기는 이름을 보인다)', async () => {
|
||||
const { wrapper, store } = await mountView()
|
||||
store.toggleCheck('m1')
|
||||
store.toggleCheck('m2')
|
||||
@@ -117,7 +117,7 @@ describe('여러 통 처리 도구 줄', () => {
|
||||
|
||||
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(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('읽음 표시')
|
||||
@@ -376,4 +376,60 @@ describe('여러 통 처리 도구 줄', () => {
|
||||
release(ok({ updatedIds: ['m1'], failedIds: [] }))
|
||||
await flushPromises()
|
||||
})
|
||||
|
||||
describe('스팸 신고 · 스팸 아님 (4단계 — 디자인 원본 MailActions4)', () => {
|
||||
it('스팸 신고는 이동 ▾ 과 삭제 사이 — PATCH { ids, mailboxId: 스팸메일함, isJunk: true }, 알림 + [되돌리기](원래 메일함 · isJunk: false)', async () => {
|
||||
const { wrapper, store } = await mountView()
|
||||
store.toggleCheck('m1')
|
||||
store.toggleCheck('m2')
|
||||
await flushPromises()
|
||||
const tools = wrapper.findAll('[role="toolbar"] [data-tool]').map((tool) => tool.attributes('data-tool'))
|
||||
expect(tools.slice(3, 6)).toEqual(['move', 'spam', 'delete'])
|
||||
|
||||
await wrapper.get('[data-tool="spam"]').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(bulkPatches()).toEqual([{ ids: ['m1', 'm2'], mailboxId: 'junk', isJunk: true }])
|
||||
expect(store.messages.map((message) => message.id)).toEqual(['m3'])
|
||||
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', 'm2'], mailboxId: 'inbox', isJunk: false })
|
||||
expect(toastTexts()).toContain('메일 2통을 되돌렸습니다.')
|
||||
})
|
||||
|
||||
it('스팸메일함에서는 「스팸 아님」(강조) — 받은편지함으로 · isJunk: false, "(스팸 아님)" 알림 · 되돌리면 스팸메일함으로 · isJunk: true', async () => {
|
||||
const junked = [summary('j1', { mailboxIds: ['junk'] }), summary('j2', { mailboxIds: ['junk'] })]
|
||||
const { wrapper, store } = await mountView({ boxId: 'junk', messages: junked })
|
||||
store.toggleCheck('j1')
|
||||
await flushPromises()
|
||||
const notSpam = wrapper.get('[data-tool="spam"]')
|
||||
expect(notSpam.attributes('aria-label')).toBe('스팸 아님')
|
||||
expect(notSpam.classes()).toContain('bulk-button--accent')
|
||||
|
||||
await notSpam.trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(bulkPatches()).toEqual([{ ids: ['j1'], mailboxId: 'inbox', isJunk: false }])
|
||||
const [toast] = useToastStore().toasts
|
||||
expect(toast?.message).toBe('메일 1통을 받은편지함으로 옮겼습니다 (스팸 아님).')
|
||||
if (toast?.action && 'run' in toast.action) toast.action.run()
|
||||
await flushPromises()
|
||||
expect(bulkPatches()[1]).toEqual({ ids: ['j1'], mailboxId: 'junk', isJunk: true })
|
||||
})
|
||||
|
||||
it('스팸메일함을 모르면 스팸 단추를 그리지 않는다 — 로빙 tabindex 도 건너뛴다', async () => {
|
||||
const { wrapper, store } = await mountView()
|
||||
store.mailboxes = MAILBOXES.filter((mailbox) => mailbox.role !== 'junk')
|
||||
store.toggleCheck('m1')
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.find('[data-tool="spam"]').exists()).toBe(false)
|
||||
await wrapper.get('[data-tool="move"]').trigger('keydown', { key: 'ArrowRight' })
|
||||
expect(document.activeElement).toBe(wrapper.get('[data-tool="delete"]').element)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
<script setup lang="ts">
|
||||
// 여러 통 처리 도구 줄 — 하나라도 고르면 목록 머리(52) 자리에 (디자인 원본 BulkSelect · ListStates, 바탕 #F3F8F8)
|
||||
// "N통 선택됨"(14px 굵게) · 읽음 표시 · 안 읽음 표시 · 중요 표시(모두 중요면 "중요 해제") | 이동 ▾ · 삭제(휴지통에서는 "영구 삭제", 빨강)
|
||||
// … 오른쪽 끝 선택 해제(✕). 하는 일은 useBulkMailActions 가 맡는다.
|
||||
// "N통 선택됨"(14px 굵게) · 읽음 표시 · 안 읽음 표시 · 중요 표시(모두 중요면 "중요 해제") | 이동 ▾ · 스팸 신고(스팸메일함에서는 "스팸 아님") ·
|
||||
// 삭제(휴지통에서는 "영구 삭제", 빨강) … 오른쪽 끝 선택 해제(✕). 하는 일은 useBulkMailActions 가 맡는다.
|
||||
// 스팸 단추는 옮길 메일함(스팸메일함 · 받은편지함)을 알 때만 그린다 (디자인 README "4단계 › 스팸 · 원문")
|
||||
// - 단추(MailBulkButton)는 높이 44 · 아이콘 + 이름. 폭 1100 미만이거나 분할 보기의 목록 칸에서는 아이콘만(40×44 — 이름은 툴팁 · aria-label).
|
||||
// "선택 해제"는 목록 보기에서 이름을 늘 보인다 (캔버스 BulkSelect)
|
||||
// - role="toolbar": Tab 한 번에 들어와 화살표 · Home · End 로 옮긴다(로빙 tabindex). 이동 메뉴는 MailBulkMoveButton.
|
||||
@@ -14,7 +15,7 @@ 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 { MAIL_BULK_TEXT, MAIL_SPAM_TEXT } from '@/shared/constants/mail'
|
||||
import { formatCount } from '@/shared/utils/format'
|
||||
import { useMailStore } from '@/stores/mail.store'
|
||||
|
||||
@@ -29,8 +30,8 @@ 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']
|
||||
type ToolId = 'markRead' | 'markUnread' | 'flag' | 'move' | 'spam' | 'delete' | 'clear'
|
||||
const ALL_TOOLS: readonly ToolId[] = ['markRead', 'markUnread', 'flag', 'move', 'spam', 'delete', 'clear']
|
||||
|
||||
interface LeadingTool {
|
||||
id: Extract<ToolId, 'markRead' | 'markUnread' | 'flag'>
|
||||
@@ -46,17 +47,23 @@ const purgeOpen = ref<boolean>(false)
|
||||
/** 초점이 도구 줄 안의 어느 단추에 있었는지 — 그 단추가 눌리지 않게 되어 초점을 잃었을 때 되돌려 줄 곳을 찾는다 */
|
||||
let lastFocusedTool: ToolId | null = null
|
||||
|
||||
/** 그리는 단추 — 스팸 단추는 옮길 메일함을 알 때만 */
|
||||
const toolOrder = computed<ToolId[]>(() => ALL_TOOLS.filter((id) => id !== 'spam' || bulk.spamTarget.value !== null))
|
||||
/** 지금 누를 수 없는 단추 — 옮기기 · 지우기를 하는 동안 */
|
||||
const disabledTools = computed<ReadonlySet<ToolId>>(() => new Set<ToolId>(bulk.busy.value ? ['move', 'delete'] : []))
|
||||
const disabledTools = computed<ReadonlySet<ToolId>>(
|
||||
() => new Set<ToolId>(bulk.busy.value ? ['move', 'spam', 'delete'] : []),
|
||||
)
|
||||
/** 화살표 · Home · End 로 옮겨 다닐 단추 — 누를 수 없는 단추는 건너뛴다 */
|
||||
const enabledTools = computed<ToolId[]>(() => TOOL_ORDER.filter((id) => !disabledTools.value.has(id)))
|
||||
/** Tab 으로 들어올 단추 — 마지막 단추를 누를 수 없게 되면 그다음(끝이면 처음부터) 누를 수 있는 단추 */
|
||||
const enabledTools = computed<ToolId[]>(() => toolOrder.value.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
|
||||
const order = toolOrder.value
|
||||
const usable = (id: ToolId | undefined): id is ToolId => id !== undefined && order.includes(id) && !disabledTools.value.has(id)
|
||||
if (usable(activeTool.value)) return activeTool.value
|
||||
const start = ALL_TOOLS.indexOf(activeTool.value)
|
||||
for (let step = 1; step < ALL_TOOLS.length; step += 1) {
|
||||
const candidate = ALL_TOOLS[(start + step) % ALL_TOOLS.length]
|
||||
if (usable(candidate)) return candidate
|
||||
}
|
||||
return 'clear'
|
||||
})
|
||||
@@ -65,6 +72,7 @@ const rovingTool = computed<ToolId>(() => {
|
||||
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 spamLabel = computed(() => (bulk.inJunk.value ? MAIL_SPAM_TEXT.notSpam : MAIL_SPAM_TEXT.report))
|
||||
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) },
|
||||
@@ -120,7 +128,7 @@ function onKeydown(event: KeyboardEvent) {
|
||||
|
||||
function onFocusIn(event: FocusEvent) {
|
||||
const element = event.target instanceof HTMLElement ? event.target : null
|
||||
const tool = TOOL_ORDER.find((id) => id === element?.dataset.tool)
|
||||
const tool = ALL_TOOLS.find((id) => id === element?.dataset.tool)
|
||||
if (!tool) return
|
||||
activeTool.value = tool
|
||||
lastFocusedTool = tool
|
||||
@@ -188,6 +196,17 @@ watch(disabledTools, (disabled) => {
|
||||
:tabindex="tabindexOf('move')"
|
||||
@move="bulk.moveTo"
|
||||
/>
|
||||
<MailBulkButton
|
||||
v-if="bulk.spamTarget.value"
|
||||
:icon="bulk.inJunk.value ? 'inbox' : 'alert-octagon'"
|
||||
:label="spamLabel"
|
||||
:label-mode="labelMode"
|
||||
:tone="bulk.inJunk.value ? 'accent' : 'default'"
|
||||
:disabled="bulk.busy.value"
|
||||
data-tool="spam"
|
||||
:tabindex="tabindexOf('spam')"
|
||||
@click="bulk.moveSpam()"
|
||||
/>
|
||||
<MailBulkButton
|
||||
icon="trash"
|
||||
:label="deleteLabel"
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { mount, type VueWrapper } from '@vue/test-utils'
|
||||
import { nextTick } from 'vue'
|
||||
|
||||
import { CLOCK } from '@/composables/useClock'
|
||||
import type { MailVacation } from '@/shared/types/mail'
|
||||
import { endOfDayIso, startOfDayIso } from '@/shared/utils/mail-vacation'
|
||||
import { createManualClock, type ManualClock } from '@/test-utils/manual-clock'
|
||||
import { createTestRouter } from '@/test-utils/router'
|
||||
import MailVacationNotice from './MailVacationNotice.vue'
|
||||
|
||||
// 메일함 패널 아래 부재중 안내 — 시각에 따라 바뀌므로 1분마다 다시 계산한다 (손으로 돌리는 시계로 시간을 넘긴다)
|
||||
// 날짜는 브라우저 시간대 기준이라 기대값도 로컬 Date 로 만든다
|
||||
|
||||
const ONE_MINUTE_MS = 60_000
|
||||
|
||||
function vacation(partial: Partial<MailVacation>): MailVacation {
|
||||
return { isEnabled: true, fromDate: null, toDate: null, subject: '부재중입니다', textBody: '자리를 비웁니다.', ...partial }
|
||||
}
|
||||
|
||||
describe('MailVacationNotice — 1분마다 다시 계산', () => {
|
||||
let wrapper: VueWrapper | null = null
|
||||
|
||||
afterEach(() => {
|
||||
wrapper?.unmount()
|
||||
wrapper = null
|
||||
})
|
||||
|
||||
async function mountNotice(value: MailVacation, clock: ManualClock) {
|
||||
const router = createTestRouter()
|
||||
await router.push('/mail')
|
||||
await router.isReady()
|
||||
wrapper = mount(MailVacationNotice, {
|
||||
props: { vacation: value },
|
||||
global: { plugins: [router], provide: { [CLOCK as symbol]: clock } },
|
||||
})
|
||||
return wrapper
|
||||
}
|
||||
|
||||
const detail = (view: VueWrapper) => view.find('[data-vacation-notice] .vacation-notice__detail')
|
||||
|
||||
it('끝나는 시각이 지나면 1분 안에 안내가 사라진다 — 새로고침을 기다리지 않는다', async () => {
|
||||
const clock = createManualClock(new Date(2026, 10, 3, 23, 59, 30).getTime())
|
||||
const view = await mountNotice(vacation({ toDate: endOfDayIso('2026-11-03') }), clock)
|
||||
expect(detail(view).text()).toBe('11월 3일까지 · 설정')
|
||||
|
||||
clock.advance(ONE_MINUTE_MS)
|
||||
await nextTick()
|
||||
expect(view.find('[data-vacation-notice]').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('시작 전에는 기간을 보이다가, 시작하면 끝나는 날 문구로 바뀐다', async () => {
|
||||
const clock = createManualClock(new Date(2026, 9, 29, 23, 59, 30).getTime())
|
||||
const view = await mountNotice(
|
||||
vacation({ fromDate: startOfDayIso('2026-10-30'), toDate: endOfDayIso('2026-11-03') }),
|
||||
clock,
|
||||
)
|
||||
expect(detail(view).text()).toBe('10월 30일 ~ 11월 3일 · 설정')
|
||||
|
||||
clock.advance(ONE_MINUTE_MS)
|
||||
await nextTick()
|
||||
expect(detail(view).text()).toBe('11월 3일까지 · 설정')
|
||||
})
|
||||
|
||||
it('타이머는 하나만 걸어 두고 이어 걸며, 화면을 떠나면 거둔다', async () => {
|
||||
const clock = createManualClock(new Date(2026, 9, 29, 9, 0).getTime())
|
||||
const view = await mountNotice(vacation({}), clock)
|
||||
expect(detail(view).text()).toBe('끌 때까지 · 설정')
|
||||
expect(clock.pendingCount()).toBe(1)
|
||||
|
||||
clock.advance(ONE_MINUTE_MS * 3)
|
||||
expect(clock.pendingCount()).toBe(1)
|
||||
|
||||
view.unmount()
|
||||
wrapper = null
|
||||
expect(clock.pendingCount()).toBe(0)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,101 @@
|
||||
<script setup lang="ts">
|
||||
// 부재중 자동 응답 안내 — 메일함 패널 아래(사용량 위) (디자인 원본 MailboxManage · README "4단계 › 부재중 자동 응답")
|
||||
// 바탕 #FFF7E6 · #7A5200, 높이 44 이상: "부재중 자동 응답 켜짐" / "11월 3일까지 · 설정" — 누르면 설정 › 메일 › 부재중 자동 응답으로.
|
||||
// 켜져 있는 동안만 보인다 — 꺼져 있거나 끝나는 날이 지났으면 그리지 않는다 (아직 시작하지 않았으면 시작일을 보인다, mail-vacation.ts).
|
||||
// 시각에 따라 바뀌므로 1분마다 다시 계산한다 (useClock — 테스트는 손으로 돌리는 시계)
|
||||
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
|
||||
import AppIcon from '@/components/AppIcon.vue'
|
||||
import { useClock, type ClockTimer } from '@/composables/useClock'
|
||||
import { VACATION_NOTICE_REFRESH_MS, VACATION_TEXT } from '@/shared/constants/mail-vacation'
|
||||
import { SETTINGS_ROUTES } from '@/shared/constants/settings-menu'
|
||||
import type { MailVacation } from '@/shared/types/mail'
|
||||
import { vacationNoticeText } from '@/shared/utils/mail-vacation'
|
||||
|
||||
interface Props {
|
||||
vacation: MailVacation | null
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
|
||||
const clock = useClock()
|
||||
|
||||
/** 안내를 계산한 시각 — 1분마다 바뀐다 */
|
||||
const now = ref<number>(clock.now())
|
||||
let timer: ClockTimer | null = null
|
||||
|
||||
const detail = computed(() => vacationNoticeText(props.vacation, new Date(now.value)))
|
||||
|
||||
function tick() {
|
||||
now.value = clock.now()
|
||||
timer = clock.setTimeout(tick, VACATION_NOTICE_REFRESH_MS)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
timer = clock.setTimeout(tick, VACATION_NOTICE_REFRESH_MS)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (timer !== null) clock.clearTimeout(timer)
|
||||
timer = null
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<RouterLink
|
||||
v-if="detail"
|
||||
:to="{ name: SETTINGS_ROUTES.vacation }"
|
||||
class="vacation-notice"
|
||||
data-vacation-notice
|
||||
>
|
||||
<AppIcon
|
||||
name="calendar"
|
||||
:size="15"
|
||||
class="vacation-notice__icon"
|
||||
/>
|
||||
<span class="vacation-notice__text">
|
||||
<span class="vacation-notice__title">{{ VACATION_TEXT.noticeTitle }}</span>
|
||||
<span class="vacation-notice__detail">{{ detail }}</span>
|
||||
</span>
|
||||
</RouterLink>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.vacation-notice {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: var(--space-8);
|
||||
min-height: var(--size-touch);
|
||||
padding: var(--space-8) var(--space-10);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--color-warning-surface);
|
||||
color: var(--color-warning-text);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.vacation-notice:hover {
|
||||
color: var(--color-warning-text);
|
||||
}
|
||||
|
||||
.vacation-notice:hover .vacation-notice__detail {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.vacation-notice__icon {
|
||||
margin-top: var(--space-2);
|
||||
}
|
||||
|
||||
.vacation-notice__text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
line-height: var(--line-height-heading);
|
||||
}
|
||||
|
||||
.vacation-notice__title {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.vacation-notice__detail {
|
||||
font-size: var(--font-size-small);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,90 @@
|
||||
<script setup lang="ts">
|
||||
// 메일함 지우기 확인 (디자인 원본 MailboxManage, 공용 모양 ModalDialog — role=alertdialog)
|
||||
// - 하위 메일함이 있으면 지울 수 없다: "‘프로젝트’ 메일함을 지울 수 없습니다" · "하위 메일함이 있습니다. 하위 메일함을 먼저 지워 주세요." · [확인]
|
||||
// - 아니면: "‘거래처’ 메일함을 지울까요?" · "안의 메일 12통은 휴지통으로 옮깁니다. 지운 메일함은 되돌릴 수 없습니다."
|
||||
// (빈 메일함은 "빈 메일함입니다. …") · [지우기](빨강 채움) [취소] — 열리면 「취소」에 초점 (실수로 Enter 를 눌러도 지워지지 않게)
|
||||
// - 휴지통을 비우는 동안은 [지우기]를 누를 수 없다 (blocked — 비우기가 옮겨 간 메일까지 영구 삭제하지 않게)
|
||||
// 고정 id — #mailbox-delete-dialog · #mailbox-delete-title · #mailbox-delete-message · #mailbox-delete-confirm · #mailbox-delete-cancel
|
||||
import { computed } from 'vue'
|
||||
|
||||
import ModalDialog from '@/components/feedback/ModalDialog.vue'
|
||||
import { MAILBOX_DIALOG_TEXT } from '@/shared/constants/mailbox-manage'
|
||||
import { formatCount } from '@/shared/utils/format'
|
||||
|
||||
interface Props {
|
||||
open: boolean
|
||||
mailboxName: string
|
||||
/** 안의 메일 수 */
|
||||
messageCount: number
|
||||
/** 하위 메일함이 있다 — 지울 수 없다고 알린다 */
|
||||
hasChildren: boolean
|
||||
/** 지금은 시작할 수 없다 (휴지통을 비우는 중) — [지우기]를 끈다 */
|
||||
blocked?: boolean
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'confirm'): void
|
||||
(e: 'cancel'): void
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
blocked: false,
|
||||
})
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
const title = computed(() =>
|
||||
props.hasChildren ? MAILBOX_DIALOG_TEXT.blockedTitle(props.mailboxName) : MAILBOX_DIALOG_TEXT.deleteTitle(props.mailboxName),
|
||||
)
|
||||
const message = computed(() => {
|
||||
if (props.hasChildren) return MAILBOX_DIALOG_TEXT.blockedMessage
|
||||
return props.messageCount > 0
|
||||
? MAILBOX_DIALOG_TEXT.deleteMessage(formatCount(props.messageCount))
|
||||
: MAILBOX_DIALOG_TEXT.deleteEmptyMessage
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ModalDialog
|
||||
id="mailbox-delete-dialog"
|
||||
:open="open"
|
||||
role="alertdialog"
|
||||
:title="title"
|
||||
:message="message"
|
||||
title-id="mailbox-delete-title"
|
||||
message-id="mailbox-delete-message"
|
||||
@cancel="emit('cancel')"
|
||||
>
|
||||
<template #actions>
|
||||
<button
|
||||
v-if="hasChildren"
|
||||
id="mailbox-delete-cancel"
|
||||
type="button"
|
||||
class="modal-dialog__action"
|
||||
autofocus
|
||||
@click="emit('cancel')"
|
||||
>
|
||||
{{ MAILBOX_DIALOG_TEXT.blockedConfirm }}
|
||||
</button>
|
||||
<template v-else>
|
||||
<button
|
||||
id="mailbox-delete-confirm"
|
||||
type="button"
|
||||
class="modal-dialog__action modal-dialog__action--danger"
|
||||
:disabled="blocked"
|
||||
@click="emit('confirm')"
|
||||
>
|
||||
{{ MAILBOX_DIALOG_TEXT.deleteConfirm }}
|
||||
</button>
|
||||
<button
|
||||
id="mailbox-delete-cancel"
|
||||
type="button"
|
||||
class="modal-dialog__action modal-dialog__action--secondary"
|
||||
autofocus
|
||||
@click="emit('cancel')"
|
||||
>
|
||||
{{ MAILBOX_DIALOG_TEXT.cancel }}
|
||||
</button>
|
||||
</template>
|
||||
</template>
|
||||
</ModalDialog>
|
||||
</template>
|
||||
@@ -0,0 +1,156 @@
|
||||
<script setup lang="ts">
|
||||
// 메일함 패널의 대화상자 — ⋯ 메뉴 · [+ 새 메일함]이 연다 (디자인 원본 MailboxManage · useMailboxActions)
|
||||
// - 새 메일함 · 하위 메일함 · 이름 바꾸기: 이름 대화상자. 같은 이름(MAIL_009) · 형식(VAL_001)은 칸 아래에 보이고 대화상자를 둔다.
|
||||
// 메일함이 그사이 사라졌으면(RES_001 — 알림은 인터셉터) 닫고 메일함 목록을 다시 맞춘다. 그 밖의 실패는 알림을 보고 다시 누를 수 있게 둔다.
|
||||
// 이름을 바꾸지 않았으면 보내지 않고 닫는다. 기다리는 사이 화면이 사라졌으면 칸 아래에 보일 실패를 알림으로 알린다 (notifyApiError)
|
||||
// - 지우기: 하위 메일함이 있으면 지울 수 없다고 알린다. 확인하면 닫고 지우기를 시작한다 (진행은 알림으로)
|
||||
// - 비우기: 확인하면 닫고 비우기를 시작한다
|
||||
// - 휴지통 비우기 · 메일함 지우기는 서로 도는 동안 시작할 수 없다 — 확인 단추를 끄고, 누르는 순간에도 다시 본다 (useMailboxActions 의 isBlocked)
|
||||
import { computed, onBeforeUnmount, ref, watch } from 'vue'
|
||||
|
||||
import MailboxDeleteDialog from '@/components/mail/MailboxDeleteDialog.vue'
|
||||
import MailboxEmptyDialog from '@/components/mail/MailboxEmptyDialog.vue'
|
||||
import MailboxNameDialog from '@/components/mail/MailboxNameDialog.vue'
|
||||
import type { MailboxDialogRequest } from '@/components/mail/mailbox-dialog'
|
||||
import { notifyApiError } from '@/composables/useApi'
|
||||
import { useMailboxActions } from '@/composables/useMailboxActions'
|
||||
import type { Mailbox } from '@/shared/types/mail'
|
||||
import { hasChildMailbox } from '@/shared/utils/mailbox'
|
||||
import { useMailStore } from '@/stores/mail.store'
|
||||
|
||||
interface Props {
|
||||
request: MailboxDialogRequest | null
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'close'): void
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
const mailStore = useMailStore()
|
||||
const actions = useMailboxActions()
|
||||
|
||||
const pending = ref<boolean>(false)
|
||||
const nameError = ref<string | null>(null)
|
||||
/** 화면(메일 모듈)이 사라졌다 — 칸 아래에 보일 곳이 없다 */
|
||||
let unmounted = false
|
||||
|
||||
const nameMode = computed<'create' | 'rename' | null>(() => {
|
||||
const kind = props.request?.kind
|
||||
return kind === 'create' || kind === 'rename' ? kind : null
|
||||
})
|
||||
/** 대상 메일함 — 연 뒤에 바뀐 개수 · 이름을 쓰도록 지금 목록에서 다시 찾는다 */
|
||||
const target = computed<Mailbox | null>(() => {
|
||||
const request = props.request
|
||||
if (!request || request.kind === 'create') return null
|
||||
return mailStore.mailboxes.find((mailbox) => mailbox.id === request.mailbox.id) ?? request.mailbox
|
||||
})
|
||||
const parentName = computed(() => (props.request?.kind === 'create' ? (props.request.parent?.name ?? null) : null))
|
||||
const hasChildren = computed(() => (target.value ? hasChildMailbox(mailStore.mailboxes, target.value.id) : false))
|
||||
/** 지금은 시작할 수 없다 — 휴지통 비우기 ↔ 메일함 지우기 */
|
||||
const deleteBlocked = computed(() => (target.value ? actions.isBlocked('delete', target.value) : false))
|
||||
const emptyBlocked = computed(() => (target.value ? actions.isBlocked('empty', target.value) : false))
|
||||
|
||||
function close() {
|
||||
pending.value = false
|
||||
nameError.value = null
|
||||
emit('close')
|
||||
}
|
||||
|
||||
async function onSubmitName(name: string) {
|
||||
const request = props.request
|
||||
if (!request || pending.value) return
|
||||
if (request.kind === 'rename' && name === target.value?.name) {
|
||||
close()
|
||||
return
|
||||
}
|
||||
pending.value = true
|
||||
const result =
|
||||
request.kind === 'rename'
|
||||
? await actions.rename(target.value ?? request.mailbox, name)
|
||||
: request.kind === 'create'
|
||||
? await actions.create(name, request.parent?.id ?? null)
|
||||
: null
|
||||
if (unmounted) {
|
||||
// 칸 아래에 보일 곳이 사라졌다 — 끈 알림을 띄운다 (성공 알림은 이미 떴다)
|
||||
if (result?.status === 'failed' && result.inline) notifyApiError(result.error)
|
||||
return
|
||||
}
|
||||
// 기다리는 사이 닫았거나 다른 대화상자를 열었으면 그 대화상자를 건드리지 않는다 (결과 알림은 이미 떴다)
|
||||
if (props.request !== request) return
|
||||
pending.value = false
|
||||
if (!result || result.status !== 'failed') {
|
||||
close()
|
||||
return
|
||||
}
|
||||
if (result.inline) {
|
||||
nameError.value = result.error.message
|
||||
return
|
||||
}
|
||||
if (result.error.code === 'RES_001') {
|
||||
void mailStore.loadMailboxes({ background: true })
|
||||
close()
|
||||
}
|
||||
}
|
||||
|
||||
function onConfirmDelete() {
|
||||
const mailbox = target.value
|
||||
// 누르는 순간 다시 본다 — 그사이 휴지통 비우기가 시작됐으면 대화상자를 그대로 둔다
|
||||
if (!mailbox || deleteBlocked.value) return
|
||||
close()
|
||||
void actions.remove(mailbox)
|
||||
}
|
||||
|
||||
function onConfirmEmpty() {
|
||||
const mailbox = target.value
|
||||
if (!mailbox || emptyBlocked.value) return
|
||||
close()
|
||||
void actions.empty(mailbox)
|
||||
}
|
||||
|
||||
// 다른 대화상자로 바뀌면 지난 오류를 지운다
|
||||
watch(
|
||||
() => props.request,
|
||||
() => {
|
||||
nameError.value = null
|
||||
},
|
||||
)
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
unmounted = true
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<MailboxNameDialog
|
||||
:open="nameMode !== null"
|
||||
:mode="nameMode ?? 'create'"
|
||||
:parent-name="parentName"
|
||||
:initial-name="nameMode === 'rename' ? (target?.name ?? '') : ''"
|
||||
:pending="pending"
|
||||
:error="nameError"
|
||||
@submit="onSubmitName"
|
||||
@cancel="close"
|
||||
@clear-error="nameError = null"
|
||||
/>
|
||||
<MailboxDeleteDialog
|
||||
:open="request?.kind === 'delete'"
|
||||
:mailbox-name="target?.name ?? ''"
|
||||
:message-count="target?.totalCount ?? 0"
|
||||
:has-children="hasChildren"
|
||||
:blocked="deleteBlocked"
|
||||
@confirm="onConfirmDelete"
|
||||
@cancel="close"
|
||||
/>
|
||||
<MailboxEmptyDialog
|
||||
:open="request?.kind === 'empty'"
|
||||
:mailbox-name="target?.name ?? ''"
|
||||
:message-count="target?.totalCount ?? 0"
|
||||
:blocked="emptyBlocked"
|
||||
id-prefix="mailbox-empty"
|
||||
@confirm="onConfirmEmpty"
|
||||
@cancel="close"
|
||||
/>
|
||||
</template>
|
||||
@@ -0,0 +1,189 @@
|
||||
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 { useToastStore } from '@/stores/toast.store'
|
||||
import {
|
||||
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'
|
||||
|
||||
// 휴지통 · 스팸메일함 목록 머리의 「비우기」 (디자인 원본 MailboxManage · 계약 2절 "메일함 관리")
|
||||
|
||||
function box(id: string, role: Mailbox['role'], name: string, totalCount: number): Mailbox {
|
||||
return { id, name, role, parentId: null, sortOrder: 0, totalCount, unreadCount: 0 }
|
||||
}
|
||||
|
||||
function summary(id: string, mailboxId: string): MessageSummary {
|
||||
return {
|
||||
id,
|
||||
threadId: `t-${id}`,
|
||||
mailboxIds: [mailboxId],
|
||||
from: { name: '호스팅 안내', email: 'ad@example.test' },
|
||||
to: [],
|
||||
subject: `제목 ${id}`,
|
||||
preview: '',
|
||||
receivedAt: '2026-10-21T01:00:00Z',
|
||||
isRead: true,
|
||||
isFlagged: false,
|
||||
hasAttachment: false,
|
||||
size: 100,
|
||||
}
|
||||
}
|
||||
|
||||
describe('목록 머리의 휴지통 · 스팸메일함 비우기', () => {
|
||||
let pinia: Pinia
|
||||
let requests: MockRequest[]
|
||||
let listed: MessageSummary[]
|
||||
|
||||
beforeEach(() => {
|
||||
pinia = createPinia()
|
||||
setActivePinia(pinia)
|
||||
useAuthStore().markAuthenticated({ email: 'ara.kim@arasystem.it.kr', name: '김아라' })
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
useToastStore().clear()
|
||||
restoreApiAdapter()
|
||||
document.body.innerHTML = ''
|
||||
})
|
||||
|
||||
async function mountList(
|
||||
mailbox: Mailbox,
|
||||
view: 'split' | 'list' = 'list',
|
||||
handler?: (request: MockRequest) => MockResult | Promise<MockResult> | undefined,
|
||||
) {
|
||||
listed = [summary('t1', mailbox.id), summary('t2', mailbox.id)]
|
||||
requests = installMockApi((request) => {
|
||||
const custom = handler?.(request)
|
||||
if (custom) return custom
|
||||
if (request.method === 'DELETE') {
|
||||
listed = []
|
||||
return ok({ processed: 2, remaining: 0 })
|
||||
}
|
||||
if (request.url === '/messages') return ok({ items: listed, pageInfo: { hasNext: false, nextCursor: null, total: listed.length } })
|
||||
if (request.url === '/mailboxes') return ok({ items: useMailStore().mailboxes, pageInfo: { hasNext: false, nextCursor: null, total: 1 } })
|
||||
return ok(null)
|
||||
})
|
||||
const store = useMailStore()
|
||||
store.mailboxes = [box('inbox', 'inbox', '받은편지함', 3), mailbox]
|
||||
store.currentMailboxId = mailbox.id
|
||||
store.messages = listed
|
||||
store.pageInfo = { hasNext: false, nextCursor: null, total: listed.length }
|
||||
store.listStatus = 'ready'
|
||||
const router = createTestRouter()
|
||||
await router.push(`/mail/${mailbox.id}`)
|
||||
await router.isReady()
|
||||
const wrapper = mount(view === 'split' ? MessageList : MessageTable, {
|
||||
global: { plugins: [pinia, router] },
|
||||
attachTo: document.body,
|
||||
})
|
||||
await flushPromises()
|
||||
return { wrapper, store }
|
||||
}
|
||||
|
||||
it('휴지통 — 빨간 글자 「휴지통 비우기」, 확인(처음 초점 「취소」) 뒤 비우고 목록을 다시 받는다 · 되돌리기 없는 알림', async () => {
|
||||
const { wrapper, store } = await mountList(box('trash', 'trash', '휴지통', 2))
|
||||
const empty = wrapper.get('[data-mailbox-empty]')
|
||||
expect(empty.text()).toBe('휴지통 비우기')
|
||||
|
||||
await empty.trigger('click')
|
||||
await flushPromises()
|
||||
expect(document.getElementById('list-empty-dialog')?.hasAttribute('open')).toBe(true)
|
||||
expect(wrapper.get('#list-empty-title').text()).toBe('휴지통의 메일 2통을 모두 영구 삭제할까요?')
|
||||
expect(document.activeElement?.id).toBe('list-empty-cancel')
|
||||
|
||||
await wrapper.get('#list-empty-confirm').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(requests.filter((request) => request.method === 'DELETE').map((request) => request.url)).toEqual([
|
||||
'/mailboxes/trash/messages',
|
||||
])
|
||||
expect(store.messages).toEqual([])
|
||||
expect(useToastStore().toasts.map((toast) => [toast.message, toast.action])).toEqual([
|
||||
['휴지통을 비웠습니다 — 메일 2통.', null],
|
||||
])
|
||||
// 비었으니 단추도 사라진다
|
||||
expect(wrapper.find('[data-mailbox-empty]').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('스팸메일함 · 분할 보기 — 「스팸메일함 비우기」는 아이콘만(이름은 aria-label · 툴팁)', async () => {
|
||||
const { wrapper } = await mountList(box('junk', 'junk', '스팸메일함', 2), 'split')
|
||||
const empty = wrapper.get('[data-mailbox-empty]')
|
||||
|
||||
expect(empty.classes()).toContain('empty-button--split')
|
||||
expect(empty.attributes('aria-label')).toBe('스팸메일함 비우기')
|
||||
expect(empty.attributes('title')).toBe('스팸메일함 비우기')
|
||||
})
|
||||
|
||||
it('빈 휴지통 · 다른 메일함에는 없다', async () => {
|
||||
const emptyTrash = await mountList(box('trash', 'trash', '휴지통', 0))
|
||||
expect(emptyTrash.wrapper.find('[data-mailbox-empty]').exists()).toBe(false)
|
||||
emptyTrash.wrapper.unmount()
|
||||
|
||||
const inbox = await mountList(box('archive', 'archive', '보관함', 2))
|
||||
expect(inbox.wrapper.find('[data-mailbox-empty]').exists()).toBe(false)
|
||||
})
|
||||
|
||||
const dialogOpen = () => document.getElementById('list-empty-dialog')?.hasAttribute('open') ?? false
|
||||
const deletes = () => requests.filter((request) => request.method === 'DELETE').map((request) => request.url)
|
||||
|
||||
it('확인창은 연 때의 메일함을 붙잡는다 — 연 채 보는 메일함이 바뀌면(뒤로 가기 등) 닫고, 연 적 없는 메일함을 비우지 않는다', async () => {
|
||||
const { wrapper, store } = await mountList(box('trash', 'trash', '휴지통', 2))
|
||||
store.mailboxes = [...store.mailboxes, box('junk', 'junk', '스팸메일함', 5)]
|
||||
await wrapper.get('[data-mailbox-empty]').trigger('click')
|
||||
await flushPromises()
|
||||
expect(dialogOpen()).toBe(true)
|
||||
expect(wrapper.get('#list-empty-title').text()).toBe('휴지통의 메일 2통을 모두 영구 삭제할까요?')
|
||||
|
||||
// 확인창을 연 채 뒤로 가기로 스팸메일함을 보게 되었다
|
||||
store.currentMailboxId = 'junk'
|
||||
await flushPromises()
|
||||
expect(dialogOpen()).toBe(false)
|
||||
expect(deletes()).toEqual([])
|
||||
|
||||
// 다시 열면 지금 메일함의 창 — 확인하면 그 메일함만 비운다
|
||||
await wrapper.get('[data-mailbox-empty]').trigger('click')
|
||||
await flushPromises()
|
||||
expect(wrapper.get('#list-empty-title').text()).toBe('스팸메일함의 메일 5통을 모두 영구 삭제할까요?')
|
||||
await wrapper.get('#list-empty-confirm').trigger('click')
|
||||
await flushPromises()
|
||||
expect(deletes()).toEqual(['/mailboxes/junk/messages'])
|
||||
})
|
||||
|
||||
it('메일함을 지우는 동안 「휴지통 비우기」는 누를 수 없다 — 꺼도 초점을 잃지 않게 aria-disabled', async () => {
|
||||
let finish: (result: MockResult) => void = () => undefined
|
||||
const deleting = new Promise<MockResult>((resolve) => {
|
||||
finish = resolve
|
||||
})
|
||||
const { wrapper, store } = await mountList(box('trash', 'trash', '휴지통', 2), 'list', (request) =>
|
||||
request.url === '/mailboxes/c' ? deleting : undefined,
|
||||
)
|
||||
const custom = box('c', null, '거래처', 12)
|
||||
store.mailboxes = [...store.mailboxes, custom]
|
||||
const running = store.runMailboxOperation('delete', custom, () => undefined)
|
||||
await flushPromises()
|
||||
|
||||
const empty = wrapper.get('[data-mailbox-empty]')
|
||||
expect(empty.attributes('aria-disabled')).toBe('true')
|
||||
expect(empty.attributes('disabled')).toBeUndefined()
|
||||
await empty.trigger('click')
|
||||
await flushPromises()
|
||||
expect(dialogOpen()).toBe(false)
|
||||
|
||||
finish(ok({ deleted: true, processed: 12, movedToTrash: 12, remaining: 0 }))
|
||||
await running
|
||||
await flushPromises()
|
||||
expect(wrapper.get('[data-mailbox-empty]').attributes('aria-disabled')).toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,138 @@
|
||||
<script setup lang="ts">
|
||||
// 휴지통 · 스팸메일함 목록 머리의 「휴지통 비우기」 · 「스팸메일함 비우기」 (디자인 원본 MailboxManage — 빨강 글자 · 44)
|
||||
// 확인 대화상자(MailboxEmptyDialog — 처음 초점 「취소」)를 거쳐 비운다 (useMailboxActions — 진행은 알림으로).
|
||||
// - 확인창은 연 때의 메일함을 붙잡는다: 열어 둔 채 뒤로 가기 등으로 보는 메일함이 바뀌면 창을 닫는다 — 연 적 없는 메일함을 비우지 않게
|
||||
// - 비우는 중 · 메일함을 지우는 중(휴지통 — 함께 돌 수 없다)에는 누를 수 없다. 꺼도 초점은 잃지 않게 aria-disabled 로 둔다
|
||||
// (끝나서 단추가 사라지면 useMailboxActions 가 메일함 패널의 지금 메일함으로 초점을 옮긴다)
|
||||
// 분할 보기의 목록 칸(384)은 머리가 좁아 아이콘만(40×44 — 이름은 툴팁 · aria-label), 목록 보기는 아이콘 + 이름
|
||||
import { computed, shallowRef, watch } from 'vue'
|
||||
|
||||
import AppIcon from '@/components/AppIcon.vue'
|
||||
import MailboxEmptyDialog from '@/components/mail/MailboxEmptyDialog.vue'
|
||||
import { useMailboxActions } from '@/composables/useMailboxActions'
|
||||
import { MAILBOX_MENU_TEXT } from '@/shared/constants/mailbox-manage'
|
||||
import type { Mailbox } from '@/shared/types/mail'
|
||||
import { useAuthStore } from '@/stores/auth.store'
|
||||
import { useMailStore } from '@/stores/mail.store'
|
||||
|
||||
interface Props {
|
||||
mailbox: Mailbox
|
||||
variant: 'split' | 'list'
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
|
||||
const actions = useMailboxActions()
|
||||
const authStore = useAuthStore()
|
||||
const mailStore = useMailStore()
|
||||
|
||||
/** 확인창을 연 메일함 — 열려 있는 동안만 */
|
||||
const target = shallowRef<Mailbox | null>(null)
|
||||
|
||||
const label = computed(() => (props.mailbox.role === 'junk' ? MAILBOX_MENU_TEXT.emptyJunk : MAILBOX_MENU_TEXT.emptyTrash))
|
||||
/** 지금은 누를 수 없다 — 비우는 중 · 함께 돌 수 없는 처리가 도는 중 */
|
||||
const unavailable = computed(
|
||||
() => actions.isRunning('empty', props.mailbox.id) || actions.isBlocked('empty', props.mailbox),
|
||||
)
|
||||
/** 확인창에 보일 메일함 — 연 뒤에 바뀐 개수를 쓰도록 지금 목록에서 다시 찾는다 */
|
||||
const shown = computed<Mailbox | null>(() => {
|
||||
const opened = target.value
|
||||
if (!opened) return null
|
||||
return mailStore.mailboxes.find((mailbox) => mailbox.id === opened.id) ?? opened
|
||||
})
|
||||
const blocked = computed(() => (shown.value ? actions.isBlocked('empty', shown.value) : false))
|
||||
|
||||
function open() {
|
||||
if (unavailable.value) return
|
||||
target.value = props.mailbox
|
||||
}
|
||||
|
||||
function close() {
|
||||
target.value = null
|
||||
}
|
||||
|
||||
function onConfirm() {
|
||||
const mailbox = shown.value
|
||||
// 누르는 순간 다시 본다 — 그사이 메일함 지우기가 시작됐으면 창을 그대로 둔다
|
||||
if (!mailbox || blocked.value) return
|
||||
close()
|
||||
void actions.empty(mailbox)
|
||||
}
|
||||
|
||||
// 보는 메일함이 바뀌면(뒤로 가기 등) 확인창을 닫는다 — 잡아 둔 메일함과 다른 메일함의 단추가 되었다
|
||||
watch(
|
||||
() => props.mailbox.id,
|
||||
(id) => {
|
||||
if (target.value && target.value.id !== id) close()
|
||||
},
|
||||
)
|
||||
|
||||
// 세션이 바뀌면(로그아웃 · 세션 만료) 확인을 닫는다 — 세션 만료 대화상자와 겹치지 않게
|
||||
watch(() => authStore.generation, close)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<button
|
||||
type="button"
|
||||
:class="['empty-button', `empty-button--${variant}`]"
|
||||
:aria-label="label"
|
||||
:title="label"
|
||||
:aria-disabled="unavailable ? 'true' : undefined"
|
||||
data-mailbox-empty
|
||||
@click="open"
|
||||
>
|
||||
<AppIcon
|
||||
name="trash"
|
||||
:size="15"
|
||||
/>
|
||||
<span class="empty-button__label">{{ label }}</span>
|
||||
</button>
|
||||
<MailboxEmptyDialog
|
||||
:open="shown !== null"
|
||||
:mailbox-name="shown?.name ?? ''"
|
||||
:message-count="shown?.totalCount ?? 0"
|
||||
:blocked="blocked"
|
||||
id-prefix="list-empty"
|
||||
@confirm="onConfirm"
|
||||
@cancel="close"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.empty-button {
|
||||
flex: 0 0 auto;
|
||||
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(--space-12);
|
||||
border: 0;
|
||||
border-radius: var(--radius-md);
|
||||
background: transparent;
|
||||
color: var(--color-danger);
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.empty-button:hover:not([aria-disabled='true']) {
|
||||
background: var(--color-danger-surface);
|
||||
color: var(--color-danger-strong);
|
||||
}
|
||||
|
||||
.empty-button[aria-disabled='true'] {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* 분할 보기의 목록 칸 — 아이콘만 40×44 */
|
||||
.empty-button--split {
|
||||
width: var(--size-icon-button);
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.empty-button--split .empty-button__label {
|
||||
display: none;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,65 @@
|
||||
<script setup lang="ts">
|
||||
// 휴지통 · 스팸메일함 비우기 확인 (디자인 원본 MailboxManage, 공용 모양 ModalDialog — role=alertdialog)
|
||||
// "휴지통의 메일 21통을 모두 영구 삭제할까요?" · "지운 메일은 되돌릴 수 없습니다." · [영구 삭제](빨강 채움) [취소]
|
||||
// 열리면 「취소」에 초점이 간다 — 실수로 Enter · Space 를 눌러도 지워지지 않게. Esc 는 취소와 같다.
|
||||
// 쓰는 곳: 메일함 패널의 ⋯ 메뉴 · 휴지통 / 스팸메일함 목록 머리의 「비우기」 — 둘이 함께 보일 수 있어 id 앞부분(idPrefix)을 따로 받는다
|
||||
// 메일함을 지우는 동안의 휴지통 비우기는 [영구 삭제]를 누를 수 없다 (blocked — 지우는 메일함의 메일이 휴지통에 들어가자마자 지워지지 않게)
|
||||
import ModalDialog from '@/components/feedback/ModalDialog.vue'
|
||||
import { MAILBOX_DIALOG_TEXT } from '@/shared/constants/mailbox-manage'
|
||||
import { formatCount } from '@/shared/utils/format'
|
||||
|
||||
interface Props {
|
||||
open: boolean
|
||||
mailboxName: string
|
||||
/** 안의 메일 수 */
|
||||
messageCount: number
|
||||
/** 제목 · 글 · 단추의 id 앞부분 — 화면에 하나뿐인 값 */
|
||||
idPrefix: string
|
||||
/** 지금은 시작할 수 없다 (메일함을 지우는 중의 휴지통) — [영구 삭제]를 끈다 */
|
||||
blocked?: boolean
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'confirm'): void
|
||||
(e: 'cancel'): void
|
||||
}
|
||||
|
||||
withDefaults(defineProps<Props>(), {
|
||||
blocked: false,
|
||||
})
|
||||
const emit = defineEmits<Emits>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ModalDialog
|
||||
:id="`${idPrefix}-dialog`"
|
||||
:open="open"
|
||||
role="alertdialog"
|
||||
:title="MAILBOX_DIALOG_TEXT.emptyTitle(mailboxName, formatCount(messageCount))"
|
||||
:message="MAILBOX_DIALOG_TEXT.emptyMessage"
|
||||
:title-id="`${idPrefix}-title`"
|
||||
:message-id="`${idPrefix}-message`"
|
||||
@cancel="emit('cancel')"
|
||||
>
|
||||
<template #actions>
|
||||
<button
|
||||
:id="`${idPrefix}-confirm`"
|
||||
type="button"
|
||||
class="modal-dialog__action modal-dialog__action--danger"
|
||||
:disabled="blocked"
|
||||
@click="emit('confirm')"
|
||||
>
|
||||
{{ MAILBOX_DIALOG_TEXT.emptyConfirm }}
|
||||
</button>
|
||||
<button
|
||||
:id="`${idPrefix}-cancel`"
|
||||
type="button"
|
||||
class="modal-dialog__action modal-dialog__action--secondary"
|
||||
autofocus
|
||||
@click="emit('cancel')"
|
||||
>
|
||||
{{ MAILBOX_DIALOG_TEXT.cancel }}
|
||||
</button>
|
||||
</template>
|
||||
</ModalDialog>
|
||||
</template>
|
||||
@@ -0,0 +1,189 @@
|
||||
<script setup lang="ts">
|
||||
// 메일함 행의 ⋯ 단추와 메뉴 (디자인 원본 MailboxManage · README "4단계 › 메일함 관리")
|
||||
// - 단추 32×40 — 목록 표의 별 칸과 같은 밀도 예외(--size-mailbox-more). 행에 마우스를 올리거나 초점이 가면 보이고, 메뉴가 열린 동안은 계속 보인다
|
||||
// (보임 · 숨김은 행 MailboxPanelRow 가 정한다 — 열림을 open-change 로 알린다)
|
||||
// - 메뉴는 편집기 메뉴(EditorMenu — 항목 40)를 쓴다. 키보드로 열면(Enter · Space · 아래 화살표) 첫 항목에, 마우스로 열면 메뉴 자체에 초점.
|
||||
// Esc 는 메뉴만 닫고 이 단추로 돌아간다. 항목을 고르면 메뉴를 닫고 이 단추에 초점을 둔 뒤 알린다 —
|
||||
// 대화상자가 열리면 닫힐 때 초점이 이 단추로 돌아온다
|
||||
// - 메뉴는 아래로 열되, 메일함 패널(스크롤되는 칸)의 보이는 영역 아래가 모자라면 위로 연다 — 아래쪽 줄의 메뉴가 잘리지 않게 (popup-placement.ts)
|
||||
import { computed, ref, watch } from 'vue'
|
||||
|
||||
import AppIcon from '@/components/AppIcon.vue'
|
||||
import EditorMenu from '@/components/mail/editor/EditorMenu.vue'
|
||||
import type { EditorMenuItem } from '@/components/mail/editor/editor-menu'
|
||||
import { MAILBOX_PANEL_TEXT } from '@/shared/constants/mailbox-manage'
|
||||
import { opensUpward } from '@/shared/utils/popup-placement'
|
||||
|
||||
interface Props {
|
||||
/** 메일함 이름 — 단추 · 메뉴의 이름 */
|
||||
mailboxName: string
|
||||
items: readonly EditorMenuItem[]
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'select', id: string): void
|
||||
(e: 'open-change', open: boolean): void
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
/** 잘라 내는 overflow — 이 조상 밖은 보이지 않는다 */
|
||||
const CLIPPING_OVERFLOW = new Set(['auto', 'scroll', 'hidden', 'clip', 'overlay'])
|
||||
|
||||
const buttonRef = ref<HTMLButtonElement | null>(null)
|
||||
const menuRef = ref<InstanceType<typeof EditorMenu> | null>(null)
|
||||
const open = ref<boolean>(false)
|
||||
/** 키보드로 열었다 — 첫 항목에 초점 */
|
||||
const byKeyboard = ref<boolean>(false)
|
||||
/** 위로 연다 — 아래가 모자랄 때 */
|
||||
const upward = ref<boolean>(false)
|
||||
|
||||
const trigger = computed<HTMLElement | null>(() => buttonRef.value)
|
||||
|
||||
function setOpen(next: boolean) {
|
||||
if (open.value === next) return
|
||||
open.value = next
|
||||
emit('open-change', next)
|
||||
}
|
||||
|
||||
/** 단추가 보일 수 있는 세로 영역 — 화면과 잘라 내는 조상(메일함 패널 등)들이 겹치는 부분 */
|
||||
function visibleArea(element: Element): { top: number; bottom: number } {
|
||||
let top = 0
|
||||
let bottom = window.innerHeight
|
||||
for (let node = element.parentElement; node; node = node.parentElement) {
|
||||
if (!CLIPPING_OVERFLOW.has(getComputedStyle(node).overflowY)) continue
|
||||
const rect = node.getBoundingClientRect()
|
||||
top = Math.max(top, rect.top)
|
||||
bottom = Math.min(bottom, rect.bottom)
|
||||
}
|
||||
return { top, bottom }
|
||||
}
|
||||
|
||||
/** 아래로 그린 메뉴를 재어 위로 열지 정한다 */
|
||||
function place() {
|
||||
const button = buttonRef.value
|
||||
const menu = menuRef.value?.$el
|
||||
if (!button || !(menu instanceof HTMLElement)) return
|
||||
const buttonRect = button.getBoundingClientRect()
|
||||
const menuRect = menu.getBoundingClientRect()
|
||||
const area = visibleArea(button)
|
||||
upward.value = opensUpward({
|
||||
triggerTop: buttonRect.top,
|
||||
triggerBottom: buttonRect.bottom,
|
||||
popupHeight: menuRect.height,
|
||||
areaTop: area.top,
|
||||
areaBottom: area.bottom,
|
||||
gap: Math.max(0, menuRect.top - buttonRect.bottom),
|
||||
})
|
||||
}
|
||||
|
||||
function onClick(event: MouseEvent) {
|
||||
if (open.value) {
|
||||
setOpen(false)
|
||||
return
|
||||
}
|
||||
// detail 0 — Enter · Space 로 눌렀다
|
||||
byKeyboard.value = event.detail === 0
|
||||
setOpen(true)
|
||||
}
|
||||
|
||||
function onKeydown(event: KeyboardEvent) {
|
||||
if (event.key !== 'ArrowDown' || open.value) return
|
||||
event.preventDefault()
|
||||
byKeyboard.value = true
|
||||
setOpen(true)
|
||||
}
|
||||
|
||||
function onClose(restoreFocus: boolean) {
|
||||
setOpen(false)
|
||||
if (restoreFocus) buttonRef.value?.focus()
|
||||
}
|
||||
|
||||
function onSelect(id: string) {
|
||||
setOpen(false)
|
||||
buttonRef.value?.focus()
|
||||
emit('select', id)
|
||||
}
|
||||
|
||||
// 메뉴를 그린 직후(초점을 주기 전) 잰다 — 닫으면 다음에 열 때 다시 아래로 재기 시작한다
|
||||
watch(
|
||||
open,
|
||||
(isOpen) => {
|
||||
upward.value = false
|
||||
if (isOpen) place()
|
||||
},
|
||||
{ flush: 'post' },
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="mailbox-more">
|
||||
<button
|
||||
ref="buttonRef"
|
||||
type="button"
|
||||
class="mailbox-more__button"
|
||||
:aria-label="MAILBOX_PANEL_TEXT.more(props.mailboxName)"
|
||||
:title="MAILBOX_PANEL_TEXT.more(props.mailboxName)"
|
||||
aria-haspopup="menu"
|
||||
:aria-expanded="open"
|
||||
data-mailbox-more
|
||||
@click="onClick"
|
||||
@keydown="onKeydown"
|
||||
>
|
||||
<AppIcon
|
||||
name="more-horizontal"
|
||||
:size="15"
|
||||
/>
|
||||
</button>
|
||||
<EditorMenu
|
||||
v-if="open"
|
||||
ref="menuRef"
|
||||
:class="['mailbox-more__menu', { 'mailbox-more__menu--up': upward }]"
|
||||
:label="MAILBOX_PANEL_TEXT.menu(props.mailboxName)"
|
||||
:items="items"
|
||||
:trigger="trigger"
|
||||
align="end"
|
||||
:focus-content="!byKeyboard"
|
||||
@select="onSelect"
|
||||
@close="onClose"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.mailbox-more {
|
||||
position: relative;
|
||||
flex: 0 0 auto;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
/* 32×40 — 밀도 예외 (tokens.css --size-mailbox-more · 행 높이 --size-row-min) */
|
||||
.mailbox-more__button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: var(--size-mailbox-more);
|
||||
height: var(--size-row-min);
|
||||
border: 0;
|
||||
border-radius: var(--radius-sm);
|
||||
background: transparent;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.mailbox-more__button:hover,
|
||||
.mailbox-more__button[aria-expanded='true'] {
|
||||
background: var(--color-border);
|
||||
}
|
||||
|
||||
/* 메뉴 폭 196 — 행 오른쪽 끝에 맞춘다 (디자인 원본 MailboxManage) */
|
||||
.mailbox-more__menu {
|
||||
min-width: 12.25rem;
|
||||
}
|
||||
|
||||
/* 아래가 모자라면 위로 — 단추 위쪽에 같은 간격을 두고 연다 (편집기 메뉴의 아래 열림보다 앞선다) */
|
||||
.mailbox-more .mailbox-more__menu--up {
|
||||
top: auto;
|
||||
bottom: calc(100% + var(--space-4));
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,182 @@
|
||||
<script setup lang="ts">
|
||||
// 메일함 이름 대화상자 — 새 메일함 · "‘프로젝트’ 안에 새 메일함" · 메일함 이름 바꾸기 (디자인 원본 MailboxManage, 공용 모양 ModalDialog)
|
||||
// 이름 칸(44 · 64자) + 안내 "64자까지. 같은 자리에 같은 이름은 쓸 수 없습니다." — 오류는 그 자리에 빨강으로
|
||||
// (같은 이름 MAIL_009 · 형식 VAL_001 — 문구는 에러 코드 사전). 열리면 이름 칸에 초점(이름 바꾸기는 이름 전체를 고른다).
|
||||
// Enter 는 [만들기 · 바꾸기]와 같다 (한글 조합 중의 Enter 는 글자 확정이라 무시한다). Esc 는 취소와 같다.
|
||||
// 고정 id — #mailbox-name-dialog · #mailbox-name-title · #mailbox-name-input · #mailbox-name-help · #mailbox-name-confirm · #mailbox-name-cancel
|
||||
import { computed, nextTick, ref, watch } from 'vue'
|
||||
|
||||
import ModalDialog from '@/components/feedback/ModalDialog.vue'
|
||||
import AppIcon from '@/components/AppIcon.vue'
|
||||
import { getErrorMessage } from '@/composables/useApi'
|
||||
import { MAILBOX_DIALOG_TEXT, MAILBOX_NAME_MAX_LENGTH } from '@/shared/constants/mailbox-manage'
|
||||
import { isImeComposing } from '@/shared/utils/keyboard-shortcut'
|
||||
import { mailboxNameProblem } from '@/shared/utils/mailbox'
|
||||
|
||||
interface Props {
|
||||
open: boolean
|
||||
mode: 'create' | 'rename'
|
||||
/** 하위 메일함을 만들 곳의 이름 — 맨 위면 null */
|
||||
parentName?: string | null
|
||||
/** 처음 이름 — 이름 바꾸기는 지금 이름 */
|
||||
initialName?: string
|
||||
/** 보내는 중 — 단추를 다시 누를 수 없다 */
|
||||
pending?: boolean
|
||||
/** 서버가 거절한 까닭(사전 문구) — 이름을 고치면 부모가 지운다(clear-error) */
|
||||
error?: string | null
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'submit', name: string): void
|
||||
(e: 'cancel'): void
|
||||
(e: 'clear-error'): void
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
parentName: null,
|
||||
initialName: '',
|
||||
pending: false,
|
||||
error: null,
|
||||
})
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
const inputRef = ref<HTMLInputElement | null>(null)
|
||||
const name = ref<string>(props.initialName)
|
||||
/** 보내기 전에 막은 형식 오류 ('/' · 길이) */
|
||||
const formatError = ref<string | null>(null)
|
||||
|
||||
const title = computed(() => {
|
||||
if (props.mode === 'rename') return MAILBOX_DIALOG_TEXT.renameTitle
|
||||
return props.parentName ? MAILBOX_DIALOG_TEXT.createChildTitle(props.parentName) : MAILBOX_DIALOG_TEXT.createTitle
|
||||
})
|
||||
const confirmLabel = computed(() => (props.mode === 'rename' ? MAILBOX_DIALOG_TEXT.rename : MAILBOX_DIALOG_TEXT.create))
|
||||
const shownError = computed(() => formatError.value ?? props.error)
|
||||
/** 이름을 비웠으면 누를 수 없다 */
|
||||
const canSubmit = computed(() => mailboxNameProblem(name.value) !== 'empty' && !props.pending)
|
||||
|
||||
function onInput(event: Event) {
|
||||
name.value = (event.target as HTMLInputElement).value
|
||||
formatError.value = null
|
||||
if (props.error) emit('clear-error')
|
||||
}
|
||||
|
||||
function submit() {
|
||||
if (!canSubmit.value) return
|
||||
if (mailboxNameProblem(name.value) === 'invalid') {
|
||||
formatError.value = getErrorMessage('VAL_001')
|
||||
inputRef.value?.focus()
|
||||
return
|
||||
}
|
||||
emit('submit', name.value.trim())
|
||||
}
|
||||
|
||||
function onKeydown(event: KeyboardEvent) {
|
||||
if (event.key !== 'Enter' || isImeComposing(event)) return
|
||||
event.preventDefault()
|
||||
submit()
|
||||
}
|
||||
|
||||
// 열 때마다 처음 이름으로 — 이름 바꾸기는 이름 전체를 골라 바로 고쳐 쓸 수 있게
|
||||
watch(
|
||||
() => props.open,
|
||||
(open) => {
|
||||
if (!open) return
|
||||
name.value = props.initialName
|
||||
formatError.value = null
|
||||
void nextTick(() => {
|
||||
if (props.mode === 'rename') inputRef.value?.select()
|
||||
})
|
||||
},
|
||||
)
|
||||
|
||||
// 서버가 거절하면 칸에 초점을 돌려준다 — 고쳐 쓰기 쉽게
|
||||
watch(
|
||||
() => props.error,
|
||||
(error) => {
|
||||
if (error) void nextTick(() => inputRef.value?.focus())
|
||||
},
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ModalDialog
|
||||
id="mailbox-name-dialog"
|
||||
:open="open"
|
||||
:title="title"
|
||||
title-id="mailbox-name-title"
|
||||
described-by="mailbox-name-help"
|
||||
@cancel="emit('cancel')"
|
||||
>
|
||||
<div class="mailbox-name">
|
||||
<label
|
||||
for="mailbox-name-input"
|
||||
class="mailbox-name__label"
|
||||
>{{ MAILBOX_DIALOG_TEXT.nameLabel }}</label>
|
||||
<input
|
||||
id="mailbox-name-input"
|
||||
ref="inputRef"
|
||||
class="form-input mailbox-name__input"
|
||||
type="text"
|
||||
:value="name"
|
||||
:maxlength="MAILBOX_NAME_MAX_LENGTH"
|
||||
autocomplete="off"
|
||||
:aria-invalid="shownError ? 'true' : 'false'"
|
||||
aria-describedby="mailbox-name-help"
|
||||
autofocus
|
||||
@input="onInput"
|
||||
@keydown="onKeydown"
|
||||
>
|
||||
<p
|
||||
id="mailbox-name-help"
|
||||
:class="['form-message', { 'form-message--error': shownError }]"
|
||||
>
|
||||
<AppIcon
|
||||
v-if="shownError"
|
||||
name="alert-circle"
|
||||
:size="14"
|
||||
:stroke-width="2"
|
||||
/>
|
||||
<span>{{ shownError ?? MAILBOX_DIALOG_TEXT.nameHelp }}</span>
|
||||
</p>
|
||||
</div>
|
||||
<template #actions>
|
||||
<button
|
||||
id="mailbox-name-confirm"
|
||||
type="button"
|
||||
class="modal-dialog__action"
|
||||
:disabled="!canSubmit"
|
||||
:aria-busy="pending"
|
||||
@click="submit"
|
||||
>
|
||||
{{ confirmLabel }}
|
||||
</button>
|
||||
<button
|
||||
id="mailbox-name-cancel"
|
||||
type="button"
|
||||
class="modal-dialog__action modal-dialog__action--secondary"
|
||||
@click="emit('cancel')"
|
||||
>
|
||||
{{ MAILBOX_DIALOG_TEXT.cancel }}
|
||||
</button>
|
||||
</template>
|
||||
</ModalDialog>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.mailbox-name {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-6);
|
||||
margin-top: var(--space-2);
|
||||
}
|
||||
|
||||
.mailbox-name__label {
|
||||
font-size: var(--font-size-label);
|
||||
font-weight: 600;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.mailbox-name__input {
|
||||
width: 100%;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,777 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { mount, type DOMWrapper, type VueWrapper } from '@vue/test-utils'
|
||||
import { createPinia, setActivePinia, type Pinia } from 'pinia'
|
||||
import { nextTick } from 'vue'
|
||||
|
||||
import { ERROR_CODE_MESSAGES } from '@/shared/constants/error-codes.generated'
|
||||
import type { MailVacation, Mailbox } from '@/shared/types/mail'
|
||||
import { endOfDayIso } from '@/shared/utils/mail-vacation'
|
||||
import { useAuthStore } from '@/stores/auth.store'
|
||||
import { useMailStore } from '@/stores/mail.store'
|
||||
import { useMailVacationStore } from '@/stores/mail-vacation.store'
|
||||
import { useToastStore } from '@/stores/toast.store'
|
||||
import {
|
||||
fail,
|
||||
flushPromises,
|
||||
installMockApi,
|
||||
ok,
|
||||
restoreApiAdapter,
|
||||
type MockRequest,
|
||||
type MockResult,
|
||||
} from '@/test-utils/mock-api'
|
||||
import { createTestRouter } from '@/test-utils/router'
|
||||
import MailboxPanel from './MailboxPanel.vue'
|
||||
|
||||
// 메일함 패널의 메일함 관리 (디자인 원본 MailboxManage · MailActions4 · 계약 2절 "메일함 관리" · "부재중 자동 응답")
|
||||
|
||||
function box(id: string, role: Mailbox['role'], name: string, partial: Partial<Mailbox> = {}): Mailbox {
|
||||
return { id, name, role, parentId: null, sortOrder: 0, totalCount: 0, unreadCount: 0, ...partial }
|
||||
}
|
||||
|
||||
const MAILBOXES: Mailbox[] = [
|
||||
box('inbox', 'inbox', '받은편지함', { unreadCount: 3, totalCount: 148 }),
|
||||
box('sent', 'sent', '보낸편지함', { totalCount: 64 }),
|
||||
box('drafts', 'drafts', '임시보관함', { totalCount: 2 }),
|
||||
box('junk', 'junk', '스팸메일함', { unreadCount: 1, totalCount: 5 }),
|
||||
box('trash', 'trash', '휴지통', { totalCount: 21 }),
|
||||
box('p', null, '프로젝트', { unreadCount: 2, totalCount: 18 }),
|
||||
box('pq', null, '견적', { parentId: 'p', totalCount: 6 }),
|
||||
box('c', null, '거래처', { unreadCount: 1, totalCount: 12 }),
|
||||
]
|
||||
|
||||
/** 응답을 직접 풀어 주는 요청 — 진행 알림을 그 사이에 본다 */
|
||||
interface Deferred {
|
||||
promise: Promise<MockResult>
|
||||
resolve: (result: MockResult) => void
|
||||
}
|
||||
|
||||
function deferred(): Deferred {
|
||||
let resolve: (result: MockResult) => void = () => undefined
|
||||
const promise = new Promise<MockResult>((done) => {
|
||||
resolve = done
|
||||
})
|
||||
return { promise, resolve }
|
||||
}
|
||||
|
||||
type Reply = MockResult | Deferred
|
||||
|
||||
describe('MailboxPanel — 메일함 관리', () => {
|
||||
let pinia: Pinia
|
||||
let requests: MockRequest[]
|
||||
/** "METHOD url" 마다 차례로 줄 응답 — 다 쓰면 마지막 것을 되풀이한다 */
|
||||
let replies: Map<string, Reply[]>
|
||||
|
||||
beforeEach(() => {
|
||||
pinia = createPinia()
|
||||
setActivePinia(pinia)
|
||||
useAuthStore().markAuthenticated({ email: 'ara.kim@arasystem.it.kr', name: '김아라' })
|
||||
replies = new Map()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
useToastStore().clear()
|
||||
restoreApiAdapter()
|
||||
document.body.innerHTML = ''
|
||||
})
|
||||
|
||||
function reply(key: string, ...results: Reply[]) {
|
||||
replies.set(key, results)
|
||||
}
|
||||
|
||||
async function mountPanel(path = '/mail/inbox') {
|
||||
requests = installMockApi((request) => {
|
||||
const queue = replies.get(`${request.method} ${request.url}`)
|
||||
if (queue && queue.length > 0) {
|
||||
const next = queue.length > 1 ? queue.shift() : queue[0]
|
||||
if (next) return typeof next === 'object' && 'promise' in next ? next.promise : next
|
||||
}
|
||||
if (request.url === '/mailboxes') {
|
||||
return ok({ items: [...useMailStore().mailboxes], pageInfo: { hasNext: false, nextCursor: null, total: 8 } })
|
||||
}
|
||||
if (request.url === '/messages') return ok({ items: [], pageInfo: { hasNext: false, nextCursor: null, total: 0 } })
|
||||
return ok(null)
|
||||
})
|
||||
const store = useMailStore()
|
||||
store.mailboxes = MAILBOXES.map((mailbox) => ({ ...mailbox }))
|
||||
store.mailboxesStatus = 'ready'
|
||||
const router = createTestRouter()
|
||||
await router.push(path)
|
||||
await router.isReady()
|
||||
const match = /^\/mail\/([^/]+)/.exec(path)
|
||||
if (match?.[1]) store.currentMailboxId = match[1]
|
||||
const wrapper = mount(MailboxPanel, { attachTo: document.body, global: { plugins: [pinia, router] } })
|
||||
await flushPromises()
|
||||
return { wrapper, router, store }
|
||||
}
|
||||
|
||||
const row = (wrapper: VueWrapper, name: string) => {
|
||||
const found = wrapper.findAll('.mailbox-row').find((item) => item.get('.mailbox-row__name').text() === name)
|
||||
if (!found) throw new Error(`${name} 줄이 없다`)
|
||||
return found
|
||||
}
|
||||
|
||||
async function openMenu(wrapper: VueWrapper, name: string) {
|
||||
await row(wrapper, name).get('[data-mailbox-more]').trigger('click')
|
||||
await flushPromises()
|
||||
return wrapper.findAll('[role="menu"] [role^="menuitem"]')
|
||||
}
|
||||
|
||||
async function choose(wrapper: VueWrapper, name: string, label: string) {
|
||||
const items = await openMenu(wrapper, name)
|
||||
const item = items.find((candidate) => candidate.text() === label)
|
||||
if (!item) throw new Error(`${name} 메뉴에 ${label} 이 없다`)
|
||||
await item.trigger('click')
|
||||
await flushPromises()
|
||||
}
|
||||
|
||||
const dialogOpen = (id: string) => document.getElementById(id)?.hasAttribute('open') ?? false
|
||||
const toastTexts = () => useToastStore().toasts.map((toast) => toast.message)
|
||||
const sent = (method: string, url: string) => requests.filter((request) => request.method === method && request.url === url)
|
||||
|
||||
it('기본 메일함 아래 "내 메일함" 묶음 — 하위 메일함은 들여 쓰고, 끝에 [+ 새 메일함]', async () => {
|
||||
const { wrapper } = await mountPanel()
|
||||
|
||||
const lists = wrapper.findAll('.mailbox-tree__list')
|
||||
expect(lists[0]?.findAll('.mailbox-row__name').map((name) => name.text())).toEqual([
|
||||
'받은편지함',
|
||||
'보낸편지함',
|
||||
'임시보관함',
|
||||
'스팸메일함',
|
||||
'휴지통',
|
||||
])
|
||||
expect(wrapper.get('.mailbox-tree__group').text()).toBe('내 메일함')
|
||||
expect(lists[1]?.attributes('aria-labelledby')).toBe(wrapper.get('.mailbox-tree__group').attributes('id'))
|
||||
expect(lists[1]?.findAll('.mailbox-row__name').map((name) => name.text())).toEqual(['거래처', '프로젝트', '견적'])
|
||||
expect(row(wrapper, '견적').get('a').classes()).toContain('mailbox-row__link--depth-1')
|
||||
expect(wrapper.get('[data-mailbox-new]').text()).toBe('새 메일함')
|
||||
// 안 읽은 수는 평범한 숫자 — ⋯ 앞에 그대로
|
||||
expect(row(wrapper, '받은편지함').get('.mailbox-row__count').text()).toContain('3')
|
||||
})
|
||||
|
||||
it('⋯ 메뉴 — 메일함마다 할 수 있는 일만 · 할 일이 없는 메일함에는 ⋯ 가 없다', async () => {
|
||||
const { wrapper } = await mountPanel()
|
||||
const labels = async (name: string) => {
|
||||
const items = await openMenu(wrapper, name)
|
||||
const result = items.map((item) => [item.text(), item.classes().includes('menu__item--danger')])
|
||||
await row(wrapper, name).get('[data-mailbox-more]').trigger('click')
|
||||
await flushPromises()
|
||||
return result
|
||||
}
|
||||
|
||||
expect(await labels('받은편지함')).toEqual([
|
||||
['모두 읽음으로 표시', false],
|
||||
['하위 메일함 만들기', false],
|
||||
])
|
||||
expect(await labels('스팸메일함')).toEqual([
|
||||
['모두 읽음으로 표시', false],
|
||||
['스팸메일함 비우기', true],
|
||||
])
|
||||
expect(await labels('휴지통')).toEqual([['휴지통 비우기', true]])
|
||||
expect(await labels('프로젝트')).toEqual([
|
||||
['모두 읽음으로 표시', false],
|
||||
['하위 메일함 만들기', false],
|
||||
['이름 바꾸기', false],
|
||||
['지우기', true],
|
||||
])
|
||||
expect(await labels('견적')).toEqual([
|
||||
['하위 메일함 만들기', false],
|
||||
['이름 바꾸기', false],
|
||||
['지우기', true],
|
||||
])
|
||||
expect(row(wrapper, '보낸편지함').find('[data-mailbox-more]').exists()).toBe(false)
|
||||
expect(row(wrapper, '임시보관함').find('[data-mailbox-more]').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('⋯ 단추 — 이름 · aria-expanded, 메뉴가 열린 동안 줄이 is-open(⋯ 가 보인다), Esc 는 메뉴만 닫고 단추로', async () => {
|
||||
const { wrapper } = await mountPanel()
|
||||
const more = row(wrapper, '거래처').get('[data-mailbox-more]')
|
||||
expect(more.attributes('aria-label')).toBe('거래처 메일함 메뉴')
|
||||
expect(more.attributes('aria-haspopup')).toBe('menu')
|
||||
|
||||
await more.trigger('keydown', { key: 'ArrowDown' })
|
||||
await flushPromises()
|
||||
const menu = wrapper.get('[role="menu"]')
|
||||
expect(menu.attributes('aria-label')).toBe('거래처 메일함')
|
||||
expect(more.attributes('aria-expanded')).toBe('true')
|
||||
expect(row(wrapper, '거래처').classes()).toContain('is-open')
|
||||
expect(document.activeElement?.textContent?.trim()).toBe('모두 읽음으로 표시')
|
||||
|
||||
await menu.trigger('keydown', { key: 'Escape' })
|
||||
await flushPromises()
|
||||
expect(wrapper.find('[role="menu"]').exists()).toBe(false)
|
||||
expect(row(wrapper, '거래처').classes()).not.toContain('is-open')
|
||||
expect(document.activeElement).toBe(more.element)
|
||||
})
|
||||
|
||||
describe('새 메일함 · 하위 메일함 · 이름 바꾸기', () => {
|
||||
it('[+ 새 메일함] — 이름 칸(64자)에 초점 · 안내, 만들면 POST /mailboxes { name } → 패널에 바로 나타나고 짧은 알림', async () => {
|
||||
const { wrapper } = await mountPanel()
|
||||
reply('POST /mailboxes', ok(box('n1', null, '협력사'), 201))
|
||||
|
||||
await wrapper.get('[data-mailbox-new]').trigger('click')
|
||||
await flushPromises()
|
||||
expect(dialogOpen('mailbox-name-dialog')).toBe(true)
|
||||
expect(wrapper.get('#mailbox-name-title').text()).toBe('새 메일함')
|
||||
const input = wrapper.get('#mailbox-name-input')
|
||||
expect(input.attributes('maxlength')).toBe('64')
|
||||
expect(document.activeElement).toBe(input.element)
|
||||
expect(wrapper.get('#mailbox-name-help').text()).toBe('64자까지. 같은 자리에 같은 이름은 쓸 수 없습니다.')
|
||||
// 이름을 비웠으면 누를 수 없다
|
||||
expect(wrapper.get('#mailbox-name-confirm').attributes('disabled')).toBeDefined()
|
||||
|
||||
await input.setValue(' 협력사 ')
|
||||
await input.trigger('keydown', { key: 'Enter' })
|
||||
await flushPromises()
|
||||
|
||||
expect(sent('POST', '/mailboxes').map((request) => request.data)).toEqual([{ name: '협력사' }])
|
||||
expect(dialogOpen('mailbox-name-dialog')).toBe(false)
|
||||
expect(wrapper.findAll('.mailbox-row__name').map((name) => name.text())).toContain('협력사')
|
||||
expect(toastTexts()).toEqual(['‘협력사’ 메일함을 만들었습니다.'])
|
||||
})
|
||||
|
||||
it('하위 메일함 만들기 — "‘프로젝트’ 안에 새 메일함", parentId 를 함께 보낸다', async () => {
|
||||
const { wrapper } = await mountPanel()
|
||||
reply('POST /mailboxes', ok(box('n2', null, '2027', { parentId: 'p' }), 201))
|
||||
|
||||
await choose(wrapper, '프로젝트', '하위 메일함 만들기')
|
||||
expect(wrapper.get('#mailbox-name-title').text()).toBe('‘프로젝트’ 안에 새 메일함')
|
||||
await wrapper.get('#mailbox-name-input').setValue('2027')
|
||||
await wrapper.get('#mailbox-name-confirm').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(sent('POST', '/mailboxes').map((request) => request.data)).toEqual([{ name: '2027', parentId: 'p' }])
|
||||
})
|
||||
|
||||
it('같은 이름(409 MAIL_009) — 알림 없이 칸 아래에 빨간 사전 문구 · 대화상자는 그대로, 고치면 안내로 돌아간다', async () => {
|
||||
const { wrapper } = await mountPanel()
|
||||
reply('POST /mailboxes', fail(409, 'MAIL_009'))
|
||||
|
||||
await wrapper.get('[data-mailbox-new]').trigger('click')
|
||||
await flushPromises()
|
||||
await wrapper.get('#mailbox-name-input').setValue('거래처')
|
||||
await wrapper.get('#mailbox-name-confirm').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
const help = wrapper.get('#mailbox-name-help')
|
||||
expect(help.text()).toBe(ERROR_CODE_MESSAGES.MAIL_009)
|
||||
expect(ERROR_CODE_MESSAGES.MAIL_009).toBe('같은 이름의 메일함이 이미 있습니다.')
|
||||
expect(help.classes()).toContain('form-message--error')
|
||||
expect(wrapper.get('#mailbox-name-input').attributes('aria-invalid')).toBe('true')
|
||||
expect(dialogOpen('mailbox-name-dialog')).toBe(true)
|
||||
expect(useToastStore().toasts).toEqual([])
|
||||
|
||||
await wrapper.get('#mailbox-name-input').setValue('거래처 2')
|
||||
expect(wrapper.get('#mailbox-name-help').text()).toBe('64자까지. 같은 자리에 같은 이름은 쓸 수 없습니다.')
|
||||
})
|
||||
|
||||
it('/ 가 든 이름은 보내지 않고 칸 아래에 사전 문구(VAL_001)', async () => {
|
||||
const { wrapper } = await mountPanel()
|
||||
await wrapper.get('[data-mailbox-new]').trigger('click')
|
||||
await flushPromises()
|
||||
await wrapper.get('#mailbox-name-input').setValue('2026/견적')
|
||||
await wrapper.get('#mailbox-name-confirm').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(sent('POST', '/mailboxes')).toEqual([])
|
||||
expect(wrapper.get('#mailbox-name-help').text()).toBe(ERROR_CODE_MESSAGES.VAL_001)
|
||||
})
|
||||
|
||||
it('이름 바꾸기 — 지금 이름으로 채운다. 그대로면 보내지 않고 닫고, 바꾸면 PATCH { name } + "메일함 이름을 바꿨습니다."', async () => {
|
||||
const { wrapper } = await mountPanel()
|
||||
reply('PATCH /mailboxes/c', ok(box('c', null, '거래처 (2026)', { totalCount: 12 })))
|
||||
|
||||
await choose(wrapper, '거래처', '이름 바꾸기')
|
||||
expect(wrapper.get('#mailbox-name-title').text()).toBe('메일함 이름 바꾸기')
|
||||
const input = wrapper.get<HTMLInputElement>('#mailbox-name-input')
|
||||
expect(input.element.value).toBe('거래처')
|
||||
await wrapper.get('#mailbox-name-confirm').trigger('click')
|
||||
await flushPromises()
|
||||
expect(dialogOpen('mailbox-name-dialog')).toBe(false)
|
||||
expect(sent('PATCH', '/mailboxes/c')).toEqual([])
|
||||
|
||||
await choose(wrapper, '거래처', '이름 바꾸기')
|
||||
await wrapper.get('#mailbox-name-input').setValue('거래처 (2026)')
|
||||
await wrapper.get('#mailbox-name-confirm').trigger('click')
|
||||
await flushPromises()
|
||||
expect(sent('PATCH', '/mailboxes/c').map((request) => request.data)).toEqual([{ name: '거래처 (2026)' }])
|
||||
expect(row(wrapper, '거래처 (2026)').exists()).toBe(true)
|
||||
expect(toastTexts()).toEqual(['메일함 이름을 바꿨습니다.'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('지우기 — 안의 메일은 휴지통으로', () => {
|
||||
it('하위 메일함이 있으면 지울 수 없다 — [확인]만, 보내지 않는다', async () => {
|
||||
const { wrapper } = await mountPanel()
|
||||
await choose(wrapper, '프로젝트', '지우기')
|
||||
|
||||
expect(dialogOpen('mailbox-delete-dialog')).toBe(true)
|
||||
expect(wrapper.get('#mailbox-delete-dialog').attributes('role')).toBe('alertdialog')
|
||||
expect(wrapper.get('#mailbox-delete-title').text()).toBe('‘프로젝트’ 메일함을 지울 수 없습니다')
|
||||
expect(wrapper.get('#mailbox-delete-message').text()).toBe('하위 메일함이 있습니다. 하위 메일함을 먼저 지워 주세요.')
|
||||
expect(wrapper.find('#mailbox-delete-confirm').exists()).toBe(false)
|
||||
expect(wrapper.get('#mailbox-delete-cancel').text()).toBe('확인')
|
||||
|
||||
await wrapper.get('#mailbox-delete-cancel').trigger('click')
|
||||
await flushPromises()
|
||||
expect(dialogOpen('mailbox-delete-dialog')).toBe(false)
|
||||
expect(requests.some((request) => request.method === 'DELETE')).toBe(false)
|
||||
})
|
||||
|
||||
it('확인(처음 초점 「취소」) 뒤 DELETE 를 deleted 가 될 때까지 다시 보낸다 — 진행 알림이 결과로 바뀌고, 보던 메일함이면 받은편지함으로', async () => {
|
||||
const second = deferred()
|
||||
reply(
|
||||
'DELETE /mailboxes/c',
|
||||
ok({ deleted: false, processed: 500, movedToTrash: 500, remaining: 734 }),
|
||||
second,
|
||||
ok({ deleted: true, processed: 0, movedToTrash: 0, remaining: 0 }),
|
||||
)
|
||||
const { wrapper, router, store } = await mountPanel('/mail/c')
|
||||
|
||||
await choose(wrapper, '거래처', '지우기')
|
||||
expect(wrapper.get('#mailbox-delete-title').text()).toBe('‘거래처’ 메일함을 지울까요?')
|
||||
expect(wrapper.get('#mailbox-delete-message').text()).toBe(
|
||||
'안의 메일 12통은 휴지통으로 옮깁니다. 지운 메일함은 되돌릴 수 없습니다.',
|
||||
)
|
||||
expect(document.activeElement?.id).toBe('mailbox-delete-cancel')
|
||||
|
||||
await wrapper.get('#mailbox-delete-confirm').trigger('click')
|
||||
await flushPromises()
|
||||
expect(dialogOpen('mailbox-delete-dialog')).toBe(false)
|
||||
expect(useToastStore().toasts.map((toast) => [toast.kind, toast.message])).toEqual([
|
||||
['progress', '‘거래처’ 메일함을 지우는 중 · 500 / 1,234통'],
|
||||
])
|
||||
// 지우는 동안 그 메일함의 메뉴 항목은 누를 수 없다
|
||||
const items = await openMenu(wrapper, '거래처')
|
||||
expect(items.every((item) => item.attributes('disabled') !== undefined)).toBe(true)
|
||||
|
||||
second.resolve(ok({ deleted: false, processed: 734, movedToTrash: 734, remaining: 0 }))
|
||||
await flushPromises()
|
||||
|
||||
expect(sent('DELETE', '/mailboxes/c')).toHaveLength(3)
|
||||
expect(useToastStore().toasts.map((toast) => [toast.kind, toast.message])).toEqual([
|
||||
['success', '‘거래처’ 메일함을 지웠습니다. 안의 메일 1,234통은 휴지통에 있습니다.'],
|
||||
])
|
||||
expect(store.mailboxes.some((mailbox) => mailbox.id === 'c')).toBe(false)
|
||||
expect(router.currentRoute.value.fullPath).toBe('/mail/inbox')
|
||||
})
|
||||
|
||||
it('빈 메일함은 "빈 메일함입니다." — 지운 뒤 "‘…’ 메일함을 지웠습니다." · 다른 메일함을 보고 있었으면 그대로', async () => {
|
||||
const { wrapper, router, store } = await mountPanel('/mail/inbox')
|
||||
store.mailboxes = store.mailboxes.map((mailbox) => (mailbox.id === 'c' ? { ...mailbox, totalCount: 0 } : mailbox))
|
||||
reply('DELETE /mailboxes/c', ok({ deleted: true, processed: 0, movedToTrash: 0, remaining: 0 }))
|
||||
await flushPromises()
|
||||
|
||||
await choose(wrapper, '거래처', '지우기')
|
||||
expect(wrapper.get('#mailbox-delete-message').text()).toBe('빈 메일함입니다. 지운 메일함은 되돌릴 수 없습니다.')
|
||||
await wrapper.get('#mailbox-delete-confirm').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(toastTexts()).toEqual(['‘거래처’ 메일함을 지웠습니다.'])
|
||||
expect(router.currentRoute.value.fullPath).toBe('/mail/inbox')
|
||||
})
|
||||
})
|
||||
|
||||
describe('오래 걸리는 처리 — 모두 읽음 · 비우기', () => {
|
||||
it('모두 읽음 — remaining 이 0 이 될 때까지 PATCH /mailboxes/{id}/messages { isRead: true } · 안 읽은 수가 바로 줄고 결과 알림', async () => {
|
||||
const second = deferred()
|
||||
reply(
|
||||
'PATCH /mailboxes/inbox/messages',
|
||||
ok({ processed: 500, remaining: 734 }),
|
||||
second,
|
||||
)
|
||||
const { wrapper, store } = await mountPanel()
|
||||
|
||||
await choose(wrapper, '받은편지함', '모두 읽음으로 표시')
|
||||
expect(sent('PATCH', '/mailboxes/inbox/messages')[0]?.data).toEqual({ isRead: true })
|
||||
expect(toastTexts()).toEqual(['받은편지함의 메일을 읽음으로 표시하는 중 · 500 / 1,234통'])
|
||||
expect(store.mailboxes.find((mailbox) => mailbox.id === 'inbox')?.unreadCount).toBe(734)
|
||||
// 처리하는 동안 다른 일을 해도 된다 — 같은 처리만 다시 누를 수 없다
|
||||
const items = await openMenu(wrapper, '받은편지함')
|
||||
expect(items.map((item) => [item.text(), item.attributes('disabled') !== undefined])).toEqual([
|
||||
['모두 읽음으로 표시', true],
|
||||
['하위 메일함 만들기', false],
|
||||
])
|
||||
await row(wrapper, '받은편지함').get('[data-mailbox-more]').trigger('click')
|
||||
|
||||
second.resolve(ok({ processed: 734, remaining: 0 }))
|
||||
await flushPromises()
|
||||
expect(sent('PATCH', '/mailboxes/inbox/messages')).toHaveLength(2)
|
||||
expect(useToastStore().toasts.map((toast) => [toast.kind, toast.message])).toEqual([
|
||||
['success', '받은편지함의 메일 1,234통을 읽음으로 표시했습니다.'],
|
||||
])
|
||||
expect(row(wrapper, '받은편지함').find('.mailbox-row__count').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('휴지통 비우기 — 확인(처음 초점 「취소」) 뒤 DELETE /mailboxes/{id}/messages 를 되풀이 · "휴지통을 비웠습니다 — 메일 21통."', async () => {
|
||||
reply('DELETE /mailboxes/trash/messages', ok({ processed: 20, remaining: 1 }), ok({ processed: 1, remaining: 0 }))
|
||||
const { wrapper } = await mountPanel()
|
||||
|
||||
await choose(wrapper, '휴지통', '휴지통 비우기')
|
||||
expect(dialogOpen('mailbox-empty-dialog')).toBe(true)
|
||||
expect(wrapper.get('#mailbox-empty-title').text()).toBe('휴지통의 메일 21통을 모두 영구 삭제할까요?')
|
||||
expect(wrapper.get('#mailbox-empty-message').text()).toBe('지운 메일은 되돌릴 수 없습니다.')
|
||||
expect(document.activeElement?.id).toBe('mailbox-empty-cancel')
|
||||
|
||||
await wrapper.get('#mailbox-empty-cancel').trigger('click')
|
||||
await flushPromises()
|
||||
expect(sent('DELETE', '/mailboxes/trash/messages')).toEqual([])
|
||||
|
||||
await choose(wrapper, '휴지통', '휴지통 비우기')
|
||||
await wrapper.get('#mailbox-empty-confirm').trigger('click')
|
||||
await flushPromises()
|
||||
expect(sent('DELETE', '/mailboxes/trash/messages')).toHaveLength(2)
|
||||
expect(toastTexts()).toEqual(['휴지통을 비웠습니다 — 메일 21통.'])
|
||||
// 되돌릴 수 없어 [되돌리기]가 없다
|
||||
expect(useToastStore().toasts[0]?.action).toBeNull()
|
||||
})
|
||||
|
||||
it('도중에 실패하면 거기까지 처리한 수를 알린다 (까닭은 인터셉터 알림) · 처음부터 실패하면 진행 알림만 닫는다', async () => {
|
||||
reply('PATCH /mailboxes/inbox/messages', ok({ processed: 500, remaining: 734 }), fail(503, 'MAIL_001'))
|
||||
const { wrapper } = await mountPanel()
|
||||
|
||||
await choose(wrapper, '받은편지함', '모두 읽음으로 표시')
|
||||
// 진행 알림은 그 자리에서 결과로 바뀐다 — 까닭(사전 문구 · 문의 코드)은 인터셉터의 알림
|
||||
expect(useToastStore().toasts.map((toast) => [toast.kind, toast.message])).toEqual([
|
||||
['error', '1,234통 가운데 500통까지 처리했습니다 — 다시 시도해 주세요.'],
|
||||
['error', ERROR_CODE_MESSAGES.MAIL_001],
|
||||
])
|
||||
|
||||
useToastStore().clear()
|
||||
reply('DELETE /mailboxes/junk/messages', fail(503, 'SYS_001'))
|
||||
await choose(wrapper, '스팸메일함', '스팸메일함 비우기')
|
||||
await wrapper.get('#mailbox-empty-confirm').trigger('click')
|
||||
await flushPromises()
|
||||
expect(useToastStore().toasts.map((toast) => toast.kind)).toEqual(['error'])
|
||||
expect(toastTexts()).toEqual([ERROR_CODE_MESSAGES.SYS_001])
|
||||
})
|
||||
|
||||
it('서버가 나아가지 못하면(남은 수만 돌려줌) 되풀이를 멈추고 사전 문구로 알린다', async () => {
|
||||
reply('PATCH /mailboxes/c/messages', ok({ processed: 0, remaining: 1 }))
|
||||
const { wrapper } = await mountPanel()
|
||||
|
||||
await choose(wrapper, '거래처', '모두 읽음으로 표시')
|
||||
|
||||
expect(sent('PATCH', '/mailboxes/c/messages')).toHaveLength(3)
|
||||
expect(toastTexts()).toEqual([ERROR_CODE_MESSAGES.BIZ_001])
|
||||
})
|
||||
})
|
||||
|
||||
it('부재중 자동 응답이 켜져 있으면 사용량 위에 안내 — 누르면 설정으로, 꺼지면 사라진다', async () => {
|
||||
const vacation: MailVacation = {
|
||||
isEnabled: true,
|
||||
fromDate: null,
|
||||
toDate: endOfDayIso('2099-11-03'),
|
||||
subject: '부재중 안내',
|
||||
textBody: '휴가입니다.',
|
||||
}
|
||||
const { wrapper } = await mountPanel()
|
||||
useMailVacationStore().vacation = vacation
|
||||
useMailStore().quota = { usedBytes: 0, limitBytes: null, attachmentMaxBytes: 1, attachmentTotalMaxBytes: 1 }
|
||||
await flushPromises()
|
||||
|
||||
const notice = wrapper.get('[data-vacation-notice]')
|
||||
expect(notice.text()).toContain('부재중 자동 응답 켜짐')
|
||||
expect(notice.text()).toContain('2099년 11월 3일까지 · 설정')
|
||||
expect(notice.attributes('href')).toBe('/settings/mail/vacation')
|
||||
const footer = wrapper.get('.mailbox-panel__footer').element
|
||||
expect(footer.firstElementChild).toBe(notice.element)
|
||||
expect(footer.lastElementChild?.classList.contains('quota')).toBe(true)
|
||||
|
||||
useMailVacationStore().vacation = { ...vacation, isEnabled: false }
|
||||
await flushPromises()
|
||||
expect(wrapper.find('[data-vacation-notice]').exists()).toBe(false)
|
||||
})
|
||||
|
||||
describe('지우기 · 비우기 — 리뷰 보완', () => {
|
||||
it('다른 메일함에도 든 메일은 이 메일함에서만 빠진다 — 진행 · 정체는 processed 로, 끝 알림의 휴지통 수는 movedToTrash 합계로', async () => {
|
||||
reply(
|
||||
'DELETE /mailboxes/c',
|
||||
ok({ deleted: false, processed: 500, movedToTrash: 300, remaining: 300 }),
|
||||
ok({ deleted: false, processed: 100, movedToTrash: 0, remaining: 200 }),
|
||||
ok({ deleted: false, processed: 100, movedToTrash: 0, remaining: 100 }),
|
||||
ok({ deleted: false, processed: 100, movedToTrash: 0, remaining: 0 }),
|
||||
ok({ deleted: true, processed: 0, movedToTrash: 0, remaining: 0 }),
|
||||
)
|
||||
const { wrapper } = await mountPanel()
|
||||
|
||||
await choose(wrapper, '거래처', '지우기')
|
||||
await wrapper.get('#mailbox-delete-confirm').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
// 휴지통으로 옮긴 것이 없는 차례가 이어져도 나아가고 있다 — 멈추지 않는다
|
||||
expect(sent('DELETE', '/mailboxes/c')).toHaveLength(5)
|
||||
expect(toastTexts()).toEqual(['‘거래처’ 메일함을 지웠습니다. 안의 메일 300통은 휴지통에 있습니다.'])
|
||||
})
|
||||
|
||||
it('이미 지워진 메일함(404 RES_001)은 끝난 것으로 — 오류 알림 없이 "‘거래처’ 메일함을 지웠습니다." · 메일함 목록을 다시 맞춘다', async () => {
|
||||
reply('DELETE /mailboxes/c', fail(404, 'RES_001'))
|
||||
const { wrapper, store } = await mountPanel()
|
||||
|
||||
await choose(wrapper, '거래처', '지우기')
|
||||
await wrapper.get('#mailbox-delete-confirm').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(useToastStore().toasts.map((toast) => [toast.kind, toast.message])).toEqual([
|
||||
['success', '‘거래처’ 메일함을 지웠습니다.'],
|
||||
])
|
||||
expect(store.mailboxes.some((mailbox) => mailbox.id === 'c')).toBe(false)
|
||||
expect(sent('GET', '/mailboxes').length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('응답이 끊긴 뒤 다시 보내 404 를 받아도 지운 것으로 — 처음 끊김은 인터셉터 알림만', async () => {
|
||||
reply('DELETE /mailboxes/c', 'network', fail(404, 'RES_001'))
|
||||
const { wrapper } = await mountPanel()
|
||||
|
||||
await choose(wrapper, '거래처', '지우기')
|
||||
await wrapper.get('#mailbox-delete-confirm').trigger('click')
|
||||
await flushPromises()
|
||||
expect(toastTexts()).toEqual([ERROR_CODE_MESSAGES.SYS_001])
|
||||
|
||||
useToastStore().clear()
|
||||
await choose(wrapper, '거래처', '지우기')
|
||||
await wrapper.get('#mailbox-delete-confirm').trigger('click')
|
||||
await flushPromises()
|
||||
expect(toastTexts()).toEqual(['‘거래처’ 메일함을 지웠습니다.'])
|
||||
})
|
||||
|
||||
it('다른 탭이 먼저 했으면 — "거래처에 안 읽은 메일이 없습니다." · "휴지통이 이미 비어 있습니다."', async () => {
|
||||
reply('PATCH /mailboxes/c/messages', ok({ processed: 0, remaining: 0 }))
|
||||
reply('DELETE /mailboxes/trash/messages', ok({ processed: 0, remaining: 0 }))
|
||||
const { wrapper } = await mountPanel()
|
||||
|
||||
await choose(wrapper, '거래처', '모두 읽음으로 표시')
|
||||
expect(toastTexts()).toEqual(['거래처에 안 읽은 메일이 없습니다.'])
|
||||
|
||||
useToastStore().clear()
|
||||
await choose(wrapper, '휴지통', '휴지통 비우기')
|
||||
await wrapper.get('#mailbox-empty-confirm').trigger('click')
|
||||
await flushPromises()
|
||||
expect(toastTexts()).toEqual(['휴지통이 이미 비어 있습니다.'])
|
||||
})
|
||||
|
||||
it('다른 탭이 같은 메일함을 처리하는 중이면(429 REQ_001) 인터셉터 알림만 — 진행 알림은 닫는다', async () => {
|
||||
reply('PATCH /mailboxes/inbox/messages', fail(429, 'REQ_001'))
|
||||
const { wrapper } = await mountPanel()
|
||||
|
||||
await choose(wrapper, '받은편지함', '모두 읽음으로 표시')
|
||||
|
||||
expect(useToastStore().toasts.map((toast) => [toast.kind, toast.message])).toEqual([
|
||||
['error', ERROR_CODE_MESSAGES.REQ_001],
|
||||
])
|
||||
})
|
||||
|
||||
it('비운 뒤 누른 ⋯ 가 사라지면 초점을 메일함 패널의 지금 메일함으로', async () => {
|
||||
reply('DELETE /mailboxes/trash/messages', ok({ processed: 21, remaining: 0 }))
|
||||
const { wrapper } = await mountPanel('/mail/inbox')
|
||||
|
||||
await choose(wrapper, '휴지통', '휴지통 비우기')
|
||||
await wrapper.get('#mailbox-empty-confirm').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(row(wrapper, '휴지통').find('[data-mailbox-more]').exists()).toBe(false)
|
||||
expect(document.activeElement).toBe(row(wrapper, '받은편지함').get('a').element)
|
||||
})
|
||||
|
||||
it('지운 메일함의 ⋯ 가 사라져도 초점을 메일함 패널의 지금 메일함으로', async () => {
|
||||
reply('DELETE /mailboxes/c', ok({ deleted: true, processed: 12, movedToTrash: 12, remaining: 0 }))
|
||||
const { wrapper } = await mountPanel('/mail/inbox')
|
||||
|
||||
await choose(wrapper, '거래처', '지우기')
|
||||
await wrapper.get('#mailbox-delete-confirm').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(document.activeElement).toBe(row(wrapper, '받은편지함').get('a').element)
|
||||
})
|
||||
})
|
||||
|
||||
describe('휴지통 비우기 ↔ 메일함 지우기 — 함께 돌지 않는다', () => {
|
||||
const itemState = (items: DOMWrapper<Element>[]) =>
|
||||
items.map((item) => [item.text(), item.attributes('disabled') !== undefined])
|
||||
|
||||
it('휴지통을 비우는 동안은 「지우기」가 꺼진다 — 끝나면 다시 켜진다', async () => {
|
||||
const second = deferred()
|
||||
reply('DELETE /mailboxes/trash/messages', ok({ processed: 20, remaining: 1 }), second)
|
||||
const { wrapper } = await mountPanel()
|
||||
|
||||
await choose(wrapper, '휴지통', '휴지통 비우기')
|
||||
await wrapper.get('#mailbox-empty-confirm').trigger('click')
|
||||
await flushPromises()
|
||||
expect(itemState(await openMenu(wrapper, '거래처'))).toEqual([
|
||||
['모두 읽음으로 표시', false],
|
||||
['하위 메일함 만들기', false],
|
||||
['이름 바꾸기', false],
|
||||
['지우기', true],
|
||||
])
|
||||
await row(wrapper, '거래처').get('[data-mailbox-more]').trigger('click')
|
||||
|
||||
second.resolve(ok({ processed: 1, remaining: 0 }))
|
||||
await flushPromises()
|
||||
expect(itemState(await openMenu(wrapper, '거래처')).at(-1)).toEqual(['지우기', false])
|
||||
})
|
||||
|
||||
it('지우기 대화상자를 연 뒤 휴지통 비우기가 시작되면 [지우기]를 누를 수 없다 — 누르는 순간에도 다시 본다', async () => {
|
||||
const emptying = deferred()
|
||||
reply('DELETE /mailboxes/trash/messages', emptying)
|
||||
const { wrapper, store } = await mountPanel()
|
||||
|
||||
await choose(wrapper, '거래처', '지우기')
|
||||
const trash = store.mailboxes.find((mailbox) => mailbox.id === 'trash')
|
||||
const target = store.mailboxes.find((mailbox) => mailbox.id === 'c')
|
||||
if (!trash || !target) throw new Error('메일함이 없다')
|
||||
const running = store.runMailboxOperation('empty', trash, () => undefined)
|
||||
await flushPromises()
|
||||
|
||||
const confirm = wrapper.get('#mailbox-delete-confirm')
|
||||
expect(confirm.attributes('disabled')).toBeDefined()
|
||||
await confirm.trigger('click')
|
||||
await flushPromises()
|
||||
expect(sent('DELETE', '/mailboxes/c')).toEqual([])
|
||||
expect(dialogOpen('mailbox-delete-dialog')).toBe(true)
|
||||
// 스토어도 시작하지 않는다
|
||||
expect((await store.runMailboxOperation('delete', target, () => undefined)).status).toBe('canceled')
|
||||
|
||||
emptying.resolve(ok({ processed: 21, remaining: 0 }))
|
||||
await running
|
||||
await flushPromises()
|
||||
expect(wrapper.get('#mailbox-delete-confirm').attributes('disabled')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('메일함을 지우는 동안은 「휴지통 비우기」가 꺼진다 — 「스팸메일함 비우기」는 그대로', async () => {
|
||||
const second = deferred()
|
||||
reply('DELETE /mailboxes/c', ok({ deleted: false, processed: 6, movedToTrash: 6, remaining: 6 }), second)
|
||||
const { wrapper } = await mountPanel()
|
||||
|
||||
await choose(wrapper, '거래처', '지우기')
|
||||
await wrapper.get('#mailbox-delete-confirm').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(itemState(await openMenu(wrapper, '휴지통'))).toEqual([['휴지통 비우기', true]])
|
||||
await row(wrapper, '휴지통').get('[data-mailbox-more]').trigger('click')
|
||||
expect(itemState(await openMenu(wrapper, '스팸메일함'))).toEqual([
|
||||
['모두 읽음으로 표시', false],
|
||||
['스팸메일함 비우기', false],
|
||||
])
|
||||
await row(wrapper, '스팸메일함').get('[data-mailbox-more]').trigger('click')
|
||||
|
||||
second.resolve(ok({ deleted: true, processed: 6, movedToTrash: 6, remaining: 0 }))
|
||||
await flushPromises()
|
||||
expect(itemState(await openMenu(wrapper, '휴지통'))).toEqual([['휴지통 비우기', false]])
|
||||
})
|
||||
})
|
||||
|
||||
describe('세션이 바뀌면 (stores/mail/mailbox-admin.ts)', () => {
|
||||
const USER = { email: 'ara.kim@arasystem.it.kr', name: '김아라' }
|
||||
const inbox = () => MAILBOXES[0] as Mailbox
|
||||
|
||||
it('처리 중 로그아웃하면 다음 요청을 보내지 않고 진행 알림이 닫힌다', async () => {
|
||||
const first = deferred()
|
||||
reply('PATCH /mailboxes/inbox/messages', first, ok({ processed: 734, remaining: 0 }))
|
||||
const { wrapper } = await mountPanel()
|
||||
|
||||
await choose(wrapper, '받은편지함', '모두 읽음으로 표시')
|
||||
expect(useToastStore().toasts.map((toast) => toast.kind)).toEqual(['progress'])
|
||||
useAuthStore().clearAuth()
|
||||
await nextTick()
|
||||
first.resolve(ok({ processed: 500, remaining: 734 }))
|
||||
await flushPromises()
|
||||
|
||||
expect(sent('PATCH', '/mailboxes/inbox/messages')).toHaveLength(1)
|
||||
expect(useToastStore().toasts).toEqual([])
|
||||
})
|
||||
|
||||
it('「모두 읽음」을 두 번 눌러도 요청은 한 줄 — 도는 동안 항목이 꺼지고 스토어도 다시 시작하지 않는다', async () => {
|
||||
const first = deferred()
|
||||
reply('PATCH /mailboxes/inbox/messages', first)
|
||||
const { wrapper, store } = await mountPanel()
|
||||
|
||||
await choose(wrapper, '받은편지함', '모두 읽음으로 표시')
|
||||
const items = await openMenu(wrapper, '받은편지함')
|
||||
await items[0]?.trigger('click')
|
||||
await flushPromises()
|
||||
expect((await store.runMailboxOperation('read', inbox(), () => undefined)).status).toBe('canceled')
|
||||
|
||||
expect(sent('PATCH', '/mailboxes/inbox/messages')).toHaveLength(1)
|
||||
first.resolve(ok({ processed: 3, remaining: 0 }))
|
||||
await flushPromises()
|
||||
expect(toastTexts()).toEqual(['받은편지함의 메일 3통을 읽음으로 표시했습니다.'])
|
||||
})
|
||||
|
||||
it('새 세션에서 같은 처리를 다시 시작할 수 있다 — 옛 처리가 끝나도 새 처리의 표시를 지우지 않는다', async () => {
|
||||
const first = deferred()
|
||||
const second = deferred()
|
||||
reply('PATCH /mailboxes/inbox/messages', first, second)
|
||||
const { store } = await mountPanel()
|
||||
|
||||
const old = store.runMailboxOperation('read', inbox(), () => undefined)
|
||||
await flushPromises()
|
||||
// 로그아웃 → 다시 로그인 (사이에 로그인 화면이 있다)
|
||||
useAuthStore().clearAuth()
|
||||
await nextTick()
|
||||
useAuthStore().startSession(USER)
|
||||
await nextTick()
|
||||
store.mailboxes = MAILBOXES.map((mailbox) => ({ ...mailbox }))
|
||||
|
||||
const fresh = store.runMailboxOperation('read', inbox(), () => undefined)
|
||||
await flushPromises()
|
||||
expect(store.isMailboxOperationRunning('read', 'inbox')).toBe(true)
|
||||
|
||||
first.resolve(ok({ processed: 1, remaining: 2 }))
|
||||
expect((await old).status).toBe('canceled')
|
||||
expect(store.isMailboxOperationRunning('read', 'inbox')).toBe(true)
|
||||
expect(sent('PATCH', '/mailboxes/inbox/messages')).toHaveLength(2)
|
||||
|
||||
second.resolve(ok({ processed: 3, remaining: 0 }))
|
||||
expect((await fresh).status).toBe('done')
|
||||
expect(store.isMailboxOperationRunning('read', 'inbox')).toBe(false)
|
||||
})
|
||||
|
||||
it('만들기 · 이름 바꾸기의 늦은 응답은 쓰지 않는다 — 다음 사용자의 메일함을 덮지 않고 알리지도 않는다', async () => {
|
||||
const created = deferred()
|
||||
const renamed = deferred()
|
||||
reply('POST /mailboxes', created)
|
||||
reply('PATCH /mailboxes/c', renamed)
|
||||
const { store } = await mountPanel()
|
||||
|
||||
const creating = store.createMailbox('협력사', null)
|
||||
const renaming = store.renameMailbox('c', '거래처 (옛 사용자)')
|
||||
await flushPromises()
|
||||
useAuthStore().clearAuth()
|
||||
await nextTick()
|
||||
useAuthStore().startSession({ email: 'doyun.lee@arasystem.it.kr', name: '이도윤' })
|
||||
await nextTick()
|
||||
// 다음 사용자의 메일함 — id 가 겹칠 수 있다
|
||||
store.mailboxes = [box('c', null, '도윤의 거래처')]
|
||||
|
||||
created.resolve(ok(box('n1', null, '협력사'), 201))
|
||||
renamed.resolve(ok(box('c', null, '거래처 (옛 사용자)')))
|
||||
expect(await creating).toBeNull()
|
||||
expect(await renaming).toBeNull()
|
||||
expect(store.mailboxes.map((mailbox) => mailbox.name)).toEqual(['도윤의 거래처'])
|
||||
expect(useToastStore().toasts).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
it('⋯ 메뉴는 메일함 패널의 보이는 영역 아래가 모자라면 위로 연다', async () => {
|
||||
const { wrapper } = await mountPanel()
|
||||
const panel = wrapper.get('[data-mailbox-panel]').element as HTMLElement
|
||||
panel.style.overflowY = 'auto'
|
||||
let buttonTop = 540
|
||||
const rectOf = (top: number, bottom: number) =>
|
||||
({ top, bottom, height: bottom - top, left: 0, right: 0, width: 0, x: 0, y: top, toJSON: () => ({}) }) as DOMRect
|
||||
vi.spyOn(Element.prototype, 'getBoundingClientRect').mockImplementation(function (this: Element) {
|
||||
if (this === panel) return rectOf(0, 600)
|
||||
if (this.matches('[data-mailbox-more]')) return rectOf(buttonTop, buttonTop + 40)
|
||||
if (this.matches('[role="menu"]')) return rectOf(buttonTop + 44, buttonTop + 44 + 254)
|
||||
return rectOf(0, 0)
|
||||
})
|
||||
|
||||
await openMenu(wrapper, '거래처')
|
||||
expect(wrapper.get('[role="menu"]').classes()).toContain('mailbox-more__menu--up')
|
||||
await row(wrapper, '거래처').get('[data-mailbox-more]').trigger('click')
|
||||
|
||||
buttonTop = 100
|
||||
await openMenu(wrapper, '거래처')
|
||||
expect(wrapper.get('[role="menu"]').classes()).not.toContain('mailbox-more__menu--up')
|
||||
})
|
||||
})
|
||||
@@ -1,33 +1,63 @@
|
||||
<script setup lang="ts">
|
||||
// 메일함 패널 — 메일 쓰기 + 메일함 목록 + 하단 사용량 (디자인 원본 Main · Compose 왼쪽)
|
||||
// 메일함 패널 — 메일 쓰기 + 메일함 목록 + 아래 안내 · 사용량 (디자인 원본 Main · Compose 왼쪽 · MailboxManage)
|
||||
// - 메일함 목록은 두 묶음(기본 메일함 · "내 메일함")과 [+ 새 메일함] — MailboxTree
|
||||
// - 메일함마다 ⋯ 메뉴(MailboxPanelRow) — 모두 읽음 · 하위 메일함 · 이름 바꾸기 · 지우기 · 비우기. 대화상자는 MailboxDialogs, 하는 일은 useMailboxActions
|
||||
// - 아래: 부재중 자동 응답이 켜져 있으면 안내(MailVacationNotice) → 사용량
|
||||
// 안 읽은 개수는 뱃지가 아니라 오른쪽의 평범한 숫자로 보인다.
|
||||
import { computed } from 'vue'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
|
||||
import AppIcon from '@/components/AppIcon.vue'
|
||||
import MailQuotaMeter from '@/components/mail/MailQuotaMeter.vue'
|
||||
import { mailboxIcon } from '@/shared/utils/mailbox'
|
||||
import { formatCount } from '@/shared/utils/format'
|
||||
import MailVacationNotice from '@/components/mail/MailVacationNotice.vue'
|
||||
import MailboxDialogs from '@/components/mail/MailboxDialogs.vue'
|
||||
import MailboxTree from '@/components/mail/MailboxTree.vue'
|
||||
import type { MailboxDialogRequest } from '@/components/mail/mailbox-dialog'
|
||||
import { useMailboxActions } from '@/composables/useMailboxActions'
|
||||
import type { Mailbox } from '@/shared/types/mail'
|
||||
import type { MailboxAction } from '@/shared/utils/mailbox'
|
||||
import { useAuthStore } from '@/stores/auth.store'
|
||||
import { useMailStore } from '@/stores/mail.store'
|
||||
|
||||
/** 하위 메일함 들여쓰기는 3단계까지만 (그 아래는 같은 깊이로) */
|
||||
const MAX_INDENT_DEPTH = 3
|
||||
import { useMailVacationStore } from '@/stores/mail-vacation.store'
|
||||
|
||||
const route = useRoute()
|
||||
const authStore = useAuthStore()
|
||||
const mailStore = useMailStore()
|
||||
const vacationStore = useMailVacationStore()
|
||||
const actions = useMailboxActions()
|
||||
|
||||
const dialog = ref<MailboxDialogRequest | null>(null)
|
||||
|
||||
const isComposing = computed(() => route.name === 'mail-compose')
|
||||
const activeMailboxId = computed(() => (isComposing.value ? null : mailStore.currentMailboxId))
|
||||
|
||||
function depthClass(depth: number): string {
|
||||
return `mailbox-link--depth-${Math.min(depth, MAX_INDENT_DEPTH)}`
|
||||
/** ⋯ 메뉴에서 고른 일 — 모두 읽음은 바로, 그 밖은 대화상자를 거친다 */
|
||||
function onAction(action: MailboxAction, mailbox: Mailbox) {
|
||||
if (action === 'markRead') {
|
||||
void actions.markAllRead(mailbox)
|
||||
return
|
||||
}
|
||||
if (action === 'createChild') dialog.value = { kind: 'create', parent: mailbox }
|
||||
else if (action === 'rename') dialog.value = { kind: 'rename', mailbox }
|
||||
else if (action === 'delete') dialog.value = { kind: 'delete', mailbox }
|
||||
else dialog.value = { kind: 'empty', mailbox }
|
||||
}
|
||||
|
||||
// 세션이 바뀌면(로그아웃 · 세션 만료) 대화상자를 닫는다 — 세션 만료 대화상자와 겹치지 않게
|
||||
watch(
|
||||
() => authStore.generation,
|
||||
() => {
|
||||
dialog.value = null
|
||||
},
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- data-mailbox-panel — 비우기 · 지우기 뒤 초점을 잃으면 여기의 지금 메일함으로 (useMailboxActions) -->
|
||||
<nav
|
||||
class="mailbox-panel"
|
||||
aria-label="메일함"
|
||||
data-mailbox-panel
|
||||
>
|
||||
<span
|
||||
v-if="isComposing"
|
||||
@@ -54,39 +84,17 @@ function depthClass(depth: number): string {
|
||||
<span>메일 쓰기</span>
|
||||
</RouterLink>
|
||||
|
||||
<ul
|
||||
<MailboxTree
|
||||
v-if="mailStore.orderedMailboxes.length > 0"
|
||||
class="mailbox-panel__list"
|
||||
>
|
||||
<li
|
||||
v-for="node in mailStore.orderedMailboxes"
|
||||
:key="node.mailbox.id"
|
||||
>
|
||||
<RouterLink
|
||||
:to="{ name: 'mail-mailbox', params: { mailboxId: node.mailbox.id } }"
|
||||
:class="[
|
||||
'mailbox-link',
|
||||
depthClass(node.depth),
|
||||
{ 'is-active': node.mailbox.id === activeMailboxId },
|
||||
]"
|
||||
:aria-current="node.mailbox.id === activeMailboxId ? 'page' : undefined"
|
||||
>
|
||||
<AppIcon :name="mailboxIcon(node.mailbox)" />
|
||||
<span class="mailbox-link__name">{{ node.mailbox.name }}</span>
|
||||
<span
|
||||
v-if="node.mailbox.unreadCount > 0"
|
||||
class="mailbox-link__count"
|
||||
>
|
||||
<span class="visually-hidden">안 읽음</span>
|
||||
{{ formatCount(node.mailbox.unreadCount) }}
|
||||
</span>
|
||||
</RouterLink>
|
||||
</li>
|
||||
</ul>
|
||||
:nodes="mailStore.orderedMailboxes"
|
||||
:active-id="activeMailboxId"
|
||||
@action="onAction"
|
||||
@create="dialog = { kind: 'create', parent: null }"
|
||||
/>
|
||||
|
||||
<ul
|
||||
v-else-if="mailStore.mailboxesStatus === 'loading' || mailStore.mailboxesStatus === 'idle'"
|
||||
class="mailbox-panel__list"
|
||||
class="mailbox-panel__placeholders"
|
||||
aria-label="메일함 불러오는 중"
|
||||
aria-busy="true"
|
||||
>
|
||||
@@ -114,12 +122,20 @@ function depthClass(depth: number): string {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 사용량은 보조 정보 — 받지 못했으면 영역을 그리지 않는다 -->
|
||||
<MailQuotaMeter
|
||||
v-if="mailStore.quota"
|
||||
:quota="mailStore.quota"
|
||||
/>
|
||||
<!-- 아래 묶음 — 부재중 안내(켜져 있을 때) → 사용량(받지 못했으면 그리지 않는다 — 보조 정보) -->
|
||||
<div class="mailbox-panel__footer">
|
||||
<MailVacationNotice :vacation="vacationStore.vacation" />
|
||||
<MailQuotaMeter
|
||||
v-if="mailStore.quota"
|
||||
:quota="mailStore.quota"
|
||||
/>
|
||||
</div>
|
||||
</nav>
|
||||
<!-- 대화상자는 메일함 목록(nav) 밖에 둔다 — 열리면 맨 위 층에 뜬다 -->
|
||||
<MailboxDialogs
|
||||
:request="dialog"
|
||||
@close="dialog = null"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
@@ -156,7 +172,7 @@ a.mailbox-panel__primary:hover {
|
||||
background: var(--color-brand-strong);
|
||||
}
|
||||
|
||||
.mailbox-panel__list {
|
||||
.mailbox-panel__placeholders {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-1);
|
||||
@@ -165,60 +181,6 @@ a.mailbox-panel__primary:hover {
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.mailbox-link {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-10);
|
||||
min-height: var(--size-row-min);
|
||||
padding: 0 var(--space-10);
|
||||
border-radius: var(--radius-item);
|
||||
color: var(--color-text-strong);
|
||||
font-weight: 500;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.mailbox-link:hover {
|
||||
background: var(--color-surface-muted);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.mailbox-link.is-active {
|
||||
background: var(--color-brand-subtle);
|
||||
color: var(--color-brand-strong);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.mailbox-link--depth-1 {
|
||||
padding-left: var(--space-28);
|
||||
}
|
||||
|
||||
.mailbox-link--depth-2 {
|
||||
padding-left: var(--space-44);
|
||||
}
|
||||
|
||||
.mailbox-link--depth-3 {
|
||||
padding-left: var(--space-56);
|
||||
}
|
||||
|
||||
.mailbox-link__name {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.mailbox-link__count {
|
||||
font-size: var(--font-size-small);
|
||||
font-weight: 600;
|
||||
color: var(--color-text);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.mailbox-link.is-active .mailbox-link__count {
|
||||
color: var(--color-brand-strong);
|
||||
}
|
||||
|
||||
.mailbox-panel__placeholder {
|
||||
height: var(--size-row-min);
|
||||
margin: var(--space-2) 0;
|
||||
@@ -249,6 +211,14 @@ a.mailbox-panel__primary:hover {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* 아래에 붙는다 — 안내와 사용량 사이 10 */
|
||||
.mailbox-panel__footer {
|
||||
margin-top: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-10);
|
||||
}
|
||||
|
||||
/* 넓은 화면에서는 패널만 따로 스크롤한다 (tokens.css 화면 폭 기준) */
|
||||
@media (min-width: 70rem) {
|
||||
.mailbox-panel {
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
<script setup lang="ts">
|
||||
// 메일함 패널의 한 줄 — 아이콘 · 이름 · 안 읽은 수(평범한 숫자, 뱃지 아님) · ⋯ 메뉴 (디자인 원본 Main · MailboxManage)
|
||||
// - 하위 메일함은 16px 씩 들여 쓴다 (3단계까지 — 그 아래는 같은 깊이로)
|
||||
// - ⋯ 는 마우스를 올리거나 초점이 가면 보이고, 메뉴가 열린 동안은 계속 보인다 (터치 화면은 늘 보인다). 할 일이 없는 메일함은 ⋯ 를 그리지 않는다
|
||||
// - 메뉴 항목은 메일함마다 다르다 (shared/utils/mailbox.ts 의 mailboxActions). 같은 처리가 도는 동안은 그 항목을 끈다 —
|
||||
// 휴지통을 비우는 동안의 「지우기」, 메일함을 지우는 동안의 「휴지통 비우기」도 끈다 (함께 돌 수 없다 — useMailboxActions 의 isBlocked)
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import AppIcon from '@/components/AppIcon.vue'
|
||||
import MailboxMoreButton from '@/components/mail/MailboxMoreButton.vue'
|
||||
import type { EditorMenuItem } from '@/components/mail/editor/editor-menu'
|
||||
import { MAILBOX_MENU_TEXT } from '@/shared/constants/mailbox-manage'
|
||||
import type { Mailbox } from '@/shared/types/mail'
|
||||
import { formatCount } from '@/shared/utils/format'
|
||||
import { mailboxActions, mailboxIcon, type MailboxAction } from '@/shared/utils/mailbox'
|
||||
import { useMailStore } from '@/stores/mail.store'
|
||||
|
||||
interface Props {
|
||||
mailbox: Mailbox
|
||||
depth: number
|
||||
active: boolean
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'action', action: MailboxAction, mailbox: Mailbox): void
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
const mailStore = useMailStore()
|
||||
|
||||
/** 하위 메일함 들여쓰기는 3단계까지만 (그 아래는 같은 깊이로) */
|
||||
const MAX_INDENT_DEPTH = 3
|
||||
|
||||
const menuOpen = ref<boolean>(false)
|
||||
|
||||
/** 그 메일함에서 돌고 있는 처리 — 지우는 중이면 모든 항목을 끈다. 함께 돌 수 없는 처리가 도는 동안의 지우기 · 비우기도 끈다 */
|
||||
function isDisabled(action: MailboxAction): boolean {
|
||||
const mailbox = props.mailbox
|
||||
if (mailStore.isMailboxOperationRunning('delete', mailbox.id)) return true
|
||||
if (action === 'markRead') return mailStore.isMailboxOperationRunning('read', mailbox.id)
|
||||
if (action === 'empty') {
|
||||
return mailStore.isMailboxOperationRunning('empty', mailbox.id) || mailStore.isMailboxOperationBlocked('empty', mailbox)
|
||||
}
|
||||
if (action === 'delete') return mailStore.isMailboxOperationBlocked('delete', mailbox)
|
||||
return false
|
||||
}
|
||||
|
||||
function labelOf(action: MailboxAction): string {
|
||||
if (action === 'empty') return props.mailbox.role === 'junk' ? MAILBOX_MENU_TEXT.emptyJunk : MAILBOX_MENU_TEXT.emptyTrash
|
||||
return MAILBOX_MENU_TEXT[action]
|
||||
}
|
||||
|
||||
const items = computed<EditorMenuItem[]>(() =>
|
||||
mailboxActions(props.mailbox).map((action) => ({
|
||||
id: action,
|
||||
label: labelOf(action),
|
||||
disabled: isDisabled(action),
|
||||
tone: action === 'delete' || action === 'empty' ? 'danger' : undefined,
|
||||
})),
|
||||
)
|
||||
|
||||
const depthClass = computed(() => `mailbox-row__link--depth-${Math.min(props.depth, MAX_INDENT_DEPTH)}`)
|
||||
|
||||
function onSelect(id: string) {
|
||||
const action = mailboxActions(props.mailbox).find((candidate) => candidate === id)
|
||||
if (action) emit('action', action, props.mailbox)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<li :class="['mailbox-row', { 'is-active': active, 'is-open': menuOpen }]">
|
||||
<RouterLink
|
||||
:to="{ name: 'mail-mailbox', params: { mailboxId: mailbox.id } }"
|
||||
:class="['mailbox-row__link', depthClass]"
|
||||
:aria-current="active ? 'page' : undefined"
|
||||
>
|
||||
<AppIcon :name="mailboxIcon(mailbox)" />
|
||||
<span class="mailbox-row__name">{{ mailbox.name }}</span>
|
||||
<span
|
||||
v-if="mailbox.unreadCount > 0"
|
||||
class="mailbox-row__count"
|
||||
>
|
||||
<span class="visually-hidden">안 읽음</span>
|
||||
{{ formatCount(mailbox.unreadCount) }}
|
||||
</span>
|
||||
</RouterLink>
|
||||
<MailboxMoreButton
|
||||
v-if="items.length > 0"
|
||||
class="mailbox-row__more"
|
||||
:mailbox-name="mailbox.name"
|
||||
:items="items"
|
||||
@select="onSelect"
|
||||
@open-change="menuOpen = $event"
|
||||
/>
|
||||
</li>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.mailbox-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
border-radius: var(--radius-item);
|
||||
}
|
||||
|
||||
.mailbox-row:hover {
|
||||
background: var(--color-surface-muted);
|
||||
}
|
||||
|
||||
.mailbox-row.is-active {
|
||||
background: var(--color-brand-subtle);
|
||||
}
|
||||
|
||||
.mailbox-row__link {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-10);
|
||||
min-height: var(--size-row-min);
|
||||
padding: 0 var(--space-4) 0 var(--space-10);
|
||||
border-radius: var(--radius-item);
|
||||
color: var(--color-text-strong);
|
||||
font-weight: 500;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.mailbox-row:hover .mailbox-row__link {
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.mailbox-row.is-active .mailbox-row__link {
|
||||
color: var(--color-brand-strong);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.mailbox-row__link--depth-1 {
|
||||
padding-left: var(--space-28);
|
||||
}
|
||||
|
||||
.mailbox-row__link--depth-2 {
|
||||
padding-left: var(--space-44);
|
||||
}
|
||||
|
||||
.mailbox-row__link--depth-3 {
|
||||
padding-left: var(--space-56);
|
||||
}
|
||||
|
||||
.mailbox-row__name {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.mailbox-row__count {
|
||||
font-size: var(--font-size-small);
|
||||
font-weight: 600;
|
||||
color: var(--color-text);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.mailbox-row.is-active .mailbox-row__count {
|
||||
color: var(--color-brand-strong);
|
||||
}
|
||||
|
||||
/* ⋯ — 마우스를 올리거나 초점이 가면, 메뉴가 열린 동안은 계속 보인다 */
|
||||
.mailbox-row__more {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.mailbox-row:hover .mailbox-row__more,
|
||||
.mailbox-row:focus-within .mailbox-row__more,
|
||||
.mailbox-row.is-open .mailbox-row__more {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* 마우스를 올릴 수 없는 화면(터치)에서는 늘 보인다 */
|
||||
@media (hover: none) {
|
||||
.mailbox-row__more {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,120 @@
|
||||
<script setup lang="ts">
|
||||
// 메일함 목록 두 묶음 — 기본 메일함 · "내 메일함"(12px 굵게) + 끝의 [+ 새 메일함](44) (디자인 원본 MailboxManage)
|
||||
// 하위 메일함은 뿌리의 묶음을 따라 16px 씩 들여 쓴다 (mailbox.ts 의 splitMailboxGroups). 줄 하나는 MailboxPanelRow.
|
||||
// "내 메일함" 이 비어도 묶음 이름과 [+ 새 메일함]은 둔다 — 첫 메일함을 만들 수 있게
|
||||
import { computed } from 'vue'
|
||||
|
||||
import AppIcon from '@/components/AppIcon.vue'
|
||||
import MailboxPanelRow from '@/components/mail/MailboxPanelRow.vue'
|
||||
import { MAILBOX_PANEL_TEXT } from '@/shared/constants/mailbox-manage'
|
||||
import type { Mailbox } from '@/shared/types/mail'
|
||||
import { splitMailboxGroups, type MailboxAction, type MailboxNode } from '@/shared/utils/mailbox'
|
||||
|
||||
interface Props {
|
||||
nodes: readonly MailboxNode[]
|
||||
/** 지금 보는 메일함 — 쓰기 화면에서는 null */
|
||||
activeId: string | null
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'action', action: MailboxAction, mailbox: Mailbox): void
|
||||
(e: 'create'): void
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
const MY_GROUP_ID = 'mailbox-panel-mine'
|
||||
|
||||
const groups = computed(() => splitMailboxGroups(props.nodes))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="mailbox-tree">
|
||||
<ul class="mailbox-tree__list">
|
||||
<MailboxPanelRow
|
||||
v-for="node in groups.system"
|
||||
:key="node.mailbox.id"
|
||||
:mailbox="node.mailbox"
|
||||
:depth="node.depth"
|
||||
:active="node.mailbox.id === activeId"
|
||||
@action="(action, mailbox) => emit('action', action, mailbox)"
|
||||
/>
|
||||
</ul>
|
||||
<p
|
||||
:id="MY_GROUP_ID"
|
||||
class="mailbox-tree__group"
|
||||
>
|
||||
{{ MAILBOX_PANEL_TEXT.myMailboxes }}
|
||||
</p>
|
||||
<ul
|
||||
v-if="groups.mine.length > 0"
|
||||
class="mailbox-tree__list"
|
||||
:aria-labelledby="MY_GROUP_ID"
|
||||
>
|
||||
<MailboxPanelRow
|
||||
v-for="node in groups.mine"
|
||||
:key="node.mailbox.id"
|
||||
:mailbox="node.mailbox"
|
||||
:depth="node.depth"
|
||||
:active="node.mailbox.id === activeId"
|
||||
@action="(action, mailbox) => emit('action', action, mailbox)"
|
||||
/>
|
||||
</ul>
|
||||
<button
|
||||
type="button"
|
||||
class="mailbox-tree__new"
|
||||
data-mailbox-new
|
||||
@click="emit('create')"
|
||||
>
|
||||
<AppIcon name="plus" />
|
||||
<span>{{ MAILBOX_PANEL_TEXT.newMailbox }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.mailbox-tree {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-1);
|
||||
}
|
||||
|
||||
.mailbox-tree__list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-1);
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
/* "내 메일함" — 12px 굵게 (디자인 원본 MailboxManage) */
|
||||
.mailbox-tree__group {
|
||||
margin: var(--space-12) var(--space-10) var(--space-4);
|
||||
font-size: var(--font-size-small);
|
||||
font-weight: 600;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
/* [+ 새 메일함] — 44 */
|
||||
.mailbox-tree__new {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-10);
|
||||
min-height: var(--size-touch);
|
||||
margin-top: var(--space-2);
|
||||
padding: 0 var(--space-10);
|
||||
border: 0;
|
||||
border-radius: var(--radius-item);
|
||||
background: transparent;
|
||||
color: var(--color-text-strong);
|
||||
font-weight: 500;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.mailbox-tree__new:hover {
|
||||
background: var(--color-surface-muted);
|
||||
color: var(--color-text);
|
||||
}
|
||||
</style>
|
||||
@@ -3,19 +3,16 @@
|
||||
// - 「영구 삭제」인지는 그 메일의 메일함으로 정한다 — 휴지통 목록에서는 휴지통에 든 메일, 그 밖에서는 휴지통에만 든 메일 (useMessageRemoval)
|
||||
// · 「삭제」: 휴지통으로 옮긴다 — "메일 1통을 휴지통으로 옮겼습니다." + [되돌리기]
|
||||
// · 「영구 삭제」: 확인 대화상자(처음 초점은 「취소」)를 거쳐 영구 삭제 — "메일 1통을 영구 삭제했습니다."
|
||||
// - 지운 뒤: 분할 보기는 다음 메일(없으면 이전 메일, 그것도 없으면 목록), 목록 보기는 목록으로 (기록을 쌓지 않는다).
|
||||
// - 지운 뒤: 분할 보기는 다음 메일(없으면 이전 메일, 그것도 없으면 목록), 목록 보기는 목록으로 (useMessageRemoval 의 leaveRemoved).
|
||||
// 그사이 다른 목록으로 옮겨 갔거나 서버가 그대로 두었으면 움직이지 않는다
|
||||
// - 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 {
|
||||
@@ -26,10 +23,8 @@ interface Props {
|
||||
|
||||
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)
|
||||
@@ -43,13 +38,7 @@ async function run(kind: 'trash' | 'purge') {
|
||||
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))
|
||||
await removal.leaveRemoved(outcome, source, props.layout)
|
||||
} finally {
|
||||
working.value = false
|
||||
}
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
<script setup lang="ts">
|
||||
// 읽기 화면 도구 줄 끝의 ⋯ (더 보기 40×44) — 메뉴에 "원문 내려받기 (.eml)" (디자인 원본 MailActions4 · 계약 2절 "스팸 · 원문")
|
||||
// 메뉴는 이동 ▾ 과 같은 편집기 메뉴(EditorMenu — 항목 40)를 쓴다. 키보드로 열면(Enter · Space · 아래 화살표) 첫 항목에,
|
||||
// 마우스로 열면 메뉴 자체에 초점. Esc 는 메뉴만 닫고 이 단추로 돌아간다.
|
||||
// 원문 내려받기는 첨부 내려받기처럼 이동하기 전에 세션을 확인한다 (useMail 의 downloadMessageSource — confirmSession)
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import AppIcon from '@/components/AppIcon.vue'
|
||||
import EditorMenu from '@/components/mail/editor/EditorMenu.vue'
|
||||
import type { EditorMenuItem } from '@/components/mail/editor/editor-menu'
|
||||
import { useMail } from '@/composables/useMail'
|
||||
import { MAIL_READER_MORE_TEXT } from '@/shared/constants/mail'
|
||||
|
||||
interface Props {
|
||||
messageId: string
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
|
||||
const { downloadMessageSource } = useMail()
|
||||
|
||||
const buttonRef = ref<HTMLButtonElement | null>(null)
|
||||
const open = ref<boolean>(false)
|
||||
/** 키보드로 열었다 — 첫 항목에 초점 */
|
||||
const byKeyboard = ref<boolean>(false)
|
||||
|
||||
const ITEMS: readonly EditorMenuItem[] = [
|
||||
{ id: 'source', label: MAIL_READER_MORE_TEXT.downloadSource, icon: 'download' },
|
||||
]
|
||||
const trigger = computed<HTMLElement | null>(() => buttonRef.value)
|
||||
|
||||
function onClick(event: MouseEvent) {
|
||||
if (open.value) {
|
||||
open.value = false
|
||||
return
|
||||
}
|
||||
// detail 0 — Enter · Space 로 눌렀다
|
||||
byKeyboard.value = event.detail === 0
|
||||
open.value = true
|
||||
}
|
||||
|
||||
function onKeydown(event: KeyboardEvent) {
|
||||
if (event.key !== 'ArrowDown' || open.value) return
|
||||
event.preventDefault()
|
||||
byKeyboard.value = true
|
||||
open.value = true
|
||||
}
|
||||
|
||||
function onClose(restoreFocus: boolean) {
|
||||
open.value = false
|
||||
if (restoreFocus) buttonRef.value?.focus()
|
||||
}
|
||||
|
||||
function onSelect(id: string) {
|
||||
open.value = false
|
||||
buttonRef.value?.focus()
|
||||
if (id === 'source') void downloadMessageSource(props.messageId)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="reader-more">
|
||||
<button
|
||||
ref="buttonRef"
|
||||
type="button"
|
||||
class="reader-more__button"
|
||||
:aria-label="MAIL_READER_MORE_TEXT.button"
|
||||
:title="MAIL_READER_MORE_TEXT.button"
|
||||
aria-haspopup="menu"
|
||||
:aria-expanded="open"
|
||||
data-reader-more
|
||||
@click="onClick"
|
||||
@keydown="onKeydown"
|
||||
>
|
||||
<AppIcon name="more-horizontal" />
|
||||
</button>
|
||||
<EditorMenu
|
||||
v-if="open"
|
||||
class="reader-more__menu"
|
||||
:label="MAIL_READER_MORE_TEXT.button"
|
||||
:items="ITEMS"
|
||||
:trigger="trigger"
|
||||
align="end"
|
||||
:focus-content="!byKeyboard"
|
||||
@select="onSelect"
|
||||
@close="onClose"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* 메뉴는 이 단추 바로 아래 — 도구 줄 아래 선 밑에서 연다 (EditorPopup) */
|
||||
.reader-more {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* 40×44 — 새로고침 · 이전/다음과 같은 크기 */
|
||||
.reader-more__button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: var(--size-icon-button);
|
||||
height: var(--size-touch);
|
||||
border: 0;
|
||||
border-radius: var(--radius-md);
|
||||
background: transparent;
|
||||
color: var(--color-text-strong);
|
||||
}
|
||||
|
||||
.reader-more__button:hover,
|
||||
.reader-more__button[aria-expanded='true'] {
|
||||
background: var(--color-surface-muted);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
/* 메뉴 폭 200 (디자인 원본 MailActions4) */
|
||||
.reader-more__menu {
|
||||
min-width: 12.5rem;
|
||||
}
|
||||
</style>
|
||||
@@ -11,6 +11,7 @@ import { useAuthStore } from '@/stores/auth.store'
|
||||
import { useMailStore } from '@/stores/mail.store'
|
||||
import { useToastStore } from '@/stores/toast.store'
|
||||
import {
|
||||
fail,
|
||||
flushPromises,
|
||||
installMockApi,
|
||||
ok,
|
||||
@@ -457,3 +458,149 @@ describe('MessageReader 삭제 · 영구 삭제 — DELETE /messages/{id} 를
|
||||
expect(deleteButton(inTrashList.wrapper).text()).toBe('영구 삭제')
|
||||
})
|
||||
})
|
||||
|
||||
describe('MessageReader 스팸 신고 · 원문 내려받기 (4단계 — 디자인 원본 MailActions4)', () => {
|
||||
let pinia: Pinia
|
||||
let requests: MockRequest[]
|
||||
/** 내려받기를 시작하는 임시 <a download> 의 클릭 — jsdom 은 이동하지 못하므로 가로채 기록만 한다 */
|
||||
let downloads: (string | null)[]
|
||||
|
||||
const INBOX: Mailbox = { id: 'inbox', name: '받은편지함', role: 'inbox', parentId: null, sortOrder: 0, totalCount: 2, unreadCount: 0 }
|
||||
const JUNK: Mailbox = { ...INBOX, id: 'junk', name: '스팸메일함', role: 'junk' }
|
||||
const TRASH: Mailbox = { ...INBOX, id: 'trash', name: '휴지통', role: 'trash' }
|
||||
|
||||
beforeEach(() => {
|
||||
pinia = createPinia()
|
||||
setActivePinia(pinia)
|
||||
useAuthStore().startSession({ email: 'ara.kim@arasystem.it.kr', name: '김아라' })
|
||||
downloads = []
|
||||
vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(function (this: HTMLAnchorElement) {
|
||||
downloads.push(this.getAttribute('href'))
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
useToastStore().clear()
|
||||
restoreApiAdapter()
|
||||
document.body.innerHTML = ''
|
||||
})
|
||||
|
||||
async function mountWith(
|
||||
options: { box?: string; mailboxes?: Mailbox[]; me?: MockResult; refresh?: MockResult } = {},
|
||||
) {
|
||||
const box = options.box ?? 'inbox'
|
||||
const mailboxes = options.mailboxes ?? [INBOX, JUNK, TRASH]
|
||||
requests = installMockApi((request) => {
|
||||
if (request.url === '/auth/me') return options.me ?? ok({ email: 'ara.kim@arasystem.it.kr', name: '김아라' })
|
||||
if (request.url === '/auth/refresh') return options.refresh ?? ok({ email: 'ara.kim@arasystem.it.kr', name: '김아라' })
|
||||
if (request.url === '/mailboxes') return ok({ items: mailboxes, pageInfo: { hasNext: false, nextCursor: null, total: 3 } })
|
||||
if (request.url === '/messages' && request.method === 'GET') {
|
||||
return ok({ items: [], pageInfo: { hasNext: false, nextCursor: null, total: 0 } })
|
||||
}
|
||||
if (request.url === '/messages') return ok({ updatedIds: (request.data as MessagesPatch).ids, failedIds: [] })
|
||||
return ok({ usedBytes: 0, limitBytes: null, attachmentMaxBytes: 1, attachmentTotalMaxBytes: 1 })
|
||||
})
|
||||
const store = useMailStore()
|
||||
store.mailboxes = mailboxes
|
||||
store.listSource = { kind: 'mailbox', mailboxId: box }
|
||||
const open = { ...DETAIL, mailboxIds: [box] }
|
||||
store.messages = [open, { ...DETAIL, id: 'm2', mailboxIds: [box], 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/${box}/m1`)
|
||||
await router.isReady()
|
||||
const wrapper = mount(MessageReader, { global: { plugins: [pinia, router] }, attachTo: document.body })
|
||||
return { wrapper, router, store }
|
||||
}
|
||||
|
||||
const patches = () => requests.filter((request) => request.method === 'PATCH').map((request) => request.data)
|
||||
|
||||
it('도구 줄 — 답장 · 전체 답장 · 전달 | 스팸 신고 · 삭제 · ⋯(더 보기)', async () => {
|
||||
const { wrapper } = await mountWith()
|
||||
const toolbar = wrapper.get('.reader__toolbar')
|
||||
|
||||
expect(toolbar.findAll('.tool-button').map((button) => button.text())).toEqual([
|
||||
'답장',
|
||||
'전체 답장',
|
||||
'전달',
|
||||
'스팸 신고',
|
||||
'삭제',
|
||||
])
|
||||
const more = toolbar.get('[data-reader-more]')
|
||||
expect(more.attributes('aria-label')).toBe('더 보기')
|
||||
expect(more.attributes('aria-haspopup')).toBe('menu')
|
||||
expect(toolbar.element.lastElementChild?.contains(more.element)).toBe(true)
|
||||
})
|
||||
|
||||
it('스팸 신고 — 스팸메일함으로 옮기며 isJunk: true, 다음 메일을 연다 · [되돌리기]는 원래 메일함 · isJunk: false', async () => {
|
||||
const { wrapper, router, store } = await mountWith()
|
||||
|
||||
await wrapper.get('[data-reader-spam]').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(patches()).toEqual([{ ids: ['m1'], mailboxId: 'junk', isJunk: true }])
|
||||
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(patches().at(-1)).toEqual({ ids: ['m1'], mailboxId: 'inbox', isJunk: false })
|
||||
})
|
||||
|
||||
it('스팸메일함의 메일은 「스팸 아님」(강조) — 받은편지함으로 · isJunk: false, "(스팸 아님)" 알림', async () => {
|
||||
const { wrapper } = await mountWith({ box: 'junk' })
|
||||
const notSpam = wrapper.get('[data-reader-spam]')
|
||||
expect(notSpam.text()).toBe('스팸 아님')
|
||||
expect(notSpam.classes()).toContain('tool-button--accent')
|
||||
|
||||
await notSpam.trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(patches()).toEqual([{ ids: ['m1'], mailboxId: 'inbox', isJunk: false }])
|
||||
expect(useToastStore().toasts[0]?.message).toBe('메일 1통을 받은편지함으로 옮겼습니다 (스팸 아님).')
|
||||
})
|
||||
|
||||
it('스팸메일함을 모르면 「스팸 신고」를 그리지 않는다', async () => {
|
||||
const { wrapper } = await mountWith({ mailboxes: [INBOX, TRASH] })
|
||||
|
||||
expect(wrapper.find('[data-reader-spam]').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('⋯ → "원문 내려받기 (.eml)" — 이동하기 전에 세션을 확인하고(confirmSession) 원문 주소로 내려받는다', async () => {
|
||||
const { wrapper } = await mountWith()
|
||||
const more = wrapper.get('[data-reader-more]')
|
||||
|
||||
await more.trigger('keydown', { key: 'ArrowDown' })
|
||||
await flushPromises()
|
||||
const items = wrapper.findAll('[role="menu"] [role="menuitem"]')
|
||||
expect(items.map((item) => item.text())).toEqual(['원문 내려받기 (.eml)'])
|
||||
expect(document.activeElement).toBe(items[0]?.element)
|
||||
|
||||
await items[0]?.trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(requests.map((request) => `${request.method} ${request.url}`)).toEqual(['GET /auth/me'])
|
||||
expect(downloads).toEqual(['/api/messages/m1/source'])
|
||||
expect(wrapper.find('[role="menu"]').exists()).toBe(false)
|
||||
expect(document.activeElement).toBe(more.element)
|
||||
})
|
||||
|
||||
it('세션을 확인하지 못하면(갱신도 401) 내려받지 않고 세션 만료로 다룬다', async () => {
|
||||
const { wrapper } = await mountWith({ me: fail(401, 'AUTH_001'), refresh: fail(401, 'AUTH_001') })
|
||||
|
||||
await wrapper.get('[data-reader-more]').trigger('click')
|
||||
await flushPromises()
|
||||
await wrapper.get('[role="menuitem"]').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(downloads).toEqual([])
|
||||
expect(useAuthStore().sessionExpired).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -3,7 +3,8 @@
|
||||
// 본문 HTML 은 MailBodyFrame(격리된 iframe)에만 그린다.
|
||||
// 툴바와 본문은 콘텐츠 열(tokens.css --content-*)을 따른다 — 분할 보기 읽기 · 목록 보기 읽기 · 쓰기 공통.
|
||||
// 검색 결과에서 연 메일은 받는 사람 아래에 "메일함 받은편지함" 한 줄을 더한다 (디자인 README "3단계 › 검색").
|
||||
// 「삭제」 · 「영구 삭제」는 MessageDeleteButton — 휴지통인지는 그 메일의 메일함으로 정한다.
|
||||
// 도구 줄: 답장 · 전체 답장 · 전달 | 스팸 신고(스팸메일함에서는 스팸 아님) · 삭제 · ⋯(더 보기 — 원문 내려받기) (디자인 README "4단계 › 스팸 · 원문").
|
||||
// 「삭제」 · 「영구 삭제」는 MessageDeleteButton — 휴지통인지는 그 메일의 메일함으로 정한다. 「스팸 신고」는 MessageSpamButton, ⋯ 는 MessageMoreMenu.
|
||||
// 삭제 뒤 · 없는 메일에서 돌아가는 곳은 지금 목록(메일함 또는 검색 결과)이다.
|
||||
import { computed } from 'vue'
|
||||
|
||||
@@ -15,6 +16,8 @@ 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 MessageMoreMenu from '@/components/mail/MessageMoreMenu.vue'
|
||||
import MessageSpamButton from '@/components/mail/MessageSpamButton.vue'
|
||||
import { useMailNavigation } from '@/composables/useMailNavigation'
|
||||
import { MAIL_SEARCH_TEXT } from '@/shared/constants/mail'
|
||||
import { formatMailDateTime, initialOf } from '@/shared/utils/format'
|
||||
@@ -99,10 +102,15 @@ function leaveToList() {
|
||||
:to="{ name: 'mail-compose', query: { forward: message.id } }"
|
||||
/>
|
||||
<MailToolbarDivider v-if="layout === 'split'" />
|
||||
<MessageSpamButton
|
||||
:message="message"
|
||||
:layout="layout"
|
||||
/>
|
||||
<MessageDeleteButton
|
||||
:message="message"
|
||||
:layout="layout"
|
||||
/>
|
||||
<MessageMoreMenu :message-id="message.id" />
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
<script setup lang="ts">
|
||||
// 읽기 화면의 「스팸 신고」 · 「스팸 아님」 — 열린 메일 한 통 (디자인 README "4단계 › 스팸 · 원문 · 오래 걸리는 처리" · useMessageRemoval)
|
||||
// - 스팸메일함에 든 메일이면 「스팸 아님」(받은편지함 아이콘 · 강조 글자 #0A5F67 굵게) — 받은편지함으로 옮기고 "스팸 아님" 표시
|
||||
// - 그 밖은 「스팸 신고」 — 스팸메일함으로 옮기고 스팸 표시. 알림에 [되돌리기](원래 메일함 · 원래 표시로)
|
||||
// - 옮길 메일함(스팸메일함 · 받은편지함)을 모르면(메일함 목록을 아직 못 받음) 그리지 않는다
|
||||
// - 옮긴 뒤: 삭제와 같다 — 분할 보기는 다음 메일, 목록 보기는 목록으로 (leaveRemoved)
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import MailToolButton from '@/components/mail/MailToolButton.vue'
|
||||
import { useMessageRemoval } from '@/composables/useMessageRemoval'
|
||||
import { MAIL_SPAM_TEXT } from '@/shared/constants/mail'
|
||||
import type { MessageSummary } from '@/shared/types/mail'
|
||||
import { useMailStore } from '@/stores/mail.store'
|
||||
|
||||
interface Props {
|
||||
message: MessageSummary
|
||||
/** split: 옮기면 다음 메일을 연다 · list: 옮기면 목록으로 */
|
||||
layout: 'split' | 'list'
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
|
||||
const mailStore = useMailStore()
|
||||
const removal = useMessageRemoval()
|
||||
|
||||
const working = ref<boolean>(false)
|
||||
|
||||
const inJunk = computed(() => removal.isInJunk(props.message))
|
||||
const available = computed(() => mailStore.mailboxByRole(inJunk.value ? 'inbox' : 'junk') !== null)
|
||||
|
||||
async function onClick() {
|
||||
if (working.value) return
|
||||
const source = mailStore.listSource
|
||||
working.value = true
|
||||
try {
|
||||
const outcome = await removal.moveSpam(props.message)
|
||||
await removal.leaveRemoved(outcome, source, props.layout)
|
||||
} finally {
|
||||
working.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<MailToolButton
|
||||
v-if="available"
|
||||
:icon="inJunk ? 'inbox' : 'alert-octagon'"
|
||||
:label="inJunk ? MAIL_SPAM_TEXT.notSpam : MAIL_SPAM_TEXT.report"
|
||||
:tone="inJunk ? 'accent' : 'default'"
|
||||
:disabled="working"
|
||||
data-reader-spam
|
||||
@click="onClick"
|
||||
/>
|
||||
</template>
|
||||
@@ -1,6 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
// 툴바 메뉴 — 글꼴 · 글자 크기 · 더보기 (디자인 원본 ComposeEditor "더보기 메뉴" · "글꼴 · 글자 크기")
|
||||
// 항목 높이 40 · 아이콘 16 · 이름 · 단축키(흐린 글자) · 켜진 것은 오른쪽 ✓ · 한 단계 더 여는 것은 ›
|
||||
// 같은 모양을 쓰는 곳: 여러 통 처리의 이동 ▾ · 메일함 패널의 ⋯ (되돌릴 수 없는 항목은 tone 'danger' — 빨간 글자) · 읽기 화면의 ⋯
|
||||
import AppIcon from '@/components/AppIcon.vue'
|
||||
import EditorColorGlyph from '@/components/mail/editor/EditorColorGlyph.vue'
|
||||
import EditorPopup from '@/components/mail/editor/EditorPopup.vue'
|
||||
@@ -52,7 +53,7 @@ function isToggle(role: string | undefined): boolean {
|
||||
<button
|
||||
v-else
|
||||
type="button"
|
||||
class="menu__item"
|
||||
:class="['menu__item', { 'menu__item--danger': item.tone === 'danger' }]"
|
||||
:role="item.role ?? 'menuitem'"
|
||||
:aria-checked="isToggle(item.role) ? Boolean(item.checked) : undefined"
|
||||
:aria-haspopup="item.opens"
|
||||
@@ -125,6 +126,19 @@ function isToggle(role: string | undefined): boolean {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
/* 되돌릴 수 없는 일 (디자인 원본 MailboxManage "지우기 · 비우기") */
|
||||
.menu__item--danger {
|
||||
color: var(--color-danger);
|
||||
}
|
||||
|
||||
.menu__item--danger:hover:not(:disabled) {
|
||||
background: var(--color-danger-surface);
|
||||
}
|
||||
|
||||
.menu__item--danger .menu__icon {
|
||||
color: var(--color-danger);
|
||||
}
|
||||
|
||||
.menu__icon {
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
@@ -16,6 +16,8 @@ export interface EditorMenuEntry {
|
||||
hint?: string
|
||||
checked?: boolean
|
||||
disabled?: boolean
|
||||
/** danger: 되돌릴 수 없는 일 — 빨간 글자 (메일함 ⋯ 메뉴의 지우기 · 비우기) */
|
||||
tone?: 'danger'
|
||||
/** 한 단계 더 여는 항목 (›) — 메뉴인지 대화상자인지 */
|
||||
opens?: 'menu' | 'dialog'
|
||||
/** 미리보기 모양 (글꼴 · 크기) — EditorMenu 의 class 이름 */
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
// 메일함 패널의 대화상자 요청 — 패널(MailboxPanel)이 열고 대화상자 묶음(MailboxDialogs)이 그린다
|
||||
import type { Mailbox } from '@/shared/types/mail'
|
||||
|
||||
export type MailboxDialogRequest =
|
||||
/** 새 메일함 — parent 가 없으면 맨 위 ("내 메일함"), 있으면 그 안 */
|
||||
| { kind: 'create'; parent: Mailbox | null }
|
||||
| { kind: 'rename'; mailbox: Mailbox }
|
||||
| { kind: 'delete'; mailbox: Mailbox }
|
||||
| { kind: 'empty'; mailbox: Mailbox }
|
||||
@@ -0,0 +1,258 @@
|
||||
<script setup lang="ts">
|
||||
// 비밀번호 바꾸기 (디자인 원본 SettingsAccount · 계약 1절 "PUT /api/auth/password")
|
||||
// 칸 셋(최대 폭 420): 지금 비밀번호 · 새 비밀번호 · 새 비밀번호 확인 — 앞의 두 칸 끝에 보기/숨기기 (확인 칸은 새 비밀번호를 따른다)
|
||||
// - 보내기 전에 서버와 같은 규칙을 본다: 8자 이상 · 지금과 다름(어기면 새 비밀번호 안내 글이 빨간 오류가 된다) · 확인 칸 일치("새 비밀번호가 서로 다릅니다.")
|
||||
// · 지금 비밀번호를 비움 · 128자 초과(사전 문구 VAL_001). 처음 걸린 칸으로 초점을 옮긴다
|
||||
// - 서버: 지금 비밀번호가 틀림(AUTH_005)은 지금 비밀번호 칸 아래, 새 비밀번호가 엔진 규칙에 어긋남(VAL_001)은 새 비밀번호 칸 아래에
|
||||
// 사전 문구로 (알림은 끈다). 그 밖(요청 과다 · 메일 서버 장애)은 인터셉터의 알림. 기다리는 사이 화면을 떠났으면 끈 거절도 알림으로 (notifyApiError).
|
||||
// 요청 한도는 30초다 (useAuth — 서버가 엔진을 두 번 부르고 다른 세션을 끊는다)
|
||||
// - 바꾸면 "비밀번호를 바꿨습니다. 다른 기기 · 브라우저의 로그인은 끊었습니다." — 세 칸을 비우고 보기를 끈다. 무엇이든 다시 치면 내린다
|
||||
// 고정 id — #password-current · #password-next · #password-confirm · #password-submit · #password-status
|
||||
import { computed, onBeforeUnmount, ref } from 'vue'
|
||||
|
||||
import AppIcon from '@/components/AppIcon.vue'
|
||||
import PasswordField from '@/components/settings/PasswordField.vue'
|
||||
import { getErrorMessage, notifyApiError, toApiError } from '@/composables/useApi'
|
||||
import { useAuth } from '@/composables/useAuth'
|
||||
import { PASSWORD_TEXT } from '@/shared/constants/account'
|
||||
import type { ErrorCode } from '@/shared/constants/error-codes.generated'
|
||||
import {
|
||||
hasPasswordChangeProblem,
|
||||
passwordChangeProblems,
|
||||
type PasswordChangeProblems,
|
||||
} from '@/shared/utils/password-change'
|
||||
import { useAuthStore } from '@/stores/auth.store'
|
||||
|
||||
/** 칸 아래에 보이는 서버 거절 — 알림은 끈다 */
|
||||
const INLINE_CODES: readonly ErrorCode[] = ['AUTH_005', 'VAL_001']
|
||||
const NO_PROBLEMS: PasswordChangeProblems = { current: null, next: null, confirm: null }
|
||||
|
||||
const { changePassword } = useAuth()
|
||||
const authStore = useAuthStore()
|
||||
|
||||
const currentRef = ref<InstanceType<typeof PasswordField> | null>(null)
|
||||
const nextRef = ref<InstanceType<typeof PasswordField> | null>(null)
|
||||
const confirmRef = ref<InstanceType<typeof PasswordField> | null>(null)
|
||||
|
||||
const current = ref<string>('')
|
||||
const next = ref<string>('')
|
||||
const confirm = ref<string>('')
|
||||
const showCurrent = ref<boolean>(false)
|
||||
const showNext = ref<boolean>(false)
|
||||
const problems = ref<PasswordChangeProblems>(NO_PROBLEMS)
|
||||
/** 서버가 거절한 까닭 — 그 칸 아래에 (사전 문구) */
|
||||
const serverError = ref<{ field: 'current' | 'next'; message: string } | null>(null)
|
||||
const submitting = ref<boolean>(false)
|
||||
const done = ref<boolean>(false)
|
||||
/** 화면이 사라졌다 — 칸 아래 글을 볼 곳이 없다 */
|
||||
let unmounted = false
|
||||
|
||||
const currentError = computed(() => {
|
||||
if (problems.value.current === 'missing') return getErrorMessage('VAL_001')
|
||||
return serverError.value?.field === 'current' ? serverError.value.message : null
|
||||
})
|
||||
const nextError = computed(() => {
|
||||
if (problems.value.next === 'tooLong') return getErrorMessage('VAL_001')
|
||||
return serverError.value?.field === 'next' ? serverError.value.message : null
|
||||
})
|
||||
const confirmError = computed(() => (problems.value.confirm === 'mismatch' ? PASSWORD_TEXT.mismatch : null))
|
||||
|
||||
// 칸을 고치면 그 칸에 걸린 오류와 "바꿨습니다"를 내린다 (지금 비밀번호를 고치면 "지금과 같음"도 다시 본다)
|
||||
function setCurrent(value: string) {
|
||||
current.value = value
|
||||
problems.value = { ...problems.value, current: null, next: problems.value.next === 'rule' ? null : problems.value.next }
|
||||
if (serverError.value?.field === 'current') serverError.value = null
|
||||
done.value = false
|
||||
}
|
||||
|
||||
function setNext(value: string) {
|
||||
next.value = value
|
||||
problems.value = { ...problems.value, next: null, confirm: null }
|
||||
if (serverError.value?.field === 'next') serverError.value = null
|
||||
done.value = false
|
||||
}
|
||||
|
||||
function setConfirm(value: string) {
|
||||
confirm.value = value
|
||||
problems.value = { ...problems.value, confirm: null }
|
||||
done.value = false
|
||||
}
|
||||
|
||||
function focusFirstProblem(found: PasswordChangeProblems) {
|
||||
if (found.current) currentRef.value?.focus()
|
||||
else if (found.next) nextRef.value?.focus()
|
||||
else if (found.confirm) confirmRef.value?.focus()
|
||||
}
|
||||
|
||||
function clearFields() {
|
||||
current.value = ''
|
||||
next.value = ''
|
||||
confirm.value = ''
|
||||
showCurrent.value = false
|
||||
showNext.value = false
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
if (submitting.value) return
|
||||
done.value = false
|
||||
serverError.value = null
|
||||
const found = passwordChangeProblems({ current: current.value, next: next.value, confirm: confirm.value })
|
||||
problems.value = found
|
||||
if (hasPasswordChangeProblem(found)) {
|
||||
focusFirstProblem(found)
|
||||
return
|
||||
}
|
||||
submitting.value = true
|
||||
try {
|
||||
await changePassword({ currentPassword: current.value, newPassword: next.value }, { silent: INLINE_CODES })
|
||||
clearFields()
|
||||
problems.value = NO_PROBLEMS
|
||||
done.value = true
|
||||
} catch (error) {
|
||||
const apiError = toApiError(error)
|
||||
if (unmounted) {
|
||||
if (INLINE_CODES.includes(apiError.code)) notifyApiError(apiError)
|
||||
return
|
||||
}
|
||||
if (apiError.code === 'AUTH_005') {
|
||||
serverError.value = { field: 'current', message: apiError.message }
|
||||
currentRef.value?.focus()
|
||||
} else if (apiError.code === 'VAL_001') {
|
||||
serverError.value = { field: 'next', message: apiError.message }
|
||||
nextRef.value?.focus()
|
||||
}
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
unmounted = true
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<form
|
||||
class="password-form"
|
||||
novalidate
|
||||
@submit.prevent="submit"
|
||||
>
|
||||
<!-- 비밀번호 관리자가 어느 계정의 비밀번호인지 알게 한다 (보이지 않는다) -->
|
||||
<input
|
||||
type="text"
|
||||
name="username"
|
||||
autocomplete="username"
|
||||
:value="authStore.user?.email ?? ''"
|
||||
hidden
|
||||
readonly
|
||||
>
|
||||
<PasswordField
|
||||
id="password-current"
|
||||
ref="currentRef"
|
||||
:label="PASSWORD_TEXT.current"
|
||||
:model-value="current"
|
||||
autocomplete="current-password"
|
||||
:shown="showCurrent"
|
||||
:toggle-label="PASSWORD_TEXT.showCurrent"
|
||||
:error="currentError"
|
||||
@update:model-value="setCurrent"
|
||||
@toggle="showCurrent = !showCurrent"
|
||||
/>
|
||||
<PasswordField
|
||||
id="password-next"
|
||||
ref="nextRef"
|
||||
:label="PASSWORD_TEXT.next"
|
||||
:model-value="next"
|
||||
autocomplete="new-password"
|
||||
:shown="showNext"
|
||||
:toggle-label="PASSWORD_TEXT.showNext"
|
||||
:hint="PASSWORD_TEXT.hint"
|
||||
:hint-is-error="problems.next === 'rule'"
|
||||
:error="nextError"
|
||||
@update:model-value="setNext"
|
||||
@toggle="showNext = !showNext"
|
||||
/>
|
||||
<PasswordField
|
||||
id="password-confirm"
|
||||
ref="confirmRef"
|
||||
:label="PASSWORD_TEXT.confirm"
|
||||
:model-value="confirm"
|
||||
autocomplete="new-password"
|
||||
:shown="showNext"
|
||||
:error="confirmError"
|
||||
@update:model-value="setConfirm"
|
||||
/>
|
||||
<div class="password-form__actions">
|
||||
<button
|
||||
id="password-submit"
|
||||
type="submit"
|
||||
class="password-form__submit"
|
||||
:disabled="submitting"
|
||||
:aria-busy="submitting"
|
||||
>
|
||||
{{ PASSWORD_TEXT.submit }}
|
||||
</button>
|
||||
<span
|
||||
id="password-status"
|
||||
class="password-form__status"
|
||||
role="status"
|
||||
>
|
||||
<template v-if="done">
|
||||
<AppIcon
|
||||
name="check"
|
||||
:size="15"
|
||||
:stroke-width="2.2"
|
||||
/>
|
||||
<span>{{ PASSWORD_TEXT.done }}</span>
|
||||
</template>
|
||||
</span>
|
||||
</div>
|
||||
</form>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.password-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-18);
|
||||
max-width: var(--form-max-width);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.password-form__actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: var(--space-12);
|
||||
}
|
||||
|
||||
.password-form__submit {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-height: var(--size-touch);
|
||||
padding: 0 var(--space-22);
|
||||
border: 0;
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--color-brand);
|
||||
color: var(--color-on-brand);
|
||||
font-size: var(--font-size-emphasis);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.password-form__submit:hover:not(:disabled) {
|
||||
background: var(--color-brand-strong);
|
||||
}
|
||||
|
||||
.password-form__submit:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.password-form__status {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-6);
|
||||
color: var(--color-success-text);
|
||||
font-weight: 500;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,158 @@
|
||||
<script setup lang="ts">
|
||||
// 비밀번호 칸 하나 — 이름 · 칸(44) · [보기/숨기기](44 · 눈 아이콘 · aria-pressed) · 안내 · 오류 (디자인 원본 SettingsAccount)
|
||||
// - 오류는 칸 아래 빨간 글(alert-circle) · 칸 테두리도 빨강. hintIsError 면 안내 글 자체가 오류가 된다 (새 비밀번호의 규칙)
|
||||
// - 보기 단추의 이름은 바뀌지 않는다 — 켜짐은 aria-pressed 로 알린다 (아이콘만 눈 ↔ 빗금 눈)
|
||||
// - 칸의 설명(aria-describedby)은 안내 → 오류 순서
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import AppIcon from '@/components/AppIcon.vue'
|
||||
|
||||
interface Props {
|
||||
id: string
|
||||
label: string
|
||||
modelValue: string
|
||||
autocomplete: 'current-password' | 'new-password'
|
||||
/** 글자를 보이는 중 */
|
||||
shown: boolean
|
||||
/** 보기 단추의 이름 — 없으면 단추를 두지 않는다 (확인 칸은 새 비밀번호의 단추를 따른다) */
|
||||
toggleLabel?: string
|
||||
hint?: string
|
||||
/** 안내 글이 그대로 오류다 */
|
||||
hintIsError?: boolean
|
||||
/** 오류 문구 — 사전 문구 또는 디자인 문구 */
|
||||
error?: string | null
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'update:modelValue', value: string): void
|
||||
(e: 'toggle'): void
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
toggleLabel: '',
|
||||
hint: '',
|
||||
hintIsError: false,
|
||||
error: null,
|
||||
})
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
const inputRef = ref<HTMLInputElement | null>(null)
|
||||
|
||||
const hintId = computed(() => `${props.id}-hint`)
|
||||
const errorId = computed(() => `${props.id}-error`)
|
||||
const invalid = computed(() => Boolean(props.error) || props.hintIsError)
|
||||
const describedBy = computed(() => {
|
||||
const ids = [props.hint ? hintId.value : '', props.error ? errorId.value : ''].filter(Boolean)
|
||||
return ids.length > 0 ? ids.join(' ') : undefined
|
||||
})
|
||||
|
||||
function onInput(event: Event) {
|
||||
emit('update:modelValue', (event.target as HTMLInputElement).value)
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
focus: () => inputRef.value?.focus(),
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="password-field">
|
||||
<label
|
||||
:for="id"
|
||||
class="password-field__label"
|
||||
>{{ label }}</label>
|
||||
<div :class="['form-input', 'password-field__box', { 'is-invalid': invalid }]">
|
||||
<input
|
||||
:id="id"
|
||||
ref="inputRef"
|
||||
class="form-input__control"
|
||||
:type="shown ? 'text' : 'password'"
|
||||
:autocomplete="autocomplete"
|
||||
:value="modelValue"
|
||||
spellcheck="false"
|
||||
autocapitalize="off"
|
||||
:aria-invalid="invalid ? 'true' : 'false'"
|
||||
:aria-describedby="describedBy"
|
||||
@input="onInput"
|
||||
>
|
||||
<button
|
||||
v-if="toggleLabel"
|
||||
type="button"
|
||||
class="password-field__toggle"
|
||||
:aria-label="toggleLabel"
|
||||
:title="toggleLabel"
|
||||
:aria-pressed="shown"
|
||||
:aria-controls="id"
|
||||
@click="emit('toggle')"
|
||||
>
|
||||
<AppIcon :name="shown ? 'eye-off' : 'eye'" />
|
||||
</button>
|
||||
</div>
|
||||
<p
|
||||
v-if="hint"
|
||||
:id="hintId"
|
||||
:class="['form-message', { 'form-message--error': hintIsError }]"
|
||||
>
|
||||
<AppIcon
|
||||
v-if="hintIsError"
|
||||
name="alert-circle"
|
||||
:size="14"
|
||||
:stroke-width="2"
|
||||
/>
|
||||
<span>{{ hint }}</span>
|
||||
</p>
|
||||
<p
|
||||
v-if="error"
|
||||
:id="errorId"
|
||||
class="form-message form-message--error"
|
||||
>
|
||||
<AppIcon
|
||||
name="alert-circle"
|
||||
:size="14"
|
||||
:stroke-width="2"
|
||||
/>
|
||||
<span>{{ error }}</span>
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.password-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-6);
|
||||
}
|
||||
|
||||
.password-field__label {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* 상자 안에 칸과 단추 — 상자가 테두리 · 초점을 그린다 (base.css .form-input). 칸 · 단추가 상자 높이(44)를 채운다 */
|
||||
.password-field__box {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
/* 44 */
|
||||
.password-field__toggle {
|
||||
flex: 0 0 auto;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: var(--size-touch);
|
||||
border: 0;
|
||||
border-radius: 0 var(--radius-md) var(--radius-md) 0;
|
||||
background: transparent;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.password-field__toggle:hover {
|
||||
background: var(--color-surface-muted);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.password-field__toggle[aria-pressed='true'] {
|
||||
color: var(--color-brand-strong);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,77 @@
|
||||
<script setup lang="ts">
|
||||
// 설정 화면의 틀 — 제목(18px) · 설명 + 내용 (디자인 원본 SettingsAccount · SettingsVacation)
|
||||
// 콘텐츠 열은 읽기 · 쓰기 화면과 같은 값이다 — 최대 800 · 좌우 32 · 위 20 · 아래 36 (tokens.css --content-*), 묶음 사이 22.
|
||||
// 사용 예: <SettingsPageFrame title-id="password-page-title" :title="…" :description="…">…</SettingsPageFrame>
|
||||
interface Props {
|
||||
/** 제목(h1)의 id — main 이 aria-labelledby 로 가리킨다 */
|
||||
titleId: string
|
||||
title: string
|
||||
description: string
|
||||
}
|
||||
|
||||
defineProps<Props>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main
|
||||
class="settings-page"
|
||||
:aria-labelledby="titleId"
|
||||
>
|
||||
<div class="settings-page__column">
|
||||
<div class="settings-page__intro">
|
||||
<h1
|
||||
:id="titleId"
|
||||
class="settings-page__title"
|
||||
>
|
||||
{{ title }}
|
||||
</h1>
|
||||
<p class="settings-page__description">
|
||||
{{ description }}
|
||||
</p>
|
||||
</div>
|
||||
<slot />
|
||||
</div>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.settings-page {
|
||||
flex: 999 1 35rem;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.settings-page__column {
|
||||
max-width: var(--content-max-width);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-22);
|
||||
padding: var(--content-block-start) var(--content-inline) var(--content-block-end);
|
||||
}
|
||||
|
||||
.settings-page__intro {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-4);
|
||||
}
|
||||
|
||||
.settings-page__title {
|
||||
margin: 0;
|
||||
font-size: var(--font-size-title-lg);
|
||||
font-weight: 700;
|
||||
letter-spacing: var(--letter-spacing-tight);
|
||||
}
|
||||
|
||||
.settings-page__description {
|
||||
margin: 0;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
/* 넓은 화면에서는 이 칸만 따로 스크롤한다 */
|
||||
@media (min-width: 70rem) {
|
||||
.settings-page {
|
||||
overflow-y: auto;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,296 @@
|
||||
<script setup lang="ts">
|
||||
// 부재중 자동 응답 설정 (디자인 원본 SettingsVacation · 계약 2절 "부재중 자동 응답")
|
||||
// 위에서부터 [x] 사용(44) → 기간(VacationPeriodFields) → 제목 · 내용(VacationMessageFields) → [저장] + 상태 글 → 미리 보기(VacationPreview).
|
||||
// - 끄면 아래 칸을 흐리게(쓴 글은 남긴다). 켤 때만 제목 · 내용이 필요하다 — 꺼 두면 빈 칸도 저장된다
|
||||
// - 저장하기 전에 막는 것(켜 둘 때): 기간(날짜가 없음 · 시작이 끝보다 늦음) · 빈 제목 · 빈 내용 — 그 칸 아래에 사전 문구(VAL_001), 첫 칸으로 초점.
|
||||
// 서버의 거절(VAL_001)은 저장 단추 옆에 사전 문구로 (알림은 끈다) — 저장하는 사이 화면을 떠났으면 알림으로 (notifyApiError)
|
||||
// - 저장하면 "저장했습니다. 10월 30일(금)부터 11월 3일(화)까지 자동으로 답합니다." 등 — 무엇이든 다시 바꾸면 내린다
|
||||
// - 나가기 확인: 불러온(저장한 뒤에는 저장한) 값과 다르면 앱 안 이동은 대화상자, 탭 닫기 · 새로고침은 브라우저 확인창 (서명 설정과 같다).
|
||||
// 저장하는 중 · 세션이 끝났을 때는 앱 안 이동을 묻지 않는다
|
||||
// 고정 id — #vacation-enabled · #vacation-save · #vacation-save-status ·
|
||||
// 나가기 확인 #vacation-leave-dialog · #vacation-leave-stay · #vacation-leave-confirm (칸의 id 는 각 칸 컴포넌트에)
|
||||
import { computed, onBeforeUnmount, reactive, ref, shallowRef } from 'vue'
|
||||
|
||||
import AppIcon from '@/components/AppIcon.vue'
|
||||
import LeaveConfirmDialog from '@/components/feedback/LeaveConfirmDialog.vue'
|
||||
import VacationMessageFields from '@/components/settings/VacationMessageFields.vue'
|
||||
import VacationPeriodFields from '@/components/settings/VacationPeriodFields.vue'
|
||||
import VacationPreview from '@/components/settings/VacationPreview.vue'
|
||||
import { getErrorMessage, notifyApiError, toApiError } from '@/composables/useApi'
|
||||
import { useLeaveConfirm } from '@/composables/useLeaveConfirm'
|
||||
import { VACATION_LEAVE_DIALOG, VACATION_TEXT } from '@/shared/constants/mail-vacation'
|
||||
import type { MailVacation } from '@/shared/types/mail'
|
||||
import { formatDate } from '@/shared/utils/format'
|
||||
import {
|
||||
sameVacationForm,
|
||||
vacationFormOf,
|
||||
vacationPayloadOf,
|
||||
vacationProblems,
|
||||
vacationSavedText,
|
||||
type VacationForm,
|
||||
type VacationPeriod,
|
||||
type VacationProblem,
|
||||
} from '@/shared/utils/mail-vacation'
|
||||
import { useAuthStore } from '@/stores/auth.store'
|
||||
import { useMailVacationStore } from '@/stores/mail-vacation.store'
|
||||
|
||||
interface Props {
|
||||
/** 서버의 설정 — 이 값으로 칸을 채운다 */
|
||||
vacation: MailVacation
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
|
||||
const authStore = useAuthStore()
|
||||
const vacationStore = useMailVacationStore()
|
||||
|
||||
const periodRef = ref<InstanceType<typeof VacationPeriodFields> | null>(null)
|
||||
const messageRef = ref<InstanceType<typeof VacationMessageFields> | null>(null)
|
||||
|
||||
const form = reactive<VacationForm>(vacationFormOf(props.vacation))
|
||||
/** 불러온(저장한 뒤에는 저장한) 값 — 이것과 다르면 바꾼 것이다 */
|
||||
const saved = shallowRef<VacationForm>(vacationFormOf(props.vacation))
|
||||
const saveState = ref<'idle' | 'saving' | 'saved'>('idle')
|
||||
const savedText = ref<string>('')
|
||||
const problems = ref<VacationProblem[]>([])
|
||||
/** 서버가 거절한 까닭 (사전 문구) */
|
||||
const saveError = ref<string | null>(null)
|
||||
/** 화면이 사라졌다 — 저장 단추 옆 글을 볼 곳이 없다 */
|
||||
let unmounted = false
|
||||
|
||||
const changed = computed(() => !sameVacationForm(form, saved.value))
|
||||
const problemText = getErrorMessage('VAL_001')
|
||||
|
||||
/** 그 칸에 걸린 문제의 문구 — 없으면 null */
|
||||
function problemOf(problem: VacationProblem): string | null {
|
||||
return problems.value.includes(problem) ? problemText : null
|
||||
}
|
||||
|
||||
const { confirming: leaveConfirming, stay: stayOnForm, leave: leaveForm } = useLeaveConfirm({
|
||||
active: () => changed.value && saveState.value !== 'saving' && authStore.isLoggedIn,
|
||||
unloadActive: () => changed.value && authStore.isLoggedIn,
|
||||
})
|
||||
|
||||
/** 칸을 고친다 — 그 칸의 문제와 "저장했습니다" · 서버 거절을 내린다 */
|
||||
function edit(patch: Partial<VacationForm>, clears: readonly VacationProblem[]) {
|
||||
Object.assign(form, patch)
|
||||
problems.value = problems.value.filter((problem) => !clears.includes(problem))
|
||||
saveError.value = null
|
||||
if (saveState.value === 'saved') saveState.value = 'idle'
|
||||
}
|
||||
|
||||
/** 켜고 끄기 — 끄면 칸이 흐려지므로 걸려 있던 문제도 내린다 */
|
||||
function setEnabled(event: Event) {
|
||||
edit({ isEnabled: (event.target as HTMLInputElement).checked }, ['period', 'subject', 'textBody'])
|
||||
}
|
||||
|
||||
/** 기간 정하기로 바꾸는데 날짜가 비었으면 시작을 오늘로 채운다 (끝은 사용자가 고른다) */
|
||||
function setPeriod(period: VacationPeriod) {
|
||||
const prefill = period === 'range' && !form.fromDay && !form.toDay ? { fromDay: formatDate(new Date()) } : {}
|
||||
edit({ period, ...prefill }, ['period'])
|
||||
}
|
||||
|
||||
function focusFirst(found: readonly VacationProblem[]) {
|
||||
if (found.includes('period')) periodRef.value?.focus()
|
||||
else if (found.includes('subject')) messageRef.value?.focusSubject()
|
||||
else if (found.includes('textBody')) messageRef.value?.focusBody()
|
||||
}
|
||||
|
||||
async function save() {
|
||||
if (saveState.value === 'saving') return
|
||||
saveError.value = null
|
||||
const found = vacationProblems(form)
|
||||
problems.value = found
|
||||
if (found.length > 0) {
|
||||
focusFirst(found)
|
||||
return
|
||||
}
|
||||
const sent: VacationForm = { ...form }
|
||||
saveState.value = 'saving'
|
||||
try {
|
||||
const result = await vacationStore.save(vacationPayloadOf(sent), { silent: ['VAL_001'] })
|
||||
const savedForm = vacationFormOf(result)
|
||||
// 저장하는 동안 더 고치지 않았으면 서버가 저장한 값으로 맞춘다 (흐린 칸의 맞지 않는 기간은 비워 저장했다)
|
||||
if (sameVacationForm(form, sent)) Object.assign(form, savedForm)
|
||||
saved.value = savedForm
|
||||
savedText.value = vacationSavedText(result)
|
||||
saveState.value = 'saved'
|
||||
} catch (error) {
|
||||
saveState.value = 'idle'
|
||||
const apiError = toApiError(error)
|
||||
if (apiError.code !== 'VAL_001') return
|
||||
// 알림을 끈 거절 — 화면을 떠났으면 알림으로 알린다 (보내는 사이 떠나도 묻지 않는다)
|
||||
if (unmounted) notifyApiError(apiError)
|
||||
else saveError.value = apiError.message
|
||||
}
|
||||
}
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
unmounted = true
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="vacation-form">
|
||||
<label class="vacation-form__toggle">
|
||||
<input
|
||||
id="vacation-enabled"
|
||||
type="checkbox"
|
||||
class="vacation-form__checkbox"
|
||||
:checked="form.isEnabled"
|
||||
@change="setEnabled"
|
||||
>
|
||||
<span>{{ VACATION_TEXT.enable }}</span>
|
||||
</label>
|
||||
|
||||
<VacationPeriodFields
|
||||
ref="periodRef"
|
||||
:period="form.period"
|
||||
:from-day="form.fromDay"
|
||||
:to-day="form.toDay"
|
||||
:disabled="!form.isEnabled"
|
||||
:error="problemOf('period')"
|
||||
@update:period="setPeriod"
|
||||
@update:from-day="edit({ fromDay: $event }, ['period'])"
|
||||
@update:to-day="edit({ toDay: $event }, ['period'])"
|
||||
/>
|
||||
|
||||
<VacationMessageFields
|
||||
ref="messageRef"
|
||||
:subject="form.subject"
|
||||
:text-body="form.textBody"
|
||||
:disabled="!form.isEnabled"
|
||||
:subject-error="problemOf('subject')"
|
||||
:body-error="problemOf('textBody')"
|
||||
@update:subject="edit({ subject: $event }, ['subject'])"
|
||||
@update:text-body="edit({ textBody: $event }, ['textBody'])"
|
||||
/>
|
||||
|
||||
<div class="vacation-form__actions">
|
||||
<button
|
||||
id="vacation-save"
|
||||
type="button"
|
||||
class="vacation-form__save"
|
||||
:disabled="saveState === 'saving'"
|
||||
:aria-busy="saveState === 'saving'"
|
||||
@click="save"
|
||||
>
|
||||
{{ saveState === 'saving' ? VACATION_TEXT.saving : VACATION_TEXT.save }}
|
||||
</button>
|
||||
<span
|
||||
id="vacation-save-status"
|
||||
:class="['vacation-form__status', { 'is-error': saveError }]"
|
||||
role="status"
|
||||
>
|
||||
<template v-if="saveError">
|
||||
<AppIcon
|
||||
name="alert-circle"
|
||||
:size="15"
|
||||
/>
|
||||
<span>{{ saveError }}</span>
|
||||
</template>
|
||||
<template v-else-if="saveState === 'saved'">
|
||||
<AppIcon
|
||||
name="check"
|
||||
:size="15"
|
||||
:stroke-width="2.2"
|
||||
/>
|
||||
<span>{{ savedText }}</span>
|
||||
</template>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<hr class="vacation-form__rule">
|
||||
|
||||
<VacationPreview
|
||||
:enabled="form.isEnabled"
|
||||
:sender-name="authStore.user?.name ?? ''"
|
||||
:sender-email="authStore.user?.email ?? ''"
|
||||
:subject="form.subject"
|
||||
:text-body="form.textBody"
|
||||
/>
|
||||
|
||||
<!-- 저장하지 않은 채 떠나려 할 때 — 답할 때까지 이동이 기다린다 (useLeaveConfirm) -->
|
||||
<LeaveConfirmDialog
|
||||
:open="leaveConfirming"
|
||||
id-prefix="vacation-leave"
|
||||
:copy="VACATION_LEAVE_DIALOG"
|
||||
@stay="stayOnForm"
|
||||
@leave="leaveForm"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.vacation-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-22);
|
||||
}
|
||||
|
||||
.vacation-form__toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-10);
|
||||
min-height: var(--size-touch);
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.vacation-form__checkbox {
|
||||
width: var(--size-option-input);
|
||||
height: var(--size-option-input);
|
||||
margin: 0;
|
||||
accent-color: var(--color-brand);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.vacation-form__actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: var(--space-12);
|
||||
}
|
||||
|
||||
.vacation-form__save {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-height: var(--size-touch);
|
||||
padding: 0 var(--space-22);
|
||||
border: 0;
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--color-brand);
|
||||
color: var(--color-on-brand);
|
||||
font-size: var(--font-size-emphasis);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.vacation-form__save:hover:not(:disabled) {
|
||||
background: var(--color-brand-strong);
|
||||
}
|
||||
|
||||
.vacation-form__save:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.vacation-form__status {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-6);
|
||||
color: var(--color-success-text);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.vacation-form__status.is-error {
|
||||
color: var(--color-danger);
|
||||
}
|
||||
|
||||
.vacation-form__rule {
|
||||
width: 100%;
|
||||
height: var(--border-width);
|
||||
margin: 0;
|
||||
border: 0;
|
||||
background: var(--color-border-subtle);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,143 @@
|
||||
<script setup lang="ts">
|
||||
// 부재중 자동 응답의 제목(44 · 최대 폭 520 · 200자) · 내용(여러 줄 · 4,000자 · 글자만) (디자인 원본 SettingsVacation)
|
||||
// 끈 동안은 흐리게(쓴 글은 남긴다). 문제가 있으면 그 칸 아래에 사전 문구 — 검사는 부모(VacationForm)가 한다.
|
||||
// 고정 id — #vacation-subject · #vacation-subject-error · #vacation-body · #vacation-body-hint · #vacation-body-error
|
||||
import { ref } from 'vue'
|
||||
|
||||
import AppIcon from '@/components/AppIcon.vue'
|
||||
import { VACATION_BODY_MAX_LENGTH, VACATION_SUBJECT_MAX_LENGTH, VACATION_TEXT } from '@/shared/constants/mail-vacation'
|
||||
|
||||
interface Props {
|
||||
subject: string
|
||||
textBody: string
|
||||
disabled: boolean
|
||||
/** 칸마다의 문제 문구 — 없으면 null */
|
||||
subjectError?: string | null
|
||||
bodyError?: string | null
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'update:subject', value: string): void
|
||||
(e: 'update:textBody', value: string): void
|
||||
}
|
||||
|
||||
withDefaults(defineProps<Props>(), {
|
||||
subjectError: null,
|
||||
bodyError: null,
|
||||
})
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
const subjectRef = ref<HTMLInputElement | null>(null)
|
||||
const bodyRef = ref<HTMLTextAreaElement | null>(null)
|
||||
|
||||
function valueOf(event: Event): string {
|
||||
return (event.target as HTMLInputElement | HTMLTextAreaElement).value
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
focusSubject: () => subjectRef.value?.focus(),
|
||||
focusBody: () => bodyRef.value?.focus(),
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="vacation-message">
|
||||
<div class="vacation-message__field">
|
||||
<label
|
||||
for="vacation-subject"
|
||||
class="vacation-message__label"
|
||||
>{{ VACATION_TEXT.subject }}</label>
|
||||
<input
|
||||
id="vacation-subject"
|
||||
ref="subjectRef"
|
||||
class="form-input vacation-message__subject"
|
||||
type="text"
|
||||
:value="subject"
|
||||
:maxlength="VACATION_SUBJECT_MAX_LENGTH"
|
||||
:disabled="disabled"
|
||||
:aria-invalid="subjectError ? 'true' : 'false'"
|
||||
:aria-describedby="subjectError ? 'vacation-subject-error' : undefined"
|
||||
@input="emit('update:subject', valueOf($event))"
|
||||
>
|
||||
<p
|
||||
v-if="subjectError"
|
||||
id="vacation-subject-error"
|
||||
class="form-message form-message--error"
|
||||
>
|
||||
<AppIcon
|
||||
name="alert-circle"
|
||||
:size="14"
|
||||
:stroke-width="2"
|
||||
/>
|
||||
<span>{{ subjectError }}</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="vacation-message__field">
|
||||
<label
|
||||
for="vacation-body"
|
||||
class="vacation-message__label"
|
||||
>{{ VACATION_TEXT.body }}</label>
|
||||
<textarea
|
||||
id="vacation-body"
|
||||
ref="bodyRef"
|
||||
class="form-input vacation-message__body"
|
||||
rows="6"
|
||||
:value="textBody"
|
||||
:maxlength="VACATION_BODY_MAX_LENGTH"
|
||||
:disabled="disabled"
|
||||
:aria-invalid="bodyError ? 'true' : 'false'"
|
||||
:aria-describedby="bodyError ? 'vacation-body-hint vacation-body-error' : 'vacation-body-hint'"
|
||||
@input="emit('update:textBody', valueOf($event))"
|
||||
/>
|
||||
<p
|
||||
v-if="bodyError"
|
||||
id="vacation-body-error"
|
||||
class="form-message form-message--error"
|
||||
>
|
||||
<AppIcon
|
||||
name="alert-circle"
|
||||
:size="14"
|
||||
:stroke-width="2"
|
||||
/>
|
||||
<span>{{ bodyError }}</span>
|
||||
</p>
|
||||
<p
|
||||
id="vacation-body-hint"
|
||||
class="form-message"
|
||||
>
|
||||
{{ VACATION_TEXT.bodyHint }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.vacation-message {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-22);
|
||||
}
|
||||
|
||||
.vacation-message__field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-6);
|
||||
}
|
||||
|
||||
.vacation-message__label {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.vacation-message__subject {
|
||||
width: 100%;
|
||||
max-width: var(--form-wide-max-width);
|
||||
}
|
||||
|
||||
.vacation-message__body {
|
||||
width: 100%;
|
||||
padding: var(--space-10) var(--space-12);
|
||||
line-height: var(--line-height-body);
|
||||
resize: vertical;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,196 @@
|
||||
<script setup lang="ts">
|
||||
// 부재중 자동 응답의 기간 (디자인 원본 SettingsVacation) — (•) 끌 때까지 계속 / ( ) 기간 정하기 → 시작 ~ 끝 날짜 칸(44)
|
||||
// "시작일 0시부터 끝나는 날 자정까지 답합니다 (이 브라우저의 시간대 기준)." 끈 동안은 묶음째 흐리게(쓴 값은 남긴다).
|
||||
// 날짜는 <input type="date"> 값(YYYY-MM-DD) 그대로 주고받는다 — 시각으로 바꾸는 일은 mail-vacation.ts
|
||||
// 고정 id — #vacation-period-until-off · #vacation-period-range · #vacation-from · #vacation-to · #vacation-period-error
|
||||
import { ref } from 'vue'
|
||||
|
||||
import AppIcon from '@/components/AppIcon.vue'
|
||||
import { VACATION_TEXT } from '@/shared/constants/mail-vacation'
|
||||
import type { VacationPeriod } from '@/shared/utils/mail-vacation'
|
||||
|
||||
interface Props {
|
||||
period: VacationPeriod
|
||||
fromDay: string
|
||||
toDay: string
|
||||
disabled: boolean
|
||||
/** 기간이 맞지 않는다 — 사전 문구 */
|
||||
error?: string | null
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'update:period', period: VacationPeriod): void
|
||||
(e: 'update:fromDay', day: string): void
|
||||
(e: 'update:toDay', day: string): void
|
||||
}
|
||||
|
||||
withDefaults(defineProps<Props>(), {
|
||||
error: null,
|
||||
})
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
const fromRef = ref<HTMLInputElement | null>(null)
|
||||
|
||||
function valueOf(event: Event): string {
|
||||
return (event.target as HTMLInputElement).value
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
/** 기간이 맞지 않을 때 — 시작 날짜 칸으로 */
|
||||
focus: () => fromRef.value?.focus(),
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<fieldset
|
||||
class="vacation-period"
|
||||
:disabled="disabled"
|
||||
>
|
||||
<legend class="vacation-period__legend">
|
||||
{{ VACATION_TEXT.periodLegend }}
|
||||
</legend>
|
||||
<label class="vacation-period__option">
|
||||
<input
|
||||
id="vacation-period-until-off"
|
||||
type="radio"
|
||||
name="vacation-period"
|
||||
class="vacation-period__radio"
|
||||
:checked="period === 'untilOff'"
|
||||
@change="emit('update:period', 'untilOff')"
|
||||
>
|
||||
<span>{{ VACATION_TEXT.untilOff }}</span>
|
||||
</label>
|
||||
<label class="vacation-period__option">
|
||||
<input
|
||||
id="vacation-period-range"
|
||||
type="radio"
|
||||
name="vacation-period"
|
||||
class="vacation-period__radio"
|
||||
:checked="period === 'range'"
|
||||
@change="emit('update:period', 'range')"
|
||||
>
|
||||
<span>{{ VACATION_TEXT.range }}</span>
|
||||
</label>
|
||||
<template v-if="period === 'range'">
|
||||
<div class="vacation-period__dates">
|
||||
<label
|
||||
for="vacation-from"
|
||||
class="vacation-period__date-label"
|
||||
>{{ VACATION_TEXT.from }}</label>
|
||||
<input
|
||||
id="vacation-from"
|
||||
ref="fromRef"
|
||||
class="form-input vacation-period__date"
|
||||
type="date"
|
||||
:value="fromDay"
|
||||
:max="toDay || undefined"
|
||||
:aria-invalid="error ? 'true' : 'false'"
|
||||
:aria-describedby="error ? 'vacation-period-hint vacation-period-error' : 'vacation-period-hint'"
|
||||
@input="emit('update:fromDay', valueOf($event))"
|
||||
>
|
||||
<span
|
||||
class="vacation-period__tilde"
|
||||
aria-hidden="true"
|
||||
>~</span>
|
||||
<label
|
||||
for="vacation-to"
|
||||
class="vacation-period__date-label"
|
||||
>{{ VACATION_TEXT.to }}</label>
|
||||
<input
|
||||
id="vacation-to"
|
||||
class="form-input vacation-period__date"
|
||||
type="date"
|
||||
:value="toDay"
|
||||
:min="fromDay || undefined"
|
||||
:aria-invalid="error ? 'true' : 'false'"
|
||||
:aria-describedby="error ? 'vacation-period-hint vacation-period-error' : 'vacation-period-hint'"
|
||||
@input="emit('update:toDay', valueOf($event))"
|
||||
>
|
||||
</div>
|
||||
<p
|
||||
id="vacation-period-hint"
|
||||
class="form-message vacation-period__note"
|
||||
>
|
||||
{{ VACATION_TEXT.rangeHint }}
|
||||
</p>
|
||||
<p
|
||||
v-if="error"
|
||||
id="vacation-period-error"
|
||||
class="form-message form-message--error vacation-period__note"
|
||||
>
|
||||
<AppIcon
|
||||
name="alert-circle"
|
||||
:size="14"
|
||||
:stroke-width="2"
|
||||
/>
|
||||
<span>{{ error }}</span>
|
||||
</p>
|
||||
</template>
|
||||
</fieldset>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.vacation-period {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.vacation-period:disabled {
|
||||
color: var(--color-icon-muted);
|
||||
}
|
||||
|
||||
.vacation-period__legend {
|
||||
margin: 0 0 var(--space-4);
|
||||
padding: 0;
|
||||
font-weight: 600;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.vacation-period__option {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-10);
|
||||
min-height: var(--size-touch);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.vacation-period:disabled .vacation-period__option {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.vacation-period__radio {
|
||||
width: var(--size-option-input);
|
||||
height: var(--size-option-input);
|
||||
margin: 0;
|
||||
accent-color: var(--color-brand);
|
||||
}
|
||||
|
||||
/* 날짜 줄 — 라디오 글자 선(28)에 맞춰 들여 쓴다 */
|
||||
.vacation-period__dates {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: var(--space-8) var(--space-10);
|
||||
padding: var(--space-4) 0 0 var(--space-28);
|
||||
}
|
||||
|
||||
.vacation-period__date-label {
|
||||
font-size: var(--font-size-label);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.vacation-period__date {
|
||||
padding: 0 var(--space-10);
|
||||
}
|
||||
|
||||
.vacation-period__tilde {
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.vacation-period__note {
|
||||
margin: var(--space-6) 0 0 var(--space-28);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,128 @@
|
||||
<script setup lang="ts">
|
||||
// 부재중 자동 응답의 미리 보기 — "보낸 사람이 받는 답장" (디자인 원본 SettingsVacation)
|
||||
// 보낸 사람(나) · 제목 · 내용(글자 그대로 — 메일 본문 글꼴 14px · 줄 간격 1.75) + 아래 한 줄
|
||||
// (켜짐: "같은 사람에게 매번 보내지는 않습니다(메일 엔진 규칙)." · 꺼짐: "꺼져 있습니다 — 자동으로 답하지 않습니다.")
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { MAIL_NO_SUBJECT } from '@/shared/constants/mail'
|
||||
import { VACATION_TEXT } from '@/shared/constants/mail-vacation'
|
||||
|
||||
interface Props {
|
||||
enabled: boolean
|
||||
senderName: string
|
||||
senderEmail: string
|
||||
subject: string
|
||||
textBody: string
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
|
||||
const sender = computed(() =>
|
||||
props.senderName ? `${props.senderName} <${props.senderEmail}>` : props.senderEmail,
|
||||
)
|
||||
const shownSubject = computed(() => props.subject.trim() || MAIL_NO_SUBJECT)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section
|
||||
class="vacation-preview"
|
||||
aria-labelledby="vacation-preview-title"
|
||||
data-vacation-preview
|
||||
>
|
||||
<h2
|
||||
id="vacation-preview-title"
|
||||
class="vacation-preview__title"
|
||||
>
|
||||
{{ VACATION_TEXT.previewTitle }}
|
||||
</h2>
|
||||
<div class="vacation-preview__card">
|
||||
<dl class="vacation-preview__meta">
|
||||
<dt class="vacation-preview__label">
|
||||
{{ VACATION_TEXT.previewFrom }}
|
||||
</dt>
|
||||
<dd class="vacation-preview__value">
|
||||
{{ sender }}
|
||||
</dd>
|
||||
<dt class="vacation-preview__label">
|
||||
{{ VACATION_TEXT.previewSubject }}
|
||||
</dt>
|
||||
<dd
|
||||
class="vacation-preview__value"
|
||||
data-vacation-preview-subject
|
||||
>
|
||||
{{ shownSubject }}
|
||||
</dd>
|
||||
</dl>
|
||||
<!-- 글자 그대로 — 템플릿의 들여쓰기 공백이 끼지 않게 v-text 로 넣는다 -->
|
||||
<div
|
||||
class="vacation-preview__body"
|
||||
data-vacation-preview-body
|
||||
v-text="textBody"
|
||||
/>
|
||||
</div>
|
||||
<p
|
||||
class="vacation-preview__note"
|
||||
data-vacation-preview-note
|
||||
>
|
||||
{{ enabled ? VACATION_TEXT.previewNoteOn : VACATION_TEXT.previewNoteOff }}
|
||||
</p>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.vacation-preview {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-12);
|
||||
}
|
||||
|
||||
.vacation-preview__title {
|
||||
margin: 0;
|
||||
font-size: var(--font-size-body);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.vacation-preview__card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-10);
|
||||
padding: var(--space-16) var(--space-20) var(--space-20);
|
||||
border: var(--border-width) solid var(--color-border);
|
||||
border-radius: var(--radius-lg);
|
||||
background: var(--color-surface);
|
||||
}
|
||||
|
||||
/* 이름 칸 72 · 값 (12.5px) */
|
||||
.vacation-preview__meta {
|
||||
display: grid;
|
||||
grid-template-columns: var(--space-72) minmax(0, 1fr);
|
||||
row-gap: var(--space-2);
|
||||
margin: 0;
|
||||
font-size: var(--font-size-label);
|
||||
}
|
||||
|
||||
.vacation-preview__label {
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.vacation-preview__value {
|
||||
margin: 0;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
/* 받는 쪽이 보는 모양 — 글자 그대로(줄바꿈 · 공백), 메일 본문 글꼴 */
|
||||
.vacation-preview__body {
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
font-family: var(--font-family-mail-body);
|
||||
font-size: var(--font-size-body);
|
||||
line-height: var(--line-height-mail-body);
|
||||
color: var(--color-text-body);
|
||||
}
|
||||
|
||||
.vacation-preview__note {
|
||||
margin: 0;
|
||||
font-size: var(--font-size-label);
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
</style>
|
||||
@@ -27,7 +27,7 @@ describe('AppRail', () => {
|
||||
|
||||
expect(current.text()).toBe('메일')
|
||||
expect(current.attributes('href')).toBe('/mail')
|
||||
// 설정은 첫 메뉴(메일 › 서명)로 바꿔 끼운다
|
||||
// 설정은 첫 메뉴(계정 › 비밀번호)로 바꿔 끼운다
|
||||
expect(links).toEqual([
|
||||
['메일', '/mail'],
|
||||
['설정', '/settings'],
|
||||
|
||||
@@ -66,6 +66,9 @@ describe('에러 코드 사전', () => {
|
||||
'MAIL_003', // 메일함 용량 — 임시 저장이 조금 뒤 저절로 다시 해 본다
|
||||
'FILE_001', // 첨부 형식 사전 검사
|
||||
'FILE_002', // 첨부 크기 사전 검사 · 413 역매핑
|
||||
// 웹메일 4단계
|
||||
'AUTH_005', // 지금 비밀번호가 틀림 — 비밀번호 화면이 지금 비밀번호 칸 아래에 보인다
|
||||
'MAIL_009', // 같은 자리에 같은 이름의 메일함 — 메일함 이름 대화상자가 칸 아래에 보인다
|
||||
]
|
||||
|
||||
for (const code of usedByClient) {
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import type { AxiosRequestConfig } from 'axios'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import { isApiError, refreshSession, settleRefresh, useApi } from './useApi'
|
||||
import { announceLogout } from './useLogoutBroadcast'
|
||||
import type { AuthUser, LoginPayload } from '@/shared/types/auth'
|
||||
import { PASSWORD_CHANGE_TIMEOUT_MS } from '@/shared/constants/account'
|
||||
import type { AuthUser, ChangePasswordPayload, LoginPayload } from '@/shared/types/auth'
|
||||
import { useAuthStore } from '@/stores/auth.store'
|
||||
|
||||
export type { LoginPayload } from '@/shared/types/auth'
|
||||
@@ -83,5 +85,18 @@ export function useAuth() {
|
||||
return authStore.restore(fetchSessionUser)
|
||||
}
|
||||
|
||||
return { loading, login, logout, me, refresh: refreshSession, restoreSession }
|
||||
/**
|
||||
* 비밀번호 바꾸기 (계약 1절 "PUT /api/auth/password" — 4단계). 실패하면 ApiError 를 그대로 던진다 — 호출부가 code 로 분기한다.
|
||||
* 지금 비밀번호가 틀리면 400 AUTH_005(세션은 그대로). 401 이 아니므로 토큰이 만료됐을 때만 갱신 뒤 같은 요청을 다시 보낸다.
|
||||
* 바뀌면 서버가 이 세션만 남기고 그 직원의 다른 로그인 세션을 모두 끊는다 — 이 탭의 세션은 이어진다.
|
||||
* 시간 한도는 30초다 (PASSWORD_CHANGE_TIMEOUT_MS — 서버가 엔진을 두 번 부르고 세션을 끊는 시간까지 기다린다)
|
||||
*/
|
||||
async function changePassword(
|
||||
payload: ChangePasswordPayload,
|
||||
options?: Pick<AxiosRequestConfig, 'silent'>,
|
||||
): Promise<void> {
|
||||
await api.put('/auth/password', payload, { timeout: PASSWORD_CHANGE_TIMEOUT_MS, ...options })
|
||||
}
|
||||
|
||||
return { loading, login, logout, me, refresh: refreshSession, restoreSession, changePassword }
|
||||
}
|
||||
|
||||
@@ -5,11 +5,12 @@ 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'
|
||||
import { useMailStore, type MailBulkResult, type MoveOptions } from '@/stores/mail.store'
|
||||
|
||||
// 여러 통 처리 — 도구 줄의 단추가 하는 일 (계약 2절 "여러 통 한꺼번에" · 디자인 README "3단계 › 여러 통 처리")
|
||||
// - 읽음 · 안 읽음 · 중요 표시: 목록이 바로 바뀌므로 알리지 않는다 (서버가 바꾸지 못한 메일만 되돌린다)
|
||||
// - 이동 · 삭제(휴지통 밖 — 휴지통으로 옮긴다) · 영구 삭제(휴지통에서만, 확인 대화상자를 거친 뒤): 알림은 useMailMoveFeedback
|
||||
// - 스팸 신고(스팸메일함으로 + 스팸 표시) · 스팸 아님(스팸메일함에서 — 받은편지함으로 + "스팸 아님" 표시): 옮기기와 같은 길에 isJunk 를 함께 보낸다
|
||||
// - 열어 둔 메일을 옮기거나 지워 목록에서 빠졌으면 목록 주소로 바꿔 끼운다 (본문 칸이 없는 메일을 붙들지 않게).
|
||||
// 끝났을 때 다른 목록으로 옮겨 가 있으면(stale) 그 목록에 기대어 움직이지 않는다
|
||||
|
||||
@@ -27,6 +28,10 @@ export function useBulkMailActions() {
|
||||
)
|
||||
/** 휴지통을 보는 중 — "삭제"가 "영구 삭제"가 된다 (휴지통 안의 검색 포함) */
|
||||
const inTrash = computed<boolean>(() => mailStore.currentMailbox?.role === 'trash')
|
||||
/** 스팸메일함을 보는 중 — 「스팸 신고」가 「스팸 아님」이 된다 (스팸메일함 안의 검색 포함) */
|
||||
const inJunk = computed<boolean>(() => mailStore.currentMailbox?.role === 'junk')
|
||||
/** 스팸 단추가 옮길 곳 — 스팸 신고는 스팸메일함, 스팸 아님은 받은편지함. 그 메일함을 모르면 단추를 그리지 않는다 */
|
||||
const spamTarget = computed<Mailbox | null>(() => mailStore.mailboxByRole(inJunk.value ? 'inbox' : 'junk'))
|
||||
/** 이동 메뉴 — 지금 메일함 · 임시보관함 · 휴지통을 뺀 메일함 (메일함 패널 순서) */
|
||||
const moveTargets = computed<Mailbox[]>(() =>
|
||||
mailStore.orderedMailboxes
|
||||
@@ -55,11 +60,11 @@ export function useBulkMailActions() {
|
||||
await router.replace(listLocationOf(source))
|
||||
}
|
||||
|
||||
async function moveTo(mailbox: Mailbox): Promise<void> {
|
||||
async function moveTo(mailbox: Mailbox, options: MoveOptions = {}): Promise<void> {
|
||||
if (busy.value || mailStore.checkedCount === 0) return
|
||||
busy.value = true
|
||||
try {
|
||||
const result = await mailStore.moveChecked(mailbox.id)
|
||||
const result = await mailStore.moveChecked(mailbox.id, options)
|
||||
await leaveRemovedMessage(result)
|
||||
feedback.announceMove(result, mailbox.name)
|
||||
} finally {
|
||||
@@ -78,6 +83,18 @@ export function useBulkMailActions() {
|
||||
await moveTo(trash.mailbox)
|
||||
}
|
||||
|
||||
/** 스팸 신고 · 스팸 아님 — 옮기며 스팸 표시를 함께 바꾼다 (되돌리기는 원래 메일함 · 원래 표시로) */
|
||||
async function moveSpam(): Promise<void> {
|
||||
if (busy.value || mailStore.checkedCount === 0) return
|
||||
const reportsSpam = !inJunk.value
|
||||
const target = await mailStore.resolveMailboxByRole(reportsSpam ? 'junk' : 'inbox')
|
||||
if ('error' in target) {
|
||||
notifyApiError(target.error)
|
||||
return
|
||||
}
|
||||
await moveTo(target.mailbox, { isJunk: reportsSpam })
|
||||
}
|
||||
|
||||
/** 영구 삭제 — 확인 대화상자에서 「영구 삭제」를 누른 뒤에만 부른다 */
|
||||
async function purge(): Promise<void> {
|
||||
if (busy.value || mailStore.checkedCount === 0) return
|
||||
@@ -91,5 +108,18 @@ export function useBulkMailActions() {
|
||||
}
|
||||
}
|
||||
|
||||
return { busy, allFlagged, inTrash, moveTargets, markRead, toggleFlagged, moveTo, moveToTrash, purge }
|
||||
return {
|
||||
busy,
|
||||
allFlagged,
|
||||
inTrash,
|
||||
inJunk,
|
||||
spamTarget,
|
||||
moveTargets,
|
||||
markRead,
|
||||
toggleFlagged,
|
||||
moveTo,
|
||||
moveToTrash,
|
||||
moveSpam,
|
||||
purge,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { AxiosRequestConfig } from 'axios'
|
||||
import { buildApiUrl, confirmSession, useApi } from './useApi'
|
||||
import {
|
||||
MAIL_IDEMPOTENCY_HEADER,
|
||||
MAIL_MAILBOX_OPERATION_TIMEOUT_MS,
|
||||
MAIL_SEND_TIMEOUT_MS,
|
||||
MAIL_UPLOAD_TIMEOUT_MS,
|
||||
} from '@/shared/constants/mail'
|
||||
@@ -14,7 +15,12 @@ import type {
|
||||
MailQuota,
|
||||
MailSignature,
|
||||
MailState,
|
||||
MailVacation,
|
||||
Mailbox,
|
||||
MailboxBulkResult,
|
||||
MailboxCreatePayload,
|
||||
MailboxDeletion,
|
||||
MailboxRenamePayload,
|
||||
MessageDeletionRequest,
|
||||
MessageDeletionResult,
|
||||
MessageDetail,
|
||||
@@ -83,6 +89,59 @@ export function useMail() {
|
||||
return api.get<MailQuota, MailQuota>('/mail-quota', options)
|
||||
}
|
||||
|
||||
// ── 메일함 관리 (계약 2절 "메일함 관리" — 4단계) ─────────────
|
||||
|
||||
/** 새 메일함 — 같은 자리에 같은 이름이면 409 MAIL_009, 없는 parentId 는 404 RES_001. parentId 가 없으면 맨 위 */
|
||||
function createMailbox(payload: MailboxCreatePayload, options?: MailRequestOptions): Promise<Mailbox> {
|
||||
const body: MailboxCreatePayload = payload.parentId ? payload : { name: payload.name }
|
||||
return api.post<Mailbox, Mailbox>('/mailboxes', body, options)
|
||||
}
|
||||
|
||||
/** 이름 바꾸기 — 기본 메일함은 400 VAL_001 (화면은 메뉴에 두지 않는다) */
|
||||
function renameMailbox(id: string, payload: MailboxRenamePayload, options?: MailRequestOptions): Promise<Mailbox> {
|
||||
return api.patch<Mailbox, Mailbox>(`/mailboxes/${segment(id)}`, payload, options)
|
||||
}
|
||||
|
||||
/**
|
||||
* 메일함 지우기 — 안의 메일을 휴지통으로 옮긴 뒤 지운다. 한 요청에 다 옮기지 못하면 deleted: false 다 (같은 요청을 다시 보낸다).
|
||||
* 하위 메일함이 있으면 400 VAL_001 (화면이 먼저 막는다)
|
||||
*/
|
||||
function deleteMailbox(id: string, options?: MailRequestOptions): Promise<MailboxDeletion> {
|
||||
return api.delete<MailboxDeletion, MailboxDeletion>(`/mailboxes/${segment(id)}`, {
|
||||
timeout: MAIL_MAILBOX_OPERATION_TIMEOUT_MS,
|
||||
...options,
|
||||
})
|
||||
}
|
||||
|
||||
/** 그 메일함의 안 읽은 메일을 읽음으로 — 한 요청에 500통씩, remaining 이 0 이 될 때까지 다시 보낸다 */
|
||||
function markMailboxRead(id: string, options?: MailRequestOptions): Promise<MailboxBulkResult> {
|
||||
return api.patch<MailboxBulkResult, MailboxBulkResult>(
|
||||
`/mailboxes/${segment(id)}/messages`,
|
||||
{ isRead: true },
|
||||
{ timeout: MAIL_MAILBOX_OPERATION_TIMEOUT_MS, ...options },
|
||||
)
|
||||
}
|
||||
|
||||
/** 휴지통 · 스팸메일함 비우기(영구 삭제) — 한 요청에 500통씩, remaining 이 0 이 될 때까지 다시 보낸다 */
|
||||
function emptyMailbox(id: string, options?: MailRequestOptions): Promise<MailboxBulkResult> {
|
||||
return api.delete<MailboxBulkResult, MailboxBulkResult>(`/mailboxes/${segment(id)}/messages`, {
|
||||
timeout: MAIL_MAILBOX_OPERATION_TIMEOUT_MS,
|
||||
...options,
|
||||
})
|
||||
}
|
||||
|
||||
// ── 부재중 자동 응답 (계약 2절 "부재중 자동 응답" — 4단계) ──────────
|
||||
|
||||
/** 설정한 적 없으면 꺼짐 · 빈 제목 · 빈 내용이 온다 */
|
||||
function getVacation(options?: MailRequestOptions): Promise<MailVacation> {
|
||||
return api.get<MailVacation, MailVacation>('/mail-vacation', options)
|
||||
}
|
||||
|
||||
/** 전체 교체 — 꺼도 제목 · 내용은 남긴다 */
|
||||
function saveVacation(payload: MailVacation, options?: MailRequestOptions): Promise<MailVacation> {
|
||||
return api.put<MailVacation, MailVacation>('/mail-vacation', payload, options)
|
||||
}
|
||||
|
||||
/**
|
||||
* 새 메일 확인 — 메일함이나 메일이 하나라도 바뀌면 state 가 달라진다 (계약 2절 "새 메일 확인").
|
||||
* 메일을 읽지 않는 가벼운 조회라 화면이 보이는 동안 1분마다 부른다 (useMailStatePolling)
|
||||
@@ -248,9 +307,33 @@ export function useMail() {
|
||||
return true
|
||||
}
|
||||
|
||||
/** 원문(.eml) 주소 — 받은 그대로의 메일 message/rfc822 (표준 래퍼 없음, 4단계 "스팸 · 원문") */
|
||||
function messageSourceUrl(messageId: string): string {
|
||||
return buildApiUrl(`/messages/${segment(messageId)}/source`)
|
||||
}
|
||||
|
||||
/**
|
||||
* 원문을 .eml 로 내려받는다 — 첨부 내려받기와 같이 이동하기 전에 세션을 확인한다 (confirmSession).
|
||||
* @returns 내려받기를 시작했으면 true. false 면 안내(세션 만료 대화상자·토스트)는 이미 끝났다.
|
||||
*/
|
||||
async function downloadMessageSource(messageId: string): Promise<boolean> {
|
||||
if (!(await confirmSession())) return false
|
||||
startBrowserDownload(messageSourceUrl(messageId))
|
||||
return true
|
||||
}
|
||||
|
||||
return {
|
||||
listMailboxes,
|
||||
getQuota,
|
||||
createMailbox,
|
||||
renameMailbox,
|
||||
deleteMailbox,
|
||||
markMailboxRead,
|
||||
emptyMailbox,
|
||||
getVacation,
|
||||
saveVacation,
|
||||
messageSourceUrl,
|
||||
downloadMessageSource,
|
||||
getMailState,
|
||||
listMessages,
|
||||
getMessage,
|
||||
|
||||
@@ -1,11 +1,19 @@
|
||||
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'
|
||||
import {
|
||||
movedMessageText,
|
||||
notSpamMovedText,
|
||||
purgedMessageText,
|
||||
restoredMessageText,
|
||||
type BulkOrigin,
|
||||
} from '@/shared/utils/mail-bulk'
|
||||
import { useMailStore, type BulkMoveResult, type MailBulkResult, type MoveOptions } from '@/stores/mail.store'
|
||||
|
||||
/**
|
||||
* 옮기기 · 영구 삭제의 결과 알림 (디자인 README "3단계 › 여러 통 처리") — 도구 줄과 읽기 화면의 「삭제」가 함께 쓴다.
|
||||
* 옮기기 · 영구 삭제의 결과 알림 (디자인 README "3단계 › 여러 통 처리" · "4단계 › 스팸 · 원문") — 도구 줄과 읽기 화면의 「삭제」 · 「스팸 신고」가 함께 쓴다.
|
||||
* - "메일 N통을 X으로 옮겼습니다." + [되돌리기](10초 — 원래 메일함별로 다시 옮기고 "메일 N통을 되돌렸습니다.")
|
||||
* 스팸 신고는 같은 문구("…스팸메일함으로 옮겼습니다."), 스팸 아님은 "메일 N통을 받은편지함으로 옮겼습니다 (스팸 아님)."
|
||||
* — 스팸 신고 · 스팸 아님의 되돌리기는 원래 메일함으로 옮기며 표시도 되돌린다(보낸 값의 반대)
|
||||
* - "메일 N통을 영구 삭제했습니다."
|
||||
* - 일부만 처리했으면 뒤에 "N통은 그사이 옮겨졌거나 지워져 그대로 두었습니다." (하나도 못 했으면 그 문장만)
|
||||
* - 하나도 보내지 못했으면(요청 실패 — 인터셉터가 알렸다) 알리지 않는다
|
||||
@@ -15,8 +23,8 @@ export function useMailMoveFeedback() {
|
||||
const mailStore = useMailStore()
|
||||
const toast = useToast()
|
||||
|
||||
async function undo(origins: readonly BulkOrigin[]): Promise<void> {
|
||||
const result = await mailStore.restoreMoved(origins)
|
||||
async function undo(origins: readonly BulkOrigin[], options: MoveOptions): Promise<void> {
|
||||
const result = await mailStore.restoreMoved(origins, options)
|
||||
const done = result.doneIds.length
|
||||
const skipped = result.skippedIds.length
|
||||
if (done > 0 || skipped > 0) toast.success(restoredMessageText(done, skipped))
|
||||
@@ -27,9 +35,12 @@ export function useMailMoveFeedback() {
|
||||
const skipped = result.skippedIds.length
|
||||
if (done === 0 && skipped === 0) return
|
||||
const origins = result.origins
|
||||
const undoOptions: MoveOptions = result.isJunk === undefined ? {} : { isJunk: !result.isJunk }
|
||||
const text =
|
||||
result.isJunk === false ? notSpamMovedText(done, mailboxName, skipped) : movedMessageText(done, mailboxName, skipped)
|
||||
toast.success(
|
||||
movedMessageText(done, mailboxName, skipped),
|
||||
origins.length > 0 ? { label: DRAFT_UNDO_LABEL, run: () => void undo(origins) } : undefined,
|
||||
text,
|
||||
origins.length > 0 ? { label: DRAFT_UNDO_LABEL, run: () => void undo(origins, undoOptions) } : undefined,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
import { nextTick } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
import { notifyApiError, toApiError, type ApiError } from './useApi'
|
||||
import { useMailNavigation } from './useMailNavigation'
|
||||
import { useToast } from './useToast'
|
||||
import type { ErrorCode } from '@/shared/constants/error-codes.generated'
|
||||
import { MAILBOX_RESULT_TEXT } from '@/shared/constants/mailbox-manage'
|
||||
import type { Mailbox } from '@/shared/types/mail'
|
||||
import { isSearchRouteName, listLocationOf, scopeMailboxIdOf } from '@/shared/utils/mail-list-source'
|
||||
import { initialOperationTotal, mailboxOperationCopy, type MailboxOperationKind } from '@/shared/utils/mailbox-operation'
|
||||
import { useMailStore } from '@/stores/mail.store'
|
||||
|
||||
// 메일함 관리 — 메일함 패널의 ⋯ 메뉴 · 대화상자와 휴지통 / 스팸메일함 목록 머리의 「비우기」가 하는 일 (계약 2절 "메일함 관리" · 디자인 README "4단계")
|
||||
// - 만들기 · 이름 바꾸기: 대화상자가 칸 안에 보일 실패(같은 이름 MAIL_009 · 형식 VAL_001)는 알리지 않고 돌려준다. 그 밖은 인터셉터가 알린다.
|
||||
// 성공하면 짧은 알림 ("‘협력사’ 메일함을 만들었습니다." · "메일함 이름을 바꿨습니다.") — 메일함 패널에 바로 나타난다.
|
||||
// 기다리는 사이 세션이 바뀌었으면 아무것도 하지 않는다 (canceled)
|
||||
// - 오래 걸리는 처리(모두 읽음 · 비우기 · 메일함 지우기): 알림이 "…하는 중 · 600 / 1,234통"(돌아가는 아이콘)으로 남았다가 끝나면 결과로 바뀐다.
|
||||
// 처리하는 동안 다른 일을 해도 된다. 도중에 실패하면 거기까지 처리한 수를 알린다(실패 까닭은 인터셉터가 알렸다 — 하나도 못 했으면 그것만).
|
||||
// 다른 탭이 먼저 해서 처리할 것이 없었으면 그렇게 알린다("…에 안 읽은 메일이 없습니다." · "…이 이미 비어 있습니다.").
|
||||
// 비우기 · 지우기는 되돌릴 수 없어 [되돌리기]가 없다. 확인 대화상자는 부르는 쪽이 먼저 띄운다.
|
||||
// 휴지통 비우기와 메일함 지우기는 함께 돌지 않는다 (isBlocked — 단추 · 메뉴 항목을 끄고, 확인하는 순간에도 다시 본다)
|
||||
// - 끝난 뒤: 보고 있던 메일함을 지웠으면 받은편지함으로, 비운 메일함의 메일을 열어 두었으면 그 목록으로 (기록을 쌓지 않는다).
|
||||
// 비우기 · 지우기를 누른 단추(목록 머리 「비우기」 · 지운 메일함의 ⋯)가 사라져 초점을 잃었으면 메일함 패널의 지금 메일함으로 옮긴다
|
||||
|
||||
/** 이름 대화상자가 칸 안에 보이는 실패 — 같은 이름 · 형식 */
|
||||
const NAME_INLINE_CODES: readonly ErrorCode[] = ['MAIL_009', 'VAL_001']
|
||||
/** 메일함 패널의 지금 메일함 항목 (MailboxPanel 의 data-mailbox-panel) */
|
||||
const CURRENT_MAILBOX_LINK = '[data-mailbox-panel] a[aria-current="page"]'
|
||||
|
||||
export type MailboxNameResult =
|
||||
| { status: 'saved'; mailbox: Mailbox }
|
||||
/** inline: 대화상자가 칸 아래에 보일 실패 (알림을 끈 코드) */
|
||||
| { status: 'failed'; error: ApiError; inline: boolean }
|
||||
/** 기다리는 사이 세션이 바뀌었다 */
|
||||
| { status: 'canceled' }
|
||||
|
||||
/** 초점이 갈 곳을 잃었는가 — 누른 단추가 사라졌거나 꺼졌다 */
|
||||
function focusIsLost(): boolean {
|
||||
const active = document.activeElement
|
||||
return active === null || active === document.body || !active.isConnected
|
||||
}
|
||||
|
||||
export function useMailboxActions() {
|
||||
const mailStore = useMailStore()
|
||||
const router = useRouter()
|
||||
const toast = useToast()
|
||||
const { replaceWithMailbox } = useMailNavigation()
|
||||
|
||||
function isRunning(kind: MailboxOperationKind, mailboxId: string): boolean {
|
||||
return mailStore.isMailboxOperationRunning(kind, mailboxId)
|
||||
}
|
||||
|
||||
/** 지금은 시작할 수 없는가 — 휴지통 비우기 ↔ 메일함 지우기 (스토어의 isMailboxOperationBlocked) */
|
||||
function isBlocked(kind: MailboxOperationKind, mailbox: Pick<Mailbox, 'role'>): boolean {
|
||||
return mailStore.isMailboxOperationBlocked(kind, mailbox)
|
||||
}
|
||||
|
||||
async function saveName(
|
||||
run: () => Promise<Mailbox | null>,
|
||||
done: (mailbox: Mailbox) => string,
|
||||
): Promise<MailboxNameResult> {
|
||||
try {
|
||||
const mailbox = await run()
|
||||
if (!mailbox) return { status: 'canceled' }
|
||||
toast.success(done(mailbox))
|
||||
return { status: 'saved', mailbox }
|
||||
} catch (error) {
|
||||
const apiError = toApiError(error)
|
||||
return { status: 'failed', error: apiError, inline: NAME_INLINE_CODES.includes(apiError.code) }
|
||||
}
|
||||
}
|
||||
|
||||
/** 새 메일함 — parentId 가 없으면 맨 위 */
|
||||
function create(name: string, parentId: string | null): Promise<MailboxNameResult> {
|
||||
return saveName(
|
||||
() => mailStore.createMailbox(name, parentId, { silent: NAME_INLINE_CODES }),
|
||||
(mailbox) => MAILBOX_RESULT_TEXT.created(mailbox.name),
|
||||
)
|
||||
}
|
||||
|
||||
function rename(mailbox: Mailbox, name: string): Promise<MailboxNameResult> {
|
||||
return saveName(
|
||||
() => mailStore.renameMailbox(mailbox.id, name, { silent: NAME_INLINE_CODES }),
|
||||
() => MAILBOX_RESULT_TEXT.renamed,
|
||||
)
|
||||
}
|
||||
|
||||
/** 지금 화면이 그 메일함(의 목록 · 메일 · 그 안의 검색)을 보고 있는가 */
|
||||
function isViewing(mailboxId: string): boolean {
|
||||
const route = router.currentRoute.value
|
||||
if (route.name === 'mail-mailbox' || route.name === 'mail-message') return route.params.mailboxId === mailboxId
|
||||
return isSearchRouteName(route.name) && scopeMailboxIdOf(mailStore.listSource) === mailboxId
|
||||
}
|
||||
|
||||
/** 끝난 뒤의 이동 (위 규칙) */
|
||||
async function moveAway(kind: MailboxOperationKind, mailboxId: string): Promise<void> {
|
||||
if (!isViewing(mailboxId)) return
|
||||
if (kind === 'delete') {
|
||||
await replaceWithMailbox(mailStore.defaultMailbox?.id ?? null)
|
||||
return
|
||||
}
|
||||
const source = mailStore.listSource
|
||||
if (kind === 'empty' && source && router.currentRoute.value.params.messageId) {
|
||||
await router.replace(listLocationOf(source))
|
||||
}
|
||||
}
|
||||
|
||||
/** 누른 단추가 사라져 초점을 잃었으면 메일함 패널의 지금 메일함으로 — 다른 곳으로 옮긴 초점은 빼앗지 않는다 */
|
||||
async function recoverFocus(): Promise<void> {
|
||||
await nextTick()
|
||||
if (!focusIsLost()) return
|
||||
document.querySelector<HTMLElement>(CURRENT_MAILBOX_LINK)?.focus()
|
||||
}
|
||||
|
||||
async function runOperation(kind: MailboxOperationKind, mailbox: Mailbox): Promise<void> {
|
||||
if (isRunning(kind, mailbox.id) || isBlocked(kind, mailbox)) return
|
||||
const copy = mailboxOperationCopy(kind, mailbox.name)
|
||||
const progress = toast.progress(copy.progress(0, initialOperationTotal(kind, mailbox)))
|
||||
const outcome = await mailStore.runMailboxOperation(kind, mailbox, (step) =>
|
||||
progress.update(copy.progress(step.processed, step.total)),
|
||||
)
|
||||
if (outcome.status === 'canceled') {
|
||||
progress.dismiss()
|
||||
return
|
||||
}
|
||||
if (outcome.status === 'failed') {
|
||||
if (!outcome.notified) notifyApiError(outcome.error)
|
||||
if (outcome.processed > 0) progress.fail(copy.failed(outcome.processed, outcome.total))
|
||||
else progress.dismiss()
|
||||
return
|
||||
}
|
||||
// 지우기는 "안의 메일 N통은 휴지통에 있습니다" — 다른 메일함에도 있어 이 메일함에서만 뺀 메일은 세지 않는다
|
||||
progress.succeed(copy.done(kind === 'delete' ? outcome.movedToTrash : outcome.processed))
|
||||
await moveAway(kind, mailbox.id)
|
||||
if (kind !== 'read') await recoverFocus()
|
||||
}
|
||||
|
||||
/** 모두 읽음으로 표시 */
|
||||
function markAllRead(mailbox: Mailbox): Promise<void> {
|
||||
return runOperation('read', mailbox)
|
||||
}
|
||||
|
||||
/** 휴지통 · 스팸메일함 비우기(영구 삭제) — 확인 대화상자를 거친 뒤에만 부른다 */
|
||||
function empty(mailbox: Mailbox): Promise<void> {
|
||||
return runOperation('empty', mailbox)
|
||||
}
|
||||
|
||||
/** 메일함 지우기(안의 메일은 휴지통으로) — 확인 대화상자를 거친 뒤에만 부른다. 하위 메일함이 있으면 대화상자가 먼저 막는다 */
|
||||
function remove(mailbox: Mailbox): Promise<void> {
|
||||
return runOperation('delete', mailbox)
|
||||
}
|
||||
|
||||
return { isRunning, isBlocked, create, rename, markAllRead, empty, remove }
|
||||
}
|
||||
@@ -1,18 +1,27 @@
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
import { notifyApiError } from './useApi'
|
||||
import { useMailMoveFeedback } from './useMailMoveFeedback'
|
||||
import { useMailNavigation } from './useMailNavigation'
|
||||
import type { MessageSummary } from '@/shared/types/mail'
|
||||
import { listLocationOf, messageLocationOf, type MailListSource } from '@/shared/utils/mail-list-source'
|
||||
import { useMailStore } from '@/stores/mail.store'
|
||||
|
||||
// 읽기 화면의 「삭제」 · 「영구 삭제」 — 열린 메일 한 통 (디자인 README "3단계 › 여러 통 처리" · 계약 2절 "여러 통 한꺼번에")
|
||||
// 읽기 화면의 「삭제」 · 「영구 삭제」 · 「스팸 신고」 · 「스팸 아님」 — 열린 메일 한 통
|
||||
// (디자인 README "3단계 › 여러 통 처리" · "4단계 › 스팸 · 원문" · 계약 2절 "여러 통 한꺼번에" · "스팸 · 원문")
|
||||
// - 「영구 삭제」인지는 그 메일의 메일함(mailboxIds)으로 정한다 — 검색 결과 · 다른 창에서 옮긴 메일도 맞게 (deletesPermanently)
|
||||
// · 휴지통 목록(휴지통 안의 검색 포함)에서는 휴지통에 든 메일이면 「영구 삭제」 (지금까지와 같다)
|
||||
// · 그 밖의 목록에서는 휴지통에만 든 메일만 「영구 삭제」 — 휴지통과 다른 메일함에 함께 든 메일(다른 메일 프로그램이 복사)은
|
||||
// 영구 삭제가 휴지통에서만 빼고 끝나 목록 · 알림이 사실과 달라지므로 「삭제」(휴지통으로 옮기기)다
|
||||
// - 「삭제」: 휴지통으로 옮긴다(PATCH /messages { mailboxId: 휴지통 }) — "메일 1통을 휴지통으로 옮겼습니다." + [되돌리기]
|
||||
// - 「영구 삭제」: 확인 대화상자를 거친 뒤 POST /message-deletions — "메일 1통을 영구 삭제했습니다."
|
||||
// - 「스팸 신고」: 스팸메일함으로 옮기며 스팸 표시(isJunk: true) — 스팸메일함에 든 메일이면 「스팸 아님」: 받은편지함으로 · isJunk: false.
|
||||
// 알림에 [되돌리기] (원래 메일함 · 원래 표시로)
|
||||
// - DELETE /messages/{id} 는 쓰지 않는다 — 메일함 정보가 낡았으면(그사이 다른 창이 휴지통으로 옮겼으면) 확인 없이 영구 삭제하기 때문이다.
|
||||
// 이 길에서는 낡은 정보가 해롭지 않다: 이미 휴지통에 있는 메일을 휴지통으로 옮기면 그대로이고, 휴지통을 떠난 메일의 영구 삭제는
|
||||
// 서버가 그대로 둔다(skippedIds — 목록을 다시 맞춘다)
|
||||
// - 뺀 뒤(leaveRemoved): 분할 보기는 다음 메일(없으면 이전 메일, 그것도 없으면 목록), 목록 보기는 목록으로 (기록을 쌓지 않는다).
|
||||
// 그사이 다른 목록으로 옮겨 갔거나 서버가 그대로 두었으면 움직이지 않는다
|
||||
|
||||
export interface MessageRemovalOutcome {
|
||||
/** 서버가 처리했고 지금 목록에서도 빠졌다 — 다음 메일(또는 목록)로 넘어간다 */
|
||||
@@ -25,7 +34,9 @@ const NOTHING: MessageRemovalOutcome = { removed: false, nextId: null }
|
||||
|
||||
export function useMessageRemoval() {
|
||||
const mailStore = useMailStore()
|
||||
const router = useRouter()
|
||||
const feedback = useMailMoveFeedback()
|
||||
const { backToSourceList } = useMailNavigation()
|
||||
|
||||
/**
|
||||
* 「영구 삭제」할 메일인가 (위 규칙) — 휴지통 메일함을 아직 모르면 아니라고 본다 (옮기기 쪽이 해롭지 않다)
|
||||
@@ -37,6 +48,12 @@ export function useMessageRemoval() {
|
||||
return message.mailboxIds.every((id) => id === trashId)
|
||||
}
|
||||
|
||||
/** 스팸메일함에 든 메일 — 「스팸 신고」 대신 「스팸 아님」 */
|
||||
function isInJunk(message: Pick<MessageSummary, 'mailboxIds'>): boolean {
|
||||
const junkId = mailStore.mailboxByRole('junk')?.id
|
||||
return Boolean(junkId && message.mailboxIds.includes(junkId))
|
||||
}
|
||||
|
||||
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 }
|
||||
@@ -63,5 +80,34 @@ export function useMessageRemoval() {
|
||||
return outcomeOf(message.id, nextId, result.doneIds.includes(message.id), result.stale)
|
||||
}
|
||||
|
||||
return { deletesPermanently, moveToTrash, purge }
|
||||
/** 스팸 신고(스팸메일함으로 + 스팸 표시) · 스팸 아님(받은편지함으로 + 스팸 아님 표시) — 그 메일의 메일함으로 정한다 */
|
||||
async function moveSpam(message: MessageSummary): Promise<MessageRemovalOutcome> {
|
||||
const reportsSpam = !isInJunk(message)
|
||||
const target = await mailStore.resolveMailboxByRole(reportsSpam ? 'junk' : 'inbox')
|
||||
if ('error' in target) {
|
||||
notifyApiError(target.error)
|
||||
return NOTHING
|
||||
}
|
||||
const nextId = mailStore.neighborAfterRemoval(message.id)
|
||||
const result = await mailStore.moveMessages([message], target.mailbox.id, { isJunk: reportsSpam })
|
||||
feedback.announceMove(result, target.mailbox.name)
|
||||
return outcomeOf(message.id, nextId, result.doneIds.includes(message.id), result.stale)
|
||||
}
|
||||
|
||||
/** 뺀 뒤의 이동 (위 규칙) — source 는 일을 시작할 때의 목록 */
|
||||
async function leaveRemoved(
|
||||
outcome: MessageRemovalOutcome,
|
||||
source: MailListSource | null,
|
||||
layout: 'split' | 'list',
|
||||
): Promise<void> {
|
||||
if (!outcome.removed || !source) return
|
||||
if (layout === 'list') {
|
||||
// 목록 보기에서는 빼면 목록으로 돌아간다 (디자인 원본 MainListRead)
|
||||
await backToSourceList(source)
|
||||
return
|
||||
}
|
||||
await router.replace(outcome.nextId ? messageLocationOf(source, outcome.nextId) : listLocationOf(source))
|
||||
}
|
||||
|
||||
return { deletesPermanently, isInJunk, moveToTrash, purge, moveSpam, leaveRemoved }
|
||||
}
|
||||
|
||||
@@ -1,11 +1,23 @@
|
||||
import { useAuthStore } from '@/stores/auth.store'
|
||||
import { useToastStore, type ToastAction } from '@/stores/toast.store'
|
||||
import { useToastStore, type TimedToastKind, type ToastAction } from '@/stores/toast.store'
|
||||
|
||||
/** 오래 걸리는 처리의 진행 알림 — 하는 중에는 남아 있다가 끝나면 그 자리에서 결과로 바뀐다 */
|
||||
export interface ProgressToast {
|
||||
/** 진행 문구를 바꾼다 ("…하는 중 · 600 / 1,234통") */
|
||||
update: (message: string) => void
|
||||
/** 끝났다 — 성공 알림으로 바꾼다 (그사이 닫혔으면 새로 띄운다) */
|
||||
succeed: (message: string) => void
|
||||
/** 도중에 멈췄다 — 오류 알림으로 바꾼다 (그사이 닫혔으면 새로 띄운다) */
|
||||
fail: (message: string) => void
|
||||
/** 알리지 않고 닫는다 */
|
||||
dismiss: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* 화면 알림(토스트) — 성공 안내처럼 화면이 직접 띄우는 알림에 쓴다.
|
||||
* API 오류 알림은 useApi 인터셉터가 이미 띄우므로 호출부에서 다시 띄우지 않는다.
|
||||
* 그 자리의 단추(되돌리기 등 — run)는 알림을 띄운 세션에서만 움직인다: 그사이 로그아웃 · 세션 만료 · 다른 계정 로그인이 있었으면
|
||||
* 눌러도 아무것도 하지 않는다 (세션이 바뀌면 알림도 닫힌다 — toast.store)
|
||||
* 눌러도 아무것도 하지 않는다 (세션이 바뀌면 알림도 닫힌다 — toast.store). 진행 알림도 띄운 세션에서만 바뀐다
|
||||
*/
|
||||
export function useToast() {
|
||||
const store = useToastStore()
|
||||
@@ -27,5 +39,26 @@ export function useToast() {
|
||||
return store.show({ kind: 'success', message, action: action ? sessionBound(action) : undefined })
|
||||
}
|
||||
|
||||
return { success, dismiss: store.dismiss }
|
||||
/** 진행 알림을 띄운다 — 저절로 닫히지 않는다. 끝나면 succeed · fail · dismiss 중 하나를 부른다 */
|
||||
function progress(message: string): ProgressToast {
|
||||
const session = authStore.generation
|
||||
let id = store.show({ kind: 'progress', message })
|
||||
const sameSession = () => authStore.generation === session
|
||||
|
||||
function settle(kind: TimedToastKind, text: string) {
|
||||
if (!sameSession()) return
|
||||
if (!store.update(id, { kind, message: text })) id = store.show({ kind, message: text })
|
||||
}
|
||||
|
||||
return {
|
||||
update: (text) => {
|
||||
if (sameSession()) store.update(id, { message: text })
|
||||
},
|
||||
succeed: (text) => settle('success', text),
|
||||
fail: (text) => settle('error', text),
|
||||
dismiss: () => store.dismiss(id),
|
||||
}
|
||||
}
|
||||
|
||||
return { success, progress, dismiss: store.dismiss }
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
// 메일 모듈 레이아웃 — 전역 셸 + 메일함 패널 + 하위 화면(목록·본문 / 검색 결과 / 쓰기)
|
||||
// 패널은 하위 화면이 바뀌어도 다시 그리지 않는다.
|
||||
// 메일 화면에 있는 동안: 상단 검색 칸은 "메일 검색", 1분마다 새 메일 확인, 탭 제목 앞에 받은편지함의 안 읽은 수 (디자인 README "3단계")
|
||||
// 들어올 때 부재중 자동 응답을 한 번 받는다 — 켜져 있으면 메일함 패널 아래에 안내 (계약 2절 "부재중 자동 응답")
|
||||
import AppShell from '@/components/shell/AppShell.vue'
|
||||
import MailSearchBox from '@/components/mail/MailSearchBox.vue'
|
||||
import MailboxPanel from '@/components/mail/MailboxPanel.vue'
|
||||
@@ -9,6 +10,7 @@ import { useClock } from '@/composables/useClock'
|
||||
import { useMailStatePolling } from '@/composables/useMailStatePolling'
|
||||
import { useMailTabTitle } from '@/composables/useMailTabTitle'
|
||||
import { useMailSignatureStore } from '@/stores/mail-signature.store'
|
||||
import { useMailVacationStore } from '@/stores/mail-vacation.store'
|
||||
import { useMailStore } from '@/stores/mail.store'
|
||||
|
||||
const mailStore = useMailStore()
|
||||
@@ -19,6 +21,8 @@ void mailStore.ensureMailboxes()
|
||||
void mailStore.ensureQuota()
|
||||
// 쓰기 화면이 열 때 넣을 서명 — 미리 받아 두면 편집기가 기다리지 않는다 (못 받으면 서명 없이 연다)
|
||||
void useMailSignatureStore().ensureSignature()
|
||||
// 메일함 패널 아래의 부재중 안내 — 못 받으면 알리지 않고 그리지 않는다 (지난 값은 둔다)
|
||||
void useMailVacationStore().load()
|
||||
|
||||
// 새 메일 확인 — 바뀌었으면 메일함 개수 · 사용량과 보이는 메일함 목록을 맞춘다 (검색 결과는 저절로 바꾸지 않는다 — 스토어가 거른다)
|
||||
useMailStatePolling({
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { mount, type VueWrapper } from '@vue/test-utils'
|
||||
import { createPinia, setActivePinia, type Pinia } from 'pinia'
|
||||
|
||||
import { PASSWORD_CHANGE_TIMEOUT_MS } from '@/shared/constants/account'
|
||||
import { ERROR_CODE_MESSAGES } from '@/shared/constants/error-codes.generated'
|
||||
import { useAuthStore } from '@/stores/auth.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 AccountPasswordPage from './AccountPasswordPage.vue'
|
||||
|
||||
// 설정 › 계정 › 비밀번호 (디자인 원본 SettingsAccount · 계약 1절 "PUT /api/auth/password")
|
||||
|
||||
const USER = { email: 'ara.kim@arasystem.it.kr', name: '김아라' }
|
||||
const CURRENT = 'old-pass-1234'
|
||||
const NEXT = 'Ara!2026-mail'
|
||||
|
||||
describe('AccountPasswordPage', () => {
|
||||
let pinia: Pinia
|
||||
let requests: MockRequest[]
|
||||
|
||||
beforeEach(() => {
|
||||
pinia = createPinia()
|
||||
setActivePinia(pinia)
|
||||
useAuthStore().markAuthenticated(USER)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
useToastStore().clear()
|
||||
restoreApiAdapter()
|
||||
document.body.innerHTML = ''
|
||||
})
|
||||
|
||||
async function mountPage(reply: (request: MockRequest) => MockResult | Promise<MockResult> = () => ok(null)) {
|
||||
requests = installMockApi((request) => (request.url === '/auth/password' ? reply(request) : ok(null)))
|
||||
const router = createTestRouter()
|
||||
await router.push('/settings/account/password')
|
||||
await router.isReady()
|
||||
const wrapper = mount(AccountPasswordPage, { attachTo: document.body, global: { plugins: [pinia, router] } })
|
||||
await flushPromises()
|
||||
return wrapper
|
||||
}
|
||||
|
||||
async function fill(wrapper: VueWrapper, values: { current?: string; next?: string; confirm?: string }) {
|
||||
if (values.current !== undefined) await wrapper.get('#password-current').setValue(values.current)
|
||||
if (values.next !== undefined) await wrapper.get('#password-next').setValue(values.next)
|
||||
if (values.confirm !== undefined) await wrapper.get('#password-confirm').setValue(values.confirm)
|
||||
}
|
||||
|
||||
async function submit(wrapper: VueWrapper) {
|
||||
await wrapper.get('form').trigger('submit')
|
||||
await flushPromises()
|
||||
}
|
||||
|
||||
const passwordRequests = () => requests.filter((request) => request.url === '/auth/password')
|
||||
const errorOf = (wrapper: VueWrapper, id: string) => wrapper.find(`#${id}-error`)
|
||||
|
||||
it('칸 셋 · 안내 · 안내 상자 — 앞의 두 칸에 보기 단추(aria-pressed), 확인 칸은 새 비밀번호를 따른다', async () => {
|
||||
const wrapper = await mountPage()
|
||||
|
||||
expect(wrapper.get('h1').text()).toBe('비밀번호')
|
||||
expect(wrapper.findAll('label').map((label) => label.text())).toEqual(['지금 비밀번호', '새 비밀번호', '새 비밀번호 확인'])
|
||||
expect(wrapper.get('#password-current').attributes('autocomplete')).toBe('current-password')
|
||||
expect(wrapper.get('#password-next').attributes('autocomplete')).toBe('new-password')
|
||||
expect(wrapper.get('#password-next-hint').text()).toBe(
|
||||
'8자 이상 · 지금 비밀번호와 달라야 합니다. 영문 · 숫자 · 기호를 섞으면 더 안전합니다.',
|
||||
)
|
||||
expect(wrapper.get('[data-password-note]').text()).toContain('메일 프로그램(IMAP)에는 새 비밀번호를 다시 넣어 주세요.')
|
||||
|
||||
const toggles = wrapper.findAll('.password-field__toggle')
|
||||
expect(toggles.map((toggle) => toggle.attributes('aria-label'))).toEqual(['지금 비밀번호 보기', '새 비밀번호 보기'])
|
||||
await toggles[1]?.trigger('click')
|
||||
expect(toggles[1]?.attributes('aria-pressed')).toBe('true')
|
||||
// 이름은 그대로다 — 켜짐은 aria-pressed 로만
|
||||
expect(toggles[1]?.attributes('aria-label')).toBe('새 비밀번호 보기')
|
||||
expect(wrapper.get('#password-next').attributes('type')).toBe('text')
|
||||
expect(wrapper.get('#password-confirm').attributes('type')).toBe('text')
|
||||
expect(wrapper.get('#password-current').attributes('type')).toBe('password')
|
||||
})
|
||||
|
||||
it('보내기 전에 규칙을 본다 — 8자 미만이면 안내 글이 빨간 오류, 확인 칸이 다르면 "새 비밀번호가 서로 다릅니다." · 보내지 않는다', async () => {
|
||||
const wrapper = await mountPage()
|
||||
await fill(wrapper, { current: CURRENT, next: 'short', confirm: 'shorter' })
|
||||
await submit(wrapper)
|
||||
|
||||
expect(passwordRequests()).toEqual([])
|
||||
expect(wrapper.get('#password-next').attributes('aria-invalid')).toBe('true')
|
||||
expect(wrapper.get('#password-next-hint').classes()).toContain('form-message--error')
|
||||
expect(errorOf(wrapper, 'password-confirm').text()).toBe('새 비밀번호가 서로 다릅니다.')
|
||||
expect(document.activeElement?.id).toBe('password-next')
|
||||
|
||||
// 고치면 그 칸의 오류를 내린다
|
||||
await fill(wrapper, { next: NEXT })
|
||||
expect(wrapper.get('#password-next').attributes('aria-invalid')).toBe('false')
|
||||
expect(errorOf(wrapper, 'password-confirm').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('지금 비밀번호와 같으면 보내지 않는다 · 지금 비밀번호를 비우면 그 칸에 사전 문구', async () => {
|
||||
const wrapper = await mountPage()
|
||||
await fill(wrapper, { current: NEXT, next: NEXT, confirm: NEXT })
|
||||
await submit(wrapper)
|
||||
expect(passwordRequests()).toEqual([])
|
||||
expect(wrapper.get('#password-next-hint').classes()).toContain('form-message--error')
|
||||
|
||||
await fill(wrapper, { current: '' })
|
||||
await submit(wrapper)
|
||||
expect(passwordRequests()).toEqual([])
|
||||
expect(errorOf(wrapper, 'password-current').text()).toBe(ERROR_CODE_MESSAGES.VAL_001)
|
||||
expect(document.activeElement?.id).toBe('password-current')
|
||||
})
|
||||
|
||||
it('바꾸면 PUT /auth/password { currentPassword, newPassword } — "비밀번호를 바꿨습니다…" 를 보이고 세 칸을 비운다', async () => {
|
||||
const wrapper = await mountPage()
|
||||
await wrapper.findAll('.password-field__toggle')[0]?.trigger('click')
|
||||
await fill(wrapper, { current: CURRENT, next: NEXT, confirm: NEXT })
|
||||
await submit(wrapper)
|
||||
|
||||
expect(passwordRequests().map((request) => [request.method, request.data])).toEqual([
|
||||
['PUT', { currentPassword: CURRENT, newPassword: NEXT }],
|
||||
])
|
||||
// 서버가 엔진을 두 번 부르고 다른 세션을 끊는다 — 요청 한도는 보통(API_TIMEOUT_MS)보다 긴 30초
|
||||
expect(PASSWORD_CHANGE_TIMEOUT_MS).toBe(30_000)
|
||||
expect(passwordRequests()[0]?.config.timeout).toBe(PASSWORD_CHANGE_TIMEOUT_MS)
|
||||
expect(wrapper.get('#password-status').text()).toBe('비밀번호를 바꿨습니다. 다른 기기 · 브라우저의 로그인은 끊었습니다.')
|
||||
for (const id of ['password-current', 'password-next', 'password-confirm']) {
|
||||
expect((wrapper.get(`#${id}`).element as HTMLInputElement).value).toBe('')
|
||||
}
|
||||
// 보기도 끈다
|
||||
expect(wrapper.get('#password-current').attributes('type')).toBe('password')
|
||||
expect(useAuthStore().isLoggedIn).toBe(true)
|
||||
|
||||
// 다시 치면 내린다
|
||||
await fill(wrapper, { current: 'x' })
|
||||
expect(wrapper.get('#password-status').text()).toBe('')
|
||||
})
|
||||
|
||||
it('지금 비밀번호가 틀리면(400 AUTH_005) 알림 없이 지금 비밀번호 칸 아래에 사전 문구 — 세션은 그대로', async () => {
|
||||
const wrapper = await mountPage(() => fail(400, 'AUTH_005'))
|
||||
await fill(wrapper, { current: 'wrong-pass', next: NEXT, confirm: NEXT })
|
||||
await submit(wrapper)
|
||||
|
||||
expect(errorOf(wrapper, 'password-current').text()).toBe(ERROR_CODE_MESSAGES.AUTH_005)
|
||||
expect(ERROR_CODE_MESSAGES.AUTH_005).toBe('지금 비밀번호가 올바르지 않습니다.')
|
||||
expect(wrapper.get('#password-current').attributes('aria-invalid')).toBe('true')
|
||||
expect(document.activeElement?.id).toBe('password-current')
|
||||
expect(useToastStore().toasts).toEqual([])
|
||||
expect(useAuthStore().isLoggedIn).toBe(true)
|
||||
// 쓴 것은 남긴다 — 지금 비밀번호만 고쳐 다시 보낼 수 있게
|
||||
expect((wrapper.get('#password-next').element as HTMLInputElement).value).toBe(NEXT)
|
||||
|
||||
await fill(wrapper, { current: CURRENT })
|
||||
expect(errorOf(wrapper, 'password-current').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('엔진 규칙에 어긋나면(400 VAL_001) 새 비밀번호 칸 아래에 사전 문구 · 그 밖의 실패(MAIL_001)는 인터셉터 알림', async () => {
|
||||
let reply: MockResult = fail(400, 'VAL_001')
|
||||
const wrapper = await mountPage(() => reply)
|
||||
await fill(wrapper, { current: CURRENT, next: NEXT, confirm: NEXT })
|
||||
await submit(wrapper)
|
||||
expect(errorOf(wrapper, 'password-next').text()).toBe(ERROR_CODE_MESSAGES.VAL_001)
|
||||
expect(useToastStore().toasts).toEqual([])
|
||||
|
||||
reply = fail(503, 'MAIL_001')
|
||||
await submit(wrapper)
|
||||
expect(errorOf(wrapper, 'password-next').exists()).toBe(false)
|
||||
expect(useToastStore().toasts.map((toast) => toast.message)).toEqual([ERROR_CODE_MESSAGES.MAIL_001])
|
||||
})
|
||||
|
||||
it('기다리는 사이 화면을 떠났는데 지금 비밀번호가 틀렸으면(AUTH_005) 볼 칸이 없으니 알림으로', async () => {
|
||||
let finish: (result: MockResult) => void = () => undefined
|
||||
const wrapper = await mountPage(
|
||||
() =>
|
||||
new Promise<MockResult>((resolve) => {
|
||||
finish = resolve
|
||||
}),
|
||||
)
|
||||
await fill(wrapper, { current: 'wrong-pass', next: NEXT, confirm: NEXT })
|
||||
await submit(wrapper)
|
||||
expect(passwordRequests()).toHaveLength(1)
|
||||
wrapper.unmount()
|
||||
|
||||
finish(fail(400, 'AUTH_005'))
|
||||
await flushPromises()
|
||||
expect(useToastStore().toasts.map((toast) => [toast.kind, toast.message])).toEqual([
|
||||
['error', ERROR_CODE_MESSAGES.AUTH_005],
|
||||
])
|
||||
expect(useAuthStore().isLoggedIn).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,55 @@
|
||||
<script setup lang="ts">
|
||||
// 설정 › 계정 › 비밀번호 (디자인 원본 SettingsAccount · README "4단계 › 비밀번호") — /settings/account/password · 설정의 첫 화면
|
||||
// 바꾸는 폼(PasswordChangeForm) 아래에 안내 상자(바탕 #F3F8F8): 다른 기기의 웹메일 로그인이 끊기는 것 · 메일 프로그램(IMAP)에 새 비밀번호를 다시 넣는 것
|
||||
import AppIcon from '@/components/AppIcon.vue'
|
||||
import PasswordChangeForm from '@/components/settings/PasswordChangeForm.vue'
|
||||
import SettingsPageFrame from '@/components/settings/SettingsPageFrame.vue'
|
||||
import { PASSWORD_TEXT } from '@/shared/constants/account'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<SettingsPageFrame
|
||||
title-id="password-page-title"
|
||||
:title="PASSWORD_TEXT.title"
|
||||
:description="PASSWORD_TEXT.description"
|
||||
>
|
||||
<PasswordChangeForm />
|
||||
<div
|
||||
class="password-note"
|
||||
data-password-note
|
||||
>
|
||||
<AppIcon
|
||||
name="info"
|
||||
class="password-note__icon"
|
||||
/>
|
||||
<div class="password-note__text">
|
||||
<span>{{ PASSWORD_TEXT.noteSessions }}</span>
|
||||
<span>{{ PASSWORD_TEXT.noteImap }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</SettingsPageFrame>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.password-note {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: var(--space-10);
|
||||
max-width: var(--note-max-width);
|
||||
padding: var(--space-12) var(--space-14);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--color-brand-tint);
|
||||
color: var(--color-text-strong);
|
||||
}
|
||||
|
||||
.password-note__icon {
|
||||
margin-top: var(--space-2);
|
||||
color: var(--color-brand-strong);
|
||||
}
|
||||
|
||||
.password-note__text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,288 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { mount, type VueWrapper } from '@vue/test-utils'
|
||||
import { createPinia, setActivePinia, type Pinia } from 'pinia'
|
||||
|
||||
import { ERROR_CODE_MESSAGES } from '@/shared/constants/error-codes.generated'
|
||||
import type { MailVacation } from '@/shared/types/mail'
|
||||
import { formatDate } from '@/shared/utils/format'
|
||||
import { endOfDayIso, startOfDayIso } from '@/shared/utils/mail-vacation'
|
||||
import { useAuthStore } from '@/stores/auth.store'
|
||||
import { useMailVacationStore } from '@/stores/mail-vacation.store'
|
||||
import { useToastStore } from '@/stores/toast.store'
|
||||
import {
|
||||
fail,
|
||||
flushPromises,
|
||||
installMockApi,
|
||||
ok,
|
||||
restoreApiAdapter,
|
||||
type MockRequest,
|
||||
type MockResult,
|
||||
} from '@/test-utils/mock-api'
|
||||
import { createTestRouter, routeViewProvide } from '@/test-utils/router'
|
||||
import MailVacationPage from './MailVacationPage.vue'
|
||||
|
||||
// 설정 › 메일 › 부재중 자동 응답 (디자인 원본 SettingsVacation · 계약 2절 "부재중 자동 응답")
|
||||
|
||||
const USER = { email: 'ara.kim@arasystem.it.kr', name: '김아라' }
|
||||
/** 설정한 적 없는 계정 — 꺼짐 · 빈 값 */
|
||||
const NEVER: MailVacation = { isEnabled: false, fromDate: null, toDate: null, subject: '', textBody: '' }
|
||||
const SAVED: MailVacation = {
|
||||
isEnabled: true,
|
||||
fromDate: startOfDayIso('2026-10-30'),
|
||||
toDate: endOfDayIso('2026-11-03'),
|
||||
subject: '부재중 안내 — 11월 4일 복귀',
|
||||
textBody: '안녕하세요, 김아라입니다.\n급한 일은 인프라팀으로 연락해 주세요.',
|
||||
}
|
||||
|
||||
interface Server {
|
||||
vacation?: MailVacation | MockResult
|
||||
save?: (request: MockRequest) => MockResult | Promise<MockResult>
|
||||
}
|
||||
|
||||
describe('MailVacationPage', () => {
|
||||
let pinia: Pinia
|
||||
let requests: MockRequest[]
|
||||
const mounted: VueWrapper[] = []
|
||||
|
||||
beforeEach(() => {
|
||||
pinia = createPinia()
|
||||
setActivePinia(pinia)
|
||||
useAuthStore().markAuthenticated(USER)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
for (const wrapper of mounted.splice(0)) {
|
||||
if (!wrapper.vm.$.isUnmounted) wrapper.unmount()
|
||||
}
|
||||
useToastStore().clear()
|
||||
restoreApiAdapter()
|
||||
document.body.innerHTML = ''
|
||||
})
|
||||
|
||||
async function mountPage(server: Server = {}) {
|
||||
requests = installMockApi((request) => {
|
||||
if (request.method === 'GET' && request.url === '/mail-vacation') {
|
||||
const vacation = server.vacation ?? NEVER
|
||||
return typeof vacation === 'string' || 'status' in vacation ? vacation : ok(vacation)
|
||||
}
|
||||
if (request.method === 'PUT' && request.url === '/mail-vacation') {
|
||||
return server.save ? server.save(request) : ok(request.data)
|
||||
}
|
||||
return ok(null)
|
||||
})
|
||||
const router = createTestRouter()
|
||||
await router.push('/settings/mail/vacation')
|
||||
await router.isReady()
|
||||
const wrapper = mount(MailVacationPage, {
|
||||
attachTo: document.body,
|
||||
global: { plugins: [pinia, router], provide: routeViewProvide(router) },
|
||||
})
|
||||
mounted.push(wrapper)
|
||||
await flushPromises()
|
||||
return { wrapper, router }
|
||||
}
|
||||
|
||||
const puts = () => requests.filter((request) => request.method === 'PUT').map((request) => request.data)
|
||||
const value = (wrapper: VueWrapper, selector: string) => (wrapper.get(selector).element as HTMLInputElement).value
|
||||
|
||||
async function save(wrapper: VueWrapper) {
|
||||
await wrapper.get('#vacation-save').trigger('click')
|
||||
await flushPromises()
|
||||
}
|
||||
|
||||
it('설정한 적 없는 계정 — 꺼짐 · 끌 때까지 · 빈 칸, 끈 동안은 아래 칸이 흐리다', async () => {
|
||||
const { wrapper } = await mountPage()
|
||||
|
||||
expect(wrapper.get('h1').text()).toBe('부재중 자동 응답')
|
||||
expect((wrapper.get('#vacation-enabled').element as HTMLInputElement).checked).toBe(false)
|
||||
expect((wrapper.get('#vacation-period-until-off').element as HTMLInputElement).checked).toBe(true)
|
||||
expect(wrapper.get('fieldset').attributes('disabled')).toBeDefined()
|
||||
expect(wrapper.get('#vacation-subject').attributes('disabled')).toBeDefined()
|
||||
expect(wrapper.get('#vacation-body').attributes('disabled')).toBeDefined()
|
||||
expect(wrapper.get('#vacation-subject').attributes('maxlength')).toBe('200')
|
||||
expect(wrapper.get('#vacation-body').attributes('maxlength')).toBe('4000')
|
||||
expect(wrapper.get('[data-vacation-preview-note]').text()).toBe('꺼져 있습니다 — 자동으로 답하지 않습니다.')
|
||||
})
|
||||
|
||||
it('저장한 값으로 채운다 — 날짜는 브라우저 시간대의 날짜로, 미리 보기는 보낸 사람 · 제목 · 내용', async () => {
|
||||
const { wrapper } = await mountPage({ vacation: SAVED })
|
||||
|
||||
expect((wrapper.get('#vacation-period-range').element as HTMLInputElement).checked).toBe(true)
|
||||
expect(value(wrapper, '#vacation-from')).toBe('2026-10-30')
|
||||
expect(value(wrapper, '#vacation-to')).toBe('2026-11-03')
|
||||
expect(wrapper.get('#vacation-period-hint').text()).toBe(
|
||||
'시작일 0시부터 끝나는 날 자정까지 답합니다 (이 브라우저의 시간대 기준).',
|
||||
)
|
||||
expect(value(wrapper, '#vacation-subject')).toBe(SAVED.subject)
|
||||
const preview = wrapper.get('[data-vacation-preview]')
|
||||
expect(preview.text()).toContain('김아라 <ara.kim@arasystem.it.kr>')
|
||||
expect(preview.get('[data-vacation-preview-subject]').text()).toBe(SAVED.subject)
|
||||
expect(preview.get('[data-vacation-preview-body]').element.textContent).toBe(SAVED.textBody)
|
||||
expect(wrapper.get('[data-vacation-preview-note]').text()).toBe(
|
||||
'받은 메일의 보낸 사람에게 위 글로 답합니다. 같은 사람에게 매번 보내지는 않습니다(메일 엔진 규칙).',
|
||||
)
|
||||
})
|
||||
|
||||
it('받는 동안은 자리만, 못 받으면 사전 문구와 "다시 시도"', async () => {
|
||||
let reply: MailVacation | MockResult = fail(503, 'MAIL_001')
|
||||
const { wrapper } = await mountPage({
|
||||
get vacation() {
|
||||
return reply
|
||||
},
|
||||
})
|
||||
|
||||
expect(wrapper.text()).toContain(ERROR_CODE_MESSAGES.MAIL_001)
|
||||
expect(useToastStore().toasts).toEqual([])
|
||||
reply = SAVED
|
||||
await wrapper.get('.mail-load-error__action').trigger('click')
|
||||
await flushPromises()
|
||||
expect(wrapper.find('#vacation-enabled').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('켜고 기간을 정해 저장 — 시작일 0시 · 끝나는 날 다음 날 0시를 UTC 로 보내고 "…부터 …까지 자동으로 답합니다."', async () => {
|
||||
const { wrapper } = await mountPage()
|
||||
await wrapper.get('#vacation-enabled').setValue(true)
|
||||
await wrapper.get('#vacation-period-range').setValue(true)
|
||||
// 날짜가 비었으면 시작을 오늘로 채운다
|
||||
expect(value(wrapper, '#vacation-from')).toBe(formatDate(new Date()))
|
||||
await wrapper.get('#vacation-from').setValue('2026-10-30')
|
||||
await wrapper.get('#vacation-to').setValue('2026-11-03')
|
||||
await wrapper.get('#vacation-subject').setValue('부재중 안내')
|
||||
await wrapper.get('#vacation-body').setValue('휴가입니다.')
|
||||
await save(wrapper)
|
||||
|
||||
expect(puts()).toEqual([
|
||||
{
|
||||
isEnabled: true,
|
||||
fromDate: new Date(2026, 9, 30).toISOString(),
|
||||
toDate: new Date(2026, 10, 4).toISOString(),
|
||||
subject: '부재중 안내',
|
||||
textBody: '휴가입니다.',
|
||||
},
|
||||
])
|
||||
expect(wrapper.get('#vacation-save-status').text()).toBe(
|
||||
'저장했습니다. 10월 30일(금)부터 11월 3일(화)까지 자동으로 답합니다.',
|
||||
)
|
||||
expect(useMailVacationStore().vacation?.isEnabled).toBe(true)
|
||||
|
||||
// 다시 바꾸면 내린다
|
||||
await wrapper.get('#vacation-subject').setValue('부재중 안내 (수정)')
|
||||
expect(wrapper.get('#vacation-save-status').text()).toBe('')
|
||||
})
|
||||
|
||||
it('켜 둘 때는 제목 · 내용 · 기간을 먼저 본다 — 칸 아래에 사전 문구(VAL_001), 첫 칸으로 초점 · 보내지 않는다', async () => {
|
||||
const { wrapper } = await mountPage()
|
||||
await wrapper.get('#vacation-enabled').setValue(true)
|
||||
await wrapper.get('#vacation-period-range').setValue(true)
|
||||
await wrapper.get('#vacation-from').setValue('2026-11-05')
|
||||
await wrapper.get('#vacation-to').setValue('2026-11-03')
|
||||
await save(wrapper)
|
||||
|
||||
expect(puts()).toEqual([])
|
||||
expect(wrapper.get('#vacation-period-error').text()).toBe(ERROR_CODE_MESSAGES.VAL_001)
|
||||
expect(wrapper.get('#vacation-subject-error').text()).toBe(ERROR_CODE_MESSAGES.VAL_001)
|
||||
expect(wrapper.get('#vacation-body-error').text()).toBe(ERROR_CODE_MESSAGES.VAL_001)
|
||||
expect(wrapper.get('#vacation-subject').attributes('aria-invalid')).toBe('true')
|
||||
expect(document.activeElement?.id).toBe('vacation-from')
|
||||
|
||||
// 고친 칸의 문구만 내린다
|
||||
await wrapper.get('#vacation-subject').setValue('부재중 안내')
|
||||
expect(wrapper.find('#vacation-subject-error').exists()).toBe(false)
|
||||
expect(wrapper.find('#vacation-body-error').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('끄면 빈 제목 · 내용도 저장된다 — 쓴 글은 남기고 "자동 응답을 껐습니다 — 써 둔 글은 남겨 둡니다."', async () => {
|
||||
const { wrapper } = await mountPage({ vacation: SAVED })
|
||||
await wrapper.get('#vacation-enabled').setValue(false)
|
||||
expect(value(wrapper, '#vacation-subject')).toBe(SAVED.subject)
|
||||
await save(wrapper)
|
||||
|
||||
expect(puts()).toEqual([{ ...SAVED, isEnabled: false }])
|
||||
expect(wrapper.get('#vacation-save-status').text()).toBe('저장했습니다. 자동 응답을 껐습니다 — 써 둔 글은 남겨 둡니다.')
|
||||
|
||||
// 설정한 적 없는 계정을 그대로 꺼 둔 채 저장해도 된다
|
||||
const { wrapper: fresh } = await mountPage()
|
||||
await save(fresh)
|
||||
expect(puts().at(-1)).toEqual(NEVER)
|
||||
})
|
||||
|
||||
it('끌 때까지 계속 — 두 날짜 모두 null · "끌 때까지 자동으로 답합니다."', async () => {
|
||||
const { wrapper } = await mountPage({ vacation: SAVED })
|
||||
await wrapper.get('#vacation-period-until-off').setValue(true)
|
||||
expect(wrapper.find('#vacation-from').exists()).toBe(false)
|
||||
await save(wrapper)
|
||||
|
||||
expect(puts()).toEqual([{ ...SAVED, fromDate: null, toDate: null }])
|
||||
expect(wrapper.get('#vacation-save-status').text()).toBe('저장했습니다. 끌 때까지 자동으로 답합니다.')
|
||||
})
|
||||
|
||||
it('서버가 거절하면(VAL_001) 알림 없이 저장 단추 옆에 사전 문구', async () => {
|
||||
const { wrapper } = await mountPage({ vacation: SAVED, save: () => fail(400, 'VAL_001') })
|
||||
await save(wrapper)
|
||||
|
||||
expect(wrapper.get('#vacation-save-status').text()).toBe(ERROR_CODE_MESSAGES.VAL_001)
|
||||
expect(useToastStore().toasts).toEqual([])
|
||||
})
|
||||
|
||||
it('저장하는 사이 화면을 떠났는데 서버가 거절하면(VAL_001) 볼 곳이 없으니 알림으로 — 떠날 때는 묻지 않는다', async () => {
|
||||
let finish: (result: MockResult) => void = () => undefined
|
||||
const { wrapper, router } = await mountPage({
|
||||
vacation: SAVED,
|
||||
save: () =>
|
||||
new Promise<MockResult>((resolve) => {
|
||||
finish = resolve
|
||||
}),
|
||||
})
|
||||
await wrapper.get('#vacation-subject').setValue('바꾼 제목')
|
||||
await save(wrapper)
|
||||
expect(wrapper.get('#vacation-save').attributes('aria-busy')).toBe('true')
|
||||
|
||||
await router.push('/mail/inbox')
|
||||
expect(router.currentRoute.value.fullPath).toBe('/mail/inbox')
|
||||
wrapper.unmount()
|
||||
|
||||
finish(fail(400, 'VAL_001'))
|
||||
await flushPromises()
|
||||
expect(useToastStore().toasts.map((toast) => [toast.kind, toast.message])).toEqual([
|
||||
['error', ERROR_CODE_MESSAGES.VAL_001],
|
||||
])
|
||||
})
|
||||
|
||||
describe('나가기 확인 (서명 설정과 같다)', () => {
|
||||
const dialogOpen = () => document.getElementById('vacation-leave-dialog')?.hasAttribute('open') ?? false
|
||||
|
||||
it('바꾸고 떠나면 묻는다 — [계속 고치기]는 머물고 [나가기]는 떠난다', async () => {
|
||||
const { wrapper, router } = await mountPage({ vacation: SAVED })
|
||||
await wrapper.get('#vacation-subject').setValue('바꾼 제목')
|
||||
|
||||
const stay = router.push('/mail/inbox')
|
||||
await flushPromises()
|
||||
expect(dialogOpen()).toBe(true)
|
||||
expect(wrapper.get('#vacation-leave-title').text()).toBe('저장하지 않은 자동 응답이 있습니다')
|
||||
expect(wrapper.get('#vacation-leave-message').text()).toBe('이 화면을 나가면 바꾼 내용이 사라집니다.')
|
||||
expect(document.activeElement?.id).toBe('vacation-leave-stay')
|
||||
await wrapper.get('#vacation-leave-stay').trigger('click')
|
||||
await stay
|
||||
expect(router.currentRoute.value.fullPath).toBe('/settings/mail/vacation')
|
||||
|
||||
const leave = router.push('/mail/inbox')
|
||||
await flushPromises()
|
||||
await wrapper.get('#vacation-leave-confirm').trigger('click')
|
||||
await leave
|
||||
expect(router.currentRoute.value.fullPath).toBe('/mail/inbox')
|
||||
})
|
||||
|
||||
it('되돌렸거나 저장했으면 묻지 않는다', async () => {
|
||||
const { wrapper, router } = await mountPage({ vacation: SAVED })
|
||||
await wrapper.get('#vacation-subject').setValue('바꾼 제목')
|
||||
await wrapper.get('#vacation-subject').setValue(SAVED.subject)
|
||||
await wrapper.get('#vacation-body').setValue('바꾼 내용')
|
||||
await save(wrapper)
|
||||
|
||||
await router.push('/mail/inbox')
|
||||
await flushPromises()
|
||||
expect(dialogOpen()).toBe(false)
|
||||
expect(router.currentRoute.value.fullPath).toBe('/mail/inbox')
|
||||
})
|
||||
})
|
||||
})
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user