diff --git a/.claude/agents/backend-dev.md b/.claude/agents/backend-dev.md index 2188afa..7f0d906 100644 --- a/.claude/agents/backend-dev.md +++ b/.claude/agents/backend-dev.md @@ -26,7 +26,7 @@ tools: Read, Grep, Glob, Edit, Write, Bash, Skill ## 작업 순서 -1. 만들기 전에 `/nestjs` 의 "템플릿 기본 제공 골격"에서 재사용할 패턴을 찾는다. 같은 역할을 새로 만들지 않는다. +1. 만들기 전에 `/nestjs` 의 "기본 골격"에서 재사용할 패턴을 찾는다. 같은 역할을 새로 만들지 않는다. 2. 실패 경로마다 에러 코드를 정한다. 코드 없는 실패는 만들지 않는다 ([[error-codes]]). 3. 목록 API 면 페이지네이션을 적용한다 ([[response-format]]). 4. Swagger 데코레이터를 함께 쓴다 ([[api-design]]). diff --git a/.claude/rules/checklist.md b/.claude/rules/checklist.md index 8885e49..914f644 100644 --- a/.claude/rules/checklist.md +++ b/.claude/rules/checklist.md @@ -1,11 +1,11 @@ -# 작업 점검 & 신규 프로젝트 셋업 +# 작업 점검 & 새 환경 셋업 -> 이 저장소는 **재사용 템플릿**이다. 클론해 어떤 프로젝트든 시작할 수 있도록, 여기에는 **언제나 유효한 공용 규칙**만 둔다(특정 시점의 구현 상태는 두지 않는다). -> 각 스택이 기본 제공하는 골격과 확장 규칙은 [[nestjs]] · [[vue3]] · [[flutter]] · [[docker]] 스킬 참조. +> 여기에는 **언제나 유효한 점검 규칙**만 둔다(특정 시점의 구현 상태는 두지 않는다 — 지금 상태는 각 스킬 · API 계약 · [로드맵](../../docs/roadmap.md)). +> 각 스택에 이미 있는 골격과 확장 규칙은 [[nestjs]] · [[vue3]] · [[flutter]] · [[docker]] 스킬 참조. --- -## 1. 신규 프로젝트 셋업 (클론 직후 1회) +## 1. 새 개발 환경 · 새 서버 셋업 (처음 한 번) - [ ] 루트 `.env.{env}.example` → `.env.{env}` 복사 후 **placeholder 값 교체** (`change-me-*` 가 남아 있으면 안 된다, [[env-structure]]) 운영은 `BACKEND_API_URL`·`CORS_ORIGIN` 을 **실제 도메인**으로 — `localhost`/`example.com` 이 남으면 기동 실패한다 @@ -85,10 +85,10 @@ ## 6. 기능 확장 시 따르는 순서 -새 기능은 만들기 전에, 해당 스킬의 **"템플릿 기본 제공 골격"** 에서 재사용할 패턴을 먼저 찾는다(같은 역할을 새로 만들지 말 것). +새 기능은 만들기 전에, 해당 스킬의 **"기본 골격"** 에서 재사용할 패턴을 먼저 찾는다(같은 역할을 새로 만들지 말 것). 1. 해당 디렉터리 스킬 규칙 확인 ([[nestjs]] / [[vue3]] / [[flutter]]) -2. 기본 제공 골격(인터셉터·필터·에러 레지스트리·dio client·예제 모듈)에서 패턴 재사용 +2. 기본 골격(인터셉터·필터·에러 레지스트리·dio client·예제 모듈)에서 패턴 재사용 3. 표준 응답/에러 흐름 준수 ([[response-format]] · [[error-codes]] · [[api-design]]) 4. 실패 경로마다 에러 코드 지정 — `BusinessException('CODE')` 로 던진다 5. env 신규 값은 루트 `.env` 부터 ([[env-structure]]) diff --git a/.claude/rules/env-structure.md b/.claude/rules/env-structure.md index 6f0f15e..466976e 100644 --- a/.claude/rules/env-structure.md +++ b/.claude/rules/env-structure.md @@ -5,7 +5,7 @@ description: 환경변수 단일 진실 공급원(SSOT) 구조 — 루트 .env.{ # 환경변수 구조 (단일 진실 공급원: 루트 `.env`) -이 템플릿은 **루트 `.env.{APP_ENV}` 가 모든 설정의 단일 진실 공급원(SSOT)** 이다. +이 저장소는 **루트 `.env.{APP_ENV}` 가 모든 설정의 단일 진실 공급원(SSOT)** 이다. 값을 한 곳에서만 관리하기 위해, 서비스(backend/frontend)는 자체 env에 값을 중복 작성하지 않고 **루트 → docker-compose 가 주입한 값을 그대로 사용**한다. ``` diff --git a/.claude/rules/security.md b/.claude/rules/security.md index b14ac9b..823733b 100644 --- a/.claude/rules/security.md +++ b/.claude/rules/security.md @@ -64,7 +64,7 @@ `img onerror` 같은 처리기가 실행된다. CSP 가 막아도 그것에만 기대지 않는다 ([[vue3]] 체크리스트 "외부 HTML") ### 파일 업로드 -템플릿은 `modules/upload` 에 로컬 디스크 기준 골격을 제공한다. 새 업로드 엔드포인트는 이 패턴을 복제한다. +`modules/upload` 에 로컬 디스크 기준 골격이 있다. 새 업로드 엔드포인트는 이 패턴을 복제한다. - **확장자와 MIME 을 둘 다** 검사한다 (`upload.config.ts`). 확장자만 보면 위장이 쉽고, MIME 만 보면 클라이언트가 보낸 값이라 신뢰할 수 없다. diff --git a/.claude/rules/testing.md b/.claude/rules/testing.md index 5e8a33f..6fc1ea9 100644 --- a/.claude/rules/testing.md +++ b/.claude/rules/testing.md @@ -5,7 +5,7 @@ description: 세 스택 공통 테스트 규칙 — 테스트 위치/네이밍, # 테스트 규칙 -> 템플릿은 각 스택에 테스트 러너가 준비되어 있다 — backend `jest`, frontend `vitest`, flutter `flutter_test`. +> 각 스택에 테스트 러너가 준비되어 있다 — backend `jest`, frontend `vitest`, flutter `flutter_test`. > 새 로직을 추가하면 최소한의 테스트를 함께 작성한다. ## 공통 원칙 diff --git a/.claude/skills/flutter/SKILL.md b/.claude/skills/flutter/SKILL.md index 32395f1..dfb7c1a 100644 --- a/.claude/skills/flutter/SKILL.md +++ b/.claude/skills/flutter/SKILL.md @@ -10,9 +10,9 @@ description: Flutter 모바일 앱(flutter/) 개발 규칙 — Dart Sound Null S --- -## 템플릿 기본 제공 골격 +## 기본 골격 -> 이 템플릿은 네트워크 · 세션 · 라우터 · 모듈 틀을 **기본 제공**한다. 새 코드는 이 파일들의 패턴을 재사용한다(같은 역할을 새로 만들지 말 것). +> 네트워크 · 세션 · 라우터 · 모듈 틀이 이미 있다. 새 코드는 이 파일들의 패턴을 재사용한다(같은 역할을 새로 만들지 말 것). > 앱 이름 "ARA SYSTEM", 식별자 `kr.it.arasystem.app`(Android applicationId · iOS bundle id — 직원 휴대폰에 설치한 뒤에는 못 바꾼다: 식별자가 다르면 다른 앱이라 그 위에 설치되지 않는다), 패키지 `arasystem_app`. | 제공 항목 | 위치 | 비고 | @@ -42,7 +42,7 @@ description: Flutter 모바일 앱(flutter/) 개발 규칙 — Dart Sound Null S | **앱 설정 · 업데이트 화면** | `lib/core/app_config/`(`app_config.dart` — 판 비교 `AppVersion` · 모양, `app_config_repository.dart`, `app_config_controller.dart` — `appUpdateRequiredProvider` · `pushAvailableProvider`) · `core/router/session_redirect.dart`(`updateRequired` 가 세션보다 먼저) · `features/app_update/`(모듈이 아니다 — 화면 · 글) · `core/app_download/`([새 버전 받기] — 받기 화면 주소 `app_download_link.dart` · 런처 `AppDownloadLauncher` · 글, 업데이트 화면과 설정 › 앱 정보가 함께 쓴다) | `GET /api/app-config` 는 띄울 때 한 번 · 앞으로 올 때 마지막으로 받은 지 1시간이 지났으면(실패는 1분 간격으로 다시) — 알리지 않고 막지 않는다. 404 `RES_001` 은 옛 서버(최소 판 없음 · 알림 쓸 수 없음). 판은 숫자 1~3칸 · 칸마다 4자리까지(서버의 판 칸 `APP_VERSION_PATTERN` 과 같다 — `-` · `+` 뒤는 버린다), 이 앱의 판을 모르면 막지 않는다. 업데이트 화면으로 옮길 때 쓰기 화면은 마저 저장한다(`ComposeController.detach(saveRemaining: true)` — 화면이 밖에서 닫힌 때도). 앱은 스토어에 올리지 않고 사내에 직접 나눠 준다(2026-11-08 — 빌드 · 서명 · 올리기는 `flutter/README.md` "사내 배포"). [새 버전 받기]는 포털의 휴대폰 앱 받기 화면 `/app`(`appDownloadPageUrlOf` — `/api` 를 뗀 출처 + 경로, 짝: 웹 `mobile-app.ts` 의 `MOBILE_APP_PATH`)을 바깥 브라우저로(`LaunchMode.externalApplication` — `canLaunchUrl` 은 쓰지 않는다) 연다. 열지 못하면 스킴 없는 주소를 담아 알린다(`openAppDownloadPage` — 두 화면이 같은 알림). 앱 설정 계약은 받는 곳 주소를 싣지 않는다 | | **설정 탭 (`App1Shell` · `App1Push` ④)** | `lib/features/settings/` — `settings_routes.dart`(`/settings` · `/settings/licenses` · 모듈이 내놓은 절) · `presentation/pages/settings_page.dart` · `widgets/settings_notification_row.dart` · `settings_texts.dart` | 한 화면(내 정보 · 알림 · 모듈 절 · 앱 정보(앱 버전 · [새 버전 받기] — 끝 아이콘이 바깥으로 여는 아이콘, 업데이트 화면과 같은 런처 · 오픈소스 라이선스) · 문의 · [로그아웃]). 문의는 로그인 화면과 같은 저장소(`supportContactProvider`), [로그아웃]은 확인 창(`shared/widgets/confirm_dialog.dart` — 처음 초점 [취소]). 스위치 색은 테마의 `switchTheme`(보드 — 켬 청록 · 끔 테두리 · 누를 수 없음 회색) | | 자리 화면 | `lib/shared/widgets/placeholder_page.dart` | 화면은 캔버스로 확인받은 뒤 만든다 — 그때까지 이름(과 갈 곳)만 보이는 자리 화면. 확정되면 라우트 묶음의 builder 를 바꾼다 | -| 예제 feature(템플릿) | `lib/features/user/` | 목록 페이지네이션(`{items, pageInfo}` — 단건 + 커서 목록) 예제로만 남긴 템플릿. 앱 API 에 `/users` 는 없다 | +| 예제 feature(참조용) | `lib/features/user/` | 목록 페이지네이션(`{items, pageInfo}` — 단건 + 커서 목록) 예제로만 남겼다. 앱 API 에 `/users` 는 없다 | | 시험 도구 | `test/support/` — `fakes.dart` · `provider_harness.dart` | 가짜 서버(`FakeServer` — HttpClientAdapter) · 메모리 보안 저장소 · 손 시계(`ManualClock`) · 알림 기록(`RecordingNotifier`). 고정 대기 없이 Completer 로 차례를 맞춘다 ([[testing]]) | > **확장 시 따르는 규칙** (아래 "프로젝트 구조"는 목표 구조 — 기능 추가 시 점진적으로 채운다): diff --git a/.claude/skills/nestjs/SKILL.md b/.claude/skills/nestjs/SKILL.md index 0a70ef2..fe8e325 100644 --- a/.claude/skills/nestjs/SKILL.md +++ b/.claude/skills/nestjs/SKILL.md @@ -9,9 +9,9 @@ description: NestJS 백엔드(backend/) 개발 규칙 — 컨트롤러/서비스 --- -## 템플릿 기본 제공 골격 +## 기본 골격 -> 이 템플릿은 아래 인프라를 **기본 제공**한다. 새 코드는 이 파일들의 패턴을 재사용한다(같은 역할을 새로 만들지 말 것). +> 아래 인프라가 이미 있다. 새 코드는 이 파일들의 패턴을 재사용한다(같은 역할을 새로 만들지 말 것). | 제공 항목 | 위치 | 비고 | | --- | --- | --- | @@ -32,13 +32,13 @@ description: NestJS 백엔드(backend/) 개발 규칙 — 컨트롤러/서비스 | 길이 검사 데코레이터 | `src/common/decorators/code-units.decorator.ts` | `MaxCodeUnits` · `MinCodeUnits` — JS 문자열 길이(UTF-16 코드 단위)로 센다(웹의 `.length` 와 같다). class-validator 의 `MinLength`·`MaxLength` 는 서로게이트 쌍 · 이모지를 덜 세어 클라이언트가 센 길이와 어긋난다 — 클라이언트와 같은 한도를 거는 문자열은 이것을 쓴다 | | 공백 · 중복 데코레이터 | `src/common/decorators/trim-string.decorator.ts` · `array-distinct.decorator.ts` | `TrimString()` — 앞뒤 공백을 지운 값으로 검사하고 그 값으로 저장한다(메일함 이름 · 필터의 이름 · 찾을 글 · 전달 주소). `ArrayDistinct(options)` — 배열 항목이 겹치지 않는지 Set 으로 한 번 훑는다(선형 — 원시 값은 값으로, 객체는 같은 객체일 때만. 객체 배열의 id 중복은 서비스가 Set 으로 본다 — 예: 필터 규칙 `findFilterRulesProblem`). class-validator 의 `ArrayUnique` 는 `indexOf` 비교라 항목 수의 제곱이다 — **쓰지 않는다** | | 직원 인증 | `modules/auth` | 쿠키 JWT 로그인·`me`·`refresh`·`logout` (계약 `docs/mail/api-contract.md` 1절) · **앱 전용 토큰 경로** `app-auth.controller.ts`(앱 1단계 — `/api/auth/app/login`·`refresh`·`logout`, 계약 1절 "앱 로그인": 웹과 같은 `AuthService` 함수를 지나 실패 카운터 · 코드 가드 · 앱 비밀번호 거절 · 막는 계정이 같고, 로그인 요청 수 상한은 웹 로그인과 같은 칸(`auth.constants.ts` 의 `LOGIN_THROTTLE` — 키를 같은 이름으로). 토큰은 응답 본문 `tokens`(끝나는 때는 토큰의 exp · `private, no-store`), 앱 토큰 둘에 `cli: 'app'`, 세션 표 `client`(web · app — 마이그레이션 `AddAppSessionClient`, DB 기본값 web — 칸을 모르는 코드가 넣어도 웹), 갱신은 같은 클라이언트의 리프레시 토큰으로만(다르면 `AUTH_001`, 세션은 끊지 않는다) · **앱 세션의 30초 유예는 그 세션을 끝낸다**(사유 `app-refresh-grace` — 앱은 갱신을 한 번에 하나만 하므로 응답을 잃었거나 토큰이 두 번 쓰였다, 응답은 웹과 같은 200 `tokens: null`). 본문 경계: `refreshToken` 이 없거나 글이 아니면 400 `VAL_001`, 빈 글 · 2,048자(`MAX_REFRESH_TOKEN_LENGTH`) 넘음은 서명을 보기 전에 틀린 토큰(갱신 401 `AUTH_001`). 로그아웃은 본문의 리프레시 토큰 → 없거나 · 빈 글 · 길거나 · 틀리면 Bearer 의 sid — 본문 모양(글이 아님 · 모르는 칸)이 틀릴 때만 400, 그 밖은 200. 앱 표시가 있는데 sid 가 UUID 가 아닌 액세스 토큰은 가드가 `AUTH_001`. 앱 요청의 "지금 세션"은 리프레시 쿠키가 없어 액세스 토큰의 sid — `AuthService.requestSessionId`, 비밀번호 바꾸기 · 로그인된 기기가 쓴다) · 비밀번호 변경 `PUT /api/auth/password`(4단계 — 직원 본인 자격으로 엔진의 `x:AccountPassword/set`, 로그인과 같은 실패 카운터 · 진행 중 잠금, 바뀌면 이 요청의 세션만 남기고 그 직원의 세션을 끊는다 — 결과를 모르는 실패에도 끊는다). 설정 `auth.settings.ts`, 쿠키 `auth-cookies.ts`, 로그인 실패 횟수 `login-attempt.service.ts`, **로그인 세션(DB)** `auth-session.entity.ts`·`auth-session.policy.ts`(1회용 회전·재사용 감지)·`auth-session.cleanup.ts`(끝난 세션 정리 cron). **메일 엔진 관리자 차단** — 정적 목록(`employee-address.ts` + `MAIL_BLOCKED_ACCOUNTS`)과 로그인·리프레시 때 엔진 권한 확인(`jmap/jmap.client.ts`) 두 겹 | -| 메일 (웹메일 API) | `modules/mail` | 엔진 포트 `mail.provider.ts`(`MAIL_PROVIDER`) + Stalwart JMAP 어댑터 `jmap/`, 받은 메일 HTML 정화 `html/sanitize-mail-html.ts`, 보내는 서식 본문 정화(허용 목록) `html/sanitize-outgoing-html.ts`, 두 정화 앞의 사전 검사(길이·파서 스택 비용, 선형 시간) `html/html-parse-budget.ts`, 첨부 형식 `attachment.policy.ts`, 설정 `mail.settings.ts`, 발송 중복 방지(`Idempotency-Key`) `send-idempotency.ts`, 발송의 받는 사람별 결과 `jmap/delivery-status.ts`(제출 다음에 따로 읽는다 — 일부 거절은 201 의 `rejectedRecipients`, 모두 거절이면 보낸 사본을 지우고 `MAIL_010` · `MAIL_011`, 계약 2절 "받는 사람별 결과"), 첨부 임시 디스크 저장 `mail-upload-storage.ts`, 임시 저장 `draft.controller.ts`(엔진 초안 — 새로 만들고 다시 읽은 뒤 옛것을 지운다), 서명 그림 주소 규칙 `html/signature-images.ts`(보낼 때 `cid:` 로), 본문 그림 판정 `html/inline-images.ts`, 받는 사람 추천 `recipient-suggestions.service.ts`(사용자별 10분 캐시) · 순서 `recipient-ranking.ts` · 커서 `recipient-suggestion-cursor.ts` · q 맞추기 `recipient-match.ts`(8단계 — **frontend `shared/utils/recipient-match.ts` 와 짝**, 같은 표본 표를 두 쪽 테스트에 둔다 — 한쪽을 고치면 둘 다. 이름은 코드 포인트로 한 번 훑는다 — 길이에 비례), 검색어 → 엔진 조건 `jmap/search-terms.ts`(한국어 조사 표는 여기 한 곳 — 낱말 안 OR · 낱말끼리 AND, 따옴표 구절), 여러 통 `PATCH /api/messages`(엔진 요청 한 번) · 휴지통에서 영구 삭제 `message-deletion.controller.ts`(휴지통의 메일만, 조건부 지우기) — 본문 `dto/bulk-messages.dto.ts`, 새 메일 확인 `GET /api/mail-state`(`mailbox.controller.ts`), 메일함 관리 · 전체 읽음 · 비우기(`mailbox.controller.ts`, 본문 `dto/mailbox.dto.ts` — 오래 걸리는 처리는 어댑터(`BulkRun` — `jmap/bulk-run.ts`)가 500통씩 · 한 요청 25초 안에서(엔진 호출마다 남은 시간만큼) 하고 남은 수를 돌려준다, 같은 직원 · 같은 메일함은 한 번에 하나(`mail.service.ts` 의 `mailboxOperations` — 진행 중이면 `REQ_001`), 지우기는 안의 메일을 휴지통으로 옮긴 뒤) · 부재중 자동 응답 `MailVacationController`(`dto/mail-vacation.dto.ts` — 6단계부터 저장 자리는 아래 필터의 Sieve 스크립트. 응답의 `isActive`(7단계)는 엔진에서 그 스크립트가 켜져 있는지 — 본문으로는 받지 않는다) · 메일 필터 `mail-filter.controller.ts`(6단계 — `GET`·`PUT /api/mail-filters`, 본문 `dto/mail-filter.dto.ts`, 칸 사이 규칙 `mail-filters.ts`. 엔진 쪽은 Sieve 스크립트 하나(`arasystem` — 필터와 **부재중을 함께**, 엔진의 VacationResponse 는 쓰지 않고 처음 읽을 때만 이어받는다): 저장소 `jmap/jmap-filter-script.ts`(읽은 상태 그대로일 때만 쓰고 켠다 · 그사이 바뀌면 다시 읽기 · 같은 규칙이면 옛 version 이어도 저장), 글 만들기 `jmap/sieve-script.ts`, 머리 주석의 원본(JSON · 판 `FILTER_SOURCE_FORMAT`) `jmap/filter-source.ts`, 부재중 제목 · 본문 인코딩 `jmap/mime-text.ts`, JMAP 결과 읽기 `jmap/jmap-results.ts`) · 이미 받은 메일에 필터 적용 `POST /api/mail-filter-runs`(7단계 — `mail-filter.controller.ts`, 본문 `dto/mail-filter-run.dto.ts`. 저장한 필터가 아니라 본문의 규칙으로 받은편지함을 최근 메일부터 판정하고 표시 · 옮기기만 한다(전달 · 지우기 없음). 판정 `mail-filter-match.ts` 는 **`jmap/sieve-script.ts` 의 `FIELD_TESTS` 와 짝**이다 — 엔진 검색은 낱말 단위라 쓰지 않고 Sieve 의 판정을 그대로 옮겼다(한쪽을 바꾸면 다른 쪽도). 견줄 글 `jmap/filter-run-texts.ts`(원래 머리글 `header:From:all` 등 — `:asRaw` 는 붙이지 않는다(엔진이 응답의 이름에서 뗀다), 풀기 `jmap/mime-text.ts` 의 `decodeHeaderText` — 접힌 줄은 띄어쓰기 하나, 인코딩된 글은 홀로 선 것만), HTML 글자 `html/html-text.ts`(엔진의 `body :text` 와 같게 — 닫는 `p` · `br` 만 줄을 끊고 `style` · `script` · `head` · `template` 은 글째 빼며 그 밖의 태그는 빈자리 없이 뺀다. 속성이 붙은 태그는 엔진처럼 알아보지 않는다 — 2026-10-29 인증 발송 네 묶음으로 맞춤, 규칙은 계약 2절), 어댑터 `jmap/jmap-filter-run.ts`(머리글 500통 · 본문 100통 차례, 본문 값은 부분마다 256KiB · 한 통 1MiB — 바깥 메일 한 통이 메모리를 크게 쓰지 않게), 이어 할 자리 `jmap/filter-run-cursor.ts`(2판 — 위 경계 · anchor · 그 받은 시각 · 규칙 지문. 모든 조회에 `before` 를 걸어 시작한 뒤 온 메일은 보지 않고, 빠진 anchor 는 그 초부터 다시 읽는다). 한 요청의 시간은 메일함 처리와 같은 `BulkRun`, 같은 직원은 한 번에 하나(`mail.service.ts` 의 `filterRuns` — 진행 중이면 `REQ_001`)) · 원문 `GET /api/messages/{id}/source`(이름 `attachment.policy.ts` 의 `messageSourceFileName`) · 스팸 표시 `isJunk` (계약 2절) · 실시간 알림 `GET /api/mail-events`(5단계 — SSE, `mail-events.controller.ts` · 엔진을 모르는 `mail-events.service.ts`(직원마다 연결 5개 · 25초 ping · 토큰 만료 30초 전 `end` · 종료 때 `end`) · 엔진 쪽 `jmap/jmap-mail-watch.ts`(직원마다 엔진 EventSource 하나 · 75초 무소식이면 다시 잇기) · `jmap/jmap-push.ts`(스트림 읽기) · `jmap/new-mail.ts`(새 메일 거르기), SSE 형식 `shared/utils/sse.ts`. 머리글은 스트림을 열기로 정하면 바로(`flushHeaders`), 처리기에 닿기 전에 끊긴 응답(`res.destroyed`)도 정리한다) | +| 메일 (웹메일 API) | `modules/mail` | 엔진 포트 `mail.provider.ts`(`MAIL_PROVIDER`) + Stalwart JMAP 어댑터 `jmap/`, 받은 메일 HTML 정화 `html/sanitize-mail-html.ts`, 보내는 서식 본문 정화(허용 목록) `html/sanitize-outgoing-html.ts`, 두 정화 앞의 사전 검사(길이·파서 스택 비용, 선형 시간) `html/html-parse-budget.ts`, 첨부 형식 `attachment.policy.ts`, 설정 `mail.settings.ts`, 발송 중복 방지(`Idempotency-Key`) `send-idempotency.ts`, 발송의 받는 사람별 결과 `jmap/delivery-status.ts`(제출 다음에 따로 읽는다 — 일부 거절은 201 의 `rejectedRecipients`, 모두 거절이면 보낸 사본을 지우고 `MAIL_010` · `MAIL_011`, 계약 2절 "받는 사람별 결과"), 첨부 임시 디스크 저장 `mail-upload-storage.ts`, 임시 저장 `draft.controller.ts`(엔진 초안 — 새로 만들고 다시 읽은 뒤 옛것을 지운다), 서명 그림 주소 규칙 `html/signature-images.ts`(보낼 때 `cid:` 로), 본문 그림 판정 `html/inline-images.ts`, 받는 사람 추천 `recipient-suggestions.service.ts`(사용자별 10분 캐시) · 순서 `recipient-ranking.ts` · 커서 `recipient-suggestion-cursor.ts` · q 맞추기 `recipient-match.ts`(8단계 — **frontend `shared/utils/recipient-match.ts` 와 짝**, 같은 표본 표를 두 쪽 테스트에 둔다 — 한쪽을 고치면 둘 다. 이름은 코드 포인트로 한 번 훑는다 — 길이에 비례), 검색어 → 엔진 조건 `jmap/search-terms.ts`(한국어 조사 표는 여기 한 곳 — 낱말 안 OR · 낱말끼리 AND, 따옴표 구절), 여러 통 `PATCH /api/messages`(엔진 요청 한 번) · 휴지통에서 영구 삭제 `message-deletion.controller.ts`(휴지통의 메일만, 조건부 지우기) — 본문 `dto/bulk-messages.dto.ts`, 새 메일 확인 `GET /api/mail-state`(`mailbox.controller.ts`), 메일함 관리 · 전체 읽음 · 비우기(`mailbox.controller.ts`, 본문 `dto/mailbox.dto.ts` — 오래 걸리는 처리는 어댑터(`BulkRun` — `jmap/bulk-run.ts`)가 500통씩 · 한 요청 25초 안에서(엔진 호출마다 남은 시간만큼) 하고 남은 수를 돌려준다, 같은 직원 · 같은 메일함은 한 번에 하나(`mail.service.ts` 의 `mailboxOperations` — 진행 중이면 `REQ_001`, 여러 메일함은 한꺼번에 잡는다), 지우기는 안의 메일을 휴지통으로 옮긴 뒤. 15단계 — **옮기기**는 `PATCH` 의 `parentId`(`UpdateMailboxDto` — 이름 · 상위 가운데 보낸 것만, 자리는 만들기와 같고 자기 아래는 서버가 먼저 막는다 · 엔진의 고리 거절은 같은 요청에서 다시 읽은 목록으로 가른다, 옮길 때만 그 메일함과 새 상위를 잡는다), **하위까지 지우기**는 `?withChildren=true`(`MailboxDeleteQueryDto` — 없으면 지금처럼 `VAL_001`): 서비스가 그 메일함을 먼저 잡고(처리 중이면 엔진에 묻지 않고 `REQ_001`) 목록을 읽어 하위 메일함까지 잡고, 어댑터가 나무의 메일을 한 조회(`inMailbox` OR)로 옮긴 뒤 깊은 것부터 한 요청에 지운다(엔진은 destroy 를 적은 차례대로 한다). 나무 계산은 순수 함수 `mailbox-tree.ts`) · 부재중 자동 응답 `MailVacationController`(`dto/mail-vacation.dto.ts` — 6단계부터 저장 자리는 아래 필터의 Sieve 스크립트. 응답의 `isActive`(7단계)는 엔진에서 그 스크립트가 켜져 있는지 — 본문으로는 받지 않는다) · 메일 필터 `mail-filter.controller.ts`(6단계 — `GET`·`PUT /api/mail-filters`, 본문 `dto/mail-filter.dto.ts`, 칸 사이 규칙 `mail-filters.ts`. 엔진 쪽은 Sieve 스크립트 하나(`arasystem` — 필터와 **부재중을 함께**, 엔진의 VacationResponse 는 쓰지 않고 처음 읽을 때만 이어받는다): 저장소 `jmap/jmap-filter-script.ts`(읽은 상태 그대로일 때만 쓰고 켠다 · 그사이 바뀌면 다시 읽기 · 같은 규칙이면 옛 version 이어도 저장), 글 만들기 `jmap/sieve-script.ts`, 머리 주석의 원본(JSON · 판 `FILTER_SOURCE_FORMAT`) `jmap/filter-source.ts`, 부재중 제목 · 본문 인코딩 `jmap/mime-text.ts`, JMAP 결과 읽기 `jmap/jmap-results.ts`) · 이미 받은 메일에 필터 적용 `POST /api/mail-filter-runs`(7단계 — `mail-filter.controller.ts`, 본문 `dto/mail-filter-run.dto.ts`. 저장한 필터가 아니라 본문의 규칙으로 받은편지함을 최근 메일부터 판정하고 표시 · 옮기기만 한다(전달 · 지우기 없음). 판정 `mail-filter-match.ts` 는 **`jmap/sieve-script.ts` 의 `FIELD_TESTS` 와 짝**이다 — 엔진 검색은 낱말 단위라 쓰지 않고 Sieve 의 판정을 그대로 옮겼다(한쪽을 바꾸면 다른 쪽도). 견줄 글 `jmap/filter-run-texts.ts`(원래 머리글 `header:From:all` 등 — `:asRaw` 는 붙이지 않는다(엔진이 응답의 이름에서 뗀다), 풀기 `jmap/mime-text.ts` 의 `decodeHeaderText` — 접힌 줄은 띄어쓰기 하나, 인코딩된 글은 홀로 선 것만), HTML 글자 `html/html-text.ts`(엔진의 `body :text` 와 같게 — 닫는 `p` · `br` 만 줄을 끊고 `style` · `script` · `head` · `template` 은 글째 빼며 그 밖의 태그는 빈자리 없이 뺀다. 속성이 붙은 태그는 엔진처럼 알아보지 않는다 — 2026-10-29 인증 발송 네 묶음으로 맞춤, 규칙은 계약 2절), 어댑터 `jmap/jmap-filter-run.ts`(머리글 500통 · 본문 100통 차례, 본문 값은 부분마다 256KiB · 한 통 1MiB — 바깥 메일 한 통이 메모리를 크게 쓰지 않게), 이어 할 자리 `jmap/filter-run-cursor.ts`(2판 — 위 경계 · anchor · 그 받은 시각 · 규칙 지문. 모든 조회에 `before` 를 걸어 시작한 뒤 온 메일은 보지 않고, 빠진 anchor 는 그 초부터 다시 읽는다). 한 요청의 시간은 메일함 처리와 같은 `BulkRun`, 같은 직원은 한 번에 하나(`mail.service.ts` 의 `filterRuns` — 진행 중이면 `REQ_001`)) · 원문 `GET /api/messages/{id}/source`(이름 `attachment.policy.ts` 의 `messageSourceFileName`) · 스팸 표시 `isJunk` (계약 2절) · 실시간 알림 `GET /api/mail-events`(5단계 — SSE, `mail-events.controller.ts` · 엔진을 모르는 `mail-events.service.ts`(직원마다 연결 5개 · 25초 ping · 토큰 만료 30초 전 `end` · 종료 때 `end`) · 엔진 쪽 `jmap/jmap-mail-watch.ts`(직원마다 엔진 EventSource 하나 · 75초 무소식이면 다시 잇기) · `jmap/jmap-push.ts`(스트림 읽기) · `jmap/new-mail.ts`(새 메일 거르기), SSE 형식 `shared/utils/sse.ts`. 머리글은 스트림을 열기로 정하면 바로(`flushHeaders`), 처리기에 닿기 전에 끊긴 응답(`res.destroyed`)도 정리한다) | | 메일 서명 | `modules/mail-signature` | 사용자마다 서명 하나(DB) + 서명 그림. 그림 형식은 내용으로 판별(매직 바이트·머리의 가로·세로, 새 의존성 없음) `signature-image-format.ts`, 파일은 업로드 볼륨의 `mail-signatures/` (`signature-image-storage.ts`), 서명에 붙지 않은 그림 정리 cron `mail-signature.cleanup.ts`. 메일 모듈이 발송·임시 저장 때 `MailSignatureService` 로 그림을 쓴다 (계약 2절 "서명") | | 사내 목록(로그인 기록) | `modules/mail-directory` | 웹메일 로그인에 성공한 직원(관리자·게이트웨이·막은 계정 제외)을 `AuthService` 가 기록한다 — 8단계부터는 엔진의 사내 목록(아래 주소록)을 **읽지 못할 때만** 받는 사람 추천의 "사내"를 대신한다. 직원 역할에는 엔진의 계정 목록(Principal) 권한이 없다(일부러 막아 두었다) | | 주소록 (8단계) | `modules/contacts` | 내 주소록(연락처 · 그룹)과 사내 목록 — 계약 3절. 컨트롤러 `contacts.controller.ts` · `contact-groups.controller.ts` · `directory-entries.controller.ts`, 서비스 `contacts.service.ts`(직원마다 주소록 캐시 `contact-book-cache.ts` — 엔진 state 로 확인(`ContactCard/get` 빈 ids, 3ms, 판은 읽기 전에 물은 것), 200명 · 모두 2,400만 · 한 사람 800만 · 30분, 같은 직원의 바꾸기는 차례로(`writeQueues`), 엔진에는 `ifInState` — 어긋남 · `serverUnavailable` 은 다시 읽어 세 번까지, 넘으면 `MAIL_001`. 쓰기의 크기 한도 800만(`MAX_ADDRESS_BOOK_WEIGHT` — 캐시의 한 사람 상한과 같은 값, 넘게 커지는 쓰기는 `CONTACT_002`). 고치기는 `PATCH`(보낸 칸만 검사) — 칸 안의 남의 속성은 어댑터 `contact-card.ts` 의 `contactPatch` 가 지금 카드와 합쳐 지킨다. 되돌리기 표 `contact-restore-store.ts`(지우기 직전에 읽은 카드 전체 — 5분 · 직원마다 20개 · 256KB · 모두 32MB) + `contact-restorations.controller.ts`) · `directory.service.ts`(사내 목록 10분 캐시, 거르기 `directory-policy.ts` — 로그인 막기와 같은 `isEmployeeAddress` + 설명의 `[setup.mjs]`). 순수 함수 `address-book.ts` · `contact-search.ts`(초성 · 겹자음 · 전화 숫자 · NFC) · `contact-paging.ts`(위치 커서 — `shared/utils/offset-cursor.ts`, 받는 사람 추천 커서도 이것), 경로 id `contact-id.pipe.ts`, 빈 글 → null `TrimStringOrNull()`. 포트 `contacts.provider.ts`(`CONTACTS_PROVIDER` · `DIRECTORY_PROVIDER`) — 엔진 어댑터는 `modules/mail/jmap/` 의 `jmap-contacts.provider.ts` · `contact-card.ts`(JSContact ↔ 도메인) · `jmap-directory.provider.ts`(`JmapClient.callDirectory()` — **사내 목록 계정만** 대리 접근). 엔진 교체는 `contacts.module.ts` 의 useClass 만. 모듈 순환을 막으려고 `MAIL_SETTINGS` · `JmapClient` 는 `modules/mail/mail-engine.module.ts` 에 둔다(메일 → 주소록 방향만, 인증 모듈은 이것을 가져온다). 사내 목록 계정(`MAIL_DIRECTORY_LOCAL_PART`, 기본 `mail-directory`)은 setup 이 복구 관리자로만 만든다 — `mail.settings.ts` 의 막는 목록에도 들어 있다 | -| 보내기 취소 · 예약 발송 (9단계) | `modules/mail/jmap/pending-send.ts` · `jmap/envelope.ts` · `scheduled-message.controller.ts` · `scheduled-messages.service.ts` | 기다리는 곳은 엔진이다 — 제출의 FUTURERELEASE(`HOLDFOR` · `HOLDUNTIL`, 엔진 한도 7일), 취소는 `undoStatus: canceled` 뒤 그 메일을 임시보관함으로(`$draft` + `$ara-canceled`, 다시 취소해도 200). `pending-send.ts` 는 붙잡기 매개변수 · 기다리는 제출 찾기(`EmailSubmission/query` 의 `filter.after: 지금` — `undoStatus` 조건은 모든 제출을 훑는다, 정렬 이름 `sentAt`) · `pendingSend` 판정(pending 이면서 보낼 시각이 아직)이다. **발송은 늘 envelope 을 직접 준다**(`envelope.ts`) — 엔진이 받지 않는 주소(모르는 최상위 도메인 등)가 있으면 제출 전체를 거절하고 첫 주소 하나만 알려 주므로, 어댑터가 그 주소를 not-found 로 빼고 같은 메일을 다시 제출한다(한 차례에 하나 — 거절 글의 따옴표 토큰과 정확히 같은 주소부터, 20차례까지 · 발송 55초 안). **붙잡힌 메일은 취소로만 보낸편지함을 떠난다** — 한 통 · 여러 통 `PATCH`(옮기기 · 스팸) · `DELETE` · 영구 삭제 · 메일함 지우기 · 비우기 · 필터 적용이 엔진에 먼저 묻고(`pending-send.ts` 의 `readHeldEmails` — 읽지 못하거나 다 받았는지 응답의 total 로 확인하지 못하면 바꾸지 않고 `MAIL_001`, 닫힌 쪽으로), 막으면 `MAIL_015` · `failedIds` · `skippedIds`. 예약 표시 키워드 `$ara-scheduled` — 계약 2절 "보내기 취소 · 예약 발송" · "받는 사람별 결과" | +| 보내기 취소 · 예약 발송 (9단계) | `modules/mail/jmap/pending-send.ts` · `jmap/envelope.ts` · `scheduled-message.controller.ts` · `scheduled-messages.service.ts` | 기다리는 곳은 엔진이다 — 제출의 FUTURERELEASE(`HOLDFOR` · `HOLDUNTIL`, 엔진 한도 7일), 취소는 `undoStatus: canceled` 뒤 그 메일을 임시보관함으로(`$draft` + `$ara-canceled`, 다시 취소해도 200). `pending-send.ts` 는 붙잡기 매개변수 · 기다리는 제출 찾기(`EmailSubmission/query` 의 `filter.after: 지금` — `undoStatus` 조건은 모든 제출을 훑는다, 정렬 이름 `sentAt`) · `pendingSend` 판정(pending 이면서 보낼 시각이 아직)이다. **발송은 늘 envelope 을 직접 준다**(`envelope.ts`) — 엔진이 받지 않는 주소(모르는 최상위 도메인 등)가 있으면 제출 전체를 거절하고 첫 주소 하나만 알려 주므로, 어댑터가 그 주소를 not-found 로 빼고 같은 메일을 다시 제출한다(한 차례에 하나 — 거절 글의 따옴표 토큰과 정확히 같은 주소부터, 20차례까지 · 발송 55초 안). **붙잡힌 메일은 취소로만 보낸편지함을 떠난다** — 한 통 · 여러 통 `PATCH`(옮기기 · 스팸) · `DELETE` · 영구 삭제 · 메일함 지우기 · 비우기 · 필터 적용이 엔진에 먼저 묻고(`pending-send.ts` 의 `readHeldEmails` — 읽지 못하거나 다 받았는지 응답의 total 로 확인하지 못하면 바꾸지 않고 `MAIL_001`, 닫힌 쪽으로), 막으면 `MAIL_015` · `failedIds` · `skippedIds`. 예약 표시 키워드 `$ara-scheduled`. **지금 보내기**(15단계 — `POST /api/scheduled-messages/{id}/release`, 어댑터 `releaseScheduledSend`): 그 메일 · 제출(envelope · 발신 ID)을 한 요청으로 읽어 상태를 가르고(`releaseStateOf` — 기다리는 예약 · 이미 지금 보냄 · 끊김 · 이미 보냄 · 예약 아님), envelope 을 먼저 정한 뒤 예약 제출을 취소하고(확인된 것만) **다른 요청으로** 같은 메일을 원래 envelope 그대로 다시 낸다 — MAIL FROM 의 붙잡기만 보내기 취소 초(`send-hold.ts`)로, 성공하면 예약 표시를 뗀다(`submitCall` 의 `extraPatch`). 엔진이 분명히 거절하면(SetError · 메서드 오류 · `isRequestRefused`) 한 번 더, 그래도 거절이면 임시보관함으로 되돌린 **뒤에만** `ScheduledSendReturnedToDraftsException`(서비스가 대용량 링크를 취소한 발송처럼 둔다). 결과를 모르면(시간 초과 · 연결 끊김 · 5xx) 다시 내지도 되돌리지도 않는다 — 한 번 다시 읽어 새 제출이 보이면 그 상태, 아니면 그냥 `MAIL_001`(메일은 보낸편지함에 그대로 — 같은 요청을 다시 받으면 끊긴 지금 보내기로 이어 낸다). 제출마다 남은 시간이 `MIN_SEND_WINDOW_MS` 보다 적으면 내지 않는다(그냥 `MAIL_001`), 다 보내면 링크의 보낸 때만 앞당긴다(끝나는 날은 그대로 — 덩이가 적었다). 같은 메일의 취소 · 지금 보내기는 `ScheduledMessagesService` 의 `inProgress` 로 한 번에 하나 — 계약 2절 "보내기 취소 · 예약 발송" · "받는 사람별 결과" | | 외부 이미지 중계 (9단계) | `modules/mail/images/` | `GET /api/mail-images/{token}`. 표 `mail-image-token.ts`(AES-256-GCM — 직원 · 원래 주소 · 1시간, 키는 프로세스가 뜰 때 무작위. 표 모양은 **짝: frontend `shared/utils/mail-html.ts` 의 `PROXIED_IMAGE_PATTERN`**), 주소 검사 `mail-image-url.ts` + `shared/utils/public-address.ts`(모든 주소가 공인일 때만 · 점 없는 이름은 묻지 않고 막는다), 받기 `mail-image-fetcher.ts`(다시 가기 3번 · 10초 · 5MB — Content-Length 가 있으면 그 크기로 한 번 잡는다), 연결 `node-image-transport.ts`(푼 주소로 고정 연결 · Host 는 원래 이름 · 쿠키 · Referer 없음, 이름 풀기는 c-ares `dns.Resolver` — 80 · 443 고정, 다른 포트 · DNS 서버는 테스트의 `forTesting()` 으로만), 형식 `mail-image-format.ts`(앞 바이트로 PNG · JPEG · GIF · WebP), 차례 `mail-image-proxy.service.ts`(서버 16 · 직원 6, 30초 · 200개까지 기다림 — 줄은 직원마다이고 받는 수가 가장 적은 직원부터 든다, 자리는 응답이 끝날 때까지 `releaseWhenDone`), `mail-image.controller.ts`(실패는 모두 404 를 직접 쓴다 — debug 로그만, IP 마다 1분 600회). 본문의 `` 를 중계 주소로 바꾸는 것은 `html/sanitize-mail-html.ts`(`externalImageSrc`) — 계약 2절 "외부 이미지 보기" | -| 보낸 사람 차단 · 보내기 · 읽기 설정 (9단계) | `modules/mail/mail-block.controller.ts` · `mail-block.service.ts` · `mail-blocks.ts` · `modules/mail-user-settings` | 차단은 필터 · 부재중과 같은 Sieve 스크립트의 맨 앞(`jmap/sieve-script.ts`, 원본 판 2 는 차단이 있을 때만 — `jmap/filter-source.ts`). 검사 `mail-blocks.ts`(`@` 앞은 dot-atom · 64자, 여러 사람이 쓰는 메일 서비스 30개는 `mail.constants.ts` — **짝: frontend `shared/constants/mail-block.ts`**, 같은 글자 · 같은 차례 — 한쪽을 고치면 둘 다). 이미 있는 주소의 `POST` 는 꺼진 규칙을 켠다. 설정은 DB(표 `mail_settings` — 마이그레이션 `AddMailSettings`, 직원 주소마다 한 줄 · 없으면 처음 값), `MailUserSettingsService` 가 프로세스마다 60초 기억하고 DB 장애면 처음 값(10초 · `ask`)으로 — 메일 모듈이 발송(`undoSendSeconds`) · 읽기(`externalImages`) 때 읽는다 — 계약 2절 "보낸 사람 차단" · "보내기 · 읽기 설정" | +| 보낸 사람 차단 · 보내기 · 읽기 설정 (9단계) | `modules/mail/mail-block.controller.ts` · `mail-block.service.ts` · `mail-blocks.ts` · `modules/mail-user-settings` | 차단은 필터 · 부재중과 같은 Sieve 스크립트의 맨 앞(`jmap/sieve-script.ts`, 원본 판 2 는 차단이 있을 때만 — `jmap/filter-source.ts`). 검사 `mail-blocks.ts`(`@` 앞은 dot-atom · 64자, 여러 사람이 쓰는 메일 서비스 30개는 `mail.constants.ts` — **짝: frontend `shared/constants/mail-block.ts`**, 같은 글자 · 같은 차례 — 한쪽을 고치면 둘 다). 이미 있는 주소의 `POST` 는 꺼진 규칙을 켠다. **받은편지함 정리**(15단계 — `POST /api/mail-block-runs`, `MailBlockRunController` · `MailService.runMailBlock` · 어댑터 `JmapFilterRunner.runBlock`): 필터 적용(7단계)과 같은 틀 — 차례 · cursor(`filter-run-cursor.ts` 의 `encodeBlockRunCursor` — 지문은 보낸 사람) · `BulkRun` · 같은 잠금 `filterRuns`, 요청이 도는 동안 그 직원의 스팸메일함도 `mailboxOperations` 로 잡는다(스팸메일함 비우기와 함께 돌지 않게 — 서로 `REQ_001`). 판정은 순수 함수 `mail-block-match.ts` — **짝: `jmap/sieve-script.ts` 의 `blockSection`**(엔진이 푼 From 주소 `header:From:asAddresses:all` 를 `address :all` · `:domain` 처럼, `i;ascii-casemap` — 엔진 검색은 넓게 잡아 쓰지 않는다), 할 일은 스팸메일함으로 + `$junk`. 설정은 DB(표 `mail_settings` — 마이그레이션 `AddMailSettings`, 직원 주소마다 한 줄 · 없으면 처음 값), `MailUserSettingsService` 가 프로세스마다 60초 기억하고 DB 장애면 처음 값(10초 · `ask`)으로 — 메일 모듈이 발송(`undoSendSeconds`) · 읽기(`externalImages`) 때 읽는다 — 계약 2절 "보낸 사람 차단" · "보내기 · 읽기 설정" | | 파일 저장 · 조각 올리기 (11단계) | `modules/file-storage` | `/api/file-uploads`(만들기 · 16MiB 조각 `PUT` · 끝내기 · 상태 · 지우기) · `/api/file-quota`. 조각은 `Content-Digest`(SHA-256)를 보고 `datasync` 뒤에 적는다, 같은 조각은 `ChunkGate`(프로세스 메모리)가 하나만 들인다, 조각 번호는 `file-id.pipe.ts`(매개변수 타입 `unknown` — 아래 점검 "경로 숫자"). 저장 포트 `FILE_BLOB_STORE` — 지금은 로컬 디스크 어댑터(named volume `/app/files`, 키는 서버가 만든 UUID 만 — `assertKey`). 보관량(직원 · 서버 총량 · 디스크 여유)은 만들기를 `pg_advisory_xact_lock` 안에서 센다(인스턴스를 늘려도 맞다). 끝내기는 `completion_started_at` · 상태 전이 결과를 본다. 악성 검사 `clamav-client.ts`(INSTREAM 직접 구현 — 판정 글은 `CLAMAV_REPLY` · `classifyClamavReply` 한 곳: 암호 · 압축 안 한도 → 검사하지 못함, 스트림 크기 한도 → 설정 오류로 검사 중 그대로 + error) · 검사 차례 `file-scan.service.ts`(DB pending · 동시 2) · 정리 cron `file-storage.cleanup.ts`. 확장자 허용 목록만 본다 — [[security]] "파일 업로드"의 예외. 계약 4절 | | 공개 링크 · 내려받기 (11단계) | `modules/file-sharing` · `modules/mail/mail-large-files.service.ts` · `large-file-block.ts` · `share-link.controller.ts` | 메일 한 통에 링크 하나(토큰 256비트 — DB 에는 SHA-256 만). 보낼 때 서버가 정화 · 지문 · 중복 확인 뒤 본문에 덩이를 넣는다(`large-file-block.ts` — 표시 `` · `data-ara-large-files` 는 서버만 넣고 두 정화기가 사용자 HTML 에서 지운다, 경계 `bodyQuoteStart`). 링크는 "확실한 실패"(`SendNotStartedException` — 형식으로 가른다)에서만 거둔다. 임시 저장은 머리글 `X-Ara-Large-Files`(보내는 메일에는 없다). 공개 경로 `/api/public-links/{token}`(로그인 없음) — 공개 호스트 미들웨어 `public-host.ts`(공개 호스트에서는 이 경로만 · 포털에서는 닫음, Host 를 스스로 정규화. 경로는 원문 · 라우터의 `req.path` · WHATWG URL · 퍼센트를 푼 원문 네 모양을 모두 본다 — 공개 호스트는 모두 공개 경로일 때만, 포털은 하나라도 공개 경로면 닫고, 읽지 못한 모양이 있으면 둘 다 닫는다. 라우터가 읽는 모양을 빼면 절대 주소 꼴 · `\` 요청이 막기를 지난다 — 2026-10-30), 파일 응답 `serveFile`(octet-stream · attachment · nosniff · CSP sandbox · no-store · Range `shared/utils/http-range.ts`), 받은 수는 조건부 UPDATE 한 문장, 종료 때 진행 중 응답을 `BeforeApplicationShutdown` 에서 끊는다. 읽기 화면의 `largeFiles` 는 표시 · 우리 출처 토큰 · 링크 주인 == From 이 모두 맞을 때만. 기간은 `shared/utils/kst-date.ts`(보낸 날부터 30일째 23:59:59 KST). 계약 4절 | | 계정 보안 (12단계) | `modules/account-security` · `modules/auth` 의 세션 부분 · `modules/mail/jmap/jmap-account-security.provider.ts` | 2단계 인증(엔진 TOTP — 계약 5절): 등록 `two-factor-enrollments.ts`(비밀 160비트 · 메모리 10분 · 직원마다 하나) · 켜기 · 끄기 `two-factor.service.ts`(엔진이 기준 — `enabledAt` 만 표 `two_factor_settings`) · **코드 가드 `two-factor-code-guard.ts`** — 실패 차지 · 다시 쓴 코드 확인 · (주소, 코드) "확인 중" 차지를 await 없이 한 번에 하고(같은 코드의 동시 요청이 둘 다 지나가지 않게), 받으면 그 자리에서 2분 기억한다. 결과는 받음 · 거절 · 쓰지 않음(`unused` — 새 비밀번호 거절처럼 코드와 상관없는 실패는 세지도 기억하지도 않는다). 코드 실패 카운터는 `login-attempt.service.ts`(같은 주소 15분 5 · 24시간 20 + IP 의 로그인 실패 한도). TOTP · base32 는 `shared/utils/totp.ts`(RFC 6238 — 새 의존성 없음, ±1 단계 상수 시간 비교). 잠금 `two-factor-locks.ts`(같은 직원의 2단계 바꾸기는 한 번에 하나). 로그인 2단계는 `auth.service.ts` — 앱 비밀번호 모양 거절 → 실패 차지 → 본인 Basic → **402 면 대리 접근 프로필로 정식 주소**(막는 계정의 별칭은 `AUTH_004`) → 코드는 엔진 `POST /api/auth`(mfaToken). 앱 비밀번호 `app-passwords.*`(엔진 `x:AppPassword` — 권한 `Replace` 셋, 직원마다 만들기는 한 번에 하나, 마지막 사용은 표 `app_password_usages` — 번호 · createdAt 이 같을 때만 잇는다, 비밀번호를 바꾸면 모두 지운다). 로그인된 브라우저 `modules/auth/auth-sessions.*`(경로 `/api/auth/sessions` — 지금 세션은 `AuthService.requestSessionId`: 웹은 리프레시 쿠키 · 앱은 액세스 토큰의 sid) · **끊은 세션 기억 `revoked-sessions.ts`**(액세스 토큰의 `sid` — 1시간, 기동 때 DB 에서 채운다, 끊는 모든 길이 `revokeWhere` 한 곳을 지난다 — 가드가 막고 실시간 알림은 다음 ping 때 닫는다). User-Agent → 짧은 이름 `shared/utils/user-agent.ts`(앱 이름 `ARA SYSTEM 앱` 은 세션의 `client` · 로그인 기록의 `method` 가 app 일 때만 `describeAppUserAgent` 로 — 웹 세션이 앱 꼴 UA 를 보내면 브라우저 null. 운영체제는 앱 UA `ARASYSTEM-App/<판> (Android <판>; <기종>)` 의 첫 괄호 칸 — 짝: flutter `kUserAgentProduct`). 세션 목록 항목에 `client`(web · app). 로그인 기록 `login-events.*`(표 `login_events` — 방식 webmail · app · imap · smtp, 90일 정리 cron `login-events.cleanup.ts`, 위치 커서 `login-event-cursor.ts`). 엔진 웹훅 `mail-engine-events.*` — 본문을 받기 전에 소켓 주소(`APP_NETWORK_SUBNET` — `shared/utils/cidr.ts`)와 키 유무를 보고, 원문 HMAC 을 상수 시간으로, 이 경로만 JSON 한도를 따로(4MB · 배열 10,000 · 값 200,000), 겹침은 내용 지문(`dedupe_key` 유일 인덱스 — 엔진은 다시 보낼 때 id 를 새로 붙인다), 1,000행씩 `ON CONFLICT DO NOTHING`, DB 실패는 5xx(엔진이 다시 보낸다), 빈 묶음 200(setup 의 받는 곳 확인). 마이그레이션 `AddAccountSecurity` | @@ -249,8 +249,10 @@ SwaggerModule.setup('api-docs', app, document); > ⚠️ 인스턴스를 늘리면 **프로세스 메모리에 둔 한도·잠금**도 함께 옮겨야 한다 — 그대로 두면 인스턴스마다 따로 센다: > 로그인 실패 카운터(`modules/auth/fixed-window-counter.ts` → `INCR`+`PEXPIRE` 를 한 Lua 스크립트로), > 같은 주소의 진행 중 로그인·비밀번호 변경·같은 `Idempotency-Key` 의 진행 중 발송(`SET NX PX` 잠금), `@nestjs/throttler` 저장소, - > 같은 직원 · 같은 메일함의 오래 걸리는 처리(`mail.service.ts` 의 `mailboxOperations` — 그대로면 두 인스턴스가 같은 휴지통을 함께 비우며 서로 어긋난다 → 잠금), - > 같은 직원의 이미 받은 메일에 필터 적용(`mail.service.ts` 의 `filterRuns` — 그대로면 두 인스턴스가 같은 받은편지함을 함께 읽으며 서로 옮긴 메일 때문에 자리가 어긋난다 → 잠금), + > 같은 직원 · 같은 메일함의 오래 걸리는 처리(`mail.service.ts` 의 `mailboxOperations` — 그대로면 두 인스턴스가 같은 휴지통을 함께 비우며 서로 어긋난다 → 잠금. + > 15단계부터 하위까지 지우기는 나무의 메일함 모두를, 옮기기는 그 메일함과 새 상위를, 차단 정리는 그 직원의 스팸메일함을 함께 잡는다 — 그대로면 다른 인스턴스에서 스팸메일함을 비우며 옮겨 넣은 메일을 지운다), + > 같은 직원의 받은편지함 정리 — 이미 받은 메일에 필터 적용 · 차단할 때 받은편지함 정리(15단계)(`mail.service.ts` 의 `filterRuns` — 둘이 같은 잠금이다. 그대로면 두 인스턴스가 같은 받은편지함을 함께 읽으며 서로 옮긴 메일 때문에 자리가 어긋난다 → 잠금), + > 같은 메일의 예약 취소 · 지금 보내기(15단계 — `scheduled-messages.service.ts` 의 `ScheduledMessagesService.inProgress` — 그대로면 두 인스턴스가 같은 예약을 함께 지금 보내 두 번 내거나, 취소와 지금 보내기가 엇갈려 임시보관함으로 되돌린 메일을 보낸다 → 잠금), > 사용자별 서명 그림 올리기 차례(`mail-signature.service.ts` 의 `uploadQueues` — 그대로면 동시 업로드가 보관 상한을 넘을 수 있다 → 잠금), > 받는 사람 추천 캐시(`recipient-suggestions.service.ts` — 틀리지는 않지만 보낸 직후 다른 인스턴스가 10분까지 옛 목록을 준다 → 공유 캐시나 무효화 알림), > 실시간 알림의 직원당 연결 5개(`mail-events.service.ts` — 기능은 맞지만 인스턴스마다 따로 세고, 엔진 연결도 인스턴스마다 하나씩 생긴다 → 필요하면 공유 셈), diff --git a/.claude/skills/vue3/SKILL.md b/.claude/skills/vue3/SKILL.md index 476e8fc..6f1c67a 100644 --- a/.claude/skills/vue3/SKILL.md +++ b/.claude/skills/vue3/SKILL.md @@ -9,9 +9,9 @@ description: Vue 3 프론트엔드(frontend/) 개발 규칙 — Composition API --- -## 템플릿 기본 제공 골격 +## 기본 골격 -> 이 템플릿은 아래를 **기본 제공**한다. 새 코드는 이 파일들의 패턴을 복제한다(같은 역할을 새로 만들지 말 것). +> 아래가 이미 있다. 새 코드는 이 파일들의 패턴을 복제한다(같은 역할을 새로 만들지 말 것). | 제공 항목 | 위치 | 비고 | | --- | --- | --- | @@ -29,7 +29,7 @@ description: Vue 3 프론트엔드(frontend/) 개발 규칙 — Composition API | 인증 스토어 | `src/stores/auth.store.ts` | 로그인 사용자(email·name) · 세션 복원(`restore`) · 만료 상태(`expireSession`) · 로그아웃 상태(`clearAuth()` 가 켜는 `signedOut` — 다시 로그인할 때까지 늦게 온 갱신·복원 결과를 쓰지 않는다) | | 공통 컴포넌트 예제 | `src/components/BaseButton.vue` | 재사용 컴포넌트 기준 패턴(타입 Props/Emits·scoped·rem·a11y) | | 라우터 | `src/router/index.ts` · 라우트 표 `src/router/routes.ts`(라우터와 라우트 테스트가 함께 쓴다) | lazy import + 첫 네비게이션 전 세션 복원 + `requiresAuth` 가드(미인증 → `{ name: 'login', query: { redirect } }`) | -| 전역 셸 | `src/components/shell/AppShell.vue` | 앱 레일 + 상단 바 + 모듈 영역. 레일 모듈 목록은 `src/shared/constants/app-modules.ts` 하나 | +| 전역 셸 | `src/components/shell/AppShell.vue` | 앱 레일 + 상단 바 + 모듈 영역. 레일 모듈 목록은 `src/shared/constants/app-modules.ts` 하나. 상단 바(`AppTopBar.vue` — 15단계): 머리글자 · [로그아웃]은 줄지 않고(nowrap) 이름 · 주소 칸만 줄어 "…"(title "이름 (주소)"), 화면 폭 480(30rem) 이하에서 주소를 감춘다 — 줄 수 · 차례는 그대로이고 검색 칸(search 슬롯 · 통합 검색)도 `min-width: 0` 으로 바 안쪽에 맞게 준다(360 에서 넘쳤다). 앱 틀은 잘못 놓은 파일을 무시한다(15단계 — `composables/useWindowFileDrop.ts` 의 `useIgnoredWindowFileDrop`: 받는 화면(`useWindowFileDrop` — 쓰기 화면 · 서명 설정)이 떠 있으면 그 화면이 받는다) | | 디자인 토큰·글꼴 | `src/styles/tokens.css` · `src/styles/base.css` | 색·간격·글자 크기 변수. 글꼴은 npm `pretendard` 자체 호스팅(`main.ts` 에서 import — CSP `font-src 'self'`) | | 브랜드 자산 | `src/assets/brand/` · `src/shared/constants/brand.ts` | 흰 로고(전체·ARA 마크). 원본은 `docs/brand/`. 탭 · 홈 화면 아이콘(`favicon-16.png` · `favicon-32.png` · `apple-touch-icon.png`)은 `index.html` 이 `/src/assets/brand/…` 로 걸어 빌드가 `/assets/` 의 해시 파일로 바꾼다 — 배포 직후 새 아이콘이 보이고, 공개 내려받기 호스트도 `/assets/` 는 연다. `public/favicon.ico` 는 주소를 직접 묻는 클라이언트용. 모양 · 크기 규칙은 디자인 README 머리 | | 토스트 | `src/stores/toast.store.ts` · `src/components/feedback/ToastHost.vue` | 오류는 `useApi.ts` 의 `showErrorUI()`, 성공 안내는 `composables/useToast.ts`(API 가 아닌 화면 쪽 실패 — 예: 브라우저가 알림을 막음 · 보냈지만 받는 사람 일부가 받지 못함(+ [보낸편지함 보기], 문장은 `shared/utils/mail-send-result.ts`) — 는 `useToast().error()`). 알림 안의 단추는 `{ label, run }`(예: 임시 저장본 [되돌리기]), 이동 링크는 지금처럼. 단추가 있는 알림은 10초(`TOAST_ACTION_DURATION_MS`), 마우스 · 초점이 있는 동안은 `hold`·`release` 로 멈춘다. **단추는 띄운 세션에서만 움직인다** — `useToast` 가 `authStore.generation` 에 묶고, 로그아웃 · 세션 만료 · 다른 계정 로그인 때 단추가 있는 알림을 닫는다(메일 id 는 계정마다 겹칠 수 있다). 오래 걸리는 처리는 **진행 알림** `useToast().progress(글)` — 저절로 닫히지 않고(넘쳐도 다른 알림부터 닫는다) 글을 고치다가(`update`) 끝나면 그 자리에서 결과가 된다(`succeed` · `fail` — `toast.store` 의 `update`). 결과에도 단추를 붙일 수 있다(`succeed(글, action)` · `fail(글, action)` — 7단계 필터 적용의 [{메일함} 보기] · [다시 시도], 시간 · 세션 묶기는 다른 단추와 같다). 진행 알림도 세션이 바뀌면 닫는다 | @@ -37,12 +37,12 @@ description: Vue 3 프론트엔드(frontend/) 개발 규칙 — Composition API | 외부 HTML 격리 렌더링 | `src/components/mail/MailBodyFrame.vue` · `src/shared/utils/mail-html.ts` | 메일 본문 등 외부 HTML 은 이 컴포넌트로만 그린다 (sandbox·내부 CSP 근거는 파일 머리말). 보여 줄 HTML 고르기는 `displayedHtmlBody()` 하나다 — null 이거나 공백뿐이면 textBody (읽기 화면·인용 공용, 서버의 `isBodyTruncated` 와 같은 규칙) | | 메일 모듈 | `src/pages/mail/*` · `src/components/mail/*` | API 계약 `docs/mail/api-contract.md`, 디자인 원본 `docs/design/webmail/` | | 콘텐츠 열 토큰 | `src/styles/tokens.css` 의 `--content-*` · `--content-toolbar-start` | 읽기·쓰기 화면이 같은 최대 폭·여백·32px 시작선을 쓴다 (새 화면도 이 토큰으로) | -| 밀도 예외 크기 | `src/styles/tokens.css` 의 `--size-compact-control` · `--size-segment-item` · `--size-segment-item-lg` · `--size-editor-tool` · `--size-editor-menu-item` · `--size-editor-swatch-cell` · `--size-editor-table-cell` · `--table-col-check` · `--size-row-check` · `--size-search-clear-width`/`-height` · `--size-notice-bar` · `--size-mailbox-more` · `--size-notice-action` · `--size-filter-control` · `--size-filter-remove` (주석에 "밀도 예외") | 클릭 영역 44px 기준의 의도된 예외(목록 표의 별·보기 전환, 설정 화면의 미리 보기 전환 36(`.segmented`)·서명 그림 크기 줄 36(작게·보통·원본·그림 빼기), 편집기 툴바 버튼 40×44·메뉴 항목 40·색 견본 40·표 고르기 칸 22, 3단계의 목록 표 고르기 칸 32·분할 행 고르기 칸 폭 36·검색 칸 ✕ 40×38·새 메일 알림 줄 40·검색 범위 전환 36·이동 메뉴 항목 40, 4단계의 메일함 행 ⋯ 32×40 · ⋯ 메뉴 항목 40, 5단계의 목록 위 알림 권하기 줄 40 · 줄 안의 단추 36, 6단계의 필터 조건 칸 40 · 조건 ✕ 36 · 필터 행 ▲▼ 32×40 · 켜기 스위치 누르는 곳 40×44 · 고치기 창 안 [다시 불러오기] 36, 8단계의 패널 그룹 줄 ⋯ 32×40 · 연락처 창의 줄 · 칸 40 · 줄 ✕ 36×40 · 구성원 넣기 창 전환 36 · 찾기 칸 40 — 새 토큰 없이 위 것을 다시 쓴다, 9단계의 읽기 화면 안내 줄 단추([이미지 보기] · [차단 풀기] · [고치기] · [예약 취소]) · 차단한 주소 줄의 [차단 풀기] 36(`--size-notice-action`) — 역시 새 토큰 없음, 14단계의 검색 칸 [조건] 32(`--size-compact-control`) · 조건 패널의 칸 · 단추 40(`--size-filter-control`) · 조건 줄 40 · 줄 안 [조건 지우기] 36 · 같은 대화 펼친 줄의 [답장] · [이 메일만 열기] 36 · 목록 머리 [대화로 묶기] 36(`--size-notice-action`) — 역시 새 토큰 없음, tokens.css 주석)에만 쓴다 — 사용자 확정(캔버스). 그 밖의 `--size-editor-*`(툴바 높이·글꼴 단추·본문 최소 높이 등)는 예외가 아니라 보통 크기다 | +| 밀도 예외 크기 | `src/styles/tokens.css` 의 `--size-compact-control` · `--size-segment-item` · `--size-segment-item-lg` · `--size-editor-tool` · `--size-editor-menu-item` · `--size-editor-swatch-cell` · `--size-editor-table-cell` · `--table-col-check` · `--size-row-check` · `--size-search-clear-width`/`-height` · `--size-notice-bar` · `--size-mailbox-more` · `--size-notice-action` · `--size-filter-control` · `--size-filter-remove` (주석에 "밀도 예외") | 클릭 영역 44px 기준의 의도된 예외(목록 표의 별·보기 전환, 설정 화면의 미리 보기 전환 36(`.segmented`)·서명 그림 크기 줄 36(작게·보통·원본·그림 빼기), 편집기 툴바 버튼 40×44·메뉴 항목 40·색 견본 40·표 고르기 칸 22, 3단계의 목록 표 고르기 칸 32·분할 행 고르기 칸 폭 36·검색 칸 ✕ 40×38·새 메일 알림 줄 40·검색 범위 전환 36·이동 메뉴 항목 40, 4단계의 메일함 행 ⋯ 32×40 · ⋯ 메뉴 항목 40, 5단계의 목록 위 알림 권하기 줄 40 · 줄 안의 단추 36, 6단계의 필터 조건 칸 40 · 조건 ✕ 36 · 필터 행 ▲▼ 32×40 · 켜기 스위치 누르는 곳 40×44 · 고치기 창 안 [다시 불러오기] 36, 8단계의 패널 그룹 줄 ⋯ 32×40 · 연락처 창의 줄 · 칸 40 · 줄 ✕ 36×40 · 구성원 넣기 창 전환 36 · 찾기 칸 40 — 새 토큰 없이 위 것을 다시 쓴다, 9단계의 읽기 화면 안내 줄 단추([이미지 보기] · [차단 풀기] · [고치기] · [예약 취소]) · 차단한 주소 줄의 [차단 풀기] 36(`--size-notice-action`) — 역시 새 토큰 없음, 14단계의 검색 칸 [조건] 32(`--size-compact-control`) · 조건 패널의 칸 · 단추 40(`--size-filter-control`) · 조건 줄 40 · 줄 안 [조건 지우기] 36 · 같은 대화 펼친 줄의 [답장] · [이 메일만 열기] 36 · 목록 머리 [대화로 묶기] 36(`--size-notice-action`) — 역시 새 토큰 없음, 15단계의 같은 대화 [모두 펼치기] · 펼친 줄 머리 [답장] 36(`--size-notice-action`) — 역시 새 토큰 없음, tokens.css 주석)에만 쓴다 — 사용자 확정(캔버스). 그 밖의 `--size-editor-*`(툴바 높이·글꼴 단추·본문 최소 높이 등)는 예외가 아니라 보통 크기다 | | 사용자별 화면 설정 | `src/stores/mail-view.store.ts` | localStorage(키에 사용자 email, try/catch, 실패 시 기본값). 서버 설정 API 가 생기면 교체 | | 화면 이동 규칙 | `src/composables/useMailNavigation.ts` | 이전/다음은 replace, "목록으로"는 뒤로 가기 또는 replace (기록이 쌓이지 않게) | | 발송 키 규칙 | `src/shared/utils/idempotency-key.ts` 의 `shouldRenewSendKey()` | `Idempotency-Key` 는 성공과 `MAIL_005` 때만 바꾼다 — 그 밖의 실패 뒤 다시 보내기는 같은 키 (계약 2절 "발송 본문"). UUID 만들기는 `shared/utils/uuid.ts` 하나(필터 id 와 공용 — `crypto.randomUUID` 가 없으면 대체 경로) | | 첨부 사전 검사 | `src/shared/utils/mail-attachment.ts` 의 `precheckAttachment()` | 형식·파일당·합계 한도(`mail.store` 의 `attachmentMaxBytes`·`attachmentTotalMaxBytes`, 서버 값이 없으면 대체값). 넘는 파일은 올리지 않는다 | -| 메일 본문 편집기 | `src/components/mail/editor/` — `MailEditor.vue` (쓰기 화면이 `defineAsyncComponent` 로 불러온다) | TipTap core (MIT 무료 패키지만, `@tiptap/*` 는 모두 같은 버전으로 고정). 툴바 배치·좁은 폭에서 접는 순서·단축키는 `docs/design/webmail/README.md` "메일 쓰기". 보낼 모양은 `mail-serialize.ts` — 서식 본문 `bodyHtml`(기본 글꼴 div, 인라인 style) + 텍스트 대체 본문 `body` (계약 2절 "서식 본문"). 답장·전달 초안 한도는 `fit-draft.ts`(처음 맞출 때 내 글 몫 5%를 남기고, 보내기 직전 `getContentForSend()` 가 다시 재서 인용을 더 줄인다 — 내 글만으로 넘으면 보내지 않고 칸 아래에 알린다), 붙여 넣거나 끌어다 놓은 파일은 첨부로(`shared/utils/editor-files.ts`, 화면 어디에 놓아도 — `composables/useWindowFileDrop.ts`). **읽어 들이는 style 값은 서버 정화와 같은 형식만** 받는다 — 규칙 `shared/utils/outgoing-html-rules.ts`(짝: backend `sanitize-outgoing-html.ts`, 같은 표본 표 `test-utils/outgoing-style-samples.ts` — 한쪽을 고치면 둘 다), 적용 `editor/mail-format-extensions.ts`. 편집기 청크를 못 받으면 `ComposeEditorLoadError.vue`("다시 시도"), 단축키 표기는 플랫폼별(`shared/utils/keyboard-shortcut.ts`). **그림은 서명 그림만** 받는다 — 노드 `editor/signature-image.ts`(쓰기 본문·서명 칸 공용, 규칙은 `outgoing-html-rules.ts` 의 그림 부분 — 짝: backend `html/signature-images.ts`·`keepSignatureImage`, 표본 `IMAGE_SAMPLES`). 메뉴(`editor/EditorMenu.vue` + `EditorPopup.vue` — 이동 ▾ · 메일함 ⋯ · 읽기 화면 ⋯ 도 같은 부품)의 꺼진 항목은 `disabled` 가 아니라 `aria-disabled` — 화살표로 가서 초점은 받되 고를 수 없다(WAI-ARIA 메뉴). 누를 수 없는 까닭은 항목의 `note`(이름 아래 작은 글, `aria-describedby`) | +| 메일 본문 편집기 | `src/components/mail/editor/` — `MailEditor.vue` (쓰기 화면이 `defineAsyncComponent` 로 불러온다) | TipTap core (MIT 무료 패키지만, `@tiptap/*` 는 모두 같은 버전으로 고정). 툴바 배치·좁은 폭에서 접는 순서·단축키는 `docs/design/webmail/README.md` "메일 쓰기". 보낼 모양은 `mail-serialize.ts` — 서식 본문 `bodyHtml`(기본 글꼴 div, 인라인 style) + 텍스트 대체 본문 `body` (계약 2절 "서식 본문"). 답장·전달 초안 한도는 `fit-draft.ts`(처음 맞출 때 내 글 몫 5%를 남기고, 보내기 직전 `getContentForSend()` 가 다시 재서 인용을 더 줄인다 — 내 글만으로 넘으면 보내지 않고 칸 아래에 알린다), 붙여 넣거나 끌어다 놓은 파일은 첨부로(`shared/utils/editor-files.ts`, 화면 어디에 놓아도 — `composables/useWindowFileDrop.ts`). **읽어 들이는 style 값은 서버 정화와 같은 형식만** 받는다 — 규칙 `shared/utils/outgoing-html-rules.ts`(짝: backend `sanitize-outgoing-html.ts`, 같은 표본 표 `test-utils/outgoing-style-samples.ts` — 한쪽을 고치면 둘 다), 적용 `editor/mail-format-extensions.ts`. 편집기 청크를 못 받으면 `ComposeEditorLoadError.vue`("다시 시도"), 단축키 표기는 플랫폼별(`shared/utils/keyboard-shortcut.ts`). **그림은 서명 그림만** 받는다 — 노드 `editor/signature-image.ts`(쓰기 본문·서명 칸 공용, 규칙은 `outgoing-html-rules.ts` 의 그림 부분 — 짝: backend `html/signature-images.ts`·`keepSignatureImage`, 표본 `IMAGE_SAMPLES`). 메뉴(`editor/EditorMenu.vue` + `EditorPopup.vue` — 이동 ▾ · 메일함 ⋯ · 읽기 화면 ⋯ 도 같은 부품)의 꺼진 항목은 `disabled` 가 아니라 `aria-disabled` — 화살표로 가서 초점은 받되 고를 수 없다(WAI-ARIA 메뉴). 누를 수 없는 까닭은 항목의 `note`(이름 아래 작은 글, `aria-describedby`). 메뉴 · 팝오버의 좌우는 바라는 끝(`align`)으로 그린 뒤 보이는 영역 밖으로 넘치면 덜 넘치는 반대 끝에 맞춘다(15단계 — `EditorPopup` 이 그린 직후 · 초점을 주기 전에 스스로 잰다: `composables/usePopupPlacement.ts` 의 `measurePopupAlign` · 판단 `shared/utils/popup-placement.ts` 의 `popupAlignFor`). 항목은 들여쓰기(`depth` — 10 · 28 · 44 · 56)와 따로 읽힐 이름(`ariaLabel`)을 받는다 | | 임시 저장 | `src/composables/useDraftSaving.ts` · `components/mail/ComposeDraftStatus.vue` · `composables/useComposeDraftLink.ts`(주소 맞추기 · 옛 id 기록 · 보내지 못한 메일을 저장본으로) · `composables/useDraftTrash.ts`(휴지통 · 되돌리기) | 3초 멈춤 · 쉬지 않고 쓰면 30초 · 10초 간격 · 한 번에 하나(계약 2절 "임시 저장", 상수 `shared/constants/mail.ts` 의 `DRAFT_*`). `MAIL_008`·PUT 404 는 막지 않고 POST 로 새로. 찾지 못한 첨부가 남았거나 본문이 한도를 넘는 동안은 저장하지 않고, 실을 수 없는 첨부가 든 남의 저장본은 첫 저장을 POST 로(원본 보존). 보내기·버리기 전에는 `suspend`+`settle` — 보내기는 settle 뒤 첨부 상태를 다시 본다. 저장할 때마다 주소를 `?draft=<새 id>` 로 맞추고(떠나기 전 저장 제외), 옛 id 로 돌아오면 `mail.store` 의 `latestDraftId()` 로 마지막 저장본을 연다. 쓰기 화면 휴지통은 DELETE 가 아니라 PATCH(휴지통으로 이동) — 이미 휴지통인 메일은 DELETE 가 영구 삭제한다 | | 받는 사람 자동 완성 | `src/composables/useRecipientSuggestions.ts` · `shared/utils/recipient-match.ts` · `components/mail/RecipientField.vue` · `RecipientSuggestionList.vue` | 검색어는 input · compositionupdate 의 `target.value`(한글 조합 중 글자 포함)로 따로 두고, 칩 확정은 v-model. Enter 등의 조합 판정은 `shared/utils/keyboard-shortcut.ts` 의 `isImeComposing()`(`isComposing` 또는 keyCode 229 — Safari · macOS) — 새 입력 칸의 Enter 처리도 이것을 쓴다. 쓰기 화면을 열 때 `limit=100` 한 번, 모자랄 때만 250ms 뒤 `q` 로(8단계부터 초성 · 조합 중 글자도 묻는다 — 서버의 q 가 같은 맞추기다: 짝 backend `modules/mail/recipient-match.ts`, 같은 표본 표 `test-utils/recipient-match-samples.ts` — 한쪽을 고치면 둘 다. 늦은 응답은 버린다). 초성 검색·맞은 글자 강조는 `recipient-match.ts`(강조는 `` 조각 — v-html 없음). combobox 접근성(aria-activedescendant · listbox · 조합 중 Enter 무시), 완전한 주소면 미리 고르지 않는다 | | 메일 서명 | `src/stores/mail-signature.store.ts` · `components/mail/editor/signature/` · `shared/utils/mail-signature.ts` · `pages/settings/` | 서명은 메일 화면에 들어올 때 받아 두고 쓰기 화면이 넣는다(새 메일은 끝, 답장·전달은 인용 위 — 이어 쓰기는 다시 넣지 않는다). 설정 화면(`SettingsLayout.vue` 왼쪽 메뉴 — 묶음 · 항목은 `shared/constants/settings-menu.ts` + `MailSignaturePage.vue`)은 들어올 때마다 새로 받는다. 미리 보기는 편집기 스키마의 `DOMSerializer` 로 그린다. 그림 3장은 서명 칸 안의 그림 수로(서버 보관 상한 10장은 서버가 센다) | @@ -53,11 +53,11 @@ description: Vue 3 프론트엔드(frontend/) 개발 규칙 — Composition API | 실시간 테스트 도구 | `src/test-utils/event-stream.ts`(가짜 SSE 서버) · `notification.ts`(가짜 Notification) · `web-locks.ts`(가짜 `navigator.locks` · BroadcastChannel) | 실시간 · 알림 테스트는 이것들과 손으로 돌리는 시계(`manual-clock.ts`)로 — 진짜 타이머 · 고정 대기 없이 | | 메일 검색 | `src/components/mail/MailSearchBox.vue`(상단 바의 search 슬롯) · `composables/useMailSearchNavigation.ts` · `MailSearchHeader.vue` · `MailSearchScope.vue` · `MailSearchEmpty.vue` · 라우트 `/mail/search` · `/mail/search/:messageId` | Enter 로만 찾는다(`isImeComposing()` 이면 무시), ✕ · Esc 는 검색을 시작한 메일함으로. 주소 `?q=…&in=<메일함 id \| all>` — 검색 화면 안의 다시 찾기 · 범위 바꾸기는 replace. 서버와 같은 한도(200자 · 낱말 8개)를 먼저 본다. 조사 처리 · 한계는 서버 몫(계약 2절 "검색") | | 메일 검색 — 14단계 (조건 · 강조 · 준비 중) | `src/components/mail/MailSearchConditions.vue`(조건 패널 — 칸 `MailSearchConditionFields.vue` · 기간 `MailSearchPeriodField.vue` · [조건] `MailSearchConditionsToggle.vue`, 고치는 값 `composables/useSearchConditionsDraft.ts` · 열고 닫기 `useSearchConditionsPopup.ts`) · `MailSearchConditionLine.vue`(조건 줄) · `MailSearchNotices.vue`(준비 중 · 한 글자 안내) · `MailSearchMore.vue`([더 찾기]) · `HighlightedText.vue` · `shared/utils/mail-search.ts`(조건 ↔ 주소 ↔ API · 기간 · 조건 줄 글) · `search-highlight.ts` · `search-match.ts` · `shared/constants/mail-search.ts` | 조건 이름 · 화면 주소(`since` · `until` 은 브라우저 시간대 날짜 · 양 끝 포함, 첨부 `attachment=1`) · 이어 찾기(`MAIL_SEARCH_FILL_MAX_REQUESTS` 5번)는 계약 2절 "검색 — 14단계"의 클라이언트 줄. **`search-match.ts` 는 backend `shared/utils/search-query.ts` · `search-text.ts` · `subject-base.ts` 와 짝** — 같은 표본 표 `test-utils/search-text-samples.ts`(backend `test/search-text-samples.ts` 와 행 · 차례까지 같다, 보이지 않는 글자는 `\u` 로 적는다 — 한쪽을 고치면 둘 다). 맞은 말은 `HighlightedText` 가 글 조각 + `` 로 그린다(v-html 없음 — 위치가 글 밖 · 거꾸로 · 정수가 아니면 버리고, 서로게이트 쌍을 가르지 않는다). **강조가 든 응답은 보이기만 한다** — `openMessage(id, highlight)` 는 강조 쿼리가 다르면 다시 받고, 답장 · 전달 · 인쇄 · 예약 · 대용량 첨부 다시 받기는 강조 쿼리 없이 받는다. 손으로 고친 주소도 패널과 같은 검사(8낱말 넘으면 묻지 않고 "찾을 말이 너무 많습니다"). 강조 노랑은 `--color-search-mark` — 본문 틀(`mail-html.ts`)의 같은 값과 짝. 미리 보기 줄(회색 글) 안의 강조만 글자를 `--color-text-secondary` 로 진하게(노랑 위 대비 — `MessageListItem.vue`). 패널의 [조건 지우기]는 칸만 비운다 — 패널은 열린 채 · 찾지 않음, 걸린 조건은 [찾기] · 조건 줄의 [조건 지우기]가 뺀다(2026-11-07) | -| 같은 대화 (14단계) | `src/components/mail/MessageConversation.vue`(읽기 화면 첨부 아래 칸) · `ConversationMessageRow.vue`(줄) · `ConversationMessagePanel.vue`(펼친 안) · `stores/mail/conversation.ts` · `composables/useConversationSync.ts`(언제 받을지 — 시계 주입) · `shared/utils/mail-conversation.ts`(이름 자리 · 쪽 맞추기 · 갈 곳) · `shared/constants/mail-conversation.ts` · `shared/utils/format.ts` 의 `formatConversationTime` | 받기 · 다시 받기는 계약 2절 "대화 — 14단계"의 클라이언트 줄(100통 · 이어 묻기 5번 · [더 보기], 실패는 칸 안 안내 — 404 면 칸 없음). **다시 받기 판정은 `mail.store` 의 `mailboxTotalsKey`**(메일함마다 `id:totalCount` — 바뀔 때만 `requestConversationRefresh()`, MailLayout 의 바뀜 소식에서는 부르지 않는다 — 읽음 · 중요 표시만 바뀐 소식으로 대화를 다시 받지 않게 — 칸의 안 읽은 줄은 목록처럼 굵게 그리고, 이 탭이 바꾼 읽음은 `list.ts` 의 `watchLocalReads` 로 바로 적는다: 받는 동안 바꾼 것은 `conversation.ts` 의 `withLocalReads` 가 받은 값보다 앞세우고, 다른 탭의 읽음은 다음 받기 때 — 2026-11-07), 간격은 받기가 끝난 때(`conversationBusy` 가 꺼질 때)부터 10초. 펼친 본문은 `MailBodyFrame` · 강조 쿼리 없이, 안 읽은 메일은 `markRead`(`Promise` — 같은 목록의 `pendingFlags` 에 같은 읽음이 있으면 건너뛴다)로. `closeMessage` 가 칸도 비운다. 초점은 누른 단추가 사라지면 줄의 펼침 단추 → 새 [다시 시도] → 칸 h3(`tabindex="-1"`) → 칸이 없어지면 읽기 화면에 `settled`. 상태 영역은 늘 두고 글만 바꾼다(다시 실패해도 읽게). [이 메일만 열기]는 분할 보기 push · 목록 보기 replace(layout prop). 줄 차례는 `orderHeldLast`(붙잡힌 메일은 끝 — 보낼 시각 차례), 미리 보기는 `previewWithoutOwnQuote`(우리 원문 인용 머리부터 잘라 냄 — `mail-conversation.ts`), 펼친 줄의 첨부 수 안내는 `ConversationMessagePanel` | +| 같은 대화 (14단계) | `src/components/mail/MessageConversation.vue`(읽기 화면 첨부 아래 칸) · `ConversationMessageRow.vue`(줄) · `ConversationMessagePanel.vue`(펼친 안) · `stores/mail/conversation.ts` · `composables/useConversationSync.ts`(언제 받을지 — 시계 주입) · `shared/utils/mail-conversation.ts`(이름 자리 · 쪽 맞추기 · 갈 곳) · `shared/constants/mail-conversation.ts` · `shared/utils/format.ts` 의 `formatConversationTime` | 받기 · 다시 받기는 계약 2절 "대화 — 14단계"의 클라이언트 줄(100통 · 이어 묻기 5번 · [더 보기], 실패는 칸 안 안내 — 404 면 칸 없음). **다시 받기 판정은 `mail.store` 의 `mailboxTotalsKey`**(메일함마다 `id:totalCount` — 바뀔 때만 `requestConversationRefresh()`, MailLayout 의 바뀜 소식에서는 부르지 않는다 — 읽음 · 중요 표시만 바뀐 소식으로 대화를 다시 받지 않게 — 칸의 안 읽은 줄은 목록처럼 굵게 그리고, 이 탭이 바꾼 읽음은 `list.ts` 의 `watchLocalReads` 로 바로 적는다: 받는 동안 바꾼 것은 `conversation.ts` 의 `withLocalReads` 가 받은 값보다 앞세우고, 다른 탭의 읽음은 다음 받기 때 — 2026-11-07), 간격은 받기가 끝난 때(`conversationBusy` 가 꺼질 때)부터 10초. 펼친 본문은 `MailBodyFrame` · 강조 쿼리 없이, 안 읽은 메일은 `markRead`(`Promise` — 같은 목록의 `pendingFlags` 에 같은 읽음이 있으면 건너뛴다)로. `closeMessage` 가 칸도 비운다. 초점은 누른 단추가 사라지면 줄의 펼침 단추 → 새 [다시 시도] → 칸 h3(`tabindex="-1"`) → 칸이 없어지면 읽기 화면에 `settled`. 상태 영역은 늘 두고 글만 바꾼다(다시 실패해도 읽게). [이 메일만 열기]는 분할 보기 push · 목록 보기 replace(layout prop). 줄 차례는 `orderHeldLast`(붙잡힌 메일은 끝 — 보낼 시각 차례), 미리 보기는 `previewWithoutOwnQuote`(우리 원문 인용 머리부터 잘라 냄 — `mail-conversation.ts`), 펼친 줄의 첨부 수 안내는 `ConversationMessagePanel`. 15단계: 칸 머리 오른쪽 [모두 펼치기] · [모두 접기](36 — `MAIL_CONVERSATION_TEXT.expandAll` · `collapseAll`, 판정 `conversationAllExpanded` — 하나씩 다 펼쳐도 [모두 접기]). 모두 펼친 줄의 본문은 화면에 들어온 줄부터 하나씩(`composables/useConversationPanelVisibility.ts` — IntersectionObserver 하나로 `[data-conversation-panel]` 을 보고 스토어의 `noteConversationPanelVisible` 에 알린다 · 차례 `detailQueue`, 차례 전에 화면 밖으로 나간 줄은 건너뛴다), 읽음은 본문이 화면에 보였을 때만(`lazyRows` — 손으로 펼친 줄은 지금처럼 받는 대로). 펼친 줄 머리 [답장](`data-conversation-head-reply` — 머리 틀 `.conversation-row__head` 안에 펼침 단추와 나란히, 임시 저장본 · 예약 · 지워진 메일엔 없다) | | 대화로 묶어 보기 (14단계) | `src/components/mail/MailConversationToggle.vue`(목록 머리 [대화로 묶기]) · `ConversationListItem.vue`(분할 보기 줄) · `ConversationTableRow.vue`(목록 보기 표 줄) · `composables/useConversationRows.ts` · `shared/utils/mail-conversation-list.ts`(응답 맞추기 · 할 일 id · 참여자 · 머리 글 · 표시 적용 · 맞추기 병합 · 삭제 뒤 갈 곳) · `stores/mail/list.ts`(출처 `conversations`) · 설정 `stores/mail-settings.store.ts` 의 `groupByConversation`(+ 설정 › 보내기 · 읽기 한 줄) · 글 `shared/constants/mail-conversation.ts` 의 `MAIL_CONVERSATION_LIST_TEXT` | 받기 · 맞추기 · 줄에 하는 일은 계약 2절 "대화 — 14단계"의 클라이언트 줄(20줄 · 맞추기는 불러온 줄 수만큼 최대 100줄 `silent` · 할 일 = `messageIds` − 붙잡힌 메일 · 줄 전체 표시는 모두 바꿨을 때만 · 옮기기 · 삭제 뒤 한 번 다시 받기). **첫 진입 짐작**은 localStorage `groupByConversationHintKey(주소)` — 설정을 받기 전 · 받지 못했으면 이 값, 받으면 서버 값, 읽기 · 쓰기가 던져도 끔. 로그아웃 · 세션 만료에 설정 스토어를 비워도 화면이 보는 값은 그대로 둔다(뒤집히면 watcher 가 떠나는 화면의 목록을 다시 부른다 — 2026-11-06 리뷰). **목록을 부르는 watcher 는 `authStore.canUseApp` 이 false 면 부르지 않는다**(소스에도 넣는다). 붙잡힌 메일이 든 줄은 고르기 칸 · 별이 `aria-disabled` + 까닭(`SEND_LATER_TEXT.heldNotSelectable`). 목록 줄의 화면 낭독 글("안 읽음, " · "중요 표시" · "첨부 있음")은 `shared/constants/mail.ts` 의 `MAIL_LIST_ROW_TEXT` — 메일 줄은 아직 직접 적은 글이라 함께 고친다. 읽기 화면 [이전] · [다음] · 위치는 대화 단위(`ListReadNav.vue`), 한 통 삭제 뒤 갈 곳은 `conversationNeighborAfterRemoval`. 분할 보기 머리의 줄인 개수 글 `listHeadShortCountText`(전체 글은 title · 숨긴 글) · 도구 줄 `selectedShort`(숨긴 글 "대화 N개 선택됨") · 표 머리 "참여자"(`participantsColumn`) · 줄 알림 `announceMove(…, { from })` → `MAIL_BULK_TEXT.movedFrom`("{메일함}에 있는 메일 N통을 …"). 영구 삭제는 `MailPurgeDialog` 의 `from` + `announcePurge(…, { from })` → `purgeTitleFrom` · `purgedFrom`(값은 `useBulkMailActions` 의 `conversationFrom` — 묶지 않은 목록 · 읽기 화면은 null). **설정 다시 받기** `composables/useMailSettingsRefresh.ts`(MailLayout — 탭이 보일 때 60초 `MAIL_SETTINGS_REFRESH_AFTER_MS` · `canUseApp`) + 스토어 `refresh()`(silent · 받는 동안 loading 으로 바꾸지 않음 · 저장 중이면 하지 않음 · 받는 사이 고른 것(`choiceSeq`) · 다시 받기(`loadSeq`) · 세션이 바뀌면 버림) · `fetchSeq` — 디자인 README "14단계 구현하며 정한 보완 — 목록 대화로 묶어 보기" | -| 여러 통 처리 · 삭제 | `src/components/mail/MailCheckbox.vue` · `MailSelectionToolbar.vue`(+ `MailBulkButton` · `MailBulkMoveButton`) · `MailPurgeDialog.vue` · `MessageDeleteButton.vue` · `composables/useBulkMailActions.ts` · `useMessageRemoval.ts` · `useMailMoveFeedback.ts` · `shared/utils/mail-selection.ts` · `mail-bulk.ts` | 100통씩 차례로. 읽음 · 중요는 먼저 바꾸고 `failedIds` 만 되돌리고, 이동 · 삭제는 응답의 id 만 뺀다. **웹은 `DELETE /api/messages/{id}` 를 쓰지 않는다** — 휴지통 밖 삭제는 `PATCH` 이동(+ 되돌리기), 휴지통에서는 확인 대화상자(처음 초점 「취소」) 뒤 `POST /message-deletions`. 읽기 화면도 그 메일의 `mailboxIds` 로 같은 규칙을 탄다. 도구 줄은 role="toolbar" 로빙 tabindex(바쁜 · 꺼진 단추는 건너뛴다). [되돌리기] 뒤 알림은 한 메일함이면 그 이름(`restoredMessageText(…, mailboxName)` — "메일 N통을 받은편지함으로 되돌렸습니다.") — 계약 2절 "여러 통 한꺼번에" | +| 여러 통 처리 · 삭제 | `src/components/mail/MailCheckbox.vue` · `MailSelectionToolbar.vue`(+ `MailBulkButton` · `MailBulkMoveButton`) · `MailPurgeDialog.vue` · `MessageDeleteButton.vue` · `composables/useBulkMailActions.ts` · `useMessageRemoval.ts` · `useMailMoveFeedback.ts` · `shared/utils/mail-selection.ts` · `mail-bulk.ts` | 100통씩 차례로. 읽음 · 중요는 먼저 바꾸고 `failedIds` 만 되돌리고, 이동 · 삭제는 응답의 id 만 뺀다. **웹은 `DELETE /api/messages/{id}` 를 쓰지 않는다** — 휴지통 밖 삭제는 `PATCH` 이동(+ 되돌리기), 휴지통에서는 확인 대화상자(처음 초점 「취소」) 뒤 `POST /message-deletions`. 읽기 화면도 그 메일의 `mailboxIds` 로 같은 규칙을 탄다. 도구 줄은 role="toolbar" 로빙 tabindex(바쁜 · 꺼진 단추는 건너뛴다). [되돌리기] 뒤 알림은 한 메일함이면 그 이름(`restoredMessageText(…, mailboxName)` — "메일 N통을 받은편지함으로 되돌렸습니다.") — 계약 2절 "여러 통 한꺼번에". [이동 ▾]의 옮길 곳 · 스팸 표시 · 끌어다 놓기는 아래 「메일 옮기기 · 끌어다 놓기 · 좁은 읽기 칸 (15단계)」 행 | | 설정 › 비밀번호 · 부재중 (4단계) | `src/pages/settings/AccountPasswordPage.vue` · `MailVacationPage.vue` · `components/settings/`(`SettingsPageFrame` 공용 틀 — 머리 오른쪽 단추는 `actions` 슬롯 · `PasswordChangeForm` · `PasswordField` · `Vacation*`) · `stores/mail-vacation.store.ts` · `shared/utils/password-change.ts` · `mail-vacation.ts` · 메뉴 `shared/constants/settings-menu.ts` | 메뉴는 묶음(계정 › 비밀번호 / 메일 › 서명 · 부재중 자동 응답 · 필터(6단계) · 알림(5단계)), 라우트 이름은 `SETTINGS_ROUTES` 하나. 비밀번호는 서버와 같은 규칙을 먼저 보고, 서버 거절(`AUTH_005` · `VAL_001`)은 칸 아래에만(알림 없음) — 성공하면 세 칸을 비운다. 부재중은 날짜만 고르고 브라우저 시간대의 시작일 0시 · 끝나는 날 다음 날 0시를 UTC 로 보낸다(변환은 `mail-vacation.ts` 한 곳 — 되읽을 때는 1ms 앞 날짜). 켤 때만 검사하고 꺼 둔 채로는 빈 값도 저장한다(길이 150 · 600 도 켤 때만 — 꺼 둔 채로는 4단계 한도까지, 끄기는 늘 저장되게). 부재중 상태는 메일 화면에 들어올 때 받고, 패널 안내 `components/mail/MailVacationNotice.vue` 가 1분마다 다시 계산한다(`useClock` · `VACATION_NOTICE_REFRESH_MS`). 저장 응답은 세션 세대(`authStore.generation`)가 바뀌었으면 쓰지 않는다. 저장한 값이 켜짐인데 엔진에서 규칙이 꺼져 있으면(응답의 `isActive: false` — 7단계) 머리 아래 노란 줄(`MailFilterNotice` 를 단추 없이 — `actionLabel` 을 주지 않는다). 칸을 고친 값이 아니라 저장한 값으로 보고, `isActive` 는 PUT 본문에 넣지 않는다(`useMail().saveVacation` 이 다섯 칸만) — 계약 1절 · 2절 "부재중 자동 응답" | -| 메일함 관리 · 오래 걸리는 처리 (4단계) | `src/components/mail/MailboxTree.vue` · `MailboxPanelRow.vue` · `MailboxMoreButton.vue`(⋯ 메뉴) · `MailboxDialogs.vue`(+ `Mailbox*Dialog.vue`) · `MailboxEmptyButton.vue`(목록 머리 「비우기」) · `composables/useMailboxActions.ts` · `stores/mail/mailbox-admin.ts` · `shared/utils/mailbox-operation.ts` · 상수 `shared/constants/mailbox-manage.ts` | 만들기 · 이름 바꾸기는 칸 안 실패(`MAIL_009` · `VAL_001`)를 알림 없이 칸 아래에. 모두 읽음 · 비우기 · 지우기는 남은 수가 0(지우기는 `deleted: true`)이 될 때까지 다시 보내며 진행 알림을 고친다 — 진행은 응답의 `processed`, 세션이 바뀌면 멈춘다(`epoch` — 늦게 온 만들기 · 이름 바꾸기 응답도 버린다), 같은 메일함의 같은 처리는 하나, **휴지통 비우기와 메일함 지우기는 함께 돌리지 않는다**(비우기는 그사이 휴지통에 들어온 메일도 지운다 — 판정은 스토어의 `isMailboxOperationBlocked` 하나, 메뉴 · 단추 · 대화상자 확인이 모두 이것을 본다). 끝난 뒤 누른 단추가 사라져 초점을 잃었으면 패널의 지금 메일함 항목으로 옮긴다. ⋯ 메뉴는 보이는 영역 안에 다 들게 연다(`shared/utils/popup-placement.ts` — 아래가 모자라면 위로). 되돌릴 수 없는 일은 확인 대화상자(처음 초점 「취소」) 뒤에, 확인한 그 메일함만 — 계약 2절 "메일함 관리" | +| 메일함 관리 · 오래 걸리는 처리 (4단계) | `src/components/mail/MailboxTree.vue` · `MailboxPanelRow.vue` · `MailboxMoreButton.vue`(⋯ 메뉴) · `MailboxDialogs.vue`(+ `Mailbox*Dialog.vue`) · `MailboxEmptyButton.vue`(목록 머리 「비우기」) · `composables/useMailboxActions.ts` · `stores/mail/mailbox-admin.ts` · `shared/utils/mailbox-operation.ts` · 상수 `shared/constants/mailbox-manage.ts` | 만들기 · 이름 바꾸기는 칸 안 실패(`MAIL_009` · `VAL_001`)를 알림 없이 칸 아래에. 모두 읽음 · 비우기 · 지우기는 남은 수가 0(지우기는 `deleted: true`)이 될 때까지 다시 보내며 진행 알림을 고친다 — 진행은 응답의 `processed`, 세션이 바뀌면 멈춘다(`epoch` — 늦게 온 만들기 · 이름 바꾸기 응답도 버린다), 같은 메일함의 같은 처리는 하나, **휴지통 비우기와 메일함 지우기는 함께 돌리지 않는다**(비우기는 그사이 휴지통에 들어온 메일도 지운다 — 판정은 스토어의 `isMailboxOperationBlocked` 하나, 메뉴 · 단추 · 대화상자 확인이 모두 이것을 본다). 끝난 뒤 누른 단추가 사라져 초점을 잃었으면 패널의 지금 메일함 항목으로 옮긴다. ⋯ 메뉴는 보이는 영역 안에 다 들게 연다(`shared/utils/popup-placement.ts` — 아래가 모자라면 위로 · 좌우가 넘치면 반대 끝으로(15단계 — EditorPopup)). 되돌릴 수 없는 일은 확인 대화상자(처음 초점 「취소」) 뒤에, 확인한 그 메일함만 — 계약 2절 "메일함 관리" | | 스팸 · 원문 (4단계) | `src/components/mail/MessageSpamButton.vue`(읽기 화면) · 도구 줄의 스팸 단추(`MailSelectionToolbar.vue`) · `MessageMoreMenu.vue`(⋯ › 원문 내려받기) | 스팸 신고 · 스팸 아님은 이동과 `isJunk` 를 한 요청에 보내고(`mailStore.moveMessages(…, { isJunk })`), [되돌리기]는 원래 메일함으로 반대 값을 보낸다. 원문(.eml)은 `useMail().downloadMessageSource()` — 첨부처럼 `confirmSession()` 을 먼저 거친다 — 계약 2절 "스팸 · 원문" | | 메일 필터 (6단계) | `src/pages/settings/MailFilterPage.vue` · `components/settings/MailFilter*.vue`(목록 · 행 · 목록 위 줄 `MailFilterNotice` · 빈 상태 · 안내 상자 `MailFilterNotes` · 고치기 창 `MailFilterDialog` + 조건 `MailFilterConditionFields` · `MailFilterConditionRow` · 할 일 `MailFilterActionFields`) · `mail-filter-ui.ts` · `stores/mail-filter.store.ts` · `composables/useMailFilterActions.ts`(켜기 · 순서 · 지우기 · 되돌리기 알림) · `useMailFilterDialog.ts`(창 상태) · `shared/utils/mail-filter.ts`(검사 · 요약 · 옮길 곳 · 되돌릴 자리) · `shared/constants/mail-filter.ts` · 읽기 화면 ⋯ 「이런 메일로 필터 만들기」(`MessageMoreMenu.vue` → `/settings/mail/filters?from=`) | 바꾸는 일은 모두 `PUT /api/mail-filters` 전체 교체(받은 `version`)이고 **저장은 한 번에 하나**(`saving`). 켜기 · 순서는 화면을 먼저 바꾸고 실패하면(그사이 목록이 안 바뀌었으면) 되돌린다. 창의 저장은 그 순간 목록의 켜짐 값을 쓴다. [되돌리기]는 지운 필터만 앞 · 뒤 필터 기준 자리에 다시 넣고, 누른 차례대로 앞 저장이 끝난 뒤 보낸다(`restoreQueue` — 기다림과 `send()` 사이에 await 를 두지 않는다 — 2026-10-28 리뷰의 경합). `MAIL_012` 는 목록 위 줄 + 창 안 안내와 [다시 불러오기](쓴 내용을 지키고 다시 받은 목록 위에 저장), 늦게 온 409 는 보낼 때의 순번(seq)이 그대로일 때만 줄을 켠다. 검사는 서버와 같은 규칙(전달 주소의 @ 앞은 dot-atom · 64자로 서버보다 좁게)이고 거절은 칸 아래(`VAL_001` · `MAIL_013` 은 알림을 끈다). 필터 화면이 떠 있지 않을 때 실패한 되돌리기는 알림으로(`attachPage` · `pageVisible`). 세션이 바뀌면 늦은 응답을 버린다(`authStore.generation`) — 계약 2절 "메일 필터", 디자인 README "6단계" | | 이미 받은 메일에 필터 적용 (7단계) | `src/components/settings/MailFilterApplyField.vue`(고치기 창의 「받은편지함에 이미 있는 메일도 정리하기」 — 창을 열 때마다 꺼짐) · `composables/useMailFilterRun.ts`(진행 · 결과 알림) · `stores/mail-filter-run.store.ts`(cursor 를 이어 보내기 — 탭마다 하나 `running`) · `shared/utils/mail-filter-run.ts`(할 일 판정 · 보낼 규칙 · 끝 알림의 메일함 · 문구) | 저장이 성공한 뒤에만 `POST /api/mail-filter-runs` 를 `cursor` 가 null 이 될 때까지 다시 보내며 진행 알림을 고친다(4단계 오래 걸리는 처리와 같은 결 — 전체는 확인 + `remaining`, 첫 응답 전에는 받은편지함의 메일 수). 전달은 보내지 않는다. 이미 받은 메일에 할 일이 없는 필터(옮길 메일함이 지워짐 · 받은편지함으로 옮기기만 · 전달만)는 칸을 끄고 시작하지도 않는다 — 판정은 `filterRunIdleReason` 하나(칸 `filterApplyBlock` · 시작 `filterRunRuleOf` 가 함께 쓴다), 이 탭에서 적용이 도는 중에도 칸을 끈다. 요청은 `silent` 로 보내고 실패는 응답이 온 때 세션이 그대로일 때만 스토어가 알린다(`REQ_001` — 다른 창 · PC 에서 적용 중 — 은 화면이 "다른 창에서 …"로). 멈추면 [다시 시도](받은 `cursor` 부터 — 수도 이어 센다), 나아가지 못한 응답이 거듭되면(`MAIL_FILTER_RUN_MAX_STALLS`) 멈춘다. 다른 화면으로 가도 이어 하고, 세션이 바뀌면 다음 요청을 보내지 않고 알림을 닫는다. 끝났거나 나아간 뒤 멈췄으면 개수 · 목록을 맞춘다 — 계약 2절 "이미 받은 메일에 적용", 디자인 README "7단계" | @@ -70,6 +70,8 @@ description: Vue 3 프론트엔드(frontend/) 개발 규칙 — Composition API | 계정 보안 (12단계) | 로그인 코드 단계 `src/components/auth/LoginCodeStep.vue` · `composables/useLoginChallenge.ts` · 설정 `pages/settings/AccountTwoFactorPage.vue` · `AccountAppPasswordsPage.vue` · `AccountLoginHistoryPage.vue`(+ 비밀번호 화면의 코드 칸 `PasswordChangeForm`) · `components/settings/`(`OtpCodeField` · `TwoFactorEnableDialog` + `TwoFactorEnrollmentScan` · `TwoFactorDisableDialog` · `AppPassword*` · `MailClientSettings` · `LoginSessionList` · `LoginEventTable` · `EndOtherSessionsDialog`) · QR `components/QrCodeImage.vue`(`uqr` 0.1.3 — SVG `` v-for, v-html 없음) · API `composables/useAccountSecurity.ts` · 스토어 `two-factor` · `app-passwords` · `login-history` · 상수 `shared/constants/account-security.ts`(상한 5 · 이름 40자 · 10분 · `MAIL_CLIENT_SETTINGS`) | **비밀은 화면 메모리에만** — 코드 단계의 비밀번호는 반응형이 아닌 클로저(`useLoginChallenge` — 성공 · [다른 계정으로 로그인] · 떠날 때 · 10분(타이머와 시계 `startedAt` 둘 다)에 지운다, 화면이 사라진 뒤의 늦은 `twoFactorRequired` 로는 시작하지 않는다), 2단계 키 · otpauth 주소 · 앱 비밀번호 값은 창 안에만 두고 닫을 때 지운다. **비밀이 드는 요청은 Pinia 액션에서 부르지 않는다** — devtools 타임라인이 액션의 인자 · 결과를 기록한다(창이 `useAccountSecurity` 로 보내고 스토어에는 비밀 없는 결과만 — `apply(status)` · `add(값을 뺀 줄)`). 세션 세대(`authStore.generation`)가 바뀌면 비밀이 든 창을 모두 닫는다(12단계부터 다른 기기의 로그아웃이 이 세션을 바로 끊는다). 창은 "몇 번째로 연 창인가"로 늦은 응답을 거른다 — 단 서버가 이미 켰거나 만든 결과는 반영한다. `login()` 은 `{ status }` 를 돌려준다. 코드로 나눈다: `AUTH_006` 칸 아래 · `AUTH_007` 코드 칸 보이기(6자리가 아닐 때도 이 문구) · `AUTH_008` 처음부터(새 키) · `AUTH_009` 상한. 지금 브라우저 줄이 없으면 [다른 곳 모두 로그아웃]은 "이 브라우저를 포함해"로 묻는다. **로그인된 기기**(2026-11-06 — 앱 세션이 함께 보인다): 웹 · 앱은 세션의 `client` · 로그인 기록의 `method` 로만 가른다(User-Agent 이름으로 짐작하지 않는다 — `shared/utils/account-security.ts` 의 `sessionCounts` · `isMobileSession`), 앱 이름은 `MOBILE_APP_NAME`(짝: backend `APP_BROWSER_NAME`) — 계약 5절, 디자인 README "12단계" | | 전환 준비 (13단계) | 제한된 세션 `stores/auth.store.ts`(`restriction` · `twoFactorDeadline` · `restrictionChecks` · `canUseApp`) · `shared/utils/auth-session.ts` · `router/index.ts` 가드 + `composables/useSessionRestriction.ts`(App) · `useApi.ts` 의 `recheckRestriction` · 로그인 틀 단계 `components/auth/LoginPasswordChangeStep.vue` · `LoginTwoFactorSetupStep.vue` · `LoginTwoFactorScan.vue`(QR 148 — `QrCodeImage` 의 `compact`) · `LoginAccountRow.vue` · `LoginSupportLine.vue` · `composables/useLoginSecret.ts` · `shared/constants/session-restriction.ts` · 문의 연락처 `stores/support-contact.store.ts` · `shared/utils/support-contact.ts` · 유예 줄 `components/mail/TwoFactorGraceNotice.vue`(AppShell 의 `notice` 슬롯 — MailLayout 에만) · 메일 프로그램 연결 `pages/settings/AccountMailClientsPage.vue` + `components/settings/MailClientGuideList.vue` · `shared/constants/mail-clients.ts` · 30일 줄 `MailListFrame`(`MAILBOX_RETENTION_NOTICES` · `MAILBOX_EMPTY_HINTS`) | **제한된 세션**(계약 6절): login · me · refresh 의 `restriction` 이 있으면 보호 화면 대신 로그인 틀의 그 단계(새 비밀번호 → 2단계 켜기)로 — 403 `AUTH_010` 은 알림 없이 me 를 다시 받고(제한 없음이면 refresh 후 원래 요청 한 번만), 서버가 제한을 확인할 때마다(`restrictionChecks`) 다시 옮긴다. 제한된 동안은 `canUseApp` 이 false 라 나가기 확인을 묻지 않고(세션이 끝난 것처럼 — 2026-11-02 리뷰: 확인에서 이동을 취소하면 메일 화면에 갇혔다) 실시간 알림 · 새 메일 확인을 열지 않는다. 모르는 제한 이름은 `unknown` — 갱신 · 다시 보내기 없이 사전 문구 + [다른 계정으로 로그인]만. **비밀**: 로그인 때 넣은 비밀번호는 반응형이 아닌 클로저에 10분(서버가 마지막으로 확인한 때부터) — 성공 · 다른 계정 · 떠남 · `isLoggedIn` false 때 지운다, 거절(`AUTH_005`)되면 지우고 칸을 보인다. 성공한 로그인 코드도 지운다(`resetCodeStep`). 단계를 마친 뒤 refresh 가 장애면 me 를 먼저 묻는다(제한이 있으면 그 단계 · 없으면 이어 감 · me 도 실패면 위쪽 상자 + [다시 시도]). 2단계를 켠 직원의 새 비밀번호 단계는 다음 코드를 받는다(로그인 코드는 2분 다시 못 쓴다). **유예 줄**: 높이 40 · [지금 켜기] 36(안내 줄 밀도 예외 재사용), 날짜는 `twoFactorDeadline` 의 1ms 앞(올해가 아니면 해), 닫기 없음, 마감이 있으면 `GET /two-factor` 를 한 번 물어 이미 켠 직원에게는 숨긴다. 설정 › 2단계 인증은 `isRequired` · `deadline` — 필수 · 켜짐이면 끄기 대신 글. **메일 프로그램 연결**: 펼침 줄 52(`--size-header-min` 재사용 — 예외 아님) · 한 번에 하나 · 처음 모두 접힘(h3 > button · `aria-expanded` · region), 글은 상수가 원본(디자인 README 13단계), 앱 비밀번호 화면에 바로가기 줄, 아이콘 `monitor-smartphone`. `PasswordField` · `OtpCodeField` 의 `variant="login"`(로그인 틀 칸 모양), `PasswordField` 의 `readonly`. 12단계 코드 단계의 도움말은 문의 줄로 바뀌었다(`LOGIN_TWO_FACTOR_TEXT.help` 삭제) — 계약 6절, 디자인 README "13단계" | | 휴대폰 앱 받기 (2026-11-08) | 설정 › 계정 › 휴대폰 앱 `src/pages/settings/AccountMobileAppPage.vue` · 앱 틀 밖 받기 화면 `pages/mobile-app/MobileAppPage.vue`(`/app` — 인쇄 화면처럼 `meta.requiresAuth` 만) · `components/mobile-app/`(`MobileAppDownloadLink` · `MobileAppIcon` · `MobileAppSteps` · `MobileAppIphoneNote`) · `stores/app-release.store.ts` · API `composables/useAppReleases.ts` · `shared/utils/app-release.ts` · `shared/types/app-release.ts` · 글 `shared/constants/mobile-app.ts`(`MOBILE_APP_PATH` · `MOBILE_APP_ROUTE` — 글은 이 상수가 원본) | 안드로이드 APK 를 로그인한 직원만 받는다(스토어 없음). 판 정보 `GET /api/app-releases/android/latest` 는 들어올 때마다 `silent` 로 받는다 — 404 `RES_001` 은 "받을 앱 없음"(알림 없음 · 안내와 문의 줄만), 그 밖의 실패는 칸 자리에 `MailLoadError`(사전 문구 + [다시 시도]), 세션 세대(`authStore.generation`)가 바뀌면 늦은 응답을 버리고 값을 지운다. [앱 내려받기]는 첨부처럼 `` + 이동 직전 `confirmSession()`(`…/latest/file`). QR(`QrCodeImage`)은 `location.origin + '/app'` 만 담는다(비밀 아님) — `/app` 은 앱의 [새 버전 받기]도 여는 약속이라 경로를 바꾸면 앱도 함께. 문의 줄은 `LoginSupportLine`(`/app` 은 `size="base"` 13). 올린 날은 브라우저 시간대(`formatLongDate`). 설정은 좁은 폭(480 이하)에서 QR 줄을 빼고 [앱 내려받기]를 전체 폭으로, `/app` 은 휴대폰 폭이 먼저 · 480 초과는 띠 전체 폭 + 가운데 칸 480 · [앱 내려받기] 48(밀도 예외 아님). 글자 17 토큰 `--font-size-title-md` — 디자인 README "휴대폰 앱 받기" | +| 메일함 옮기기 · 하위까지 지우기 · 차단 정리 · 지금 보내기 (15단계) | 메일함 나무 도우미 `src/shared/utils/mailbox.ts`(`mailboxTree` · `mailboxSpokenName` · `mailboxDisplayName` · `mailboxMoveTargets` · `canHoldChildMailbox` · `sameMailboxName` · `descendantMailboxes`) · 옮기기 창 `components/mail/MailboxMoveDialog.vue`(+ `MailboxDialogs.vue` · `mailbox-dialog.ts` 의 `move`) · 지우기 확인 `MailboxDeleteDialog.vue`(`childCount`) · `composables/useMailboxActions.ts`(`move` · `remove(…, { withChildren })`) · `stores/mail/mailbox-admin.ts`(`moveMailbox` · `runMailboxOperation` 의 `withChildren`) · 글 `shared/utils/mailbox-operation.ts`(`mailboxMoveText`) · `shared/constants/mailbox-manage.ts`(`MAILBOX_MOVE_TEXT` · `MAILBOX_PATH_TEXT`) · 차단 창 `MessageBlockDialog.vue`(새 칸) · `MessageMoreMenu.vue` · `composables/useSenderBlock.ts`(`cleanInbox`) · `useSenderBlockRun.ts`(진행 · 결과 알림) · `stores/mail-block-run.store.ts`(`inboxBusy` · `startBlock`) · 되풀이 `stores/inbox-cleanup-run.ts`(필터 적용 · 차단 정리 공용) · 글 `shared/utils/mail-block.ts` 의 `mailBlockRunCopy` · `shared/constants/mail-filter.ts` 의 `INBOX_CLEANUP_TEXT` · 지금 보내기 `MessageReaderNotices.vue`([지금 보내기]) · `composables/useScheduledMessageActions.ts`(`sendNow`) · `stores/mail/scheduled.ts`(`releasePendingSend`) · API `composables/useMail.ts`(`moveMailbox` · `deleteMailbox` 의 `withChildren` · `runSenderBlock` · `releaseScheduledMessage`) | 계약 2절 "메일함 관리" · "보낸 사람 차단" · "보내기 취소 · 예약 발송". **나무 도우미는 한 곳** — [이동 ▾] 들여쓰기 · 끌어다 놓기 알림 · 옮기기 창이 함께 쓴다(알림 이름은 같은 이름이 다른 곳에도 있을 때만 "거래처 › 2025", 같은 자리 겹침은 대소문자 무시 — 엔진과 같다). 옮기기는 `PATCH /mailboxes/{id}` 의 `parentId`(null 은 맨 위) — 옮길 곳은 「하위 메일함 만들기」가 있는 곳과 같고, 자기 · 하위 · 지금 있는 곳 · 같은 이름이 있는 곳은 처음부터 고를 수 없다(radiogroup — 꺼진 줄에도 초점은 간다). `MAIL_009` 만 창 안, 결과 알림 + [되돌리기](원래 상위로). 하위까지 지우기는 확인 창이 물은 뒤에만 `?withChildren=true` — 나무 전체를 "지우는 중"으로 두고 수도 나무 전체로 센다, 끝나면 나무째 뺀다. 차단 창의 새 칸(처음 꺼짐)을 켜면 받은편지함의 이 메일은 첫 칸을 켠 채 잠그고(aria-disabled) 먼저 옮긴 뒤 `POST /mail-block-runs` 를 cursor 가 null 이 될 때까지 — 차단 알림 대신 진행 알림 하나가 결과로 바뀐다([스팸메일함 보기] · [다시 시도] — 다른 창에서 하는 중이어도 단추, [되돌리기] 없음). **받은편지함 정리는 한 직원에 한 번에 하나** — 이 탭의 필터 적용 · 다른 차단 정리가 도는 동안(`inboxBusy`)과 이 탭에서 스팸메일함을 비우는 동안 새 칸을 끈다(`startBlock` — 까닭 글이 다르다, 필터 적용은 스팸메일함 비우기를 보지 않는다), 다른 창은 서버가 429 `REQ_001`(차단 정리는 "다른 창에서 받은편지함이나 스팸메일함을 정리하는 중입니다. …" — 필터 적용은 "…받은편지함을 정리하는 중입니다. …"). [지금 보내기]는 `POST /scheduled-messages/{id}/release` — 응답의 `pendingSend` 가 undo 면 쓰기 화면과 같은 마감 알림 + [보내기 취소](`undoToastDeadline`), null 이면 "메일을 보냈습니다." + [보낸편지함 보기], 예약됨에서 빠진 뒤의 이동은 [예약 취소]와 같다. 503 `MAIL_001` · 결과를 모르는 실패(`SYS_001` — 앞단 502 · 504 · 시간 초과 · 연결 끊김)는 그 메일을 다시 받아 가른다(`shared/utils/pending-send.ts` 의 `unsentReleaseState`, 스토어의 `ReleaseOutcome`): 임시보관함 [임시보관함 보기] · 예약 그대로(확인함) "지금 보내지 못했습니다. …" + [다시 시도](안내 줄 그대로) · 예약만 풀림 · 다시 받지 못함은 이미 나갔을 수 있어 못 보냈다고 단정하지 않는다 — "메일을 보냈는지 확인하지 못했습니다. …" + [다시 시도](예약만 풀림은 안내 줄을 내리고, 다시 받지 못함은 마지막으로 안 그대로. [다시 시도]는 이미 보낸 메일이면 서버가 200) · 다시 낸 발송이 기다림은 보낸 것과 같다. 차단 정리와 이 탭의 스팸메일함 비우기는 서로 막는다(`isMailboxOperationBlocked` · `startBlock` — 다른 창끼리는 서버 잠금이 요청 하나씩이라 엇갈릴 수 있다, 알려진 한계) — 디자인 README "15단계" | +| 메일 옮기기 · 끌어다 놓기 · 좁은 읽기 칸 (15단계) | 읽기 화면 [이동 ▾] `src/components/mail/MessageMoveButton.vue` · 목록 [이동 ▾] `MailBulkMoveButton.vue` · 메뉴 항목 `components/mail/mail-move-menu.ts` · 옮길 곳 규칙 `shared/utils/mail-move-targets.ts`(`mailMoveTargets` · `hasMoveTarget` · `canDropMailOn` · `hereMailboxIds` · `junkMarkForMove`) · 끌기 `composables/useMailDrag.ts`(`useMailRowDrag` · `activeMailDrag` · `takeMailDrop` · `mailDragging` — 목록 줄 넷이 쓴다) · 끄는 표 `components/mail/MailDragChip.vue`(MailLayout) · 놓기 `MailboxPanelRow.vue`(모양 · 받을지) → `MailboxTree.vue` → `useBulkMailActions` 의 `moveDragged` · 알림 이름 `useMailMoveFeedback` 의 `placeName` · 좁은 읽기 칸 `MessageReader.vue`(컨테이너 쿼리) + `MailToolButton.vue`(`collapsible` · `menu`) · 파일 놓기 무시 `composables/useWindowFileDrop.ts` 의 `useIgnoredWindowFileDrop`(앱 틀 `AppShell`) · 시험 도구 `src/test-utils/drag.ts` | 옮길 곳은 메일함 패널 차례 · 깊이(위 「메일함 옮기기 …」 행의 나무 도우미)이고 메일이 든 메일함 · 임시보관함 · 휴지통은 빼되 하위가 있으면 고를 수 없는 줄(읽기 "이 메일이 있는 메일함" · 목록 "고른 메일이 있는 메일함")로 남긴다. 들여 쓴 항목은 `EditorMenuEntry` 의 `depth` · `ariaLabel`. **스팸메일함으로 옮기는 세 길 같게**: 메뉴 · 끌기로 스팸메일함에 넣으면 `isJunk: true`, 스팸메일함에서 휴지통 밖으로 꺼내면 `isJunk: false`(`junkMarkForMove` — [스팸 신고] · [스팸 아님]과 같은 알림 · 되돌리기). 알림의 메일함 이름은 `placeName`(나무 도우미의 `mailboxDisplayName` — 옮김 · 되돌림 · 대화 목록의 "{메일함}에 있는 …" 모두). 끌기는 우리 자료(`MAIL_DRAG_TYPE` + 이 끌기의 표)만 싣고 링크의 주소는 지운다(`clearData`) — 끄는 동안의 자료는 탭 메모리(dragover 에서는 값을 읽지 못한다), 놓을 때 표가 같아야 받는다. 줄은 `:draggable="!held"` · 끌 수 없는 줄에서 시작한 링크 끌기는 dragstart 에서 막는다. 놓을 수 있는 줄만 dragover 를 받는다(나머지는 브라우저 금지 커서) · 들어온 요소 수로 점선을 켜고 끄며 끌기가 끝나면(`mailDragging`) 끈다. 파일 끌기는 받지 않는다. 앱 틀(메일 · 주소록 · 설정)에 놓은 파일은 무시하되 받는 화면(`useWindowFileDrop` — 쓰기 화면 · 서명 설정)이 떠 있으면 그 화면에 맡긴다. **좁은 읽기 칸**: 도구 줄 띠가 컨테이너(`container: reader-tools / inline-size`) — 분할 보기 580 · 휴지통 605(실측 569.19 · 594.83 에 여유) · 목록 보기 850 · 875 보다 좁으면 이름을 숨긴다(목록 보기는 단추 줄 최대 폭을 푼다, 화면 폭 기준은 tokens.css 머리). 접히는 단추는 `collapsible`(aria-label · title · `.tool-button__label`) — 계약 2절, 디자인 README "15단계" | | 시계 주입 | `src/composables/useClock.ts` · `src/test-utils/manual-clock.ts` | 타이머를 쓰는 로직(자동 저장 등)은 시계를 주입받는다 — 테스트는 손으로 돌리는 시계로 시간을 넘긴다(고정 대기 없이, [[testing]]) | | 나가기 전 확인 | `src/composables/useLeaveConfirm.ts` · `src/shared/utils/compose-changes.ts` | 화면을 연 뒤(또는 마지막으로 저장한 뒤) 바꾼 것이 있으면 앱 안 이동(라우트 떠남·바뀜)은 확인 대화상자, 탭 닫기·새로고침은 `beforeunload`. 쓰기 화면은 떠나기 전에 먼저 저장하고 저장하지 못한 것이 있을 때만 묻는다(서명 설정은 저장하지 않은 서명이 있으면 묻는다). 가드가 도는 중은 `guarding`. 바꿈 여부는 연 직후(저장한 뒤에는 마지막으로 저장한 판)의 스냅숏과 비교한다(편집기는 `isChanged()`). 떠나기 전 저장은 5초까지만 기다린다. 보낸 뒤·보내는 중·세션이 끝났을 때는 묻지 않는다 — 보내는 중에 떠나면 결과는 토스트로만 알리고 떠난 양식·새 양식을 건드리지 않는다(양식 세대로 늦게 온 결과를 버린다, 디자인 README "나가기 전 확인"). **고치던 모달 창**(필터 고치기 창 — 7단계)은 `composables/useDialogLeaveConfirm.ts` — 화면의 확인에 "창 닫기" 길을 더한다: [취소] · Esc 는 `asking: 'close'`([계속 고치기]면 창을 그대로 — 브라우저가 닫았으면 `reshow()`), 다른 화면은 `'route'`, 탭 닫기 · 새로고침은 `beforeunload`. 보내는 중 · 세션이 끝났을 때는 묻지 않는다 | | 읽기 화면 본문 안내 | `src/shared/utils/mail-body-notices.ts` · `shared/constants/mail.ts` 의 `MAIL_BODY_NOTICES` | 서버의 `isHtmlBodyOmitted`·`isBodyTruncated`(계약 2절 "읽기 한도")를 외부 이미지 차단과 같은 안내 줄로 보인다(`.reader__notice[data-notice]`) | diff --git a/CLAUDE.md b/CLAUDE.md index 668be15..5e3a9a0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,6 +1,7 @@ # 프로젝트 개요 -이 저장소는 **풀스택 모노레포 기본 템플릿**이다. 누구나 클론하여 바로 개발을 시작할 수 있도록 공통 규칙과 언어별 규칙을 정의한다. +이 저장소는 **ARA SYSTEM** — 사내 그룹웨어(첫 모듈은 메일: 웹메일 · 휴대폰 앱)다. 백엔드 · 웹 · 앱과 메일 엔진 · 운영 설정을 한 저장소에서 다루고, 여기에 공통 규칙과 스택별 규칙을 둔다. +프로젝트 소개 · 문서 지도는 [`README.md`](README.md), 작업 차례는 [`docs/roadmap.md`](docs/roadmap.md). | 디렉터리 | 스택 | 역할 | 개발 규칙 (스킬) | | ----------- | -------------- | ------------------------ | ----------------------------------------- | diff --git a/README.md b/README.md index 93ab551..eac22dc 100644 --- a/README.md +++ b/README.md @@ -1,109 +1,97 @@ -# 풀스택 모노레포 기본 템플릿 +# ARA SYSTEM -NestJS(백엔드) · Vue 3(웹) · Flutter(모바일)를 하나의 저장소에서 운영하는 풀스택 템플릿입니다. -세 클라이언트는 **동일한 표준 응답 포맷**과 **공통 에러 코드 맵**을 공유하며, Docker Compose로 한 번에 구동됩니다. +사내 그룹웨어다. 첫 모듈은 **메일** — 웹메일(포털)과 휴대폰 앱(안드로이드)이다. +메일 엔진(Stalwart)이 메일을 주고받고, 우리 서버(NestJS)가 엔진을 감싸 웹과 앱에 같은 API 를 준다. 화면은 직접 만든다(Vue 3 · Flutter). +`@arasystem.it.kr` 메일은 2026-11-05 옛 서버(mailcow)에서 옮겨 와 전 직원이 쓰고 있다. + +## 지금 + +- 운영: 메일 1~13단계. 메일 14단계(자체 검색 색인 · 같은 대화)와 앱 첫 판 · 휴대폰 앱 받기는 main 에 합쳤고 서버 배포를 기다린다. +- 개발 중: 메일 15단계(메일 · 메일함 다루기). 다음은 16단계(개인 설정 — 주소록 가져오기 · 여러 서명 등). +- 단계별 내용 · 다음 차례 · 미뤄 둔 후보는 [`docs/roadmap.md`](./docs/roadmap.md). ## 구성 -| 디렉터리 | 스택 | 역할 | 포트(기본) | -| ----------- | -------------- | ------------------------ | ---------- | -| `backend/` | NestJS + TypeORM | REST API 서버 | 3000 | -| `frontend/` | Vue 3 + Vite | 웹 프론트엔드 | 5173 | -| `flutter/` | Flutter + Riverpod | 모바일 앱 | - | -| `db` | PostgreSQL | 데이터베이스 | 5432 | -| `redis` | Redis | 캐시 / 세션 (**기본 비활성**) | 6379 | -| `mail` | Stalwart (JMAP) | 사내 메일 엔진 — 직원 로그인 = 메일 계정 (profile `mail`, [`mail/README.md`](./mail/README.md)) | 운영 25·465·993·443 / 개발 관리 8180 | -| `mailpit` | Mailpit | **개발 전용** — 사내 도메인 밖으로 보낸 메일 수집 | 8025 | +| 이름 | 스택 | 하는 일 | 포트(개발) | +| --- | --- | --- | --- | +| `backend/` | NestJS · TypeORM | 웹 · 앱이 함께 쓰는 REST API — 메일 엔진 중계, 로그인 세션, 검색 색인, 앱 알림, 대용량 첨부 | 3000 | +| `frontend/` | Vue 3 · Vite | 웹메일 화면. 운영은 nginx 가 화면과 같은 주소의 `/api` 를 백엔드로 넘긴다 | 5173 | +| `flutter/` | Flutter · Riverpod | 휴대폰 앱(안드로이드 — 스토어 없이 사내 배포) | - | +| `db` | PostgreSQL 15 | 로그인 세션 · 설정 · 검색 색인 · 대용량 첨부 기록 등 | 5432 | +| `mail` | Stalwart | 메일 엔진(JMAP · IMAP · SMTP) — 직원 로그인 = 메일 계정 | 관리 8180(운영은 25 · 465 · 993 · 443) | +| `mail-resolver` | unbound | 메일 엔진 전용 DNS(스팸 차단 목록 · DNSSEC) | - | +| `clamav` | ClamAV | 대용량 첨부 악성 검사(운영은 늘, 개발은 profile `clamav`) | - | +| `mailpit` | Mailpit | 개발 전용 — 바깥 주소로 보낸 메일을 모은다 | 8025 | +| `redis` | Redis | 기본 꺼짐 — 서버를 여러 대로 늘릴 때의 자리 | 6379 | +| `ops/` | 셸 · systemd | 운영 서버의 백업(restic) · 15분 점검 · 부팅 때 되살리기 | - | +| `ci/` | Gitea Actions 러너 | PR 마다 lint · 시험 · 빌드 · 인프라 검사 | - | + +## 문서 지도 + +| 알고 싶은 것 | 문서 | +| --- | --- | +| 서버 · 웹 · 앱이 함께 지키는 API 약속 | [`docs/mail/api-contract.md`](./docs/mail/api-contract.md) | +| 웹 화면 디자인 · 화면 문구 규칙 · 단계별 화면 | [`docs/design/webmail/README.md`](./docs/design/webmail/README.md) — 디자인 원본은 같은 폴더의 `*.dc.html`(캔버스 보드) | +| 앱 화면 | [`docs/design/app/README.md`](./docs/design/app/README.md) | +| 메일 엔진 설정 · 운영 배포 · 백업 · 감시 · 전환 | [`mail/README.md`](./mail/README.md) | +| 앱 개발 실행 · 사내 배포(서명 키 · APK 올리기) | [`flutter/README.md`](./flutter/README.md) | +| CI 러너 | [`ci/README.md`](./ci/README.md) | +| 개발 규칙(공통 · 스택별 · 역할) | [`CLAUDE.md`](./CLAUDE.md) · 아래 "개발 규칙" | +| 로드맵 | [`docs/roadmap.md`](./docs/roadmap.md) | +| 로고 · 색 | [`docs/brand/`](./docs/brand/) | ## 사전 요구사항 -- Docker / Docker Compose — backend·frontend·db·redis 구동 (컨테이너 안 런타임은 Node 24 LTS — `node:24.22.1-alpine3.24` 고정, 갱신 규칙은 docker 스킬 "Node 버전") -- Node.js **24 LTS 권장** — 호스트에서 프론트엔드 dev 서버·메일 setup 스크립트를 실행할 때 필요하다. - 프론트 `engines` 는 `^20.19.0 || >=22.12.0` 이라 20.19 이상이면 돌아가지만, 이미지·CI(24)와 맞추는 것을 권장한다 - (메일 setup 스크립트는 `--env-file` 때문에 20.6 이상) -- Flutter SDK `^3.11` — 모바일 앱 실행용 (compose 대상 아님) +- Docker / Docker Compose — 백엔드 · DB · 메일 엔진(컨테이너 안 Node 는 24 LTS 로 고정 — docker 스킬 "Node 버전") +- Node.js 24 LTS — 호스트에서 웹 개발 서버 · 메일 setup 스크립트를 돌릴 때 +- Flutter 3.41.9(Dart 3.11.5) — 앱(compose 대상이 아니다 — [`flutter/README.md`](./flutter/README.md)) -## 빠른 시작 (Docker) +## 빠른 시작 (개발) ```bash -# 1) 환경변수 파일 생성 (예제 복사) -# 실제 설정값은 루트 .env.development 한 곳에서 관리한다 (SSOT). -# backend/frontend 는 docker-compose 가 값을 주입하므로 예제만 복사하면 된다. -cp .env.development.example .env.development # ← 여기에만 값 입력 -cp backend/.env.development.example backend/.env.development # (존재 필요: JWT 등 고유 시크릿) -# frontend 는 compose 가 빌드 시 값을 주입하므로 자체 env 복사 불필요 +# 1) 환경변수 파일 — 값은 루트 .env.development 한 곳에 쓴다(SSOT) +cp .env.development.example .env.development +cp backend/.env.development.example backend/.env.development # 있어야 한다(JWT 등 백엔드 고유 비밀) -# 2) 백엔드 스택 기동 (backend + db, 기본 COMPOSE_PROFILES=mail 이면 mail + mailpit 포함) +# 2) 백엔드 스택 — backend + db, 기본 COMPOSE_PROFILES=mail 이면 mail · mail-resolver · mailpit 까지 docker compose -f docker-compose.yml -f docker-compose.dev.yml --env-file .env.development up -d -# 2-1) 메일 엔진 초기화 — 처음 한 번 (여러 번 실행해도 안전, mail/README.md) +# 3) 메일 엔진 초기화 — 처음 한 번(여러 번 돌려도 안전하다). 개발 시험 계정도 여기서 만든다(mail/README.md) node --env-file=.env.development mail/scripts/setup.mjs -# 3) 의존성 변경 시에만 --build -docker compose -f docker-compose.yml -f docker-compose.dev.yml --env-file .env.development up -d --build - -# 4) 프론트엔드는 호스트에서 직접 띄운다 (컨테이너보다 수십 배 빠르다) +# 4) 웹은 호스트에서 띄운다 — /api 는 vite 프록시가 백엔드 컨테이너로 넘긴다 cd frontend && npm install && npm run dev ``` -기동 후: +- 웹 · API · Swagger +- 의존성을 바꿨을 때만 `up -d --build`(백엔드는 `--renew-anon-volumes` 도 — docker 스킬). +- 백엔드를 처음 띄우면 watch 빌드에 수십 분이 걸릴 수 있다(Windows 바인드 마운트). 브랜치를 바꿨다고 다시 시작하지 않는다. +- 웹을 컨테이너로 띄워야 하면 `--profile frontend`(느리다 — 호스트 vite 와 함께 띄우지 않는다). 운영(`docker-compose.yml` 단독)은 nginx 정적 호스팅이다. +- 포트는 루트 `.env.{env}` 를 따른다(`FRONTEND_PORT` · `BACKEND_PORT` — `vite.config.ts` 가 직접 읽는다). 포트가 이미 쓰이면 다른 포트로 옮기지 않고 실패한다. +- Redis 는 뜨지 않는다. 필요해지면 `COMPOSE_PROFILES` 에 `redis` 를 더한다. -- 웹: -- API: -- Swagger 문서: +전체 실행 · 배포 명령은 [`.claude/skills/docker/SKILL.md`](./.claude/skills/docker/SKILL.md). -> **개발 환경에서 프론트엔드는 컨테이너로 뜨지 않습니다.** -> vite 를 컨테이너에서 돌리면 소스가 Windows 바인드 마운트 너머에 있어 첫 서빙까지 수 분이 걸립니다. -> 호스트에서 `npm run dev` 로 띄우면 1~2초이고, `/api` 요청은 vite 프록시가 백엔드 컨테이너로 넘깁니다. -> 컨테이너로 띄워야 한다면 `--profile frontend` 를 붙이세요 (아래 "프론트엔드 실행 방식" 참조). -> 운영(`docker-compose.yml` 단독)은 그대로 nginx 정적 호스팅으로 동작합니다. - -> Redis 는 기본적으로 뜨지 않습니다. 필요해지면 루트 `.env.{env}` 에 `COMPOSE_PROFILES=redis` 를 설정하세요. - -### 프론트엔드 실행 방식 - -| 방식 | 명령 | 첫 서빙 | 용도 | -| --- | --- | --- | --- | -| **호스트 vite (개발 기본)** | `cd frontend && npm run dev` | 1~2초 (최초 의존성 사전번들 10초 내외) | 일상 개발 | -| 컨테이너 vite (선택) | `docker compose ... --profile frontend up -d` | 수 분 (Windows 바인드 마운트) | 호스트에 Node 를 둘 수 없을 때 | -| nginx 정적 (운영) | `docker compose --env-file .env.production up -d --build` | - | 배포 | - -> 컨테이너 방식을 상시 사용하려면 루트 `.env.development` 에 `COMPOSE_PROFILES=frontend` 를 넣습니다. -> (redis 와 함께 켤 때는 `COMPOSE_PROFILES=frontend,redis`) -> 호스트 vite 와 컨테이너 vite 는 같은 호스트 포트를 쓰므로 **동시에 띄우지 마세요.** - -> **포트는 루트 `.env.{env}` 를 따릅니다.** 호스트에서 띄우는 vite 도 `FRONTEND_PORT` 로 열리고, -> `/api` 프록시 대상은 `BACKEND_PORT` 에서 나옵니다 — `vite.config.ts` 가 루트 env 를 직접 읽습니다. -> 포트가 이미 사용 중이면 조용히 다른 포트로 옮기지 않고 **실패**합니다 -> (`CORS_ORIGIN` 과 compose 포트 매핑이 그 값에 묶여 있어, 말없이 옮겨가면 원인 모르게 어긋납니다). - -전체 실행/배포 명령어는 [`.claude/skills/docker/SKILL.md`](./.claude/skills/docker/SKILL.md) 참조. - -## 모바일 앱 실행 (Flutter) - -Flutter 는 Docker Compose 대상이 아니므로 로컬에서 직접 실행합니다. +## 앱 실행 (Flutter) ```bash cd flutter && cp .env.example .env && flutter pub get && flutter run ``` -> `flutter/.env` 의 `API_URL` 을 백엔드 주소로 설정합니다. (Android 에뮬레이터는 `http://10.0.2.2:3000/api`) +`flutter/.env` 의 `API_URL` 을 백엔드 주소로 둔다(Android 에뮬레이터는 `http://10.0.2.2:3000/api`). 실기기 · 새 메일 알림 · 사내 배포는 [`flutter/README.md`](./flutter/README.md). ## 환경변수 -`.env*` 는 커밋하지 않습니다. 각 디렉터리의 `*.example` 파일을 복사해 사용하세요. +`.env*` 는 커밋하지 않는다. 각 자리의 `*.example` 을 복사해 쓴다. **루트 `.env.{env}` 가 단일 진실 공급원(SSOT)**이다 — backend · frontend 는 compose 가 루트 값을 넣어 준다 +(구조는 [`.claude/rules/env-structure.md`](./.claude/rules/env-structure.md)). -**루트 `.env.{env}` 가 단일 진실 공급원(SSOT)** 입니다. backend·frontend 는 docker-compose 가 -루트 값을 주입하므로 자체 env 에 값을 중복 작성하지 않습니다. (자세한 위임 구조는 [`CLAUDE.md`](./CLAUDE.md) 의 "환경변수 구조" 참조) +| 파일 | 용도 | 값을 쓰는 곳 | +| --- | --- | --- | +| `.env.{env}.example` | 루트 — 모든 설정의 SSOT(compose 가 넣어 준다) | 여기 | +| `backend/.env.{env}.example` | 백엔드 고유 비밀(JWT · 앱 알림 키 등)만 | 최소(파일은 있어야 한다) | +| `flutter/.env.example` | 앱 — `API_URL`(compose 대상이 아니다) | 따로 | -| 파일 | 용도 | 값 작성 위치 | -| ----------------------------- | ---------------------------------------- | --------------------- | -| `.env.{env}.example` | 루트 — 모든 설정의 SSOT (compose 주입) | ✅ 여기에 작성 | -| `backend/.env.{env}.example` | 백엔드 — 고유 시크릿(JWT 등)만 | 최소 (존재 필수) | -| `frontend/.env.{env}.example` | 프론트 — API 주소는 같은 출처의 `/api` 고정(빌드 arg) | 작성 불필요 | -| `flutter/.env.example` | 모바일 — `API_URL` (compose 비대상) | ✅ 독립 관리 | - -`{env}` = `development` | `production` +`{env}` = `development` | `production`. 운영 서버의 백업 · 점검 설정은 `/etc/arasystem/`(`ops/ops.env.example` — mail/README "백업"). ## 표준 응답 포맷 @@ -114,97 +102,74 @@ cd flutter && cp .env.example .env && flutter pub get && flutter run // 실패 — 사용자에게 보이는 모든 실패는 에러 코드를 가진다 { "success": false, "error": { "code": "VAL_001", "message": "...", "traceId": "3f1c0a2e-..." } } -// 목록 — data 안의 형태가 고정되어 있다 +// 목록 — data 안의 꼴이 정해져 있다 { "success": true, "data": { "items": [], "pageInfo": { "hasNext": false, "nextCursor": null, "total": 0 } } } ``` -`traceId` 는 요청마다 발급되는 추적 ID(`x-request-id` 헤더와 동일)로, 서버 로그에 같은 값이 남습니다. -사용자 문의가 들어오면 이 값으로 로그를 검색하면 됩니다. +`traceId` 는 요청마다 붙는 추적 ID(`x-request-id` 헤더와 같다)라 서버 로그에 같은 값이 남는다 — 문의가 오면 이 값으로 로그를 찾는다. +`/api/health` 는 예외다(모니터링 도구가 읽는 원본 꼴). -> `/api/health` 는 예외입니다. 모니터링 도구가 읽는 엔드포인트라 래퍼 없이 원본 형식을 반환합니다. - -### 에러 코드는 한 곳에서 관리됩니다 +### 에러 코드는 한 곳에서 ``` -backend/src/common/errors/error-codes.ts ← 여기만 수정 +backend/src/common/errors/error-codes.ts ← 여기만 고친다 │ cd backend && npm run sync:error-codes ├─▶ frontend/src/shared/constants/error-codes.generated.ts (자동 생성) └─▶ flutter/lib/core/api/error_lexicon.g.dart (자동 생성) ``` -생성 파일은 직접 수정하지 마세요. CI 가 동기화 여부를 검사합니다. -자세한 규칙은 [`.claude/rules/error-codes.md`](./.claude/rules/error-codes.md) 참조. +생성 파일은 직접 고치지 않는다. CI 가 맞는지 본다 — [`.claude/rules/error-codes.md`](./.claude/rules/error-codes.md). -## 로컬 개발 (Docker 없이) - -DB 는 컨테이너로 띄우고 앱만 호스트에서 실행하는 방식이 가장 간단합니다. +## 커밋 전 검사 ```bash -# 1) DB 만 컨테이너로 기동 (127.0.0.1:5432 로 노출된다) -docker compose -f docker-compose.yml -f docker-compose.dev.yml --env-file .env.development up -d db - -# 2) backend — DB_HOST 기본값이 localhost 라 그대로 붙는다 -cd backend && npm ci && npm run start:dev - -# 3) frontend — vite dev 서버가 /api 를 localhost:3000 으로 프록시한다 (개발 기본 방식) -cd frontend && npm ci && npm run dev - -# 4) flutter -cd flutter && flutter pub get && flutter run -``` - -> 프론트엔드의 API 요청은 `/api` 상대경로로 나가고 vite dev 프록시가 백엔드로 넘깁니다. -> 백엔드를 다른 주소에서 띄웠다면 `VITE_DEV_API_PROXY` 로 대상을 바꾸세요. - -### 커밋 전 검사 - -```bash -cd backend && npm run lint && npm run test && npm run build -cd frontend && npm run lint && npm run type-check && npm run test:unit +# backend — 컨테이너 안에서(의존성 · lock 이 Linux 기준이라 호스트에서 npm install 하지 않는다) +docker exec arasystem-backend npm run lint +docker exec arasystem-backend npm run test # 메모리가 빠듯하면 -- --maxWorkers=2 +docker exec arasystem-backend npx jest --config ./test/jest-e2e.json # e2e — DB · 메일 엔진이 떠 있어야 한다 +# frontend · flutter — 호스트에서 +cd frontend && npm run lint && npm run type-check && npm run test:unit # 시간대가 걸린 화면을 고쳤으면 TZ=UTC 로도 cd flutter && flutter analyze && flutter test ``` -lint 는 `--max-warnings 0` 이라 경고 하나만 있어도 실패합니다. 자동 수정은 `npm run lint:fix`. -같은 검사를 CI([`.github/workflows/ci.yml`](./.github/workflows/ci.yml))가 PR 마다 수행합니다. +컨테이너 이름은 `-backend`(개발 기본 `arasystem`). lint 는 `--max-warnings 0` 이라 경고 하나도 실패다. 같은 검사를 CI(`.github/workflows/ci.yml` · `ci-flutter.yml`)가 PR 마다 한다. -> ⚠️ **의존성을 추가·제거할 때는 컨테이너 안에서** 실행하세요. -> Windows 에서 `npm install` 하면 Linux 전용 패키지가 lock 에서 잘려 Docker 빌드가 깨집니다. -> ```bash -> docker compose -f docker-compose.yml -f docker-compose.dev.yml --env-file .env.development exec backend npm install <패키지> -> ``` +> ⚠️ **백엔드 의존성은 컨테이너 안에서만 바꾼다** — Windows 에서 `npm install` 하면 Linux 전용 패키지가 lock 에서 빠져 Docker 빌드가 깨진다. +> `docker compose -f docker-compose.yml -f docker-compose.dev.yml --env-file .env.development exec backend npm install <패키지>` ## 개발 규칙 -| 영역 | 문서 | -| --------- | ------------------------------------------------- | -| 공통/보안 | [`CLAUDE.md`](./CLAUDE.md) · [`.claude/rules/`](./.claude/rules/) | -| 응답·에러 | [`response-format.md`](./.claude/rules/response-format.md) · [`error-codes.md`](./.claude/rules/error-codes.md) | -| API 설계 | [`api-design.md`](./.claude/rules/api-design.md) | -| 로깅 | [`logging.md`](./.claude/rules/logging.md) | -| 체크리스트 | [`checklist.md`](./.claude/rules/checklist.md) | -| NestJS | [`.claude/skills/nestjs/SKILL.md`](./.claude/skills/nestjs/SKILL.md) | -| Vue 3 | [`.claude/skills/vue3/SKILL.md`](./.claude/skills/vue3/SKILL.md) | -| Flutter | [`.claude/skills/flutter/SKILL.md`](./.claude/skills/flutter/SKILL.md) | -| Docker | [`.claude/skills/docker/SKILL.md`](./.claude/skills/docker/SKILL.md) | -| 역할 분담 | [`.claude/agents/README.md`](./.claude/agents/README.md) | +| 영역 | 문서 | +| --- | --- | +| 입구 · 공통 | [`CLAUDE.md`](./CLAUDE.md) · [`.claude/rules/`](./.claude/rules/) | +| 응답 · 에러 | [`response-format.md`](./.claude/rules/response-format.md) · [`error-codes.md`](./.claude/rules/error-codes.md) | +| API 설계 | [`api-design.md`](./.claude/rules/api-design.md) | +| 로깅 · 보안 | [`logging.md`](./.claude/rules/logging.md) · [`security.md`](./.claude/rules/security.md) | +| 점검 목록 | [`checklist.md`](./.claude/rules/checklist.md) | +| NestJS | [`.claude/skills/nestjs/SKILL.md`](./.claude/skills/nestjs/SKILL.md) | +| Vue 3 | [`.claude/skills/vue3/SKILL.md`](./.claude/skills/vue3/SKILL.md) | +| Flutter | [`.claude/skills/flutter/SKILL.md`](./.claude/skills/flutter/SKILL.md) | +| Docker · 운영 | [`.claude/skills/docker/SKILL.md`](./.claude/skills/docker/SKILL.md) | +| 역할 분담 | [`.claude/agents/README.md`](./.claude/agents/README.md) | + +새 화면은 캔버스 보드로 먼저 확인받은 뒤 만든다(디자인 README). 화면 · 알림 글은 디자인 README 「화면 문구 — 사용자에게 말하듯」을 따른다. ## 디렉터리 구조 ``` . -├── backend/ # NestJS API (Controller / Service / DTO / 공통 필터·인터셉터) -├── frontend/ # Vue 3 (pages / components / composables / stores / router) -├── flutter/ # Flutter (core / features / shared) -├── docker-compose.yml # 베이스 정의 -├── docker-compose.dev.yml # 개발 오버라이드 (핫리로드, 포트 노출) -├── CLAUDE.md # 프로젝트 개요 + 공통 규칙 @import -├── .github/ -│ ├── workflows/ci.yml # lint·test·build + 에러 코드 동기화 + 인프라(compose·Dockerfile·nginx) 검사 -│ └── pull_request_template.md -├── ci/ # CI 러너 (Gitea Actions) — 앱 스택과 분리 -└── .claude/ - ├── settings.json # Claude Code 권한·환경 설정 - ├── rules/ # 공통 규칙 (코딩·응답 포맷·에러 코드·API 설계·로깅·보안·환경변수·테스트) - ├── skills/ # 도메인별 개발 규칙 (nestjs·vue3·flutter·docker) - └── agents/ # 역할별 규칙 (개발 3종·테스터·리뷰어·인프라·사용자/관리자 관점) +├── backend/ # NestJS API — modules/(auth · mail · contacts · file-storage · push · ops 등) · common · shared +├── frontend/ # Vue 3 웹메일 — pages · components · composables · stores · router, 운영 nginx 설정 +├── flutter/ # 휴대폰 앱 — core · features · shared +├── mail/ # 메일 엔진 setup 스크립트 · DNS 리졸버 · ClamAV 설정 · 운영 문서(README) +├── ops/ # 운영 서버의 백업 · 점검 · 부팅 되살리기(systemd) +├── docs/ # API 계약 · 화면 디자인(캔버스 보드) · 브랜드 · 로드맵 +├── ci/ # CI 러너(Gitea Actions) — 앱 스택과 따로 띄운다 +├── data/ # 개발용 바인드 마운트 자리(내용은 커밋하지 않는다) +├── docker-compose.yml # 운영 · 개발 공통 정의 +├── docker-compose.dev.yml # 개발 오버라이드(핫리로드 · 127.0.0.1 포트) +├── docker-compose.remote-proxy.yml # 리버스 프록시가 다른 서버에 있을 때(운영 선택) +├── CLAUDE.md # 개발 규칙의 입구(.claude/ 의 규칙을 불러온다) +├── .github/ # CI 워크플로(ci.yml · ci-flutter.yml) · PR 양식 +└── .claude/ # rules(공통 규칙) · skills(스택별 규칙) · agents(역할) · 설정 ``` diff --git a/backend/README.md b/backend/README.md index 8f0f65f..bddfea7 100644 --- a/backend/README.md +++ b/backend/README.md @@ -1,98 +1,36 @@ -

- Nest Logo -

+# backend — ARA SYSTEM API (NestJS) -[circleci-image]: https://img.shields.io/circleci/build/github/nestjs/nest/master?token=abc123def456 -[circleci-url]: https://circleci.com/gh/nestjs/nest +웹 · 앱이 함께 쓰는 REST API 다(`/api`). 메일 엔진(Stalwart)을 JMAP 으로 감싸고, 로그인 세션 · 설정 · 검색 색인 · 앱 알림 · 대용량 첨부 · 운영 도구를 맡는다. -

A progressive Node.js framework for building efficient and scalable server-side applications.

-

-NPM Version -Package License -NPM Downloads -CircleCI -Discord -Backers on Open Collective -Sponsors on Open Collective - Donate us - Support us - Follow us on Twitter -

- +- 모듈 지도 · 개발 규칙: [`.claude/skills/nestjs/SKILL.md`](../.claude/skills/nestjs/SKILL.md) +- API 약속(웹 · 앱과 함께): [`docs/mail/api-contract.md`](../docs/mail/api-contract.md) +- 공통 규칙(응답 꼴 · 에러 코드 · 로그 · 보안): [`CLAUDE.md`](../CLAUDE.md) -## Description +## 실행 — 컨테이너로 -[Nest](https://github.com/nestjs/nest) framework TypeScript starter repository. - -## Project setup +백엔드는 늘 컨테이너로 띄운다. 의존성 · lock 이 Linux 기준이라 **호스트에서 `npm install` 하지 않는다**(바꿀 때도 컨테이너 안에서 — nestjs 스킬 "의존성 추가/제거"). ```bash -$ npm install +# 저장소 루트에서 — 개발 스택(backend + db + 메일 엔진) +docker compose -f docker-compose.yml -f docker-compose.dev.yml --env-file .env.development up -d +docker compose -f docker-compose.yml -f docker-compose.dev.yml --env-file .env.development logs -f backend ``` -## Compile and run the project +- 처음 띄우면 마이그레이션을 한 번 돌리고 watch 빌드를 시작한다(Windows 바인드 마운트라 수십 분 걸릴 수 있다). 코드를 고치면 저절로 다시 뜬다. +- Swagger: + +## 시험 · 검사 ```bash -# development -$ npm run start - -# watch mode -$ npm run start:dev - -# production mode -$ npm run start:prod +docker exec arasystem-backend npm run lint # 경고 0 +docker exec arasystem-backend npm run test # 단위 — 메모리가 빠듯하면 -- --maxWorkers=2 +docker exec arasystem-backend npx jest --config ./test/jest-e2e.json # e2e — DB · 메일 엔진 필요 ``` -## Run tests +컨테이너 이름은 `-backend`(개발 기본 `arasystem`). Git Bash 에서 컨테이너 안 경로를 줄 때는 `MSYS_NO_PATHCONV=1` 을 붙인다. -```bash -# unit tests -$ npm run test +## 자주 하는 일 -# e2e tests -$ npm run test:e2e - -# test coverage -$ npm run test:cov -``` - -## Deployment - -When you're ready to deploy your NestJS application to production, there are some key steps you can take to ensure it runs as efficiently as possible. Check out the [deployment documentation](https://docs.nestjs.com/deployment) for more information. - -If you are looking for a cloud-based platform to deploy your NestJS application, check out [Mau](https://mau.nestjs.com), our official platform for deploying NestJS applications on AWS. Mau makes deployment straightforward and fast, requiring just a few simple steps: - -```bash -$ npm install -g @nestjs/mau -$ mau deploy -``` - -With Mau, you can deploy your application in just a few clicks, allowing you to focus on building features rather than managing infrastructure. - -## Resources - -Check out a few resources that may come in handy when working with NestJS: - -- Visit the [NestJS Documentation](https://docs.nestjs.com) to learn more about the framework. -- For questions and support, please visit our [Discord channel](https://discord.gg/G7Qnnhy). -- To dive deeper and get more hands-on experience, check out our official video [courses](https://courses.nestjs.com/). -- Deploy your application to AWS with the help of [NestJS Mau](https://mau.nestjs.com) in just a few clicks. -- Visualize your application graph and interact with the NestJS application in real-time using [NestJS Devtools](https://devtools.nestjs.com). -- Need help with your project (part-time to full-time)? Check out our official [enterprise support](https://enterprise.nestjs.com). -- To stay in the loop and get updates, follow us on [X](https://x.com/nestframework) and [LinkedIn](https://linkedin.com/company/nestjs). -- Looking for a job, or have a job to offer? Check out our official [Jobs board](https://jobs.nestjs.com). - -## Support - -Nest is an MIT-licensed open source project. It can grow thanks to the sponsors and support by the amazing backers. If you'd like to join them, please [read more here](https://docs.nestjs.com/support). - -## Stay in touch - -- Author - [Kamil Myśliwiec](https://twitter.com/kammysliwiec) -- Website - [https://nestjs.com](https://nestjs.com/) -- Twitter - [@nestframework](https://twitter.com/nestframework) - -## License - -Nest is [MIT licensed](https://github.com/nestjs/nest/blob/master/LICENSE). +- **에러 코드**: `src/common/errors/error-codes.ts` 만 고치고 호스트에서 `cd backend && npm run sync:error-codes`(웹 · 앱 사전이 함께 바뀐다). +- **DB 스키마**: 엔티티를 고친 뒤 `docker exec arasystem-backend npm run migration:generate -- src/migrations/<이름>` → 만든 SQL 을 눈으로 확인 → 엔티티와 함께 커밋(nestjs 스킬 "DB 마이그레이션"). +- **운영 도구**(운영 서버 안 명령 — 입사 · 비밀번호 재설정 · 휴대폰 앱 올리기 등): `sudo docker exec -it -backend node dist/cli/ops.js` — 계약 6절 · mail/README. diff --git a/backend/src/modules/file-sharing/file-sharing.repository.ts b/backend/src/modules/file-sharing/file-sharing.repository.ts index dd04ee4..ffa968c 100644 --- a/backend/src/modules/file-sharing/file-sharing.repository.ts +++ b/backend/src/modules/file-sharing/file-sharing.repository.ts @@ -97,6 +97,19 @@ export class FileSharingRepository { await this.links.update({ id }, { sentEmailId }); } + /** 보낸 때를 앞당긴다 — 취소하지 않았고 지금 보낸 때가 더 늦을 때만, 한 문장으로 (true = 바꿨다) */ + async moveSentAtEarlier(id: string, sentAt: Date): Promise { + const result = await this.links + .createQueryBuilder() + .update(ShareLinkEntity) + .set({ sentAt }) + .where('id = :id', { id }) + .andWhere('canceled_at IS NULL') + .andWhere('sent_at > :sentAt', { sentAt }) + .execute(); + return (result.affected ?? 0) > 0; + } + /** 취소한 링크로 — 이미 취소했으면 그대로 (true = 이번에 바꿨다) */ async cancel( id: string, diff --git a/backend/src/modules/file-sharing/file-sharing.service.spec.ts b/backend/src/modules/file-sharing/file-sharing.service.spec.ts index 4fe6cfc..d8549b0 100644 --- a/backend/src/modules/file-sharing/file-sharing.service.spec.ts +++ b/backend/src/modules/file-sharing/file-sharing.service.spec.ts @@ -91,6 +91,16 @@ class FakeRepository { return Promise.resolve(); } + /** 실제 표처럼 — 취소하지 않았고 지금 보낸 때가 더 늦을 때만 (조건부 UPDATE 한 문장) */ + moveSentAtEarlier(id: string, sentAt: Date) { + const link = this.records.find((item) => item.link.id === id)?.link; + if (!link || link.canceledAt !== null || link.sentAt <= sentAt) { + return Promise.resolve(false); + } + link.sentAt = sentAt; + return Promise.resolve(true); + } + cancel(id: string, reason: 'send-failed' | 'send-canceled', now: Date) { const link = this.records.find((item) => item.link.id === id)?.link; if (!link || link.canceledAt !== null) return Promise.resolve(false); @@ -314,6 +324,34 @@ describe('FileSharingService — 공유 링크 · 공개 내려받기 (11단계) expect(await codeOf(() => service.publicLink(token))).toBe('RES_001'); }); + it('예약 메일을 지금 보냈으면(메일 15단계) 보낸 때를 앞당긴다 — 끝나는 때는 그대로(덩이가 적은 날짜), 취소한 링크 · 더 늦은 시각은 바꾸지 않는다', async () => { + const { service, repository } = setup(); + const scheduled = new Date(Date.now() + 5 * 24 * 60 * 60_000); + const created = await service.createMailLink({ + ownerEmail: ARA, + senderName: '김아라', + files: [{ id: FILE_A, name: '설계도.dwg', size: CONTENT.length }], + sentAt: scheduled, + messageId: 'ara.release@arasystem.test', + }); + const { link } = created.record; + const expiresAt = link.expiresAt.getTime(); + const now = new Date(); + + await expect(service.markReleased(link.id, now)).resolves.toBe(true); + expect(link.sentAt).toEqual(now); + expect(link.expiresAt.getTime()).toBe(expiresAt); + // 다시 와도(응답을 받지 못한 재시도) 더 늦은 시각으로 되돌리지 않는다 + await expect(service.markReleased(link.id, scheduled)).resolves.toBe(false); + expect(link.sentAt).toEqual(now); + + await service.cancel(created.record, 'send-canceled'); + await expect( + service.markReleased(link.id, new Date(now.getTime() - 1_000)), + ).resolves.toBe(false); + expect(repository.records).toHaveLength(1); + }); + describe('파일 응답 (Range · HEAD · 횟수)', () => { it('전체 200 — 내려받기 머리글(형식 · 이름 · nosniff · sandbox · no-store · ETag · Accept-Ranges · X-Accel-Buffering)', async () => { const { service } = setup(); diff --git a/backend/src/modules/file-sharing/file-sharing.service.ts b/backend/src/modules/file-sharing/file-sharing.service.ts index 76fc345..9d480d7 100644 --- a/backend/src/modules/file-sharing/file-sharing.service.ts +++ b/backend/src/modules/file-sharing/file-sharing.service.ts @@ -199,6 +199,15 @@ export class FileSharingService implements BeforeApplicationShutdown { await this.repository.markSent(linkId, sentEmailId); } + /** + * 예약한 메일을 지금 보냈다(메일 15단계 — 계약 2절 "보내기 취소 · 예약 발송") — 보낸 때를 실제로 보낸 때로 앞당긴다(보낸 링크 목록 · 공개 화면의 보낸 때). + * **끝나는 때는 그대로 둔다** — 보낸 메일의 덩이가 예약 시각으로 센 그 날짜를 이미 적었다. 앞당기면 받는 사람이 본 날짜보다 먼저 끝난다(파일 기간도 그대로라 다시 세지 않는다). + * 취소한 링크 · 이미 그보다 앞선 보낸 때는 바꾸지 않는다 (true = 바꿨다) + */ + async markReleased(linkId: string, sentAt: Date): Promise { + return this.repository.moveSentAtEarlier(linkId, sentAt); + } + /** * 같은 Idempotency-Key 의 재시도가 처음 메일을 찾았다 — 그 발송이 만든 링크(보낸 메일을 모르는 것)에 보낸 사본을 잇는다. * 결과를 모르게 끝난 첫 시도의 링크가 이렇게 제자리를 찾는다 (계약 4절 "링크를 만드는 때") diff --git a/backend/src/modules/mail/dto/mail-block-run.dto.ts b/backend/src/modules/mail/dto/mail-block-run.dto.ts new file mode 100644 index 0000000..dfc3901 --- /dev/null +++ b/backend/src/modules/mail/dto/mail-block-run.dto.ts @@ -0,0 +1,86 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsString, Matches, MaxLength, ValidateIf } from 'class-validator'; + +import { + MAIL_FILTER_RUN_CURSOR_PATTERN, + MAX_EMAIL_LENGTH, + MAX_MAIL_FILTER_RUN_CURSOR_LENGTH, +} from '@/modules/mail/mail.constants'; +import type { + MailBlockRunRequest, + MailFilterRun, +} from '@/modules/mail/mail.types'; +import { BlockAddress } from './mail-block.dto'; + +/** + * 차단할 때 받은편지함 정리 — POST /api/mail-block-runs 의 본문과 응답 (계약 2절 "보낸 사람 차단" — 15단계). + * 보낸 사람은 차단하기와 같은 검사(주소 · `@도메인` 모양 · 254자 — mail-blocks.ts), 차단 목록에 있는지는 서비스가 본다(없으면 404 RES_001). + * cursor 는 이미 받은 메일에 필터 적용(dto/mail-filter-run.dto.ts)과 같은 검사다 + */ + +/** null 은 "처음부터"로 받는다 — 키가 없으면(undefined) 형식 오류다 (늘 보낸다) */ +const unlessNull = (_object: object, value: unknown) => value !== null; + +/** POST /api/mail-block-runs — 정리할 보낸 사람(차단 목록의 값)과 이어 할 자리 */ +export class MailBlockRunRequestDto implements MailBlockRunRequest { + @ApiProperty({ + description: + '차단 목록의 값 그대로 — 소문자 주소("kim@spam.example") 또는 도메인("@spam.example"). 차단한 뒤에만 부른다 — 차단 목록에 없으면 404 RES_001', + example: '@spam.example', + maxLength: MAX_EMAIL_LENGTH, + }) + @BlockAddress() + sender!: string; + + @ApiProperty({ + description: + '이어 할 자리 — 처음은 null, 이어 할 때는 앞 응답의 cursor 그대로(해석하지 않는다). 늘 보낸다. ' + + '다른 보낸 사람의 cursor · 깨진 cursor 는 400 VAL_001', + nullable: true, + type: String, + maxLength: MAX_MAIL_FILTER_RUN_CURSOR_LENGTH, + example: null, + }) + @ValidateIf(unlessNull) + @IsString({ message: '이어 할 자리를 다시 받아 주세요.' }) + @MaxLength(MAX_MAIL_FILTER_RUN_CURSOR_LENGTH, { + message: '이어 할 자리를 다시 받아 주세요.', + }) + @Matches(MAIL_FILTER_RUN_CURSOR_PATTERN, { + message: '이어 할 자리를 다시 받아 주세요.', + }) + cursor!: string | null; +} + +/** POST /api/mail-block-runs 의 응답 — 이미 받은 메일에 필터 적용(MailFilterRun)과 같은 꼴 */ +export class MailBlockRunDto implements MailFilterRun { + @ApiProperty({ + description: '이번 요청에서 확인한 받은편지함 메일 수', + example: 500, + }) + scanned!: number; + + @ApiProperty({ + description: + '그중 그 보낸 사람의 메일이라 스팸메일함으로 옮긴 수 (스팸 표시도 붙였다). 보내기를 기다리는 메일은 옮기지 않아 세지 않는다', + example: 37, + }) + matched!: number; + + @ApiProperty({ + description: + '아직 확인하지 않은 메일 수 — 어림(그사이 들어오고 나간 메일이 있다). 진행의 전체는 지금까지 확인한 수 + 이 값', + example: 4_300, + }) + remaining!: number; + + @ApiProperty({ + description: + 'null = 끝. 아니면 같은 본문에 이 cursor 를 넣어 다시 보낸다 (해석하지 않는다)', + nullable: true, + type: String, + example: + 'eyJ2IjoyLCJiIjoxNzkzMjY0NDAxLCJhIjoic2VhYWFhZXIiLCJ0IjoxNzkzMjY0MzQwLCJyIjoiZDJWM2R0ejdOMmdCVVRyZyJ9', + }) + cursor!: string | null; +} diff --git a/backend/src/modules/mail/dto/mail-block.dto.ts b/backend/src/modules/mail/dto/mail-block.dto.ts index e1c001e..818922d 100644 --- a/backend/src/modules/mail/dto/mail-block.dto.ts +++ b/backend/src/modules/mail/dto/mail-block.dto.ts @@ -32,7 +32,8 @@ function IsBlockTarget() { ); } -function BlockAddress(): PropertyDecorator { +/** 차단 대상 칸 — 차단하기(POST /api/mail-blocks) · 받은편지함 정리(POST /api/mail-block-runs — 15단계)가 같이 쓴다 */ +export function BlockAddress(): PropertyDecorator { return applyDecorators( TrimString(), IsString({ message: '메일 주소나 @도메인 형식으로 써 주세요.' }), diff --git a/backend/src/modules/mail/dto/mailbox.dto.spec.ts b/backend/src/modules/mail/dto/mailbox.dto.spec.ts index e1fc5ba..3e53ac7 100644 --- a/backend/src/modules/mail/dto/mailbox.dto.spec.ts +++ b/backend/src/modules/mail/dto/mailbox.dto.spec.ts @@ -4,8 +4,9 @@ import { validate } from 'class-validator'; import { MAX_MAILBOX_NAME_LENGTH } from '@/modules/mail/mail.constants'; import { CreateMailboxDto, + MailboxDeleteQueryDto, MailboxMessagesPatchDto, - RenameMailboxDto, + UpdateMailboxDto, } from './mailbox.dto'; /** 전역 ValidationPipe 와 같은 옵션으로 검증한다 (app.setup.ts — stopAtFirstError 만 빼고 — 걸린 규칙을 모두 본다, 통과 · 실패는 같다) — 변환한 값과 걸린 규칙 이름 */ @@ -29,7 +30,7 @@ async function check( describe('메일함 요청 본문 (계약 2절 "메일함 관리")', () => { it.each([ ['만들기', CreateMailboxDto], - ['이름 바꾸기', RenameMailboxDto], + ['바꾸기', UpdateMailboxDto], ] as const)( `%s — 이름은 앞뒤 공백을 뺀 1~${MAX_MAILBOX_NAME_LENGTH}자 (JS 문자열 길이)`, async (_name, type) => { @@ -65,7 +66,7 @@ describe('메일함 요청 본문 (계약 2절 "메일함 관리")', () => { it.each([ ['만들기', CreateMailboxDto], - ['이름 바꾸기', RenameMailboxDto], + ['바꾸기', UpdateMailboxDto], ] as const)( '%s — "/" 와 제어 문자(줄바꿈 · 탭 등)는 쓸 수 없다, 문자열이 아니면 형식 오류', async (_name, type) => { @@ -87,31 +88,72 @@ describe('메일함 요청 본문 (계약 2절 "메일함 관리")', () => { expect((await check(type, { name: 7 })).constraints).toEqual( expect.arrayContaining(['isString']), ); - expect((await check(type, {})).constraints).toEqual( + expect((await check(type, { name: null })).constraints).toEqual( expect.arrayContaining(['isString']), ); }, ); - it('만들기의 상위는 없거나 null(맨 위) · 메일함 id 형식 — 이름 바꾸기에는 상위가 없다', async () => { + it('만들기는 이름이 없으면 형식 오류 — 바꾸기는 이름을 보내지 않아도 된다(빈 본문은 서비스가 VAL_001)', async () => { + expect((await check(CreateMailboxDto, {})).constraints).toEqual( + expect.arrayContaining(['isString']), + ); + expect((await check(UpdateMailboxDto, {})).constraints).toEqual([]); + }); + + it.each([ + ['만들기', CreateMailboxDto, { name: '거래처' }], + ['바꾸기(옮기기 — 15단계)', UpdateMailboxDto, {}], + ] as const)( + '%s의 상위는 없거나 null(맨 위) · 메일함 id 형식', + async (_name, type, base) => { + expect((await check(type, base)).constraints).toEqual([]); + expect( + (await check(type, { ...base, parentId: null })).constraints, + ).toEqual([]); + expect( + (await check(type, { ...base, parentId: 'a' })).constraints, + ).toEqual([]); + for (const parentId of ['../a', '', 'a b', 7]) { + expect((await check(type, { ...base, parentId })).constraints).toEqual([ + 'matches', + ]); + } + }, + ); + + it('바꾸기는 이름 · 상위를 함께 받고, 보낸 값을 그대로 둔다 — null 상위는 null(맨 위), 보내지 않은 칸은 undefined', async () => { + const both = await check(UpdateMailboxDto, { + name: ' 2025 ', + parentId: 'u1', + }); + expect(both.constraints).toEqual([]); + expect(both.dto).toMatchObject({ name: '2025', parentId: 'u1' }); + + const top = await check(UpdateMailboxDto, { parentId: null }); + expect(top.constraints).toEqual([]); + expect(top.dto.parentId).toBeNull(); + expect(top.dto.name).toBeUndefined(); + expect( - (await check(CreateMailboxDto, { name: '거래처' })).constraints, - ).toEqual([]); + (await check(UpdateMailboxDto, { name: '거래처', role: 'inbox' })) + .constraints, + ).toEqual(['whitelistValidation']); + }); + + it('지우기의 쿼리는 withChildren=true 만 받는다 (15단계 — 하위 메일함까지)', async () => { + expect((await check(MailboxDeleteQueryDto, {})).constraints).toEqual([]); expect( - (await check(CreateMailboxDto, { name: '거래처', parentId: null })) + (await check(MailboxDeleteQueryDto, { withChildren: 'true' })) .constraints, ).toEqual([]); + for (const withChildren of ['false', 'TRUE', '1', '', true]) { + expect( + (await check(MailboxDeleteQueryDto, { withChildren })).constraints, + ).toEqual(['isIn']); + } expect( - (await check(CreateMailboxDto, { name: '거래처', parentId: 'a' })) - .constraints, - ).toEqual([]); - expect( - (await check(CreateMailboxDto, { name: '거래처', parentId: '../a' })) - .constraints, - ).toEqual(['matches']); - expect( - (await check(RenameMailboxDto, { name: '거래처', parentId: 'a' })) - .constraints, + (await check(MailboxDeleteQueryDto, { recursive: 'true' })).constraints, ).toEqual(['whitelistValidation']); }); diff --git a/backend/src/modules/mail/dto/mailbox.dto.ts b/backend/src/modules/mail/dto/mailbox.dto.ts index a4731b9..d10cec2 100644 --- a/backend/src/modules/mail/dto/mailbox.dto.ts +++ b/backend/src/modules/mail/dto/mailbox.dto.ts @@ -1,6 +1,13 @@ import { applyDecorators } from '@nestjs/common'; import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; -import { Equals, IsOptional, IsString, Matches } from 'class-validator'; +import { + Equals, + IsIn, + IsOptional, + IsString, + Matches, + ValidateIf, +} from 'class-validator'; import { MaxCodeUnits, @@ -16,15 +23,32 @@ import { /** 메일함 이름에 쓸 수 없는 '/' — 경로 구분자로 읽는 메일 프로그램이 있다 (계약 2절 "메일함 관리") */ const NO_SLASH_PATTERN = /^[^/]*$/; -/** 메일함 이름 — 앞뒤 공백을 뺀 1~MAX_MAILBOX_NAME_LENGTH 자(JS 문자열 길이), `/` · 제어 문자 없음. 만들기 · 이름 바꾸기가 같이 쓴다 */ -function MailboxName() { +/** 메일함 이름 칸의 설명 */ +const MAILBOX_NAME_DESCRIPTION = `메일함 이름 — 앞뒤 공백을 뺀 1~${MAX_MAILBOX_NAME_LENGTH}자(JS 문자열 길이). "/" 와 제어 문자(줄바꿈 · 탭 등)는 쓸 수 없다. 같은 상위 안에 같은 이름이 있으면 409 MAIL_009`; + +/** 보낸 칸만 검사한다 — 없으면(undefined) 건너뛰고, null 은 검사해 형식 오류로 막는다 (이름 · 상위 가운데 바꿀 것만 보내는 PATCH) */ +const unlessAbsent = (_object: object, value: unknown) => value !== undefined; + +/** + * 메일함 이름 — 앞뒤 공백을 뺀 1~MAX_MAILBOX_NAME_LENGTH 자(JS 문자열 길이), `/` · 제어 문자 없음. 만들기 · 바꾸기가 같이 쓴다. + * `optional` 이면(바꾸기 — 15단계) 보내지 않아도 된다 — 보낸 값은 같은 검사를 한다(null 도 형식 오류) + */ +function MailboxName({ optional = false }: { optional?: boolean } = {}) { return applyDecorators( - ApiProperty({ - description: `메일함 이름 — 앞뒤 공백을 뺀 1~${MAX_MAILBOX_NAME_LENGTH}자(JS 문자열 길이). "/" 와 제어 문자(줄바꿈 · 탭 등)는 쓸 수 없다. 같은 상위 안에 같은 이름이 있으면 409 MAIL_009`, - example: '프로젝트', - minLength: 1, - maxLength: MAX_MAILBOX_NAME_LENGTH, - }), + optional + ? ApiPropertyOptional({ + description: `${MAILBOX_NAME_DESCRIPTION}. 보내지 않으면 이름은 그대로`, + example: '프로젝트', + minLength: 1, + maxLength: MAX_MAILBOX_NAME_LENGTH, + }) + : ApiProperty({ + description: MAILBOX_NAME_DESCRIPTION, + example: '프로젝트', + minLength: 1, + maxLength: MAX_MAILBOX_NAME_LENGTH, + }), + ...(optional ? [ValidateIf(unlessAbsent)] : []), TrimString(), IsString({ message: '메일함 이름 형식이 올바르지 않습니다.' }), MinCodeUnits(1, { message: '메일함 이름을 입력해 주세요.' }), @@ -59,10 +83,43 @@ export class CreateMailboxDto { parentId?: string | null; } -/** PATCH /api/mailboxes/{id} — 이름만 바꾼다 (다른 상위로 옮기기는 이 단계에서 하지 않는다) */ -export class RenameMailboxDto { - @MailboxName() - name!: string; +/** + * PATCH /api/mailboxes/{id} — 이름 · 상위(옮기기 — 15단계) 가운데 바꿀 것만. 둘 다 없으면(빈 본문) 서비스가 400 VAL_001. + * 상위의 자리 규칙(만들기와 같다) · 자기 자신 · 하위 아래로 옮기기는 어댑터가 본다 + */ +export class UpdateMailboxDto { + @MailboxName({ optional: true }) + name?: string; + + @ApiPropertyOptional({ + description: + '새 상위 메일함 id — null 이면 맨 위, 보내지 않으면 상위는 그대로. 만들기와 같은 자리만(받은편지함 · 보관함 · 내 메일함 아래 — 그 밖 400 VAL_001, 없는 id 는 404 RES_001). ' + + '자기 자신 · 자기 하위 아래로는 400 VAL_001. 하위 메일함과 안의 메일은 함께 간다', + nullable: true, + type: String, + example: 'a', + }) + @IsOptional() + @Matches(MAIL_ID_PATTERN, { message: '옮길 곳을 다시 선택해 주세요.' }) + parentId?: string | null; +} + +/** 하위 메일함까지 지우기의 값 — 이것만 받는다 (15단계) */ +export const WITH_CHILDREN = 'true'; + +/** DELETE /api/mailboxes/{id} 의 쿼리 — withChildren=true 면 하위 메일함까지 지운다 (15단계). 모르는 쿼리는 400 VAL_001 */ +export class MailboxDeleteQueryDto { + @ApiPropertyOptional({ + description: + 'true 면 하위 메일함이 있어도 그 메일함과 하위 모두를 지운다(깊은 것부터 — 안의 메일은 휴지통으로). 없으면 하위가 있을 때 400 VAL_001(옛 화면이 모르고 하위를 지우지 않게). 다른 값은 400 VAL_001', + enum: [WITH_CHILDREN], + example: WITH_CHILDREN, + }) + @IsOptional() + @IsIn([WITH_CHILDREN], { + message: '하위 메일함 지우기 값이 올바르지 않습니다.', + }) + withChildren?: typeof WITH_CHILDREN; } /** PATCH /api/mailboxes/{id}/messages — 그 메일함 전체를 읽음으로 (true 만 받는다) */ diff --git a/backend/src/modules/mail/jmap/filter-run-cursor.spec.ts b/backend/src/modules/mail/jmap/filter-run-cursor.spec.ts index 8fdaba7..fe660ef 100644 --- a/backend/src/modules/mail/jmap/filter-run-cursor.spec.ts +++ b/backend/src/modules/mail/jmap/filter-run-cursor.spec.ts @@ -1,7 +1,9 @@ import { BusinessException } from '@/common/errors/business.exception'; import type { MailFilterRunRequest } from '@/modules/mail/mail.types'; import { + decodeBlockRunCursor, decodeFilterRunCursor, + encodeBlockRunCursor, encodeFilterRunCursor, MAX_FILTER_RUN_SECONDS, type FilterRunPosition, @@ -172,3 +174,62 @@ describe('이미 받은 메일에 필터 적용의 cursor (계약 2절 "이미 ).toEqual({ before: NINE, anchor: null, anchorTime: null }); }); }); + +describe('차단할 때 받은편지함 정리의 cursor (15단계 — 계약 2절 "보낸 사람 차단")', () => { + const position: FilterRunPosition = { + before: NINE + 1, + anchor: 'seaaaaer', + anchorTime: NINE - 60, + }; + + it('자리는 필터 적용과 같은 모양으로 되돌린다 — 지문은 그 보낸 사람', () => { + for (const sender of ['kim@spam.example', '@spam.example']) { + expect( + decodeBlockRunCursor(encodeBlockRunCursor(position, sender), sender), + ).toEqual(position); + } + expect(encodeBlockRunCursor(position, '@spam.example')).toMatch( + /^[A-Za-z0-9_-]+$/, + ); + }); + + it('다른 보낸 사람의 cursor · 필터 적용의 cursor 는 VAL_001 — 거꾸로 차단 정리의 cursor 로 필터를 이어 하지도 못한다', () => { + const cursor = encodeBlockRunCursor(position, 'kim@spam.example'); + for (const other of ['lee@spam.example', '@spam.example']) { + expect(failureOf(() => decodeBlockRunCursor(cursor, other))).toEqual({ + code: 'VAL_001', + reason: 'invalid-mail-block-run-cursor:other-sender', + }); + } + expect( + failureOf(() => + decodeBlockRunCursor( + encodeFilterRunCursor(position, rule), + 'kim@spam.example', + ), + ), + ).toEqual({ + code: 'VAL_001', + reason: 'invalid-mail-block-run-cursor:other-sender', + }); + expect(failureOf(() => decodeFilterRunCursor(cursor, rule))).toEqual({ + code: 'VAL_001', + reason: 'invalid-filter-run-cursor:other-rule', + }); + }); + + it('깨진 cursor 는 필터 적용과 같은 검사로 VAL_001', () => { + for (const [cursor, reason] of [ + ['%%%', 'not-json'], + [forged({ v: 1, b: NINE }), 'shape'], + ['a'.repeat(1_025), 'too-long'], + ] as const) { + expect( + failureOf(() => decodeBlockRunCursor(cursor, 'kim@spam.example')), + ).toEqual({ + code: 'VAL_001', + reason: `invalid-mail-block-run-cursor:${reason}`, + }); + } + }); +}); diff --git a/backend/src/modules/mail/jmap/filter-run-cursor.ts b/backend/src/modules/mail/jmap/filter-run-cursor.ts index 057c3e1..840c4f5 100644 --- a/backend/src/modules/mail/jmap/filter-run-cursor.ts +++ b/backend/src/modules/mail/jmap/filter-run-cursor.ts @@ -17,7 +17,10 @@ import type { MailFilterRunRequest } from '@/modules/mail/mail.types'; * - anchorTime: 그 메일의 받은 시각(초) — 그 메일이 그사이 받은편지함에서 빠졌으면(`anchorNotFound`) `before: anchorTime + 1초` 의 처음부터 읽는다 * (그 초의 메일을 다시 판정한다 — 건너뛰지 않는다). 그렇게 읽은 차례에 남긴 메일이 없으면 anchor 는 없고 anchorTime 만 남는다 * - 규칙의 지문: 묶는 방법 · 조건 · 할 일 — 다른 규칙의 cursor 를 섞어 쓰지 못하게 (충돌보다는 "다른 규칙" 감지가 목적이라 짧게 둔다) - * 시각은 UTC 의 초(epoch)다 — 해석할 때 범위(0 ~ 9999-12-31)를 본다. 깨졌거나 다른 규칙의 것이면 400 VAL_001 — 엔진에 묻기 전에 본다 + * 시각은 UTC 의 초(epoch)다 — 해석할 때 범위(0 ~ 9999-12-31)를 본다. 깨졌거나 다른 규칙의 것이면 400 VAL_001 — 엔진에 묻기 전에 본다. + * + * 차단할 때 받은편지함 정리(15단계 — 계약 2절 "보낸 사람 차단")도 같은 자리 · 같은 모양을 쓴다 — 지문만 그 보낸 사람(차단 목록의 값)이다. + * 지문의 재료가 달라(필터는 규칙의 세 칸, 차단은 표시 글 + 보낸 사람) 필터 적용의 cursor 를 차단 정리에 쓰면 다른 주인의 것으로 막힌다 */ const CURSOR_VERSION = 2; @@ -51,9 +54,20 @@ interface EncodedFilterRunCursor { r: string; } +/** 차단 정리 cursor 의 지문 재료 앞에 두는 표시 — 필터 규칙의 재료(첫 칸이 묶는 방법)와 섞이지 않게 */ +const MAIL_BLOCK_SCOPE = 'mail-block'; + +/** 정해진 차례의 JSON 을 SHA-256 으로 — 짧게 자른다 */ +function scopeHash(canonical: unknown[]): string { + return createHash('sha256') + .update(JSON.stringify(canonical), 'utf8') + .digest('base64url') + .slice(0, RULE_HASH_LENGTH); +} + /** 규칙의 지문 — 정해진 차례의 JSON 을 SHA-256 으로 (들어온 객체의 키 순서 · 다른 속성과 상관없다) */ function ruleHash(rule: FilterRunRule): string { - const canonical = JSON.stringify([ + return scopeHash([ rule.match, rule.conditions.map(({ field, operator, value }) => [ field, @@ -62,15 +76,19 @@ function ruleHash(rule: FilterRunRule): string { ]), [rule.actions.moveToMailboxId, rule.actions.markAsRead, rule.actions.flag], ]); - return createHash('sha256') - .update(canonical, 'utf8') - .digest('base64url') - .slice(0, RULE_HASH_LENGTH); } -function invalidCursor(reason: string): BusinessException { +/** 차단 정리의 지문 — 보낸 사람(차단 목록의 값 — 소문자) */ +function senderHash(sender: string): string { + return scopeHash([MAIL_BLOCK_SCOPE, sender]); +} + +/** 깨진 cursor 의 로그 까닭 — 필터 적용 · 차단 정리를 가른다 */ +type CursorKind = 'filter-run' | 'mail-block-run'; + +function invalidCursor(kind: CursorKind, reason: string): BusinessException { return new BusinessException('VAL_001', { - context: { reason: `invalid-filter-run-cursor:${reason}` }, + context: { reason: `invalid-${kind}-cursor:${reason}` }, }); } @@ -84,50 +102,85 @@ function isSeconds(value: unknown): value is number { ); } -export function encodeFilterRunCursor( - position: FilterRunPosition, - rule: FilterRunRule, -): string { +/** 자리와 지문을 cursor 로 */ +function encodeRunCursor(position: FilterRunPosition, hash: string): string { const payload: EncodedFilterRunCursor = { v: CURSOR_VERSION, b: position.before, a: position.anchor, t: position.anchorTime, - r: ruleHash(rule), + r: hash, }; return Buffer.from(JSON.stringify(payload)).toString('base64url'); } +/** cursor 를 해석한다 — 형식 · 범위가 틀리거나 지문이 다르면(다른 주인의 cursor) VAL_001 */ +function decodeRunCursor( + cursor: string, + hash: string, + kind: CursorKind, +): FilterRunPosition { + if (cursor.length > MAX_MAIL_FILTER_RUN_CURSOR_LENGTH) { + throw invalidCursor(kind, 'too-long'); + } + + let parsed: unknown; + try { + parsed = JSON.parse(Buffer.from(cursor, 'base64url').toString('utf8')); + } catch { + throw invalidCursor(kind, 'not-json'); + } + + const { v, b, a, t, r } = (parsed ?? {}) as Partial; + if (v !== CURSOR_VERSION || !isSeconds(b) || b === 0) { + throw invalidCursor(kind, 'shape'); + } + if (!(a === null || (typeof a === 'string' && MAIL_ID_PATTERN.test(a)))) { + throw invalidCursor(kind, 'shape'); + } + // anchor 가 있으면 그 받은 시각도 있다 — 위 경계보다 앞선다 + if (!(t === null || (isSeconds(t) && t < b)) || (a !== null && t === null)) { + throw invalidCursor(kind, 'shape'); + } + if (typeof r !== 'string') throw invalidCursor(kind, 'shape'); + + if (r !== hash) { + throw invalidCursor( + kind, + kind === 'filter-run' ? 'other-rule' : 'other-sender', + ); + } + + return { before: b, anchor: a, anchorTime: t }; +} + +export function encodeFilterRunCursor( + position: FilterRunPosition, + rule: FilterRunRule, +): string { + return encodeRunCursor(position, ruleHash(rule)); +} + /** cursor 를 해석한다 — 형식 · 범위가 틀리거나 다른 규칙의 것이면 VAL_001 */ export function decodeFilterRunCursor( cursor: string, rule: FilterRunRule, ): FilterRunPosition { - if (cursor.length > MAX_MAIL_FILTER_RUN_CURSOR_LENGTH) { - throw invalidCursor('too-long'); - } - - let parsed: unknown; - try { - parsed = JSON.parse(Buffer.from(cursor, 'base64url').toString('utf8')); - } catch { - throw invalidCursor('not-json'); - } - - const { v, b, a, t, r } = (parsed ?? {}) as Partial; - if (v !== CURSOR_VERSION || !isSeconds(b) || b === 0) { - throw invalidCursor('shape'); - } - if (!(a === null || (typeof a === 'string' && MAIL_ID_PATTERN.test(a)))) { - throw invalidCursor('shape'); - } - // anchor 가 있으면 그 받은 시각도 있다 — 위 경계보다 앞선다 - if (!(t === null || (isSeconds(t) && t < b)) || (a !== null && t === null)) { - throw invalidCursor('shape'); - } - if (typeof r !== 'string') throw invalidCursor('shape'); - - if (r !== ruleHash(rule)) throw invalidCursor('other-rule'); - - return { before: b, anchor: a, anchorTime: t }; + return decodeRunCursor(cursor, ruleHash(rule), 'filter-run'); +} + +/** 차단 정리의 cursor (15단계) — 자리는 필터 적용과 같고 지문은 그 보낸 사람 */ +export function encodeBlockRunCursor( + position: FilterRunPosition, + sender: string, +): string { + return encodeRunCursor(position, senderHash(sender)); +} + +/** 차단 정리의 cursor 를 해석한다 — 형식 · 범위가 틀리거나 다른 보낸 사람(또는 필터 적용)의 것이면 VAL_001 */ +export function decodeBlockRunCursor( + cursor: string, + sender: string, +): FilterRunPosition { + return decodeRunCursor(cursor, senderHash(sender), 'mail-block-run'); } diff --git a/backend/src/modules/mail/jmap/filter-run-texts.ts b/backend/src/modules/mail/jmap/filter-run-texts.ts index acf2db8..8b7b4b5 100644 --- a/backend/src/modules/mail/jmap/filter-run-texts.ts +++ b/backend/src/modules/mail/jmap/filter-run-texts.ts @@ -83,6 +83,33 @@ function decodedHeaders(email: JmapEmail, names: readonly string[]): string[] { ); } +/** + * 차단할 때 받은편지함 정리(15단계)가 읽는 보낸 사람 — 엔진이 푼 From 머리글의 주소 목록, 같은 이름의 머리글이 여럿이면 모두(머리글마다 배열). + * Sieve 의 `address` 판정(jmap/sieve-script.ts 의 blockSection)이 견주는 것과 같다 — 표시 이름에 쓴 주소는 들지 않는다. + * 엔진은 물은 이름 그대로 답한다(Stalwart 0.16.27 — 2026-11-09 확인: `[[{ name, email }]]`) + */ +export const FROM_ADDRESSES_PROPERTY = 'header:From:asAddresses:all'; + +/** From 머리글들의 주소 — 물은 이름으로 찾되 대소문자가 달라도 찾는다. 모양이 틀린 칸은 건너뛴다 */ +export function fromAddressesOf(email: JmapEmail): string[] { + const wanted = FROM_ADDRESSES_PROPERTY.toLowerCase(); + for (const [key, value] of Object.entries(email)) { + if (key.toLowerCase() !== wanted || !Array.isArray(value)) continue; + return value.flatMap((header: unknown) => + Array.isArray(header) + ? header.flatMap((address: unknown) => { + const found = + typeof address === 'object' && address !== null + ? (address as { email?: unknown }).email + : undefined; + return typeof found === 'string' ? [found] : []; + }) + : [], + ); + } + return []; +} + /** 머리글의 견줄 글 — 보낸 사람 · 받는 사람 · 제목 (본문은 bodyTextsOf) */ export function headerTextsOf(email: JmapEmail): Omit { const subject = email.subject; diff --git a/backend/src/modules/mail/jmap/jmap-filter-run.spec.ts b/backend/src/modules/mail/jmap/jmap-filter-run.spec.ts index 68bdac7..40f7ce8 100644 --- a/backend/src/modules/mail/jmap/jmap-filter-run.spec.ts +++ b/backend/src/modules/mail/jmap/jmap-filter-run.spec.ts @@ -11,6 +11,7 @@ import type { } from '@/modules/mail/mail.types'; import { decodeFilterRunCursor, + encodeBlockRunCursor, encodeFilterRunCursor, } from './filter-run-cursor'; import type { JmapClient } from './jmap.client'; @@ -1180,6 +1181,278 @@ describe('JmapFilterRunner — 이미 받은 메일에 필터 적용 (계약 2 ).toMatchObject({ code: 'MAIL_001', reason: 'timeout' }); }); }); + + describe('차단할 때 받은편지함 정리 (runBlock — 15단계)', () => { + const JUNK = 'k'; + const BLOCK_MAILBOXES: readonly Mailbox[] = [ + mailbox(INBOX, 'inbox', 3), + mailbox(TARGET, null), + mailbox(JUNK, 'junk'), + ]; + const blockContext = ( + overrides: Partial = {}, + ): MailFilterRunContext => ({ + mailboxes: BLOCK_MAILBOXES, + startedAt: Date.now(), + ...overrides, + }); + /** 받은편지함의 메일 — 엔진이 푼 From 머리글의 주소(머리글마다 배열) */ + const fromMail = ( + id: string, + ...headers: { name: string | null; email: string }[][] + ) => ({ + id, + mailboxIds: { [INBOX]: true }, + receivedAt: utc(T0), + 'header:From:asAddresses:all': headers, + }); + const KIM = { name: null, email: 'kim@spam.example' }; + const junkPatch = { + [`mailboxIds/${INBOX}`]: null, + [`mailboxIds/${JUNK}`]: true, + 'keywords/$junk': true, + 'keywords/$notjunk': null, + }; + + it('받은편지함을 받은 시각 내림차순으로 읽어 엔진이 푼 From 주소로 판정하고, 맞은 메일을 스팸메일함으로 옮기며 스팸 표시 — 옮기기는 읽은 상태 그대로일 때만', async () => { + const { runner, recorded } = fakeRunner([ + page( + ['m1', 'm2', 'm3'], + [ + fromMail('m1', [KIM]), + fromMail('m2', [{ name: null, email: 'lee@spam.example' }]), + fromMail('m3', [{ name: 'Kim', email: 'KIM@Spam.Example' }]), + ], + ), + updated(['m1', 'm3']), + ]); + + await expect( + runner.runBlock( + ARA, + { sender: 'kim@spam.example', cursor: null }, + blockContext(), + ), + ).resolves.toEqual({ + scanned: 3, + matched: 2, + remaining: 0, + cursor: null, + rounds: 1, + }); + expect(argsOf(recorded[0])).toMatchObject({ + filter: { inMailbox: INBOX }, + sort: [{ property: 'receivedAt', isAscending: false }], + limit: 500, + calculateTotal: true, + }); + expect(argsOf(recorded[0], 1)).toMatchObject({ + properties: [ + 'id', + 'mailboxIds', + 'receivedAt', + 'header:From:asAddresses:all', + ], + }); + // 붙잡힌 메일을 같은 요청에서 묻는다 (9단계 — 옮기지 않는다) + expect(methodsOf([recorded[0]])).toEqual([ + 'Email/query', + 'Email/get', + 'EmailSubmission/query', + 'EmailSubmission/get', + ]); + expect(argsOf(recorded[1])).toEqual({ + accountId: 'd', + ifInState: 's1', + update: { m1: junkPatch, m3: junkPatch }, + }); + }); + + it('판정은 차단 규칙(Sieve 의 address)과 같다 — 주소 전체가 같을 때 · From 이 여럿이면 하나라도 · 표시 이름의 주소 · 비슷한 주소는 아니다 (대소문자는 ASCII 만)', async () => { + const cases: [string, ReturnType][] = [ + ['맞음', fromMail('m1', [KIM])], + [ + '맞음', + fromMail('m2', [ + { name: 'First', email: 'first@example.net' }, + { name: 'Kim', email: 'kim@spam.example' }, + ]), + ], + [ + '맞음', + fromMail('m3', [{ name: null, email: 'first@example.net' }], [KIM]), + ], + [ + '아님', + fromMail('m4', [ + { name: 'kim@spam.example', email: 'other@example.net' }, + ]), + ], + [ + '아님', + fromMail('m5', [{ name: null, email: 'kim.lee@spam.example' }]), + ], + [ + '아님', + fromMail('m6', [{ name: null, email: 'kim@sub.spam.example' }]), + ], + [ + '아님', + fromMail('m7', [{ name: null, email: 'akim@spam.example' }]), + ], + ['아님', fromMail('m8')], + ]; + const { runner, recorded } = fakeRunner([ + page( + cases.map(([, mail]) => mail.id), + cases.map(([, mail]) => mail), + ), + updated(['m1', 'm2', 'm3']), + ]); + + await runner.runBlock( + ARA, + { sender: 'kim@spam.example', cursor: null }, + blockContext(), + ); + expect(Object.keys(argsOf(recorded[1]).update as object)).toEqual( + cases + .filter(([verdict]) => verdict === '맞음') + .map(([, mail]) => mail.id), + ); + }); + + it('도메인 차단(@도메인)은 From 주소의 @ 뒤가 그 도메인일 때만 — 하위 도메인 · 더 긴 도메인 · 표시 이름은 아니다', async () => { + const mails = [ + fromMail('m1', [{ name: null, email: 'kim@spam.example' }]), + fromMail('m2', [{ name: null, email: 'Lee@SPAM.example' }]), + fromMail('m3', [{ name: null, email: 'kim@sub.spam.example' }]), + fromMail('m4', [{ name: null, email: 'kim@spam.example.net' }]), + fromMail('m5', [ + { name: '@spam.example', email: 'kim@other.example' }, + ]), + ]; + const { runner, recorded } = fakeRunner([ + page( + mails.map(({ id }) => id), + mails, + ), + updated(['m1', 'm2']), + ]); + + await runner.runBlock( + ARA, + { sender: '@spam.example', cursor: null }, + blockContext(), + ); + expect(Object.keys(argsOf(recorded[1]).update as object)).toEqual([ + 'm1', + 'm2', + ]); + }); + + it('붙잡힌 메일(9단계)은 맞아도 옮기지 않는다 — 받은편지함에 남긴 것으로 세고 matched 에 넣지 않는다', async () => { + const later = utc(Math.floor(Date.now() / 1_000) + 3_600); + const { runner, recorded } = fakeRunner([ + page(['m1', 'm2'], [fromMail('m1', [KIM]), fromMail('m2', [KIM])], { + held: [ + { + id: 'sub1', + emailId: 'm2', + undoStatus: 'pending', + sendAt: later, + }, + ], + }), + updated(['m1']), + ]); + + await expect( + runner.runBlock( + ARA, + { sender: 'kim@spam.example', cursor: null }, + blockContext(), + ), + ).resolves.toMatchObject({ scanned: 2, matched: 1 }); + expect(argsOf(recorded[1])).toMatchObject({ + update: { m1: junkPatch }, + }); + expect(argsOf(recorded[1]).update).not.toHaveProperty('m2'); + }); + + it('스팸메일함이 없으면 엔진에 묻지 않고 MAIL_001 · 받은편지함이 없으면 할 메일이 없다', async () => { + const noJunk = fakeRunner([]); + expect( + await failureOf( + noJunk.runner.runBlock( + ARA, + { sender: 'kim@spam.example', cursor: null }, + blockContext({ + mailboxes: BLOCK_MAILBOXES.filter( + ({ role }) => role !== 'junk', + ), + }), + ), + ), + ).toMatchObject({ code: 'MAIL_001', reason: 'junk-mailbox-missing' }); + expect(noJunk.recorded).toHaveLength(0); + + const noInbox = fakeRunner([]); + await expect( + noInbox.runner.runBlock( + ARA, + { sender: 'kim@spam.example', cursor: null }, + blockContext({ + mailboxes: BLOCK_MAILBOXES.filter(({ role }) => role !== 'inbox'), + }), + ), + ).resolves.toEqual({ + scanned: 0, + matched: 0, + remaining: 0, + cursor: null, + rounds: 0, + }); + }); + + it('이어 할 자리 — 다른 보낸 사람의 cursor · 필터 적용의 cursor · 깨진 cursor 는 엔진에 묻기 전에 VAL_001', async () => { + const position = { before: T0 + 1, anchor: 'm1', anchorTime: T0 }; + const others = [ + encodeBlockRunCursor(position, 'lee@spam.example'), + encodeFilterRunCursor(position, request()), + 'not-a-cursor', + ]; + for (const cursor of others) { + const fake = fakeRunner([]); + expect( + await failureOf( + fake.runner.runBlock( + ARA, + { sender: 'kim@spam.example', cursor }, + blockContext(), + ), + ), + ).toMatchObject({ code: 'VAL_001' }); + expect(fake.recorded).toHaveLength(0); + } + + // 같은 보낸 사람의 cursor 는 그 자리부터 — anchor 다음 · 위 경계 미만 + const fake = fakeRunner([page([], [])]); + await fake.runner.runBlock( + ARA, + { + sender: 'kim@spam.example', + cursor: encodeBlockRunCursor(position, 'kim@spam.example'), + }, + blockContext(), + ); + expect(argsOf(fake.recorded[0])).toMatchObject({ + filter: { inMailbox: INBOX, before: utc(T0 + 1) }, + anchor: 'm1', + anchorOffset: 1, + }); + }); + }); }); /** @@ -1369,6 +1642,10 @@ describe('JmapFilterRunner — 이미 받은 메일에 필터 적용 (계약 2 ), receivedAt: utc(email.receivedAt), 'header:From:all': [` ${email.from}`], + // 엔진이 푼 주소 — 꺾쇠 안 (차단 정리 — 15단계) + 'header:From:asAddresses:all': [ + [{ name: null, email: /<([^>]+)>/.exec(email.from)?.[1] ?? '' }], + ], }; } @@ -1706,5 +1983,68 @@ describe('JmapFilterRunner — 이미 받은 메일에 필터 적용 (계약 2 } expect(engine.sorted(TARGET)).toHaveLength(700 - kept.length); }); + + it('차단 정리(15단계)도 같은 틀로 끝까지 — 그 보낸 사람의 메일만 스팸메일함으로(스팸 표시) · 요청 사이에 온 새 메일은 건드리지 않는다', async () => { + const JUNK = 'k'; + const engine = new FakeEngine(); + fill(engine, 1_200); + let now = 1_000_000; + jest.spyOn(Date, 'now').mockImplementation(() => now); + const client = { + call: jest.fn( + (_user: string, build: (accountId: string) => JmapInvocation[]) => { + const calls = build('d'); + if (calls[0][0] === 'Email/query') now += 21_000; + return Promise.resolve(engine.handle(calls)); + }, + ), + }; + const runner = new JmapFilterRunner(client as unknown as JmapClient); + const sender = 'sales@hanbit-partners.co.kr'; + const wanted = engine + .sorted(INBOX) + .filter(({ from }) => from.includes(sender)).length; + + let cursor: string | null = null; + let requests = 0; + let matched = 0; + do { + // 요청 사이에 그 보낸 사람의 새 메일이 온다 — 시작한 뒤 온 메일은 보지 않는다 (새 메일은 Sieve 가 이미 판정했다) + if (cursor !== null) engine.add(T0 + 10_000 + requests, HANBIT_FROM); + const result = await runner.runBlock( + ARA, + { sender, cursor }, + { + mailboxes: [ + mailbox(INBOX, 'inbox', engine.sorted(INBOX).length), + mailbox(JUNK, 'junk'), + ], + startedAt: now, + }, + ); + matched += result.matched; + cursor = result.cursor; + requests += 1; + now += 100_000; + } while (cursor !== null && requests < 100); + + expect(cursor).toBeNull(); + expect(requests).toBeGreaterThan(1); + expect(matched).toBe(wanted); + const moved = engine.sorted(JUNK); + expect(moved).toHaveLength(wanted); + for (const email of moved) { + expect(email.keywords.has('$junk')).toBe(true); + expect(email.receivedAt).toBeLessThan(T0 + 10_000); + } + // 받은편지함에 남은 그 보낸 사람의 메일은 요청 사이에 온 새 메일뿐 + const left = engine + .sorted(INBOX) + .filter(({ from }) => from.includes(sender)); + expect(left).toHaveLength(requests - 1); + expect(left.every(({ receivedAt }) => receivedAt >= T0 + 10_000)).toBe( + true, + ); + }); }); }); diff --git a/backend/src/modules/mail/jmap/jmap-filter-run.ts b/backend/src/modules/mail/jmap/jmap-filter-run.ts index 0a5dbb9..0264747 100644 --- a/backend/src/modules/mail/jmap/jmap-filter-run.ts +++ b/backend/src/modules/mail/jmap/jmap-filter-run.ts @@ -1,6 +1,7 @@ import { Injectable, Logger } from '@nestjs/common'; import { BusinessException } from '@/common/errors/business.exception'; +import { isBlockedSenderMail } from '@/modules/mail/mail-block-match'; import { filterFields, judgeFilter, @@ -8,6 +9,7 @@ import { } from '@/modules/mail/mail-filter-match'; import type { Mailbox, + MailBlockRunRequest, MailFilterRunContext, MailFilterRunOutcome, MailFilterRunRequest, @@ -15,15 +17,20 @@ import type { import { BulkRun } from './bulk-run'; import { MAX_FILTER_RUN_SECONDS, + decodeBlockRunCursor, decodeFilterRunCursor, + encodeBlockRunCursor, encodeFilterRunCursor, + type FilterRunPosition, } from './filter-run-cursor'; import { FILTER_RUN_BODY_PROPERTIES, FILTER_RUN_PART_PROPERTIES, + FROM_ADDRESSES_PROPERTY, bodyTextsOf, estimatedBodyBytes, filterRunProperties, + fromAddressesOf, headerTextsOf, textPartsOf, } from './filter-run-texts'; @@ -97,6 +104,32 @@ const HELD_GUARD_CALLS = 'h'; /** 붙잡힌 메일이 없다 — 옮기지 않는 규칙 · 읽은 메일이 없는 쪽 */ const NO_HELD: HeldEmails = { ids: new Set(), complete: true }; +/** 처음부터 — 위 경계 · 남긴 메일 · 그 받은 시각을 아직 모른다 (첫 차례가 위 경계를 잡는다) */ +const FROM_TOP: RunState = { before: null, anchor: null, anchorTime: null }; + +/** 바꾼 것이 없다 — 맞은 메일이 없거나 모두 바꿀 것이 없는 차례 */ +const NOTHING_APPLIED: Applied = { updated: new Set(), gone: new Set() }; + +/** + * 차단 정리(15단계)가 차례마다 읽는 속성 — 받은편지함에 아직 있는지 · 받은 시각(이어 할 자리) · 엔진이 푼 From 머리글의 주소(filter-run-texts.ts). + * 표시는 읽지 않는다 — 맞은 메일은 늘 옮기고 스팸 표시를 붙인다(이미 붙어 있어도 같은 패치) + */ +const BLOCK_RUN_PROPERTIES = [ + 'id', + 'mailboxIds', + 'receivedAt', + FROM_ADDRESSES_PROPERTY, +]; + +/** 받은편지함이 없는 계정 — 할 메일이 없다 (필터 적용 · 차단 정리 모두) */ +const NOTHING_TO_RUN: MailFilterRunOutcome = { + scanned: 0, + matched: 0, + remaining: 0, + cursor: null, + rounds: 0, +}; + /** * 한 통의 본문을 부분마다 읽는 몫 (바이트) — 한 부분은 FILTER_RUN_BODY_VALUE_BYTES 까지, 한 통은 모두 합쳐 FILTER_RUN_MESSAGE_BODY_BYTES 까지. * 글자 부분이 많으면 같은 몫씩 나눈다 (적어도 1바이트). 엔진의 maxBodyValueBytes 는 요청 단위라 몫이 다른 메일은 따로 읽는다 @@ -185,6 +218,37 @@ interface BodyBatch { readonly ids: readonly string[]; } +/** 한 차례를 판정하고 할 일을 한다 — 필터 적용(applyRound) · 차단 정리(applyBlockRound). 멈춰야 하면 null */ +type RoundRunner = (page: RoundPage) => Promise; + +/** 받은편지함을 차례로 훑은 결과 (sweep) */ +interface SweepResult { + readonly scanned: number; + readonly matched: number; + readonly remaining: number; + /** 끝까지 했다 — 이어 할 자리가 없다 */ + readonly finished: boolean; + /** 마지막으로 마친 차례까지의 자리 */ + readonly position: RunState; + readonly rounds: number; +} + +/** 판정 — true 맞음 · false 안 맞음 · 'gone' 받은편지함에서 빠진 메일(건너뛰고 세지 않는다) */ +type Verdict = boolean | 'gone'; + +/** + * 차단 정리(15단계)의 패치 — 받은편지함에서 빼고 스팸메일함에 넣고 스팸 표시(`$junk` 를 붙이고 `$notjunk` 를 뗀다 — 스팸 신고 `isJunk: true` 와 같다, + * jmap-mail.provider.ts 의 toEmailPatch) + */ +function blockPatch(inboxId: string, junkId: string): Record { + return { + [`mailboxIds/${inboxId}`]: null, + [`mailboxIds/${junkId}`]: true, + 'keywords/$junk': true, + 'keywords/$notjunk': null, + }; +} + /** 옮길 메일함 — 규칙에 없거나 · 받은편지함이거나 · 메일함 목록에 없으면(지워짐) 옮기지 않는다 (계약 — 표시는 한다) */ function runPlan( request: MailFilterRunRequest, @@ -242,6 +306,41 @@ function utcDate(seconds: number): string { return toJmapUtcDate(new Date(seconds * MS_PER_SECOND).toISOString()); } +/** + * 한 차례를 마친 결과 — 판정한 수(받은편지함에서 빠진 메일은 빼고)와 받은편지함에 남긴 마지막 메일(다음 차례의 anchor). + * 옮긴 메일(moving 가운데 바뀐 것)과 그사이 없어진 메일은 남긴 것이 아니다 + * @param moving 옮기기가 든 패치를 보낸 메일 — 바뀌면 받은편지함을 떠난다 + * @param matched 이 차례에서 할 일을 한 수 (필터 적용은 이미 표시가 있던 메일 포함 · 차단 정리는 옮긴 수) + */ +function roundResult( + page: RoundPage, + verdicts: ReadonlyMap, + moving: ReadonlySet, + applied: Applied, + matched: number, +): RoundResult { + let judged = 0; + let lastStayed: string | null = null; + for (const id of page.ids) { + const verdict = verdicts.get(id); + if (verdict === undefined || verdict === 'gone') continue; + judged += 1; + const moved = moving.has(id) && applied.updated.has(id); + if (!moved && !applied.gone.has(id)) lastStayed = id; + } + return { + judged, + matched, + lastStayed: + lastStayed === null + ? null + : { + id: lastStayed, + time: receivedSeconds(page.emails.get(lastStayed)), + }, + }; +} + /** 차례에서 가장 최근의 받은 시각(초) — 받은편지함 맨 위 메일 (읽은 메일이 없으면 null) */ function newestSecond(page: RoundPage): number | null { let newest: number | null = null; @@ -305,6 +404,9 @@ function bodyBatches(emails: readonly JmapEmail[]): BodyBatch[] { * 진행(processed)은 판정한 메일 수다 — 판정한 것이 없는 차례는 멈춘 차례로 센다 * - 이어 할 자리는 cursor — 한 차례를 다 마친 곳까지만 (도중에 멈춘 차례는 다음 요청이 다시 한다 — 할 일은 다시 해도 같다) * - 받은편지함 · 옮길 메일함은 서비스가 읽은 메일함 목록(context.mailboxes)에서 찾는다 — 다시 읽지 않는다 + * + * 차단할 때 받은편지함 정리(runBlock — 15단계, 계약 2절 "보낸 사람 차단")도 같은 틀이다 — 차례 · 위 경계 · 자리 · 시간 · 멈춤 · 옮기기의 조건(ifInState) · + * 붙잡힌 메일 막기는 그대로(sweep · readPage · applyPatches), 판정만 차단 규칙(Sieve 의 `address` — mail-block-match.ts)이고 할 일은 스팸메일함으로 옮기기 + 스팸 표시다 */ @Injectable() export class JmapFilterRunner { @@ -318,17 +420,15 @@ export class JmapFilterRunner { context: MailFilterRunContext, ): Promise { // 이어 할 자리는 엔진에 묻기 전에 본다 — 다른 규칙 · 깨진 cursor 면 VAL_001 - let position: RunState = + const start: RunState = request.cursor === null - ? { before: null, anchor: null, anchorTime: null } + ? FROM_TOP : decodeFilterRunCursor(request.cursor, request); const run = new BulkRun(context.startedAt); const inbox = context.mailboxes.find((mailbox) => mailbox.role === 'inbox'); // 받은편지함이 없는 계정 — 적용할 메일이 없다 - if (!inbox) { - return { scanned: 0, matched: 0, remaining: 0, cursor: null, rounds: 0 }; - } + if (!inbox) return NOTHING_TO_RUN; const plan = runPlan(request, inbox.id, context.mailboxes); const fields = filterFields(request); @@ -341,6 +441,78 @@ export class JmapFilterRunner { withHeld: plan.moveTo !== null, }; + const swept = await this.sweep(user, inbox, start, read, run, (page) => + this.applyRound(user, request, plan, page, run), + ); + if (!swept.finished) run.assertProgress('filter-run'); + return this.outcomeOf(swept, (position) => + encodeFilterRunCursor(position, request), + ); + } + + /** + * 차단할 때 받은편지함 정리 (계약 2절 "보낸 사람 차단" — 15단계) — 받은편지함의 그 보낸 사람 메일을 스팸메일함으로 옮기고 스팸 표시를 붙인다. + * 판정은 엔진이 푼 From 머리글의 주소로 차단 규칙과 같게(mail-block-match.ts) — 머리글 원문을 읽지 않는다(표시 이름에 쓴 주소가 걸리지 않게). + * 맞은 메일은 늘 옮긴다(옮기기라 읽은 상태 그대로일 때만 — ifInState). 붙잡힌 메일(9단계)은 옮기지 않는다(받은편지함에는 없지만 지금 규칙대로). + * 스팸메일함이 없으면 엔진에 묻기 전에 MAIL_001, cursor 가 깨졌거나 다른 보낸 사람의 것이면 그 앞에 VAL_001. + * 응답의 matched 는 스팸메일함으로 옮긴 수다 + */ + async runBlock( + user: string, + request: MailBlockRunRequest, + context: MailFilterRunContext, + ): Promise { + const start: RunState = + request.cursor === null + ? FROM_TOP + : decodeBlockRunCursor(request.cursor, request.sender); + + const run = new BulkRun(context.startedAt); + const inbox = context.mailboxes.find((mailbox) => mailbox.role === 'inbox'); + if (!inbox) return NOTHING_TO_RUN; + const junk = context.mailboxes.find((mailbox) => mailbox.role === 'junk'); + if (!junk) { + throw new BusinessException('MAIL_001', { + context: { reason: 'junk-mailbox-missing' }, + }); + } + + const plan: RunPlan = { + inboxId: inbox.id, + moveTo: junk.id, + markAsRead: false, + flag: false, + }; + const read: PageRead = { + properties: BLOCK_RUN_PROPERTIES, + needsBody: false, + limit: HEADER_ROUND_SIZE, + withHeld: true, + }; + + const swept = await this.sweep(user, inbox, start, read, run, (page) => + this.applyBlockRound(user, request.sender, plan, junk.id, page, run), + ); + if (!swept.finished) run.assertProgress('mail-block-run'); + return this.outcomeOf(swept, (position) => + encodeBlockRunCursor(position, request.sender), + ); + } + + /** + * 받은편지함을 받은 시각이 최근인 메일부터 차례로 훑는다 — 차례마다 읽고(readPage) 판정 · 할 일(applyRound)을 한 뒤 자리를 옮긴다. + * 한 요청의 시간 · 멈춤은 BulkRun. 처음 차례가 맨 위 메일의 받은 시각 + 1초를 위 경계로 잡는다(그 뒤에 온 메일은 보지 않는다). + * 남은 시간 때문에 끊긴 호출은 진행한 것이 있으면 거기까지로 답한다(cutOffAfterProgress) + */ + private async sweep( + user: string, + inbox: Mailbox, + start: RunState, + read: PageRead, + run: BulkRun, + applyRound: RoundRunner, + ): Promise { + let position = start; let scanned = 0; let matched = 0; let rounds = 0; @@ -359,7 +531,7 @@ export class JmapFilterRunner { const newest = position.before === null ? newestSecond(page) : null; if (newest !== null) position = { ...position, before: newest + 1 }; - const round = await this.applyRound(user, request, plan, page, run); + const round = await applyRound(page); // 멈춘다 — 이 차례는 하지 않은 것으로 (다음 요청이 같은 자리부터 다시 한다) if (round === null) break; @@ -387,22 +559,38 @@ export class JmapFilterRunner { } catch (error) { if (!run.cutOffAfterProgress(error)) throw error; } - if (!finished) run.assertProgress('filter-run'); - return { scanned, matched, remaining: finished ? 0 : remaining, - cursor: finished ? null : this.cursorOf(position, request), + finished, + position, rounds, }; } + /** 훑은 결과 → 응답 — 끝까지 했으면 cursor 없음, 아니면 마친 자리의 cursor (cursorOf) */ + private outcomeOf( + swept: SweepResult, + encode: (position: FilterRunPosition) => string, + ): MailFilterRunOutcome { + return { + scanned: swept.scanned, + matched: swept.matched, + remaining: swept.remaining, + cursor: swept.finished ? null : this.cursorOf(swept.position, encode), + rounds: swept.rounds, + }; + } + /** * 이어 할 자리 — 위 경계와 남긴 마지막 메일의 받은 시각이 있어야 한다. 없으면 엔진이 받은 시각을 주지 않은 것 — MAIL_001 * (끝까지 했으면 이어 할 자리가 필요 없어 묻지 않는다) */ - private cursorOf(position: RunState, request: MailFilterRunRequest): string { + private cursorOf( + position: RunState, + encode: (position: FilterRunPosition) => string, + ): string { if ( position.before === null || (position.anchor !== null && position.anchorTime === null) @@ -411,14 +599,11 @@ export class JmapFilterRunner { context: { reason: 'received-at-missing' }, }); } - return encodeFilterRunCursor( - { - before: position.before, - anchor: position.anchor, - anchorTime: position.anchorTime, - }, - request, - ); + return encode({ + before: position.before, + anchor: position.anchor, + anchorTime: position.anchorTime, + }); } /** @@ -571,7 +756,7 @@ export class JmapFilterRunner { run: BulkRun, ): Promise { /** 판정 — 'gone' 은 받은편지함에서 빠진 메일(건너뛰고 세지 않는다) */ - const verdicts = new Map(); + const verdicts = new Map(); const undecided: JmapEmail[] = []; const headerTexts = new Map>(); for (const id of page.ids) { @@ -641,30 +826,69 @@ export class JmapFilterRunner { const applied: Applied | null = patches.size === 0 - ? { updated: new Set(), gone: new Set() } + ? NOTHING_APPLIED : await this.applyPatches(user, plan, page.state, patches, run); if (applied === null) return null; - let judged = 0; - let lastStayed: string | null = null; + return roundResult( + page, + verdicts, + moving, + applied, + unchanged + applied.updated.size, + ); + } + + /** + * 차단 정리의 한 차례 (15단계) — 엔진이 푼 From 주소로 판정하고(mail-block-match.ts) 맞은 메일을 스팸메일함으로 옮긴다(스팸 표시와 함께). + * 붙잡힌 메일(9단계)은 옮기지 않는다 — 받은편지함에 남은 것으로 센다. matched 는 옮긴 수다. 멈춰야 하면 null (applyPatches) + */ + private async applyBlockRound( + user: string, + sender: string, + plan: RunPlan, + junkId: string, + page: RoundPage, + run: BulkRun, + ): Promise { + const verdicts = new Map(); for (const id of page.ids) { - const verdict = verdicts.get(id); - if (verdict === undefined || verdict === 'gone') continue; - judged += 1; - const moved = moving.has(id) && applied.updated.has(id); - if (!moved && !applied.gone.has(id)) lastStayed = id; + const email = page.emails.get(id); + verdicts.set( + id, + email?.mailboxIds?.[plan.inboxId] !== true + ? 'gone' + : isBlockedSenderMail(fromAddressesOf(email), sender), + ); } - return { - judged, - matched: unchanged + applied.updated.size, - lastStayed: - lastStayed === null - ? null - : { - id: lastStayed, - time: receivedSeconds(page.emails.get(lastStayed)), - }, - }; + + const matchedIds = page.ids.filter((id) => verdicts.get(id) === true); + const held = + matchedIds.length === 0 + ? new Set() + : await heldAmong( + page.held, + matchedIds, + this.heldLookup(user, run.callOptions()), + ); + const patches = new Map>(); + for (const id of matchedIds) { + if (!held.has(id)) patches.set(id, blockPatch(plan.inboxId, junkId)); + } + + const applied: Applied | null = + patches.size === 0 + ? NOTHING_APPLIED + : await this.applyPatches(user, plan, page.state, patches, run); + if (applied === null) return null; + + return roundResult( + page, + verdicts, + new Set(patches.keys()), + applied, + applied.updated.size, + ); } /** diff --git a/backend/src/modules/mail/jmap/jmap-mail.provider.spec.ts b/backend/src/modules/mail/jmap/jmap-mail.provider.spec.ts index 59fc8cf..55614ee 100644 --- a/backend/src/modules/mail/jmap/jmap-mail.provider.spec.ts +++ b/backend/src/modules/mail/jmap/jmap-mail.provider.spec.ts @@ -11,7 +11,10 @@ import { MAX_ENVELOPE_RESUBMITS, MAX_SEARCH_WORDS, } from '@/modules/mail/mail.constants'; -import { SendNotStartedException } from '@/modules/mail/mail.provider'; +import { + ScheduledSendReturnedToDraftsException, + SendNotStartedException, +} from '@/modules/mail/mail.provider'; import type { MailSettings } from '@/modules/mail/mail.settings'; import type { DraftContent, @@ -6545,7 +6548,7 @@ describe('JmapMailProvider', () => { }); }); - describe('이름 바꾸기 (renameMailbox)', () => { + describe('이름 바꾸기 · 옮기기 (updateMailbox — 15단계)', () => { it('그 메일함만 이름을 바꾸고 같은 요청에서 다시 읽어 돌려준다', async () => { const { provider, recorded } = fakeClient([ mailboxList(), @@ -6566,9 +6569,14 @@ describe('JmapMailProvider', () => { ], ]); + const log = jest + .spyOn(Reflect.get(provider, 'logger') as Logger, 'log') + .mockImplementation(() => undefined); await expect( - provider.renameMailbox(ARA, 'u1', '새 이름'), + provider.updateMailbox(ARA, 'u1', { name: '새 이름' }), ).resolves.toMatchObject({ id: 'u1', name: '새 이름', totalCount: 2 }); + // 이름만 바꾸기는 지금처럼 로그를 남기지 않는다 (옮겼을 때만) + expect(log).not.toHaveBeenCalled(); expect(recorded[1].calls).toEqual([ [ 'Mailbox/set', @@ -6594,41 +6602,201 @@ describe('JmapMailProvider', () => { ['i1', 'VAL_001'], ['nope', 'RES_001'], ] as const) { - const { provider, recorded } = fakeClient([ - mailboxList(withUnknownRole), - ]); - expect(await codeOf(provider.renameMailbox(ARA, id, '새 이름'))).toBe( - code, - ); - expect(recorded).toHaveLength(1); + for (const change of [{ name: '새 이름' }, { parentId: 'u3' }]) { + const { provider, recorded } = fakeClient([ + mailboxList(withUnknownRole), + ]); + expect(await codeOf(provider.updateMailbox(ARA, id, change))).toBe( + code, + ); + expect(recorded).toHaveLength(1); + } } }); - it('지금 이름과 같으면 엔진에 묻지 않고 그대로 돌려준다', async () => { - const { provider, recorded } = fakeClient([mailboxList()]); - - await expect( - provider.renameMailbox(ARA, 'u1', '프로젝트'), - ).resolves.toMatchObject({ id: 'u1', name: '프로젝트' }); - expect(recorded).toHaveLength(1); + it('지금 이름 · 지금 상위와 같으면 엔진에 묻지 않고 그대로 돌려준다', async () => { + const cases: [string, { name?: string; parentId?: string | null }][] = [ + ['u1', { name: '프로젝트' }], + ['u2', { parentId: 'u1' }], + ['u3', { parentId: null }], + ['u2', { name: '하위', parentId: 'u1' }], + ]; + for (const [id, change] of cases) { + const { provider, recorded } = fakeClient([mailboxList()]); + const log = jest + .spyOn(Reflect.get(provider, 'logger') as Logger, 'log') + .mockImplementation(() => undefined); + await expect( + provider.updateMailbox(ARA, id, change), + ).resolves.toMatchObject({ id }); + expect(recorded).toHaveLength(1); + // 바꾸지 않았으니 "옮겼습니다" 로그도 없다 + expect(log).not.toHaveBeenCalled(); + } }); - it('같은 이름이 있으면(alreadyExists) MAIL_009, 그사이 없어졌으면 RES_001', async () => { + it('같은 이름이 있으면(alreadyExists — 대소문자 무시) MAIL_009, 그사이 없어졌으면 RES_001', async () => { for (const [setError, code] of [ [{ type: 'alreadyExists' }, 'MAIL_009'], [{ type: 'notFound' }, 'RES_001'], ] as const) { + for (const change of [{ name: '보관 자료' }, { parentId: 'u4' }]) { + const { provider } = fakeClient([ + mailboxList(), + [ + ['Mailbox/set', { notUpdated: { u1: setError } }, 'u'], + ['Mailbox/get', { list: [] }, 'm'], + ], + ]); + expect( + await codeOf(provider.updateMailbox(ARA, 'u1', change)), + ).toBe(code); + } + } + }); + + it('옮기기 — parentId 를 바꾸고 목록 전체를 같은 요청에서 다시 읽는다 (맨 위는 null, 이름과 함께도 된다)', async () => { + const cases: [ + string, + { name?: string; parentId: string | null }, + Record, + ][] = [ + ['u1', { parentId: 'u4' }, { parentId: 'u4' }], + ['u2', { parentId: null }, { parentId: null }], + [ + 'u3', + { name: '2025', parentId: 'a' }, + { name: '2025', parentId: 'a' }, + ], + ]; + for (const [id, change, update] of cases) { + const moved = userMailbox(id, change.name ?? id, { + parentId: change.parentId, + }); + const { provider, recorded } = fakeClient([ + mailboxList(), + [ + ['Mailbox/set', { updated: { [id]: null } }, 'u'], + [ + 'Mailbox/get', + { + list: [ + ...mailboxes.filter((mailbox) => mailbox.id !== id), + moved, + ], + }, + 'm', + ], + ], + ]); + + const log = jest + .spyOn(Reflect.get(provider, 'logger') as Logger, 'log') + .mockImplementation(() => undefined); + await expect( + provider.updateMailbox(ARA, id, change), + ).resolves.toMatchObject({ id, parentId: change.parentId }); + // 옮겼을 때만 — 메일함 id · 새 상위 id 만 (이름 · 주소 없이) + expect(log).toHaveBeenCalledTimes(1); + const [line] = log.mock.calls[0] as [string]; + expect(line).toContain(`메일함=${id}`); + expect(line).toContain(`새 상위=${change.parentId ?? '맨 위'}`); + expect(line).not.toContain('2025'); + expect(line).not.toContain(ARA); + expect(recorded[1].calls).toEqual([ + ['Mailbox/set', { accountId: 'd', update: { [id]: update } }, 'u'], + [ + 'Mailbox/get', + { accountId: 'd', ids: null, properties: MAILBOX_PROPERTIES }, + 'm', + ], + ]); + } + }); + + it('받은편지함 · 보관함 · 내 메일함 아래로는 옮길 수 있다 — 보낸편지함 · 임시보관함 · 스팸메일함 · 휴지통 아래는 VAL_001, 없는 상위는 RES_001 (엔진에 묻지 않는다)', async () => { + for (const parentId of ['a', 'f', 'u3']) { const { provider } = fakeClient([ mailboxList(), [ - ['Mailbox/set', { notUpdated: { u1: setError } }, 'u'], - ['Mailbox/get', { list: [] }, 'm'], + ['Mailbox/set', { updated: { u4: null } }, 'u'], + [ + 'Mailbox/get', + { list: [userMailbox('u4', '보관 자료', { parentId })] }, + 'm', + ], ], ]); - expect( - await codeOf(provider.renameMailbox(ARA, 'u1', '보관 자료')), - ).toBe(code); + await expect( + provider.updateMailbox(ARA, 'u4', { parentId }), + ).resolves.toMatchObject({ parentId }); } + for (const [parentId, code] of [ + ['e', 'VAL_001'], + ['d', 'VAL_001'], + ['c', 'VAL_001'], + ['b', 'VAL_001'], + ['nope', 'RES_001'], + ] as const) { + const { provider, recorded } = fakeClient([mailboxList()]); + expect( + await codeOf(provider.updateMailbox(ARA, 'u4', { parentId })), + ).toBe(code); + expect(recorded).toHaveLength(1); + } + }); + + it('자기 자신 · 자기 하위(손주까지) 아래로는 VAL_001 — 엔진에 묻기 전에 본다', async () => { + const deep = [ + ...mailboxes, + userMailbox('u5', '손주', { parentId: 'u2' }), + ]; + for (const parentId of ['u1', 'u2', 'u5']) { + const { provider, recorded } = fakeClient([mailboxList(deep)]); + await expect( + provider.updateMailbox(ARA, 'u1', { parentId }), + ).rejects.toMatchObject({ + code: 'VAL_001', + context: { reason: 'mailbox-move-into-itself' }, + }); + expect(recorded).toHaveLength(1); + } + }); + + it('엔진이 상위를 거절하면(invalidProperties parentId) — 다시 읽은 목록에 그 상위가 있으면 고리라 VAL_001, 없으면 그사이 지워져 RES_001', async () => { + const refusal = { + type: 'invalidProperties', + properties: ['parentId'], + }; + const cycle = fakeClient([ + mailboxList(), + [ + ['Mailbox/set', { notUpdated: { u1: refusal } }, 'u'], + ['Mailbox/get', { list: mailboxes }, 'm'], + ], + ]); + expect( + await codeOf( + cycle.provider.updateMailbox(ARA, 'u1', { parentId: 'u4' }), + ), + ).toBe('VAL_001'); + + const gone = fakeClient([ + mailboxList(), + [ + ['Mailbox/set', { notUpdated: { u1: refusal } }, 'u'], + [ + 'Mailbox/get', + { list: mailboxes.filter((mailbox) => mailbox.id !== 'u4') }, + 'm', + ], + ], + ]); + expect( + await codeOf( + gone.provider.updateMailbox(ARA, 'u1', { parentId: 'u4' }), + ), + ).toBe('RES_001'); }); }); @@ -7093,6 +7261,240 @@ describe('JmapMailProvider', () => { 'MAIL_001', ); }); + + describe('하위 메일함까지 지우기 (withChildren — 15단계)', () => { + /** u1 › u2 › u5 (u5 는 손주) */ + const tree = [ + ...mailboxes, + userMailbox('u5', '손주', { parentId: 'u2', totalEmails: 1 }), + ]; + const children = { withChildren: true }; + + it('나무(그 메일함 + 하위 모두)의 메일을 한 조회(OR)로 읽어 옮긴다 — 나무 안에만 있으면 휴지통으로, 나무 밖에도 있으면 나무에서만 뺀다 · 깊은 것부터 한 요청으로 지운다', async () => { + const { provider, recorded } = fakeClient([ + mailboxList(tree), + batch( + [ + only('m1', 'u1'), + only('m2', 'u5'), + { id: 'm3', mailboxIds: { u1: true, u2: true } }, + { id: 'm4', mailboxIds: { u2: true, a: true } }, + ], + 's1', + 4, + ), + [ + [ + 'Email/set', + { updated: { m1: null, m2: null, m3: null, m4: null } }, + 's', + ], + ], + batch([]), + [['Mailbox/set', { destroyed: ['u5', 'u2', 'u1'] }, 'x']], + ]); + + await expect( + provider.deleteMailbox(ARA, 'u1', children), + ).resolves.toEqual({ + deleted: true, + processed: 4, + movedToTrash: 3, + remaining: 0, + }); + expect(argsOf(recorded[1])).toMatchObject({ + filter: { + operator: 'OR', + conditions: [ + { inMailbox: 'u1' }, + { inMailbox: 'u2' }, + { inMailbox: 'u5' }, + ], + }, + calculateTotal: true, + }); + expect(argsOf(recorded[2])).toEqual({ + accountId: 'd', + ifInState: 's1', + update: { + m1: { 'mailboxIds/u1': null, 'mailboxIds/b': true }, + m2: { 'mailboxIds/u5': null, 'mailboxIds/b': true }, + m3: { + 'mailboxIds/u1': null, + 'mailboxIds/u2': null, + 'mailboxIds/b': true, + }, + m4: { 'mailboxIds/u2': null }, + }, + }); + // 깊은 것부터 — 엔진은 적은 차례대로 지운다 (메일을 지우는 옵션은 쓰지 않는다) + expect(argsOf(recorded[4])).toEqual({ + accountId: 'd', + destroy: ['u5', 'u2', 'u1'], + onDestroyRemoveEmails: false, + }); + }); + + it('하위가 있는데 withChildren 이 아니면 VAL_001(지금처럼) · 하위에 역할이 있는 메일함이 있으면 VAL_001 — 아무것도 옮기지 않는다', async () => { + const plain = fakeClient([mailboxList(tree)]); + await expect( + plain.provider.deleteMailbox(ARA, 'u1'), + ).rejects.toMatchObject({ + code: 'VAL_001', + context: { reason: 'mailbox-has-children' }, + }); + + const odd = fakeClient([ + mailboxList([ + ...tree, + userMailbox('i1', 'Important', { + role: 'important', + parentId: 'u2', + }), + ]), + ]); + await expect( + odd.provider.deleteMailbox(ARA, 'u1', children), + ).rejects.toMatchObject({ + code: 'VAL_001', + context: { reason: 'role-mailbox-in-tree' }, + }); + expect(odd.recorded).toHaveLength(1); + }); + + it('하위가 없는 메일함에 withChildren 을 줘도 지금 지우기와 같다 — 조건은 inMailbox 하나', async () => { + const { provider, recorded } = fakeClient([ + mailboxList(tree), + batch([]), + [['Mailbox/set', { destroyed: ['u3'] }, 'x']], + ]); + + await expect( + provider.deleteMailbox(ARA, 'u3', children), + ).resolves.toMatchObject({ deleted: true }); + expect(argsOf(recorded[1])).toMatchObject({ + filter: { inMailbox: 'u3' }, + }); + expect(argsOf(recorded[2])).toMatchObject({ destroy: ['u3'] }); + }); + + it('지우는 사이 깊은 메일함에 메일이 들어오면(mailboxHasEmail) 지운 하위는 나무에서 빼고 남은 나무의 메일을 옮긴 뒤 다시 지운다', async () => { + const { provider, recorded } = fakeClient([ + mailboxList(tree), + batch([]), + [ + [ + 'Mailbox/set', + { + destroyed: ['u5'], + notDestroyed: { + u2: { type: 'mailboxHasEmail' }, + u1: { type: 'mailboxHasChild' }, + }, + }, + 'x', + ], + ], + batch([only('m9', 'u2')]), + [['Email/set', { updated: { m9: null } }, 's']], + batch([]), + [['Mailbox/set', { destroyed: ['u2', 'u1'] }, 'x']], + ]); + + await expect( + provider.deleteMailbox(ARA, 'u1', children), + ).resolves.toEqual({ + deleted: true, + processed: 1, + movedToTrash: 1, + remaining: 0, + }); + expect(argsOf(recorded[3])).toMatchObject({ + filter: { + operator: 'OR', + conditions: [{ inMailbox: 'u1' }, { inMailbox: 'u2' }], + }, + }); + expect(argsOf(recorded[6])).toMatchObject({ destroy: ['u2', 'u1'] }); + }); + + it('그사이 나무 밖의 하위 메일함이 생기면(mailboxHasChild) 목록을 다시 읽어 그 메일함까지 지운다', async () => { + const grown = [ + ...tree, + userMailbox('u6', '새 하위', { parentId: 'u1' }), + ]; + const { provider, recorded } = fakeClient([ + mailboxList(tree), + batch([]), + [ + [ + 'Mailbox/set', + { + destroyed: ['u5', 'u2'], + notDestroyed: { u1: { type: 'mailboxHasChild' } }, + }, + 'x', + ], + ], + mailboxList( + grown.filter( + (mailbox) => mailbox.id !== 'u2' && mailbox.id !== 'u5', + ), + ), + batch([]), + [['Mailbox/set', { destroyed: ['u6', 'u1'] }, 'x']], + ]); + + await expect( + provider.deleteMailbox(ARA, 'u1', children), + ).resolves.toMatchObject({ deleted: true }); + expect(argsOf(recorded[5])).toMatchObject({ destroy: ['u6', 'u1'] }); + }); + + it('나무의 남은 수(remaining)는 OR 조회의 전체 수다 — 다 옮기지 못하면 deleted: false', async () => { + const fake = fakeClient([ + mailboxList(tree), + batch( + ids('m', 500).map((id) => only(id, 'u2')), + 's1', + 700, + ), + [ + [ + 'Email/set', + { + updated: Object.fromEntries( + ids('m', 500).map((id) => [id, null]), + ), + }, + 's', + ], + ], + ]); + advanceClockAfterCall(fake, 3, 21_000); + + await expect( + fake.provider.deleteMailbox(ARA, 'u1', children), + ).resolves.toEqual({ + deleted: false, + processed: 500, + movedToTrash: 500, + remaining: 200, + }); + }); + + it('한 요청의 시간은 서비스가 받은 때(startedAt)부터 센다 — 남은 시간이 모자라면 차례를 시작하지 않고 MAIL_001', async () => { + const { provider, recorded } = fakeClient([mailboxList(tree)]); + expect( + await codeOf( + provider.deleteMailbox(ARA, 'u3', { + startedAt: Date.now() - 21_000, + }), + ), + ).toBe('MAIL_001'); + expect(recorded).toHaveLength(1); + }); + }); }); describe('전체 읽음 (markMailboxRead)', () => { @@ -8684,6 +9086,757 @@ describe('JmapMailProvider', () => { }); }); + describe('예약 메일 지금 보내기 (releaseScheduledSend — 15단계)', () => { + const SCHEDULED = { $seen: true, '$ara-scheduled': true }; + /** 예약한 보낸 사본 — 받는 사람 둘레에 숨은참조(SEOJUN)가 있다(보낸 사본에는 Bcc 머리글이 남는다) */ + const sentCopy = (keywords: Record = SCHEDULED) => ({ + id: 'm1', + mailboxIds: { e: true }, + keywords, + to: [{ name: '이도윤', email: DOYUN }], + cc: [], + bcc: [{ name: null, email: SEOJUN }], + }); + const queued = { delivered: 'queued', smtpReply: '250 2.1.5 Queued' }; + /** 예약 제출 — 원래 envelope(HOLDUNTIL · 받는 사람 · 숨은참조)과 발신 ID */ + const scheduledSubmission = ( + overrides: Record = {}, + ) => ({ + id: 'sub1', + emailId: 'm1', + identityId: 'b', + undoStatus: 'pending', + sendAt: inFuture(3_600_000), + envelope: { + mailFrom: { + email: ARA, + parameters: { HOLDUNTIL: inFuture(3_600_000) }, + }, + rcptTo: [ + { email: DOYUN, parameters: null }, + { email: SEOJUN, parameters: null }, + ], + }, + deliveryStatus: { [DOYUN]: queued, [SEOJUN]: queued }, + ...overrides, + }); + /** 한 요청으로 읽는 것 — 메일함 · 그 메일 · 그 메일의 제출 */ + const releaseLookup = ( + submissions: Record[], + email: Record | null = sentCopy(), + ): JmapInvocation[] => [ + ['Mailbox/get', { list: stalwartMailboxes }, 'm'], + [ + 'Email/get', + { list: email ? [email] : [], notFound: email ? [] : ['m1'] }, + 'g', + ], + [ + 'EmailSubmission/query', + { + ids: submissions.map((submission) => submission.id), + total: submissions.length, + }, + 'rq', + ], + ['EmailSubmission/get', { list: submissions }, 'rg'], + ]; + const canceledOk: JmapInvocation[] = [ + ['EmailSubmission/set', { updated: { sub1: null } }, 'x'], + ]; + const created = (sendAt: string, undoStatus = 'pending') => + [ + [ + 'EmailSubmission/set', + { created: { send: { id: 'sub2', sendAt, undoStatus } } }, + 's', + ], + ['Email/set', { updated: { m1: null } }, 's'], + ] as JmapInvocation[]; + const result = ( + deliveryStatus: Record, + undoStatus = 'pending', + ): JmapInvocation[] => [ + [ + 'EmailSubmission/get', + { list: [{ id: 'sub2', undoStatus, deliveryStatus }] }, + 'd', + ], + ]; + const quiet = (provider: JmapMailProvider) => { + const logger = Reflect.get(provider, 'logger') as { + log: () => void; + warn: () => void; + }; + jest.spyOn(logger, 'log').mockImplementation(() => undefined); + return jest.spyOn(logger, 'warn').mockImplementation(() => undefined); + }; + + it('기다리는 예약 제출을 먼저 취소하고, 같은 메일을 원래 envelope 그대로(숨은참조 포함) 보내기 취소 초만큼 붙잡아 다시 낸다 — 예약 표시를 떼고, 받는 사람별 결과는 발송과 같다', async () => { + const undoAt = inFuture(10_000); + const { provider, recorded } = fakeClient([ + releaseLookup([scheduledSubmission()]), + canceledOk, + created(undoAt), + result({ [DOYUN]: queued, [SEOJUN]: queued }), + ]); + quiet(provider); + + const released = await provider.releaseScheduledSend(ARA, 'm1', { + kind: 'undo', + seconds: 10, + }); + + expect(released).toEqual({ + id: 'm1', + rejectedRecipients: [], + pendingSend: { kind: 'undo', sendAt: undoAt.replace('Z', '.000Z') }, + releasedAt: expect.any(String) as string, + }); + expect(recorded).toHaveLength(4); + // 1) 한 요청으로 읽는다 — 엔진이 id 를 해석하지 못하면 RES_001 + expect(recorded[0].options).toMatchObject({ + invalidRequestCode: 'RES_001', + }); + expect(recorded[0].calls.map(([method]) => method)).toEqual([ + 'Mailbox/get', + 'Email/get', + 'EmailSubmission/query', + 'EmailSubmission/get', + ]); + // 2) 취소가 먼저 — 다시 내기와 다른 요청 + expect(recorded[1].calls).toEqual([ + [ + 'EmailSubmission/set', + { accountId: 'd', update: { sub1: { undoStatus: 'canceled' } } }, + 'x', + ], + ]); + // 3) 같은 메일 · 같은 발신 ID · 원래 받는 사람(숨은참조 SEOJUN 포함) — MAIL FROM 의 붙잡기만 HOLDFOR 로 + expect(recorded[2].calls).toEqual([ + [ + 'EmailSubmission/set', + { + accountId: 'd', + create: { + send: { + identityId: 'b', + emailId: 'm1', + envelope: { + mailFrom: { email: ARA, parameters: { HOLDFOR: '10' } }, + rcptTo: [ + { email: DOYUN, parameters: null }, + { email: SEOJUN, parameters: null }, + ], + }, + }, + }, + onSuccessUpdateEmail: { + '#send': { + 'mailboxIds/d': null, + 'mailboxIds/e': true, + 'keywords/$draft': null, + 'keywords/$ara-sending': null, + 'keywords/$ara-scheduled': null, + }, + }, + }, + 's', + ], + ]); + // 4) 받는 사람별 결과는 따로 읽는다 (발송과 같다) + expect(argsOf(recorded[3])).toMatchObject({ ids: ['sub2'] }); + }); + + it('보내기 취소를 0초로 두었으면 붙잡지 않고 낸다 — pendingSend null, 일부 거절은 rejectedRecipients 로', async () => { + const { provider, recorded } = fakeClient([ + releaseLookup([scheduledSubmission()]), + canceledOk, + created(inFuture(0), 'final'), + result( + { + [DOYUN]: queued, + [SEOJUN]: { + delivered: 'no', + smtpReply: '550 5.1.2 Mailbox does not exist.', + }, + }, + 'final', + ), + ]); + quiet(provider); + + await expect( + provider.releaseScheduledSend(ARA, 'm1', null), + ).resolves.toMatchObject({ + pendingSend: null, + rejectedRecipients: [{ email: SEOJUN, reason: 'not-found' }], + }); + expect(argsOf(recorded[2])).toMatchObject({ + create: { + send: { + envelope: { mailFrom: { email: ARA, parameters: null } }, + }, + }, + }); + }); + + it('예약이 아닌 메일은 RES_001 — 보내기 취소를 기다리는 메일 · 취소해 임시보관함으로 간 메일 · 제출이 없는 메일 · 보낸 일반 메일 · 없는 메일 (아무것도 바꾸지 않는다)', async () => { + const cases: [ + Record[], + Record | null, + ][] = [ + // 보내기 취소를 기다린다 (예약 표시 없음) + [ + [scheduledSubmission({ sendAt: inFuture(8_000) })], + sentCopy({ $seen: true }), + ], + // 예약을 취소해 임시보관함으로 갔다 + [ + [scheduledSubmission({ undoStatus: 'canceled' })], + sentCopy({ $draft: true, '$ara-canceled': true }), + ], + // 제출이 없다 + [[], sentCopy()], + // 예약이 아니었던 보낸 메일 + [ + [ + scheduledSubmission({ + undoStatus: 'final', + sendAt: '2026-10-29T00:00:00Z', + }), + ], + sentCopy({ $seen: true }), + ], + // 없는 메일 + [[], null], + ]; + for (const [submissions, email] of cases) { + const { provider, recorded } = fakeClient([ + releaseLookup(submissions, email), + ]); + quiet(provider); + expect( + await codeOf( + provider.releaseScheduledSend(ARA, 'm1', { + kind: 'undo', + seconds: 10, + }), + ), + ).toBe('RES_001'); + expect(recorded).toHaveLength(1); + } + }); + + it('이미 보낸 메일(예약 시각이 막 지나 final · 배달 중)은 오류가 아니다 — pendingSend null · releasedAt null, 다시 내지 않는다', async () => { + for (const submission of [ + scheduledSubmission({ + undoStatus: 'final', + sendAt: '2026-10-29T00:00:00Z', + }), + // 보낼 시각이 지나 배달 중 (pending 이지만 기다리지 않는다) + scheduledSubmission({ sendAt: '2026-10-29T00:00:00Z' }), + ]) { + const { provider, recorded } = fakeClient([ + releaseLookup([submission]), + ]); + quiet(provider); + await expect( + provider.releaseScheduledSend(ARA, 'm1', { + kind: 'undo', + seconds: 10, + }), + ).resolves.toEqual({ + id: 'm1', + rejectedRecipients: [], + pendingSend: null, + releasedAt: null, + }); + expect(recorded).toHaveLength(1); + } + }); + + it('취소하려니 엔진이 받지 않으면(cannotUnsend — 그사이 보냄) 다시 읽어 보낸 것으로 답한다 — 다시 내지 않는다', async () => { + const { provider, recorded } = fakeClient([ + releaseLookup([scheduledSubmission()]), + [ + [ + 'EmailSubmission/set', + { notUpdated: { sub1: { type: 'cannotUnsend' } } }, + 'x', + ], + ], + releaseLookup([ + scheduledSubmission({ + undoStatus: 'final', + sendAt: '2026-10-29T00:00:00Z', + }), + ]), + ]); + quiet(provider); + + await expect( + provider.releaseScheduledSend(ARA, 'm1', null), + ).resolves.toMatchObject({ pendingSend: null, releasedAt: null }); + expect(recorded).toHaveLength(3); + }); + + it('이미 지금 보내기를 한 메일(취소한 예약 제출 + 그 뒤 새 제출 — 다시 누름 · 응답을 받지 못한 재시도)은 그 새 제출의 지금 상태로 — 다시 내지 않는다', async () => { + const undoAt = inFuture(6_000); + const { provider, recorded } = fakeClient([ + releaseLookup( + [ + scheduledSubmission({ undoStatus: 'canceled' }), + scheduledSubmission({ + id: 'sub2', + undoStatus: 'pending', + sendAt: undoAt, + }), + ], + sentCopy({ $seen: true }), + ), + ]); + quiet(provider); + + await expect( + provider.releaseScheduledSend(ARA, 'm1', { + kind: 'undo', + seconds: 10, + }), + ).resolves.toEqual({ + id: 'm1', + rejectedRecipients: [], + pendingSend: { kind: 'undo', sendAt: undoAt.replace('Z', '.000Z') }, + releasedAt: undoAt.replace('Z', '.000Z'), + }); + expect(recorded).toHaveLength(1); + }); + + it('앞 요청이 예약을 취소한 뒤 끊겼으면(예약 표시 · 제출은 모두 canceled) 취소를 건너뛰고 다시 낸다', async () => { + const { provider, recorded } = fakeClient([ + releaseLookup([scheduledSubmission({ undoStatus: 'canceled' })]), + created(inFuture(10_000)), + result({ [DOYUN]: queued, [SEOJUN]: queued }), + ]); + quiet(provider); + + await expect( + provider.releaseScheduledSend(ARA, 'm1', { + kind: 'undo', + seconds: 10, + }), + ).resolves.toMatchObject({ pendingSend: { kind: 'undo' } }); + expect(recorded.map(({ calls }) => calls[0][0])).toEqual([ + 'Mailbox/get', + 'EmailSubmission/set', + 'EmailSubmission/get', + ]); + expect(argsOf(recorded[1])).toHaveProperty('create'); + }); + + it('envelope · 발신 ID 를 읽지 못하면 취소하지 않고 MAIL_001 — 취소한 뒤 다시 내지 못하는 메일이 생기지 않게', async () => { + for (const broken of [ + { envelope: null }, + { identityId: undefined }, + { + envelope: { + mailFrom: { email: ARA }, + rcptTo: [{ email: DOYUN }, { email: null }], + }, + }, + { envelope: { mailFrom: { email: ARA }, rcptTo: [] } }, + ]) { + const { provider, recorded } = fakeClient([ + releaseLookup([scheduledSubmission(broken)]), + ]); + quiet(provider); + expect( + await codeOf(provider.releaseScheduledSend(ARA, 'm1', null)), + ).toBe('MAIL_001'); + expect(recorded).toHaveLength(1); + } + }); + + it('엔진이 취소를 확인하지 않으면 다시 읽어 — 아직 기다리면 다시 내지 않고 MAIL_001, 취소됐으면 잇는다', async () => { + const silent: JmapInvocation[] = [ + ['EmailSubmission/set', { updated: {} }, 'x'], + ]; + const still = fakeClient([ + releaseLookup([scheduledSubmission()]), + silent, + releaseLookup([scheduledSubmission()]), + ]); + quiet(still.provider); + expect( + await codeOf(still.provider.releaseScheduledSend(ARA, 'm1', null)), + ).toBe('MAIL_001'); + expect(still.recorded).toHaveLength(3); + + const gone = fakeClient([ + releaseLookup([scheduledSubmission()]), + new BusinessException('MAIL_001', { + context: { reason: 'timeout' }, + }), + releaseLookup([scheduledSubmission({ undoStatus: 'canceled' })]), + created(inFuture(0), 'final'), + result({ [DOYUN]: queued, [SEOJUN]: queued }, 'final'), + ]); + quiet(gone.provider); + await expect( + gone.provider.releaseScheduledSend(ARA, 'm1', null), + ).resolves.toMatchObject({ pendingSend: null }); + expect(gone.recorded).toHaveLength(5); + }); + + it('엔진이 다시 내기를 분명히 거절하면(SetError · 메서드 오류 · 4xx) 다시 읽지 않고 한 번 더, 그래도 거절이면 임시보관함으로 되돌린 뒤 MAIL_001 (ScheduledSendReturnedToDraftsException)', async () => { + const setError: JmapInvocation[] = [ + [ + 'EmailSubmission/set', + { notCreated: { send: { type: 'serverFail' } } }, + 's', + ], + ]; + const methodError: JmapInvocation[] = [ + ['error', { type: 'serverFail' }, 's'], + ]; + // 엔진이 요청을 실행하지 않았다(4xx — isRequestRefused) + const rejected = new BusinessException('MAIL_001', { + context: { reason: 'http-error', status: 400 }, + }); + for (const [first, second] of [ + [setError, methodError], + [rejected, setError], + ] as const) { + const { provider, recorded } = fakeClient([ + releaseLookup([scheduledSubmission()]), + canceledOk, + first, + second, + [['Email/set', { updated: { m1: null } }, 'u']], + ]); + const warn = quiet(provider); + + const failure = await provider + .releaseScheduledSend(ARA, 'm1', null) + .catch((caught: unknown) => caught); + expect(failure).toBeInstanceOf( + ScheduledSendReturnedToDraftsException, + ); + expect(failure).toMatchObject({ + code: 'MAIL_001', + context: { reason: 'release-resubmit-refused' }, + }); + // 읽기 · 취소 · 내기 두 번 · 되돌리기 — 거절 사이에 다시 읽지 않는다 + expect(recorded.map(({ calls }) => calls[0][0])).toEqual([ + 'Mailbox/get', + 'EmailSubmission/set', + 'EmailSubmission/set', + 'EmailSubmission/set', + 'Email/set', + ]); + expect(argsOf(recorded[4])).toEqual({ + accountId: 'd', + update: { + m1: { + mailboxIds: { d: true }, + 'keywords/$draft': true, + 'keywords/$ara-canceled': true, + 'keywords/$ara-scheduled': null, + 'keywords/$ara-sending': null, + }, + }, + }); + expect(warn).toHaveBeenCalled(); + } + }); + + it('거듭 거절해 임시보관함으로 되돌리려는데 그것마저 못 하면 그냥 MAIL_001 — 되돌림 예외가 아니다(메일은 보낸편지함에 그대로)', async () => { + const setError: JmapInvocation[] = [ + [ + 'EmailSubmission/set', + { notCreated: { send: { type: 'serverFail' } } }, + 's', + ], + ]; + const notMoved: JmapInvocation[] = [ + ['Email/set', { notUpdated: { m1: { type: 'serverFail' } } }, 'u'], + ]; + const { provider, recorded } = fakeClient([ + releaseLookup([scheduledSubmission()]), + canceledOk, + setError, + setError, + notMoved, + notMoved, + ]); + quiet(provider); + + const failure = await provider + .releaseScheduledSend(ARA, 'm1', null) + .catch((caught: unknown) => caught); + expect(failure).toBeInstanceOf(BusinessException); + expect(failure).not.toBeInstanceOf( + ScheduledSendReturnedToDraftsException, + ); + expect(failure).toMatchObject({ + code: 'MAIL_001', + context: { reason: 'canceled-send-not-returned-to-drafts' }, + }); + expect(recorded).toHaveLength(6); + }); + + it('다시 내기가 결과를 모르게 끊겼는데 다시 읽으니 새 제출이 있으면 그 상태로 답한다 — 임시보관함으로 옮기지 않는다', async () => { + const undoAt = inFuture(9_000); + const { provider, recorded } = fakeClient([ + releaseLookup([scheduledSubmission()]), + canceledOk, + new BusinessException('MAIL_001', { + context: { reason: 'timeout' }, + }), + releaseLookup( + [ + scheduledSubmission({ undoStatus: 'canceled' }), + scheduledSubmission({ id: 'sub2', sendAt: undoAt }), + ], + sentCopy({ $seen: true }), + ), + ]); + quiet(provider); + + await expect( + provider.releaseScheduledSend(ARA, 'm1', { + kind: 'undo', + seconds: 10, + }), + ).resolves.toMatchObject({ + pendingSend: { kind: 'undo', sendAt: undoAt.replace('Z', '.000Z') }, + }); + expect(recorded).toHaveLength(4); + }); + + it('다시 내기도 · 다시 읽기도 실패하면 보냈는지 모른다 — 임시보관함으로 옮기지 않고 MAIL_001 (다시 부르면 끊긴 지금 보내기로 잇는다)', async () => { + const { provider, recorded } = fakeClient([ + releaseLookup([scheduledSubmission()]), + canceledOk, + new BusinessException('MAIL_001', { + context: { reason: 'timeout' }, + }), + new BusinessException('MAIL_001', { + context: { reason: 'timeout' }, + }), + ]); + quiet(provider); + + const failure = await provider + .releaseScheduledSend(ARA, 'm1', null) + .catch((caught: unknown) => caught); + expect(failure).toBeInstanceOf(BusinessException); + expect(failure).not.toBeInstanceOf( + ScheduledSendReturnedToDraftsException, + ); + expect((failure as BusinessException).code).toBe('MAIL_001'); + expect(recorded).toHaveLength(4); + }); + + it('다시 내기의 결과를 모르고(시간 초과 · 5xx · 해석하지 못한 응답) 다시 읽어도 새 제출이 없으면 — 둘째 제출 없음 · 임시보관함으로 옮기지 않음 · 그냥 MAIL_001', async () => { + const unknowns: (JmapInvocation[] | Error)[] = [ + new BusinessException('MAIL_001', { context: { reason: 'timeout' } }), + new BusinessException('MAIL_001', { + context: { reason: 'http-error', status: 502 }, + }), + // 응답에 제출 결과가 없다 — 엔진이 실행했는지 모른다 + [['Email/set', { updated: {} }, 'x']], + ]; + for (const unknown of unknowns) { + const { provider, recorded } = fakeClient([ + releaseLookup([scheduledSubmission()]), + canceledOk, + unknown, + // 다시 읽기 — 예약 표시 그대로 · 예약 제출은 취소됨 · 새 제출 없음 (끊긴 지금 보내기) + releaseLookup([scheduledSubmission({ undoStatus: 'canceled' })]), + ]); + const warn = quiet(provider); + + const failure = await provider + .releaseScheduledSend(ARA, 'm1', { kind: 'undo', seconds: 10 }) + .catch((caught: unknown) => caught); + expect(failure).toBeInstanceOf(BusinessException); + expect(failure).not.toBeInstanceOf( + ScheduledSendReturnedToDraftsException, + ); + expect(failure).toMatchObject({ + code: 'MAIL_001', + context: { + reason: 'release-resubmit-unknown', + reread: 'interrupted', + }, + }); + // 읽기 · 취소 · 내기 한 번 · 다시 읽기 — 둘째 제출도 임시보관함으로 옮기기(Email/set)도 없다 + expect(recorded.map(({ calls }) => calls[0][0])).toEqual([ + 'Mailbox/get', + 'EmailSubmission/set', + 'EmailSubmission/set', + 'Mailbox/get', + ]); + expect(argsOf(recorded[2])).toHaveProperty('create'); + expect(warn).toHaveBeenCalledTimes(1); + } + }); + + it('결과를 모르게 끝난 뒤 같은 요청을 다시 받으면 이어 낸다 — 예약 취소를 건너뛰고 다시 제출만 (끊긴 지금 보내기)', async () => { + const undoAt = inFuture(10_000); + const { provider, recorded } = fakeClient([ + // 1) 첫 요청 — 취소한 뒤 다시 내기가 시간 초과 · 다시 읽어도 새 제출 없음 + releaseLookup([scheduledSubmission()]), + canceledOk, + new BusinessException('MAIL_001', { context: { reason: 'timeout' } }), + releaseLookup([scheduledSubmission({ undoStatus: 'canceled' })]), + // 2) 같은 요청을 다시 — 끊긴 지금 보내기로 이어 낸다 + releaseLookup([scheduledSubmission({ undoStatus: 'canceled' })]), + created(undoAt), + result({ [DOYUN]: queued, [SEOJUN]: queued }), + ]); + quiet(provider); + + expect( + await codeOf( + provider.releaseScheduledSend(ARA, 'm1', { + kind: 'undo', + seconds: 10, + }), + ), + ).toBe('MAIL_001'); + await expect( + provider.releaseScheduledSend(ARA, 'm1', { + kind: 'undo', + seconds: 10, + }), + ).resolves.toEqual({ + id: 'm1', + rejectedRecipients: [], + pendingSend: { kind: 'undo', sendAt: undoAt.replace('Z', '.000Z') }, + releasedAt: expect.any(String) as string, + }); + // 둘째 요청은 취소하지 않고(이미 취소됨) 원래 envelope 그대로 다시 낸다 + expect(recorded.slice(4).map(({ calls }) => calls[0][0])).toEqual([ + 'Mailbox/get', + 'EmailSubmission/set', + 'EmailSubmission/get', + ]); + expect(argsOf(recorded[5])).toMatchObject({ + create: { + send: { + emailId: 'm1', + envelope: { + mailFrom: { email: ARA, parameters: { HOLDFOR: '10' } }, + rcptTo: [ + { email: DOYUN, parameters: null }, + { email: SEOJUN, parameters: null }, + ], + }, + }, + }, + }); + }); + + it('제출마다 남은 시간을 먼저 본다 — 10초보다 적으면 내지 않고 그냥 MAIL_001 (예약은 취소된 채 — 다시 부르면 이어 낸다)', async () => { + const realNow = Date.now(); + // 시계를 손으로 — 엔진 호출이 n 번 기록된 뒤부터 40초 지난 것으로 (한 요청의 시간 45초 가운데 5초 남음) + const clockAfter = (calls: number, recorded: unknown[]) => + jest + .spyOn(Date, 'now') + .mockImplementation(() => + recorded.length >= calls ? realNow + 40_000 : realNow, + ); + + // 첫 제출 전 — 취소한 뒤 남은 시간이 모자라다 + const beforeFirst = fakeClient([ + releaseLookup([scheduledSubmission()]), + canceledOk, + ]); + quiet(beforeFirst.provider); + const firstClock = clockAfter(2, beforeFirst.recorded); + try { + const failure = await beforeFirst.provider + .releaseScheduledSend(ARA, 'm1', null) + .catch((caught: unknown) => caught); + expect(failure).not.toBeInstanceOf( + ScheduledSendReturnedToDraftsException, + ); + expect(failure).toMatchObject({ + code: 'MAIL_001', + context: { reason: 'release-budget-exhausted', attempt: 1 }, + }); + expect(beforeFirst.recorded).toHaveLength(2); + } finally { + firstClock.mockRestore(); + } + + // 둘째 제출 전 — 첫 제출을 엔진이 거절하는 사이 시간이 갔다: 둘째는 내지 않고 되돌리지도 않는다 + const beforeSecond = fakeClient([ + releaseLookup([scheduledSubmission()]), + canceledOk, + [ + [ + 'EmailSubmission/set', + { notCreated: { send: { type: 'serverFail' } } }, + 's', + ], + ], + ]); + quiet(beforeSecond.provider); + const secondClock = clockAfter(3, beforeSecond.recorded); + try { + const failure = await beforeSecond.provider + .releaseScheduledSend(ARA, 'm1', null) + .catch((caught: unknown) => caught); + expect(failure).not.toBeInstanceOf( + ScheduledSendReturnedToDraftsException, + ); + expect(failure).toMatchObject({ + code: 'MAIL_001', + context: { reason: 'release-budget-exhausted', attempt: 2 }, + }); + expect(beforeSecond.recorded).toHaveLength(3); + // 첫 제출은 남은 시간(최대 40초)으로 — 10초 아래로 보낸 적이 없다 + expect(beforeSecond.recorded[2].options).toEqual({ + timeoutMs: JMAP_SEND_TIMEOUT_MS, + }); + } finally { + secondClock.mockRestore(); + } + }); + + it('받는 사람이 모두 받지 않았으면 기다리는 새 제출을 취소하고 임시보관함으로 되돌린다 — MAIL_010 (ScheduledSendReturnedToDraftsException)', async () => { + const rejected = { + delivered: 'no', + smtpReply: '550 5.1.2 Mailbox does not exist.', + }; + const { provider, recorded } = fakeClient([ + releaseLookup([scheduledSubmission()]), + canceledOk, + created(inFuture(10_000)), + result({ [DOYUN]: rejected, [SEOJUN]: rejected }), + [['EmailSubmission/set', { updated: { sub2: null } }, 'c']], + [['Email/set', { updated: { m1: null } }, 'u']], + ]); + quiet(provider); + + const failure = await provider + .releaseScheduledSend(ARA, 'm1', { kind: 'undo', seconds: 10 }) + .catch((caught: unknown) => caught); + expect(failure).toBeInstanceOf(ScheduledSendReturnedToDraftsException); + expect((failure as BusinessException).code).toBe('MAIL_010'); + expect(argsOf(recorded[4])).toEqual({ + accountId: 'd', + update: { sub2: { undoStatus: 'canceled' } }, + }); + expect(argsOf(recorded[5])).toMatchObject({ + update: { m1: { mailboxIds: { d: true } } }, + }); + }); + }); + describe('취소해 되돌린 발송을 이어 쓴다 (getDraft)', () => { const SIGNATURE_CID = 'sig-0b9a4f9e-7c2d-4d3a-9f4e-1a2b3c4d5e6f@arasystem.test'; diff --git a/backend/src/modules/mail/jmap/jmap-mail.provider.ts b/backend/src/modules/mail/jmap/jmap-mail.provider.ts index cc10059..ad634c9 100644 --- a/backend/src/modules/mail/jmap/jmap-mail.provider.ts +++ b/backend/src/modules/mail/jmap/jmap-mail.provider.ts @@ -12,6 +12,7 @@ import { MAX_ENVELOPE_RESUBMITS, } from '@/modules/mail/mail.constants'; import { + ScheduledSendReturnedToDraftsException, SendNotStartedException, type MailProvider, } from '@/modules/mail/mail.provider'; @@ -34,9 +35,12 @@ import type { MailAttachment, MailBlockAdded, MailBlockList, + MailBlockRunRequest, Mailbox, MailboxBulkResult, + MailboxChange, MailboxCreate, + MailboxDeleteOptions, MailboxDeletion, MailFilterRunContext, MailFilterRunOutcome, @@ -64,6 +68,7 @@ import type { PasswordChangeOutcome, RecipientFields, RejectedRecipient, + ReleasedScheduledSend, SavedDraft, SendHold, SentMessage, @@ -72,6 +77,11 @@ import type { TwoFactorLoginResult, UploadedFile, } from '@/modules/mail/mail.types'; +import { + deepestFirstIds, + descendantsOf, + isSelfOrDescendant, +} from '@/modules/mail/mailbox-tree'; import { traceTag } from '@/shared/logger/request-context'; import { maskEmail } from '@/shared/utils/mask'; import { @@ -114,6 +124,7 @@ import { firstSetError, isState, warnRefusals, + type MethodResult, } from './jmap-results'; import { describeFailure, @@ -593,9 +604,18 @@ function assertUserMailbox(mailbox: JmapMailbox, action: string): void { } } +/** 엔진이 상위(parentId)를 받지 않은 거절인가 — 없는 상위 · 고리(자기 아래로 옮기기 — 2026-11-09 확인) */ +function isParentRefusal(error: JmapError): boolean { + return ( + error.type === 'invalidProperties' && + (error.properties ?? []).includes('parentId') + ); +} + /** - * 메일함 만들기 · 이름 바꾸기를 엔진이 거절한 것 — 같은 상위 안에 같은 이름(alreadyExists, 확인함)은 MAIL_009, - * 상위가 그사이 없어졌으면(parentId 속성 거절) RES_001, 그 밖은 오류 표대로 (jmap.errors.ts) + * 메일함 만들기 · 이름 바꾸기 · 옮기기를 엔진이 거절한 것 — 같은 상위 안에 같은 이름(alreadyExists, 확인함 — 대소문자 무시)은 MAIL_009, + * 상위가 그사이 없어졌으면(parentId 속성 거절) RES_001, 그 밖은 오류 표대로 (jmap.errors.ts). + * 옮기기의 고리(상위는 있는데 parentId 거절)는 부르는 쪽(updateMailbox)이 먼저 VAL_001 로 가른다 */ function mailboxSetFailure( error: JmapError, @@ -606,10 +626,7 @@ function mailboxSetFailure( context: { reason: 'mailbox-name-taken', method }, }); } - if ( - error.type === 'invalidProperties' && - (error.properties ?? []).includes('parentId') - ) { + if (isParentRefusal(error)) { return new BusinessException('RES_001', { context: { reason: 'mailbox-parent-gone', method }, }); @@ -617,6 +634,80 @@ function mailboxSetFailure( return jmapFailure(error, { method }); } +/** + * 메일함 가운데 하나에라도 든 메일 (Email/query 의 조건) — 하나면 지금처럼 `inMailbox` 하나, 여럿이면(하위까지 지우기의 나무 — 15단계) OR. + * 엔진은 OR 조건의 전체 수를 겹치지 않게 센다(2026-11-09 확인) + */ +function inAnyMailbox(mailboxIds: readonly string[]): JmapEmailFilter { + return mailboxIds.length === 1 + ? { inMailbox: mailboxIds[0] } + : { + operator: 'OR', + conditions: mailboxIds.map((inMailbox) => ({ inMailbox })), + }; +} + +/** 지울 나무 (15단계) — 그 메일함과(하위까지 지우기면) 하위 모두 */ +interface DeletionTree { + /** 나무의 메일함 — 그 메일함이 맨 앞 */ + readonly ids: readonly string[]; + /** 지울 차례 — 깊은 것부터, 그 메일함은 맨 끝 (mailbox-tree.ts) */ + readonly order: readonly string[]; + /** 처음 남은 수의 어림 — 나무의 메일함마다 든 수의 합 (차례를 하나도 읽지 못하고 끝날 때만 쓴다) */ + readonly initialTotal: number; +} + +/** + * 지울 나무를 정한다 — 하위 메일함이 있는데 하위까지 지우기가 아니면 VAL_001(옛 화면이 모르고 하위를 지우지 않게), + * 하위에 역할이 있는 메일함이 있으면(다른 프로그램이 만든 나무) VAL_001 + */ +function deletionTree( + mailboxes: readonly JmapMailbox[], + root: JmapMailbox, + withChildren: boolean, +): DeletionTree { + const descendants = descendantsOf(mailboxes, root.id).map( + ({ mailbox }) => mailbox, + ); + if (descendants.length > 0 && !withChildren) { + throw new BusinessException('VAL_001', { + context: { reason: 'mailbox-has-children' }, + }); + } + const roleMailbox = descendants.find( + (mailbox) => engineRole(mailbox) !== null, + ); + if (roleMailbox) { + throw new BusinessException('VAL_001', { + context: { + reason: 'role-mailbox-in-tree', + role: engineRole(roleMailbox), + }, + }); + } + return { + ids: [root.id, ...descendants.map((mailbox) => mailbox.id)], + order: deepestFirstIds(mailboxes, root.id), + initialTotal: [root, ...descendants].reduce( + (sum, mailbox) => sum + (mailbox.totalEmails ?? 0), + 0, + ), + }; +} + +/** 지운 메일함을 나무에서 뺀다 — 나머지를 지우는 사이 메일이 들어와 다음 차례에 다시 읽을 때 */ +function withoutDestroyed( + tree: DeletionTree, + destroyed: ReadonlySet, +): DeletionTree { + if (destroyed.size === 0) return tree; + return { + ids: tree.ids.filter((id) => !destroyed.has(id)), + order: tree.order.filter((id) => !destroyed.has(id)), + initialTotal: tree.initialTotal, + }; +} + /** 조회의 전체 수 — 엔진이 세어 주지 않으면 받은 수로 (가득 찼으면 더 있는 것으로 하나 더) */ function queryTotal(result: JmapQueryResult, received: number): number { if (typeof result.total === 'number' && result.total >= received) { @@ -835,13 +926,15 @@ interface CreatedSubmission { /** * 제출 하나(EmailSubmission/set create, 호출 id 's') — 늘 envelope 을 직접 준다. 성공하면 초안 → 보낸편지함으로 옮기고 발송 중 표시를 뗀다 - * @param emailId 같은 요청에서 만든 초안(`#draft`)이나 이미 만든 메일 id (envelope 을 고쳐 다시 낼 때) + * @param emailId 같은 요청에서 만든 초안(`#draft`)이나 이미 만든 메일 id (envelope 을 고쳐 다시 낼 때 · 예약 메일 지금 보내기 — 15단계) + * @param extraPatch 성공하면 메일에 함께 할 것 (지금 보내기는 예약 표시를 뗀다) */ function submitCall( accountId: string, plan: SubmissionPlan, emailId: string, rcptTo: readonly string[], + extraPatch: Readonly> = {}, ): JmapInvocation { return [ 'EmailSubmission/set', @@ -860,6 +953,7 @@ function submitCall( [`mailboxIds/${plan.sentId}`]: true, 'keywords/$draft': null, [`keywords/${INTERNAL_SEND_KEYWORD}`]: null, + ...extraPatch, }, }, }, @@ -867,6 +961,147 @@ function submitCall( ]; } +/** + * 예약 메일 지금 보내기(15단계) 한 요청이 엔진을 기다리는 시간 — 엔진 호출마다 남은 시간만큼만 기다린다. + * 정리(새 제출 취소 · 임시보관함으로 되돌리기 두 번 · 결과를 모를 때 다시 읽기)는 남은 시간이 모자라도 RELEASE_CLEANUP_MIN_MS 씩 더 쓴다 — + * 그래서 한 요청은 54초 남짓까지 걸린다(45 + 3 × 3). 앞단 nginx 의 기본 시간 제한(60초) · 클라이언트의 발송 한도(60초) 안이다 + */ +const RELEASE_BUDGET_MS = 45_000; +/** 정리(임시보관함으로 되돌리기 · 새 제출 취소 · 결과를 모를 때 다시 읽기)에 남은 시간이 모자라도 쓰는 최소 시간 */ +const RELEASE_CLEANUP_MIN_MS = 3_000; +/** 다시 제출하는 횟수 — 엔진이 분명히 거절했을 때만 한 번 더 (결과를 모르면 다시 내지 않는다 — 계약 2절 "보내기 취소 · 예약 발송") */ +const RELEASE_SUBMIT_ATTEMPTS = 2; +/** 지금 보내기가 읽는 그 메일의 제출 속성 — 상태 · 보낼 시각 · 다시 제출할 envelope · 발신 ID · 받는 사람별 결과 */ +const RELEASE_SUBMISSION_PROPERTIES = [ + 'id', + 'emailId', + 'identityId', + 'undoStatus', + 'sendAt', + 'envelope', + 'deliveryStatus', +]; +/** 지금 보내기가 읽는 그 메일의 속성 — 메일함 · 표시(예약 · 취소한 발송) · 받는 사람(받는 사람별 결과를 맞춘다) */ +const RELEASE_EMAIL_PROPERTIES = [ + 'id', + 'mailboxIds', + 'keywords', + 'to', + 'cc', + 'bcc', +]; + +/** 지금 보내기가 본 그 메일의 지금 상태 (releaseStateOf) */ +type ReleaseState = + /** 예약 표시가 있고 기다리는 제출이 있다 — 취소하고 다시 제출한다 */ + | { + readonly kind: 'scheduled'; + readonly held: readonly JmapEmailSubmission[]; + } + /** 예약 표시가 있고 취소한 발송 표시는 없는데 제출이 모두 취소됐다 — 앞 요청이 취소한 뒤 끊겼다: 다시 제출만 한다 */ + | { readonly kind: 'interrupted'; readonly source: JmapEmailSubmission } + /** 취소한 제출과 그 뒤 제출이 함께 있다 — 지금 보내기를 이미 했다(다시 누름 · 응답을 받지 못한 재시도) */ + | { readonly kind: 'released'; readonly current: JmapEmailSubmission } + /** 예약 표시가 있고 끝나지 않은 제출이 있는데 기다리지 않는다 — 예약 시각이 지나 보냈다(배달 중 포함) */ + | { readonly kind: 'sent'; readonly live: readonly JmapEmailSubmission[] } + /** 예약이 아니다 — 보내기 취소를 기다리는 메일 · 취소해 임시보관함으로 간 메일 · 제출이 없음 */ + | { readonly kind: 'not-scheduled'; readonly why: string }; + +/** 보낼 시각이 가장 늦은 제출 (같으면 뒤의 것) */ +function latestSubmission( + submissions: readonly JmapEmailSubmission[], +): JmapEmailSubmission { + return submissions.reduce((latest, submission) => + (Date.parse(submission.sendAt ?? '') || 0) >= + (Date.parse(latest.sendAt ?? '') || 0) + ? submission + : latest, + ); +} + +/** + * 지금 보내기가 본 그 메일의 상태 — 순수 함수 (계약 2절 "보내기 취소 · 예약 발송" — 15단계). 차례가 중요하다: + * 취소한 발송 표시(임시보관함으로 되돌림) → 기다리는 예약 → 이미 지금 보냄 → 앞 요청이 끊김 → 이미 보냄 → 예약이 아님. + * 기다리는 제출은 pending 이고 보낼 시각이 아직인 것(isHeld)이다 — 보낼 시각이 지나 배달 중인 제출은 보낸 것으로 본다 + */ +export function releaseStateOf( + email: Pick, + submissions: readonly JmapEmailSubmission[], + nowMs: number, +): ReleaseState { + if (email.keywords?.[CANCELED_SEND_KEYWORD] === true) { + return { kind: 'not-scheduled', why: 'returned-to-drafts' }; + } + const scheduled = email.keywords?.[SCHEDULED_SEND_KEYWORD] === true; + const canceled = submissions.filter( + (submission) => submission.undoStatus === UNDO_CANCELED, + ); + const live = submissions.filter( + (submission) => submission.undoStatus !== UNDO_CANCELED, + ); + const held = live.filter((submission) => isHeld(submission, nowMs)); + if (scheduled && held.length > 0) return { kind: 'scheduled', held }; + if (canceled.length > 0 && live.length > 0) { + return { + kind: 'released', + current: + held.length > 0 ? latestSubmission(held) : latestSubmission(live), + }; + } + if (scheduled && canceled.length > 0) { + return { kind: 'interrupted', source: latestSubmission(canceled) }; + } + if (scheduled && live.length > 0) return { kind: 'sent', live }; + return { + kind: 'not-scheduled', + why: held.length > 0 ? 'undo-pending' : 'no-scheduled-submission', + }; +} + +/** + * 다시 제출할 envelope — 그 예약 제출의 보내는 사람 · 받는 사람 그대로(받는 사람을 머리글에서 다시 만들지 않는다 — 숨은참조 · envelope 에서 뺀 주소가 있다), + * MAIL FROM 의 붙잡기 매개변수만 바꾼다(hold — 보내기 취소 설정, 없으면 바로). 하나라도 읽지 못하면 null (다시 제출하지 않는다 — 닫힌 쪽) + */ +export function releaseEnvelopeOf(source: JmapEmailSubmission): { + readonly identityId: string; + readonly from: string; + readonly rcptTo: string[]; +} | null { + const identityId = source.identityId; + const from = source.envelope?.mailFrom?.email; + const recipients = source.envelope?.rcptTo; + if (!identityId || typeof from !== 'string' || from.trim() === '') { + return null; + } + if (!Array.isArray(recipients) || recipients.length === 0) return null; + const rcptTo: string[] = []; + for (const recipient of recipients) { + const address = recipient?.email; + if (typeof address !== 'string' || address.trim() === '') return null; + rcptTo.push(address); + } + return { identityId, from, rcptTo }; +} + +/** 지금 보내기가 한 요청으로 읽은 것 (readReleaseSnapshot) */ +interface ReleaseSnapshot { + readonly email: JmapEmail; + readonly submissions: JmapEmailSubmission[]; + readonly draftsId: string; + readonly sentId: string; +} + +/** 보낸 사본의 받는 사람 · 참조 · 숨은참조 — 받는 사람별 결과를 맞춘다(보낸 사본에는 숨은참조 머리글이 남는다 — envelope.ts 머리말) */ +function recipientsOf( + email: Pick, +): RecipientFields { + return { + to: toAddresses(email.to), + cc: toAddresses(email.cc), + bcc: toAddresses(email.bcc), + }; +} + /** 답장 스레드 헤더 — 원 메일의 Message-ID 를 In-Reply-To 로, References 끝에 이어 붙인다 */ export function buildThreadHeaders( original: Pick | undefined, @@ -1029,67 +1264,123 @@ export class JmapMailProvider implements MailProvider { } /** - * 메일함 이름 바꾸기 — 역할이 있는 메일함(기본 메일함)은 VAL_001, 없으면 RES_001, 같은 이름이 있으면 MAIL_009. - * 지금 이름과 같으면 엔진에 묻지 않고 그대로 돌려준다 (자기 이름과 겹친다고 거절되지 않게) + * 메일함 이름 바꾸기 · 옮기기 (15단계 — 계약 2절 "메일함 관리") — 보낸 칸(이름 · 상위)만 바꾼다. 엔진은 상위를 바꾸면 하위와 안의 메일을 함께 옮긴다. + * - 역할이 있는 메일함(기본 메일함)은 VAL_001, 없으면 RES_001 + * - 새 상위: 없으면 RES_001, 만들기와 같은 자리(받은편지함 · 보관함 · 역할 없는 메일함)가 아니면 VAL_001, 자기 자신 · 자기 하위면 VAL_001 — 엔진에 묻기 전에 본다 + * (엔진도 고리를 invalidProperties(parentId)로 막는다 — 2026-11-09 확인. 그때 같은 요청에서 다시 읽은 목록에 새 상위가 있으면 VAL_001, 그사이 없어졌으면 RES_001) + * - 새 자리에 같은 이름이 있으면 엔진이 alreadyExists 로 거절한다(대소문자 무시 — 2026-11-09 확인) → MAIL_009 + * - 이름 · 상위가 지금과 같으면 엔진에 묻지 않고 그대로 돌려준다 (자기 이름과 겹친다고 거절되지 않게). 깊이 한도는 두지 않는다(만들기와 같다) */ - async renameMailbox( + async updateMailbox( user: string, id: string, - name: string, + change: MailboxChange, ): Promise { - const mailbox = findMailbox(await this.loadMailboxes(user), id); - assertUserMailbox(mailbox, 'rename'); - if (mailbox.name === name) return toMailbox(mailbox); + const mailboxes = await this.loadMailboxes(user); + const mailbox = findMailbox(mailboxes, id); + assertUserMailbox(mailbox, 'update'); + + const { name, parentId } = change; + if (parentId !== undefined && parentId !== null) { + const role = engineRole(findMailbox(mailboxes, parentId)); + if (role !== null && !PARENT_ALLOWED_ROLES.has(role)) { + throw new BusinessException('VAL_001', { + context: { reason: 'mailbox-parent-not-allowed', parentRole: role }, + }); + } + if (isSelfOrDescendant(mailboxes, mailbox.id, parentId)) { + throw new BusinessException('VAL_001', { + context: { reason: 'mailbox-move-into-itself' }, + }); + } + } + + const update: Record = {}; + if (name !== undefined && name !== mailbox.name) update.name = name; + if (parentId !== undefined && parentId !== (mailbox.parentId ?? null)) { + update.parentId = parentId; + } + if (Object.keys(update).length === 0) return toMailbox(mailbox); + const moving = 'parentId' in update; const responses = await this.jmap.call(user, (accountId) => [ - ['Mailbox/set', { accountId, update: { [mailbox.id]: { name } } }, 'u'], + ['Mailbox/set', { accountId, update: { [mailbox.id]: update } }, 'u'], [ 'Mailbox/get', - { accountId, ids: [mailbox.id], properties: MAILBOX_PROPERTIES }, + { + accountId, + // 옮길 때는 목록 전체 — 엔진이 상위를 거절하면 그 상위가 아직 있는지 같은 순간의 목록으로 가른다 + ids: moving ? null : [mailbox.id], + properties: MAILBOX_PROPERTIES, + }, 'm', ], ]); const set = expectResult(responses, 'u', 'Mailbox/set'); + const { list = [] } = expectResult>( + responses, + 'm', + 'Mailbox/get', + ); const failure = firstSetError(set.notUpdated); - if (failure) throw mailboxSetFailure(failure, 'Mailbox/set'); + if (failure) { + if ( + moving && + typeof parentId === 'string' && + isParentRefusal(failure) && + list.some((candidate) => candidate.id === parentId) + ) { + // 상위는 있는데 엔진이 거절했다 — 그사이 그 상위가 이 메일함의 하위가 됐다(고리) + throw new BusinessException('VAL_001', { + context: { reason: 'mailbox-move-refused', method: 'Mailbox/set' }, + }); + } + throw mailboxSetFailure(failure, 'Mailbox/set'); + } - const [renamed] = - expectResult>(responses, 'm', 'Mailbox/get') - .list ?? []; - if (!renamed) { + const updated = list.find((candidate) => candidate.id === mailbox.id); + if (!updated) { throw new BusinessException('RES_001', { context: { reason: 'mailbox-gone' }, }); } - return toMailbox(renamed); + if (moving) { + // 옮겼을 때만 — 메일함 id 만 남긴다 (이름 없이 — 사용자가 정한 글이다). 지금 자리 그대로면 위에서 엔진에 묻지 않고 돌려줬다 + this.logger.log( + `${traceTag()} 메일함을 옮겼습니다: user=${maskEmail(user)}, 메일함=${mailbox.id}, 새 상위=${parentId ?? '맨 위'}${'name' in update ? ', 이름도 바꿈' : ''}`, + ); + } + return toMailbox(updated); } /** * 메일함 지우기 (계약 2절 "메일함 관리") — 메일을 영구 삭제하지 않는다. 한 요청의 시간 · 멈춘 차례는 BulkRun. - * 1) 메일함 목록으로 확인 — 없으면 RES_001, 역할이 있으면 · 하위 메일함이 있으면 VAL_001 - * 2) 차례마다 메일을 BULK_BATCH_SIZE 통씩 읽어(id · 들어 있는 메일함, 그때의 상태) 확인한 상태 그대로일 때만(ifInState) 옮긴다 — - * 이 메일함에만 있으면 휴지통으로(이 메일함에서 빼고 휴지통에 넣는다), 다른 메일함에도 있으면 이 메일함에서만 뺀다 - * 3) 비었으면 빈 메일함을 지운다(`onDestroyRemoveEmails: false` — 메일을 지우는 옵션은 쓰지 않는다). - * 그사이 메일이 들어와 엔진이 mailboxHasEmail 로 거절하면 다시 옮긴다 - * 다 옮기지 못하면 deleted: false 와 진행 수(processed — 이 메일함에서 빠진 수)·휴지통으로 옮긴 수·남은 수 (클라이언트가 다시 보낸다). + * 1) 메일함 목록으로 확인 — 없으면 RES_001, 역할이 있으면 · 하위 메일함이 있으면 VAL_001. + * 하위까지 지우기(withChildren — 15단계)면 그 메일함과 하위 모두(나무)를 같은 규칙으로 다룬다 — 하위에 역할이 있는 메일함이 있으면 VAL_001 + * 2) 차례마다 나무의 메일을 BULK_BATCH_SIZE 통씩 읽어(id · 들어 있는 메일함, 그때의 상태) 확인한 상태 그대로일 때만(ifInState) 옮긴다 — + * 나무 안에만 있으면 휴지통으로(나무의 메일함에서 빼고 휴지통에 넣는다), 나무 밖의 메일함에도 있으면 나무의 메일함에서만 뺀다 + * 3) 비었으면 빈 메일함을 지운다(`onDestroyRemoveEmails: false` — 메일을 지우는 옵션은 쓰지 않는다). 나무는 깊은 것부터 한 요청에 적는다 — + * 엔진은 적은 차례대로 지운다(2026-11-09 확인). 그사이 메일이 들어와 엔진이 mailboxHasEmail 로 거절하면 다시 옮긴다. + * 그사이 하위 메일함이 생기면(mailboxHasChild) 하위까지 지울 때는 나무를 다시 읽어 잇고, 아니면 VAL_001 + * 다 옮기지 못하면 deleted: false 와 진행 수(processed — 나무에서 빠진 수)·휴지통으로 옮긴 수·남은 수 (클라이언트가 다시 보낸다). * 옮길 메일이 있는데 휴지통이 없으면 MAIL_001 (옮길 곳 없이 지우지 않는다). * 붙잡힌 메일(보내기 취소 · 예약 — 9단계)은 옮기지 않는다 — 남은 것이 그것뿐이면 이 요청에서 옮긴 것까지로 답하고(남은 수에 든다), * 옮긴 것이 없으면 MAIL_015 (같은 메일을 끝없이 다시 읽지 않게 — 클라이언트의 되풀이도 끝난다). 차례마다 제출을 읽지 못하면 MAIL_001 */ - async deleteMailbox(user: string, id: string): Promise { - const run = new BulkRun(); + async deleteMailbox( + user: string, + id: string, + options: MailboxDeleteOptions = {}, + ): Promise { + const run = new BulkRun(options.startedAt); + const withChildren = options.withChildren === true; const mailboxes = await this.loadMailboxes(user, run.callOptions()); const mailbox = findMailbox(mailboxes, id); assertUserMailbox(mailbox, 'delete'); - if (mailboxes.some((candidate) => candidate.parentId === mailbox.id)) { - throw new BusinessException('VAL_001', { - context: { reason: 'mailbox-has-children' }, - }); - } + let tree = deletionTree(mailboxes, mailbox, withChildren); const trashId = roleMailboxId(mailboxes, 'trash'); - const target = `mailboxIds/${mailbox.id}`; let movedToTrash = 0; - let remaining = mailbox.totalEmails ?? 0; + let remaining = tree.initialTotal; const result = (deleted: boolean): MailboxDeletion => ({ deleted, processed: run.processed, @@ -1101,16 +1392,33 @@ export class JmapMailProvider implements MailProvider { while (run.canStartRound()) { const batch = await this.readMailboxBatch( user, - mailbox.id, + tree.ids, run.callOptions(), ); remaining = batch.total; if (batch.emails.length === 0) { - if ( - await this.destroyEmptyMailbox(user, mailbox.id, run.callOptions()) - ) { - return result(true); + const destroyed = await this.destroyEmptyMailboxes( + user, + tree.order, + run.callOptions(), + ); + if (destroyed.outcome === 'destroyed') return result(true); + if (destroyed.outcome === 'has-child') { + if (!withChildren) { + throw new BusinessException('VAL_001', { + context: { reason: 'mailbox-has-children' }, + }); + } + // 그사이 하위 메일함이 생겼다 — 나무를 다시 읽어 그 메일함까지 (그사이 그 메일함이 없어졌으면 지운 것으로) + const fresh = await this.loadMailboxes(user, run.callOptions()); + const current = fresh.find((candidate) => candidate.id === id); + if (!current) return result(true); + tree = deletionTree(fresh, current, true); + if (run.stalled('mailbox-has-child')) break; + continue; } + // 지운 하위는 나무에서 뺀다 — 다음 차례가 없는 메일함을 묻지 않게 + tree = withoutDestroyed(tree, destroyed.destroyed); // 비운 사이 메일이 들어왔다 — 다시 옮긴다 (읽은 목록이 비어 있는데도 이어서 거절되면 엔진 이상 — 멈춘다) if (run.stalled('mailbox-has-email')) break; continue; @@ -1133,17 +1441,26 @@ export class JmapMailProvider implements MailProvider { batch.emails.map((email) => email.id), this.heldLookup(user, run.callOptions()), ); + const inTree = new Set(tree.ids); const toTrash: string[] = []; - const detach: string[] = []; + const update: Record> = {}; for (const email of batch.emails) { if (held.has(email.id)) continue; - if (removalFrom(inMailboxes(email), mailbox.id) === 'destroy') { + const mailboxIds = inMailboxes(email); + // 나무의 메일함에서 뺀다 — 나무 안에만 있었으면 휴지통에 넣는다 + const patch: Record = Object.fromEntries( + mailboxIds + .filter((mailboxId) => inTree.has(mailboxId)) + .map((mailboxId) => [`mailboxIds/${mailboxId}`, null]), + ); + if (mailboxIds.every((mailboxId) => inTree.has(mailboxId))) { + patch[`mailboxIds/${trashId}`] = true; toTrash.push(email.id); - } else { - detach.push(email.id); } + update[email.id] = patch; } - if (toTrash.length === 0 && detach.length === 0) { + const patched = Object.keys(update); + if (patched.length === 0) { // 남은 것이 붙잡힌 메일뿐이다 — 이 요청에서 옮긴 것이 있으면 거기까지, 없으면 MAIL_015 (같은 메일을 끝없이 다시 읽지 않게) this.warnHeldSkipped(user, held.size, '지우는 메일함에서 옮기지'); if (run.processed > 0) break; @@ -1152,19 +1469,7 @@ export class JmapMailProvider implements MailProvider { const set = await this.conditionalEmailSet( user, batch.state, - { - update: { - ...Object.fromEntries( - toTrash.map((emailId) => [ - emailId, - { [target]: null, [`mailboxIds/${trashId}`]: true }, - ]), - ), - ...Object.fromEntries( - detach.map((emailId) => [emailId, { [target]: null }]), - ), - }, - }, + { update }, run.callOptions(), ); if (set === null) { @@ -1174,8 +1479,9 @@ export class JmapMailProvider implements MailProvider { const updated = new Set(Object.keys(set.updated ?? {})); const moved = toTrash.filter((emailId) => updated.has(emailId)).length; - const removed = - moved + detach.filter((emailId) => updated.has(emailId)).length; + const removed = patched.filter((emailId) => + updated.has(emailId), + ).length; this.warnRefusals(user, set.notUpdated, '지우는 메일함에서 옮기지'); movedToTrash += moved; remaining = Math.max(0, batch.total - removed); @@ -1309,7 +1615,7 @@ export class JmapMailProvider implements MailProvider { while (run.canStartRound()) { const batch = await this.readMailboxBatch( user, - mailbox.id, + [mailbox.id], run.callOptions(), ); if (batch.emails.length === 0) { @@ -1440,6 +1746,15 @@ export class JmapMailProvider implements MailProvider { return this.filterRuns.run(user, request, context); } + /** 차단할 때 받은편지함 정리 (15단계) — 필터 적용과 같은 틀(JmapFilterRunner.runBlock — 계약 2절 "보낸 사람 차단") */ + runMailBlock( + user: string, + request: MailBlockRunRequest, + context: MailFilterRunContext, + ): Promise { + return this.filterRuns.runBlock(user, request, context); + } + async getUsage(user: string): Promise { const responses = await this.jmap.call( user, @@ -3059,6 +3374,7 @@ export class JmapMailProvider implements MailProvider { user: string, id: string, draftsId: string, + options: () => JmapCallOptions | undefined = () => undefined, ): Promise { const patch = { mailboxIds: { [draftsId]: true }, @@ -3070,9 +3386,13 @@ export class JmapMailProvider implements MailProvider { let reason = 'unknown'; for (let attempt = 1; attempt <= RETURN_TO_DRAFTS_ATTEMPTS; attempt += 1) { try { - const responses = await this.jmap.call(user, (accountId) => [ - ['Email/set', { accountId, update: { [id]: patch } }, 'u'], - ]); + const responses = await this.jmap.call( + user, + (accountId) => [ + ['Email/set', { accountId, update: { [id]: patch } }, 'u'], + ], + options(), + ); const set = expectResult(responses, 'u', 'Email/set'); const failure = firstSetError(set.notUpdated); if (!failure) return; @@ -3089,6 +3409,476 @@ export class JmapMailProvider implements MailProvider { }); } + /** + * 예약 메일 지금 보내기 (계약 2절 "보내기 취소 · 예약 발송" — 15단계). 엔진 사실(2026-11-09 개발 엔진 — phase15 E4): 기다리는 예약 제출을 취소하고 + * 같은 메일을 바로 다시 제출하면 받는 사람에게 **정확히 한 통** 간다 — 취소가 된 뒤에만 다시 내므로 두 번 나갈 틈이 없다(그 전에 엔진이 보냈으면 취소가 cannotUnsend). + * 1) 그 메일 · 메일함 · 그 메일의 제출(envelope · 발신 ID · 결과까지)을 한 요청으로 읽어 상태를 가른다(releaseStateOf) + * 2) scheduled — 다시 제출할 envelope 을 먼저 정하고(못 읽으면 아무것도 바꾸지 않고 MAIL_001) 기다리는 예약 제출을 취소한다(cancelHeld). + * 엔진이 받지 않으면(cannotUnsend — 그사이 보냄) 다시 읽어 보냈으면 pendingSend null, 취소를 확인하지 못하면 다시 읽어 취소됐을 때만 잇는다 + * 3) interrupted(앞 요청이 취소한 뒤 끊김) · 2) 다음 — 같은 메일을 그 envelope 그대로 다시 제출한다(resubmitReleased — 성공하면 같은 요청에서 예약 표시를 뗀다). + * 엔진이 거듭 거절하면 임시보관함으로 되돌리고, 결과를 모르거나 남은 시간이 모자라면 그대로 두고 MAIL_001 (다시 부르면 3) 으로 이어 낸다) + * 4) 받는 사람별 결과를 발송과 같은 규칙으로 읽는다 — 모두 받지 않았으면 기다리는 새 제출을 취소하고 임시보관함으로 되돌린다(MAIL_010 · MAIL_011) + * ScheduledSendReturnedToDraftsException 은 임시보관함으로 **되돌린 뒤에만** 던진다 — 되돌리지 못하면 그냥 MAIL_001(메일은 보낸편지함에 그대로) + * released · sent 는 지금 상태로 답한다(다시 제출하지 않는다). 로그는 메일 id · 보낼 시각만 (받는 사람 · 제목 없음 — 9단계와 같다) + */ + async releaseScheduledSend( + user: string, + id: string, + hold: SendHold | null, + ): Promise { + const startedAt = Date.now(); + const left = () => RELEASE_BUDGET_MS - (Date.now() - startedAt); + /** 엔진 호출의 제한 시간 — 기본과 남은 시간 가운데 짧은 쪽, 정리는 그래도 RELEASE_CLEANUP_MIN_MS */ + const callOptions = (floorMs = 1): JmapCallOptions => ({ + timeoutMs: Math.max(floorMs, Math.min(JMAP_REQUEST_TIMEOUT_MS, left())), + }); + + const snapshot = await this.readReleaseSnapshot(user, id, callOptions()); + const state = releaseStateOf( + snapshot.email, + snapshot.submissions, + startedAt, + ); + if (state.kind === 'not-scheduled') { + throw new BusinessException('RES_001', { + context: { reason: 'not-a-scheduled-send', state: state.why }, + }); + } + if (state.kind === 'released' || state.kind === 'sent') { + return this.releasedNow(user, id, snapshot, state, startedAt); + } + + // envelope 을 먼저 정한다 — 읽지 못하면 취소하지 않는다(취소한 뒤 다시 내지 못하는 메일이 생기지 않게) + const source = + state.kind === 'scheduled' ? latestSubmission(state.held) : state.source; + const envelope = releaseEnvelopeOf(source); + if (envelope === null) { + throw new BusinessException('MAIL_001', { + context: { reason: 'scheduled-envelope-unknown', state: state.kind }, + }); + } + if (state.kind === 'scheduled') { + const canceled = await this.cancelHeld( + user, + id, + state.held, + callOptions, + startedAt, + ); + if (canceled !== null) return canceled; + } else { + this.logger.warn( + `[MAIL_001]${traceTag()} 앞서 예약을 취소하고 끊긴 지금 보내기를 잇습니다 — 다시 제출합니다: user=${maskEmail(user)}, id=${id}`, + ); + } + + const plan: SubmissionPlan = { + identityId: envelope.identityId, + from: envelope.from, + parameters: hold ? holdParameters(hold) : null, + draftsId: snapshot.draftsId, + sentId: snapshot.sentId, + }; + const submitted = await this.resubmitReleased( + user, + id, + plan, + envelope.rcptTo, + { callOptions, left, startedAt, draftsId: snapshot.draftsId }, + ); + if ('released' in submitted) return submitted.released; + + // 받는 사람별 결과 — 발송과 같은 규칙 (계약 2절 "받는 사람별 결과") + const recipients = recipientsOf(snapshot.email); + const result = await this.readSendResult( + user, + recipients, + submitted.send.id, + Math.min(SEND_RESULT_TIMEOUT_MS, left() - RELEASE_CLEANUP_MIN_MS), + envelope.rcptTo, + ); + const { delivery } = result; + if (delivery.isEveryoneRejected) { + const code = everyoneRejectedCode(delivery.rejected); + this.logger.warn( + `[${code}]${traceTag()} 지금 보낸 예약 메일을 받는 사람이 모두 받지 않아 임시보관함으로 되돌립니다: user=${maskEmail(user)}, id=${id}, ${describeRejections(delivery.rejected)}`, + ); + // 새 제출이 아직 기다리면 먼저 취소한다 — 풀리는 시각에 다시 보내지 않게 (못 해도 되돌린다: 받을 사람이 없다) + if (result.undoStatus === UNDO_PENDING && submitted.send.id) { + await this.cancelQuietly(user, submitted.send.id, () => + callOptions(RELEASE_CLEANUP_MIN_MS), + ); + } + await this.returnToDrafts(user, id, snapshot.draftsId, () => + callOptions(RELEASE_CLEANUP_MIN_MS), + ); + throw new ScheduledSendReturnedToDraftsException(code, { + context: { + reason: 'release-all-recipients-rejected', + rejected: delivery.rejected.length, + }, + }); + } + if (delivery.rejected.length > 0) { + this.logger.log( + `${traceTag()} 지금 보낸 예약 메일을 엔진이 받는 사람 일부에게 받지 않았습니다 — 나머지에게는 보냈습니다: user=${maskEmail(user)}, id=${id}, ${describeRejections(delivery.rejected)}`, + ); + } + const pendingSend = hold + ? submittedPendingSend(hold, submitted.send, startedAt) + : null; + this.logger.log( + `${traceTag()} 예약한 메일을 지금 보냈습니다: user=${maskEmail(user)}, id=${id}, 예약했던 시각=${toUtcIso(source.sendAt) ?? 'unknown'}, ${pendingSend ? `보내기 취소 대기 — 보낼 시각=${pendingSend.sendAt}` : '바로 보냄'}`, + ); + return { + id, + rejectedRecipients: toRejectedRecipients(delivery.rejected), + pendingSend, + releasedAt: new Date(startedAt).toISOString(), + }; + } + + /** 지금 보내기가 읽는 것 — 메일함(임시보관함 · 보낸편지함) · 그 메일 · 그 메일의 제출 (한 요청 — 엔진이 그 id 를 해석하지 못하면 RES_001) */ + private async readReleaseSnapshot( + user: string, + id: string, + options: JmapCallOptions, + ): Promise { + const responses = await this.jmap.call( + user, + (accountId) => [ + [ + 'Mailbox/get', + { accountId, ids: null, properties: ['id', 'role'] }, + 'm', + ], + [ + 'Email/get', + { accountId, ids: [id], properties: RELEASE_EMAIL_PROPERTIES }, + 'g', + ], + [ + 'EmailSubmission/query', + { accountId, filter: { emailIds: [id] } }, + 'rq', + ], + [ + 'EmailSubmission/get', + { + accountId, + '#ids': { + resultOf: 'rq', + name: 'EmailSubmission/query', + path: '/ids', + }, + properties: RELEASE_SUBMISSION_PROPERTIES, + }, + 'rg', + ], + ], + { ...options, invalidRequestCode: 'RES_001' }, + ); + const draftsId = this.requireDraftsMailbox(responses); + const { list: mailboxes = [] } = expectResult>( + responses, + 'm', + 'Mailbox/get', + ); + const sentId = roleMailboxId(mailboxes, 'sent'); + if (!sentId) { + throw new BusinessException('MAIL_001', { + context: { reason: 'sent-mailbox-missing' }, + }); + } + const email = this.requireEmail(responses, 'g'); + // 첫 메서드(제출 찾기)의 오류부터 본다 — 막혔으면 뒤 메서드는 참조를 풀지 못해 invalidResultReference 로 끝난다 (listScheduledMessages 와 같다) + expectResult(responses, 'rq', 'EmailSubmission/query'); + const { list: submissions = [] } = expectResult< + JmapGetResult + >(responses, 'rg', 'EmailSubmission/get'); + return { email, submissions, draftsId, sentId }; + } + + /** + * 이미 지금 보내기를 했거나(released) 예약 시각에 보낸(sent) 메일 — 다시 제출하지 않고 지금 상태로 답한다. + * released 는 새 제출이 아직 기다리면 보내기 취소를 기다리는 발송(pendingSend undo), 받는 사람별 결과는 그 제출로 맞춘다 + */ + private releasedNow( + user: string, + id: string, + snapshot: Pick, + state: Extract, + nowMs: number, + ): ReleasedScheduledSend { + const submissions = + state.kind === 'released' ? [state.current] : state.live; + const delivery = readDeliveryResult( + recipientsOf(snapshot.email), + submissions, + submittedEnvelope(submissions), + ); + const sendAt = + state.kind === 'released' && isHeld(state.current, nowMs) + ? toUtcIso(state.current.sendAt) + : null; + this.logger.log( + `${traceTag()} ${state.kind === 'released' ? '이미 지금 보내기를 한 예약 메일입니다 — 그 발송의 지금 상태로 답합니다' : '예약 시각이 지나 이미 보낸 메일입니다 — 보낸 것으로 답합니다'}: user=${maskEmail(user)}, id=${id}`, + ); + return { + id, + rejectedRecipients: toRejectedRecipients(delivery.rejected), + pendingSend: sendAt === null ? null : { kind: 'undo', sendAt }, + releasedAt: + state.kind === 'released' + ? (toUtcIso(state.current.sendAt) ?? new Date(nowMs).toISOString()) + : null, + }; + } + + /** + * 기다리는 예약 제출을 취소한다 — 엔진이 취소했다고 답한 것만 취소된 것이다(updated 에 그 제출 — cancelPendingSend 와 같다). + * @returns null 이면 모두 취소했다(다시 제출한다). 아니면 그대로 답할 결과 — 엔진이 받지 않았거나 확인하지 못해 다시 읽어 보니 보냈다 · 이미 지금 보냈다 + * @throws 다시 읽어도 아직 기다리거나(취소되지 않음) 그 밖의 상태면 MAIL_001 — 아무것도 다시 내지 않았다 + */ + private async cancelHeld( + user: string, + id: string, + held: readonly JmapEmailSubmission[], + callOptions: () => JmapCallOptions, + startedAt: number, + ): Promise { + let outcome: 'canceled' | 'refused' | 'unknown' = 'unknown'; + let jmapType: string | undefined; + try { + const responses = await this.jmap.call( + user, + (accountId) => [ + [ + 'EmailSubmission/set', + { + accountId, + update: Object.fromEntries( + held.map((submission) => [ + submission.id, + { undoStatus: UNDO_CANCELED }, + ]), + ), + }, + 'x', + ], + ], + callOptions(), + ); + const set = expectResult( + responses, + 'x', + 'EmailSubmission/set', + ); + const refused = firstSetError(set.notUpdated); + const confirmed = set.updated ?? {}; + if (refused) { + outcome = 'refused'; + jmapType = refused.type ?? 'unknown'; + } else if ( + held.every((submission) => + Object.prototype.hasOwnProperty.call(confirmed, submission.id), + ) + ) { + outcome = 'canceled'; + } + } catch (error) { + // 결과를 모른다 — 다시 읽어 가른다 (취소됐으면 잇고, 아니면 바꾸지 않은 것으로) + jmapType = describeFailure(error); + } + if (outcome === 'canceled') return null; + + // 엔진이 받지 않았거나(그사이 보냄) 확인하지 못했다 — 다시 읽어 지금 상태로 가른다 + const fresh = await this.readReleaseSnapshot(user, id, callOptions()); + const state = releaseStateOf(fresh.email, fresh.submissions, Date.now()); + if (state.kind === 'released' || state.kind === 'sent') { + return this.releasedNow(user, id, fresh, state, startedAt); + } + if (outcome === 'unknown' && state.kind === 'interrupted') return null; + throw new BusinessException('MAIL_001', { + context: { + reason: + outcome === 'refused' + ? 'release-cancel-refused' + : 'release-cancel-not-confirmed', + state: state.kind, + ...(jmapType !== undefined ? { jmapType } : {}), + }, + }); + } + + /** + * 취소한 예약 메일을 같은 envelope 으로 다시 제출한다 — 성공하면 같은 요청에서 예약 표시를 뗀다(이제 보내기 취소를 기다리는 메일 · 보낸 메일이다). + * 실패는 둘로 가른다 (처음 발송의 finishSubmission 과 같은 결 — 9단계 리뷰 2): + * - 엔진이 분명히 거절했다(SetError · 메서드 오류 · 요청을 실행하지 않았다는 답 isRequestRefused) — 아무것도 나가지 않았다. + * 한 번 더 내고(RELEASE_SUBMIT_ATTEMPTS), 그래도 거절이면 그 메일을 임시보관함으로 되돌린 **뒤에** ScheduledSendReturnedToDraftsException(MAIL_001). + * 되돌리지 못하면 returnToDrafts 의 MAIL_001 그대로다(되돌림 예외가 아니다 — 메일은 예약이 취소된 채 보낸편지함에 있다) + * - 결과를 모른다(시간 초과 · 연결 끊김 · 5xx · 해석하지 못한 응답) — 나갔을 수 있다: 다시 내지도 · 되돌리지도 않는다 (afterUnknownResubmit) + * 제출마다 먼저 남은 시간을 본다 — MIN_SEND_WINDOW_MS 보다 적으면 내지 않고 MAIL_001 (보내다 끊기면 나갔는지 알 수 없다). + * 메일은 그대로라(예약 표시 · 취소한 예약 제출) 같은 요청을 다시 받으면 끊긴 지금 보내기(interrupted)로 이어 낸다 + * @returns 만든 제출, 또는 그대로 답할 결과(released — 결과를 모르게 끝났지만 다시 읽으니 엔진이 만들었다) + */ + private async resubmitReleased( + user: string, + id: string, + plan: SubmissionPlan, + rcptTo: readonly string[], + context: { + readonly callOptions: (floorMs?: number) => JmapCallOptions; + readonly left: () => number; + readonly startedAt: number; + readonly draftsId: string; + }, + ): Promise< + | { readonly send: CreatedSubmission } + | { readonly released: ReleasedScheduledSend } + > { + let reason = 'unknown'; + for (let attempt = 1; attempt <= RELEASE_SUBMIT_ATTEMPTS; attempt += 1) { + // 보내다 끊기면 나갔는지 알 수 없다 — 남은 시간이 모자라면 내지 않는다 (예약은 취소된 채 — 다시 부르면 이어 낸다) + const timeoutMs = Math.min(JMAP_SEND_TIMEOUT_MS, context.left()); + if (timeoutMs < MIN_SEND_WINDOW_MS) { + this.logger.warn( + `[MAIL_001]${traceTag()} 지금 보내기에 남은 시간이 모자라 다시 제출하지 않았습니다 — 예약을 취소한 채 보낸편지함에 둡니다(같은 요청을 다시 받으면 이어 냅니다): user=${maskEmail(user)}, id=${id}, ${attempt}번째, 남은 시간=${Math.max(0, timeoutMs)}ms`, + ); + throw new BusinessException('MAIL_001', { + context: { + reason: 'release-budget-exhausted', + leftMs: Math.max(0, timeoutMs), + attempt, + }, + }); + } + + let responses: JmapInvocation[]; + let result: MethodResult; + try { + responses = await this.jmap.call( + user, + (accountId) => [ + submitCall(accountId, plan, id, rcptTo, { + [`keywords/${SCHEDULED_SEND_KEYWORD}`]: null, + }), + ], + { timeoutMs }, + ); + // 응답에 제출 결과가 없으면(해석하지 못한 응답) 결과를 모른다 — findResult 가 MAIL_001 missing-response 로 던진다 + result = findResult(responses, 's', 'EmailSubmission/set'); + } catch (error) { + // 엔진이 실행하지 않았다고 답했다(4xx · 429) — 아무것도 나가지 않았다: 한 번 더 낸다 + if (isRequestRefused(error)) { + reason = describeFailure(error); + continue; + } + return this.afterUnknownResubmit(user, id, error, context); + } + + const set = result.ok ? (result.args as JmapSetResult) : undefined; + const send = set?.created?.send; + if (send) { + this.warnIfNotMovedToSent(responses, user); + return { send }; + } + // 엔진의 답이 거절(메서드 오류 · SetError) — 만들지 않았다: 한 번 더 낸다 + const failure = result.ok ? firstSetError(set?.notCreated) : result.error; + reason = `jmap:${failure?.type ?? 'unknown'}`; + } + + // 엔진이 거듭 거절했다 — 아무것도 나가지 않았다: 임시보관함으로 되돌린다 (못 하면 returnToDrafts 의 MAIL_001 — 되돌림 예외가 아니다) + this.logger.warn( + `[MAIL_001]${traceTag()} 예약을 취소한 메일을 엔진이 다시 받지 않아 임시보관함으로 되돌립니다 — 보내지 않았습니다: user=${maskEmail(user)}, id=${id}, reason=${reason}`, + ); + await this.returnToDrafts(user, id, context.draftsId, () => + context.callOptions(RELEASE_CLEANUP_MIN_MS), + ); + throw new ScheduledSendReturnedToDraftsException('MAIL_001', { + context: { reason: 'release-resubmit-refused', jmapReason: reason }, + }); + } + + /** + * 다시 제출의 결과를 모른다(시간 초과 · 연결 끊김 · 5xx · 해석하지 못한 응답) — 나갔을 수 있어 다시 내지도 · 임시보관함으로 되돌리지도 않는다. + * 제출을 한 번만 다시 읽어(남은 시간이 없어도 RELEASE_CLEANUP_MIN_MS) 새 제출이 보이면(엔진은 만들었다) 그 상태로 답한다. + * 아니면 503 MAIL_001 — 그냥 BusinessException 이다(되돌림 예외가 아니다 — 대용량 첨부 링크도 그대로). + * 메일은 예약이 취소된 채 보낸편지함에 그대로라 같은 요청을 다시 받으면 이어 낸다(그때 새 제출이 보이면 released 로 200) + */ + private async afterUnknownResubmit( + user: string, + id: string, + failure: unknown, + context: { + readonly callOptions: (floorMs?: number) => JmapCallOptions; + readonly startedAt: number; + }, + ): Promise<{ readonly released: ReleasedScheduledSend }> { + let reread: string; + try { + const fresh = await this.readReleaseSnapshot( + user, + id, + context.callOptions(RELEASE_CLEANUP_MIN_MS), + ); + const state = releaseStateOf(fresh.email, fresh.submissions, Date.now()); + if (state.kind === 'released') { + return { + released: this.releasedNow(user, id, fresh, state, context.startedAt), + }; + } + reread = state.kind; + } catch (error) { + reread = describeFailure(error); + } + this.logger.warn( + `[MAIL_001]${traceTag()} 지금 보내기의 다시 제출이 결과를 모르게 끝났습니다 — 다시 내지도 · 임시보관함으로 옮기지도 않고 그대로 둡니다(같은 요청을 다시 받으면 이어 냅니다): user=${maskEmail(user)}, id=${id}, reason=${describeFailure(failure)}, 다시 읽기=${reread}`, + ); + throw new BusinessException('MAIL_001', { + context: { reason: 'release-resubmit-unknown', reread }, + }); + } + + /** 기다리는 제출 하나를 취소한다 — 못 해도 던지지 않는다(경고만 — 받을 사람이 없는 제출이다) */ + private async cancelQuietly( + user: string, + submissionId: string, + options: () => JmapCallOptions, + ): Promise { + try { + const responses = await this.jmap.call( + user, + (accountId) => [ + [ + 'EmailSubmission/set', + { + accountId, + update: { [submissionId]: { undoStatus: UNDO_CANCELED } }, + }, + 'c', + ], + ], + options(), + ); + const set = expectResult( + responses, + 'c', + 'EmailSubmission/set', + ); + const refused = firstSetError(set.notUpdated); + if (refused) { + this.logger.warn( + `[MAIL_001]${traceTag()} 받는 사람이 모두 받지 않은 지금 보내기의 제출을 취소하지 못했습니다: user=${maskEmail(user)}, jmapType=${refused.type ?? 'unknown'}`, + ); + } + } catch (error) { + this.logger.warn( + `[MAIL_001]${traceTag()} 받는 사람이 모두 받지 않은 지금 보내기의 제출을 취소하지 못했습니다: user=${maskEmail(user)}, reason=${describeFailure(error)}`, + ); + } + } + async uploadAttachment( user: string, file: UploadedFile, @@ -3763,12 +4553,13 @@ export class JmapMailProvider implements MailProvider { /** * 메일함의 메일 한 차례(최대 BULK_BATCH_SIZE 통) — id · 들어 있는 메일함과 그때의 Email 상태(ifInState 에 쓴다), * 이 메일함의 전체 수. 조회와 읽기를 한 요청으로 한다 (같은 순간의 상태). 그 메일함에 든 것만 돌려준다. + * 메일함이 여럿이면(하위까지 지우기의 나무 — 15단계) 그 가운데 하나에라도 든 메일 — 전체 수도 겹치지 않게 센다(엔진의 OR 조건). * 같은 요청에 계정의 기다리는 제출(보낼 시각이 지금 뒤)을 묻는다 — 붙잡힌 메일(9단계)은 옮기지도 지우지도 않는다. * 읽은 메일이 있는데 제출을 읽지 못하면 MAIL_001 (fail closed — 비었으면 묻지 않은 것과 같다) */ private async readMailboxBatch( user: string, - mailboxId: string, + mailboxIds: readonly string[], options: JmapCallOptions, ): Promise<{ emails: JmapEmail[]; @@ -3784,7 +4575,7 @@ export class JmapMailProvider implements MailProvider { 'Email/query', { accountId, - filter: { inMailbox: mailboxId }, + filter: inAnyMailbox(mailboxIds), limit: BULK_BATCH_SIZE, calculateTotal: true, }, @@ -3817,7 +4608,10 @@ export class JmapMailProvider implements MailProvider { const byId = new Map((found.list ?? []).map((email) => [email.id, email])); const emails = ids.flatMap((emailId) => { const email = byId.get(emailId); - return email && email.mailboxIds?.[mailboxId] === true ? [email] : []; + return email && + mailboxIds.some((mailboxId) => email.mailboxIds?.[mailboxId] === true) + ? [email] + : []; }); return { emails, @@ -3869,36 +4663,52 @@ export class JmapMailProvider implements MailProvider { } /** - * 빈 메일함을 지운다 — 메일이 든 메일함은 엔진이 mailboxHasEmail 로 거절한다(메일을 지우는 옵션은 쓰지 않는다, 확인함). - * @returns 지웠거나 이미 없으면 true, 그사이 메일이 들어와 거절됐으면 false - * @throws 하위 메일함이 생겼으면 VAL_001, 그 밖의 거절은 그 코드 + * 빈 메일함들을 지운다 — 적은 차례대로(하위까지 지우기는 깊은 것부터 — 엔진은 한 요청의 destroy 를 적은 차례대로 한다, 2026-11-09 확인). + * 메일이 든 메일함은 엔진이 mailboxHasEmail 로, 하위가 남은 메일함은 mailboxHasChild 로 거절한다(메일을 지우는 옵션은 쓰지 않는다, 확인함). + * 이미 없으면(notFound) 지운 것으로 본다 + * @returns destroyed — 모두 지웠다 · has-email — 그사이 메일이 들어와 거절된 메일함이 있다(그 위는 하위가 남아 함께 거절된다) · + * has-child — 메일은 없는데 하위가 남았다(그사이 나무 밖의 하위 메일함이 생겼다). 이번에 지운 메일함 id 도 함께 + * @throws 그 밖의 거절은 그 코드 */ - private async destroyEmptyMailbox( + private async destroyEmptyMailboxes( user: string, - mailboxId: string, + mailboxIds: readonly string[], options: JmapCallOptions, - ): Promise { + ): Promise<{ + readonly outcome: 'destroyed' | 'has-email' | 'has-child'; + readonly destroyed: ReadonlySet; + }> { const responses = await this.jmap.call( user, (accountId) => [ [ 'Mailbox/set', - { accountId, destroy: [mailboxId], onDestroyRemoveEmails: false }, + { + accountId, + destroy: [...mailboxIds], + onDestroyRemoveEmails: false, + }, 'x', ], ], options, ); const set = expectResult(responses, 'x', 'Mailbox/set'); - const failure = firstSetError(set.notDestroyed); - if (!failure || failure.type === NOT_FOUND) return true; - if (failure.type === MAILBOX_HAS_EMAIL) return false; - if (failure.type === MAILBOX_HAS_CHILD) { - throw new BusinessException('VAL_001', { - context: { reason: 'mailbox-has-children' }, - }); + const failures = Object.entries(set.notDestroyed ?? {}).flatMap( + ([mailboxId, error]) => + error && error.type !== NOT_FOUND ? [{ mailboxId, error }] : [], + ); + const failed = new Set(failures.map(({ mailboxId }) => mailboxId)); + const destroyed = new Set(mailboxIds.filter((id) => !failed.has(id))); + if (failures.length === 0) return { outcome: 'destroyed', destroyed }; + if (failures.some(({ error }) => error.type === MAILBOX_HAS_EMAIL)) { + return { outcome: 'has-email', destroyed }; } - throw jmapFailure(failure, { method: 'Mailbox/set' }); + const other = failures.find( + ({ error }) => error.type !== MAILBOX_HAS_CHILD, + ); + if (other) throw jmapFailure(other.error, { method: 'Mailbox/set' }); + return { outcome: 'has-child', destroyed }; } /** diff --git a/backend/src/modules/mail/jmap/jmap.types.ts b/backend/src/modules/mail/jmap/jmap.types.ts index ebfeb40..4304673 100644 --- a/backend/src/modules/mail/jmap/jmap.types.ts +++ b/backend/src/modules/mail/jmap/jmap.types.ts @@ -150,6 +150,8 @@ export interface JmapEmailSubmission { id: string; /** 이 제출이 보내는 메일 */ emailId?: string; + /** 이 제출의 발신 ID — 예약 메일 지금 보내기(15단계)가 같은 발신 ID 로 다시 제출한다 */ + identityId?: string; /** * pending — 아직 붙잡고 있다(취소할 수 있다) · final — 보냈다 · canceled — 취소했다 (RFC 8621 §7). * 붙잡지 않은 제출도 대기열에서 배달하는 동안 잠깐 pending 이다 — 붙잡은 것은 보낼 시각(sendAt)이 아직 오지 않은 것으로 가린다 (9단계) diff --git a/backend/src/modules/mail/mail-block-match.spec.ts b/backend/src/modules/mail/mail-block-match.spec.ts new file mode 100644 index 0000000..ddfa6e7 --- /dev/null +++ b/backend/src/modules/mail/mail-block-match.spec.ts @@ -0,0 +1,52 @@ +import { asciiCasemap, isBlockedSenderMail } from './mail-block-match'; + +/** + * 차단할 때 받은편지함 정리(15단계)의 판정 — 새로 오는 메일의 차단 규칙(jmap/sieve-script.ts 의 blockSection — Sieve `address :all :is` · + * `address :domain :is`, `i;ascii-casemap`)과 같은지. 표의 "엔진이 넓게 잡은 것"은 2026-11-09 개발 엔진의 `Email/query` from 조건이 잡은 메일이다(phase15 E3) + */ +describe('차단 정리의 판정 (mail-block-match — Sieve 의 address 와 짝)', () => { + it('ASCII 글자만 소문자로 — 그 밖의 글자는 그대로 (i;ascii-casemap)', () => { + expect(asciiCasemap('Kim@Spam.EXAMPLE')).toBe('kim@spam.example'); + expect(asciiCasemap('ÄBC@Ü.example')).toBe('Äbc@Ü.example'); + }); + + it.each<[string, string[], boolean]>([ + ['정확한 주소', ['kim@spam.example'], true], + ['대문자 주소', ['KIM@SPAM.EXAMPLE'], true], + [ + '보낸 사람 둘 가운데 둘째', + ['first@example.net', 'kim@spam.example'], + true, + ], + // 엔진 검색(from)은 잡지만 차단 규칙은 맞지 않는 것 + ['점이 더 붙은 주소', ['kim.lee@spam.example'], false], + ['하위 도메인', ['kim@sub.spam.example'], false], + ['더 긴 도메인', ['kim@spam.example.example.net'], false], + ['앞에 글자가 더', ['akim@spam.example'], false], + ['다른 도메인', ['kim@spam.example.net'], false], + ['From 이 없음', [], false], + ])('주소 차단 — %s', (_label, addresses, expected) => { + expect(isBlockedSenderMail(addresses, 'kim@spam.example')).toBe(expected); + }); + + it('주소 앞부분의 ASCII 밖 글자는 대소문자를 가린다 — 엔진 규칙과 같은 한계', () => { + expect(isBlockedSenderMail(['ü@spam.example'], 'ü@spam.example')).toBe( + true, + ); + expect(isBlockedSenderMail(['Ü@spam.example'], 'ü@spam.example')).toBe( + false, + ); + }); + + it.each<[string, string[], boolean]>([ + ['그 도메인', ['kim@spam.example'], true], + ['대문자 도메인', ['Lee@SPAM.Example'], true], + ['둘째 보낸 사람이 그 도메인', ['a@example.net', 'b@spam.example'], true], + ['하위 도메인', ['kim@sub.spam.example'], false], + ['더 긴 도메인', ['kim@spam.example.net'], false], + ['도메인이 이름 끝에만', ['kim@notspam.example'], false], + ['@ 가 없는 주소', ['spam.example'], false], + ])('도메인 차단 — %s', (_label, addresses, expected) => { + expect(isBlockedSenderMail(addresses, '@spam.example')).toBe(expected); + }); +}); diff --git a/backend/src/modules/mail/mail-block-match.ts b/backend/src/modules/mail/mail-block-match.ts new file mode 100644 index 0000000..b8d2a73 --- /dev/null +++ b/backend/src/modules/mail/mail-block-match.ts @@ -0,0 +1,40 @@ +/** + * 차단할 때 받은편지함 정리(계약 2절 "보낸 사람 차단" — 15단계)의 판정 — 순수 함수. + * **짝: jmap/sieve-script.ts 의 blockSection**(새로 오는 메일의 차단 규칙) — `address :all :is "from" [주소들]` · + * `address :domain :is "from" [도메인들]`, 비교는 엔진 기본 `i;ascii-casemap`(ASCII 대소문자만 무시). 한쪽을 바꾸면 다른 쪽도. + * + * 견주는 것은 엔진이 푼 From 머리글의 주소다(같은 이름의 머리글이 여럿이면 모두 — 표시 이름은 보지 않는다, jmap/filter-run-texts.ts 의 fromAddressesOf). + * 엔진의 검색(`Email/query` 의 from)은 쓰지 않는다 — 낱말 단위라 비슷한 주소(`kim.lee@` · `@sub.` · `.example.net`)와 + * 표시 이름에만 그 주소를 쓴 남의 메일까지 잡는다(2026-11-09 개발 엔진 확인) + */ + +/** ASCII 글자만 소문자로 — 엔진의 `i;ascii-casemap` (그 밖의 글자는 그대로 견준다) */ +export function asciiCasemap(value: string): string { + return value.replace(/[A-Z]/g, (char) => char.toLowerCase()); +} + +/** 주소의 도메인 — 마지막 `@` 뒤 (없으면 빈 글) */ +function domainOf(address: string): string { + const at = address.lastIndexOf('@'); + return at < 0 ? '' : address.slice(at + 1); +} + +/** + * 그 메일의 From 주소 가운데 하나라도 차단 대상에 맞는가 — 주소 차단(`kim@spam.example`)은 주소 전체가 같을 때, + * 도메인 차단(`@spam.example`)은 주소의 도메인이 같을 때(하위 도메인 · 더 긴 도메인은 아님) + * @param fromAddresses 엔진이 푼 From 머리글의 주소들 + * @param sender 차단 목록의 값 (소문자 주소 또는 `@도메인` — BlockedSender.address) + */ +export function isBlockedSenderMail( + fromAddresses: readonly string[], + sender: string, +): boolean { + const wanted = asciiCasemap(sender); + if (wanted.startsWith('@')) { + const domain = wanted.slice(1); + return fromAddresses.some( + (address) => asciiCasemap(domainOf(address)) === domain, + ); + } + return fromAddresses.some((address) => asciiCasemap(address) === wanted); +} diff --git a/backend/src/modules/mail/mail-block.controller.ts b/backend/src/modules/mail/mail-block.controller.ts index c544b39..d89e294 100644 --- a/backend/src/modules/mail/mail-block.controller.ts +++ b/backend/src/modules/mail/mail-block.controller.ts @@ -3,6 +3,7 @@ import { Controller, Delete, Get, + HttpCode, HttpStatus, Param, Post, @@ -28,17 +29,23 @@ import { MailBlockListDto, MailBlockRemovedDto, } from './dto/mail-block.dto'; +import { + MailBlockRunDto, + MailBlockRunRequestDto, +} from './dto/mail-block-run.dto'; import { MailBlockService } from './mail-block.service'; import { + MAIL_BLOCK_RUNS_ROUTE, MAIL_BLOCKS_ROUTE, MAX_EMAIL_LENGTH, MAX_MAIL_BLOCKS, } from './mail.constants'; +import { MailService } from './mail.service'; /** 차단의 효과 — 세 엔드포인트의 설명에 함께 쓴다 (계약 2절 "보낸 사람 차단") */ const BLOCK_NOTE = '차단한 보낸 사람(머리글 From 의 주소 · 도메인 — 대소문자 무시)의 메일은 스팸메일함으로 간다. 필터 · 부재중보다 먼저 돌고 멈추므로 필터는 돌지 않고 부재중 응답도 나가지 않는다. ' + - '이미 받은 메일은 옮기지 않는다. 저장 자리는 필터 · 부재중과 같은 엔진 규칙(Sieve 스크립트)이다 — 필터의 version 은 묻지 않는다.'; + '이미 받은 메일은 저절로 옮기지 않는다 — 받은편지함에 있는 것은 POST /api/mail-block-runs 로 옮긴다(15단계). 저장 자리는 필터 · 부재중과 같은 엔진 규칙(Sieve 스크립트)이다 — 필터의 version 은 묻지 않는다.'; // 보낸 사람 차단 (계약 2절 "보낸 사람 차단", 9단계) @ApiTags('Mail') @@ -109,3 +116,52 @@ export class MailBlockController { return this.blocks.remove(user, address); } } + +// 차단할 때 받은편지함 정리 (계약 2절 "보낸 사람 차단", 15단계) — 이미 받은 메일에 필터 적용(7단계)과 같은 틀 · 같은 잠금 +@ApiTags('Mail') +@ApiCookieAuth() +@UseGuards(JwtAuthGuard) +@ApiSessionErrors('로그인 필요 (쿠키 없음·만료)') +@Controller(MAIL_BLOCK_RUNS_ROUTE) +export class MailBlockRunController { + constructor(private readonly mail: MailService) {} + + @Post() + @HttpCode(HttpStatus.OK) + @ApiOperation({ + summary: '차단한 보낸 사람의 메일을 받은편지함에서 스팸메일함으로', + description: + '받은편지함에 이미 있는 그 보낸 사람의 메일을 스팸메일함으로 옮기고 스팸 표시를 붙인다($junk — 스팸 신고와 같다). 받은편지함만 본다(정리해 둔 메일함은 건드리지 않는다). ' + + '판정은 새로 오는 메일의 차단 규칙과 같다 — 주소는 머리글 From 의 주소 가운데 하나가 같을 때, 도메인은 From 주소의 @ 뒤가 그 도메인과 같을 때(하위 도메인은 아님), 대소문자는 ASCII 만 무시. ' + + '표시 이름에 쓴 주소 · 비슷한 주소는 맞지 않는다. 보내기를 기다리는 메일은 옮기지 않는다. 되돌리기는 없다(스팸메일함에서 스팸 아님으로). ' + + '받은 시각이 최근인 메일부터, 첫 요청 뒤에 온 메일은 보지 않는다. 한 요청은 25초 안에 끝내고 cursor 를 돌려준다 — null 이 될 때까지 같은 본문에 받은 cursor 를 넣어 다시 보낸다. ' + + '같은 직원의 받은편지함 정리(이것 · POST /api/mail-filter-runs)는 한 번에 하나다 — 진행 중이면 엔진에 묻지 않고 429 REQ_001. ' + + '요청이 도는 동안 그 직원의 스팸메일함을 잡는다 — 그동안 스팸메일함 비우기 · 전체 읽음은 429, 비우는 중이면 이 요청이 메일함 목록을 읽은 뒤 429(받은편지함은 건드리지 않았다).', + }) + @ApiStandardResponse(MailBlockRunDto, { + description: + '이번 요청에서 확인한 수 · 스팸메일함으로 옮긴 수 · 남은 수(어림) · 이어 할 자리(끝이면 null)', + }) + @ApiErrorResponse( + 'VAL_001', + `형식(메일 주소나 @도메인 · ${MAX_EMAIL_LENGTH}자) · 깨진 cursor · 다른 보낸 사람의 cursor · 모르는 필드 — 옮기지 않았다`, + ) + @ApiErrorResponse( + 'RES_001', + '차단 목록에 없는 보낸 사람(차단한 뒤에만 부른다 · 그사이 풀었다) — 옮기지 않았다', + ) + @ApiErrorResponse( + 'REQ_001', + '같은 직원의 받은편지함 정리(필터 적용 포함)가 진행 중(다른 탭 · PC) — 엔진에 묻지 않았다 · 스팸메일함 비우기가 진행 중 — 옮기지 않았다', + ) + @ApiErrorResponse( + 'MAIL_001', + '메일 엔진 장애 · 스팸메일함이 없음 · 하나도 확인하지 못하고 멈춤(시간 · 확인하는 사이 받은편지함이 계속 바뀜). 도중의 엔진 장애면 일부를 옮겼을 수 있다 — 마지막으로 받은 cursor 로 다시 보내면 된다', + ) + run( + @CurrentUser() user: AuthenticatedUser, + @Body() dto: MailBlockRunRequestDto, + ) { + return this.mail.runMailBlock(user, dto); + } +} diff --git a/backend/src/modules/mail/mail-block.service.ts b/backend/src/modules/mail/mail-block.service.ts index 372c25a..df80be5 100644 --- a/backend/src/modules/mail/mail-block.service.ts +++ b/backend/src/modules/mail/mail-block.service.ts @@ -6,19 +6,14 @@ import { traceTag } from '@/shared/logger/request-context'; import { maskEmail } from '@/shared/utils/mask'; import type { MailBlockCreateDto } from './dto/mail-block.dto'; import { + describeBlockTarget, findBlockProblem, parseBlockTarget, - type BlockTarget, } from './mail-blocks'; import { MAIL_PROVIDER, type MailProvider } from './mail.provider'; import { MAIL_SETTINGS, type MailSettings } from './mail.settings'; import type { MailBlockAdded, MailBlockList } from './mail.types'; -/** 로그에 남기는 차단 대상 — 주소는 가리고(maskEmail), 도메인 차단은 도메인만 (계약 2절 "보낸 사람 차단"의 로그) */ -function maskedTarget(target: BlockTarget): string { - return target.kind === 'domain' ? target.value : maskEmail(target.value); -} - /** * 보낸 사람 차단 (계약 2절 "보낸 사람 차단" — 9단계) — 엔진과 무관한 규칙(막을 수 없는 대상)은 여기서, 저장(엔진 규칙)은 포트가 한다. * - 모양은 요청 검증이 봤다 — 여기서는 내 주소 · 사내 도메인(MAIL_DOMAIN 과 그 하위) · 여러 사람이 쓰는 메일 서비스의 도메인을 막는다(VAL_001) @@ -65,7 +60,7 @@ export class MailBlockService { }); if (result.created) { this.logger.log( - `${traceTag()} 보낸 사람을 차단했습니다: user=${maskEmail(user.email)}, ${target.kind === 'domain' ? '도메인' : '주소'}=${maskedTarget(target)}`, + `${traceTag()} 보낸 사람을 차단했습니다: user=${maskEmail(user.email)}, ${describeBlockTarget(target)}`, ); } return result; @@ -84,7 +79,7 @@ export class MailBlockService { } await this.provider.removeMailBlock(user.email, target.value); this.logger.log( - `${traceTag()} 보낸 사람 차단을 풀었습니다: user=${maskEmail(user.email)}, ${target.kind === 'domain' ? '도메인' : '주소'}=${maskedTarget(target)}`, + `${traceTag()} 보낸 사람 차단을 풀었습니다: user=${maskEmail(user.email)}, ${describeBlockTarget(target)}`, ); return { address: target.value }; } diff --git a/backend/src/modules/mail/mail-blocks.ts b/backend/src/modules/mail/mail-blocks.ts index d4aca3c..3d5b8f7 100644 --- a/backend/src/modules/mail/mail-blocks.ts +++ b/backend/src/modules/mail/mail-blocks.ts @@ -1,6 +1,7 @@ import { isEmail } from 'class-validator'; import { isIP } from 'net'; +import { maskEmail } from '@/shared/utils/mask'; import { MAX_EMAIL_LENGTH, PUBLIC_MAIL_DOMAINS, @@ -88,6 +89,16 @@ export function blockTargetDomain(target: BlockTarget): string { return target.value.slice(target.value.lastIndexOf('@') + 1); } +/** + * 로그에 남기는 차단 대상 — `주소=k***@spam.example` · `도메인=@spam.example` (계약 2절 "보낸 사람 차단"의 로그 — 주소는 가리고(maskEmail), + * 도메인 차단은 도메인만). 차단 · 풀기 · 받은편지함 정리(15단계)가 같이 쓴다 + */ +export function describeBlockTarget(target: BlockTarget): string { + return target.kind === 'domain' + ? `도메인=${target.value}` + : `주소=${maskEmail(target.value)}`; +} + /** 사내 도메인이거나 그 하위 도메인인가 */ function isInternalDomain(domain: string, internal: string): boolean { return domain === internal || domain.endsWith(`.${internal}`); diff --git a/backend/src/modules/mail/mail-large-files.service.spec.ts b/backend/src/modules/mail/mail-large-files.service.spec.ts index 4ecabf8..eb24a9e 100644 --- a/backend/src/modules/mail/mail-large-files.service.spec.ts +++ b/backend/src/modules/mail/mail-large-files.service.spec.ts @@ -147,6 +147,7 @@ function setup() { url: shareUrl(ORIGIN, TOKEN), }), markSent: jest.fn().mockResolvedValue(undefined), + markReleased: jest.fn().mockResolvedValue(true), cancel: jest.fn().mockResolvedValue(undefined), attachRetry: jest.fn().mockResolvedValue(undefined), findBySentEmail: jest.fn().mockResolvedValue(null), @@ -389,6 +390,31 @@ describe('MailLargeFilesService — 메일과 대용량 첨부 (11단계)', () = FILE_B, ]); }); + + it('예약 메일을 지금 보냈으면(15단계) 그 링크의 보낸 때를 앞당긴다 — 링크가 없거나 취소한 링크면 그대로, 실패해도 던지지 않는다', async () => { + const { service, sharing } = setup(); + const now = new Date('2026-11-09T05:00:00.000Z'); + + sharing.findBySentEmail.mockResolvedValue(record()); + await service.onScheduledSendReleased(ARA, 'sent-1', now); + expect(sharing.findBySentEmail).toHaveBeenCalledWith(ARA.email, 'sent-1'); + expect(sharing.markReleased).toHaveBeenCalledWith('link-1', now); + + sharing.markReleased.mockClear(); + for (const found of [ + null, + record({ canceledAt: new Date('2026-11-09T04:00:00.000Z') }), + ]) { + sharing.findBySentEmail.mockResolvedValueOnce(found); + await service.onScheduledSendReleased(ARA, 'sent-1', now); + } + expect(sharing.markReleased).not.toHaveBeenCalled(); + + sharing.findBySentEmail.mockRejectedValueOnce(new Error('db down')); + await expect( + service.onScheduledSendReleased(ARA, 'sent-1', now), + ).resolves.toBeUndefined(); + }); }); describe('읽기 — 덩이 알아보기', () => { diff --git a/backend/src/modules/mail/mail-large-files.service.ts b/backend/src/modules/mail/mail-large-files.service.ts index d046515..9cb905f 100644 --- a/backend/src/modules/mail/mail-large-files.service.ts +++ b/backend/src/modules/mail/mail-large-files.service.ts @@ -303,6 +303,29 @@ export class MailLargeFilesService implements OnModuleInit, OnModuleDestroy { } } + /** + * 예약 메일을 지금 보냈다(15단계 — 엔진에서 다시 제출한 뒤) — 그 발송이 만든 링크의 보낸 때를 실제로 보낸 때로 앞당긴다. + * 끝나는 때는 그대로 둔다 — 보낸 메일의 덩이가 그 날짜(예약 시각 기준)를 이미 적었다(FileSharingService.markReleased). 링크가 없거나 취소한 링크면 그대로. + * 실패해도 보내기는 된 것이다 — 경고만 + */ + async onScheduledSendReleased( + user: AuthenticatedUser, + sentId: string, + sentAt: Date, + ): Promise { + try { + const record = await this.sharing.findBySentEmail(user.email, sentId); + if (!record || record.link.canceledAt !== null) return; + await this.sharing.markReleased(record.link.id, sentAt); + } catch (error) { + this.warn( + '지금 보낸 예약 메일의 링크에 보낸 때를 적지 못했습니다', + user, + error, + ); + } + } + // ── 임시 저장 ──────────────────────────────────────────────────────────── /** 저장할 파일 확인 — 내 것이면 올리는 중 · 위험 판정이어도 된다. 없음 · 남의 것 · 지움 · 기간 지남은 400 MAIL_016 */ diff --git a/backend/src/modules/mail/mail.constants.ts b/backend/src/modules/mail/mail.constants.ts index a412474..68730ee 100644 --- a/backend/src/modules/mail/mail.constants.ts +++ b/backend/src/modules/mail/mail.constants.ts @@ -151,6 +151,9 @@ export const SCHEDULED_SEND_MAX_LEAD_MS = 7 * 24 * 60 * 60_000; /** 보낸 사람 차단 라우트 경로 (`/api/mail-blocks`) — 계약 2절 "보낸 사람 차단" (9단계) */ export const MAIL_BLOCKS_ROUTE = 'mail-blocks'; +/** 차단할 때 받은편지함 정리 라우트 경로 (`/api/mail-block-runs`) — 계약 2절 "보낸 사람 차단" (15단계) */ +export const MAIL_BLOCK_RUNS_ROUTE = 'mail-block-runs'; + /** 차단할 수 있는 주소 · 도메인 수 — 넘으면 400 VAL_001 (계약 2절 "보낸 사람 차단") */ export const MAX_MAIL_BLOCKS = 500; diff --git a/backend/src/modules/mail/mail.module.ts b/backend/src/modules/mail/mail.module.ts index e2b6c7e..f2d723a 100644 --- a/backend/src/modules/mail/mail.module.ts +++ b/backend/src/modules/mail/mail.module.ts @@ -34,7 +34,10 @@ import { JmapFilterRunner } from './jmap/jmap-filter-run'; import { JmapFilterScriptStore } from './jmap/jmap-filter-script'; import { JmapMailProvider } from './jmap/jmap-mail.provider'; import { MailAttachmentController } from './mail-attachment.controller'; -import { MailBlockController } from './mail-block.controller'; +import { + MailBlockController, + MailBlockRunController, +} from './mail-block.controller'; import { MailBlockService } from './mail-block.service'; import { MailEventsController } from './mail-events.controller'; import { @@ -148,6 +151,8 @@ function attachmentUploadOptions(config: ConfigService): MulterModuleOptions { MailFilterController, MailFilterRunController, MailBlockController, + // 차단할 때 받은편지함 정리 (15단계 — 필터 적용과 같은 잠금) + MailBlockRunController, MessageController, // 같은 대화 · 대화로 묶은 목록 (14단계) ConversationController, diff --git a/backend/src/modules/mail/mail.provider.ts b/backend/src/modules/mail/mail.provider.ts index ec33dcf..c3e971d 100644 --- a/backend/src/modules/mail/mail.provider.ts +++ b/backend/src/modules/mail/mail.provider.ts @@ -13,9 +13,12 @@ import type { MailAttachment, MailBlockAdded, MailBlockList, + MailBlockRunRequest, Mailbox, MailboxBulkResult, + MailboxChange, MailboxCreate, + MailboxDeleteOptions, MailboxDeletion, MailFilterRunContext, MailFilterRunOutcome, @@ -40,7 +43,9 @@ import type { PasswordChangeOutcome, RecipientFields, RejectedRecipient, + ReleasedScheduledSend, SavedDraft, + SendHold, SentMessage, SentMessageRecord, SummariesWithHeld, @@ -68,6 +73,14 @@ export const MAIL_PROVIDER = Symbol('MAIL_PROVIDER'); */ export class SendNotStartedException extends BusinessException {} +/** + * 예약 메일 지금 보내기(15단계)가 끝내 보내지 못해 그 메일을 임시보관함의 초안으로 **되돌렸다** — 예약 취소(DELETE /api/scheduled-messages/{id})와 같은 자리다. + * 되돌리기까지 된 때만 이 형식이다(엔진이 다시 제출을 거듭 거절함 MAIL_001 · 받는 사람이 모두 받지 않음 MAIL_010 · MAIL_011). 코드 · 응답은 BusinessException 그대로다. + * 결과를 모르는 실패 · 남은 시간 모자람 · 되돌리기 실패는 그냥 BusinessException(MAIL_001)이다 — 메일은 보낸편지함에 그대로다. + * 서비스는 이 형식으로 갈라 대용량 첨부 링크를 취소한 발송처럼 둔다(되돌린 초안이 그 파일을 쓴다) — context 는 로그 전용이라 갈래에 쓰지 않는다 + */ +export class ScheduledSendReturnedToDraftsException extends BusinessException {} + /** * 그 직원의 비밀번호가 만료됐다 (13단계 — 계약 6절 "비밀번호 바꾸기 강제") — 엔진은 그 계정의 권한을 비밀번호 바꾸기만으로 줄여 메일 기능이 없다 * (본인 Basic · 게이트웨이 대리 접근 모두 — 2026-11-02 개발 엔진 확인). 프로필 · 메일 호출(엔진 세션을 열거나 메일 메서드가 forbidden)이 이 예외로 던진다. @@ -148,16 +161,30 @@ export interface MailProvider { */ createMailbox(user: string, input: MailboxCreate): Promise; - /** 메일함 이름을 바꾼다 — 없으면 RES_001, 역할이 있는 메일함(기본 메일함)이면 VAL_001, 같은 이름이 있으면 MAIL_009 */ - renameMailbox(user: string, id: string, name: string): Promise; + /** + * 메일함의 이름 · 상위를 바꾼다 (15단계 — 옮기기) — 보내지 않은 칸은 그대로. 없으면 RES_001, 역할이 있는 메일함(기본 메일함)이면 VAL_001, + * 새 상위가 없으면 RES_001 · 만들 수 없는 자리(만들기와 같다)이거나 자기 자신 · 자기 하위면 VAL_001, 새 자리에 같은 이름이 있으면 MAIL_009. + * 이름 · 상위가 지금과 같으면 엔진에 묻지 않고 그대로 돌려준다. 하위 메일함과 안의 메일은 함께 간다(메일함 id 는 그대로다) + */ + updateMailbox( + user: string, + id: string, + change: MailboxChange, + ): Promise; /** * 메일함을 지운다 — 안의 메일을 휴지통으로 옮긴 뒤(다른 메일함에도 있는 메일은 이 메일함에서만 뺀다) 빈 메일함을 지운다. * 메일을 영구 삭제하지 않는다. 한 요청에 다 옮기지 못하면 deleted: false (클라이언트가 다시 보낸다). * 없으면 RES_001, 역할이 있는 메일함 · 하위 메일함이 있으면 VAL_001. + * `withChildren` 이면(15단계) 하위가 있어도 나무 전체(그 메일함과 하위 모두)를 같은 규칙으로 — 메일을 옮긴 뒤 깊은 것부터 지운다. + * 하위에 역할이 있는 메일함이 있으면 VAL_001. 수(processed · movedToTrash · remaining)는 나무 전체의 것이다. * 붙잡힌 메일(보내기 취소 · 예약을 기다림 — 9단계)은 옮기지 않는다 — 남은 것이 그것뿐이고 이 요청에서 옮긴 것이 없으면 MAIL_015 */ - deleteMailbox(user: string, id: string): Promise; + deleteMailbox( + user: string, + id: string, + options?: MailboxDeleteOptions, + ): Promise; /** 메일함의 안 읽은 메일을 읽음으로 바꾼다 (한 요청의 시간 안에서 차례로) — 없으면 RES_001 */ markMailboxRead(user: string, id: string): Promise; @@ -224,6 +251,18 @@ export interface MailProvider { context: MailFilterRunContext, ): Promise; + /** + * 차단할 때 받은편지함 정리 (계약 2절 "보낸 사람 차단" — 15단계) — 받은편지함에 이미 있는 그 보낸 사람의 메일을 스팸메일함으로 옮기고 스팸 표시를 한다. + * 판정은 새로 오는 메일의 차단 규칙(Sieve 의 `address` 판정 — mail-block-match.ts)과 같다. 차례 · 시간 · cursor 는 runMailFilter 와 같은 규칙이다 + * (matched = 스팸메일함으로 옮긴 수). 스팸메일함이 없으면 MAIL_001, cursor 가 깨졌거나 다른 보낸 사람의 것이면 엔진에 묻기 전에 VAL_001. + * 차단 목록에 있는지는 서비스가 본 값이다. 받은편지함 · 스팸메일함은 context.mailboxes 에서 찾는다 + */ + runMailBlock( + user: string, + request: MailBlockRunRequest, + context: MailFilterRunContext, + ): Promise; + /** 메일함 사용량과 한도 (한도가 없으면 limitBytes = null) */ getUsage(user: string): Promise; @@ -391,6 +430,25 @@ export interface MailProvider { */ cancelPendingSend(user: string, id: string): Promise; + /** + * 예약한 메일을 지금 보낸다 (계약 2절 "보내기 취소 · 예약 발송" — 15단계) — `id` 는 보낸 사본의 메일 id. + * 기다리는 예약 제출을 취소하고(엔진이 확인한 뒤에만) 같은 메일을 그 제출의 envelope 그대로 다시 제출한다 — `hold` 만큼 붙잡는다(보내기 취소 설정 — null 이면 바로). + * 예약 표시를 떼고, 받는 사람별 결과는 발송과 같은 규칙으로 읽는다. + * - 예약이 아닌 메일(보내기 취소를 기다리는 메일 · 취소해 임시보관함으로 간 메일 · 제출이 없음) · 없는 메일 → RES_001 (아무것도 바꾸지 않는다) + * - 이미 보냈다(예약 시각이 막 지남 — 제출이 final · 취소하려니 엔진이 받지 않음) → 오류가 아니다: pendingSend null (releasedAt null) + * - 이미 지금 보내기를 한 메일(취소한 예약 제출 + 그 뒤 새 제출 — 다시 누름 · 응답이 끊겨 다시 보냄) → 그 새 제출의 지금 상태 + * - 엔진이 다시 제출을 분명히 거절하면 한 번 더 — 그래도 거절이면 그 메일을 임시보관함으로 되돌린 뒤 ScheduledSendReturnedToDraftsException(MAIL_001). + * 받는 사람이 모두 받지 않았어도 임시보관함으로 되돌린 뒤 같은 예외(MAIL_010 · MAIL_011). 되돌리지 못하면 그냥 MAIL_001 + * - 다시 제출의 결과를 모르면(시간 초과 · 연결 끊김 · 5xx) 다시 내지도 · 되돌리지도 않는다 — 다시 읽어 새 제출이 보이면 그 상태, 아니면 그냥 MAIL_001. + * 남은 시간이 모자라 내지 않았어도 그냥 MAIL_001 — 둘 다 메일은 예약이 취소된 채 보낸편지함에 있고, 같은 요청을 다시 받으면 이어 낸다 + * - 예약 제출의 envelope · 발신 ID 를 읽지 못하거나 엔진이 취소를 확인해 주지 않으면 MAIL_001 — 취소하지 않았거나 취소를 확인하지 못해 다시 제출하지 않았다 + */ + releaseScheduledSend( + user: string, + id: string, + hold: SendHold | null, + ): Promise; + /** * 이미 보낸 메일의 받지 않은 사람 (같은 키의 재시도 — 계약 2절 "받는 사람별 결과") — 그 메일의 제출 기록을 * 이 요청의 받는 사람(to · cc · bcc)과 맞춰 다시 만든다. 제출 기록이 없거나 읽지 못하면 빈 배열 — 던지지 않는다 (메일은 이미 보냈다) diff --git a/backend/src/modules/mail/mail.service.spec.ts b/backend/src/modules/mail/mail.service.spec.ts index 5d44722..32982cc 100644 --- a/backend/src/modules/mail/mail.service.spec.ts +++ b/backend/src/modules/mail/mail.service.spec.ts @@ -9,6 +9,7 @@ import type { MailUserSettingsService } from '@/modules/mail-user-settings/mail- import type { MailUserSettings } from '@/modules/mail-user-settings/mail-user-settings.types'; import type { ContactsService } from '@/modules/contacts/contacts.service'; import type { DirectoryService } from '@/modules/contacts/directory.service'; +import type { MailBlockRunRequestDto } from './dto/mail-block-run.dto'; import type { MailFilterUpdateDto } from './dto/mail-filter.dto'; import type { MailFilterRunRequestDto } from './dto/mail-filter-run.dto'; import type { DraftBodyDto, SendMessageDto } from './dto/send-message.dto'; @@ -82,7 +83,7 @@ function fakeProvider(): ProviderMocks { getProfile: mockOf<'getProfile'>(), listMailboxes: mockOf<'listMailboxes'>(), createMailbox: mockOf<'createMailbox'>(), - renameMailbox: mockOf<'renameMailbox'>(), + updateMailbox: mockOf<'updateMailbox'>(), deleteMailbox: mockOf<'deleteMailbox'>(), markMailboxRead: mockOf<'markMailboxRead'>(), emptyMailbox: mockOf<'emptyMailbox'>(), @@ -91,6 +92,7 @@ function fakeProvider(): ProviderMocks { getMailFilters: mockOf<'getMailFilters'>(), setMailFilters: mockOf<'setMailFilters'>(), runMailFilter: mockOf<'runMailFilter'>(), + runMailBlock: mockOf<'runMailBlock'>(), openMessageSource: mockOf<'openMessageSource'>(), getMessageSubjects: mockOf<'getMessageSubjects'>(), getUsage: mockOf<'getUsage'>(), @@ -132,6 +134,7 @@ function fakeProvider(): ProviderMocks { removeMailBlock: mockOf<'removeMailBlock'>(), listScheduledMessages: mockOf<'listScheduledMessages'>(), cancelPendingSend: mockOf<'cancelPendingSend'>(), + releaseScheduledSend: mockOf<'releaseScheduledSend'>(), }; } @@ -544,7 +547,7 @@ describe('MailService', () => { it('메일함 만들기 — 상위가 없으면 맨 위(null)로 넘기고, 이름 바꾸기와 함께 표시 규칙을 거친 메일함을 돌려준다', async () => { provider.createMailbox.mockResolvedValue(mailbox); - provider.renameMailbox.mockResolvedValue({ + provider.updateMailbox.mockResolvedValue({ ...mailbox, id: 'a', name: 'Inbox', @@ -562,13 +565,34 @@ describe('MailService', () => { // (어댑터가 막지만) 역할이 있는 메일함은 한국어 표준 이름으로 보인다 await expect( - service.renameMailbox(user, 'a', { name: '새 이름' }), + service.updateMailbox(user, 'a', { name: '새 이름' }), ).resolves.toMatchObject({ name: '받은편지함' }); - expect(provider.renameMailbox).toHaveBeenCalledWith( - user.email, - 'a', - '새 이름', - ); + expect(provider.updateMailbox).toHaveBeenCalledWith(user.email, 'a', { + name: '새 이름', + }); + }); + + it('메일함 바꾸기(15단계) — 이름 · 상위 가운데 보낸 것만 넘긴다, 둘 다 없으면 엔진에 묻지 않고 VAL_001', async () => { + provider.updateMailbox.mockResolvedValue(mailbox); + + expect( + await failureOf(() => service.updateMailbox(user, 'n1', {})), + ).toEqual({ + code: 'VAL_001', + reason: 'empty-mailbox-update', + }); + expect(provider.updateMailbox).not.toHaveBeenCalled(); + + await service.updateMailbox(user, 'n1', { parentId: null }); + await service.updateMailbox(user, 'n1', { parentId: 'u1' }); + await service.updateMailbox(user, 'n1', { name: '2025', parentId: 'u1' }); + expect(provider.updateMailbox.mock.calls).toEqual([ + [user.email, 'n1', { parentId: null }], + [user.email, 'n1', { parentId: 'u1' }], + [user.email, 'n1', { name: '2025', parentId: 'u1' }], + ]); + // 옮긴 로그는 어댑터가 실제로 옮겼을 때만 남긴다 (지금 자리 그대로면 남기지 않게 — jmap-mail.provider.spec.ts) + expect(logSpy).not.toHaveBeenCalled(); }); it('메일함 지우기 — 지웠을 때만 개수를 남긴다 (메일함 이름 · 주소 없이)', async () => { @@ -598,6 +622,37 @@ describe('MailService', () => { expect(line).toContain('휴지통으로 옮김=7통'); expect(line).toContain('이 메일함에서만 뺌=2통'); expect(line).not.toContain(user.email); + // 하위까지가 아니면 메일함 목록을 읽지 않고, 한 요청의 시간은 서비스가 받은 때부터 (startedAt) + expect(provider.listMailboxes).not.toHaveBeenCalled(); + expect(provider.deleteMailbox).toHaveBeenLastCalledWith( + user.email, + 'u4', + { + startedAt: expect.any(Number) as number, + }, + ); + }); + + it('하위 메일함까지 지우기(15단계 — withChildren=true)는 메일함 목록으로 나무를 정해 넘긴다', async () => { + provider.listMailboxes.mockResolvedValue([ + { ...mailbox, id: 'u1' }, + { ...mailbox, id: 'u2', parentId: 'u1' }, + { ...mailbox, id: 'u3' }, + ]); + provider.deleteMailbox.mockResolvedValue({ + deleted: true, + processed: 3, + movedToTrash: 3, + remaining: 0, + }); + + await service.deleteMailbox(user, 'u1', { withChildren: 'true' }); + expect(provider.deleteMailbox).toHaveBeenCalledWith(user.email, 'u1', { + withChildren: true, + startedAt: expect.any(Number) as number, + }); + const [line] = logSpy.mock.calls.at(-1) as [string]; + expect(line).toContain('하위까지(메일함 2개)'); }); it('전체 읽음은 { isRead: true } 를 어댑터의 전체 읽음으로 — 로그를 남기지 않는다', async () => { @@ -712,6 +767,103 @@ describe('MailService', () => { provider.emptyMailbox.mockResolvedValueOnce(emptied); await expect(service.emptyMailbox(user, 'b')).resolves.toEqual(emptied); }); + + it('하위까지 지우기는 나무의 메일함 모두를 잡는다 — 하위 메일함의 처리 · 그 안으로 옮기기는 REQ_001, 나무 밖은 된다 (15단계)', async () => { + const deletion = { + deleted: true, + processed: 0, + movedToTrash: 0, + remaining: 0, + }; + const first = pending(); + provider.listMailboxes.mockResolvedValue([ + { ...mailbox, id: 'u1' }, + { ...mailbox, id: 'u2', parentId: 'u1' }, + { ...mailbox, id: 'u3' }, + ]); + provider.deleteMailbox.mockReturnValueOnce(first.promise); + provider.markMailboxRead.mockResolvedValue({ + processed: 0, + remaining: 0, + }); + provider.updateMailbox.mockResolvedValue(mailbox); + + const running = service.deleteMailbox(user, 'u1', { + withChildren: 'true', + }); + // 메일함 목록(엔진)을 지나 어댑터에 들어갈 때까지 기다린다 + await new Promise((resolve) => setImmediate(resolve)); + expect(provider.deleteMailbox).toHaveBeenCalledTimes(1); + + for (const attempt of [ + () => service.updateMailboxMessages(user, 'u2', { isRead: true }), + () => service.emptyMailbox(user, 'u2'), + // 지우는 나무 안으로 옮기기 · 지우는 나무의 메일함을 빼 가기 + () => service.updateMailbox(user, 'u3', { parentId: 'u2' }), + () => service.updateMailbox(user, 'u2', { parentId: null }), + ]) { + expect(await failureOf(attempt)).toEqual({ + code: 'REQ_001', + reason: 'mailbox-operation-in-progress', + }); + } + expect(provider.updateMailbox).not.toHaveBeenCalled(); + + // 나무 밖은 된다 — 이름만 바꾸기는 잠그지 않는다(지금처럼) + await service.updateMailboxMessages(user, 'u3', { isRead: true }); + await service.updateMailbox(user, 'u2', { name: '새 이름' }); + expect(provider.updateMailbox).toHaveBeenCalledTimes(1); + + first.finish(deletion); + await running; + await service.updateMailbox(user, 'u3', { parentId: 'u1' }); + expect(provider.updateMailbox).toHaveBeenCalledTimes(2); + }); + + it('하위까지 지우기는 그 메일함을 먼저 잡는다 — 그 메일함이 처리 중이면 목록을 읽지 않고 REQ_001, 하위가 처리 중이면 목록만 읽고 REQ_001 (15단계)', async () => { + provider.listMailboxes.mockResolvedValue([ + { ...mailbox, id: 'u1' }, + { ...mailbox, id: 'u2', parentId: 'u1' }, + ]); + const busy = pending<{ processed: number; remaining: number }>(); + provider.markMailboxRead.mockReturnValueOnce(busy.promise); + + // 그 메일함이 처리 중 — 엔진에 묻지 않는다 + const rootBusy = service.updateMailboxMessages(user, 'u1', { + isRead: true, + }); + expect( + await failureOf(() => + service.deleteMailbox(user, 'u1', { withChildren: 'true' }), + ), + ).toEqual({ code: 'REQ_001', reason: 'mailbox-operation-in-progress' }); + expect(provider.listMailboxes).not.toHaveBeenCalled(); + busy.finish({ processed: 0, remaining: 0 }); + await rootBusy; + + // 하위가 처리 중 — 목록만 읽고 지우지 않는다, 그 메일함의 잠금은 푼다 + const child = pending<{ processed: number; remaining: number }>(); + provider.markMailboxRead.mockReturnValueOnce(child.promise); + const childBusy = service.updateMailboxMessages(user, 'u2', { + isRead: true, + }); + expect( + await failureOf(() => + service.deleteMailbox(user, 'u1', { withChildren: 'true' }), + ), + ).toEqual({ code: 'REQ_001', reason: 'mailbox-operation-in-progress' }); + expect(provider.listMailboxes).toHaveBeenCalledTimes(1); + expect(provider.deleteMailbox).not.toHaveBeenCalled(); + provider.markMailboxRead.mockResolvedValueOnce({ + processed: 0, + remaining: 0, + }); + await expect( + service.updateMailboxMessages(user, 'u1', { isRead: true }), + ).resolves.toEqual({ processed: 0, remaining: 0 }); + child.finish({ processed: 0, remaining: 0 }); + await childBusy; + }); }); describe('부재중 자동 응답 저장', () => { @@ -1456,6 +1608,204 @@ describe('MailService', () => { }); }); + describe('차단할 때 받은편지함 정리 (15단계)', () => { + const outcome = { + scanned: 500, + matched: 12, + remaining: 40, + cursor: 'next-cursor', + rounds: 1, + }; + const blocked = (address: string) => ({ + address, + blockedAt: '2026-11-09T01:00:00.000Z', + }); + const mailboxes: Mailbox[] = [ + { + id: 'a', + name: 'Inbox', + role: 'inbox', + parentId: null, + sortOrder: 0, + totalCount: 540, + unreadCount: 0, + }, + { + id: 'c', + name: 'Junk Mail', + role: 'junk', + parentId: null, + sortOrder: 0, + totalCount: 0, + unreadCount: 0, + }, + ]; + const body = ( + sender: string, + cursor: string | null = null, + ): MailBlockRunRequestDto => ({ sender, cursor }); + + /** 손으로 끝내는 정리 — 끝낼 때까지 진행 중이다 */ + function pending(): { + promise: Promise; + finish: (value: T) => void; + } { + let finish!: (value: T) => void; + const promise = new Promise((resolve) => { + finish = resolve; + }); + return { promise, finish }; + } + + beforeEach(() => { + provider.getMailBlocks.mockResolvedValue({ + senders: [blocked('kim@spam.example'), blocked('@spam.example')], + isActive: true, + }); + provider.listMailboxes.mockResolvedValue(mailboxes); + provider.runMailBlock.mockResolvedValue(outcome); + }); + + it('차단 목록에 있는 보낸 사람이면 메일함 목록과 함께 넘기고 결과를 돌려준다 — 대소문자 · 앞뒤 공백은 맞춘다, 로그에는 가린 주소 · 수만', async () => { + await expect( + service.runMailBlock(user, body(' Kim@Spam.Example ')), + ).resolves.toEqual({ + scanned: 500, + matched: 12, + remaining: 40, + cursor: 'next-cursor', + }); + expect(provider.runMailBlock).toHaveBeenCalledWith( + user.email, + { sender: 'kim@spam.example', cursor: null }, + { mailboxes, startedAt: expect.any(Number) as number }, + ); + const [line] = logSpy.mock.calls.at(-1) as [string]; + expect(line).toContain('확인 500통 · 옮김 12통 · 남음 40통'); + expect(line).not.toContain('kim@spam.example'); + expect(line).not.toContain('next-cursor'); + expect(line).not.toContain(user.email); + + // 도메인 차단은 도메인만 남긴다 + await service.runMailBlock(user, body('@spam.example', 'next-cursor')); + expect(provider.runMailBlock).toHaveBeenLastCalledWith( + user.email, + { sender: '@spam.example', cursor: 'next-cursor' }, + expect.any(Object), + ); + const [domainLine] = logSpy.mock.calls.at(-1) as [string]; + expect(domainLine).toContain('도메인=@spam.example'); + }); + + it('차단 목록에 없으면 RES_001 — 받은편지함을 읽지 않는다 · 모양이 틀리면 VAL_001 (엔진에 묻지 않는다)', async () => { + expect( + await failureOf(() => + service.runMailBlock(user, body('lee@spam.example')), + ), + ).toEqual({ code: 'RES_001', reason: 'mail-block-not-found' }); + expect(provider.listMailboxes).not.toHaveBeenCalled(); + expect(provider.runMailBlock).not.toHaveBeenCalled(); + + provider.getMailBlocks.mockClear(); + expect( + await failureOf(() => + service.runMailBlock(user, body('not an address')), + ), + ).toEqual({ code: 'VAL_001', reason: 'malformed-mail-block-run-sender' }); + expect(provider.getMailBlocks).not.toHaveBeenCalled(); + }); + + it('차단 정리가 도는 동안 그 직원의 스팸메일함을 잡는다 — 비우기 · 전체 읽음은 REQ_001, 비우는 중이면 차단 정리가 목록을 읽은 뒤 REQ_001 (받은편지함 정리 잠금도 푼다)', async () => { + const first = pending(); + provider.runMailBlock.mockReturnValueOnce(first.promise); + provider.emptyMailbox.mockResolvedValue({ processed: 0, remaining: 0 }); + provider.markMailboxRead.mockResolvedValue({ + processed: 0, + remaining: 0, + }); + + const running = service.runMailBlock(user, body('kim@spam.example')); + await new Promise((resolve) => setImmediate(resolve)); + expect(provider.runMailBlock).toHaveBeenCalledTimes(1); + for (const attempt of [ + () => service.emptyMailbox(user, 'c'), + () => service.updateMailboxMessages(user, 'c', { isRead: true }), + ]) { + expect(await failureOf(attempt)).toEqual({ + code: 'REQ_001', + reason: 'mailbox-operation-in-progress', + }); + } + expect(provider.emptyMailbox).not.toHaveBeenCalled(); + // 다른 메일함은 된다 + await expect( + service.updateMailboxMessages(user, 'b', { isRead: true }), + ).resolves.toEqual({ processed: 0, remaining: 0 }); + first.finish(outcome); + await running; + + // 스팸메일함을 비우는 중이면 차단 정리가 REQ_001 — 받은편지함을 건드리지 않는다 + const emptying = pending<{ processed: number; remaining: number }>(); + provider.emptyMailbox.mockReturnValueOnce(emptying.promise); + const empty = service.emptyMailbox(user, 'c'); + provider.runMailBlock.mockClear(); + expect( + await failureOf(() => + service.runMailBlock(user, body('kim@spam.example')), + ), + ).toEqual({ code: 'REQ_001', reason: 'mailbox-operation-in-progress' }); + expect(provider.runMailBlock).not.toHaveBeenCalled(); + // 받은편지함 정리 잠금(filterRuns)은 풀렸다 — 필터 적용은 된다 + provider.runMailFilter.mockResolvedValueOnce(outcome); + await expect( + service.runMailFilter(user, { + match: 'always', + conditions: [], + actions: { moveToMailboxId: null, markAsRead: true, flag: false }, + cursor: null, + } as unknown as MailFilterRunRequestDto), + ).resolves.toMatchObject({ matched: 12 }); + emptying.finish({ processed: 0, remaining: 0 }); + await empty; + await expect( + service.runMailBlock(user, body('kim@spam.example')), + ).resolves.toMatchObject({ matched: 12 }); + }); + + it('같은 직원의 받은편지함 정리(필터 적용 · 차단 정리)는 한 번에 하나 — 진행 중이면 서로 REQ_001', async () => { + const first = pending(); + provider.runMailBlock.mockReturnValueOnce(first.promise); + provider.runMailFilter.mockResolvedValue(outcome); + + const running = service.runMailBlock(user, body('kim@spam.example')); + await new Promise((resolve) => setImmediate(resolve)); + expect(provider.runMailBlock).toHaveBeenCalledTimes(1); + + expect( + await failureOf(() => + service.runMailBlock(user, body('@spam.example')), + ), + ).toMatchObject({ code: 'REQ_001' }); + expect( + await failureOf(() => + service.runMailFilter(user, { + match: 'always', + conditions: [], + actions: { moveToMailboxId: null, markAsRead: true, flag: false }, + cursor: null, + } as unknown as MailFilterRunRequestDto), + ), + ).toMatchObject({ code: 'REQ_001' }); + expect(provider.runMailFilter).not.toHaveBeenCalled(); + + first.finish(outcome); + await running; + await expect( + service.runMailBlock(user, body('kim@spam.example')), + ).resolves.toMatchObject({ matched: 12 }); + }); + }); + describe('부재중 자동 응답 읽기 (7단계 — isActive)', () => { it('어댑터 그대로 — 저장한 값과 엔진에서 동작하는지', async () => { const state = { diff --git a/backend/src/modules/mail/mail.service.ts b/backend/src/modules/mail/mail.service.ts index 40ef947..3f92fe5 100644 --- a/backend/src/modules/mail/mail.service.ts +++ b/backend/src/modules/mail/mail.service.ts @@ -30,13 +30,16 @@ import type { MessageDeletionDto, UpdateMessagesDto, } from './dto/bulk-messages.dto'; +import type { MailBlockRunRequestDto } from './dto/mail-block-run.dto'; import type { MailFilterUpdateDto } from './dto/mail-filter.dto'; import type { MailFilterRunRequestDto } from './dto/mail-filter-run.dto'; import type { MailVacationDto } from './dto/mail-vacation.dto'; -import type { - CreateMailboxDto, - MailboxMessagesPatchDto, - RenameMailboxDto, +import { + WITH_CHILDREN, + type CreateMailboxDto, + type MailboxDeleteQueryDto, + type MailboxMessagesPatchDto, + type UpdateMailboxDto, } from './dto/mailbox.dto'; import { SHOW_IMAGES, @@ -80,6 +83,7 @@ import { SCHEDULED_SEND_MAX_LEAD_MS, SCHEDULED_SEND_MIN_LEAD_MS, } from './mail.constants'; +import { describeBlockTarget, parseBlockTarget } from './mail-blocks'; import { findFilterRulesProblem, findFilterRunProblem } from './mail-filters'; import { MAIL_PROVIDER, type MailProvider } from './mail.provider'; import { MAIL_SETTINGS, type MailSettings } from './mail.settings'; @@ -118,6 +122,7 @@ import type { SentMessage, } from './mail.types'; import { presentMailbox, presentMailboxes } from './mailbox.presenter'; +import { subtreeIds } from './mailbox-tree'; import { highlightDetail } from './message-highlight'; import { RecipientSuggestionService } from './recipient-suggestions.service'; import { @@ -131,6 +136,7 @@ import { type LargeFilePlan, type RecognizedLargeFiles, } from './mail-large-files.service'; +import { undoSendHold } from './send-hold'; /** * 엔진 장애(MAIL_001)로 끝난 발송의 키를 잠가 두는 시간. @@ -336,13 +342,15 @@ export class MailService { /** * 진행 중인 오래 걸리는 처리(전체 읽음 · 비우기 · 지우기)의 (사용자, 메일함) — 같은 메일함에는 하나씩만, 종류가 달라도 막는다. * 두 탭이 같은 휴지통을 비우면 서로 상태가 어긋나 둘 다 멈추고, 동시 요청이 많으면 엔진 호출이 그 수만큼 곱절로 는다. + * 15단계부터 하위까지 지우기는 나무의 메일함 모두를, 옮기기는 그 메일함과 새 상위를, 차단 정리는 그 직원의 스팸메일함을 잡는다. * ⚠️ 프로세스 메모리다. 인스턴스를 늘리면 Redis 잠금(SET NX PX)으로 옮겨야 인스턴스 사이에서도 하나씩이 된다. */ private readonly mailboxOperations = new Set(); /** - * 진행 중인 "이미 받은 메일에 필터 적용"의 직원 — 한 직원에 하나씩만 (계약 2절 "이미 받은 메일에 적용" — 다른 탭 · PC 에서 함께 하면 429 REQ_001). - * 두 적용이 같은 받은편지함을 함께 읽으면 서로 옮긴 메일 때문에 자리가 어긋나고 엔진 호출이 그 수만큼 곱절로 는다. + * 진행 중인 받은편지함 정리 — "이미 받은 메일에 필터 적용"(7단계)과 차단할 때 받은편지함 정리(15단계)의 직원. 한 직원에 하나씩만, 종류가 달라도 막는다 + * (계약 2절 "이미 받은 메일에 적용" · "보낸 사람 차단" — 다른 탭 · PC 에서 함께 하면 429 REQ_001). + * 두 정리가 같은 받은편지함을 함께 읽으면 서로 옮긴 메일 때문에 자리가 어긋나고 엔진 호출이 그 수만큼 곱절로 는다. * ⚠️ 프로세스 메모리다. 인스턴스를 늘리면 Redis 잠금(SET NX PX)으로 옮겨야 인스턴스 사이에서도 하나씩이 된다. */ private readonly filterRuns = new Set(); @@ -415,32 +423,75 @@ export class MailService { ); } - /** 메일함 이름 바꾸기 — 기본 메일함(역할 있음)은 VAL_001 */ - async renameMailbox( + /** + * 메일함 이름 바꾸기 · 옮기기 (15단계) — 이름 · 상위 가운데 보낸 것만 바꾼다. 둘 다 없으면 엔진에 묻지 않고 VAL_001. + * 기본 메일함(역할 있음) · 만들 수 없는 자리 · 자기 하위 아래는 VAL_001, 없는 새 상위는 RES_001, 같은 이름은 MAIL_009 (어댑터). + * 옮길 때(parentId 가 있을 때)는 그 메일함과 새 상위의 오래 걸리는 처리와 함께 하지 않는다 — 진행 중이면 REQ_001 (runExclusive — 지우는 나무 안으로 옮기거나 지우는 메일함을 빼 가지 않게). + * 옮긴 로그는 어댑터가 남긴다 — 실제로 옮겼을 때만(지금 자리 그대로면 엔진에 묻지 않아 남기지 않는다), 메일함 id 만 + */ + async updateMailbox( user: AuthenticatedUser, id: string, - dto: RenameMailboxDto, + dto: UpdateMailboxDto, ): Promise { + const { name, parentId } = dto; + if (name === undefined && parentId === undefined) { + throw new BusinessException('VAL_001', { + context: { reason: 'empty-mailbox-update' }, + }); + } + if (parentId === undefined) { + return presentMailbox( + await this.provider.updateMailbox(user.email, id, { name }), + ); + } + + const keys = parentId === null ? [id] : [id, parentId]; return presentMailbox( - await this.provider.renameMailbox(user.email, id, dto.name), + await this.runExclusive(user, keys, () => + this.provider.updateMailbox(user.email, id, { + ...(name === undefined ? {} : { name }), + parentId, + }), + ), ); } /** * 메일함 지우기 — 안의 메일을 휴지통으로 옮긴 뒤 지운다(영구 삭제하지 않는다). 한 요청에 끝나지 않으면 deleted: false. - * 같은 메일함의 오래 걸리는 처리는 하나씩만 (진행 중이면 REQ_001 — runExclusive). + * 같은 메일함의 오래 걸리는 처리는 하나씩만 (진행 중이면 엔진에 묻지 않고 REQ_001 — runExclusive). 한 요청의 시간(25초)은 여기 들어온 때부터 센다. + * 하위 메일함까지 지우기(withChildren=true — 15단계)는 나무(그 메일함과 하위 모두)를 지운다 — 그 메일함을 먼저 잡고(진행 중이면 엔진에 묻지 않고 REQ_001) + * 메일함 목록을 읽어 하위 메일함까지 잡는다(하위가 처리 중이면 목록만 읽고 REQ_001 — 아무것도 바꾸지 않는다). * 지웠으면 info 로그에 개수만 남긴다 (메일함 이름 없이 — 사용자가 정한 글이다) */ async deleteMailbox( user: AuthenticatedUser, id: string, + query: MailboxDeleteQueryDto = {}, ): Promise { - const result = await this.runExclusive(user, id, () => - this.provider.deleteMailbox(user.email, id), - ); + const startedAt = Date.now(); + const withChildren = query.withChildren === WITH_CHILDREN; + let treeSize = 1; + const result = await this.runExclusive(user, [id], async () => { + if (!withChildren) { + return this.provider.deleteMailbox(user.email, id, { startedAt }); + } + // 나무의 하위 메일함 — 없는 메일함이면 없음(어댑터가 RES_001) + const descendants = subtreeIds( + await this.provider.listMailboxes(user.email), + id, + ).filter((mailboxId) => mailboxId !== id); + treeSize = descendants.length + 1; + return this.runExclusive(user, descendants, () => + this.provider.deleteMailbox(user.email, id, { + withChildren, + startedAt, + }), + ); + }); if (result.deleted) { this.logger.log( - `${traceTag()} 메일함을 지웠습니다: user=${maskEmail(user.email)}, 이번 요청에서 휴지통으로 옮김=${result.movedToTrash}통 · 이 메일함에서만 뺌=${result.processed - result.movedToTrash}통`, + `${traceTag()} 메일함을 지웠습니다: user=${maskEmail(user.email)}, ${withChildren ? `하위까지(메일함 ${treeSize}개), ` : ''}이번 요청에서 휴지통으로 옮김=${result.movedToTrash}통 · 이 메일함에서만 뺌=${result.processed - result.movedToTrash}통`, ); } return result; @@ -461,7 +512,7 @@ export class MailService { context: { reason: 'unsupported-mailbox-messages-change' }, }); } - return this.runExclusive(user, id, () => + return this.runExclusive(user, [id], () => this.provider.markMailboxRead(user.email, id), ); } @@ -474,7 +525,7 @@ export class MailService { user: AuthenticatedUser, id: string, ): Promise { - const result = await this.runExclusive(user, id, () => + const result = await this.runExclusive(user, [id], () => this.provider.emptyMailbox(user.email, id), ); if (result.processed > 0) { @@ -648,6 +699,61 @@ export class MailService { ); } + /** + * 차단할 때 받은편지함 정리 (계약 2절 "보낸 사람 차단" — 15단계) — 받은편지함에 이미 있는 그 보낸 사람의 메일을 스팸메일함으로 옮긴다. + * 이미 받은 메일에 필터 적용(7단계)과 같은 틀이다 — 한 요청의 시간(25초)은 여기 들어온 때부터, 이어 할 자리(cursor)는 어댑터가 만든다. + * - 보낸 사람은 차단 목록의 모양(주소 · `@도메인`)이어야 한다 — 아니면 엔진에 묻지 않고 VAL_001 + * - **같은 직원의 받은편지함 정리(필터 적용 · 이것)는 한 번에 하나** — 같은 잠금(filterRuns), 진행 중이면 엔진에 묻지 않고 REQ_001 + * - 차단 목록에 없으면(차단한 뒤에만 부른다 · 그사이 풀었다) RES_001 — 요청마다 본다(도중에 풀면 다음 요청에서 멈춘다) + * - 요청이 도는 동안 그 직원의 스팸메일함을 메일함 처리 잠금(mailboxOperations)으로 잡는다 — 옮겨 넣는 동안 스팸메일함 비우기 · 모두 읽음이 함께 돌지 않게. + * 비우는 중이면 메일함 목록을 읽은 뒤 REQ_001 (받은편지함은 건드리지 않았다) + * 로그에는 가린 보낸 사람(도메인 차단은 도메인만) · 확인 · 옮긴 수 · 차례 수 · 걸린 시간만 남긴다 + */ + async runMailBlock( + user: AuthenticatedUser, + dto: MailBlockRunRequestDto, + ): Promise { + const startedAt = Date.now(); + const target = parseBlockTarget(dto.sender); + if (target === null) { + throw new BusinessException('VAL_001', { + context: { reason: 'malformed-mail-block-run-sender' }, + }); + } + + return this.exclusive( + this.filterRuns, + user.email.toLowerCase(), + 'mail-block-run-in-progress', + user, + async () => { + const { senders } = await this.provider.getMailBlocks(user.email); + if (!senders.some(({ address }) => address === target.value)) { + throw new BusinessException('RES_001', { + context: { reason: 'mail-block-not-found', kind: target.kind }, + }); + } + const mailboxes = await this.provider.listMailboxes(user.email); + // 스팸메일함이 없으면 잡을 것이 없다 — 어댑터가 MAIL_001 로 멈춘다 + const junk = mailboxes.find((mailbox) => mailbox.role === 'junk'); + const { rounds, ...result } = await this.runExclusive( + user, + junk ? [junk.id] : [], + () => + this.provider.runMailBlock( + user.email, + { sender: target.value, cursor: dto.cursor }, + { mailboxes, startedAt }, + ), + ); + this.logger.log( + `${traceTag()} 차단한 보낸 사람의 메일을 받은편지함에서 스팸메일함으로 옮겼습니다: user=${maskEmail(user.email)}, ${describeBlockTarget(target)}, 확인 ${result.scanned}통 · 옮김 ${result.matched}통 · 남음 ${result.remaining}통${result.cursor === null ? '(끝)' : ''}, 차례 ${rounds}번, ${Date.now() - startedAt}ms`, + ); + return result; + }, + ); + } + /** 받은 그대로의 메일 원문 — 파일 이름은 제목(없으면 "메일") + .eml (attachment.policy.ts 의 messageSourceFileName) */ async openMessageSource( user: AuthenticatedUser, @@ -974,12 +1080,10 @@ export class MailService { ); } - /** 보내기 취소를 기다리는 붙잡기 — 그 직원 설정의 초(0 이면 붙잡지 않는다) */ + /** 보내기 취소를 기다리는 붙잡기 — 그 직원 설정의 초(0 이면 붙잡지 않는다 — send-hold.ts) */ private async undoHold(user: AuthenticatedUser): Promise { const { undoSendSeconds } = await this.userSettingsOf(user); - return undoSendSeconds > 0 - ? { kind: 'undo', seconds: undoSendSeconds } - : null; + return undoSendHold(undoSendSeconds); } /** 임시 저장 — 처음 저장 (계약 2절 "임시 저장") */ @@ -1091,15 +1195,19 @@ export class MailService { ); } - /** 같은 직원 · 같은 메일함의 오래 걸리는 처리를 하나씩만 한다 — 다른 메일함끼리는 동시에 된다 (exclusive) */ + /** + * 같은 직원 · 같은 메일함의 오래 걸리는 처리를 하나씩만 한다 — 다른 메일함끼리는 동시에 된다 (exclusive). + * 메일함 여럿(하위까지 지우기의 나무 · 옮기기의 그 메일함과 새 상위 — 15단계)은 모두 비어 있을 때만 한꺼번에 잡는다 + */ private runExclusive( user: AuthenticatedUser, - mailboxId: string, + mailboxIds: readonly string[], operation: () => Promise, ): Promise { + const owner = user.email.toLowerCase(); return this.exclusive( this.mailboxOperations, - `${user.email.toLowerCase()}\n${mailboxId}`, + mailboxIds.map((mailboxId) => `${owner}\n${mailboxId}`), 'mailbox-operation-in-progress', user, operation, @@ -1108,25 +1216,27 @@ export class MailService { /** * 같은 열쇠의 일을 하나씩만 한다 — 진행 중이면 엔진에 묻지 않고 바로 REQ_001 (로그인의 진행 중 시도와 같은 모양). + * 열쇠가 여럿이면 하나라도 잡혀 있을 때 REQ_001 이고, 모두 비었으면 await 없이 한꺼번에 잡는다(사이에 다른 요청이 끼지 않는다). * 끝나면 성공 · 실패와 상관없이 푼다 */ private async exclusive( running: Set, - key: string, + keys: string | readonly string[], reason: string, user: AuthenticatedUser, operation: () => Promise, ): Promise { - if (running.has(key)) { + const held = [...new Set(typeof keys === 'string' ? [keys] : keys)]; + if (held.some((key) => running.has(key))) { throw new BusinessException('REQ_001', { context: { reason, user: maskEmail(user.email) }, }); } - running.add(key); + for (const key of held) running.add(key); try { return await operation(); } finally { - running.delete(key); + for (const key of held) running.delete(key); } } diff --git a/backend/src/modules/mail/mail.types.ts b/backend/src/modules/mail/mail.types.ts index 3ba5a07..d9a4927 100644 --- a/backend/src/modules/mail/mail.types.ts +++ b/backend/src/modules/mail/mail.types.ts @@ -285,18 +285,38 @@ export interface MailboxCreate { readonly parentId: string | null; } +/** + * 메일함 바꾸기 (PATCH /api/mailboxes/{id} — 계약 2절 "메일함 관리") — 이름 · 상위(15단계 — 옮기기). 보내지 않은 칸은 그대로다. + * 서비스가 하나 이상인지 · 이름 형식을 본 값이다 + */ +export interface MailboxChange { + /** 앞뒤 공백을 뺀 1~64자, `/` 없음 — 없으면 이름은 그대로 */ + readonly name?: string; + /** 새 상위 메일함 — null 이면 맨 위, 없으면(undefined) 상위는 그대로 */ + readonly parentId?: string | null; +} + +/** 메일함 지우기의 방법 (DELETE /api/mailboxes/{id} — 15단계 "하위 메일함까지 지우기") */ +export interface MailboxDeleteOptions { + /** true 면 하위 메일함이 있어도 깊은 것부터 모두 지운다 — 없거나 false 면 하위가 있을 때 VAL_001 (지금처럼) */ + readonly withChildren?: boolean; + /** 요청이 서비스에 들어온 때(Date.now() 의 ms) — 한 요청의 시간(25초)을 이때부터 센다. 없으면 어댑터가 시작한 때부터 */ + readonly startedAt?: number; +} + /** * 메일함 지우기 결과 (DELETE /api/mailboxes/{id}) — 안의 메일을 휴지통으로 옮긴 뒤 빈 메일함을 지운다. * 한 요청에 다 옮기지 못하면 deleted: false — 클라이언트가 같은 요청을 다시 보낸다. + * 하위 메일함까지 지우면(15단계 — withChildren) 수는 나무 전체(그 메일함과 하위 모두)의 것이다 */ export interface MailboxDeletion { - /** true = 지웠다 */ + /** true = 지웠다 (하위까지 지울 때는 나무 전체를 지웠다) */ readonly deleted: boolean; - /** 이번 요청에서 이 메일함에서 빠진 메일 수 — 휴지통으로 옮긴 것 + 다른 메일함에도 있어 이 메일함에서만 뺀 것 (진행 수) */ + /** 이번 요청에서 이 메일함(나무)에서 빠진 메일 수 — 휴지통으로 옮긴 것 + 다른 메일함에도 있어 이 메일함(나무)에서만 뺀 것 (진행 수) */ readonly processed: number; /** 이번 요청에서 휴지통으로 옮긴 메일 수 (다른 메일함에도 있어 이 메일함에서만 뺀 메일은 세지 않는다) */ readonly movedToTrash: number; - /** 아직 이 메일함에 남은 메일 수 */ + /** 아직 이 메일함(나무)에 남은 메일 수 */ readonly remaining: number; } @@ -428,6 +448,18 @@ export interface MailFilterRunOutcome extends MailFilterRun { readonly rounds: number; } +/** + * 차단할 때 받은편지함 정리 (POST /api/mail-block-runs — 계약 2절 "보낸 사람 차단" · 15단계) — 서비스가 차단 목록에 있는지 본 값이다. + * 이미 받은 메일에 필터 적용(7단계)과 같은 틀이다 — 응답 · 서비스가 넘기는 것(MailFilterRunContext) · 어댑터의 결과(MailFilterRunOutcome)는 그것을 함께 쓴다 + * (matched 는 스팸메일함으로 옮긴 수) + */ +export interface MailBlockRunRequest { + /** 차단 목록의 값 — 소문자 주소 또는 `@도메인` (BlockedSender.address) */ + readonly sender: string; + /** 처음은 null — 이어 할 때는 앞 결과의 cursor 그대로 (어댑터가 만들고 해석한다 — 다른 보낸 사람의 것이면 VAL_001) */ + readonly cursor: string | null; +} + /** 받은 그대로의 메일 원문 (GET /api/messages/{id}/source) — 파일 이름은 서비스가 제목으로 정한다 */ export interface MessageSource { readonly stream: Readable; @@ -738,6 +770,18 @@ export interface SentMessage { readonly pendingSend: PendingSend | null; } +/** + * 예약 메일 지금 보내기의 결과 (POST /api/scheduled-messages/{id}/release — 15단계) — 응답은 발송 결과(SentMessage)와 같은 칸이고, + * 대용량 첨부 링크에 적을 보낸 때를 더했다 (서비스가 떼고 답한다) + */ +export interface ReleasedScheduledSend extends SentMessage { + /** + * 지금 보내기로 다시 제출한 때(ISO 8601 UTC — 이 요청이나 응답을 받지 못한 앞 요청) — 대용량 첨부 링크의 "보낸 때"로 적는다. + * 예약 시각에 이미 보낸 메일(지금 보내기가 아니다)이면 null — 링크를 그대로 둔다 + */ + readonly releasedAt: string | null; +} + /** * 같은 Message-ID 로 이미 발송한 메일 — 받지 않은 사람은 지문이 맞을 때만 따로 다시 만든다 * (MailProvider.getRejectedRecipients — 내용이 달라 MAIL_005 로 끝날 때 제출 기록을 헛되이 읽지 않게) diff --git a/backend/src/modules/mail/mailbox-tree.spec.ts b/backend/src/modules/mail/mailbox-tree.spec.ts new file mode 100644 index 0000000..1132295 --- /dev/null +++ b/backend/src/modules/mail/mailbox-tree.spec.ts @@ -0,0 +1,72 @@ +import { startCpuTimer } from '@/../test/cpu-time'; +import { + deepestFirstIds, + descendantsOf, + isSelfOrDescendant, + subtreeIds, +} from './mailbox-tree'; + +/** 거래처 › 2025 › 1분기 · 거래처 › 2026, 프로젝트(맨 위), 받은편지함 › 프로젝트 */ +const mailboxes = [ + { id: 'a', parentId: null }, + { id: 'u1', parentId: null }, + { id: 'u2', parentId: 'u1' }, + { id: 'u3', parentId: 'u2' }, + { id: 'u4', parentId: 'u1' }, + { id: 'p', parentId: undefined }, + { id: 'q', parentId: 'a' }, +]; + +describe('메일함 나무 (mailbox-tree — 15단계 옮기기 · 하위까지 지우기)', () => { + it('하위 메일함 모두 — 깊이와 함께 가까운 것부터, 그 메일함 자신은 넣지 않는다', () => { + expect( + descendantsOf(mailboxes, 'u1').map(({ mailbox, depth }) => [ + mailbox.id, + depth, + ]), + ).toEqual([ + ['u2', 1], + ['u4', 1], + ['u3', 2], + ]); + expect(descendantsOf(mailboxes, 'p')).toEqual([]); + expect(descendantsOf(mailboxes, 'nope')).toEqual([]); + }); + + it('잠글 메일함(그 메일함이 맨 앞) · 지울 차례(깊은 것부터, 그 메일함은 맨 끝)', () => { + expect(subtreeIds(mailboxes, 'u1')).toEqual(['u1', 'u2', 'u4', 'u3']); + expect(deepestFirstIds(mailboxes, 'u1')).toEqual(['u3', 'u2', 'u4', 'u1']); + expect(deepestFirstIds(mailboxes, 'u3')).toEqual(['u3']); + }); + + it('자기 자신 · 자기 하위(손주까지)인가 — 위 · 옆 메일함은 아니다', () => { + for (const candidate of ['u1', 'u2', 'u3', 'u4']) { + expect(isSelfOrDescendant(mailboxes, 'u1', candidate)).toBe(true); + } + for (const candidate of ['a', 'p', 'q']) { + expect(isSelfOrDescendant(mailboxes, 'u1', candidate)).toBe(false); + } + expect(isSelfOrDescendant(mailboxes, 'u2', 'u1')).toBe(false); + }); + + it('읽은 목록에 고리가 있어도 끝난다 — 한 번 본 메일함은 다시 보지 않는다', () => { + const looped = [ + { id: 'x', parentId: 'z' }, + { id: 'y', parentId: 'x' }, + { id: 'z', parentId: 'y' }, + ]; + expect(subtreeIds(looped, 'x')).toEqual(['x', 'y', 'z']); + expect(deepestFirstIds(looped, 'x')).toEqual(['z', 'y', 'x']); + }); + + it('메일함 수에 비례한다 — 깊이 3,000 의 한 줄 나무도 바로 (CPU 시간)', () => { + const chain = Array.from({ length: 3_000 }, (_, index) => ({ + id: `m${index}`, + parentId: index === 0 ? null : `m${index - 1}`, + })); + const elapsed = startCpuTimer(); + expect(deepestFirstIds(chain, 'm0')).toHaveLength(3_000); + expect(isSelfOrDescendant(chain, 'm0', 'm2999')).toBe(true); + expect(elapsed()).toBeLessThan(500); + }); +}); diff --git a/backend/src/modules/mail/mailbox-tree.ts b/backend/src/modules/mail/mailbox-tree.ts new file mode 100644 index 0000000..8766b3d --- /dev/null +++ b/backend/src/modules/mail/mailbox-tree.ts @@ -0,0 +1,95 @@ +/** + * 메일함 나무 (순수 함수 — 계약 2절 "메일함 관리"의 15단계: 옮기기 · 하위 메일함까지 지우기) — 메일함은 parentId 로 잇는다. + * 엔진의 메일함(JmapMailbox)과 도메인의 메일함(Mailbox)이 함께 쓴다 — id · parentId 만 본다. + * 엔진이 고리를 막지만(옮기기의 invalidProperties — 2026-11-09 확인) 읽은 목록에 고리가 있어도 끝나게, 한 번 본 메일함은 다시 보지 않는다. + * 메일함 수에 비례한다(부모 → 자식 표를 한 번 만든다) + */ + +/** 나무를 만드는 데 쓰는 칸 */ +interface TreeNode { + readonly id: string; + readonly parentId?: string | null; +} + +/** 하위 메일함 하나와 그 깊이 (그 메일함의 바로 아래가 1) */ +export interface MailboxTreeEntry { + readonly mailbox: T; + readonly depth: number; +} + +/** + * 그 메일함의 하위 메일함 모두 — 깊이와 함께, 위에서부터(가까운 것부터) 차례. 그 메일함 자신은 넣지 않는다. + * 그 메일함이 목록에 없어도 그 id 를 상위로 둔 메일함을 찾는다(부르는 쪽이 먼저 있는지 본다) + */ +export function descendantsOf( + mailboxes: readonly T[], + rootId: string, +): MailboxTreeEntry[] { + const children = new Map(); + for (const mailbox of mailboxes) { + const parentId = mailbox.parentId ?? null; + if (parentId === null) continue; + const siblings = children.get(parentId); + if (siblings) siblings.push(mailbox); + else children.set(parentId, [mailbox]); + } + + const seen = new Set([rootId]); + const found: MailboxTreeEntry[] = []; + let level = [rootId]; + for (let depth = 1; level.length > 0; depth += 1) { + const next: string[] = []; + for (const id of level) { + for (const child of children.get(id) ?? []) { + if (seen.has(child.id)) continue; + seen.add(child.id); + found.push({ mailbox: child, depth }); + next.push(child.id); + } + } + level = next; + } + return found; +} + +/** 그 메일함과 하위 모두의 id — 그 메일함이 맨 앞, 하위는 가까운 것부터 (잠금 · 메일 찾기) */ +export function subtreeIds( + mailboxes: readonly T[], + rootId: string, +): string[] { + return [ + rootId, + ...descendantsOf(mailboxes, rootId).map(({ mailbox }) => mailbox.id), + ]; +} + +/** + * 지울 차례 — 깊은 것부터, 그 메일함은 맨 끝. 엔진은 한 요청의 destroy 를 적은 차례대로 하므로(E2 — 2026-11-09 확인) + * 이 차례면 빈 나무를 요청 하나로 지운다. 깊이가 같으면 찾은 차례 그대로다 + */ +export function deepestFirstIds( + mailboxes: readonly T[], + rootId: string, +): string[] { + const descendants = descendantsOf(mailboxes, rootId); + return [ + ...[...descendants] + .sort((a, b) => b.depth - a.depth) + .map(({ mailbox }) => mailbox.id), + rootId, + ]; +} + +/** candidateId 가 그 메일함 자신이거나 그 하위인가 — 옮기기의 고리(자기 아래로 옮기기) 막기 */ +export function isSelfOrDescendant( + mailboxes: readonly T[], + rootId: string, + candidateId: string, +): boolean { + return ( + candidateId === rootId || + descendantsOf(mailboxes, rootId).some( + ({ mailbox }) => mailbox.id === candidateId, + ) + ); +} diff --git a/backend/src/modules/mail/mailbox.controller.ts b/backend/src/modules/mail/mailbox.controller.ts index 51a8c7f..fa42085 100644 --- a/backend/src/modules/mail/mailbox.controller.ts +++ b/backend/src/modules/mail/mailbox.controller.ts @@ -9,6 +9,7 @@ import { Patch, Post, Put, + Query, UseGuards, } from '@nestjs/common'; import { ApiCookieAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; @@ -34,8 +35,9 @@ import { import { MailVacationDto, MailVacationStateDto } from './dto/mail-vacation.dto'; import { CreateMailboxDto, + MailboxDeleteQueryDto, MailboxMessagesPatchDto, - RenameMailboxDto, + UpdateMailboxDto, } from './dto/mailbox.dto'; import { MailIdPipe } from './mail-id.pipe'; import { @@ -51,7 +53,8 @@ import { MailService } from './mail.service'; const BULK_NOTE = '한 요청을 25초 안에 끝내고 남은 수를 돌려준다 — 클라이언트는 남은 것이 없을 때까지 같은 요청을 다시 보낸다. ' + '도중에 실패하면 거기까지 처리했다 — 다시 보내면 남은 것만 처리한다. ' + - '같은 직원 · 같은 메일함에는 하나씩만 — 처리 중이면(종류가 달라도) 엔진에 묻지 않고 429 REQ_001. 다른 메일함은 동시에 된다.'; + '같은 직원 · 같은 메일함에는 하나씩만 — 처리 중이면(종류가 달라도) 엔진에 묻지 않고 429 REQ_001. 다른 메일함은 동시에 된다. ' + + '차단할 때 받은편지함 정리(POST /api/mail-block-runs — 15단계)가 도는 동안은 스팸메일함도 처리 중이다.'; /** 오래 걸리는 처리가 진행 중일 때의 안내 */ const BULK_IN_PROGRESS = @@ -108,20 +111,36 @@ export class MailboxController { @Patch(':id') @ApiOperation({ - summary: '메일함 이름 바꾸기', + summary: '메일함 이름 바꾸기 · 옮기기', description: - '기본 메일함(역할이 있는 메일함 — 받은편지함 · 보낸편지함 등)은 바꿀 수 없다. 다른 상위로 옮기기는 하지 않는다.', + '이름(name) · 상위(parentId — 15단계 "메일함 옮기기") 가운데 바꿀 것만 보낸다 — 하나 이상(빈 본문은 400 VAL_001). parentId: null 은 맨 위다. ' + + '기본 메일함(역할이 있는 메일함 — 받은편지함 · 보낸편지함 등)은 바꿀 수 없다. 새 상위는 만들기와 같은 자리만(맨 위 · 받은편지함 · 보관함 · 내 메일함), ' + + '자기 자신 · 자기 하위 아래로는 옮길 수 없다. 하위 메일함과 안의 메일은 함께 가고 메일함 id 는 그대로다(그 메일함으로 옮기는 필터도 그대로 돈다). ' + + '이름 · 상위가 지금과 같으면 엔진에 묻지 않고 그대로 돌려준다. 옮길 때(parentId 가 있을 때) 그 메일함이나 새 상위의 오래 걸리는 처리가 진행 중이면 429 REQ_001.', }) @ApiStandardResponse(MailboxDto, { description: '바뀐 메일함' }) - @ApiErrorResponse('VAL_001', '기본 메일함 · 이름 형식 · 모르는 필드') - @ApiErrorResponse('RES_001', '없는 메일함 (형식이 틀린 id 포함)') - @ApiErrorResponse('MAIL_009', '같은 상위 안에 같은 이름의 메일함이 있다') - rename( + @ApiErrorResponse( + 'VAL_001', + '빈 본문 · 기본 메일함 · 이름 형식 · 만들 수 없는 자리(보낸편지함 · 임시보관함 · 스팸메일함 · 휴지통 아래) · 자기 자신 · 자기 하위 아래 · 모르는 필드', + ) + @ApiErrorResponse( + 'RES_001', + '없는 메일함 · 없는 새 상위 (형식이 틀린 id 포함)', + ) + @ApiErrorResponse( + 'MAIL_009', + '새 자리(같은 상위 안)에 같은 이름의 메일함이 있다 — 대소문자는 가리지 않는다', + ) + @ApiErrorResponse( + 'REQ_001', + '옮길 때 — 그 메일함이나 새 상위의 오래 걸리는 처리(전체 읽음 · 비우기 · 지우기)가 진행 중 — 엔진에 묻지 않았다', + ) + update( @CurrentUser() user: AuthenticatedUser, @Param('id', MailIdPipe) id: string, - @Body() dto: RenameMailboxDto, + @Body() dto: UpdateMailboxDto, ) { - return this.mail.renameMailbox(user, id, dto); + return this.mail.updateMailbox(user, id, dto); } @Delete(':id') @@ -129,13 +148,17 @@ export class MailboxController { summary: '메일함 지우기 — 안의 메일을 휴지통으로 옮긴 뒤 지운다', description: '메일을 영구 삭제하지 않는다 — 이 메일함에만 있는 메일은 휴지통으로 옮기고, 다른 메일함에도 있는 메일은 이 메일함에서만 뺀 뒤 빈 메일함을 지운다. ' + - `옮기는 동안 들어온 메일도 옮긴다. ${BULK_NOTE} (deleted: false 면 다시 보낸다)`, + '하위 메일함이 있으면 withChildren=true 일 때만 지운다(15단계) — 그 메일함과 하위 모두(나무)의 메일을 같은 규칙으로 옮긴 뒤 깊은 것부터 지운다. 수는 나무 전체의 것이다. ' + + `옮기는 동안 들어온 메일도 옮긴다. ${BULK_NOTE} 하위까지 지울 때는 그 메일함을 먼저 잡고 목록을 읽어 하위 메일함까지 잡는다 — 하위가 처리 중이면 목록만 읽고 429. (deleted: false 면 다시 보낸다)`, }) @ApiStandardResponse(MailboxDeletionDto, { description: - '지웠다(deleted: true) · 아직 옮길 메일이 남았다(deleted: false)', + '지웠다(deleted: true — 하위까지면 나무 전체) · 아직 옮길 메일이 남았다(deleted: false)', }) - @ApiErrorResponse('VAL_001', '기본 메일함 · 하위 메일함이 있음') + @ApiErrorResponse( + 'VAL_001', + '기본 메일함 · 하위 메일함이 있는데 withChildren=true 가 아님 · 하위에 기본 메일함이 있음 · withChildren 의 다른 값 · 모르는 쿼리', + ) @ApiErrorResponse('RES_001', '없는 메일함 (형식이 틀린 id 포함)') @ApiErrorResponse( 'MAIL_015', @@ -146,8 +169,9 @@ export class MailboxController { remove( @CurrentUser() user: AuthenticatedUser, @Param('id', MailIdPipe) id: string, + @Query() query: MailboxDeleteQueryDto, ) { - return this.mail.deleteMailbox(user, id); + return this.mail.deleteMailbox(user, id, query); } @Patch(':id/messages') diff --git a/backend/src/modules/mail/scheduled-message.controller.ts b/backend/src/modules/mail/scheduled-message.controller.ts index 2a54fcc..506d9e0 100644 --- a/backend/src/modules/mail/scheduled-message.controller.ts +++ b/backend/src/modules/mail/scheduled-message.controller.ts @@ -2,7 +2,10 @@ import { Controller, Delete, Get, + HttpCode, + HttpStatus, Param, + Post, Query, UseGuards, } from '@nestjs/common'; @@ -22,6 +25,7 @@ import { JwtAuthGuard } from '@/common/guards/jwt-auth.guard'; import { ScheduledMessageCanceledDto, ScheduledMessagePageDto, + SentMessageDto, } from './dto/mail-response.dto'; import { MailIdPipe } from './mail-id.pipe'; import { SCHEDULED_MESSAGES_ROUTE } from './mail.constants'; @@ -72,10 +76,54 @@ export class ScheduledMessageController { 'MAIL_014', '이미 보냈다(메일 서버가 풀었음 — 보낸 제출이 있음 · 배달 중) · 붙잡은 적이 없는 메일이다 — 아무것도 바꾸지 않았다', ) + @ApiErrorResponse( + 'REQ_001', + '같은 메일의 지금 보내기가 처리 중(15단계) — 엔진에 묻지 않았다', + ) cancel( @CurrentUser() user: AuthenticatedUser, @Param('id', MailIdPipe) id: string, ) { return this.scheduled.cancel(user, id); } + + @Post(':id/release') + @HttpCode(HttpStatus.OK) + @ApiOperation({ + summary: '예약한 메일 지금 보내기 (15단계)', + description: + 'id 는 보낸 사본의 메일 id 다. 본문 없음. 기다리는 예약 제출을 메일 서버에서 취소한 **뒤에** 같은 메일을 그 제출의 받는 사람(envelope — 숨은참조 포함) 그대로 다시 제출한다 — ' + + '[보내기]를 누른 것처럼 그 직원의 보내기 취소 설정 초만큼 붙잡았다 보낸다(0초면 바로 — 응답 pendingSend). 예약 표시를 떼서 이제 보내기 취소를 기다리는 메일이다(취소는 DELETE /api/scheduled-messages/{id}). ' + + '대용량 첨부 링크의 보낸 때는 지금으로 바꾸고 끝나는 날은 그대로 둔다(본문에 적힌 날짜). 받는 사람별 결과는 발송과 같다(rejectedRecipients). ' + + '이미 보낸 메일(예약 시각이 막 지남)은 오류가 아니다 — 200 pendingSend: null. 이미 지금 보내기를 한 메일(다시 누름 · 응답을 받지 못한 재시도)도 그 발송의 지금 상태로 200. ' + + '메일 서버가 다시 제출을 거절하면 한 번 더 하고, 그래도 거절이면 그 메일을 임시보관함으로 되돌리고(예약 취소와 같은 자리) 503 MAIL_001. ' + + '결과를 모르게 끝나면(시간 초과 · 연결 끊김) 다시 내지도 되돌리지도 않고 503 MAIL_001 — 메일은 보낸편지함에 그대로라 같은 요청을 다시 보내면 이어 낸다 ' + + '(503 뒤에는 그 메일을 다시 읽어 임시보관함인지 보낸편지함인지로 가른다). 54초 남짓 안에 답한다(정리 몫 포함 — 앞단 · 클라이언트 60초 안).', + }) + @ApiStandardResponse(SentMessageDto, { + description: + '보냈다 · 보내기 취소를 기다린다(pendingSend.kind = undo) — 발송 응답과 같은 칸 (id 는 같은 메일 id)', + }) + @ApiErrorResponse( + 'RES_001', + '없는 메일 · 예약이 아닌 메일(보내기 취소를 기다리는 메일 · 취소해 임시보관함으로 간 메일 · 제출이 없음) — 형식이 틀린 id 포함, 아무것도 바꾸지 않았다', + ) + @ApiErrorResponse( + 'MAIL_010', + '받는 사람 모두 거절(없는 주소 등) — 보내지 않고 임시보관함으로 되돌렸다', + ) + @ApiErrorResponse( + 'MAIL_011', + '받는 사람 모두 지금은 받을 수 없음 — 보내지 않고 임시보관함으로 되돌렸다', + ) + @ApiErrorResponse( + 'REQ_001', + '같은 메일의 예약 취소 · 지금 보내기가 처리 중 — 엔진에 묻지 않았다', + ) + release( + @CurrentUser() user: AuthenticatedUser, + @Param('id', MailIdPipe) id: string, + ) { + return this.scheduled.release(user, id); + } } diff --git a/backend/src/modules/mail/scheduled-messages.service.spec.ts b/backend/src/modules/mail/scheduled-messages.service.spec.ts index b9a0624..eee9d46 100644 --- a/backend/src/modules/mail/scheduled-messages.service.spec.ts +++ b/backend/src/modules/mail/scheduled-messages.service.spec.ts @@ -1,8 +1,15 @@ +import { Logger } from '@nestjs/common'; + import { BusinessException } from '@/common/errors/business.exception'; +import type { MailUserSettingsService } from '@/modules/mail-user-settings/mail-user-settings.service'; +import { DEFAULT_MAIL_USER_SETTINGS } from '@/modules/mail-user-settings/mail-user-settings.types'; import { encodeOffsetCursor } from '@/shared/utils/offset-cursor'; import type { MailLargeFilesService } from './mail-large-files.service'; -import type { MailProvider } from './mail.provider'; -import type { MessageSummary } from './mail.types'; +import { + ScheduledSendReturnedToDraftsException, + type MailProvider, +} from './mail.provider'; +import type { MessageSummary, ReleasedScheduledSend } from './mail.types'; import { ScheduledMessagesService } from './scheduled-messages.service'; const user = { email: 'ara.kim@arasystem.test', name: '김아라' }; @@ -44,24 +51,48 @@ describe('ScheduledMessagesService — 예약한 메일 · 붙잡은 발송 취 let provider: { listScheduledMessages: jest.Mock; cancelPendingSend: jest.Mock; + releaseScheduledSend: jest.Mock; }; let service: ScheduledMessagesService; - let largeFiles: { onPendingSendCanceled: jest.Mock }; + let largeFiles: { + onPendingSendCanceled: jest.Mock; + onScheduledSendReleased: jest.Mock; + }; + let userSettings: { get: jest.Mock }; + + /** 대역 엔진의 지금 보내기 결과 — 보내기 취소를 기다린다 */ + const released: ReleasedScheduledSend = { + id: 'm1', + rejectedRecipients: [], + pendingSend: { kind: 'undo', sendAt: '2026-11-09T05:00:10.000Z' }, + releasedAt: '2026-11-09T05:00:00.000Z', + }; beforeEach(() => { provider = { listScheduledMessages: jest.fn().mockResolvedValue(all), cancelPendingSend: jest.fn().mockResolvedValue(undefined), + releaseScheduledSend: jest.fn().mockResolvedValue(released), }; largeFiles = { onPendingSendCanceled: jest.fn().mockResolvedValue(undefined), + onScheduledSendReleased: jest.fn().mockResolvedValue(undefined), + }; + userSettings = { + get: jest.fn().mockResolvedValue({ + ...DEFAULT_MAIL_USER_SETTINGS, + undoSendSeconds: 20, + }), }; service = new ScheduledMessagesService( provider as unknown as MailProvider, largeFiles as unknown as MailLargeFilesService, + userSettings as unknown as MailUserSettingsService, ); }); + afterEach(() => jest.restoreAllMocks()); + it('목록 — 엔진이 준 차례(보낼 시각)대로 한 쪽, total 은 전체 수 · 커서로 이어 받는다', async () => { const first = await service.list(user, { limit: 2 }); expect(first.items.map(({ id }) => id)).toEqual(['s1', 's2']); @@ -138,4 +169,123 @@ describe('ScheduledMessagesService — 예약한 메일 · 붙잡은 발송 취 await failureOf(() => service.cancel(user, 'm2')); expect(largeFiles.onPendingSendCanceled).not.toHaveBeenCalled(); }); + + describe('지금 보내기 (15단계)', () => { + it('그 직원의 보내기 취소 초만큼 붙잡아 다시 내게 하고, 발송과 같은 칸으로 답한다 — 다시 냈으면 대용량 링크의 보낸 때를 그 시각으로', async () => { + await expect(service.release(user, 'm1')).resolves.toEqual({ + id: 'm1', + rejectedRecipients: [], + pendingSend: { kind: 'undo', sendAt: '2026-11-09T05:00:10.000Z' }, + }); + expect(provider.releaseScheduledSend).toHaveBeenCalledWith( + user.email, + 'm1', + { kind: 'undo', seconds: 20 }, + ); + expect(largeFiles.onScheduledSendReleased).toHaveBeenCalledWith( + user, + 'm1', + new Date('2026-11-09T05:00:00.000Z'), + ); + }); + + it('보내기 취소를 0초로 두었으면 붙잡지 않는다(null) · 설정을 읽지 못하면 처음 값(10초)으로 — 경고만', async () => { + userSettings.get.mockResolvedValueOnce({ + ...DEFAULT_MAIL_USER_SETTINGS, + undoSendSeconds: 0, + }); + await service.release(user, 'm1'); + expect(provider.releaseScheduledSend).toHaveBeenLastCalledWith( + user.email, + 'm1', + null, + ); + + const warn = jest + .spyOn(Logger.prototype, 'warn') + .mockImplementation(() => undefined); + userSettings.get.mockRejectedValueOnce(new Error('db down')); + await service.release(user, 'm1'); + expect(provider.releaseScheduledSend).toHaveBeenLastCalledWith( + user.email, + 'm1', + { kind: 'undo', seconds: 10 }, + ); + expect(warn).toHaveBeenCalledTimes(1); + expect(String(warn.mock.calls[0][0])).not.toContain(user.email); + }); + + it('이미 보낸 메일(releasedAt null)은 링크를 그대로 둔다 — 응답은 pendingSend null', async () => { + provider.releaseScheduledSend.mockResolvedValueOnce({ + ...released, + pendingSend: null, + releasedAt: null, + }); + await expect(service.release(user, 'm1')).resolves.toMatchObject({ + pendingSend: null, + }); + expect(largeFiles.onScheduledSendReleased).not.toHaveBeenCalled(); + }); + + it('끝내 보내지 못해 임시보관함으로 되돌렸으면 링크를 취소한 발송처럼 둔다 — 그 밖의 실패는 링크를 건드리지 않는다 (코드는 그대로)', async () => { + provider.releaseScheduledSend.mockRejectedValueOnce( + new ScheduledSendReturnedToDraftsException('MAIL_010', { + context: { reason: 'x' }, + }), + ); + expect(await failureOf(() => service.release(user, 'm1'))).toMatchObject({ + code: 'MAIL_010', + }); + expect(largeFiles.onPendingSendCanceled).toHaveBeenCalledWith(user, 'm1'); + + largeFiles.onPendingSendCanceled.mockClear(); + for (const code of ['RES_001', 'MAIL_001'] as const) { + provider.releaseScheduledSend.mockRejectedValueOnce( + new BusinessException(code, { context: { reason: 'x' } }), + ); + expect( + await failureOf(() => service.release(user, 'm1')), + ).toMatchObject({ + code, + }); + } + expect(largeFiles.onPendingSendCanceled).not.toHaveBeenCalled(); + expect(largeFiles.onScheduledSendReleased).not.toHaveBeenCalled(); + }); + + it('같은 직원 · 같은 메일의 예약 취소 · 지금 보내기는 한 번에 하나 — 진행 중이면 엔진에 묻지 않고 REQ_001, 다른 메일은 된다, 끝나면 다시 받는다', async () => { + let finish!: (value: ReleasedScheduledSend) => void; + provider.releaseScheduledSend.mockReturnValueOnce( + new Promise((resolve) => { + finish = resolve; + }), + ); + + const running = service.release(user, 'm1'); + await new Promise((resolve) => setImmediate(resolve)); + expect(provider.releaseScheduledSend).toHaveBeenCalledTimes(1); + + for (const attempt of [ + () => service.release(user, 'm1'), + () => service.cancel(user, 'm1'), + () => + service.release({ ...user, email: 'ARA.KIM@arasystem.test' }, 'm1'), + ]) { + expect(await failureOf(attempt)).toEqual({ + code: 'REQ_001', + reason: 'scheduled-send-in-progress', + }); + } + expect(provider.cancelPendingSend).not.toHaveBeenCalled(); + await expect(service.cancel(user, 'm2')).resolves.toEqual({ + draftId: 'm2', + }); + + finish(released); + await running; + await expect(service.cancel(user, 'm1')).resolves.toEqual({ + draftId: 'm1', + }); + }); + }); }); diff --git a/backend/src/modules/mail/scheduled-messages.service.ts b/backend/src/modules/mail/scheduled-messages.service.ts index 2a8e91c..a448072 100644 --- a/backend/src/modules/mail/scheduled-messages.service.ts +++ b/backend/src/modules/mail/scheduled-messages.service.ts @@ -1,4 +1,4 @@ -import { Inject, Injectable } from '@nestjs/common'; +import { Inject, Injectable, Logger } from '@nestjs/common'; import type { AuthenticatedUser } from '@/common/decorators/current-user.decorator'; import { @@ -7,28 +7,48 @@ import { toPaginatedResult, } from '@/common/dto/pagination.dto'; import { BusinessException } from '@/common/errors/business.exception'; +import { MailUserSettingsService } from '@/modules/mail-user-settings/mail-user-settings.service'; +import { DEFAULT_MAIL_USER_SETTINGS } from '@/modules/mail-user-settings/mail-user-settings.types'; +import { traceTag } from '@/shared/logger/request-context'; +import { maskEmail } from '@/shared/utils/mask'; import { decodeOffsetCursor, encodeOffsetCursor, } from '@/shared/utils/offset-cursor'; import { MailLargeFilesService } from './mail-large-files.service'; -import { MAIL_PROVIDER, type MailProvider } from './mail.provider'; -import type { MessageSummary } from './mail.types'; +import { + MAIL_PROVIDER, + ScheduledSendReturnedToDraftsException, + type MailProvider, +} from './mail.provider'; +import type { MessageSummary, SendHold, SentMessage } from './mail.types'; +import { undoSendHold } from './send-hold'; /** 예약한 메일 목록의 커서 범위 — 다른 목록의 커서를 섞어 쓰지 못하게 (shared/utils/offset-cursor.ts) */ const SCHEDULED_SCOPE = 'scheduled-messages'; /** - * 붙잡아 둔 발송 (계약 2절 "보내기 취소 · 예약 발송" — 9단계) — 예약한 메일 목록과 취소. + * 붙잡아 둔 발송 (계약 2절 "보내기 취소 · 예약 발송" — 9단계) — 예약한 메일 목록 · 취소 · 지금 보내기(15단계). * 우리 서버는 붙잡은 발송을 따로 기억하지 않는다 — 목록 · 상태는 늘 엔진의 기다리는 제출에 묻는다(붙잡은 메일은 엔진을 다시 띄워도 남는다). * 보내기(붙잡는 시간을 정하는 것)는 MailService.sendMessage 다 */ @Injectable() export class ScheduledMessagesService { + private readonly logger = new Logger(ScheduledMessagesService.name); + + /** + * 진행 중인 (직원, 메일)의 취소 · 지금 보내기 — 같은 메일에는 하나씩만(종류가 달라도 막는다 — 계약 2절, 진행 중이면 429 REQ_001). + * 취소와 지금 보내기가 같은 예약 제출을 함께 다루면 한쪽이 취소한 뒤 다른 쪽이 다시 내거나 임시보관함으로 옮긴다. + * ⚠️ 프로세스 메모리다. 인스턴스를 늘리면 Redis 잠금(SET NX PX)으로 옮겨야 인스턴스 사이에서도 하나씩이 된다 + */ + private readonly inProgress = new Set(); + constructor( @Inject(MAIL_PROVIDER) private readonly provider: MailProvider, - /** 취소한 발송의 대용량 첨부 링크를 취소한 링크로 (11단계) */ + /** 취소한 발송의 대용량 첨부 링크를 취소한 링크로 · 지금 보낸 예약의 링크에 보낸 때를 (11단계 · 15단계) */ private readonly largeFiles: MailLargeFilesService, + /** 지금 보내기의 보내기 취소 초 (15단계 — [보내기]를 누른 것처럼) */ + private readonly userSettings: MailUserSettingsService, ) {} /** @@ -64,14 +84,88 @@ export class ScheduledMessagesService { /** * 붙잡은 발송을 취소하고 그 메일을 임시보관함으로 되돌린다 — 쓰기 화면이 같은 메일 id 로 이어 쓴다 (draftId). * 없는 메일 RES_001, 이미 보냄 · 기다리는 발송이 아님 MAIL_014, 취소를 확인하지 못함 · 옮기지 못함 MAIL_001 (어댑터 — 취소 · 예약의 로그도 어댑터가 남긴다). - * 엔진에서 취소가 확인된 **뒤에** 그 발송이 만든 대용량 첨부 링크를 취소한 링크로 둔다 (11단계 — 파일은 되돌린 초안이 쓴다) + * 엔진에서 취소가 확인된 **뒤에** 그 발송이 만든 대용량 첨부 링크를 취소한 링크로 둔다 (11단계 — 파일은 되돌린 초안이 쓴다). + * 같은 메일의 지금 보내기가 진행 중이면 REQ_001 (15단계) */ async cancel( user: AuthenticatedUser, id: string, ): Promise<{ draftId: string }> { - await this.provider.cancelPendingSend(user.email, id); - await this.largeFiles.onPendingSendCanceled(user, id); - return { draftId: id }; + return this.exclusive(user, id, async () => { + await this.provider.cancelPendingSend(user.email, id); + await this.largeFiles.onPendingSendCanceled(user, id); + return { draftId: id }; + }); + } + + /** + * 예약한 메일을 지금 보낸다 (계약 2절 "보내기 취소 · 예약 발송" — 15단계) — [보내기]를 누른 것처럼 그 직원의 보내기 취소 초만큼 붙잡았다 보낸다(0 이면 바로). + * 응답은 발송 결과(SentMessage)와 같은 칸이다 — 예약 시각이 막 지나 이미 보냈으면 pendingSend null(오류가 아니다). + * - 예약이 아닌 메일 · 없는 메일 RES_001, 다시 제출하지 못함 MAIL_001 · 받는 사람 모두 거절 MAIL_010 · MAIL_011 (어댑터 — 로그도 어댑터가 남긴다) + * - 다시 제출한 뒤 그 발송이 만든 대용량 첨부 링크의 보낸 때를 지금으로 (끝나는 때는 그대로 — 덩이가 이미 적었다) + * - 끝내 보내지 못해 임시보관함으로 **되돌렸을 때만**(ScheduledSendReturnedToDraftsException) 링크를 취소한 발송처럼 둔다(되돌린 초안이 쓴다). + * 그 밖의 MAIL_001(결과를 모름 · 남은 시간 모자람 · 되돌리기 실패)은 링크를 건드리지 않는다 — 메일은 보낸편지함에 그대로고 다시 부르면 이어 낸다 + * - 같은 메일의 취소 · 지금 보내기가 진행 중이면 REQ_001 + */ + async release(user: AuthenticatedUser, id: string): Promise { + return this.exclusive(user, id, async () => { + const hold = await this.undoHold(user); + try { + const { releasedAt, ...sent } = + await this.provider.releaseScheduledSend(user.email, id, hold); + if (releasedAt !== null) { + await this.largeFiles.onScheduledSendReleased( + user, + id, + new Date(releasedAt), + ); + } + return sent; + } catch (error) { + if (error instanceof ScheduledSendReturnedToDraftsException) { + await this.largeFiles.onPendingSendCanceled(user, id); + } + throw error; + } + }); + } + + /** + * 지금 보내기의 붙잡기 — 그 직원 설정의 보내기 취소 초(send-hold.ts). 설정을 읽지 못하면(DB 장애) 처음 값으로 보고 경고만 — + * 발송과 같다(MailService.userSettingsOf) + */ + private async undoHold(user: AuthenticatedUser): Promise { + try { + const { undoSendSeconds } = await this.userSettings.get(user.email); + return undoSendHold(undoSendSeconds); + } catch (error) { + this.logger.warn( + `[SYS_001]${traceTag()} 보내기 · 읽기 설정을 읽지 못해 처음 값으로 봅니다: user=${maskEmail(user.email)}, reason=${error instanceof Error ? error.message : String(error)}`, + ); + return undoSendHold(DEFAULT_MAIL_USER_SETTINGS.undoSendSeconds); + } + } + + /** 같은 직원 · 같은 메일의 취소 · 지금 보내기를 하나씩만 — 진행 중이면 엔진에 묻지 않고 REQ_001. 끝나면 성공 · 실패와 상관없이 푼다 */ + private async exclusive( + user: AuthenticatedUser, + id: string, + operation: () => Promise, + ): Promise { + const key = `${user.email.toLowerCase()}\n${id}`; + if (this.inProgress.has(key)) { + throw new BusinessException('REQ_001', { + context: { + reason: 'scheduled-send-in-progress', + user: maskEmail(user.email), + }, + }); + } + this.inProgress.add(key); + try { + return await operation(); + } finally { + this.inProgress.delete(key); + } } } diff --git a/backend/src/modules/mail/send-hold.ts b/backend/src/modules/mail/send-hold.ts new file mode 100644 index 0000000..d13f176 --- /dev/null +++ b/backend/src/modules/mail/send-hold.ts @@ -0,0 +1,11 @@ +import type { SendHold } from './mail.types'; + +/** + * 보내기 취소를 기다리는 붙잡기 (계약 2절 "보내기 취소 · 예약 발송") — 그 직원 설정의 초, 0 이면 붙잡지 않는다(null — 바로 보낸다). + * 발송(MailService.sendMessage)과 예약 메일 지금 보내기(ScheduledMessagesService.release — 15단계)가 같이 쓴다 — [보내기]를 누른 것처럼 붙잡는다 + */ +export function undoSendHold(undoSendSeconds: number): SendHold | null { + return undoSendSeconds > 0 + ? { kind: 'undo', seconds: undoSendSeconds } + : null; +} diff --git a/backend/test/app.e2e-spec.ts b/backend/test/app.e2e-spec.ts index 159b1c5..1f36631 100644 --- a/backend/test/app.e2e-spec.ts +++ b/backend/test/app.e2e-spec.ts @@ -84,6 +84,7 @@ import { AccountLoginDisabledException, MAIL_PROVIDER, PasswordExpiredException, + ScheduledSendReturnedToDraftsException, } from '@/modules/mail/mail.provider'; import { MAIL_SETTINGS, type MailSettings } from '@/modules/mail/mail.settings'; import type { @@ -93,10 +94,13 @@ import type { DraftSource, Mailbox, MailboxBulkResult, + MailboxChange, MailboxCreate, + MailboxDeleteOptions, MailboxDeletion, MailBlockAdded, MailBlockList, + MailBlockRunRequest, MailFilterRule, MailFilterRunOutcome, MailFilterRunRequest, @@ -118,7 +122,9 @@ import type { PasswordChangeOutcome, PendingSend, RecipientRejectionReason, + ReleasedScheduledSend, SavedDraft, + SendHold, SentMessage, } from '@/modules/mail/mail.types'; import { @@ -326,6 +332,9 @@ const NEVER_CONFIGURED_VACATION: MailVacation = { /** 9단계 — 대역 메일 엔진이 "이미 보냈다(엔진이 풀었다)"고 답하는 메일 id (409 MAIL_014 경로) */ const ALREADY_RELEASED_MESSAGE = 'alreadyreleased'; +/** 15단계 — 대역 메일 엔진의 지금 보내기가 받는 사람을 모두 받지 않아 임시보관함으로 되돌리는 메일 id (400 MAIL_010 경로) */ +const RELEASE_REJECTED_MESSAGE = 'releaserejected'; + /** * 9단계 — 대역 메일 엔진이 붙잡고 있는(보내기 취소 · 예약을 기다리는) 메일 id — 옮기기 · 스팸 · 지우기는 막힌다 (409 MAIL_015 · failedIds 경로). * 붙잡힘 판정(보낼 시각이 지금 뒤인 pending 제출 · fail closed)은 jmap-mail.provider.spec.ts 가 본다 @@ -860,6 +869,12 @@ describe('응답 계약 (e2e)', () => { update: MessageUpdate; }[] = []; + /** 15단계 — 대역 엔진이 받은 차단 정리 · 지금 보내기, 직원마다 더한 메일함(하위 메일함이 있는 나무) · 지금 보내기를 붙잡는 문 */ + const blockRuns: { user: string; request: MailBlockRunRequest }[] = []; + const releases: { user: string; id: string; hold: SendHold | null }[] = []; + const extraMailboxes = new Map(); + let slowReleaseGate: Promise | null = null; + /** 9단계 — 대역 엔진의 차단 목록(직원마다 — 최근 것부터) · 예약한 메일 · 취소한 발송 · 바깥 이미지 요청 */ const mailBlocks = new Map(); const scheduledMessages = new Map(); @@ -1588,30 +1603,57 @@ describe('응답 계약 (e2e)', () => { unreadCount: 0, }); }, - renameMailbox: ( + // 15단계 — 이름 · 상위 바꾸기. 상위의 자리 · 고리 판정은 jmap-mail.provider.spec.ts 가 본다 (여기서는 엔진의 답만 흉내 낸다) + updateMailbox: ( user: string, id: string, - name: string, + change: MailboxChange, ): Promise => { - mailboxCalls.push({ user, action: 'rename', args: [id, name] }); + mailboxCalls.push({ user, action: 'update', args: [id, change] }); + if (change.parentId === MISSING_MAILBOX) { + return Promise.reject( + new BusinessException('RES_001', { + context: { reason: 'e2e-parent-not-found' }, + }), + ); + } + if (change.parentId === id) { + return Promise.reject( + new BusinessException('VAL_001', { + context: { reason: 'e2e-mailbox-move-into-itself' }, + }), + ); + } + if (change.name === TAKEN_MAILBOX_NAME) { + return Promise.reject( + new BusinessException('MAIL_009', { + context: { reason: 'e2e-mailbox-name-taken' }, + }), + ); + } return Promise.resolve({ id, - name, + name: change.name ?? id, role: null, - parentId: null, + parentId: change.parentId ?? null, sortOrder: 0, totalCount: 3, unreadCount: 1, }); }, - deleteMailbox: (user: string, id: string): Promise => { - mailboxCalls.push({ user, action: 'delete', args: [id] }); - return Promise.resolve({ + deleteMailbox: async ( + user: string, + id: string, + options?: MailboxDeleteOptions, + ): Promise => { + mailboxCalls.push({ user, action: 'delete', args: [id, options] }); + if (id === SLOW_MAILBOX && slowMailboxGate) await slowMailboxGate; + return { deleted: true, processed: 4, movedToTrash: 3, remaining: 0, - }); + }; }, markMailboxRead: ( user: string, @@ -1646,7 +1688,11 @@ describe('응답 계약 (e2e)', () => { inactiveScripts.delete(user); return Promise.resolve({ ...vacation, isActive: true }); }, - listMailboxes: (): Promise => Promise.resolve(E2E_MAILBOXES), + listMailboxes: (user: string): Promise => + Promise.resolve([ + ...E2E_MAILBOXES, + ...(extraMailboxes.get(user) ?? []), + ]), // 6단계 — 엔진 쪽 판단(지문 비교 · 한도)만 흉내 낸다. 만드는 Sieve · 조건부 쓰기는 jmap/*.spec.ts 가 본다 getMailFilters: (user: string): Promise => { const rules = mailScripts.get(user)?.rules ?? []; @@ -1724,6 +1770,34 @@ describe('응답 계약 (e2e)', () => { rounds: 1, }; }, + // 15단계 — 차단 정리. 판정 · 차례 · 이어 할 자리는 jmap-filter-run.spec.ts 가 본다. 필터 적용과 같은 문으로 붙잡는다(같은 잠금 확인) + runMailBlock: async ( + user: string, + request: MailBlockRunRequest, + ): Promise => { + blockRuns.push({ user, request }); + if (request.cursor === OTHER_RULE_CURSOR) { + throw new BusinessException('VAL_001', { + context: { reason: 'e2e-mail-block-run-other-sender' }, + }); + } + if (slowFilterRunGate) await slowFilterRunGate; + return request.cursor === null + ? { + scanned: 500, + matched: 7, + remaining: 40, + cursor: 'e2eblockcursor', + rounds: 1, + } + : { + scanned: 40, + matched: 1, + remaining: 0, + cursor: null, + rounds: 1, + }; + }, openMessageSource: ( _user: string, id: string, @@ -1905,6 +1979,46 @@ describe('응답 계약 (e2e)', () => { canceledSends.push({ user, id }); return Promise.resolve(); }, + // 15단계 — 지금 보내기 (취소 → 다시 제출 · 상태 가르기 · 되돌리기는 jmap-mail.provider.spec.ts 가 본다) + releaseScheduledSend: async ( + user: string, + id: string, + hold: SendHold | null, + ): Promise => { + releases.push({ user, id, hold }); + if (slowReleaseGate) await slowReleaseGate; + if (id === GONE_MESSAGE) { + throw new BusinessException('RES_001', { + context: { reason: 'e2e-not-a-scheduled-send' }, + }); + } + if (id === RELEASE_REJECTED_MESSAGE) { + throw new ScheduledSendReturnedToDraftsException('MAIL_010', { + context: { reason: 'e2e-release-all-recipients-rejected' }, + }); + } + if (id === ALREADY_RELEASED_MESSAGE) { + return { + id, + rejectedRecipients: [], + pendingSend: null, + releasedAt: null, + }; + } + const now = Date.now(); + return { + id, + rejectedRecipients: [], + pendingSend: + hold?.kind === 'undo' + ? { + kind: 'undo', + sendAt: new Date(now + hold.seconds * 1_000).toISOString(), + } + : null, + releasedAt: new Date(now).toISOString(), + }; + }, }) .overrideProvider(MAIL_SETTINGS) .useValue(TEST_MAIL_SETTINGS) @@ -2449,6 +2563,10 @@ describe('응답 계약 (e2e)', () => { ['get', '/api/mail-filters'], ['put', '/api/mail-filters'], ['post', '/api/mail-filter-runs'], + // 15단계 — 차단 정리 · 지금 보내기 (옮기기 · 하위까지 지우기는 위의 PATCH · DELETE 와 같은 경로) + ['post', '/api/mail-block-runs'], + ['post', '/api/scheduled-messages/eaaaaab/release'], + ['delete', '/api/mailboxes/u1?withChildren=true'], ['get', '/api/messages/eaaaaab/source'], // 템플릿 업로드 예제도 로그인한 직원만 쓴다 ['post', '/api/files'], @@ -3638,27 +3756,21 @@ describe('응답 계약 (e2e)', () => { expect(body(orphan).error?.code).toBe('RES_001'); }); - it('이름 바꾸기는 200 Mailbox — 형식이 틀린 id 는 404 RES_001, 상위는 바꾸지 않는다(모르는 필드)', async () => { + it('이름 바꾸기는 200 Mailbox — 형식이 틀린 id 는 404 RES_001, 이름만 보내면 상위는 그대로 (15단계부터 상위도 받는다 — 아래 "15단계")', async () => { const res = await send('patch', '/api/mailboxes/u1', { name: ' 새 이름 ', }).expect(200); expect(dataOf<{ name: string }>(res).name).toBe('새 이름'); expect(mailboxCalls.at(-1)).toEqual({ user: ME, - action: 'rename', - args: ['u1', '새 이름'], + action: 'update', + args: ['u1', { name: '새 이름' }], }); const malformed = await send('patch', '/api/mailboxes/a%20b', { name: '새 이름', }).expect(404); expect(body(malformed).error?.code).toBe('RES_001'); - - const move = await send('patch', '/api/mailboxes/u1', { - name: '새 이름', - parentId: 'a', - }).expect(400); - expect(body(move).error?.code).toBe('VAL_001'); }); it('지우기는 204 가 아니라 200 { deleted, processed, movedToTrash, remaining }', async () => { @@ -3671,7 +3783,7 @@ describe('응답 계약 (e2e)', () => { expect(mailboxCalls.at(-1)).toEqual({ user: ME, action: 'delete', - args: ['u1'], + args: ['u1', { startedAt: expect.any(Number) as number }], }); }); @@ -6201,6 +6313,31 @@ describe('응답 계약 (e2e)', () => { return { sentId, message, token, link }; }; + it('예약 메일을 지금 보내면(15단계) 그 링크의 보낸 때를 지금으로 앞당기고 끝나는 날은 그대로 둔다 — 덩이가 적은 날짜 (실제 DB 의 조건부 UPDATE)', async () => { + const ME = 'lf.release@e2e.arasystem.test'; + const file = await uploadReady(ME, '지금 보내기 시험', '도면.zip'); + const sendAt = new Date( + Math.floor((Date.now() + 3 * 24 * 60 * 60_000) / 1_000) * 1_000, + ).toISOString(); + const { sentId, link } = await sendAndShare(ME, [file.id], { sendAt }); + expect(link?.sentAt).toBe(sendAt); + const expiresAt = link?.expiresAt; + + const before = Date.now(); + await client( + request(server).post(`/api/scheduled-messages/${sentId}/release`), + ME, + ).expect(200); + const after = (await shareLinksOf(ME)).items.find( + (item) => item.sentEmailId === sentId, + ); + expect(Date.parse(after?.sentAt ?? '')).toBeGreaterThanOrEqual( + Math.floor(before / 1_000) * 1_000, + ); + expect(Date.parse(after?.sentAt ?? '')).toBeLessThan(Date.parse(sendAt)); + expect(after?.expiresAt).toBe(expiresAt); + }); + it('로그인하지 않았으면 401 AUTH_001 — 공개 경로 밖은 모두', async () => { const id = randomUUID(); for (const res of [ @@ -11119,6 +11256,426 @@ describe('응답 계약 (e2e)', () => { }); }); + /** + * 15단계 — 메일함 옮기기 · 하위 메일함까지 지우기 · 차단할 때 받은편지함 정리 · 예약 메일 지금 보내기 (계약 2절). + * 엔진 쪽 판단(상위의 자리 · 고리 · 깊은 것부터 지우기 · Sieve 와 같은 판정 · 취소 → 다시 제출)은 jmap-*.spec.ts 가 본다 — 여기서는 HTTP 형식 · 검사 · 잠금 · 응답 꼴을 본다 + */ + describe('15단계 — 메일함 옮기기 · 하위까지 지우기 · 차단 정리 · 지금 보내기', () => { + /** 보내기 · 읽기 설정 — 지금 보내기의 보내기 취소 초 (9단계 "보내기 · 읽기 설정") */ + const patchSettings = (email: string, payload: object) => + request(server) + .patch('/api/mail-settings') + .set('Cookie', cookieOf(email)) + .send(payload); + + describe('메일함 옮기기 (PATCH /api/mailboxes/{id} 의 parentId)', () => { + const ME = 'move.me@e2e.arasystem.test'; + const patch = (id: string, payload: object) => + request(server) + .patch(`/api/mailboxes/${id}`) + .set('Cookie', cookieOf(ME)) + .send(payload); + + it('parentId 로 옮긴다 — null 은 맨 위, 이름과 함께도 된다 · 보낸 칸만 엔진에 넘긴다', async () => { + const res = await patch('u1', { parentId: 'a' }).expect(200); + expect(body(res)).toEqual({ + success: true, + data: { + id: 'u1', + name: 'u1', + role: null, + parentId: 'a', + sortOrder: 0, + totalCount: 3, + unreadCount: 1, + }, + }); + expect(mailboxCalls.at(-1)).toEqual({ + user: ME, + action: 'update', + args: ['u1', { parentId: 'a' }], + }); + + await patch('u1', { parentId: null }).expect(200); + expect(mailboxCalls.at(-1)?.args).toEqual(['u1', { parentId: null }]); + + await patch('u1', { name: ' 2025 ', parentId: 'j' }).expect(200); + expect(mailboxCalls.at(-1)?.args).toEqual([ + 'u1', + { name: '2025', parentId: 'j' }, + ]); + }); + + it('빈 본문 · 상위 id 형식 · null 이름 · 모르는 필드는 400 VAL_001 — 엔진에 묻지 않는다', async () => { + const before = mailboxCalls.length; + for (const payload of [ + {}, + { parentId: '../a' }, + { parentId: 7 }, + { name: null }, + { parentId: 'a', role: 'inbox' }, + ]) { + const res = await patch('u1', payload).expect(400); + expect(body(res).error?.code).toBe('VAL_001'); + } + expect(mailboxCalls).toHaveLength(before); + }); + + it('엔진의 답 그대로 — 없는 상위 404 RES_001 · 자기 아래 400 VAL_001 · 같은 이름 409 MAIL_009', async () => { + const orphan = await patch('u1', { parentId: MISSING_MAILBOX }).expect( + 404, + ); + expect(body(orphan).error?.code).toBe('RES_001'); + const self = await patch('u1', { parentId: 'u1' }).expect(400); + expect(body(self).error?.code).toBe('VAL_001'); + const taken = await patch('u1', { + name: TAKEN_MAILBOX_NAME, + parentId: 'a', + }).expect(409); + expect(body(taken).error?.code).toBe('MAIL_009'); + }); + }); + + describe('하위 메일함까지 지우기 (DELETE /api/mailboxes/{id}?withChildren=true)', () => { + const ME = 'tree.me@e2e.arasystem.test'; + const CHILD = 'slowchild'; + const remove = (id: string, query = '') => + request(server) + .delete(`/api/mailboxes/${id}${query}`) + .set('Cookie', cookieOf(ME)); + + beforeAll(() => { + // 대역 엔진의 나무 — SLOW_MAILBOX › CHILD + extraMailboxes.set(ME, [ + { + id: SLOW_MAILBOX, + name: '거래처', + role: null, + parentId: null, + sortOrder: 0, + totalCount: 0, + unreadCount: 0, + }, + { + id: CHILD, + name: '2025', + role: null, + parentId: SLOW_MAILBOX, + sortOrder: 0, + totalCount: 0, + unreadCount: 0, + }, + ]); + }); + + it('withChildren=true 면 하위까지 지우기를 넘기고 200 MailboxDeletion — 다른 값 · 모르는 쿼리는 400 VAL_001 (엔진에 묻지 않는다)', async () => { + const res = await remove('u1', '?withChildren=true').expect(200); + expect(body(res)).toEqual({ + success: true, + data: { deleted: true, processed: 4, movedToTrash: 3, remaining: 0 }, + }); + expect(mailboxCalls.at(-1)).toEqual({ + user: ME, + action: 'delete', + args: [ + 'u1', + { withChildren: true, startedAt: expect.any(Number) as number }, + ], + }); + + const before = mailboxCalls.length; + for (const query of [ + '?withChildren=false', + '?withChildren=TRUE', + '?withChildren=1', + '?recursive=true', + ]) { + const rejected = await remove('u1', query).expect(400); + expect(body(rejected).error?.code).toBe('VAL_001'); + } + expect(mailboxCalls).toHaveLength(before); + }); + + it( + '나무를 지우는 동안 나무의 메일함 모두가 처리 중이다 — 하위의 전체 읽음 · 나무 안으로 옮기기는 429 REQ_001, 나무 밖은 된다', + async () => { + const httpServer = app.getHttpServer(); + const startedServer = !httpServer.listening; + if (startedServer) await listenOnAnyPort(httpServer); + + const gate = createGate(); + slowMailboxGate = gate.opened; + const before = mailboxCalls.length; + const first = remove(SLOW_MAILBOX, '?withChildren=true').then( + (res) => res, + ); + + try { + await waitUntil( + () => mailboxCalls.length > before, + GATE_DEADLINE_MS, + ); + for (const [method, path, payload] of [ + ['patch', `/api/mailboxes/${CHILD}/messages`, { isRead: true }], + ['delete', `/api/mailboxes/${CHILD}`, undefined], + ['patch', '/api/mailboxes/j', { parentId: CHILD }], + ] as const) { + const req = request(server) + [method](path) + .set('Cookie', cookieOf(ME)); + const busy = await ( + payload === undefined ? req : req.send(payload) + ).expect(429); + expect(body(busy).error?.code).toBe('REQ_001'); + } + expect(mailboxCalls).toHaveLength(before + 1); + + // 나무 밖은 된다 · 이름만 바꾸기는 잠그지 않는다(지금처럼) + await request(server) + .patch('/api/mailboxes/a/messages') + .set('Cookie', cookieOf(ME)) + .send({ isRead: true }) + .expect(200); + await request(server) + .patch(`/api/mailboxes/${CHILD}`) + .set('Cookie', cookieOf(ME)) + .send({ name: '새 이름' }) + .expect(200); + } finally { + gate.open(); + slowMailboxGate = null; + await Promise.allSettled([first]); + if (startedServer) await closeServer(httpServer); + } + expect((await first).status).toBe(200); + }, + GATED_TEST_TIMEOUT_MS, + ); + }); + + describe('차단할 때 받은편지함 정리 (POST /api/mail-block-runs)', () => { + const ME = 'blockrun.me@e2e.arasystem.test'; + const SENDER = 'kim@spam.e2e.example'; + const run = (payload: object) => + request(server) + .post('/api/mail-block-runs') + .set('Cookie', cookieOf(ME)) + .send(payload); + + beforeAll(() => { + mailBlocks.set(ME, [ + { address: SENDER, blockedAt: '2026-11-09T01:00:00.000Z' }, + { + address: '@spam.e2e.example', + blockedAt: '2026-11-09T01:00:00.000Z', + }, + ]); + }); + + it('200 { scanned, matched, remaining, cursor } — cursor 로 이어 하고 null 이면 끝 (차례 수는 응답에 없다)', async () => { + const first = await run({ sender: SENDER, cursor: null }).expect(200); + expect(body(first)).toEqual({ + success: true, + data: { + scanned: 500, + matched: 7, + remaining: 40, + cursor: 'e2eblockcursor', + }, + }); + expect(blockRuns.at(-1)).toEqual({ + user: ME, + request: { sender: SENDER, cursor: null }, + }); + + const second = await run({ + sender: '@SPAM.e2e.example', + cursor: 'e2eblockcursor', + }).expect(200); + expect(dataOf<{ cursor: string | null }>(second).cursor).toBeNull(); + // 차단 목록의 모양(소문자)으로 넘긴다 + expect(blockRuns.at(-1)?.request.sender).toBe('@spam.e2e.example'); + }); + + it('차단 목록에 없으면 404 RES_001 · 형식 · cursor 키 없음 · 모르는 필드는 400 VAL_001 · 어댑터의 다른 보낸 사람 cursor 도 400 — 받은편지함을 읽지 않는다', async () => { + const before = blockRuns.length; + const missing = await run({ + sender: 'lee@spam.e2e.example', + cursor: null, + }).expect(404); + expect(body(missing).error?.code).toBe('RES_001'); + + for (const payload of [ + { sender: SENDER }, + { sender: 'not an address', cursor: null }, + { sender: SENDER, cursor: 7 }, + { sender: SENDER, cursor: 'a b' }, + { sender: SENDER, cursor: null, mailboxId: 'a' }, + ]) { + const rejected = await run(payload).expect(400); + expect(body(rejected).error?.code).toBe('VAL_001'); + } + expect(blockRuns).toHaveLength(before); + + const other = await run({ + sender: SENDER, + cursor: OTHER_RULE_CURSOR, + }).expect(400); + expect(body(other).error?.code).toBe('VAL_001'); + }); + + it( + '같은 직원의 받은편지함 정리(필터 적용 · 차단 정리)는 한 번에 하나 — 진행 중이면 서로 429 REQ_001', + async () => { + const httpServer = app.getHttpServer(); + const startedServer = !httpServer.listening; + if (startedServer) await listenOnAnyPort(httpServer); + + const gate = createGate(); + slowFilterRunGate = gate.opened; + const before = blockRuns.length; + const first = run({ sender: SENDER, cursor: null }).then( + (res) => res, + ); + try { + await waitUntil(() => blockRuns.length > before, GATE_DEADLINE_MS); + const again = await run({ sender: SENDER, cursor: null }).expect( + 429, + ); + expect(body(again).error?.code).toBe('REQ_001'); + const filter = await request(server) + .post('/api/mail-filter-runs') + .set('Cookie', cookieOf(ME)) + .send({ + match: 'always', + conditions: [], + actions: { + moveToMailboxId: null, + markAsRead: true, + flag: false, + }, + cursor: null, + }) + .expect(429); + expect(body(filter).error?.code).toBe('REQ_001'); + // 차단 정리가 도는 동안 그 직원의 스팸메일함(c)도 잡혀 있다 — 비우기는 429 (엔진에 묻지 않는다) + const emptyCalls = mailboxCalls.length; + const empty = await request(server) + .delete('/api/mailboxes/c/messages') + .set('Cookie', cookieOf(ME)) + .expect(429); + expect(body(empty).error?.code).toBe('REQ_001'); + expect(mailboxCalls).toHaveLength(emptyCalls); + } finally { + gate.open(); + slowFilterRunGate = null; + await Promise.allSettled([first]); + if (startedServer) await closeServer(httpServer); + } + expect((await first).status).toBe(200); + }, + GATED_TEST_TIMEOUT_MS, + ); + }); + + describe('예약 메일 지금 보내기 (POST /api/scheduled-messages/{id}/release)', () => { + const ME = 'release.me@e2e.arasystem.test'; + const release = (id: string) => + request(server) + .post(`/api/scheduled-messages/${id}/release`) + .set('Cookie', cookieOf(ME)); + + it('200 — 발송 응답과 같은 칸 { id, rejectedRecipients, pendingSend }, 보내기 취소 설정 초만큼 붙잡게 넘긴다(0초면 null)', async () => { + const res = await release('escheduled1').expect(200); + expect(body(res)).toEqual({ + success: true, + data: { + id: 'escheduled1', + rejectedRecipients: [], + pendingSend: { + kind: 'undo', + sendAt: expect.any(String) as string, + }, + }, + }); + expect(releases.at(-1)).toEqual({ + user: ME, + id: 'escheduled1', + hold: { kind: 'undo', seconds: 10 }, + }); + + await patchSettings(ME, { undoSendSeconds: 0 }).expect(200); + const now = await release('escheduled2').expect(200); + expect( + dataOf<{ pendingSend: PendingSend | null }>(now).pendingSend, + ).toBeNull(); + expect(releases.at(-1)?.hold).toBeNull(); + await patchSettings(ME, { undoSendSeconds: 10 }).expect(200); + }); + + it('이미 보낸 메일은 오류가 아니다 — 200 pendingSend: null', async () => { + const res = await release(ALREADY_RELEASED_MESSAGE).expect(200); + expect(body(res)).toEqual({ + success: true, + data: { + id: ALREADY_RELEASED_MESSAGE, + rejectedRecipients: [], + pendingSend: null, + }, + }); + }); + + it('예약이 아닌 메일 · 형식이 틀린 id 는 404 RES_001 · 받는 사람이 모두 받지 않으면 400 MAIL_010 (임시보관함으로 되돌렸다)', async () => { + const gone = await release(GONE_MESSAGE).expect(404); + expect(body(gone).error?.code).toBe('RES_001'); + + const before = releases.length; + const malformed = await release('a"b').expect(404); + expect(body(malformed).error?.code).toBe('RES_001'); + expect(releases).toHaveLength(before); + + const rejected = await release(RELEASE_REJECTED_MESSAGE).expect(400); + expect(body(rejected).error?.code).toBe('MAIL_010'); + }); + + it( + '같은 메일의 지금 보내기 · 예약 취소는 한 번에 하나 — 진행 중이면 429 REQ_001, 다른 메일은 된다', + async () => { + const httpServer = app.getHttpServer(); + const startedServer = !httpServer.listening; + if (startedServer) await listenOnAnyPort(httpServer); + + const gate = createGate(); + slowReleaseGate = gate.opened; + const before = releases.length; + const first = release('eslowrelease').then((res) => res); + try { + await waitUntil(() => releases.length > before, GATE_DEADLINE_MS); + const again = await release('eslowrelease').expect(429); + expect(body(again).error?.code).toBe('REQ_001'); + const cancel = await request(server) + .delete('/api/scheduled-messages/eslowrelease') + .set('Cookie', cookieOf(ME)) + .expect(429); + expect(body(cancel).error?.code).toBe('REQ_001'); + // 다른 메일의 취소는 된다 + await request(server) + .delete('/api/scheduled-messages/eotherheld') + .set('Cookie', cookieOf(ME)) + .expect(200); + } finally { + gate.open(); + slowReleaseGate = null; + await Promise.allSettled([first]); + if (startedServer) await closeServer(httpServer); + } + expect((await first).status).toBe(200); + }, + GATED_TEST_TIMEOUT_MS, + ); + }); + }); + describe('출처 검사 (상태 변경 요청)', () => { const EVIL_ORIGIN = 'https://evil.example'; diff --git a/docs/design/webmail/ListDrag15.dc.html b/docs/design/webmail/ListDrag15.dc.html new file mode 100644 index 0000000..eb24f53 --- /dev/null +++ b/docs/design/webmail/ListDrag15.dc.html @@ -0,0 +1,843 @@ + + + + +끌어다 놓기 · 스팸메일함으로 옮기기 · 좁은 폭 상단 바 + + + + + + + +
+ +
+

끌어다 놓기 · 스팸메일함으로 옮기기 · 좁은 폭 상단 바

+

메일 15단계(메일 · 메일함 다루기)에서 정한 것 가운데 3 · 4 · 9의 화면입니다. 목록에서 고른 메일이나 대화 줄을 메일함 패널로 끌어다 놓아 옮기고, 스팸메일함으로 옮기는 세 길(단추, 메뉴, 끌기)을 같게 맞추고, 휴대폰 폭에서 상단 바의 [로그아웃]이 꺾이지 않게 합니다. 1의 단추로 마우스가 있는 곳을 바꿔 볼 수 있습니다. 초안이며 사용자 확인 전입니다(2026-11-09).

+
+ +
+
+

1. 분할 보기 (1440) · 고른 메일 3통을 끌어다 놓는 중

+

받은편지함에서 메일 3통을 고른 채 그중 한 줄(박지훈)을 눌러 메일함 패널로 끌고 있습니다. 마우스 오른쪽 아래에 메일 수를 적은 표가 따라옵니다.

+
+
+ 마우스가 있는 곳 +
+ + + +
+
+
+

{{ overTitle }}

+

{{ overNote }}

+
+
+ +
+ + + +
+ +
+ 메일 +
+ + + +
+
+ +
+ 김아라 + ara.kim@arasystem.it.kr +
+ +
+
+ +
+ + + +
+
+

3통 선택됨

+
+ + + + + + + +
+
+ +
+ +
+
+
+ + + 답장 + + + + 전체 답장 + + + + 전달 + + + + + 이동 + + + + + 스팸 신고 + + + + 삭제 + + +
+
+
+
+

Re: 유지보수 계약 갱신 건

+ +
+
+ +
+
+ 이도윤 + doyun.lee@arasystem.it.kr + 2026. 11. 9. (월) 오전 10:42 +
+ 받는 사람 김아라 <ara.kim@arasystem.it.kr> +
+
+
+

김아라님, 안녕하세요.

+

말씀하신 조건으로 계약서 초안을 고쳐 첨부했습니다.
기간은 1년이고, 단가는 작년과 같습니다.

+

이도윤 드림

+
+
+

첨부 파일 1개

+
+ + + 유지보수_계약서_초안.docx + 64 KB + + +
+
+
+
+ +
+
+
+

고르지 않은 줄을 끌면 그 줄만 옮기고, 고른 메일은 그대로 둡니다. 보내기를 기다리는 메일의 줄은 끌 수 없습니다. 읽기 화면 도구 줄의 [이동 ▾]는 정한 것 1의 모습입니다.

+ +
+
+

2. 목록 보기 (1440) · 대화 줄 하나를 끌어다 놓는 중

+

대화로 묶은 받은편지함에서 고르지 않은 줄(박지훈, 나 · 3통)을 끌어 견적 메일함 위에 두었습니다. 따라오는 표에는 대화 수를 적습니다.

+
+
+ +
+ + + +
+ +
+ 메일 +
+ + + +
+
+ +
+ 김아라 + ara.kim@arasystem.it.kr +
+ +
+
+ +
+ + + +
+
+
+

받은편지함

+ 대화 7개, 안 읽은 메일 2통 +
+
+ +
+ + + 분할 + + + + 목록 + +
+ + + +
+
+ +
    + +
  • + + + + {{ c.who }} + {{ c.count }} + + + {{ c.subject }} + + + {{ c.date }} +
  • +
    +
+
+ 메일 더 보기 +
+
+ +
+
+
+

대화 줄을 끌면 그 대화에서 지금 메일함(받은편지함)에 있는 메일만 옮깁니다. [이동 ▾]와 같이 보내기를 기다리는 메일은 빼고, 놓은 뒤 알림은 3e입니다.

+ +
+
+

3. 놓은 뒤 알림

+

메뉴로 옮길 때와 같은 알림이고, 화면 오른쪽 아래에 뜹니다. [되돌리기]가 있는 알림은 10초 동안 남습니다.

+
+
+ + +
+
+

{{ t.title }}

+

{{ t.note }}

+
+
+ + {{ t.text }} + +
+
+
+ +
+

끌어다 놓기는 마우스로만 합니다. 키보드와 터치 화면에서는 지금처럼 [이동 ▾]로 옮기고, 옮길 곳과 알림은 같습니다.

+
+ +
+
+

4. 상단 바 · 휴대폰 폭 360 (2배로 확대)

+

레일 72와 상단 바만 잘라 2배로 키웠습니다. 바 안쪽 폭은 256입니다(360에서 레일 72와 바의 좌우 여백 32를 뺀 폭). 넓은 화면은 달라지지 않습니다.

+
+ +
+
+
+
+ + + + 메일 + + + + 캘린더 + +
+
+
+ 메일 +
+ + 메일 검색 + + + 조건 + +
+
+ +
+ 김아라 + ara.kim@arasystem.it.kr +
+ + + 로그아웃 + +
+
+
+ + + 메일 쓰기 + +
+
+
+
+
+

4a. 지금

+
    +
  • 검색 칸(기본 폭 280)이 둘째 줄로, 계정 칸이 셋째 줄로 내려갑니다.
  • +
  • 계정 칸은 아바타 32, 이름과 주소, [로그아웃] 순서입니다. 주소는 줄지 않아 [로그아웃]만 줄어들고 글자가 두 줄로 꺾입니다.
  • +
  • 주소가 더 긴 직원은 [로그아웃]이 한 글자씩 꺾여 셋째 줄이 더 높아집니다.
  • +
+
+
+ +
+
+
+
+ + + + 메일 + + + + 캘린더 + +
+
+
+ 메일 +
+ + 메일 검색 + + + 조건 + +
+
+ +
+ 김아라 +
+ + + 로그아웃 + +
+
+
+ + + 메일 쓰기 + +
+
+
+
+
+

4b. 고친 뒤

+
    +
  • [로그아웃]은 줄지도 꺾이지도 않습니다. 늘 한 줄입니다.
  • +
  • 화면 폭이 480 이하면 주소를 감추고 이름만 둡니다.
  • +
  • 이름이나 주소가 아주 길어 넘치면 끝을 "…"로 줄이고, 마우스를 올리면 다 보입니다.
  • +
  • 줄 수와 차례(메일, 검색 칸, 계정)는 지금과 같습니다.
  • +
+
+
+
+ +
+ +
+

정한 것 · 끌 수 있는 것과 놓을 곳

+
    +
  • 고른 줄을 끌면 고른 메일을 모두, 고르지 않은 줄을 끌면 그 줄만 옮깁니다. 고른 것은 그대로 둡니다.
  • +
  • 대화로 묶은 목록의 줄은 그 대화에서 지금 메일함에 있는 메일을 옮깁니다. 보내기를 기다리는 메일은 빼고([이동 ▾]와 같다), 그런 메일뿐인 줄은 끌 수 없습니다.
  • +
  • 놓을 곳은 [이동 ▾]의 옮길 곳에 휴지통을 더한 곳입니다. 지금 메일함, 임시보관함, 예약됨에는 놓을 수 없습니다.
  • +
  • 휴지통에 놓으면 [삭제], 스팸메일함에 놓으면 [스팸 신고]와 같고, 그 밖은 [이동 ▾]와 같습니다. 알림과 [되돌리기]도 같습니다.
  • +
+
+ +
+

끌어다 놓는 동안의 모양

+
    +
  • 마우스를 따라오는 표는 커서 오른쪽 아래에 붙습니다. "메일 N통" 또는 "대화 N개"이고, 알림과 같은 어두운 바탕(#0B3640)에 12.5px 굵은 흰 글자, 봉투나 말풍선 아이콘입니다.
  • +
  • 놓을 수 있는 메일함 위에서는 그 줄에 2px 청록 점선과 옅은 청록 바탕이 생깁니다. 쓰기 화면에 파일을 끌어다 놓을 때와 같은 모양이라 초점 선(실선)과 헷갈리지 않습니다.
  • +
  • 놓을 수 없는 곳 위에서는 줄이 그대로이고 마우스 커서만 금지 모양(⊘)이 됩니다. 메일함 줄마다 금지 표시를 그리지 않습니다.
  • +
  • 끌기 시작한 줄은 흐리게 하지 않습니다. 고른 줄은 고른 바탕 그대로입니다.
  • +
+
+ +
+

정한 것 · 스팸메일함으로 옮기는 길

+
    +
  • [스팸 신고], [이동 ▾]에서 스팸메일함 고르기, 스팸메일함에 끌어다 놓기는 모두 스팸 표시를 붙여 옮깁니다(3c). 지금은 [이동 ▾]만 표시 없이 옮깁니다.
  • +
  • 스팸메일함에서 휴지통이 아닌 메일함으로 옮기면(메뉴, 끌기) [스팸 아님]과 같이 스팸 아님 표시를 붙입니다(3d). 휴지통으로 옮길 때는 표시를 바꾸지 않습니다.
  • +
  • [되돌리기]는 원래 메일함으로 옮기며 표시도 되돌립니다. 지금의 [스팸 신고], [스팸 아님]과 같습니다.
  • +
+
+ +
+

구현 때 맞출 것

+
    +
  • HTML5 끌어다 놓기로 만들고, 따라오는 표는 setDragImage 로 붙입니다. 놓을 수 없는 곳에서는 dropEffect 를 none 으로 두어 브라우저가 금지 커서를 보입니다. 커서 모양과 따라오는 표의 투명도는 운영체제와 브라우저마다 조금 다릅니다.
  • +
  • 목록 줄은 링크라 지금도 끌면 그 메일 화면의 주소(URL)가 끌려 나갑니다. 우리 끌기 자료로 바꾸고, 다른 창이나 바깥 프로그램에 놓아도 아무 일도 일어나지 않게 합니다.
  • +
  • 메일함 패널은 쓰기 화면에도 보이므로 파일 끌기는 받지 않습니다(첨부 놓기와 겹치지 않게). 메일 화면에 파일을 잘못 놓으면 지금은 브라우저가 그 파일을 열어 웹메일을 떠나는데, 앞으로는 무시합니다.
  • +
  • 패널의 메일함이 많아 아래가 가려져 있으면 패널 끝에서 저절로 스크롤되는지 확인합니다.
  • +
  • 상단 바는 아바타와 [로그아웃]을 줄지 않게 하고 이름 칸만 줄게 합니다. 480 이하에서 주소를 감추는 것은 tokens.css 의 화면 폭 기준에 한 줄을 더합니다.
  • +
+
+ +
+
+
+ + + diff --git a/docs/design/webmail/Mailbox15.dc.html b/docs/design/webmail/Mailbox15.dc.html new file mode 100644 index 0000000..c57d516 --- /dev/null +++ b/docs/design/webmail/Mailbox15.dc.html @@ -0,0 +1,765 @@ + + + + +메일함 옮기기 · 하위 메일함까지 지우기 · 차단할 때 받은 메일 옮기기 + + + + + + + +
+ +
+

메일 15단계 · 메일함 옮기기 · 하위 메일함까지 지우기 · 차단할 때 받은 메일 옮기기

+

메일함 패널의 ⋯ 메뉴와 보낸 사람 차단 창에 더하는 것입니다. 메일함을 다른 메일함 안이나 맨 위로 옮기고, 하위 메일함이 있는 메일함도 한 번에 지우고, 보낸 사람을 차단할 때 받은편지함에 이미 있는 그 사람의 메일까지 스팸메일함으로 옮깁니다. 2의 목록은 줄을 눌러 고를 수 있습니다. 예시의 보낸 사람은 news@example.com입니다. 초안이며 사용자 확인 전입니다(2026-11-09).

+
+ +
+
+

1. 메일함 ⋯ 메뉴

+

내 메일함의 ⋯ 메뉴에 [옮기기]가 생깁니다. 이름 바꾸기 다음, 지우기 앞입니다. 받은편지함 같은 기본 메일함에는 없습니다.

+
+
+ +
+
+

내 메일함의 ⋯

+

거래처 안의 2025

+
+ +
+ +
+
+

받은편지함의 ⋯

+

기본 메일함에는 옮기기가 없습니다

+
+ +
+ +
+

정한 것

+
    +
  • 항목 차례는 모두 읽음으로 표시, 하위 메일함 만들기, 이름 바꾸기, 옮기기, 지우기입니다. 안 읽은 메일이 없으면 첫 항목이 빠지는 것은 지금과 같습니다.
  • +
  • 옮기기는 내 메일함에만 있습니다. 받은편지함, 보낸편지함 같은 기본 메일함은 옮기지 않습니다.
  • +
  • 하위 메일함이 있어도 옮길 수 있습니다. 하위 메일함과 안의 메일이 함께 가고, 그 메일함으로 옮기는 필터도 그대로 돕니다.
  • +
  • 창을 여는 다른 항목(하위 메일함 만들기, 이름 바꾸기)처럼 이름 뒤에 …를 붙이지 않았습니다.
  • +
  • 메일함을 지우는 동안에는 지금처럼 모든 항목을 누를 수 없습니다. 항목 높이 40은 지금 메뉴와 같습니다.
  • +
+
+ +
+
+ +
+
+

2. 옮기기 창

+

옮길 곳은 맨 위, 받은편지함, 내 메일함 가운데서 고릅니다. 목록의 줄을 눌러 고를 수 있습니다.

+
+
+ +
+
+

‘2025’ 메일함을 어디로 옮길까요?

+

하위 메일함과 안의 메일도 함께 옮깁니다.

+
+ + +

{{ r.name }}

+
+ + + +
+
+
+ + +
+
+
+ +
+

정한 것

+
    +
  • 창 폭은 400입니다(주소록의 그룹 창과 같습니다). 처음에는 아무 곳도 고르지 않은 채 열리고, 고르기 전에는 [옮기기]를 누를 수 없습니다.
  • +
  • 목록은 메일함 패널과 같은 차례와 들여쓰기이고 줄 높이는 40입니다. 고른 줄은 패널의 지금 메일함처럼 청록 바탕에 굵은 글자이고, 오른쪽에 체크 표시가 붙습니다.
  • +
  • 옮기는 메일함(거래처 안의 2025)과 그 안의 정산은 흐리게 두고 고를 수 없습니다. 지금 있는 곳(거래처)도 고를 수 없고 지금 있는 곳 표시를 붙입니다. 맨 위에 있는 메일함이면 맨 위 줄에 붙습니다.
  • +
  • 바로 아래에 같은 이름의 메일함이 있는 곳(보관 안의 2025)도 처음부터 고를 수 없게 하고, 같은 자리에 같은 이름의 메일함이 있습니다 표시를 붙입니다. 대소문자는 가리지 않고 견줍니다. 서버에 묻기 전에 화면이 하는 검사라 화면 상수 글입니다.
  • +
  • 옮길 곳에 보낸편지함, 임시보관함, 스팸메일함, 휴지통은 없습니다. 하위 메일함을 만들 수 있는 곳과 같습니다.
  • +
  • 하위 메일함이 없으면 제목 아래 글은 "안의 메일도 함께 옮깁니다."입니다.
  • +
  • 메일함이 많으면 목록 칸 안에서 스크롤합니다. 화살표 키로 줄을 옮기고, Enter는 [옮기기], Esc는 [취소]와 같습니다.
  • +
+
+ +
+
+ +
+
+

3. 그사이 같은 이름이 생겼을 때

+

드문 경우입니다. 창을 연 사이 다른 창에서 프로젝트 안에 2025를 만들어, 프로젝트를 고르고 [옮기기]를 누르자 서버가 거절한 모습입니다.

+
+
+ +
+
+

‘2025’ 메일함을 어디로 옮길까요?

+

하위 메일함과 안의 메일도 함께 옮깁니다.

+
+ + +

{{ r.name }}

+
+ + + +
+
+ +
+ + +
+
+
+ +
+

정한 것

+
    +
  • 같은 이름은 보통 2의 표시로 미리 막습니다. 이 그림은 창의 목록이 연 때의 것이라 막지 못한 경우입니다.
  • +
  • 글은 오류 사전의 MAIL_009를 그대로 씁니다. 알림은 띄우지 않고 창을 그대로 둡니다(이름 바꾸기 창과 같습니다). 다른 곳을 고르면 글이 사라집니다.
  • +
  • 대소문자만 다른 이름(Report와 report)도 같은 이름으로 봅니다. 미리 막는 검사도 같은 규칙입니다.
  • +
  • 그사이 메일함이 사라졌으면 RES_001 알림을 띄우고 창을 닫은 뒤 메일함 목록을 다시 받습니다(이름 바꾸기 창과 같습니다).
  • +
  • 옮기는 동안에는 [옮기기]를 다시 누를 수 없습니다.
  • +
+
+ +
+
+ +
+
+

4. 옮긴 뒤 알림

+

메일함 패널에 바로 나타나고, 알림의 [되돌리기]로 원래 있던 곳에 되돌립니다.

+
+
+ +
+
+

옮긴 뒤 메일함 패널

+

2025가 정산과 함께 프로젝트 안으로

+
+ +
+ +
+
+

다른 메일함 안으로 옮겼을 때

+
+ + ‘2025’ 메일함을 ‘프로젝트’ 안으로 옮겼습니다. + +
+
+
+

맨 위로 옮겼을 때

+
+ + ‘2025’ 메일함을 다른 메일함 밖으로 옮겼습니다. + +
+
+
+

[되돌리기]를 누른 뒤

+
+ + ‘2025’ 메일함을 ‘거래처’ 안으로 되돌렸습니다. +
+
+
+ +
+

정한 것

+
    +
  • 메일함 패널에 바로 나타납니다. 보던 메일함을 옮겨도 그 메일함을 계속 봅니다.
  • +
  • [되돌리기]는 원래 있던 곳으로 다시 옮깁니다. 단추가 있는 알림은 지금처럼 10초 동안 남습니다.
  • +
  • 받은편지함 안으로 옮겼으면 "‘2025’ 메일함을 받은편지함 안으로 옮겼습니다."처럼 받은편지함에는 따옴표를 붙이지 않습니다.
  • +
+
+ +
+
+ +
+
+

5. 하위 메일함까지 지우기

+

하위 메일함이 있는 메일함을 지우려 하면 지금은 지울 수 없다고만 알립니다. 15단계부터는 하위 메일함까지 함께 지울지 묻습니다.

+
+
+ +
+
+

지금

+

하위 메일함을 먼저 지우라고만 합니다

+
+
+
+

‘거래처’ 메일함을 지울 수 없습니다

+

하위 메일함이 있습니다. 하위 메일함을 먼저 지워 주세요.

+
+ +
+
+
+
+ +
+
+

15단계

+

하위 메일함까지 지울지 묻습니다 · 처음 초점은 [취소]

+
+
+
+

‘거래처’ 메일함과 하위 메일함 2개를 지울까요?

+

하위 메일함에 든 메일까지 모두 40통을 휴지통으로 옮깁니다. 휴지통에 들어간 메일은 30일 뒤 자동으로 지워집니다. 지운 메일함은 되돌릴 수 없습니다.

+
+ + +
+
+
+
+ +
+

정한 것

+
    +
  • 하위 메일함 수는 아래의 모든 깊이를 셉니다(2025와 그 안의 정산으로 2개). 메일 수도 하위 메일함까지 더한 수입니다.
  • +
  • 메일이 하나도 없으면 둘째 글은 "하위 메일함까지 모두 비어 있습니다. 지운 메일함은 되돌릴 수 없습니다."입니다.
  • +
  • 처음 초점은 [취소]이고, 휴지통을 비우는 동안에는 [지우기]를 누를 수 없습니다. 둘 다 지금 지우기 창과 같습니다.
  • +
+
+ +
+

진행과 결과는 지금 메일함 지우기와 같습니다. "‘거래처’ 메일함을 지우는 중 · 12 / 40통" 알림이 끝나면 "‘거래처’ 메일함을 지웠습니다. 안의 메일 40통은 휴지통에 있습니다."로 바뀝니다.

+
+ +
+
+

6. 보낸 사람 차단 창

+

지금 창에 칸 하나를 더해, 받은편지함에 이미 있는 같은 보낸 사람의 메일까지 스팸메일함으로 옮깁니다. 많을 수 있고 한꺼번에 되돌릴 수 없어서 처음에는 꺼져 있습니다.

+
+
+ +
+
+

처음 모습

+

이 주소만 · 새 칸은 꺼져 있습니다

+
+
+
+

보낸 사람 차단

+

앞으로 오는 메일은 스팸메일함으로 옮겨지고, 부재중 자동 응답도 보내지 않습니다.

+
+
+ + +
+
+ + +

옮긴 메일은 한꺼번에 되돌릴 수 없습니다.

+
+

차단은 [설정] → [차단한 주소]에서 풀 수 있습니다.

+
+
+ + +
+
+
+
+ +
+
+

이 도메인 전체를 고르고 새 칸을 켬

+

이 메일이 받은편지함에 있어 첫 칸이 켜진 채 잠깁니다

+
+
+
+

보낸 사람 차단

+

앞으로 오는 메일은 스팸메일함으로 옮겨지고, 부재중 자동 응답도 보내지 않습니다.

+
+
+ + +
+
+ +

받은편지함의 메일을 옮길 때 함께 옮깁니다.

+ +

옮긴 메일은 한꺼번에 되돌릴 수 없습니다.

+
+

차단은 [설정] → [차단한 주소]에서 풀 수 있습니다.

+
+
+ + +
+
+
+
+ +
+
+

스팸메일함의 메일에서 차단할 때

+

첫 칸은 지금처럼 없고 새 칸은 남습니다

+
+
+
+

보낸 사람 차단

+

앞으로 오는 메일은 스팸메일함으로 옮겨지고, 부재중 자동 응답도 보내지 않습니다.

+
+
+ + +
+
+ +

옮긴 메일은 한꺼번에 되돌릴 수 없습니다.

+
+

차단은 [설정] → [차단한 주소]에서 풀 수 있습니다.

+
+
+ + +
+
+
+
+ +
+
+ +
+
+

이 탭에서 필터를 적용하는 중일 때

+

창 안의 두 칸만 그렸습니다 · 새 칸이 꺼지고 까닭이 붙습니다

+
+
+ + +

받은편지함을 정리하는 중이라 지금은 고를 수 없습니다.

+
+
+ +
+

정한 것

+
    +
  • 새 칸은 창을 열 때마다 꺼진 채 시작합니다. 첫 칸은 지금처럼 켜진 채 시작합니다.
  • +
  • 새 칸을 켰는데 이 메일이 받은편지함에 있으면 첫 칸은 켜진 채 잠기고(aria-disabled) 칸 아래에 까닭이 붙습니다. 새 칸을 끄면 첫 칸은 원래대로 돌아옵니다. 이 메일이 다른 메일함에 있으면 잠그지 않습니다.
  • +
  • 이 도메인 전체를 고르면 새 칸의 이 보낸 사람이 이 도메인으로 바뀝니다. 차단처럼 그 도메인만 보고 하위 도메인은 보지 않습니다.
  • +
  • 받은편지함에 있는 메일만 옮깁니다. 직원이 정리해 둔 다른 메일함은 그대로 둡니다(필터 적용과 같습니다).
  • +
  • 이 탭에서 필터를 받은편지함에 적용하는 동안에는 새 칸을 끄고(aria-disabled) 칸 아래 글을 까닭으로 바꿉니다. 7단계 필터 창의 칸과 같습니다.
  • +
  • 두 칸은 붙여 두고, 칸 글이 두 줄이면 고르기 상자를 첫 줄에 맞춥니다. 칸 아래 글은 글 선(28)에 맞춰 들여 씁니다.
  • +
+
+ +
+
+ +
+
+

7. 차단한 뒤 알림

+

새 칸을 켜고 차단하면 차단 알림을 따로 띄우지 않습니다. 알림 하나가 차단했다는 글로 시작해 진행 숫자를 보이다가, 같은 자리에서 결과로 바뀝니다. 7단계 필터 적용의 진행 알림과 같은 결입니다.

+
+
+ +
+
+

새 칸을 켜지 않았을 때

+

지금 차단 알림 그대로

+
+
+ + ‘news@example.com’을(를) 차단하고 이 메일을 스팸메일함으로 옮겼습니다. + +
+
+ +
+
+

새 칸을 켰을 때

+

알림 하나가 진행을 보이다가 같은 자리에서 결과로

+
+
+ + ‘news@example.com’을(를) 차단했습니다. 받은편지함의 메일을 스팸메일함으로 옮기는 중 · 1,200 / 4,800통 확인 +
+
+ + ‘news@example.com’을(를) 차단하고 받은편지함의 메일 37통을 스팸메일함으로 옮겼습니다. + 스팸메일함 보기 +
+
+ + ‘news@example.com’을(를) 차단했습니다. 받은편지함에는 옮길 메일이 없습니다. +
+
+ +
+
+

새 칸을 켰는데 멈췄을 때

+

도중에 멈춤 · 처음부터 실패 · 다른 창에서 하는 중

+
+
+ + ‘news@example.com’을(를) 차단했습니다. 받은편지함은 4,800통 가운데 1,200통까지 확인해 12통을 옮긴 뒤 멈췄습니다. 다시 시도해 주세요. + + +
+
+ + ‘news@example.com’을(를) 차단했지만 받은편지함의 메일은 옮기지 못했습니다. 다시 시도해 주세요. + + +
+
+ + ‘news@example.com’을(를) 차단했습니다. 다른 창에서 받은편지함을 정리하는 중입니다. 끝난 뒤 다시 시도해 주세요. + + +
+
+ +
+
+

정한 것

+
    +
  • 새 칸을 켠 차단은 알림이 하나라 [되돌리기]가 없습니다. 차단은 [설정] → [차단한 주소]에서 풉니다. 새 칸을 켜지 않았으면 지금 알림이 [되돌리기]와 함께 그대로 뜹니다.
  • +
  • 결과와 멈춤 글은 모두 차단했다는 말로 시작합니다. 차단은 이미 끝났고, 받은편지함의 메일 옮기기만 멈춘 것입니다.
  • +
  • 숫자는 확인한 메일 / 받은편지함의 메일 수이고, 최근 메일부터 확인합니다. 첫 응답 전에는 받은편지함의 메일 수를 씁니다.
  • +
  • [스팸메일함 보기]는 스팸메일함 목록으로 갑니다. 옮긴 메일이 없으면 단추가 없습니다. 잘못 옮긴 메일은 스팸메일함에서 [스팸 아님]으로 돌려놓습니다.
  • +
  • [다시 시도]는 멈춘 자리부터, 처음부터 실패했으면 처음부터 합니다. 차단한 뒤에는 ⋯ 메뉴가 차단 풀기로 바뀌어 다시 고를 길이 없으므로, 다른 창에서 하는 중일 때도 단추를 둡니다.
  • +
  • 다른 창에서 하는 중 글은 필터 적용과 함께 씁니다. 지금 필터 글(다른 창에서 필터로 받은편지함을 정리하는 중입니다.)도 구현 때 이 글로 바꿉니다.
  • +
  • 그동안 다른 화면으로 가도 이어 하고, 탭을 닫거나 로그아웃하면 멈추고 알림을 닫습니다. 필터 적용과 이 일은 한 직원에 한 번에 하나만 돕니다.
  • +
+
+
+ +
+
+ + + diff --git a/docs/design/webmail/README.md b/docs/design/webmail/README.md index e2929e0..7f4997a 100644 --- a/docs/design/webmail/README.md +++ b/docs/design/webmail/README.md @@ -1088,8 +1088,8 @@ API 와 서버 규칙은 계약 2절 "실시간 알림". ### 처음 판에서 미룬 것 -본문 강조의 다음 · 이전 이동, 검색어 줄임말(from: 같은 꼴), 대화째 끌어다 놓기, 첨부 파일 안의 글 찾기, 끝이 바뀐 말 찾기(형태소 분석). -같은 대화 칸 머리의 [모두 펼치기]와 펼친 줄 머리의 [답장]도 다음 후보로 둔다(2026-11-07 사용자 관점 점검에서 나온 제안 — 사용자가 미루기로 정함). 지금은 줄마다 펼치고, [답장]은 펼친 본문 아래에만 있다. +본문 강조의 다음 · 이전 이동, 검색어 줄임말(from: 같은 꼴), 첨부 파일 안의 글 찾기, 끝이 바뀐 말 찾기(형태소 분석). +대화째 끌어다 놓기와 같은 대화 칸 머리의 [모두 펼치기] · 펼친 줄 머리의 [답장](2026-11-07 사용자 관점 점검에서 나와 미뤘던 것)은 15단계에서 했다(아래 "15단계"). ## 휴대폰 앱 받기 (2026-11-08 확정 — `SettingsMobileApp`) @@ -1164,6 +1164,114 @@ PC 에서는 설정 › 계정 › 휴대폰 앱(A)이 QR 코드로 휴대폰에 - 서명 키가 다른 판(시험판)이 깔린 휴대폰은 새 판을 덮어 설치할 수 없다 — 그때는 앱을 지운 뒤 설치하고 로그인을 다시 한다. - 미룬 것: 앱 안에서 받아 바로 설치, 최소 판이 아닌 새 판 알림, 아이폰(Ad Hoc · TestFlight), Google 개발자 인증(2027 전 세계 — 그 전에 등록할지 정한다). +## 15단계 — 메일 · 메일함 다루기 (2026-11-09 확정 — `Reader15` · `ListDrag15` · `Mailbox15`) + +메일과 메일함을 옮기고 정리하는 일을 더한다 — 읽기 화면 [이동 ▾] · 끌어다 놓기 · 메일함 옮기기 · 하위 메일함까지 지우기 · 차단할 때 받은 메일 옮기기 · 예약 메일 [지금 보내기]. +서버 약속은 계약 2절("메일함 관리" · "보낸 사람 차단" · "보내기 취소 · 예약 발송")이다. 글의 원본은 화면 상수(`shared/constants/mailbox-manage.ts` · `mail-block.ts` · `mail-send-later.ts` · `mail-filter.ts`)이고 아래는 옮겨 적은 것이다. +새 크기 토큰 · 밀도 예외는 없다 — 옮기기 창의 줄 40 은 메일함 패널의 줄(`--size-row-min`), [지금 보내기] 36 은 안내 줄 단추(`--size-notice-action`)를 다시 쓴다. 문구는 위 「화면 문구 — 사용자에게 말하듯」을 따른다. + +### 읽기 화면 · 목록 · 상단 바 (`Reader15` 1~3 · 6 · `ListDrag15`) + +- **읽기 화면 [이동 ▾]**: 도구 줄 답장 · 전체 답장 · 전달 | **이동 ▾** · 스팸 신고 · 삭제 · ⋯ — 메일이 어디로 가는지를 한 묶음에 둔다(목록 보기는 지금처럼 구분선 없이 [전달] 뒤). + 메뉴는 목록 도구 줄의 [이동 ▾]와 같은 부품(항목 40) · 같은 옮길 곳이다. 그 메일이 든 메일함(검색 결과에서 연 메일도 그 메일이 든 메일함) · 임시보관함 · 휴지통은 없다 — 휴지통으로는 [삭제]로 옮긴다. + 보내기를 기다리는 메일(보내는 중 · 예약)에는 [스팸 신고]처럼 없다. 키보드는 목록의 [이동 ▾]와 같다(Enter · Space · 아래 화살표로 열면 첫 항목에 초점, Esc 는 메뉴만 닫고 [이동 ▾]으로). +- **옮긴 뒤**: [삭제]와 같은 길 — 분할 보기는 목록의 다음 메일을 열고 목록 보기는 목록으로 돌아간다. 알림 "메일 1통을 거래처 › 2025로 옮겼습니다." + [되돌리기](10초 — 되돌리면 "메일 1통을 받은편지함으로 되돌렸습니다."). +- **옮길 메일함은 메일함 패널처럼**: 같은 차례로 깊이만큼 들여 쓰고(10 · 28 · 44 · 56, 3단계까지) 앞에 패널과 같은 아이콘, 기본 메일함과 내 메일함 사이에 가는 선. + 들여 쓴 메일함은 화면 낭독이 위 메일함부터 읽는다("거래처 안의 2025"). 목록의 [이동 ▾]도 같은 모양이다. +- **그 메일이 든 메일함에 하위 메일함이 있으면** 빼지 않고 고를 수 없는 흐린 줄(aria-disabled)로 남기고 이름 아래에 "이 메일이 있는 메일함" — 하위 메일함이 누구 밑에 있는지 보이게. + 하위 메일함이 없으면 지금처럼 뺀다. 목록의 [이동 ▾]는 같은 규칙에 글만 "고른 메일이 있는 메일함"이다. +- **알림의 메일함 이름**: 같은 이름의 메일함이 다른 곳에도 있을 때만 위 메일함 이름을 붙인다("거래처 › 2025로" — 정하는 규칙은 아래 "15단계 구현하며 정한 보완"의 메일함 나무 도우미). + 목록의 [이동 ▾] · 끌어다 놓기 · 되돌리기 알림도 같다. +- **메뉴 자리**: 화면(보이는 영역) 밖으로 넘치면 반대 끝에 맞춘다 — 단추 왼쪽 끝에 맞춘 메뉴가 오른쪽 밖으로 넘치면 메뉴 오른쪽 끝을 단추 오른쪽 끝에. 위아래만 바꾸던 메뉴 자리 정하기에 좌우를 더해 같은 부품의 모든 메뉴에 걸린다. +- **좁은 읽기 칸**: 도구 줄의 이름은 화면 폭이 아니라 읽기 칸 폭(컨테이너 쿼리)으로 숨긴다 — 이름을 한 줄에 다 보일 폭에 여유를 둔 580(도구 줄 좌우 여백 포함 · 휴지통은 [영구 삭제] 때문에 605)보다 좁으면 아이콘만 40×44([이동 ▾]는 ▾ 까지). + 이름은 마우스를 올리면 뜨는 글(title)과 화면 낭독(aria-label)으로 읽힌다. 분할 보기 1144~1233px 에서 두 줄로 꺾이던 일이 없고, 360 에서도 두 줄로 끝난다. +- **끌어다 놓기**(목록 → 메일함 패널): 고른 줄을 끌면 고른 것 모두, 고르지 않은 줄을 끌면 그 줄만(고른 것은 그대로). 대화로 묶은 목록의 줄은 그 대화에서 지금 메일함에 든 메일(보내기를 기다리는 메일은 빼고 — [이동 ▾]와 같다), + 보내기를 기다리는 메일의 줄 · 그런 메일뿐인 대화 줄은 끌 수 없다. + - 끄는 동안 마우스 오른쪽 아래에 "메일 N통" · "대화 N개" 표(알림과 같은 어두운 바탕 · 12.5 굵은 흰 글자 · 봉투 · 말풍선 아이콘). 끈 줄은 흐리게 하지 않는다. + - 놓을 수 있는 메일함 위에서는 그 줄에 2px 청록 점선 · 옅은 청록 바탕(쓰기 화면 파일 놓기와 같은 모양 — 초점 선과 헷갈리지 않게). 놓을 수 없는 곳(지금 메일함 · 임시보관함 · 「예약됨」)은 줄이 그대로이고 마우스 커서만 금지 모양이다. + - 놓을 곳은 [이동 ▾]의 옮길 곳 + 휴지통. 휴지통에 놓으면 [삭제], 스팸메일함에 놓으면 [스팸 신고]와 같고 그 밖은 [이동 ▾]와 같다(알림 · [되돌리기]도 같다). + - 마우스로만 한다 — 키보드 · 터치는 [이동 ▾]. 메일함 패널은 쓰기 화면에도 보이므로 파일 끌기는 받지 않는다(첨부 놓기와 겹치지 않게). 줄의 링크가 끌어 가던 메일 주소(URL)는 싣지 않는다 — 다른 창 · 바깥 프로그램에 놓아도 아무 일 없다. +- **스팸메일함으로 옮기는 세 길 같게**: [스팸 신고] · [이동 ▾]에서 스팸메일함 · 스팸메일함에 끌어다 놓기는 모두 스팸 표시를 붙여 옮긴다. 스팸메일함에서 휴지통이 아닌 메일함으로 옮기면(메뉴 · 끌기) + [스팸 아님]과 같이 스팸 아님 표시를 붙인다 — "메일 1통을 스팸이 아닌 메일로 표시해 거래처로 옮겼습니다." 휴지통으로는 표시를 바꾸지 않는다. [되돌리기]는 표시도 되돌린다. +- **화면에 파일을 잘못 놓으면**(메일 · 주소록 · 설정 — 로그인 뒤 모든 모듈 화면): 지금은 브라우저가 그 파일을 열어 웹메일을 떠났다 — 무시한다(놓는 동안 금지 커서). 쓰기 화면은 지금처럼 어디에 놓아도 첨부로, 서명 설정은 그림으로 받는다. +- **상단 바 좁은 폭**(360 — 바 안쪽 256): [로그아웃]은 줄지도 꺾이지도 않는다. 이름 · 주소는 넘치면 끝을 "…"로 줄이고 마우스를 올리면 다 보인다(title "김아라 (ara.kim@arasystem.it.kr)"). + 화면 폭 480 이하에서는 주소를 감추고 이름만 둔다. 줄 수와 차례(메일 · 검색 칸 · 계정)는 지금과 같다 — 계정 줄을 첫 줄로 올리지 않는다(Tab 차례). 검색 칸은 바 안쪽 폭에 맞게 준다. +- **같은 대화 [모두 펼치기]**: 칸 머리 오른쪽의 글자 단추(36 — 펼친 줄의 [답장]과 같은 크기, 안내 줄 단추 `--size-notice-action` 재사용). 불러온 줄(지금 보는 메일 빼고)을 모두 펼치고 [모두 접기]로 바뀐다 — + 줄을 하나씩 다 펼쳐도 [모두 접기]이고, 하나라도 접혀 있으면 [모두 펼치기]. [더 보기]로 더 받은 줄은 접힌 채로 온다. + 본문은 한꺼번에 받지 않는다 — 화면에 들어오는 줄부터 하나씩(요청 수 한도와 화면 무게), 아직 화면 밖인 줄은 받는 중과 같은 자리만 둔다. + 읽음은 지금 규칙 그대로 — 본문이 화면에 보인 메일만 읽음이 되고 펼쳤어도 화면 밖이면 그대로다. +- **펼친 줄 머리 [답장]**: 펼친 줄 머리 오른쪽(36) — 긴 메일을 끝까지 내리지 않고 답장한다. 줄 머리 전체가 펼침 단추라 머리를 감싸는 틀을 두고 펼침 단추와 [답장]을 나란히 놓는다(마우스를 올린 바탕은 틀 전체). + 아래쪽 [답장] · [이 메일만 열기]는 그대로이고, 임시 저장본 · 예약한 메일의 줄에는 없다(지금과 같다). + +### 메일함 ⋯ 「옮기기」 · 옮기기 창 (`Mailbox15` 1~4) + +- **⋯ 메뉴**: 내 메일함(역할 없는 메일함 — 받은편지함 아래에 만든 메일함 포함)에만 「옮기기」. 차례는 모두 읽음으로 표시 · 하위 메일함 만들기 · 이름 바꾸기 · **옮기기** · 지우기. + 창을 여는 다른 항목처럼 이름 뒤에 …를 붙이지 않는다. 메일함을 지우는 동안에는 지금처럼 모든 항목을 누를 수 없다. +- **옮기기 창**(모달 400): 제목 "‘2025’ 메일함을 어디로 옮길까요?" · 글 "하위 메일함과 안의 메일도 함께 옮깁니다."(하위가 없으면 "안의 메일도 함께 옮깁니다.") → 옮길 곳 목록 → [옮기기] [취소]. + - 목록(테두리 칸 · 줄 40 · 메일함 패널과 같은 차례 · 들여쓰기 10 · 28 · 44 · 56 · 패널 아이콘): 「맨 위」(위 화살표 — "다른 메일함 안에 두지 않습니다") · 받은편지함(과 그 아래) · "내 메일함" 묶음. + 보낸편지함 · 임시보관함 · 스팸메일함 · 휴지통은 없다(하위 메일함을 만들 수 있는 곳과 같다). 메일함이 많으면 목록 칸 안에서 스크롤한다. + - 처음에는 아무 곳도 고르지 않은 채 열리고 [옮기기]는 꺼져 있다. 고른 줄은 패널의 지금 메일함처럼 청록 바탕 · 굵은 글자 · 오른쪽 체크. + - 고를 수 없는 줄은 이름 · 아이콘만 흐리게(까닭 글은 그대로): 옮기는 메일함과 그 하위(까닭 없음) · 지금 있는 곳("지금 있는 곳" — 맨 위의 메일함이면 맨 위 줄) · + 바로 아래에 같은 이름의 메일함이 있는 곳("같은 이름의 메일함이 있습니다" — 대소문자를 가리지 않는다, 서버에 묻기 전에 화면이 하는 검사). + - 줄은 radiogroup 의 radio 다. 화살표로 줄을 옮기면 고를 수 있는 줄은 바로 고르고, Enter 는 [옮기기], Esc 는 [취소]와 같다. 들여 쓴 줄은 위 메일함부터 읽는다("거래처 안의 2025"). +- **그사이 같은 이름이 생겼을 때**(드묾 — 409 `MAIL_009`): 목록 아래 빨간 사전 문구 "같은 이름의 메일함이 이미 있습니다." — 알림 없이 창을 둔다. 다른 곳을 고르면 사라진다. + 그사이 메일함이 사라졌으면(`RES_001`) 알림을 띄우고 창을 닫은 뒤 메일함 목록을 다시 받는다(이름 바꾸기 창과 같다). 옮기는 동안에는 [옮기기]를 다시 누를 수 없다. +- **옮긴 뒤**: 메일함 패널에 바로 나타나고(하위 메일함과 안의 메일이 함께 간다 · 그 메일함으로 옮기는 필터도 그대로 돈다), 보던 메일함이면 그 메일함을 계속 본다. + 알림 "‘2025’ 메일함을 ‘프로젝트’ 안으로 옮겼습니다."(받은편지함은 따옴표 없이 "…받은편지함 안으로 …") · 맨 위로는 "‘2025’ 메일함을 다른 메일함 밖으로 옮겼습니다." + [되돌리기](원래 있던 곳으로 — + "‘2025’ 메일함을 ‘거래처’ 안으로 되돌렸습니다."). + +### 하위 메일함까지 지우기 (`Mailbox15` 5) + +- 하위 메일함이 있는 메일함의 「지우기」는 지금까지의 "지울 수 없습니다" 창 대신 확인 창(처음 초점 [취소]): "‘거래처’ 메일함과 하위 메일함 2개를 지울까요?" · + "하위 메일함에 든 메일까지 모두 40통을 휴지통으로 옮깁니다. 휴지통에 들어간 메일은 30일 뒤 자동으로 지워집니다. 지운 메일함은 되돌릴 수 없습니다." [지우기] [취소]. + 메일이 하나도 없으면 둘째 글은 "하위 메일함까지 모두 비어 있습니다. 지운 메일함은 되돌릴 수 없습니다." 하위 메일함 수 · 메일 수는 모든 깊이를 센다. +- 확인하면 같은 지우기를 하위까지(`?withChildren=true`) 보낸다 — 진행 · 결과는 지금 메일함 지우기 그대로("‘거래처’ 메일함을 지우는 중 · 12 / 40통" → "‘거래처’ 메일함을 지웠습니다. 안의 메일 40통은 휴지통에 있습니다."). + 휴지통을 비우는 동안에는 [지우기]를 누를 수 없다(지금과 같다). + +### 보낸 사람 차단 창 — 받은편지함의 메일도 옮기기 (`Mailbox15` 6~7) + +- 「이 메일도 스팸메일함으로 옮기기」 아래에 「받은편지함에 있는 이 보낸 사람의 메일도 스팸메일함으로 옮기기」 + "옮긴 메일은 한꺼번에 되돌릴 수 없습니다." — 창을 열 때마다 **꺼진 채** 시작한다. + 「이 도메인 전체」를 고르면 "…이 도메인의 메일도 …"(그 도메인만 — 하위 도메인은 보지 않는다). 두 칸은 붙여 두고, 칸 글이 두 줄이면 고르기 상자를 첫 줄에 맞춘다. 칸 아래 글은 글 선(28)에 맞춘다. +- 새 칸을 켰는데 이 메일이 받은편지함에 있으면 첫 칸은 켜진 채 잠기고(aria-disabled) 아래에 "받은편지함의 메일을 옮길 때 함께 옮깁니다." — 새 칸을 끄면 첫 칸은 원래 값으로 돌아온다. + 이 메일이 다른 메일함에 있으면 잠그지 않는다. 스팸메일함의 메일이면 첫 칸은 지금처럼 없고 새 칸은 남는다. +- 이 탭에서 받은편지함을 정리하는 동안(필터 적용 · 다른 차단 정리)은 새 칸을 끄고(aria-disabled) 칸 아래 글을 "받은편지함을 정리하는 중이라 지금은 고를 수 없습니다."로 바꾼다. + 이 탭에서 스팸메일함을 비우는 동안도 끄고 "스팸메일함을 비우는 중이라 지금은 고를 수 없습니다."로 바꾼다(보드에 없다 — 아래 "15단계 구현하며 정한 보완"). +- **차단한 뒤**: 새 칸을 켜지 않았으면 지금 알림 그대로(+ [되돌리기]). 켰으면 차단 알림을 따로 띄우지 않고 알림 하나가 진행을 보이다 같은 자리에서 결과로 바뀐다 — [되돌리기]는 없다(차단은 설정에서 푼다): + "‘news@example.com’을(를) 차단했습니다. 받은편지함의 메일을 스팸메일함으로 옮기는 중 · 1,200 / 4,800통 확인"(확인한 메일 / 받은편지함의 메일 수 — 첫 응답 전에는 받은편지함의 메일 수) → + "‘news@example.com’을(를) 차단하고 받은편지함의 메일 37통을 스팸메일함으로 옮겼습니다." + [스팸메일함 보기] · 옮길 메일이 없으면 "…차단했습니다. 받은편지함에는 옮길 메일이 없습니다."(단추 없음). + - 멈춤: "…받은편지함은 4,800통 가운데 1,200통까지 확인해 12통을 옮긴 뒤 멈췄습니다. 다시 시도해 주세요." + [다시 시도](멈춘 자리부터 · 수는 이어 센다) + - 처음부터 실패: "…차단했지만 받은편지함의 메일은 옮기지 못했습니다. 다시 시도해 주세요." + [다시 시도](처음부터) + - 다른 창에서 하는 중: "…차단했습니다. 다른 창에서 받은편지함이나 스팸메일함을 정리하는 중입니다. 끝난 뒤 다시 시도해 주세요." + [다시 시도] — 차단한 뒤에는 ⋯ 메뉴가 「차단 풀기」라 다시 고를 길이 이것뿐이다. + 서버의 차단 정리는 받은편지함 정리(필터 적용)뿐 아니라 스팸메일함 비우기와도 잠금을 나누어 두 곳을 함께 말한다(2026-11-09 재리뷰). + 필터 적용의 429 글은 "다른 창에서 받은편지함을 정리하는 중입니다. 끝난 뒤 다시 시도해 주세요." 그대로다(7단계의 "다른 창에서 필터로 …"를 이 글로 바꿨다). + - 그동안 다른 화면으로 가도 이어 하고, 탭을 닫거나 로그아웃하면 멈추고 알림을 닫는다. 필터 적용과 이 일은 한 직원에 한 번에 하나다. 잘못 옮긴 메일은 스팸메일함에서 [스팸 아님]으로 돌려놓는다. + +### 예약한 메일 [지금 보내기] (`Reader15` 4~5) + +- 예약한 메일의 청록 안내 줄 단추가 [지금 보내기] [고치기] [예약 취소] 차례다(같은 흰 단추 36 — 누르는 동안 세 단추를 함께 막는다). 예약됨 목록 줄 · 휴대폰 앱에는 두지 않는다. +- 누르면 [보내기]를 누른 것과 같다 — 보내기 취소 설정의 초만큼 기다렸다 보내고, 그동안 "메일을 보냈습니다." + [보내기 취소] + 남은 시간 막대(9단계 마감 알림 그대로). 확인 창은 두지 않는다. + 끔으로 두었거나 예약 시각이 막 지나 이미 보낸 메일이면 오류가 아니라 "메일을 보냈습니다." + [보낸편지함 보기]. 받는 사람 일부가 받지 못하면 쓰기 화면과 같은 문장이다. +- 알림의 [보내기 취소]는 지금의 [보내기 취소]와 같다(그 메일을 쓰기 화면으로 — 다시 예약하려면 [보내기 ▾] → [예약 발송…]). +- 보낸 메일은 예약됨에서 빠지므로 분할 보기는 다음 예약 메일을 열고 목록 보기는 예약됨 목록으로 간다([예약 취소]와 같다). 예약됨 수를 다시 맞춘다. 보낸편지함에서 연 메일이면 그 자리에 두고 안내 줄만 사라진다(날짜 칸은 보내는 동안 "보내는 중"). +- 예약 시각보다 먼저 보내므로 대용량 첨부 링크의 기간은 실제로 보낸 날부터 센다(서버). +- **보내지 못했거나 결과를 모를 때**(서버가 503 `MAIL_001` 로 답함 — 결과를 모르는 실패와 엔진이 거절해 임시보관함으로 되돌린 실패가 같은 꼴이다. + 앞단의 502 · 504 · 시간 초과 · 연결 끊김(`SYS_001`)도 결과를 모르는 실패라 같은 갈래다): 사전 문구 알림은 끄고, 화면이 그 메일을 다시 받아 붙잡은 발송과 메일함으로 가른다(2026-11-09 15단계 리뷰 · 재리뷰). + - 임시보관함: "지금 보내지 못해 임시보관함으로 옮겼습니다." + [임시보관함 보기] — 예약됨을 떠났으니 안내 줄을 내리고 이동은 [예약 취소]와 같다. + - 예약이 그대로(예약을 거두기 전에 멈췄다 — 예약한 시각에 나간다): "지금 보내지 못했습니다. 다시 시도해 주세요." + [다시 시도](같은 요청 — 같은 메일은 요청 하나로 묶는다). 안내 줄은 그대로 둔다. + - 예약만 풀렸다(보낸편지함에 있고 기다리는 발송이 없다 — 예약한 시각에 따로 나가지는 않는다): "메일을 보냈는지 확인하지 못했습니다. [다시 시도]를 누르면 아직 보내지 않았을 때만 보냅니다." + [다시 시도]. + 이미 나갔을 수 있어(보내기 취소 0초로 바로 나감 · 응답만 잃음 · 그사이 예약 시각이 됨) 못 보냈다고 단정하지 않는다 — 그 글을 믿고 손으로 다시 보내면 두 통이 간다(2026-11-09 마지막 확인). + [다시 시도]는 같은 요청이라 안전하다(이미 보낸 메일이면 서버가 200 으로 답한다). 예약이 아닌 것은 확인했으니 "…에 보낼 메일입니다." 안내 줄은 내린다 — 예약됨 · 보낸편지함 · 검색 어디서 열었든. + 예약됨에서는 빠지므로 이동은 [예약 취소]와 같고, 보낸편지함 · 검색에서 연 메일은 그 자리에 둔다. + - 다시 낸 발송이 보내기 취소를 기다린다: 보낸 것과 같다("메일을 보냈습니다." + [보내기 취소]). + - 그 메일을 다시 받지 못했다: 위와 같은 "메일을 보냈는지 확인하지 못했습니다. …" + [다시 시도] — 보냈다 · 못 보냈다 · 되돌렸다고 말하지 않는다(닫힌 쪽). + 안내 줄은 마지막으로 안 상태 그대로 두고, 다음 다시 맞추기에서 바로잡힌다. + - 예약됨 수와 목록은 어느 쪽이든 다시 맞춘다. 받는 사람이 모두 받지 않으면(400 `MAIL_010` · 503 `MAIL_011` — 그 메일은 임시보관함에) 사전 문구 그대로다. + - **알려진 한계**: 드물게 [다시 시도]를 누르지 않고 알림을 닫으면 그 메일은 보내지 않은 채 보낸편지함에 남는다 — 9단계 예약 취소와 같은 한계다. + +### 처음 판에서 미룬 것 + +메일함을 끌어 옮기기 · 순서 바꾸기(옮기기 창으로), 차단할 때 옮긴 메일의 한꺼번에 되돌리기, 예약됨 목록 줄의 단추, 옮기기 단축키, 휴대폰 앱의 [지금 보내기]. + ## 구현하며 정한 보완 (2026-10-26 — 캔버스에는 아직 없다) - 찾지 못한 첨부(보관 시간이 지난 업로드·지워진 원본)는 실패로 표시하고, 전달 원본에는 "빼기"만 둔다(다시 올릴 수 없다). @@ -1574,3 +1682,75 @@ PC 에서는 설정 › 계정 › 휴대폰 앱(A)이 QR 코드로 휴대폰에 - **앱 그림**의 모서리는 한 변의 1/4 이다(48 은 12, 52 는 13 — 보드 값). - **폭 기준**: 설정의 QR 줄을 빼는 좁은 폭은 480 이하(공개 내려받기 화면의 좁은 폭과 같은 값), `/app` 의 넓은 화면은 그 반대(481 이상)다. - 글자 크기 17(`/app` 의 앱 이름 · 받을 앱이 없을 때의 제목)은 토큰 `--font-size-title-md` 를 더했다(크기 · 밀도 예외 토큰은 아니다). + +### 15단계 구현하며 정한 보완 (2026-11-09 — 캔버스에는 아직 없다) + +메일함 · 차단 · 예약 쪽을 먼저, 읽기 화면 · 목록 · 상단 바 쪽을 뒤에 적는다. + +- **메일함 나무 도우미**(`shared/utils/mailbox.ts`): 패널 차례로 편 나무 `mailboxTree`(깊이 · 위 메일함 · 하위가 있는지 · 묶음), 화면 낭독 이름 `mailboxSpokenName`("거래처 안의 2025" — + 더 깊으면 "거래처 안의 2025 안의 정산"), 알림 이름 `mailboxDisplayName`(같은 이름이 다른 곳에도 있을 때만 "거래처 › 2025" — 위 메일함까지 같으면 가를 수 있을 때까지 올라간다, 맨 위의 메일함은 이름 그대로). + [이동 ▾] · 끌어다 놓기 · 옮기기 창이 함께 쓴다. 알림의 이름은 글자 모양으로 견주고(NFC — 대소문자는 가린다), 같은 자리 겹침은 대소문자를 가리지 않는다(엔진과 같다). +- **옮기기 창** + - 옮길 곳은 ⋯ 「하위 메일함 만들기」가 있는 메일함(받은편지함 · 내 메일함)과 맨 위다. 하위를 둘 수 없는 메일함(보낸편지함 등 · 보관함) 아래의 메일함은 그 위까지 모두 둘 수 있는 곳일 때만 넣는다 — + 다른 메일 프로그램이 보낸편지함 아래에 만든 메일함은 옮길 곳에 없다. + - 화살표는 꺼진 줄에도 초점을 옮긴다(까닭을 읽게 — 고른 것은 그대로). 처음과 끝에서 돌고, Home · End 도 된다. 처음 초점은 고를 수 있는 첫 줄이다(로빙 tabindex). + - 맨 위에서 옮긴 것을 되돌린 글은 "‘2025’ 메일함을 다른 메일함 밖으로 되돌렸습니다."(보드에 없다 — 맨 위로 옮긴 글과 같은 결). [되돌리기]가 실패하면(그사이 원래 자리에 같은 이름 · 원래 상위가 지워짐) 사전 문구 알림만. + - 옮길 수 없는 자리였으면(400 `VAL_001` — 그사이 나무가 바뀜) 알림을 본 뒤 메일함 목록을 다시 받고 창은 둔다(다시 고를 수 있게). `REQ_001`(그 메일함을 정리하는 중) 등도 알림만 · 창은 그대로. + - 옮기는 동안 [옮기기]는 `aria-disabled` + `aria-busy`(누른 단추가 초점을 잃지 않게), 고르기 전에는 `disabled`. +- **하위까지 지우기**: 지우는 동안 나무의 모든 메일함(하위 포함)을 "지우는 중"으로 둔다 — 그 메뉴들이 꺼지고, 나무 안의 메일함을 따로 지우기 시작하지 않는다. + 하위 메일함을 보고 있었어도 끝나면 받은편지함으로 간다. 하위가 없는 메일함은 지금처럼 `withChildren` 없이 보낸다. +- **차단할 때 받은 메일 옮기기** + - 「이 메일도 …」가 켜져 있으면 그 메일을 먼저 옮긴 뒤 받은편지함 정리를 시작한다. 그 메일이 받은편지함에 있었으면 옮긴 수에 넣는다. + 옮긴 뒤 읽기 화면은 다음 메일을 열지 않고 목록으로 간다 — 분할 보기는 읽기 칸이 비고 목록 보기는 목록이다(2026-11-09 실사용 시험: 다음 메일이 같은 보낸 사람의 것이면 + 열자마자 읽음이 되고, 곧 정리가 스팸메일함으로 옮겨 받은편지함 주소에 [스팸 아님] · 차단 안내가 뜬 채 남았다). 목록은 정리가 끝나면 다시 맞춘다. 새 칸을 켜지 않은 차단은 지금처럼 삭제와 같이 다음 메일이다. + - 이 탭에서 받은편지함을 정리하는 중에 [다시 시도]를 누르면 "‘…’을(를) 차단했습니다. 받은편지함을 정리하는 중입니다. 끝난 뒤 다시 시도해 주세요." + 같은 [다시 시도](보드에 없다 — 필터 적용의 busy 와 같은 결). + - 그사이 차단을 풀었으면(404 `RES_001`) 진행 알림을 닫고 사전 문구만 보인다. + - 끝났거나 나아간 뒤 멈췄으면 개수를 다시 받고, 받은편지함 · 스팸메일함 목록을 보고 있으면 그 목록도 맞춘다(필터 적용과 같다). + - 되풀이(cursor · 25초 · 나아가지 못한 응답 3번)는 필터 적용과 함께 쓴다(`stores/inbox-cleanup-run.ts`). + - 이 탭에서 받은편지함 정리(필터 적용 · 차단 정리)가 하나라도 도는 동안은 필터 고치기 창의 「받은편지함에 이미 있는 메일도 정리하기」도 끈다 — 차단 창의 새 칸과 같은 규칙(`inboxBusy`)이다. + 두 칸의 까닭 글은 같은 공용 글 "받은편지함을 정리하는 중이라 지금은 고를 수 없습니다."(Mailbox15 보드의 차단 칸 글)이고, 7단계 칸 글 "다른 필터로 받은편지함을 정리하는 중입니다. 끝난 뒤에 고를 수 있습니다."도 + 이 글로 바꿨다 — 칸을 끈 까닭이 필터가 아니라 차단 정리일 수도 있어서다(메인이 정함). + 같은 까닭으로 필터 적용 알림의 [다시 시도]도 이 탭의 차단 정리를 보고(`inboxBusy`), 그때 글은 공용 글 "받은편지함을 정리하는 중입니다. 끝난 뒤 다시 시도해 주세요."다(7단계 "다른 필터로 …"에서 바꿨다). + - 이 탭에서 차단 정리가 도는 동안은 스팸메일함 비우기를 시작할 수 없다(메뉴 · 목록 머리 [비우기] · 확인 단추가 같은 판정 — 휴지통 비우기와 메일함 지우기를 함께 돌리지 않는 규칙과 같은 결). + 옮겨 온 메일이 들어오자마자 영구 삭제되지 않게다. 반대로 이 탭에서 스팸메일함을 비우는 동안은 차단 창의 새 칸을 끄고(까닭 "스팸메일함을 비우는 중이라 지금은 고를 수 없습니다.") 차단 정리를 시작하지 않는다 — + 알림의 [다시 시도]를 누르면 "‘…’을(를) 차단했습니다. 스팸메일함을 비우는 중입니다. 끝난 뒤 다시 시도해 주세요." + 같은 [다시 시도](2026-11-09 재리뷰). + 필터 적용은 스팸메일함에 넣지 않으므로 막지 않는다(`inboxBusy` 는 그대로이고 차단 정리만 `mail-block-run.store` 의 `startBlock` 을 본다). + 다른 창과는 서버가 요청마다 스팸메일함 처리 잠금을 잡아 겹친 요청을 429 로 막지만, 잠금은 요청 하나씩이라 두 창의 비우기와 차단 정리가 요청 사이에 번갈아 돌 수 있다 — + 차단 정리가 옮겨 넣은 메일을 다른 창의 다음 비우기 요청이 지울 수 있다(알려진 한계 — 다른 창끼리의 휴지통 비우기와 메일함 지우기와 같다, 계약 2절 "보낸 사람 차단"). +- **지금 보내기** + - 서버가 혹시 409 `MAIL_014`(이미 보냄)로 답해도 보낸 것으로 다룬다("메일을 보냈습니다." — 약속은 200 이다). + - 503 `MAIL_001` · `SYS_001` 의 갈래(임시보관함 · 예약 그대로 · 예약만 풀림 · 다시 낸 발송이 기다림 · 다시 받지 못함)는 위 "예약한 메일 [지금 보내기]"에 적었다 — 사전 문구 알림은 끄고 화면 글 하나로 알린다. + 판정은 `shared/utils/pending-send.ts` 의 `unsentReleaseState` 하나다(임시보관함을 먼저 보고, 다음으로 기다리는 발송의 종류). 다시 낸 발송이 보내기 취소를 기다리는 갈래는 리뷰에 없던 것이라 구현하며 정했다 — + 같은 요청을 다시 보내도 서버가 그 상태로 200 을 주므로 보낸 것과 같게 알린다(받는 사람별 결과는 모르니 일부가 받지 못한 문장은 없다). + 받는 사람이 모두 받지 않았거나(400 `MAIL_010` · 503 `MAIL_011`) 예약이 그사이 사라졌으면(404) 사전 문구 알림만 보이고 그 자리에 두며, 예약 표시 · 예약됨 수 · 보이는 목록을 서버 기준으로 다시 맞춘다. + - 보내기 취소 알림의 마감은 쓰기 화면과 같은 규칙이다(설정한 초를 알면 "지금 + 초 − 1초", 모르면 보낼 시각까지 · 30초를 넘지 않게 — `undoToastDeadline`). + - 안내 줄이 좁으면(글이 12rem 보다 좁아질 때) 단추 셋을 묶음째 다음 줄 오른쪽으로 보낸다 — 단추가 셋이 되며 360 폭에서 글이 한 글자 폭으로 눌리지 않게(보드는 넓은 폭만 그렸다). +- **모달 창의 제목 · 글**(모든 창 — `ModalDialog`): 낱말 가운데서 줄을 바꾸지 않는다(`word-break: keep-all` — "지울까/요?"가 나왔다, 15단계 전부터). + 띄어쓰기 없는 긴 글(긴 주소 · 파일 이름)은 창 밖으로 넘치지 않게 아무 데서나 끊는다(`overflow-wrap: anywhere`). 슬롯에 넣은 칸 · 목록은 쓰는 쪽이 정한다(2026-11-09 실사용 시험). + +**읽기 화면 · 목록 · 상단 바** + +- **좁은 읽기 칸의 기준(보드에 없는 것)**: 컨테이너는 도구 줄 띠(`.reader__toolbar-bar` — 칸 전체 폭)다. 목록 보기는 앞에 [목록으로] · 이전/다음이 함께 놓여 이름이 다 보이는 줄이 800(콘텐츠 열)보다 조금 넓다 — + 그래서 목록 보기만 단추 줄의 최대 폭을 풀고 접는 폭을 850 · 휴지통 875 로 두었다(분할 보기의 필요 폭에 [목록으로] · 이전/다음 · 위치 글을 더한 어림 — 실사용 시험에서 넉넉했다). 화면 폭 기준은 tokens.css 머리에 한 줄씩 적었다. + 분할 보기는 보드의 570 · 595 가 아니라 580 · 605 다 — 실측한 필요 폭(받은편지함 569.19 · 휴지통 594.83)에 여유가 0.81 · 0.17px 뿐이라 글자 · 여백이 1px 만 늘어도 좁은 구간에서 두 줄로 꺾인다(15단계 실사용 시험). +- **메뉴 폭**: 옮길 메일함 메뉴는 이름 길이만큼(200 부터 320 까지 — 좁은 화면은 화면 폭 − 80 까지) 넓어지고 그보다 긴 이름은 줄을 바꾼다(보드는 줄을 바꾸지 않는 짧은 이름만 그렸다). +- **좌우 바꾸기**: 오른쪽 끝에 맞춘 메뉴(⋯)가 왼쪽 밖으로 넘칠 때도 같은 규칙으로 단추 왼쪽 끝에 맞춘다(360 에서 ⋯ 가 둘째 줄 맨 앞으로 내려간다). 양쪽 다 넘치면 덜 넘치는 쪽이다(위아래와 같은 결). +- **흐린 줄 더**: 하위 메일함이 있는 휴지통 · 임시보관함(다른 메일 프로그램이 지운 폴더를 휴지통 아래에 둔다)도 까닭 글 없이 흐린 줄로 남겨 그 하위 메일함을 고를 수 있게 한다. + 흐린 줄 아래에 보일 하위 메일함이 하나도 없으면 그 줄도 뺀다. +- **전체 메일함 검색에서 [이동 ▾] · 끌기**: 고른 메일이 모두 든 메일함만 "메일이 있는 메일함"으로 친다(메일마다 다르면 아무 데나 옮길 수 있다 — 지금과 같다). +- **끌어다 놓기** + - 줄 전체가 끌린다(링크만이 아니라) — 목록 보기 표의 글은 마우스로 끌어 고를 수 없게 된다(두 번 눌러 고르기는 된다). + - 끄는 표는 메일 화면에 늘 그려 두는 화면 밖 요소를 브라우저가 찍은 것이다(setDragImage) — 운영체제 · 브라우저마다 투명도 · 커서 모양이 조금 다르다. + - 이 탭에서 시작한 끌기만 받는다(다른 탭 · 바깥에서 끌어 온 같은 꼴의 자료는 놓을 수 없다). + - 열어 둔 메일을 끌어 옮겼으면 목록 도구 줄로 옮길 때처럼 목록 주소로 바꿔 끼운다. + - 패널의 메일함이 많아 아래가 가려져 있으면 패널 끝에서 저절로 스크롤되는지는 브라우저가 한다 — 브라우저에서 확인한다. +- **파일 놓기 무시는 앱 틀(AppShell)에**: 처음에는 메일 화면에만 걸어 주소록 · 설정에 놓은 파일은 여전히 브라우저가 열었다(15단계 실사용 시험) — 로그인 뒤 모든 모듈 화면이 쓰는 앱 틀로 옮겼다. + 쓰기 화면 · 서명 설정도 앱 틀 안이라 두 창 처리기가 함께 걸린다 — 파일을 받는 화면이 떠 있으면 무시하는 쪽이 아무것도 하지 않는다(어느 쪽이 먼저 걸렸는지와 상관없이 받는 화면이 받는다). + 앱 틀 밖의 화면(로그인 · 인쇄 · 휴대폰 앱 받기 · 공개 내려받기)은 그대로다. +- **상단 바**: 줄어든 이름 · 주소의 title 은 이름 · 주소 칸 전체에 둔다(넓은 폭에서 주소가 줄어도 같은 글). + 검색 칸(메일 검색 · 비활성 통합 검색)은 최소 폭을 0 으로 둔다 — 입력 칸의 기본 폭에 묶여 360 에서 278 로 바 안쪽(256)을 넘고 화면이 옆으로 10px 밀렸다(14단계부터 — 15단계 실사용 시험). +- **같은 대화 [모두 펼치기]** + - 손으로 펼친 줄은 지금처럼 누른 그 자리에서 본문을 받고 받는 대로 읽음이다. [모두 펼치기]로 펼친 줄만 화면에 들어올 때 차례로 받는다 — 차례가 오기 전에 화면 밖으로 나간 줄은 건너뛰고 다시 들어오면 다시 줄을 선다. + - 받는 사이 화면 밖으로 나갔으면 읽음으로 바꾸지 않고, 다시 들어올 때 읽음이다(읽음이 실패해도 드나들 때마다 다시 묻지 않는다). + - 화면에 들어왔는지 알 수 없는 브라우저(IntersectionObserver 없음)는 펼친 줄이 생기면 들어온 것으로 본다 — 그래도 하나씩 받는다. + - 펼칠 줄이 없으면(지금 메일만 받고 나머지를 받는 중) 단추가 없다. 그사이 지워진 메일(본문이 404)의 줄에는 머리 [답장]도 없다(아래 [답장]과 같다). diff --git a/docs/design/webmail/Reader15.dc.html b/docs/design/webmail/Reader15.dc.html new file mode 100644 index 0000000..67d6d94 --- /dev/null +++ b/docs/design/webmail/Reader15.dc.html @@ -0,0 +1,1274 @@ + + + + +15단계 · 읽기 화면 · 이동 · 지금 보내기 · 같은 대화 펼치기 + + + + + + + +
+ +
+

15단계 · 읽기 화면에서 옮기기 · 지금 보내기 · 같은 대화 모두 펼치기

+

메일 15단계(메일 · 메일함 다루기) 가운데 읽기 화면에 더하는 셋입니다. 1~3은 도구 줄의 [이동 ▾]과 옮길 메일함의 들여쓰기, 4~5는 예약한 메일의 [지금 보내기], 6은 같은 대화 칸의 [모두 펼치기]와 펼친 줄 머리의 [답장]입니다. 화면 글은 지금 화면 상수의 글을 그대로 쓰고, 새로 지은 글은 [지금 보내기], [모두 펼치기], [모두 접기], 들여 쓴 메일함의 화면 낭독 이름(거래처 안의 2025), 흐린 줄의 까닭 글(이 메일이 있는 메일함), 같은 이름이 있을 때 알림에 붙는 위 메일함 이름(거래처 › 2025로)입니다. 예시 날은 2026년 11월 9일(월) 오전이고 직원은 김아라입니다. 메인 검토(정할 것 여섯)를 반영한 초안이며 사용자 확인 전입니다(2026-11-09).

+
+ + +
+

{{ f.title }}

+

{{ f.desc }}

+ +
+ + + +
+
+
+ + +
+ + + +
+ +
+ 메일 +
+ + + +
+
+ +
+ 김아라 + ara.kim@arasystem.it.kr +
+ + + 로그아웃 + +
+
+ +
+ + + +
+
+
+

{{ f.listTitle }}

+ {{ f.listCount }} +
+
+ + + +
+ + + 분할 + + + + 목록 + +
+ +
+
+ +
+ +
+
+
+ + + + 답장 + + + + 전체 답장 + + + + 전달 + + +
+ + +
+ +
+ + + +
+
+
+
+ +
+ + +
+
+ +
+
+

{{ f.subject }}

+ +
+
+ +
+
+ {{ f.fromName }} + {{ f.fromEmail }} + {{ f.date }} +
+ 받는 사람{{ f.to }} +
+
+ +
+ + {{ f.noticeText }} + + + +
+
+
{{ f.body }}
+ +
+

첨부 파일 1개

+
+ +
+ {{ f.attName }} + {{ f.attSize }} +
+ +
+
+
+ +
+
+

같은 대화 6통

+ +
+
    + +
  1. +
    + + + {{ p.who }} + {{ p.mailbox }} + {{ p.preview }} + {{ p.time }} +
    +
  2. +
    +
+
+
+
+
+
+
+ + +
+ + {{ f.toastText }} + +
+
+ +
+ + 메일을 보냈습니다. + + +
+
+
+
+ + +
+
+ +
+
+

2-1. 폭 1024 · 읽기 칸 744 · 이름이 보임

+

읽기 칸이 목록 아래로 내려와 넓어서 이름이 다 보입니다. 읽기 칸에서 왼쪽 640만 잘랐습니다.

+
+
+
+
+
+ 답장 + 전체 답장 + 전달 + + + + + +
+
+
+
+

2025년 유지보수 정산 자료 보내 드립니다

+ +
+
+ +
+ (주)한빛솔루션 영업팀 + sales@example.com +
+
+
+
+
+
+ +
+
+

2-2. 폭 1200 · 분할 보기의 읽기 칸 536 · 아이콘만

+

읽기 칸이 570보다 좁아 아이콘만 남고 한 줄입니다. 화면 폭 1100 기준이던 때 이름이 두 줄로 꺾이던 폭입니다.

+
+
+
+
+ + + + + + + + +
+
+
+
+

2025년 유지보수 정산 자료 보내 드립니다

+ +
+
+ +
+ (주)한빛솔루션 영업팀 + sales@example.com +
+
+
+
+
+ +
+
+
+
+

2-3. 폭 360 · 읽기 칸 288

+

왼쪽 띠는 앱 레일(72)입니다. 읽기 칸이 288이라 아이콘만 남고, [삭제]와 [⋯]가 둘째 줄로 넘어가 두 줄입니다.

+
+
+ +
+
+
+ + + + + + + + +
+
+
+
+

2025년 유지보수 정산 자료 보내 드립니다

+ +
+
+
+
+
+ +
+
+

2-4. 폭 360 · [이동 ▾]을 연 모습

+

메뉴가 화면 오른쪽 밖으로 넘치면 메뉴 오른쪽 끝을 단추 오른쪽 끝에 맞춥니다. 메뉴 자리 정하기에 좌우 바꾸기를 더해, 같은 부품을 쓰는 모든 메뉴에 걸립니다.

+
+
+ +
+
+
+ + + + +
+ +
+ +
+ + + +
+
+
+ + + +
+
+
+
+

2025년 유지보수 정산 자료 보내 드립니다

+ +
+
+
+
+
+
+
+
+ + +
+ +
+
+

{{ col.title }}

+

{{ col.note }}

+
+
+
+
이번 주 안에 회신 주시면 다음 주 월요일에 서명하겠습니다.

이도윤 드림
+
+

첨부 파일 1개

+
+ +
+ 유지보수_계약서_수정본.docx + 52 KB +
+ +
+
+ +
+
+

같은 대화 6통

+ +
+
    + +
  1. + + + + + +
    + + {{ c.who }} + 지금 보고 있는 메일 + {{ c.time }} +
    +
    + +
    + + 답장 +
    + +
    + 받는 사람{{ c.to }} + 제목{{ c.subject }} +
    {{ c.body }}
    + + {{ c.attNote }} + + +
    + + +
    +
    + +
    +
    +
    +
  2. +
    +
+
+
+
+
+
+
+
+ + +
+ +
+
+

{{ t.title }}

+

{{ t.note }}

+
+ +
+ + {{ t.toastText }} + {{ t.actionLabel }} +
+
+ +
+ +
+ + + +
+
+
+
+
+
+
+
+ +
+ +
+

{{ n.title }}

+
    + +
  • {{ i.text }}
  • +
    +
+
+
+
+
+
+ + + diff --git a/docs/design/webmail/canvas.json b/docs/design/webmail/canvas.json index da141b2..791ec14 100644 --- a/docs/design/webmail/canvas.json +++ b/docs/design/webmail/canvas.json @@ -59,9 +59,12 @@ "App1SearchStates.dc.html": { "x": 3178, "y": 33450, "w": 2660, "h": 1028, "title": "앱 · 검색 (상태 · 결과에서 연 메일)", "is_interactive": true }, "App1SearchConditions.dc.html": { "x": 5918, "y": 33450, "w": 1784, "h": 1028, "title": "앱 · 검색 2차 (조건으로 찾기)", "is_interactive": true }, "App1Push.dc.html": { "x": 0, "y": 34900, "w": 2222, "h": 3270, "title": "앱 · 새 메일 알림 (푸시)", "is_interactive": true }, - "SettingsMobileApp.dc.html": { "x": 0, "y": 38770, "w": 1440, "h": 3209, "title": "설정 · 휴대폰 앱 · 앱 받기 화면", "is_interactive": true } + "SettingsMobileApp.dc.html": { "x": 0, "y": 38770, "w": 1440, "h": 3209, "title": "설정 · 휴대폰 앱 · 앱 받기 화면", "is_interactive": true }, + "Reader15.dc.html": { "x": 0, "y": 42600, "w": 1440, "h": 8043, "title": "읽기 화면 · 이동 · 지금 보내기 · 같은 대화", "is_interactive": true }, + "ListDrag15.dc.html": { "x": 1520, "y": 42600, "w": 1440, "h": 3845, "title": "목록 · 끌어다 놓기 · 상단 바 좁은 폭", "is_interactive": true }, + "Mailbox15.dc.html": { "x": 3040, "y": 42600, "w": 1440, "h": 4937, "title": "메일함 옮기기 · 하위까지 지우기 · 차단할 때 받은 메일", "is_interactive": true } }, - "order": ["Main.dc.html", "Login.dc.html", "Compose.dc.html", "States.dc.html", "MainList.dc.html", "MainListRead.dc.html", "ComposeEditor.dc.html", "Settings.dc.html", "ComposeAssist.dc.html", "Search.dc.html", "BulkSelect.dc.html", "ListStates.dc.html", "SettingsAccount.dc.html", "SettingsVacation.dc.html", "MailboxManage.dc.html", "MailActions4.dc.html", "SettingsNotify.dc.html", "MailRealtime.dc.html", "SettingsFilter.dc.html", "MailFilterStates.dc.html", "MailFilterApply.dc.html", "MailFilterApplyStates.dc.html", "Contacts.dc.html", "ContactsEdit.dc.html", "ContactsMail.dc.html", "MailReadTools.dc.html", "MailPrint.dc.html", "MailSendLater.dc.html", "SettingsSendRead.dc.html", "ComposeLargeFiles.dc.html", "LargeFileBlock.dc.html", "MailReadLargeFiles.dc.html", "SharePage.dc.html", "SettingsLargeFiles.dc.html", "LoginTwoFactor.dc.html", "SettingsTwoFactor.dc.html", "SettingsAppPasswords.dc.html", "SettingsLoginHistory.dc.html", "LoginPasswordChange.dc.html", "LoginTwoFactorSetup.dc.html", "SettingsMailClients.dc.html", "MailNoticesCutover.dc.html", "Search14.dc.html", "Thread14.dc.html", "ListThreads14.dc.html", "App1Login.dc.html", "App1MailList.dc.html", "App1Reader.dc.html", "App1Compose.dc.html", "App1Shell.dc.html", "App1Search.dc.html", "App1SearchStates.dc.html", "App1SearchConditions.dc.html", "App1Push.dc.html", "SettingsMobileApp.dc.html"], + "order": ["Main.dc.html", "Login.dc.html", "Compose.dc.html", "States.dc.html", "MainList.dc.html", "MainListRead.dc.html", "ComposeEditor.dc.html", "Settings.dc.html", "ComposeAssist.dc.html", "Search.dc.html", "BulkSelect.dc.html", "ListStates.dc.html", "SettingsAccount.dc.html", "SettingsVacation.dc.html", "MailboxManage.dc.html", "MailActions4.dc.html", "SettingsNotify.dc.html", "MailRealtime.dc.html", "SettingsFilter.dc.html", "MailFilterStates.dc.html", "MailFilterApply.dc.html", "MailFilterApplyStates.dc.html", "Contacts.dc.html", "ContactsEdit.dc.html", "ContactsMail.dc.html", "MailReadTools.dc.html", "MailPrint.dc.html", "MailSendLater.dc.html", "SettingsSendRead.dc.html", "ComposeLargeFiles.dc.html", "LargeFileBlock.dc.html", "MailReadLargeFiles.dc.html", "SharePage.dc.html", "SettingsLargeFiles.dc.html", "LoginTwoFactor.dc.html", "SettingsTwoFactor.dc.html", "SettingsAppPasswords.dc.html", "SettingsLoginHistory.dc.html", "LoginPasswordChange.dc.html", "LoginTwoFactorSetup.dc.html", "SettingsMailClients.dc.html", "MailNoticesCutover.dc.html", "Search14.dc.html", "Thread14.dc.html", "ListThreads14.dc.html", "App1Login.dc.html", "App1MailList.dc.html", "App1Reader.dc.html", "App1Compose.dc.html", "App1Shell.dc.html", "App1Search.dc.html", "App1SearchStates.dc.html", "App1SearchConditions.dc.html", "App1Push.dc.html", "SettingsMobileApp.dc.html", "Reader15.dc.html", "ListDrag15.dc.html", "Mailbox15.dc.html"], "notes": { "flow": { "x": 0, "y": -300, "text": "arasystem 웹메일 — 1단계 화면 (로그인 → 메일함 → 쓰기)", "kind": "title1", "maxW": 4480 }, "phase2": { "x": 0, "y": 2180, "text": "2단계 화면 — 임시 저장 · 받는 사람 자동 완성 · 서명 (2026-10-27 확정)", "kind": "title1", "maxW": 2960 }, @@ -80,7 +83,8 @@ "app1search": { "x": 0, "y": 33150, "text": "앱 1단계 검색 화면 — 찾기 · 결과 · 강조 · 이어 찾기 · 안내 — 2026-11-07 확정(글은 화면 상수가 기준)", "kind": "title1", "maxW": 5838 }, "app1search2": { "x": 5918, "y": 33150, "text": "앱 검색 2차 — 조건으로 찾기 · 날짜 · 조건 줄 — 2026-11-07 확정(글은 화면 상수가 기준)", "kind": "title1", "maxW": 1784 }, "app1push": { "x": 0, "y": 34600, "text": "앱 새 메일 알림 — 허락 묻기 · 설정 · 휴대폰 알림 · 업데이트 · 앱 아이콘 — 2026-11-07 확정(글은 화면 상수가 기준) · 11-08 업데이트 화면 · 앱 정보를 사내 배포에 맞게 바꿈(확정)", "kind": "title1", "maxW": 2222 }, - "appdist": { "x": 0, "y": 38470, "text": "휴대폰 앱 받기 — 설정 › 휴대폰 앱 · 휴대폰 받기 화면(로그인한 직원만) — 2026-11-08 확정(글은 화면 상수가 기준)", "kind": "title1", "maxW": 1440 } + "appdist": { "x": 0, "y": 38470, "text": "휴대폰 앱 받기 — 설정 › 휴대폰 앱 · 휴대폰 받기 화면(로그인한 직원만) — 2026-11-08 확정(글은 화면 상수가 기준)", "kind": "title1", "maxW": 1440 }, + "phase15": { "x": 0, "y": 42300, "text": "15단계 화면 — 메일 · 메일함 다루기(이동 · 끌어다 놓기 · 메일함 옮기기 · 지금 보내기 · 같은 대화) — 2026-11-09 확정(글은 화면 상수가 기준)", "kind": "title1", "maxW": 4480 } }, "designSystems": [] } diff --git a/docs/mail/api-contract.md b/docs/mail/api-contract.md index 1e9f9ce..581fbee 100644 --- a/docs/mail/api-contract.md +++ b/docs/mail/api-contract.md @@ -411,8 +411,8 @@ HTML 은 정화하기 **전에** 길이(HTML 부분이 여럿이면 이어 붙 | --- | --- | --- | --- | | `GET /api/mailboxes` | — | 200 `{ items: Mailbox[], pageInfo }` (hasNext=false) | `MAIL_001` | | `POST /api/mailboxes` | `{ name, parentId? }` (아래 "메일함 관리") | 201 `Mailbox` | 400 `VAL_001`, 404 `RES_001`(없는 parentId), 409 `MAIL_009`(같은 자리에 같은 이름), 503 `MAIL_001` | -| `PATCH /api/mailboxes/{id}` | `{ name }` | 200 `Mailbox` | 400 `VAL_001`(기본 메일함), 404 `RES_001`, 409 `MAIL_009`, 503 `MAIL_001` | -| `DELETE /api/mailboxes/{id}` | — | 200 `MailboxDeletion` — 안의 메일을 휴지통으로 옮긴 뒤 지운다 | 400 `VAL_001`(기본 메일함 · 하위 메일함 있음), 404 `RES_001`, 409 `MAIL_015`(남은 메일이 붙잡힌 메일뿐 — 9단계 "보내기 취소 · 예약 발송"), 429 `REQ_001`(같은 메일함의 처리가 진행 중), 503 `MAIL_001` | +| `PATCH /api/mailboxes/{id}` | `{ name?, parentId? }` — 하나 이상(`parentId` 는 15단계 "메일함 옮기기" — `null` 은 맨 위) | 200 `Mailbox` | 400 `VAL_001`(빈 본문 · 기본 메일함 · 만들 수 없는 자리 · 자기 자신 · 자기 하위 아래), 404 `RES_001`(없는 메일함 · 없는 새 상위), 409 `MAIL_009`, 429 `REQ_001`(옮길 때 — 그 메일함 · 새 상위의 처리가 진행 중), 503 `MAIL_001` | +| `DELETE /api/mailboxes/{id}` | `withChildren=true`(선택 — 15단계 "하위 메일함까지 지우기") | 200 `MailboxDeletion` — 안의 메일을 휴지통으로 옮긴 뒤 지운다(하위까지면 나무 전체) | 400 `VAL_001`(기본 메일함 · 하위 메일함이 있는데 `withChildren=true` 가 아님 · 하위에 기본 메일함 · 다른 값), 404 `RES_001`, 409 `MAIL_015`(남은 메일이 붙잡힌 메일뿐 — 9단계 "보내기 취소 · 예약 발송"), 429 `REQ_001`(같은 메일함(하위까지면 나무의 메일함)의 처리가 진행 중), 503 `MAIL_001` | | `PATCH /api/mailboxes/{id}/messages` | `{ isRead: true }` — 그 메일함 전체를 읽음으로 | 200 `MailboxBulkResult` | 400 `VAL_001`, 404 `RES_001`, 429 `REQ_001`(같은 메일함의 처리가 진행 중), 503 `MAIL_001` | | `DELETE /api/mailboxes/{id}/messages` | — 휴지통 · 스팸메일함 비우기(영구 삭제) | 200 `MailboxBulkResult` | 400 `VAL_001`(휴지통 · 스팸메일함이 아님), 404 `RES_001`, 409 `MAIL_015`(남은 메일이 붙잡힌 메일뿐 — 9단계), 429 `REQ_001`(같은 메일함의 처리가 진행 중), 503 `MAIL_001` | | `GET /api/mail-vacation` | — | 200 `MailVacationState` (아래 "부재중 자동 응답" — `MailVacation` + 7단계의 `isActive`) | 503 `MAIL_001` | @@ -446,13 +446,15 @@ HTML 은 정화하기 **전에** 길이(HTML 부분이 여럿이면 이어 붙 | `GET /api/mail-signature/images/{id}` | — | 그림 파일 (표준 래퍼 없음, 본인 그림만) | 404 `RES_001` | | `GET /api/recipient-suggestions` | `q`(선택, 1~100자) · `limit`(기본 20, 최대 100) · `cursor` | 200 `{ items: RecipientSuggestion[], pageInfo }` (아래 "받는 사람 추천") | 400 `VAL_001`, 503 `MAIL_001` | | `GET /api/scheduled-messages` | `cursor` · `limit`(기본 20, 최대 100) | 200 `{ items: MessageSummary[], pageInfo }` — 예약한 메일, 보낼 시각 차례, `total` 포함 (아래 "보내기 취소 · 예약 발송", 9단계) | 400 `VAL_001`, 503 `MAIL_001` | -| `DELETE /api/scheduled-messages/{id}` | — (`id` 는 보낸 사본의 메일 id) | 200 `{ draftId }` — 붙잡은 발송을 취소하고 임시보관함으로(보내기 취소 · 예약 취소). 이미 취소한 발송이면 다시 200(같은 id) | 404 `RES_001`(형식이 틀린 id 포함), 409 `MAIL_014`(이미 보냄 · 기다리는 발송이 아님), 503 `MAIL_001` | +| `DELETE /api/scheduled-messages/{id}` | — (`id` 는 보낸 사본의 메일 id) | 200 `{ draftId }` — 붙잡은 발송을 취소하고 임시보관함으로(보내기 취소 · 예약 취소). 이미 취소한 발송이면 다시 200(같은 id) | 404 `RES_001`(형식이 틀린 id 포함), 409 `MAIL_014`(이미 보냄 · 기다리는 발송이 아님), 429 `REQ_001`(같은 메일의 지금 보내기가 진행 중 — 15단계), 503 `MAIL_001` | +| `POST /api/scheduled-messages/{id}/release` | — 본문 없음(`id` 는 보낸 사본의 메일 id) | 200 `ReleasedSend` — 예약한 메일을 지금 보낸다(15단계, 아래 "보내기 취소 · 예약 발송"의 "지금 보내기"). 이미 보냈으면 200 `pendingSend: null` | 400 `MAIL_010`(받는 사람 모두 거절 — 임시보관함으로 되돌렸다), 404 `RES_001`(예약이 아닌 메일 · 형식이 틀린 id 포함), 429 `REQ_001`(같은 메일의 예약 취소 · 지금 보내기가 진행 중), 503 `MAIL_001`(임시보관함으로 되돌렸거나 · 결과를 몰라 보낸편지함에 그대로 — 그 메일을 다시 읽어 가른다) · `MAIL_011` | | `GET /api/mail-images/{token}` | — | 바깥 이미지 (표준 래퍼 없음 — 아래 "외부 이미지 보기", 9단계) | 404 `RES_001`(까닭을 가리지 않는다) | | `GET /api/mail-settings` | — | 200 `MailSettings` (아래 "보내기 · 읽기 설정", 9단계) | — | | `PATCH /api/mail-settings` | `Partial` — 하나 이상 | 200 `MailSettings` | 400 `VAL_001` | | `GET /api/mail-blocks` | — | 200 `MailBlockList` (아래 "보낸 사람 차단", 9단계) | 503 `MAIL_001` | | `POST /api/mail-blocks` | `{ address }` — 주소 또는 `@도메인` | 201 `BlockedSender` (이미 있으면 200 — 꺼져 있던 규칙도 켠다) | 400 `VAL_001` · `MAIL_013`, 503 `MAIL_001` | | `DELETE /api/mail-blocks/{address}` | — (URL 인코딩) | 200 `{ address }` | 404 `RES_001`(차단하지 않은 주소 · 틀린 모양), 503 `MAIL_001` | +| `POST /api/mail-block-runs` | `{ sender, cursor }` — 받은편지함에 이미 있는 그 보낸 사람의 메일을 스팸메일함으로(15단계, 아래 "보낸 사람 차단"의 "받은편지함 정리") | 200 `MailBlockRun` | 400 `VAL_001`(형식 · 깨진 cursor · 다른 보낸 사람의 cursor), 404 `RES_001`(차단 목록에 없음), 429 `REQ_001`(같은 직원의 받은편지함 정리(필터 적용 포함) · 스팸메일함 비우기가 진행 중), 503 `MAIL_001`(스팸메일함 없음 포함) | 표에 없는 실패 (구현에서 확정): @@ -1082,12 +1084,12 @@ interface MessageDeletionResult { ```ts interface MailboxCreate { name: string; parentId?: string | null } // parentId 없음 = 맨 위 -interface MailboxRename { name: string } +interface MailboxUpdate { name?: string; parentId?: string | null } // 하나 이상 — 보내지 않은 칸은 그대로. parentId: null = 맨 위 (15단계 — 4단계의 MailboxRename { name } 을 넓혔다) interface MailboxDeletion { - deleted: boolean // true = 지웠다. false = 아직 옮길 메일이 남았다 — 같은 요청을 다시 보낸다 - processed: number // 이번 요청에서 이 메일함에서 빠진 메일 수 — 휴지통으로 옮김 + 다른 메일함에도 있어 이 메일함에서만 뺌 (진행 표시용) + deleted: boolean // true = 지웠다(하위까지면 나무 전체). false = 아직 옮길 메일이 남았다 — 같은 요청을 다시 보낸다 + processed: number // 이번 요청에서 이 메일함(하위까지면 나무)에서 빠진 메일 수 — 휴지통으로 옮김 + 다른 메일함에도 있어 이 메일함에서만 뺌 (진행 표시용) movedToTrash: number // 그중 휴지통으로 옮긴 수 (끝난 알림의 "안의 메일 N통은 휴지통에 있습니다") - remaining: number // 아직 남은 메일 수 + remaining: number // 아직 남은 메일 수 (하위까지면 나무 전체에 — 여러 메일함에 든 메일도 한 번) } interface MailboxBulkResult { processed: number // 이번 요청에서 읽음으로 바꾼 · 지운 메일 수 @@ -1102,14 +1104,35 @@ interface MailboxBulkResult { - **만들 수 있는 자리**: 맨 위, 받은편지함 · 보관함 · 내 메일함(역할 없음) 아래. 보낸편지함 · 임시보관함 · 스팸메일함 · 휴지통 아래는 400 `VAL_001`. 웹 화면은 받은편지함 · 내 메일함 아래만 메뉴에 둔다(보관함 아래는 다른 메일 프로그램이 만든 것을 그대로 보인다). - **기본 메일함**(역할이 있는 메일함)은 이름을 바꾸거나 지울 수 없다 — 400 `VAL_001`. 화면은 메뉴에 그 항목을 두지 않는다. -- **지우기**: 하위 메일함이 있으면 400 `VAL_001`(화면이 먼저 막는다). 안의 메일은 **휴지통으로 옮긴 뒤**(다른 메일함에도 있는 메일은 이 메일함에서만 뺀다) +- **지우기**: 하위 메일함이 있으면 400 `VAL_001`(화면이 먼저 막는다 — 15단계부터는 아래 "하위 메일함까지 지우기"). 안의 메일은 **휴지통으로 옮긴 뒤**(다른 메일함에도 있는 메일은 이 메일함에서만 뺀다) 빈 메일함을 지운다 — 엔진은 메일이 든 메일함을 `mailboxHasEmail` 로 거절하므로(`onDestroyRemoveEmails: false` — 메일을 영구 삭제하는 옵션은 쓰지 않는다) 그사이 들어온 메일도 남지 않는다. 메일이 많으면 한 요청에 다 옮기지 못한다 — `deleted: false` 면 클라이언트가 같은 요청을 다시 보낸다. +- **옮기기**(15단계 — `PATCH /api/mailboxes/{id}` 의 `parentId`): `{ name?, parentId? }` 가운데 바꿀 것만 보낸다 — 둘 다 없으면 400 `VAL_001`, `parentId: null` 은 맨 위. + - 새 상위는 **만들기와 같은 자리**다 — 맨 위 · 받은편지함 · 보관함 · 내 메일함 아래(그 밖은 400 `VAL_001`, 없는 상위는 404 `RES_001`). 기본 메일함은 옮길 수 없다(400 `VAL_001`). + - 자기 자신 · 자기 하위(손주까지) 아래로는 400 `VAL_001` — 서버가 엔진에 묻기 전에 메일함 목록으로 본다. 엔진도 고리를 `invalidProperties`(parentId)로 막는다(2026-11-09 개발 엔진 확인) — + 그 거절이 오면 같은 요청에서 다시 읽은 목록에 새 상위가 있으면 400 `VAL_001`(그사이 생긴 고리), 없으면 404 `RES_001`(그사이 지워짐)이다. + - 새 자리에 같은 이름이 있으면 409 `MAIL_009` — **엔진은 대소문자를 가리지 않는다**(`p15-자식` 도 `P15-자식` 과 겹친다 — 같은 날 확인). 화면은 같은 이름이 있는 곳을 처음부터 고르지 못하게 한다(같은 판정). + - 하위 메일함과 안의 메일은 함께 간다 — 메일함 id 는 그대로라 그 메일함으로 옮기는 필터도 그대로 돈다. 깊이 한도는 두지 않는다(만들기와 같다 — 엔진은 15칸까지 막지 않았다). + - 이름 · 상위가 지금과 같으면 엔진에 묻지 않고 200(그대로). 응답은 지금처럼 `Mailbox`(새 `parentId`). + - 옮길 때(`parentId` 를 보낼 때) 그 메일함이나 새 상위의 오래 걸리는 처리(아래)가 진행 중이면 엔진에 묻지 않고 429 `REQ_001` — 지우는 나무 안으로 옮기거나 지우는 메일함을 빼 가지 않게. 이름만 바꾸기는 지금처럼 잡지 않는다. + - 로그: 실제로 옮겼을 때만, 메일함 id 와 새 상위 id 만(이름 없이 — 사용자가 정한 글이다). +- **하위 메일함까지 지우기**(15단계 — `DELETE /api/mailboxes/{id}?withChildren=true`): `withChildren=true` 일 때만 하위가 있어도 지운다 — 없으면 지금처럼 400 `VAL_001`(옛 화면이 모르고 하위를 지우지 않게). + 값은 `true` 만 받는다(그 밖 · 모르는 쿼리는 400 `VAL_001`). + - 나무(그 메일함과 하위 모두)를 지금 지우기와 같은 규칙으로 — 나무의 메일을 한 조회(`inMailbox` 의 OR — 엔진이 겹치지 않게 센다)로 500통씩 읽어, 나무 안에만 있는 메일은 휴지통으로, + 나무 밖의 메일함에도 있는 메일은 나무의 메일함에서만 뺀다(확인한 상태 그대로일 때만 — `ifInState`). 붙잡힌 메일은 그대로 둔다(9단계 — 남은 것이 그것뿐이면 `MAIL_015`). + - 비었으면 빈 메일함을 **깊은 것부터 한 요청에** 지운다(`onDestroyRemoveEmails: false`) — 엔진은 한 요청의 destroy 를 적은 차례대로 한다(2026-11-09 확인 — 위에서부터 적으면 맨 아래 하나만 지워진다). + 그사이 메일이 들어와 거절되면 지운 하위는 나무에서 빼고 남은 나무의 메일을 다시 옮긴다. 그사이 나무 밖의 하위 메일함이 생기면(`mailboxHasChild`) 목록을 다시 읽어 그 메일함까지 지운다. + - 하위에 기본 메일함(역할이 있는 메일함 — 다른 프로그램이 만든 이상한 나무)이 있으면 400 `VAL_001`. + - 응답은 지금 `MailboxDeletion` 그대로 — 수는 나무 전체의 것이다(`deleted: true` 까지 다시 보낸다). 그 밖의 오류는 지금 지우기와 같다. + - 잠금: 나무의 메일함 모두(아래 "같은 직원 · 같은 메일함의 처리는 한 번에 하나") — 서버는 그 메일함을 먼저 잡고(처리 중이면 엔진에 묻지 않고 429 `REQ_001`) + 메일함 목록을 읽어 하위 메일함까지 잡는다(하위가 처리 중이면 목록만 읽고 429 — 아무것도 바꾸지 않는다). 휴지통 비우기와 함께 돌지 않는 것은 지금처럼 화면이 막는다. - **전체 읽음**(`PATCH /api/mailboxes/{id}/messages {isRead:true}`): 안 읽은 메일을 500통씩 읽음으로 바꾼다. **비우기**(`DELETE /api/mailboxes/{id}/messages`): 휴지통 · 스팸메일함만, 500통씩 확인한 상태 그대로일 때만(`ifInState`) 영구 삭제한다 — 그 메일함에만 있는 메일은 지우고, 다른 메일함에도 있는 메일은 그 메일함에서만 뺀다(`POST /api/message-deletions` 와 같은 규칙). - 오래 걸리는 처리(전체 읽음 · 비우기 · 메일함 지우기)는 한 요청을 25초 안에 끝내고(엔진 호출마다 남은 시간만큼만 기다린다) `remaining` 을 돌려준다. 클라이언트는 0(또는 `deleted: true`)이 될 때까지 다시 보내며 진행을 알림으로 보인다("…하는 중 · 600 / 1,234통"). 도중에 실패하면 거기까지 처리한 수를 알리고, 다시 하면 남은 것만 처리한다. - 같은 직원 · 같은 메일함의 처리는 한 번에 하나다 — 진행 중이면 엔진에 묻지 않고 429 `REQ_001`(다른 탭에서 같은 메일함을 처리할 때). 다른 메일함끼리는 함께 된다. + 하위까지 지우기는 나무의 메일함 모두를(하위는 목록을 읽은 뒤), 옮기기는 그 메일함과 새 상위를 함께 잡는다(15단계 — 하나라도 처리 중이면 429). + 차단할 때 받은편지함 정리(15단계 — 아래 "보낸 사람 차단")가 도는 동안은 그 직원의 스팸메일함도 잡혀 있다 — 그동안 스팸메일함 비우기 · 전체 읽음은 429. - 한 요청의 상한 25초는 메일함 목록을 읽기 전부터 잰다(엔진의 세션 조회 포함). 다음 500통은 남은 시간이 5초보다 많을 때만 시작한다. - 멈추는 경우 — 진행한 것이 있으면 거기까지 200(`remaining` > 0 · `deleted: false`), 하나도 처리하지 못했으면 503 `MAIL_001`(클라이언트가 끝없이 다시 보내지 않게): 아무것도 바뀌지 않는 차례(확인하는 사이 계속 바뀜 · 바뀐 것도 거절도 없음 · 비운 메일함을 지우는 사이 메일이 들어옴)가 이어서 3번, 남은 시간 때문에 엔진 호출이 끊김. @@ -1480,6 +1503,45 @@ interface PendingSend { - 서명 그림 부분 빼기 · `cid:sig-…` 되돌리기는 취소 표시(`$ara-canceled`)가 있는 초안에만 한다(다른 메일 프로그램이 만든 초안의 그림은 건드리지 않는다). - 취소와 옮기기는 다른 요청이다(같은 요청에 두면 취소가 실패해도 옮겨진다). 취소는 됐는데 옮기지 못하면(엔진 오류) 한 번 더 하고, 그래도 못 하면 503 `MAIL_001` — 그 메일은 보내지 않은 채 보낸편지함에 남는다(드묾 — 알려진 한계, warn 로그). + - 같은 메일의 지금 보내기(아래)가 진행 중이면 엔진에 묻지 않고 429 `REQ_001`(15단계). +- **지금 보내기**(15단계 — `POST /api/scheduled-messages/{id}/release`, `id` 는 보낸 사본의 메일 id, 본문 없음): 예약한 메일을 [보내기]를 누른 것처럼 보낸다. + 엔진 사실(2026-11-09 개발 엔진): 기다리는 예약 제출을 취소(`undoStatus: canceled`)하고 같은 메일을 바로 다시 제출하면 받는 사람에게 **정확히 한 통** 간다 — + 취소가 된 뒤에만 다시 내므로 두 번 나갈 틈이 없다(그 전에 엔진이 보냈으면 취소가 `cannotUnsend`). 이미 보냈거나 이미 취소한 제출을 취소해도 `cannotUnsend` 라 서버는 제출의 `undoStatus` 를 다시 읽어 가른다. + ```ts + interface ReleasedSend { // 발송 응답(SentMessage)과 같은 칸 + id: string // 같은 메일 id (보낸 사본) + rejectedRecipients: RejectedRecipient[] // 받는 사람별 결과 — 발송과 같은 규칙(아래 "받는 사람별 결과") + pendingSend: PendingSend | null // { kind: 'undo', sendAt } — 보내기 취소를 기다린다 · null — 바로 보냈다 · 이미 보냈다 + } + ``` + - 차례: 그 메일 · 메일함 · 그 메일의 제출(envelope · 발신 ID · 결과까지)을 한 요청으로 읽고 → 다시 낼 envelope 을 먼저 정한 뒤(읽지 못하면 취소하지 않고 503 `MAIL_001`) → + 기다리는 예약 제출을 취소한다(엔진이 취소를 확인한 것만 — 확인이 없거나 결과를 모르면 다시 읽어 취소됐을 때만 잇고, 아직 기다리면 다시 내지 않고 503 `MAIL_001`) → + **다른 요청으로** 같은 메일을 그 제출의 envelope 그대로(받는 사람 · 참조 · 숨은참조 · envelope 에서 뺀 주소까지 원래 제출의 것 — 머리글에서 다시 만들지 않는다) 다시 제출한다. + MAIL FROM 의 붙잡기 매개변수만 바꾼다 — 그 직원의 보내기 취소 설정(`undoSendSeconds`)만큼 `HOLDFOR`(0 이면 붙잡지 않는다 — 설정을 읽지 못하면 처음 값 10초). + 성공하면 같은 요청에서 예약 표시(`$ara-scheduled`)를 뗀다 — 이제 보내기 취소를 기다리는 메일이다(취소는 지금처럼 `DELETE` — 쓰기 화면으로). + - **이미 보낸 메일**(예약 시각이 막 지나 제출이 `final` · 보낼 시각이 지나 배달 중 · 취소하려니 엔진이 `cannotUnsend`)은 오류가 아니다 → 200 `pendingSend: null`(보낸 것으로). + - **이미 지금 보내기를 한 메일**(취소한 예약 제출 + 그 뒤 새 제출 — 다시 누름 · 응답을 받지 못한 재시도)도 다시 내지 않고 그 새 제출의 지금 상태로 200(기다리면 `pendingSend: { kind: 'undo' }`). + - 예약이 아닌 메일(보내기 취소를 기다리는 메일 · 취소해 임시보관함으로 간 메일(`$ara-canceled`) · 제출이 없음 · 예약하지 않고 보낸 메일) · 없는 메일 · 형식이 틀린 id 는 404 `RES_001` — 아무것도 바꾸지 않는다. + - 앞 요청이 예약을 취소한 뒤 끊겼으면(예약 표시가 있고 취소 표시는 없는데 제출이 모두 `canceled`) 취소를 건너뛰고 다시 제출만 한다 — 보내지지도 예약돼 있지도 않은 메일이 남지 않게(구현하며 정함). + - 다시 제출이 실패하면 둘로 가른다(처음 발송과 같은 결 — 위 "발송 본문"의 "엔진이 받지 않는 주소"에서 요청째 거절과 결과를 모르는 실패를 가르는 것과 같다): + - **엔진이 분명히 거절했다**(SetError · 메서드 오류 · 요청을 실행하지 않았다는 4xx · 429 — 아무것도 나가지 않았다): 한 번 더 낸다. 그래도 거절이면 그 메일을 임시보관함으로 되돌리고 + (예약 취소와 같은 되돌리기 — `$draft` · `$ara-canceled`, 대용량 첨부 링크도 취소한 발송처럼) 503 `MAIL_001`(warn — 메일은 임시보관함에 있다). + - **결과를 모른다**(시간 초과 · 연결 끊김 · 5xx · 해석하지 못한 응답 — 나갔을 수 있다): 다시 내지도 · 임시보관함으로 옮기지도 않는다. 제출을 한 번 다시 읽어(남은 시간이 없어도 3초) + 새 제출이 보이면 그 상태로 200, 아니면 503 `MAIL_001` — 메일은 예약이 취소된 채 보낸편지함에 그대로다(링크도 그대로). 같은 요청을 다시 보내면 위의 "끊긴 지금 보내기"로 이어 낸다 + (그때 새 제출이 보이면 "이미 지금 보내기를 한 메일"로 200). + - 제출마다 먼저 남은 시간을 본다 — 10초보다 적으면 내지 않고 503 `MAIL_001`(결과를 모를 때와 같다 — 메일은 보낸편지함에 그대로, 다시 보내면 이어 낸다). + - 임시보관함으로 되돌리기마저 실패하면 503 `MAIL_001` — 메일은 예약이 취소된 채 보낸편지함에 남는다(위 예약 취소의 "옮기지 못하면"과 같다). + - 503 `MAIL_001` 은 응답만으로는 어느 쪽인지 모른다(응답 꼴은 그대로) — **클라이언트는 503 뒤에 그 메일을 다시 읽어**(`GET /api/messages/{id}`) 임시보관함에 있으면 되돌린 것으로, + 보낸편지함에 있으면 [다시 시도](같은 `POST` — 이어 낸다)를 보인다. + - **알려진 한계**: 결과를 모르는 503 뒤에 다시 보내지 않으면 그 메일은 보내지 않은 채(예약도 취소된 채) 보낸편지함에 남는다 — 9단계 예약 취소의 "옮기지 못하면"과 같은 한계다(드묾, warn 로그). + - 받는 사람이 **모두** 받지 않으면(발송의 `MAIL_010` · `MAIL_011` 과 같은 판정) 보내지 않은 것이다 — 기다리는 새 제출을 취소하고 그 메일을 임시보관함으로 되돌린 뒤 + 400 `MAIL_010` · 503 `MAIL_011`(구현하며 정함 — 발송처럼 사본을 지우지 않는다: 쓰기 화면이 없는 메일이라 잃지 않게). 일부만 받지 않았으면 200 `rejectedRecipients`. + - 대용량 첨부(4절): 다시 제출한 뒤 그 발송이 만든 링크의 **보낸 때를 지금으로 앞당기고 끝나는 날은 그대로 둔다** — 보낸 메일의 덩이가 예약 시각으로 센 끝나는 날을 이미 적었다(앞당기면 받는 사람이 본 날짜보다 먼저 끝난다). + 이미 보낸 메일이면 링크를 건드리지 않는다. + - 같은 직원 · 같은 메일의 예약 취소 · 지금 보내기는 한 번에 하나 — 진행 중이면 엔진에 묻지 않고 429 `REQ_001`. + 서버는 54초 남짓 안에 답한다 — 엔진 호출은 45초 안에서 남은 시간만큼, 정리(새 제출 취소 · 임시보관함으로 되돌리기 · 결과를 모를 때 다시 읽기)는 남은 시간이 없어도 3초씩 더 쓴다. + 앞단 nginx · 클라이언트의 60초 안이다(클라이언트는 발송과 같은 시간 한도로 부른다). + - 로그: 메일 id · 예약했던 시각 · 보낼 시각만(받는 사람 · 제목 없음 — 위 "로그"와 같다). - **중복 발송 방지와 함께**: 취소한 발송의 `Idempotency-Key` 로 다시 보내면 409 `MAIL_005`(보내지 않는다) — 클라이언트는 취소 뒤 새 키로 보낸다. 서버는 취소한 발송의 메일을 "보낸 것"으로도, "끊긴 발송이 남긴 초안"으로도 보지 않는다(지우지 않는다). 취소 표시(`$ara-canceled`)가 있으면 제출 기록과 상관없이 취소한 발송이다. 붙잡는 시간은 지문에 넣지 않는다 — 같은 키로 다시 보내면 `sendAt` 이 달라도 처음 결과(그때의 `pendingSend`)를 돌려준다. @@ -1547,7 +1609,8 @@ interface MailBlockList { ``` - **효과**: 차단한 보낸 사람(머리글 From 의 주소 · 도메인 — 대소문자 무시)의 메일은 스팸메일함으로 간다. 필터 · 부재중보다 먼저 돌고 멈추므로 필터는 돌지 않고 부재중 응답도 나가지 않는다. - 이미 받은 메일은 옮기지 않는다 — 읽기 화면의 차단 창은 그 메일만 함께 스팸메일함으로 옮긴다(`PATCH /api/messages/{id}` 의 `isJunk`). + 이미 받은 메일은 저절로 옮기지 않는다 — 읽기 화면의 차단 창은 그 메일만 함께 스팸메일함으로 옮기고(`PATCH /api/messages/{id}` 의 `isJunk`), + 받은편지함에 있는 그 보낸 사람의 다른 메일은 고르면 아래 "받은편지함 정리"(15단계)로 옮긴다. - **검사**: 주소는 발송의 주소와 같은 규칙에 `@` 앞은 dot-atom · 64자까지(따옴표 앞부분은 받지 않는다 — 클라이언트의 `isStrictEmail` 과 같은 폭, 2026-10-29 리뷰), 도메인은 `@` + 호스트 이름(점이 하나 이상 · 253자 · IP 주소 아님 · ASCII 만 — 국제화 도메인은 `xn--` 꼴로, `${` 는 받지 않는다). 이미 차단한 주소를 다시 `POST` 하면 200 — 엔진에서 규칙이 꺼져 있었으면 같은 원본을 다시 써서 켠다. 그래서 `POST` · `DELETE` 가 성공한 뒤에는 늘 켜져 있다(`isActive: true`). @@ -1568,6 +1631,34 @@ interface MailBlockList { - **클라이언트**: 읽기 화면 ⋯ 「보낸 사람 차단」(사내 직원 · 내 주소면 없고, 이미 차단했으면 「보낸 사람 차단 풀기」, 500개면 꺼짐 + 까닭) → 확인 창(이 주소만 · 이 도메인 전체, 「이 메일도 스팸메일함으로 옮기기」) → `POST`(+ 옮기기) → 알림 + [되돌리기](차단을 풀고 메일을 원래 메일함으로 — 구현하며 정함). 설정 › 메일 › 차단한 주소에서 더하고 푼다. 스팸메일함에서 차단한 보낸 사람의 메일을 열면 안내 줄 + [차단 풀기]. 차단 목록은 메일 화면에 들어올 때 받아 둔다. +- **받은편지함 정리**(15단계 — `POST /api/mail-block-runs`): 차단한 뒤 받은편지함에 이미 있는 그 보낸 사람의 메일을 스팸메일함으로 옮기고 스팸 표시를 붙인다(`$junk` 를 붙이고 `$notjunk` 를 뗀다 — 스팸 신고 `isJunk: true` 와 같다). + ```ts + interface MailBlockRunRequest { + sender: string // 차단 목록의 값 그대로 — 소문자 주소 또는 "@도메인" (BlockedSender.address — 대소문자 · 앞뒤 공백은 서버가 맞춘다) + cursor: string | null // 처음은 null, 이어 할 때는 앞 응답의 cursor 그대로 — 늘 보낸다 + } + interface MailBlockRun { // 이미 받은 메일에 필터 적용(MailFilterRun)과 같은 꼴 + scanned: number // 이번 요청에서 확인한 받은편지함 메일 수 + matched: number // 그중 스팸메일함으로 옮긴 수 (붙잡힌 메일은 옮기지 않아 세지 않는다) + remaining: number // 아직 확인하지 않은 수 — 어림 + cursor: string | null // null = 끝 + } + ``` + - 차단 목록에 없으면(차단한 뒤에만 부른다 · 그사이 풀었다) 404 `RES_001` — 요청마다 본다(도중에 풀면 다음 요청이 멈춘다). 보낸 사람의 모양은 차단하기와 같은 검사(400 `VAL_001`). + - 틀은 위 "이미 받은 메일에 적용"(7단계)과 같다: 받은편지함만(정리해 둔 메일함은 건드리지 않는다) · 받은 시각이 최근인 메일부터 · 첫 요청의 위 경계 뒤에 온 메일은 보지 않는다(새로 온 메일은 차단 규칙이 이미 옮겼다) · + `cursor`(위 경계 · 마지막으로 남긴 메일과 그 받은 시각 · 보낸 사람의 지문 — 다른 보낸 사람의 cursor · 필터 적용의 cursor · 깨진 cursor 는 400 `VAL_001`) · + 한 요청 25초 · 다음 차례는 남은 시간 5초 넘을 때만 · 나아감 없는 차례 3번이면 멈춤 · 하나도 확인하지 못하면 503 `MAIL_001`. 옮기기는 읽은 상태 그대로일 때만(`ifInState`). + - **같은 직원의 받은편지함 정리(필터 적용 · 이것)는 한 번에 하나** — 같은 잠금, 진행 중이면 엔진에 묻지 않고 429 `REQ_001`. + - 요청이 도는 동안 그 직원의 **스팸메일함**을 메일함 처리 잠금(위 "메일함 관리" — 같은 메일함의 처리는 한 번에 하나)으로 잡는다 — 옮겨 넣는 동안 스팸메일함 비우기 · 전체 읽음이 함께 돌지 않게(그동안 그쪽이 429). + 스팸메일함을 비우는 중이면 메일함 목록을 읽은 뒤 429 `REQ_001` — 받은편지함은 건드리지 않았다(비우기가 끝난 뒤 같은 `cursor` 로 다시 보낸다). + 잠금은 **요청 하나씩**이다 — 다른 창에서 비우기와 차단 정리가 함께 진행되면 요청 사이에 번갈아 돌 수 있어, 차단 정리가 옮겨 넣은 메일을 다음 비우기 요청이 지울 수 있다 + (알려진 한계 — 휴지통 비우기와 메일함 지우기의 관계와 같다. 같은 탭에서는 화면이 둘을 함께 돌리지 않는다). + - **판정은 차단 규칙(위 "만든 Sieve")과 같다** — 주소는 From 의 주소 가운데 하나가 같을 때(`address :all :is`), 도메인은 From 주소의 `@` 뒤가 그 도메인과 같을 때(`address :domain :is` — 하위 도메인 · 더 긴 도메인은 아님), + 대소문자는 ASCII 만 무시(`i;ascii-casemap`). 견주는 것은 **엔진이 푼 From 머리글의 주소**(`header:From:asAddresses:all` — 같은 이름의 머리글이 여럿이면 모두)다 — 표시 이름에 그 주소를 쓴 남의 메일은 걸리지 않는다. + 엔진 검색(`Email/query` 의 `from`)은 쓰지 않는다 — 낱말 단위라 넓게 잡는다(2026-11-09 개발 엔진: `kim.lee@` · `@sub.` · `.example.net` · 표시 이름에만 그 주소를 쓴 남의 메일까지). + - 붙잡힌 메일(9단계)은 옮기지 않는다(받은편지함에는 없지만 지금 규칙대로). 스팸메일함이 없으면 503 `MAIL_001`. 되돌리기는 없다(스팸메일함에서 [스팸 아님]으로). + - 로그: 보낸 사람은 가린다(`maskEmail` — 도메인 차단은 도메인만). 확인 · 옮긴 수 · 차례 · 시간만. + - **클라이언트**: 차단 창의 「받은편지함에 있는 이 보낸 사람의 다른 메일도 옮기기」(처음 꺼짐)를 켜면 차단(`POST /api/mail-blocks`)이 성공한 뒤 이 요청을 `cursor` 가 null 이 될 때까지 보낸다 — 진행 · 결과 알림은 디자인 README "15단계". ### 보내기 · 읽기 설정 (`/api/mail-settings`) — 9단계 @@ -1594,8 +1685,10 @@ interface MailSettings { 탭을 모두 닫아도 오는 알림(웹 푸시 — 엔진은 `webpush-vapid` 를 지원한다), 알림 소리(운영체제 설정을 따른다), 휴대폰 푸시, 본문 안 이미지(인라인 cid — 붙여 넣은 이미지는 첨부로, 서명 그림만 예외), 메일 안의 배경 그림 보이기 · 보낸 사람마다 고르는 "이미지 늘 보기" 목록(9단계는 설정 세 가지로), 여러 개의 서명(자체 검색 색인 · 조건 검색 · 검색어 강조는 14단계에서 했다 — 위 "검색 — 14단계"), -끌어다 놓아 옮기기, 메일함 옮기기(다른 상위로) · 하위 메일함까지 한꺼번에 지우기, 앱 비밀번호 · 2단계 인증(엔진은 지원한다), 부재중 응답의 서식 본문 · 사내/외부 구분, -예약한 메일의 「지금 보내기」 · 7일 넘는 예약(엔진 한도) · 차단할 때 이미 받은 그 보낸 사람의 메일 모두 옮기기(9단계는 그 메일만). +메일함을 끌어 옮기기 · 메일함 순서 바꾸기(15단계는 ⋯ 메뉴 「옮기기」 창으로 — 메일을 끌어다 메일함에 놓기는 15단계에서 했다, 서버는 지금 `PATCH /api/messages` 그대로), 옮기기 단축키, +부재중 응답의 서식 본문 · 사내/외부 구분(앱 비밀번호 · 2단계 인증은 12단계에서 했다), 7일 넘는 예약(엔진 한도), 예약됨 목록 줄의 [지금 보내기](15단계는 읽기 화면의 예약 안내 줄에만), +차단 정리의 되돌리기 · 받은편지함 밖 메일함의 정리(15단계는 받은편지함만 — 위 "보낸 사람 차단"의 "받은편지함 정리"). +15단계에 한 것: 메일함 옮기기 · 하위 메일함까지 지우기(위 "메일함 관리"), 차단할 때 받은편지함 정리(위 "보낸 사람 차단"), 예약한 메일 지금 보내기(위 "보내기 취소 · 예약 발송"). --- @@ -2128,6 +2221,9 @@ interface FileUploadCreate { - 덩이는 제출할 때 메일 안에 들어 있다 — 엔진이 붙잡았다가 그대로 보낸다. - 링크의 보낸 때: 예약은 `sendAt`(끝나는 날도 그 날짜로 센다 — 예약 7일 + 30일이면 37일까지 보관), 보내기 취소를 기다리는 발송은 요청한 때(몇 초 차이는 무시). 예약 링크는 보낼 시각 전에도 열린다(주소를 아는 사람은 보낸 사람뿐이다). +- 예약한 메일을 **지금 보내면**(15단계 — 2절 "보내기 취소 · 예약 발송"의 "지금 보내기") 보낸 때를 다시 제출한 때로 앞당긴다(조건부 UPDATE 한 문장 — 취소한 링크 · 이미 그보다 앞선 보낸 때는 그대로). + **끝나는 날은 그대로 둔다** — 보낸 메일의 덩이가 예약 시각으로 센 날짜를 이미 적었다(메일은 고칠 수 없다 — 앞당기면 받는 사람이 본 날짜보다 먼저 끝난다). 파일의 보관 기간도 그대로다. + 끝내 보내지 못해 임시보관함으로 **되돌렸을 때만** 예약 취소처럼 그 링크를 취소한 링크로 둔다 — 결과를 몰라 보낸편지함에 그대로 둔 503 은 링크도 그대로다(다시 보내면 이어 낸다). - 취소(`DELETE /api/scheduled-messages/{id}`)가 엔진에서 확인된 뒤 그 발송이 만든 링크를 취소한 링크로 둔다. 파일은 남는다(되돌린 초안이 쓴다 — 임시 저장 기간으로, 보내지 않은 파일 목록에 다시 나온다). - 되돌린 초안(취소 표시 `$ara-canceled`)을 열면 서버가 원문 HTML 의 표시 사이(덩이)를 정화 **전에** 떼고(`mail.service.ts:1282-1304` 의 `draftHtml` — 취소한 발송의 서명 그림을 되돌리는 그 자리), 텍스트 본문의 덩이도 뗀다(서버가 넣은 글 그대로일 때 — 손대면 남는다). 그 링크의 파일을 `largeFiles` 로 돌려준다 → 쓰기 화면은 대용량 목록으로 보이고, 다시 보내면 새 링크다. diff --git a/docs/roadmap.md b/docs/roadmap.md new file mode 100644 index 0000000..abc7323 --- /dev/null +++ b/docs/roadmap.md @@ -0,0 +1,73 @@ +# ARA SYSTEM 로드맵 + +그룹웨어 전체의 작업 차례다. 단계 번호는 모듈마다 따로 센다 — 메일의 "N단계"는 API 계약([`docs/mail/api-contract.md`](./mail/api-contract.md))과 +화면 디자인([`docs/design/webmail/README.md`](./design/webmail/README.md))의 "N단계"와 같다. 단계마다 **조사 → 캔버스(화면 보드) → 확정 → 구현 → 배포** 차례로 한다. + +마지막 정리: 2026-11-09 + +## 지금 + +- **운영**: 메일 1~13단계. 2026-11-05 옛 메일 서버(mailcow)에서 전 직원이 옮겨 왔다(메일만 옮겼다 — 주소록 · 달력 · 필터 · 부재중 · 서명은 직원이 새로). +- **배포를 기다림**: 메일 14단계 · 앱 서버(앱 로그인 · 새 메일 알림) · 휴대폰 앱 받기 — main 에 합쳤고, 전환 뒤 안정화 1~2주를 지나 11월 셋째 주에 운영에 올린다. +- **개발 중**: 메일 15단계(메일 · 메일함 다루기). +- **다음**: 메일 16단계(개인 설정). + +## 1부 — 메일 + +| 단계 | 내용 | 상태 | +| --- | --- | --- | +| 1 | 웹메일 기본 — 로그인 · 메일함 · 목록 · 읽기 · 쓰기(서식 편집기 · 첨부) | 운영 | +| 2 | 임시 저장 · 서명 · 받는 사람 자동 완성 | 운영 | +| 3 | 새 메일 확인 · 검색 · 여러 통 한꺼번에(읽음 · 중요 · 이동 · 삭제) | 운영 | +| 4 | 메일함 관리 · 비밀번호 바꾸기 · 부재중 자동 응답 · 스팸 · 원문 | 운영 | +| 5 | 실시간 새 메일 · 바탕화면 알림 | 운영 | +| 6 | 메일 필터 | 운영 | +| 7 | 이미 받은 메일에 필터 적용 | 운영 | +| 8 | 주소록(내 연락처 · 그룹 · 사내 목록) | 운영 | +| 9 | 보내기 취소 · 예약 발송 · 외부 이미지 보기 · 인쇄 · 보낸 사람 차단 · 보내기 · 읽기 설정 | 운영 | +| 10 | 파일럿 공존 — 옛 서버와 같은 도메인을 함께 쓰며 먼저 써 보기 | 운영(전환으로 끝남) | +| 11 | 대용량 첨부 — 링크로 보내기 · 조각 올리기 · 30일 보관 · 로그인 없는 내려받기 화면 · 악성 검사 | 운영 | +| 12 | 계정 보안 — 2단계 인증 · 앱 비밀번호 · 로그인 기록 · 로그인된 기기 | 운영 | +| 13 | 전환 준비 — 새 비밀번호 · 2단계 필수 · 메일 프로그램 연결 안내 · 운영 도구 · 백업 · 감시 · 휴지통 · 스팸 30일 | 운영 | +| 14 | 자체 검색 색인 · 조건 검색 · 검색어 강조 · 같은 대화 · 대화로 묶어 보기 | main 에 합침 — 배포 기다림 | +| 15 | 메일 · 메일함 다루기 — 읽기 화면 [이동] · 끌어다 놓기 · 메일함 옮기기 · 하위까지 지우기 · 차단할 때 받은편지함 정리 · 예약 메일 [지금 보내기] · 같은 대화 [모두 펼치기] | 개발 중 | +| 16 | 개인 설정 — **주소록 가져오기 · 내보내기**(vCard · CSV) · 여러 서명(사내 · 대외 · 영문) · 부재중 응답의 서식 본문과 사내/외부 구분 · 2단계 인증 휴대폰 직접 바꾸기 | 다음 | + +- 15단계부터는 직원이 쓰며 바라는 것을 먼저 본다 — 16단계가 끝날 즈음 아래 "추후 후보" 가운데 요청이 실제로 있는 것을 골라 다음 단계로 넣는다. +- 2026-11-09 계획에서 뺀 것: 수신 확인 · 사내 발송 취소(사내 받는 사람에게만 되고, 흔한 실수는 보내기 취소가 막는다 — 아래 후보로). + +## 공통 기반 — 조직 · 권한 + +부서 · 관리자 화면 · 모듈 사이 권한. 파일 공유 · 일정 공유 · 사내 AI 의 데이터 범위가 모두 "누가 무엇을 볼 수 있나"에 달려 2부보다 먼저 한다. + +## 2부 — 다른 웹 모듈 + +파일 저장소 → 캘린더(일정 · 초대) → 사내 AI(메일 · 파일 · 일정을 쓰는 도우미) → 그 밖. 파일 저장소는 11단계 대용량 첨부의 저장 · 올리기를 바탕으로 한다. + +## 3부 — 휴대폰 앱 (웹과 나란히) + +| 판 | 내용 | 상태 | +| --- | --- | --- | +| 앱 1단계 | 로그인(코드 · 새 비밀번호 · 2단계 켜기) · 메일(목록 · 읽기 · 쓰기 · 첨부 · 검색 · 이동 · 삭제) · 새 메일 알림(안드로이드) | main 에 합침 — 서버 배포 뒤 직원 배포 | +| 사내 배포 | 스토어 없이 우리 키로 서명한 APK 를 포털의 휴대폰 앱 받기 화면에서 | main 에 합침 — 서명 키 준비됨 | +| 다음 판 후보 | 주소록 · 대용량 첨부 · 필터 · 서식 편집 · 예약 메일 [지금 보내기] | 첫 판 반응을 보고 고른다 | + +## 추후 후보 (필요해지면) + +단계마다 "처음 판에서 미룬 것" · "이 단계에서 하지 않는 것"을 한데 모은 것이다. 직원 요청이 있거나 다른 모듈을 만들 때 함께 본다. + +- **메일 쓰기 · 읽기**: 수신 확인 · 사내 발송 취소, 본문 안에 그림 넣기(지금은 붙여 넣으면 첨부), 메일 안의 배경 그림, 보낸 사람마다 "이미지 늘 보기", 탭을 모두 닫아도 오는 알림(웹 푸시), + 7일 넘는 예약(메일 엔진 한도라 어렵다). +- **검색**(14단계에서 미룸): 강조한 곳 사이 다음 · 이전 이동, 검색어 줄임말(`from:` 꼴), 첨부 파일 안의 글 찾기, 끝이 바뀐 말 찾기(형태소 분석). +- **메일함 정리**(15단계에서 미룸): 메일함을 끌어 옮기기 · 순서 바꾸기, 옮기기 단축키, 예약됨 목록 줄의 [지금 보내기], 차단할 때 옮긴 메일 한꺼번에 되돌리기 · 받은편지함 밖 메일함 정리. +- **대용량 첨부**(11단계에서 미룸 — 파일 저장소 때 함께): 보관 기간 고르기 · 연장, 다시 보내기, 받는 사람 확인 코드 · 비밀번호, 사내 직원만 받기, 받는 사람별 기록, + 처음 받음 · 만료 전 알림, 미리 보기, 모두 받기(zip), 폴더 올리기, 링크 → 메일에 담기 되돌리기. +- **계정 · 보안**(12 · 13단계에서 미룸 — 공통 기반 때 함께): 복구 코드, 이 브라우저 기억, 패스키, 새 기기 로그인 알림 메일, 계정 변경 기록, 로그인 기록의 나라 · 지역, + 앱 비밀번호 이름 바꾸기 · 만료, 비밀번호 스스로 재설정, 공용 주소로 보내기 · 공유 메일함, 웹메일 관리자 화면. +- **앱**: 앱 안에서 받아 바로 설치, 최소 판이 아닌 새 판 알림, 아이폰, Google 개발자 인증(2027년 전 세계 — 그 전에 등록할지 정한다). + +## 틈날 때 (기능이 아닌 것) + +- 작은 정리: 메일 첨부 받기 경로의 긴 한글 파일 이름(nginx 머리글 버퍼), 백엔드 대소문자 라우팅에 대한 옛 주석, 앱 APK 확인을 서명 지문 대조로. +- 기술 정리(9단계에서 나온 것): 외부 이미지 중계의 접근 로그 양, 실시간 알림에 발송 상태 싣기, 예약 시각 범위의 오류 코드, 모두 4xx 로 붙잡힌 발송의 엔진 동작 실측. +- 의존성 갱신(배포 2주가 지난 판 — 보안 릴리스는 바로)과 코드 정리(쓰기 화면 쪼개기, 운영 이미지의 시험 파일). diff --git a/frontend/README.md b/frontend/README.md index 40e015c..9ca2629 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -1,48 +1,32 @@ -# frontend +# frontend — ARA SYSTEM 웹메일 (Vue 3) -This template should help get you started developing with Vue 3 in Vite. +웹메일 화면이다. 개발에서는 호스트의 vite 로, 운영에서는 nginx 가 화면을 내주고 같은 주소의 `/api` 를 백엔드로 넘긴다. -## Recommended IDE Setup +- 구조 · 개발 규칙(골격 · 스토어 · 컴포저블 · 화면별 파일): [`.claude/skills/vue3/SKILL.md`](../.claude/skills/vue3/SKILL.md) +- 화면 디자인 · 화면 문구 규칙: [`docs/design/webmail/README.md`](../docs/design/webmail/README.md) +- API 약속: [`docs/mail/api-contract.md`](../docs/mail/api-contract.md) -[VS Code](https://code.visualstudio.com/) + [Vue (Official)](https://marketplace.visualstudio.com/items?itemName=Vue.volar) (and disable Vetur). +## 개발 서버 -## Recommended Browser Setup - -- Chromium-based browsers (Chrome, Edge, Brave, etc.): - - [Vue.js devtools](https://chromewebstore.google.com/detail/vuejs-devtools/nhdogjmejiglipccpnnnanhbledajbpd) - - [Turn on Custom Object Formatter in Chrome DevTools](http://bit.ly/object-formatters) -- Firefox: - - [Vue.js devtools](https://addons.mozilla.org/en-US/firefox/addon/vue-js-devtools/) - - [Turn on Custom Object Formatter in Firefox DevTools](https://fxdx.dev/firefox-devtools-custom-object-formatters/) - -## Type Support for `.vue` Imports in TS - -TypeScript cannot handle type information for `.vue` imports by default, so we replace the `tsc` CLI with `vue-tsc` for type checking. In editors, we need [Volar](https://marketplace.visualstudio.com/items?itemName=Vue.volar) to make the TypeScript language service aware of `.vue` types. - -## Customize configuration - -See [Vite Configuration Reference](https://vite.dev/config/). - -## Project Setup - -```sh -npm install +```bash +cd frontend && npm install && npm run dev # http://localhost:5173 — /api 는 vite 프록시가 백엔드 컨테이너로 ``` -### Compile and Hot-Reload for Development +백엔드 · DB · 메일 엔진은 루트의 개발 스택으로 먼저 띄운다(루트 README "빠른 시작"). 포트는 루트 `.env.{mode}` 의 `FRONTEND_PORT` · `BACKEND_PORT` 를 따른다. +API 응답이 JSON 이 아니라 HTML 이면 vite 프록시가 백엔드에 닿지 못한 것이다(백엔드 컨테이너 · 포트를 본다). -```sh -npm run dev +## 검사 + +```bash +npm run lint # 경고 0 +npm run type-check +npm run test:unit # 시간대가 걸린 화면을 고쳤으면 Git Bash 에서 TZ=UTC npm run test:unit -- --run 도 ``` -### Type-Check, Compile and Minify for Production +## 운영 빌드 -```sh -npm run build -``` +운영은 이미지(nginx)로 만든다 — `docker compose --env-file .env.production up -d --build`(docker 스킬 "운영 환경" · "같은 주소 배포"). `VITE_API_BASE_URL` 은 `/api` 로 고정이다. -### Run Unit Tests with [Vitest](https://vitest.dev/) +## 편집기 -```sh -npm run test:unit -``` +VS Code + [Vue (Official)](https://marketplace.visualstudio.com/items?itemName=Vue.volar). 형 검사는 `vue-tsc` 다. diff --git a/frontend/src/components/feedback/ModalDialog.vue b/frontend/src/components/feedback/ModalDialog.vue index e60484e..bc539bc 100644 --- a/frontend/src/components/feedback/ModalDialog.vue +++ b/frontend/src/components/feedback/ModalDialog.vue @@ -11,6 +11,7 @@ // - 닫기는 열기보다 먼저 한다 — 한 대화상자가 닫히고 다른 대화상자가 열리는 때(세션 만료) 둘이 겹치지 않게 // 단추는 actions 슬롯에 둔다: class="modal-dialog__action" (보조 단추는 modal-dialog__action--secondary, 되돌릴 수 없는 일은 --danger 를 더한다) // 글(message) 대신 · 글 아래에 입력 칸을 둘 때는 기본 슬롯에 넣는다 — 글이 없으면 describedBy 로 칸의 안내를 가리킨다 +// 제목 · 글은 낱말 단위로 줄을 바꾸고(keep-all) 띄어쓰기 없는 긴 글만 아무 데서나 끊는다(15단계 실사용 시험) — 슬롯 안의 글은 쓰는 쪽이 정한다 import { computed, onMounted, ref, watch } from 'vue' interface Props { @@ -190,6 +191,16 @@ onMounted(() => { background: var(--color-backdrop); } +/* + * 제목 · 글은 낱말 가운데서 줄을 바꾸지 않는다("지울까/요?" — 2026-11-09 실사용 시험). 띄어쓰기 없는 긴 글(긴 주소 · 파일 이름)은 + * 창 밖으로 넘치지 않게 아무 데서나 끊는다 — 둘을 함께 둔다 + */ +.modal-dialog__title, +.modal-dialog__message { + word-break: keep-all; + overflow-wrap: anywhere; +} + .modal-dialog__title { margin: 0; font-size: var(--font-size-title-sm); diff --git a/frontend/src/components/mail/ConversationListItem.vue b/frontend/src/components/mail/ConversationListItem.vue index 8dcdaed..d7d3901 100644 --- a/frontend/src/components/mail/ConversationListItem.vue +++ b/frontend/src/components/mail/ConversationListItem.vue @@ -5,11 +5,14 @@ // - 줄의 메일이 모두 보내기를 기다리는 메일이면 고르기 · 별을 쓸 수 없다(aria-disabled + 까닭은 title — 메일 줄의 고르기 칸과 같은 결) // - 마지막 메일이 붙잡힌 메일이면 시각 자리가 "보내는 중" · "예약 · …"(청록 굵게 — 메일 목록과 같다) // - 임시보관함의 제목 없는 저장본은 메일 줄처럼 흐린 "(제목 없음)" (리뷰 L5) +// 끌어다 놓기(15단계 — 디자인 원본 ListDrag15): 줄을 메일함 패널로 끌어 옮긴다 — 고른 줄이면 고른 것 모두, 아니면 이 줄만. +// 그 대화에서 이 메일함에 든 메일을 옮기고(붙잡힌 메일은 빼고), 그런 메일뿐인 줄은 끌 수 없다. 줄의 링크가 끌어 가던 메일 주소 대신 우리 끌기 자료만 싣는다 (useMailRowDrag) import { computed } from 'vue' import type { RouteLocationRaw } from 'vue-router' import AppIcon from '@/components/AppIcon.vue' import MailCheckbox from '@/components/mail/MailCheckbox.vue' +import { useMailRowDrag } from '@/composables/useMailDrag' import { MAIL_BULK_TEXT, MAIL_LIST_ROW_TEXT, MAIL_NO_SUBJECT } from '@/shared/constants/mail' import { MAIL_CONVERSATION_LIST_TEXT } from '@/shared/constants/mail-conversation' import { SEND_LATER_TEXT } from '@/shared/constants/mail-send-later' @@ -55,6 +58,8 @@ const props = withDefaults(defineProps(), { const emit = defineEmits() +const drag = useMailRowDrag() + const subject = computed(() => props.row.latest.subject || MAIL_NO_SUBJECT) const countText = computed(() => conversationCountText(props.row)) /** 마지막 메일이 붙잡아 둔 발송이면 "예약 · …" · "보내는 중" (9단계) */ @@ -85,7 +90,10 @@ function onStar() { { 'is-selected': selected, 'is-unread': unread, 'is-checked': checked, 'is-checking': checking }, ]" :data-row-id="row.id" + :draggable="!held" @mousedown="onMouseDown" + @dragstart="(event: DragEvent) => drag.start(event, row.id)" + @dragend="drag.end" >

() /** 펼친 안의 id — 메일 id 는 서버의 불투명한 글이라 그대로 붙인다 */ const panelId = computed(() => `conversation-panel-${props.row.message.id}`) +/** 펼친 줄 머리의 [답장] — 답장할 수 있는 메일이고(row.replyLocation) 그사이 지워지지 않았을 때 (펼친 안 아래 [답장]과 같은 조건) */ +const headReply = computed(() => { + if (!props.expanded || !props.row.replyLocation) return null + const detail = props.detail + if (detail?.status === 'error' && detail.error.code === 'RES_001') return null + return props.row.replyLocation +})