Compare commits

...

10 Commits

Author SHA1 Message Date
ttipo fe8a99e728 test(backend): 동시 로그인 e2e 를 시간에 기대지 않게
"같은 주소로 동시에 보낸 30건 — 엔진 확인은 1번" 테스트는 대역 엔진이 고정 500ms 를 붙잡는 동안 30건이 모두 닿기를 바랐다.
바쁜 기계(이 호스트는 다른 프로젝트의 CI 러너도 돈다)에서 요청이 500ms 넘게 퍼지면 늦은 요청이 엔진을 한 번 더 불러 실패할 수 있었다.

- 고정 대기 대신 문(gate)을 쓴다 — 엔진은 테스트가 열 때까지 답하지 않고, 테스트는 나머지 29건이 REQ_001 로 돌아온 뒤에 연다.
  증명하는 것은 그대로다: 한 주소의 확인이 엔진에 가 있는 동안 같은 주소의 다른 시도는 엔진에 묻지 않고 거절된다
- supertest 는 듣고 있지 않은 서버를 요청마다 열고 닫아, 동시에 보낸 요청이 닫히는 중인 서버에 거절될 수 있었다 —
  이 테스트는 서버를 미리 열고 끝나면 닫는다
- 기한(10초)을 넘기면 멈추지 않고 분명히 실패하고, 문은 언제나 연다 (finally)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 14:15:52 +09:00
ttipo 490a85290d docs(rules): lock 의 npm 버그·성능 단언은 계산량으로·새 공용 부품
- testing: 성능 단언은 벽시계가 아니라 계산량(CPU 시간·비용)으로, 비동기는 고정 대기 대신 조건 대기
- docker·nestjs 스킬: npm 11.6.2 lock 버그의 원인과 지킬 것(unrs-resolver 1.12.x 로 올리지 않기, 인자 없는 npm install 확인),
  CPU 시간 도우미
- vue3 스킬: 공용 ModalDialog·나가기 전 확인·읽기 화면 본문 안내·displayedHtmlBody·notifyApiError, 테스트 시간 한도

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 13:00:10 +09:00
ttipo 0282b6b7bd docs: 본문 길이 초과 코드·본문 잘림 표시·나가기 확인 계약과 디자인
- 계약 2절: MAIL_007 과 그 경우들, MessageDetail 의 isHtmlBodyOmitted·isBodyTruncated, 보여 줄 본문 규칙,
  보내는 서식 본문의 보이지 않는 서식 하한(8px·알파 0.3 — 바탕과 같은 색은 막지 않는다)
- 디자인: 나가기 전 확인(문구·단추·묻지 않는 경우·보내는 중·알려진 한계), 읽기 화면 본문 안내,
  내 메일 답장의 받는 사람, 인용할 원문 고르기, 본문 길이 띠의 MAIL_007 문구

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 13:00:09 +09:00
ttipo bbd88e707a feat(frontend): 쓰기 화면 나가기 확인·읽기 화면 본문 안내·내 메일 답장 받는 사람
- 쓰기 화면을 연 뒤 바꾼 것이 있으면 떠나기 전에 묻는다 — 앱 안 이동은 확인 대화상자, 탭 닫기·새로고침은 브라우저 확인창.
  보낸 뒤·보내는 중·세션이 끝났을 때는 묻지 않고, 보내는 중에 떠나면 결과는 알림으로만 알린다(떠난 양식은 건드리지 않는다).
  대화상자는 공용 ModalDialog 로 모았다 — 세션 만료 대화상자도 같은 것을 쓴다
- 본문 길이 초과는 MAIL_007 문구로 본문 아래에 알린다 (서버가 같은 코드를 돌려줘도 같다, 발송 키는 그대로)
- 읽기 화면에 본문 안내(서식 없이 표시·표시할 수 없음·앞부분만 표시). 잘린 원문을 인용하면 생략 표시를 둔다.
  인용할 원문은 읽기 화면과 같은 규칙으로 고른다 — 공백뿐인 HTML·그림만 남는 HTML 은 글자 본문으로
- 붙여 넣은 글·원문 인용에서 8px 미만 글자 크기와 알파 0.3 미만 글자 색을 버린다 (서버와 같은 규칙, 같은 표본 표 105행)
- 내가 보낸 메일에 답장하면 받는 사람이 원래 받는 사람이다 (전에는 받는 사람 칸이 비었다)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 13:00:09 +09:00
ttipo fbfad8d0ca feat(backend): 본문 길이 초과 코드 MAIL_007·본문 잘림 표시·보이지 않는 서식 제거
- 본문이 너무 길면 일반 VAL_001 대신 MAIL_007 을 돌려준다 — 텍스트 100,000자·서식 본문 500,000자 초과(클라이언트와 같은 JS 길이),
  서식 본문의 정화 전 비용 초과, 정화 뒤 2,000,000바이트 초과. 모두 엔진·중복 방지 잠금 전이라 메일은 나가지 않는다.
  클라이언트 에러 사전(frontend·flutter)도 다시 생성했다
- 메일 상세에 isHtmlBodyOmitted(정화 한도를 넘어 HTML 을 비움)와 isBodyTruncated(보여 줄 본문이 읽기 한도에서 잘림)를 더했다
  — 보여 줄 본문은 htmlBody, 그것이 null 이거나 공백뿐이면 textBody 다
- 보내는 서식 본문은 8px 미만 글자 크기와 알파 0.3 미만 글자 색을 지운다 — 붙여 넣거나 인용한 글에 숨긴 글이 그대로 나가지 않게.
  받은 메일 정화는 그대로다

계약: docs/mail/api-contract.md 2절

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 13:00:09 +09:00
ttipo 6735bba43d chore(backend): npm 11.6.2 에서 lock 이 깨지지 않게 unrs-resolver 를 1.11.1 로
컨테이너에서 아무 패키지나 npm install 하면 lock 에서 @emnapi/core·@emnapi/runtime 이 빠지고 npm ci 가 실패했다.
원인은 npm 11.6.1~11.6.2 의 버그다 — npm install 이 의존 표시를 다시 계산하면서, 들어오는 peer 간선이 있는 패키지를
일반 의존성이어도 peer 로 남기고 peer 이면서 optional 인 패키지를 지운다. jest 가 쓰는 unrs-resolver 1.12.x 의 wasm32 바인딩이
그 모양이다(@napi-rs/wasm-runtime 1.x 가 emnapi 를 필수 peer 로 둔다).

- unrs-resolver 와 바인딩을 1.11.1(2025-07), @napi-rs/wasm-runtime 을 0.2.12 로 내렸다 — emnapi 를 일반 의존성으로 쓴다.
  모두 개발 전용이고 package.json·운영 의존성·npm audit 결과는 그대로다
- 깨끗한 리눅스 컨테이너(npm 11.6.2)에서 확인: npm ci, 인자 없는 npm install 뒤 lock 바이트 동일,
  다른 패키지를 설치한 뒤 npm ci, npm ci --omit=dev, 운영 이미지 빌드
- npm 을 11.6.3 이상으로 올리면 원인이 사라진다 (docker 스킬 "의존성은 컨테이너 안에서")

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 13:00:09 +09:00
ttipo 2701d04fc2 test: 부하에 흔들리던 시간 검사를 계산량·조건 대기로 고정
다른 작업과 함께 돌면 혼자 0.8초인 정화가 9초로 재지는 등, 벽시계 한도와 고정 대기를 쓰던 테스트가 가끔 실패했다.

- 백엔드 성능 단언은 벽시계 대신 이 프로세스의 CPU 시간으로 잰다 (backend/test/cpu-time.ts 의 startCpuTimer).
  부하 중에도 1.4배쯤만 늘고, 예전 제곱 시간 경로(15~172초)는 그대로 한도를 넘는다
- 임시 업로드 삭제 테스트는 고정 20ms 대신 파일이 지워질 때까지 기다린다 (최대 2초, 지워지지 않으면 실패)
- 프론트 테스트 시간 한도를 20초로 (jsdom·편집기 테스트가 부하에서 5~13초), 긴 초안을 맞추는 테스트는 30초
  (src/test-utils/timeouts.ts)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 13:00:08 +09:00
ttipo 13888af2c2 docs(rules): 서식 본문 정화·정화 전 비용 검사·숫자 env 표기 규칙
- security: 사용자가 쓴 HTML 은 서버가 허용 목록으로 다시 정화한다, 밖에서 온 HTML 은 정화 전에 비용부터 잰다,
  브라우저에서 외부 HTML 을 고칠 때는 떨어진 문서 안에서 끝낸다
- env-structure: 숫자 env 는 앞자리 0 없는 10진수, nginx 도 읽는 크기 값은 6자리 이하
- nestjs·vue3·docker 스킬: 골격 표(정화·사전 검사·편집기·값 규칙 짝 파일)와 체크리스트, 발송 경로 4MB,
  컨테이너의 npm install 이 lock 을 깨뜨린 사례(2026-10-27)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 07:49:41 +09:00
ttipo d807cb4441 docs: 서식 본문 계약과 메일 쓰기 편집기 디자인
- 계약 2절: 서식 본문(bodyHtml)의 허용 목록·값 형식·한도(VAL_001)·지문, 읽기 한도와 정화 전 검사(htmlBody null),
  답장·전달 초안 길이 맞춤, 발송 JSON 4MB, 서식 본문 지문의 알려진 한계
- 디자인: 쓰기 화면 입력칸 포커스 규칙, 편집기 툴바·메뉴·단축키·좁은 폭 순서·팔레트, 원문 인용, 본문 이미지 → 첨부
  (캔버스 Compose·ComposeEditor 보드와 구현하며 정한 것)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 07:49:40 +09:00
ttipo 2a854073f7 feat(docker): 메일 발송 요청만 nginx 본문 한도 4MB
서식 본문과 긴 인용으로 발송 JSON 이 1MB 를 넘을 수 있다. 백엔드 파서와 같은 값으로 `location = /api/messages` 만
4m·90s 로 둔다 — 정확히 이 경로만이라 /api/messages/{id} 는 1m 그대로다.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 07:49:40 +09:00
77 changed files with 4501 additions and 577 deletions
+5
View File
@@ -37,6 +37,11 @@ description: 환경변수 단일 진실 공급원(SSOT) 구조 — 루트 .env.{
- **compose 는 정의되지 않은 변수를 빈 문자열로 주입한다.** 코드에서 `??` 기본값만 쓰면 `Number('')===0` 같은 함정에 빠진다 —
숫자 설정값은 `shared/utils/env.ts` 의 `parsePositiveInt()` 처럼 빈 문자열까지 방어하는 파서를 거친다.
(기존 `.env` 를 쓰는 사람이 새 키를 추가하지 않아도 안전해야 한다)
- **숫자 값은 앞자리 0 없는 10진수로만 쓴다** — `08`·`+5`·`5.0`·`1e3`·`0x10` 은 env 검증이 기동을 막는다
(기간 `015m`, `TRUST_PROXY` 홉 수·CIDR 접두도 같다. 규칙은 `shared/utils/env.ts` 의 `isPositiveIntString()`).
같은 값을 읽는 nginx 템플릿(`frontend/nginx-env.envsh`)이 셸 산술 때문에 `08` 을 거부하므로 백엔드도 같은 규칙을 쓴다 (2026-10-27).
nginx 본문 한도가 되는 `MAIL_ATTACHMENT_MAX_SIZE_MB`·`UPLOAD_MAX_SIZE_MB` 는 템플릿과 같이 **6자리(999999) 이하**만 받는다
(백엔드만 뜨고 frontend 가 뜨지 않는 조합을 막는다 — `env.validation.ts` 의 `NGINX_SHARED_INT_KEYS`).
- `docker-compose.yml` 의 `env_file` 지시자 때문에 `backend/.env.{APP_ENV}` 파일은 비어 있어도 **존재해야 한다.**
- **프론트엔드 dev 서버는 compose 를 거치지 않는다** — 호스트에서 직접 띄우므로([[docker]])
`vite.config.ts` 가 루트 `.env.{mode}` 를 `loadEnv` 로 읽어 포트·프록시 대상을 정한다.
+6
View File
@@ -33,6 +33,12 @@
- **서버 서명 키(`JWT_SECRET` 등)나 거기서 파생한 키를 쓰지 않는다** — 평문을 아는 받은 사람이 (평문, HMAC) 쌍으로
서명 키 후보를 오프라인에서 대입해 볼 수 있고, 맞히면 토큰을 위조할 수 있다 (2026-10-26 리뷰)
- 에러 응답에 스택·쿼리·내부 호스트명을 싣지 않는다 — 미정의 예외는 항상 `SYS_001` 로 수렴시킨다 ([[error-codes]])
- 사용자가 쓴 HTML(메일 서식 본문)은 **서버가 허용 목록으로 다시 정화**한 뒤 내보낸다 — 클라이언트 편집기의 출력을 믿지 않는다.
style 은 속성마다 값 형식을 처음부터 끝까지(`^…$`) 맞춘다 (`modules/mail/html/sanitize-outgoing-html.ts`, 계약 2절 "서식 본문")
- 받은 메일·서식 본문처럼 **밖에서 온 HTML 은 정화하기 전에 비용부터 잰다** — 정화기(htmlparser2)는 중첩 깊이의 제곱으로 느려져
메일 한 통으로 서버 전체를 수 분 멈출 수 있다 (2026-10-27 리뷰). 넘으면 정화하지 않는다 (`modules/mail/html/html-parse-budget.ts`, [[nestjs]])
- 브라우저에서 외부 HTML 을 고쳐 쓸 때는 떨어진 문서(`<template>`·`DOMParser`) 안에서 끝낸다 — 화면 문서로 옮기면
`img onerror` 같은 처리기가 실행된다. CSP 가 막아도 그것에만 기대지 않는다 ([[vue3]] 체크리스트 "외부 HTML")
### 파일 업로드
템플릿은 `modules/upload` 에 로컬 디스크 기준 골격을 제공한다. 새 업로드 엔드포인트는 이 패턴을 복제한다.
+3
View File
@@ -13,6 +13,9 @@ description: 세 스택 공통 테스트 규칙 — 테스트 위치/네이밍,
- **무엇을 테스트하나**: 순수 함수(utils), 서비스/비즈니스 로직, 에러 코드 매핑 분기를 우선한다. 프레임워크 기본 동작은 테스트하지 않는다.
- **공유 계약은 반드시 테스트한다** — 아래 "계약 테스트" 절 참조.
- **테스트는 결정적(deterministic)** 으로 — 외부 네트워크/시간/랜덤에 의존하지 않도록 모킹한다.
- 성능 단언("입력이 커져도 오래 걸리지 않는다")은 **벽시계가 아니라 계산량**으로 잰다 — 프로세스 CPU 시간이나 비용 수치.
벽시계는 다른 작업이 CPU 를 나눠 쓰면 몇 배로 늘어 CI 에서 흔들린다 (2026-10-27: 혼자 0.8초인 정화가 부하에서 9초).
비동기 결과를 기다릴 때도 고정 대기(`setTimeout(20)`) 대신 조건이 될 때까지 짧게 다시 보고 넉넉한 기한을 둔다.
- **커밋 전 실행** — 변경 영역의 테스트가 통과해야 한다. ([[checklist]])
- **CI 가 강제한다** — `.github/workflows/ci.yml` 이 세 스택의 lint·test·build 와 에러 코드 동기화를 PR 마다 검사한다.
- 테스트 설명(it/test 제목)은 한국어로 "무엇을 검증하는지" 명확히 작성한다.
+18 -1
View File
@@ -68,7 +68,9 @@ 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),
그 밖은 1m(백엔드 JSON 파서 한도). 루트 env 한 곳만 고치면 백엔드와 nginx 가 함께 바뀐다 (frontend 컨테이너만 다시 만들면 된다).
**메일 발송 `POST /api/messages` = 4m 고정**(서식 본문·긴 인용 — 계약 2절, 백엔드도 이 경로만 4MB 파서. 정확히 이 경로만이라 `/api/messages/{id}` 는 1m),
그 밖은 1m(백엔드 JSON 파서 한도). 앞의 두 값은 루트 env 한 곳만 고치면 백엔드와 nginx 가 함께 바뀐다 (frontend 컨테이너만 다시 만들면 된다).
JSON 한도(1m·4m)는 계약과 백엔드 코드의 고정값이라 env 로 바꾸지 않는다 — 바꾸려면 계약·백엔드 파서·`frontend/nginx.conf` 를 함께 고친다.
- 시간 제한: `/api/messages` 90s, `/api/mail-attachments` 150s (`proxy_read_timeout`).
- 캐시: `index.html`(직접 요청·`/`·SPA 폴백 모두)은 `Cache-Control: no-cache` — 배포 뒤 옛 index.html 이 지워진 해시 자산을 부르지 않게.
`/assets/`(Vite 산출물 — 파일명에 내용 해시)만 `max-age=30일, public, immutable` 이다.
@@ -322,6 +324,21 @@ node --env-file=.env.development mail/scripts/setup.mjs
`npm ci` 가 `Invalid: lock file's @emnapi/wasi-threads@1.2.1 does not satisfy @emnapi/wasi-threads@1.2.3` 로 실패했다(되돌림).
lock 은 의존성을 실제로 바꿀 때만 컨테이너 안 `npm install <패키지>` 로 고치고, 고친 뒤에는 **임시 폴더에 복사해 `npm ci`** 로 확인한다
(저장소 폴더를 그대로 마운트하면 호스트의 Windows `node_modules` 를 읽어 엉뚱하게 실패한다).
- ⚠️ **2026-10-27 에는 컨테이너의 `npm install <패키지>` 도 같은 항목을 지웠다** — backend 에 `htmlparser2@^12.0.0` 을 직접 의존성으로 더하자
`@emnapi/core`·`@emnapi/runtime` 과 중첩 `chokidar` 가 빠져 `npm ci` 가 위와 같은 오류로 실패했다.
그 패키지는 이미 lock 에 같은 판으로 있었으므로(sanitize-html 의 의존성) npm 이 루트 `dependencies` 에 더한 한 줄만 원래 lock 에 옮기고,
임시 복사본에서 `npm ci`·`npm ci --omit=dev` 와 `require` 로 확인했다 — **lock 에 없던 패키지에는 쓸 수 없는 방법**이다.
- **원인과 해결 (2026-10-27)**: npm 11.6.1~11.6.2(arborist 9.1.5~9.1.6)의 버그다. `npm ci` 는 lock 의 의존 표시를 그대로 믿지만
`npm install` 은 늘 다시 계산하는데, 들어오는 peer 간선이 하나라도 있는 패키지를 일반 의존성이어도 `peer` 로 남기고 `peer`+`optional` 인 패키지를 모두 지운다.
jest 가 쓰는 `unrs-resolver@1.12.x` 가 그 모양이다 — 선택 설치인 wasm32 바인딩의 일반 의존성 `@emnapi/core`·`@emnapi/runtime` 이
`@napi-rs/wasm-runtime@^1.1.4` 의 필수 peer 이기도 하다. 그래서 backend lock 의 `unrs-resolver` 를 **1.11.1** 로 내렸다
(바인딩이 peer 없는 `@napi-rs/wasm-runtime@0.2.12` 를 쓴다. 개발 전용, `npm audit` 결과 같음).
- **npm 이 11.6.2 인 동안 `unrs-resolver` 를 1.12.x 로 올리지 않는다** — `npm update` 로 범위 안 최신을 받아도 1.12.2 가 되어 다시 깨진다.
npm 을 11.6.3 이상으로 올리면 원인이 사라진다(그때 lock 의 `peer` 표시가 한 번 크게 바뀌지만 `npm ci` 는 통과한다).
- 중첩 `chokidar@4.0.3`(@angular-devkit 의 optional peer — 루트는 chokidar 5)은 npm 이 판과 무관하게 일부러 지우는 항목이라 lock 에 없다
(`npm ls` 의 chokidar invalid·readdirp extraneous 표시는 정상).
- lock 을 바꾼 뒤에는 임시 복사본에서 `npm ci` 와 함께 **인자 없는 `npm install` 이 lock 을 바이트 그대로 두는지**도 본다.
frontend lock 은 같은 문제가 없다(2026-10-27 확인).
- **dev backend 의 `/app/dist` 는 익명 볼륨이다.** 호스트에서 `npm run build` 를 하면 바인드 마운트된 `backend/dist` 가 지워졌다
다시 생기면서 컨테이너의 Nest watch 가 끊겨 앱이 옛 코드로 계속 돈다 — 그래서 산출물을 호스트와 나눴다.
`nest-cli.json` 의 `deleteOutDir` 는 마운트 지점을 지우려다 EBUSY 로 죽으므로, dev `command` 는 기동 때 볼륨 내용만 비우고
+10 -2
View File
@@ -22,14 +22,14 @@ 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, 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` 만 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`, 첨부 형식 `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` (계약 2절) |
| 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]]) |
@@ -319,6 +319,8 @@ dc exec backend npm uninstall <패키지>
- 변경 후 호스트의 `package.json`·`package-lock.json` 이 갱신됐는지 확인하고 **함께 커밋**한다 (bind mount 라 호스트에 반영된다).
- 호스트에서 실행해버렸다면 `git checkout backend/package-lock.json` 으로 되돌린 뒤 위 방법으로 다시 한다.
- 커밋 전 `docker compose ... build backend` 로 `npm ci` 가 통과하는지 확인한다. CI 도 같은 검사를 한다.
⚠️ npm 이 11.6.2 인 동안 jest 의 `unrs-resolver` 를 1.12.x 로 올리지 않는다(`npm update` 포함) — 올리면 컨테이너의 `npm install` 이 lock 을 깨뜨린다.
원인과 확인 방법은 [[docker]] "의존성은 컨테이너 안에서" 의 2026-10-27 항목.
- **운영 의존성이 실제로 들어가는지**도 본다 — 임시 복사본에서 `npm ci --omit=dev` 후 새 운영 의존성을 `require` 해 본다.
lock 의 `dev` 표시가 틀리면 운영 이미지에서만 패키지가 빠져 앱이 뜨지 않는다 (2026-10-27 htmlparser2 사례).
- **ESM 전용 패키지**가 들어와 Jest 가 `Cannot use import statement outside a module` 로 실패하면, jest 설정 두 곳
@@ -341,6 +343,7 @@ dc exec backend npm uninstall <패키지>
- [ ] **Swagger**: 새 엔드포인트에 `@ApiTags`/`@ApiOperation` + `@ApiStandardResponse`/`@ApiErrorResponse`.
- [ ] **경로 alias**: `@/` 사용 (`tsconfig.json` paths + jest `moduleNameMapper` 설정 완료). 깊은 상대경로 금지.
- [ ] **lint/test**: 커밋 전 `npm run lint` (`--max-warnings 0`) + `npm run test` 통과. 자동 수정은 `npm run lint:fix`.
성능 단언은 `backend/test/cpu-time.ts` 의 `startCpuTimer()`(프로세스 CPU 시간)로 잰다 — 벽시계 한도는 부하에서 흔들린다 ([[testing]]).
- [ ] **의존성 변경 시**: 호스트가 아니라 **컨테이너 안에서** `npm install/uninstall` (위 주의 항목 참조). 변경 후 Docker 빌드 확인.
- [ ] **에러 던지기**: `HttpException` 직접 생성은 린트로 차단된다 — `BusinessException(code)` 사용.
- [ ] **새 필수 env 추가 시**: `shared/config/env.validation.ts` 에 검증 규칙과 테스트를 함께 추가 ([[env-structure]]).
@@ -348,4 +351,9 @@ dc exec backend npm uninstall <패키지>
- [ ] **보호 라우트**: `@UseGuards(JwtAuthGuard)` + `@ApiCookieAuth()` + `@CurrentUser()` — 가드 없이 `@CurrentUser()` 를 쓰면 AUTH_001 로 막힌다 ([[security]]).
- [ ] **전역 설정 변경 시**: `src/app.setup.ts` 만 고친다 — main.ts 와 e2e 가 같은 함수를 쓴다.
- [ ] **메일 엔진 코드**: JMAP 타입·호출은 `modules/mail/jmap/` 밖으로 내보내지 않는다. 받은 메일 HTML 은 `html/sanitize-mail-html.ts` 를 거친 뒤에만 내보낸다.
보내는 서식 본문(`bodyHtml`)은 `html/sanitize-outgoing-html.ts` 를 거친 뒤에만 엔진에 넘긴다 — 편집기가 새 서식을 쓰게 되면 이 허용 목록과
받은 메일 정화(다시 읽을 때 남아야 한다)를 함께 넓힌다 (계약 2절 "서식 본문").
HTML 을 정화하는 경로는 먼저 `html/html-parse-budget.ts` 의 `checkHtmlParseBudget()` 을 부른다 — sanitize-html 이 쓰는 htmlparser2 는
중첩 깊이의 제곱으로 느려져 2MB 한 통에 수 분 동안 이벤트 루프를 멈춘다 (2026-10-27 리뷰). 정규식·변환도 입력 길이에 비례하게 둔다.
htmlparser2 판을 올리면 `html-parse-budget.spec.ts` 의 Parser 비교 테스트가 어긋남을 잡는다 — 실패하면 바뀐 Parser 규칙을 옮긴다.
- [ ] **env**: DB/Redis/CORS/포트는 루트 `.env` 에서 주입된다. 백엔드 고유 시크릿(`JWT_SECRET` 등)만 `backend/.env.{env}` 에 작성(compose `env_file` 로 로드).
+16 -3
View File
@@ -21,7 +21,7 @@ description: Vue 3 프론트엔드(frontend/) 개발 규칙 — Composition API
| 세션 만료 처리 | `useApi.ts` 의 `refreshSession()` · 응답 인터셉터 | 로그인 상태에서 401 → `/auth/refresh` 한 번 시도 후 원 요청 재시도. 실패하면 `AUTH_002` 승격 + `authStore.expireSession()` → `components/feedback/SessionExpiredDialog.vue` (흐름: `docs/mail/api-contract.md` 1절). 갱신은 시작할 때 세션 세대(`authStore.generation`)를 잡고, 끝났을 때 바뀌었으면 성공이어도 버린다. 로그인은 진행 중인 갱신이 끝난 뒤(`settleRefresh()`)에 보낸다 |
| 이동 전 세션 확인 | `useApi.ts` 의 `confirmSession()` | `<a href>` 다운로드처럼 axios 를 거치지 않는 이동 직전에 부른다 — me → refresh 1회 → 실패하면 세션 만료 |
| 탭 간 로그아웃 | `src/composables/useLogoutBroadcast.ts` (`App.vue` 의 `useLogoutSync()`) | 로그아웃은 요청 **전에** BroadcastChannel 로 다른 탭에 알린다 — 받은 탭은 로그아웃으로 다루고 로그인 화면으로. 로그아웃 뒤 늦게 온 `/auth/refresh` 성공은 버리고 `/auth/logout` 을 한 번 더 부른다 (계약 1절) |
| 요청 단위 알림 끄기 | `useApi.ts` 의 `silent` 요청 옵션 | `true` 또는 코드 배열 (`api.get(url, { silent: ['AUTH_001'] })`). 세션 만료 대화상자는 끄지 않는다 |
| 요청 단위 알림 끄기 | `useApi.ts` 의 `silent` 요청 옵션 | `true` 또는 코드 배열 (`api.get(url, { silent: ['AUTH_001'] })`). 세션 만료 대화상자는 끄지 않는다. 끈 코드를 나중에 알림으로 알려야 하면(그 칸이 사라진 뒤 등) `notifyApiError()` — 같은 `showErrorUI()` 를 거친다 |
| 페이지네이션 타입 | `src/shared/types/pagination.ts` | 목록 응답 공유 계약 ([[response-format]]) |
| 도메인 Composable 예제 | `src/composables/useUser.ts` · `useMail.ts` | 새 도메인 API 패턴 참고 (`useMail` 이 실제 모듈 예) |
| 인증 Composable | `src/composables/useAuth.ts` | 로그인·로그아웃·`restoreSession()`(me 가 AUTH_001 이면 refresh 1회, 알림 없음) — 쿠키 기반 |
@@ -33,14 +33,19 @@ description: Vue 3 프론트엔드(frontend/) 개발 규칙 — Composition API
| 디자인 토큰·글꼴 | `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` |
| 외부 HTML 격리 렌더링 | `src/components/mail/MailBodyFrame.vue` · `src/shared/utils/mail-html.ts` | 메일 본문 등 외부 HTML 은 이 컴포넌트로만 그린다 (sandbox·내부 CSP 근거는 파일 머리말) |
| 모달 대화상자 | `src/components/feedback/ModalDialog.vue` | 네이티브 `<dialog>`(초점·Esc·닫힘 처리)와 모양. 세션 만료(`SessionExpiredDialog`)·나가기 확인(`components/mail/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` | 클릭 영역 44px 기준의 의도된 예외(목록 표의 별·보기 전환)에만 쓴다 — 사용자 확정 |
| 밀도 예외 크기 | `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/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/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` |
| dev API 프록시 | `vite.config.ts` 의 `server.proxy` | `/api` → 백엔드. 없으면 vite 가 SPA 폴백으로 **HTML 을 200 으로** 돌려줘 조용히 실패한다 |
| dev 서버 포트 | `vite.config.ts` 의 `loadEnv` | 루트 `.env.{mode}` 의 `FRONTEND_PORT`/`BACKEND_PORT` 를 읽는다. `strictPort` 로 포트 충돌 시 fail-fast ([[env-structure]]) |
@@ -236,7 +241,15 @@ router.beforeEach((to) => {
- [ ] **라우트**: 페이지는 lazy import, 보호 라우트는 `meta.requiresAuth`.
- [ ] **auth 가드**: `router/index.ts` 가드는 첫 네비게이션 전에 세션을 복원한 뒤 `isLoggedIn` 으로 판정하고, 미인증은 `{ name: 'login', query: { redirect } }` 로 보낸다. 새 보호 화면은 `meta.requiresAuth` 만 붙인다.
- [ ] **외부 HTML**: 메일 본문처럼 외부에서 온 HTML 은 `MailBodyFrame` 으로만 그린다. `v-html` 로 직접 넣지 않는다.
외부 HTML 을 고쳐 쓸 때(붙여 넣기·원문 인용 다듬기)는 `<template>`·`DOMParser` 같은 **떨어진 문서 안에서 끝내고** `template.innerHTML` 로 직렬화한다
(`mail-serialize.ts` 의 `rewriteInert()`). 화면 문서의 요소로 옮기면(`append`) 입양되면서 `img onerror` 같은 처리기가 실행된다 —
운영 CSP 가 막아도 그것에만 기대지 않는다 (2026-10-27 사례, 회귀 테스트는 `mail-serialize.spec.ts`).
편집기로 읽어 들이는 서식은 **서버가 남기는 형식만** 받는다 (`outgoing-html-rules.ts`) — 서버만 지우면 편집기에서 숨은 글(`font-size:0` 등)이
받는 사람에게 보이는 식으로 화면과 보낸 메일이 달라진다 (2026-10-27 리뷰).
- [ ] **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 시간·비용)으로 잰다 — 벽시계는 다른 작업이 많으면 몇 배로 늘어
CI 에서 흔들린다 (2026-10-27 사례, backend 는 `backend/test/cpu-time.ts`).
- [ ] **린트로 강제되는 항목**: Options API·인라인 스타일·`console`·`any` 는 빌드가 실패한다.
- [ ] **env**: `VITE_API_BASE_URL` 은 운영 빌드에서 `/api` 로 고정된다 (같은 주소 배포 — 운영은 프론트 nginx, 개발은 vite 프록시가 `/api` 를 백엔드로 넘긴다).
`frontend/.env` 에 별도 작성 불필요 — 다른 주소를 넣으면 쿠키(Path=/api)·CSP `connect-src 'self'`·출처 검사가 맞지 않는다 ([[docker]] "같은 주소 배포").
+87 -194
View File
@@ -187,24 +187,6 @@
"url": "https://github.com/sponsors/epoberezkin"
}
},
"node_modules/@angular-devkit/schematics-cli/node_modules/chokidar": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz",
"integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==",
"dev": true,
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"readdirp": "^4.0.1"
},
"engines": {
"node": ">= 14.16.0"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@angular-devkit/schematics-cli/node_modules/json-schema-traverse": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
@@ -292,24 +274,6 @@
"url": "https://github.com/sponsors/epoberezkin"
}
},
"node_modules/@angular-devkit/schematics/node_modules/chokidar": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz",
"integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==",
"dev": true,
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"readdirp": "^4.0.1"
},
"engines": {
"node": ">= 14.16.0"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@angular-devkit/schematics/node_modules/json-schema-traverse": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
@@ -2574,22 +2538,16 @@
}
},
"node_modules/@napi-rs/wasm-runtime": {
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.5.tgz",
"integrity": "sha512-AWPoBRJ9tsnVhor4sjO7rkni+7p+2IAEFj6cx06UgP10jkQHqay/36uRV/bFkgrh18D9vb4cr8Q0Pthskgzy+Q==",
"version": "0.2.12",
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.12.tgz",
"integrity": "sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
"@tybys/wasm-util": "^0.10.2"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
},
"peerDependencies": {
"@emnapi/core": "^1.7.1",
"@emnapi/runtime": "^1.7.1"
"@emnapi/core": "^1.4.3",
"@emnapi/runtime": "^1.4.3",
"@tybys/wasm-util": "^0.10.0"
}
},
"node_modules/@nestjs/cache-manager": {
@@ -3129,24 +3087,6 @@
"url": "https://github.com/sponsors/epoberezkin"
}
},
"node_modules/@nestjs/schematics/node_modules/chokidar": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz",
"integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==",
"dev": true,
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"readdirp": "^4.0.1"
},
"engines": {
"node": ">= 14.16.0"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@nestjs/schematics/node_modules/json-schema-traverse": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
@@ -4567,9 +4507,9 @@
"license": "ISC"
},
"node_modules/@unrs/resolver-binding-android-arm-eabi": {
"version": "1.12.2",
"resolved": "https://registry.npmjs.org/@unrs/resolver-binding-android-arm-eabi/-/resolver-binding-android-arm-eabi-1.12.2.tgz",
"integrity": "sha512-g5T90pqg1bo/7mytQx6F4iBNC0Wsh9cu+z9veDbFjc7HjpesJFWD7QMS0NGStXM075+7dJPPVvBbpZlnrdpi/w==",
"version": "1.11.1",
"resolved": "https://registry.npmjs.org/@unrs/resolver-binding-android-arm-eabi/-/resolver-binding-android-arm-eabi-1.11.1.tgz",
"integrity": "sha512-ppLRUgHVaGRWUx0R0Ut06Mjo9gBaBkg3v/8AxusGLhsIotbBLuRk51rAzqLC8gq6NyyAojEXglNjzf6R948DNw==",
"cpu": [
"arm"
],
@@ -4581,9 +4521,9 @@
]
},
"node_modules/@unrs/resolver-binding-android-arm64": {
"version": "1.12.2",
"resolved": "https://registry.npmjs.org/@unrs/resolver-binding-android-arm64/-/resolver-binding-android-arm64-1.12.2.tgz",
"integrity": "sha512-YGCRZv/9GLhwmz6mYDeTsm/92BAyR28l6c2ReweVW5pWgfsitWLY8upvfRlGdoyD8HjeTHSYJWyZGD4KJA/nFQ==",
"version": "1.11.1",
"resolved": "https://registry.npmjs.org/@unrs/resolver-binding-android-arm64/-/resolver-binding-android-arm64-1.11.1.tgz",
"integrity": "sha512-lCxkVtb4wp1v+EoN+HjIG9cIIzPkX5OtM03pQYkG+U5O/wL53LC4QbIeazgiKqluGeVEeBlZahHalCaBvU1a2g==",
"cpu": [
"arm64"
],
@@ -4595,9 +4535,9 @@
]
},
"node_modules/@unrs/resolver-binding-darwin-arm64": {
"version": "1.12.2",
"resolved": "https://registry.npmjs.org/@unrs/resolver-binding-darwin-arm64/-/resolver-binding-darwin-arm64-1.12.2.tgz",
"integrity": "sha512-u9DiNT1auQMO20A9SyTuG3wUgQWB9Z7KjAg0uFuCDR1FsAY8A0CG2S6JpHS1xwm/w1G08bjXZDcyOCjv1WAm2w==",
"version": "1.11.1",
"resolved": "https://registry.npmjs.org/@unrs/resolver-binding-darwin-arm64/-/resolver-binding-darwin-arm64-1.11.1.tgz",
"integrity": "sha512-gPVA1UjRu1Y/IsB/dQEsp2V1pm44Of6+LWvbLc9SDk1c2KhhDRDBUkQCYVWe6f26uJb3fOK8saWMgtX8IrMk3g==",
"cpu": [
"arm64"
],
@@ -4609,9 +4549,9 @@
]
},
"node_modules/@unrs/resolver-binding-darwin-x64": {
"version": "1.12.2",
"resolved": "https://registry.npmjs.org/@unrs/resolver-binding-darwin-x64/-/resolver-binding-darwin-x64-1.12.2.tgz",
"integrity": "sha512-f7rPLi/T1HVKZu/u6t87lroib16n8vrSzcyxI7lg4BGO9UF26KhQL44sd9eOUgrTYhvRXtWOIZT5PejdPyJfUA==",
"version": "1.11.1",
"resolved": "https://registry.npmjs.org/@unrs/resolver-binding-darwin-x64/-/resolver-binding-darwin-x64-1.11.1.tgz",
"integrity": "sha512-cFzP7rWKd3lZaCsDze07QX1SC24lO8mPty9vdP+YVa3MGdVgPmFc59317b2ioXtgCMKGiCLxJ4HQs62oz6GfRQ==",
"cpu": [
"x64"
],
@@ -4623,9 +4563,9 @@
]
},
"node_modules/@unrs/resolver-binding-freebsd-x64": {
"version": "1.12.2",
"resolved": "https://registry.npmjs.org/@unrs/resolver-binding-freebsd-x64/-/resolver-binding-freebsd-x64-1.12.2.tgz",
"integrity": "sha512-BpcOjWCJub6nRZUS2zA20pmLvjtqAtGejETaIyRLiZiQf++cbrjltLA5NN/xaXfqeOBOSlMFbemIl5/S5tljmg==",
"version": "1.11.1",
"resolved": "https://registry.npmjs.org/@unrs/resolver-binding-freebsd-x64/-/resolver-binding-freebsd-x64-1.11.1.tgz",
"integrity": "sha512-fqtGgak3zX4DCB6PFpsH5+Kmt/8CIi4Bry4rb1ho6Av2QHTREM+47y282Uqiu3ZRF5IQioJQ5qWRV6jduA+iGw==",
"cpu": [
"x64"
],
@@ -4637,9 +4577,9 @@
]
},
"node_modules/@unrs/resolver-binding-linux-arm-gnueabihf": {
"version": "1.12.2",
"resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm-gnueabihf/-/resolver-binding-linux-arm-gnueabihf-1.12.2.tgz",
"integrity": "sha512-vZTDvdSISZjJx66OzJqtsOhzifbqRjbmI1Mnu49fQDwog5GtDI4QidRiEAYbZCRj9C8YZEW+3ZjqsyS9GR4k2A==",
"version": "1.11.1",
"resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm-gnueabihf/-/resolver-binding-linux-arm-gnueabihf-1.11.1.tgz",
"integrity": "sha512-u92mvlcYtp9MRKmP+ZvMmtPN34+/3lMHlyMj7wXJDeXxuM0Vgzz0+PPJNsro1m3IZPYChIkn944wW8TYgGKFHw==",
"cpu": [
"arm"
],
@@ -4651,9 +4591,9 @@
]
},
"node_modules/@unrs/resolver-binding-linux-arm-musleabihf": {
"version": "1.12.2",
"resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm-musleabihf/-/resolver-binding-linux-arm-musleabihf-1.12.2.tgz",
"integrity": "sha512-BiPI+IrIlwcW4nLLMM21+B1dFPzd55yAVgVGrdgDjNef+ch03GdxrcyaIz8X9SsQirh/kCQ7mviyWlMxdh2D7g==",
"version": "1.11.1",
"resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm-musleabihf/-/resolver-binding-linux-arm-musleabihf-1.11.1.tgz",
"integrity": "sha512-cINaoY2z7LVCrfHkIcmvj7osTOtm6VVT16b5oQdS4beibX2SYBwgYLmqhBjA1t51CarSaBuX5YNsWLjsqfW5Cw==",
"cpu": [
"arm"
],
@@ -4665,9 +4605,9 @@
]
},
"node_modules/@unrs/resolver-binding-linux-arm64-gnu": {
"version": "1.12.2",
"resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm64-gnu/-/resolver-binding-linux-arm64-gnu-1.12.2.tgz",
"integrity": "sha512-zJc0H99FEPoFfSrNpa91HYfxzfAJCr502oxNK1cfdC9hlaFI43RT+JFCann9JUgZmLzzntChHyn13Sgn9ljHNg==",
"version": "1.11.1",
"resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm64-gnu/-/resolver-binding-linux-arm64-gnu-1.11.1.tgz",
"integrity": "sha512-34gw7PjDGB9JgePJEmhEqBhWvCiiWCuXsL9hYphDF7crW7UgI05gyBAi6MF58uGcMOiOqSJ2ybEeCvHcq0BCmQ==",
"cpu": [
"arm64"
],
@@ -4679,9 +4619,9 @@
]
},
"node_modules/@unrs/resolver-binding-linux-arm64-musl": {
"version": "1.12.2",
"resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm64-musl/-/resolver-binding-linux-arm64-musl-1.12.2.tgz",
"integrity": "sha512-KQ3Lki6l+Pz1k/eBipN41ES+YUK30beLGb9YqcB1O542cyLCNE6GaxrfcY3T6EezmGGk84wb5XyO9loTM9tkcA==",
"version": "1.11.1",
"resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm64-musl/-/resolver-binding-linux-arm64-musl-1.11.1.tgz",
"integrity": "sha512-RyMIx6Uf53hhOtJDIamSbTskA99sPHS96wxVE/bJtePJJtpdKGXO1wY90oRdXuYOGOTuqjT8ACccMc4K6QmT3w==",
"cpu": [
"arm64"
],
@@ -4692,38 +4632,10 @@
"linux"
]
},
"node_modules/@unrs/resolver-binding-linux-loong64-gnu": {
"version": "1.12.2",
"resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-loong64-gnu/-/resolver-binding-linux-loong64-gnu-1.12.2.tgz",
"integrity": "sha512-3SJGEh1DborhG6pyxvhPzCT4bbSIVihsvgJc13P1bHG7KLdNDaF9T3gsTwFc7Jw/5Y5/iWOjkEx7Zy0NvCGX3Q==",
"cpu": [
"loong64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@unrs/resolver-binding-linux-loong64-musl": {
"version": "1.12.2",
"resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-loong64-musl/-/resolver-binding-linux-loong64-musl-1.12.2.tgz",
"integrity": "sha512-jiuG/Obbel7uw1PwHNFfrkiKhLAF6mnyZ6aWlOAVN9WqKm8v0OFGnciJIHu8+CMvXLQ8AD51LPzAoUfT21D5Ew==",
"cpu": [
"loong64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@unrs/resolver-binding-linux-ppc64-gnu": {
"version": "1.12.2",
"resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-ppc64-gnu/-/resolver-binding-linux-ppc64-gnu-1.12.2.tgz",
"integrity": "sha512-q7xRvVpmcfeL+LlZg8Pbbo6QaTZwDU5BaGZbwfhkEsXJn3Was8xYfE0RBH266xZt0rM6B7i8xAYIvjthuUIWHg==",
"version": "1.11.1",
"resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-ppc64-gnu/-/resolver-binding-linux-ppc64-gnu-1.11.1.tgz",
"integrity": "sha512-D8Vae74A4/a+mZH0FbOkFJL9DSK2R6TFPC9M+jCWYia/q2einCubX10pecpDiTmkJVUH+y8K3BZClycD8nCShA==",
"cpu": [
"ppc64"
],
@@ -4735,9 +4647,9 @@
]
},
"node_modules/@unrs/resolver-binding-linux-riscv64-gnu": {
"version": "1.12.2",
"resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-riscv64-gnu/-/resolver-binding-linux-riscv64-gnu-1.12.2.tgz",
"integrity": "sha512-0CVdx6lcnT3Q9inOH8tsMIOJ6ImndllMjqJHg8RLVdB7Vq4SfkEXl9mCSsVNuNA4MCYycRicCUxPCabVHJRr6A==",
"version": "1.11.1",
"resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-riscv64-gnu/-/resolver-binding-linux-riscv64-gnu-1.11.1.tgz",
"integrity": "sha512-frxL4OrzOWVVsOc96+V3aqTIQl1O2TjgExV4EKgRY09AJ9leZpEg8Ak9phadbuX0BA4k8U5qtvMSQQGGmaJqcQ==",
"cpu": [
"riscv64"
],
@@ -4749,9 +4661,9 @@
]
},
"node_modules/@unrs/resolver-binding-linux-riscv64-musl": {
"version": "1.12.2",
"resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-riscv64-musl/-/resolver-binding-linux-riscv64-musl-1.12.2.tgz",
"integrity": "sha512-iOwlRo9vnp6R6ohHQS11n0NnfdXx/omhkocmIfaPRpQhKZ+3BDMkkdRVh53qjkFkpPddf+FETA28NwGN7l5l+w==",
"version": "1.11.1",
"resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-riscv64-musl/-/resolver-binding-linux-riscv64-musl-1.11.1.tgz",
"integrity": "sha512-mJ5vuDaIZ+l/acv01sHoXfpnyrNKOk/3aDoEdLO/Xtn9HuZlDD6jKxHlkN8ZhWyLJsRBxfv9GYM2utQ1SChKew==",
"cpu": [
"riscv64"
],
@@ -4763,9 +4675,9 @@
]
},
"node_modules/@unrs/resolver-binding-linux-s390x-gnu": {
"version": "1.12.2",
"resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-s390x-gnu/-/resolver-binding-linux-s390x-gnu-1.12.2.tgz",
"integrity": "sha512-HYJtLfXq94q8iZNFT1lknx258wlkkWhZeUXJRqzKBBUJ00CvZ+N33zgbCqimLjsyw5Va6uUxhVa12mI+kaveEw==",
"version": "1.11.1",
"resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-s390x-gnu/-/resolver-binding-linux-s390x-gnu-1.11.1.tgz",
"integrity": "sha512-kELo8ebBVtb9sA7rMe1Cph4QHreByhaZ2QEADd9NzIQsYNQpt9UkM9iqr2lhGr5afh885d/cB5QeTXSbZHTYPg==",
"cpu": [
"s390x"
],
@@ -4777,9 +4689,9 @@
]
},
"node_modules/@unrs/resolver-binding-linux-x64-gnu": {
"version": "1.12.2",
"resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-x64-gnu/-/resolver-binding-linux-x64-gnu-1.12.2.tgz",
"integrity": "sha512-mPsUhunKKDih5O96Y6enDQyHc1SqBPlY1E/SfMWDM3EdJ95Z9CArPeCVwCCqbP45ljvivdEk8Fxn+SIb1rDAJQ==",
"version": "1.11.1",
"resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-x64-gnu/-/resolver-binding-linux-x64-gnu-1.11.1.tgz",
"integrity": "sha512-C3ZAHugKgovV5YvAMsxhq0gtXuwESUKc5MhEtjBpLoHPLYM+iuwSj3lflFwK3DPm68660rZ7G8BMcwSro7hD5w==",
"cpu": [
"x64"
],
@@ -4791,9 +4703,9 @@
]
},
"node_modules/@unrs/resolver-binding-linux-x64-musl": {
"version": "1.12.2",
"resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-x64-musl/-/resolver-binding-linux-x64-musl-1.12.2.tgz",
"integrity": "sha512-azrt6+5ydLd8Vt210AAFis/lZevSfPw93EJRIJG+xPu4WCJ8K0kppCTpMyLPcKT7H15M4Jnt2tMp5bOvCkRC6A==",
"version": "1.11.1",
"resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-x64-musl/-/resolver-binding-linux-x64-musl-1.11.1.tgz",
"integrity": "sha512-rV0YSoyhK2nZ4vEswT/QwqzqQXw5I6CjoaYMOX0TqBlWhojUf8P94mvI7nuJTeaCkkds3QE4+zS8Ko+GdXuZtA==",
"cpu": [
"x64"
],
@@ -4804,24 +4716,10 @@
"linux"
]
},
"node_modules/@unrs/resolver-binding-openharmony-arm64": {
"version": "1.12.2",
"resolved": "https://registry.npmjs.org/@unrs/resolver-binding-openharmony-arm64/-/resolver-binding-openharmony-arm64-1.12.2.tgz",
"integrity": "sha512-YZ9hP4O0X9PQb8eO980qmLNGH4zT3I9+SZTdt0Pr0YyuGQhYKoOZkV02VzrzyOZJ5xIJ3UFIenKkUkGg8GjgWQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openharmony"
]
},
"node_modules/@unrs/resolver-binding-wasm32-wasi": {
"version": "1.12.2",
"resolved": "https://registry.npmjs.org/@unrs/resolver-binding-wasm32-wasi/-/resolver-binding-wasm32-wasi-1.12.2.tgz",
"integrity": "sha512-tYFDIkMxSflfEc/h92ZWNsZlHSwgimbNHSO3PL2JWQHfCuC2q316jMyYU9TIWZsFK2bQwyK5VAdYgn8ygPj69A==",
"version": "1.11.1",
"resolved": "https://registry.npmjs.org/@unrs/resolver-binding-wasm32-wasi/-/resolver-binding-wasm32-wasi-1.11.1.tgz",
"integrity": "sha512-5u4RkfxJm+Ng7IWgkzi3qrFOvLvQYnPBmjmZQ8+szTK/b31fQCnleNl1GgEt7nIsZRIf5PLhPwT0WM+q45x/UQ==",
"cpu": [
"wasm32"
],
@@ -4829,18 +4727,16 @@
"license": "MIT",
"optional": true,
"dependencies": {
"@emnapi/core": "1.10.0",
"@emnapi/runtime": "1.10.0",
"@napi-rs/wasm-runtime": "^1.1.4"
"@napi-rs/wasm-runtime": "^0.2.11"
},
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/@unrs/resolver-binding-win32-arm64-msvc": {
"version": "1.12.2",
"resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-arm64-msvc/-/resolver-binding-win32-arm64-msvc-1.12.2.tgz",
"integrity": "sha512-qzNyg3xL0VPQmCaUh+N5jSitce6k+uCBfMDesWRnlULOZaqUkaJ0ybdT+UqlAWJoQjuqfIU/0Ptx9bteN4D82g==",
"version": "1.11.1",
"resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-arm64-msvc/-/resolver-binding-win32-arm64-msvc-1.11.1.tgz",
"integrity": "sha512-nRcz5Il4ln0kMhfL8S3hLkxI85BXs3o8EYoattsJNdsX4YUU89iOkVn7g0VHSRxFuVMdM4Q1jEpIId1Ihim/Uw==",
"cpu": [
"arm64"
],
@@ -4852,9 +4748,9 @@
]
},
"node_modules/@unrs/resolver-binding-win32-ia32-msvc": {
"version": "1.12.2",
"resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-ia32-msvc/-/resolver-binding-win32-ia32-msvc-1.12.2.tgz",
"integrity": "sha512-WD9sY00OfpHVGfsnHZoA8jVT+esS/Bg8z8jzxp5BnDCjjwsuKsPQrzswwpFy4J1AUJbXPRfkpcX0mXrzeXW79g==",
"version": "1.11.1",
"resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-ia32-msvc/-/resolver-binding-win32-ia32-msvc-1.11.1.tgz",
"integrity": "sha512-DCEI6t5i1NmAZp6pFonpD5m7i6aFrpofcp4LA2i8IIq60Jyo28hamKBxNrZcyOwVOZkgsRp9O2sXWBWP8MnvIQ==",
"cpu": [
"ia32"
],
@@ -4866,9 +4762,9 @@
]
},
"node_modules/@unrs/resolver-binding-win32-x64-msvc": {
"version": "1.12.2",
"resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-x64-msvc/-/resolver-binding-win32-x64-msvc-1.12.2.tgz",
"integrity": "sha512-nAB74NfSNKknqQ1RrYj6uz8FcXEomu/MATJZxh/x+BArzN2U3JbOYC0APYzUIGhVY3m5hRxA8VPNdPBoG8txlA==",
"version": "1.11.1",
"resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-x64-msvc/-/resolver-binding-win32-x64-msvc-1.11.1.tgz",
"integrity": "sha512-lrW200hZdbfRtztbygyaq/6jP6AKE8qQN2KvPcJ+x7wiD038YtnYtZ82IMNJ69GJibV7bwL3y9FgK+5w/pYt6g==",
"cpu": [
"x64"
],
@@ -13445,41 +13341,38 @@
}
},
"node_modules/unrs-resolver": {
"version": "1.12.2",
"resolved": "https://registry.npmjs.org/unrs-resolver/-/unrs-resolver-1.12.2.tgz",
"integrity": "sha512-dmlRxBJJayXjqTwC+JtF1HhJmgf3ftQ3YejFcZrf4+KKtJv0qDsK1pjqaaVjG7wJ5NJ6UVP1OqRMQ71Z4C3rxQ==",
"version": "1.11.1",
"resolved": "https://registry.npmjs.org/unrs-resolver/-/unrs-resolver-1.11.1.tgz",
"integrity": "sha512-bSjt9pjaEBnNiGgc9rUiHGKv5l4/TGzDmYw3RhnkJGtLhbnnA/5qJj7x3dNDCRx/PJxu774LlH8lCOlB4hEfKg==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"dependencies": {
"napi-postinstall": "^0.3.4"
"napi-postinstall": "^0.3.0"
},
"funding": {
"url": "https://opencollective.com/unrs-resolver"
},
"optionalDependencies": {
"@unrs/resolver-binding-android-arm-eabi": "1.12.2",
"@unrs/resolver-binding-android-arm64": "1.12.2",
"@unrs/resolver-binding-darwin-arm64": "1.12.2",
"@unrs/resolver-binding-darwin-x64": "1.12.2",
"@unrs/resolver-binding-freebsd-x64": "1.12.2",
"@unrs/resolver-binding-linux-arm-gnueabihf": "1.12.2",
"@unrs/resolver-binding-linux-arm-musleabihf": "1.12.2",
"@unrs/resolver-binding-linux-arm64-gnu": "1.12.2",
"@unrs/resolver-binding-linux-arm64-musl": "1.12.2",
"@unrs/resolver-binding-linux-loong64-gnu": "1.12.2",
"@unrs/resolver-binding-linux-loong64-musl": "1.12.2",
"@unrs/resolver-binding-linux-ppc64-gnu": "1.12.2",
"@unrs/resolver-binding-linux-riscv64-gnu": "1.12.2",
"@unrs/resolver-binding-linux-riscv64-musl": "1.12.2",
"@unrs/resolver-binding-linux-s390x-gnu": "1.12.2",
"@unrs/resolver-binding-linux-x64-gnu": "1.12.2",
"@unrs/resolver-binding-linux-x64-musl": "1.12.2",
"@unrs/resolver-binding-openharmony-arm64": "1.12.2",
"@unrs/resolver-binding-wasm32-wasi": "1.12.2",
"@unrs/resolver-binding-win32-arm64-msvc": "1.12.2",
"@unrs/resolver-binding-win32-ia32-msvc": "1.12.2",
"@unrs/resolver-binding-win32-x64-msvc": "1.12.2"
"@unrs/resolver-binding-android-arm-eabi": "1.11.1",
"@unrs/resolver-binding-android-arm64": "1.11.1",
"@unrs/resolver-binding-darwin-arm64": "1.11.1",
"@unrs/resolver-binding-darwin-x64": "1.11.1",
"@unrs/resolver-binding-freebsd-x64": "1.11.1",
"@unrs/resolver-binding-linux-arm-gnueabihf": "1.11.1",
"@unrs/resolver-binding-linux-arm-musleabihf": "1.11.1",
"@unrs/resolver-binding-linux-arm64-gnu": "1.11.1",
"@unrs/resolver-binding-linux-arm64-musl": "1.11.1",
"@unrs/resolver-binding-linux-ppc64-gnu": "1.11.1",
"@unrs/resolver-binding-linux-riscv64-gnu": "1.11.1",
"@unrs/resolver-binding-linux-riscv64-musl": "1.11.1",
"@unrs/resolver-binding-linux-s390x-gnu": "1.11.1",
"@unrs/resolver-binding-linux-x64-gnu": "1.11.1",
"@unrs/resolver-binding-linux-x64-musl": "1.11.1",
"@unrs/resolver-binding-wasm32-wasi": "1.11.1",
"@unrs/resolver-binding-win32-arm64-msvc": "1.11.1",
"@unrs/resolver-binding-win32-ia32-msvc": "1.11.1",
"@unrs/resolver-binding-win32-x64-msvc": "1.11.1"
}
},
"node_modules/update-browserslist-db": {
+7
View File
@@ -112,6 +112,13 @@ const DOMAIN_ERROR_CODES = {
status: HttpStatus.BAD_REQUEST,
message: '첨부는 한 메일에 최대 20개까지 보낼 수 있습니다.',
},
// 본문이 너무 길다 — 텍스트 100,000자·서식 본문 500,000자 초과, 서식 본문이 정화 전 비용 한도나 정화 뒤 2,000,000바이트를 넘음.
// 메일은 나가지 않는다. 클라이언트는 보내기 전에 같은 한도로 미리 막고 본문 아래에 이 문구를 보인다 (계약 2절)
MAIL_007: {
status: HttpStatus.BAD_REQUEST,
message:
'본문이 너무 길어 보낼 수 없습니다. 내용을 줄인 뒤 다시 보내 주세요.',
},
} as const satisfies Record<string, ErrorCodeDefinition>;
export const ERROR_CODES = {
@@ -13,8 +13,25 @@ function contextWith(file?: { path: string }): ExecutionContext {
} as unknown as ExecutionContext;
}
/** finalize 의 비동기 삭제가 끝날 때까지 잠깐 기다린다 */
const settle = () => new Promise((resolve) => setTimeout(resolve, 20));
/** 임시 파일이 지워지기를 기다리는 최대 시간 — 바쁜 CI 에서도 넉넉하게 */
const REMOVAL_DEADLINE_MS = 2_000;
/** 지워졌는지 다시 보는 간격 */
const REMOVAL_POLL_MS = 10;
/**
* 파일이 지워질 때까지 기다린다 — 기한 안에 지워졌으면 true, 기한이 지나도 남아 있으면 false.
* 인터셉터는 finalize 안에서 삭제를 시작만 하고 기다리지 않는다(`void this.remove(...)`) — 응답이 끝난 뒤에도 rm 이 진행 중일 수 있다.
* 예전에는 고정으로 20ms 만 기다렸는데, 부하가 큰 CI 에서 rm 이 그보다 늦어 가끔 실패했다 (2026-10-27).
* 그래서 지워질 때까지 자주 확인하고, 기한 안에 지워졌는지를 검사한다 — 삭제가 빠지면 기한 뒤에 그대로 실패한다.
*/
async function waitUntilRemoved(path: string): Promise<boolean> {
const deadline = Date.now() + REMOVAL_DEADLINE_MS;
while (existsSync(path)) {
if (Date.now() >= deadline) return false;
await new Promise((resolve) => setTimeout(resolve, REMOVAL_POLL_MS));
}
return true;
}
describe('DeleteUploadedFileInterceptor', () => {
let dir: string;
@@ -35,9 +52,8 @@ describe('DeleteUploadedFileInterceptor', () => {
await expect(
lastValueFrom(interceptor.intercept(contextWith({ path: file }), next)),
).resolves.toEqual({ id: 'blob' });
await settle();
expect(existsSync(file)).toBe(false);
await expect(waitUntilRemoved(file)).resolves.toBe(true);
});
it('핸들러가 실패해도 임시 파일을 지운다', async () => {
@@ -48,9 +64,8 @@ describe('DeleteUploadedFileInterceptor', () => {
await expect(
lastValueFrom(interceptor.intercept(contextWith({ path: file }), next)),
).rejects.toThrow('engine down');
await settle();
expect(existsSync(file)).toBe(false);
await expect(waitUntilRemoved(file)).resolves.toBe(true);
});
it('파일이 없는 요청은 그냥 통과한다', async () => {
@@ -232,7 +232,7 @@ export class MessageDetailDto
@ApiProperty({
description:
'서버가 정화한 HTML — 스크립트·스타일 태그·이벤트 핸들러·폼·iframe·object·embed·svg 제거, 외부 이미지 제거. 클라이언트는 격리된 iframe 에만 그린다. ' +
`HTML 이 없거나, 정화 한도(HTML 부분을 이어 붙여 ${MAX_BODY_VALUE_BYTES.toLocaleString('ko-KR')}자 초과 · 너무 깊은 중첩)를 넘으면 null — 그때는 textBody 를 보여 준다`,
`HTML 이 없거나, 정화 한도(HTML 부분을 이어 붙여 ${MAX_BODY_VALUE_BYTES.toLocaleString('ko-KR')}자 초과 · 너무 깊은 중첩)를 넘으면 null — 그때는 textBody 를 보여 준다 (한도를 넘은 경우는 isHtmlBodyOmitted 가 true)`,
nullable: true,
type: String,
})
@@ -251,6 +251,22 @@ export class MessageDetailDto
})
hasBlockedImages!: boolean;
@ApiProperty({
description:
'HTML 이 있었지만 정화 한도(너무 길거나 깊게 중첩됨)를 넘어 htmlBody 를 비웠으면 true → 서식 없이 textBody 를 보여 주고 안내한다 (textBody 도 없으면 표시할 수 없음). ' +
'HTML 이 없는 메일은 false',
example: false,
})
isHtmlBodyOmitted!: boolean;
@ApiProperty({
description:
`보여 줄 본문(htmlBody — null 이거나 공백뿐이면 textBody)이 읽기 한도(본문 한 부분 UTF-8 ${MAX_BODY_VALUE_BYTES.toLocaleString('ko-KR')}바이트)에서 잘렸으면 true → "앞부분만 표시" 안내, ` +
'답장·전달 인용 끝에 생략 표시',
example: false,
})
isBodyTruncated!: boolean;
@ApiProperty({
description: '첨부 (본문 내장 이미지 포함)',
type: [MailAttachmentDto],
@@ -5,7 +5,11 @@ import {
MAX_ATTACHMENT_LIST_LENGTH,
MAX_ATTACHMENTS_PER_MESSAGE,
} from '@/modules/mail/attachment.policy';
import { MAX_BODY_HTML_LENGTH } from '@/modules/mail/mail.constants';
import {
MAX_BODY_HTML_LENGTH,
MAX_BODY_LENGTH,
MAX_SUBJECT_LENGTH,
} from '@/modules/mail/mail.constants';
import { SendMessageDto } from './send-message.dto';
/** 전역 ValidationPipe 와 같은 옵션으로 검증한다 (app.setup.ts) */
@@ -28,26 +32,48 @@ const withAttachments = (count: number) => ({
})),
});
describe('SendMessageDto 서식 본문 (bodyHtml)', () => {
const withHtml = (bodyHtml: unknown) => ({
describe('SendMessageDto 본문 (body · bodyHtml)', () => {
const message = (overrides: object) => ({
to: [{ name: null, email: 'doyun.lee@arasystem.test' }],
subject: '제목',
body: '본문',
bodyHtml,
...overrides,
});
it('선택 항목이고 500,000자까지 받는다', async () => {
expect(await constraintsOf({ ...withHtml(undefined) })).toEqual([]);
it('서식 본문은 선택 항목이다', async () => {
expect(await constraintsOf(message({ bodyHtml: undefined }))).toEqual([]);
expect(await constraintsOf(message({ bodyHtml: '<p>x</p>' }))).toEqual([]);
});
it('길이는 형식 검증에서 막지 않는다 — 한도는 서비스가 JS 문자열 길이로 세어 MAIL_007 로 답한다', async () => {
expect(
await constraintsOf(withHtml('가'.repeat(MAX_BODY_HTML_LENGTH))),
await constraintsOf(
message({
body: '가'.repeat(MAX_BODY_LENGTH + 1),
bodyHtml: '가'.repeat(MAX_BODY_HTML_LENGTH + 1),
}),
),
).toEqual([]);
});
it('500,000자를 넘거나 문자열이 아니면 형식 검증에서 막는다 (VAL_001)', async () => {
it('문자열이 아니면 형식 검증에서 막는다 (VAL_001)', async () => {
expect(await constraintsOf(message({ body: 1 }))).toContain('isString');
expect(await constraintsOf(message({ bodyHtml: ['<p>x</p>'] }))).toContain(
'isString',
);
});
it('제목은 그대로 형식 검증에서 255자로 막는다 (VAL_001)', async () => {
expect(
await constraintsOf(withHtml('가'.repeat(MAX_BODY_HTML_LENGTH + 1))),
await constraintsOf(
message({ subject: '가'.repeat(MAX_SUBJECT_LENGTH) }),
),
).toEqual([]);
expect(
await constraintsOf(
message({ subject: '가'.repeat(MAX_SUBJECT_LENGTH + 1) }),
),
).toContain('maxLength');
expect(await constraintsOf(withHtml(['<p>x</p>']))).toContain('isString');
});
});
@@ -21,6 +21,7 @@ import {
MAIL_ID_PATTERN,
MAX_BODY_HTML_LENGTH,
MAX_BODY_LENGTH,
MAX_BODY_VALUE_BYTES,
MAX_DISPLAY_NAME_LENGTH,
MAX_EMAIL_LENGTH,
MAX_FILE_NAME_LENGTH,
@@ -123,29 +124,29 @@ export class SendMessageDto {
})
subject!: string;
// 본문 두 개의 길이는 여기서 막지 않는다 — 서비스가 JS 문자열 길이(UTF-16 코드 단위)로 세어 400 MAIL_007 로 답한다 (mail.service.ts 의 prepareBody).
// MaxLength(validator.js)는 서로게이트 쌍·이모지 표시 선택자를 덜 세어 클라이언트가 센 길이와 어긋나고, 실패 코드도 VAL_001 이 된다.
@ApiProperty({
description: `일반 텍스트 본문 (${MAX_BODY_LENGTH.toLocaleString('ko-KR')}자 이하, 빈 문자열 가능) — 서식 본문을 보낼 때는 같은 내용의 대체 본문 (목록은 "- "·"1. ", 인용은 "> ")`,
description: `일반 텍스트 본문 (${MAX_BODY_LENGTH.toLocaleString('ko-KR')}자 이하 — JS 문자열 길이, 넘으면 400 MAIL_007. 빈 문자열 가능) — 서식 본문을 보낼 때는 같은 내용의 대체 본문 (목록은 "- "·"1. ", 인용은 "> ")`,
example: '도윤님, 확인했습니다.',
maxLength: MAX_BODY_LENGTH,
})
@IsString({ message: '본문 형식이 올바르지 않습니다.' })
@MaxLength(MAX_BODY_LENGTH, {
message: `본문은 ${MAX_BODY_LENGTH.toLocaleString('ko-KR')}자 이하로 입력해 주세요.`,
})
body!: string;
@ApiPropertyOptional({
description:
`서식 본문 HTML (${MAX_BODY_HTML_LENGTH.toLocaleString('ko-KR')}자 이하) — 서식 편집기가 만든 HTML. ` +
`서식 본문 HTML (${MAX_BODY_HTML_LENGTH.toLocaleString('ko-KR')}자 이하 — JS 문자열 길이, 넘으면 400 MAIL_007) — 서식 편집기가 만든 HTML. ` +
'서버가 허용 목록(문단·글자 서식·span 의 색·글꼴·크기·정렬·들여쓰기·목록·인용·가로줄·표·http/https/mailto 링크)으로 정화한 뒤 ' +
'body 와 함께 multipart/alternative 로 보낸다. 없으면 텍스트 메일',
'body 와 함께 multipart/alternative 로 보낸다. 없으면 텍스트 메일. ' +
`정화 전 비용 한도(깊은 중첩)를 넘거나 정화한 결과가 UTF-8 ${MAX_BODY_VALUE_BYTES.toLocaleString('ko-KR')}바이트를 넘어도 400 MAIL_007`,
example:
'<div style="font-family:\'맑은 고딕\', sans-serif;font-size:14px"><p>도윤님, 확인했습니다.</p></div>',
maxLength: MAX_BODY_HTML_LENGTH,
})
@IsOptional()
@IsString({ message: '서식 본문 형식이 올바르지 않습니다.' })
@MaxLength(MAX_BODY_HTML_LENGTH, {
message: `서식 본문은 ${MAX_BODY_HTML_LENGTH.toLocaleString('ko-KR')}자 이하여야 합니다.`,
})
bodyHtml?: string;
@ApiPropertyOptional({
@@ -1,5 +1,6 @@
import { Parser } from 'htmlparser2';
import { startCpuTimer } from '@/../test/cpu-time';
import {
MAX_HTML_PARSE_COST,
MAX_HTML_PARSE_LENGTH,
@@ -9,8 +10,11 @@ import {
import { sanitizeMailHtml } from './sanitize-mail-html';
import { sanitizeOutgoingHtml } from './sanitize-outgoing-html';
/** 시간 단언은 넉넉하게 — 이 PC 에서 검사는 2MB 에 0.1초 안쪽이다 (느린 CI 도 견디게) */
const GENEROUS_MS = 2_000;
/**
* CPU 시간 한도 (벽시계가 아니다 — test/cpu-time.ts). 지금 검사는 2MB 를 끝까지 읽어도 CPU 0.3초 안쪽이고 공격 모양은 0.15초 안쪽에 멈춘다.
* 막으려는 제곱 경로는 같은 공격 입력을 정화하는 것 — '<b>' 2MB 에 172초다.
*/
const GENEROUS_CPU_MS = 2_000;
/** 보내는 서식 본문 한도 (계약 2절 — DTO 가 막는다) */
const OUTGOING_MAX_CHARS = 500_000;
@@ -19,10 +23,11 @@ const OUTGOING_MAX_CHARS = 500_000;
const fillTo = (unit: string, chars = MAX_HTML_PARSE_LENGTH) =>
unit.repeat(Math.floor(chars / unit.length));
function elapsedMs<T>(run: () => T): [number, T] {
const started = performance.now();
/** run 이 쓴 CPU 시간(ms)과 그 결과 */
function cpuTimed<T>(run: () => T): [number, T] {
const cpuMs = startCpuTimer();
const result = run();
return [performance.now() - started, result];
return [cpuMs(), result];
}
/** htmlparser2 Parser 의 빈 요소 (12.0.0 src/Parser.ts) — 스택에 올라가지 않으므로 비교에서 뺀다 */
@@ -227,10 +232,11 @@ describe('checkHtmlParseBudget (정화 전 사전 검사)', () => {
const html = make(MAX_HTML_PARSE_LENGTH);
expect(html.length).toBeLessThanOrEqual(MAX_HTML_PARSE_LENGTH);
const [ms, result] = elapsedMs(() => checkHtmlParseBudget(html));
const [cpuMs, result] = cpuTimed(() => checkHtmlParseBudget(html));
expect(result).toEqual({ ok: false, reason: 'too-complex' });
expect(ms).toBeLessThan(GENEROUS_MS);
// 지금 CPU 0.15초 안쪽에 멈춘다 — 정화까지 갔다면 '<b>' 2MB 에 172초
expect(cpuMs).toBeLessThan(GENEROUS_CPU_MS);
});
it.each(ATTACKS)('보내는 서식 본문 500,000자 — %s', (_name, make) => {
@@ -265,10 +271,11 @@ describe('checkHtmlParseBudget (정화 전 사전 검사)', () => {
const html = make();
expect(html.length).toBeLessThanOrEqual(MAX_HTML_PARSE_LENGTH);
const [ms, result] = elapsedMs(() => checkHtmlParseBudget(html));
const [cpuMs, result] = cpuTimed(() => checkHtmlParseBudget(html));
expect(result).toEqual({ ok: true });
expect(ms).toBeLessThan(GENEROUS_MS);
// 끝까지 읽어도 지금 CPU 0.3초 안쪽 (검사는 사건마다 O(1) — 스택을 훑지 않는다)
expect(cpuMs).toBeLessThan(GENEROUS_CPU_MS);
});
it('300단 인용 스레드도 상한에 3배 넘게 여유가 있다', () => {
@@ -294,12 +301,12 @@ describe('checkHtmlParseBudget (정화 전 사전 검사)', () => {
const html = '<b>'.repeat(1_000) + '</i>'.repeat(closes);
expect(checkHtmlParseBudget(html)).toEqual({ ok: true });
const [receivedMs] = elapsedMs(() => sanitizeMailHtml(html));
const [outgoingMs] = elapsedMs(() => sanitizeOutgoingHtml(html));
const [receivedCpuMs] = cpuTimed(() => sanitizeMailHtml(html));
const [outgoingCpuMs] = cpuTimed(() => sanitizeOutgoingHtml(html));
// 이 PC 에서 0.1~0.2초 — 상한이 없으면 같은 모양 2MB 가 0.6초, 깊이 3000 이면 1.7초다
expect(receivedMs).toBeLessThan(GENEROUS_MS);
expect(outgoingMs).toBeLessThan(GENEROUS_MS);
// 지금 CPU 0.2~0.4초 — 상한이 없으면 같은 모양 2MB 가 0.6초, 깊이 3000 이면 1.7초다 (벽시계로 잰 값)
expect(receivedCpuMs).toBeLessThan(GENEROUS_CPU_MS);
expect(outgoingCpuMs).toBeLessThan(GENEROUS_CPU_MS);
});
});
@@ -325,12 +332,12 @@ describe('measureHtmlParseCost', () => {
const html = '<b>'.repeat(600_000);
const limit = 1_000_000;
const [ms, cost] = elapsedMs(() => measureHtmlParseCost(html, { limit }));
const [cpuMs, cost] = cpuTimed(() => measureHtmlParseCost(html, { limit }));
// 깊이 1414 쯤에서 넘는다 (끝까지 세면 1.8×10¹¹)
// 깊이 1414 쯤에서 넘는다 (끝까지 세면 1.8×10¹¹) — 멈췄는지는 비용 단언이 본다. 지금 CPU 수 ms
expect(cost).toBeGreaterThan(limit);
expect(cost).toBeLessThan(limit + 2_000);
expect(ms).toBeLessThan(GENEROUS_MS);
expect(cpuMs).toBeLessThan(GENEROUS_CPU_MS);
});
describe('htmlparser2 Parser 와 같은 순서로 열고 닫는다 — 판을 올려 규칙이 바뀌면 여기서 걸린다', () => {
@@ -441,7 +441,7 @@ export type HtmlParseBudgetCheck =
/**
* 이 HTML 을 정화해도 되는가 — 길이와 스택 비용이 한도 안인지 본다 (선형 시간).
* 넘으면 정화하지 않는다: 받은 메일은 htmlBody 없이(텍스트 본문으로), 보내는 서식 본문은 VAL_001 (mail.service.ts).
* 넘으면 정화하지 않는다: 받은 메일은 htmlBody 없이(텍스트 본문으로, isHtmlBodyOmitted), 보내는 서식 본문은 MAIL_007 (mail.service.ts).
*/
export function checkHtmlParseBudget(html: string): HtmlParseBudgetCheck {
if (html.length > MAX_HTML_PARSE_LENGTH) {
@@ -1,3 +1,4 @@
import { startCpuTimer } from '@/../test/cpu-time';
import { sanitizeMailHtml } from './sanitize-mail-html';
const clean = (html: string, inline?: ReadonlyMap<string, string>) =>
@@ -99,14 +100,15 @@ describe('sanitizeMailHtml', () => {
});
it('url( 뒤 공백이 아주 길어도 한 번만 훑는다 (예전: 공백 4만 개에 0.6초 — 공백 수의 제곱)', () => {
const started = performance.now();
const cpuMs = startCpuTimer();
const result = sanitizeMailHtml(
`<b style="background:url(${' '.repeat(1_900_000)}x)">y</b>`,
);
expect(result).toEqual({ html: '<b>y</b>', hasBlockedImages: false });
expect(performance.now() - started).toBeLessThan(2_000);
// CPU 시간 (test/cpu-time.ts) — 지금 190만 개에 0.2초 안쪽. 예전 제곱 경로면 같은 길이에 20분 넘게 걸린다 (0.6초 × 47.5²)
expect(cpuMs()).toBeLessThan(2_000);
// 따옴표 앞뒤의 공백도 그대로 외부 주소로 알아본다
expect(
sanitizeMailHtml(
@@ -224,13 +226,14 @@ describe('sanitizeMailHtml', () => {
it('주소가 남지 않은 이미지가 아주 많아도 입력 길이에 비례해 끝난다 (예전: 27만 개에 15초)', () => {
const count = 333_333; // 2MB
const started = performance.now();
const cpuMs = startCpuTimer();
const result = sanitizeMailHtml('x<img>'.repeat(count));
expect(result.html).toBe('x'.repeat(count));
// 이 PC 에서 0.5초 안쪽 — 넉넉하게
expect(performance.now() - started).toBeLessThan(5_000);
// CPU 시간 (test/cpu-time.ts) — 지금 1.1~1.6초(부하 중에도). 예전 경로(결과를 이미지마다 다시 자름)는 27만 개(1.6MB)에 15초, 이 크기면 20초가 넘는다.
// 벽시계로 재던 때는 바쁜 PC 에서 5.9초·9.1초로 재져 실패했다 (2026-10-27)
expect(cpuMs()).toBeLessThan(5_000);
});
});
@@ -223,6 +223,39 @@ describe('sanitizeOutgoingHtml (보내는 서식 본문)', () => {
'<span style="color:#abc;background-color:rgba(0, 0, 0, 0.5)">a</span>',
);
});
it('보이지 않게 숨긴 글은 그 서식만 지운다 — 8px 보다 작은 글자, 알파 0.3 미만의 글자 색 (글은 기본 크기·색으로 남는다)', () => {
for (const style of [
'font-size: 1px',
'font-size: 7.99px',
'color: rgba(255, 255, 255, 0)',
'color: rgba(0, 0, 0, .29)',
// 알파를 붙인 rgb() 도 같은 규칙이다
'color: rgb(0, 0, 0, 0.1)',
]) {
expect(clean(`<p><span style="${style}">숨은 글</span></p>`)).toBe(
'<p><span>숨은 글</span></p>',
);
}
// 하한 위의 값과, 투명해도 글을 숨기지 않는 배경 색·테두리 색은 남긴다
expect(
clean(
'<p><span style="font-size: 8px; color: rgba(0, 0, 0, .3); background-color: rgba(0, 0, 0, 0)">글</span></p>' +
'<table style="border: 1px solid rgba(0, 0, 0, 0)"><tbody><tr><td style="border-color: rgba(0, 0, 0, 0)">칸</td></tr></tbody></table>',
),
).toBe(
'<p><span style="font-size:8px;color:rgba(0, 0, 0, .3);background-color:rgba(0, 0, 0, 0)">글</span></p>' +
'<table style="border:1px solid rgba(0, 0, 0, 0)"><tbody><tr><td style="border-color:rgba(0, 0, 0, 0)">칸</td></tr></tbody></table>',
);
});
it('받은 메일 정화에는 이 하한이 없다 — 받은 뉴스레터의 작은 글자·투명한 글자 색은 그대로 둔다', () => {
const received =
'<p><span style="font-size:1px;color:rgba(0, 0, 0, 0)">작은 글</span></p>';
expect(sanitizeMailHtml(received).html).toBe(received);
expect(clean(received)).toBe('<p><span>작은 글</span></p>');
});
});
/**
@@ -244,6 +277,9 @@ describe('sanitizeOutgoingHtml (보내는 서식 본문)', () => {
['color', 'rgb(23, 92, 211)', true],
['color', 'RGB(1,2,3)', true],
['color', 'rgba(0, 0, 0, 0.5)', true],
['color', 'rgba(0, 0, 0, 0.3)', true],
['color', 'rgba(0, 0, 0, 0.29)', false],
['color', 'rgba(0, 0, 0, 0)', false],
['color', 'red', false],
['color', 'transparent', false],
['color', 'currentcolor', false],
@@ -257,11 +293,14 @@ describe('sanitizeOutgoingHtml (보내는 서식 본문)', () => {
['background-color', '#FFF1A8', true],
['background-color', 'yellow', false],
['background-color', 'transparent', false],
// 글자 크기 — px 만, 1~99.99px
['background-color', 'rgba(0, 0, 0, 0)', true],
// 글자 크기 — px 만, 8~99.99px (8px 미만은 보이지 않게 숨긴 글로 보고 지운다)
['font-size', '14px', true],
['font-size', '12.5px', true],
['font-size', '13.33px', true],
['font-size', '1px', true],
['font-size', '1px', false],
['font-size', '8px', true],
['font-size', '7.99px', false],
['font-size', '99.99px', true],
['font-size', '0', false],
['font-size', '0px', false],
@@ -13,6 +13,9 @@ import { NON_TEXT_TAGS, SAFE_LINK } from './sanitize-mail-html';
* - 태그: 문단·줄바꿈, 굵게·기울임·밑줄·취소선, span(글자 서식), 링크, 목록, 인용, 가로줄, 표, 감싸는 div
* - style: **속성마다 값의 형식까지** 검사한다 (색·길이·글꼴 이름·정렬 낱말). 괄호는 rgb()·rgba() 색에만 쓸 수 있어
* url()·expression()·CSS 이스케이프가 들어갈 자리가 없다
* - 보이지 않을 만큼 작거나 투명한 서식은 지운다: 8px 보다 작은 글자 크기, 알파 0.3 미만의 글자 색 — 붙여 넣거나 인용한 글에
* 그렇게 숨긴 글이 보이지 않는 채로 실려 나가지 않게. 바탕과 같은 색(흰 바탕 위 흰 글자 등)은 바탕을 알 수 없어 막지 않는다
* (받은 메일 정화에는 이 하한이 없다 — 받은 뉴스레터의 모양은 그대로 둔다)
* - 링크: http·https·mailto 만. 그 밖의 링크는 글자만 남긴다
* - 이미지·폼·스크립트·이벤트 속성·id·class·data-* 는 남기지 않는다 (본문 이미지는 이번 단계에서 넣지 않는다 — 첨부로)
*
@@ -21,7 +24,7 @@ import { NON_TEXT_TAGS, SAFE_LINK } from './sanitize-mail-html';
* (sanitize-outgoing-html.spec.ts 의 "다시 읽기" 테스트가 지킨다).
*
* ⚠️ 부르기 전에 html-parse-budget.ts 의 `checkHtmlParseBudget` 으로 중첩 비용을 보고, 부른 뒤에는 결과의 바이트 수를 본다
* (mail.service.ts 의 sanitizeBodyHtml — 둘 다 넘으면 VAL_001).
* (mail.service.ts 의 sanitizeBodyHtml — 둘 다 넘으면 MAIL_007).
*
* ⚠️ 프론트에 짝이 있다 — frontend/src/shared/utils/outgoing-html-rules.ts 가 아래 STYLE_RULES · OUTGOING_FONT_NAMES ·
* SMALL_NUMBER 와 **같은 값 형식**을 둔다. 쓰기 화면의 편집기가 붙여 넣은 글·원문 인용의 HTML 을 읽어 들일 때 그 형식에 맞는
@@ -75,21 +78,31 @@ const ALLOWED_ATTRIBUTES: Record<string, string[]> = {
// 모두 처음부터 끝까지(^…$) 맞아야 한다. 값에 쓸 수 있는 글자가 정해져 있어 함수·주석·이스케이프가 끼어들 수 없다.
// 형식(단위·글꼴 목록·색 표기)은 엄격하지만 값의 범위는 쓰기 화면의 메뉴(docs/design/webmail/README.md "메일 쓰기")보다
// **넓다** — 붙여 넣거나 답장·전달로 인용한 글의 모양도 지키기 위해서다:
// 글자 크기 1~99.99px(메뉴는 12~32px), 여백·들여쓰기·테두리 두께 0~999.99px(음수 없음),
// 색 #rgb·#rrggbb·rgb()·rgba()(알파 0 포함 — 브라우저가 색을 되읽으면 rgb()/rgba() 로 바꾼다), 글꼴은 아래 목록의 이름만.
// 글자 크기 8~99.99px(메뉴는 12~32px), 여백·들여쓰기·테두리 두께 0~999.99px(음수 없음),
// 색 #rgb·#rrggbb·rgb()·rgba()(브라우저가 색을 되읽으면 rgb()/rgba() 로 바꾼다 — 배경 색·테두리 색은 알파 0~1,
// 글자 색은 알파 0.3~1), 글꼴은 아래 목록의 이름만.
// 글자 크기·글자 색의 하한은 보이지 않는 글을 막는 선이다 — 더 작거나 더 투명한 값은 그 선언만 지워 기본 크기·색으로 보인다.
// 쓰기 화면의 편집기도 같은 규칙에 맞는 값만 남긴다 (편집기에 보이는 모양 = 보내는 모양) — 한쪽을 바꾸면 다른 쪽도 함께 바꾼다.
/** px 길이 — 0 또는 0~999.99px. 음수는 받지 않는다 (음수 여백으로 다른 내용을 덮는 화면 위장 방지) */
const PX = String.raw`(?:0|\d{1,3}(?:\.\d{1,2})?px)`;
/** 칸 폭 — px · % · auto */
const WIDTH = String.raw`(?:${PX}|\d{1,3}(?:\.\d{1,2})?%|auto)`;
/** 색 — #rgb · #rrggbb · rgb()/rgba() (괄호 안은 숫자만) */
/** 색 — #rgb · #rrggbb · rgb()/rgba() (괄호 안은 숫자만, 알파 0~1) — 배경 색·테두리 색 */
const COLOR = String.raw`(?:#[0-9a-f]{3}|#[0-9a-f]{6}|rgba?\(\s*\d{1,3}\s*,\s*\d{1,3}\s*,\s*\d{1,3}\s*(?:,\s*(?:0|1|0?\.\d{1,3})\s*)?\))`;
/**
* 글자 색 — COLOR 와 같되 rgb()/rgba() 에 알파가 있으면 0.3~1 (투명에 가까운 글자로 숨긴 글을 보내지 않게).
* hex 는 알파가 없다(8자리 hex 는 COLOR 에서도 받지 않는다).
*/
const TEXT_COLOR = String.raw`(?:#[0-9a-f]{3}|#[0-9a-f]{6}|rgba?\(\s*\d{1,3}\s*,\s*\d{1,3}\s*,\s*\d{1,3}\s*(?:,\s*(?:1|0?\.[3-9]\d{0,2})\s*)?\))`;
const BORDER_STYLE = String.raw`(?:none|hidden|dotted|dashed|solid|double)`;
const BORDER_WIDTH = String.raw`(?:${PX}|thin|medium|thick)`;
const BORDER_PART = String.raw`(?:${BORDER_WIDTH}|${BORDER_STYLE}|${COLOR})`;
/** 글자 크기 — px 만, 1~99.99px (메뉴는 12·13·14·16·18·20·24·32, 인용 머리 12.5 — 붙여 넣은 글의 크기도 남긴다) */
const FONT_SIZE = String.raw`(?:[1-9]\d?(?:\.\d{1,2})?px)`;
/**
* 글자 크기 — px 만, 8~99.99px (메뉴는 12·13·14·16·18·20·24·32, 인용 머리 12.5 — 붙여 넣은 글의 크기도 남긴다).
* 8px 아래는 보이지 않게 숨긴 글로 보고 지운다.
*/
const FONT_SIZE = String.raw`(?:(?:[89]|[1-9]\d)(?:\.\d{1,2})?px)`;
/** 줄 간격 — 단위 없는 숫자 1~3, 소수 둘째 자리까지 (편집기 기본 1.75). px·%·em·normal 은 받지 않는다 */
const LINE_HEIGHT = String.raw`(?:[12](?:\.\d{1,2})?|3(?:\.0{1,2})?)`;
@@ -140,6 +153,7 @@ const exactly = (pattern: string, flags = 'i') =>
new RegExp(`^${pattern}$`, flags);
const COLOR_VALUE = exactly(COLOR);
const TEXT_COLOR_VALUE = exactly(TEXT_COLOR);
const PX_VALUE = exactly(PX);
const WIDTH_VALUE = exactly(WIDTH);
/** margin·padding 줄임형 — px 길이 1~4개 */
@@ -161,9 +175,10 @@ const LINE_HEIGHT_VALUE = exactly(LINE_HEIGHT);
* - 인용 막대: border-left·padding-left·margin·color
* - 표(원본 메일 머리 표 포함): 테두리·칸 합치기·칸 폭·칸 여백·세로 정렬·줄바꿈 막기
* 어느 태그에 오든 같은 규칙이다 (편집기가 서식을 문단·칸·목록 어디에 붙이든 남도록).
* 글자 색(color)만 투명도 하한이 있다 — 배경 색·테두리 색은 투명해도 글이 숨지 않는다.
*/
const STYLE_RULES: Record<string, RegExp[]> = {
color: [COLOR_VALUE],
color: [TEXT_COLOR_VALUE],
'background-color': [COLOR_VALUE],
'font-family': [FONT_FAMILY_VALUE],
'font-size': [FONT_SIZE_VALUE],
@@ -608,7 +608,9 @@ describe('JmapMailProvider', () => {
messageId: 'abc@arasystem.test',
references: [],
rawHtmlBody: '<p>HTML 본문</p><img src="cid:logo@x">',
isRawHtmlBodyTruncated: false,
textBody: '텍스트 본문',
isTextBodyTruncated: false,
});
expect(detail.attachments).toEqual([
{
@@ -647,9 +649,92 @@ describe('JmapMailProvider', () => {
const detail = await provider.getMessage(ARA, 'm1');
expect(detail.rawHtmlBody).toBeNull();
expect(detail.isRawHtmlBodyTruncated).toBe(false);
expect(detail.textBody).toBe('텍스트 본문');
});
/** 엔진이 본문 읽기 한도(maxBodyValueBytes)에서 자른 부분은 isTruncated 가 켜져 온다 (RFC 8621 EmailBodyValue) */
it.each([
{
name: 'HTML 부분이 잘렸다',
bodyValues: {
'1': { value: '텍스트 본문', isTruncated: false },
'2': { value: '<p>HTML 앞부분', isTruncated: true },
},
html: true,
text: false,
},
{
name: '텍스트 부분이 잘렸다',
bodyValues: {
'1': { value: '텍스트 앞부분', isTruncated: true },
'2': { value: '<p>HTML 본문</p>', isTruncated: false },
},
html: false,
text: true,
},
{
name: '잘린 부분이 없다 (isTruncated 가 없는 값 포함)',
bodyValues: {
'1': { value: '텍스트 본문' },
'2': { value: '<p>HTML 본문</p>', isTruncated: false },
},
html: false,
text: false,
},
])(
'본문 잘림은 HTML·텍스트 따로 넘긴다 — $name',
async ({ bodyValues, html, text }) => {
const { provider } = fakeClient([
[['Email/get', { list: [{ ...detailEmail, bodyValues }] }, 'g']],
]);
const detail = await provider.getMessage(ARA, 'm1');
expect(detail.isRawHtmlBodyTruncated).toBe(html);
expect(detail.isTextBodyTruncated).toBe(text);
// 잘린 값도 그대로 넘긴다 — 무엇을 보여 줄지는 서비스가 정한다
expect(detail.rawHtmlBody).toBe(bodyValues['2'].value);
expect(detail.textBody).toBe(bodyValues['1'].value);
},
);
it('HTML 부분이 여럿이면 하나라도 잘렸으면 잘린 본문이다 — 다른 형식의 부분은 세지 않는다', async () => {
const multipart = {
...detailEmail,
bodyValues: {
'1': { value: '텍스트 앞부분', isTruncated: true },
'2': { value: '<p>첫 부분</p>', isTruncated: false },
'5': { value: '<p>둘째 부분', isTruncated: true },
},
// JMAP htmlBody 에는 HTML 이 없는 구간의 text/plain 부분도 들어 있을 수 있다 — 그 잘림은 HTML 의 것이 아니다
htmlBody: [
{ partId: '2', type: 'text/html' },
{ partId: '1', type: 'text/plain' },
{ partId: '5', type: 'text/html' },
],
};
const onlyTextTruncated = {
...multipart,
bodyValues: {
...multipart.bodyValues,
'5': { value: '<p>둘째 부분</p>', isTruncated: false },
},
};
const { provider } = fakeClient([
[['Email/get', { list: [multipart] }, 'g']],
[['Email/get', { list: [onlyTextTruncated] }, 'g']],
]);
const first = await provider.getMessage(ARA, 'm1');
expect(first.rawHtmlBody).toBe('<p>첫 부분</p>\n<p>둘째 부분');
expect(first.isRawHtmlBodyTruncated).toBe(true);
const second = await provider.getMessage(ARA, 'm1');
expect(second.isRawHtmlBodyTruncated).toBe(false);
expect(second.isTextBodyTruncated).toBe(true);
});
it('없는 메일은 RES_001 이다', async () => {
const { provider } = fakeClient([
[['Email/get', { list: [], notFound: ['nope'] }, 'g']],
@@ -330,7 +330,7 @@ export class JmapMailProvider implements MailProvider {
bodyProperties: BODY_PART_PROPERTIES,
fetchHTMLBodyValues: true,
fetchTextBodyValues: true,
// 본문 한 부분을 이 바이트까지 읽는다 — 넘는 부분은 엔진이 잘라서 준다.
// 본문 한 부분을 이 바이트까지 읽는다 — 넘는 부분은 엔진이 잘라서 주고 그 부분에 isTruncated 를 켠다 (매퍼가 읽는다).
// 우리가 보낸 서식 본문은 발송 때 정화 결과를 같은 바이트 수로 막았으므로 잘리지 않는다 (mail.constants.ts)
maxBodyValueBytes: MAX_BODY_VALUE_BYTES,
},
+20 -5
View File
@@ -204,20 +204,31 @@ function toAttachment(
};
}
/** 이어 붙인 본문 — 엔진이 그중 한 부분이라도 잘라서 줬으면 isTruncated */
interface JoinedBody {
readonly value: string;
readonly isTruncated: boolean;
}
/**
* 지정한 형식의 본문 부분 값을 이어 붙인다. 그 형식 부분이 없으면 null.
* (JMAP 의 htmlBody 는 HTML 이 없으면 text/plain 부분으로 채워지므로 형식을 확인해야 한다)
* 잘림은 부분마다 온다(RFC 8621 EmailBodyValue.isTruncated — maxBodyValueBytes 를 넘은 부분) — 하나라도 잘렸으면 잘린 본문이다.
*/
function joinBodyValues(
email: JmapEmail,
parts: JmapBodyPart[] | undefined,
type: string,
): string | null {
): JoinedBody | null {
const values = (parts ?? [])
.filter((part) => partType(part) === type && part.partId)
.map((part) => email.bodyValues?.[part.partId as string]?.value ?? '');
.map((part) => email.bodyValues?.[part.partId as string]);
if (values.length === 0) return null;
return values.length > 0 ? values.join('\n') : null;
return {
value: values.map((bodyValue) => bodyValue?.value ?? '').join('\n'),
isTruncated: values.some((bodyValue) => bodyValue?.isTruncated === true),
};
}
export function toDetailSource(email: JmapEmail): MessageDetailSource {
@@ -230,6 +241,8 @@ export function toDetailSource(email: JmapEmail): MessageDetailSource {
type: partType(part),
size: part.size ?? 0,
}));
const html = joinBodyValues(email, email.htmlBody, 'text/html');
const text = joinBodyValues(email, email.textBody, 'text/plain');
return {
...toSummary(email),
@@ -239,8 +252,10 @@ export function toDetailSource(email: JmapEmail): MessageDetailSource {
sentAt: toUtcIso(email.sentAt),
messageId: email.messageId?.[0] ?? null,
references: email.references ?? [],
rawHtmlBody: joinBodyValues(email, email.htmlBody, 'text/html'),
textBody: joinBodyValues(email, email.textBody, 'text/plain'),
rawHtmlBody: html?.value ?? null,
isRawHtmlBodyTruncated: html?.isTruncated ?? false,
textBody: text?.value ?? null,
isTextBodyTruncated: text?.isTruncated ?? false,
attachments: attachments.map(toAttachment),
inlineParts,
};
+8 -5
View File
@@ -14,16 +14,19 @@ export const MAX_RECIPIENTS = 100;
/** 제목 최대 길이 */
export const MAX_SUBJECT_LENGTH = 255;
/** 본문(일반 텍스트) 최대 길이 — 서식 본문이 있으면 그 대체 본문이다 */
/**
* 본문(일반 텍스트) 최대 길이 — 서식 본문이 있으면 그 대체 본문이다.
* JS 문자열 길이(UTF-16 코드 단위)로 세고, 넘으면 400 MAIL_007 (mail.service.ts — 웹 클라이언트도 같은 방법으로 센다)
*/
export const MAX_BODY_LENGTH = 100_000;
/** 서식 본문(bodyHtml) 최대 길이 — 서식 편집기가 만든 HTML (계약 2절) */
/** 서식 본문(bodyHtml) 최대 길이 — 서식 편집기가 만든 HTML (계약 2절). 세는 방법·실패 코드는 MAX_BODY_LENGTH 와 같다 */
export const MAX_BODY_HTML_LENGTH = 500_000;
/**
* 본문 한 부분을 읽는 상한 (UTF-8 바이트) — 상세 조회는 이 크기까지 읽고, 넘는 부분은 엔진이 잘라서 준다.
* 보내는 서식 본문은 **정화한 결과**가 이 크기를 넘으면 보내지 않는다(VAL_001) — 우리 웹메일이 다시 읽을 때
* (보낸편지함·받는 사람) 표시 없이 잘리지 않게. 500,000자 한도만으로는 부족하다: 정화가 `&` 를 `&amp;` 로 늘리고
* 본문 한 부분을 읽는 상한 (UTF-8 바이트) — 상세 조회는 이 크기까지 읽고, 넘는 부분은 엔진이 잘라서 준다(그때 isBodyTruncated).
* 보내는 서식 본문은 **정화한 결과**가 이 크기를 넘으면 보내지 않는다(MAIL_007) — 우리 웹메일이 다시 읽을 때
* (보낸편지함·받는 사람) 잘리지 않게. 500,000자 한도만으로는 부족하다: 정화가 `&` 를 `&amp;` 로 늘리고
* 한글·이모지는 글자당 3~4바이트라 같은 글자 수가 2MB 를 넘을 수 있다.
*/
export const MAX_BODY_VALUE_BYTES = 2_000_000;
+271 -28
View File
@@ -1,9 +1,14 @@
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 { MAX_HTML_PARSE_LENGTH } from './html/html-parse-budget';
import { MAX_BODY_VALUE_BYTES } from './mail.constants';
import {
MAX_BODY_HTML_LENGTH,
MAX_BODY_LENGTH,
MAX_BODY_VALUE_BYTES,
} from './mail.constants';
import { MailService } from './mail.service';
import type { MailProvider } from './mail.provider';
import type { MailSettings } from './mail.settings';
@@ -71,6 +76,20 @@ async function codeOf(
return undefined;
}
/** 실패의 코드와 로그에 남는 사유(context.reason) */
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 {};
}
const source = (
overrides: Partial<MessageDetailSource> = {},
): MessageDetailSource => ({
@@ -93,12 +112,17 @@ const source = (
messageId: 'abc@x',
references: [],
rawHtmlBody: null,
isRawHtmlBodyTruncated: false,
textBody: '본문',
isTextBodyTruncated: false,
attachments: [],
inlineParts: [],
...overrides,
});
/** 정화 비용 한도를 넘는 HTML — 500,000자 안의 '<b>' 16만 개 (정화하면 9초 가까이 이벤트 루프가 멈춘다) */
const TOO_DEEP_HTML = '<b>'.repeat(166_666);
const sendDto = (overrides: Partial<SendMessageDto> = {}): SendMessageDto => ({
to: [{ name: '이도윤', email: 'doyun.lee@arasystem.test' }],
subject: '안녕하세요',
@@ -235,9 +259,18 @@ describe('MailService', () => {
htmlBody: null,
textBody: '본문',
hasBlockedImages: false,
isHtmlBodyOmitted: false,
isBodyTruncated: false,
});
expect(detail).not.toHaveProperty('rawHtmlBody');
expect(detail).not.toHaveProperty('inlineParts');
// 어댑터의 정화 전 값·내부 표시는 내보내지 않는다
for (const internal of [
'rawHtmlBody',
'inlineParts',
'isRawHtmlBodyTruncated',
'isTextBodyTruncated',
]) {
expect(detail).not.toHaveProperty(internal);
}
});
it('HTML 은 정화해서 내려준다 (원본을 그대로 내보내지 않는다)', async () => {
@@ -335,15 +368,17 @@ describe('MailService', () => {
source({ rawHtmlBody, textBody: '텍스트 본문', inlineParts: [logo] }),
);
const started = performance.now();
const cpuMs = startCpuTimer();
const detail = await service.getMessage(user, 'm1');
expect(detail).toMatchObject({
htmlBody: null,
textBody: '텍스트 본문',
hasBlockedImages: false,
isHtmlBodyOmitted: true,
});
expect(performance.now() - started).toBeLessThan(2_000);
// CPU 시간 (test/cpu-time.ts) — 지금 0.1초 안쪽 (검사만 하고 정화하지 않는다). 정화했다면 2MB 중첩에 172초
expect(cpuMs()).toBeLessThan(2_000);
}
expect(provider.readAttachmentBytes).not.toHaveBeenCalled();
@@ -357,6 +392,121 @@ describe('MailService', () => {
expect(message).not.toContain(user.email);
}
});
it.each([
{
name: 'HTML 이 없다',
rawHtmlBody: null,
textBody: '본문',
omitted: false,
},
{
name: 'HTML 을 정화해서 보여 준다',
rawHtmlBody: '<p>본문</p>',
textBody: '본문',
omitted: false,
},
{
name: 'HTML 이 정화 한도를 넘었다',
rawHtmlBody: TOO_DEEP_HTML,
textBody: '본문',
omitted: true,
},
{
name: 'HTML 만 있는 메일이 정화 한도를 넘었다 (보여 줄 본문이 없다)',
rawHtmlBody: TOO_DEEP_HTML,
textBody: null,
omitted: true,
},
])(
'isHtmlBodyOmitted 는 HTML 이 있었지만 정화 한도로 비웠을 때만 true — $name',
async ({ rawHtmlBody, textBody, omitted }) => {
provider.getMessage.mockResolvedValue(
source({ rawHtmlBody, textBody }),
);
const detail = await service.getMessage(user, 'm1');
expect(detail.isHtmlBodyOmitted).toBe(omitted);
expect(detail.textBody).toBe(textBody);
if (omitted) expect(detail.htmlBody).toBeNull();
},
);
it.each([
{
name: 'HTML 이 잘렸고 HTML 을 보여 준다',
rawHtmlBody: '<p>앞부분',
html: true,
text: false,
truncated: true,
},
{
name: 'HTML 을 정화 한도로 비웠고 텍스트가 잘렸다',
rawHtmlBody: TOO_DEEP_HTML,
html: false,
text: true,
truncated: true,
},
{
name: 'HTML 을 보여 주고 텍스트만 잘렸다',
rawHtmlBody: '<p>본문</p>',
html: false,
text: true,
truncated: false,
},
{
name: 'HTML 이 없고 텍스트가 잘렸다',
rawHtmlBody: null,
html: false,
text: true,
truncated: true,
},
{
name: 'HTML 을 정화 한도로 비웠고 HTML 만 잘렸다',
rawHtmlBody: TOO_DEEP_HTML,
html: true,
text: false,
truncated: false,
},
// 정화해서 빈 htmlBody 는 클라이언트가 그리지 않고 textBody 를 보여 준다 (웹 buildMailDocument)
{
name: '정화한 HTML 이 비었고 텍스트가 잘렸다',
rawHtmlBody: '<script>x()</script>',
html: false,
text: true,
truncated: true,
},
{
name: '정화한 HTML 이 비었고 HTML 만 잘렸다',
rawHtmlBody: '<script>x()</script>',
html: true,
text: false,
truncated: false,
},
{
name: '잘린 부분이 없다',
rawHtmlBody: '<p>본문</p>',
html: false,
text: false,
truncated: false,
},
])(
'isBodyTruncated 는 보여 줄 본문의 잘림을 따른다 — $name',
async ({ rawHtmlBody, html, text, truncated }) => {
provider.getMessage.mockResolvedValue(
source({
rawHtmlBody,
isRawHtmlBodyTruncated: html,
isTextBodyTruncated: text,
}),
);
await expect(service.getMessage(user, 'm1')).resolves.toMatchObject({
isBodyTruncated: truncated,
});
},
);
});
it('PATCH 는 하나 이상 지정해야 한다', async () => {
@@ -455,26 +605,108 @@ describe('MailService', () => {
}
});
it('서식 본문이 너무 깊게 중첩되면 정화하지 않고 400 VAL_001 — 엔진·첨부를 찾기 전에 (키가 있어도)', async () => {
// 500,000자 안의 '<b>' 16만 개 — 정화하면 9초 가까이 이벤트 루프가 멈춘다
const bodyHtml = '<b>'.repeat(166_666);
it('본문이 너무 길면 400 MAIL_007 — 엔진·첨부·중복 방지 잠금 전에 (키가 있어도), 로그 사유는 경우마다 다르다', async () => {
const attachments = [{ id: 'blob-1', name: '체크리스트.xlsx' }];
const cases: [Partial<SendMessageDto>, string][] = [
[{ body: 'x'.repeat(MAX_BODY_LENGTH + 1) }, 'body-too-long'],
[
{ bodyHtml: 'x'.repeat(MAX_BODY_HTML_LENGTH + 1) },
'body-html-too-long',
],
[{ bodyHtml: TOO_DEEP_HTML }, 'body-html-too-complex'],
// '&' 는 정화하면 '&amp;'(5바이트) — 400,001개면 한도를 5바이트 넘는다
[{ bodyHtml: '&'.repeat(400_001) }, 'body-html-too-large'],
];
for (const key of [undefined, IDEMPOTENCY_KEY]) {
const started = performance.now();
expect(
await codeOf(() =>
service.sendMessage(user, sendDto({ bodyHtml, attachments }), key),
),
).toBe('VAL_001');
expect(performance.now() - started).toBeLessThan(2_000);
for (const [overrides, reason] of cases) {
for (const key of [undefined, IDEMPOTENCY_KEY]) {
expect(
await failureOf(() =>
service.sendMessage(
user,
sendDto({ ...overrides, attachments }),
key,
),
),
).toEqual({ code: 'MAIL_007', reason });
}
}
expect(provider.findSentMessage).not.toHaveBeenCalled();
expect(provider.sendMessage).not.toHaveBeenCalled();
});
it('정화한 서식 본문이 UTF-8 2,000,000바이트를 넘으면 400 VAL_001 — 다시 읽을 때 잘리지 않게 (넘지 않으면 보낸다)', async () => {
// '&' 는 정화하면 '&amp;'(5바이트) — 400,000개면 정확히 한도
it('깊게 중첩된 서식 본문은 정화하지 않고 바로 막는다 (정화하면 9초 가까이 걸린다)', async () => {
// CPU 시간 (test/cpu-time.ts) — 지금 수십 ms (비용 검사가 멈춘다). 정화했다면 '<b>' 16만 개에 8.8초.
// 정화까지 하는 다른 한도(정화 뒤 바이트)는 시간을 보지 않는다 — 막는지만 본다 (위 테스트)
const cpuMs = startCpuTimer();
expect(
await failureOf(() =>
service.sendMessage(user, sendDto({ bodyHtml: TOO_DEEP_HTML })),
),
).toEqual({ code: 'MAIL_007', reason: 'body-html-too-complex' });
expect(cpuMs()).toBeLessThan(2_000);
});
it('길이는 JS 문자열 길이(UTF-16 코드 단위)로 센다 — 웹 클라이언트와 같은 수 (이모지·표시 선택자가 붙은 글자는 2자)', async () => {
// 한도에 딱 맞으면 보낸다
await service.sendMessage(
user,
sendDto({ body: '😀'.repeat(MAX_BODY_LENGTH / 2) }),
);
await service.sendMessage(
user,
sendDto({ bodyHtml: '❤️'.repeat(MAX_BODY_HTML_LENGTH / 2) }),
);
expect(provider.sendMessage).toHaveBeenCalledTimes(2);
// 한 자만 넘어도 막는다 — 형식 검증(class-validator MaxLength)은 이것을 절반으로 세어 통과시켰다
expect(
await failureOf(() =>
service.sendMessage(
user,
sendDto({ body: `${'😀'.repeat(MAX_BODY_LENGTH / 2)}x` }),
),
),
).toEqual({ code: 'MAIL_007', reason: 'body-too-long' });
expect(
await failureOf(() =>
service.sendMessage(
user,
sendDto({ bodyHtml: `${'❤️'.repeat(MAX_BODY_HTML_LENGTH / 2)}x` }),
),
),
).toEqual({ code: 'MAIL_007', reason: 'body-html-too-long' });
expect(provider.sendMessage).toHaveBeenCalledTimes(2);
});
it('본문 한도는 텍스트 길이를 먼저 보고, 서식 본문은 길이를 정화 전 비용보다 먼저 본다 (비용은 정화 전 — 위 시간 검사)', async () => {
// 둘 다 넘으면 텍스트 길이가 먼저
expect(
await failureOf(() =>
service.sendMessage(
user,
sendDto({
body: 'x'.repeat(MAX_BODY_LENGTH + 1),
bodyHtml: 'x'.repeat(MAX_BODY_HTML_LENGTH + 1),
}),
),
),
).toEqual({ code: 'MAIL_007', reason: 'body-too-long' });
// 길이를 넘는 깊은 중첩은 비용을 재기 전에 길이로 막는다
expect(
await failureOf(() =>
service.sendMessage(
user,
sendDto({
bodyHtml: '<b>'.repeat(Math.ceil(MAX_BODY_HTML_LENGTH / 3)),
}),
),
),
).toEqual({ code: 'MAIL_007', reason: 'body-html-too-long' });
});
it('정화한 서식 본문이 UTF-8 2,000,000바이트 이하면 보낸다 — 500,000자 안이어도 한글과 & 가 섞이면 넘는다', async () => {
// '&' 400,000개는 정화하면 정확히 2,000,000바이트
await service.sendMessage(
user,
sendDto({ bodyHtml: '&'.repeat(400_000) }),
@@ -484,17 +716,16 @@ describe('MailService', () => {
MAX_BODY_VALUE_BYTES,
);
for (const bodyHtml of [
'&'.repeat(400_001),
// 글자 + 이모지 표시 선택자(VS16) — 형식 검증은 1자로 세지만(500,000자 안) UTF-8 로 6바이트씩 3MB
'❤️'.repeat(500_000),
]) {
expect(
await codeOf(() =>
service.sendMessage(user, sendDto({ bodyHtml }), IDEMPOTENCY_KEY),
// '가&&' — 3자가 정화하면 13바이트 (한글 3 + '&amp;' 5 × 2): 499,998자로 2,166,658바이트
expect(
await failureOf(() =>
service.sendMessage(
user,
sendDto({ bodyHtml: '가&&'.repeat(166_666) }),
IDEMPOTENCY_KEY,
),
).toBe('VAL_001');
}
),
).toEqual({ code: 'MAIL_007', reason: 'body-html-too-large' });
expect(provider.sendMessage).toHaveBeenCalledTimes(1);
expect(provider.findSentMessage).not.toHaveBeenCalled();
});
@@ -615,6 +846,18 @@ describe('MailService', () => {
expect(provider.sendMessage).not.toHaveBeenCalled();
});
it('본문이 너무 길어 막힌 뒤(MAIL_007)에는 고친 본문을 같은 키로 곧바로 보낼 수 있다 — 키를 잠그거나 기록하지 않는다', async () => {
expect(
await codeOf(() =>
send(sendDto({ body: 'x'.repeat(MAX_BODY_LENGTH + 1) })),
),
).toBe('MAIL_007');
await expect(send()).resolves.toEqual({ id: 'sent-1' });
expect(provider.findSentMessage).toHaveBeenCalledTimes(1);
expect(provider.sendMessage).toHaveBeenCalledTimes(1);
});
it('처음 보내는 키면 (사용자, 키)로 정한 Message-ID 와 내용 지문을 실어 보낸다', async () => {
await expect(send()).resolves.toEqual({ id: 'sent-1' });
+60 -16
View File
@@ -27,7 +27,12 @@ import {
import { checkHtmlParseBudget } from './html/html-parse-budget';
import { sanitizeMailHtml } from './html/sanitize-mail-html';
import { sanitizeOutgoingHtml } from './html/sanitize-outgoing-html';
import { MAX_BODY_VALUE_BYTES, MAX_RECIPIENTS } from './mail.constants';
import {
MAX_BODY_HTML_LENGTH,
MAX_BODY_LENGTH,
MAX_BODY_VALUE_BYTES,
MAX_RECIPIENTS,
} from './mail.constants';
import { MAIL_PROVIDER, type MailProvider } from './mail.provider';
import { MAIL_SETTINGS, type MailSettings } from './mail.settings';
import type {
@@ -67,14 +72,36 @@ function toAddress(recipient: RecipientDto): MailAddress {
}
/**
* 서식 본문 정화 — 앞뒤로 한도를 본다. 넘으면 400 VAL_001 (엔진·첨부 조회 전이다).
* - 정화 전: 깊게 중첩된 HTML 은 정화 자체가 이벤트 루프를 멈춘다 (html/html-parse-budget.ts)
* - 정화 뒤: 결과가 본문 읽기 한도(바이트)를 넘으면 보낸편지함·받는 사람 화면에서 표시 없이 잘린다 (mail.constants.ts)
* 발송 본문의 한도 — 넘으면 400 MAIL_007 (계약 2절). 엔진·첨부 조회와 중복 방지 잠금 **전**이라 메일은 나가지 않는다.
* 검사 순서: 텍스트 길이 → 서식 본문 길이 → 정화 전 비용 → 정화 → 정화 뒤 바이트.
* 길이는 JS 문자열 길이(UTF-16 코드 단위)로 센다 — 웹 클라이언트가 보내기 전에 세는 방법과 같다
* (class-validator 의 MaxLength 는 서로게이트 쌍·이모지 표시 선택자를 다르게 세서 클라이언트와 어긋났다).
* @returns 정화한 서식 본문 — 서식 본문이 없거나 정화해서 비면 빈 문자열(텍스트 메일)
*/
function prepareBody(body: string, bodyHtml: string | undefined): string {
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 {
if (bodyHtml.length > MAX_BODY_HTML_LENGTH) {
throw new BusinessException('MAIL_007', {
context: { reason: 'body-html-too-long', length: bodyHtml.length },
});
}
const budget = checkHtmlParseBudget(bodyHtml);
if (!budget.ok) {
throw new BusinessException('VAL_001', {
throw new BusinessException('MAIL_007', {
context: {
reason: 'body-html-too-complex',
detail: budget.reason,
@@ -86,7 +113,7 @@ function sanitizeBodyHtml(bodyHtml: string): string {
const html = sanitizeOutgoingHtml(bodyHtml);
const bytes = Buffer.byteLength(html, 'utf8');
if (bytes > MAX_BODY_VALUE_BYTES) {
throw new BusinessException('VAL_001', {
throw new BusinessException('MAIL_007', {
context: { reason: 'body-html-too-large', bytes },
});
}
@@ -165,18 +192,32 @@ export class MailService {
/**
* 상세 — 조회만 한다(읽음 처리 없음). HTML 은 정화한 뒤에만 내보낸다.
* 정화가 이벤트 루프를 오래 붙잡을 HTML(너무 길거나 깊게 중첩된 것)은 정화하지 않고 htmlBody 를 비운다 —
* 클라이언트는 textBody 를 보여 준다 (html/html-parse-budget.ts).
* 그때 isHtmlBodyOmitted 를 켜고, 클라이언트는 textBody 를 보여 준다 (html/html-parse-budget.ts).
* isBodyTruncated 는 클라이언트가 보여 줄 본문의 잘림을 따른다 — htmlBody 가 null 이거나 공백뿐이면 텍스트, 아니면 HTML
* (웹의 buildMailDocument 가 htmlBody 를 trim 해 비면 textBody 를 그린다).
*/
async getMessage(
user: AuthenticatedUser,
id: string,
): Promise<MessageDetail> {
const { rawHtmlBody, inlineParts, ...detail } =
await this.provider.getMessage(user.email, id);
const {
rawHtmlBody,
isRawHtmlBodyTruncated,
isTextBodyTruncated,
inlineParts,
...detail
} = await this.provider.getMessage(user.email, id);
if (rawHtmlBody === null) {
return { ...detail, htmlBody: null, hasBlockedImages: false };
}
/** HTML 없이 텍스트 본문을 보여 줄 때 (HTML 이 없거나, 있었지만 정화 한도를 넘어 비웠을 때) */
const withoutHtml = (isHtmlBodyOmitted: boolean): MessageDetail => ({
...detail,
htmlBody: null,
hasBlockedImages: false,
isHtmlBodyOmitted,
isBodyTruncated: isTextBodyTruncated,
});
if (rawHtmlBody === null) return withoutHtml(false);
// 내장 이미지를 읽기 전에 본다 — 보여 주지 않을 본문의 이미지를 엔진에서 받아 오지 않게
const budget = checkHtmlParseBudget(rawHtmlBody);
@@ -185,7 +226,7 @@ export class MailService {
this.logger.warn(
`${traceTag()} 받은 메일 HTML 이 정화 한도를 넘어 텍스트 본문만 내려줍니다: user=${maskEmail(user.email)}, id=${id}, 사유=${budget.reason}, 길이=${rawHtmlBody.length}자`,
);
return { ...detail, htmlBody: null, hasBlockedImages: false };
return withoutHtml(true);
}
const inlineImages = await this.loadInlineImages(
@@ -194,10 +235,13 @@ export class MailService {
inlineParts,
);
const sanitized = sanitizeMailHtml(rawHtmlBody, inlineImages);
const showsHtml = sanitized.html.trim() !== '';
return {
...detail,
htmlBody: sanitized.html,
hasBlockedImages: sanitized.hasBlockedImages,
isHtmlBodyOmitted: false,
isBodyTruncated: showsHtml ? isRawHtmlBodyTruncated : isTextBodyTruncated,
};
}
@@ -314,7 +358,7 @@ export class MailService {
}
}
/** 엔진과 무관한 발송 규칙 — 받는 사람 수(MAIL_002), 첨부 개수(MAIL_006)·형식(FILE_001), 헤더 값 줄바꿈, 서식 본문 정화(한도 VAL_001) */
/** 엔진과 무관한 발송 규칙 — 받는 사람 수(MAIL_002), 첨부 개수(MAIL_006)·형식(FILE_001), 헤더 값 줄바꿈, 본문 한도(MAIL_007)·서식 본문 정화 */
private prepareOutgoing(dto: SendMessageDto): OutgoingMessage {
const to = dto.to.map(toAddress);
const cc = (dto.cc ?? []).map(toAddress);
@@ -347,8 +391,8 @@ export class MailService {
return { id: attachment.id, name, type };
});
// 서식 본문 — 편집기가 만드는 서식만 남긴다 (보내는 메일 전용 허용 목록). 정화해서 비면 텍스트 메일로 보낸다
const htmlBody = dto.bodyHtml ? sanitizeBodyHtml(dto.bodyHtml) : '';
// 본문 한도를 보고, 서식 본문은 편집기가 만드는 서식만 남긴다 (보내는 메일 전용 허용 목록). 정화해서 비면 텍스트 메일로 보낸다
const htmlBody = prepareBody(dto.body, dto.bodyHtml);
return {
to,
+17 -1
View File
@@ -68,6 +68,13 @@ export interface MessageDetail extends MessageSummary {
readonly textBody: string | null;
/** 외부 이미지를 지웠으면 true */
readonly hasBlockedImages: boolean;
/** HTML 이 있었지만 정화 전 사전 검사(html/html-parse-budget.ts)를 넘어 htmlBody 를 비웠으면 true */
readonly isHtmlBodyOmitted: boolean;
/**
* 보여 줄 본문이 본문 읽기 한도(MAX_BODY_VALUE_BYTES)에서 잘렸으면 true.
* 보여 줄 본문 = htmlBody, 그것이 null 이거나 공백뿐이면 textBody (웹 클라이언트의 buildMailDocument 와 같은 규칙)
*/
readonly isBodyTruncated: boolean;
readonly attachments: MailAttachment[];
}
@@ -83,13 +90,22 @@ export interface InlinePart {
/**
* 어댑터가 돌려주는 메일 상세 원본.
* HTML 은 **정화 전** 값이다 — 서비스 계층이 정화한 뒤에만 MessageDetail 로 내보낸다.
* 잘림 표시는 HTML·텍스트 따로 준다 — 어느 쪽을 보여 줄지는 정화 결과에 달려 있어 서비스 계층이 정한다.
*/
export interface MessageDetailSource extends Omit<
MessageDetail,
'htmlBody' | 'textBody' | 'hasBlockedImages'
| 'htmlBody'
| 'textBody'
| 'hasBlockedImages'
| 'isHtmlBodyOmitted'
| 'isBodyTruncated'
> {
readonly rawHtmlBody: string | null;
/** HTML 부분 중 하나라도 엔진이 본문 읽기 한도에서 잘랐으면 true (HTML 이 없으면 false) */
readonly isRawHtmlBodyTruncated: boolean;
readonly textBody: string | null;
/** 텍스트 부분 중 하나라도 엔진이 본문 읽기 한도에서 잘랐으면 true (텍스트가 없으면 false) */
readonly isTextBodyTruncated: boolean;
readonly inlineParts: InlinePart[];
}
+18 -5
View File
@@ -44,7 +44,13 @@ import {
SentMessageDto,
} from './dto/mail-response.dto';
import { MAX_ATTACHMENTS_PER_MESSAGE } from './attachment.policy';
import { MAX_BODY_VALUE_BYTES, MESSAGES_ROUTE } from './mail.constants';
import {
MAX_BODY_HTML_LENGTH,
MAX_BODY_LENGTH,
MAX_BODY_VALUE_BYTES,
MAX_SUBJECT_LENGTH,
MESSAGES_ROUTE,
} from './mail.constants';
import { MessageListQueryDto } from './dto/message-list-query.dto';
import { SendMessageDto } from './dto/send-message.dto';
import { UpdateMessageDto } from './dto/update-message.dto';
@@ -93,7 +99,8 @@ export class MessageController {
summary: '메일 상세 — 조회만으로 읽음 처리하지 않는다',
description:
'htmlBody 는 서버가 정화한 HTML 이다. 클라이언트는 격리된 iframe 에만 그린다. ' +
'정화 한도(너무 길거나 깊게 중첩된 HTML)를 넘으면 htmlBody 는 null 이다 — textBody 를 보여 준다.',
'정화 한도(너무 길거나 깊게 중첩된 HTML)를 넘으면 htmlBody 는 null, isHtmlBodyOmitted 는 true 다 — textBody 를 보여 준다. ' +
`보여 줄 본문(htmlBody — null 이거나 공백뿐이면 textBody)이 읽기 한도(본문 한 부분 UTF-8 ${MAX_BODY_VALUE_BYTES.toLocaleString('ko-KR')}바이트)에서 잘렸으면 isBodyTruncated 가 true 다.`,
})
@ApiStandardResponse(MessageDetailDto, { description: '메일 상세' })
@ApiErrorResponse('RES_001', '없는 메일')
@@ -143,8 +150,10 @@ export class MessageController {
description:
'Idempotency-Key 가 같으면 한 번만 보낸다 — 이미 보냈으면 그 결과(201 { id })를 그대로 돌려준다. ' +
'같은 키인데 내용이 다르면 409 MAIL_005, 같은 키가 처리 중이면 429 REQ_001. ' +
'서식 본문(bodyHtml)이 있으면 정화한 뒤 body(텍스트)와 함께 multipart/alternative 로 보낸다 — ' +
`너무 깊게 중첩됐거나(정화 전 검사) 정화한 결과가 UTF-8 ${MAX_BODY_VALUE_BYTES.toLocaleString('ko-KR')}바이트를 넘으면 400 VAL_001 (엔진에 묻기 전). ` +
'서식 본문(bodyHtml)이 있으면 정화한 뒤 body(텍스트)와 함께 multipart/alternative 로 보낸다. ' +
`본문이 너무 길면 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).',
})
@ApiHeader({
@@ -160,7 +169,11 @@ export class MessageController {
})
@ApiErrorResponse(
'MAIL_002',
`MAIL_002 받는 사람 주소 · MAIL_003 메일함 용량 · MAIL_004 첨부를 찾을 수 없음(만료된 업로드는 다시 올리고, 지워진 원본 첨부는 뺀다) · MAIL_006 첨부 ${MAX_ATTACHMENTS_PER_MESSAGE}개 초과 · VAL_001 형식·Idempotency-Key 형식·서식 본문 한도(중첩·정화 뒤 크기) · FILE_001 첨부 형식 (모두 400)`,
`MAIL_002 받는 사람 주소 · MAIL_003 메일함 용량 · MAIL_004 첨부를 찾을 수 없음(만료된 업로드는 다시 올리고, 지워진 원본 첨부는 뺀다) · MAIL_006 첨부 ${MAX_ATTACHMENTS_PER_MESSAGE}개 초과 · VAL_001 형식(제목 ${MAX_SUBJECT_LENGTH}자 초과 포함)·Idempotency-Key 형식 · FILE_001 첨부 형식 (모두 400)`,
)
@ApiErrorResponse(
'MAIL_007',
`MAIL_007 본문이 너무 김 (400) — body ${MAX_BODY_LENGTH.toLocaleString('ko-KR')}자·bodyHtml ${MAX_BODY_HTML_LENGTH.toLocaleString('ko-KR')}자 초과(JS 문자열 길이), bodyHtml 의 정화 전 비용·정화 뒤 UTF-8 ${MAX_BODY_VALUE_BYTES.toLocaleString('ko-KR')}바이트 초과. 메일은 나가지 않았다`,
)
@ApiErrorResponse(
'MAIL_005',
+157 -17
View File
@@ -3,6 +3,7 @@ import { JwtService } from '@nestjs/jwt';
import { Test, TestingModule } from '@nestjs/testing';
import { createHash } from 'crypto';
import { existsSync, unlinkSync } from 'fs';
import type { Server } from 'http';
import { basename, join } from 'path';
import request from 'supertest';
import type { Response } from 'supertest';
@@ -12,6 +13,11 @@ import { DataSource } from 'typeorm';
import { AppModule } from '@/app.module';
import { configureApp } from '@/app.setup';
import { AUTH_SETTINGS, type AuthSettings } from '@/modules/auth/auth.settings';
import {
MAX_BODY_HTML_LENGTH,
MAX_BODY_LENGTH,
MAX_SUBJECT_LENGTH,
} from '@/modules/mail/mail.constants';
import { MAIL_PROVIDER } from '@/modules/mail/mail.provider';
import { MAIL_SETTINGS, type MailSettings } from '@/modules/mail/mail.settings';
import { UPLOAD_DIR } from '@/modules/upload/upload.config';
@@ -69,9 +75,50 @@ const CORRECT_PASSWORD = 'correct-password-for-e2e';
/** 메일 엔진 관리자 주소 — 로그인할 수 없는 계정 (mail.settings.ts 의 blockedAccounts) */
const ADMIN_ACCOUNT = 'admin@e2e.arasystem.test';
/** 대역 메일 엔진이 일부러 늦게 답하는 주소 — 동시 로그인 시도 확인용 */
/** 대역 메일 엔진이 테스트가 문을 열 때까지 답하지 않는 주소 — 동시 로그인 시도 확인용 ("로그인 실패 한도") */
const SLOW_ACCOUNT = 'slow.target@e2e.arasystem.test';
const SLOW_ENGINE_MS = 500;
/** 문을 열 조건을 다시 보는 간격 */
const GATE_POLL_MS = 10;
/** 문을 열 조건을 기다리는 최대 시간 — 회귀가 생기면 멈춰 있지 않고 이 시간 뒤에 분명히 실패한다 */
const GATE_DEADLINE_MS = 10_000;
/**
* 문을 쓰는 테스트 하나의 시간 제한 — GATE_DEADLINE_MS 보다 넉넉히 길어야 기한이 먼저 걸린다.
* test/jest-e2e.json 에 testTimeout 이 없어 jest 기본값(5초)이라, 이 테스트만 따로 늘린다.
*/
const GATED_TEST_TIMEOUT_MS = 20_000;
/** 열 때까지 기다리게 하는 문 — 한 번 열면 기다리던 쪽이 모두 풀린다 */
function createGate(): { readonly opened: Promise<void>; open: () => void } {
let open!: () => void;
const opened = new Promise<void>((resolve) => {
open = resolve;
});
return { opened, open };
}
/** 조건이 맞거나 기한이 지날 때까지 기다린다 — 맞았는지는 뒤의 단언이 본다 */
async function waitUntil(
condition: () => boolean,
deadlineMs: number,
): Promise<void> {
const deadline = Date.now() + deadlineMs;
while (!condition() && Date.now() < deadline) {
await new Promise((resolve) => setTimeout(resolve, GATE_POLL_MS));
}
}
/** 서버를 빈 포트로 연다 */
function listenOnAnyPort(server: Server): Promise<void> {
return new Promise((resolve) => server.listen(0, () => resolve()));
}
/** 서버를 닫는다 — 열린 연결이 모두 끝나야 끝난다 */
function closeServer(server: Server): Promise<void> {
return new Promise((resolve, reject) =>
server.close((error) => (error ? reject(error) : resolve())),
);
}
/**
* 이 테스트가 만드는 세션의 주소 — 끝나면 지운다 (개발 DB 에 남기지 않는다).
@@ -96,8 +143,13 @@ describe('응답 계약 (e2e)', () => {
let jwt: JwtService;
let authSettings: AuthSettings;
let dataSource: DataSource;
/** 대역 엔진이 비밀번호를 확인한 횟수 (주소별) */
/** 대역 엔진이 비밀번호를 확인한 횟수 (주소별) — 확인이 엔진에 도착한 때 는다 */
const engineChecks = new Map<string, number>();
/**
* 대역 엔진의 SLOW_ACCOUNT 확인이 기다리는 문 — 테스트가 만들고 연다. null 이면 바로 답한다.
* 열기 전에 온 확인은 모두 같은 문을 기다린다 (회귀로 여러 번 와도 한 번 열면 모두 풀린다).
*/
let slowEngineGate: Promise<void> | null = null;
/** 이 앱의 서명 키로 토큰을 만든다 (CI 처럼 JWT_SECRET 이 없으면 임시 키) */
const sign = (payload: object, expiresIn: number) =>
@@ -139,9 +191,8 @@ describe('응답 계약 (e2e)', () => {
Promise.resolve({ email, name: EMPLOYEE.name }),
verifyCredentials: async (email: string, password: string) => {
engineChecks.set(email, (engineChecks.get(email) ?? 0) + 1);
if (email === SLOW_ACCOUNT) {
await new Promise((resolve) => setTimeout(resolve, SLOW_ENGINE_MS));
}
// 테스트가 문을 열 때까지 답하지 않는다 — 그동안 같은 주소의 다른 시도가 어떻게 되는지 본다
if (email === SLOW_ACCOUNT && slowEngineGate) await slowEngineGate;
return password === CORRECT_PASSWORD ? email : null;
},
})
@@ -537,6 +588,39 @@ describe('응답 계약 (e2e)', () => {
const stored = join(UPLOAD_DIR, basename(saved?.fileName ?? ''));
if (saved?.fileName && existsSync(stored)) unlinkSync(stored);
});
it('발송 본문이 너무 길면 400 MAIL_007 — 형식 검증이 아니라 서비스가 JS 문자열 길이(UTF-16)로 센다, 제목은 그대로 VAL_001', async () => {
const accessToken = sign(
{ sub: EMPLOYEE.email, typ: 'access', name: EMPLOYEE.name },
600,
);
// 대역 엔진에는 발송이 없다 — 엔진에 닿으면 500 이 된다 (한도는 엔진에 묻기 전에 본다)
const send = (overrides: object) =>
request(server)
.post('/api/messages')
.set('Cookie', `access_token=${accessToken}`)
.send({
to: [{ name: null, email: 'doyun.lee@e2e.arasystem.test' }],
subject: '제목',
body: '본문',
...overrides,
});
for (const overrides of [
// 이모지 50,001개 = 100,002자 — 예전 형식 검증(MaxLength)은 50,001자로 세어 통과시켰다
{ body: '😀'.repeat(MAX_BODY_LENGTH / 2 + 1) },
{ bodyHtml: 'x'.repeat(MAX_BODY_HTML_LENGTH + 1) },
]) {
const res = await send(overrides).expect(400);
expect(body(res).error?.code).toBe('MAIL_007');
expect(body(res).error?.traceId).toBe(res.headers['x-request-id']);
}
const longSubject = await send({
subject: '가'.repeat(MAX_SUBJECT_LENGTH + 1),
}).expect(400);
expect(body(longSubject).error?.code).toBe('VAL_001');
});
});
/**
@@ -663,18 +747,74 @@ describe('응답 계약 (e2e)', () => {
}
});
it('같은 주소로 동시에 보낸 틀린 비밀번호 30건 — 엔진 확인은 1번뿐이고 나머지는 처리 중이라 429 REQ_001', async () => {
const results = await Promise.all(
Array.from({ length: 30 }, () =>
loginFrom('203.0.113.6', SLOW_ACCOUNT, 'wrong-password'),
),
);
const codes = results.map((res) => body(res).error?.code);
/**
* 한 주소의 확인이 엔진에 가 있는 동안, 같은 주소의 다른 시도는 엔진에 묻지 않고 바로 REQ_001 이다
* (login-attempt.service.ts 의 inFlight — 한 주소는 한 번에 하나만 엔진에 묻는다).
*
* 대역 엔진은 첫 확인을 문으로 붙잡아 두고, 나머지 29건이 모두 돌아온 것을 본 뒤에 연다 — 시간과 상관없이 늘 같은 순서다.
* 예전에는 엔진이 500ms 동안만 붙잡았는데, 바쁜 호스트(다른 프로젝트의 CI 러너와 함께 돈다)에서 30건이 500ms 넘게
* 퍼지면 늦게 온 요청이 두 번째 확인을 시작해 실패했다 (2026-10-27).
*
* 서버도 이 테스트 동안 미리 열어 둔다. supertest(7.2)는 서버가 듣고 있지 않으면 첫 요청이 서버를 열고 자기 응답 뒤에 닫는데,
* 닫기는 모든 연결이 끝나야 끝나고 그사이 새 연결은 거절된다 — 붙잡힌 요청이 첫 요청이 아니면 첫 요청의 응답이 문이 열릴 때까지
* 돌아오지 않고(29건이 오지 않는다), 늦게 연결한 요청은 거절될 수 있다.
*/
it(
'같은 주소로 동시에 보낸 틀린 비밀번호 30건 — 엔진 확인은 1번뿐이고 나머지는 처리 중이라 429 REQ_001',
async () => {
const httpServer = app.getHttpServer();
const startedServer = !httpServer.listening;
if (startedServer) await listenOnAnyPort(httpServer);
expect(engineChecks.get(SLOW_ACCOUNT)).toBe(1);
expect(codes.filter((code) => code === 'AUTH_004')).toHaveLength(1);
expect(codes.filter((code) => code === 'REQ_001')).toHaveLength(29);
});
const gate = createGate();
slowEngineGate = gate.opened;
const arrived: Response[] = [];
const pending = Array.from({ length: 30 }, () =>
loginFrom('203.0.113.6', SLOW_ACCOUNT, 'wrong-password').then(
(res) => {
arrived.push(res);
return res;
},
),
);
try {
// 문이 닫힌 동안: 첫 확인은 엔진에서 기다리고, 나머지 29건은 엔진에 묻지 않고 돌아와야 한다
await waitUntil(
() =>
(arrived.length >= 29 &&
(engineChecks.get(SLOW_ACCOUNT) ?? 0) >= 1) ||
arrived.length === 30,
GATE_DEADLINE_MS,
);
expect({
arrived: arrived.length,
engineChecks: engineChecks.get(SLOW_ACCOUNT),
codes: arrived.map((res) => body(res).error?.code),
}).toEqual({
arrived: 29,
engineChecks: 1,
codes: Array.from({ length: 29 }, () => 'REQ_001'),
});
} finally {
// 실패해도 문을 열고 붙잡힌 요청이 끝나기를 기다린다 — 다음 테스트·앱 종료가 멈추지 않게.
// 미리 연 서버는 닫아 다른 테스트는 예전처럼 요청마다 supertest 가 연다
gate.open();
slowEngineGate = null;
await Promise.allSettled(pending);
if (startedServer) await closeServer(httpServer);
}
// 문을 연 뒤: 붙잡혀 있던 첫 확인만 엔진의 답(틀린 비밀번호)으로 끝난다
const codes = (await Promise.all(pending)).map(
(res) => body(res).error?.code,
);
expect(engineChecks.get(SLOW_ACCOUNT)).toBe(1);
expect(codes.filter((code) => code === 'AUTH_004')).toHaveLength(1);
expect(codes.filter((code) => code === 'REQ_001')).toHaveLength(29);
},
GATED_TEST_TIMEOUT_MS,
);
it('같은 IP 에서 실패 20회를 넘으면 처음 보는 주소도 429 REQ_001 — 다른 IP 는 영향 없음', async () => {
for (let i = 0; i < 20; i += 1) {
+33
View File
@@ -0,0 +1,33 @@
/**
* CPU 시간 스톱워치 — 단위 테스트 전용 도우미 (앱 코드에서 import 하지 않는다).
*
* 왜 벽시계(performance.now)가 아닌가: 다른 프로세스가 CPU 를 나눠 쓰면 벽시계 시간은 그만큼 늘어난다.
* 2026-10-27 바쁜 PC(병렬 테스트·개발 서버와 함께)에서 혼자 0.7~0.8초인 정화가 5.9초·9.1초로 재져
* 전체 테스트 4번 중 2번이 실패했다. CPU 시간은 이 프로세스가 실제로 계산한 시간만 세므로 다른 작업이 많아도 거의 늘지 않는다 —
* "계산량이 입력 길이에 비례하는가(예전의 제곱 경로가 돌아오지 않았는가)"를 보는 단언에 쓴다.
*
* - 프로세스 전체(GC·JIT 스레드 포함)의 user + system 시간이다 — 한도는 지금 값보다 넉넉하게, 예전의 제곱 경로는 확실히 넘는 값으로 둔다.
* - 기다리는 시간(I/O·타이머)은 CPU 시간에 들어가지 않는다 — 그런 단언(예: 파일이 지워질 때까지 기다리기)에는 쓰지 않는다.
* - Windows 에서는 CPU 시간이 스케줄러 틱(약 15.6ms) 단위로 는다 — 수십 ms 아래를 비교하는 데 쓰지 않는다.
*
* 위치: src 밖(test/)이라 빌드(nest build 는 sourceRoot 인 src 만 컴파일한다)와 단위 테스트 커버리지(rootDir 이 src)에 들어가지 않고,
* 이름이 `*.spec.ts` 가 아니라 테스트로 돌지도 않는다. 단위 테스트는 `@/../test/cpu-time` 으로 부른다 —
* `@/` 기준이라 부르는 파일의 깊이와 상관없다 (tsconfig paths 와 jest moduleNameMapper 가 그대로 푼다).
*/
const MICROSECONDS_PER_MS = 1_000;
/**
* 지금부터 재기 시작한다. 돌려준 함수를 부르면 그때까지 이 프로세스가 쓴 CPU 시간(ms)을 준다.
*
* const cpuMs = startCpuTimer();
* sanitizeMailHtml(html);
* expect(cpuMs()).toBeLessThan(5_000);
*/
export function startCpuTimer(): () => number {
const started = process.cpuUsage();
return () => {
const { user, system } = process.cpuUsage(started);
return (user + system) / MICROSECONDS_PER_MS;
};
}
+688 -24
View File
@@ -13,6 +13,34 @@
body{margin:0}
a{color:#0E7C86}a:hover{color:#0A5F67}
:focus-visible{outline:2px solid #0E7C86;outline-offset:2px}
/* 글자 입력칸 포커스: 커서가 있는 칸의 아래 선이 2px 브랜드색 (받는 사람 · 참조 · 숨은참조 · 제목 · 본문 공통) */
.cf:has(.cf-in:focus){border-bottom-color:#0E7C86 !important;box-shadow:inset 0 -1px 0 #0E7C86}
.cf-in:focus,.cf-in:focus-visible{outline:none}
/* 본문 편집기 */
.ce p{margin:0}
.ce ul,.ce ol{margin:0;padding-left:24px}
.ce:empty::before{content:attr(data-placeholder);float:left;height:0;color:#667085;pointer-events:none}
/* 툴바 버튼: 올림 · 켜짐 · 비활성 · 메뉴 열림 */
.tb{background:transparent;color:#344054}
.tb:hover,.tb[aria-expanded="true"]{background:#F2F4F7;color:#171C26}
.tb[aria-pressed="true"]{background:#E3F2F3;color:#0A5F67;box-shadow:inset 0 0 0 1px #0E7C86}
.tb[aria-pressed="true"]:hover{background:#D5EBED;color:#0A5F67}
.tb:disabled,.tb:disabled:hover{opacity:.5;cursor:default;background:transparent;color:#344054}
.mi{background:transparent;color:#171C26}
.mi:hover{background:#F2F4F7}
.mi:disabled,.mi:disabled:hover{opacity:.5;cursor:default;background:transparent}
.sw{background:transparent}
.sw:hover{background:#F2F4F7}
/* 어두운 바탕(알림)에서는 강조색 고리가 묻혀 밝은 색으로 바꾼다 */
.on-dark:focus-visible{outline-color:#9ED6DB}
/* 좁은 폭: 쓰기 칸 폭을 보고 묶음을 정해진 순서로 더보기에 옮긴다 (참고값 — 구현은 실제 폭을 잰다) */
.cmp{container:cmp/inline-size}
.mm-a,.mm-b,.mm-d,.mm-e,.mm-f{display:none !important}
@container cmp (max-width:733px){.tg-a{display:none !important}.mm-a{display:flex !important}}
@container cmp (max-width:638px){.tg-b{display:none !important}.mm-b{display:flex !important}}
@container cmp (max-width:459px){.tg-d{display:none !important}.mm-d{display:flex !important}}
@container cmp (max-width:364px){.tg-f{display:none !important}.mm-f{display:flex !important}}
@container cmp (max-width:311px){.tg-e{display:none !important}.mm-e{display:flex !important}}
</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">
@@ -122,7 +150,7 @@ a{color:#0E7C86}a:hover{color:#0A5F67}
</nav>
<main style="flex: 999 1 560px; min-width: 0; box-sizing: border-box; display: flex; flex-direction: column">
<form aria-labelledby="compose-title" style="flex: 1 1 auto; display: flex; flex-direction: column; max-width: 800px">
<form class="cmp" aria-labelledby="compose-title" onDragOver="{{ onFormDragOver }}" onDragLeave="{{ onFormDragLeave }}" onDrop="{{ onFormDrop }}" style="flex: 1 1 auto; display: flex; flex-direction: column; max-width: 800px">
<div style="display: flex; align-items: center; gap: 8px; min-height: 52px; box-sizing: border-box; padding: 4px 18px 4px 32px; border-bottom: 1px solid #E6E8EC">
<h1 id="compose-title" style="flex: 1 1 auto; margin: 0; font-size: 15px; font-weight: 700; letter-spacing: -0.01em">{{ title }}</h1>
@@ -131,7 +159,7 @@ a{color:#0E7C86}a:hover{color:#0A5F67}
</a>
</div>
<div style="display: flex; flex-wrap: wrap; align-items: center; gap: 6px 12px; min-height: 44px; box-sizing: border-box; padding: 4px 22px 4px 32px; border-bottom: 1px solid #EEF0F3">
<div class="cf" style="display: flex; flex-wrap: wrap; align-items: center; gap: 6px 12px; min-height: 44px; box-sizing: border-box; padding: 4px 22px 4px 32px; border-bottom: 1px solid #EEF0F3">
<label for="compose-to" style="flex: 0 0 64px; font-size: 12.5px; font-weight: 600; color: #4A5363">받는 사람</label>
<div style="flex: 1 1 320px; min-width: 0; display: flex; flex-wrap: wrap; align-items: center; gap: 4px 6px">
<sc-for list="{{ chips }}" as="chip" hint-placeholder-count="1">
@@ -142,7 +170,7 @@ a{color:#0E7C86}a:hover{color:#0A5F67}
</button>
</span>
</sc-for>
<input id="compose-to" type="text" value="{{ toInput }}" onChange="{{ onToInput }}" onKeyDown="{{ onToKey }}" placeholder="이름 또는 메일 주소 입력 후 Enter" style="flex: 1 1 200px; min-width: 120px; min-height: 34px; border: 0; font: inherit; color: #171C26; background: transparent">
<input id="compose-to" class="cf-in" type="text" value="{{ toInput }}" onChange="{{ onToInput }}" onKeyDown="{{ onToKey }}" placeholder="이름 또는 메일 주소 입력 후 Enter" style="flex: 1 1 200px; min-width: 120px; min-height: 34px; border: 0; font: inherit; color: #171C26; background: transparent">
</div>
<button type="button" onClick="{{ toggleCc }}" aria-expanded="{{ ccExpanded }}" style="min-height: 44px; padding: 0 10px; border: 0; border-radius: 8px; background: transparent; color: #0E7C86; font: inherit; font-size: 12.5px; font-weight: 600; cursor: pointer">참조 · 숨은참조</button>
</div>
@@ -155,30 +183,423 @@ a{color:#0E7C86}a:hover{color:#0A5F67}
</sc-if>
<sc-if value="{{ showCc }}" hint-placeholder-val="{{ false }}">
<div style="display: flex; flex-wrap: wrap; align-items: center; gap: 6px 12px; min-height: 44px; box-sizing: border-box; padding: 4px 22px 4px 32px; border-bottom: 1px solid #EEF0F3">
<div class="cf" style="display: flex; flex-wrap: wrap; align-items: center; gap: 6px 12px; min-height: 44px; box-sizing: border-box; padding: 4px 22px 4px 32px; border-bottom: 1px solid #EEF0F3">
<label for="compose-cc" style="flex: 0 0 64px; font-size: 12.5px; font-weight: 600; color: #4A5363">참조</label>
<input id="compose-cc" type="text" placeholder="이름 또는 메일 주소" style="flex: 1 1 320px; min-width: 0; min-height: 34px; border: 0; font: inherit; color: #171C26; background: transparent">
<input id="compose-cc" class="cf-in" type="text" placeholder="이름 또는 메일 주소" style="flex: 1 1 320px; min-width: 0; min-height: 34px; border: 0; font: inherit; color: #171C26; background: transparent">
</div>
<div style="display: flex; flex-wrap: wrap; align-items: center; gap: 6px 12px; min-height: 44px; box-sizing: border-box; padding: 4px 22px 4px 32px; border-bottom: 1px solid #EEF0F3">
<div class="cf" style="display: flex; flex-wrap: wrap; align-items: center; gap: 6px 12px; min-height: 44px; box-sizing: border-box; padding: 4px 22px 4px 32px; border-bottom: 1px solid #EEF0F3">
<label for="compose-bcc" style="flex: 0 0 64px; font-size: 12.5px; font-weight: 600; color: #4A5363">숨은참조</label>
<input id="compose-bcc" type="text" placeholder="이름 또는 메일 주소" style="flex: 1 1 320px; min-width: 0; min-height: 34px; border: 0; font: inherit; color: #171C26; background: transparent">
<input id="compose-bcc" class="cf-in" type="text" placeholder="이름 또는 메일 주소" style="flex: 1 1 320px; min-width: 0; min-height: 34px; border: 0; font: inherit; color: #171C26; background: transparent">
</div>
</sc-if>
<div style="display: flex; flex-wrap: wrap; align-items: center; gap: 6px 12px; min-height: 44px; box-sizing: border-box; padding: 4px 22px 4px 32px; border-bottom: 1px solid #EEF0F3">
<div class="cf" style="display: flex; flex-wrap: wrap; align-items: center; gap: 6px 12px; min-height: 44px; box-sizing: border-box; padding: 4px 22px 4px 32px; border-bottom: 1px solid #EEF0F3">
<label for="compose-subject" style="flex: 0 0 64px; font-size: 12.5px; font-weight: 600; color: #4A5363">제목</label>
<input id="compose-subject" type="text" value="{{ subject }}" onChange="{{ onSubject }}" placeholder="제목을 입력하세요" style="flex: 1 1 320px; min-width: 0; min-height: 34px; border: 0; font: inherit; font-size: 14px; font-weight: 600; color: #171C26; background: transparent">
<input id="compose-subject" class="cf-in" type="text" value="{{ subject }}" onChange="{{ onSubject }}" placeholder="제목을 입력하세요" style="flex: 1 1 320px; min-width: 0; min-height: 34px; border: 0; font: inherit; font-size: 14px; font-weight: 600; color: #171C26; background: transparent">
</div>
<textarea aria-label="본문" value="{{ body }}" onChange="{{ onBody }}" rows="13" placeholder="내용을 입력하세요" style="flex: 1 1 auto; min-height: 300px; width: 100%; max-width: 744px; box-sizing: border-box; padding: 16px 32px; border: 0; resize: vertical; font: inherit; font-size: 14px; line-height: 1.75; color: #1F2733; background: #FFFFFF"></textarea>
<div style="flex: 1 1 auto; display: flex; flex-direction: column">
<div style="display: flex; flex-direction: column; gap: 8px; padding: 10px 32px 14px; border-top: 1px solid #EEF0F3">
<div role="toolbar" aria-label="본문 서식" aria-controls="compose-body" style="position: relative; z-index: 2; display: flex; align-items: center; gap: 2px; min-height: 52px; box-sizing: border-box; padding: 4px 20px; border-bottom: 1px solid #EEF0F3">
<div class="tg-a" style="display: flex; align-items: center; gap: 2px">
<button type="button" class="tb" disabled="{{ undoDisabled }}" aria-label="실행 취소" aria-keyshortcuts="Control+Z" title="실행 취소 (Ctrl+Z)" style="width: 40px; height: 44px; flex: 0 0 auto; 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="M9 14 4 9l5-5"></path><path d="M4 9h10.5a5.5 5.5 0 0 1 0 11H11"></path></svg>
</button>
<button type="button" class="tb" disabled="{{ true }}" aria-label="다시 실행" aria-keyshortcuts="Control+Y" title="다시 실행 (Ctrl+Y)" style="width: 40px; height: 44px; flex: 0 0 auto; 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="m15 14 5-5-5-5"></path><path d="M20 9H9.5a5.5 5.5 0 0 0 0 11H13"></path></svg>
</button>
<span aria-hidden="true" style="flex: 0 0 auto; width: 1px; height: 20px; margin: 0 4px; background: #E6E8EC"></span>
</div>
<div class="tg-b" style="display: flex; align-items: center; gap: 2px">
<div style="position: relative; display: flex">
<button type="button" class="tb" onClick="{{ openFont }}" aria-haspopup="menu" aria-expanded="{{ fontExpanded }}" aria-label="글꼴: {{ fontName }}" title="글꼴" style="width: 108px; height: 44px; flex: 0 0 auto; display: inline-flex; align-items: center; gap: 4px; box-sizing: border-box; padding: 0 8px 0 12px; border: 0; border-radius: 8px; font: inherit; font-weight: 500; cursor: pointer">
<span style="flex: 1 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; text-align: left">{{ fontName }}</span>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.25" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" style="flex: 0 0 auto"><polyline points="6 9 12 15 18 9"></polyline></svg>
</button>
<sc-if value="{{ fontOpen }}" hint-placeholder-val="{{ false }}">
<div role="menu" aria-label="글꼴" onKeyDown="{{ onPopKey }}" style="position: absolute; top: 48px; left: 0; z-index: 30; width: 216px; box-sizing: border-box; padding: 6px; display: flex; flex-direction: column; background: #FFFFFF; border: 1px solid #E6E8EC; border-radius: 10px; box-shadow: 0 10px 28px rgba(16, 24, 28, 0.12)">
<sc-for list="{{ fontsKo }}" as="f" hint-placeholder-count="4">
<button type="button" role="menuitemradio" aria-checked="{{ f.checked }}" class="mi" onClick="{{ f.pick }}" style="display: flex; align-items: center; gap: 10px; width: 100%; min-height: 40px; box-sizing: border-box; padding: 0 10px; border: 0; border-radius: 6px; font: inherit; font-size: 14px; font-family: {{ f.family }}; text-align: left; cursor: pointer">
<span style="flex: 1 1 auto; min-width: 0">{{ f.label }}</span>
<sc-if value="{{ f.isBase }}" hint-placeholder-val="{{ false }}"><span style="flex: 0 0 auto; font-family: Pretendard, -apple-system, 'Apple SD Gothic Neo', 'Malgun Gothic', system-ui, sans-serif; font-size: 12px; color: #667085">기본</span></sc-if>
<sc-if value="{{ f.on }}" hint-placeholder-val="{{ false }}"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#0A5F67" stroke-width="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></sc-if>
</button>
</sc-for>
<hr style="width: 100%; height: 1px; margin: 4px 0; border: 0; background: #EEF0F3">
<sc-for list="{{ fontsEn }}" as="f" hint-placeholder-count="5">
<button type="button" role="menuitemradio" aria-checked="{{ f.checked }}" class="mi" onClick="{{ f.pick }}" style="display: flex; align-items: center; gap: 10px; width: 100%; min-height: 40px; box-sizing: border-box; padding: 0 10px; border: 0; border-radius: 6px; font: inherit; font-size: 14px; font-family: {{ f.family }}; text-align: left; cursor: pointer">
<span style="flex: 1 1 auto; min-width: 0">{{ f.label }}</span>
<sc-if value="{{ f.on }}" hint-placeholder-val="{{ false }}"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#0A5F67" stroke-width="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></sc-if>
</button>
</sc-for>
</div>
</sc-if>
</div>
<div style="position: relative; display: flex">
<button type="button" class="tb" onClick="{{ openSize }}" aria-haspopup="menu" aria-expanded="{{ sizeExpanded }}" aria-label="글자 크기: {{ sizeLabel }}" title="글자 크기" style="width: 56px; height: 44px; flex: 0 0 auto; display: inline-flex; align-items: center; gap: 4px; box-sizing: border-box; padding: 0 8px 0 12px; border: 0; border-radius: 8px; font: inherit; font-weight: 500; font-variant-numeric: tabular-nums; cursor: pointer">
<span style="flex: 1 1 auto; text-align: left">{{ sizeLabel }}</span>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.25" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" style="flex: 0 0 auto"><polyline points="6 9 12 15 18 9"></polyline></svg>
</button>
<sc-if value="{{ sizeOpen }}" hint-placeholder-val="{{ false }}">
<div role="menu" aria-label="글자 크기" onKeyDown="{{ onPopKey }}" style="position: absolute; top: 48px; left: 0; z-index: 30; width: 132px; box-sizing: border-box; padding: 6px; display: flex; flex-direction: column; background: #FFFFFF; border: 1px solid #E6E8EC; border-radius: 10px; box-shadow: 0 10px 28px rgba(16, 24, 28, 0.12)">
<sc-for list="{{ sizes }}" as="s" hint-placeholder-count="8">
<button type="button" role="menuitemradio" aria-checked="{{ s.checked }}" class="mi" onClick="{{ s.pick }}" style="display: flex; align-items: center; gap: 8px; width: 100%; min-height: 40px; box-sizing: border-box; padding: 0 10px; border: 0; border-radius: 6px; font: inherit; text-align: left; cursor: pointer">
<span style="flex: 1 1 auto; font-size: {{ s.px }}; line-height: 1.1; font-variant-numeric: tabular-nums">{{ s.label }}</span>
<sc-if value="{{ s.isBase }}" hint-placeholder-val="{{ false }}"><span style="font-size: 12px; color: #667085">기본</span></sc-if>
<sc-if value="{{ s.on }}" hint-placeholder-val="{{ false }}"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#0A5F67" stroke-width="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></sc-if>
</button>
</sc-for>
</div>
</sc-if>
</div>
<span aria-hidden="true" style="flex: 0 0 auto; width: 1px; height: 20px; margin: 0 4px; background: #E6E8EC"></span>
</div>
<div class="tg-c" style="display: flex; align-items: center; gap: 2px">
<button type="button" class="tb" onClick="{{ toggleBold }}" aria-pressed="{{ boldPressed }}" aria-label="굵게" aria-keyshortcuts="Control+B" title="굵게 (Ctrl+B)" style="width: 40px; height: 44px; flex: 0 0 auto; 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" onClick="{{ toggleItalic }}" aria-pressed="{{ italicPressed }}" aria-label="기울임" aria-keyshortcuts="Control+I" title="기울임 (Ctrl+I)" style="width: 40px; height: 44px; flex: 0 0 auto; 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" onClick="{{ toggleUnderline }}" aria-pressed="{{ underlinePressed }}" aria-label="밑줄" aria-keyshortcuts="Control+U" title="밑줄 (Ctrl+U)" style="width: 40px; height: 44px; flex: 0 0 auto; 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>
</div>
<div class="tg-d" style="display: flex; align-items: center; gap: 2px">
<div style="position: relative; display: flex">
<button type="button" class="tb" onClick="{{ openColor }}" aria-haspopup="dialog" aria-expanded="{{ colorExpanded }}" aria-label="글자 색: {{ colorName }}" title="글자 색" style="width: 40px; height: 44px; flex: 0 0 auto; 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.5 14.5 12 3.5l5.5 11"></path><path d="M8.5 10.5h7"></path><rect x="4.5" y="18" width="15" height="3" rx="1" fill="{{ colorHex }}" stroke="none"></rect></svg>
</button>
<sc-if value="{{ colorOpen }}" hint-placeholder-val="{{ false }}">
<div role="dialog" aria-label="글자 색" onKeyDown="{{ onPopKey }}" style="position: absolute; top: 48px; left: 0; z-index: 30; width: 186px; box-sizing: border-box; padding: 10px; display: flex; flex-direction: column; gap: 8px; background: #FFFFFF; border: 1px solid #E6E8EC; border-radius: 10px; box-shadow: 0 10px 28px rgba(16, 24, 28, 0.12)">
<div style="display: flex; align-items: baseline; justify-content: space-between; gap: 8px; padding: 0 2px">
<span style="font-size: 12.5px; font-weight: 600; color: #4A5363">글자 색</span>
<span style="font-size: 12px; color: #667085">{{ colorName }}</span>
</div>
<div style="display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 2px">
<sc-for list="{{ textColors }}" as="c" hint-placeholder-count="8">
<button type="button" class="sw" onClick="{{ c.pick }}" aria-label="{{ c.name }}" aria-pressed="{{ c.pressed }}" title="{{ c.name }}" style="width: 40px; height: 40px; display: inline-flex; align-items: center; justify-content: center; padding: 0; border: 0; border-radius: 8px; cursor: pointer">
<span style="width: 26px; height: 26px; display: inline-flex; align-items: center; justify-content: center; border-radius: 6px; background: {{ c.hex }}; box-shadow: inset 0 0 0 1px rgba(16, 24, 40, 0.14); color: {{ c.checkColor }}">
<sc-if value="{{ c.on }}" hint-placeholder-val="{{ false }}"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="20 6 9 17 4 12"></polyline></svg></sc-if>
</span>
</button>
</sc-for>
</div>
</div>
</sc-if>
</div>
<div style="position: relative; display: flex">
<button type="button" class="tb" onClick="{{ openBg }}" aria-haspopup="dialog" aria-expanded="{{ bgExpanded }}" aria-label="배경 색: {{ bgName }}" title="배경 색" style="width: 40px; height: 44px; flex: 0 0 auto; 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.5" y="3.5" width="17" height="17" rx="3" fill="{{ bgFill }}"></rect><path d="M8 16.5 12 7.5l4 9"></path><path d="M9.3 13.5h5.4"></path></svg>
</button>
<sc-if value="{{ bgOpen }}" hint-placeholder-val="{{ false }}">
<div role="dialog" aria-label="배경 색" onKeyDown="{{ onPopKey }}" style="position: absolute; top: 48px; left: 0; z-index: 30; width: 186px; box-sizing: border-box; padding: 10px; display: flex; flex-direction: column; gap: 8px; background: #FFFFFF; border: 1px solid #E6E8EC; border-radius: 10px; box-shadow: 0 10px 28px rgba(16, 24, 28, 0.12)">
<div style="display: flex; align-items: baseline; justify-content: space-between; gap: 8px; padding: 0 2px">
<span style="font-size: 12.5px; font-weight: 600; color: #4A5363">배경 색</span>
<span style="font-size: 12px; color: #667085">{{ bgName }}</span>
</div>
<div style="display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 2px">
<sc-for list="{{ bgColors }}" as="c" hint-placeholder-count="8">
<button type="button" class="sw" onClick="{{ c.pick }}" aria-label="{{ c.name }}" aria-pressed="{{ c.pressed }}" title="{{ c.name }}" style="width: 40px; height: 40px; display: inline-flex; align-items: center; justify-content: center; padding: 0; border: 0; border-radius: 8px; cursor: pointer">
<span style="width: 26px; height: 26px; display: inline-flex; align-items: center; justify-content: center; border-radius: 6px; background: {{ c.hex }}; box-shadow: inset 0 0 0 1px rgba(16, 24, 40, 0.14); color: {{ c.checkColor }}">
<sc-if value="{{ c.slash }}" hint-placeholder-val="{{ false }}"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="#B42318" stroke-width="2" stroke-linecap="round" aria-hidden="true"><line x1="5" y1="19" x2="19" y2="5"></line></svg></sc-if>
<sc-if value="{{ c.on }}" hint-placeholder-val="{{ false }}"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="20 6 9 17 4 12"></polyline></svg></sc-if>
</span>
</button>
</sc-for>
</div>
</div>
</sc-if>
</div>
<span aria-hidden="true" style="flex: 0 0 auto; width: 1px; height: 20px; margin: 0 4px; background: #E6E8EC"></span>
</div>
<div class="tg-e" style="display: flex; align-items: center; gap: 2px">
<button type="button" class="tb" onClick="{{ toggleBullet }}" aria-pressed="{{ bulletPressed }}" aria-label="글머리 목록" aria-keyshortcuts="Control+Shift+8" title="글머리 목록 (Ctrl+Shift+8)" style="width: 40px; height: 44px; flex: 0 0 auto; 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="9" y1="6" x2="20" y2="6"></line><line x1="9" y1="12" x2="20" y2="12"></line><line x1="9" y1="18" x2="20" y2="18"></line><circle cx="4.5" cy="6" r="1.2" fill="currentColor" stroke="none"></circle><circle cx="4.5" cy="12" r="1.2" fill="currentColor" stroke="none"></circle><circle cx="4.5" cy="18" r="1.2" fill="currentColor" stroke="none"></circle></svg>
</button>
<button type="button" class="tb" onClick="{{ toggleOrdered }}" aria-pressed="{{ orderedPressed }}" aria-label="번호 목록" aria-keyshortcuts="Control+Shift+7" title="번호 목록 (Ctrl+Shift+7)" style="width: 40px; height: 44px; flex: 0 0 auto; 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="10" y1="6" x2="21" y2="6"></line><line x1="10" y1="12" x2="21" y2="12"></line><line x1="10" y1="18" x2="21" y2="18"></line><path d="M4 6h1v4"></path><path d="M4 10h2"></path><path d="M6 18H4c0-1 2-2 2-3s-1-1.5-2-1"></path></svg>
</button>
<span aria-hidden="true" style="flex: 0 0 auto; width: 1px; height: 20px; margin: 0 4px; background: #E6E8EC"></span>
</div>
<div class="tg-f" style="display: flex; align-items: center; gap: 2px">
<div style="position: relative; display: flex">
<button type="button" class="tb" onClick="{{ openLink }}" aria-haspopup="dialog" aria-expanded="{{ linkExpanded }}" aria-label="링크" aria-keyshortcuts="Control+K" title="링크 (Ctrl+K)" style="width: 40px; height: 44px; flex: 0 0 auto; 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>
<sc-if value="{{ linkOpen }}" hint-placeholder-val="{{ false }}">
<div role="dialog" aria-label="링크 넣기" onKeyDown="{{ onPopKey }}" style="position: absolute; top: 48px; right: 0; z-index: 30; width: 300px; box-sizing: border-box; padding: 14px; display: flex; flex-direction: column; gap: 10px; background: #FFFFFF; border: 1px solid #E6E8EC; border-radius: 10px; box-shadow: 0 10px 28px rgba(16, 24, 28, 0.12)">
<div style="display: flex; flex-direction: column; gap: 4px">
<label for="link-url" style="font-size: 12.5px; font-weight: 600; color: #344054">주소</label>
<input id="link-url" type="url" placeholder="https://" style="min-height: 40px; box-sizing: border-box; padding: 0 12px; border: 1px solid #8A94A3; border-radius: 8px; font: inherit; color: #171C26; background: #FFFFFF">
</div>
<div style="display: flex; flex-direction: column; gap: 4px">
<label for="link-text" style="font-size: 12.5px; font-weight: 600; color: #344054">표시할 글자</label>
<input id="link-text" type="text" placeholder="비우면 주소를 그대로 보여 줍니다" style="min-height: 40px; box-sizing: border-box; padding: 0 12px; border: 1px solid #8A94A3; border-radius: 8px; font: inherit; color: #171C26; background: #FFFFFF">
</div>
<div style="display: flex; justify-content: flex-end; gap: 6px">
<button type="button" onClick="{{ closeMenus }}" style="min-height: 44px; padding: 0 14px; border: 0; border-radius: 8px; background: transparent; color: #344054; font: inherit; font-weight: 500; cursor: pointer">취소</button>
<button type="button" onClick="{{ closeMenus }}" style="min-height: 44px; padding: 0 18px; border: 0; border-radius: 8px; background: #0E7C86; color: #FFFFFF; font: inherit; font-weight: 600; cursor: pointer">넣기</button>
</div>
</div>
</sc-if>
</div>
<span aria-hidden="true" style="flex: 0 0 auto; width: 1px; height: 20px; margin: 0 4px; background: #E6E8EC"></span>
</div>
<div style="position: relative; display: flex">
<button type="button" class="tb" onClick="{{ openMore }}" aria-haspopup="menu" aria-expanded="{{ moreExpanded }}" aria-label="서식 더보기" title="서식 더보기" style="width: 40px; height: 44px; flex: 0 0 auto; 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="currentColor" stroke="none" aria-hidden="true"><circle cx="5" cy="12" r="1.6"></circle><circle cx="12" cy="12" r="1.6"></circle><circle cx="19" cy="12" r="1.6"></circle></svg>
</button>
<sc-if value="{{ moreOpen }}" hint-placeholder-val="{{ false }}">
<div role="menu" aria-label="서식 더보기" onKeyDown="{{ onPopKey }}" style="position: absolute; top: 48px; right: 0; z-index: 30; width: 256px; max-height: min(560px, 70vh); overflow-y: auto; box-sizing: border-box; padding: 6px; display: flex; flex-direction: column; background: #FFFFFF; border: 1px solid #E6E8EC; border-radius: 10px; box-shadow: 0 10px 28px rgba(16, 24, 28, 0.12)">
<div class="mm-a" style="flex-direction: column">
<button type="button" role="menuitem" class="mi" disabled="{{ undoDisabled }}" onClick="{{ closeMenus }}" style="display: flex; align-items: center; gap: 10px; width: 100%; min-height: 40px; box-sizing: border-box; padding: 0 10px; border: 0; border-radius: 6px; font: inherit; text-align: left; 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" style="flex: 0 0 auto; color: #4A5363"><path d="M9 14 4 9l5-5"></path><path d="M4 9h10.5a5.5 5.5 0 0 1 0 11H11"></path></svg>
<span style="flex: 1 1 auto; min-width: 0">실행 취소</span>
<span style="flex: 0 0 auto; font-size: 12px; color: #667085">Ctrl+Z</span>
<span style="flex: 0 0 auto; width: 16px; height: 16px"></span>
</button>
<button type="button" role="menuitem" class="mi" disabled="{{ true }}" style="display: flex; align-items: center; gap: 10px; width: 100%; min-height: 40px; box-sizing: border-box; padding: 0 10px; border: 0; border-radius: 6px; font: inherit; text-align: left; 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" style="flex: 0 0 auto; color: #4A5363"><path d="m15 14 5-5-5-5"></path><path d="M20 9H9.5a5.5 5.5 0 0 0 0 11H13"></path></svg>
<span style="flex: 1 1 auto; min-width: 0">다시 실행</span>
<span style="flex: 0 0 auto; font-size: 12px; color: #667085">Ctrl+Y</span>
<span style="flex: 0 0 auto; width: 16px; height: 16px"></span>
</button>
<hr style="width: 100%; height: 1px; margin: 4px 0; border: 0; background: #EEF0F3">
</div>
<div class="mm-b" style="flex-direction: column">
<button type="button" role="menuitem" aria-haspopup="menu" class="mi" onClick="{{ closeMenus }}" style="display: flex; align-items: center; gap: 10px; width: 100%; min-height: 40px; box-sizing: border-box; padding: 0 10px; border: 0; border-radius: 6px; font: inherit; text-align: left; 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" style="flex: 0 0 auto; color: #4A5363"><polyline points="4 7 4 4 20 4 20 7"></polyline><line x1="9" y1="20" x2="15" y2="20"></line><line x1="12" y1="4" x2="12" y2="20"></line></svg>
<span style="flex: 1 1 auto; min-width: 0">글꼴</span>
<span style="flex: 0 0 auto; font-size: 12px; color: #667085">{{ fontName }}</span>
<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" style="flex: 0 0 auto; margin: 0 1px; color: #667085"><polyline points="9 18 15 12 9 6"></polyline></svg>
</button>
<button type="button" role="menuitem" aria-haspopup="menu" class="mi" onClick="{{ closeMenus }}" style="display: flex; align-items: center; gap: 10px; width: 100%; min-height: 40px; box-sizing: border-box; padding: 0 10px; border: 0; border-radius: 6px; font: inherit; text-align: left; 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" style="flex: 0 0 auto; color: #4A5363"><path d="M3 7V5h10v2"></path><path d="M8 5v14"></path><path d="M6 19h4"></path><path d="M14 12v-1h7v1"></path><path d="M17.5 11v8"></path><path d="M16 19h3"></path></svg>
<span style="flex: 1 1 auto; min-width: 0">글자 크기</span>
<span style="flex: 0 0 auto; font-size: 12px; color: #667085">{{ sizeLabel }}</span>
<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" style="flex: 0 0 auto; margin: 0 1px; color: #667085"><polyline points="9 18 15 12 9 6"></polyline></svg>
</button>
<hr style="width: 100%; height: 1px; margin: 4px 0; border: 0; background: #EEF0F3">
</div>
<div style="display: flex; flex-direction: column">
<button type="button" role="menuitemcheckbox" aria-checked="{{ strikeChecked }}" class="mi" onClick="{{ toggleStrike }}" style="display: flex; align-items: center; gap: 10px; width: 100%; min-height: 40px; box-sizing: border-box; padding: 0 10px; border: 0; border-radius: 6px; font: inherit; text-align: left; 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" style="flex: 0 0 auto; color: #4A5363"><path d="M16 4H9a3 3 0 0 0-2.83 4"></path><path d="M14 12a4 4 0 0 1 0 8H6"></path><line x1="4" y1="12" x2="20" y2="12"></line></svg>
<span style="flex: 1 1 auto; min-width: 0">취소선</span>
<span style="flex: 0 0 auto; font-size: 12px; color: #667085">Ctrl+Shift+S</span>
<span style="flex: 0 0 auto; width: 16px; height: 16px; display: inline-flex"><sc-if value="{{ strikeOn }}" hint-placeholder-val="{{ false }}"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#0A5F67" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="20 6 9 17 4 12"></polyline></svg></sc-if></span>
</button>
<div class="mm-d" style="flex-direction: column">
<button type="button" role="menuitem" aria-haspopup="dialog" class="mi" onClick="{{ closeMenus }}" style="display: flex; align-items: center; gap: 10px; width: 100%; min-height: 40px; box-sizing: border-box; padding: 0 10px; border: 0; border-radius: 6px; font: inherit; text-align: left; 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" style="flex: 0 0 auto; color: #4A5363"><path d="M6.5 14.5 12 3.5l5.5 11"></path><path d="M8.5 10.5h7"></path><rect x="4.5" y="18" width="15" height="3" rx="1" fill="{{ colorHex }}" stroke="none"></rect></svg>
<span style="flex: 1 1 auto; min-width: 0">글자 색</span>
<span style="flex: 0 0 auto; font-size: 12px; color: #667085">{{ colorName }}</span>
<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" style="flex: 0 0 auto; margin: 0 1px; color: #667085"><polyline points="9 18 15 12 9 6"></polyline></svg>
</button>
<button type="button" role="menuitem" aria-haspopup="dialog" class="mi" onClick="{{ closeMenus }}" style="display: flex; align-items: center; gap: 10px; width: 100%; min-height: 40px; box-sizing: border-box; padding: 0 10px; border: 0; border-radius: 6px; font: inherit; text-align: left; 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" style="flex: 0 0 auto; color: #4A5363"><rect x="3.5" y="3.5" width="17" height="17" rx="3" fill="{{ bgFill }}"></rect><path d="M8 16.5 12 7.5l4 9"></path><path d="M9.3 13.5h5.4"></path></svg>
<span style="flex: 1 1 auto; min-width: 0">배경 색</span>
<span style="flex: 0 0 auto; font-size: 12px; color: #667085">{{ bgName }}</span>
<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" style="flex: 0 0 auto; margin: 0 1px; color: #667085"><polyline points="9 18 15 12 9 6"></polyline></svg>
</button>
</div>
<hr style="width: 100%; height: 1px; margin: 4px 0; border: 0; background: #EEF0F3">
</div>
<div style="display: flex; flex-direction: column">
<div class="mm-e" style="flex-direction: column">
<button type="button" role="menuitemcheckbox" aria-checked="{{ bulletChecked }}" class="mi" onClick="{{ toggleBulletMenu }}" style="display: flex; align-items: center; gap: 10px; width: 100%; min-height: 40px; box-sizing: border-box; padding: 0 10px; border: 0; border-radius: 6px; font: inherit; text-align: left; 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" style="flex: 0 0 auto; color: #4A5363"><line x1="9" y1="6" x2="20" y2="6"></line><line x1="9" y1="12" x2="20" y2="12"></line><line x1="9" y1="18" x2="20" y2="18"></line><circle cx="4.5" cy="6" r="1.2" fill="currentColor" stroke="none"></circle><circle cx="4.5" cy="12" r="1.2" fill="currentColor" stroke="none"></circle><circle cx="4.5" cy="18" r="1.2" fill="currentColor" stroke="none"></circle></svg>
<span style="flex: 1 1 auto; min-width: 0">글머리 목록</span>
<span style="flex: 0 0 auto; font-size: 12px; color: #667085">Ctrl+Shift+8</span>
<span style="flex: 0 0 auto; width: 16px; height: 16px; display: inline-flex"><sc-if value="{{ bulletOn }}" hint-placeholder-val="{{ false }}"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#0A5F67" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="20 6 9 17 4 12"></polyline></svg></sc-if></span>
</button>
<button type="button" role="menuitemcheckbox" aria-checked="{{ orderedChecked }}" class="mi" onClick="{{ toggleOrderedMenu }}" style="display: flex; align-items: center; gap: 10px; width: 100%; min-height: 40px; box-sizing: border-box; padding: 0 10px; border: 0; border-radius: 6px; font: inherit; text-align: left; 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" style="flex: 0 0 auto; color: #4A5363"><line x1="10" y1="6" x2="21" y2="6"></line><line x1="10" y1="12" x2="21" y2="12"></line><line x1="10" y1="18" x2="21" y2="18"></line><path d="M4 6h1v4"></path><path d="M4 10h2"></path><path d="M6 18H4c0-1 2-2 2-3s-1-1.5-2-1"></path></svg>
<span style="flex: 1 1 auto; min-width: 0">번호 목록</span>
<span style="flex: 0 0 auto; font-size: 12px; color: #667085">Ctrl+Shift+7</span>
<span style="flex: 0 0 auto; width: 16px; height: 16px; display: inline-flex"><sc-if value="{{ orderedOn }}" hint-placeholder-val="{{ false }}"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#0A5F67" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="20 6 9 17 4 12"></polyline></svg></sc-if></span>
</button>
</div>
<button type="button" role="menuitemradio" aria-checked="{{ alignLeftChecked }}" class="mi" onClick="{{ setAlignLeft }}" style="display: flex; align-items: center; gap: 10px; width: 100%; min-height: 40px; box-sizing: border-box; padding: 0 10px; border: 0; border-radius: 6px; font: inherit; text-align: left; 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" style="flex: 0 0 auto; color: #4A5363"><line x1="17" y1="10" x2="3" y2="10"></line><line x1="21" y1="6" x2="3" y2="6"></line><line x1="21" y1="14" x2="3" y2="14"></line><line x1="17" y1="18" x2="3" y2="18"></line></svg>
<span style="flex: 1 1 auto; min-width: 0">왼쪽 정렬</span>
<span style="flex: 0 0 auto; font-size: 12px; color: #667085">Ctrl+Shift+L</span>
<span style="flex: 0 0 auto; width: 16px; height: 16px; display: inline-flex"><sc-if value="{{ alignLeftOn }}" hint-placeholder-val="{{ false }}"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#0A5F67" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="20 6 9 17 4 12"></polyline></svg></sc-if></span>
</button>
<button type="button" role="menuitemradio" aria-checked="{{ alignCenterChecked }}" class="mi" onClick="{{ setAlignCenter }}" style="display: flex; align-items: center; gap: 10px; width: 100%; min-height: 40px; box-sizing: border-box; padding: 0 10px; border: 0; border-radius: 6px; font: inherit; text-align: left; 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" style="flex: 0 0 auto; color: #4A5363"><line x1="18" y1="10" x2="6" y2="10"></line><line x1="21" y1="6" x2="3" y2="6"></line><line x1="21" y1="14" x2="3" y2="14"></line><line x1="18" y1="18" x2="6" y2="18"></line></svg>
<span style="flex: 1 1 auto; min-width: 0">가운데 정렬</span>
<span style="flex: 0 0 auto; font-size: 12px; color: #667085">Ctrl+Shift+E</span>
<span style="flex: 0 0 auto; width: 16px; height: 16px; display: inline-flex"><sc-if value="{{ alignCenterOn }}" hint-placeholder-val="{{ false }}"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#0A5F67" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="20 6 9 17 4 12"></polyline></svg></sc-if></span>
</button>
<button type="button" role="menuitemradio" aria-checked="{{ alignRightChecked }}" class="mi" onClick="{{ setAlignRight }}" style="display: flex; align-items: center; gap: 10px; width: 100%; min-height: 40px; box-sizing: border-box; padding: 0 10px; border: 0; border-radius: 6px; font: inherit; text-align: left; 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" style="flex: 0 0 auto; color: #4A5363"><line x1="21" y1="10" x2="7" y2="10"></line><line x1="21" y1="6" x2="3" y2="6"></line><line x1="21" y1="14" x2="3" y2="14"></line><line x1="21" y1="18" x2="7" y2="18"></line></svg>
<span style="flex: 1 1 auto; min-width: 0">오른쪽 정렬</span>
<span style="flex: 0 0 auto; font-size: 12px; color: #667085">Ctrl+Shift+R</span>
<span style="flex: 0 0 auto; width: 16px; height: 16px; display: inline-flex"><sc-if value="{{ alignRightOn }}" hint-placeholder-val="{{ false }}"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#0A5F67" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="20 6 9 17 4 12"></polyline></svg></sc-if></span>
</button>
<hr style="width: 100%; height: 1px; margin: 4px 0; border: 0; background: #EEF0F3">
</div>
<div style="display: flex; flex-direction: column">
<button type="button" role="menuitem" class="mi" onClick="{{ closeMenus }}" style="display: flex; align-items: center; gap: 10px; width: 100%; min-height: 40px; box-sizing: border-box; padding: 0 10px; border: 0; border-radius: 6px; font: inherit; text-align: left; 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" style="flex: 0 0 auto; color: #4A5363"><polyline points="7 8 3 12 7 16"></polyline><line x1="21" y1="12" x2="11" y2="12"></line><line x1="21" y1="6" x2="11" y2="6"></line><line x1="21" y1="18" x2="11" y2="18"></line></svg>
<span style="flex: 1 1 auto; min-width: 0">내어쓰기</span>
<span style="flex: 0 0 auto; font-size: 12px; color: #667085">Ctrl+[</span>
<span style="flex: 0 0 auto; width: 16px; height: 16px"></span>
</button>
<button type="button" role="menuitem" class="mi" onClick="{{ closeMenus }}" style="display: flex; align-items: center; gap: 10px; width: 100%; min-height: 40px; box-sizing: border-box; padding: 0 10px; border: 0; border-radius: 6px; font: inherit; text-align: left; 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" style="flex: 0 0 auto; color: #4A5363"><polyline points="3 8 7 12 3 16"></polyline><line x1="21" y1="12" x2="11" y2="12"></line><line x1="21" y1="6" x2="11" y2="6"></line><line x1="21" y1="18" x2="11" y2="18"></line></svg>
<span style="flex: 1 1 auto; min-width: 0">들여쓰기</span>
<span style="flex: 0 0 auto; font-size: 12px; color: #667085">Ctrl+]</span>
<span style="flex: 0 0 auto; width: 16px; height: 16px"></span>
</button>
<hr style="width: 100%; height: 1px; margin: 4px 0; border: 0; background: #EEF0F3">
</div>
<div style="display: flex; flex-direction: column">
<div class="mm-f" style="flex-direction: column">
<button type="button" role="menuitem" aria-haspopup="dialog" class="mi" onClick="{{ closeMenus }}" style="display: flex; align-items: center; gap: 10px; width: 100%; min-height: 40px; box-sizing: border-box; padding: 0 10px; border: 0; border-radius: 6px; font: inherit; text-align: left; 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" style="flex: 0 0 auto; color: #4A5363"><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>
<span style="flex: 1 1 auto; min-width: 0">링크 넣기</span>
<span style="flex: 0 0 auto; font-size: 12px; color: #667085">Ctrl+K</span>
<span style="flex: 0 0 auto; width: 16px; height: 16px"></span>
</button>
</div>
<button type="button" role="menuitemcheckbox" aria-checked="{{ quoteChecked }}" class="mi" onClick="{{ toggleQuote }}" style="display: flex; align-items: center; gap: 10px; width: 100%; min-height: 40px; box-sizing: border-box; padding: 0 10px; border: 0; border-radius: 6px; font: inherit; text-align: left; 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" style="flex: 0 0 auto; color: #4A5363"><path d="M17 6H3"></path><path d="M21 12H8"></path><path d="M21 18H8"></path><path d="M3 12v6"></path></svg>
<span style="flex: 1 1 auto; min-width: 0">인용</span>
<span style="flex: 0 0 auto; font-size: 12px; color: #667085">Ctrl+Shift+B</span>
<span style="flex: 0 0 auto; width: 16px; height: 16px; display: inline-flex"><sc-if value="{{ quoteOn }}" hint-placeholder-val="{{ false }}"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#0A5F67" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="20 6 9 17 4 12"></polyline></svg></sc-if></span>
</button>
<button type="button" role="menuitem" aria-haspopup="dialog" class="mi" onClick="{{ openTable }}" style="display: flex; align-items: center; gap: 10px; width: 100%; min-height: 40px; box-sizing: border-box; padding: 0 10px; border: 0; border-radius: 6px; font: inherit; text-align: left; 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" style="flex: 0 0 auto; color: #4A5363"><rect x="3" y="3" width="18" height="18" rx="2" ry="2"></rect><line x1="3" y1="9" x2="21" y2="9"></line><line x1="3" y1="15" x2="21" y2="15"></line><line x1="9" y1="3" x2="9" y2="21"></line><line x1="15" y1="3" x2="15" y2="21"></line></svg>
<span style="flex: 1 1 auto; min-width: 0">표 넣기</span>
<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" style="flex: 0 0 auto; margin: 0 1px; color: #667085"><polyline points="9 18 15 12 9 6"></polyline></svg>
</button>
<button type="button" role="menuitem" class="mi" onClick="{{ closeMenus }}" style="display: flex; align-items: center; gap: 10px; width: 100%; min-height: 40px; box-sizing: border-box; padding: 0 10px; border: 0; border-radius: 6px; font: inherit; text-align: left; 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" style="flex: 0 0 auto; color: #4A5363"><line x1="3" y1="12" x2="21" y2="12"></line><polyline points="8 8 12 4 16 8"></polyline><polyline points="16 16 12 20 8 16"></polyline></svg>
<span style="flex: 1 1 auto; min-width: 0">가로줄</span>
<span style="flex: 0 0 auto; width: 16px; height: 16px"></span>
</button>
<hr style="width: 100%; height: 1px; margin: 4px 0; border: 0; background: #EEF0F3">
</div>
<button type="button" role="menuitem" class="mi" onClick="{{ closeMenus }}" style="display: flex; align-items: center; gap: 10px; width: 100%; min-height: 40px; box-sizing: border-box; padding: 0 10px; border: 0; border-radius: 6px; font: inherit; text-align: left; 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" style="flex: 0 0 auto; color: #4A5363"><path d="M4 7V4h16v3"></path><path d="M5 20h6"></path><path d="M13 4 8 20"></path><path d="m15 15 5 5"></path><path d="m20 15-5 5"></path></svg>
<span style="flex: 1 1 auto; min-width: 0">서식 지우기</span>
<span style="flex: 0 0 auto; font-size: 12px; color: #667085">Ctrl+\</span>
<span style="flex: 0 0 auto; width: 16px; height: 16px"></span>
</button>
</div>
</sc-if>
<sc-if value="{{ tableOpen }}" hint-placeholder-val="{{ false }}">
<div role="dialog" aria-label="표 넣기" onKeyDown="{{ onPopKey }}" style="position: absolute; top: 48px; right: 0; z-index: 30; box-sizing: border-box; padding: 12px; display: flex; flex-direction: column; gap: 8px; background: #FFFFFF; border: 1px solid #E6E8EC; border-radius: 10px; box-shadow: 0 10px 28px rgba(16, 24, 28, 0.12)">
<span style="font-size: 12.5px; font-weight: 600; color: #4A5363">표 넣기</span>
<div style="display: grid; grid-template-columns: repeat(8, 22px); gap: 3px">
<sc-for list="{{ cells }}" as="cell" hint-placeholder-count="48">
<button type="button" onClick="{{ cell.pick }}" onMouseEnter="{{ cell.hover }}" onFocus="{{ cell.hover }}" aria-label="{{ cell.label }}" style="width: 22px; height: 22px; box-sizing: border-box; padding: 0; border: 1px solid {{ cell.border }}; border-radius: 3px; background: {{ cell.bg }}; cursor: pointer"></button>
</sc-for>
</div>
<span aria-live="polite" style="font-size: 12.5px; color: #344054; font-variant-numeric: tabular-nums">{{ tableSize }}</span>
</div>
</sc-if>
</div>
</div>
<div class="cf" style="position: relative; flex: 1 1 0px; min-height: 300px; display: flex; flex-direction: column; overflow-y: auto; border-bottom: 1px solid #EEF0F3">
<sc-if value="{{ dragging }}" hint-placeholder-val="{{ false }}">
<div aria-hidden="true" style="position: absolute; inset: 8px; z-index: 1; pointer-events: none; display: flex; align-items: center; justify-content: center; gap: 8px; border: 2px dashed #0E7C86; border-radius: 8px; background: rgba(243, 248, 248, 0.94); color: #0A5F67; font-weight: 600">
<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="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>놓으면 첨부 파일로 올립니다</span>
</div>
</sc-if>
<sc-if value="{{ isReply }}" hint-placeholder-val="{{ true }}">
<div id="compose-body" class="ce cf-in" onPaste="{{ onBodyPaste }}" contenteditable="true" tabindex="0" role="textbox" aria-multiline="true" aria-label="본문" style="flex: 1 1 auto; max-width: 744px; box-sizing: border-box; padding: 16px 32px 28px; font-family: {{ bodyFont }}; font-size: 14px; line-height: 1.75; color: #1F2733; outline: none">
<p>도윤님, 확인했습니다.</p>
<p>체크리스트 기준으로 <strong>금요일 저녁 전환</strong>을 진행해 주세요.</p>
<ul>
<li>전환 하루 전에 DNS TTL을 300초로 낮추기</li>
<li>기존 서버는 2주 동안 함께 운영</li>
</ul>
<p><br></p>
<p>김아라</p>
<blockquote style="margin: 24px 0 0; padding: 2px 0 2px 14px; border-left: 2px solid #D0D5DD; color: #4A5363">
<div contenteditable="false" style="margin: 0 0 12px; font-size: 12.5px; line-height: 1.6">
<p style="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>김아라님, 안녕하세요.</p>
<p><br></p>
<p>말씀하신 대로 이번 주 금요일(10/30) 저녁 9시에 MX 레코드를 새 메일 서버로 전환하겠습니다.</p>
<p>전환 하루 전에 DNS TTL을 300초로 낮춰 두고, 기존 서버는 2주 동안 함께 운영합니다.</p>
<p><br></p>
<p>전환 체크리스트를 첨부했으니 확인 부탁드립니다.</p>
<p><br></p>
<p>이도윤 드림</p>
</blockquote>
</div>
</sc-if>
<sc-if value="{{ isForward }}" hint-placeholder-val="{{ false }}">
<div id="compose-body" class="ce cf-in" onPaste="{{ onBodyPaste }}" contenteditable="true" tabindex="0" role="textbox" aria-multiline="true" aria-label="본문" style="flex: 1 1 auto; max-width: 744px; box-sizing: border-box; padding: 16px 32px 28px; font-family: {{ bodyFont }}; font-size: 14px; line-height: 1.75; color: #1F2733; outline: none">
<p><br></p>
<blockquote style="margin: 24px 0 0; padding: 2px 0 2px 14px; border-left: 2px solid #D0D5DD; color: #4A5363">
<div contenteditable="false" style="margin: 0 0 12px; font-size: 12.5px; line-height: 1.6">
<p style="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">김아라 &lt;ara.kim@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">인프라팀 &lt;infra@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">Re: 메일 서버 이전 일정 공유</td></tr>
</tbody>
</table>
</div>
<p>김아라님, 안녕하세요.</p>
<p><br></p>
<p>말씀하신 대로 이번 주 금요일(10/30) 저녁 9시에 MX 레코드를 새 메일 서버로 전환하겠습니다.</p>
<p>전환 하루 전에 DNS TTL을 300초로 낮춰 두고, 기존 서버는 2주 동안 함께 운영합니다.</p>
<p><br></p>
<p>전환 체크리스트를 첨부했으니 확인 부탁드립니다.</p>
<p><br></p>
<p>이도윤 드림</p>
</blockquote>
</div>
</sc-if>
<sc-if value="{{ isNew }}" hint-placeholder-val="{{ false }}">
<div id="compose-body" class="ce cf-in" onPaste="{{ onBodyPaste }}" contenteditable="true" tabindex="0" role="textbox" aria-multiline="true" aria-label="본문" data-placeholder="내용을 입력하세요" style="flex: 1 1 auto; max-width: 744px; box-sizing: border-box; padding: 16px 32px 28px; font-family: {{ bodyFont }}; font-size: 14px; line-height: 1.75; color: #1F2733; outline: none"></div>
</sc-if>
</div>
</div>
<div style="display: flex; flex-direction: column; gap: 8px; padding: 10px 32px 14px">
<div style="display: flex; flex-wrap: wrap; align-items: center; gap: 4px 10px">
<button type="button" style="display: inline-flex; align-items: center; gap: 6px; min-height: 44px; margin-left: -6px; padding: 0 10px 0 6px; border: 0; border-radius: 8px; background: transparent; color: #344054; font: inherit; font-weight: 500; cursor: pointer">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21.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>파일 첨부</span>
</button>
<span style="font-size: 12px; color: #667085">파일당 최대 20MB · 문서, 이미지, 압축 파일</span>
<span style="font-size: 12px; color: #667085">파일당 최대 20MB · 합계 25MB · 최대 20개 · 문서, 이미지, 압축 파일</span>
</div>
<sc-if value="{{ hasFiles }}" hint-placeholder-val="{{ true }}">
<ul style="list-style: none; margin: 0; padding: 0; display: grid; grid-template-columns: repeat(auto-fill, minmax(min(280px, 100%), 1fr)); gap: 6px">
@@ -208,33 +629,153 @@ a{color:#0E7C86}a:hover{color:#0A5F67}
</main>
</div>
</div>
<sc-if value="{{ notice }}" hint-placeholder-val="{{ false }}">
<div role="status" style="position: fixed; right: 24px; bottom: 24px; z-index: 40; display: flex; align-items: center; gap: 10px; max-width: calc(100vw - 48px); box-sizing: border-box; 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" onClick="{{ hideNotice }}" aria-label="알림 닫기" style="flex: 0 0 auto; width: 36px; height: 36px; display: inline-flex; align-items: center; justify-content: center; padding: 0; border: 0; border-radius: 6px; background: transparent; color: #A3C2C6; cursor: pointer">
<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"><line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line></svg>
</button>
</div>
</sc-if>
</div>
</x-dc>
<script type="text/x-dc" data-dc-script data-props='{"mode":{"editor":"enum","options":["reply","new"],"default":"reply"},"$preview":{"width":1440,"height":900}}'>
<script type="text/x-dc" data-dc-script data-props='{"mode":{"editor":"enum","options":["reply","forward","new"],"default":"reply"},"bodyFont":{"editor":"enum","options":["malgun","pretendard"],"default":"malgun"},"$preview":{"width":1440,"height":900}}'>
class Component extends DCLogic {
state = { mode: null };
initial(mode) {
// 툴바 서식 상태는 모드마다 처음부터 시작한다
const ui = {
open: null, bold: false, italic: false, underline: false, strike: false,
bullet: false, ordered: false, quote: false, align: 'left',
font: 'base', size: 14, color: 'auto', bg: 'none', rows: 3, cols: 4,
dragging: false, notice: false,
};
if (mode === 'new') {
return { mode: 'new', chips: [], toInput: '', showCc: false, subject: '', body: '', files: [] };
return Object.assign({}, ui, { mode: 'new', chips: [], toInput: '', showCc: false, subject: '', files: [] });
}
return {
if (mode === 'forward') {
return Object.assign({}, ui, {
mode: 'forward', chips: [], toInput: '', showCc: false,
subject: 'Fwd: Re: 메일 서버 이전 일정 공유',
files: [{ name: '메일서버_전환_체크리스트.xlsx', size: '48 KB' }],
});
}
return Object.assign({}, ui, {
mode: 'reply',
chips: [{ label: '이도윤 <doyun.lee@arasystem.it.kr>', invalid: false }],
toInput: '',
showCc: false,
subject: 'Re: 메일 서버 이전 일정 공유',
body: '도윤님, 확인했습니다.\n체크리스트 기준으로 금요일 저녁 전환을 진행해 주세요.\n\n김아라\n\n──────────\n2026. 10. 26. (월) 오전 10:42, 이도윤 <doyun.lee@arasystem.it.kr> 작성:\n> 김아라님, 안녕하세요.\n> 말씀하신 대로 이번 주 금요일(10/30) 저녁 9시에 MX 레코드를 새 메일 서버로 전환하겠습니다.',
files: [{ name: '메일서버_전환_체크리스트_v2.xlsx', size: '52 KB' }],
};
});
}
renderVals() {
const mode = this.props.mode ?? 'reply';
// 모드(답장/새 메일)가 바뀌면 그 모드의 초기값에서 다시 시작한다
// 모드(답장/전달/새 메일)가 바뀌면 그 모드의 초기값에서 다시 시작한다
const data = this.state.mode === mode ? this.state : this.initial(mode);
const update = (patch) => this.setState({ ...data, ...patch });
const update = (patch) => this.setState(Object.assign({}, data, patch));
const EMAIL = /^[^@\s<>]+@[^@\s<>]+\.[^@\s<>]+$/;
const flag = (value) => (value ? 'true' : 'false');
// 본문 기본 글꼴 — 맑은 고딕 스택으로 확정 (편집기 안 모양 = 받는 사람이 보는 모양)
const BODY_FONTS = {
malgun: "'Malgun Gothic', '맑은 고딕', 'Apple SD Gothic Neo', sans-serif",
pretendard: "Pretendard, -apple-system, 'Apple SD Gothic Neo', 'Malgun Gothic', system-ui, sans-serif",
};
const bodyFontId = this.props.bodyFont === 'pretendard' ? 'pretendard' : 'malgun';
const bodyFont = BODY_FONTS[bodyFontId];
const FONTS_KO = [
// 첫 항목이 본문 기본 글꼴 — 고르면 글꼴 지정을 지운다
{ id: 'base', label: bodyFontId === 'pretendard' ? 'Pretendard' : '맑은 고딕', family: bodyFont, isBase: true },
{ id: 'gulim', label: '굴림', family: "Gulim, '굴림', sans-serif" },
{ id: 'dotum', label: '돋움', family: "Dotum, '돋움', sans-serif" },
{ id: 'batang', label: '바탕', family: "Batang, '바탕', serif" },
];
const FONTS_EN = [
{ id: 'arial', label: 'Arial', family: 'Arial, sans-serif' },
{ id: 'verdana', label: 'Verdana', family: 'Verdana, sans-serif' },
{ id: 'georgia', label: 'Georgia', family: 'Georgia, serif' },
{ id: 'times', label: 'Times New Roman', family: "'Times New Roman', serif" },
{ id: 'courier', label: 'Courier New', family: "'Courier New', monospace" },
];
const SIZES = [12, 13, 14, 16, 18, 20, 24, 32];
// 글자 색은 모두 흰 바탕 4.5:1 이상
const TEXT_COLORS = [
{ id: 'auto', name: '기본', hex: '#1F2733', dark: true },
{ id: 'gray', name: '회색', hex: '#667085', dark: true },
{ id: 'red', name: '빨강', hex: '#B42318', dark: true },
{ id: 'orange', name: '주황', hex: '#B54708', dark: true },
{ id: 'green', name: '초록', hex: '#067647', dark: true },
{ id: 'teal', name: '청록', hex: '#0E7C86', dark: true },
{ id: 'blue', name: '파랑', hex: '#175CD3', dark: true },
{ id: 'purple', name: '보라', hex: '#6941C6', dark: true },
];
// 배경 색은 본문 글자(#1F2733)가 10:1 이상 남는 옅은 색만
const BG_COLORS = [
{ id: 'none', name: '없음', hex: '#FFFFFF', none: true },
{ id: 'yellow', name: '노랑', hex: '#FFF1A8' },
{ id: 'orange', name: '주황', hex: '#FDE4CC' },
{ id: 'pink', name: '분홍', hex: '#FBDCE3' },
{ id: 'green', name: '초록', hex: '#D9F2DF' },
{ id: 'sky', name: '하늘', hex: '#D6E8FB' },
{ id: 'purple', name: '보라', hex: '#E9E0FA' },
{ id: 'gray', name: '회색', hex: '#E9EBEF' },
];
const open = data.open;
// 메뉴·팝오버는 한 번에 하나만 열린다
const opener = (name) => () => update({ open: open === name ? null : name });
const close = () => update({ open: null });
const fontItem = (f) => ({
label: f.label,
family: f.family,
isBase: Boolean(f.isBase),
on: f.id === data.font,
checked: flag(f.id === data.font),
pick: () => update({ font: f.id, open: null }),
});
const currentFont = FONTS_KO.concat(FONTS_EN).find((f) => f.id === data.font) ?? FONTS_KO[0];
const sizes = SIZES.map((px) => ({
label: String(px),
px: px + 'px',
isBase: px === 14,
on: px === data.size,
checked: flag(px === data.size),
pick: () => update({ size: px, open: null }),
}));
const swatch = (c, current, key) => ({
name: c.name,
hex: c.hex,
on: c.id === current,
pressed: flag(c.id === current),
slash: Boolean(c.none) && c.id !== current,
checkColor: c.dark ? '#FFFFFF' : '#171C26',
pick: () => update({ [key]: c.id, open: null }),
});
const textColor = TEXT_COLORS.find((c) => c.id === data.color) ?? TEXT_COLORS[0];
const bgColor = BG_COLORS.find((c) => c.id === data.bg) ?? BG_COLORS[0];
// 표 넣기 — 8열 × 6행 격자, 올린 칸까지 고른다
const cells = [];
for (let r = 1; r <= 6; r += 1) {
for (let c = 1; c <= 8; c += 1) {
const on = r <= data.rows && c <= data.cols;
cells.push({
label: r + '행 ' + c + '열 표 넣기',
bg: on ? '#E3F2F3' : '#FFFFFF',
border: on ? '#0E7C86' : '#D0D5DD',
hover: () => {
if (data.rows !== r || data.cols !== c) update({ rows: r, cols: c });
},
pick: close,
});
}
}
const chips = data.chips.map((c, i) => ({
label: c.label,
@@ -252,17 +793,141 @@ class Component extends DCLogic {
remove: () => update({ files: data.files.filter((_, j) => j !== i) }),
}));
const alignTo = (id) => () => update({ align: id, open: null });
// 본문에는 이미지를 넣지 않는다 — 붙여 넣거나 끌어다 놓은 파일은 첨부로 올리고, 이미지였으면 알린다
const formatSize = (bytes) => (bytes >= 1048576 ? (bytes / 1048576).toFixed(1) + ' MB' : Math.max(1, Math.round(bytes / 1024)) + ' KB');
const hasFiles = (e) => Array.prototype.indexOf.call((e.dataTransfer && e.dataTransfer.types) || [], 'Files') !== -1;
const attachFiles = (list) => {
if (list.length === 0) return;
const hasImage = list.some((f) => /^image\//.test(f.type));
update({
files: data.files.concat(list.map((f) => ({ name: f.name || 'image.png', size: formatSize(f.size || 0) }))),
dragging: false,
notice: hasImage,
});
if (hasImage) {
clearTimeout(this.noticeTimer);
// 성공 알림과 같은 5초 (toast.store TOAST_DURATION_MS.success)
this.noticeTimer = setTimeout(() => this.setState({ notice: false }), 5000);
}
};
return {
title: mode === 'new' ? '새 메일' : '답장',
title: mode === 'new' ? '새 메일' : mode === 'forward' ? '전달' : '답장',
chips: chips,
hasInvalid: data.chips.some((c) => c.invalid),
toInput: data.toInput,
showCc: data.showCc,
ccExpanded: data.showCc ? 'true' : 'false',
ccExpanded: flag(data.showCc),
subject: data.subject,
body: data.body,
files: files,
hasFiles: files.length > 0,
isReply: mode === 'reply',
isForward: mode === 'forward',
isNew: mode === 'new',
bodyFont: bodyFont,
undoDisabled: mode !== 'reply',
fontName: currentFont.label,
fontOpen: open === 'font',
fontExpanded: flag(open === 'font'),
openFont: opener('font'),
fontsKo: FONTS_KO.map(fontItem),
fontsEn: FONTS_EN.map(fontItem),
sizeLabel: String(data.size),
sizeOpen: open === 'size',
sizeExpanded: flag(open === 'size'),
openSize: opener('size'),
sizes: sizes,
boldPressed: flag(data.bold),
toggleBold: () => update({ bold: !data.bold }),
italicPressed: flag(data.italic),
toggleItalic: () => update({ italic: !data.italic }),
underlinePressed: flag(data.underline),
toggleUnderline: () => update({ underline: !data.underline }),
colorHex: textColor.hex,
colorName: textColor.name,
colorOpen: open === 'color',
colorExpanded: flag(open === 'color'),
openColor: opener('color'),
textColors: TEXT_COLORS.map((c) => swatch(c, data.color, 'color')),
bgFill: bgColor.none ? 'none' : bgColor.hex,
bgName: bgColor.name,
bgOpen: open === 'bg',
bgExpanded: flag(open === 'bg'),
openBg: opener('bg'),
bgColors: BG_COLORS.map((c) => swatch(c, data.bg, 'bg')),
bulletPressed: flag(data.bullet),
toggleBullet: () => update({ bullet: !data.bullet, ordered: false }),
orderedPressed: flag(data.ordered),
toggleOrdered: () => update({ ordered: !data.ordered, bullet: false }),
bulletOn: data.bullet,
bulletChecked: flag(data.bullet),
toggleBulletMenu: () => update({ bullet: !data.bullet, ordered: false, open: null }),
orderedOn: data.ordered,
orderedChecked: flag(data.ordered),
toggleOrderedMenu: () => update({ ordered: !data.ordered, bullet: false, open: null }),
linkOpen: open === 'link',
linkExpanded: flag(open === 'link'),
openLink: opener('link'),
moreOpen: open === 'more',
moreExpanded: flag(open === 'more' || open === 'table'),
openMore: () => update({ open: open === 'more' || open === 'table' ? null : 'more' }),
strikeOn: data.strike,
strikeChecked: flag(data.strike),
toggleStrike: () => update({ strike: !data.strike, open: null }),
quoteOn: data.quote,
quoteChecked: flag(data.quote),
toggleQuote: () => update({ quote: !data.quote, open: null }),
alignLeftOn: data.align === 'left',
alignLeftChecked: flag(data.align === 'left'),
setAlignLeft: alignTo('left'),
alignCenterOn: data.align === 'center',
alignCenterChecked: flag(data.align === 'center'),
setAlignCenter: alignTo('center'),
alignRightOn: data.align === 'right',
alignRightChecked: flag(data.align === 'right'),
setAlignRight: alignTo('right'),
tableOpen: open === 'table',
openTable: () => update({ open: 'table' }),
cells: cells,
tableSize: data.rows + '행 × ' + data.cols + '열',
closeMenus: close,
onPopKey: (e) => {
if (e.key === 'Escape') close();
},
dragging: data.dragging,
notice: data.notice,
hideNotice: () => update({ notice: false }),
onFormDragOver: (e) => {
if (!hasFiles(e)) return;
e.preventDefault();
if (!data.dragging) update({ dragging: true });
},
onFormDragLeave: (e) => {
if (e.currentTarget.contains(e.relatedTarget)) return;
if (data.dragging) update({ dragging: false });
},
onFormDrop: (e) => {
if (!hasFiles(e)) return;
e.preventDefault();
attachFiles(Array.from(e.dataTransfer.files));
},
onBodyPaste: (e) => {
const images = Array.from((e.clipboardData && e.clipboardData.files) || []).filter((f) => /^image\//.test(f.type));
if (images.length === 0) return;
e.preventDefault();
attachFiles(images);
},
onToInput: (e) => update({ toInput: e.target.value }),
onToKey: (e) => {
if (e.key !== 'Enter' && e.key !== ',') return;
@@ -270,11 +935,10 @@ class Component extends DCLogic {
const value = data.toInput.trim();
if (!value) return;
const address = value.includes('<') ? value.slice(value.indexOf('<') + 1, value.indexOf('>')) : value;
update({ chips: [...data.chips, { label: value, invalid: !EMAIL.test(address) }], toInput: '' });
update({ chips: data.chips.concat([{ label: value, invalid: !EMAIL.test(address) }]), toInput: '' });
},
toggleCc: () => update({ showCc: !data.showCc }),
onSubject: (e) => update({ subject: e.target.value }),
onBody: (e) => update({ body: e.target.value }),
};
}
}
+652
View File
@@ -0,0 +1,652 @@
<!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}
</style>
</helmet>
<div style="width: 1440px; min-height: 3576px; 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: 900px">
<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">본문이 서식 편집기로 바뀝니다. 글자를 쓰는 칸의 포커스는 한 가지 모양으로 보이고, 툴바 버튼은 메일 툴바 버튼과 같은 44px 높이를 씁니다. 본문 글꼴은 맑은 고딕입니다.</p>
</div>
<div style="display: flex; flex-direction: column; gap: 20px">
<div style="display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 20px; align-items: start">
<section style="grid-column: span 2; min-width: 0; 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">받는 사람 · 참조 · 숨은참조 · 제목 · 본문 편집기. 커서가 있는 칸의 아래 선이 2px 브랜드색이 됩니다.</p>
</div>
<div style="display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 18px 16px; align-items: start">
<figure style="margin: 0; min-width: 0; display: flex; flex-direction: column; gap: 8px">
<div style="display: flex; flex-direction: column; border: 1px solid #E6E8EC; border-radius: 8px; background: #FFFFFF; overflow: hidden">
<div style="display: flex; align-items: center; gap: 12px; min-height: 52px; box-sizing: border-box; padding: 4px 10px 4px 32px; border-bottom: 1px solid #0E7C86; box-shadow: inset 0 -1px 0 #0E7C86">
<span style="flex: 0 0 64px; font-size: 12.5px; font-weight: 600; color: #4A5363">받는 사람</span>
<span style="flex: 1 1 auto; min-width: 0; display: flex; align-items: center; gap: 6px">
<span style="display: inline-flex; align-items: center; gap: 2px; min-width: 0; min-height: 28px; box-sizing: border-box; padding: 0 4px 0 8px; border-radius: 4px; background: #F2F4F7; color: #171C26; font-size: 13px">
<span style="min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap">이도윤 &lt;doyun.lee@arasystem.it.kr&gt;</span>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.25" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" style="flex: 0 0 auto; margin: 0 5px"><line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line></svg>
</span>
<span aria-hidden="true" style="flex: 0 0 auto; width: 1px; height: 18px; background: #171C26"></span>
</span>
<span style="flex: 0 0 auto; display: inline-flex; align-items: center; min-height: 44px; padding: 0 10px; color: #0E7C86; font-size: 12.5px; font-weight: 600">참조 · 숨은참조</span>
</div>
<div style="display: flex; align-items: center; gap: 12px; min-height: 44px; box-sizing: border-box; padding: 4px 10px 4px 32px">
<span style="flex: 0 0 64px; font-size: 12.5px; font-weight: 600; color: #4A5363">제목</span>
<span style="font-size: 14px; font-weight: 600">Re: 메일 서버 이전 일정 공유</span>
</div>
</div>
<figcaption style="font-size: 12.5px; color: #344054"><strong style="font-weight: 600">① 받는 사람 · 참조 · 숨은참조</strong> — 칩까지 포함한 행 전체의 아래 선</figcaption>
</figure>
<figure style="margin: 0; min-width: 0; display: flex; flex-direction: column; gap: 8px">
<div style="display: flex; flex-direction: column; border: 1px solid #E6E8EC; border-radius: 8px; background: #FFFFFF; overflow: hidden">
<div style="display: flex; align-items: center; gap: 12px; min-height: 52px; box-sizing: border-box; padding: 4px 10px 4px 32px; border-bottom: 1px solid #EEF0F3">
<span style="flex: 0 0 64px; font-size: 12.5px; font-weight: 600; color: #4A5363">받는 사람</span>
<span style="flex: 1 1 auto; min-width: 0; display: flex; align-items: center">
<span style="display: inline-flex; align-items: center; gap: 2px; min-width: 0; min-height: 28px; box-sizing: border-box; padding: 0 4px 0 8px; border-radius: 4px; background: #F2F4F7; color: #171C26; font-size: 13px">
<span style="min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap">이도윤 &lt;doyun.lee@arasystem.it.kr&gt;</span>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.25" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" style="flex: 0 0 auto; margin: 0 5px"><line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line></svg>
</span>
</span>
<span style="flex: 0 0 auto; display: inline-flex; align-items: center; min-height: 44px; padding: 0 10px; color: #0E7C86; font-size: 12.5px; font-weight: 600">참조 · 숨은참조</span>
</div>
<div style="display: flex; align-items: center; gap: 12px; min-height: 44px; box-sizing: border-box; padding: 4px 10px 4px 32px; border-bottom: 1px solid #0E7C86; box-shadow: inset 0 -1px 0 #0E7C86">
<span style="flex: 0 0 64px; font-size: 12.5px; font-weight: 600; color: #4A5363">제목</span>
<span style="display: inline-flex; align-items: center; gap: 1px; font-size: 14px; font-weight: 600">Re: 메일 서버 이전 일정 공유<span aria-hidden="true" style="width: 1px; height: 18px; background: #171C26"></span></span>
</div>
</div>
<figcaption style="font-size: 12.5px; color: #344054"><strong style="font-weight: 600">② 제목</strong> — 같은 자리, 같은 두께</figcaption>
</figure>
<figure style="margin: 0; min-width: 0; display: flex; flex-direction: column; gap: 8px">
<div style="display: flex; flex-direction: column; border: 1px solid #E6E8EC; border-radius: 8px; background: #FFFFFF; overflow: hidden">
<div style="display: flex; align-items: center; gap: 2px; min-height: 52px; box-sizing: border-box; padding: 4px 20px; border-bottom: 1px solid #EEF0F3">
<button type="button" aria-label="굵게" style="width: 40px; height: 44px; flex: 0 0 auto; display: inline-flex; align-items: center; justify-content: center; padding: 0; border: 0; border-radius: 8px; background: transparent; color: #344054; 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" aria-label="기울임" style="width: 40px; height: 44px; flex: 0 0 auto; display: inline-flex; align-items: center; justify-content: center; padding: 0; border: 0; border-radius: 8px; background: transparent; color: #344054; 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" aria-label="밑줄" style="width: 40px; height: 44px; flex: 0 0 auto; display: inline-flex; align-items: center; justify-content: center; padding: 0; border: 0; border-radius: 8px; background: transparent; color: #344054; 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" aria-label="글머리 목록" style="width: 40px; height: 44px; flex: 0 0 auto; display: inline-flex; align-items: center; justify-content: center; padding: 0; border: 0; border-radius: 8px; background: transparent; color: #344054; 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="9" y1="6" x2="20" y2="6"></line><line x1="9" y1="12" x2="20" y2="12"></line><line x1="9" y1="18" x2="20" y2="18"></line><circle cx="4.5" cy="6" r="1.2" fill="currentColor" stroke="none"></circle><circle cx="4.5" cy="12" r="1.2" fill="currentColor" stroke="none"></circle><circle cx="4.5" cy="18" r="1.2" fill="currentColor" stroke="none"></circle></svg></button>
<button type="button" aria-label="번호 목록" style="width: 40px; height: 44px; flex: 0 0 auto; display: inline-flex; align-items: center; justify-content: center; padding: 0; border: 0; border-radius: 8px; background: transparent; color: #344054; 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="10" y1="6" x2="21" y2="6"></line><line x1="10" y1="12" x2="21" y2="12"></line><line x1="10" y1="18" x2="21" y2="18"></line><path d="M4 6h1v4"></path><path d="M4 10h2"></path><path d="M6 18H4c0-1 2-2 2-3s-1-1.5-2-1"></path></svg></button>
<span aria-hidden="true" style="flex: 0 0 auto; width: 1px; height: 20px; margin: 0 4px; background: #E6E8EC"></span>
<button type="button" aria-label="링크" style="width: 40px; height: 44px; flex: 0 0 auto; display: inline-flex; align-items: center; justify-content: center; padding: 0; border: 0; border-radius: 8px; background: transparent; color: #344054; 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>
<span aria-hidden="true" style="flex: 0 0 auto; width: 1px; height: 20px; margin: 0 4px; background: #E6E8EC"></span>
<button type="button" aria-label="서식 더보기" style="width: 40px; height: 44px; flex: 0 0 auto; display: inline-flex; align-items: center; justify-content: center; padding: 0; border: 0; border-radius: 8px; background: transparent; color: #344054; cursor: pointer"><svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor" stroke="none" aria-hidden="true"><circle cx="5" cy="12" r="1.6"></circle><circle cx="12" cy="12" r="1.6"></circle><circle cx="19" cy="12" r="1.6"></circle></svg></button>
</div>
<div style="min-height: 104px; box-sizing: border-box; padding: 14px 32px 16px; border-bottom: 1px solid #0E7C86; box-shadow: inset 0 -1px 0 #0E7C86; font-family: 'Malgun Gothic', '맑은 고딕', 'Apple SD Gothic Neo', sans-serif; font-size: 14px; line-height: 1.75; color: #1F2733">
<p style="margin: 0">도윤님, 확인했습니다.<span aria-hidden="true" style="display: inline-block; width: 1px; height: 17px; margin-left: 1px; vertical-align: -3px; background: #171C26"></span></p>
<p style="margin: 0">체크리스트 기준으로 <strong>금요일 저녁 전환</strong>을 진행해 주세요.</p>
</div>
<div style="display: flex; align-items: center; gap: 6px; min-height: 44px; box-sizing: border-box; padding: 0 32px 0 26px; color: #344054; font-weight: 500">
<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" style="margin-left: 6px"><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>파일 첨부</span>
</div>
</div>
<figcaption style="font-size: 12.5px; color: #344054"><strong style="font-weight: 600">③ 본문 편집기</strong> — 편집 영역 아래 선. 툴바 아래 선은 그대로</figcaption>
</figure>
<figure style="margin: 0; min-width: 0; display: flex; flex-direction: column; gap: 8px">
<div style="display: flex; flex-direction: column; border: 1px solid #E6E8EC; border-radius: 8px; background: #FFFFFF; overflow: hidden">
<div style="display: flex; align-items: center; gap: 12px; min-height: 52px; box-sizing: border-box; padding: 4px 10px 4px 32px; border-bottom: 1px solid #EEF0F3">
<span style="flex: 0 0 64px; font-size: 12.5px; font-weight: 600; color: #4A5363">받는 사람</span>
<span style="flex: 1 1 auto; min-width: 0; display: flex; align-items: center">
<span style="display: inline-flex; align-items: center; gap: 2px; min-width: 0; min-height: 28px; box-sizing: border-box; padding: 0 4px 0 8px; border-radius: 4px; background: #F2F4F7; color: #171C26; font-size: 13px">
<span style="min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap">이도윤 &lt;doyun.lee@arasystem.it.kr&gt;</span>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.25" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" style="flex: 0 0 auto; margin: 0 5px"><line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line></svg>
</span>
</span>
<button type="button" aria-expanded="false" style="flex: 0 0 auto; min-height: 44px; padding: 0 10px; border: 0; border-radius: 8px; background: transparent; color: #0E7C86; font: inherit; font-size: 12.5px; font-weight: 600; outline: 2px solid #0E7C86; outline-offset: 2px; cursor: pointer">참조 · 숨은참조</button>
</div>
<div style="display: flex; align-items: center; gap: 12px; min-height: 44px; box-sizing: border-box; padding: 4px 10px 4px 32px">
<span style="flex: 0 0 64px; font-size: 12.5px; font-weight: 600; color: #4A5363">제목</span>
<span style="font-size: 14px; font-weight: 600">Re: 메일 서버 이전 일정 공유</span>
</div>
</div>
<figcaption style="font-size: 12.5px; color: #344054"><strong style="font-weight: 600">④ 행 안의 버튼</strong> — 버튼에만 고리. 행의 선은 회색 그대로</figcaption>
</figure>
</div>
<div style="display: flex; flex-direction: column; gap: 6px">
<span style="font-size: 12px; font-weight: 600; color: #4A5363">구현 — tokens.css 변수</span>
<code style="display: block; padding: 10px 12px; border-radius: 8px; background: #F6F7F9; font-family: Consolas, 'SFMono-Regular', Menlo, monospace; font-size: 12px; line-height: 1.65; color: #344054">.compose-field:has(:is(input, .ProseMirror):focus)<br>&nbsp;&nbsp;border-bottom-color: var(--color-brand)<br>&nbsp;&nbsp;box-shadow: inset 0 calc(var(--border-width) * -1) 0 var(--color-brand)<br>.compose-field :is(input, .ProseMirror):focus-visible → outline: none</code>
</div>
</section>
<section style="min-width: 0; 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>
<dl style="margin: 0; display: grid; grid-template-columns: 64px minmax(0, 1fr); gap: 9px 12px; font-size: 12.5px; line-height: 1.55">
<dt style="color: #667085">대상</dt>
<dd style="margin: 0">받는 사람 · 참조 · 숨은참조 · 제목 · 본문 편집기</dd>
<dt style="color: #667085">위치</dt>
<dd style="margin: 0">그 칸의 아래 hairline 자리. 본문은 편집 영역 아래(첨부 영역 위)</dd>
<dt style="color: #667085">두께 · 색</dt>
<dd style="margin: 0">2px #0E7C86 — 1px 선 색을 바꾸고 안쪽 1px 를 더해 높이가 변하지 않는다</dd>
<dt style="color: #667085">모서리</dt>
<dd style="margin: 0">없음 (곧은 선)</dd>
<dt style="color: #667085">범위</dt>
<dd style="margin: 0">행 전체 폭 — 원래 hairline 과 같은 길이 (콘텐츠 열, 최대 800px)</dd>
<dt style="color: #667085">언제</dt>
<dd style="margin: 0">글자 입력 요소에 포커스가 있을 때. 마우스로 눌러도 보인다</dd>
<dt style="color: #667085">대비</dt>
<dd style="margin: 0">흰 바탕 4.9:1 · 원래 선 #EEF0F3 4.3:1 · 오류 띠 #FEF3F2 4.5:1</dd>
<dt style="color: #667085">버튼</dt>
<dd style="margin: 0">2px 고리, 바깥 2px (전역 규칙 그대로). 누르는 것은 고리, 쓰는 곳은 선 — 한 번에 하나만 보인다</dd>
<dt style="color: #667085">상자 입력</dt>
<dd style="margin: 0">테두리가 있는 입력칸(링크 팝오버 · 로그인)은 상자가 있으므로 버튼처럼 고리</dd>
</dl>
</section>
</div>
<div style="display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 20px; align-items: start">
<section style="grid-column: span 2; min-width: 0; 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">본문 툴바 — 800px 열</h2>
<p style="margin: 0; font-size: 12px; color: #667085">버튼 40×44 (폭 40 은 밀도 예외 · 사용자 확정) · 아이콘 16 · 버튼 사이 2 · 묶음 사이 세로선 1×20 · 툴바 높이 52 (위아래 4)</p>
</div>
<div style="position: relative; width: 800px; border: 1px solid #E6E8EC; border-radius: 8px; background: #FFFFFF">
<div role="toolbar" aria-label="본문 서식 (예시)" style="display: flex; align-items: center; gap: 2px; min-height: 52px; box-sizing: border-box; padding: 4px 20px; border-bottom: 1px solid #EEF0F3">
<div style="display: flex; align-items: center; gap: 2px">
<button type="button" aria-label="실행 취소" style="width: 40px; height: 44px; flex: 0 0 auto; display: inline-flex; align-items: center; justify-content: center; padding: 0; border: 0; border-radius: 8px; background: transparent; color: #344054; 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="M9 14 4 9l5-5"></path><path d="M4 9h10.5a5.5 5.5 0 0 1 0 11H11"></path></svg></button>
<button type="button" aria-label="다시 실행" disabled="{{ true }}" style="width: 40px; height: 44px; flex: 0 0 auto; display: inline-flex; align-items: center; justify-content: center; padding: 0; border: 0; border-radius: 8px; background: transparent; color: #344054; opacity: 0.5"><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="m15 14 5-5-5-5"></path><path d="M20 9H9.5a5.5 5.5 0 0 0 0 11H13"></path></svg></button>
<span aria-hidden="true" style="flex: 0 0 auto; width: 1px; height: 20px; margin: 0 4px; background: #E6E8EC"></span>
</div>
<div style="display: flex; align-items: center; gap: 2px">
<button type="button" aria-haspopup="menu" aria-label="글꼴: 맑은 고딕" style="width: 108px; height: 44px; flex: 0 0 auto; display: inline-flex; align-items: center; gap: 4px; box-sizing: border-box; padding: 0 8px 0 12px; border: 0; border-radius: 8px; background: transparent; color: #344054; font: inherit; font-weight: 500; cursor: pointer"><span style="flex: 1 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; text-align: left">맑은 고딕</span><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.25" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" style="flex: 0 0 auto"><polyline points="6 9 12 15 18 9"></polyline></svg></button>
<button type="button" aria-haspopup="menu" aria-label="글자 크기: 14" style="width: 56px; height: 44px; flex: 0 0 auto; display: inline-flex; align-items: center; gap: 4px; box-sizing: border-box; padding: 0 8px 0 12px; border: 0; border-radius: 8px; background: transparent; color: #344054; font: inherit; font-weight: 500; cursor: pointer"><span style="flex: 1 1 auto; text-align: left">14</span><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.25" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" style="flex: 0 0 auto"><polyline points="6 9 12 15 18 9"></polyline></svg></button>
<span aria-hidden="true" style="flex: 0 0 auto; width: 1px; height: 20px; margin: 0 4px; background: #E6E8EC"></span>
</div>
<div style="display: flex; align-items: center; gap: 2px">
<button type="button" aria-label="굵게" style="width: 40px; height: 44px; flex: 0 0 auto; display: inline-flex; align-items: center; justify-content: center; padding: 0; border: 0; border-radius: 8px; background: transparent; color: #344054; 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" aria-label="기울임" style="width: 40px; height: 44px; flex: 0 0 auto; display: inline-flex; align-items: center; justify-content: center; padding: 0; border: 0; border-radius: 8px; background: transparent; color: #344054; 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" aria-label="밑줄" style="width: 40px; height: 44px; flex: 0 0 auto; display: inline-flex; align-items: center; justify-content: center; padding: 0; border: 0; border-radius: 8px; background: transparent; color: #344054; 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>
</div>
<div style="display: flex; align-items: center; gap: 2px">
<button type="button" aria-haspopup="dialog" aria-label="글자 색: 기본" style="width: 40px; height: 44px; flex: 0 0 auto; display: inline-flex; align-items: center; justify-content: center; padding: 0; border: 0; border-radius: 8px; background: transparent; color: #344054; 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.5 14.5 12 3.5l5.5 11"></path><path d="M8.5 10.5h7"></path><rect x="4.5" y="18" width="15" height="3" rx="1" fill="#1F2733" stroke="none"></rect></svg></button>
<button type="button" aria-haspopup="dialog" aria-label="배경 색: 없음" style="width: 40px; height: 44px; flex: 0 0 auto; display: inline-flex; align-items: center; justify-content: center; padding: 0; border: 0; border-radius: 8px; background: transparent; color: #344054; 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.5" y="3.5" width="17" height="17" rx="3" fill="none"></rect><path d="M8 16.5 12 7.5l4 9"></path><path d="M9.3 13.5h5.4"></path></svg></button>
<span aria-hidden="true" style="flex: 0 0 auto; width: 1px; height: 20px; margin: 0 4px; background: #E6E8EC"></span>
</div>
<div style="display: flex; align-items: center; gap: 2px">
<button type="button" aria-label="글머리 목록" style="width: 40px; height: 44px; flex: 0 0 auto; display: inline-flex; align-items: center; justify-content: center; padding: 0; border: 0; border-radius: 8px; background: transparent; color: #344054; 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="9" y1="6" x2="20" y2="6"></line><line x1="9" y1="12" x2="20" y2="12"></line><line x1="9" y1="18" x2="20" y2="18"></line><circle cx="4.5" cy="6" r="1.2" fill="currentColor" stroke="none"></circle><circle cx="4.5" cy="12" r="1.2" fill="currentColor" stroke="none"></circle><circle cx="4.5" cy="18" r="1.2" fill="currentColor" stroke="none"></circle></svg></button>
<button type="button" aria-label="번호 목록" style="width: 40px; height: 44px; flex: 0 0 auto; display: inline-flex; align-items: center; justify-content: center; padding: 0; border: 0; border-radius: 8px; background: transparent; color: #344054; 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="10" y1="6" x2="21" y2="6"></line><line x1="10" y1="12" x2="21" y2="12"></line><line x1="10" y1="18" x2="21" y2="18"></line><path d="M4 6h1v4"></path><path d="M4 10h2"></path><path d="M6 18H4c0-1 2-2 2-3s-1-1.5-2-1"></path></svg></button>
<span aria-hidden="true" style="flex: 0 0 auto; width: 1px; height: 20px; margin: 0 4px; background: #E6E8EC"></span>
</div>
<div style="display: flex; align-items: center; gap: 2px">
<button type="button" aria-haspopup="dialog" aria-label="링크" style="width: 40px; height: 44px; flex: 0 0 auto; display: inline-flex; align-items: center; justify-content: center; padding: 0; border: 0; border-radius: 8px; background: transparent; color: #344054; 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>
<span aria-hidden="true" style="flex: 0 0 auto; width: 1px; height: 20px; margin: 0 4px; background: #E6E8EC"></span>
</div>
<button type="button" aria-haspopup="menu" aria-label="서식 더보기" style="width: 40px; height: 44px; flex: 0 0 auto; display: inline-flex; align-items: center; justify-content: center; padding: 0; border: 0; border-radius: 8px; background: transparent; color: #344054; cursor: pointer"><svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor" stroke="none" aria-hidden="true"><circle cx="5" cy="12" r="1.6"></circle><circle cx="12" cy="12" r="1.6"></circle><circle cx="19" cy="12" r="1.6"></circle></svg></button>
</div>
<div aria-hidden="true" style="display: flex; gap: 2px; padding: 6px 20px 8px; font-size: 11.5px; line-height: 1.4; color: #667085">
<span style="flex: 0 0 93px">실행 취소</span>
<span style="flex: 0 0 177px">글꼴 · 크기</span>
<span style="flex: 0 0 135px">굵게 · 기울임 · 밑줄</span>
<span style="flex: 0 0 93px">글자 · 배경 색</span>
<span style="flex: 0 0 93px">목록</span>
<span style="flex: 0 0 51px">링크</span>
<span style="flex: 0 0 40px">더보기</span>
</div>
<span aria-hidden="true" style="position: absolute; left: 32px; top: 0; bottom: 0; width: 1px; background: #F97066"></span>
</div>
<ul style="margin: 0; padding-left: 18px; display: flex; flex-direction: column; gap: 3px; font-size: 12.5px; color: #4A5363">
<li>붉은 세로선이 콘텐츠 열 32px 선 — 첫 아이콘(실행 취소)이 여기서 시작한다 (툴바 왼쪽 여백 20 + 버튼 안 12).</li>
<li>취소선 · 정렬 · 내어쓰기 · 들여쓰기 · 인용 · 표 · 가로줄 · 서식 지우기는 늘 더보기에 있다.</li>
<li>이미지 버튼은 두지 않는다 — 본문에 넣은 이미지는 첨부 파일로 올린다 (맨 아래 카드).</li>
<li>키보드: 툴바는 Tab 한 번에 들어가 화살표로 옮긴다. 본문에서 Alt+F10 → 툴바, Esc → 본문.</li>
</ul>
</section>
<section style="min-width: 0; 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">MailToolButton 과 같은 높이 44 · 모서리 8 · 글자색</p>
</div>
<div style="display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 10px">
<div style="display: flex; flex-direction: column; align-items: center; gap: 4px; padding: 12px 6px; border: 1px solid #EEF0F3; border-radius: 8px; text-align: center">
<button type="button" aria-label="굵게" style="width: 40px; height: 44px; display: inline-flex; align-items: center; justify-content: center; padding: 0; border: 0; border-radius: 8px; background: transparent; color: #344054; 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>
<span style="margin-top: 2px; font-size: 12.5px; font-weight: 600">기본</span>
<span style="font-size: 11.5px; color: #667085">바탕 없음 · 아이콘 #344054</span>
</div>
<div style="display: flex; flex-direction: column; align-items: center; gap: 4px; padding: 12px 6px; border: 1px solid #EEF0F3; border-radius: 8px; text-align: center">
<button type="button" aria-label="굵게" style="width: 40px; height: 44px; display: inline-flex; align-items: center; justify-content: center; padding: 0; border: 0; border-radius: 8px; background: #F2F4F7; color: #171C26; 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>
<span style="margin-top: 2px; font-size: 12.5px; font-weight: 600">마우스 올림</span>
<span style="font-size: 11.5px; color: #667085">바탕 #F2F4F7 · 아이콘 #171C26</span>
</div>
<div style="display: flex; flex-direction: column; align-items: center; gap: 4px; padding: 12px 6px; border: 1px solid #EEF0F3; border-radius: 8px; text-align: center">
<button type="button" aria-label="굵게" aria-pressed="true" style="width: 40px; height: 44px; display: inline-flex; align-items: center; justify-content: center; padding: 0; border: 0; border-radius: 8px; background: #E3F2F3; box-shadow: inset 0 0 0 1px #0E7C86; color: #0A5F67; 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>
<span style="margin-top: 2px; font-size: 12.5px; font-weight: 600">켜짐 (눌림)</span>
<span style="font-size: 11.5px; color: #667085">바탕 #E3F2F3 · 안쪽 1px #0E7C86 · 아이콘 #0A5F67</span>
</div>
<div style="display: flex; flex-direction: column; align-items: center; gap: 4px; padding: 12px 6px; border: 1px solid #EEF0F3; border-radius: 8px; text-align: center">
<button type="button" aria-label="다시 실행" disabled="{{ true }}" style="width: 40px; height: 44px; display: inline-flex; align-items: center; justify-content: center; padding: 0; border: 0; border-radius: 8px; background: transparent; color: #344054; opacity: 0.5"><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="m15 14 5-5-5-5"></path><path d="M20 9H9.5a5.5 5.5 0 0 0 0 11H13"></path></svg></button>
<span style="margin-top: 2px; font-size: 12.5px; font-weight: 600">비활성</span>
<span style="font-size: 11.5px; color: #667085">50% 투명 (MailToolButton 과 같다)</span>
</div>
<div style="display: flex; flex-direction: column; align-items: center; gap: 4px; padding: 12px 6px; border: 1px solid #EEF0F3; border-radius: 8px; text-align: center">
<button type="button" aria-label="굵게" style="width: 40px; height: 44px; display: inline-flex; align-items: center; justify-content: center; padding: 0; border: 0; border-radius: 8px; background: transparent; color: #344054; outline: 2px solid #0E7C86; outline-offset: 2px; 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>
<span style="margin-top: 2px; font-size: 12.5px; font-weight: 600">포커스</span>
<span style="font-size: 11.5px; color: #667085">2px #0E7C86 고리 · 바깥 2px</span>
</div>
<div style="display: flex; flex-direction: column; align-items: center; gap: 4px; padding: 12px 6px; border: 1px solid #EEF0F3; border-radius: 8px; text-align: center">
<button type="button" aria-haspopup="menu" aria-expanded="true" aria-label="글꼴: 맑은 고딕" style="width: 108px; max-width: 100%; height: 44px; display: inline-flex; align-items: center; gap: 4px; box-sizing: border-box; padding: 0 8px 0 12px; border: 0; border-radius: 8px; background: #F2F4F7; color: #171C26; font: inherit; font-weight: 500; cursor: pointer"><span style="flex: 1 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; text-align: left">맑은 고딕</span><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.25" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" style="flex: 0 0 auto"><polyline points="6 9 12 15 18 9"></polyline></svg></button>
<span style="margin-top: 2px; font-size: 12.5px; font-weight: 600">메뉴 열림</span>
<span style="font-size: 11.5px; color: #667085">올림과 같은 바탕</span>
</div>
</div>
<p style="margin: 0; font-size: 12px; color: #4A5363">켜짐에 안쪽 1px 선을 두는 까닭 — 옅은 바탕(#E3F2F3)만으로는 흰 바탕과의 대비가 3:1 에 못 미친다. 선은 4.3:1.</p>
<div style="display: flex; flex-direction: column; gap: 4px; padding: 10px 12px; border-radius: 8px; background: #F6F7F9">
<span style="font-size: 12.5px; font-weight: 600; color: #344054">밀도 예외 — 사용자 확정 (2026-10-27)</span>
<span style="font-size: 12px; color: #4A5363">클릭 영역 44px 기준의 의도된 예외: 툴바 버튼 폭 40 (높이 44) · 메뉴 항목 높이 40 · 색 견본 칸 40×40 · 표 고르기 칸 22 (간격 3, 키보드는 화살표)</span>
</div>
</section>
</div>
<div style="display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 20px; align-items: start">
<section style="grid-column: span 2; min-width: 0; display: flex; flex-direction: column; gap: 14px; padding: 20px; background: #FFFFFF; border: 1px solid #E6E8EC; border-radius: 12px">
<div style="display: flex; flex-direction: column; gap: 2px">
<h2 style="margin: 0; font-size: 14px; font-weight: 700">좁은 폭 — 넘치는 묶음은 더보기로</h2>
<p style="margin: 0; font-size: 12px; color: #667085">툴바는 늘 한 줄입니다. 정해진 순서로 묶음째 더보기에 들어가고, 더보기 단추에는 숫자를 달지 않습니다.</p>
</div>
<div style="display: flex; gap: 20px; align-items: flex-start">
<div style="flex: 0 0 600px; min-width: 0; display: flex; flex-direction: column; gap: 14px">
<figure style="margin: 0; display: flex; flex-direction: column; gap: 6px">
<figcaption style="font-size: 12.5px; font-weight: 600; color: #344054">쓰기 칸 600px</figcaption>
<div style="width: 600px; box-sizing: border-box; border: 1px solid #E6E8EC; border-radius: 8px; background: #FFFFFF">
<div style="display: flex; align-items: center; gap: 2px; min-height: 52px; box-sizing: border-box; padding: 4px 20px">
<button type="button" aria-label="굵게" style="width: 40px; height: 44px; flex: 0 0 auto; display: inline-flex; align-items: center; justify-content: center; padding: 0; border: 0; border-radius: 8px; background: transparent; color: #344054; 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" aria-label="기울임" style="width: 40px; height: 44px; flex: 0 0 auto; display: inline-flex; align-items: center; justify-content: center; padding: 0; border: 0; border-radius: 8px; background: transparent; color: #344054; 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" aria-label="밑줄" style="width: 40px; height: 44px; flex: 0 0 auto; display: inline-flex; align-items: center; justify-content: center; padding: 0; border: 0; border-radius: 8px; background: transparent; color: #344054; 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" aria-label="글자 색" style="width: 40px; height: 44px; flex: 0 0 auto; display: inline-flex; align-items: center; justify-content: center; padding: 0; border: 0; border-radius: 8px; background: transparent; color: #344054; 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.5 14.5 12 3.5l5.5 11"></path><path d="M8.5 10.5h7"></path><rect x="4.5" y="18" width="15" height="3" rx="1" fill="#1F2733" stroke="none"></rect></svg></button>
<button type="button" aria-label="배경 색" style="width: 40px; height: 44px; flex: 0 0 auto; display: inline-flex; align-items: center; justify-content: center; padding: 0; border: 0; border-radius: 8px; background: transparent; color: #344054; 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.5" y="3.5" width="17" height="17" rx="3" fill="none"></rect><path d="M8 16.5 12 7.5l4 9"></path><path d="M9.3 13.5h5.4"></path></svg></button>
<span aria-hidden="true" style="flex: 0 0 auto; width: 1px; height: 20px; margin: 0 4px; background: #E6E8EC"></span>
<button type="button" aria-label="글머리 목록" style="width: 40px; height: 44px; flex: 0 0 auto; display: inline-flex; align-items: center; justify-content: center; padding: 0; border: 0; border-radius: 8px; background: transparent; color: #344054; 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="9" y1="6" x2="20" y2="6"></line><line x1="9" y1="12" x2="20" y2="12"></line><line x1="9" y1="18" x2="20" y2="18"></line><circle cx="4.5" cy="6" r="1.2" fill="currentColor" stroke="none"></circle><circle cx="4.5" cy="12" r="1.2" fill="currentColor" stroke="none"></circle><circle cx="4.5" cy="18" r="1.2" fill="currentColor" stroke="none"></circle></svg></button>
<button type="button" aria-label="번호 목록" style="width: 40px; height: 44px; flex: 0 0 auto; display: inline-flex; align-items: center; justify-content: center; padding: 0; border: 0; border-radius: 8px; background: transparent; color: #344054; 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="10" y1="6" x2="21" y2="6"></line><line x1="10" y1="12" x2="21" y2="12"></line><line x1="10" y1="18" x2="21" y2="18"></line><path d="M4 6h1v4"></path><path d="M4 10h2"></path><path d="M6 18H4c0-1 2-2 2-3s-1-1.5-2-1"></path></svg></button>
<span aria-hidden="true" style="flex: 0 0 auto; width: 1px; height: 20px; margin: 0 4px; background: #E6E8EC"></span>
<button type="button" aria-label="링크" style="width: 40px; height: 44px; flex: 0 0 auto; display: inline-flex; align-items: center; justify-content: center; padding: 0; border: 0; border-radius: 8px; background: transparent; color: #344054; 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>
<span aria-hidden="true" style="flex: 0 0 auto; width: 1px; height: 20px; margin: 0 4px; background: #E6E8EC"></span>
<button type="button" aria-label="서식 더보기" style="width: 40px; height: 44px; flex: 0 0 auto; display: inline-flex; align-items: center; justify-content: center; padding: 0; border: 0; border-radius: 8px; background: transparent; color: #344054; cursor: pointer"><svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor" stroke="none" aria-hidden="true"><circle cx="5" cy="12" r="1.6"></circle><circle cx="12" cy="12" r="1.6"></circle><circle cx="19" cy="12" r="1.6"></circle></svg></button>
</div>
</div>
</figure>
<figure style="margin: 0; display: flex; flex-direction: column; gap: 6px">
<figcaption style="font-size: 12.5px; font-weight: 600; color: #344054">쓰기 칸 340px (휴대폰)</figcaption>
<div style="width: 340px; box-sizing: border-box; border: 1px solid #E6E8EC; border-radius: 8px; background: #FFFFFF">
<div style="display: flex; align-items: center; gap: 2px; min-height: 52px; box-sizing: border-box; padding: 4px 20px">
<button type="button" aria-label="굵게" style="width: 40px; height: 44px; flex: 0 0 auto; display: inline-flex; align-items: center; justify-content: center; padding: 0; border: 0; border-radius: 8px; background: transparent; color: #344054; 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" aria-label="기울임" style="width: 40px; height: 44px; flex: 0 0 auto; display: inline-flex; align-items: center; justify-content: center; padding: 0; border: 0; border-radius: 8px; background: transparent; color: #344054; 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" aria-label="밑줄" style="width: 40px; height: 44px; flex: 0 0 auto; display: inline-flex; align-items: center; justify-content: center; padding: 0; border: 0; border-radius: 8px; background: transparent; color: #344054; 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" aria-label="글머리 목록" style="width: 40px; height: 44px; flex: 0 0 auto; display: inline-flex; align-items: center; justify-content: center; padding: 0; border: 0; border-radius: 8px; background: transparent; color: #344054; 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="9" y1="6" x2="20" y2="6"></line><line x1="9" y1="12" x2="20" y2="12"></line><line x1="9" y1="18" x2="20" y2="18"></line><circle cx="4.5" cy="6" r="1.2" fill="currentColor" stroke="none"></circle><circle cx="4.5" cy="12" r="1.2" fill="currentColor" stroke="none"></circle><circle cx="4.5" cy="18" r="1.2" fill="currentColor" stroke="none"></circle></svg></button>
<button type="button" aria-label="번호 목록" style="width: 40px; height: 44px; flex: 0 0 auto; display: inline-flex; align-items: center; justify-content: center; padding: 0; border: 0; border-radius: 8px; background: transparent; color: #344054; 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="10" y1="6" x2="21" y2="6"></line><line x1="10" y1="12" x2="21" y2="12"></line><line x1="10" y1="18" x2="21" y2="18"></line><path d="M4 6h1v4"></path><path d="M4 10h2"></path><path d="M6 18H4c0-1 2-2 2-3s-1-1.5-2-1"></path></svg></button>
<span aria-hidden="true" style="flex: 0 0 auto; width: 1px; height: 20px; margin: 0 4px; background: #E6E8EC"></span>
<button type="button" aria-haspopup="menu" aria-expanded="true" aria-label="서식 더보기" style="width: 40px; height: 44px; flex: 0 0 auto; display: inline-flex; align-items: center; justify-content: center; padding: 0; border: 0; border-radius: 8px; background: #F2F4F7; color: #171C26; cursor: pointer"><svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor" stroke="none" aria-hidden="true"><circle cx="5" cy="12" r="1.6"></circle><circle cx="12" cy="12" r="1.6"></circle><circle cx="19" cy="12" r="1.6"></circle></svg></button>
</div>
</div>
</figure>
<ol style="margin: 0; padding-left: 20px; display: flex; flex-direction: column; gap: 3px; font-size: 12.5px; color: #344054">
<li>실행 취소 · 다시 실행 — 쓰기 칸 734px 미만 (Ctrl+Z · Ctrl+Y 는 그대로)</li>
<li>글꼴 · 글자 크기 — 639px 미만</li>
<li>글자 색 · 배경 색 — 460px 미만</li>
<li>링크 — 365px 미만 (주소를 붙여 넣으면 저절로 링크가 된다)</li>
<li>글머리 · 번호 목록 — 312px 미만</li>
</ol>
<p style="margin: 0; font-size: 12px; color: #4A5363">굵게 · 기울임 · 밑줄과 더보기는 늘 남습니다. 폭은 지금 크기로 셈한 참고값이고, 구현은 툴바의 실제 폭을 재어(ResizeObserver) 이 순서로 넘깁니다.</p>
</div>
<figure style="flex: 1 1 auto; min-width: 0; margin: 0; display: flex; flex-direction: column; gap: 6px">
<figcaption style="font-size: 12.5px; font-weight: 600; color: #344054">340px 에서 연 더보기 (위쪽)</figcaption>
<div role="menu" aria-label="서식 더보기 (예시)" style="width: 220px; box-sizing: border-box; padding: 6px; display: flex; flex-direction: column; background: #FFFFFF; border: 1px solid #E6E8EC; border-radius: 10px; box-shadow: 0 10px 28px rgba(16, 24, 28, 0.12)">
<button type="button" role="menuitem" style="display: flex; align-items: center; gap: 10px; width: 100%; min-height: 40px; box-sizing: border-box; padding: 0 10px; border: 0; border-radius: 6px; background: transparent; color: #171C26; font: inherit; text-align: left; 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" style="flex: 0 0 auto; color: #4A5363"><path d="M9 14 4 9l5-5"></path><path d="M4 9h10.5a5.5 5.5 0 0 1 0 11H11"></path></svg><span style="flex: 1 1 auto">실행 취소</span><span style="font-size: 12px; color: #667085">Ctrl+Z</span></button>
<button type="button" role="menuitem" disabled="{{ true }}" style="display: flex; align-items: center; gap: 10px; width: 100%; min-height: 40px; box-sizing: border-box; padding: 0 10px; border: 0; border-radius: 6px; background: transparent; color: #171C26; font: inherit; text-align: left; opacity: 0.5"><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" style="flex: 0 0 auto; color: #4A5363"><path d="m15 14 5-5-5-5"></path><path d="M20 9H9.5a5.5 5.5 0 0 0 0 11H13"></path></svg><span style="flex: 1 1 auto">다시 실행</span><span style="font-size: 12px; color: #667085">Ctrl+Y</span></button>
<hr style="width: 100%; height: 1px; margin: 4px 0; border: 0; background: #EEF0F3">
<button type="button" role="menuitem" aria-haspopup="menu" style="display: flex; align-items: center; gap: 10px; width: 100%; min-height: 40px; box-sizing: border-box; padding: 0 10px; border: 0; border-radius: 6px; background: transparent; color: #171C26; font: inherit; text-align: left; 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" style="flex: 0 0 auto; color: #4A5363"><polyline points="4 7 4 4 20 4 20 7"></polyline><line x1="9" y1="20" x2="15" y2="20"></line><line x1="12" y1="4" x2="12" y2="20"></line></svg><span style="flex: 1 1 auto">글꼴</span><span style="font-size: 12px; color: #667085">맑은 고딕</span><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" style="flex: 0 0 auto; color: #667085"><polyline points="9 18 15 12 9 6"></polyline></svg></button>
<button type="button" role="menuitem" aria-haspopup="menu" style="display: flex; align-items: center; gap: 10px; width: 100%; min-height: 40px; box-sizing: border-box; padding: 0 10px; border: 0; border-radius: 6px; background: transparent; color: #171C26; font: inherit; text-align: left; 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" style="flex: 0 0 auto; color: #4A5363"><path d="M3 7V5h10v2"></path><path d="M8 5v14"></path><path d="M6 19h4"></path><path d="M14 12v-1h7v1"></path><path d="M17.5 11v8"></path><path d="M16 19h3"></path></svg><span style="flex: 1 1 auto">글자 크기</span><span style="font-size: 12px; color: #667085">14</span><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" style="flex: 0 0 auto; color: #667085"><polyline points="9 18 15 12 9 6"></polyline></svg></button>
<hr style="width: 100%; height: 1px; margin: 4px 0; border: 0; background: #EEF0F3">
<button type="button" role="menuitemcheckbox" aria-checked="false" style="display: flex; align-items: center; gap: 10px; width: 100%; min-height: 40px; box-sizing: border-box; padding: 0 10px; border: 0; border-radius: 6px; background: transparent; color: #171C26; font: inherit; text-align: left; 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" style="flex: 0 0 auto; color: #4A5363"><path d="M16 4H9a3 3 0 0 0-2.83 4"></path><path d="M14 12a4 4 0 0 1 0 8H6"></path><line x1="4" y1="12" x2="20" y2="12"></line></svg><span style="flex: 1 1 auto">취소선</span><span style="font-size: 12px; color: #667085">Ctrl+Shift+S</span></button>
<button type="button" role="menuitem" aria-haspopup="dialog" style="display: flex; align-items: center; gap: 10px; width: 100%; min-height: 40px; box-sizing: border-box; padding: 0 10px; border: 0; border-radius: 6px; background: transparent; color: #171C26; font: inherit; text-align: left; 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" style="flex: 0 0 auto; color: #4A5363"><path d="M6.5 14.5 12 3.5l5.5 11"></path><path d="M8.5 10.5h7"></path><rect x="4.5" y="18" width="15" height="3" rx="1" fill="#1F2733" stroke="none"></rect></svg><span style="flex: 1 1 auto">글자 색</span><span style="font-size: 12px; color: #667085">기본</span><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" style="flex: 0 0 auto; color: #667085"><polyline points="9 18 15 12 9 6"></polyline></svg></button>
<button type="button" role="menuitem" aria-haspopup="dialog" style="display: flex; align-items: center; gap: 10px; width: 100%; min-height: 40px; box-sizing: border-box; padding: 0 10px; border: 0; border-radius: 6px; background: transparent; color: #171C26; font: inherit; text-align: left; 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" style="flex: 0 0 auto; color: #4A5363"><rect x="3.5" y="3.5" width="17" height="17" rx="3" fill="none"></rect><path d="M8 16.5 12 7.5l4 9"></path><path d="M9.3 13.5h5.4"></path></svg><span style="flex: 1 1 auto">배경 색</span><span style="font-size: 12px; color: #667085">없음</span><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" style="flex: 0 0 auto; color: #667085"><polyline points="9 18 15 12 9 6"></polyline></svg></button>
<hr style="width: 100%; height: 1px; margin: 4px 0; border: 0; background: #EEF0F3">
<p style="margin: 0; padding: 6px 10px 4px; font-size: 12px; line-height: 1.5; color: #667085">아래로 정렬 · 들여쓰기 · 링크 넣기 · 인용 · 표 · 가로줄 · 서식 지우기. 화면 높이에 맞춰 안에서 스크롤</p>
</div>
<p style="margin: 0; font-size: 12px; color: #4A5363">값을 고르는 것(글꼴 · 크기 · 색)은 › 로 한 단계 더 엽니다.</p>
</figure>
</div>
</section>
<section style="min-width: 0; display: flex; flex-direction: column; gap: 14px; padding: 20px; background: #FFFFFF; border: 1px solid #E6E8EC; border-radius: 12px">
<div style="display: flex; flex-direction: column; gap: 2px">
<h2 style="margin: 0; font-size: 14px; font-weight: 700">빈 본문</h2>
<p style="margin: 0; font-size: 12px; color: #667085">새 메일처럼 본문이 비었을 때 (커서가 본문에 있는 상태)</p>
</div>
<div style="display: flex; flex-direction: column; border: 1px solid #E6E8EC; border-radius: 8px; background: #FFFFFF; overflow: hidden">
<div style="display: flex; align-items: center; gap: 2px; min-height: 52px; box-sizing: border-box; padding: 4px 20px; border-bottom: 1px solid #EEF0F3">
<button type="button" aria-label="굵게" style="width: 40px; height: 44px; flex: 0 0 auto; display: inline-flex; align-items: center; justify-content: center; padding: 0; border: 0; border-radius: 8px; background: transparent; color: #344054; 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" aria-label="기울임" style="width: 40px; height: 44px; flex: 0 0 auto; display: inline-flex; align-items: center; justify-content: center; padding: 0; border: 0; border-radius: 8px; background: transparent; color: #344054; 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" aria-label="밑줄" style="width: 40px; height: 44px; flex: 0 0 auto; display: inline-flex; align-items: center; justify-content: center; padding: 0; border: 0; border-radius: 8px; background: transparent; color: #344054; 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" aria-label="글머리 목록" style="width: 40px; height: 44px; flex: 0 0 auto; display: inline-flex; align-items: center; justify-content: center; padding: 0; border: 0; border-radius: 8px; background: transparent; color: #344054; 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="9" y1="6" x2="20" y2="6"></line><line x1="9" y1="12" x2="20" y2="12"></line><line x1="9" y1="18" x2="20" y2="18"></line><circle cx="4.5" cy="6" r="1.2" fill="currentColor" stroke="none"></circle><circle cx="4.5" cy="12" r="1.2" fill="currentColor" stroke="none"></circle><circle cx="4.5" cy="18" r="1.2" fill="currentColor" stroke="none"></circle></svg></button>
<button type="button" aria-label="번호 목록" style="width: 40px; height: 44px; flex: 0 0 auto; display: inline-flex; align-items: center; justify-content: center; padding: 0; border: 0; border-radius: 8px; background: transparent; color: #344054; 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="10" y1="6" x2="21" y2="6"></line><line x1="10" y1="12" x2="21" y2="12"></line><line x1="10" y1="18" x2="21" y2="18"></line><path d="M4 6h1v4"></path><path d="M4 10h2"></path><path d="M6 18H4c0-1 2-2 2-3s-1-1.5-2-1"></path></svg></button>
<span aria-hidden="true" style="flex: 0 0 auto; width: 1px; height: 20px; margin: 0 4px; background: #E6E8EC"></span>
<button type="button" aria-label="링크" style="width: 40px; height: 44px; flex: 0 0 auto; display: inline-flex; align-items: center; justify-content: center; padding: 0; border: 0; border-radius: 8px; background: transparent; color: #344054; 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>
<span aria-hidden="true" style="flex: 0 0 auto; width: 1px; height: 20px; margin: 0 4px; background: #E6E8EC"></span>
<button type="button" aria-label="서식 더보기" style="width: 40px; height: 44px; flex: 0 0 auto; display: inline-flex; align-items: center; justify-content: center; padding: 0; border: 0; border-radius: 8px; background: transparent; color: #344054; cursor: pointer"><svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor" stroke="none" aria-hidden="true"><circle cx="5" cy="12" r="1.6"></circle><circle cx="12" cy="12" r="1.6"></circle><circle cx="19" cy="12" r="1.6"></circle></svg></button>
</div>
<div style="min-height: 120px; box-sizing: border-box; padding: 16px 32px; border-bottom: 1px solid #0E7C86; box-shadow: inset 0 -1px 0 #0E7C86; font-family: 'Malgun Gothic', '맑은 고딕', 'Apple SD Gothic Neo', sans-serif; font-size: 14px; line-height: 1.75">
<p style="margin: 0; display: flex; align-items: center; color: #667085"><span aria-hidden="true" style="flex: 0 0 auto; width: 1px; height: 18px; margin-right: 1px; background: #171C26"></span>내용을 입력하세요</p>
</div>
<div style="display: flex; align-items: center; gap: 6px; min-height: 44px; box-sizing: border-box; padding: 0 32px 0 26px; color: #344054; font-weight: 500">
<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" style="margin-left: 6px"><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>파일 첨부</span>
</div>
</div>
<p style="margin: 0; font-size: 12px; color: #4A5363">안내 문구는 지금과 같은 "내용을 입력하세요" · #667085 (흰 바탕 4.9:1). 한 글자라도 쓰면 사라지고, 커서는 문구 앞에 선다.</p>
</section>
</div>
<div style="display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 20px; align-items: start">
<section style="min-width: 0; 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">800px — 툴바에 없는 서식 · 항목 높이 40 (밀도 예외 · 사용자 확정)</p>
</div>
<div style="display: flex; flex-direction: column; align-items: flex-end; gap: 4px">
<div style="align-self: stretch; display: flex; justify-content: flex-end; align-items: center; gap: 2px; min-height: 52px; box-sizing: border-box; padding: 4px 0; border-bottom: 1px solid #EEF0F3">
<button type="button" aria-label="링크" style="width: 40px; height: 44px; flex: 0 0 auto; display: inline-flex; align-items: center; justify-content: center; padding: 0; border: 0; border-radius: 8px; background: transparent; color: #344054; 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>
<span aria-hidden="true" style="flex: 0 0 auto; width: 1px; height: 20px; margin: 0 4px; background: #E6E8EC"></span>
<button type="button" aria-haspopup="menu" aria-expanded="true" aria-label="서식 더보기" style="width: 40px; height: 44px; flex: 0 0 auto; display: inline-flex; align-items: center; justify-content: center; padding: 0; border: 0; border-radius: 8px; background: #F2F4F7; color: #171C26; cursor: pointer"><svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor" stroke="none" aria-hidden="true"><circle cx="5" cy="12" r="1.6"></circle><circle cx="12" cy="12" r="1.6"></circle><circle cx="19" cy="12" r="1.6"></circle></svg></button>
</div>
<div role="menu" aria-label="서식 더보기 (예시)" style="width: 256px; box-sizing: border-box; padding: 6px; display: flex; flex-direction: column; background: #FFFFFF; border: 1px solid #E6E8EC; border-radius: 10px; box-shadow: 0 10px 28px rgba(16, 24, 28, 0.12)">
<button type="button" role="menuitemcheckbox" aria-checked="false" style="display: flex; align-items: center; gap: 10px; width: 100%; min-height: 40px; box-sizing: border-box; padding: 0 10px; border: 0; border-radius: 6px; background: transparent; color: #171C26; font: inherit; text-align: left; 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" style="flex: 0 0 auto; color: #4A5363"><path d="M16 4H9a3 3 0 0 0-2.83 4"></path><path d="M14 12a4 4 0 0 1 0 8H6"></path><line x1="4" y1="12" x2="20" y2="12"></line></svg><span style="flex: 1 1 auto">취소선</span><span style="font-size: 12px; color: #667085">Ctrl+Shift+S</span><span style="flex: 0 0 auto; width: 16px; height: 16px"></span></button>
<hr style="width: 100%; height: 1px; margin: 4px 0; border: 0; background: #EEF0F3">
<button type="button" role="menuitemradio" aria-checked="true" style="display: flex; align-items: center; gap: 10px; width: 100%; min-height: 40px; box-sizing: border-box; padding: 0 10px; border: 0; border-radius: 6px; background: transparent; color: #171C26; font: inherit; text-align: left; 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" style="flex: 0 0 auto; color: #4A5363"><line x1="17" y1="10" x2="3" y2="10"></line><line x1="21" y1="6" x2="3" y2="6"></line><line x1="21" y1="14" x2="3" y2="14"></line><line x1="17" y1="18" x2="3" y2="18"></line></svg><span style="flex: 1 1 auto">왼쪽 정렬</span><span style="font-size: 12px; color: #667085">Ctrl+Shift+L</span><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#0A5F67" stroke-width="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></button>
<button type="button" role="menuitemradio" aria-checked="false" style="display: flex; align-items: center; gap: 10px; width: 100%; min-height: 40px; box-sizing: border-box; padding: 0 10px; border: 0; border-radius: 6px; background: #F2F4F7; color: #171C26; font: inherit; text-align: left; 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" style="flex: 0 0 auto; color: #4A5363"><line x1="18" y1="10" x2="6" y2="10"></line><line x1="21" y1="6" x2="3" y2="6"></line><line x1="21" y1="14" x2="3" y2="14"></line><line x1="18" y1="18" x2="6" y2="18"></line></svg><span style="flex: 1 1 auto">가운데 정렬</span><span style="font-size: 12px; color: #667085">Ctrl+Shift+E</span><span style="flex: 0 0 auto; width: 16px; height: 16px"></span></button>
<button type="button" role="menuitemradio" aria-checked="false" style="display: flex; align-items: center; gap: 10px; width: 100%; min-height: 40px; box-sizing: border-box; padding: 0 10px; border: 0; border-radius: 6px; background: transparent; color: #171C26; font: inherit; text-align: left; 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" style="flex: 0 0 auto; color: #4A5363"><line x1="21" y1="10" x2="7" y2="10"></line><line x1="21" y1="6" x2="3" y2="6"></line><line x1="21" y1="14" x2="3" y2="14"></line><line x1="21" y1="18" x2="7" y2="18"></line></svg><span style="flex: 1 1 auto">오른쪽 정렬</span><span style="font-size: 12px; color: #667085">Ctrl+Shift+R</span><span style="flex: 0 0 auto; width: 16px; height: 16px"></span></button>
<hr style="width: 100%; height: 1px; margin: 4px 0; border: 0; background: #EEF0F3">
<button type="button" role="menuitem" style="display: flex; align-items: center; gap: 10px; width: 100%; min-height: 40px; box-sizing: border-box; padding: 0 10px; border: 0; border-radius: 6px; background: transparent; color: #171C26; font: inherit; text-align: left; 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" style="flex: 0 0 auto; color: #4A5363"><polyline points="7 8 3 12 7 16"></polyline><line x1="21" y1="12" x2="11" y2="12"></line><line x1="21" y1="6" x2="11" y2="6"></line><line x1="21" y1="18" x2="11" y2="18"></line></svg><span style="flex: 1 1 auto">내어쓰기</span><span style="font-size: 12px; color: #667085">Ctrl+[</span><span style="flex: 0 0 auto; width: 16px; height: 16px"></span></button>
<button type="button" role="menuitem" style="display: flex; align-items: center; gap: 10px; width: 100%; min-height: 40px; box-sizing: border-box; padding: 0 10px; border: 0; border-radius: 6px; background: transparent; color: #171C26; font: inherit; text-align: left; 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" style="flex: 0 0 auto; color: #4A5363"><polyline points="3 8 7 12 3 16"></polyline><line x1="21" y1="12" x2="11" y2="12"></line><line x1="21" y1="6" x2="11" y2="6"></line><line x1="21" y1="18" x2="11" y2="18"></line></svg><span style="flex: 1 1 auto">들여쓰기</span><span style="font-size: 12px; color: #667085">Ctrl+]</span><span style="flex: 0 0 auto; width: 16px; height: 16px"></span></button>
<hr style="width: 100%; height: 1px; margin: 4px 0; border: 0; background: #EEF0F3">
<button type="button" role="menuitemcheckbox" aria-checked="false" style="display: flex; align-items: center; gap: 10px; width: 100%; min-height: 40px; box-sizing: border-box; padding: 0 10px; border: 0; border-radius: 6px; background: transparent; color: #171C26; font: inherit; text-align: left; 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" style="flex: 0 0 auto; color: #4A5363"><path d="M17 6H3"></path><path d="M21 12H8"></path><path d="M21 18H8"></path><path d="M3 12v6"></path></svg><span style="flex: 1 1 auto">인용</span><span style="font-size: 12px; color: #667085">Ctrl+Shift+B</span><span style="flex: 0 0 auto; width: 16px; height: 16px"></span></button>
<button type="button" role="menuitem" aria-haspopup="dialog" style="display: flex; align-items: center; gap: 10px; width: 100%; min-height: 40px; box-sizing: border-box; padding: 0 10px; border: 0; border-radius: 6px; background: transparent; color: #171C26; font: inherit; text-align: left; 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" style="flex: 0 0 auto; color: #4A5363"><rect x="3" y="3" width="18" height="18" rx="2" ry="2"></rect><line x1="3" y1="9" x2="21" y2="9"></line><line x1="3" y1="15" x2="21" y2="15"></line><line x1="9" y1="3" x2="9" y2="21"></line><line x1="15" y1="3" x2="15" y2="21"></line></svg><span style="flex: 1 1 auto">표 넣기</span><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" style="flex: 0 0 auto; margin: 0 1px; color: #667085"><polyline points="9 18 15 12 9 6"></polyline></svg></button>
<button type="button" role="menuitem" style="display: flex; align-items: center; gap: 10px; width: 100%; min-height: 40px; box-sizing: border-box; padding: 0 10px; border: 0; border-radius: 6px; background: transparent; color: #171C26; font: inherit; text-align: left; 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" style="flex: 0 0 auto; color: #4A5363"><line x1="3" y1="12" x2="21" y2="12"></line><polyline points="8 8 12 4 16 8"></polyline><polyline points="16 16 12 20 8 16"></polyline></svg><span style="flex: 1 1 auto">가로줄</span><span style="flex: 0 0 auto; width: 16px; height: 16px"></span></button>
<hr style="width: 100%; height: 1px; margin: 4px 0; border: 0; background: #EEF0F3">
<button type="button" role="menuitem" style="display: flex; align-items: center; gap: 10px; width: 100%; min-height: 40px; box-sizing: border-box; padding: 0 10px; border: 0; border-radius: 6px; background: transparent; color: #171C26; font: inherit; text-align: left; 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" style="flex: 0 0 auto; color: #4A5363"><path d="M4 7V4h16v3"></path><path d="M5 20h6"></path><path d="M13 4 8 20"></path><path d="m15 15 5 5"></path><path d="m20 15-5 5"></path></svg><span style="flex: 1 1 auto">서식 지우기</span><span style="font-size: 12px; color: #667085">Ctrl+\</span><span style="flex: 0 0 auto; width: 16px; height: 16px"></span></button>
</div>
</div>
<p style="margin: 0; font-size: 12px; color: #4A5363">켜진 서식은 오른쪽 ✓ (취소선 · 정렬 · 인용). 단축키는 흐린 글자. 올린 항목은 #F2F4F7 (가운데 정렬).</p>
</section>
<section style="min-width: 0; display: flex; flex-direction: column; gap: 14px; padding: 20px; background: #FFFFFF; border: 1px solid #E6E8EC; border-radius: 12px">
<div style="display: flex; flex-direction: column; gap: 2px">
<h2 style="margin: 0; font-size: 14px; font-weight: 700">글꼴 · 글자 크기</h2>
<p style="margin: 0; font-size: 12px; color: #667085">받는 쪽에 흔히 있는 글꼴만 — 이름은 그 글꼴로 보입니다</p>
</div>
<div style="display: flex; gap: 16px; align-items: flex-start">
<div style="display: flex; flex-direction: column; gap: 4px">
<button type="button" aria-haspopup="menu" aria-expanded="true" aria-label="글꼴: 맑은 고딕" style="width: 108px; height: 44px; display: inline-flex; align-items: center; gap: 4px; box-sizing: border-box; padding: 0 8px 0 12px; border: 0; border-radius: 8px; background: #F2F4F7; color: #171C26; font: inherit; font-weight: 500; cursor: pointer"><span style="flex: 1 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; text-align: left">맑은 고딕</span><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.25" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" style="flex: 0 0 auto"><polyline points="6 9 12 15 18 9"></polyline></svg></button>
<div role="menu" aria-label="글꼴 (예시)" style="width: 216px; box-sizing: border-box; padding: 6px; display: flex; flex-direction: column; background: #FFFFFF; border: 1px solid #E6E8EC; border-radius: 10px; box-shadow: 0 10px 28px rgba(16, 24, 28, 0.12)">
<button type="button" role="menuitemradio" aria-checked="true" style="display: flex; align-items: center; gap: 10px; width: 100%; min-height: 40px; box-sizing: border-box; padding: 0 10px; border: 0; border-radius: 6px; background: transparent; color: #171C26; font: inherit; font-size: 14px; font-family: 'Malgun Gothic', '맑은 고딕', 'Apple SD Gothic Neo', sans-serif; text-align: left; cursor: pointer"><span style="flex: 1 1 auto">맑은 고딕</span><span style="flex: 0 0 auto; font-family: Pretendard, -apple-system, 'Apple SD Gothic Neo', 'Malgun Gothic', system-ui, sans-serif; font-size: 12px; color: #667085">기본</span><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#0A5F67" stroke-width="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></button>
<button type="button" role="menuitemradio" aria-checked="false" style="display: flex; align-items: center; gap: 10px; width: 100%; min-height: 40px; box-sizing: border-box; padding: 0 10px; border: 0; border-radius: 6px; background: transparent; color: #171C26; font: inherit; font-size: 14px; font-family: Gulim, '굴림', sans-serif; text-align: left; cursor: pointer"><span style="flex: 1 1 auto">굴림</span></button>
<button type="button" role="menuitemradio" aria-checked="false" style="display: flex; align-items: center; gap: 10px; width: 100%; min-height: 40px; box-sizing: border-box; padding: 0 10px; border: 0; border-radius: 6px; background: transparent; color: #171C26; font: inherit; font-size: 14px; font-family: Dotum, '돋움', sans-serif; text-align: left; cursor: pointer"><span style="flex: 1 1 auto">돋움</span></button>
<button type="button" role="menuitemradio" aria-checked="false" style="display: flex; align-items: center; gap: 10px; width: 100%; min-height: 40px; box-sizing: border-box; padding: 0 10px; border: 0; border-radius: 6px; background: transparent; color: #171C26; font: inherit; font-size: 14px; font-family: Batang, '바탕', serif; text-align: left; cursor: pointer"><span style="flex: 1 1 auto">바탕</span></button>
<hr style="width: 100%; height: 1px; margin: 4px 0; border: 0; background: #EEF0F3">
<button type="button" role="menuitemradio" aria-checked="false" style="display: flex; align-items: center; gap: 10px; width: 100%; min-height: 40px; box-sizing: border-box; padding: 0 10px; border: 0; border-radius: 6px; background: transparent; color: #171C26; font: inherit; font-size: 14px; font-family: Arial, sans-serif; text-align: left; cursor: pointer"><span style="flex: 1 1 auto">Arial</span></button>
<button type="button" role="menuitemradio" aria-checked="false" style="display: flex; align-items: center; gap: 10px; width: 100%; min-height: 40px; box-sizing: border-box; padding: 0 10px; border: 0; border-radius: 6px; background: transparent; color: #171C26; font: inherit; font-size: 14px; font-family: Verdana, sans-serif; text-align: left; cursor: pointer"><span style="flex: 1 1 auto">Verdana</span></button>
<button type="button" role="menuitemradio" aria-checked="false" style="display: flex; align-items: center; gap: 10px; width: 100%; min-height: 40px; box-sizing: border-box; padding: 0 10px; border: 0; border-radius: 6px; background: transparent; color: #171C26; font: inherit; font-size: 14px; font-family: Georgia, serif; text-align: left; cursor: pointer"><span style="flex: 1 1 auto">Georgia</span></button>
<button type="button" role="menuitemradio" aria-checked="false" style="display: flex; align-items: center; gap: 10px; width: 100%; min-height: 40px; box-sizing: border-box; padding: 0 10px; border: 0; border-radius: 6px; background: transparent; color: #171C26; font: inherit; font-size: 14px; font-family: 'Times New Roman', serif; text-align: left; cursor: pointer"><span style="flex: 1 1 auto">Times New Roman</span></button>
<button type="button" role="menuitemradio" aria-checked="false" style="display: flex; align-items: center; gap: 10px; width: 100%; min-height: 40px; box-sizing: border-box; padding: 0 10px; border: 0; border-radius: 6px; background: transparent; color: #171C26; font: inherit; font-size: 14px; font-family: 'Courier New', monospace; text-align: left; cursor: pointer"><span style="flex: 1 1 auto">Courier New</span></button>
</div>
</div>
<div style="display: flex; flex-direction: column; gap: 4px">
<button type="button" aria-haspopup="menu" aria-expanded="true" aria-label="글자 크기: 14" style="width: 56px; height: 44px; display: inline-flex; align-items: center; gap: 4px; box-sizing: border-box; padding: 0 8px 0 12px; border: 0; border-radius: 8px; background: #F2F4F7; color: #171C26; font: inherit; font-weight: 500; cursor: pointer"><span style="flex: 1 1 auto; text-align: left">14</span><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.25" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" style="flex: 0 0 auto"><polyline points="6 9 12 15 18 9"></polyline></svg></button>
<div role="menu" aria-label="글자 크기 (예시)" style="width: 124px; box-sizing: border-box; padding: 6px; display: flex; flex-direction: column; background: #FFFFFF; border: 1px solid #E6E8EC; border-radius: 10px; box-shadow: 0 10px 28px rgba(16, 24, 28, 0.12)">
<button type="button" role="menuitemradio" aria-checked="false" style="display: flex; align-items: center; gap: 8px; width: 100%; min-height: 40px; box-sizing: border-box; padding: 0 10px; border: 0; border-radius: 6px; background: transparent; color: #171C26; font: inherit; text-align: left; cursor: pointer"><span style="flex: 1 1 auto; font-size: 12px; line-height: 1.1">12</span></button>
<button type="button" role="menuitemradio" aria-checked="false" style="display: flex; align-items: center; gap: 8px; width: 100%; min-height: 40px; box-sizing: border-box; padding: 0 10px; border: 0; border-radius: 6px; background: transparent; color: #171C26; font: inherit; text-align: left; cursor: pointer"><span style="flex: 1 1 auto; font-size: 13px; line-height: 1.1">13</span></button>
<button type="button" role="menuitemradio" aria-checked="true" style="display: flex; align-items: center; gap: 8px; width: 100%; min-height: 40px; box-sizing: border-box; padding: 0 10px; border: 0; border-radius: 6px; background: transparent; color: #171C26; font: inherit; text-align: left; cursor: pointer"><span style="flex: 1 1 auto; font-size: 14px; line-height: 1.1">14</span><span style="font-size: 12px; color: #667085">기본</span><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#0A5F67" stroke-width="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></button>
<button type="button" role="menuitemradio" aria-checked="false" style="display: flex; align-items: center; gap: 8px; width: 100%; min-height: 40px; box-sizing: border-box; padding: 0 10px; border: 0; border-radius: 6px; background: transparent; color: #171C26; font: inherit; text-align: left; cursor: pointer"><span style="flex: 1 1 auto; font-size: 16px; line-height: 1.1">16</span></button>
<button type="button" role="menuitemradio" aria-checked="false" style="display: flex; align-items: center; gap: 8px; width: 100%; min-height: 40px; box-sizing: border-box; padding: 0 10px; border: 0; border-radius: 6px; background: transparent; color: #171C26; font: inherit; text-align: left; cursor: pointer"><span style="flex: 1 1 auto; font-size: 18px; line-height: 1.1">18</span></button>
<button type="button" role="menuitemradio" aria-checked="false" style="display: flex; align-items: center; gap: 8px; width: 100%; min-height: 40px; box-sizing: border-box; padding: 0 10px; border: 0; border-radius: 6px; background: transparent; color: #171C26; font: inherit; text-align: left; cursor: pointer"><span style="flex: 1 1 auto; font-size: 20px; line-height: 1.1">20</span></button>
<button type="button" role="menuitemradio" aria-checked="false" style="display: flex; align-items: center; gap: 8px; width: 100%; min-height: 40px; box-sizing: border-box; padding: 0 10px; border: 0; border-radius: 6px; background: transparent; color: #171C26; font: inherit; text-align: left; cursor: pointer"><span style="flex: 1 1 auto; font-size: 24px; line-height: 1.1">24</span></button>
<button type="button" role="menuitemradio" aria-checked="false" style="display: flex; align-items: center; gap: 8px; width: 100%; min-height: 40px; box-sizing: border-box; padding: 0 10px; border: 0; border-radius: 6px; background: transparent; color: #171C26; font: inherit; text-align: left; cursor: pointer"><span style="flex: 1 1 auto; font-size: 32px; line-height: 1.1">32</span></button>
</div>
</div>
</div>
<p style="margin: 0; font-size: 12px; color: #4A5363">맑은 고딕이 본문 기본 글꼴이라 받는 쪽에서도 같은 모양입니다(맥에서는 Apple SD 산돌고딕 Neo). 크기는 px 숫자 · 기본 14 · 제목 스타일은 두지 않습니다. 고른 글자의 크기가 섞이면 "–".</p>
</section>
<section style="min-width: 0; 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">8색씩 — 글자 색은 모두 흰 바탕 4.5:1 이상</p>
</div>
<div style="display: flex; gap: 16px; align-items: flex-start">
<div style="display: flex; flex-direction: column; gap: 4px">
<button type="button" aria-haspopup="dialog" aria-expanded="true" aria-label="글자 색: 빨강" style="width: 40px; height: 44px; display: inline-flex; align-items: center; justify-content: center; padding: 0; border: 0; border-radius: 8px; background: #F2F4F7; color: #171C26; 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.5 14.5 12 3.5l5.5 11"></path><path d="M8.5 10.5h7"></path><rect x="4.5" y="18" width="15" height="3" rx="1" fill="#B42318" stroke="none"></rect></svg></button>
<div role="dialog" aria-label="글자 색 (예시)" style="width: 186px; box-sizing: border-box; padding: 10px; display: flex; flex-direction: column; gap: 8px; background: #FFFFFF; border: 1px solid #E6E8EC; border-radius: 10px; box-shadow: 0 10px 28px rgba(16, 24, 28, 0.12)">
<div style="display: flex; align-items: baseline; justify-content: space-between; gap: 8px; padding: 0 2px"><span style="font-size: 12.5px; font-weight: 600; color: #4A5363">글자 색</span><span style="font-size: 12px; color: #667085">빨강</span></div>
<div style="display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 2px">
<button type="button" aria-label="기본" aria-pressed="false" style="width: 40px; height: 40px; display: inline-flex; align-items: center; justify-content: center; padding: 0; border: 0; border-radius: 8px; background: transparent; cursor: pointer"><span style="width: 26px; height: 26px; border-radius: 6px; background: #1F2733; box-shadow: inset 0 0 0 1px rgba(16, 24, 40, 0.14)"></span></button>
<button type="button" aria-label="회색" aria-pressed="false" style="width: 40px; height: 40px; display: inline-flex; align-items: center; justify-content: center; padding: 0; border: 0; border-radius: 8px; background: transparent; cursor: pointer"><span style="width: 26px; height: 26px; border-radius: 6px; background: #667085; box-shadow: inset 0 0 0 1px rgba(16, 24, 40, 0.14)"></span></button>
<button type="button" aria-label="빨강" aria-pressed="true" style="width: 40px; height: 40px; display: inline-flex; align-items: center; justify-content: center; padding: 0; border: 0; border-radius: 8px; background: transparent; cursor: pointer"><span style="width: 26px; height: 26px; display: inline-flex; align-items: center; justify-content: center; border-radius: 6px; background: #B42318; box-shadow: inset 0 0 0 1px rgba(16, 24, 40, 0.14); color: #FFFFFF"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="20 6 9 17 4 12"></polyline></svg></span></button>
<button type="button" aria-label="주황" aria-pressed="false" style="width: 40px; height: 40px; display: inline-flex; align-items: center; justify-content: center; padding: 0; border: 0; border-radius: 8px; background: transparent; cursor: pointer"><span style="width: 26px; height: 26px; border-radius: 6px; background: #B54708; box-shadow: inset 0 0 0 1px rgba(16, 24, 40, 0.14)"></span></button>
<button type="button" aria-label="초록" aria-pressed="false" style="width: 40px; height: 40px; display: inline-flex; align-items: center; justify-content: center; padding: 0; border: 0; border-radius: 8px; background: transparent; cursor: pointer"><span style="width: 26px; height: 26px; border-radius: 6px; background: #067647; box-shadow: inset 0 0 0 1px rgba(16, 24, 40, 0.14)"></span></button>
<button type="button" aria-label="청록" aria-pressed="false" style="width: 40px; height: 40px; display: inline-flex; align-items: center; justify-content: center; padding: 0; border: 0; border-radius: 8px; background: transparent; cursor: pointer"><span style="width: 26px; height: 26px; border-radius: 6px; background: #0E7C86; box-shadow: inset 0 0 0 1px rgba(16, 24, 40, 0.14)"></span></button>
<button type="button" aria-label="파랑" aria-pressed="false" style="width: 40px; height: 40px; display: inline-flex; align-items: center; justify-content: center; padding: 0; border: 0; border-radius: 8px; background: transparent; cursor: pointer"><span style="width: 26px; height: 26px; border-radius: 6px; background: #175CD3; box-shadow: inset 0 0 0 1px rgba(16, 24, 40, 0.14)"></span></button>
<button type="button" aria-label="보라" aria-pressed="false" style="width: 40px; height: 40px; display: inline-flex; align-items: center; justify-content: center; padding: 0; border: 0; border-radius: 8px; background: transparent; cursor: pointer"><span style="width: 26px; height: 26px; border-radius: 6px; background: #6941C6; box-shadow: inset 0 0 0 1px rgba(16, 24, 40, 0.14)"></span></button>
</div>
</div>
</div>
<div style="display: flex; flex-direction: column; gap: 4px">
<button type="button" aria-haspopup="dialog" aria-expanded="true" aria-label="배경 색: 노랑" style="width: 40px; height: 44px; display: inline-flex; align-items: center; justify-content: center; padding: 0; border: 0; border-radius: 8px; background: #F2F4F7; color: #171C26; 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.5" y="3.5" width="17" height="17" rx="3" fill="#FFF1A8"></rect><path d="M8 16.5 12 7.5l4 9"></path><path d="M9.3 13.5h5.4"></path></svg></button>
<div role="dialog" aria-label="배경 색 (예시)" style="width: 186px; box-sizing: border-box; padding: 10px; display: flex; flex-direction: column; gap: 8px; background: #FFFFFF; border: 1px solid #E6E8EC; border-radius: 10px; box-shadow: 0 10px 28px rgba(16, 24, 28, 0.12)">
<div style="display: flex; align-items: baseline; justify-content: space-between; gap: 8px; padding: 0 2px"><span style="font-size: 12.5px; font-weight: 600; color: #4A5363">배경 색</span><span style="font-size: 12px; color: #667085">노랑</span></div>
<div style="display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 2px">
<button type="button" aria-label="없음" aria-pressed="false" style="width: 40px; height: 40px; display: inline-flex; align-items: center; justify-content: center; padding: 0; border: 0; border-radius: 8px; background: transparent; cursor: pointer"><span style="width: 26px; height: 26px; display: inline-flex; align-items: center; justify-content: center; border-radius: 6px; background: #FFFFFF; box-shadow: inset 0 0 0 1px rgba(16, 24, 40, 0.14)"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="#B42318" stroke-width="2" stroke-linecap="round" aria-hidden="true"><line x1="5" y1="19" x2="19" y2="5"></line></svg></span></button>
<button type="button" aria-label="노랑" aria-pressed="true" style="width: 40px; height: 40px; display: inline-flex; align-items: center; justify-content: center; padding: 0; border: 0; border-radius: 8px; background: transparent; cursor: pointer"><span style="width: 26px; height: 26px; display: inline-flex; align-items: center; justify-content: center; border-radius: 6px; background: #FFF1A8; box-shadow: inset 0 0 0 1px rgba(16, 24, 40, 0.14); color: #171C26"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="20 6 9 17 4 12"></polyline></svg></span></button>
<button type="button" aria-label="주황" aria-pressed="false" style="width: 40px; height: 40px; display: inline-flex; align-items: center; justify-content: center; padding: 0; border: 0; border-radius: 8px; background: transparent; cursor: pointer"><span style="width: 26px; height: 26px; border-radius: 6px; background: #FDE4CC; box-shadow: inset 0 0 0 1px rgba(16, 24, 40, 0.14)"></span></button>
<button type="button" aria-label="분홍" aria-pressed="false" style="width: 40px; height: 40px; display: inline-flex; align-items: center; justify-content: center; padding: 0; border: 0; border-radius: 8px; background: transparent; cursor: pointer"><span style="width: 26px; height: 26px; border-radius: 6px; background: #FBDCE3; box-shadow: inset 0 0 0 1px rgba(16, 24, 40, 0.14)"></span></button>
<button type="button" aria-label="초록" aria-pressed="false" style="width: 40px; height: 40px; display: inline-flex; align-items: center; justify-content: center; padding: 0; border: 0; border-radius: 8px; background: transparent; cursor: pointer"><span style="width: 26px; height: 26px; border-radius: 6px; background: #D9F2DF; box-shadow: inset 0 0 0 1px rgba(16, 24, 40, 0.14)"></span></button>
<button type="button" aria-label="하늘" aria-pressed="false" style="width: 40px; height: 40px; display: inline-flex; align-items: center; justify-content: center; padding: 0; border: 0; border-radius: 8px; background: transparent; cursor: pointer"><span style="width: 26px; height: 26px; border-radius: 6px; background: #D6E8FB; box-shadow: inset 0 0 0 1px rgba(16, 24, 40, 0.14)"></span></button>
<button type="button" aria-label="보라" aria-pressed="false" style="width: 40px; height: 40px; display: inline-flex; align-items: center; justify-content: center; padding: 0; border: 0; border-radius: 8px; background: transparent; cursor: pointer"><span style="width: 26px; height: 26px; border-radius: 6px; background: #E9E0FA; box-shadow: inset 0 0 0 1px rgba(16, 24, 40, 0.14)"></span></button>
<button type="button" aria-label="회색" aria-pressed="false" style="width: 40px; height: 40px; display: inline-flex; align-items: center; justify-content: center; padding: 0; border: 0; border-radius: 8px; background: transparent; cursor: pointer"><span style="width: 26px; height: 26px; border-radius: 6px; background: #E9EBEF; box-shadow: inset 0 0 0 1px rgba(16, 24, 40, 0.14)"></span></button>
</div>
</div>
</div>
</div>
<p style="margin: 0; font-size: 12px; color: #4A5363">견본 칸 40×40 (보이는 색 26 · 밀도 예외 · 사용자 확정). 고른 색에 ✓ 하고 이름을 위에 글자로도 씁니다. "기본"은 글자 색을, "없음"은 배경 색을 지웁니다.</p>
</section>
</div>
<div style="display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 20px; align-items: start">
<section style="grid-column: span 2; min-width: 0; display: flex; flex-direction: column; gap: 14px; padding: 20px; background: #FFFFFF; border: 1px solid #E6E8EC; border-radius: 12px">
<div style="display: flex; flex-direction: column; gap: 2px">
<h2 style="margin: 0; font-size: 14px; font-weight: 700">답장 · 전달 — 편집기 안의 원문 인용</h2>
<p style="margin: 0; font-size: 12px; color: #667085">원문은 회색 인용 막대 안에 "원본 메일" 머리와 함께 둡니다(답장 · 전달 모두). 내 글은 그 위에 씁니다.</p>
</div>
<div style="display: flex; gap: 16px; align-items: flex-start">
<figure style="flex: 1 1 500px; min-width: 0; margin: 0; display: flex; flex-direction: column; gap: 6px">
<div style="box-sizing: border-box; padding: 16px 32px; border: 1px solid #E6E8EC; border-radius: 8px; 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">도윤님, 확인했습니다.</p>
<p style="margin: 0">체크리스트 기준으로 <strong>금요일 저녁 전환</strong>을 진행해 주세요.</p>
<p style="margin: 0">&nbsp;</p>
<p style="margin: 0">김아라</p>
<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">&nbsp;</p>
<p style="margin: 0">말씀하신 대로 이번 주 금요일(10/30) 저녁 9시에 MX 레코드를 새 메일 서버로 전환하겠습니다.</p>
<p style="margin: 0; color: #98A2B3" aria-hidden="true">…</p>
</blockquote>
</div>
<figcaption style="font-size: 12.5px; color: #344054"><strong style="font-weight: 600">답장</strong> — 보낸 사람 · 날짜 · 제목. 커서는 맨 위 첫 줄</figcaption>
</figure>
<figure style="flex: 0 1 320px; min-width: 0; margin: 0; display: flex; flex-direction: column; gap: 6px">
<div style="box-sizing: border-box; padding: 16px 20px 16px 24px; border: 1px solid #E6E8EC; border-radius: 8px; 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">&nbsp;</p>
<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; overflow-wrap: anywhere">이도윤 &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; overflow-wrap: anywhere">김아라 &lt;ara.kim@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; overflow-wrap: anywhere">인프라팀 &lt;infra@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">Re: 메일 서버 이전 일정 공유</td></tr>
</tbody>
</table>
</div>
<p style="margin: 0">김아라님, 안녕하세요.</p>
<p style="margin: 0; color: #98A2B3" aria-hidden="true">…</p>
</blockquote>
</div>
<figcaption style="font-size: 12.5px; color: #344054"><strong style="font-weight: 600">전달</strong> — 받는 사람 · 참조가 더해진다. 커서는 받는 사람 칸</figcaption>
</figure>
</div>
<p style="margin: 0; font-size: 12px; color: #4A5363">인용 막대 2px #D0D5DD · 막대와 글 사이 14 · 원문 글 #4A5363 · 머리 12.5px(항목 이름 #667085) · 내 글과 24 띄움. 막대를 회색으로 둔 것은 2px 브랜드색 선(포커스)과 헷갈리지 않게 하려는 것. 툴바의 "인용"도 같은 막대를 머리 없이 쓴다.</p>
</section>
<section style="min-width: 0; 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-family: 'Malgun Gothic', '맑은 고딕', 'Apple SD Gothic Neo', sans-serif; font-size: 14px; line-height: 1.75; color: #1F2733">진행 상황은 <span style="background: #D6E8FB; color: #0E7C86; text-decoration: underline">전환 안내 페이지</span>에서 볼 수 있습니다.</p>
<div role="dialog" aria-label="링크 고치기 (예시)" style="width: 300px; max-width: 100%; box-sizing: border-box; padding: 14px; display: flex; flex-direction: column; gap: 10px; background: #FFFFFF; border: 1px solid #E6E8EC; border-radius: 10px; box-shadow: 0 10px 28px rgba(16, 24, 28, 0.12)">
<div style="display: flex; flex-direction: column; gap: 4px">
<label for="ex-link-url" style="font-size: 12.5px; font-weight: 600; color: #344054">주소</label>
<input id="ex-link-url" type="url" value="https://wiki.arasystem.it.kr/mail-migration" readonly style="min-height: 40px; box-sizing: border-box; padding: 0 12px; border: 1px solid #8A94A3; border-radius: 8px; font: inherit; color: #171C26; background: #FFFFFF; outline: 2px solid #0E7C86; outline-offset: 2px">
</div>
<div style="display: flex; flex-direction: column; gap: 4px">
<label for="ex-link-text" style="font-size: 12.5px; font-weight: 600; color: #344054">표시할 글자</label>
<input id="ex-link-text" type="text" value="전환 안내 페이지" readonly style="min-height: 40px; box-sizing: border-box; padding: 0 12px; border: 1px solid #8A94A3; border-radius: 8px; font: inherit; color: #171C26; background: #FFFFFF">
</div>
<div style="display: flex; align-items: center; gap: 6px">
<button type="button" style="display: inline-flex; align-items: center; gap: 6px; min-height: 44px; margin-left: -6px; padding: 0 8px 0 6px; border: 0; border-radius: 8px; background: transparent; color: #344054; font: inherit; font-weight: 500; cursor: pointer"><svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m18.84 12.25 1.72-1.71a5 5 0 0 0-7.07-7.07l-1.72 1.71"></path><path d="m5.17 11.75-1.71 1.71a5 5 0 0 0 7.07 7.07l1.71-1.71"></path><line x1="8" y1="2" x2="8" y2="5"></line><line x1="2" y1="8" x2="5" y2="8"></line><line x1="16" y1="19" x2="16" y2="22"></line><line x1="19" y1="16" x2="22" y2="16"></line></svg><span>링크 빼기</span></button>
<span style="flex: 1 1 auto"></span>
<button type="button" style="min-height: 44px; padding: 0 12px; border: 0; border-radius: 8px; background: transparent; color: #344054; font: inherit; font-weight: 500; cursor: pointer">취소</button>
<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>
</div>
</div>
<p style="margin: 0; font-size: 12px; color: #4A5363">Enter = 적용 · Esc = 닫고 본문으로. 새 링크는 "링크 빼기" 없이 "넣기". 주소는 http(s) · mailto 만. 상자 입력칸이라 포커스는 고리(주소 칸).</p>
</section>
</div>
<div style="display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 20px; align-items: start">
<section style="grid-column: span 2; min-width: 0; display: flex; flex-direction: column; gap: 14px; padding: 20px; background: #FFFFFF; border: 1px solid #E6E8EC; border-radius: 12px">
<div style="display: flex; flex-direction: column; gap: 2px">
<h2 style="margin: 0; font-size: 14px; font-weight: 700">본문에 넣은 이미지 → 첨부 파일</h2>
<p style="margin: 0; font-size: 12px; color: #667085">본문에는 이미지를 넣지 않습니다(사용자 확정). 붙여 넣거나 끌어다 놓은 이미지는 첨부 파일로 올리고, 툴바에는 이미지 버튼이 없습니다.</p>
</div>
<div style="display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 16px; align-items: start">
<figure style="margin: 0; min-width: 0; display: flex; flex-direction: column; gap: 6px">
<figcaption style="font-size: 12.5px; font-weight: 600; color: #344054">① 끌어다 놓는 중</figcaption>
<div style="position: relative; height: 150px; box-sizing: border-box; padding: 12px 16px; border: 1px solid #E6E8EC; border-radius: 8px; background: #FFFFFF; overflow: hidden; font-family: 'Malgun Gothic', '맑은 고딕', 'Apple SD Gothic Neo', sans-serif; font-size: 14px; line-height: 1.75; color: #1F2733">
<p style="margin: 0">화면 캡처 함께 보냅니다.</p>
<div aria-hidden="true" style="position: absolute; inset: 8px; display: flex; align-items: center; justify-content: center; gap: 8px; border: 2px dashed #0E7C86; border-radius: 8px; background: rgba(243, 248, 248, 0.94); color: #0A5F67; font-family: Pretendard, -apple-system, 'Apple SD Gothic Neo', 'Malgun Gothic', system-ui, sans-serif; font-size: 13px; font-weight: 600">
<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="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>놓으면 첨부 파일로 올립니다</span>
</div>
</div>
</figure>
<figure style="margin: 0; min-width: 0; display: flex; flex-direction: column; gap: 6px">
<figcaption style="font-size: 12.5px; font-weight: 600; color: #344054">② 첨부 목록에 올라간다</figcaption>
<div style="display: flex; flex-direction: column; gap: 6px; padding: 8px 12px 12px; border: 1px solid #E6E8EC; border-radius: 8px; background: #FFFFFF">
<span style="display: inline-flex; align-items: center; gap: 6px; min-height: 32px; color: #344054; font-weight: 500"><svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21.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>
<span style="font-size: 12px; color: #667085">파일당 최대 20MB · 합계 25MB · 최대 20개 · 문서, 이미지, 압축 파일</span>
<ul style="list-style: none; margin: 2px 0 0; padding: 0">
<li style="display: flex; flex-direction: column; gap: 4px; padding: 2px 2px 2px 10px; border: 1px solid #E6E8EC; border-radius: 8px">
<div style="display: flex; align-items: center; gap: 10px">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#4A5363" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" style="flex: 0 0 auto"><path d="M13 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V9z"></path><polyline points="13 2 13 9 20 9"></polyline></svg>
<span style="flex: 1 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-weight: 500">image.png</span>
<span style="flex: 0 0 auto; font-size: 12px; color: #667085; font-variant-numeric: tabular-nums">올리는 중 62%</span>
<button type="button" aria-label="image.png 첨부 취소" style="flex: 0 0 auto; width: 40px; height: 40px; display: inline-flex; align-items: center; justify-content: center; padding: 0; border: 0; border-radius: 6px; background: transparent; color: #4A5363; cursor: pointer"><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"><line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line></svg></button>
</div>
<div style="width: calc(100% - 8px); height: 3px; margin: 0 0 6px; border-radius: 2px; background: #E4E7EC"><div style="width: 62%; height: 3px; border-radius: 2px; background: #0E7C86"></div></div>
</li>
</ul>
</div>
</figure>
<figure style="margin: 0; min-width: 0; display: flex; flex-direction: column; gap: 6px">
<figcaption style="font-size: 12.5px; font-weight: 600; color: #344054">③ 이미지였으면 알린다</figcaption>
<div 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" aria-label="알림 닫기" style="flex: 0 0 auto; width: 36px; height: 36px; display: inline-flex; align-items: center; justify-content: center; padding: 0; border: 0; border-radius: 6px; background: transparent; color: #A3C2C6; cursor: pointer"><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"><line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line></svg></button>
</div>
<p style="margin: 0; font-size: 12px; color: #667085">성공 알림과 같은 모양 · 5초 뒤 닫힌다</p>
</figure>
</div>
<ul style="margin: 0; padding-left: 18px; display: flex; flex-direction: column; gap: 3px; font-size: 12.5px; color: #4A5363">
<li>붙여 넣기(Ctrl+V) · 끌어다 놓기 모두. 끌어다 놓은 다른 파일도 첨부로 올린다 — 알림은 이미지를 돌렸을 때만.</li>
<li>한도는 첨부와 같다. 넘치면 첨부 목록에 실패로 보인다 (오류 · 빈 상태 보드의 "첨부 업로드").</li>
<li>끌어다 놓는 동안의 덮개는 2px #0E7C86 점선 · 바탕 #F3F8F8 — 포커스 선(실선)과 모양이 다르다.</li>
</ul>
</section>
<section style="min-width: 0; display: flex; flex-direction: column; gap: 14px; padding: 20px; background: #FFFFFF; border: 1px solid #E6E8EC; border-radius: 12px">
<div style="display: flex; flex-direction: column; gap: 2px">
<h2 style="margin: 0; font-size: 14px; font-weight: 700">표 넣기</h2>
<p style="margin: 0; font-size: 12px; color: #667085">더보기 › 표 넣기 — 행 · 열을 고릅니다</p>
</div>
<div role="dialog" aria-label="표 넣기 (예시)" style="align-self: flex-start; box-sizing: border-box; padding: 12px; display: flex; flex-direction: column; gap: 8px; background: #FFFFFF; border: 1px solid #E6E8EC; border-radius: 10px; box-shadow: 0 10px 28px rgba(16, 24, 28, 0.12)">
<span style="font-size: 12.5px; font-weight: 600; color: #4A5363">표 넣기</span>
<div aria-hidden="true" style="display: grid; grid-template-columns: repeat(8, 22px); gap: 3px">
<span style="width: 22px; height: 22px; box-sizing: border-box; border: 1px solid #0E7C86; border-radius: 3px; background: #E3F2F3"></span><span style="width: 22px; height: 22px; box-sizing: border-box; border: 1px solid #0E7C86; border-radius: 3px; background: #E3F2F3"></span><span style="width: 22px; height: 22px; box-sizing: border-box; border: 1px solid #0E7C86; border-radius: 3px; background: #E3F2F3"></span><span style="width: 22px; height: 22px; box-sizing: border-box; border: 1px solid #0E7C86; border-radius: 3px; background: #E3F2F3"></span><span style="width: 22px; height: 22px; box-sizing: border-box; border: 1px solid #D0D5DD; border-radius: 3px; background: #FFFFFF"></span><span style="width: 22px; height: 22px; box-sizing: border-box; border: 1px solid #D0D5DD; border-radius: 3px; background: #FFFFFF"></span><span style="width: 22px; height: 22px; box-sizing: border-box; border: 1px solid #D0D5DD; border-radius: 3px; background: #FFFFFF"></span><span style="width: 22px; height: 22px; box-sizing: border-box; border: 1px solid #D0D5DD; border-radius: 3px; background: #FFFFFF"></span>
<span style="width: 22px; height: 22px; box-sizing: border-box; border: 1px solid #0E7C86; border-radius: 3px; background: #E3F2F3"></span><span style="width: 22px; height: 22px; box-sizing: border-box; border: 1px solid #0E7C86; border-radius: 3px; background: #E3F2F3"></span><span style="width: 22px; height: 22px; box-sizing: border-box; border: 1px solid #0E7C86; border-radius: 3px; background: #E3F2F3"></span><span style="width: 22px; height: 22px; box-sizing: border-box; border: 1px solid #0E7C86; border-radius: 3px; background: #E3F2F3"></span><span style="width: 22px; height: 22px; box-sizing: border-box; border: 1px solid #D0D5DD; border-radius: 3px; background: #FFFFFF"></span><span style="width: 22px; height: 22px; box-sizing: border-box; border: 1px solid #D0D5DD; border-radius: 3px; background: #FFFFFF"></span><span style="width: 22px; height: 22px; box-sizing: border-box; border: 1px solid #D0D5DD; border-radius: 3px; background: #FFFFFF"></span><span style="width: 22px; height: 22px; box-sizing: border-box; border: 1px solid #D0D5DD; border-radius: 3px; background: #FFFFFF"></span>
<span style="width: 22px; height: 22px; box-sizing: border-box; border: 1px solid #0E7C86; border-radius: 3px; background: #E3F2F3"></span><span style="width: 22px; height: 22px; box-sizing: border-box; border: 1px solid #0E7C86; border-radius: 3px; background: #E3F2F3"></span><span style="width: 22px; height: 22px; box-sizing: border-box; border: 1px solid #0E7C86; border-radius: 3px; background: #E3F2F3"></span><span style="width: 22px; height: 22px; box-sizing: border-box; border: 1px solid #0E7C86; border-radius: 3px; background: #E3F2F3"></span><span style="width: 22px; height: 22px; box-sizing: border-box; border: 1px solid #D0D5DD; border-radius: 3px; background: #FFFFFF"></span><span style="width: 22px; height: 22px; box-sizing: border-box; border: 1px solid #D0D5DD; border-radius: 3px; background: #FFFFFF"></span><span style="width: 22px; height: 22px; box-sizing: border-box; border: 1px solid #D0D5DD; border-radius: 3px; background: #FFFFFF"></span><span style="width: 22px; height: 22px; box-sizing: border-box; border: 1px solid #D0D5DD; border-radius: 3px; background: #FFFFFF"></span>
<span style="width: 22px; height: 22px; box-sizing: border-box; border: 1px solid #D0D5DD; border-radius: 3px; background: #FFFFFF"></span><span style="width: 22px; height: 22px; box-sizing: border-box; border: 1px solid #D0D5DD; border-radius: 3px; background: #FFFFFF"></span><span style="width: 22px; height: 22px; box-sizing: border-box; border: 1px solid #D0D5DD; border-radius: 3px; background: #FFFFFF"></span><span style="width: 22px; height: 22px; box-sizing: border-box; border: 1px solid #D0D5DD; border-radius: 3px; background: #FFFFFF"></span><span style="width: 22px; height: 22px; box-sizing: border-box; border: 1px solid #D0D5DD; border-radius: 3px; background: #FFFFFF"></span><span style="width: 22px; height: 22px; box-sizing: border-box; border: 1px solid #D0D5DD; border-radius: 3px; background: #FFFFFF"></span><span style="width: 22px; height: 22px; box-sizing: border-box; border: 1px solid #D0D5DD; border-radius: 3px; background: #FFFFFF"></span><span style="width: 22px; height: 22px; box-sizing: border-box; border: 1px solid #D0D5DD; border-radius: 3px; background: #FFFFFF"></span>
<span style="width: 22px; height: 22px; box-sizing: border-box; border: 1px solid #D0D5DD; border-radius: 3px; background: #FFFFFF"></span><span style="width: 22px; height: 22px; box-sizing: border-box; border: 1px solid #D0D5DD; border-radius: 3px; background: #FFFFFF"></span><span style="width: 22px; height: 22px; box-sizing: border-box; border: 1px solid #D0D5DD; border-radius: 3px; background: #FFFFFF"></span><span style="width: 22px; height: 22px; box-sizing: border-box; border: 1px solid #D0D5DD; border-radius: 3px; background: #FFFFFF"></span><span style="width: 22px; height: 22px; box-sizing: border-box; border: 1px solid #D0D5DD; border-radius: 3px; background: #FFFFFF"></span><span style="width: 22px; height: 22px; box-sizing: border-box; border: 1px solid #D0D5DD; border-radius: 3px; background: #FFFFFF"></span><span style="width: 22px; height: 22px; box-sizing: border-box; border: 1px solid #D0D5DD; border-radius: 3px; background: #FFFFFF"></span><span style="width: 22px; height: 22px; box-sizing: border-box; border: 1px solid #D0D5DD; border-radius: 3px; background: #FFFFFF"></span>
<span style="width: 22px; height: 22px; box-sizing: border-box; border: 1px solid #D0D5DD; border-radius: 3px; background: #FFFFFF"></span><span style="width: 22px; height: 22px; box-sizing: border-box; border: 1px solid #D0D5DD; border-radius: 3px; background: #FFFFFF"></span><span style="width: 22px; height: 22px; box-sizing: border-box; border: 1px solid #D0D5DD; border-radius: 3px; background: #FFFFFF"></span><span style="width: 22px; height: 22px; box-sizing: border-box; border: 1px solid #D0D5DD; border-radius: 3px; background: #FFFFFF"></span><span style="width: 22px; height: 22px; box-sizing: border-box; border: 1px solid #D0D5DD; border-radius: 3px; background: #FFFFFF"></span><span style="width: 22px; height: 22px; box-sizing: border-box; border: 1px solid #D0D5DD; border-radius: 3px; background: #FFFFFF"></span><span style="width: 22px; height: 22px; box-sizing: border-box; border: 1px solid #D0D5DD; border-radius: 3px; background: #FFFFFF"></span><span style="width: 22px; height: 22px; box-sizing: border-box; border: 1px solid #D0D5DD; border-radius: 3px; background: #FFFFFF"></span>
</div>
<span style="font-size: 12.5px; color: #344054; font-variant-numeric: tabular-nums">3행 × 4열</span>
</div>
<p style="margin: 0; font-size: 12px; color: #4A5363">칸 22px · 간격 3 (밀도 예외 · 사용자 확정). 마우스는 칸 위로 움직여 고르고 누르면 넣습니다. 키보드는 화살표 + Enter. 표 안의 행 · 열 추가와 삭제는 다음 단계.</p>
</section>
</div>
</div>
</div>
</x-dc>
<script type="text/x-dc" data-dc-script data-props='{"$preview":{"width":1440,"height":3576}}'>
class Component extends DCLogic {
renderVals() {
return {};
}
}
</script>
</body>
</html>
+145 -5
View File
@@ -2,10 +2,12 @@
확정된 디자인 캔버스의 원본이다. 화면 구현의 기준으로 쓴다.
- 캔버스: https://claude.ai/artifact/WJvVsKvXepxn3p75CGJAr2 (확정본: 2026-10-26, 버전 11)
- 캔버스: https://claude.ai/artifact/WJvVsKvXepxn3p75CGJAr2 (확정본: 2026-10-27, 버전 14)
- 화면: `Login`(로그인) · `Main`(분할 보기: 메일함·목록·본문) · `MainList`(목록 보기: 게시판형 표) · `MainListRead`(목록 보기에서 연 메일)
· `Compose`(메일 쓰기) · `States`(오류·빈 상태·피드백 모음)
· `Compose`(메일 쓰기 — 서식 편집기) · `ComposeEditor`(메일 쓰기 · 편집기 상태: 포커스·툴바·메뉴·팝오버·인용·이미지 첨부)
· `States`(오류·빈 상태·피드백 모음)
- 파일 형식은 캔버스 전용(`<x-dc>` 마크업 + `DCLogic` 클래스)이다. 그대로 쓰지 말고 **구조·간격·색·문구의 기준**으로만 본다.
`Compose` 의 툴바·메뉴는 눌러 볼 수 있는 시안이다 — 버튼 상태만 바뀌고 본문 글자에 서식이 입혀지지는 않는다.
## 캔버스 안의 자산 주소
@@ -28,16 +30,154 @@
- 목록 보기 행: 1줄 표, 38px. 칸 = 별 32 · 보낸 사람 168 · 제목(나머지) · 날짜 84 · 크기 64 (px, 칸 사이 12).
폭 1100px 미만에서 크기 칸 숨김, 600px 미만에서 보낸 사람·날짜 축소, 더 좁으면 가로 스크롤.
캔버스는 파서 제약 때문에 grid 로 그렸지만 **구현은 실제 `<table>`** 로 한다.
- 별 버튼·"분할 | 목록" 전환은 밀도를 위해 32~36px 로 둔다(클릭 영역 44px 기준의 의도된 예외 — 사용자 확정).
- **클릭 영역은 44px 이 기본**이다. 아래만 밀도를 위한 의도된 예외다(사용자 확정).
- 목록 표의 별 버튼(폭 32px) · "분할 | 목록" 전환(바깥 36px)
- 메일 쓰기 편집기(2026-10-27): 툴바 아이콘 버튼 40×44(새로고침·이전/다음 버튼과 같은 크기) · 메뉴 항목 높이 40(메일함 항목과 같다)
· 색 견본 칸 40×40 · 표 고르기 격자 칸 22(간격 3, 키보드는 화살표)
- 보기 방식: 목록 머리의 "분할 | 목록" 전환. 선택은 사용자별로 기억한다.
- **콘텐츠 열(읽기·쓰기 공통)**: 최대 폭 800px, 좌우 여백 32px, 위 20px·아래 36px, 본문 글줄 최대 680px, 영역 안 왼쪽 정렬.
분할 보기 읽기·목록 보기 읽기·쓰기 세 곳이 같은 값을 쓰고, 툴바 첫 아이콘도 같은 32px 선에서 시작한다.
- 브랜드 컬러 "바다 청록": 바탕 `#0B3640`, 강조 `#0E7C86`. 파생 색은 각 화면 원본의 인라인 스타일을 따른다.
## 메일 쓰기 (2026-10-27 확정 — `Compose` · `ComposeEditor`)
위에서부터 머리(제목·닫기) → 받는 사람(→ 참조·숨은참조) → 제목 → 본문 툴바 → 본문 → 첨부 → 보내기 줄이다. 모두 콘텐츠 열 안에 있다.
첨부 안내문은 "파일당 최대 20MB · 합계 25MB · 최대 20개 · 문서, 이미지, 압축 파일" 이다(용량은 서버 값, 개수는 계약 상수).
### 글자 입력칸 포커스 — 한 가지 규칙
| 항목 | 값 |
| --- | --- |
| 대상 | 받는 사람 · 참조 · 숨은참조 · 제목 · 본문 편집기 |
| 위치 | 그 칸의 아래 hairline 자리. 본문은 편집 영역 아래(첨부 영역 위) |
| 두께 · 색 | 2px `--color-brand`(#0E7C86). 1px 선의 색을 바꾸고 안쪽 그림자 1px 를 더한다 — 높이가 변하지 않는다 |
| 모서리 | 없음(곧은 선) |
| 범위 | 행 전체 폭 — 원래 hairline 과 같은 길이(콘텐츠 열, 최대 800px). 이름표 아래까지 이어진다 |
| 언제 | 글자 입력 요소(input·편집 영역)에 포커스가 있을 때. 마우스로 눌러도 보인다(`:focus`) |
| 대비 | 흰 바탕 4.9:1 · 원래 선 #EEF0F3 4.3:1 · 오류 띠 #FEF3F2 4.5:1 (모두 3:1 이상) |
- **버튼 고리와의 관계**: 같은 색·같은 2px 이고 모양만 다르다. 누르는 것(버튼·링크)은 전역 `:focus-visible` 고리(바깥 2px, 버튼 모서리를 따른다),
쓰는 곳은 선이다. 행 안의 버튼(받는 사람 토큰 ✕ · "참조 · 숨은참조")에 포커스가 가면 고리만 보이고 행의 선은 회색 그대로다 — 한 번에 하나만 보인다.
- 테두리가 있는 입력칸(링크 팝오버·로그인)은 상자가 있으므로 버튼처럼 고리를 쓴다.
- 구현(tokens.css 변수):
```css
.compose-field:has(:is(input, .ProseMirror):focus) {
border-bottom-color: var(--color-brand);
box-shadow: inset 0 calc(var(--border-width) * -1) 0 var(--color-brand);
}
.compose-field :is(input, .ProseMirror):focus-visible { outline: none; }
```
`.compose-field` 는 받는 사람·참조·숨은참조 행, 제목 행, 본문 편집 영역에 붙인다. 편집 영역이 아래 hairline 을 가지므로 첨부 영역의 위 선은 없앤다.
### 본문 서식 편집기 (TipTap)
- **툴바**: 한 줄 · 높이 52(위아래 4) · 왼쪽 여백 20 + 버튼 안 12 → 첫 아이콘이 32px 선. 버튼 사이 2, 묶음 사이 세로선 1×20 #E6E8EC(좌우 4).
- 아이콘 버튼 40×44 · 모서리 8 · 아이콘 16(선 1.75) · #344054. 글꼴 108×44 · 글자 크기 56×44(글자 + ▾).
- 상태: 올림 #F2F4F7 · 켜짐(`aria-pressed`) #E3F2F3 + 안쪽 1px #0E7C86 + 아이콘 #0A5F67(옅은 바탕만으로는 3:1 이 안 돼 선을 둔다)
· 비활성 50% · 포커스는 전역 고리 · 메뉴 열림(`aria-expanded`)은 올림과 같은 바탕.
- `role="toolbar"` — Tab 한 번에 들어가 화살표로 옮긴다. 본문에서 Alt+F10 → 툴바, Esc → 본문.
- **툴바 기능(800px)**: 실행 취소 · 다시 실행 | 글꼴 ▾ · 글자 크기 ▾ | 굵게 · 기울임 · 밑줄 | 글자 색 · 배경 색 | 글머리 목록 · 번호 목록 | 링크 | 더보기
- **더보기(늘)**: 취소선 | 왼쪽 · 가운데 · 오른쪽 정렬 | 내어쓰기 · 들여쓰기 | 인용 · 표 넣기 › · 가로줄 | 서식 지우기
- 항목 높이 40 · 아이콘 16 · 이름 · 단축키(12px #667085) · 켜진 것은 오른쪽 ✓. 값을 고르는 것(글꼴·크기·색·표)은 › 로 한 단계 더 연다.
- 단축키: 굵게 Ctrl+B · 기울임 Ctrl+I · 밑줄 Ctrl+U · 취소선 Ctrl+Shift+S · 글머리 Ctrl+Shift+8 · 번호 Ctrl+Shift+7 · 정렬 Ctrl+Shift+L/E/R
· 내어쓰기 Ctrl+[ · 들여쓰기 Ctrl+] · 인용 Ctrl+Shift+B · 링크 Ctrl+K · 서식 지우기 Ctrl+\ · 실행 취소 Ctrl+Z · 다시 실행 Ctrl+Y
- 맥은 Ctrl 대신 ⌘ 이고 표기도 기호로 보인다(⌘B · ⇧⌘S). 다시 실행은 맥 관례대로 ⇧⌘Z 로 적는다(⌘Y 도 된다).
내어쓰기 · 들여쓰기 · 서식 지우기는 할 일이 없어도 키를 삼킨다 — 맥의 ⌘[ 가 브라우저 "뒤로"가 되어 쓰던 메일을 잃지 않게 (2026-10-27 구현하며 정함).
- **좁은 폭**: 툴바는 줄을 바꾸지 않는다. 묶음째 아래 순서로 더보기에 옮기고, 숨은 묶음은 더보기 맨 위에 툴바 순서대로 들어간다. 더보기 단추에 숫자를 달지 않는다.
1. 실행 취소 · 다시 실행 — 쓰기 칸 734px 미만 (Ctrl+Z · Ctrl+Y 는 그대로)
2. 글꼴 · 글자 크기 — 639px 미만
3. 글자 색 · 배경 색 — 460px 미만
4. 링크 — 365px 미만 (주소를 붙여 넣으면 저절로 링크가 된다)
5. 글머리 · 번호 목록 — 312px 미만
굵게 · 기울임 · 밑줄과 더보기는 늘 남는다. 폭은 지금 크기로 셈한 참고값이다 — 구현은 툴바의 실제 폭을 재어(ResizeObserver) 이 순서로 넘긴다.
`Compose` 보드의 좁은 폭 메뉴는 색 · 목록을 취소선 뒤에 그렸지만, 이 문서의 순서(숨은 묶음 전부를 맨 위에)가 기준이다 — 구현도 이 순서다.
휴대폰에서는 더보기 메뉴가 화면 높이에 맞춰 안에서 스크롤된다.
- **글꼴**: 맑은 고딕(기본) · 굴림 · 돋움 · 바탕 | Arial · Verdana · Georgia · Times New Roman · Courier New. 목록의 이름은 그 글꼴로 보인다.
- 본문 기본 글꼴은 맑은 고딕 스택 `'Malgun Gothic', '맑은 고딕', 'Apple SD Gothic Neo', sans-serif` 다 — 편집기 안 모양을 받는 사람이 보는 모양과 같게 한다.
화면 UI 는 Pretendard 그대로다. 맥에서는 Apple SD 산돌고딕 Neo 로 보인다.
- "맑은 고딕(기본)"을 고르면 글꼴 지정을 지운다.
- **글자 크기(px)**: 12 · 13 · 14(기본) · 16 · 18 · 20 · 24 · 32. 제목 스타일(제목 1·2·3)은 두지 않는다. 고른 글자의 크기가 섞이면 "–".
- **색 팔레트**(8색씩 · 견본 칸 40×40, 보이는 색 26 · 고른 색에 ✓ · 고른 색 이름을 글자로도 보인다):
- 글자 색: 기본 #1F2733 · 회색 #667085 · 빨강 #B42318 · 주황 #B54708 · 초록 #067647 · 청록 #0E7C86 · 파랑 #175CD3 · 보라 #6941C6
— 모두 흰 바탕 4.5:1 이상. "기본"은 글자 색을 지운다.
- 배경 색: 없음 · 노랑 #FFF1A8 · 주황 #FDE4CC · 분홍 #FBDCE3 · 초록 #D9F2DF · 하늘 #D6E8FB · 보라 #E9E0FA · 회색 #E9EBEF
— 본문 글자(#1F2733)가 10:1 이상 남는다. "없음"은 배경 색을 지운다.
- **붙여 넣은 글 · 원문 인용의 서식**: 보낼 수 있는 형식(계약 2절 "서식 본문")만 받고 나머지는 버린다 — 색 이름(red) · Calibri 같은 서식은
기본 모양으로 보인다. 편집기에 보이는 모양이 곧 받는 사람이 보는 모양이다 (2026-10-27 구현하며 정함).
보이지 않을 만큼 작은 글자(8px 미만)와 거의 투명한 글자 색(알파 0.3 미만)도 그 서식을 버린다 — 그렇게 숨긴 글은 드러난다.
바탕과 같은 색(흰 바탕 위 흰 글자 등)은 바탕을 알 수 없어 막지 않는다 (2026-10-27 사용자 확정).
- **링크** 팝오버(폭 300): 주소 · 표시할 글자(비우면 주소). 새 링크는 [취소] [넣기], 이미 있는 링크는 [링크 빼기] … [취소] [적용].
Enter = 적용, Esc = 닫고 본문으로. 주소는 http(s) · mailto 만 받는다.
- **표 넣기**(더보기 ›): 8열 × 6행 격자에서 고른다("3행 × 4열"). 표 안의 행·열 추가·삭제는 다음 단계.
- **메뉴·팝오버 공통**: 흰 바탕 · 테두리 #E6E8EC · 모서리 10 · 그림자 `--shadow-toast` · 툴바 아래 선 바로 밑에서 연다. Esc 로 닫는다.
- **빈 본문**: 안내 문구 "내용을 입력하세요" · #667085(4.9:1). 커서는 문구 앞에 선다.
- **문단**: 문단 사이 여백은 0 — Enter 한 번이 한 줄이다(지금 textarea 와 같다). 줄 간격은 1.75 이고, 보내는 본문도 같은 값이다
(감싸는 div 의 `line-height` — 받는 사람이 편집기와 같은 모양으로 본다, 2026-10-27 구현하며 정함).
여러 칸 띄어쓰기 · 줄 앞 공백도 받는 쪽에서 그대로 보이게 보낸다. 탭은 다음 8칸 자리까지의 공백이 된다(편집 영역의 탭 간격과 같다, 2026-10-27).
### 답장 · 전달의 원문 인용
- 원문은 인용 막대(왼쪽 2px #D0D5DD · 막대와 글 사이 14) 안에 둔다. 원문 글은 #4A5363, 내 글과 24 띄운다.
- 머리는 답장 · 전달 모두 **"원본 메일"**(12.5px 굵게)이고, 그 아래에 항목 이름(#667085) · 값 표를 둔다.
- 답장: 보낸 사람 · 날짜 · 제목 — 커서는 맨 위 첫 줄
- 전달: 보낸 사람 · 날짜 · 받는 사람 · 참조 · 제목 — 커서는 받는 사람 칸
- 막대는 회색이다 — 2px 브랜드색 선은 포커스 표시라 겹치지 않게 한다. 툴바의 "인용"도 같은 막대를 머리 없이 쓴다.
- 초안이 한도(제목 255자 · 본문 텍스트 100,000자 · 서식 본문 500,000자 — 계약 2절)를 넘으면 인용을 줄이고 인용 끝에 "(원문이 길어 이하 생략)"을 둔다(2026-10-26 구현하며 정함 — 캔버스에는 그리지 않았다).
- 처음 맞출 때 내 글 몫으로 한도의 5%를 남긴다. 보내기 직전에 다시 재서 넘으면 인용을 더 줄이고, 편집기에도 줄인 모양을 보인다(실행 취소로 되돌릴 수 있다).
- 내 글만으로 넘으면 보내지 않는다 — 본문 아래(첨부 위)에 받는 사람 오류와 같은 빨간 띠로 알리고 본문에 초점을 준다.
제목이 255자를 넘으면 제목 아래에 같은 띠를 둔다 (2026-10-27 구현하며 정함).
- 본문 띠의 문구는 전용 코드 `MAIL_007`("본문이 너무 길어 보낼 수 없습니다. 내용을 줄인 뒤 다시 보내 주세요.")다 — 서버가 같은 코드를 돌려줘도
같은 띠를 보인다. 제목 띠는 `VAL_001` 그대로다 (제목 칸은 255자에서 더 입력되지 않는다, 2026-10-27 사용자 확정).
- 원문이 서버의 읽기 한도에서 잘렸으면(`isBodyTruncated`) 클라이언트가 줄이지 않았어도 인용 끝에 같은 생략 표시를 둔다.
- 인용할 원문은 읽기 화면과 같은 규칙으로 고른다 — HTML 이 없거나 공백뿐이면 글자 본문(`textBody`)이다. 인용은 그림을 빼므로,
그림을 빼고 남는 글자가 없으면(그림만 있는 뉴스레터 — 그림을 감싼 레이아웃 표 포함) 그때도 글자 본문으로 인용한다 (2026-10-27 리뷰 반영).
- 내가 보낸 메일에 답장하면 받는 사람은 그 메일의 받는 사람이다 — 답장은 원래 받는 사람, 전체 답장은 원래 받는 사람과 참조(둘 다 나는 뺀다).
나에게만 보낸 메일이면 나에게 답장한다 (2026-10-27 사용자 확정 — 전에는 받는 사람 칸이 비었다).
이때 회신 주소(Reply-To)는 보지 않는다. 받는 사람 없이 참조만 있던 메일이면 답장은 나에게, 전체 답장은 나에게 + 원래 참조로 간다.
### 나가기 전 확인 (2026-10-27 사용자 확정 — 캔버스에는 아직 없다)
- 쓰기 화면을 연 뒤 받는 사람 · 제목 · 본문 · 첨부를 바꿨으면, 화면을 떠나기 전에 묻는다 — 답장 · 전달로 처음 채운 내용은 "바꿈"이 아니다.
- 앱 안 이동(앱 레일 · 메일함 · 뒤로/앞으로 · 쓰기 화면의 취소 · 닫기 ✕)은 확인 대화상자를 띄운다 — 세션 만료 대화상자와 같은 모양이다.
제목 "보내지 않은 메일이 있습니다", 글 "이 화면을 나가면 쓰던 내용이 사라집니다.", 단추 [계속 쓰기](처음 초점 · Esc 와 같다) [나가기].
단추는 오른쪽 정렬이고, 계속 쓰기는 채운 기본 단추 · 나가기는 "취소"와 같은 보조 단추다. 계속 쓰기를 고르면 주소와 방문 기록이 그대로다.
- "바꿈"에는 아직 주소 칩이 되지 않은 받는 사람 입력 글자, 첨부 목록의 모든 변화(사전 검사에 걸린 파일 포함)도 든다.
지운 · 실행 취소로 처음 모양에 돌아오면 바꾸지 않은 것으로 본다. 방문 기록(뒤로 · 앞으로)으로 쓰기 화면끼리 옮겨 갈 때도 묻는다
(쓰기 화면에서는 메일함 패널의 "메일 쓰기"가 "메일 쓰는 중"으로 바뀌어 눌리지 않는다).
- 대화상자가 떠 있는 동안 세션이 끝나면 대화상자를 닫고 그 이동을 취소한다(세션 만료 대화상자가 그 뒤에 뜬다).
- 알려진 한계(vue-router): 뒤로 가기로 연 확인 창이 떠 있는 동안 주소창에는 가려던 주소가 보인다(계속 쓰기를 고르면 돌아온다).
그 사이 뒤로 가기를 두 번 누르면 방문 기록이 한 칸 어긋날 수 있다.
- 탭 닫기 · 새로고침 · 다른 사이트로 가기는 브라우저 기본 확인창을 띄운다(문구는 브라우저가 정한다).
- 보낸 뒤와 세션이 끝났을 때(로그아웃 · 만료 · 다른 탭의 로그아웃)는 묻지 않는다 — 세션 만료 대화상자와 겹치지 않는다.
- **보내는 중에도 묻지 않는다** — 이미 보내기를 눌렀기 때문이다(Gmail 과 같다). 보내는 중에 화면을 떠나면 결과는 알림으로만 알린다:
성공은 평소의 성공 알림, 실패는 그 오류의 사전 문구 알림이다(받는 사람 · 첨부 · 본문 길이 오류처럼 평소 칸 아래에 보이던 것도).
떠난 화면이나 새로 연 쓰기 화면은 그 결과로 바뀌지 않는다 (2026-10-27 리뷰 반영).
단 보내는 중에 탭을 닫거나 새로고침하면 브라우저 기본 확인창은 뜬다 — 요청이 끊길 수 있고 결과를 알릴 화면이 남지 않기 때문이다.
- 맥의 ⌘[ 를 본문 밖에서 눌러 브라우저 "뒤로"가 되어도 이 확인에 걸린다.
### 본문 이미지 → 첨부 파일
- 본문에는 이미지를 넣지 않는다(사용자 확정). 툴바에 이미지 버튼이 없다.
- 본문에 붙여 넣거나(Ctrl+V) 끌어다 놓은 이미지는 첨부 파일로 올린다. 끌어다 놓은 다른 파일도 첨부로 올린다(쓰기 화면 어디에 놓아도 같다).
한도는 첨부와 같다 — 넘치면 첨부 목록에 실패로 보인다(`States` 의 "첨부 업로드").
- 클립보드에 **글이 함께 있으면 글을 붙여 넣는다** — 워드 · 엑셀에서 복사하면 고른 부분의 그림이 글과 함께 따라오기 때문이다.
글 없이 파일만 있을 때 첨부로 돌린다 (2026-10-27 구현하며 정함 — 캔버스 시안은 그림이 있으면 늘 첨부로 돌린다).
- 이름 없이 붙여 넣은 그림은 "붙여넣은 이미지 N.확장자"로 올린다. 원래 이름이 있는 파일은 그 이름 그대로다.
- 끌어다 놓는 동안 본문 위에 점선 덮개(2px #0E7C86 점선 · 바탕 #F3F8F8)와 "놓으면 첨부 파일로 올립니다"를 보인다.
쓰기 화면이 떠 있으면 파일은 창 어디(앱 레일 위 포함)에 놓아도 첨부로 받고, 덮개도 파일이 창 위에 있는 동안 보인다 —
받지 않는 곳에 놓으면 브라우저가 그 파일을 열어 쓰던 메일을 잃기 때문이다 (2026-10-27 구현하며 정함).
- 이미지를 첨부로 돌렸을 때만 성공 알림 "이미지는 본문 대신 첨부 파일로 올렸습니다."를 띄운다(성공 알림과 같은 모양 · 5초).
## 구현하며 정한 보완 (2026-10-26 — 캔버스에는 아직 없다)
- 쓰기 화면 첨부 안내문에 합계·개수 한도를 함께 적는다: "파일당 최대 20MB · 합계 25MB · 최대 20개 · 문서, 이미지, 압축 파일" (용량은 서버 값, 개수는 계약 상수).
- 답장·전달 초안이 한도(제목 255자·본문 100,000자)를 넘으면 인용을 줄이고 끝에 "(원문이 길어 이하 생략)"을 둔다.
- 찾지 못한 첨부(보관 시간이 지난 업로드·지워진 원본)는 실패로 표시하고, 전달 원본에는 "빼기"만 둔다(다시 올릴 수 없다).
- 없는 메일함·메일(주소창에 남은 옛 id 등)에는 "다시 시도" 대신 "받은편지함으로"·"목록으로"를 둔다.
"다시 시도"는 메일 서버 장애처럼 다시 해 보면 나아질 수 있는 오류에만 쓴다.
- 읽기 화면의 본문 안내(2026-10-27 사용자 확정) — "외부 이미지를 차단했습니다"와 같은 모양(아이콘 alert-circle)의 안내 줄을 본문 위에 둔다.
순서는 생략 → 잘림 → 외부 이미지 차단이다. "표시할 수 없음"을 보일 때는 잘림 안내를 숨기고, 공백뿐인 글자 본문은 없는 것으로 본다:
- 서버가 HTML 을 정화 한도 때문에 비웠을 때(`isHtmlBodyOmitted`): 글자 본문이 있으면 "본문이 복잡해 서식 없이 글자만 표시합니다.",
없으면 "본문이 너무 크거나 복잡해 표시할 수 없습니다."
- 보여 주는 본문이 읽기 한도(2MB)에서 잘렸을 때(`isBodyTruncated`): "본문이 길어 앞부분만 표시합니다."
+3 -2
View File
@@ -10,9 +10,10 @@
"Compose.dc.html": { "x": 3040, "y": 0, "w": 1440, "h": 900, "title": "메일 쓰기", "expand": "fill", "is_interactive": true },
"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 }
"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": "메일 쓰기 · 편집기 상태" }
},
"order": ["Main.dc.html", "Login.dc.html", "Compose.dc.html", "States.dc.html", "MainList.dc.html", "MainListRead.dc.html"],
"order": ["Main.dc.html", "Login.dc.html", "Compose.dc.html", "States.dc.html", "MainList.dc.html", "MainListRead.dc.html", "ComposeEditor.dc.html"],
"notes": {
"flow": { "x": 0, "y": -300, "text": "arasystem 웹메일 — 1단계 화면 (로그인 → 메일함 → 쓰기)", "kind": "title1", "maxW": 4480 }
},
+60 -9
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` `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` `SYS_001`
- **적용 범위: 1단계는 웹(브라우저) 전용이다.** 인증은 HttpOnly 쿠키뿐이고 토큰을 본문으로 주지 않는다.
모바일 앱(Flutter)의 인증 방식(토큰 발급·`flutter_secure_storage`)은 앱 착수 때 이 문서에 추가한다.
- **배포 형태: 화면과 API 는 같은 주소(origin)를 쓴다.** 운영은 프론트 nginx 가 `/api` 를 백엔드로 넘기고,
@@ -136,6 +136,8 @@ interface MessageDetail extends MessageSummary {
htmlBody: string | null // 서버가 정화한 HTML (아래 참고)
textBody: string | null
hasBlockedImages: boolean // 외부 이미지를 지웠으면 true → "외부 이미지 차단" 안내
isHtmlBodyOmitted: boolean // HTML 이 있었지만 정화 한도를 넘어 htmlBody 를 비웠으면 true (아래 "읽기 한도")
isBodyTruncated: boolean // 보여 줄 본문(htmlBody — null 이거나 공백뿐이면 textBody)이 읽기 한도에서 잘렸으면 true
attachments: Attachment[]
}
@@ -150,6 +152,13 @@ interface Attachment { id: string; name: string; type: string; size: number }
본문 안 인라인 이미지(`cid:`)는 작은 것만 `data:` URI 로 바꿔 넣고 나머지는 지운다(첨부 목록에는 남는다).
**클라이언트는 그래도 격리된 iframe 에만 그린다** (스크립트 비허용 sandbox + 내부 CSP — 이중 방어).
**읽기 한도**: 상세 조회는 본문 한 부분을 UTF-8 2,000,000바이트까지 읽는다 — 넘는 부분은 잘린 채 오고, 보여 줄 본문이 잘렸으면 `isBodyTruncated: true` 다.
HTML 은 정화하기 **전에** 길이(HTML 부분이 여럿이면 이어 붙인 것이 2,000,000자 이하)와 파서 비용을 본다 —
약 2만 단을 넘는 중첩이나, 깊게 열린 태그 아래로 짝 없는 닫는 태그를 아주 많이 둔 HTML 은 정화가 서버를 수십 초씩 붙잡기 때문이다.
넘으면 정화하지 않고 `htmlBody: null`·`hasBlockedImages: false`·`isHtmlBodyOmitted: true` 로 내려준다. `textBody` 는 그대로라 클라이언트는 그것을 보여 주고
답장·전달 인용도 그것으로 만든다 (HTML 만 있는 메일이면 둘 다 `null`). 수백 단의 인용 스레드·2MB 짜리 큰 표·Outlook 조건부 주석은 한도 안이다.
클라이언트는 두 표시를 읽기 화면에 알리고(서식 없이 표시 · 표시할 수 없음 · 앞부분만 표시), 잘린 본문을 인용할 때는 인용 끝에 생략 표시를 둔다.
### 엔드포인트
| 메서드 · 경로 | 요청 | 성공 | 주요 실패 |
@@ -160,7 +169,7 @@ interface Attachment { id: string; name: string; type: string; size: number }
| `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` |
| `POST /api/messages` | 아래 발송 본문 + `Idempotency-Key` 헤더 | 201 `{ id }` (보낸편지함의 메일 id) | 400 `MAIL_002`·`MAIL_003`·`MAIL_004`·`MAIL_006`·`VAL_001`, 403 `AUTH_003`(보낼 권한 없음), 409 `MAIL_005`(같은 키·다른 내용), 413 `FILE_002`(첨부 합계 초과 포함), 429 `REQ_001`(엔진 발송 한도·같은 키 처리 중·엔진 장애 직후 30초), 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` |
@@ -173,7 +182,8 @@ interface Attachment { id: string; name: string; type: string; size: number }
| 받는 사람 합계 100명 초과 (배열 하나가 100을 넘으면 `VAL_001`) | 400 `MAIL_002` |
| 엔진에서 찾을 수 없는 첨부 id — 올려 두고 오래 지나 만료된 업로드, 그사이 지워진 원본 메일의 첨부 등 | 400 `MAIL_004` — 클라이언트는 해당 첨부를 실패로 표시한다. 올린 파일은 다시 올리게, 전달하는 원본 첨부는 빼게 한다 (원본은 다시 올릴 수 없다) |
| 첨부 20개 초과 | 400 `MAIL_006` — 클라이언트는 첨부를 추가할 때 미리 막는다 |
| JSON 본문 1MB 초과 | 413 `FILE_002` |
| 본문이 너무 김 — 텍스트 100,000자·서식 본문 500,000자 초과(JS 문자열 길이, UTF-16 단위), 서식 본문이 정화 전 비용 한도·정화 뒤 2,000,000바이트 초과 | 400 `MAIL_007` — 메일은 나가지 않는다. 클라이언트는 보내기 전에 같은 한도로 막고 본문 아래에 알린다 (아래 "서식 본문"의 한도) |
| JSON 본문 1MB 초과 (발송 `POST /api/messages` 만 4MB — 서식 본문과 긴 인용 때문) | 413 `FILE_002` |
메일함 정렬(확정 디자인 기준): 받은편지함 → 보낸편지함 → 임시보관함 → 스팸메일함 → 휴지통 → 보관함 → 그 외(`sortOrder`, 이름 순).
@@ -202,11 +212,13 @@ interface MailQuota {
- 같은 키인데 **내용이 다르면**(실패 뒤 받는 사람·제목·본문·첨부를 고친 경우) 보내지 않고 409 `MAIL_005`.
첫 메일이 실제로 나갔을 수 있으므로, 클라이언트는 사용자에게 보낸편지함을 확인하게 하고 **새 키**로 다시 보낼 수 있게 한다.
서버는 첫 발송 때 내용 지문(`Idempotency-Key` 를 키로 한 HMAC — 받는 사람은 키를 모른다)을 함께 남겨 비교한다.
- 지문에 들어가는 것: 받는 사람·참조·숨은참조의 **주소**(대소문자·순서 무시), 제목, 본문(줄바꿈 정규화), 첨부 id(순서 무시), `inReplyTo`.
- 지문에 들어가는 것: 받는 사람·참조·숨은참조의 **주소**(대소문자·순서 무시), 제목, 본문(줄바꿈 정규화), 서식 본문(서버가 정화한 HTML), 첨부 id(순서 무시), `inReplyTo`.
- 들어가지 않는 것: 표시 이름, 첨부 이름 — 이것만 바꾼 재시도는 "같은 내용"으로 보고 처음 결과를 돌려준다.
- **알려진 한계** (1단계는 발송 기록을 따로 저장하지 않고 보낸편지함에서 찾는다):
- 첫 발송 뒤 보낸 사본을 다른 메일함으로 옮기거나 지웠으면, 또는 그사이 보낸편지함·임시보관함에 200통 넘게 쌓였으면 같은 키의 재시도가 **다시 나갈 수 있다**.
- "처리 중" 표시는 서버 메모리에 있어 서버가 재시작되면 사라진다. 엔진 장애 직후의 드문 경합에서도 두 번 나갈 수 있다.
- 서식 본문의 지문은 **정화한 결과**로 만든다 — 첫 발송과 재시도 사이에 서버의 정화 규칙이 바뀌면(배포) 같은 키의 재시도가
`MAIL_005` 로 막힐 수 있다. 그때도 위 409 처리(보낸편지함 확인 → 새 키)를 따른다.
- 이 한계를 없애려면 (사용자, 키) → (지문, 메일 id) 를 서버 DB 에 저장한다 — 서버를 여러 대로 늘릴 때 함께 검토한다.
```jsonc
@@ -215,7 +227,8 @@ interface MailQuota {
"cc": [], // 선택
"bcc": [], // 선택 (받는 사람 합계 100 이하)
"subject": "Re: 메일 서버 이전 일정 공유", // 255자 이하
"body": "도윤님, 확인했습니다.\n…", // 일반 텍스트, 100,000자 이하
"body": "도윤님, 확인했습니다.\n…", // 일반 텍스트(서식 본문의 대체 본문), 100,000자 이하
"bodyHtml": "<div style=\"font-family:…\"><p>도윤님, 확인했습니다.</p>…</div>", // 선택 — 서식 편집기 본문, 500,000자 이하 (아래 "서식 본문")
"attachments": [{ "id": "<업로드 응답 id>", "name": "체크리스트.xlsx" }], // 선택, 20개 이하 (넘으면 MAIL_006)
"inReplyTo": "<답장할 메일의 id>" // 선택 — 스레드 헤더를 서버가 채운다
}
@@ -223,10 +236,48 @@ interface MailQuota {
**전달**: 원본 메일 `attachments[].id` 를 발송 본문의 `attachments` 에 그대로 넣으면 원본 첨부가 다시 붙는다
(같은 계정 안의 파일이라 다시 올릴 필요가 없다). 제목 `Fwd:` 와 인용 본문은 클라이언트가 만들고, `inReplyTo` 는 보내지 않는다.
발송 본문의 첨부 이름(`attachments[].name`)은 업로드한 파일과 같은 규칙으로 확장자를 허용 목록에서 검사한다(밖이면 400 `FILE_001`) — 원본 첨부를 그대로 전달할 때도 같다.
**답장·전달 초안의 길이**: 클라이언트가 초안을 만들 때 한도에 맞춘다 — 제목은 255자, 본문은 100,000자.
넘으면 인용을 줄이고 끝에 생략 표시를 둔다 (넘긴 채 보내면 400 `VAL_001`).
업로드한 파일과 같은 규칙으로 이름의 확장자를 허용 목록에서 검사한다(밖이면 400 `FILE_001`).
**답장·전달 초안의 길이**: 클라이언트가 초안을 만들 때 한도에 맞춘다 — 제목은 255자, 본문은 텍스트 100,000자·서식 본문 500,000자.
넘으면 인용을 줄이고 끝에 생략 표시를 둔다 (본문을 넘긴 채 보내면 400 `MAIL_007`, 제목은 `VAL_001`).
처음 맞출 때는 쓸 글 몫으로 한도의 5%를 남기고(텍스트 95,000자·서식 475,000자), 보내기 직전에 실제로 보낼 본문을 다시 재서 넘으면 인용을 더 줄인다.
인용 없이 쓴 글만으로 넘으면 보내지 않고 쓰기 화면의 본문 아래에 `MAIL_007` 문구로 알린다 (서버가 `MAIL_007` 을 돌려줘도 같다).
**서식 본문 (`bodyHtml`)**: 쓰기 화면의 서식 편집기가 만든 HTML 이다.
- 클라이언트는 같은 내용의 일반 텍스트를 `body` 로 **함께** 보낸다 (받는 쪽 프로그램이 텍스트만 볼 때 쓰는 대체 본문 —
목록은 `- `/`1. `, 인용은 `> ` 를 붙인다). 서버는 둘을 `multipart/alternative` 로 보낸다. `bodyHtml` 이 없으면 텍스트 메일이다.
- 서버는 보내기 전에 **허용 목록으로 정화**한다 — 편집기가 만드는 것만 남긴다:
문단·줄바꿈, 굵게·기울임·밑줄·취소선, 글자 색·배경 색·글꼴·글자 크기(span 의 style), 정렬·들여쓰기·줄 간격, 목록, 인용, 가로줄,
표(칸 폭을 적는 `colgroup`·`col` 포함), 링크(`http`·`https`·`mailto`).
그 밖의 태그·속성·스타일·스크립트는 지운다. 지문에는 정화한 결과가 들어간다.
- 남는 속성: `style`(`a` 를 뺀 모든 태그), `a` 의 `href`(링크에는 이것만 남는다 — 링크 글자 색은 안쪽 span 의 style 로), `ol` 의 `start`,
`td`·`th` 의 `colspan`·`rowspan`(숫자만).
- 남는 style: `color`·`background-color`·`font-family`·`font-size`·`font-weight`·`line-height`·`text-align`·`margin`·`padding`(각 변 포함)·
`border`(각 변·`border-color`·`border-style`·`border-width`·`border-collapse`)·`width`·`min-width`·`vertical-align`·`white-space`.
- style 은 **값의 형식까지** 맞아야 남는다 — 형식이 틀린 선언은 그 선언만 지운다:
글자 크기는 `px` 만 8~99.99px, 색은 `#rgb`·`#rrggbb`·`rgb()`·`rgba()`(색 이름·`hsl()` 은 지운다) — 글자 색(`color`)의 투명도(알파)는 0.3 이상,
여백은 `0` 또는 `px`(음수 없음),
줄 간격은 단위 없는 숫자 1~3(소수 둘째 자리까지), 글꼴은 쓰기 화면의 글꼴 목록과 흔한 대체 이름(`Helvetica`·`Times`·`Courier`·
`AppleGothic`·`AppleMyungjo`)·일반 이름(`sans-serif`·`serif`·`monospace`)으로만 이뤄진 목록.
- 형광 표시는 `mark` 가 아니라 span 의 `background-color` 로 보낸다 (`mark`·`h1` 같은 모르는 태그는 태그만 지우고 글자는 남긴다).
- 정화하고 남는 것이 없으면 `body` 만으로 텍스트 메일을 보낸다.
- 보이지 않을 만큼 작은 글자(8px 미만)·거의 투명한 글자 색(알파 0.3 미만)은 그 선언을 지운다 — 붙여 넣거나 인용한 글에 그렇게 숨긴 글이
그대로 실려 나가지 않게. 바탕과 같은 색(흰 바탕 위 흰 글자 등)은 바탕을 알 수 없어 막지 않는다
(받은 메일을 읽을 때의 정화는 이 제한을 두지 않는다).
- 한도 — 넘으면 400 `MAIL_007`. 엔진·첨부 조회 **전**이라 메일은 나가지 않으므로, 고친 뒤 **같은 키**로 다시 보내면 된다.
정화 전에는 받은 메일과 같은 파서 비용 한도(위 "읽기 한도")를, 정화 뒤에는 결과가 UTF-8 2,000,000바이트 이하인지 본다
(우리 웹메일이 다시 읽을 때 잘리지 않게). 500,000자 안이어도 넘을 수 있다 — 정화가 `&` 를 `&amp;`(5바이트)로 바꾸고,
한글은 글자당 3바이트·이모지는 4바이트다.
- 기본 글꼴은 **맑은 고딕** 이다 — 클라이언트가 본문 전체를 `font-family`·`font-size:14px`·`line-height:1.75` 를 가진 `div` 하나로 감싸 보낸다.
편집기 화면의 CSS 는 메일에 따라가지 않으므로 문단 여백(0)·인용 막대·표 테두리도 각 요소의 인라인 `style` 로 보낸다.
- 클라이언트도 **같은 값 형식만 읽는다** — 붙여 넣은 글과 원문 인용을 편집기로 옮길 때 위 형식 밖의 서식(색 이름·`font-size:0`·목록에 없는 글꼴 등)은
버린다 (글자 크기의 `pt` 는 `px` 로 바꾼다). 서버만 지우면 편집기에서 숨은 글이 받는 사람에게 보이는 식으로 화면과 보낸 메일이 달라진다.
- 여러 칸 띄어쓰기와 줄 앞 공백은 받는 쪽이 합치거나 지우므로 nbsp 로 바꿔 보낸다 (보통 공백과 번갈아 — 긴 공백에서도 줄은 바뀐다).
탭은 다음 8칸 자리까지의 공백으로 바꾼다. 텍스트 대체 본문(`body`)은 그대로 둔다.
- 우리 웹메일이 다시 읽을 때(보낸편지함·받은 사람)도 이 서식이 남아야 한다 — 받은 메일 정화(`htmlBody`)의 허용 목록이 위 서식을 포함한다.
- 답장·전달의 원문 인용은 두 경우 모두 **"원본 메일"** 머리(보낸 사람·날짜·제목, 전달은 받는 사람·참조도)와 회색 인용 막대로 만든다.
원문은 `htmlBody`(없으면 `textBody`)를 편집기로 옮기며, 편집기가 모르는 요소(이미지 등)는 빠진다.
- **본문 안 이미지는 이번 단계에서 넣지 않는다** — 붙여 넣거나 끌어다 놓은 이미지는 첨부 파일로 올린다 (첨부 한도 규칙 그대로).
### 첨부 업로드 한도
@@ -241,4 +292,4 @@ interface MailQuota {
### 이 단계에서 하지 않는 것
실시간 새 메일 알림(SSE), 검색 화면, 임시 저장(Drafts 쓰기), 서식 있는 편집기, 외부 이미지 보기(이미지 중계), 다중 선택 일괄 처리.
실시간 새 메일 알림(SSE), 검색 화면, 임시 저장(Drafts 쓰기), 본문 안 이미지(인라인 cid — 붙여 넣은 이미지는 첨부로), 외부 이미지 보기(이미지 중계), 다중 선택 일괄 처리.
@@ -18,6 +18,7 @@ const Map<String, String> kErrorCodeMessages = {
'MAIL_004': '첨부 파일을 찾을 수 없습니다. 해당 첨부를 빼거나 다시 올린 뒤 보내 주세요.',
'MAIL_005': '이미 보낸 메일과 내용이 달라 보내지 않았습니다. 보낸편지함을 확인한 뒤 다시 보내 주세요.',
'MAIL_006': '첨부는 한 메일에 최대 20개까지 보낼 수 있습니다.',
'MAIL_007': '본문이 너무 길어 보낼 수 없습니다. 내용을 줄인 뒤 다시 보내 주세요.',
};
/// 매핑 실패 시 사용하는 폴백 코드
+11 -2
View File
@@ -21,7 +21,7 @@ server {
access_log /var/log/nginx/access.log main_rid;
# 기본 요청 본문 한도 — 백엔드 JSON 파서 한도(1mb)와 같다. 업로드 경로만 아래 /api 에서 늘린다
# 기본 요청 본문 한도 — 백엔드 JSON 파서 한도(1mb)와 같다. 업로드·발송 경로만 아래 /api 에서 늘린다
client_max_body_size 1m;
# ----------------------------------------
@@ -162,11 +162,20 @@ server {
proxy_pass $backend_upstream;
}
# 메일 발송·조회·첨부 내려받기 — 메일 엔진 응답을 기다리는 시간
# 메일 조회·수정·첨부 내려받기 — 메일 엔진 응답을 기다리는 시간 (본문 한도는 기본 1m)
location ^~ /api/messages {
proxy_read_timeout 90s;
proxy_pass $backend_upstream;
}
# 메일 발송(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 {
client_max_body_size 4m;
proxy_read_timeout 90s;
proxy_pass $backend_upstream;
}
}
# /api 의 nginx 오류 응답 — message 는 비운다 (문구는 클라이언트가 code 로 사전에서 찾는다, 레지스트리와 겹치지 않게).
@@ -0,0 +1,111 @@
import { afterEach, describe, expect, it } from 'vitest'
import { mount, type VueWrapper } from '@vue/test-utils'
import { defineComponent, h, nextTick, ref } from 'vue'
import { flushPromises } from '@/test-utils/mock-api'
import ModalDialog from './ModalDialog.vue'
// jsdom 에는 showModal 이 없다 — 대화상자는 open 속성만 붙였다 뗀다 (ModalDialog 의 대체 경로)
const mounted: VueWrapper[] = []
afterEach(() => {
for (const wrapper of mounted.splice(0)) wrapper.unmount()
})
function mountDialog(open: boolean) {
const wrapper = mount(ModalDialog, {
attachTo: document.body,
props: { open, title: '보내지 않은 메일이 있습니다', message: '글', titleId: 't', messageId: 'm', role: 'alertdialog' },
slots: {
actions: () => [
h('button', { id: 'stay', type: 'button', autofocus: true }, '계속 쓰기'),
h('button', { id: 'leave', type: 'button' }, '나가기'),
],
},
})
mounted.push(wrapper)
return wrapper
}
describe('ModalDialog', () => {
it('제목 · 글을 aria 로 잇고, 열면 autofocus 단추로 · 닫으면 연 때의 자리로 초점을 옮긴다', async () => {
const before = document.createElement('button')
document.body.append(before)
before.focus()
const wrapper = mountDialog(false)
const dialog = wrapper.get('dialog')
expect(dialog.attributes()).toMatchObject({ role: 'alertdialog', 'aria-labelledby': 't', 'aria-describedby': 'm' })
expect(dialog.attributes('open')).toBeUndefined()
await wrapper.setProps({ open: true })
await flushPromises()
expect(dialog.attributes('open')).toBeDefined()
expect(document.activeElement?.id).toBe('stay')
await wrapper.setProps({ open: false })
expect(dialog.attributes('open')).toBeUndefined()
expect(document.activeElement).toBe(before)
before.remove()
})
it('Esc(cancel)는 브라우저가 닫지 않게 막고 cancel 로 알린다 — 닫을지는 쓰는 쪽이 정한다', async () => {
const wrapper = mountDialog(true)
await flushPromises()
const cancel = new Event('cancel', { cancelable: true })
wrapper.get('dialog').element.dispatchEvent(cancel)
expect(cancel.defaultPrevented).toBe(true)
expect(wrapper.emitted('cancel')).toHaveLength(1)
expect(wrapper.get('dialog').attributes('open')).toBeDefined()
})
it('열려 있어야 하는데 브라우저가 스스로 닫으면(close) cancel 로 알린다 — 쓰는 쪽이 닫았을 때는 알리지 않는다', async () => {
const wrapper = mountDialog(true)
await flushPromises()
const dialog = wrapper.get('dialog').element
dialog.dispatchEvent(new Event('close'))
expect(wrapper.emitted('cancel')).toHaveLength(1)
await wrapper.setProps({ open: false })
dialog.dispatchEvent(new Event('close'))
expect(wrapper.emitted('cancel')).toHaveLength(1)
})
it('한 대화상자가 닫히며 다른 대화상자가 열리면 닫기가 먼저다 — 여는 쪽을 먼저 고쳐도 둘이 겹쳐 열리지 않는다', async () => {
const first = ref<boolean>(true)
// 여는 쪽(B)을 앞에 둔다 — 화면을 고치는 순서가 B 먼저여도 A 가 먼저 닫혀야 한다 (세션 만료 대화상자가 앞에 있는 앱과 같다)
const Pair = defineComponent({
setup() {
return () => [
h(ModalDialog, { open: !first.value, title: 'B', message: 'b', titleId: 'b-t', messageId: 'b-m', id: 'b' }),
h(ModalDialog, { open: first.value, title: 'A', message: 'a', titleId: 'a-t', messageId: 'a-m', id: 'a' }),
]
},
})
const wrapper = mount(Pair, { attachTo: document.body })
mounted.push(wrapper)
await flushPromises()
const changes: string[] = []
const observer = new MutationObserver((records) => {
for (const record of records) {
const target = record.target as HTMLDialogElement
changes.push(`${target.id}:${target.hasAttribute('open') ? 'open' : 'closed'}`)
}
})
for (const dialog of wrapper.findAll('dialog')) {
observer.observe(dialog.element, { attributes: true, attributeFilter: ['open'] })
}
first.value = false
await nextTick()
await flushPromises()
observer.disconnect()
expect(changes).toEqual(['a:closed', 'b:open'])
})
})
@@ -0,0 +1,182 @@
<script setup lang="ts">
// 모달 대화상자 틀 — 브라우저 <dialog>(showModal)로 띄운다. 모양은 디자인 원본 States "세션 만료".
// 쓰는 곳: SessionExpiredDialog(세션 만료) · ComposeLeaveDialog(쓰기 화면 나가기 전 확인)
// - 모달이라 뒤의 화면을 조작할 수 없다. 처음 초점은 autofocus 를 붙인 단추 (없으면 브라우저가 첫 단추로)
// - Esc(cancel)는 브라우저가 닫지 않게 막고 cancel 로 알린다 — 무엇을 할지는 쓰는 쪽이 정한다
// - 닫기는 열기보다 먼저 한다 — 한 대화상자가 닫히고 다른 대화상자가 열리는 때(세션 만료) 둘이 겹치지 않게
// 단추는 actions 슬롯에 둔다: class="modal-dialog__action" (보조 단추는 modal-dialog__action--secondary 를 더한다)
import { onMounted, ref, watch } from 'vue'
interface Props {
open: boolean
title: string
message: string
/** 제목 · 글 문단의 id — aria-labelledby · aria-describedby 가 가리킨다 (화면에 하나뿐인 값) */
titleId: string
messageId: string
/** 사용자가 답해야 하는 확인이면 'alertdialog' — 주지 않으면 dialog 그대로 */
role?: 'alertdialog'
}
interface Emits {
/** Esc 로 닫으려 했다 — 브라우저가 막을 수 없게 스스로 닫았을 때도 같다 */
(e: 'cancel'): void
}
const props = defineProps<Props>()
const emit = defineEmits<Emits>()
const dialogRef = ref<HTMLDialogElement | null>(null)
/** 열기 전에 초점이 있던 곳 — 닫으면 돌려준다 (브라우저가 돌려주지 않는 환경에서도) */
let returnFocus: HTMLElement | null = null
function show() {
const dialog = dialogRef.value
if (!dialog || dialog.open) return
returnFocus = document.activeElement instanceof HTMLElement ? document.activeElement : null
// showModal 이 없는 환경(구형 브라우저·테스트)에서는 열린 상태만 표시한다
if (typeof dialog.showModal === 'function') dialog.showModal()
else dialog.setAttribute('open', '')
// showModal 은 autofocus 단추로 초점을 옮긴다 — 없는 환경에서도 같게
dialog.querySelector<HTMLElement>('[autofocus]')?.focus()
}
function hide() {
const dialog = dialogRef.value
if (!dialog?.open) return
if (typeof dialog.close === 'function') dialog.close()
else dialog.removeAttribute('open')
// 초점이 대화상자 안에 남았거나 갈 곳을 잃었을 때만 돌려준다 (브라우저가 이미 돌려줬으면 그대로)
const active = document.activeElement
if (active === null || active === document.body || dialog.contains(active)) returnFocus?.focus()
returnFocus = null
}
function onCancel(event: Event) {
event.preventDefault()
emit('cancel')
}
/** 열려 있어야 하는데 닫혔다 — 브라우저가 스스로 닫았다 (Esc 를 거듭 누르는 등). 쓰는 쪽의 상태와 맞춘다 */
function onClose() {
if (props.open) emit('cancel')
}
// 닫기는 쓰는 쪽이 다시 그릴 때 바로, 열기는 화면을 다 고친 뒤에 (위 "닫기는 열기보다 먼저")
watch(
() => props.open,
(open) => {
if (!open) hide()
},
)
watch(
() => props.open,
(open) => {
if (open) show()
},
{ flush: 'post' },
)
onMounted(() => {
if (props.open) show()
})
</script>
<template>
<dialog
ref="dialogRef"
class="modal-dialog"
:role="role"
:aria-labelledby="titleId"
:aria-describedby="messageId"
@cancel="onCancel"
@close="onClose"
>
<h2
:id="titleId"
class="modal-dialog__title"
>
{{ title }}
</h2>
<p
:id="messageId"
class="modal-dialog__message"
>
{{ message }}
</p>
<div class="modal-dialog__actions">
<slot name="actions" />
</div>
</dialog>
</template>
<style scoped>
.modal-dialog {
width: min(22.5rem, calc(100vw - var(--space-48)));
padding: var(--space-18) var(--space-18) var(--space-14);
border: var(--border-width) solid var(--color-border);
border-radius: var(--radius-xl);
background: var(--color-surface);
color: var(--color-text);
box-shadow: var(--shadow-dialog);
}
.modal-dialog[open] {
display: flex;
flex-direction: column;
gap: var(--space-8);
}
.modal-dialog::backdrop {
background: var(--color-backdrop);
}
.modal-dialog__title {
margin: 0;
font-size: var(--font-size-title-sm);
font-weight: 700;
}
.modal-dialog__message {
margin: 0;
color: var(--color-text-secondary);
}
/* 단추 줄 — 오른쪽 정렬 */
.modal-dialog__actions {
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
gap: var(--space-8);
margin-top: var(--space-4);
}
/* 주 단추 — 브랜드색 (슬롯으로 받은 단추라 :slotted 로 꾸민다) */
:slotted(.modal-dialog__action) {
display: inline-flex;
align-items: center;
min-height: var(--size-touch);
padding: 0 var(--space-16);
border: 0;
border-radius: var(--radius-md);
background: var(--color-brand);
color: var(--color-on-brand);
font-weight: 600;
}
:slotted(.modal-dialog__action:hover) {
background: var(--color-brand-strong);
}
/* 보조 단추 — 바탕 없이 (쓰기 화면의 "취소"와 같은 모양) */
:slotted(.modal-dialog__action--secondary) {
background: transparent;
color: var(--color-text-strong);
font-weight: 500;
}
:slotted(.modal-dialog__action--secondary:hover) {
background: var(--color-surface-muted);
color: var(--color-text);
}
</style>
@@ -2,9 +2,10 @@
// 세션 만료(AUTH_002) 안내 — 디자인 원본 States "세션 만료"
// useApi 가 로그인 상태의 401(갱신 실패)을 받으면 authStore.expireSession() 을 부르고, 이 대화상자가 뜬다.
// 모달이라 뒤의 화면(이미 정리된 세션)을 더 조작할 수 없다. Esc 로 닫아도 로그인 화면으로 보낸다.
import { onMounted, ref, watch } from 'vue'
// 대화상자 틀(열기 · 닫기 · Esc · 모양)은 ModalDialog 가 맡는다.
import { useRoute, useRouter } from 'vue-router'
import ModalDialog from '@/components/feedback/ModalDialog.vue'
import { getErrorMessage } from '@/composables/useApi'
import { useAuthStore } from '@/stores/auth.store'
@@ -12,125 +13,34 @@ const authStore = useAuthStore()
const router = useRouter()
const route = useRoute()
const dialogRef = ref<HTMLDialogElement | null>(null)
/** 문구는 에러 코드 사전에서만 온다 */
const message = getErrorMessage('AUTH_002')
function open() {
const dialog = dialogRef.value
if (!dialog || dialog.open) return
// showModal 이 없는 환경(구형 브라우저·테스트)에서는 열린 상태만 표시한다
if (typeof dialog.showModal === 'function') dialog.showModal()
else dialog.setAttribute('open', '')
}
function close() {
const dialog = dialogRef.value
if (!dialog?.open) return
if (typeof dialog.close === 'function') dialog.close()
else dialog.removeAttribute('open')
}
async function goToLogin() {
// 로그인 후 보던 화면으로 돌아오게 한다 (로그인이 필요한 화면일 때만)
const redirect = route.meta.requiresAuth ? route.fullPath : undefined
authStore.acknowledgeSessionExpired()
await router.push({ name: 'login', query: redirect ? { redirect } : {} })
}
function onCancel(event: Event) {
event.preventDefault()
void goToLogin()
}
watch(
() => authStore.sessionExpired,
(expired) => (expired ? open() : close()),
{ flush: 'post' },
)
onMounted(() => {
if (authStore.sessionExpired) open()
})
</script>
<template>
<dialog
ref="dialogRef"
class="session-dialog"
aria-labelledby="session-expired-title"
aria-describedby="session-expired-message"
@cancel="onCancel"
<ModalDialog
:open="authStore.sessionExpired"
title="다시 로그인해 주세요"
:message="message"
title-id="session-expired-title"
message-id="session-expired-message"
@cancel="goToLogin"
>
<h2
id="session-expired-title"
class="session-dialog__title"
>
다시 로그인해 주세요
</h2>
<p
id="session-expired-message"
class="session-dialog__message"
>
{{ message }}
</p>
<button
type="button"
class="session-dialog__action"
@click="goToLogin"
>
로그인 화면으로
</button>
</dialog>
<template #actions>
<button
type="button"
class="modal-dialog__action"
@click="goToLogin"
>
로그인 화면으로
</button>
</template>
</ModalDialog>
</template>
<style scoped>
.session-dialog {
width: min(22.5rem, calc(100vw - var(--space-48)));
padding: var(--space-18) var(--space-18) var(--space-14);
border: var(--border-width) solid var(--color-border);
border-radius: var(--radius-xl);
background: var(--color-surface);
color: var(--color-text);
box-shadow: var(--shadow-dialog);
}
.session-dialog[open] {
display: flex;
flex-direction: column;
gap: var(--space-8);
}
.session-dialog::backdrop {
background: var(--color-backdrop);
}
.session-dialog__title {
margin: 0;
font-size: var(--font-size-title-sm);
font-weight: 700;
}
.session-dialog__message {
margin: 0;
color: var(--color-text-secondary);
}
.session-dialog__action {
align-self: flex-end;
display: inline-flex;
align-items: center;
min-height: var(--size-touch);
margin-top: var(--space-4);
padding: 0 var(--space-16);
border: 0;
border-radius: var(--radius-md);
background: var(--color-brand);
color: var(--color-on-brand);
font-weight: 600;
}
.session-dialog__action:hover {
background: var(--color-brand-strong);
}
</style>
@@ -0,0 +1,56 @@
<script setup lang="ts">
// 쓰기 화면 나가기 전 확인 — 디자인 README "나가기 전 확인" (세션 만료 대화상자와 같은 모양 · 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 { COMPOSE_LEAVE_DIALOG } from '@/shared/constants/mail'
interface Props {
open: boolean
}
interface Emits {
/** 계속 쓰기 (Esc 포함) — 이동을 취소한다 */
(e: 'stay'): void
/** 나가기 — 쓰던 내용을 버리고 이동한다 */
(e: 'leave'): void
}
defineProps<Props>()
const emit = defineEmits<Emits>()
</script>
<template>
<ModalDialog
id="compose-leave-dialog"
: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>
</template>
@@ -4,6 +4,7 @@ import { createPinia, setActivePinia, type Pinia } from 'pinia'
import { h } from 'vue'
import { ERROR_CODE_MESSAGES } from '@/shared/constants/error-codes.generated'
import { MAIL_BODY_NOTICES } from '@/shared/constants/mail'
import type { MessageDetail } from '@/shared/types/mail'
import { useMailStore } from '@/stores/mail.store'
import { useToastStore } from '@/stores/toast.store'
@@ -98,6 +99,68 @@ describe('MessageReader 도구 모음', () => {
})
})
describe('MessageReader 본문 안내 (계약 2절 "읽기 한도")', () => {
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] } })
}
function notices(wrapper: Awaited<ReturnType<typeof mountWith>>) {
return wrapper.findAll('.reader__notice').map((notice) => ({
kind: notice.attributes('data-notice'),
text: notice.text(),
}))
}
// 화면들이 같은 스토어(선택한 메일)를 보므로 한 번 띄울 때마다 바로 확인한다
it('다 받은 본문이면 안내가 없다 — 표시 값이 없는(이전 서버) 응답도 같다', async () => {
expect(notices(await mountWith(DETAIL))).toEqual([])
expect(notices(await mountWith({ ...DETAIL, isHtmlBodyOmitted: false, isBodyTruncated: false }))).toEqual([])
})
it('서식 본문을 비웠으면(isHtmlBodyOmitted) 글자 본문이 있을 때 · 없을 때 다른 문구', async () => {
const withText = await mountWith({ ...DETAIL, isHtmlBodyOmitted: true, textBody: '글자 본문' })
expect(notices(withText)).toEqual([{ kind: 'htmlOmitted', text: MAIL_BODY_NOTICES.htmlOmitted }])
const withoutText = await mountWith({ ...DETAIL, isHtmlBodyOmitted: true, textBody: null })
expect(notices(withoutText)).toEqual([{ kind: 'unavailable', text: MAIL_BODY_NOTICES.unavailable }])
})
it('생략 → 잘림 순서로, 외부 이미지 차단 안내 앞에 같은 모양으로 둔다', async () => {
const wrapper = await mountWith({
...DETAIL,
htmlBody: '<p>앞부분</p>',
isHtmlBodyOmitted: false,
isBodyTruncated: true,
hasBlockedImages: true,
})
expect(notices(wrapper).map((notice) => notice.kind)).toEqual(['truncated', 'blockedImages'])
expect(notices(wrapper)[0]?.text).toBe(MAIL_BODY_NOTICES.truncated)
const both = await mountWith({ ...DETAIL, isHtmlBodyOmitted: true, isBodyTruncated: true, textBody: '긴 글' })
expect(notices(both).map((notice) => notice.kind)).toEqual(['htmlOmitted', 'truncated'])
// 안내 줄 모양은 하나 — 아이콘 + 글
for (const notice of both.findAll('.reader__notice')) {
expect(notice.element.tagName).toBe('P')
expect(notice.find('svg').exists()).toBe(true)
}
})
})
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 { mailBodyNotices } from '@/shared/utils/mail-body-notices'
import { useMailStore } from '@/stores/mail.store'
interface Props {
@@ -42,6 +43,8 @@ const fromEmail = computed(() => message.value?.from?.email ?? '')
const initial = computed(() => initialOf(message.value?.from?.name, fromEmail.value))
const subject = computed(() => message.value?.subject || '(제목 없음)')
const receivedAt = computed(() => (message.value ? formatMailDateTime(message.value.receivedAt) : ''))
/** 서버가 본문을 다 주지 못했을 때의 안내 — 서식을 비웠으면 먼저, 잘렸으면 다음 (계약 2절 "읽기 한도") */
const bodyNotices = computed(() => (message.value ? mailBodyNotices(message.value) : []))
async function onDelete() {
const current = message.value
@@ -203,9 +206,25 @@ function leaveToList() {
</div>
</div>
<!-- 본문 안내 — 본문이 다 보이는지가 먼저다 (생략 → 잘림), 그다음 외부 이미지 차단 -->
<p
v-for="notice in bodyNotices"
:key="notice.kind"
class="reader__notice"
:data-notice="notice.kind"
>
<AppIcon
name="alert-circle"
:size="15"
class="reader__notice-icon"
/>
<span>{{ notice.text }}</span>
</p>
<p
v-if="message.hasBlockedImages"
class="reader__notice"
data-notice="blockedImages"
>
<AppIcon
name="shield"
@@ -41,6 +41,8 @@ const draft = ref<string>('')
const inputRef = ref<HTMLInputElement | null>(null)
const hasInvalid = computed(() => props.modelValue.some((token) => !token.valid))
/** 아직 토큰이 되지 않은 입력이 있다 — 쓰기 화면이 "쓰던 내용"으로 본다 (나가기 전 확인 · 칸을 벗어나지 않고 뒤로 가기 등) */
const hasDraft = computed(() => draft.value.trim() !== '')
/** 입력한 글을 토큰으로 확정한다 (여러 주소를 붙여 넣었으면 나눠서) */
function commit(text: string = draft.value) {
@@ -90,7 +92,12 @@ function focus() {
inputRef.value?.focus()
}
defineExpose({ commit, focus })
/** 아직 토큰이 되지 않은 입력을 버린다 — 쓰기 화면이 양식을 새로 채울 때 */
function clearDraft() {
draft.value = ''
}
defineExpose({ commit, focus, clearDraft, hasDraft })
</script>
<template>
@@ -8,6 +8,7 @@ import { docToText } from '@/components/mail/editor/mail-serialize'
import { MAIL_BODY_MAX_LENGTH, MAIL_QUOTE_OMITTED_MARK } from '@/shared/constants/mail'
import { MAIL_BODY_HTML_MAX_LENGTH } from '@/shared/constants/mail-editor'
import { editorOf, fileDragEvent, installProseMirrorDom, pasteEvent } from '@/test-utils/prosemirror-dom'
import { LONG_DRAFT_TIMEOUT_MS } from '@/test-utils/timeouts'
import { buildQuoteHtml, textToParagraphHtml } from '@/shared/utils/mail-quote'
type EditorWrapper = VueWrapper<InstanceType<typeof MailEditor>>
@@ -112,7 +113,7 @@ describe('MailEditor (서식 편집기)', () => {
expect(html?.length ?? 0).toBeLessThanOrEqual(INITIAL_DRAFT_LIMITS.html)
expect(text.endsWith(`> ${MAIL_QUOTE_OMITTED_MARK}`)).toBe(true)
expect(text).toContain('> 제목: 긴 메일')
})
}, LONG_DRAFT_TIMEOUT_MS)
describe('보내기 직전의 길이 (텍스트 100,000 · 서식 500,000 — 최종 직렬화 결과로 잰다)', () => {
it('맞춘 초안에 몇 글자 쓰면 그대로 보낸다 — 남겨 둔 자리 안이다', () => {
@@ -127,7 +128,7 @@ describe('MailEditor (서식 편집기)', () => {
expect(content?.text.length).toBeLessThanOrEqual(MAIL_BODY_MAX_LENGTH)
// 인용은 그대로 — 처음 맞춘 모양
expect(mailQuoteOf(editor.state.doc)).toBe(quoteBefore)
})
}, LONG_DRAFT_TIMEOUT_MS)
it('남겨 둔 자리보다 길게 쓰면 인용을 더 줄여 맞추고 편집기에도 그 모양을 보인다 — 내 글은 그대로', () => {
const view = mountEditor({ initialHtml: longQuoteDraft(), fitToLimits: true })
@@ -148,7 +149,7 @@ describe('MailEditor (서식 편집기)', () => {
// 실행 취소로 되돌릴 수 있다
editor.commands.undo()
expect(docToText(editor.state.doc).length).toBeGreaterThan(MAIL_BODY_MAX_LENGTH)
})
}, LONG_DRAFT_TIMEOUT_MS)
it('인용이 없는데(또는 인용을 다 줄여도) 내 글만으로 넘으면 null — 편집기는 그대로 둔다', () => {
const view = mountEditor()
@@ -45,7 +45,7 @@ interface Emits {
(e: 'ready'): void
/** 본문에 붙여 넣거나 끌어다 놓은 파일 — 첨부로 올린다 */
(e: 'files', files: File[]): void
/** 본문 내용이 바뀌었다 (커서만 옮긴 것은 빼고) */
/** 본문 내용이 바뀌었다 (커서만 옮긴 것은 빼고) — 연 때와 다른지는 isChanged 로 묻는다 */
(e: 'change'): void
}
@@ -65,6 +65,12 @@ const revision = ref<number>(0)
let pastedImageCount = 0
/** 편집 영역을 화면에 붙였다 — 그 전에는 편집기 화면(view)이 없다 */
let mounted = false
/**
* 편집기를 다 연 때의 문서(답장 · 전달 초안은 맞춘 뒤) — 쓰기 화면이 "본문을 바꿨는가"를 이것과 비교한다 (나가기 전 확인).
* settled: 같은 문서에 편집기가 스스로 끝 빈 줄을 붙인 모양 — 끝이 인용처럼 문단이 아니면 첫 거래(초점 옮기기 등) 때
* 붙인다(StarterKit 의 TrailingNode). 사용자가 쓴 것이 아니므로 "바꿈"이 아니다.
*/
let opened: { doc: ProseMirrorNode; settled: ProseMirrorNode } | null = null
function initialContent(extensions: AnyExtension[]): Content {
if (!props.initialHtml) return ''
@@ -176,6 +182,13 @@ function getContentForSend(): MailEditorContent | null {
return content
}
/** 연 뒤에 본문을 바꿨는가 — 지웠다 다시 써 처음과 같아졌으면 바꾸지 않은 것이다 */
function isChanged(): boolean {
if (!opened) return false
const { doc } = editor.state
return !doc.eq(opened.doc) && !doc.eq(opened.settled)
}
/**
* 본문에 초점을 준다 — 'start' · 'end' 면 커서를 맨 위(답장) · 맨 끝으로, 주지 않으면 커서 자리 그대로.
* TipTap 의 focus 명령은 다음 화면 그리기 때 초점을 옮긴다 — 곧바로 쓰는 첫 글자가 사라지지 않게 바로 옮긴다.
@@ -194,8 +207,11 @@ onMounted(() => {
editor.mount(bodyRef.value)
mounted = true
}
// 붙이면서 플러그인(실행 취소 기록 등)이 켜진다 — 툴바가 다시 읽게 한다
// 붙이면서 플러그인(실행 취소 기록 · 끝 빈 줄 등)이 켜진다 — 툴바가 다시 읽게 하고, 연 때의 문서를 찍어 둔다.
// settled 는 빈 거래 하나를 적용해 본 모양이다 (화면에 보내지 않는다 — 플러그인이 붙일 것만 미리 본다)
revision.value += 1
const { state } = editor
opened = { doc: state.doc, settled: state.apply(state.tr).doc }
emit('ready')
})
@@ -211,7 +227,7 @@ watch(
},
)
defineExpose({ getContent, getContentForSend, focus })
defineExpose({ getContent, getContentForSend, focus, isChanged })
</script>
<template>
@@ -116,7 +116,7 @@ describe('fitDraftDoc — 답장·전달 초안 한도 (계약 2절)', () => {
expect(fitted.child(0).textContent).toBe('내가 쓴 첫 줄')
})
it('원문 인용이 없는 문서는 그대로 둔다 (넘어도 — 보낼 때 서버가 VAL_001 로 막는다)', () => {
it('원문 인용이 없는 문서는 그대로 둔다 (넘어도 — 쓰기 화면이 보내지 않고, 보내도 서버가 MAIL_007 로 막는다)', () => {
const doc = createDocument(lines(200), schema)
expect(fitDraftDoc(doc, schema, { text: 100, html: 1_000 })).toBe(doc)
@@ -16,7 +16,7 @@ export interface DraftLimits {
html: number
}
/** 발송 본문 한도 — 계약 2절 "발송 본문" (넘기면 400 VAL_001) */
/** 발송 본문 한도 — 계약 2절 "발송 본문" (넘기면 400 MAIL_007) */
export const DRAFT_LIMITS: DraftLimits = { text: MAIL_BODY_MAX_LENGTH, html: MAIL_BODY_HTML_MAX_LENGTH }
/**
@@ -19,6 +19,11 @@ export interface MailEditorHandle {
getContentForSend: () => MailEditorContent | null
/** 본문으로 초점을 옮긴다 — 'start' 는 맨 위 첫 줄(답장), 'end' 는 맨 끝, 주지 않으면 커서 자리 그대로 */
focus: (position?: 'start' | 'end') => void
/**
* 편집기를 연 뒤(답장 · 전달 초안을 맞춘 뒤) 사용자가 본문을 바꿨는가 — 쓰기 화면의 나가기 전 확인이 쓴다.
* 지웠다 다시 써 처음과 같아졌거나, 편집기가 스스로 붙인 끝 빈 줄뿐이면 false
*/
isChanged: () => boolean
}
/** 편집기 청크를 내려받는다 — 쓰기 화면의 defineAsyncComponent 가 부른다 (TipTap 은 이 청크에만 있다) */
@@ -79,6 +79,22 @@ describe('글자 서식 값 — 서버가 남기는 형식만 읽는다 (보이
expect(outgoingHtml(doc)).not.toMatch(/font-size: 0|transparent/)
})
it('보이지 않을 만큼 작은 글자(8px 미만) · 거의 투명한 글자 색(알파 0.3 미만)도 서식째 버린다 — 배경 색의 알파는 그대로', () => {
const tiny = parse('<p><span style="font-size: 1px">작은 글</span></p>')
const pale = parse('<p><span style="color: rgba(0, 0, 0, 0.1)">흐린 글</span></p>')
const word = parse('<p><span style="font-size: 5.25pt">워드 7px</span></p>')
const highlight = parse('<p><span style="background-color: rgba(0, 0, 0, 0)">배경</span></p>')
expect(firstTextStyle(tiny)?.fontSize ?? null).toBeNull()
expect(firstTextStyle(pale)?.color ?? null).toBeNull()
expect(firstTextStyle(word)?.fontSize ?? null).toBeNull()
expect(outgoingHtml(tiny)).not.toContain('font-size: 1px')
expect(outgoingHtml(pale)).not.toContain('rgba')
expect(docToText(tiny)).toBe('작은 글')
expect(firstTextStyle(highlight)?.backgroundColor).toBe('rgba(0, 0, 0, 0)')
expect(firstTextStyle(parse('<p><span style="font-size: 6pt">a</span></p>'))?.fontSize).toBe('8px')
})
it('pt · 긴 소수의 글자 크기는 px 로 맞춰 읽는다 (워드 10pt · 아웃룩 13.3333px), !important 는 뗀다', () => {
expect(firstTextStyle(parse('<p><span style="font-size: 10pt">a</span></p>'))?.fontSize).toBe('13.33px')
expect(firstTextStyle(parse('<p><span style="font-size: 13.3333px">a</span></p>'))?.fontSize).toBe('13.33px')
+1
View File
@@ -59,6 +59,7 @@ describe('에러 코드 사전', () => {
'MAIL_004', // 첨부 만료 — 쓰기 화면이 첨부를 "다시 올리기 필요"로 표시
'MAIL_005', // 같은 키·다른 내용 — 쓰기 화면이 발송 키를 새로 만든다
'MAIL_006', // 첨부 20개 초과 — 첨부 사전 검사가 사전 문구를 직접 꺼내 쓴다
'MAIL_007', // 본문이 너무 김 — 쓰기 화면이 본문 아래 띠로 안내한다 (보내기 전 검사 · 서버 거절 모두)
'FILE_001', // 첨부 형식 사전 검사
'FILE_002', // 첨부 크기 사전 검사 · 413 역매핑
]
+9
View File
@@ -115,6 +115,15 @@ const showErrorUI = (code: ErrorCode, message: string, traceId?: string) => {
useToastStore().show({ kind: 'error', message, traceId })
}
/**
* 알림을 끄고(silent) 보낸 요청의 실패를 나중에 알린다 — 그 실패를 칸 안에서 안내하려던 화면이 이미 사라졌을 때
* (예: 보내는 중에 쓰기 화면을 떠났다). 다른 오류 알림과 같은 길(showErrorUI — 같은 코드 중복 억제 · 세션 만료는 대화상자)이다.
*/
export function notifyApiError(error: ApiError): void {
if (error.canceled) return
showErrorUI(error.code, error.message, error.traceId)
}
// 중앙 Axios 인스턴스
const api: AxiosInstance = axios.create({
baseURL: API_BASE_URL,
@@ -171,6 +171,18 @@ describe('useAttachmentUploads', () => {
stop()
})
it('올린 항목을 돌려준다 — 쓰기 화면이 "처음 채운 첨부"로 기억한다 (나가기 전 확인)', () => {
installMockApi(() => ok({}))
const { uploads, stop } = withUploads()
uploads.addFiles([file('회의록.pdf')])
const added = uploads.addExisting([CHECKLIST, { ...CHECKLIST, id: 'att3', name: '견적.pdf' }])
expect(added.map((item) => item.name)).toEqual(['체크리스트.xlsx', '견적.pdf'])
expect(uploads.items.value.slice(1).map((item) => item.key)).toEqual(added.map((item) => item.key))
stop()
})
it('허용 목록 밖의 원본 첨부는 실패(FILE_001)로 보이고 발송에서 빠진다', () => {
installMockApi(() => ok({}))
const { uploads, stop } = withUploads()
@@ -162,8 +162,9 @@ export function useAttachmentUploads(options: AttachmentUploadOptions = {}) {
* 전달 — 원본 메일의 첨부를 목록에 미리 올려 둔다 (다시 올리지 않고 id 를 그대로 보낸다, 계약 2절 "전달").
* 서버는 업로드와 같은 규칙으로 개수·확장자를, 발송 때 합계를 검사한다 — 걸리면 실패로 표시하고 발송에서 뺀다.
* (파일당 한도는 업로드에만 있어 보지 않는다)
* @returns 목록에 올린 항목 — 쓰기 화면이 "처음 채운 첨부"로 기억한다 (나가기 전 확인)
*/
function addExisting(attachments: readonly Attachment[]) {
function addExisting(attachments: readonly Attachment[]): UploadItem[] {
const running: AttachedSummary = { ...attached.value }
const added = attachments.map((attachment): UploadItem => {
const rejected = precheckAttachment(attachment, limitsFor('forward'), running)
@@ -184,6 +185,7 @@ export function useAttachmentUploads(options: AttachmentUploadOptions = {}) {
}
})
items.value = [...items.value, ...added]
return added
}
/**
@@ -0,0 +1,97 @@
import { onBeforeUnmount, readonly, ref, watch } from 'vue'
import { onBeforeRouteLeave, onBeforeRouteUpdate, type RouteLocationNormalized } from 'vue-router'
// 화면을 떠나기 전 확인 — 쓰던 내용을 잃는 이동을 묻는다 (디자인 README "나가기 전 확인")
// - 앱 안 이동(링크 · 앱 레일 · 뒤로/앞으로 · 화면의 취소 · 닫기): 이동 가드가 답을 기다린다. 화면은 confirming 동안 대화상자를 띄운다.
// 남기로 하면 이동을 취소한다 — 뒤로/앞으로였으면 라우터가 주소와 방문 기록을 되돌린다.
// - 창 닫기 · 새로고침 · 다른 사이트로 가기: 지킬 것이 있는 동안만 beforeunload 를 걸어 브라우저 기본 확인창을 띄운다.
// 화면 컴포넌트의 setup 에서 부른다 — 이동 가드(onBeforeRouteLeave · onBeforeRouteUpdate)는 RouterView 가 그린 화면에 걸린다.
export interface LeaveConfirmOptions {
/**
* 지금 앱 안에서 떠나면 잃는 것이 있다 — 이때만 묻는다. 반응형으로 읽는다.
* 묻는 중에 false 가 되면(세션이 끝남 등) 대화상자를 닫고 그 이동은 취소한다 — 그 뒤의 이동은 다른 흐름(로그인 화면으로)이 한다.
*/
active: () => boolean
/**
* 창 닫기 · 새로고침 확인(beforeunload)을 걸 때 — 주지 않으면 active 와 같다. 반응형으로 읽는다.
* 앱 안 이동은 묻지 않아도 창은 닫으면 안 되는 때가 있다 — 예: 보내는 중 (앱 안에서는 요청이 이어지고 결과를 토스트로 알리지만,
* 창을 닫으면 요청이 끊길 수 있고 결과를 알릴 곳도 없다)
*/
unloadActive?: () => boolean
/**
* 같은 경로 안의 이동(쿼리만 바뀜)도 이 화면을 떠나는 것인가 — 예: 답장 쓰기 → 새 메일 쓰기는 양식을 새로 채운다.
* 주지 않으면 같은 경로 안의 이동은 묻지 않는다.
*/
leavesOnUpdate?: (to: RouteLocationNormalized, from: RouteLocationNormalized) => boolean
}
export function useLeaveConfirm(options: LeaveConfirmOptions) {
/** 대화상자를 띄우고 답을 기다리는 중 */
const confirming = ref<boolean>(false)
/** 기다리는 답 — 뒤로 가기를 거듭 눌러 이동이 겹쳐도 대화상자는 하나, 답도 하나다 */
let pending: Promise<boolean> | null = null
let resolvePending: ((leave: boolean) => void) | null = null
function ask(): Promise<boolean> {
if (!pending) {
pending = new Promise<boolean>((resolve) => {
resolvePending = resolve
})
confirming.value = true
}
return pending
}
/** 답한다 — true: 떠난다 · false: 남는다 (이동을 취소한다) */
function answer(leave: boolean) {
const resolve = resolvePending
pending = null
resolvePending = null
confirming.value = false
resolve?.(leave)
}
function confirmLeave(): true | Promise<boolean> {
return options.active() ? ask() : true
}
onBeforeRouteLeave(() => confirmLeave())
onBeforeRouteUpdate((to, from) => (options.leavesOnUpdate?.(to, from) ? confirmLeave() : true))
/** 창 닫기 · 새로고침 — 브라우저 기본 확인창 (문구는 브라우저가 정한다) */
function onBeforeUnload(event: BeforeUnloadEvent) {
event.preventDefault()
// 옛 브라우저(Chrome · Edge 119 미만)는 returnValue 를 채워야 묻는다
event.returnValue = true
}
let listening = false
function listenBeforeUnload(on: boolean) {
if (on === listening) return
listening = on
if (on) window.addEventListener('beforeunload', onBeforeUnload)
else window.removeEventListener('beforeunload', onBeforeUnload)
}
// 지킬 것이 있는 동안만 건다 — 늘 걸어 두면 일부 브라우저가 페이지를 뒤로 가기 캐시(bfcache)에 두지 않는다 (MDN 권고)
watch(options.unloadActive ?? options.active, listenBeforeUnload, { immediate: true })
watch(options.active, (active) => {
if (!active && pending) answer(false)
})
onBeforeUnmount(() => {
listenBeforeUnload(false)
// 다른 이동이 이겨 화면이 사라진다 — 기다리던 이동은 취소로 끝낸다
if (pending) answer(false)
})
return {
confirming: readonly(confirming),
/** 계속 쓰기 — 이동을 취소한다 */
stay: () => answer(false),
/** 나가기 — 이동을 그대로 보낸다 */
leave: () => answer(true),
}
}
@@ -8,7 +8,7 @@ import { useAuthStore } from '@/stores/auth.store'
import { useToastStore } from '@/stores/toast.store'
import { flushPromises, installMockApi, ok, restoreApiAdapter, type MockHandler } from '@/test-utils/mock-api'
import { installProseMirrorDom } from '@/test-utils/prosemirror-dom'
import { createTestRouter } from '@/test-utils/router'
import { createTestRouter, routeViewProvide } from '@/test-utils/router'
import ComposePage from './ComposePage.vue'
/**
@@ -41,7 +41,11 @@ vi.mock('@/components/mail/editor/mail-editor-handle', async (importOriginal) =>
})
const USER = { email: 'ara.kim@arasystem.it.kr', name: '김아라' }
const EDITOR_LOAD_TIMEOUT_MS = 10_000
/**
* 편집기 청크 · 편집기 자리를 기다리는 시간 — ComposePage.spec.ts 와 같은 값.
* 두 겹 부하에서 이 파일의 테스트가 12.3초까지 걸렸다 (2026-10-27) — 테스트 한도(vitest.config.ts, 20초)보다는 짧게 둔다.
*/
const EDITOR_LOAD_TIMEOUT_MS = 15_000
const ORIGINAL: MessageDetail = {
id: 'm1',
@@ -109,6 +113,7 @@ describe('ComposePage — 서식 편집기 청크', () => {
attachTo: document.body,
global: {
plugins: [pinia, router],
provide: routeViewProvide(router),
// 내려받기 오류는 Vue 가 앱 오류로도 알린다 — 테스트에서는 모아 둔다
config: { errorHandler: (error) => loadErrors.push(error) },
},
@@ -117,11 +122,17 @@ describe('ComposePage — 서식 편집기 청크', () => {
return wrapper
}
/** 붙잡아 둔 청크를 놓아 준다 — 편집기 자리가 그려져 내려받기가 시작될 때까지 기다린 뒤 */
/**
* 붙잡아 둔 청크를 놓아 준다 — 편집기 자리가 그려져 내려받기가 시작될 때까지 기다린 뒤.
* (답장은 원문을 받아 초안을 만들고 편집기 자리를 다시 그린 뒤에야 내려받기를 시작한다 — 편집기 기다림과 같은 시간을 준다)
*/
async function releaseEditor() {
await vi.waitFor(() => {
if (!loader.release) throw new Error('아직 내려받기를 시작하지 않았다')
})
await vi.waitFor(
() => {
if (!loader.release) throw new Error('아직 내려받기를 시작하지 않았다')
},
{ timeout: EDITOR_LOAD_TIMEOUT_MS },
)
loader.release?.()
}
@@ -139,9 +150,13 @@ describe('ComposePage — 서식 편집기 청크', () => {
loader.mode = 'fail-once'
const view = await mountCompose('/mail/compose')
await vi.waitFor(() => {
if (!view.find('.compose__editor [role="alert"]').exists()) throw new Error('아직 실패를 보이지 않는다')
})
// 청크 내려받기가 실패로 끝나고 오류 화면이 그려질 때까지 — 편집기 기다림과 같은 시간을 준다
await vi.waitFor(
() => {
if (!view.find('.compose__editor [role="alert"]').exists()) throw new Error('아직 실패를 보이지 않는다')
},
{ timeout: EDITOR_LOAD_TIMEOUT_MS },
)
const alert = view.get('.compose__editor [role="alert"]')
expect(alert.text()).toContain(ERROR_CODE_MESSAGES.SYS_001)
expect(view.get('.compose__send').attributes('disabled')).toBeDefined()
+568 -9
View File
@@ -1,7 +1,14 @@
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
import { mount, type VueWrapper } from '@vue/test-utils'
import { createPinia, setActivePinia, type Pinia } from 'pinia'
import { defineComponent, h, type FunctionalComponent } from 'vue'
import SessionExpiredDialog from '@/components/feedback/SessionExpiredDialog.vue'
import {
AUTH_CHANNEL_NAME,
closeLogoutChannel,
useLogoutSync,
} from '@/composables/useLogoutBroadcast'
import { ERROR_CODE_MESSAGES } from '@/shared/constants/error-codes.generated'
import {
MAIL_BODY_MAX_LENGTH,
@@ -13,6 +20,7 @@ import type { MessageDetail } from '@/shared/types/mail'
import { useAuthStore } from '@/stores/auth.store'
import { useMailStore } from '@/stores/mail.store'
import { useToastStore } from '@/stores/toast.store'
import { FakeBroadcastChannel } from '@/test-utils/broadcast'
import {
fail,
flushPromises,
@@ -24,12 +32,17 @@ import {
type MockResult,
} from '@/test-utils/mock-api'
import { editorOf, fileDragEvent, installProseMirrorDom, pasteEvent } from '@/test-utils/prosemirror-dom'
import { createTestRouter } from '@/test-utils/router'
import { createTestRouter, routeViewProvide } from '@/test-utils/router'
import { LONG_DRAFT_TIMEOUT_MS } from '@/test-utils/timeouts'
import ComposePage from './ComposePage.vue'
const USER = { email: 'ara.kim@arasystem.it.kr', name: '김아라' }
/** 지연 로딩한 편집기를 기다리는 시간 — 테스트를 한꺼번에 돌리면 첫 변환이 느리다 */
const EDITOR_LOAD_TIMEOUT_MS = 10_000
/**
* 지연 로딩한 편집기를 기다리는 시간 — 테스트를 한꺼번에 돌리면 첫 변환이 느리다.
* 전체 실행 두 개를 함께 돌린 부하에서 편집기를 띄우는 테스트가 12.8초까지 걸렸다 (2026-10-27) — 15초로 두되,
* 테스트 한도(vitest.config.ts, 20초)보다 짧게 둬 편집기가 끝내 뜨지 않으면 이 기다림이 먼저 알린다.
*/
const EDITOR_LOAD_TIMEOUT_MS = 15_000
let clock = Date.UTC(2026, 9, 26, 6, 0, 0)
const ORIGINAL: MessageDetail = {
@@ -87,15 +100,20 @@ describe('ComposePage', () => {
vi.useRealTimers()
})
/** attach: 초점 이동 · 화면 전체의 끌기를 확인할 때 문서에 붙인다 */
async function mountCompose(path: string, handler: MockHandler, attach = false) {
/**
* attach: 초점 이동 · 화면 전체의 끌기를 확인할 때 문서에 붙인다.
* 화면은 RouterView 없이 띄운다 — 다른 경로로 가도 화면이 남아 결과를 볼 수 있다. 이동 가드는 지금 경로에 건다 (routeViewProvide)
*/
async function mountCompose(path: string, handler: MockHandler, attach = false, visited: readonly string[] = []) {
const requests = installMockApi(handler)
const router = createTestRouter()
// 방문 기록 — 뒤로 가기를 확인할 때 쓰기 화면 앞에 둘 화면들
for (const earlier of visited) await router.push(earlier)
await router.push(path)
await router.isReady()
const wrapper = mount(ComposePage, {
attachTo: attach ? document.body : undefined,
global: { plugins: [pinia, router] },
global: { plugins: [pinia, router], provide: routeViewProvide(router) },
})
mountedWrappers.push(wrapper)
await flushPromises()
@@ -625,7 +643,7 @@ describe('ComposePage', () => {
await input.trigger('blur')
}
it('내 글만으로 본문 한도를 넘으면 보내지 않고 본문 아래에 사전 문구(VAL_001) — 본문으로 초점, 고치면 내린다', async () => {
it('내 글만으로 본문 한도를 넘으면 보내지 않고 본문 아래에 사전 문구(MAIL_007) — 본문으로 초점, 고치면 내린다', async () => {
const { wrapper, requests } = await mountCompose('/mail/compose', replyApi, true)
await addRecipient(wrapper)
const editor = editorOf(wrapper.element)
@@ -635,8 +653,9 @@ describe('ComposePage', () => {
await flushPromises()
expect(sendRequest(requests)).toBeUndefined()
expect(wrapper.get('#compose-body-error').text()).toBe(ERROR_CODE_MESSAGES.VAL_001)
expect(wrapper.get('#compose-body-error').text()).toBe(ERROR_CODE_MESSAGES.MAIL_007)
expect(wrapper.get('#compose-body').attributes('aria-describedby')).toBe('compose-body-error')
expect(wrapper.get('#compose-body').attributes('aria-invalid')).toBe('true')
expect(document.activeElement?.id).toBe('compose-body')
expect(useToastStore().toasts).toEqual([])
@@ -646,6 +665,45 @@ describe('ComposePage', () => {
wrapper.unmount()
})
it('서버가 본문을 MAIL_007 로 거절하면(정화 한도 등) 토스트 없이 같은 띠 · 잘못된 입력 · 본문으로 초점 — 줄여 다시 보내면 같은 키', async () => {
const outcomes: MockResult[] = [fail(400, 'MAIL_007'), ok({ id: 'sent-1' }, 201)]
const { wrapper, requests } = await mountCompose(
'/mail/compose',
(request) =>
request.method === 'POST' && request.url === '/messages'
? (outcomes.shift() ?? ok({ id: 'sent-2' }, 201))
: replyApi(request),
true,
)
await addRecipient(wrapper)
const editor = editorOf(wrapper.element)
editor.commands.setContent('<p>서식이 아주 복잡한 본문</p>')
;(wrapper.get('#compose-subject').element as HTMLInputElement).focus()
await wrapper.get('form').trigger('submit')
await flushPromises()
expect(wrapper.get('#compose-body-error').text()).toBe(ERROR_CODE_MESSAGES.MAIL_007)
expect(wrapper.get('#compose-body').attributes('aria-describedby')).toBe('compose-body-error')
expect(wrapper.get('#compose-body').attributes('aria-invalid')).toBe('true')
expect(document.activeElement?.id).toBe('compose-body')
expect(useToastStore().toasts).toEqual([])
// 줄이면 띠를 내리고, 다시 보내면 같은 키로 간다 (서버가 엔진에 넘기기 전에 거절했다)
editor.commands.setContent('<p>짧게 줄인 본문</p>')
await flushPromises()
expect(wrapper.find('#compose-body-error').exists()).toBe(false)
await wrapper.get('form').trigger('submit')
await flushPromises()
const keys = requests
.filter((request) => request.method === 'POST' && request.url === '/messages')
.map((request) => request.config.headers.get('Idempotency-Key'))
expect(keys).toHaveLength(2)
expect(keys[1]).toBe(keys[0])
wrapper.unmount()
})
it('답장 원문이 길어도 맞춘 초안에 내 글을 쓰면 보낸다 — 남겨 둔 자리보다 길면 인용을 더 줄여(생략 표시) 보낸다', async () => {
const longOriginal = { ...ORIGINAL, textBody: `${'긴 원문 줄입니다. '.repeat(10)}\n`.repeat(1_000) }
const { wrapper, requests } = await mountCompose('/mail/compose?replyTo=m1', (request) =>
@@ -662,7 +720,7 @@ describe('ComposePage', () => {
expect(payload?.bodyHtml.length).toBeLessThanOrEqual(MAIL_BODY_HTML_MAX_LENGTH)
expect(payload?.body.startsWith(mine.trimEnd())).toBe(true)
expect(payload?.body.endsWith(`> ${MAIL_QUOTE_OMITTED_MARK}`)).toBe(true)
})
}, LONG_DRAFT_TIMEOUT_MS)
it('제목이 255자를 넘으면(붙여 넣기 등) 보내지 않고 제목 아래에 사전 문구 — 제목으로 초점', async () => {
const { wrapper, requests } = await mountCompose('/mail/compose', replyApi, true)
@@ -847,4 +905,505 @@ describe('ComposePage', () => {
expect(new Set([replyKey, newMailKey, reopenedKey]).size).toBe(3)
})
})
describe('나가기 전 확인 (디자인 README "나가기 전 확인")', () => {
const DIALOG = '#compose-leave-dialog'
const CHECKLIST = { id: 'att1', name: '체크리스트.xlsx', type: 'application/vnd.ms-excel', size: 49152 }
const forwardApi: MockHandler = (request) =>
request.method === 'GET' && request.url === '/messages/m1'
? ok({ ...ORIGINAL, attachments: [CHECKLIST] })
: replyApi(request)
/** 이 탭에서 useLogoutSync 를 부르는 최소 화면 (App.vue 대신) */
const LogoutSyncHost = defineComponent({
setup() {
useLogoutSync()
return () => h('div')
},
})
afterEach(() => {
closeLogoutChannel()
FakeBroadcastChannel.closeAll()
vi.unstubAllGlobals()
})
function dialogOpen(wrapper: VueWrapper): boolean {
return wrapper.get(DIALOG).attributes('open') !== undefined
}
/** 창 닫기 · 새로고침 때 브라우저 확인창을 띄우려 하는가 */
function blocksUnload(): boolean {
const event = new Event('beforeunload', { cancelable: true })
window.dispatchEvent(event)
return event.defaultPrevented
}
async function writeSubject(wrapper: VueWrapper, text: string) {
await wrapper.get('#compose-subject').setValue(text)
}
function choose(file: File, wrapper: VueWrapper) {
const picker = wrapper.get('input[type="file"]')
Object.defineProperty(picker.element, 'files', { value: [file], configurable: true })
return picker.trigger('change')
}
it('손대지 않은 답장 초안은 바꾼 것이 아니다 — 편집기가 인용 뒤에 끝 빈 줄을 붙여도 묻지 않고 떠난다', async () => {
const { wrapper, router } = await mountCompose('/mail/compose?replyTo=m1', replyApi, true)
await vi.waitFor(
() => {
if (document.activeElement?.id !== 'compose-body') throw new Error('아직 초점이 없다')
},
{ timeout: EDITOR_LOAD_TIMEOUT_MS },
)
await flushPromises()
// 초점을 옮기는 첫 거래에 편집기가 인용(맨 끝 블록) 뒤에 빈 문단을 붙였다
expect(editorOf(wrapper.element).state.doc.lastChild?.type.name).toBe('paragraph')
expect(blocksUnload()).toBe(false)
await router.push('/mail/inbox')
expect(router.currentRoute.value.fullPath).toBe('/mail/inbox')
expect(dialogOpen(wrapper)).toBe(false)
wrapper.unmount()
})
it('손대지 않은 전달 초안(원본 첨부 포함)도 묻지 않는다 — 원본 첨부를 빼면 바꾼 것이다', async () => {
const { wrapper, router } = await mountCompose('/mail/compose?forward=m1', forwardApi)
expect(blocksUnload()).toBe(false)
await wrapper.get('button[aria-label="체크리스트.xlsx 첨부 취소"]').trigger('click')
expect(blocksUnload()).toBe(true)
void router.push('/mail/inbox')
await flushPromises()
expect(dialogOpen(wrapper)).toBe(true)
})
it('바꾸면 앱 안 이동 전에 묻는다 — alertdialog · 처음 초점은 계속 쓰기, 계속 쓰기는 이동을 취소하고 초점을 돌려준다', async () => {
const { wrapper, router } = await mountCompose('/mail/compose', replyApi, true)
await writeSubject(wrapper, '일정 공유')
const subjectInput = wrapper.get('#compose-subject').element as HTMLInputElement
subjectInput.focus()
const navigation = router.push('/mail/inbox')
await flushPromises()
const dialog = wrapper.get(DIALOG)
expect(dialogOpen(wrapper)).toBe(true)
expect(dialog.attributes('role')).toBe('alertdialog')
expect(dialog.attributes('aria-labelledby')).toBe('compose-leave-title')
expect(dialog.attributes('aria-describedby')).toBe('compose-leave-message')
expect(wrapper.get('#compose-leave-title').text()).toBe('보내지 않은 메일이 있습니다')
expect(wrapper.get('#compose-leave-message').text()).toBe('이 화면을 나가면 쓰던 내용이 사라집니다.')
expect(wrapper.get('#compose-leave-stay').text()).toBe('계속 쓰기')
expect(wrapper.get('#compose-leave-confirm').text()).toBe('나가기')
expect(document.activeElement?.id).toBe('compose-leave-stay')
expect(router.currentRoute.value.fullPath).toBe('/mail/compose')
await wrapper.get('#compose-leave-stay').trigger('click')
await navigation
await flushPromises()
expect(dialogOpen(wrapper)).toBe(false)
expect(router.currentRoute.value.fullPath).toBe('/mail/compose')
expect(subjectInput.value).toBe('일정 공유')
expect(document.activeElement).toBe(subjectInput)
wrapper.unmount()
})
it('쓰기 화면의 닫기 ✕ · 취소도 묻는다 — 나가기를 고르면 쓰던 내용을 버리고 보던 화면으로 간다', async () => {
useMailStore().rememberBrowseLocation('/mail/inbox/m1')
const { wrapper, router } = await mountCompose('/mail/compose', replyApi)
editorOf(wrapper.element).commands.insertContent('쓰던 본문')
await flushPromises()
await wrapper.get('.compose__cancel').trigger('click')
await flushPromises()
expect(dialogOpen(wrapper)).toBe(true)
await wrapper.get('#compose-leave-stay').trigger('click')
await flushPromises()
expect(router.currentRoute.value.fullPath).toBe('/mail/compose')
await wrapper.get('.compose__close').trigger('click')
await flushPromises()
expect(dialogOpen(wrapper)).toBe(true)
await wrapper.get('#compose-leave-confirm').trigger('click')
await flushPromises()
expect(router.currentRoute.value.fullPath).toBe('/mail/inbox/m1')
expect(dialogOpen(wrapper)).toBe(false)
})
it('Esc 는 계속 쓰기와 같다 — 브라우저가 닫지 않게 막고 이동을 취소한다', async () => {
const { wrapper, router } = await mountCompose('/mail/compose', replyApi)
await writeSubject(wrapper, '일정 공유')
const navigation = router.push('/mail/inbox')
await flushPromises()
const cancel = new Event('cancel', { cancelable: true })
wrapper.get(DIALOG).element.dispatchEvent(cancel)
await navigation
await flushPromises()
expect(cancel.defaultPrevented).toBe(true)
expect(dialogOpen(wrapper)).toBe(false)
expect(router.currentRoute.value.fullPath).toBe('/mail/compose')
})
it('뒤로 가기를 취소하면 주소와 방문 기록이 그대로다 — 다시 뒤로 가서 나가기를 고르면 앞 화면으로', async () => {
const { wrapper, router } = await mountCompose('/mail/compose', replyApi, false, ['/mail/inbox'])
await writeSubject(wrapper, '초안')
router.back()
await flushPromises()
expect(dialogOpen(wrapper)).toBe(true)
await wrapper.get('#compose-leave-stay').trigger('click')
await flushPromises()
expect(router.currentRoute.value.fullPath).toBe('/mail/compose')
expect(router.options.history.location).toBe('/mail/compose')
router.back()
await flushPromises()
expect(dialogOpen(wrapper)).toBe(true)
await wrapper.get('#compose-leave-confirm').trigger('click')
await flushPromises()
expect(router.currentRoute.value.fullPath).toBe('/mail/inbox')
expect(router.options.history.location).toBe('/mail/inbox')
})
it('묻는 중에 또 이동하면 대화상자는 하나다 — 나가기를 고르면 마지막 이동으로 간다', async () => {
const { wrapper, router } = await mountCompose('/mail/compose', replyApi)
await writeSubject(wrapper, '일정 공유')
const first = router.push('/mail/inbox')
await flushPromises()
const second = router.push('/mail/sent')
await flushPromises()
expect(wrapper.findAll('dialog[open]')).toHaveLength(1)
await wrapper.get('#compose-leave-confirm').trigger('click')
await Promise.all([first, second])
await flushPromises()
expect(router.currentRoute.value.fullPath).toBe('/mail/sent')
expect(dialogOpen(wrapper)).toBe(false)
})
it('같은 쓰기 화면 안에서도 쓰기 종류가 바뀌면(답장 → 새 메일) 묻는다 — 계속 쓰면 답장 그대로', async () => {
const { wrapper, router } = await mountCompose('/mail/compose?replyTo=m1', replyApi)
await writeSubject(wrapper, 'Re: 고친 제목')
const navigation = router.push('/mail/compose')
await flushPromises()
expect(dialogOpen(wrapper)).toBe(true)
await wrapper.get('#compose-leave-stay').trigger('click')
await navigation
await flushPromises()
expect(router.currentRoute.value.fullPath).toBe('/mail/compose?replyTo=m1')
expect((wrapper.get('#compose-subject').element as HTMLInputElement).value).toBe('Re: 고친 제목')
})
it('받는 사람 칸에 쓰던 글 · 본문 · 첨부(올리는 중 포함)도 바꿈이다 — 지우거나 되돌리면 묻지 않는다', async () => {
// 올리기는 끝내지 않고 붙잡아 둔다 — "올리는 중"인 첨부도 바꿈이다
const upload: { finish: ((result: MockResult) => void) | null } = { finish: null }
const { wrapper } = await mountCompose('/mail/compose', (request) =>
request.method === 'POST' && request.url === '/mail-attachments'
? new Promise<MockResult>((resolve) => {
upload.finish = resolve
})
: replyApi(request),
)
expect(blocksUnload()).toBe(false)
// 받는 사람 칸에 아직 토큰이 되지 않은 글
await wrapper.get('#compose-to').setValue('kim@ara')
expect(blocksUnload()).toBe(true)
await wrapper.get('#compose-to').setValue('')
expect(blocksUnload()).toBe(false)
// 본문 — 쓰고 실행 취소하면 처음과 같다
const editor = editorOf(wrapper.element)
editor.commands.insertContent('안녕하세요')
await flushPromises()
expect(blocksUnload()).toBe(true)
editor.commands.undo()
await flushPromises()
expect(blocksUnload()).toBe(false)
// 첨부 — 올리는 중이어도 바꿈, 빼면 처음과 같다
await choose(new File(['x'], '회의록.pdf'), wrapper)
await flushPromises()
expect(wrapper.findAll('.upload-item--uploading')).toHaveLength(1)
expect(blocksUnload()).toBe(true)
await wrapper.get('button[aria-label="회의록.pdf 첨부 취소"]').trigger('click')
expect(blocksUnload()).toBe(false)
upload.finish?.(ok({ id: 'up1', name: '회의록.pdf', type: 'application/pdf', size: 1 }, 201))
})
it('창 닫기 · 새로고침 확인(beforeunload)은 바꾼 동안만 건다 — 처음으로 돌아오거나 화면을 내리면 뗀다', async () => {
const { wrapper } = await mountCompose('/mail/compose', replyApi)
expect(blocksUnload()).toBe(false)
await writeSubject(wrapper, '제목')
expect(blocksUnload()).toBe(true)
await writeSubject(wrapper, '')
expect(blocksUnload()).toBe(false)
await writeSubject(wrapper, '다시')
expect(blocksUnload()).toBe(true)
wrapper.unmount()
expect(blocksUnload()).toBe(false)
})
it('보낸 뒤에는 묻지 않는다 — 보던 화면으로 바로 돌아가고 창 닫기도 막지 않는다', async () => {
useMailStore().rememberBrowseLocation('/mail/inbox')
const { wrapper, router } = await mountCompose('/mail/compose', replyApi)
const input = wrapper.get('#compose-to')
await input.setValue('doyun.lee@arasystem.it.kr')
await input.trigger('blur')
await writeSubject(wrapper, '일정 공유')
expect(blocksUnload()).toBe(true)
await wrapper.get('form').trigger('submit')
await flushPromises()
expect(router.currentRoute.value.fullPath).toBe('/mail/inbox')
expect(dialogOpen(wrapper)).toBe(false)
expect(blocksUnload()).toBe(false)
})
describe('보내는 중에 떠나기 — 묻지 않고, 결과는 토스트로만 알린다 (떠난 양식은 건드리지 않는다)', () => {
/** 라우터만 쓰는 빈 화면 — 쓰기 화면을 내린 뒤에도 앱(App.vue)처럼 라우터를 붙잡아 둔다 */
const RouterHost: FunctionalComponent = () => h('div')
/** 첫 발송의 응답을 붙잡아 두는 서버 — 그다음 발송은 outcomes 를 차례로 돌려준다 */
function heldSendApi(outcomes: MockResult[] = []) {
const held: { release: ((result: MockResult) => void) | null } = { release: null }
let first = true
const handler: MockHandler = (request) => {
if (request.method !== 'POST' || request.url !== '/messages') return replyApi(request)
if (!first) return outcomes.shift() ?? ok({ id: 'sent-2' }, 201)
first = false
return new Promise<MockResult>((resolve) => {
held.release = resolve
})
}
return { handler, held }
}
function sentKeys(requests: MockRequest[]): unknown[] {
return requests
.filter((request) => request.method === 'POST' && request.url === '/messages')
.map((request) => request.config.headers.get('Idempotency-Key'))
}
function toastMessages(): string[] {
return useToastStore().toasts.map((toast) => toast.message)
}
/**
* 쓰기 화면만 내린다 — 앱(App.vue)처럼 라우터를 쓰는 화면 하나를 남겨 둔다.
* (라우터를 쓰는 앱이 모두 내려가면 vue-router 가 지금 경로를 처음('/')으로 되돌려 "끌고 오지 않았다"를 볼 수 없다)
*/
function leaveScreen(wrapper: VueWrapper, router: ReturnType<typeof createTestRouter>) {
mountedWrappers.push(mount(RouterHost, { global: { plugins: [pinia, router] } }))
wrapper.unmount()
}
/** 고친 답장을 보내 놓는다 — 응답은 붙잡혀 있다 */
async function startSending(wrapper: VueWrapper) {
await writeSubject(wrapper, 'Re: 고친 제목')
await wrapper.get('form').trigger('submit')
await flushPromises()
expect(wrapper.get('.compose__send').text()).toBe('보내는 중…')
}
it('보내는 중에는 앱 안 이동을 묻지 않는다 — 떠난 뒤 보내지면 성공 토스트만, 간 곳에서 끌고 오지 않는다 (창 닫기는 묻는다)', async () => {
useMailStore().rememberBrowseLocation('/mail/inbox/m1')
const { handler, held } = heldSendApi()
const { wrapper, router } = await mountCompose('/mail/compose?replyTo=m1', handler)
await startSending(wrapper)
// 창을 닫으면 요청이 끊길 수 있고 결과를 알릴 곳이 없다 — 창 닫기 · 새로고침은 보내는 중에도 묻는다
expect(blocksUnload()).toBe(true)
await router.push('/mail/sent')
expect(dialogOpen(wrapper)).toBe(false)
expect(router.currentRoute.value.fullPath).toBe('/mail/sent')
// RouterView 안이라면 화면이 내려간다
leaveScreen(wrapper, router)
held.release?.(ok({ id: 'sent-1' }, 201))
await flushPromises()
expect(toastMessages()).toEqual(['메일을 보냈습니다.'])
expect(router.currentRoute.value.fullPath).toBe('/mail/sent')
})
it.each([
['MAIL_002 (평소에는 받는 사람 칸 아래에 알린다)', fail(400, 'MAIL_002'), 'MAIL_002'],
['MAIL_004 (평소에는 첨부 목록에 알린다)', fail(400, 'MAIL_004'), 'MAIL_004'],
['MAIL_007 (평소에는 본문 아래 띠로 알린다)', fail(400, 'MAIL_007'), 'MAIL_007'],
['MAIL_001 (평소에도 토스트)', fail(503, 'MAIL_001'), 'MAIL_001'],
] as const)('떠난 뒤 보내기가 %s 로 끝나면 사전 문구 토스트 하나로 알린다', async (_label, failure, code) => {
const { handler, held } = heldSendApi()
const { wrapper, router } = await mountCompose('/mail/compose?replyTo=m1', handler)
await startSending(wrapper)
await router.push('/mail/inbox')
leaveScreen(wrapper, router)
held.release?.(failure)
await flushPromises()
expect(toastMessages()).toEqual([ERROR_CODE_MESSAGES[code]])
expect(router.currentRoute.value.fullPath).toBe('/mail/inbox')
})
it('보내는 중에 쓰기 종류가 바뀌면(답장 → 새 메일) 묻지 않고 새 양식을 연다 — 앞 보내기가 끝나도 새 양식과 그 키는 그대로', async () => {
const { handler, held } = heldSendApi(['network', ok({ id: 'sent-3' }, 201)])
const { wrapper, router, requests } = await mountCompose('/mail/compose?replyTo=m1', handler)
await startSending(wrapper)
await router.push('/mail/compose')
await flushPromises()
expect(dialogOpen(wrapper)).toBe(false)
expect(wrapper.get('h1').text()).toBe('새 메일')
// 앞 요청에 묶이지 않는다 — 새 양식은 바로 보낼 수 있다
expect(wrapper.get('.compose__send').text()).toBe('보내기')
const input = wrapper.get('#compose-to')
await input.setValue('junho.park@arasystem.it.kr')
await input.trigger('blur')
await writeSubject(wrapper, '새 메일 제목')
// 새 양식의 첫 보내기 — 응답 없음(키를 그대로 둔다)
await wrapper.get('form').trigger('submit')
await flushPromises()
// 앞 양식의 보내기가 이제 끝난다 — 알리기만 한다
held.release?.(ok({ id: 'sent-1' }, 201))
await flushPromises()
expect(toastMessages()).toContain('메일을 보냈습니다.')
expect(router.currentRoute.value.fullPath).toBe('/mail/compose')
expect(chips(wrapper, 'compose-to')).toEqual(['junho.park@arasystem.it.kr'])
expect((wrapper.get('#compose-subject').element as HTMLInputElement).value).toBe('새 메일 제목')
// 앞 양식의 성공이 새 양식의 키를 바꾸지 않았다 — 다시 보내면 같은 키
await wrapper.get('form').trigger('submit')
await flushPromises()
const [oldKey, newKey, retryKey] = sentKeys(requests)
expect(newKey).not.toBe(oldKey)
expect(retryKey).toBe(newKey)
})
it('쓰기 종류가 바뀐 뒤 앞 보내기가 MAIL_002 로 끝나면 — 새 양식에 받는 사람 오류를 띄우지 않고 토스트로 알린다', async () => {
const { handler, held } = heldSendApi()
const { wrapper, router } = await mountCompose('/mail/compose?replyTo=m1', handler)
await startSending(wrapper)
await router.push('/mail/compose')
await flushPromises()
held.release?.(fail(400, 'MAIL_002'))
await flushPromises()
expect(wrapper.find('#compose-recipient-error').exists()).toBe(false)
expect(toastMessages()).toEqual([ERROR_CODE_MESSAGES.MAIL_002])
expect(wrapper.get('.compose__send').text()).toBe('보내기')
})
})
it.each([
['이 탭의 로그아웃', (store: ReturnType<typeof useAuthStore>) => store.clearAuth()],
['세션 만료', (store: ReturnType<typeof useAuthStore>) => store.expireSession()],
])('세션이 끝나면(%s) 묻지 않는다 — 로그인 화면으로 가는 이동이 이긴다', async (_label, end) => {
const { wrapper, router } = await mountCompose('/mail/compose', replyApi)
await writeSubject(wrapper, '쓰던 메일')
expect(blocksUnload()).toBe(true)
end(useAuthStore())
await flushPromises()
expect(blocksUnload()).toBe(false)
await router.push({ name: 'login' })
expect(router.currentRoute.value.name).toBe('login')
expect(dialogOpen(wrapper)).toBe(false)
})
it('다른 탭이 로그아웃하면 묻지 않고 로그인 화면으로 간다', async () => {
vi.stubGlobal('BroadcastChannel', FakeBroadcastChannel)
const { wrapper, router } = await mountCompose('/mail/compose', replyApi)
mountedWrappers.push(mount(LogoutSyncHost, { global: { plugins: [pinia, router] } }))
await writeSubject(wrapper, '쓰던 메일')
new FakeBroadcastChannel(AUTH_CHANNEL_NAME).postMessage({ type: 'logout' })
await flushPromises()
await flushPromises()
expect(useAuthStore().isLoggedIn).toBe(false)
expect(router.currentRoute.value.name).toBe('login')
expect(dialogOpen(wrapper)).toBe(false)
})
it('묻는 중에 세션이 만료되면 이 대화상자를 닫고 그 이동은 취소한다 — 세션 만료 대화상자와 겹치지 않는다', async () => {
const { wrapper, router } = await mountCompose('/mail/compose', replyApi)
const sessionDialog = mount(SessionExpiredDialog, { global: { plugins: [pinia, router] } })
mountedWrappers.push(sessionDialog)
await writeSubject(wrapper, '쓰던 메일')
const navigation = router.push('/mail/inbox')
await flushPromises()
expect(dialogOpen(wrapper)).toBe(true)
useAuthStore().expireSession()
await navigation
await flushPromises()
expect(dialogOpen(wrapper)).toBe(false)
expect(sessionDialog.get('dialog').attributes('open')).toBeDefined()
expect(router.currentRoute.value.fullPath).toBe('/mail/compose')
// 세션 만료 대화상자의 "로그인 화면으로"는 묻지 않고 간다 — 다시 로그인하면 쓰기 화면으로 돌아온다
await sessionDialog.get('button').trigger('click')
await flushPromises()
expect(router.currentRoute.value.name).toBe('login')
expect(router.currentRoute.value.query.redirect).toBe('/mail/compose')
})
it('원문을 불러오는 동안 붙인 파일은 처음 채운 첨부가 아니다 — 바꿈으로 남는다', async () => {
// 원문 응답을 붙잡아 둔다 — 그동안 양식은 막혀 있지만 첨부는 붙일 수 있다
const original: { release: (() => void) | null } = { release: null }
const requests = installMockApi((request) => {
if (request.method === 'GET' && request.url === '/messages/m1') {
return new Promise<MockResult>((resolve) => {
original.release = () => resolve(ok({ ...ORIGINAL, attachments: [CHECKLIST] }))
})
}
if (request.method === 'POST' && request.url === '/mail-attachments') {
return ok({ id: 'up1', name: '회의록.pdf', type: 'application/pdf', size: 1 }, 201)
}
return replyApi(request)
})
const router = createTestRouter()
await router.push('/mail/compose?forward=m1')
await router.isReady()
const wrapper = mount(ComposePage, { global: { plugins: [pinia, router], provide: routeViewProvide(router) } })
mountedWrappers.push(wrapper)
await flushPromises()
await choose(new File(['x'], '회의록.pdf'), wrapper)
await flushPromises()
original.release?.()
await editorLoaded(wrapper)
expect(requests.some((request) => request.url === '/mail-attachments')).toBe(true)
expect(wrapper.findAll('.upload-item__name').map((item) => item.text())).toEqual(['회의록.pdf', '체크리스트.xlsx'])
expect(blocksUnload()).toBe(true)
// 올린 파일을 빼면 처음 채운 모양(원본 첨부만)과 같다
await wrapper.get('button[aria-label="회의록.pdf 첨부 취소"]').trigger('click')
expect(blocksUnload()).toBe(false)
})
})
})
+165 -30
View File
@@ -7,21 +7,27 @@
// 본문에 붙여 넣거나 끌어다 놓은 파일(이미지 포함)은 첨부로 올린다 — 본문에는 이미지를 넣지 않는다 (사용자 확정).
// 화면 어디에 파일을 놓아도 첨부로 받는다 — 브라우저가 파일을 열어 쓰던 메일을 잃지 않게 (useWindowFileDrop).
// 받는 사람 오류 문구는 에러 코드 사전(MAIL_002)에서만 온다 — 형식 오류(즉시)와 서버 거부 모두 같은 문구.
// 보내기 직전에 제목 · 본문 길이를 최종 모양으로 잰다 — 넘으면 보내지 않고 그 칸 아래에 사전 문구(VAL_001).
import { computed, defineAsyncComponent, nextTick, provide, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
// 보내기 직전에 제목 · 본문 길이를 최종 모양으로 잰다 — 넘으면 보내지 않고 그 칸 아래에 사전 문구
// (제목 VAL_001 · 본문 MAIL_007). 서버가 본문을 MAIL_007 로 거절해도 본문 아래에 같은 띠를 보인다.
// 연 뒤에 받는 사람 · 제목 · 본문 · 첨부를 바꿨으면 떠나기 전에 묻는다 (디자인 README "나가기 전 확인", useLeaveConfirm) —
// 보내는 중 · 보낸 뒤 · 세션이 끝났을 때(로그아웃 · 만료 · 다른 탭의 로그아웃)는 앱 안 이동을 묻지 않는다.
// 보내는 중에 떠나면 요청은 이어지고, 결과는 토스트로만 알린다 — 떠난 양식은 건드리지 않고 끌고 오지도 않는다 (formGeneration).
import { computed, defineAsyncComponent, nextTick, onBeforeUnmount, provide, ref, shallowRef, watch } from 'vue'
import { useRoute, useRouter, type LocationQuery } from 'vue-router'
import AppIcon from '@/components/AppIcon.vue'
import ComposeAttachmentList from '@/components/mail/ComposeAttachmentList.vue'
import ComposeEditorLoadError from '@/components/mail/ComposeEditorLoadError.vue'
import ComposeLeaveDialog from '@/components/mail/ComposeLeaveDialog.vue'
import RecipientField from '@/components/mail/RecipientField.vue'
import {
MAIL_EDITOR_RETRY,
loadMailEditor,
type MailEditorHandle,
} from '@/components/mail/editor/mail-editor-handle'
import { getErrorMessage, toApiError } from '@/composables/useApi'
import { getErrorMessage, notifyApiError, toApiError } from '@/composables/useApi'
import { useAttachmentUploads } from '@/composables/useAttachmentUploads'
import { useLeaveConfirm } from '@/composables/useLeaveConfirm'
import { useMail } from '@/composables/useMail'
import { useToast } from '@/composables/useToast'
import { useWindowFileDrop } from '@/composables/useWindowFileDrop'
@@ -36,7 +42,13 @@ import {
type ComposeMode,
} from '@/shared/constants/mail'
import { IMAGE_AS_ATTACHMENT_MESSAGE } from '@/shared/constants/mail-editor'
import type { ErrorCode } from '@/shared/constants/error-codes.generated'
import type { SendMessagePayload } from '@/shared/types/mail'
import {
isSameComposeForm,
snapshotComposeForm,
type ComposeFormSnapshot,
} from '@/shared/utils/compose-changes'
import { isImageFile } from '@/shared/utils/editor-files'
import {
createIdempotencyKey,
@@ -110,8 +122,30 @@ const sending = ref<boolean>(false)
const attempted = ref<boolean>(false)
/** 서버가 받는 사람을 거부했다 (MAIL_002) */
const rejectedByServer = ref<boolean>(false)
/** 보내기 직전에 잰 길이가 한도를 넘은 칸 — 제목 255자 · 본문(텍스트 100,000 · 서식 500,000, 인용을 다 줄여도) */
/**
* 길이가 한도를 넘은 칸 — 제목 255자 · 본문(텍스트 100,000 · 서식 500,000, 인용을 다 줄여도).
* 본문은 보내기 직전에 잰 값이 넘었거나 서버가 MAIL_007 로 거절했을 때 (정화 전 비용 · 정화 뒤 크기 한도 포함)
*/
const lengthError = ref<'subject' | 'body' | null>(null)
/**
* 쓰기 화면을 다 연 때의 양식(받는 사람 · 제목 · 첨부) — 답장 · 전달로 처음 채운 내용은 "바꿈"이 아니다.
* 이것과 달라졌거나 본문을 바꿨으면 떠나기 전에 묻는다 (디자인 README "나가기 전 확인"). 본문은 편집기가 잰다(bodyChanged)
*/
const openedForm = shallowRef<ComposeFormSnapshot>(
snapshotComposeForm({ to: [], cc: [], bcc: [], subject: '', attachments: [] }),
)
/** 편집기를 연 뒤 본문을 바꿨다 — 편집기를 새로 만들면 처음부터 다시 잰다 */
const bodyChanged = ref<boolean>(false)
/** 보냈다 — 쓴 내용이 메일로 나갔으므로 떠날 때 묻지 않는다 (양식을 새로 채우면 풀린다) */
const sent = ref<boolean>(false)
/**
* 양식 세대 — 양식을 새로 채울 때(resetForm)와 화면을 내릴 때 하나씩 올린다.
* 보내기는 시작할 때 세대를 잡아 두고, 응답이 왔을 때 바뀌었으면 그 양식은 이미 없다 (보내는 중에 떠났거나 쓰기 종류가 바뀌었다).
* 그때는 양식 상태(보냄 표시 · 발송 키 · 칸 안내 · 첨부 표시)를 건드리지 않고 이동하지도 않는다 — 결과는 토스트로만 알린다.
*/
let formGeneration = 0
/** 보내기 결과를 이 화면이 칸 안에서 직접 안내하는 코드 — 인터셉터 알림을 끈다 (양식이 사라졌으면 토스트로 대신 알린다) */
const SEND_INLINE_CODES: readonly ErrorCode[] = ['MAIL_002', 'MAIL_004', 'MAIL_007']
/**
* 중복 발송 방지 키 (계약 2절 Idempotency-Key) — 쓰기 화면마다 하나.
* 양식을 새로 채울 때(새 화면·다른 쓰기 종류나 원본) 만들고, 같은 화면에서는 보내기 결과가
@@ -138,12 +172,23 @@ function isReply(value: ComposeMode): value is 'reply' | 'replyAll' {
}
/** 답장·전달할 원본 메일 id */
const sourceId = computed(() => queryId(route.query.forward) ?? queryId(route.query.replyTo))
const mode = computed<ComposeMode>(() => {
if (queryId(route.query.forward)) return 'forward'
if (!queryId(route.query.replyTo)) return 'new'
return route.query.all === '1' ? 'replyAll' : 'reply'
})
function sourceIdOf(query: LocationQuery): string | null {
return queryId(query.forward) ?? queryId(query.replyTo)
}
function modeOf(query: LocationQuery): ComposeMode {
if (queryId(query.forward)) return 'forward'
if (!queryId(query.replyTo)) return 'new'
return query.all === '1' ? 'replyAll' : 'reply'
}
/** 쓰기 종류(새 메일·답장·전달)와 원본 메일 — 바뀌면 양식을 새로 채운다 (같은 화면 안의 이동도 쓰던 내용을 버린다) */
function draftKeyOf(query: LocationQuery): string {
return `${modeOf(query)}:${sourceIdOf(query) ?? ''}`
}
const sourceId = computed(() => sourceIdOf(route.query))
const mode = computed<ComposeMode>(() => modeOf(route.query))
const title = computed(() => COMPOSE_TITLES[mode.value])
const attachmentHintText = computed(() =>
attachmentHint(mailStore.attachmentMaxBytes, mailStore.attachmentTotalMaxBytes, MAIL_MAX_ATTACHMENTS),
@@ -164,17 +209,56 @@ const showRecipientError = computed(
)
/** 문구는 에러 코드 사전에서만 온다 */
const recipientErrorMessage = getErrorMessage('MAIL_002')
/** 길이가 넘으면 서버도 VAL_001 로 거절한다 — 같은 문구를 보내기 전에 보인다 */
const lengthErrorMessage = getErrorMessage('VAL_001')
/** 제목이 255자를 넘으면 서버도 VAL_001 로 거절한다 — 같은 문구를 보내기 전에 보인다 */
const subjectErrorMessage = getErrorMessage('VAL_001')
/** 본문이 너무 길면 서버는 MAIL_007 로 거절한다 — 보내기 전에 막을 때(내 글만으로 넘음)와 서버가 거절했을 때 모두 이 문구 */
const bodyErrorMessage = getErrorMessage('MAIL_007')
/** 서버가 찾지 못한 첨부(MAIL_004)가 남아 있으면 보내지 않는다 — 모르는 사이 첨부 없이 나가지 않게 */
const canSend = computed(
() =>
editorReady.value && !preparing.value && !sending.value && !isUploading.value && !hasMissing.value,
)
/** 지금 양식(받는 사람 · 제목 · 첨부) — 연 때의 양식(openedForm)과 견준다 */
function snapshotForm(): ComposeFormSnapshot {
return snapshotComposeForm({
to: to.value,
cc: cc.value,
bcc: bcc.value,
subject: subject.value,
attachments: uploadItems.value,
})
}
const formChanged = computed(() => !isSameComposeForm(openedForm.value, snapshotForm()))
/** 받는 사람 칸에 아직 토큰이 되지 않은 글이 있다 — 칸을 벗어나지 않고 떠나면(뒤로 가기 등) 확정되지 않은 채 사라진다 */
const typingRecipient = computed(() =>
[toField.value, ccField.value, bccField.value].some((field) => field?.hasDraft === true),
)
/** 떠나면 잃는 것이 있다 — 연 뒤에 바꿨고 아직 보내지 않았다 */
const hasUnsentChanges = computed(
() => !sent.value && (formChanged.value || bodyChanged.value || typingRecipient.value),
)
/**
* 떠나기 전 확인 — 앱 안 이동은 대화상자(ComposeLeaveDialog), 창 닫기 · 새로고침은 브라우저 확인창.
* 세션이 끝났으면(로그아웃 · 만료 · 다른 탭의 로그아웃) 묻지 않는다 — 그 흐름이 로그인 화면으로 보낸다.
* 묻는 중에 세션이 끝나면 대화상자를 닫는다 — 세션 만료 대화상자와 겹치지 않는다.
* 같은 쓰기 화면 안의 이동도 쓰기 종류나 원본이 바뀌면 양식을 새로 채우므로 묻는다 (답장 쓰다 "쓰기" 등).
* 보내는 중에는 앱 안 이동을 묻지 않는다 — 이미 보내기로 했고, 요청은 이어지며 결과는 토스트로 알린다 (formGeneration).
* 창 닫기 · 새로고침은 보내는 중에도 묻는다 — 창을 닫으면 요청이 끊길 수 있고 결과를 알릴 곳이 없다.
*/
const { confirming: leaveConfirming, stay: stayOnCompose, leave: leaveCompose } = useLeaveConfirm({
active: () => hasUnsentChanges.value && authStore.isLoggedIn && !sending.value,
unloadActive: () => hasUnsentChanges.value && authStore.isLoggedIn,
leavesOnUpdate: (next, current) => draftKeyOf(next.query) !== draftKeyOf(current.query),
})
/** 편집기를 새로 만든다 — 다음에 그릴 때 draftHtml 로 시작한다 */
function renewEditor() {
editorKey.value += 1
editorReady.value = false
bodyChanged.value = false
}
/**
@@ -192,6 +276,8 @@ function resetForm() {
to.value = []
cc.value = []
bcc.value = []
// 칸에 쓰다 만 주소도 버린다 — 새로 채운 양식이 처음부터 "바꿈"이 되지 않게
for (const field of [toField.value, ccField.value, bccField.value]) field?.clearDraft()
showCopies.value = false
subject.value = ''
draftHtml.value = ''
@@ -202,8 +288,12 @@ function resetForm() {
rejectedByServer.value = false
lengthError.value = null
resetUploads()
// 양식을 새로 채우면 다른 메일이다
// 양식을 새로 채우면 다른 메일이다 — 앞 양식에서 보내던 요청은 끝나도 이 양식을 건드리지 않는다 (formGeneration)
formGeneration += 1
sending.value = false
sendKey = createIdempotencyKey()
sent.value = false
openedForm.value = snapshotForm()
}
let prepareSeq = 0
@@ -216,12 +306,14 @@ async function prepareFrom(messageId: string, sourceMode: Exclude<ComposeMode, '
try {
const original = await getMessage(messageId)
if (seq !== prepareSeq) return
/** 처음 채운 원본 첨부 — 불러오는 동안 사용자가 붙인 파일(창에 끌어다 놓기 등)은 여기 들지 않는다 ("바꿈"으로 남는다) */
let filledAttachments: readonly number[] = []
if (sourceMode === 'forward') {
const draft = buildForwardDraft(original)
subject.value = draft.subject
draftHtml.value = draft.html
// 원본 첨부는 다시 올리지 않고 id 를 그대로 보낸다 — 목록에서 하나씩 뺄 수 있다
addExisting(draft.attachments)
filledAttachments = addExisting(draft.attachments).map((item) => item.key)
} else {
const draft = buildReplyDraft(original, authStore.user?.email ?? '', sourceMode)
to.value = draft.to.map(tokenFromAddress)
@@ -232,6 +324,8 @@ async function prepareFrom(messageId: string, sourceMode: Exclude<ComposeMode, '
// 답장은 커서를 본문 맨 위 첫 줄(인용 위)에 둔다 — 편집기가 준비되면
focusBodyWhenReady = true
}
// 처음 채운 내용은 "바꿈"이 아니다 — 받는 사람 · 제목은 불러오는 동안 막혀 있어 지금 값이 곧 채운 값이다
openedForm.value = { ...snapshotForm(), attachments: filledAttachments }
// 한도(텍스트 100,000 · 서식 500,000)를 넘는 원문은 편집기가 인용을 줄인다 (계약 2절 "답장·전달 초안의 길이")
fitDraft.value = true
renewEditor()
@@ -256,6 +350,7 @@ function isFocusFree(): boolean {
function onEditorReady() {
editorReady.value = true
bodyChanged.value = false
if (!focusBodyWhenReady) return
focusBodyWhenReady = false
void nextTick(() => {
@@ -288,11 +383,11 @@ function settleSendKey(outcome: SendOutcome) {
}
function notifySent() {
const sent = mailStore.mailboxByRole('sent')
const sentMailbox = mailStore.mailboxByRole('sent')
toast.success(
MAIL_SENT_MESSAGE,
sent
? { label: MAIL_SENT_ACTION_LABEL, to: { name: 'mail-mailbox', params: { mailboxId: sent.id } } }
sentMailbox
? { label: MAIL_SENT_ACTION_LABEL, to: { name: 'mail-mailbox', params: { mailboxId: sentMailbox.id } } }
: undefined,
)
}
@@ -308,7 +403,7 @@ async function onSubmit() {
}
if (!canSend.value || !editorRef.value) return
// 길이는 보낼 모양 그대로 잰다 (계약 2절 "발송 본문" — 넘기면 서버가 VAL_001 로 거절한다)
// 길이는 보낼 모양 그대로 잰다 (계약 2절 "발송 본문" — 넘기면 서버가 제목은 VAL_001, 본문은 MAIL_007 로 거절한다)
const subjectText = subject.value.trim()
if (subjectText.length > MAIL_SUBJECT_MAX_LENGTH) {
lengthError.value = 'subject'
@@ -319,8 +414,7 @@ async function onSubmit() {
// 한도를 넘으면 편집기가 원문 인용을 더 줄인다. 내 글만으로 넘으면 null — 보내지 않고 본문 아래에 알린다
const content = editorRef.value.getContentForSend()
if (content === null) {
lengthError.value = 'body'
editorRef.value.focus()
showBodyTooLong()
return
}
lengthError.value = null
@@ -337,25 +431,41 @@ async function onSubmit() {
...(isReply(mode.value) && sourceId.value ? { inReplyTo: sourceId.value } : {}),
}
/** 이 보내기가 속한 양식 — 응답이 왔을 때 바뀌었으면 양식을 건드리지 않고 토스트로만 알린다 */
const generation = formGeneration
sending.value = true
try {
// 받는 사람 거부(MAIL_002)와 첨부 만료(MAIL_004)는 이 화면이 해당 칸에 직접 안내한다.
// 받는 사람 거부(MAIL_002) · 첨부 만료(MAIL_004) · 본문이 너무 김(MAIL_007)은 이 화면이 해당 칸에 직접 안내한다.
// 그 밖의 실패는 인터셉터 알림(사전 문구)으로 충분하다 — AUTH_003 권한·출처, REQ_001 발송 한도·처리 중,
// MAIL_005 같은 키·다른 내용(문구가 보낸편지함 확인을 안내한다), FILE_002 첨부 합계, MAIL_006 첨부 개수
// (붙일 때 미리 막으므로 드물다), MAIL_001 엔진 장애.
// REQ_001 은 자동으로 다시 보내지 않는다 — 사용자가 잠시 뒤 다시 누르면 같은 키로 간다.
await sendMessage(payload, { idempotencyKey: sendKey, silent: ['MAIL_002', 'MAIL_004'] })
settleSendKey('sent')
await sendMessage(payload, { idempotencyKey: sendKey, silent: SEND_INLINE_CODES })
notifySent()
// 보낸편지함 개수 · 사용량을 서버 기준으로 다시 맞춘다
mailStore.syncCounts()
// 보내는 중에 화면을 떠났거나 양식을 새로 채웠다 — 알리기만 하고 간 곳에 둔다
if (generation !== formGeneration) return
settleSendKey('sent')
// 쓴 내용이 메일로 나갔다 — 이제 떠날 때 묻지 않는다
sent.value = true
await router.push(backTo.value)
} catch (error) {
const { code } = toApiError(error)
const apiError = toApiError(error)
if (generation !== formGeneration) {
// 양식이 이미 없다 — 칸 안 안내 대신 토스트로 (그 밖의 코드는 인터셉터가 이미 알렸다)
if (SEND_INLINE_CODES.includes(apiError.code)) notifyApiError(apiError)
return
}
const { code } = apiError
// MAIL_007 도 키를 그대로 둔다 — 서버가 엔진에 넘기기 전에 거절했으므로, 줄인 뒤 같은 키로 보내면 된다 (계약 2절)
settleSendKey(code)
if (code === 'MAIL_002') {
rejectedByServer.value = true
toField.value?.focus()
} else if (code === 'MAIL_007') {
// 서버가 본문을 거절했다(정화 전 비용 · 정화 뒤 크기 한도 등) — 보내기 전 검사와 같은 띠 · 같은 초점
showBodyTooLong()
} else if (code === 'MAIL_004') {
// 서버가 첨부를 찾지 못했다 — 어느 것인지 알 수 없으니 보낼 예정이던 첨부 전부를 "찾지 못함"으로 표시한다
const marked = markMissing()
@@ -370,7 +480,8 @@ async function onSubmit() {
}
// 그 밖의 실패는 인터셉터가 알렸다 — 쓴 내용은 그대로 둔다
} finally {
sending.value = false
// 양식이 바뀌었으면 손대지 않는다 — 새 양식은 resetForm 이 이미 풀었고, 그 양식의 보내기가 진행 중일 수 있다
if (generation === formGeneration) sending.value = false
}
}
@@ -381,11 +492,23 @@ function onSubjectEnter(event: KeyboardEvent) {
editorRef.value?.focus()
}
/** 본문이 너무 길다(MAIL_007) — 본문 아래 띠를 보이고 본문으로 초점 (본문이 그 띠를 가리키고 잘못된 입력으로 읽힌다) */
function showBodyTooLong() {
lengthError.value = 'body'
editorRef.value?.focus()
}
/** 길이 안내는 그 칸을 고치면 내린다 */
function clearLengthError(field: 'subject' | 'body') {
if (lengthError.value === field) lengthError.value = null
}
/** 본문이 바뀌었다 — 길이 안내를 내리고, 연 때와 다른지 다시 잰다 (나가기 전 확인) */
function onEditorChange() {
clearLengthError('body')
bodyChanged.value = editorRef.value?.isChanged() ?? false
}
function pickFiles() {
fileInputRef.value?.click()
}
@@ -397,8 +520,13 @@ function onFilesChosen(event: Event) {
input.value = ''
}
onBeforeUnmount(() => {
// 화면을 떠났다 — 진행 중인 보내기가 끝나도 이 양식을 건드리거나 사용자를 끌고 오지 않는다
formGeneration += 1
})
// 쓰기 종류(새 메일·답장·전달)나 원본 메일이 바뀌면 양식을 새로 채운다
const draftKey = computed(() => `${mode.value}:${sourceId.value ?? ''}`)
const draftKey = computed(() => draftKeyOf(route.query))
watch(
draftKey,
() => {
@@ -531,7 +659,7 @@ watch(allRecipients, () => {
name="alert-circle"
:size="15"
/>
<span>{{ lengthErrorMessage }}</span>
<span>{{ subjectErrorMessage }}</span>
</p>
<!-- 편집기를 불러오거나 원문을 채우는 동안에도 자리를 지켜 아래 줄이 뛰지 않게 한다 -->
@@ -546,7 +674,7 @@ watch(allRecipients, () => {
:described-by="lengthError === 'body' ? BODY_ERROR_ID : null"
@ready="onEditorReady"
@files="attachFromBody"
@change="clearLengthError('body')"
@change="onEditorChange"
/>
</div>
<p
@@ -559,7 +687,7 @@ watch(allRecipients, () => {
name="alert-circle"
:size="15"
/>
<span>{{ lengthErrorMessage }}</span>
<span>{{ bodyErrorMessage }}</span>
</p>
<div class="compose__attachments">
@@ -625,6 +753,13 @@ watch(allRecipients, () => {
</span>
</div>
</form>
<!-- 바꾼 채 떠나려 할 때 — 답할 때까지 이동이 기다린다 (useLeaveConfirm) -->
<ComposeLeaveDialog
:open="leaveConfirming"
@stay="stayOnCompose"
@leave="leaveCompose"
/>
</main>
</template>
@@ -18,6 +18,7 @@ export const ERROR_CODE_MESSAGES = {
MAIL_004: '첨부 파일을 찾을 수 없습니다. 해당 첨부를 빼거나 다시 올린 뒤 보내 주세요.',
MAIL_005: '이미 보낸 메일과 내용이 달라 보내지 않았습니다. 보낸편지함을 확인한 뒤 다시 보내 주세요.',
MAIL_006: '첨부는 한 메일에 최대 20개까지 보낼 수 있습니다.',
MAIL_007: '본문이 너무 길어 보낼 수 없습니다. 내용을 줄인 뒤 다시 보내 주세요.',
} as const
export type ErrorCode = keyof typeof ERROR_CODE_MESSAGES
+1 -1
View File
@@ -24,7 +24,7 @@ export const MAIL_BODY_FONT_SIZE_PX = 14
*/
export const MAIL_BODY_LINE_HEIGHT = 1.75
/** 서식 본문(bodyHtml) 길이 한도 — 계약 2절 "발송 본문" (넘기면 400 VAL_001) */
/** 서식 본문(bodyHtml) 길이 한도 — 계약 2절 "발송 본문" (넘기면 400 MAIL_007) */
export const MAIL_BODY_HTML_MAX_LENGTH = 500_000
/** 빈 본문 안내 (디자인 원본 ComposeEditor "빈 본문") */
+26
View File
@@ -45,7 +45,9 @@ export const MAILBOX_EMPTY_HINTS: Readonly<Record<MailboxKind, string>> = {
export const OUTGOING_MAILBOX_ROLES: readonly MailboxRole[] = ['sent', 'drafts']
// ── 발송 한도 (계약 2절 "발송 본문") ─────────────────────────
/** 제목 — 넘기면 400 VAL_001 (입력칸이 이 길이에서 더 받지 않는다) */
export const MAIL_SUBJECT_MAX_LENGTH = 255
/** 텍스트 본문(body) — 넘기면 400 MAIL_007 (서식 본문 한도는 mail-editor.ts 의 MAIL_BODY_HTML_MAX_LENGTH) */
export const MAIL_BODY_MAX_LENGTH = 100_000
export const MAIL_MAX_RECIPIENTS = 100
/**
@@ -129,3 +131,27 @@ export type ComposeMode = keyof typeof COMPOSE_TITLES
/** 발송 완료 토스트 (디자인 원본 States "보내기 완료") */
export const MAIL_SENT_MESSAGE = '메일을 보냈습니다.'
export const MAIL_SENT_ACTION_LABEL = '보낸편지함 보기'
/**
* 쓰기 화면 나가기 전 확인 (디자인 README "나가기 전 확인") — 받는 사람 · 제목 · 본문 · 첨부를 바꾼 채 화면을 떠날 때.
* [계속 쓰기]가 처음 초점이고 Esc 와 같다.
*/
export const COMPOSE_LEAVE_DIALOG = {
title: '보내지 않은 메일이 있습니다',
message: '이 화면을 나가면 쓰던 내용이 사라집니다.',
stay: '계속 쓰기',
leave: '나가기',
} as const
// ── 읽기 화면의 본문 안내 (디자인 README "읽기 화면의 본문 안내" · 계약 2절 "읽기 한도") ─────────
/**
* 서버가 본문을 다 주지 못했을 때 본문 위에 두는 안내 — "외부 이미지를 차단했습니다"와 같은 모양.
* - htmlOmitted · unavailable: 서식 본문을 정화 한도 때문에 비웠다(isHtmlBodyOmitted) — 글자 본문이 있을 때 · 없을 때
* - truncated: 보여 주는 본문이 읽기 한도(2MB)에서 잘렸다(isBodyTruncated)
*/
export const MAIL_BODY_NOTICES = {
htmlOmitted: '본문이 복잡해 서식 없이 글자만 표시합니다.',
unavailable: '본문이 너무 크거나 복잡해 표시할 수 없습니다.',
truncated: '본문이 길어 앞부분만 표시합니다.',
} as const
export type MailBodyNoticeKind = keyof typeof MAIL_BODY_NOTICES
+10
View File
@@ -58,6 +58,16 @@ export interface MessageDetail extends MessageSummary {
textBody: string | null
/** 외부 이미지를 지웠으면 true → "외부 이미지 차단" 안내 */
hasBlockedImages: boolean
/**
* HTML 이 있었지만 서버의 정화 한도(너무 길거나 깊게 중첩됨)를 넘어 htmlBody 를 비웠으면 true
* → textBody 를 서식 없이 보이며 안내한다 (textBody 도 없으면 "표시할 수 없음"). 필드가 없으면(이전 서버) false 로 본다
*/
isHtmlBodyOmitted?: boolean
/**
* 보여 주는 본문(htmlBody — null 이거나 공백뿐이면 textBody)이 서버의 읽기 한도(2MB)에서 잘렸으면 true
* → "앞부분만 표시" 안내 · 답장 · 전달 인용 끝에 생략 표시. 필드가 없으면 false 로 본다
*/
isBodyTruncated?: boolean
attachments: Attachment[]
}
@@ -0,0 +1,38 @@
import { describe, expect, it } from 'vitest'
import { isSameComposeForm, snapshotComposeForm, type ComposeFormFields } from './compose-changes'
import { parseRecipient, tokenFromAddress, type RecipientToken } from './mail-address'
const DOYUN = tokenFromAddress({ name: '이도윤', email: 'doyun.lee@arasystem.it.kr' })
function token(text: string): RecipientToken {
const parsed = parseRecipient(text)
if (!parsed) throw new Error('빈 토큰')
return parsed
}
function fields(partial: Partial<ComposeFormFields> = {}): ComposeFormFields {
return { to: [DOYUN], cc: [], bcc: [], subject: 'Re: 일정', attachments: [{ key: 1 }], ...partial }
}
describe('쓰기 양식의 바꿈 판정 (디자인 README "나가기 전 확인")', () => {
const opened = snapshotComposeForm(fields())
it('연 때와 같으면 바꾸지 않은 것이다 — 썼다 지워 돌아온 것도 같다', () => {
expect(isSameComposeForm(opened, snapshotComposeForm(fields()))).toBe(true)
expect(isSameComposeForm(opened, snapshotComposeForm(fields({ to: [tokenFromAddress(DOYUN.address)] })))).toBe(true)
})
it.each<[string, Partial<ComposeFormFields>]>([
['받는 사람을 더함', { to: [DOYUN, token('junho.park@arasystem.it.kr')] }],
['받는 사람을 뺌', { to: [] }],
['같은 주소를 이름 없이 다시 씀', { to: [token('doyun.lee@arasystem.it.kr')] }],
['참조를 더함', { cc: [token('infra@arasystem.it.kr')] }],
['숨은참조를 더함', { bcc: [token('ceo@arasystem.it.kr')] }],
['제목을 고침', { subject: 'Re: 일정 ' }],
['첨부를 더함(올리는 중 포함)', { attachments: [{ key: 1 }, { key: 2 }] }],
['전달 원본 첨부를 뺌', { attachments: [] }],
])('%s', (_label, change) => {
expect(isSameComposeForm(opened, snapshotComposeForm(fields(change)))).toBe(false)
})
})
@@ -0,0 +1,50 @@
// 쓰기 화면의 "바꿈" 판정 — 순수 함수 (디자인 README "나가기 전 확인")
// 쓰기 화면을 다 연 때(답장 · 전달 초안을 채운 뒤)의 양식을 찍어 두고, 지금 양식과 다르면 떠나기 전에 묻는다.
// 본문은 편집기가 따로 잰다 (MailEditor 의 isChanged) — 여기서는 받는 사람 · 제목 · 첨부만 본다.
import type { RecipientToken } from '@/shared/utils/mail-address'
export interface ComposeFormSnapshot {
/** 받는 사람 · 참조 · 숨은참조 — 토큰에 보이는 글자 (이름 · 주소를 고치면 달라진다) */
to: readonly string[]
cc: readonly string[]
bcc: readonly string[]
subject: string
/** 첨부 목록 항목의 키 — 붙이거나(올리는 중 · 실패 포함) 빼면 달라진다 */
attachments: readonly number[]
}
export interface ComposeFormFields {
to: readonly RecipientToken[]
cc: readonly RecipientToken[]
bcc: readonly RecipientToken[]
subject: string
attachments: readonly { key: number }[]
}
const labels = (tokens: readonly RecipientToken[]) => tokens.map((token) => token.label)
export function snapshotComposeForm(fields: ComposeFormFields): ComposeFormSnapshot {
return {
to: labels(fields.to),
cc: labels(fields.cc),
bcc: labels(fields.bcc),
subject: fields.subject,
attachments: fields.attachments.map((item) => item.key),
}
}
function sameList<T>(a: readonly T[], b: readonly T[]): boolean {
return a.length === b.length && a.every((value, index) => value === b[index])
}
/** 같은 양식인가 — 순서까지 본다 (썼다 지워 처음으로 돌아왔으면 같다) */
export function isSameComposeForm(a: ComposeFormSnapshot, b: ComposeFormSnapshot): boolean {
return (
a.subject === b.subject &&
sameList(a.to, b.to) &&
sameList(a.cc, b.cc) &&
sameList(a.bcc, b.bcc) &&
sameList(a.attachments, b.attachments)
)
}
@@ -67,6 +67,10 @@ describe('shouldRenewSendKey — 키는 성공과 MAIL_005 때만 바꾼다 (계
},
)
it('MAIL_007(본문이 너무 김) 뒤에도 같은 키 — 서버가 엔진에 넘기기 전에 거절했으므로 줄인 뒤 같은 키로 보낸다 (계약 2절)', () => {
expect(shouldRenewSendKey('MAIL_007')).toBe(false)
})
it('사전의 다른 모든 코드도 키를 유지한다 — 새 코드가 생겨도 바꾸는 경우는 늘지 않는다', () => {
const renewing = (Object.keys(ERROR_CODE_MESSAGES) as ErrorCode[]).filter(shouldRenewSendKey)
expect(renewing).toEqual(['MAIL_005'])
@@ -0,0 +1,35 @@
import { describe, expect, it } from 'vitest'
import { MAIL_BODY_NOTICES } from '@/shared/constants/mail'
import { mailBodyNotices } from './mail-body-notices'
const texts = (source: Parameters<typeof mailBodyNotices>[0]) => mailBodyNotices(source).map((item) => item.text)
describe('mailBodyNotices — 읽기 화면의 본문 안내 (계약 2절 "읽기 한도")', () => {
it('서버가 다 준 본문이면 안내가 없다 — 표시 값이 없는(이전 서버) 메일도 같다', () => {
expect(texts({ textBody: '본문', isHtmlBodyOmitted: false, isBodyTruncated: false })).toEqual([])
expect(texts({ textBody: '본문' })).toEqual([])
})
it('서식 본문을 비웠는데 글자 본문이 있으면 "서식 없이 글자만", 없으면(공백뿐 포함) "표시할 수 없음"', () => {
expect(texts({ textBody: '글자 본문', isHtmlBodyOmitted: true })).toEqual([MAIL_BODY_NOTICES.htmlOmitted])
expect(texts({ textBody: null, isHtmlBodyOmitted: true })).toEqual([MAIL_BODY_NOTICES.unavailable])
expect(texts({ textBody: ' \n ', isHtmlBodyOmitted: true })).toEqual([MAIL_BODY_NOTICES.unavailable])
})
it('보여 주는 본문이 잘렸으면 "앞부분만 표시"', () => {
expect(mailBodyNotices({ textBody: null, isBodyTruncated: true })).toEqual([
{ kind: 'truncated', text: MAIL_BODY_NOTICES.truncated },
])
})
it('둘 다면 생략 → 잘림 순서 — 보여 줄 본문이 없으면 잘림 안내는 두지 않는다', () => {
expect(texts({ textBody: '긴 글자 본문', isHtmlBodyOmitted: true, isBodyTruncated: true })).toEqual([
MAIL_BODY_NOTICES.htmlOmitted,
MAIL_BODY_NOTICES.truncated,
])
expect(texts({ textBody: '', isHtmlBodyOmitted: true, isBodyTruncated: true })).toEqual([
MAIL_BODY_NOTICES.unavailable,
])
})
})
@@ -0,0 +1,30 @@
// 읽기 화면의 본문 안내 — 서버가 본문을 다 주지 못했을 때 (계약 2절 MessageDetail · "읽기 한도") — 순수 함수만 둔다
// 순서는 생략(서식 없이 · 표시할 수 없음) → 잘림 (디자인 README "읽기 화면의 본문 안내").
import { MAIL_BODY_NOTICES, type MailBodyNoticeKind } from '@/shared/constants/mail'
import type { MessageDetail } from '@/shared/types/mail'
export interface MailBodyNotice {
kind: MailBodyNoticeKind
text: string
}
type NoticeSource = Pick<MessageDetail, 'textBody' | 'isHtmlBodyOmitted' | 'isBodyTruncated'>
function notice(kind: MailBodyNoticeKind): MailBodyNotice {
return { kind, text: MAIL_BODY_NOTICES[kind] }
}
/**
* 본문 위에 둘 안내 — 없으면 빈 목록. 표시 값이 없는(이전 서버) 메일은 안내하지 않는다.
* 글자 본문은 공백뿐이면 없는 것으로 본다 — 화면(MailBodyFrame)에 보이는 것이 없다.
*/
export function mailBodyNotices(message: NoticeSource): MailBodyNotice[] {
const notices: MailBodyNotice[] = []
const omitted = message.isHtmlBodyOmitted === true
const hasText = (message.textBody ?? '').trim() !== ''
if (omitted) notices.push(notice(hasText ? 'htmlOmitted' : 'unavailable'))
// 보여 줄 본문이 없으면("표시할 수 없음") 앞부분만 보인다는 안내는 맞지 않는다
if (message.isBodyTruncated === true && !(omitted && !hasText)) notices.push(notice('truncated'))
return notices
}
@@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest'
import { MAIL_SUBJECT_MAX_LENGTH } from '@/shared/constants/mail'
import { MAIL_QUOTE_OMITTED_MARK, MAIL_SUBJECT_MAX_LENGTH } from '@/shared/constants/mail'
import type { MessageDetail } from '@/shared/types/mail'
import { buildForwardDraft, buildForwardSubject } from './mail-forward'
@@ -88,4 +88,10 @@ describe('buildForwardDraft', () => {
expect(fits.subject).toBe(`Fwd: ${'가'.repeat(250)}`)
expect(over.subject).toHaveLength(MAIL_SUBJECT_MAX_LENGTH)
})
it('서버가 읽기 한도에서 자른 원문(isBodyTruncated)이면 인용 끝에 생략 표시', () => {
const { html } = buildForwardDraft(message({ textBody: '앞부분', isBodyTruncated: true }), 'Asia/Seoul')
expect(html.endsWith(`<p>앞부분</p><p>${MAIL_QUOTE_OMITTED_MARK}</p></blockquote>`)).toBe(true)
})
})
+15 -1
View File
@@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest'
import { MAIL_FRAME_CSP, buildMailDocument, escapeHtml, sanitizeMailHtml } from './mail-html'
import { MAIL_FRAME_CSP, buildMailDocument, displayedHtmlBody, escapeHtml, sanitizeMailHtml } from './mail-html'
// 보안 핵심 — 서버 정화에 빈틈이 있어도 클라이언트가 한 번 더 걸러 내는지 확인한다.
describe('sanitizeMailHtml', () => {
@@ -126,6 +126,20 @@ describe('buildMailDocument', () => {
})
})
describe('displayedHtmlBody — 보여 줄 서식 본문 (읽기 화면 · 답장 · 전달 인용이 같은 규칙)', () => {
it('null 이거나 공백뿐이면 없다 — 그때는 텍스트를 보인다', () => {
expect(displayedHtmlBody(null)).toBeNull()
expect(displayedHtmlBody(undefined)).toBeNull()
expect(displayedHtmlBody('')).toBeNull()
// 외부 그림 하나뿐이던 HTML 을 서버가 정화하면 줄바꿈만 남는다
expect(displayedHtmlBody('\n\n\n\n\n')).toBeNull()
})
it('글이 있으면 받은 그대로 돌려준다', () => {
expect(displayedHtmlBody('\n<p>본문</p>\n')).toBe('\n<p>본문</p>\n')
})
})
describe('escapeHtml', () => {
it('특수 문자를 모두 바꾼다', () => {
expect(escapeHtml(`<a href="x">'&'</a>`)).toBe('&lt;a href=&quot;x&quot;&gt;&#39;&amp;&#39;&lt;/a&gt;')
+16 -5
View File
@@ -130,16 +130,27 @@ export interface MailBodySource {
textBody: string | null
}
/**
* 보여 줄 서식 본문 — null 이거나 공백뿐이면 없다(null). 그때는 textBody 를 보인다.
* 서버(정화 결과가 공백뿐이면 htmlBody 를 비운다) · 계약 2절과 같은 규칙이다.
* 읽기 화면(buildMailDocument)과 답장 · 전달 인용(mail-quote.ts 의 originalBodyHtml)이 함께 써서 둘이 같은 본문을 고른다
* — 예) 외부 이미지 하나뿐인 HTML 은 정화하면 줄바꿈만 남는다. 읽기 화면은 글자 본문을 보이므로 인용도 글자 본문을 쓴다.
*/
export function displayedHtmlBody(htmlBody: string | null | undefined): string | null {
return htmlBody?.trim() ? htmlBody : null
}
/**
* 본문 → iframe srcdoc 문서.
* HTML 이 있으면 정화해서, 없으면 텍스트를 이스케이프해서 넣는다.
* HTML 이 있으면 정화해서, 없으면(공백뿐 포함 — displayedHtmlBody) 텍스트를 이스케이프해서 넣는다.
* <base target="_blank"> 로 남은 링크도 모두 새 창으로 연다.
*/
export function buildMailDocument(source: MailBodySource): string {
const html = source.htmlBody?.trim()
const content = html
? sanitizeMailHtml(html)
: `<div class="mail-text">${escapeHtml(source.textBody ?? '')}</div>`
const html = displayedHtmlBody(source.htmlBody)
const content =
html !== null
? sanitizeMailHtml(html.trim())
: `<div class="mail-text">${escapeHtml(source.textBody ?? '')}</div>`
return [
'<!doctype html>',
@@ -6,6 +6,7 @@ import {
QUOTE_SOURCE_MAX_LENGTH,
buildQuoteHtml,
forwardQuoteRows,
hasVisibleQuoteContent,
originalBodyHtml,
replyQuoteRows,
textToParagraphHtml,
@@ -52,6 +53,36 @@ describe('originalBodyHtml', () => {
expect(html).toBe(`${head}${OMITTED}`)
})
it('서버가 읽기 한도에서 자른 원문(isBodyTruncated)이면 여기서 자르지 않았어도 끝에 생략 표시 — 서식 · 텍스트 모두', () => {
expect(originalBodyHtml({ htmlBody: '<p>앞부분</p>', textBody: '텍스트', isBodyTruncated: true })).toBe(
`<p>앞부분</p>${OMITTED}`,
)
expect(originalBodyHtml({ htmlBody: null, textBody: '앞부분', isBodyTruncated: true })).toBe(
`<p>앞부분</p>${OMITTED}`,
)
// 잘리지 않았다고 알려 왔거나 알리지 않았으면(이전 서버) 그대로
expect(originalBodyHtml({ htmlBody: '<p>전부</p>', textBody: null, isBodyTruncated: false })).toBe('<p>전부</p>')
})
it('공백뿐인 서식 본문은 없는 것으로 보고 텍스트를 인용한다 — 읽기 화면과 같은 본문 (외부 그림만 있던 뉴스레터 등)', () => {
const html = originalBodyHtml({ htmlBody: '\n\n', textBody: '원문', isBodyTruncated: true })
expect(html).toContain('<p>원문</p>')
expect(html).toBe(`<p>원문</p>${OMITTED}`)
expect(originalBodyHtml({ htmlBody: ' \n\t ', textBody: '한 줄\n두 줄' })).toBe('<p>한 줄</p><p>두 줄</p>')
})
it('서버도 잘랐고 여기서도 잘랐으면 생략 표시는 한 번만', () => {
const html = originalBodyHtml({
htmlBody: 'x'.repeat(QUOTE_SOURCE_MAX_LENGTH + 10),
textBody: null,
isBodyTruncated: true,
})
expect(html).toBe(`${'x'.repeat(QUOTE_SOURCE_MAX_LENGTH)}${OMITTED}`)
expect(html.split(MAIL_QUOTE_OMITTED_MARK)).toHaveLength(2)
})
it('그림(서버가 넣은 data: 그림 포함)은 자르기 전에 걷어 낸다 — 큰 그림 뒤의 글이 생략 표시 없이 사라지지 않게', () => {
const picture = `<img src="data:image/png;base64,${'A'.repeat(QUOTE_SOURCE_MAX_LENGTH + 100_000)}" alt="서명">`
const html = originalBodyHtml({ htmlBody: `<p>앞 글</p><p>${picture}</p><p>뒤쪽 본문 내용</p>`, textBody: null })
@@ -61,6 +92,74 @@ describe('originalBodyHtml', () => {
})
})
describe('그림뿐인 원문 — 인용은 그림을 받지 않으므로 걷어 내면 보이는 것이 없다', () => {
const PICTURE = '<img src="data:image/png;base64,iVBORw0KGgo=" alt="회사 로고">'
it('글자 본문이 있으면 그 문단을 인용한다 (읽기 화면은 그대로 그림을 보인다)', () => {
expect(originalBodyHtml({ htmlBody: `<div>${PICTURE}</div>`, textBody: '회사 로고\n안녕하세요' })).toBe(
'<p>회사 로고</p><p>안녕하세요</p>',
)
// 링크로 감싼 그림 · 빈 문단 · 공백 문자 참조만 남아도 보이는 것이 없다
expect(
originalBodyHtml({ htmlBody: `<p><a href="https://x.kr">${PICTURE}</a></p><p>&nbsp;</p>`, textBody: '본문' }),
).toBe('<p>본문</p>')
})
it('글자 본문도 없으면 인용 머리만 남는다 — 지금과 같다', () => {
const body = originalBodyHtml({ htmlBody: `<div>${PICTURE}</div>`, textBody: null })
const html = buildQuoteHtml(replyQuoteRows(HEADER, 'Asia/Seoul'), body)
expect(body).toBe('<p></p>')
expect(html.endsWith('</tbody></table><p></p></blockquote>')).toBe(true)
expect(html).not.toContain('<img')
})
it('잘린 원문(isBodyTruncated)이면 글자 본문 끝에 생략 표시를 한 번 둔다', () => {
expect(originalBodyHtml({ htmlBody: PICTURE, textBody: '앞부분', isBodyTruncated: true })).toBe(`<p>앞부분</p>${OMITTED}`)
})
it('글자가 있는 원문은 그대로 — 그림만 걷어 낸다', () => {
expect(originalBodyHtml({ htmlBody: `<p>안녕하세요</p><p>${PICTURE}</p>`, textBody: '텍스트 본문' })).toBe(
'<p>안녕하세요</p><p></p>',
)
})
it('글자 없는 표 · 가로줄만 남아도 보이는 내용이 아니다 — 글자 본문을 인용한다', () => {
expect(
originalBodyHtml({ htmlBody: `<table><tbody><tr><td>${PICTURE}</td></tr></tbody></table>`, textBody: '텍스트' }),
).toBe('<p>텍스트</p>')
expect(originalBodyHtml({ htmlBody: `${PICTURE}<hr>`, textBody: '텍스트' })).toBe('<p>텍스트</p>')
})
it('그림만 표로 늘어놓은 뉴스레터 — 글자 본문의 문단을 인용한다', () => {
const layout =
'<table width="600" align="center"><tbody>' +
`<tr><td align="center">${PICTURE}</td></tr>` +
`<tr><td><table><tbody><tr><td>${PICTURE}</td><td>&nbsp;</td><td>${PICTURE}</td></tr></tbody></table></td></tr>` +
'</tbody></table>'
expect(originalBodyHtml({ htmlBody: layout, textBody: '10월 소식\n신제품을 소개합니다.' })).toBe(
'<p>10월 소식</p><p>신제품을 소개합니다.</p>',
)
})
it('글자가 든 표는 그대로 원문 HTML 을 인용한다 — 그림만 걷어 낸다', () => {
expect(
originalBodyHtml({
htmlBody: `<table><tbody><tr><td>${PICTURE}</td><td>견적 합계</td></tr></tbody></table>`,
textBody: '텍스트',
}),
).toBe('<table><tbody><tr><td></td><td>견적 합계</td></tr></tbody></table>')
})
it('hasVisibleQuoteContent — 태그를 걷어 내고 공백이 아닌 글자가 남았는가 (&nbsp; · U+00A0 은 글자가 아니다)', () => {
expect(hasVisibleQuoteContent('<p>&nbsp;  &#160;</p><div><br></div>')).toBe(false)
expect(hasVisibleQuoteContent('<table><tbody><tr><td></td></tr></tbody></table><HR>')).toBe(false)
expect(hasVisibleQuoteContent('<p>&amp;</p>')).toBe(true)
expect(hasVisibleQuoteContent('<p><strong>글</strong></p>')).toBe(true)
})
})
describe('인용 머리', () => {
it('답장 — 보낸 사람 · 날짜 · 제목 (제목이 없으면 "(제목 없음)")', () => {
expect(replyQuoteRows(HEADER, 'Asia/Seoul')).toEqual([
+47 -11
View File
@@ -17,7 +17,7 @@ import type { MessageDetail } from '@/shared/types/mail'
import { formatMailDateTime } from '@/shared/utils/format'
import { formatAddress, formatAddressList } from '@/shared/utils/mail-address'
import { truncateText } from '@/shared/utils/mail-draft'
import { escapeHtml } from '@/shared/utils/mail-html'
import { displayedHtmlBody, escapeHtml } from '@/shared/utils/mail-html'
export interface QuoteRow {
label: string
@@ -37,17 +37,43 @@ export const QUOTE_SOURCE_MAX_LENGTH = 2_000_000
*/
const IMAGE_TAG = /<img\b[^>]*>/gi
/** 태그 하나 — IMAGE_TAG 와 같은 가정(정화한 HTML 이라 속성 값 안의 `>` 는 &gt; 다)으로 태그를 걷어 내 글자만 본다 */
const ANY_TAG = /<[^>]*>/g
/** 공백 문자 참조(&nbsp;) — 글자로 세지 않는다 (U+00A0 등 공백 문자 그대로인 것은 \s 가 걸러 낸다) */
const SPACE_ENTITY = /&(?:nbsp|#160|#x0*a0);/gi
/**
* 그림을 걷어 낸 원문 HTML 에 보이는 내용이 남았는가 — 태그를 걷어 내고 공백이 아닌 글자가 남았는가 (&nbsp; · U+00A0 은 글자가 아니다).
* 글자 없는 표 · 가로줄도 보이는 내용으로 치지 않는다 — 그림만 표로 늘어놓은 뉴스레터는 걷어 내면 빈 칸만 남는다.
* 그림뿐이던 원문은 이렇게 아무것도 남지 않는다 — 그때 인용은 글자 본문을 쓴다 (originalBodyHtml).
*/
export function hasVisibleQuoteContent(html: string): boolean {
return /\S/.test(html.replace(ANY_TAG, '').replace(SPACE_ENTITY, ''))
}
/** 생략 표시 문단 — 편집기의 맞춤(fit-draft)과 같은 글 */
const OMITTED_PARAGRAPH = `<p>${escapeHtml(MAIL_QUOTE_OMITTED_MARK)}</p>`
/** HTML 을 길이에 맞춰 자른다 — 태그 한가운데서 자르지 않고, 잘랐으면 끝에 생략 표시를 둔다 */
function truncateHtml(html: string, maxLength: number): string {
if (html.length <= maxLength) return html
/** 인용할 원문 HTML — cut: 길이 때문에 여기서 잘랐다 (끝에 생략 표시를 둔다) */
interface QuotedSource {
html: string
cut: boolean
}
/** HTML 을 길이에 맞춰 자른다 — 태그 한가운데서 자르지 않는다 */
function truncateHtml(html: string, maxLength: number): QuotedSource {
if (html.length <= maxLength) return { html, cut: false }
let cut = truncateText(html, maxLength)
// 마지막 태그가 닫히지 않았으면 그 태그 앞에서 자른다
const lastOpen = cut.lastIndexOf('<')
if (lastOpen > cut.lastIndexOf('>')) cut = cut.slice(0, lastOpen)
return `${cut}${OMITTED_PARAGRAPH}`
return { html: cut, cut: true }
}
/** 텍스트를 길이에 맞춰 자른 뒤 문단 HTML 로 — 서로게이트 쌍 가운데는 자르지 않는다 */
function truncateTextToParagraphs(text: string, maxLength: number): QuotedSource {
if (text.length <= maxLength) return { html: textToParagraphHtml(text), cut: false }
return { html: textToParagraphHtml(truncateText(text, maxLength)), cut: true }
}
const NO_SUBJECT = '(제목 없음)'
@@ -61,14 +87,24 @@ export function textToParagraphHtml(text: string): string {
.join('')
}
type OriginalSource = Pick<MessageDetail, 'htmlBody' | 'textBody'>
type OriginalSource = Pick<MessageDetail, 'htmlBody' | 'textBody' | 'isBodyTruncated'>
/** 원문 본문 — 서버가 정화한 htmlBody(그림을 걷어 낸 것), 없으면 textBody 를 문단으로. 아주 길면 자르고 생략 표시 */
/**
* 원문 본문 — 서버가 정화한 htmlBody(그림을 걷어 낸 것), null 이거나 공백뿐이면 textBody 를 문단으로.
* 고르는 규칙은 읽기 화면과 같다 (mail-html.ts 의 displayedHtmlBody) — 화면에 보인 본문을 인용한다. 아주 길면 자른다.
* 다만 그림을 걷어 내니 보이는 것이 남지 않았으면(그림뿐이던 원문) 공백뿐인 HTML 과 같이 textBody 를 쓴다 —
* 인용은 그림을 받지 않으므로, 읽기 화면에 보인 그림 대신 글자 본문이라도 남긴다 (읽기 화면은 그대로 그림을 보인다).
* 여기서 잘랐거나 서버가 읽기 한도(2MB)에서 잘라 보냈으면(isBodyTruncated — 인용하는 본문이 그 본문이다)
* 끝에 생략 표시를 한 번 둔다 — 원문이 거기서 끝나는 것처럼 보이지 않게.
*/
export function originalBodyHtml(message: OriginalSource): string {
if (message.htmlBody) return truncateHtml(message.htmlBody.replace(IMAGE_TAG, ''), QUOTE_SOURCE_MAX_LENGTH)
const text = message.textBody ?? ''
if (text.length <= QUOTE_SOURCE_MAX_LENGTH) return textToParagraphHtml(text)
return `${textToParagraphHtml(truncateText(text, QUOTE_SOURCE_MAX_LENGTH))}${OMITTED_PARAGRAPH}`
const html = displayedHtmlBody(message.htmlBody)?.replace(IMAGE_TAG, '') ?? null
const source =
html !== null && hasVisibleQuoteContent(html)
? truncateHtml(html, QUOTE_SOURCE_MAX_LENGTH)
: truncateTextToParagraphs(message.textBody ?? '', QUOTE_SOURCE_MAX_LENGTH)
const omitted = source.cut || message.isBodyTruncated === true
return omitted ? `${source.html}${OMITTED_PARAGRAPH}` : source.html
}
type HeaderSource = Pick<MessageDetail, 'from' | 'to' | 'cc' | 'subject' | 'receivedAt' | 'sentAt'>
+74 -1
View File
@@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest'
import { MAIL_SUBJECT_MAX_LENGTH } from '@/shared/constants/mail'
import { MAIL_QUOTE_OMITTED_MARK, MAIL_SUBJECT_MAX_LENGTH } from '@/shared/constants/mail'
import type { MessageDetail } from '@/shared/types/mail'
import { buildReplyDraft, buildReplyRecipients, buildReplySubject } from './mail-reply'
@@ -75,6 +75,57 @@ describe('buildReplyRecipients', () => {
})
})
describe('buildReplyRecipients — 내가 보낸 메일(보낸 사람이 나)에 답장', () => {
const MYSELF = { name: '김아라', email: ME }
it('답장 — 원래 받는 사람(나는 뺀다). 참조는 넣지 않는다', () => {
const sent = message({ from: MYSELF, to: [JUNHO, MYSELF, DOYUN], cc: [INFRA] })
expect(buildReplyRecipients(sent, ME, 'reply')).toEqual({ to: [JUNHO, DOYUN], cc: [] })
})
it('전체 답장 — 원래 받는 사람과 원래 참조 (둘 다 나는 뺀다)', () => {
const sent = message({ from: MYSELF, to: [JUNHO], cc: [INFRA, MYSELF] })
expect(buildReplyRecipients(sent, ME, 'replyAll')).toEqual({ to: [JUNHO], cc: [INFRA] })
})
it('회신 주소(Reply-To)가 있어도 원래 받는 사람에게 — 내 메일의 회신 주소는 남이 답할 때 쓰는 값이다', () => {
const sent = message({ from: MYSELF, replyTo: [INFRA], to: [JUNHO] })
expect(buildReplyRecipients(sent, ME, 'reply')).toEqual({ to: [JUNHO], cc: [] })
expect(buildReplyRecipients(sent, ME, 'replyAll')).toEqual({ to: [JUNHO], cc: [] })
})
it.each(['reply', 'replyAll'] as const)(
'나에게만 보낸 메일이면 받는 사람 칸을 비우지 않고 나에게 답장한다 (%s)',
(mode) => {
const toSelf = message({ from: MYSELF, to: [MYSELF], cc: [] })
expect(buildReplyRecipients(toSelf, ME, mode)).toEqual({ to: [MYSELF], cc: [] })
},
)
it('받는 사람 칸이 비고 참조만 있으면 — 받는 사람은 나, 전체 답장은 원래 참조를 그대로 둔다', () => {
const ccOnly = message({ from: MYSELF, to: [], cc: [INFRA] })
expect(buildReplyRecipients(ccOnly, ME, 'reply')).toEqual({ to: [MYSELF], cc: [] })
expect(buildReplyRecipients(ccOnly, ME, 'replyAll')).toEqual({ to: [MYSELF], cc: [INFRA] })
})
it('보낸 사람이 나인지는 앞뒤 공백 · 대소문자를 가리지 않는다', () => {
const sent = message({ from: { name: '김아라', email: ' ARA.Kim@AraSystem.it.kr ' }, to: [JUNHO] })
expect(buildReplyRecipients(sent, ` ${ME.toUpperCase()} `, 'reply').to).toEqual([JUNHO])
})
it('로그인한 주소를 모르면(빈 값) 지금처럼 보낸 사람에게', () => {
const sent = message({ from: MYSELF, to: [JUNHO] })
expect(buildReplyRecipients(sent, '', 'reply').to).toEqual([MYSELF])
})
})
describe('buildReplyDraft', () => {
it('받는 사람 · Re: 제목 · inReplyTo(메일 id) 를 채운다', () => {
const draft = buildReplyDraft(message(), ME, 'reply', 'Asia/Seoul')
@@ -118,6 +169,28 @@ describe('buildReplyDraft', () => {
expect(html).not.toContain('텍스트')
})
it('서버가 읽기 한도에서 자른 원문(isBodyTruncated)이면 인용 끝에 생략 표시 — 편집기가 줄이지 않아도', () => {
const { html } = buildReplyDraft(
message({ htmlBody: '<p>앞부분</p>', isBodyTruncated: true }),
ME,
'reply',
'Asia/Seoul',
)
expect(html.endsWith(`<p>앞부분</p><p>${MAIL_QUOTE_OMITTED_MARK}</p></blockquote>`)).toBe(true)
})
it('서식 본문이 공백뿐이면(외부 그림만 있던 메일) 읽기 화면처럼 글자 본문을 인용한다 — 잘린 원문이면 생략 표시도', () => {
const { html } = buildReplyDraft(
message({ htmlBody: '\n\n\n\n\n', textBody: '원문 글자 본문', isBodyTruncated: true }),
ME,
'reply',
'Asia/Seoul',
)
expect(html.endsWith(`<p>원문 글자 본문</p><p>${MAIL_QUOTE_OMITTED_MARK}</p></blockquote>`)).toBe(true)
})
it('계약 한도에 맞춘다 — 원래 제목 251자면 "Re: " 를 붙여 255자, 252자면 255자에서 자른다', () => {
const fits = buildReplyDraft(message({ subject: '가'.repeat(251) }), ME, 'reply')
const over = buildReplyDraft(message({ subject: '가'.repeat(252) }), ME, 'reply')
+27 -3
View File
@@ -31,11 +31,32 @@ export function buildReplySubject(subject: string): string {
type ReplySource = Pick<MessageDetail, 'from' | 'to' | 'cc' | 'replyTo'>
/**
* 내가 보낸 메일(보낸 사람이 나)에 답장 — 그 메일을 받은 사람에게 보낸다 (Reply-To 는 보지 않는다).
* - 답장: 원래 받는 사람 · 전체 답장: 원래 받는 사람과 원래 참조 — 둘 다 나는 뺀다
* - 받는 사람이 비면(나에게만 보낸 메일) 나에게 답장한다 — 받는 사람 칸을 비워 두지 않는다
*/
function replyToOwnMessage(
message: ReplySource,
me: Address,
notMe: (address: Address) => boolean,
mode: ReplyMode,
): { to: Address[]; cc: Address[] } {
const recipients = uniqueAddresses(message.to).filter(notMe)
const to = recipients.length > 0 ? recipients : [me]
if (mode === 'reply') return { to, cc: [] }
const cc = uniqueAddresses(message.cc).filter(
(address) => notMe(address) && !to.some((target) => sameAddress(target, address)),
)
return { to, cc }
}
/**
* 답장 받는 사람.
* - 답장: Reply-To 가 있으면 그 주소, 없으면 보낸 사람
* - 전체 답장: 위 주소 + 원래 받는 사람(받는 사람 칸) + 원래 참조(참조 칸)
* 어느 쪽이든 나 자신은 뺀다. 내가 보낸 메일에 답장하면 원래 받는 사람에게 보낸다.
* 어느 쪽이든 나 자신은 뺀다 (주소는 앞뒤 공백 · 대소문자를 가리지 않고 비교한다).
* 내가 보낸 메일에 답장하면 원래 받는 사람에게 보낸다 (replyToOwnMessage — 나에게만 보낸 메일이면 나에게).
*/
export function buildReplyRecipients(
message: ReplySource,
@@ -45,9 +66,12 @@ export function buildReplyRecipients(
const me = normalizeEmail(myEmail)
const notMe = (address: Address) => normalizeEmail(address.email) !== me
const primary = message.replyTo.length > 0 ? message.replyTo : message.from ? [message.from] : []
const { from } = message
if (from && me !== '' && !notMe(from)) return replyToOwnMessage(message, from, notMe, mode)
const primary = message.replyTo.length > 0 ? message.replyTo : from ? [from] : []
const others = primary.filter(notMe)
// 내가 보낸 메일이면(보낸 사람이 나) 원래 받는 사람에게 답장한다
// 답장할 주소가 나뿐이면(Reply-To 가 나) 원래 받는 사람에게 답장한다
const base = others.length > 0 ? others : message.to.filter(notMe)
if (mode === 'reply') return { to: uniqueAddresses(base), cc: [] }
@@ -77,6 +77,26 @@ describe('acceptStyleValue — 들어온 값(붙여 넣기 · 원문 인용)을
expect(acceptStyleValue('font-size', '80pt')).toBeNull()
})
it('보이지 않을 만큼 작은 글자는 받지 않는다 — 하한 8px 은 pt 를 px 로 바꾼 값으로 본다 (6pt = 8px · 5.25pt = 7px)', () => {
expect(acceptStyleValue('font-size', '8px')).toBe('8px')
expect(acceptStyleValue('font-size', '6pt')).toBe('8px')
expect(acceptStyleValue('font-size', '5.25pt')).toBeNull()
expect(acceptStyleValue('font-size', '7.99px')).toBeNull()
expect(acceptStyleValue('font-size', '1px')).toBeNull()
})
it('거의 투명한 글자 색(알파 0.3 미만)은 받지 않는다 — 배경 색 · 테두리 색은 알파 0~1 그대로', () => {
expect(acceptStyleValue('color', 'rgba(0, 0, 0, 0.3)')).toBe('rgba(0, 0, 0, 0.3)')
expect(acceptStyleValue('color', 'rgba(0,0,0,.35)')).toBe('rgba(0,0,0,.35)')
expect(acceptStyleValue('color', 'rgba(0, 0, 0, 1)')).toBe('rgba(0, 0, 0, 1)')
expect(acceptStyleValue('color', 'rgba(0, 0, 0, 0.1)')).toBeNull()
expect(acceptStyleValue('color', 'rgba(0, 0, 0, 0)')).toBeNull()
expect(acceptStyleValue('color', 'rgb(0, 0, 0)')).toBe('rgb(0, 0, 0)')
expect(acceptStyleValue('background-color', 'rgba(0, 0, 0, 0.1)')).toBe('rgba(0, 0, 0, 0.1)')
expect(acceptStyleValue('border-color', 'rgba(0, 0, 0, 0)')).toBe('rgba(0, 0, 0, 0)')
expect(isOutgoingStyleValue('border', '1px solid rgba(0, 0, 0, 0)')).toBe(true)
})
it('lengthToPx — px · pt 만 (기준 크기를 모르는 em · % 는 null)', () => {
expect(lengthToPx('40px')).toBe(40)
expect(lengthToPx('36pt')).toBe(48)
@@ -7,6 +7,9 @@
// 쓰는 곳: 서식 편집기가 붙여 넣은 글 · 원문 인용을 읽어 들일 때 이 형식에 맞는 값만 받는다 (editor/mail-format-extensions.ts).
// 서버만 지우면 편집기에서만 보이는(또는 숨는) 모양이 생긴다 — 예: font-size:0 으로 숨긴 글이 받는 사람에게는 보인다.
// 그래서 편집기에서도 처음부터 버린다 — 편집기에 보이는 모양 = 받는 사람이 보는 모양.
// 숨긴 글을 막는 하한은 두 가지만 막는다 — 8px 보다 작은 글자 크기, 알파가 0.3 보다 작은 글자 색(rgba).
// 그런 값은 그 서식만 버려 기본 크기 · 색으로 보인다 (계약 2절 "서식 본문", 디자인 README "붙여 넣은 글 · 원문 인용의 서식").
// 바탕과 같은 색의 글(흰 바탕에 흰 글 등)은 막지 않는다 — 받는 쪽에서 글 뒤에 깔릴 바탕 색을 알 수 없기 때문이다.
// ── style 값 형식 (서버 STYLE_RULES 와 같다 — 모두 처음부터 끝까지, 대소문자 무시) ──────────────
@@ -14,13 +17,18 @@
const PX = String.raw`(?:0|\d{1,3}(?:\.\d{1,2})?px)`
/** 칸 폭 — px · % · auto */
const WIDTH = String.raw`(?:${PX}|\d{1,3}(?:\.\d{1,2})?%|auto)`
/** 색 — #rgb · #rrggbb · rgb()/rgba() (괄호 안은 숫자만) */
/** 색 — #rgb · #rrggbb · rgb()/rgba() (괄호 안은 숫자만, 알파 0~1) — 배경 색 · 테두리 색 */
const COLOR = String.raw`(?:#[0-9a-f]{3}|#[0-9a-f]{6}|rgba?\(\s*\d{1,3}\s*,\s*\d{1,3}\s*,\s*\d{1,3}\s*(?:,\s*(?:0|1|0?\.\d{1,3})\s*)?\))`
/**
* 글자 색 — COLOR 와 같되 rgb()/rgba() 에 알파가 있으면 0.3~1 (거의 투명한 글자로 숨긴 글을 보내지 않게).
* hex 는 알파가 없다 (8자리 hex 는 COLOR 에서도 받지 않는다).
*/
const TEXT_COLOR = String.raw`(?:#[0-9a-f]{3}|#[0-9a-f]{6}|rgba?\(\s*\d{1,3}\s*,\s*\d{1,3}\s*,\s*\d{1,3}\s*(?:,\s*(?:1|0?\.[3-9]\d{0,2})\s*)?\))`
const BORDER_STYLE = String.raw`(?:none|hidden|dotted|dashed|solid|double)`
const BORDER_WIDTH = String.raw`(?:${PX}|thin|medium|thick)`
const BORDER_PART = String.raw`(?:${BORDER_WIDTH}|${BORDER_STYLE}|${COLOR})`
/** 글자 크기 — px 만, 1~99.99px */
const FONT_SIZE = String.raw`(?:[1-9]\d?(?:\.\d{1,2})?px)`
/** 글자 크기 — px 만, 8~99.99px. 8px 아래는 보이지 않게 숨긴 글로 보고 받지 않는다 */
const FONT_SIZE = String.raw`(?:(?:[89]|[1-9]\d)(?:\.\d{1,2})?px)`
/** 줄 간격 — 단위 없는 숫자 1~3, 소수 둘째 자리까지 */
const LINE_HEIGHT = String.raw`(?:[12](?:\.\d{1,2})?|3(?:\.0{1,2})?)`
@@ -67,6 +75,7 @@ const FONT_NAME = (() => {
const exactly = (pattern: string) => new RegExp(`^${pattern}$`, 'i')
const COLOR_VALUE = exactly(COLOR)
const TEXT_COLOR_VALUE = exactly(TEXT_COLOR)
const PX_VALUE = exactly(PX)
const WIDTH_VALUE = exactly(WIDTH)
/** margin · padding 줄임형 — px 길이 1~4개 */
@@ -78,7 +87,7 @@ const BORDER_VALUE = exactly(String.raw`${BORDER_PART}(?:\s+${BORDER_PART}){0,2}
* 여기 없는 속성(display · position · visibility · opacity · text-decoration 등)은 서버가 지운다.
*/
export const OUTGOING_STYLE_RULES = {
color: COLOR_VALUE,
color: TEXT_COLOR_VALUE,
'background-color': COLOR_VALUE,
'font-family': exactly(String.raw`${FONT_NAME}(?:\s*,\s*${FONT_NAME}){0,9}`),
'font-size': exactly(FONT_SIZE),
@@ -148,7 +157,8 @@ export function lengthToPx(value: string): number | null {
/**
* 들어온 style 값 → 보낼 수 있는 값. 형식이 틀리면 null (그 서식은 받지 않는다).
* - `!important` 는 떼고 본다 (서버도 값만 본다)
* - 글자 크기: pt 는 px 로 바꾸고 소수 셋째 자리부터는 반올림한다 (워드 10pt → 13.33px, 아웃룩의 13.3333px)
* - 글자 크기: pt 는 px 로 바꾸고 소수 셋째 자리부터는 반올림한다 (워드 10pt → 13.33px, 아웃룩의 13.3333px).
* 하한은 바꾼 px 로 본다 — 6pt(8px)는 받고 5.25pt(7px)는 받지 않는다
*/
export function acceptStyleValue(property: OutgoingStyleProperty, raw: string | null | undefined): string | null {
if (typeof raw !== 'string') return null
@@ -13,6 +13,9 @@ export const STYLE_SAMPLES: readonly (readonly [property: string, value: string,
['color', 'rgb(23, 92, 211)', true],
['color', 'RGB(1,2,3)', true],
['color', 'rgba(0, 0, 0, 0.5)', true],
['color', 'rgba(0, 0, 0, 0.3)', true],
['color', 'rgba(0, 0, 0, 0.29)', false],
['color', 'rgba(0, 0, 0, 0)', false],
['color', 'red', false],
['color', 'transparent', false],
['color', 'currentcolor', false],
@@ -26,11 +29,14 @@ export const STYLE_SAMPLES: readonly (readonly [property: string, value: string,
['background-color', '#FFF1A8', true],
['background-color', 'yellow', false],
['background-color', 'transparent', false],
// 글자 크기 — px 만, 1~99.99px
['background-color', 'rgba(0, 0, 0, 0)', true],
// 글자 크기 — px 만, 8~99.99px (8px 미만은 보이지 않게 숨긴 글로 보고 지운다)
['font-size', '14px', true],
['font-size', '12.5px', true],
['font-size', '13.33px', true],
['font-size', '1px', true],
['font-size', '1px', false],
['font-size', '8px', true],
['font-size', '7.99px', false],
['font-size', '99.99px', true],
['font-size', '0', false],
['font-size', '0px', false],
+11 -2
View File
@@ -1,6 +1,15 @@
// 단위 테스트 전용 — 앱과 같은 이름의 라우트를 가진 메모리 라우터 (화면은 빈 컴포넌트)
import { h, type FunctionalComponent } from 'vue'
import { createMemoryHistory, createRouter, type Router } from 'vue-router'
import { computed, h, type FunctionalComponent } from 'vue'
import { createMemoryHistory, createRouter, matchedRouteKey, type Router } from 'vue-router'
/**
* RouterView 없이 바로 띄운 화면에 "지금 그리는 경로"를 준다 — 화면의 onBeforeRouteLeave · onBeforeRouteUpdate 가 그 경로에 걸린다
* (RouterView 가 하위 화면에 주는 값과 같다). 띄우기 전에 router.push 로 그 경로에 가 있어야 한다.
* 사용: mount(Page, { global: { plugins: [pinia, router], provide: routeViewProvide(router) } })
*/
export function routeViewProvide(router: Router): Record<symbol, unknown> {
return { [matchedRouteKey as symbol]: computed(() => router.currentRoute.value.matched.at(-1)) }
}
const Empty: FunctionalComponent = () => h('div')
+11
View File
@@ -0,0 +1,11 @@
// 단위 테스트 전용 — 여러 테스트 파일이 함께 쓰는 시간 한도. 전체 한도는 vitest.config.ts 의 TEST_TIMEOUT_MS(20초)다.
// 앱 코드에서 import 하지 않는다 (번들에 들어가지 않는다).
/**
* 긴 초안 — 텍스트 한도(100,000자)를 넘는 11만 자 남짓의 원문 인용 — 을 만들어 한도에 맞추는 테스트의 시간 한도.
* 초안을 편집기로 옮기고 한도에 맞추느라 오래 걸린다 (MailEditor.spec 의 긴 초안 테스트 · ComposePage.spec 의 긴 답장 보내기).
* 따로 돌리면 0.7~3.6초지만, 전체 테스트를 한꺼번에 돌리던 중 5.76초, 이 파일과 전체 실행을 함께 돌린 부하에서 11.7초,
* 전체 실행 두 개를 함께 돌린 부하에서 13.2초까지 걸렸다 — 쓰기 화면의 긴 답장 보내기는 부하에서 7.6초 (2026-10-27).
* 전체 한도에 가까워 이 테스트들에만 30초를 준다.
*/
export const LONG_DRAFT_TIMEOUT_MS = 30_000
+11
View File
@@ -2,6 +2,15 @@ import { fileURLToPath } from 'node:url'
import { mergeConfig, defineConfig, configDefaults } from 'vitest/config'
import viteConfig from './vite.config'
/**
* 테스트 하나 · 훅 하나의 시간 한도 (기본은 테스트 5초 · 훅 10초).
* jsdom 에 서식 편집기(TipTap/ProseMirror)를 띄우는 테스트는 CPU 를 많이 쓴다 — 부하가 걸리면 테스트 하나가 5~13초 걸려
* 기본 5초에서 전체 실행이 들쭉날쭉 실패했다. 이 호스트는 다른 프로젝트의 CI 도 함께 돌려 두 겹 부하가 흔하다 —
* 전체 실행 두 개를 함께 돌리자 15초 한도 아래의 가장 느린 테스트가 12.8초까지 걸려 20초로 둔다 (2026-10-27).
* 정말 멈춘 테스트는 이 한도(20초) 안에 여전히 실패한다.
*/
const TEST_TIMEOUT_MS = 20_000
// vite.config 는 mode 별 설정(포트·프록시)을 계산하는 함수다.
// mergeConfig 는 함수를 병합하지 못하므로 테스트용 ConfigEnv 로 먼저 평가한다.
// (테스트는 dev 서버를 띄우지 않아 포트·프록시 값에 영향받지 않는다)
@@ -12,6 +21,8 @@ export default mergeConfig(
environment: 'jsdom',
exclude: [...configDefaults.exclude, 'e2e/**'],
root: fileURLToPath(new URL('./', import.meta.url)),
testTimeout: TEST_TIMEOUT_MS,
hookTimeout: TEST_TIMEOUT_MS,
},
}),
)