Compare commits

..

5 Commits

Author SHA1 Message Date
ttipo ca0e489ab1 docs(rules): 2단계 공용 부품·조건부 영구 삭제·검색어 로그 규칙
CI (Flutter) / Flutter (analyze · test) (pull_request) Has been cancelled
CI / Backend (lint · type-check · test · e2e · build) (pull_request) Has been cancelled
CI / Frontend (lint · type-check · test · build) (pull_request) Has been cancelled
CI / Infra (compose · Dockerfile · nginx) (pull_request) Has been cancelled
CI / 에러 코드 SSOT 동기화 검사 (pull_request) Has been cancelled
스킬 표에 새 모듈·부품(임시 저장 · 서명 · 사내 목록 · 받는 사람 추천 · 시계 주입 · 나가기 확인 공용 대화상자)을 더하고,
엔진 destroy 는 확인한 상태 그대로일 때만 쓰는 규칙, 프로세스 메모리에 둔 새 상태(추천 캐시 · 그림 올리기 차례),
검색어(q)를 로그에 남기지 않는 규칙, nginx 의 임시 저장 4MB · /api 로그 형식을 적는다.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 19:23:22 +09:00
ttipo ff6f757fa4 feat(docker): 임시 저장 본문 4MB·/api 접근 로그에서 쿼리 빼기
임시 저장(POST·PUT /api/drafts)은 발송과 같은 본문이라 nginx 도 4MB·90초로 받는다(백엔드도 같은 파서).
/api 접근 로그는 쿼리 없이 남겨 메일 검색·받는 사람 추천의 검색어가 요청마다 남지 않게 한다.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 19:23:22 +09:00
ttipo 5710151c0b feat(frontend): 임시 저장·서명 설정·받는 사람 자동 완성 화면
- 쓰기 화면 자동 저장(3초 멈춤 · 30초 · 10초 간격 · 한 번에 하나)과 상태 글, 저장할 때마다 주소를 ?draft= 로 맞춰
  새로고침해도 이어 쓴다. MAIL_008 · PUT 404 는 막지 않고 새 저장본으로 남긴다
- 찾지 못한 첨부가 남았거나 본문이 한도를 넘는 동안은 저장하지 않고, 실을 수 없는 첨부가 든 남의 저장본은 원본을 지우지 않는다
- 휴지통은 휴지통으로 옮기기(PATCH)와 되돌리기 — 이미 휴지통에 있는 메일을 DELETE 로 영구 삭제하지 않게
- 설정 › 메일 › 서명: 서식 · 그림(크기 줄) · 미리 보기 · 넣을 메일, 저장하지 않고 떠나면 묻는다
- 받는 사람 자동 완성: 사내 · 최근 묶음, 초성 검색(조합 중 글자 포함), 완전한 주소는 미리 고르지 않는다
- 본문에 그림으로 보인 첨부(isInline)는 읽기 화면의 첨부 목록에서 빼고 전달에는 붙인다

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 19:23:22 +09:00
ttipo a79f0791d0 feat(backend): 임시 저장·서명(그림)·받는 사람 추천 API와 MAIL_008
- /api/drafts: 발송과 같은 준비(정화·한도)로 초안을 만들고, 바꿔 저장은 새로 만들어 다시 읽은 뒤 옛것을 지운다.
  만들기와 지우기를 한 요청에 넣지 않는다 — 엔진이 만들기가 실패해도 지우기를 해서 저장본이 사라졌다.
  옛 초안이 이미 없으면 409 MAIL_008(새 코드), 초안이 아닌 id 는 404 RES_001 로 아무것도 지우지 않는다.
- 사용자 메일의 영구 삭제(교체한 옛 초안 · 보낸 뒤 draftId · 휴지통 영구 삭제)는 대상인지 확인한 상태 그대로일 때만
  ifInState 로 지운다 — 확인과 지우기 사이에 다른 탭이 옮긴 메일까지 지우지 않게 (Stalwart 0.16.27 에서 확인)
- /api/mail-signature: 서명(DB)과 그림(업로드 볼륨). 그림은 확장자·MIME·매직 바이트·크기·가로세로를 보고,
  보낼 때 multipart/related 의 cid 그림으로 싣는다. 서명 저장은 사용자별로 잠가 차례로 한다
