generated from ttipo/project-base
Compare commits
10 Commits
181d2a4cbf
...
a39f8bd630
| Author | SHA1 | Date | |
|---|---|---|---|
| a39f8bd630 | |||
| 894550732e | |||
| 7ce234750d | |||
| b4d30482fe | |||
| 41b5fdbe22 | |||
| b6aa73c611 | |||
| 4d95fc28d8 | |||
| 3634cee955 | |||
| 19c20eb617 | |||
| ac418e3aa9 |
@@ -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`). 평범한 해시는 받은 사람이 후보 값(숨은참조 주소 등)을 대입해 맞춰 볼 수 있다.
|
||||
|
||||
@@ -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 가 없으면 바로 실패한다.
|
||||
|
||||
@@ -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` 로 로드).
|
||||
|
||||
@@ -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]`) |
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -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 안에 끝나지 않아 먼저 답합니다',
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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>
|
||||
@@ -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`).
|
||||
- **하지 않은 것**: 고치기 창에 쓰다 만 채로 떠날 때 묻기(나가기 확인) — 보드에 없다. 창을 연 채 브라우저 뒤로 가기를 하면 쓴 것을 잃는다(알려진 한계).
|
||||
|
||||
@@ -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>
|
||||
@@ -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
@@ -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'
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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' })
|
||||
})
|
||||
})
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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())
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user