Compare commits

..

10 Commits

Author SHA1 Message Date
ttipo a39f8bd630 docs(rules): 6단계 메일 필터 부품 · JSON 본문 모양 한도 · Sieve 규칙
CI (Flutter) / Flutter (analyze · test) (pull_request) Successful in 24m1s
CI / Backend (lint · type-check · test · e2e · build) (pull_request) Failing after 7m45s
CI / Frontend (lint · type-check · test · build) (pull_request) Successful in 11m39s
CI / Infra (compose · Dockerfile · nginx) (pull_request) Successful in 23s
CI / 에러 코드 SSOT 동기화 검사 (pull_request) Successful in 10m20s
nestjs · vue3 스킬에 6단계 파일과 규칙을 적는다(Sieve 글 만들기 · 스팸 판정 · 원본 판 ·
되돌리기 차례 · 메뉴의 꺼진 항목). security 에는 JSON 본문 모양 한도와 ArrayUnique 금지를 적는다.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 21:00:01 +09:00
ttipo 894550732e feat(frontend): 설정 › 메일 › 필터 · 읽기 화면에서 필터 만들기
확정 보드(SettingsFilter · MailFilterStates)대로 필터 목록 · 켜기 · 순서 · 지우기(되돌리기) ·
고치기 창을 만들고, 읽기 화면 ⋯ 에 「이런 메일로 필터 만들기」를 둔다.

- 저장은 한 번에 하나다. 되돌리기는 누른 차례대로, 앞 저장이 끝난 뒤에 보낸다 —
  연달아 누르면 하나가 사라지던 경합을 리뷰에서 찾았다
- 다른 곳에서 먼저 바꿨으면 창 안에서도 다시 불러올 수 있고, 쓴 내용은 지킨다
- 메뉴의 꺼진 항목은 aria-disabled 로 둔다(편집기 메뉴 공용) — 키보드로 가서 까닭을 들을 수 있다

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 20:59:53 +09:00
ttipo 7ce234750d fix(backend): JSON 본문 모양 한도 · UTF-8 만 — 본문 검증 DoS
본문 크기 한도 안에서도 작은 객체 수만 개면 DTO 변환 · 검사가 서버를 수 초 멈추고
수백 MB 를 썼다(리뷰 측정: 1MB 필터 본문 7.8초 · +329MB, 깊은 중첩은 500 SYS_001,
4MB 발송 본문은 한 건으로 메모리 초과 추정). DTO 의 개수 한도로는 막지 못한다 —
변환은 항목마다 객체를 만들고, 검사는 한도에 걸려도 항목마다 중첩 검사를 한다.

- 모든 JSON 본문을 DTO 보다 앞에서 잰다(배열 1,000 · 값 10,000 · 깊이 32).
  읽기 전에 UTF-8 바이트를 한 번 훑고, 읽은 뒤에 한 번 더 본다. 넘으면 400 VAL_001
- JSON 은 UTF-8 만 받는다. 다른 utf-* 본문은 바이트로 셀 수 없다
- 전역 파이프에 stopAtFirstError 를 켠다. 항목 수의 제곱이던 ArrayUnique 대신
  Set 으로 보는 ArrayDistinct 를 쓴다

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 20:59:42 +09:00
ttipo b4d30482fe feat(backend): 메일 필터 API · 부재중을 Sieve 스크립트로
GET·PUT /api/mail-filters 를 더한다. 4단계 부재중 자동 응답의 저장 자리는 엔진의
VacationResponse 에서 우리 Sieve 스크립트(arasystem)로 옮긴다 — API 는 그대로다.

- 엔진은 계정마다 켜진 스크립트가 하나라 필터와 부재중을 한 스크립트에 둔다.
  읽을 때는 머리 주석의 원본(JSON)만 믿고, 쓸 때는 읽은 상태 그대로일 때만 쓴다(ifInState)
- 다른 곳에서 먼저 바꿨으면 409 MAIL_012, 만든 글이 90,000바이트를 넘으면 400 MAIL_013
- 스팸은 엔진의 분류 표시(X-Spam-Score)를 보고 건너뛴다. 처음에는 spamtest(정수로 내린
  점수)로 막았는데, 실엔진에서 9.7점 스팸에 규칙과 부재중 응답이 나가는 것을 찾았다
- 엔진의 오류 설명은 로그에 줄 · 칸만 남긴다. 새 판 원본은 덮어쓰지 않는다(배포를 되돌릴 때)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 20:59:31 +09:00
ttipo 41b5fdbe22 docs: 6단계(메일 필터) 계약과 디자인 · JSON 본문 모양 한도
메일 필터(GET·PUT /api/mail-filters)와, 부재중 자동 응답의 저장 자리를 엔진의 Sieve
스크립트 하나로 옮기는 계약을 적는다. 확정한 캔버스 보드(SettingsFilter · MailFilterStates)와
구현하며 정한 보완도 함께 둔다.

- 엔진(Stalwart 0.16.27)은 계정마다 켜진 스크립트가 하나뿐이다. 엔진의 부재중도 스크립트라
  따로 두면 하나를 켤 때 다른 하나가 꺼진다. 그래서 한 스크립트에 함께 둔다
- 스팸 막기는 엔진의 분류 표시(X-Spam-Score)로 한다. spamtest 는 점수를 정수로 내린 값이라
  10 으로 막으면 5~10점 스팸을 놓친다(실엔진에서 확인)
- 모든 JSON 본문에 모양 한도(배열 1,000 · 값 10,000 · 깊이 32)를 걸고 UTF-8 만 받는다.
  DTO 의 개수 한도로는 본문 검증 DoS 를 막지 못했다(리뷰 측정)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 20:58:51 +09:00
ttipo b6aa73c611 docs(rules): 받는 사람별 발송 결과 · 엔진 한도 규칙
- nestjs: jmap/delivery-status.ts, 조건 없는 destroy 대상에 "받는 사람이 모두 거절한 발송의 보낸 사본"
- vue3: 오류 알림의 일부 거절 쓰임, 부재중 길이는 켤 때만
- docker: mail setup 의 받는 쪽 한도

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 17:11:27 +09:00
ttipo 4d95fc28d8 fix(docker): mail setup — 받는 사람 단위 받는 쪽 한도를 인증 없는 세션에만
- 엔진 기본 "Sender address to recipient throttle"(같은 보낸 도메인 → 한 받는 사람, 시간당 25)의 match 를 is_empty(authenticated_as) 로 —
  사내 메일이 동료 모두 합쳐 한 사람에게 25통을 넘으면 웹메일 발송이 452 로 거절되던 문제. 바깥에서 들어오는 메일에는 그대로
- "Sender IP throttle"(초당 연결 5개)은 그대로 — 연결 단계 한도라 웹메일 발송에는 걸리지 않고, 조건을 붙이면 MAIL FROM 단계로 옮겨진다
- mail/README.md "엔진 한도" — 기본값 · 바꾸는 까닭 · 확인 결과 · 운영 전에 정할 것

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 17:11:26 +09:00
ttipo 3634cee955 fix(frontend): 받는 사람별 발송 결과 알림 · 보내는 중 세션 바뀜 막기 · 부재중 한도
- 일부 거절: 보낸 것으로 떠나며 "보냈지만 N명은 받지 못했습니다 — 주소 (까닭)" 오류 모양 알림 + [보낸편지함 보기]
- MAIL_010: 받는 사람 안내 띠 · 초점(알림 없음, 칸은 aria-invalid · aria-describedby), MAIL_011: 인터셉터 알림 — 둘 다 같은 키로 다시 보낸다
- 보내기를 시작한 세션이 바뀌면(로그아웃 · 만료 · 다른 계정 로그인) 보내지 않고, 결과 알림 · 보내지 못한 메일의 저장본 남기기도 하지 않는다
  — 다른 계정의 쿠키로 앞 사용자의 메일이 나가거나 저장되던 틈
- 응답의 rejectedRecipients 는 sendMessage 한 곳에서 배열로 맞춘다(필드가 없는 옛 서버)
- 부재중 자동 응답 제목 150 · 내용 600 (켤 때 검사)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 17:11:26 +09:00
ttipo 19c20eb617 fix(backend): 발송의 받는 사람별 결과 · 부재중 길이 한도
- 제출 다음에 EmailSubmission/get 으로 받는 사람별 결과를 읽는다(jmap/delivery-status.ts) — 일부 거절은 201 { id, rejectedRecipients },
  모두 거절이면 이 요청이 만든 보낸 사본을 지우고 400 MAIL_010(5xx 하나라도) · 503 MAIL_011(모두 4xx).
  이어 쓴 저장본은 지우지 않고 같은 키 잠금도 붙잡지 않는다 — 같은 키로 다시 보내면 새로 보낸다
- 같은 키 재시도는 처음 메일의 제출 기록으로 결과를 다시 만든다. 결과 읽기는 발송 55초 안에서 최대 5초, 읽지 못하면 받은 것으로 본다(warn)
- 로그는 가린 주소와 SMTP 상태 코드만
- 부재중 자동 응답: 켤 때 제목 150 · 내용 600, 꺼 둔 채로는 200 · 4,000
- 에러 코드 MAIL_010 · MAIL_011 과 생성 사전(frontend · flutter)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 17:11:25 +09:00
ttipo ac418e3aa9 docs: 발송의 받는 사람별 결과 · 부재중 길이 한도 (6단계 앞 수정)
- 계약 2절 "받는 사람별 결과" — 엔진이 제출 때 거절한 받는 사람(deliveryStatus)을 201 의 rejectedRecipients 로 알리고,
  모두 거절이면 보내지 않은 것으로 다룬다(보낸 사본을 지우고 MAIL_010 · MAIL_011). 같은 키 재시도 · 분류 규칙 · 알려진 한계 · 로그
- 부재중 자동 응답 — 켤 때 제목 150 · 내용 600. 엔진이 UTF-8 511 · 2,047바이트로 막아 4단계의 200 · 4,000 은 한글로 저장되지 않았다.
  꺼 둔 채로는 4단계 한도까지 받는다(켜진 옛 긴 글도 끌 수 있게)
- 엔진의 받는 쪽 한도는 로그인한 직원의 발송에 걸지 않는다(mail setup)
- 디자인 README — 일부 거절 알림 · MAIL_010 안내 띠 · 부재중 길이

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 17:11:13 +09:00
129 changed files with 14824 additions and 632 deletions
+3
View File
@@ -27,6 +27,9 @@
## 입력 · 출력
- 입력값은 DTO(`class-validator`) 검증을 거친 뒤 서비스 계층에 전달한다. 전역 `ValidationPipe` 의 `whitelist`/`forbidNonWhitelisted` 를 끄지 않는다
- JSON 본문은 DTO 에 닿기 전에 **모양부터 잰다** — 배열 하나 1,000개 · 값 10,000개 · 깊이 32단, UTF-8 만 (`app.setup.ts` 의 `JSON_SHAPE_LIMITS`, `shared/utils/json-shape.ts`).
DTO 의 개수 한도로는 막지 못한다 — 변환이 항목마다 객체를 만들고, 검사는 개수 한도에 걸려도 항목마다 중첩 검사를 해서 1MB 본문 하나로 서버가 수 초 멈췄다 (2026-10-28 리뷰).
class-validator 의 `ArrayUnique` 는 항목 수의 제곱으로 느려 쓰지 않는다 (`common/decorators/array-distinct.decorator.ts` 의 `ArrayDistinct`)
- TypeORM ORM 쿼리 사용으로 SQL Injection 차단 (Raw 쿼리 시 파라미터 바인딩 필수)
- 외부로 나가는 데이터(보내는 메일의 헤더 등)에 비교용 지문을 실어야 하면 평범한 해시가 아니라 **받는 쪽이 모르는 비밀로 HMAC** 한다
(예: 메일마다 다른 무작위 `Idempotency-Key`). 평범한 해시는 받은 사람이 후보 값(숨은참조 주소 등)을 대입해 맞춰 볼 수 있다.
+2
View File
@@ -293,6 +293,8 @@ node --env-file=.env.development mail/scripts/setup.mjs
`MAIL_ATTACHMENT_TOTAL_MAX_SIZE_MB`(기본 25) 를 compose 로 받는다.
- 데이터는 named volume `<PROJECT_NAME>_mail-config`·`<PROJECT_NAME>_mail-data` (UID 2000 소유가 필요해 바인드 마운트를 쓰지 않는다).
- setup 이 잠그는 것: 공개 443 의 JMAP·관리 API·웹 포털(→ 403, MTA-STS 정책 파일만 연다), 직원 역할의 계정 목록(Principal) 조회.
- setup 은 엔진의 받는 쪽 한도 "같은 보낸 도메인 → 한 받는 사람"(기본 시간당 25)을 **인증 없는 세션에만** 건다 — 사내 메일은 보낸 도메인이 같아 동료 모두 합쳐
한 사람에게 25통을 넘으면 웹메일 발송이 거절됐다(2026-10-28). 보낸 IP 마다 초당 연결 5개 한도는 그대로. 한도 값 · 직원마다 내보내는 양의 한도는 운영 전에 정한다 (mail/README.md "엔진 한도").
신뢰 대역은 고정한 app-network 서브넷(`APP_NETWORK_SUBNET`) 안으로만 받는다 — 더 넓으면 실패한다.
- setup 은 **바꾸기 전에** 앱 네트워크 서브넷·구성원(frontend 등이 붙어 있지 않은지)·백엔드 주소를 확인하고, 어긋나면 아무것도 바꾸지 않고 멈춘다.
**운영은 docker 를 쓸 수 있는 사용자로 호스트에서 돌린다** (docker 그룹 또는 `sudo -E`) — docker 가 없으면 바로 실패한다.
+12 -4
View File
@@ -22,15 +22,17 @@ description: NestJS 백엔드(backend/) 개발 규칙 — 컨트롤러/서비스
| 요청 추적 미들웨어 | `src/common/middleware/request-id.middleware.ts` | `x-request-id` 발급 → 로그·응답 `traceId` ([[logging]]). `configureApp()` **맨 앞**의 express 미들웨어라 본문 파서 오류(413·400·415)에도 붙는다. 들어온 값은 `^[A-Za-z0-9._-]{1,128}$` 일 때만 이어받고 아니면 새로 발급(로그 위조 방지) |
| Swagger 래퍼 데코레이터 | `src/common/decorators/api-standard-response.decorator.ts` | `ApiStandardResponse` / `ApiErrorResponse` — 문서를 실응답과 일치시킨다 |
| 페이지네이션 DTO | `src/common/dto/pagination.dto.ts` | 커서/오프셋 쿼리 + `PageInfoDto` + `toPaginatedResult()` |
| 전역 HTTP 설정 | `src/app.setup.ts` 의 `configureApp()` | prefix · helmet · cookie-parser · JSON 본문 파서(1mb — 발송 `POST /api/messages` 와 임시 저장 `POST /api/drafts`·`PUT /api/drafts/{id}` 만 4mb(`SEND_MESSAGE_JSON_BODY_LIMIT`, 앞단 nginx 와 같은 값), JSON 만) · ValidationPipe(`whitelist/forbidNonWhitelisted/transform`) · 필터 · 인터셉터. **main.ts 와 e2e 가 같은 함수를 쓴다** (앱은 `bodyParser: false` 로 생성) |
| 전역 HTTP 설정 | `src/app.setup.ts` 의 `configureApp()` | prefix · helmet · cookie-parser · JSON 본문 파서(1mb — 발송 `POST /api/messages` 와 임시 저장 `POST /api/drafts`·`PUT /api/drafts/{id}` 만 4mb(`SEND_MESSAGE_JSON_BODY_LIMIT`, 앞단 nginx 와 같은 값), JSON 만 · **UTF-8 만**. `express.json` 두 개를 직접 붙인다 — 읽기 전 확인 `verify` 를 쓰려고(Nest 의 `useBodyParser` 는 verify 를 받지 않는다)) · 모양 한도 `rejectOversizedJsonBody`(아래 행) · ValidationPipe(`createValidationPipe()` — `whitelist/forbidNonWhitelisted/transform/stopAtFirstError`) · 필터 · 인터셉터. **main.ts 와 e2e 가 같은 함수를 쓴다** (앱은 `bodyParser: false` 로 생성) |
| **JSON 본문 모양 한도** | `src/shared/utils/json-shape.ts` + `app.setup.ts` 의 `JSON_SHAPE_LIMITS` | 배열 하나 1,000 · 값(객체 · 배열 · 원시) 10,000 · 깊이 32. 읽기 전(UTF-8 바이트를 한 번 훑는다 — `verifyJsonBody`, 재귀 없이 넘는 순간 멈춘다)과 읽은 뒤(`rejectOversizedJsonBody`) 두 번 보고, 넘으면 DTO 변환 · 검사 전에 400 `VAL_001`(본문 파서는 가드보다 앞 — 로그인 전 요청도 400). DTO 의 개수 한도로는 막지 못해 둔 것이다(2026-10-28 리뷰 — 1MB 에 작은 객체 8만 개로 7.8초 · +329MB, 깊은 중첩은 500). 새 계약의 본문이 한도를 넘으면 여기부터 올린다 ([[security]]) |
| Swagger | `main.ts` → `/api-docs` | 모든 엔드포인트 데코레이터. 인증은 쿠키(`addCookieAuth('access_token')`)만 등록 — Authorization 헤더 인증은 없다 |
| 보안 미들웨어 | `app.setup.ts`(helmet · `TRUST_PROXY`) · `main.ts`(CORS 화이트리스트 — 운영은 `CORS_ORIGIN` 만) | 클라이언트 IP 는 `TRUST_PROXY`(홉 수 또는 IP·CIDR 목록, 운영 필수) 기준 — `shared/utils/trust-proxy.ts` |
| **출처 검사(CSRF)** | `src/common/guards/origin.guard.ts` (APP_GUARD) | 상태 변경 요청은 `Sec-Fetch-Site`(same-origin·none) 또는 허용 목록(`CORS_ORIGIN`)의 `Origin` 이어야 통과, 아니면 `AUTH_003`. 둘 다 없으면 통과. 규칙 `shared/utils/request-origin.ts` |
| **인증 가드** | `src/common/guards/jwt-auth.guard.ts` | `JwtAuthGuard` — 액세스 토큰 쿠키 검증, 실패는 `AUTH_001`. 전략은 `modules/auth/jwt.strategy.ts` |
| 현재 사용자 데코레이터 | `src/common/decorators/current-user.decorator.ts` | `@CurrentUser()` → `AuthenticatedUser { email, name }` |
| 길이 검사 데코레이터 | `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절) · 비밀번호 변경 `PUT /api/auth/password`(4단계 — 직원 본인 자격으로 엔진의 `x:AccountPassword/set`, 로그인과 같은 실패 카운터 · 진행 중 잠금, 바뀌면 이 요청의 세션만 남기고 그 직원의 세션을 끊는다 — 결과를 모르는 실패에도 끊는다). 설정 `auth.settings.ts`, 쿠키 `auth-cookies.ts`, 로그인 실패 횟수 `login-attempt.service.ts`, **로그인 세션(DB)** `auth-session.entity.ts`·`auth-session.policy.ts`(1회용 회전·재사용 감지)·`auth-session.cleanup.ts`(끝난 세션 정리 cron). **메일 엔진 관리자 차단** — 정적 목록(`employee-address.ts` + `MAIL_BLOCKED_ACCOUNTS`)과 로그인·리프레시 때 엔진 권한 확인(`jmap/jmap.client.ts`) 두 겹 |
| 메일 (웹메일 API) | `modules/mail` | 엔진 포트 `mail.provider.ts`(`MAIL_PROVIDER`) + Stalwart JMAP 어댑터 `jmap/`, 받은 메일 HTML 정화 `html/sanitize-mail-html.ts`, 보내는 서식 본문 정화(허용 목록) `html/sanitize-outgoing-html.ts`, 두 정화 앞의 사전 검사(길이·파서 스택 비용, 선형 시간) `html/html-parse-budget.ts`, 첨부 형식 `attachment.policy.ts`, 설정 `mail.settings.ts`, 발송 중복 방지(`Idempotency-Key`) `send-idempotency.ts`, 첨부 임시 디스크 저장 `mail-upload-storage.ts`, 임시 저장 `draft.controller.ts`(엔진 초안 — 새로 만들고 다시 읽은 뒤 옛것을 지운다), 서명 그림 주소 규칙 `html/signature-images.ts`(보낼 때 `cid:` 로), 본문 그림 판정 `html/inline-images.ts`, 받는 사람 추천 `recipient-suggestions.service.ts`(사용자별 10분 캐시) · 순서 `recipient-ranking.ts` · 커서 `recipient-suggestion-cursor.ts`, 검색어 → 엔진 조건 `jmap/search-terms.ts`(한국어 조사 표는 여기 한 곳 — 낱말 안 OR · 낱말끼리 AND, 따옴표 구절), 여러 통 `PATCH /api/messages`(엔진 요청 한 번) · 휴지통에서 영구 삭제 `message-deletion.controller.ts`(휴지통의 메일만, 조건부 지우기) — 본문 `dto/bulk-messages.dto.ts`, 새 메일 확인 `GET /api/mail-state`(`mailbox.controller.ts`), 메일함 관리 · 전체 읽음 · 비우기(`mailbox.controller.ts`, 본문 `dto/mailbox.dto.ts` — 오래 걸리는 처리는 어댑터(`BulkRun`)가 500통씩 · 한 요청 25초 안에서(엔진 호출마다 남은 시간만큼) 하고 남은 수를 돌려준다, 같은 직원 · 같은 메일함은 한 번에 하나(`mail.service.ts` 의 `mailboxOperations` — 진행 중이면 `REQ_001`), 지우기는 안의 메일을 휴지통으로 옮긴 뒤) · 부재중 자동 응답 `MailVacationController`(`dto/mail-vacation.dto.ts`) · 원문 `GET /api/messages/{id}/source`(이름 `attachment.policy.ts` 의 `messageSourceFileName`) · 스팸 표시 `isJunk` (계약 2절) · 실시간 알림 `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`, 검색어 → 엔진 조건 `jmap/search-terms.ts`(한국어 조사 표는 여기 한 곳 — 낱말 안 OR · 낱말끼리 AND, 따옴표 구절), 여러 통 `PATCH /api/messages`(엔진 요청 한 번) · 휴지통에서 영구 삭제 `message-deletion.controller.ts`(휴지통의 메일만, 조건부 지우기) — 본문 `dto/bulk-messages.dto.ts`, 새 메일 확인 `GET /api/mail-state`(`mailbox.controller.ts`), 메일함 관리 · 전체 읽음 · 비우기(`mailbox.controller.ts`, 본문 `dto/mailbox.dto.ts` — 오래 걸리는 처리는 어댑터(`BulkRun`)가 500통씩 · 한 요청 25초 안에서(엔진 호출마다 남은 시간만큼) 하고 남은 수를 돌려준다, 같은 직원 · 같은 메일함은 한 번에 하나(`mail.service.ts` 의 `mailboxOperations` — 진행 중이면 `REQ_001`), 지우기는 안의 메일을 휴지통으로 옮긴 뒤) · 부재중 자동 응답 `MailVacationController`(`dto/mail-vacation.dto.ts` — 6단계부터 저장 자리는 아래 필터의 Sieve 스크립트) · 메일 필터 `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`) · 원문 `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` 가 기록 → 받는 사람 추천의 "사내". 엔진의 계정 목록(Principal) 권한은 쓰지 않는다(일부러 막아 두었다) |
| Rate Limiting | `app.module.ts` 의 `ClientIpThrottlerGuard`(APP_GUARD, `common/guards/client-ip-throttler.guard.ts`) | 초과 시 429 → `REQ_001`. 루트 `.env` 의 `THROTTLE_*` 로 조정. `ThrottlerGuard` 를 상속해 tracker 만 바꿨다 — IP 는 `shared/utils/client-ip.ts` 의 `clientIpKey()`(IPv6 는 /64 로 묶음, 로그인 실패 카운터와 같은 함수) |
@@ -348,7 +350,8 @@ dc exec backend npm uninstall <패키지>
- [ ] **에러**: 사용자 노출 실패는 `BusinessException('CODE')` 로 던진다. 코드 없는 실패 금지.
- [ ] **에러 코드 추가 시**: `error-codes.ts` 만 수정 → `npm run sync:error-codes` 실행 → 세 스택 목록 테스트 갱신 ([[error-codes]]).
- [ ] **목록 API**: 페이지네이션 적용, 응답은 `{items, pageInfo}`.
- [ ] **DTO**: 모든 입력 DTO 에 `class-validator` + 한국어 메시지 + `@ApiProperty()`.
- [ ] **DTO**: 모든 입력 DTO 에 `class-validator` + 한국어 메시지 + `@ApiProperty()`. 배열 중복은 `ArrayDistinct`(`ArrayUnique` 금지 — 항목 수의 제곱).
DTO spec 은 전역 파이프 옵션에서 `stopAtFirstError` 만 빼고 검사한다(걸린 규칙을 모두 보려고 — 통과 · 실패 판정은 같다).
- [ ] **Swagger**: 새 엔드포인트에 `@ApiTags`/`@ApiOperation` + `@ApiStandardResponse`/`@ApiErrorResponse`.
- [ ] **경로 alias**: `@/` 사용 (`tsconfig.json` paths + jest `moduleNameMapper` 설정 완료). 깊은 상대경로 금지.
- [ ] **lint/test**: 커밋 전 `npm run lint` (`--max-warnings 0`) + `npm run test` 통과. 자동 수정은 `npm run lint:fix`.
@@ -370,8 +373,13 @@ dc exec backend npm uninstall <패키지>
휴지통(비우기는 그 메일함)에만 있는 메일 — 다른 메일함에도 있으면 그 메일함에서만 뺀다) → 확인한 상태 그대로일 때만 `ifInState` 로 지우기 → 어긋나면 다시 확인
(한 통 · 여러 통은 합계 `CONDITIONAL_DESTROY_ATTEMPTS`, 비우기는 500통 차례마다 이어서 그만큼). 확인과 지우기 사이에
다른 탭이 옮기거나 되돌린 메일까지 지우지 않게 하기 위해서다 (2026-10-27, Stalwart 0.16.27 이 `stateMismatch` 로 거절하는 것을 확인).
`conditionalEmailSet` 은 `ifInState` 를 필수로 받는다 — 조건 없는 destroy 는 그 요청이 방금 만든 메일(실패한 저장의 되돌림)에만, 따로 쓴다.
`conditionalEmailSet` 은 `ifInState` 를 필수로 받는다 — 조건 없는 destroy 는 그 요청이 방금 만든 메일(실패한 저장의 되돌림 · 받는 사람이 모두 거절한 발송의 보낸 사본 — `discardQuietly`)에만, 따로 쓴다.
같은 요청에서 만들기와 지우기를 함께 하지 않는다 — 엔진은 만들기가 실패해도 지우기를 한다.
**메일함 지우기(`Mailbox/set destroy`)는 `onDestroyRemoveEmails: false` 만 쓴다** — `true` 는 안의 메일을 영구 삭제한다. 안의 메일은 먼저 휴지통으로 옮기고,
그사이 들어온 메일이 있으면 엔진이 `mailboxHasEmail` 로 거절하므로 다시 옮긴다 (2026-10-28 확인).
**Sieve 스크립트**(6단계 — 필터 · 부재중)는 `jmap/sieve-script.ts` 한 곳에서 만든다: 사용자 글은 `quote()` 만 거친다(`\` · `"` 이스케이프), `${` 는 받지 않는다
(엔진이 require 와 상관없이 변수로 펼친다 — `%{…}` 는 펼치지 않는다), 부재중 제목은 RFC 2047 · 본문은 `vacation :mime` base64, 만든 글이 90,000바이트를 넘으면 `MAIL_013`.
**스팸 판정은 엔진의 분류 표시**(`X-Spam-Score` 가 `spam` 으로 시작)로 한다 — 점수 문턱을 따로 두지 않는다(`spamtest` 는 점수를 정수로 내린 값이라 10 으로 막다가 5~10점 스팸을 놓쳤다, 2026-10-28 실엔진).
날짜 비교는 `currentdate :zone "+0000"`. 엔진 오류의 설명 문장은 로그에 남기지 않는다(줄 · 칸만 — 만난 문자열에 규칙의 글이 들어 있다).
원본 판(`FILTER_SOURCE_FORMAT`)을 올린 배포를 되돌리면 새 판으로 저장한 직원의 필터 · 부재중 저장이 503 `MAIL_001` 로 막힌다(옛 서버가 새 판을 지우지 않게) — 판을 올릴 때는 되돌리기 계획을 함께 둔다.
- [ ] **env**: DB/Redis/CORS/포트는 루트 `.env` 에서 주입된다. 백엔드 고유 시크릿(`JWT_SECRET` 등)만 `backend/.env.{env}` 에 작성(compose `env_file` 로 로드).
+7 -6
View File
@@ -32,17 +32,17 @@ description: Vue 3 프론트엔드(frontend/) 개발 규칙 — Composition API
| 전역 셸 | `src/components/shell/AppShell.vue` | 앱 레일 + 상단 바 + 모듈 영역. 레일 모듈 목록은 `src/shared/constants/app-modules.ts` 하나 |
| 디자인 토큰·글꼴 | `src/styles/tokens.css` · `src/styles/base.css` | 색·간격·글자 크기 변수. 글꼴은 npm `pretendard` 자체 호스팅(`main.ts` 에서 import — CSP `font-src 'self'`) |
| 브랜드 자산 | `src/assets/brand/` · `src/shared/constants/brand.ts` | 흰 로고(전체·ARA 마크). 원본은 `docs/brand/` |
| 토스트 | `src/stores/toast.store.ts` · `src/components/feedback/ToastHost.vue` | 오류는 `useApi.ts` 의 `showErrorUI()`, 성공 안내는 `composables/useToast.ts`(API 가 아닌 화면 쪽 실패 — 예: 브라우저가 알림을 막음 — 는 `useToast().error()`). 알림 안의 단추는 `{ label, run }`(예: 임시 저장본 [되돌리기]), 이동 링크는 지금처럼. 단추가 있는 알림은 10초(`TOAST_ACTION_DURATION_MS`), 마우스 · 초점이 있는 동안은 `hold`·`release` 로 멈춘다. **단추는 띄운 세션에서만 움직인다** — `useToast` 가 `authStore.generation` 에 묶고, 로그아웃 · 세션 만료 · 다른 계정 로그인 때 단추가 있는 알림을 닫는다(메일 id 는 계정마다 겹칠 수 있다). 오래 걸리는 처리는 **진행 알림** `useToast().progress(글)` — 저절로 닫히지 않고(넘쳐도 다른 알림부터 닫는다) 글을 고치다가(`update`) 끝나면 그 자리에서 결과가 된다(`succeed` · `fail` — `toast.store` 의 `update`). 진행 알림도 세션이 바뀌면 닫는다 |
| 모달 대화상자 | `src/components/feedback/ModalDialog.vue` | 네이티브 `<dialog>`(초점·Esc·닫힘 처리)와 모양. 세션 만료(`SessionExpiredDialog`)·나가기 확인(공용 `components/feedback/LeaveConfirmDialog.vue` — 쓰기 화면 `ComposeLeaveDialog.vue`·서명 설정 · 부재중 설정이 쓴다) · 메일함 대화상자(`components/mail/Mailbox*Dialog.vue` — 기본 슬롯에 이름 칸)가 쓴다 — 새 대화상자도 이것으로 만든다. 되돌릴 수 없는 일의 확인은 처음 초점을 「취소」에 둔다 |
| 토스트 | `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`). 진행 알림도 세션이 바뀌면 닫는다 |
| 모달 대화상자 | `src/components/feedback/ModalDialog.vue` | 네이티브 `<dialog>`(초점·Esc·닫힘 처리)와 모양. 세션 만료(`SessionExpiredDialog`)·나가기 확인(공용 `components/feedback/LeaveConfirmDialog.vue` — 쓰기 화면 `ComposeLeaveDialog.vue`·서명 설정 · 부재중 설정이 쓴다) · 메일함 대화상자(`components/mail/Mailbox*Dialog.vue` — 기본 슬롯에 이름 칸) · 필터 고치기 창(`components/settings/MailFilterDialog.vue` — 칸이 많은 창은 `wide`: 폭 620)이 쓴다 — 새 대화상자도 이것으로 만든다. 되돌릴 수 없는 일의 확인은 처음 초점을 「취소」에 둔다. 초점을 지키려고 `aria-disabled` 로 막은 단추에는 마우스를 올린 모양이 걸리지 않는다 |
| 외부 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` (주석에 "밀도 예외") | 클릭 영역 44px 기준의 의도된 예외(목록 표의 별·보기 전환, 설정 화면의 미리 보기 전환 36(`.segmented`)·서명 그림 크기 줄 36(작게·보통·원본·그림 빼기), 편집기 툴바 버튼 40×44·메뉴 항목 40·색 견본 40·표 고르기 칸 22, 3단계의 목록 표 고르기 칸 32·분할 행 고르기 칸 폭 36·검색 칸 ✕ 40×38·새 메일 알림 줄 40·검색 범위 전환 36·이동 메뉴 항목 40, 4단계의 메일함 행 ⋯ 32×40 · ⋯ 메뉴 항목 40, 5단계의 목록 위 알림 권하기 줄 40 · 줄 안의 단추 36)에만 쓴다 — 사용자 확정(캔버스). 그 밖의 `--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)에만 쓴다 — 사용자 확정(캔버스). 그 밖의 `--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절 "발송 본문") |
| 발송 키 규칙 | `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`) |
| 메일 본문 편집기 | `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/composables/useDraftSaving.ts` · `components/mail/ComposeDraftStatus.vue` · `composables/useComposeDraftLink.ts`(주소 맞추기 · 옛 id 기록 · 보내지 못한 메일을 저장본으로) · `composables/useDraftTrash.ts`(휴지통 · 되돌리기) | 3초 멈춤 · 쉬지 않고 쓰면 30초 · 10초 간격 · 한 번에 하나(계약 2절 "임시 저장", 상수 `shared/constants/mail.ts` 의 `DRAFT_*`). `MAIL_008`·PUT 404 는 막지 않고 POST 로 새로. 찾지 못한 첨부가 남았거나 본문이 한도를 넘는 동안은 저장하지 않고, 실을 수 없는 첨부가 든 남의 저장본은 첫 저장을 POST 로(원본 보존). 보내기·버리기 전에는 `suspend`+`settle` — 보내기는 settle 뒤 첨부 상태를 다시 본다. 저장할 때마다 주소를 `?draft=<새 id>` 로 맞추고(떠나기 전 저장 제외), 옛 id 로 돌아오면 `mail.store` 의 `latestDraftId()` 로 마지막 저장본을 연다. 쓰기 화면 휴지통은 DELETE 가 아니라 PATCH(휴지통으로 이동) — 이미 휴지통인 메일은 DELETE 가 영구 삭제한다 |
| 받는 사람 자동 완성 | `src/composables/useRecipientSuggestions.ts` · `shared/utils/recipient-match.ts` · `components/mail/RecipientField.vue` · `RecipientSuggestionList.vue` | 검색어는 input · compositionupdate 의 `target.value`(한글 조합 중 글자 포함)로 따로 두고, 칩 확정은 v-model. Enter 등의 조합 판정은 `shared/utils/keyboard-shortcut.ts` 의 `isImeComposing()`(`isComposing` 또는 keyCode 229 — Safari · macOS) — 새 입력 칸의 Enter 처리도 이것을 쓴다. 쓰기 화면을 열 때 `limit=100` 한 번, 모자랄 때만 250ms 뒤 `q` 로(초성·자모만 친 글자는 묻지 않는다, 늦은 응답은 버린다). 초성 검색·맞은 글자 강조는 `recipient-match.ts`(강조는 `<strong>` 조각 — v-html 없음). combobox 접근성(aria-activedescendant · listbox · 조합 중 Enter 무시), 완전한 주소면 미리 고르지 않는다 |
| 메일 서명 | `src/stores/mail-signature.store.ts` · `components/mail/editor/signature/` · `shared/utils/mail-signature.ts` · `pages/settings/` | 서명은 메일 화면에 들어올 때 받아 두고 쓰기 화면이 넣는다(새 메일은 끝, 답장·전달은 인용 위 — 이어 쓰기는 다시 넣지 않는다). 설정 화면(`SettingsLayout.vue` 왼쪽 메뉴 — 묶음 · 항목은 `shared/constants/settings-menu.ts` + `MailSignaturePage.vue`)은 들어올 때마다 새로 받는다. 미리 보기는 편집기 스키마의 `DOMSerializer` 로 그린다. 그림 3장은 서명 칸 안의 그림 수로(서버 보관 상한 10장은 서버가 센다) |
@@ -53,9 +53,10 @@ 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절 "검색") |
| 여러 통 처리 · 삭제 | `src/components/mail/MailCheckbox.vue` · `MailSelectionToolbar.vue`(+ `MailBulkButton` · `MailBulkMoveButton`) · `MailPurgeDialog.vue` · `MessageDeleteButton.vue` · `composables/useBulkMailActions.ts` · `useMessageRemoval.ts` · `useMailMoveFeedback.ts` · `shared/utils/mail-selection.ts` · `mail-bulk.ts` | 100통씩 차례로. 읽음 · 중요는 먼저 바꾸고 `failedIds` 만 되돌리고, 이동 · 삭제는 응답의 id 만 뺀다. **웹은 `DELETE /api/messages/{id}` 를 쓰지 않는다** — 휴지통 밖 삭제는 `PATCH` 이동(+ 되돌리기), 휴지통에서는 확인 대화상자(처음 초점 「취소」) 뒤 `POST /message-deletions`. 읽기 화면도 그 메일의 `mailboxIds` 로 같은 규칙을 탄다. 도구 줄은 role="toolbar" 로빙 tabindex(바쁜 · 꺼진 단추는 건너뛴다) — 계약 2절 "여러 통 한꺼번에" |
| 설정 › 비밀번호 · 부재중 (4단계) | `src/pages/settings/AccountPasswordPage.vue` · `MailVacationPage.vue` · `components/settings/`(`SettingsPageFrame` 공용 틀 · `PasswordChangeForm` · `PasswordField` · `Vacation*`) · `stores/mail-vacation.store.ts` · `shared/utils/password-change.ts` · `mail-vacation.ts` · 메뉴 `shared/constants/settings-menu.ts` | 메뉴는 묶음(계정 › 비밀번호 / 메일 › 서명 · 부재중 자동 응답 · 알림 — 5단계), 라우트 이름은 `SETTINGS_ROUTES` 하나. 비밀번호는 서버와 같은 규칙을 먼저 보고, 서버 거절(`AUTH_005` · `VAL_001`)은 칸 아래에만(알림 없음) — 성공하면 세 칸을 비운다. 부재중은 날짜만 고르고 브라우저 시간대의 시작일 0시 · 끝나는 날 다음 날 0시를 UTC 로 보낸다(변환은 `mail-vacation.ts` 한 곳 — 되읽을 때는 1ms 앞 날짜). 켤 때만 검사하고 꺼 둔 채로는 빈 값도 저장한다. 부재중 상태는 메일 화면에 들어올 때 받고, 패널 안내 `components/mail/MailVacationNotice.vue` 가 1분마다 다시 계산한다(`useClock` · `VACATION_NOTICE_REFRESH_MS`). 저장 응답은 세션 세대(`authStore.generation`)가 바뀌었으면 쓰지 않는다 — 계약 1절 · 2절 "부재중 자동 응답" |
| 설정 › 비밀번호 · 부재중 (4단계) | `src/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`)가 바뀌었으면 쓰지 않는다 — 계약 1절 · 2절 "부재중 자동 응답" |
| 메일함 관리 · 오래 걸리는 처리 (4단계) | `src/components/mail/MailboxTree.vue` · `MailboxPanelRow.vue` · `MailboxMoreButton.vue`(⋯ 메뉴) · `MailboxDialogs.vue`(+ `Mailbox*Dialog.vue`) · `MailboxEmptyButton.vue`(목록 머리 「비우기」) · `composables/useMailboxActions.ts` · `stores/mail/mailbox-admin.ts` · `shared/utils/mailbox-operation.ts` · 상수 `shared/constants/mailbox-manage.ts` | 만들기 · 이름 바꾸기는 칸 안 실패(`MAIL_009` · `VAL_001`)를 알림 없이 칸 아래에. 모두 읽음 · 비우기 · 지우기는 남은 수가 0(지우기는 `deleted: true`)이 될 때까지 다시 보내며 진행 알림을 고친다 — 진행은 응답의 `processed`, 세션이 바뀌면 멈춘다(`epoch` — 늦게 온 만들기 · 이름 바꾸기 응답도 버린다), 같은 메일함의 같은 처리는 하나, **휴지통 비우기와 메일함 지우기는 함께 돌리지 않는다**(비우기는 그사이 휴지통에 들어온 메일도 지운다 — 판정은 스토어의 `isMailboxOperationBlocked` 하나, 메뉴 · 단추 · 대화상자 확인이 모두 이것을 본다). 끝난 뒤 누른 단추가 사라져 초점을 잃었으면 패널의 지금 메일함 항목으로 옮긴다. ⋯ 메뉴는 보이는 영역 안에 다 들게 연다(`shared/utils/popup-placement.ts` — 아래가 모자라면 위로). 되돌릴 수 없는 일은 확인 대화상자(처음 초점 「취소」) 뒤에, 확인한 그 메일함만 — 계약 2절 "메일함 관리" |
| 스팸 · 원문 (4단계) | `src/components/mail/MessageSpamButton.vue`(읽기 화면) · 도구 줄의 스팸 단추(`MailSelectionToolbar.vue`) · `MessageMoreMenu.vue`(⋯ › 원문 내려받기) | 스팸 신고 · 스팸 아님은 이동과 `isJunk` 를 한 요청에 보내고(`mailStore.moveMessages(…, { isJunk })`), [되돌리기]는 원래 메일함으로 반대 값을 보낸다. 원문(.eml)은 `useMail().downloadMessageSource()` — 첨부처럼 `confirmSession()` 을 먼저 거친다 — 계약 2절 "스팸 · 원문" |
| 메일 필터 (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단계" |
| 시계 주입 | `src/composables/useClock.ts` · `src/test-utils/manual-clock.ts` | 타이머를 쓰는 로직(자동 저장 등)은 시계를 주입받는다 — 테스트는 손으로 돌리는 시계로 시간을 넘긴다(고정 대기 없이, [[testing]]) |
| 나가기 전 확인 | `src/composables/useLeaveConfirm.ts` · `src/shared/utils/compose-changes.ts` | 화면을 연 뒤(또는 마지막으로 저장한 뒤) 바꾼 것이 있으면 앱 안 이동(라우트 떠남·바뀜)은 확인 대화상자, 탭 닫기·새로고침은 `beforeunload`. 쓰기 화면은 떠나기 전에 먼저 저장하고 저장하지 못한 것이 있을 때만 묻는다(서명 설정은 저장하지 않은 서명이 있으면 묻는다). 가드가 도는 중은 `guarding`. 바꿈 여부는 연 직후(저장한 뒤에는 마지막으로 저장한 판)의 스냅숏과 비교한다(편집기는 `isChanged()`). 떠나기 전 저장은 5초까지만 기다린다. 보낸 뒤·보내는 중·세션이 끝났을 때는 묻지 않는다 — 보내는 중에 떠나면 결과는 토스트로만 알리고 떠난 양식·새 양식을 건드리지 않는다(양식 세대로 늦게 온 결과를 버린다, 디자인 README "나가기 전 확인") |
| 읽기 화면 본문 안내 | `src/shared/utils/mail-body-notices.ts` · `shared/constants/mail.ts` 의 `MAIL_BODY_NOTICES` | 서버의 `isHtmlBodyOmitted`·`isBodyTruncated`(계약 2절 "읽기 한도")를 외부 이미지 차단과 같은 안내 줄로 보인다(`.reader__notice[data-notice]`) |
+268 -1
View File
@@ -1,4 +1,271 @@
import { isDraftSaveRequest, isSendMessageRequest } from './app.setup';
import { BadRequestException, HttpStatus } from '@nestjs/common';
import type { Request, Response } from 'express';
import type { IncomingMessage, ServerResponse } from 'http';
import { startCpuTimer } from '@/../test/cpu-time';
import { BusinessException } from '@/common/errors/business.exception';
import { MessageDeletionDto } from '@/modules/mail/dto/bulk-messages.dto';
import { MailFilterUpdateDto } from '@/modules/mail/dto/mail-filter.dto';
import { SendMessageDto } from '@/modules/mail/dto/send-message.dto';
import {
JSON_SHAPE_LIMITS,
createValidationPipe,
isDraftSaveRequest,
isSendMessageRequest,
rejectOversizedJsonBody,
verifyJsonBody,
} from './app.setup';
/** 본문 파서의 읽기 전 확인을 부른다 — 던진 것을 돌려준다 (던지지 않으면 undefined) */
function verifyFailure(text: string, encoding = 'utf-8'): unknown {
try {
verifyJsonBody(
{} as IncomingMessage,
{} as ServerResponse,
Buffer.from(text, 'utf8'),
encoding,
);
} catch (error) {
return error;
}
return undefined;
}
/** 읽은 뒤의 확인을 부른다 — next 에 넘긴 것 */
function afterParse(body: unknown): unknown[] {
const next = jest.fn();
rejectOversizedJsonBody({ body } as Request, {} as Response, next);
return next.mock.calls[0] as unknown[];
}
const repeat = (unit: string, count: number) =>
Array.from({ length: count }, () => unit).join(',');
describe('JSON 본문의 모양 한도 (JSON_SHAPE_LIMITS — DTO 변환 · 검사 전)', () => {
it('읽기 전 확인 — 넘으면 BusinessException VAL_001(상태 400 — body-parser 가 이 상태를 쓴다), 까닭은 로그용 context 에만', () => {
const failure = verifyFailure(`{"ids":[${repeat('1', 1_001)}]}`);
expect(failure).toBeInstanceOf(BusinessException);
expect((failure as BusinessException).code).toBe('VAL_001');
expect((failure as BusinessException).getStatus()).toBe(
HttpStatus.BAD_REQUEST,
);
// http-errors 는 감싼 오류의 status 를 상태 코드로 쓴다 (body-parser 의 createError(403, err))
expect((failure as { status?: unknown }).status).toBe(400);
expect((failure as BusinessException).context).toEqual({
reason: 'json-shape-limit',
problem: 'array-too-long',
stage: 'before-parse',
});
expect(verifyFailure(`{"ids":[${repeat('1', 1_000)}]}`)).toBeUndefined();
});
it('JSON 본문은 UTF-8 만 — 다른 글자 인코딩(바이트로 셀 수 없다)은 작은 본문도 400 VAL_001', () => {
// body-parser 는 `utf-` 로 시작하는 charset 을 모두 받아 소문자로 넘긴다
for (const encoding of [
'utf-16',
'utf-16le',
'utf-16be',
'utf-32',
'utf-7',
]) {
const failure = verifyFailure('{"ids":[]}', encoding);
expect(failure).toBeInstanceOf(BusinessException);
expect((failure as { status?: unknown }).status).toBe(400);
expect((failure as BusinessException).code).toBe('VAL_001');
expect((failure as BusinessException).context).toEqual({
reason: 'json-not-utf8',
});
}
});
it('UTF-8 로 읽히는 다른 이름(utf-_8 등)도 UTF-8 로 보고 모양을 본다 — 이름 글자 그대로 견주면 확인을 건너뛴다', () => {
for (const encoding of [
'utf-8',
'utf-_8',
'utf--8',
'utf-8.',
'utf-8:2026',
]) {
expect(verifyFailure('{"ids":[]}', encoding)).toBeUndefined();
expect(
(
verifyFailure(
`{"ids":[${repeat('1', 1_001)}]}`,
encoding,
) as BusinessException
).context,
).toEqual({
reason: 'json-shape-limit',
problem: 'array-too-long',
stage: 'before-parse',
});
}
});
it('읽은 뒤 확인 — 한도 안이면 그대로 넘기고(next()), 본문이 없는 요청도 지난다. 넘으면 next(VAL_001)', () => {
expect(afterParse({ rules: [], version: 'v' })).toEqual([]);
expect(afterParse(undefined)).toEqual([]);
const [failure] = afterParse({
ids: Array.from({ length: 5_000 }, () => 1),
});
expect(failure).toBeInstanceOf(BusinessException);
expect((failure as BusinessException).context).toEqual({
reason: 'json-shape-limit',
problem: 'array-too-long',
stage: 'after-parse',
});
});
it('한도는 계약의 어떤 본문보다 넉넉하다 — 배열 1,000 · 값 10,000 · 깊이 32', () => {
expect(JSON_SHAPE_LIMITS).toEqual({
maxArrayLength: 1_000,
maxValues: 10_000,
maxDepth: 32,
});
});
describe('리뷰에서 잰 모양이 400 으로 0.5초 안에 끝난다 (본문 파서의 읽기 전 확인 — 본문은 재기 전에 만든다)', () => {
it.each([
[
'필터 rules 에 {"id":N} 8만 개 (1MB)',
`{"version":"v","rules":[${Array.from(
{ length: 80_000 },
(_, index) => `{"id":${index}}`,
).join(',')}]}`,
],
[
'규칙 하나의 conditions 에 {} 15만 개',
`{"version":"v","rules":[{"id":"r","conditions":[${repeat('{}', 150_000)}]}]}`,
],
[
'규칙 하나의 conditions 에 {} 30만 개 (900KB)',
`{"version":"v","rules":[{"id":"r","conditions":[${repeat('{}', 300_000)}]}]}`,
],
[
'발송 to 에 {} 139만 개 (4MB 경로의 최대)',
`{"subject":"x","body":"x","to":[${repeat('{}', 1_390_000)}]}`,
],
[
'여러 통 ids 에 문자열 6만 개',
`{"ids":[${Array.from(
{ length: 60_000 },
(_, index) => `"${index.toString(36)}"`,
).join(',')}]}`,
],
[
'로그인 본문에 10만 단 중첩 (600KB — 전에는 DTO 변환의 재귀가 넘쳐 500)',
`{"email":"a@b.c","password":"x","z":${'{"a":'.repeat(100_000)}1${'}'.repeat(100_000)}}`,
],
])('%s', (_label, text) => {
const cpuMs = startCpuTimer();
const failure = verifyFailure(text);
expect(cpuMs()).toBeLessThan(500);
expect((failure as BusinessException).code).toBe('VAL_001');
});
});
});
describe('전역 DTO 검증 (createValidationPipe — stopAtFirstError)', () => {
/** 모양 한도 안의 가장 무거운 본문들 — 본문 파서 · 모양 확인을 지난 뒤 DTO 변환 · 검사까지 0.5초 안에 400 이다 */
const filtersWithManyUnknowns = {
version: 'v',
rules: Array.from({ length: 50 }, (_, index) => ({
id: `r${index}`,
name: '',
isEnabled: true,
match: 'all',
conditions: Array.from({ length: 5 }, () => ({
field: 'subject',
operator: 'contains',
value: 'x',
extra: Array.from({ length: 32 }, () => 1),
})),
actions: {
moveToMailboxId: null,
markAsRead: true,
flag: false,
forwardTo: null,
},
})),
};
it.each<[string, new () => object, unknown]>([
[
'필터 rules 1,000개 (배열 한도까지)',
MailFilterUpdateDto,
{
version: 'v',
rules: Array.from({ length: 1_000 }, (_, index) => ({ id: index })),
},
],
[
'규칙 하나의 conditions 1,000개',
MailFilterUpdateDto,
{
version: 'v',
rules: [
{ id: 'r', conditions: Array.from({ length: 1_000 }, () => ({})) },
],
},
],
[
'필터 50개 × 조건 5개마다 모르는 배열 — 값 약 9,800개 (값 한도 가까이)',
MailFilterUpdateDto,
filtersWithManyUnknowns,
],
[
'발송 to 1,000명',
SendMessageDto,
{
subject: 'x',
body: 'x',
to: Array.from({ length: 1_000 }, () => ({})),
},
],
[
'여러 통 ids 1,000개',
MessageDeletionDto,
{ ids: Array.from({ length: 1_000 }, (_, index) => `m${index}`) },
],
])(
'%s — 모양 확인을 지나도 DTO 검사가 0.5초 안에 400',
async (_label, metatype, body) => {
const text = JSON.stringify(body);
const pipe = createValidationPipe();
const cpuMs = startCpuTimer();
expect(verifyFailure(text)).toBeUndefined();
const parsed: unknown = JSON.parse(text);
expect(afterParse(parsed)).toEqual([]);
await expect(
pipe.transform(parsed, { type: 'body', metatype }),
).rejects.toBeInstanceOf(BadRequestException);
expect(cpuMs()).toBeLessThan(500);
},
);
it('검사 결과는 그대로다 — 맞는 본문은 지나고, 한도를 넘거나 모르는 칸이 있으면 400', async () => {
const pipe = createValidationPipe();
const valid = {
rules: [],
version: 'Cbd1Lkq0H3S7mJ5yV0w2qg8qkQvJ8sB7bW2nZ1y3u9E',
};
await expect(
pipe.transform(valid, { type: 'body', metatype: MailFilterUpdateDto }),
).resolves.toBeInstanceOf(MailFilterUpdateDto);
for (const body of [
{ ...valid, isActive: true },
{ ...valid, rules: Array.from({ length: 51 }, () => ({})) },
{ ...valid, version: 'a b' },
]) {
await expect(
pipe.transform(body, { type: 'body', metatype: MailFilterUpdateDto }),
).rejects.toBeInstanceOf(BadRequestException);
}
});
});
describe('임시 저장 요청 판정 (발송과 같은 JSON 본문 4MB)', () => {
it('POST /api/drafts 와 PUT /api/drafts/{id} 는 임시 저장 요청이다 — 대소문자·끝의 / ·쿼리는 가리지 않는다', () => {
+119 -16
View File
@@ -2,13 +2,21 @@ import { RequestMethod, ValidationPipe } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import type { NestExpressApplication } from '@nestjs/platform-express';
import cookieParser from 'cookie-parser';
import { json, type NextFunction, type Request, type Response } from 'express';
import helmet from 'helmet';
import type { IncomingMessage } from 'http';
import type { IncomingMessage, ServerResponse } from 'http';
import { BusinessException } from '@/common/errors/business.exception';
import { HttpExceptionFilter } from '@/common/filters/http-exception.filter';
import { TransformInterceptor } from '@/common/interceptors/transform.interceptor';
import { requestIdMiddleware } from '@/common/middleware/request-id.middleware';
import { DRAFTS_ROUTE, MESSAGES_ROUTE } from '@/modules/mail/mail.constants';
import {
findJsonShapeProblem,
findJsonTextShapeProblem,
type JsonShapeLimits,
type JsonShapeProblem,
} from '@/shared/utils/json-shape';
import { resolveTrustProxy } from '@/shared/utils/trust-proxy';
/** 글로벌 prefix — 모든 라우트가 /api/* (클라이언트 baseURL 과 일치) */
@@ -28,6 +36,20 @@ export const JSON_BODY_LIMIT = '1mb';
*/
export const SEND_MESSAGE_JSON_BODY_LIMIT = '4mb';
/**
* JSON 본문의 모양 한도 — 두 본문 파서(1MB · 4MB)의 본문 모두에, 읽기 전(verifyJsonBody)과 읽은 뒤(rejectOversizedJsonBody)
* DTO 변환 · 검사 **전에** 건다 (shared/utils/json-shape.ts 머리말).
* 지금 계약의 어떤 본문보다 넉넉히 둔다: 가장 긴 배열은 받는 사람 · id 100(발송 본문의 첨부 목록 상한도 100), 가장 깊은 본문은 5단
* (필터 — 본문 › rules › 규칙 › conditions › 조건), 값이 가장 많은 본문은 필터 50개를 모두 채운 저장(약 1,600개).
* 넘으면 400 VAL_001 — 이 한도를 넘는 계약을 새로 만들면 여기부터 올린다.
* 한도까지 채운 본문도 DTO 변환 · 검사가 0.5초 안에 끝난다 (app.setup.spec.ts 가 CPU 시간으로 본다)
*/
export const JSON_SHAPE_LIMITS: JsonShapeLimits = {
maxArrayLength: 1_000,
maxValues: 10_000,
maxDepth: 32,
};
const SEND_MESSAGE_PATH = `/${API_PREFIX}/${MESSAGES_ROUTE}`;
const DRAFTS_PATH = `/${API_PREFIX}/${DRAFTS_ROUTE}`;
@@ -61,6 +83,83 @@ export function isDraftSaveRequest(
return id !== '' && !id.includes('/');
}
/** 모양 한도를 넘은 본문의 실패 — 400 VAL_001. 로그에는 넘은 한도의 이름과 본 자리(읽기 전 · 뒤)만 남는다 */
function jsonShapeFailure(
problem: JsonShapeProblem,
stage: 'before-parse' | 'after-parse',
): BusinessException {
return new BusinessException('VAL_001', {
context: { reason: 'json-shape-limit', problem, stage },
});
}
/** 글자 인코딩 이름의 비교용 모양 — body-parser 가 본문을 글자로 바꿀 때(iconv-lite)와 같게 소문자 영숫자만 남긴다 */
function canonicalCharset(label: string): string {
return label.toLowerCase().replace(/:\d{4}$|[^0-9a-z]/g, '');
}
/** UTF-8 의 비교용 이름 — `utf-8` · `UTF-8` · `utf-_8` 이 모두 이것이다 (body-parser 는 이 본문을 모두 UTF-8 로 읽는다) */
const UTF8_CHARSET = 'utf8';
/**
* 본문 파서의 읽기 전 확인(body-parser 의 verify) — UTF-8 본문을 JSON.parse 하기 **전에** 한 번 훑어 모양 한도를 넘으면 멈춘다.
* JSON.parse 도 작은 객체가 아주 많으면 무겁다 (4MB 에 `{}` 139만 개를 읽는 데만 CPU 0.67초 — 2026-10-28 측정).
*
* JSON 본문은 UTF-8 만 받는다 (RFC 8259 8.1 — 웹 · 앱 모두 UTF-8 로 보낸다). body-parser 는 `utf-` 로 시작하는 charset 을
* 모두 받지만(utf-16 · utf-32 · utf-7) 그 본문은 바이트로 셀 수 없어 JSON.parse 전에 끊지 못한다 — 그래서 400 VAL_001 로 거절한다.
* 이름은 견줄 모양으로 바꿔 본다: 글자 그대로 `utf-8` 과 견주면 `utf-_8` 처럼 UTF-8 로 읽히는 다른 이름이 확인을 건너뛴다.
*
* 여기서 던진 오류는 body-parser 가 http-errors 로 감싸며 상태를 오류에서 가져와 400 이 되고(BusinessException 의 status),
* 전역 필터가 VAL_001 로 답한다 (app.e2e-spec.ts 가 실제 응답으로 본다). 본문 파서는 가드보다 앞이라 로그인 전의 요청도 같다
*/
export function verifyJsonBody(
_req: IncomingMessage,
_res: ServerResponse,
buffer: Buffer,
encoding: string,
): void {
if (canonicalCharset(encoding) !== UTF8_CHARSET) {
throw new BusinessException('VAL_001', {
context: { reason: 'json-not-utf8' },
});
}
const problem = findJsonTextShapeProblem(buffer, JSON_SHAPE_LIMITS);
if (problem !== null) throw jsonShapeFailure(problem, 'before-parse');
}
/**
* 본문 파서가 읽은 JSON 본문의 모양을 본다 (JSON_SHAPE_LIMITS) — 넘으면 DTO 변환 · 검사로 넘기지 않고 400 VAL_001.
* 지금은 읽기 전 확인을 지난 UTF-8 본문만 오므로 같은 판정을 한 번 더 하는 셈이다(값 10,000개 안이라 가볍다) —
* 본문 파서를 새로 붙이거나 읽기 전 확인이 빠져도 DTO 변환 · 검사 전에 끊도록 남겨 둔다
*/
export function rejectOversizedJsonBody(
req: Request,
_res: Response,
next: NextFunction,
): void {
const body: unknown = req.body;
const problem = findJsonShapeProblem(body, JSON_SHAPE_LIMITS);
if (problem === null) {
next();
return;
}
next(jsonShapeFailure(problem, 'after-parse'));
}
/**
* 전역 DTO 검증 — 첫 실패에서 그 칸의 남은 검사를 멈춘다(stopAtFirstError).
* 배열 길이 한도에 걸린 칸은 항목마다의 중첩 검사를 하지 않는다 — 실패 응답은 늘 VAL_001 이라 어느 제약이 먼저 걸렸는지는 쓰지 않는다
* (도메인 코드 — 받는 사람 합계 MAIL_002 · 첨부 수 MAIL_006 · 본문 길이 MAIL_007 등 — 는 검증을 지난 뒤 서비스가 고른다)
*/
export function createValidationPipe(): ValidationPipe {
return new ValidationPipe({
whitelist: true, // DTO 에 없는 값은 버린다
forbidNonWhitelisted: true, // DTO 에 없는 필드가 오면 400
transform: true, // DTO·기본 타입으로 변환
stopAtFirstError: true,
});
}
/** JSON 본문인가 — body-parser 의 기본 판정('application/json', 매개변수는 보지 않는다)과 같다 */
function isJsonBody(req: IncomingMessage): boolean {
const mediaType = (req.headers['content-type'] ?? '')
@@ -103,23 +202,27 @@ export function configureApp(app: NestExpressApplication): void {
//
// 경로별 한도: 발송·임시 저장 요청만 4MB 파서가 읽고(type 판정으로 고른다), 나머지는 1MB 파서가 읽는다.
// 같은 요청을 두 번 읽지 않는다 — body-parser 는 이미 다 읽은 요청을 건너뛴다(read.js 의 isFinished 확인).
// 순서가 중요하다: 4MB 파서가 먼저여야 발송 본문이 1MB 파서에서 413 이 되지 않는다
app.useBodyParser('json', {
limit: SEND_MESSAGE_JSON_BODY_LIMIT,
inflate: false,
type: (req: IncomingMessage) =>
(isSendMessageRequest(req) || isDraftSaveRequest(req)) && isJsonBody(req),
});
app.useBodyParser('json', { limit: JSON_BODY_LIMIT, inflate: false });
// 전역 DTO 검증
app.useGlobalPipes(
new ValidationPipe({
whitelist: true, // DTO 에 없는 값은 버린다
forbidNonWhitelisted: true, // DTO 에 없는 필드가 오면 400
transform: true, // DTO·기본 타입으로 변환
// 순서가 중요하다: 4MB 파서가 먼저여야 발송 본문이 1MB 파서에서 413 이 되지 않는다.
// 두 파서 모두 읽기 전 확인(verify — UTF-8 만 · 모양 한도)을 건다 — Nest 의 useBodyParser 는 verify 를 rawBody 용으로 남겨 두고 받지 않아(타입에서 뺀다)
// 같은 파서(express.json = body-parser)를 직접 붙인다 (useBodyParser 도 안에서 이렇게 붙인다)
app.use(
json({
limit: SEND_MESSAGE_JSON_BODY_LIMIT,
inflate: false,
type: (req: IncomingMessage) =>
(isSendMessageRequest(req) || isDraftSaveRequest(req)) &&
isJsonBody(req),
verify: verifyJsonBody,
}),
);
app.use(
json({ limit: JSON_BODY_LIMIT, inflate: false, verify: verifyJsonBody }),
);
// 두 파서가 읽은 본문 모두 — 배열 길이 · 값 수 · 깊이가 한도를 넘으면 DTO 변환 · 검사 전에 400 (JSON_SHAPE_LIMITS)
app.use(rejectOversizedJsonBody);
// 전역 DTO 검증
app.useGlobalPipes(createValidationPipe());
// 표준 실패·성공 응답
app.useGlobalFilters(new HttpExceptionFilter());
@@ -0,0 +1,39 @@
import { validate } from 'class-validator';
import { startCpuTimer } from '@/../test/cpu-time';
import { ArrayDistinct } from './array-distinct.decorator';
class Ids {
@ArrayDistinct({ message: '같은 값이 두 번 들어 있습니다.' })
ids!: unknown;
}
async function constraintsOf(ids: unknown): Promise<string[]> {
const target = Object.assign(new Ids(), { ids });
const errors = await validate(target);
return errors.flatMap((error) => Object.keys(error.constraints ?? {}));
}
describe('ArrayDistinct — 배열 항목이 겹치지 않는다 (선형)', () => {
it('겹치는 값이 있으면 arrayDistinct — 원시 값은 값으로 견준다', async () => {
expect(await constraintsOf(['m1', 'm2'])).toEqual([]);
expect(await constraintsOf([])).toEqual([]);
expect(await constraintsOf(['m1', 'm2', 'm1'])).toEqual(['arrayDistinct']);
expect(await constraintsOf([1, 1])).toEqual(['arrayDistinct']);
// 문자열 '1' 과 숫자 1 은 다른 값이다 (ArrayUnique 와 같다)
expect(await constraintsOf(['1', 1])).toEqual([]);
});
it('배열이 아니면 실패한다', async () => {
for (const ids of ['m1', null, undefined, { 0: 'm1' }]) {
expect(await constraintsOf(ids)).toEqual(['arrayDistinct']);
}
});
it('항목 수에 비례한다 — 20만 개도 0.5초 안 (ArrayUnique 는 6만 개에 4.7초였다)', async () => {
const ids = Array.from({ length: 200_000 }, (_, index) => `m${index}`);
const cpuMs = startCpuTimer();
expect(await constraintsOf(ids)).toEqual([]);
expect(cpuMs()).toBeLessThan(500);
});
});
@@ -0,0 +1,19 @@
import { ValidateBy, type ValidationOptions } from 'class-validator';
/**
* 배열 항목이 겹치지 않는다 — Set 으로 한 번 훑는다(항목 수에 비례). 배열이 아니면 실패한다(IsArray 가 함께 알린다).
* class-validator 의 ArrayUnique 는 항목마다 indexOf 로 앞을 다시 훑어 항목 수의 제곱이다 — id 6만 개에 4.7초 (2026-10-28 리뷰).
* 견주는 방법은 ArrayUnique 와 같다(같은 값 — 원시 값은 값으로, 객체는 같은 객체일 때만)
*/
export function ArrayDistinct(options: ValidationOptions): PropertyDecorator {
return ValidateBy(
{
name: 'arrayDistinct',
validator: {
validate: (value: unknown) =>
Array.isArray(value) && new Set(value).size === value.length,
},
},
options,
);
}
@@ -0,0 +1,24 @@
import { plainToInstance } from 'class-transformer';
import { TrimString } from './trim-string.decorator';
class Named {
@TrimString()
name!: unknown;
}
const nameOf = (name: unknown) => plainToInstance(Named, { name }).name;
describe('TrimString — DTO 변환에서 앞뒤 공백을 지운다', () => {
it('문자열은 JS trim() 과 같게 — 탭 · 줄바꿈 · NBSP · U+3000 도 지운다, 가운데는 그대로', () => {
expect(nameOf(' 거래처 메일 ')).toBe('거래처 메일');
expect(nameOf('\t\n\u00a0\u3000가 나\u3000')).toBe('가 나');
expect(nameOf(' ')).toBe('');
});
it('문자열이 아니면 그대로 둔다 — 형식 검사(IsString 등)가 막는다', () => {
expect(nameOf(null)).toBeNull();
expect(nameOf(42)).toBe(42);
expect(nameOf([' a '])).toEqual([' a ']);
});
});
@@ -0,0 +1,16 @@
import { Transform, type TransformFnParams } from 'class-transformer';
/**
* 앞뒤 공백을 지운다 — DTO 변환(class-transformer) 단계라 검사(class-validator)는 지운 값으로 한다.
* 문자열이 아니면 그대로 두어 IsString · IsEmail 같은 검사가 막는다. 지우는 공백은 JS `trim()` 과 같다(NBSP · U+3000 포함 — 웹의 `.trim()` 과 같게 센다).
* 메일함 이름(dto/mailbox.dto.ts) · 메일 필터의 이름 · 찾을 글 · 전달 주소(dto/mail-filter.dto.ts)가 쓴다
*/
export function TrimString(): PropertyDecorator {
return Transform(trimStringValue);
}
/** TrimString 의 변환 — 문자열만 앞뒤 공백을 지운다 */
export function trimStringValue({ value }: TransformFnParams): unknown {
const raw: unknown = value;
return typeof raw === 'string' ? raw.trim() : raw;
}
@@ -54,6 +54,16 @@ describe('에러 코드 레지스트리', () => {
expect(ERROR_CODES.MAIL_009.status).toBe(HttpStatus.CONFLICT);
});
it('받는 사람을 엔진이 모두 받지 않은 발송 — 5xx 가 섞이면 400 MAIL_010, 모두 4xx(지금은 받을 수 없음)면 503 MAIL_011', () => {
expect(ERROR_CODES.MAIL_010.status).toBe(HttpStatus.BAD_REQUEST);
expect(ERROR_CODES.MAIL_011.status).toBe(HttpStatus.SERVICE_UNAVAILABLE);
});
it('메일 필터 — 그사이 규칙이 바뀜은 409 MAIL_012, 만든 엔진 규칙이 엔진 한도를 넘음은 400 MAIL_013', () => {
expect(ERROR_CODES.MAIL_012.status).toBe(HttpStatus.CONFLICT);
expect(ERROR_CODES.MAIL_013.status).toBe(HttpStatus.BAD_REQUEST);
});
it('클라이언트 전용 코드(AUTH_002)는 서버가 상태 역매핑으로 선택하지 않는다', () => {
const clientOnlyCodes = Object.entries(ERROR_CODES)
.filter(([, definition]) => 'clientOnly' in definition)
+24
View File
@@ -137,6 +137,30 @@ const DOMAIN_ERROR_CODES = {
status: HttpStatus.CONFLICT,
message: '같은 이름의 메일함이 이미 있습니다.',
},
// 발송했지만 엔진이 받는 사람을 모두 거절했다 — 그중 하나라도 5xx(없는 주소 · 거절). 아무에게도 가지 않아 보낸 사본을 지웠다 (계약 2절 "받는 사람별 결과")
MAIL_010: {
status: HttpStatus.BAD_REQUEST,
message: '받는 사람 주소로 메일을 보낼 수 없습니다. 주소를 확인해 주세요.',
},
// 발송했지만 엔진이 받는 사람을 모두 4xx(지금은 받을 수 없음 — 한도 등)로 거절했다. 보낸 사본을 지웠다 — 같은 키로 다시 보내면 된다
MAIL_011: {
status: HttpStatus.SERVICE_UNAVAILABLE,
message:
'받는 쪽이 지금은 메일을 받을 수 없습니다. 잠시 뒤 다시 보내 주세요.',
},
// 필터 저장(PUT /api/mail-filters) 때 그사이 다른 곳(다른 탭 · PC)에서 규칙이 바뀌었다 — 저장하지 않았다.
// 화면은 목록 위 줄 + [다시 불러오기] 로 알린다 (계약 2절 "메일 필터"). 부재중 응답만 바뀐 것은 걸리지 않는다
MAIL_012: {
status: HttpStatus.CONFLICT,
message:
'다른 곳에서 필터가 바뀌어 저장하지 않았습니다. 다시 불러온 뒤 고쳐 주세요.',
},
// 필터 · 부재중 응답으로 만든 엔진 규칙(Sieve 스크립트)이 엔진 한도를 넘는다 — 저장하지 않았다 (계약 2절 "메일 필터"의 크기)
MAIL_013: {
status: HttpStatus.BAD_REQUEST,
message:
'필터가 너무 길어 저장할 수 없습니다. 조건을 줄이거나 쓰지 않는 필터를 지워 주세요.',
},
} as const satisfies Record<string, ErrorCodeDefinition>;
export const ERROR_CODES = {
@@ -8,7 +8,7 @@ import {
} from '@/modules/auth/auth.constants';
import { ChangePasswordDto } from './change-password.dto';
/** 전역 ValidationPipe 와 같은 옵션으로 검증한다 (app.setup.ts) — 걸린 규칙 이름만 */
/** 전역 ValidationPipe 와 같은 옵션으로 검증한다 (app.setup.ts — stopAtFirstError 만 빼고 — 걸린 규칙을 모두 본다, 통과 · 실패는 같다) — 걸린 규칙 이름만 */
async function constraintsOf(body: object): Promise<string[]> {
const errors = await validate(plainToInstance(ChangePasswordDto, body), {
whitelist: true,
@@ -4,7 +4,7 @@ import { validate } from 'class-validator';
import { MAX_BULK_MESSAGE_IDS } from '@/modules/mail/mail.constants';
import { MessageDeletionDto, UpdateMessagesDto } from './bulk-messages.dto';
/** 전역 ValidationPipe 와 같은 옵션으로 검증한다 (app.setup.ts) */
/** 전역 ValidationPipe 와 같은 옵션으로 검증한다 (app.setup.ts — stopAtFirstError 만 빼고 — 걸린 규칙을 모두 본다, 통과 · 실패는 같다) */
async function constraintsOf(
type: typeof UpdateMessagesDto | typeof MessageDeletionDto,
body: object,
@@ -47,7 +47,7 @@ describe('여러 통 요청 본문 (PATCH /api/messages · POST /api/message-del
).toEqual(['arrayMaxSize']);
expect(
await constraintsOf(type, { ids: ['m1', 'm1'], ...changes }),
).toEqual(['arrayUnique']);
).toEqual(['arrayDistinct']);
for (const bad of ['../m1', '', 'm 1', 1, null]) {
expect(
await constraintsOf(type, { ids: ['m1', bad], ...changes }),
@@ -1,13 +1,8 @@
import { applyDecorators } from '@nestjs/common';
import { ApiProperty } from '@nestjs/swagger';
import {
ArrayMaxSize,
ArrayMinSize,
ArrayUnique,
IsArray,
Matches,
} from 'class-validator';
import { ArrayMaxSize, ArrayMinSize, IsArray, Matches } from 'class-validator';
import { ArrayDistinct } from '@/common/decorators/array-distinct.decorator';
import {
MAIL_ID_PATTERN,
MAX_BULK_MESSAGE_IDS,
@@ -33,7 +28,7 @@ function MessageIdList(description: string) {
ArrayMaxSize(MAX_BULK_MESSAGE_IDS, {
message: `메일은 한 번에 ${MAX_BULK_MESSAGE_IDS}통까지 선택할 수 있습니다.`,
}),
ArrayUnique({ message: '같은 메일을 두 번 선택했습니다.' }),
ArrayDistinct({ message: '같은 메일을 두 번 선택했습니다.' }),
Matches(MAIL_ID_PATTERN, {
each: true,
message: '메일을 다시 선택해 주세요.',
@@ -0,0 +1,249 @@
import { plainToInstance } from 'class-transformer';
import { validate, type ValidationError } from 'class-validator';
import {
MAX_MAIL_FILTER_CONDITIONS,
MAX_MAIL_FILTER_NAME_LENGTH,
MAX_MAIL_FILTER_RULES,
MAX_MAIL_FILTER_VALUE_LENGTH,
} from '@/modules/mail/mail.constants';
import { MailFilterUpdateDto } from './mail-filter.dto';
/** 걸린 규칙 이름 — 중첩된 칸(rules.0.conditions.1.value 등)까지 */
function constraintNames(errors: ValidationError[]): string[] {
return errors.flatMap((error) => [
...Object.keys(error.constraints ?? {}),
...constraintNames(error.children ?? []),
]);
}
/** 전역 ValidationPipe 와 같은 옵션으로 변환 · 검증한다 (app.setup.ts — stopAtFirstError 만 빼고 — 걸린 규칙을 모두 본다, 통과 · 실패는 같다) */
async function check(
body: object,
): Promise<{ names: string[]; dto: MailFilterUpdateDto }> {
const dto = plainToInstance(MailFilterUpdateDto, body);
const errors = await validate(dto, {
whitelist: true,
forbidNonWhitelisted: true,
});
return { names: constraintNames(errors), dto };
}
const namesOf = async (body: object) => (await check(body)).names;
const condition = {
field: 'from',
operator: 'contains',
value: '@hanbit-partners.co.kr',
};
const actions = {
moveToMailboxId: 'j',
markAsRead: true,
flag: false,
forwardTo: null,
};
const rule = {
id: '0b9a4f9e-7c2d-4d3a-9f4e-1a2b3c4d5e6f',
name: '거래처 메일',
isEnabled: true,
match: 'any',
conditions: [condition],
actions,
};
const VERSION = 'Cbd1Lkq0H3S7mJ5yV0w2qg8qkQvJ8sB7bW2nZ1y3u9E';
const body = (overrides: object = {}) => ({
rules: [rule],
version: VERSION,
...overrides,
});
/** 규칙 하나를 바꾼 본문 */
const withRule = (overrides: object) =>
body({ rules: [{ ...rule, ...overrides }] });
const withCondition = (overrides: object) =>
withRule({ conditions: [{ ...condition, ...overrides }] });
const withActions = (overrides: object) =>
withRule({ actions: { ...actions, ...overrides } });
describe('메일 필터 저장 본문 (PUT /api/mail-filters)', () => {
it('맞는 본문 — 빈 목록 · 모든 메일(조건 없음) · 전달만 하는 규칙도 형식은 맞다', async () => {
expect(await namesOf(body())).toEqual([]);
expect(await namesOf(body({ rules: [] }))).toEqual([]);
expect(
await namesOf(
withRule({
match: 'always',
conditions: [],
actions: {
moveToMailboxId: null,
markAsRead: false,
flag: true,
forwardTo: 'doyun.lee@arasystem.it.kr',
},
}),
),
).toEqual([]);
});
it('이름 · 찾을 글 · 전달 주소는 앞뒤 공백을 지운 값으로 검사하고 그 값을 쓴다', async () => {
const { names, dto } = await check(
withRule({
name: ` ${'가'.repeat(MAX_MAIL_FILTER_NAME_LENGTH)} `,
conditions: [
{
...condition,
value: `\u3000 ${'x'.repeat(MAX_MAIL_FILTER_VALUE_LENGTH)} `,
},
],
actions: { ...actions, forwardTo: ' doyun.lee@arasystem.it.kr ' },
}),
);
expect(names).toEqual([]);
expect(dto.rules[0].name).toBe('가'.repeat(MAX_MAIL_FILTER_NAME_LENGTH));
expect(dto.rules[0].conditions[0].value).toBe(
'x'.repeat(MAX_MAIL_FILTER_VALUE_LENGTH),
);
expect(dto.rules[0].actions.forwardTo).toBe('doyun.lee@arasystem.it.kr');
});
it('지문(version)은 서버가 준 모양만 — 빠짐 · 빈칸 · 너무 김은 형식 오류', async () => {
expect(await namesOf({ rules: [] })).toContain('isString');
expect(await namesOf(body({ version: 'a b' }))).toEqual(['matches']);
expect(await namesOf(body({ version: 'x'.repeat(129) }))).toEqual([
'matches',
]);
});
it(`규칙은 배열 · ${MAX_MAIL_FILTER_RULES}개까지 · 모르는 필드는 받지 않는다(isActive 포함) — 겹친 id 는 서비스가 본다`, async () => {
expect(await namesOf(body({ rules: rule }))).toContain('isArray');
const many = Array.from({ length: MAX_MAIL_FILTER_RULES + 1 }, (_, i) => ({
...rule,
id: `r${i}`,
}));
expect(await namesOf(body({ rules: many }))).toEqual(['arrayMaxSize']);
expect(await namesOf(body({ rules: many.slice(1) }))).toEqual([]);
// 겹친 id 는 형식이 아니라 목록의 규칙이다 — 서비스가 findFilterRulesProblem(Set, 항목 수에 비례)으로 VAL_001.
// 요청 검증의 ArrayUnique 는 항목 수의 제곱이라 쓰지 않는다 (mail-filters.spec.ts · mail.service.spec.ts)
expect(await namesOf(body({ rules: [rule, rule] }))).toEqual([]);
expect(await namesOf(body({ isActive: true }))).toEqual([
'whitelistValidation',
]);
expect(await namesOf(withRule({ priority: 1 }))).toEqual([
'whitelistValidation',
]);
expect(await namesOf(body({ rules: [null] }))).not.toEqual([]);
});
it('규칙의 칸 — id 형식 · 이름 길이와 제어 문자 · 켬은 불리언 · 묶는 방법', async () => {
expect(await namesOf(withRule({ id: '규칙 1' }))).toEqual(['matches']);
expect(await namesOf(withRule({ id: 'x'.repeat(65) }))).toEqual([
'matches',
]);
expect(
await namesOf(
withRule({ name: 'x'.repeat(MAX_MAIL_FILTER_NAME_LENGTH + 1) }),
),
).toEqual(['maxCodeUnits']);
expect(await namesOf(withRule({ name: '거래처\t메일' }))).toEqual([
'matches',
]);
expect(await namesOf(withRule({ name: null }))).toContain('isString');
expect(await namesOf(withRule({ isEnabled: 'true' }))).toEqual([
'isBoolean',
]);
expect(await namesOf(withRule({ match: 'none' }))).toEqual(['isIn']);
});
it(`조건 — 배열 · ${MAX_MAIL_FILTER_CONDITIONS}개까지, 견줄 곳 · 포함 여부는 정해진 값만`, async () => {
expect(await namesOf(withRule({ conditions: condition }))).toContain(
'isArray',
);
expect(
await namesOf(
withRule({
conditions: Array.from(
{ length: MAX_MAIL_FILTER_CONDITIONS + 1 },
() => condition,
),
}),
),
).toEqual(['arrayMaxSize']);
expect(await namesOf(withCondition({ field: 'cc' }))).toEqual(['isIn']);
expect(await namesOf(withCondition({ operator: 'is' }))).toEqual(['isIn']);
expect(await namesOf(withCondition({ regex: '.*' }))).toEqual([
'whitelistValidation',
]);
});
it(`찾을 글 — 앞뒤 공백을 빼고 1~${MAX_MAIL_FILTER_VALUE_LENGTH}자(JS 문자열 길이) · 제어 문자 없음 · \${ 없음`, async () => {
for (const value of ['', ' ', ' \u3000\u00a0 ']) {
expect(await namesOf(withCondition({ value }))).toEqual(['minCodeUnits']);
}
expect(
await namesOf(
withCondition({ value: 'x'.repeat(MAX_MAIL_FILTER_VALUE_LENGTH + 1) }),
),
).toEqual(['maxCodeUnits']);
// 이모지는 두 자로 센다 (웹의 .length 와 같다)
expect(
await namesOf(
withCondition({
value: `${'🌴'.repeat(MAX_MAIL_FILTER_VALUE_LENGTH / 2)}x`,
}),
),
).toEqual(['maxCodeUnits']);
for (const value of ['a\nb', 'a\u0000', 'a\u007f', 'a\u0085']) {
expect(await namesOf(withCondition({ value }))).toEqual(['matches']);
}
for (const value of ['${x}', '광고${', '${unicode:41}']) {
expect(await namesOf(withCondition({ value }))).toEqual([
'noSieveVariable',
]);
}
// $ · { 하나씩 · 따옴표 · 역슬래시는 받는다 (엔진 규칙에서 이스케이프한다)
expect(await namesOf(withCondition({ value: '"$5{0}" \\ 견적' }))).toEqual(
[],
);
expect(await namesOf(withCondition({ value: 42 }))).toContain('isString');
});
it('할 일 — 객체여야 한다. 옮길 메일함은 id 형식이거나 null, 읽음 · 중요는 불리언 (키가 빠지면 형식 오류)', async () => {
expect(await namesOf(withRule({ actions: undefined }))).toContain(
'isObject',
);
expect(await namesOf(withRule({ actions: 'move' }))).toContain('isObject');
expect(await namesOf(withActions({ moveToMailboxId: 'a/b' }))).toEqual([
'matches',
]);
const withoutMove: Record<string, unknown> = { ...actions };
delete withoutMove.moveToMailboxId;
expect(await namesOf(withRule({ actions: withoutMove }))).toEqual([
'matches',
]);
expect(await namesOf(withActions({ markAsRead: 1 }))).toEqual([
'isBoolean',
]);
expect(await namesOf(withActions({ flag: null }))).toEqual(['isBoolean']);
expect(await namesOf(withActions({ copy: true }))).toEqual([
'whitelistValidation',
]);
});
it('전달 주소 — 메일 주소 형식 · 254자까지 · ${ 없음 · null 은 없음 (키가 빠지면 형식 오류)', async () => {
expect(await namesOf(withActions({ forwardTo: 'doyun.lee' }))).toContain(
'isEmail',
);
expect(
await namesOf(
withActions({ forwardTo: `${'a'.repeat(250)}@arasystem.it.kr` }),
),
).not.toEqual([]);
expect(
await namesOf(withActions({ forwardTo: '${x}@arasystem.it.kr' })),
).toContain('noSieveVariable');
const withoutForward: Record<string, unknown> = { ...actions };
delete withoutForward.forwardTo;
expect(await namesOf(withRule({ actions: withoutForward }))).toContain(
'isEmail',
);
});
});
@@ -0,0 +1,269 @@
import { ApiProperty } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import {
ArrayMaxSize,
IsArray,
IsBoolean,
IsEmail,
IsIn,
IsObject,
IsString,
Matches,
MaxLength,
ValidateBy,
ValidateIf,
ValidateNested,
type ValidationOptions,
} from 'class-validator';
import {
MaxCodeUnits,
MinCodeUnits,
} from '@/common/decorators/code-units.decorator';
import { TrimString } from '@/common/decorators/trim-string.decorator';
import {
MAIL_FILTER_FIELDS,
MAIL_FILTER_MATCHES,
MAIL_FILTER_OPERATORS,
hasSieveVariable,
} from '@/modules/mail/mail-filters';
import {
MAIL_FILTER_RULE_ID_PATTERN,
MAIL_FILTER_VERSION_PATTERN,
MAIL_ID_PATTERN,
MAX_EMAIL_LENGTH,
MAX_MAIL_FILTER_CONDITIONS,
MAX_MAIL_FILTER_NAME_LENGTH,
MAX_MAIL_FILTER_RULES,
MAX_MAIL_FILTER_VALUE_LENGTH,
SINGLE_LINE_TEXT_PATTERN,
} from '@/modules/mail/mail.constants';
import type {
MailFilterActions,
MailFilterCondition,
MailFilterField,
MailFilterMatch,
MailFilterOperator,
MailFilterRule,
MailFilterSet,
} from '@/modules/mail/mail.types';
/**
* 메일 필터 — PUT 본문(`{ rules, version }` 전체 교체)과 GET · PUT 응답(MailFilterSet) (계약 2절 "메일 필터").
* 여기서는 칸마다 형식을 본다. 칸 사이의 규칙(묶는 방법 ↔ 조건 수 · 할 일 하나 이상)과 전달 주소(사내 · 자기 주소 아님) ·
* 옮길 메일함(보낸편지함 · 임시보관함 아님)은 서비스가 본다 (모두 400 VAL_001).
* 글은 앞뒤 공백을 지운 값으로 검사하고 그 값으로 저장한다.
*/
/** null 은 "없음"으로 받는다 — 키가 없으면(undefined) 형식 오류다 (전체 교체라 모든 필드를 보낸다) */
const unlessNull = (_object: object, value: unknown) => value !== null;
/** 엔진이 변수로 펼칠 `${` 가 없다 — 펼쳐져 빈 값이 되면 `:contains` 가 모든 메일에 맞는다 (계약 2절 "만드는 Sieve") */
function NoSieveVariable(options: ValidationOptions) {
return ValidateBy(
{
name: 'noSieveVariable',
validator: {
validate: (value: unknown) =>
typeof value === 'string' && !hasSieveVariable(value),
},
},
options,
);
}
export class MailFilterConditionDto implements MailFilterCondition {
@ApiProperty({
description:
'견줄 곳 — from 보낸 사람(이름 · 주소) · to 받는 사람(받는 사람 · 참조의 이름 · 주소) · subject 제목 · body 본문(글자 부분)',
enum: [...MAIL_FILTER_FIELDS],
example: 'from',
})
@IsIn(MAIL_FILTER_FIELDS, { message: '조건의 견줄 곳을 다시 골라 주세요.' })
field!: MailFilterField;
@ApiProperty({
description: '포함 · 포함하지 않음 — 대소문자는 영문만 가리지 않는다',
enum: [...MAIL_FILTER_OPERATORS],
example: 'contains',
})
@IsIn(MAIL_FILTER_OPERATORS, {
message: '조건의 포함 · 포함하지 않음을 다시 골라 주세요.',
})
operator!: MailFilterOperator;
@ApiProperty({
description:
`찾을 글 — 앞뒤 공백을 뺀 1~${MAX_MAIL_FILTER_VALUE_LENGTH}자(JS 문자열 길이, 앞뒤 공백은 지워 저장한다). ` +
'제어 문자(줄바꿈 · 탭 등)와 "${" 는 쓸 수 없다',
example: '@hanbit-partners.co.kr',
minLength: 1,
maxLength: MAX_MAIL_FILTER_VALUE_LENGTH,
})
@TrimString()
@IsString({ message: '찾을 글 형식이 올바르지 않습니다.' })
@MinCodeUnits(1, { message: '찾을 글을 입력해 주세요.' })
@MaxCodeUnits(MAX_MAIL_FILTER_VALUE_LENGTH, {
message: `찾을 글은 ${MAX_MAIL_FILTER_VALUE_LENGTH}자 이하로 입력해 주세요.`,
})
@Matches(SINGLE_LINE_TEXT_PATTERN, {
message: '찾을 글에는 줄바꿈 같은 제어 문자를 쓸 수 없습니다.',
})
@NoSieveVariable({ message: '찾을 글에는 ${ 를 쓸 수 없습니다.' })
value!: string;
}
export class MailFilterActionsDto implements MailFilterActions {
@ApiProperty({
description:
'옮길 메일함 id (없으면 null) — 받은편지함 · 스팸메일함 · 휴지통 · 내 메일함 (보낸편지함 · 임시보관함은 400 VAL_001). ' +
'없는 id 도 받는다 — 그 메일함이 없으면 받은편지함에 들어온다. 옮기는 규칙에 맞으면 아래 규칙은 보지 않는다',
nullable: true,
type: String,
example: 'j',
})
@ValidateIf(unlessNull)
@Matches(MAIL_ID_PATTERN, { message: '옮길 메일함을 다시 골라 주세요.' })
moveToMailboxId!: string | null;
@ApiProperty({ description: '읽음으로 표시', example: false })
@IsBoolean({ message: '읽음으로 표시할지 다시 골라 주세요.' })
markAsRead!: boolean;
@ApiProperty({ description: '중요 표시', example: true })
@IsBoolean({ message: '중요 표시할지 다시 골라 주세요.' })
flag!: boolean;
@ApiProperty({
description:
'전달할 사내 주소(MAIL_DOMAIN — 없으면 null). 자기 주소는 안 된다(400 VAL_001). 사본은 늘 남긴다. ' +
'메일 엔진은 한 통에 한 곳만 전달한다 — 맞는 규칙이 여럿이면 두 번째 전달은 엔진이 버린다',
nullable: true,
type: String,
example: 'doyun.lee@arasystem.it.kr',
})
@ValidateIf(unlessNull)
@TrimString()
@IsEmail({}, { message: '전달할 주소 형식이 올바르지 않습니다.' })
@MaxLength(MAX_EMAIL_LENGTH, { message: '전달할 주소가 너무 깁니다.' })
@NoSieveVariable({ message: '전달할 주소에는 ${ 를 쓸 수 없습니다.' })
forwardTo!: string | null;
}
export class MailFilterRuleDto implements MailFilterRule {
@ApiProperty({
description:
'규칙 id — 클라이언트가 만든다(UUID, 영문 · 숫자 · - · _ 64자까지). 목록 안에서 겹치지 않는다',
example: '0b9a4f9e-7c2d-4d3a-9f4e-1a2b3c4d5e6f',
})
@Matches(MAIL_FILTER_RULE_ID_PATTERN, {
message: '필터를 다시 만들어 주세요.',
})
id!: string;
@ApiProperty({
description: `이름 — 앞뒤 공백을 뺀 0~${MAX_MAIL_FILTER_NAME_LENGTH}자(비면 화면이 조건으로 이름을 보인다). 제어 문자는 쓸 수 없다`,
example: '거래처 메일',
maxLength: MAX_MAIL_FILTER_NAME_LENGTH,
})
@TrimString()
@IsString({ message: '필터 이름 형식이 올바르지 않습니다.' })
@MaxCodeUnits(MAX_MAIL_FILTER_NAME_LENGTH, {
message: `필터 이름은 ${MAX_MAIL_FILTER_NAME_LENGTH}자 이하로 입력해 주세요.`,
})
@Matches(SINGLE_LINE_TEXT_PATTERN, {
message: '필터 이름에는 줄바꿈 같은 제어 문자를 쓸 수 없습니다.',
})
name!: string;
@ApiProperty({
description: '켜짐 — 꺼 둔 규칙은 목록에만 있고 적용하지 않는다',
example: true,
})
@IsBoolean({ message: '필터를 켤지 다시 골라 주세요.' })
isEnabled!: boolean;
@ApiProperty({
description:
'조건이 모두 맞음(all) · 하나라도 맞음(any) · 모든 메일(always — 조건 없음)',
enum: [...MAIL_FILTER_MATCHES],
example: 'any',
})
@IsIn(MAIL_FILTER_MATCHES, {
message: '조건을 묶는 방법을 다시 골라 주세요.',
})
match!: MailFilterMatch;
@ApiProperty({
description: `조건 — all · any 는 1~${MAX_MAIL_FILTER_CONDITIONS}개, always 는 빈 배열 (아니면 400 VAL_001)`,
type: [MailFilterConditionDto],
maxItems: MAX_MAIL_FILTER_CONDITIONS,
})
@IsArray({ message: '조건 형식이 올바르지 않습니다.' })
@ArrayMaxSize(MAX_MAIL_FILTER_CONDITIONS, {
message: `조건은 ${MAX_MAIL_FILTER_CONDITIONS}개까지 넣을 수 있습니다.`,
})
@ValidateNested({ each: true })
@Type(() => MailFilterConditionDto)
conditions!: MailFilterConditionDto[];
@ApiProperty({
description:
'할 일 — 하나 이상 (아니면 400 VAL_001). 맞으면 표시(읽음 · 중요) → 전달 → 옮기기 차례로 한다',
type: MailFilterActionsDto,
})
@IsObject({ message: '할 일 형식이 올바르지 않습니다.' })
@ValidateNested()
@Type(() => MailFilterActionsDto)
actions!: MailFilterActionsDto;
}
/** PUT /api/mail-filters — 규칙 전체 교체 + 고치기 시작할 때 받은 지문 */
export class MailFilterUpdateDto {
@ApiProperty({
description: `규칙 — 위에서부터 차례로 적용한다. ${MAX_MAIL_FILTER_RULES}개까지, id 가 겹치지 않는다`,
type: [MailFilterRuleDto],
maxItems: MAX_MAIL_FILTER_RULES,
})
@IsArray({ message: '필터 목록 형식이 올바르지 않습니다.' })
@ArrayMaxSize(MAX_MAIL_FILTER_RULES, {
message: `필터는 ${MAX_MAIL_FILTER_RULES}개까지 만들 수 있습니다.`,
})
@ValidateNested({ each: true })
@Type(() => MailFilterRuleDto)
rules!: MailFilterRuleDto[];
@ApiProperty({
description:
'고치기 시작할 때 받은 version 그대로 — 그사이 다른 곳에서 규칙이 바뀌었으면 409 MAIL_012 (저장하지 않는다)',
example: 'Cbd1Lkq0H3S7mJ5yV0w2qg8qkQvJ8sB7bW2nZ1y3u9E',
})
@IsString({ message: '필터를 다시 불러온 뒤 저장해 주세요.' })
@Matches(MAIL_FILTER_VERSION_PATTERN, {
message: '필터를 다시 불러온 뒤 저장해 주세요.',
})
version!: string;
}
/** GET · PUT /api/mail-filters 의 응답 */
export class MailFilterSetDto implements MailFilterSet {
@ApiProperty({
description: `규칙 — 위에서부터 차례로 적용한다 (${MAX_MAIL_FILTER_RULES}개까지)`,
type: [MailFilterRuleDto],
})
rules!: MailFilterRuleDto[];
@ApiProperty({
description:
'지금 규칙의 지문 — 해석하지 말고 PUT 에 그대로 돌려보낸다. 규칙만 본다(부재중 자동 응답이 바뀌어도 그대로다)',
example: 'Cbd1Lkq0H3S7mJ5yV0w2qg8qkQvJ8sB7bW2nZ1y3u9E',
})
version!: string;
@ApiProperty({
description:
'false = 메일 엔진에서 다른 규칙이 켜져 지금은 필터 · 부재중 자동 응답이 동작하지 않는다(드묾) — 지금 목록 그대로 저장하면 다시 켜진다',
example: true,
})
isActive!: boolean;
}
@@ -17,8 +17,11 @@ import type {
MessageDetail,
MessagesPatchResult,
MessageSummary,
RecipientRejectionReason,
RecipientSuggestion,
RejectedRecipient,
SavedDraft,
SentMessage,
} from '@/modules/mail/mail.types';
/**
@@ -357,9 +360,42 @@ export class MessagePageDto {
pageInfo!: PageInfoDto;
}
export class SentMessageDto {
const REJECTION_REASONS: RecipientRejectionReason[] = [
'not-found',
'refused',
'temporary',
];
/** 계약 2절 "받는 사람별 결과"의 RejectedRecipient — 엔진이 받지 않은 사람 */
export class RejectedRecipientDto implements RejectedRecipient {
@ApiProperty({
description:
'요청에 쓴 주소 그대로 (받는 사람 · 참조 · 숨은참조 모두, 같은 주소는 한 번)',
example: 'nobody@arasystem.it.kr',
})
email!: string;
@ApiProperty({
description:
'not-found = 없는 주소(SMTP 확장 상태 5.1.x) · refused = 받는 쪽이 거절(그 밖의 5xx) · temporary = 지금은 받을 수 없음(4xx — 한도 등)',
enum: REJECTION_REASONS,
example: 'not-found',
})
reason!: RecipientRejectionReason;
}
/** 계약 2절 "받는 사람별 결과"의 SentMessage — POST /api/messages 의 201 */
export class SentMessageDto implements SentMessage {
@ApiProperty({ description: '보낸편지함에 남은 메일 id', example: 'maaaaad' })
id!: string;
@ApiProperty({
description:
'엔진이 받지 않은 사람 — 없으면 빈 배열. 다른 사람에게는 보냈다(보낸 사본은 보낸편지함에 남는다). ' +
'아무도 받지 않았으면 이 응답 대신 400 MAIL_010 · 503 MAIL_011 이다. 결과를 읽지 못하면 빈 배열(받은 것으로 본다)',
type: [RejectedRecipientDto],
})
rejectedRecipients!: RejectedRecipientDto[];
}
/** 계약 2절 "새 메일 확인"의 MailState */
@@ -3,11 +3,13 @@ import { validate } from 'class-validator';
import {
MAX_VACATION_SUBJECT_LENGTH,
MAX_VACATION_SUBJECT_LENGTH_WHEN_OFF,
MAX_VACATION_TEXT_LENGTH,
MAX_VACATION_TEXT_LENGTH_WHEN_OFF,
} from '@/modules/mail/mail.constants';
import { MailVacationDto, isUtcDateTime } from './mail-vacation.dto';
/** 전역 ValidationPipe 와 같은 옵션으로 검증한다 (app.setup.ts) — 걸린 규칙 이름만 */
/** 전역 ValidationPipe 와 같은 옵션으로 검증한다 (app.setup.ts — stopAtFirstError 만 빼고 — 걸린 규칙을 모두 본다, 통과 · 실패는 같다) — 걸린 규칙 이름만 */
async function constraintsOf(body: object): Promise<string[]> {
const errors = await validate(plainToInstance(MailVacationDto, body), {
whitelist: true,
@@ -70,26 +72,31 @@ describe('부재중 자동 응답 본문 (PUT /api/mail-vacation)', () => {
).toEqual([]);
});
it(`제목 ${MAX_VACATION_SUBJECT_LENGTH}자 · 내용 ${MAX_VACATION_TEXT_LENGTH}자까지 (JS 문자열 길이) — 서식 본문은 받지 않는다`, async () => {
expect(
await constraintsOf({
...enabled,
subject: 'x'.repeat(MAX_VACATION_SUBJECT_LENGTH),
textBody: 'x'.repeat(MAX_VACATION_TEXT_LENGTH),
}),
).toEqual([]);
expect(
await constraintsOf({
...enabled,
subject: 'x'.repeat(MAX_VACATION_SUBJECT_LENGTH + 1),
}),
).toEqual(['maxCodeUnits']);
expect(
await constraintsOf({
...enabled,
textBody: `${'🌴'.repeat(MAX_VACATION_TEXT_LENGTH / 2)}x`,
}),
).toEqual(['maxCodeUnits']);
it(`요청 검증의 길이 한도는 켬 · 끔과 상관없이 제목 ${MAX_VACATION_SUBJECT_LENGTH_WHEN_OFF}자 · 내용 ${MAX_VACATION_TEXT_LENGTH_WHEN_OFF}자 (JS 문자열 길이) — 서식 본문은 받지 않는다`, async () => {
for (const isEnabled of [false, true]) {
expect(
await constraintsOf({
...enabled,
isEnabled,
subject: 'x'.repeat(MAX_VACATION_SUBJECT_LENGTH_WHEN_OFF),
textBody: 'x'.repeat(MAX_VACATION_TEXT_LENGTH_WHEN_OFF),
}),
).toEqual([]);
expect(
await constraintsOf({
...enabled,
isEnabled,
subject: 'x'.repeat(MAX_VACATION_SUBJECT_LENGTH_WHEN_OFF + 1),
}),
).toEqual(['maxCodeUnits']);
expect(
await constraintsOf({
...enabled,
isEnabled,
textBody: `${'🌴'.repeat(MAX_VACATION_TEXT_LENGTH_WHEN_OFF / 2)}x`,
}),
).toEqual(['maxCodeUnits']);
}
expect(await constraintsOf({ ...enabled, subject: null })).toEqual(
expect.arrayContaining(['isString']),
);
@@ -98,6 +105,52 @@ describe('부재중 자동 응답 본문 (PUT /api/mail-vacation)', () => {
).toEqual(['whitelistValidation']);
});
it(`꺼 둔 채로는 켤 때의 한도(제목 ${MAX_VACATION_SUBJECT_LENGTH}자 · 내용 ${MAX_VACATION_TEXT_LENGTH}자)를 넘는 글도 받는다 — 제목 151~200자 · 내용 601~4,000자 (켤 때의 한도는 서비스가 본다)`, async () => {
const off = { ...enabled, isEnabled: false };
for (const [subjectLength, textLength] of [
[MAX_VACATION_SUBJECT_LENGTH + 1, MAX_VACATION_TEXT_LENGTH + 1],
[MAX_VACATION_SUBJECT_LENGTH_WHEN_OFF, MAX_VACATION_TEXT_LENGTH_WHEN_OFF],
]) {
expect(
await constraintsOf({
...off,
subject: 'x'.repeat(subjectLength),
textBody: 'x'.repeat(textLength),
}),
).toEqual([]);
}
});
it('켤 때의 한도까지 쓴 어떤 글도 엔진의 UTF-8 바이트 한도(제목 511 · 내용 2,047) 안이다 — 한글 · 이모지 · 서로게이트 반쪽 모두', async () => {
// Stalwart 0.16.27 이 넘으면 invalidProperties 로 거절한다 (계약 2절 "길이 한도의 근거", 2026-10-28 확인)
const ENGINE_SUBJECT_MAX_BYTES = 511;
const ENGINE_TEXT_MAX_BYTES = 2_047;
const fill = (unit: string, length: number) =>
unit.repeat(Math.floor(length / unit.length));
// 한 자(UTF-16 코드 단위)가 UTF-8 로 가장 큰 것은 3바이트다 — BMP 글자(한글), 짝 없는 서로게이트(U+FFFD 로 바뀐다)
for (const unit of ['가', '😀', '\ud83d', 'a']) {
const subject = fill(unit, MAX_VACATION_SUBJECT_LENGTH);
const textBody = fill(unit, MAX_VACATION_TEXT_LENGTH);
expect(Buffer.byteLength(subject, 'utf8')).toBeLessThanOrEqual(
ENGINE_SUBJECT_MAX_BYTES,
);
expect(Buffer.byteLength(textBody, 'utf8')).toBeLessThanOrEqual(
ENGINE_TEXT_MAX_BYTES,
);
}
// 한글로 한도까지 채운 값은 형식 검사를 지난다 (4단계의 200자 · 4,000자는 한글이면 엔진이 거절했다)
expect(
await constraintsOf({
...enabled,
subject: '가'.repeat(MAX_VACATION_SUBJECT_LENGTH),
textBody: '가'.repeat(MAX_VACATION_TEXT_LENGTH),
}),
).toEqual([]);
});
it('제목에는 제어 문자(C0 — 탭 · 줄바꿈 포함, DEL, C1)를 쓸 수 없다 — 범위 바로 바깥 문자는 받는다', async () => {
for (const subject of [
'부재\n중',
@@ -11,7 +11,9 @@ import {
import { MaxCodeUnits } from '@/common/decorators/code-units.decorator';
import {
MAX_VACATION_SUBJECT_LENGTH,
MAX_VACATION_SUBJECT_LENGTH_WHEN_OFF,
MAX_VACATION_TEXT_LENGTH,
MAX_VACATION_TEXT_LENGTH_WHEN_OFF,
MULTI_LINE_TEXT_PATTERN,
SINGLE_LINE_TEXT_PATTERN,
} from '@/modules/mail/mail.constants';
@@ -45,11 +47,13 @@ const unlessNull = (_object: object, value: unknown) => value !== null;
/**
* 부재중 자동 응답 — GET 응답과 PUT 본문(전체 교체)이 같은 모양이다 (계약 2절 "부재중 자동 응답").
* 켤 때 제목 · 내용이 비었는지, 시작이 끝보다 앞인지는 서비스가 본다 (400 VAL_001). 서식 본문은 받지 않는다.
* 켤 때 제목 · 내용이 비었는지 · 켤 때의 길이(150자 · 600자), 시작이 끝보다 앞인지는 서비스가 본다 (400 VAL_001).
* 여기서는 켬 · 끔과 상관없는 한도(4단계의 200자 · 4,000자)만 본다 — 같은 속성에 ValidateIf 를 걸면 그 속성의 검사가 모두 빠져
* 켤 때만의 한도를 함께 걸 수 없다. 서식 본문은 받지 않는다.
*/
export class MailVacationDto implements MailVacation {
@ApiProperty({
description: '켜짐 — 켜면 제목 · 내용이 1자 이상이어야 한다',
description: `켜짐 — 켜면 제목 · 내용이 1자 이상, 제목 ${MAX_VACATION_SUBJECT_LENGTH}자 · 내용 ${MAX_VACATION_TEXT_LENGTH}자까지여야 한다 (아니면 400 VAL_001)`,
example: true,
})
@IsBoolean({ message: '부재중 자동 응답을 켤지 다시 선택해 주세요.' })
@@ -77,13 +81,15 @@ export class MailVacationDto implements MailVacation {
toDate!: string | null;
@ApiProperty({
description: `자동 응답 제목 — ${MAX_VACATION_SUBJECT_LENGTH}자까지(JS 문자열 길이), 켤 때는 1자 이상 (끈 채로는 빈 문자열도 저장된다)`,
maxLength: MAX_VACATION_SUBJECT_LENGTH,
description:
`자동 응답 제목 — 켤 때는 1~${MAX_VACATION_SUBJECT_LENGTH}자(엔진의 바이트 한도 안), 꺼 둔 채로는 ${MAX_VACATION_SUBJECT_LENGTH_WHEN_OFF}자까지 ` +
'(JS 문자열 길이 — 끈 채로는 빈 문자열도 저장된다)',
maxLength: MAX_VACATION_SUBJECT_LENGTH_WHEN_OFF,
example: '부재중입니다',
})
@IsString({ message: '자동 응답 제목 형식이 올바르지 않습니다.' })
@MaxCodeUnits(MAX_VACATION_SUBJECT_LENGTH, {
message: `자동 응답 제목은 ${MAX_VACATION_SUBJECT_LENGTH}자 이하로 입력해 주세요.`,
@MaxCodeUnits(MAX_VACATION_SUBJECT_LENGTH_WHEN_OFF, {
message: `자동 응답 제목은 ${MAX_VACATION_SUBJECT_LENGTH_WHEN_OFF}자 이하로 입력해 주세요.`,
})
@Matches(SINGLE_LINE_TEXT_PATTERN, {
message: '자동 응답 제목에는 줄바꿈 같은 제어 문자를 쓸 수 없습니다.',
@@ -91,13 +97,15 @@ export class MailVacationDto implements MailVacation {
subject!: string;
@ApiProperty({
description: `자동 응답 내용 — 글자만(서식 없음), ${MAX_VACATION_TEXT_LENGTH.toLocaleString('ko-KR')}자까지(JS 문자열 길이), 켤 때는 1자 이상. 서명은 붙지 않는다`,
maxLength: MAX_VACATION_TEXT_LENGTH,
description:
`자동 응답 내용 — 글자만(서식 없음). 켤 때는 1~${MAX_VACATION_TEXT_LENGTH.toLocaleString('ko-KR')}자(엔진의 바이트 한도 안), ` +
`꺼 둔 채로는 ${MAX_VACATION_TEXT_LENGTH_WHEN_OFF.toLocaleString('ko-KR')}자까지 (JS 문자열 길이). 서명은 붙지 않는다`,
maxLength: MAX_VACATION_TEXT_LENGTH_WHEN_OFF,
example: '11월 7일까지 휴가입니다. 급한 일은 이도윤 님께 연락해 주세요.',
})
@IsString({ message: '자동 응답 내용 형식이 올바르지 않습니다.' })
@MaxCodeUnits(MAX_VACATION_TEXT_LENGTH, {
message: `자동 응답 내용은 ${MAX_VACATION_TEXT_LENGTH.toLocaleString('ko-KR')}자 이하로 입력해 주세요.`,
@MaxCodeUnits(MAX_VACATION_TEXT_LENGTH_WHEN_OFF, {
message: `자동 응답 내용은 ${MAX_VACATION_TEXT_LENGTH_WHEN_OFF.toLocaleString('ko-KR')}자 이하로 입력해 주세요.`,
})
@Matches(MULTI_LINE_TEXT_PATTERN, {
message: '자동 응답 내용에는 탭 · 줄바꿈 밖의 제어 문자를 쓸 수 없습니다.',
@@ -8,7 +8,7 @@ import {
RenameMailboxDto,
} from './mailbox.dto';
/** 전역 ValidationPipe 와 같은 옵션으로 검증한다 (app.setup.ts) — 변환한 값과 걸린 규칙 이름 */
/** 전역 ValidationPipe 와 같은 옵션으로 검증한다 (app.setup.ts — stopAtFirstError 만 빼고 — 걸린 규칙을 모두 본다, 통과 · 실패는 같다) — 변환한 값과 걸린 규칙 이름 */
async function check<T extends object>(
type: ClassConstructor<T>,
body: object,
+2 -8
View File
@@ -1,12 +1,12 @@
import { applyDecorators } from '@nestjs/common';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { Transform, type TransformFnParams } from 'class-transformer';
import { Equals, IsOptional, IsString, Matches } from 'class-validator';
import {
MaxCodeUnits,
MinCodeUnits,
} from '@/common/decorators/code-units.decorator';
import { TrimString } from '@/common/decorators/trim-string.decorator';
import {
MAIL_ID_PATTERN,
MAX_MAILBOX_NAME_LENGTH,
@@ -16,12 +16,6 @@ import {
/** 메일함 이름에 쓸 수 없는 '/' — 경로 구분자로 읽는 메일 프로그램이 있다 (계약 2절 "메일함 관리") */
const NO_SLASH_PATTERN = /^[^/]*$/;
/** 앞뒤 공백을 지운다 — 문자열이 아니면 그대로 두어 IsString 이 막는다 */
function trimString({ value }: TransformFnParams): unknown {
const raw: unknown = value;
return typeof raw === 'string' ? raw.trim() : raw;
}
/** 메일함 이름 — 앞뒤 공백을 뺀 1~MAX_MAILBOX_NAME_LENGTH 자(JS 문자열 길이), `/` · 제어 문자 없음. 만들기 · 이름 바꾸기가 같이 쓴다 */
function MailboxName() {
return applyDecorators(
@@ -31,7 +25,7 @@ function MailboxName() {
minLength: 1,
maxLength: MAX_MAILBOX_NAME_LENGTH,
}),
Transform(trimString),
TrimString(),
IsString({ message: '메일함 이름 형식이 올바르지 않습니다.' }),
MinCodeUnits(1, { message: '메일함 이름을 입력해 주세요.' }),
MaxCodeUnits(MAX_MAILBOX_NAME_LENGTH, {
@@ -7,7 +7,7 @@ import {
} from '@/modules/mail/mail.constants';
import { MessageListQueryDto } from './message-list-query.dto';
/** 전역 ValidationPipe 와 같은 옵션으로 변환·검증한다 (app.setup.ts — transform · whitelist · forbidNonWhitelisted) */
/** 전역 ValidationPipe 와 같은 옵션으로 변환·검증한다 (app.setup.ts — transform · whitelist · forbidNonWhitelisted, stopAtFirstError 만 빼고 — 걸린 규칙을 모두 본다, 통과 · 실패는 같다) */
async function check(
query: Record<string, unknown>,
): Promise<{ dto: MessageListQueryDto; constraints: string[] }> {
@@ -12,7 +12,7 @@ import {
} from '@/modules/mail/mail.constants';
import { SendMessageDto } from './send-message.dto';
/** 전역 ValidationPipe 와 같은 옵션으로 검증한다 (app.setup.ts) */
/** 전역 ValidationPipe 와 같은 옵션으로 검증한다 (app.setup.ts — stopAtFirstError 만 빼고 — 걸린 규칙을 모두 본다, 통과 · 실패는 같다) */
async function constraintsOf(body: object): Promise<string[]> {
const dto = plainToInstance(SendMessageDto, body);
const errors = await validate(dto, {
@@ -0,0 +1,269 @@
import type { RecipientFields } from '@/modules/mail/mail.types';
import {
DELIVERED_TO_ALL,
classifySmtpReply,
describeRejections,
everyoneRejectedCode,
readDeliveryResult,
toRejectedRecipients,
} from './delivery-status';
import type { JmapEmailSubmission } from './jmap.types';
/** 개발 엔진(Stalwart 0.16.27)이 돌려준 모양 그대로 (2026-10-28) */
const REFUSED_NOT_FOUND = {
delivered: 'no',
smtpReply: '550 5.1.2 Mailbox does not exist.',
displayed: 'unknown',
};
const REFUSED_RATE_LIMIT = {
delivered: 'no',
smtpReply: '452 4.4.5 Rate limit exceeded, try again later.',
displayed: 'unknown',
};
const QUEUED = {
delivered: 'unknown',
smtpReply: '250 2.1.5 Queued',
displayed: 'unknown',
};
const only = (email: string): RecipientFields => ({
to: [{ name: null, email }],
cc: [],
bcc: [],
});
const submission = (
deliveryStatus: JmapEmailSubmission['deliveryStatus'],
): JmapEmailSubmission => ({ id: 's1', deliveryStatus });
describe('받는 사람별 발송 결과 (delivery-status)', () => {
describe('SMTP 응답 → 까닭 (classifySmtpReply)', () => {
it.each([
['550 5.1.1 User unknown', 'not-found', '550 5.1.1'],
['550 5.1.2 Mailbox does not exist.', 'not-found', '550 5.1.2'],
['550-5.1.1 The email account does not exist', 'not-found', '550 5.1.1'],
['5.1.1 User unknown', 'not-found', '5.1.1'],
['554 5.7.1 Relay access denied', 'refused', '554 5.7.1'],
['550 Mailbox unavailable', 'refused', '550'],
[
'452 4.4.5 Rate limit exceeded, try again later.',
'temporary',
'452 4.4.5',
],
['421 4.7.0 Try again later', 'temporary', '421 4.7.0'],
['451 4.1.8 Sender domain not resolving', 'temporary', '451 4.1.8'],
])('"%s" 는 %s — 로그에는 상태 코드 %s 만', (reply, reason, smtpStatus) => {
expect(classifySmtpReply(reply)).toEqual({ reason, smtpStatus });
});
it('기본 코드와 확장 상태가 어긋나면 기본 코드로 나눈다 — 5xx 에 4.x.x 는 거절, 4xx 에 5.1.x 는 지금은 받을 수 없음', () => {
expect(classifySmtpReply('550 4.4.5 odd')).toEqual({
reason: 'refused',
smtpStatus: '550 4.4.5',
});
expect(classifySmtpReply('450 5.1.1 odd')).toEqual({
reason: 'temporary',
smtpStatus: '450 5.1.1',
});
});
it('읽을 수 없는 응답 · 응답 없음은 거절(refused) — 상태 코드는 null, 응답 글 안의 숫자는 읽지 않는다', () => {
for (const reply of [
undefined,
null,
'',
'Mailbox does not exist',
'see 4.4.5 later',
'5501 nope',
]) {
expect(classifySmtpReply(reply)).toEqual({
reason: 'refused',
smtpStatus: null,
});
}
});
});
describe('요청의 받는 사람과 맞추기 (readDeliveryResult)', () => {
it('delivered 가 "no" 인 사람만 받지 않은 사람이다 — 대기열(250 Queued) · 결과 없음은 받은 것', () => {
const recipients: RecipientFields = {
to: [{ name: '없는 사람', email: 'nobody@arasystem.test' }],
cc: [{ name: null, email: 'doyun.lee@arasystem.test' }],
bcc: [{ name: null, email: 'outside@example.com' }],
};
expect(
readDeliveryResult(recipients, [
submission({
'nobody@arasystem.test': REFUSED_NOT_FOUND,
'doyun.lee@arasystem.test': QUEUED,
}),
]),
).toEqual({
rejected: [
{
email: 'nobody@arasystem.test',
reason: 'not-found',
smtpStatus: '550 5.1.2',
},
],
isEveryoneRejected: false,
});
});
it('주소는 대소문자를 무시하고 맞추고, 돌려주는 주소는 요청에 쓴 그대로다', () => {
const result = readDeliveryResult(only('NoBody@AraSystem.Test'), [
submission({ 'nobody@arasystem.test': REFUSED_NOT_FOUND }),
]);
expect(result.rejected).toEqual([
expect.objectContaining({ email: 'NoBody@AraSystem.Test' }),
]);
expect(result.isEveryoneRejected).toBe(true);
});
it('같은 주소가 받는 사람 · 참조 · 숨은참조에 여러 번 있어도 한 번 — 처음 쓴 모양으로', () => {
const result = readDeliveryResult(
{
to: [{ name: null, email: 'nobody@arasystem.test' }],
cc: [{ name: null, email: 'NOBODY@arasystem.test' }],
bcc: [{ name: null, email: 'nobody@arasystem.test' }],
},
[submission({ 'nobody@arasystem.test': REFUSED_NOT_FOUND })],
);
expect(result.rejected.map(({ email }) => email)).toEqual([
'nobody@arasystem.test',
]);
expect(result.isEveryoneRejected).toBe(true);
});
it('받은 사람이 하나도 없어야 모두 거절이다 — 결과가 없는 사람이 있으면 그 사람은 받은 것', () => {
const two: RecipientFields = {
to: [{ name: null, email: 'nobody@arasystem.test' }],
cc: [{ name: null, email: 'busy@arasystem.test' }],
bcc: [],
};
expect(
readDeliveryResult(two, [
submission({
'nobody@arasystem.test': REFUSED_NOT_FOUND,
'busy@arasystem.test': REFUSED_RATE_LIMIT,
}),
]).isEveryoneRejected,
).toBe(true);
expect(
readDeliveryResult(two, [
submission({ 'nobody@arasystem.test': REFUSED_NOT_FOUND }),
]),
).toMatchObject({
rejected: [{ email: 'nobody@arasystem.test' }],
isEveryoneRejected: false,
});
});
it('엔진이 결과를 모르면(deliveryStatus null · 제출 없음) 받지 않은 사람이 없다', () => {
const recipients = only('nobody@arasystem.test');
for (const submissions of [
[],
[submission(null)],
[submission(undefined)],
[submission({ 'nobody@arasystem.test': null })],
]) {
expect(readDeliveryResult(recipients, submissions)).toEqual({
rejected: [],
isEveryoneRejected: false,
});
}
});
it('제출이 여럿이면 한 번이라도 거절되지 않은 사람은 받은 것이다', () => {
const result = readDeliveryResult(only('nobody@arasystem.test'), [
submission({ 'nobody@arasystem.test': REFUSED_RATE_LIMIT }),
{ id: 's2', deliveryStatus: { 'nobody@arasystem.test': QUEUED } },
]);
expect(result).toEqual({ rejected: [], isEveryoneRejected: false });
});
it('받는 사람이 없으면 모두 거절이 아니다 (보낼 수 없는 요청은 엔진이 제출 전에 거절한다)', () => {
expect(
readDeliveryResult({ to: [], cc: [], bcc: [] }, [
submission({ 'nobody@arasystem.test': REFUSED_NOT_FOUND }),
]).isEveryoneRejected,
).toBe(false);
});
});
it('받지 않은 사람이 없는 결과(DELIVERED_TO_ALL)는 여러 요청이 함께 쓰므로 얼려 둔다 — 배열까지', () => {
expect(DELIVERED_TO_ALL).toEqual({
rejected: [],
isEveryoneRejected: false,
});
expect(Object.isFrozen(DELIVERED_TO_ALL)).toBe(true);
expect(Object.isFrozen(DELIVERED_TO_ALL.rejected)).toBe(true);
});
it('응답에는 주소와 까닭만 싣는다 — SMTP 상태 코드는 로그 전용', () => {
expect(
toRejectedRecipients([
{
email: 'nobody@arasystem.test',
reason: 'not-found',
smtpStatus: '550 5.1.2',
},
]),
).toEqual([{ email: 'nobody@arasystem.test', reason: 'not-found' }]);
});
it('모두 거절의 코드 — 5xx 가 하나라도 있으면 MAIL_010, 모두 4xx 면 MAIL_011', () => {
const temporary = {
email: 'busy@arasystem.test',
reason: 'temporary' as const,
smtpStatus: '452 4.4.5',
};
const notFound = {
email: 'nobody@arasystem.test',
reason: 'not-found' as const,
smtpStatus: '550 5.1.2',
};
const refused = {
email: 'blocked@example.com',
reason: 'refused' as const,
smtpStatus: null,
};
expect(everyoneRejectedCode([temporary])).toBe('MAIL_011');
expect(everyoneRejectedCode([temporary, temporary])).toBe('MAIL_011');
expect(everyoneRejectedCode([temporary, notFound])).toBe('MAIL_010');
expect(everyoneRejectedCode([refused])).toBe('MAIL_010');
});
it('로그에는 가린 주소와 SMTP 상태 코드만 — 응답 글은 남기지 않고, 10명을 넘으면 "외 N명"', () => {
const line = describeRejections([
{
email: 'nobody@arasystem.test',
reason: 'not-found',
smtpStatus: '550 5.1.2',
},
{ email: 'odd@example.com', reason: 'refused', smtpStatus: null },
]);
expect(line).toBe(
'받지 않은 사람 2명: n***@arasystem.test(550 5.1.2), o***@example.com(상태 코드 없음)',
);
expect(line).not.toMatch(/nobody|Mailbox/);
const many = Array.from({ length: 12 }, (_, index) => ({
email: `r${index}@arasystem.test`,
reason: 'temporary' as const,
smtpStatus: '452 4.4.5',
}));
const long = describeRejections(many);
expect(long).toMatch(/^받지 않은 사람 12명: /);
expect(long.match(/r\*\*\*@arasystem\.test/g)).toHaveLength(10);
expect(long).toMatch(/ 외 2명$/);
});
});
@@ -0,0 +1,165 @@
import type { ErrorCode } from '@/common/errors/error-codes';
import type {
RecipientFields,
RecipientRejectionReason,
RejectedRecipient,
} from '@/modules/mail/mail.types';
import { maskEmail } from '@/shared/utils/mask';
import type { JmapDeliveryStatus, JmapEmailSubmission } from './jmap.types';
/**
* 받는 사람별 발송 결과 (계약 2절 "받는 사람별 결과") — 순수 함수.
*
* 엔진은 제출(EmailSubmission/set)을 만들면서 받는 사람마다 그 자리에서 SMTP 로 묻고, 결과를 제출 기록의 deliveryStatus 에
* 남긴다(키는 받는 사람 주소). 거절된 사람은 `delivered: "no"` 와 SMTP 응답으로만 남고 엔진이 **다시 보내지도 반송하지도 않는다**
* (Stalwart 0.16.27 — 2026-10-28 확인):
* - 거절: `550 5.1.2 Mailbox does not exist.` (없는 사내 주소) · `452 4.4.5 Rate limit exceeded, try again later.` (엔진 한도)
* - 받음: `{ delivered: "unknown", smtpReply: "250 2.1.5 Queued" }` — 사내 · 바깥 모두 대기열에 들어가면 이 모양이다.
* 바깥에서 나중에 실패하면 지금처럼 반송 메일이 온다
* 판단은 deliveryStatus 로만 한다 — undoStatus 는 모두 거절이면 final, 하나라도 대기열에 들어가면 pending 일 뿐이다.
*/
/** 받지 않은 사람 하나 — 로그에 남길 SMTP 상태 코드를 함께 둔다 (응답에는 email · reason 만 — toRejectedRecipients) */
export interface RecipientRejection extends RejectedRecipient {
/** SMTP 상태 코드만 (`550 5.1.2` 등) — 응답 글은 주소가 들어 있을 수 있어 남기지 않는다. 읽지 못하면 null */
readonly smtpStatus: string | null;
}
/** 한 번 보낸 결과 */
export interface DeliveryResult {
/** 받지 않은 사람 — 요청의 받는 사람 · 참조 · 숨은참조 순서, 같은 주소(대소문자 무시)는 한 번 */
readonly rejected: readonly RecipientRejection[];
/** 받은 사람이 아무도 없다 — 보내지 않은 것으로 다룬다 (MAIL_010 · MAIL_011) */
readonly isEveryoneRejected: boolean;
}
/** 받지 않은 사람이 없다 — 결과를 읽지 못했을 때도 이것으로 본다 (제출은 이미 되었다). 여러 요청이 함께 쓰는 값이라 얼려 둔다 */
export const DELIVERED_TO_ALL: DeliveryResult = Object.freeze({
rejected: Object.freeze([]),
isEveryoneRejected: false,
});
/**
* SMTP 응답 맨 앞의 상태 — 기본 코드(`550`)와 확장 상태(`5.1.2` — RFC 3463), 둘 다 없을 수 있다.
* 여러 줄 응답의 이음표(`550-5.1.1`)도 받는다. 맨 앞만 보므로 응답 글 안의 숫자(판 번호 등)는 읽지 않는다
*/
const SMTP_STATUS =
/^\s*(?:([2-5]\d{2})(?:[ -]+|$))?(?:([245])\.(\d{1,3})\.(\d{1,3})(?=[\s.,;:]|$))?/;
/** 확장 상태의 주제가 "주소"(RFC 3463 의 X.1.Y)면 없는 주소 · 잘못된 주소다 */
const ADDRESS_STATUS_SUBJECT = '1';
/** 로그 한 줄에 적는 받지 않은 사람 수 — 넘으면 "외 N명" */
const LOGGED_REJECTIONS_MAX = 10;
/** 주소를 맞출 때의 열쇠 — 대소문자와 앞뒤 공백을 무시한다 */
function addressKey(email: string): string {
return email.trim().toLowerCase();
}
/**
* 받지 않은 SMTP 응답의 까닭 — 4xx 는 temporary, 확장 상태 5.1.x 는 not-found, 그 밖(5xx · 읽을 수 없는 응답)은 refused.
* 기본 코드가 없으면 확장 상태의 첫 자리로 본다 (`delivered: "no"` 라 어느 쪽이든 이 사람에게는 가지 않는다)
*/
export function classifySmtpReply(reply: string | null | undefined): {
readonly reason: RecipientRejectionReason;
readonly smtpStatus: string | null;
} {
const match = SMTP_STATUS.exec(reply ?? '');
const basic: string | undefined = match?.[1];
const severity: string | undefined = match?.[2];
const enhanced = severity
? `${severity}.${match?.[3]}.${match?.[4]}`
: undefined;
const smtpStatus = [basic, enhanced].filter(Boolean).join(' ') || null;
const kind = basic?.charAt(0) ?? severity;
if (kind === '4') return { reason: 'temporary', smtpStatus };
if (
kind === '5' &&
severity === '5' &&
match?.[3] === ADDRESS_STATUS_SUBJECT
) {
return { reason: 'not-found', smtpStatus };
}
return { reason: 'refused', smtpStatus };
}
/**
* 제출 기록의 결과를 요청의 받는 사람과 맞춘다 — 주소는 대소문자를 무시하고 맞추고, 돌려주는 주소는 요청에 쓴 그대로다.
* `delivered` 가 "no" 가 아니거나 그 사람의 결과가 없으면 받은 것으로 본다.
* 제출이 여럿이면(이 서버는 메일 하나에 하나만 만든다) 한 번이라도 거절되지 않은 사람은 받은 것이다.
*/
export function readDeliveryResult(
recipients: RecipientFields,
submissions: readonly JmapEmailSubmission[],
): DeliveryResult {
const refusals = new Map<string, JmapDeliveryStatus>();
const accepted = new Set<string>();
for (const submission of submissions) {
for (const [address, status] of Object.entries(
submission.deliveryStatus ?? {},
)) {
const key = addressKey(address);
if (status?.delivered !== 'no') {
accepted.add(key);
} else if (!refusals.has(key)) {
refusals.set(key, status);
}
}
}
const seen = new Set<string>();
const rejected: RecipientRejection[] = [];
for (const { email } of [
...recipients.to,
...recipients.cc,
...recipients.bcc,
]) {
const key = addressKey(email);
if (seen.has(key)) continue;
seen.add(key);
const refusal = accepted.has(key) ? undefined : refusals.get(key);
if (refusal) {
rejected.push({ email, ...classifySmtpReply(refusal.smtpReply) });
}
}
return {
rejected,
isEveryoneRejected: seen.size > 0 && rejected.length === seen.size,
};
}
/** 응답에 싣는 모양 (계약의 RejectedRecipient) — SMTP 상태 코드는 로그 전용이라 뺀다 */
export function toRejectedRecipients(
rejected: readonly RecipientRejection[],
): RejectedRecipient[] {
return rejected.map(({ email, reason }) => ({ email, reason }));
}
/** 모두 거절됐을 때의 코드 — 거절 가운데 5xx(없는 주소 · 거절)가 하나라도 있으면 MAIL_010, 모두 4xx 면 MAIL_011 */
export function everyoneRejectedCode(
rejected: readonly RecipientRejection[],
): Extract<ErrorCode, 'MAIL_010' | 'MAIL_011'> {
return rejected.some(({ reason }) => reason !== 'temporary')
? 'MAIL_010'
: 'MAIL_011';
}
/**
* 로그에 남길 받지 않은 사람 — 가린 주소와 SMTP 상태 코드만, LOGGED_REJECTIONS_MAX 명까지
* (예: `받지 않은 사람 2명: n***@arasystem.it.kr(550 5.1.2), b***@example.com(452 4.4.5)`)
*/
export function describeRejections(
rejected: readonly RecipientRejection[],
): string {
const listed = rejected
.slice(0, LOGGED_REJECTIONS_MAX)
.map(
({ email, smtpStatus }) =>
`${maskEmail(email)}(${smtpStatus ?? '상태 코드 없음'})`,
);
const more = rejected.length - listed.length;
return `받지 않은 사람 ${rejected.length}명: ${listed.join(', ')}${more > 0 ? ` 외 ${more}명` : ''}`;
}
@@ -0,0 +1,341 @@
import {
MAIL_FILTER_VERSION_PATTERN,
MAX_MAIL_FILTER_RULES,
} from '@/modules/mail/mail.constants';
import type { MailFilterRule, MailVacation } from '@/modules/mail/mail.types';
import {
FILTER_SOURCE_FORMAT,
FILTER_SOURCE_MARKER,
filterRulesVersion,
filterSourceLine,
readFilterSource,
type FilterScriptSource,
} from './filter-source';
const rule = (
id: string,
extra: Partial<MailFilterRule> = {},
): MailFilterRule => ({
id,
name: '거래처',
isEnabled: true,
match: 'any',
conditions: [
{ field: 'from', operator: 'contains', value: '@hanbit-partners.co.kr' },
{ field: 'subject', operator: 'notContains', value: '"광고" \\ {중괄호}' },
],
actions: {
moveToMailboxId: 'j',
markAsRead: true,
flag: false,
forwardTo: 'doyun.lee@arasystem.test',
},
...extra,
});
const vacation: MailVacation = {
isEnabled: true,
fromDate: '2026-11-02T15:00:00.000Z',
toDate: null,
subject: '부재중 ${x} "따옴표"',
textBody: '첫 줄\r\n둘째 줄\n\t탭',
};
const source: FilterScriptSource = {
rules: [rule('r1'), rule('r2', { isEnabled: false, match: 'all' })],
vacation,
};
/** 원본 줄을 스크립트 머리에 둔 글 — 생성기가 쓰는 모양 (설명 주석 → 원본 → Sieve) */
const scriptWith = (line: string, eol = '\r\n') =>
['# 설명', line, 'require ["imap4flags"];', 'keep;', ''].join(eol);
/** 원본 JSON 을 직접 써서 넣는다 — 깨진 원본 · 다른 메일 프로그램이 고친 원본 */
const scriptWithJson = (json: unknown) =>
scriptWith(`${FILTER_SOURCE_MARKER}${JSON.stringify(json)}`);
/** 이 판의 원본이면 그것, 아니면 null */
const sourceOf = (script: string): FilterScriptSource | null => {
const reading = readFilterSource(script);
return reading.status === 'ok' ? reading.source : null;
};
const BROKEN = { status: 'broken' };
const stored = (overrides: Record<string, unknown> = {}) => ({
v: FILTER_SOURCE_FORMAT,
rules: source.rules,
vacation,
...overrides,
});
describe('filterSourceLine · readFilterSource — 스크립트 머리의 원본(JSON)', () => {
it('써 둔 원본을 그대로 읽는다 — 규칙(꺼 둔 것 포함) · 부재중, 줄바꿈은 CRLF · LF 모두', () => {
for (const eol of ['\r\n', '\n']) {
expect(
readFilterSource(scriptWith(filterSourceLine(source), eol)),
).toEqual({ status: 'ok', source });
}
});
it('원본은 한 줄이다 — 글 안의 줄바꿈 · 탭은 JSON 이스케이프로, 판 번호와 함께', () => {
const line = filterSourceLine(source);
expect(line.startsWith(FILTER_SOURCE_MARKER)).toBe(true);
expect(line).not.toMatch(/[\r\n\t]/);
expect(JSON.parse(line.slice(FILTER_SOURCE_MARKER.length))).toMatchObject({
v: FILTER_SOURCE_FORMAT,
});
});
it('정해진 키 순서로 쓴다 — 들어온 객체의 키 순서 · 다른 속성과 상관없다', () => {
const shuffled = {
actions: {
forwardTo: 'doyun.lee@arasystem.test',
flag: false,
markAsRead: true,
moveToMailboxId: 'j',
extra: 1,
},
conditions: source.rules[0].conditions.map(
({ value, operator, field }) => ({ value, operator, field }),
),
match: 'any',
isEnabled: true,
name: '거래처',
id: 'r1',
unknown: '버린다',
} as unknown as MailFilterRule;
expect(
filterSourceLine({ rules: [shuffled, source.rules[1]], vacation }),
).toBe(filterSourceLine(source));
});
it('원본 줄은 스크립트 맨 앞의 주석들에서만 찾는다 — Sieve 글 뒤에 있으면 없는 것', () => {
const line = filterSourceLine(source);
expect(sourceOf(['# 설명', line, 'keep;'].join('\r\n'))).toEqual(source);
expect(readFilterSource(['keep;', line].join('\r\n'))).toEqual(BROKEN);
expect(readFilterSource(['# 설명', 'keep;', line].join('\r\n'))).toEqual(
BROKEN,
);
expect(readFilterSource('')).toEqual(BROKEN);
expect(readFilterSource('# 설명만')).toEqual(BROKEN);
});
it('부재중 날짜는 읽을 때 ISO 8601 UTC(소수 초 3자리)로 맞춘다', () => {
const read = sourceOf(
scriptWithJson(
stored({
vacation: {
...vacation,
fromDate: '2026-11-02T15:00:00Z',
toDate: '2026-11-07T15:00:00Z',
},
}),
),
);
expect(read?.vacation).toMatchObject({
fromDate: '2026-11-02T15:00:00.000Z',
toDate: '2026-11-07T15:00:00.000Z',
});
});
it('부재중은 모양만 본다 — 엔진의 VacationResponse 에서 가져와 우리 한도보다 긴 글도 깨진 것이 아니다', () => {
const long = {
...vacation,
subject: 'x'.repeat(511),
textBody: 'y'.repeat(2_047),
};
expect(
sourceOf(scriptWithJson(stored({ vacation: long })))?.vacation,
).toEqual(long);
});
it('짝 없는 서로게이트도 쓴 그대로 돌아온다 — 원본 JSON 은 \\ud800 꼴(ASCII), 엔진에 올리는 UTF-8 에서는 Sieve 쪽만 U+FFFD', () => {
const lonely = {
rules: [
rule('s1', {
name: '반쪽\ud800글자',
conditions: [
{ field: 'subject', operator: 'contains', value: 'a\udc00b' },
],
}),
],
vacation: { ...vacation, subject: '부재\ud83d중' },
};
const line = filterSourceLine(lonely);
expect(line).toContain('반쪽\\ud800글자');
expect(line).toContain('a\\udc00b');
// 엔진은 UTF-8 로 받는다 — 원본 줄은 그 왕복에도 그대로다
const uploaded = Buffer.from(scriptWith(line), 'utf8').toString('utf8');
expect(sourceOf(uploaded)).toEqual(lonely);
});
it('새 판(이 서버의 판보다 큰 정수)은 깨짐과 나눈다 — 나머지 모양은 보지 않는다', () => {
expect(
readFilterSource(scriptWithJson(stored({ v: FILTER_SOURCE_FORMAT + 1 }))),
).toEqual({ status: 'newer', format: FILTER_SOURCE_FORMAT + 1 });
expect(
readFilterSource(
scriptWithJson({ v: FILTER_SOURCE_FORMAT + 5, entries: '새 모양' }),
),
).toEqual({ status: 'newer', format: FILTER_SOURCE_FORMAT + 5 });
// 정수가 아니거나 · 판이 아닌 값 · 옛 판(0 이하)은 깨진 원본이다 — 저장하면 덮어쓴다
for (const v of [
FILTER_SOURCE_FORMAT + 0.5,
`${FILTER_SOURCE_FORMAT + 1}`,
0,
-1,
null,
Number.MAX_VALUE,
]) {
expect(readFilterSource(scriptWithJson(stored({ v })))).toEqual(BROKEN);
}
});
describe('깨진 원본은 broken — 부르는 쪽이 빈 규칙 + 동작하지 않음으로 다루고, 저장하면 덮어쓴다', () => {
it.each([
['표지 없음', '# 다른 프로그램이 만든 스크립트\r\nkeep;\r\n'],
['JSON 이 아님', scriptWith(`${FILTER_SOURCE_MARKER}{"v":1,`)],
['판 없음', scriptWithJson(stored({ v: undefined }))],
['규칙이 배열이 아님', scriptWithJson(stored({ rules: {} }))],
['부재중 없음', scriptWithJson(stored({ vacation: undefined }))],
[
'부재중 날짜를 읽을 수 없음',
scriptWithJson(stored({ vacation: { ...vacation, toDate: '내일' } })),
],
[
'부재중 켬이 불리언이 아님',
scriptWithJson(stored({ vacation: { ...vacation, isEnabled: 'yes' } })),
],
])('%s', (_label, script) => {
expect(readFilterSource(script)).toEqual(BROKEN);
});
it.each<[string, (r: MailFilterRule) => unknown]>([
[
'모르는 견줄 곳',
(r) => ({ ...r, conditions: [{ ...r.conditions[0], field: 'cc' }] }),
],
['모르는 묶는 방법', (r) => ({ ...r, match: 'none' })],
['할 일 없음', (r) => ({ ...r, actions: undefined })],
[
'할 일이 하나도 없음',
(r) => ({
...r,
actions: {
moveToMailboxId: null,
markAsRead: false,
flag: false,
forwardTo: null,
},
}),
],
[
'찾을 글에 ${',
(r) => ({ ...r, conditions: [{ ...r.conditions[0], value: '${x}' }] }),
],
[
'찾을 글이 200자 초과',
(r) => ({
...r,
conditions: [{ ...r.conditions[0], value: 'x'.repeat(201) }],
}),
],
[
'찾을 글에 줄바꿈',
(r) => ({ ...r, conditions: [{ ...r.conditions[0], value: 'a\nb' }] }),
],
[
'조건 6개',
(r) => ({
...r,
conditions: Array.from({ length: 6 }, () => r.conditions[0]),
}),
],
['모든 메일인데 조건이 있음', (r) => ({ ...r, match: 'always' })],
['모두인데 조건이 없음', (r) => ({ ...r, match: 'all', conditions: [] })],
[
'전달 주소에 ${',
(r) => ({
...r,
actions: { ...r.actions, forwardTo: '${x}@arasystem.test' },
}),
],
[
'전달 주소 모양이 아님',
(r) => ({ ...r, actions: { ...r.actions, forwardTo: 'a b@c' } }),
],
[
'메일함 id 형식',
(r) => ({ ...r, actions: { ...r.actions, moveToMailboxId: 'a"b' } }),
],
['id 형식', (r) => ({ ...r, id: '규칙 하나' })],
['이름 51자', (r) => ({ ...r, name: 'x'.repeat(51) })],
])('규칙이 계약에 맞지 않음 — %s', (_label, tamper) => {
const rules = [tamper(source.rules[0]), source.rules[1]];
expect(readFilterSource(scriptWithJson(stored({ rules })))).toEqual(
BROKEN,
);
});
it('규칙이 50개를 넘거나 id 가 겹침', () => {
const many = Array.from({ length: MAX_MAIL_FILTER_RULES + 1 }, (_, i) =>
rule(`r${i}`),
);
expect(readFilterSource(scriptWithJson(stored({ rules: many })))).toEqual(
BROKEN,
);
expect(
readFilterSource(
scriptWithJson(stored({ rules: [rule('same'), rule('same')] })),
),
).toEqual(BROKEN);
expect(
sourceOf(scriptWithJson(stored({ rules: many.slice(1) }))),
).not.toBeNull();
});
});
});
describe('filterRulesVersion — 규칙의 지문(version)', () => {
const rules = source.rules;
it('같은 규칙이면 같은 값 — base64url 이라 요청 검증의 형식에 맞는다', () => {
const version = filterRulesVersion(rules);
expect(filterRulesVersion(rules.map((r) => ({ ...r })))).toBe(version);
expect(version).toMatch(MAIL_FILTER_VERSION_PATTERN);
expect(filterRulesVersion([])).toMatch(MAIL_FILTER_VERSION_PATTERN);
});
it('규칙 하나라도 바뀌면 달라진다 — 순서 · 켜기 · 찾을 글 · 할 일 · 이름', () => {
const version = filterRulesVersion(rules);
const changed: MailFilterRule[][] = [
[rules[1], rules[0]],
[{ ...rules[0], isEnabled: false }, rules[1]],
[
{
...rules[0],
conditions: [{ ...rules[0].conditions[0], value: '@other.co.kr' }],
},
rules[1],
],
[{ ...rules[0], actions: { ...rules[0].actions, flag: true } }, rules[1]],
[{ ...rules[0], name: '거래처 메일' }, rules[1]],
[rules[0]],
[],
];
const versions = changed.map(filterRulesVersion);
expect(versions).not.toContain(version);
expect(new Set(versions).size).toBe(versions.length);
});
it('키 순서 · 다른 속성과 상관없다 — 원본 줄과 같은 정해진 모양으로 센다', () => {
const withExtra = rules.map((r) => ({
...r,
extra: '무시',
})) as unknown as MailFilterRule[];
expect(filterRulesVersion(withExtra)).toBe(filterRulesVersion(rules));
});
});
@@ -0,0 +1,294 @@
import { createHash } from 'crypto';
import {
MAIL_FILTER_FIELDS,
MAIL_FILTER_MATCHES,
MAIL_FILTER_OPERATORS,
findFilterRulesProblem,
} from '@/modules/mail/mail-filters';
import { MAX_MAIL_FILTER_RULES } from '@/modules/mail/mail.constants';
import type {
MailFilterActions,
MailFilterCondition,
MailFilterRule,
MailVacation,
} from '@/modules/mail/mail.types';
import { toUtcIso } from './jmap.mapper';
/**
* 엔진 규칙(Sieve 스크립트) 머리의 원본 — 규칙과 부재중 자동 응답을 JSON 한 줄로 주석에 둔다 (계약 2절 "메일 필터"의 저장 자리).
*
* - 읽을 때는 이것만 믿는다 — 엔진의 Sieve 를 다시 해석하지 않는다 (읽은 결과는 FilterSourceReading)
* - 주석(`#`)은 줄 끝(CRLF)까지라 JSON 이 한 줄이어야 한다 — JSON.stringify 는 글 안의 줄바꿈 · 제어 문자를 `\n` · `\u0000` 꼴로 쓴다.
* 짝 없는 서로게이트도 `\ud800` 꼴로 쓴다 — 원본은 쓴 그대로 돌아온다(Sieve 글 쪽은 UTF-8 로 올릴 때 U+FFFD 가 된다).
* 주석 안의 `${` · 따옴표 · 역슬래시 · 한국어는 엔진이 건드리지 않는다 (97,216바이트짜리 주석 한 줄도 validate 통과 — 2026-10-28 확인)
* - 원본 줄은 스크립트 맨 앞의 주석들 가운데에만 둔다 — 그 아래의 Sieve 글은 보지 않는다
*/
/** 원본 줄의 표지 — 이 뒤가 JSON 이다. 판을 올려도 이 표지와 JSON 의 `v` 는 지킨다 (옛 서버가 새 판인 줄 알아야 한다) */
export const FILTER_SOURCE_MARKER = '# arasystem-source: ';
/**
* 원본 모양의 판 — 모양을 바꾸면 올린다.
* 이보다 큰 판(새 판)은 깨진 원본과 나눠 다룬다(FilterSourceReading 의 `newer`): 새 판을 쓰는 서버를 배포했다 되돌리면 옛 서버가 그 원본을 읽는다 —
* 읽기는 빈 규칙 · 동작하지 않음으로 보이고, 쓰기(필터 · 부재중 모두)는 거절한다. 옛 서버가 모르는 규칙을 덮어써 알림 없이 잃지 않게
*/
export const FILTER_SOURCE_FORMAT = 1;
/**
* 스크립트 글에서 원본을 읽은 결과
* - ok: 이 판의 원본
* - broken: 원본이 없거나 깨졌다 — 표지 없음 · JSON 이 아님 · 판이 없음 · 모양이나 규칙이 계약에 어긋남(다른 메일 프로그램이 고친 경우 등).
* 부르는 쪽이 빈 규칙 + 동작하지 않음으로 다루고, 저장하면 덮어쓴다
* - newer: 이 서버가 모르는 새 판 (FILTER_SOURCE_FORMAT 설명) — 읽기는 깨진 것처럼 보이되 쓰지 않는다
*/
export type FilterSourceReading =
| { readonly status: 'ok'; readonly source: FilterScriptSource }
| { readonly status: 'broken' }
| { readonly status: 'newer'; readonly format: number };
/** 엔진 규칙 하나에 함께 두는 것 — 필터 규칙(차례대로)과 부재중 자동 응답 */
export interface FilterScriptSource {
readonly rules: readonly MailFilterRule[];
readonly vacation: MailVacation;
}
/** 한 번도 설정하지 않은 부재중 자동 응답 (계약 2절 "부재중 자동 응답") */
export const NEVER_CONFIGURED_VACATION: MailVacation = {
isEnabled: false,
fromDate: null,
toDate: null,
subject: '',
textBody: '',
};
/** 원본을 읽지 못할 때(깨짐 · 새 판) 보이는 것 — 규칙 없음 · 부재중 꺼짐 (깨진 원본은 저장하면 덮어쓴다 · 새 판은 쓰지 않는다) */
export const EMPTY_FILTER_SOURCE: FilterScriptSource = {
rules: [],
vacation: NEVER_CONFIGURED_VACATION,
};
/** 정해진 키 순서의 사본 — 들어온 객체(DTO 등)의 키 순서 · 다른 속성과 상관없이 같은 규칙이면 같은 JSON 이 된다 */
function canonicalRule(rule: MailFilterRule): MailFilterRule {
const { moveToMailboxId, markAsRead, flag, forwardTo } = rule.actions;
return {
id: rule.id,
name: rule.name,
isEnabled: rule.isEnabled,
match: rule.match,
conditions: rule.conditions.map(({ field, operator, value }) => ({
field,
operator,
value,
})),
actions: { moveToMailboxId, markAsRead, flag, forwardTo },
};
}
function canonicalVacation(vacation: MailVacation): MailVacation {
return {
isEnabled: vacation.isEnabled,
fromDate: vacation.fromDate,
toDate: vacation.toDate,
subject: vacation.subject,
textBody: vacation.textBody,
};
}
/** 같은 규칙이면 같은 사본 목록 — 저장 · 지문 · 응답이 이것을 쓴다 */
export function canonicalRules(
rules: readonly MailFilterRule[],
): MailFilterRule[] {
return rules.map(canonicalRule);
}
/**
* 규칙의 지문(계약의 version) — 정해진 키 순서의 JSON 을 SHA-256 으로, base64url.
* 규칙 부분만 본다 — 부재중 응답만 바뀐 것은 지문을 바꾸지 않는다. 직원의 글에서 나온 값이라 로그에 남기지 않는다
*/
export function filterRulesVersion(rules: readonly MailFilterRule[]): string {
return createHash('sha256')
.update(JSON.stringify(canonicalRules(rules)), 'utf8')
.digest('base64url');
}
/** 원본 줄 — 표지 + JSON 한 줄 (판 · 규칙 · 부재중) */
export function filterSourceLine(source: FilterScriptSource): string {
return `${FILTER_SOURCE_MARKER}${JSON.stringify({
v: FILTER_SOURCE_FORMAT,
rules: canonicalRules(source.rules),
vacation: canonicalVacation(source.vacation),
})}`;
}
/** 스크립트 맨 앞의 주석들에서 원본 줄을 찾는다 — 주석이 아닌 줄이 나오면 멈춘다 */
function findSourceLine(script: string): string | null {
let start = 0;
while (start < script.length) {
const end = script.indexOf('\n', start);
const line = script
.slice(start, end === -1 ? script.length : end)
.replace(/\r$/, '');
if (!line.startsWith('#')) return null;
if (line.startsWith(FILTER_SOURCE_MARKER)) return line;
if (end === -1) return null;
start = end + 1;
}
return null;
}
type JsonRecord = Record<string, unknown>;
function isRecord(value: unknown): value is JsonRecord {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
function isOneOf<T extends string>(
value: unknown,
allowed: readonly T[],
): value is T {
return (
typeof value === 'string' && (allowed as readonly string[]).includes(value)
);
}
const isNullableString = (value: unknown): value is string | null =>
value === null || typeof value === 'string';
function toCondition(raw: unknown): MailFilterCondition | null {
if (
!isRecord(raw) ||
!isOneOf(raw.field, MAIL_FILTER_FIELDS) ||
!isOneOf(raw.operator, MAIL_FILTER_OPERATORS) ||
typeof raw.value !== 'string'
) {
return null;
}
return { field: raw.field, operator: raw.operator, value: raw.value };
}
function toActions(raw: unknown): MailFilterActions | null {
if (
!isRecord(raw) ||
!isNullableString(raw.moveToMailboxId) ||
typeof raw.markAsRead !== 'boolean' ||
typeof raw.flag !== 'boolean' ||
!isNullableString(raw.forwardTo)
) {
return null;
}
return {
moveToMailboxId: raw.moveToMailboxId,
markAsRead: raw.markAsRead,
flag: raw.flag,
forwardTo: raw.forwardTo,
};
}
function toRule(raw: unknown): MailFilterRule | null {
if (
!isRecord(raw) ||
typeof raw.id !== 'string' ||
typeof raw.name !== 'string' ||
typeof raw.isEnabled !== 'boolean' ||
!isOneOf(raw.match, MAIL_FILTER_MATCHES) ||
!Array.isArray(raw.conditions)
) {
return null;
}
const conditions: MailFilterCondition[] = [];
for (const entry of raw.conditions) {
const condition = toCondition(entry);
if (!condition) return null;
conditions.push(condition);
}
const actions = toActions(raw.actions);
if (!actions) return null;
return {
id: raw.id,
name: raw.name,
isEnabled: raw.isEnabled,
match: raw.match,
conditions,
actions,
};
}
/** 날짜 — null 이면 null, 읽을 수 있는 시각이면 ISO 8601 UTC(소수 초 3자리), 아니면 undefined(깨짐) */
function readDate(raw: unknown): string | null | undefined {
if (raw === null) return null;
if (typeof raw !== 'string') return undefined;
return toUtcIso(raw) ?? undefined;
}
/**
* 부재중 — 모양만 본다. 길이 한도는 보지 않는다: 저장해 둔 값은 4단계 요청 검증 · 엔진의 VacationResponse 한도 안에서 온 것이고
* (VacationResponse 에서 가져온 값은 우리 한도보다 길 수 있다), Sieve 에는 base64 로 들어가 글이 문법을 깨지 않는다
*/
function toVacation(raw: unknown): MailVacation | null {
if (
!isRecord(raw) ||
typeof raw.isEnabled !== 'boolean' ||
typeof raw.subject !== 'string' ||
typeof raw.textBody !== 'string'
) {
return null;
}
const fromDate = readDate(raw.fromDate);
const toDate = readDate(raw.toDate);
if (fromDate === undefined || toDate === undefined) return null;
return {
isEnabled: raw.isEnabled,
fromDate,
toDate,
subject: raw.subject,
textBody: raw.textBody,
};
}
const BROKEN: FilterSourceReading = { status: 'broken' };
/**
* 스크립트 글에서 원본을 읽는다 (FilterSourceReading).
* 판(`v`)이 이 서버의 판보다 큰 정수면 나머지를 보지 않고 newer 다 — 모양이 달라졌을 수 있다.
* 같은 판이면 모양과 규칙을 계약대로 다시 본다(mail-filters.ts — 50개 · 겹친 id · 조건 수 · `${` 등) — 어긋나면 broken.
* 다시 보는 까닭: 다른 메일 프로그램(ManageSieve 등)이 고친 원본으로 Sieve 를 다시 만들면 엔진 한도 · 변수 펼침에 걸릴 수 있다
*/
export function readFilterSource(script: string): FilterSourceReading {
const line = findSourceLine(script);
if (line === null) return BROKEN;
let parsed: unknown;
try {
parsed = JSON.parse(line.slice(FILTER_SOURCE_MARKER.length));
} catch {
return BROKEN;
}
if (!isRecord(parsed)) return BROKEN;
const format = parsed.v;
if (
typeof format === 'number' &&
Number.isSafeInteger(format) &&
format > FILTER_SOURCE_FORMAT
) {
return { status: 'newer', format };
}
if (
format !== FILTER_SOURCE_FORMAT ||
!Array.isArray(parsed.rules) ||
parsed.rules.length > MAX_MAIL_FILTER_RULES
) {
return BROKEN;
}
const rules: MailFilterRule[] = [];
for (const entry of parsed.rules) {
const rule = toRule(entry);
if (!rule) return BROKEN;
rules.push(rule);
}
if (findFilterRulesProblem(rules)) return BROKEN;
const vacation = toVacation(parsed.vacation);
return vacation ? { status: 'ok', source: { rules, vacation } } : BROKEN;
}
@@ -0,0 +1,983 @@
import { Logger } from '@nestjs/common';
import { BusinessException } from '@/common/errors/business.exception';
import type { MailFilterRule, MailVacation } from '@/modules/mail/mail.types';
import {
NEVER_CONFIGURED_VACATION,
filterRulesVersion,
readFilterSource,
type FilterScriptSource,
} from './filter-source';
import type { JmapClient } from './jmap.client';
import {
FILTER_SCRIPT_NAME,
JmapFilterScriptStore,
scriptErrorPosition,
} from './jmap-filter-script';
import type {
JmapError,
JmapInvocation,
JmapVacationResponse,
} from './jmap.types';
import { buildFilterScript } from './sieve-script';
const ARA = 'ara.kim@arasystem.test';
const SIEVE = 'urn:ietf:params:jmap:sieve';
const VACATION = 'urn:ietf:params:jmap:vacationresponse';
const rule = (
id: string,
extra: Partial<MailFilterRule> = {},
): MailFilterRule => ({
id,
name: '거래처',
isEnabled: true,
match: 'any',
conditions: [
{ field: 'from', operator: 'contains', value: '@hanbit-partners.co.kr' },
],
actions: {
moveToMailboxId: 'j',
markAsRead: true,
flag: false,
forwardTo: null,
},
...extra,
});
const vacationOn: MailVacation = {
isEnabled: true,
fromDate: '2026-11-02T15:00:00.000Z',
toDate: '2026-11-07T15:00:00.000Z',
subject: '부재중입니다',
textBody: '11월 7일까지 휴가입니다.',
};
interface FakeScript {
id: string;
name: string;
isActive: boolean;
blobId: string;
}
/** 엔진이 받은 JMAP 요청 하나 */
interface RecordedCall {
readonly calls: JmapInvocation[];
readonly options: unknown;
}
/**
* 대역 엔진 — Sieve 스크립트 · blob · 상태(ifInState)만 흉내 낸다 (RFC 9661 · Stalwart 0.16.27 에서 확인한 모양).
* 계정마다 켜진 스크립트는 하나다 — 켜면 다른 것은 꺼진다.
*/
class FakeSieveEngine {
state = 1;
scripts: FakeScript[] = [];
readonly blobs = new Map<string, string>();
vacation: JmapVacationResponse | null = null;
readonly recorded: RecordedCall[] = [];
/** 다음 SieveScript/set 을 처리하기 전에 한 번씩 부른다 — 그사이 다른 곳의 변경을 흉내 낸다 */
readonly beforeSet: (() => void)[] = [];
/** 다음 SieveScript/set 의 응답을 대신한다 (null 이면 평소대로) */
readonly setReplies: ((
args: Record<string, unknown>,
) => JmapInvocation | null)[] = [];
/** 다음 SieveScript/get 이 상태를 알려 주지 않는다 */
hideState = false;
/** 다음 내려받기를 이만큼 404 로 */
missingDownloads = 0;
/** 저장 뒤 같은 요청의 SieveScript/get 을 메서드 오류로 */
failRereadAfterSet = false;
private sequence = 0;
readonly client = {
call: jest.fn(
(
_user: string,
build: (accountId: string) => JmapInvocation[],
options?: unknown,
) => {
const calls = build('d');
this.recorded.push({ calls, options });
const afterSet = calls.some(([method]) => method === 'SieveScript/set');
return Promise.resolve(
calls.map((call) => this.handle(call, afterSet)),
);
},
),
upload: jest.fn(async (_user: string, content: Blob, type: string) => {
const text = await content.text();
const blobId = this.nextId('blob');
this.blobs.set(blobId, text);
return { blobId, type, size: content.size };
}),
downloadBytes: jest.fn(
(_user: string, blobId: string, maxBytes: number) => {
const text = this.blobs.get(blobId);
if (this.missingDownloads > 0 || text === undefined) {
this.missingDownloads = Math.max(0, this.missingDownloads - 1);
return Promise.reject(
new BusinessException('RES_001', {
context: { reason: 'blob-not-found' },
}),
);
}
const bytes = Buffer.from(text, 'utf8');
return Promise.resolve(bytes.length > maxBytes ? null : bytes);
},
),
};
readonly store = new JmapFilterScriptStore(
this.client as unknown as JmapClient,
);
/** 스크립트를 하나 둔다 (다른 메일 프로그램 · 앞선 저장이 만든 것) */
addScript(name: string, text: string, isActive: boolean): FakeScript {
const blobId = this.nextId('blob');
this.blobs.set(blobId, text);
if (isActive) for (const script of this.scripts) script.isActive = false;
const script = { id: this.nextId('sc'), name, isActive, blobId };
this.scripts.push(script);
this.state += 1;
return script;
}
/** 우리 스크립트를 이 원본으로 둔다 */
addOurs(source: FilterScriptSource, isActive = true): FakeScript {
return this.addScript(
FILTER_SCRIPT_NAME,
buildFilterScript(source).text,
isActive,
);
}
/** 우리 스크립트의 지금 원본 */
ourSource(): FilterScriptSource | null {
const ours = this.scripts.find(
(script) => script.name === FILTER_SCRIPT_NAME,
);
if (!ours) return null;
const reading = readFilterSource(this.blobs.get(ours.blobId) ?? '');
return reading.status === 'ok' ? reading.source : null;
}
ours(): FakeScript | undefined {
return this.scripts.find((script) => script.name === FILTER_SCRIPT_NAME);
}
/** 엔진이 받은 SieveScript/set 인자 */
setCalls(): Record<string, unknown>[] {
return this.recorded.flatMap(({ calls }) =>
calls
.filter(([method]) => method === 'SieveScript/set')
.map(([, args]) => args),
);
}
private nextId(prefix: string): string {
this.sequence += 1;
return `${prefix}${this.sequence}`;
}
private handle(
[method, args, callId]: JmapInvocation,
afterSet: boolean,
): JmapInvocation {
switch (method) {
case 'SieveScript/get': {
if (afterSet && this.failRereadAfterSet) {
return ['error', { type: 'serverFail' }, callId];
}
const state = this.hideState ? undefined : `s${this.state}`;
this.hideState = false;
return [
method,
{ state, list: this.scripts.map((script) => ({ ...script })) },
callId,
];
}
case 'VacationResponse/get':
return [
method,
{ list: this.vacation ? [{ ...this.vacation }] : [] },
callId,
];
case 'SieveScript/set':
return this.set(args, callId);
default:
return ['error', { type: 'unknownMethod' }, callId];
}
}
private set(args: Record<string, unknown>, callId: string): JmapInvocation {
this.beforeSet.shift()?.();
const replaced = this.setReplies.shift()?.(args);
if (replaced) return [replaced[0], replaced[1], callId];
if (args.ifInState !== `s${this.state}`) {
return ['error', { type: 'stateMismatch' }, callId];
}
const created: Record<string, { id: string }> = {};
const updated: Record<string, null> = {};
const notCreated: Record<string, JmapError> = {};
const notUpdated: Record<string, JmapError> = {};
const create = (args.create ?? {}) as Record<
string,
{ name: string; blobId: string }
>;
for (const [createId, { name, blobId }] of Object.entries(create)) {
const existing = this.scripts.find((script) => script.name === name);
if (existing) {
notCreated[createId] = { type: 'alreadyExists' };
continue;
}
const id = this.nextId('sc');
this.scripts.push({ id, name, isActive: false, blobId });
created[createId] = { id };
}
const update = (args.update ?? {}) as Record<string, { blobId: string }>;
for (const [id, { blobId }] of Object.entries(update)) {
const script = this.scripts.find((candidate) => candidate.id === id);
if (!script) {
notUpdated[id] = { type: 'notFound' };
continue;
}
script.blobId = blobId;
updated[id] = null;
}
const failed =
Object.keys(notCreated).length > 0 || Object.keys(notUpdated).length > 0;
const activate = args.onSuccessActivateScript;
if (!failed && typeof activate === 'string') {
const target = activate.startsWith('#')
? created[activate.slice(1)]?.id
: activate;
for (const script of this.scripts) script.isActive = script.id === target;
}
const oldState = `s${this.state}`;
if (!failed) this.state += 1;
return [
'SieveScript/set',
{
oldState,
newState: `s${this.state}`,
...(Object.keys(created).length ? { created } : {}),
...(Object.keys(updated).length ? { updated } : {}),
...(Object.keys(notCreated).length ? { notCreated } : {}),
...(Object.keys(notUpdated).length ? { notUpdated } : {}),
},
callId,
];
}
}
async function failureOf(
promise: Promise<unknown>,
): Promise<{ code?: string; context?: Record<string, unknown> }> {
try {
await promise;
} catch (error) {
return error instanceof BusinessException
? { code: error.code, context: error.context }
: { code: 'NOT_BUSINESS' };
}
return {};
}
describe('JmapFilterScriptStore — 필터 · 부재중 자동 응답의 저장 자리', () => {
// 저장할 때마다 남기는 진단 로그(debug)는 이 테스트의 관심이 아니다
beforeEach(() => {
jest.spyOn(Logger.prototype, 'debug').mockImplementation(() => undefined);
});
afterEach(() => jest.restoreAllMocks());
describe('읽기 (getFilters · getVacation)', () => {
it('우리 스크립트가 없으면 규칙 없음 · 동작함 — 부재중은 엔진의 VacationResponse (한 요청으로 함께 묻는다)', async () => {
const engine = new FakeSieveEngine();
engine.addScript('vacation', 'require ["vacation"];\r\n', true);
engine.vacation = {
id: 'singleton',
isEnabled: true,
fromDate: '2026-11-02T15:00:00Z',
toDate: null,
subject: '4단계 부재중',
textBody: '휴가입니다.',
htmlBody: null,
};
await expect(engine.store.getFilters(ARA)).resolves.toEqual({
rules: [],
version: filterRulesVersion([]),
isActive: true,
});
await expect(engine.store.getVacation(ARA)).resolves.toEqual({
isEnabled: true,
fromDate: '2026-11-02T15:00:00.000Z',
toDate: null,
subject: '4단계 부재중',
textBody: '휴가입니다.',
});
expect(engine.recorded[0]).toEqual({
calls: [
[
'SieveScript/get',
{
accountId: 'd',
ids: null,
properties: ['name', 'isActive', 'blobId'],
},
'g',
],
['VacationResponse/get', { accountId: 'd', ids: ['singleton'] }, 'v'],
],
options: { capabilities: [SIEVE, VACATION] },
});
expect(engine.client.downloadBytes).not.toHaveBeenCalled();
});
it('한 번도 설정하지 않은 계정은 끈 부재중 · 빈 제목과 내용', async () => {
const engine = new FakeSieveEngine();
await expect(engine.store.getVacation(ARA)).resolves.toEqual(
NEVER_CONFIGURED_VACATION,
);
});
it('VacationResponse 의 null 제목 · 내용은 빈 문자열로 이어받는다 — 처음 저장하면 그 값이 우리 스크립트에 들어간다', async () => {
const engine = new FakeSieveEngine();
engine.vacation = {
id: 'singleton',
isEnabled: false,
fromDate: null,
toDate: '2026-11-07T15:00:00Z',
subject: null,
textBody: null,
htmlBody: null,
};
const imported = {
isEnabled: false,
fromDate: null,
toDate: '2026-11-07T15:00:00.000Z',
subject: '',
textBody: '',
};
await expect(engine.store.getVacation(ARA)).resolves.toEqual(imported);
await engine.store.setFilters(ARA, {
rules: [rule('r1')],
version: filterRulesVersion([]),
});
expect(engine.ourSource()?.vacation).toEqual(imported);
});
it('우리 스크립트가 켜져 있으면 원본의 규칙 · 부재중 — 글은 엔진 한도(102,400바이트)까지만 내려받는다', async () => {
const engine = new FakeSieveEngine();
const rules = [rule('r1'), rule('r2', { isEnabled: false })];
const ours = engine.addOurs({ rules, vacation: vacationOn });
await expect(engine.store.getFilters(ARA)).resolves.toEqual({
rules,
version: filterRulesVersion(rules),
isActive: true,
});
await expect(engine.store.getVacation(ARA)).resolves.toEqual(vacationOn);
expect(engine.client.downloadBytes).toHaveBeenCalledWith(
ARA,
ours.blobId,
102_400,
);
});
it('엔진에서 다른 스크립트가 켜져 있으면 isActive false — 규칙 · 부재중은 원본 그대로', async () => {
const engine = new FakeSieveEngine();
const rules = [rule('r1')];
engine.addOurs({ rules, vacation: vacationOn });
engine.addScript('vacation', 'require ["vacation"];\r\n', true);
await expect(engine.store.getFilters(ARA)).resolves.toEqual({
rules,
version: filterRulesVersion(rules),
isActive: false,
});
await expect(engine.store.getVacation(ARA)).resolves.toEqual(vacationOn);
});
it('원본이 깨졌으면 규칙 없음 · 부재중 꺼짐 · isActive false — 켜져 있어도, 글이 너무 커도', async () => {
for (const text of [
'# 다른 메일 프로그램이 고친 스크립트\r\nkeep;\r\n',
`# arasystem-source: {"v":1,"rules":[${'x'.repeat(103_000)}]}\r\n`,
]) {
const engine = new FakeSieveEngine();
engine.addScript(FILTER_SCRIPT_NAME, text, true);
engine.vacation = {
id: 'singleton',
isEnabled: true,
subject: '안 가져온다',
textBody: '스크립트가 있으면 VacationResponse 는 보지 않는다',
};
await expect(engine.store.getFilters(ARA)).resolves.toEqual({
rules: [],
version: filterRulesVersion([]),
isActive: false,
});
await expect(engine.store.getVacation(ARA)).resolves.toEqual(
NEVER_CONFIGURED_VACATION,
);
}
});
it('내려받는 사이 글이 없어졌으면(404 — 그사이 바뀜) 다시 묻는다 — 세 번 모두면 MAIL_001', async () => {
const engine = new FakeSieveEngine();
engine.addOurs({ rules: [rule('r1')], vacation: vacationOn });
engine.missingDownloads = 2;
await expect(engine.store.getFilters(ARA)).resolves.toMatchObject({
isActive: true,
});
expect(engine.recorded).toHaveLength(3);
engine.missingDownloads = 3;
expect(await failureOf(engine.store.getFilters(ARA))).toMatchObject({
code: 'MAIL_001',
context: { reason: 'filter-script-unreadable' },
});
});
});
describe('새 판의 원본 (이 서버가 모르는 판 — 새 판을 쓰는 서버를 배포했다 되돌린 경우)', () => {
const newer = `# 설명\r\n# arasystem-source: ${JSON.stringify({ v: 99, entries: [{ id: 'r1', name: '새 판의 규칙' }] })}\r\nkeep;\r\n`;
it('읽기는 빈 규칙 · 부재중 꺼짐 · 동작하지 않음으로 보인다', async () => {
const engine = new FakeSieveEngine();
engine.addScript(FILTER_SCRIPT_NAME, newer, true);
await expect(engine.store.getFilters(ARA)).resolves.toEqual({
rules: [],
version: filterRulesVersion([]),
isActive: false,
});
await expect(engine.store.getVacation(ARA)).resolves.toEqual(
NEVER_CONFIGURED_VACATION,
);
});
it('필터 저장 · 부재중 저장 모두 쓰지 않는다 — 503 MAIL_001(filter-source-newer), 글은 그대로 남는다', async () => {
const engine = new FakeSieveEngine();
const ours = engine.addScript(FILTER_SCRIPT_NAME, newer, true);
for (const save of [
() =>
engine.store.setFilters(ARA, {
rules: [rule('r1')],
version: filterRulesVersion([]),
}),
() => engine.store.setVacation(ARA, vacationOn),
]) {
expect(await failureOf(save())).toEqual({
code: 'MAIL_001',
context: {
reason: 'filter-source-newer',
format: 99,
supportedFormat: 1,
},
});
}
expect(engine.client.upload).not.toHaveBeenCalled();
expect(engine.setCalls()).toEqual([]);
expect(engine.blobs.get(ours.blobId)).toBe(newer);
});
it('같은 판의 깨진 원본은 지금처럼 저장하면 덮어쓴다', async () => {
const engine = new FakeSieveEngine();
engine.addScript(
FILTER_SCRIPT_NAME,
'# arasystem-source: {"v":1,"rules":"깨짐"}\r\nkeep;\r\n',
true,
);
await engine.store.setVacation(ARA, vacationOn);
expect(engine.ourSource()).toEqual({ rules: [], vacation: vacationOn });
});
});
describe('필터 저장 (setFilters)', () => {
it('처음 저장 — 읽은 상태 그대로일 때만 만들고 켠다. 엔진의 VacationResponse 를 이어받아 함께 둔다', async () => {
const engine = new FakeSieveEngine();
engine.addScript('vacation', 'require ["vacation"];\r\n', true);
engine.vacation = {
id: 'singleton',
isEnabled: true,
fromDate: '2026-11-02T15:00:00Z',
toDate: '2026-11-07T15:00:00Z',
subject: '부재중입니다',
textBody: '11월 7일까지 휴가입니다.',
};
const rules = [rule('r1')];
const state = `s${engine.state}`;
await expect(
engine.store.setFilters(ARA, {
rules,
version: filterRulesVersion([]),
}),
).resolves.toEqual({
rules,
version: filterRulesVersion(rules),
isActive: true,
});
const expectedSource = { rules, vacation: vacationOn };
expect(engine.client.upload).toHaveBeenCalledTimes(1);
const [, blob, type] = engine.client.upload.mock.calls[0];
expect(type).toBe('application/sieve');
expect(await blob.text()).toBe(buildFilterScript(expectedSource).text);
expect(engine.setCalls()).toEqual([
{
accountId: 'd',
ifInState: state,
create: {
script: {
name: FILTER_SCRIPT_NAME,
blobId: engine.ours()?.blobId,
},
},
onSuccessActivateScript: '#script',
},
]);
expect(engine.recorded.at(-1)?.options).toEqual({
capabilities: [SIEVE],
});
expect(engine.ourSource()).toEqual(expectedSource);
// 켜진 스크립트는 하나 — 엔진의 vacation 스크립트는 꺼졌다(지우지 않는다)
expect(
engine.scripts.map(({ name, isActive }) => ({ name, isActive })),
).toEqual([
{ name: 'vacation', isActive: false },
{ name: FILTER_SCRIPT_NAME, isActive: true },
]);
});
it('있으면 고친다 — 부재중은 다시 읽은 원본 그대로, 꺼져 있었으면 다시 켠다', async () => {
const engine = new FakeSieveEngine();
const before = [rule('r1')];
const ours = engine.addOurs(
{ rules: before, vacation: vacationOn },
false,
);
engine.addScript('other', 'keep;\r\n', true);
const rules = [rule('r2', { name: '새 규칙' }), rule('r1')];
await expect(
engine.store.setFilters(ARA, {
rules,
version: filterRulesVersion(before),
}),
).resolves.toEqual({
rules,
version: filterRulesVersion(rules),
isActive: true,
});
expect(engine.setCalls()).toEqual([
{
accountId: 'd',
ifInState: expect.any(String) as string,
update: { [ours.id]: { blobId: engine.ours()?.blobId } },
onSuccessActivateScript: ours.id,
},
]);
expect(engine.ourSource()).toEqual({ rules, vacation: vacationOn });
expect(engine.ours()?.isActive).toBe(true);
});
it('그사이 다른 곳에서 규칙이 바뀌었으면(지문이 다름) MAIL_012 — 올리지도 쓰지도 않는다', async () => {
const engine = new FakeSieveEngine();
engine.addOurs({ rules: [rule('r1')], vacation: vacationOn });
expect(
await failureOf(
engine.store.setFilters(ARA, {
rules: [rule('mine')],
version: filterRulesVersion([]),
}),
),
).toEqual({
code: 'MAIL_012',
context: { reason: 'filter-rules-changed', rules: 1, storedRules: 1 },
});
expect(engine.client.upload).not.toHaveBeenCalled();
expect(engine.setCalls()).toEqual([]);
});
it('부재중 응답만 바뀐 것은 걸리지 않는다 — 다른 곳에서 바꾼 부재중을 그대로 둔다', async () => {
const engine = new FakeSieveEngine();
const before = [rule('r1')];
engine.addOurs({ rules: before, vacation: NEVER_CONFIGURED_VACATION });
const version = (await engine.store.getFilters(ARA)).version;
await engine.store.setVacation(ARA, vacationOn);
const rules = [rule('r1', { isEnabled: false })];
await expect(
engine.store.setFilters(ARA, { rules, version }),
).resolves.toMatchObject({ rules });
expect(engine.ourSource()).toEqual({ rules, vacation: vacationOn });
});
it('저장해 둔 규칙이 이미 바꾸려는 규칙과 같으면 옛 지문이어도 그대로 저장한다 — 같은 요청을 다시 보낸 경우', async () => {
const engine = new FakeSieveEngine();
const rules = [rule('r1')];
engine.addOurs({ rules, vacation: vacationOn }, false);
engine.addScript('other', 'keep;\r\n', true);
await expect(
engine.store.setFilters(ARA, {
rules,
version: filterRulesVersion([]),
}),
).resolves.toEqual({
rules,
version: filterRulesVersion(rules),
isActive: true,
});
});
it('쓰는 사이 상태가 바뀌면(stateMismatch) 다시 읽어 확인하고 쓴다 — 그사이 바뀐 부재중을 지킨다', async () => {
const engine = new FakeSieveEngine();
const before = [rule('r1')];
engine.addOurs({ rules: before, vacation: NEVER_CONFIGURED_VACATION });
// 다른 탭이 부재중을 켰다
engine.beforeSet.push(() => {
const ours = engine.ours()!;
const blobId = 'other-tab';
engine.blobs.set(
blobId,
buildFilterScript({ rules: before, vacation: vacationOn }).text,
);
ours.blobId = blobId;
engine.state += 1;
});
const rules = [rule('r1', { name: '이름 바꿈' })];
await expect(
engine.store.setFilters(ARA, {
rules,
version: filterRulesVersion(before),
}),
).resolves.toMatchObject({ rules, isActive: true });
expect(engine.setCalls()).toHaveLength(2);
expect(engine.client.upload).toHaveBeenCalledTimes(2);
expect(engine.ourSource()).toEqual({ rules, vacation: vacationOn });
});
it('다시 읽은 글이 같으면 올린 것을 다시 쓴다 — 엔진에 같은 글을 두 번 올리지 않는다', async () => {
const engine = new FakeSieveEngine();
engine.addOurs({ rules: [], vacation: vacationOn });
engine.beforeSet.push(() => {
engine.state += 1;
});
await engine.store.setFilters(ARA, {
rules: [rule('r1')],
version: filterRulesVersion([]),
});
expect(engine.setCalls()).toHaveLength(2);
expect(engine.client.upload).toHaveBeenCalledTimes(1);
});
it('다시 읽었더니 다른 곳에서 규칙을 저장했으면 MAIL_012', async () => {
const engine = new FakeSieveEngine();
engine.addOurs({ rules: [], vacation: vacationOn });
engine.beforeSet.push(() => {
const ours = engine.ours()!;
engine.blobs.set(
'other-tab',
buildFilterScript({ rules: [rule('theirs')], vacation: vacationOn })
.text,
);
ours.blobId = 'other-tab';
engine.state += 1;
});
expect(
(
await failureOf(
engine.store.setFilters(ARA, {
rules: [rule('mine')],
version: filterRulesVersion([]),
}),
)
).code,
).toBe('MAIL_012');
expect(engine.ourSource()?.rules.map(({ id }) => id)).toEqual(['theirs']);
});
it('처음 쓰기가 겹치면(alreadyExists) 다시 읽어 그 스크립트를 고친다', async () => {
const engine = new FakeSieveEngine();
// 상태 확인을 지나 만들기에서 겹쳤다 — 다른 요청이 같은 이름으로 먼저 만들었다
engine.setReplies.push(() => {
engine.addOurs({ rules: [], vacation: vacationOn }, true);
return [
'SieveScript/set',
{ notCreated: { script: { type: 'alreadyExists' } } },
's',
];
});
const rules = [rule('r1')];
await expect(
engine.store.setFilters(ARA, {
rules,
version: filterRulesVersion([]),
}),
).resolves.toMatchObject({ rules, isActive: true });
expect(engine.setCalls()[1]).toMatchObject({
update: { [engine.ours()!.id]: expect.any(Object) as object },
});
expect(engine.ourSource()).toEqual({ rules, vacation: vacationOn });
});
it('세 번 모두 어긋나면 MAIL_001 — 쓰지 않았다', async () => {
const engine = new FakeSieveEngine();
for (let i = 0; i < 3; i += 1) {
engine.beforeSet.push(() => {
engine.state += 1;
});
}
expect(
await failureOf(
engine.store.setFilters(ARA, {
rules: [rule('r1')],
version: filterRulesVersion([]),
}),
),
).toMatchObject({
code: 'MAIL_001',
context: { reason: 'filter-script-contended', attempts: 3 },
});
expect(engine.setCalls()).toHaveLength(3);
expect(engine.ours()).toBeUndefined();
});
it('엔진이 상태를 알려 주지 않으면 조건 없이 쓰지 않는다 — MAIL_001', async () => {
const engine = new FakeSieveEngine();
engine.hideState = true;
expect(
await failureOf(
engine.store.setFilters(ARA, {
rules: [rule('r1')],
version: filterRulesVersion([]),
}),
),
).toMatchObject({
code: 'MAIL_001',
context: { reason: 'filter-script-state-unknown' },
});
expect(engine.client.upload).not.toHaveBeenCalled();
});
it('만든 스크립트가 90,000바이트를 넘으면 MAIL_013 — 올리지 않는다. 로그에는 크기 · 규칙 수만 (글 없이)', async () => {
const engine = new FakeSieveEngine();
const value = '가'.repeat(200);
const rules = Array.from({ length: 50 }, (_, index) =>
rule(`r${index}`, {
match: 'all',
conditions: Array.from({ length: 5 }, () => ({
field: 'subject' as const,
operator: 'contains' as const,
value,
})),
}),
);
const failure = await failureOf(
engine.store.setFilters(ARA, {
rules,
version: filterRulesVersion([]),
}),
);
expect(failure).toMatchObject({
code: 'MAIL_013',
context: {
reason: 'filter-script-too-large',
rules: 50,
enabledRules: 50,
},
});
expect(failure.context?.bytes).toBeGreaterThan(90_000);
expect(JSON.stringify(failure.context)).not.toContain(value);
expect(engine.client.upload).not.toHaveBeenCalled();
});
it('엔진이 한도로 거절하면(tooLarge) MAIL_013', async () => {
const engine = new FakeSieveEngine();
engine.setReplies.push(() => [
'SieveScript/set',
{ notCreated: { script: { type: 'tooLarge' } } },
's',
]);
expect(
await failureOf(
engine.store.setFilters(ARA, {
rules: [rule('r1')],
version: filterRulesVersion([]),
}),
),
).toMatchObject({ code: 'MAIL_013', context: { jmapType: 'tooLarge' } });
});
it('엔진이 글을 받지 않으면(invalidScript) 우리 생성기의 결함 — SYS_001, 엔진이 알려 준 자리(줄 · 칸)와 규칙 수만 남긴다 (설명 문장의 규칙 글은 남기지 않는다)', async () => {
const engine = new FakeSieveEngine();
const secret = '거래처-비밀-낱말';
engine.setReplies.push(() => [
'SieveScript/set',
{
notCreated: {
script: {
type: 'invalidScript',
// Sieve 컴파일 오류는 만난 문자열 토큰(규칙의 글)을 그대로 적는다
description: `Expected token "string" but found "${secret}" at line 7, column 46.`,
},
},
},
's',
]);
const failure = await failureOf(
engine.store.setFilters(ARA, {
rules: [
rule('r1', {
conditions: [
{ field: 'subject', operator: 'contains', value: secret },
],
}),
],
version: filterRulesVersion([]),
}),
);
expect(failure).toMatchObject({
code: 'SYS_001',
context: {
reason: 'filter-script-rejected',
jmapType: 'invalidScript',
rules: 1,
enabledRules: 1,
},
});
expect(failure.context?.position).toEqual({ line: 7, column: 46 });
expect(failure.context).not.toHaveProperty('description');
expect(JSON.stringify(failure.context)).not.toContain(secret);
});
it('엔진의 설명에 자리가 없으면 자리는 null — 설명은 여전히 남기지 않는다', () => {
expect(scriptErrorPosition('Sieve script is too large')).toBeNull();
expect(scriptErrorPosition(undefined)).toBeNull();
expect(
scriptErrorPosition(
"Undeclared capability 'mailbox' at line 3, column 59.",
),
).toEqual({ line: 3, column: 59 });
});
it('그 밖의 거절은 오류 표대로 — 권한 없음 AUTH_003, 메서드 오류 MAIL_001', async () => {
const forbidden = new FakeSieveEngine();
forbidden.setReplies.push(() => [
'SieveScript/set',
{ notCreated: { script: { type: 'forbidden' } } },
's',
]);
const serverFail = new FakeSieveEngine();
serverFail.setReplies.push(() => ['error', { type: 'serverFail' }, 's']);
for (const [engine, code] of [
[forbidden, 'AUTH_003'],
[serverFail, 'MAIL_001'],
] as const) {
expect(
(
await failureOf(
engine.store.setFilters(ARA, {
rules: [rule('r1')],
version: filterRulesVersion([]),
}),
)
).code,
).toBe(code);
}
});
it('저장한 뒤 같은 요청의 다시 읽기가 실패해도 저장한 것으로 — 켜기는 저장의 일부다', async () => {
const engine = new FakeSieveEngine();
engine.failRereadAfterSet = true;
const rules = [rule('r1')];
await expect(
engine.store.setFilters(ARA, {
rules,
version: filterRulesVersion([]),
}),
).resolves.toEqual({
rules,
version: filterRulesVersion(rules),
isActive: true,
});
});
});
describe('부재중 저장 (setVacation)', () => {
it('규칙을 그대로 두고 부재중만 바꾼다 — 지문을 보지 않고, 규칙의 지문은 그대로다', async () => {
const engine = new FakeSieveEngine();
const rules = [rule('r1'), rule('r2', { isEnabled: false })];
engine.addOurs({ rules, vacation: NEVER_CONFIGURED_VACATION });
const before = await engine.store.getFilters(ARA);
await expect(engine.store.setVacation(ARA, vacationOn)).resolves.toEqual(
vacationOn,
);
expect(engine.ourSource()).toEqual({ rules, vacation: vacationOn });
await expect(engine.store.getFilters(ARA)).resolves.toEqual(before);
});
it('우리 스크립트가 없으면 만든다 — 규칙 없음 + 부재중, 켠다', async () => {
const engine = new FakeSieveEngine();
engine.addScript('vacation', 'require ["vacation"];\r\n', true);
await engine.store.setVacation(ARA, vacationOn);
expect(engine.ourSource()).toEqual({ rules: [], vacation: vacationOn });
expect(engine.ours()?.isActive).toBe(true);
});
it('원본이 깨졌으면 규칙 없음으로 덮어쓴다 (계약 2절 — 저장하면 덮어쓴다)', async () => {
const engine = new FakeSieveEngine();
engine.addScript(FILTER_SCRIPT_NAME, 'keep;\r\n', true);
await engine.store.setVacation(ARA, vacationOn);
expect(engine.ourSource()).toEqual({ rules: [], vacation: vacationOn });
});
it('쓰는 사이 다른 곳에서 필터를 저장했으면 다시 읽어 그 규칙을 지킨다', async () => {
const engine = new FakeSieveEngine();
engine.addOurs({ rules: [], vacation: NEVER_CONFIGURED_VACATION });
const theirs = [rule('theirs')];
engine.beforeSet.push(() => {
const ours = engine.ours()!;
engine.blobs.set(
'other-tab',
buildFilterScript({
rules: theirs,
vacation: NEVER_CONFIGURED_VACATION,
}).text,
);
ours.blobId = 'other-tab';
engine.state += 1;
});
await engine.store.setVacation(ARA, vacationOn);
expect(engine.ourSource()).toEqual({
rules: theirs,
vacation: vacationOn,
});
});
});
});
@@ -0,0 +1,472 @@
import { Injectable, Logger } from '@nestjs/common';
import { BusinessException } from '@/common/errors/business.exception';
import type {
MailFilterSet,
MailFilterUpdate,
MailVacation,
} from '@/modules/mail/mail.types';
import { traceTag } from '@/shared/logger/request-context';
import { maskEmail } from '@/shared/utils/mask';
import {
EMPTY_FILTER_SOURCE,
FILTER_SOURCE_FORMAT,
canonicalRules,
filterRulesVersion,
readFilterSource,
type FilterScriptSource,
} from './filter-source';
import { JMAP_REQUEST_TIMEOUT_MS, JmapClient } from './jmap.client';
import { jmapFailure } from './jmap.errors';
import { toVacation } from './jmap.mapper';
import {
expectResult,
findResult,
firstSetError,
isState,
} from './jmap-results';
import type {
JmapError,
JmapGetResult,
JmapSetResult,
JmapSieveScript,
JmapVacationResponse,
} from './jmap.types';
import {
FILTER_SCRIPT_MAX_BYTES,
SIEVE_STRING_MAX_BYTES,
buildFilterScript,
type BuiltFilterScript,
} from './sieve-script';
/** JMAP for Sieve (RFC 9661) — 이 호출에만 선언한다 (계정 id 는 메일 계정과 같다 — 2026-10-28 확인) */
const SIEVE_CAPABILITY = 'urn:ietf:params:jmap:sieve';
/** 부재중 자동 응답 기능 (RFC 8621 §8) — 우리 스크립트가 아직 없을 때 엔진의 값을 가져온다 */
const VACATION_CAPABILITY = 'urn:ietf:params:jmap:vacationresponse';
/** 계정마다 하나인 부재중 자동 응답의 id (RFC 8621 §8) */
const VACATION_ID = 'singleton';
/** 필터와 부재중 자동 응답을 함께 두는 스크립트의 이름 — 이름은 계정 안에서 하나다 (계약 2절 "저장 자리") */
export const FILTER_SCRIPT_NAME = 'arasystem';
/** 올리는 스크립트 글의 형식 */
const SIEVE_TYPE = 'application/sieve';
/** 처음 만들 때의 생성 id — 같은 요청의 onSuccessActivateScript 가 `#` 를 붙여 가리킨다 */
const CREATE_ID = 'script';
/**
* 읽은 상태 그대로일 때만(ifInState) 쓰는 횟수 — 그사이 바뀌면 다시 읽어 확인한다. 합계이고, 넘으면 MAIL_001 (계약 2절 "저장 자리").
* 읽기에서 스크립트 글이 그사이 없어졌을 때(내려받기 404) 다시 읽는 횟수도 같다
*/
const SCRIPT_ATTEMPTS = 3;
/** 스크립트 글을 내려받는 상한 — 엔진의 사용자 스크립트 한도. 넘는 글은 우리가 쓴 것이 아니다(깨진 원본) */
const SCRIPT_READ_MAX_BYTES = 102_400;
/** SieveScript/get 에서 읽는 속성 (id 는 늘 온다) */
const SCRIPT_PROPERTIES = ['name', 'isActive', 'blobId'];
/** JMAP 메서드 오류 — ifInState 가 지금 상태와 다르다 (RFC 8620 §5.3, SetError 가 아니다 — 같은 날 확인) */
const STATE_MISMATCH = 'stateMismatch';
/** SetError — 같은 이름의 스크립트가 있다 (그사이 다른 곳에서 처음 쓰기가 겹쳤다) · 그 스크립트가 없다 (그사이 지워졌다) */
const ALREADY_EXISTS = 'alreadyExists';
const NOT_FOUND = 'notFound';
/** SetError — 엔진의 스크립트 한도(102,400바이트)를 넘는다 */
const TOO_LARGE = 'tooLarge';
/** SetError — 엔진이 스크립트 글을 받지 않는다. Stalwart 의 이름(invalidScript)과 RFC 9661 의 이름(invalidSieve) */
const INVALID_SCRIPT_TYPES: ReadonlySet<string> = new Set([
'invalidScript',
'invalidSieve',
]);
/**
* 엔진이 글을 받지 않은 자리 — 설명 문장에서 `line N, column M` 만 꺼낸다.
* 설명 문장은 남기지 않는다: Sieve 컴파일 오류는 만난 문자열 토큰(규칙의 글)을 그대로 적을 수 있다
* (예: `Expected token "string" but found ";" at line 2, column 46.` — 계약 "규칙의 글은 남기지 않는다", jmap.errors.ts 의 jmapFailure 와 같은 규칙)
*/
const SCRIPT_POSITION_PATTERN = /\bline (\d+), column (\d+)\b/;
/** 엔진의 설명에서 자리(줄 · 칸)만 — 없으면 null */
export function scriptErrorPosition(
description: string | undefined,
): { readonly line: number; readonly column: number } | null {
const match = SCRIPT_POSITION_PATTERN.exec(description ?? '');
return match ? { line: Number(match[1]), column: Number(match[2]) } : null;
}
/** 엔진 규칙의 내용 — 원본과 동작하는지 */
interface ScriptView {
readonly source: FilterScriptSource;
/**
* 필터 · 부재중이 동작하는가 (계약의 isActive) — 우리 스크립트가 없으면 true(막힌 것이 없다),
* 있으면 엔진에서 켜져 있고 원본을 읽을 수 있을 때만 true
*/
readonly isActive: boolean;
}
/** 지금 엔진 규칙 — 내용과 쓸 때의 조건 */
interface ScriptSnapshot extends ScriptView {
/** SieveScript 상태 — 엔진이 알려 주지 않으면 null (조건 없이는 쓰지 않는다) */
readonly state: string | null;
/** 우리 스크립트 id — 없으면 null (처음 쓸 때 만든다) */
readonly scriptId: string | null;
/** 원본이 이 서버가 모르는 새 판이면 그 판 — 그때는 쓰지 않는다 (filter-source.ts 의 FILTER_SOURCE_FORMAT) */
readonly newerFormat: number | null;
}
/** 로그 · 오류 context 에 남기는 스크립트 요약 — 크기와 규칙 수만 (규칙의 글은 남기지 않는다) */
function scriptSummary(
script: BuiltFilterScript,
source: FilterScriptSource,
): Record<string, unknown> {
return {
bytes: script.bytes,
rules: source.rules.length,
enabledRules: source.rules.filter((rule) => rule.isEnabled).length,
vacation: source.vacation.isEnabled,
};
}
function toFilterSet(view: ScriptView): MailFilterSet {
return {
rules: canonicalRules(view.source.rules),
version: filterRulesVersion(view.source.rules),
isActive: view.isActive,
};
}
/**
* 메일 필터와 부재중 자동 응답의 저장 자리 — 엔진의 Sieve 스크립트 하나(`arasystem`) (계약 2절 "메일 필터"의 저장 자리).
* Stalwart 0.16.27 은 계정마다 켜진 스크립트가 하나뿐이고 엔진의 VacationResponse 도 스크립트(`vacation`)라서, 둘을 따로 두면
* 부재중을 켜면 필터가 꺼지고 필터를 켜면 부재중이 꺼진다 — 그래서 한 스크립트에 함께 둔다. `vacation` 스크립트는 지우지도 고치지도 않는다(엔진이 forbidden).
*
* - 읽기: SieveScript/get → 우리 스크립트의 글을 내려받아 머리의 원본(JSON)만 믿는다(filter-source.ts). 없으면 규칙 없음 +
* 부재중은 엔진의 VacationResponse 에서 가져온다(4단계 데이터 이어받기 — 처음 저장할 때 우리 스크립트로 옮겨진다).
* 원본이 깨졌거나 새 판이면 빈 규칙 · 부재중 꺼짐 · 동작하지 않음으로 보인다 — 깨진 원본은 저장하면 덮어쓰고, 새 판은 쓰지 않는다(MAIL_001)
* - 쓰기: 읽은 상태로 다음 원본을 정해 글을 만들고(sieve-script.ts) 크기를 본 뒤 올리고, 읽은 상태 그대로일 때만(ifInState)
* 만들거나 고치고 켠다(onSuccessActivateScript — 다른 스크립트는 엔진이 끈다). 그사이 바뀌면 다시 읽어 확인한다 (합계 SCRIPT_ATTEMPTS 번)
*/
@Injectable()
export class JmapFilterScriptStore {
private readonly logger = new Logger(JmapFilterScriptStore.name);
constructor(private readonly jmap: JmapClient) {}
/** 지금 규칙 · 지문 · 동작하는지 */
async getFilters(user: string): Promise<MailFilterSet> {
return toFilterSet(await this.snapshot(user));
}
/**
* 규칙을 통째로 바꾼다 — 부재중은 다시 읽은 값 그대로 둔다.
* 다시 읽은 규칙의 지문이 받은 version 과 다르면 MAIL_012 (저장하지 않는다) — 다만 저장해 둔 규칙이 이미 바꾸려는 규칙과 같으면
* 그대로 저장한다(같은 요청을 다시 보낸 경우 — 결과를 모르는 실패 뒤의 재시도가 409 로 끝나지 않게, 꺼져 있으면 다시 켜진다)
*/
async setFilters(
user: string,
update: MailFilterUpdate,
): Promise<MailFilterSet> {
const wanted = filterRulesVersion(update.rules);
const saved = await this.write(user, (current) => {
const version = filterRulesVersion(current.rules);
if (version !== update.version && version !== wanted) {
throw new BusinessException('MAIL_012', {
context: {
reason: 'filter-rules-changed',
rules: update.rules.length,
storedRules: current.rules.length,
},
});
}
return { rules: update.rules, vacation: current.vacation };
});
return toFilterSet(saved);
}
/** 부재중 자동 응답 — 우리 스크립트가 없으면 엔진의 VacationResponse, 원본이 깨졌으면 끈 상태 */
async getVacation(user: string): Promise<MailVacation> {
return (await this.snapshot(user)).source.vacation;
}
/** 부재중 자동 응답만 바꾼다 — 규칙은 다시 읽은 그대로 둔다 (지문을 보지 않는다). 저장한 값을 돌려준다 */
async setVacation(
user: string,
vacation: MailVacation,
): Promise<MailVacation> {
const saved = await this.write(user, (current) => ({
rules: current.rules,
vacation,
}));
return saved.source.vacation;
}
/**
* 지금 엔진 규칙을 읽는다 — 스크립트 목록과 엔진의 부재중 응답을 한 요청으로 묻고, 우리 스크립트가 있으면 글을 내려받는다.
* 내려받는 사이 글이 없어졌으면(그사이 바뀜) 다시 묻는다 — SCRIPT_ATTEMPTS 번을 넘으면 MAIL_001
*/
private async snapshot(user: string): Promise<ScriptSnapshot> {
for (let attempt = 1; attempt <= SCRIPT_ATTEMPTS; attempt += 1) {
const responses = await this.jmap.call(
user,
(accountId) => [
[
'SieveScript/get',
{ accountId, ids: null, properties: SCRIPT_PROPERTIES },
'g',
],
['VacationResponse/get', { accountId, ids: [VACATION_ID] }, 'v'],
],
{ capabilities: [SIEVE_CAPABILITY, VACATION_CAPABILITY] },
);
const scripts = expectResult<JmapGetResult<JmapSieveScript>>(
responses,
'g',
'SieveScript/get',
);
const state = isState(scripts.state) ? scripts.state : null;
const ours = (scripts.list ?? []).find(
(script) => script.name === FILTER_SCRIPT_NAME,
);
// 아직 저장한 적이 없다 — 막힌 것이 없고(isActive), 부재중은 엔진의 VacationResponse 를 이어받는다
if (!ours) {
const { list = [] } = expectResult<JmapGetResult<JmapVacationResponse>>(
responses,
'v',
'VacationResponse/get',
);
return {
state,
scriptId: null,
isActive: true,
source: { rules: [], vacation: toVacation(list[0]) },
newerFormat: null,
};
}
const text = await this.downloadScript(user, ours);
// 목록을 읽은 뒤 스크립트가 바뀌어 그 글이 없어졌다 — 다시 묻는다
if (text === undefined) continue;
// 읽을 수 없는 글(없음 · 너무 큼)은 깨진 원본과 같다 — 깨짐 · 새 판은 빈 규칙 · 동작하지 않음으로 보인다
const reading =
text === null ? { status: 'broken' as const } : readFilterSource(text);
return {
state,
scriptId: ours.id,
isActive: reading.status === 'ok' && ours.isActive === true,
source: reading.status === 'ok' ? reading.source : EMPTY_FILTER_SOURCE,
newerFormat: reading.status === 'newer' ? reading.format : null,
};
}
throw new BusinessException('MAIL_001', {
context: {
reason: 'filter-script-unreadable',
attempts: SCRIPT_ATTEMPTS,
},
});
}
/** 스크립트 글 — 읽을 수 없으면(글이 없음 · 너무 큼) null, 그사이 바뀌어 글이 없어졌으면(404) undefined */
private async downloadScript(
user: string,
script: JmapSieveScript,
): Promise<string | null | undefined> {
if (!script.blobId) return null;
try {
const bytes = await this.jmap.downloadBytes(
user,
script.blobId,
SCRIPT_READ_MAX_BYTES,
);
return bytes === null ? null : bytes.toString('utf8');
} catch (error) {
if (error instanceof BusinessException && error.code === 'RES_001') {
return undefined;
}
throw error;
}
}
/**
* 다음 원본을 정해(change) 쓴다 — 읽은 상태 그대로일 때만 쓰고 켠다. change 가 던지면(MAIL_012) 그대로 던진다.
* 만든 글이 한도를 넘으면 MAIL_013 (올리지 않는다). 같은 글을 다시 쓸 때는 올린 것을 다시 쓴다.
* 원본이 새 판이면 쓰지 않고 MAIL_001 — 이 서버가 모르는 규칙 · 부재중을 덮어써 알림 없이 잃지 않게 (부재중만 저장해도 규칙을 다시 쓴다)
*/
private async write(
user: string,
change: (current: FilterScriptSource) => FilterScriptSource,
): Promise<ScriptView> {
let uploaded: { readonly text: string; readonly blobId: string } | null =
null;
for (let attempt = 1; attempt <= SCRIPT_ATTEMPTS; attempt += 1) {
const current = await this.snapshot(user);
if (current.newerFormat !== null) {
throw new BusinessException('MAIL_001', {
context: {
reason: 'filter-source-newer',
format: current.newerFormat,
supportedFormat: FILTER_SOURCE_FORMAT,
},
});
}
// 상태를 모르면 조건 없이 쓰지 않는다 — 그사이 다른 곳에서 바꾼 것을 덮을 수 있다
if (current.state === null) {
throw new BusinessException('MAIL_001', {
context: { reason: 'filter-script-state-unknown' },
});
}
const next = change(current.source);
const script = buildFilterScript(next);
if (
script.bytes > FILTER_SCRIPT_MAX_BYTES ||
script.longestStringBytes > SIEVE_STRING_MAX_BYTES
) {
throw new BusinessException('MAIL_013', {
context: {
reason: 'filter-script-too-large',
...scriptSummary(script, next),
longestStringBytes: script.longestStringBytes,
},
});
}
const blobId: string =
uploaded?.text === script.text
? uploaded.blobId
: await this.upload(user, script.text);
uploaded = { text: script.text, blobId };
const saved = await this.save(user, current, blobId, script, next);
if (saved !== null) {
this.logger.debug(
`${traceTag()} 엔진 규칙(Sieve)을 저장했습니다: user=${maskEmail(user)}, ${script.bytes}바이트, 규칙 ${next.rules.length}개, 부재중 ${next.vacation.isEnabled ? '켬' : '끔'}, ${attempt}번째`,
);
return { source: next, isActive: saved.isActive };
}
}
throw new BusinessException('MAIL_001', {
context: {
reason: 'filter-script-contended',
attempts: SCRIPT_ATTEMPTS,
},
});
}
private async upload(user: string, text: string): Promise<string> {
const { blobId } = await this.jmap.upload(
user,
new Blob([text], { type: SIEVE_TYPE }),
SIEVE_TYPE,
{ timeoutMs: JMAP_REQUEST_TIMEOUT_MS },
);
return blobId;
}
/**
* 읽은 상태 그대로일 때만 만들거나(없으면) 고치고(있으면) 켠다 — 같은 요청에서 다시 읽어 켜졌는지 본다.
* @returns 저장했으면 켜졌는지, 그사이 바뀌었으면(stateMismatch · alreadyExists · notFound) null — 다시 읽는다
*/
private async save(
user: string,
current: ScriptSnapshot,
blobId: string,
script: BuiltFilterScript,
next: FilterScriptSource,
): Promise<{ readonly isActive: boolean } | null> {
const { scriptId } = current;
const responses = await this.jmap.call(
user,
(accountId) => [
[
'SieveScript/set',
{
accountId,
ifInState: current.state,
...(scriptId === null
? {
create: {
[CREATE_ID]: { name: FILTER_SCRIPT_NAME, blobId },
},
}
: { update: { [scriptId]: { blobId } } }),
onSuccessActivateScript: scriptId ?? `#${CREATE_ID}`,
},
's',
],
[
'SieveScript/get',
{ accountId, ids: null, properties: SCRIPT_PROPERTIES },
'g',
],
],
{ capabilities: [SIEVE_CAPABILITY] },
);
const result = findResult(responses, 's', 'SieveScript/set');
if (!result.ok) {
if (result.error.type === STATE_MISMATCH) return null;
throw jmapFailure(result.error, { method: 'SieveScript/set' });
}
const set = result.args as JmapSetResult;
const failure =
firstSetError(set.notCreated) ?? firstSetError(set.notUpdated);
if (failure) {
if (failure.type === ALREADY_EXISTS || failure.type === NOT_FOUND) {
return null;
}
throw this.rejection(failure, script, next);
}
const savedId = scriptId ?? set.created?.[CREATE_ID]?.id;
const updated =
scriptId === null ||
(set.updated !== undefined &&
Object.prototype.hasOwnProperty.call(set.updated, scriptId));
if (!savedId || !updated) {
throw new BusinessException('MAIL_001', {
context: { reason: 'filter-script-not-saved' },
});
}
// 저장은 끝났다 — 다시 읽지 못해도 실패로 답하지 않는다 (켜기는 같은 저장의 일부다)
const reread = findResult(responses, 'g', 'SieveScript/get');
const isActive = reread.ok
? ((reread.args as JmapGetResult<JmapSieveScript>).list ?? []).some(
(candidate) =>
candidate.id === savedId && candidate.isActive === true,
)
: true;
return { isActive };
}
/**
* 엔진이 저장을 거절한 것 — 한도 초과(tooLarge)는 MAIL_013.
* 글을 받지 않음(invalidScript)은 우리가 만든 글의 결함이라 내부 오류(SYS_001)다 — 엔진이 알려 준 자리(줄 · 칸)와 크기 · 규칙 수만 남긴다
* (설명 문장은 규칙의 글을 담을 수 있어 남기지 않는다 — scriptErrorPosition). 그 밖은 오류 표대로 (jmap.errors.ts)
*/
private rejection(
failure: JmapError,
script: BuiltFilterScript,
next: FilterScriptSource,
): BusinessException {
const context = {
method: 'SieveScript/set',
jmapType: failure.type ?? 'unknown',
...scriptSummary(script, next),
};
if (failure.type === TOO_LARGE) {
return new BusinessException('MAIL_013', {
context: { reason: 'filter-script-too-large', ...context },
});
}
if (failure.type !== undefined && INVALID_SCRIPT_TYPES.has(failure.type)) {
return new BusinessException('SYS_001', {
context: {
reason: 'filter-script-rejected',
position: scriptErrorPosition(failure.description),
...context,
},
});
}
return jmapFailure(failure, { method: 'SieveScript/set' });
}
}
@@ -10,6 +10,7 @@ import type {
import { SseParser } from '@/shared/utils/sse';
import type { JmapEventStream } from './jmap.client';
import { PUSH_STATE_EVENT, isMailStateChange } from './jmap-push';
import { describeFailure } from './jmap.errors';
/**
* 실시간 알림의 메일 엔진 연결 하나 (계약 2절 "실시간 알림") — 직원 한 명에 하나, 그 직원의 모든 클라이언트 연결이 나눠 쓴다(MailEventsService).
@@ -112,24 +113,6 @@ function isRateLimited(error: unknown): boolean {
return error instanceof BusinessException && error.code === 'REQ_001';
}
/**
* 로그용 실패 요약 — 코드와 사유만 (엔진의 설명 문구 · 주소는 넣지 않는다).
* 스트림을 읽다 끊긴 것은 오류 이름과 원인 코드 (예: TypeError/UND_ERR_SOCKET — fetch 본문의 연결이 끊김)
*/
function describeFailure(error: unknown): string {
if (error instanceof BusinessException) {
const reason: unknown = error.context?.reason;
return typeof reason === 'string' ? `${error.code}/${reason}` : error.code;
}
if (!(error instanceof Error)) return 'unknown';
const { cause } = error as { cause?: unknown };
const causeCode =
cause instanceof Error
? ((cause as NodeJS.ErrnoException).code ?? cause.name)
: null;
return causeCode ? `${error.name}/${causeCode}` : error.name;
}
/** 프로세스를 붙잡지 않는 타이머 — 종료를 늦추지 않게 */
function timer(callback: () => void, ms: number): NodeJS.Timeout {
const handle = setTimeout(callback, ms);
@@ -25,6 +25,7 @@ import {
} from './jmap.client';
import type { JmapClient, JmapSession } from './jmap.client';
import { decodeCursor, encodeCursor } from './jmap-cursor';
import { JmapFilterScriptStore } from './jmap-filter-script';
import { WATCH_DEBOUNCE_MS, WATCH_START_TIMEOUT_MS } from './jmap-mail-watch';
import { JmapMailProvider, buildThreadHeaders } from './jmap-mail.provider';
import { MAILBOX_PROPERTIES } from './jmap.mapper';
@@ -32,6 +33,7 @@ import type { JmapInvocation } from './jmap.types';
const ARA = 'ara.kim@arasystem.test';
const DOYUN = 'doyun.lee@arasystem.test';
const SEOJUN = 'seojun.park@arasystem.test';
const MB = 1024 * 1024;
@@ -173,6 +175,7 @@ function fakeClient(
client as unknown as JmapClient,
cache,
overrides.settings ?? settings,
new JmapFilterScriptStore(client as unknown as JmapClient),
),
};
}
@@ -2193,7 +2196,7 @@ describe('JmapMailProvider', () => {
const outgoing: OutgoingMessage = {
to: [{ name: '이도윤', email: DOYUN }],
cc: [],
bcc: [{ name: null, email: 'seojun.park@arasystem.test' }],
bcc: [{ name: null, email: SEOJUN }],
subject: 'Re: 메일 서버 이전 일정 공유',
body: '도윤님, 확인했습니다.',
attachments: [
@@ -2238,6 +2241,34 @@ describe('JmapMailProvider', () => {
['Email/set', { updated: { maaaaad: null } }, 's'],
];
/** 받는 사람별 결과 (EmailSubmission/get — 제출 다음에 따로 읽는다). 개발 엔진이 돌려준 모양 그대로 (2026-10-28) */
const deliveryOf = (
deliveryStatus: Record<string, Record<string, string>>,
): JmapInvocation[] => [
[
'EmailSubmission/get',
{ state: 'sb1', list: [{ id: 'b', deliveryStatus }], notFound: [] },
'd',
],
];
const QUEUED = {
delivered: 'unknown',
smtpReply: '250 2.1.5 Queued',
displayed: 'unknown',
};
const MAILBOX_MISSING = {
delivered: 'no',
smtpReply: '550 5.1.2 Mailbox does not exist.',
displayed: 'unknown',
};
const RATE_LIMITED = {
delivered: 'no',
smtpReply: '452 4.4.5 Rate limit exceeded, try again later.',
displayed: 'unknown',
};
/** 받는 사람 · 숨은참조 모두 대기열에 들어갔다 (받은 것) */
const queued = deliveryOf({ [DOYUN]: QUEUED, [SEOJUN]: QUEUED });
/** 첨부 크기 확인 응답 (Blob/get) */
const blobSizes = (size: number): JmapInvocation[] => [
['Blob/get', { list: [{ id: 'blob-xlsx', size }], notFound: [] }, 'b'],
@@ -2245,10 +2276,11 @@ describe('JmapMailProvider', () => {
const fits = blobSizes(1000);
it('초안 생성 → 같은 요청에서 제출 → 보낸편지함 이동 (검증된 형태)', async () => {
const { provider, recorded } = fakeClient([prepared, fits, sent]);
const { provider, recorded } = fakeClient([prepared, fits, sent, queued]);
await expect(provider.sendMessage(ARA, outgoing)).resolves.toEqual({
id: 'maaaaad',
rejectedRecipients: [],
});
const draft = (argsOf(recorded[2], 0).create as Record<string, unknown>)
@@ -2293,7 +2325,7 @@ describe('JmapMailProvider', () => {
});
it('서식 본문이 있으면 textBody + htmlBody 로 만든다 — 엔진이 multipart/alternative 로 보낸다', async () => {
const { provider, recorded } = fakeClient([prepared, fits, sent]);
const { provider, recorded } = fakeClient([prepared, fits, sent, queued]);
const html =
'<div style="font-size:14px"><p><strong>확인</strong>했습니다.</p></div>';
@@ -2312,7 +2344,7 @@ describe('JmapMailProvider', () => {
});
it('서식 본문이 없으면 htmlBody 를 만들지 않는다 (텍스트 메일)', async () => {
const { provider, recorded } = fakeClient([prepared, fits, sent]);
const { provider, recorded } = fakeClient([prepared, fits, sent, queued]);
await provider.sendMessage(ARA, outgoing);
@@ -2325,7 +2357,7 @@ describe('JmapMailProvider', () => {
});
it('중복 방지 Message-ID 와 내용 지문을 초안에 싣는다 (지문은 사용자 헤더)', async () => {
const { provider, recorded } = fakeClient([prepared, fits, sent]);
const { provider, recorded } = fakeClient([prepared, fits, sent, queued]);
await provider.sendMessage(ARA, {
...outgoing,
@@ -2341,7 +2373,7 @@ describe('JmapMailProvider', () => {
});
it('첨부 크기는 Blob/get 으로 잰다 (blob 기능은 이 호출에만, 해석 못 하는 id 는 MAIL_004)', async () => {
const { provider, recorded } = fakeClient([prepared, fits, sent]);
const { provider, recorded } = fakeClient([prepared, fits, sent, queued]);
await provider.sendMessage(ARA, outgoing);
@@ -2359,11 +2391,12 @@ describe('JmapMailProvider', () => {
});
it('첨부가 없으면 크기를 재지 않는다', async () => {
const { provider, recorded } = fakeClient([prepared, sent]);
const { provider, recorded } = fakeClient([prepared, sent, queued]);
await provider.sendMessage(ARA, { ...outgoing, attachments: [] });
expect(recorded).toHaveLength(2);
// 준비 · 보내기 · 받는 사람별 결과
expect(recorded).toHaveLength(3);
});
it('첨부 합계가 설정 한도(25MB)를 넘으면 메일을 만들지 않고 413 FILE_002 — 같은 첨부 두 번은 두 번 센다', async () => {
@@ -2547,6 +2580,7 @@ describe('JmapMailProvider', () => {
],
fits,
sent,
queued,
]);
const warn = jest
.spyOn(Reflect.get(provider, 'logger') as { warn: () => void }, 'warn')
@@ -2561,6 +2595,299 @@ describe('JmapMailProvider', () => {
expect(warn).toHaveBeenCalled();
});
/** 계약 2절 "받는 사람별 결과" (6단계 앞 수정) — 엔진이 그 자리에서 거절한 받는 사람 */
describe('받는 사람별 결과', () => {
// 시각(Date.now)을 바꾸는 테스트가 있다 — 다음 테스트로 넘기지 않는다
afterEach(() => jest.restoreAllMocks());
const textOnly: OutgoingMessage = { ...outgoing, attachments: [] };
/** 보낸 사본 지우기 응답 */
const discarded: JmapInvocation[] = [
['Email/set', { destroyed: ['maaaaad'] }, 'x'],
];
const everyoneMissing = deliveryOf({
[DOYUN]: MAILBOX_MISSING,
[SEOJUN]: MAILBOX_MISSING,
});
const spyLogger = (provider: JmapMailProvider, level: 'log' | 'warn') =>
jest
.spyOn(
Reflect.get(provider, 'logger') as Record<
'log' | 'warn',
(message: string) => void
>,
level,
)
.mockImplementation(() => undefined);
const logged = (spy: jest.SpyInstance) =>
spy.mock.calls.map(([message]) => String(message)).join('\n');
/** 엔진 호출마다 시간이 흐르게 한다 — n 번째 호출이 끝나면 delays[n - 1] 만큼 */
const elapseOnCalls = (
fake: ReturnType<typeof fakeClient>,
delays: readonly number[],
clock: { now: number },
) => {
const answer = fake.client.call.getMockImplementation();
if (!answer) throw new Error('호출 대역이 없습니다');
fake.client.call.mockImplementation((...args) => {
const reply = answer(...args);
clock.now += delays[fake.recorded.length - 1] ?? 0;
return reply;
});
jest.spyOn(Date, 'now').mockImplementation(() => clock.now);
};
it('제출 다음에 따로 읽는다 — 제출 id 로 deliveryStatus 만, 짧은 제한 시간(5초)으로 (같은 요청 안의 참조는 엔진이 풀지 못한다)', async () => {
const { provider, recorded } = fakeClient([prepared, sent, queued]);
await expect(provider.sendMessage(ARA, textOnly)).resolves.toEqual({
id: 'maaaaad',
rejectedRecipients: [],
});
expect(recorded[1].calls.map(([name]) => name)).toEqual([
'Email/set',
'EmailSubmission/set',
]);
expect(recorded[2].calls).toEqual([
[
'EmailSubmission/get',
{ accountId: 'd', ids: ['b'], properties: ['deliveryStatus'] },
'd',
],
]);
expect(recorded[2].options).toEqual({ timeoutMs: 5_000 });
expect(recorded).toHaveLength(3);
});
it('일부만 받지 않았으면 보낸 것이다 — 받지 않은 사람만 돌려주고 보낸 사본은 그대로, log 에는 가린 주소와 상태 코드만', async () => {
const { provider, recorded } = fakeClient([
prepared,
sent,
deliveryOf({ [DOYUN]: MAILBOX_MISSING, [SEOJUN]: QUEUED }),
]);
const log = spyLogger(provider, 'log');
await expect(provider.sendMessage(ARA, textOnly)).resolves.toEqual({
id: 'maaaaad',
rejectedRecipients: [{ email: DOYUN, reason: 'not-found' }],
});
// 지우지 않았다
expect(recorded).toHaveLength(3);
expect(logged(log)).toContain('받지 않은 사람 1명');
expect(logged(log)).toContain('d***@arasystem.test(550 5.1.2)');
expect(logged(log)).not.toMatch(/doyun|Mailbox does not exist/);
});
it('분류 표 — 5.1.1 · 5.1.2 는 not-found, 5.7.1 은 refused, 452 4.4.5 는 temporary, 250(대기열) · 결과 없음은 받은 것, 대소문자가 다른 주소도 요청에 쓴 그대로', async () => {
const many: OutgoingMessage = {
...textOnly,
to: [
{ name: null, email: 'a@arasystem.test' },
{ name: null, email: 'b@arasystem.test' },
],
cc: [
{ name: null, email: 'c@example.com' },
{ name: null, email: 'd@example.com' },
],
bcc: [
{ name: null, email: 'e@example.com' },
{ name: null, email: 'f@example.com' },
{ name: '김지', email: 'G.Kim@AraSystem.Test' },
],
};
const { provider } = fakeClient([
prepared,
sent,
deliveryOf({
'a@arasystem.test': {
delivered: 'no',
smtpReply: '550 5.1.1 User unknown',
},
'b@arasystem.test': MAILBOX_MISSING,
'c@example.com': {
delivered: 'no',
smtpReply: '554 5.7.1 Relay access denied',
},
'd@example.com': RATE_LIMITED,
'e@example.com': QUEUED,
'g.kim@arasystem.test': MAILBOX_MISSING,
}),
]);
spyLogger(provider, 'log');
await expect(provider.sendMessage(ARA, many)).resolves.toEqual({
id: 'maaaaad',
rejectedRecipients: [
{ email: 'a@arasystem.test', reason: 'not-found' },
{ email: 'b@arasystem.test', reason: 'not-found' },
{ email: 'c@example.com', reason: 'refused' },
{ email: 'd@example.com', reason: 'temporary' },
{ email: 'G.Kim@AraSystem.Test', reason: 'not-found' },
],
});
});
it('아무도 받지 않았으면(5xx 가 섞임) 이 요청이 만든 보낸 사본을 조건 없이 지우고 400 MAIL_010 — warn 에 코드 · 가린 주소 · 상태 코드만', async () => {
const { provider, recorded } = fakeClient([
prepared,
sent,
deliveryOf({ [DOYUN]: MAILBOX_MISSING, [SEOJUN]: RATE_LIMITED }),
discarded,
]);
const warn = spyLogger(provider, 'warn');
const error = await provider
.sendMessage(ARA, textOnly)
.catch((caught: unknown) => caught as BusinessException);
expect(error).toBeInstanceOf(BusinessException);
expect((error as BusinessException).code).toBe('MAIL_010');
expect((error as BusinessException).context).toEqual({
reason: 'all-recipients-rejected',
rejected: 2,
copyDiscarded: true,
});
// 방금 만든 메일이라 조건(ifInState) 없이 — 만들기와 다른 요청으로
expect(recorded[3].calls).toEqual([
['Email/set', { accountId: 'd', destroy: ['maaaaad'] }, 'x'],
]);
expect(recorded).toHaveLength(4);
const warned = logged(warn);
expect(warned).toContain('[MAIL_010]');
expect(warned).toContain('d***@arasystem.test(550 5.1.2)');
expect(warned).toContain('s***@arasystem.test(452 4.4.5)');
expect(warned).not.toMatch(/doyun|seojun|Mailbox does not exist|Rate/);
});
it('모두 4xx(지금은 받을 수 없음)면 503 MAIL_011 — 보낸 사본을 지우는 것은 같다', async () => {
const { provider, recorded } = fakeClient([
prepared,
sent,
deliveryOf({ [DOYUN]: RATE_LIMITED, [SEOJUN]: RATE_LIMITED }),
discarded,
]);
const warn = spyLogger(provider, 'warn');
expect(await codeOf(provider.sendMessage(ARA, textOnly))).toBe(
'MAIL_011',
);
expect(argsOf(recorded[3])).toEqual({
accountId: 'd',
destroy: ['maaaaad'],
});
expect(logged(warn)).toContain('[MAIL_011]');
});
it('같은 주소가 받는 사람과 숨은참조에 함께 있어도 한 사람이다 — 그 사람이 받지 않았으면 모두 거절', async () => {
const { provider } = fakeClient([
prepared,
sent,
deliveryOf({ [DOYUN]: MAILBOX_MISSING }),
discarded,
]);
spyLogger(provider, 'warn');
expect(
await codeOf(
provider.sendMessage(ARA, {
...textOnly,
bcc: [{ name: null, email: DOYUN.toUpperCase() }],
}),
),
).toBe('MAIL_010');
});
it('보낸 사본을 지우지 못해도 MAIL_010 그대로 — 경고만 (보낸편지함에 한 통이 남는다)', async () => {
const { provider } = fakeClient([
prepared,
sent,
everyoneMissing,
[
[
'Email/set',
{ notDestroyed: { maaaaad: { type: 'forbidden' } } },
'x',
],
],
]);
const warn = spyLogger(provider, 'warn');
const error = await provider
.sendMessage(ARA, textOnly)
.catch((caught: unknown) => caught as BusinessException);
expect((error as BusinessException).code).toBe('MAIL_010');
expect((error as BusinessException).context).toMatchObject({
copyDiscarded: false,
});
expect(logged(warn)).toContain(
'받는 사람이 모두 받지 않은 보낸 사본을 지우지 못했습니다 — 보낸편지함에 한 통이 남습니다',
);
});
it('결과를 읽지 못하면(메서드 오류 · 호출 실패 · 제출 id 없음) 받은 것으로 본다 — 빈 배열, 경고 (제출은 이미 되었다)', async () => {
const methodError: JmapInvocation[] = [
['error', { type: 'serverFail' }, 'd'],
];
const withoutId: JmapInvocation[] = [
sent[0],
['EmailSubmission/set', { created: { send: {} } }, 's'],
sent[2],
];
for (const [responses, calls] of [
[[prepared, sent, methodError], 3],
// 준비한 응답이 없으면 대역의 호출이 실패한다
[[prepared, sent], 3],
[[prepared, withoutId], 2],
] as const) {
const { provider, recorded } = fakeClient([...responses]);
const warn = spyLogger(provider, 'warn');
await expect(provider.sendMessage(ARA, textOnly)).resolves.toEqual({
id: 'maaaaad',
rejectedRecipients: [],
});
expect(recorded).toHaveLength(calls);
expect(logged(warn)).toContain(
'받는 사람별 발송 결과를 읽지 못해 모두 받은 것으로 봅니다',
);
}
});
it('결과 읽기는 발송 전체 시간(55초) 안에서 보낸 사본을 지울 3초를 남기고 — 지우기는 남은 시간으로', async () => {
const fake = fakeClient([prepared, sent, everyoneMissing, discarded]);
// 준비에 20초, 보내기(초안 + 제출)에 31초가 걸렸다 — 남은 시간은 4초
elapseOnCalls(fake, [20_000, 31_000], { now: 1_000_000 });
spyLogger(fake.provider, 'warn');
expect(await codeOf(fake.provider.sendMessage(ARA, textOnly))).toBe(
'MAIL_010',
);
// 보내기 = 55 - 20 = 35초, 결과 읽기 = 4 - 3 = 1초, 지우기 = 남은 4초
expect(fake.recorded[1].options).toMatchObject({ timeoutMs: 35_000 });
expect(fake.recorded[2].options).toEqual({ timeoutMs: 1_000 });
expect(fake.recorded[3].options).toEqual({ timeoutMs: 4_000 });
});
it('결과를 읽을 시간이 남지 않았으면 묻지 않고 받은 것으로 본다 — 경고', async () => {
const fake = fakeClient([prepared, sent]);
// 준비에 20초, 보내기에 33초 — 남은 2초에서 지울 시간 3초를 남기면 읽을 시간이 없다
elapseOnCalls(fake, [20_000, 33_000], { now: 1_000_000 });
const warn = spyLogger(fake.provider, 'warn');
await expect(fake.provider.sendMessage(ARA, textOnly)).resolves.toEqual(
{ id: 'maaaaad', rejectedRecipients: [] },
);
expect(fake.recorded).toHaveLength(2);
expect(logged(warn)).toContain('reason=no-time');
});
});
describe('서명 그림 (inlineImages — multipart/related)', () => {
const png = Buffer.from('89504e470d0a1a0a', 'hex');
const html =
@@ -2599,6 +2926,7 @@ describe('JmapMailProvider', () => {
prepared,
fits,
sent,
queued,
]);
client.upload.mockResolvedValue({
blobId: 'blob-sig',
@@ -2651,7 +2979,11 @@ describe('JmapMailProvider', () => {
});
it('첨부가 없으면 related 가 맨 위다 — 그림은 첨부 합계에 넣지 않는다 (Blob/get 없음)', async () => {
const { provider, recorded, client } = fakeClient([prepared, sent]);
const { provider, recorded, client } = fakeClient([
prepared,
sent,
queued,
]);
client.upload.mockResolvedValue({
blobId: 'blob-sig',
type: 'image/png',
@@ -2660,7 +2992,7 @@ describe('JmapMailProvider', () => {
await provider.sendMessage(ARA, { ...withImage, attachments: [] });
expect(recorded).toHaveLength(2);
expect(recorded).toHaveLength(3);
const draft = (
argsOf(recorded[1], 0).create as Record<
string,
@@ -2674,7 +3006,7 @@ describe('JmapMailProvider', () => {
});
it('서명 그림은 한꺼번에, 짧은 제한 시간으로 올린다 — 일반 업로드의 120초를 쓰지 않는다', async () => {
const { provider, client } = fakeClient([prepared, fits, sent]);
const { provider, client } = fakeClient([prepared, fits, sent, queued]);
let inFlight = 0;
let maxInFlight = 0;
client.upload.mockImplementation(async () => {
@@ -2733,6 +3065,7 @@ describe('JmapMailProvider', () => {
prepared,
fits,
sent,
queued,
]);
client.upload.mockImplementation(() => {
now += 25_000;
@@ -2773,7 +3106,7 @@ describe('JmapMailProvider', () => {
['Email/get', { list }, 'g'],
];
it('보낸편지함·임시보관함의 최근 메일에서 Message-ID 로 찾고 지문을 돌려준다', async () => {
it('보낸편지함·임시보관함의 최근 메일에서 Message-ID 로 찾고 지문을 돌려준다 — 받는 사람별 결과는 읽지 않는다 (지문이 맞을 때 따로)', async () => {
const { provider, recorded } = fakeClient([
mailboxes,
recent([
@@ -2808,6 +3141,7 @@ describe('JmapMailProvider', () => {
'header:X-Ara-Payload-Hash:asText',
],
});
expect(recorded).toHaveLength(2);
});
it('없으면 null', async () => {
@@ -2882,6 +3216,191 @@ describe('JmapMailProvider', () => {
});
});
/** 계약 2절 "받는 사람별 결과" — 같은 키의 재시도가 처음 결과를 돌려줄 때 */
describe('이미 보낸 메일의 받지 않은 사람 (getRejectedRecipients)', () => {
/** 이 요청(같은 키의 재시도)의 받는 사람 */
const RECIPIENTS = {
to: [{ name: '이도윤', email: 'DoYun.Lee@AraSystem.Test' }],
cc: [],
bcc: [{ name: null, email: SEOJUN }],
};
/** 처음 발송의 제출 기록 — 찾기(query)와 읽기(get)를 한 요청으로 */
const submissionsOf = (
deliveryStatus: Record<string, Record<string, string>>,
): JmapInvocation[] => [
['EmailSubmission/query', { ids: ['sb1'] }, 'q'],
['EmailSubmission/get', { list: [{ id: 'sb1', deliveryStatus }] }, 'g'],
];
const QUEUED_REPLY = {
delivered: 'unknown',
smtpReply: '250 2.1.5 Queued',
};
const MISSING_REPLY = {
delivered: 'no',
smtpReply: '550 5.1.2 Mailbox does not exist.',
};
it('그 메일의 제출을 찾아(emailIds) 같은 요청에서 결과를 읽고, 이 요청의 받는 사람과 맞춘다 — 요청에 쓴 주소 그대로', async () => {
const { provider, recorded } = fakeClient([
submissionsOf({ [DOYUN]: MISSING_REPLY, [SEOJUN]: QUEUED_REPLY }),
]);
await expect(
provider.getRejectedRecipients(ARA, 'sent1', RECIPIENTS),
).resolves.toEqual([
{ email: 'DoYun.Lee@AraSystem.Test', reason: 'not-found' },
]);
expect(recorded[0].calls).toEqual([
[
'EmailSubmission/query',
{ accountId: 'd', filter: { emailIds: ['sent1'] } },
'q',
],
[
'EmailSubmission/get',
{
accountId: 'd',
'#ids': {
resultOf: 'q',
name: 'EmailSubmission/query',
path: '/ids',
},
properties: ['deliveryStatus'],
},
'g',
],
]);
});
it('모두 받았으면 빈 배열', async () => {
const { provider } = fakeClient([
submissionsOf({ [DOYUN]: QUEUED_REPLY, [SEOJUN]: QUEUED_REPLY }),
]);
await expect(
provider.getRejectedRecipients(ARA, 'sent1', RECIPIENTS),
).resolves.toEqual([]);
});
it('제출 기록이 없거나 읽지 못하면 빈 배열 — 던지지 않는다 (메일은 이미 보냈다, 읽지 못한 것은 경고)', async () => {
const missing: JmapInvocation[] = [
['EmailSubmission/query', { ids: [] }, 'q'],
['EmailSubmission/get', { list: [], notFound: [] }, 'g'],
];
const unresolved: JmapInvocation[] = [
['EmailSubmission/query', { ids: ['sb1'] }, 'q'],
['error', { type: 'invalidResultReference' }, 'g'],
];
const cases: [JmapInvocation[] | null, string | null][] = [
[missing, null],
[unresolved, 'reason=MAIL_001/invalidResultReference'],
// 준비한 응답이 없으면 대역의 호출이 실패한다
[null, 'reason=Error'],
];
for (const [reading, warned] of cases) {
const { provider } = fakeClient(reading ? [reading] : []);
const warn = jest
.spyOn(
Reflect.get(provider, 'logger') as { warn: () => void },
'warn',
)
.mockImplementation(() => undefined);
await expect(
provider.getRejectedRecipients(ARA, 'sent1', RECIPIENTS),
).resolves.toEqual([]);
if (warned) {
expect(warn).toHaveBeenCalledWith(expect.stringContaining(warned));
} else {
expect(warn).not.toHaveBeenCalled();
}
}
});
it('모두 거절된 사본을 지우지 못한 채 남았으면 — 같은 키 · 같은 내용의 재시도는 그 사본을 찾고(지문이 같다) 받는 사람 전원을 받지 않은 사람으로 돌려준다', async () => {
const outgoing: OutgoingMessage = {
to: [{ name: '이도윤', email: DOYUN }],
cc: [],
bcc: [],
subject: '제목',
body: '본문',
attachments: [],
messageId: 'ara.0123abcd@arasystem.test',
fingerprint: 'v2:feedface',
};
const { provider } = fakeClient([
// 처음 발송 — 모두 거절, 보낸 사본은 지우지 못했다
[
['Mailbox/get', { list: stalwartMailboxes }, 'm'],
[
'Identity/get',
{ list: [{ id: 'b', name: '김아라', email: ARA }] },
'i',
],
],
[
['Email/set', { created: { draft: { id: 'maaaaad' } } }, 'e'],
['EmailSubmission/set', { created: { send: { id: 'b' } } }, 's'],
['Email/set', { updated: { maaaaad: null } }, 's'],
],
[
[
'EmailSubmission/get',
{ list: [{ id: 'b', deliveryStatus: { [DOYUN]: MISSING_REPLY } }] },
'd',
],
],
[['error', { type: 'serverFail' }, 'x']],
// 같은 키의 재시도 — 보낸편지함에 남은 사본을 찾는다
[['Mailbox/get', { list: stalwartMailboxes }, 'm']],
[
['Email/query', { ids: ['maaaaad'] }, 'q'],
[
'Email/get',
{
list: [
{
id: 'maaaaad',
messageId: ['ara.0123abcd@arasystem.test'],
keywords: { $seen: true },
'header:X-Ara-Payload-Hash:asText': 'v2:feedface',
},
],
},
'g',
],
],
[
['EmailSubmission/query', { ids: ['b'] }, 'q'],
[
'EmailSubmission/get',
{ list: [{ id: 'b', deliveryStatus: { [DOYUN]: MISSING_REPLY } }] },
'g',
],
],
]);
jest
.spyOn(Reflect.get(provider, 'logger') as { warn: () => void }, 'warn')
.mockImplementation(() => undefined);
const first = await provider
.sendMessage(ARA, outgoing)
.catch((caught: unknown) => caught as BusinessException);
expect((first as BusinessException).code).toBe('MAIL_010');
expect((first as BusinessException).context).toMatchObject({
copyDiscarded: false,
});
await expect(
provider.findSentMessage(ARA, 'ara.0123abcd@arasystem.test'),
).resolves.toEqual({ id: 'maaaaad', fingerprint: 'v2:feedface' });
await expect(
provider.getRejectedRecipients(ARA, 'maaaaad', outgoing),
).resolves.toEqual([{ email: DOYUN, reason: 'not-found' }]);
});
});
/** 계약 2절 "임시 저장" — 사용자의 임시 저장본 */
describe('임시 저장 (saveDraft)', () => {
const XLSX =
@@ -5018,7 +5537,8 @@ describe('JmapMailProvider', () => {
});
});
describe('부재중 자동 응답 (getVacation · setVacation)', () => {
describe('부재중 자동 응답 · 메일 필터 — 엔진의 Sieve 스크립트 하나(arasystem)에 함께 둔다', () => {
// 읽기 · 쓰기 · 다시 시도의 규칙은 jmap-filter-script.spec.ts, 만드는 Sieve 는 sieve-script.spec.ts 가 본다 — 여기서는 어댑터가 그 저장 자리를 쓰는지만
const stored = {
id: 'singleton',
isEnabled: true,
@@ -5028,175 +5548,82 @@ describe('JmapMailProvider', () => {
textBody: '11월 7일까지 휴가입니다.',
htmlBody: null,
};
const vacation = {
isEnabled: true,
fromDate: '2026-11-02T15:00:00.000Z',
toDate: '2026-11-07T15:00:00.000Z',
subject: '부재중입니다',
textBody: '11월 7일까지 휴가입니다.',
};
beforeEach(() => {
jest.spyOn(Logger.prototype, 'debug').mockImplementation(() => undefined);
});
afterEach(() => jest.restoreAllMocks());
it('VacationResponse 를 그 기능을 선언한 요청으로 읽어 우리 모양으로 — 날짜는 ISO 8601 UTC', async () => {
const { provider, recorded } = fakeClient([
[['VacationResponse/get', { list: [stored] }, 'v']],
]);
/** 우리 스크립트가 아직 없는 계정 — 엔진의 VacationResponse 만 있다 */
const noScript: JmapInvocation[] = [
['SieveScript/get', { state: 's1', list: [] }, 'g'],
['VacationResponse/get', { list: [stored] }, 'v'],
];
await expect(provider.getVacation(ARA)).resolves.toEqual({
isEnabled: true,
fromDate: '2026-11-02T15:00:00.000Z',
toDate: '2026-11-07T15:00:00.000Z',
subject: '부재중입니다',
textBody: '11월 7일까지 휴가입니다.',
});
expect(recorded[0].calls).toEqual([
['VacationResponse/get', { accountId: 'd', ids: ['singleton'] }, 'v'],
it('부재중은 우리 스크립트가 없으면 엔진의 VacationResponse 를 이어받는다 — 스크립트 목록과 한 요청으로 묻는다', async () => {
const { provider, recorded } = fakeClient([noScript]);
await expect(provider.getVacation(ARA)).resolves.toEqual(vacation);
expect(recorded[0].calls.map(([method]) => method)).toEqual([
'SieveScript/get',
'VacationResponse/get',
]);
expect(recorded[0].options).toEqual({
capabilities: ['urn:ietf:params:jmap:vacationresponse'],
capabilities: [
'urn:ietf:params:jmap:sieve',
'urn:ietf:params:jmap:vacationresponse',
],
});
});
it('한 번도 설정하지 않은 계정(엔진 값이 null)은 끈 상태 · 빈 제목과 내용', async () => {
const blank = {
id: 'singleton',
isEnabled: false,
fromDate: null,
toDate: null,
subject: null,
textBody: null,
htmlBody: null,
};
for (const list of [[blank], []]) {
const { provider } = fakeClient([
[['VacationResponse/get', { list }, 'v']],
]);
await expect(provider.getVacation(ARA)).resolves.toEqual({
isEnabled: false,
fromDate: null,
toDate: null,
subject: '',
textBody: '',
});
}
});
it('저장은 통째로 바꾼다 — 서식 본문은 비우고, 빈 제목·내용은 null, 날짜는 소수 초 없는 UTCDate. 같은 요청에서 다시 읽은 값을 돌려준다', async () => {
const { provider, recorded } = fakeClient([
it('부재중 저장은 VacationResponse 가 아니라 우리 스크립트를 만들고 켠다 — 저장한 값을 돌려준다', async () => {
const { provider, recorded, client } = fakeClient([
noScript,
[
['VacationResponse/set', { updated: { singleton: null } }, 's'],
['SieveScript/set', { created: { script: { id: 'sc1' } } }, 's'],
[
'VacationResponse/get',
{
list: [
{
...stored,
isEnabled: false,
subject: null,
textBody: null,
},
],
},
'v',
'SieveScript/get',
{ list: [{ id: 'sc1', name: 'arasystem', isActive: true }] },
'g',
],
],
]);
client.upload.mockResolvedValue({
blobId: 'blob1',
type: 'application/sieve',
size: 100,
});
const off = { ...vacation, isEnabled: false };
await expect(
provider.setVacation(ARA, {
isEnabled: false,
fromDate: '2026-11-02T15:00:00.000Z',
toDate: '2026-11-07T15:00:00.000Z',
subject: '',
textBody: '',
}),
).resolves.toEqual({
isEnabled: false,
fromDate: '2026-11-02T15:00:00.000Z',
toDate: '2026-11-07T15:00:00.000Z',
subject: '',
textBody: '',
});
expect(recorded[0].calls).toEqual([
[
'VacationResponse/set',
{
accountId: 'd',
update: {
singleton: {
isEnabled: false,
fromDate: '2026-11-02T15:00:00Z',
toDate: '2026-11-07T15:00:00Z',
subject: null,
textBody: null,
htmlBody: null,
},
},
},
's',
],
['VacationResponse/get', { accountId: 'd', ids: ['singleton'] }, 'v'],
]);
expect(recorded[0].options).toEqual({
capabilities: ['urn:ietf:params:jmap:vacationresponse'],
await expect(provider.setVacation(ARA, off)).resolves.toEqual(off);
expect((client.upload.mock.calls[0] as unknown[])[2]).toBe(
'application/sieve',
);
expect(argsOf(recorded[1])).toEqual({
accountId: 'd',
ifInState: 's1',
create: { script: { name: 'arasystem', blobId: 'blob1' } },
onSuccessActivateScript: '#script',
});
expect(
recorded.flatMap(({ calls }) => calls.map(([method]) => method)),
).not.toContain('VacationResponse/set');
});
it('날짜 없이 켜면 날짜는 null 그대로 — 엔진이 거절하면 그 코드(형식 거절 VAL_001), 바꿨다는 답이 없으면 MAIL_001', async () => {
const { provider, recorded } = fakeClient([
[
['VacationResponse/set', { updated: { singleton: null } }, 's'],
['VacationResponse/get', { list: [stored] }, 'v'],
],
]);
await provider.setVacation(ARA, {
isEnabled: true,
fromDate: null,
toDate: null,
subject: '부재중입니다',
textBody: '휴가입니다.',
});
expect(argsOf(recorded[0])).toMatchObject({
update: {
singleton: {
isEnabled: true,
fromDate: null,
toDate: null,
subject: '부재중입니다',
textBody: '휴가입니다.',
},
},
});
it('메일 필터도 같은 저장 자리에서 읽는다 — 스크립트가 없으면 규칙 없음 · 동작함', async () => {
const { provider } = fakeClient([noScript]);
const vacation = {
isEnabled: true,
fromDate: null,
toDate: null,
subject: '부재중',
textBody: '휴가',
};
const rejected = fakeClient([
[
[
'VacationResponse/set',
{
notUpdated: {
singleton: {
type: 'invalidProperties',
properties: ['fromDate'],
},
},
},
's',
],
['VacationResponse/get', { list: [stored] }, 'v'],
],
]);
expect(await codeOf(rejected.provider.setVacation(ARA, vacation))).toBe(
'VAL_001',
);
const silent = fakeClient([
[
['VacationResponse/set', {}, 's'],
['VacationResponse/get', { list: [stored] }, 'v'],
],
]);
expect(await codeOf(silent.provider.setVacation(ARA, vacation))).toBe(
'MAIL_001',
);
await expect(provider.getMailFilters(ARA)).resolves.toEqual({
rules: [],
version: expect.any(String) as string,
isActive: true,
});
});
});
@@ -23,6 +23,8 @@ import type {
MailboxBulkResult,
MailboxCreate,
MailboxDeletion,
MailFilterSet,
MailFilterUpdate,
MailLiveState,
MailState,
MailUsage,
@@ -41,7 +43,10 @@ import type {
OutgoingInlineImage,
OutgoingMessage,
PasswordChangeOutcome,
RecipientFields,
RejectedRecipient,
SavedDraft,
SentMessage,
SentMessageRecord,
UploadedFile,
} from '@/modules/mail/mail.types';
@@ -55,7 +60,16 @@ import {
type JmapCallOptions,
type JmapSession,
} from './jmap.client';
import {
DELIVERED_TO_ALL,
describeRejections,
everyoneRejectedCode,
readDeliveryResult,
toRejectedRecipients,
type DeliveryResult,
} from './delivery-status';
import { decodeCursor, encodeCursor } from './jmap-cursor';
import { JmapFilterScriptStore } from './jmap-filter-script';
import {
JmapMailWatch,
type WatchPosition,
@@ -63,6 +77,13 @@ import {
} from './jmap-mail-watch';
import { PUSH_PING_SECONDS, PUSH_TYPES } from './jmap-push';
import {
expectResult,
findResult,
firstSetError,
isState,
} from './jmap-results';
import {
describeFailure,
isMissingAttachmentBlob,
jmapFailure,
mapJmapErrorType,
@@ -79,12 +100,10 @@ import {
selectAccountQuota,
toAddresses,
toDetailSource,
toJmapVacation,
toMailbox,
toSummary,
toUsage,
toUtcIso,
toVacation,
} from './jmap.mapper';
import type {
JmapBlob,
@@ -92,6 +111,7 @@ import type {
JmapEmail,
JmapEmailFilter,
JmapEmailFilterCondition,
JmapEmailSubmission,
JmapError,
JmapGetResult,
JmapIdentity,
@@ -100,7 +120,6 @@ import type {
JmapQueryResult,
JmapQuota,
JmapSetResult,
JmapVacationResponse,
} from './jmap.types';
import { NEW_MAIL_PROPERTIES, selectNewMail } from './new-mail';
import { searchTextConditions, withSearchConditions } from './search-terms';
@@ -188,11 +207,6 @@ const BULK_ROUND_RESERVE_MS = 5_000;
/** 한 차례에 다루는 메일 수 — 엔진의 한 번 처리 한도(maxObjectsInGet · maxObjectsInSet 500) */
const BULK_BATCH_SIZE = 500;
/** 부재중 자동 응답 기능 (RFC 8621 §8) — 이 호출에만 선언한다 (모르는 기능이 using 에 있으면 요청 전체가 거부된다) */
const VACATION_CAPABILITY = 'urn:ietf:params:jmap:vacationresponse';
/** 계정마다 하나인 부재중 자동 응답의 id (RFC 8621 §8) */
const VACATION_ID = 'singleton';
/** 원문 내려받기 때 엔진에 요청하는 형식·이름 — 클라이언트에게 주는 이름·헤더는 서비스·컨트롤러가 정한다 */
const SOURCE_TYPE = 'message/rfc822';
const SOURCE_DOWNLOAD_NAME = 'message.eml';
@@ -208,6 +222,15 @@ const SEARCH_EXCLUDED_ROLES: ReadonlySet<string> = new Set(['trash', 'junk']);
const SEND_TOTAL_BUDGET_MS = 55_000;
/** 보내기에 남은 시간이 이보다 적으면 보내기 시작하지 않는다 — 보내다 끊기면 나갔는지 알 수 없다 */
const MIN_SEND_WINDOW_MS = 10_000;
/**
* 제출한 뒤 받는 사람별 결과(EmailSubmission/get 의 deliveryStatus)를 읽는 제한 시간 — 가벼운 조회 하나다 (계약 2절 "받는 사람별 결과").
* 발송 전체 시간 안에서, 모두 거절됐을 때 보낸 사본을 지울 시간(SEND_DISCARD_RESERVE_MS)을 남기고 쓴다
*/
const SEND_RESULT_TIMEOUT_MS = 5_000;
/** 결과 읽기에 쓸 시간이 이보다 적으면 읽지 않고 받은 것으로 본다 — 제출은 이미 되었다 */
const SEND_RESULT_MIN_WINDOW_MS = 1_000;
/** 받는 사람이 모두 거절했을 때 보낸 사본을 지울 시간 — 결과 읽기가 발송 전체 시간에서 이만큼 남겨 둔다 */
const SEND_DISCARD_RESERVE_MS = 3_000;
/** 사용자 초안을 조건부로 지운 결과 */
type DraftDestroyOutcome =
@@ -241,44 +264,6 @@ const MAX_REFERENCES = 20;
/** 다운로드할 때 엔진에 요청하는 형식 — 클라이언트로는 어차피 octet-stream 으로 내려준다 */
const DOWNLOAD_TYPE = 'application/octet-stream';
type MethodResult =
| { ok: true; args: Record<string, unknown> }
| { ok: false; error: JmapError };
/** 호출 id·메서드 이름으로 응답을 찾는다 (onSuccessUpdateEmail 처럼 한 호출 id 에 응답이 둘일 수 있다) */
function findResult(
responses: JmapInvocation[],
callId: string,
method: string,
): MethodResult {
for (const [name, args, id] of responses) {
if (id !== callId) continue;
if (name === 'error') return { ok: false, error: args };
if (name === method) return { ok: true, args };
}
throw new BusinessException('MAIL_001', {
context: { reason: 'missing-response', method },
});
}
/** 성공 응답을 꺼낸다. 메서드 오류면 코드가 있는 예외로 바꾼다 */
function expectResult<T>(
responses: JmapInvocation[],
callId: string,
method: string,
): T {
const result = findResult(responses, callId, method);
if (!result.ok) throw jmapFailure(result.error, { method });
return result.args as T;
}
/** id 하나로 처리한 SetError — 엔진이 id 를 정규화해 키가 달라질 수 있어 첫 값을 본다 */
function firstSetError(
errors: Record<string, JmapError> | undefined,
): JmapError | undefined {
return errors ? Object.values(errors)[0] : undefined;
}
function toJmapAddress(address: MailAddress): {
name: string | null;
email: string;
@@ -555,11 +540,6 @@ function firstRefusal(
.find((error) => error?.type !== NOT_FOUND);
}
/** 엔진이 알려 준 상태 문자열인가 (비었거나 없으면 아니다) */
function isState(value: unknown): value is string {
return typeof value === 'string' && value !== '';
}
/**
* 새 메일 확인 값 — 메일함 상태와 메일 상태를 이어 붙인다 (클라이언트에게는 불투명).
* 각각 base64url 로 바꿔 '.' 으로 잇는다 — base64url 에는 '.' 이 없어 서로 다른 두 상태 쌍이 같은 값이 되지 않는다.
@@ -745,6 +725,8 @@ export class JmapMailProvider implements MailProvider {
private readonly jmap: JmapClient,
@Inject(CACHE_MANAGER) private readonly cache: Cache,
@Inject(MAIL_SETTINGS) private readonly settings: MailSettings,
/** 필터 · 부재중 자동 응답의 저장 자리 — 엔진의 Sieve 스크립트 하나 (jmap-filter-script.ts) */
private readonly filterScripts: JmapFilterScriptStore,
) {}
verifyCredentials(email: string, password: string): Promise<string | null> {
@@ -1180,66 +1162,30 @@ export class JmapMailProvider implements MailProvider {
return { processed: run.processed, remaining };
}
/** 부재중 자동 응답 (RFC 8621 §8 — 계정마다 하나) */
async getVacation(user: string): Promise<MailVacation> {
const responses = await this.jmap.call(
user,
(accountId) => [
['VacationResponse/get', { accountId, ids: [VACATION_ID] }, 'v'],
],
{ capabilities: [VACATION_CAPABILITY] },
);
const { list = [] } = expectResult<JmapGetResult<JmapVacationResponse>>(
responses,
'v',
'VacationResponse/get',
);
return toVacation(list[0]);
/**
* 부재중 자동 응답 — 필터와 같은 엔진 규칙(`arasystem` 스크립트)에 둔다. 그 스크립트가 아직 없으면 엔진의 VacationResponse 값이다
* (계약 2절 "메일 필터"의 저장 자리 — jmap-filter-script.ts)
*/
getVacation(user: string): Promise<MailVacation> {
return this.filterScripts.getVacation(user);
}
/**
* 부재중 자동 응답을 통째로 바꾼다 — 같은 요청에서 다시 읽은 값을 돌려준다(엔진이 저장한 그대로).
* 서식 본문은 늘 비운다 (toJmapVacation). 엔진이 거절하면 그 코드 (형식 거절은 VAL_001)
*/
async setVacation(
/** 부재중 자동 응답만 바꾼다 — 같은 스크립트의 필터 규칙은 그대로 둔다. 저장한 값을 돌려준다 */
setVacation(user: string, vacation: MailVacation): Promise<MailVacation> {
return this.filterScripts.setVacation(user, vacation);
}
/** 메일 필터 — 규칙 · 지문 · 동작하는지 (계약 2절 "메일 필터") */
getMailFilters(user: string): Promise<MailFilterSet> {
return this.filterScripts.getFilters(user);
}
/** 메일 필터를 통째로 바꾼다 — 그사이 규칙이 바뀌었으면 MAIL_012, 만든 스크립트가 한도를 넘으면 MAIL_013 */
setMailFilters(
user: string,
vacation: MailVacation,
): Promise<MailVacation> {
const responses = await this.jmap.call(
user,
(accountId) => [
[
'VacationResponse/set',
{ accountId, update: { [VACATION_ID]: toJmapVacation(vacation) } },
's',
],
['VacationResponse/get', { accountId, ids: [VACATION_ID] }, 'v'],
],
{ capabilities: [VACATION_CAPABILITY] },
);
const set = expectResult<JmapSetResult>(
responses,
's',
'VacationResponse/set',
);
const failure = firstSetError(set.notUpdated);
if (failure) {
throw jmapFailure(failure, { method: 'VacationResponse/set' });
}
if (
!set.updated ||
!Object.prototype.hasOwnProperty.call(set.updated, VACATION_ID)
) {
throw new BusinessException('MAIL_001', {
context: { reason: 'vacation-not-updated' },
});
}
const { list = [] } = expectResult<JmapGetResult<JmapVacationResponse>>(
responses,
'v',
'VacationResponse/get',
);
return toVacation(list[0]);
update: MailFilterUpdate,
): Promise<MailFilterSet> {
return this.filterScripts.setFilters(user, update);
}
async getUsage(user: string): Promise<MailUsage> {
@@ -1787,7 +1733,7 @@ export class JmapMailProvider implements MailProvider {
async sendMessage(
user: string,
message: OutgoingMessage,
): Promise<{ id: string }> {
): Promise<SentMessage> {
const startedAt = Date.now();
const budgetLeft = () => SEND_TOTAL_BUDGET_MS - (Date.now() - startedAt);
@@ -1939,7 +1885,8 @@ export class JmapMailProvider implements MailProvider {
const submitted = submission.ok
? (submission.args as JmapSetResult)
: undefined;
if (!submitted?.created?.send) {
const send = submitted?.created?.send;
if (!send) {
// 보내지 못한 초안이 임시보관함에 남지 않게 지운다
await this.discardDraft(user, draftId);
throw jmapFailure(
@@ -1949,7 +1896,51 @@ export class JmapMailProvider implements MailProvider {
}
this.warnIfNotMovedToSent(responses, user);
return { id: draftId };
// 4) 받는 사람별 결과 — 엔진이 그 자리에서 거절한 사람 (계약 2절 "받는 사람별 결과")
const delivery = await this.readSendResult(
user,
message,
send.id,
Math.min(SEND_RESULT_TIMEOUT_MS, budgetLeft() - SEND_DISCARD_RESERVE_MS),
);
if (delivery.isEveryoneRejected) {
// 아무에게도 가지 않았다 — 보내지 않은 것으로 다룬다. 보낸 사본은 조건 없이 지운다: 이 요청이 방금 만든 메일이고
// (초안을 만들어 제출했고 엔진이 보낸편지함으로 옮겼다) 응답 전이라 사용자는 아직 이 메일을 모른다.
// 지운 사본은 같은 키의 재시도(findSentMessage)에 걸리지 않는다 — 다시 보내면 새로 보낸다
const code = everyoneRejectedCode(delivery.rejected);
this.logger.warn(
`[${code}]${traceTag()} 엔진이 받는 사람을 모두 받지 않아 보내지 않은 것으로 다룹니다 — 보낸 사본을 지웁니다: user=${maskEmail(user)}, ${describeRejections(delivery.rejected)}`,
);
const discarded = await this.discardQuietly(
user,
draftId,
'받는 사람이 모두 받지 않은 보낸 사본',
{
remains: '보낸편지함에 한 통이 남습니다',
timeoutMs: Math.max(
1,
Math.min(JMAP_REQUEST_TIMEOUT_MS, budgetLeft()),
),
},
);
throw new BusinessException(code, {
context: {
reason: 'all-recipients-rejected',
rejected: delivery.rejected.length,
copyDiscarded: discarded,
},
});
}
if (delivery.rejected.length > 0) {
this.logger.log(
`${traceTag()} 엔진이 받는 사람 일부를 받지 않았습니다 — 나머지에게는 보냈습니다: user=${maskEmail(user)}, ${describeRejections(delivery.rejected)}`,
);
}
return {
id: draftId,
rejectedRecipients: toRejectedRecipients(delivery.rejected),
};
}
async findSentMessage(
@@ -2030,6 +2021,52 @@ export class JmapMailProvider implements MailProvider {
};
}
/**
* 이미 보낸 메일의 받지 않은 사람 — 같은 키의 재시도가 처음 결과를 돌려줄 때 (계약 2절 "받는 사람별 결과").
* 그 메일의 제출 기록을 찾아(EmailSubmission/query 의 emailIds) 같은 요청에서 결과를 읽고(get — #ids 참조, 발송 요청과 달리
* 엔진이 덧붙이는 응답이 없다) 이 요청의 받는 사람과 맞춘다. 제출 기록이 없거나 읽지 못하면 빈 배열 (읽지 못한 것은 경고).
* 모두 거절된 사본이 남아 있으면(지우지 못한 경우) 그대로 모두 돌려준다 — 같은 키 · 같은 내용의 재시도는 201 이 된다
*/
async getRejectedRecipients(
user: string,
sentId: string,
recipients: RecipientFields,
): Promise<RejectedRecipient[]> {
try {
const responses = await this.jmap.call(user, (accountId) => [
[
'EmailSubmission/query',
{ accountId, filter: { emailIds: [sentId] } },
'q',
],
[
'EmailSubmission/get',
{
accountId,
'#ids': {
resultOf: 'q',
name: 'EmailSubmission/query',
path: '/ids',
},
properties: ['deliveryStatus'],
},
'g',
],
]);
const { list = [] } = expectResult<JmapGetResult<JmapEmailSubmission>>(
responses,
'g',
'EmailSubmission/get',
);
return toRejectedRecipients(
readDeliveryResult(recipients, list).rejected,
);
} catch (error) {
this.warnDeliveryUnknown(user, describeFailure(error));
return [];
}
}
async uploadAttachment(
user: string,
file: UploadedFile,
@@ -2946,6 +2983,58 @@ export class JmapMailProvider implements MailProvider {
);
}
/**
* 방금 한 제출의 받는 사람별 결과 (계약 2절 "받는 사람별 결과") — 제출 응답(created.send)에는 없어 제출 다음에 따로 묻는다.
* 발송 요청 안에서 참조(#ids)로 읽지 않는 이유: onSuccessUpdateEmail 이 같은 호출 id 로 Email/set 응답을 덧붙여
* 엔진이 참조를 풀지 못한다 (invalidResultReference — Stalwart 0.16.27, 2026-10-28 확인).
* 제출은 이미 되었다 — 시간이 모자라거나 읽지 못하면 받은 것으로 본다 (경고).
* @param timeoutMs 쓸 수 있는 시간 — SEND_RESULT_MIN_WINDOW_MS 보다 적으면 묻지 않는다
*/
private async readSendResult(
user: string,
recipients: RecipientFields,
submissionId: string | undefined,
timeoutMs: number,
): Promise<DeliveryResult> {
if (!submissionId || timeoutMs < SEND_RESULT_MIN_WINDOW_MS) {
this.warnDeliveryUnknown(
user,
submissionId ? `no-time(${Math.max(0, timeoutMs)}ms)` : 'no-id',
);
return DELIVERED_TO_ALL;
}
try {
const responses = await this.jmap.call(
user,
(accountId) => [
[
'EmailSubmission/get',
{ accountId, ids: [submissionId], properties: ['deliveryStatus'] },
'd',
],
],
{ timeoutMs },
);
const { list = [] } = expectResult<JmapGetResult<JmapEmailSubmission>>(
responses,
'd',
'EmailSubmission/get',
);
return readDeliveryResult(recipients, list);
} catch (error) {
this.warnDeliveryUnknown(user, describeFailure(error));
return DELIVERED_TO_ALL;
}
}
/** 받는 사람별 결과를 모른다 — 경고만 (메일은 이미 제출했다, 받은 것으로 본다) */
private warnDeliveryUnknown(user: string, reason: string): void {
this.logger.warn(
`[MAIL_001]${traceTag()} 받는 사람별 발송 결과를 읽지 못해 모두 받은 것으로 봅니다 — 제출은 되었습니다: user=${maskEmail(user)}, reason=${reason}`,
);
}
/** 이 메일로 만든 제출(EmailSubmission)이 있는가 */
private async wasSubmitted(user: string, emailId: string): Promise<boolean> {
const responses = await this.jmap.call(user, (accountId) => [
@@ -3100,7 +3189,7 @@ export class JmapMailProvider implements MailProvider {
outcome = await this.destroyUserDraft(user, oldId, draftsId, null);
} catch (error) {
this.logger.warn(
`[MAIL_001]${traceTag()} 옛 임시 저장본을 지우지 못해 두 통이 남을 수 있습니다: user=${maskEmail(user)}, reason=${error instanceof Error ? error.message : String(error)}`,
`[MAIL_001]${traceTag()} 옛 임시 저장본을 지우지 못해 두 통이 남을 수 있습니다: user=${maskEmail(user)}, reason=${describeFailure(error)}`,
);
return;
}
@@ -3198,23 +3287,38 @@ export class JmapMailProvider implements MailProvider {
);
}
/** 메일을 지운다 — 실패해도 던지지 않고 경고만 남긴다 (보정용) */
/**
* 이 요청이 방금 만든 메일을 지운다 — 실패해도 던지지 않고 경고만 남긴다 (보정용).
* 조건 없이 지운다 — 방금 만든 메일이라 사용자가 아직 모른다 (사용자 메일의 영구 삭제는 조건부 지우기 — EmailSetChanges)
* @param what 로그 문장의 대상 — '방금 만든 임시 저장본' 등
* @param options.remains 지우지 못했을 때 남는 곳 (로그 문장) · options.timeoutMs 제한 시간 (기본 10초)
* @returns 지웠거나 이미 없으면 true
*/
private async discardQuietly(
user: string,
id: string,
what: string,
): Promise<void> {
options: { readonly remains?: string; readonly timeoutMs?: number } = {},
): Promise<boolean> {
try {
const responses = await this.jmap.call(user, (accountId) => [
['Email/set', { accountId, destroy: [id] }, 'x'],
]);
const responses = await this.jmap.call(
user,
(accountId) => [['Email/set', { accountId, destroy: [id] }, 'x']],
options.timeoutMs === undefined
? undefined
: { timeoutMs: options.timeoutMs },
);
const set = expectResult<JmapSetResult>(responses, 'x', 'Email/set');
const failure = firstSetError(set.notDestroyed);
if (failure && failure.type !== 'notFound') throw new Error(failure.type);
if (failure && failure.type !== NOT_FOUND) {
throw jmapFailure(failure, { method: 'Email/set' });
}
return true;
} catch (error) {
this.logger.warn(
`[MAIL_001]${traceTag()} ${what}을 지우지 못했습니다 — 임시보관함에 한 통이 더 남습니다: user=${maskEmail(user)}, reason=${error instanceof Error ? error.message : String(error)}`,
`[MAIL_001]${traceTag()} ${what}을 지우지 못했습니다 — ${options.remains ?? '임시보관함에 한 통이 더 남습니다'}: user=${maskEmail(user)}, reason=${describeFailure(error)}`,
);
return false;
}
}
@@ -3294,10 +3398,11 @@ export class JmapMailProvider implements MailProvider {
['Email/set', { accountId, destroy: [draftId] }, 'x'],
]);
const set = expectResult<JmapSetResult>(responses, 'x', 'Email/set');
if (firstSetError(set.notDestroyed)) throw new Error('notDestroyed');
const failure = firstSetError(set.notDestroyed);
if (failure) throw jmapFailure(failure, { method: 'Email/set' });
} catch (error) {
this.logger.warn(
`[MAIL_001]${traceTag()} 보내지 못한 초안 정리에 실패했습니다: user=${maskEmail(user)}, reason=${error instanceof Error ? error.message : String(error)}`,
`[MAIL_001]${traceTag()} 보내지 못한 초안 정리에 실패했습니다: user=${maskEmail(user)}, reason=${describeFailure(error)}`,
);
}
}
@@ -0,0 +1,51 @@
import { BusinessException } from '@/common/errors/business.exception';
import { jmapFailure } from './jmap.errors';
import type { JmapError, JmapInvocation } from './jmap.types';
/**
* JMAP 응답 읽기 — 어댑터(jmap-mail.provider.ts)와 엔진 규칙 저장(jmap-filter-script.ts)이 같이 쓴다.
* 이 모듈의 타입 · 함수는 `modules/mail/jmap/` 밖으로 내보내지 않는다.
*/
export type MethodResult =
| { ok: true; args: Record<string, unknown> }
| { ok: false; error: JmapError };
/** 호출 id·메서드 이름으로 응답을 찾는다 (onSuccessUpdateEmail 처럼 한 호출 id 에 응답이 둘일 수 있다) */
export function findResult(
responses: JmapInvocation[],
callId: string,
method: string,
): MethodResult {
for (const [name, args, id] of responses) {
if (id !== callId) continue;
if (name === 'error') return { ok: false, error: args };
if (name === method) return { ok: true, args };
}
throw new BusinessException('MAIL_001', {
context: { reason: 'missing-response', method },
});
}
/** 성공 응답을 꺼낸다. 메서드 오류면 코드가 있는 예외로 바꾼다 */
export function expectResult<T>(
responses: JmapInvocation[],
callId: string,
method: string,
): T {
const result = findResult(responses, callId, method);
if (!result.ok) throw jmapFailure(result.error, { method });
return result.args as T;
}
/** id 하나로 처리한 SetError — 엔진이 id 를 정규화해 키가 달라질 수 있어 첫 값을 본다 */
export function firstSetError(
errors: Record<string, JmapError> | undefined,
): JmapError | undefined {
return errors ? Object.values(errors)[0] : undefined;
}
/** 엔진이 알려 준 상태 문자열인가 (비었거나 없으면 아니다) */
export function isState(value: unknown): value is string {
return typeof value === 'string' && value !== '';
}
@@ -1,4 +1,6 @@
import { BusinessException } from '@/common/errors/business.exception';
import {
describeFailure,
isMissingAttachmentBlob,
jmapFailure,
mapJmapErrorType,
@@ -101,3 +103,47 @@ describe('JMAP 오류 매핑', () => {
expect(isMissingAttachmentBlob(undefined)).toBe(false);
});
});
describe('로그용 실패 요약 (describeFailure)', () => {
it('코드가 있는 실패는 코드와 사유 — HTTP 상태 · JMAP 오류 종류가 있으면 함께 (설명 문구 · 주소는 넣지 않는다)', () => {
expect(
describeFailure(
new BusinessException('MAIL_001', { context: { reason: 'network' } }),
),
).toBe('MAIL_001/network');
expect(
describeFailure(
new BusinessException('MAIL_001', {
context: { reason: 'http-error', call: 'api', status: 502 },
}),
),
).toBe('MAIL_001/http-error/502');
// 메서드 오류 · SetError (jmapFailure) — 사유 없이 종류만 있다
expect(
describeFailure(jmapFailure({ type: 'serverFail' }, { method: 'x' })),
).toBe('MAIL_001/serverFail');
expect(
describeFailure(
jmapFailure(
{
type: 'forbidden',
description: 'someone@example.net may not be destroyed',
},
{ method: 'Email/set' },
),
),
).toBe('AUTH_003/forbidden');
expect(describeFailure(new BusinessException('REQ_001'))).toBe('REQ_001');
});
it('코드가 없는 실패는 오류 이름과 원인 코드 — 오류가 아니면 unknown', () => {
const socket = Object.assign(new Error('other side closed'), {
code: 'UND_ERR_SOCKET',
});
expect(
describeFailure(new TypeError('terminated', { cause: socket })),
).toBe('TypeError/UND_ERR_SOCKET');
expect(describeFailure(new Error('준비한 응답이 없습니다'))).toBe('Error');
expect(describeFailure('문자열')).toBe('unknown');
});
});
@@ -72,3 +72,28 @@ export function jmapFailure(
},
});
}
/**
* 로그용 실패 요약 — 코드와 원인만 (엔진의 설명 문구 · 주소는 넣지 않는다).
* 코드가 있는 실패는 `코드/사유/HTTP 상태/JMAP 오류 종류` 가운데 있는 것 — 예: MAIL_001/timeout · MAIL_001/http-error/502 ·
* MAIL_001/serverFail(메서드 오류 · SetError — jmapFailure 의 jmapType) · AUTH_003/forbidden.
* 스트림을 읽다 끊긴 것은 오류 이름과 원인 코드 (예: TypeError/UND_ERR_SOCKET — fetch 본문의 연결이 끊김)
*/
export function describeFailure(error: unknown): string {
if (error instanceof BusinessException) {
const { reason, status, jmapType } = error.context ?? {};
return [error.code, reason, status, jmapType]
.filter(
(part): part is string | number =>
(typeof part === 'string' && part !== '') || typeof part === 'number',
)
.join('/');
}
if (!(error instanceof Error)) return 'unknown';
const { cause } = error as { cause?: unknown };
const causeCode =
cause instanceof Error
? ((cause as NodeJS.ErrnoException).code ?? cause.name)
: null;
return causeCode ? `${error.name}/${causeCode}` : error.name;
}
+5 -21
View File
@@ -334,7 +334,8 @@ export function toUsage(quota: JmapQuota): MailUsage {
}
/**
* 부재중 자동 응답 → 우리 모양 (계약 2절 "부재중 자동 응답").
* 엔진의 VacationResponse → 우리 모양 (계약 2절 "부재중 자동 응답") — 필터 · 부재중을 두는 우리 스크립트가 아직 없을 때 가져온다
* (4단계에 저장한 값 이어받기 — jmap-filter-script.ts).
* 엔진에 없는 값(null)의 제목·내용은 빈 문자열, 날짜는 ISO 8601 UTC 로 — 한 번도 설정하지 않은 계정은
* `{ isEnabled: false, fromDate: null, toDate: null, subject: '', textBody: '' }` 가 된다. 서식 본문(htmlBody)은 쓰지 않는다.
*/
@@ -351,31 +352,14 @@ export function toVacation(
}
/**
* 날짜를 JMAP UTCDate 로 — 초 단위까지, 소수 초 없이 'Z' 로 끝낸다
* (RFC 8620 §1.4: 소수 초가 0 이면 쓰지 않는다. 화면이 보내는 값은 0시라 잃는 것이 없다)
* 날짜를 JMAP UTCDate 꼴로 — 초 단위까지, 소수 초 없이 'Z' 로 끝낸다 (RFC 8620 §1.4: 소수 초가 0 이면 쓰지 않는다).
* 부재중 자동 응답의 기간을 엔진 규칙(Sieve 의 currentdate)에 넣을 때 쓴다 — 엔진이 VacationResponse 로 만들던 모양과 같다.
* 화면이 보내는 값은 0시라 잃는 것이 없다
*/
export function toJmapUtcDate(iso: string): string {
return new Date(Date.parse(iso)).toISOString().replace(/\.\d+Z$/, 'Z');
}
/**
* 우리 모양 → VacationResponse 전체 교체 값. 빈 제목·내용은 null(엔진의 "정하지 않음" — 끈 채로 저장할 때만 비어 있다).
* 서식 본문은 늘 비운다 — 다른 메일 프로그램이 넣어 둔 것도 지운다 (글자만 보낸다, 계약 2절)
*/
export function toJmapVacation(
vacation: MailVacation,
): Record<string, unknown> {
return {
isEnabled: vacation.isEnabled,
fromDate:
vacation.fromDate === null ? null : toJmapUtcDate(vacation.fromDate),
toDate: vacation.toDate === null ? null : toJmapUtcDate(vacation.toDate),
subject: vacation.subject === '' ? null : vacation.subject,
textBody: vacation.textBody === '' ? null : vacation.textBody,
htmlBody: null,
};
}
/** 발신 ID — 직원 주소와 같은 것을 고르고, 없으면 첫 번째 */
export function pickIdentity(
identities: JmapIdentity[],
@@ -111,12 +111,45 @@ export interface JmapVacationResponse {
htmlBody?: string | null;
}
/**
* RFC 9661 SieveScript — 계정마다 켜진 스크립트는 하나다. 엔진의 VacationResponse 도 `vacation` 이라는 스크립트다
* (Stalwart 0.16.27, 2026-10-28 확인 — 그 스크립트는 고칠 수 없다)
*/
export interface JmapSieveScript {
id: string;
/** 계정 안에서 하나 — 같은 이름으로 만들면 alreadyExists */
name?: string | null;
isActive?: boolean;
/** 스크립트 글(application/sieve) — 내려받기 주소로 읽는다 */
blobId?: string;
}
export interface JmapIdentity {
id: string;
name?: string;
email?: string;
}
/**
* RFC 8621 §7 EmailSubmission 의 받는 사람 하나의 결과 (deliveryStatus 의 값).
* Stalwart 0.16.27 예 (2026-10-28 확인): 거절 `{ delivered: "no", smtpReply: "550 5.1.2 Mailbox does not exist." }`,
* 대기열에 들어감 `{ delivered: "unknown", smtpReply: "250 2.1.5 Queued" }`
*/
export interface JmapDeliveryStatus {
/** 받는 쪽(엔진의 SMTP)이 마지막으로 한 응답 — 글에 주소가 들어 있을 수 있어 로그에는 상태 코드만 남긴다 */
smtpReply?: string | null;
/** queued · yes · no · unknown — "no" 면 이 사람에게 가지 않는다 */
delivered?: string | null;
displayed?: string | null;
}
/** RFC 8621 §7 EmailSubmission 중 이 어댑터가 읽는 부분 */
export interface JmapEmailSubmission {
id: string;
/** 받는 사람 주소 → 결과 (엔진이 모르면 null) */
deliveryStatus?: Record<string, JmapDeliveryStatus | null> | null;
}
export interface JmapGetResult<T> {
/** 이 종류(Email 등)의 지금 상태 — 무엇이든 바뀌면 달라진다 (Foo/set 의 ifInState 에 쓴다) */
state?: string;
@@ -0,0 +1,96 @@
import {
base64TextBody,
encodeHeaderWords,
plainTextMimeEntity,
} from './mime-text';
/** encoded-word 를 읽는 쪽처럼 되돌린다 — 낱말 사이의 빈칸은 버린다 (RFC 2047 §6.2) */
function decodeWords(encoded: string): string {
const bytes = encoded.split(' ').map((word) => {
const match = /^=\?UTF-8\?B\?([A-Za-z0-9+/=]*)\?=$/.exec(word);
if (!match) throw new Error(`encoded-word 가 아니다: ${word}`);
return Buffer.from(match[1], 'base64');
});
return Buffer.concat(bytes).toString('utf8');
}
describe('encodeHeaderWords — 부재중 제목 (RFC 2047)', () => {
it('짧은 제목은 낱말 하나 — UTF-8 의 base64', () => {
expect(encodeHeaderWords('부재중입니다')).toBe(
`=?UTF-8?B?${Buffer.from('부재중입니다').toString('base64')}?=`,
);
});
it('ASCII 만 남는다 — 따옴표 · 역슬래시 · ${ 가 글자에 있어도 결과에는 없다', () => {
const encoded = encodeHeaderWords('휴가 "중" \\ ${x}');
expect(encoded).toMatch(/^[\x21-\x7e ]+$/);
expect(encoded).not.toMatch(/["\\]|\$\{/);
expect(decodeWords(encoded)).toBe('휴가 "중" \\ ${x}');
});
it('긴 제목은 75자 이하의 낱말들로 나눠 빈칸으로 잇는다 — 한글 150자는 한 낱말에 15자씩 10낱말', () => {
const subject = '가'.repeat(150);
const words = encodeHeaderWords(subject).split(' ');
expect(words).toHaveLength(10);
for (const word of words) expect(word.length).toBeLessThanOrEqual(75);
expect(decodeWords(words.join(' '))).toBe(subject);
});
it('글자 경계에서 나눈다 — 한 글자의 바이트나 이모지(서로게이트 쌍)를 두 낱말에 나누지 않는다', () => {
// 'a' 하나 뒤에 3바이트 글자들 · 4바이트 이모지 — 45바이트 경계가 글자 중간에 걸리는 모양
for (const subject of [
`a${'가'.repeat(40)}`,
`ab${'🌴'.repeat(30)}`,
`${'😀가a'.repeat(20)}`,
]) {
const words = encodeHeaderWords(subject).split(' ');
for (const word of words) {
expect(word.length).toBeLessThanOrEqual(75);
// 낱말 하나만 되돌려도 깨진 글자(U+FFFD)가 없다 = 글자 경계에서 나뉘었다
expect(decodeWords(word)).not.toContain('\ufffd');
}
expect(decodeWords(words.join(' '))).toBe(subject);
}
});
it('빈 제목은 빈 문자열', () => {
expect(encodeHeaderWords('')).toBe('');
});
});
describe('base64TextBody · plainTextMimeEntity — 부재중 내용', () => {
it('줄바꿈은 모두 CRLF 로 맞춘 뒤 base64 — LF · CR 홀로 · CRLF', () => {
const encoded = base64TextBody('첫 줄\n둘째\r셋째\r\n넷째');
expect(Buffer.from(encoded, 'base64').toString('utf8')).toBe(
'첫 줄\r\n둘째\r\n셋째\r\n넷째',
);
});
it('base64 는 76자 줄로 나눈다 (RFC 2045) — 한글 600자', () => {
const text = '나'.repeat(600);
const lines = base64TextBody(text).split('\r\n');
expect(lines.length).toBeGreaterThan(1);
for (const line of lines.slice(0, -1)) expect(line).toHaveLength(76);
expect(lines[lines.length - 1].length).toBeLessThanOrEqual(76);
expect(Buffer.from(lines.join(''), 'base64').toString('utf8')).toBe(text);
});
it('MIME 개체 — 형식 · 인코딩 머리, 빈 줄, 본문, 마지막 줄바꿈 (엔진이 만들던 모양)', () => {
expect(plainTextMimeEntity('휴가입니다.')).toBe(
'Content-Type: text/plain; charset="utf-8"\r\n' +
'Content-Transfer-Encoding: base64\r\n' +
'\r\n' +
`${Buffer.from('휴가입니다.').toString('base64')}\r\n`,
);
});
it('본문에 ${ · 역슬래시가 있어도 base64 라 결과에는 없다 — 빈 내용은 빈 본문', () => {
const entity = plainTextMimeEntity('${x} \\ "따옴표"');
expect(entity.split('\r\n\r\n')[1]).not.toMatch(/["\\]|\$\{/);
expect(plainTextMimeEntity('')).toBe(
'Content-Type: text/plain; charset="utf-8"\r\nContent-Transfer-Encoding: base64\r\n\r\n\r\n',
);
});
});
@@ -0,0 +1,83 @@
/**
* 부재중 자동 응답을 엔진 규칙(Sieve)에 넣을 때의 글 모양 (순수 함수) — 제목은 RFC 2047 encoded-word, 내용은 base64 MIME 본문.
* 둘 다 ASCII 로만 나오므로 Sieve 문자열 안에 따옴표 · 역슬래시 · `${` 가 생기지 않는다
* (엔진은 require 와 상관없이 문자열의 `${…}` 를 늘 변수로 펼친다 — 계약 2절 "만드는 Sieve", 2026-10-28 확인).
*/
const CRLF = '\r\n';
const ENCODED_WORD_PREFIX = '=?UTF-8?B?';
const ENCODED_WORD_SUFFIX = '?=';
/** encoded-word 하나의 최대 길이 (RFC 2047 §2) */
const ENCODED_WORD_MAX_LENGTH = 75;
/**
* encoded-word 하나에 넣는 UTF-8 바이트 — base64 는 3바이트를 4자로 쓰므로 앞뒤 표시(12자)를 빼고 75자에 드는 만큼.
* (75 - 12) / 4 를 내림한 15묶음 × 3 = 45바이트 → base64 60자 → 낱말 72자
*/
const ENCODED_WORD_MAX_BYTES =
Math.floor(
(ENCODED_WORD_MAX_LENGTH -
ENCODED_WORD_PREFIX.length -
ENCODED_WORD_SUFFIX.length) /
4,
) * 3;
/** MIME 본문의 base64 한 줄 길이 (RFC 2045 §6.8 — 76자 이하) */
const BASE64_LINE_LENGTH = 76;
/**
* 머리글 값(제목)을 RFC 2047 encoded-word(B, UTF-8)로 — 낱말 하나가 75자를 넘지 않게 **글자 경계에서** 나눠 빈칸으로 잇는다
* (한 글자의 UTF-8 바이트를 두 낱말에 나누지 않는다 — RFC 2047 §5. 낱말 사이의 빈칸은 읽는 쪽이 버린다 — §6.2).
* 코드 포인트 단위로 세어 이모지(서로게이트 쌍)도 나누지 않는다. 빈 문자열이면 빈 문자열
*/
export function encodeHeaderWords(text: string): string {
const words: string[] = [];
let pending: Buffer[] = [];
let pendingBytes = 0;
const flush = () => {
words.push(
`${ENCODED_WORD_PREFIX}${Buffer.concat(pending).toString('base64')}${ENCODED_WORD_SUFFIX}`,
);
pending = [];
pendingBytes = 0;
};
for (const character of text) {
const bytes = Buffer.from(character, 'utf8');
if (
pendingBytes > 0 &&
pendingBytes + bytes.length > ENCODED_WORD_MAX_BYTES
) {
flush();
}
pending.push(bytes);
pendingBytes += bytes.length;
}
if (pendingBytes > 0) flush();
return words.join(' ');
}
/** 글자 본문을 base64 로 — 줄바꿈은 모두 CRLF 로 맞춘 뒤(LF · CR 홀로 · CRLF), 76자 줄로 나눈다 */
export function base64TextBody(text: string): string {
const normalized = text.replace(/\r\n|\r|\n/g, CRLF);
const encoded = Buffer.from(normalized, 'utf8').toString('base64');
const lines: string[] = [];
for (let start = 0; start < encoded.length; start += BASE64_LINE_LENGTH) {
lines.push(encoded.slice(start, start + BASE64_LINE_LENGTH));
}
return lines.join(CRLF);
}
/**
* 글자만 있는 MIME 개체 — 머리(형식 · 인코딩), 빈 줄, base64 본문, 마지막 줄바꿈.
* 엔진이 VacationResponse 로 만들던 `vacation :mime` 의 모양 그대로다 (본문만 base64 — 같은 날 확인)
*/
export function plainTextMimeEntity(text: string): string {
return [
'Content-Type: text/plain; charset="utf-8"',
'Content-Transfer-Encoding: base64',
'',
base64TextBody(text),
'',
].join(CRLF);
}
@@ -0,0 +1,313 @@
import type { MailFilterRule, MailVacation } from '@/modules/mail/mail.types';
import {
FILTER_SOURCE_MARKER,
NEVER_CONFIGURED_VACATION,
filterSourceLine,
readFilterSource,
type FilterScriptSource,
} from './filter-source';
import { buildFilterScript } from './sieve-script';
const b64 = (text: string) => Buffer.from(text, 'utf8').toString('base64');
const rule = (
id: string,
extra: Partial<MailFilterRule> = {},
): MailFilterRule => ({
id,
name: '',
isEnabled: true,
match: 'all',
conditions: [{ field: 'subject', operator: 'contains', value: '견적' }],
actions: {
moveToMailboxId: null,
markAsRead: true,
flag: false,
forwardTo: null,
},
...extra,
});
const vacationOn: MailVacation = {
isEnabled: true,
fromDate: '2026-11-02T15:00:00.000Z',
toDate: '2026-11-07T15:00:00.000Z',
subject: '부재중입니다',
textBody: '휴가입니다.\n급한 일은 이도윤 님께.',
};
/** 대표 원본 — 하나라도(옮기기 · 표시 둘) · 꺼 둔 규칙 · 모든 메일(표시 · 전달) · 모두(받는 사람 · 본문 포함하지 않음, 없는 메일함) */
const representative: FilterScriptSource = {
rules: [
rule('r1', {
name: '거래처',
match: 'any',
conditions: [
{
field: 'from',
operator: 'contains',
value: '@hanbit-partners.co.kr',
},
{ field: 'subject', operator: 'notContains', value: '"광고" \\ 끝' },
],
actions: {
moveToMailboxId: 'j',
markAsRead: true,
flag: true,
forwardTo: null,
},
}),
rule('r2', { isEnabled: false, name: '꺼 둔 규칙' }),
rule('r3', {
match: 'always',
conditions: [],
actions: {
moveToMailboxId: null,
markAsRead: false,
flag: true,
forwardTo: 'doyun.lee@arasystem.test',
},
}),
rule('r4', {
conditions: [
{ field: 'to', operator: 'contains', value: 'team@arasystem.test' },
{ field: 'body', operator: 'notContains', value: '수신 거부' },
],
actions: {
moveToMailboxId: 'nosuchid0',
markAsRead: false,
flag: false,
forwardTo: null,
},
}),
],
vacation: vacationOn,
};
/** 스크립트의 줄 — 머리 주석 두 줄(설명 · 원본)은 따로 본다 */
const linesOf = (text: string) => text.split('\r\n');
/** 스크립트 머리의 원본 — 이 판의 원본이 아니면 null */
const sourceOf = (text: string): FilterScriptSource | null => {
const reading = readFilterSource(text);
return reading.status === 'ok' ? reading.source : null;
};
describe('buildFilterScript — 규칙 · 부재중 → 엔진 규칙(Sieve)', () => {
it('대표 원본의 스크립트 그대로 — 머리 주석(설명 · 원본), 쓰는 확장, 스팸 막기(엔진의 분류 표시) 안에 부재중 → 켠 규칙 차례로', () => {
const { text } = buildFilterScript(representative);
const lines = linesOf(text);
expect(lines[0]).toMatch(/^# /);
expect(lines[1]).toBe(filterSourceLine(representative));
expect(lines.slice(2)).toEqual([
'require ["body", "copy", "date", "fileinto", "imap4flags", "mailbox", "mailboxid", "relational", "vacation"];',
'if not header :matches "X-Spam-Score" "spam*" {',
' if currentdate :zone "+0000" :value "ge" "iso8601" "2026-11-02T15:00:00Z" {',
' if currentdate :zone "+0000" :value "le" "iso8601" "2026-11-07T15:00:00Z" {',
` vacation :mime :subject "=?UTF-8?B?${b64('부재중입니다')}?=" "Content-Type: text/plain; charset=\\"utf-8\\"`,
'Content-Transfer-Encoding: base64',
'',
b64('휴가입니다.\r\n급한 일은 이도윤 님께.'),
'";',
' }',
' }',
' if anyof (header :contains "from" "@hanbit-partners.co.kr", not header :contains "subject" "\\"광고\\" \\\\ 끝") {',
' addflag ["\\\\Seen", "\\\\Flagged"];',
' fileinto :mailboxid "j" "INBOX";',
' stop;',
' }',
' if true {',
' addflag ["\\\\Flagged"];',
' redirect :copy "doyun.lee@arasystem.test";',
' }',
' if allof (header :contains ["to", "cc"] "team@arasystem.test", not body :text :contains "수신 거부") {',
' fileinto :mailboxid "nosuchid0" "INBOX";',
' stop;',
' }',
'}',
'',
]);
});
it('스팸 막기는 엔진의 분류 표시(X-Spam-Score 의 spam)를 본다 — spamtest 점수 · 그 확장은 쓰지 않는다', () => {
const { text } = buildFilterScript(representative);
const lines = linesOf(text);
expect(lines[3]).toBe('if not header :matches "X-Spam-Score" "spam*" {');
expect(text).not.toContain('spamtest');
expect(text).not.toContain('comparator-i;ascii-numeric');
expect(text).not.toContain('X-Spam-Status');
});
it('줄바꿈은 모두 CRLF — 홀로 있는 LF 가 없고 마지막 줄도 CRLF 로 끝난다', () => {
const { text } = buildFilterScript(representative);
expect(text.replace(/\r\n/g, '')).not.toMatch(/[\r\n]/);
expect(text.endsWith('\r\n')).toBe(true);
});
it('꺼 둔 규칙은 원본에만 있다 — Sieve 에는 없다', () => {
const { text } = buildFilterScript(representative);
const sieve = linesOf(text).slice(2).join('\n');
expect(sourceOf(text)?.rules.map((r) => r.id)).toEqual([
'r1',
'r2',
'r3',
'r4',
]);
// r2 의 조건(제목에 '견적')은 Sieve 에 없다
expect(sieve).not.toContain('견적');
});
it('찾을 글의 따옴표 · 역슬래시만 이스케이프한다 — 한국어 · 그 밖의 글자는 그대로', () => {
const { text } = buildFilterScript({
rules: [
rule('q', {
conditions: [
{ field: 'subject', operator: 'contains', value: 'a"b\\c 가$나{}' },
],
}),
],
vacation: NEVER_CONFIGURED_VACATION,
});
expect(text).toContain(
'if allof (header :contains "subject" "a\\"b\\\\c 가$나{}") {',
);
});
it('짝 없는 서로게이트 — 원본 JSON 은 \\udc00 꼴로 남고, 엔진에 올리는 UTF-8 에서 Sieve 의 글만 U+FFFD 가 된다 (바이트 수도 그 값)', () => {
const built = buildFilterScript({
rules: [
rule('s1', {
conditions: [
{ field: 'subject', operator: 'contains', value: 'a\udc00b' },
],
}),
],
vacation: NEVER_CONFIGURED_VACATION,
});
const uploaded = Buffer.from(built.text, 'utf8').toString('utf8');
expect(linesOf(uploaded)[1]).toContain('"value":"a\\udc00b"');
expect(uploaded).toContain('header :contains "subject" "a\ufffdb"');
expect(sourceOf(uploaded)?.rules[0].conditions[0].value).toBe('a\udc00b');
expect(built.bytes).toBe(Buffer.byteLength(uploaded, 'utf8'));
});
it('하나뿐인 조건도 allof · anyof 로 묶는다 — 쓰는 확장만 require 에 (표시만 하는 규칙)', () => {
const lines = linesOf(
buildFilterScript({
rules: [rule('one')],
vacation: NEVER_CONFIGURED_VACATION,
}).text,
);
expect(lines.slice(2)).toEqual([
'require ["imap4flags"];',
'if not header :matches "X-Spam-Score" "spam*" {',
' if allof (header :contains "subject" "견적") {',
' addflag ["\\\\Seen"];',
' }',
'}',
'',
]);
});
it('켠 규칙도 부재중도 없으면 머리 주석만 — require · 스팸 막기도 없다 (엔진이 받는 것을 확인함)', () => {
for (const rules of [[], [rule('off', { isEnabled: false })]]) {
const { text } = buildFilterScript({
rules,
vacation: NEVER_CONFIGURED_VACATION,
});
const lines = linesOf(text);
expect(lines).toHaveLength(3);
expect(lines[1].startsWith(FILTER_SOURCE_MARKER)).toBe(true);
expect(lines[2]).toBe('');
}
});
describe('부재중', () => {
const vacationLines = (vacation: MailVacation) =>
linesOf(buildFilterScript({ rules: [], vacation }).text).slice(2);
it('날짜가 없으면 기간 검사 없이 — date 확장도 쓰지 않는다', () => {
const lines = vacationLines({
...vacationOn,
fromDate: null,
toDate: null,
});
expect(lines[0]).toBe('require ["vacation"];');
expect(lines[2]).toMatch(/^ {2}vacation :mime :subject /);
});
it('시작만 · 끝만 있으면 그 검사 하나 — 시작은 ge, 끝은 le (엔진이 만들던 모양), 견주는 시각은 UTC(:zone "+0000")', () => {
const fromOnly = vacationLines({ ...vacationOn, toDate: null });
expect(fromOnly[2]).toBe(
' if currentdate :zone "+0000" :value "ge" "iso8601" "2026-11-02T15:00:00Z" {',
);
expect(fromOnly[3]).toMatch(/^ {4}vacation :mime /);
const toOnly = vacationLines({ ...vacationOn, fromDate: null });
expect(toOnly[2]).toBe(
' if currentdate :zone "+0000" :value "le" "iso8601" "2026-11-07T15:00:00Z" {',
);
});
it('날짜는 소수 초 없는 UTC 로 쓴다', () => {
const lines = vacationLines({
...vacationOn,
fromDate: '2026-11-02T15:00:00.750Z',
});
expect(lines[2]).toContain('"2026-11-02T15:00:00Z"');
});
it('제목이 비었으면 :subject 를 빼 엔진이 정하게 둔다', () => {
const lines = vacationLines({
...vacationOn,
fromDate: null,
toDate: null,
subject: '',
});
expect(lines[2]).toMatch(
/^ {2}vacation :mime "Content-Type: text\/plain; charset=\\"utf-8\\"$/,
);
});
it('꺼져 있으면 부재중이 없다 — 원본에는 남는다', () => {
const off = { ...vacationOn, isEnabled: false };
const { text } = buildFilterScript({ rules: [rule('a')], vacation: off });
expect(text).not.toContain('vacation :mime');
expect(linesOf(text)[2]).toBe('require ["imap4flags"];');
expect(sourceOf(text)?.vacation).toEqual(off);
});
it('제목 · 내용의 ${ · 따옴표 · 역슬래시는 Sieve 글에 그대로 나오지 않는다 — encoded-word · base64 (원본 주석에만)', () => {
const { text } = buildFilterScript({
rules: [],
vacation: {
...vacationOn,
subject: '부재 ${x} "중" \\',
textBody: '${unicode:41} \\ "따옴표"',
},
});
const sieve = linesOf(text).slice(2).join('\r\n');
expect(sieve).not.toContain('${');
expect(sieve).not.toContain('\\\\');
});
});
it('바이트 수와 가장 긴 문자열의 바이트를 함께 준다 — 한도는 부르는 쪽이 본다', () => {
const built = buildFilterScript({
rules: [],
vacation: {
...vacationOn,
subject: '가'.repeat(150),
textBody: '나'.repeat(600),
},
});
expect(built.bytes).toBe(Buffer.byteLength(built.text, 'utf8'));
// 내용 1,800바이트 → base64 2,400자 + 줄바꿈 · MIME 머리 · 따옴표
expect(built.longestStringBytes).toBeGreaterThan(2_400);
expect(built.longestStringBytes).toBeLessThan(4_096);
});
});
@@ -0,0 +1,264 @@
import type {
MailFilterCondition,
MailFilterField,
MailFilterRule,
MailVacation,
} from '@/modules/mail/mail.types';
import { filterSourceLine, type FilterScriptSource } from './filter-source';
import { toJmapUtcDate } from './jmap.mapper';
import { encodeHeaderWords, plainTextMimeEntity } from './mime-text';
/**
* 메일 필터 · 부재중 자동 응답 → 엔진 규칙(Sieve 스크립트, RFC 5228) — 순수 함수. 계약 2절 "만드는 Sieve" (Stalwart 0.16.27, 2026-10-28 확인한 모양):
*
* ```
* # 설명 한 줄
* # arasystem-source: {원본 JSON — filter-source.ts}
* require [쓰는 확장만];
* if not header :matches "X-Spam-Score" "spam*" { ← 엔진이 스팸으로 분류한 메일에는 아무것도 하지 않는다
* 부재중 — 켜져 있으면 (기간은 currentdate, UTC)
* 규칙 — 켠 것만 위에서부터: if <조건> { addflag → redirect :copy → fileinto :mailboxid + stop }
* }
* ```
*
* - 스팸: 엔진은 규칙의 옮기기가 스팸메일함 분류를 이기므로 막는다 — 엔진의 **분류 표시**를 본다 (SPAM_VERDICT_HEADER).
* `spamtest` 의 점수는 쓰지 않는다: 점수를 정수로 내린 값(10에서 멈춤)이라 기준(엔진 설정 `scoreSpam` — 지금 5)과 상관없는 문턱을
* 우리가 따로 정하게 된다 — 9.70점 스팸이 "10 이상"을 지나 규칙대로 옮겨지고 위조한 보낸 사람에게 부재중 응답까지 나갔다
* (2026-10-28 실엔진). 분류 표시를 보면 엔진의 기준을 바꿔도 엔진의 분류를 그대로 따른다
* - 부재중을 규칙보다 먼저 둔다 — 옮기는 규칙의 `stop` 이 부재중을 건너뛰지 않게
* - 표시(`addflag`)는 옮기기보다 먼저 — 엔진은 fileinto 하는 순간의 표시만 붙인다. 옮기지 않은 메일은 마지막 표시로 받은편지함에 남는다
* - 옮길 메일함이 없으면 대체 이름 "INBOX" 로 받은편지함에 들어온다 (`:mailboxid` 는 "fileinto" · "mailbox" · "mailboxid" 가 다 있어야 한다)
* - 전달은 사본을 남긴다(`redirect :copy`). 엔진은 한 통에 리다이렉트 하나만 한다 — 두 번째는 조용히 버린다
* - 대소문자는 ASCII 만 무시한다 (엔진 기본 비교)
* - 글은 모두 따옴표 문자열 — `\` 와 `"` 만 이스케이프한다. `${` 는 받지 않는다(요청 검증) — 엔진이 늘 변수로 펼친다
* - 줄바꿈은 CRLF. 규칙 글이 없으면(켠 규칙도 부재중도 없음) 머리 주석만 남는다
*/
/**
* 만든 스크립트의 상한 (UTF-8 바이트) — 엔진 한도(102,400)보다 낮게 둔다. 넘으면 저장하지 않고 400 MAIL_013 (계약 2절 "크기")
*/
export const FILTER_SCRIPT_MAX_BYTES = 90_000;
/** 엔진의 문자열 하나 한도 (UTF-8 바이트) — 넘는 문자열이 있으면 엔진이 받지 않는다(invalidScript). 만든 문자열이 넘으면 MAIL_013 */
export const SIEVE_STRING_MAX_BYTES = 4_096;
/**
* 엔진의 스팸 분류 표시 — 우리 스크립트가 돌 때 이미 붙어 있다: `spam, score=9.70` · `ham, score=2.20` (Stalwart 0.16.27, 2026-10-28 확인).
* - 머리글 검사는 같은 이름이 여럿이면 하나라도 맞을 때 참이다 — 바깥에서 넣어 온 같은 이름의 머리글은 지워지지 않고 엔진 것과 함께 남지만,
* 위조한 `ham` 으로 진짜 스팸을 숨길 수는 없다. 위조한 `spam` 은 그 메일에 우리 규칙이 적용되지 않게 할 뿐이다(엔진 판정대로 들어간다)
* - `X-Spam-Status: Yes|No` 는 쓰지 않는다 — 스크립트가 돌 때는 없고, 끝난 뒤 실제로 들어간 곳에 따라 붙는다(우리 규칙이 옮긴 스팸은 No)
* - 엔진의 스팸 필터는 인증 없는 세션(바깥에서 오는 메일)에만 돈다 — 사내 메일에는 이 머리글이 없어 규칙이 그대로 적용된다
*/
const SPAM_VERDICT_HEADER = 'X-Spam-Score';
/** 분류 표시가 스팸인지 (`:matches` — 대소문자는 ASCII 만 무시한다) */
const SPAM_VERDICT_PATTERN = 'spam*';
/**
* 부재중 기간을 견주는 시간대 — 기간 값(UTC, 'Z')과 같게 둔다. 엔진 서버의 시간대에 기대지 않는다
* (개발 엔진에서 `currentdate :zone "+0000" :value "ge" "iso8601" "2020-01-01T00:00:00Z"` 가 참 · "lt" 가 거짓 — 2026-10-28 확인)
*/
const VACATION_PERIOD_ZONE = '+0000';
/** 옮길 메일함이 없을 때 쓰는 대체 이름 — 받은편지함 */
const FALLBACK_MAILBOX = 'INBOX';
const CRLF = '\r\n';
const INDENT = ' ';
/** 첫 줄 — 엔진의 관리 화면 등에서 이 스크립트를 보는 사람에게 */
const DESCRIPTION_LINE =
'# ARA SYSTEM 웹메일의 필터 · 부재중 자동 응답 — 웹메일이 아래 원본(JSON)으로 만든다. 여기서 고친 것은 웹메일에서 저장할 때 덮어쓴다.';
/** 견줄 곳 → Sieve 검사 (찾을 글을 받는다) — 표시 이름 · 주소 · 인코딩된 한국어 제목 · 본문 모두 맞는다 (같은 날 확인) */
const FIELD_TESTS: Record<MailFilterField, (value: string) => string> = {
from: (value) => `header :contains "from" ${value}`,
to: (value) => `header :contains ["to", "cc"] ${value}`,
subject: (value) => `header :contains "subject" ${value}`,
body: (value) => `body :text :contains ${value}`,
};
/** require 에 넣는 확장 — 이 차례로 쓴다 (쓰는 것만 고른다). 스팸 막기의 `header :matches` 는 기본 명령이라 확장이 없다 */
type SieveExtension =
| 'body'
| 'copy'
| 'date'
| 'fileinto'
| 'imap4flags'
| 'mailbox'
| 'mailboxid'
| 'relational'
| 'vacation';
const EXTENSION_ORDER: readonly SieveExtension[] = [
'body',
'copy',
'date',
'fileinto',
'imap4flags',
'mailbox',
'mailboxid',
'relational',
'vacation',
];
/** 만든 스크립트 */
export interface BuiltFilterScript {
readonly text: string;
/** 글 전체의 UTF-8 바이트 */
readonly bytes: number;
/** 가장 긴 따옴표 문자열의 UTF-8 바이트 (이스케이프 포함) — SIEVE_STRING_MAX_BYTES 와 견준다 */
readonly longestStringBytes: number;
}
/** 스크립트를 만드는 동안 쓴 확장 · 가장 긴 문자열을 모은다 */
class ScriptWriter {
readonly extensions = new Set<SieveExtension>();
longestStringBytes = 0;
/** Sieve 따옴표 문자열 — `\` 와 `"` 만 이스케이프한다 (RFC 5228 §2.4.2) */
quote(value: string): string {
const quoted = `"${value.replace(/[\\"]/g, '\\$&')}"`;
this.longestStringBytes = Math.max(
this.longestStringBytes,
Buffer.byteLength(quoted, 'utf8'),
);
return quoted;
}
use(...extensions: SieveExtension[]): void {
for (const extension of extensions) this.extensions.add(extension);
}
}
function indent(depth: number, line: string): string {
return `${INDENT.repeat(depth)}${line}`;
}
function conditionTest(
writer: ScriptWriter,
condition: MailFilterCondition,
): string {
if (condition.field === 'body') writer.use('body');
const test = FIELD_TESTS[condition.field](writer.quote(condition.value));
return condition.operator === 'notContains' ? `not ${test}` : test;
}
/** 규칙의 조건 — 모두 allof, 하나라도 anyof, 모든 메일 true */
function ruleTest(writer: ScriptWriter, rule: MailFilterRule): string {
if (rule.match === 'always') return 'true';
const tests = rule.conditions.map((condition) =>
conditionTest(writer, condition),
);
return `${rule.match === 'all' ? 'allof' : 'anyof'} (${tests.join(', ')})`;
}
/** 규칙의 할 일 — 표시 → 전달 → 옮기기(+ 멈춤). 있는 것만 */
function ruleCommands(writer: ScriptWriter, rule: MailFilterRule): string[] {
const { moveToMailboxId, markAsRead, flag, forwardTo } = rule.actions;
const commands: string[] = [];
const flags = [
...(markAsRead ? ['\\Seen'] : []),
...(flag ? ['\\Flagged'] : []),
];
if (flags.length > 0) {
writer.use('imap4flags');
commands.push(
`addflag [${flags.map((name) => writer.quote(name)).join(', ')}];`,
);
}
if (forwardTo !== null) {
writer.use('copy');
commands.push(`redirect :copy ${writer.quote(forwardTo)};`);
}
if (moveToMailboxId !== null) {
writer.use('fileinto', 'mailbox', 'mailboxid');
commands.push(
`fileinto :mailboxid ${writer.quote(moveToMailboxId)} ${writer.quote(FALLBACK_MAILBOX)};`,
'stop;',
);
}
return commands;
}
function ruleBlock(
writer: ScriptWriter,
rule: MailFilterRule,
depth: number,
): string[] {
return [
indent(depth, `if ${ruleTest(writer, rule)} {`),
...ruleCommands(writer, rule).map((command) => indent(depth + 1, command)),
indent(depth, '}'),
];
}
/**
* 부재중 — 엔진이 VacationResponse 로 만들던 모양에 시간대만 더했다: 기간은 currentdate(시작 ge · 끝 le — 끝 시각 그 1초에도 답한다, 지금과 같다),
* 견주는 시각은 UTC(`:zone "+0000"` — 기간 값도 UTC 다), `vacation :mime` 에 base64 본문, 제목은 encoded-word.
* `:days` · `:handle` 은 쓰지 않는다(엔진 기본값 — 같은 사람에게 다시 답하는 간격).
* 제목이 비었으면(VacationResponse 에서 가져온 값 등) `:subject` 를 빼 엔진이 정하게 둔다
*/
function vacationBlock(
writer: ScriptWriter,
vacation: MailVacation,
depth: number,
): string[] {
writer.use('vacation');
const subject = encodeHeaderWords(vacation.subject);
const command = `vacation :mime ${subject ? `:subject ${writer.quote(subject)} ` : ''}${writer.quote(plainTextMimeEntity(vacation.textBody))};`;
/** 기간 검사 하나 — 지금 시각(UTC)을 그 시각과 견준다 */
const period = (relation: 'ge' | 'le', iso: string) =>
`currentdate :zone ${writer.quote(VACATION_PERIOD_ZONE)} :value "${relation}" "iso8601" ${writer.quote(toJmapUtcDate(iso))}`;
const periods = [
...(vacation.fromDate === null ? [] : [period('ge', vacation.fromDate)]),
...(vacation.toDate === null ? [] : [period('le', vacation.toDate)]),
];
if (periods.length > 0) writer.use('date', 'relational');
const lines = periods.map((test, level) =>
indent(depth + level, `if ${test} {`),
);
lines.push(indent(depth + periods.length, command));
for (let level = periods.length - 1; level >= 0; level -= 1) {
lines.push(indent(depth + level, '}'));
}
return lines;
}
/** 규칙 · 부재중 원본으로 엔진 규칙 글을 만든다 — 크기 한도는 부르는 쪽이 본다(FILTER_SCRIPT_MAX_BYTES · SIEVE_STRING_MAX_BYTES) */
export function buildFilterScript(
source: FilterScriptSource,
): BuiltFilterScript {
const writer = new ScriptWriter();
const body: string[] = [];
if (source.vacation.isEnabled) {
body.push(...vacationBlock(writer, source.vacation, 1));
}
for (const rule of source.rules) {
if (rule.isEnabled) body.push(...ruleBlock(writer, rule, 1));
}
const lines = [DESCRIPTION_LINE, filterSourceLine(source)];
if (body.length > 0) {
const spamCheck = `if not header :matches ${writer.quote(SPAM_VERDICT_HEADER)} ${writer.quote(SPAM_VERDICT_PATTERN)} {`;
const extensions = EXTENSION_ORDER.filter((extension) =>
writer.extensions.has(extension),
);
// 빈 목록(`require [];`)은 문법 오류다 — 할 일마다 확장을 쓰므로 늘 하나 이상이지만, 없으면 줄을 뺀다
if (extensions.length > 0) {
lines.push(
`require [${extensions.map((extension) => writer.quote(extension)).join(', ')}];`,
);
}
lines.push(spamCheck, ...body, '}');
}
const text = `${lines.join(CRLF)}${CRLF}`;
return {
text,
bytes: Buffer.byteLength(text, 'utf8'),
longestStringBytes: writer.longestStringBytes,
};
}
@@ -0,0 +1,88 @@
import { Body, Controller, Get, Put, UseGuards } from '@nestjs/common';
import { ApiCookieAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import {
ApiErrorResponse,
ApiStandardResponse,
} from '@/common/decorators/api-standard-response.decorator';
import {
CurrentUser,
type AuthenticatedUser,
} from '@/common/decorators/current-user.decorator';
import { JwtAuthGuard } from '@/common/guards/jwt-auth.guard';
import { MailFilterSetDto, MailFilterUpdateDto } from './dto/mail-filter.dto';
import {
MAIL_FILTERS_ROUTE,
MAX_MAIL_FILTER_CONDITIONS,
MAX_MAIL_FILTER_NAME_LENGTH,
MAX_MAIL_FILTER_RULES,
MAX_MAIL_FILTER_VALUE_LENGTH,
} from './mail.constants';
import { MailService } from './mail.service';
/** 적용 규칙 — 두 엔드포인트의 설명에 함께 쓴다 (계약 2절 "메일 필터"의 적용 순서) */
const APPLY_NOTE =
'새로 온 메일을 위의 규칙부터 차례로 견주고, 맞는 규칙의 할 일을 모두 한다(읽음 · 중요는 겹쳐 붙는다). ' +
'메일함으로 옮기는 규칙에 맞으면 거기서 멈춘다. 전달은 한 통에 한 곳(엔진 한도 — 두 번째는 엔진이 버린다)이고 사본을 남긴다. ' +
'스팸으로 판정된 메일에는 아무 규칙도 적용하지 않는다(부재중 응답도 하지 않는다). 꺼 둔 규칙 · 이미 받은 메일에는 적용하지 않는다. ' +
'필터와 부재중 자동 응답은 메일 엔진의 규칙 하나에 함께 저장된다.';
// 메일 필터 (계약 2절 "메일 필터", 6단계)
@ApiTags('Mail')
@ApiCookieAuth()
@UseGuards(JwtAuthGuard)
@ApiErrorResponse('AUTH_001', '로그인 필요 (쿠키 없음·만료)')
@ApiErrorResponse('MAIL_001', '메일 엔진 장애')
@Controller(MAIL_FILTERS_ROUTE)
export class MailFilterController {
constructor(private readonly mail: MailService) {}
@Get()
@ApiOperation({
summary: '메일 필터',
description:
`${APPLY_NOTE} 한 번도 저장하지 않았으면 규칙 없이 isActive: true 다. ` +
'메일 엔진에서 다른 규칙이 켜졌거나 저장해 둔 규칙을 읽을 수 없으면 isActive: false 다(읽을 수 없으면 규칙 없음) — 지금 목록 그대로 저장하면 다시 켜진다.',
})
@ApiStandardResponse(MailFilterSetDto, {
description: '지금 규칙 · 지문(version) · 동작하는지',
})
get(@CurrentUser() user: AuthenticatedUser) {
return this.mail.getMailFilters(user);
}
@Put()
@ApiOperation({
summary: '메일 필터 저장 (전체 교체)',
description:
'켜기 · 끄기 · 순서 바꾸기 · 지우기 · 고치기 모두 규칙 전체를 받은 version 과 함께 보낸다. 부재중 자동 응답은 그대로 둔다. ' +
`규칙 ${MAX_MAIL_FILTER_RULES}개까지(id 가 겹치지 않음) · 이름 앞뒤 공백을 뺀 0~${MAX_MAIL_FILTER_NAME_LENGTH}자 · ` +
`조건은 all · any 면 1~${MAX_MAIL_FILTER_CONDITIONS}개, always 면 없음 · 찾을 글은 앞뒤 공백을 뺀 1~${MAX_MAIL_FILTER_VALUE_LENGTH}자(제어 문자 · "\${" 없음) · ` +
'할 일 하나 이상 · 옮길 메일함은 보낸편지함 · 임시보관함이 아님(없는 id 는 받는다) · 전달은 사내 주소이고 자기 주소가 아님. ' +
'글은 앞뒤 공백을 지워 저장한다. 그사이 다른 곳에서 규칙이 바뀌었으면 저장하지 않고 409 MAIL_012 — 지금 규칙이 보낸 규칙과 이미 같으면 그대로 저장한다. ' +
`저장하면 isActive 는 true 가 된다. ${APPLY_NOTE}`,
})
@ApiStandardResponse(MailFilterSetDto, {
description: '저장한 규칙(앞뒤 공백을 지운 값) · 새 지문 · 동작하는지',
})
@ApiErrorResponse(
'VAL_001',
`VAL_001 형식 · 한도(규칙 ${MAX_MAIL_FILTER_RULES}개 · 조건 ${MAX_MAIL_FILTER_CONDITIONS}개 · 이름 ${MAX_MAIL_FILTER_NAME_LENGTH}자 · 찾을 글 ${MAX_MAIL_FILTER_VALUE_LENGTH}자) · ` +
'겹친 id · 묶는 방법과 조건 수가 맞지 않음 · 할 일 없음 · 제어 문자 · "${" · 바깥 주소나 자기 주소로 전달 · 보낸편지함 · 임시보관함으로 옮김 · 모르는 필드 ' +
'/ MAIL_013 만든 엔진 규칙이 엔진 한도를 넘음(조건을 줄이거나 쓰지 않는 필터를 지운다) — 모두 400, 저장하지 않았다',
)
@ApiErrorResponse(
'MAIL_012',
'그사이 다른 곳(다른 탭 · 기기)에서 규칙이 바뀌었다 — 저장하지 않았다 (다시 불러온 뒤 고친다). 부재중 응답만 바뀐 것은 걸리지 않는다',
)
@ApiErrorResponse(
'MAIL_001',
'메일 엔진 장애 · 저장하는 사이 엔진의 규칙이 계속 바뀜(세 번). 엔진이 답하기 전에 끊겼으면 저장됐을 수도 있다 — 같은 요청을 다시 보내면 된다(이미 같으면 그대로 저장한다)',
)
update(
@CurrentUser() user: AuthenticatedUser,
@Body() dto: MailFilterUpdateDto,
) {
return this.mail.updateMailFilters(user, dto);
}
}
@@ -0,0 +1,185 @@
import {
MAX_MAIL_FILTER_CONDITIONS,
MAX_MAIL_FILTER_RULES,
} from './mail.constants';
import {
findFilterRulesProblem,
hasFilterAction,
hasSieveVariable,
} from './mail-filters';
import type { MailFilterRule } from './mail.types';
const rule = (
id: string,
extra: Partial<MailFilterRule> = {},
): MailFilterRule => ({
id,
name: '거래처 메일',
isEnabled: true,
match: 'all',
conditions: [
{ field: 'from', operator: 'contains', value: '@hanbit-partners.co.kr' },
],
actions: {
moveToMailboxId: 'j',
markAsRead: false,
flag: false,
forwardTo: null,
},
...extra,
});
const noActions = {
moveToMailboxId: null,
markAsRead: false,
flag: false,
forwardTo: null,
};
describe('findFilterRulesProblem — 계약 2절 "메일 필터"의 타입', () => {
it('맞는 목록이면 null — 모든 메일(조건 없음) · 꺼 둔 규칙 · 이름 없음 · 조건 5개 · 빈 목록', () => {
expect(
findFilterRulesProblem([
rule('a'),
rule('b', { match: 'always', conditions: [] }),
rule('c', { isEnabled: false, name: '' }),
rule('d', {
match: 'any',
conditions: Array.from(
{ length: MAX_MAIL_FILTER_CONDITIONS },
(_, index) => ({
field: 'subject' as const,
operator: 'notContains' as const,
value: `광고 ${index} "따옴표" \\ {중괄호} $달러`,
}),
),
}),
rule('e', {
actions: { ...noActions, forwardTo: 'doyun.lee@arasystem.test' },
}),
]),
).toBeNull();
expect(findFilterRulesProblem([])).toBeNull();
});
it(`규칙은 ${MAX_MAIL_FILTER_RULES}개까지 · id 는 겹치지 않는다`, () => {
const many = Array.from({ length: MAX_MAIL_FILTER_RULES + 1 }, (_, i) =>
rule(`r${i}`),
);
expect(findFilterRulesProblem(many)).toEqual({
index: null,
reason: 'too-many-rules',
});
expect(findFilterRulesProblem(many.slice(1))).toBeNull();
expect(
findFilterRulesProblem([rule('same'), rule('other'), rule('same')]),
).toEqual({ index: 2, reason: 'duplicate-rule-id' });
});
it.each<[string, Partial<MailFilterRule>, string]>([
['모든 메일인데 조건이 있음', { match: 'always' }, 'conditions-for-match'],
[
'모두인데 조건이 없음',
{ match: 'all', conditions: [] },
'conditions-for-match',
],
[
'하나라도인데 조건이 없음',
{ match: 'any', conditions: [] },
'conditions-for-match',
],
[
`조건 ${MAX_MAIL_FILTER_CONDITIONS + 1}개`,
{
conditions: Array.from(
{ length: MAX_MAIL_FILTER_CONDITIONS + 1 },
() => ({
field: 'subject' as const,
operator: 'contains' as const,
value: '견적',
}),
),
},
'too-many-conditions',
],
['할 일이 하나도 없음', { actions: noActions }, 'no-action'],
['id 형식', { id: '규칙 1' }, 'rule-id'],
['이름 51자', { name: 'x'.repeat(51) }, 'rule-name'],
['이름에 줄바꿈', { name: '거래처\n메일' }, 'rule-name'],
[
'찾을 글이 공백뿐',
{
conditions: [
{ field: 'subject', operator: 'contains', value: ' \u3000 ' },
],
},
'condition-value-length',
],
[
'찾을 글 201자',
{
conditions: [
{ field: 'subject', operator: 'contains', value: 'x'.repeat(201) },
],
},
'condition-value-length',
],
[
'찾을 글에 탭',
{ conditions: [{ field: 'body', operator: 'contains', value: 'a\tb' }] },
'condition-value-control',
],
[
'찾을 글에 ${',
{ conditions: [{ field: 'to', operator: 'contains', value: 'a${b}' }] },
'condition-value-variable',
],
[
'옮길 메일함 id 형식',
{ actions: { ...noActions, moveToMailboxId: 'a b' } },
'move-mailbox-id',
],
[
'전달 주소 모양이 아님',
{ actions: { ...noActions, forwardTo: 'doyun.lee' } },
'forward-address',
],
[
'전달 주소에 ${',
{ actions: { ...noActions, forwardTo: '${x}@arasystem.test' } },
'forward-address-variable',
],
])('%s — 그 규칙의 위치와 까닭', (_label, extra, reason) => {
expect(findFilterRulesProblem([rule('ok'), rule('bad', extra)])).toEqual({
index: 1,
reason,
});
});
it('꺼 둔 규칙도 같게 본다 — 켜면 그대로 적용된다', () => {
expect(
findFilterRulesProblem([
rule('off', { isEnabled: false, actions: noActions }),
]),
).toEqual({ index: 0, reason: 'no-action' });
});
});
describe('hasSieveVariable · hasFilterAction', () => {
it('${ 가 있으면 true — $ 나 { 하나씩은 괜찮다', () => {
expect(hasSieveVariable('${x}')).toBe(true);
expect(hasSieveVariable('a${')).toBe(true);
expect(hasSieveVariable('$ {x}')).toBe(false);
expect(hasSieveVariable('{$}')).toBe(false);
});
it('할 일 — 옮기기 · 읽음 · 중요 · 전달 가운데 하나라도', () => {
expect(hasFilterAction(noActions)).toBe(false);
expect(hasFilterAction({ ...noActions, markAsRead: true })).toBe(true);
expect(hasFilterAction({ ...noActions, flag: true })).toBe(true);
expect(hasFilterAction({ ...noActions, moveToMailboxId: 'a' })).toBe(true);
expect(
hasFilterAction({ ...noActions, forwardTo: 'a@arasystem.test' }),
).toBe(true);
});
});
+142
View File
@@ -0,0 +1,142 @@
import {
MAIL_FILTER_RULE_ID_PATTERN,
MAIL_ID_PATTERN,
MAX_EMAIL_LENGTH,
MAX_MAIL_FILTER_CONDITIONS,
MAX_MAIL_FILTER_NAME_LENGTH,
MAX_MAIL_FILTER_RULES,
MAX_MAIL_FILTER_VALUE_LENGTH,
SIEVE_VARIABLE_MARKER,
SINGLE_LINE_TEXT_PATTERN,
} from './mail.constants';
import type {
MailFilterActions,
MailFilterCondition,
MailFilterField,
MailFilterMatch,
MailFilterOperator,
MailFilterRule,
} from './mail.types';
/**
* 메일 필터 규칙 (계약 2절 "메일 필터"의 타입) — 메일 엔진과 무관한 순수 규칙.
* 요청 검증(dto/mail-filter.dto.ts)이 칸마다 형식을 보고, 서비스가 칸 사이의 규칙을 이것으로 한 번 더 본다.
* 어댑터가 엔진에 저장해 둔 원본을 읽을 때(jmap/filter-source.ts)도 같은 규칙으로 본다 — 어긋나면 깨진 원본이다.
*/
/** 견줄 곳 · 견주는 방법 · 묶는 방법 — 요청 검증과 원본 읽기가 같이 쓴다 */
export const MAIL_FILTER_FIELDS = [
'from',
'to',
'subject',
'body',
] as const satisfies readonly MailFilterField[];
export const MAIL_FILTER_OPERATORS = [
'contains',
'notContains',
] as const satisfies readonly MailFilterOperator[];
export const MAIL_FILTER_MATCHES = [
'all',
'any',
'always',
] as const satisfies readonly MailFilterMatch[];
/**
* 전달 주소의 대략적인 모양 — 자세한 형식은 요청 검증(IsEmail)이 본다.
* 여기서는 저장한 원본을 다시 쓸 때 엔진 규칙 글을 지킬 만큼만 본다 (빈칸 · `@` 하나)
*/
const FORWARD_ADDRESS_SHAPE = /^[^\s@]+@[^\s@]+$/;
/** 규칙 목록이 어긋난 까닭 — 로그용 (규칙의 글은 넣지 않는다). index 는 몇 번째 규칙인지 (목록 전체의 문제면 null) */
export interface FilterRulesProblem {
readonly index: number | null;
readonly reason: string;
}
/** 엔진이 변수로 펼칠 표시(`${`)가 있는가 — 찾을 글 · 전달 주소에 쓰지 않는다 */
export function hasSieveVariable(value: string): boolean {
return value.includes(SIEVE_VARIABLE_MARKER);
}
/** 할 일이 하나라도 있는가 (옮기기 · 읽음 · 중요 · 전달) */
export function hasFilterAction(actions: MailFilterActions): boolean {
return (
actions.moveToMailboxId !== null ||
actions.markAsRead ||
actions.flag ||
actions.forwardTo !== null
);
}
/** 조건 하나 — 찾을 글은 공백이 아닌 글자 1자 이상 · 200자 이하, 제어 문자 · `${` 없음 */
function conditionProblem(condition: MailFilterCondition): string | null {
const { value } = condition;
if (value.trim() === '' || value.length > MAX_MAIL_FILTER_VALUE_LENGTH) {
return 'condition-value-length';
}
if (!SINGLE_LINE_TEXT_PATTERN.test(value)) return 'condition-value-control';
if (hasSieveVariable(value)) return 'condition-value-variable';
return null;
}
/** 할 일 — 하나 이상, 옮길 메일함 id 형식, 전달 주소 모양 (사내 주소인지 · 자기 주소가 아닌지는 서비스가 본다) */
function actionsProblem(actions: MailFilterActions): string | null {
if (!hasFilterAction(actions)) return 'no-action';
const { moveToMailboxId, forwardTo } = actions;
if (moveToMailboxId !== null && !MAIL_ID_PATTERN.test(moveToMailboxId)) {
return 'move-mailbox-id';
}
if (forwardTo !== null) {
if (
forwardTo.length > MAX_EMAIL_LENGTH ||
!FORWARD_ADDRESS_SHAPE.test(forwardTo) ||
!SINGLE_LINE_TEXT_PATTERN.test(forwardTo)
) {
return 'forward-address';
}
if (hasSieveVariable(forwardTo)) return 'forward-address-variable';
}
return null;
}
/** 규칙 하나 — id · 이름 · 묶는 방법에 맞는 조건 수 · 조건 · 할 일 */
function ruleProblem(rule: MailFilterRule): string | null {
if (!MAIL_FILTER_RULE_ID_PATTERN.test(rule.id)) return 'rule-id';
if (
rule.name.length > MAX_MAIL_FILTER_NAME_LENGTH ||
!SINGLE_LINE_TEXT_PATTERN.test(rule.name)
) {
return 'rule-name';
}
const count = rule.conditions.length;
if (rule.match === 'always' ? count !== 0 : count < 1) {
return 'conditions-for-match';
}
if (count > MAX_MAIL_FILTER_CONDITIONS) return 'too-many-conditions';
for (const condition of rule.conditions) {
const problem = conditionProblem(condition);
if (problem) return problem;
}
return actionsProblem(rule.actions);
}
/**
* 규칙 목록이 계약에 맞는지 — 어긋난 첫 까닭, 맞으면 null.
* 50개 이하 · id 가 겹치지 않음 · 규칙마다(id 형식 · 이름 50자 · 모든 메일은 조건 0개, 나머지는 1~5개 · 찾을 글 · 할 일 하나 이상).
* 꺼 둔 규칙도 같게 본다 (켜면 그대로 적용된다)
*/
export function findFilterRulesProblem(
rules: readonly MailFilterRule[],
): FilterRulesProblem | null {
if (rules.length > MAX_MAIL_FILTER_RULES) {
return { index: null, reason: 'too-many-rules' };
}
const ids = new Set<string>();
for (const [index, rule] of rules.entries()) {
if (ids.has(rule.id)) return { index, reason: 'duplicate-rule-id' };
ids.add(rule.id);
const reason = ruleProblem(rule);
if (reason) return { index, reason };
}
return null;
}
+39 -3
View File
@@ -64,9 +64,18 @@ export const MAX_BULK_MESSAGE_IDS = 100;
*/
export const MAX_MAILBOX_NAME_LENGTH = 64;
/** 부재중 자동 응답의 제목 · 내용 최대 길이 — JS 문자열 길이 (계약 2절 "부재중 자동 응답"). 켤 때는 둘 다 1자 이상 */
export const MAX_VACATION_SUBJECT_LENGTH = 200;
export const MAX_VACATION_TEXT_LENGTH = 4_000;
/**
* 부재중 자동 응답의 제목 · 내용 최대 길이 — JS 문자열 길이 (계약 2절 "부재중 자동 응답"의 "길이 한도의 근거"). 켤 때는 둘 다 1자 이상.
* - 켤 때: 150자 · 600자 — 서비스가 본다(400 VAL_001). 엔진은 UTF-8 **바이트**로 막는다 — 제목 511바이트 · 내용 2,047바이트
* (Stalwart 0.16.27, 2026-10-28 확인 — 넘으면 invalidProperties). JS 문자열 한 자(UTF-16 코드 단위)는 많아야 3바이트라
* (한글 3 · 이모지는 2자에 4바이트) 150자 · 600자면 어떤 글이어도 넘지 않는다
* - 꺼 둔 채로: 4단계 한도 200자 · 4,000자까지 — 요청 검증이 늘 본다(..._WHEN_OFF). 4단계에 엔진 한도 안으로 저장해 둔 긴 글
* (영문 내용 1,000자 등)이 켜져 있어도 끌 수 있게 — 화면은 꺼 두면 칸이 흐려 고칠 수 없는데, 끄기는 늘 저장되어야 한다
*/
export const MAX_VACATION_SUBJECT_LENGTH = 150;
export const MAX_VACATION_TEXT_LENGTH = 600;
export const MAX_VACATION_SUBJECT_LENGTH_WHEN_OFF = 200;
export const MAX_VACATION_TEXT_LENGTH_WHEN_OFF = 4_000;
/**
* 한 줄 글자 — 제어 문자(C0 · DEL · C1 — 줄바꿈 · 탭 포함)가 없다. 메일함 이름 · 부재중 자동 응답 제목이 쓴다 (계약 2절)
@@ -81,6 +90,33 @@ export const MULTI_LINE_TEXT_PATTERN =
// eslint-disable-next-line no-control-regex
/^[^\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f-\u009f]*$/;
/**
* 메일 필터 라우트 경로 (`/api/mail-filters`) — 계약 2절 "메일 필터"
*/
export const MAIL_FILTERS_ROUTE = 'mail-filters';
/**
* 메일 필터의 한도 (계약 2절 "메일 필터"의 타입) — 길이는 앞뒤 공백을 뺀 값의 JS 문자열 길이. 넘으면 400 VAL_001
* - 규칙 50개 · 조건(모두 · 하나라도) 1~5개 — 모든 메일(always)은 조건 0개
* - 이름 0~50자 · 찾을 글 1~200자
*/
export const MAX_MAIL_FILTER_RULES = 50;
export const MAX_MAIL_FILTER_CONDITIONS = 5;
export const MAX_MAIL_FILTER_NAME_LENGTH = 50;
export const MAX_MAIL_FILTER_VALUE_LENGTH = 200;
/** 필터 규칙 id — 클라이언트가 만든다(UUID). 목록 안에서 겹치지 않는다 */
export const MAIL_FILTER_RULE_ID_PATTERN = /^[A-Za-z0-9_-]{1,64}$/;
/** 필터 지문(version) — 서버가 만든 값(base64url)을 그대로 돌려받는다 */
export const MAIL_FILTER_VERSION_PATTERN = /^[A-Za-z0-9_-]{1,128}$/;
/**
* 메일 엔진(Stalwart)이 규칙 글 안에서 변수로 펼치는 표시 — require 와 상관없이 늘 펼친다(2026-10-28 확인).
* 펼쳐져 빈 값이 되면 `:contains` 가 모든 메일에 맞으므로 찾을 글 · 전달 주소에 쓰지 않는다 (계약 2절 "만드는 Sieve")
*/
export const SIEVE_VARIABLE_MARKER = '${';
/** 받는 사람 추천의 거르는 글자(q) 최대 길이 — 계약 2절 "받는 사람 추천" */
export const MAX_SUGGESTION_QUERY_LENGTH = 100;
+5
View File
@@ -11,9 +11,11 @@ import { MailSignatureModule } from '@/modules/mail-signature/mail-signature.mod
import { isAllowedAttachment } from './attachment.policy';
import { DraftController } from './draft.controller';
import { JmapClient } from './jmap/jmap.client';
import { JmapFilterScriptStore } from './jmap/jmap-filter-script';
import { JmapMailProvider } from './jmap/jmap-mail.provider';
import { MailAttachmentController } from './mail-attachment.controller';
import { MailEventsController } from './mail-events.controller';
import { MailFilterController } from './mail-filter.controller';
import { MailEventsService } from './mail-events.service';
import {
MailUploadTempCleanup,
@@ -108,6 +110,7 @@ function attachmentUploadOptions(config: ConfigService): MulterModuleOptions {
MailStateController,
MailEventsController,
MailVacationController,
MailFilterController,
MessageController,
MessageDeletionController,
MailAttachmentController,
@@ -121,6 +124,8 @@ function attachmentUploadOptions(config: ConfigService): MulterModuleOptions {
useFactory: createMailSettings,
},
JmapClient,
// 필터 · 부재중 자동 응답의 저장 자리(엔진의 Sieve 스크립트 하나) — 어댑터 안에서만 쓴다
JmapFilterScriptStore,
{ provide: MAIL_PROVIDER, useClass: JmapMailProvider },
MailService,
// 실시간 알림 — 직원마다 엔진 연결 하나를 나눠 쓰고, 서버 종료 때 열린 연결을 먼저 닫는다 (beforeApplicationShutdown)
+41 -4
View File
@@ -9,6 +9,8 @@ import type {
MailboxBulkResult,
MailboxCreate,
MailboxDeletion,
MailFilterSet,
MailFilterUpdate,
MailState,
MailUsage,
MailVacation,
@@ -24,7 +26,10 @@ import type {
MessageUpdate,
OutgoingMessage,
PasswordChangeOutcome,
RecipientFields,
RejectedRecipient,
SavedDraft,
SentMessage,
SentMessageRecord,
UploadedFile,
} from './mail.types';
@@ -99,12 +104,32 @@ export interface MailProvider {
*/
emptyMailbox(user: string, id: string): Promise<MailboxBulkResult>;
/** 부재중 자동 응답 — 엔진 값이 없으면(null) 제목 · 내용은 빈 문자열 */
/**
* 부재중 자동 응답 — 한 번도 설정하지 않았으면 끈 상태 · 빈 제목과 내용.
* 저장은 메일 필터와 같은 엔진 규칙에 둔다 (계약 2절 "메일 필터"의 저장 자리)
*/
getVacation(user: string): Promise<MailVacation>;
/** 부재중 자동 응답을 통째로 바꾸고(서식 본문은 비운다) 다시 읽은 값을 돌려준다 — 형식은 요청 검증이, 켤 때의 빈 값 · 날짜 순서는 서비스가 본다 */
/**
* 부재중 자동 응답을 통째로 바꾸고 저장한 값을 돌려준다 — 메일 필터 규칙은 그대로 둔다.
* 형식은 요청 검증이, 켤 때의 빈 값 · 길이 · 날짜 순서는 서비스가 본다. 함께 만든 엔진 규칙이 엔진 한도를 넘으면 MAIL_013
*/
setVacation(user: string, vacation: MailVacation): Promise<MailVacation>;
/** 메일 필터 (계약 2절 "메일 필터") — 규칙(차례대로) · 지금 규칙의 지문(version) · 동작하는지(isActive) */
getMailFilters(user: string): Promise<MailFilterSet>;
/**
* 메일 필터 규칙을 통째로 바꾸고 저장한 것을 돌려준다 — 부재중 자동 응답은 그대로 둔다. 저장하면 다시 동작한다(isActive).
* 그사이 다른 곳에서 규칙이 바뀌었으면(지금 규칙의 지문이 version 과 다름) 저장하지 않고 MAIL_012 —
* 지금 규칙이 바꾸려는 규칙과 이미 같으면 그대로 저장한다(같은 요청을 다시 보낸 경우).
* 만든 엔진 규칙이 엔진 한도를 넘으면 MAIL_013. 규칙의 형식 · 내용은 서비스가 검사한 값이다
*/
setMailFilters(
user: string,
update: MailFilterUpdate,
): Promise<MailFilterSet>;
/** 메일함 사용량과 한도 (한도가 없으면 limitBytes = null) */
getUsage(user: string): Promise<MailUsage>;
@@ -172,11 +197,13 @@ export interface MailProvider {
): Promise<MessageDeletionResult>;
/**
* @returns 보낸편지함에 남은 메일 id
* @returns 보낸편지함에 남은 메일 id 와 엔진이 받지 않은 사람 (계약 2절 "받는 사람별 결과" — 결과를 읽지 못하면 받은 것으로 본다)
* 첨부 합계가 한도(MAIL_ATTACHMENT_TOTAL_MAX_SIZE_MB 와 엔진 한도 중 작은 값)를 넘으면 FILE_002,
* 엔진에서 찾을 수 없는 첨부가 있으면 MAIL_004.
* 받는 사람을 엔진이 모두 받지 않았으면 보내지 않은 것으로 다룬다 — 이 요청이 만든 보낸 사본을 지우고
* MAIL_010(거절 중 5xx 가 하나라도) · MAIL_011(모두 4xx — 지금은 받을 수 없음)
*/
sendMessage(user: string, message: OutgoingMessage): Promise<{ id: string }>;
sendMessage(user: string, message: OutgoingMessage): Promise<SentMessage>;
/**
* 같은 Message-ID 로 이미 **발송한** 메일을 찾는다 (중복 발송 방지 — 계약 2절 Idempotency-Key).
@@ -187,6 +214,16 @@ export interface MailProvider {
messageId: string,
): Promise<SentMessageRecord | null>;
/**
* 이미 보낸 메일의 받지 않은 사람 (같은 키의 재시도 — 계약 2절 "받는 사람별 결과") — 그 메일의 제출 기록을
* 이 요청의 받는 사람(to · cc · bcc)과 맞춰 다시 만든다. 제출 기록이 없거나 읽지 못하면 빈 배열 — 던지지 않는다 (메일은 이미 보냈다)
*/
getRejectedRecipients(
user: string,
sentId: string,
recipients: RecipientFields,
): Promise<RejectedRecipient[]>;
uploadAttachment(user: string, file: UploadedFile): Promise<MailAttachment>;
/** 받은 그대로의 메일 원문(엔진의 원본)을 흘려보낸다 — 없는 메일은 RES_001 */
+483 -21
View File
@@ -5,17 +5,26 @@ import { startCpuTimer } from '@/../test/cpu-time';
import { BusinessException } from '@/common/errors/business.exception';
import { SIGNATURE_IMAGES_PER_SIGNATURE } from '@/modules/mail-signature/mail-signature.constants';
import type { MailSignatureService } from '@/modules/mail-signature/mail-signature.service';
import type { MailFilterUpdateDto } from './dto/mail-filter.dto';
import type { DraftBodyDto, SendMessageDto } from './dto/send-message.dto';
import { MAX_HTML_PARSE_LENGTH } from './html/html-parse-budget';
import {
MAX_BODY_HTML_LENGTH,
MAX_BODY_LENGTH,
MAX_BODY_VALUE_BYTES,
MAX_VACATION_SUBJECT_LENGTH,
MAX_VACATION_TEXT_LENGTH,
} from './mail.constants';
import { DRAFT_CLEANUP_WAIT_MS, MailService } from './mail.service';
import type { MailProvider } from './mail.provider';
import type { MailSettings } from './mail.settings';
import type { DraftSource, MessageDetailSource } from './mail.types';
import type {
DraftSource,
Mailbox,
MailFilterRule,
MessageDetailSource,
SentMessage,
} from './mail.types';
import type { RecipientSuggestionService } from './recipient-suggestions.service';
import { idempotentMessageId, payloadFingerprint } from './send-idempotency';
@@ -46,9 +55,12 @@ function mockOf<K extends keyof MailProvider>(): ProviderMocks[K] {
return jest.fn();
}
/** 대역 엔진의 발송 결과 — 모두 받았다 */
const SENT = { id: 'sent-1', rejectedRecipients: [] };
function fakeProvider(): ProviderMocks {
const sendMessage = mockOf<'sendMessage'>();
sendMessage.mockResolvedValue({ id: 'sent-1' });
sendMessage.mockResolvedValue(SENT);
return {
verifyCredentials: mockOf<'verifyCredentials'>(),
@@ -62,6 +74,8 @@ function fakeProvider(): ProviderMocks {
emptyMailbox: mockOf<'emptyMailbox'>(),
getVacation: mockOf<'getVacation'>(),
setVacation: mockOf<'setVacation'>(),
getMailFilters: mockOf<'getMailFilters'>(),
setMailFilters: mockOf<'setMailFilters'>(),
openMessageSource: mockOf<'openMessageSource'>(),
getUsage: mockOf<'getUsage'>(),
getAttachmentsTotalLimit:
@@ -76,6 +90,9 @@ function fakeProvider(): ProviderMocks {
deleteFromTrash: mockOf<'deleteFromTrash'>(),
sendMessage,
findSentMessage: mockOf<'findSentMessage'>().mockResolvedValue(null),
getRejectedRecipients: mockOf<'getRejectedRecipients'>().mockResolvedValue(
[],
),
uploadAttachment: mockOf<'uploadAttachment'>(),
openAttachment: mockOf<'openAttachment'>(),
readAttachmentBytes: mockOf<'readAttachmentBytes'>(),
@@ -709,6 +726,58 @@ describe('MailService', () => {
]);
});
it(`켤 때는 제목 ${MAX_VACATION_SUBJECT_LENGTH}자 · 내용 ${MAX_VACATION_TEXT_LENGTH}자까지 — 넘으면 VAL_001(엔진의 바이트 한도), 꺼 둔 채로는 저장한다 (계약 2절 "길이 한도의 근거")`, async () => {
for (const long of [
{ subject: 'x'.repeat(MAX_VACATION_SUBJECT_LENGTH + 1) },
{ textBody: 'x'.repeat(MAX_VACATION_TEXT_LENGTH + 1) },
// JS 문자열 길이로 센다 — 이모지 하나는 2자
{ textBody: `${'🌴'.repeat(MAX_VACATION_TEXT_LENGTH / 2)}x` },
]) {
expect(
await failureOf(() =>
service.updateVacation(user, { ...vacation, ...long }),
),
).toEqual({
code: 'VAL_001',
reason: 'vacation-enabled-text-too-long',
});
}
expect(provider.setVacation).not.toHaveBeenCalled();
// 로그 사유에는 길이만 (제목 · 내용 없이)
const error = await service
.updateVacation(user, {
...vacation,
subject: 's'.repeat(MAX_VACATION_SUBJECT_LENGTH + 1),
})
.catch((caught: unknown) => caught);
expect(error).toBeInstanceOf(BusinessException);
expect((error as BusinessException).context).toEqual({
reason: 'vacation-enabled-text-too-long',
subjectLength: MAX_VACATION_SUBJECT_LENGTH + 1,
textLength: vacation.textBody.length,
});
provider.setVacation.mockImplementation((_user, value) =>
Promise.resolve(value),
);
// 한도까지는 켜도 받는다
const full = {
...vacation,
subject: '가'.repeat(MAX_VACATION_SUBJECT_LENGTH),
textBody: '가'.repeat(MAX_VACATION_TEXT_LENGTH),
};
await expect(service.updateVacation(user, full)).resolves.toEqual(full);
// 꺼 두면 4단계에 저장해 둔 긴 글(영문 내용 1,000자 등)도 그대로 저장한다 — 끄기는 늘 된다
const off = {
...vacation,
isEnabled: false,
subject: 'x'.repeat(MAX_VACATION_SUBJECT_LENGTH + 1),
textBody: 'x'.repeat(1_000),
};
await expect(service.updateVacation(user, off)).resolves.toEqual(off);
expect(provider.setVacation).toHaveBeenCalledTimes(2);
});
it('날짜는 초 단위로 맞춰 넘기고, 순서도 맞춘 값으로 본다 — 같은 초 안의 두 시각은 VAL_001', async () => {
expect(
await failureOf(() =>
@@ -817,6 +886,253 @@ describe('MailService', () => {
});
});
describe('메일 필터 (6단계)', () => {
const condition = {
field: 'from' as const,
operator: 'contains' as const,
value: '@hanbit-partners.co.kr',
};
const noActions = {
moveToMailboxId: null,
markAsRead: false,
flag: false,
forwardTo: null,
};
const rule = (
id: string,
extra: Partial<MailFilterRule> = {},
): MailFilterRule => ({
id,
name: '거래처 메일',
isEnabled: true,
match: 'all',
conditions: [condition],
actions: { ...noActions, markAsRead: true },
...extra,
});
const VERSION = 'v-before';
const update = (rules: MailFilterRule[]) =>
({ rules, version: VERSION }) as unknown as MailFilterUpdateDto;
const mailbox = (id: string, role: Mailbox['role']): Mailbox => ({
id,
name: id,
role,
parentId: null,
sortOrder: 0,
totalCount: 0,
unreadCount: 0,
});
beforeEach(() => {
provider.setMailFilters.mockImplementation((_user, { rules }) =>
Promise.resolve({ rules, version: 'v-after', isActive: true }),
);
provider.listMailboxes.mockResolvedValue([
mailbox('a', 'inbox'),
mailbox('c', 'junk'),
mailbox('b', 'trash'),
mailbox('e', 'sent'),
mailbox('d', 'drafts'),
mailbox('j', null),
]);
});
it('읽기는 어댑터 그대로 (규칙 · 지문 · 동작하는지)', async () => {
const filters = { rules: [rule('r1')], version: 'v1', isActive: false };
provider.getMailFilters.mockResolvedValue(filters);
await expect(service.getMailFilters(user)).resolves.toBe(filters);
expect(provider.getMailFilters).toHaveBeenCalledWith(user.email);
});
it('검사를 지나면 규칙과 받은 지문을 넘기고 저장한 것을 돌려준다 — 로그에는 규칙 수만 (조건의 글 · 전달 주소 없이)', async () => {
const rules = [
rule('r1', {
conditions: [{ ...condition, value: '비밀 거래처' }],
actions: {
...noActions,
forwardTo: 'doyun.lee@arasystem.test',
moveToMailboxId: 'j',
},
}),
rule('r2', { isEnabled: false, match: 'always', conditions: [] }),
];
await expect(
service.updateMailFilters(user, update(rules)),
).resolves.toEqual({ rules, version: 'v-after', isActive: true });
expect(provider.setMailFilters).toHaveBeenCalledWith(user.email, {
rules,
version: VERSION,
});
const [line] = logSpy.mock.calls.at(-1) as [string];
expect(line).toContain('규칙 2개(켬 1개)');
expect(line).not.toContain('비밀 거래처');
expect(line).not.toContain('doyun.lee');
expect(line).not.toContain(user.email);
});
it.each<[string, Partial<MailFilterRule>, string]>([
[
'모든 메일인데 조건이 있음',
{ match: 'always' },
'conditions-for-match',
],
['모두인데 조건이 없음', { conditions: [] }, 'conditions-for-match'],
[
'하나라도인데 조건이 없음',
{ match: 'any', conditions: [] },
'conditions-for-match',
],
['할 일이 하나도 없음', { actions: noActions }, 'no-action'],
])(
'칸 사이의 규칙이 어긋나면 엔진에 묻지 않고 VAL_001 — %s',
async (_label, extra, problem) => {
let failure: unknown;
try {
await service.updateMailFilters(
user,
update([rule('ok'), rule('bad', extra)]),
);
} catch (error) {
failure = error;
}
expect(failure).toBeInstanceOf(BusinessException);
expect((failure as BusinessException).code).toBe('VAL_001');
expect((failure as BusinessException).context).toEqual({
reason: 'invalid-filter-rule',
problem,
index: 1,
});
expect(provider.listMailboxes).not.toHaveBeenCalled();
expect(provider.setMailFilters).not.toHaveBeenCalled();
},
);
it('같은 id 의 규칙이 둘이면 VAL_001 — 요청 검증이 아니라 여기서 본다(Set — 항목 수에 비례)', async () => {
expect(
await failureOf(() =>
service.updateMailFilters(
user,
update([rule('same'), rule('other'), rule('same')]),
),
),
).toEqual({ code: 'VAL_001', reason: 'invalid-filter-rule' });
expect(provider.setMailFilters).not.toHaveBeenCalled();
});
it('전달은 사내 주소만 — 바깥 도메인 · 하위 도메인은 VAL_001, 대소문자는 가리지 않는다', async () => {
const forwardTo = (address: string) =>
update([rule('r1', { actions: { ...noActions, forwardTo: address } })]);
for (const address of [
'someone@gmail.com',
'doyun.lee@mail.arasystem.test',
'doyun.lee@arasystem.test.evil.com',
]) {
expect(
await failureOf(() =>
service.updateMailFilters(user, forwardTo(address)),
),
).toEqual({ code: 'VAL_001', reason: 'filter-forward-not-internal' });
}
expect(provider.setMailFilters).not.toHaveBeenCalled();
await service.updateMailFilters(
user,
forwardTo('Doyun.Lee@ARASYSTEM.test'),
);
expect(provider.setMailFilters).toHaveBeenCalledTimes(1);
});
it('자기 주소로는 전달하지 않는다 (대소문자 무시) — VAL_001', async () => {
expect(
await failureOf(() =>
service.updateMailFilters(
user,
update([
rule('r1', {
actions: { ...noActions, forwardTo: 'ARA.KIM@arasystem.test' },
}),
]),
),
),
).toEqual({ code: 'VAL_001', reason: 'filter-forward-to-self' });
});
it('메일 도메인 설정이 없으면 사내 주소인지 알 수 없어 전달을 받지 않는다', async () => {
const unconfigured = new MailService(
provider,
{ ...settings, domain: null },
signatures as unknown as MailSignatureService,
suggestions as unknown as RecipientSuggestionService,
);
expect(
await failureOf(() =>
unconfigured.updateMailFilters(
user,
update([
rule('r1', {
actions: {
...noActions,
forwardTo: 'doyun.lee@arasystem.test',
},
}),
]),
),
),
).toEqual({ code: 'VAL_001', reason: 'filter-forward-not-internal' });
});
it('보낸편지함 · 임시보관함으로는 옮기지 않는다 — VAL_001. 받은편지함 · 스팸메일함 · 휴지통 · 내 메일함 · 없는 id 는 받는다', async () => {
const moveTo = (mailboxId: string) =>
update([
rule('r1', {
actions: { ...noActions, moveToMailboxId: mailboxId },
}),
]);
for (const mailboxId of ['e', 'd']) {
expect(
await failureOf(() =>
service.updateMailFilters(user, moveTo(mailboxId)),
),
).toEqual({
code: 'VAL_001',
reason: 'filter-move-target-not-allowed',
});
}
expect(provider.setMailFilters).not.toHaveBeenCalled();
for (const mailboxId of ['a', 'c', 'b', 'j', 'gone']) {
await service.updateMailFilters(user, moveTo(mailboxId));
}
expect(provider.setMailFilters).toHaveBeenCalledTimes(5);
expect(provider.listMailboxes).toHaveBeenCalledWith(user.email);
});
it('옮기는 규칙이 없으면 메일함 목록을 읽지 않는다', async () => {
await service.updateMailFilters(user, update([rule('r1')]));
await service.updateMailFilters(user, update([]));
expect(provider.listMailboxes).not.toHaveBeenCalled();
expect(provider.setMailFilters).toHaveBeenCalledTimes(2);
});
it('어댑터의 실패는 그대로 — 그사이 바뀜 MAIL_012 · 한도 MAIL_013 · 엔진 장애 MAIL_001 (저장 로그 없음)', async () => {
for (const code of ['MAIL_012', 'MAIL_013', 'MAIL_001'] as const) {
provider.setMailFilters.mockRejectedValueOnce(
new BusinessException(code),
);
expect(
await codeOf(() =>
service.updateMailFilters(user, update([rule('r1')])),
),
).toBe(code);
}
expect(logSpy).not.toHaveBeenCalled();
});
});
describe('메일 상세', () => {
it('HTML 이 없으면 정화 없이 텍스트만 내려준다', async () => {
provider.getMessage.mockResolvedValue(source());
@@ -1544,19 +1860,21 @@ describe('MailService', () => {
),
).toBe('MAIL_007');
await expect(send()).resolves.toEqual({ id: 'sent-1' });
await expect(send()).resolves.toEqual(SENT);
expect(provider.findSentMessage).toHaveBeenCalledTimes(1);
expect(provider.sendMessage).toHaveBeenCalledTimes(1);
});
it('처음 보내는 키면 (사용자, 키)로 정한 Message-ID 와 내용 지문을 실어 보낸다', async () => {
await expect(send()).resolves.toEqual({ id: 'sent-1' });
await expect(send()).resolves.toEqual(SENT);
const messageId = idempotentMessageId(user.email, IDEMPOTENCY_KEY);
expect(provider.findSentMessage).toHaveBeenCalledWith(
user.email,
messageId,
);
// 처음 보내는 키라 받는 사람별 결과를 다시 만들 것이 없다
expect(provider.getRejectedRecipients).not.toHaveBeenCalled();
const [, outgoing] = provider.sendMessage.mock.calls[0];
expect(outgoing.messageId).toBe(messageId);
// 지문 키는 이 요청의 Idempotency-Key — 서버 비밀이 아니다 (v2)
@@ -1579,10 +1897,68 @@ describe('MailService', () => {
it('같은 키·같은 내용으로 이미 보냈으면 다시 보내지 않고 처음 결과(id)를 돌려준다', async () => {
const fingerprint = await fingerprintOf(sendDto());
provider.sendMessage.mockClear();
provider.findSentMessage.mockResolvedValue({
id: 'sent-1',
fingerprint,
});
await expect(send()).resolves.toEqual(SENT);
expect(provider.sendMessage).not.toHaveBeenCalled();
});
it('처음 결과를 돌려줄 때 받지 않은 사람은 그 메일의 제출 기록으로 다시 만든다 — 이 요청의 받는 사람과 맞추고, 지문은 응답에 싣지 않는다', async () => {
const fingerprint = await fingerprintOf(sendDto());
provider.sendMessage.mockClear();
const rejectedRecipients = [
{ email: 'nobody@arasystem.test', reason: 'not-found' as const },
];
provider.findSentMessage.mockResolvedValue({ id: 'sent-1', fingerprint });
provider.getRejectedRecipients.mockResolvedValue(rejectedRecipients);
const result = await send();
expect(result).toEqual({ id: 'sent-1', rejectedRecipients });
expect(result).not.toHaveProperty('fingerprint');
expect(provider.sendMessage).not.toHaveBeenCalled();
expect(provider.getRejectedRecipients).toHaveBeenCalledWith(
user.email,
'sent-1',
expect.objectContaining({
to: [{ name: '이도윤', email: 'doyun.lee@arasystem.test' }],
cc: [],
bcc: [],
}),
);
});
it('지우지 못해 남은 모두 거절 사본 — 같은 키 · 같은 내용의 재시도는 그것을 찾아 201 + 받는 사람 전원을 받지 않은 사람으로 (다시 보내지 않는다, 이어 쓴 저장본은 지운다)', async () => {
const fingerprint = await fingerprintOf(sendDto());
provider.sendMessage.mockClear();
const everyone = [
{ email: 'doyun.lee@arasystem.test', reason: 'not-found' as const },
];
provider.findSentMessage.mockResolvedValue({ id: 'sent-1', fingerprint });
provider.getRejectedRecipients.mockResolvedValue(everyone);
await expect(
service.sendMessage(
user,
sendDto({ draftId: 'draft-1' }),
IDEMPOTENCY_KEY,
),
).resolves.toEqual({ id: 'sent-1', rejectedRecipients: everyone });
expect(provider.sendMessage).not.toHaveBeenCalled();
expect(provider.deleteDraft).toHaveBeenCalledWith(user.email, 'draft-1');
});
it('내용이 달라 MAIL_005 로 끝나면 처음 발송의 결과를 읽지 않는다 (제출 기록을 헛되이 읽지 않게)', async () => {
const fingerprint = await fingerprintOf(sendDto());
provider.findSentMessage.mockResolvedValue({ id: 'sent-1', fingerprint });
await expect(send()).resolves.toEqual({ id: 'sent-1' });
expect(provider.sendMessage).not.toHaveBeenCalled();
expect(await codeOf(() => send(sendDto({ subject: '고친 제목' })))).toBe(
'MAIL_005',
);
expect(provider.getRejectedRecipients).not.toHaveBeenCalled();
});
it('지문에는 정화한 서식 본문이 들어간다 — 정화 뒤가 같으면 같은 내용, 서식이 바뀌면 MAIL_005', async () => {
@@ -1593,12 +1969,15 @@ describe('MailService', () => {
expect(fingerprint).toBe(payloadFingerprint(first, IDEMPOTENCY_KEY));
provider.sendMessage.mockClear();
provider.findSentMessage.mockResolvedValue({ id: 'sent-1', fingerprint });
provider.findSentMessage.mockResolvedValue({
id: 'sent-1',
fingerprint,
});
// 정화로 지워지는 부분만 다르면 같은 메일이다
await expect(
send(sendDto({ bodyHtml: `${html}<script>x()</script>` })),
).resolves.toEqual({ id: 'sent-1' });
).resolves.toEqual(SENT);
// 서식이 바뀌거나(굵게 → 기울임) 서식 본문을 뺀 텍스트 메일은 다른 내용이다
for (const changed of [
sendDto({ bodyHtml: '<p><em>확인</em>했습니다.</p>' }),
@@ -1612,7 +1991,10 @@ describe('MailService', () => {
it('같은 키인데 내용(받는 사람·제목·본문·첨부)이 다르면 보내지 않고 409 MAIL_005', async () => {
const fingerprint = await fingerprintOf(sendDto());
provider.sendMessage.mockClear();
provider.findSentMessage.mockResolvedValue({ id: 'sent-1', fingerprint });
provider.findSentMessage.mockResolvedValue({
id: 'sent-1',
fingerprint,
});
for (const changed of [
sendDto({ subject: '고친 제목' }),
@@ -1635,7 +2017,7 @@ describe('MailService', () => {
});
it('같은 키의 발송이 처리 중이면 기다리지 않고 429 REQ_001 — 끝나면 다시 받는다', async () => {
let finish!: (value: { id: string }) => void;
let finish!: (value: SentMessage) => void;
provider.sendMessage.mockImplementationOnce(
() => new Promise((resolve) => (finish = resolve)),
);
@@ -1648,11 +2030,14 @@ describe('MailService', () => {
// 다른 키·다른 사용자는 막지 않는다
await expect(
send(sendDto(), '00000000-0000-4000-8000-000000000000'),
).resolves.toEqual({ id: 'sent-1' });
).resolves.toEqual(SENT);
finish({ id: 'sent-first' });
await expect(first).resolves.toEqual({ id: 'sent-first' });
await expect(send()).resolves.toEqual({ id: 'sent-1' });
finish({ id: 'sent-first', rejectedRecipients: [] });
await expect(first).resolves.toEqual({
id: 'sent-first',
rejectedRecipients: [],
});
await expect(send()).resolves.toEqual(SENT);
});
it('엔진 장애(MAIL_001)로 끝나면 엔진이 아직 처리 중일 수 있어 30초 동안 같은 키를 REQ_001 로 막는다', async () => {
@@ -1666,7 +2051,7 @@ describe('MailService', () => {
expect(await codeOf(() => send())).toBe('REQ_001');
jest.advanceTimersByTime(30_000);
await expect(send()).resolves.toEqual({ id: 'sent-1' });
await expect(send()).resolves.toEqual(SENT);
} finally {
jest.useRealTimers();
}
@@ -1678,16 +2063,44 @@ describe('MailService', () => {
);
expect(await codeOf(() => send())).toBe('MAIL_004');
await expect(send()).resolves.toEqual({ id: 'sent-1' });
await expect(send()).resolves.toEqual(SENT);
});
it.each(['MAIL_010', 'MAIL_011'] as const)(
'받는 사람 모두 거절(%s)은 같은 키를 잠가 두지 않는다 — 아무것도 나가지 않았다, 곧바로 같은 키로 다시 보내면 새로 보낸다',
async (code) => {
jest.useFakeTimers({ doNotFake: ['setImmediate', 'nextTick'] });
try {
provider.sendMessage.mockRejectedValueOnce(
new BusinessException(code, {
context: { reason: 'all-recipients-rejected' },
}),
);
expect(await codeOf(() => send())).toBe(code);
// 시간을 넘기지 않아도 처리 중(REQ_001)이 아니다 — 보낸 사본은 어댑터가 지웠다 (찾아도 없다)
await expect(
send(
sendDto({
to: [{ name: null, email: 'doyun.lee@arasystem.test' }],
}),
),
).resolves.toEqual(SENT);
expect(provider.sendMessage).toHaveBeenCalledTimes(2);
expect(provider.findSentMessage).toHaveBeenCalledTimes(2);
} finally {
jest.useRealTimers();
}
},
);
it('이미 보낸 메일을 찾는 중 엔진 장애면 잠그지 않는다 (보낸 것이 없다)', async () => {
provider.findSentMessage.mockRejectedValueOnce(
new BusinessException('MAIL_001'),
);
expect(await codeOf(() => send())).toBe('MAIL_001');
await expect(send()).resolves.toEqual({ id: 'sent-1' });
await expect(send()).resolves.toEqual(SENT);
});
});
@@ -2027,7 +2440,7 @@ describe('MailService', () => {
it('이어 쓴 임시 저장본(draftId)을 보내기에 성공한 뒤 지우고, 받는 사람 추천을 다시 모으게 한다', async () => {
await expect(
service.sendMessage(user, sendDto({ draftId: 'draft-1' })),
).resolves.toEqual({ id: 'sent-1' });
).resolves.toEqual(SENT);
expect(provider.deleteDraft).toHaveBeenCalledWith(user.email, 'draft-1');
expect(suggestions.invalidate).toHaveBeenCalledWith(user.email);
@@ -2059,7 +2472,7 @@ describe('MailService', () => {
sendDto({ draftId: 'draft-1' }),
IDEMPOTENCY_KEY,
),
).resolves.toEqual({ id: 'sent-1' });
).resolves.toEqual(SENT);
expect(provider.sendMessage).not.toHaveBeenCalled();
expect(provider.deleteDraft).toHaveBeenCalledWith(user.email, 'draft-1');
@@ -2088,7 +2501,7 @@ describe('MailService', () => {
user,
sendDto({ subject: '비밀 제목', draftId: 'draft-1' }),
),
).resolves.toEqual({ id: 'sent-1' });
).resolves.toEqual(SENT);
const warned = warnSpy.mock.calls
.map(([message]) => String(message))
@@ -2111,6 +2524,55 @@ describe('MailService', () => {
expect(suggestions.invalidate).not.toHaveBeenCalled();
});
it.each(['MAIL_010', 'MAIL_011'] as const)(
'받는 사람을 엔진이 모두 받지 않았으면(%s) 보내지 않은 것이다 — 이어 쓴 저장본을 지우지 않는다 (키가 있어도 없어도)',
async (code) => {
provider.sendMessage.mockRejectedValue(
new BusinessException(code, {
context: { reason: 'all-recipients-rejected' },
}),
);
for (const key of [undefined, IDEMPOTENCY_KEY]) {
expect(
await codeOf(() =>
service.sendMessage(user, sendDto({ draftId: 'draft-1' }), key),
),
).toBe(code);
}
expect(provider.deleteDraft).not.toHaveBeenCalled();
expect(suggestions.invalidate).not.toHaveBeenCalled();
},
);
it('일부만 받지 않았으면 보낸 것이다 — 그 결과를 그대로 돌려주고 이어 쓴 저장본을 지운다, 발송 로그에는 수만', async () => {
const partial = {
id: 'sent-1',
rejectedRecipients: [
{ email: 'nobody@arasystem.test', reason: 'not-found' as const },
],
};
provider.sendMessage.mockResolvedValue(partial);
await expect(
service.sendMessage(
user,
sendDto({
draftId: 'draft-1',
cc: [{ name: null, email: 'nobody@arasystem.test' }],
}),
),
).resolves.toEqual(partial);
expect(provider.deleteDraft).toHaveBeenCalledWith(user.email, 'draft-1');
const logged = logSpy.mock.calls
.map(([message]) => String(message))
.join('\n');
expect(logged).toContain('받는 사람 2명');
expect(logged).toContain('받지 않은 사람 1명');
expect(logged).not.toContain('nobody');
});
it(`초안 지우기는 ${DRAFT_CLEANUP_WAIT_MS}ms 까지만 기다린다 — 넘으면 먼저 답하고, 뒤에서 끝난 결과는 로그로만 남긴다`, async () => {
jest.useFakeTimers({ doNotFake: ['setImmediate', 'nextTick'] });
try {
@@ -2130,7 +2592,7 @@ describe('MailService', () => {
await new Promise((resolve) => setImmediate(resolve));
jest.advanceTimersByTime(DRAFT_CLEANUP_WAIT_MS);
await expect(sending).resolves.toEqual({ id: 'sent-1' });
await expect(sending).resolves.toEqual(SENT);
expect(warnSpy).toHaveBeenCalledWith(
expect.stringContaining(
'보낸 메일의 임시 저장본 지우기가 5000ms 안에 끝나지 않아 먼저 답합니다',
+161 -12
View File
@@ -23,6 +23,7 @@ import type {
MessageDeletionDto,
UpdateMessagesDto,
} from './dto/bulk-messages.dto';
import type { MailFilterUpdateDto } from './dto/mail-filter.dto';
import type { MailVacationDto } from './dto/mail-vacation.dto';
import type {
CreateMailboxDto,
@@ -58,7 +59,10 @@ import {
MAX_BODY_LENGTH,
MAX_BODY_VALUE_BYTES,
MAX_RECIPIENTS,
MAX_VACATION_SUBJECT_LENGTH,
MAX_VACATION_TEXT_LENGTH,
} from './mail.constants';
import { findFilterRulesProblem } from './mail-filters';
import { MAIL_PROVIDER, type MailProvider } from './mail.provider';
import { MAIL_SETTINGS, type MailSettings } from './mail.settings';
import type {
@@ -72,6 +76,9 @@ import type {
Mailbox,
MailboxBulkResult,
MailboxDeletion,
MailboxRole,
MailFilterRule,
MailFilterSet,
MailQuota,
MailState,
MailVacation,
@@ -85,6 +92,7 @@ import type {
OutgoingInlineImage,
OutgoingMessage,
SavedDraft,
SentMessage,
} from './mail.types';
import { presentMailbox, presentMailboxes } from './mailbox.presenter';
import { RecipientSuggestionService } from './recipient-suggestions.service';
@@ -117,6 +125,12 @@ function toAddress(recipient: RecipientDto): MailAddress {
return { name: name || null, email: recipient.email.trim() };
}
/** 발송 로그 끝에 붙이는 받지 않은 사람 수 — 없으면 빈 문자열 (주소는 남기지 않는다) */
function rejectedSuffix(result: SentMessage): string {
const count = result.rejectedRecipients.length;
return count > 0 ? `, 받지 않은 사람 ${count}명` : '';
}
/** 발송·임시 저장 본문이 함께 쓰는 입력 — 받는 사람 규칙만 다르다 (dto/send-message.dto.ts) */
type ComposeInput = ComposeContentDto & { readonly to: RecipientDto[] };
@@ -197,6 +211,12 @@ function toMessageUpdate(dto: UpdateMessageDto): MessageUpdate {
return { isRead, isFlagged, mailboxId, isJunk };
}
/** 필터가 메일을 옮길 수 없는 메일함 — 보낸편지함 · 임시보관함 (계약 2절 "메일 필터"의 타입) */
const FILTER_MOVE_FORBIDDEN_ROLES: ReadonlySet<MailboxRole> = new Set([
'sent',
'drafts',
]);
/** 서명 그림의 Content-ID — 같은 그림이면 늘 같은 값이다 (발송 지문이 cid 가 든 서식 본문을 본다) */
function signatureImageCid(imageId: string, sender: string): string {
const domain = sender.slice(sender.lastIndexOf('@') + 1).toLowerCase();
@@ -360,11 +380,14 @@ export class MailService {
}
/**
* 부재중 자동 응답 저장(전체 교체) — 엔진이 저장한 값을 다시 읽어 돌려준다.
* 부재중 자동 응답 저장(전체 교체) — 저장한 값을 돌려준다. 메일 필터와 같은 엔진 규칙에 두며 필터 규칙은 그대로 둔다
* (계약 2절 "메일 필터"의 저장 자리 — 함께 만든 엔진 규칙이 한도를 넘으면 MAIL_013).
* - 켤 때는 제목 · 내용이 앞뒤 공백을 빼고 1자 이상 (저장은 쓴 그대로 — 지우지 않는다).
* 끈 채로는 빈 값도 저장한다 — 한 번도 설정하지 않은 값을 그대로 되돌려 놓을 수 있게
* - 날짜는 초 단위로 맞춰 엔진에 보낸다(소수 초를 버린다 — 엔진의 UTCDate). 시작 · 끝이 둘 다 있으면 **맞춘 값으로** 시작 < 끝
* 어긋나면 엔진에 묻지 않고 VAL_001. 형식(길이 · 제어 문자 · 날짜 모양)은 요청 검증이 본다. 로그에는 켬 · 끔만 (제목 · 내용 없이)
* - 켤 때는 제목 150자 · 내용 600자까지 (엔진의 바이트 한도 안 — mail.constants.ts). 끈 채로는 요청 검증의 200자 · 4,000자까지 받는다 —
* 4단계에 저장해 둔 긴 글이 켜져 있어도 끌 수 있게 (계약 2절 "길이 한도의 근거")
* - 날짜는 초 단위로 맞춰 저장한다(소수 초를 버린다 — 엔진 규칙의 기간은 초 단위다). 시작 · 끝이 둘 다 있으면 **맞춘 값으로** 시작 < 끝
* 어긋나면 엔진에 묻지 않고 VAL_001. 형식(늘 보는 길이 · 제어 문자 · 날짜 모양)은 요청 검증이 본다. 로그에는 켬 · 끔만 (제목 · 내용 없이)
*/
async updateVacation(
user: AuthenticatedUser,
@@ -385,6 +408,20 @@ export class MailService {
context: { reason: 'vacation-enabled-without-text' },
});
}
// 길이는 JS 문자열 길이 — 요청 검증(MaxCodeUnits)과 같게 센다. 로그에는 길이만 (제목 · 내용 없이)
if (
vacation.isEnabled &&
(vacation.subject.length > MAX_VACATION_SUBJECT_LENGTH ||
vacation.textBody.length > MAX_VACATION_TEXT_LENGTH)
) {
throw new BusinessException('VAL_001', {
context: {
reason: 'vacation-enabled-text-too-long',
subjectLength: vacation.subject.length,
textLength: vacation.textBody.length,
},
});
}
if (
vacation.fromDate !== null &&
vacation.toDate !== null &&
@@ -402,6 +439,48 @@ export class MailService {
return saved;
}
/** 메일 필터 (계약 2절 "메일 필터") — 규칙 · 지문(version) · 동작하는지(isActive) */
getMailFilters(user: AuthenticatedUser): Promise<MailFilterSet> {
return this.provider.getMailFilters(user.email);
}
/**
* 메일 필터 저장(전체 교체) — 그사이 다른 곳에서 규칙이 바뀌었으면 MAIL_012, 만든 엔진 규칙이 한도를 넘으면 MAIL_013 (어댑터).
* 요청 검증(칸마다의 형식 — 글은 앞뒤 공백을 지운 값) 뒤에 칸 사이의 규칙을 본다 — 어긋나면 엔진에 묻지 않고 VAL_001:
* - 모든 메일(always)은 조건 0개 · 모두 · 하나라도는 1~5개 · 할 일 하나 이상 (mail-filters.ts — 저장해 둔 원본을 읽을 때와 같은 규칙)
* - 전달은 사내 주소(MAIL_DOMAIN)이고 자기 주소가 아님 (대소문자 무시)
* - 옮길 메일함이 보낸편지함 · 임시보관함이 아님 — 옮기는 규칙이 있을 때만 메일함 목록을 읽는다. 없는 id 는 받는다
* 로그에는 규칙 수만 남긴다 — 조건의 글 · 전달 주소는 남기지 않는다 (검색어처럼 직원의 내용이다)
*/
async updateMailFilters(
user: AuthenticatedUser,
dto: MailFilterUpdateDto,
): Promise<MailFilterSet> {
const { rules, version } = dto;
const problem = findFilterRulesProblem(rules);
if (problem) {
throw new BusinessException('VAL_001', {
context: {
reason: 'invalid-filter-rule',
problem: problem.reason,
index: problem.index,
},
});
}
this.assertFilterForwardTargets(user, rules);
await this.assertFilterMoveTargets(user, rules);
const saved = await this.provider.setMailFilters(user.email, {
rules,
version,
});
const enabled = saved.rules.filter((rule) => rule.isEnabled).length;
this.logger.log(
`${traceTag()} 메일 필터를 저장했습니다: user=${maskEmail(user.email)}, 규칙 ${saved.rules.length}개(켬 ${enabled}개)`,
);
return saved;
}
/** 받은 그대로의 메일 원문 — 파일 이름은 제목(없으면 "메일") + .eml (attachment.policy.ts 의 messageSourceFileName) */
async openMessageSource(
user: AuthenticatedUser,
@@ -562,11 +641,14 @@ export class MailService {
/**
* 메일 발송. `idempotencyKey`(Idempotency-Key 헤더)가 있으면 중복 발송을 막는다 (계약 2절):
* - 같은 사용자·같은 키로 이미 보낸 메일이 있으면 다시 보내지 않고 그 id 를 돌려준다
* - 같은 사용자·같은 키로 이미 보낸 메일이 있으면 다시 보내지 않고 그 결과(id · 받지 않은 사람)를 돌려준다
* - 그 메일과 내용(받는 사람·제목·본문·첨부)이 다르면 보내지 않고 409 MAIL_005
* - 같은 키의 요청이 처리 중이면 기다리지 않고 429 REQ_001
* - 키 형식이 틀리면 400 VAL_001, 키가 없으면 중복 방지 없이 보낸다
*
* 받는 사람별 결과 (계약 2절 "받는 사람별 결과"): 일부만 받지 않았으면 보낸 것이다 — 201 에 rejectedRecipients 를 싣는다.
* 모두 받지 않았으면 어댑터가 보낸 사본을 지우고 MAIL_010 · MAIL_011 로 던진다 — 보내지 않은 것이라 아래 "보낸 뒤"를 하지 않는다.
*
* `draftId`(이어 쓴 임시 저장본)는 보내기에 성공한 뒤 — 같은 키 재시도로 처음 결과를 돌려줄 때 포함 — 지운다.
* 초안 자체를 제출하지 않는다(다른 메일 프로그램이 고쳤을 수 있다 — 정화·지문을 건너뛰게 된다). 지문에는 넣지 않는다.
*/
@@ -574,7 +656,7 @@ export class MailService {
user: AuthenticatedUser,
dto: SendMessageDto,
idempotencyKey?: string,
): Promise<{ id: string }> {
): Promise<SentMessage> {
const parsed = parseIdempotencyKey(idempotencyKey);
if (!parsed.ok) {
throw new BusinessException('VAL_001', {
@@ -630,6 +712,63 @@ export class MailService {
};
}
/**
* 필터의 전달 주소 — 사내 도메인(MAIL_DOMAIN)의 주소이고 자기 주소가 아니어야 한다 (계약 2절 "메일 필터" — 바깥 주소 전달은 하지 않는다).
* 메일 도메인 설정이 없으면(비운영의 엔진 미설정) 사내 주소인지 알 수 없어 받지 않는다
*/
private assertFilterForwardTargets(
user: AuthenticatedUser,
rules: readonly MailFilterRule[],
): void {
const self = user.email.toLowerCase();
for (const [index, rule] of rules.entries()) {
const target = rule.actions.forwardTo?.toLowerCase();
if (target === undefined) continue;
const domain = target.slice(target.lastIndexOf('@') + 1);
if (this.settings.domain === null || domain !== this.settings.domain) {
throw new BusinessException('VAL_001', {
context: { reason: 'filter-forward-not-internal', index },
});
}
if (target === self) {
throw new BusinessException('VAL_001', {
context: { reason: 'filter-forward-to-self', index },
});
}
}
}
/**
* 필터가 옮길 메일함 — 보낸편지함 · 임시보관함이면 VAL_001. 메일함 목록에 없는 id 는 받는다
* (지운 메일함을 가리키는 규칙이 남아 있을 수 있다 — 그때는 받은편지함에 들어온다). 옮기는 규칙이 없으면 엔진에 묻지 않는다
*/
private async assertFilterMoveTargets(
user: AuthenticatedUser,
rules: readonly MailFilterRule[],
): Promise<void> {
const targets = rules.flatMap((rule, index) =>
rule.actions.moveToMailboxId === null
? []
: [{ index, mailboxId: rule.actions.moveToMailboxId }],
);
if (targets.length === 0) return;
const roles = new Map(
(await this.provider.listMailboxes(user.email)).map((mailbox) => [
mailbox.id,
mailbox.role,
]),
);
for (const { index, mailboxId } of targets) {
const role = roles.get(mailboxId);
if (role && FILTER_MOVE_FORBIDDEN_ROLES.has(role)) {
throw new BusinessException('VAL_001', {
context: { reason: 'filter-move-target-not-allowed', role, index },
});
}
}
}
/**
* 같은 직원 · 같은 메일함의 오래 걸리는 처리를 하나씩만 한다 — 진행 중이면 엔진에 묻지 않고 바로 REQ_001
* (로그인의 진행 중 시도와 같은 모양). 끝나면 성공 · 실패와 상관없이 푼다. 다른 메일함끼리는 동시에 된다
@@ -661,7 +800,7 @@ export class MailService {
user: AuthenticatedUser,
message: OutgoingMessage,
key: string,
): Promise<{ id: string }> {
): Promise<SentMessage> {
const lockKey = `${user.email.toLowerCase()}\n${key}`;
if (this.sendsInProgress.has(lockKey)) {
throw new BusinessException('REQ_001', {
@@ -693,16 +832,26 @@ export class MailService {
},
});
}
// 처음 발송의 받는 사람별 결과 — 같은 내용일 때만 읽는다 (제출 기록을 이 요청의 받는 사람과 맞춘다, 읽지 못하면 빈 배열)
const result: SentMessage = {
id: previous.id,
rejectedRecipients: await this.provider.getRejectedRecipients(
user.email,
previous.id,
message,
),
};
this.logger.log(
`${traceTag()} 같은 Idempotency-Key 의 재발송 — 이미 보낸 메일을 돌려준다: from=${maskEmail(user.email)}, id=${previous.id}`,
`${traceTag()} 같은 Idempotency-Key 의 재발송 — 이미 보낸 메일을 돌려준다: from=${maskEmail(user.email)}, id=${previous.id}${rejectedSuffix(result)}`,
);
return { id: previous.id };
return result;
}
try {
return await this.deliver(user, { ...message, messageId, fingerprint });
} catch (error) {
// 엔진에 닿지 못했거나 답을 못 받았다 — 엔진이 아직 처리 중일 수 있으니 잠시 같은 키를 막아 둔다
// 엔진에 닿지 못했거나 답을 못 받았다 — 엔진이 아직 처리 중일 수 있으니 잠시 같은 키를 막아 둔다.
// 받는 사람 모두 거절(MAIL_010 · MAIL_011)은 막지 않는다 — 아무것도 나가지 않았고 보낸 사본도 지웠다 (다시 보내면 새로 보낸다)
holdLock =
error instanceof BusinessException && error.code === 'MAIL_001';
throw error;
@@ -944,14 +1093,14 @@ export class MailService {
private async deliver(
user: AuthenticatedUser,
message: OutgoingMessage,
): Promise<{ id: string }> {
): Promise<SentMessage> {
const result = await this.provider.sendMessage(user.email, message);
// 제목·본문·받는 사람 주소는 남기지 않는다 (.claude/rules/logging.md)
// 제목·본문·받는 사람 주소는 남기지 않는다 (.claude/rules/logging.md) — 받지 않은 사람의 가린 주소는 어댑터가 남긴다
const recipientCount =
message.to.length + message.cc.length + message.bcc.length;
this.logger.log(
`메일 발송: from=${maskEmail(user.email)}, 받는 사람 ${recipientCount}명, 첨부 ${message.attachments.length}개${message.htmlBody ? ', 서식 본문' : ''}${message.inReplyTo ? ', 답장' : ''}${message.messageId ? ', 중복 방지 키' : ''}`,
`메일 발송: from=${maskEmail(user.email)}, 받는 사람 ${recipientCount}명, 첨부 ${message.attachments.length}개${message.htmlBody ? ', 서식 본문' : ''}${message.inReplyTo ? ', 답장' : ''}${message.messageId ? ', 중복 방지 키' : ''}${rejectedSuffix(result)}`,
);
return result;
}
+89 -2
View File
@@ -183,7 +183,9 @@ export interface MailboxBulkResult {
readonly remaining: number;
}
/** 부재중 자동 응답 (계약 2절 "부재중 자동 응답") — 엔진의 VacationResponse 하나 */
/**
* 부재중 자동 응답 (계약 2절 "부재중 자동 응답") — 저장은 메일 필터와 같은 엔진 규칙에 함께 둔다 (계약 2절 "메일 필터"의 저장 자리)
*/
export interface MailVacation {
readonly isEnabled: boolean;
/** ISO 8601 UTC — null 이면 켠 때부터 */
@@ -196,6 +198,61 @@ export interface MailVacation {
readonly textBody: string;
}
/** 필터 조건을 견줄 곳 — 보낸 사람(이름 · 주소) · 받는 사람(받는 사람 · 참조의 이름 · 주소) · 제목 · 본문(글자 부분) */
export type MailFilterField = 'from' | 'to' | 'subject' | 'body';
/** 포함 · 포함하지 않음 (대소문자는 ASCII 만 무시한다 — 엔진 기본 비교) */
export type MailFilterOperator = 'contains' | 'notContains';
/** 조건이 모두 맞음 · 하나라도 맞음 · 모든 메일(조건 없음) */
export type MailFilterMatch = 'all' | 'any' | 'always';
export interface MailFilterCondition {
readonly field: MailFilterField;
readonly operator: MailFilterOperator;
/** 찾을 글 — 앞뒤 공백을 뺀 1~200자, 제어 문자 · `${` 없음 */
readonly value: string;
}
/** 맞는 메일에 할 일 — 하나 이상 (계약 2절 "메일 필터") */
export interface MailFilterActions {
/** 옮길 메일함 — 없는 id 도 받는다(지운 메일함을 가리키는 규칙 — 그때는 받은편지함에 들어온다) */
readonly moveToMailboxId: string | null;
readonly markAsRead: boolean;
readonly flag: boolean;
/** 전달할 사내 주소 — 사본은 늘 남긴다 */
readonly forwardTo: string | null;
}
export interface MailFilterRule {
/** 클라이언트가 만든다(UUID) — 목록 안에서 겹치지 않는다 */
readonly id: string;
/** 0~50자 — 비면 화면이 조건으로 이름을 보인다 */
readonly name: string;
/** 꺼 둔 규칙은 목록에만 있고 적용하지 않는다 */
readonly isEnabled: boolean;
readonly match: MailFilterMatch;
/** 모두 · 하나라도는 1~5개, 모든 메일은 빈 배열 */
readonly conditions: readonly MailFilterCondition[];
readonly actions: MailFilterActions;
}
/** 메일 필터 (GET · PUT /api/mail-filters 의 응답) — 위에서부터 차례로 적용한다 */
export interface MailFilterSet {
readonly rules: readonly MailFilterRule[];
/** 지금 규칙의 지문 — PUT 에 그대로 돌려받는다 (그사이 규칙이 바뀌었으면 MAIL_012) */
readonly version: string;
/** false = 엔진에서 다른 규칙이 켜져 지금은 필터 · 부재중 응답이 동작하지 않는다 (저장하면 다시 켜진다) */
readonly isActive: boolean;
}
/** 필터 저장 (PUT /api/mail-filters — 전체 교체) — 규칙은 서비스가 검사한 값이다 */
export interface MailFilterUpdate {
readonly rules: readonly MailFilterRule[];
/** 고치기 시작할 때 받은 지문 */
readonly version: string;
}
/** 받은 그대로의 메일 원문 (GET /api/messages/{id}/source) — 파일 이름은 서비스가 제목으로 정한다 */
export interface MessageSource {
readonly stream: Readable;
@@ -338,6 +395,9 @@ export interface OutgoingMessage {
readonly fingerprint?: string;
}
/** 보내는 메일의 받는 사람 칸 — 받는 사람별 결과를 이것과 맞춘다 (계약 2절 "받는 사람별 결과") */
export type RecipientFields = Pick<OutgoingMessage, 'to' | 'cc' | 'bcc'>;
/**
* 임시 저장할 내용 — 발송과 같은 규칙으로 준비한다(정화·한도). 서명 그림은 `/api/mail-signature/images/{id}` 주소 그대로 둔다.
* 발송 중복 방지용 Message-ID·지문은 넣지 않는다 (계약 2절 "임시 저장").
@@ -416,7 +476,34 @@ export interface RecipientSuggestion {
readonly source: 'directory' | 'recent';
}
/** 같은 Message-ID 로 이미 발송한 메일 */
/**
* 엔진이 받지 않은 까닭 (계약 2절 "받는 사람별 결과") — SMTP 응답으로 나눈다.
* not-found 없는 주소(확장 상태 5.1.x) · refused 받는 쪽이 거절(그 밖의 5xx) · temporary 지금은 받을 수 없음(4xx — 한도 등)
*/
export type RecipientRejectionReason = 'not-found' | 'refused' | 'temporary';
/** 엔진이 받지 않은 받는 사람 하나 */
export interface RejectedRecipient {
/** 요청에 쓴 주소 그대로 (받는 사람 · 참조 · 숨은참조) */
readonly email: string;
readonly reason: RecipientRejectionReason;
}
/**
* 발송 결과 (POST /api/messages 의 201 SentMessage — 계약 2절 "받는 사람별 결과").
* 받은 사람이 아무도 없으면 결과가 아니라 실패다 — 어댑터가 보낸 사본을 지우고 MAIL_010 · MAIL_011 로 던진다
*/
export interface SentMessage {
/** 보낸편지함의 메일 id */
readonly id: string;
/** 엔진이 받지 않은 사람 — 없으면 빈 배열. 같은 주소는 한 번만, 요청의 받는 사람 · 참조 · 숨은참조 순서 */
readonly rejectedRecipients: RejectedRecipient[];
}
/**
* 같은 Message-ID 로 이미 발송한 메일 — 받지 않은 사람은 지문이 맞을 때만 따로 다시 만든다
* (MailProvider.getRejectedRecipients — 내용이 달라 MAIL_005 로 끝날 때 제출 기록을 헛되이 읽지 않게)
*/
export interface SentMessageRecord {
/** 보낸편지함의 메일 id */
readonly id: string;
+17 -4
View File
@@ -37,7 +37,13 @@ import {
RenameMailboxDto,
} from './dto/mailbox.dto';
import { MailIdPipe } from './mail-id.pipe';
import { MAX_MAILBOX_NAME_LENGTH } from './mail.constants';
import {
MAX_MAILBOX_NAME_LENGTH,
MAX_VACATION_SUBJECT_LENGTH,
MAX_VACATION_SUBJECT_LENGTH_WHEN_OFF,
MAX_VACATION_TEXT_LENGTH,
MAX_VACATION_TEXT_LENGTH_WHEN_OFF,
} from './mail.constants';
import { MailService } from './mail.service';
/** 오래 걸리는 처리(전체 읽음 · 비우기 · 지우기)의 안내 — 계약 2절 "메일함 관리" */
@@ -182,7 +188,7 @@ export class MailboxController {
}
}
// 부재중 자동 응답 (계약 2절 "부재중 자동 응답") — 엔진의 VacationResponse 하나를 그대로 쓴다
// 부재중 자동 응답 (계약 2절 "부재중 자동 응답") — 메일 필터와 같은 엔진 규칙(Sieve 스크립트)에 함께 둔다 (계약 2절 "메일 필터"의 저장 자리)
@ApiTags('Mail')
@ApiCookieAuth()
@UseGuards(JwtAuthGuard)
@@ -208,14 +214,21 @@ export class MailVacationController {
summary: '부재중 자동 응답 저장 (전체 교체)',
description:
'다섯 필드를 모두 보낸다(날짜는 null 가능). 켤 때는 제목 · 내용이 앞뒤 공백을 빼고 1자 이상(저장은 쓴 그대로), ' +
`제목 ${MAX_VACATION_SUBJECT_LENGTH}자 · 내용 ${MAX_VACATION_TEXT_LENGTH}자까지(엔진의 바이트 한도 안). ` +
`꺼 둔 채로는 제목 ${MAX_VACATION_SUBJECT_LENGTH_WHEN_OFF}자 · 내용 ${MAX_VACATION_TEXT_LENGTH_WHEN_OFF.toLocaleString('ko-KR')}자까지 받는다 — 전에 저장한 긴 글이 켜져 있어도 끌 수 있게. ` +
'시작 · 끝이 둘 다 있으면 시작이 끝보다 앞이어야 한다 — 날짜는 소수 초를 버린 값으로 저장하고, 순서도 그 값으로 본다. ' +
'제목에는 제어 문자(탭 · 줄바꿈 포함)를, 내용에는 탭 · 줄바꿈 밖의 제어 문자를 쓸 수 없다. ' +
'꺼도 쓴 제목 · 내용은 남겨 둔다. 서식 본문은 쓰지 않는다. 엔진이 저장한 값을 다시 읽어 돌려준다.',
'꺼도 쓴 제목 · 내용은 남겨 둔다. 서식 본문은 쓰지 않는다. 저장한 값을 돌려준다. 메일 필터 규칙은 그대로 둔다.',
})
@ApiStandardResponse(MailVacationDto, { description: '저장한 설정' })
@ApiErrorResponse(
'VAL_001',
'형식 · 길이(제목 200자 · 내용 4,000자) · 제어 문자 · 켤 때 공백뿐인 제목 · 내용 · 시작이 끝보다 늦거나 같음(소수 초를 버린 값으로) · 모르는 필드',
`VAL_001 형식 · 길이(제목 ${MAX_VACATION_SUBJECT_LENGTH_WHEN_OFF}자 · 내용 ${MAX_VACATION_TEXT_LENGTH_WHEN_OFF.toLocaleString('ko-KR')}자, 켤 때는 제목 ${MAX_VACATION_SUBJECT_LENGTH}자 · 내용 ${MAX_VACATION_TEXT_LENGTH}자) · 제어 문자 · 켤 때 공백뿐인 제목 · 내용 · 시작이 끝보다 늦거나 같음(소수 초를 버린 값으로) · 모르는 필드 ` +
'/ MAIL_013 메일 필터와 함께 만든 엔진 규칙이 엔진 한도를 넘음 — 모두 400, 저장하지 않았다',
)
@ApiErrorResponse(
'MAIL_001',
'메일 엔진 장애 · 저장하는 사이 엔진의 규칙이 계속 바뀜(세 번) — 같은 요청을 다시 보내면 된다',
)
update(@CurrentUser() user: AuthenticatedUser, @Body() dto: MailVacationDto) {
return this.mail.updateVacation(user, dto);
+14 -3
View File
@@ -198,8 +198,11 @@ export class MessageController {
summary:
'메일 발송 (답장: inReplyTo · 전달: 원본 첨부 id 를 attachments 에)',
description:
'Idempotency-Key 가 같으면 한 번만 보낸다 — 이미 보냈으면 그 결과(201 { id })를 그대로 돌려준다. ' +
'Idempotency-Key 가 같으면 한 번만 보낸다 — 이미 보냈으면 그 결과(201 { id, rejectedRecipients } — 받지 않은 사람은 처음 발송의 제출 기록으로 다시 만든다)를 돌려준다. ' +
'같은 키인데 내용이 다르면 409 MAIL_005, 같은 키가 처리 중이면 429 REQ_001. ' +
'엔진은 받는 사람마다 그 자리에서 받을지 답한다 — 일부만 받지 않았으면 보낸 것이다(201 에 rejectedRecipients, draftId 는 지운다). ' +
'아무도 받지 않았으면 보내지 않은 것으로 다룬다 — 보낸 사본을 지우고 draftId 는 그대로 둔다. 거절 가운데 5xx(없는 주소 · 거절)가 하나라도 있으면 400 MAIL_010, ' +
'모두 4xx(지금은 받을 수 없음)면 503 MAIL_011 — 어느 쪽이든 같은 키로 다시 보내면 새로 보낸다. ' +
'서식 본문(bodyHtml)이 있으면 정화한 뒤 body(텍스트)와 함께 multipart/alternative 로 보낸다. ' +
'보내는 사람 자신의 서명 그림(<img src="/api/mail-signature/images/{id}">)만 남겨 메일 안에 함께 싣는다(multipart/related, cid:). ' +
'draftId(이어 쓴 임시 저장본)가 있으면 보내기에 성공한 뒤 — 같은 키 재시도로 처음 결과를 돌려줄 때 포함 — 그 초안을 지운다(지우지 못해도 발송은 성공). ' +
@@ -217,7 +220,7 @@ export class MessageController {
@ApiStandardResponse(SentMessageDto, {
status: HttpStatus.CREATED,
description:
'발송 완료(또는 같은 키로 이미 보낸 메일) — 보낸편지함의 메일 id',
'발송 완료(또는 같은 키로 이미 보낸 메일) — 보낸편지함의 메일 id 와 엔진이 받지 않은 사람(없으면 빈 배열)',
})
@ApiErrorResponse(
'MAIL_002',
@@ -227,6 +230,10 @@ export class MessageController {
'MAIL_007',
`MAIL_007 본문이 너무 김 (400) — body ${MAX_BODY_LENGTH.toLocaleString('ko-KR')}자·bodyHtml ${MAX_BODY_HTML_LENGTH.toLocaleString('ko-KR')}자 초과(JS 문자열 길이), bodyHtml 의 정화 전 비용·정화 뒤 UTF-8 ${MAX_BODY_VALUE_BYTES.toLocaleString('ko-KR')}바이트 초과. 메일은 나가지 않았다`,
)
@ApiErrorResponse(
'MAIL_010',
'MAIL_010 받는 사람을 엔진이 모두 받지 않았다 — 그중 5xx(없는 주소 · 거절)가 있다 (400). 보낸 사본을 지웠다 — 주소를 고쳐 같은 키로 다시 보내면 새로 보낸다',
)
@ApiErrorResponse(
'MAIL_005',
'같은 Idempotency-Key 로 이미 보낸 메일과 내용이 다르다 — 보내지 않았다 (보낸편지함 확인 후 새 키로)',
@@ -241,7 +248,11 @@ export class MessageController {
)
@ApiErrorResponse(
'MAIL_001',
'메일 엔진 장애 — 실제로는 나갔을 수 있으니 같은 키로 다시 보낸다',
'MAIL_001 메일 엔진 장애 (503) — 실제로는 나갔을 수 있으니 같은 키로 다시 보낸다',
)
@ApiErrorResponse(
'MAIL_011',
'MAIL_011 받는 사람을 엔진이 모두 지금은 받지 않았다 — 모두 4xx(한도 등) (503). 보낸 사본을 지웠다 — 잠시 뒤 같은 키로 다시 보내면 새로 보낸다',
)
send(
@CurrentUser() user: AuthenticatedUser,
+225
View File
@@ -0,0 +1,225 @@
import { startCpuTimer } from '@/../test/cpu-time';
import {
findJsonShapeProblem,
findJsonTextShapeProblem,
type JsonShapeLimits,
} from './json-shape';
const LIMITS: JsonShapeLimits = {
maxArrayLength: 1_000,
maxValues: 10_000,
maxDepth: 32,
};
/** 읽기 전(UTF-8 글자)과 읽은 뒤(값)의 판정 — 같은 본문이면 같아야 한다 */
const textProblem = (text: string) =>
findJsonTextShapeProblem(Buffer.from(text, 'utf8'), LIMITS);
const bothProblems = (value: unknown) => ({
parsed: findJsonShapeProblem(value, LIMITS),
text: textProblem(JSON.stringify(value)),
pretty: textProblem(JSON.stringify(value, null, 2)),
});
const numbers = (count: number) =>
Array.from({ length: count }, (_, index) => index);
const nested = (depth: number): unknown =>
depth === 0 ? 1 : { a: nested(depth - 1) };
/** 계약에서 가장 큰 본문들 — 필터 50개를 모두 채운 저장 · 받는 사람과 첨부 목록을 모두 채운 발송 */
const fullFilters = {
version: 'v',
rules: Array.from({ length: 50 }, (_, index) => ({
id: `r${index}`,
name: '가'.repeat(50),
isEnabled: true,
match: 'any',
conditions: Array.from({ length: 5 }, () => ({
field: 'subject',
operator: 'contains',
value: '나'.repeat(200),
})),
actions: {
moveToMailboxId: 'j',
markAsRead: true,
flag: true,
forwardTo: 'doyun.lee@arasystem.test',
},
})),
};
const recipients = (count: number) =>
Array.from({ length: count }, (_, index) => ({
name: '이도윤',
email: `u${index}@arasystem.test`,
}));
const fullSend = {
to: recipients(100),
cc: recipients(100),
bcc: recipients(100),
subject: '제목',
body: '본문',
bodyHtml: '<p>본문</p>',
attachments: Array.from({ length: 100 }, (_, index) => ({
id: `a${index}`,
name: `파일${index}.pdf`,
})),
inReplyTo: 'm1',
draftId: 'd1',
};
describe('JSON 본문의 모양 한도 — 읽은 뒤(findJsonShapeProblem) · 읽기 전(findJsonTextShapeProblem)', () => {
it('계약에서 가장 큰 본문들(필터 50개 · 받는 사람 300명과 첨부 100개)은 한도 안이다', () => {
for (const body of [fullFilters, fullSend, { ids: numbers(100) }]) {
expect(bothProblems(body)).toEqual({
parsed: null,
text: null,
pretty: null,
});
}
});
it('원시 값 · 빈 본문 · 빈 객체와 배열은 한도 안이다', () => {
for (const body of [null, 1, 'x', true, {}, [], { a: [] }, [[], {}]]) {
expect(bothProblems(body)).toEqual({
parsed: null,
text: null,
pretty: null,
});
}
expect(findJsonShapeProblem(undefined, LIMITS)).toBeNull();
expect(textProblem('')).toBeNull();
});
it('배열 하나는 1,000개까지 — 1,001개면 array-too-long (중첩된 배열도)', () => {
expect(bothProblems({ ids: numbers(1_000) }).parsed).toBeNull();
for (const body of [
{ ids: numbers(1_001) },
{ rules: [{ conditions: numbers(1_001).map(() => ({})) }] },
[numbers(1_001)],
]) {
expect(bothProblems(body)).toEqual({
parsed: 'array-too-long',
text: 'array-too-long',
pretty: 'array-too-long',
});
}
});
it('값은 본문 전체에서 10,000개까지 — 객체 · 배열 · 원시 값을 하나씩 센다', () => {
// 맨 바깥 1 + 키 9개 + 배열 9 × 1,000 = 9,010
const within = Object.fromEntries(
Array.from({ length: 9 }, (_, index) => [`k${index}`, numbers(1_000)]),
);
expect(bothProblems(within).parsed).toBeNull();
// 1 + 10 + 10 × 1,000 = 10,011
const over = Object.fromEntries(
Array.from({ length: 10 }, (_, index) => [`k${index}`, numbers(1_000)]),
);
expect(bothProblems(over)).toEqual({
parsed: 'too-many-values',
text: 'too-many-values',
pretty: 'too-many-values',
});
// 키가 아주 많은 객체 하나도 같다
const wide = Object.fromEntries(
numbers(10_000).map((index) => [`k${index}`, index]),
);
expect(bothProblems(wide).parsed).toBe('too-many-values');
});
it('객체 · 배열은 32단까지 — 33단이면 too-deep', () => {
expect(bothProblems(nested(32)).parsed).toBeNull();
expect(bothProblems(nested(33))).toEqual({
parsed: 'too-deep',
text: 'too-deep',
pretty: 'too-deep',
});
});
it('읽기 전 셈은 문자열 안의 구조 글자 · 이스케이프 · 여러 바이트 글자를 세지 않는다', () => {
const tricky = {
a: '[{,,,}] "따옴표" \\ \\" ,[',
b: ['\u{1f334}', '가,나', '\\', '"', ''],
c: { '키,[': '값]}', '': { '"': ',' } },
};
expect(textProblem(JSON.stringify(tricky))).toBe(
findJsonShapeProblem(tricky, LIMITS),
);
// 쉼표 · 괄호가 아주 많은 문자열도 값 하나다
expect(textProblem(JSON.stringify({ s: ',['.repeat(500_000) }))).toBeNull();
});
it('틀린 JSON 에서도 던지거나 멈추지 않는다 — 판정은 뒤의 JSON.parse 가 한다', () => {
for (const text of [
'{',
'}',
']]]]',
'{"a":',
'"끝나지 않은',
'[1,,2]',
'\\',
'{"a":"\\',
]) {
expect(() => textProblem(text)).not.toThrow();
}
});
describe('리뷰에서 잰 모양 — 넘는 곳에서 바로 멈춘다 (CPU 시간, 본문은 재기 전에 만든다)', () => {
const repeat = (unit: string, count: number) =>
Array.from({ length: count }, () => unit).join(',');
const shapes: [string, string][] = [
[
'필터 rules 에 {"id":N} 8만 개 (1MB)',
`{"version":"v","rules":[${numbers(80_000)
.map((index) => `{"id":${index}}`)
.join(',')}]}`,
],
[
'규칙 하나의 conditions 에 {} 15만 개 (450KB)',
`{"version":"v","rules":[{"id":"r","conditions":[${repeat('{}', 150_000)}]}]}`,
],
[
'규칙 하나의 conditions 에 {} 30만 개 (900KB)',
`{"version":"v","rules":[{"id":"r","conditions":[${repeat('{}', 300_000)}]}]}`,
],
[
'발송 to 에 {} 139만 개 (4MB — 발송 · 임시 저장 경로의 최대)',
`{"subject":"x","body":"x","to":[${repeat('{}', 1_390_000)}]}`,
],
[
'여러 통 ids 에 문자열 6만 개',
`{"ids":[${numbers(60_000)
.map((index) => `"${index.toString(36)}"`)
.join(',')}]}`,
],
];
it.each(shapes)(
'%s — 읽기 전에 array-too-long, 0.5초 안',
(_label, text) => {
const bytes = Buffer.from(text, 'utf8');
const cpuMs = startCpuTimer();
expect(findJsonTextShapeProblem(bytes, LIMITS)).toBe('array-too-long');
expect(cpuMs()).toBeLessThan(500);
},
);
it.each(shapes.filter(([, text]) => text.length < 1_100_000))(
'%s — 읽은 뒤에도(JSON.parse 포함) array-too-long, 0.5초 안',
(_label, text) => {
const cpuMs = startCpuTimer();
expect(findJsonShapeProblem(JSON.parse(text), LIMITS)).toBe(
'array-too-long',
);
expect(cpuMs()).toBeLessThan(500);
},
);
it('10만 단 중첩(600KB)도 재귀 없이 바로 too-deep — 읽기 전 · 읽은 뒤 모두', () => {
const text = `{"z":${'{"a":'.repeat(100_000)}1${'}'.repeat(100_000)}}`;
const cpuMs = startCpuTimer();
expect(textProblem(text)).toBe('too-deep');
expect(findJsonShapeProblem(JSON.parse(text), LIMITS)).toBe('too-deep');
expect(cpuMs()).toBeLessThan(500);
});
});
});
+143
View File
@@ -0,0 +1,143 @@
/**
* JSON 본문의 모양 한도 (순수 함수) — 본문 파서 다음, DTO 변환 · 검사(ValidationPipe) **전에** 본다 (app.setup.ts).
*
* 왜: DTO 변환(class-transformer)은 배열 항목마다 DTO 객체를 만들고, 검사(class-validator)는 배열 길이 한도에 걸려도 항목마다 중첩 검사를 한다.
* 본문 크기 한도 안에서도 작은 객체 수십만 개면 몇 초씩 CPU 를 붙잡고 수백 MB 를 쓰며, 오류가 너무 많으면 스택이 넘쳐 500 이 된다
* (2026-10-28 리뷰: 1MB 에 규칙 8만 개 7.8초 · +329MB, 조건 15만 개 6.3초 → 500 SYS_001, 4MB 발송 경로는 한 건으로 메모리가 넘친다).
* 깊게 중첩한 객체도 DTO 변환의 재귀가 스택을 넘겨 500 이 된다 (60KB 로 1만 단).
*
* 그래서 값 수 · 배열 길이 · 깊이를 세다가 넘는 순간 멈춘다. 두 번 본다:
* - 읽기 전(findJsonTextShapeProblem) — UTF-8 글자 그대로 한 번 훑는다. JSON.parse 자체도 작은 객체가 많으면 무겁다
* (4MB 에 `{}` 139만 개를 읽는 데만 CPU 0.67초) — 그 전에 끊는다
* - 읽은 뒤(findJsonShapeProblem) — 값으로 위에서부터. 글자 인코딩과 상관없는 마지막 확인이다 (UTF-16 본문도 받는다)
* 둘 다 재귀 없이 돌아 깊이와 상관없고, 넘는 순간 멈춘다.
*/
export interface JsonShapeLimits {
/** 배열 하나의 항목 수 */
readonly maxArrayLength: number;
/** 본문 전체의 값 수 — 객체 · 배열 · 원시 값을 하나씩 센다 (맨 바깥 포함) */
readonly maxValues: number;
/** 객체 · 배열이 겹친 깊이 — 맨 바깥 객체가 1 */
readonly maxDepth: number;
}
/** 넘은 한도 — 로그용 */
export type JsonShapeProblem =
| 'array-too-long'
| 'too-many-values'
| 'too-deep';
interface PendingContainer {
readonly value: object;
readonly depth: number;
}
/** JSON 의 구조 글자 (UTF-8 의 한 바이트 — 여러 바이트 글자의 어느 바이트와도 겹치지 않는다) */
const QUOTE = 0x22;
const BACKSLASH = 0x5c;
const COMMA = 0x2c;
const OPEN_BRACE = 0x7b;
const CLOSE_BRACE = 0x7d;
const OPEN_BRACKET = 0x5b;
const CLOSE_BRACKET = 0x5d;
const SPACE = 0x20;
const TAB = 0x09;
const LF = 0x0a;
const CR = 0x0d;
/** JSON 의 빈칸인가 — 공백 · 탭 · LF · CR (한 바이트씩 보는 자리라 비교로 둔다) */
function isJsonWhitespace(byte: number): boolean {
return byte === SPACE || byte === TAB || byte === LF || byte === CR;
}
/**
* 읽기 전의 본문(UTF-8 바이트)이 모양 한도를 넘는지 — findJsonShapeProblem 과 같은 한도 · 같은 셈(값 = 맨 바깥 + 객체 · 배열마다 그 항목 수).
* 따옴표 문자열 안(이스케이프 포함)은 건너뛰고, 열린 객체 · 배열마다 항목을 센다 — 첫 항목은 그 첫 글자에서, 다음 항목은 쉼표에서.
* JSON 이 맞는지는 보지 않는다 — 틀린 글이면 뒤의 JSON.parse 가 거절한다(여기서는 셈만 어긋날 뿐 멈추거나 던지지 않는다)
*/
export function findJsonTextShapeProblem(
text: Uint8Array,
limits: JsonShapeLimits,
): JsonShapeProblem | null {
/** 열린 객체 · 배열 — 배열인가 · 지금까지 센 항목 수 */
const isArray: boolean[] = [];
const items: number[] = [];
let values = 1;
let inString = false;
for (let index = 0; index < text.length; index += 1) {
const byte = text[index];
if (inString) {
if (byte === BACKSLASH) index += 1;
else if (byte === QUOTE) inString = false;
continue;
}
if (isJsonWhitespace(byte)) continue;
const top = items.length - 1;
if (byte === CLOSE_BRACE || byte === CLOSE_BRACKET) {
isArray.pop();
items.pop();
continue;
}
if (byte === COMMA) {
if (top < 0) continue;
items[top] += 1;
values += 1;
if (values > limits.maxValues) return 'too-many-values';
if (isArray[top] && items[top] > limits.maxArrayLength) {
return 'array-too-long';
}
continue;
}
// 그 밖의 글자 — 열린 객체 · 배열의 첫 내용이면 첫 항목이다 (객체는 키의 따옴표, 배열은 값의 첫 글자)
if (top >= 0 && items[top] === 0) {
items[top] = 1;
values += 1;
if (values > limits.maxValues) return 'too-many-values';
}
if (byte === QUOTE) {
inString = true;
} else if (byte === OPEN_BRACE || byte === OPEN_BRACKET) {
isArray.push(byte === OPEN_BRACKET);
items.push(0);
if (items.length > limits.maxDepth) return 'too-deep';
}
}
return null;
}
/**
* 본문이 모양 한도를 넘는지 — 넘으면 그 까닭, 아니면 null (원시 값 · 빈 본문은 null).
* 배열은 길이부터 본다(항목을 열기 전에), 값 수는 객체 · 배열을 열 때마다 그 항목 수를 더해 본다.
*/
export function findJsonShapeProblem(
body: unknown,
limits: JsonShapeLimits,
): JsonShapeProblem | null {
if (typeof body !== 'object' || body === null) return null;
const pending: PendingContainer[] = [{ value: body, depth: 1 }];
let values = 1;
for (let next = pending.pop(); next !== undefined; next = pending.pop()) {
if (next.depth > limits.maxDepth) return 'too-deep';
let children: unknown[];
if (Array.isArray(next.value)) {
if (next.value.length > limits.maxArrayLength) return 'array-too-long';
children = next.value;
} else {
children = Object.values(next.value);
}
values += children.length;
if (values > limits.maxValues) return 'too-many-values';
for (const child of children) {
if (typeof child === 'object' && child !== null) {
pending.push({ value: child, depth: next.depth + 1 });
}
}
}
return null;
}
+600 -10
View File
@@ -1,7 +1,7 @@
import type { NestExpressApplication } from '@nestjs/platform-express';
import { JwtService } from '@nestjs/jwt';
import { Test, TestingModule } from '@nestjs/testing';
import { createHash } from 'crypto';
import { createHash, randomUUID } from 'crypto';
import { existsSync, unlinkSync } from 'fs';
import { get as httpGet, type IncomingMessage, type Server } from 'http';
import { basename, join } from 'path';
@@ -22,6 +22,10 @@ import {
MAX_SEARCH_LENGTH,
MAX_SEARCH_WORDS,
MAX_SUBJECT_LENGTH,
MAX_VACATION_SUBJECT_LENGTH,
MAX_VACATION_SUBJECT_LENGTH_WHEN_OFF,
MAX_VACATION_TEXT_LENGTH,
MAX_VACATION_TEXT_LENGTH_WHEN_OFF,
} from '@/modules/mail/mail.constants';
import { MAIL_PROVIDER } from '@/modules/mail/mail.provider';
import { MAIL_SETTINGS, type MailSettings } from '@/modules/mail/mail.settings';
@@ -33,6 +37,9 @@ import type {
MailboxBulkResult,
MailboxCreate,
MailboxDeletion,
MailFilterRule,
MailFilterSet,
MailFilterUpdate,
MailVacation,
MailWatch,
MailWatchListener,
@@ -44,7 +51,9 @@ import type {
MessageUpdate,
OutgoingMessage,
PasswordChangeOutcome,
RecipientRejectionReason,
SavedDraft,
SentMessage,
} from '@/modules/mail/mail.types';
import {
SIGNATURE_IMAGES_PER_SIGNATURE,
@@ -146,6 +155,44 @@ const SLOW_MAILBOX = 'slowbox';
/** 대역 메일 엔진이 실시간 알림의 처음 연결을 받지 않는 주소 (503 MAIL_001 경로) */
const EVENTS_ENGINE_DOWN = 'events.down@e2e.arasystem.test';
/**
* 대역 메일 엔진이 발송 때 받지 않는 받는 사람과 그 까닭 (계약 2절 "받는 사람별 결과").
* SMTP 응답의 분류 · 모두 거절일 때 보낸 사본 지우기는 jmap-mail.provider.spec.ts · delivery-status.spec.ts 가 본다
*/
const NOBODY = 'nobody@e2e.arasystem.test';
const BUSY = 'busy@e2e.arasystem.test';
const ENGINE_REJECTIONS = new Map<string, RecipientRejectionReason>([
[NOBODY, 'not-found'],
[BUSY, 'temporary'],
]);
/** 대역 메일 엔진이 "만든 엔진 규칙이 엔진 한도를 넘는다"고 답하는 필터의 찾을 글 (400 MAIL_013 경로) */
const ENGINE_TOO_LARGE_FILTER_VALUE = 'engine-says-too-large';
/** 대역 메일 엔진의 메일함 — 필터가 옮길 수 없는 보낸편지함(e) · 임시보관함(d) 확인용 */
const E2E_MAILBOXES: Mailbox[] = (
[
['a', 'inbox'],
['b', 'trash'],
['c', 'junk'],
['d', 'drafts'],
['e', 'sent'],
['j', null],
] as const
).map(([id, role]) => ({
id,
name: id,
role,
parentId: null,
sortOrder: 0,
totalCount: 0,
unreadCount: 0,
}));
/** 대역 엔진의 규칙 지문 — 실제 지문은 jmap/filter-source.ts (여기서는 같은 규칙이면 같은 값이면 된다) */
const fakeFilterVersion = (rules: readonly MailFilterRule[]) =>
createHash('sha256').update(JSON.stringify(rules)).digest('base64url');
/** 한 번도 설정하지 않은 계정의 부재중 자동 응답 (계약 2절) */
const NEVER_CONFIGURED_VACATION: MailVacation = {
isEnabled: false,
@@ -297,6 +344,8 @@ describe('응답 계약 (e2e)', () => {
const deletedDrafts: string[] = [];
/** 대역 엔진이 받은 발송 */
const sentMessages: { user: string; message: OutgoingMessage }[] = [];
/** 대역 엔진이 받는 사람을 모두 받지 않은 발송 — 보내지 않은 것이라 sentMessages 에 넣지 않는다 */
const refusedSends: { user: string; message: OutgoingMessage }[] = [];
/** 대역 엔진이 돌려줄 주고받은 주소 (직원별) */
const correspondents = new Map<string, Correspondence[]>();
@@ -313,8 +362,13 @@ describe('응답 계약 (e2e)', () => {
/** 4단계 — 대역 엔진이 받은 비밀번호 바꾸기 · 메일함 관리 · 부재중 자동 응답 · 한 통 바꾸기 */
const passwordChanges: { email: string; newPassword: string }[] = [];
const mailboxCalls: { user: string; action: string; args: unknown[] }[] = [];
const vacations = new Map<string, MailVacation>();
const vacationSaves: { user: string; vacation: MailVacation }[] = [];
/** 6단계 — 대역 엔진의 엔진 규칙(직원마다 하나): 필터 규칙과 부재중 자동 응답을 함께 둔다 (계약 2절 "메일 필터"의 저장 자리) */
const mailScripts = new Map<
string,
{ rules: readonly MailFilterRule[]; vacation: MailVacation }
>();
const filterSaves: { user: string; update: MailFilterUpdate }[] = [];
const singleUpdates: {
user: string;
id: string;
@@ -379,11 +433,37 @@ describe('응답 계약 (e2e)', () => {
if (email === SLOW_ACCOUNT && slowEngineGate) await slowEngineGate;
return password === CORRECT_PASSWORD ? email : null;
},
// 발송은 받은 것을 기록만 한다 — 중복 방지는 "처음 보내는 키"로 본다
// 발송은 받은 것을 기록만 한다 — 중복 방지는 "처음 보내는 키"로 본다.
// 받는 사람별 결과는 ENGINE_REJECTIONS 로 흉내 낸다 — 모두 받지 않으면 어댑터처럼 MAIL_010(5xx 가 섞임) · MAIL_011(모두 4xx)
findSentMessage: () => Promise.resolve(null),
sendMessage: (user: string, message: OutgoingMessage) => {
getRejectedRecipients: () => Promise.resolve([]),
sendMessage: (
user: string,
message: OutgoingMessage,
): Promise<SentMessage> => {
const recipients = [...message.to, ...message.cc, ...message.bcc];
const rejectedRecipients = recipients.flatMap(({ email }) => {
const reason = ENGINE_REJECTIONS.get(email);
return reason ? [{ email, reason }] : [];
});
if (rejectedRecipients.length === recipients.length) {
refusedSends.push({ user, message });
const code = rejectedRecipients.some(
({ reason }) => reason !== 'temporary',
)
? 'MAIL_010'
: 'MAIL_011';
return Promise.reject(
new BusinessException(code, {
context: { reason: 'e2e-all-recipients-rejected' },
}),
);
}
sentMessages.push({ user, message });
return Promise.resolve({ id: `sent${sentMessages.length}` });
return Promise.resolve({
id: `sent${sentMessages.length}`,
rejectedRecipients,
});
},
// 임시 저장 — 엔진 쪽 판단(초안이 아님 RES_001 · 이미 바뀜 MAIL_008)만 흉내 낸다 (실제 규칙은 jmap-mail.provider.spec.ts)
saveDraft: (
@@ -590,15 +670,71 @@ describe('응답 계약 (e2e)', () => {
return { processed: 2, remaining: 0 };
},
getVacation: (user: string): Promise<MailVacation> =>
Promise.resolve(vacations.get(user) ?? NEVER_CONFIGURED_VACATION),
Promise.resolve(
mailScripts.get(user)?.vacation ?? NEVER_CONFIGURED_VACATION,
),
// 부재중만 바꾼다 — 같은 엔진 규칙의 필터 규칙은 그대로 (실제 규칙은 jmap-filter-script.spec.ts)
setVacation: (
user: string,
vacation: MailVacation,
): Promise<MailVacation> => {
vacationSaves.push({ user, vacation });
vacations.set(user, vacation);
mailScripts.set(user, {
rules: mailScripts.get(user)?.rules ?? [],
vacation,
});
return Promise.resolve(vacation);
},
listMailboxes: (): Promise<Mailbox[]> => Promise.resolve(E2E_MAILBOXES),
// 6단계 — 엔진 쪽 판단(지문 비교 · 한도)만 흉내 낸다. 만드는 Sieve · 조건부 쓰기는 jmap/*.spec.ts 가 본다
getMailFilters: (user: string): Promise<MailFilterSet> => {
const rules = mailScripts.get(user)?.rules ?? [];
return Promise.resolve({
rules,
version: fakeFilterVersion(rules),
isActive: true,
});
},
setMailFilters: (
user: string,
update: MailFilterUpdate,
): Promise<MailFilterSet> => {
filterSaves.push({ user, update });
const current = mailScripts.get(user);
const version = fakeFilterVersion(current?.rules ?? []);
if (
version !== update.version &&
version !== fakeFilterVersion(update.rules)
) {
return Promise.reject(
new BusinessException('MAIL_012', {
context: { reason: 'e2e-filter-rules-changed' },
}),
);
}
if (
update.rules.some((rule) =>
rule.conditions.some(
({ value }) => value === ENGINE_TOO_LARGE_FILTER_VALUE,
),
)
) {
return Promise.reject(
new BusinessException('MAIL_013', {
context: { reason: 'e2e-filter-script-too-large' },
}),
);
}
mailScripts.set(user, {
rules: update.rules,
vacation: current?.vacation ?? NEVER_CONFIGURED_VACATION,
});
return Promise.resolve({
rules: update.rules,
version: fakeFilterVersion(update.rules),
isActive: true,
});
},
openMessageSource: (
_user: string,
id: string,
@@ -799,6 +935,82 @@ describe('응답 계약 (e2e)', () => {
}
});
/**
* JSON 본문의 모양 한도(app.setup.ts 의 JSON_SHAPE_LIMITS — 배열 1,000 · 값 10,000 · 깊이 32)와 UTF-8 만.
* 본문 파서 단계라 인증 · 요청 수 한도보다 앞이다 — 로그인 전의 요청도 JSON.parse · DTO 변환 · 검사 전에 끊는다.
* CPU 시간은 app.setup.spec.ts · json-shape.spec.ts 가 잰다 — 여기서는 실제 HTTP 응답(상태 · 코드 · traceId)을 본다
*/
describe('JSON 본문의 모양 한도 · UTF-8 만 (본문 파서 단계)', () => {
const expectRejectedBody = (res: Response) => {
expect(res.status).toBe(400);
expect(body(res).success).toBe(false);
expect(body(res).error?.code).toBe('VAL_001');
expect(body(res).error?.traceId).toBe(res.headers['x-request-id']);
};
const items = (count: number, item: (index: number) => string) =>
Array.from({ length: count }, (_, index) => item(index)).join(',');
it('필터 저장(1MB 경로)에 규칙 8만 개 — 로그인한 직원도 DTO 검사 전에 400 VAL_001, 엔진에 묻지 않는다', async () => {
const before = filterSaves.length;
const res = await request(server)
.put('/api/mail-filters')
.set('Cookie', cookieOf('shape.me@e2e.arasystem.test'))
.set('Content-Type', 'application/json')
.send(
`{"version":"v","rules":[${items(80_000, (index) => `{"id":${index}}`)}]}`,
);
expectRejectedBody(res);
expect(filterSaves).toHaveLength(before);
});
it('발송(4MB 경로)의 to 에 {} 139만 개 — 로그인 전이어도 401 이 아니라 400 VAL_001 (본문을 읽기 전에 끊는다)', async () => {
const text = `{"subject":"x","body":"x","to":[${items(1_390_000, () => '{}')}]}`;
expect(text.length).toBeGreaterThan(4_000_000);
expectRejectedBody(
await request(server)
.post('/api/messages')
.set('Content-Type', 'application/json')
.send(text),
);
});
it('10만 단 중첩한 본문은 400 VAL_001 — 전에는 DTO 변환의 재귀가 넘쳐 500 SYS_001 이었다', async () => {
expectRejectedBody(
await request(server)
.post('/api/auth/login')
.set('Content-Type', 'application/json')
.send(
`{"email":"a@b.c","password":"x","z":${'{"a":'.repeat(100_000)}1${'}'.repeat(100_000)}}`,
),
);
});
it('UTF-8 이 아닌 JSON 본문(charset=utf-16le · utf-7)은 작아도 400 VAL_001 — utf-_8 처럼 UTF-8 로 읽히는 이름은 받는다', async () => {
const payload = JSON.stringify({ ids: ['eaaaaab'], isRead: true });
for (const [charset, bytes] of [
['utf-16le', Buffer.from(payload, 'utf16le')],
['utf-7', Buffer.from(payload, 'latin1')],
] as const) {
expectRejectedBody(
await request(server)
.patch('/api/messages')
.set('Content-Type', `application/json; charset=${charset}`)
.send(bytes),
);
}
// 본문 파서를 지나 인증 검사에 닿는다 — 로그인하지 않아 401
const utf8 = await request(server)
.patch('/api/messages')
.set('Content-Type', 'application/json; charset=utf-_8')
.send(payload)
.expect(401);
expect(body(utf8).error?.code).toBe('AUTH_001');
});
});
it('형식이 맞는 x-request-id 는 이어받고, 그 밖의 값은 새로 발급한다', async () => {
const kept = await request(server)
.get('/api/users/999999')
@@ -1028,6 +1240,8 @@ describe('응답 계약 (e2e)', () => {
['delete', '/api/mailboxes/u1/messages'],
['get', '/api/mail-vacation'],
['put', '/api/mail-vacation'],
['get', '/api/mail-filters'],
['put', '/api/mail-filters'],
['get', '/api/messages/eaaaaab/source'],
// 템플릿 업로드 예제도 로그인한 직원만 쓴다
['post', '/api/files'],
@@ -2393,7 +2607,7 @@ describe('응답 계약 (e2e)', () => {
expect(dataOf<unknown>(read)).toEqual(vacation);
});
it('켤 때 빈(공백뿐인) 제목 · 내용, 제어 문자, 시작이 끝보다 늦음 · 같음(같은 초 포함), 날짜 형식, 빠진 키, 길이 초과, 서식 본문은 400 VAL_001 — 저장하지 않는다', async () => {
it('켤 때 빈(공백뿐인) 제목 · 내용, 제어 문자, 시작이 끝보다 늦음 · 같음(같은 초 포함), 날짜 형식, 빠진 키, 길이 초과(켤 때 150 · 600자, 꺼 둔 채로 200 · 4,000자), 서식 본문은 400 VAL_001 — 저장하지 않는다', async () => {
const before = vacationSaves.length;
const withoutToDate = Object.fromEntries(
Object.entries(vacation).filter(([key]) => key !== 'toDate'),
@@ -2407,8 +2621,18 @@ describe('응답 계약 (e2e)', () => {
{ ...vacation, fromDate: '2026-11-02' },
{ ...vacation, fromDate: '2026-11-02T15:00:00+09:00' },
withoutToDate,
{ ...vacation, subject: 'x'.repeat(201) },
{ ...vacation, textBody: 'x'.repeat(4_001) },
{ ...vacation, subject: 'x'.repeat(MAX_VACATION_SUBJECT_LENGTH + 1) },
{ ...vacation, textBody: 'x'.repeat(MAX_VACATION_TEXT_LENGTH + 1) },
{
...vacation,
isEnabled: false,
subject: 'x'.repeat(MAX_VACATION_SUBJECT_LENGTH_WHEN_OFF + 1),
},
{
...vacation,
isEnabled: false,
textBody: 'x'.repeat(MAX_VACATION_TEXT_LENGTH_WHEN_OFF + 1),
},
{ ...vacation, htmlBody: '<p>휴가</p>' },
{ ...vacation, subject: ' ' },
{ ...vacation, textBody: ' \n\t ' },
@@ -2427,6 +2651,44 @@ describe('응답 계약 (e2e)', () => {
expect(vacationSaves).toHaveLength(before);
});
it(`제목 ${MAX_VACATION_SUBJECT_LENGTH}자 · 내용 ${MAX_VACATION_TEXT_LENGTH}자까지는 한글로 채워도 저장한다 (엔진의 UTF-8 바이트 한도 안 — 계약 2절 "길이 한도의 근거")`, async () => {
const full = {
...vacation,
subject: '가'.repeat(MAX_VACATION_SUBJECT_LENGTH),
textBody: '가'.repeat(MAX_VACATION_TEXT_LENGTH),
};
const res = await put(full).expect(200);
expect(dataOf<unknown>(res)).toEqual(full);
});
it('꺼 둔 채로는 4단계 한도까지 받는다 — 4단계에 저장해 둔 영문 내용 1,000자도 끌 수 있고(200), 그대로 켜면 400 VAL_001 (줄인 뒤 켠다)', async () => {
const longOff = {
...vacation,
isEnabled: false,
textBody: 'x'.repeat(1_000),
};
const res = await put(longOff).expect(200);
expect(dataOf<unknown>(res)).toEqual(longOff);
expect(vacationSaves.at(-1)).toEqual({ user: ME, vacation: longOff });
const before = vacationSaves.length;
for (const enabling of [
{ ...longOff, isEnabled: true },
{
...longOff,
isEnabled: true,
textBody: 'x'.repeat(MAX_VACATION_TEXT_LENGTH + 1),
},
]) {
const rejected = await put(enabling).expect(400);
expect(body(rejected).error?.code).toBe('VAL_001');
}
expect(vacationSaves).toHaveLength(before);
});
it('꺼 둔 채로는 공백뿐인 제목도 저장한다 — 쓴 그대로(내용의 탭 · 줄바꿈 포함), 날짜는 소수 초를 버려 저장한다', async () => {
const off = {
...vacation,
@@ -2646,6 +2908,334 @@ describe('응답 계약 (e2e)', () => {
);
});
/**
* 받는 사람별 발송 결과 — 6단계 앞 수정 (계약 2절 "받는 사람별 결과").
* 엔진 쪽 판단(SMTP 응답 분류 · 모두 거절일 때 보낸 사본 지우기)은 대역 엔진이 흉내 낸다 (ENGINE_REJECTIONS) —
* 여기서는 HTTP 형식 · 이어 쓴 저장본 · 같은 키 잠금을 본다
*/
describe('받는 사람별 발송 결과 (POST /api/messages — 6단계 앞 수정)', () => {
const ME = 'delivery.me@e2e.arasystem.test';
const DOYUN = { name: '이도윤', email: 'doyun.lee@e2e.arasystem.test' };
const address = (email: string) => ({ name: null, email });
const send = (payload: object, key?: string) => {
const sending = request(server)
.post('/api/messages')
.set('Cookie', cookieOf(ME));
return (key ? sending.set('Idempotency-Key', key) : sending).send({
subject: '제목',
body: '본문',
...payload,
});
};
/** 이어 쓸 저장본을 하나 만든다 */
const savedDraft = async () =>
dataOf<SavedDraft>(
await request(server)
.post('/api/drafts')
.set('Cookie', cookieOf(ME))
.send({ to: [DOYUN], subject: '쓰는 중', body: '본문' })
.expect(201),
).id;
it('모두 받았으면 201 { id, rejectedRecipients: [] }', async () => {
const res = await send({ to: [DOYUN] }).expect(201);
expect(body(res)).toEqual({
success: true,
data: {
id: expect.stringMatching(/^sent\d+$/) as string,
rejectedRecipients: [],
},
});
});
it('일부만 받지 않았으면 보낸 것이다 — 201 에 받지 않은 사람(요청에 쓴 주소 · 까닭), 이어 쓴 저장본은 지운다', async () => {
const draftId = await savedDraft();
const res = await send({
to: [DOYUN],
cc: [address(NOBODY)],
bcc: [address(BUSY)],
draftId,
}).expect(201);
expect(dataOf<unknown>(res)).toEqual({
id: expect.stringMatching(/^sent\d+$/) as string,
rejectedRecipients: [
{ email: NOBODY, reason: 'not-found' },
{ email: BUSY, reason: 'temporary' },
],
});
expect(deletedDrafts).toContain(draftId);
});
it('아무도 받지 않았으면(5xx 가 섞임) 400 MAIL_010 — 표준 실패 JSON, 이어 쓴 저장본은 그대로 둔다', async () => {
const draftId = await savedDraft();
const sentBefore = sentMessages.length;
const res = await send({
to: [address(NOBODY)],
cc: [address(BUSY)],
draftId,
}).expect(400);
expect(body(res).success).toBe(false);
expect(body(res).error?.code).toBe('MAIL_010');
expect(body(res).error?.traceId).toBe(res.headers['x-request-id']);
expect(sentMessages).toHaveLength(sentBefore);
expect(deletedDrafts).not.toContain(draftId);
expect(drafts.has(draftId)).toBe(true);
});
it('모두 4xx(지금은 받을 수 없음)면 503 MAIL_011 — 이어 쓴 저장본은 그대로 둔다', async () => {
const draftId = await savedDraft();
const res = await send({ to: [address(BUSY)], draftId }).expect(503);
expect(body(res).error?.code).toBe('MAIL_011');
expect(body(res).error?.traceId).toBe(res.headers['x-request-id']);
expect(deletedDrafts).not.toContain(draftId);
});
it('모두 받지 않은 키는 잠가 두지 않는다 — 곧바로 같은 키로 다시 보내면 새로 보낸다 (429 · 409 가 아니다)', async () => {
for (const [rejected, status] of [
[NOBODY, 400],
[BUSY, 503],
] as const) {
const key = randomUUID();
const refusedBefore = refusedSends.length;
await send({ to: [address(rejected)] }, key).expect(status);
// 같은 내용으로 다시 — 처리 중(REQ_001)이 아니라 엔진에 다시 묻는다
await send({ to: [address(rejected)] }, key).expect(status);
expect(refusedSends).toHaveLength(refusedBefore + 2);
// 주소를 고쳐 같은 키로 — 보낸 사본이 없으니 내용이 달라도 MAIL_005 가 아니다
await send({ to: [DOYUN] }, key).expect(201);
}
});
});
/**
* 6단계 — 메일 필터 (계약 2절 "메일 필터").
* 엔진 쪽(만드는 Sieve · 머리의 원본 · 지문 · 조건부 쓰기 · 다시 시도)은 jmap/*.spec.ts 가 본다 —
* 여기서는 HTTP 형식 · 요청 검사 · 부재중 자동 응답과 같은 저장 자리를 쓰는지를 본다 (대역 엔진이 둘을 한 곳에 둔다)
*/
describe('6단계 — 메일 필터', () => {
describe('메일 필터 (/api/mail-filters)', () => {
const ME = 'filter.me@e2e.arasystem.test';
const rule = {
id: '0b9a4f9e-7c2d-4d3a-9f4e-1a2b3c4d5e6f',
name: '거래처 메일',
isEnabled: true,
match: 'any',
conditions: [
{
field: 'from',
operator: 'contains',
value: '@hanbit-partners.co.kr',
},
],
actions: {
moveToMailboxId: 'j',
markAsRead: true,
flag: false,
forwardTo: 'doyun.lee@e2e.arasystem.test',
},
};
const get = () =>
request(server).get('/api/mail-filters').set('Cookie', cookieOf(ME));
const put = (payload: object) =>
request(server)
.put('/api/mail-filters')
.set('Cookie', cookieOf(ME))
.send(payload);
const versionNow = async () =>
dataOf<{ version: string }>(await get().expect(200)).version;
it('처음에는 규칙 없음 · 동작함 — 받은 version 으로 저장하면 200(글은 앞뒤 공백을 지워서), 다음 조회도 그 규칙', async () => {
const first = await get().expect(200);
expect(body(first).success).toBe(true);
const initial = dataOf<{
rules: unknown[];
version: string;
isActive: boolean;
}>(first);
expect(initial).toEqual({
rules: [],
version: expect.any(String) as string,
isActive: true,
});
const res = await put({
rules: [
{
...rule,
name: ' 거래처 메일 ',
conditions: [
{ ...rule.conditions[0], value: ' @hanbit-partners.co.kr ' },
],
},
],
version: initial.version,
}).expect(200);
const saved = dataOf<{ rules: unknown[]; version: string }>(res);
expect(saved.rules).toEqual([rule]);
expect(saved.version).not.toBe(initial.version);
expect(filterSaves.at(-1)?.user).toBe(ME);
const read = dataOf<{ rules: unknown[]; version: string }>(
await get().expect(200),
);
expect(read).toEqual({
rules: [rule],
version: saved.version,
isActive: true,
});
});
it('옛 version 으로 다른 규칙을 저장하면 409 MAIL_012 — 저장하지 않는다. 같은 규칙을 다시 보내면 그대로 200', async () => {
const before = await versionNow();
await put({
rules: [{ ...rule, id: 'other-tab-rule' }],
version: before,
}).expect(200);
const conflict = await put({
rules: [{ ...rule, id: 'my-rule' }],
version: before,
}).expect(409);
expect(body(conflict).error?.code).toBe('MAIL_012');
expect(
dataOf<{ rules: { id: string }[] }>(await get().expect(200)).rules,
).toEqual([expect.objectContaining({ id: 'other-tab-rule' })]);
// 결과를 모르는 실패 뒤의 재시도 — 저장해 둔 규칙과 같으면 옛 version 이어도 저장한다
await put({
rules: [{ ...rule, id: 'other-tab-rule' }],
version: before,
}).expect(200);
});
it('형식 · 칸 사이의 규칙이 어긋나면 400 VAL_001 — 엔진에 묻지 않는다', async () => {
const version = await versionNow();
const before = filterSaves.length;
const withRule = (overrides: object) => ({
rules: [{ ...rule, ...overrides }],
version,
});
const withActions = (overrides: object) =>
withRule({ actions: { ...rule.actions, ...overrides } });
const withValue = (value: unknown) =>
withRule({ conditions: [{ ...rule.conditions[0], value }] });
const payloads = [
{ rules: [rule] },
{ rules: [rule], version, isActive: true },
{
rules: Array.from({ length: 51 }, (_, i) => ({
...rule,
id: `r${i}`,
})),
version,
},
{ rules: [rule, rule], version },
withRule({ id: '규칙 하나' }),
withRule({ name: 'x'.repeat(51) }),
withRule({ match: 'always' }),
withRule({ match: 'all', conditions: [] }),
withRule({
conditions: Array.from({ length: 6 }, () => rule.conditions[0]),
}),
withRule({ priority: 1 }),
withValue(''),
withValue(' '),
withValue('x'.repeat(201)),
withValue('광고${x}'),
withValue('광고\n줄바꿈'),
withValue('탭\t가운데'),
withActions({
moveToMailboxId: null,
markAsRead: false,
flag: false,
forwardTo: null,
}),
withActions({ forwardTo: 'someone@gmail.com' }),
withActions({ forwardTo: 'filter.me@e2e.arasystem.test' }),
withActions({ forwardTo: '${x}@e2e.arasystem.test' }),
withActions({ forwardTo: 'not-an-address' }),
withActions({ moveToMailboxId: 'e' }),
withActions({ moveToMailboxId: 'd' }),
];
// 몇 번째 본문이 어긋났는지 보이게 모두 보낸 뒤 견준다
const answers: string[] = [];
for (const payload of payloads) {
const res = await put(payload);
answers.push(`${res.status} ${body(res).error?.code ?? ''}`);
}
expect(answers).toEqual(payloads.map(() => '400 VAL_001'));
expect(filterSaves).toHaveLength(before);
// 지운 메일함을 가리키는 규칙은 받는다 — 그 메일함이 없으면 받은편지함에 들어온다
await put(withActions({ moveToMailboxId: 'deletedbox' })).expect(200);
});
it('만든 엔진 규칙이 엔진 한도를 넘으면 400 MAIL_013', async () => {
const res = await put({
rules: [
{
...rule,
conditions: [
{
...rule.conditions[0],
value: ENGINE_TOO_LARGE_FILTER_VALUE,
},
],
},
],
version: await versionNow(),
}).expect(400);
expect(body(res).error?.code).toBe('MAIL_013');
});
it('필터를 저장해도 부재중 자동 응답은 그대로다 — 같은 엔진 규칙에 함께 두고, 부재중 저장도 필터를 지킨다', async () => {
const vacation = {
isEnabled: true,
fromDate: '2026-11-02T15:00:00.000Z',
toDate: '2026-11-07T15:00:00.000Z',
subject: '부재중입니다',
textBody: '11월 7일까지 휴가입니다.',
};
await request(server)
.put('/api/mail-vacation')
.set('Cookie', cookieOf(ME))
.send(vacation)
.expect(200);
await put({
rules: [{ ...rule, isEnabled: false }],
version: await versionNow(),
}).expect(200);
const read = await request(server)
.get('/api/mail-vacation')
.set('Cookie', cookieOf(ME))
.expect(200);
expect(dataOf<unknown>(read)).toEqual(vacation);
await request(server)
.put('/api/mail-vacation')
.set('Cookie', cookieOf(ME))
.send({ ...vacation, isEnabled: false })
.expect(200);
expect(
dataOf<{ rules: unknown[] }>(await get().expect(200)).rules,
).toEqual([{ ...rule, isEnabled: false }]);
});
});
});
/**
* 출처 검사(CSRF) — 상태를 바꾸는 요청은 Sec-Fetch-Site(same-origin·none) 또는 허용 목록의 Origin 이어야 한다.
* 둘 다 없으면 브라우저가 아닌 클라이언트로 보고 통과시킨다 (docs/mail/api-contract.md 머리말).
@@ -0,0 +1,219 @@
<!doctype html>
<html lang="ko">
<head>
<meta charset="utf-8">
<title>메일 필터 상태 모음</title>
<script src="./support.js"></script>
</head>
<body>
<x-dc>
<helmet>
<style>
@font-face{font-family:'Pretendard';src:url('/_blob/16067b9aa5137abf808e3dbef30c42e0') format('woff2-variations'),url('/_blob/16067b9aa5137abf808e3dbef30c42e0') format('woff2');font-weight:45 920;font-style:normal;font-display:swap}
body{margin:0}
a{color:#0E7C86}a:hover{color:#0A5F67}
:focus-visible{outline:2px solid #0E7C86;outline-offset:2px}
.on-dark:focus-visible{outline-color:#9ED6DB}
.primary{background:#0E7C86;color:#FFFFFF}
.primary:disabled{background:#E4E7EC;color:#98A2B3;cursor:not-allowed}
.ghost{background:transparent;color:#4A5363}
.bar-action{background:transparent;color:#0A5F67}
.bar-action:hover{background:#E3F2F3}
.warn-action{background:transparent;color:#7A5200}
.warn-action:hover{background:#FDEBC8}
.field{box-sizing:border-box;border:1px solid #D0D5DD;border-radius:8px;background:#FFFFFF;font:inherit;color:#171C26}
.field.is-error{border-color:#B42318}
.menu-item{background:transparent;color:#171C26}
.menu-item:hover{background:#F2F4F7}
.toast-action{background:transparent;color:#9ED6DB}
</style>
</helmet>
<div style="width: 1440px; min-height: 1420px; box-sizing: border-box; padding: 48px; display: flex; flex-direction: column; gap: 28px; background: #F6F7F9; color: #171C26; font-family: Pretendard, -apple-system, 'Apple SD Gothic Neo', 'Malgun Gothic', system-ui, sans-serif; font-size: 13px; line-height: 1.5">
<div style="display: flex; flex-direction: column; gap: 6px; max-width: 960px">
<h1 style="margin: 0; font-size: 24px; font-weight: 700; letter-spacing: -0.02em">메일 필터</h1>
<p style="margin: 0; font-size: 13.5px; color: #4A5363">6단계에서 새로 생기는 것입니다. 필터는 설정 › 메일 › 필터에서 만들고, 「설정 · 필터」 보드에서 눌러 볼 수 있습니다. 이 보드는 그 화면의 다른 상태와 메일 읽기 화면에서 바로 만드는 길을 모았습니다.</p>
</div>
<div style="display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 20px; align-items: start">
<section style="display: flex; flex-direction: column; gap: 14px; padding: 20px; background: #FFFFFF; border: 1px solid #E6E8EC; border-radius: 12px">
<div style="display: flex; flex-direction: column; gap: 2px">
<h2 style="margin: 0; font-size: 14px; font-weight: 700">필터가 아직 없을 때</h2>
<p style="margin: 0; font-size: 12px; color: #667085">목록 자리에 안내 · [새 필터]는 머리의 단추와 같은 일을 한다</p>
</div>
<div style="display: flex; flex-direction: column; align-items: center; gap: 8px; padding: 40px 24px; border: 1px dashed #D0D5DD; border-radius: 10px; text-align: center">
<svg width="36" height="36" viewBox="0 0 24 24" fill="none" stroke="#98A2B3" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polygon points="22 3 2 3 10 12.46 10 19 14 21 14 12.46 22 3"></polygon></svg>
<p style="margin: 4px 0 0; font-size: 14px; font-weight: 600">아직 필터가 없습니다</p>
<p style="margin: 0; max-width: 400px; font-size: 12.5px; color: #667085">보낸 사람 · 받는 사람 · 제목 · 본문의 글로 메일을 골라 메일함으로 옮기거나, 읽음 · 중요로 표시하거나, 동료에게 전달합니다.</p>
<button type="button" class="primary" style="margin-top: 6px; display: inline-flex; align-items: center; gap: 6px; min-height: 44px; padding: 0 16px; border: 0; border-radius: 8px; font: inherit; font-weight: 600; cursor: pointer">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="12" y1="5" x2="12" y2="19"></line><line x1="5" y1="12" x2="19" y2="12"></line></svg>
<span>새 필터</span>
</button>
</div>
</section>
<section style="display: flex; flex-direction: column; gap: 14px; padding: 20px; background: #FFFFFF; border: 1px solid #E6E8EC; border-radius: 12px">
<div style="display: flex; flex-direction: column; gap: 2px">
<h2 style="margin: 0; font-size: 14px; font-weight: 700">저장하지 못하는 필터 — 칸 아래에 알린다</h2>
<p style="margin: 0; font-size: 12px; color: #667085">「저장」을 누를 때 검사한다 · 알림(토스트)은 띄우지 않는다 · 첫 잘못된 칸에 초점</p>
</div>
<div style="display: flex; flex-direction: column; gap: 12px; padding: 16px; border: 1px solid #E6E8EC; border-radius: 10px">
<div style="display: grid; grid-template-columns: 104px 120px minmax(0, 1fr); gap: 8px">
<span class="field" style="display: flex; align-items: center; height: 40px; padding: 0 8px">보낸 사람</span>
<span class="field" style="display: flex; align-items: center; height: 40px; padding: 0 8px">포함</span>
<span class="field is-error" style="display: flex; align-items: center; height: 40px; padding: 0 10px; color: #98A2B3">예: @hanbit-partners.co.kr 또는 이름</span>
</div>
<p style="margin: -4px 0 0; font-size: 12.5px; color: #B42318">비어 있는 조건이 있습니다. 찾을 글을 적거나 그 조건을 지워 주세요.</p>
<div style="display: grid; grid-template-columns: 104px 120px minmax(0, 1fr); gap: 8px">
<span class="field" style="display: flex; align-items: center; height: 40px; padding: 0 8px">제목</span>
<span class="field" style="display: flex; align-items: center; height: 40px; padding: 0 8px">포함</span>
<span class="field is-error" style="display: flex; align-items: center; height: 40px; padding: 0 10px">가격 ${price}</span>
</div>
<p style="margin: -4px 0 0; font-size: 12.5px; color: #B42318">‘${’ 는 쓸 수 없습니다 — 메일 서버가 다른 뜻으로 읽습니다.</p>
<div style="display: flex; align-items: center; gap: 12px">
<span style="min-width: 150px">동료에게 전달</span>
<span class="field is-error" style="flex: 1 1 auto; display: flex; align-items: center; height: 40px; padding: 0 10px">me@gmail.com</span>
</div>
<p style="margin: -4px 0 0; font-size: 12.5px; color: #B42318">사내 주소(@arasystem.it.kr)로만 전달할 수 있습니다.</p>
<p style="margin: 0; font-size: 12.5px; color: #B42318">할 일을 하나 이상 골라 주세요. <span style="color: #667085">— 할 일 칸이 모두 비었을 때</span></p>
</div>
</section>
</div>
<div style="display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 20px; align-items: start">
<section style="display: flex; flex-direction: column; gap: 14px; padding: 20px; background: #FFFFFF; border: 1px solid #E6E8EC; border-radius: 12px">
<div style="display: flex; flex-direction: column; gap: 2px">
<h2 style="margin: 0; font-size: 14px; font-weight: 700">다른 곳에서 먼저 바꿨을 때</h2>
<p style="margin: 0; font-size: 12px; color: #667085">다른 탭 · PC 에서 필터를 먼저 저장한 뒤 이 화면에서 저장하면 — 목록 위 줄 (부재중 응답을 저장한 것은 걸리지 않는다)</p>
</div>
<div style="display: flex; align-items: flex-start; gap: 10px; padding: 10px 8px 10px 12px; border-radius: 8px; background: #FFF7E6; color: #7A5200">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" style="flex: 0 0 auto; margin-top: 2px"><polyline points="23 4 23 10 17 10"></polyline><path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"></path></svg>
<span style="flex: 1 1 auto">다른 곳에서 필터가 바뀌어 저장하지 않았습니다. 다시 불러온 뒤 고쳐 주세요.</span>
<button type="button" class="warn-action" style="flex: 0 0 auto; min-height: 36px; padding: 0 10px; border: 0; border-radius: 7px; font: inherit; font-weight: 600; cursor: pointer">다시 불러오기</button>
</div>
<p style="margin: 0; font-size: 12.5px; color: #667085">고치던 창은 닫지 않고 그대로 둔다(쓴 것을 잃지 않게). 다시 불러오면 목록을 새로 받고 창을 닫는다.</p>
</section>
<section style="display: flex; flex-direction: column; gap: 14px; padding: 20px; background: #FFFFFF; border: 1px solid #E6E8EC; border-radius: 12px">
<div style="display: flex; flex-direction: column; gap: 2px">
<h2 style="margin: 0; font-size: 14px; font-weight: 700">필터가 동작하지 않을 때</h2>
<p style="margin: 0; font-size: 12px; color: #667085">메일 서버의 규칙이 이 화면 밖에서 바뀐 드문 경우(관리자 작업 등) — 목록 위 줄</p>
</div>
<div style="display: flex; align-items: flex-start; gap: 10px; padding: 10px 8px 10px 12px; border-radius: 8px; background: #FFF7E6; color: #7A5200">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" style="flex: 0 0 auto; margin-top: 2px"><circle cx="12" cy="12" r="10"></circle><line x1="12" y1="8" x2="12" y2="12"></line><line x1="12" y1="16" x2="12.01" y2="16"></line></svg>
<span style="flex: 1 1 auto">지금은 필터와 부재중 자동 응답이 동작하지 않습니다. 메일 서버의 규칙이 다른 곳에서 바뀌었습니다.</span>
<button type="button" class="warn-action" style="flex: 0 0 auto; min-height: 36px; padding: 0 10px; border: 0; border-radius: 7px; font: inherit; font-weight: 600; cursor: pointer">다시 켜기</button>
</div>
<p style="margin: 0; font-size: 12.5px; color: #667085">[다시 켜기]는 지금 보이는 필터 · 부재중 응답 그대로 다시 저장한다.</p>
</section>
<section style="display: flex; flex-direction: column; gap: 14px; padding: 20px; background: #FFFFFF; border: 1px solid #E6E8EC; border-radius: 12px">
<div style="display: flex; flex-direction: column; gap: 2px">
<h2 style="margin: 0; font-size: 14px; font-weight: 700">50개를 다 만들었을 때</h2>
<p style="margin: 0; font-size: 12px; color: #667085">[새 필터]를 끄고 까닭을 옆에 · 메일 읽기 화면의 「이런 메일로 필터 만들기」도 같은 안내</p>
</div>
<div style="display: flex; align-items: center; flex-wrap: wrap; gap: 10px 14px">
<button type="button" class="primary" disabled style="display: inline-flex; align-items: center; gap: 6px; min-height: 44px; padding: 0 16px; border: 0; border-radius: 8px; font: inherit; font-weight: 600">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="12" y1="5" x2="12" y2="19"></line><line x1="5" y1="12" x2="19" y2="12"></line></svg>
<span>새 필터</span>
</button>
<span style="font-size: 12.5px; color: #667085">필터는 50개까지 만들 수 있습니다. 쓰지 않는 필터를 지워 주세요.</span>
</div>
<p style="margin: 0; font-size: 12.5px; color: #667085">조건이 길어 메일 서버의 한도(100KB)를 넘으면 저장할 때 알린다 — "필터가 너무 길어 저장할 수 없습니다. 조건을 줄이거나 쓰지 않는 필터를 지워 주세요."</p>
</section>
</div>
<div style="display: grid; grid-template-columns: minmax(0, 1.15fr) minmax(0, 1fr); gap: 20px; align-items: start">
<section style="display: flex; flex-direction: column; gap: 14px; padding: 20px; background: #FFFFFF; border: 1px solid #E6E8EC; border-radius: 12px">
<div style="display: flex; flex-direction: column; gap: 2px">
<h2 style="margin: 0; font-size: 14px; font-weight: 700">메일에서 바로 만들기 — 읽기 화면 ⋯</h2>
<p style="margin: 0; font-size: 12px; color: #667085">⋯ 메뉴에 「이런 메일로 필터 만들기」 · 누르면 설정 › 필터의 새 필터 창이 보낸 사람으로 채워져 열린다</p>
</div>
<div style="display: grid; grid-template-columns: 236px minmax(0, 1fr); gap: 18px; align-items: start">
<div style="position: relative; display: flex; flex-direction: column; gap: 8px">
<div style="display: flex; align-items: center; justify-content: flex-end; gap: 4px; padding: 4px; border: 1px solid #E6E8EC; border-radius: 8px">
<span style="flex: 1 1 auto; padding-left: 6px; font-size: 12px; color: #667085">읽기 화면 도구 줄</span>
<span aria-hidden="true" style="display: inline-flex; align-items: center; justify-content: center; width: 36px; height: 36px; border-radius: 7px; background: #F2F4F7; color: #171C26">
<svg width="15" height="15" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><circle cx="5" cy="12" r="1.6"></circle><circle cx="12" cy="12" r="1.6"></circle><circle cx="19" cy="12" r="1.6"></circle></svg>
</span>
</div>
<div role="menu" aria-label="메일 더 보기" style="display: flex; flex-direction: column; padding: 6px; border: 1px solid #E6E8EC; border-radius: 10px; background: #FFFFFF; box-shadow: 0 10px 28px rgba(16, 24, 28, 0.14)">
<button type="button" role="menuitem" class="menu-item" style="display: flex; align-items: center; gap: 10px; min-height: 40px; box-sizing: border-box; padding: 0 10px; border: 0; border-radius: 6px; font: inherit; text-align: left; cursor: pointer">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path><polyline points="7 10 12 15 17 10"></polyline><line x1="12" y1="15" x2="12" y2="3"></line></svg>
<span>원문 내려받기</span>
</button>
<button type="button" role="menuitem" class="menu-item" style="display: flex; align-items: center; gap: 10px; min-height: 40px; box-sizing: border-box; padding: 0 10px; border: 0; border-radius: 6px; background: #F2F4F7; font: inherit; font-weight: 600; text-align: left; cursor: pointer">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polygon points="22 3 2 3 10 12.46 10 19 14 21 14 12.46 22 3"></polygon></svg>
<span>이런 메일로 필터 만들기</span>
</button>
</div>
</div>
<div style="display: flex; flex-direction: column; gap: 10px; padding: 14px; border: 1px solid #E6E8EC; border-radius: 10px">
<span style="font-size: 15px; font-weight: 700">새 필터</span>
<span style="font-size: 12.5px; font-weight: 600; color: #4A5363">어떤 메일에</span>
<span class="field" style="display: flex; align-items: center; height: 40px; padding: 0 10px">다음 조건이 모두 맞는 메일</span>
<div style="display: grid; grid-template-columns: 96px 76px minmax(0, 1fr); gap: 8px">
<span class="field" style="display: flex; align-items: center; height: 40px; padding: 0 8px">보낸 사람</span>
<span class="field" style="display: flex; align-items: center; height: 40px; padding: 0 8px">포함</span>
<span class="field" style="display: flex; align-items: center; min-width: 0; height: 40px; padding: 0 10px; overflow: hidden; white-space: nowrap">seojun.park@arasystem.it.kr</span>
</div>
<p style="margin: 0; font-size: 12.5px; color: #667085">보낸 사람의 주소로 채운다(이름이 아니라 주소 — 같은 이름의 다른 사람을 고르지 않게). 할 일은 비어 있고, 초점은 할 일의 첫 칸.</p>
</div>
</div>
</section>
<section style="display: flex; flex-direction: column; gap: 14px; padding: 20px; background: #FFFFFF; border: 1px solid #E6E8EC; border-radius: 12px">
<div style="display: flex; flex-direction: column; gap: 2px">
<h2 style="margin: 0; font-size: 14px; font-weight: 700">알림 문구</h2>
<p style="margin: 0; font-size: 12px; color: #667085">켜기 · 끄기 · 순서 바꾸기는 목록이 바로 바뀌므로 알리지 않는다 — 실패하면 되돌리고 오류 알림</p>
</div>
<div style="display: flex; flex-direction: column; gap: 10px">
<div role="status" style="display: flex; align-items: center; gap: 10px; min-height: 44px; box-sizing: border-box; padding: 6px 14px; border-radius: 10px; background: #0B3640; color: #FFFFFF">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#9FE0B5" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" style="flex: 0 0 auto"><polyline points="20 6 9 17 4 12"></polyline></svg>
<span style="flex: 1 1 auto; font-weight: 500">필터를 저장했습니다.</span>
</div>
<div role="status" style="display: flex; align-items: center; gap: 10px; min-height: 44px; box-sizing: border-box; padding: 6px 6px 6px 14px; border-radius: 10px; background: #0B3640; color: #FFFFFF">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#9FE0B5" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" style="flex: 0 0 auto"><polyline points="20 6 9 17 4 12"></polyline></svg>
<span style="flex: 1 1 auto; font-weight: 500">‘거래처 메일’ 필터를 지웠습니다.</span>
<button type="button" class="on-dark toast-action" style="min-height: 36px; padding: 0 10px; border: 0; border-radius: 7px; font: inherit; font-weight: 600; cursor: pointer">되돌리기</button>
</div>
<div role="alert" style="display: flex; align-items: flex-start; gap: 10px; min-height: 44px; box-sizing: border-box; padding: 10px 14px; border: 1px solid #E6E8EC; border-left: 3px solid #D92D20; border-radius: 10px; background: #FFFFFF; color: #171C26; box-shadow: 0 10px 28px rgba(16, 24, 28, 0.10)">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#D92D20" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" style="flex: 0 0 auto; margin-top: 2px"><circle cx="12" cy="12" r="10"></circle><line x1="12" y1="8" x2="12" y2="12"></line><line x1="12" y1="16" x2="12.01" y2="16"></line></svg>
<span style="flex: 1 1 auto">메일 서버와 연결할 수 없습니다. 잠시 후 다시 시도해 주세요. <span style="color: #667085">— 켜기 · 순서 바꾸기가 실패하면 목록을 되돌리고</span></span>
</div>
</div>
<p style="margin: 0; font-size: 12.5px; color: #667085">[되돌리기]는 지우기 직전의 목록 그대로 다시 저장한다(10초 · 마우스를 올린 동안 멈춤 — 지금의 되돌리기 알림과 같다).</p>
</section>
</div>
<section style="display: flex; flex-direction: column; gap: 12px; padding: 20px; background: #FFFFFF; border: 1px solid #E6E8EC; border-radius: 12px">
<h2 style="margin: 0; font-size: 14px; font-weight: 700">어떻게 적용하나</h2>
<div style="display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 12px 24px">
<p style="margin: 0; display: flex; gap: 8px"><span aria-hidden="true" style="color: #0E7C86; font-weight: 700">1</span><span>새로 온 메일을 <strong style="font-weight: 600">위의 필터부터</strong> 차례로 견준다. 맞는 필터의 할 일을 모두 한다 — 읽음 · 중요 표시는 겹쳐 붙는다.</span></p>
<p style="margin: 0; display: flex; gap: 8px"><span aria-hidden="true" style="color: #0E7C86; font-weight: 700">2</span><span><strong style="font-weight: 600">메일함으로 옮기는 필터에 맞으면 거기서 멈춘다</strong> — 아래 필터는 보지 않는다. 받은편지함으로 옮기기를 위에 두면 "이 사람 메일은 늘 받은편지함에"가 된다.</span></p>
<p style="margin: 0; display: flex; gap: 8px"><span aria-hidden="true" style="color: #0E7C86; font-weight: 700">3</span><span>전달은 한 통에 한 곳 — 먼저 맞은 필터의 주소로만 간다. 사본은 늘 내 메일함에 남는다.</span></p>
<p style="margin: 0; display: flex; gap: 8px"><span aria-hidden="true" style="color: #0E7C86; font-weight: 700">4</span><span>스팸으로 판정된 메일은 필터를 건너뛰고 스팸메일함으로 간다. 부재중 자동 응답도 스팸에는 답하지 않는다.</span></p>
<p style="margin: 0; display: flex; gap: 8px"><span aria-hidden="true" style="color: #0E7C86; font-weight: 700">5</span><span>옮길 메일함이 지워졌으면 받은편지함으로 들어온다 — 필터 목록에 빨간 안내. 메일함 이름을 바꾼 것은 그대로 이어진다.</span></p>
<p style="margin: 0; display: flex; gap: 8px"><span aria-hidden="true" style="color: #0E7C86; font-weight: 700">6</span><span>꺼 둔 필터는 목록에만 남고 적용하지 않는다. 이미 받은 메일에는 적용하지 않는다.</span></p>
</div>
</section>
</div>
</x-dc>
<script type="text/x-dc" data-dc-script data-props='{"$preview":{"width":1440,"height":1420}}'>
class Component extends DCLogic {
state = {};
renderVals() {
return {};
}
}
</script>
</body>
</html>
+68 -2
View File
@@ -1,8 +1,8 @@
# 웹메일 디자인 원본 (1~5단계)
# 웹메일 디자인 원본 (1~6단계)
확정된 디자인 캔버스의 원본이다. 화면 구현의 기준으로 쓴다.
- 캔버스: https://claude.ai/artifact/WJvVsKvXepxn3p75CGJAr2 (확정본: 2026-10-28, 버전 27)
- 캔버스: https://claude.ai/artifact/WJvVsKvXepxn3p75CGJAr2 (확정본: 2026-10-28, 버전 31)
- 화면: `Login`(로그인) · `Main`(분할 보기: 메일함·목록·본문) · `MainList`(목록 보기: 게시판형 표) · `MainListRead`(목록 보기에서 연 메일)
· `Compose`(메일 쓰기 — 서식 편집기) · `ComposeEditor`(메일 쓰기 · 편집기 상태: 포커스·툴바·메뉴·팝오버·인용·이미지 첨부)
· `States`(오류·빈 상태·피드백 모음)
@@ -10,6 +10,7 @@
· 3단계: `Search`(메일 검색 · 분할 보기) · `BulkSelect`(여러 통 선택 · 목록 보기) · `ListStates`(새 메일 · 검색 · 여러 통 처리 상태) — 아래 "3단계"
· 4단계: `SettingsAccount`(설정 · 비밀번호) · `SettingsVacation`(설정 · 부재중 자동 응답) · `MailboxManage`(메일함 관리 · 휴지통 비우기) · `MailActions4`(스팸 · 원문 · 오래 걸리는 처리) — 아래 "4단계"
· 5단계: `SettingsNotify`(설정 · 알림) · `MailRealtime`(실시간 새 메일 · 바탕화면 알림 상태) — 아래 "5단계"
· 6단계: `SettingsFilter`(설정 · 필터 — 고치기 창) · `MailFilterStates`(메일 필터 상태 · 메일에서 바로 만들기) — 아래 "6단계"
- 파일 형식은 캔버스 전용(`<x-dc>` 마크업 + `DCLogic` 클래스)이다. 그대로 쓰지 말고 **구조·간격·색·문구의 기준**으로만 본다.
`Compose` 의 툴바·메뉴는 눌러 볼 수 있는 시안이다 — 버튼 상태만 바뀌고 본문 글자에 서식이 입혀지지는 않는다.
@@ -346,6 +347,31 @@ API 와 서버 규칙은 계약 2절 "실시간 알림".
- 다른 탭을 보는 동안에도 탭 제목의 "(N)" 이 바로 바뀐다.
- 실시간 연결이 끊기면 화면에는 알리지 않고 1분 확인으로 돌아간다(설정 › 알림의 연결 상태로 볼 수 있다).
## 6단계 — 메일 필터 (2026-10-28 확정 — `SettingsFilter` · `MailFilterStates`)
받은 메일을 조건에 따라 메일함으로 옮기고, 읽음 · 중요로 표시하고, 동료에게 전달한다. 규칙과 엔진 쪽 사실은 계약 2절 "메일 필터".
### 설정 › 메일 › 필터
- 메뉴는 메일 묶음의 서명 · 부재중 자동 응답 · **필터** · 알림 (깔때기 아이콘). 머리는 "필터" + 설명 한 줄 + 오른쪽 [새 필터](브랜드 색).
- 목록은 테두리 상자 하나에 행 60 — 순서 번호 · 켜기 스위치(36×20) · 이름(굵게)과 요약 한 줄 · ▲ ▼(32×40, 밀도 예외 — 메일함 ⋯ 와 같다). 행을 누르면 고친다.
요약은 "보낸 사람에 ‘@hanbit-partners.co.kr’ 포함 → 옮기기: 거래처" — 조건은 " 그리고 " / " 또는 ", 할 일은 " · ". 이름이 없으면 조건이 이름 자리에 온다.
꺼 둔 규칙은 글자를 흐리게. 옮길 메일함이 지워진 규칙은 빨간 안내 한 줄 "옮길 메일함이 지워져 받은편지함으로 들어옵니다 — 눌러서 고치세요."
- 목록 아래 "필터 N개 · 50개까지 만들 수 있습니다." 와 안내 상자(차례로 적용 · 옮기면 멈춤 / 스팸 제외 / 사내 전달만 · 한 곳 · 사본 남김 / 부재중과 함께 동작).
### 고치기 창 (모달 620)
- 이름(선택) → "어떤 메일에"(조건이 모두 · 하나라도 · 모든 메일) → 조건 줄(어디에서 116 · 포함/포함하지 않음 132 · 글 · ✕ 36, 5개까지 [조건 더하기])
→ "할 일"(메일함으로 옮기기 + 메일함 고르기 · 읽음으로 표시 · 중요 표시 · 동료에게 전달 + 사내 주소) → 안내 띠 → [이 필터 지우기](고칠 때만, 왼쪽) · [저장] [취소].
- 메일함 고르기에는 받은편지함 · 내 메일함 · 스팸메일함 · 휴지통(보낸편지함 · 임시보관함 없음 — 보관함은 구현하며 더했다, 아래 "6단계 구현하며 정한 보완"). 지워진 메일함이면 "(지워진 메일함) — 다른 메일함을 고르세요"가 골라진 채로.
- 잘못은 [저장]을 누를 때 칸 아래 빨간 글로만 — 빈 조건 · `${` · 바깥 주소 전달 · 할 일 없음 (`MailFilterStates` 의 모음).
### 상태 · 들어오는 길 (`MailFilterStates`)
- 필터가 없을 때 · 다른 곳에서 먼저 바꿨을 때(목록 위 노란 줄 + [다시 불러오기]) · 동작하지 않을 때(+ [다시 켜기]) · 50개를 다 만들었을 때 · 알림 문구.
- 읽기 화면 ⋯ 메뉴에 「이런 메일로 필터 만들기」(원문 내려받기 아래) — 설정 › 필터의 새 필터 창이 보낸 사람의 **주소**로 채워져 열린다.
- 켜기 · 끄기 · 순서 바꾸기는 알리지 않는다(목록이 바로 바뀐다). 저장 "필터를 저장했습니다.", 지우기 "‘거래처 메일’ 필터를 지웠습니다. [되돌리기]".
## 구현하며 정한 보완 (2026-10-26 — 캔버스에는 아직 없다)
- 찾지 못한 첨부(보관 시간이 지난 업로드·지워진 원본)는 실패로 표시하고, 전달 원본에는 "빼기"만 둔다(다시 올릴 수 없다).
@@ -466,3 +492,43 @@ API 와 서버 규칙은 계약 2절 "실시간 알림".
- 허락을 묻는 동안 단추 · 칸은 눌러도 다시 묻지 않을 뿐 초점을 잃지 않는다(`aria-disabled`). 막힘 · 지원 안 함으로 칸이 꺼지면 초점을 안내 글로 옮긴다.
- **탭 제목 "(N)"** 은 지금처럼 메일 화면에서만 붙인다(메일 밖 화면에는 받은편지함 수의 기준이 없다). 메일 밖에 있는 동안 생긴 바뀜은 남겨 두었다가 메일 화면으로 돌아오면 맞춘다.
- **실시간 반영 시간**: 실시간이 부른 목록 맞추기는 1초 모아서 한 번 한다(한 통을 읽어도 바뀜이 오므로 — 연달은 바뀜에 목록을 여러 번 받지 않게). 그래서 새 메일은 "1초 안"이 아니라 2초 안팎에 목록에 나타난다.
### 발송 · 부재중 결함 수정 (6단계 앞, 2026-10-28 — 캔버스에는 없다)
- **받는 사람 일부가 받지 못했을 때**(없는 주소 · 받는 쪽 거절 · 지금은 받을 수 없음): 보낸 것으로 처리해 쓰기 화면을 닫고, "보냈습니다" 알림 대신 오류 알림 모양(흰 카드)에
"보냈지만 N명은 받지 못했습니다 — 주소 (까닭)"와 [보낸편지함 보기](보냈다는 알림과 같은 링크) — 두 명까지 주소 · 까닭을 적고 나머지는 " 외 N명". 까닭 문구 "없는 주소" · "받는 쪽이 거절" · "지금은 받을 수 없음".
- **모두 받지 못했을 때**: 보내지 않은 것이다 — 없는 주소 · 거절(`MAIL_010`)은 받는 사람 오류(`MAIL_002`)와 같은 안내 띠 · 같은 초점으로, 지금은 받을 수 없음(`MAIL_011`)은 오류 알림으로. 쓰던 메일은 그대로 둔다.
- **부재중 자동 응답 길이**: 제목 150자 · 내용 600자(입력칸 한도(maxlength)도 이 값, 켤 때 검사 — 꺼 둔 채로는 4단계 한도까지 저장된다) — 엔진이 바이트로 막아 한글로는 4단계의 200자 · 4,000자가 저장되지 않았다(계약 2절 "부재중 자동 응답").
### 6단계 구현하며 정한 보완 (2026-10-28 — 캔버스에는 아직 없다)
- **되돌리기**: 지우기 전 목록을 통째로 되돌리지 않고, 지운 필터만 지운 자리에 다시 넣어 저장한다 — 10초 안에 다른 필터를 켜거나 옮긴 것이 사라지지 않게(보드는 "지우기 직전의 목록 그대로").
자리는 앞 · 뒤 필터로 기억한다(앞 필터 뒤 → 없으면 뒤 필터 앞 → 없으면 지운 때의 차례) — 여러 개를 어떤 차례로 되돌려도 원래 차례가 된다.
여러 알림의 [되돌리기]는 누른 차례대로, 돌고 있는 저장이 끝난 뒤에 보낸다. 다시 넣으면 50개를 넘을 때는 보내지 않고 50개 안내를 오류 알림으로.
필터 화면을 떠난 뒤에 실패하면(다른 곳에서 바뀜 포함) 알림으로 알린다.
- **다른 곳에서 바뀜**: 고치기 창 안(단추 줄 위)에도 목록 위 줄과 같은 문구와 [다시 불러오기](36 — 밀도 예외)를 둔다 — 창이 모달이라 목록 위 줄을 누를 수 없다.
창에서 다시 불러오면 창과 쓴 내용은 그대로이고 초점은 [저장]으로 간다. 다음 [저장]은 다시 받은 목록에 넣는다 —
같은 필터의 자리, 그사이 다른 곳에서 지워졌으면 맨 아래(그때 50개를 넘으면 창 안에 50개 안내). 안내는 다시 받기 전까지 남는다(칸을 고쳐도).
다시 받은 목록에 이미 없는 필터에서 [이 필터 지우기]를 누르면 보내지 않고 창을 닫는다. 목록 위 줄의 [다시 불러오기]는 보드대로 창이 닫혀 있을 때 쓴다.
- **켜기 스위치**: 이름은 "‘…’ 필터 사용" 하나 — 켜짐 · 꺼짐은 `aria-checked` 가 알린다(보드는 이름을 끄기 · 켜기로 바꿨다). 그림은 36×20, 누르는 곳은 40×44(밀도 예외).
- **누를 수 없는 단추는 `aria-disabled`**(누른 단추 · 창이 돌아올 단추가 초점을 잃지 않게): 맨 위 ▲ · 맨 아래 ▼, 저장 중의 스위치 · ▲▼, 창의 [저장] · [이 필터 지우기],
50개일 때의 [새 필터](까닭 글을 `aria-describedby`). 마우스를 올려도 모양이 바뀌지 않는다.
메뉴의 꺼진 항목도 모두 그렇다 — 편집기 메뉴 · 이동 · 메일함 ⋯ · 읽기 화면 ⋯ 에서 화살표로 가서 초점은 받되 고를 수 없다(WAI-ARIA 메뉴). 이름 아래 까닭 글은 `aria-describedby`.
- **저장은 한 번에 하나**: 저장이 도는 동안 누른 다른 행의 스위치 · ▲▼ 는 보내지 않는다(모양은 그대로, 커서만 바뀐다).
창으로 저장할 때 켜짐은 그 순간 목록의 값이다 — 창은 켜기를 고치지 않으므로, 창을 연 뒤 실패해 되돌려진 켜기가 창의 저장으로 살아나지 않게.
- **앞뒤 공백**: 이름 · 찾을 글 · 전달 주소는 앞뒤 공백을 빼고 보낸다(서버도 뺀 값으로 저장한다).
- **문구**(보드에 없던 것): "내 주소로는 전달할 수 없습니다." · "전달할 주소를 입력해 주세요." · "전달할 주소 형식이 올바르지 않습니다."
(@ 앞은 RFC 5322 dot-atom · 64자 — 서버의 검사보다 좁게 봐서, 화면이 통과시킨 주소를 서버가 거절하지 않게). 길이 · 제어 문자 · 이름 오류는 사전의 `VAL_001` 문구.
이름 없는 필터의 이름(조건으로 만든 것)은 알림 · 단추 이름에서 50자로 자르고 "…"를 붙인다. 작은따옴표가 든 이름은 큰따옴표로 묶는다.
- **읽기 화면 ⋯**: 50개인지 알려고 메뉴를 열 때 필터를 알림 없이 받는다(받아 둔 것이 없거나 50개일 때). 결과가 오기 전에는 항목을 켜 둔다.
50개면 항목을 끄고 이름 아래에 까닭 한 줄. 보낸 사람 주소가 없는 메일에는 항목이 없다. `?from=` 으로 왔는데 50개면 창을 열지 않고 주소의 쿼리만 지운다.
- **화면 폭**: 공용 설정 틀 그대로 최대 800 · 묶음 사이 22(보드는 880 · 18 — 다른 설정 화면과 맞춘다). 머리의 [새 필터]는 틀의 `actions` 자리. 안내 상자 700, 창 620.
- **메일함**: 받아 둔 메일함 목록을 보이며 뒤에서 다시 받는다(다른 탭에서 지웠을 수 있다). 요약에 메일함 이름이 들어가 필터와 메일함을 둘 다 받은 뒤 목록을 그린다.
옮길 곳 고르기는 받은편지함 → 내 메일함 → **보관함** → 스팸메일함 → 휴지통(보관함과 그 아래 — 보드에 없었다. 서버가 받고, mailcow 에서 옮겨 온 계정에 있다).
지워진 메일함은 "(지워진 메일함)…"이 꺼진 채 골라져 보이고, 그대로 저장해도 막지 않는다(서버가 받는다 — 받은편지함에 들어온다).
- **초점**: 조건 더하기 → 새 줄의 "어디에서", 조건 빼기 → 이웃 줄의 찾을 글, 필터 지우기 → 다음 행(마지막이면 앞 행),
목록 위 [다시 불러오기] · [다시 켜기] · `?from=` 으로 연 창의 [취소] · 이미 없는 필터 지우기 → 머리의 [새 필터], 창 안 [다시 불러오기] → [저장].
- **그 밖**: [다시 켜기]가 성공해도 알림은 없다(줄이 사라진다). 목록 아래 줄은 보드 글 그대로 "… 행을 누르면 고칩니다."까지.
전달 칸은 `type=email` · 254자이고, 보기 글(받는 사람 칸 · 전달 칸)은 로그인한 직원 주소의 도메인으로 만든다.
저장하는 사이 화면을 떠나면 칸 안에 보일 거절을 알림으로 띄운다(부재중 화면과 같다). 목록에 `aria-busy` 를 걸지 않는다(칸마다 `aria-disabled`).
- **하지 않은 것**: 고치기 창에 쓰다 만 채로 떠날 때 묻기(나가기 확인) — 보드에 없다. 창을 연 채 브라우저 뒤로 가기를 하면 쓴 것을 잃는다(알려진 한계).
+447
View File
@@ -0,0 +1,447 @@
<!doctype html>
<html lang="ko">
<head>
<meta charset="utf-8">
<title>설정 · 필터</title>
<script src="./support.js"></script>
</head>
<body>
<x-dc>
<helmet>
<style>
@font-face{font-family:'Pretendard';src:url('/_blob/16067b9aa5137abf808e3dbef30c42e0') format('woff2-variations'),url('/_blob/16067b9aa5137abf808e3dbef30c42e0') format('woff2');font-weight:45 920;font-style:normal;font-display:swap}
body{margin:0}
a{color:#0E7C86}a:hover{color:#0A5F67}
:focus-visible{outline:2px solid #0E7C86;outline-offset:2px}
.on-dark:focus-visible{outline-color:#9ED6DB}
.primary{background:#0E7C86;color:#FFFFFF}
.primary:hover{background:#0A5F67}
.ghost{background:transparent;color:#4A5363}
.ghost:hover{background:#F2F4F7;color:#171C26}
.ghost:disabled{background:transparent;color:#98A2B3;cursor:not-allowed}
.danger{background:transparent;color:#B42318}
.danger:hover{background:#FEF3F2;color:#912018}
.menu-link{color:#344054}
.menu-link:hover{background:#EEF0F3}
.field{box-sizing:border-box;border:1px solid #D0D5DD;border-radius:8px;background:#FFFFFF;font:inherit;color:#171C26}
.field:focus{outline:none;border-color:#0E7C86;box-shadow:0 0 0 1px #0E7C86}
.field:disabled{background:#F8F9FB;color:#98A2B3}
.field.is-error{border-color:#B42318}
.field.is-error:focus{box-shadow:0 0 0 1px #B42318}
.rule-main:hover{background:#F8F9FB}
.icon-btn{background:transparent;color:#4A5363}
.icon-btn:hover{background:#F2F4F7;color:#171C26}
.icon-btn:disabled{background:transparent;color:#D0D5DD;cursor:default}
.toast-action{background:transparent;color:#9ED6DB}
.toast-action:hover{background:rgba(255,255,255,0.1)}
</style>
</helmet>
<div style="position: relative; min-height: 100vh; display: flex; background: #FFFFFF; color: #171C26; font-family: Pretendard, -apple-system, 'Apple SD Gothic Neo', 'Malgun Gothic', system-ui, sans-serif; font-size: 13px; line-height: 1.5">
<nav aria-label="사내 시스템" style="flex: 0 0 72px; box-sizing: border-box; display: flex; flex-direction: column; align-items: center; gap: 14px; padding: 10px 0 12px; background: #0B3640">
<a href="Main.dc.html" style="display: inline-flex; align-items: center; justify-content: center; width: 60px; min-height: 44px; border-radius: 10px; text-decoration: none">
<img src="/_blob/2b9ef4349333debc969432c859eaf1bf" alt="ARA SYSTEM" width="48" height="17" style="display: block; width: 48px; height: auto">
</a>
<ul style="list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 4px">
<li>
<a href="Main.dc.html" style="display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 3px; width: 60px; min-height: 56px; border-radius: 10px; color: #A3C2C6; font-size: 11px; font-weight: 500; text-decoration: none">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z"></path><polyline points="22,6 12,13 2,6"></polyline></svg>
<span>메일</span>
</a>
</li>
<li>
<a href="#" style="display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 3px; width: 60px; min-height: 56px; border-radius: 10px; color: #A3C2C6; font-size: 11px; font-weight: 500; text-decoration: none">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="4" width="18" height="18" rx="2" ry="2"></rect><line x1="16" y1="2" x2="16" y2="6"></line><line x1="8" y1="2" x2="8" y2="6"></line><line x1="3" y1="10" x2="21" y2="10"></line></svg>
<span>캘린더</span>
</a>
</li>
<li>
<a href="#" style="display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 3px; width: 60px; min-height: 56px; border-radius: 10px; color: #A3C2C6; font-size: 11px; font-weight: 500; text-decoration: none">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"></path></svg>
<span>파일</span>
</a>
</li>
<li>
<a href="#" style="display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 3px; width: 60px; min-height: 56px; border-radius: 10px; color: #A3C2C6; font-size: 11px; font-weight: 500; text-decoration: none">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="2" y="7" width="20" height="14" rx="2" ry="2"></rect><path d="M16 21V5a2 2 0 0 0-2-2h-4a2 2 0 0 0-2 2v16"></path></svg>
<span>ERP</span>
</a>
</li>
<li>
<a href="#" style="display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 3px; width: 60px; min-height: 56px; border-radius: 10px; color: #A3C2C6; font-size: 11px; font-weight: 500; text-decoration: none">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"></path><circle cx="9" cy="7" r="4"></circle><path d="M23 21v-2a4 4 0 0 0-3-3.87"></path><path d="M16 3.13a4 4 0 0 1 0 7.75"></path></svg>
<span>주소록</span>
</a>
</li>
</ul>
<a href="Settings.dc.html" aria-current="page" style="margin-top: auto; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 3px; width: 60px; min-height: 56px; border-radius: 10px; background: #155A64; color: #FFFFFF; font-size: 11px; font-weight: 600; text-decoration: none">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="3"></circle><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"></path></svg>
<span>설정</span>
</a>
</nav>
<div style="flex: 1 1 auto; min-width: 0; display: flex; flex-direction: column">
<header style="display: flex; align-items: center; flex-wrap: wrap; gap: 8px 16px; box-sizing: border-box; min-height: 56px; padding: 6px 12px 6px 20px; border-bottom: 1px solid #E6E8EC">
<span style="font-size: 16px; font-weight: 700; letter-spacing: -0.01em">설정</span>
<div style="flex: 1 1 280px; max-width: 520px; display: flex; align-items: center; gap: 8px; box-sizing: border-box; height: 40px; padding: 0 12px; border: 1px solid #8A94A3; border-radius: 8px; background: #FFFFFF">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#667085" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="11" cy="11" r="8"></circle><line x1="21" y1="21" x2="16.65" y2="16.65"></line></svg>
<input type="search" aria-label="통합 검색" placeholder="통합 검색" style="flex: 1 1 auto; min-width: 0; border: 0; background: transparent; font: inherit; color: #171C26">
</div>
<div style="margin-left: auto; display: flex; align-items: center; gap: 10px">
<span aria-hidden="true" style="display: inline-flex; align-items: center; justify-content: center; width: 32px; height: 32px; border-radius: 50%; background: #E3F2F3; color: #0A5F67; font-size: 13px; font-weight: 600">김</span>
<div style="display: flex; flex-direction: column; line-height: 1.3">
<span style="font-weight: 600">김아라</span>
<span style="font-size: 12px; color: #667085">ara.kim@arasystem.it.kr</span>
</div>
<a href="Login.dc.html" style="display: inline-flex; align-items: center; gap: 6px; min-height: 44px; padding: 0 10px; border-radius: 8px; color: #4A5363; font-weight: 500; text-decoration: none">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"></path><polyline points="16 17 21 12 16 7"></polyline><line x1="21" y1="12" x2="9" y2="12"></line></svg>
<span>로그아웃</span>
</a>
</div>
</header>
<div style="flex: 1 1 auto; display: flex; flex-wrap: wrap; align-items: stretch">
<nav aria-label="설정 메뉴" style="flex: 1 1 208px; box-sizing: border-box; display: flex; flex-direction: column; gap: 4px; padding: 14px 10px; background: #F8F9FB; border-right: 1px solid #E6E8EC">
<p style="margin: 4px 10px 4px; font-size: 12px; font-weight: 600; color: #667085">계정</p>
<ul style="list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 1px">
<li><a href="SettingsAccount.dc.html" class="menu-link" style="display: flex; align-items: center; gap: 10px; min-height: 40px; box-sizing: border-box; padding: 0 10px; border-radius: 7px; font-weight: 500; text-decoration: none">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="11" width="18" height="11" rx="2" ry="2"></rect><path d="M7 11V7a5 5 0 0 1 10 0v4"></path></svg>
<span>비밀번호</span>
</a></li>
</ul>
<p style="margin: 14px 10px 4px; font-size: 12px; font-weight: 600; color: #667085">메일</p>
<ul style="list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 1px">
<li><a href="Settings.dc.html" class="menu-link" style="display: flex; align-items: center; gap: 10px; min-height: 40px; box-sizing: border-box; padding: 0 10px; border-radius: 7px; font-weight: 500; text-decoration: none">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 20h9"></path><path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z"></path></svg>
<span>서명</span>
</a></li>
<li><a href="SettingsVacation.dc.html" class="menu-link" style="display: flex; align-items: center; gap: 10px; min-height: 40px; box-sizing: border-box; padding: 0 10px; border-radius: 7px; font-weight: 500; text-decoration: none">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="4" width="18" height="18" rx="2" ry="2"></rect><line x1="16" y1="2" x2="16" y2="6"></line><line x1="8" y1="2" x2="8" y2="6"></line><line x1="3" y1="10" x2="21" y2="10"></line><line x1="10" y1="14" x2="14" y2="18"></line><line x1="14" y1="14" x2="10" y2="18"></line></svg>
<span>부재중 자동 응답</span>
</a></li>
<li><a href="#" aria-current="page" style="display: flex; align-items: center; gap: 10px; min-height: 40px; box-sizing: border-box; padding: 0 10px; border-radius: 7px; background: #E3F2F3; color: #0A5F67; font-weight: 600; text-decoration: none">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polygon points="22 3 2 3 10 12.46 10 19 14 21 14 12.46 22 3"></polygon></svg>
<span>필터</span>
</a></li>
<li><a href="SettingsNotify.dc.html" class="menu-link" style="display: flex; align-items: center; gap: 10px; min-height: 40px; box-sizing: border-box; padding: 0 10px; border-radius: 7px; font-weight: 500; text-decoration: none">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9"></path><path d="M13.73 21a2 2 0 0 1-3.46 0"></path></svg>
<span>알림</span>
</a></li>
</ul>
</nav>
<main style="flex: 999 1 560px; min-width: 0; box-sizing: border-box; display: flex; flex-direction: column">
<div style="max-width: 880px; box-sizing: border-box; padding: 20px 32px 36px; display: flex; flex-direction: column; gap: 18px">
<div style="display: flex; align-items: flex-start; flex-wrap: wrap; gap: 12px 20px">
<div style="flex: 1 1 360px; display: flex; flex-direction: column; gap: 4px">
<h1 style="margin: 0; font-size: 18px; font-weight: 700; letter-spacing: -0.01em">필터</h1>
<p style="margin: 0; color: #4A5363">받은 메일을 조건에 따라 메일함으로 옮기거나, 읽음 · 중요로 표시하거나, 동료에게 전달합니다. 새로 오는 메일에만 적용됩니다.</p>
</div>
<button type="button" class="primary" onClick="{{ openNew }}" style="display: inline-flex; align-items: center; gap: 6px; min-height: 44px; padding: 0 16px; border: 0; border-radius: 8px; font: inherit; font-weight: 600; cursor: pointer">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="12" y1="5" x2="12" y2="19"></line><line x1="5" y1="12" x2="19" y2="12"></line></svg>
<span>새 필터</span>
</button>
</div>
<div style="display: flex; flex-direction: column; gap: 8px">
<ol aria-label="필터 — 위에서부터 차례로 적용" style="list-style: none; margin: 0; padding: 0; border: 1px solid #E6E8EC; border-radius: 10px; overflow: hidden">
<sc-for list="{{ rules }}" as="r" hint-placeholder-count="5">
<li class="rule-row" style="display: grid; grid-template-columns: 22px 40px minmax(0, 1fr) auto; align-items: center; gap: 10px; min-height: 60px; box-sizing: border-box; padding: 6px 8px 6px 14px; border-bottom: {{ r.border }}; background: #FFFFFF">
<span style="font-size: 12px; color: #98A2B3; font-variant-numeric: tabular-nums; text-align: right">{{ r.no }}</span>
<button type="button" role="switch" aria-checked="{{ r.onStr }}" aria-label="{{ r.switchLabel }}" onClick="{{ r.toggle }}" style="position: relative; width: 36px; height: 20px; padding: 0; border: 0; border-radius: 10px; background: {{ r.trackBg }}; cursor: pointer">
<span aria-hidden="true" style="position: absolute; top: 2px; left: {{ r.knobLeft }}px; width: 16px; height: 16px; border-radius: 50%; background: #FFFFFF; box-shadow: 0 1px 2px rgba(16, 24, 40, 0.24)"></span>
</button>
<button type="button" class="rule-main" onClick="{{ r.edit }}" aria-label="{{ r.editLabel }}" style="min-width: 0; display: flex; flex-direction: column; gap: 2px; padding: 6px 8px; border: 0; border-radius: 6px; background: transparent; font: inherit; text-align: left; cursor: pointer">
<span style="overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-weight: 600; color: {{ r.titleColor }}">{{ r.title }}</span>
<span style="overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 12.5px; color: {{ r.subColor }}">{{ r.sub }}</span>
<sc-if value="{{ r.missing }}" hint-placeholder-val="{{ false }}">
<span style="display: flex; align-items: center; gap: 6px; font-size: 12.5px; color: #B42318">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="10"></circle><line x1="12" y1="8" x2="12" y2="12"></line><line x1="12" y1="16" x2="12.01" y2="16"></line></svg>
<span>옮길 메일함이 지워져 받은편지함으로 들어옵니다 — 눌러서 고치세요.</span>
</span>
</sc-if>
</button>
<div style="display: flex; align-items: center; gap: 2px">
<button type="button" class="icon-btn" onClick="{{ r.up }}" disabled="{{ r.first }}" aria-label="{{ r.upLabel }}" style="width: 32px; height: 40px; display: inline-flex; align-items: center; justify-content: center; border: 0; border-radius: 6px; cursor: pointer">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="18 15 12 9 6 15"></polyline></svg>
</button>
<button type="button" class="icon-btn" onClick="{{ r.down }}" disabled="{{ r.last }}" aria-label="{{ r.downLabel }}" style="width: 32px; height: 40px; display: inline-flex; align-items: center; justify-content: center; border: 0; border-radius: 6px; cursor: pointer">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="6 9 12 15 18 9"></polyline></svg>
</button>
</div>
</li>
</sc-for>
</ol>
<p style="margin: 0; font-size: 12.5px; color: #667085">필터 {{ count }}개 · 50개까지 만들 수 있습니다. 행을 누르면 고칩니다.</p>
</div>
<div style="display: flex; flex-direction: column; gap: 6px; box-sizing: border-box; max-width: 700px; padding: 14px 16px; border-radius: 10px; background: #F3F8F8; color: #344054">
<p style="margin: 0; display: flex; gap: 8px"><span aria-hidden="true">·</span><span><strong style="font-weight: 600">위에서부터 차례로</strong> 적용합니다. 메일함으로 옮기는 필터에 맞으면 그 아래 필터는 적용하지 않습니다 — 순서는 ▲ ▼ 로 바꿉니다.</span></p>
<p style="margin: 0; display: flex; gap: 8px"><span aria-hidden="true">·</span><span>스팸으로 판정된 메일에는 필터를 적용하지 않습니다 — 그대로 스팸메일함으로 갑니다.</span></p>
<p style="margin: 0; display: flex; gap: 8px"><span aria-hidden="true">·</span><span>전달은 사내 주소로만 할 수 있고, 메일 한 통은 한 곳으로만 전달됩니다(위의 필터가 먼저). 전달해도 내 메일함에 그대로 남습니다.</span></p>
<p style="margin: 0; display: flex; gap: 8px"><span aria-hidden="true">·</span><span>부재중 자동 응답은 필터와 함께 동작합니다.</span></p>
</div>
</div>
</main>
</div>
</div>
<sc-if value="{{ editing }}" hint-placeholder-val="{{ false }}">
<div style="position: fixed; inset: 0; z-index: 30; display: flex; align-items: center; justify-content: center; padding: 24px; background: rgba(16, 24, 28, 0.32)">
<div role="dialog" aria-modal="true" aria-labelledby="flt-title" style="width: min(620px, 100%); max-height: calc(100vh - 48px); overflow: auto; box-sizing: border-box; padding: 18px 20px 14px; border: 1px solid #E6E8EC; border-radius: 12px; background: #FFFFFF; color: #171C26; box-shadow: 0 14px 36px rgba(16, 24, 28, 0.14); display: flex; flex-direction: column; gap: 16px">
<h3 id="flt-title" style="margin: 0; font-size: 15px; font-weight: 700">{{ dialogTitle }}</h3>
<div style="display: flex; flex-direction: column; gap: 6px">
<label for="flt-name" style="font-size: 12.5px; font-weight: 600; color: #4A5363">이름 <span style="font-weight: 400; color: #667085">(선택)</span></label>
<input id="flt-name" class="field" type="text" value="{{ d.name }}" onChange="{{ setName }}" maxlength="50" placeholder="비워 두면 조건이 이름이 됩니다" style="height: 44px; padding: 0 12px">
</div>
<fieldset style="display: flex; flex-direction: column; gap: 8px; margin: 0; padding: 0; border: 0">
<legend style="margin: 0 0 6px; padding: 0; font-size: 12.5px; font-weight: 600; color: #4A5363">어떤 메일에</legend>
<select class="field" aria-label="어떤 메일에" onChange="{{ setMatch }}" style="height: 44px; padding: 0 10px; max-width: 320px">
<option value="all" selected="{{ matchAll }}">다음 조건이 모두 맞는 메일</option>
<option value="any" selected="{{ matchAny }}">다음 조건 가운데 하나라도 맞는 메일</option>
<option value="always" selected="{{ matchAlways }}">모든 메일</option>
</select>
<sc-if value="{{ hasConds }}" hint-placeholder-val="{{ true }}">
<ul style="list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 8px">
<sc-for list="{{ conds }}" as="c" hint-placeholder-count="2">
<li style="display: grid; grid-template-columns: 116px 132px minmax(0, 1fr) 36px; align-items: center; gap: 8px">
<select class="field" aria-label="{{ c.fieldLabel }}" onChange="{{ c.onField }}" style="height: 40px; padding: 0 8px">
<option value="from" selected="{{ c.isFrom }}">보낸 사람</option>
<option value="to" selected="{{ c.isTo }}">받는 사람</option>
<option value="subject" selected="{{ c.isSubject }}">제목</option>
<option value="body" selected="{{ c.isBody }}">본문</option>
</select>
<select class="field" aria-label="{{ c.opLabel }}" onChange="{{ c.onOp }}" style="height: 40px; padding: 0 8px">
<option value="has" selected="{{ c.isHas }}">포함</option>
<option value="not" selected="{{ c.isNot }}">포함하지 않음</option>
</select>
<input class="{{ c.inputClass }}" type="text" value="{{ c.v }}" onChange="{{ c.onValue }}" aria-label="{{ c.valueLabel }}" aria-invalid="{{ c.invalid }}" maxlength="200" placeholder="{{ c.placeholder }}" style="height: 40px; padding: 0 10px; min-width: 0">
<button type="button" class="icon-btn" onClick="{{ c.remove }}" disabled="{{ c.onlyOne }}" aria-label="{{ c.removeLabel }}" style="width: 36px; height: 40px; display: inline-flex; align-items: center; justify-content: center; border: 0; border-radius: 6px; cursor: pointer">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line></svg>
</button>
</li>
</sc-for>
</ul>
<div style="display: flex; align-items: center; gap: 10px">
<button type="button" class="ghost" onClick="{{ addCond }}" disabled="{{ condFull }}" style="display: inline-flex; align-items: center; gap: 6px; min-height: 40px; padding: 0 10px; border: 0; border-radius: 7px; font: inherit; font-weight: 500; cursor: pointer">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="12" y1="5" x2="12" y2="19"></line><line x1="5" y1="12" x2="19" y2="12"></line></svg>
<span>조건 더하기</span>
</button>
<span style="font-size: 12px; color: #667085">5개까지 · 글자는 대소문자를 가리지 않습니다</span>
</div>
</sc-if>
<sc-if value="{{ condError }}" hint-placeholder-val="{{ false }}">
<p role="alert" style="margin: 0; font-size: 12.5px; color: #B42318">비어 있는 조건이 있습니다. 찾을 글을 적거나 그 조건을 지워 주세요.</p>
</sc-if>
</fieldset>
<fieldset style="display: flex; flex-direction: column; gap: 2px; margin: 0; padding: 0; border: 0">
<legend style="margin: 0 0 6px; padding: 0; font-size: 12.5px; font-weight: 600; color: #4A5363">할 일</legend>
<div style="display: flex; align-items: center; flex-wrap: wrap; gap: 4px 12px; min-height: 44px">
<label style="display: flex; align-items: center; gap: 10px; min-width: 176px; cursor: pointer">
<input type="checkbox" checked="{{ hasMove }}" onChange="{{ toggleMove }}" style="width: 18px; height: 18px; margin: 0; accent-color: #0E7C86; cursor: pointer">
<span>메일함으로 옮기기</span>
</label>
<select class="field" aria-label="옮길 메일함" disabled="{{ noMove }}" onChange="{{ setMove }}" style="height: 40px; padding: 0 10px; min-width: 200px">
<option value="gone" selected="{{ selGone }}" disabled>(지워진 메일함) — 다른 메일함을 고르세요</option>
<option value="inbox" selected="{{ selInbox }}">받은편지함</option>
<option value="vendor" selected="{{ selVendor }}">거래처</option>
<option value="notice" selected="{{ selNotice }}">전체 공지</option>
<option value="project" selected="{{ selProject }}">프로젝트</option>
<option value="junk" selected="{{ selJunk }}">스팸메일함</option>
<option value="trash" selected="{{ selTrash }}">휴지통</option>
</select>
</div>
<p style="margin: 0 0 4px 28px; font-size: 12.5px; color: #667085">옮기면 이 아래 필터는 적용하지 않습니다.</p>
<label style="display: flex; align-items: center; gap: 10px; min-height: 44px; cursor: pointer">
<input type="checkbox" checked="{{ d.read }}" onChange="{{ toggleRead }}" style="width: 18px; height: 18px; margin: 0; accent-color: #0E7C86; cursor: pointer">
<span>읽음으로 표시</span>
</label>
<label style="display: flex; align-items: center; gap: 10px; min-height: 44px; cursor: pointer">
<input type="checkbox" checked="{{ d.flag }}" onChange="{{ toggleFlag }}" style="width: 18px; height: 18px; margin: 0; accent-color: #0E7C86; cursor: pointer">
<span>중요 표시</span>
</label>
<div style="display: flex; align-items: center; flex-wrap: wrap; gap: 4px 12px; min-height: 44px">
<label style="display: flex; align-items: center; gap: 10px; min-width: 176px; cursor: pointer">
<input type="checkbox" checked="{{ hasFwd }}" onChange="{{ toggleFwd }}" style="width: 18px; height: 18px; margin: 0; accent-color: #0E7C86; cursor: pointer">
<span>동료에게 전달</span>
</label>
<input class="{{ fwdClass }}" type="text" value="{{ d.fwd }}" onChange="{{ setFwd }}" disabled="{{ noFwd }}" aria-label="전달할 사내 주소" aria-invalid="{{ fwdInvalid }}" placeholder="이름@arasystem.it.kr" style="flex: 1 1 240px; height: 40px; padding: 0 10px; max-width: 320px">
</div>
<p style="margin: 0 0 0 28px; font-size: 12.5px; color: {{ fwdHelpColor }}">{{ fwdHelp }}</p>
<sc-if value="{{ actionError }}" hint-placeholder-val="{{ false }}">
<p role="alert" style="margin: 4px 0 0; font-size: 12.5px; color: #B42318">할 일을 하나 이상 골라 주세요.</p>
</sc-if>
</fieldset>
<p style="margin: 0; padding: 10px 12px; border-radius: 8px; background: #F8F9FB; font-size: 12.5px; color: #4A5363">새로 오는 메일에만 적용됩니다 — 이미 받은 메일은 그대로입니다. 스팸으로 판정된 메일에는 적용하지 않습니다.</p>
<div style="display: flex; align-items: center; gap: 8px; margin-top: 2px">
<sc-if value="{{ canDelete }}" hint-placeholder-val="{{ true }}">
<button type="button" class="danger" onClick="{{ remove }}" style="display: inline-flex; align-items: center; gap: 6px; min-height: 44px; padding: 0 12px; border: 0; border-radius: 8px; font: inherit; font-weight: 600; cursor: pointer">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="3 6 5 6 21 6"></polyline><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path></svg>
<span>이 필터 지우기</span>
</button>
</sc-if>
<div style="margin-left: auto; display: flex; gap: 8px">
<button type="button" class="primary" onClick="{{ save }}" style="min-height: 44px; padding: 0 18px; border: 0; border-radius: 8px; font: inherit; font-weight: 600; cursor: pointer">저장</button>
<button type="button" class="ghost" onClick="{{ cancel }}" style="min-height: 44px; padding: 0 14px; border: 0; border-radius: 8px; font: inherit; font-weight: 500; cursor: pointer">취소</button>
</div>
</div>
</div>
</div>
</sc-if>
<sc-if value="{{ hasToast }}" hint-placeholder-val="{{ false }}">
<div role="status" style="position: fixed; right: 20px; bottom: 20px; z-index: 20; width: min(420px, calc(100vw - 40px)); box-sizing: border-box; display: flex; align-items: center; gap: 10px; min-height: 44px; padding: 6px 6px 6px 14px; border-radius: 10px; background: #0B3640; color: #FFFFFF; box-shadow: 0 10px 28px rgba(16, 24, 28, 0.22)">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#9FE0B5" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" style="flex: 0 0 auto"><polyline points="20 6 9 17 4 12"></polyline></svg>
<span style="flex: 1 1 auto; font-weight: 500">{{ toastText }}</span>
<sc-if value="{{ toastUndo }}" hint-placeholder-val="{{ false }}">
<button type="button" class="on-dark toast-action" onClick="{{ undo }}" style="min-height: 36px; padding: 0 10px; border: 0; border-radius: 7px; font: inherit; font-weight: 600; cursor: pointer">되돌리기</button>
</sc-if>
</div>
</sc-if>
</div>
</x-dc>
<script type="text/x-dc" data-dc-script data-props='{"$preview":{"width":1440,"height":900}}'>
class Component extends DCLogic {
state = {
rules: [
{ id: 'r1', name: '휴가 중 전달', on: false, match: 'always', conds: [], move: null, read: false, flag: false, fwd: 'doyun.lee@arasystem.it.kr' },
{ id: 'r2', name: '거래처 메일', on: true, match: 'all', conds: [{ f: 'from', op: 'has', v: '@hanbit-partners.co.kr' }], move: 'vendor', read: false, flag: false, fwd: '' },
{ id: 'r3', name: '결재 알림', on: true, match: 'all', conds: [{ f: 'subject', op: 'has', v: '[결재]' }], move: null, read: true, flag: true, fwd: '' },
{ id: 'r4', name: '', on: false, match: 'all', conds: [{ f: 'to', op: 'has', v: 'all@arasystem.it.kr' }], move: 'notice', read: true, flag: false, fwd: '' },
{ id: 'r5', name: '알파 프로젝트', on: true, match: 'any', conds: [{ f: 'subject', op: 'has', v: '알파' }, { f: 'body', op: 'has', v: 'Project Alpha' }], move: 'gone', read: false, flag: false, fwd: '' },
],
edit: null,
toast: null,
};
renderVals() {
const st = this.state;
const BOXES = [
{ id: 'inbox', name: '받은편지함' }, { id: 'vendor', name: '거래처' }, { id: 'notice', name: '전체 공지' },
{ id: 'project', name: '프로젝트' }, { id: 'junk', name: '스팸메일함' }, { id: 'trash', name: '휴지통' },
];
const FIELD = { from: '보낸 사람', to: '받는 사람', subject: '제목', body: '본문' };
const HINT = { from: '예: @hanbit-partners.co.kr 또는 이름', to: '예: all@arasystem.it.kr', subject: '예: [결재]', body: '예: 견적서' };
const DOMAIN = '@arasystem.it.kr';
const boxName = (id) => (BOXES.find((b) => b.id === id) || {}).name;
const condText = (c) => `${FIELD[c.f]}에 ‘${c.v}’ ${c.op === 'has' ? '포함' : '포함하지 않음'}`;
const condsText = (r) => (r.match === 'always' ? '모든 메일' : r.conds.map(condText).join(r.match === 'all' ? ' 그리고 ' : ' 또는 '));
const actText = (r) => [
r.move ? `옮기기: ${boxName(r.move) || '없는 메일함'}` : null,
r.read ? '읽음으로 표시' : null,
r.flag ? '중요 표시' : null,
r.fwd ? `전달: ${r.fwd}` : null,
].filter(Boolean).join(' · ');
const titleOf = (r) => r.name || condsText(r);
const setRules = (rules, toast) => this.setState({ rules, toast: toast === undefined ? st.toast : toast });
const move = (i, by) => {
const next = st.rules.slice();
const [it] = next.splice(i, 1);
next.splice(i + by, 0, it);
setRules(next, null);
};
const n = st.rules.length;
const rules = st.rules.map((r, i) => ({
no: i + 1,
border: i === n - 1 ? '0' : '1px solid #EEF0F3',
onStr: r.on ? 'true' : 'false',
switchLabel: `‘${titleOf(r)}’ 필터 ${r.on ? '끄기' : '켜기'}`,
trackBg: r.on ? '#0E7C86' : '#D0D5DD',
knobLeft: r.on ? 18 : 2,
toggle: () => setRules(st.rules.map((x, k) => (k === i ? { ...x, on: !x.on } : x)), null),
edit: () => this.setState({ edit: { index: i, d: JSON.parse(JSON.stringify(r)), error: null }, toast: null }),
editLabel: `‘${titleOf(r)}’ 필터 고치기`,
title: titleOf(r),
sub: r.name ? `${condsText(r)} → ${actText(r)}` : `→ ${actText(r)}`,
titleColor: r.on ? '#171C26' : '#667085',
subColor: r.on ? '#4A5363' : '#98A2B3',
missing: Boolean(r.move) && !boxName(r.move),
up: () => move(i, -1),
down: () => move(i, 1),
first: i === 0,
last: i === n - 1,
upLabel: `‘${titleOf(r)}’ 위로`,
downLabel: `‘${titleOf(r)}’ 아래로`,
}));
const e = st.edit;
const d = e ? e.d : { name: '', match: 'all', conds: [{ f: 'from', op: 'has', v: '' }], move: null, read: false, flag: false, fwd: '' };
const patch = (p) => this.setState({ edit: { ...e, d: { ...d, ...p }, error: null } });
const setCond = (k, p) => patch({ conds: d.conds.map((c, j) => (j === k ? { ...c, ...p } : c)) });
const fwdBad = Boolean(e && e.error === 'fwd');
const conds = d.conds.map((c, k) => ({
...c,
isFrom: c.f === 'from', isTo: c.f === 'to', isSubject: c.f === 'subject', isBody: c.f === 'body',
isHas: c.op === 'has', isNot: c.op === 'not',
fieldLabel: `조건 ${k + 1} — 어디에서`, opLabel: `조건 ${k + 1} — 포함 · 포함하지 않음`, valueLabel: `조건 ${k + 1} — 찾을 글`, removeLabel: `조건 ${k + 1} 지우기`,
placeholder: HINT[c.f],
invalid: e && e.error === 'cond' && !c.v.trim() ? 'true' : 'false',
inputClass: e && e.error === 'cond' && !c.v.trim() ? 'field is-error' : 'field',
onField: (ev) => setCond(k, { f: ev.target.value }),
onOp: (ev) => setCond(k, { op: ev.target.value }),
onValue: (ev) => setCond(k, { v: ev.target.value }),
remove: () => patch({ conds: d.conds.filter((_, j) => j !== k) }),
onlyOne: d.conds.length === 1,
}));
const save = () => {
if (d.match !== 'always' && d.conds.some((c) => !c.v.trim())) return this.setState({ edit: { ...e, error: 'cond' } });
if (!d.move && !d.read && !d.flag && !d.fwd) return this.setState({ edit: { ...e, error: 'action' } });
if (d.fwd && !d.fwd.trim().toLowerCase().endsWith(DOMAIN)) return this.setState({ edit: { ...e, error: 'fwd' } });
const rule = { ...d, conds: d.match === 'always' ? [] : d.conds, fwd: d.fwd.trim() };
const next = e.index === null ? [...st.rules, { ...rule, id: `r${Date.now()}`, on: true }] : st.rules.map((x, k) => (k === e.index ? { ...x, ...rule } : x));
this.setState({ rules: next, edit: null, toast: { text: '필터를 저장했습니다.' } });
};
const removed = e && e.index !== null ? st.rules[e.index] : null;
return {
rules,
count: n,
openNew: () => this.setState({ edit: { index: null, d, error: null }, toast: null }),
editing: Boolean(e),
dialogTitle: e && e.index !== null ? '필터 고치기' : '새 필터',
d,
setName: (ev) => patch({ name: ev.target.value }),
setMatch: (ev) => patch({ match: ev.target.value, conds: ev.target.value !== 'always' && d.conds.length === 0 ? [{ f: 'from', op: 'has', v: '' }] : d.conds }),
matchAll: d.match === 'all', matchAny: d.match === 'any', matchAlways: d.match === 'always',
hasConds: d.match !== 'always',
conds,
addCond: () => patch({ conds: [...d.conds, { f: 'subject', op: 'has', v: '' }] }),
condFull: d.conds.length >= 5,
condError: Boolean(e && e.error === 'cond'),
hasMove: Boolean(d.move), noMove: !d.move,
toggleMove: (ev) => patch({ move: ev.target.checked ? 'vendor' : null }),
setMove: (ev) => patch({ move: ev.target.value }),
selGone: Boolean(d.move) && !boxName(d.move), selInbox: d.move === 'inbox', selVendor: d.move === 'vendor' || !d.move, selNotice: d.move === 'notice', selProject: d.move === 'project', selJunk: d.move === 'junk', selTrash: d.move === 'trash',
toggleRead: (ev) => patch({ read: ev.target.checked }),
toggleFlag: (ev) => patch({ flag: ev.target.checked }),
hasFwd: d.fwd !== '' || Boolean(e && e.fwdOn), noFwd: d.fwd === '' && !(e && e.fwdOn),
toggleFwd: (ev) => this.setState({ edit: { ...e, fwdOn: ev.target.checked, d: { ...d, fwd: ev.target.checked ? d.fwd : '' }, error: null } }),
setFwd: (ev) => patch({ fwd: ev.target.value }),
fwdClass: fwdBad ? 'field is-error' : 'field',
fwdInvalid: fwdBad ? 'true' : 'false',
fwdHelp: fwdBad ? '사내 주소(@arasystem.it.kr)로만 전달할 수 있습니다.' : '사내 주소로만 전달할 수 있습니다. 전달해도 내 메일함에 그대로 남습니다.',
fwdHelpColor: fwdBad ? '#B42318' : '#667085',
actionError: Boolean(e && e.error === 'action'),
canDelete: Boolean(e && e.index !== null),
remove: () => this.setState({ rules: st.rules.filter((_, k) => k !== e.index), edit: null, toast: { text: `‘${titleOf(removed)}’ 필터를 지웠습니다.`, undo: st.rules } }),
save,
cancel: () => this.setState({ edit: null }),
hasToast: Boolean(st.toast),
toastText: st.toast ? st.toast.text : '',
toastUndo: Boolean(st.toast && st.toast.undo),
undo: () => this.setState({ rules: st.toast.undo, toast: { text: '필터를 되돌렸습니다.' } }),
};
}
}
</script>
</body>
</html>
+6 -3
View File
@@ -22,15 +22,18 @@
"MailboxManage.dc.html": { "x": 3040, "y": 6640, "w": 1440, "h": 900, "title": "메일함 관리 · 휴지통 비우기", "expand": "fill", "is_interactive": true },
"MailActions4.dc.html": { "x": 4560, "y": 6640, "w": 1440, "h": 1320, "title": "스팸 · 원문 · 오래 걸리는 처리 상태" },
"SettingsNotify.dc.html": { "x": 0, "y": 8560, "w": 1440, "h": 900, "title": "설정 · 알림", "expand": "fill", "is_interactive": true },
"MailRealtime.dc.html": { "x": 1520, "y": 8560, "w": 1440, "h": 1520, "title": "실시간 새 메일 · 바탕화면 알림 상태" }
"MailRealtime.dc.html": { "x": 1520, "y": 8560, "w": 1440, "h": 1520, "title": "실시간 새 메일 · 바탕화면 알림 상태" },
"SettingsFilter.dc.html": { "x": 0, "y": 10680, "w": 1440, "h": 900, "title": "설정 · 필터", "expand": "fill", "is_interactive": true },
"MailFilterStates.dc.html": { "x": 1520, "y": 10680, "w": 1440, "h": 1420, "title": "메일 필터 상태 · 메일에서 바로 만들기" }
},
"order": ["Main.dc.html", "Login.dc.html", "Compose.dc.html", "States.dc.html", "MainList.dc.html", "MainListRead.dc.html", "ComposeEditor.dc.html", "Settings.dc.html", "ComposeAssist.dc.html", "Search.dc.html", "BulkSelect.dc.html", "ListStates.dc.html", "SettingsAccount.dc.html", "SettingsVacation.dc.html", "MailboxManage.dc.html", "MailActions4.dc.html", "SettingsNotify.dc.html", "MailRealtime.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"],
"notes": {
"flow": { "x": 0, "y": -300, "text": "arasystem 웹메일 — 1단계 화면 (로그인 → 메일함 → 쓰기)", "kind": "title1", "maxW": 4480 },
"phase2": { "x": 0, "y": 2180, "text": "2단계 화면 — 임시 저장 · 받는 사람 자동 완성 · 서명 (2026-10-27 확정)", "kind": "title1", "maxW": 2960 },
"phase3": { "x": 0, "y": 4180, "text": "3단계 화면 — 새 메일 · 검색 · 여러 통 처리 (2026-10-27 확정)", "kind": "title1", "maxW": 4480 },
"phase4": { "x": 0, "y": 6340, "text": "4단계 화면 — 계정 · 메일 설정 (비밀번호 · 부재중 응답 · 메일함 관리 · 스팸 · 원문) — 2026-10-28 확정", "kind": "title1", "maxW": 6000 },
"phase5": { "x": 0, "y": 8260, "text": "5단계 화면 — 실시간 새 메일 · 바탕화면 알림 — 2026-10-28 확정", "kind": "title1", "maxW": 2960 }
"phase5": { "x": 0, "y": 8260, "text": "5단계 화면 — 실시간 새 메일 · 바탕화면 알림 — 2026-10-28 확정", "kind": "title1", "maxW": 2960 },
"phase6": { "x": 0, "y": 10380, "text": "6단계 화면 — 메일 필터 (설정 › 메일 › 필터 · 읽기 화면 ⋯ 「이런 메일로 필터 만들기」) — 2026-10-28 확정", "kind": "title1", "maxW": 2960 }
},
"designSystems": []
}
+163 -7
View File
@@ -8,7 +8,7 @@
- 성공 `{ "success": true, "data": … }` / 실패 `{ "success": false, "error": { "code", "message", "traceId" } }`
- 날짜·시각은 ISO 8601 UTC 문자열, 목록은 `{ items, pageInfo }`
- 이 단계에서 쓰는 에러 코드: `AUTH_001` `AUTH_002`(클라이언트 전용) `AUTH_003` `AUTH_004` `AUTH_005` `VAL_001` `RES_001` `REQ_001` `FILE_001` `FILE_002` `MAIL_001` `MAIL_002` `MAIL_003` `MAIL_004` `MAIL_005` `MAIL_006` `MAIL_007` `MAIL_008` `MAIL_009` `SYS_001`
- 이 단계에서 쓰는 에러 코드: `AUTH_001` `AUTH_002`(클라이언트 전용) `AUTH_003` `AUTH_004` `AUTH_005` `VAL_001` `RES_001` `REQ_001` `FILE_001` `FILE_002` `MAIL_001` `MAIL_002` `MAIL_003` `MAIL_004` `MAIL_005` `MAIL_006` `MAIL_007` `MAIL_008` `MAIL_009` `MAIL_010` `MAIL_011` `MAIL_012` `MAIL_013` `SYS_001`
- **적용 범위: 1단계는 웹(브라우저) 전용이다.** 인증은 HttpOnly 쿠키뿐이고 토큰을 본문으로 주지 않는다.
모바일 앱(Flutter)의 인증 방식(토큰 발급·`flutter_secure_storage`)은 앱 착수 때 이 문서에 추가한다.
- **배포 형태: 화면과 API 는 같은 주소(origin)를 쓴다.** 운영은 프론트 nginx 가 `/api` 를 백엔드로 넘기고,
@@ -209,7 +209,9 @@ HTML 은 정화하기 **전에** 길이(HTML 부분이 여럿이면 이어 붙
| `PATCH /api/mailboxes/{id}/messages` | `{ isRead: true }` — 그 메일함 전체를 읽음으로 | 200 `MailboxBulkResult` | 400 `VAL_001`, 404 `RES_001`, 429 `REQ_001`(같은 메일함의 처리가 진행 중), 503 `MAIL_001` |
| `DELETE /api/mailboxes/{id}/messages` | — 휴지통 · 스팸메일함 비우기(영구 삭제) | 200 `MailboxBulkResult` | 400 `VAL_001`(휴지통 · 스팸메일함이 아님), 404 `RES_001`, 429 `REQ_001`(같은 메일함의 처리가 진행 중), 503 `MAIL_001` |
| `GET /api/mail-vacation` | — | 200 `MailVacation` (아래 "부재중 자동 응답") | 503 `MAIL_001` |
| `PUT /api/mail-vacation` | `MailVacation` (전체 교체) | 200 `MailVacation` | 400 `VAL_001`, 503 `MAIL_001` |
| `PUT /api/mail-vacation` | `MailVacation` (전체 교체) | 200 `MailVacation`(저장한 값) | 400 `VAL_001` · `MAIL_013`(필터와 함께 만든 엔진 규칙이 한도를 넘음 — 6단계), 503 `MAIL_001` |
| `GET /api/mail-filters` | — | 200 `MailFilterSet` (아래 "메일 필터", 6단계) | 503 `MAIL_001` |
| `PUT /api/mail-filters` | `{ rules, version }` (전체 교체) | 200 `MailFilterSet` | 400 `VAL_001` · `MAIL_013`(만든 규칙이 엔진 한도를 넘음), 409 `MAIL_012`(그사이 다른 곳에서 필터가 바뀜 — 저장 안 함), 503 `MAIL_001` |
| `GET /api/mail-quota` | — | 200 `MailQuota` | `MAIL_001` |
| `GET /api/mail-state` | — | 200 `MailState` (아래 "새 메일 확인") | 503 `MAIL_001` |
| `GET /api/mail-events` | — | `text/event-stream` (표준 래퍼 없음 — 아래 "실시간 알림", 5단계) | 401 `AUTH_001`, 429 `REQ_001`(한 직원의 연결 5개 초과), 503 `MAIL_001` |
@@ -219,7 +221,7 @@ HTML 은 정화하기 **전에** 길이(HTML 부분이 여럿이면 이어 붙
| `PATCH /api/messages` | `{ ids, isRead?, isFlagged?, mailboxId?, isJunk? }` — 여러 통 (아래 "여러 통 한꺼번에") | 200 `MessagesPatched` | 400 `VAL_001`, 404 `RES_001`(없는 `mailboxId` — 아무것도 바꾸지 않음), 503 `MAIL_001` |
| `DELETE /api/messages/{id}` | — | 204 — 휴지통이 아니면 휴지통으로 이동, 휴지통에만 있으면 영구 삭제(확인한 상태 그대로일 때만 — 아래). 휴지통과 다른 메일함에 함께 있으면 휴지통에서만 뺀다. **웹 클라이언트는 쓰지 않는다**(3단계 — 아래 "여러 통 한꺼번에") | 404 `RES_001`, 503 `MAIL_001`(확인하는 사이 계속 바뀜 — 지우지 않았다) |
| `POST /api/message-deletions` | `{ ids }` — 휴지통에서 영구 삭제, 여러 통 (아래 "여러 통 한꺼번에") | 200 `MessageDeletionResult` | 400 `VAL_001`, 503 `MAIL_001`(확인하는 사이 계속 바뀜 · 휴지통 없음 — 지우지 않았다) |
| `POST /api/messages` | 아래 발송 본문 + `Idempotency-Key` 헤더 | 201 `{ id }` (보낸편지함의 메일 id) | 400 `MAIL_002`·`MAIL_003`·`MAIL_004`·`MAIL_006`·`MAIL_007`·`VAL_001`, 403 `AUTH_003`(보낼 권한 없음), 409 `MAIL_005`(같은 키·다른 내용), 413 `FILE_002`(첨부 합계 초과 포함), 429 `REQ_001`(엔진 발송 한도·같은 키 처리 중·엔진 장애 직후 30초), 503 `MAIL_001` |
| `POST /api/messages` | 아래 발송 본문 + `Idempotency-Key` 헤더 | 201 `SentMessage` (보낸편지함의 메일 id + 받지 못한 사람 — 아래 "받는 사람별 결과") | 400 `MAIL_002`·`MAIL_003`·`MAIL_004`·`MAIL_006`·`MAIL_007`·`MAIL_010`(받는 사람 모두 거절 — 없는 주소 등)·`VAL_001`, 403 `AUTH_003`(보낼 권한 없음), 409 `MAIL_005`(같은 키·다른 내용), 413 `FILE_002`(첨부 합계 초과 포함), 429 `REQ_001`(엔진 발송 한도·같은 키 처리 중·엔진 장애 직후 30초), 503 `MAIL_001` · `MAIL_011`(받는 사람 모두 지금은 받을 수 없음) |
| `POST /api/mail-attachments` | multipart `file` 1개 | 201 `Attachment` | 400 `FILE_001`, 413 `FILE_002` |
| `GET /api/messages/{id}/attachments/{attachmentId}` | — | 파일 (표준 래퍼 없음, `Content-Disposition: attachment`) | 404 `RES_001` |
| `GET /api/messages/{id}/source` | — | 받은 그대로의 메일 `message/rfc822` (표준 래퍼 없음, `Content-Disposition: attachment; filename="<제목>.eml"`) — 4단계 | 404 `RES_001`, 503 `MAIL_001` |
@@ -244,6 +246,13 @@ HTML 은 정화하기 **전에** 길이(HTML 부분이 여럿이면 이어 붙
| 첨부 20개 초과 | 400 `MAIL_006` — 클라이언트는 첨부를 추가할 때 미리 막는다 |
| 본문이 너무 김 — 텍스트 100,000자·서식 본문 500,000자 초과(JS 문자열 길이, UTF-16 단위), 서식 본문이 정화 전 비용 한도·정화 뒤 2,000,000바이트 초과 | 400 `MAIL_007` — 메일은 나가지 않는다. 클라이언트는 보내기 전에 같은 한도로 막고 본문 아래에 알린다 (아래 "서식 본문"의 한도) |
| JSON 본문 1MB 초과 (발송 `POST /api/messages` 와 임시 저장 `POST /api/drafts`·`PUT /api/drafts/{id}` 만 4MB — 서식 본문과 긴 인용 때문) | 413 `FILE_002` |
| JSON 본문의 모양이 한도를 넘음 — 배열 하나 1,000개 · 값(객체 · 배열 · 원시 값) 10,000개 · 깊이 32단 | 400 `VAL_001` |
| JSON 본문이 UTF-8 이 아님 — `charset` 이 `utf-16` · `utf-32` · `utf-7` 등 (`charset` 을 붙이지 않거나 `utf-8` 이면 된다) | 400 `VAL_001` |
**JSON 본문 모양 한도 (2026-10-28 — 6단계 리뷰에서, 전부터 있던 경로도 같은 원인)**: 본문 크기 한도 안에서도 작은 객체 수만~수십만 개면 DTO 변환 · 검사가 몇 초씩 서버를 멈추고 수백 MB 를 썼다
(리뷰 측정 — 1MB 에 필터 규칙 8만 개 7.8초, 조건 15만 개는 500). 그래서 모든 JSON 본문을 DTO 에 닿기 전에 본다 — 읽기 전에 글자를 한 번 훑고, 읽은 뒤 한 번 더.
한도는 지금 계약의 어떤 본문보다 넉넉하다(가장 긴 배열 100 · 가장 깊은 본문 5단 · 값이 가장 많은 필터 50개 저장이 약 1,600개).
이 검사와 UTF-8 검사는 본문 크기 한도처럼 인증보다 앞이라, 로그인하지 않은 요청도 401 이 아니라 400 이다. JSON 은 UTF-8 만 받는다(RFC 8259 8.1 — 웹 · 앱 모두 UTF-8 로 보낸다).
메일함 정렬(확정 디자인 기준): 받은편지함 → 보낸편지함 → 임시보관함 → 스팸메일함 → 휴지통 → 보관함 → 그 외(`sortOrder`, 이름 순).
@@ -263,7 +272,7 @@ interface MailQuota {
**중복 발송 방지 — `Idempotency-Key` 요청 헤더**: 클라이언트는 쓰기 화면마다 무작위 키(UUID) 하나를 만들어 보내고,
같은 메일을 다시 보낼 때(타임아웃 뒤 재시도 등)에도 **같은 키**를 보낸다. 서버는 같은 사용자·같은 키로 이미 발송된 메일이 있으면
다시 보내지 않고 그 결과(201 `{ id }`)를 그대로 돌려준다. 응답이 늦어 `MAIL_001` 을 받았어도 실제로는 나갔을 수 있으므로,
다시 보내지 않고 그 결과(201 `SentMessage` — 아래 "받는 사람별 결과")를 돌려준다. 응답이 늦어 `MAIL_001` 을 받았어도 실제로는 나갔을 수 있으므로,
재시도는 반드시 같은 키로 한다. (키 형식이 틀리면 400 `VAL_001`. 키가 없으면 중복 방지 없이 보낸다.)
서버는 발송 요청에 55초 안에 답한다 — 앞 단계(첨부 확인 · 서명 그림 올리기)가 늦어 보낼 시간이 10초도 남지 않으면 메일을 만들지 않고 503 `MAIL_001`
(아무것도 나가지 않았으니 같은 키로 다시 보내면 된다). 보낸 뒤 이어 쓴 저장본 지우기는 5초까지만 기다리고, 넘으면 먼저 답한 뒤 마저 지운다.
@@ -297,6 +306,53 @@ interface MailQuota {
}
```
**받는 사람별 결과 (발송 응답) — 6단계 앞 수정**
```ts
interface SentMessage {
id: string // 보낸편지함의 메일 id
rejectedRecipients: RejectedRecipient[] // 엔진이 받지 않은 사람 — 없으면 빈 배열
}
interface RejectedRecipient {
email: string // 요청에 쓴 주소 그대로 (받는 사람 · 참조 · 숨은참조 모두 — 같은 주소는 한 번)
reason: 'not-found' | 'refused' | 'temporary' // 없는 주소 · 받는 쪽이 거절 · 지금은 받을 수 없음(한도 등)
}
```
- 엔진은 제출(`EmailSubmission/set`)을 만들면서 받는 사람마다 그 자리에서 SMTP 로 묻는다. 거절된 사람은 제출 기록의 `deliveryStatus` 에
`delivered: "no"` 와 SMTP 응답(`550 5.1.2 Mailbox does not exist` · `452 4.4.5 Rate limit exceeded` 등)으로만 남고 **다시 보내지도 반송하지도 않는다**
(Stalwart 0.16.27 — 2026-10-28 확인. 전에는 서버가 이것을 보지 않아, 없는 사내 주소로 보낸 메일도 "보냈습니다"로 끝났다). 그래서 서버가 제출한 뒤 결과를 읽어 알린다.
- 결과는 제출 다음 **따로** 읽는다(`EmailSubmission/get`) — 제출 응답(`created`)에는 없고, 같은 요청 안의 참조(`#ids`)는 `onSuccessUpdateEmail` 이 덧붙이는
응답 때문에 엔진이 풀지 못한다(같은 날 확인).
- 분류 — SMTP 응답 맨 앞의 기본 코드(`550`)와 확장 상태(`5.1.2`)만 읽는다(응답 글 안의 숫자는 보지 않는다). 4xx 면 `temporary`, 5xx 이고 확장 상태가 `5.1.x`(받는 사람 주소 문제)면 `not-found`,
그 밖의 5xx 와 읽을 수 없는 응답은 `refused` — 두 값이 어긋나면 기본 코드를 따른다(`450 5.1.1` 은 temporary).
`5.1.7` · `5.1.8`(보낸 사람 주소 문제)도 `not-found` 로 보이는데, 엔진의 제출 때 거절에는 나오지 않고 같은 키 재시도가 바깥 반송까지 읽을 때만 생길 수 있다.
`delivered` 가 `"no"` 가 아니거나 결과가 없는 사람은 받은 것으로 본다 — 바깥 주소는 대기열에 들어가 `250 Queued` 이고, 나중에 실패하면 지금처럼 반송 메일이 온다.
- 결과를 읽지 못하면(엔진 오류 · 시간) 보낸 것으로 보고 `rejectedRecipients: []` 로 답한다(warn 로그) — 제출은 이미 되었다.
결과 읽기는 발송 전체 55초 안에서 최대 5초(모두 거절일 때 사본을 지울 3초를 남긴다), 쓸 시간이 1초도 안 남으면 묻지 않는다.
- 같은 주소(대소문자 무시)는 한 번만 싣는다 — 받는 사람 → 참조 → 숨은참조 순서이고 처음 쓴 모양 그대로.
- **모두 거절**(받은 사람이 아무도 없음) — 보내지 않은 것으로 다룬다. 이 요청이 만든 보낸 사본은 지우고(아무에게도 가지 않았다 — 그 요청이 방금 만든 메일이라 조건 없이 지운다),
이어 쓴 저장본(`draftId`)은 지우지 않는다. 거절 가운데 5xx 가 하나라도 있으면 400 `MAIL_010`, 모두 4xx 면 503 `MAIL_011`.
같은 키로 다시 보내면 새로 보낸다(지운 사본은 중복 방지에 걸리지 않는다).
사본을 지우지 못해도(엔진 오류 — warn) 코드는 그대로다. **알려진 한계**: 그때 보낸편지함에 사본 한 통이 남고, 같은 키 · 같은 내용의 재시도는 그 사본을 찾아
201 + 받는 사람 전원의 `rejectedRecipients` 로 답한다(이어 쓴 저장본도 지운다), 내용을 고친 재시도는 409 `MAIL_005` 다 —
지금 남은 결과만으로는 "제출 때 거절"과 "보낸 뒤 바깥에서 반송"을 가를 수 없어 다시 보내지 않는다.
제출 응답을 받지 못해 503 `MAIL_001` 로 끝났는데 엔진에서는 제출되어 모두 거절된 경우도 같다(사본을 지우는 데까지 가지 못했다) — 30초 잠금이 풀린 뒤 같은 키의 재시도가 그 사본을 찾는다.
- **일부 거절** — 보낸 것이다. 201 에 `rejectedRecipients` 를 싣고, 보낸 사본은 보낸편지함에 남고, `draftId` 는 지운다.
- 같은 키의 재시도가 처음 메일을 찾아 그대로 돌려줄 때도 그 메일의 제출 기록으로 `rejectedRecipients` 를 다시 만든다(찾지 못하면 빈 배열).
- 로그 — 모두 거절은 warn(`[MAIL_010]` · `[MAIL_011]`), 일부 거절은 log. 주소는 가리고(`maskEmail`) SMTP 상태 코드(`550 5.1.2` 등)만 남긴다.
`MAIL_011` 은 503 이라 전역 예외 필터가 5xx 규칙대로 error(스택 포함)로 한 줄 더 남긴다(`MAIL_001` 과 같다) — 웹메일 발송은 엔진 한도에 걸리지 않게 했으므로 드물다.
- 엔진 기본 한도 중 받는 사람 단위의 것("보낸 도메인 → 한 받는 사람" 시간당 25통)은 **로그인한 직원의 발송에는 걸지 않는다** —
사내 메일은 보낸 도메인이 모두 같아, 동료 모두 합쳐 한 사람에게 시간당 25통을 넘으면 그 뒤가 거절되던 문제(같은 날 확인). mail setup 이 맞춘다(mail/README.md "엔진 한도").
바깥에서 들어오는 메일(인증 없는 SMTP)에는 그대로 건다. 다른 기본 한도 "보낸 IP 마다 초당 연결 5개"는 연결할 때 세는 것이라 웹메일 발송에는 원래 걸리지 않고, 그대로 둔다
(조건을 붙이면 엔진이 그 한도를 연결이 아닌 MAIL FROM 단계로 옮겨 바깥 접속을 더는 세지 않는다 — Stalwart 0.16.27 소스로 확인).
- **클라이언트**
- 일부 거절 — 보낸 것으로 처리한다(쓰기 화면을 닫고 떠난다). 보냈다는 알림 대신 실패 알림(「보낸편지함 보기」 링크 — 보냈다는 알림과 같은 글, 단추 있는 알림과 같은 10초)으로
"보냈지만 N명은 받지 못했습니다 — nobody@arasystem.it.kr (없는 주소)" — 두 명까지 주소와 까닭을 적고 더 있으면 " 외 N명".
까닭 문구는 `not-found` "없는 주소" · `refused` "받는 쪽이 거절" · `temporary` "지금은 받을 수 없음".
- `MAIL_010` — 받는 사람 칸의 안내 띠(`MAIL_002` 와 같은 자리 · 같은 초점)로만 알린다(토스트 없음). 쓰던 메일 · 키는 그대로.
- `MAIL_011` — 인터셉터 알림(사전 문구). 쓰던 메일 · 키는 그대로 — 잠시 뒤 다시 누르면 같은 키로 간다.
**전달**: 원본 메일 `attachments[].id` 를 발송 본문의 `attachments` 에 그대로 넣으면 원본 첨부가 다시 붙는다
(같은 계정 안의 파일이라 다시 올릴 필요가 없다). 제목 `Fwd:` 와 인용 본문은 클라이언트가 만들고, `inReplyTo` 는 보내지 않는다.
발송 본문의 첨부 이름(`attachments[].name`)은 업로드한 파일과 같은 규칙으로 확장자를 허용 목록에서 검사한다(밖이면 400 `FILE_001`) — 원본 첨부를 그대로 전달할 때도 같다.
@@ -622,18 +678,31 @@ interface MailVacation {
isEnabled: boolean
fromDate: string | null // ISO 8601 UTC — null 이면 켠 때부터
toDate: string | null // null 이면 끌 때까지
subject: string // 200자까지 — 켤 때(isEnabled true)는 공백이 아닌 글자 1자 이상. 제어 문자(메일함 이름과 같은 범위 — 탭 · 줄바꿈 포함)는 쓰지 않는다(한 줄짜리 머리글)
textBody: string // 4,000자까지 — 켤 때는 공백이 아닌 글자 1자 이상. 글자만(서식 없음) — 제어 문자는 탭 · LF · CR 만. 서명은 붙지 않는다
subject: string // 켤 때 150자까지(꺼 둔 채로는 200자까지 — 아래 "길이 한도의 근거") — 켤 때(isEnabled true)는 공백이 아닌 글자 1자 이상. 제어 문자(메일함 이름과 같은 범위 — 탭 · 줄바꿈 포함)는 쓰지 않는다(한 줄짜리 머리글)
textBody: string // 켤 때 600자까지(꺼 둔 채로는 4,000자까지) — 켤 때는 공백이 아닌 글자 1자 이상. 글자만(서식 없음) — 제어 문자는 탭 · LF · CR 만. 서명은 붙지 않는다
}
```
- **길이 한도의 근거 (6단계 앞 수정)**: 엔진이 UTF-8 **바이트**로 막는다 — 제목 511바이트(한글 170자 · 영문 511자), 본문 2,047바이트(한글 682자 · 영문 2,047자)
(Stalwart 0.16.27, 2026-10-28 확인 — 넘으면 엔진이 `invalidProperties` 로 거절해 400 `VAL_001` 이었다. 4단계 계약의 200자 · 4,000자는 한글로는 맞지 않았다).
글자마다 바이트가 달라(한글 3 · 이모지 4바이트/2자) 어떤 글이어도 넘지 않는 150자 · 600자로 둔다(JS 문자열 길이).
이 한도는 **켤 때만** 본다 — 꺼 둔 채로는 4단계 한도(200자 · 4,000자)까지 받는다. 4단계에 엔진 한도 안으로 저장해 둔 긴 글(영문 내용 1,000자 등)이 켜져 있으면
화면은 꺼 둘 때 칸이 흐려 고칠 수 없는데, 끄기는 늘 저장되어야 하기 때문이다. 다시 켤 때 150 · 600 에 걸리면 줄인 뒤 켠다.
- 한 번도 설정하지 않은 계정은 `{ isEnabled: false, fromDate: null, toDate: null, subject: "", textBody: "" }` 로 온다(엔진의 null 은 빈 문자열로). 끈 채로는 빈 제목 · 내용도 저장된다.
제목 · 내용은 쓴 그대로 저장한다 — "공백이 아닌 글자" 검사만 앞뒤 공백을 뺀 값(JS `trim()` — NBSP · U+3000 포함)으로 한다. 제어 문자 검사는 꺼 둔 채로도 한다.
- 엔진의 JMAP `VacationResponse`(singleton)를 그대로 쓴다 — `htmlBody` 는 비운다(null). 저장은 전체 교체다. 꺼도 쓴 제목 · 내용은 남겨 둔다.
- 저장 자리 — 6단계부터 메일 필터와 같은 엔진 규칙(Sieve 스크립트 `arasystem`)에 둔다(아래 "메일 필터"의 "저장 자리"). 4단계에는 엔진의 JMAP `VacationResponse`(singleton)였다.
서식 본문은 없다(글자만). 저장은 전체 교체이고 응답은 저장한 값이다. 꺼도 쓴 제목 · 내용은 남겨 둔다.
필터와 한 스크립트라서 부재중을 저장해도 스크립트를 다시 켠다(필터가 꺼져 있었으면 함께 다시 동작한다). 필터 규칙은 건드리지 않는다.
- 6단계부터 바뀐 동작: 엔진이 스팸으로 분류한 메일에는 답하지 않는다(필터와 같은 스팸 막기 — "메일 필터"의 "만드는 Sieve").
- 우리 스크립트가 엔진에서 꺼져 있으면(필터의 `isActive: false`) 부재중도 동작하지 않는다. 이 API 의 `isEnabled` 는 저장한 값이라 그때도 `true` 로 온다
(알려진 한계 — 드묾. 화면은 필터 화면의 줄로만 알린다).
- 저장해 둔 원본이 깨진 계정은 꺼짐(`isEnabled: false`, 빈 제목 · 내용)으로 온다. 원본이 이 서버보다 새 판이면 저장은 503 `MAIL_001` 이다("메일 필터"의 "저장 자리").
- `fromDate` · `toDate` 는 `Z` 로 끝나는 UTC 시각이고, 두 키는 늘 보낸다(값은 null 가능). 엔진에는 초 단위로 저장한다(소수 초는 버린다).
둘 다 있으면 버린 값으로 `fromDate < toDate`(아니면 400 `VAL_001`). 화면은 날짜만 고르고, 브라우저 시간대의 시작일 0시 · 끝나는 날 다음 날 0시를 UTC 로 보낸다.
- 같은 사람에게 매번 답하지는 않는다 — JMAP 규격(RFC 8621 8절)이 "보낸 사람마다 적당한 기간에 한 번" 을 요구하고 엔진이 따른다.
6단계부터는 Sieve `vacation`(RFC 5230)이 같은 일을 한다 — 간격은 엔진 기본값이다.
- **클라이언트**: 설정 › 메일 › 부재중 자동 응답. 켜져 있으면 메일 화면의 메일함 패널 아래(사용량 위)에 "부재중 자동 응답 켜짐 · 11월 3일까지"(누르면 설정)를 보인다 —
메일 화면에 들어올 때 한 번 받고, 시간이 지나면 다시 계산한다(끝나는 시각이 지나면 숨기고, 시작 전이면 "10월 30일 ~ 11월 3일 · 설정").
@@ -725,6 +794,93 @@ data: {"reason":"token-expiring"}
같은 tag 로 앞 알림을 바꿔 쓴다(쌓이지 않는다). 누르면 그 탭으로 와서 메일을 연다(여러 통이면 받은편지함). 사용자가 웹메일로 돌아오면(어느 탭이든 보이고 초점이 생기면) 띄운 알림을 닫는다.
허락은 사용자가 누른 뒤에만 묻는다(목록 위 한 번 권하기 · 설정의 켜기 칸). `new Notification()` 을 막는 브라우저(휴대폰 Chrome)는 처음 실패할 때 "지원하지 않음"으로 바꾼다.
### 메일 필터 (`/api/mail-filters`) — 6단계
```ts
interface MailFilterSet {
rules: MailFilterRule[] // 위에서부터 차례로 적용한다 — 50개까지
version: string // 지금 규칙의 지문 — 해석하지 말고 PUT 에 그대로 돌려보낸다(그사이 다른 곳에서 규칙이 바뀌었으면 409 MAIL_012). 영문 · 숫자 · - · _ 128자까지. 규칙만 본다(부재중이 바뀌어도 그대로)
isActive: boolean // false = 우리 규칙이 엔진에서 꺼져 있거나(다른 규칙이 켜짐) 저장해 둔 원본을 읽을 수 없어 지금은 필터 · 부재중 응답이 동작하지 않는다(드묾) — 저장하면 다시 켜진다
// (아래 "저장 자리"의 새 판 원본은 예외). 한 번도 저장하지 않은 계정은 true(막힌 것이 없다)
}
interface MailFilterRule {
id: string // 클라이언트가 만든다(UUID — 영문 · 숫자 · - · _ 64자까지) — 목록 안에서 겹치지 않게
name: string // 0~50자(앞뒤 공백은 지워 저장한다), 제어 문자는 안 된다 — 비면 화면이 조건으로 이름을 보인다
isEnabled: boolean
match: 'all' | 'any' | 'always' // 조건이 모두 맞음 · 하나라도 맞음 · 모든 메일(조건 없음)
conditions: MailFilterCondition[] // all · any 는 1~5개, always 는 빈 배열
actions: MailFilterActions // 하나 이상
}
interface MailFilterCondition {
field: 'from' | 'to' | 'subject' | 'body' // 보낸 사람(이름 · 주소) · 받는 사람(받는 사람 · 참조의 이름 · 주소) · 제목 · 본문(글자 부분)
operator: 'contains' | 'notContains'
value: string // 1~200자(앞뒤 공백을 지운 값 — 그 값으로 저장한다), 제어 문자 · `${` 는 안 된다
}
interface MailFilterActions {
moveToMailboxId: string | null // 받은편지함 · 내 메일함 · 보관함 · 스팸메일함 · 휴지통 (보낸편지함 · 임시보관함은 400 VAL_001). 없는 id 도 받는다 — 지운 메일함을 가리키는 규칙이 남아 있을 수 있다
markAsRead: boolean
flag: boolean
forwardTo: string | null // 사내 주소(도메인이 `MAIL_DOMAIN` 과 똑같은 주소 — 하위 도메인은 안 된다)만, 자기 주소는 안 된다(대소문자 무시).
// 앞뒤 공백은 지워 저장한다. `${` 는 안 된다
}
```
- PUT 본문은 `{ rules, version }` 뿐이다 — `isActive` 같은 다른 키를 보내면 400 `VAL_001`(모르는 필드). 응답은 저장한 규칙(앞뒤 공백을 지운 값)과 새 `version` 이다.
- **적용 순서** — 새로 온 메일을 위의 규칙부터 차례로 견주고, 맞는 규칙의 할 일을 모두 한다(읽음 · 중요는 겹쳐 붙는다).
**메일함으로 옮기는 규칙에 맞으면 거기서 멈춘다** — 아래 규칙은 보지 않는다. 받은편지함으로 옮기는 규칙을 위에 두면 "늘 받은편지함에"가 된다.
전달은 한 통에 한 곳 — 엔진 한도(사용자 스크립트의 리다이렉트 1)라 두 번째 전달은 엔진이 조용히 버린다(2026-10-28 확인). 사본은 늘 남긴다(`redirect :copy`).
**스팸으로 판정된 메일에는 아무 규칙도 적용하지 않는다**(부재중 응답도 하지 않는다) — 엔진은 규칙의 옮기기가 스팸메일함 분류를 이기므로(같은 날 확인) 막는다.
판정은 엔진의 분류 그대로다(지금 엔진 설정은 5점부터 스팸 — 아래 "만드는 Sieve"의 스팸 막기).
꺼 둔 규칙은 목록에만 있고 적용하지 않는다. 이미 받은 메일에는 적용하지 않는다.
- **저장 자리** — 엔진의 Sieve 스크립트 하나(`arasystem`)에 필터와 **부재중 자동 응답을 함께** 둔다. Stalwart 0.16.27 은 계정마다 켜진 스크립트가 하나뿐이고
엔진의 VacationResponse 도 스크립트(`vacation`)라서, 부재중을 켜면 필터가 꺼지고 필터를 켜면 부재중이 꺼진다(같은 날 확인).
- 4단계 `/api/mail-vacation` 은 계약 그대로 두고 저장 자리만 이 스크립트로 옮긴다 — 엔진의 VacationResponse 는 더 쓰지 않는다.
스크립트가 아직 없는 계정은 처음 읽을 때 VacationResponse 의 값을 가져온다(개발 데이터 이어받기). 엔진의 `vacation` 스크립트는 고칠 수 없다(엔진이 `forbidden`).
- 스크립트 머리의 주석에 규칙 · 부재중 원본(JSON — 판 번호 `v` 를 함께 둔다)을 두고, 읽을 때는 그것만 믿는다(엔진의 Sieve 를 다시 해석하지 않는다).
- 스크립트가 아직 없으면 빈 규칙 + `isActive: true`, 부재중은 엔진의 VacationResponse 값(위 이어받기).
- 스크립트는 있는데 원본이 없거나 깨졌으면 빈 규칙 + `isActive: false`, 부재중은 꺼짐 — 저장하면(필터든 부재중이든) 덮어쓴다.
- 원본의 판이 이 서버가 아는 것보다 새로우면(새 서버를 배포했다 되돌린 경우) 읽기는 위와 같지만 **쓰기는 거절한다**(503 `MAIL_001`) — 옛 서버가 새 판의 규칙을 지우지 않게.
- 쓸 때는 읽은 상태 그대로일 때만(`ifInState`) 쓰고 켠다(`onSuccessActivateScript`). 그사이 바뀌면 다시 읽어 확인한다
(쓰기 3번까지 — 시도마다 읽는 사이 글이 사라지면 그 읽기도 3번까지 다시 한다. 그래도 어긋나면 503 `MAIL_001`).
필터 저장은 다시 읽은 규칙의 지문이 `version` 과 다르면 409 `MAIL_012`(부재중 응답만 바뀐 것은 걸리지 않는다). 부재중 저장은 규칙을 그대로 두고 부재중 부분만 바꾼다.
다만 저장해 둔 규칙이 보내는 규칙과 이미 같으면 지문이 달라도 200 으로 저장한다 — 결과를 모르는 실패 뒤에 같은 요청을 다시 보낸 경우가 409 로 끝나지 않게
(꺼져 있었으면 이때 다시 켜진다).
- **만드는 Sieve** (엔진 확인 사실 — 2026-10-28):
- 조건: `from` → `header :contains "from"`, `to` → `header :contains ["to", "cc"]`, `subject` → `header :contains "subject"`, `body` → `body :text :contains`.
한국어 제목(인코딩된 머리글) · 본문 · 표시 이름 모두 맞는다. 대소문자는 ASCII 만 무시한다(엔진 기본 비교 — `i;unicode-casemap` 은 없다).
- 한 규칙은 `if` 하나: 표시(`addflag` — 옮기기보다 먼저, 엔진은 옮기는 순간의 표시만 붙인다) → 전달 → 옮기기(`fileinto :mailboxid "<id>" "INBOX"` + `stop`).
옮길 메일함이 없으면 대체 이름 "INBOX" 로 받은편지함에 들어온다.
- 스팸 막기는 엔진의 분류 표시로 한다 — `X-Spam-Score` 머리글(`spam, score=9.70` · `ham, score=2.20`)이 `spam` 으로 시작하면 아무 규칙도 · 부재중도 하지 않는다
(`header :matches "X-Spam-Score" "spam*"`). 엔진이 우리 스크립트를 돌리기 **전에** 붙인다. `X-Spam-Status: Yes|No` 는 스크립트가 끝난 뒤 실제로 들어간 곳에 따라 붙어 쓸 수 없다
(우리 규칙이 옮긴 스팸은 "No" 였다). 엔진 설정의 기준 점수(`scoreSpam` — 지금 5)를 바꿔도 이 표시가 따라간다.
- `spamtest` 는 쓰지 않는다 — 엔진의 값은 "스팸이면 10"이 아니라 점수를 정수로 내린 값(10에서 멈춤)이라, 10 으로 막으면 5~10점 스팸을 놓친다
(2026-10-28 실엔진: 2.20점 → 2, 9.70점 → 10 미만, 11.2점 → 10. 처음 구현은 10 으로 막아 9.70점 스팸을 규칙대로 옮기고 부재중 응답까지 보냈다).
- 바깥에서 같은 이름의 머리글을 넣어 와도 엔진의 머리글이 함께 남는다(하나라도 맞으면 참). 그래서 위조로 스팸을 숨길 수는 없고, 자기 메일에 규칙이 적용되지 않게만 할 수 있다
(그 메일은 엔진 판정대로 들어간다). 엔진의 스팸 필터는 바깥에서 오는(인증 없는) 메일에만 돌아 사내 메일에는 이 머리글이 없다 — 규칙이 그대로 적용된다.
- 부재중: 엔진이 하던 대로 `vacation :mime`(본문 base64) — 제목은 RFC 2047 로 인코딩해 넣는다. Stalwart 는 require 와 상관없이 문자열의 `${…}` 를 늘 변수로 펼쳐서
그대로 넣으면 지워진다(같은 날 확인). 기간은 `currentdate :zone "+0000"`(엔진 시간대에 기대지 않게 — 같은 날 확인). 같은 사람에게 다시 답하는 간격은 엔진 기본값(지금과 같다).
- 규칙 값에 `${` 는 받지 않는다(400 `VAL_001`) — 펼쳐져 빈 값이 되면 `:contains` 가 모든 메일에 맞는다. 문자열은 `"` · `\` 를 이스케이프한다.
`%{…}`(Stalwart 의 식 모양)는 펼치지 않아 막지 않는다 — `%{"a"}` · `%{}` · `%{1+1}` · `%{subject}` 모두 글자 그대로 견주었다(같은 날 확인).
- 크기: 엔진 한도는 스크립트 102,400바이트 · 문자열 4,096바이트다. 만든 스크립트가 90,000바이트를 넘으면 저장하지 않고 400 `MAIL_013`.
- 로그 — 규칙의 글(조건 값 · 전달 주소)은 남기지 않는다(검색어처럼 직원의 내용이다). 규칙 수 · 크기 · 결과만.
엔진이 스크립트를 거절하면(`invalidScript` → 500 `SYS_001`) 엔진 설명의 줄 · 칸만 남긴다 — 설명 문장에는 만난 문자열(규칙의 글)이 그대로 들어갈 수 있다.
- **클라이언트**: 설정 › 메일 › 필터(`/settings/mail/filters`, 탭 제목 "메일 필터") — 메뉴는 서명 · 부재중 자동 응답 · 필터 · 알림.
- 켜기 · 끄기 · 순서 바꾸기 · 지우기 · 고치기 모두 `PUT` 전체 교체(받은 `version` 으로). 켜기 · 순서는 화면을 먼저 바꾸고 실패하면 되돌린다(오류 알림).
지우기는 확인 없이 하고 알림의 [되돌리기](10초)로 지운 필터를 **지운 자리에 다시 넣어** 저장한다 — 그사이 바꾼 다른 필터(켜기 · 순서 등)는 그대로 둔다.
저장은 한 번에 하나다 — 앞 저장이 도는 동안 누른 [되돌리기]는 차례로 보낸다. 고치기 창의 [저장]이 끝나면 "필터를 저장했습니다."
- `MAIL_012` — 목록 위 줄 "다른 곳에서 필터가 바뀌어 저장하지 않았습니다. 다시 불러온 뒤 고쳐 주세요." + [다시 불러오기]. 고치던 창은 닫지 않는다 —
창 안에도 같은 안내와 [다시 불러오기]를 둔다(모달이라 뒤의 줄을 누를 수 없다). 창에서 다시 불러오면 쓴 내용은 그대로이고, 다음 [저장]은 다시 받은 목록에
그 필터를 넣는다(같은 id 자리 — 그사이 다른 곳에서 지워졌으면 맨 아래).
- `isActive: false` — 목록 위 줄 "지금은 필터와 부재중 자동 응답이 동작하지 않습니다…" + [다시 켜기](지금 목록 그대로 저장).
- 읽기 화면 ⋯ 「이런 메일로 필터 만들기」 → `/settings/mail/filters?from=<보낸 사람 주소>` — 새 필터 창이 "보낸 사람 · 포함 · 그 주소"로 채워져 열린다(초점은 할 일의 첫 칸).
50개를 다 만들었는지 알려고 ⋯ 메뉴를 열 때 `GET` 을 한다(받아 둔 목록이 없거나 50개일 때만 — 알림 없이).
- 검사는 서버와 같은 규칙(이름 50 · 값 200 · 조건 5 · 규칙 50 · `${` · 제어 문자 · 할 일 하나 이상 · 전달은 사내 주소이고 자기 주소 아님) — 칸 아래에만 알린다.
전달 주소의 앞부분은 서버(`IsEmail`)보다 좁게 본다(RFC 5322 dot-atom · 64자) — 화면이 통과시킨 주소를 서버가 거절하지 않게.
규칙이 50개면 [새 필터] · 「이런 메일로 필터 만들기」를 끄고 까닭을 보인다. 지운 메일함을 가리키는 규칙은 목록에 빨간 안내.
- 이 단계에서 하지 않는 것: 이미 받은 메일에 적용, **바깥 주소로 전달**(엔진에 SRS 가 없어 Gmail 등이 DMARC 로 거절할 수 있고, 회사 메일이 개인 메일로 빠지는 통로다 —
필요해지면 정책과 함께), 크기 · 첨부 · 날짜 조건, 정규식, 맞는 메일 미리 보기, mailcow(SOGo)의 필터 옮겨 오기(운영 전환 때 — imapsync 로는 옮겨지지 않는다).
### 이 단계에서 하지 않는 것
탭을 모두 닫아도 오는 알림(웹 푸시 — 엔진은 `webpush-vapid` 를 지원한다), 알림 소리(운영체제 설정을 따른다), 휴대폰 푸시, 본문 안 이미지(인라인 cid — 붙여 넣은 이미지는 첨부로, 서명 그림만 예외), 외부 이미지 보기(이미지 중계),
@@ -22,6 +22,10 @@ const Map<String, String> kErrorCodeMessages = {
'MAIL_007': '본문이 너무 길어 보낼 수 없습니다. 내용을 줄인 뒤 다시 보내 주세요.',
'MAIL_008': '임시 저장한 메일이 다른 곳에서 바뀌었거나 지워졌습니다. 지금 내용을 새 임시 저장본으로 남겨 주세요.',
'MAIL_009': '같은 이름의 메일함이 이미 있습니다.',
'MAIL_010': '받는 사람 주소로 메일을 보낼 수 없습니다. 주소를 확인해 주세요.',
'MAIL_011': '받는 쪽이 지금은 메일을 받을 수 없습니다. 잠시 뒤 다시 보내 주세요.',
'MAIL_012': '다른 곳에서 필터가 바뀌어 저장하지 않았습니다. 다시 불러온 뒤 고쳐 주세요.',
'MAIL_013': '필터가 너무 길어 저장할 수 없습니다. 조건을 줄이거나 쓰지 않는 필터를 지워 주세요.',
};
/// 매핑 실패 시 사용하는 폴백 코드
@@ -1,7 +1,8 @@
<script setup lang="ts">
// 모달 대화상자 틀 — 브라우저 <dialog>(showModal)로 띄운다. 모양은 디자인 원본 States "세션 만료".
// 쓰는 곳: SessionExpiredDialog(세션 만료) · LeaveConfirmDialog(떠나기 전 확인 — 쓰기 화면 · 설정 › 서명 · 부재중 자동 응답) ·
// MailPurgeDialog(영구 삭제 확인) · 메일함 관리(MailboxNameDialog — 이름 칸 · MailboxDeleteDialog · MailboxEmptyDialog)
// MailPurgeDialog(영구 삭제 확인) · 메일함 관리(MailboxNameDialog — 이름 칸 · MailboxDeleteDialog · MailboxEmptyDialog) ·
// 메일 필터 고치기 창(MailFilterDialog — 넓은 창 620, wide)
// - 모달이라 뒤의 화면을 조작할 수 없다. 처음 초점은 autofocus 를 붙인 단추 (없으면 브라우저가 첫 단추로)
// - Esc(cancel)는 브라우저가 닫지 않게 막고 cancel 로 알린다 — 무엇을 할지는 쓰는 쪽이 정한다
// - 닫기는 열기보다 먼저 한다 — 한 대화상자가 닫히고 다른 대화상자가 열리는 때(세션 만료) 둘이 겹치지 않게
@@ -21,6 +22,8 @@ interface Props {
describedBy?: string
/** 사용자가 답해야 하는 확인이면 'alertdialog' — 주지 않으면 dialog 그대로 */
role?: 'alertdialog'
/** 칸이 많은 창 — 폭 620 · 좌우 여백 20 (디자인 원본 SettingsFilter 의 고치기 창). 주지 않으면 공용 폭 360 */
wide?: boolean
}
interface Emits {
@@ -33,6 +36,7 @@ const props = withDefaults(defineProps<Props>(), {
messageId: undefined,
describedBy: undefined,
role: undefined,
wide: false,
})
const emit = defineEmits<Emits>()
@@ -97,7 +101,7 @@ onMounted(() => {
<template>
<dialog
ref="dialogRef"
class="modal-dialog"
:class="['modal-dialog', { 'modal-dialog--wide': wide }]"
:role="role"
:aria-labelledby="titleId"
:aria-describedby="descriptionId"
@@ -135,6 +139,12 @@ onMounted(() => {
box-shadow: var(--shadow-dialog);
}
/* 넓은 창 — 칸이 많은 고치기 창 (디자인 원본 SettingsFilter: 폭 620 · 안쪽 여백 18 20 14) */
.modal-dialog--wide {
width: min(38.75rem, calc(100vw - var(--space-48)));
padding: var(--space-18) var(--space-20) var(--space-14);
}
.modal-dialog[open] {
display: flex;
flex-direction: column;
@@ -178,13 +188,14 @@ onMounted(() => {
font-weight: 600;
}
:slotted(.modal-dialog__action:not(:disabled):hover) {
:slotted(.modal-dialog__action:not(:disabled):not([aria-disabled='true']):hover) {
background: var(--color-brand-strong);
}
/*
* 아직 누를 수 없는 단추 (메일함 이름을 비웠다 · 보내는 중 · 함께 돌 수 없는 처리가 도는 중) — 마우스를 올려도 그대로.
* 마우스를 올린 모양은 모두 :not(:disabled) 로만 그린다 — 꺼진 빨강 · 보조 단추가 브랜드색으로 바뀌지 않게
* 마우스를 올린 모양은 모두 :not(:disabled):not([aria-disabled='true']) 로만 그린다 — 꺼진 빨강 · 보조 단추가 브랜드색으로 바뀌지 않게
* (초점을 지키려고 disabled 대신 aria-disabled 로 막은 단추 — 필터 고치기 창의 [저장] · [이 필터 지우기] — 도 같다)
*/
:slotted(.modal-dialog__action:disabled) {
opacity: 0.5;
@@ -196,7 +207,7 @@ onMounted(() => {
background: var(--color-danger);
}
:slotted(.modal-dialog__action--danger:not(:disabled):hover) {
:slotted(.modal-dialog__action--danger:not(:disabled):not([aria-disabled='true']):hover) {
background: var(--color-danger-strong);
}
@@ -207,7 +218,7 @@ onMounted(() => {
font-weight: 500;
}
:slotted(.modal-dialog__action--secondary:not(:disabled):hover) {
:slotted(.modal-dialog__action--secondary:not(:disabled):not([aria-disabled='true']):hover) {
background: var(--color-surface-muted);
color: var(--color-text);
}
@@ -1,6 +1,6 @@
<script setup lang="ts">
// 화면 오른쪽 아래 알림 (디자인 원본 States "오류 알림" · "보내기 완료" · MailActions4 "오래 걸리는 처리")
// 오류: 흰 카드 + 문의 코드(traceId 앞 8자리) + (이동 링크 — 예: 바탕화면 알림을 막아 둠 [설정 열기]) + 닫기
// 오류: 흰 카드 + 문의 코드(traceId 앞 8자리) + (이동 링크 — 예: 바탕화면 알림을 막아 둠 [설정 열기] · 받는 사람 일부가 받지 못함 [보낸편지함 보기]) + 닫기
// 성공: 브랜드 바탕 + 이동 링크 또는 그 자리의 단추(되돌리기 등)
// 진행: 성공과 같은 바탕 + 돌아가는 아이콘 — 끝날 때까지 남았다가 그 자리에서 결과로 바뀐다 (toast.store 의 update)
// 마우스가 올라가 있거나 초점이 알림 안에 있는 동안은 닫히지 않는다 (toast.store 의 hold · release — WCAG 2.2.1)
@@ -186,6 +186,9 @@ function runAction(toast: Toast) {
.toast__message {
font-weight: 500;
/* 한국어 낱말 가운데서는 줄을 바꾸지 않고, 받지 못한 주소처럼 끊을 곳 없는 긴 글자는 알림 안에서 줄을 바꾼다 */
word-break: keep-all;
overflow-wrap: anywhere;
}
.toast__trace {
@@ -1,5 +1,5 @@
<script setup lang="ts">
// 쓰기 화면의 칸 안내 띠 — 받는 사람 오류(MAIL_002) · 제목 길이(VAL_001) · 본문 길이(MAIL_007) (디자인 원본 Compose)
// 쓰기 화면의 칸 안내 띠 — 받는 사람 오류(MAIL_002 · 엔진이 모두 거절한 MAIL_010) · 제목 길이(VAL_001) · 본문 길이(MAIL_007) (디자인 원본 Compose)
// 문구는 쓰기 화면이 에러 코드 사전에서 가져와 준다. 그 칸의 입력이 id 를 aria-describedby 로 가리킨다
import AppIcon from '@/components/AppIcon.vue'
@@ -341,9 +341,9 @@ describe('MailboxPanel — 메일함 관리', () => {
expect(useToastStore().toasts.map((toast) => [toast.kind, toast.message])).toEqual([
['progress', '‘거래처’ 메일함을 지우는 중 · 500 / 1,234통'],
])
// 지우는 동안 그 메일함의 메뉴 항목은 누를 수 없다
// 지우는 동안 그 메일함의 메뉴 항목은 누를 수 없다 (꺼진 항목은 aria-disabled — 초점은 받는다)
const items = await openMenu(wrapper, '거래처')
expect(items.every((item) => item.attributes('disabled') !== undefined)).toBe(true)
expect(items.every((item) => item.attributes('aria-disabled') === 'true')).toBe(true)
second.resolve(ok({ deleted: false, processed: 734, movedToTrash: 734, remaining: 0 }))
await flushPromises()
@@ -388,7 +388,7 @@ describe('MailboxPanel — 메일함 관리', () => {
expect(store.mailboxes.find((mailbox) => mailbox.id === 'inbox')?.unreadCount).toBe(734)
// 처리하는 동안 다른 일을 해도 된다 — 같은 처리만 다시 누를 수 없다
const items = await openMenu(wrapper, '받은편지함')
expect(items.map((item) => [item.text(), item.attributes('disabled') !== undefined])).toEqual([
expect(items.map((item) => [item.text(), item.attributes('aria-disabled') === 'true'])).toEqual([
['모두 읽음으로 표시', true],
['하위 메일함 만들기', false],
])
@@ -587,7 +587,7 @@ describe('MailboxPanel — 메일함 관리', () => {
describe('휴지통 비우기 ↔ 메일함 지우기 — 함께 돌지 않는다', () => {
const itemState = (items: DOMWrapper<Element>[]) =>
items.map((item) => [item.text(), item.attributes('disabled') !== undefined])
items.map((item) => [item.text(), item.attributes('aria-disabled') === 'true'])
it('휴지통을 비우는 동안은 「지우기」가 꺼진다 — 끝나면 다시 켜진다', async () => {
const second = deferred()
@@ -1,49 +1,79 @@
<script setup lang="ts">
// 읽기 화면 도구 줄 끝의 ⋯ (더 보기 40×44) — 메뉴에 "원문 내려받기 (.eml)" (디자인 원본 MailActions4 · 계약 2절 "스팸 · 원문")
// 과 그 아래 「이런 메일로 필터 만들기」(깔때기 — 디자인 원본 MailFilterStates · 계약 2절 "메일 필터"의 "클라이언트")
// 메뉴는 이동 ▾ 과 같은 편집기 메뉴(EditorMenu — 항목 40)를 쓴다. 키보드로 열면(Enter · Space · 아래 화살표) 첫 항목에,
// 마우스로 열면 메뉴 자체에 초점. Esc 는 메뉴만 닫고 이 단추로 돌아간다.
// 원문 내려받기는 첨부 내려받기처럼 이동하기 전에 세션을 확인한다 (useMail 의 downloadMessageSource — confirmSession)
// 필터 만들기는 설정 › 필터로 간다 — /settings/mail/filters?from=<보낸 사람 주소> (새 필터 창이 그 주소로 채워져 열린다).
// 보낸 사람 주소가 없는 메일에는 두지 않는다. 필터를 50개 다 만들었으면 끄고 까닭을 아래에 보인다 —
// 메뉴를 열 때 필터를 아직 받지 않았으면 받아 본다(알리지 않는다 — 모르는 동안은 켜 두고, 설정 화면이 다시 본다)
import { computed, ref } from 'vue'
import { useRouter } from 'vue-router'
import AppIcon from '@/components/AppIcon.vue'
import EditorMenu from '@/components/mail/editor/EditorMenu.vue'
import type { EditorMenuItem } from '@/components/mail/editor/editor-menu'
import { useMail } from '@/composables/useMail'
import { MAIL_READER_MORE_TEXT } from '@/shared/constants/mail'
import { MAIL_FILTER_FROM_QUERY, MAIL_FILTER_TEXT } from '@/shared/constants/mail-filter'
import { SETTINGS_ROUTES } from '@/shared/constants/settings-menu'
import { useMailFilterStore } from '@/stores/mail-filter.store'
interface Props {
messageId: string
/** 보낸 사람 주소 — 없으면 「이런 메일로 필터 만들기」를 두지 않는다 */
senderEmail?: string | null
}
const props = defineProps<Props>()
const props = withDefaults(defineProps<Props>(), {
senderEmail: null,
})
const { downloadMessageSource } = useMail()
const filterStore = useMailFilterStore()
const router = useRouter()
const buttonRef = ref<HTMLButtonElement | null>(null)
const open = ref<boolean>(false)
/** 키보드로 열었다 — 첫 항목에 초점 */
const byKeyboard = ref<boolean>(false)
const ITEMS: readonly EditorMenuItem[] = [
{ id: 'source', label: MAIL_READER_MORE_TEXT.downloadSource, icon: 'download' },
]
const sender = computed(() => props.senderEmail?.trim() ?? '')
const items = computed<EditorMenuItem[]>(() => {
const list: EditorMenuItem[] = [{ id: 'source', label: MAIL_READER_MORE_TEXT.downloadSource, icon: 'download' }]
if (sender.value) {
list.push({
id: 'filter',
label: MAIL_READER_MORE_TEXT.createFilter,
icon: 'filter',
disabled: filterStore.isFull,
note: filterStore.isFull ? MAIL_FILTER_TEXT.fullNote : undefined,
})
}
return list
})
const trigger = computed<HTMLElement | null>(() => buttonRef.value)
function openMenu(keyboard: boolean) {
byKeyboard.value = keyboard
open.value = true
// 필터를 50개 다 만들었는지 — 받아 둔 것이 없을 때만 받아 본다
if (sender.value) filterStore.ensureLoaded()
}
function onClick(event: MouseEvent) {
if (open.value) {
open.value = false
return
}
// detail 0 — Enter · Space 로 눌렀다
byKeyboard.value = event.detail === 0
open.value = true
openMenu(event.detail === 0)
}
function onKeydown(event: KeyboardEvent) {
if (event.key !== 'ArrowDown' || open.value) return
event.preventDefault()
byKeyboard.value = true
open.value = true
openMenu(true)
}
function onClose(restoreFocus: boolean) {
@@ -55,6 +85,9 @@ function onSelect(id: string) {
open.value = false
buttonRef.value?.focus()
if (id === 'source') void downloadMessageSource(props.messageId)
if (id === 'filter' && sender.value && !filterStore.isFull) {
void router.push({ name: SETTINGS_ROUTES.filters, query: { [MAIL_FILTER_FROM_QUERY]: sender.value } })
}
}
</script>
@@ -78,7 +111,7 @@ function onSelect(id: string) {
v-if="open"
class="reader-more__menu"
:label="MAIL_READER_MORE_TEXT.button"
:items="ITEMS"
:items="items"
:trigger="trigger"
align="end"
:focus-content="!byKeyboard"
@@ -113,8 +146,9 @@ function onSelect(id: string) {
color: var(--color-text);
}
/* 메뉴 폭 200 (디자인 원본 MailActions4) */
/* 메뉴 폭 200 (디자인 원본 MailActions4) — 필터 50개의 까닭(note)은 280 안에서 줄을 바꾼다 */
.reader-more__menu {
min-width: 12.5rem;
max-width: 17.5rem;
}
</style>
@@ -5,7 +5,7 @@ import { h } from 'vue'
import { ERROR_CODE_MESSAGES } from '@/shared/constants/error-codes.generated'
import { MAIL_BODY_NOTICES } from '@/shared/constants/mail'
import type { Mailbox, MessageDetail, MessagesPatch } from '@/shared/types/mail'
import type { Mailbox, MailFilterRule, MailFilterSet, MessageDetail, MessagesPatch } from '@/shared/types/mail'
import type { MailListSource } from '@/shared/utils/mail-list-source'
import { useAuthStore } from '@/stores/auth.store'
import { useMailStore } from '@/stores/mail.store'
@@ -47,6 +47,9 @@ const DETAIL: MessageDetail = {
attachments: [],
}
/** 필터가 없는 계정 — 읽기 화면 ⋯ 를 열면 50개 여부를 알려고 받는다 */
const NO_FILTERS: MailFilterSet = { rules: [], version: 'v1', isActive: true }
describe('MessageReader 도구 모음', () => {
let pinia: Pinia
@@ -487,12 +490,20 @@ describe('MessageReader 스팸 신고 · 원문 내려받기 (4단계 — 디자
})
async function mountWith(
options: { box?: string; mailboxes?: Mailbox[]; me?: MockResult; refresh?: MockResult } = {},
options: {
box?: string
mailboxes?: Mailbox[]
me?: MockResult
refresh?: MockResult
filters?: MailFilterSet
from?: MessageDetail['from']
} = {},
) {
const box = options.box ?? 'inbox'
const mailboxes = options.mailboxes ?? [INBOX, JUNK, TRASH]
requests = installMockApi((request) => {
if (request.url === '/auth/me') return options.me ?? ok({ email: 'ara.kim@arasystem.it.kr', name: '김아라' })
if (request.url === '/mail-filters') return ok(options.filters ?? NO_FILTERS)
if (request.url === '/auth/refresh') return options.refresh ?? ok({ email: 'ara.kim@arasystem.it.kr', name: '김아라' })
if (request.url === '/mailboxes') return ok({ items: mailboxes, pageInfo: { hasNext: false, nextCursor: null, total: 3 } })
if (request.url === '/messages' && request.method === 'GET') {
@@ -504,7 +515,7 @@ describe('MessageReader 스팸 신고 · 원문 내려받기 (4단계 — 디자
const store = useMailStore()
store.mailboxes = mailboxes
store.listSource = { kind: 'mailbox', mailboxId: box }
const open = { ...DETAIL, mailboxIds: [box] }
const open = { ...DETAIL, mailboxIds: [box], from: options.from === undefined ? DETAIL.from : options.from }
store.messages = [open, { ...DETAIL, id: 'm2', mailboxIds: [box], subject: '다음 메일' }]
store.pageInfo = { hasNext: false, nextCursor: null, total: 2 }
store.listStatus = 'ready'
@@ -580,13 +591,14 @@ describe('MessageReader 스팸 신고 · 원문 내려받기 (4단계 — 디자
await more.trigger('keydown', { key: 'ArrowDown' })
await flushPromises()
const items = wrapper.findAll('[role="menu"] [role="menuitem"]')
expect(items.map((item) => item.text())).toEqual(['원문 내려받기 (.eml)'])
expect(items.map((item) => item.text())).toEqual(['원문 내려받기 (.eml)', '이런 메일로 필터 만들기'])
expect(document.activeElement).toBe(items[0]?.element)
await items[0]?.trigger('click')
await flushPromises()
expect(requests.map((request) => `${request.method} ${request.url}`)).toEqual(['GET /auth/me'])
// 메뉴를 열 때 필터를 한 번 받아 본다 (50개를 다 만들었는지 — 「이런 메일로 필터 만들기」)
expect(requests.map((request) => `${request.method} ${request.url}`)).toEqual(['GET /mail-filters', 'GET /auth/me'])
expect(downloads).toEqual(['/api/messages/m1/source'])
expect(wrapper.find('[role="menu"]').exists()).toBe(false)
expect(document.activeElement).toBe(more.element)
@@ -603,4 +615,94 @@ describe('MessageReader 스팸 신고 · 원문 내려받기 (4단계 — 디자
expect(downloads).toEqual([])
expect(useAuthStore().sessionExpired).toBe(true)
})
it('⋯ → 「이런 메일로 필터 만들기」(원문 내려받기 아래) — 설정 › 필터로 보낸 사람 주소를 싣고 간다', async () => {
const { wrapper, router } = await mountWith()
await wrapper.get('[data-reader-more]').trigger('click')
await flushPromises()
const item = wrapper.findAll('[role="menuitem"]')[1]
expect(item?.find('polygon').attributes('points')).toBe('22 3 2 3 10 12.46 10 19 14 21 14 12.46 22 3')
expect(item?.attributes('disabled')).toBeUndefined()
await item?.trigger('click')
await flushPromises()
expect(router.currentRoute.value.name).toBe('settings-mail-filters')
expect(router.currentRoute.value.query).toEqual({ from: 'doyun.lee@arasystem.it.kr' })
})
const fiftyFilters = (): MailFilterRule[] =>
Array.from({ length: 50 }, (_, index) => ({
id: `f${index}`,
name: '',
isEnabled: true,
match: 'always',
conditions: [],
actions: { moveToMailboxId: null, markAsRead: true, flag: false, forwardTo: null },
}))
it('필터를 50개 다 만들었으면 그 항목을 끄고 까닭을 아래에 — 키보드로 가서 까닭은 듣되 고를 수 없다 (WAI-ARIA 메뉴)', async () => {
const { wrapper, router } = await mountWith({ filters: { rules: fiftyFilters(), version: 'v1', isActive: true } })
await wrapper.get('[data-reader-more]').trigger('keydown', { key: 'ArrowDown' })
await flushPromises()
const items = wrapper.findAll('[role="menuitem"]')
const item = items[1]
// 꺼진 항목은 disabled 가 아니라 aria-disabled — 화살표로 가서 초점을 받는다
expect(item?.attributes('aria-disabled')).toBe('true')
expect(item?.attributes('disabled')).toBeUndefined()
const note = item?.get('.menu__note')
expect(note?.text()).toBe('필터는 50개까지 만들 수 있습니다. 쓰지 않는 필터를 지워 주세요.')
expect(item?.attributes('aria-describedby')).toBe(note?.attributes('id'))
expect(document.activeElement).toBe(items[0]?.element)
await items[0]?.trigger('keydown', { key: 'ArrowDown' })
expect(document.activeElement).toBe(item?.element)
await item?.trigger('click')
await flushPromises()
expect(router.currentRoute.value.fullPath).toBe('/mail/inbox/m1')
// 꺼진 항목을 골라도 메뉴는 그대로다
expect(wrapper.find('[role="menu"]').exists()).toBe(true)
})
it('50개로 받아 둔 뒤 다른 곳에서 지웠으면 — ⋯ 를 다시 열 때 알림 없이 다시 받아 항목을 켠다', async () => {
const server: { filters: MailFilterSet } = { filters: { rules: fiftyFilters(), version: 'v1', isActive: true } }
const { wrapper } = await mountWith({
get filters() {
return server.filters
},
})
const more = wrapper.get('[data-reader-more]')
const filterItem = () => wrapper.findAll('[role="menuitem"]')[1]
const filterGets = () => requests.filter((request) => request.url === '/mail-filters')
await more.trigger('click')
await flushPromises()
expect(filterItem()?.attributes('aria-disabled')).toBe('true')
await more.trigger('click')
server.filters = { rules: fiftyFilters().slice(1), version: 'v2', isActive: true }
await more.trigger('click')
await flushPromises()
expect(filterItem()?.attributes('aria-disabled')).toBeUndefined()
expect(filterItem()?.find('.menu__note').exists()).toBe(false)
expect(filterGets()).toHaveLength(2)
// 49개면 다시 받지 않는다
await more.trigger('click')
await more.trigger('click')
await flushPromises()
expect(filterGets()).toHaveLength(2)
expect(useToastStore().toasts).toEqual([])
})
it('보낸 사람 주소가 없는 메일에는 「이런 메일로 필터 만들기」를 두지 않는다 (필터도 받지 않는다)', async () => {
const { wrapper } = await mountWith({ from: null })
await wrapper.get('[data-reader-more]').trigger('click')
await flushPromises()
expect(wrapper.findAll('[role="menuitem"]').map((item) => item.text())).toEqual(['원문 내려받기 (.eml)'])
expect(requests.some((request) => request.url === '/mail-filters')).toBe(false)
})
})
@@ -3,7 +3,8 @@
// 본문 HTML 은 MailBodyFrame(격리된 iframe)에만 그린다.
// 툴바와 본문은 콘텐츠 열(tokens.css --content-*)을 따른다 — 분할 보기 읽기 · 목록 보기 읽기 · 쓰기 공통.
// 검색 결과에서 연 메일은 받는 사람 아래에 "메일함 받은편지함" 한 줄을 더한다 (디자인 README "3단계 › 검색").
// 도구 줄: 답장 · 전체 답장 · 전달 | 스팸 신고(스팸메일함에서는 스팸 아님) · 삭제 · ⋯(더 보기 — 원문 내려받기) (디자인 README "4단계 › 스팸 · 원문").
// 도구 줄: 답장 · 전체 답장 · 전달 | 스팸 신고(스팸메일함에서는 스팸 아님) · 삭제 · ⋯(더 보기 — 원문 내려받기 · 이런 메일로 필터 만들기)
// (디자인 README "4단계 › 스팸 · 원문" · "6단계 › 상태 · 들어오는 길").
// 「삭제」 · 「영구 삭제」는 MessageDeleteButton — 휴지통인지는 그 메일의 메일함으로 정한다. 「스팸 신고」는 MessageSpamButton, ⋯ 는 MessageMoreMenu.
// 삭제 뒤 · 없는 메일에서 돌아가는 곳은 지금 목록(메일함 또는 검색 결과)이다.
import { computed } from 'vue'
@@ -110,7 +111,10 @@ function leaveToList() {
:message="message"
:layout="layout"
/>
<MessageMoreMenu :message-id="message.id" />
<MessageMoreMenu
:message-id="message.id"
:sender-email="message.from?.email ?? null"
/>
</template>
</div>
</div>
@@ -1,6 +1,8 @@
<script setup lang="ts">
// 받는 사람 · 참조 · 숨은참조 입력 — Enter · 쉼표 · 입력칸을 벗어날 때 토큰으로 확정한다 (디자인 원본 Compose)
// 형식이 틀린 주소는 빨간 물결 밑줄로 바로 표시한다. 오류 문구(MAIL_002)는 쓰기 화면이 사전에서 가져와 보인다.
// 서버가 받는 사람을 거절하면(MAIL_002 · MAIL_010 — rejected) 주소가 든 칸은 형식이 맞아도 잘못된 입력으로 두고 같은 안내 띠를 가리킨다
// (어느 주소인지는 모른다 — 밑줄은 긋지 않는다).
// 자동 완성 (디자인 원본 ComposeAssist · README "2단계 › 받는 사람 자동 완성") — 쓰기 화면이 추천(RECIPIENT_SUGGESTIONS)을 주면:
// - 한 글자 이상 치면 칸 바로 아래에 추천 목록(최대 8) — "사내" · "최근 주고받은 주소" 두 묶음, 맞는 글자는 굵게
// - ↑↓ 고르기 · Enter · Tab 넣기 · Esc 닫기 · 눌러 넣기. 처음에는 첫 추천을 골라 둔다 —
@@ -41,8 +43,10 @@ interface Props {
inputId: string
placeholder?: string
disabled?: boolean
/** 오류 안내 문단 id — 형식이 틀린 주소가 있으면 입력칸과 연결한다 */
/** 오류 안내 문단 id — 형식이 틀린 주소가 있거나 서버가 거절했으면(rejected) 입력칸과 연결한다 */
errorId?: string
/** 쓰기 화면이 서버의 받는 사람 거절(MAIL_002 · MAIL_010)을 안내하는 중 — 빈 칸은 거절된 주소가 없으므로 그대로 둔다 */
rejected?: boolean
}
interface Emits {
@@ -53,6 +57,7 @@ const props = withDefaults(defineProps<Props>(), {
placeholder: '',
disabled: false,
errorId: '',
rejected: false,
})
const emit = defineEmits<Emits>()
@@ -76,6 +81,8 @@ const dismissed = ref<boolean>(false)
const activeIndex = ref<number>(-1)
const hasInvalid = computed(() => props.modelValue.some((token) => !token.valid))
/** 잘못된 입력 — 형식이 틀린 주소가 있거나, 서버가 거절한 받는 사람이 이 칸에 들었을 수 있다 (rejected · 주소가 있는 칸) */
const invalid = computed(() => hasInvalid.value || (props.rejected && props.modelValue.length > 0))
/** 아직 토큰이 되지 않은 입력이 있다 — 쓰기 화면이 "쓰던 내용"으로 본다 (나가기 전 확인 · 칸을 벗어나지 않고 뒤로 가기 등) */
const hasDraft = computed(() => draft.value.trim() !== '' || query.value.trim() !== '')
@@ -94,7 +101,7 @@ const activeOptionId = computed(() =>
)
const hint = computed(() => (typedFullAddress.value && activeIndex.value < 0 ? HINT_TYPED_ADDRESS : HINT_DEFAULT))
const describedBy = computed(() => {
const ids = [hasInvalid.value && props.errorId ? props.errorId : '', listOpen.value ? hintId.value : '']
const ids = [invalid.value && props.errorId ? props.errorId : '', listOpen.value ? hintId.value : '']
return ids.filter((id) => id !== '').join(' ') || undefined
})
@@ -281,7 +288,7 @@ defineExpose({ commit, focus, clearDraft, hasDraft })
:aria-expanded="suggestions ? listOpen : undefined"
:aria-controls="listOpen ? listId : undefined"
:aria-activedescendant="activeOptionId"
:aria-invalid="hasInvalid"
:aria-invalid="invalid"
:aria-describedby="describedBy"
@input="onTextInput"
@compositionupdate="onTextInput"
@@ -2,6 +2,10 @@
// 툴바 메뉴 — 글꼴 · 글자 크기 · 더보기 (디자인 원본 ComposeEditor "더보기 메뉴" · "글꼴 · 글자 크기")
// 항목 높이 40 · 아이콘 16 · 이름 · 단축키(흐린 글자) · 켜진 것은 오른쪽 ✓ · 한 단계 더 여는 것은 ›
// 같은 모양을 쓰는 곳: 여러 통 처리의 이동 ▾ · 메일함 패널의 ⋯ (되돌릴 수 없는 항목은 tone 'danger' — 빨간 글자) · 읽기 화면의 ⋯
// 꺼진 항목(disabled)은 disabled 가 아니라 aria-disabled — 화살표로 가서 초점은 받되 고를 수 없다(WAI-ARIA 메뉴 권고).
// 누를 수 없는 까닭은 이름 아래 작은 글(note)이고 항목이 aria-describedby 로 가리킨다 — 꺼진 항목이어도 그 글은 흐리지 않는다
import { useId } from 'vue'
import AppIcon from '@/components/AppIcon.vue'
import EditorColorGlyph from '@/components/mail/editor/EditorColorGlyph.vue'
import EditorPopup from '@/components/mail/editor/EditorPopup.vue'
@@ -28,9 +32,22 @@ withDefaults(defineProps<Props>(), {
})
const emit = defineEmits<Emits>()
/** 이 메뉴의 고유 접두 — 까닭(note)의 id */
const menuId = useId()
function isToggle(role: string | undefined): boolean {
return role === 'menuitemcheckbox' || role === 'menuitemradio'
}
function noteId(itemId: string): string {
return `${menuId}-${itemId}-note`
}
/** 꺼진 항목은 골라도 아무것도 하지 않는다 (메뉴도 닫지 않는다) */
function select(item: EditorMenuItem, event: MouseEvent) {
if (item.separator || item.disabled) return
emit('select', item.id, event.detail === 0)
}
</script>
<template>
@@ -53,12 +70,13 @@ function isToggle(role: string | undefined): boolean {
<button
v-else
type="button"
:class="['menu__item', { 'menu__item--danger': item.tone === 'danger' }]"
:class="['menu__item', { 'menu__item--danger': item.tone === 'danger', 'menu__item--noted': item.note }]"
:role="item.role ?? 'menuitem'"
:aria-checked="isToggle(item.role) ? Boolean(item.checked) : undefined"
:aria-haspopup="item.opens"
:disabled="item.disabled"
@click="(event: MouseEvent) => emit('select', item.id, event.detail === 0)"
:aria-disabled="item.disabled ? 'true' : undefined"
:aria-describedby="item.note ? noteId(item.id) : undefined"
@click="(event: MouseEvent) => select(item, event)"
>
<AppIcon
v-if="item.icon"
@@ -71,7 +89,22 @@ function isToggle(role: string | undefined): boolean {
:color="item.colorGlyph.color"
class="menu__icon"
/>
<span :class="['menu__label', item.preview ? `preview--${item.preview}` : '']">{{ item.label }}</span>
<span
v-if="item.note"
class="menu__text"
>
<span class="menu__label">{{ item.label }}</span>
<!-- 이름에 섞이지 않게 숨기고 aria-describedby 로만 읽힌다 -->
<span
:id="noteId(item.id)"
class="menu__note"
aria-hidden="true"
>{{ item.note }}</span>
</span>
<span
v-else
:class="['menu__label', item.preview ? `preview--${item.preview}` : '']"
>{{ item.label }}</span>
<span
v-if="item.badge"
class="menu__badge"
@@ -117,11 +150,11 @@ function isToggle(role: string | undefined): boolean {
text-align: left;
}
.menu__item:hover:not(:disabled) {
.menu__item:hover:not([aria-disabled='true']) {
background: var(--color-surface-muted);
}
.menu__item:disabled {
.menu__item[aria-disabled='true'] {
opacity: 0.5;
cursor: default;
}
@@ -131,7 +164,7 @@ function isToggle(role: string | undefined): boolean {
color: var(--color-danger);
}
.menu__item--danger:hover:not(:disabled) {
.menu__item--danger:hover:not([aria-disabled='true']) {
background: var(--color-danger-surface);
}
@@ -148,6 +181,32 @@ function isToggle(role: string | undefined): boolean {
min-width: 0;
}
/* 이름 + 아래 작은 글 (note) — 글은 메뉴 폭 안에서 줄을 바꾼다 */
.menu__text {
flex: 1 1 auto;
min-width: 0;
display: flex;
flex-direction: column;
gap: var(--space-2);
padding: var(--space-6) 0;
}
.menu__note {
font-size: var(--font-size-small);
color: var(--color-text-muted);
white-space: normal;
}
/* 까닭이 붙은 꺼진 항목 — 이름 · 아이콘만 흐리게, 까닭은 그대로 읽힌다 */
.menu__item--noted[aria-disabled='true'] {
opacity: 1;
}
.menu__item--noted[aria-disabled='true'] .menu__label,
.menu__item--noted[aria-disabled='true'] .menu__icon {
opacity: 0.5;
}
.menu__badge,
.menu__hint {
flex: 0 0 auto;
@@ -33,6 +33,10 @@ const emit = defineEmits<Emits>()
const rootRef = ref<HTMLElement | null>(null)
/**
* 화살표로 오가는 항목 — 꺼진 메뉴 항목은 aria-disabled 라 여기에 든다(초점은 받되 고를 수 없다 — WAI-ARIA 메뉴, EditorMenu).
* disabled 단추는 초점을 받을 수 없어 건너뛴다
*/
const MENU_ITEM = '[role^="menuitem"]:not(:disabled)'
const FOCUSABLE = 'button:not(:disabled), input:not(:disabled), [tabindex="0"]'
@@ -16,6 +16,8 @@ export interface EditorMenuEntry {
hint?: string
checked?: boolean
disabled?: boolean
/** 이름 아래 작은 글 — 누를 수 없는 까닭 등 (읽기 화면 ⋯ 「이런 메일로 필터 만들기」 — 필터 50개). 꺼져도 흐리지 않는다 */
note?: string
/** danger: 되돌릴 수 없는 일 — 빨간 글자 (메일함 ⋯ 메뉴의 지우기 · 비우기) */
tone?: 'danger'
/** 한 단계 더 여는 항목 (›) — 메뉴인지 대화상자인지 */
@@ -0,0 +1,278 @@
<script setup lang="ts">
// 고치기 창의 "할 일" (디자인 원본 SettingsFilter) — 줄마다 44:
// [x] 메일함으로 옮기기 + 옮길 메일함(40) · "옮기면 이 아래 필터는 적용하지 않습니다." / [x] 읽음으로 표시 / [x] 중요 표시 /
// [x] 동료에게 전달 + 사내 주소(40) · 안내(잘못이면 그 자리가 빨간 글)
// 끈 할 일의 칸은 흐리게 — 고른 메일함 · 쓴 주소는 남긴다. 할 일을 하나도 고르지 않았으면 묶음 아래 빨간 글(첫 칸이 그 글을 가리킨다).
// 옮길 메일함은 받은편지함 · 내 메일함 · 스팸메일함 · 휴지통(filterMoveTargets). 골라 둔 메일함이 지워졌으면
// "(지워진 메일함) — 다른 메일함을 고르세요"가 골라진 채로 보인다(다시 고를 수는 없다). 고를 수 없는 곳(보관함 등)을 가리키면 그 이름을 그대로 보인다
// 고정 id — #mail-filter-move · #mail-filter-move-target · #mail-filter-move-hint · #mail-filter-read · #mail-filter-flag ·
// #mail-filter-forward · #mail-filter-forward-to · #mail-filter-forward-help · #mail-filter-actions-error
import { computed, ref } from 'vue'
import AppIcon from '@/components/AppIcon.vue'
import { MAIL_FILTER_DIALOG_TEXT, MAIL_FILTER_FORWARD_MAX_LENGTH } from '@/shared/constants/mail-filter'
import type { Mailbox } from '@/shared/types/mail'
import type { MailFilterActionDraft } from '@/shared/utils/mail-filter'
interface Props {
value: MailFilterActionDraft
/** 고를 수 있는 메일함 — 이 차례로 보인다 */
targets: readonly Mailbox[]
/** 모든 메일함 — 골라 둔 메일함이 있는지 본다 */
mailboxes: readonly Mailbox[]
/** 로그인한 직원 주소의 도메인 — 주소 칸의 보기 */
domain: string
/** 할 일을 하나도 고르지 않았다 — 칸 문구 */
actionsError: string | null
/** 전달 주소의 잘못 — 칸 문구 (안내 자리에 빨간 글로) */
forwardError: string | null
/** 처음 초점을 첫 칸(메일함으로 옮기기)에 — 읽기 화면에서 만든 새 필터 */
autofocusFirst: boolean
}
interface Emits {
(e: 'update', patch: Partial<MailFilterActionDraft>): void
}
interface MoveOption {
id: string
label: string
disabled: boolean
}
const props = defineProps<Props>()
const emit = defineEmits<Emits>()
const moveRef = ref<HTMLInputElement | null>(null)
const forwardRef = ref<HTMLInputElement | null>(null)
const options = computed<MoveOption[]>(() => {
const list = props.targets.map((mailbox) => ({ id: mailbox.id, label: mailbox.name, disabled: false }))
const target = props.value.moveTarget
if (target === '' || list.some((option) => option.id === target)) return list
const known = props.mailboxes.find((mailbox) => mailbox.id === target)
const current = known
? { id: known.id, label: known.name, disabled: false }
: { id: target, label: MAIL_FILTER_DIALOG_TEXT.missingMailboxOption, disabled: true }
return [current, ...list]
})
function checked(event: Event): boolean {
return (event.target as HTMLInputElement).checked
}
function valueOf(event: Event): string {
return (event.target as HTMLInputElement | HTMLSelectElement).value
}
defineExpose({
/** 할 일이 없을 때 · 읽기 화면에서 만든 새 필터 — 첫 칸(메일함으로 옮기기)으로 */
focusFirst: () => moveRef.value?.focus(),
/** 전달 주소가 잘못됐을 때 */
focusForward: () => forwardRef.value?.focus(),
})
</script>
<template>
<fieldset class="filter-actions">
<legend class="filter-actions__legend">
{{ MAIL_FILTER_DIALOG_TEXT.actionsLegend }}
</legend>
<div class="filter-actions__line">
<label class="filter-actions__check filter-actions__check--lead">
<input
id="mail-filter-move"
ref="moveRef"
type="checkbox"
class="filter-actions__box"
:checked="value.move"
:autofocus="autofocusFirst || undefined"
:aria-describedby="actionsError ? 'mail-filter-actions-error' : undefined"
@change="emit('update', { move: checked($event) })"
>
<span>{{ MAIL_FILTER_DIALOG_TEXT.move }}</span>
</label>
<select
id="mail-filter-move-target"
class="form-input filter-actions__select"
:aria-label="MAIL_FILTER_DIALOG_TEXT.moveTarget"
aria-describedby="mail-filter-move-hint"
:value="value.moveTarget"
:disabled="!value.move"
@change="emit('update', { moveTarget: valueOf($event) })"
>
<option
v-for="option in options"
:key="option.id"
:value="option.id"
:disabled="option.disabled"
>
{{ option.label }}
</option>
</select>
</div>
<p
id="mail-filter-move-hint"
class="form-message filter-actions__hint filter-actions__hint--spaced"
>
{{ MAIL_FILTER_DIALOG_TEXT.moveHint }}
</p>
<label class="filter-actions__check">
<input
id="mail-filter-read"
type="checkbox"
class="filter-actions__box"
:checked="value.markAsRead"
@change="emit('update', { markAsRead: checked($event) })"
>
<span>{{ MAIL_FILTER_DIALOG_TEXT.markAsRead }}</span>
</label>
<label class="filter-actions__check">
<input
id="mail-filter-flag"
type="checkbox"
class="filter-actions__box"
:checked="value.flag"
@change="emit('update', { flag: checked($event) })"
>
<span>{{ MAIL_FILTER_DIALOG_TEXT.flag }}</span>
</label>
<div class="filter-actions__line">
<label class="filter-actions__check filter-actions__check--lead">
<input
id="mail-filter-forward"
type="checkbox"
class="filter-actions__box"
:checked="value.forward"
@change="emit('update', { forward: checked($event) })"
>
<span>{{ MAIL_FILTER_DIALOG_TEXT.forward }}</span>
</label>
<input
id="mail-filter-forward-to"
ref="forwardRef"
class="form-input filter-actions__forward"
type="email"
:value="value.forwardTo"
:maxlength="MAIL_FILTER_FORWARD_MAX_LENGTH"
:placeholder="MAIL_FILTER_DIALOG_TEXT.forwardPlaceholder(domain)"
:aria-label="MAIL_FILTER_DIALOG_TEXT.forwardTarget"
:aria-invalid="forwardError ? 'true' : 'false'"
aria-describedby="mail-filter-forward-help"
:disabled="!value.forward"
autocomplete="off"
spellcheck="false"
@input="emit('update', { forwardTo: valueOf($event) })"
>
</div>
<p
id="mail-filter-forward-help"
:class="['form-message', 'filter-actions__hint', { 'form-message--error': forwardError }]"
>
<AppIcon
v-if="forwardError"
name="alert-circle"
:size="14"
:stroke-width="2"
/>
<span>{{ forwardError ?? MAIL_FILTER_DIALOG_TEXT.forwardHint }}</span>
</p>
<p
v-if="actionsError"
id="mail-filter-actions-error"
class="form-message form-message--error filter-actions__error"
>
<AppIcon
name="alert-circle"
:size="14"
:stroke-width="2"
/>
<span>{{ actionsError }}</span>
</p>
</fieldset>
</template>
<style scoped>
.filter-actions {
display: flex;
flex-direction: column;
gap: var(--space-2);
min-width: 0;
margin: 0;
padding: 0;
border: 0;
}
.filter-actions__legend {
margin: 0 0 var(--space-6);
padding: 0;
font-size: var(--font-size-label);
font-weight: 600;
color: var(--color-text-secondary);
}
/* 고르기 + 칸 한 줄 — 좁으면 칸이 아래로 내려간다 */
.filter-actions__line {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--space-4) var(--space-12);
min-height: var(--size-touch);
}
.filter-actions__check {
display: flex;
align-items: center;
gap: var(--space-10);
min-height: var(--size-touch);
cursor: pointer;
}
/* 칸 앞의 이름 — 두 줄의 칸이 같은 선에서 시작한다 (176) */
.filter-actions__check--lead {
min-width: 11rem;
}
.filter-actions__box {
width: var(--size-option-input);
height: var(--size-option-input);
margin: 0;
accent-color: var(--color-brand);
cursor: pointer;
}
/* 칸 높이 40 — 밀도 예외 (tokens.css "6단계") */
.filter-actions__select,
.filter-actions__forward {
min-height: var(--size-filter-control);
height: var(--size-filter-control);
padding: 0 var(--space-10);
}
.filter-actions__select {
min-width: 12.5rem;
}
.filter-actions__forward {
flex: 1 1 15rem;
min-width: 0;
max-width: 20rem;
}
/* 고르기 상자(18) + 사이(10) 만큼 들여 쓴다 */
.filter-actions__hint {
margin-left: calc(var(--size-option-input) + var(--space-10));
}
.filter-actions__hint--spaced {
margin-bottom: var(--space-4);
}
.filter-actions__error {
margin-top: var(--space-4);
}
</style>
@@ -0,0 +1,193 @@
<script setup lang="ts">
// 고치기 창의 "어떤 메일에" (디자인 원본 SettingsFilter) — 고르기(모두 · 하나라도 · 모든 메일 — 44, 최대 320) + 조건 줄(MailFilterConditionRow) +
// [조건 더하기](40 — 5개까지, 새 줄은 제목) · "5개까지 · 글자는 대소문자를 가리지 않습니다". 모든 메일이면 조건 줄을 숨긴다(쓴 조건은 부모가 남겨 둔다).
// 줄을 더하면 새 줄의 "어디에서"로, 지우면 같은 자리(마지막이었으면 앞) 줄의 찾을 글로 초점을 옮긴다 — 누른 단추가 사라지거나 꺼지므로
// 고정 id — #mail-filter-match · #mail-filter-add-condition (줄의 오류 id 는 MailFilterConditionRow)
import { nextTick, type ComponentPublicInstance } from 'vue'
import AppIcon from '@/components/AppIcon.vue'
import MailFilterConditionRow from '@/components/settings/MailFilterConditionRow.vue'
import {
MAIL_FILTER_DIALOG_TEXT,
MAIL_FILTER_MATCH_LABELS,
MAIL_FILTER_MAX_CONDITIONS,
} from '@/shared/constants/mail-filter'
import type { MailFilterCondition, MailFilterMatch } from '@/shared/types/mail'
interface Props {
match: MailFilterMatch
conditions: readonly MailFilterCondition[]
/** 줄마다의 잘못 — 칸 문구 (차례는 conditions 와 같다) */
errors: readonly (string | null)[]
/** 로그인한 직원 주소의 도메인 — 받는 사람 칸의 보기 */
domain: string
}
interface Emits {
(e: 'update:match', match: MailFilterMatch): void
(e: 'update-condition', index: number, patch: Partial<MailFilterCondition>): void
(e: 'add'): void
(e: 'remove', index: number): void
}
const props = defineProps<Props>()
const emit = defineEmits<Emits>()
const MATCHES = Object.keys(MAIL_FILTER_MATCH_LABELS) as MailFilterMatch[]
/** 조건 줄 — 차례대로 (잘못된 줄 · 새 줄에 초점을 준다) */
const rows: (InstanceType<typeof MailFilterConditionRow> | null)[] = []
function setRow(index: number, row: Element | ComponentPublicInstance | null) {
rows[index] = row as InstanceType<typeof MailFilterConditionRow> | null
}
function setMatch(event: Event) {
const match = MATCHES.find((candidate) => candidate === (event.target as HTMLSelectElement).value)
if (match) emit('update:match', match)
}
function add() {
if (props.conditions.length >= MAIL_FILTER_MAX_CONDITIONS) return
const added = props.conditions.length
emit('add')
void nextTick(() => rows[added]?.focusField())
}
function remove(index: number) {
if (props.conditions.length <= 1) return
const next = Math.min(index, props.conditions.length - 2)
emit('remove', index)
void nextTick(() => rows[next]?.focusValue())
}
defineExpose({
/** 잘못된 줄의 찾을 글 칸으로 */
focusValue: (index: number) => rows[index]?.focusValue(),
})
</script>
<template>
<fieldset class="filter-conditions">
<legend class="filter-conditions__legend">
{{ MAIL_FILTER_DIALOG_TEXT.matchLegend }}
</legend>
<select
id="mail-filter-match"
class="form-input filter-conditions__match"
:aria-label="MAIL_FILTER_DIALOG_TEXT.matchLegend"
:value="match"
@change="setMatch"
>
<option
v-for="option in MATCHES"
:key="option"
:value="option"
>
{{ MAIL_FILTER_MATCH_LABELS[option] }}
</option>
</select>
<template v-if="match !== 'always'">
<ul class="filter-conditions__list">
<MailFilterConditionRow
v-for="(condition, index) in conditions"
:key="index"
:ref="(row) => setRow(index, row)"
:condition="condition"
:index="index"
:can-remove="conditions.length > 1"
:error="errors[index] ?? null"
:domain="domain"
@update="(patch) => emit('update-condition', index, patch)"
@remove="remove(index)"
/>
</ul>
<div class="filter-conditions__add">
<button
id="mail-filter-add-condition"
type="button"
class="filter-conditions__add-button"
:disabled="conditions.length >= MAIL_FILTER_MAX_CONDITIONS"
@click="add"
>
<AppIcon
name="plus"
:size="15"
:stroke-width="1.9"
/>
<span>{{ MAIL_FILTER_DIALOG_TEXT.addCondition }}</span>
</button>
<span class="filter-conditions__hint">{{ MAIL_FILTER_DIALOG_TEXT.conditionHint }}</span>
</div>
</template>
</fieldset>
</template>
<style scoped>
.filter-conditions {
display: flex;
flex-direction: column;
gap: var(--space-8);
min-width: 0;
margin: 0;
padding: 0;
border: 0;
}
.filter-conditions__legend {
margin: 0 0 var(--space-6);
padding: 0;
font-size: var(--font-size-label);
font-weight: 600;
color: var(--color-text-secondary);
}
.filter-conditions__match {
max-width: 20rem;
}
.filter-conditions__list {
display: flex;
flex-direction: column;
gap: var(--space-8);
margin: 0;
padding: 0;
list-style: none;
}
.filter-conditions__add {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--space-4) var(--space-10);
}
/* [조건 더하기] 높이 40 — 밀도 예외 (tokens.css "6단계") */
.filter-conditions__add-button {
display: inline-flex;
align-items: center;
gap: var(--space-6);
min-height: var(--size-filter-control);
padding: 0 var(--space-10);
border: 0;
border-radius: var(--radius-item);
background: transparent;
color: var(--color-text-secondary);
font-weight: 500;
}
.filter-conditions__add-button:hover:not(:disabled) {
background: var(--color-surface-muted);
color: var(--color-text);
}
.filter-conditions__add-button:disabled {
color: var(--color-icon-muted);
cursor: not-allowed;
}
.filter-conditions__hint {
font-size: var(--font-size-small);
color: var(--color-text-muted);
}
</style>
@@ -0,0 +1,197 @@
<script setup lang="ts">
// 고치기 창의 조건 한 줄 (디자인 원본 SettingsFilter) — 어디에서(116) · 포함/포함하지 않음(132) · 찾을 글 · ✕(36×40). 칸 높이 40 — 밀도 예외(tokens.css "6단계").
// 잘못은 [저장]을 누를 때 그 줄 아래 빨간 글로 (디자인 원본 MailFilterStates) — 찾을 글 칸이 aria-invalid · aria-describedby 로 가리킨다.
// 조건이 하나뿐이면 ✕ 를 끈다 (모두 · 하나라도는 조건이 1개 이상)
import { computed, ref } from 'vue'
import AppIcon from '@/components/AppIcon.vue'
import {
MAIL_FILTER_DIALOG_TEXT,
MAIL_FILTER_FIELD_LABELS,
MAIL_FILTER_OPERATOR_LABELS,
MAIL_FILTER_VALUE_MAX_LENGTH,
MAIL_FILTER_VALUE_PLACEHOLDERS,
} from '@/shared/constants/mail-filter'
import type { MailFilterCondition, MailFilterField, MailFilterOperator } from '@/shared/types/mail'
interface Props {
condition: MailFilterCondition
/** 0부터 — 칸 이름은 "조건 1 — …" */
index: number
canRemove: boolean
/** 이 줄의 잘못 (칸 · 사전 문구) — 없으면 null */
error: string | null
/** 로그인한 직원 주소의 도메인 — 받는 사람 칸의 보기("예: all@<도메인>") */
domain: string
}
interface Emits {
(e: 'update', patch: Partial<MailFilterCondition>): void
(e: 'remove'): void
}
const props = defineProps<Props>()
const emit = defineEmits<Emits>()
const FIELDS = Object.keys(MAIL_FILTER_FIELD_LABELS) as MailFilterField[]
const OPERATORS = Object.keys(MAIL_FILTER_OPERATOR_LABELS) as MailFilterOperator[]
const fieldRef = ref<HTMLSelectElement | null>(null)
const valueRef = ref<HTMLInputElement | null>(null)
const no = computed(() => props.index + 1)
const errorId = computed(() => `mail-filter-condition-${props.index}-error`)
function selected(event: Event): string {
return (event.target as HTMLSelectElement).value
}
function setField(event: Event) {
const field = FIELDS.find((candidate) => candidate === selected(event))
if (field) emit('update', { field })
}
function setOperator(event: Event) {
const operator = OPERATORS.find((candidate) => candidate === selected(event))
if (operator) emit('update', { operator })
}
function setValue(event: Event) {
emit('update', { value: (event.target as HTMLInputElement).value })
}
defineExpose({
/** 잘못된 줄 — 찾을 글 칸으로 */
focusValue: () => valueRef.value?.focus(),
/** 방금 더한 줄 — 어디에서 칸으로 */
focusField: () => fieldRef.value?.focus(),
})
</script>
<template>
<li class="filter-condition">
<div class="filter-condition__row">
<select
ref="fieldRef"
class="form-input filter-condition__control"
:aria-label="MAIL_FILTER_DIALOG_TEXT.conditionField(no)"
:value="condition.field"
data-filter-condition-field
@change="setField"
>
<option
v-for="field in FIELDS"
:key="field"
:value="field"
>
{{ MAIL_FILTER_FIELD_LABELS[field] }}
</option>
</select>
<select
class="form-input filter-condition__control"
:aria-label="MAIL_FILTER_DIALOG_TEXT.conditionOperator(no)"
:value="condition.operator"
data-filter-condition-operator
@change="setOperator"
>
<option
v-for="operator in OPERATORS"
:key="operator"
:value="operator"
>
{{ MAIL_FILTER_OPERATOR_LABELS[operator] }}
</option>
</select>
<input
ref="valueRef"
class="form-input filter-condition__control filter-condition__value"
type="text"
:value="condition.value"
:maxlength="MAIL_FILTER_VALUE_MAX_LENGTH"
:placeholder="MAIL_FILTER_VALUE_PLACEHOLDERS[condition.field](domain)"
:aria-label="MAIL_FILTER_DIALOG_TEXT.conditionValue(no)"
:aria-invalid="error ? 'true' : 'false'"
:aria-describedby="error ? errorId : undefined"
autocomplete="off"
data-filter-condition-value
@input="setValue"
>
<button
type="button"
class="filter-condition__remove"
:aria-label="MAIL_FILTER_DIALOG_TEXT.conditionRemove(no)"
:disabled="!canRemove"
data-filter-condition-remove
@click="emit('remove')"
>
<AppIcon
name="x"
:size="15"
:stroke-width="1.9"
/>
</button>
</div>
<p
v-if="error"
:id="errorId"
class="form-message form-message--error"
>
<AppIcon
name="alert-circle"
:size="14"
:stroke-width="2"
/>
<span>{{ error }}</span>
</p>
</li>
</template>
<style scoped>
.filter-condition {
display: flex;
flex-direction: column;
gap: var(--space-4);
}
/* 어디에서 116 · 포함/포함하지 않음 132 · 찾을 글 · ✕ 36 (디자인 원본 SettingsFilter) */
.filter-condition__row {
display: grid;
grid-template-columns: 7.25rem 8.25rem minmax(0, 1fr) var(--size-filter-remove);
align-items: center;
gap: var(--space-8);
}
/* 칸 높이 40 — 밀도 예외 */
.filter-condition__control {
min-width: 0;
min-height: var(--size-filter-control);
height: var(--size-filter-control);
padding: 0 var(--space-8);
}
.filter-condition__value {
padding: 0 var(--space-10);
}
.filter-condition__remove {
display: inline-flex;
align-items: center;
justify-content: center;
width: var(--size-filter-remove);
height: var(--size-filter-control);
padding: 0;
border: 0;
border-radius: var(--radius-sm);
background: transparent;
color: var(--color-text-secondary);
}
.filter-condition__remove:hover:not(:disabled) {
background: var(--color-surface-muted);
color: var(--color-text);
}
.filter-condition__remove:disabled {
color: var(--color-border-strong);
}
</style>
@@ -0,0 +1,81 @@
<script setup lang="ts">
// [새 필터] — 설정 › 필터 머리 오른쪽 · 필터가 없을 때의 안내 안 (디자인 원본 SettingsFilter · MailFilterStates — 브랜드색 44, + 아이콘 15)
// 누를 수 없을 때(50개를 다 만들었다 · 아직 받는 중)는 disabled 가 아니라 aria-disabled — 고치기 창을 닫으면 초점이 이 단추로 돌아오기 때문이다.
// 까닭은 옆의 글을 describedBy 로 가리킨다
import { ref } from 'vue'
import AppIcon from '@/components/AppIcon.vue'
import { MAIL_FILTER_TEXT } from '@/shared/constants/mail-filter'
interface Props {
disabled?: boolean
/** 누를 수 없는 까닭의 id */
describedBy?: string
}
interface Emits {
(e: 'create'): void
}
const props = withDefaults(defineProps<Props>(), {
disabled: false,
describedBy: undefined,
})
const emit = defineEmits<Emits>()
const buttonRef = ref<HTMLButtonElement | null>(null)
function onClick() {
if (!props.disabled) emit('create')
}
defineExpose({
focus: () => buttonRef.value?.focus(),
})
</script>
<template>
<button
ref="buttonRef"
type="button"
class="filter-create"
:aria-disabled="disabled ? 'true' : undefined"
:aria-describedby="describedBy"
data-filter-create
@click="onClick"
>
<AppIcon
name="plus"
:size="15"
:stroke-width="2"
/>
<span>{{ MAIL_FILTER_TEXT.create }}</span>
</button>
</template>
<style scoped>
.filter-create {
flex: 0 0 auto;
display: inline-flex;
align-items: center;
gap: var(--space-6);
min-height: var(--size-touch);
padding: 0 var(--space-16);
border: 0;
border-radius: var(--radius-md);
background: var(--color-brand);
color: var(--color-on-brand);
font-weight: 600;
}
.filter-create:hover:not([aria-disabled='true']) {
background: var(--color-brand-strong);
}
/* 꺼진 모양 (디자인 원본 MailFilterStates "50개를 다 만들었을 때") */
.filter-create[aria-disabled='true'] {
background: var(--color-skeleton);
color: var(--color-icon-muted);
cursor: not-allowed;
}
</style>
@@ -0,0 +1,466 @@
<script setup lang="ts">
// 필터 고치기 창 (디자인 원본 SettingsFilter "새 필터" · "필터 고치기" — 모달 620, 공용 모양 ModalDialog 의 넓은 창)
// 이름(선택, 50자) → "어떤 메일에"(MailFilterConditionFields — 모두 · 하나라도 · 모든 메일 + 조건 줄 5개까지) →
// 할 일(MailFilterActionFields) → 안내 띠 → [이 필터 지우기](고칠 때만, 왼쪽) · [저장] [취소].
// - 잘못은 [저장]을 누를 때 칸 아래 빨간 글로만 — 빈 조건 · `${` · 할 일 없음 · 전달 주소(빈 칸 · 형식 · 사내 주소가 아님 · 내 주소) ·
// 이름 · 찾을 글의 길이 · 제어 문자(사전의 VAL_001).
// 서버와 같은 규칙(filterDraftProblems)이고, 첫 잘못된 칸에 초점을 준다(알림 없음). 칸을 고치면 그 칸의 잘못을 내린다.
// 서버의 거절(VAL_001 · MAIL_013 · 그사이 바뀜 MAIL_012)과 50개 넘음은 단추 줄 위에 — 부모가 error 로 준다(무엇이든 고치면 clear-error).
// 그사이 바뀜(conflict)이면 그 옆에 [다시 불러오기] — 누르면 부모가 목록을 다시 받고, 받았으면 [저장]으로 초점을 옮긴다(focusSave)
// - 열 때마다 칸을 새로 채운다 — 새 필터는 빈 조건 한 줄(읽기 화면에서 왔으면 "보낸 사람 · 포함 · 그 주소"), 고치기는 그 규칙.
// 처음 초점은 이름 칸, 읽기 화면에서 만든 새 필터는 할 일의 첫 칸 (디자인 원본 MailFilterStates)
// - 보내는 · 다시 받는 동안(pending)과 다른 저장을 기다리는 동안(busy)은 [저장] · [이 필터 지우기]를 aria-disabled 로 막는다 — 누른 단추가 초점을 잃지 않게.
// [취소] · Esc 는 언제든 닫는다(보낸 저장의 결과는 부모가 알림으로 알린다)
// 고정 id — #mail-filter-dialog · #mail-filter-dialog-title · #mail-filter-name · #mail-filter-name-error · #mail-filter-error · #mail-filter-reload ·
// #mail-filter-remove · #mail-filter-save · #mail-filter-cancel (조건 · 할 일 칸의 id 는 각 칸 컴포넌트에)
import { computed, reactive, ref, watch } from 'vue'
import AppIcon from '@/components/AppIcon.vue'
import ModalDialog from '@/components/feedback/ModalDialog.vue'
import MailFilterActionFields from '@/components/settings/MailFilterActionFields.vue'
import MailFilterConditionFields from '@/components/settings/MailFilterConditionFields.vue'
import { getErrorMessage } from '@/composables/useApi'
import type { MailFilterDialogPending } from '@/composables/useMailFilterDialog'
import { MAIL_FILTER_DIALOG_TEXT, MAIL_FILTER_NAME_MAX_LENGTH, MAIL_FILTER_TEXT } from '@/shared/constants/mail-filter'
import type { Mailbox, MailFilterCondition, MailFilterMatch, MailFilterRule } from '@/shared/types/mail'
import {
defaultMoveTarget,
emptyCondition,
filterDraftOf,
filterDraftProblems,
filterMoveTargets,
filterRuleOf,
hasDraftAction,
hasFilterProblems,
mailDomainOf,
newFilterDraft,
newFilterRuleBase,
noFilterProblems,
type MailFilterActionDraft,
type MailFilterConditionProblem,
type MailFilterDraft,
type MailFilterForwardProblem,
type MailFilterProblems,
} from '@/shared/utils/mail-filter'
interface Props {
open: boolean
/** 고칠 규칙 — null 이면 새 필터 */
rule: MailFilterRule | null
/** 새 필터를 채울 보낸 사람 주소 (읽기 화면 ⋯ 「이런 메일로 필터 만들기」) */
prefillFrom?: string | null
/** 모든 메일함 — 옮길 메일함 고르기 · 지워진 메일함 판정 */
mailboxes: readonly Mailbox[]
/** 로그인한 직원 주소 — 사내 도메인 · 자기 주소 판정 */
userEmail: string
pending?: MailFilterDialogPending
/** 다른 저장(켜기 · 순서)을 기다리는 중 */
busy?: boolean
/** 서버가 거절한 까닭 — 사전 문구 (50개 넘음은 fullNote) */
error?: string | null
/** 그 거절이 그사이 바뀜(MAIL_012)이다 — 옆에 [다시 불러오기]를 둔다 */
conflict?: boolean
}
interface Emits {
(e: 'save', rule: MailFilterRule): void
(e: 'remove'): void
(e: 'cancel'): void
(e: 'clear-error'): void
/** 그사이 바뀜 — 목록을 다시 받아 달라 (창 · 쓴 내용은 그대로) */
(e: 'reload'): void
}
const props = withDefaults(defineProps<Props>(), {
prefillFrom: null,
pending: null,
busy: false,
error: null,
conflict: false,
})
const emit = defineEmits<Emits>()
const nameRef = ref<HTMLInputElement | null>(null)
const conditionsRef = ref<InstanceType<typeof MailFilterConditionFields> | null>(null)
const actionsRef = ref<InstanceType<typeof MailFilterActionFields> | null>(null)
const saveRef = ref<HTMLButtonElement | null>(null)
const targets = computed(() => filterMoveTargets(props.mailboxes))
const domain = computed(() => mailDomainOf(props.userEmail))
/** 읽기 화면에서 만든 새 필터 — 처음 초점이 할 일의 첫 칸이다 */
const prefilled = computed(() => props.rule === null && Boolean(props.prefillFrom))
const locked = computed(() => props.pending !== null || props.busy)
const title = computed(() =>
props.rule ? MAIL_FILTER_DIALOG_TEXT.editTitle : MAIL_FILTER_DIALOG_TEXT.createTitle,
)
const draft = reactive<MailFilterDraft>(initialDraft())
const problems = ref<MailFilterProblems>(noFilterProblems(draft.conditions.length))
/** 새 규칙의 id · 켜짐 — 창을 열 때 한 번 만든다 (서버가 거절한 뒤 다시 눌러도 같은 규칙이다) */
let base = baseOf()
const actionsValue = computed<MailFilterActionDraft>(() => ({
move: draft.move,
moveTarget: draft.moveTarget,
markAsRead: draft.markAsRead,
flag: draft.flag,
forward: draft.forward,
forwardTo: draft.forwardTo,
}))
const nameError = computed(() => (problems.value.name ? getErrorMessage('VAL_001') : null))
const conditionErrors = computed(() => problems.value.conditions.map(conditionMessage))
const actionsError = computed(() => (problems.value.actions ? MAIL_FILTER_DIALOG_TEXT.actionsEmpty : null))
const forwardError = computed(() => forwardMessage(problems.value.forward))
function initialDraft(): MailFilterDraft {
const target = defaultMoveTarget(targets.value)
return props.rule
? filterDraftOf(props.rule, target)
: newFilterDraft({ moveTarget: target, from: props.prefillFrom })
}
function baseOf(): Pick<MailFilterRule, 'id' | 'isEnabled'> {
return props.rule ? { id: props.rule.id, isEnabled: props.rule.isEnabled } : newFilterRuleBase()
}
/** 열 때마다 — 그 규칙(또는 새 필터)으로 칸을 다시 채운다 */
function resetDraft() {
Object.assign(draft, initialDraft())
problems.value = noFilterProblems(draft.conditions.length)
base = baseOf()
}
function conditionMessage(problem: MailFilterConditionProblem | null): string | null {
if (problem === 'empty') return MAIL_FILTER_DIALOG_TEXT.conditionEmpty
if (problem === 'variable') return MAIL_FILTER_DIALOG_TEXT.conditionVariable
if (problem === 'invalid') return getErrorMessage('VAL_001')
return null
}
function forwardMessage(problem: MailFilterForwardProblem | null): string | null {
if (problem === 'empty') return MAIL_FILTER_DIALOG_TEXT.forwardEmpty
if (problem === 'invalid') return MAIL_FILTER_DIALOG_TEXT.forwardInvalid
if (problem === 'external') return MAIL_FILTER_DIALOG_TEXT.forwardExternal(domain.value)
if (problem === 'self') return MAIL_FILTER_DIALOG_TEXT.forwardSelf
if (problem === 'variable') return MAIL_FILTER_DIALOG_TEXT.conditionVariable
return null
}
/** 칸을 고쳤다 — 그 칸의 잘못(patch)과 서버의 거절을 내린다 */
function edited(patch: Partial<MailFilterProblems>) {
problems.value = { ...problems.value, ...patch }
if (props.error) emit('clear-error')
}
function setName(event: Event) {
draft.name = (event.target as HTMLInputElement).value
edited({ name: false })
}
function setMatch(match: MailFilterMatch) {
draft.match = match
if (match !== 'always' && draft.conditions.length === 0) draft.conditions = [emptyCondition()]
edited({ conditions: draft.conditions.map(() => null) })
}
function updateCondition(index: number, patch: Partial<MailFilterCondition>) {
draft.conditions = draft.conditions.map((condition, at) => (at === index ? { ...condition, ...patch } : condition))
edited({ conditions: problems.value.conditions.map((problem, at) => (at === index ? null : problem)) })
}
function addCondition() {
draft.conditions = [...draft.conditions, emptyCondition('subject')]
edited({ conditions: [...problems.value.conditions, null] })
}
function removeCondition(index: number) {
draft.conditions = draft.conditions.filter((_, at) => at !== index)
edited({ conditions: problems.value.conditions.filter((_, at) => at !== index) })
}
function updateActions(patch: Partial<MailFilterActionDraft>) {
Object.assign(draft, patch)
const touchesForward = 'forward' in patch || 'forwardTo' in patch
edited({
actions: problems.value.actions && !hasDraftAction(draft),
forward: touchesForward ? null : problems.value.forward,
})
}
/** 첫 잘못된 칸 — 이름 → 조건(차례대로) → 할 일 → 전달 주소 */
function focusFirstProblem(found: MailFilterProblems) {
const condition = found.conditions.findIndex((problem) => problem !== null)
if (found.name) nameRef.value?.focus()
else if (condition >= 0) conditionsRef.value?.focusValue(condition)
else if (found.actions) actionsRef.value?.focusFirst()
else if (found.forward) actionsRef.value?.focusForward()
}
function save() {
if (locked.value) return
const found = filterDraftProblems(draft, props.userEmail)
problems.value = found
if (hasFilterProblems(found)) {
focusFirstProblem(found)
return
}
emit('save', filterRuleOf(draft, base))
}
function remove() {
if (!locked.value) emit('remove')
}
function reload() {
if (props.pending === null) emit('reload')
}
defineExpose({
/** 다시 받은 뒤 — [저장]으로 (다시 받은 목록 위에 저장한다) */
focusSave: () => saveRef.value?.focus(),
})
// 열 때마다(연 채로 다른 규칙을 받아도) 칸을 새로 채운다 — 창을 그리기 전에(처음 초점이 새 칸에 간다)
watch(
() => [props.open, props.rule, props.prefillFrom] as const,
([open]) => {
if (open) resetDraft()
},
)
</script>
<template>
<ModalDialog
id="mail-filter-dialog"
:open="open"
:title="title"
title-id="mail-filter-dialog-title"
wide
@cancel="emit('cancel')"
>
<div class="filter-dialog">
<div class="filter-dialog__field">
<label
for="mail-filter-name"
class="filter-dialog__label"
>
{{ MAIL_FILTER_DIALOG_TEXT.nameLabel }}
<span class="filter-dialog__optional">{{ MAIL_FILTER_DIALOG_TEXT.nameOptional }}</span>
</label>
<input
id="mail-filter-name"
ref="nameRef"
class="form-input"
type="text"
:value="draft.name"
:maxlength="MAIL_FILTER_NAME_MAX_LENGTH"
:placeholder="MAIL_FILTER_DIALOG_TEXT.namePlaceholder"
:aria-invalid="nameError ? 'true' : 'false'"
:aria-describedby="nameError ? 'mail-filter-name-error' : undefined"
:autofocus="!prefilled || undefined"
autocomplete="off"
@input="setName"
>
<p
v-if="nameError"
id="mail-filter-name-error"
class="form-message form-message--error"
>
<AppIcon
name="alert-circle"
:size="14"
:stroke-width="2"
/>
<span>{{ nameError }}</span>
</p>
</div>
<MailFilterConditionFields
ref="conditionsRef"
:match="draft.match"
:conditions="draft.conditions"
:errors="conditionErrors"
:domain="domain"
@update:match="setMatch"
@update-condition="updateCondition"
@add="addCondition"
@remove="removeCondition"
/>
<MailFilterActionFields
ref="actionsRef"
:value="actionsValue"
:targets="targets"
:mailboxes="mailboxes"
:domain="domain"
:actions-error="actionsError"
:forward-error="forwardError"
:autofocus-first="prefilled"
@update="updateActions"
/>
<p class="filter-dialog__notice">
{{ MAIL_FILTER_DIALOG_TEXT.notice }}
</p>
<div
v-if="error"
class="filter-dialog__error"
>
<p
id="mail-filter-error"
class="form-message form-message--error filter-dialog__error-text"
role="alert"
>
<AppIcon
name="alert-circle"
:size="14"
:stroke-width="2"
/>
<span>{{ error }}</span>
</p>
<button
v-if="conflict"
id="mail-filter-reload"
type="button"
class="filter-dialog__reload"
aria-describedby="mail-filter-error"
:aria-disabled="pending !== null ? 'true' : undefined"
:aria-busy="pending === 'reload' ? 'true' : undefined"
@click="reload"
>
{{ MAIL_FILTER_TEXT.reload }}
</button>
</div>
</div>
<template #actions>
<button
v-if="rule"
id="mail-filter-remove"
type="button"
class="modal-dialog__action filter-dialog__remove"
:aria-disabled="locked ? 'true' : undefined"
:aria-busy="pending === 'remove' ? 'true' : undefined"
@click="remove"
>
<AppIcon
name="trash"
:size="15"
/>
<span>{{ MAIL_FILTER_DIALOG_TEXT.remove }}</span>
</button>
<button
id="mail-filter-save"
ref="saveRef"
type="button"
class="modal-dialog__action filter-dialog__save"
:aria-disabled="locked ? 'true' : undefined"
:aria-busy="pending === 'save' ? 'true' : undefined"
@click="save"
>
{{ MAIL_FILTER_DIALOG_TEXT.save }}
</button>
<button
id="mail-filter-cancel"
type="button"
class="modal-dialog__action modal-dialog__action--secondary"
@click="emit('cancel')"
>
{{ MAIL_FILTER_DIALOG_TEXT.cancel }}
</button>
</template>
</ModalDialog>
</template>
<style scoped>
/* 묶음 사이 16 — 제목과의 사이도 16(창의 8 + 8), 단추 줄 앞도 18 (디자인 원본 SettingsFilter) */
.filter-dialog {
display: flex;
flex-direction: column;
gap: var(--space-16);
margin: var(--space-8) 0 var(--space-6);
}
.filter-dialog__field {
display: flex;
flex-direction: column;
gap: var(--space-6);
}
.filter-dialog__label {
font-size: var(--font-size-label);
font-weight: 600;
color: var(--color-text-secondary);
}
.filter-dialog__optional {
font-weight: 400;
color: var(--color-text-muted);
}
/* 단추 줄 위의 거절 — 그사이 바뀜이면 옆에 [다시 불러오기] (36 — 목록 위 줄의 단추와 같은 밀도 예외) */
.filter-dialog__error {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--space-4) var(--space-10);
}
.filter-dialog__error-text {
flex: 1 1 18rem;
}
.filter-dialog__reload {
flex: 0 0 auto;
min-height: var(--size-notice-action);
padding: 0 var(--space-10);
border: 0;
border-radius: var(--radius-item);
background: transparent;
color: var(--color-brand-strong);
font-weight: 600;
}
.filter-dialog__reload:hover:not([aria-disabled='true']) {
background: var(--color-brand-subtle);
}
.filter-dialog__reload[aria-disabled='true'] {
opacity: 0.6;
cursor: progress;
}
.filter-dialog__notice {
margin: 0;
padding: var(--space-10) var(--space-12);
border-radius: var(--radius-md);
background: var(--color-surface-panel);
font-size: var(--font-size-label);
color: var(--color-text-secondary);
}
/* [이 필터 지우기] — 왼쪽 끝, 바탕 없는 빨간 글 (디자인 원본 SettingsFilter) */
.modal-dialog__action.filter-dialog__remove {
gap: var(--space-6);
margin-right: auto;
padding: 0 var(--space-12);
background: transparent;
color: var(--color-danger);
}
.modal-dialog__action.filter-dialog__remove:not([aria-disabled='true']):hover {
background: var(--color-danger-surface);
color: var(--color-danger-strong);
}
.modal-dialog__action.filter-dialog__save {
padding: 0 var(--space-18);
}
/* 보내는 동안 — 끄지 않고 흐리게만 (초점이 남는다) */
.modal-dialog__action[aria-disabled='true'] {
opacity: 0.6;
cursor: progress;
}
</style>
@@ -0,0 +1,71 @@
<script setup lang="ts">
// 필터가 아직 없을 때 — 목록 자리의 안내 (디자인 원본 MailFilterStates): 점선 상자 · 깔때기 36 · "아직 필터가 없습니다" · 설명 · [새 필터]
// [새 필터]는 머리의 단추와 같은 일을 한다
import AppIcon from '@/components/AppIcon.vue'
import MailFilterCreateButton from '@/components/settings/MailFilterCreateButton.vue'
import { MAIL_FILTER_TEXT } from '@/shared/constants/mail-filter'
interface Emits {
(e: 'create'): void
}
const emit = defineEmits<Emits>()
</script>
<template>
<div
class="filter-empty"
data-filter-empty
>
<AppIcon
name="filter"
:size="36"
:stroke-width="1.5"
class="filter-empty__icon"
/>
<p class="filter-empty__title">
{{ MAIL_FILTER_TEXT.emptyTitle }}
</p>
<p class="filter-empty__text">
{{ MAIL_FILTER_TEXT.emptyText }}
</p>
<MailFilterCreateButton
class="filter-empty__create"
@create="emit('create')"
/>
</div>
</template>
<style scoped>
.filter-empty {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--space-8);
padding: var(--space-40) var(--space-24);
border: var(--border-width) dashed var(--color-border-strong);
border-radius: var(--radius-lg);
text-align: center;
}
.filter-empty__icon {
color: var(--color-icon-muted);
}
.filter-empty__title {
margin: var(--space-4) 0 0;
font-size: var(--font-size-body);
font-weight: 600;
}
.filter-empty__text {
max-width: 25rem;
margin: 0;
font-size: var(--font-size-label);
color: var(--color-text-muted);
}
.filter-empty__create {
margin-top: var(--space-6);
}
</style>
@@ -0,0 +1,75 @@
<script setup lang="ts">
// 필터 목록 — 테두리 상자 하나에 행(MailFilterRow) (디자인 원본 SettingsFilter). 위에서부터 차례로 적용하므로 순서 있는 목록(ol)이다.
// 켜기 · ▲▼ · 행 누름(고치기)을 부모에 알리고, 부모가 행 안의 단추에 초점을 줄 수 있게 한다(focusRule — 순서를 바꾸거나 지운 뒤)
import type { ComponentPublicInstance } from 'vue'
import MailFilterRow from '@/components/settings/MailFilterRow.vue'
import type { MailFilterRowPart } from '@/components/settings/mail-filter-ui'
import { MAIL_FILTER_TEXT } from '@/shared/constants/mail-filter'
import type { Mailbox, MailFilterRule } from '@/shared/types/mail'
interface Props {
rules: readonly MailFilterRule[]
mailboxes: readonly Mailbox[]
/** 저장을 기다리는 중 — 행마다 켜기 · 순서를 막는다(aria-disabled — 목록 전체에 aria-busy 는 걸지 않는다) */
busy: boolean
}
interface Emits {
(e: 'toggle', rule: MailFilterRule): void
(e: 'move', rule: MailFilterRule, offset: -1 | 1): void
(e: 'edit', rule: MailFilterRule): void
}
defineProps<Props>()
const emit = defineEmits<Emits>()
/** 규칙 id → 행 — 행이 옮겨 다녀도 같은 행을 찾는다 */
const rows = new Map<string, InstanceType<typeof MailFilterRow>>()
function setRow(id: string, row: Element | ComponentPublicInstance | null) {
if (row) rows.set(id, row as InstanceType<typeof MailFilterRow>)
else rows.delete(id)
}
defineExpose({
/** 그 규칙 행의 단추에 초점 — 행이 없으면 false */
focusRule(id: string, part: MailFilterRowPart): boolean {
const row = rows.get(id)
row?.focus(part)
return row !== undefined
},
})
</script>
<template>
<ol
class="filter-list"
:aria-label="MAIL_FILTER_TEXT.listLabel"
>
<MailFilterRow
v-for="(rule, index) in rules"
:key="rule.id"
:ref="(row) => setRow(rule.id, row)"
:rule="rule"
:index="index"
:count="rules.length"
:mailboxes="mailboxes"
:busy="busy"
@toggle="emit('toggle', rule)"
@move="(offset) => emit('move', rule, offset)"
@edit="emit('edit', rule)"
/>
</ol>
</template>
<style scoped>
.filter-list {
margin: 0;
padding: 0;
list-style: none;
border: var(--border-width) solid var(--color-border);
border-radius: var(--radius-lg);
overflow: hidden;
}
</style>
@@ -0,0 +1,52 @@
<script setup lang="ts">
// 설정 › 필터 아래 안내 상자 (디자인 원본 SettingsFilter — 바탕 #F3F8F8, 최대 700):
// 차례로 적용 · 옮기면 멈춤 / 스팸 제외 / 사내 전달만 · 한 곳 · 사본 남김 / 부재중과 함께 동작. 첫 줄의 "위에서부터 차례로"는 굵게
import { MAIL_FILTER_TEXT } from '@/shared/constants/mail-filter'
const NOTES = [MAIL_FILTER_TEXT.noteSpam, MAIL_FILTER_TEXT.noteForward, MAIL_FILTER_TEXT.noteVacation] as const
</script>
<template>
<div
class="filter-notes"
data-filter-notes
>
<p class="filter-notes__note">
<span aria-hidden="true">·</span>
<span><strong class="filter-notes__strong">{{ MAIL_FILTER_TEXT.noteOrderStrong }}</strong>{{
MAIL_FILTER_TEXT.noteOrderAfter
}}</span>
</p>
<p
v-for="note in NOTES"
:key="note"
class="filter-notes__note"
>
<span aria-hidden="true">·</span>
<span>{{ note }}</span>
</p>
</div>
</template>
<style scoped>
.filter-notes {
display: flex;
flex-direction: column;
gap: var(--space-6);
max-width: 43.75rem;
padding: var(--space-14) var(--space-16);
border-radius: var(--radius-lg);
background: var(--color-brand-tint);
color: var(--color-text-strong);
}
.filter-notes__note {
display: flex;
gap: var(--space-8);
margin: 0;
}
.filter-notes__strong {
font-weight: 600;
}
</style>
@@ -0,0 +1,97 @@
<script setup lang="ts">
// 필터 목록 위 노란 줄 (디자인 원본 MailFilterStates) — 바탕 #FFF7E6 · 글 #7A5200, 아이콘 16 · 글 · 단추(36 — 밀도 예외 --size-notice-action)
// 다른 곳에서 먼저 바꿈(409 MAIL_012 — [다시 불러오기]) · 동작하지 않음(isActive: false — [다시 켜기]) 두 곳에 쓴다.
// 단추를 누르고 기다리는 동안은 disabled 가 아니라 aria-disabled · aria-busy — 초점이 그대로 남게 (다시 누르는 것은 부모가 막는다)
import AppIcon from '@/components/AppIcon.vue'
import type { IconName } from '@/shared/constants/icons'
interface Props {
icon: IconName
message: string
actionLabel: string
busy?: boolean
/** 저장이 막혀 바로 알려야 하면 alert(그사이 바뀜), 들어올 때부터 있던 상태면 status(동작하지 않음) */
live?: 'alert' | 'status'
}
interface Emits {
(e: 'action'): void
}
const props = withDefaults(defineProps<Props>(), {
busy: false,
live: 'status',
})
const emit = defineEmits<Emits>()
function onAction() {
if (!props.busy) emit('action')
}
</script>
<template>
<div
class="filter-notice"
:role="live"
>
<AppIcon
:name="icon"
:size="16"
:stroke-width="1.9"
class="filter-notice__icon"
/>
<span class="filter-notice__text">{{ message }}</span>
<button
type="button"
class="filter-notice__action"
:aria-disabled="busy ? 'true' : undefined"
:aria-busy="busy ? 'true' : undefined"
@click="onAction"
>
{{ actionLabel }}
</button>
</div>
</template>
<style scoped>
.filter-notice {
display: flex;
align-items: flex-start;
gap: var(--space-10);
padding: var(--space-10) var(--space-8) var(--space-10) var(--space-12);
border-radius: var(--radius-md);
background: var(--color-warning-surface);
color: var(--color-warning-text);
}
.filter-notice__icon {
margin-top: var(--space-2);
}
.filter-notice__text {
flex: 1 1 auto;
min-width: 0;
align-self: center;
}
/* 줄 안의 단추 36 — 밀도 예외 (알림 권하기 줄의 단추와 같다) */
.filter-notice__action {
flex: 0 0 auto;
min-height: var(--size-notice-action);
padding: 0 var(--space-10);
border: 0;
border-radius: var(--radius-item);
background: transparent;
color: var(--color-warning-text);
font-weight: 600;
}
.filter-notice__action:hover:not([aria-disabled='true']) {
background: var(--color-warning-hover);
}
.filter-notice__action[aria-disabled='true'] {
opacity: 0.6;
cursor: progress;
}
</style>
@@ -0,0 +1,306 @@
<script setup lang="ts">
// 필터 목록의 한 행 (디자인 원본 SettingsFilter — 행 60, 칸 22 · 40 · 나머지 · ▲▼)
// 순서 번호 · 켜기 스위치(role="switch" — 그림은 36×20, 누르는 곳은 칸 전체 40×44 — 밀도 예외) · 이름(굵게)과 요약 한 줄(누르면 고치기 창) ·
// ▲ ▼(32×40 — 밀도 예외, 메일함 ⋯ 와 같다). 단추 이름에 넣는 이름은 quotedRuleTitle(길면 자르고 따옴표가 겹치지 않게).
// 꺼 둔 규칙은 글자를 흐리게, 옮길 메일함이 지워진 규칙은 빨간 안내 한 줄.
// 저장하는 동안(busy)과 맨 위의 ▲ · 맨 아래의 ▼ 는 disabled 가 아니라 aria-disabled — 누른 단추가 초점을 잃지 않게 (5단계의 규칙과 같다)
import { computed, ref } from 'vue'
import AppIcon from '@/components/AppIcon.vue'
import type { MailFilterRowPart } from '@/components/settings/mail-filter-ui'
import { MAIL_FILTER_TEXT } from '@/shared/constants/mail-filter'
import type { Mailbox, MailFilterRule } from '@/shared/types/mail'
import { isMoveTargetMissing, quotedRuleTitle, ruleSummary, ruleTitle } from '@/shared/utils/mail-filter'
interface Props {
rule: MailFilterRule
/** 0부터 */
index: number
count: number
mailboxes: readonly Mailbox[]
/** 다른 저장을 기다리는 중 — 켜기 · 순서를 막는다 */
busy: boolean
}
interface Emits {
(e: 'toggle'): void
(e: 'move', offset: -1 | 1): void
(e: 'edit'): void
}
const props = defineProps<Props>()
const emit = defineEmits<Emits>()
const editRef = ref<HTMLButtonElement | null>(null)
const upRef = ref<HTMLButtonElement | null>(null)
const downRef = ref<HTMLButtonElement | null>(null)
const title = computed(() => ruleTitle(props.rule))
/** 단추 이름에 넣는 이름 — 따옴표로 묶고, 조건 글이 길면 자른다 */
const quoted = computed(() => quotedRuleTitle(props.rule))
const summary = computed(() => ruleSummary(props.rule, mailboxName))
const missing = computed(() => isMoveTargetMissing(props.rule, props.mailboxes))
const isFirst = computed(() => props.index === 0)
const isLast = computed(() => props.index === props.count - 1)
const summaryId = computed(() => `mail-filter-${props.index}-summary`)
const missingId = computed(() => `mail-filter-${props.index}-missing`)
function mailboxName(id: string): string | null {
return props.mailboxes.find((mailbox) => mailbox.id === id)?.name ?? null
}
function toggle() {
if (!props.busy) emit('toggle')
}
function move(offset: -1 | 1) {
const atEdge = offset < 0 ? isFirst.value : isLast.value
if (!props.busy && !atEdge) emit('move', offset)
}
defineExpose({
focus: (part: MailFilterRowPart) => ({ edit: editRef, up: upRef, down: downRef })[part].value?.focus(),
})
</script>
<template>
<li
:class="['filter-row', { 'is-off': !rule.isEnabled, 'is-busy': busy }]"
:data-filter-rule="rule.id"
>
<span class="filter-row__no">{{ index + 1 }}</span>
<button
type="button"
class="filter-row__switch"
role="switch"
:aria-checked="rule.isEnabled ? 'true' : 'false'"
:aria-label="MAIL_FILTER_TEXT.switchLabel(quoted)"
:aria-disabled="busy ? 'true' : undefined"
data-filter-switch
@click="toggle"
>
<span
class="filter-row__track"
aria-hidden="true"
>
<span class="filter-row__knob" />
</span>
</button>
<button
ref="editRef"
type="button"
class="filter-row__main"
:aria-label="MAIL_FILTER_TEXT.editLabel(quoted)"
:aria-describedby="missing ? `${summaryId} ${missingId}` : summaryId"
data-filter-edit
@click="emit('edit')"
>
<span class="filter-row__title">{{ title }}</span>
<span
:id="summaryId"
class="filter-row__summary"
>{{ summary }}</span>
<span
v-if="missing"
:id="missingId"
class="filter-row__missing"
data-filter-missing
>
<AppIcon
name="alert-circle"
:size="13"
:stroke-width="2"
/>
<span>{{ MAIL_FILTER_TEXT.missingMailbox }}</span>
</span>
</button>
<div class="filter-row__order">
<button
ref="upRef"
type="button"
:class="['filter-row__arrow', { 'is-edge': isFirst }]"
:aria-label="MAIL_FILTER_TEXT.moveUp(quoted)"
:aria-disabled="busy || isFirst ? 'true' : undefined"
data-filter-up
@click="move(-1)"
>
<AppIcon
name="chevron-up"
:size="15"
:stroke-width="1.9"
/>
</button>
<button
ref="downRef"
type="button"
:class="['filter-row__arrow', { 'is-edge': isLast }]"
:aria-label="MAIL_FILTER_TEXT.moveDown(quoted)"
:aria-disabled="busy || isLast ? 'true' : undefined"
data-filter-down
@click="move(1)"
>
<AppIcon
name="chevron-down"
:size="15"
:stroke-width="1.9"
/>
</button>
</div>
</li>
</template>
<style scoped>
/* 행 60 — 칸 22(순서) · 40(스위치) · 나머지(이름 · 요약) · ▲▼ (디자인 원본 SettingsFilter) */
.filter-row {
display: grid;
grid-template-columns: 1.375rem 2.5rem minmax(0, 1fr) auto;
align-items: center;
gap: var(--space-10);
min-height: 3.75rem;
padding: var(--space-6) var(--space-8) var(--space-6) var(--space-14);
border-bottom: var(--border-width) solid var(--color-border-subtle);
background: var(--color-surface);
}
.filter-row:last-child {
border-bottom: 0;
}
.filter-row__no {
font-size: var(--font-size-small);
color: var(--color-icon-muted);
font-variant-numeric: tabular-nums;
text-align: right;
}
/* 누르는 곳은 칸 전체 40×44(밀도 예외 — tokens.css "6단계", 툴바 아이콘 단추와 같은 폭), 보이는 스위치는 36×20 */
.filter-row__switch {
display: inline-flex;
align-items: center;
justify-content: center;
width: var(--size-icon-button);
height: var(--size-touch);
padding: 0;
border: 0;
border-radius: var(--radius-sm);
background: transparent;
}
.filter-row__track {
position: relative;
display: block;
width: 2.25rem;
height: 1.25rem;
border-radius: 0.625rem;
background: var(--color-border-strong);
transition: background-color 0.15s;
}
.filter-row__knob {
position: absolute;
top: var(--space-2);
left: var(--space-2);
width: 1rem;
height: 1rem;
border-radius: var(--radius-round);
background: var(--color-surface);
box-shadow: var(--shadow-switch-knob);
transition: transform 0.15s;
}
.filter-row__switch[aria-checked='true'] .filter-row__track {
background: var(--color-brand);
}
.filter-row__switch[aria-checked='true'] .filter-row__knob {
transform: translateX(1rem);
}
.filter-row__main {
min-width: 0;
display: flex;
flex-direction: column;
gap: var(--space-2);
padding: var(--space-6) var(--space-8);
border: 0;
border-radius: var(--radius-sm);
background: transparent;
color: var(--color-text);
text-align: left;
}
.filter-row__main:hover {
background: var(--color-surface-panel);
}
.filter-row__title,
.filter-row__summary {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.filter-row__title {
font-weight: 600;
}
.filter-row__summary {
font-size: var(--font-size-label);
color: var(--color-text-secondary);
}
/* 꺼 둔 규칙 — 글자를 흐리게 */
.filter-row.is-off .filter-row__title {
color: var(--color-text-muted);
}
.filter-row.is-off .filter-row__summary {
color: var(--color-icon-muted);
}
.filter-row__missing {
display: flex;
align-items: center;
gap: var(--space-6);
font-size: var(--font-size-label);
color: var(--color-danger);
white-space: normal;
}
.filter-row__order {
display: flex;
align-items: center;
gap: var(--space-2);
}
/* ▲ ▼ 32×40 — 밀도 예외 (메일함 행의 ⋯ 와 같은 크기 — tokens.css "6단계") */
.filter-row__arrow {
display: inline-flex;
align-items: center;
justify-content: center;
width: var(--size-mailbox-more);
height: var(--size-row-min);
padding: 0;
border: 0;
border-radius: var(--radius-sm);
background: transparent;
color: var(--color-text-secondary);
}
.filter-row__arrow:hover:not([aria-disabled='true']) {
background: var(--color-surface-muted);
color: var(--color-text);
}
/* 맨 위의 ▲ · 맨 아래의 ▼ — 꺼진 모양 */
.filter-row__arrow.is-edge {
color: var(--color-border-strong);
cursor: default;
}
/* 저장을 기다리는 동안 — 모양은 그대로, 누를 수만 없다 */
.filter-row.is-busy .filter-row__switch,
.filter-row.is-busy .filter-row__arrow:not(.is-edge) {
cursor: progress;
}
</style>
@@ -1,6 +1,7 @@
<script setup lang="ts">
// 설정 화면의 틀 — 제목(18px) · 설명 + 내용 (디자인 원본 SettingsAccount · SettingsVacation)
// 콘텐츠 열은 읽기 · 쓰기 화면과 같은 값이다 — 최대 800 · 좌우 32 · 위 20 · 아래 36 (tokens.css --content-*), 묶음 사이 22.
// 머리 오른쪽에 단추를 둘 때는 actions 슬롯에 넣는다 — 좁으면 제목 아래로 내려간다 (디자인 원본 SettingsFilter 의 [새 필터])
// 한국어 안내 글은 낱말 가운데에서 줄을 바꾸지 않는다(keep-all) — 긴 주소 · 영문은 넘치지 않게 어디서든 끊는다(anywhere) (2026-10-28 화면 점검)
// 사용 예: <SettingsPageFrame title-id="password-page-title" :title="…" :description="…">…</SettingsPageFrame>
interface Props {
@@ -19,16 +20,24 @@ defineProps<Props>()
:aria-labelledby="titleId"
>
<div class="settings-page__column">
<div class="settings-page__intro">
<h1
:id="titleId"
class="settings-page__title"
<div :class="['settings-page__head', { 'settings-page__head--actions': $slots.actions }]">
<div class="settings-page__intro">
<h1
:id="titleId"
class="settings-page__title"
>
{{ title }}
</h1>
<p class="settings-page__description">
{{ description }}
</p>
</div>
<div
v-if="$slots.actions"
class="settings-page__actions"
>
{{ title }}
</h1>
<p class="settings-page__description">
{{ description }}
</p>
<slot name="actions" />
</div>
</div>
<slot />
</div>
@@ -53,6 +62,18 @@ defineProps<Props>()
padding: var(--content-block-start) var(--content-inline) var(--content-block-end);
}
/* 머리 오른쪽 단추 — 제목 · 설명이 360 보다 좁아지면 아래로 내려간다 (디자인 원본 SettingsFilter) */
.settings-page__head--actions {
display: flex;
flex-wrap: wrap;
align-items: flex-start;
gap: var(--space-12) var(--space-20);
}
.settings-page__head--actions .settings-page__intro {
flex: 1 1 22.5rem;
}
.settings-page__intro {
display: flex;
flex-direction: column;
@@ -1,5 +1,5 @@
<script setup lang="ts">
// 부재중 자동 응답의 제목(44 · 최대 폭 520 · 200자) · 내용(여러 줄 · 4,000자 · 글자만) (디자인 원본 SettingsVacation)
// 부재중 자동 응답의 제목(44 · 최대 폭 520 · 150자) · 내용(여러 줄 · 600자 · 글자만) (디자인 원본 SettingsVacation — 한도는 계약 2절)
// 끈 동안은 흐리게(쓴 글은 남긴다). 문제가 있으면 그 칸 아래에 사전 문구 — 검사는 부모(VacationForm)가 한다.
// 고정 id — #vacation-subject · #vacation-subject-error · #vacation-body · #vacation-body-hint · #vacation-body-error
import { ref } from 'vue'
@@ -0,0 +1,4 @@
// 메일 필터 화면의 목록(MailFilterList · MailFilterRow)과 화면(MailFilterPage)이 주고받는 값
/** 행 안에서 초점을 줄 곳 — 고치기(행) · ▲ · ▼ (순서를 바꾸거나 지운 뒤 화면이 정한다) */
export type MailFilterRowPart = 'edit' | 'up' | 'down'
+5
View File
@@ -69,6 +69,11 @@ describe('에러 코드 사전', () => {
// 웹메일 4단계
'AUTH_005', // 지금 비밀번호가 틀림 — 비밀번호 화면이 지금 비밀번호 칸 아래에 보인다
'MAIL_009', // 같은 자리에 같은 이름의 메일함 — 메일함 이름 대화상자가 칸 아래에 보인다
// 6단계 앞 수정
'MAIL_010', // 엔진이 받는 사람을 모두 거절(없는 주소 등) — 쓰기 화면이 받는 사람 칸 아래 띠에 보인다
// 웹메일 6단계
'MAIL_012', // 그사이 다른 곳에서 필터가 바뀜 — 필터 화면이 알림 없이 목록 위 줄 · 고치기 창 안에 사전 문구를 직접 꺼내 보이고 [다시 불러오기]를 둔다
'MAIL_013', // 필터가 엔진 한도를 넘음 — 필터 고치기 창이 알림 없이 창 안에 보인다
]
for (const code of usedByClient) {
@@ -3,6 +3,7 @@ import { useRouter } from 'vue-router'
import { saveDraftDetached } from './useDraftSaving'
import type { DraftSaved, SendMessagePayload } from '@/shared/types/mail'
import { useAuthStore } from '@/stores/auth.store'
import { useMailStore } from '@/stores/mail.store'
// 쓰기 화면과 서버 저장본 id 를 잇는다 (계약 2절 "임시 저장" — 저장할 때마다 id 가 바뀐다 · 디자인 README "2단계 › 임시 저장")
@@ -13,7 +14,7 @@ import { useMailStore } from '@/stores/mail.store'
// 보내기 · 버리기 등)와, 라우터 가드를 지나 화면이 바뀌는 중인 다른 이동(여기서 센다).
// - 옛 id → 새 id(recordSaved): 저장할 때마다 탭 메모리(mailStore)에 적는다 — 옛 주소(?draft=<옛 id>, 떠나기 전 저장처럼
// 주소를 맞추지 못한 채 떠난 경우)로 열면 마지막 저장본을 연다(openDraft).
// - 보내는 중에 떠났고 보내지 못했으면 보내려던 내용을 조용히 저장본으로 남긴다(keepUnsent).
// - 보내는 중에 떠났고 보내지 못했으면 보내려던 내용을 조용히 저장본으로 남긴다(keepUnsent) — 보내기를 시작한 세션 그대로일 때만.
// 쓰기 화면의 setup 에서 부른다 — 라우터 훅을 걸고, 화면을 내리면 뗀다.
export interface ComposeDraftLinkOptions {
@@ -28,6 +29,7 @@ function draftKeyFor(id: string): string {
export function useComposeDraftLink(options: ComposeDraftLinkOptions) {
const router = useRouter()
const authStore = useAuthStore()
const mailStore = useMailStore()
/**
@@ -99,12 +101,18 @@ export function useComposeDraftLink(options: ComposeDraftLinkOptions) {
/**
* 보내지 못한 메일을 저장본으로 남긴다 — 보내는 중에 화면을 떠났고 발송이 실패했을 때(양식이 이미 없다).
* 발송 본문의 draftId 가 있으면 그 저장본을 PUT(바뀌었거나 없으면 POST), 없으면 POST — 자동 저장과 같은 규칙. 알리지 않는다
* 발송 본문의 draftId 가 있으면 그 저장본을 PUT(바뀌었거나 없으면 POST), 없으면 POST — 자동 저장과 같은 규칙. 알리지 않는다.
* session 은 보내기를 시작한 세션 세대(authStore.generation)다 — 그 뒤 세션이 끝났거나 바뀌었으면(로그아웃 · 만료 · 다른 계정 로그인)
* 남기지 않는다. 요청은 그때의 쿠키로 나가, 다른 계정의 임시보관함에 앞 사용자의 메일이 들어가기 때문이다
* (PUT 을 기다리는 사이 바뀔 수도 있어 PUT 뒤에 새로 남기기 전에도 본다)
*/
async function keepUnsent(payload: SendMessagePayload) {
async function keepUnsent(payload: SendMessagePayload, session: number) {
const sameSession = () => authStore.generation === session
if (!sameSession()) return
const { draftId = null, ...draft } = payload
const saved = await saveDraftDetached(draft, draftId)
if (!saved) return
const saved = await saveDraftDetached(draft, draftId, sameSession)
// 저장하는 사이 세션이 바뀌었으면 이 탭의 기록 · 개수도 건드리지 않는다 (새 세션의 것이다)
if (!saved || !sameSession()) return
recordSaved(draftId, saved)
mailStore.syncCounts()
}
+12 -5
View File
@@ -84,19 +84,21 @@ type DraftApi = Pick<ReturnType<typeof useMail>, 'createDraft' | 'updateDraft'>
/**
* 저장 요청 한 번 — id 가 있으면 PUT, 그 저장본이 다른 곳에서 바뀌었거나 없으면(MAIL_008 · RES_001) 지금 내용을 POST 로 새로 남긴다.
* silent 는 첫 요청(PUT · POST)과 새로 남기는 POST 각각의 알림 끄기다
* silent 는 첫 요청(PUT · POST)과 새로 남기는 POST 각각의 알림 끄기다.
* canFallback 이 false 면 새로 남기지 않고 PUT 의 실패를 그대로 던진다 — PUT 을 기다리는 사이 세션이 바뀌었을 때 (saveDraftDetached)
*/
async function putOrCreate(
mail: DraftApi,
payload: DraftPayload,
id: string | null,
silent: { first: MailRequestOptions['silent']; fallback: MailRequestOptions['silent'] },
canFallback: () => boolean = () => true,
): Promise<{ saved: DraftSaved; replaced: boolean }> {
if (id === null) return { saved: await mail.createDraft(payload, { silent: silent.first }), replaced: false }
try {
return { saved: await mail.updateDraft(id, payload, { silent: silent.first }), replaced: false }
} catch (error) {
if (!REPLACED_CODES.includes(toApiError(error).code)) throw error
if (!REPLACED_CODES.includes(toApiError(error).code) || !canFallback()) throw error
// 옛 저장본이 다른 곳에서 바뀌었거나 지워졌다(또는 임시보관함 밖으로 옮겨졌다) — 지금 내용을 새 저장본으로 남긴다
return { saved: await mail.createDraft(payload, { silent: silent.fallback }), replaced: true }
}
@@ -104,11 +106,16 @@ async function putOrCreate(
/**
* 양식 없이 한 번 조용히 저장한다 — 보내는 중에 화면을 떠났고 발송이 실패했을 때 보내려던 내용을 남긴다.
* 저장 규칙은 자동 저장과 같다 (putOrCreate). 알리지 않는다 — 실패하면 null
* 저장 규칙은 자동 저장과 같다 (putOrCreate). 알리지 않는다 — 실패하면 null.
* stillValid 가 false 가 되면(세션이 바뀜) 옛 저장본이 없어도 새로 남기지 않는다 — 두 번째 요청은 다른 계정의 쿠키로 나간다
*/
export async function saveDraftDetached(payload: DraftPayload, draftId: string | null): Promise<DraftSaved | null> {
export async function saveDraftDetached(
payload: DraftPayload,
draftId: string | null,
stillValid: () => boolean = () => true,
): Promise<DraftSaved | null> {
try {
return (await putOrCreate(useMail(), payload, draftId, { first: true, fallback: true })).saved
return (await putOrCreate(useMail(), payload, draftId, { first: true, fallback: true }, stillValid)).saved
} catch {
return null
}
+81
View File
@@ -0,0 +1,81 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { createPinia, setActivePinia } from 'pinia'
import type { MailFilterSet, RejectedRecipient, SendMessagePayload } from '@/shared/types/mail'
import { installMockApi, ok, restoreApiAdapter } from '@/test-utils/mock-api'
import { useMail } from './useMail'
const PAYLOAD: SendMessagePayload = {
to: [{ name: null, email: 'doyun.lee@arasystem.it.kr' }],
cc: [],
bcc: [],
subject: '일정 공유',
body: '확인 부탁드립니다.',
attachments: [],
}
const NOBODY: RejectedRecipient = { email: 'nobody@arasystem.it.kr', reason: 'not-found' }
describe('useMail.sendMessage — 받지 못한 사람은 늘 배열로 (계약 2절 "받는 사람별 결과")', () => {
beforeEach(() => {
setActivePinia(createPinia())
})
afterEach(() => {
restoreApiAdapter()
})
it.each([
['받지 못한 사람이 있으면 그대로', { id: 'sent-1', rejectedRecipients: [NOBODY] }, [NOBODY]],
['빈 배열은 그대로', { id: 'sent-1', rejectedRecipients: [] }, []],
['필드가 없는 옛 서버 응답은 빈 배열', { id: 'sent-1' }, []],
['배열이 아니면(응답을 바꾼 게이트웨이 등) 빈 배열', { id: 'sent-1', rejectedRecipients: { email: NOBODY.email } }, []],
['null 도 빈 배열', { id: 'sent-1', rejectedRecipients: null }, []],
])('%s', async (_label, reply, rejectedRecipients) => {
const requests = installMockApi(() => ok(reply, 201))
await expect(useMail().sendMessage(PAYLOAD, { idempotencyKey: 'key-1' })).resolves.toEqual({
id: 'sent-1',
rejectedRecipients,
})
expect(requests[0]?.config.headers.get('Idempotency-Key')).toBe('key-1')
})
})
describe('useMail 메일 필터 — GET · PUT /mail-filters (계약 2절 "메일 필터")', () => {
const SET: MailFilterSet = {
rules: [
{
id: '0f3c2a1e-7b8d-4c5e-9f10-2a3b4c5d6e7f',
name: '거래처 메일',
isEnabled: true,
match: 'all',
conditions: [{ field: 'from', operator: 'contains', value: '@hanbit-partners.co.kr' }],
actions: { moveToMailboxId: 'vendor', markAsRead: false, flag: false, forwardTo: null },
},
],
version: 'v1',
isActive: true,
}
beforeEach(() => {
setActivePinia(createPinia())
})
afterEach(() => {
restoreApiAdapter()
})
it('읽기는 GET, 저장은 받은 version 과 목록 전체를 PUT 본문 { rules, version } 으로', async () => {
const requests = installMockApi((request) => ok(request.method === 'GET' ? SET : { ...SET, version: 'v2' }))
const mail = useMail()
await expect(mail.getFilters({ silent: true })).resolves.toEqual(SET)
await expect(mail.saveFilters({ rules: SET.rules, version: SET.version })).resolves.toMatchObject({ version: 'v2' })
expect(requests.map((request) => `${request.method} ${request.url}`)).toEqual([
'GET /mail-filters',
'PUT /mail-filters',
])
expect(requests[1]?.data).toEqual({ rules: SET.rules, version: 'v1' })
})
})
+30 -4
View File
@@ -12,6 +12,8 @@ import type {
Draft,
DraftPayload,
DraftSaved,
MailFilterSet,
MailFilterSetPayload,
MailQuota,
MailSignature,
MailState,
@@ -32,7 +34,7 @@ import type {
RecipientSuggestionQuery,
SaveSignaturePayload,
SendMessagePayload,
SendMessageResult,
SentMessage,
SignatureImage,
UpdateMessagePayload,
} from '@/shared/types/mail'
@@ -49,6 +51,9 @@ export interface SendMessageOptions extends MailRequestOptions {
idempotencyKey: string
}
/** POST /messages 가 실제로 주는 모양 — 옛 서버는 rejectedRecipients 가 없다 (sendMessage 가 배열로 맞춘다) */
type SentMessageReply = Omit<SentMessage, 'rejectedRecipients'> & { rejectedRecipients?: unknown }
export interface UploadOptions {
/** 0~100 */
onProgress?: (percent: number) => void
@@ -142,6 +147,21 @@ export function useMail() {
return api.put<MailVacation, MailVacation>('/mail-vacation', payload, options)
}
// ── 메일 필터 (계약 2절 "메일 필터" — 6단계) ──────────────────────
/** 규칙 · 지문(version) · 동작 여부 — 저장해 둔 원본이 없거나 깨진 계정은 빈 규칙 + isActive: false 로 온다 */
function getFilters(options?: MailRequestOptions): Promise<MailFilterSet> {
return api.get<MailFilterSet, MailFilterSet>('/mail-filters', options)
}
/**
* 전체 교체 — 받은 version 을 그대로 돌려보낸다. 그사이 다른 곳(다른 탭 · PC)에서 규칙이 바뀌었으면 저장하지 않고 409 MAIL_012,
* 만든 엔진 규칙이 한도를 넘으면 400 MAIL_013. 저장하면 꺼져 있던 규칙(isActive: false)도 다시 켜진다
*/
function saveFilters(payload: MailFilterSetPayload, options?: MailRequestOptions): Promise<MailFilterSet> {
return api.put<MailFilterSet, MailFilterSet>('/mail-filters', payload, options)
}
/**
* 새 메일 확인 — 메일함이나 메일이 하나라도 바뀌면 state 가 달라진다 (계약 2절 "새 메일 확인").
* 메일을 읽지 않는 가벼운 조회라 화면이 보이는 동안 1분마다 부른다 (useMailStatePolling)
@@ -256,17 +276,21 @@ export function useMail() {
/**
* 메일 발송. 같은 idempotencyKey 로 다시 보내면 서버는 두 번 내보내지 않고 처음 결과를 돌려준다.
* (401 뒤 갱신 재시도도 같은 헤더로 다시 간다 — 인터셉터가 원래 설정을 그대로 쓴다)
* 201 의 rejectedRecipients 는 엔진이 받지 않은 사람이다 — 있어도 보낸 것이다. 모두 받지 않았으면 보내지 않은 것으로
* 400 MAIL_010(없는 주소 · 거절) · 503 MAIL_011(지금은 받을 수 없음)이다 (계약 2절 "받는 사람별 결과").
* rejectedRecipients 는 여기서 늘 배열로 맞춘다 — 이 필드가 없는 옛 서버 · 응답을 바꾼 게이트웨이면 빈 배열(모두 받은 것)
*/
function sendMessage(
async function sendMessage(
payload: SendMessagePayload,
options: SendMessageOptions,
): Promise<SendMessageResult> {
): Promise<SentMessage> {
const { idempotencyKey, ...requestOptions } = options
return api.post<SendMessageResult, SendMessageResult>('/messages', payload, {
const sent = await api.post<SentMessageReply, SentMessageReply>('/messages', payload, {
timeout: MAIL_SEND_TIMEOUT_MS,
...requestOptions,
headers: { [MAIL_IDEMPOTENCY_HEADER]: idempotencyKey },
})
return { ...sent, rejectedRecipients: Array.isArray(sent.rejectedRecipients) ? sent.rejectedRecipients : [] }
}
/**
@@ -332,6 +356,8 @@ export function useMail() {
emptyMailbox,
getVacation,
saveVacation,
getFilters,
saveFilters,
messageSourceUrl,
downloadMessageSource,
getMailState,
@@ -0,0 +1,163 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { createPinia, setActivePinia } from 'pinia'
import { ERROR_CODE_MESSAGES } from '@/shared/constants/error-codes.generated'
import type { MailFilterRule, MailFilterSet, MailFilterSetPayload } from '@/shared/types/mail'
import { useAuthStore } from '@/stores/auth.store'
import { useMailFilterStore } from '@/stores/mail-filter.store'
import { useToastStore, type Toast } from '@/stores/toast.store'
import {
fail,
flushPromises,
installMockApi,
ok,
restoreApiAdapter,
type MockRequest,
type MockResult,
} from '@/test-utils/mock-api'
import { useMailFilterActions } from './useMailFilterActions'
// 메일 필터의 지우기 · 되돌리기 알림 (디자인 원본 MailFilterStates "알림 문구" · 계약 2절 "메일 필터")
const USER = { email: 'ara.kim@arasystem.it.kr', name: '김아라' }
function rule(id: string, patch: Partial<MailFilterRule> = {}): MailFilterRule {
return {
id,
name: `필터 ${id}`,
isEnabled: true,
match: 'all',
conditions: [{ field: 'subject', operator: 'contains', value: id }],
actions: { moveToMailboxId: null, markAsRead: true, flag: false, forwardTo: null },
...patch,
}
}
function deferred() {
let finish: (result: MockResult) => void = () => undefined
const promise = new Promise<MockResult>((resolve) => {
finish = resolve
})
return { promise, finish }
}
/** 서버 흉내 — 받은 목록을 그대로 저장하고 version 을 올린다 */
function echo(request: MockRequest): MockResult {
const body = request.data as MailFilterSetPayload
return ok({ rules: body.rules, version: `${body.version}+`, isActive: true })
}
describe('useMailFilterActions', () => {
let requests: MockRequest[]
beforeEach(() => {
setActivePinia(createPinia())
useAuthStore().startSession(USER)
})
afterEach(() => {
useToastStore().clear()
restoreApiAdapter()
})
async function setup(set: MailFilterSet, save: (request: MockRequest) => MockResult | Promise<MockResult> = echo) {
requests = installMockApi((request) => (request.method === 'GET' ? ok(set) : save(request)))
const store = useMailFilterStore()
await store.load()
return { store, actions: useMailFilterActions() }
}
const puts = () => requests.filter((request) => request.method === 'PUT').map((request) => request.data as MailFilterSetPayload)
const messages = () => useToastStore().toasts.map((toast) => toast.message)
function undo(toast: Toast | undefined) {
if (toast?.action && 'run' in toast.action) toast.action.run()
}
it('저장 하나를 기다리는 되돌리기 둘 — 둘 다 보내고 둘 다 성공 알림', async () => {
const toggle = deferred()
// 세 번째 PUT(켜기)만 손으로 끝낸다 — 앞의 둘은 지우기
const { store, actions } = await setup({ rules: [rule('a'), rule('b'), rule('c'), rule('d')], version: 'v1', isActive: true }, (request) =>
puts().length === 3 ? toggle.promise : echo(request),
)
await actions.remove('b')
await actions.remove('d')
const [removedB, removedD] = useToastStore().toasts
expect(messages()).toEqual(['‘필터 b’ 필터를 지웠습니다.', '‘필터 d’ 필터를 지웠습니다.'])
// 켜기가 저장되는 동안 두 알림의 [되돌리기]를 누른다
const switching = store.setRuleEnabled('a', false)
undo(removedB)
undo(removedD)
await flushPromises()
expect(puts()).toHaveLength(3)
toggle.finish(echo(requests[requests.length - 1] as MockRequest))
await switching
await flushPromises()
expect(puts().slice(3).map((payload) => payload.rules.map((item) => item.id))).toEqual([
['a', 'b', 'c'],
['a', 'b', 'c', 'd'],
])
expect(store.filterSet?.rules.map((item) => item.id)).toEqual(['a', 'b', 'c', 'd'])
expect(messages().filter((message) => message === '필터를 되돌렸습니다.')).toHaveLength(2)
})
it('필터 화면이 떠 있지 않을 때 되돌리기가 그사이 바뀜(409)으로 실패하면 알림으로 — 화면이 떠 있으면 목록 위 줄만', async () => {
let conflictNow = false
const { store, actions } = await setup({ rules: [rule('a'), rule('b')], version: 'v1', isActive: true }, (request) =>
conflictNow ? fail(409, 'MAIL_012') : echo(request),
)
await actions.remove('a')
await actions.remove('b')
const [removedA, removedB] = useToastStore().toasts
useToastStore().clear()
// 그사이 다른 곳에서 바꿨다 — 되돌리기는 409
conflictNow = true
// 화면이 떠 있다 — 목록 위 줄이 알린다
const detach = store.attachPage()
undo(removedA)
await flushPromises()
expect(store.conflict).toBe(true)
expect(messages()).toEqual([])
detach()
// 화면 밖 — 볼 곳이 없으니 알림으로
undo(removedB)
await flushPromises()
expect(messages()).toEqual([ERROR_CODE_MESSAGES.MAIL_012])
})
it('되돌리면 50개를 넘으면 보내지 않고 오류 알림 — "필터는 50개까지 만들 수 있습니다. …"', async () => {
const rules = Array.from({ length: 50 }, (_, index) => rule(`r${index}`))
const { store, actions } = await setup({ rules, version: 'v1', isActive: true })
await actions.remove('r0')
const [removed] = useToastStore().toasts
// 그사이 다른 곳에서 하나를 더 만들어 다시 50개가 됐다
await store.saveRule(rule('new'))
useToastStore().clear()
undo(removed)
await flushPromises()
expect(puts()).toHaveLength(2)
expect(useToastStore().toasts.map((toast) => [toast.kind, toast.message])).toEqual([
['error', '필터는 50개까지 만들 수 있습니다. 쓰지 않는 필터를 지워 주세요.'],
])
})
it('지운 알림의 이름 — 이름 없는 필터는 조건 글이 50자에서 잘리고 큰따옴표로 묶인다 (작은따옴표가 겹치지 않게)', async () => {
const long = rule('a', {
name: '',
conditions: [{ field: 'subject', operator: 'contains', value: '견적'.repeat(100) }],
})
const { actions } = await setup({ rules: [long], version: 'v1', isActive: true })
await actions.remove('a')
expect(messages()).toEqual([`“제목에 ‘${'견적'.repeat(22)}견…” 필터를 지웠습니다.`])
})
})
@@ -0,0 +1,59 @@
import { notifyApiError } from './useApi'
import { useToast } from './useToast'
import { MAIL_FILTER_TEXT } from '@/shared/constants/mail-filter'
import type { MailFilterRule } from '@/shared/types/mail'
import { quotedRuleTitle, type MailFilterRestorePoint } from '@/shared/utils/mail-filter'
import {
useMailFilterStore,
type MailFilterRemoveOutcome,
type MailFilterSaveOutcome,
} from '@/stores/mail-filter.store'
// 메일 필터 설정 화면의 저장 · 지우기 알림 (디자인 README "6단계 › 상태 · 들어오는 길" · 디자인 원본 MailFilterStates "알림 문구")
// - 고치기 창의 [저장]이 끝나면 "필터를 저장했습니다."
// - 지우기는 확인 없이 하고 "‘거래처 메일’ 필터를 지웠습니다." + [되돌리기](10초 — 단추가 있는 알림, 띄운 세션에서만 움직인다 — useToast).
// 되돌리기는 지운 규칙을 지운 자리(앞 · 뒤 규칙 기준)에 다시 저장한다 → "필터를 되돌렸습니다." 알림 여럿의 [되돌리기]는 누른 차례대로 보낸다(스토어).
// 실패는 인터셉터가 알린다. 그사이 바뀜(409)만은 알림을 끄고 목록 위 줄이 알리는데, 필터 화면이 떠 있지 않으면 볼 곳이 없으니
// 알림으로 알린다(고치기 창의 notifyUnseen 과 같은 규칙). 다시 넣으면 50개를 넘으면 보내지 않고 오류 알림(fullNote)
// - 켜기 · 끄기 · 순서 바꾸기는 알리지 않는다 — 목록이 바로 바뀐다(실패하면 스토어가 되돌리고 인터셉터가 알린다)
export function useMailFilterActions() {
const filterStore = useMailFilterStore()
const toast = useToast()
/** 고치기 창의 [저장] — 칸 안에 알릴 거절(VAL_001 · MAIL_013 · MAIL_012 · 50개)은 부르는 쪽이 그린다 */
async function save(rule: MailFilterRule): Promise<MailFilterSaveOutcome> {
const outcome = await filterStore.saveRule(rule)
if (outcome.ok) toast.success(MAIL_FILTER_TEXT.saved)
return outcome
}
async function restore(point: MailFilterRestorePoint): Promise<void> {
const outcome = await filterStore.restoreRule(point)
if (outcome.ok) {
toast.success(MAIL_FILTER_TEXT.restored)
return
}
if (outcome.full) {
toast.error(MAIL_FILTER_TEXT.fullNote)
return
}
// 되돌리기는 그사이 바뀜(MAIL_012)만 알림을 끈다 — 필터 화면이 떠 있으면 목록 위 줄이 알린다
if (outcome.error?.code === 'MAIL_012' && !filterStore.pageVisible) notifyApiError(outcome.error)
}
/** [이 필터 지우기] — 지웠으면 [되돌리기] 알림 */
async function remove(id: string): Promise<MailFilterRemoveOutcome> {
const outcome = await filterStore.removeRule(id)
if (outcome.ok) {
const { point } = outcome
toast.success(MAIL_FILTER_TEXT.removed(quotedRuleTitle(point.rule)), {
label: MAIL_FILTER_TEXT.undo,
run: () => void restore(point),
})
}
return outcome
}
return { save, remove }
}
@@ -0,0 +1,161 @@
import { onBeforeUnmount, ref } from 'vue'
import { notifyApiError, type ApiError } from './useApi'
import { useMailFilterActions } from './useMailFilterActions'
import type { ErrorCode } from '@/shared/constants/error-codes.generated'
import { MAIL_FILTER_TEXT } from '@/shared/constants/mail-filter'
import type { MailFilterRule } from '@/shared/types/mail'
import { useMailFilterStore } from '@/stores/mail-filter.store'
// 설정 › 필터의 고치기 창 상태 — 연 창 · 보내는 중 · 창 안의 거절 (MailFilterPage 가 setup 에서 부른다)
// - 저장 · 지우기 · 다시 불러오기는 한 번에 하나. 결과가 늦게 와도 그사이 닫고 다시 연 창에는 쓰지 않는다(연 차례 token)
// - 창 안에 보이는 거절은 형식(VAL_001) · 엔진 한도(MAIL_013) · 50개 넘음(fullNote) · 그사이 바뀜(MAIL_012 — 목록 위 줄과 함께)이다.
// 그 밖은 인터셉터가 알렸다. 알림을 끈 그 거절이 창(또는 화면)이 사라진 뒤에 오면 알림으로 알린다 — 그사이 바뀜은 화면이 남아 있으면 목록 위 줄이 알린다
// - 그사이 바뀜이면 창 안에 [다시 불러오기]를 둔다(conflict — 창이 모달이라 목록 위 줄을 누를 수 없다). 다시 받으면 창 · 쓴 내용은 그대로 두고 안내를 내린다 —
// 다음 [저장]은 다시 받은 목록 · version 위에 넣는다(스토어의 saveRule). 칸을 고쳐도 이 안내는 남는다(다시 받아야 풀린다)
// - 끝나면 초점을 줄 규칙을 돌려준다 — 저장은 그 규칙, 지우기는 이웃 행(다음, 마지막이었으면 앞). 초점은 화면이 옮긴다
export type MailFilterDialogPending = 'save' | 'remove' | 'reload' | null
export interface MailFilterDialogState {
/** 고칠 규칙 — null 이면 새 필터 */
rule: MailFilterRule | null
/** 새 필터를 채울 보낸 사람 주소 (읽기 화면 ⋯ 「이런 메일로 필터 만들기」) */
prefillFrom: string | null
/** 연 차례 */
token: number
}
/** closed — 끝나서 창을 닫았다(그 뒤의 초점은 focusRuleId 의 행, 없으면 화면이 정한다). false 면 창이 그대로다 */
export interface MailFilterDialogResult {
closed: boolean
focusRuleId: string | null
}
/** 창 안에 사전 문구로 보이는 저장 거절 */
const DIALOG_ERROR_CODES: readonly ErrorCode[] = ['VAL_001', 'MAIL_013', 'MAIL_012']
const KEPT: MailFilterDialogResult = { closed: false, focusRuleId: null }
export function useMailFilterDialog() {
const filterStore = useMailFilterStore()
const filterActions = useMailFilterActions()
const state = ref<MailFilterDialogState | null>(null)
const pending = ref<MailFilterDialogPending>(null)
const error = ref<string | null>(null)
/** 창 안의 거절이 그사이 바뀜(MAIL_012)이다 — 창 안에 [다시 불러오기]를 둔다 */
const conflict = ref<boolean>(false)
let token = 0
/** 화면이 사라졌다 — 창 안 · 목록 위 줄의 글을 볼 곳이 없다 */
let disposed = false
function showError(message: string | null, isConflict = false) {
error.value = message
conflict.value = message !== null && isConflict
}
function open(rule: MailFilterRule | null, prefillFrom: string | null = null) {
token += 1
state.value = { rule, prefillFrom, token }
pending.value = null
showError(null)
}
function close() {
state.value = null
pending.value = null
showError(null)
}
/** 칸을 고쳤다 — 창 안의 거절을 내린다. 그사이 바뀜은 다시 불러와야 풀리므로 그대로 둔다 */
function clearError() {
if (!conflict.value) showError(null)
}
/** 그 창이 아직 열려 있고 화면도 그대로다 — 결과를 창 안에 보일 수 있다 */
function isCurrent(opened: number): boolean {
return !disposed && state.value?.token === opened
}
function dialogMessageOf(apiError: ApiError | null): string | null {
return apiError && DIALOG_ERROR_CODES.includes(apiError.code) ? apiError.message : null
}
/** 알림을 끈 거절이 보일 곳 없이 왔다 — silenced: 그 요청이 알림을 끈 코드 */
function notifyUnseen(apiError: ApiError | null, silenced: readonly ErrorCode[]) {
if (!apiError || !silenced.includes(apiError.code)) return
if (disposed || apiError.code !== 'MAIL_012') notifyApiError(apiError)
}
/** [저장] — 지금 목록 위에 넣는다(있는 규칙은 그 자리, 새 규칙 · 다른 곳에서 지워진 규칙은 맨 아래 — 스토어의 saveRule) */
async function save(rule: MailFilterRule): Promise<MailFilterDialogResult> {
const current = state.value
if (!current || pending.value || filterStore.saving) return KEPT
pending.value = 'save'
showError(null)
const outcome = await filterActions.save(rule)
if (!isCurrent(current.token)) {
if (!outcome.ok) notifyUnseen(outcome.error, DIALOG_ERROR_CODES)
return KEPT
}
pending.value = null
if (outcome.ok) {
close()
return { closed: true, focusRuleId: rule.id }
}
if (outcome.full) showError(MAIL_FILTER_TEXT.fullNote)
else showError(dialogMessageOf(outcome.error), outcome.error?.code === 'MAIL_012')
return KEPT
}
/** [이 필터 지우기] — 확인 없이 (알림의 [되돌리기]는 useMailFilterActions) */
async function remove(): Promise<MailFilterDialogResult> {
const current = state.value
const target = current?.rule
if (!current || !target || pending.value || filterStore.saving) return KEPT
const rules = filterStore.filterSet?.rules ?? []
const index = rules.findIndex((rule) => rule.id === target.id)
// 다시 불러온 목록에 없다 — 다른 곳에서 이미 지웠다. 지울 것이 없으니 닫는다
if (index < 0) {
close()
return { closed: true, focusRuleId: null }
}
const neighbor = rules[index + 1]?.id ?? rules[index - 1]?.id ?? null
pending.value = 'remove'
showError(null)
const outcome = await filterActions.remove(target.id)
if (!isCurrent(current.token)) {
// 지우기는 그사이 바뀜만 알림을 끈다
if (!outcome.ok) notifyUnseen(outcome.error, ['MAIL_012'])
return KEPT
}
pending.value = null
if (outcome.ok) {
close()
return { closed: true, focusRuleId: neighbor }
}
if (outcome.error?.code === 'MAIL_012') showError(outcome.error.message, true)
return KEPT
}
/**
* 창 안의 [다시 불러오기] — 목록을 다시 받고 창 · 쓴 내용은 그대로 둔다. 받으면 창 안 안내와 목록 위 줄을 내리고 true.
* 못 받으면 인터셉터가 알리고 안내는 그대로 둔다
*/
async function reload(): Promise<boolean> {
const current = state.value
if (!current || pending.value) return false
pending.value = 'reload'
const done = await filterStore.reload()
if (!isCurrent(current.token)) return false
pending.value = null
if (done) showError(null)
return done
}
onBeforeUnmount(() => {
disposed = true
})
return { state, pending, error, conflict, open, close, clearError, save, remove, reload }
}
+2 -1
View File
@@ -40,7 +40,8 @@ export function useToast() {
}
/**
* 화면이 직접 띄우는 오류 알림 — API 오류가 아니라 브라우저 상태를 알릴 때만 쓴다 (예: 바탕화면 알림을 막아 둠 + [설정 열기]).
* 화면이 직접 띄우는 오류 알림 — API 오류가 아닌 실패를 알릴 때만 쓴다: 브라우저 상태(예: 바탕화면 알림을 막아 둠 + [설정 열기]),
* 성공 응답에 담긴 일부 실패(예: 보냈지만 받는 사람 일부가 받지 못함 + [보낸편지함]).
* API 오류는 인터셉터가 사전 문구로 이미 띄운다 — 호출부에서 다시 띄우지 않는다
*/
function error(message: string, action?: ToastAction): number {
@@ -859,6 +859,126 @@ describe('ComposePage — 임시 저장 · 서명 (2단계)', () => {
})
})
describe('보내는 중에 세션이 바뀌면 — 보내지도 · 알리지도 · 저장본으로 남기지도 않는다 (요청은 지금 쿠키로 나간다)', () => {
const RouterHost: FunctionalComponent = () => h('div')
const OTHER = { email: 'doyun.lee@arasystem.it.kr', name: '이도윤' }
/** 붙잡아 둔 응답 — 테스트가 풀어 준다 */
function holder() {
const held: { release: ((result: MockResult) => void) | null } = { release: null }
const hold = () =>
new Promise<MockResult>((resolve) => {
held.release = resolve
})
return { held, hold }
}
/** 이 탭의 로그아웃 → 로그인 화면 → 다른 계정으로 로그인 (쓰기 화면은 내려간다) */
async function switchAccount(wrapper: VueWrapper, router: ReturnType<typeof createTestRouter>) {
useAuthStore().clearAuth()
await router.push('/login')
mountedWrappers.push(mount(RouterHost, { global: { plugins: [pinia, router] } }))
wrapper.unmount()
useAuthStore().startSession(OTHER)
}
async function writeAndAutosave(wrapper: VueWrapper) {
const input = wrapper.get('#compose-to')
await input.setValue('junho.park@arasystem.it.kr')
await input.trigger('blur')
await wrapper.get('#compose-subject').setValue('일정 공유')
await wait(3_000)
}
const sendCalls = (requests: MockRequest[]) =>
requests.filter((request) => request.method === 'POST' && request.url === '/messages')
it('임시 저장을 기다리는 사이 바뀌면 보내지 않는다 — 앞 사용자의 메일이 다른 계정에서 나가지 않게', async () => {
const save = holder()
const { wrapper, router, requests } = await mountCompose(
'/mail/compose',
server({
extra: (request) => (request.method === 'POST' && request.url === '/drafts' ? save.hold() : undefined),
}),
)
await writeAndAutosave(wrapper)
// 저장이 나가 있는 동안 보내기를 누른다 — 보내기는 그 저장을 기다린다
await wrapper.get('form').trigger('submit')
await flushPromises()
await switchAccount(wrapper, router)
save.held.release?.(ok({ id: 'd1', attachments: [], updatedAt: SAVED_AT }, 201))
await flushPromises()
await flushPromises()
expect(sendCalls(requests)).toEqual([])
expect(useToastStore().toasts).toEqual([])
})
it('보내기가 실패로 끝나도(MAIL_002) 알리지 않고 저장본으로 남기지 않는다 — 다른 계정의 임시보관함에 들어가지 않게', async () => {
const send = holder()
const { wrapper, router, requests } = await mountCompose(
'/mail/compose',
server({
extra: (request) => (request.method === 'POST' && request.url === '/messages' ? send.hold() : undefined),
}),
)
await writeAndAutosave(wrapper)
await wrapper.get('#compose-subject').setValue('일정 공유 (최종)')
await wrapper.get('form').trigger('submit')
await flushPromises()
await switchAccount(wrapper, router)
send.held.release?.(fail(400, 'MAIL_002'))
await flushPromises()
await flushPromises()
expect(draftCalls(requests).map((request) => `${request.method} ${request.url}`)).toEqual(['POST /drafts'])
expect(useToastStore().toasts).toEqual([])
})
it('저장본으로 남기는 PUT 을 기다리는 사이 바뀌면 — 옛 저장본이 없어도(RES_001) 새로 남기지 않는다', async () => {
const send = holder()
const keep = holder()
const { wrapper, router, requests } = await mountCompose(
'/mail/compose',
server({
extra: (request) => {
if (request.method === 'POST' && request.url === '/messages') return send.hold()
if (request.method === 'PUT' && request.url === '/drafts/d1') return keep.hold()
return undefined
},
}),
)
await writeAndAutosave(wrapper)
await wrapper.get('#compose-subject').setValue('일정 공유 (최종)')
await wrapper.get('form').trigger('submit')
await flushPromises()
// 같은 세션에서 다른 화면으로 — 보내기가 실패하면 보내려던 내용을 남긴다 (PUT /drafts/d1)
await router.push('/mail/sent')
mountedWrappers.push(mount(RouterHost, { global: { plugins: [pinia, router] } }))
wrapper.unmount()
send.held.release?.(fail(503, 'MAIL_001'))
await flushPromises()
expect(draftCalls(requests).map((request) => `${request.method} ${request.url}`)).toEqual([
'POST /drafts',
'PUT /drafts/d1',
])
// 그 PUT 이 나가 있는 동안 다른 계정으로 로그인했다 — 새로 남기는 POST 는 그 계정의 쿠키로 나간다
useAuthStore().clearAuth()
useAuthStore().startSession(OTHER)
keep.held.release?.(fail(404, 'RES_001'))
await flushPromises()
await flushPromises()
expect(draftCalls(requests).map((request) => `${request.method} ${request.url}`)).toEqual([
'POST /drafts',
'PUT /drafts/d1',
])
})
})
describe('본문이 한도를 넘으면', () => {
it('자동 저장을 보내지 않는다 — 상태 글은 실패, 본문 아래 MAIL_007 안내. 내용이 바뀔 때까지 다시 하지 않는다', async () => {
const { wrapper, requests } = await mountCompose('/mail/compose', server())
+213 -1
View File
@@ -17,7 +17,7 @@ import {
MAIL_SUBJECT_MAX_LENGTH,
} from '@/shared/constants/mail'
import { MAIL_BODY_HTML_MAX_LENGTH } from '@/shared/constants/mail-editor'
import type { MessageDetail } from '@/shared/types/mail'
import type { Mailbox, MessageDetail, RejectedRecipient } from '@/shared/types/mail'
import { useAuthStore } from '@/stores/auth.store'
import { useMailStore } from '@/stores/mail.store'
import { useToastStore } from '@/stores/toast.store'
@@ -265,6 +265,135 @@ describe('ComposePage', () => {
expect((wrapper.get('#compose-subject').element as HTMLInputElement).value).toBe('안녕하세요')
})
describe('받는 사람별 결과 — 일부만 받지 못하면 보낸 것, 모두 받지 못하면 보내지 않은 것 (계약 2절 "받는 사람별 결과")', () => {
const MAILBOXES: Mailbox[] = [
{ id: 'inbox', name: '받은편지함', role: 'inbox', parentId: null, sortOrder: 1, totalCount: 1, unreadCount: 0 },
{ id: 'sent', name: '보낸편지함', role: 'sent', parentId: null, sortOrder: 2, totalCount: 0, unreadCount: 0 },
]
const NOBODY: RejectedRecipient = { email: 'nobody@arasystem.it.kr', reason: 'not-found' }
/** 보내기 응답을 차례로 돌려주는 서버 — 메일함 목록에는 보낸편지함이 있다 */
function sendSequence(outcomes: MockResult[]): MockHandler {
return (request) => {
if (request.method === 'GET' && request.url === '/mailboxes') {
return ok({ items: MAILBOXES, pageInfo: { hasNext: false, nextCursor: null, total: MAILBOXES.length } })
}
if (request.method === 'POST' && request.url === '/messages') {
return outcomes.shift() ?? ok({ id: 'sent-2', rejectedRecipients: [] }, 201)
}
return replyApi(request)
}
}
function sentKeys(requests: MockRequest[]): unknown[] {
return requests
.filter((request) => request.method === 'POST' && request.url === '/messages')
.map((request) => request.config.headers.get('Idempotency-Key'))
}
async function addRecipient(wrapper: VueWrapper, email: string) {
const input = wrapper.get('#compose-to')
await input.setValue(email)
await input.trigger('blur')
}
async function submit(wrapper: VueWrapper) {
await wrapper.get('form').trigger('submit')
await flushPromises()
}
it('일부만 받지 못했으면 보낸 것이다 — 보던 곳으로 돌아가고, "보냈습니다" 대신 오류 알림(받지 못한 사람 · [보낸편지함])', async () => {
useMailStore().rememberBrowseLocation('/mail/inbox')
const { wrapper, router, requests } = await mountCompose(
'/mail/compose',
sendSequence([ok({ id: 'sent-1', rejectedRecipients: [NOBODY] }, 201)]),
)
await useMailStore().loadMailboxes()
await addRecipient(wrapper, 'doyun.lee@arasystem.it.kr')
await addRecipient(wrapper, NOBODY.email)
await submit(wrapper)
expect(router.currentRoute.value.fullPath).toBe('/mail/inbox')
expect(useToastStore().toasts).toEqual([
expect.objectContaining({
kind: 'error',
message: '보냈지만 1명은 받지 못했습니다 — nobody@arasystem.it.kr (없는 주소)',
// 성공 알림과 같은 곳 — 링크가 있으니 10초 (toast.store)
action: { label: '보낸편지함 보기', to: { name: 'mail-mailbox', params: { mailboxId: 'sent' } } },
}),
])
// 보낸 것이니 키를 바꾼다 — 같은 화면에서 또 보내면 다른 메일이다
await submit(wrapper)
const keys = sentKeys(requests)
expect(keys).toEqual([expect.any(String), expect.any(String)])
expect(keys[1]).not.toBe(keys[0])
})
it.each(['MAIL_002', 'MAIL_010'] as const)(
'서버가 받는 사람을 거절하면(%s) 주소가 든 칸은 잘못된 입력 — 안내 띠를 가리킨다. 빈 칸은 그대로, 받는 사람을 고치면 푼다',
async (code) => {
const { wrapper } = await mountCompose('/mail/compose', sendSequence([fail(400, code)]))
await addRecipient(wrapper, NOBODY.email)
const cc = wrapper.get('#compose-cc')
await cc.setValue('infra@arasystem.it.kr')
await cc.trigger('blur')
expect(wrapper.get('#compose-to').attributes('aria-invalid')).toBe('false')
await submit(wrapper)
expect(wrapper.get('#compose-recipient-error').text()).toBe(ERROR_CODE_MESSAGES[code])
for (const id of ['compose-to', 'compose-cc']) {
expect(wrapper.get(`#${id}`).attributes('aria-invalid')).toBe('true')
expect(wrapper.get(`#${id}`).attributes('aria-describedby')).toBe('compose-recipient-error')
}
// 빈 숨은참조에는 거절된 주소가 없다
expect(wrapper.get('#compose-bcc').attributes('aria-invalid')).toBe('false')
expect(wrapper.get('#compose-bcc').attributes('aria-describedby')).toBeUndefined()
// 받는 사람을 고치면 띠와 함께 푼다
await addRecipient(wrapper, 'doyun.lee@arasystem.it.kr')
expect(wrapper.find('#compose-recipient-error').exists()).toBe(false)
expect(wrapper.get('#compose-to').attributes('aria-invalid')).toBe('false')
expect(wrapper.get('#compose-to').attributes('aria-describedby')).toBeUndefined()
},
)
it('모두 받지 못했으면(MAIL_010 — 없는 주소 등) 보내지 않은 것이다 — 알림 없이 받는 사람 띠에 그 문구 · 받는 사람 칸으로 초점, 쓴 내용 · 키는 그대로', async () => {
const { wrapper, router, requests } = await mountCompose(
'/mail/compose',
sendSequence([fail(400, 'MAIL_010')]),
true,
)
await addRecipient(wrapper, NOBODY.email)
await wrapper.get('#compose-subject').setValue('안녕하세요')
;(wrapper.get('#compose-subject').element as HTMLInputElement).focus()
await submit(wrapper)
expect(wrapper.get('#compose-recipient-error').text()).toBe(ERROR_CODE_MESSAGES.MAIL_010)
expect(document.activeElement?.id).toBe('compose-to')
expect(useToastStore().toasts).toEqual([])
expect(chips(wrapper, 'compose-to')).toEqual([NOBODY.email])
expect((wrapper.get('#compose-subject').element as HTMLInputElement).value).toBe('안녕하세요')
expect(router.currentRoute.value.fullPath).toBe('/mail/compose')
// 받는 사람을 고치면 띠를 내린다 — 없는 주소를 빼고 다시 보내면 같은 키로 간다 (서버가 보낸 사본을 지워 새로 보낸다)
await addRecipient(wrapper, 'doyun.lee@arasystem.it.kr')
expect(wrapper.find('#compose-recipient-error').exists()).toBe(false)
await wrapper.get(`button[aria-label="${NOBODY.email} 삭제"]`).trigger('click')
expect(chips(wrapper, 'compose-to')).toEqual(['doyun.lee@arasystem.it.kr'])
await submit(wrapper)
const keys = sentKeys(requests)
expect(keys).toHaveLength(2)
expect(keys[1]).toBe(keys[0])
expect(router.currentRoute.value.fullPath).toBe('/mail')
wrapper.unmount()
})
})
describe('전달 (?forward=)', () => {
const CHECKLIST = { id: 'att1', name: '체크리스트.xlsx', type: 'application/vnd.ms-excel', size: 49152 }
const ESTIMATE = { id: 'att2', name: '견적.pdf', type: 'application/pdf', size: 1000 }
@@ -800,6 +929,8 @@ describe('ComposePage', () => {
['413 FILE_002 (첨부 합계 초과)', fail(413, 'FILE_002'), 'FILE_002'],
['403 AUTH_003 (보낼 권한 없음·출처 불일치)', fail(403, 'AUTH_003'), 'AUTH_003'],
['400 MAIL_006 (첨부 20개 초과 — 붙일 때 미리 막지만 서버가 마지막에 본다)', fail(400, 'MAIL_006'), 'MAIL_006'],
// 보내지 않은 것이다(서버가 보낸 사본을 지웠다) — 잠시 뒤 다시 누르면 같은 키로 새로 간다 (계약 2절 "받는 사람별 결과")
['503 MAIL_011 (받는 사람 모두 지금은 받을 수 없음)', fail(503, 'MAIL_011'), 'MAIL_011'],
] as const)(
'%s — 사전 문구로 알리고 쓴 내용·키를 그대로 둔다. 자동으로 다시 보내지 않고, 다시 누르면 같은 키다',
async (_label, failure, code) => {
@@ -1230,6 +1361,32 @@ describe('ComposePage', () => {
expect(blocksUnload()).toBe(false)
})
it('받는 사람 일부가 받지 못했어도 보낸 것이다 — 묻지 않고 돌아가며 창 닫기도 막지 않는다 (계약 2절 "받는 사람별 결과")', async () => {
useMailStore().rememberBrowseLocation('/mail/inbox')
const { wrapper, router } = await mountCompose('/mail/compose', (request) =>
request.method === 'POST' && request.url === '/messages'
? ok({ id: 'sent-1', rejectedRecipients: [{ email: 'nobody@arasystem.it.kr', reason: 'refused' }] }, 201)
: replyApi(request),
)
for (const email of ['doyun.lee@arasystem.it.kr', 'nobody@arasystem.it.kr']) {
const input = wrapper.get('#compose-to')
await input.setValue(email)
await input.trigger('blur')
}
await writeSubject(wrapper, '일정 공유')
expect(blocksUnload()).toBe(true)
await wrapper.get('form').trigger('submit')
await flushPromises()
expect(router.currentRoute.value.fullPath).toBe('/mail/inbox')
expect(dialogOpen(wrapper)).toBe(false)
expect(blocksUnload()).toBe(false)
expect(useToastStore().toasts.map((toast) => [toast.kind, toast.message])).toEqual([
['error', '보냈지만 1명은 받지 못했습니다 — nobody@arasystem.it.kr (받는 쪽이 거절)'],
])
})
describe('보내는 중에 떠나기 — 묻지 않고, 결과는 토스트로만 알린다 (떠난 양식은 건드리지 않는다)', () => {
/** 라우터만 쓰는 빈 화면 — 쓰기 화면을 내린 뒤에도 앱(App.vue)처럼 라우터를 붙잡아 둔다 */
const RouterHost: FunctionalComponent = () => h('div')
@@ -1297,11 +1454,66 @@ describe('ComposePage', () => {
expect(router.currentRoute.value.fullPath).toBe('/mail/sent')
})
it('떠난 뒤 보내기가 받는 사람 일부를 받지 못한 채 끝나도 — "보냈습니다" 대신 받지 못한 사람 알림 하나, 간 곳에서 끌고 오지 않는다', async () => {
const { handler, held } = heldSendApi()
const { wrapper, router } = await mountCompose('/mail/compose?replyTo=m1&all=1', handler)
await startSending(wrapper)
await router.push('/mail/sent')
leaveScreen(wrapper, router)
held.release?.(
ok({ id: 'sent-1', rejectedRecipients: [{ email: 'infra@arasystem.it.kr', reason: 'temporary' }] }, 201),
)
await flushPromises()
expect(useToastStore().toasts.map((toast) => [toast.kind, toast.message])).toEqual([
['error', '보냈지만 1명은 받지 못했습니다 — infra@arasystem.it.kr (지금은 받을 수 없음)'],
])
expect(router.currentRoute.value.fullPath).toBe('/mail/sent')
})
type AuthStore = ReturnType<typeof useAuthStore>
const OTHER = { email: 'doyun.lee@arasystem.it.kr', name: '이도윤' }
const PARTIAL = ok({ id: 'sent-1', rejectedRecipients: [{ email: 'infra@arasystem.it.kr', reason: 'not-found' }] }, 201)
it.each([
['로그아웃', (store: AuthStore) => store.clearAuth(), PARTIAL, true],
[
'다른 계정 로그인',
(store: AuthStore) => {
store.clearAuth()
store.startSession(OTHER)
},
PARTIAL,
true,
],
['로그아웃 — 모두 받았다', (store: AuthStore) => store.clearAuth(), ok({ id: 'sent-1', rejectedRecipients: [] }, 201), true],
['세션 만료 — 쓰기 화면은 대화상자 뒤에 남는다', (store: AuthStore) => store.expireSession(), PARTIAL, false],
] as const)(
'보내는 중 %s — 결과가 와도 알리지 않는다 (일부 거절 알림에는 앞 사용자의 받는 사람 주소가 있다)',
async (_label, endSession, result, leaves) => {
const { handler, held } = heldSendApi()
const { wrapper, router } = await mountCompose('/mail/compose?replyTo=m1&all=1', handler)
await startSending(wrapper)
endSession(useAuthStore())
if (leaves) {
await router.push('/login')
leaveScreen(wrapper, router)
}
held.release?.(result)
await flushPromises()
expect(useToastStore().toasts).toEqual([])
},
)
it.each([
['MAIL_002 (평소에는 받는 사람 칸 아래에 알린다)', fail(400, 'MAIL_002'), 'MAIL_002'],
['MAIL_010 (평소에는 받는 사람 칸 아래에 알린다)', fail(400, 'MAIL_010'), 'MAIL_010'],
['MAIL_004 (평소에는 첨부 목록에 알린다)', fail(400, 'MAIL_004'), 'MAIL_004'],
['MAIL_007 (평소에는 본문 아래 띠로 알린다)', fail(400, 'MAIL_007'), 'MAIL_007'],
['MAIL_001 (평소에도 토스트)', fail(503, 'MAIL_001'), 'MAIL_001'],
['MAIL_011 (평소에도 토스트)', fail(503, 'MAIL_011'), 'MAIL_011'],
] as const)('떠난 뒤 보내기가 %s 로 끝나면 사전 문구 토스트 하나로 알린다', async (_label, failure, code) => {
const { handler, held } = heldSendApi()
const { wrapper, router } = await mountCompose('/mail/compose?replyTo=m1', handler)
+79 -33
View File
@@ -21,10 +21,14 @@
// 본문에 붙여 넣거나 끌어다 놓은 파일(이미지 포함)은 첨부로 올린다 — 본문의 그림은 서명 그림뿐이다 (사용자 확정).
// 화면 어디에 파일을 놓아도 첨부로 받는다 — 브라우저가 파일을 열어 쓰던 메일을 잃지 않게 (useWindowFileDrop).
// 받는 사람 오류 문구는 에러 코드 사전(MAIL_002)에서만 온다 — 형식 오류(즉시)와 서버 거부 모두 같은 문구.
// 엔진이 받는 사람을 모두 거절하면(MAIL_010 — 없는 주소 등, 보내지 않은 것) 같은 띠 · 같은 초점에 그 코드의 문구.
// 일부만 받지 못했으면 보낸 것이다 — 떠나면서 "보냈습니다" 대신 받지 못한 사람을 오류 알림 모양으로 알린다 (계약 2절 "받는 사람별 결과").
// 보내기 직전에 제목 · 본문 길이를 최종 모양으로 잰다 — 넘으면 보내지 않고 그 칸 아래에 사전 문구
// (제목 VAL_001 · 본문 MAIL_007). 서버가 본문을 MAIL_007 로 거절해도 본문 아래에 같은 띠를 보인다.
// 보내는 중 · 보낸 뒤 · 버린 뒤 · 세션이 끝났을 때(로그아웃 · 만료 · 다른 탭의 로그아웃)는 앱 안 이동을 묻지 않는다.
// 보내는 중에 떠나면 요청은 이어지고, 결과는 토스트로만 알린다 — 떠난 양식은 건드리지 않고 끌고 오지도 않는다 (formGeneration).
// 보내는 중에 세션이 바뀌면(로그아웃 · 만료 · 다른 계정 로그인) 결과를 알리지도 저장본으로 남기지도 않는다 — 앞 사용자의 받는 사람이
// 로그인 화면 · 다른 계정에 보이거나 그 계정의 임시보관함에 들어가지 않게. 임시 저장을 기다리는 사이 바뀌었으면 보내지도 않는다.
// 고정 id — 화면 검증 · 테스트가 쓴다: 임시 저장 #compose-save-draft · 상태 #compose-draft-status(ComposeDraftStatus) ·
// 휴지통 #compose-discard · 닫기 .compose__close
import {
@@ -71,6 +75,7 @@ import {
MAIL_ATTACHMENT_ACCEPT,
MAIL_MAX_ATTACHMENTS,
MAIL_MAX_RECIPIENTS,
MAIL_PARTIAL_SEND_TEXT,
MAIL_SENT_ACTION_LABEL,
MAIL_SENT_MESSAGE,
MAIL_SUBJECT_MAX_LENGTH,
@@ -78,7 +83,7 @@ import {
} from '@/shared/constants/mail'
import { IMAGE_AS_ATTACHMENT_MESSAGE } from '@/shared/constants/mail-editor'
import type { ErrorCode } from '@/shared/constants/error-codes.generated'
import type { Draft, DraftPayload, SendMessagePayload } from '@/shared/types/mail'
import type { Draft, DraftPayload, SendMessagePayload, SentMessage } from '@/shared/types/mail'
import {
isSameComposeForm,
snapshotComposeForm,
@@ -101,6 +106,7 @@ import { attachmentHint } from '@/shared/utils/mail-attachment'
import { buildForwardDraft, isForwardSubject } from '@/shared/utils/mail-forward'
import { textToParagraphHtml } from '@/shared/utils/mail-quote'
import { buildReplyDraft } from '@/shared/utils/mail-reply'
import { partialSendMessage } from '@/shared/utils/mail-send-result'
import { newMailHtmlWithSignature, signatureHtmlFor } from '@/shared/utils/mail-signature'
import { useAuthStore } from '@/stores/auth.store'
import { useMailSignatureStore } from '@/stores/mail-signature.store'
@@ -124,6 +130,9 @@ const BODY_ERROR_ID = 'compose-body-error'
/** 쓰기 화면을 여는 까닭 — 새 메일 · 답장 · 전체 답장 · 전달 · 이어 쓰기(임시 저장본) */
type ComposeSource = ComposeMode | 'draft'
/** 서버가 받는 사람을 거부한 코드 — 받는 사람 칸 아래 띠로 안내한다 */
type RecipientRejectionCode = Extract<ErrorCode, 'MAIL_002' | 'MAIL_010'>
const route = useRoute()
const router = useRouter()
const authStore = useAuthStore()
@@ -179,8 +188,11 @@ const discarding = ref<boolean>(false)
const discarded = ref<boolean>(false)
/** 보내기를 한 번 눌렀는지 — 받는 사람이 비었다는 안내는 그 뒤에만 보인다 */
const attempted = ref<boolean>(false)
/** 서버가 받는 사람을 거부했다 (MAIL_002) */
const rejectedByServer = ref<boolean>(false)
/**
* 서버가 받는 사람을 거부했다 — MAIL_002(형식 · 합계 100명 초과) · MAIL_010(엔진이 모두 거절 — 없는 주소 등).
* 받는 사람을 고치거나 다시 보내면 내린다
*/
const rejectedByServer = ref<RecipientRejectionCode | null>(null)
/**
* 길이가 한도를 넘은 칸 — 제목 255자 · 본문(텍스트 100,000 · 서식 500,000, 인용을 다 줄여도).
* 본문은 보내기 직전에 잰 값이 넘었거나 서버가 MAIL_007 로 거절했을 때 (정화 전 비용 · 정화 뒤 크기 한도 포함)
@@ -210,7 +222,7 @@ let formGeneration = 0
*/
let resetCount = 0
/** 보내기 결과를 이 화면이 칸 안에서 직접 안내하는 코드 — 인터셉터 알림을 끈다 (양식이 사라졌으면 토스트로 대신 알린다) */
const SEND_INLINE_CODES: readonly ErrorCode[] = ['MAIL_002', 'MAIL_004', 'MAIL_007']
const SEND_INLINE_CODES: readonly ErrorCode[] = ['MAIL_002', 'MAIL_010', 'MAIL_004', 'MAIL_007']
/**
* 중복 발송 방지 키 (계약 2절 Idempotency-Key) — 쓰기 화면마다 하나.
* 양식을 새로 채울 때(새 화면·다른 쓰기 종류나 원본) 만들고, 같은 화면에서는 보내기 결과가
@@ -268,14 +280,18 @@ const allRecipients = computed(() => [...to.value, ...cc.value, ...bcc.value])
const hasInvalidRecipient = computed(() => allRecipients.value.some((token) => !token.valid))
/** 받는 사람 합계 100명 초과 — 서버도 MAIL_002 로 거절하므로 보내기 전에 같은 문구로 막는다 (계약 2절) */
const tooManyRecipients = computed(() => allRecipients.value.length > MAIL_MAX_RECIPIENTS)
const showRecipientError = computed(
() =>
hasInvalidRecipient.value ||
rejectedByServer.value ||
(attempted.value && (to.value.length === 0 || tooManyRecipients.value)),
/** 보내기 전 검사에 걸린 받는 사람 — 형식이 틀린 주소, 보내기를 누른 뒤의 빈 받는 사람 · 100명 초과 */
const recipientPrecheckFailed = computed(
() => hasInvalidRecipient.value || (attempted.value && (to.value.length === 0 || tooManyRecipients.value)),
)
const showRecipientError = computed(() => recipientPrecheckFailed.value || rejectedByServer.value !== null)
/**
* 문구는 에러 코드 사전에서만 온다 — 보내기 전 검사와 서버의 MAIL_002 는 같은 문구,
* 엔진이 모두 거절한 것(MAIL_010)은 그 코드의 문구 (보내기 전 검사에 걸렸으면 그것부터)
*/
const recipientErrorMessage = computed(() =>
getErrorMessage(recipientPrecheckFailed.value ? 'MAIL_002' : (rejectedByServer.value ?? 'MAIL_002')),
)
/** 문구는 에러 코드 사전에서만 온다 */
const recipientErrorMessage = getErrorMessage('MAIL_002')
/** 제목이 255자를 넘으면 서버도 VAL_001 로 거절한다 — 같은 문구를 보내기 전에 보인다 */
const subjectErrorMessage = getErrorMessage('VAL_001')
/** 본문이 너무 길면 서버는 MAIL_007 로 거절한다 — 보내기 전에 막을 때(내 글만으로 넘음)와 서버가 거절했을 때 모두 이 문구 */
@@ -528,7 +544,7 @@ function resetForm(next: ComposeSource, sourceId: string | null) {
bodyPending.value = false
renewEditor()
attempted.value = false
rejectedByServer.value = false
rejectedByServer.value = null
lengthError.value = null
resetUploads()
// 양식을 새로 채우면 다른 메일이다 — 앞 양식에서 보내던 요청은 끝나도 이 양식을 건드리지 않는다 (formGeneration)
@@ -691,14 +707,20 @@ function settleSendKey(outcome: SendOutcome) {
if (shouldRenewSendKey(outcome)) sendKey = createIdempotencyKey()
}
function notifySent() {
/**
* 보냈다고 알린다 — 보내는 중에 떠났어도 알린다(세션이 바뀌었으면 부르지 않는다 — onSubmit). 받는 사람 일부가 받지 못했으면
* "보냈습니다" 대신 오류 알림 모양(흰 카드)으로 받지 못한 사람을 적는다 — 링크는 같은 보낸편지함 (계약 2절 "받는 사람별 결과").
* 링크가 있는 알림은 둘 다 10초다 (toast.store 의 TOAST_ACTION_DURATION_MS)
*/
function notifySent(result: SentMessage) {
const sentMailbox = mailStore.mailboxByRole('sent')
toast.success(
MAIL_SENT_MESSAGE,
sentMailbox
? { label: MAIL_SENT_ACTION_LABEL, to: { name: 'mail-mailbox', params: { mailboxId: sentMailbox.id } } }
: undefined,
)
const sentLocation = sentMailbox ? { name: 'mail-mailbox', params: { mailboxId: sentMailbox.id } } : null
const partial = partialSendMessage(result.rejectedRecipients)
if (partial) {
toast.error(partial, sentLocation ? { label: MAIL_PARTIAL_SEND_TEXT.action, to: sentLocation } : undefined)
return
}
toast.success(MAIL_SENT_MESSAGE, sentLocation ? { label: MAIL_SENT_ACTION_LABEL, to: sentLocation } : undefined)
}
/** 보내지 못했다 — 쓰던 대로 두고 자동 저장을 다시 켠다. 기다린 저장이 저장본을 바꿨으면 주소도 맞춘다 */
@@ -713,7 +735,7 @@ async function onSubmit() {
// 입력칸에 남은 주소도 토큰으로 확정한 뒤 검사한다
commitRecipientInputs()
attempted.value = true
rejectedByServer.value = false
rejectedByServer.value = null
if (to.value.length === 0 || hasInvalidRecipient.value || tooManyRecipients.value) {
toField.value?.focus()
return
@@ -745,12 +767,24 @@ async function onSubmit() {
/** 이 보내기가 속한 양식 — 응답이 왔을 때 바뀌었으면 양식을 건드리지 않고 토스트로만 알린다 */
const generation = formGeneration
const mark = resetCount
/**
* 이 보내기를 시작한 세션 — 그 뒤 끝났거나 바뀌었으면(로그아웃 · 만료 · 다른 계정 로그인) 보내지도 · 알리지도 · 저장본으로 남기지도 않는다.
* 결과 알림에는 받는 사람 주소가 들 수 있고(일부 거절), 그 뒤의 요청은 지금 쿠키(다른 계정일 수 있다)로 나간다
*/
const session = authStore.generation
const sameSession = () => authStore.generation === session
sending.value = true
// 진행 중인 임시 저장을 기다린다 — 보내는 본문의 draftId · 첨부 id 가 최신이어야 한다 (계약 2절 "임시 저장").
// 그사이 화면을 떠나도 보낸다(보내기를 이미 눌렀다). 다른 메일로 바뀌었으면 이 메일은 보내지 않는다
drafts.suspend()
await drafts.settle()
if (mark !== resetCount) return
// 기다리는 사이 세션이 바뀌었다 — 보내지 않는다 (다른 계정의 쿠키로 나가지 않게). 자동 저장도 다시 켜지 않는다 —
// 양식이 남아 있다면 세션 만료 대화상자 뒤다
if (!sameSession()) {
if (generation === formGeneration) sending.value = false
return
}
// 기다린 저장이 첨부를 찾지 못했거나(MAIL_004 — 첨부를 모두 "찾지 못함"으로 바꾼다) 그사이 새 첨부를 올리기 시작했다.
// 지금 첨부로 보내면 모르는 사이 첨부 없이 나간다 — 보내지 않고 MAIL_004 와 같이 안내한다
if (hasMissing.value || isUploading.value) {
@@ -780,15 +814,19 @@ async function onSubmit() {
}
try {
// 받는 사람 거부(MAIL_002) · 첨부 만료(MAIL_004) · 본문이 너무 김(MAIL_007)은 이 화면이 해당 칸에 직접 안내한다.
// 받는 사람 거부(MAIL_002) · 엔진이 받는 사람을 모두 거절(MAIL_010 — 없는 주소 등) · 첨부 만료(MAIL_004) ·
// 본문이 너무 김(MAIL_007)은 이 화면이 해당 칸에 직접 안내한다.
// 그 밖의 실패는 인터셉터 알림(사전 문구)으로 충분하다 — AUTH_003 권한·출처, REQ_001 발송 한도·처리 중,
// MAIL_005 같은 키·다른 내용(문구가 보낸편지함 확인을 안내한다), FILE_002 첨부 합계, MAIL_006 첨부 개수
// (붙일 때 미리 막으므로 드물다), MAIL_001 엔진 장애.
// REQ_001 은 자동으로 다시 보내지 않는다 — 사용자가 잠시 뒤 다시 누르면 같은 키로 간다.
await sendMessage(payload, { idempotencyKey: sendKey, silent: SEND_INLINE_CODES })
notifySent()
// 보낸편지함 · 임시보관함 개수 · 사용량을 서버 기준으로 다시 맞춘다
mailStore.syncCounts()
// (붙일 때 미리 막으므로 드물다), MAIL_001 엔진 장애, MAIL_011 받는 사람 모두 지금은 받을 수 없음(보내지 않았다).
// REQ_001 · MAIL_011 은 자동으로 다시 보내지 않는다 — 사용자가 잠시 뒤 다시 누르면 같은 키로 간다.
const result = await sendMessage(payload, { idempotencyKey: sendKey, silent: SEND_INLINE_CODES })
if (sameSession()) {
// 받는 사람 일부가 받지 못했어도 보낸 것이다 — 아래는 모두 받았을 때와 같고, 알림만 받지 못한 사람을 적는다
notifySent(result)
// 보낸편지함 · 임시보관함 개수 · 사용량을 서버 기준으로 다시 맞춘다
mailStore.syncCounts()
}
// 보내는 중에 화면을 떠났거나 양식을 새로 채웠다 — 알리기만 하고 간 곳에 둔다
if (generation !== formGeneration) return
settleSendKey('sent')
@@ -800,18 +838,23 @@ async function onSubmit() {
} catch (error) {
const apiError = toApiError(error)
if (generation !== formGeneration) {
// 양식이 이미 없다 — 칸 안 안내 대신 토스트로 (그 밖의 코드는 인터셉터가 이미 알렸다)
// 양식이 이미 없다 — 세션이 바뀌었으면 알리지도 저장본으로 남기지도 않는다 (로그인 화면 · 다른 계정에 앞 사용자의 실패가 보이거나
// 그 사람의 임시보관함에 이 메일이 들어가지 않게)
if (!sameSession()) return
// 칸 안 안내 대신 토스트로 (그 밖의 코드는 인터셉터가 이미 알렸다)
if (SEND_INLINE_CODES.includes(apiError.code)) notifyApiError(apiError)
// 마지막 자동 저장 뒤에 쓴 글을 잃지 않게 보내려던 내용을 조용히 저장본으로 남긴다 (코드와 상관없이 — 알림은 위의 실패 알림뿐)
void draftLink.keepUnsent(payload)
void draftLink.keepUnsent(payload, session)
return
}
const { code } = apiError
// MAIL_007 도 키를 그대로 둔다 — 서버가 엔진에 넘기기 전에 거절했으므로, 줄인 뒤 같은 키로 보내면 된다 (계약 2절)
// MAIL_007 도 키를 그대로 둔다 — 서버가 엔진에 넘기기 전에 거절했으므로, 줄인 뒤 같은 키로 보내면 된다 (계약 2절).
// MAIL_010 · MAIL_011 도 그대로 — 아무에게도 가지 않아 서버가 보낸 사본을 지웠다. 같은 키로 다시 보내면 새로 보낸다
settleSendKey(code)
endSending(generation)
if (code === 'MAIL_002') {
rejectedByServer.value = true
if (code === 'MAIL_002' || code === 'MAIL_010') {
// 받는 사람 칸 아래 띠 — MAIL_010 은 보내지 않은 것이라 쓰던 메일은 그대로 두고 주소를 고치게 한다
rejectedByServer.value = code
toField.value?.focus()
} else if (code === 'MAIL_007') {
// 서버가 본문을 거절했다(정화 전 비용 · 정화 뒤 크기 한도 등) — 보내기 전 검사와 같은 띠 · 같은 초점
@@ -949,7 +992,7 @@ watch(hasMissing, (missing) => {
// 받는 사람을 고치면 서버 거부 안내를 내린다
watch(allRecipients, () => {
rejectedByServer.value = false
rejectedByServer.value = null
})
</script>
@@ -975,6 +1018,7 @@ watch(allRecipients, () => {
input-id="compose-to"
placeholder="메일 주소 입력 후 Enter"
:error-id="RECIPIENT_ERROR_ID"
:rejected="rejectedByServer !== null"
:disabled="preparing"
>
<template #trailing>
@@ -1001,6 +1045,7 @@ watch(allRecipients, () => {
input-id="compose-cc"
placeholder="메일 주소"
:error-id="RECIPIENT_ERROR_ID"
:rejected="rejectedByServer !== null"
:disabled="preparing"
/>
<RecipientField
@@ -1010,6 +1055,7 @@ watch(allRecipients, () => {
input-id="compose-bcc"
placeholder="메일 주소"
:error-id="RECIPIENT_ERROR_ID"
:rejected="rejectedByServer !== null"
:disabled="preparing"
/>
</div>
@@ -0,0 +1,784 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { mount, type DOMWrapper, type VueWrapper } from '@vue/test-utils'
import { createPinia, setActivePinia, type Pinia } from 'pinia'
import { h } from 'vue'
import { RouterView } from 'vue-router'
import MessageReader from '@/components/mail/MessageReader.vue'
import { ERROR_CODE_MESSAGES } from '@/shared/constants/error-codes.generated'
import type { Mailbox, MailFilterRule, MailFilterSet, MailFilterSetPayload, MessageDetail } from '@/shared/types/mail'
import { useAuthStore } from '@/stores/auth.store'
import { useMailStore } from '@/stores/mail.store'
import { useToastStore } from '@/stores/toast.store'
import {
fail,
flushPromises,
installMockApi,
ok,
restoreApiAdapter,
type MockRequest,
type MockResult,
} from '@/test-utils/mock-api'
import { createTestRouter } from '@/test-utils/router'
import MailFilterPage from './MailFilterPage.vue'
// 설정 › 메일 › 필터 (디자인 원본 SettingsFilter · MailFilterStates · 계약 2절 "메일 필터")
const USER = { email: 'ara.kim@arasystem.it.kr', name: '김아라' }
function mailbox(id: string, name: string, role: Mailbox['role'] = null): Mailbox {
return { id, name, role, parentId: null, sortOrder: 0, totalCount: 0, unreadCount: 0 }
}
const MAILBOXES: Mailbox[] = [
mailbox('inbox', '받은편지함', 'inbox'),
mailbox('sent', '보낸편지함', 'sent'),
mailbox('drafts', '임시보관함', 'drafts'),
mailbox('junk', '스팸메일함', 'junk'),
mailbox('trash', '휴지통', 'trash'),
mailbox('archive', '보관함', 'archive'),
mailbox('vendor', '거래처'),
mailbox('notice', '전체 공지'),
]
const ACTIONS = { moveToMailboxId: null, markAsRead: false, flag: false, forwardTo: null }
/** 디자인 원본 SettingsFilter 의 시안과 같은 다섯 규칙 */
const RULES: MailFilterRule[] = [
{
id: 'r1',
name: '휴가 중 전달',
isEnabled: false,
match: 'always',
conditions: [],
actions: { ...ACTIONS, forwardTo: 'doyun.lee@arasystem.it.kr' },
},
{
id: 'r2',
name: '거래처 메일',
isEnabled: true,
match: 'all',
conditions: [{ field: 'from', operator: 'contains', value: '@hanbit-partners.co.kr' }],
actions: { ...ACTIONS, moveToMailboxId: 'vendor' },
},
{
id: 'r3',
name: '결재 알림',
isEnabled: true,
match: 'all',
conditions: [{ field: 'subject', operator: 'contains', value: '[결재]' }],
actions: { ...ACTIONS, markAsRead: true, flag: true },
},
{
id: 'r4',
name: '',
isEnabled: false,
match: 'all',
conditions: [{ field: 'to', operator: 'contains', value: 'all@arasystem.it.kr' }],
actions: { ...ACTIONS, moveToMailboxId: 'notice', markAsRead: true },
},
{
id: 'r5',
name: '알파 프로젝트',
isEnabled: true,
match: 'any',
conditions: [
{ field: 'subject', operator: 'contains', value: '알파' },
{ field: 'body', operator: 'contains', value: 'Project Alpha' },
],
actions: { ...ACTIONS, moveToMailboxId: 'gone' },
},
]
const SET: MailFilterSet = { rules: RULES, version: 'v1', isActive: true }
interface Server {
filters?: MailFilterSet | MockResult
save?: (request: MockRequest) => MockResult | Promise<MockResult>
}
/** 서버 흉내 — 받은 목록을 그대로 저장하고 version 을 올린다 */
function echo(request: MockRequest): MockResult {
const body = request.data as MailFilterSetPayload
return ok({ rules: body.rules, version: `${body.version}+`, isActive: true })
}
function deferred() {
let finish: (result: MockResult) => void = () => undefined
const promise = new Promise<MockResult>((resolve) => {
finish = resolve
})
return { promise, finish }
}
describe('MailFilterPage', () => {
let pinia: Pinia
let requests: MockRequest[]
const mounted: VueWrapper[] = []
beforeEach(() => {
pinia = createPinia()
setActivePinia(pinia)
useAuthStore().startSession(USER)
})
afterEach(() => {
for (const wrapper of mounted.splice(0)) {
if (!wrapper.vm.$.isUnmounted) wrapper.unmount()
}
useToastStore().clear()
restoreApiAdapter()
document.body.innerHTML = ''
})
function installServer(server: Server = {}) {
requests = installMockApi((request) => {
if (request.url === '/mailboxes') {
return ok({ items: MAILBOXES, pageInfo: { hasNext: false, nextCursor: null, total: MAILBOXES.length } })
}
if (request.url === '/mail-filters' && request.method === 'GET') {
const filters = server.filters ?? SET
return typeof filters === 'string' || 'status' in filters ? filters : ok(filters)
}
if (request.url === '/mail-filters' && request.method === 'PUT') return (server.save ?? echo)(request)
return ok(null)
})
}
async function mountPage(server: Server = {}, path = '/settings/mail/filters') {
installServer(server)
const router = createTestRouter()
await router.push(path)
await router.isReady()
const wrapper = mount(MailFilterPage, { attachTo: document.body, global: { plugins: [pinia, router] } })
mounted.push(wrapper)
await flushPromises()
return { wrapper, router }
}
const puts = () => requests.filter((request) => request.method === 'PUT').map((request) => request.data as MailFilterSetPayload)
const rows = (wrapper: VueWrapper) => wrapper.findAll('[data-filter-rule]')
const row = (wrapper: VueWrapper, id: string) => wrapper.get(`[data-filter-rule="${id}"]`)
const dialog = (wrapper: VueWrapper) => wrapper.get('#mail-filter-dialog')
const isOpen = (wrapper: VueWrapper) => dialog(wrapper).attributes('open') !== undefined
const toasts = () => useToastStore().toasts.map((toast) => toast.message)
const conditionValues = (wrapper: VueWrapper) =>
wrapper.findAll('[data-filter-condition-value]').map((input) => (input.element as HTMLInputElement).value)
/** 사람이 누른 것처럼 — 초점을 먼저 준다 (창을 닫으면 초점이 돌아올 곳) */
async function click(target: Pick<DOMWrapper<Element>, 'element' | 'trigger'>) {
;(target.element as HTMLElement).focus()
await target.trigger('click')
await flushPromises()
}
async function openRule(wrapper: VueWrapper, id: string) {
await click(row(wrapper, id).get('[data-filter-edit]'))
}
async function save(wrapper: VueWrapper) {
await click(wrapper.get('#mail-filter-save'))
}
it('목록 — 순서 번호 · 켜기 스위치 · 이름과 요약 한 줄, 꺼 둔 규칙은 흐리게, 지워진 메일함은 빨간 안내', async () => {
const { wrapper } = await mountPage()
expect(wrapper.get('h1').text()).toBe('필터')
expect(requests.map((request) => `${request.method} ${request.url}`).sort()).toEqual([
'GET /mail-filters',
'GET /mailboxes',
])
expect(rows(wrapper).map((item) => item.get('.filter-row__no').text())).toEqual(['1', '2', '3', '4', '5'])
expect(rows(wrapper).map((item) => item.get('.filter-row__title').text())).toEqual([
'휴가 중 전달',
'거래처 메일',
'결재 알림',
'받는 사람에 ‘all@arasystem.it.kr’ 포함',
'알파 프로젝트',
])
expect(rows(wrapper).map((item) => item.get('.filter-row__summary').text())).toEqual([
'모든 메일 → 전달: doyun.lee@arasystem.it.kr',
'보낸 사람에 ‘@hanbit-partners.co.kr’ 포함 → 옮기기: 거래처',
'제목에 ‘[결재]’ 포함 → 읽음으로 표시 · 중요 표시',
'→ 옮기기: 전체 공지 · 읽음으로 표시',
'제목에 ‘알파’ 포함 또는 본문에 ‘Project Alpha’ 포함 → 옮기기: 없는 메일함',
])
const switches = wrapper.findAll('[data-filter-switch]')
expect(switches.map((item) => item.attributes('aria-checked'))).toEqual(['false', 'true', 'true', 'false', 'true'])
expect(switches[1]?.attributes()).toMatchObject({ role: 'switch', 'aria-label': '‘거래처 메일’ 필터 사용' })
// 이름 없는 필터 — 조건 글이 이름이라 작은따옴표가 겹치지 않게 큰따옴표로 묶는다
expect(switches[3]?.attributes('aria-label')).toBe('“받는 사람에 ‘all@arasystem.it.kr’ 포함” 필터 사용')
expect(row(wrapper, 'r4').get('[data-filter-up]').attributes('aria-label')).toBe(
'“받는 사람에 ‘all@arasystem.it.kr’ 포함” 위로',
)
// 켜기마다 목록 전체가 바쁜 것으로 읽히지 않게 — 칸마다 aria-disabled
expect(wrapper.get('ol').attributes('aria-busy')).toBeUndefined()
expect(rows(wrapper).map((item) => item.classes('is-off'))).toEqual([true, false, false, true, false])
expect(wrapper.findAll('[data-filter-missing]').map((item) => item.text())).toEqual([
'옮길 메일함이 지워져 받은편지함으로 들어옵니다 — 눌러서 고치세요.',
])
expect(row(wrapper, 'r5').get('[data-filter-edit]').attributes('aria-label')).toBe('‘알파 프로젝트’ 필터 고치기')
// 맨 위의 ▲ · 맨 아래의 ▼ 는 누를 수 없다 (초점은 그대로 받는다)
expect(row(wrapper, 'r1').get('[data-filter-up]').attributes('aria-disabled')).toBe('true')
expect(row(wrapper, 'r1').get('[data-filter-down]').attributes('aria-disabled')).toBeUndefined()
expect(row(wrapper, 'r5').get('[data-filter-down]').attributes('aria-disabled')).toBe('true')
expect(wrapper.get('[data-filter-count]').text()).toBe('필터 5개 · 50개까지 만들 수 있습니다. 행을 누르면 고칩니다.')
expect(wrapper.get('[data-filter-notes]').findAll('p')).toHaveLength(4)
expect(wrapper.find('[data-filter-empty]').exists()).toBe(false)
expect(wrapper.find('[data-filter-conflict]').exists()).toBe(false)
expect(wrapper.find('[data-filter-inactive]').exists()).toBe(false)
})
it('받는 동안은 자리만, 못 받으면 사전 문구와 "다시 시도" — 알림은 띄우지 않는다', async () => {
let reply: MailFilterSet | MockResult = fail(503, 'MAIL_001')
const { wrapper } = await mountPage({
get filters() {
return reply
},
})
expect(wrapper.text()).toContain(ERROR_CODE_MESSAGES.MAIL_001)
expect(toasts()).toEqual([])
expect(wrapper.get('[data-filter-create]').attributes('aria-disabled')).toBe('true')
reply = SET
await wrapper.get('.mail-load-error__action').trigger('click')
await flushPromises()
expect(rows(wrapper)).toHaveLength(5)
})
it('필터가 없으면 목록 자리에 안내 — 안의 [새 필터]는 머리의 단추와 같은 일을 한다', async () => {
const { wrapper } = await mountPage({ filters: { rules: [], version: 'v0', isActive: true } })
const empty = wrapper.get('[data-filter-empty]')
expect(empty.text()).toContain('아직 필터가 없습니다')
expect(wrapper.find('[data-filter-count]').exists()).toBe(false)
await click(empty.get('[data-filter-create]'))
expect(isOpen(wrapper)).toBe(true)
expect(wrapper.get('#mail-filter-dialog-title').text()).toBe('새 필터')
expect(wrapper.find('#mail-filter-remove').exists()).toBe(false)
expect(document.activeElement?.id).toBe('mail-filter-name')
})
it('켜기 스위치 — 화면을 먼저 바꾸고 전체 교체로 저장한다 (알리지 않는다), 실패하면 되돌리고 오류 알림', async () => {
const reply = deferred()
const { wrapper } = await mountPage({ save: () => reply.promise })
const toggle = row(wrapper, 'r2').get('[data-filter-switch]')
await toggle.trigger('click')
expect(toggle.attributes('aria-checked')).toBe('false')
expect(puts()).toHaveLength(1)
expect(puts()[0]?.version).toBe('v1')
expect(puts()[0]?.rules.map((rule) => rule.isEnabled)).toEqual([false, false, true, false, true])
// 저장하는 동안은 다른 켜기 · 순서를 보내지 않는다 (막아도 초점은 그대로 — aria-disabled)
const other = row(wrapper, 'r3').get('[data-filter-switch]')
expect(other.attributes('aria-disabled')).toBe('true')
await other.trigger('click')
await row(wrapper, 'r3').get('[data-filter-up]').trigger('click')
expect(puts()).toHaveLength(1)
reply.finish(fail(503, 'MAIL_001'))
await flushPromises()
expect(toggle.attributes('aria-checked')).toBe('true')
expect(toasts()).toEqual([ERROR_CODE_MESSAGES.MAIL_001])
})
it('켜기가 저장되면 알림이 없다', async () => {
const { wrapper } = await mountPage()
await row(wrapper, 'r1').get('[data-filter-switch]').trigger('click')
await flushPromises()
expect(row(wrapper, 'r1').get('[data-filter-switch]').attributes('aria-checked')).toBe('true')
expect(row(wrapper, 'r1').classes('is-off')).toBe(false)
expect(toasts()).toEqual([])
})
it('▲ ▼ — 한 칸 옮겨 저장하고, 누른 단추에 초점이 남는다 (맨 위로 오면 그 ▲ 는 꺼진 채로 초점을 둔다)', async () => {
const { wrapper } = await mountPage()
await click(row(wrapper, 'r2').get('[data-filter-up]'))
expect(rows(wrapper).map((item) => item.attributes('data-filter-rule'))).toEqual(['r2', 'r1', 'r3', 'r4', 'r5'])
expect(puts()[0]?.rules.map((rule) => rule.id)).toEqual(['r2', 'r1', 'r3', 'r4', 'r5'])
const up = row(wrapper, 'r2').get('[data-filter-up]')
expect(document.activeElement).toBe(up.element)
expect(up.attributes('aria-disabled')).toBe('true')
// 맨 위에서 다시 눌러도 보내지 않는다
await click(up)
expect(puts()).toHaveLength(1)
expect(toasts()).toEqual([])
})
it('행을 누르면 그 규칙으로 채운 고치기 창 — [저장]하면 그 자리에 저장하고 "필터를 저장했습니다.", 초점은 그 행으로', async () => {
const { wrapper } = await mountPage()
await openRule(wrapper, 'r2')
expect(isOpen(wrapper)).toBe(true)
expect(wrapper.get('#mail-filter-dialog-title').text()).toBe('필터 고치기')
expect((wrapper.get('#mail-filter-name').element as HTMLInputElement).value).toBe('거래처 메일')
expect((wrapper.get('#mail-filter-match').element as HTMLSelectElement).value).toBe('all')
expect(conditionValues(wrapper)).toEqual(['@hanbit-partners.co.kr'])
expect((wrapper.get('#mail-filter-move').element as HTMLInputElement).checked).toBe(true)
expect((wrapper.get('#mail-filter-move-target').element as HTMLSelectElement).value).toBe('vendor')
expect(wrapper.get('#mail-filter-forward-to').attributes('disabled')).toBeDefined()
expect(wrapper.get('#mail-filter-remove').text()).toBe('이 필터 지우기')
expect(document.activeElement?.id).toBe('mail-filter-name')
await wrapper.get('#mail-filter-name').setValue('한빛 거래처')
await wrapper.get('#mail-filter-read').setValue(true)
await save(wrapper)
expect(puts()).toEqual([
{
version: 'v1',
rules: [
RULES[0],
{ ...RULES[1], name: '한빛 거래처', actions: { ...RULES[1]?.actions, markAsRead: true } },
RULES[2],
RULES[3],
RULES[4],
],
},
])
expect(isOpen(wrapper)).toBe(false)
expect(toasts()).toEqual(['필터를 저장했습니다.'])
expect(row(wrapper, 'r2').get('.filter-row__title').text()).toBe('한빛 거래처')
expect(document.activeElement).toBe(row(wrapper, 'r2').get('[data-filter-edit]').element)
})
it('새 필터 — 잘못은 [저장]을 누를 때 칸 아래 빨간 글로만, 첫 잘못된 칸에 초점 (알림 없음 · 보내지 않는다)', async () => {
const { wrapper } = await mountPage()
await click(wrapper.get('[data-filter-create]'))
expect(wrapper.get('#mail-filter-dialog-title').text()).toBe('새 필터')
await save(wrapper)
expect(wrapper.get('#mail-filter-condition-0-error').text()).toBe(
'비어 있는 조건이 있습니다. 찾을 글을 적거나 그 조건을 지워 주세요.',
)
expect(wrapper.get('#mail-filter-actions-error').text()).toBe('할 일을 하나 이상 골라 주세요.')
const value = wrapper.get('[data-filter-condition-value]')
expect(value.attributes()).toMatchObject({ 'aria-invalid': 'true', 'aria-describedby': 'mail-filter-condition-0-error' })
expect(document.activeElement).toBe(value.element)
await value.setValue('가격 ${price}')
expect(wrapper.find('#mail-filter-condition-0-error').exists()).toBe(false)
await wrapper.get('#mail-filter-forward').setValue(true)
expect(wrapper.find('#mail-filter-actions-error').exists()).toBe(false)
await wrapper.get('#mail-filter-forward-to').setValue('me@gmail.com')
await save(wrapper)
expect(wrapper.get('#mail-filter-condition-0-error').text()).toBe(
'‘${’ 는 쓸 수 없습니다 — 메일 서버가 다른 뜻으로 읽습니다.',
)
expect(wrapper.get('#mail-filter-forward-help').text()).toBe('사내 주소(@arasystem.it.kr)로만 전달할 수 있습니다.')
expect(document.activeElement).toBe(value.element)
await value.setValue('견적서')
await save(wrapper)
expect(document.activeElement?.id).toBe('mail-filter-forward-to')
await wrapper.get('#mail-filter-forward-to').setValue('Ara.Kim@arasystem.it.kr')
await save(wrapper)
expect(wrapper.get('#mail-filter-forward-help').text()).toBe('내 주소로는 전달할 수 없습니다.')
expect(puts()).toEqual([])
expect(toasts()).toEqual([])
await wrapper.get('#mail-filter-forward-to').setValue('doyun.lee@arasystem.it.kr')
expect(wrapper.get('#mail-filter-forward-help').text()).toBe(
'사내 주소로만 전달할 수 있습니다. 전달해도 내 메일함에 그대로 남습니다.',
)
await save(wrapper)
const added = puts()[0]?.rules.at(-1)
expect(puts()[0]?.rules).toHaveLength(6)
expect(added).toMatchObject({
name: '',
isEnabled: true,
match: 'all',
conditions: [{ field: 'from', operator: 'contains', value: '견적서' }],
actions: { moveToMailboxId: null, markAsRead: false, flag: false, forwardTo: 'doyun.lee@arasystem.it.kr' },
})
expect(added?.id).toMatch(/^[0-9a-f-]{36}$/)
expect(isOpen(wrapper)).toBe(false)
expect(toasts()).toEqual(['필터를 저장했습니다.'])
})
it('조건 줄 — [조건 더하기]는 5개까지(새 줄은 제목), 하나만 남으면 ✕ 를 끈다 · 모든 메일이면 조건을 보내지 않는다', async () => {
const { wrapper } = await mountPage()
await click(wrapper.get('[data-filter-create]'))
for (let count = 1; count < 5; count += 1) await wrapper.get('#mail-filter-add-condition').trigger('click')
await flushPromises()
const fields = wrapper.findAll('[data-filter-condition-field]')
expect(fields.map((field) => (field.element as HTMLSelectElement).value)).toEqual([
'from',
'subject',
'subject',
'subject',
'subject',
])
expect(document.activeElement).toBe(fields[4]?.element)
expect(wrapper.get('#mail-filter-add-condition').attributes('disabled')).toBeDefined()
expect(wrapper.get('[data-filter-condition-value]').attributes('placeholder')).toBe(
'예: @hanbit-partners.co.kr 또는 이름',
)
for (let count = 0; count < 4; count += 1) await wrapper.get('[data-filter-condition-remove]').trigger('click')
await flushPromises()
expect(wrapper.findAll('[data-filter-condition-value]')).toHaveLength(1)
expect(wrapper.get('[data-filter-condition-remove]').attributes('disabled')).toBeDefined()
await wrapper.get('#mail-filter-match').setValue('always')
expect(wrapper.find('[data-filter-condition-value]').exists()).toBe(false)
await wrapper.get('#mail-filter-flag').setValue(true)
await save(wrapper)
expect(puts()[0]?.rules.at(-1)).toMatchObject({ match: 'always', conditions: [], actions: { flag: true } })
})
it('옮길 메일함 — 받은편지함 · 내 메일함 · 보관함 · 스팸메일함 · 휴지통, 지워진 메일함은 "(지워진 메일함)"이 골라진 채로', async () => {
const { wrapper } = await mountPage()
await openRule(wrapper, 'r5')
const select = wrapper.get('#mail-filter-move-target')
const options = select.findAll('option')
expect(options.map((option) => option.text())).toEqual([
'(지워진 메일함) — 다른 메일함을 고르세요',
'받은편지함',
'거래처',
'전체 공지',
'보관함',
'스팸메일함',
'휴지통',
])
expect(options[0]?.attributes('disabled')).toBeDefined()
expect((select.element as HTMLSelectElement).value).toBe('gone')
await select.setValue('junk')
await save(wrapper)
expect(puts()[0]?.rules[4]?.actions.moveToMailboxId).toBe('junk')
expect(wrapper.find('[data-filter-missing]').exists()).toBe(false)
})
it('서버의 거절(MAIL_013 · VAL_001)은 창 안에 사전 문구로 — 알림 없이 창을 그대로 두고, 고치면 내린다', async () => {
const { wrapper } = await mountPage({ save: () => fail(400, 'MAIL_013') })
await openRule(wrapper, 'r3')
await save(wrapper)
expect(isOpen(wrapper)).toBe(true)
expect(wrapper.get('#mail-filter-error').text()).toBe(ERROR_CODE_MESSAGES.MAIL_013)
expect(toasts()).toEqual([])
await wrapper.get('#mail-filter-name').setValue('결재')
expect(wrapper.find('#mail-filter-error').exists()).toBe(false)
})
it('저장하는 동안 [저장] · [이 필터 지우기]는 aria-disabled — 다시 눌러도 보내지 않는다', async () => {
const reply = deferred()
const { wrapper } = await mountPage({ save: () => reply.promise })
await openRule(wrapper, 'r3')
await save(wrapper)
expect(wrapper.get('#mail-filter-save').attributes()).toMatchObject({ 'aria-disabled': 'true', 'aria-busy': 'true' })
expect(wrapper.get('#mail-filter-remove').attributes('aria-disabled')).toBe('true')
await save(wrapper)
await click(wrapper.get('#mail-filter-remove'))
expect(puts()).toHaveLength(1)
reply.finish(echo(requests[requests.length - 1] as MockRequest))
await flushPromises()
expect(isOpen(wrapper)).toBe(false)
})
it('그사이 다른 곳에서 바뀜(409 MAIL_012) — 알림 없이 목록 위 줄 + 창 안 문구, 창은 닫지 않는다 · [다시 불러오기]는 새로 받고 창을 닫는다', async () => {
let filters: MailFilterSet = SET
const { wrapper } = await mountPage({
get filters() {
return filters
},
save: () => fail(409, 'MAIL_012'),
})
await openRule(wrapper, 'r2')
await wrapper.get('#mail-filter-name').setValue('고친 이름')
await save(wrapper)
expect(isOpen(wrapper)).toBe(true)
expect((wrapper.get('#mail-filter-name').element as HTMLInputElement).value).toBe('고친 이름')
expect(wrapper.get('#mail-filter-error').text()).toBe(ERROR_CODE_MESSAGES.MAIL_012)
const bar = wrapper.get('[data-filter-conflict]')
expect(bar.attributes('role')).toBe('alert')
expect(bar.text()).toContain('다른 곳에서 필터가 바뀌어 저장하지 않았습니다. 다시 불러온 뒤 고쳐 주세요.')
expect(toasts()).toEqual([])
filters = { rules: [RULES[2] as MailFilterRule], version: 'v9', isActive: true }
await click(bar.get('button'))
expect(wrapper.find('[data-filter-conflict]').exists()).toBe(false)
expect(isOpen(wrapper)).toBe(false)
expect(rows(wrapper).map((item) => item.attributes('data-filter-rule'))).toEqual(['r3'])
})
interface ConflictServer {
filters: MailFilterSet | MockResult
conflict: boolean
}
/** 고치기 창에서 409 를 받은 상태 — 창 안의 [다시 불러오기]를 누르기 직전 */
async function conflictInDialog(server: ConflictServer) {
const { wrapper } = await mountPage({
get filters() {
return server.filters
},
save: (request) => (server.conflict ? fail(409, 'MAIL_012') : echo(request)),
})
await openRule(wrapper, 'r2')
await wrapper.get('#mail-filter-name').setValue('고친 이름')
await save(wrapper)
expect(wrapper.get('#mail-filter-error').text()).toBe(ERROR_CODE_MESSAGES.MAIL_012)
return wrapper
}
it('그사이 바뀜 — 창 안의 [다시 불러오기]는 목록만 다시 받고 창 · 쓴 것은 그대로, 초점은 [저장] · 다음 [저장]은 다시 받은 목록 위에', async () => {
const server: ConflictServer = { filters: SET, conflict: true }
const wrapper = await conflictInDialog(server)
const reload = wrapper.get('#mail-filter-reload')
expect(reload.text()).toBe('다시 불러오기')
expect(reload.attributes('aria-describedby')).toBe('mail-filter-error')
// 칸을 고쳐도 그사이 바뀜 안내는 남는다 — 다시 받아야 풀린다
await wrapper.get('#mail-filter-name').setValue('고친 이름 2')
expect(wrapper.get('#mail-filter-error').text()).toBe(ERROR_CODE_MESSAGES.MAIL_012)
// 다른 곳에서 r2 를 끄고 r1 을 지웠다
const others = [{ ...(RULES[1] as MailFilterRule), isEnabled: false, name: '다른 곳' }, ...RULES.slice(2)]
server.filters = { rules: others, version: 'v7', isActive: true }
server.conflict = false
await click(reload)
expect(isOpen(wrapper)).toBe(true)
expect((wrapper.get('#mail-filter-name').element as HTMLInputElement).value).toBe('고친 이름 2')
expect(wrapper.find('#mail-filter-error').exists()).toBe(false)
expect(wrapper.find('[data-filter-conflict]').exists()).toBe(false)
expect(document.activeElement?.id).toBe('mail-filter-save')
expect(rows(wrapper).map((item) => item.attributes('data-filter-rule'))).toEqual(['r2', 'r3', 'r4', 'r5'])
expect(toasts()).toEqual([])
await save(wrapper)
// 같은 id 자리에서 바꾸고, 켜짐은 다시 받은 목록의 값(꺼짐)이다 — 창은 켜기를 고치지 않는다
expect(puts().at(-1)).toEqual({
version: 'v7',
rules: [{ ...RULES[1], name: '고친 이름 2', isEnabled: false }, ...RULES.slice(2)],
})
expect(isOpen(wrapper)).toBe(false)
expect(toasts()).toEqual(['필터를 저장했습니다.'])
})
it('다시 받은 목록에서 그 규칙이 다른 곳에서 지워졌으면 맨 아래에 넣는다', async () => {
const server: ConflictServer = { filters: SET, conflict: true }
const wrapper = await conflictInDialog(server)
server.filters = { rules: [RULES[0] as MailFilterRule, RULES[2] as MailFilterRule], version: 'v7', isActive: true }
server.conflict = false
await click(wrapper.get('#mail-filter-reload'))
await save(wrapper)
expect(puts().at(-1)?.version).toBe('v7')
expect(puts().at(-1)?.rules.map((rule) => [rule.id, rule.name])).toEqual([
['r1', '휴가 중 전달'],
['r3', '결재 알림'],
['r2', '고친 이름'],
])
})
it('지워진 규칙을 맨 아래에 넣으면 50개를 넘을 때 — 보내지 않고 창 안에 "필터는 50개까지 …" (알림 없음)', async () => {
const server: ConflictServer = { filters: SET, conflict: true }
const wrapper = await conflictInDialog(server)
const fifty = Array.from({ length: 50 }, (_, index) => ({ ...(RULES[2] as MailFilterRule), id: `f${index}` }))
server.filters = { rules: fifty, version: 'v7', isActive: true }
server.conflict = false
await click(wrapper.get('#mail-filter-reload'))
await save(wrapper)
expect(puts()).toHaveLength(1)
expect(isOpen(wrapper)).toBe(true)
expect(wrapper.get('#mail-filter-error').text()).toBe('필터는 50개까지 만들 수 있습니다. 쓰지 않는 필터를 지워 주세요.')
expect(wrapper.find('#mail-filter-reload').exists()).toBe(false)
expect(toasts()).toEqual([])
})
it('창 안의 [다시 불러오기]를 실패하면 인터셉터 알림만 — 창과 안내 · 단추는 그대로', async () => {
const server: ConflictServer = { filters: SET, conflict: true }
const wrapper = await conflictInDialog(server)
// 같은 코드의 알림은 3초 안에 다시 띄우지 않는다(useApi) — 앞 테스트의 MAIL_001 과 섞이지 않게 다른 코드로
server.filters = fail(500, 'SYS_001')
await click(wrapper.get('#mail-filter-reload'))
expect(toasts()).toEqual([ERROR_CODE_MESSAGES.SYS_001])
expect(isOpen(wrapper)).toBe(true)
expect(wrapper.get('#mail-filter-error').text()).toBe(ERROR_CODE_MESSAGES.MAIL_012)
expect(wrapper.find('#mail-filter-reload').exists()).toBe(true)
expect(wrapper.find('[data-filter-conflict]').exists()).toBe(true)
})
it('전달 칸 — 비우면 "전달할 주소를 입력해 주세요.", 형식이 틀리면 "전달할 주소 형식이 올바르지 않습니다." (칸 아래 · 알림 없음)', async () => {
const { wrapper } = await mountPage()
await click(wrapper.get('[data-filter-create]'))
await wrapper.get('[data-filter-condition-value]').setValue('견적')
await wrapper.get('#mail-filter-forward').setValue(true)
await save(wrapper)
expect(wrapper.get('#mail-filter-forward-help').text()).toBe('전달할 주소를 입력해 주세요.')
expect(wrapper.get('#mail-filter-forward-to').attributes('aria-invalid')).toBe('true')
expect(document.activeElement?.id).toBe('mail-filter-forward-to')
await wrapper.get('#mail-filter-forward-to').setValue('doyun..lee@arasystem.it.kr')
await save(wrapper)
expect(wrapper.get('#mail-filter-forward-help').text()).toBe('전달할 주소 형식이 올바르지 않습니다.')
expect(puts()).toEqual([])
expect(toasts()).toEqual([])
})
it('받는 사람 칸 · 전달 칸의 보기는 로그인한 직원 주소의 도메인으로', async () => {
useAuthStore().startSession({ email: 'mina.choi@ara.test', name: '최미나' })
const { wrapper } = await mountPage()
await click(wrapper.get('[data-filter-create]'))
await wrapper.get('[data-filter-condition-field]').setValue('to')
expect(wrapper.get('[data-filter-condition-value]').attributes('placeholder')).toBe('예: all@ara.test')
expect(wrapper.get('#mail-filter-forward-to').attributes('placeholder')).toBe('이름@ara.test')
})
it('지우기 — 확인 없이 지우고 알림 [되돌리기], 초점은 다음 행 · 되돌리면 지운 자리에 다시 저장한다', async () => {
const { wrapper } = await mountPage()
await openRule(wrapper, 'r2')
await click(wrapper.get('#mail-filter-remove'))
expect(puts()[0]?.rules.map((rule) => rule.id)).toEqual(['r1', 'r3', 'r4', 'r5'])
expect(isOpen(wrapper)).toBe(false)
expect(rows(wrapper)).toHaveLength(4)
expect(document.activeElement).toBe(row(wrapper, 'r3').get('[data-filter-edit]').element)
const [toast] = useToastStore().toasts
expect(toast?.message).toBe('‘거래처 메일’ 필터를 지웠습니다.')
expect(toast?.action?.label).toBe('되돌리기')
if (toast?.action && 'run' in toast.action) toast.action.run()
await flushPromises()
expect(puts()[1]).toEqual({ version: 'v1+', rules: RULES })
expect(rows(wrapper).map((item) => item.attributes('data-filter-rule'))).toEqual(['r1', 'r2', 'r3', 'r4', 'r5'])
expect(toasts().at(-1)).toBe('필터를 되돌렸습니다.')
})
it('50개를 다 만들었으면 [새 필터]를 끄고 까닭을 옆에 — 눌러도 열지 않는다', async () => {
const fifty = Array.from({ length: 50 }, (_, index) => ({ ...(RULES[2] as MailFilterRule), id: `f${index}` }))
const { wrapper } = await mountPage({ filters: { rules: fifty, version: 'v1', isActive: true } })
const create = wrapper.get('[data-filter-create]')
expect(create.attributes()).toMatchObject({ 'aria-disabled': 'true', 'aria-describedby': 'mail-filter-full-note' })
expect(wrapper.get('#mail-filter-full-note').text()).toBe('필터는 50개까지 만들 수 있습니다. 쓰지 않는 필터를 지워 주세요.')
await click(create)
expect(isOpen(wrapper)).toBe(false)
})
it('동작하지 않을 때(isActive: false) — 목록 위 줄 + [다시 켜기]는 지금 목록 그대로 저장한다', async () => {
const { wrapper } = await mountPage({ filters: { ...SET, isActive: false } })
const bar = wrapper.get('[data-filter-inactive]')
expect(bar.text()).toContain('지금은 필터와 부재중 자동 응답이 동작하지 않습니다. 메일 서버의 규칙이 다른 곳에서 바뀌었습니다.')
await click(bar.get('button'))
expect(puts()).toEqual([{ rules: RULES, version: 'v1' }])
expect(wrapper.find('[data-filter-inactive]').exists()).toBe(false)
// 누른 단추가 사라졌다 — 머리의 [새 필터]로
expect(document.activeElement).toBe(wrapper.get('[data-filter-create]').element)
})
it('읽기 화면에서 — ?from= 이면 새 필터 창이 "보낸 사람 · 포함 · 그 주소"로 열리고 초점은 할 일의 첫 칸, 주소에서 지운다', async () => {
const { wrapper, router } = await mountPage({}, '/settings/mail/filters?from=seojun.park%40arasystem.it.kr')
expect(isOpen(wrapper)).toBe(true)
expect(wrapper.get('#mail-filter-dialog-title').text()).toBe('새 필터')
expect((wrapper.get('[data-filter-condition-field]').element as HTMLSelectElement).value).toBe('from')
expect((wrapper.get('[data-filter-condition-operator]').element as HTMLSelectElement).value).toBe('contains')
expect(conditionValues(wrapper)).toEqual(['seojun.park@arasystem.it.kr'])
expect(document.activeElement?.id).toBe('mail-filter-move')
expect(router.currentRoute.value.fullPath).toBe('/settings/mail/filters')
// 취소하면 — 창을 연 단추가 없다(주소로 왔다) · 머리의 [새 필터]로
await click(wrapper.get('#mail-filter-cancel'))
expect(isOpen(wrapper)).toBe(false)
expect(document.activeElement).toBe(wrapper.get('[data-filter-create]').element)
})
it('?from= 이어도 50개를 다 만들었으면 창을 열지 않는다 (주소에서는 지운다)', async () => {
const fifty = Array.from({ length: 50 }, (_, index) => ({ ...(RULES[2] as MailFilterRule), id: `f${index}` }))
const { wrapper, router } = await mountPage(
{ filters: { rules: fifty, version: 'v1', isActive: true } },
'/settings/mail/filters?from=seojun.park%40arasystem.it.kr',
)
expect(isOpen(wrapper)).toBe(false)
expect(router.currentRoute.value.query).toEqual({})
})
it('읽기 화면 ⋯ 「이런 메일로 필터 만들기」를 누르면 — 설정 › 필터의 새 필터 창이 그 메일의 보낸 사람 주소로 채워져 열린다', async () => {
installServer()
const message: MessageDetail = {
id: 'm1',
threadId: 't1',
mailboxIds: ['inbox'],
from: { name: '박서준', email: 'seojun.park@arasystem.it.kr' },
to: [{ name: '김아라', email: USER.email }],
subject: '견적서 보내 드립니다',
preview: '',
receivedAt: '2026-10-28T01:42:00Z',
isRead: true,
isFlagged: false,
hasAttachment: false,
size: 100,
cc: [],
bcc: [],
replyTo: [],
sentAt: null,
messageId: null,
references: [],
htmlBody: null,
textBody: '본문',
hasBlockedImages: false,
attachments: [],
}
const mailStore = useMailStore()
mailStore.selected = message
mailStore.selectedId = message.id
mailStore.selectedStatus = 'ready'
// 앱과 같은 이름의 라우트 — 읽기 화면과 필터 화면만 실제 화면으로 (같은 이름을 다시 더하면 바꿔 낀다)
const router = createTestRouter()
router.addRoute({ path: '/mail/:mailboxId/:messageId', name: 'mail-message', component: MessageReader })
router.addRoute({ path: '/settings/mail/filters', name: 'settings-mail-filters', component: MailFilterPage })
await router.push('/mail/inbox/m1')
await router.isReady()
const app = mount({ render: () => h(RouterView) }, { attachTo: document.body, global: { plugins: [pinia, router] } })
mounted.push(app)
await app.get('[data-reader-more]').trigger('keydown', { key: 'ArrowDown' })
await flushPromises()
const item = app.findAll('[role="menuitem"]').find((menuItem) => menuItem.text() === '이런 메일로 필터 만들기')
await item?.trigger('click')
await flushPromises()
expect(app.get('h1').text()).toBe('필터')
expect(app.get('#mail-filter-dialog').attributes('open')).toBeDefined()
expect(app.get('#mail-filter-dialog-title').text()).toBe('새 필터')
expect(
app.findAll('[data-filter-condition-value]').map((input) => (input.element as HTMLInputElement).value),
).toEqual(['seojun.park@arasystem.it.kr'])
expect((app.get('#mail-filter-move').element as HTMLInputElement).checked).toBe(false)
expect(document.activeElement?.id).toBe('mail-filter-move')
expect(router.currentRoute.value.fullPath).toBe('/settings/mail/filters')
})
})
@@ -0,0 +1,330 @@
<script setup lang="ts">
// 설정 › 메일 › 필터 (디자인 원본 SettingsFilter · MailFilterStates · README "6단계 — 메일 필터") — /settings/mail/filters · 탭 제목 "메일 필터"
// 머리 "필터" + 설명 + 오른쪽 [새 필터](50개면 끄고 까닭을 옆에) → 목록 위 노란 줄(다른 곳에서 바뀜 · 동작하지 않음) →
// 목록(MailFilterList — 없으면 MailFilterEmpty) + "필터 N개 · 50개까지 …" → 안내 상자(MailFilterNotes). 행 · [새 필터]는 고치기 창(MailFilterDialog)을 연다.
// - 들어올 때마다 필터를 새로 받는다(다른 탭 · PC 에서 바꿨을 수 있다). 메일함은 메일 화면이 받아 둔 목록이 있으면 그대로 쓰며 다시 맞추고
// (다른 탭에서 지웠을 수 있다) 없으면 받는다 — 옮길 메일함의 이름 · 지워진 메일함 안내에 쓴다. 둘 다 받아야 목록을 그린다
// (받는 동안은 자리만, 못 받으면 사전 문구와 "다시 시도")
// - 켜기 · 순서는 화면을 먼저 바꾸고 실패하면 되돌린다(스토어 — 오류 알림은 인터셉터). 창의 저장 · 지우기는 useMailFilterDialog, 그 알림은 useMailFilterActions
// - 409 MAIL_012 는 알리지 않고 목록 위 줄 + [다시 불러오기](목록을 새로 받고 창을 닫는다). 고치던 창은 닫지 않고, 창 안에도
// 같은 사전 문구와 [다시 불러오기]를 둔다(창이 모달이라 뒤의 줄을 누를 수 없다) — 창 안의 것은 창 · 쓴 내용을 그대로 두고 목록만 다시 받는다.
// 받으면 초점을 [저장]으로 옮기고, 다음 [저장]은 다시 받은 목록 위에 넣는다
// - 읽기 화면 ⋯ 「이런 메일로 필터 만들기」는 ?from=<보낸 사람 주소> 로 온다 — 받은 뒤 새 필터 창을 그 주소로 채워 열고(50개면 열지 않는다)
// 주소에서 지운다(replace — 새로 고침에 또 열리지 않게)
// - 누른 단추가 사라져 초점을 잃으면(지운 행 · 사라진 줄 · 빈 상태의 [새 필터]) 가까운 행이나 머리의 [새 필터]로 옮긴다
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import MailLoadError from '@/components/mail/MailLoadError.vue'
import MailFilterCreateButton from '@/components/settings/MailFilterCreateButton.vue'
import MailFilterDialog from '@/components/settings/MailFilterDialog.vue'
import MailFilterEmpty from '@/components/settings/MailFilterEmpty.vue'
import MailFilterList from '@/components/settings/MailFilterList.vue'
import MailFilterNotes from '@/components/settings/MailFilterNotes.vue'
import MailFilterNotice from '@/components/settings/MailFilterNotice.vue'
import SettingsPageFrame from '@/components/settings/SettingsPageFrame.vue'
import type { MailFilterRowPart } from '@/components/settings/mail-filter-ui'
import { getErrorMessage, type ApiError } from '@/composables/useApi'
import { useMailFilterDialog, type MailFilterDialogResult } from '@/composables/useMailFilterDialog'
import { MAIL_FILTER_FROM_QUERY, MAIL_FILTER_TEXT } from '@/shared/constants/mail-filter'
import type { MailFilterRule } from '@/shared/types/mail'
import { formatCount } from '@/shared/utils/format'
import { useAuthStore } from '@/stores/auth.store'
import { useMailFilterStore } from '@/stores/mail-filter.store'
import { useMailStore } from '@/stores/mail.store'
const filterStore = useMailFilterStore()
const mailStore = useMailStore()
const authStore = useAuthStore()
const route = useRoute()
const router = useRouter()
const {
state: dialog,
pending: dialogPending,
error: dialogError,
conflict: dialogConflict,
open: openDialog,
close: closeDialog,
clearError: clearDialogError,
save: saveDialog,
remove: removeDialog,
reload: reloadDialog,
} = useMailFilterDialog()
const createRef = ref<InstanceType<typeof MailFilterCreateButton> | null>(null)
const listRef = ref<InstanceType<typeof MailFilterList> | null>(null)
const dialogRef = ref<InstanceType<typeof MailFilterDialog> | null>(null)
/** 이 화면이 떠 있다고 스토어에 알린 것을 내린다 (화면 밖 되돌리기의 실패 알림 — useMailFilterActions) */
let detachPage: (() => void) | null = null
/** 목록 위 줄의 단추를 누르고 기다리는 중 */
const noticeBusy = ref<'reload' | 'reactivate' | null>(null)
/** ?from= 을 이미 받아 창을 열었다 — 주소에서 지울 때까지 다시 열지 않는다 */
let fromHandled = false
const rules = computed<readonly MailFilterRule[]>(() => filterStore.filterSet?.rules ?? [])
const ready = computed(
() => filterStore.status === 'ready' && filterStore.filterSet !== null && mailStore.mailboxesStatus === 'ready',
)
const loadError = computed<ApiError | null>(() => {
if (filterStore.status === 'error' && filterStore.error) return filterStore.error
if (mailStore.mailboxesStatus === 'error' && mailStore.mailboxesError) return mailStore.mailboxesError
return null
})
const full = computed(() => ready.value && filterStore.isFull)
const countText = computed(() => MAIL_FILTER_TEXT.count(formatCount(rules.value.length)))
const conflictText = getErrorMessage('MAIL_012')
/** 메일함 — 받아 둔 목록이 있으면 그대로 보이며 다시 맞춘다(다른 탭에서 지웠을 수 있다), 없으면 받는다 */
function loadMailboxes() {
if (mailStore.mailboxesStatus === 'ready') void mailStore.loadMailboxes({ background: true })
else void mailStore.ensureMailboxes()
}
function retry() {
if (filterStore.status === 'error') void filterStore.load()
if (mailStore.mailboxesStatus === 'error') void mailStore.ensureMailboxes()
}
/** 초점을 잃었다 — 본문으로 갔거나, 닫힌 창 안에 남았다(창을 연 단추가 사라져 돌아갈 곳이 없을 때) */
function focusLost(): boolean {
const active = document.activeElement
if (active === null || active === document.body) return true
const dialogElement = active.closest('dialog')
return dialogElement !== null && !dialogElement.hasAttribute('open')
}
/** 누른 단추가 사라져 초점을 잃었으면 — 그 규칙 행의 단추, 없으면 머리의 [새 필터]로 */
async function restoreFocus(ruleId: string | null, part: MailFilterRowPart = 'edit') {
await nextTick()
if (!focusLost()) return
if (ruleId !== null && listRef.value?.focusRule(ruleId, part)) return
createRef.value?.focus()
}
function openNew() {
if (ready.value && !filterStore.isFull) openDialog(null)
}
function cancelDialog() {
const ruleId = dialog.value?.rule?.id ?? null
closeDialog()
void restoreFocus(ruleId)
}
/** 창의 저장 · 지우기가 끝나 창을 닫았으면 — 초점이 사라진 단추에 남지 않게 */
async function settleDialog(result: Promise<MailFilterDialogResult>) {
const { closed, focusRuleId } = await result
if (closed) void restoreFocus(focusRuleId)
}
function toggleRule(rule: MailFilterRule) {
void filterStore.setRuleEnabled(rule.id, !rule.isEnabled)
}
/** 순서 바꾸기 — 행을 옮기면(실패해서 되돌릴 때도) 누른 ▲ · ▼ 가 초점을 잃을 수 있어 같은 단추로 돌려준다 */
async function moveRule(rule: MailFilterRule, offset: -1 | 1) {
const part: MailFilterRowPart = offset < 0 ? 'up' : 'down'
const moving = filterStore.moveRule(rule.id, offset)
void restoreFocus(rule.id, part)
if (!(await moving)) void restoreFocus(rule.id, part)
}
/** 창 안의 [다시 불러오기] — 창 · 쓴 내용은 그대로 두고 목록을 다시 받는다. 받았으면 [저장]으로 */
async function reloadInDialog() {
if (await reloadDialog()) {
await nextTick()
dialogRef.value?.focusSave()
}
}
/** 목록 위 줄의 [다시 불러오기] — 목록을 새로 받고 고치기 창을 닫는다 */
async function reload() {
if (noticeBusy.value) return
noticeBusy.value = 'reload'
const done = await filterStore.reload()
noticeBusy.value = null
if (!done) return
closeDialog()
void restoreFocus(null)
}
/** [다시 켜기] — 지금 목록 그대로 저장한다 */
async function reactivate() {
if (noticeBusy.value || filterStore.saving) return
noticeBusy.value = 'reactivate'
await filterStore.reactivate()
noticeBusy.value = null
void restoreFocus(null)
}
// 처음 그리기 전에 — 지난번에 받아 둔 목록을 잠깐 그렸다 바꾸지 않게
void filterStore.load()
loadMailboxes()
onMounted(() => {
detachPage = filterStore.attachPage()
})
onBeforeUnmount(() => {
detachPage?.()
})
// 읽기 화면 ⋯ 「이런 메일로 필터 만들기」(?from=<주소>) — 받은 뒤 한 번 새 필터 창을 열고(50개면 열지 않는다) 주소에서 지운다
watch(
[ready, () => route.query[MAIL_FILTER_FROM_QUERY]],
([isReady, from]) => {
if (from === undefined) {
fromHandled = false
return
}
if (!isReady || fromHandled) return
fromHandled = true
const address = typeof from === 'string' ? from.trim() : ''
if (address && !filterStore.isFull) openDialog(null, address)
const query = Object.fromEntries(Object.entries(route.query).filter(([key]) => key !== MAIL_FILTER_FROM_QUERY))
void router.replace({ query })
},
{ immediate: true },
)
</script>
<template>
<SettingsPageFrame
title-id="filter-page-title"
:title="MAIL_FILTER_TEXT.title"
:description="MAIL_FILTER_TEXT.description"
>
<template #actions>
<div class="filter-page__create">
<MailFilterCreateButton
ref="createRef"
:disabled="!ready || full"
:described-by="full ? 'mail-filter-full-note' : undefined"
@create="openNew"
/>
<p
v-if="full"
id="mail-filter-full-note"
class="filter-page__full-note"
>
{{ MAIL_FILTER_TEXT.fullNote }}
</p>
</div>
</template>
<MailLoadError
v-if="!ready && loadError"
:error="loadError"
leave-label=""
@retry="retry"
/>
<template v-else-if="ready">
<MailFilterNotice
v-if="filterStore.conflict"
icon="rotate-cw"
:message="conflictText"
:action-label="MAIL_FILTER_TEXT.reload"
:busy="noticeBusy === 'reload'"
live="alert"
data-filter-conflict
@action="reload"
/>
<MailFilterNotice
v-if="filterStore.filterSet?.isActive === false"
icon="alert-circle"
:message="MAIL_FILTER_TEXT.inactive"
:action-label="MAIL_FILTER_TEXT.reactivate"
:busy="noticeBusy === 'reactivate' || filterStore.saving"
data-filter-inactive
@action="reactivate"
/>
<div
v-if="rules.length > 0"
class="filter-page__list"
>
<MailFilterList
ref="listRef"
:rules="rules"
:mailboxes="mailStore.mailboxes"
:busy="filterStore.saving"
@toggle="toggleRule"
@move="moveRule"
@edit="(rule) => openDialog(rule)"
/>
<p
class="filter-page__count"
data-filter-count
>
{{ countText }}
</p>
</div>
<MailFilterEmpty
v-else
@create="openNew"
/>
<MailFilterNotes />
<MailFilterDialog
ref="dialogRef"
:open="dialog !== null"
:rule="dialog?.rule ?? null"
:prefill-from="dialog?.prefillFrom ?? null"
:mailboxes="mailStore.mailboxes"
:user-email="authStore.user?.email ?? ''"
:pending="dialogPending"
:busy="filterStore.saving"
:error="dialogError"
:conflict="dialogConflict"
@save="(rule) => settleDialog(saveDialog(rule))"
@remove="settleDialog(removeDialog())"
@cancel="cancelDialog"
@clear-error="clearDialogError"
@reload="reloadInDialog"
/>
</template>
<div
v-else
class="filter-page__placeholder"
aria-busy="true"
/>
</SettingsPageFrame>
</template>
<style scoped>
/* [새 필터] + 50개를 다 만들었을 때의 까닭 (디자인 원본 MailFilterStates) */
.filter-page__create {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--space-10) var(--space-14);
}
.filter-page__full-note {
max-width: 17.5rem;
margin: 0;
font-size: var(--font-size-label);
color: var(--color-text-muted);
}
.filter-page__list {
display: flex;
flex-direction: column;
gap: var(--space-8);
}
.filter-page__count {
margin: 0;
font-size: var(--font-size-label);
color: var(--color-text-muted);
}
/* 받는 동안의 자리 — 목록 몇 줄만큼 */
.filter-page__placeholder {
min-height: 15rem;
border-radius: var(--radius-lg);
background: var(--color-border-subtle);
}
</style>

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