- /api/recipient-suggestions: 로그인한 직원 기록(mail_directory)과 내 메일함의 최근 주소로 추천, 사용자별 10분 캐시
- 본문 그림은 첨부 표시에서 뺀다(hasAttachment 직접 계산 · isInline), 예외 필터 로그의 검색어(q)는 길이만 남긴다
- 마이그레이션 AddMailSignaturesAndDirectory: 새 표 3개만 만든다

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 19:22:45 +09:00
ttipo 1868665592 docs: 2단계(임시 저장·서명·받는 사람 추천) 계약과 디자인
캔버스 19 에서 확정한 2단계 화면(Settings · ComposeAssist)을 저장소에 두고,
계약 2절에 임시 저장 · 서명 · 받는 사람 추천 절과 서식 본문의 서명 그림 예외를 더한다.

구현과 리뷰에서 정한 것도 함께 적는다:
- 엔진의 destroy 는 되돌릴 수 없어, 사용자 메일을 지우는 경로는 확인한 상태 그대로일 때만(ifInState) 지운다
- 본문에 그림으로 보인 부분은 첨부 목록·클립에서 뺀다(isInline · hasAttachment) — 서명 그림이 매번 첨부로 보이지 않게
- 서명 그림 한도(서명 3장 · 보관 10장 · 300KB · 4096px), 사내 추천은 최근 90일 로그인
- 저장하지 않는 경우(찾지 못한 첨부 · 본문 한도 초과)와 남의 저장본 원본 보존, 알려진 한계

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 19:21:52 +09:00
169 changed files with 18593 additions and 604 deletions
+2
View File
@@ -33,6 +33,8 @@
- 비밀번호, 토큰(JWT·리프레시·API 키), 쿠키, `Authorization` 헤더
- 주민등록번호·카드번호·계좌번호 등 개인 식별/금융 정보
- 요청 본문 전체 덤프 (`JSON.stringify(req.body)` 금지 — 필요한 필드만 선택해 남긴다)
- 사용자가 친 검색어(쿼리의 `q` — 메일 검색 · 받는 사람 추천) — 요청 URL 을 남길 때는 `shared/utils/mask.ts` 의 `maskUrlForLog()` 를 거친다(`q` 는 길이만).
앞단 nginx 도 `/api` 접근 로그에 쿼리를 남기지 않는다 ([[docker]])
```typescript
// ❌ 금지
+7 -3
View File
@@ -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`.
+14 -4
View File
@@ -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` 로 로드).
+10 -5
View 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 시간·비용)으로 잰다 — 벽시계는 다른 작업이 많으면 몇 배로 늘어
+29 -1
View File
@@ -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
View File
@@ -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 });
+7
View File
@@ -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"`);
}
}
+3
View File
@@ -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);
+21
View File
@@ -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
+7 -1
View File
@@ -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);
+8 -2
View File
@@ -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 로 응답한다.
+43 -23
View File
@@ -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;
+15
View File
@@ -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],
+35
View File
@@ -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[]>;
}
+669 -4
View File
@@ -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',
+327 -34
View File
@@ -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;
}
+108 -1
View File
@@ -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);
}
}
}
+39 -1
View File
@@ -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('첫 글자와 도메인만 남기고 나머지를 가린다', () => {
+40
View File
@@ -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}`;
}
+32
View File
@@ -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);
});
});
+19
View File
@@ -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);
});
}
+774 -2
View File
@@ -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']);
});
});
});
+75
View File
@@ -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]),
]);
}
+251
View File
@@ -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>
+78 -3
View File
@@ -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). 화살표는 끝에서 처음으로 돌고, 마우스를 올린 줄이 골라진다.
초성 · 자모만 친 글자는 서버에 묻지 않는다(받아 둔 목록에서만 거른다).
+270
View File
@@ -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">이도윤 &lt;doyun.lee@arasystem.it.kr&gt;</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>
+6 -3
View File
@@ -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
View File
@@ -8,7 +8,7 @@
- 성공 `{ "success": true, "data": … }` / 실패 `{ "success": false, "error": { "code", "message", "traceId" } }`
- 날짜·시각은 ISO 8601 UTC 문자열, 목록은 `{ items, pageInfo }`
- 이 단계에서 쓰는 에러 코드: `AUTH_001` `AUTH_002`(클라이언트 전용) `AUTH_003` `AUTH_004` `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
View File
@@ -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 로 알린다 — 무엇을 할지는 쓰는 쪽이 정한다
// - 닫기는 열기보다 먼저 한다 — 한 대화상자가 닫히고 다른 대화상자가 열리는 때(세션 만료) 둘이 겹치지 않게
+33 -3
View File
@@ -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 } }),
+38 -10
View File
@@ -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()
})
})
})
+164 -9
View File
@@ -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