generated from ttipo/project-base
Compare commits
5 Commits
fe8a99e728
...
ca0e489ab1
| Author | SHA1 | Date | |
|---|---|---|---|
| ca0e489ab1 | |||
| ff6f757fa4 | |||
| 5710151c0b | |||
| a79f0791d0 | |||
| 1868665592 |
@@ -33,6 +33,8 @@
|
||||
- 비밀번호, 토큰(JWT·리프레시·API 키), 쿠키, `Authorization` 헤더
|
||||
- 주민등록번호·카드번호·계좌번호 등 개인 식별/금융 정보
|
||||
- 요청 본문 전체 덤프 (`JSON.stringify(req.body)` 금지 — 필요한 필드만 선택해 남긴다)
|
||||
- 사용자가 친 검색어(쿼리의 `q` — 메일 검색 · 받는 사람 추천) — 요청 URL 을 남길 때는 `shared/utils/mask.ts` 의 `maskUrlForLog()` 를 거친다(`q` 는 길이만).
|
||||
앞단 nginx 도 `/api` 접근 로그에 쿼리를 남기지 않는다 ([[docker]])
|
||||
|
||||
```typescript
|
||||
// ❌ 금지
|
||||
|
||||
@@ -68,15 +68,19 @@ sudo docker compose --env-file .env.production logs -f backend
|
||||
- `frontend/nginx.conf` 는 **템플릿**이다. 컨테이너가 뜰 때 이미지 entrypoint 가 `frontend/nginx-env.envsh` 로 값을 만들고
|
||||
`${NGINX_*}` 만 envsubst 로 채운다 (nginx 의 `$uri` 등은 그대로). 값은 루트 env 에서 온다:
|
||||
- 본문 한도: `/api/mail-attachments` = `MAIL_ATTACHMENT_MAX_SIZE_MB` + 1MB(기본 21m), `/api/files` = `UPLOAD_MAX_SIZE_MB` + 1MB(기본 6m),
|
||||
**메일 발송 `POST /api/messages` = 4m 고정**(서식 본문·긴 인용 — 계약 2절, 백엔드도 이 경로만 4MB 파서. 정확히 이 경로만이라 `/api/messages/{id}` 는 1m),
|
||||
**메일 발송 `POST /api/messages` 와 임시 저장 `/api/drafts` = 4m 고정**(서식 본문·긴 인용 — 계약 2절, 백엔드도 이 요청들만 4MB 파서.
|
||||
발송은 정확히 이 경로만이라 `/api/messages/{id}` 는 1m. 임시 저장은 `/api/drafts` 아래 전부 — 본문이 있는 것은 `POST`·`PUT` 뿐이다),
|
||||
그 밖은 1m(백엔드 JSON 파서 한도). 앞의 두 값은 루트 env 한 곳만 고치면 백엔드와 nginx 가 함께 바뀐다 (frontend 컨테이너만 다시 만들면 된다).
|
||||
JSON 한도(1m·4m)는 계약과 백엔드 코드의 고정값이라 env 로 바꾸지 않는다 — 바꾸려면 계약·백엔드 파서·`frontend/nginx.conf` 를 함께 고친다.
|
||||
- 시간 제한: `/api/messages` 90s, `/api/mail-attachments` 150s (`proxy_read_timeout`).
|
||||
- 시간 제한: `/api/messages`·`/api/drafts` 90s, `/api/mail-attachments` 150s (`proxy_read_timeout`).
|
||||
- 캐시: `index.html`(직접 요청·`/`·SPA 폴백 모두)은 `Cache-Control: no-cache` — 배포 뒤 옛 index.html 이 지워진 해시 자산을 부르지 않게.
|
||||
`/assets/`(Vite 산출물 — 파일명에 내용 해시)만 `max-age=30일, public, immutable` 이다.
|
||||
해시가 없는 공개 파일(`frontend/public` → 루트, 예: `favicon.ico`)은 같은 이름으로 바뀌므로 `no-cache`(매번 재검증, 안 바뀌면 304).
|
||||
보안 헤더는 어느 경우에도 붙는다(`add_header_inherit merge`).
|
||||
- 헤더: `X-Forwarded-For` 는 이어 붙이고, `X-Request-Id` 는 nginx 의 `$request_id` 로 덮어써 백엔드 traceId 와 nginx 로그 `rid=` 가 같다.
|
||||
- 로그: `/api` 의 접근 로그는 쿼리 없이 남긴다(`log_format api_rid` — 검색어 `q` 등이 요청마다 남지 않게, 오류용 이름 있는 location 에도 다시 지정).
|
||||
오류 로그(413·502·504 등)에는 nginx 가 요청 줄을 쿼리째 남긴다 — 장애 원인 줄을 잃지 않으려고 그대로 둔다. 헤더가 너무 커서 location 을 고르기 전에
|
||||
거부된 요청도 화면용 형식(쿼리 포함)으로 남는다(드문 경우).
|
||||
`Origin`·`Sec-Fetch-Site`·`Idempotency-Key`·`Cookie` 는 그대로 넘긴다. `/api` 응답에는 화면용 보안 헤더를 붙이지 않는다(백엔드 helmet 이 정한다).
|
||||
- 백엔드 주소는 요청마다 도커 DNS 로 다시 풀어(`resolver`), 백엔드를 다시 만들어도 nginx 를 재시작할 필요가 없다.
|
||||
- **nginx 가 직접 끝내는 `/api` 오류도 표준 실패 형식(JSON)이다** — 본문 한도 초과 413 `FILE_002`, 백엔드 응답 없음 502·시간 초과 504
|
||||
@@ -110,7 +114,7 @@ sudo docker compose --env-file .env.production logs -f backend
|
||||
`X-Forwarded-For` 에 원래 IPv6 주소가 그대로 들어가는지 본다.
|
||||
- 업로드 중 임시 디스크: 백엔드는 첨부를 `/tmp/arasystem-mail-uploads`(컨테이너 쓰기 계층)에, nginx 는 요청 본문을 자기 임시 폴더에 받는다.
|
||||
필요한 공간은 동시 업로드 수 × 20MB 정도. backend 를 `read_only` 로 바꾸면 그 경로에 **tmpfs 가 아닌 볼륨**을 붙인다 (tmpfs 는 메모리 한도에 잡힌다).
|
||||
- **업로드 파일은 named volume `<PROJECT_NAME>_uploads` 에 쌓인다** (`/api/files`). 호스트 디렉터리(`./data/uploads`)를 쓰지 않는 이유 —
|
||||
- **업로드 파일은 named volume `<PROJECT_NAME>_uploads` 에 쌓인다** (`/api/files`, 메일 서명 그림은 그 안의 `mail-signatures/` — 서명 표와 함께 백업한다). 호스트 디렉터리(`./data/uploads`)를 쓰지 않는 이유 —
|
||||
`sudo docker compose` 가 없는 디렉터리를 root 소유로 만들어 backend(node, UID 1000)가 쓰지 못한다. 새 named volume 은 이미지의
|
||||
`/app/uploads`(node 소유) 소유자를 물려받는다 (2026-10-27 운영 이미지로 확인). 개발만 `./data/uploads` 바인드 마운트다.
|
||||
백업은 메일 볼륨과 같은 방식: `sudo docker run --rm -v "${PROJECT_NAME}_uploads:/uploads:ro" -v "$PWD/backup:/backup" alpine tar czf "/backup/uploads-$(date +%Y%m%d).tar.gz" -C / uploads`.
|
||||
|
||||
@@ -22,14 +22,16 @@ 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` 만 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 만) · ValidationPipe(`whitelist/forbidNonWhitelisted/transform`) · 필터 · 인터셉터. **main.ts 와 e2e 가 같은 함수를 쓴다** (앱은 `bodyParser: false` 로 생성) |
|
||||
| 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 }` |
|
||||
| 직원 인증 | `modules/auth` | 쿠키 JWT 로그인·`me`·`refresh`·`logout` (계약 `docs/mail/api-contract.md` 1절). 설정 `auth.settings.ts`, 쿠키 `auth-cookies.ts`, 로그인 실패 횟수 `login-attempt.service.ts`, **로그인 세션(DB)** `auth-session.entity.ts`·`auth-session.policy.ts`(1회용 회전·재사용 감지)·`auth-session.cleanup.ts`(끝난 세션 정리 cron). **메일 엔진 관리자 차단** — 정적 목록(`employee-address.ts` + `MAIL_BLOCKED_ACCOUNTS`)과 로그인·리프레시 때 엔진 권한 확인(`jmap/jmap.client.ts`) 두 겹 |
|
||||
| 메일 (웹메일 API) | `modules/mail` | 엔진 포트 `mail.provider.ts`(`MAIL_PROVIDER`) + Stalwart JMAP 어댑터 `jmap/`, 받은 메일 HTML 정화 `html/sanitize-mail-html.ts`, 보내는 서식 본문 정화(허용 목록) `html/sanitize-outgoing-html.ts`, 두 정화 앞의 사전 검사(길이·파서 스택 비용, 선형 시간) `html/html-parse-budget.ts`, 첨부 형식 `attachment.policy.ts`, 설정 `mail.settings.ts`, 발송 중복 방지(`Idempotency-Key`) `send-idempotency.ts`, 첨부 임시 디스크 저장 `mail-upload-storage.ts` (계약 2절) |
|
||||
| 메일 (웹메일 API) | `modules/mail` | 엔진 포트 `mail.provider.ts`(`MAIL_PROVIDER`) + Stalwart JMAP 어댑터 `jmap/`, 받은 메일 HTML 정화 `html/sanitize-mail-html.ts`, 보내는 서식 본문 정화(허용 목록) `html/sanitize-outgoing-html.ts`, 두 정화 앞의 사전 검사(길이·파서 스택 비용, 선형 시간) `html/html-parse-budget.ts`, 첨부 형식 `attachment.policy.ts`, 설정 `mail.settings.ts`, 발송 중복 방지(`Idempotency-Key`) `send-idempotency.ts`, 첨부 임시 디스크 저장 `mail-upload-storage.ts`, 임시 저장 `draft.controller.ts`(엔진 초안 — 새로 만들고 다시 읽은 뒤 옛것을 지운다), 서명 그림 주소 규칙 `html/signature-images.ts`(보낼 때 `cid:` 로), 본문 그림 판정 `html/inline-images.ts`, 받는 사람 추천 `recipient-suggestions.service.ts`(사용자별 10분 캐시) · 순서 `recipient-ranking.ts` · 커서 `recipient-suggestion-cursor.ts` (계약 2절) |
|
||||
| 메일 서명 | `modules/mail-signature` | 사용자마다 서명 하나(DB) + 서명 그림. 그림 형식은 내용으로 판별(매직 바이트·머리의 가로·세로, 새 의존성 없음) `signature-image-format.ts`, 파일은 업로드 볼륨의 `mail-signatures/` (`signature-image-storage.ts`), 서명에 붙지 않은 그림 정리 cron `mail-signature.cleanup.ts`. 메일 모듈이 발송·임시 저장 때 `MailSignatureService` 로 그림을 쓴다 (계약 2절 "서명") |
|
||||
| 사내 목록 | `modules/mail-directory` | 웹메일 로그인에 성공한 직원(관리자·게이트웨이·막은 계정 제외)을 `AuthService` 가 기록 → 받는 사람 추천의 "사내". 엔진의 계정 목록(Principal) 권한은 쓰지 않는다(일부러 막아 두었다) |
|
||||
| Rate Limiting | `app.module.ts` 의 `ClientIpThrottlerGuard`(APP_GUARD, `common/guards/client-ip-throttler.guard.ts`) | 초과 시 429 → `REQ_001`. 루트 `.env` 의 `THROTTLE_*` 로 조정. `ThrottlerGuard` 를 상속해 tracker 만 바꿨다 — IP 는 `shared/utils/client-ip.ts` 의 `clientIpKey()`(IPv6 는 /64 로 묶음, 로그인 실패 카운터와 같은 함수) |
|
||||
| 로깅 | `shared/logger/winston.config.ts` | `console.log` 대신 사용 ([[logging]]) |
|
||||
| env 검증 | `shared/config/env.validation.ts` | 잘못된 설정이면 **기동 실패**(fail-fast) ([[env-structure]]) |
|
||||
@@ -40,7 +42,7 @@ description: NestJS 백엔드(backend/) 개발 규칙 — 컨트롤러/서비스
|
||||
| 파일 업로드 | `modules/upload` | **로그인 필요**(`JwtAuthGuard`). 로컬 디스크 저장 + 형식·크기 검증 + 안전한 파일명 생성 ([[security]]). 한글 파일명은 multer `defParamCharset: 'utf8'` |
|
||||
| 헬스체크 | `modules/health` | `@SkipThrottle`+`@SkipTransform` — 기계가 읽는 엔드포인트 패턴 ([[response-format]]) |
|
||||
| 요청 컨텍스트 | `shared/logger/request-context.ts` | 요청마다 추적 ID 를 AsyncLocalStorage 에 둔다 — 필터를 거치지 않는 로그도 `traceTag()` 로 `[traceId]` 를 붙인다 ([[logging]]) |
|
||||
| 순수 유틸 | `shared/utils/` | 순수 함수 분리 위치 (예: `mask.ts` 로그용 이메일 마스킹, `duration.ts` 기간 파싱, `content-disposition.ts` 다운로드 헤더, `cookies.ts`, `trust-proxy.ts`, `request-origin.ts`) |
|
||||
| 순수 유틸 | `shared/utils/` | 순수 함수 분리 위치 (예: `mask.ts` 로그용 이메일 마스킹·URL 의 검색어 가리기(`maskUrlForLog` — `q` 는 길이만), `settle.ts` 정해진 시간까지만 기다리기, `duration.ts` 기간 파싱, `content-disposition.ts` 다운로드 헤더, `cookies.ts`, `trust-proxy.ts`, `request-origin.ts`) |
|
||||
|
||||
> **확장 시 따르는 규칙** (기능이 필요해지면 규칙대로 구현):
|
||||
> - **보호 라우트**: 인증은 구현되어 있다 (`modules/auth` + `common/guards/jwt-auth.guard.ts`). 새 보호 라우트는 컨트롤러·핸들러에
|
||||
@@ -224,7 +226,9 @@ SwaggerModule.setup('api-docs', app, document);
|
||||
> Redis 가 실제로 필요해지는 시점(다중 인스턴스 운영, 세션·캐시 공유, 분산 락)에 연결한다.
|
||||
> ⚠️ 인스턴스를 늘리면 **프로세스 메모리에 둔 한도·잠금**도 함께 옮겨야 한다 — 그대로 두면 인스턴스마다 따로 센다:
|
||||
> 로그인 실패 카운터(`modules/auth/fixed-window-counter.ts` → `INCR`+`PEXPIRE` 를 한 Lua 스크립트로),
|
||||
> 같은 주소의 진행 중 로그인·같은 `Idempotency-Key` 의 진행 중 발송(`SET NX PX` 잠금), `@nestjs/throttler` 저장소:
|
||||
> 같은 주소의 진행 중 로그인·같은 `Idempotency-Key` 의 진행 중 발송(`SET NX PX` 잠금), `@nestjs/throttler` 저장소,
|
||||
> 사용자별 서명 그림 올리기 차례(`mail-signature.service.ts` 의 `uploadQueues` — 그대로면 동시 업로드가 보관 상한을 넘을 수 있다 → 잠금),
|
||||
> 받는 사람 추천 캐시(`recipient-suggestions.service.ts` — 틀리지는 않지만 보낸 직후 다른 인스턴스가 10분까지 옛 목록을 준다 → 공유 캐시나 무효화 알림):
|
||||
> 0. 컨테이너 활성화 — 루트 `.env` 에 `COMPOSE_PROFILES=redis` ([[docker]])
|
||||
> 1. Redis store 패키지 설치 (**컨테이너 안에서** — 위 의존성 주의 항목 참조)
|
||||
> 2. `app.module.ts` 의 `CacheModule.register` 를 `registerAsync` + Redis store 로 교체
|
||||
@@ -356,4 +360,10 @@ dc exec backend npm uninstall <패키지>
|
||||
HTML 을 정화하는 경로는 먼저 `html/html-parse-budget.ts` 의 `checkHtmlParseBudget()` 을 부른다 — sanitize-html 이 쓰는 htmlparser2 는
|
||||
중첩 깊이의 제곱으로 느려져 2MB 한 통에 수 분 동안 이벤트 루프를 멈춘다 (2026-10-27 리뷰). 정규식·변환도 입력 길이에 비례하게 둔다.
|
||||
htmlparser2 판을 올리면 `html-parse-budget.spec.ts` 의 Parser 비교 테스트가 어긋남을 잡는다 — 실패하면 바뀐 Parser 규칙을 옮긴다.
|
||||
**엔진의 `Email/set destroy` 는 메일함과 상관없이 영구 삭제다** — 사용자 메일을 영구 삭제하는 경로(임시 저장 교체의 옛 초안 · 보낸 뒤 `draftId` ·
|
||||
휴지통에서 영구 삭제)는 모두 `jmap-mail.provider.ts` 의 조건부 지우기를 쓴다: 지울 대상인지 확인(임시보관함에만 있는 사용자 초안 / 휴지통에만 있는 메일)
|
||||
→ 확인한 상태 그대로일 때만 `ifInState` 로 지우기 → 어긋나면 다시 확인(합계 `CONDITIONAL_DESTROY_ATTEMPTS`). 확인과 지우기 사이에
|
||||
다른 탭이 옮기거나 되돌린 메일까지 지우지 않게 하기 위해서다 (2026-10-27, Stalwart 0.16.27 이 `stateMismatch` 로 거절하는 것을 확인).
|
||||
조건 없는 destroy 는 그 요청이 방금 만든 메일(실패한 저장의 되돌림)에만 쓴다.
|
||||
같은 요청에서 만들기와 지우기를 함께 하지 않는다 — 엔진은 만들기가 실패해도 지우기를 한다.
|
||||
- [ ] **env**: DB/Redis/CORS/포트는 루트 `.env` 에서 주입된다. 백엔드 고유 시크릿(`JWT_SECRET` 등)만 `backend/.env.{env}` 에 작성(compose `env_file` 로 로드).
|
||||
|
||||
@@ -32,18 +32,22 @@ 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` |
|
||||
| 모달 대화상자 | `src/components/feedback/ModalDialog.vue` | 네이티브 `<dialog>`(초점·Esc·닫힘 처리)와 모양. 세션 만료(`SessionExpiredDialog`)·나가기 확인(`components/mail/ComposeLeaveDialog.vue`)이 쓴다 — 새 대화상자도 이것으로 만든다 |
|
||||
| 토스트 | `src/stores/toast.store.ts` · `src/components/feedback/ToastHost.vue` | 오류는 `useApi.ts` 의 `showErrorUI()`, 성공 안내는 `composables/useToast.ts`. 알림 안의 단추는 `{ label, run }`(예: 임시 저장본 [되돌리기]), 이동 링크는 지금처럼. 단추가 있는 알림은 10초(`TOAST_ACTION_DURATION_MS`), 마우스 · 초점이 있는 동안은 `hold`·`release` 로 멈춘다 |
|
||||
| 모달 대화상자 | `src/components/feedback/ModalDialog.vue` | 네이티브 `<dialog>`(초점·Esc·닫힘 처리)와 모양. 세션 만료(`SessionExpiredDialog`)·나가기 확인(공용 `components/feedback/LeaveConfirmDialog.vue` — 쓰기 화면 `ComposeLeaveDialog.vue`·서명 설정이 쓴다)이 쓴다 — 새 대화상자도 이것으로 만든다 |
|
||||
| 외부 HTML 격리 렌더링 | `src/components/mail/MailBodyFrame.vue` · `src/shared/utils/mail-html.ts` | 메일 본문 등 외부 HTML 은 이 컴포넌트로만 그린다 (sandbox·내부 CSP 근거는 파일 머리말). 보여 줄 HTML 고르기는 `displayedHtmlBody()` 하나다 — null 이거나 공백뿐이면 textBody (읽기 화면·인용 공용, 서버의 `isBodyTruncated` 와 같은 규칙) |
|
||||
| 메일 모듈 | `src/pages/mail/*` · `src/components/mail/*` | API 계약 `docs/mail/api-contract.md`, 디자인 원본 `docs/design/webmail/` |
|
||||
| 콘텐츠 열 토큰 | `src/styles/tokens.css` 의 `--content-*` · `--content-toolbar-start` | 읽기·쓰기 화면이 같은 최대 폭·여백·32px 시작선을 쓴다 (새 화면도 이 토큰으로) |
|
||||
| 밀도 예외 크기 | `src/styles/tokens.css` 의 `--size-compact-control` · `--size-segment-item` · `--size-editor-tool` · `--size-editor-menu-item` · `--size-editor-swatch-cell` · `--size-editor-table-cell` (주석에 "밀도 예외") | 클릭 영역 44px 기준의 의도된 예외(목록 표의 별·보기 전환, 편집기 툴바 버튼 40×44·메뉴 항목 40·색 견본 40·표 고르기 칸 22)에만 쓴다 — 사용자 확정. 그 밖의 `--size-editor-*`(툴바 높이·글꼴 단추·본문 최소 높이 등)는 예외가 아니라 보통 크기다 |
|
||||
| 밀도 예외 크기 | `src/styles/tokens.css` 의 `--size-compact-control` · `--size-segment-item` · `--size-segment-item-lg` · `--size-editor-tool` · `--size-editor-menu-item` · `--size-editor-swatch-cell` · `--size-editor-table-cell` (주석에 "밀도 예외") | 클릭 영역 44px 기준의 의도된 예외(목록 표의 별·보기 전환, 설정 화면의 미리 보기 전환 36(`.segmented`)·서명 그림 크기 줄 36(작게·보통·원본·그림 빼기), 편집기 툴바 버튼 40×44·메뉴 항목 40·색 견본 40·표 고르기 칸 22)에만 쓴다 — 사용자 확정(캔버스). 그 밖의 `--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/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`) |
|
||||
| 나가기 전 확인 | `src/composables/useLeaveConfirm.ts` · `src/shared/utils/compose-changes.ts` | 화면을 연 뒤 바꾼 것이 있으면 앱 안 이동(라우트 떠남·바뀜)은 확인 대화상자, 탭 닫기·새로고침은 `beforeunload`. 바꿈 여부는 연 직후의 스냅숏과 비교한다(편집기는 `isChanged()`). 보낸 뒤·보내는 중·세션이 끝났을 때는 묻지 않는다 — 보내는 중에 떠나면 결과는 토스트로만 알리고 떠난 양식·새 양식을 건드리지 않는다(양식 세대로 늦게 온 결과를 버린다, 디자인 README "나가기 전 확인") |
|
||||
| 메일 본문 편집기 | `src/components/mail/editor/` — `MailEditor.vue` (쓰기 화면이 `defineAsyncComponent` 로 불러온다) | TipTap core (MIT 무료 패키지만, `@tiptap/*` 는 모두 같은 버전으로 고정). 툴바 배치·좁은 폭에서 접는 순서·단축키는 `docs/design/webmail/README.md` "메일 쓰기". 보낼 모양은 `mail-serialize.ts` — 서식 본문 `bodyHtml`(기본 글꼴 div, 인라인 style) + 텍스트 대체 본문 `body` (계약 2절 "서식 본문"). 답장·전달 초안 한도는 `fit-draft.ts`(처음 맞출 때 내 글 몫 5%를 남기고, 보내기 직전 `getContentForSend()` 가 다시 재서 인용을 더 줄인다 — 내 글만으로 넘으면 보내지 않고 칸 아래에 알린다), 붙여 넣거나 끌어다 놓은 파일은 첨부로(`shared/utils/editor-files.ts`, 화면 어디에 놓아도 — `composables/useWindowFileDrop.ts`). **읽어 들이는 style 값은 서버 정화와 같은 형식만** 받는다 — 규칙 `shared/utils/outgoing-html-rules.ts`(짝: backend `sanitize-outgoing-html.ts`, 같은 표본 표 `test-utils/outgoing-style-samples.ts` — 한쪽을 고치면 둘 다), 적용 `editor/mail-format-extensions.ts`. 편집기 청크를 못 받으면 `ComposeEditorLoadError.vue`("다시 시도"), 단축키 표기는 플랫폼별(`shared/utils/keyboard-shortcut.ts`). **그림은 서명 그림만** 받는다 — 노드 `editor/signature-image.ts`(쓰기 본문·서명 칸 공용, 규칙은 `outgoing-html-rules.ts` 의 그림 부분 — 짝: backend `html/signature-images.ts`·`keepSignatureImage`, 표본 `IMAGE_SAMPLES`) |
|
||||
| 임시 저장 | `src/composables/useDraftSaving.ts` · `components/mail/ComposeDraftStatus.vue` · `composables/useComposeDraftLink.ts`(주소 맞추기 · 옛 id 기록 · 보내지 못한 메일을 저장본으로) · `composables/useDraftTrash.ts`(휴지통 · 되돌리기) | 3초 멈춤 · 쉬지 않고 쓰면 30초 · 10초 간격 · 한 번에 하나(계약 2절 "임시 저장", 상수 `shared/constants/mail.ts` 의 `DRAFT_*`). `MAIL_008`·PUT 404 는 막지 않고 POST 로 새로. 찾지 못한 첨부가 남았거나 본문이 한도를 넘는 동안은 저장하지 않고, 실을 수 없는 첨부가 든 남의 저장본은 첫 저장을 POST 로(원본 보존). 보내기·버리기 전에는 `suspend`+`settle` — 보내기는 settle 뒤 첨부 상태를 다시 본다. 저장할 때마다 주소를 `?draft=<새 id>` 로 맞추고(떠나기 전 저장 제외), 옛 id 로 돌아오면 `mail.store` 의 `latestDraftId()` 로 마지막 저장본을 연다. 쓰기 화면 휴지통은 DELETE 가 아니라 PATCH(휴지통으로 이동) — 이미 휴지통인 메일은 DELETE 가 영구 삭제한다 |
|
||||
| 받는 사람 자동 완성 | `src/composables/useRecipientSuggestions.ts` · `shared/utils/recipient-match.ts` · `components/mail/RecipientField.vue` · `RecipientSuggestionList.vue` | 검색어는 input · compositionupdate 의 `target.value`(한글 조합 중 글자 포함)로 따로 두고, 칩 확정은 v-model. Enter 등의 조합 판정은 `shared/utils/keyboard-shortcut.ts` 의 `isImeComposing()`(`isComposing` 또는 keyCode 229 — Safari · macOS) — 새 입력 칸의 Enter 처리도 이것을 쓴다. 쓰기 화면을 열 때 `limit=100` 한 번, 모자랄 때만 250ms 뒤 `q` 로(초성·자모만 친 글자는 묻지 않는다, 늦은 응답은 버린다). 초성 검색·맞은 글자 강조는 `recipient-match.ts`(강조는 `<strong>` 조각 — v-html 없음). combobox 접근성(aria-activedescendant · listbox · 조합 중 Enter 무시), 완전한 주소면 미리 고르지 않는다 |
|
||||
| 메일 서명 | `src/stores/mail-signature.store.ts` · `components/mail/editor/signature/` · `shared/utils/mail-signature.ts` · `pages/settings/` | 서명은 메일 화면에 들어올 때 받아 두고 쓰기 화면이 넣는다(새 메일은 끝, 답장·전달은 인용 위 — 이어 쓰기는 다시 넣지 않는다). 설정 화면(`SettingsLayout.vue` 왼쪽 메뉴 + `MailSignaturePage.vue`)은 들어올 때마다 새로 받는다. 미리 보기는 편집기 스키마의 `DOMSerializer` 로 그린다. 그림 3장은 서명 칸 안의 그림 수로(서버 보관 상한 10장은 서버가 센다) |
|
||||
| 시계 주입 | `src/composables/useClock.ts` · `src/test-utils/manual-clock.ts` | 타이머를 쓰는 로직(자동 저장 등)은 시계를 주입받는다 — 테스트는 손으로 돌리는 시계로 시간을 넘긴다(고정 대기 없이, [[testing]]) |
|
||||
| 나가기 전 확인 | `src/composables/useLeaveConfirm.ts` · `src/shared/utils/compose-changes.ts` | 화면을 연 뒤(또는 마지막으로 저장한 뒤) 바꾼 것이 있으면 앱 안 이동(라우트 떠남·바뀜)은 확인 대화상자, 탭 닫기·새로고침은 `beforeunload`. 쓰기 화면은 떠나기 전에 먼저 저장하고 저장하지 못한 것이 있을 때만 묻는다(서명 설정은 저장하지 않은 서명이 있으면 묻는다). 가드가 도는 중은 `guarding`. 바꿈 여부는 연 직후(저장한 뒤에는 마지막으로 저장한 판)의 스냅숏과 비교한다(편집기는 `isChanged()`). 떠나기 전 저장은 5초까지만 기다린다. 보낸 뒤·보내는 중·세션이 끝났을 때는 묻지 않는다 — 보내는 중에 떠나면 결과는 토스트로만 알리고 떠난 양식·새 양식을 건드리지 않는다(양식 세대로 늦게 온 결과를 버린다, 디자인 README "나가기 전 확인") |
|
||||
| 읽기 화면 본문 안내 | `src/shared/utils/mail-body-notices.ts` · `shared/constants/mail.ts` 의 `MAIL_BODY_NOTICES` | 서버의 `isHtmlBodyOmitted`·`isBodyTruncated`(계약 2절 "읽기 한도")를 외부 이미지 차단과 같은 안내 줄로 보인다(`.reader__notice[data-notice]`) |
|
||||
| 쓰기 칸 포커스 | `src/styles/base.css` 의 `.compose-field` | 글자 입력 행(받는 사람·참조·숨은참조·제목·본문)은 아래 선 하나로 포커스를 보인다. 새 입력 행에도 이 클래스를 붙인다 — 칸마다 포커스 규칙을 따로 만들지 않는다 |
|
||||
| env 타입 선언 | `frontend/env.d.ts` | `VITE_API_BASE_URL` |
|
||||
@@ -246,6 +250,7 @@ router.beforeEach((to) => {
|
||||
운영 CSP 가 막아도 그것에만 기대지 않는다 (2026-10-27 사례, 회귀 테스트는 `mail-serialize.spec.ts`).
|
||||
편집기로 읽어 들이는 서식은 **서버가 남기는 형식만** 받는다 (`outgoing-html-rules.ts`) — 서버만 지우면 편집기에서 숨은 글(`font-size:0` 등)이
|
||||
받는 사람에게 보이는 식으로 화면과 보낸 메일이 달라진다 (2026-10-27 리뷰).
|
||||
이어 쓰는 임시 저장본의 `bodyHtml`·서명 HTML 도 같은 경로로 편집기에 옮긴다 (다른 메일 프로그램이 만든 초안도 있다).
|
||||
- [ ] **lint/test**: 커밋 전 `npm run lint` (`--max-warnings 0`) + `npm run type-check` + `npm run test:unit` 통과. 자동 수정은 `npm run lint:fix`.
|
||||
테스트 시간 한도는 전역 20초(`vitest.config.ts` — jsdom·편집기 테스트가 부하에서 5~13초)이고, 긴 초안을 맞추는 테스트는 `src/test-utils/timeouts.ts` 의 한도를 쓴다.
|
||||
"몇 ms 안에 끝난다"를 검사하는 성능 단언은 벽시계 대신 계산량(CPU 시간·비용)으로 잰다 — 벽시계는 다른 작업이 많으면 몇 배로 늘어
|
||||
|
||||
@@ -1,4 +1,32 @@
|
||||
import { isSendMessageRequest } from './app.setup';
|
||||
import { isDraftSaveRequest, isSendMessageRequest } from './app.setup';
|
||||
|
||||
describe('임시 저장 요청 판정 (발송과 같은 JSON 본문 4MB)', () => {
|
||||
it('POST /api/drafts 와 PUT /api/drafts/{id} 는 임시 저장 요청이다 — 대소문자·끝의 / ·쿼리는 가리지 않는다', () => {
|
||||
for (const [method, url] of [
|
||||
['POST', '/api/drafts'],
|
||||
['POST', '/API/Drafts/'],
|
||||
['PUT', '/api/drafts/faaaaabi'],
|
||||
['PUT', '/api/drafts/faaaaabi/?x=1'],
|
||||
] as const) {
|
||||
expect(isDraftSaveRequest({ method, url })).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('조회·다른 경로·하위 경로는 1MB 그대로다', () => {
|
||||
for (const [method, url] of [
|
||||
['GET', '/api/drafts/faaaaabi'],
|
||||
['PUT', '/api/drafts'],
|
||||
['POST', '/api/drafts/faaaaabi'],
|
||||
['PUT', '/api/drafts/faaaaabi/attachments'],
|
||||
['PATCH', '/api/drafts/faaaaabi'],
|
||||
['POST', '/api/draftsx'],
|
||||
['PUT', '/drafts/faaaaabi'],
|
||||
] as const) {
|
||||
expect(isDraftSaveRequest({ method, url })).toBe(false);
|
||||
}
|
||||
expect(isDraftSaveRequest({ method: 'POST', url: undefined })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('발송 요청 판정 (발송만 JSON 본문 4MB)', () => {
|
||||
it('POST /api/messages 는 발송 요청이다 — 대소문자·끝의 / ·쿼리는 가리지 않는다 (라우터와 같다)', () => {
|
||||
|
||||
+30
-10
@@ -8,37 +8,57 @@ import type { IncomingMessage } from 'http';
|
||||
import { HttpExceptionFilter } from '@/common/filters/http-exception.filter';
|
||||
import { TransformInterceptor } from '@/common/interceptors/transform.interceptor';
|
||||
import { requestIdMiddleware } from '@/common/middleware/request-id.middleware';
|
||||
import { MESSAGES_ROUTE } from '@/modules/mail/mail.constants';
|
||||
import { DRAFTS_ROUTE, MESSAGES_ROUTE } from '@/modules/mail/mail.constants';
|
||||
import { resolveTrustProxy } from '@/shared/utils/trust-proxy';
|
||||
|
||||
/** 글로벌 prefix — 모든 라우트가 /api/* (클라이언트 baseURL 과 일치) */
|
||||
export const API_PREFIX = 'api';
|
||||
|
||||
/**
|
||||
* JSON 본문 최대 크기 — 발송(`POST /api/messages`)을 뺀 모든 API.
|
||||
* JSON 본문 최대 크기 — 발송(`POST /api/messages`)·임시 저장(`POST /api/drafts`·`PUT /api/drafts/{id}`)을 뺀 모든 API.
|
||||
* 텍스트 본문 100,000자(한글은 UTF-8 로 3바이트라 약 300KB)도 이 안에 든다 (기본값 100KB 로는 잘린다).
|
||||
*/
|
||||
export const JSON_BODY_LIMIT = '1mb';
|
||||
|
||||
/**
|
||||
* 발송 요청(`POST /api/messages`)의 JSON 본문 최대 크기 (계약 2절) — 서식 본문(최대 500,000자: 한글이면 약 1.5MB 에
|
||||
* 발송·임시 저장 요청의 JSON 본문 최대 크기 (계약 2절) — 서식 본문(최대 500,000자: 한글이면 약 1.5MB 에
|
||||
* JSON 이스케이프가 더해진다)과 텍스트 대체 본문(최대 100,000자)이 함께 오고, 답장·전달은 원문 인용이 길다.
|
||||
* 앞단 nginx 도 이 경로만 4m 으로 둔다 (release-ops). 넘으면 413 FILE_002.
|
||||
* 임시 저장 본문은 발송 본문과 같은 필드·한도라 같은 파서를 쓴다.
|
||||
* 앞단 nginx 도 이 경로들을 같은 값(4m)으로 둬야 한다 (release-ops — frontend/nginx.conf). 넘으면 413 FILE_002.
|
||||
*/
|
||||
export const SEND_MESSAGE_JSON_BODY_LIMIT = '4mb';
|
||||
|
||||
const SEND_MESSAGE_PATH = `/${API_PREFIX}/${MESSAGES_ROUTE}`;
|
||||
const DRAFTS_PATH = `/${API_PREFIX}/${DRAFTS_ROUTE}`;
|
||||
|
||||
/** 라우터가 보는 경로 — Express 처럼 대소문자·끝의 '/' 를 가리지 않고 쿼리는 뺀다 */
|
||||
function routedPath(req: Pick<IncomingMessage, 'url'>): string {
|
||||
return (req.url ?? '').split('?')[0].replace(/\/+$/, '').toLowerCase();
|
||||
}
|
||||
|
||||
/**
|
||||
* 발송 요청인가 — `POST /api/messages`.
|
||||
* 라우터가 발송 핸들러로 보내는 모양이면 모두 해당한다: Express 처럼 대소문자·끝의 '/' 를 가리지 않고 쿼리는 뺀다.
|
||||
* 라우터가 발송 핸들러로 보내는 모양이면 모두 해당한다.
|
||||
*/
|
||||
export function isSendMessageRequest(
|
||||
req: Pick<IncomingMessage, 'method' | 'url'>,
|
||||
): boolean {
|
||||
if (req.method !== 'POST') return false;
|
||||
const path = (req.url ?? '').split('?')[0].replace(/\/+$/, '').toLowerCase();
|
||||
return path === SEND_MESSAGE_PATH;
|
||||
return req.method === 'POST' && routedPath(req) === SEND_MESSAGE_PATH;
|
||||
}
|
||||
|
||||
/**
|
||||
* 임시 저장 요청인가 — `POST /api/drafts`(처음 저장) · `PUT /api/drafts/{id}`(바꿔 저장). 조회(GET)는 본문이 없다.
|
||||
*/
|
||||
export function isDraftSaveRequest(
|
||||
req: Pick<IncomingMessage, 'method' | 'url'>,
|
||||
): boolean {
|
||||
const path = routedPath(req);
|
||||
if (req.method === 'POST') return path === DRAFTS_PATH;
|
||||
if (req.method !== 'PUT' || !path.startsWith(`${DRAFTS_PATH}/`)) {
|
||||
return false;
|
||||
}
|
||||
const id = path.slice(DRAFTS_PATH.length + 1);
|
||||
return id !== '' && !id.includes('/');
|
||||
}
|
||||
|
||||
/** JSON 본문인가 — body-parser 의 기본 판정('application/json', 매개변수는 보지 않는다)과 같다 */
|
||||
@@ -81,14 +101,14 @@ export function configureApp(app: NestExpressApplication): void {
|
||||
// 요청(CSRF)은 본문이 비어 VAL_001 이 된다. JSON 요청은 CORS 사전 확인을 거치므로 화이트리스트가 막는다.
|
||||
// inflate=false: 압축된 요청 본문을 받지 않는다 (브라우저는 보내지 않는다 — 압축 폭탄·body-parser DoS 경로 차단)
|
||||
//
|
||||
// 경로별 한도: 발송 요청만 4MB 파서가 읽고(type 판정으로 고른다), 나머지는 1MB 파서가 읽는다.
|
||||
// 경로별 한도: 발송·임시 저장 요청만 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) && isJsonBody(req),
|
||||
(isSendMessageRequest(req) || isDraftSaveRequest(req)) && isJsonBody(req),
|
||||
});
|
||||
app.useBodyParser('json', { limit: JSON_BODY_LIMIT, inflate: false });
|
||||
|
||||
|
||||
@@ -119,6 +119,13 @@ const DOMAIN_ERROR_CODES = {
|
||||
message:
|
||||
'본문이 너무 길어 보낼 수 없습니다. 내용을 줄인 뒤 다시 보내 주세요.',
|
||||
},
|
||||
// 임시 저장본 교체(PUT /api/drafts/{id}) 때 옛 초안이 이미 없다 — 다른 창·기기에서 먼저 고쳤거나 지웠다. 아무것도 저장하지 않는다.
|
||||
// 클라이언트는 막지 않고 지금 내용을 새 임시 저장본(POST)으로 남긴다 (계약 2절 "임시 저장")
|
||||
MAIL_008: {
|
||||
status: HttpStatus.CONFLICT,
|
||||
message:
|
||||
'임시 저장한 메일이 다른 곳에서 바뀌었거나 지워졌습니다. 지금 내용을 새 임시 저장본으로 남겨 주세요.',
|
||||
},
|
||||
} as const satisfies Record<string, ErrorCodeDefinition>;
|
||||
|
||||
export const ERROR_CODES = {
|
||||
|
||||
@@ -21,7 +21,11 @@ interface CapturedResponse {
|
||||
}
|
||||
|
||||
/** 필터 실행 후 실제로 내려간 응답을 캡처한다. */
|
||||
function runFilter(exception: unknown, requestId?: string): CapturedResponse {
|
||||
function runFilter(
|
||||
exception: unknown,
|
||||
requestId?: string,
|
||||
url = '/api/users/1',
|
||||
): CapturedResponse {
|
||||
const captured = {} as CapturedResponse;
|
||||
|
||||
const response = {
|
||||
@@ -37,7 +41,7 @@ function runFilter(exception: unknown, requestId?: string): CapturedResponse {
|
||||
|
||||
const request = {
|
||||
method: 'GET',
|
||||
url: '/api/users/1',
|
||||
url,
|
||||
header: () => requestId,
|
||||
};
|
||||
|
||||
@@ -152,6 +156,22 @@ describe('HttpExceptionFilter', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('로그의 요청 주소에서 검색어(q)는 길이만 남긴다 — 나머지 쿼리는 그대로', () => {
|
||||
const warnLog = jest.spyOn(Logger.prototype, 'warn');
|
||||
warnLog.mockClear();
|
||||
|
||||
runFilter(
|
||||
new BusinessException('VAL_001'),
|
||||
'trace-q',
|
||||
'/api/recipient-suggestions?q=%EB%8F%84%EC%9C%A4&cursor=bad',
|
||||
);
|
||||
|
||||
const logged = warnLog.mock.calls.map(([message]) => String(message));
|
||||
expect(logged).toEqual([
|
||||
'[VAL_001][trace-q] GET /api/recipient-suggestions?q=<2자>&cursor=bad',
|
||||
]);
|
||||
});
|
||||
|
||||
it('미정의 예외는 내부 정보 없이 SYS_001 로 수렴한다', () => {
|
||||
const result = runFilter(new Error('DB 커넥션 풀 고갈: host=10.0.0.5'));
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
resolveCodeByStatus,
|
||||
} from '@/common/errors/error-codes';
|
||||
import { REQUEST_ID_HEADER } from '@/common/middleware/request-id.middleware';
|
||||
import { maskUrlForLog } from '@/shared/utils/mask';
|
||||
|
||||
/** 이 값 이상이면 서버 책임 오류로 보고 error 레벨로 남긴다. */
|
||||
const SERVER_ERROR_STATUS_THRESHOLD = 500;
|
||||
@@ -140,7 +141,8 @@ export class HttpExceptionFilter implements ExceptionFilter {
|
||||
request: Request,
|
||||
traceId?: string,
|
||||
): void {
|
||||
const where = `${request.method} ${request.url}`;
|
||||
// 검색어(q)는 길이만 — 사람 이름·주소가 든다 (shared/utils/mask.ts)
|
||||
const where = `${request.method} ${maskUrlForLog(request.url)}`;
|
||||
const prefix = `[${code}]${traceId ? `[${traceId}]` : ''} ${where}`;
|
||||
// context 는 로그 전용 개발자 정보다 — 5xx(예: 외부 메일 엔진 장애 MAIL_001)도 원인을 알 수 있게 함께 남긴다
|
||||
const context =
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
/**
|
||||
* 메일 서명·서명 그림·사내 목록 표 (docs/mail/api-contract.md 2절 "서명" · "받는 사람 추천").
|
||||
* - mail_signatures: 사용자마다 서명 하나 (정화한 HTML·텍스트·넣을 메일)
|
||||
* - mail_signature_images: 서명 그림 정보 — 파일은 업로드 볼륨의 mail-signatures 폴더. 주인 조회용 인덱스(user_email),
|
||||
* 정리 작업용 부분 인덱스(attached_at 이 비어 있는 행의 created_at)
|
||||
* - mail_directory: 웹메일 로그인에 성공한 직원 (받는 사람 추천의 "사내")
|
||||
* 새 표만 만든다 — 기존 표(auth_sessions)는 건드리지 않는다.
|
||||
* (자동 생성: npm run migration:generate — 엔티티는 modules/mail-signature/*.entity.ts · modules/mail-directory/mail-directory.entity.ts)
|
||||
*/
|
||||
export class AddMailSignaturesAndDirectory1793084109734 implements MigrationInterface {
|
||||
name = 'AddMailSignaturesAndDirectory1793084109734';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`CREATE TABLE "mail_directory" ("email" character varying(254) NOT NULL, "name" character varying(200) NOT NULL, "last_login_at" TIMESTAMP WITH TIME ZONE NOT NULL, CONSTRAINT "PK_d79c6055eed1d875a53a13a71f8" PRIMARY KEY ("email"))`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE TABLE "mail_signature_images" ("id" uuid NOT NULL, "user_email" character varying(254) NOT NULL, "content_type" character varying(32) NOT NULL, "size" integer NOT NULL, "width" integer NOT NULL, "height" integer NOT NULL, "file_name" character varying(64) NOT NULL, "created_at" TIMESTAMP WITH TIME ZONE NOT NULL, "attached_at" TIMESTAMP WITH TIME ZONE, CONSTRAINT "PK_f52f2c8dbb00f2973088763a776" PRIMARY KEY ("id"))`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_mail_signature_images_unattached" ON "mail_signature_images" ("created_at") WHERE "attached_at" IS NULL`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_mail_signature_images_user_email" ON "mail_signature_images" ("user_email") `,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE TABLE "mail_signatures" ("user_email" character varying(254) NOT NULL, "body_html" text, "body_text" text, "is_for_new" boolean NOT NULL DEFAULT true, "is_for_reply" boolean NOT NULL DEFAULT true, "updated_at" TIMESTAMP WITH TIME ZONE NOT NULL, CONSTRAINT "PK_4846a94ce96c5f4fe400f449a7f" PRIMARY KEY ("user_email"))`,
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`DROP TABLE "mail_signatures"`);
|
||||
await queryRunner.query(
|
||||
`DROP INDEX "public"."IDX_mail_signature_images_user_email"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`DROP INDEX "public"."IDX_mail_signature_images_unattached"`,
|
||||
);
|
||||
await queryRunner.query(`DROP TABLE "mail_signature_images"`);
|
||||
await queryRunner.query(`DROP TABLE "mail_directory"`);
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import { PassportModule } from '@nestjs/passport';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
|
||||
import { MailModule } from '@/modules/mail/mail.module';
|
||||
import { MailDirectoryModule } from '@/modules/mail-directory/mail-directory.module';
|
||||
import { AuthSessionCleanup } from './auth-session.cleanup';
|
||||
import { AuthSessionEntity } from './auth-session.entity';
|
||||
import { AuthSessionRepository } from './auth-session.repository';
|
||||
@@ -29,6 +30,8 @@ import { LoginAttemptService } from './login-attempt.service';
|
||||
JwtModule.register({}),
|
||||
TypeOrmModule.forFeature([AuthSessionEntity]),
|
||||
MailModule,
|
||||
// 로그인에 성공한 직원을 사내 목록에 기록한다 (받는 사람 추천)
|
||||
MailDirectoryModule,
|
||||
],
|
||||
controllers: [AuthController],
|
||||
providers: [
|
||||
|
||||
@@ -4,6 +4,7 @@ import { JwtService } from '@nestjs/jwt';
|
||||
import { BusinessException } from '@/common/errors/business.exception';
|
||||
import type { MailProvider } from '@/modules/mail/mail.provider';
|
||||
import type { MailSettings } from '@/modules/mail/mail.settings';
|
||||
import type { MailDirectoryService } from '@/modules/mail-directory/mail-directory.service';
|
||||
import type { AuthSessionEntity } from './auth-session.entity';
|
||||
import { hashRefreshToken } from './auth-session.policy';
|
||||
import type {
|
||||
@@ -135,6 +136,7 @@ describe('AuthService', () => {
|
||||
let mail: jest.Mocked<Pick<MailProvider, 'verifyCredentials' | 'getProfile'>>;
|
||||
let attempts: LoginAttemptService;
|
||||
let sessions: FakeSessions;
|
||||
let directory: { recordLogin: jest.Mock };
|
||||
let jwt: JwtService;
|
||||
let service: AuthService;
|
||||
let logs: string[];
|
||||
@@ -147,6 +149,7 @@ describe('AuthService', () => {
|
||||
jwt,
|
||||
attempts,
|
||||
sessions as unknown as AuthSessionRepository,
|
||||
directory as unknown as MailDirectoryService,
|
||||
);
|
||||
|
||||
const decode = (token: string) =>
|
||||
@@ -184,6 +187,7 @@ describe('AuthService', () => {
|
||||
};
|
||||
attempts = new LoginAttemptService();
|
||||
sessions = new FakeSessions();
|
||||
directory = { recordLogin: jest.fn().mockResolvedValue(undefined) };
|
||||
jwt = new JwtService({});
|
||||
service = build();
|
||||
|
||||
@@ -235,6 +239,31 @@ describe('AuthService', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('로그인에 성공한 직원을 사내 목록에 기록한다 (받는 사람 추천) — 실패한 로그인은 기록하지 않는다', async () => {
|
||||
await service.login(ARA, PASSWORD, CLIENT_IP);
|
||||
expect(directory.recordLogin).toHaveBeenCalledWith({
|
||||
email: ARA,
|
||||
name: '김아라',
|
||||
});
|
||||
|
||||
directory.recordLogin.mockClear();
|
||||
mail.verifyCredentials.mockResolvedValue(null);
|
||||
expect(
|
||||
(await failureOf(service.login(ARA, 'wrong', CLIENT_IP)))?.code,
|
||||
).toBe('AUTH_004');
|
||||
expect(directory.recordLogin).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('사내 목록 기록에 실패해도 로그인은 성공한다 — 경고만 남긴다 (주소는 가려서)', async () => {
|
||||
directory.recordLogin.mockRejectedValue(new Error('db down'));
|
||||
|
||||
const result = await service.login(ARA, PASSWORD, CLIENT_IP);
|
||||
|
||||
expect(result.user.email).toBe(ARA);
|
||||
expect(logs.some((line) => line.includes('사내 목록'))).toBe(true);
|
||||
expect(logs.join('\n')).not.toContain(ARA);
|
||||
});
|
||||
|
||||
it('비밀번호가 틀리면 AUTH_004 이고 세션을 만들지 않는다', async () => {
|
||||
mail.verifyCredentials.mockResolvedValue(null);
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import { BusinessException } from '@/common/errors/business.exception';
|
||||
import { MAIL_PROVIDER, type MailProvider } from '@/modules/mail/mail.provider';
|
||||
import { MAIL_SETTINGS, type MailSettings } from '@/modules/mail/mail.settings';
|
||||
import type { MailAccountProfile } from '@/modules/mail/mail.types';
|
||||
import { MailDirectoryService } from '@/modules/mail-directory/mail-directory.service';
|
||||
import { traceTag } from '@/shared/logger/request-context';
|
||||
import { maskEmail } from '@/shared/utils/mask';
|
||||
import type { SessionTokens } from './auth-cookies';
|
||||
@@ -73,6 +74,7 @@ export class AuthService {
|
||||
private readonly jwt: JwtService,
|
||||
private readonly attempts: LoginAttemptService,
|
||||
private readonly sessions: AuthSessionRepository,
|
||||
private readonly directory: MailDirectoryService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -115,10 +117,29 @@ export class AuthService {
|
||||
}
|
||||
|
||||
const tokens = await this.startSession(profile);
|
||||
await this.recordInDirectory(profile);
|
||||
this.logger.log(`로그인: ${maskEmail(profile.email)}`);
|
||||
return { user: toUser(profile), tokens };
|
||||
}
|
||||
|
||||
/**
|
||||
* 로그인한 직원을 사내 목록에 기록한다 (받는 사람 추천 — 계약 2절). 관리자·게이트웨이·막은 계정은 넣지 않는다.
|
||||
* 기록하지 못해도 로그인은 그대로 성공한다 — 추천에서 빠질 뿐이다.
|
||||
*/
|
||||
private async recordInDirectory(profile: MailAccountProfile): Promise<void> {
|
||||
if (!this.isLoginAddress(profile.email)) return;
|
||||
try {
|
||||
await this.directory.recordLogin({
|
||||
email: profile.email,
|
||||
name: profile.name,
|
||||
});
|
||||
} catch (error) {
|
||||
this.logger.warn(
|
||||
`[SYS_001]${traceTag()} 사내 목록에 로그인한 직원을 기록하지 못했습니다: user=${maskEmail(profile.email)}, reason=${errorMessage(error)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 리프레시 쿠키만으로 두 토큰을 새로 발급한다(1회용 회전) — 액세스 쿠키가 없거나 만료돼도 된다.
|
||||
*
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import { Column, Entity, PrimaryColumn } from 'typeorm';
|
||||
|
||||
/** 메일 주소 최대 길이 (RFC 5321 경로 한도) — mail.constants 의 MAX_EMAIL_LENGTH 와 같다 */
|
||||
const EMAIL_COLUMN_LENGTH = 254;
|
||||
/** 표시 이름 최대 길이 — mail.constants 의 MAX_DISPLAY_NAME_LENGTH 와 같다 */
|
||||
export const DIRECTORY_NAME_LENGTH = 200;
|
||||
|
||||
/**
|
||||
* 사내 목록 (계약 2절 "받는 사람 추천") — 웹메일 로그인에 성공한 직원의 주소·이름.
|
||||
* 메일 엔진의 계정 목록(Principal) 권한은 쓰지 않는다 — 일부러 막아 두었다 (mail/scripts/setup.mjs).
|
||||
* 메일 엔진 관리자·게이트웨이·막은 계정은 넣지 않는다 (로그인할 수도 없다).
|
||||
*/
|
||||
@Entity({ name: 'mail_directory' })
|
||||
export class MailDirectoryEntryEntity {
|
||||
/** 정규화한(소문자) 직원 주소 */
|
||||
@PrimaryColumn({ type: 'varchar', length: EMAIL_COLUMN_LENGTH })
|
||||
email!: string;
|
||||
|
||||
/** 마지막으로 로그인했을 때 메일 엔진이 알려 준 표시 이름 */
|
||||
@Column({ type: 'varchar', length: DIRECTORY_NAME_LENGTH })
|
||||
name!: string;
|
||||
|
||||
@Column({ name: 'last_login_at', type: 'timestamptz' })
|
||||
lastLoginAt!: Date;
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
|
||||
import { MailDirectoryEntryEntity } from './mail-directory.entity';
|
||||
import { MailDirectoryService } from './mail-directory.service';
|
||||
|
||||
/**
|
||||
* 사내 목록 모듈 — 인증 모듈(로그인 때 기록)과 메일 모듈(받는 사람 추천)이 함께 쓴다.
|
||||
* 두 모듈 사이에 두어 서로를 가져오지 않게 한다 (AuthModule → MailModule 방향만 있다).
|
||||
*/
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([MailDirectoryEntryEntity])],
|
||||
providers: [MailDirectoryService],
|
||||
exports: [MailDirectoryService],
|
||||
})
|
||||
export class MailDirectoryModule {}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { MoreThanOrEqual, type Repository } from 'typeorm';
|
||||
|
||||
import type { MailDirectoryEntryEntity } from './mail-directory.entity';
|
||||
import {
|
||||
MAIL_DIRECTORY_ACTIVE_DAYS,
|
||||
MAX_DIRECTORY_CONTACTS,
|
||||
MailDirectoryService,
|
||||
} from './mail-directory.service';
|
||||
|
||||
describe('MailDirectoryService (사내 목록)', () => {
|
||||
let upsert: jest.Mock;
|
||||
let find: jest.Mock;
|
||||
let service: MailDirectoryService;
|
||||
|
||||
beforeEach(() => {
|
||||
upsert = jest.fn().mockResolvedValue(undefined);
|
||||
find = jest.fn().mockResolvedValue([]);
|
||||
service = new MailDirectoryService({
|
||||
upsert,
|
||||
find,
|
||||
} as unknown as Repository<MailDirectoryEntryEntity>);
|
||||
});
|
||||
|
||||
it('로그인한 직원을 주소 하나에 한 줄로 넣거나 고친다 — 주소는 소문자, 이름은 앞뒤 공백 없이 200자까지', async () => {
|
||||
const now = new Date('2026-10-27T03:00:00Z');
|
||||
|
||||
await service.recordLogin(
|
||||
{ email: ' Doyun.Lee@ARASYSTEM.test ', name: ` ${'가'.repeat(250)} ` },
|
||||
now,
|
||||
);
|
||||
|
||||
expect(upsert).toHaveBeenCalledWith(
|
||||
{
|
||||
email: 'doyun.lee@arasystem.test',
|
||||
name: '가'.repeat(200),
|
||||
lastLoginAt: now,
|
||||
},
|
||||
['email'],
|
||||
);
|
||||
});
|
||||
|
||||
it(`최근 ${MAIL_DIRECTORY_ACTIVE_DAYS}일 안에 로그인한 직원만, 최근에 로그인한 사람부터 상한까지 주소·이름만 읽는다 (퇴사자는 추천하지 않는다)`, async () => {
|
||||
const now = new Date('2026-10-27T03:00:00Z');
|
||||
find.mockResolvedValue([
|
||||
{
|
||||
email: 'doyun.lee@arasystem.test',
|
||||
name: '이도윤',
|
||||
lastLoginAt: new Date(),
|
||||
},
|
||||
]);
|
||||
|
||||
await expect(
|
||||
service.listContacts(MAX_DIRECTORY_CONTACTS, now),
|
||||
).resolves.toEqual([{ email: 'doyun.lee@arasystem.test', name: '이도윤' }]);
|
||||
expect(find).toHaveBeenCalledWith({
|
||||
// 2026-10-27 의 90일 전 = 2026-07-29
|
||||
where: { lastLoginAt: MoreThanOrEqual(new Date('2026-07-29T03:00:00Z')) },
|
||||
order: { lastLoginAt: 'DESC' },
|
||||
take: MAX_DIRECTORY_CONTACTS,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,63 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { MoreThanOrEqual, type Repository } from 'typeorm';
|
||||
|
||||
import type { DirectoryContact } from '@/modules/mail/mail.types';
|
||||
import {
|
||||
DIRECTORY_NAME_LENGTH,
|
||||
MailDirectoryEntryEntity,
|
||||
} from './mail-directory.entity';
|
||||
|
||||
/** 추천에 읽어 오는 사내 목록 상한 — 최근에 로그인한 사람부터 (추천 목록 자체는 1,000개까지다) */
|
||||
export const MAX_DIRECTORY_CONTACTS = 2000;
|
||||
|
||||
/**
|
||||
* 이 기간 안에 로그인한 직원만 "사내"로 추천한다 — 퇴사자가 계속 사내 맨 앞에 나와 반송되지 않게.
|
||||
* 웹메일을 쓰는 직원은 세션이 길어야 30일이라 그 안에 다시 로그인한다. 표에서 지우지는 않는다 (다시 로그인하면 돌아온다).
|
||||
*/
|
||||
export const MAIL_DIRECTORY_ACTIVE_DAYS = 90;
|
||||
|
||||
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
/**
|
||||
* 사내 목록 — 로그인에 성공한 직원을 기록하고(AuthService), 받는 사람 추천이 읽는다(RecipientSuggestionService).
|
||||
* 누가 넣을지(관리자·게이트웨이 제외)는 부르는 쪽이 정한다.
|
||||
*/
|
||||
@Injectable()
|
||||
export class MailDirectoryService {
|
||||
constructor(
|
||||
@InjectRepository(MailDirectoryEntryEntity)
|
||||
private readonly entries: Repository<MailDirectoryEntryEntity>,
|
||||
) {}
|
||||
|
||||
/** 로그인한 직원을 넣거나 이름·마지막 로그인 시각을 고친다 (주소 하나에 한 줄 — INSERT … ON CONFLICT) */
|
||||
async recordLogin(
|
||||
contact: DirectoryContact,
|
||||
now: Date = new Date(),
|
||||
): Promise<void> {
|
||||
await this.entries.upsert(
|
||||
{
|
||||
email: contact.email.trim().toLowerCase(),
|
||||
name: contact.name.trim().slice(0, DIRECTORY_NAME_LENGTH),
|
||||
lastLoginAt: now,
|
||||
},
|
||||
['email'],
|
||||
);
|
||||
}
|
||||
|
||||
/** 최근 MAIL_DIRECTORY_ACTIVE_DAYS 일 안에 로그인한 직원 — 최근에 로그인한 사람부터 */
|
||||
async listContacts(
|
||||
limit: number = MAX_DIRECTORY_CONTACTS,
|
||||
now: Date = new Date(),
|
||||
): Promise<DirectoryContact[]> {
|
||||
const activeSince = new Date(
|
||||
now.getTime() - MAIL_DIRECTORY_ACTIVE_DAYS * DAY_MS,
|
||||
);
|
||||
const rows = await this.entries.find({
|
||||
where: { lastLoginAt: MoreThanOrEqual(activeSince) },
|
||||
order: { lastLoginAt: 'DESC' },
|
||||
take: limit,
|
||||
});
|
||||
return rows.map(({ email, name }) => ({ email, name }));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { IsBoolean, IsOptional, IsString } from 'class-validator';
|
||||
|
||||
import {
|
||||
SIGNATURE_HTML_MAX_LENGTH,
|
||||
SIGNATURE_IMAGES_PER_SIGNATURE,
|
||||
SIGNATURE_IMAGE_MAX_BYTES,
|
||||
SIGNATURE_TEXT_MAX_LENGTH,
|
||||
} from '@/modules/mail-signature/mail-signature.constants';
|
||||
import type {
|
||||
MailSignature,
|
||||
SignatureImage,
|
||||
} from '@/modules/mail-signature/mail-signature.types';
|
||||
import type { SignatureImageType } from '@/modules/mail-signature/signature-image-format';
|
||||
|
||||
const IMAGE_TYPES: SignatureImageType[] = [
|
||||
'image/png',
|
||||
'image/jpeg',
|
||||
'image/gif',
|
||||
];
|
||||
|
||||
/** PUT /api/mail-signature — 계약 2절 "서명" */
|
||||
export class UpdateMailSignatureDto {
|
||||
@ApiPropertyOptional({
|
||||
description:
|
||||
`서명 서식 HTML — 서식 본문과 같은 허용 목록 + 내 서명 그림(<img src="/api/mail-signature/images/{id}" width="1~600" alt="">). ` +
|
||||
`정화한 뒤 ${SIGNATURE_HTML_MAX_LENGTH.toLocaleString('ko-KR')}자 이하, 서로 다른 그림 ${SIGNATURE_IMAGES_PER_SIGNATURE}장까지(넘으면 400 VAL_001). null·빈 문자열이면 서식 서명 없음. ` +
|
||||
'서명 HTML 이 더는 가리키지 않는 그림은 저장할 때 지운다',
|
||||
nullable: true,
|
||||
type: String,
|
||||
example:
|
||||
'<p style="margin:0"><strong>김아라</strong> · 아라시스템</p><img src="/api/mail-signature/images/3f1c0a2e-0f1b-4f2a-9a1e-6c9f0a1b2c3d" width="144" alt="">',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString({ message: '서명 형식이 올바르지 않습니다.' })
|
||||
bodyHtml?: string | null;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: `텍스트 서명 — 텍스트 대체 본문에 넣는다. ${SIGNATURE_TEXT_MAX_LENGTH.toLocaleString('ko-KR')}자 이하(JS 문자열 길이, 넘으면 400 VAL_001)`,
|
||||
nullable: true,
|
||||
type: String,
|
||||
example: '김아라 · 아라시스템',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString({ message: '텍스트 서명 형식이 올바르지 않습니다.' })
|
||||
body?: string | null;
|
||||
|
||||
@ApiProperty({ description: '새 메일에 넣는다', example: true })
|
||||
@IsBoolean({ message: '새 메일에 넣을지 여부를 다시 선택해 주세요.' })
|
||||
isForNew!: boolean;
|
||||
|
||||
@ApiProperty({ description: '답장·전달에 넣는다 (인용 위)', example: true })
|
||||
@IsBoolean({ message: '답장·전달에 넣을지 여부를 다시 선택해 주세요.' })
|
||||
isForReply!: boolean;
|
||||
}
|
||||
|
||||
export class SignatureImageDto implements SignatureImage {
|
||||
@ApiProperty({
|
||||
description:
|
||||
'그림 id — 서명 HTML 의 <img src="/api/mail-signature/images/{id}"> 에 쓴다',
|
||||
example: '3f1c0a2e-0f1b-4f2a-9a1e-6c9f0a1b2c3d',
|
||||
})
|
||||
id!: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: '파일 내용으로 확인한 형식',
|
||||
enum: IMAGE_TYPES,
|
||||
example: 'image/png',
|
||||
})
|
||||
type!: SignatureImageType;
|
||||
|
||||
@ApiProperty({
|
||||
description: `크기 (바이트, ${SIGNATURE_IMAGE_MAX_BYTES.toLocaleString('ko-KR')} 이하)`,
|
||||
example: 18342,
|
||||
})
|
||||
size!: number;
|
||||
|
||||
@ApiProperty({ description: '원본 가로 픽셀', example: 480 })
|
||||
width!: number;
|
||||
|
||||
@ApiProperty({ description: '원본 세로 픽셀', example: 120 })
|
||||
height!: number;
|
||||
}
|
||||
|
||||
export class MailSignatureDto implements MailSignature {
|
||||
@ApiProperty({
|
||||
description: `서명 서식 HTML (정화한 값, ${SIGNATURE_HTML_MAX_LENGTH.toLocaleString('ko-KR')}자 이하) — 저장한 적 없거나 비었으면 null`,
|
||||
nullable: true,
|
||||
type: String,
|
||||
})
|
||||
bodyHtml!: string | null;
|
||||
|
||||
@ApiProperty({
|
||||
description: '텍스트 서명 — 저장한 적 없거나 비었으면 null',
|
||||
nullable: true,
|
||||
type: String,
|
||||
})
|
||||
body!: string | null;
|
||||
|
||||
@ApiProperty({ description: '새 메일에 넣는다 (기본 true)', example: true })
|
||||
isForNew!: boolean;
|
||||
|
||||
@ApiProperty({
|
||||
description: '답장·전달에 넣는다 — 인용 위 (기본 true)',
|
||||
example: true,
|
||||
})
|
||||
isForReply!: boolean;
|
||||
|
||||
@ApiProperty({
|
||||
description: `서명 HTML 이 쓰는 그림 — HTML 에 나온 순서 (${SIGNATURE_IMAGES_PER_SIGNATURE}장까지)`,
|
||||
type: [SignatureImageDto],
|
||||
})
|
||||
images!: SignatureImageDto[];
|
||||
|
||||
@ApiProperty({
|
||||
description: '저장한 시각 (ISO 8601 UTC) — 저장한 적 없으면 null',
|
||||
nullable: true,
|
||||
type: String,
|
||||
example: '2026-10-27T05:12:00.000Z',
|
||||
})
|
||||
updatedAt!: string | null;
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import { Column, Entity, Index, PrimaryColumn } from 'typeorm';
|
||||
|
||||
/** 메일 주소 최대 길이 (RFC 5321 경로 한도) — mail.constants 의 MAX_EMAIL_LENGTH 와 같다 */
|
||||
const EMAIL_COLUMN_LENGTH = 254;
|
||||
/** 'image/jpeg' 등 */
|
||||
const CONTENT_TYPE_LENGTH = 32;
|
||||
/** 서버가 만든 파일 이름 — `<uuid>.<확장자>` */
|
||||
const FILE_NAME_LENGTH = 64;
|
||||
|
||||
/**
|
||||
* 서명 그림 (계약 2절 "서명") — 파일은 업로드 볼륨의 하위 폴더에 두고(mail-signature.constants.ts), 여기에는 정보만 둔다.
|
||||
* - 파일 이름은 서버가 만든다 (UUID + 내용으로 확인한 형식의 확장자). 올린 이름은 저장하지 않는다
|
||||
* - `attached_at`: 서명을 저장할 때 그 HTML 이 가리키면 채운다. 비어 있고 하루가 지난 그림은 정리 작업이 지운다
|
||||
*/
|
||||
@Entity({ name: 'mail_signature_images' })
|
||||
@Index('IDX_mail_signature_images_user_email', ['userEmail'])
|
||||
@Index('IDX_mail_signature_images_unattached', ['createdAt'], {
|
||||
where: '"attached_at" IS NULL',
|
||||
})
|
||||
export class MailSignatureImageEntity {
|
||||
@PrimaryColumn({ type: 'uuid' })
|
||||
id!: string;
|
||||
|
||||
@Column({ name: 'user_email', type: 'varchar', length: EMAIL_COLUMN_LENGTH })
|
||||
userEmail!: string;
|
||||
|
||||
/** 파일 내용으로 확인한 형식 (클라이언트가 보낸 값이 아니다) */
|
||||
@Column({
|
||||
name: 'content_type',
|
||||
type: 'varchar',
|
||||
length: CONTENT_TYPE_LENGTH,
|
||||
})
|
||||
contentType!: string;
|
||||
|
||||
/** 바이트 */
|
||||
@Column({ type: 'integer' })
|
||||
size!: number;
|
||||
|
||||
/** 원본 픽셀 — 파일 머리에서 읽었다 */
|
||||
@Column({ type: 'integer' })
|
||||
width!: number;
|
||||
|
||||
@Column({ type: 'integer' })
|
||||
height!: number;
|
||||
|
||||
@Column({ name: 'file_name', type: 'varchar', length: FILE_NAME_LENGTH })
|
||||
fileName!: string;
|
||||
|
||||
@Column({ name: 'created_at', type: 'timestamptz' })
|
||||
createdAt!: Date;
|
||||
|
||||
/** 저장한 서명이 처음 가리킨 시각 — null 이면 올리기만 한 그림 */
|
||||
@Column({ name: 'attached_at', type: 'timestamptz', nullable: true })
|
||||
attachedAt!: Date | null;
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
import { Logger } from '@nestjs/common';
|
||||
import { mkdtempSync, rmSync, utimesSync, writeFileSync } from 'fs';
|
||||
import { tmpdir } from 'os';
|
||||
import { join } from 'path';
|
||||
|
||||
import type { MailSignatureImageEntity } from './mail-signature-image.entity';
|
||||
import { MailSignatureImageCleanup } from './mail-signature.cleanup';
|
||||
import type { MailSignatureRepository } from './mail-signature.repository';
|
||||
import { SignatureImageStorage } from './signature-image-storage';
|
||||
|
||||
const NOW = new Date('2026-10-27T04:00:00Z');
|
||||
const HOUR = 60 * 60 * 1000;
|
||||
|
||||
const idOf = (n: number) => `0000000${n}-0000-4000-8000-000000000000`;
|
||||
|
||||
describe('MailSignatureImageCleanup (하루 한 번 서명 그림 정리)', () => {
|
||||
let dir: string;
|
||||
let storage: SignatureImageStorage;
|
||||
let repository: {
|
||||
findUnattachedBefore: jest.Mock<
|
||||
Promise<MailSignatureImageEntity[]>,
|
||||
[Date]
|
||||
>;
|
||||
deleteUnattachedImages: jest.Mock<
|
||||
Promise<Pick<MailSignatureImageEntity, 'id' | 'fileName'>[]>,
|
||||
[readonly string[]]
|
||||
>;
|
||||
listFileNames: jest.Mock<Promise<Set<string>>, []>;
|
||||
};
|
||||
let cleanup: MailSignatureImageCleanup;
|
||||
|
||||
/** 파일을 만들고 수정 시각을 `hoursAgo` 시간 전으로 둔다 */
|
||||
function file(n: number, hoursAgo: number): string {
|
||||
const fileName = `${idOf(n)}.png`;
|
||||
const path = join(dir, fileName);
|
||||
writeFileSync(path, 'png');
|
||||
const at = new Date(NOW.getTime() - hoursAgo * HOUR);
|
||||
utimesSync(path, at, at);
|
||||
return fileName;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
dir = mkdtempSync(join(tmpdir(), 'signature-cleanup-'));
|
||||
storage = new SignatureImageStorage(dir);
|
||||
repository = {
|
||||
findUnattachedBefore: jest
|
||||
.fn<Promise<MailSignatureImageEntity[]>, [Date]>()
|
||||
.mockResolvedValue([]),
|
||||
deleteUnattachedImages: jest
|
||||
.fn<
|
||||
Promise<Pick<MailSignatureImageEntity, 'id' | 'fileName'>[]>,
|
||||
[readonly string[]]
|
||||
>()
|
||||
.mockImplementation((ids) =>
|
||||
Promise.resolve(ids.map((id) => ({ id, fileName: `${id}.png` }))),
|
||||
),
|
||||
listFileNames: jest
|
||||
.fn<Promise<Set<string>>, []>()
|
||||
.mockResolvedValue(new Set()),
|
||||
};
|
||||
cleanup = new MailSignatureImageCleanup(
|
||||
repository as unknown as MailSignatureRepository,
|
||||
storage,
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('서명에 붙지 않은 채 하루가 지난 그림을 표와 파일에서 지운다', async () => {
|
||||
const stale = file(1, 30);
|
||||
repository.findUnattachedBefore.mockResolvedValue([
|
||||
{ id: idOf(1), fileName: stale } as MailSignatureImageEntity,
|
||||
]);
|
||||
repository.listFileNames.mockResolvedValue(new Set());
|
||||
|
||||
await expect(cleanup.purgeUnattachedImages(NOW)).resolves.toBe(1);
|
||||
|
||||
expect(repository.findUnattachedBefore).toHaveBeenCalledWith(
|
||||
new Date(NOW.getTime() - 24 * HOUR),
|
||||
);
|
||||
expect(repository.deleteUnattachedImages).toHaveBeenCalledWith([idOf(1)]);
|
||||
expect(await storage.list()).toEqual([]);
|
||||
});
|
||||
|
||||
it('표에 없는 파일은 하루가 지났을 때만 지운다 — 표에 있는 파일·막 쓴 파일·다른 이름의 파일은 그대로', async () => {
|
||||
const known = file(1, 48);
|
||||
const orphan = file(2, 25);
|
||||
const fresh = file(3, 1);
|
||||
writeFileSync(join(dir, 'README.txt'), '그림이 아니다');
|
||||
repository.listFileNames.mockResolvedValue(new Set([known]));
|
||||
|
||||
await expect(cleanup.purgeUnattachedImages(NOW)).resolves.toBe(1);
|
||||
|
||||
const left = (await storage.list()).map((stored) => stored.fileName).sort();
|
||||
expect(left).toEqual([known, fresh].sort());
|
||||
expect(left).not.toContain(orphan);
|
||||
});
|
||||
|
||||
it('찾은 뒤 그사이 서명에 붙은 그림은 지우지 않는다 — 실제로 지운 행의 파일만 지운다', async () => {
|
||||
const stillUnattached = file(1, 30);
|
||||
const attachedMeanwhile = file(2, 30);
|
||||
repository.findUnattachedBefore.mockResolvedValue([
|
||||
{ id: idOf(1), fileName: stillUnattached } as MailSignatureImageEntity,
|
||||
{ id: idOf(2), fileName: attachedMeanwhile } as MailSignatureImageEntity,
|
||||
]);
|
||||
// 저장소는 지우는 순간 attached_at IS NULL 을 다시 본다 — 2번은 그사이 붙어 지우지 않았다
|
||||
repository.deleteUnattachedImages.mockResolvedValue([
|
||||
{ id: idOf(1), fileName: stillUnattached },
|
||||
]);
|
||||
repository.listFileNames.mockResolvedValue(new Set([attachedMeanwhile]));
|
||||
|
||||
await expect(cleanup.purgeUnattachedImages(NOW)).resolves.toBe(1);
|
||||
|
||||
expect(repository.deleteUnattachedImages).toHaveBeenCalledWith([
|
||||
idOf(1),
|
||||
idOf(2),
|
||||
]);
|
||||
expect((await storage.list()).map((stored) => stored.fileName)).toEqual([
|
||||
attachedMeanwhile,
|
||||
]);
|
||||
});
|
||||
|
||||
it('정리에 실패해도 던지지 않는다 — 오류 로그를 남기고 다음 주기에 다시 한다', async () => {
|
||||
const errorSpy = jest
|
||||
.spyOn(Logger.prototype, 'error')
|
||||
.mockImplementation(() => undefined);
|
||||
repository.findUnattachedBefore.mockRejectedValue(new Error('db down'));
|
||||
|
||||
await expect(cleanup.purgeUnattachedImages(NOW)).resolves.toBe(0);
|
||||
expect(errorSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining('[SYS_001] 서명 그림 정리에 실패했습니다'),
|
||||
);
|
||||
});
|
||||
|
||||
it('지울 것이 없으면 아무것도 지우지 않는다 (폴더가 아직 없어도)', async () => {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
|
||||
await expect(cleanup.purgeUnattachedImages(NOW)).resolves.toBe(0);
|
||||
expect(repository.deleteUnattachedImages).toHaveBeenCalledWith([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,60 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { Cron, CronExpression } from '@nestjs/schedule';
|
||||
|
||||
import { UNATTACHED_SIGNATURE_IMAGE_RETENTION_MS } from './mail-signature.constants';
|
||||
import { MailSignatureRepository } from './mail-signature.repository';
|
||||
import { SignatureImageStorage } from './signature-image-storage';
|
||||
|
||||
/**
|
||||
* 서명 그림 정리 — 하루에 한 번 (auth-session.cleanup.ts 와 같은 모양).
|
||||
* - 올리고 서명에 저장하지 않은 채 하루가 지난 그림을 지운다 (표와 파일)
|
||||
* - 표에 없는 파일(올리다 끊긴 것 등)도 하루가 지났으면 지운다
|
||||
*
|
||||
* ⚠️ 여러 인스턴스로 늘리면 모든 인스턴스가 같은 시각에 같은 일을 한다 (결과는 같지만 낭비).
|
||||
*/
|
||||
@Injectable()
|
||||
export class MailSignatureImageCleanup {
|
||||
private readonly logger = new Logger(MailSignatureImageCleanup.name);
|
||||
|
||||
constructor(
|
||||
private readonly repository: MailSignatureRepository,
|
||||
private readonly storage: SignatureImageStorage,
|
||||
) {}
|
||||
|
||||
@Cron(CronExpression.EVERY_DAY_AT_4AM, {
|
||||
name: 'mail-signature-image-cleanup',
|
||||
})
|
||||
async purgeUnattachedImages(now: Date = new Date()): Promise<number> {
|
||||
try {
|
||||
const before = new Date(
|
||||
now.getTime() - UNATTACHED_SIGNATURE_IMAGE_RETENTION_MS,
|
||||
);
|
||||
|
||||
// 찾은 뒤 그사이 서명에 붙은 그림은 지우지 않는다 — 실제로 지운 행의 파일만 지운다
|
||||
const unattached = await this.repository.findUnattachedBefore(before);
|
||||
const deleted = await this.repository.deleteUnattachedImages(
|
||||
unattached.map((image) => image.id),
|
||||
);
|
||||
for (const image of deleted) await this.storage.remove(image.fileName);
|
||||
|
||||
const known = await this.repository.listFileNames();
|
||||
const orphans = (await this.storage.list()).filter(
|
||||
(file) => !known.has(file.fileName) && file.modifiedAt < before,
|
||||
);
|
||||
for (const file of orphans) await this.storage.remove(file.fileName);
|
||||
|
||||
const removed = deleted.length + orphans.length;
|
||||
// 반복되는 정상 작업이라 info 로 남기지 않는다 (.claude/rules/logging.md)
|
||||
if (removed > 0) {
|
||||
this.logger.debug(`서명 그림 ${removed}개를 지웠습니다.`);
|
||||
}
|
||||
return removed;
|
||||
} catch (error) {
|
||||
// 정리 작업이 실패해도 앱은 계속 돈다 — 다음 주기에 다시 시도한다
|
||||
this.logger.error(
|
||||
`[SYS_001] 서명 그림 정리에 실패했습니다: ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import { join } from 'path';
|
||||
|
||||
import { INLINE_IMAGE_MAX_BYTES } from '@/modules/mail/html/inline-images';
|
||||
import { UPLOAD_DIR } from '@/modules/upload/upload.config';
|
||||
|
||||
/**
|
||||
* 서명 한도 — 계약 2절 "서명"의 숫자를 한 곳에 모은다.
|
||||
*/
|
||||
|
||||
/**
|
||||
* 서명 그림 한 장 최대 크기 — 받은 메일의 내장 그림을 본문에 넣는 한도와 **같은 값**이다.
|
||||
* 더 크게 받으면 보낸 메일을 우리 웹메일로 다시 읽을 때(보낸편지함·받는 사람) 그림이 빠진다 (html/inline-images.ts).
|
||||
*/
|
||||
export const SIGNATURE_IMAGE_MAX_BYTES = INLINE_IMAGE_MAX_BYTES;
|
||||
|
||||
/**
|
||||
* 서명 그림의 가로·세로 상한(픽셀, 각각) — 넘으면 올리기가 400 FILE_001.
|
||||
* 300KB 안에도 수만×수만 픽셀(압축 폭탄)이 들 수 있고, PNG 머리의 가로·세로(32비트)는 표의 integer 열을 넘을 수 있다.
|
||||
* 서명에 넣는 폭은 600px 까지라(보내는 정화의 width) 충분히 넉넉하다.
|
||||
*/
|
||||
export const SIGNATURE_IMAGE_MAX_DIMENSION = 4096;
|
||||
|
||||
/** 서명 하나에 넣을 수 있는 서로 다른 그림 수 — 넘으면 서명 저장(PUT)이 400 VAL_001 */
|
||||
export const SIGNATURE_IMAGES_PER_SIGNATURE = 3;
|
||||
|
||||
/**
|
||||
* 한 사람이 둘 수 있는 서명 그림 수 — 서명에 붙은 것 + 올리고 아직 저장하지 않은 것. 넘으면 올리기가 400 VAL_001.
|
||||
* 편집기에서 그림을 넣었다 빼기를 되풀이해도(빼도 저장 전·10분 유예 동안은 남는다) 막히지 않게 서명 한도보다 넉넉히 둔다.
|
||||
* 저장하지 않은 그림은 하루 뒤 정리 작업이, 서명이 더는 가리키지 않는 그림은 다음 저장이 지운다.
|
||||
*/
|
||||
export const SIGNATURE_IMAGES_STORED_MAX = 10;
|
||||
|
||||
/** 서명 서식(HTML) 최대 길이 — 정화한 뒤 JS 문자열 길이 */
|
||||
export const SIGNATURE_HTML_MAX_LENGTH = 10_000;
|
||||
|
||||
/** 텍스트 서명 최대 길이 — JS 문자열 길이 (웹 클라이언트가 세는 방법과 같다) */
|
||||
export const SIGNATURE_TEXT_MAX_LENGTH = 2_000;
|
||||
|
||||
/** 올리고 서명에 저장하지 않은 그림을 지우기까지의 시간 */
|
||||
export const UNATTACHED_SIGNATURE_IMAGE_RETENTION_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
/**
|
||||
* 서명 저장(PUT)이 지우지 않고 남겨 두는, 막 올린 그림의 나이.
|
||||
* 올리기와 저장이 동시에 오면(그림을 넣자마자 [저장]) 저장 본문에는 아직 그 그림이 없다 — 그때 지우면
|
||||
* 편집기에 넣은 그림이 사라진다. 이 시간보다 오래된, 한 번도 저장하지 않은 그림은 저장할 때 지운다
|
||||
* (그 밖에는 하루 뒤 정리 작업이 지운다).
|
||||
*/
|
||||
export const SIGNATURE_IMAGE_UPLOAD_GRACE_MS = 10 * 60_000;
|
||||
|
||||
/**
|
||||
* 서명 그림 파일 위치 — 업로드 볼륨(modules/upload 와 같은 UPLOAD_DIR) 안의 하위 폴더.
|
||||
* 운영은 named volume, 개발은 바인드 마운트다 (.claude/skills/docker/SKILL.md).
|
||||
*/
|
||||
export const SIGNATURE_IMAGE_DIR = join(UPLOAD_DIR, 'mail-signatures');
|
||||
|
||||
/** 그림 응답의 브라우저 캐시 — 그림 id 는 내용이 바뀌지 않는다(새로 올리면 새 id). 본인만 보므로 private */
|
||||
export const SIGNATURE_IMAGE_CACHE_CONTROL = 'private, max-age=86400';
|
||||
@@ -0,0 +1,178 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Get,
|
||||
HttpStatus,
|
||||
Param,
|
||||
Post,
|
||||
Put,
|
||||
Res,
|
||||
StreamableFile,
|
||||
UploadedFile,
|
||||
UseGuards,
|
||||
UseInterceptors,
|
||||
} from '@nestjs/common';
|
||||
import { FileInterceptor } from '@nestjs/platform-express';
|
||||
import {
|
||||
ApiBody,
|
||||
ApiConsumes,
|
||||
ApiCookieAuth,
|
||||
ApiOperation,
|
||||
ApiProduces,
|
||||
ApiResponse,
|
||||
ApiTags,
|
||||
} from '@nestjs/swagger';
|
||||
import type { Response } from 'express';
|
||||
|
||||
import {
|
||||
ApiErrorResponse,
|
||||
ApiStandardResponse,
|
||||
} from '@/common/decorators/api-standard-response.decorator';
|
||||
import {
|
||||
CurrentUser,
|
||||
type AuthenticatedUser,
|
||||
} from '@/common/decorators/current-user.decorator';
|
||||
import { SkipTransform } from '@/common/decorators/skip-transform.decorator';
|
||||
import { BusinessException } from '@/common/errors/business.exception';
|
||||
import { JwtAuthGuard } from '@/common/guards/jwt-auth.guard';
|
||||
import {
|
||||
MailSignatureDto,
|
||||
SignatureImageDto,
|
||||
UpdateMailSignatureDto,
|
||||
} from './dto/mail-signature.dto';
|
||||
import {
|
||||
SIGNATURE_HTML_MAX_LENGTH,
|
||||
SIGNATURE_IMAGES_PER_SIGNATURE,
|
||||
SIGNATURE_IMAGES_STORED_MAX,
|
||||
SIGNATURE_IMAGE_CACHE_CONTROL,
|
||||
SIGNATURE_IMAGE_MAX_BYTES,
|
||||
SIGNATURE_TEXT_MAX_LENGTH,
|
||||
} from './mail-signature.constants';
|
||||
import { MailSignatureService } from './mail-signature.service';
|
||||
import { SIGNATURE_IMAGE_EXTENSIONS } from './signature-image-format';
|
||||
import { SignatureImageIdPipe } from './signature-image-id.pipe';
|
||||
|
||||
/** 서명 그림 응답 형식 — 저장할 때 파일 내용으로 확인한 형식 그대로 */
|
||||
const IMAGE_CONTENT_TYPES = ['image/png', 'image/jpeg', 'image/gif'];
|
||||
|
||||
// 메일 서명 (계약 2절 "서명") — 로그인한 직원 자신의 서명·그림만 다룬다.
|
||||
// 그림 업로드는 modules/upload 패턴을 따른다 (가드가 multer 보다 먼저 돌아 미인증 요청은 파일을 읽기 전에 막힌다).
|
||||
@ApiTags('Mail')
|
||||
@ApiCookieAuth()
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ApiErrorResponse('AUTH_001', '로그인 필요 (쿠키 없음·만료)')
|
||||
@Controller('mail-signature')
|
||||
export class MailSignatureController {
|
||||
constructor(private readonly signatures: MailSignatureService) {}
|
||||
|
||||
@Get()
|
||||
@ApiOperation({
|
||||
summary: '내 서명',
|
||||
description:
|
||||
'저장한 적 없으면 기본값(bodyHtml·body null, isForNew·isForReply true, images [])을 돌려준다.',
|
||||
})
|
||||
@ApiStandardResponse(MailSignatureDto, { description: '서명' })
|
||||
getSignature(@CurrentUser() user: AuthenticatedUser) {
|
||||
return this.signatures.getSignature(user.email);
|
||||
}
|
||||
|
||||
@Put()
|
||||
@ApiOperation({
|
||||
summary: '서명 저장 (전체 교체)',
|
||||
description:
|
||||
`bodyHtml 은 서식 본문 허용 목록 + 내 서명 그림으로 정화해 저장한다(정화 뒤 ${SIGNATURE_HTML_MAX_LENGTH.toLocaleString('ko-KR')}자 이하), ` +
|
||||
`body 는 ${SIGNATURE_TEXT_MAX_LENGTH.toLocaleString('ko-KR')}자 이하, 서로 다른 그림은 ${SIGNATURE_IMAGES_PER_SIGNATURE}장까지(같은 그림 두 번은 한 장). ` +
|
||||
'서명 HTML 이 더는 가리키지 않는 그림은 지운다 — 막 올리고(10분 안) 아직 넣지 않은 그림은 남긴다.',
|
||||
})
|
||||
@ApiStandardResponse(MailSignatureDto, { description: '저장한 서명' })
|
||||
@ApiErrorResponse(
|
||||
'VAL_001',
|
||||
`형식 오류 · 텍스트 서명 2,000자 초과 · 정화한 서식 서명 10,000자 초과 · 서식 서명이 너무 깊게 중첩됨 · 그림 ${SIGNATURE_IMAGES_PER_SIGNATURE}장 초과`,
|
||||
)
|
||||
saveSignature(
|
||||
@CurrentUser() user: AuthenticatedUser,
|
||||
@Body() dto: UpdateMailSignatureDto,
|
||||
) {
|
||||
return this.signatures.saveSignature(user.email, dto);
|
||||
}
|
||||
|
||||
@Post('images')
|
||||
@UseInterceptors(FileInterceptor('file'))
|
||||
@ApiOperation({
|
||||
summary: '서명 그림 올리기 (파일 1개)',
|
||||
description:
|
||||
`PNG·JPEG·GIF 만 — 확장자·MIME·파일 앞부분(내용)을 모두 본다(WebP·SVG 는 받지 않는다). ` +
|
||||
`한 장 ${SIGNATURE_IMAGE_MAX_BYTES.toLocaleString('ko-KR')}바이트 이하. 한 사람이 둘 수 있는 그림은 ${SIGNATURE_IMAGES_STORED_MAX}장까지 — 서명에 붙은 것 + 올리고 아직 저장하지 않은 것 ` +
|
||||
`(서명 하나에는 ${SIGNATURE_IMAGES_PER_SIGNATURE}장 — PUT 이 본다). ` +
|
||||
'서명을 저장하지 않으면 하루 뒤 지운다.',
|
||||
})
|
||||
@ApiConsumes('multipart/form-data')
|
||||
@ApiBody({
|
||||
schema: {
|
||||
type: 'object',
|
||||
properties: { file: { type: 'string', format: 'binary' } },
|
||||
required: ['file'],
|
||||
},
|
||||
})
|
||||
@ApiStandardResponse(SignatureImageDto, {
|
||||
status: HttpStatus.CREATED,
|
||||
description: '올린 그림 — 이 id 를 서명 HTML 의 img 주소에 쓴다',
|
||||
})
|
||||
@ApiErrorResponse(
|
||||
'FILE_001',
|
||||
'허용되지 않는 형식 · 내용이 확장자·MIME 과 다른 형식 (400)',
|
||||
)
|
||||
@ApiErrorResponse(
|
||||
'VAL_001',
|
||||
`파일 없음 · 둘 수 있는 그림 ${SIGNATURE_IMAGES_STORED_MAX}장 초과 (400)`,
|
||||
)
|
||||
@ApiErrorResponse('FILE_002', '한 장 300KB 초과')
|
||||
uploadImage(
|
||||
@CurrentUser() user: AuthenticatedUser,
|
||||
@UploadedFile() file?: Express.Multer.File,
|
||||
) {
|
||||
if (!file?.buffer) {
|
||||
// 파일 없이 보낸 요청 — multer 는 오류를 내지 않으므로 여기서 막는다
|
||||
throw new BusinessException('VAL_001', {
|
||||
context: { reason: 'signature-image-file-missing' },
|
||||
});
|
||||
}
|
||||
return this.signatures.uploadImage(user.email, {
|
||||
originalName: file.originalname,
|
||||
mimeType: file.mimetype,
|
||||
data: file.buffer,
|
||||
});
|
||||
}
|
||||
|
||||
@Get('images/:id')
|
||||
@SkipTransform()
|
||||
@ApiOperation({
|
||||
summary: '서명 그림 (표준 래퍼 없음, 내 그림만)',
|
||||
description:
|
||||
'편집기·미리 보기용 — 받는 사람은 이 주소를 쓰지 않는다(보낼 때 메일 안에 함께 싣는다). ' +
|
||||
'형식은 저장할 때 확인한 것, X-Content-Type-Options: nosniff, Content-Disposition: inline, 캐시는 private.',
|
||||
})
|
||||
@ApiProduces(...IMAGE_CONTENT_TYPES)
|
||||
@ApiResponse({
|
||||
status: HttpStatus.OK,
|
||||
description: '그림 파일',
|
||||
schema: { type: 'string', format: 'binary' },
|
||||
})
|
||||
@ApiErrorResponse('RES_001', '없는 그림 · 남의 그림 · 형식이 틀린 id')
|
||||
async downloadImage(
|
||||
@CurrentUser() user: AuthenticatedUser,
|
||||
@Param('id', SignatureImageIdPipe) id: string,
|
||||
@Res({ passthrough: true }) res: Response,
|
||||
): Promise<StreamableFile> {
|
||||
const image = await this.signatures.openImage(user.email, id);
|
||||
|
||||
// 본인만 보는 그림 — 공유 캐시에 남기지 않는다. 내용은 바뀌지 않으므로(새로 올리면 새 id) 브라우저는 하루 둔다
|
||||
res.setHeader('Cache-Control', SIGNATURE_IMAGE_CACHE_CONTROL);
|
||||
res.setHeader('X-Content-Type-Options', 'nosniff');
|
||||
return new StreamableFile(image.data, {
|
||||
type: image.type,
|
||||
disposition: `inline; filename="signature.${SIGNATURE_IMAGE_EXTENSIONS[image.type]}"`,
|
||||
length: image.data.length,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { Column, Entity, PrimaryColumn } from 'typeorm';
|
||||
|
||||
/** 메일 주소 최대 길이 (RFC 5321 경로 한도) — mail.constants 의 MAX_EMAIL_LENGTH 와 같다 */
|
||||
const EMAIL_COLUMN_LENGTH = 254;
|
||||
|
||||
/**
|
||||
* 메일 서명 (계약 2절 "서명") — 사용자마다 하나. 메일 엔진(JMAP Identity)이 아니라 우리 DB 에 둔다 —
|
||||
* 엔진을 바꿔도 남고, DB 백업에 함께 들어간다.
|
||||
* HTML 은 저장하기 전에 보내는 정화(서명 그림 포함)를 거친 값이다.
|
||||
*/
|
||||
@Entity({ name: 'mail_signatures' })
|
||||
export class MailSignatureEntity {
|
||||
@PrimaryColumn({
|
||||
name: 'user_email',
|
||||
type: 'varchar',
|
||||
length: EMAIL_COLUMN_LENGTH,
|
||||
})
|
||||
userEmail!: string;
|
||||
|
||||
@Column({ name: 'body_html', type: 'text', nullable: true })
|
||||
bodyHtml!: string | null;
|
||||
|
||||
@Column({ name: 'body_text', type: 'text', nullable: true })
|
||||
bodyText!: string | null;
|
||||
|
||||
/** 새 메일에 넣는다 */
|
||||
@Column({ name: 'is_for_new', type: 'boolean', default: true })
|
||||
isForNew!: boolean;
|
||||
|
||||
/** 답장·전달에 넣는다 (인용 위) */
|
||||
@Column({ name: 'is_for_reply', type: 'boolean', default: true })
|
||||
isForReply!: boolean;
|
||||
|
||||
@Column({ name: 'updated_at', type: 'timestamptz' })
|
||||
updatedAt!: Date;
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { MulterModule } from '@nestjs/platform-express';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { memoryStorage } from 'multer';
|
||||
import { extname } from 'path';
|
||||
|
||||
import { BusinessException } from '@/common/errors/business.exception';
|
||||
import { MailSignatureImageEntity } from './mail-signature-image.entity';
|
||||
import { MailSignatureImageCleanup } from './mail-signature.cleanup';
|
||||
import { SIGNATURE_IMAGE_MAX_BYTES } from './mail-signature.constants';
|
||||
import { MailSignatureController } from './mail-signature.controller';
|
||||
import { MailSignatureEntity } from './mail-signature.entity';
|
||||
import { MailSignatureRepository } from './mail-signature.repository';
|
||||
import { MailSignatureService } from './mail-signature.service';
|
||||
import { SIGNATURE_IMAGE_TYPES_BY_EXTENSION } from './signature-image-format';
|
||||
import { SignatureImageStorage } from './signature-image-storage';
|
||||
|
||||
/**
|
||||
* 메일 서명 모듈 (계약 2절 "서명") — 서명·그림은 서버 DB 와 업로드 볼륨에 둔다.
|
||||
* 메일 모듈이 발송·임시 저장 때 그림을 쓰도록 서비스를 내보낸다 (이 모듈은 메일 모듈을 가져오지 않는다).
|
||||
*
|
||||
* 그림 업로드: 한 장 300KB 이하라 메모리에 받는다 — 파일 내용(앞부분)을 본 뒤에만 디스크에 서버가 만든 이름으로 쓴다.
|
||||
* 형식 사전 검사는 확장자 + MIME 짝(FILE_001), 크기 초과는 multer → 413(FILE_002).
|
||||
*/
|
||||
@Module({
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([MailSignatureEntity, MailSignatureImageEntity]),
|
||||
MulterModule.register({
|
||||
storage: memoryStorage(),
|
||||
limits: { fileSize: SIGNATURE_IMAGE_MAX_BYTES, files: 1, fields: 10 },
|
||||
// 브라우저는 파일명을 UTF-8 그대로 보낸다 — 기본값(latin1)이면 한글 이름이 깨진다
|
||||
defParamCharset: 'utf8',
|
||||
fileFilter: (_req, file, callback) => {
|
||||
const type = SIGNATURE_IMAGE_TYPES_BY_EXTENSION.get(
|
||||
extname(file.originalname).toLowerCase(),
|
||||
);
|
||||
if (!type || type !== file.mimetype) {
|
||||
callback(
|
||||
new BusinessException('FILE_001', {
|
||||
context: { reason: 'signature-image-type-not-allowed' },
|
||||
}),
|
||||
false,
|
||||
);
|
||||
return;
|
||||
}
|
||||
callback(null, true);
|
||||
},
|
||||
}),
|
||||
],
|
||||
controllers: [MailSignatureController],
|
||||
providers: [
|
||||
MailSignatureRepository,
|
||||
{
|
||||
provide: SignatureImageStorage,
|
||||
useFactory: () => new SignatureImageStorage(),
|
||||
},
|
||||
MailSignatureService,
|
||||
MailSignatureImageCleanup,
|
||||
],
|
||||
exports: [MailSignatureService],
|
||||
})
|
||||
export class MailSignatureModule {}
|
||||
@@ -0,0 +1,167 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { DataSource, In, IsNull, LessThan, type Repository } from 'typeorm';
|
||||
|
||||
import { MailSignatureImageEntity } from './mail-signature-image.entity';
|
||||
import { MailSignatureEntity } from './mail-signature.entity';
|
||||
|
||||
/**
|
||||
* 서명 저장을 사용자별로 차례대로 — `pg_advisory_xact_lock(이 값, hashtext(주소))` (트랜잭션이 끝나면 풀린다).
|
||||
* 서명 행이 아직 없을 때도 잠긴다 (행 잠금 FOR UPDATE 는 없는 행을 잠그지 못한다). 첫 인자는 다른 잠금과 섞이지 않게 붙인 이름표 — 'SIG1' 의 ASCII.
|
||||
*/
|
||||
const SIGNATURE_SAVE_LOCK = 0x53494731;
|
||||
|
||||
/** 서명 저장 — 잠근 뒤 본 그림으로 저장할 서명을 정한다 (서비스가 그사이 없어진 그림을 서명에서 뺀다) */
|
||||
export type PrepareSignature = (
|
||||
images: readonly MailSignatureImageEntity[],
|
||||
) => {
|
||||
readonly signature: MailSignatureEntity;
|
||||
readonly referencedImageIds: readonly string[];
|
||||
};
|
||||
|
||||
/** 서명 저장 결과 */
|
||||
export interface SavedSignature {
|
||||
readonly signature: MailSignatureEntity;
|
||||
/** 저장한 뒤 남은 그 사람의 그림 (올린 순서) */
|
||||
readonly images: MailSignatureImageEntity[];
|
||||
/** 지운 그림 — 파일은 부르는 쪽이 커밋 뒤에 지운다 */
|
||||
readonly removed: MailSignatureImageEntity[];
|
||||
}
|
||||
|
||||
/**
|
||||
* 서명·서명 그림 표 접근 — 판단(정화·한도·누가 볼 수 있나)은 MailSignatureService 가 한다.
|
||||
* 그림을 찾을 때는 늘 주인(user_email)을 함께 조건에 넣는다 — 남의 그림 id 로는 아무것도 나오지 않는다.
|
||||
*/
|
||||
@Injectable()
|
||||
export class MailSignatureRepository {
|
||||
constructor(
|
||||
@InjectRepository(MailSignatureEntity)
|
||||
private readonly signatures: Repository<MailSignatureEntity>,
|
||||
@InjectRepository(MailSignatureImageEntity)
|
||||
private readonly images: Repository<MailSignatureImageEntity>,
|
||||
private readonly dataSource: DataSource,
|
||||
) {}
|
||||
|
||||
findSignature(userEmail: string): Promise<MailSignatureEntity | null> {
|
||||
return this.signatures.findOneBy({ userEmail });
|
||||
}
|
||||
|
||||
/** 그 사람의 그림 — 올린 순서대로 */
|
||||
listImages(userEmail: string): Promise<MailSignatureImageEntity[]> {
|
||||
return this.images.find({
|
||||
where: { userEmail },
|
||||
order: { createdAt: 'ASC' },
|
||||
});
|
||||
}
|
||||
|
||||
findImage(
|
||||
id: string,
|
||||
userEmail: string,
|
||||
): Promise<MailSignatureImageEntity | null> {
|
||||
return this.images.findOneBy({ id, userEmail });
|
||||
}
|
||||
|
||||
countImages(userEmail: string): Promise<number> {
|
||||
return this.images.countBy({ userEmail });
|
||||
}
|
||||
|
||||
async insertImage(image: MailSignatureImageEntity): Promise<void> {
|
||||
await this.images.insert(image);
|
||||
}
|
||||
|
||||
/**
|
||||
* 서명을 저장한다 — 한 트랜잭션에서, 사용자별로 차례대로 (두 탭이 동시에 저장해도 서로의 그림을 엉뚱하게 지우지 않게).
|
||||
* 1) 사용자 잠금 2) 그 사람의 그림을 잠가 읽는다(FOR UPDATE — 정리 작업이 그사이 지우지 못한다) 3) `prepare` 가 저장할 서명을 정한다
|
||||
* 4) 서명을 넣거나 고치고, 서명이 가리키는 그림은 붙은 것으로 표시하고, 가리키지 않는 그림은 지운다 —
|
||||
* 다만 한 번도 저장하지 않은 그림 중 `keepUploadedAfter` 뒤에 올린 것은 남긴다 (막 올린 그림 — SIGNATURE_IMAGE_UPLOAD_GRACE_MS).
|
||||
*/
|
||||
saveSignature(
|
||||
userEmail: string,
|
||||
prepare: PrepareSignature,
|
||||
now: Date,
|
||||
keepUploadedAfter: Date,
|
||||
): Promise<SavedSignature> {
|
||||
return this.dataSource.transaction(async (manager) => {
|
||||
await manager.query('SELECT pg_advisory_xact_lock($1, hashtext($2))', [
|
||||
SIGNATURE_SAVE_LOCK,
|
||||
userEmail,
|
||||
]);
|
||||
const images = await manager.find(MailSignatureImageEntity, {
|
||||
where: { userEmail },
|
||||
order: { createdAt: 'ASC' },
|
||||
lock: { mode: 'pessimistic_write' },
|
||||
});
|
||||
const { signature, referencedImageIds } = prepare(images);
|
||||
await manager.upsert(MailSignatureEntity, signature, ['userEmail']);
|
||||
|
||||
const referenced = new Set(referencedImageIds);
|
||||
const attach = images
|
||||
.filter(
|
||||
(image) => referenced.has(image.id) && image.attachedAt === null,
|
||||
)
|
||||
.map((image) => image.id);
|
||||
const stale = images.filter(
|
||||
(image) =>
|
||||
!referenced.has(image.id) &&
|
||||
(image.attachedAt !== null || image.createdAt <= keepUploadedAfter),
|
||||
);
|
||||
|
||||
if (attach.length > 0) {
|
||||
await manager.update(
|
||||
MailSignatureImageEntity,
|
||||
{ id: In(attach) },
|
||||
{ attachedAt: now },
|
||||
);
|
||||
}
|
||||
if (stale.length > 0) {
|
||||
await manager.delete(MailSignatureImageEntity, {
|
||||
id: In(stale.map((image) => image.id)),
|
||||
});
|
||||
}
|
||||
const attached = new Set(attach);
|
||||
return {
|
||||
signature,
|
||||
images: images
|
||||
.filter((image) => !stale.includes(image))
|
||||
.map((image) =>
|
||||
attached.has(image.id) ? { ...image, attachedAt: now } : image,
|
||||
),
|
||||
removed: stale,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/** 서명에 붙지 않은 채 `before` 전에 올린 그림 (정리 작업용 — 부분 인덱스를 탄다) */
|
||||
findUnattachedBefore(before: Date): Promise<MailSignatureImageEntity[]> {
|
||||
return this.images.find({
|
||||
where: { attachedAt: IsNull(), createdAt: LessThan(before) },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 서명에 붙지 않은 그림을 지운다 — 지우는 그 순간에도 붙지 않았을 때만(`attached_at IS NULL` 을 다시 본다).
|
||||
* 찾은 뒤 그사이 서명에 붙은 그림은 남는다 (서명 저장은 그림 행을 잠그므로 저장이 끝난 뒤의 값으로 다시 본다).
|
||||
* @returns 실제로 지운 그림 — 이 그림들의 파일만 지운다
|
||||
*/
|
||||
async deleteUnattachedImages(
|
||||
ids: readonly string[],
|
||||
): Promise<Pick<MailSignatureImageEntity, 'id' | 'fileName'>[]> {
|
||||
if (ids.length === 0) return [];
|
||||
const result = await this.images
|
||||
.createQueryBuilder()
|
||||
.delete()
|
||||
.where('id IN (:...ids)', { ids: [...ids] })
|
||||
.andWhere('attached_at IS NULL')
|
||||
// 배열은 엔티티 속성 이름이다 — SQL 은 RETURNING "id", "file_name" (행의 키는 열 이름)
|
||||
.returning(['id', 'fileName'])
|
||||
.execute();
|
||||
const rows = (result.raw ?? []) as { id: string; file_name: string }[];
|
||||
return rows.map((row) => ({ id: row.id, fileName: row.file_name }));
|
||||
}
|
||||
|
||||
/** 표에 있는 모든 그림의 파일 이름 (정리 작업이 표에 없는 파일을 찾는다) */
|
||||
async listFileNames(): Promise<Set<string>> {
|
||||
const rows = await this.images.find({ select: { fileName: true } });
|
||||
return new Set(rows.map((row) => row.fileName));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,577 @@
|
||||
import { Logger } from '@nestjs/common';
|
||||
import { existsSync, mkdtempSync, rmSync } from 'fs';
|
||||
import { tmpdir } from 'os';
|
||||
import { join } from 'path';
|
||||
|
||||
import { gifBytes, pngBytes } from '@/../test/image-fixtures';
|
||||
import { BusinessException } from '@/common/errors/business.exception';
|
||||
import { MAX_BODY_HTML_LENGTH } from '@/modules/mail/mail.constants';
|
||||
import { MailSignatureImageEntity } from './mail-signature-image.entity';
|
||||
import {
|
||||
SIGNATURE_IMAGES_PER_SIGNATURE,
|
||||
SIGNATURE_IMAGES_STORED_MAX,
|
||||
SIGNATURE_IMAGE_MAX_BYTES,
|
||||
SIGNATURE_IMAGE_MAX_DIMENSION,
|
||||
SIGNATURE_IMAGE_UPLOAD_GRACE_MS,
|
||||
} from './mail-signature.constants';
|
||||
import { MailSignatureEntity } from './mail-signature.entity';
|
||||
import type {
|
||||
MailSignatureRepository,
|
||||
PrepareSignature,
|
||||
SavedSignature,
|
||||
} from './mail-signature.repository';
|
||||
import { MailSignatureService } from './mail-signature.service';
|
||||
import { SignatureImageStorage } from './signature-image-storage';
|
||||
|
||||
const ARA = 'ara.kim@arasystem.test';
|
||||
const DOYUN = 'doyun.lee@arasystem.test';
|
||||
const src = (id: string) => `/api/mail-signature/images/${id}`;
|
||||
|
||||
/**
|
||||
* 표 대역 — MailSignatureRepository 와 같은 판단(주인 조건·저장 때 지울 그림)을 메모리에서 한다.
|
||||
* 실제 SQL(트랜잭션·부분 인덱스)은 e2e 가 본다.
|
||||
*/
|
||||
class FakeRepository {
|
||||
readonly signatures = new Map<string, MailSignatureEntity>();
|
||||
images: MailSignatureImageEntity[] = [];
|
||||
/** 다음 insertImage 를 실패시킨다 */
|
||||
failNextInsert = false;
|
||||
/** 다음 저장이 잠그기 직전에 이 그림이 지워진다 (다른 탭의 저장·정리 작업이 먼저 끝난 경우) */
|
||||
deletedBeforeNextSave: string | null = null;
|
||||
readonly saveCalls: {
|
||||
referenced: readonly string[];
|
||||
keepUploadedAfter: Date;
|
||||
}[] = [];
|
||||
|
||||
findSignature(user: string): Promise<MailSignatureEntity | null> {
|
||||
return Promise.resolve(this.signatures.get(user) ?? null);
|
||||
}
|
||||
|
||||
listImages(user: string): Promise<MailSignatureImageEntity[]> {
|
||||
return Promise.resolve(this.images.filter((row) => row.userEmail === user));
|
||||
}
|
||||
|
||||
findImage(
|
||||
id: string,
|
||||
user: string,
|
||||
): Promise<MailSignatureImageEntity | null> {
|
||||
return Promise.resolve(
|
||||
this.images.find((row) => row.id === id && row.userEmail === user) ??
|
||||
null,
|
||||
);
|
||||
}
|
||||
|
||||
countImages(user: string): Promise<number> {
|
||||
return Promise.resolve(
|
||||
this.images.filter((row) => row.userEmail === user).length,
|
||||
);
|
||||
}
|
||||
|
||||
insertImage(row: MailSignatureImageEntity): Promise<void> {
|
||||
if (this.failNextInsert) {
|
||||
this.failNextInsert = false;
|
||||
return Promise.reject(new Error('db down'));
|
||||
}
|
||||
this.images.push(row);
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
saveSignature(
|
||||
user: string,
|
||||
prepare: PrepareSignature,
|
||||
now: Date,
|
||||
keepUploadedAfter: Date,
|
||||
): Promise<SavedSignature> {
|
||||
if (this.deletedBeforeNextSave) {
|
||||
const gone = this.deletedBeforeNextSave;
|
||||
this.deletedBeforeNextSave = null;
|
||||
this.images = this.images.filter((row) => row.id !== gone);
|
||||
}
|
||||
const mine = this.images.filter((row) => row.userEmail === user);
|
||||
const { signature, referencedImageIds: referenced } = prepare(mine);
|
||||
this.saveCalls.push({ referenced, keepUploadedAfter });
|
||||
this.signatures.set(signature.userEmail, signature);
|
||||
for (const row of mine) {
|
||||
if (referenced.includes(row.id) && row.attachedAt === null) {
|
||||
row.attachedAt = now;
|
||||
}
|
||||
}
|
||||
const stale = mine.filter(
|
||||
(row) =>
|
||||
!referenced.includes(row.id) &&
|
||||
(row.attachedAt !== null || row.createdAt <= keepUploadedAfter),
|
||||
);
|
||||
this.images = this.images.filter((row) => !stale.includes(row));
|
||||
return Promise.resolve({
|
||||
signature,
|
||||
images: mine.filter((row) => !stale.includes(row)),
|
||||
removed: stale,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
describe('MailSignatureService', () => {
|
||||
let dir: string;
|
||||
let repository: FakeRepository;
|
||||
let storage: SignatureImageStorage;
|
||||
let service: MailSignatureService;
|
||||
let warnSpy: jest.SpyInstance;
|
||||
|
||||
const upload = (data: Buffer, name = 'logo.png', mimeType = 'image/png') =>
|
||||
service.uploadImage(ARA, { originalName: name, mimeType, data });
|
||||
|
||||
async function failureOf(
|
||||
run: () => Promise<unknown>,
|
||||
): Promise<{ code?: string; reason?: unknown }> {
|
||||
try {
|
||||
await run();
|
||||
} catch (error) {
|
||||
return error instanceof BusinessException
|
||||
? { code: error.code, reason: error.context?.reason }
|
||||
: { code: 'NOT_BUSINESS' };
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
/** 이미 올려 둔 그림 한 장 (표 + 파일) */
|
||||
async function storedImage(
|
||||
overrides: Partial<MailSignatureImageEntity> = {},
|
||||
): Promise<MailSignatureImageEntity> {
|
||||
const id =
|
||||
overrides.id ??
|
||||
`0000000${repository.images.length}-0000-4000-8000-000000000000`;
|
||||
const row: MailSignatureImageEntity = {
|
||||
id,
|
||||
userEmail: ARA,
|
||||
contentType: 'image/png',
|
||||
size: 33,
|
||||
width: 10,
|
||||
height: 10,
|
||||
fileName: `${id}.png`,
|
||||
createdAt: new Date('2026-10-26T00:00:00Z'),
|
||||
attachedAt: null,
|
||||
...overrides,
|
||||
};
|
||||
repository.images.push(row);
|
||||
await storage.write(row.fileName, pngBytes(row.width, row.height));
|
||||
return row;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
dir = mkdtempSync(join(tmpdir(), 'signature-images-'));
|
||||
repository = new FakeRepository();
|
||||
storage = new SignatureImageStorage(dir);
|
||||
service = new MailSignatureService(
|
||||
repository as unknown as MailSignatureRepository,
|
||||
storage,
|
||||
);
|
||||
warnSpy = jest
|
||||
.spyOn(Logger.prototype, 'warn')
|
||||
.mockImplementation(() => undefined);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('저장한 적 없으면 기본값을 돌려준다', async () => {
|
||||
await expect(service.getSignature(ARA)).resolves.toEqual({
|
||||
bodyHtml: null,
|
||||
body: null,
|
||||
isForNew: true,
|
||||
isForReply: true,
|
||||
images: [],
|
||||
updatedAt: null,
|
||||
});
|
||||
});
|
||||
|
||||
describe('저장', () => {
|
||||
it('서식 서명은 보내는 정화를 거쳐 저장한다 — 내 그림만 남고, 서명이 쓰는 그림을 함께 돌려준다', async () => {
|
||||
const mine = await storedImage();
|
||||
const html = `<p onclick="x()"><strong>김아라</strong><script>alert(1)</script></p><img src="${src(mine.id)}" width="144" alt="" style="border:0"><img src="${src('11111111-1111-4111-8111-111111111111')}">`;
|
||||
|
||||
const saved = await service.saveSignature(` ${ARA.toUpperCase()} `, {
|
||||
bodyHtml: html,
|
||||
body: '김아라 · 아라시스템',
|
||||
isForNew: true,
|
||||
isForReply: false,
|
||||
});
|
||||
|
||||
expect(saved).toEqual({
|
||||
bodyHtml: `<p><strong>김아라</strong></p><img src="${src(mine.id)}" width="144" alt="" />`,
|
||||
body: '김아라 · 아라시스템',
|
||||
isForNew: true,
|
||||
isForReply: false,
|
||||
images: [
|
||||
{ id: mine.id, type: 'image/png', size: 33, width: 10, height: 10 },
|
||||
],
|
||||
updatedAt: expect.any(String) as string,
|
||||
});
|
||||
// 주소는 소문자로 모은다 — 같은 사람의 서명이 둘로 나뉘지 않게
|
||||
expect(repository.signatures.has(ARA)).toBe(true);
|
||||
expect(repository.saveCalls[0].referenced).toEqual([mine.id]);
|
||||
await expect(service.getSignature(ARA)).resolves.toEqual(saved);
|
||||
});
|
||||
|
||||
it('막 올린 그림(10분 안)은 서명에 없어도 남기도록 기준 시각을 넘긴다 — 올리기와 저장이 엇갈려도 편집기의 그림이 사라지지 않게', async () => {
|
||||
const before = Date.now();
|
||||
|
||||
await service.saveSignature(ARA, {
|
||||
bodyHtml: null,
|
||||
body: null,
|
||||
isForNew: true,
|
||||
isForReply: true,
|
||||
});
|
||||
|
||||
const { keepUploadedAfter } = repository.saveCalls[0];
|
||||
expect(keepUploadedAfter.getTime()).toBeGreaterThanOrEqual(
|
||||
before - SIGNATURE_IMAGE_UPLOAD_GRACE_MS,
|
||||
);
|
||||
expect(keepUploadedAfter.getTime()).toBeLessThanOrEqual(
|
||||
Date.now() - SIGNATURE_IMAGE_UPLOAD_GRACE_MS,
|
||||
);
|
||||
});
|
||||
|
||||
it('잠그기 전에 다른 저장·정리 작업이 지운 그림은 서명에서 뺀다 — 없는 그림을 가리키는 서명을 남기지 않는다', async () => {
|
||||
const kept = await storedImage();
|
||||
const gone = await storedImage();
|
||||
repository.deletedBeforeNextSave = gone.id;
|
||||
|
||||
const saved = await service.saveSignature(ARA, {
|
||||
bodyHtml: `<p>서명</p><img src="${src(kept.id)}" width="96"><img src="${src(gone.id)}" width="96">`,
|
||||
body: null,
|
||||
isForNew: true,
|
||||
isForReply: true,
|
||||
});
|
||||
|
||||
expect(saved.bodyHtml).toBe(
|
||||
`<p>서명</p><img src="${src(kept.id)}" width="96" />`,
|
||||
);
|
||||
expect(saved.images.map((image) => image.id)).toEqual([kept.id]);
|
||||
expect(repository.saveCalls[0].referenced).toEqual([kept.id]);
|
||||
|
||||
// 그림만 있던 서명에서 그림이 모두 사라지면 서식 서명이 없다
|
||||
repository.deletedBeforeNextSave = kept.id;
|
||||
const empty = await service.saveSignature(ARA, {
|
||||
bodyHtml: `<img src="${src(kept.id)}">`,
|
||||
body: '텍스트 서명',
|
||||
isForNew: true,
|
||||
isForReply: true,
|
||||
});
|
||||
expect(empty.bodyHtml).toBeNull();
|
||||
expect(empty.body).toBe('텍스트 서명');
|
||||
});
|
||||
|
||||
it('서명이 더는 가리키지 않는 그림은 표와 파일에서 지운다', async () => {
|
||||
const kept = await storedImage({ attachedAt: new Date() });
|
||||
const dropped = await storedImage({ attachedAt: new Date() });
|
||||
|
||||
await service.saveSignature(ARA, {
|
||||
bodyHtml: `<img src="${src(kept.id)}">`,
|
||||
body: null,
|
||||
isForNew: true,
|
||||
isForReply: true,
|
||||
});
|
||||
|
||||
expect(repository.images.map((row) => row.id)).toEqual([kept.id]);
|
||||
expect(existsSync(join(dir, kept.fileName))).toBe(true);
|
||||
expect(existsSync(join(dir, dropped.fileName))).toBe(false);
|
||||
});
|
||||
|
||||
it('지운 그림의 파일을 지우지 못해도 저장은 끝난다 — 경고만 (남은 파일은 정리 작업이 지운다)', async () => {
|
||||
const dropped = await storedImage({ attachedAt: new Date() });
|
||||
jest
|
||||
.spyOn(storage, 'remove')
|
||||
.mockRejectedValue(new Error('EACCES: permission denied'));
|
||||
|
||||
await expect(
|
||||
service.saveSignature(ARA, {
|
||||
bodyHtml: '<p>그림 없음</p>',
|
||||
body: null,
|
||||
isForNew: true,
|
||||
isForReply: true,
|
||||
}),
|
||||
).resolves.toMatchObject({ bodyHtml: '<p>그림 없음</p>', images: [] });
|
||||
expect(repository.images).not.toContainEqual(dropped);
|
||||
expect(warnSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining('서명 그림 파일을 지우지 못했습니다'),
|
||||
);
|
||||
});
|
||||
|
||||
it('빈 서식·텍스트, 정화해서 비는 서식은 null 로 저장한다', async () => {
|
||||
for (const bodyHtml of ['', '<script>x()</script>', null, undefined]) {
|
||||
const saved = await service.saveSignature(ARA, {
|
||||
bodyHtml,
|
||||
body: '',
|
||||
isForNew: false,
|
||||
isForReply: true,
|
||||
});
|
||||
expect(saved.bodyHtml).toBeNull();
|
||||
expect(saved.body).toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
it(`서명 하나에 서로 다른 그림은 ${SIGNATURE_IMAGES_PER_SIGNATURE}장까지 — 넘으면 400 VAL_001 (같은 그림 두 번은 한 장 · 남의 그림은 정화가 지워 세지 않는다)`, async () => {
|
||||
const images: MailSignatureImageEntity[] = [];
|
||||
for (
|
||||
let index = 0;
|
||||
index < SIGNATURE_IMAGES_PER_SIGNATURE + 1;
|
||||
index += 1
|
||||
) {
|
||||
images.push(await storedImage());
|
||||
}
|
||||
const img = (id: string) => `<img src="${src(id)}" width="96">`;
|
||||
const save = (bodyHtml: string) =>
|
||||
service.saveSignature(ARA, {
|
||||
bodyHtml,
|
||||
body: null,
|
||||
isForNew: true,
|
||||
isForReply: true,
|
||||
});
|
||||
|
||||
expect(
|
||||
await failureOf(() =>
|
||||
save(images.map((image) => img(image.id)).join('')),
|
||||
),
|
||||
).toEqual({ code: 'VAL_001', reason: 'signature-too-many-images' });
|
||||
expect(repository.saveCalls).toHaveLength(0);
|
||||
|
||||
// 세 장 + 같은 그림 한 번 더 + 남의 그림 — 서로 다른 내 그림은 세 장이다
|
||||
const [a, b, c] = images;
|
||||
const saved = await save(
|
||||
[a, b, c, a].map((image) => img(image.id)).join('') +
|
||||
img('11111111-1111-4111-8111-111111111111'),
|
||||
);
|
||||
expect(saved.images.map((image) => image.id)).toEqual([a.id, b.id, c.id]);
|
||||
});
|
||||
|
||||
it('한도를 넘으면 400 VAL_001 — 저장하지 않는다 (텍스트 2,000자 · 정화한 서식 10,000자 · 정화 전 길이·비용)', async () => {
|
||||
const cases: [Record<string, string>, string][] = [
|
||||
[{ body: '가'.repeat(2_001) }, 'signature-text-too-long'],
|
||||
[
|
||||
{ bodyHtml: `<p>${'가'.repeat(10_000)}</p>` },
|
||||
'signature-html-too-long',
|
||||
],
|
||||
[
|
||||
{ bodyHtml: 'x'.repeat(MAX_BODY_HTML_LENGTH + 1) },
|
||||
'signature-html-too-long',
|
||||
],
|
||||
[{ bodyHtml: '<b>'.repeat(166_666) }, 'signature-html-too-complex'],
|
||||
];
|
||||
|
||||
for (const [input, reason] of cases) {
|
||||
expect(
|
||||
await failureOf(() =>
|
||||
service.saveSignature(ARA, {
|
||||
isForNew: true,
|
||||
isForReply: true,
|
||||
...input,
|
||||
}),
|
||||
),
|
||||
).toEqual({ code: 'VAL_001', reason });
|
||||
}
|
||||
expect(repository.saveCalls).toHaveLength(0);
|
||||
|
||||
// 경계 — 정확히 2,000자 · 정화한 결과 10,000자는 저장한다
|
||||
const saved = await service.saveSignature(ARA, {
|
||||
body: '가'.repeat(2_000),
|
||||
bodyHtml: `<p>${'가'.repeat(10_000 - 7)}</p>`,
|
||||
isForNew: true,
|
||||
isForReply: true,
|
||||
});
|
||||
expect(saved.bodyHtml).toHaveLength(10_000);
|
||||
});
|
||||
});
|
||||
|
||||
describe('그림 올리기', () => {
|
||||
it('PNG 를 내용으로 확인하고 서버가 만든 이름으로 저장한다 — 가로·세로는 파일 머리에서 읽는다', async () => {
|
||||
const data = pngBytes(480, 120, 2_048);
|
||||
|
||||
const image = await upload(data, '로고 (최종).PNG');
|
||||
|
||||
expect(image).toEqual({
|
||||
id: expect.stringMatching(
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/,
|
||||
) as string,
|
||||
type: 'image/png',
|
||||
size: 2_048,
|
||||
width: 480,
|
||||
height: 120,
|
||||
});
|
||||
const [row] = repository.images;
|
||||
expect(row).toMatchObject({
|
||||
userEmail: ARA,
|
||||
fileName: `${image.id}.png`,
|
||||
attachedAt: null,
|
||||
});
|
||||
expect(existsSync(join(dir, `${image.id}.png`))).toBe(true);
|
||||
});
|
||||
|
||||
it('내용이 확장자·MIME 과 다른 형식이면 FILE_001 — 아무것도 저장하지 않는다', async () => {
|
||||
for (const [data, name, mimeType] of [
|
||||
// PNG 내용인데 이름은 JPEG
|
||||
[pngBytes(10, 10), 'logo.jpg', 'image/jpeg'],
|
||||
// GIF 내용인데 PNG 라고 한다
|
||||
[gifBytes(10, 10), 'logo.png', 'image/png'],
|
||||
// 그림이 아니다
|
||||
[Buffer.from('<svg onload="x()"></svg>'), 'logo.png', 'image/png'],
|
||||
// 이름과 MIME 이 서로 다르다
|
||||
[pngBytes(10, 10), 'logo.png', 'image/gif'],
|
||||
] as const) {
|
||||
expect(await failureOf(() => upload(data, name, mimeType))).toEqual({
|
||||
code: 'FILE_001',
|
||||
reason: 'signature-image-content-mismatch',
|
||||
});
|
||||
}
|
||||
expect(repository.images).toHaveLength(0);
|
||||
});
|
||||
|
||||
it(`가로·세로가 각각 ${SIGNATURE_IMAGE_MAX_DIMENSION} 픽셀을 넘으면 FILE_001 — 아무것도 저장하지 않는다 (작은 파일 안의 거대한 그림 · 32비트 PNG 값)`, async () => {
|
||||
for (const [width, height] of [
|
||||
[SIGNATURE_IMAGE_MAX_DIMENSION + 1, 10],
|
||||
[10, SIGNATURE_IMAGE_MAX_DIMENSION + 1],
|
||||
// 표의 integer(2^31-1)를 넘는 PNG 가로 — 예전에는 INSERT 가 500 이 됐다
|
||||
[2 ** 31, 10],
|
||||
[2 ** 32 - 1, 2 ** 32 - 1],
|
||||
]) {
|
||||
expect(await failureOf(() => upload(pngBytes(width, height)))).toEqual({
|
||||
code: 'FILE_001',
|
||||
reason: 'signature-image-dimensions',
|
||||
});
|
||||
}
|
||||
expect(repository.images).toHaveLength(0);
|
||||
expect(await storage.list()).toEqual([]);
|
||||
|
||||
await expect(
|
||||
upload(
|
||||
pngBytes(
|
||||
SIGNATURE_IMAGE_MAX_DIMENSION,
|
||||
SIGNATURE_IMAGE_MAX_DIMENSION,
|
||||
),
|
||||
),
|
||||
).resolves.toMatchObject({
|
||||
width: SIGNATURE_IMAGE_MAX_DIMENSION,
|
||||
height: SIGNATURE_IMAGE_MAX_DIMENSION,
|
||||
});
|
||||
});
|
||||
|
||||
it('300KB 를 넘으면 FILE_002 (multer 가 먼저 막지만 한 번 더)', async () => {
|
||||
expect(
|
||||
await failureOf(() =>
|
||||
upload(pngBytes(10, 10, SIGNATURE_IMAGE_MAX_BYTES + 1)),
|
||||
),
|
||||
).toEqual({ code: 'FILE_002', reason: 'signature-image-too-large' });
|
||||
await expect(
|
||||
upload(pngBytes(10, 10, SIGNATURE_IMAGE_MAX_BYTES)),
|
||||
).resolves.toMatchObject({ size: SIGNATURE_IMAGE_MAX_BYTES });
|
||||
});
|
||||
|
||||
it(`한 사람이 둘 수 있는 그림은 ${SIGNATURE_IMAGES_STORED_MAX}장까지 — 넘으면 400 VAL_001. 동시에 올려도 넘지 않는다`, async () => {
|
||||
const results = await Promise.allSettled(
|
||||
Array.from({ length: SIGNATURE_IMAGES_STORED_MAX + 2 }, () =>
|
||||
upload(pngBytes(10, 10)),
|
||||
),
|
||||
);
|
||||
|
||||
expect(results.filter((r) => r.status === 'fulfilled')).toHaveLength(
|
||||
SIGNATURE_IMAGES_STORED_MAX,
|
||||
);
|
||||
const rejected = results.flatMap((result): unknown[] =>
|
||||
result.status === 'rejected' ? [result.reason] : [],
|
||||
);
|
||||
expect(rejected).toHaveLength(2);
|
||||
for (const reason of rejected) {
|
||||
expect(reason).toBeInstanceOf(BusinessException);
|
||||
expect((reason as BusinessException).code).toBe('VAL_001');
|
||||
expect((reason as BusinessException).context?.reason).toBe(
|
||||
'signature-image-storage-full',
|
||||
);
|
||||
}
|
||||
expect(repository.images).toHaveLength(SIGNATURE_IMAGES_STORED_MAX);
|
||||
|
||||
// 다른 사람은 따로 센다
|
||||
await expect(
|
||||
service.uploadImage(DOYUN, {
|
||||
originalName: 'logo.png',
|
||||
mimeType: 'image/png',
|
||||
data: pngBytes(10, 10),
|
||||
}),
|
||||
).resolves.toMatchObject({ type: 'image/png' });
|
||||
});
|
||||
|
||||
it('표에 넣지 못하면 쓴 파일을 지우고 실패를 그대로 던진다 — 다음 올리기는 된다', async () => {
|
||||
repository.failNextInsert = true;
|
||||
|
||||
expect(await failureOf(() => upload(pngBytes(10, 10)))).toEqual({
|
||||
code: 'NOT_BUSINESS',
|
||||
});
|
||||
expect(repository.images).toHaveLength(0);
|
||||
expect(await storage.list()).toEqual([]);
|
||||
|
||||
await expect(upload(pngBytes(10, 10))).resolves.toMatchObject({
|
||||
type: 'image/png',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('그림 읽기', () => {
|
||||
it('주인에게는 파일과 형식을 준다 — 남의 그림·없는 그림은 RES_001', async () => {
|
||||
const mine = await storedImage({ width: 7, height: 3 });
|
||||
|
||||
await expect(service.openImage(ARA, mine.id)).resolves.toEqual({
|
||||
data: pngBytes(7, 3),
|
||||
type: 'image/png',
|
||||
});
|
||||
for (const [user, id] of [
|
||||
[DOYUN, mine.id],
|
||||
[ARA, '11111111-1111-4111-8111-111111111111'],
|
||||
]) {
|
||||
expect(await failureOf(() => service.openImage(user, id))).toEqual({
|
||||
code: 'RES_001',
|
||||
reason: 'signature-image-not-found',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it('표에는 있는데 파일이 없으면 RES_001 — 경고를 남긴다', async () => {
|
||||
const mine = await storedImage();
|
||||
await storage.remove(mine.fileName);
|
||||
|
||||
expect(await failureOf(() => service.openImage(ARA, mine.id))).toEqual({
|
||||
code: 'RES_001',
|
||||
reason: 'signature-image-not-found',
|
||||
});
|
||||
expect(warnSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining('서명 그림 파일이 없습니다'),
|
||||
);
|
||||
});
|
||||
|
||||
it('발송용 — 내 그림 id 전부, 그리고 요청한 순서대로 내 그림만 읽는다 (파일이 없는 그림은 빼고 경고)', async () => {
|
||||
const first = await storedImage();
|
||||
const second = await storedImage({ attachedAt: new Date() });
|
||||
const missing = await storedImage();
|
||||
await storage.remove(missing.fileName);
|
||||
const others = await storedImage({ userEmail: DOYUN });
|
||||
|
||||
await expect(service.imageIdsOf(ARA)).resolves.toEqual(
|
||||
new Set([first.id, second.id, missing.id]),
|
||||
);
|
||||
|
||||
const loaded = await service.loadImages(ARA, [
|
||||
second.id,
|
||||
others.id,
|
||||
missing.id,
|
||||
first.id,
|
||||
]);
|
||||
expect(loaded.map((image) => image.id)).toEqual([second.id, first.id]);
|
||||
expect(loaded[0]).toEqual({
|
||||
id: second.id,
|
||||
type: 'image/png',
|
||||
data: pngBytes(10, 10),
|
||||
});
|
||||
expect(warnSpy).toHaveBeenCalledTimes(2);
|
||||
await expect(service.loadImages(ARA, [])).resolves.toEqual([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,372 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { randomUUID } from 'crypto';
|
||||
|
||||
import { BusinessException } from '@/common/errors/business.exception';
|
||||
import { checkHtmlParseBudget } from '@/modules/mail/html/html-parse-budget';
|
||||
import { MAX_BODY_HTML_LENGTH } from '@/modules/mail/mail.constants';
|
||||
import { sanitizeOutgoingHtml } from '@/modules/mail/html/sanitize-outgoing-html';
|
||||
import {
|
||||
findSignatureImageIds,
|
||||
rewriteSignatureImages,
|
||||
signatureImagePath,
|
||||
} from '@/modules/mail/html/signature-images';
|
||||
import { traceTag } from '@/shared/logger/request-context';
|
||||
import { maskEmail } from '@/shared/utils/mask';
|
||||
import { MailSignatureImageEntity } from './mail-signature-image.entity';
|
||||
import {
|
||||
SIGNATURE_HTML_MAX_LENGTH,
|
||||
SIGNATURE_IMAGES_PER_SIGNATURE,
|
||||
SIGNATURE_IMAGES_STORED_MAX,
|
||||
SIGNATURE_IMAGE_MAX_BYTES,
|
||||
SIGNATURE_IMAGE_UPLOAD_GRACE_MS,
|
||||
SIGNATURE_TEXT_MAX_LENGTH,
|
||||
} from './mail-signature.constants';
|
||||
import { MailSignatureEntity } from './mail-signature.entity';
|
||||
import { MailSignatureRepository } from './mail-signature.repository';
|
||||
import type {
|
||||
MailSignature,
|
||||
MailSignatureInput,
|
||||
SignatureImage,
|
||||
SignatureImageUpload,
|
||||
StoredSignatureImage,
|
||||
} from './mail-signature.types';
|
||||
import {
|
||||
SIGNATURE_IMAGE_EXTENSIONS,
|
||||
SIGNATURE_IMAGE_TYPES_BY_EXTENSION,
|
||||
type SignatureImageType,
|
||||
detectSignatureImage,
|
||||
isWithinSignatureImageDimensions,
|
||||
} from './signature-image-format';
|
||||
import { SignatureImageStorage } from './signature-image-storage';
|
||||
|
||||
const DEFAULT_SIGNATURE: Omit<MailSignature, 'images'> = {
|
||||
bodyHtml: null,
|
||||
body: null,
|
||||
isForNew: true,
|
||||
isForReply: true,
|
||||
updatedAt: null,
|
||||
};
|
||||
|
||||
function normalizeUser(email: string): string {
|
||||
return email.trim().toLowerCase();
|
||||
}
|
||||
|
||||
function toImage(row: MailSignatureImageEntity): SignatureImage {
|
||||
return {
|
||||
id: row.id,
|
||||
type: row.contentType as SignatureImageType,
|
||||
size: row.size,
|
||||
width: row.width,
|
||||
height: row.height,
|
||||
};
|
||||
}
|
||||
|
||||
/** 올린 이름의 확장자 (소문자, 점 포함) */
|
||||
function extensionOf(name: string): string {
|
||||
const dot = name.lastIndexOf('.');
|
||||
return dot >= 0 ? name.slice(dot).toLowerCase() : '';
|
||||
}
|
||||
|
||||
/**
|
||||
* 메일 서명 (계약 2절 "서명") — 사용자마다 하나, 서버 DB 에 둔다.
|
||||
* - 서명 HTML 은 보내는 정화(서명 그림 포함)를 거친 뒤 저장한다 — 서명은 쓰기 화면이 본문에 넣어 그대로 보내므로 같은 규칙이다
|
||||
* - 그림은 PNG·JPEG·GIF 만 — 확장자·MIME 은 multer 가, 파일 내용(앞부분)·크기는 여기서 본다
|
||||
* - 그림은 주인만 본다 — 남의 그림 id 는 없는 것과 같다 (RES_001)
|
||||
*/
|
||||
@Injectable()
|
||||
export class MailSignatureService {
|
||||
private readonly logger = new Logger(MailSignatureService.name);
|
||||
/** 사용자별 그림 올리기 차례 — 보관 한도(10장)를 동시 업로드로 넘지 못하게 (프로세스 메모리 — 인스턴스를 늘리면 DB 잠금으로) */
|
||||
private readonly uploadQueues = new Map<string, Promise<void>>();
|
||||
|
||||
constructor(
|
||||
private readonly repository: MailSignatureRepository,
|
||||
private readonly storage: SignatureImageStorage,
|
||||
) {}
|
||||
|
||||
async getSignature(userEmail: string): Promise<MailSignature> {
|
||||
const user = normalizeUser(userEmail);
|
||||
const [row, images] = await Promise.all([
|
||||
this.repository.findSignature(user),
|
||||
this.repository.listImages(user),
|
||||
]);
|
||||
if (!row) return { ...DEFAULT_SIGNATURE, images: [] };
|
||||
return this.toSignature(row, images);
|
||||
}
|
||||
|
||||
/**
|
||||
* 서명을 저장한다. 서명 HTML 이 더는 가리키지 않는 그림은 지운다(표와 파일) — 막 올린 그림(10분 안)은 남긴다
|
||||
* (올리기와 저장이 엇갈린 경우 — SIGNATURE_IMAGE_UPLOAD_GRACE_MS).
|
||||
* 한도 — 텍스트 2,000자·정화한 HTML 10,000자(JS 문자열 길이), 정화 전 비용, 서로 다른 그림 3장 — 를 넘으면 VAL_001.
|
||||
*/
|
||||
async saveSignature(
|
||||
userEmail: string,
|
||||
input: MailSignatureInput,
|
||||
): Promise<MailSignature> {
|
||||
const user = normalizeUser(userEmail);
|
||||
const body = input.body ? input.body : null;
|
||||
if (body !== null && body.length > SIGNATURE_TEXT_MAX_LENGTH) {
|
||||
throw new BusinessException('VAL_001', {
|
||||
context: { reason: 'signature-text-too-long', length: body.length },
|
||||
});
|
||||
}
|
||||
|
||||
const bodyHtml = await this.sanitizeSignatureHtml(user, input.bodyHtml);
|
||||
// 정화한 HTML 에는 내 그림만 남는다 — 같은 그림을 두 번 넣으면 한 장으로 센다
|
||||
const referenced = bodyHtml ? findSignatureImageIds(bodyHtml) : [];
|
||||
if (referenced.length > SIGNATURE_IMAGES_PER_SIGNATURE) {
|
||||
throw new BusinessException('VAL_001', {
|
||||
context: {
|
||||
reason: 'signature-too-many-images',
|
||||
count: referenced.length,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
const saved = await this.repository.saveSignature(
|
||||
user,
|
||||
(images) => {
|
||||
// 잠근 뒤 본 그림 기준으로 — 정화한 뒤 그사이 지워진 그림(다른 탭의 저장·정리 작업)은 서명에서 뺀다 (없는 그림을 가리키지 않게)
|
||||
const existing = new Set(images.map((image) => image.id));
|
||||
const html =
|
||||
bodyHtml === null
|
||||
? null
|
||||
: rewriteSignatureImages(bodyHtml, (id) =>
|
||||
existing.has(id) ? signatureImagePath(id) : null,
|
||||
);
|
||||
const kept = html !== null && html.trim() !== '' ? html : null;
|
||||
return {
|
||||
signature: {
|
||||
userEmail: user,
|
||||
bodyHtml: kept,
|
||||
bodyText: body,
|
||||
isForNew: input.isForNew,
|
||||
isForReply: input.isForReply,
|
||||
updatedAt: now,
|
||||
},
|
||||
referencedImageIds: kept ? findSignatureImageIds(kept) : [],
|
||||
};
|
||||
},
|
||||
now,
|
||||
new Date(now.getTime() - SIGNATURE_IMAGE_UPLOAD_GRACE_MS),
|
||||
);
|
||||
await this.removeFiles(saved.removed, user);
|
||||
|
||||
return this.toSignature(saved.signature, saved.images);
|
||||
}
|
||||
|
||||
/** 서명 그림을 올린다 — 내용이 PNG·JPEG·GIF 이고 확장자·MIME 과 같은 형식이어야 한다 (아니면 FILE_001) */
|
||||
uploadImage(
|
||||
userEmail: string,
|
||||
upload: SignatureImageUpload,
|
||||
): Promise<SignatureImage> {
|
||||
const user = normalizeUser(userEmail);
|
||||
const info = detectSignatureImage(upload.data);
|
||||
const declared = SIGNATURE_IMAGE_TYPES_BY_EXTENSION.get(
|
||||
extensionOf(upload.originalName),
|
||||
);
|
||||
if (!info || info.type !== declared || info.type !== upload.mimeType) {
|
||||
return Promise.reject(
|
||||
new BusinessException('FILE_001', {
|
||||
context: { reason: 'signature-image-content-mismatch' },
|
||||
}),
|
||||
);
|
||||
}
|
||||
// 가로·세로 각각 4096 픽셀까지 — 작은 파일 안의 거대한 그림(압축 폭탄)·표의 integer 를 넘는 값을 받지 않는다
|
||||
if (!isWithinSignatureImageDimensions(info)) {
|
||||
return Promise.reject(
|
||||
new BusinessException('FILE_001', {
|
||||
context: {
|
||||
reason: 'signature-image-dimensions',
|
||||
width: info.width,
|
||||
height: info.height,
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
if (upload.data.length > SIGNATURE_IMAGE_MAX_BYTES) {
|
||||
return Promise.reject(
|
||||
new BusinessException('FILE_002', {
|
||||
context: {
|
||||
reason: 'signature-image-too-large',
|
||||
size: upload.data.length,
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
return this.inUploadQueue(user, async () => {
|
||||
const count = await this.repository.countImages(user);
|
||||
if (count >= SIGNATURE_IMAGES_STORED_MAX) {
|
||||
throw new BusinessException('VAL_001', {
|
||||
context: { reason: 'signature-image-storage-full', count },
|
||||
});
|
||||
}
|
||||
|
||||
const id = randomUUID();
|
||||
const row: MailSignatureImageEntity = {
|
||||
id,
|
||||
userEmail: user,
|
||||
contentType: info.type,
|
||||
size: upload.data.length,
|
||||
width: info.width,
|
||||
height: info.height,
|
||||
fileName: `${id}.${SIGNATURE_IMAGE_EXTENSIONS[info.type]}`,
|
||||
createdAt: new Date(),
|
||||
attachedAt: null,
|
||||
};
|
||||
await this.storage.write(row.fileName, upload.data);
|
||||
try {
|
||||
await this.repository.insertImage(row);
|
||||
} catch (error) {
|
||||
// 표에 넣지 못한 파일은 남기지 않는다
|
||||
await this.storage.remove(row.fileName).catch(() => undefined);
|
||||
throw error;
|
||||
}
|
||||
return toImage(row);
|
||||
});
|
||||
}
|
||||
|
||||
/** 그 사람의 그림 파일 — 남의 그림·없는 그림은 RES_001 */
|
||||
async openImage(
|
||||
userEmail: string,
|
||||
id: string,
|
||||
): Promise<{ data: Buffer; type: SignatureImageType }> {
|
||||
const user = normalizeUser(userEmail);
|
||||
const row = await this.repository.findImage(id, user);
|
||||
const data = row ? await this.storage.read(row.fileName) : null;
|
||||
if (!row || !data) {
|
||||
if (row) {
|
||||
this.logger.warn(
|
||||
`[RES_001]${traceTag()} 서명 그림 파일이 없습니다: user=${maskEmail(user)}, id=${id}`,
|
||||
);
|
||||
}
|
||||
throw new BusinessException('RES_001', {
|
||||
context: { reason: 'signature-image-not-found' },
|
||||
});
|
||||
}
|
||||
return { data, type: row.contentType as SignatureImageType };
|
||||
}
|
||||
|
||||
/** 보내는 정화가 남겨도 되는 그림 id — 그 사람의 그림 전부 (서명에 붙었든 아니든) */
|
||||
async imageIdsOf(userEmail: string): Promise<ReadonlySet<string>> {
|
||||
const images = await this.repository.listImages(normalizeUser(userEmail));
|
||||
return new Set(images.map((image) => image.id));
|
||||
}
|
||||
|
||||
/**
|
||||
* 메일에 함께 실을 그림을 읽는다 — 그 사람의 그림만, 요청한 순서대로. 파일이 없는 그림은 빼고 경고만 남긴다
|
||||
* (부르는 쪽이 그 img 를 지운다).
|
||||
*/
|
||||
async loadImages(
|
||||
userEmail: string,
|
||||
ids: readonly string[],
|
||||
): Promise<StoredSignatureImage[]> {
|
||||
if (ids.length === 0) return [];
|
||||
const user = normalizeUser(userEmail);
|
||||
const rows = new Map(
|
||||
(await this.repository.listImages(user)).map((row) => [row.id, row]),
|
||||
);
|
||||
|
||||
const loaded: StoredSignatureImage[] = [];
|
||||
for (const id of ids) {
|
||||
const row = rows.get(id);
|
||||
const data = row ? await this.storage.read(row.fileName) : null;
|
||||
if (!row || !data) {
|
||||
this.logger.warn(
|
||||
`[RES_001]${traceTag()} 서명 그림을 메일에 싣지 못해 뺍니다: user=${maskEmail(user)}, id=${id}`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
loaded.push({ id, type: row.contentType as SignatureImageType, data });
|
||||
}
|
||||
return loaded;
|
||||
}
|
||||
|
||||
/** 서명 HTML 정화 — 비었으면 null. 정화 전 비용·정화 뒤 길이 한도를 넘으면 VAL_001 */
|
||||
private async sanitizeSignatureHtml(
|
||||
user: string,
|
||||
html: string | null | undefined,
|
||||
): Promise<string | null> {
|
||||
if (!html) return null;
|
||||
// 정화 전 — 쓰기 화면의 서식 본문과 같은 길이 한도와 파서 비용 (정화가 이벤트 루프를 오래 붙잡지 않게)
|
||||
if (html.length > MAX_BODY_HTML_LENGTH) {
|
||||
throw new BusinessException('VAL_001', {
|
||||
context: { reason: 'signature-html-too-long', length: html.length },
|
||||
});
|
||||
}
|
||||
const budget = checkHtmlParseBudget(html);
|
||||
if (!budget.ok) {
|
||||
throw new BusinessException('VAL_001', {
|
||||
context: {
|
||||
reason: 'signature-html-too-complex',
|
||||
detail: budget.reason,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const sanitized = sanitizeOutgoingHtml(html, {
|
||||
signatureImageIds: await this.imageIdsOf(user),
|
||||
});
|
||||
if (sanitized.length > SIGNATURE_HTML_MAX_LENGTH) {
|
||||
throw new BusinessException('VAL_001', {
|
||||
context: {
|
||||
reason: 'signature-html-too-long',
|
||||
length: sanitized.length,
|
||||
},
|
||||
});
|
||||
}
|
||||
return sanitized === '' ? null : sanitized;
|
||||
}
|
||||
|
||||
private toSignature(
|
||||
row: MailSignatureEntity,
|
||||
images: readonly MailSignatureImageEntity[],
|
||||
): MailSignature {
|
||||
const byId = new Map(images.map((image) => [image.id, image]));
|
||||
const used = row.bodyHtml ? findSignatureImageIds(row.bodyHtml) : [];
|
||||
return {
|
||||
bodyHtml: row.bodyHtml,
|
||||
body: row.bodyText,
|
||||
isForNew: row.isForNew,
|
||||
isForReply: row.isForReply,
|
||||
images: used.flatMap((id) => {
|
||||
const image = byId.get(id);
|
||||
return image ? [toImage(image)] : [];
|
||||
}),
|
||||
updatedAt: row.updatedAt.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
/** 지운 그림의 파일을 지운다 — 실패해도 서명 저장은 끝났다 (남은 파일은 정리 작업이 지운다) */
|
||||
private async removeFiles(
|
||||
removed: readonly MailSignatureImageEntity[],
|
||||
user: string,
|
||||
): Promise<void> {
|
||||
for (const image of removed) {
|
||||
try {
|
||||
await this.storage.remove(image.fileName);
|
||||
} catch (error) {
|
||||
this.logger.warn(
|
||||
`[SYS_001]${traceTag()} 서명 그림 파일을 지우지 못했습니다: user=${maskEmail(user)}, reason=${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 같은 사용자의 작업을 차례로 돌린다 (앞 작업이 실패해도 다음 작업은 돈다) */
|
||||
private inUploadQueue<T>(user: string, task: () => Promise<T>): Promise<T> {
|
||||
const previous = this.uploadQueues.get(user) ?? Promise.resolve();
|
||||
const run = previous.then(task, task);
|
||||
const tail = run.then(
|
||||
() => undefined,
|
||||
() => undefined,
|
||||
);
|
||||
this.uploadQueues.set(user, tail);
|
||||
void tail.then(() => {
|
||||
if (this.uploadQueues.get(user) === tail) this.uploadQueues.delete(user);
|
||||
});
|
||||
return run;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import type { SignatureImageType } from './signature-image-format';
|
||||
|
||||
/**
|
||||
* 서명 도메인 타입 — 계약 2절 "서명"과 같은 모양이다.
|
||||
*/
|
||||
|
||||
export interface SignatureImage {
|
||||
readonly id: string;
|
||||
readonly type: SignatureImageType;
|
||||
/** 바이트 */
|
||||
readonly size: number;
|
||||
/** 원본 픽셀 */
|
||||
readonly width: number;
|
||||
readonly height: number;
|
||||
}
|
||||
|
||||
export interface MailSignature {
|
||||
/** 서명 서식 — 서식 본문 규칙 + 서명 그림. 정화 뒤 10,000자 이하 */
|
||||
readonly bodyHtml: string | null;
|
||||
/** 텍스트 서명 (텍스트 대체 본문에 넣는다) — 2,000자 이하 */
|
||||
readonly body: string | null;
|
||||
readonly isForNew: boolean;
|
||||
readonly isForReply: boolean;
|
||||
/** 서명 HTML 이 쓰는 그림 (HTML 에 나온 순서) */
|
||||
readonly images: SignatureImage[];
|
||||
/** 저장한 적 없으면 null */
|
||||
readonly updatedAt: string | null;
|
||||
}
|
||||
|
||||
/** PUT /api/mail-signature 본문 */
|
||||
export interface MailSignatureInput {
|
||||
readonly bodyHtml?: string | null;
|
||||
readonly body?: string | null;
|
||||
readonly isForNew: boolean;
|
||||
readonly isForReply: boolean;
|
||||
}
|
||||
|
||||
/** 메일에 함께 실을 그림 — 발송 때 읽는다 */
|
||||
export interface StoredSignatureImage {
|
||||
readonly id: string;
|
||||
readonly type: SignatureImageType;
|
||||
readonly data: Buffer;
|
||||
}
|
||||
|
||||
/** 올린 그림 파일 (multer 가 메모리에 받은 것 — 한 장 300KB 이하) */
|
||||
export interface SignatureImageUpload {
|
||||
readonly originalName: string;
|
||||
readonly mimeType: string;
|
||||
readonly data: Buffer;
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
import {
|
||||
gifBytes as gif,
|
||||
jpegBytes as jpeg,
|
||||
pngBytes as png,
|
||||
} from '@/../test/image-fixtures';
|
||||
import { SIGNATURE_IMAGE_MAX_DIMENSION } from './mail-signature.constants';
|
||||
import {
|
||||
detectSignatureImage,
|
||||
isWithinSignatureImageDimensions,
|
||||
} from './signature-image-format';
|
||||
|
||||
describe('isWithinSignatureImageDimensions (서명 그림 크기 상한)', () => {
|
||||
it(`가로·세로가 각각 1~${SIGNATURE_IMAGE_MAX_DIMENSION} 픽셀이면 받는다`, () => {
|
||||
expect(isWithinSignatureImageDimensions({ width: 1, height: 1 })).toBe(
|
||||
true,
|
||||
);
|
||||
expect(
|
||||
isWithinSignatureImageDimensions({
|
||||
width: SIGNATURE_IMAGE_MAX_DIMENSION,
|
||||
height: SIGNATURE_IMAGE_MAX_DIMENSION,
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('하나라도 넘거나 0 이면 받지 않는다 — PNG 머리의 32비트 값(표의 integer 를 넘는다) 포함', () => {
|
||||
for (const [width, height] of [
|
||||
[SIGNATURE_IMAGE_MAX_DIMENSION + 1, 1],
|
||||
[1, SIGNATURE_IMAGE_MAX_DIMENSION + 1],
|
||||
[0, 10],
|
||||
[2 ** 31, 10],
|
||||
]) {
|
||||
expect(isWithinSignatureImageDimensions({ width, height })).toBe(false);
|
||||
}
|
||||
// PNG 는 머리에 32비트로 적는다 — 읽기는 하고(형식은 맞다) 크기 검사가 거른다
|
||||
const huge = detectSignatureImage(png(2 ** 32 - 1, 2 ** 32 - 1));
|
||||
expect(huge).toEqual({
|
||||
type: 'image/png',
|
||||
width: 2 ** 32 - 1,
|
||||
height: 2 ** 32 - 1,
|
||||
});
|
||||
expect(huge && isWithinSignatureImageDimensions(huge)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('detectSignatureImage (서명 그림 형식·크기)', () => {
|
||||
it('PNG·GIF(87a·89a)·JPEG(기본·점진) 의 형식과 가로·세로를 읽는다', () => {
|
||||
expect(detectSignatureImage(png(640, 120))).toEqual({
|
||||
type: 'image/png',
|
||||
width: 640,
|
||||
height: 120,
|
||||
});
|
||||
expect(detectSignatureImage(gif(300, 80))).toEqual({
|
||||
type: 'image/gif',
|
||||
width: 300,
|
||||
height: 80,
|
||||
});
|
||||
expect(detectSignatureImage(gif(1, 2, 'GIF87a'))).toEqual({
|
||||
type: 'image/gif',
|
||||
width: 1,
|
||||
height: 2,
|
||||
});
|
||||
expect(detectSignatureImage(jpeg(1024, 256))).toEqual({
|
||||
type: 'image/jpeg',
|
||||
width: 1024,
|
||||
height: 256,
|
||||
});
|
||||
// 점진(SOF2) · 표지 앞 채움 바이트
|
||||
expect(
|
||||
detectSignatureImage(jpeg(96, 48, { sof: 0xc2, fill: true })),
|
||||
).toEqual({ type: 'image/jpeg', width: 96, height: 48 });
|
||||
});
|
||||
|
||||
it('내용이 그 형식이 아니면 받지 않는다 — WebP·SVG·HTML·빈 파일', () => {
|
||||
for (const data of [
|
||||
Buffer.from('RIFF\x00\x00\x00\x00WEBPVP8 ', 'binary'),
|
||||
Buffer.from('<svg xmlns="http://www.w3.org/2000/svg"></svg>'),
|
||||
Buffer.from('<html><script>alert(1)</script></html>'),
|
||||
Buffer.alloc(0),
|
||||
]) {
|
||||
expect(detectSignatureImage(data)).toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
it('머리가 잘렸거나 망가졌거나 크기가 0 이면 받지 않는다', () => {
|
||||
expect(detectSignatureImage(png(10, 10).subarray(0, 20))).toBeNull();
|
||||
expect(detectSignatureImage(png(0, 10))).toBeNull();
|
||||
expect(detectSignatureImage(gif(10, 0))).toBeNull();
|
||||
expect(detectSignatureImage(gif(10, 10).subarray(0, 8))).toBeNull();
|
||||
expect(detectSignatureImage(jpeg(0, 10))).toBeNull();
|
||||
|
||||
// IHDR 가 첫 덩어리가 아니다
|
||||
const broken = png(10, 10);
|
||||
broken.write('IDAT', 12, 'ascii');
|
||||
expect(detectSignatureImage(broken)).toBeNull();
|
||||
|
||||
// JPEG: SOF 전에 그림 자료(SOS)가 오거나, 조각 길이가 파일 밖을 가리킨다
|
||||
expect(
|
||||
detectSignatureImage(
|
||||
Buffer.from([0xff, 0xd8, 0xff, 0xda, 0x00, 0x02, 0xff, 0xd9]),
|
||||
),
|
||||
).toBeNull();
|
||||
expect(
|
||||
detectSignatureImage(Buffer.from([0xff, 0xd8, 0xff, 0xe0, 0x7f, 0xff])),
|
||||
).toBeNull();
|
||||
expect(
|
||||
detectSignatureImage(Buffer.from([0xff, 0xd8, 0xff, 0xd9])),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('긴 JPEG 도 조각 길이를 따라 앞으로만 간다 (입력 길이에 비례)', () => {
|
||||
// 64KB 짜리 주석(COM) 조각 4개 뒤에 SOF
|
||||
const comment = Buffer.alloc(0xffff + 2);
|
||||
comment[0] = 0xff;
|
||||
comment[1] = 0xfe;
|
||||
comment.writeUInt16BE(0xffff, 2);
|
||||
const data = Buffer.concat([
|
||||
Buffer.from([0xff, 0xd8]),
|
||||
comment,
|
||||
comment,
|
||||
comment,
|
||||
comment,
|
||||
jpeg(20, 10).subarray(2),
|
||||
]);
|
||||
|
||||
expect(detectSignatureImage(data)).toEqual({
|
||||
type: 'image/jpeg',
|
||||
width: 20,
|
||||
height: 10,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,161 @@
|
||||
/**
|
||||
* 서명 그림 형식 확인 (순수 함수) — 파일 앞부분(매직 바이트)으로 형식을 알아내고 머리에서 가로·세로 픽셀을 읽는다.
|
||||
* 확장자·MIME 은 클라이언트가 정하므로 믿지 않는다 — 이름이 .png 여도 내용이 PNG 가 아니면 받지 않는다 (.claude/rules/security.md).
|
||||
* 그림을 풀지(디코드) 않는다 — 머리만 읽으므로 네이티브 라이브러리·새 의존성이 필요 없다.
|
||||
*/
|
||||
|
||||
import { SIGNATURE_IMAGE_MAX_DIMENSION } from './mail-signature.constants';
|
||||
|
||||
export type SignatureImageType = 'image/png' | 'image/jpeg' | 'image/gif';
|
||||
|
||||
export interface SignatureImageInfo {
|
||||
readonly type: SignatureImageType;
|
||||
readonly width: number;
|
||||
readonly height: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 받을 수 있는 크기인가 — 가로·세로가 각각 1~SIGNATURE_IMAGE_MAX_DIMENSION(4096) 픽셀.
|
||||
* (0 은 detectSignatureImage 가 이미 거른다)
|
||||
*/
|
||||
export function isWithinSignatureImageDimensions(
|
||||
info: Pick<SignatureImageInfo, 'width' | 'height'>,
|
||||
): boolean {
|
||||
return (
|
||||
info.width >= 1 &&
|
||||
info.height >= 1 &&
|
||||
info.width <= SIGNATURE_IMAGE_MAX_DIMENSION &&
|
||||
info.height <= SIGNATURE_IMAGE_MAX_DIMENSION
|
||||
);
|
||||
}
|
||||
|
||||
/** 형식별 저장 확장자 — 서버가 만드는 파일 이름에 쓴다 */
|
||||
export const SIGNATURE_IMAGE_EXTENSIONS: Readonly<
|
||||
Record<SignatureImageType, string>
|
||||
> = {
|
||||
'image/png': 'png',
|
||||
'image/jpeg': 'jpg',
|
||||
'image/gif': 'gif',
|
||||
};
|
||||
|
||||
/** 받는 확장자 → 형식 (업로드 사전 검사 — 확장자와 MIME 이 이 짝이어야 한다) */
|
||||
export const SIGNATURE_IMAGE_TYPES_BY_EXTENSION: ReadonlyMap<
|
||||
string,
|
||||
SignatureImageType
|
||||
> = new Map([
|
||||
['.png', 'image/png'],
|
||||
['.jpg', 'image/jpeg'],
|
||||
['.jpeg', 'image/jpeg'],
|
||||
['.gif', 'image/gif'],
|
||||
]);
|
||||
|
||||
const PNG_SIGNATURE = Buffer.from([
|
||||
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a,
|
||||
]);
|
||||
/** PNG 첫 덩어리 IHDR — 길이(4) 'IHDR'(4) 가로(4) 세로(4) … */
|
||||
const PNG_IHDR_LENGTH = 13;
|
||||
const PNG_IHDR_TYPE = Buffer.from('IHDR', 'ascii');
|
||||
|
||||
const GIF_SIGNATURES = [
|
||||
Buffer.from('GIF87a', 'ascii'),
|
||||
Buffer.from('GIF89a', 'ascii'),
|
||||
];
|
||||
/** GIF 논리 화면 가로·세로 (little-endian 16비트) 위치 */
|
||||
const GIF_WIDTH_OFFSET = 6;
|
||||
const GIF_HEIGHT_OFFSET = 8;
|
||||
|
||||
const JPEG_SOI = [0xff, 0xd8, 0xff];
|
||||
const JPEG_MARKER_PREFIX = 0xff;
|
||||
/** 길이 없이 홀로 오는 표지 — TEM, RST0~7 */
|
||||
const JPEG_STANDALONE = new Set([
|
||||
0x01, 0xd0, 0xd1, 0xd2, 0xd3, 0xd4, 0xd5, 0xd6, 0xd7,
|
||||
]);
|
||||
/** 그림 자료가 시작되거나 끝나는 표지 — 그 전에 SOF 가 있어야 한다 */
|
||||
const JPEG_SOS = 0xda;
|
||||
const JPEG_EOI = 0xd9;
|
||||
/** 프레임 머리(SOF0~15) 중 프레임이 아닌 것 — DHT(C4)·JPG(C8)·DAC(CC) */
|
||||
const JPEG_NOT_SOF = new Set([0xc4, 0xc8, 0xcc]);
|
||||
const JPEG_SOF_FIRST = 0xc0;
|
||||
const JPEG_SOF_LAST = 0xcf;
|
||||
|
||||
function startsWith(data: Buffer, prefix: Buffer | readonly number[]): boolean {
|
||||
if (data.length < prefix.length) return false;
|
||||
for (let index = 0; index < prefix.length; index++) {
|
||||
if (data[index] !== prefix[index]) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function sized(
|
||||
type: SignatureImageType,
|
||||
width: number,
|
||||
height: number,
|
||||
): SignatureImageInfo | null {
|
||||
return width > 0 && height > 0 ? { type, width, height } : null;
|
||||
}
|
||||
|
||||
function readPng(data: Buffer): SignatureImageInfo | null {
|
||||
// 서명 8 + 길이 4 + 형식 4 + 가로 4 + 세로 4
|
||||
if (data.length < 24) return null;
|
||||
if (data.readUInt32BE(8) !== PNG_IHDR_LENGTH) return null;
|
||||
if (!data.subarray(12, 16).equals(PNG_IHDR_TYPE)) return null;
|
||||
return sized('image/png', data.readUInt32BE(16), data.readUInt32BE(20));
|
||||
}
|
||||
|
||||
function readGif(data: Buffer): SignatureImageInfo | null {
|
||||
if (data.length < GIF_HEIGHT_OFFSET + 2) return null;
|
||||
return sized(
|
||||
'image/gif',
|
||||
data.readUInt16LE(GIF_WIDTH_OFFSET),
|
||||
data.readUInt16LE(GIF_HEIGHT_OFFSET),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* JPEG 은 표지(0xFF xx)와 길이로 이어진 조각을 따라가 첫 프레임 머리(SOFn)에서 크기를 읽는다.
|
||||
* 조각 길이를 따라 앞으로만 가므로 입력 길이에 비례해 끝난다. SOF 전에 그림 자료(SOS)·끝(EOI)이 오면 받지 않는다.
|
||||
*/
|
||||
function readJpeg(data: Buffer): SignatureImageInfo | null {
|
||||
let index = 2;
|
||||
while (index < data.length) {
|
||||
if (data[index] !== JPEG_MARKER_PREFIX) return null;
|
||||
// 표지 앞의 채움 바이트(0xFF 여러 개)는 건너뛴다
|
||||
while (index < data.length && data[index] === JPEG_MARKER_PREFIX) index++;
|
||||
if (index >= data.length) return null;
|
||||
const marker = data[index];
|
||||
index++;
|
||||
|
||||
if (JPEG_STANDALONE.has(marker)) continue;
|
||||
if (marker === JPEG_SOS || marker === JPEG_EOI) return null;
|
||||
if (index + 2 > data.length) return null;
|
||||
|
||||
const length = data.readUInt16BE(index);
|
||||
if (length < 2 || index + length > data.length) return null;
|
||||
|
||||
const isFrame =
|
||||
marker >= JPEG_SOF_FIRST &&
|
||||
marker <= JPEG_SOF_LAST &&
|
||||
!JPEG_NOT_SOF.has(marker);
|
||||
if (isFrame) {
|
||||
// 길이(2) 정밀도(1) 세로(2) 가로(2)
|
||||
if (length < 7) return null;
|
||||
return sized(
|
||||
'image/jpeg',
|
||||
data.readUInt16BE(index + 5),
|
||||
data.readUInt16BE(index + 3),
|
||||
);
|
||||
}
|
||||
index += length;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** 내용으로 형식과 크기를 알아낸다 — PNG·JPEG·GIF 가 아니거나 머리가 망가졌으면 null */
|
||||
export function detectSignatureImage(data: Buffer): SignatureImageInfo | null {
|
||||
if (startsWith(data, PNG_SIGNATURE)) return readPng(data);
|
||||
if (GIF_SIGNATURES.some((signature) => startsWith(data, signature))) {
|
||||
return readGif(data);
|
||||
}
|
||||
if (startsWith(data, JPEG_SOI)) return readJpeg(data);
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { Injectable, PipeTransform } from '@nestjs/common';
|
||||
|
||||
import { BusinessException } from '@/common/errors/business.exception';
|
||||
|
||||
/** 서명 그림 id — 서버가 만든 UUID */
|
||||
const SIGNATURE_IMAGE_ID =
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||
|
||||
/**
|
||||
* 경로의 서명 그림 id 형식 확인 — 형식이 틀리면 "그런 그림이 없다"와 같으므로 RES_001 (DB 의 uuid 열에 넣기 전에 막는다 —
|
||||
* 형식이 틀린 값이 DB 오류(SYS_001)로 새어 나가지 않게).
|
||||
*/
|
||||
@Injectable()
|
||||
export class SignatureImageIdPipe implements PipeTransform<unknown, string> {
|
||||
transform(value: unknown): string {
|
||||
if (typeof value === 'string' && SIGNATURE_IMAGE_ID.test(value)) {
|
||||
return value.toLowerCase();
|
||||
}
|
||||
throw new BusinessException('RES_001', {
|
||||
context: { reason: 'malformed-signature-image-id' },
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import { mkdtempSync, rmSync, statSync } from 'fs';
|
||||
import { tmpdir } from 'os';
|
||||
import { join } from 'path';
|
||||
|
||||
import { SignatureImageStorage } from './signature-image-storage';
|
||||
|
||||
const NAME = '3f1c0a2e-0f1b-4f2a-9a1e-6c9f0a1b2c3d.png';
|
||||
|
||||
describe('SignatureImageStorage (서명 그림 파일)', () => {
|
||||
let root: string;
|
||||
let dir: string;
|
||||
let storage: SignatureImageStorage;
|
||||
|
||||
beforeEach(() => {
|
||||
root = mkdtempSync(join(tmpdir(), 'signature-storage-'));
|
||||
// 아직 없는 하위 폴더 — 처음 쓸 때 만든다
|
||||
dir = join(root, 'mail-signatures');
|
||||
storage = new SignatureImageStorage(dir);
|
||||
});
|
||||
|
||||
afterEach(() => rmSync(root, { recursive: true, force: true }));
|
||||
|
||||
it('쓰고 읽고 지운다 — 없는 파일은 null, 두 번 지워도 된다', async () => {
|
||||
await expect(storage.read(NAME)).resolves.toBeNull();
|
||||
await expect(storage.list()).resolves.toEqual([]);
|
||||
|
||||
await storage.write(NAME, Buffer.from('png'));
|
||||
await expect(storage.read(NAME)).resolves.toEqual(Buffer.from('png'));
|
||||
expect((await storage.list()).map((file) => file.fileName)).toEqual([NAME]);
|
||||
|
||||
await storage.remove(NAME);
|
||||
await storage.remove(NAME);
|
||||
await expect(storage.read(NAME)).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it('같은 이름을 덮어쓰지 않는다', async () => {
|
||||
await storage.write(NAME, Buffer.from('first'));
|
||||
|
||||
await expect(storage.write(NAME, Buffer.from('second'))).rejects.toThrow();
|
||||
await expect(storage.read(NAME)).resolves.toEqual(Buffer.from('first'));
|
||||
});
|
||||
|
||||
it('서버가 만든 이름(UUID.png·jpg·gif)이 아니면 경로로 쓰지 않는다', async () => {
|
||||
for (const name of [
|
||||
'../../etc/passwd',
|
||||
`../${NAME}`,
|
||||
`${NAME}/x`,
|
||||
NAME.toUpperCase(),
|
||||
'3f1c0a2e-0f1b-4f2a-9a1e-6c9f0a1b2c3d.svg',
|
||||
'3f1c0a2e-0f1b-4f2a-9a1e-6c9f0a1b2c3d.jpeg',
|
||||
'',
|
||||
]) {
|
||||
await expect(storage.write(name, Buffer.from('x'))).rejects.toThrow(
|
||||
'서명 그림 파일 이름 형식이 올바르지 않습니다.',
|
||||
);
|
||||
await expect(storage.read(name)).rejects.toThrow();
|
||||
await expect(storage.remove(name)).rejects.toThrow();
|
||||
}
|
||||
});
|
||||
|
||||
it('그룹은 쓸 수 없고 다른 사람은 읽을 수도 없다 (POSIX — Windows 는 권한 비트가 없다)', async () => {
|
||||
await storage.write(NAME, Buffer.from('png'));
|
||||
|
||||
if (process.platform !== 'win32') {
|
||||
// 만들 때 0o640 — umask 가 더 좁히는 것은 괜찮다
|
||||
const mode = statSync(join(dir, NAME)).mode;
|
||||
expect(mode & 0o027).toBe(0);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,89 @@
|
||||
import { mkdir, readFile, readdir, stat, unlink, writeFile } from 'fs/promises';
|
||||
import { join } from 'path';
|
||||
|
||||
import { SIGNATURE_IMAGE_DIR } from './mail-signature.constants';
|
||||
|
||||
/** 서버가 만든 파일 이름만 다룬다 — 바깥 값이 경로가 되지 않게 (`../../etc/passwd` 같은 값) */
|
||||
const STORED_FILE_NAME =
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\.(?:png|jpg|gif)$/;
|
||||
|
||||
/** 파일 권한 — 소유자만 쓴다 (그룹은 읽기만) */
|
||||
const FILE_MODE = 0o640;
|
||||
|
||||
export interface StoredFile {
|
||||
readonly fileName: string;
|
||||
readonly modifiedAt: Date;
|
||||
}
|
||||
|
||||
function isMissing(error: unknown): boolean {
|
||||
return (error as NodeJS.ErrnoException | null)?.code === 'ENOENT';
|
||||
}
|
||||
|
||||
/**
|
||||
* 서명 그림 파일 저장소 — 업로드 볼륨의 하위 폴더(SIGNATURE_IMAGE_DIR).
|
||||
* 폴더 그대로 정적 서빙하지 않는다 — 내려줄 때는 컨트롤러가 형식을 정해 준다 (.claude/rules/security.md "파일 업로드").
|
||||
* 모듈은 useFactory 로 만든다 (생성자의 폴더 인자를 DI 가 찾지 않게) — 테스트는 임시 폴더를 넘긴다.
|
||||
*/
|
||||
export class SignatureImageStorage {
|
||||
constructor(private readonly dir: string = SIGNATURE_IMAGE_DIR) {}
|
||||
|
||||
async write(fileName: string, data: Buffer): Promise<void> {
|
||||
await mkdir(this.dir, { recursive: true });
|
||||
// wx — 같은 이름이 이미 있으면 덮어쓰지 않고 실패한다 (UUID 라 생기지 않지만 지키는 선)
|
||||
await writeFile(this.pathOf(fileName), data, {
|
||||
flag: 'wx',
|
||||
mode: FILE_MODE,
|
||||
});
|
||||
}
|
||||
|
||||
/** 없으면 null */
|
||||
async read(fileName: string): Promise<Buffer | null> {
|
||||
try {
|
||||
return await readFile(this.pathOf(fileName));
|
||||
} catch (error) {
|
||||
if (isMissing(error)) return null;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/** 지운다 — 이미 없으면 그대로 둔다 */
|
||||
async remove(fileName: string): Promise<void> {
|
||||
try {
|
||||
await unlink(this.pathOf(fileName));
|
||||
} catch (error) {
|
||||
if (!isMissing(error)) throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/** 폴더 안의 그림 파일 (정리 작업용). 폴더가 없으면 빈 목록 */
|
||||
async list(): Promise<StoredFile[]> {
|
||||
let names: string[];
|
||||
try {
|
||||
names = await readdir(this.dir);
|
||||
} catch (error) {
|
||||
if (isMissing(error)) return [];
|
||||
throw error;
|
||||
}
|
||||
|
||||
const files: StoredFile[] = [];
|
||||
for (const fileName of names.filter((name) =>
|
||||
STORED_FILE_NAME.test(name),
|
||||
)) {
|
||||
try {
|
||||
const { mtime } = await stat(join(this.dir, fileName));
|
||||
files.push({ fileName, modifiedAt: mtime });
|
||||
} catch (error) {
|
||||
if (!isMissing(error)) throw error;
|
||||
}
|
||||
}
|
||||
return files;
|
||||
}
|
||||
|
||||
private pathOf(fileName: string): string {
|
||||
if (!STORED_FILE_NAME.test(fileName)) {
|
||||
// 서버가 만든 이름이 아니다 — 버그이므로 응답 코드 없는 내부 오류로 둔다 (SYS_001)
|
||||
throw new Error('서명 그림 파일 이름 형식이 올바르지 않습니다.');
|
||||
}
|
||||
return join(this.dir, fileName);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Get,
|
||||
HttpStatus,
|
||||
Param,
|
||||
Post,
|
||||
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 { MAX_ATTACHMENTS_PER_MESSAGE } from './attachment.policy';
|
||||
import { DraftDto, DraftSavedDto } from './dto/mail-response.dto';
|
||||
import { DraftBodyDto } from './dto/send-message.dto';
|
||||
import { MailIdPipe } from './mail-id.pipe';
|
||||
import { DRAFTS_ROUTE } from './mail.constants';
|
||||
import { MailService } from './mail.service';
|
||||
|
||||
/** 저장 요청의 400 실패 — 발송 본문과 같은 한도 */
|
||||
const SAVE_BAD_REQUEST =
|
||||
`MAIL_002 받는 사람 합계 100명 초과 · MAIL_003 메일함 용량 · MAIL_004 첨부를 찾을 수 없음 · MAIL_006 첨부 ${MAX_ATTACHMENTS_PER_MESSAGE}개 초과 · ` +
|
||||
'VAL_001 형식(제목 255자 초과·draftId 같은 모르는 필드 포함) · FILE_001 첨부 형식 (모두 400)';
|
||||
|
||||
// 임시 저장 (계약 2절 "임시 저장") — 목록·버리기·되돌리기는 메일 API(GET·DELETE·PATCH /api/messages) 그대로다.
|
||||
// 저장 본문은 발송 본문과 같은 필드·한도이고 JSON 본문 4MB 까지 받는다 (app.setup.ts — 앞단 nginx 도 같은 값이어야 한다).
|
||||
@ApiTags('Mail')
|
||||
@ApiCookieAuth()
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ApiErrorResponse(
|
||||
'AUTH_001',
|
||||
'로그인 필요 (쿠키 없음·만료) — 클라이언트는 /auth/refresh 를 한 번 시도한다',
|
||||
)
|
||||
@Controller(DRAFTS_ROUTE)
|
||||
export class DraftController {
|
||||
constructor(private readonly mail: MailService) {}
|
||||
|
||||
@Get(':id')
|
||||
@ApiOperation({
|
||||
summary: '이어 쓸 임시 저장본',
|
||||
description:
|
||||
'서식 본문은 보내는 정화(서식 본문 허용 목록 + 내 서명 그림)를 거친 값이다 — 다른 메일 프로그램이 만든 초안도 같다. ' +
|
||||
'임시보관함의 초안이 아니면(발송 중에 생긴 내부 초안 포함) 404.',
|
||||
})
|
||||
@ApiStandardResponse(DraftDto, { description: '임시 저장본' })
|
||||
@ApiErrorResponse('RES_001', '없는 메일 · 임시보관함의 초안이 아님')
|
||||
@ApiErrorResponse('MAIL_001', '메일 엔진 장애')
|
||||
getDraft(
|
||||
@CurrentUser() user: AuthenticatedUser,
|
||||
@Param('id', MailIdPipe) id: string,
|
||||
) {
|
||||
return this.mail.getDraft(user, id);
|
||||
}
|
||||
|
||||
@Post()
|
||||
@ApiOperation({
|
||||
summary: '임시 저장 — 처음 저장',
|
||||
description:
|
||||
'발송 본문과 같은 필드·한도(받는 사람 0명도 된다, draftId 없음). 발송 중복 방지용 Message-ID·지문 헤더는 넣지 않는다. ' +
|
||||
'본문이 너무 길면 400 MAIL_007.',
|
||||
})
|
||||
@ApiStandardResponse(DraftSavedDto, {
|
||||
status: HttpStatus.CREATED,
|
||||
description: '저장한 임시 저장본 — 다음 저장은 PUT /api/drafts/{id}',
|
||||
})
|
||||
@ApiErrorResponse('MAIL_002', SAVE_BAD_REQUEST)
|
||||
@ApiErrorResponse(
|
||||
'MAIL_007',
|
||||
'MAIL_007 본문이 너무 김 (400) — 발송과 같은 한도 (텍스트 100,000자·서식 500,000자·정화 전 비용·정화 뒤 2,000,000바이트)',
|
||||
)
|
||||
@ApiErrorResponse('FILE_002', '첨부 합계 크기 초과 · JSON 본문 4MB 초과')
|
||||
@ApiErrorResponse('MAIL_001', '메일 엔진 장애')
|
||||
createDraft(
|
||||
@CurrentUser() user: AuthenticatedUser,
|
||||
@Body() dto: DraftBodyDto,
|
||||
) {
|
||||
return this.mail.createDraft(user, dto);
|
||||
}
|
||||
|
||||
@Put(':id')
|
||||
@ApiOperation({
|
||||
summary: '임시 저장 — 바꿔 저장 (전체 교체, 새 id)',
|
||||
description:
|
||||
'메일은 고칠 수 없어 새 초안을 만든 뒤 옛것을 지운다 — 응답의 id·attachments 로 바꿔 쓴다. ' +
|
||||
'{id} 가 임시보관함의 초안이 아니면 아무것도 지우지 않고 404 RES_001, ' +
|
||||
'옛 초안이 이미 없으면(다른 창·기기에서 먼저 고쳤거나 지웠음) 저장하지 않고 409 MAIL_008 — 클라이언트는 POST 로 새로 남긴다.',
|
||||
})
|
||||
@ApiStandardResponse(DraftSavedDto, {
|
||||
description: '새 임시 저장본 (새 id)',
|
||||
})
|
||||
@ApiErrorResponse('MAIL_002', SAVE_BAD_REQUEST)
|
||||
@ApiErrorResponse(
|
||||
'MAIL_007',
|
||||
'MAIL_007 본문이 너무 김 (400) — 발송과 같은 한도',
|
||||
)
|
||||
@ApiErrorResponse('RES_001', '임시보관함의 초안이 아님 (지우지 않았다)')
|
||||
@ApiErrorResponse(
|
||||
'MAIL_008',
|
||||
'다른 곳에서 먼저 바뀌었거나 지워짐 — 저장하지 않았다 (POST 로 새로 남긴다)',
|
||||
)
|
||||
@ApiErrorResponse('FILE_002', '첨부 합계 크기 초과 · JSON 본문 4MB 초과')
|
||||
@ApiErrorResponse('MAIL_001', '메일 엔진 장애')
|
||||
replaceDraft(
|
||||
@CurrentUser() user: AuthenticatedUser,
|
||||
@Param('id', MailIdPipe) id: string,
|
||||
@Body() dto: DraftBodyDto,
|
||||
) {
|
||||
return this.mail.replaceDraft(user, id, dto);
|
||||
}
|
||||
}
|
||||
@@ -3,13 +3,17 @@ import { ApiProperty } from '@nestjs/swagger';
|
||||
import { PageInfoDto } from '@/common/dto/pagination.dto';
|
||||
import { MAX_BODY_VALUE_BYTES } from '@/modules/mail/mail.constants';
|
||||
import type {
|
||||
Draft,
|
||||
MailAddress,
|
||||
MailAttachment,
|
||||
Mailbox,
|
||||
MailboxRole,
|
||||
MailQuota,
|
||||
MessageAttachment,
|
||||
MessageDetail,
|
||||
MessageSummary,
|
||||
RecipientSuggestion,
|
||||
SavedDraft,
|
||||
} from '@/modules/mail/mail.types';
|
||||
|
||||
/**
|
||||
@@ -63,6 +67,20 @@ export class MailAttachmentDto implements MailAttachment {
|
||||
size!: number;
|
||||
}
|
||||
|
||||
/** 메일 상세의 첨부 — 업로드 응답·임시 저장본의 첨부(MailAttachmentDto)와 달리 isInline 이 있다 */
|
||||
export class MessageAttachmentDto
|
||||
extends MailAttachmentDto
|
||||
implements MessageAttachment
|
||||
{
|
||||
@ApiProperty({
|
||||
description:
|
||||
'이 부분을 돌려준 htmlBody 안에 그림으로 넣었으면 true — 읽기 화면의 첨부 목록에서 뺀다 (전달할 때는 그대로 모두 붙인다). ' +
|
||||
'너무 크거나 개수·합계 한도를 넘거나 읽지 못해 넣지 못한 그림, htmlBody 를 비운 경우·텍스트 메일은 false',
|
||||
example: false,
|
||||
})
|
||||
isInline!: boolean;
|
||||
}
|
||||
|
||||
export class MailboxDto implements Mailbox {
|
||||
@ApiProperty({ description: '메일함 id', example: 'a' })
|
||||
id!: string;
|
||||
@@ -187,7 +205,12 @@ export class MessageSummaryDto implements MessageSummary {
|
||||
@ApiProperty({ description: '중요 표시 여부', example: false })
|
||||
isFlagged!: boolean;
|
||||
|
||||
@ApiProperty({ description: '첨부 여부', example: true })
|
||||
@ApiProperty({
|
||||
description:
|
||||
'첨부 여부 — 본문 그림(본문에 넣을 수 있는 그림 형식 · Content-ID 있음 · disposition 이 attachment 가 아님 · 1바이트~300KB)이 아닌 첨부가 하나라도 있으면 true. ' +
|
||||
'서명 로고처럼 본문 그림만 있으면 false',
|
||||
example: true,
|
||||
})
|
||||
hasAttachment!: boolean;
|
||||
|
||||
@ApiProperty({ description: '크기 (바이트)', example: 20480 })
|
||||
@@ -268,10 +291,11 @@ export class MessageDetailDto
|
||||
isBodyTruncated!: boolean;
|
||||
|
||||
@ApiProperty({
|
||||
description: '첨부 (본문 내장 이미지 포함)',
|
||||
type: [MailAttachmentDto],
|
||||
description:
|
||||
'첨부 (본문 내장 이미지 포함) — 본문에 그림으로 넣은 부분은 isInline: true',
|
||||
type: [MessageAttachmentDto],
|
||||
})
|
||||
attachments!: MailAttachmentDto[];
|
||||
attachments!: MessageAttachmentDto[];
|
||||
}
|
||||
|
||||
export class MessagePageDto {
|
||||
@@ -289,3 +313,126 @@ export class SentMessageDto {
|
||||
@ApiProperty({ description: '보낸편지함에 남은 메일 id', example: 'maaaaad' })
|
||||
id!: string;
|
||||
}
|
||||
|
||||
/** 계약 2절 "임시 저장"의 Draft — 쓰기 화면이 이어 쓴다 */
|
||||
export class DraftDto implements Draft {
|
||||
@ApiProperty({
|
||||
description:
|
||||
'임시 저장본 id — 저장할 때마다 바뀐다 (응답의 id 로 바꿔 쓴다)',
|
||||
example: 'faaaaabi',
|
||||
})
|
||||
id!: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: '받는 사람 (0명도 된다)',
|
||||
type: [MailAddressDto],
|
||||
})
|
||||
to!: MailAddressDto[];
|
||||
|
||||
@ApiProperty({ description: '참조', type: [MailAddressDto] })
|
||||
cc!: MailAddressDto[];
|
||||
|
||||
@ApiProperty({ description: '숨은 참조', type: [MailAddressDto] })
|
||||
bcc!: MailAddressDto[];
|
||||
|
||||
@ApiProperty({
|
||||
description: '제목 (없으면 빈 문자열)',
|
||||
example: 'Re: 메일 서버 이전 일정 공유',
|
||||
})
|
||||
subject!: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: '텍스트 대체 본문 (없으면 빈 문자열)',
|
||||
example: '도윤님, 확인했습니다.',
|
||||
})
|
||||
body!: string;
|
||||
|
||||
@ApiProperty({
|
||||
description:
|
||||
'서식 본문 — 보내는 정화(서식 본문 허용 목록 + 내 서명 그림)를 거친 값. 없거나 비면 null. ' +
|
||||
'다른 메일 프로그램이 만든 초안도 같은 정화를 거친다 (정화 한도를 넘는 HTML 이면 null)',
|
||||
nullable: true,
|
||||
type: String,
|
||||
})
|
||||
bodyHtml!: string | null;
|
||||
|
||||
@ApiProperty({
|
||||
description: '이 판의 첨부 — id 가 저장할 때마다 바뀐다',
|
||||
type: [MailAttachmentDto],
|
||||
})
|
||||
attachments!: MailAttachmentDto[];
|
||||
|
||||
@ApiProperty({
|
||||
description: '답장할 메일 id — 없거나 원 메일이 사라졌으면 null',
|
||||
nullable: true,
|
||||
type: String,
|
||||
example: 'eaaaaab',
|
||||
})
|
||||
inReplyTo!: string | null;
|
||||
|
||||
@ApiProperty({
|
||||
description: '마지막으로 저장한 시각 (ISO 8601 UTC)',
|
||||
example: '2026-10-27T06:12:00.000Z',
|
||||
})
|
||||
updatedAt!: string;
|
||||
}
|
||||
|
||||
/** 계약 2절 "임시 저장"의 DraftSaved */
|
||||
export class DraftSavedDto implements SavedDraft {
|
||||
@ApiProperty({ description: '새 임시 저장본 id', example: 'faaaaabj' })
|
||||
id!: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: '새 판의 첨부 — 클라이언트는 이 id 로 바꿔 쓴다',
|
||||
type: [MailAttachmentDto],
|
||||
})
|
||||
attachments!: MailAttachmentDto[];
|
||||
|
||||
@ApiProperty({
|
||||
description: '저장한 시각 (ISO 8601 UTC)',
|
||||
example: '2026-10-27T06:12:00.000Z',
|
||||
})
|
||||
updatedAt!: string;
|
||||
}
|
||||
|
||||
const SUGGESTION_SOURCES: RecipientSuggestion['source'][] = [
|
||||
'directory',
|
||||
'recent',
|
||||
];
|
||||
|
||||
/** 계약 2절 "받는 사람 추천" */
|
||||
export class RecipientSuggestionDto implements RecipientSuggestion {
|
||||
@ApiProperty({
|
||||
description: '표시 이름 (없으면 null)',
|
||||
nullable: true,
|
||||
type: String,
|
||||
example: '이도윤',
|
||||
})
|
||||
name!: string | null;
|
||||
|
||||
@ApiProperty({
|
||||
description: '메일 주소',
|
||||
example: 'doyun.lee@arasystem.it.kr',
|
||||
})
|
||||
email!: string;
|
||||
|
||||
@ApiProperty({
|
||||
description:
|
||||
'directory = 사내(웹메일에 로그인한 적 있는 직원), recent = 최근 주고받은 주소. 두 쪽에 다 있으면 directory',
|
||||
enum: SUGGESTION_SOURCES,
|
||||
example: 'directory',
|
||||
})
|
||||
source!: RecipientSuggestion['source'];
|
||||
}
|
||||
|
||||
export class RecipientSuggestionPageDto {
|
||||
@ApiProperty({
|
||||
type: [RecipientSuggestionDto],
|
||||
description:
|
||||
'사내 → 최근 주고받은 주소, 묶음 안에서는 주고받은 횟수와 최근성 순',
|
||||
})
|
||||
items!: RecipientSuggestionDto[];
|
||||
|
||||
@ApiProperty({ type: PageInfoDto, description: 'total 포함 (걸러진 개수)' })
|
||||
pageInfo!: PageInfoDto;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { IsOptional, IsString, MaxLength } from 'class-validator';
|
||||
|
||||
import { CursorPaginationQueryDto } from '@/common/dto/pagination.dto';
|
||||
import { MAX_SUGGESTION_QUERY_LENGTH } from '@/modules/mail/mail.constants';
|
||||
|
||||
/** GET /api/recipient-suggestions — 커서 페이지네이션 + 거르는 글자 (계약 2절 "받는 사람 추천") */
|
||||
export class RecipientSuggestionQueryDto extends CursorPaginationQueryDto {
|
||||
@ApiPropertyOptional({
|
||||
description: `이름·주소에 든 글자 (${MAX_SUGGESTION_QUERY_LENGTH}자 이하, 대소문자 무시). 없거나 공백뿐이면 거르지 않는다`,
|
||||
example: '도윤',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString({ message: '찾는 글자 형식이 올바르지 않습니다.' })
|
||||
@MaxLength(MAX_SUGGESTION_QUERY_LENGTH, {
|
||||
message: `찾는 글자는 ${MAX_SUGGESTION_QUERY_LENGTH}자 이하로 입력해 주세요.`,
|
||||
})
|
||||
q?: string;
|
||||
}
|
||||
@@ -76,21 +76,12 @@ export class OutgoingAttachmentDto {
|
||||
name!: string;
|
||||
}
|
||||
|
||||
/** POST /api/messages — 계약 2절 "발송 본문" */
|
||||
export class SendMessageDto {
|
||||
@ApiProperty({
|
||||
description: '받는 사람 (1명 이상)',
|
||||
type: [RecipientDto],
|
||||
})
|
||||
@IsArray({ message: '받는 사람 형식이 올바르지 않습니다.' })
|
||||
@ArrayMinSize(1, { message: '받는 사람을 1명 이상 입력해 주세요.' })
|
||||
@ArrayMaxSize(MAX_RECIPIENTS, {
|
||||
message: `받는 사람은 ${MAX_RECIPIENTS}명 이하여야 합니다.`,
|
||||
})
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => RecipientDto)
|
||||
to!: RecipientDto[];
|
||||
|
||||
/**
|
||||
* 발송 본문과 임시 저장 본문이 함께 쓰는 필드 — 계약 2절 "발송 본문"·"임시 저장" (같은 필드·같은 한도).
|
||||
* 받는 사람(to)은 둘의 규칙이 달라(발송 1명 이상 · 임시 저장 0명도 됨) 아래 두 클래스가 따로 둔다 —
|
||||
* class-validator 는 부모의 검사를 자식에서 풀 수 없다.
|
||||
*/
|
||||
export class ComposeContentDto {
|
||||
@ApiPropertyOptional({ description: '참조', type: [RecipientDto] })
|
||||
@IsOptional()
|
||||
@IsArray({ message: '참조 형식이 올바르지 않습니다.' })
|
||||
@@ -172,3 +163,44 @@ export class SendMessageDto {
|
||||
@Matches(MAIL_ID_PATTERN, { message: '답장할 메일을 다시 선택해 주세요.' })
|
||||
inReplyTo?: string;
|
||||
}
|
||||
|
||||
/** POST /api/messages — 계약 2절 "발송 본문" */
|
||||
export class SendMessageDto extends ComposeContentDto {
|
||||
@ApiProperty({
|
||||
description: '받는 사람 (1명 이상)',
|
||||
type: [RecipientDto],
|
||||
})
|
||||
@IsArray({ message: '받는 사람 형식이 올바르지 않습니다.' })
|
||||
@ArrayMinSize(1, { message: '받는 사람을 1명 이상 입력해 주세요.' })
|
||||
@ArrayMaxSize(MAX_RECIPIENTS, {
|
||||
message: `받는 사람은 ${MAX_RECIPIENTS}명 이하여야 합니다.`,
|
||||
})
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => RecipientDto)
|
||||
to!: RecipientDto[];
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description:
|
||||
'이어 쓴 임시 저장본 id — 보내기에 성공하면(같은 키 재시도로 처음 결과를 돌려줄 때 포함) 서버가 그 초안을 지운다. ' +
|
||||
'지우지 못하면 로그만 남긴다. 내용 지문에는 넣지 않는다 (계약 2절 "임시 저장")',
|
||||
example: 'faaaaabi',
|
||||
})
|
||||
@IsOptional()
|
||||
@Matches(MAIL_ID_PATTERN, { message: '임시 저장한 메일을 다시 열어 주세요.' })
|
||||
draftId?: string;
|
||||
}
|
||||
|
||||
/** POST /api/drafts · PUT /api/drafts/{id} — 계약 2절 "임시 저장" (발송 본문과 같고, 받는 사람 0명도 된다 · draftId 없음) */
|
||||
export class DraftBodyDto extends ComposeContentDto {
|
||||
@ApiProperty({
|
||||
description: `받는 사람 (0명도 된다 — 받는 사람·참조·숨은 참조 합계 ${MAX_RECIPIENTS}명 이하)`,
|
||||
type: [RecipientDto],
|
||||
})
|
||||
@IsArray({ message: '받는 사람 형식이 올바르지 않습니다.' })
|
||||
@ArrayMaxSize(MAX_RECIPIENTS, {
|
||||
message: `받는 사람은 ${MAX_RECIPIENTS}명 이하여야 합니다.`,
|
||||
})
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => RecipientDto)
|
||||
to!: RecipientDto[];
|
||||
}
|
||||
|
||||
@@ -3,6 +3,8 @@ import {
|
||||
INLINE_IMAGES_MAX_COUNT,
|
||||
INLINE_IMAGE_MAX_BYTES,
|
||||
findReferencedContentIds,
|
||||
hasRealAttachment,
|
||||
isBodyImagePart,
|
||||
normalizeContentId,
|
||||
selectInlineImages,
|
||||
toDataUri,
|
||||
@@ -10,11 +12,72 @@ import {
|
||||
|
||||
const part = (cid: string, type = 'image/png', size = 1000): InlinePart => ({
|
||||
attachmentId: `blob-${cid}`,
|
||||
attachmentIndex: 0,
|
||||
cid,
|
||||
type,
|
||||
size,
|
||||
});
|
||||
|
||||
/** 본문 그림 판정 (첨부 표시) — 계약 2절 MessageSummary.hasAttachment */
|
||||
describe('본문 그림 · 첨부 표시', () => {
|
||||
/** 우리가 보내는 서명 그림 부분 — disposition inline + Content-ID, 300KB 이하 */
|
||||
const signature = {
|
||||
type: 'image/png',
|
||||
disposition: 'inline',
|
||||
cid: 'sig-3f1c0a2e@arasystem.it.kr',
|
||||
size: 18_342,
|
||||
};
|
||||
|
||||
it('본문 그림 — 넣을 수 있는 그림 형식 · Content-ID 있음 · disposition 이 attachment 가 아님 · 1바이트~300KB', () => {
|
||||
expect(isBodyImagePart(signature)).toBe(true);
|
||||
// disposition 이 없어도(Outlook 등) · 형식 대소문자·별칭(image/jpg) · 꺾쇠 붙은 Content-ID · 정확히 300KB
|
||||
expect(isBodyImagePart({ ...signature, disposition: null })).toBe(true);
|
||||
expect(
|
||||
isBodyImagePart({
|
||||
type: 'IMAGE/JPG',
|
||||
disposition: undefined,
|
||||
cid: '<image001.png@01DA>',
|
||||
size: INLINE_IMAGE_MAX_BYTES,
|
||||
}),
|
||||
).toBe(true);
|
||||
|
||||
for (const notBodyImage of [
|
||||
{ ...signature, disposition: 'attachment' },
|
||||
{ ...signature, disposition: ' Attachment ' },
|
||||
{ ...signature, cid: null },
|
||||
{ ...signature, cid: ' <> ' },
|
||||
{ ...signature, type: 'image/svg+xml' },
|
||||
{ ...signature, type: 'application/pdf' },
|
||||
{ ...signature, type: null },
|
||||
{ ...signature, size: INLINE_IMAGE_MAX_BYTES + 1 },
|
||||
{ ...signature, size: 0 },
|
||||
{ ...signature, size: undefined },
|
||||
]) {
|
||||
expect(isBodyImagePart(notBodyImage)).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it('첨부 표시 — 본문 그림이 아닌 부분이 하나라도 있으면 true, 본문 그림만 있거나 부분이 없으면 false', () => {
|
||||
const pdf = {
|
||||
type: 'application/pdf',
|
||||
disposition: 'attachment',
|
||||
cid: null,
|
||||
size: 20_480,
|
||||
};
|
||||
|
||||
expect(hasRealAttachment([])).toBe(false);
|
||||
expect(hasRealAttachment([signature])).toBe(false);
|
||||
expect(
|
||||
hasRealAttachment([signature, { ...signature, cid: 'logo2@x' }]),
|
||||
).toBe(false);
|
||||
expect(hasRealAttachment([signature, pdf])).toBe(true);
|
||||
// 첨부로 붙인 그림(disposition attachment)은 그림이어도 첨부다
|
||||
expect(
|
||||
hasRealAttachment([{ ...signature, disposition: 'attachment' }]),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('본문 내장 이미지 정책', () => {
|
||||
it('Content-ID 를 꺾쇠·퍼센트 인코딩·대소문자와 무관하게 맞춘다', () => {
|
||||
expect(normalizeContentId('<Logo@AraSystem.it.kr>')).toBe(
|
||||
|
||||
@@ -55,6 +55,39 @@ export function inlineImageType(type: string): string | null {
|
||||
return INLINE_IMAGE_TYPES.get(type.trim().toLowerCase()) ?? null;
|
||||
}
|
||||
|
||||
/** 첨부 부분의 모양 — 본문 그림인지 가릴 때 보는 것만 (엔진과 무관한 모양) */
|
||||
export interface AttachmentPartInfo {
|
||||
readonly type?: string | null;
|
||||
readonly disposition?: string | null;
|
||||
readonly cid?: string | null;
|
||||
readonly size?: number | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 본문 그림인가 — 메일을 열면 본문 안에 그려질 수 있는 부분이라 첨부 표시(hasAttachment)에서 뺀다.
|
||||
* 모두 맞아야 한다: 넣을 수 있는 그림 형식(inlineImageType) · Content-ID 가 있음 · disposition 이 `attachment` 가 아님(inline 이거나 없음) ·
|
||||
* 크기 1바이트 이상 INLINE_IMAGE_MAX_BYTES 이하 (selectInlineImages 가 넣을 후보로 고르는 기준과 같다).
|
||||
* 본문이 실제로 가리키는지는 보지 않는다 — 목록은 본문을 읽지 않는다.
|
||||
* 우리가 보내는 서명 그림(disposition inline + cid, 300KB 이하)은 늘 본문 그림이다.
|
||||
*/
|
||||
export function isBodyImagePart(part: AttachmentPartInfo): boolean {
|
||||
const size = part.size ?? 0;
|
||||
return (
|
||||
inlineImageType(part.type ?? '') !== null &&
|
||||
normalizeContentId(part.cid ?? '') !== '' &&
|
||||
(part.disposition ?? '').trim().toLowerCase() !== 'attachment' &&
|
||||
size > 0 &&
|
||||
size <= INLINE_IMAGE_MAX_BYTES
|
||||
);
|
||||
}
|
||||
|
||||
/** 첨부가 있는 메일인가 — 본문 그림이 아닌 부분이 하나라도 있으면 true (본문 그림만 있으면 첨부 없는 메일) */
|
||||
export function hasRealAttachment(
|
||||
parts: readonly AttachmentPartInfo[],
|
||||
): boolean {
|
||||
return parts.some((part) => !isBodyImagePart(part));
|
||||
}
|
||||
|
||||
/**
|
||||
* 내려받아 넣을 후보를 고른다 — 허용 형식, 개당 한도, 본문 참조 여부, 합계 한도, 개수 상한.
|
||||
* (엔진이 알려 준 크기 기준이다. 실제로 읽을 때도 한도를 다시 지킨다)
|
||||
|
||||
@@ -106,7 +106,11 @@ describe('sanitizeMailHtml', () => {
|
||||
`<b style="background:url(${' '.repeat(1_900_000)}x)">y</b>`,
|
||||
);
|
||||
|
||||
expect(result).toEqual({ html: '<b>y</b>', hasBlockedImages: false });
|
||||
expect(result).toEqual({
|
||||
html: '<b>y</b>',
|
||||
hasBlockedImages: false,
|
||||
embeddedContentIds: new Set(),
|
||||
});
|
||||
// CPU 시간 (test/cpu-time.ts) — 지금 190만 개에 0.2초 안쪽. 예전 제곱 경로면 같은 길이에 20분 넘게 걸린다 (0.6초 × 47.5²)
|
||||
expect(cpuMs()).toBeLessThan(2_000);
|
||||
// 따옴표 앞뒤의 공백도 그대로 외부 주소로 알아본다
|
||||
@@ -190,6 +194,28 @@ describe('sanitizeMailHtml', () => {
|
||||
|
||||
expect(result.html).toBe('<p>a</p>');
|
||||
expect(result.hasBlockedImages).toBe(false);
|
||||
expect(result.embeddedContentIds).toEqual(new Set());
|
||||
});
|
||||
|
||||
it('결과에 그림으로 실제로 넣은 Content-ID 만 알려 준다 — 버린 영역 안·img 가 아닌 곳의 참조는 들지 않는다 (첨부의 isInline)', () => {
|
||||
const inline = new Map([
|
||||
['logo@x', PNG_DATA_URI],
|
||||
['hidden@x', PNG_DATA_URI],
|
||||
['background@x', PNG_DATA_URI],
|
||||
['unused@x', PNG_DATA_URI],
|
||||
]);
|
||||
|
||||
const result = sanitizeMailHtml(
|
||||
'<p><img src="CID:Logo@X"><img src="cid:logo@x"></p>' +
|
||||
'<svg><img src="cid:hidden@x"></svg>' +
|
||||
'<div style="background:url(cid:background@x)">배경</div>',
|
||||
inline,
|
||||
);
|
||||
|
||||
expect(result.embeddedContentIds).toEqual(new Set(['logo@x']));
|
||||
expect(result.html).toBe(
|
||||
`<p><img src="${PNG_DATA_URI}" /><img src="${PNG_DATA_URI}" /></p><div>배경</div>`,
|
||||
);
|
||||
});
|
||||
|
||||
it('원문의 data: 이미지는 png·jpeg·gif·webp 만 남긴다 (svg 제외)', () => {
|
||||
|
||||
@@ -31,6 +31,11 @@ export interface SanitizedMailHtml {
|
||||
readonly html: string;
|
||||
/** 외부 이미지를 하나라도 지웠으면 true → 화면에 "외부 이미지 차단" 안내 */
|
||||
readonly hasBlockedImages: boolean;
|
||||
/**
|
||||
* 결과 HTML 에 그림(data: URI)으로 실제로 넣은 내장 이미지의 Content-ID (정규화한 값).
|
||||
* 넘겨받은 그림이어도 버린 영역(svg·template 등) 안에 있어 그리지 않은 것은 들지 않는다 — 첨부 목록의 isInline 표시에 쓴다
|
||||
*/
|
||||
readonly embeddedContentIds: ReadonlySet<string>;
|
||||
}
|
||||
|
||||
const ALLOWED_TAGS = [
|
||||
@@ -294,6 +299,7 @@ function resolveImage(
|
||||
attribs: sanitizeHtml.Attributes,
|
||||
inlineImages: ReadonlyMap<string, string>,
|
||||
markBlocked: () => void,
|
||||
markEmbedded: (contentId: string) => void,
|
||||
): sanitizeHtml.Attributes {
|
||||
const next: sanitizeHtml.Attributes = { ...attribs };
|
||||
delete next.src;
|
||||
@@ -305,8 +311,13 @@ function resolveImage(
|
||||
|
||||
const src = attribs.src?.trim() ?? '';
|
||||
if (/^cid:/i.test(src)) {
|
||||
const dataUri = inlineImages.get(normalizeContentId(src.slice(4)));
|
||||
if (dataUri) next.src = dataUri;
|
||||
const contentId = normalizeContentId(src.slice(4));
|
||||
const dataUri = inlineImages.get(contentId);
|
||||
if (dataUri) {
|
||||
next.src = dataUri;
|
||||
// 주소가 남은 img 는 그대로 나간다 — 버린 영역 안의 태그는 sanitize-html 이 변환 전에 건너뛰어 여기까지 오지 않는다
|
||||
markEmbedded(contentId);
|
||||
}
|
||||
return next;
|
||||
}
|
||||
if (SAFE_DATA_IMAGE.test(src)) {
|
||||
@@ -338,6 +349,10 @@ export function sanitizeMailHtml(
|
||||
const markBlocked = () => {
|
||||
hasBlockedImages = true;
|
||||
};
|
||||
const embeddedContentIds = new Set<string>();
|
||||
const markEmbedded = (contentId: string) => {
|
||||
embeddedContentIds.add(contentId);
|
||||
};
|
||||
|
||||
const output = sanitizeHtml(html, {
|
||||
allowedTags: ALLOWED_TAGS,
|
||||
@@ -352,7 +367,12 @@ export function sanitizeMailHtml(
|
||||
transformTags: {
|
||||
a: (tagName, attribs) => ({ tagName, attribs: secureLink(attribs) }),
|
||||
img: (tagName, attribs) => {
|
||||
const next = resolveImage(attribs, inlineImages, markBlocked);
|
||||
const next = resolveImage(
|
||||
attribs,
|
||||
inlineImages,
|
||||
markBlocked,
|
||||
markEmbedded,
|
||||
);
|
||||
// 주소가 남지 않은 이미지는 태그째 지운다 (깨진 이미지 아이콘 방지) — 이름을 '' 로 바꾸면 허용 목록에 없어
|
||||
// sanitize-html 이 태그를 버린다 (img 는 빈 요소라 버릴 내용도 없다).
|
||||
// - exclusiveFilter 로 지우지 않는 이유: sanitize-html 이 그때까지의 결과 문자열을 잘라 다시 만들어(substring)
|
||||
@@ -370,5 +390,5 @@ export function sanitizeMailHtml(
|
||||
},
|
||||
});
|
||||
|
||||
return { html: output, hasBlockedImages };
|
||||
return { html: output, hasBlockedImages, embeddedContentIds };
|
||||
}
|
||||
|
||||
@@ -506,6 +506,199 @@ describe('sanitizeOutgoingHtml (보내는 서식 본문)', () => {
|
||||
});
|
||||
});
|
||||
|
||||
/** 계약 2절 "서식 본문"의 그림 예외 — 보내는 사람 자신의 서명 그림만 남는다 */
|
||||
describe('서명 그림', () => {
|
||||
const MINE = '3f1c0a2e-0f1b-4f2a-9a1e-6c9f0a1b2c3d';
|
||||
const NOT_MINE = '00000000-0000-4000-8000-000000000000';
|
||||
const options = { signatureImageIds: new Set([MINE]) };
|
||||
const src = (id: string) => `/api/mail-signature/images/${id}`;
|
||||
|
||||
it('내 서명 그림은 src·width·alt 만 남긴다 (style·그 밖의 속성은 지운다)', () => {
|
||||
expect(
|
||||
clean(
|
||||
`<p>서명</p><img src="${src(MINE)}" width="144" alt="아라시스템 로고" style="border: 1px solid #000" height="50" class="x" onerror="x()">`,
|
||||
options,
|
||||
),
|
||||
).toBe(
|
||||
`<p>서명</p><img src="${src(MINE)}" width="144" alt="아라시스템 로고" />`,
|
||||
);
|
||||
});
|
||||
|
||||
it('주소의 대소문자·앞뒤 공백은 정규화한다 — 지문이 같은 그림에 같은 값이 되게', () => {
|
||||
expect(
|
||||
clean(
|
||||
`<img src=" /api/mail-signature/images/${MINE.toUpperCase()} " width=" 96 ">`,
|
||||
options,
|
||||
),
|
||||
).toBe(`<img src="${src(MINE)}" width="96" />`);
|
||||
});
|
||||
|
||||
it('width 는 1~600 정수만, alt 는 200자까지 — 형식이 틀리면 그 속성만 뺀다', () => {
|
||||
for (const width of [
|
||||
'0',
|
||||
'601',
|
||||
'96.5',
|
||||
'96px',
|
||||
'50%',
|
||||
'096',
|
||||
'-1',
|
||||
'',
|
||||
]) {
|
||||
expect(
|
||||
clean(`<img src="${src(MINE)}" width="${width}">`, options),
|
||||
).toBe(`<img src="${src(MINE)}" />`);
|
||||
}
|
||||
expect(clean(`<img src="${src(MINE)}" width="600">`, options)).toBe(
|
||||
`<img src="${src(MINE)}" width="600" />`,
|
||||
);
|
||||
expect(
|
||||
clean(`<img src="${src(MINE)}" alt="${'가'.repeat(200)}">`, options),
|
||||
).toBe(`<img src="${src(MINE)}" alt="${'가'.repeat(200)}" />`);
|
||||
expect(
|
||||
clean(`<img src="${src(MINE)}" alt="${'가'.repeat(201)}">`, options),
|
||||
).toBe(`<img src="${src(MINE)}" />`);
|
||||
});
|
||||
|
||||
it('남의 그림·찾지 못한 그림·서명 그림이 아닌 주소는 태그째 지운다', () => {
|
||||
for (const img of [
|
||||
`<img src="${src(NOT_MINE)}" width="96">`,
|
||||
`<img src="https://portal.example${src(MINE)}">`,
|
||||
`<img src="${src(MINE)}?v=2">`,
|
||||
`<img src="/api/files/${MINE}">`,
|
||||
'<img src="cid:logo@x">',
|
||||
'<img src="data:image/png;base64,iVBORw0KGgo=">',
|
||||
'<img src="https://evil.example/pixel.gif">',
|
||||
'<img>',
|
||||
]) {
|
||||
expect(clean(`<p>앞${img}뒤</p>`, options)).toBe('<p>앞뒤</p>');
|
||||
}
|
||||
});
|
||||
|
||||
it('그림 id 를 주지 않으면 서명 그림도 지운다 (기본값 — 그림 없음)', () => {
|
||||
expect(clean(`<p>앞<img src="${src(MINE)}">뒤</p>`)).toBe('<p>앞뒤</p>');
|
||||
});
|
||||
|
||||
it('지운 그림 뒤의 형제 태그는 제 이름으로 닫힌다', () => {
|
||||
expect(
|
||||
clean(
|
||||
`<p><img src="${src(NOT_MINE)}"><strong>굵게</strong><img src="${src(MINE)}"><em>기울임</em></p>`,
|
||||
options,
|
||||
),
|
||||
).toBe(
|
||||
`<p><strong>굵게</strong><img src="${src(MINE)}" /><em>기울임</em></p>`,
|
||||
);
|
||||
});
|
||||
|
||||
it('한 번 더 정화해도 그대로이고, 받은 메일 정화를 거쳐도 그림이 남는다 (임시 저장본을 다시 읽을 때)', () => {
|
||||
const once = clean(
|
||||
`<div><p>서명</p><img src="${src(MINE)}" width="144" alt=""></div>`,
|
||||
options,
|
||||
);
|
||||
|
||||
expect(clean(once, options)).toBe(once);
|
||||
// 받은 메일 정화는 서명 그림 주소(상대 주소)를 외부 이미지로 보지 않고 지운다 — 우리 주소는 cid 로 실려 나간다
|
||||
expect(sanitizeMailHtml(once)).toEqual({
|
||||
html: '<div><p>서명</p></div>',
|
||||
hasBlockedImages: false,
|
||||
embeddedContentIds: new Set(),
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* 그림(img) 속성 표본 — frontend/src/test-utils/outgoing-style-samples.ts 의 IMAGE_SAMPLES 와 **같은 표**다 (행과 순서까지 그대로).
|
||||
* [속성, 값, 서버가 남기는가] — src 가 남지 않으면 그림째 지우고, width · alt 가 남지 않으면 그 속성만 지운다.
|
||||
* 표의 id 는 보내는 사람의 그림이다 (서버는 여기에 더해 주인을 본다 — 위 테스트들).
|
||||
* ⚠️ 그림 규칙을 바꾸면 두 표(이 표·outgoing-style-samples.ts)와 두 규칙 파일(sanitize-outgoing-html.ts·프론트 signature-image.ts)을 함께 고친다.
|
||||
*/
|
||||
describe('그림 속성 표본 — 프론트 편집기와 같은 표', () => {
|
||||
const SAMPLE_IMAGE_ID = '3f1c0a2e-0f1b-4f2a-9a1e-6c9f0a1b2c3d';
|
||||
const IMAGE_SAMPLES: readonly (readonly [
|
||||
attribute: 'src' | 'width' | 'alt',
|
||||
value: string,
|
||||
kept: boolean,
|
||||
])[] = [
|
||||
// 주소 — 정확히 /api/mail-signature/images/{uuid} (앞뒤 공백은 떼고, 대소문자는 가리지 않는다)
|
||||
['src', `/api/mail-signature/images/${SAMPLE_IMAGE_ID}`, true],
|
||||
[
|
||||
'src',
|
||||
`/api/mail-signature/images/${SAMPLE_IMAGE_ID.toUpperCase()}`,
|
||||
true,
|
||||
],
|
||||
['src', ` /api/mail-signature/images/${SAMPLE_IMAGE_ID} `, true],
|
||||
['src', '', false],
|
||||
['src', '/api/mail-signature/images/', false],
|
||||
['src', '/api/mail-signature/images/not-a-uuid', false],
|
||||
['src', `/api/mail-signature/images/${SAMPLE_IMAGE_ID}?x=1`, false],
|
||||
['src', `/api/mail-signature/images/${SAMPLE_IMAGE_ID}#a`, false],
|
||||
['src', `/api/mail-signature/images/${SAMPLE_IMAGE_ID}/../x`, false],
|
||||
['src', `/api/mail-signature/images/${SAMPLE_IMAGE_ID}/`, false],
|
||||
[
|
||||
'src',
|
||||
`https://portal.example/api/mail-signature/images/${SAMPLE_IMAGE_ID}`,
|
||||
false,
|
||||
],
|
||||
[
|
||||
'src',
|
||||
`//portal.example/api/mail-signature/images/${SAMPLE_IMAGE_ID}`,
|
||||
false,
|
||||
],
|
||||
['src', `api/mail-signature/images/${SAMPLE_IMAGE_ID}`, false],
|
||||
['src', `/API/mail-signature/images/${SAMPLE_IMAGE_ID}`, true],
|
||||
['src', `/api/files/${SAMPLE_IMAGE_ID}`, false],
|
||||
['src', `cid:${SAMPLE_IMAGE_ID}`, false],
|
||||
['src', 'data:image/png;base64,iVBORw0KGgo=', false],
|
||||
['src', 'https://evil.example/t.png', false],
|
||||
// 폭 — 1~600 정수 (앞뒤 공백은 뗀다)
|
||||
['width', '1', true],
|
||||
['width', '96', true],
|
||||
['width', '144', true],
|
||||
['width', '600', true],
|
||||
['width', ' 144 ', true],
|
||||
['width', '0', false],
|
||||
['width', '601', false],
|
||||
['width', '1000', false],
|
||||
['width', '096', false],
|
||||
['width', '-1', false],
|
||||
['width', '+5', false],
|
||||
['width', '14.5', false],
|
||||
['width', '144px', false],
|
||||
['width', '50%', false],
|
||||
['width', '1e2', false],
|
||||
['width', '', false],
|
||||
// 설명 — 200자 이하면 그대로 (빈 글자 포함)
|
||||
['alt', '', true],
|
||||
['alt', '회사 로고', true],
|
||||
['alt', 'ㄱ'.repeat(200), true],
|
||||
['alt', 'ㄱ'.repeat(201), false],
|
||||
];
|
||||
/** 서버가 다시 쓰는 정식 주소 (소문자 경로 · 소문자 id) */
|
||||
const canonical = `/api/mail-signature/images/${SAMPLE_IMAGE_ID}`;
|
||||
const mine = { signatureImageIds: new Set([SAMPLE_IMAGE_ID]) };
|
||||
|
||||
it.each(IMAGE_SAMPLES)(
|
||||
'%s="%s" — 서버가 남기는가 %s',
|
||||
(attribute, value, kept) => {
|
||||
if (attribute === 'src') {
|
||||
// 주소가 남지 않으면 그림째 지운다
|
||||
expect(clean(`<p>앞<img src="${value}">뒤</p>`, mine)).toBe(
|
||||
kept ? `<p>앞<img src="${canonical}" />뒤</p>` : '<p>앞뒤</p>',
|
||||
);
|
||||
return;
|
||||
}
|
||||
// 폭·설명은 그 속성만 — 남으면 폭은 앞뒤 공백을 뗀 값, 설명은 그대로
|
||||
const left = attribute === 'width' ? value.trim() : value;
|
||||
expect(
|
||||
clean(`<img src="${canonical}" ${attribute}="${value}">`, mine),
|
||||
).toBe(
|
||||
kept
|
||||
? `<img src="${canonical}" ${attribute}="${left}" />`
|
||||
: `<img src="${canonical}" />`,
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* 실제 편집기가 보내는 모양 — 브라우저가 style 을 정규화해 쓴다(값 끝의 `;`·쉼표 뒤 공백·따옴표 없는 한글 글꼴 이름).
|
||||
* 보내는 정화와 받은 메일 정화를 모두 지나도 남아야 한다.
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import sanitizeHtml from 'sanitize-html';
|
||||
|
||||
import { NON_TEXT_TAGS, SAFE_LINK } from './sanitize-mail-html';
|
||||
import { parseSignatureImageId, signatureImagePath } from './signature-images';
|
||||
|
||||
/**
|
||||
* 보내는 메일의 서식 본문 정화 (순수 함수) — 계약 2절 "서식 본문 (bodyHtml)".
|
||||
@@ -17,7 +18,10 @@ import { NON_TEXT_TAGS, SAFE_LINK } from './sanitize-mail-html';
|
||||
* 그렇게 숨긴 글이 보이지 않는 채로 실려 나가지 않게. 바탕과 같은 색(흰 바탕 위 흰 글자 등)은 바탕을 알 수 없어 막지 않는다
|
||||
* (받은 메일 정화에는 이 하한이 없다 — 받은 뉴스레터의 모양은 그대로 둔다)
|
||||
* - 링크: http·https·mailto 만. 그 밖의 링크는 글자만 남긴다
|
||||
* - 이미지·폼·스크립트·이벤트 속성·id·class·data-* 는 남기지 않는다 (본문 이미지는 이번 단계에서 넣지 않는다 — 첨부로)
|
||||
* - 그림은 **보내는 사람 자신의 서명 그림만** — `<img src="/api/mail-signature/images/{id}">` 이고 그 id 가
|
||||
* `signatureImageIds`(그 사람의 그림)에 있을 때. 속성은 src·width(1~600 정수)·alt(200자 이하)만 남기고, 그 밖의 img 는 태그째 지운다
|
||||
* (html/signature-images.ts — 보낼 때 서비스가 주소를 `cid:` 로 바꿔 메일 안에 함께 싣는다)
|
||||
* - 폼·스크립트·이벤트 속성·id·class·data-* 는 남기지 않는다 (붙여 넣은 그림은 첨부로 — 서명 그림만 예외)
|
||||
*
|
||||
* 같은 입력이면 늘 같은 결과다 — 발송 중복 방지의 내용 지문이 이 결과를 쓴다 (send-idempotency.ts).
|
||||
* 우리 웹메일이 다시 읽을 때는 받은 메일 정화를 한 번 더 거친다 — 여기서 남긴 서식은 그쪽 목록에도 모두 있다
|
||||
@@ -64,6 +68,8 @@ const ALLOWED_TAGS = [
|
||||
'tr',
|
||||
'th',
|
||||
'td',
|
||||
// 서명 그림만 — 아래 keepSignatureImage 가 그 밖의 img 를 태그째 지운다
|
||||
'img',
|
||||
];
|
||||
|
||||
const ALLOWED_ATTRIBUTES: Record<string, string[]> = {
|
||||
@@ -72,8 +78,21 @@ const ALLOWED_ATTRIBUTES: Record<string, string[]> = {
|
||||
ol: ['start'],
|
||||
td: ['colspan', 'rowspan'],
|
||||
th: ['colspan', 'rowspan'],
|
||||
img: ['src', 'width', 'alt'],
|
||||
};
|
||||
|
||||
/** 서명 그림의 표시 폭 상한 (px) — 계약 2절 "서명" (작게 96 · 보통 144 · 원본은 원래 폭, 600 이하) */
|
||||
export const MAX_SIGNATURE_IMAGE_WIDTH = 600;
|
||||
/** 서명 그림의 대체 글자 상한 (JS 문자열 길이) */
|
||||
export const MAX_SIGNATURE_IMAGE_ALT_LENGTH = 200;
|
||||
/** width 값 — 앞자리 0 없는 1~3자리 정수 (600 이하는 따로 본다) */
|
||||
const IMAGE_WIDTH = /^[1-9]\d{0,2}$/;
|
||||
|
||||
export interface OutgoingSanitizeOptions {
|
||||
/** 남겨도 되는 서명 그림 id — 보내는 사람 자신의 그림(소문자 UUID). 없으면 그림을 하나도 남기지 않는다 */
|
||||
readonly signatureImageIds?: ReadonlySet<string>;
|
||||
}
|
||||
|
||||
// ── style 값 형식 ───────────────────────────────────────────────────────────
|
||||
// 모두 처음부터 끝까지(^…$) 맞아야 한다. 값에 쓸 수 있는 글자가 정해져 있어 함수·주석·이스케이프가 끼어들 수 없다.
|
||||
// 형식(단위·글꼴 목록·색 표기)은 엄격하지만 값의 범위는 쓰기 화면의 메뉴(docs/design/webmail/README.md "메일 쓰기")보다
|
||||
@@ -235,11 +254,49 @@ function keepSafeLink(attribs: sanitizeHtml.Attributes): sanitizeHtml.Tag {
|
||||
: { tagName: 'span', attribs: {} };
|
||||
}
|
||||
|
||||
/**
|
||||
* 서명 그림만 남긴다 — src 가 정확히 `/api/mail-signature/images/{id}` 이고 그 id 가 보내는 사람의 그림일 때.
|
||||
* 속성은 src(정규화한 주소)·width(1~600 정수)·alt(200자 이하)만 — style 도 남기지 않는다. 형식이 틀린 width·alt 는 그 속성만 뺀다.
|
||||
* 그 밖의 img 는 태그째 지운다 — 이름을 '' 로 바꾸면 허용 목록에 없어 버려진다(img 는 빈 요소라 버릴 내용도 없다).
|
||||
* '' 인 이유는 받은 메일 정화(sanitize-mail-html.ts 의 img)와 같다 — 버린 태그의 바뀐 이름이 다음 형제를 닫는 데 쓰이지 않게.
|
||||
*/
|
||||
function keepSignatureImage(
|
||||
attribs: sanitizeHtml.Attributes,
|
||||
allowedIds: ReadonlySet<string>,
|
||||
): sanitizeHtml.Tag {
|
||||
const id = parseSignatureImageId(attribs.src);
|
||||
if (id === null || !allowedIds.has(id)) return { tagName: '', attribs: {} };
|
||||
|
||||
const next: sanitizeHtml.Attributes = { src: signatureImagePath(id) };
|
||||
const width = attribs.width?.trim();
|
||||
if (
|
||||
width !== undefined &&
|
||||
IMAGE_WIDTH.test(width) &&
|
||||
Number(width) <= MAX_SIGNATURE_IMAGE_WIDTH
|
||||
) {
|
||||
next.width = width;
|
||||
}
|
||||
if (
|
||||
attribs.alt !== undefined &&
|
||||
attribs.alt.length <= MAX_SIGNATURE_IMAGE_ALT_LENGTH
|
||||
) {
|
||||
next.alt = attribs.alt;
|
||||
}
|
||||
return { tagName: 'img', attribs: next };
|
||||
}
|
||||
|
||||
const NO_SIGNATURE_IMAGES: ReadonlySet<string> = new Set();
|
||||
|
||||
/**
|
||||
* 서식 본문을 정화한다. 결과가 비면(서식 없는 빈 입력·모두 지워진 경우) 빈 문자열이다 —
|
||||
* 그때 서비스는 텍스트 메일로 보낸다.
|
||||
* 같은 입력·같은 `signatureImageIds` 면 늘 같은 결과다 (발송 지문).
|
||||
*/
|
||||
export function sanitizeOutgoingHtml(html: string): string {
|
||||
export function sanitizeOutgoingHtml(
|
||||
html: string,
|
||||
options: OutgoingSanitizeOptions = {},
|
||||
): string {
|
||||
const signatureImageIds = options.signatureImageIds ?? NO_SIGNATURE_IMAGES;
|
||||
return sanitizeHtml(html, {
|
||||
allowedTags: ALLOWED_TAGS,
|
||||
allowedAttributes: ALLOWED_ATTRIBUTES,
|
||||
@@ -263,6 +320,8 @@ export function sanitizeOutgoingHtml(html: string): string {
|
||||
tagName,
|
||||
attribs: keepNumbers(attribs, ['colspan', 'rowspan']),
|
||||
}),
|
||||
img: (_tagName, attribs) =>
|
||||
keepSignatureImage(attribs, signatureImageIds),
|
||||
},
|
||||
}).trim();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import {
|
||||
findSignatureImageIds,
|
||||
parseSignatureImageId,
|
||||
rewriteSignatureImages,
|
||||
signatureImagePath,
|
||||
} from './signature-images';
|
||||
|
||||
const ID = '3f1c0a2e-0f1b-4f2a-9a1e-6c9f0a1b2c3d';
|
||||
const OTHER = '00000000-0000-4000-8000-000000000000';
|
||||
|
||||
describe('서명 그림 주소 (signature-images)', () => {
|
||||
it('주소가 정확히 /api/mail-signature/images/{uuid} 일 때만 id 를 준다 (대소문자 무시, 소문자로)', () => {
|
||||
expect(parseSignatureImageId(signatureImagePath(ID))).toBe(ID);
|
||||
expect(
|
||||
parseSignatureImageId(
|
||||
` /api/mail-signature/images/${ID.toUpperCase()} `,
|
||||
),
|
||||
).toBe(ID);
|
||||
|
||||
for (const src of [
|
||||
undefined,
|
||||
'',
|
||||
`https://portal.example/api/mail-signature/images/${ID}`,
|
||||
`//portal.example/api/mail-signature/images/${ID}`,
|
||||
`/api/mail-signature/images/${ID}?x=1`,
|
||||
`/api/mail-signature/images/${ID}#a`,
|
||||
`/api/mail-signature/images/${ID}/../x`,
|
||||
`/api/mail-signature/images/not-a-uuid`,
|
||||
`/api/files/${ID}`,
|
||||
`cid:${ID}`,
|
||||
]) {
|
||||
expect(parseSignatureImageId(src)).toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
it('정화한 HTML 이 가리키는 그림 id 를 나온 순서대로, 겹치지 않게 찾는다', () => {
|
||||
const html =
|
||||
`<p>a</p><img src="${signatureImagePath(OTHER)}" width="96" />` +
|
||||
`<img src="${signatureImagePath(ID)}" alt="" />` +
|
||||
`<img src="${signatureImagePath(OTHER)}" />`;
|
||||
|
||||
expect(findSignatureImageIds(html)).toEqual([OTHER, ID]);
|
||||
expect(findSignatureImageIds('<p>그림 없음</p>')).toEqual([]);
|
||||
});
|
||||
|
||||
it('주소를 바꾸고(cid 등) 나머지 속성은 그대로 둔다 — 실을 수 없는 그림은 태그째 지운다', () => {
|
||||
const html =
|
||||
`<p>서명</p><img src="${signatureImagePath(ID)}" width="144" alt="로고" />` +
|
||||
`<img src="${signatureImagePath(OTHER)}" />`;
|
||||
|
||||
expect(
|
||||
rewriteSignatureImages(html, (id) =>
|
||||
id === ID ? `cid:sig-${id}@arasystem.test` : null,
|
||||
),
|
||||
).toBe(
|
||||
`<p>서명</p><img src="cid:sig-${ID}@arasystem.test" width="144" alt="로고" />`,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,69 @@
|
||||
/**
|
||||
* 서명 그림 주소 규칙 (순수 함수) — 계약 2절 "서식 본문"의 그림 예외 · "서명".
|
||||
*
|
||||
* 서식 본문에 남는 그림은 보내는 사람 자신의 서명 그림 `<img src="/api/mail-signature/images/{id}">` 뿐이다.
|
||||
* - 임시 저장본은 이 주소 그대로 둔다 (편집기가 그 주소로 그림을 다시 불러온다)
|
||||
* - 보낼 때는 서버가 그림을 메일 안에 함께 실어(`multipart/related`) 주소를 `cid:` 로 바꾼다 — 받는 쪽에서 외부 이미지처럼 막히지 않게
|
||||
*
|
||||
* ⚠️ 아래 정규식은 **보내는 정화를 거친 HTML** 에만 쓴다 — 정화가 img 를 늘 `<img src="…" width="…" alt="…" />` 순서로 다시 쓴다
|
||||
* (sanitize-outgoing-html.ts). 남이 만든 HTML 에 직접 쓰지 않는다.
|
||||
*/
|
||||
|
||||
/** 서명 그림 주소 앞부분 — 컨트롤러 경로(`/api/mail-signature/images/{id}`)와 같다 */
|
||||
export const SIGNATURE_IMAGE_PATH_PREFIX = '/api/mail-signature/images/';
|
||||
|
||||
/** 서명 그림 id — 서버가 만든 UUID (소문자 16진) */
|
||||
const UUID = '[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}';
|
||||
|
||||
/** 그림 주소 전체 — 앞뒤에 다른 글자가 붙으면(쿼리·조각·다른 경로) 서명 그림이 아니다. 대소문자는 가리지 않는다 */
|
||||
const SIGNATURE_IMAGE_SRC = new RegExp(
|
||||
`^${escapeRegExp(SIGNATURE_IMAGE_PATH_PREFIX)}(${UUID})$`,
|
||||
'i',
|
||||
);
|
||||
|
||||
/** 정화한 HTML 안의 서명 그림 태그 — 정화가 src 를 맨 앞에 쓴다 */
|
||||
const SANITIZED_SIGNATURE_IMG = new RegExp(
|
||||
`<img src="${escapeRegExp(SIGNATURE_IMAGE_PATH_PREFIX)}(${UUID})"([^>]*)>`,
|
||||
'g',
|
||||
);
|
||||
|
||||
function escapeRegExp(text: string): string {
|
||||
return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
|
||||
/** 그림 id 의 주소 */
|
||||
export function signatureImagePath(id: string): string {
|
||||
return `${SIGNATURE_IMAGE_PATH_PREFIX}${id}`;
|
||||
}
|
||||
|
||||
/** img 의 src 가 서명 그림 주소면 그 id(소문자), 아니면 null */
|
||||
export function parseSignatureImageId(src: string | undefined): string | null {
|
||||
const match = src?.trim().match(SIGNATURE_IMAGE_SRC);
|
||||
return match ? match[1].toLowerCase() : null;
|
||||
}
|
||||
|
||||
/** 정화한 HTML 이 가리키는 서명 그림 id — 나온 순서대로, 겹치지 않게 */
|
||||
export function findSignatureImageIds(sanitizedHtml: string): string[] {
|
||||
const ids = new Set<string>();
|
||||
for (const match of sanitizedHtml.matchAll(SANITIZED_SIGNATURE_IMG)) {
|
||||
ids.add(match[1]);
|
||||
}
|
||||
return [...ids];
|
||||
}
|
||||
|
||||
/**
|
||||
* 정화한 HTML 의 서명 그림 주소를 바꾼다 — `resolve` 가 돌려준 값(예: `cid:…`)으로, null 이면 그 img 를 지운다
|
||||
* (실을 수 없는 그림 — 그사이 지운 그림 등). 나머지 속성(width·alt)은 그대로 둔다.
|
||||
*/
|
||||
export function rewriteSignatureImages(
|
||||
sanitizedHtml: string,
|
||||
resolve: (id: string) => string | null,
|
||||
): string {
|
||||
return sanitizedHtml.replace(
|
||||
SANITIZED_SIGNATURE_IMG,
|
||||
(_whole, id: string, rest: string) => {
|
||||
const src = resolve(id);
|
||||
return src === null ? '' : `<img src="${src}"${rest}>`;
|
||||
},
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -73,6 +73,11 @@ const REQUEST_TIMEOUT_MS = 10_000;
|
||||
export const JMAP_SEND_TIMEOUT_MS = 40_000;
|
||||
/** 첨부 업로드 제한 시간 — 임시 파일에서 엔진으로 흘려보낸다 (내부망 기준 20MB 에 충분한 여유) */
|
||||
const UPLOAD_TIMEOUT_MS = 120_000;
|
||||
/**
|
||||
* 발송 중에 서명 그림(한 장 300KB 이하)을 올리는 제한 시간 — 발송 전체가 클라이언트 한도(60초) 안에 답하도록 짧게.
|
||||
* 발송 경로는 남은 시간이 이보다 적으면 그만큼만 기다린다 (jmap-mail.provider.ts 의 SEND_TOTAL_BUDGET_MS).
|
||||
*/
|
||||
export const JMAP_INLINE_UPLOAD_TIMEOUT_MS = 15_000;
|
||||
/** 다운로드는 응답 헤더까지만 제한한다 — 본문은 클라이언트로 흘려보내는 중이라 시간을 정할 수 없다 */
|
||||
const DOWNLOAD_HEADERS_TIMEOUT_MS = 15_000;
|
||||
/** 작은 부분(본문 내장 이미지)을 메모리로 읽을 때의 전체 제한 시간 */
|
||||
@@ -432,6 +437,7 @@ export class JmapClient {
|
||||
user: string,
|
||||
content: Blob,
|
||||
type: string,
|
||||
options: { readonly timeoutMs?: number } = {},
|
||||
): Promise<{ blobId: string; type: string; size: number }> {
|
||||
const session = await this.getSession(user);
|
||||
const path = expandTemplate(session.uploadPath, {
|
||||
@@ -448,7 +454,7 @@ export class JmapClient {
|
||||
},
|
||||
body: content,
|
||||
},
|
||||
UPLOAD_TIMEOUT_MS,
|
||||
options.timeoutMs ?? UPLOAD_TIMEOUT_MS,
|
||||
'upload',
|
||||
async (response) => {
|
||||
await this.assertImpersonationAccepted(response, user);
|
||||
|
||||
@@ -79,10 +79,22 @@ describe('JMAP 오류 매핑', () => {
|
||||
properties: ['attachments/2/blobId'],
|
||||
}),
|
||||
).toBe(true);
|
||||
// 서명 그림을 실을 때(bodyStructure) — Stalwart 는 부분 위치 없이 알려 준다
|
||||
for (const property of [
|
||||
'bodyStructure/blobId',
|
||||
'bodyStructure/subParts/1/blobId',
|
||||
]) {
|
||||
expect(
|
||||
isMissingAttachmentBlob({
|
||||
type: 'invalidProperties',
|
||||
properties: [property],
|
||||
}),
|
||||
).toBe(true);
|
||||
}
|
||||
expect(
|
||||
isMissingAttachmentBlob({
|
||||
type: 'invalidProperties',
|
||||
properties: ['attachments/type', 'to/0/email'],
|
||||
properties: ['attachments/type', 'to/0/email', 'bodyStructure/type'],
|
||||
}),
|
||||
).toBe(false);
|
||||
expect(isMissingAttachmentBlob({ type: 'overQuota' })).toBe(false);
|
||||
|
||||
@@ -31,8 +31,14 @@ export function mapJmapErrorType(type: string | undefined): ErrorCode {
|
||||
return (type && JMAP_ERROR_CODES.get(type)) || 'MAIL_001';
|
||||
}
|
||||
|
||||
/** `attachments/blobId`, `attachments/0/blobId` — 첨부의 blob 참조 속성 */
|
||||
const ATTACHMENT_BLOB_PROPERTY = /^attachments(?:\/\d+)?\/blobId$/;
|
||||
/**
|
||||
* 첨부의 blob 참조 속성 — `attachments/blobId`, `attachments/0/blobId`.
|
||||
* 서명 그림을 함께 실을 때는 구조를 직접 적어(bodyStructure) 첨부도 그 안에 든다 — Stalwart(v0.16)는 그 안의 없는 blob 을
|
||||
* 부분 위치 없이 `bodyStructure/blobId` 로 알려 준다 (2026-10-27 개발 엔진으로 확인). 이 경로에서 blob 을 찾지 못하는 것은
|
||||
* 첨부뿐이다 — 서명 그림은 같은 요청 직전에 올린다.
|
||||
*/
|
||||
const ATTACHMENT_BLOB_PROPERTY =
|
||||
/^(?:attachments(?:\/\d+)?|bodyStructure(?:\/subParts\/\d+)*)\/blobId$/;
|
||||
|
||||
/**
|
||||
* 첨부 blob 을 엔진에서 찾지 못한 오류인지 — MAIL_004 로 응답한다.
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { sanitizeFileName } from '@/modules/mail/attachment.policy';
|
||||
import { hasRealAttachment } from '@/modules/mail/html/inline-images';
|
||||
import type {
|
||||
InlinePart,
|
||||
MailAddress,
|
||||
@@ -23,7 +24,11 @@ import type {
|
||||
* 엔진 응답은 외부 입력이므로 모든 필드를 방어적으로 읽는다.
|
||||
*/
|
||||
|
||||
/** 목록·요약에 필요한 Email 속성 */
|
||||
/**
|
||||
* 목록·요약에 필요한 Email 속성.
|
||||
* 엔진의 hasAttachment 대신 첨부 부분(attachments)을 읽어 우리가 정한다 — 본문 그림(서명 로고 등)만 있는 메일은 첨부 없음
|
||||
* (html/inline-images.ts 의 hasRealAttachment). 부분에서는 SUMMARY_BODY_PART_PROPERTIES 만 읽는다.
|
||||
*/
|
||||
export const SUMMARY_PROPERTIES = [
|
||||
'id',
|
||||
'threadId',
|
||||
@@ -34,23 +39,33 @@ export const SUMMARY_PROPERTIES = [
|
||||
'subject',
|
||||
'preview',
|
||||
'receivedAt',
|
||||
'hasAttachment',
|
||||
'attachments',
|
||||
'size',
|
||||
];
|
||||
|
||||
/** 상세에 필요한 Email 속성 */
|
||||
/** 목록·요약에서 첨부 부분마다 읽는 속성 — 첨부 표시(본문 그림인지) 판정에 쓰는 것만 */
|
||||
export const SUMMARY_BODY_PART_PROPERTIES = [
|
||||
'type',
|
||||
'disposition',
|
||||
'cid',
|
||||
'size',
|
||||
];
|
||||
|
||||
/** 상세에 필요한 Email 속성 (겹치는 이름은 한 번만 — attachments) */
|
||||
export const DETAIL_PROPERTIES = [
|
||||
...SUMMARY_PROPERTIES,
|
||||
'cc',
|
||||
'bcc',
|
||||
'replyTo',
|
||||
'sentAt',
|
||||
'messageId',
|
||||
'references',
|
||||
'bodyValues',
|
||||
'htmlBody',
|
||||
'textBody',
|
||||
'attachments',
|
||||
...new Set([
|
||||
...SUMMARY_PROPERTIES,
|
||||
'cc',
|
||||
'bcc',
|
||||
'replyTo',
|
||||
'sentAt',
|
||||
'messageId',
|
||||
'references',
|
||||
'bodyValues',
|
||||
'htmlBody',
|
||||
'textBody',
|
||||
'attachments',
|
||||
]),
|
||||
];
|
||||
|
||||
/** 본문 부분(part)에서 읽는 속성 */
|
||||
@@ -166,7 +181,7 @@ export function toSummary(email: JmapEmail): MessageSummary {
|
||||
receivedAt: toUtcIso(email.receivedAt) ?? new Date(0).toISOString(),
|
||||
isRead: email.keywords?.$seen === true,
|
||||
isFlagged: email.keywords?.$flagged === true,
|
||||
hasAttachment: email.hasAttachment === true,
|
||||
hasAttachment: hasRealAttachment(email.attachments ?? []),
|
||||
size: email.size ?? 0,
|
||||
};
|
||||
}
|
||||
@@ -233,14 +248,19 @@ function joinBodyValues(
|
||||
|
||||
export function toDetailSource(email: JmapEmail): MessageDetailSource {
|
||||
const attachments = downloadableAttachments(email);
|
||||
const inlineParts: InlinePart[] = attachments
|
||||
.filter((part) => typeof part.cid === 'string' && part.cid.trim() !== '')
|
||||
.map((part) => ({
|
||||
attachmentId: part.blobId,
|
||||
cid: (part.cid as string).trim(),
|
||||
type: partType(part),
|
||||
size: part.size ?? 0,
|
||||
}));
|
||||
const inlineParts: InlinePart[] = attachments.flatMap((part, index) =>
|
||||
typeof part.cid === 'string' && part.cid.trim() !== ''
|
||||
? [
|
||||
{
|
||||
attachmentId: part.blobId,
|
||||
attachmentIndex: index,
|
||||
cid: part.cid.trim(),
|
||||
type: partType(part),
|
||||
size: part.size ?? 0,
|
||||
},
|
||||
]
|
||||
: [],
|
||||
);
|
||||
const html = joinBodyValues(email, email.htmlBody, 'text/html');
|
||||
const text = joinBodyValues(email, email.textBody, 'text/plain');
|
||||
|
||||
|
||||
@@ -102,11 +102,17 @@ export interface JmapIdentity {
|
||||
}
|
||||
|
||||
export interface JmapGetResult<T> {
|
||||
/** 이 종류(Email 등)의 지금 상태 — 무엇이든 바뀌면 달라진다 (Foo/set 의 ifInState 에 쓴다) */
|
||||
state?: string;
|
||||
list?: T[];
|
||||
notFound?: string[];
|
||||
}
|
||||
|
||||
export interface JmapSetResult {
|
||||
/** 이 요청이 바꾸기 전의 상태 (엔진이 모르면 null) */
|
||||
oldState?: string | null;
|
||||
/** 이 요청이 바꾼 뒤의 상태 */
|
||||
newState?: string;
|
||||
created?: Record<string, { id?: string }>;
|
||||
updated?: Record<string, unknown>;
|
||||
destroyed?: string[];
|
||||
|
||||
@@ -36,9 +36,21 @@ export const MAX_BODY_VALUE_BYTES = 2_000_000;
|
||||
*/
|
||||
export const MESSAGES_ROUTE = 'messages';
|
||||
|
||||
/**
|
||||
* 임시 저장 라우트 경로 (`/api/drafts`) — 컨트롤러와, 저장 요청의 본문 한도를 발송과 같게 늘리는 파서(app.setup.ts)가 같이 쓴다.
|
||||
*/
|
||||
export const DRAFTS_ROUTE = 'drafts';
|
||||
|
||||
/** 검색어 최대 길이 */
|
||||
export const MAX_SEARCH_LENGTH = 200;
|
||||
|
||||
/** 받는 사람 추천의 거르는 글자(q) 최대 길이 — 계약 2절 "받는 사람 추천" */
|
||||
export const MAX_SUGGESTION_QUERY_LENGTH = 100;
|
||||
|
||||
/** 받는 사람 추천 — 주고받은 주소를 모으는 최근 메일 수 (보낸편지함·받은편지함 각각) */
|
||||
export const SUGGESTION_SENT_SCAN = 500;
|
||||
export const SUGGESTION_RECEIVED_SCAN = 500;
|
||||
|
||||
/** 받는 사람 표시 이름 최대 길이 */
|
||||
export const MAX_DISPLAY_NAME_LENGTH = 200;
|
||||
|
||||
|
||||
@@ -6,7 +6,10 @@ import {
|
||||
} from '@nestjs/platform-express';
|
||||
|
||||
import { BusinessException } from '@/common/errors/business.exception';
|
||||
import { MailDirectoryModule } from '@/modules/mail-directory/mail-directory.module';
|
||||
import { MailSignatureModule } from '@/modules/mail-signature/mail-signature.module';
|
||||
import { isAllowedAttachment } from './attachment.policy';
|
||||
import { DraftController } from './draft.controller';
|
||||
import { JmapClient } from './jmap/jmap.client';
|
||||
import { JmapMailProvider } from './jmap/jmap-mail.provider';
|
||||
import { MailAttachmentController } from './mail-attachment.controller';
|
||||
@@ -24,6 +27,8 @@ import {
|
||||
} from './mail.settings';
|
||||
import { MailboxController, MailQuotaController } from './mailbox.controller';
|
||||
import { MessageController } from './message.controller';
|
||||
import { RecipientSuggestionController } from './recipient-suggestion.controller';
|
||||
import { RecipientSuggestionService } from './recipient-suggestions.service';
|
||||
|
||||
function readMailSettings(config: ConfigService): MailSettings {
|
||||
return buildMailSettings((key) => config.get<string>(key));
|
||||
@@ -74,6 +79,9 @@ function attachmentUploadOptions(config: ConfigService): MulterModuleOptions {
|
||||
* 메일 모듈 — 웹메일 API 와 메일 엔진 포트.
|
||||
* 엔진을 바꿀 때는 `MAIL_PROVIDER` 의 구현(useClass)만 바꾼다.
|
||||
* 인증 모듈이 직원 자격 확인에 쓰도록 포트와 설정을 내보낸다.
|
||||
*
|
||||
* 서명(그림 포함)은 MailSignatureModule, 받는 사람 추천의 사내 목록은 MailDirectoryModule 이 맡는다 —
|
||||
* 두 모듈은 메일 모듈을 가져오지 않는다 (순환 없음).
|
||||
*/
|
||||
@Module({
|
||||
imports: [
|
||||
@@ -81,12 +89,18 @@ function attachmentUploadOptions(config: ConfigService): MulterModuleOptions {
|
||||
inject: [ConfigService],
|
||||
useFactory: attachmentUploadOptions,
|
||||
}),
|
||||
// 발송 때 서명 그림을 메일 안에 넣고, 임시 저장·발송 정화에 내 그림 id 를 쓴다 (계약 2절 "서명")
|
||||
MailSignatureModule,
|
||||
// 받는 사람 추천의 사내 목록 (계약 2절 "받는 사람 추천")
|
||||
MailDirectoryModule,
|
||||
],
|
||||
controllers: [
|
||||
MailboxController,
|
||||
MailQuotaController,
|
||||
MessageController,
|
||||
MailAttachmentController,
|
||||
DraftController,
|
||||
RecipientSuggestionController,
|
||||
],
|
||||
providers: [
|
||||
{
|
||||
@@ -97,6 +111,7 @@ function attachmentUploadOptions(config: ConfigService): MulterModuleOptions {
|
||||
JmapClient,
|
||||
{ provide: MAIL_PROVIDER, useClass: JmapMailProvider },
|
||||
MailService,
|
||||
RecipientSuggestionService,
|
||||
MailUploadTempCleanup,
|
||||
],
|
||||
exports: [MAIL_PROVIDER, MAIL_SETTINGS],
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import type {
|
||||
AttachmentContent,
|
||||
Correspondence,
|
||||
DraftContent,
|
||||
DraftSource,
|
||||
MailAccountProfile,
|
||||
MailAttachment,
|
||||
Mailbox,
|
||||
@@ -10,6 +13,7 @@ import type {
|
||||
MessageSummary,
|
||||
MessageUpdate,
|
||||
OutgoingMessage,
|
||||
SavedDraft,
|
||||
SentMessageRecord,
|
||||
UploadedFile,
|
||||
} from './mail.types';
|
||||
@@ -104,4 +108,35 @@ export interface MailProvider {
|
||||
attachmentId: string,
|
||||
maxBytes: number,
|
||||
): Promise<Buffer | null>;
|
||||
|
||||
/**
|
||||
* 임시 저장 (계약 2절 "임시 저장") — 임시보관함에 새 초안을 만든다. `replaceId` 가 있으면 그 초안을 바꾼다:
|
||||
* - 메일은 고칠 수 없어 새 초안을 **만든 뒤에** 옛것을 지운다 — 만들기가 실패하면 옛것은 그대로다
|
||||
* - `replaceId` 가 임시보관함의 사용자 초안이 아니면 아무것도 만들지 않고 RES_001
|
||||
* - 옛 초안이 이미 없으면(다른 창·기기에서 먼저 바꿨거나 지웠음) 방금 만든 것을 지우고 MAIL_008
|
||||
* 발송 중복 방지용 Message-ID·지문·발송 중 표시는 넣지 않는다. 첨부 합계·없는 첨부는 발송과 같다(FILE_002·MAIL_004).
|
||||
*/
|
||||
saveDraft(
|
||||
user: string,
|
||||
content: DraftContent,
|
||||
replaceId?: string,
|
||||
): Promise<SavedDraft>;
|
||||
|
||||
/** 쓰기 화면이 이어 쓸 임시 저장본 — 임시보관함의 사용자 초안이 아니면(발송 중에 생긴 내부 초안 포함) RES_001 */
|
||||
getDraft(user: string, id: string): Promise<DraftSource>;
|
||||
|
||||
/**
|
||||
* 임시 저장본을 지운다 (보낸 뒤) — 사용자 초안만 지운다. 이미 없거나 초안이 아니면 아무것도 하지 않는다
|
||||
* (클라이언트가 보낸 id 로 다른 메일을 지우지 않게).
|
||||
*/
|
||||
deleteDraft(user: string, id: string): Promise<void>;
|
||||
|
||||
/**
|
||||
* 주고받은 주소 (받는 사람 추천) — 그 직원의 보낸편지함 최근 `sent` 통의 받는 사람·참조·숨은참조와
|
||||
* 받은편지함 최근 `received` 통의 보낸 사람. 다른 직원의 메일함은 읽지 않는다.
|
||||
*/
|
||||
listCorrespondents(
|
||||
user: string,
|
||||
limits: { readonly sent: number; readonly received: number },
|
||||
): Promise<Correspondence[]>;
|
||||
}
|
||||
|
||||
@@ -2,17 +2,20 @@ import { Logger } from '@nestjs/common';
|
||||
|
||||
import { startCpuTimer } from '@/../test/cpu-time';
|
||||
import { BusinessException } from '@/common/errors/business.exception';
|
||||
import type { SendMessageDto } from './dto/send-message.dto';
|
||||
import { SIGNATURE_IMAGES_PER_SIGNATURE } from '@/modules/mail-signature/mail-signature.constants';
|
||||
import type { MailSignatureService } from '@/modules/mail-signature/mail-signature.service';
|
||||
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,
|
||||
} from './mail.constants';
|
||||
import { MailService } from './mail.service';
|
||||
import { DRAFT_CLEANUP_WAIT_MS, MailService } from './mail.service';
|
||||
import type { MailProvider } from './mail.provider';
|
||||
import type { MailSettings } from './mail.settings';
|
||||
import type { MessageDetailSource } from './mail.types';
|
||||
import type { DraftSource, MessageDetailSource } from './mail.types';
|
||||
import type { RecipientSuggestionService } from './recipient-suggestions.service';
|
||||
import { idempotentMessageId, payloadFingerprint } from './send-idempotency';
|
||||
|
||||
const user = { email: 'ara.kim@arasystem.test', name: '김아라' };
|
||||
@@ -62,9 +65,51 @@ function fakeProvider(): ProviderMocks {
|
||||
uploadAttachment: mockOf<'uploadAttachment'>(),
|
||||
openAttachment: mockOf<'openAttachment'>(),
|
||||
readAttachmentBytes: mockOf<'readAttachmentBytes'>(),
|
||||
saveDraft: mockOf<'saveDraft'>().mockResolvedValue({
|
||||
id: 'draft-2',
|
||||
attachments: [],
|
||||
updatedAt: '2026-10-27T01:00:00.000Z',
|
||||
}),
|
||||
getDraft: mockOf<'getDraft'>(),
|
||||
deleteDraft: mockOf<'deleteDraft'>().mockResolvedValue(undefined),
|
||||
listCorrespondents: mockOf<'listCorrespondents'>(),
|
||||
};
|
||||
}
|
||||
|
||||
/** 메일 서비스가 쓰는 서명 서비스의 두 메서드 — 그림 id 목록과 그림 읽기 */
|
||||
interface SignatureMocks {
|
||||
imageIdsOf: jest.Mock<
|
||||
ReturnType<MailSignatureService['imageIdsOf']>,
|
||||
Parameters<MailSignatureService['imageIdsOf']>
|
||||
>;
|
||||
loadImages: jest.Mock<
|
||||
ReturnType<MailSignatureService['loadImages']>,
|
||||
Parameters<MailSignatureService['loadImages']>
|
||||
>;
|
||||
}
|
||||
|
||||
function fakeSignatures(): SignatureMocks {
|
||||
return {
|
||||
imageIdsOf: jest
|
||||
.fn<
|
||||
ReturnType<MailSignatureService['imageIdsOf']>,
|
||||
Parameters<MailSignatureService['imageIdsOf']>
|
||||
>()
|
||||
.mockResolvedValue(new Set()),
|
||||
loadImages: jest
|
||||
.fn<
|
||||
ReturnType<MailSignatureService['loadImages']>,
|
||||
Parameters<MailSignatureService['loadImages']>
|
||||
>()
|
||||
.mockResolvedValue([]),
|
||||
};
|
||||
}
|
||||
|
||||
/** 내 서명 그림 · 남의 서명 그림 (서명 그림 id 는 UUID) */
|
||||
const MY_IMAGE_ID = '0b9a4f9e-7c2d-4d3a-9f4e-1a2b3c4d5e6f';
|
||||
const OTHER_IMAGE_ID = '5e6f0b9a-4f9e-4c2d-8d3a-9f4e1a2b3c4d';
|
||||
const imageSrc = (id: string) => `/api/mail-signature/images/${id}`;
|
||||
|
||||
async function codeOf(
|
||||
run: () => Promise<unknown>,
|
||||
): Promise<string | undefined> {
|
||||
@@ -130,15 +175,45 @@ const sendDto = (overrides: Partial<SendMessageDto> = {}): SendMessageDto => ({
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const draftDto = (overrides: Partial<DraftBodyDto> = {}): DraftBodyDto => ({
|
||||
to: [],
|
||||
subject: '쓰는 중',
|
||||
body: '본문',
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const draftSource = (overrides: Partial<DraftSource> = {}): DraftSource => ({
|
||||
id: 'draft-1',
|
||||
to: [{ name: '이도윤', email: 'doyun.lee@arasystem.test' }],
|
||||
cc: [],
|
||||
bcc: [],
|
||||
subject: '쓰는 중',
|
||||
textBody: '본문',
|
||||
rawHtmlBody: null,
|
||||
attachments: [],
|
||||
inReplyTo: null,
|
||||
updatedAt: '2026-10-27T01:00:00.000Z',
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe('MailService', () => {
|
||||
let provider: ProviderMocks;
|
||||
let signatures: SignatureMocks;
|
||||
let suggestions: { invalidate: jest.Mock<void, [string]> };
|
||||
let service: MailService;
|
||||
let logSpy: jest.SpyInstance;
|
||||
let warnSpy: jest.SpyInstance;
|
||||
|
||||
beforeEach(() => {
|
||||
provider = fakeProvider();
|
||||
service = new MailService(provider, settings);
|
||||
signatures = fakeSignatures();
|
||||
suggestions = { invalidate: jest.fn<void, [string]>() };
|
||||
service = new MailService(
|
||||
provider,
|
||||
settings,
|
||||
signatures as unknown as MailSignatureService,
|
||||
suggestions as unknown as RecipientSuggestionService,
|
||||
);
|
||||
logSpy = jest
|
||||
.spyOn(Logger.prototype, 'log')
|
||||
.mockImplementation(() => undefined);
|
||||
@@ -294,18 +369,21 @@ describe('MailService', () => {
|
||||
inlineParts: [
|
||||
{
|
||||
attachmentId: 'blob-logo',
|
||||
attachmentIndex: 0,
|
||||
cid: 'logo@x',
|
||||
type: 'image/png',
|
||||
size: 3,
|
||||
},
|
||||
{
|
||||
attachmentId: 'blob-big',
|
||||
attachmentIndex: 1,
|
||||
cid: 'big@x',
|
||||
type: 'image/png',
|
||||
size: 5 * 1024 * 1024,
|
||||
},
|
||||
{
|
||||
attachmentId: 'blob-unused',
|
||||
attachmentIndex: 2,
|
||||
cid: 'unused@x',
|
||||
type: 'image/png',
|
||||
size: 3,
|
||||
@@ -330,9 +408,13 @@ describe('MailService', () => {
|
||||
provider.getMessage.mockResolvedValue(
|
||||
source({
|
||||
rawHtmlBody: '<p>본문</p><img src="cid:logo@x">',
|
||||
attachments: [
|
||||
{ id: 'blob-logo', name: 'logo.png', type: 'image/png', size: 3 },
|
||||
],
|
||||
inlineParts: [
|
||||
{
|
||||
attachmentId: 'blob-logo',
|
||||
attachmentIndex: 0,
|
||||
cid: 'logo@x',
|
||||
type: 'image/png',
|
||||
size: 3,
|
||||
@@ -344,14 +426,130 @@ describe('MailService', () => {
|
||||
new BusinessException('MAIL_001'),
|
||||
);
|
||||
|
||||
// 넣지 못한 그림은 첨부 목록에 남는다 (isInline false)
|
||||
await expect(service.getMessage(user, 'm1')).resolves.toMatchObject({
|
||||
htmlBody: '<p>본문</p>',
|
||||
attachments: [
|
||||
{
|
||||
id: 'blob-logo',
|
||||
name: 'logo.png',
|
||||
type: 'image/png',
|
||||
size: 3,
|
||||
isInline: false,
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
describe('첨부의 isInline — 본문에 그림으로 넣은 부분만', () => {
|
||||
const logoPart = {
|
||||
attachmentId: 'blob-logo',
|
||||
attachmentIndex: 1,
|
||||
cid: 'logo@x',
|
||||
type: 'image/png',
|
||||
size: 3,
|
||||
};
|
||||
const attachments = [
|
||||
{
|
||||
id: 'blob-xlsx',
|
||||
name: '체크리스트.xlsx',
|
||||
type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
size: 2048,
|
||||
},
|
||||
{
|
||||
id: 'blob-logo',
|
||||
name: 'signature-1.png',
|
||||
type: 'image/png',
|
||||
size: 3,
|
||||
},
|
||||
];
|
||||
const inlineOf = (detail: { attachments: { isInline: boolean }[] }) =>
|
||||
detail.attachments.map((attachment) => attachment.isInline);
|
||||
|
||||
it('본문에 넣은 그림은 isInline true, 그 밖의 첨부는 false — 응답에는 정화 전 원본·내부 값이 없다', async () => {
|
||||
provider.getMessage.mockResolvedValue(
|
||||
source({
|
||||
rawHtmlBody: '<p>서명</p><img src="cid:logo@x">',
|
||||
attachments,
|
||||
inlineParts: [logoPart],
|
||||
}),
|
||||
);
|
||||
provider.readAttachmentBytes.mockResolvedValue(Buffer.from([1, 2, 3]));
|
||||
|
||||
const detail = await service.getMessage(user, 'm1');
|
||||
|
||||
expect(detail.attachments).toEqual([
|
||||
{ ...attachments[0], isInline: false },
|
||||
{ ...attachments[1], isInline: true },
|
||||
]);
|
||||
expect(detail).not.toHaveProperty('inlineParts');
|
||||
});
|
||||
|
||||
it('넣지 못한 그림은 false — 본문이 가리키지 않음 · 너무 큼 · 버린 영역(svg) 안', async () => {
|
||||
for (const [rawHtmlBody, size] of [
|
||||
['<p>그림을 가리키지 않는 본문</p>', 3],
|
||||
['<img src="cid:logo@x">', 5 * 1024 * 1024],
|
||||
['<svg><img src="cid:logo@x"></svg><p>본문</p>', 3],
|
||||
] as const) {
|
||||
provider.getMessage.mockResolvedValue(
|
||||
source({
|
||||
rawHtmlBody,
|
||||
attachments,
|
||||
inlineParts: [{ ...logoPart, size }],
|
||||
}),
|
||||
);
|
||||
provider.readAttachmentBytes.mockResolvedValue(
|
||||
Buffer.from([1, 2, 3]),
|
||||
);
|
||||
|
||||
expect(inlineOf(await service.getMessage(user, 'm1'))).toEqual([
|
||||
false,
|
||||
false,
|
||||
]);
|
||||
}
|
||||
});
|
||||
|
||||
it('HTML 이 없거나(텍스트 메일) 정화 한도를 넘어 비웠으면 모두 false', async () => {
|
||||
for (const rawHtmlBody of [null, TOO_DEEP_HTML]) {
|
||||
provider.getMessage.mockResolvedValue(
|
||||
source({ rawHtmlBody, attachments, inlineParts: [logoPart] }),
|
||||
);
|
||||
|
||||
expect(inlineOf(await service.getMessage(user, 'm1'))).toEqual([
|
||||
false,
|
||||
false,
|
||||
]);
|
||||
}
|
||||
expect(provider.readAttachmentBytes).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('같은 Content-ID 가 둘이면 실제로 넣은 첫 부분만 isInline', async () => {
|
||||
provider.getMessage.mockResolvedValue(
|
||||
source({
|
||||
rawHtmlBody: '<img src="cid:logo@x">',
|
||||
attachments: [
|
||||
attachments[1],
|
||||
{ ...attachments[1], id: 'blob-copy' },
|
||||
],
|
||||
inlineParts: [
|
||||
{ ...logoPart, attachmentIndex: 0 },
|
||||
{ ...logoPart, attachmentId: 'blob-copy', attachmentIndex: 1 },
|
||||
],
|
||||
}),
|
||||
);
|
||||
provider.readAttachmentBytes.mockResolvedValue(Buffer.from([1, 2, 3]));
|
||||
|
||||
expect(inlineOf(await service.getMessage(user, 'm1'))).toEqual([
|
||||
true,
|
||||
false,
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
it('정화 한도를 넘는 HTML(깊은 중첩·이어 붙여 너무 긴 본문)은 정화하지 않고 텍스트 본문만 내려준다 — 내장 이미지도 읽지 않는다', async () => {
|
||||
const logo = {
|
||||
attachmentId: 'blob-logo',
|
||||
attachmentIndex: 0,
|
||||
cid: 'logo@x',
|
||||
type: 'image/png',
|
||||
size: 3,
|
||||
@@ -1000,6 +1198,473 @@ describe('MailService', () => {
|
||||
});
|
||||
});
|
||||
|
||||
/** 계약 2절 "서식 본문"의 그림 예외 — 보낼 때 내 서명 그림을 메일 안에 싣는다 */
|
||||
describe('서명 그림 (발송)', () => {
|
||||
const png = Buffer.from('89504e470d0a1a0a', 'hex');
|
||||
const cid = `sig-${MY_IMAGE_ID}@arasystem.test`;
|
||||
|
||||
it('내 서명 그림은 cid 로 바꾸고 그림을 함께 실어 보낸다 (width·alt 는 그대로)', async () => {
|
||||
signatures.imageIdsOf.mockResolvedValue(new Set([MY_IMAGE_ID]));
|
||||
signatures.loadImages.mockResolvedValue([
|
||||
{ id: MY_IMAGE_ID, type: 'image/png', data: png },
|
||||
]);
|
||||
|
||||
await service.sendMessage(
|
||||
user,
|
||||
sendDto({
|
||||
bodyHtml: `<p>김아라 드림</p><img src="${imageSrc(MY_IMAGE_ID)}" width="144" alt="아라시스템">`,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(signatures.loadImages).toHaveBeenCalledWith(user.email, [
|
||||
MY_IMAGE_ID,
|
||||
]);
|
||||
const [, outgoing] = provider.sendMessage.mock.calls[0];
|
||||
expect(outgoing.htmlBody).toBe(
|
||||
`<p>김아라 드림</p><img src="cid:${cid}" width="144" alt="아라시스템" />`,
|
||||
);
|
||||
expect(outgoing.inlineImages).toEqual([
|
||||
{ cid, type: 'image/png', name: 'signature-1.png', data: png },
|
||||
]);
|
||||
// 텍스트 대체 본문에는 그림이 없다
|
||||
expect(outgoing.body).toBe('본문');
|
||||
});
|
||||
|
||||
it('같은 그림은 늘 같은 cid 다 — 같은 키의 재시도가 같은 지문이 된다', async () => {
|
||||
signatures.imageIdsOf.mockResolvedValue(new Set([MY_IMAGE_ID]));
|
||||
signatures.loadImages.mockResolvedValue([
|
||||
{ id: MY_IMAGE_ID, type: 'image/png', data: png },
|
||||
]);
|
||||
const dto = sendDto({
|
||||
bodyHtml: `<p>서명</p><img src="${imageSrc(MY_IMAGE_ID)}">`,
|
||||
});
|
||||
|
||||
await service.sendMessage(user, dto, IDEMPOTENCY_KEY);
|
||||
await service.sendMessage(
|
||||
user,
|
||||
dto,
|
||||
'00000000-0000-4000-8000-000000000000',
|
||||
);
|
||||
|
||||
const [[, first], [, second]] = provider.sendMessage.mock.calls;
|
||||
expect(first.htmlBody).toBe(second.htmlBody);
|
||||
expect(first.fingerprint).toBe(
|
||||
payloadFingerprint(first, IDEMPOTENCY_KEY),
|
||||
);
|
||||
});
|
||||
|
||||
it('남의 그림은 정화가 지우고, 파일을 읽지 못한 내 그림은 태그째 뺀다 — 그림이 없으면 싣지 않는다', async () => {
|
||||
signatures.imageIdsOf.mockResolvedValue(new Set([MY_IMAGE_ID]));
|
||||
// 그사이 지운 그림 — 서명 서비스가 빼고 돌려준다
|
||||
signatures.loadImages.mockResolvedValue([]);
|
||||
|
||||
await service.sendMessage(
|
||||
user,
|
||||
sendDto({
|
||||
bodyHtml: `<p>앞<img src="${imageSrc(OTHER_IMAGE_ID)}">가운데<img src="${imageSrc(MY_IMAGE_ID)}">뒤</p>`,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(signatures.loadImages).toHaveBeenCalledWith(user.email, [
|
||||
MY_IMAGE_ID,
|
||||
]);
|
||||
const [, outgoing] = provider.sendMessage.mock.calls[0];
|
||||
expect(outgoing.htmlBody).toBe('<p>앞가운데뒤</p>');
|
||||
expect(outgoing).not.toHaveProperty('inlineImages');
|
||||
});
|
||||
|
||||
it(`본문의 서명 그림도 서로 다른 내 그림 ${SIGNATURE_IMAGES_PER_SIGNATURE}장까지 — 넘으면 400 VAL_001 (보내지도 저장하지도 않는다)`, async () => {
|
||||
const ids = [
|
||||
MY_IMAGE_ID,
|
||||
'1b9a4f9e-7c2d-4d3a-9f4e-1a2b3c4d5e6f',
|
||||
'2b9a4f9e-7c2d-4d3a-9f4e-1a2b3c4d5e6f',
|
||||
'3b9a4f9e-7c2d-4d3a-9f4e-1a2b3c4d5e6f',
|
||||
];
|
||||
signatures.imageIdsOf.mockResolvedValue(new Set(ids));
|
||||
const img = (id: string) => `<img src="${imageSrc(id)}" width="96">`;
|
||||
const tooMany = `<p>서명</p>${ids.map(img).join('')}`;
|
||||
|
||||
for (const run of [
|
||||
() => service.sendMessage(user, sendDto({ bodyHtml: tooMany })),
|
||||
() =>
|
||||
service.sendMessage(
|
||||
user,
|
||||
sendDto({ bodyHtml: tooMany }),
|
||||
IDEMPOTENCY_KEY,
|
||||
),
|
||||
() => service.createDraft(user, draftDto({ bodyHtml: tooMany })),
|
||||
() =>
|
||||
service.replaceDraft(
|
||||
user,
|
||||
'draft-1',
|
||||
draftDto({ bodyHtml: tooMany }),
|
||||
),
|
||||
]) {
|
||||
expect(await failureOf(run)).toEqual({
|
||||
code: 'VAL_001',
|
||||
reason: 'too-many-signature-images',
|
||||
});
|
||||
}
|
||||
expect(provider.sendMessage).not.toHaveBeenCalled();
|
||||
expect(provider.findSentMessage).not.toHaveBeenCalled();
|
||||
expect(provider.saveDraft).not.toHaveBeenCalled();
|
||||
expect(signatures.loadImages).not.toHaveBeenCalled();
|
||||
|
||||
// 세 장 + 같은 그림 한 번 더 — 서로 다른 그림은 세 장이다
|
||||
await expect(
|
||||
service.createDraft(
|
||||
user,
|
||||
draftDto({
|
||||
bodyHtml: [ids[0], ids[1], ids[2], ids[0]].map(img).join(''),
|
||||
}),
|
||||
),
|
||||
).resolves.toMatchObject({ id: 'draft-2' });
|
||||
});
|
||||
|
||||
it('서명 그림 주소가 없으면 그림 목록을 읽지 않는다 (DB 를 보지 않는다)', async () => {
|
||||
await service.sendMessage(
|
||||
user,
|
||||
sendDto({
|
||||
bodyHtml:
|
||||
'<p>그림 없는 메일<img src="https://evil.example/p.gif"></p>',
|
||||
}),
|
||||
);
|
||||
|
||||
expect(signatures.imageIdsOf).not.toHaveBeenCalled();
|
||||
expect(signatures.loadImages).not.toHaveBeenCalled();
|
||||
expect(provider.sendMessage.mock.calls[0][1].htmlBody).toBe(
|
||||
'<p>그림 없는 메일</p>',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
/** 계약 2절 "임시 저장" — 발송과 같은 준비(정화·한도), 받는 사람 0명도 된다 */
|
||||
describe('임시 저장', () => {
|
||||
it('받는 사람이 없어도 저장한다 — 보내는 정화를 거친 서식 본문을 넘기고, 중복 방지 Message-ID·지문은 넣지 않는다', async () => {
|
||||
await expect(
|
||||
service.createDraft(
|
||||
user,
|
||||
draftDto({
|
||||
subject: '줄\r\n바꿈',
|
||||
bodyHtml: '<p onclick="x()">쓰는 중<script>alert(1)</script></p>',
|
||||
attachments: [{ id: 'blob-1', name: '체크리스트.xlsx' }],
|
||||
}),
|
||||
),
|
||||
).resolves.toEqual({
|
||||
id: 'draft-2',
|
||||
attachments: [],
|
||||
updatedAt: '2026-10-27T01:00:00.000Z',
|
||||
});
|
||||
|
||||
expect(provider.saveDraft).toHaveBeenCalledTimes(1);
|
||||
const [who, content, replaceId] = provider.saveDraft.mock.calls[0];
|
||||
expect(who).toBe(user.email);
|
||||
expect(replaceId).toBeUndefined();
|
||||
// toEqual 은 정의된 다른 속성(messageId·fingerprint·inlineImages)이 있으면 실패한다
|
||||
expect(content).toEqual({
|
||||
to: [],
|
||||
cc: [],
|
||||
bcc: [],
|
||||
subject: '줄 바꿈',
|
||||
body: '본문',
|
||||
htmlBody: '<p>쓰는 중</p>',
|
||||
attachments: [
|
||||
{
|
||||
id: 'blob-1',
|
||||
name: '체크리스트.xlsx',
|
||||
type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
},
|
||||
],
|
||||
inReplyTo: undefined,
|
||||
});
|
||||
expect(provider.findSentMessage).not.toHaveBeenCalled();
|
||||
expect(provider.sendMessage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('바꿔 저장은 옛 id 를 함께 넘기고, 엔진 쪽 판단(초안이 아님 RES_001 · 이미 바뀜 MAIL_008)을 그대로 돌려준다', async () => {
|
||||
await service.replaceDraft(user, 'draft-1', draftDto());
|
||||
expect(provider.saveDraft.mock.calls[0][2]).toBe('draft-1');
|
||||
|
||||
for (const code of ['RES_001', 'MAIL_008'] as const) {
|
||||
provider.saveDraft.mockRejectedValueOnce(new BusinessException(code));
|
||||
expect(
|
||||
await codeOf(() => service.replaceDraft(user, 'draft-1', draftDto())),
|
||||
).toBe(code);
|
||||
}
|
||||
});
|
||||
|
||||
it('발송과 같은 한도 — 본문 MAIL_007 · 받는 사람 합계 MAIL_002 · 첨부 개수 MAIL_006 · 첨부 형식 FILE_001 (저장하지 않는다)', async () => {
|
||||
const recipients = (count: number) =>
|
||||
Array.from({ length: count }, (_, index) => ({
|
||||
name: null,
|
||||
email: `r${index}@arasystem.test`,
|
||||
}));
|
||||
const cases: [Partial<DraftBodyDto>, string, string][] = [
|
||||
[
|
||||
{ body: 'x'.repeat(MAX_BODY_LENGTH + 1) },
|
||||
'MAIL_007',
|
||||
'body-too-long',
|
||||
],
|
||||
[
|
||||
{ bodyHtml: 'x'.repeat(MAX_BODY_HTML_LENGTH + 1) },
|
||||
'MAIL_007',
|
||||
'body-html-too-long',
|
||||
],
|
||||
[{ bodyHtml: TOO_DEEP_HTML }, 'MAIL_007', 'body-html-too-complex'],
|
||||
[{ bodyHtml: '&'.repeat(400_001) }, 'MAIL_007', 'body-html-too-large'],
|
||||
[
|
||||
{ to: recipients(60), cc: recipients(41) },
|
||||
'MAIL_002',
|
||||
'too-many-recipients',
|
||||
],
|
||||
[
|
||||
{
|
||||
attachments: Array.from({ length: 21 }, (_, index) => ({
|
||||
id: `blob-${index}`,
|
||||
name: `보고서${index}.pdf`,
|
||||
})),
|
||||
},
|
||||
'MAIL_006',
|
||||
'too-many-attachments',
|
||||
],
|
||||
[
|
||||
{ attachments: [{ id: 'blob-1', name: '설치.exe' }] },
|
||||
'FILE_001',
|
||||
'attachment-type-not-allowed',
|
||||
],
|
||||
];
|
||||
|
||||
for (const [overrides, code, reason] of cases) {
|
||||
expect(
|
||||
await failureOf(() => service.createDraft(user, draftDto(overrides))),
|
||||
).toEqual({ code, reason });
|
||||
expect(
|
||||
await failureOf(() =>
|
||||
service.replaceDraft(user, 'draft-1', draftDto(overrides)),
|
||||
),
|
||||
).toEqual({ code, reason });
|
||||
}
|
||||
expect(provider.saveDraft).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('서명 그림은 내 그림만 그림 주소 그대로 남긴다 — 저장본에는 그림을 싣지 않는다', async () => {
|
||||
signatures.imageIdsOf.mockResolvedValue(new Set([MY_IMAGE_ID]));
|
||||
|
||||
await service.createDraft(
|
||||
user,
|
||||
draftDto({
|
||||
bodyHtml: `<p>김아라</p><img src="${imageSrc(MY_IMAGE_ID)}" width="144" alt=""><img src="${imageSrc(OTHER_IMAGE_ID)}" width="144">`,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(signatures.imageIdsOf).toHaveBeenCalledWith(user.email);
|
||||
expect(signatures.loadImages).not.toHaveBeenCalled();
|
||||
expect(provider.saveDraft.mock.calls[0][1].htmlBody).toBe(
|
||||
`<p>김아라</p><img src="${imageSrc(MY_IMAGE_ID)}" width="144" alt="" />`,
|
||||
);
|
||||
});
|
||||
|
||||
describe('이어 쓰기', () => {
|
||||
it('엔진의 초안을 계약 모양으로 — 서식 본문은 보내는 정화를 거친다 (다른 메일 프로그램이 만든 초안도)', async () => {
|
||||
signatures.imageIdsOf.mockResolvedValue(new Set([MY_IMAGE_ID]));
|
||||
provider.getDraft.mockResolvedValue(
|
||||
draftSource({
|
||||
rawHtmlBody: `<html><body><h1 style="color: red">제목</h1><p>본문<img src="https://tracker.example/p.gif"></p><img src="${imageSrc(MY_IMAGE_ID)}" width="96"><img src="${imageSrc(OTHER_IMAGE_ID)}"></body></html>`,
|
||||
inReplyTo: 'eaaaaab',
|
||||
}),
|
||||
);
|
||||
|
||||
await expect(service.getDraft(user, 'draft-1')).resolves.toEqual({
|
||||
id: 'draft-1',
|
||||
to: [{ name: '이도윤', email: 'doyun.lee@arasystem.test' }],
|
||||
cc: [],
|
||||
bcc: [],
|
||||
subject: '쓰는 중',
|
||||
body: '본문',
|
||||
// 모르는 태그(h1)는 태그만 지우고 글자는 남긴다 — 외부 이미지·남의 그림은 태그째
|
||||
bodyHtml: `제목<p>본문</p><img src="${imageSrc(MY_IMAGE_ID)}" width="96" />`,
|
||||
attachments: [],
|
||||
inReplyTo: 'eaaaaab',
|
||||
updatedAt: '2026-10-27T01:00:00.000Z',
|
||||
});
|
||||
expect(provider.getDraft).toHaveBeenCalledWith(user.email, 'draft-1');
|
||||
});
|
||||
|
||||
it('텍스트 본문이 없으면 빈 문자열, 서식 본문이 없거나 정화해서 비면 null', async () => {
|
||||
for (const rawHtmlBody of [null, '', '<script>x()</script>']) {
|
||||
provider.getDraft.mockResolvedValue(
|
||||
draftSource({ textBody: null, rawHtmlBody }),
|
||||
);
|
||||
|
||||
const draft = await service.getDraft(user, 'draft-1');
|
||||
expect(draft.body).toBe('');
|
||||
expect(draft.bodyHtml).toBeNull();
|
||||
}
|
||||
expect(signatures.imageIdsOf).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('정화 한도를 넘는 서식 본문은 정화하지 않고 null — 텍스트 본문으로 이어 쓴다 (본문은 로그에 없다)', async () => {
|
||||
provider.getDraft.mockResolvedValue(
|
||||
draftSource({ textBody: '비밀 본문', rawHtmlBody: TOO_DEEP_HTML }),
|
||||
);
|
||||
|
||||
const draft = await service.getDraft(user, 'draft-1');
|
||||
|
||||
expect(draft.bodyHtml).toBeNull();
|
||||
expect(draft.body).toBe('비밀 본문');
|
||||
const warned = warnSpy.mock.calls
|
||||
.map(([message]) => String(message))
|
||||
.join('\n');
|
||||
expect(warned).toContain('임시 저장본 HTML 이 정화 한도를 넘어');
|
||||
expect(warned).not.toContain('비밀');
|
||||
});
|
||||
|
||||
it('초안이 아니면 엔진의 RES_001 을 그대로 돌려준다', async () => {
|
||||
provider.getDraft.mockRejectedValue(new BusinessException('RES_001'));
|
||||
|
||||
expect(await codeOf(() => service.getDraft(user, 'm1'))).toBe(
|
||||
'RES_001',
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
/** 계약 2절 "임시 저장"의 보내기 · "받는 사람 추천"의 캐시 */
|
||||
describe('보낸 뒤', () => {
|
||||
it('이어 쓴 임시 저장본(draftId)을 보내기에 성공한 뒤 지우고, 받는 사람 추천을 다시 모으게 한다', async () => {
|
||||
await expect(
|
||||
service.sendMessage(user, sendDto({ draftId: 'draft-1' })),
|
||||
).resolves.toEqual({ id: 'sent-1' });
|
||||
|
||||
expect(provider.deleteDraft).toHaveBeenCalledWith(user.email, 'draft-1');
|
||||
expect(suggestions.invalidate).toHaveBeenCalledWith(user.email);
|
||||
// 지우기는 보낸 뒤다
|
||||
expect(provider.sendMessage.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
provider.deleteDraft.mock.invocationCallOrder[0],
|
||||
);
|
||||
});
|
||||
|
||||
it('draftId 가 없으면 지우지 않는다 — 추천은 다시 모은다', async () => {
|
||||
await service.sendMessage(user, sendDto());
|
||||
|
||||
expect(provider.deleteDraft).not.toHaveBeenCalled();
|
||||
expect(suggestions.invalidate).toHaveBeenCalledWith(user.email);
|
||||
});
|
||||
|
||||
it('같은 키 재시도로 처음 결과를 돌려줄 때도 초안을 지운다', async () => {
|
||||
await service.sendMessage(user, sendDto(), IDEMPOTENCY_KEY);
|
||||
const { fingerprint } = provider.sendMessage.mock.calls[0][1];
|
||||
provider.sendMessage.mockClear();
|
||||
provider.findSentMessage.mockResolvedValue({
|
||||
id: 'sent-1',
|
||||
fingerprint: fingerprint ?? null,
|
||||
});
|
||||
|
||||
await expect(
|
||||
service.sendMessage(
|
||||
user,
|
||||
sendDto({ draftId: 'draft-1' }),
|
||||
IDEMPOTENCY_KEY,
|
||||
),
|
||||
).resolves.toEqual({ id: 'sent-1' });
|
||||
|
||||
expect(provider.sendMessage).not.toHaveBeenCalled();
|
||||
expect(provider.deleteDraft).toHaveBeenCalledWith(user.email, 'draft-1');
|
||||
});
|
||||
|
||||
it('draftId 는 지문에 넣지 않는다 — 초안 id 만 다른 재시도는 같은 내용이다', async () => {
|
||||
await service.sendMessage(
|
||||
user,
|
||||
sendDto({ draftId: 'draft-1' }),
|
||||
IDEMPOTENCY_KEY,
|
||||
);
|
||||
await service.sendMessage(user, sendDto(), IDEMPOTENCY_KEY);
|
||||
|
||||
const [[, withDraft], [, withoutDraft]] = provider.sendMessage.mock.calls;
|
||||
expect(withDraft).not.toHaveProperty('draftId');
|
||||
expect(withDraft.fingerprint).toBe(withoutDraft.fingerprint);
|
||||
});
|
||||
|
||||
it('초안을 지우지 못해도 발송은 성공이다 — 경고만 남긴다 (주소·제목 없이)', async () => {
|
||||
provider.deleteDraft.mockRejectedValue(
|
||||
new BusinessException('MAIL_001', { context: { reason: 'timeout' } }),
|
||||
);
|
||||
|
||||
await expect(
|
||||
service.sendMessage(
|
||||
user,
|
||||
sendDto({ subject: '비밀 제목', draftId: 'draft-1' }),
|
||||
),
|
||||
).resolves.toEqual({ id: 'sent-1' });
|
||||
|
||||
const warned = warnSpy.mock.calls
|
||||
.map(([message]) => String(message))
|
||||
.join('\n');
|
||||
expect(warned).toContain('[MAIL_001]');
|
||||
expect(warned).toContain('보낸 메일의 임시 저장본을 지우지 못했습니다');
|
||||
expect(warned).toContain('user=a***@arasystem.test');
|
||||
expect(warned).not.toMatch(/비밀|doyun|ara\.kim/);
|
||||
});
|
||||
|
||||
it('보내지 못하면 초안을 지우지 않고 추천도 그대로다', async () => {
|
||||
provider.sendMessage.mockRejectedValue(new BusinessException('MAIL_004'));
|
||||
|
||||
expect(
|
||||
await codeOf(() =>
|
||||
service.sendMessage(user, sendDto({ draftId: 'draft-1' })),
|
||||
),
|
||||
).toBe('MAIL_004');
|
||||
expect(provider.deleteDraft).not.toHaveBeenCalled();
|
||||
expect(suggestions.invalidate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it(`초안 지우기는 ${DRAFT_CLEANUP_WAIT_MS}ms 까지만 기다린다 — 넘으면 먼저 답하고, 뒤에서 끝난 결과는 로그로만 남긴다`, async () => {
|
||||
jest.useFakeTimers({ doNotFake: ['setImmediate', 'nextTick'] });
|
||||
try {
|
||||
let finish!: () => void;
|
||||
provider.deleteDraft.mockImplementation(
|
||||
() =>
|
||||
new Promise<void>((resolve) => {
|
||||
finish = resolve;
|
||||
}),
|
||||
);
|
||||
|
||||
const sending = service.sendMessage(
|
||||
user,
|
||||
sendDto({ draftId: 'draft-1' }),
|
||||
);
|
||||
// 보내고 지우기를 시작할 때까지
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
jest.advanceTimersByTime(DRAFT_CLEANUP_WAIT_MS);
|
||||
|
||||
await expect(sending).resolves.toEqual({ id: 'sent-1' });
|
||||
expect(warnSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining(
|
||||
'보낸 메일의 임시 저장본 지우기가 5000ms 안에 끝나지 않아 먼저 답합니다',
|
||||
),
|
||||
);
|
||||
|
||||
// 뒤에서 끝났다 — 결과는 로그로만
|
||||
jest.advanceTimersByTime(1_000);
|
||||
finish();
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
expect(logSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining('보낸 메일의 임시 저장본을 늦게 지웠습니다'),
|
||||
);
|
||||
} finally {
|
||||
jest.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it('초안 지우기가 제때 끝나면 기다렸다가 답한다 — 경고 없음', async () => {
|
||||
await service.sendMessage(user, sendDto({ draftId: 'draft-1' }));
|
||||
|
||||
expect(provider.deleteDraft).toHaveBeenCalledTimes(1);
|
||||
const warned = warnSpy.mock.calls.map(([message]) => String(message));
|
||||
expect(warned.join('\n')).not.toContain('먼저 답합니다');
|
||||
});
|
||||
});
|
||||
|
||||
it('업로드는 정리한 이름과 확장자로 정한 형식을 엔진에 넘긴다', async () => {
|
||||
provider.uploadAttachment.mockResolvedValue({
|
||||
id: 'blob-1',
|
||||
|
||||
@@ -6,15 +6,24 @@ import {
|
||||
toPaginatedResult,
|
||||
} from '@/common/dto/pagination.dto';
|
||||
import { BusinessException } from '@/common/errors/business.exception';
|
||||
import { SIGNATURE_IMAGES_PER_SIGNATURE } from '@/modules/mail-signature/mail-signature.constants';
|
||||
import { MailSignatureService } from '@/modules/mail-signature/mail-signature.service';
|
||||
import { SIGNATURE_IMAGE_EXTENSIONS } from '@/modules/mail-signature/signature-image-format';
|
||||
import { traceTag } from '@/shared/logger/request-context';
|
||||
import { maskEmail } from '@/shared/utils/mask';
|
||||
import { settledWithin } from '@/shared/utils/settle';
|
||||
import {
|
||||
MAX_ATTACHMENTS_PER_MESSAGE,
|
||||
resolveAttachmentType,
|
||||
sanitizeFileName,
|
||||
} from './attachment.policy';
|
||||
import type { MessageListQueryDto } from './dto/message-list-query.dto';
|
||||
import type { RecipientDto, SendMessageDto } from './dto/send-message.dto';
|
||||
import type {
|
||||
ComposeContentDto,
|
||||
DraftBodyDto,
|
||||
RecipientDto,
|
||||
SendMessageDto,
|
||||
} from './dto/send-message.dto';
|
||||
import type { UpdateMessageDto } from './dto/update-message.dto';
|
||||
import {
|
||||
INLINE_IMAGES_TOTAL_MAX_BYTES,
|
||||
@@ -27,6 +36,11 @@ import {
|
||||
import { checkHtmlParseBudget } from './html/html-parse-budget';
|
||||
import { sanitizeMailHtml } from './html/sanitize-mail-html';
|
||||
import { sanitizeOutgoingHtml } from './html/sanitize-outgoing-html';
|
||||
import {
|
||||
SIGNATURE_IMAGE_PATH_PREFIX,
|
||||
findSignatureImageIds,
|
||||
rewriteSignatureImages,
|
||||
} from './html/signature-images';
|
||||
import {
|
||||
MAX_BODY_HTML_LENGTH,
|
||||
MAX_BODY_LENGTH,
|
||||
@@ -37,17 +51,24 @@ import { MAIL_PROVIDER, type MailProvider } from './mail.provider';
|
||||
import { MAIL_SETTINGS, type MailSettings } from './mail.settings';
|
||||
import type {
|
||||
AttachmentContent,
|
||||
Draft,
|
||||
DraftContent,
|
||||
DraftSource,
|
||||
InlinePart,
|
||||
MailAddress,
|
||||
MailAttachment,
|
||||
Mailbox,
|
||||
MailQuota,
|
||||
MessageAttachment,
|
||||
MessageDetail,
|
||||
MessageSummary,
|
||||
OutgoingAttachment,
|
||||
OutgoingInlineImage,
|
||||
OutgoingMessage,
|
||||
SavedDraft,
|
||||
} from './mail.types';
|
||||
import { presentMailboxes } from './mailbox.presenter';
|
||||
import { RecipientSuggestionService } from './recipient-suggestions.service';
|
||||
import {
|
||||
idempotentMessageId,
|
||||
parseIdempotencyKey,
|
||||
@@ -61,6 +82,12 @@ import {
|
||||
*/
|
||||
const UNCERTAIN_SEND_HOLD_MS = 30_000;
|
||||
|
||||
/**
|
||||
* 보낸 뒤 이어 쓴 임시 저장본(draftId)을 지우는 것을 기다리는 최대 시간 — 보통은 끝날 때까지 기다려
|
||||
* 보낸 직후의 임시보관함 목록에서 그 초안이 사라져 있게 한다. 넘으면 먼저 답하고 지우기는 뒤에서 계속한다 (결과는 로그로만).
|
||||
*/
|
||||
export const DRAFT_CLEANUP_WAIT_MS = 5_000;
|
||||
|
||||
/** 헤더에 들어가는 값(제목·표시 이름)의 줄바꿈을 공백으로 — 헤더 분리 시도를 막는다 */
|
||||
function singleLine(value: string): string {
|
||||
return value.replace(/[\r\n]+/g, ' ').trim();
|
||||
@@ -71,28 +98,42 @@ function toAddress(recipient: RecipientDto): MailAddress {
|
||||
return { name: name || null, email: recipient.email.trim() };
|
||||
}
|
||||
|
||||
/**
|
||||
* 발송 본문의 한도 — 넘으면 400 MAIL_007 (계약 2절). 엔진·첨부 조회와 중복 방지 잠금 **전**이라 메일은 나가지 않는다.
|
||||
* 검사 순서: 텍스트 길이 → 서식 본문 길이 → 정화 전 비용 → 정화 → 정화 뒤 바이트.
|
||||
/** 발송·임시 저장 본문이 함께 쓰는 입력 — 받는 사람 규칙만 다르다 (dto/send-message.dto.ts) */
|
||||
type ComposeInput = ComposeContentDto & { readonly to: RecipientDto[] };
|
||||
|
||||
/** 준비를 어디에 쓰나 — 보낼 때만 서명 그림을 메일 안에 싣는다 (임시 저장본은 그림 주소 그대로) */
|
||||
type ComposePurpose = 'send' | 'draft';
|
||||
|
||||
const NO_SIGNATURE_IMAGES: ReadonlySet<string> = new Set();
|
||||
|
||||
/** 본문에 그림으로 넣은 첨부가 없다 (HTML 없이 텍스트 본문을 보여 줄 때) */
|
||||
const NO_INLINE_ATTACHMENTS: ReadonlySet<number> = new Set();
|
||||
|
||||
/** 본문에 넣으려고 읽은 내장 이미지 — 정규화한 Content-ID 로 찾는다 */
|
||||
interface LoadedInlineImages {
|
||||
/** Content-ID → data: URI (정화가 cid: 를 이것으로 바꾼다) */
|
||||
readonly dataUris: Map<string, string>;
|
||||
/** Content-ID → 그 그림을 가져온 첨부의 위치 (attachments 의 몇 번째) */
|
||||
readonly attachmentIndexes: Map<string, number>;
|
||||
}
|
||||
|
||||
/*
|
||||
* 본문 한도 — 넘으면 400 MAIL_007 (계약 2절). 엔진·첨부 조회와 중복 방지 잠금 **전**이라 메일은 나가지 않는다.
|
||||
* 검사 순서: 텍스트 길이 → 서식 본문 길이 → 정화 전 비용 → 정화(서명 그림 포함) → (보낼 때) 그림을 cid 로 → 정화 뒤 바이트.
|
||||
* 길이는 JS 문자열 길이(UTF-16 코드 단위)로 센다 — 웹 클라이언트가 보내기 전에 세는 방법과 같다
|
||||
* (class-validator 의 MaxLength 는 서로게이트 쌍·이모지 표시 선택자를 다르게 세서 클라이언트와 어긋났다).
|
||||
* @returns 정화한 서식 본문 — 서식 본문이 없거나 정화해서 비면 빈 문자열(텍스트 메일)
|
||||
*/
|
||||
function prepareBody(body: string, bodyHtml: string | undefined): string {
|
||||
|
||||
function assertBodyLength(body: string): void {
|
||||
if (body.length > MAX_BODY_LENGTH) {
|
||||
throw new BusinessException('MAIL_007', {
|
||||
context: { reason: 'body-too-long', length: body.length },
|
||||
});
|
||||
}
|
||||
return bodyHtml ? sanitizeBodyHtml(bodyHtml) : '';
|
||||
}
|
||||
|
||||
/**
|
||||
* 서식 본문 정화 — 앞뒤로 한도를 본다 (넘으면 MAIL_007).
|
||||
* - 정화 전: 길이, 그리고 깊게 중첩된 HTML 은 정화 자체가 이벤트 루프를 멈춘다 (html/html-parse-budget.ts)
|
||||
* - 정화 뒤: 결과가 본문 읽기 한도(바이트)를 넘으면 보낸편지함·받는 사람 화면에서 잘린다 (mail.constants.ts)
|
||||
*/
|
||||
function sanitizeBodyHtml(bodyHtml: string): string {
|
||||
/** 정화 전 — 길이, 그리고 깊게 중첩된 HTML 은 정화 자체가 이벤트 루프를 멈춘다 (html/html-parse-budget.ts) */
|
||||
function assertHtmlBeforeSanitize(bodyHtml: string): void {
|
||||
if (bodyHtml.length > MAX_BODY_HTML_LENGTH) {
|
||||
throw new BusinessException('MAIL_007', {
|
||||
context: { reason: 'body-html-too-long', length: bodyHtml.length },
|
||||
@@ -109,20 +150,28 @@ function sanitizeBodyHtml(bodyHtml: string): string {
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const html = sanitizeOutgoingHtml(bodyHtml);
|
||||
/** 정화 뒤 — 결과가 본문 읽기 한도(바이트)를 넘으면 보낸편지함·받는 사람 화면에서 잘린다 (mail.constants.ts) */
|
||||
function assertHtmlBytes(html: string): void {
|
||||
const bytes = Buffer.byteLength(html, 'utf8');
|
||||
if (bytes > MAX_BODY_VALUE_BYTES) {
|
||||
throw new BusinessException('MAIL_007', {
|
||||
context: { reason: 'body-html-too-large', bytes },
|
||||
});
|
||||
}
|
||||
return html;
|
||||
}
|
||||
|
||||
/** 서명 그림의 Content-ID — 같은 그림이면 늘 같은 값이다 (발송 지문이 cid 가 든 서식 본문을 본다) */
|
||||
function signatureImageCid(imageId: string, sender: string): string {
|
||||
const domain = sender.slice(sender.lastIndexOf('@') + 1).toLowerCase();
|
||||
return `sig-${imageId}@${domain}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 메일 비즈니스 로직 — 메일 엔진은 `MailProvider` 포트로만 다룬다.
|
||||
* 엔진과 무관한 규칙(표시 이름·정렬, HTML 정화, 받는 사람 수, 첨부 형식)은 여기서 지킨다.
|
||||
* 엔진과 무관한 규칙(표시 이름·정렬, HTML 정화, 받는 사람 수, 첨부 형식, 서명 그림)은 여기서 지킨다.
|
||||
* 발송과 임시 저장은 같은 준비(prepareContent)를 거친다 — 같은 필드·같은 한도 (계약 2절 "임시 저장").
|
||||
*/
|
||||
@Injectable()
|
||||
export class MailService {
|
||||
@@ -137,6 +186,8 @@ export class MailService {
|
||||
constructor(
|
||||
@Inject(MAIL_PROVIDER) private readonly provider: MailProvider,
|
||||
@Inject(MAIL_SETTINGS) private readonly settings: MailSettings,
|
||||
private readonly signatures: MailSignatureService,
|
||||
private readonly suggestions: RecipientSuggestionService,
|
||||
) {}
|
||||
|
||||
async listMailboxes(
|
||||
@@ -195,6 +246,7 @@ export class MailService {
|
||||
* 그때 isHtmlBodyOmitted 를 켜고, 클라이언트는 textBody 를 보여 준다 (html/html-parse-budget.ts).
|
||||
* isBodyTruncated 는 클라이언트가 보여 줄 본문의 잘림을 따른다 — htmlBody 가 null 이거나 공백뿐이면 텍스트, 아니면 HTML
|
||||
* (웹의 buildMailDocument 가 htmlBody 를 trim 해 비면 textBody 를 그린다).
|
||||
* 첨부의 isInline 은 정화가 실제로 본문에 그림으로 넣은 부분만 켠다 (서명 로고 등 — 읽기 화면의 첨부 목록에서 뺀다).
|
||||
*/
|
||||
async getMessage(
|
||||
user: AuthenticatedUser,
|
||||
@@ -205,12 +257,21 @@ export class MailService {
|
||||
isRawHtmlBodyTruncated,
|
||||
isTextBodyTruncated,
|
||||
inlineParts,
|
||||
attachments,
|
||||
...detail
|
||||
} = await this.provider.getMessage(user.email, id);
|
||||
|
||||
/** 첨부 — 본문에 그림으로 넣은 위치만 isInline */
|
||||
const markInline = (inlined: ReadonlySet<number>): MessageAttachment[] =>
|
||||
attachments.map((attachment, index) => ({
|
||||
...attachment,
|
||||
isInline: inlined.has(index),
|
||||
}));
|
||||
|
||||
/** HTML 없이 텍스트 본문을 보여 줄 때 (HTML 이 없거나, 있었지만 정화 한도를 넘어 비웠을 때) */
|
||||
const withoutHtml = (isHtmlBodyOmitted: boolean): MessageDetail => ({
|
||||
...detail,
|
||||
attachments: markInline(NO_INLINE_ATTACHMENTS),
|
||||
htmlBody: null,
|
||||
hasBlockedImages: false,
|
||||
isHtmlBodyOmitted,
|
||||
@@ -234,10 +295,17 @@ export class MailService {
|
||||
rawHtmlBody,
|
||||
inlineParts,
|
||||
);
|
||||
const sanitized = sanitizeMailHtml(rawHtmlBody, inlineImages);
|
||||
const sanitized = sanitizeMailHtml(rawHtmlBody, inlineImages.dataUris);
|
||||
const inlined = new Set(
|
||||
[...sanitized.embeddedContentIds].flatMap((contentId) => {
|
||||
const index = inlineImages.attachmentIndexes.get(contentId);
|
||||
return index === undefined ? [] : [index];
|
||||
}),
|
||||
);
|
||||
const showsHtml = sanitized.html.trim() !== '';
|
||||
return {
|
||||
...detail,
|
||||
attachments: markInline(inlined),
|
||||
htmlBody: sanitized.html,
|
||||
hasBlockedImages: sanitized.hasBlockedImages,
|
||||
isHtmlBodyOmitted: false,
|
||||
@@ -277,6 +345,9 @@ export class MailService {
|
||||
* - 그 메일과 내용(받는 사람·제목·본문·첨부)이 다르면 보내지 않고 409 MAIL_005
|
||||
* - 같은 키의 요청이 처리 중이면 기다리지 않고 429 REQ_001
|
||||
* - 키 형식이 틀리면 400 VAL_001, 키가 없으면 중복 방지 없이 보낸다
|
||||
*
|
||||
* `draftId`(이어 쓴 임시 저장본)는 보내기에 성공한 뒤 — 같은 키 재시도로 처음 결과를 돌려줄 때 포함 — 지운다.
|
||||
* 초안 자체를 제출하지 않는다(다른 메일 프로그램이 고쳤을 수 있다 — 정화·지문을 건너뛰게 된다). 지문에는 넣지 않는다.
|
||||
*/
|
||||
async sendMessage(
|
||||
user: AuthenticatedUser,
|
||||
@@ -290,9 +361,52 @@ export class MailService {
|
||||
});
|
||||
}
|
||||
|
||||
const message = this.prepareOutgoing(dto);
|
||||
if (parsed.key === null) return this.deliver(user, message);
|
||||
return this.deliverOnce(user, message, parsed.key);
|
||||
const message = await this.prepareOutgoing(user, dto);
|
||||
const result =
|
||||
parsed.key === null
|
||||
? await this.deliver(user, message)
|
||||
: await this.deliverOnce(user, message, parsed.key);
|
||||
await this.afterSent(user, dto.draftId);
|
||||
return result;
|
||||
}
|
||||
|
||||
/** 임시 저장 — 처음 저장 (계약 2절 "임시 저장") */
|
||||
async createDraft(
|
||||
user: AuthenticatedUser,
|
||||
dto: DraftBodyDto,
|
||||
): Promise<SavedDraft> {
|
||||
const { content } = await this.prepareContent(user, dto, 'draft');
|
||||
return this.provider.saveDraft(user.email, content);
|
||||
}
|
||||
|
||||
/** 임시 저장 — 바꿔 저장. 새 id 로 돌려준다 (초안이 아니면 RES_001, 다른 곳에서 먼저 바뀌었으면 MAIL_008) */
|
||||
async replaceDraft(
|
||||
user: AuthenticatedUser,
|
||||
id: string,
|
||||
dto: DraftBodyDto,
|
||||
): Promise<SavedDraft> {
|
||||
const { content } = await this.prepareContent(user, dto, 'draft');
|
||||
return this.provider.saveDraft(user.email, content, id);
|
||||
}
|
||||
|
||||
/**
|
||||
* 이어 쓸 임시 저장본 — 서식 본문은 보내는 정화(서명 그림 포함)를 거쳐 내보낸다.
|
||||
* 다른 메일 프로그램이 만든 초안도 같다 — 정화 한도(길이·깊은 중첩)를 넘는 HTML 이면 bodyHtml 은 null (텍스트 본문으로 이어 쓴다).
|
||||
*/
|
||||
async getDraft(user: AuthenticatedUser, id: string): Promise<Draft> {
|
||||
const source = await this.provider.getDraft(user.email, id);
|
||||
return {
|
||||
id: source.id,
|
||||
to: source.to,
|
||||
cc: source.cc,
|
||||
bcc: source.bcc,
|
||||
subject: source.subject,
|
||||
body: source.textBody ?? '',
|
||||
bodyHtml: await this.draftHtml(user, source),
|
||||
attachments: source.attachments,
|
||||
inReplyTo: source.inReplyTo,
|
||||
updatedAt: source.updatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
/** 같은 키로는 한 번만 보낸다 — 처리 중이면 REQ_001, 이미 보냈으면 그 결과, 내용이 다르면 MAIL_005 */
|
||||
@@ -358,8 +472,34 @@ export class MailService {
|
||||
}
|
||||
}
|
||||
|
||||
/** 엔진과 무관한 발송 규칙 — 받는 사람 수(MAIL_002), 첨부 개수(MAIL_006)·형식(FILE_001), 헤더 값 줄바꿈, 본문 한도(MAIL_007)·서식 본문 정화 */
|
||||
private prepareOutgoing(dto: SendMessageDto): OutgoingMessage {
|
||||
/** 보낼 메일 — 준비에 서명 그림(cid)을 더한다 */
|
||||
private async prepareOutgoing(
|
||||
user: AuthenticatedUser,
|
||||
dto: SendMessageDto,
|
||||
): Promise<OutgoingMessage> {
|
||||
const { content, inlineImages } = await this.prepareContent(
|
||||
user,
|
||||
dto,
|
||||
'send',
|
||||
);
|
||||
return {
|
||||
...content,
|
||||
...(inlineImages.length > 0 ? { inlineImages } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 엔진과 무관한 발송·임시 저장 규칙 — 받는 사람 수(MAIL_002), 첨부 개수(MAIL_006)·형식(FILE_001), 헤더 값 줄바꿈,
|
||||
* 본문 한도(MAIL_007)·서식 본문 정화(서명 그림 포함)
|
||||
*/
|
||||
private async prepareContent(
|
||||
user: AuthenticatedUser,
|
||||
dto: ComposeInput,
|
||||
purpose: ComposePurpose,
|
||||
): Promise<{
|
||||
content: DraftContent;
|
||||
inlineImages: OutgoingInlineImage[];
|
||||
}> {
|
||||
const to = dto.to.map(toAddress);
|
||||
const cc = (dto.cc ?? []).map(toAddress);
|
||||
const bcc = (dto.bcc ?? []).map(toAddress);
|
||||
@@ -392,20 +532,168 @@ export class MailService {
|
||||
});
|
||||
|
||||
// 본문 한도를 보고, 서식 본문은 편집기가 만드는 서식만 남긴다 (보내는 메일 전용 허용 목록). 정화해서 비면 텍스트 메일로 보낸다
|
||||
const htmlBody = prepareBody(dto.body, dto.bodyHtml);
|
||||
const { htmlBody, inlineImages } = await this.prepareBody(
|
||||
user,
|
||||
dto.body,
|
||||
dto.bodyHtml,
|
||||
purpose,
|
||||
);
|
||||
|
||||
return {
|
||||
to,
|
||||
cc,
|
||||
bcc,
|
||||
subject: singleLine(dto.subject),
|
||||
body: dto.body,
|
||||
...(htmlBody ? { htmlBody } : {}),
|
||||
attachments,
|
||||
inReplyTo: dto.inReplyTo,
|
||||
content: {
|
||||
to,
|
||||
cc,
|
||||
bcc,
|
||||
subject: singleLine(dto.subject),
|
||||
body: dto.body,
|
||||
...(htmlBody ? { htmlBody } : {}),
|
||||
attachments,
|
||||
inReplyTo: dto.inReplyTo,
|
||||
},
|
||||
inlineImages,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 본문 한도와 서식 본문 정화 — 순서는 위 "본문 한도" 주석 그대로. 정화한 뒤 서명 그림이 서로 다른 3장을 넘으면 VAL_001.
|
||||
* 서명 그림은 보내는 사람 자신의 그림만 남는다 — 그림 주소가 있을 때만 그 사람의 그림 목록을 읽는다.
|
||||
* @returns 정화한 서식 본문(없거나 비면 빈 문자열 — 텍스트 메일)과 메일에 함께 실을 그림(보낼 때만)
|
||||
*/
|
||||
private async prepareBody(
|
||||
user: AuthenticatedUser,
|
||||
body: string,
|
||||
bodyHtml: string | undefined,
|
||||
purpose: ComposePurpose,
|
||||
): Promise<{ htmlBody: string; inlineImages: OutgoingInlineImage[] }> {
|
||||
assertBodyLength(body);
|
||||
if (!bodyHtml) return { htmlBody: '', inlineImages: [] };
|
||||
|
||||
assertHtmlBeforeSanitize(bodyHtml);
|
||||
const sanitized = sanitizeOutgoingHtml(bodyHtml, {
|
||||
signatureImageIds: await this.signatureImageIdsFor(user, bodyHtml),
|
||||
});
|
||||
// 서명 그림은 서명 저장과 같은 한도 — 서로 다른 내 그림 3장까지 (정화가 남의 그림은 이미 지웠다). 보내기 전이라 메일은 나가지 않는다
|
||||
const imageCount = findSignatureImageIds(sanitized).length;
|
||||
if (imageCount > SIGNATURE_IMAGES_PER_SIGNATURE) {
|
||||
throw new BusinessException('VAL_001', {
|
||||
context: { reason: 'too-many-signature-images', count: imageCount },
|
||||
});
|
||||
}
|
||||
const prepared =
|
||||
purpose === 'send'
|
||||
? await this.embedSignatureImages(user, sanitized)
|
||||
: { htmlBody: sanitized, inlineImages: [] };
|
||||
assertHtmlBytes(prepared.htmlBody);
|
||||
return prepared;
|
||||
}
|
||||
|
||||
/** 서명 그림 주소가 있을 때만 그 사람의 그림 목록을 읽는다 (없으면 DB 를 보지 않는다) */
|
||||
private async signatureImageIdsFor(
|
||||
user: AuthenticatedUser,
|
||||
html: string,
|
||||
): Promise<ReadonlySet<string>> {
|
||||
return html.toLowerCase().includes(SIGNATURE_IMAGE_PATH_PREFIX)
|
||||
? this.signatures.imageIdsOf(user.email)
|
||||
: NO_SIGNATURE_IMAGES;
|
||||
}
|
||||
|
||||
/**
|
||||
* 보낼 때 — 서명 그림 주소를 `cid:` 로 바꾸고 그림을 메일 안에 함께 싣게 한다 (계약 2절 "서식 본문"의 그림 예외).
|
||||
* 실을 수 없는 그림(그사이 지운 그림 등)은 태그째 지운다.
|
||||
*/
|
||||
private async embedSignatureImages(
|
||||
user: AuthenticatedUser,
|
||||
html: string,
|
||||
): Promise<{ htmlBody: string; inlineImages: OutgoingInlineImage[] }> {
|
||||
const ids = findSignatureImageIds(html);
|
||||
if (ids.length === 0) return { htmlBody: html, inlineImages: [] };
|
||||
|
||||
const images = await this.signatures.loadImages(user.email, ids);
|
||||
const cids = new Map(
|
||||
images.map((image) => [
|
||||
image.id,
|
||||
signatureImageCid(image.id, user.email),
|
||||
]),
|
||||
);
|
||||
const inlineImages = images.map(
|
||||
(image, index): OutgoingInlineImage => ({
|
||||
cid: cids.get(image.id) ?? '',
|
||||
type: image.type,
|
||||
name: `signature-${index + 1}.${SIGNATURE_IMAGE_EXTENSIONS[image.type]}`,
|
||||
data: image.data,
|
||||
}),
|
||||
);
|
||||
const htmlBody = rewriteSignatureImages(html, (id) => {
|
||||
const cid = cids.get(id);
|
||||
return cid ? `cid:${cid}` : null;
|
||||
}).trim();
|
||||
return { htmlBody, inlineImages };
|
||||
}
|
||||
|
||||
/** 이어 쓸 임시 저장본의 서식 본문 — 보내는 정화를 거친 값, 없거나 비거나 정화 한도를 넘으면 null */
|
||||
private async draftHtml(
|
||||
user: AuthenticatedUser,
|
||||
source: DraftSource,
|
||||
): Promise<string | null> {
|
||||
const raw = source.rawHtmlBody;
|
||||
if (!raw) return null;
|
||||
|
||||
const budget = checkHtmlParseBudget(raw);
|
||||
if (!budget.ok) {
|
||||
// 본문 내용은 남기지 않는다 (.claude/rules/logging.md)
|
||||
this.logger.warn(
|
||||
`${traceTag()} 임시 저장본 HTML 이 정화 한도를 넘어 텍스트 본문만 내려줍니다: user=${maskEmail(user.email)}, id=${source.id}, 사유=${budget.reason}, 길이=${raw.length}자`,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
const html = sanitizeOutgoingHtml(raw, {
|
||||
signatureImageIds: await this.signatureImageIdsFor(user, raw),
|
||||
});
|
||||
return html === '' ? null : html;
|
||||
}
|
||||
|
||||
/**
|
||||
* 보낸 뒤 — 받는 사람 추천을 다시 모으게 하고, 이어 쓴 임시 저장본을 지운다.
|
||||
* 지우지 못해도 발송은 성공이다 — 경고만 남긴다 (초안이 하나 남을 뿐이다).
|
||||
* 지우기는 DRAFT_CLEANUP_WAIT_MS 까지만 기다린다 — 엔진이 늦으면 먼저 답하고 뒤에서 계속한다 (발송 응답을 붙잡지 않게).
|
||||
*/
|
||||
private async afterSent(
|
||||
user: AuthenticatedUser,
|
||||
draftId: string | undefined,
|
||||
): Promise<void> {
|
||||
this.suggestions.invalidate(user.email);
|
||||
if (!draftId) return;
|
||||
|
||||
const startedAt = Date.now();
|
||||
const cleanup = this.deleteSentDraft(user, draftId, startedAt);
|
||||
if (!(await settledWithin(cleanup, DRAFT_CLEANUP_WAIT_MS))) {
|
||||
this.logger.warn(
|
||||
`[MAIL_001]${traceTag()} 보낸 메일의 임시 저장본 지우기가 ${DRAFT_CLEANUP_WAIT_MS}ms 안에 끝나지 않아 먼저 답합니다 — 뒤에서 계속합니다: user=${maskEmail(user.email)}, id=${draftId}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** 보낸 메일의 임시 저장본을 지운다 — 던지지 않는다 (발송은 이미 성공이다). 기다림을 넘겨 끝났으면 그 결과를 로그로 */
|
||||
private async deleteSentDraft(
|
||||
user: AuthenticatedUser,
|
||||
draftId: string,
|
||||
startedAt: number,
|
||||
): Promise<void> {
|
||||
try {
|
||||
await this.provider.deleteDraft(user.email, draftId);
|
||||
const elapsedMs = Date.now() - startedAt;
|
||||
if (elapsedMs > DRAFT_CLEANUP_WAIT_MS) {
|
||||
this.logger.log(
|
||||
`${traceTag()} 보낸 메일의 임시 저장본을 늦게 지웠습니다 (${elapsedMs}ms): user=${maskEmail(user.email)}, id=${draftId}`,
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
this.logger.warn(
|
||||
`[MAIL_001]${traceTag()} 보낸 메일의 임시 저장본을 지우지 못했습니다: user=${maskEmail(user.email)}, id=${draftId}, reason=${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private async deliver(
|
||||
user: AuthenticatedUser,
|
||||
message: OutgoingMessage,
|
||||
@@ -458,8 +746,11 @@ export class MailService {
|
||||
user: string,
|
||||
html: string,
|
||||
parts: readonly InlinePart[],
|
||||
): Promise<Map<string, string>> {
|
||||
const images = new Map<string, string>();
|
||||
): Promise<LoadedInlineImages> {
|
||||
const images: LoadedInlineImages = {
|
||||
dataUris: new Map(),
|
||||
attachmentIndexes: new Map(),
|
||||
};
|
||||
const candidates = selectInlineImages(
|
||||
parts,
|
||||
findReferencedContentIds(html),
|
||||
@@ -496,7 +787,9 @@ export class MailService {
|
||||
if (!dataUri) continue;
|
||||
|
||||
total += data.length;
|
||||
images.set(normalizeContentId(part.cid), dataUri);
|
||||
const contentId = normalizeContentId(part.cid);
|
||||
images.dataUris.set(contentId, dataUri);
|
||||
images.attachmentIndexes.set(contentId, part.attachmentIndex);
|
||||
}
|
||||
return images;
|
||||
}
|
||||
|
||||
@@ -55,6 +55,16 @@ export interface MailAttachment {
|
||||
readonly size: number;
|
||||
}
|
||||
|
||||
/** 메일 상세의 첨부 — 본문 안에 그림으로 넣었는지 함께 (업로드 응답·임시 저장본의 첨부는 MailAttachment 그대로) */
|
||||
export interface MessageAttachment extends MailAttachment {
|
||||
/**
|
||||
* 이 부분을 돌려준 htmlBody 안에 그림(data: URI)으로 넣었으면 true — 읽기 화면의 첨부 목록에서 뺀다.
|
||||
* 너무 크거나 개수·합계 한도를 넘거나 읽지 못해 넣지 못한 그림, htmlBody 를 비운 경우(isHtmlBodyOmitted)·텍스트 메일은 false.
|
||||
* 전달할 때는 isInline 이어도 모두 붙인다 (원문 그림을 잃지 않게)
|
||||
*/
|
||||
readonly isInline: boolean;
|
||||
}
|
||||
|
||||
export interface MessageDetail extends MessageSummary {
|
||||
readonly cc: MailAddress[];
|
||||
readonly bcc: MailAddress[];
|
||||
@@ -75,12 +85,14 @@ export interface MessageDetail extends MessageSummary {
|
||||
* 보여 줄 본문 = htmlBody, 그것이 null 이거나 공백뿐이면 textBody (웹 클라이언트의 buildMailDocument 와 같은 규칙)
|
||||
*/
|
||||
readonly isBodyTruncated: boolean;
|
||||
readonly attachments: MailAttachment[];
|
||||
readonly attachments: MessageAttachment[];
|
||||
}
|
||||
|
||||
/** 본문이 `cid:` 로 참조할 수 있는 내장 부분 (첨부 목록에 있는 것 중 Content-ID 가 있는 것) */
|
||||
export interface InlinePart {
|
||||
readonly attachmentId: string;
|
||||
/** 상세의 attachments 에서 몇 번째 부분인지 — 본문에 넣었으면 그 첨부의 isInline 을 켠다 */
|
||||
readonly attachmentIndex: number;
|
||||
/** Content-ID (꺾쇠 괄호 없이) */
|
||||
readonly cid: string;
|
||||
readonly type: string;
|
||||
@@ -99,7 +111,10 @@ export interface MessageDetailSource extends Omit<
|
||||
| 'hasBlockedImages'
|
||||
| 'isHtmlBodyOmitted'
|
||||
| 'isBodyTruncated'
|
||||
| 'attachments'
|
||||
> {
|
||||
/** 첨부 — 본문에 넣었는지(isInline)는 정화한 뒤 서비스 계층이 정한다 */
|
||||
readonly attachments: MailAttachment[];
|
||||
readonly rawHtmlBody: string | null;
|
||||
/** HTML 부분 중 하나라도 엔진이 본문 읽기 한도에서 잘랐으면 true (HTML 이 없으면 false) */
|
||||
readonly isRawHtmlBodyTruncated: boolean;
|
||||
@@ -140,6 +155,18 @@ export interface OutgoingAttachment {
|
||||
readonly type: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 메일 안에 함께 싣는 그림 (서명 그림) — 서식 본문이 `cid:{cid}` 로 가리킨다.
|
||||
* 어댑터는 서식 본문과 함께 multipart/related 로 묶는다 (계약 2절 "서식 본문"의 그림 예외).
|
||||
*/
|
||||
export interface OutgoingInlineImage {
|
||||
/** Content-ID (꺾쇠 괄호 없이) — 같은 그림이면 늘 같은 값이다 (발송 지문이 서식 본문에 든 cid 를 본다) */
|
||||
readonly cid: string;
|
||||
readonly type: string;
|
||||
readonly name: string;
|
||||
readonly data: Buffer;
|
||||
}
|
||||
|
||||
export interface OutgoingMessage {
|
||||
readonly to: MailAddress[];
|
||||
readonly cc: MailAddress[];
|
||||
@@ -153,6 +180,8 @@ export interface OutgoingMessage {
|
||||
*/
|
||||
readonly htmlBody?: string;
|
||||
readonly attachments: OutgoingAttachment[];
|
||||
/** 서식 본문이 cid 로 가리키는 서명 그림 — 있으면 multipart/related 로 함께 싣는다 */
|
||||
readonly inlineImages?: readonly OutgoingInlineImage[];
|
||||
/** 답장할 메일의 id — 어댑터가 스레드 헤더(In-Reply-To·References)를 채운다 */
|
||||
readonly inReplyTo?: string;
|
||||
/**
|
||||
@@ -164,6 +193,84 @@ export interface OutgoingMessage {
|
||||
readonly fingerprint?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 임시 저장할 내용 — 발송과 같은 규칙으로 준비한다(정화·한도). 서명 그림은 `/api/mail-signature/images/{id}` 주소 그대로 둔다.
|
||||
* 발송 중복 방지용 Message-ID·지문은 넣지 않는다 (계약 2절 "임시 저장").
|
||||
*/
|
||||
export type DraftContent = Pick<
|
||||
OutgoingMessage,
|
||||
| 'to'
|
||||
| 'cc'
|
||||
| 'bcc'
|
||||
| 'subject'
|
||||
| 'body'
|
||||
| 'htmlBody'
|
||||
| 'attachments'
|
||||
| 'inReplyTo'
|
||||
>;
|
||||
|
||||
/** 저장한 임시 저장본 — 저장할 때마다 id·첨부 id 가 바뀐다 */
|
||||
export interface SavedDraft {
|
||||
readonly id: string;
|
||||
readonly attachments: MailAttachment[];
|
||||
/** 마지막으로 저장한 시각 (ISO 8601 UTC) */
|
||||
readonly updatedAt: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 어댑터가 돌려주는 임시 저장본 원본 — HTML 은 정화 전 값이다(다른 메일 프로그램이 만든 초안일 수 있다).
|
||||
* 서비스가 보내는 정화를 거친 뒤에만 Draft 로 내보낸다.
|
||||
*/
|
||||
export interface DraftSource {
|
||||
readonly id: string;
|
||||
readonly to: MailAddress[];
|
||||
readonly cc: MailAddress[];
|
||||
readonly bcc: MailAddress[];
|
||||
readonly subject: string;
|
||||
readonly textBody: string | null;
|
||||
readonly rawHtmlBody: string | null;
|
||||
readonly attachments: MailAttachment[];
|
||||
/** 답장할 메일 id — 그 메일이 아직 있을 때만 (없거나 사라졌으면 null) */
|
||||
readonly inReplyTo: string | null;
|
||||
readonly updatedAt: string;
|
||||
}
|
||||
|
||||
/** 쓰기 화면에서 이어 쓸 임시 저장본 (계약 2절 "임시 저장"의 Draft) */
|
||||
export interface Draft {
|
||||
readonly id: string;
|
||||
readonly to: MailAddress[];
|
||||
readonly cc: MailAddress[];
|
||||
readonly bcc: MailAddress[];
|
||||
readonly subject: string;
|
||||
readonly body: string;
|
||||
/** 보내는 정화를 거친 서식 본문 — 없거나 비면 null */
|
||||
readonly bodyHtml: string | null;
|
||||
readonly attachments: MailAttachment[];
|
||||
readonly inReplyTo: string | null;
|
||||
readonly updatedAt: string;
|
||||
}
|
||||
|
||||
/** 주고받은 주소 하나 — 보낸 메일의 받는 사람·참조·숨은참조, 받은 메일의 보낸 사람 */
|
||||
export interface Correspondence {
|
||||
readonly name: string | null;
|
||||
readonly email: string;
|
||||
/** 그 메일의 받은 시각 (ISO 8601 UTC) */
|
||||
readonly at: string;
|
||||
}
|
||||
|
||||
/** 사내 목록의 한 사람 — 웹메일에 로그인한 적 있는 직원 */
|
||||
export interface DirectoryContact {
|
||||
readonly email: string;
|
||||
readonly name: string;
|
||||
}
|
||||
|
||||
/** 받는 사람 추천 (계약 2절 "받는 사람 추천") */
|
||||
export interface RecipientSuggestion {
|
||||
readonly name: string | null;
|
||||
readonly email: string;
|
||||
readonly source: 'directory' | 'recent';
|
||||
}
|
||||
|
||||
/** 같은 Message-ID 로 이미 발송한 메일 */
|
||||
export interface SentMessageRecord {
|
||||
/** 보낸편지함의 메일 id */
|
||||
|
||||
@@ -151,10 +151,12 @@ export class MessageController {
|
||||
'Idempotency-Key 가 같으면 한 번만 보낸다 — 이미 보냈으면 그 결과(201 { id })를 그대로 돌려준다. ' +
|
||||
'같은 키인데 내용이 다르면 409 MAIL_005, 같은 키가 처리 중이면 429 REQ_001. ' +
|
||||
'서식 본문(bodyHtml)이 있으면 정화한 뒤 body(텍스트)와 함께 multipart/alternative 로 보낸다. ' +
|
||||
'보내는 사람 자신의 서명 그림(<img src="/api/mail-signature/images/{id}">)만 남겨 메일 안에 함께 싣는다(multipart/related, cid:). ' +
|
||||
'draftId(이어 쓴 임시 저장본)가 있으면 보내기에 성공한 뒤 — 같은 키 재시도로 처음 결과를 돌려줄 때 포함 — 그 초안을 지운다(지우지 못해도 발송은 성공). ' +
|
||||
`본문이 너무 길면 400 MAIL_007 — body ${MAX_BODY_LENGTH.toLocaleString('ko-KR')}자·bodyHtml ${MAX_BODY_HTML_LENGTH.toLocaleString('ko-KR')}자 초과(JS 문자열 길이, UTF-16 단위), ` +
|
||||
`bodyHtml 이 너무 깊게 중첩됨(정화 전 검사)·정화한 결과가 UTF-8 ${MAX_BODY_VALUE_BYTES.toLocaleString('ko-KR')}바이트 초과. ` +
|
||||
'엔진에 묻기 전이라 메일은 나가지 않는다 (고친 뒤 같은 키로 다시 보내면 된다). ' +
|
||||
'이 요청만 JSON 본문 4MB 까지 받는다 (다른 API 는 1MB, 넘으면 413 FILE_002).',
|
||||
'이 요청과 임시 저장(POST·PUT /api/drafts)만 JSON 본문 4MB 까지 받는다 (다른 API 는 1MB, 넘으면 413 FILE_002).',
|
||||
})
|
||||
@ApiHeader({
|
||||
name: 'Idempotency-Key',
|
||||
@@ -169,7 +171,7 @@ export class MessageController {
|
||||
})
|
||||
@ApiErrorResponse(
|
||||
'MAIL_002',
|
||||
`MAIL_002 받는 사람 주소 · MAIL_003 메일함 용량 · MAIL_004 첨부를 찾을 수 없음(만료된 업로드는 다시 올리고, 지워진 원본 첨부는 뺀다) · MAIL_006 첨부 ${MAX_ATTACHMENTS_PER_MESSAGE}개 초과 · VAL_001 형식(제목 ${MAX_SUBJECT_LENGTH}자 초과 포함)·Idempotency-Key 형식 · FILE_001 첨부 형식 (모두 400)`,
|
||||
`MAIL_002 받는 사람 주소 · MAIL_003 메일함 용량 · MAIL_004 첨부를 찾을 수 없음(만료된 업로드는 다시 올리고, 지워진 원본 첨부는 뺀다) · MAIL_006 첨부 ${MAX_ATTACHMENTS_PER_MESSAGE}개 초과 · VAL_001 형식(제목 ${MAX_SUBJECT_LENGTH}자 초과 포함)·Idempotency-Key 형식·draftId 형식 · FILE_001 첨부 형식 (모두 400)`,
|
||||
)
|
||||
@ApiErrorResponse(
|
||||
'MAIL_007',
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
import type { Correspondence } from './mail.types';
|
||||
import {
|
||||
MAX_RECIPIENT_SUGGESTIONS,
|
||||
filterRecipientSuggestions,
|
||||
rankRecipientSuggestions,
|
||||
} from './recipient-ranking';
|
||||
|
||||
const NOW = new Date('2026-10-27T12:00:00.000Z');
|
||||
const DAY = 24 * 60 * 60 * 1000;
|
||||
const ago = (days: number) =>
|
||||
new Date(NOW.getTime() - days * DAY).toISOString();
|
||||
const ME = 'ara.kim@arasystem.test';
|
||||
const EXCLUDED = new Set([
|
||||
'admin@arasystem.test',
|
||||
'mail-gateway@arasystem.test',
|
||||
'blocked@arasystem.test',
|
||||
]);
|
||||
|
||||
const sent = (
|
||||
email: string,
|
||||
days: number,
|
||||
name: string | null = null,
|
||||
): Correspondence => ({
|
||||
email,
|
||||
name,
|
||||
at: ago(days),
|
||||
});
|
||||
|
||||
const rank = (
|
||||
correspondence: Correspondence[],
|
||||
directory: { email: string; name: string }[] = [],
|
||||
) =>
|
||||
rankRecipientSuggestions({
|
||||
me: ME,
|
||||
excluded: EXCLUDED,
|
||||
directory,
|
||||
correspondence,
|
||||
now: NOW,
|
||||
});
|
||||
|
||||
describe('rankRecipientSuggestions (받는 사람 추천 순서)', () => {
|
||||
it('사내 → 최근 주고받은 주소 순이고, 두 쪽에 다 있는 주소는 사내로 한 번만 나온다', () => {
|
||||
const result = rank(
|
||||
[sent('partner@vendor.example', 0), sent('doyun.lee@arasystem.test', 0)],
|
||||
[
|
||||
{ email: 'Doyun.Lee@arasystem.test', name: '이도윤' },
|
||||
{ email: 'seojun.park@arasystem.test', name: '박서준' },
|
||||
],
|
||||
);
|
||||
|
||||
expect(result).toEqual([
|
||||
{
|
||||
name: '이도윤',
|
||||
email: 'doyun.lee@arasystem.test',
|
||||
source: 'directory',
|
||||
},
|
||||
{
|
||||
name: '박서준',
|
||||
email: 'seojun.park@arasystem.test',
|
||||
source: 'directory',
|
||||
},
|
||||
{ name: null, email: 'partner@vendor.example', source: 'recent' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('묶음 안에서는 횟수와 최근성으로 — 오래된 한 번은 반감기(30일)로 줄어든다', () => {
|
||||
const result = rank([
|
||||
// 60일 전 3번 = 0.75 · 오늘 1번 = 1 · 60일 전 5번 = 1.25
|
||||
...Array.from({ length: 3 }, () => sent('old-three@x.example', 60)),
|
||||
sent('today-once@x.example', 0),
|
||||
...Array.from({ length: 5 }, () => sent('old-five@x.example', 60)),
|
||||
]);
|
||||
|
||||
expect(result.map((item) => item.email)).toEqual([
|
||||
'old-five@x.example',
|
||||
'today-once@x.example',
|
||||
'old-three@x.example',
|
||||
]);
|
||||
});
|
||||
|
||||
it('사내 안에서도 주고받은 사람이 먼저고, 나머지는 이름 순이다', () => {
|
||||
const result = rank(
|
||||
[sent('seojun.park@arasystem.test', 1)],
|
||||
[
|
||||
{ email: 'ga@arasystem.test', name: '가나다' },
|
||||
{ email: 'seojun.park@arasystem.test', name: '박서준' },
|
||||
{ email: 'ha@arasystem.test', name: '하늘' },
|
||||
],
|
||||
);
|
||||
|
||||
expect(result.map((item) => item.name)).toEqual([
|
||||
'박서준',
|
||||
'가나다',
|
||||
'하늘',
|
||||
]);
|
||||
});
|
||||
|
||||
it('나·게이트웨이·관리자·막은 계정·noreply 류·주소가 아닌 값은 뺀다 (대소문자 무시)', () => {
|
||||
const result = rank(
|
||||
[
|
||||
sent('ARA.KIM@arasystem.test', 0),
|
||||
sent('admin@arasystem.test', 0),
|
||||
sent('mail-gateway@arasystem.test', 0),
|
||||
sent('blocked@arasystem.test', 0),
|
||||
...[
|
||||
'noreply@service.example',
|
||||
'no-reply@service.example',
|
||||
'no_reply@service.example',
|
||||
'NoReply+alerts@service.example',
|
||||
'donotreply@service.example',
|
||||
'do-not-reply@service.example',
|
||||
'mailer-daemon@service.example',
|
||||
].map((email) => sent(email, 0)),
|
||||
sent('not-an-address', 0),
|
||||
sent('@nolocal.example', 0),
|
||||
sent('kept@service.example', 0),
|
||||
],
|
||||
[{ email: 'admin@arasystem.test', name: '관리자' }],
|
||||
);
|
||||
|
||||
expect(result).toEqual([
|
||||
{ name: null, email: 'kept@service.example', source: 'recent' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('이름은 사내 목록의 이름, 없으면 가장 최근에 본 표시 이름을 쓴다', () => {
|
||||
const result = rank(
|
||||
[
|
||||
sent('partner@vendor.example', 10, '옛 이름'),
|
||||
sent('partner@vendor.example', 1, '새 이름'),
|
||||
sent('partner@vendor.example', 0, ' '),
|
||||
sent('colleague@arasystem.test', 0, '메일에 적힌 이름'),
|
||||
],
|
||||
[{ email: 'colleague@arasystem.test', name: ' ' }],
|
||||
);
|
||||
|
||||
expect(result).toEqual([
|
||||
{
|
||||
name: '메일에 적힌 이름',
|
||||
email: 'colleague@arasystem.test',
|
||||
source: 'directory',
|
||||
},
|
||||
{ name: '새 이름', email: 'partner@vendor.example', source: 'recent' },
|
||||
]);
|
||||
});
|
||||
|
||||
it(`한 사람에게 ${MAX_RECIPIENT_SUGGESTIONS}개까지만 기억한다`, () => {
|
||||
const result = rank(
|
||||
Array.from({ length: MAX_RECIPIENT_SUGGESTIONS + 50 }, (_, i) =>
|
||||
sent(`person${i}@x.example`, i % 30),
|
||||
),
|
||||
);
|
||||
|
||||
expect(result).toHaveLength(MAX_RECIPIENT_SUGGESTIONS);
|
||||
});
|
||||
});
|
||||
|
||||
describe('filterRecipientSuggestions (q 로 거르기)', () => {
|
||||
const list = [
|
||||
{
|
||||
name: '이도윤',
|
||||
email: 'doyun.lee@arasystem.test',
|
||||
source: 'directory' as const,
|
||||
},
|
||||
{ name: null, email: 'Partner@Vendor.example', source: 'recent' as const },
|
||||
];
|
||||
|
||||
it('이름·주소에 그 글자가 든 것만 준다 (대소문자 무시, 앞뒤 공백 무시)', () => {
|
||||
expect(filterRecipientSuggestions(list, '도윤')).toEqual([list[0]]);
|
||||
expect(filterRecipientSuggestions(list, ' DOYUN ')).toEqual([list[0]]);
|
||||
expect(filterRecipientSuggestions(list, 'vendor')).toEqual([list[1]]);
|
||||
expect(filterRecipientSuggestions(list, '없는사람')).toEqual([]);
|
||||
});
|
||||
|
||||
it('조합형(NFD)으로 친 한글도 찾는다 — 맥에서 붙여 넣은 글자', () => {
|
||||
expect(filterRecipientSuggestions(list, '도윤'.normalize('NFD'))).toEqual([
|
||||
list[0],
|
||||
]);
|
||||
});
|
||||
|
||||
it('q 가 없거나 공백뿐이면 그대로 준다', () => {
|
||||
expect(filterRecipientSuggestions(list, undefined)).toBe(list);
|
||||
expect(filterRecipientSuggestions(list, ' ')).toBe(list);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,161 @@
|
||||
import type {
|
||||
Correspondence,
|
||||
DirectoryContact,
|
||||
RecipientSuggestion,
|
||||
} from './mail.types';
|
||||
|
||||
/**
|
||||
* 받는 사람 추천 순서 (순수 함수) — 계약 2절 "받는 사람 추천".
|
||||
*
|
||||
* - 사내(웹메일에 로그인한 적 있는 직원) → 최근 주고받은 주소 순. 두 쪽에 다 있는 주소는 사내로 한 번만 나온다
|
||||
* - 묶음 안에서는 주고받은 횟수와 최근성으로 — 주고받을 때마다 점수를 더하되 오래된 것일수록 적게 더한다(반감기)
|
||||
* - 나·메일 엔진 관리자·게이트웨이·막은 계정(excluded)·noreply 류는 뺀다
|
||||
*/
|
||||
|
||||
/** 최근성 반감기 — 30일 전에 주고받은 한 번은 오늘의 한 번의 절반으로 센다 */
|
||||
const RECENCY_HALF_LIFE_MS = 30 * 24 * 60 * 60 * 1000;
|
||||
/** 한 사람에게 기억하는 추천 수 상한 */
|
||||
export const MAX_RECIPIENT_SUGGESTIONS = 1000;
|
||||
|
||||
/** 자동 발송 주소 — 답장을 받지 않는 주소는 추천하지 않는다 (noreply@ · no-reply+… · donotreply@ · mailer-daemon@) */
|
||||
const NOREPLY_LOCAL_PART =
|
||||
/^(?:no[-_.]?reply|do[-_.]?not[-_.]?reply|mailer[-_.]?daemon)(?:[-_.+].*)?$/i;
|
||||
|
||||
export interface RankingInput {
|
||||
/** 요청한 직원 주소 */
|
||||
readonly me: string;
|
||||
/** 추천하지 않는 주소 — 게이트웨이·메일 엔진 관리자·막은 계정 (소문자) */
|
||||
readonly excluded: ReadonlySet<string>;
|
||||
readonly directory: readonly DirectoryContact[];
|
||||
readonly correspondence: readonly Correspondence[];
|
||||
readonly now: Date;
|
||||
}
|
||||
|
||||
interface Tally {
|
||||
score: number;
|
||||
latestAt: number;
|
||||
/** 가장 최근에 본 표시 이름 (비어 있지 않은 것) */
|
||||
name: string | null;
|
||||
nameAt: number;
|
||||
}
|
||||
|
||||
function normalizeEmail(email: string): string {
|
||||
return email.trim().toLowerCase();
|
||||
}
|
||||
|
||||
function cleanName(name: string | null | undefined): string | null {
|
||||
const trimmed = name?.trim();
|
||||
return trimmed ? trimmed : null;
|
||||
}
|
||||
|
||||
/** 추천할 수 있는 주소인가 — 형식(로컬@도메인), 나·제외 목록·noreply 류가 아닌지 */
|
||||
export function isSuggestable(
|
||||
email: string,
|
||||
me: string,
|
||||
excluded: ReadonlySet<string>,
|
||||
): boolean {
|
||||
const at = email.lastIndexOf('@');
|
||||
if (at <= 0 || at === email.length - 1) return false;
|
||||
if (email === me || excluded.has(email)) return false;
|
||||
return !NOREPLY_LOCAL_PART.test(email.slice(0, at));
|
||||
}
|
||||
|
||||
function tally(
|
||||
correspondence: readonly Correspondence[],
|
||||
now: number,
|
||||
): Map<string, Tally> {
|
||||
const tallies = new Map<string, Tally>();
|
||||
for (const item of correspondence) {
|
||||
const email = normalizeEmail(item.email);
|
||||
const at = Date.parse(item.at);
|
||||
const when = Number.isNaN(at) ? 0 : at;
|
||||
const age = Math.max(0, now - when);
|
||||
const weight = Math.pow(0.5, age / RECENCY_HALF_LIFE_MS);
|
||||
|
||||
const entry = tallies.get(email) ?? {
|
||||
score: 0,
|
||||
latestAt: 0,
|
||||
name: null,
|
||||
nameAt: -1,
|
||||
};
|
||||
entry.score += weight;
|
||||
entry.latestAt = Math.max(entry.latestAt, when);
|
||||
const name = cleanName(item.name);
|
||||
if (name !== null && when >= entry.nameAt) {
|
||||
entry.name = name;
|
||||
entry.nameAt = when;
|
||||
}
|
||||
tallies.set(email, entry);
|
||||
}
|
||||
return tallies;
|
||||
}
|
||||
|
||||
const byName = new Intl.Collator('ko');
|
||||
|
||||
/** 사내 → 최근 순으로 줄 세운 추천 목록 (MAX_RECIPIENT_SUGGESTIONS 까지) */
|
||||
export function rankRecipientSuggestions(
|
||||
input: RankingInput,
|
||||
): RecipientSuggestion[] {
|
||||
const me = normalizeEmail(input.me);
|
||||
const tallies = tally(input.correspondence, input.now.getTime());
|
||||
const scoreOf = (email: string) => tallies.get(email)?.score ?? 0;
|
||||
|
||||
const directory = new Map<string, string | null>();
|
||||
for (const contact of input.directory) {
|
||||
const email = normalizeEmail(contact.email);
|
||||
if (!isSuggestable(email, me, input.excluded) || directory.has(email)) {
|
||||
continue;
|
||||
}
|
||||
directory.set(
|
||||
email,
|
||||
cleanName(contact.name) ?? tallies.get(email)?.name ?? null,
|
||||
);
|
||||
}
|
||||
|
||||
const colleagues: RecipientSuggestion[] = [...directory]
|
||||
.map(([email, name]) => ({ name, email, source: 'directory' as const }))
|
||||
.sort(
|
||||
(a, b) =>
|
||||
scoreOf(b.email) - scoreOf(a.email) ||
|
||||
byName.compare(a.name ?? a.email, b.name ?? b.email) ||
|
||||
a.email.localeCompare(b.email),
|
||||
);
|
||||
|
||||
const recent: RecipientSuggestion[] = [...tallies]
|
||||
.filter(
|
||||
([email]) =>
|
||||
!directory.has(email) && isSuggestable(email, me, input.excluded),
|
||||
)
|
||||
.sort(
|
||||
([emailA, a], [emailB, b]) =>
|
||||
b.score - a.score ||
|
||||
b.latestAt - a.latestAt ||
|
||||
emailA.localeCompare(emailB),
|
||||
)
|
||||
.map(([email, entry]) => ({
|
||||
name: entry.name,
|
||||
email,
|
||||
source: 'recent' as const,
|
||||
}));
|
||||
|
||||
return [...colleagues, ...recent].slice(0, MAX_RECIPIENT_SUGGESTIONS);
|
||||
}
|
||||
|
||||
/** 대소문자·유니코드 정규형을 맞춘 비교용 글자 */
|
||||
function folded(text: string): string {
|
||||
return text.normalize('NFC').toLowerCase();
|
||||
}
|
||||
|
||||
/** 이름·주소에 q 가 든 것만 (대소문자 무시). q 가 없으면 그대로 */
|
||||
export function filterRecipientSuggestions(
|
||||
suggestions: readonly RecipientSuggestion[],
|
||||
query: string | undefined,
|
||||
): readonly RecipientSuggestion[] {
|
||||
const q = query ? folded(query.trim()) : '';
|
||||
if (q === '') return suggestions;
|
||||
return suggestions.filter(
|
||||
(suggestion) =>
|
||||
folded(suggestion.email).includes(q) ||
|
||||
(suggestion.name !== null && folded(suggestion.name).includes(q)),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import { BusinessException } from '@/common/errors/business.exception';
|
||||
import {
|
||||
decodeSuggestionCursor,
|
||||
encodeSuggestionCursor,
|
||||
} from './recipient-suggestion-cursor';
|
||||
|
||||
function reasonOf(run: () => unknown): unknown {
|
||||
try {
|
||||
run();
|
||||
} catch (error) {
|
||||
if (error instanceof BusinessException && error.code === 'VAL_001') {
|
||||
return error.context?.reason;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const encoded = (payload: unknown) =>
|
||||
Buffer.from(JSON.stringify(payload)).toString('base64url');
|
||||
|
||||
describe('받는 사람 추천 커서', () => {
|
||||
it('같은 검색어로 되돌리면 위치가 그대로다 (검색어 앞뒤 공백은 부르는 쪽이 지운다)', () => {
|
||||
for (const q of [undefined, '도윤', 'partner.test']) {
|
||||
expect(decodeSuggestionCursor(encodeSuggestionCursor(40, q), q)).toBe(40);
|
||||
}
|
||||
// 검색어 없음과 빈 검색어는 같은 범위다
|
||||
expect(
|
||||
decodeSuggestionCursor(encodeSuggestionCursor(3, undefined), ''),
|
||||
).toBe(3);
|
||||
});
|
||||
|
||||
it('다른 검색어의 커서는 VAL_001 — 걸러진 목록이 달라 위치가 맞지 않는다', () => {
|
||||
const cursor = encodeSuggestionCursor(20, '도윤');
|
||||
|
||||
expect(reasonOf(() => decodeSuggestionCursor(cursor, '서준'))).toBe(
|
||||
'invalid-suggestion-cursor:other-query',
|
||||
);
|
||||
expect(reasonOf(() => decodeSuggestionCursor(cursor, undefined))).toBe(
|
||||
'invalid-suggestion-cursor:other-query',
|
||||
);
|
||||
});
|
||||
|
||||
it('형식이 틀리면 VAL_001 — 너무 김 · JSON 아님 · 모르는 판 · 음수·소수 위치', () => {
|
||||
const scope = JSON.parse(
|
||||
Buffer.from(encodeSuggestionCursor(0, undefined), 'base64url').toString(
|
||||
'utf8',
|
||||
),
|
||||
) as { s: string };
|
||||
|
||||
expect(
|
||||
reasonOf(() => decodeSuggestionCursor('x'.repeat(257), undefined)),
|
||||
).toBe('invalid-suggestion-cursor:too-long');
|
||||
expect(reasonOf(() => decodeSuggestionCursor('@@@', undefined))).toBe(
|
||||
'invalid-suggestion-cursor:not-json',
|
||||
);
|
||||
for (const payload of [
|
||||
null,
|
||||
{ v: 2, o: 0, s: scope.s },
|
||||
{ v: 1, o: -1, s: scope.s },
|
||||
{ v: 1, o: 1.5, s: scope.s },
|
||||
{ v: 1, o: '1', s: scope.s },
|
||||
{ v: 1, o: 1 },
|
||||
]) {
|
||||
expect(
|
||||
reasonOf(() => decodeSuggestionCursor(encoded(payload), undefined)),
|
||||
).toBe('invalid-suggestion-cursor:malformed');
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,69 @@
|
||||
import { createHash } from 'crypto';
|
||||
|
||||
import { BusinessException } from '@/common/errors/business.exception';
|
||||
|
||||
/**
|
||||
* 받는 사람 추천 목록의 커서 (클라이언트에게는 불투명 문자열) — 걸러진 목록에서 다음 항목의 위치.
|
||||
* 다른 검색어(q)의 커서를 섞어 쓰지 못하도록 검색어의 해시를 함께 넣는다 (jmap-cursor.ts 와 같은 방식).
|
||||
* 목록은 서버가 10분 캐시하므로 그 안에서는 위치가 그대로다 — 다시 모으면 조금 어긋날 수 있다(추천이라 문제 삼지 않는다).
|
||||
*/
|
||||
const CURSOR_VERSION = 1;
|
||||
const MAX_CURSOR_LENGTH = 256;
|
||||
const SCOPE_HASH_LENGTH = 16;
|
||||
|
||||
interface EncodedCursor {
|
||||
v: number;
|
||||
o: number;
|
||||
s: string;
|
||||
}
|
||||
|
||||
function scopeHash(query: string | undefined): string {
|
||||
return createHash('sha256')
|
||||
.update(query ?? '')
|
||||
.digest('base64url')
|
||||
.slice(0, SCOPE_HASH_LENGTH);
|
||||
}
|
||||
|
||||
function invalidCursor(reason: string): BusinessException {
|
||||
return new BusinessException('VAL_001', {
|
||||
context: { reason: `invalid-suggestion-cursor:${reason}` },
|
||||
});
|
||||
}
|
||||
|
||||
export function encodeSuggestionCursor(
|
||||
offset: number,
|
||||
query: string | undefined,
|
||||
): string {
|
||||
const payload: EncodedCursor = {
|
||||
v: CURSOR_VERSION,
|
||||
o: offset,
|
||||
s: scopeHash(query),
|
||||
};
|
||||
return Buffer.from(JSON.stringify(payload)).toString('base64url');
|
||||
}
|
||||
|
||||
/** 커서를 해석한다 — 형식이 틀리거나 다른 검색어의 커서면 VAL_001 */
|
||||
export function decodeSuggestionCursor(
|
||||
cursor: string,
|
||||
query: string | undefined,
|
||||
): number {
|
||||
if (cursor.length > MAX_CURSOR_LENGTH) throw invalidCursor('too-long');
|
||||
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(Buffer.from(cursor, 'base64url').toString('utf8'));
|
||||
} catch {
|
||||
throw invalidCursor('not-json');
|
||||
}
|
||||
|
||||
const { v, o, s } = (parsed ?? {}) as Partial<EncodedCursor>;
|
||||
const valid =
|
||||
v === CURSOR_VERSION &&
|
||||
typeof o === 'number' &&
|
||||
Number.isSafeInteger(o) &&
|
||||
o >= 0 &&
|
||||
typeof s === 'string';
|
||||
if (!valid) throw invalidCursor('malformed');
|
||||
if (s !== scopeHash(query)) throw invalidCursor('other-query');
|
||||
return o;
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { Controller, Get, Header, Query, 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 { RecipientSuggestionPageDto } from './dto/mail-response.dto';
|
||||
import { RecipientSuggestionQueryDto } from './dto/recipient-suggestion-query.dto';
|
||||
import { RecipientSuggestionService } from './recipient-suggestions.service';
|
||||
|
||||
// 받는 사람 추천 (계약 2절 "받는 사람 추천") — 요청한 직원 자신의 메일함과 사내 목록에서만 모은다.
|
||||
@ApiTags('Mail')
|
||||
@ApiCookieAuth()
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ApiErrorResponse('AUTH_001', '로그인 필요 (쿠키 없음·만료)')
|
||||
@Controller('recipient-suggestions')
|
||||
export class RecipientSuggestionController {
|
||||
constructor(private readonly suggestions: RecipientSuggestionService) {}
|
||||
|
||||
@Get()
|
||||
// 주고받은 사람 목록 — 공유 캐시·브라우저 캐시 어디에도 남기지 않는다
|
||||
@Header('Cache-Control', 'private, no-store')
|
||||
@ApiOperation({
|
||||
summary: '받는 사람 추천 (커서 페이지네이션)',
|
||||
description:
|
||||
'사내(웹메일에 로그인한 적 있는 직원) → 최근 주고받은 주소(보낸편지함 최근 500통의 받는 사람·참조·숨은참조, 받은편지함 최근 500통의 보낸 사람) 순, ' +
|
||||
'묶음 안에서는 주고받은 횟수와 최근성으로. 두 쪽에 다 있는 주소는 사내로 한 번만 나온다. 나·메일 엔진 관리자·게이트웨이·막은 계정·noreply 류는 뺀다. ' +
|
||||
'q 가 있으면 이름·주소에 그 글자가 든 것만 (대소문자 무시). 서버는 사용자마다 10분 기억하고, 메일을 보내면 다시 모은다. Cache-Control: private, no-store.',
|
||||
})
|
||||
@ApiStandardResponse(RecipientSuggestionPageDto, {
|
||||
description: '추천 목록 (total 은 걸러진 개수)',
|
||||
})
|
||||
@ApiErrorResponse('VAL_001', 'q 100자 초과 · 잘못된 커서 · limit 범위 밖')
|
||||
@ApiErrorResponse('MAIL_001', '메일 엔진 장애')
|
||||
list(
|
||||
@CurrentUser() user: AuthenticatedUser,
|
||||
@Query() query: RecipientSuggestionQueryDto,
|
||||
) {
|
||||
return this.suggestions.suggest(user, query);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,310 @@
|
||||
import { Logger } from '@nestjs/common';
|
||||
|
||||
import { BusinessException } from '@/common/errors/business.exception';
|
||||
import type { MailDirectoryService } from '@/modules/mail-directory/mail-directory.service';
|
||||
import type { RecipientSuggestionQueryDto } from './dto/recipient-suggestion-query.dto';
|
||||
import type { MailProvider } from './mail.provider';
|
||||
import type { MailSettings } from './mail.settings';
|
||||
import type { Correspondence, DirectoryContact } from './mail.types';
|
||||
import {
|
||||
RecipientSuggestionService,
|
||||
SUGGESTION_CACHE_MAX_USERS,
|
||||
SUGGESTION_CACHE_TTL_MS,
|
||||
} from './recipient-suggestions.service';
|
||||
|
||||
const ARA = { email: 'ara.kim@arasystem.test', name: '김아라' };
|
||||
|
||||
const settings: MailSettings = {
|
||||
jmapBaseUrl: 'http://mail:8080',
|
||||
domain: 'arasystem.test',
|
||||
gatewayUser: 'mail-gateway@arasystem.test',
|
||||
gatewayPassword: 'secret',
|
||||
attachmentMaxBytes: 20 * 1024 * 1024,
|
||||
attachmentTotalMaxBytes: 25 * 1024 * 1024,
|
||||
blockedAccounts: new Set(['admin@arasystem.test']),
|
||||
};
|
||||
|
||||
const NOW = Date.parse('2026-10-27T03:00:00Z');
|
||||
const daysAgo = (days: number) =>
|
||||
new Date(NOW - days * 24 * 60 * 60 * 1000).toISOString();
|
||||
|
||||
const query = (
|
||||
overrides: Partial<RecipientSuggestionQueryDto> = {},
|
||||
): RecipientSuggestionQueryDto => ({ limit: 20, ...overrides });
|
||||
|
||||
describe('RecipientSuggestionService', () => {
|
||||
let listCorrespondents: jest.Mock<
|
||||
Promise<Correspondence[]>,
|
||||
Parameters<MailProvider['listCorrespondents']>
|
||||
>;
|
||||
let listContacts: jest.Mock<Promise<DirectoryContact[]>, [number?]>;
|
||||
let service: RecipientSuggestionService;
|
||||
let debugSpy: jest.SpyInstance;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.spyOn(Date, 'now').mockReturnValue(NOW);
|
||||
listCorrespondents = jest
|
||||
.fn<
|
||||
Promise<Correspondence[]>,
|
||||
Parameters<MailProvider['listCorrespondents']>
|
||||
>()
|
||||
.mockResolvedValue([
|
||||
{ name: '고객사', email: 'client@partner.test', at: daysAgo(1) },
|
||||
{ name: null, email: 'Client@Partner.test', at: daysAgo(2) },
|
||||
{ name: '이도윤', email: 'doyun.lee@arasystem.test', at: daysAgo(1) },
|
||||
{ name: null, email: 'vendor@supply.test', at: daysAgo(90) },
|
||||
// 나·게이트웨이·관리자·자동 발송 주소는 추천하지 않는다
|
||||
{ name: '나', email: 'ARA.KIM@arasystem.test', at: daysAgo(0) },
|
||||
{ name: null, email: 'mail-gateway@arasystem.test', at: daysAgo(0) },
|
||||
{ name: null, email: 'admin@arasystem.test', at: daysAgo(0) },
|
||||
{ name: null, email: 'no-reply@service.test', at: daysAgo(0) },
|
||||
]);
|
||||
listContacts = jest
|
||||
.fn<Promise<DirectoryContact[]>, [number?]>()
|
||||
.mockResolvedValue([
|
||||
{ email: 'seojun.park@arasystem.test', name: '박서준' },
|
||||
{ email: 'doyun.lee@arasystem.test', name: '이도윤' },
|
||||
{ email: 'ara.kim@arasystem.test', name: '김아라' },
|
||||
]);
|
||||
service = new RecipientSuggestionService(
|
||||
{ listCorrespondents } as unknown as MailProvider,
|
||||
settings,
|
||||
{ listContacts } as unknown as MailDirectoryService,
|
||||
);
|
||||
debugSpy = jest
|
||||
.spyOn(Logger.prototype, 'debug')
|
||||
.mockImplementation(() => undefined);
|
||||
});
|
||||
|
||||
afterEach(() => jest.restoreAllMocks());
|
||||
|
||||
it('사내 → 최근 순으로, 그 직원 자신의 메일함에서 모은다 — 나·게이트웨이·관리자·noreply 는 빼고, 두 쪽에 다 있으면 사내로 한 번', async () => {
|
||||
const page = await service.suggest(ARA, query());
|
||||
|
||||
expect(page.items).toEqual([
|
||||
// 사내 묶음 안에서는 주고받은 사람이 먼저
|
||||
{
|
||||
name: '이도윤',
|
||||
email: 'doyun.lee@arasystem.test',
|
||||
source: 'directory',
|
||||
},
|
||||
{
|
||||
name: '박서준',
|
||||
email: 'seojun.park@arasystem.test',
|
||||
source: 'directory',
|
||||
},
|
||||
// 대소문자만 다른 주소는 한 사람 — 두 번 주고받았다
|
||||
{ name: '고객사', email: 'client@partner.test', source: 'recent' },
|
||||
{ name: null, email: 'vendor@supply.test', source: 'recent' },
|
||||
]);
|
||||
expect(page.pageInfo).toEqual({
|
||||
hasNext: false,
|
||||
nextCursor: null,
|
||||
total: 4,
|
||||
});
|
||||
expect(listCorrespondents).toHaveBeenCalledWith('ara.kim@arasystem.test', {
|
||||
sent: 500,
|
||||
received: 500,
|
||||
});
|
||||
});
|
||||
|
||||
it('커서 페이지네이션 — 걸러진 목록의 다음 위치, total 은 걸러진 개수', async () => {
|
||||
const first = await service.suggest(ARA, query({ limit: 3 }));
|
||||
|
||||
expect(first.items).toHaveLength(3);
|
||||
expect(first.pageInfo).toMatchObject({ hasNext: true, total: 4 });
|
||||
const cursor = first.pageInfo.nextCursor;
|
||||
if (!cursor) throw new Error('다음 커서가 없다');
|
||||
|
||||
const second = await service.suggest(ARA, query({ limit: 3, cursor }));
|
||||
expect(second.items.map((item) => item.email)).toEqual([
|
||||
'vendor@supply.test',
|
||||
]);
|
||||
expect(second.pageInfo).toEqual({
|
||||
hasNext: false,
|
||||
nextCursor: null,
|
||||
total: 4,
|
||||
});
|
||||
});
|
||||
|
||||
it('q 는 이름·주소에 든 것만 (대소문자 무시) — 다른 q 의 커서는 VAL_001', async () => {
|
||||
const page = await service.suggest(
|
||||
ARA,
|
||||
query({ q: ' PARTNER ', limit: 1 }),
|
||||
);
|
||||
expect(page.items.map((item) => item.email)).toEqual([
|
||||
'client@partner.test',
|
||||
]);
|
||||
expect(page.pageInfo).toEqual({
|
||||
hasNext: false,
|
||||
nextCursor: null,
|
||||
total: 1,
|
||||
});
|
||||
|
||||
const byName = await service.suggest(ARA, query({ q: '서준', limit: 1 }));
|
||||
expect(byName.items.map((item) => item.email)).toEqual([
|
||||
'seojun.park@arasystem.test',
|
||||
]);
|
||||
|
||||
const all = await service.suggest(ARA, query({ limit: 1 }));
|
||||
const cursor = all.pageInfo.nextCursor ?? '';
|
||||
await expect(
|
||||
service.suggest(ARA, query({ q: '이', cursor })),
|
||||
).rejects.toMatchObject({
|
||||
code: 'VAL_001',
|
||||
context: { reason: 'invalid-suggestion-cursor:other-query' },
|
||||
});
|
||||
});
|
||||
|
||||
it('틀린 커서는 엔진에 묻기 전에 VAL_001', async () => {
|
||||
for (const cursor of ['not-a-cursor', 'x'.repeat(300)]) {
|
||||
const error = await service
|
||||
.suggest(ARA, query({ cursor }))
|
||||
.catch((caught: unknown) => caught);
|
||||
expect(error).toBeInstanceOf(BusinessException);
|
||||
expect((error as BusinessException).code).toBe('VAL_001');
|
||||
}
|
||||
expect(listCorrespondents).not.toHaveBeenCalled();
|
||||
expect(listContacts).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('사용자마다 10분 기억한다 — 그 안에서는 다시 모으지 않고, 지나면 다시 모은다', async () => {
|
||||
await service.suggest(ARA, query());
|
||||
await service.suggest({ ...ARA, email: 'Ara.Kim@arasystem.test' }, query());
|
||||
expect(listCorrespondents).toHaveBeenCalledTimes(1);
|
||||
|
||||
jest.spyOn(Date, 'now').mockReturnValue(NOW + SUGGESTION_CACHE_TTL_MS + 1);
|
||||
await service.suggest(ARA, query());
|
||||
expect(listCorrespondents).toHaveBeenCalledTimes(2);
|
||||
|
||||
// 다른 사람은 따로 모은다 (주고받은 주소가 섞이지 않게)
|
||||
await service.suggest(
|
||||
{ email: 'doyun.lee@arasystem.test', name: '이도윤' },
|
||||
query(),
|
||||
);
|
||||
expect(listCorrespondents).toHaveBeenLastCalledWith(
|
||||
'doyun.lee@arasystem.test',
|
||||
{ sent: 500, received: 500 },
|
||||
);
|
||||
});
|
||||
|
||||
it('메일을 보내면(invalidate) 잊는다 — 다음 요청에 다시 모은다', async () => {
|
||||
await service.suggest(ARA, query());
|
||||
service.invalidate('ARA.KIM@arasystem.test');
|
||||
await service.suggest(ARA, query());
|
||||
|
||||
expect(listCorrespondents).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('같은 사람의 동시 요청은 한 번만 모은다', async () => {
|
||||
let finish!: (value: Correspondence[]) => void;
|
||||
listCorrespondents.mockImplementationOnce(
|
||||
() => new Promise((resolve) => (finish = resolve)),
|
||||
);
|
||||
|
||||
const first = service.suggest(ARA, query());
|
||||
const second = service.suggest(ARA, query({ q: '도윤' }));
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
finish([]);
|
||||
|
||||
await expect(first).resolves.toMatchObject({ pageInfo: { total: 2 } });
|
||||
await expect(second).resolves.toMatchObject({ pageInfo: { total: 1 } });
|
||||
expect(listCorrespondents).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('모으는 중에 보냈으면 그 결과는 기억하지 않는다 — 보낸 뒤의 요청은 새로 모은다', async () => {
|
||||
let finish!: (value: Correspondence[]) => void;
|
||||
listCorrespondents.mockImplementationOnce(
|
||||
() => new Promise((resolve) => (finish = resolve)),
|
||||
);
|
||||
|
||||
const before = service.suggest(ARA, query());
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
service.invalidate(ARA.email);
|
||||
const after = service.suggest(ARA, query());
|
||||
finish([]);
|
||||
|
||||
await before;
|
||||
await after;
|
||||
expect(listCorrespondents).toHaveBeenCalledTimes(2);
|
||||
|
||||
// 보낸 뒤에 모은 결과는 기억한다
|
||||
await service.suggest(ARA, query());
|
||||
expect(listCorrespondents).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('엔진 장애는 그대로 던지고 기억하지 않는다 — 다음 요청은 다시 묻는다', async () => {
|
||||
listCorrespondents.mockRejectedValueOnce(new BusinessException('MAIL_001'));
|
||||
|
||||
await expect(service.suggest(ARA, query())).rejects.toMatchObject({
|
||||
code: 'MAIL_001',
|
||||
});
|
||||
await expect(service.suggest(ARA, query())).resolves.toMatchObject({
|
||||
pageInfo: { total: 4 },
|
||||
});
|
||||
expect(listCorrespondents).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it(`기억하는 사람은 ${SUGGESTION_CACHE_MAX_USERS}명까지 — 가장 오래 쓰지 않은 사람부터 잊는다`, async () => {
|
||||
const userAt = (index: number) => ({
|
||||
email: `user${index}@arasystem.test`,
|
||||
name: `직원${index}`,
|
||||
});
|
||||
for (let index = 0; index < SUGGESTION_CACHE_MAX_USERS; index += 1) {
|
||||
await service.suggest(userAt(index), query());
|
||||
}
|
||||
// 0번을 다시 쓴다 — 이제 가장 오래 쓰지 않은 사람은 1번이다
|
||||
await service.suggest(userAt(0), query());
|
||||
await service.suggest(userAt(SUGGESTION_CACHE_MAX_USERS), query());
|
||||
listCorrespondents.mockClear();
|
||||
|
||||
await service.suggest(userAt(0), query());
|
||||
expect(listCorrespondents).not.toHaveBeenCalled();
|
||||
await service.suggest(userAt(1), query());
|
||||
expect(listCorrespondents).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('기억할 때 만료된 목록을 함께 지운다 — 다시 오지 않은 사람의 목록이 남지 않는다', async () => {
|
||||
const cache = Reflect.get(service, 'cache') as Map<string, unknown>;
|
||||
await service.suggest(ARA, query());
|
||||
expect(cache.has(ARA.email)).toBe(true);
|
||||
|
||||
jest.spyOn(Date, 'now').mockReturnValue(NOW + SUGGESTION_CACHE_TTL_MS + 1);
|
||||
await service.suggest(
|
||||
{ email: 'doyun.lee@arasystem.test', name: '이도윤' },
|
||||
query(),
|
||||
);
|
||||
|
||||
expect([...cache.keys()]).toEqual(['doyun.lee@arasystem.test']);
|
||||
});
|
||||
|
||||
it('줄지 않는 표를 두지 않는다 — 모으기가 끝나면 빠지고, 잊기만 한 사람은 아무 데도 남지 않는다', async () => {
|
||||
const collecting = Reflect.get(service, 'collecting') as Map<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
const cache = Reflect.get(service, 'cache') as Map<string, unknown>;
|
||||
|
||||
await service.suggest(ARA, query());
|
||||
expect(collecting.size).toBe(0);
|
||||
|
||||
for (let index = 0; index < 1_000; index += 1) {
|
||||
service.invalidate(`user${index}@arasystem.test`);
|
||||
}
|
||||
service.invalidate(ARA.email);
|
||||
expect(collecting.size).toBe(0);
|
||||
expect(cache.size).toBe(0);
|
||||
// 잊은 횟수를 사람마다 세던 표(generations)는 없앴다
|
||||
expect(Reflect.get(service, 'generations')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('로그에는 개수만 남긴다 — 주소·이름·검색어 없음', async () => {
|
||||
await service.suggest(ARA, query({ q: '고객' }));
|
||||
|
||||
const logged = debugSpy.mock.calls
|
||||
.map(([message]) => String(message))
|
||||
.join('\n');
|
||||
expect(logged).toContain('사내 2명, 최근 2명');
|
||||
expect(logged).not.toMatch(/partner|doyun|seojun|고객|이도윤/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,181 @@
|
||||
import { Inject, Injectable, Logger } from '@nestjs/common';
|
||||
|
||||
import type { AuthenticatedUser } from '@/common/decorators/current-user.decorator';
|
||||
import {
|
||||
type PaginatedResult,
|
||||
toPaginatedResult,
|
||||
} from '@/common/dto/pagination.dto';
|
||||
import { MailDirectoryService } from '@/modules/mail-directory/mail-directory.service';
|
||||
import { traceTag } from '@/shared/logger/request-context';
|
||||
import type { RecipientSuggestionQueryDto } from './dto/recipient-suggestion-query.dto';
|
||||
import {
|
||||
SUGGESTION_RECEIVED_SCAN,
|
||||
SUGGESTION_SENT_SCAN,
|
||||
} from './mail.constants';
|
||||
import { MAIL_PROVIDER, type MailProvider } from './mail.provider';
|
||||
import { MAIL_SETTINGS, type MailSettings } from './mail.settings';
|
||||
import type { RecipientSuggestion } from './mail.types';
|
||||
import {
|
||||
filterRecipientSuggestions,
|
||||
rankRecipientSuggestions,
|
||||
} from './recipient-ranking';
|
||||
import {
|
||||
decodeSuggestionCursor,
|
||||
encodeSuggestionCursor,
|
||||
} from './recipient-suggestion-cursor';
|
||||
|
||||
/** 사용자별 추천 목록을 기억하는 시간 */
|
||||
export const SUGGESTION_CACHE_TTL_MS = 10 * 60_000;
|
||||
/**
|
||||
* 기억하는 사용자 수 상한 — 넘으면 가장 오래 쓰지 않은 사람부터 잊는다.
|
||||
* 한 사람의 목록은 1,000개까지라(recipient-ranking.ts) 사람당 수십~백수십 KB — 상한이 메모리 상한이다.
|
||||
*/
|
||||
export const SUGGESTION_CACHE_MAX_USERS = 500;
|
||||
|
||||
interface CacheEntry {
|
||||
readonly expiresAt: number;
|
||||
readonly items: readonly RecipientSuggestion[];
|
||||
}
|
||||
|
||||
/**
|
||||
* 모으는 중인 목록. `token` 은 이 모으기의 표 — 그 사람을 잊으면(invalidate) 표에서 빠지므로,
|
||||
* 끝났을 때 표가 그대로인 모으기만 결과를 기억한다 (잊기 전에 시작한 모으기의 옛 결과를 남기지 않는다).
|
||||
*/
|
||||
interface Collecting {
|
||||
readonly token: object;
|
||||
readonly promise: Promise<readonly RecipientSuggestion[]>;
|
||||
}
|
||||
|
||||
function normalizeUser(email: string): string {
|
||||
return email.trim().toLowerCase();
|
||||
}
|
||||
|
||||
/**
|
||||
* 받는 사람 추천 (계약 2절 "받는 사람 추천") — 사내 목록 + 그 사람이 최근 주고받은 주소.
|
||||
*
|
||||
* - 주고받은 주소는 **요청한 직원 자신의** 메일함에서만 모은다 (대리 접근이 그 사람으로만 들어간다). 다른 직원의 것과 섞지 않는다
|
||||
* - 사용자마다 10분 기억하고, 그 사람이 메일을 보내면 잊는다 — 다음 요청에 다시 모은다
|
||||
* - 로그에는 개수만 남긴다 — 주소·검색어는 남기지 않는다
|
||||
*
|
||||
* ⚠️ 프로세스 메모리다. 인스턴스를 늘리면 인스턴스마다 따로 기억한다 (틀리지는 않고, 보낸 직후 다른 인스턴스가 10분까지 옛 목록을 줄 수 있다).
|
||||
*/
|
||||
@Injectable()
|
||||
export class RecipientSuggestionService {
|
||||
private readonly logger = new Logger(RecipientSuggestionService.name);
|
||||
private readonly cache = new Map<string, CacheEntry>();
|
||||
/** 모으는 중인 목록 — 같은 사람의 동시 요청은 한 번만 모은다. 끝나면 빠진다 (줄지 않는 표를 두지 않는다) */
|
||||
private readonly collecting = new Map<string, Collecting>();
|
||||
private readonly excluded: ReadonlySet<string>;
|
||||
|
||||
constructor(
|
||||
@Inject(MAIL_PROVIDER) private readonly provider: MailProvider,
|
||||
@Inject(MAIL_SETTINGS) settings: MailSettings,
|
||||
private readonly directory: MailDirectoryService,
|
||||
) {
|
||||
this.excluded = new Set([
|
||||
...settings.blockedAccounts,
|
||||
...(settings.gatewayUser ? [settings.gatewayUser] : []),
|
||||
]);
|
||||
}
|
||||
|
||||
async suggest(
|
||||
user: AuthenticatedUser,
|
||||
query: RecipientSuggestionQueryDto,
|
||||
): Promise<PaginatedResult<RecipientSuggestion>> {
|
||||
const q = query.q?.trim() ? query.q.trim() : undefined;
|
||||
// 커서는 엔진에 묻기 전에 확인한다 — 틀린 커서로 목록을 모으지 않게
|
||||
const start = query.cursor ? decodeSuggestionCursor(query.cursor, q) : 0;
|
||||
|
||||
const matched = filterRecipientSuggestions(
|
||||
await this.suggestionsOf(user.email),
|
||||
q,
|
||||
);
|
||||
const items = matched.slice(start, start + query.limit);
|
||||
const next = start + items.length;
|
||||
const hasNext = next < matched.length;
|
||||
return toPaginatedResult([...items], {
|
||||
hasNext,
|
||||
nextCursor: hasNext ? encodeSuggestionCursor(next, q) : null,
|
||||
total: matched.length,
|
||||
});
|
||||
}
|
||||
|
||||
/** 그 사람이 메일을 보냈다 — 기억한 목록을 잊는다 (다음 요청에 다시 모은다) */
|
||||
invalidate(userEmail: string): void {
|
||||
const key = normalizeUser(userEmail);
|
||||
this.cache.delete(key);
|
||||
// 모으는 중이던 것도 잊는다 — 끝나도 기억하지 않고, 이 뒤의 요청은 보낸 메일까지 넣어 새로 모은다
|
||||
this.collecting.delete(key);
|
||||
}
|
||||
|
||||
private suggestionsOf(
|
||||
userEmail: string,
|
||||
): Promise<readonly RecipientSuggestion[]> {
|
||||
const key = normalizeUser(userEmail);
|
||||
const cached = this.cache.get(key);
|
||||
if (cached && cached.expiresAt > Date.now()) {
|
||||
// 최근에 쓴 사람이 맨 뒤로 — 상한을 넘으면 맨 앞(가장 오래 쓰지 않은 사람)부터 잊는다
|
||||
this.cache.delete(key);
|
||||
this.cache.set(key, cached);
|
||||
return Promise.resolve(cached.items);
|
||||
}
|
||||
|
||||
const pending = this.collecting.get(key);
|
||||
if (pending) return pending.promise;
|
||||
|
||||
const token = {};
|
||||
const promise = this.collect(key, token).finally(() => {
|
||||
// 그사이 잊고 새로 모으기 시작했으면 그것은 그대로 둔다
|
||||
if (this.collecting.get(key)?.token === token)
|
||||
this.collecting.delete(key);
|
||||
});
|
||||
this.collecting.set(key, { token, promise });
|
||||
return promise;
|
||||
}
|
||||
|
||||
private async collect(
|
||||
user: string,
|
||||
token: object,
|
||||
): Promise<readonly RecipientSuggestion[]> {
|
||||
const [correspondence, directory] = await Promise.all([
|
||||
this.provider.listCorrespondents(user, {
|
||||
sent: SUGGESTION_SENT_SCAN,
|
||||
received: SUGGESTION_RECEIVED_SCAN,
|
||||
}),
|
||||
this.directory.listContacts(),
|
||||
]);
|
||||
const items = rankRecipientSuggestions({
|
||||
me: user,
|
||||
excluded: this.excluded,
|
||||
directory,
|
||||
correspondence,
|
||||
now: new Date(),
|
||||
});
|
||||
|
||||
// 모으는 사이 잊었으면(그 사람이 메일을 보냈다) 기억하지 않는다
|
||||
if (this.collecting.get(user)?.token === token) this.remember(user, items);
|
||||
// 반복되는 정상 흐름이라 debug — 개수만 (주소·검색어는 남기지 않는다)
|
||||
const colleagues = items.filter(
|
||||
(item) => item.source === 'directory',
|
||||
).length;
|
||||
this.logger.debug(
|
||||
`${traceTag()} 받는 사람 추천을 모았습니다: 사내 ${colleagues}명, 최근 ${items.length - colleagues}명`,
|
||||
);
|
||||
return items;
|
||||
}
|
||||
|
||||
private remember(user: string, items: readonly RecipientSuggestion[]): void {
|
||||
const now = Date.now();
|
||||
// 만료된 목록은 그때그때 지운다 — 다시 오지 않은 사람의 목록이 상한까지 쌓여 메모리를 잡지 않게
|
||||
for (const [key, entry] of this.cache) {
|
||||
if (entry.expiresAt <= now) this.cache.delete(key);
|
||||
}
|
||||
this.cache.delete(user);
|
||||
this.cache.set(user, { expiresAt: now + SUGGESTION_CACHE_TTL_MS, items });
|
||||
while (this.cache.size > SUGGESTION_CACHE_MAX_USERS) {
|
||||
const oldest = this.cache.keys().next().value;
|
||||
if (oldest === undefined) break;
|
||||
this.cache.delete(oldest);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,42 @@
|
||||
import { maskEmail } from './mask';
|
||||
import { maskEmail, maskUrlForLog } from './mask';
|
||||
|
||||
describe('maskUrlForLog (로그에 남길 요청 주소)', () => {
|
||||
it('검색어(q)는 값 대신 디코딩한 글자 수만 남기고, 경로와 나머지 쿼리는 그대로 둔다', () => {
|
||||
expect(
|
||||
maskUrlForLog(
|
||||
'/api/recipient-suggestions?q=%EB%8F%84%EC%9C%A4&limit=20&cursor=eyJ2Ijox',
|
||||
),
|
||||
).toBe('/api/recipient-suggestions?q=<2자>&limit=20&cursor=eyJ2Ijox');
|
||||
// + 는 공백 · 주소 형태의 검색어
|
||||
expect(
|
||||
maskUrlForLog(
|
||||
'/api/messages?mailboxId=a&q=ara.kim%40arasystem.it.kr+%EC%9D%BC%EC%A0%95',
|
||||
),
|
||||
).toBe('/api/messages?mailboxId=a&q=<26자>');
|
||||
});
|
||||
|
||||
it('q 가 여럿이어도, 이름이 인코딩·대문자·배열 표기여도 모두 가린다', () => {
|
||||
expect(
|
||||
maskUrlForLog('/api/x?q=a&limit=1&q=%EA%B9%80%EC%95%84%EB%9D%BC'),
|
||||
).toBe('/api/x?q=<1자>&limit=1&q=<3자>');
|
||||
expect(
|
||||
maskUrlForLog('/api/x?%71=secret&Q=secret&q[]=secret&q%5B0%5D=secret'),
|
||||
).toBe('/api/x?%71=<6자>&Q=<6자>&q[]=<6자>&q%5B0%5D=<6자>');
|
||||
});
|
||||
|
||||
it('q 가 없거나 쿼리가 없으면 그대로 · 값이 비었거나 = 가 없으면 0자 · 잘못된 인코딩은 원문 길이', () => {
|
||||
expect(maskUrlForLog('/api/users/1')).toBe('/api/users/1');
|
||||
expect(maskUrlForLog('/api/messages?mailboxId=a&limit=20')).toBe(
|
||||
'/api/messages?mailboxId=a&limit=20',
|
||||
);
|
||||
// q 로 시작하는 다른 이름은 가리지 않는다
|
||||
expect(maskUrlForLog('/api/x?query=1&qq=2')).toBe('/api/x?query=1&qq=2');
|
||||
expect(maskUrlForLog('/api/x?q=&q')).toBe('/api/x?q=<0자>&q=<0자>');
|
||||
expect(maskUrlForLog('/api/x?q=%E0%A4%A')).toBe('/api/x?q=<8자>');
|
||||
// 값 안의 = 도 값이다
|
||||
expect(maskUrlForLog('/api/x?q=a=b')).toBe('/api/x?q=<3자>');
|
||||
});
|
||||
});
|
||||
|
||||
describe('maskEmail', () => {
|
||||
it('첫 글자와 도메인만 남기고 나머지를 가린다', () => {
|
||||
|
||||
@@ -8,6 +8,21 @@
|
||||
/** 가린 부분을 표시하는 문자열 — 원래 길이를 드러내지 않도록 고정 길이로 쓴다 */
|
||||
const MASK = '***';
|
||||
|
||||
/**
|
||||
* 값을 남기지 않는 쿼리 이름 — 검색어. 받는 사람 추천의 q 에는 사람 이름·주소가 들어간다 (계약 2절 "로그에는 개수만").
|
||||
* 대소문자와 배열 표기(`q[]`·`q[0]`)도 같은 이름으로 본다 — 형식 오류(VAL_001)로 거절된 요청도 로그에는 남는다.
|
||||
*/
|
||||
const REDACTED_QUERY_NAME = /^q(?:\[[^\]]*\])*$/i;
|
||||
|
||||
/** 쿼리 조각을 디코딩한다 (`+` 는 공백) — 잘못된 퍼센트 인코딩이면 원문 그대로 */
|
||||
function decodeQueryPart(part: string): string {
|
||||
try {
|
||||
return decodeURIComponent(part.replace(/\+/g, ' '));
|
||||
} catch {
|
||||
return part;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 이메일 주소를 로그용으로 가린다. 첫 글자와 도메인만 남긴다.
|
||||
*
|
||||
@@ -22,3 +37,28 @@ export function maskEmail(email: string): string {
|
||||
const [first] = Array.from(email.slice(0, at));
|
||||
return `${first}${MASK}@${email.slice(at + 1)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 로그에 남길 요청 주소 — 검색어(q)는 값 대신 길이만 남기고, 경로와 나머지 쿼리는 그대로 둔다.
|
||||
* 길이는 디코딩한 값의 JS 문자열 길이다 (클라이언트가 세는 방법과 같다).
|
||||
*
|
||||
* 예) '/api/recipient-suggestions?q=%EB%8F%84%EC%9C%A4&limit=20' → '/api/recipient-suggestions?q=<2자>&limit=20'
|
||||
*/
|
||||
export function maskUrlForLog(url: string): string {
|
||||
const queryStart = url.indexOf('?');
|
||||
if (queryStart < 0) return url;
|
||||
|
||||
const query = url
|
||||
.slice(queryStart + 1)
|
||||
.split('&')
|
||||
.map((pair) => {
|
||||
const separator = pair.indexOf('=');
|
||||
const name = separator < 0 ? pair : pair.slice(0, separator);
|
||||
if (!REDACTED_QUERY_NAME.test(decodeQueryPart(name))) return pair;
|
||||
const value =
|
||||
separator < 0 ? '' : decodeQueryPart(pair.slice(separator + 1));
|
||||
return `${name}=<${value.length}자>`;
|
||||
})
|
||||
.join('&');
|
||||
return `${url.slice(0, queryStart)}?${query}`;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import { settledWithin } from './settle';
|
||||
|
||||
describe('settledWithin (정해진 시간까지만 기다리기)', () => {
|
||||
beforeEach(() => jest.useFakeTimers());
|
||||
afterEach(() => jest.useRealTimers());
|
||||
|
||||
it('시간 안에 끝나면 true — 실패로 끝나도 끝난 것이다', async () => {
|
||||
await expect(settledWithin(Promise.resolve('done'), 1_000)).resolves.toBe(
|
||||
true,
|
||||
);
|
||||
const failed = Promise.reject(new Error('engine down'));
|
||||
await expect(settledWithin(failed, 1_000)).resolves.toBe(true);
|
||||
});
|
||||
|
||||
it('시간이 지나면 false — 작업은 멈추지 않고 뒤에서 끝난다', async () => {
|
||||
let finish!: () => void;
|
||||
let finished = false;
|
||||
const work = new Promise<void>((resolve) => {
|
||||
finish = resolve;
|
||||
}).then(() => {
|
||||
finished = true;
|
||||
});
|
||||
|
||||
const waited = settledWithin(work, 5_000);
|
||||
jest.advanceTimersByTime(5_000);
|
||||
await expect(waited).resolves.toBe(false);
|
||||
|
||||
finish();
|
||||
await work;
|
||||
expect(finished).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,19 @@
|
||||
/**
|
||||
* 정해진 시간까지만 기다린다 — 그 안에 끝나면 true, 아니면 false (작업은 멈추지 않고 뒤에서 계속된다).
|
||||
* 작업이 실패해도 "끝난 것"으로 본다 — 실패 처리(로그 등)는 작업 쪽이 한다. 이 함수는 결과를 바꾸지 않는다.
|
||||
* 기다림 타이머는 프로세스를 붙잡지 않는다(unref) — 종료가 늦어지지 않게.
|
||||
*/
|
||||
export function settledWithin(
|
||||
work: Promise<unknown>,
|
||||
ms: number,
|
||||
): Promise<boolean> {
|
||||
return new Promise((resolve) => {
|
||||
const timer = setTimeout(() => resolve(false), ms);
|
||||
timer.unref();
|
||||
const finish = () => {
|
||||
clearTimeout(timer);
|
||||
resolve(true);
|
||||
};
|
||||
work.then(finish, finish);
|
||||
});
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import { DataSource } from 'typeorm';
|
||||
|
||||
import { AppModule } from '@/app.module';
|
||||
import { configureApp } from '@/app.setup';
|
||||
import { BusinessException } from '@/common/errors/business.exception';
|
||||
import { AUTH_SETTINGS, type AuthSettings } from '@/modules/auth/auth.settings';
|
||||
import {
|
||||
MAX_BODY_HTML_LENGTH,
|
||||
@@ -20,7 +21,22 @@ import {
|
||||
} from '@/modules/mail/mail.constants';
|
||||
import { MAIL_PROVIDER } from '@/modules/mail/mail.provider';
|
||||
import { MAIL_SETTINGS, type MailSettings } from '@/modules/mail/mail.settings';
|
||||
import type {
|
||||
Correspondence,
|
||||
DraftContent,
|
||||
DraftSource,
|
||||
OutgoingMessage,
|
||||
SavedDraft,
|
||||
} from '@/modules/mail/mail.types';
|
||||
import {
|
||||
SIGNATURE_IMAGES_PER_SIGNATURE,
|
||||
SIGNATURE_IMAGES_STORED_MAX,
|
||||
SIGNATURE_IMAGE_DIR,
|
||||
SIGNATURE_IMAGE_MAX_BYTES,
|
||||
} from '@/modules/mail-signature/mail-signature.constants';
|
||||
import { MailSignatureRepository } from '@/modules/mail-signature/mail-signature.repository';
|
||||
import { UPLOAD_DIR } from '@/modules/upload/upload.config';
|
||||
import { pngBytes } from './image-fixtures';
|
||||
|
||||
/**
|
||||
* 세 플랫폼이 공유하는 응답 계약을 실제 HTTP 요청으로 검증한다.
|
||||
@@ -55,6 +71,9 @@ interface ContractBody {
|
||||
|
||||
const body = (res: Response): ContractBody => res.body as ContractBody;
|
||||
|
||||
/** 성공 응답의 data — 계약 형태를 테스트가 정해 읽는다 */
|
||||
const dataOf = <T>(res: Response): T => (res.body as { data: T }).data;
|
||||
|
||||
/** Set-Cookie 헤더 중 이름이 같은 것 */
|
||||
function setCookie(res: Response, name: string): string | undefined {
|
||||
const raw = res.headers['set-cookie'] as unknown;
|
||||
@@ -78,6 +97,15 @@ const ADMIN_ACCOUNT = 'admin@e2e.arasystem.test';
|
||||
/** 대역 메일 엔진이 테스트가 문을 열 때까지 답하지 않는 주소 — 동시 로그인 시도 확인용 ("로그인 실패 한도") */
|
||||
const SLOW_ACCOUNT = 'slow.target@e2e.arasystem.test';
|
||||
|
||||
/** 대역 메일 엔진이 "다른 창·기기에서 먼저 바뀌었다"고 답하는 임시 저장본 id (409 MAIL_008 경로) */
|
||||
const DRAFT_CHANGED_ELSEWHERE = 'draftchangedelsewhere';
|
||||
|
||||
/** 대역 메일 엔진이 로그인 때 알려 주는 이름 — 없으면 EMPLOYEE.name */
|
||||
const PROFILE_NAMES = new Map([
|
||||
['colleague.a@e2e.arasystem.test', '가나다'],
|
||||
['colleague.b@e2e.arasystem.test', '박서준'],
|
||||
]);
|
||||
|
||||
/** 문을 열 조건을 다시 보는 간격 */
|
||||
const GATE_POLL_MS = 10;
|
||||
/** 문을 열 조건을 기다리는 최대 시간 — 회귀가 생기면 멈춰 있지 않고 이 시간 뒤에 분명히 실패한다 */
|
||||
@@ -150,6 +178,18 @@ describe('응답 계약 (e2e)', () => {
|
||||
* 열기 전에 온 확인은 모두 같은 문을 기다린다 (회귀로 여러 번 와도 한 번 열면 모두 풀린다).
|
||||
*/
|
||||
let slowEngineGate: Promise<void> | null = null;
|
||||
/** 대역 엔진의 임시 저장본 (id → 주인·내용) — 계약 2절 "임시 저장" */
|
||||
const drafts = new Map<
|
||||
string,
|
||||
{ user: string; content: DraftContent; updatedAt: string }
|
||||
>();
|
||||
let draftSequence = 0;
|
||||
/** 대역 엔진이 지운 임시 저장본 id (보낸 뒤 지우기) */
|
||||
const deletedDrafts: string[] = [];
|
||||
/** 대역 엔진이 받은 발송 */
|
||||
const sentMessages: { user: string; message: OutgoingMessage }[] = [];
|
||||
/** 대역 엔진이 돌려줄 주고받은 주소 (직원별) */
|
||||
const correspondents = new Map<string, Correspondence[]>();
|
||||
|
||||
/** 이 앱의 서명 키로 토큰을 만든다 (CI 처럼 JWT_SECRET 이 없으면 임시 키) */
|
||||
const sign = (payload: object, expiresIn: number) =>
|
||||
@@ -188,13 +228,79 @@ describe('응답 계약 (e2e)', () => {
|
||||
.useValue({
|
||||
// 리프레시는 계정이 살아 있는지 메일 엔진에 다시 묻는다
|
||||
getProfile: (email: string) =>
|
||||
Promise.resolve({ email, name: EMPLOYEE.name }),
|
||||
Promise.resolve({
|
||||
email,
|
||||
name: PROFILE_NAMES.get(email) ?? EMPLOYEE.name,
|
||||
}),
|
||||
verifyCredentials: async (email: string, password: string) => {
|
||||
engineChecks.set(email, (engineChecks.get(email) ?? 0) + 1);
|
||||
// 테스트가 문을 열 때까지 답하지 않는다 — 그동안 같은 주소의 다른 시도가 어떻게 되는지 본다
|
||||
if (email === SLOW_ACCOUNT && slowEngineGate) await slowEngineGate;
|
||||
return password === CORRECT_PASSWORD ? email : null;
|
||||
},
|
||||
// 발송은 받은 것을 기록만 한다 — 중복 방지는 "처음 보내는 키"로 본다
|
||||
findSentMessage: () => Promise.resolve(null),
|
||||
sendMessage: (user: string, message: OutgoingMessage) => {
|
||||
sentMessages.push({ user, message });
|
||||
return Promise.resolve({ id: `sent${sentMessages.length}` });
|
||||
},
|
||||
// 임시 저장 — 엔진 쪽 판단(초안이 아님 RES_001 · 이미 바뀜 MAIL_008)만 흉내 낸다 (실제 규칙은 jmap-mail.provider.spec.ts)
|
||||
saveDraft: (
|
||||
user: string,
|
||||
content: DraftContent,
|
||||
replaceId?: string,
|
||||
): Promise<SavedDraft> => {
|
||||
if (replaceId === DRAFT_CHANGED_ELSEWHERE) {
|
||||
return Promise.reject(
|
||||
new BusinessException('MAIL_008', {
|
||||
context: { reason: 'e2e-draft-replaced-elsewhere' },
|
||||
}),
|
||||
);
|
||||
}
|
||||
if (replaceId !== undefined && drafts.get(replaceId)?.user !== user) {
|
||||
return Promise.reject(
|
||||
new BusinessException('RES_001', {
|
||||
context: { reason: 'e2e-not-a-draft' },
|
||||
}),
|
||||
);
|
||||
}
|
||||
draftSequence += 1;
|
||||
const id = `draft${draftSequence}`;
|
||||
const updatedAt = new Date().toISOString();
|
||||
drafts.set(id, { user, content, updatedAt });
|
||||
if (replaceId !== undefined) drafts.delete(replaceId);
|
||||
return Promise.resolve({ id, attachments: [], updatedAt });
|
||||
},
|
||||
getDraft: (user: string, id: string): Promise<DraftSource> => {
|
||||
const draft = drafts.get(id);
|
||||
if (!draft || draft.user !== user) {
|
||||
return Promise.reject(
|
||||
new BusinessException('RES_001', {
|
||||
context: { reason: 'e2e-not-a-draft' },
|
||||
}),
|
||||
);
|
||||
}
|
||||
const { content, updatedAt } = draft;
|
||||
return Promise.resolve({
|
||||
id,
|
||||
to: content.to,
|
||||
cc: content.cc,
|
||||
bcc: content.bcc,
|
||||
subject: content.subject,
|
||||
textBody: content.body,
|
||||
rawHtmlBody: content.htmlBody ?? null,
|
||||
attachments: [],
|
||||
inReplyTo: null,
|
||||
updatedAt,
|
||||
});
|
||||
},
|
||||
deleteDraft: (user: string, id: string) => {
|
||||
deletedDrafts.push(id);
|
||||
if (drafts.get(id)?.user === user) drafts.delete(id);
|
||||
return Promise.resolve();
|
||||
},
|
||||
listCorrespondents: (user: string) =>
|
||||
Promise.resolve(correspondents.get(user) ?? []),
|
||||
})
|
||||
.overrideProvider(MAIL_SETTINGS)
|
||||
.useValue(TEST_MAIL_SETTINGS)
|
||||
@@ -222,6 +328,22 @@ describe('응답 계약 (e2e)', () => {
|
||||
'DELETE FROM auth_sessions WHERE user_email LIKE $1',
|
||||
[TEST_DOMAIN_PATTERN],
|
||||
);
|
||||
// 서명 그림은 파일도 지운다 (개발 업로드 볼륨에 남기지 않는다)
|
||||
const images: { file_name: string }[] = await dataSource.query(
|
||||
'SELECT file_name FROM mail_signature_images WHERE user_email LIKE $1',
|
||||
[TEST_DOMAIN_PATTERN],
|
||||
);
|
||||
for (const { file_name: fileName } of images) {
|
||||
const stored = join(SIGNATURE_IMAGE_DIR, basename(fileName));
|
||||
if (existsSync(stored)) unlinkSync(stored);
|
||||
}
|
||||
for (const statement of [
|
||||
'DELETE FROM mail_signature_images WHERE user_email LIKE $1',
|
||||
'DELETE FROM mail_signatures WHERE user_email LIKE $1',
|
||||
'DELETE FROM mail_directory WHERE email LIKE $1',
|
||||
]) {
|
||||
await dataSource.query(statement, [TEST_DOMAIN_PATTERN]);
|
||||
}
|
||||
await app.close();
|
||||
});
|
||||
|
||||
@@ -544,6 +666,17 @@ describe('응답 계약 (e2e)', () => {
|
||||
['get', '/api/messages/eaaaaab'],
|
||||
['post', '/api/messages'],
|
||||
['post', '/api/mail-attachments'],
|
||||
['get', '/api/drafts/eaaaaab'],
|
||||
['post', '/api/drafts'],
|
||||
['put', '/api/drafts/eaaaaab'],
|
||||
['get', '/api/mail-signature'],
|
||||
['put', '/api/mail-signature'],
|
||||
['post', '/api/mail-signature/images'],
|
||||
[
|
||||
'get',
|
||||
'/api/mail-signature/images/3f1c0a2e-0f1b-4f2a-9a1e-6c9f0a1b2c3d',
|
||||
],
|
||||
['get', '/api/recipient-suggestions'],
|
||||
// 템플릿 업로드 예제도 로그인한 직원만 쓴다
|
||||
['post', '/api/files'],
|
||||
] as const)('쿠키 없이 %s %s 는 401 AUTH_001', async (method, path) => {
|
||||
@@ -594,7 +727,8 @@ describe('응답 계약 (e2e)', () => {
|
||||
{ sub: EMPLOYEE.email, typ: 'access', name: EMPLOYEE.name },
|
||||
600,
|
||||
);
|
||||
// 대역 엔진에는 발송이 없다 — 엔진에 닿으면 500 이 된다 (한도는 엔진에 묻기 전에 본다)
|
||||
// 한도는 엔진에 묻기 전에 본다 — 대역 엔진이 받은 발송이 늘지 않아야 한다
|
||||
const sentBefore = sentMessages.length;
|
||||
const send = (overrides: object) =>
|
||||
request(server)
|
||||
.post('/api/messages')
|
||||
@@ -620,6 +754,644 @@ describe('응답 계약 (e2e)', () => {
|
||||
subject: '가'.repeat(MAX_SUBJECT_LENGTH + 1),
|
||||
}).expect(400);
|
||||
expect(body(longSubject).error?.code).toBe('VAL_001');
|
||||
expect(sentMessages).toHaveLength(sentBefore);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* 2단계 — 임시 저장 · 서명 · 받는 사람 추천 (계약 2절).
|
||||
* 엔진 쪽 판단(초안인지·옛것이 그사이 없어졌는지)은 대역 엔진이 흉내 낸다 — 여기서는 HTTP 형식·정화·DB·파일을 본다.
|
||||
*/
|
||||
describe('2단계 — 임시 저장 · 서명 · 받는 사람 추천', () => {
|
||||
/** 그 직원의 액세스 쿠키 (세션 없이 서명만 맞춘다 — 보호 라우트는 액세스 토큰만 본다) */
|
||||
const cookieOf = (email: string) =>
|
||||
`access_token=${sign({ sub: email, typ: 'access', name: '테스트 직원' }, 600)}`;
|
||||
const UUID =
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
|
||||
const DOYUN = { name: '이도윤', email: 'doyun.lee@e2e.arasystem.test' };
|
||||
|
||||
describe('임시 저장 (/api/drafts)', () => {
|
||||
const save = (method: 'post' | 'put', path: string, payload: object) =>
|
||||
request(server)
|
||||
[method](path)
|
||||
.set('Cookie', cookieOf(EMPLOYEE.email))
|
||||
.send({ to: [], subject: '쓰는 중', body: '본문', ...payload });
|
||||
|
||||
it('받는 사람 없이도 처음 저장(POST)은 201 — 서식 본문은 보내는 정화를 거쳐 엔진에 넘어간다', async () => {
|
||||
const res = await save('post', '/api/drafts', {
|
||||
bodyHtml: '<p onclick="x()">쓰는 중<script>alert(1)</script></p>',
|
||||
}).expect(201);
|
||||
|
||||
const saved = dataOf<SavedDraft>(res);
|
||||
expect(saved).toEqual({
|
||||
id: expect.stringMatching(/^draft\d+$/) as string,
|
||||
attachments: [],
|
||||
updatedAt: expect.any(String) as string,
|
||||
});
|
||||
expect(drafts.get(saved.id)?.content).toMatchObject({
|
||||
to: [],
|
||||
htmlBody: '<p>쓰는 중</p>',
|
||||
});
|
||||
});
|
||||
|
||||
it('바꿔 저장(PUT)은 새 id 를 돌려주고, 이어 쓰기(GET)는 계약 모양이다', async () => {
|
||||
const first = dataOf<SavedDraft>(
|
||||
await save('post', '/api/drafts', {}).expect(201),
|
||||
);
|
||||
const second = dataOf<SavedDraft>(
|
||||
await save('put', `/api/drafts/${first.id}`, {
|
||||
to: [DOYUN],
|
||||
subject: '고친 제목',
|
||||
bodyHtml:
|
||||
'<p><strong>굵게</strong><img src="https://x.test/p.gif"></p>',
|
||||
}).expect(200),
|
||||
);
|
||||
expect(second.id).not.toBe(first.id);
|
||||
expect(drafts.has(first.id)).toBe(false);
|
||||
|
||||
const res = await request(server)
|
||||
.get(`/api/drafts/${second.id}`)
|
||||
.set('Cookie', cookieOf(EMPLOYEE.email))
|
||||
.expect(200);
|
||||
expect(dataOf<unknown>(res)).toEqual({
|
||||
id: second.id,
|
||||
to: [DOYUN],
|
||||
cc: [],
|
||||
bcc: [],
|
||||
subject: '고친 제목',
|
||||
body: '본문',
|
||||
bodyHtml: '<p><strong>굵게</strong></p>',
|
||||
attachments: [],
|
||||
inReplyTo: null,
|
||||
updatedAt: second.updatedAt,
|
||||
});
|
||||
});
|
||||
|
||||
it('옛 저장본이 다른 곳에서 먼저 바뀌었으면 409 MAIL_008 · 초안이 아닌 id 는 404 RES_001 · 형식이 틀린 id 도 404', async () => {
|
||||
const changed = await save(
|
||||
'put',
|
||||
`/api/drafts/${DRAFT_CHANGED_ELSEWHERE}`,
|
||||
{},
|
||||
).expect(409);
|
||||
expect(body(changed).error?.code).toBe('MAIL_008');
|
||||
expect(body(changed).error?.traceId).toBe(
|
||||
changed.headers['x-request-id'],
|
||||
);
|
||||
|
||||
for (const path of ['/api/drafts/eaaaaab', '/api/drafts/bad!id']) {
|
||||
const res = await save('put', path, {}).expect(404);
|
||||
expect(body(res).error?.code).toBe('RES_001');
|
||||
}
|
||||
});
|
||||
|
||||
it('다른 직원의 저장본은 없는 것과 같다 (404 RES_001)', async () => {
|
||||
const mine = dataOf<SavedDraft>(
|
||||
await save('post', '/api/drafts', {}).expect(201),
|
||||
);
|
||||
|
||||
const res = await request(server)
|
||||
.get(`/api/drafts/${mine.id}`)
|
||||
.set('Cookie', cookieOf('other.person@e2e.arasystem.test'))
|
||||
.expect(404);
|
||||
expect(body(res).error?.code).toBe('RES_001');
|
||||
});
|
||||
|
||||
it('저장 본문은 발송 본문과 같은 한도 — 본문 MAIL_007 · 받는 사람 합계 MAIL_002 · draftId 같은 모르는 필드 VAL_001', async () => {
|
||||
const recipients = (count: number) =>
|
||||
Array.from({ length: count }, (_, index) => ({
|
||||
name: null,
|
||||
email: `r${index}@e2e.arasystem.test`,
|
||||
}));
|
||||
const cases: [object, number, string][] = [
|
||||
[{ body: '😀'.repeat(MAX_BODY_LENGTH / 2 + 1) }, 400, 'MAIL_007'],
|
||||
[{ to: recipients(60), cc: recipients(41) }, 400, 'MAIL_002'],
|
||||
[{ draftId: 'eaaaaab' }, 400, 'VAL_001'],
|
||||
];
|
||||
|
||||
const before = drafts.size;
|
||||
for (const [payload, status, code] of cases) {
|
||||
const res = await save('post', '/api/drafts', payload).expect(status);
|
||||
expect(body(res).error?.code).toBe(code);
|
||||
}
|
||||
expect(drafts.size).toBe(before);
|
||||
});
|
||||
|
||||
it('저장(POST·PUT /api/drafts)도 JSON 본문 4MB 까지 받는다 — 2MB 는 인증 검사에 닿고, 4MB 를 넘으면 413 FILE_002', async () => {
|
||||
for (const [method, path] of [
|
||||
['post', '/api/drafts'],
|
||||
['put', '/api/drafts/eaaaaab'],
|
||||
] as const) {
|
||||
const parsed = await request(server)
|
||||
[method](path)
|
||||
.set('Content-Type', 'application/json')
|
||||
.send(JSON.stringify({ body: '', bodyHtml: 'x'.repeat(2_000_000) }))
|
||||
.expect(401);
|
||||
expect(body(parsed).error?.code).toBe('AUTH_001');
|
||||
|
||||
const tooLarge = await request(server)
|
||||
[method](path)
|
||||
.set('Content-Type', 'application/json')
|
||||
.send(JSON.stringify({ body: '', bodyHtml: 'x'.repeat(4_300_000) }))
|
||||
.expect(413);
|
||||
expect(body(tooLarge).error?.code).toBe('FILE_002');
|
||||
}
|
||||
});
|
||||
|
||||
it('이어 쓴 저장본(draftId)을 보내면 보낸 뒤 지운다 — 형식이 틀린 draftId 는 400 VAL_001 (보내지 않는다)', async () => {
|
||||
const draft = dataOf<SavedDraft>(
|
||||
await save('post', '/api/drafts', { to: [DOYUN] }).expect(201),
|
||||
);
|
||||
const send = (draftId: string) =>
|
||||
request(server)
|
||||
.post('/api/messages')
|
||||
.set('Cookie', cookieOf(EMPLOYEE.email))
|
||||
.send({ to: [DOYUN], subject: '제목', body: '본문', draftId });
|
||||
|
||||
const sentBefore = sentMessages.length;
|
||||
const malformed = await send('../draft1').expect(400);
|
||||
expect(body(malformed).error?.code).toBe('VAL_001');
|
||||
expect(sentMessages).toHaveLength(sentBefore);
|
||||
|
||||
await send(draft.id).expect(201);
|
||||
expect(sentMessages).toHaveLength(sentBefore + 1);
|
||||
// 발송 내용에는 draftId 가 없다 (지문에 넣지 않는다)
|
||||
expect(sentMessages[sentBefore].message).not.toHaveProperty('draftId');
|
||||
expect(deletedDrafts).toContain(draft.id);
|
||||
expect(drafts.has(draft.id)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('서명 (/api/mail-signature)', () => {
|
||||
const imageUrl = (id: string) => `/api/mail-signature/images/${id}`;
|
||||
const uploadAs = (
|
||||
email: string,
|
||||
data: Buffer,
|
||||
filename = 'logo.png',
|
||||
contentType = 'image/png',
|
||||
) =>
|
||||
request(server)
|
||||
.post('/api/mail-signature/images')
|
||||
.set('Cookie', cookieOf(email))
|
||||
.attach('file', data, { filename, contentType });
|
||||
const uploaded = async (email: string, data = pngBytes(480, 120)) =>
|
||||
dataOf<{ id: string }>(await uploadAs(email, data).expect(201)).id;
|
||||
const saveSignature = (email: string, payload: object) =>
|
||||
request(server)
|
||||
.put('/api/mail-signature')
|
||||
.set('Cookie', cookieOf(email))
|
||||
.send({ isForNew: true, isForReply: true, ...payload });
|
||||
|
||||
it('저장한 적 없으면 기본값', async () => {
|
||||
const res = await request(server)
|
||||
.get('/api/mail-signature')
|
||||
.set('Cookie', cookieOf('sign.default@e2e.arasystem.test'))
|
||||
.expect(200);
|
||||
|
||||
expect(dataOf<unknown>(res)).toEqual({
|
||||
bodyHtml: null,
|
||||
body: null,
|
||||
isForNew: true,
|
||||
isForReply: true,
|
||||
images: [],
|
||||
updatedAt: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('그림을 올리면 파일 내용으로 확인한 형식·크기를 돌려주고, 그 그림은 주인만 받는다 (nosniff · inline · private 캐시)', async () => {
|
||||
const owner = 'sign.owner@e2e.arasystem.test';
|
||||
const data = pngBytes(480, 120, 2_048);
|
||||
|
||||
const image = dataOf<Record<string, unknown>>(
|
||||
await uploadAs(owner, data, '회사 로고.png').expect(201),
|
||||
);
|
||||
expect(image).toEqual({
|
||||
id: expect.stringMatching(UUID) as string,
|
||||
type: 'image/png',
|
||||
size: 2_048,
|
||||
width: 480,
|
||||
height: 120,
|
||||
});
|
||||
const id = String(image.id);
|
||||
|
||||
const res = await request(server)
|
||||
.get(imageUrl(id))
|
||||
.set('Cookie', cookieOf(owner))
|
||||
.expect(200);
|
||||
expect(res.headers['content-type']).toBe('image/png');
|
||||
expect(res.headers['x-content-type-options']).toBe('nosniff');
|
||||
expect(res.headers['cache-control']).toBe('private, max-age=86400');
|
||||
expect(res.headers['content-disposition']).toBe(
|
||||
'inline; filename="signature.png"',
|
||||
);
|
||||
expect(res.body).toEqual(data);
|
||||
|
||||
for (const [cookie, path] of [
|
||||
[cookieOf('other.person@e2e.arasystem.test'), imageUrl(id)],
|
||||
[cookieOf(owner), imageUrl('not-a-uuid')],
|
||||
[cookieOf(owner), imageUrl('11111111-1111-4111-8111-111111111111')],
|
||||
]) {
|
||||
const denied = await request(server)
|
||||
.get(path)
|
||||
.set('Cookie', cookie)
|
||||
.expect(404);
|
||||
expect(body(denied).error?.code).toBe('RES_001');
|
||||
}
|
||||
});
|
||||
|
||||
it('그림이 아니거나 형식이 다르면 400 FILE_001 (webp·svg 는 받지 않는다) · 300KB 를 넘으면 413 FILE_002 · 파일이 없으면 400 VAL_001', async () => {
|
||||
const user = 'sign.reject@e2e.arasystem.test';
|
||||
for (const [data, filename, contentType] of [
|
||||
// 이름·MIME 은 PNG 인데 내용은 HTML
|
||||
[
|
||||
Buffer.from('<html><script>alert(1)</script></html>'),
|
||||
'logo.png',
|
||||
'image/png',
|
||||
],
|
||||
// 이름과 MIME 이 다르다
|
||||
[pngBytes(10, 10), 'logo.png', 'image/gif'],
|
||||
[pngBytes(10, 10), 'logo.webp', 'image/webp'],
|
||||
[Buffer.from('<svg onload="x()"/>'), 'logo.svg', 'image/svg+xml'],
|
||||
] as const) {
|
||||
const res = await uploadAs(user, data, filename, contentType).expect(
|
||||
400,
|
||||
);
|
||||
expect(body(res).error?.code).toBe('FILE_001');
|
||||
}
|
||||
|
||||
const tooLarge = await uploadAs(
|
||||
user,
|
||||
pngBytes(10, 10, SIGNATURE_IMAGE_MAX_BYTES + 1),
|
||||
).expect(413);
|
||||
expect(body(tooLarge).error?.code).toBe('FILE_002');
|
||||
|
||||
const missing = await request(server)
|
||||
.post('/api/mail-signature/images')
|
||||
.set('Cookie', cookieOf(user))
|
||||
.expect(400);
|
||||
expect(body(missing).error?.code).toBe('VAL_001');
|
||||
|
||||
// 하나도 저장되지 않았다
|
||||
const rows: { id: string }[] = await dataSource.query(
|
||||
'SELECT id FROM mail_signature_images WHERE user_email = $1',
|
||||
[user],
|
||||
);
|
||||
expect(rows).toEqual([]);
|
||||
});
|
||||
|
||||
it(`둘 수 있는 그림은 ${SIGNATURE_IMAGES_STORED_MAX}장까지(넘으면 400 VAL_001) · 서명 하나에는 서로 다른 그림 ${SIGNATURE_IMAGES_PER_SIGNATURE}장까지(넘으면 400 VAL_001)`, async () => {
|
||||
const user = 'sign.many@e2e.arasystem.test';
|
||||
const ids: string[] = [];
|
||||
for (let index = 0; index < SIGNATURE_IMAGES_STORED_MAX; index += 1) {
|
||||
ids.push(await uploaded(user));
|
||||
}
|
||||
|
||||
const overflow = await uploadAs(user, pngBytes(10, 10)).expect(400);
|
||||
expect(body(overflow).error?.code).toBe('VAL_001');
|
||||
|
||||
const img = (id: string) => `<img src="${imageUrl(id)}" width="96">`;
|
||||
const tooMany = await saveSignature(user, {
|
||||
bodyHtml: ids
|
||||
.slice(0, SIGNATURE_IMAGES_PER_SIGNATURE + 1)
|
||||
.map(img)
|
||||
.join(''),
|
||||
}).expect(400);
|
||||
expect(body(tooMany).error?.code).toBe('VAL_001');
|
||||
|
||||
const fits = await saveSignature(user, {
|
||||
bodyHtml: ids
|
||||
.slice(0, SIGNATURE_IMAGES_PER_SIGNATURE)
|
||||
.map(img)
|
||||
.join(''),
|
||||
}).expect(200);
|
||||
expect(
|
||||
dataOf<{ images: { id: string }[] }>(fits).images.map(
|
||||
(image) => image.id,
|
||||
),
|
||||
).toEqual(ids.slice(0, SIGNATURE_IMAGES_PER_SIGNATURE));
|
||||
});
|
||||
|
||||
it('저장하면 정화해 두고, 서명이 더는 가리키지 않는 그림은 지운다 — 막 올리고 아직 넣지 않은 그림은 남긴다', async () => {
|
||||
const user = 'sign.save@e2e.arasystem.test';
|
||||
const [a, b] = [await uploaded(user), await uploaded(user)];
|
||||
|
||||
const first = await saveSignature(user, {
|
||||
bodyHtml: `<p onclick="x()">김아라</p><img src="${imageUrl(a)}" width="144" alt=""><img src="${imageUrl(b)}" width="96"><img src="${imageUrl('11111111-1111-4111-8111-111111111111')}">`,
|
||||
body: '김아라 · 아라시스템',
|
||||
isForReply: false,
|
||||
}).expect(200);
|
||||
expect(dataOf<Record<string, unknown>>(first)).toMatchObject({
|
||||
bodyHtml: `<p>김아라</p><img src="${imageUrl(a)}" width="144" alt="" /><img src="${imageUrl(b)}" width="96" />`,
|
||||
body: '김아라 · 아라시스템',
|
||||
isForNew: true,
|
||||
isForReply: false,
|
||||
images: [
|
||||
expect.objectContaining({ id: a }) as unknown,
|
||||
expect.objectContaining({ id: b }) as unknown,
|
||||
],
|
||||
updatedAt: expect.any(String) as string,
|
||||
});
|
||||
|
||||
// b 를 빼고 저장한다 — 그사이 올린 c 는 아직 서명에 넣지 않았다
|
||||
const c = await uploaded(user);
|
||||
const second = await saveSignature(user, {
|
||||
bodyHtml: `<img src="${imageUrl(a)}" width="144">`,
|
||||
body: null,
|
||||
}).expect(200);
|
||||
expect(
|
||||
dataOf<{ images: { id: string }[] }>(second).images.map(
|
||||
(image) => image.id,
|
||||
),
|
||||
).toEqual([a]);
|
||||
|
||||
const statusOf = async (id: string) =>
|
||||
(
|
||||
await request(server)
|
||||
.get(imageUrl(id))
|
||||
.set('Cookie', cookieOf(user))
|
||||
).status;
|
||||
expect(await statusOf(a)).toBe(200);
|
||||
expect(await statusOf(b)).toBe(404);
|
||||
expect(await statusOf(c)).toBe(200);
|
||||
// 지운 그림은 파일도 없다
|
||||
expect(existsSync(join(SIGNATURE_IMAGE_DIR, `${b}.png`))).toBe(false);
|
||||
|
||||
const current = await request(server)
|
||||
.get('/api/mail-signature')
|
||||
.set('Cookie', cookieOf(user))
|
||||
.expect(200);
|
||||
expect(dataOf<unknown>(current)).toEqual(dataOf<unknown>(second));
|
||||
});
|
||||
|
||||
it('한도를 넘으면 400 VAL_001 — 텍스트 2,000자 · 정화한 서식 10,000자', async () => {
|
||||
const user = 'sign.limits@e2e.arasystem.test';
|
||||
for (const payload of [
|
||||
{ body: '가'.repeat(2_001) },
|
||||
{ bodyHtml: `<p>${'가'.repeat(10_000)}</p>` },
|
||||
]) {
|
||||
const res = await saveSignature(user, payload).expect(400);
|
||||
expect(body(res).error?.code).toBe('VAL_001');
|
||||
}
|
||||
});
|
||||
|
||||
it(`보내거나 임시 저장하는 본문의 서명 그림도 서로 다른 그림 ${SIGNATURE_IMAGES_PER_SIGNATURE}장까지 — 넘으면 400 VAL_001, 메일은 나가지 않는다`, async () => {
|
||||
const user = 'sign.body@e2e.arasystem.test';
|
||||
const ids: string[] = [];
|
||||
for (
|
||||
let index = 0;
|
||||
index < SIGNATURE_IMAGES_PER_SIGNATURE + 1;
|
||||
index += 1
|
||||
) {
|
||||
ids.push(await uploaded(user, pngBytes(40, 10)));
|
||||
}
|
||||
const bodyHtml = `<p>본문</p>${ids.map((id) => `<img src="${imageUrl(id)}" width="96">`).join('')}`;
|
||||
const sentBefore = sentMessages.length;
|
||||
const draftsBefore = drafts.size;
|
||||
|
||||
const send = await request(server)
|
||||
.post('/api/messages')
|
||||
.set('Cookie', cookieOf(user))
|
||||
.send({ to: [DOYUN], subject: '그림 넷', body: '본문', bodyHtml })
|
||||
.expect(400);
|
||||
expect(body(send).error?.code).toBe('VAL_001');
|
||||
const draft = await request(server)
|
||||
.post('/api/drafts')
|
||||
.set('Cookie', cookieOf(user))
|
||||
.send({ to: [], subject: '그림 넷', body: '본문', bodyHtml })
|
||||
.expect(400);
|
||||
expect(body(draft).error?.code).toBe('VAL_001');
|
||||
|
||||
expect(sentMessages).toHaveLength(sentBefore);
|
||||
expect(drafts.size).toBe(draftsBefore);
|
||||
});
|
||||
|
||||
it('두 탭이 동시에 서명을 저장해도 남은 서명이 없는 그림을 가리키지 않는다 (사용자별로 차례대로 저장)', async () => {
|
||||
const user = 'sign.race@e2e.arasystem.test';
|
||||
const [a, b] = [await uploaded(user), await uploaded(user)];
|
||||
const img = (id: string) => `<img src="${imageUrl(id)}" width="96">`;
|
||||
await saveSignature(user, { bodyHtml: `${img(a)}${img(b)}` }).expect(
|
||||
200,
|
||||
);
|
||||
|
||||
// 한 탭은 a 만, 다른 탭은 b 만 남기고 동시에 저장한다 — 누가 나중이든 결과는 앞뒤가 맞아야 한다
|
||||
const [first, second] = await Promise.all([
|
||||
saveSignature(user, { bodyHtml: `<p>탭 1</p>${img(a)}` }),
|
||||
saveSignature(user, { bodyHtml: `<p>탭 2</p>${img(b)}` }),
|
||||
]);
|
||||
expect([first.status, second.status]).toEqual([200, 200]);
|
||||
|
||||
const final = dataOf<{
|
||||
bodyHtml: string | null;
|
||||
images: { id: string }[];
|
||||
}>(
|
||||
await request(server)
|
||||
.get('/api/mail-signature')
|
||||
.set('Cookie', cookieOf(user))
|
||||
.expect(200),
|
||||
);
|
||||
const referenced = [
|
||||
...(final.bodyHtml ?? '').matchAll(
|
||||
/\/api\/mail-signature\/images\/([0-9a-f-]{36})/g,
|
||||
),
|
||||
].map((match) => match[1]);
|
||||
expect(final.images.map((image) => image.id)).toEqual(referenced);
|
||||
// 서명이 가리키는 그림은 모두 있다 — 가리키지 않는 그림은 지워졌다
|
||||
for (const id of [a, b]) {
|
||||
const res = await request(server)
|
||||
.get(imageUrl(id))
|
||||
.set('Cookie', cookieOf(user));
|
||||
expect(res.status).toBe(referenced.includes(id) ? 200 : 404);
|
||||
}
|
||||
});
|
||||
|
||||
it('정리 작업의 지우기는 지우는 순간에도 서명에 붙지 않은 그림만 지우고, 실제로 지운 행만 돌려준다 (그 행의 파일만 지운다)', async () => {
|
||||
const user = 'sign.cleanup@e2e.arasystem.test';
|
||||
const [attached, loose] = [await uploaded(user), await uploaded(user)];
|
||||
await saveSignature(user, {
|
||||
bodyHtml: `<img src="${imageUrl(attached)}" width="96">`,
|
||||
}).expect(200);
|
||||
|
||||
// 정리 작업이 둘을 "붙지 않은 그림"으로 찾았는데, 그사이 하나가 서명에 붙은 경우와 같다
|
||||
const repository = app.get(MailSignatureRepository, { strict: false });
|
||||
const deleted = await repository.deleteUnattachedImages([
|
||||
attached,
|
||||
loose,
|
||||
]);
|
||||
// 표에서 지운 그림의 파일은 정리 작업이 지운다 — 여기서는 테스트가 먼저 (단언이 실패해도 개발 볼륨에 남기지 않게)
|
||||
const left: { id: string }[] = await dataSource.query(
|
||||
'SELECT id FROM mail_signature_images WHERE id = ANY($1)',
|
||||
[[attached, loose]],
|
||||
);
|
||||
for (const id of [attached, loose]) {
|
||||
const stored = join(SIGNATURE_IMAGE_DIR, `${id}.png`);
|
||||
if (!left.some((row) => row.id === id) && existsSync(stored)) {
|
||||
unlinkSync(stored);
|
||||
}
|
||||
}
|
||||
expect(deleted).toEqual([{ id: loose, fileName: `${loose}.png` }]);
|
||||
|
||||
const statusOf = async (id: string) =>
|
||||
(
|
||||
await request(server)
|
||||
.get(imageUrl(id))
|
||||
.set('Cookie', cookieOf(user))
|
||||
).status;
|
||||
expect(await statusOf(attached)).toBe(200);
|
||||
expect(await statusOf(loose)).toBe(404);
|
||||
});
|
||||
|
||||
it('보낼 때 내 서명 그림을 메일 안에 함께 싣는다(cid) — 임시 저장본은 그림 주소 그대로', async () => {
|
||||
const user = 'sign.send@e2e.arasystem.test';
|
||||
const data = pngBytes(200, 50, 1_000);
|
||||
const id = await uploaded(user, data);
|
||||
const html = `<p>본문</p><img src="${imageUrl(id)}" width="144" alt="로고">`;
|
||||
const cid = `sig-${id}@e2e.arasystem.test`;
|
||||
|
||||
await request(server)
|
||||
.post('/api/messages')
|
||||
.set('Cookie', cookieOf(user))
|
||||
.send({ to: [DOYUN], subject: '서명', body: '본문', bodyHtml: html })
|
||||
.expect(201);
|
||||
const sent = sentMessages[sentMessages.length - 1];
|
||||
expect(sent.user).toBe(user);
|
||||
expect(sent.message.htmlBody).toBe(
|
||||
`<p>본문</p><img src="cid:${cid}" width="144" alt="로고" />`,
|
||||
);
|
||||
expect(sent.message.inlineImages).toEqual([
|
||||
{ cid, type: 'image/png', name: 'signature-1.png', data },
|
||||
]);
|
||||
|
||||
const saved = dataOf<SavedDraft>(
|
||||
await request(server)
|
||||
.post('/api/drafts')
|
||||
.set('Cookie', cookieOf(user))
|
||||
.send({ to: [], subject: '', body: '본문', bodyHtml: html })
|
||||
.expect(201),
|
||||
);
|
||||
expect(drafts.get(saved.id)?.content.htmlBody).toBe(
|
||||
`<p>본문</p><img src="${imageUrl(id)}" width="144" alt="로고" />`,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('받는 사람 추천 (/api/recipient-suggestions)', () => {
|
||||
const ME = 'suggest.me@e2e.arasystem.test';
|
||||
const COLLEAGUE_A = 'colleague.a@e2e.arasystem.test';
|
||||
const COLLEAGUE_B = 'colleague.b@e2e.arasystem.test';
|
||||
const suggest = (params: Record<string, string | number>) =>
|
||||
request(server)
|
||||
.get('/api/recipient-suggestions')
|
||||
.query(params)
|
||||
.set('Cookie', cookieOf(ME));
|
||||
interface SuggestionPage {
|
||||
items: { name: string | null; email: string; source: string }[];
|
||||
pageInfo: {
|
||||
hasNext: boolean;
|
||||
nextCursor: string | null;
|
||||
total: number;
|
||||
};
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
// 사내 목록은 로그인에 성공한 직원으로 만든다
|
||||
await login(COLLEAGUE_A);
|
||||
await login(COLLEAGUE_B);
|
||||
const now = new Date().toISOString();
|
||||
correspondents.set(ME, [
|
||||
{ name: '박서준', email: COLLEAGUE_B, at: now },
|
||||
{
|
||||
name: '협력사',
|
||||
email: 'partner@vendor.e2e.arasystem.test',
|
||||
at: now,
|
||||
},
|
||||
{ name: null, email: 'no-reply@notice.e2e.arasystem.test', at: now },
|
||||
{ name: null, email: 'mail-gateway@e2e.arasystem.test', at: now },
|
||||
{ name: '나', email: ME, at: now },
|
||||
]);
|
||||
});
|
||||
|
||||
it('사내(로그인한 적 있는 직원) → 최근 주고받은 주소 순 — 나·게이트웨이·noreply 는 빼고, Cache-Control: private, no-store', async () => {
|
||||
// 개발 DB 의 사내 목록에는 다른 직원도 있다 — 이 테스트의 도메인만 거른다
|
||||
const res = await suggest({
|
||||
q: 'e2e.arasystem.test',
|
||||
limit: 100,
|
||||
}).expect(200);
|
||||
|
||||
expect(res.headers['cache-control']).toBe('private, no-store');
|
||||
const { items } = dataOf<SuggestionPage>(res);
|
||||
const emails = items.map((item) => item.email);
|
||||
expect(items).toContainEqual({
|
||||
name: '가나다',
|
||||
email: COLLEAGUE_A,
|
||||
source: 'directory',
|
||||
});
|
||||
// 주고받은 적 있는 동료가 사내 묶음에서 먼저, 두 쪽에 다 있어도 사내로 한 번만
|
||||
expect(emails.indexOf(COLLEAGUE_B)).toBeLessThan(
|
||||
emails.indexOf(COLLEAGUE_A),
|
||||
);
|
||||
expect(emails.filter((email) => email === COLLEAGUE_B)).toHaveLength(1);
|
||||
expect(items[items.length - 1]).toEqual({
|
||||
name: '협력사',
|
||||
email: 'partner@vendor.e2e.arasystem.test',
|
||||
source: 'recent',
|
||||
});
|
||||
expect(emails).not.toContain(ME);
|
||||
expect(emails).not.toContain('mail-gateway@e2e.arasystem.test');
|
||||
expect(emails).not.toContain('no-reply@notice.e2e.arasystem.test');
|
||||
});
|
||||
|
||||
it('q 와 커서 페이지네이션 — 다른 q 의 커서 · 틀린 커서 · 100자 넘는 q · limit 범위 밖은 400 VAL_001', async () => {
|
||||
const first = dataOf<SuggestionPage>(
|
||||
await suggest({ q: 'COLLEAGUE', limit: 1 }).expect(200),
|
||||
);
|
||||
expect(first.items.map((item) => item.email)).toEqual([COLLEAGUE_B]);
|
||||
expect(first.pageInfo).toMatchObject({ hasNext: true, total: 2 });
|
||||
const cursor = first.pageInfo.nextCursor ?? '';
|
||||
|
||||
const second = dataOf<SuggestionPage>(
|
||||
await suggest({ q: 'COLLEAGUE', limit: 1, cursor }).expect(200),
|
||||
);
|
||||
expect(second.items.map((item) => item.email)).toEqual([COLLEAGUE_A]);
|
||||
expect(second.pageInfo).toEqual({
|
||||
hasNext: false,
|
||||
nextCursor: null,
|
||||
total: 2,
|
||||
});
|
||||
|
||||
const rejected: Record<string, string | number>[] = [
|
||||
{ q: 'partner', cursor },
|
||||
{ cursor: 'not-a-cursor' },
|
||||
{ q: 'x'.repeat(101) },
|
||||
{ limit: 101 },
|
||||
];
|
||||
for (const params of rejected) {
|
||||
const res = await suggest(params).expect(400);
|
||||
expect(body(res).error?.code).toBe('VAL_001');
|
||||
}
|
||||
});
|
||||
|
||||
it('메일을 보내면 다시 모은다 — 그 전에는 10분 동안 기억한 목록을 준다', async () => {
|
||||
const now = new Date().toISOString();
|
||||
correspondents.set(ME, [
|
||||
{
|
||||
name: '새 거래처',
|
||||
email: 'new@client.e2e.arasystem.test',
|
||||
at: now,
|
||||
},
|
||||
]);
|
||||
const emailsOf = async () =>
|
||||
dataOf<SuggestionPage>(
|
||||
await suggest({ q: 'client.e2e', limit: 100 }).expect(200),
|
||||
).items.map((item) => item.email);
|
||||
|
||||
expect(await emailsOf()).toEqual([]);
|
||||
|
||||
await request(server)
|
||||
.post('/api/messages')
|
||||
.set('Cookie', cookieOf(ME))
|
||||
.send({ to: [DOYUN], subject: '제목', body: '본문' })
|
||||
.expect(201);
|
||||
expect(await emailsOf()).toEqual(['new@client.e2e.arasystem.test']);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
/**
|
||||
* 서명 그림 테스트용 그림 파일 머리 — 테스트 전용 도우미 (앱 코드에서 import 하지 않는다).
|
||||
*
|
||||
* 형식 확인(signature-image-format.ts)은 파일 앞부분만 본다 — 여기서는 그 앞부분과, 크기를 맞출 채움 바이트만 만든다
|
||||
* (실제로 그릴 수 있는 그림일 필요는 없다).
|
||||
*
|
||||
* 위치: src 밖(test/) — cpu-time.ts 와 같은 이유로 빌드·커버리지에 들어가지 않는다.
|
||||
* 단위 테스트는 `@/../test/image-fixtures`, e2e 는 `./image-fixtures` 로 부른다.
|
||||
*/
|
||||
|
||||
/** PNG 서명 + IHDR(가로·세로·비트 깊이·색 형식) — CRC 는 보지 않으므로 비워 둔다. `size` 바이트가 되게 0 으로 채운다 */
|
||||
export function pngBytes(width: number, height: number, size = 33): Buffer {
|
||||
const data = Buffer.alloc(Math.max(size, 33));
|
||||
Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]).copy(data);
|
||||
data.writeUInt32BE(13, 8);
|
||||
data.write('IHDR', 12, 'ascii');
|
||||
data.writeUInt32BE(width, 16);
|
||||
data.writeUInt32BE(height, 20);
|
||||
data[24] = 8; // 비트 깊이
|
||||
data[25] = 6; // 색 형식 RGBA
|
||||
return data;
|
||||
}
|
||||
|
||||
/** GIF 머리 (논리 화면의 가로·세로, 리틀 엔디언) */
|
||||
export function gifBytes(
|
||||
width: number,
|
||||
height: number,
|
||||
version = 'GIF89a',
|
||||
): Buffer {
|
||||
const data = Buffer.alloc(13);
|
||||
data.write(version, 0, 'ascii');
|
||||
data.writeUInt16LE(width, 6);
|
||||
data.writeUInt16LE(height, 8);
|
||||
return data;
|
||||
}
|
||||
|
||||
/** SOI + APP0(JFIF) + (선택) 표지 앞 채움 바이트 + SOFn(세로·가로) + EOI */
|
||||
export function jpegBytes(
|
||||
width: number,
|
||||
height: number,
|
||||
options: { sof?: number; fill?: boolean } = {},
|
||||
): Buffer {
|
||||
const app0 = Buffer.from([
|
||||
0xff, 0xe0, 0x00, 0x10, 0x4a, 0x46, 0x49, 0x46, 0x00, 0x01, 0x01, 0x00,
|
||||
0x00, 0x01, 0x00, 0x01, 0x00, 0x00,
|
||||
]);
|
||||
const sof = Buffer.from([
|
||||
0xff,
|
||||
options.sof ?? 0xc0,
|
||||
0x00,
|
||||
0x11,
|
||||
0x08,
|
||||
(height >> 8) & 0xff,
|
||||
height & 0xff,
|
||||
(width >> 8) & 0xff,
|
||||
width & 0xff,
|
||||
0x03,
|
||||
0x01,
|
||||
0x22,
|
||||
0x00,
|
||||
0x02,
|
||||
0x11,
|
||||
0x01,
|
||||
0x03,
|
||||
0x11,
|
||||
0x01,
|
||||
]);
|
||||
return Buffer.concat([
|
||||
Buffer.from([0xff, 0xd8]),
|
||||
app0,
|
||||
...(options.fill ? [Buffer.from([0xff, 0xff])] : []),
|
||||
sof,
|
||||
Buffer.from([0xff, 0xd9]),
|
||||
]);
|
||||
}
|
||||
@@ -0,0 +1,251 @@
|
||||
<!doctype html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>메일 쓰기 · 임시 저장 · 자동 완성 · 서명</title>
|
||||
<script src="./support.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<x-dc>
|
||||
<helmet>
|
||||
<style>
|
||||
@font-face{font-family:'Pretendard';src:url('/_blob/16067b9aa5137abf808e3dbef30c42e0') format('woff2-variations'),url('/_blob/16067b9aa5137abf808e3dbef30c42e0') format('woff2');font-weight:45 920;font-style:normal;font-display:swap}
|
||||
body{margin:0}
|
||||
a{color:#0E7C86}a:hover{color:#0A5F67}
|
||||
:focus-visible{outline:2px solid #0E7C86;outline-offset:2px}
|
||||
.on-dark:focus-visible{outline-color:#9ED6DB}
|
||||
.ghost{background:transparent;color:#344054}
|
||||
.ghost:hover{background:#F2F4F7;color:#171C26}
|
||||
.row:hover{background:#F8F9FB}
|
||||
</style>
|
||||
</helmet>
|
||||
<div style="width: 1440px; height: 1400px; 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: 820px">
|
||||
<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">쓰기 화면에 더해지는 세 가지입니다. 서명을 고치는 화면은 「설정 · 메일 서명」 보드에 있습니다. 문구 중 오류는 에러 코드 사전에서 가져옵니다.</p>
|
||||
</div>
|
||||
|
||||
<div style="display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 20px; align-items: start">
|
||||
|
||||
<section style="display: flex; flex-direction: column; gap: 14px; padding: 20px; background: #FFFFFF; border: 1px solid #E6E8EC; border-radius: 12px">
|
||||
<div style="display: flex; flex-direction: column; gap: 2px">
|
||||
<h2 style="margin: 0; font-size: 14px; font-weight: 700">임시 저장 · 자동 저장</h2>
|
||||
<p style="margin: 0; font-size: 12px; color: #667085">입력이 3초 멈추면 · 10초에 한 번까지 · 화면을 떠날 때</p>
|
||||
</div>
|
||||
<p style="margin: 0; font-size: 12.5px; color: #4A5363">바꾼 내용이 있으면 임시보관함에 저장합니다. 아무것도 쓰지 않은 쓰기 화면은 저장하지 않습니다. 쓰기 화면의 「취소」는 「임시 저장」으로 바뀌고, 저장된 채로는 닫기(✕)·다른 메일함으로 가도 묻지 않습니다.</p>
|
||||
<div style="display: flex; flex-wrap: wrap; align-items: center; gap: 8px; padding: 10px 12px; border: 1px solid #E6E8EC; border-radius: 10px">
|
||||
<button type="button" style="display: inline-flex; align-items: center; gap: 8px; min-height: 44px; padding: 0 18px; border: 0; border-radius: 8px; background: #0E7C86; color: #FFFFFF; font: inherit; font-size: 13.5px; 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="22" y1="2" x2="11" y2="13"></line><polygon points="22 2 15 22 11 13 2 9 22 2"></polygon></svg>
|
||||
<span>보내기</span>
|
||||
</button>
|
||||
<button type="button" class="ghost" style="min-height: 44px; padding: 0 14px; border: 0; border-radius: 8px; font: inherit; font-weight: 500; cursor: pointer">임시 저장</button>
|
||||
<span role="status" style="display: inline-flex; align-items: center; gap: 6px; font-size: 12px; color: #667085">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 12a9 9 0 1 1-6.22-8.56"></path></svg>
|
||||
<span>저장하는 중…</span>
|
||||
</span>
|
||||
</div>
|
||||
<div style="display: flex; flex-wrap: wrap; align-items: center; gap: 8px; padding: 10px 12px; border: 1px solid #E6E8EC; border-radius: 10px">
|
||||
<button type="button" style="display: inline-flex; align-items: center; gap: 8px; min-height: 44px; padding: 0 18px; border: 0; border-radius: 8px; background: #0E7C86; color: #FFFFFF; font: inherit; font-size: 13.5px; 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="22" y1="2" x2="11" y2="13"></line><polygon points="22 2 15 22 11 13 2 9 22 2"></polygon></svg>
|
||||
<span>보내기</span>
|
||||
</button>
|
||||
<button type="button" class="ghost" style="min-height: 44px; padding: 0 14px; border: 0; border-radius: 8px; font: inherit; font-weight: 500; cursor: pointer">임시 저장</button>
|
||||
<span role="status" style="display: inline-flex; align-items: center; gap: 6px; font-size: 12px; color: #667085">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="#0E7C86" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="20 6 9 17 4 12"></polyline></svg>
|
||||
<span>오후 3:12에 임시 저장됨</span>
|
||||
</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section style="display: flex; flex-direction: column; gap: 14px; padding: 20px; background: #FFFFFF; border: 1px solid #E6E8EC; border-radius: 12px">
|
||||
<div style="display: flex; flex-direction: column; gap: 2px">
|
||||
<h2 style="margin: 0; font-size: 14px; font-weight: 700">임시 저장 · 실패</h2>
|
||||
<p style="margin: 0; font-size: 12px; color: #667085">에러 코드 · MAIL_001 · MAIL_003</p>
|
||||
</div>
|
||||
<p style="margin: 0; font-size: 12.5px; color: #4A5363">자동 저장이 실패하면 알림 없이 이 자리에만 보이고 조금 뒤 다시 시도합니다. 「임시 저장」을 눌러 실패하면 사전 문구 알림도 띄웁니다.</p>
|
||||
<div style="display: flex; flex-wrap: wrap; align-items: center; gap: 8px; padding: 10px 12px; border: 1px solid #E6E8EC; border-radius: 10px">
|
||||
<button type="button" style="display: inline-flex; align-items: center; gap: 8px; min-height: 44px; padding: 0 18px; border: 0; border-radius: 8px; background: #0E7C86; color: #FFFFFF; font: inherit; font-size: 13.5px; 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="22" y1="2" x2="11" y2="13"></line><polygon points="22 2 15 22 11 13 2 9 22 2"></polygon></svg>
|
||||
<span>보내기</span>
|
||||
</button>
|
||||
<button type="button" class="ghost" style="min-height: 44px; padding: 0 14px; border: 0; border-radius: 8px; font: inherit; font-weight: 500; cursor: pointer">임시 저장</button>
|
||||
<span role="status" style="display: inline-flex; align-items: center; gap: 6px; font-size: 12px; color: #B42318">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><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>
|
||||
<button type="button" class="ghost" style="min-height: 44px; padding: 0 10px; border: 0; border-radius: 8px; color: #0E7C86; font: inherit; font-size: 12.5px; font-weight: 600; cursor: pointer">다시 시도</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">에러 코드 · MAIL_008</p>
|
||||
</div>
|
||||
<p style="margin: 0; font-size: 12.5px; color: #4A5363">같은 저장본을 다른 창·기기에서 먼저 고쳤거나 지웠으면, 막지 않고 지금 내용을 새 저장본으로 남깁니다 — 어느 쪽도 지우지 않으므로 임시보관함에 두 통이 보입니다.</p>
|
||||
<div style="display: flex; flex-wrap: wrap; align-items: center; gap: 8px; padding: 10px 12px; border: 1px solid #E6E8EC; border-radius: 10px">
|
||||
<button type="button" style="display: inline-flex; align-items: center; gap: 8px; min-height: 44px; padding: 0 18px; border: 0; border-radius: 8px; background: #0E7C86; color: #FFFFFF; font: inherit; font-size: 13.5px; 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="22" y1="2" x2="11" y2="13"></line><polygon points="22 2 15 22 11 13 2 9 22 2"></polygon></svg>
|
||||
<span>보내기</span>
|
||||
</button>
|
||||
<button type="button" class="ghost" style="min-height: 44px; padding: 0 14px; border: 0; border-radius: 8px; font: inherit; font-weight: 500; cursor: pointer">임시 저장</button>
|
||||
<span role="status" style="display: inline-flex; align-items: flex-start; gap: 6px; font-size: 12px; color: #7A5200">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" style="flex: 0 0 auto; margin-top: 2px"><circle cx="12" cy="12" r="10"></circle><line x1="12" y1="16" x2="12" y2="12"></line><line x1="12" y1="8" x2="12.01" y2="8"></line></svg>
|
||||
<span>다른 곳에서 바뀐 저장본이 있어 새로 저장했습니다 · 오후 3:14</span>
|
||||
</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section style="display: flex; flex-direction: column; gap: 14px; padding: 20px; background: #FFFFFF; border: 1px solid #E6E8EC; border-radius: 12px">
|
||||
<div style="display: flex; flex-direction: column; gap: 2px">
|
||||
<h2 style="margin: 0; font-size: 14px; font-weight: 700">임시보관함 목록</h2>
|
||||
<p style="margin: 0; font-size: 12px; color: #667085">누르면 쓰기 화면에서 이어 씁니다</p>
|
||||
</div>
|
||||
<p style="margin: 0; font-size: 12.5px; color: #4A5363">보낸 사람 자리에 받는 사람을 보이고, 시각은 마지막으로 저장한 때입니다. 보내면 임시보관함에서 사라집니다.</p>
|
||||
<ul aria-label="임시보관함" style="list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; border: 1px solid #E6E8EC; border-radius: 10px; overflow: hidden">
|
||||
<li><a href="Compose.dc.html" class="row" style="display: flex; flex-direction: column; gap: 2px; padding: 9px 12px; border-bottom: 1px solid #EEF0F3; color: #171C26; text-decoration: none">
|
||||
<span style="display: flex; justify-content: space-between; gap: 8px"><span style="font-weight: 500">이도윤</span><span style="font-size: 12px; color: #667085; font-variant-numeric: tabular-nums">오후 3:12</span></span>
|
||||
<span style="display: flex; align-items: center; gap: 6px">
|
||||
<span style="flex: 1 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: #344054">Re: 메일 서버 이전 일정 공유</span>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="#667085" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" role="img" aria-label="첨부 있음"><path d="M21.44 11.05l-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48"></path></svg>
|
||||
</span>
|
||||
</a></li>
|
||||
<li><a href="Compose.dc.html" class="row" style="display: flex; flex-direction: column; gap: 2px; padding: 9px 12px; border-bottom: 1px solid #EEF0F3; color: #171C26; text-decoration: none">
|
||||
<span style="display: flex; justify-content: space-between; gap: 8px"><span style="font-weight: 500">박준호, 인프라팀</span><span style="font-size: 12px; color: #667085; font-variant-numeric: tabular-nums">어제</span></span>
|
||||
<span style="overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: #344054">10월 장애 보고서 초안</span>
|
||||
</a></li>
|
||||
<li><a href="Compose.dc.html" class="row" style="display: flex; flex-direction: column; gap: 2px; padding: 9px 12px; color: #171C26; text-decoration: none">
|
||||
<span style="display: flex; justify-content: space-between; gap: 8px"><span style="color: #667085">(받는 사람 없음)</span><span style="font-size: 12px; color: #667085; font-variant-numeric: tabular-nums">10월 24일</span></span>
|
||||
<span style="color: #667085">(제목 없음)</span>
|
||||
</a></li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section style="display: flex; flex-direction: column; gap: 14px; padding: 20px; background: #FFFFFF; border: 1px solid #E6E8EC; border-radius: 12px">
|
||||
<div style="display: flex; flex-direction: column; gap: 2px">
|
||||
<h2 style="margin: 0; font-size: 14px; font-weight: 700">임시 저장본 삭제</h2>
|
||||
<p style="margin: 0; font-size: 12px; color: #667085">쓰기 화면 머리의 휴지통 단추</p>
|
||||
</div>
|
||||
<p style="margin: 0; font-size: 12.5px; color: #4A5363">삭제는 임시 저장본을 휴지통으로 옮기고 쓰기 화면을 닫습니다 — 알림의 「되돌리기」로 다시 꺼냅니다. 닫기(✕)는 저장한 채 닫습니다.</p>
|
||||
<div style="display: flex; align-items: center; gap: 2px; min-height: 52px; box-sizing: border-box; padding: 4px 6px 4px 16px; border: 1px solid #E6E8EC; border-radius: 10px">
|
||||
<span style="flex: 1 1 auto; font-size: 15px; font-weight: 700; letter-spacing: -0.01em">답장</span>
|
||||
<button type="button" class="ghost" aria-label="이 메일 삭제" title="삭제" style="width: 44px; height: 44px; display: inline-flex; align-items: center; justify-content: center; padding: 0; border: 0; border-radius: 8px; cursor: pointer">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="3 6 5 6 21 6"></polyline><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path></svg>
|
||||
</button>
|
||||
<button type="button" class="ghost" aria-label="쓰기 닫기" title="닫기" style="width: 44px; height: 44px; display: inline-flex; align-items: center; justify-content: center; padding: 0; border: 0; border-radius: 8px; cursor: pointer">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line></svg>
|
||||
</button>
|
||||
</div>
|
||||
<div role="status" style="display: flex; align-items: center; gap: 10px; padding: 6px 6px 6px 14px; border-radius: 10px; background: #0B3640; color: #FFFFFF; box-shadow: 0 10px 28px rgba(16, 24, 28, 0.22)">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#9FE0B5" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" style="flex: 0 0 auto"><polyline points="20 6 9 17 4 12"></polyline></svg>
|
||||
<span style="flex: 1 1 auto; font-weight: 500">임시 저장한 메일을 휴지통으로 옮겼습니다.</span>
|
||||
<button type="button" class="on-dark" style="flex: 0 0 auto; min-height: 44px; padding: 0 10px; border: 0; border-radius: 6px; background: transparent; color: #9ED6DB; font: inherit; font-weight: 600; cursor: pointer">되돌리기</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section style="display: flex; flex-direction: column; gap: 14px; padding: 20px; background: #FFFFFF; border: 1px solid #E6E8EC; border-radius: 12px">
|
||||
<div style="display: flex; flex-direction: column; gap: 2px">
|
||||
<h2 style="margin: 0; font-size: 14px; font-weight: 700">나가기 확인 · 저장하지 못했을 때</h2>
|
||||
<p style="margin: 0; font-size: 12px; color: #667085">자동 저장이 되는 동안에는 묻지 않습니다</p>
|
||||
</div>
|
||||
<p style="margin: 0; font-size: 12.5px; color: #4A5363">저장이 실패했거나 아직 저장하지 못한 내용이 있을 때만 지금의 나가기 확인을 띄웁니다. 단추와 키는 그대로입니다.</p>
|
||||
<dialog open aria-labelledby="unsaved-title" aria-describedby="unsaved-text" style="position: static; margin: 0; padding: 18px 18px 14px; width: auto; border: 1px solid #E6E8EC; border-radius: 12px; background: #FFFFFF; color: #171C26; box-shadow: 0 14px 36px rgba(16, 24, 28, 0.14); display: flex; flex-direction: column; gap: 8px">
|
||||
<h3 id="unsaved-title" style="margin: 0; font-size: 15px; font-weight: 700">저장하지 못한 내용이 있습니다</h3>
|
||||
<p id="unsaved-text" style="margin: 0; color: #4A5363">이 화면을 나가면 마지막으로 바꾼 내용이 사라집니다.</p>
|
||||
<div style="display: flex; justify-content: flex-end; gap: 8px; margin-top: 4px">
|
||||
<button type="button" style="min-height: 44px; padding: 0 16px; border: 0; border-radius: 8px; background: #0E7C86; color: #FFFFFF; font: inherit; font-weight: 600; cursor: pointer">계속 쓰기</button>
|
||||
<button type="button" class="ghost" style="min-height: 44px; padding: 0 14px; border: 0; border-radius: 8px; font: inherit; font-weight: 500; cursor: pointer">나가기</button>
|
||||
</div>
|
||||
</dialog>
|
||||
</section>
|
||||
|
||||
<section style="display: flex; flex-direction: column; gap: 14px; padding: 20px; background: #FFFFFF; border: 1px solid #E6E8EC; border-radius: 12px">
|
||||
<div style="display: flex; flex-direction: column; gap: 2px">
|
||||
<h2 style="margin: 0; font-size: 14px; font-weight: 700">받는 사람 자동 완성</h2>
|
||||
<p style="margin: 0; font-size: 12px; color: #667085">받는 사람 · 참조 · 숨은참조 공통</p>
|
||||
</div>
|
||||
<p style="margin: 0; font-size: 12.5px; color: #4A5363">이름이나 주소를 한 글자 이상 치면 사내(웹메일에 로그인한 적 있는 직원)와 최근 주고받은 주소에서 찾아 보입니다. 초성으로도 찾습니다(ㄷㅇ → 도윤). 이미 넣은 주소는 빼고 최대 8개, 맞는 글자는 굵게 보입니다.</p>
|
||||
<div style="display: flex; flex-direction: column; gap: 4px">
|
||||
<div style="display: flex; align-items: center; gap: 6px 12px; min-height: 44px; box-sizing: border-box; padding: 4px 12px; border-bottom: 2px solid #0E7C86; background: #FFFFFF">
|
||||
<label for="assist-to" style="flex: 0 0 auto; font-size: 12.5px; font-weight: 600; color: #4A5363">받는 사람</label>
|
||||
<span style="display: inline-flex; align-items: center; min-height: 28px; padding: 0 8px; border-radius: 4px; background: #F2F4F7; color: #171C26; font-size: 13px">박준호</span>
|
||||
<input id="assist-to" type="text" value="도윤" role="combobox" aria-autocomplete="list" aria-expanded="true" aria-controls="assist-list" aria-activedescendant="assist-opt-1" style="flex: 1 1 80px; min-width: 60px; min-height: 34px; border: 0; outline: none; font: inherit; color: #171C26; background: transparent">
|
||||
</div>
|
||||
<div id="assist-list" role="listbox" 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.12)">
|
||||
<div role="group" aria-label="사내" style="display: flex; flex-direction: column">
|
||||
<span aria-hidden="true" style="padding: 4px 8px 2px; font-size: 12px; font-weight: 600; color: #667085">사내</span>
|
||||
<div id="assist-opt-1" role="option" aria-selected="true" style="display: flex; align-items: baseline; gap: 8px; min-height: 40px; box-sizing: border-box; padding: 9px 8px; border-radius: 6px; background: #E3F2F3">
|
||||
<span style="flex: 0 0 auto; color: #171C26">이<strong>도윤</strong></span>
|
||||
<span style="min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 12px; color: #4A5363"><strong>doyun</strong>.lee@arasystem.it.kr</span>
|
||||
</div>
|
||||
<div role="option" aria-selected="false" style="display: flex; align-items: baseline; gap: 8px; min-height: 40px; box-sizing: border-box; padding: 9px 8px; border-radius: 6px">
|
||||
<span style="flex: 0 0 auto; color: #171C26">김<strong>도윤</strong></span>
|
||||
<span style="min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 12px; color: #4A5363"><strong>doyun</strong>.kim@arasystem.it.kr</span>
|
||||
</div>
|
||||
</div>
|
||||
<div role="group" aria-label="최근 주고받은 주소" style="display: flex; flex-direction: column; margin-top: 4px; padding-top: 4px; border-top: 1px solid #EEF0F3">
|
||||
<span aria-hidden="true" style="padding: 4px 8px 2px; font-size: 12px; font-weight: 600; color: #667085">최근 주고받은 주소</span>
|
||||
<div role="option" aria-selected="false" style="display: flex; align-items: baseline; gap: 8px; min-height: 40px; box-sizing: border-box; padding: 9px 8px; border-radius: 6px">
|
||||
<span style="flex: 0 0 auto; color: #171C26">최<strong>도윤</strong></span>
|
||||
<span style="min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 12px; color: #4A5363">[협력사 주소]</span>
|
||||
</div>
|
||||
</div>
|
||||
<p style="margin: 4px 8px 2px; font-size: 12px; color: #667085">↑↓ 고르기 · Enter · Tab 넣기 · Esc 닫기</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>
|
||||
<p style="margin: 0; font-size: 12.5px; color: #4A5363">친 글자가 완전한 메일 주소면 추천을 미리 고르지 않습니다 — Enter 는 친 주소를 그대로 넣습니다. 화살표로 고른 뒤에만 추천이 들어갑니다.</p>
|
||||
<div style="display: flex; flex-direction: column; gap: 4px">
|
||||
<div style="display: flex; align-items: center; gap: 6px 12px; min-height: 44px; box-sizing: border-box; padding: 4px 12px; border-bottom: 2px solid #0E7C86; background: #FFFFFF">
|
||||
<label for="assist-to-full" style="flex: 0 0 auto; font-size: 12.5px; font-weight: 600; color: #4A5363">받는 사람</label>
|
||||
<input id="assist-to-full" type="text" value="doyun.lee@partner.example" role="combobox" aria-autocomplete="list" aria-expanded="true" aria-controls="assist-list-full" style="flex: 1 1 120px; min-width: 80px; min-height: 34px; border: 0; outline: none; font: inherit; color: #171C26; background: transparent">
|
||||
</div>
|
||||
<div id="assist-list-full" role="listbox" 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.12)">
|
||||
<div role="option" aria-selected="false" style="display: flex; align-items: baseline; gap: 8px; min-height: 40px; box-sizing: border-box; padding: 9px 8px; border-radius: 6px">
|
||||
<span style="flex: 0 0 auto; color: #171C26">이도윤</span>
|
||||
<span style="min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 12px; color: #4A5363"><strong>doyun.lee@</strong>arasystem.it.kr</span>
|
||||
</div>
|
||||
<p style="margin: 4px 8px 2px; font-size: 12px; color: #667085">Enter 는 친 주소를 그대로 넣습니다</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>
|
||||
<p style="margin: 0; font-size: 12.5px; color: #4A5363">쓰기 화면을 열 때 서명을 보통 글로 넣어 둡니다 — 그 메일에서 바로 고치거나 지울 수 있고, 임시 저장에도 그대로 남습니다. 서명 그림은 보낼 때 메일 안에 함께 실어 보냅니다.</p>
|
||||
<div style="box-sizing: border-box; padding: 14px 16px; border: 1px solid #E6E8EC; border-radius: 10px; font-family: 'Malgun Gothic', '맑은 고딕', 'Apple SD Gothic Neo', sans-serif; font-size: 14px; line-height: 1.75; color: #1F2733">
|
||||
<p style="margin: 0">도윤님, 확인했습니다.</p>
|
||||
<p style="margin: 0"><br></p>
|
||||
<p style="margin: 0"><strong>김아라</strong> · 인프라팀</p>
|
||||
<p style="margin: 0">ARA SYSTEM · [전화번호]</p>
|
||||
<p style="margin: 6px 0 0"><span role="img" aria-label="[회사 로고]" style="display: inline-flex; align-items: center; justify-content: center; box-sizing: border-box; width: 144px; height: 43px; border: 1px dashed #98A2B3; border-radius: 4px; color: #667085; font-family: Pretendard, -apple-system, 'Apple SD Gothic Neo', 'Malgun Gothic', system-ui, sans-serif; font-size: 12px">[회사 로고]</span></p>
|
||||
<blockquote style="margin: 16px 0 0; padding: 2px 0 2px 14px; border-left: 2px solid #D0D5DD; color: #4A5363">
|
||||
<p style="margin: 0; font-size: 12.5px; font-weight: 600">원본 메일</p>
|
||||
<p style="margin: 0; font-size: 12.5px">보낸 사람 이도윤 · 2026. 10. 26. (월) 오전 10:42</p>
|
||||
<p style="margin: 4px 0 0">김아라님, 안녕하세요.</p>
|
||||
</blockquote>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</x-dc>
|
||||
<script type="text/x-dc" data-dc-script data-props='{"$preview":{"width":1440,"height":1400}}'>
|
||||
class Component extends DCLogic {
|
||||
renderVals() {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,11 +1,12 @@
|
||||
# 웹메일 디자인 원본 (1단계)
|
||||
# 웹메일 디자인 원본 (1·2단계)
|
||||
|
||||
확정된 디자인 캔버스의 원본이다. 화면 구현의 기준으로 쓴다.
|
||||
|
||||
- 캔버스: https://claude.ai/artifact/WJvVsKvXepxn3p75CGJAr2 (확정본: 2026-10-27, 버전 14)
|
||||
- 캔버스: https://claude.ai/artifact/WJvVsKvXepxn3p75CGJAr2 (확정본: 2026-10-27, 버전 19)
|
||||
- 화면: `Login`(로그인) · `Main`(분할 보기: 메일함·목록·본문) · `MainList`(목록 보기: 게시판형 표) · `MainListRead`(목록 보기에서 연 메일)
|
||||
· `Compose`(메일 쓰기 — 서식 편집기) · `ComposeEditor`(메일 쓰기 · 편집기 상태: 포커스·툴바·메뉴·팝오버·인용·이미지 첨부)
|
||||
· `States`(오류·빈 상태·피드백 모음)
|
||||
· 2단계: `Settings`(설정 · 메일 서명) · `ComposeAssist`(메일 쓰기 · 임시 저장 · 받는 사람 자동 완성 · 서명) — 아래 "2단계"
|
||||
- 파일 형식은 캔버스 전용(`<x-dc>` 마크업 + `DCLogic` 클래스)이다. 그대로 쓰지 말고 **구조·간격·색·문구의 기준**으로만 본다.
|
||||
`Compose` 의 툴바·메뉴는 눌러 볼 수 있는 시안이다 — 버튼 상태만 바뀌고 본문 글자에 서식이 입혀지지는 않는다.
|
||||
|
||||
@@ -157,10 +158,12 @@
|
||||
떠난 화면이나 새로 연 쓰기 화면은 그 결과로 바뀌지 않는다 (2026-10-27 리뷰 반영).
|
||||
단 보내는 중에 탭을 닫거나 새로고침하면 브라우저 기본 확인창은 뜬다 — 요청이 끊길 수 있고 결과를 알릴 화면이 남지 않기 때문이다.
|
||||
- 맥의 ⌘[ 를 본문 밖에서 눌러 브라우저 "뒤로"가 되어도 이 확인에 걸린다.
|
||||
- **2단계(임시 저장)부터는 저장된 채로는 묻지 않는다** — 저장하지 못한 내용이 있을 때만 이 확인을 띄우고 제목 · 글이 바뀐다
|
||||
(아래 "2단계 › 임시 저장"). 단추 · 키 · 묻지 않는 경우는 그대로다.
|
||||
|
||||
### 본문 이미지 → 첨부 파일
|
||||
|
||||
- 본문에는 이미지를 넣지 않는다(사용자 확정). 툴바에 이미지 버튼이 없다.
|
||||
- 본문에는 이미지를 넣지 않는다(사용자 확정). 툴바에 이미지 버튼이 없다. **서명 그림만 예외**다 (아래 "2단계 › 서명").
|
||||
- 본문에 붙여 넣거나(Ctrl+V) 끌어다 놓은 이미지는 첨부 파일로 올린다. 끌어다 놓은 다른 파일도 첨부로 올린다(쓰기 화면 어디에 놓아도 같다).
|
||||
한도는 첨부와 같다 — 넘치면 첨부 목록에 실패로 보인다(`States` 의 "첨부 업로드").
|
||||
- 클립보드에 **글이 함께 있으면 글을 붙여 넣는다** — 워드 · 엑셀에서 복사하면 고른 부분의 그림이 글과 함께 따라오기 때문이다.
|
||||
@@ -171,6 +174,47 @@
|
||||
받지 않는 곳에 놓으면 브라우저가 그 파일을 열어 쓰던 메일을 잃기 때문이다 (2026-10-27 구현하며 정함).
|
||||
- 이미지를 첨부로 돌렸을 때만 성공 알림 "이미지는 본문 대신 첨부 파일로 올렸습니다."를 띄운다(성공 알림과 같은 모양 · 5초).
|
||||
|
||||
## 2단계 — 임시 저장 · 서명 · 받는 사람 자동 완성 (2026-10-27 확정 — `Settings` · `ComposeAssist`)
|
||||
|
||||
### 임시 저장
|
||||
|
||||
- **자동 저장**: 바꾼 내용이 있으면 입력이 3초 멈출 때(10초에 한 번까지)와 화면을 떠날 때 임시보관함에 저장한다.
|
||||
아무것도 쓰지 않은 쓰기 화면은 저장하지 않는다. 저장은 한 번에 하나씩이고, 보내기 전에는 진행 중인 저장이 끝나기를 기다린다.
|
||||
- **아래 단추 줄**: [보내기] [임시 저장] 뒤에 상태 글(12px) — "저장하는 중…"(#667085) · "오후 3:12에 임시 저장됨"(✓ #0E7C86) ·
|
||||
실패 "임시 저장하지 못했습니다"(#B42318) + [다시 시도]. 「취소」는 없앤다 — 닫기(✕)가 저장한 채 닫는다.
|
||||
- 자동 저장이 실패하면 알림 없이 그 자리에만 보이고 조금 뒤 다시 시도한다. 「임시 저장」을 눌러 실패하면 사전 문구 알림도 띄운다.
|
||||
- 같은 저장본을 다른 창 · 기기에서 먼저 고쳤거나 지웠으면(`MAIL_008`, 휴지통으로 옮겼으면 PUT 의 404 `RES_001`) 막지 않고 지금 내용을 새 저장본으로 남긴다 —
|
||||
상태 글 "다른 곳에서 바뀐 저장본이 있어 새로 저장했습니다 · 오후 3:14"(정보 아이콘, #7A5200). 임시보관함에 두 통이 남는다.
|
||||
- **삭제**: 쓰기 화면 머리의 휴지통 단추(44 · "이 메일 삭제") — 임시 저장본을 휴지통으로 옮기고 닫는다. 알림 "임시 저장한 메일을 휴지통으로 옮겼습니다."
|
||||
+ [되돌리기](임시보관함으로 되돌린다). 저장본이 아직 없으면 그냥 닫는다.
|
||||
- **임시보관함 목록**: 보낸 사람 자리에 받는 사람(없으면 "(받는 사람 없음)"), 제목이 없으면 "(제목 없음)" — 둘 다 #667085.
|
||||
시각은 마지막으로 저장한 때. 누르면 읽기 화면이 아니라 쓰기 화면에서 이어 쓴다. 보내면 임시보관함에서 사라진다.
|
||||
- **나가기 확인**: 저장된 채로는 묻지 않는다. 떠나기 전에 남은 변경을 먼저 저장하고, 저장하지 못한 내용이 있을 때만
|
||||
제목 "저장하지 못한 내용이 있습니다", 글 "이 화면을 나가면 마지막으로 바꾼 내용이 사라집니다."로 묻는다.
|
||||
|
||||
### 서명
|
||||
|
||||
- **설정 › 메일 › 서명** — 앱 레일의 "설정"에서 연다. 왼쪽 패널(208 · #F8F9FB)의 "메일" 묶음에 "서명", 오른쪽은 콘텐츠 열(최대 800).
|
||||
- 서명 칸: 굵게 · 기울임 · 밑줄 · 글자 색 · 링크 · 그림. 글꼴은 쓰기 화면 본문과 같은 맑은 고딕 14px · 줄 간격 1.75.
|
||||
- **그림**: 그림 단추 · 붙여 넣기 · 끌어다 놓기로 넣는다 — PNG · JPG · GIF, 한 장 300KB 이하, 3장까지.
|
||||
그림을 고르면 칸 아래에 크기 줄(작게 96px · 보통 144px · 원본 — 최대 600px · 그림 빼기). 메일 안에 함께 실어 보내므로
|
||||
받는 쪽에서 외부 이미지처럼 막히지 않는다. 지금 있는 흰 로고는 흰 메일 바탕에서 보이지 않으니 서명에는 색이 있는 로고를 쓴다.
|
||||
- 서명을 넣을 메일: 새 메일 · 답장 · 전달(인용한 원문 위) — 둘 다 기본으로 켠다. 미리 보기에서 [새 메일 | 답장]을 바꿔 본다.
|
||||
- [저장] → "저장했습니다. 새로 여는 쓰기 화면부터 들어갑니다."(#067647).
|
||||
- 쓰기 화면을 열 때 서명을 보통 글로 넣어 둔다 — 새 메일은 본문 끝(빈 줄 하나 뒤), 답장 · 전달은 인용 위. 그 메일에서 고치거나 지울 수 있고
|
||||
임시 저장에도 남는다. 임시 저장본을 이어 쓸 때는 서명을 다시 넣지 않는다. 본문에 붙여 넣은 다른 그림은 지금처럼 첨부로 올린다.
|
||||
- 서명 그림처럼 **본문 안에 그림으로 보인 부분은 첨부로 보이지 않는다** — 읽기 화면의 첨부 목록과 목록의 첨부 클립에서 뺀다
|
||||
(받은 메일의 본문 그림도 같다). 전달할 때는 원문 그림을 잃지 않게 원본 첨부로 함께 붙는다 (인용 본문에서는 그림이 빠지기 때문이다).
|
||||
|
||||
### 받는 사람 자동 완성
|
||||
|
||||
- 받는 사람 · 참조 · 숨은참조에서 한 글자 이상 치면 칸 바로 아래에 추천 목록(최대 8개) — "사내"(웹메일에 로그인한 적 있는 직원) ·
|
||||
"최근 주고받은 주소" 두 묶음(묶음 이름 12px #667085 굵게). 줄은 높이 40 · 이름(맞는 글자 굵게) + 주소(12px #4A5363), 고른 줄은 #E3F2F3.
|
||||
- 이미 넣은 주소는 빼고, 초성으로도 찾는다(ㄷㅇ → 도윤). 목록 아래 안내 "↑↓ 고르기 · Enter · Tab 넣기 · Esc 닫기".
|
||||
- 친 글자가 완전한 메일 주소면 추천을 미리 고르지 않는다 — Enter 는 친 주소를 그대로 넣고("Enter 는 친 주소를 그대로 넣습니다"),
|
||||
화살표로 고른 뒤에만 추천이 들어간다. 엉뚱한 사람이 받는 사람에 들어가지 않게 하기 위해서다.
|
||||
- 쓰기 화면을 열 때 추천 목록을 한 번 받아 두고 치는 대로 바로 거른다 — 모자랄 때만 서버에 다시 묻는다.
|
||||
|
||||
## 구현하며 정한 보완 (2026-10-26 — 캔버스에는 아직 없다)
|
||||
|
||||
- 찾지 못한 첨부(보관 시간이 지난 업로드·지워진 원본)는 실패로 표시하고, 전달 원본에는 "빼기"만 둔다(다시 올릴 수 없다).
|
||||
@@ -181,3 +225,34 @@
|
||||
- 서버가 HTML 을 정화 한도 때문에 비웠을 때(`isHtmlBodyOmitted`): 글자 본문이 있으면 "본문이 복잡해 서식 없이 글자만 표시합니다.",
|
||||
없으면 "본문이 너무 크거나 복잡해 표시할 수 없습니다."
|
||||
- 보여 주는 본문이 읽기 한도(2MB)에서 잘렸을 때(`isBodyTruncated`): "본문이 길어 앞부분만 표시합니다."
|
||||
|
||||
### 2단계 구현하며 정한 보완 (2026-10-27 — 캔버스에는 아직 없다)
|
||||
|
||||
- **임시 저장**
|
||||
- 쉬지 않고 쓰는 동안에도 처음 바꾼 때(또는 마지막 저장을 시작한 때)부터 30초가 지나면 저장한다 — 멈춤을 기다리다 오래 쓴 글을 잃지 않게.
|
||||
- 저장할 때마다 주소를 `/mail/compose?draft=<새 id>` 로 맞춘다 — 새로고침하거나 다시 열면 마지막 저장본을 이어 쓴다.
|
||||
떠나기 전 저장은 주소를 바꾸지 않는다(가던 이동이 취소되므로). 대신 같은 탭에서 옛 `?draft=` 로 돌아오면(뒤로 가기) 마지막 저장본을 연다
|
||||
— 저장본이 바뀐 차례는 탭 메모리에만 두고 로그아웃 · 세션 만료 때 지운다.
|
||||
- 자동 저장 실패 가운데 메일 서버 장애·용량·요청 과다는 15초 → 30초 → 60초 뒤 저절로 다시 한다. 그 밖의 실패는 내용을 고치면 다시 저장한다.
|
||||
- 「임시 저장」은 아직 한 번도 저장하지 않았으면 바꾼 것이 없어도 저장한다 (빈 메일 · 미리 채운 답장 포함).
|
||||
- 형식이 틀린 주소 · 사전 검사에 걸린 첨부는 저장하지 않는다 (칸에는 남는다). 올리는 중인 첨부는 "저장하지 못한 내용"으로 친다.
|
||||
- 찾지 못한 첨부(`MAIL_004`)가 남아 있는 동안은 저장하지 않는다(「임시 저장」 · [다시 시도] 포함) — 첨부가 빠진 판으로 멀쩡한 저장본을 덮지 않게.
|
||||
그 첨부를 빼면 다시 저장한다.
|
||||
- 본문이 한도를 넘으면 보내기처럼 인용을 줄여 저장한다(편집기 화면은 그대로). 인용을 다 줄여도 넘으면 저장하지 않고 보내기와 같은 안내를
|
||||
본문 아래에 보이며, 내용을 고칠 때까지 다시 시도하지 않는다.
|
||||
- 다른 메일 프로그램이 만든 저장본에 우리가 실을 수 없는 첨부(허용 목록 밖 · 개수 · 합계 초과)가 있으면, 첫 저장은 원본을 지우지 않고 새 저장본으로 남긴다
|
||||
— 원본은 첨부째 임시보관함에 그대로 있다.
|
||||
- 떠나기 전 저장은 5초까지만 기다린다. 끝나지 않으면 "저장하지 못한 내용이 있습니다"로 묻고, 묻는 사이에 저장이 끝나면 그대로 떠난다.
|
||||
- 보내는 중에 화면을 떠났는데 발송이 실패하면, 보내려던 내용을 임시 저장본으로 한 번 남긴다 (실패 알림은 그대로).
|
||||
- 알려진 한계: 떠나기 전 저장이 첫 저장이었던 새 메일 · 답장 · 전달은, 뒤로 가기로 돌아오면 새 양식이 열린다 (저장본은 임시보관함에 있다).
|
||||
- 휴지통 단추는 저장본을 휴지통 메일함으로 **옮긴다**(삭제 요청이 아니다 — 다른 창에서 먼저 휴지통으로 옮긴 것을 영구 삭제하지 않게).
|
||||
[되돌리기]로 되돌리면 "임시보관함으로 되돌렸습니다." 알림을 띄운다. 휴지통 메일함을 알 수 없으면 지우지 않고 실패로 알린다.
|
||||
이미 없는 저장본이면 알림 없이 닫는다. 단추가 있는 알림은 10초 보인다. 알림은 마우스 · 초점이 그 위에 있는 동안 사라지지 않고,
|
||||
놓으면 그 알림의 시간을 처음부터 다시 센다.
|
||||
- 이어 쓰는 저장본의 제목 줄: 답장 대상이 있으면 답장, 제목이 `Fwd:`·`FW:`·`전달:` 로 시작하면 전달, 그 밖은 새 메일.
|
||||
- **서명**
|
||||
- 서명을 바꾸고 저장하지 않은 채 떠나면 묻는다 — 제목 "저장하지 않은 서명이 있습니다", 글 "이 화면을 나가면 바꾼 서명이 사라집니다.", [계속 고치기] [나가기].
|
||||
- 서명 그림 3장은 서명 칸에 있는 그림 수로 센다. 미리 보기의 답장 원문 자리는 예시 이름 대신 "(보낸 사람)" 같은 괄호 글이다.
|
||||
- 설정 화면의 미리 보기 전환 단추와 그림 크기 줄(작게 · 보통 · 원본 · 그림 빼기, 36px)은 목록의 보기 전환처럼 밀도 예외다 (캔버스 확정값).
|
||||
- **받는 사람 자동 완성**: Tab 은 고른 추천을 넣고 그 칸에 머문다(고른 것이 없으면 원래 Tab). 화살표는 끝에서 처음으로 돌고, 마우스를 올린 줄이 골라진다.
|
||||
초성 · 자모만 친 글자는 서버에 묻지 않는다(받아 둔 목록에서만 거른다).
|
||||
|
||||
@@ -0,0 +1,270 @@
|
||||
<!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}
|
||||
/* 서명 편집 칸: 쓰기 화면 본문과 같은 글꼴 · 문단 여백 0 */
|
||||
.se p{margin:0}
|
||||
.se:focus,.se:focus-visible{outline:none}
|
||||
.sig-box:has(.se:focus){border-color:#0E7C86 !important;box-shadow:0 0 0 1px #0E7C86}
|
||||
.tb{background:transparent;color:#344054}
|
||||
.tb:hover{background:#F2F4F7;color:#171C26}
|
||||
/* 미리 보기 전환 (분할 | 목록 전환과 같은 모양) */
|
||||
.seg{background:transparent;color:#4A5363}
|
||||
.seg[aria-pressed="true"]{background:#FFFFFF;color:#171C26;box-shadow:0 1px 2px rgba(16,24,28,.14)}
|
||||
</style>
|
||||
</helmet>
|
||||
<div style="min-height: 100vh; display: flex; background: #FFFFFF; color: #171C26; font-family: Pretendard, -apple-system, 'Apple SD Gothic Neo', 'Malgun Gothic', system-ui, sans-serif; font-size: 13px; line-height: 1.5">
|
||||
|
||||
<nav aria-label="사내 시스템" style="flex: 0 0 72px; box-sizing: border-box; display: flex; flex-direction: column; align-items: center; gap: 14px; padding: 10px 0 12px; background: #0B3640">
|
||||
<a href="Main.dc.html" style="display: inline-flex; align-items: center; justify-content: center; width: 60px; min-height: 44px; border-radius: 10px; text-decoration: none">
|
||||
<img src="/_blob/2b9ef4349333debc969432c859eaf1bf" alt="ARA SYSTEM" width="48" height="17" style="display: block; width: 48px; height: auto">
|
||||
</a>
|
||||
<ul style="list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 4px">
|
||||
<li>
|
||||
<a href="Main.dc.html" style="display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 3px; width: 60px; min-height: 56px; border-radius: 10px; color: #A3C2C6; font-size: 11px; font-weight: 500; text-decoration: none">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z"></path><polyline points="22,6 12,13 2,6"></polyline></svg>
|
||||
<span>메일</span>
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="#" style="display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 3px; width: 60px; min-height: 56px; border-radius: 10px; color: #A3C2C6; font-size: 11px; font-weight: 500; text-decoration: none">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="4" width="18" height="18" rx="2" ry="2"></rect><line x1="16" y1="2" x2="16" y2="6"></line><line x1="8" y1="2" x2="8" y2="6"></line><line x1="3" y1="10" x2="21" y2="10"></line></svg>
|
||||
<span>캘린더</span>
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="#" style="display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 3px; width: 60px; min-height: 56px; border-radius: 10px; color: #A3C2C6; font-size: 11px; font-weight: 500; text-decoration: none">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"></path></svg>
|
||||
<span>파일</span>
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="#" style="display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 3px; width: 60px; min-height: 56px; border-radius: 10px; color: #A3C2C6; font-size: 11px; font-weight: 500; text-decoration: none">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="2" y="7" width="20" height="14" rx="2" ry="2"></rect><path d="M16 21V5a2 2 0 0 0-2-2h-4a2 2 0 0 0-2 2v16"></path></svg>
|
||||
<span>ERP</span>
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="#" style="display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 3px; width: 60px; min-height: 56px; border-radius: 10px; color: #A3C2C6; font-size: 11px; font-weight: 500; text-decoration: none">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"></path><circle cx="9" cy="7" r="4"></circle><path d="M23 21v-2a4 4 0 0 0-3-3.87"></path><path d="M16 3.13a4 4 0 0 1 0 7.75"></path></svg>
|
||||
<span>주소록</span>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
<a href="#" aria-current="page" style="margin-top: auto; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 3px; width: 60px; min-height: 56px; border-radius: 10px; background: #155A64; color: #FFFFFF; font-size: 11px; font-weight: 600; text-decoration: none">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="3"></circle><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"></path></svg>
|
||||
<span>설정</span>
|
||||
</a>
|
||||
</nav>
|
||||
|
||||
<div style="flex: 1 1 auto; min-width: 0; display: flex; flex-direction: column">
|
||||
|
||||
<header style="display: flex; align-items: center; flex-wrap: wrap; gap: 8px 16px; box-sizing: border-box; min-height: 56px; padding: 6px 12px 6px 20px; border-bottom: 1px solid #E6E8EC">
|
||||
<span style="font-size: 16px; font-weight: 700; letter-spacing: -0.01em">설정</span>
|
||||
<div style="flex: 1 1 280px; max-width: 520px; display: flex; align-items: center; gap: 8px; box-sizing: border-box; height: 40px; padding: 0 12px; border: 1px solid #8A94A3; border-radius: 8px; background: #FFFFFF">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#667085" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="11" cy="11" r="8"></circle><line x1="21" y1="21" x2="16.65" y2="16.65"></line></svg>
|
||||
<input type="search" aria-label="통합 검색" placeholder="통합 검색" style="flex: 1 1 auto; min-width: 0; border: 0; background: transparent; font: inherit; color: #171C26">
|
||||
</div>
|
||||
<div style="margin-left: auto; display: flex; align-items: center; gap: 10px">
|
||||
<span aria-hidden="true" style="display: inline-flex; align-items: center; justify-content: center; width: 32px; height: 32px; border-radius: 50%; background: #E3F2F3; color: #0A5F67; font-size: 13px; font-weight: 600">김</span>
|
||||
<div style="display: flex; flex-direction: column; line-height: 1.3">
|
||||
<span style="font-weight: 600">김아라</span>
|
||||
<span style="font-size: 12px; color: #667085">ara.kim@arasystem.it.kr</span>
|
||||
</div>
|
||||
<a href="Login.dc.html" style="display: inline-flex; align-items: center; gap: 6px; min-height: 44px; padding: 0 10px; border-radius: 8px; color: #4A5363; font-weight: 500; text-decoration: none">
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"></path><polyline points="16 17 21 12 16 7"></polyline><line x1="21" y1="12" x2="9" y2="12"></line></svg>
|
||||
<span>로그아웃</span>
|
||||
</a>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div style="flex: 1 1 auto; display: flex; flex-wrap: wrap; align-items: stretch">
|
||||
|
||||
<nav aria-label="설정 메뉴" style="flex: 1 1 208px; box-sizing: border-box; display: flex; flex-direction: column; gap: 4px; padding: 14px 10px; background: #F8F9FB; border-right: 1px solid #E6E8EC">
|
||||
<p style="margin: 4px 10px 4px; font-size: 12px; font-weight: 600; color: #667085">메일</p>
|
||||
<ul style="list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 1px">
|
||||
<li><a href="#" aria-current="page" style="display: flex; align-items: center; gap: 10px; min-height: 40px; box-sizing: border-box; padding: 0 10px; border-radius: 7px; background: #E3F2F3; color: #0A5F67; font-weight: 600; text-decoration: none">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><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>
|
||||
</ul>
|
||||
</nav>
|
||||
|
||||
<main style="flex: 999 1 560px; min-width: 0; box-sizing: border-box; display: flex; flex-direction: column">
|
||||
<div style="max-width: 800px; box-sizing: border-box; padding: 20px 32px 36px; display: flex; flex-direction: column; gap: 22px">
|
||||
|
||||
<div style="display: flex; flex-direction: column; gap: 4px">
|
||||
<h1 style="margin: 0; font-size: 18px; font-weight: 700; letter-spacing: -0.01em">서명</h1>
|
||||
<p style="margin: 0; color: #4A5363">보내는 메일 끝에 넣는 글입니다. 쓰기 화면을 열 때 본문에 넣어 두므로, 그 메일에서만 고치거나 지울 수도 있습니다.</p>
|
||||
</div>
|
||||
|
||||
<div style="display: flex; flex-direction: column; gap: 8px">
|
||||
<p id="signature-label" style="margin: 0; font-weight: 600">서명 내용</p>
|
||||
<div class="sig-box" style="display: flex; flex-direction: column; border: 1px solid #D0D5DD; border-radius: 10px; background: #FFFFFF">
|
||||
<div role="toolbar" aria-label="서명 서식" aria-controls="signature-body" style="display: flex; align-items: center; gap: 2px; min-height: 52px; box-sizing: border-box; padding: 4px 8px; border-bottom: 1px solid #EEF0F3">
|
||||
<button type="button" class="tb" aria-label="굵게" aria-keyshortcuts="Control+B" title="굵게 (Ctrl+B)" style="width: 40px; height: 44px; display: inline-flex; align-items: center; justify-content: center; padding: 0; border: 0; border-radius: 8px; cursor: pointer">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M6 4h8a4 4 0 0 1 0 8H6z"></path><path d="M6 12h9a4 4 0 0 1 0 8H6z"></path></svg>
|
||||
</button>
|
||||
<button type="button" class="tb" aria-label="기울임" aria-keyshortcuts="Control+I" title="기울임 (Ctrl+I)" style="width: 40px; height: 44px; display: inline-flex; align-items: center; justify-content: center; padding: 0; border: 0; border-radius: 8px; cursor: pointer">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="19" y1="4" x2="10" y2="4"></line><line x1="14" y1="20" x2="5" y2="20"></line><line x1="15" y1="4" x2="9" y2="20"></line></svg>
|
||||
</button>
|
||||
<button type="button" class="tb" aria-label="밑줄" aria-keyshortcuts="Control+U" title="밑줄 (Ctrl+U)" style="width: 40px; height: 44px; display: inline-flex; align-items: center; justify-content: center; padding: 0; border: 0; border-radius: 8px; cursor: pointer">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M6 3v7a6 6 0 0 0 12 0V3"></path><line x1="4" y1="21" x2="20" y2="21"></line></svg>
|
||||
</button>
|
||||
<span aria-hidden="true" style="flex: 0 0 auto; width: 1px; height: 20px; margin: 0 4px; background: #E6E8EC"></span>
|
||||
<button type="button" class="tb" aria-label="글자 색: 기본" title="글자 색" style="width: 40px; height: 44px; display: inline-flex; flex-direction: column; align-items: center; justify-content: center; gap: 1px; padding: 0; border: 0; border-radius: 8px; cursor: pointer">
|
||||
<svg width="16" height="14" viewBox="0 0 24 20" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M5 17 12 3l7 14"></path><line x1="7.5" y1="12" x2="16.5" y2="12"></line></svg>
|
||||
<span aria-hidden="true" style="width: 16px; height: 3px; border-radius: 1px; background: #1F2733"></span>
|
||||
</button>
|
||||
<button type="button" class="tb" aria-label="링크" aria-keyshortcuts="Control+K" title="링크 (Ctrl+K)" style="width: 40px; height: 44px; display: inline-flex; align-items: center; justify-content: center; padding: 0; border: 0; border-radius: 8px; cursor: pointer">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"></path><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"></path></svg>
|
||||
</button>
|
||||
<button type="button" class="tb" aria-label="그림 넣기" title="그림 넣기 — PNG · JPG · GIF" style="width: 40px; height: 44px; display: inline-flex; align-items: center; justify-content: center; padding: 0; border: 0; border-radius: 8px; cursor: pointer">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="3" width="18" height="18" rx="2" ry="2"></rect><circle cx="8.5" cy="8.5" r="1.5"></circle><polyline points="21 15 16 10 5 21"></polyline></svg>
|
||||
</button>
|
||||
</div>
|
||||
<div id="signature-body" class="se" contenteditable="true" tabindex="0" role="textbox" aria-multiline="true" aria-labelledby="signature-label" style="min-height: 132px; box-sizing: border-box; padding: 14px 16px; font-family: 'Malgun Gothic', '맑은 고딕', 'Apple SD Gothic Neo', sans-serif; font-size: 14px; line-height: 1.75; color: #1F2733">
|
||||
<p><strong>김아라</strong> · 인프라팀</p>
|
||||
<p>ARA SYSTEM · [전화번호]</p>
|
||||
<p><a href="mailto:ara.kim@arasystem.it.kr">ara.kim@arasystem.it.kr</a></p>
|
||||
<p style="margin-top: 8px"><span role="img" aria-label="[회사 로고] — 고른 그림" style="display: inline-flex; align-items: center; justify-content: center; box-sizing: border-box; width: {{ logoWidth }}px; height: {{ logoHeight }}px; border: 1px dashed #98A2B3; border-radius: 4px; outline: 2px solid #0E7C86; outline-offset: 2px; color: #667085; font-family: Pretendard, -apple-system, 'Apple SD Gothic Neo', 'Malgun Gothic', system-ui, sans-serif; font-size: 12px">[회사 로고]</span></p>
|
||||
</div>
|
||||
<div style="display: flex; flex-wrap: wrap; align-items: center; gap: 6px 10px; padding: 6px 12px 8px; border-top: 1px solid #EEF0F3">
|
||||
<span style="font-size: 12.5px; font-weight: 600; color: #4A5363">그림 크기</span>
|
||||
<div role="group" aria-label="그림 크기" style="display: inline-flex; gap: 2px; padding: 2px; border-radius: 8px; background: #F2F4F7">
|
||||
<button type="button" class="seg" aria-pressed="{{ smallPressed }}" onClick="{{ sizeSmall }}" style="min-height: 36px; padding: 0 12px; border: 0; border-radius: 6px; font: inherit; font-weight: 600; cursor: pointer">작게</button>
|
||||
<button type="button" class="seg" aria-pressed="{{ mediumPressed }}" onClick="{{ sizeMedium }}" style="min-height: 36px; padding: 0 12px; border: 0; border-radius: 6px; font: inherit; font-weight: 600; cursor: pointer">보통</button>
|
||||
<button type="button" class="seg" aria-pressed="{{ originalPressed }}" onClick="{{ sizeOriginal }}" style="min-height: 36px; padding: 0 12px; border: 0; border-radius: 6px; font: inherit; font-weight: 600; cursor: pointer">원본</button>
|
||||
</div>
|
||||
<button type="button" class="tb" style="min-height: 36px; padding: 0 10px; border: 0; border-radius: 6px; font: inherit; font-size: 12.5px; font-weight: 500; cursor: pointer">그림 빼기</button>
|
||||
<span style="font-size: 12px; color: #667085">그림을 고르면 나오는 줄입니다</span>
|
||||
</div>
|
||||
</div>
|
||||
<p style="margin: 0; font-size: 12.5px; color: #667085">굵게 · 기울임 · 밑줄 · 글자 색 · 링크 · 그림을 쓸 수 있습니다. 그림은 PNG · JPG · GIF, 한 장 300KB 이하, 3장까지이고 붙여 넣거나 끌어다 놓아도 됩니다. 메일 안에 함께 실어 보내므로 받는 쪽에서 외부 이미지처럼 막히지 않습니다.</p>
|
||||
</div>
|
||||
|
||||
<fieldset style="display: flex; flex-direction: column; gap: 0; margin: 0; padding: 0; border: 0">
|
||||
<legend style="margin: 0 0 4px; padding: 0; font-weight: 600; color: #171C26">서명을 넣을 메일</legend>
|
||||
<label style="display: flex; align-items: center; gap: 10px; min-height: 44px; cursor: pointer">
|
||||
<input type="checkbox" checked="{{ inNew }}" onChange="{{ toggleNew }}" 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="{{ inReply }}" onChange="{{ toggleReply }}" style="width: 18px; height: 18px; margin: 0; accent-color: #0E7C86; cursor: pointer">
|
||||
<span>답장 · 전달</span>
|
||||
<span style="color: #667085">— 인용한 원문 위에 넣습니다</span>
|
||||
</label>
|
||||
</fieldset>
|
||||
|
||||
<div style="display: flex; flex-wrap: wrap; align-items: center; gap: 12px">
|
||||
<button type="button" onClick="{{ save }}" style="display: inline-flex; align-items: center; min-height: 44px; padding: 0 22px; border: 0; border-radius: 8px; background: #0E7C86; color: #FFFFFF; font: inherit; font-size: 13.5px; font-weight: 600; cursor: pointer">저장</button>
|
||||
<sc-if value="{{ saved }}" hint-placeholder-val="{{ false }}">
|
||||
<span role="status" style="display: inline-flex; align-items: center; gap: 6px; color: #067647; font-weight: 500">
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="20 6 9 17 4 12"></polyline></svg>
|
||||
<span>저장했습니다. 새로 여는 쓰기 화면부터 들어갑니다.</span>
|
||||
</span>
|
||||
</sc-if>
|
||||
</div>
|
||||
|
||||
<hr style="width: 100%; height: 1px; margin: 0; border: 0; background: #EEF0F3">
|
||||
|
||||
<section aria-labelledby="sig-preview-title" style="display: flex; flex-direction: column; gap: 12px">
|
||||
<div style="display: flex; flex-wrap: wrap; align-items: center; gap: 8px 14px">
|
||||
<h2 id="sig-preview-title" style="margin: 0; font-size: 14px; font-weight: 700">미리 보기</h2>
|
||||
<div role="group" aria-label="미리 볼 메일" style="display: inline-flex; gap: 2px; padding: 2px; border-radius: 8px; background: #F2F4F7">
|
||||
<button type="button" class="seg" aria-pressed="{{ newPressed }}" onClick="{{ showNew }}" style="min-height: 36px; padding: 0 14px; border: 0; border-radius: 6px; font: inherit; font-weight: 600; cursor: pointer">새 메일</button>
|
||||
<button type="button" class="seg" aria-pressed="{{ replyPressed }}" onClick="{{ showReply }}" style="min-height: 36px; padding: 0 14px; border: 0; border-radius: 6px; font: inherit; font-weight: 600; cursor: pointer">답장</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="box-sizing: border-box; padding: 16px 20px 20px; border: 1px solid #E6E8EC; border-radius: 10px; background: #FFFFFF; font-family: 'Malgun Gothic', '맑은 고딕', 'Apple SD Gothic Neo', sans-serif; font-size: 14px; line-height: 1.75; color: #1F2733">
|
||||
<p style="margin: 0; color: #667085">(여기에 쓴 내용)</p>
|
||||
<p style="margin: 0"><br></p>
|
||||
<sc-if value="{{ showSignature }}" hint-placeholder-val="{{ true }}">
|
||||
<div>
|
||||
<p style="margin: 0"><strong>김아라</strong> · 인프라팀</p>
|
||||
<p style="margin: 0">ARA SYSTEM · [전화번호]</p>
|
||||
<p style="margin: 0"><a href="mailto:ara.kim@arasystem.it.kr">ara.kim@arasystem.it.kr</a></p>
|
||||
<p style="margin: 8px 0 0"><span role="img" aria-label="[회사 로고]" style="display: inline-flex; align-items: center; justify-content: center; box-sizing: border-box; width: {{ logoWidth }}px; height: {{ logoHeight }}px; border: 1px dashed #98A2B3; border-radius: 4px; color: #667085; font-family: Pretendard, -apple-system, 'Apple SD Gothic Neo', 'Malgun Gothic', system-ui, sans-serif; font-size: 12px">[회사 로고]</span></p>
|
||||
</div>
|
||||
</sc-if>
|
||||
<sc-if value="{{ isReplyPreview }}" hint-placeholder-val="{{ false }}">
|
||||
<blockquote style="margin: 24px 0 0; padding: 2px 0 2px 14px; border-left: 2px solid #D0D5DD; color: #4A5363">
|
||||
<div style="margin: 0 0 12px; font-size: 12.5px; line-height: 1.6">
|
||||
<p style="margin: 0; font-weight: 600">원본 메일</p>
|
||||
<table style="border-collapse: collapse; font-size: 12.5px; line-height: 1.6">
|
||||
<tbody>
|
||||
<tr><th scope="row" style="padding: 0 12px 0 0; text-align: left; vertical-align: top; font-weight: 400; color: #667085; white-space: nowrap">보낸 사람</th><td style="padding: 0">이도윤 <doyun.lee@arasystem.it.kr></td></tr>
|
||||
<tr><th scope="row" style="padding: 0 12px 0 0; text-align: left; vertical-align: top; font-weight: 400; color: #667085; white-space: nowrap">날짜</th><td style="padding: 0">2026. 10. 26. (월) 오전 10:42</td></tr>
|
||||
<tr><th scope="row" style="padding: 0 12px 0 0; text-align: left; vertical-align: top; font-weight: 400; color: #667085; white-space: nowrap">제목</th><td style="padding: 0">Re: 메일 서버 이전 일정 공유</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<p style="margin: 0">김아라님, 안녕하세요.</p>
|
||||
<p style="margin: 0">말씀하신 대로 이번 주 금요일(10/30) 저녁 9시에 MX 레코드를 새 메일 서버로 전환하겠습니다.</p>
|
||||
</blockquote>
|
||||
</sc-if>
|
||||
</div>
|
||||
<p style="margin: 0; font-size: 12.5px; color: #667085">{{ previewNote }}</p>
|
||||
</section>
|
||||
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</x-dc>
|
||||
<script type="text/x-dc" data-dc-script data-props='{"$preview":{"width":1440,"height":900}}'>
|
||||
class Component extends DCLogic {
|
||||
state = { inNew: true, inReply: true, preview: 'new', saved: false, imageSize: 'medium' };
|
||||
|
||||
renderVals() {
|
||||
const s = this.state;
|
||||
// 그림 표시 크기 — 작게 · 보통 · 원본(올린 그림의 폭, 최대 600px). 시안은 로고 자리 상자로 보인다
|
||||
const LOGO_SIZES = { small: [96, 29], medium: [144, 43], original: [200, 60] };
|
||||
const [logoWidth, logoHeight] = LOGO_SIZES[s.imageSize] || LOGO_SIZES.medium;
|
||||
const isReply = s.preview === 'reply';
|
||||
const showSignature = isReply ? s.inReply : s.inNew;
|
||||
let previewNote = '새 메일은 본문 끝에 서명을 넣습니다.';
|
||||
if (isReply) previewNote = s.inReply ? '답장 · 전달은 인용한 원문 위에 서명을 넣습니다.' : '답장 · 전달에는 서명을 넣지 않습니다.';
|
||||
else if (!s.inNew) previewNote = '새 메일에는 서명을 넣지 않습니다.';
|
||||
const flag = (value) => (value ? 'true' : 'false');
|
||||
return {
|
||||
inNew: s.inNew,
|
||||
inReply: s.inReply,
|
||||
saved: s.saved,
|
||||
showSignature,
|
||||
isReplyPreview: isReply,
|
||||
newPressed: flag(!isReply),
|
||||
replyPressed: flag(isReply),
|
||||
previewNote,
|
||||
logoWidth,
|
||||
logoHeight,
|
||||
smallPressed: flag(s.imageSize === 'small'),
|
||||
mediumPressed: flag(s.imageSize === 'medium'),
|
||||
originalPressed: flag(s.imageSize === 'original'),
|
||||
sizeSmall: () => this.setState({ imageSize: 'small', saved: false }),
|
||||
sizeMedium: () => this.setState({ imageSize: 'medium', saved: false }),
|
||||
sizeOriginal: () => this.setState({ imageSize: 'original', saved: false }),
|
||||
toggleNew: () => this.setState({ inNew: !s.inNew, saved: false }),
|
||||
toggleReply: () => this.setState({ inReply: !s.inReply, saved: false }),
|
||||
showNew: () => this.setState({ preview: 'new' }),
|
||||
showReply: () => this.setState({ preview: 'reply' }),
|
||||
save: () => this.setState({ saved: true }),
|
||||
};
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -11,11 +11,14 @@
|
||||
"States.dc.html": { "x": 0, "y": 1020, "w": 1440, "h": 1040, "title": "오류 · 빈 상태 · 피드백" },
|
||||
"MainList.dc.html": { "x": 1520, "y": 1020, "w": 1440, "h": 900, "title": "메일함 · 목록 보기", "expand": "fill", "is_interactive": true },
|
||||
"MainListRead.dc.html": { "x": 3040, "y": 1020, "w": 1440, "h": 900, "title": "목록 보기 · 메일 읽기", "expand": "fill", "is_interactive": true },
|
||||
"ComposeEditor.dc.html": { "x": 4560, "y": 0, "w": 1440, "h": 3576, "title": "메일 쓰기 · 편집기 상태" }
|
||||
"ComposeEditor.dc.html": { "x": 4560, "y": 0, "w": 1440, "h": 3576, "title": "메일 쓰기 · 편집기 상태" },
|
||||
"Settings.dc.html": { "x": 0, "y": 2480, "w": 1440, "h": 900, "title": "설정 · 메일 서명", "expand": "fill", "is_interactive": true },
|
||||
"ComposeAssist.dc.html": { "x": 1520, "y": 2480, "w": 1440, "h": 1400, "title": "메일 쓰기 · 임시 저장 · 자동 완성 · 서명" }
|
||||
},
|
||||
"order": ["Main.dc.html", "Login.dc.html", "Compose.dc.html", "States.dc.html", "MainList.dc.html", "MainListRead.dc.html", "ComposeEditor.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"],
|
||||
"notes": {
|
||||
"flow": { "x": 0, "y": -300, "text": "arasystem 웹메일 — 1단계 화면 (로그인 → 메일함 → 쓰기)", "kind": "title1", "maxW": 4480 }
|
||||
"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 }
|
||||
},
|
||||
"designSystems": []
|
||||
}
|
||||
|
||||
+149
-8
@@ -8,7 +8,7 @@
|
||||
|
||||
- 성공 `{ "success": true, "data": … }` / 실패 `{ "success": false, "error": { "code", "message", "traceId" } }`
|
||||
- 날짜·시각은 ISO 8601 UTC 문자열, 목록은 `{ items, pageInfo }`
|
||||
- 이 단계에서 쓰는 에러 코드: `AUTH_001` `AUTH_002`(클라이언트 전용) `AUTH_003` `AUTH_004` `VAL_001` `RES_001` `REQ_001` `FILE_001` `FILE_002` `MAIL_001` `MAIL_002` `MAIL_003` `MAIL_004` `MAIL_005` `MAIL_006` `MAIL_007` `SYS_001`
|
||||
- 이 단계에서 쓰는 에러 코드: `AUTH_001` `AUTH_002`(클라이언트 전용) `AUTH_003` `AUTH_004` `VAL_001` `RES_001` `REQ_001` `FILE_001` `FILE_002` `MAIL_001` `MAIL_002` `MAIL_003` `MAIL_004` `MAIL_005` `MAIL_006` `MAIL_007` `MAIL_008` `SYS_001`
|
||||
- **적용 범위: 1단계는 웹(브라우저) 전용이다.** 인증은 HttpOnly 쿠키뿐이고 토큰을 본문으로 주지 않는다.
|
||||
모바일 앱(Flutter)의 인증 방식(토큰 발급·`flutter_secure_storage`)은 앱 착수 때 이 문서에 추가한다.
|
||||
- **배포 형태: 화면과 API 는 같은 주소(origin)를 쓴다.** 운영은 프론트 nginx 가 `/api` 를 백엔드로 넘기고,
|
||||
@@ -122,7 +122,7 @@ interface MessageSummary {
|
||||
receivedAt: string
|
||||
isRead: boolean
|
||||
isFlagged: boolean
|
||||
hasAttachment: boolean
|
||||
hasAttachment: boolean // 본문 그림(아래)만 있으면 false — 서명 그림만 붙은 메일은 첨부 표시를 하지 않는다
|
||||
size: number // 바이트
|
||||
}
|
||||
|
||||
@@ -138,10 +138,14 @@ interface MessageDetail extends MessageSummary {
|
||||
hasBlockedImages: boolean // 외부 이미지를 지웠으면 true → "외부 이미지 차단" 안내
|
||||
isHtmlBodyOmitted: boolean // HTML 이 있었지만 정화 한도를 넘어 htmlBody 를 비웠으면 true (아래 "읽기 한도")
|
||||
isBodyTruncated: boolean // 보여 줄 본문(htmlBody — null 이거나 공백뿐이면 textBody)이 읽기 한도에서 잘렸으면 true
|
||||
attachments: Attachment[]
|
||||
attachments: MessageAttachment[]
|
||||
}
|
||||
|
||||
interface Attachment { id: string; name: string; type: string; size: number }
|
||||
|
||||
interface MessageAttachment extends Attachment {
|
||||
isInline: boolean // 돌려준 htmlBody 안에 그림으로 들어갔다(cid → data:) — 읽기 화면의 첨부 목록에서 뺀다. 전달할 때는 함께 붙인다
|
||||
}
|
||||
```
|
||||
|
||||
역할별 한국어 이름: `inbox` 받은편지함 · `drafts` 임시보관함 · `sent` 보낸편지함 · `junk` 스팸메일함 · `trash` 휴지통 · `archive` 보관함.
|
||||
@@ -149,9 +153,16 @@ interface Attachment { id: string; name: string; type: string; size: number }
|
||||
**`htmlBody` 정화 규칙 (서버 책임)**: 스크립트·스타일 태그·이벤트 핸들러·폼·iframe·object·embed·svg 제거,
|
||||
링크는 `http` · `https` · `mailto` 만 남기고 새 창(`target="_blank" rel="noopener noreferrer"`),
|
||||
외부(`http/https`) 이미지는 제거하고 `hasBlockedImages = true`.
|
||||
본문 안 인라인 이미지(`cid:`)는 작은 것만 `data:` URI 로 바꿔 넣고 나머지는 지운다(첨부 목록에는 남는다).
|
||||
본문 안 인라인 이미지(`cid:`)는 작은 것만 `data:` URI 로 바꿔 넣고 나머지는 지운다.
|
||||
넣은 그림은 `attachments` 에 `isInline: true` 로 남는다 — 읽기 화면은 첨부 목록에서 빼고, 전달은 원문 그림을 잃지 않게 함께 붙인다
|
||||
(인용 본문에서는 그림이 빠지기 때문이다). 넣지 못한 그림(크거나 많거나 읽지 못함)은 `isInline: false` 로 첨부 목록에 보인다.
|
||||
**클라이언트는 그래도 격리된 iframe 에만 그린다** (스크립트 비허용 sandbox + 내부 CSP — 이중 방어).
|
||||
|
||||
**`hasAttachment`** 는 엔진 값이 아니라 서버가 첨부 부분의 머리로 정한다 — "본문 그림"이 아닌 첨부가 하나라도 있으면 `true`.
|
||||
본문 그림 = 본문에 넣을 수 있는 그림 형식(PNG·JPEG·GIF·WebP) · `Content-ID` 있음 · `Content-Disposition` 이 `attachment` 가 아님(`inline` 이거나 없음) ·
|
||||
크기 1바이트~300KB. 목록은 본문을 읽지 않으므로 이 판정은 "실제로 본문에 들어갔나"(`isInline`)와 드물게 다를 수 있다
|
||||
(본문이 가리키지 않는 그런 그림, 20장·합계 2MB 를 넘는 본문 그림 — 목록은 클립이 없는데 상세의 첨부 목록에는 보인다).
|
||||
|
||||
**읽기 한도**: 상세 조회는 본문 한 부분을 UTF-8 2,000,000바이트까지 읽는다 — 넘는 부분은 잘린 채 오고, 보여 줄 본문이 잘렸으면 `isBodyTruncated: true` 다.
|
||||
HTML 은 정화하기 **전에** 길이(HTML 부분이 여럿이면 이어 붙인 것이 2,000,000자 이하)와 파서 비용을 본다 —
|
||||
약 2만 단을 넘는 중첩이나, 깊게 열린 태그 아래로 짝 없는 닫는 태그를 아주 많이 둔 HTML 은 정화가 서버를 수십 초씩 붙잡기 때문이다.
|
||||
@@ -168,10 +179,18 @@ HTML 은 정화하기 **전에** 길이(HTML 부분이 여럿이면 이어 붙
|
||||
| `GET /api/messages` | `mailboxId`(필수) · `cursor` · `limit`(기본 20, 최대 100) · `q`(선택) | 200 `{ items: MessageSummary[], pageInfo }` — 받은 시각 내림차순, `total` 포함 | 400 `VAL_001`, `MAIL_001` |
|
||||
| `GET /api/messages/{id}` | — | 200 `MessageDetail` — **조회만으로 읽음 처리하지 않는다** | 404 `RES_001` |
|
||||
| `PATCH /api/messages/{id}` | `{ isRead?, isFlagged?, mailboxId? }` (하나 이상) | 200 `MessageSummary` | 404 `RES_001`, 400 `VAL_001` |
|
||||
| `DELETE /api/messages/{id}` | — | 204 — 휴지통이 아니면 휴지통으로 이동, 휴지통이면 영구 삭제 | 404 `RES_001` |
|
||||
| `DELETE /api/messages/{id}` | — | 204 — 휴지통이 아니면 휴지통으로 이동, 휴지통에만 있으면 영구 삭제(확인한 상태 그대로일 때만 — 아래). 휴지통과 다른 메일함에 함께 있으면 휴지통에서만 뺀다 | 404 `RES_001`, 503 `MAIL_001`(확인하는 사이 계속 바뀜 — 지우지 않았다) |
|
||||
| `POST /api/messages` | 아래 발송 본문 + `Idempotency-Key` 헤더 | 201 `{ id }` (보낸편지함의 메일 id) | 400 `MAIL_002`·`MAIL_003`·`MAIL_004`·`MAIL_006`·`MAIL_007`·`VAL_001`, 403 `AUTH_003`(보낼 권한 없음), 409 `MAIL_005`(같은 키·다른 내용), 413 `FILE_002`(첨부 합계 초과 포함), 429 `REQ_001`(엔진 발송 한도·같은 키 처리 중·엔진 장애 직후 30초), 503 `MAIL_001` |
|
||||
| `POST /api/mail-attachments` | multipart `file` 1개 | 201 `Attachment` | 400 `FILE_001`, 413 `FILE_002` |
|
||||
| `GET /api/messages/{id}/attachments/{attachmentId}` | — | 파일 (표준 래퍼 없음, `Content-Disposition: attachment`) | 404 `RES_001` |
|
||||
| `GET /api/drafts/{id}` | — | 200 `Draft` (아래 "임시 저장") | 404 `RES_001`(없음·초안이 아님), 503 `MAIL_001` |
|
||||
| `POST /api/drafts` | 초안 본문 | 201 `DraftSaved` | 400 `VAL_001`·`MAIL_002`·`MAIL_003`·`MAIL_004`·`MAIL_006`·`MAIL_007`·`FILE_001`, 413 `FILE_002`, 503 `MAIL_001` |
|
||||
| `PUT /api/drafts/{id}` | 초안 본문 (전체 교체) | 200 `DraftSaved` — **새 id** | 위와 같음 + 404 `RES_001`(초안이 아닌 id — 지우지 않음) + 409 `MAIL_008`(다른 곳에서 이미 바뀜 — 저장 안 함) |
|
||||
| `GET /api/mail-signature` | — | 200 `MailSignature` (아래 "서명") | — |
|
||||
| `PUT /api/mail-signature` | `{ bodyHtml, body, isForNew, isForReply }` | 200 `MailSignature` | 400 `VAL_001` |
|
||||
| `POST /api/mail-signature/images` | multipart `file` 1개 | 201 `SignatureImage` | 400 `FILE_001`·`VAL_001`(올려 둔 그림 10장 초과), 413 `FILE_002`(300KB 초과) |
|
||||
| `GET /api/mail-signature/images/{id}` | — | 그림 파일 (표준 래퍼 없음, 본인 그림만) | 404 `RES_001` |
|
||||
| `GET /api/recipient-suggestions` | `q`(선택, 1~100자) · `limit`(기본 20, 최대 100) · `cursor` | 200 `{ items: RecipientSuggestion[], pageInfo }` (아래 "받는 사람 추천") | 400 `VAL_001`, 503 `MAIL_001` |
|
||||
|
||||
표에 없는 실패 (구현에서 확정):
|
||||
|
||||
@@ -183,7 +202,7 @@ HTML 은 정화하기 **전에** 길이(HTML 부분이 여럿이면 이어 붙
|
||||
| 엔진에서 찾을 수 없는 첨부 id — 올려 두고 오래 지나 만료된 업로드, 그사이 지워진 원본 메일의 첨부 등 | 400 `MAIL_004` — 클라이언트는 해당 첨부를 실패로 표시한다. 올린 파일은 다시 올리게, 전달하는 원본 첨부는 빼게 한다 (원본은 다시 올릴 수 없다) |
|
||||
| 첨부 20개 초과 | 400 `MAIL_006` — 클라이언트는 첨부를 추가할 때 미리 막는다 |
|
||||
| 본문이 너무 김 — 텍스트 100,000자·서식 본문 500,000자 초과(JS 문자열 길이, UTF-16 단위), 서식 본문이 정화 전 비용 한도·정화 뒤 2,000,000바이트 초과 | 400 `MAIL_007` — 메일은 나가지 않는다. 클라이언트는 보내기 전에 같은 한도로 막고 본문 아래에 알린다 (아래 "서식 본문"의 한도) |
|
||||
| JSON 본문 1MB 초과 (발송 `POST /api/messages` 만 4MB — 서식 본문과 긴 인용 때문) | 413 `FILE_002` |
|
||||
| JSON 본문 1MB 초과 (발송 `POST /api/messages` 와 임시 저장 `POST /api/drafts`·`PUT /api/drafts/{id}` 만 4MB — 서식 본문과 긴 인용 때문) | 413 `FILE_002` |
|
||||
|
||||
메일함 정렬(확정 디자인 기준): 받은편지함 → 보낸편지함 → 임시보관함 → 스팸메일함 → 휴지통 → 보관함 → 그 외(`sortOrder`, 이름 순).
|
||||
|
||||
@@ -205,6 +224,8 @@ interface MailQuota {
|
||||
같은 메일을 다시 보낼 때(타임아웃 뒤 재시도 등)에도 **같은 키**를 보낸다. 서버는 같은 사용자·같은 키로 이미 발송된 메일이 있으면
|
||||
다시 보내지 않고 그 결과(201 `{ id }`)를 그대로 돌려준다. 응답이 늦어 `MAIL_001` 을 받았어도 실제로는 나갔을 수 있으므로,
|
||||
재시도는 반드시 같은 키로 한다. (키 형식이 틀리면 400 `VAL_001`. 키가 없으면 중복 방지 없이 보낸다.)
|
||||
서버는 발송 요청에 55초 안에 답한다 — 앞 단계(첨부 확인 · 서명 그림 올리기)가 늦어 보낼 시간이 10초도 남지 않으면 메일을 만들지 않고 503 `MAIL_001`
|
||||
(아무것도 나가지 않았으니 같은 키로 다시 보내면 된다). 보낸 뒤 이어 쓴 저장본 지우기는 5초까지만 기다리고, 넘으면 먼저 답한 뒤 마저 지운다.
|
||||
|
||||
- 같은 키의 요청이 **아직 처리 중**이면 기다리지 않고 429 `REQ_001` — 같은 메일이 동시에 두 번 나가지 않게 한다.
|
||||
엔진 장애(`MAIL_001`)로 끝난 키도 **30초 동안은 처리 중**으로 본다 (시간 초과라면 엔진이 아직 보내는 중일 수 있다).
|
||||
@@ -230,7 +251,8 @@ interface MailQuota {
|
||||
"body": "도윤님, 확인했습니다.\n…", // 일반 텍스트(서식 본문의 대체 본문), 100,000자 이하
|
||||
"bodyHtml": "<div style=\"font-family:…\"><p>도윤님, 확인했습니다.</p>…</div>", // 선택 — 서식 편집기 본문, 500,000자 이하 (아래 "서식 본문")
|
||||
"attachments": [{ "id": "<업로드 응답 id>", "name": "체크리스트.xlsx" }], // 선택, 20개 이하 (넘으면 MAIL_006)
|
||||
"inReplyTo": "<답장할 메일의 id>" // 선택 — 스레드 헤더를 서버가 채운다
|
||||
"inReplyTo": "<답장할 메일의 id>", // 선택 — 스레드 헤더를 서버가 채운다
|
||||
"draftId": "<이어 쓴 임시 저장본 id>" // 선택 — 보낸 뒤 서버가 그 초안을 지운다 (아래 "임시 저장")
|
||||
}
|
||||
```
|
||||
|
||||
@@ -261,6 +283,9 @@ interface MailQuota {
|
||||
`AppleGothic`·`AppleMyungjo`)·일반 이름(`sans-serif`·`serif`·`monospace`)으로만 이뤄진 목록.
|
||||
- 형광 표시는 `mark` 가 아니라 span 의 `background-color` 로 보낸다 (`mark`·`h1` 같은 모르는 태그는 태그만 지우고 글자는 남긴다).
|
||||
- 정화하고 남는 것이 없으면 `body` 만으로 텍스트 메일을 보낸다.
|
||||
- **그림은 보내는 사람 자신의 서명 그림만** 남는다 — `<img src="/api/mail-signature/images/{id}">` 이고 그 id 가 보내는 사람의 그림일 때.
|
||||
속성은 `src`·`width`(1~600, 숫자만)·`alt`(200자 이하)만 남는다. 서버가 그 그림을 메일 안에 함께 실어(`multipart/related`, `cid:`) 보내므로
|
||||
받는 쪽에서 외부 이미지처럼 막히지 않는다. 그 밖의 `img`·찾지 못한 서명 그림(지운 그림)은 태그째 지운다. 클라이언트 편집기도 이 그림만 받는다.
|
||||
- 보이지 않을 만큼 작은 글자(8px 미만)·거의 투명한 글자 색(알파 0.3 미만)은 그 선언을 지운다 — 붙여 넣거나 인용한 글에 그렇게 숨긴 글이
|
||||
그대로 실려 나가지 않게. 바탕과 같은 색(흰 바탕 위 흰 글자 등)은 바탕을 알 수 없어 막지 않는다
|
||||
(받은 메일을 읽을 때의 정화는 이 제한을 두지 않는다).
|
||||
@@ -278,6 +303,7 @@ interface MailQuota {
|
||||
- 답장·전달의 원문 인용은 두 경우 모두 **"원본 메일"** 머리(보낸 사람·날짜·제목, 전달은 받는 사람·참조도)와 회색 인용 막대로 만든다.
|
||||
원문은 `htmlBody`(없으면 `textBody`)를 편집기로 옮기며, 편집기가 모르는 요소(이미지 등)는 빠진다.
|
||||
- **본문 안 이미지는 이번 단계에서 넣지 않는다** — 붙여 넣거나 끌어다 놓은 이미지는 첨부 파일로 올린다 (첨부 한도 규칙 그대로).
|
||||
서명 그림만 예외다 (아래 "서명").
|
||||
|
||||
### 첨부 업로드 한도
|
||||
|
||||
@@ -290,6 +316,121 @@ interface MailQuota {
|
||||
이동은 axios 를 거치지 않아 401 재시도 규칙이 적용되지 않으므로, **이동하기 전에 세션을 확인**한다
|
||||
(`/auth/me` 가 `AUTH_001` 이면 `/auth/refresh` 한 번 → 실패하면 세션 만료 처리).
|
||||
|
||||
### 임시 저장 (`/api/drafts`)
|
||||
|
||||
```ts
|
||||
interface Draft {
|
||||
id: string // 저장할 때마다 바뀐다 — 응답의 id 로 바꿔 쓴다
|
||||
to: Address[]
|
||||
cc: Address[]
|
||||
bcc: Address[]
|
||||
subject: string
|
||||
body: string // 텍스트 대체 본문
|
||||
bodyHtml: string | null // 서식 본문 — 보내는 정화를 거친 값 (없거나 비면 null). 다른 메일 프로그램이 만든 초안도 같은 정화를 거친다
|
||||
attachments: Attachment[] // 이 판의 첨부 — id 가 저장할 때마다 바뀐다
|
||||
inReplyTo: string | null // 답장할 메일 id (없거나 원 메일이 사라졌으면 null)
|
||||
updatedAt: string // 마지막으로 저장한 시각 (ISO 8601 UTC)
|
||||
}
|
||||
|
||||
interface DraftSaved { id: string; attachments: Attachment[]; updatedAt: string }
|
||||
```
|
||||
|
||||
- **초안 본문**은 발송 본문과 같은 필드·한도다(본문 길이 `MAIL_007`, 첨부 20개 `MAIL_006`, 첨부 합계 `FILE_002`, JSON 본문 4MB …). 다만 받는 사람은 0명이어도 되고
|
||||
(합계 100명 이하), `draftId` 는 없다.
|
||||
- **저장**: 처음은 `POST`, 그다음은 `PUT /api/drafts/{직전 id}`. 메일은 고칠 수 없어 서버가 새 초안을 만들고, 다시 읽어 확인한 뒤 옛것을 지운다 —
|
||||
클라이언트는 응답의 `id`·`attachments` 로 바꿔 쓴다. 서버는 초안에 발송 중복 방지용 Message-ID·지문 헤더를 넣지 않는다.
|
||||
만들기와 지우기를 한 요청에 넣지 않는다 — 엔진은 같은 요청에서 만들기가 실패해도 지우기를 그대로 해서, 저장에 실패한 순간 저장본이 사라진다.
|
||||
- "임시보관함의 초안" = 임시보관함에**만** 있고 `$draft` 이며 보내기 중에 생긴 내부 초안이 아닌 메일. PUT 의 id 가 그렇지 않으면(휴지통·보낸 메일 등)
|
||||
아무것도 만들거나 지우지 않고 404 `RES_001`.
|
||||
- 옛 초안이 이미 없으면(다른 창·기기에서 먼저 고쳤거나 지웠음) 409 `MAIL_008` — 처음 확인에서 없으면 아무것도 만들지 않고, 만든 뒤 지우는 사이에
|
||||
없어졌으면 방금 만든 것을 지운다.
|
||||
- 클라이언트는 `MAIL_008` 과 PUT 의 404 `RES_001`(다른 창에서 휴지통으로 옮긴 경우 등)을 모두 막지 않고, 지금 내용을 `POST` 로 새로 남긴다.
|
||||
- 옛것은 **확인한 뒤로 메일함이 바뀌지 않았을 때만** 지운다(JMAP `ifInState`, 엇갈리면 다시 확인 — 모두 세 번까지). 지우기는 되돌릴 수 없어서다.
|
||||
그사이 다른 곳에서 옮겼으면 지우지 않고, 계속 엇갈리면 옛것이 한 통 더 남는다 (저장은 성공).
|
||||
- **목록·버리기·되돌리기**는 기존 API 다 — 목록 `GET /api/messages?mailboxId=<임시보관함>`(보내기 중에 생긴 내부 초안은 나오지 않는다),
|
||||
버리기 `DELETE /api/messages/{id}`(휴지통으로), 되돌리기 `PATCH /api/messages/{id}` `{ mailboxId: <임시보관함> }`.
|
||||
- **보내기**: 이어 쓴 초안은 발송 본문에 `draftId` 를 넣어 보낸다. 서버는 지금의 발송 경로(정화·지문·중복 방지 그대로)로 보내고, 성공한 뒤
|
||||
(같은 키 재시도로 처음 결과를 돌려줄 때 포함) 그 초안을 지운다 — 초안 자체를 그대로 제출하지 않는다. 지우기는 위 저장과 같은 조건(임시보관함의 초안 ·
|
||||
확인한 뒤 바뀌지 않음)일 때만 하고, 못 지우면 로그만 남긴다(발송은 성공). `draftId` 는 지문에 넣지 않는다.
|
||||
- **클라이언트 저장 주기**: 바꾼 내용이 있을 때 입력이 3초 멈추면 · 쉬지 않고 쓰는 중에도 30초마다 · 10초에 한 번까지 · 화면을 떠날 때, 한 번에 하나씩.
|
||||
찾지 못한 첨부(`MAIL_004`)가 남은 동안과 본문이 한도를 넘는 동안은 저장하지 않는다 (첨부가 빠진 판으로 저장본을 덮지 않게).
|
||||
다른 메일 프로그램이 만든 저장본에 실을 수 없는 첨부(허용 목록 밖 · 개수 · 합계 초과)가 있으면 첫 저장은 PUT 이 아니라 POST 다 — 원본을 지우지 않는다.
|
||||
보내기 전에는 진행 중인 저장을 기다린다 (그래야 `draftId` 가 최신이다).
|
||||
- **알려진 한계**:
|
||||
- 저장할 때마다 첨부 내용이 새 초안으로 복사돼 큰 첨부가 있으면 저장이 무겁다. 메일함 용량이 거의 차면 교체하는 순간 두 판이
|
||||
함께 있어 `MAIL_003` 이 날 수 있다.
|
||||
- 답장 대상은 초안의 사용자 머리 `X-Ara-Reply-To-Id`(메일 id)에 둔다. 우리 서버는 보낼 때 새 메일을 만들어 이 머리가 나가지 않지만,
|
||||
다른 메일 프로그램이 그 초안을 그대로 보내면 함께 나갈 수 있다.
|
||||
- 보내기에서 제출은 됐지만 보낸편지함으로 옮기지 못한 메일은 임시보관함에 보이지 않는 채로 남는다 (같은 키의 재시도는 그 메일을 찾는다).
|
||||
- 저장 응답을 받지 못하면(시간 초과 등) 서버는 이미 새 저장본을 만들었을 수 있다 — 다음 PUT 은 `MAIL_008` 이 되어 클라이언트가 새로 남기므로
|
||||
임시보관함에 한 통이 더 남을 수 있다 (잃는 내용은 없다).
|
||||
|
||||
### 서명 (`/api/mail-signature`)
|
||||
|
||||
```ts
|
||||
interface MailSignature {
|
||||
bodyHtml: string | null // 서명 서식 — 서식 본문 규칙 + 서명 그림. 정화 뒤 10,000자 이하
|
||||
body: string | null // 텍스트 서명 (텍스트 대체 본문에 넣는다) — 2,000자 이하
|
||||
isForNew: boolean // 새 메일에 넣는다 (기본 true)
|
||||
isForReply: boolean // 답장·전달에 넣는다 — 인용 위 (기본 true)
|
||||
images: SignatureImage[] // 서명이 쓰는 그림
|
||||
updatedAt: string | null // 저장한 적 없으면 null
|
||||
}
|
||||
|
||||
interface SignatureImage {
|
||||
id: string
|
||||
type: 'image/png' | 'image/jpeg' | 'image/gif'
|
||||
size: number // 바이트
|
||||
width: number // 원본 픽셀
|
||||
height: number
|
||||
}
|
||||
```
|
||||
|
||||
- 사용자마다 하나다. 저장한 적 없으면 기본값(`bodyHtml: null`·`body: null`·둘 다 `true`·`images: []`)을 돌려준다. 서버 DB 에 둔다(메일 엔진에 두지 않는다).
|
||||
- `PUT` 의 한도 — 넘으면 400 `VAL_001`: 텍스트 서명 2,000자, 서식 서명은 정화 전에 서식 본문과 같은 한도(500,000자·파서 비용)와 정화 뒤 10,000자,
|
||||
서명 그림 3장(서로 다른 그림 기준).
|
||||
- **서명 그림**: `POST /api/mail-signature/images` (multipart `file`) — PNG·JPEG·GIF 만(확장자·MIME·파일 앞부분 모두 검사, WebP·SVG 는 받지 않는다),
|
||||
한 장 300KB 이하(넘으면 413 `FILE_002` — 받은 메일의 본문 그림 한도와 같아서, 보낸 메일을 우리 웹메일로 다시 읽어도 그림이 보인다),
|
||||
가로·세로 각각 4096px 이하(넘으면 400 `FILE_001` — 작은 파일 안의 아주 큰 그림이 받는 쪽을 무겁게 하지 않게).
|
||||
서명에는 3장까지 넣는다(편집기가 먼저 막는다). 발송 본문·임시 저장본도 서로 다른 서명 그림 3장까지다(넘으면 400 `VAL_001`).
|
||||
올려 두기만 한 그림을 포함해 한 사람 10장까지 둘 수 있다(넘으면 올리기가 400 `VAL_001`).
|
||||
서명 HTML 은 그림을 `<img src="/api/mail-signature/images/{id}" width="144" alt="">` 로 가리킨다(`width` 1~600 — 작게 96 · 보통 144 · 원본은 원래 폭, 600 이하).
|
||||
- `GET /api/mail-signature/images/{id}` 는 그 사람의 그림만 내준다(남의 것은 404 `RES_001`) — 편집기·미리 보기용이고, 받는 사람은 이 주소를 쓰지 않는다.
|
||||
저장할 때 확인한 형식 그대로, `Content-Disposition: inline` · `X-Content-Type-Options: nosniff` · `Cache-Control: private, max-age=86400`
|
||||
(그림 id 의 내용은 바뀌지 않는다 — 새로 올리면 새 id).
|
||||
- `PUT` 은 서명 HTML 이 더는 가리키지 않는 그림을 지운다 — 다만 한 번도 저장하지 않은 그림 가운데 10분 안에 올린 것은 남긴다
|
||||
(그림을 넣자마자 저장하면 올리기와 저장이 엇갈릴 수 있다). 올리고 저장하지 않은 그림은 하루 뒤 지운다.
|
||||
- **보낼 때**: 쓰기 화면이 서명을 본문에 보통 글로 넣어 둔 그대로 보낸다 — 서명 그림은 위 "서식 본문"의 그림 예외로 메일 안에 함께 실린다
|
||||
(`multipart/related` 의 그림 부분 — `Content-Disposition: inline`, `Content-ID: <sig-{그림 id}@{보내는 사람 도메인}>`, 이름 `signature-1.png` …,
|
||||
첨부가 있으면 그 묶음을 `multipart/mixed` 안에 둔다). 우리 웹메일에서는 위 `hasAttachment`·`isInline` 규칙으로 첨부로 보이지 않는다.
|
||||
텍스트 대체 본문에는 그림이 없다. 임시 저장본도 같은 그림 주소를 쓴다.
|
||||
- **알려진 한계**: 서명 그림을 지운 뒤 그 그림을 쓴 임시 저장본을 보내면 그 그림은 빠진다. 답장·전달로 인용한 원문 속 그림(남의 서명 로고 포함)은 인용에서 빠진다.
|
||||
|
||||
### 받는 사람 추천 (`/api/recipient-suggestions`)
|
||||
|
||||
```ts
|
||||
interface RecipientSuggestion {
|
||||
name: string | null
|
||||
email: string
|
||||
source: 'directory' | 'recent' // directory = 사내(웹메일에 로그인한 적 있는 직원), recent = 최근 주고받은 주소
|
||||
}
|
||||
```
|
||||
|
||||
- `q` 가 있으면 이름·주소에 그 글자가 든 것만 준다(대소문자 무시). 응답은 커서 페이지네이션 규칙 그대로(`{ items, pageInfo }`) —
|
||||
커서는 받은 그 `q` 로만 쓴다(다른 `q` 와 함께 보내면 400 `VAL_001`).
|
||||
- **순서**: 사내 → 최근 주고받은 주소, 각 묶음 안에서는 주고받은 횟수와 최근성으로. 두 쪽에 다 있는 주소는 사내로 한 번만 나온다.
|
||||
- **최근 주고받은 주소**는 그 사람의 보낸편지함 최근 500통(받는 사람·참조·숨은참조)과 받은편지함 최근 500통(보낸 사람)에서 모은다 —
|
||||
다른 직원의 메일함은 읽지 않는다. 나·메일 엔진 관리자·게이트웨이·막은 계정·noreply 류는 뺀다.
|
||||
- **사내 목록**은 로그인에 성공한 직원(주소·이름)을 서버가 기록해 만든다 — 메일 엔진의 계정 목록 권한은 쓰지 않는다(일부러 막아 두었다).
|
||||
한 번도 로그인하지 않은 직원은 그 사람과 메일을 주고받기 전까지 나오지 않는다. 최근 90일 안에 로그인한 직원만 사내로 추천한다
|
||||
(퇴사자가 계속 맨 앞에 나와 반송되지 않게 — 웹메일 세션은 최대 30일이라 쓰는 사람은 그 안에 다시 로그인한다).
|
||||
- **알려진 한계**: 최근 주고받은 주소의 이름은 받은 메일의 보낸 사람 표시 이름에서 온다 — 바깥 사람이 동료 이름을 표시 이름으로 쓰면 그 이름으로 추천된다.
|
||||
추천 줄은 늘 주소를 함께 보이고, 사내와 최근 묶음을 나눠 보인다.
|
||||
- 응답은 `Cache-Control: private, no-store`. 서버는 사용자마다 10분 캐시하고(서버 메모리, 최대 1,000명), 그 사람이 메일을 보내면 다시 모은다.
|
||||
같은 사람의 동시 요청은 한 번만 모은다. 로그에는 개수만 남긴다.
|
||||
- **클라이언트**: 쓰기 화면을 열 때 `q` 없이 `limit=100` 으로 한 번 받아 두고 치는 대로 거른다(초성 포함). 맞는 것이 모자랄 때만 `q` 로 다시 묻는다(250ms 기다린 뒤).
|
||||
|
||||
### 이 단계에서 하지 않는 것
|
||||
|
||||
실시간 새 메일 알림(SSE), 검색 화면, 임시 저장(Drafts 쓰기), 본문 안 이미지(인라인 cid — 붙여 넣은 이미지는 첨부로), 외부 이미지 보기(이미지 중계), 다중 선택 일괄 처리.
|
||||
실시간 새 메일 알림(SSE), 검색 화면, 본문 안 이미지(인라인 cid — 붙여 넣은 이미지는 첨부로, 서명 그림만 예외), 외부 이미지 보기(이미지 중계), 다중 선택 일괄 처리,
|
||||
여러 개의 서명, 엔진 계정 목록 기반의 사내 주소록.
|
||||
|
||||
@@ -19,6 +19,7 @@ const Map<String, String> kErrorCodeMessages = {
|
||||
'MAIL_005': '이미 보낸 메일과 내용이 달라 보내지 않았습니다. 보낸편지함을 확인한 뒤 다시 보내 주세요.',
|
||||
'MAIL_006': '첨부는 한 메일에 최대 20개까지 보낼 수 있습니다.',
|
||||
'MAIL_007': '본문이 너무 길어 보낼 수 없습니다. 내용을 줄인 뒤 다시 보내 주세요.',
|
||||
'MAIL_008': '임시 저장한 메일이 다른 곳에서 바뀌었거나 지워졌습니다. 지금 내용을 새 임시 저장본으로 남겨 주세요.',
|
||||
};
|
||||
|
||||
/// 매핑 실패 시 사용하는 폴백 코드
|
||||
|
||||
+21
-3
@@ -14,6 +14,10 @@
|
||||
log_format main_rid '$remote_addr - $remote_user [$time_local] "$request" $status $body_bytes_sent '
|
||||
'"$http_referer" "$http_user_agent" "$http_x_forwarded_for" rid=$request_id rt=$request_time';
|
||||
|
||||
# /api 용 — main_rid 와 같되 요청 줄에서 쿼리를 뺀다: 검색어(q) 등이 요청마다 남지 않게 (계약 2절 "받는 사람 추천" — 로그에는 개수만)
|
||||
log_format api_rid '$remote_addr - $remote_user [$time_local] "$request_method $uri $server_protocol" $status $body_bytes_sent '
|
||||
'"$http_referer" "$http_user_agent" "$http_x_forwarded_for" rid=$request_id rt=$request_time';
|
||||
|
||||
server {
|
||||
# 비-root(nginx-unprivileged) 구동이라 1024 미만 포트를 쓸 수 없다
|
||||
listen 8080;
|
||||
@@ -21,7 +25,7 @@ server {
|
||||
|
||||
access_log /var/log/nginx/access.log main_rid;
|
||||
|
||||
# 기본 요청 본문 한도 — 백엔드 JSON 파서 한도(1mb)와 같다. 업로드·발송 경로만 아래 /api 에서 늘린다
|
||||
# 기본 요청 본문 한도 — 백엔드 JSON 파서 한도(1mb)와 같다. 업로드·발송·임시 저장 경로만 아래 /api 에서 늘린다
|
||||
client_max_body_size 1m;
|
||||
|
||||
# ----------------------------------------
|
||||
@@ -132,6 +136,8 @@ server {
|
||||
# API 응답의 보안 헤더는 백엔드가 정한다 — 화면용 CSP 등을 겹쳐 붙이지 않는다
|
||||
add_header_inherit off;
|
||||
proxy_pass $backend_upstream;
|
||||
# 쿼리 없는 로그 형식 — 하위 location 은 물려받는다. 오류로 넘어가는 이름 있는 location(아래 @api_*)은 따로 적는다
|
||||
access_log /var/log/nginx/access.log api_rid;
|
||||
|
||||
# nginx 가 백엔드에 닿기 전에 끝내는 요청도 표준 실패 형식(JSON)으로 답한다 — 계약 머리말 "프록시가 직접 내는 오류"
|
||||
# (= 없이 쓰면 상태 코드는 원래 값 그대로다. 백엔드가 보낸 4xx·5xx 는 proxy_intercept_errors 가 꺼져 있어 손대지 않는다)
|
||||
@@ -168,7 +174,7 @@ server {
|
||||
proxy_pass $backend_upstream;
|
||||
}
|
||||
|
||||
# 메일 발송(POST /api/messages)만 JSON 본문 4MB — 서식 본문·긴 인용 때문 (계약 2절 413 표, 백엔드도 이 경로만 4MB 파서).
|
||||
# 메일 발송(POST /api/messages)은 JSON 본문 4MB — 서식 본문·긴 인용 때문 (계약 2절 413 표, 백엔드도 이 경로와 아래 임시 저장만 4MB 파서).
|
||||
# 계약이 정한 고정값이라 env 로 바꾸지 않는다 (나머지 /api 의 1m 도 백엔드 JSON_BODY_LIMIT 고정값과 같다 — 값이 같아 여유를 두지 않는다).
|
||||
# 정확히 이 경로만 — /api/messages/{id}(PATCH·DELETE·첨부 내려받기)는 위 location 의 1m 그대로다. 같은 경로의 GET(목록)은 본문이 없다.
|
||||
location = /api/messages {
|
||||
@@ -176,13 +182,24 @@ server {
|
||||
proxy_read_timeout 90s;
|
||||
proxy_pass $backend_upstream;
|
||||
}
|
||||
|
||||
# 메일 임시 저장 — 처음 저장(POST /api/drafts)·바꿔 저장(PUT /api/drafts/{id})도 JSON 본문 4MB. 본문이 발송과 같은 필드·한도라서다
|
||||
# (계약 2절 413 표·"임시 저장", 백엔드도 이 두 요청은 발송과 같은 4MB 파서). 발송과 같은 이유로 env 로 바꾸지 않는다.
|
||||
# 조회(GET /api/drafts/{id})는 본문이 없어 같은 블록에 둔다. 저장도 메일 엔진에 만들고·다시 읽고·옛것을 지우는 요청이 이어져 90s 를 기다린다
|
||||
location ^~ /api/drafts {
|
||||
client_max_body_size 4m;
|
||||
proxy_read_timeout 90s;
|
||||
proxy_pass $backend_upstream;
|
||||
}
|
||||
}
|
||||
|
||||
# /api 의 nginx 오류 응답 — message 는 비운다 (문구는 클라이언트가 code 로 사전에서 찾는다, 레지스트리와 겹치지 않게).
|
||||
# traceId 는 이 nginx 의 요청 ID(로그의 rid=)다. 이름 있는 location 은 server 단계라 화면용 헤더 상속을 끈다.
|
||||
# traceId 는 이 nginx 의 요청 ID(로그의 rid=)다. 이름 있는 location 은 server 단계라 화면용 헤더 상속을 끄고 로그 형식(api_rid)도 다시 정한다
|
||||
# (내부 이동 뒤에는 이 location 의 access_log 가 쓰인다).
|
||||
# return 200 은 본문만 정한다 — error_page 를 = 없이 썼으므로 상태 코드는 원래 값(413·502·504 등)이 그대로 나간다 (2026-10-27 확인)
|
||||
location @api_payload_too_large {
|
||||
add_header_inherit off;
|
||||
access_log /var/log/nginx/access.log api_rid;
|
||||
default_type application/json;
|
||||
add_header Cache-Control "no-store" always;
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
@@ -192,6 +209,7 @@ server {
|
||||
|
||||
location @api_unavailable {
|
||||
add_header_inherit off;
|
||||
access_log /var/log/nginx/access.log api_rid;
|
||||
default_type application/json;
|
||||
add_header Cache-Control "no-store" always;
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
<script setup lang="ts">
|
||||
// 화면을 떠나기 전 확인 — 세션 만료 대화상자와 같은 모양 (ModalDialog · role=alertdialog)
|
||||
// 쓰는 곳: 쓰기 화면(ComposeLeaveDialog — 저장하지 못한 내용) · 설정 › 메일 › 서명(저장하지 않은 서명)
|
||||
// [머무르기](계속 쓰기 · 계속 고치기)가 처음 초점이고 Esc 도 같다. 쓰는 쪽이 답을 받아 이동을 취소하거나(머무르기) 그대로 보낸다(나가기).
|
||||
// 단추는 오른쪽 정렬 — 머무르기는 채운 기본 단추 · 나가기는 보조 단추 (디자인 README "나가기 전 확인").
|
||||
// 고정 id — {idPrefix}-dialog · {idPrefix}-title · {idPrefix}-message · {idPrefix}-stay · {idPrefix}-confirm
|
||||
import ModalDialog from '@/components/feedback/ModalDialog.vue'
|
||||
|
||||
interface LeaveDialogCopy {
|
||||
title: string
|
||||
message: string
|
||||
/** 머무르기 단추 */
|
||||
stay: string
|
||||
/** 나가기 단추 */
|
||||
leave: string
|
||||
}
|
||||
|
||||
interface Props {
|
||||
open: boolean
|
||||
/** 고정 id 의 앞부분 — 화면에 하나뿐인 값 */
|
||||
idPrefix: string
|
||||
copy: LeaveDialogCopy
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
/** 머무르기 (Esc 포함) — 이동을 취소한다 */
|
||||
(e: 'stay'): void
|
||||
/** 나가기 — 바꾼 것을 버리고 이동한다 */
|
||||
(e: 'leave'): void
|
||||
}
|
||||
|
||||
defineProps<Props>()
|
||||
const emit = defineEmits<Emits>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ModalDialog
|
||||
:id="`${idPrefix}-dialog`"
|
||||
:open="open"
|
||||
role="alertdialog"
|
||||
:title="copy.title"
|
||||
:message="copy.message"
|
||||
:title-id="`${idPrefix}-title`"
|
||||
:message-id="`${idPrefix}-message`"
|
||||
@cancel="emit('stay')"
|
||||
>
|
||||
<template #actions>
|
||||
<!-- 처음 초점 — 실수로 Enter · Space 를 눌러도 바꾼 것이 남는다 -->
|
||||
<button
|
||||
:id="`${idPrefix}-stay`"
|
||||
type="button"
|
||||
class="modal-dialog__action"
|
||||
autofocus
|
||||
@click="emit('stay')"
|
||||
>
|
||||
{{ copy.stay }}
|
||||
</button>
|
||||
<button
|
||||
:id="`${idPrefix}-confirm`"
|
||||
type="button"
|
||||
class="modal-dialog__action modal-dialog__action--secondary"
|
||||
@click="emit('leave')"
|
||||
>
|
||||
{{ copy.leave }}
|
||||
</button>
|
||||
</template>
|
||||
</ModalDialog>
|
||||
</template>
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
// 모달 대화상자 틀 — 브라우저 <dialog>(showModal)로 띄운다. 모양은 디자인 원본 States "세션 만료".
|
||||
// 쓰는 곳: SessionExpiredDialog(세션 만료) · ComposeLeaveDialog(쓰기 화면 나가기 전 확인)
|
||||
// 쓰는 곳: SessionExpiredDialog(세션 만료) · LeaveConfirmDialog(떠나기 전 확인 — 쓰기 화면 · 설정 › 서명)
|
||||
// - 모달이라 뒤의 화면을 조작할 수 없다. 처음 초점은 autofocus 를 붙인 단추 (없으면 브라우저가 첫 단추로)
|
||||
// - Esc(cancel)는 브라우저가 닫지 않게 막고 cancel 로 알린다 — 무엇을 할지는 쓰는 쪽이 정한다
|
||||
// - 닫기는 열기보다 먼저 한다 — 한 대화상자가 닫히고 다른 대화상자가 열리는 때(세션 만료) 둘이 겹치지 않게
|
||||
|
||||
@@ -1,11 +1,26 @@
|
||||
<script setup lang="ts">
|
||||
// 화면 오른쪽 아래 알림 (디자인 원본 States "오류 알림" · "보내기 완료")
|
||||
// 오류: 흰 카드 + 문의 코드(traceId 앞 8자리) + 닫기 / 성공: 브랜드 바탕 + 이동 링크
|
||||
// 오류: 흰 카드 + 문의 코드(traceId 앞 8자리) + 닫기 / 성공: 브랜드 바탕 + 이동 링크 또는 그 자리의 단추(되돌리기 등)
|
||||
// 마우스가 올라가 있거나 초점이 알림 안에 있는 동안은 닫히지 않는다 (toast.store 의 hold · release — WCAG 2.2.1)
|
||||
import AppIcon from '@/components/AppIcon.vue'
|
||||
import { formatTraceCode } from '@/shared/utils/format'
|
||||
import { useToastStore } from '@/stores/toast.store'
|
||||
import { useToastStore, type Toast } from '@/stores/toast.store'
|
||||
|
||||
const toastStore = useToastStore()
|
||||
|
||||
/** 초점이 알림 밖으로 나갔을 때만 놓는다 — 알림 안의 다른 단추로 옮긴 것은 그대로 붙잡는다 */
|
||||
function onFocusOut(event: FocusEvent, id: number) {
|
||||
const next = event.relatedTarget
|
||||
if (next instanceof Node && event.currentTarget instanceof Node && event.currentTarget.contains(next)) return
|
||||
toastStore.release(id, 'focus')
|
||||
}
|
||||
|
||||
/** 그 자리의 단추 — 알림을 닫고 할 일을 한다 */
|
||||
function runAction(toast: Toast) {
|
||||
const action = toast.action
|
||||
toastStore.dismiss(toast.id)
|
||||
if (action && 'run' in action) action.run()
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -23,6 +38,10 @@ const toastStore = useToastStore()
|
||||
:key="toast.id"
|
||||
:class="['toast', `toast--${toast.kind}`]"
|
||||
:role="toast.kind === 'error' ? 'alert' : 'status'"
|
||||
@mouseenter="toastStore.hold(toast.id, 'pointer')"
|
||||
@mouseleave="toastStore.release(toast.id, 'pointer')"
|
||||
@focusin="toastStore.hold(toast.id, 'focus')"
|
||||
@focusout="onFocusOut($event, toast.id)"
|
||||
>
|
||||
<AppIcon
|
||||
:name="toast.kind === 'error' ? 'alert-circle' : 'check'"
|
||||
@@ -38,13 +57,21 @@ const toastStore = useToastStore()
|
||||
>문의 코드 {{ formatTraceCode(toast.traceId) }}</span>
|
||||
</div>
|
||||
<RouterLink
|
||||
v-if="toast.action"
|
||||
v-if="toast.action && 'to' in toast.action"
|
||||
:to="toast.action.to"
|
||||
class="toast__action"
|
||||
@click="toastStore.dismiss(toast.id)"
|
||||
>
|
||||
{{ toast.action.label }}
|
||||
</RouterLink>
|
||||
<button
|
||||
v-else-if="toast.action"
|
||||
type="button"
|
||||
class="toast__action"
|
||||
@click="runAction(toast)"
|
||||
>
|
||||
{{ toast.action.label }}
|
||||
</button>
|
||||
<button
|
||||
v-if="toast.kind === 'error'"
|
||||
type="button"
|
||||
@@ -140,8 +167,11 @@ const toastStore = useToastStore()
|
||||
align-items: center;
|
||||
min-height: var(--size-touch);
|
||||
padding: 0 var(--space-10);
|
||||
border: 0;
|
||||
border-radius: var(--radius-sm);
|
||||
background: transparent;
|
||||
color: var(--color-on-brand-link);
|
||||
font: inherit;
|
||||
font-weight: 600;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
<script setup lang="ts">
|
||||
// 쓰기 화면의 칸 안내 띠 — 받는 사람 오류(MAIL_002) · 제목 길이(VAL_001) · 본문 길이(MAIL_007) (디자인 원본 Compose)
|
||||
// 문구는 쓰기 화면이 에러 코드 사전에서 가져와 준다. 그 칸의 입력이 id 를 aria-describedby 로 가리킨다
|
||||
import AppIcon from '@/components/AppIcon.vue'
|
||||
|
||||
interface Props {
|
||||
/** 칸이 가리키는 id — 화면에 하나뿐인 값 */
|
||||
id: string
|
||||
/** 사전 문구 */
|
||||
message: string
|
||||
}
|
||||
|
||||
defineProps<Props>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<p
|
||||
:id="id"
|
||||
class="compose-alert"
|
||||
role="alert"
|
||||
>
|
||||
<AppIcon
|
||||
name="alert-circle"
|
||||
:size="15"
|
||||
/>
|
||||
<span>{{ message }}</span>
|
||||
</p>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.compose-alert {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-8);
|
||||
margin: 0;
|
||||
padding: var(--space-8) var(--content-inline);
|
||||
border-bottom: var(--border-width) solid var(--color-danger-border);
|
||||
background: var(--color-danger-surface);
|
||||
color: var(--color-danger);
|
||||
font-size: var(--font-size-label);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,135 @@
|
||||
<script setup lang="ts">
|
||||
// 쓰기 화면 아래 단추 줄의 임시 저장 상태 (디자인 원본 ComposeAssist · README "2단계 › 임시 저장") — 12px
|
||||
// - 저장하는 중: "저장하는 중…" (#667085)
|
||||
// - 저장됨: ✓ "오후 3:12에 임시 저장됨" (✓ #0E7C86)
|
||||
// - 실패: "임시 저장하지 못했습니다" (#B42318) + [다시 시도]
|
||||
// - 다른 곳에서 바뀐 저장본(MAIL_008): 정보 아이콘 "다른 곳에서 바뀐 저장본이 있어 새로 저장했습니다 · 오후 3:14" (#7A5200)
|
||||
// 상태 글은 role="status" 라 바뀌면 스크린리더가 읽는다. 아직 저장한 적이 없으면 아무것도 보이지 않는다.
|
||||
// 고정 id — #compose-draft-status · 다시 시도 #compose-draft-retry
|
||||
import { computed } from 'vue'
|
||||
|
||||
import AppIcon from '@/components/AppIcon.vue'
|
||||
import type { DraftSaveStatus } from '@/composables/useDraftSaving'
|
||||
import type { IconName } from '@/shared/constants/icons'
|
||||
import { DRAFT_STATUS_TEXT } from '@/shared/constants/mail'
|
||||
import { formatDraftSavedTime } from '@/shared/utils/format'
|
||||
|
||||
interface Props {
|
||||
status: DraftSaveStatus
|
||||
/** 마지막으로 저장한 시각 (ISO 8601) */
|
||||
savedAt: string | null
|
||||
/** 다시 시도할 수 없다 (보내는 중 등) */
|
||||
retryDisabled?: boolean
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'retry'): void
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
retryDisabled: false,
|
||||
})
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
const time = computed(() => (props.savedAt ? formatDraftSavedTime(props.savedAt) : ''))
|
||||
|
||||
const view = computed<{ icon: IconName; text: string; tone: 'muted' | 'saved' | 'danger' | 'warning' } | null>(() => {
|
||||
switch (props.status) {
|
||||
case 'saving':
|
||||
return { icon: 'loader', text: DRAFT_STATUS_TEXT.saving, tone: 'muted' }
|
||||
case 'saved':
|
||||
return time.value ? { icon: 'check', text: DRAFT_STATUS_TEXT.saved(time.value), tone: 'saved' } : null
|
||||
case 'replaced':
|
||||
return { icon: 'info', text: DRAFT_STATUS_TEXT.replaced(time.value), tone: 'warning' }
|
||||
case 'failed':
|
||||
return { icon: 'alert-circle', text: DRAFT_STATUS_TEXT.failed, tone: 'danger' }
|
||||
default:
|
||||
return null
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span class="draft-status">
|
||||
<span
|
||||
id="compose-draft-status"
|
||||
:class="['draft-status__text', view ? `draft-status__text--${view.tone}` : null]"
|
||||
role="status"
|
||||
:data-status="status"
|
||||
>
|
||||
<template v-if="view">
|
||||
<AppIcon
|
||||
:name="view.icon"
|
||||
:size="14"
|
||||
:stroke-width="view.tone === 'saved' ? 2.2 : 1.9"
|
||||
:class="['draft-status__icon', { 'draft-status__icon--saved': view.tone === 'saved' }]"
|
||||
/>
|
||||
<span>{{ view.text }}</span>
|
||||
</template>
|
||||
</span>
|
||||
<button
|
||||
v-if="status === 'failed'"
|
||||
id="compose-draft-retry"
|
||||
type="button"
|
||||
class="draft-status__retry"
|
||||
:disabled="retryDisabled"
|
||||
@click="emit('retry')"
|
||||
>
|
||||
{{ DRAFT_STATUS_TEXT.retry }}
|
||||
</button>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.draft-status {
|
||||
display: inline-flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: var(--space-2) var(--space-4);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.draft-status__text {
|
||||
display: inline-flex;
|
||||
align-items: flex-start;
|
||||
gap: var(--space-6);
|
||||
font-size: var(--font-size-small);
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.draft-status__icon {
|
||||
margin-top: var(--space-2);
|
||||
}
|
||||
|
||||
.draft-status__icon--saved {
|
||||
color: var(--color-brand);
|
||||
}
|
||||
|
||||
.draft-status__text--danger {
|
||||
color: var(--color-danger);
|
||||
}
|
||||
|
||||
.draft-status__text--warning {
|
||||
color: var(--color-warning-text);
|
||||
}
|
||||
|
||||
.draft-status__retry {
|
||||
min-height: var(--size-touch);
|
||||
padding: 0 var(--space-10);
|
||||
border: 0;
|
||||
border-radius: var(--radius-md);
|
||||
background: transparent;
|
||||
color: var(--color-brand);
|
||||
font-size: var(--font-size-label);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.draft-status__retry:hover:not(:disabled) {
|
||||
background: var(--color-surface-muted);
|
||||
}
|
||||
|
||||
.draft-status__retry:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,136 @@
|
||||
<script setup lang="ts">
|
||||
// 쓰기 화면의 아래 단추 줄 — [보내기] [임시 저장] + 임시 저장 상태 글 + 보내는 사람 (디자인 원본 Compose · ComposeAssist)
|
||||
// 「취소」는 없다 — 머리의 닫기(✕)가 저장한 채 닫는다. [보내기]는 쓰기 화면의 form 을 제출한다(type="submit").
|
||||
// 고정 id · 이름 — 화면 검증 · 테스트가 쓴다: 보내기 .compose__send · 임시 저장 #compose-save-draft ·
|
||||
// 상태 #compose-draft-status(ComposeDraftStatus) · 줄 .compose__footer
|
||||
import AppIcon from '@/components/AppIcon.vue'
|
||||
import ComposeDraftStatus from '@/components/mail/ComposeDraftStatus.vue'
|
||||
import type { DraftSaveStatus } from '@/composables/useDraftSaving'
|
||||
|
||||
interface Props {
|
||||
canSend: boolean
|
||||
sending: boolean
|
||||
canSaveDraft: boolean
|
||||
draftStatus: DraftSaveStatus
|
||||
/** 마지막으로 저장한 시각 (ISO 8601) */
|
||||
draftSavedAt: string | null
|
||||
/** 보내는 사람 — "이름 <주소>" (비면 숨긴다) */
|
||||
sender: string
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
/** 「임시 저장」 · 상태 글의 [다시 시도] */
|
||||
(e: 'save-draft'): void
|
||||
}
|
||||
|
||||
defineProps<Props>()
|
||||
const emit = defineEmits<Emits>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="compose__footer">
|
||||
<button
|
||||
type="submit"
|
||||
class="compose__send"
|
||||
:disabled="!canSend"
|
||||
:aria-busy="sending"
|
||||
>
|
||||
<AppIcon
|
||||
name="send"
|
||||
:size="15"
|
||||
:stroke-width="2"
|
||||
/>
|
||||
<span>{{ sending ? '보내는 중…' : '보내기' }}</span>
|
||||
</button>
|
||||
<button
|
||||
id="compose-save-draft"
|
||||
type="button"
|
||||
class="compose__save"
|
||||
:disabled="!canSaveDraft"
|
||||
@click="emit('save-draft')"
|
||||
>
|
||||
임시 저장
|
||||
</button>
|
||||
<ComposeDraftStatus
|
||||
:status="draftStatus"
|
||||
:saved-at="draftSavedAt"
|
||||
:retry-disabled="!canSaveDraft"
|
||||
@retry="emit('save-draft')"
|
||||
/>
|
||||
<span
|
||||
v-if="sender"
|
||||
class="compose__sender"
|
||||
>
|
||||
<span class="compose__sender-label">보내는 사람</span>
|
||||
{{ sender }}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.compose__footer {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: var(--space-8);
|
||||
padding: var(--space-10) var(--content-inline);
|
||||
border-top: var(--border-width) solid var(--color-border);
|
||||
}
|
||||
|
||||
.compose__send {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-8);
|
||||
min-height: var(--size-touch);
|
||||
padding: 0 var(--space-20);
|
||||
border: 0;
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--color-brand);
|
||||
color: var(--color-on-brand);
|
||||
font-size: var(--font-size-emphasis);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.compose__send:hover:not(:disabled) {
|
||||
background: var(--color-brand-strong);
|
||||
}
|
||||
|
||||
.compose__send:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* 「임시 저장」 — 보조 단추 (디자인 원본 ComposeAssist) */
|
||||
.compose__save {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-height: var(--size-touch);
|
||||
padding: 0 var(--space-14);
|
||||
border: 0;
|
||||
border-radius: var(--radius-md);
|
||||
background: transparent;
|
||||
color: var(--color-text-strong);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.compose__save:hover:not(:disabled) {
|
||||
background: var(--color-surface-muted);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.compose__save:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.compose__sender {
|
||||
margin-left: auto;
|
||||
font-size: var(--font-size-small);
|
||||
color: var(--color-text-muted);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.compose__sender-label {
|
||||
margin-right: var(--space-6);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,99 @@
|
||||
<script setup lang="ts">
|
||||
// 쓰기 화면의 머리 — 제목(쓰기 종류) · 휴지통 · 닫기 (디자인 원본 ComposeAssist: 단추 사이 2)
|
||||
// 휴지통은 저장본을 휴지통으로 옮기고 닫는다(쓰기 화면이 한다), 닫기는 저장한 채 닫는다 — 떠나기 전에 남은 변경을 저장한다.
|
||||
// 고정 id · 이름 — 화면 검증 · 테스트가 쓴다: 제목 #compose-title · 휴지통 #compose-discard · 닫기 .compose__close
|
||||
import type { RouteLocationRaw } from 'vue-router'
|
||||
|
||||
import AppIcon from '@/components/AppIcon.vue'
|
||||
import { DRAFT_DISCARD_LABEL } from '@/shared/constants/mail'
|
||||
|
||||
interface Props {
|
||||
title: string
|
||||
/** 휴지통을 누를 수 없다 — 버리는 중 · 보내는 중 · 저장본을 불러오는 중 */
|
||||
discardDisabled: boolean
|
||||
/** 닫으면 갈 곳 — 보던 화면 */
|
||||
closeTo: RouteLocationRaw
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'discard'): void
|
||||
}
|
||||
|
||||
defineProps<Props>()
|
||||
const emit = defineEmits<Emits>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="compose__header">
|
||||
<h1
|
||||
id="compose-title"
|
||||
class="compose__title"
|
||||
>
|
||||
{{ title }}
|
||||
</h1>
|
||||
<!-- 저장본을 휴지통으로 옮기고 닫는다 (디자인 원본 ComposeAssist "임시 저장본 삭제") -->
|
||||
<button
|
||||
id="compose-discard"
|
||||
type="button"
|
||||
class="compose__icon-button"
|
||||
:aria-label="DRAFT_DISCARD_LABEL"
|
||||
title="삭제"
|
||||
:disabled="discardDisabled"
|
||||
@click="emit('discard')"
|
||||
>
|
||||
<AppIcon name="trash" />
|
||||
</button>
|
||||
<!-- 닫기는 저장한 채 닫는다 — 떠나기 전에 남은 변경을 저장한다 -->
|
||||
<RouterLink
|
||||
:to="closeTo"
|
||||
class="compose__icon-button compose__close"
|
||||
aria-label="쓰기 닫기"
|
||||
title="닫기"
|
||||
>
|
||||
<AppIcon name="x" />
|
||||
</RouterLink>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.compose__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
min-height: var(--size-header-min);
|
||||
padding: var(--space-4) var(--space-18) var(--space-4) var(--content-inline);
|
||||
border-bottom: var(--border-width) solid var(--color-border);
|
||||
}
|
||||
|
||||
.compose__title {
|
||||
flex: 1 1 auto;
|
||||
margin: 0;
|
||||
font-size: var(--font-size-title-sm);
|
||||
font-weight: 700;
|
||||
letter-spacing: var(--letter-spacing-tight);
|
||||
}
|
||||
|
||||
/* 머리의 아이콘 단추 — 휴지통 · 닫기 (44) */
|
||||
.compose__icon-button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: var(--size-touch);
|
||||
height: var(--size-touch);
|
||||
padding: 0;
|
||||
border: 0;
|
||||
border-radius: var(--radius-md);
|
||||
background: transparent;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.compose__icon-button:hover:not(:disabled) {
|
||||
background: var(--color-surface-muted);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.compose__icon-button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
</style>
|
||||
@@ -1,9 +1,10 @@
|
||||
<script setup lang="ts">
|
||||
// 쓰기 화면 나가기 전 확인 — 디자인 README "나가기 전 확인" (세션 만료 대화상자와 같은 모양 · ModalDialog)
|
||||
// 쓰기 화면 나가기 전 확인 — 디자인 README "2단계 › 임시 저장 › 나가기 확인" (공통 틀 LeaveConfirmDialog · ModalDialog)
|
||||
// 떠나기 전에 남은 변경을 저장하고, 저장하지 못한 내용이 있을 때만 쓰기 화면이 연다.
|
||||
// [계속 쓰기]가 처음 초점이고 Esc 도 같다. 쓰기 화면이 답을 받아 이동을 취소하거나(계속 쓰기) 그대로 보낸다(나가기).
|
||||
// 고정 id — 화면 검증 · 테스트가 쓴다: 대화상자 #compose-leave-dialog · 제목 #compose-leave-title · 글 #compose-leave-message ·
|
||||
// 단추 #compose-leave-stay(계속 쓰기) · #compose-leave-confirm(나가기)
|
||||
import ModalDialog from '@/components/feedback/ModalDialog.vue'
|
||||
import LeaveConfirmDialog from '@/components/feedback/LeaveConfirmDialog.vue'
|
||||
import { COMPOSE_LEAVE_DIALOG } from '@/shared/constants/mail'
|
||||
|
||||
interface Props {
|
||||
@@ -13,7 +14,7 @@ interface Props {
|
||||
interface Emits {
|
||||
/** 계속 쓰기 (Esc 포함) — 이동을 취소한다 */
|
||||
(e: 'stay'): void
|
||||
/** 나가기 — 쓰던 내용을 버리고 이동한다 */
|
||||
/** 나가기 — 저장하지 못한 내용을 버리고 이동한다 */
|
||||
(e: 'leave'): void
|
||||
}
|
||||
|
||||
@@ -22,35 +23,11 @@ const emit = defineEmits<Emits>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ModalDialog
|
||||
id="compose-leave-dialog"
|
||||
<LeaveConfirmDialog
|
||||
:open="open"
|
||||
role="alertdialog"
|
||||
:title="COMPOSE_LEAVE_DIALOG.title"
|
||||
:message="COMPOSE_LEAVE_DIALOG.message"
|
||||
title-id="compose-leave-title"
|
||||
message-id="compose-leave-message"
|
||||
@cancel="emit('stay')"
|
||||
>
|
||||
<template #actions>
|
||||
<!-- 처음 초점 — 실수로 Enter · Space 를 눌러도 쓰던 내용이 남는다 -->
|
||||
<button
|
||||
id="compose-leave-stay"
|
||||
type="button"
|
||||
class="modal-dialog__action"
|
||||
autofocus
|
||||
@click="emit('stay')"
|
||||
>
|
||||
{{ COMPOSE_LEAVE_DIALOG.stay }}
|
||||
</button>
|
||||
<button
|
||||
id="compose-leave-confirm"
|
||||
type="button"
|
||||
class="modal-dialog__action modal-dialog__action--secondary"
|
||||
@click="emit('leave')"
|
||||
>
|
||||
{{ COMPOSE_LEAVE_DIALOG.leave }}
|
||||
</button>
|
||||
</template>
|
||||
</ModalDialog>
|
||||
id-prefix="compose-leave"
|
||||
:copy="COMPOSE_LEAVE_DIALOG"
|
||||
@stay="emit('stay')"
|
||||
@leave="emit('leave')"
|
||||
/>
|
||||
</template>
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { createPinia, setActivePinia, type Pinia } from 'pinia'
|
||||
|
||||
import type { Mailbox, MessageSummary } from '@/shared/types/mail'
|
||||
import { useAuthStore } from '@/stores/auth.store'
|
||||
import { useMailStore } from '@/stores/mail.store'
|
||||
import { restoreApiAdapter } from '@/test-utils/mock-api'
|
||||
import { createTestRouter } from '@/test-utils/router'
|
||||
import MessageList from './MessageList.vue'
|
||||
|
||||
const DRAFTS: Mailbox = {
|
||||
id: 'drafts',
|
||||
name: '임시보관함',
|
||||
role: 'drafts',
|
||||
parentId: null,
|
||||
sortOrder: 3,
|
||||
totalCount: 2,
|
||||
unreadCount: 0,
|
||||
}
|
||||
|
||||
function summary(id: string, partial: Partial<MessageSummary> = {}): MessageSummary {
|
||||
return {
|
||||
id,
|
||||
threadId: `t-${id}`,
|
||||
mailboxIds: [DRAFTS.id],
|
||||
from: { name: '김아라', email: 'ara.kim@arasystem.test' },
|
||||
to: [{ name: '이도윤', email: 'doyun.lee@arasystem.test' }],
|
||||
subject: `제목 ${id}`,
|
||||
preview: '',
|
||||
receivedAt: '2025-10-24T01:00:00Z',
|
||||
isRead: true,
|
||||
isFlagged: false,
|
||||
hasAttachment: false,
|
||||
size: 100,
|
||||
...partial,
|
||||
}
|
||||
}
|
||||
|
||||
describe('MessageList (분할 보기 목록)', () => {
|
||||
let pinia: Pinia
|
||||
|
||||
beforeEach(() => {
|
||||
pinia = createPinia()
|
||||
setActivePinia(pinia)
|
||||
useAuthStore().markAuthenticated({ email: 'ara.kim@arasystem.test', name: '김아라' })
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
restoreApiAdapter()
|
||||
})
|
||||
|
||||
async function mountList(mailbox: Mailbox, messages: MessageSummary[]) {
|
||||
const store = useMailStore()
|
||||
store.mailboxes = [mailbox]
|
||||
store.currentMailboxId = mailbox.id
|
||||
store.messages = messages
|
||||
store.pageInfo = { hasNext: false, nextCursor: null, total: messages.length }
|
||||
store.listStatus = 'ready'
|
||||
const router = createTestRouter()
|
||||
await router.push(`/mail/${mailbox.id}`)
|
||||
await router.isReady()
|
||||
return mount(MessageList, { props: { mailboxId: mailbox.id }, global: { plugins: [pinia, router] } })
|
||||
}
|
||||
|
||||
it('임시보관함 — 보낸 사람 자리에 받는 사람, 비었으면 흐린 "(받는 사람 없음)" · "(제목 없음)" — 누르면 쓰기 화면에서 이어 쓴다', async () => {
|
||||
const wrapper = await mountList(DRAFTS, [
|
||||
summary('d1', { isRead: false, to: [{ name: '박준호', email: 'junho.park@arasystem.test' }, { name: '인프라팀', email: 'infra@arasystem.test' }] }),
|
||||
summary('d2', { to: [], subject: '' }),
|
||||
])
|
||||
const rows = wrapper.findAll('.message-row')
|
||||
|
||||
expect(rows[0]?.get('.message-row__who').text()).toBe('박준호, 인프라팀')
|
||||
expect(rows[0]?.classes()).not.toContain('is-unread')
|
||||
expect(rows[0]?.get('.message-row__main').attributes('href')).toBe('/mail/compose?draft=d1')
|
||||
expect(rows[1]?.get('.message-row__who').text()).toBe('(받는 사람 없음)')
|
||||
expect(rows[1]?.get('.message-row__who').classes()).toContain('is-placeholder')
|
||||
expect(rows[1]?.get('.message-row__subject').text()).toBe('(제목 없음)')
|
||||
expect(rows[1]?.get('.message-row__subject').classes()).toContain('is-placeholder')
|
||||
})
|
||||
|
||||
it('보낸편지함은 그대로 "받는 사람: …" 이고 읽기 화면으로 간다', async () => {
|
||||
const sent: Mailbox = { ...DRAFTS, id: 'sent', name: '보낸편지함', role: 'sent' }
|
||||
const wrapper = await mountList(sent, [summary('s1', { mailboxIds: ['sent'] })])
|
||||
const row = wrapper.get('.message-row')
|
||||
|
||||
expect(row.get('.message-row__who').text()).toBe('받는 사람: 이도윤')
|
||||
expect(row.get('.message-row__main').attributes('href')).toBe('/mail/sent/s1')
|
||||
})
|
||||
})
|
||||
@@ -27,6 +27,8 @@ const showRecipients = computed(() => {
|
||||
const role = mailbox.value?.role
|
||||
return role ? OUTGOING_MAILBOX_ROLES.includes(role) : false
|
||||
})
|
||||
/** 임시보관함 — 행을 누르면 쓰기 화면에서 이어 쓴다 */
|
||||
const isDrafts = computed(() => mailbox.value?.role === 'drafts')
|
||||
const empty = computed(() =>
|
||||
emptyMailboxText({ name: mailbox.value?.name ?? '메일함', role: mailbox.value?.role ?? null }),
|
||||
)
|
||||
@@ -70,6 +72,7 @@ function goToInbox() {
|
||||
:mailbox-id="mailboxId"
|
||||
:selected="message.id === mailStore.selectedId"
|
||||
:show-recipients="showRecipients"
|
||||
:draft="isDrafts"
|
||||
@toggle-flag="mailStore.toggleFlag"
|
||||
/>
|
||||
</ul>
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
<script setup lang="ts">
|
||||
// 분할 보기 목록의 2줄 행 (디자인 원본 Main) — 안 읽음은 뱃지 없이 굵은 글자로만 구분한다
|
||||
// 임시보관함(draft)의 행은 보낸 사람 자리에 받는 사람(없으면 "(받는 사람 없음)")을 보이고, 누르면 쓰기 화면에서 이어 쓴다
|
||||
// (디자인 원본 ComposeAssist "임시보관함 목록" — 빈 값은 흐린 글자, 안 읽음 굵기는 쓰지 않는다)
|
||||
import { computed } from 'vue'
|
||||
import type { RouteLocationRaw } from 'vue-router'
|
||||
|
||||
import AppIcon from '@/components/AppIcon.vue'
|
||||
import { DRAFT_NO_RECIPIENTS, MAIL_NO_SUBJECT } from '@/shared/constants/mail'
|
||||
import type { MessageSummary } from '@/shared/types/mail'
|
||||
import { formatMailListTime } from '@/shared/utils/format'
|
||||
import { displayName, summarizeRecipients } from '@/shared/utils/mail-address'
|
||||
import { displayName, draftRecipientNames, summarizeRecipients } from '@/shared/utils/mail-address'
|
||||
|
||||
interface Props {
|
||||
message: MessageSummary
|
||||
@@ -14,6 +18,8 @@ interface Props {
|
||||
selected?: boolean
|
||||
/** 보낸편지함처럼 받는 사람을 보여 줄 메일함인지 */
|
||||
showRecipients?: boolean
|
||||
/** 임시보관함의 행 — 누르면 쓰기 화면에서 이어 쓴다 */
|
||||
draft?: boolean
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
@@ -23,31 +29,42 @@ interface Emits {
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
selected: false,
|
||||
showRecipients: false,
|
||||
draft: false,
|
||||
})
|
||||
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
/** 임시 저장본의 받는 사람 — 없으면 빈 문자열 */
|
||||
const draftRecipients = computed(() => (props.draft ? draftRecipientNames(props.message.to) : ''))
|
||||
const who = computed(() => {
|
||||
if (props.draft) return draftRecipients.value || DRAFT_NO_RECIPIENTS
|
||||
if (props.showRecipients) {
|
||||
const recipients = summarizeRecipients(props.message.to)
|
||||
return recipients ? `받는 사람: ${recipients}` : '받는 사람 없음'
|
||||
}
|
||||
return displayName(props.message.from) || '(보낸 사람 없음)'
|
||||
})
|
||||
const subject = computed(() => props.message.subject || '(제목 없음)')
|
||||
const subject = computed(() => props.message.subject || MAIL_NO_SUBJECT)
|
||||
const time = computed(() => formatMailListTime(props.message.receivedAt))
|
||||
/** 임시 저장본은 읽음 여부를 보이지 않는다 */
|
||||
const unread = computed(() => !props.draft && !props.message.isRead)
|
||||
const location = computed<RouteLocationRaw>(() =>
|
||||
props.draft
|
||||
? { name: 'mail-compose', query: { draft: props.message.id } }
|
||||
: { name: 'mail-message', params: { mailboxId: props.mailboxId, messageId: props.message.id } },
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<li :class="['message-row', { 'is-selected': selected, 'is-unread': !message.isRead }]">
|
||||
<li :class="['message-row', { 'is-selected': selected, 'is-unread': unread }]">
|
||||
<RouterLink
|
||||
class="message-row__main"
|
||||
:to="{ name: 'mail-message', params: { mailboxId, messageId: message.id } }"
|
||||
:to="location"
|
||||
>
|
||||
<span class="message-row__top">
|
||||
<span class="message-row__who">
|
||||
<span :class="['message-row__who', { 'is-placeholder': draft && draftRecipients === '' }]">
|
||||
<span
|
||||
v-if="!message.isRead"
|
||||
v-if="unread"
|
||||
class="visually-hidden"
|
||||
>안 읽음, </span>{{ who }}
|
||||
</span>
|
||||
@@ -58,7 +75,7 @@ const time = computed(() => formatMailListTime(props.message.receivedAt))
|
||||
</span>
|
||||
<!-- 본문 미리보기는 넣지 않는다 (디자인 원칙) — 제목 뒤에 첨부 클립만 -->
|
||||
<span class="message-row__bottom">
|
||||
<span class="message-row__subject">{{ subject }}</span>
|
||||
<span :class="['message-row__subject', { 'is-placeholder': draft && !message.subject }]">{{ subject }}</span>
|
||||
<AppIcon
|
||||
v-if="message.hasAttachment"
|
||||
name="paperclip"
|
||||
@@ -166,6 +183,12 @@ const time = computed(() => formatMailListTime(props.message.receivedAt))
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
/* 비어 있는 값 — "(받는 사람 없음)" · "(제목 없음)" (디자인 원본 ComposeAssist #667085) */
|
||||
.message-row__who.is-placeholder,
|
||||
.message-row__subject.is-placeholder {
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
/* 안 읽음 — 뱃지 대신 굵기·색으로만 구분 */
|
||||
.message-row.is-unread .message-row__who {
|
||||
font-weight: 650;
|
||||
|
||||
@@ -161,6 +161,52 @@ describe('MessageReader 본문 안내 (계약 2절 "읽기 한도")', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('MessageReader 첨부 목록 (계약 2절 "MessageAttachment.isInline")', () => {
|
||||
let pinia: Pinia
|
||||
|
||||
beforeEach(() => {
|
||||
pinia = createPinia()
|
||||
setActivePinia(pinia)
|
||||
})
|
||||
|
||||
async function mountWith(detail: MessageDetail) {
|
||||
const store = useMailStore()
|
||||
store.selected = detail
|
||||
store.selectedId = detail.id
|
||||
store.selectedStatus = 'ready'
|
||||
const router = createTestRouter()
|
||||
await router.push('/mail/inbox/m1')
|
||||
await router.isReady()
|
||||
return mount(MessageReader, { global: { plugins: [pinia, router] } })
|
||||
}
|
||||
|
||||
const REPORT = { id: 'a1', name: '보고서.pdf', type: 'application/pdf', size: 2048 }
|
||||
const LOGO = { id: 'a2', name: 'signature-1.png', type: 'image/png', size: 1024 }
|
||||
|
||||
it('본문 안에 그림으로 보인 첨부(isInline)는 목록 · 개수에서 뺀다', async () => {
|
||||
const wrapper = await mountWith({
|
||||
...DETAIL,
|
||||
hasAttachment: true,
|
||||
attachments: [
|
||||
{ ...REPORT, isInline: false },
|
||||
{ ...LOGO, isInline: true },
|
||||
],
|
||||
})
|
||||
|
||||
expect(wrapper.get('#reader-attachments-title').text()).toBe('첨부 파일 1개')
|
||||
expect(wrapper.findAll('.attachments__name').map((name) => name.text())).toEqual(['보고서.pdf'])
|
||||
})
|
||||
|
||||
it('본문 그림뿐이면 첨부 목록이 없다 — 표시 값이 없는(이전 서버) 첨부는 첨부로 본다', async () => {
|
||||
const inlineOnly = await mountWith({ ...DETAIL, attachments: [{ ...LOGO, isInline: true }] })
|
||||
expect(inlineOnly.find('#reader-attachments-title').exists()).toBe(false)
|
||||
inlineOnly.unmount()
|
||||
|
||||
const legacy = await mountWith({ ...DETAIL, attachments: [REPORT] })
|
||||
expect(legacy.findAll('.attachments__name').map((name) => name.text())).toEqual(['보고서.pdf'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('MessageReader 불러오기 실패', () => {
|
||||
let pinia: Pinia
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ import MessageAttachments from '@/components/mail/MessageAttachments.vue'
|
||||
import { useMailNavigation } from '@/composables/useMailNavigation'
|
||||
import { formatMailDateTime, initialOf } from '@/shared/utils/format'
|
||||
import { displayName, formatAddressList } from '@/shared/utils/mail-address'
|
||||
import { listedAttachments } from '@/shared/utils/mail-attachment'
|
||||
import { mailBodyNotices } from '@/shared/utils/mail-body-notices'
|
||||
import { useMailStore } from '@/stores/mail.store'
|
||||
|
||||
@@ -45,6 +46,8 @@ const subject = computed(() => message.value?.subject || '(제목 없음)')
|
||||
const receivedAt = computed(() => (message.value ? formatMailDateTime(message.value.receivedAt) : ''))
|
||||
/** 서버가 본문을 다 주지 못했을 때의 안내 — 서식을 비웠으면 먼저, 잘렸으면 다음 (계약 2절 "읽기 한도") */
|
||||
const bodyNotices = computed(() => (message.value ? mailBodyNotices(message.value) : []))
|
||||
/** 첨부 목록 — 본문 안에 그림으로 보인 것(서명 그림 · 본문 그림, isInline)은 뺀다 */
|
||||
const attachments = computed(() => (message.value ? listedAttachments(message.value.attachments) : []))
|
||||
|
||||
async function onDelete() {
|
||||
const current = message.value
|
||||
@@ -241,9 +244,9 @@ function leaveToList() {
|
||||
/>
|
||||
|
||||
<MessageAttachments
|
||||
v-if="message.attachments.length > 0"
|
||||
v-if="attachments.length > 0"
|
||||
:message-id="message.id"
|
||||
:attachments="message.attachments"
|
||||
:attachments="attachments"
|
||||
/>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
@@ -151,6 +151,37 @@ describe('MessageTable (목록 보기)', () => {
|
||||
expect(wrapper.findAll('tbody .mail-table__who')[0]?.text()).toBe('박준호')
|
||||
})
|
||||
|
||||
it('임시보관함 — 받는 사람 칸에 받는 사람을 쉼표로, 비었으면 흐린 "(받는 사람 없음)" · "(제목 없음)" — 누르면 쓰기 화면에서 이어 쓴다', async () => {
|
||||
const drafts: Mailbox = { ...INBOX, id: 'drafts', name: '임시보관함', role: 'drafts', unreadCount: 0 }
|
||||
const { wrapper, router } = await mountTable({
|
||||
mailbox: drafts,
|
||||
messages: [
|
||||
summary('d1', {
|
||||
isRead: false,
|
||||
to: [
|
||||
{ name: '박준호', email: 'junho.park@arasystem.test' },
|
||||
{ name: null, email: 'infra@arasystem.test' },
|
||||
],
|
||||
}),
|
||||
summary('d2', { to: [], subject: '' }),
|
||||
],
|
||||
})
|
||||
const rows = wrapper.findAll('tbody tr')
|
||||
|
||||
expect(rows[0]?.get('.mail-table__who').text()).toBe('박준호, infra@arasystem.test')
|
||||
// 임시 저장본은 읽음 여부를 보이지 않는다
|
||||
expect(rows[0]?.classes()).not.toContain('is-unread')
|
||||
expect(rows[1]?.get('.mail-table__who').text()).toBe('(받는 사람 없음)')
|
||||
expect(rows[1]?.get('.mail-table__who').classes()).toContain('is-placeholder')
|
||||
expect(rows[1]?.get('.mail-table__link').text()).toBe('(제목 없음)')
|
||||
expect(rows[1]?.get('.mail-table__link').classes()).toContain('is-placeholder')
|
||||
expect(rows[0]?.get('.mail-table__link').attributes('href')).toBe('/mail/compose?draft=d1')
|
||||
|
||||
await rows[1]?.get('.mail-table__who').trigger('click')
|
||||
await flushPromises()
|
||||
expect(router.currentRoute.value.fullPath).toBe('/mail/compose?draft=d2')
|
||||
})
|
||||
|
||||
it('서버에 더 있으면 "메일 더 보기" 로 다음 페이지를 불러온다', async () => {
|
||||
const requests = installMockApi(() =>
|
||||
ok({ items: [summary('m3')], pageInfo: { hasNext: false, nextCursor: null, total: 15 } }),
|
||||
|
||||
@@ -2,8 +2,10 @@
|
||||
// 목록 보기 — 게시판형 표 (디자인 원본 MainList). 캔버스는 grid 로 그렸지만 구현은 진짜 <table> 이다.
|
||||
// 칸: 별 32 · 보낸 사람 168 · 제목(나머지) · 날짜 84 · 크기 64 (칸 사이 12) — tokens.css 의 --table-* 값.
|
||||
// 행 어디를 눌러도 메일이 열리고, 제목은 키보드로 갈 수 있는 링크다. 별 버튼은 따로 동작한다.
|
||||
// 임시보관함은 받는 사람 칸에 받는 사람(없으면 "(받는 사람 없음)")을 보이고, 행을 누르면 쓰기 화면에서 이어 쓴다
|
||||
// (디자인 원본 ComposeAssist "임시보관함 목록" — 빈 값은 흐린 글자, 안 읽음 굵기는 쓰지 않는다).
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useRouter, type RouteLocationRaw } from 'vue-router'
|
||||
|
||||
import AppIcon from '@/components/AppIcon.vue'
|
||||
import StateMessage from '@/components/feedback/StateMessage.vue'
|
||||
@@ -11,10 +13,10 @@ import MailListHeader from '@/components/mail/MailListHeader.vue'
|
||||
import MailLoadError from '@/components/mail/MailLoadError.vue'
|
||||
import MessageListSkeleton from '@/components/mail/MessageListSkeleton.vue'
|
||||
import { useMailNavigation } from '@/composables/useMailNavigation'
|
||||
import { OUTGOING_MAILBOX_ROLES } from '@/shared/constants/mail'
|
||||
import { DRAFT_NO_RECIPIENTS, MAIL_NO_SUBJECT, OUTGOING_MAILBOX_ROLES } from '@/shared/constants/mail'
|
||||
import type { MessageSummary } from '@/shared/types/mail'
|
||||
import { formatFileSize, formatMailTableDate } from '@/shared/utils/format'
|
||||
import { displayName, summarizeRecipients } from '@/shared/utils/mail-address'
|
||||
import { displayName, draftRecipientNames, summarizeRecipients } from '@/shared/utils/mail-address'
|
||||
import { emptyMailboxText, mailboxIcon } from '@/shared/utils/mailbox'
|
||||
import { useMailStore } from '@/stores/mail.store'
|
||||
import { useMailViewStore } from '@/stores/mail-view.store'
|
||||
@@ -38,6 +40,8 @@ const isOutgoing = computed(() => {
|
||||
const role = mailbox.value?.role
|
||||
return role ? OUTGOING_MAILBOX_ROLES.includes(role) : false
|
||||
})
|
||||
/** 임시보관함 — 행을 누르면 쓰기 화면에서 이어 쓴다 */
|
||||
const isDrafts = computed(() => mailbox.value?.role === 'drafts')
|
||||
const whoHeader = computed(() => (isOutgoing.value ? '받는 사람' : '보낸 사람'))
|
||||
const caption = computed(() => `${mailbox.value?.name ?? '메일함'} 메일 목록`)
|
||||
const empty = computed(() =>
|
||||
@@ -47,16 +51,34 @@ const emptyIcon = computed(() => mailboxIcon({ role: mailbox.value?.role ?? null
|
||||
const busy = computed(() => mailStore.listStatus === 'loading' || mailStore.isRefreshing)
|
||||
|
||||
function whoOf(message: MessageSummary): string {
|
||||
if (isDrafts.value) return draftRecipientNames(message.to) || DRAFT_NO_RECIPIENTS
|
||||
if (isOutgoing.value) return summarizeRecipients(message.to) || '받는 사람 없음'
|
||||
return displayName(message.from) || '(보낸 사람 없음)'
|
||||
}
|
||||
|
||||
/** 임시 저장본의 받는 사람이 없다 — 흐린 글자로 */
|
||||
function hasNoDraftRecipients(message: MessageSummary): boolean {
|
||||
return isDrafts.value && draftRecipientNames(message.to) === ''
|
||||
}
|
||||
|
||||
/** 임시 저장본은 읽음 여부를 보이지 않는다 */
|
||||
function isUnread(message: MessageSummary): boolean {
|
||||
return !isDrafts.value && !message.isRead
|
||||
}
|
||||
|
||||
/** 행이 여는 곳 — 임시 저장본은 쓰기 화면, 그 밖은 읽기 화면 */
|
||||
function rowLocation(message: MessageSummary): RouteLocationRaw {
|
||||
return isDrafts.value
|
||||
? { name: 'mail-compose', query: { draft: message.id } }
|
||||
: messageLocation(props.mailboxId, message.id)
|
||||
}
|
||||
|
||||
/** 행 어디를 눌러도 연다 — 링크·별 버튼을 누른 경우와 글자를 고르는 중이면 그대로 둔다 */
|
||||
function onRowClick(event: MouseEvent, message: MessageSummary) {
|
||||
const target = event.target instanceof Element ? event.target : null
|
||||
if (target?.closest('a, button')) return
|
||||
if (window.getSelection()?.toString()) return
|
||||
void router.push(messageLocation(props.mailboxId, message.id))
|
||||
void router.push(rowLocation(message))
|
||||
}
|
||||
|
||||
function retry() {
|
||||
@@ -167,7 +189,7 @@ onMounted(async () => {
|
||||
<tr
|
||||
v-for="message in mailStore.messages"
|
||||
:key="message.id"
|
||||
:class="['mail-table__row', { 'is-unread': !message.isRead }]"
|
||||
:class="['mail-table__row', { 'is-unread': isUnread(message) }]"
|
||||
@click="onRowClick($event, message)"
|
||||
>
|
||||
<td class="mail-table__star">
|
||||
@@ -186,20 +208,20 @@ onMounted(async () => {
|
||||
/>
|
||||
</button>
|
||||
</td>
|
||||
<td class="mail-table__who">
|
||||
<td :class="['mail-table__who', { 'is-placeholder': hasNoDraftRecipients(message) }]">
|
||||
<span
|
||||
v-if="!message.isRead"
|
||||
v-if="isUnread(message)"
|
||||
class="visually-hidden"
|
||||
>안 읽음, </span>{{ whoOf(message) }}
|
||||
</td>
|
||||
<td class="mail-table__subject">
|
||||
<div class="mail-table__subject-line">
|
||||
<RouterLink
|
||||
class="mail-table__link"
|
||||
:to="messageLocation(mailboxId, message.id)"
|
||||
:class="['mail-table__link', { 'is-placeholder': isDrafts && !message.subject }]"
|
||||
:to="rowLocation(message)"
|
||||
:data-message-id="message.id"
|
||||
>
|
||||
{{ message.subject || '(제목 없음)' }}
|
||||
{{ message.subject || MAIL_NO_SUBJECT }}
|
||||
</RouterLink>
|
||||
<AppIcon
|
||||
v-if="message.hasAttachment"
|
||||
@@ -404,6 +426,12 @@ onMounted(async () => {
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
/* 비어 있는 값 — "(받는 사람 없음)" · "(제목 없음)" (디자인 원본 ComposeAssist #667085) */
|
||||
.mail-table__who.is-placeholder,
|
||||
.mail-table__link.is-placeholder {
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
/* 안 읽음 — 뱃지 대신 굵기·색으로만 구분 */
|
||||
.mail-table__row.is-unread .mail-table__who,
|
||||
.mail-table__row.is-unread .mail-table__link {
|
||||
|
||||
@@ -1,14 +1,51 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { mount, type VueWrapper } from '@vue/test-utils'
|
||||
import { shallowRef } from 'vue'
|
||||
|
||||
import type { RecipientToken } from '@/shared/utils/mail-address'
|
||||
import { RECIPIENT_SUGGESTIONS, type RecipientSuggestionsHandle } from '@/composables/useRecipientSuggestions'
|
||||
import type { RecipientSuggestion } from '@/shared/types/mail'
|
||||
import { normalizeEmail, type RecipientToken } from '@/shared/utils/mail-address'
|
||||
import { filterRecipientSuggestions } from '@/shared/utils/recipient-match'
|
||||
import RecipientField from './RecipientField.vue'
|
||||
|
||||
/** v-model 처럼 갱신 이벤트를 받아 다시 props 로 넣는다 — 마지막 값은 models 에서 읽는다 */
|
||||
const models = new WeakMap<VueWrapper, RecipientToken[]>()
|
||||
|
||||
function mountField(initial: RecipientToken[] = []): VueWrapper {
|
||||
/** 쓰기 화면이 주는 추천 — 받아 둔 목록에서 거르고, 이미 넣은 주소(이 칸의 토큰)는 뺀다 */
|
||||
function suggestionsOf(pool: RecipientSuggestion[]) {
|
||||
const items = shallowRef(pool)
|
||||
const searches: string[] = []
|
||||
const preloads: number[] = []
|
||||
let current: () => RecipientToken[] = () => []
|
||||
const handle: RecipientSuggestionsHandle = {
|
||||
preload: () => {
|
||||
preloads.push(preloads.length + 1)
|
||||
},
|
||||
matchesFor: (query) =>
|
||||
filterRecipientSuggestions(
|
||||
query,
|
||||
items.value,
|
||||
new Set(current().map((token) => normalizeEmail(token.address.email))),
|
||||
),
|
||||
search: (query) => {
|
||||
searches.push(query)
|
||||
},
|
||||
}
|
||||
return {
|
||||
handle,
|
||||
items,
|
||||
searches,
|
||||
preloads,
|
||||
bind: (tokens: () => RecipientToken[]) => {
|
||||
current = tokens
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function mountField(initial: RecipientToken[] = [], suggestions?: ReturnType<typeof suggestionsOf>): VueWrapper {
|
||||
const wrapper: VueWrapper = mount(RecipientField, {
|
||||
attachTo: suggestions ? document.body : undefined,
|
||||
global: suggestions ? { provide: { [RECIPIENT_SUGGESTIONS as symbol]: suggestions.handle } } : {},
|
||||
props: {
|
||||
modelValue: initial,
|
||||
label: '받는 사람',
|
||||
@@ -21,6 +58,7 @@ function mountField(initial: RecipientToken[] = []): VueWrapper {
|
||||
},
|
||||
})
|
||||
models.set(wrapper, initial)
|
||||
suggestions?.bind(() => models.get(wrapper) ?? [])
|
||||
return wrapper
|
||||
}
|
||||
|
||||
@@ -109,4 +147,246 @@ describe('RecipientField', () => {
|
||||
await wrapper.get('button[aria-label="a@b.kr 삭제"]').trigger('click')
|
||||
expect(tokensOf(wrapper).map((token) => token.label)).toEqual(['c@d.kr'])
|
||||
})
|
||||
|
||||
describe('자동 완성 (디자인 원본 ComposeAssist)', () => {
|
||||
const POOL: RecipientSuggestion[] = [
|
||||
{ name: '이도윤', email: 'doyun.lee@arasystem.it.kr', source: 'directory' },
|
||||
{ name: '최도윤', email: 'choi@partner.example', source: 'recent' },
|
||||
{ name: '김도윤', email: 'doyun.kim@arasystem.it.kr', source: 'directory' },
|
||||
{ name: '박준호', email: 'junho.park@arasystem.it.kr', source: 'directory' },
|
||||
]
|
||||
|
||||
async function typed(wrapper: VueWrapper, text: string) {
|
||||
const input = wrapper.get('input')
|
||||
;(input.element as HTMLInputElement).focus()
|
||||
await input.trigger('focus')
|
||||
await input.setValue(text)
|
||||
return input
|
||||
}
|
||||
|
||||
/** 추천 한 줄 — "이름 주소" */
|
||||
function optionText(option: ReturnType<VueWrapper['find']>): string {
|
||||
return option
|
||||
.findAll('.recipient-suggestions__name, .recipient-suggestions__email')
|
||||
.map((part) => part.text())
|
||||
.join(' ')
|
||||
}
|
||||
|
||||
function options(wrapper: VueWrapper): string[] {
|
||||
return wrapper.findAll('[role="option"]').map(optionText)
|
||||
}
|
||||
|
||||
function activeOption(wrapper: VueWrapper): string | null {
|
||||
const active = wrapper.find('[role="option"][aria-selected="true"]')
|
||||
return active.exists() ? optionText(active) : null
|
||||
}
|
||||
|
||||
it('한 글자 이상 치면 칸 아래에 사내 · 최근 주고받은 주소 묶음으로 보이고, 맞는 글자는 굵게 · 첫 추천을 골라 둔다', async () => {
|
||||
const suggestions = suggestionsOf(POOL)
|
||||
const wrapper = mountField([], suggestions)
|
||||
const input = await typed(wrapper, 'ㄷㅇ')
|
||||
|
||||
expect(input.attributes('role')).toBe('combobox')
|
||||
expect(input.attributes('aria-expanded')).toBe('true')
|
||||
expect(input.attributes('aria-controls')).toBe('compose-to-suggestions')
|
||||
expect(wrapper.get('#compose-to-suggestions').attributes('role')).toBe('listbox')
|
||||
expect(wrapper.findAll('[role="group"]').map((group) => group.attributes('aria-label'))).toEqual([
|
||||
'사내',
|
||||
'최근 주고받은 주소',
|
||||
])
|
||||
expect(options(wrapper)).toEqual([
|
||||
'이도윤 doyun.lee@arasystem.it.kr',
|
||||
'김도윤 doyun.kim@arasystem.it.kr',
|
||||
'최도윤 choi@partner.example',
|
||||
])
|
||||
expect(wrapper.findAll('#compose-to-option-0 strong').map((part) => part.text())).toEqual(['도윤'])
|
||||
expect(input.attributes('aria-activedescendant')).toBe('compose-to-option-0')
|
||||
expect(wrapper.get('#compose-to-suggestions-hint').text()).toBe('↑↓ 고르기 · Enter · Tab 넣기 · Esc 닫기')
|
||||
expect(input.attributes('aria-describedby')).toBe('compose-to-suggestions-hint')
|
||||
expect(suggestions.searches).toEqual(['ㄷㅇ'])
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('↑↓ 로 고르고 Enter 로 넣는다 — 폼을 제출하지 않고, 이름 <주소> 토큰이 되고 목록은 닫힌다', async () => {
|
||||
const wrapper = mountField([], suggestionsOf(POOL))
|
||||
const input = await typed(wrapper, '도윤')
|
||||
|
||||
await input.trigger('keydown', { key: 'ArrowDown' })
|
||||
expect(activeOption(wrapper)).toBe('김도윤 doyun.kim@arasystem.it.kr')
|
||||
await input.trigger('keydown', { key: 'ArrowUp' })
|
||||
await input.trigger('keydown', { key: 'ArrowUp' })
|
||||
expect(activeOption(wrapper)).toBe('최도윤 choi@partner.example')
|
||||
|
||||
const enter = new KeyboardEvent('keydown', { key: 'Enter', cancelable: true })
|
||||
input.element.dispatchEvent(enter)
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
expect(enter.defaultPrevented).toBe(true)
|
||||
expect(tokensOf(wrapper).map((token) => token.label)).toEqual(['최도윤 <choi@partner.example>'])
|
||||
expect((input.element as HTMLInputElement).value).toBe('')
|
||||
expect(wrapper.find('[role="listbox"]').exists()).toBe(false)
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('Tab 으로도 넣고 칸에 남아 이어 친다 — 이미 넣은 주소는 다음 추천에서 빠진다', async () => {
|
||||
const wrapper = mountField([], suggestionsOf(POOL))
|
||||
const input = await typed(wrapper, '도윤')
|
||||
|
||||
const tab = new KeyboardEvent('keydown', { key: 'Tab', cancelable: true })
|
||||
input.element.dispatchEvent(tab)
|
||||
await wrapper.vm.$nextTick()
|
||||
expect(tab.defaultPrevented).toBe(true)
|
||||
expect(tokensOf(wrapper).map((token) => token.address.email)).toEqual(['doyun.lee@arasystem.it.kr'])
|
||||
|
||||
await input.setValue('도윤')
|
||||
expect(options(wrapper)).not.toContain('이도윤 doyun.lee@arasystem.it.kr')
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('Esc 는 목록만 닫고, 다시 치면 연다 — 누르면(마우스) 그 추천을 넣는다', async () => {
|
||||
const wrapper = mountField([], suggestionsOf(POOL))
|
||||
const input = await typed(wrapper, '준호')
|
||||
|
||||
await input.trigger('keydown', { key: 'Escape' })
|
||||
expect(wrapper.find('[role="listbox"]').exists()).toBe(false)
|
||||
expect(input.attributes('aria-expanded')).toBe('false')
|
||||
|
||||
await input.setValue('준')
|
||||
const option = wrapper.get('[role="option"]')
|
||||
const down = new MouseEvent('mousedown', { cancelable: true, bubbles: true })
|
||||
option.element.dispatchEvent(down)
|
||||
await option.trigger('click')
|
||||
|
||||
expect(down.defaultPrevented).toBe(true)
|
||||
expect(tokensOf(wrapper).map((token) => token.label)).toEqual(['박준호 <junho.park@arasystem.it.kr>'])
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('완전한 주소를 쳤으면 미리 고르지 않는다 — Enter 는 친 주소를 그대로, 화살표로 고른 뒤에만 추천이 들어간다', async () => {
|
||||
const wrapper = mountField([], suggestionsOf(POOL))
|
||||
const input = await typed(wrapper, 'doyun.lee@partner.example')
|
||||
|
||||
expect(options(wrapper)).toEqual(['이도윤 doyun.lee@arasystem.it.kr'])
|
||||
expect(activeOption(wrapper)).toBeNull()
|
||||
expect(input.attributes('aria-activedescendant')).toBeUndefined()
|
||||
expect(wrapper.get('#compose-to-suggestions-hint').text()).toBe('Enter 는 친 주소를 그대로 넣습니다')
|
||||
|
||||
await input.trigger('keydown', { key: 'Enter' })
|
||||
expect(tokensOf(wrapper).map((token) => token.label)).toEqual(['doyun.lee@partner.example'])
|
||||
|
||||
await input.setValue('doyun.lee@partner.example')
|
||||
await input.trigger('keydown', { key: 'ArrowDown' })
|
||||
expect(wrapper.get('#compose-to-suggestions-hint').text()).toBe('↑↓ 고르기 · Enter · Tab 넣기 · Esc 닫기')
|
||||
await input.trigger('keydown', { key: 'Enter' })
|
||||
expect(tokensOf(wrapper).map((token) => token.address.email)).toEqual([
|
||||
'doyun.lee@partner.example',
|
||||
'doyun.lee@arasystem.it.kr',
|
||||
])
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('한글 조합 중의 Enter 는 추천도 넣지 않는다', async () => {
|
||||
const wrapper = mountField([], suggestionsOf(POOL))
|
||||
const input = await typed(wrapper, '도윤')
|
||||
|
||||
await input.trigger('keydown', { key: 'Enter', isComposing: true })
|
||||
|
||||
expect(tokensOf(wrapper)).toEqual([])
|
||||
expect(wrapper.find('[role="listbox"]').exists()).toBe(true)
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('Safari · macOS 처럼 조합을 끝내는 Enter 가 isComposing 없이 keyCode 229 로 와도 넣지 않는다', async () => {
|
||||
const wrapper = mountField([], suggestionsOf(POOL))
|
||||
const input = await typed(wrapper, '도윤')
|
||||
|
||||
const enter = new KeyboardEvent('keydown', { key: 'Enter', keyCode: 229, cancelable: true })
|
||||
input.element.dispatchEvent(enter)
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
expect(enter.defaultPrevented).toBe(false)
|
||||
expect(tokensOf(wrapper)).toEqual([])
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('칸에 초점이 오면 미리 받기를 부른다 — 실패했던 미리 받기를 다시 해 본다 (받았으면 추천 쪽이 아무것도 하지 않는다)', async () => {
|
||||
const suggestions = suggestionsOf(POOL)
|
||||
const wrapper = mountField([], suggestions)
|
||||
expect(suggestions.preloads).toHaveLength(0)
|
||||
await wrapper.get('input').trigger('focus')
|
||||
expect(suggestions.preloads).toHaveLength(1)
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('Shift+Tab 은 추천을 넣지 않는다 — 원래대로 앞 칸으로 (막지 않는다)', async () => {
|
||||
const wrapper = mountField([], suggestionsOf(POOL))
|
||||
const input = await typed(wrapper, '도윤')
|
||||
|
||||
const back = new KeyboardEvent('keydown', { key: 'Tab', shiftKey: true, cancelable: true })
|
||||
input.element.dispatchEvent(back)
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
expect(back.defaultPrevented).toBe(false)
|
||||
expect(tokensOf(wrapper)).toEqual([])
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('한글 조합 중인 글자로도 찾는다 — "김"(조합 중) · "ㄷㅇ"(마지막 자모가 조합 중). 토큰 확정은 조합이 끝난 글자로', async () => {
|
||||
const suggestions = suggestionsOf([
|
||||
...POOL,
|
||||
{ name: '김아라', email: 'ara.kim@arasystem.it.kr', source: 'directory' },
|
||||
])
|
||||
const wrapper = mountField([], suggestions)
|
||||
const input = wrapper.get('input')
|
||||
const element = input.element as HTMLInputElement
|
||||
element.focus()
|
||||
await input.trigger('focus')
|
||||
|
||||
// "김" 을 조합하는 중 — v-model 은 아직 비어 있지만 추천은 보인다
|
||||
await input.trigger('compositionstart')
|
||||
element.value = '김'
|
||||
await input.trigger('compositionupdate')
|
||||
await input.trigger('input')
|
||||
expect(options(wrapper)).toEqual(['김도윤 doyun.kim@arasystem.it.kr', '김아라 ara.kim@arasystem.it.kr'])
|
||||
expect(suggestions.searches.at(-1)).toBe('김')
|
||||
await input.trigger('compositionend')
|
||||
|
||||
// "ㄷ" 을 확정하고 "ㅇ" 을 조합하는 중 — 초성 "ㄷㅇ" 으로 찾는다
|
||||
element.value = ''
|
||||
await input.trigger('input')
|
||||
await input.trigger('compositionstart')
|
||||
element.value = 'ㄷ'
|
||||
await input.trigger('input')
|
||||
await input.trigger('compositionend')
|
||||
await input.trigger('compositionstart')
|
||||
element.value = 'ㄷㅇ'
|
||||
await input.trigger('input')
|
||||
expect(options(wrapper)).toEqual([
|
||||
'이도윤 doyun.lee@arasystem.it.kr',
|
||||
'김도윤 doyun.kim@arasystem.it.kr',
|
||||
'최도윤 choi@partner.example',
|
||||
])
|
||||
|
||||
// 조합 중의 Enter 는 넣지 않는다 — 조합이 끝난 뒤의 Enter 가 고른 추천을 넣는다
|
||||
await input.trigger('keydown', { key: 'Enter', isComposing: true })
|
||||
expect(tokensOf(wrapper)).toEqual([])
|
||||
await input.trigger('compositionend')
|
||||
await input.trigger('keydown', { key: 'Enter' })
|
||||
expect(tokensOf(wrapper).map((token) => token.address.email)).toEqual(['doyun.lee@arasystem.it.kr'])
|
||||
expect(element.value).toBe('')
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('칸을 벗어나면 목록을 닫고 친 글자를 확정한다 — 맞는 추천이 없으면 목록을 열지 않는다', async () => {
|
||||
const wrapper = mountField([], suggestionsOf(POOL))
|
||||
const input = await typed(wrapper, 'zzz')
|
||||
expect(wrapper.find('[role="listbox"]').exists()).toBe(false)
|
||||
|
||||
await input.setValue('준호')
|
||||
expect(wrapper.find('[role="listbox"]').exists()).toBe(true)
|
||||
await input.trigger('blur')
|
||||
expect(wrapper.find('[role="listbox"]').exists()).toBe(false)
|
||||
wrapper.unmount()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,18 +1,39 @@
|
||||
<script setup lang="ts">
|
||||
// 받는 사람 · 참조 · 숨은참조 입력 — Enter · 쉼표 · 입력칸을 벗어날 때 토큰으로 확정한다 (디자인 원본 Compose)
|
||||
// 형식이 틀린 주소는 빨간 물결 밑줄로 바로 표시한다. 오류 문구(MAIL_002)는 쓰기 화면이 사전에서 가져와 보인다.
|
||||
// 자동 완성 (디자인 원본 ComposeAssist · README "2단계 › 받는 사람 자동 완성") — 쓰기 화면이 추천(RECIPIENT_SUGGESTIONS)을 주면:
|
||||
// - 한 글자 이상 치면 칸 바로 아래에 추천 목록(최대 8) — "사내" · "최근 주고받은 주소" 두 묶음, 맞는 글자는 굵게
|
||||
// - ↑↓ 고르기 · Enter · Tab 넣기 · Esc 닫기 · 눌러 넣기. 처음에는 첫 추천을 골라 둔다 —
|
||||
// 친 글자가 완전한 메일 주소면 고르지 않는다: Enter 는 친 주소를 그대로 넣고, 화살표로 고른 뒤에만 추천이 들어간다.
|
||||
// Shift+Tab 은 추천을 넣지 않는다 (원래대로 앞 칸으로 — 칸을 벗어나며 친 글자를 확정한다)
|
||||
// - 추천은 입력칸에 보이는 글 그대로 찾는다 — 한글 조합 중인 글자도 ("김" · "ㄷㅇ"). v-model 은 조합이 끝나야 바뀌므로
|
||||
// input · compositionupdate 의 값을 따로 둔다(query). 주소 토큰 확정은 v-model(draft) 그대로다
|
||||
// - 한글 조합 중의 Enter · Tab 은 글자 확정이다 — 토큰으로도 추천으로도 넣지 않는다 (isImeComposing — Safari 의 keyCode 229 포함)
|
||||
// 목록은 RecipientSuggestionList 가 그린다 — 고르기 · 넣기 키는 이 입력칸이 받는다(초점은 늘 입력칸).
|
||||
// 고정 id — 목록 #{inputId}-suggestions · 추천 #{inputId}-option-{n} · 안내 #{inputId}-suggestions-hint (recipient-suggestion-ids.ts)
|
||||
// 사용 예: <RecipientField v-model="to" label="받는 사람" input-id="compose-to" error-id="compose-recipient-error" />
|
||||
import { computed, ref } from 'vue'
|
||||
import { computed, inject, ref, watch } from 'vue'
|
||||
|
||||
import AppIcon from '@/components/AppIcon.vue'
|
||||
import RecipientSuggestionList from '@/components/mail/RecipientSuggestionList.vue'
|
||||
import {
|
||||
suggestionHintId,
|
||||
suggestionListId,
|
||||
suggestionOptionId,
|
||||
} from '@/components/mail/recipient-suggestion-ids'
|
||||
import { RECIPIENT_SUGGESTIONS } from '@/composables/useRecipientSuggestions'
|
||||
import {
|
||||
hasOpenRecipientGroup,
|
||||
isValidEmail,
|
||||
mergeRecipientTokens,
|
||||
parseRecipient,
|
||||
recipientTokenKey,
|
||||
splitRecipientInput,
|
||||
tokenFromAddress,
|
||||
type RecipientToken,
|
||||
} from '@/shared/utils/mail-address'
|
||||
import { isImeComposing } from '@/shared/utils/keyboard-shortcut'
|
||||
import type { RecipientMatch } from '@/shared/utils/recipient-match'
|
||||
|
||||
interface Props {
|
||||
modelValue: RecipientToken[]
|
||||
@@ -36,27 +57,103 @@ const props = withDefaults(defineProps<Props>(), {
|
||||
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
/** 아직 토큰이 되지 않은 입력 */
|
||||
/** 추천 — 쓰기 화면이 주지 않았으면 자동 완성 없이 쓴다 */
|
||||
const suggestions = inject(RECIPIENT_SUGGESTIONS, null)
|
||||
|
||||
const HINT_DEFAULT = '↑↓ 고르기 · Enter · Tab 넣기 · Esc 닫기'
|
||||
const HINT_TYPED_ADDRESS = 'Enter 는 친 주소를 그대로 넣습니다'
|
||||
|
||||
/** 아직 토큰이 되지 않은 입력 — v-model (한글 조합이 끝나야 바뀐다) */
|
||||
const draft = ref<string>('')
|
||||
/** 추천 검색어 — 입력칸에 보이는 글 그대로(한글 조합 중인 글자 포함). 조합이 끝나면 draft 와 같아진다 */
|
||||
const query = ref<string>('')
|
||||
const inputRef = ref<HTMLInputElement | null>(null)
|
||||
/** 입력칸에 초점이 있다 — 그동안만 추천 목록을 연다 */
|
||||
const focused = ref<boolean>(false)
|
||||
/** Esc 로 닫았다 — 다시 치면 연다 */
|
||||
const dismissed = ref<boolean>(false)
|
||||
/** 고른 추천 — -1 이면 고르지 않았다 (Enter 는 친 글자를 넣는다) */
|
||||
const activeIndex = ref<number>(-1)
|
||||
|
||||
const hasInvalid = computed(() => props.modelValue.some((token) => !token.valid))
|
||||
/** 아직 토큰이 되지 않은 입력이 있다 — 쓰기 화면이 "쓰던 내용"으로 본다 (나가기 전 확인 · 칸을 벗어나지 않고 뒤로 가기 등) */
|
||||
const hasDraft = computed(() => draft.value.trim() !== '')
|
||||
const hasDraft = computed(() => draft.value.trim() !== '' || query.value.trim() !== '')
|
||||
|
||||
const listId = computed(() => suggestionListId(props.inputId))
|
||||
const hintId = computed(() => suggestionHintId(props.inputId))
|
||||
const matches = computed<RecipientMatch[]>(() => (suggestions ? suggestions.matchesFor(query.value) : []))
|
||||
const listOpen = computed(
|
||||
() =>
|
||||
focused.value && !props.disabled && !dismissed.value && query.value.trim() !== '' && matches.value.length > 0,
|
||||
)
|
||||
/** 친 글자가 이미 완전한 메일 주소다 — 추천을 미리 고르지 않는다 */
|
||||
const typedFullAddress = computed(() => isValidEmail(query.value))
|
||||
|
||||
const activeOptionId = computed(() =>
|
||||
listOpen.value && activeIndex.value >= 0 ? suggestionOptionId(props.inputId, activeIndex.value) : undefined,
|
||||
)
|
||||
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 : '']
|
||||
return ids.filter((id) => id !== '').join(' ') || undefined
|
||||
})
|
||||
|
||||
/** 입력한 글을 토큰으로 확정한다 (여러 주소를 붙여 넣었으면 나눠서) */
|
||||
function commit(text: string = draft.value) {
|
||||
const tokens = splitRecipientInput(text)
|
||||
.map(parseRecipient)
|
||||
.filter((token): token is RecipientToken => token !== null)
|
||||
draft.value = ''
|
||||
clearDraft()
|
||||
if (tokens.length === 0) return
|
||||
emit('update:modelValue', mergeRecipientTokens(props.modelValue, tokens))
|
||||
}
|
||||
|
||||
/** 추천을 넣는다 — "이름 <주소>" 토큰으로. 초점은 입력칸에 남아 다음 주소를 이어 친다 */
|
||||
function pick(match: RecipientMatch) {
|
||||
const { name, email } = match.suggestion
|
||||
clearDraft()
|
||||
emit('update:modelValue', mergeRecipientTokens(props.modelValue, [tokenFromAddress({ name, email })]))
|
||||
}
|
||||
|
||||
/** 화살표로 고른다 — 끝에서 처음으로 돈다 */
|
||||
function move(step: 1 | -1) {
|
||||
const count = matches.value.length
|
||||
if (count === 0) return
|
||||
if (activeIndex.value < 0) {
|
||||
activeIndex.value = step === 1 ? 0 : count - 1
|
||||
return
|
||||
}
|
||||
activeIndex.value = (activeIndex.value + step + count) % count
|
||||
}
|
||||
|
||||
/** 추천 목록의 키 — 처리했으면 true */
|
||||
function onListKeydown(event: KeyboardEvent): boolean {
|
||||
if (!listOpen.value) return false
|
||||
if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
|
||||
event.preventDefault()
|
||||
move(event.key === 'ArrowDown' ? 1 : -1)
|
||||
return true
|
||||
}
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault()
|
||||
dismissed.value = true
|
||||
return true
|
||||
}
|
||||
const active = matches.value[activeIndex.value]
|
||||
// Shift+Tab 은 넣지 않는다 — 원래대로 앞 칸으로 간다
|
||||
if ((event.key === 'Enter' || (event.key === 'Tab' && !event.shiftKey)) && active) {
|
||||
// Enter 가 폼을 제출(메일 발송)하지 않고, Tab 이 칸을 벗어나지 않게 — 넣은 뒤 이어 친다
|
||||
event.preventDefault()
|
||||
pick(active)
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
function onKeydown(event: KeyboardEvent) {
|
||||
// 한글 조합 중의 Enter 는 글자 확정이다 — 토큰으로 만들지 않는다
|
||||
if (event.isComposing) return
|
||||
// 한글 조합 중의 Enter · Tab 은 글자 확정이다 — 토큰으로도 추천으로도 넣지 않는다
|
||||
if (isImeComposing(event)) return
|
||||
if (onListKeydown(event)) return
|
||||
|
||||
const isSeparator =
|
||||
(event.key === ',' || event.key === ';') && !hasOpenRecipientGroup(draft.value)
|
||||
@@ -79,6 +176,22 @@ function onPaste(event: ClipboardEvent) {
|
||||
commit(`${draft.value}${text}`)
|
||||
}
|
||||
|
||||
/** 입력칸에 보이는 글 — 한글 조합 중인 글자도 (v-model 은 조합 중의 input 을 건너뛴다) */
|
||||
function onTextInput(event: Event) {
|
||||
query.value = (event.target as HTMLInputElement).value
|
||||
}
|
||||
|
||||
function onFocus() {
|
||||
focused.value = true
|
||||
// 미리 받기가 실패했으면 다시 받아 본다 (받았거나 받는 중이면 아무것도 하지 않는다)
|
||||
suggestions?.preload()
|
||||
}
|
||||
|
||||
function onBlur() {
|
||||
focused.value = false
|
||||
commit()
|
||||
}
|
||||
|
||||
function remove(token: RecipientToken) {
|
||||
const key = recipientTokenKey(token)
|
||||
emit(
|
||||
@@ -92,11 +205,33 @@ function focus() {
|
||||
inputRef.value?.focus()
|
||||
}
|
||||
|
||||
/** 아직 토큰이 되지 않은 입력을 버린다 — 쓰기 화면이 양식을 새로 채울 때 */
|
||||
/** 아직 토큰이 되지 않은 입력을 버린다 — 확정 · 추천을 넣은 뒤, 쓰기 화면이 양식을 새로 채울 때 */
|
||||
function clearDraft() {
|
||||
draft.value = ''
|
||||
query.value = ''
|
||||
}
|
||||
|
||||
// v-model 이 바뀌면(조합이 끝남 · 확정 · 비움) 검색어도 그 값으로
|
||||
watch(draft, (text) => {
|
||||
query.value = text
|
||||
})
|
||||
|
||||
// 친 글자가 바뀌면 — 닫았던 목록을 다시 열고, 첫 추천을 골라 둔다(완전한 주소면 고르지 않는다). 모자라면 서버에 묻는다
|
||||
watch(query, (text) => {
|
||||
dismissed.value = false
|
||||
activeIndex.value = isValidEmail(text) ? -1 : 0
|
||||
suggestions?.search(text)
|
||||
})
|
||||
|
||||
// 추천이 늘거나 줄면(서버 결과 · 이미 넣은 주소) 고른 자리를 목록 안에 둔다
|
||||
watch(
|
||||
() => matches.value.length,
|
||||
(count) => {
|
||||
if (activeIndex.value >= count) activeIndex.value = count - 1
|
||||
if (activeIndex.value < 0 && count > 0 && !typedFullAddress.value) activeIndex.value = 0
|
||||
},
|
||||
)
|
||||
|
||||
defineExpose({ commit, focus, clearDraft, hasDraft })
|
||||
</script>
|
||||
|
||||
@@ -141,19 +276,39 @@ defineExpose({ commit, focus, clearDraft, hasDraft })
|
||||
class="recipient-field__input"
|
||||
:placeholder="modelValue.length === 0 ? placeholder : ''"
|
||||
:disabled="disabled"
|
||||
:role="suggestions ? 'combobox' : undefined"
|
||||
:aria-autocomplete="suggestions ? 'list' : undefined"
|
||||
:aria-expanded="suggestions ? listOpen : undefined"
|
||||
:aria-controls="listOpen ? listId : undefined"
|
||||
:aria-activedescendant="activeOptionId"
|
||||
:aria-invalid="hasInvalid"
|
||||
:aria-describedby="hasInvalid && errorId ? errorId : undefined"
|
||||
:aria-describedby="describedBy"
|
||||
@input="onTextInput"
|
||||
@compositionupdate="onTextInput"
|
||||
@keydown="onKeydown"
|
||||
@blur="commit()"
|
||||
@focus="onFocus"
|
||||
@blur="onBlur"
|
||||
@paste="onPaste"
|
||||
>
|
||||
</div>
|
||||
<slot name="trailing" />
|
||||
|
||||
<RecipientSuggestionList
|
||||
v-if="listOpen"
|
||||
:input-id="inputId"
|
||||
:label="label"
|
||||
:matches="matches"
|
||||
:active-index="activeIndex"
|
||||
:hint="hint"
|
||||
@activate="activeIndex = $event"
|
||||
@pick="pick"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.recipient-field {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
|
||||
@@ -0,0 +1,217 @@
|
||||
<script setup lang="ts">
|
||||
// 받는 사람 추천 목록 (디자인 원본 ComposeAssist · README "2단계 › 받는 사람 자동 완성") — RecipientField 가 칸 바로 아래에 연다
|
||||
// - "사내" · "최근 주고받은 주소" 두 묶음, 줄마다 이름(맞는 글자 굵게) + 주소, 고른 줄은 바탕색. 번호는 화살표 순서다
|
||||
// - 고르기 · 넣기 키는 입력칸이 받는다(초점은 늘 입력칸) — 이 목록은 그리기와 마우스(올리면 고르기 · 누르면 넣기)만 한다.
|
||||
// 누르는 동안 입력칸의 초점을 지킨다(mousedown)
|
||||
// 고정 id — 목록 #{inputId}-suggestions · 추천 #{inputId}-option-{n} · 안내 #{inputId}-suggestions-hint (recipient-suggestion-ids.ts)
|
||||
import { computed } from 'vue'
|
||||
|
||||
import {
|
||||
suggestionHintId,
|
||||
suggestionListId,
|
||||
suggestionOptionId,
|
||||
} from '@/components/mail/recipient-suggestion-ids'
|
||||
import { highlightParts, type HighlightPart, type RecipientMatch } from '@/shared/utils/recipient-match'
|
||||
|
||||
interface Props {
|
||||
/** 입력칸 id — 목록 · 추천 · 안내 id 의 앞부분 */
|
||||
inputId: string
|
||||
/** 칸 이름 — 목록의 이름("받는 사람 추천") */
|
||||
label: string
|
||||
matches: RecipientMatch[]
|
||||
/** 고른 추천 — -1 이면 고르지 않았다 */
|
||||
activeIndex: number
|
||||
/** 목록 아래 안내 */
|
||||
hint: string
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
/** 누른 추천을 넣는다 */
|
||||
(e: 'pick', match: RecipientMatch): void
|
||||
/** 마우스를 올린 줄을 고른다 */
|
||||
(e: 'activate', index: number): void
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
/** 추천 묶음 이름 (디자인 원본 ComposeAssist) */
|
||||
const GROUP_LABELS = { directory: '사내', recent: '최근 주고받은 주소' } as const
|
||||
|
||||
interface OptionView {
|
||||
index: number
|
||||
id: string
|
||||
match: RecipientMatch
|
||||
/** 첫 줄 — 이름(없으면 주소) */
|
||||
primary: HighlightPart[]
|
||||
/** 둘째 줄 — 주소 (이름이 없으면 없다) */
|
||||
secondary: HighlightPart[] | null
|
||||
}
|
||||
|
||||
function optionView(match: RecipientMatch, index: number): OptionView {
|
||||
const { name, email } = match.suggestion
|
||||
return {
|
||||
index,
|
||||
id: suggestionOptionId(props.inputId, index),
|
||||
match,
|
||||
primary: name ? highlightParts(name, match.name) : highlightParts(email, match.email),
|
||||
secondary: name ? highlightParts(email, match.email) : null,
|
||||
}
|
||||
}
|
||||
|
||||
/** 묶음별 추천 — 사내 → 최근 주고받은 주소, 번호는 화살표 순서 */
|
||||
const groups = computed(() => {
|
||||
const options = props.matches.map(optionView)
|
||||
return (['directory', 'recent'] as const)
|
||||
.map((source) => ({
|
||||
source,
|
||||
label: GROUP_LABELS[source],
|
||||
options: options.filter((option) => option.match.suggestion.source === source),
|
||||
}))
|
||||
.filter((group) => group.options.length > 0)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- 칸 바로 아래에 겹쳐 연다 (디자인 원본 ComposeAssist). 누르는 동안 입력칸의 초점을 지킨다(mousedown) -->
|
||||
<div class="recipient-suggestions">
|
||||
<div
|
||||
:id="suggestionListId(inputId)"
|
||||
role="listbox"
|
||||
:aria-label="`${label} 추천`"
|
||||
class="recipient-suggestions__list"
|
||||
>
|
||||
<div
|
||||
v-for="group in groups"
|
||||
:key="group.source"
|
||||
role="group"
|
||||
:aria-label="group.label"
|
||||
class="recipient-suggestions__group"
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
class="recipient-suggestions__group-label"
|
||||
>{{ group.label }}</span>
|
||||
<div
|
||||
v-for="option in group.options"
|
||||
:id="option.id"
|
||||
:key="option.id"
|
||||
role="option"
|
||||
:aria-selected="option.index === activeIndex"
|
||||
:class="['recipient-suggestions__option', { 'is-active': option.index === activeIndex }]"
|
||||
@mousedown.prevent
|
||||
@mousemove="emit('activate', option.index)"
|
||||
@click="emit('pick', option.match)"
|
||||
>
|
||||
<span class="recipient-suggestions__name">
|
||||
<template
|
||||
v-for="(part, partIndex) in option.primary"
|
||||
:key="partIndex"
|
||||
>
|
||||
<strong v-if="part.strong">{{ part.text }}</strong>
|
||||
<template v-else>{{ part.text }}</template>
|
||||
</template>
|
||||
</span>
|
||||
<span
|
||||
v-if="option.secondary"
|
||||
class="recipient-suggestions__email"
|
||||
>
|
||||
<template
|
||||
v-for="(part, partIndex) in option.secondary"
|
||||
:key="partIndex"
|
||||
>
|
||||
<strong v-if="part.strong">{{ part.text }}</strong>
|
||||
<template v-else>{{ part.text }}</template>
|
||||
</template>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p
|
||||
:id="suggestionHintId(inputId)"
|
||||
class="recipient-suggestions__hint"
|
||||
>
|
||||
{{ hint }}
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/*
|
||||
* 추천 목록 (디자인 원본 ComposeAssist) — 칸(.recipient-field, position: relative) 바로 아래에 겹쳐 연다. 편집기 툴바(z-index 2) 위에 온다.
|
||||
* 줄 높이 40 · 이름(맞는 글자 굵게) + 주소(12px #4A5363) · 고른 줄 #E3F2F3 · 묶음 이름 12px #667085 굵게
|
||||
*/
|
||||
.recipient-suggestions {
|
||||
position: absolute;
|
||||
top: calc(100% + var(--space-4));
|
||||
left: var(--content-inline);
|
||||
right: var(--content-toolbar-start);
|
||||
z-index: 4;
|
||||
max-width: 36rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: var(--space-6);
|
||||
border: var(--border-width) solid var(--color-border);
|
||||
border-radius: var(--radius-lg);
|
||||
background: var(--color-surface);
|
||||
box-shadow: var(--shadow-toast);
|
||||
}
|
||||
|
||||
.recipient-suggestions__list,
|
||||
.recipient-suggestions__group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.recipient-suggestions__group + .recipient-suggestions__group {
|
||||
margin-top: var(--space-4);
|
||||
padding-top: var(--space-4);
|
||||
border-top: var(--border-width) solid var(--color-border-subtle);
|
||||
}
|
||||
|
||||
.recipient-suggestions__group-label {
|
||||
padding: var(--space-4) var(--space-8) var(--space-2);
|
||||
font-size: var(--font-size-small);
|
||||
font-weight: 600;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.recipient-suggestions__option {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: var(--space-8);
|
||||
min-height: var(--size-row-min);
|
||||
padding: var(--space-9) var(--space-8);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--color-text);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.recipient-suggestions__option.is-active {
|
||||
background: var(--color-brand-subtle);
|
||||
}
|
||||
|
||||
.recipient-suggestions__name {
|
||||
flex: 0 1 auto;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.recipient-suggestions__email {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: var(--font-size-small);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.recipient-suggestions__hint {
|
||||
margin: var(--space-4) var(--space-8) var(--space-2);
|
||||
font-size: var(--font-size-small);
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
</style>
|
||||
@@ -9,6 +9,7 @@ import AppIcon from '@/components/AppIcon.vue'
|
||||
import EditorPopup from '@/components/mail/editor/EditorPopup.vue'
|
||||
import type { LinkFormValue } from '@/components/mail/editor/editor-menu'
|
||||
import { getErrorMessage } from '@/composables/useApi'
|
||||
import { isImeComposing } from '@/shared/utils/keyboard-shortcut'
|
||||
import { normalizeLinkHref } from '@/shared/utils/link-href'
|
||||
|
||||
interface Props {
|
||||
@@ -58,8 +59,8 @@ function submit() {
|
||||
}
|
||||
|
||||
function onEnter(event: KeyboardEvent) {
|
||||
// 한글 조합 중의 Enter 는 글자를 확정할 뿐이다
|
||||
if (event.isComposing) return
|
||||
// 한글 조합 중의 Enter 는 글자를 확정할 뿐이다 (Safari · macOS 의 keyCode 229 포함)
|
||||
if (isImeComposing(event)) return
|
||||
event.preventDefault()
|
||||
submit()
|
||||
}
|
||||
|
||||
@@ -160,6 +160,22 @@ describe('MailEditor (서식 편집기)', () => {
|
||||
expect(view.vm.getContentForSend()).toBeNull()
|
||||
expect(editor.state.doc).toBe(before)
|
||||
})
|
||||
|
||||
it('임시 저장도 같은 한도로 잰다 — 넘으면 인용을 줄인 모양을 싣되 편집기는 그대로, 내 글만으로 넘으면 null', () => {
|
||||
const view = mountEditor({ initialHtml: longQuoteDraft(), fitToLimits: true })
|
||||
const editor = editorOf(view.element)
|
||||
const mine = '내가 쓴 긴 답장입니다. '.repeat(600)
|
||||
editor.commands.insertContentAt(1, mine)
|
||||
const before = editor.state.doc
|
||||
|
||||
const snapshot = view.vm.captureForSave()
|
||||
expect(snapshot?.content.text.length).toBeLessThanOrEqual(MAIL_BODY_MAX_LENGTH)
|
||||
expect(snapshot?.content.text.startsWith(mine.trimEnd())).toBe(true)
|
||||
expect(editor.state.doc).toBe(before)
|
||||
|
||||
editor.commands.setContent(`<p>${'가'.repeat(MAIL_BODY_MAX_LENGTH + 1)}</p>`)
|
||||
expect(view.vm.captureForSave()).toBeNull()
|
||||
}, LONG_DRAFT_TIMEOUT_MS)
|
||||
})
|
||||
|
||||
it('본문 오류 안내(describedBy)를 주면 본문이 그 안내를 가리키고 잘못된 입력으로 읽힌다', async () => {
|
||||
@@ -313,4 +329,54 @@ describe('MailEditor (서식 편집기)', () => {
|
||||
|
||||
expect(view.get('.mail-editor__drop').text()).toBe('놓으면 첨부 파일로 올립니다')
|
||||
})
|
||||
|
||||
describe('임시 저장 · 서명 그림 (2단계)', () => {
|
||||
const SIGNATURE_IMAGE = '<img src="/api/mail-signature/images/3f1c0a2e-0f1b-4f2a-9a1e-6c9f0a1b2c3d" width="144" alt="">'
|
||||
|
||||
it('저장한 뒤에는 저장한 본문과 견준다 — 저장하는 동안 더 쓴 글은 바꿈으로 남는다', () => {
|
||||
const view = mountEditor()
|
||||
const editor = editorOf(view.element)
|
||||
editor.commands.insertContent('첫 줄')
|
||||
const snapshot = view.vm.captureForSave()
|
||||
if (!snapshot) throw new Error('저장할 본문이 없다')
|
||||
expect(snapshot.content.text).toBe('첫 줄')
|
||||
|
||||
// 저장하는 동안 더 썼다
|
||||
editor.commands.insertContent(' 더')
|
||||
view.vm.markSaved(snapshot.savePoint)
|
||||
expect(view.vm.isChanged()).toBe(true)
|
||||
|
||||
// 더 쓴 글을 지워 저장한 모양으로 돌아오면 바꾸지 않은 것이다
|
||||
const end = editor.state.doc.content.size - 1
|
||||
editor.commands.deleteRange({ from: end - ' 더'.length, to: end })
|
||||
expect(editor.getText()).toBe('첫 줄')
|
||||
expect(view.vm.isChanged()).toBe(false)
|
||||
})
|
||||
|
||||
it('서명 그림은 본문에 남고, 글자 없이 그림만 있어도 서식 본문으로 보낸다 (텍스트 대체 본문은 빈 글)', () => {
|
||||
const view = mountEditor({ initialHtml: `<p>${SIGNATURE_IMAGE}</p>` })
|
||||
|
||||
const { text, html } = view.vm.getContent()
|
||||
|
||||
expect(text).toBe('')
|
||||
expect(html).toContain(SIGNATURE_IMAGE)
|
||||
})
|
||||
|
||||
it('붙여 넣은 HTML 의 다른 그림은 빠진다 — 서명 그림 주소만 받는다', () => {
|
||||
const view = mountEditor()
|
||||
const dom = view.get('.ProseMirror').element
|
||||
|
||||
dom.dispatchEvent(
|
||||
pasteEvent({
|
||||
text: '글',
|
||||
html: `<p>글<img src="https://tracker.example/p.gif"><img src="data:image/png;base64,AAAA">${SIGNATURE_IMAGE}</p>`,
|
||||
}),
|
||||
)
|
||||
|
||||
const { html } = view.vm.getContent()
|
||||
expect(html).toContain(SIGNATURE_IMAGE)
|
||||
expect(html).not.toContain('tracker.example')
|
||||
expect(html).not.toContain('data:image')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user