24 Commits

Author SHA1 Message Date
ttipo 198ac8f4f2 chore(flutter): 앱 판을 1.0.1 로 올린다
CI (Flutter) / Flutter (analyze · test) (pull_request) Successful in 22m14s
CI / Backend (lint · type-check · test · e2e · build) (pull_request) Successful in 13m2s
CI / Frontend (lint · type-check · test · build) (pull_request) Successful in 13m25s
CI / Infra (compose · Dockerfile · nginx · 운영 스크립트) (pull_request) Successful in 2m6s
CI / 에러 코드 SSOT 동기화 검사 (pull_request) Successful in 11m25s
CI (Flutter) / Flutter (analyze · test) (push) Successful in 22m26s
CI / Backend (lint · type-check · test · e2e · build) (push) Successful in 13m44s
CI / Frontend (lint · type-check · test · build) (push) Successful in 12m40s
CI / Infra (compose · Dockerfile · nginx · 운영 스크립트) (push) Successful in 2m11s
CI / 에러 코드 SSOT 동기화 검사 (push) Has started running
읽기 화면의 빈 본문과 쓰기 · 검색 입력칸 고침을 직원 휴대폰에 내보낼 판이다.
최소 판(app-config 1.0.0)은 그대로라 1.0.0 을 쓰는 휴대폰도 막지 않는다.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-12 08:11:02 +09:00
ttipo b644e8b104 docs: 앱 읽기 화면의 빈 본문과 쓰기 화면 입력칸 규칙을 앱 디자인 README · flutter 스킬에 적는다
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-12 08:10:34 +09:00
ttipo bd916ccbed fix(flutter): 쓰기 · 검색 입력칸에 테마의 둥근 테두리가 그려지던 것을 고치고 칸 줄을 보드에 맞춘다
InputDecoration.collapsed 는 enabledBorder · focusedBorder 를 비워 두어 TextField 가
앱 테마의 둥근 테두리로 채운다. 그래서 받는 사람 · 제목 · 본문 · 검색 칸에 글자를
감싼 테두리가 그려지고 초점이면 칸 줄의 아래 선과 청록 테두리가 겹쳤다
(11-12 배포 뒤 사용자 보고). 여섯 테두리를 모두 없앤 borderlessInputDecoration 으로
바꾸고, lib 에 InputDecoration.collapsed 가 다시 들어오면 시험이 실패하게 한다.

함께 칸 줄을 보드 App1Compose 에 맞췄다: 칸 이름 15 · 간격 6 · 오른쪽 여백 4,
칩이 여러 줄이면 칸 이름과 [참조 펼치기]를 첫 칩 줄에, 안내 글 색 · 칩 ✕ 색,
본문 칸 최소 높이(원본 메일이 있으면 150 · 없으면 서명이 있어도 240).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-12 08:10:34 +09:00
ttipo 94ec659b78 fix(flutter): 본문 없이 첨부만 있는 메일에서 읽기 화면 아래가 비고 첨부가 잘리던 것을 고친다
읽기 화면은 머리(제목 · 보낸 사람 · 안내 · 첨부)가 남은 높이의 절반까지만 쓰고
나머지는 본문 WebView 였다. 본문이 비면 아래 절반이 빈 칸으로 남고 첨부가 많으면
위 절반 안에서 잘렸다(11-12 배포 뒤 사용자 보고).

정화한 본문에 보이는 것(글자 · 주소가 남은 그림 · hr · li · details · q)이 없으면
상태 bodyEmpty 로 본문 칸을 두지 않고 머리가 화면 전체를 한 스크롤로 쓴다.
판정은 정화와 같은 길에서 내고(큰 본문은 isolate), 아래 막대가 없는 예약 메일은
머리 스크롤에 시스템 아래 여백을 더한다. 본문이 있는 메일은 그대로다.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-12 08:10:34 +09:00
ttipo 487760bd9b docs: 휴대폰 앱 APK 크기와 apksigner 의 Java 를 바로 적는다
첫 release APK(1.0.0)는 기기 종류 셋(ABI)이 한 파일에 들어 64.4MB 였다 —
문서의 "판마다 30MB 남짓"을 고친다. 개발 PC 에 java 가 PATH 에 없으면
apksigner 가 결과 없이 끝나 서명 확인을 놓치므로 Android Studio 의 Java 를
먼저 잡는 줄을 flutter/README 사내 배포에 더한다.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-12 00:28:55 +09:00
ttipo 37ffcfca2c docs: 로드맵에 11월 12일 운영 배포를 적는다
14 · 15 · 16단계 · 앱 서버 · 업데이트 내역을 11-12 0시에 운영에 올렸고
휴대폰 앱 1.0.0 을 나눠 주기 시작했다. 배포 뒤 받은 의견(앱 읽기 화면 ·
쓰기 화면 입력칸)은 앱 1.0.1 로 고치는 중이다.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-12 00:28:54 +09:00
ttipo f4a85fda27 docs: 쓰기 화면 스크롤과 목록 고르기 칸 고침을 디자인 README · 스킬에 적는다
CI (Flutter) / Flutter (analyze · test) (pull_request) Successful in 23m1s
CI / Backend (lint · type-check · test · e2e · build) (pull_request) Successful in 14m22s
CI / Frontend (lint · type-check · test · build) (pull_request) Successful in 15m10s
CI / Infra (compose · Dockerfile · nginx · 운영 스크립트) (pull_request) Successful in 3m50s
CI / 에러 코드 SSOT 동기화 검사 (pull_request) Successful in 11m41s
CI (Flutter) / Flutter (analyze · test) (push) Successful in 22m57s
CI / Backend (lint · type-check · test · e2e · build) (push) Successful in 13m47s
CI / Frontend (lint · type-check · test · build) (push) Successful in 13m15s
CI / Infra (compose · Dockerfile · nginx · 운영 스크립트) (push) Successful in 1m51s
CI / 에러 코드 SSOT 동기화 검사 (push) Successful in 11m19s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 22:14:52 +09:00
ttipo 0ff1ec46af fix(frontend): 목록 보기에서 고르기 상자를 누르면 메일까지 열리던 것을 고친다
목록 보기의 줄은 줄 전체의 click 에서 event.target.closest('button') 로 단추를 가렸다. 상자 가운데(그림 rect)를 누르면 단추 처리기가 고르기를 바꾼 뒤
처리기 사이에 Vue 가 다시 그려 그 그림이 문서에서 떨어지고, 줄 처리기의 closest 가 비어 메일까지 열렸다(사용자 보고).

- 무엇을 눌렀는지는 사건이 지나간 길(composedPath — eventPathHas)로 가르고, 상자 · 별 그림은 pointer-events: none 이다
- 고르기 칸의 빈자리를 눌러도 고른다(Shift 는 범위 · Ctrl 은 하나씩), 별 칸의 빈자리는 아무 일도 없다. 대화로 묶은 목록의 줄도 같다

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 22:14:52 +09:00
ttipo f3586abcd7 fix(frontend): 받는 사람이 많으면 쓰기 화면의 [보내기]가 화면 밖으로 숨던 것을 고친다
받는 사람 · 참조 · 숨은참조가 여러 줄이 되면 편집 영역(최소 300)이 줄지 않아 쓰기 칸 전체가 화면을 넘쳤다. 바깥 스크롤과 본문 스크롤이 겹치고,
흐름 끝의 [보내기] 줄이 화면 밖으로 밀렸다(사용자 보고 — 전 직원을 숨은참조에 넣었을 때).

- [보내기] 줄은 스크롤하는 칸의 아래에 붙는다(sticky). 겹침 차례는 편집기 툴바 2 < [보내기] 줄 3 < 받는 사람 추천 목록 · 메뉴를 연 툴바 4
- 칩 · 입력 칸은 96px 을 넘으면 그 칸 안에서 스크롤한다(초점 · 칩 추가 · 입력 때 맨 아래로). 넓은 화면은 편집 영역이 200 까지 먼저 줄어 보통 높이에서 바깥 스크롤이 생기지 않는다
- 넓은 화면에서 숨긴 낭독 글이 앱 틀 밖에 놓여 문서까지 스크롤하던 것을 고치고(.compose position: relative), [보내기] 줄에 가린 칸으로 옮긴 초점은 가린 만큼 올린다(useFocusAboveFooter)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 22:14:52 +09:00
ttipo 6eba013416 docs(backend): 업데이트 내역 첫 글의 날짜를 배포 날(11월 12일)로 정한다
배포 일정이 11월 12일(목) 오전 1시로 정해져 파일 이름을 2026-11-12.md 로 바꾸고 날짜 메모 줄을 지웠다.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 20:35:24 +09:00
ttipo f0e9a50b01 docs(backend): 업데이트 내역 첫 글을 11월 5일 이후 바뀐 것으로 쓴다
CI (Flutter) / Flutter (analyze · test) (pull_request) Successful in 24m43s
CI / Backend (lint · type-check · test · e2e · build) (pull_request) Successful in 14m6s
CI / Frontend (lint · type-check · test · build) (pull_request) Successful in 13m45s
CI / Infra (compose · Dockerfile · nginx · 운영 스크립트) (pull_request) Successful in 2m4s
CI / 에러 코드 SSOT 동기화 검사 (pull_request) Successful in 11m43s
1차 배포(11월 5일 전환) 뒤 만든 기능을 직원이 어디서 무엇을 누르면 되는지 중심으로 모았다(휴대폰 앱 · 웹메일과 휴대폰 앱 · 웹메일의 메일 읽기와 정리 · 주소록 · 설정 · 그 밖에).
앱에서도 보이는 글이라 묶음 머리에 어느 쪽 기능인지 적었다. 파일 이름(날짜)은 임시 — 배포 날로 고치고 메모 줄을 지운다.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 20:16:29 +09:00
ttipo 5ce614cc0c docs: 업데이트 내역을 계약 7절 · 디자인 README · 스킬에 적는다
계약 7절(API 둘 · 글 파일 규칙)과 머리말의 달력 날짜 예외, 웹 · 앱 디자인 README 의 업데이트 내역 절, 세 스택 스킬 행을 더했다.
보드의 목록 최대 개수를 구현(100)에 맞추고, 편집 도구가 보이지 않는 글자의 이스케이프를 실제 글자로 푼 일을 공통 규칙에 적는다.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 19:37:01 +09:00
ttipo def96e833b feat(frontend): 설정 › 정보 › 업데이트 내역(목록 → 글)을 더한다
- 설정 메뉴 셋째 묶음 "정보", 목록은 게시판 표(줄 전체 링크 · [더 보기]), 글은 [← 목록으로] · 다음 글 · 이전 글 줄 · [목록]
- 글을 보는 동안에도 메뉴 항목이 고른 상태다(aria-current 는 목록 page · 글 true)
- 문서가 스크롤하는 좁은 폭에서도 글 · 목록이 맨 위부터 보이고(라우터 전역 scrollBehavior 없이), 위에 붙은 줄에 초점이 가리지 않는다
- 날짜는 달력 날짜 글이라 시간대를 거치지 않는다, /settings/info 는 목록으로 바꿔 끼운다

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 19:37:00 +09:00
ttipo 1d01130c9d feat(flutter): 설정 › 앱 정보에 업데이트 내역(목록 → 글)을 더한다
앱 버전 다음 줄에서 목록(제목 · 날짜, [더 보기])으로 들어가 줄을 누르면 글(날짜 · 제목 · 묶음, 다음 글 · 이전 글 줄)이 열린다.
없는 글 · 받지 못함 · 글 없음 상태를 두고, 날짜는 달력 날짜 글이라 시각으로 바꾸지 않는다(CalendarDate). 다른 글 줄은 지금 글을 바꿔 끼워 뒤로는 늘 목록이다.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 19:37:00 +09:00
ttipo 1b3f9ba7fe refactor(flutter): 메일 화면의 불러오기 상태 위젯을 shared 로 옮긴다
업데이트 내역(설정 모듈)도 같은 상태 틀을 쓰는데 모듈끼리 import 하지 않으므로 shared/widgets/load_states.dart 로 옮겼다.
모양 · 동작은 그대로다(MailStateLayout → StateLayout · MailListError → LoadErrorView · 막대 SkeletonBar · LoadingSkeleton). 메일 줄 꼴의 막대와 빈 메일함 등은 메일에 남는다.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 19:36:59 +09:00
ttipo cd958f3bf4 feat(backend): 업데이트 내역 읽기 API 와 첫 글을 더한다
직원이 웹메일 · 휴대폰 앱에서 무엇이 새로 생겼는지 읽는 게시판의 서버 몫이다. 글은 운영자가 backend/release-notes/ 에 글마다 파일 하나로 쓰고
(올리는 API · DB 표 · 새 에러 코드 없음), GET /api/release-notes(커서 목록)와 GET /api/release-notes/{id}(글 + 바로 옛 글 · 새 글)로 웹 · 앱에 똑같이 준다.

- 글 폴더는 src 밖이라 개발 컨테이너(바인드 마운트)는 다시 띄우지 않아도 새 글이 보이고, 운영 이미지는 Dockerfile 이 폴더를 함께 복사한다
- 틀린 글은 실행 때 그 글만 건너뛰고 error 로그(파일 이름 · 줄 · 까닭만 — 기동 때 한 번 읽어 배포 직후 보인다), 실제 폴더를 엄격하게 읽는 시험이 CI 에서 운영자의 오타를 잡는다
- 파일은 O_NOFOLLOW 로 한 번 열어 일반 파일 · 256KB 까지만 읽고, 모양이 틀린 id 도 없는 글과 같은 404 RES_001 이다
- 첫 글(2026-11-18.md)은 다음 배포 소식이다 — 파일 이름(날짜)은 배포 날로 고친다

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 19:36:59 +09:00
ttipo abe4c1364c docs: 업데이트 내역 보드를 더한다
웹 설정 › 정보 › 업데이트 내역과 앱 설정 › 앱 정보 › 업데이트 내역(게시판 — 목록에서 글로), 글 파일 쓰는 법을 한 보드에 그렸다(캔버스 v80 확정).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 16:43:26 +09:00
ttipo 1962f07d79 docs: 로드맵에 16단계 합침과 업데이트 내역 시작을 적는다
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 16:33:55 +09:00
ttipo d6b804ba83 fix(frontend): 16단계 실사용 시험에서 나온 둘을 고친다
CI / Frontend (lint · type-check · test · build) (pull_request) Successful in 13m52s
CI / Infra (compose · Dockerfile · nginx · 운영 스크립트) (pull_request) Successful in 2m4s
CI / Backend (lint · type-check · test · e2e · build) (pull_request) Successful in 13m39s
CI / 에러 코드 SSOT 동기화 검사 (pull_request) Successful in 11m53s
CI / Backend (lint · type-check · test · e2e · build) (push) Successful in 14m39s
CI / Frontend (lint · type-check · test · build) (push) Successful in 14m11s
CI / Infra (compose · Dockerfile · nginx · 운영 스크립트) (push) Successful in 2m4s
CI / 에러 코드 SSOT 동기화 검사 (push) Successful in 11m34s
- 주소록 가져오기 창의 미리 보기가 1366×768 · 1440×900 에서 잘리고 창도 스크롤하지 않았다 — 창(<dialog>)은 flex 열이라 overflow 를 건 세 묶음 상자가 내용보다 작게 눌렸다. 상자에 flex-shrink: 0 을 두고 가져오기 · 내보내기 창은 창 안이 스크롤한다
- 서명 위 빈 줄에 한글을 조합해 쓰면 띄어쓰기를 칠 때까지 빈 줄이 돌아오지 않았다 — 조합이 끝나면(compositionend 뒤 ProseMirror 가 조합을 마무리한 다음 차례) 편집기가 스스로 빈 줄을 넣는다. 조합 중에 임시 저장한 뒤 편집기가 넣은 빈 줄은 바꿈으로 세지 않는다
- 디자인 README · vue3 스킬에 두 가지를 적는다(모달 창의 flex 열 — 창 바로 아래 칸에 overflow: hidden 을 걸면 flex-shrink: 0 을 함께)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 13:30:08 +09:00
ttipo 19dbb2babb docs: 메일 16단계(개인 설정)를 계약 · 디자인 README · 스킬 · 로드맵에 적는다
CI / Backend (lint · type-check · test · e2e · build) (pull_request) Successful in 15m17s
CI / Frontend (lint · type-check · test · build) (pull_request) Successful in 14m10s
CI / Infra (compose · Dockerfile · nginx · 운영 스크립트) (pull_request) Successful in 2m13s
CI / 에러 코드 SSOT 동기화 검사 (pull_request) Successful in 11m54s
CI / Backend (lint · type-check · test · e2e · build) (push) Successful in 14m55s
CI / Frontend (lint · type-check · test · build) (push) Successful in 13m43s
CI / Infra (compose · Dockerfile · nginx · 운영 스크립트) (push) Successful in 2m2s
CI / 에러 코드 SSOT 동기화 검사 (push) Successful in 11m39s
계약 2절(서명 · 부재중 — 원본 판 3 과 되돌리기 계획) · 3절(가져오기 · 내보내기) · 5절(휴대폰 바꾸기),
디자인 README 16단계 절과 구현하며 정한 보완, nestjs · vue3 스킬 표, 로드맵의 추후 후보(16단계에서 미룬 것).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 11:44:43 +09:00
ttipo 907287021f feat(frontend): 주소록 가져오기 · 내보내기 · 여러 서명과 서명 바꾸기 · 부재중 서식 · 휴대폰 바꾸기
메일 16단계(개인 설정)의 화면이다(보드 Contacts16 · Signature16 · Settings16).
- 주소록: 브라우저가 vCard · CSV · 엑셀(.xlsx)을 읽어 미리 보기(파일은 서버로 가지 않는다 — 새 의존성 없이
  DecompressionStream · DOMParser), 네이버 · Google · Outlook 머리를 알아보고 칸 없는 정보는 메모 끝에,
  묶음(100명 · 값 8,000 · 700KB)으로 넣고 ✕까지 남는 결과 알림의 [되돌리기], 내보내기 창.
- 서명: 왼쪽 목록 · 오른쪽 편집 · 메일에 넣을 서명 둘, 쓰기 화면 [서명 ▾]로 서명 자리만 바꾸기
  (앱과 같은 경계 — 앞 빈 줄을 지켜 임시 저장 · 이어 쓰기에서도 찾는다).
- 부재중: 내용 편집기(서식이 있을 때만 서식 본문) · 회사 밖에서 온 메일 응답 셋 · 미리 보기 전환.
- 2단계 인증: [휴대폰 바꾸기] 두 걸음 창(비밀은 창 안에만).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 11:44:42 +09:00
ttipo 0c3238936a feat(backend): 주소록 가져오기 · 내보내기 · 여러 서명 · 부재중 서식 · 2단계 인증 휴대폰 바꾸기
메일 16단계(개인 설정)의 서버다.
- 주소록: POST /api/contact-import-checks · contact-imports · contact-deletions 로 여러 명을 한 번에
  확인 · 만들기 · 지우기(화면이 준 uid 로 다시 보내도 두 번 들어가지 않는다), GET /api/contact-exports 로
  vCard 3.0 · CSV(BOM · 한국어 머리 · 수식 막기) 내보내기 — 하나씩 만들면 사무실 요청 수 한도에 걸린다.
- 여러 서명: /api/mail-signatures · /api/mail-signature-defaults, 서명마다 한 줄로 바꾸는 마이그레이션
  (지금 서명은 "기본 서명"으로 — 값 보존), 그림 정리는 모든 서명의 합집합. 옛 /api/mail-signature 는 앱 첫 판을 위해 그대로.
- 부재중: 서식 본문(multipart — 문자열 4,096바이트는 variables 조각으로)과 회사 밖 응답(봉투 도메인으로 가름 —
  답장도 그 주소로만), 원본 판 3 은 새 칸을 쓴 직원만.
- 2단계 인증: 켠 채 휴대폰 바꾸기(새 코드 확인 → 지금 코드 · 비밀번호로 엔진 요청 하나 — 꺼지는 틈 없음).
새 에러 코드 · 새 의존성 없음.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 11:44:41 +09:00
ttipo fceb6ed3e4 docs: 메일 16단계(개인 설정) 보드 셋을 더한다
주소록 가져오기 · 내보내기(Contacts16), 여러 서명과 쓰기 화면 서명 바꾸기(Signature16),
부재중 서식 · 회사 밖 응답과 2단계 인증 휴대폰 바꾸기(Settings16). 캔버스 v74 에서 확정했다 — 글은 화면 상수가 기준이다.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 08:55:04 +09:00
ttipo 060265c8b0 docs: 로드맵에 15단계 합침과 16단계 시작을 적는다
15단계(메일 · 메일함 다루기)는 main 에 합쳐 14단계와 함께 배포를 기다리고,
16단계(개인 설정)를 개발하기 시작했다. 16단계 가져오기는 엑셀(.xlsx)도 받는다.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 08:53:04 +09:00
286 changed files with 39607 additions and 1822 deletions
+1
View File
@@ -18,6 +18,7 @@ description: 세 클라이언트(backend/frontend/flutter) 공통 코딩 규칙
- 절대경로 alias 사용 (TS `@/`, Dart `package:`), 깊은 상대경로(`../../`) 지양
— backend/frontend 모두 `@/` → `src/` 로 설정되어 있다 (backend 는 `tsconfig.json` paths + jest `moduleNameMapper`)
- **자동 생성 파일은 직접 수정하지 않는다** — `*.generated.ts`, `*.g.dart` 는 생성 스크립트를 통해서만 바꾼다 ([[error-codes]])
- 소스에 보이지 않는 글자(nbsp · U+2028 · U+FEFF 등)는 `\uXXXX` 이스케이프로 적되, **저장한 뒤 바이트로 확인한다** — 편집 도구가 U+00A0 이상의 이스케이프를 실제 글자로 풀어 저장한 일이 있다(2026-11-10 — 정규식 안의 U+2028 이 줄 끝이 되어 컴파일 실패, 16단계에서는 U+FEFF). 풀리면 `String.fromCharCode(0x2028)` 처럼 글자 코드로 만든다
## 네이밍 규약
+2 -2
View File
@@ -106,7 +106,7 @@ sudo docker compose --env-file .env.production logs -f backend
`nginx-api-errors.conf`(이름 있는 오류 location — `@api_not_found` 404 `RES_001` 포함). 공개 화면 `/s/` 는 `no-referrer`(주소에 토큰) · `noindex` · `no-cache`.
- 받기 `GET /api/public-links/{token}/files/…` · `GET /api/messages/{id}/large-files/…` 는 SSE 처럼 버퍼링 · 압축을 끄고 `X-Accel-Buffering` 을 넘긴다
(기본값이면 받는 사람마다 1GB 까지 임시 파일에 받아 둔다). Range · If-Range 는 그대로 — 206 · 416 은 백엔드가 만든다.
**휴대폰 앱(APK) 받기 `GET /api/app-releases/android/latest/file`**(2026-11-08 — 계약 1절 "휴대폰 앱 받기", 포털 · 로그인한 직원, 판마다 30MB 남짓)도 같은 줄들이다 —
**휴대폰 앱(APK) 받기 `GET /api/app-releases/android/latest/file`**(2026-11-08 — 계약 1절 "휴대폰 앱 받기", 포털 · 로그인한 직원, 판마다 65MB 안팎 — 기기 종류 셋(ABI)이 한 파일에 든다)도 같은 줄들이다 —
머리글을 대용량 받기와 같은 도우미(`backend/src/shared/utils/download-headers.ts` — `X-Accel-Buffering: no`)가 만든다. 정보 `GET /api/app-releases/android/latest`(JSON)는 받기 location 에 들지 않고,
공개 내려받기 server 에서는 그 밖의 `/api` 처럼 404 `RES_001` 이다.
머리글 버퍼는 `proxy_buffer_size 16k` + `proxy_busy_buffers_size 16k`(둘이 함께 — 하나만이면 `nginx -t` 가 실패) — 한글 255자 이름이면 머리글이 3.5KB 라 기본 4KB 에 아슬아슬하다.
@@ -223,7 +223,7 @@ sudo docker compose --env-file .env.production logs -f backend
- 업로드 중 임시 디스크: 백엔드는 첨부를 `/tmp/arasystem-mail-uploads`(컨테이너 쓰기 계층)에, nginx 는 요청 본문을 자기 임시 폴더에 받는다.
필요한 공간은 동시 업로드 수 × 20MB 정도. backend 를 `read_only` 로 바꾸면 그 경로에 **tmpfs 가 아닌 볼륨**을 붙인다 (tmpfs 는 메모리 한도에 잡힌다).
- **업로드 파일은 named volume `<PROJECT_NAME>_uploads` 에 쌓인다** (`/api/files`, 메일 서명 그림은 그 안의 `mail-signatures/` — 서명 표와 함께 백업한다,
휴대폰 앱 APK 는 그 안의 `app-releases/` — 판마다 30MB 남짓 · 지우는 명령이 없어 올린 판이 모두 남는다 · 매일 백업에 든다). 호스트 디렉터리(`./data/uploads`)를 쓰지 않는 이유 —
휴대폰 앱 APK 는 그 안의 `app-releases/` — 판마다 65MB 안팎 · 지우는 명령이 없어 올린 판이 모두 남는다 · 매일 백업에 든다). 호스트 디렉터리(`./data/uploads`)를 쓰지 않는 이유 —
`sudo docker compose` 가 없는 디렉터리를 root 소유로 만들어 backend(node, UID 1000)가 쓰지 못한다. 새 named volume 은 이미지의
`/app/uploads`(node 소유) 소유자를 물려받는다 (2026-10-27 운영 이미지로 확인). 개발만 `./data/uploads` 바인드 마운트다.
백업은 매일 백업(`ops/` — 아래 "백업 · 감시")에 든다.
+12 -5
View File
@@ -25,9 +25,9 @@ description: Flutter 모바일 앱(flutter/) 개발 규칙 — Dart Sound Null S
| 앱 로그인 저장소 | `lib/features/auth/data/` | 계약 1절 "앱 로그인"(`/auth/app/login` · `refresh` · `logout`(리프레시 토큰 + Bearer) + Bearer `me`, 인터셉터 줄 밖의 세션 확인 `readSessionDirect`). 실패는 늘 `DioException`(`error` 에 `ApiException` — 응답 모양이 틀리면 `SYS_001`, `apiFailure()`). 모델은 손으로 쓴다(`AppLoginResult` · `AuthSessionInfo` · `SessionRestriction` — 모르는 제한은 `unknown`). 로그인 화면이 칸에 보이는 실패(`kLoginInlineCodes`)는 알리지 않는다 |
| **로그인 화면 (네 단계 — `App1Login`)** | `lib/features/auth/presentation/pages/`(login · login_code · password_change · two_factor_setup · restricted_login) · `widgets/`(`login_frame` · `login_fields` · `login_parts`) · 글 `core/constants/login_texts.dart`(제목은 `screen_titles.dart`) · `data/`(`two_factor_repository` · `support_contact_repository` · `authenticator_launcher`) | 노티파이어의 제한 단계 API(`attempt` · `passedCodeStep` · `heldLoginListenable` · `changePassword` · `enableTwoFactor` · `finishRestrictedStep` → `RestrictedStepResult { done, needsPassword, stale }`). 로그인 비밀번호는 두 제한 단계에서만 10분 쥔다(모르는 제한 · 제한이 풀리면 지움). 칸 · 상자에 보이는 코드만 알림을 끈다(`kLoginInlineCodes` · `kLoginCodeInlineCodes` · `kPasswordChangeInlineCodes` · `kTwoFactorSetupInlineCodes` — 앱 디자인 README 의 표), `AUTH_002` 는 스낵바 없이 로그인 화면 상자만. 2단계 키 · otpauth 주소는 그 화면 State 에만. **url_launcher**: `canLaunchUrl` 은 쓰지 않는다(debug 판이 주소째 로그를 남긴다) — `launchUrl(externalNonBrowserApplication)` 만, 실패 예외에 주소가 들어 있어 삼킨다, `<queries>` 는 필요 없다. 한글 줄바꿈은 `shared/widgets/keep_all_text.dart`(시험은 `findCopy`). 보안 설정 요청 한도 `kSecuritySettingTimeout` 30초 |
| **메일 목록 (`App1MailList`)** · 앱 틀 | `lib/features/mail/` — data(models · `mail_repository.dart`) · domain(`mailbox_tree` · `mail_list_merge` · `mail_bulk` — 순수 함수) · presentation(`pages/mail_list_page.dart` · providers · widgets), 틀 `core/router/app_shell.dart` · `shell_bar.dart`, `core/utils/`(`app_foreground` · `accessible_navigation` · `korean_format`), `shared/widgets/confirm_dialog.dart`, 시험 도구 `test/support/mail_fakes.dart` · `mail_harness.dart` | **틀은 Scaffold 가 아니다**(Column + 탭 막대 — 탭 안 화면마다 자기 Scaffold 를 둔다, 없으면 스낵바 · Material 조상이 없다). 탭 막대는 `MediaQuery.removePadding(removeTop)` 으로 감싸고, 화면이 숨기려면 `ShellBarHidden`(모듈마다 센다 — 보이는지는 TickerMode). Scaffold 는 넓은 단추 자리에 안전 영역을 넘기지 않으니 Scaffold 밖에서 읽어 넘긴다. 목록 상태는 메일함마다(`mailListProvider(String?)` autoDispose), 세션이 바뀌면 `mailAccountProvider` 로 처음부터. 알림은 두 갈래 — 단추 있는 `mailToastProvider`(한 번에 하나 · 화면 낭독기 켠 동안 닫지 않음) · 단추 없는 `mailNoticeSinkProvider`(전역 스낵바). 생명주기 안에서는 프로바이더를 바꾸지 않는다(그린 뒤 · 마이크로태스크), 다른 노티파이어 인스턴스를 든 노티파이어는 세션이 바뀐 뒤 다시 읽는다. 가짜 서버는 응답이 한 프레임 안에 오므로 "받는 중이 끝나는 순간"을 기다리는 위젯을 만들지 않는다 |
| **메일 읽기 (`App1Reader`)** | `lib/features/mail/` — domain(`mail_html`(두 번째 정화 · 중첩 비용 · CSP 문서 · 중계 그림 바꿔 넣기) · `mail_images` · `mail_links` · `compose_request` · `mail_body_notices`) · `data/`(`models/message_detail.dart` · `mail_link_launcher.dart`) · presentation(`pages/message_reader_page.dart` · `providers/message_reader_controller.dart` · `attachment_downloads.dart` · widgets `mail_body_view` · `reader_*`), 시험 도구 `test/support/fake_webview.dart`(`WebViewPlatform.instance`) · `file_fakes.dart` | **WebView 에는 `buildMailDocument` 의 문서만 넣는다**(CSP 를 맨 앞 · JS 끔 · 처음 문서 말고는 탐색을 막음 · 안드로이드는 파일 · content 접근 · 위치 · 섞인 내용 끔, 아래 틀(iframe)의 탐색은 판정을 거치지 않으니 막는 것은 정화(내용째 버림)와 CSP 다). 받은 HTML 은 서버가 정화했어도 `sanitizeMailHtml` 을 한 번 더 지난다 — package:html 은 `parse().body`(`parseFragment` 는 맨 위 형제가 많으면 제곱), 그 전에 `htmlNestingCost`(선형 · 파서보다 깊게 센다)로 한도를 보고, 바로 할지 isolate 로 보낼지는 `mailBodyBuildPlaceOf` 하나로 고른다. 중계 그림은 Dio(Bearer)로 받아 `data:` 로 넣고 토큰은 WebView 에 가지 않는다 — 문서에 싣는 양은 **나온 횟수까지** 센다(`resolveProxiedImages`). 응답은 그 요청이 바꾼 칸만 지금 요약에 덮는다(`_applyResponse`). 메일 id 마다 autoDispose 프로바이더(라우트 `ValueKey(id)`) · 받기 세대 · 본문 세대 · 세션 세대로 늦은 응답을 버린다. 목록에는 `MailChange(updated, removedIds)` 로 알린다. 쓰기 화면을 여는 값은 웹과 같은 쿼리(`replyTo` · `all` · `forward` · `draft` — `composeIntentOf`), mailto 로 채울 값은 go_router `extra` 의 `ComposePrefill` |
| **메일 읽기 (`App1Reader`)** | `lib/features/mail/` — domain(`mail_html`(두 번째 정화 · 중첩 비용 · CSP 문서 · 중계 그림 바꿔 넣기) · `mail_images` · `mail_links` · `compose_request` · `mail_body_notices`) · `data/`(`models/message_detail.dart` · `mail_link_launcher.dart`) · presentation(`pages/message_reader_page.dart` · `providers/message_reader_controller.dart` · `attachment_downloads.dart` · widgets `mail_body_view` · `reader_*`), 시험 도구 `test/support/fake_webview.dart`(`WebViewPlatform.instance`) · `file_fakes.dart` | **WebView 에는 `buildMailDocument` 의 문서만 넣는다**(CSP 를 맨 앞 · JS 끔 · 처음 문서 말고는 탐색을 막음 · 안드로이드는 파일 · content 접근 · 위치 · 섞인 내용 끔, 아래 틀(iframe)의 탐색은 판정을 거치지 않으니 막는 것은 정화(내용째 버림)와 CSP 다). **보이는 것이 없는 본문이면 본문 칸(WebView)을 두지 않는다**(2026-11-12 — 첨부만 있는 메일에서 아래 절반이 빈 칸으로 남고 첨부가 위 절반에서 잘렸다): 판정은 정화 길이 함께 낸다(`SanitizedMailBody.hasVisibleContent` — 남긴 글 가운데 `core/utils/visible_text.dart` 의 `hasVisibleText`(공백 · 제어 문자 · Default_Ignorable(제로폭 · 방향 표시 · 한글 채움 등) · 점자 빈칸은 보이지 않음)인 글, 주소가 남은 그림(`_attributesOf` 가 src 를 맨 앞에 쓰는 모양에 기댄다 — 외부 그림은 받기 전에도), 글 없이 그려지는 `hr` · `li` · `details` · `q`. 꾸밈(style · bgcolor · border)만은 치지 않는다. 고르는 본문(`displayedHtmlBody`)은 그대로) → 상태 `bodyEmpty` → 머리의 최대 높이가 화면 전체(`kReaderHeadMaxFraction` 대신 — 그림 받는 진행 막대 자리만 뺀다 · 0 아래로 내려가지 않게), 아래 막대가 없는 메일(예약)은 머리 스크롤에 시스템 아래 여백을 더한다. 문서를 만들기 전(`document == null`)은 지금처럼 본문 자리의 진행 줄. 받은 HTML 은 서버가 정화했어도 `sanitizeMailHtml` 을 한 번 더 지난다 — package:html 은 `parse().body`(`parseFragment` 는 맨 위 형제가 많으면 제곱), 그 전에 `htmlNestingCost`(선형 · 파서보다 깊게 센다)로 한도를 보고, 바로 할지 isolate 로 보낼지는 `mailBodyBuildPlaceOf` 하나로 고른다. 중계 그림은 Dio(Bearer)로 받아 `data:` 로 넣고 토큰은 WebView 에 가지 않는다 — 문서에 싣는 양은 **나온 횟수까지** 센다(`resolveProxiedImages`). 응답은 그 요청이 바꾼 칸만 지금 요약에 덮는다(`_applyResponse`). 메일 id 마다 autoDispose 프로바이더(라우트 `ValueKey(id)`) · 받기 세대 · 본문 세대 · 세션 세대로 늦은 응답을 버린다. 목록에는 `MailChange(updated, removedIds)` 로 알린다. 쓰기 화면을 여는 값은 웹과 같은 쿼리(`replyTo` · `all` · `forward` · `draft` — `composeIntentOf`), mailto 로 채울 값은 go_router `extra` 의 `ComposePrefill` |
| 받은 파일 · 고른 파일 · 형식 | `lib/core/files/` — `downloaded_files.dart`(`DownloadedFileStore` · `CacheDirFileStore` · `FileOpener`) · `picked_files.dart`(`PickedFileStore` · `PlatformPickedFileStore` — 채널의 `pick`) · `file_names.dart`(`safeFileName` · `displayFileName`) · `file_types.dart`(`fileOpenTypeOf` · `isBlockedFileOpen`), 안드로이드 `MainActivity.kt`(채널 `kr.it.arasystem.app/files`) · `res/xml/downloaded_file_paths.xml` · 매니페스트의 FileProvider `${applicationId}.downloads` | 모듈 공통(메일 첨부 · 나중의 파일 모듈). 자리는 `cacheDir/downloads/<무작위 16자>/<정리한 이름>` 뿐 — **짝 셋**: Dart `kDownloadedFilesFolder` · `downloaded_file_paths.xml` 의 cache-path · `MainActivity.kt` 의 `DOWNLOADS_DIR`(채널 이름도 짝). Kotlin 은 canonical 경로로 그 폴더 안인지 다시 보고 읽기 권한을 한 번만 준다. 파일 여는 플러그인(open_filex 등)은 쓰지 않는다 — 저장소 권한 · 기기 전체 경로를 더한다. 열 형식은 확장자 먼저(`package:mime`), 보낸 쪽 형식은 확장자를 모를 때만 — 형식 글 모양 검사는 짝: Kotlin `MIME_PATTERN` · `GENERIC_TYPE`. 막는 형식(웹 문서 · svg · 설치 파일 · 인증서)은 받지도 않는다. **고른 파일**(메일 쓰기 2차): 고르는 창(`ACTION_GET_CONTENT` — 권한 없음 · `*/*` · 여러 개)으로 고른 파일을 `cacheDir/uploads/<무작위 16자>/<정리한 이름>` 에 복사본으로 둔다 — **짝 둘**: Dart `kPickedFilesFolder` · `MainActivity.kt` 의 `UPLOADS_DIR`(다른 앱에 건네지 않아 FileProvider xml 에는 없다). 채널 인자 · 답의 칸 · 값도 짝이다 — Dart `PlatformPickedFileStore.pick` · `PickResult.parse` · `PickedFile.fromChannel` ↔ Kotlin `ARG_*` · `KEY_*` · `OUTCOME_*` · `STATUS_*`. Kotlin 은 `content://` 만 읽고, Dart 사전 검사와 같은 차례(개수 → 형식 → 파일당 → 합계 — `copyPicked` ↔ `precheckPickedFile`)로 넣을 파일만 복사한다 — 미리 거르기일 뿐 판정은 Dart 가 다시 한다. 확장자를 떼는 규칙도 짝(Kotlin `displayFileName` · `extensionOf` · `isDartWhitespace` ↔ Dart `displayFileName` · `extensionOf` · `trim()`). 복사본 이름은 Kotlin `safeFileName`(Dart 와 같은 규칙), 보이고 보내는 이름은 `displayFileName` 이다. 복사 스레드는 Throwable 까지 잡아 한 번만 답한다. 고르기와 모두 지우기는 차례로 돌고, 지우기는 기호 링크를 푼 경로로 복사본 폴더 바로 아래 한 칸만 지운다. `pick` 은 그 차례 안에서 `kPickTimeout`(10분) 시한을 둔다 — 넘으면 `timedOut`(Dart 에만 있는 결과)으로 끝내고 차례를 넘긴다(늦게 온 복사본은 다음 정리에). 파일 고르는 플러그인은 쓰지 않는다(권한 · 기기 전체 경로) |
| **메일 쓰기 (`App1Compose` — 1차 · 2차)** | `lib/core/people/`(사람 고르기 — 주소 칩 · 추천 맞추기 · 그룹, 모듈 공통. 표본 표 `test/core/people/recipient_match_samples.dart` 는 웹 · 백엔드와 **세 쪽 짝**) · `features/mail/domain/`(`compose_body` 직렬화 · `compose_fit`(`buildInitialQuote`) · `mail_quote` · `compose_draft_body`(`buildDraftBody` · `draftBodyPlaceOf`) · `compose_reply` · `compose_send` · `compose_attachments`(2차 — 형식 표 · 한도 · 사전 검사) · `compose_signature` · `html_nodes`) · `data/`(`compose_models` · `compose_payload` · `repositories/compose_repository.dart`) · presentation(`pages/message_compose_page.dart` · `providers/compose_controller.dart`(화면마다 하나 — `ComposeArgs.instance`, autoDispose) · `draft_autosave.dart` · `attachment_uploads.dart`(`AttachmentUploadQueue` — 둘씩 · 취소 · 다시 올리기) · `signature_image_provider.dart` · widgets `compose_*`) · `core/utils/utf16_length.dart` · 시험 도구 `test/support/compose_fakes.dart` | 본문 모양은 계약 2절 "앱이 만드는 모양". 웹 짝(`mail-address.ts` · `recipient-match.ts` · `MAIL_HTML_STYLES` · `docToText` · `fit-draft` · `DRAFT_*` · `shouldRenewSendKey`)은 각 파일 머리말에 적었다 — 한쪽을 고치면 둘 다. **package:html 의 `text` · `querySelectorAll` · `innerHtml` · `outerHtml` 은 깊이만큼 되풀이한다** — 쓰기 화면은 `html_nodes.dart`(되풀이 없는 글 모으기 · 찾기 · 직렬화)를 쓰고, 저장본 · 원문은 읽기 화면과 같은 한도 · 문턱(`mailBodyBuildPlaceOf`)으로 그 자리 · isolate · 읽지 않음을 고른다. **화면을 떠나도 끝까지 가야 하는 일(보내기)은 기다림을 시작하기 전에 `ref.keepAlive()` 를 잡고 try/finally 로 놓는다**(2026-11-07 리뷰 — 저장을 기다리는 사이 내려가 메일이 나가지 않았다). 임시 저장은 Timer 만 쓴다(시험은 `tester.pump` 로 시간을 넘긴다). 보내기 결과는 `mailToastProvider` 의 마감 알림(목록 · 읽기 화면이 그린다). 서버와 같은 한도를 거는 칸은 `Utf16LengthLimitFormatter`(UTF-16 단위). **첨부(2차)**: 형식 표 `kMailAttachmentTypes` 는 **짝: 백엔드 `attachment.policy.ts` 의 `ATTACHMENT_TYPES`** — 시험이 그 파일을 읽어 표와 `MAX_ATTACHMENTS_PER_MESSAGE` 를 맞춰 본다 (앱 CI 는 flutter/ 가 바뀐 커밋에서만 돈다 — 백엔드 표만 고친 커밋에서는 이 시험이 돌지 않으니 함께 고친다). 사전 검사는 웹 `precheckAttachment` 의 차례, 보내기 막기는 웹 `sendBlockOf` 의 차례(가져오는 중은 올리는 중 다음). 저장소 `uploadAttachment`(multipart `file` · 확장자의 정식 형식 · 보내기 5분 · 받기 60초 · 알림 끔 — 401 뒤에는 FormData 를 복제해 복사본을 처음부터 다시 읽는다) · `getAttachmentLimits`(`GET /api/mail-quota`). 다 올리면 서버의 이름 · 크기로 싣는다(이름 255자 — 웹과 같다, 2026-11-07 리뷰). `MAIL_004` 뒤 다시 올릴 수 없는 고른 파일은 `problem: missing` 으로 막는다(웹 `markMissing`). 시험 도구 `FakePickedFiles`(`test/support/file_fakes.dart`) · `FakeComposeBackend` 의 `uploads` · `uploadGates` · `quota` · `quotaGate` · `FakeServer.readRequestBodies` |
| **메일 쓰기 (`App1Compose` — 1차 · 2차)** | `lib/core/people/`(사람 고르기 — 주소 칩 · 추천 맞추기 · 그룹, 모듈 공통. 표본 표 `test/core/people/recipient_match_samples.dart` 는 웹 · 백엔드와 **세 쪽 짝**) · `features/mail/domain/`(`compose_body` 직렬화 · `compose_fit`(`buildInitialQuote`) · `mail_quote` · `compose_draft_body`(`buildDraftBody` · `draftBodyPlaceOf`) · `compose_reply` · `compose_send` · `compose_attachments`(2차 — 형식 표 · 한도 · 사전 검사) · `compose_signature` · `html_nodes`) · `data/`(`compose_models` · `compose_payload` · `repositories/compose_repository.dart`) · presentation(`pages/message_compose_page.dart` · `providers/compose_controller.dart`(화면마다 하나 — `ComposeArgs.instance`, autoDispose) · `draft_autosave.dart` · `attachment_uploads.dart`(`AttachmentUploadQueue` — 둘씩 · 취소 · 다시 올리기) · `signature_image_provider.dart` · widgets `compose_*`) · `core/utils/utf16_length.dart` · 시험 도구 `test/support/compose_fakes.dart` | 본문 모양은 계약 2절 "앱이 만드는 모양". 웹 짝(`mail-address.ts` · `recipient-match.ts` · `MAIL_HTML_STYLES` · `docToText` · `fit-draft` · `DRAFT_*` · `shouldRenewSendKey`)은 각 파일 머리말에 적었다 — 한쪽을 고치면 둘 다. **package:html 의 `text` · `querySelectorAll` · `innerHtml` · `outerHtml` 은 깊이만큼 되풀이한다** — 쓰기 화면은 `html_nodes.dart`(되풀이 없는 글 모으기 · 찾기 · 직렬화)를 쓰고, 저장본 · 원문은 읽기 화면과 같은 한도 · 문턱(`mailBodyBuildPlaceOf`)으로 그 자리 · isolate · 읽지 않음을 고른다. **화면을 떠나도 끝까지 가야 하는 일(보내기)은 기다림을 시작하기 전에 `ref.keepAlive()` 를 잡고 try/finally 로 놓는다**(2026-11-07 리뷰 — 저장을 기다리는 사이 내려가 메일이 나가지 않았다). 임시 저장은 Timer 만 쓴다(시험은 `tester.pump` 로 시간을 넘긴다). 보내기 결과는 `mailToastProvider` 의 마감 알림(목록 · 읽기 화면이 그린다). 서버와 같은 한도를 거는 칸은 `Utf16LengthLimitFormatter`(UTF-16 단위). **첨부(2차)**: 형식 표 `kMailAttachmentTypes` 는 **짝: 백엔드 `attachment.policy.ts` 의 `ATTACHMENT_TYPES`** — 시험이 그 파일을 읽어 표와 `MAX_ATTACHMENTS_PER_MESSAGE` 를 맞춰 본다 (앱 CI 는 flutter/ 가 바뀐 커밋에서만 돈다 — 백엔드 표만 고친 커밋에서는 이 시험이 돌지 않으니 함께 고친다). 사전 검사는 웹 `precheckAttachment` 의 차례, 보내기 막기는 웹 `sendBlockOf` 의 차례(가져오는 중은 올리는 중 다음). 저장소 `uploadAttachment`(multipart `file` · 확장자의 정식 형식 · 보내기 5분 · 받기 60초 · 알림 끔 — 401 뒤에는 FormData 를 복제해 복사본을 처음부터 다시 읽는다) · `getAttachmentLimits`(`GET /api/mail-quota`). 다 올리면 서버의 이름 · 크기로 싣는다(이름 255자 — 웹과 같다, 2026-11-07 리뷰). `MAIL_004` 뒤 다시 올릴 수 없는 고른 파일은 `problem: missing` 으로 막는다(웹 `markMissing`). 시험 도구 `FakePickedFiles`(`test/support/file_fakes.dart`) · `FakeComposeBackend` 의 `uploads` · `uploadGates` · `quota` · `quotaGate` · `FakeServer.readRequestBodies`. **칸 줄**은 `ComposeFieldRow`(`widgets/compose_recipient_field.dart`) 하나 — 칸 이름 72 · 글자 15 · 간격 6 · 여백 4 4 4 16, 칸 이름 · 오른쪽 단추는 첫 줄(`ComposeFieldRow.lineHeight` 48)의 가운데(받는 사람은 `firstLineHeight` 로 칩 줄 40 을 넘겨 칩이 여러 줄이어도 첫 칩 줄에 맞춘다), 아래 선은 `foregroundDecoration` 이라 초점으로 굵어져도 칸이 움직이지 않는다. 입력칸은 `borderlessInputDecoration`(2026-11-12 — 아래 함정). 본문 칸 최소 높이는 `composeBodyMinHeightOf`(원본 메일이 있으면 150 · 없으면 240 — 서명과 상관없다, 보드 App1Compose) |
| **메일 검색 (`App1Search` — 1차 · `App1SearchConditions` — 2차)** | domain `search_text`(**짝: 웹 `search-match.ts` · `mail-search.ts` · 백엔드 `search-text.ts`** — 표본 표는 시험이 `backend/test/search-text-samples.ts` 의 FOLD · GRAM 을 직접 읽는다 · `test/support/ts_table.dart`) · `search_highlight`(짝: 웹 `search-highlight.ts`) · `search_conditions`(2차 — **짝: 웹 `mail-search.ts` · `mail-vacation.ts`** — 조건 다듬기 · 합친 낱말 수 · 기간 · after/before(기기 시간대 0시 → UTC, 끝 날짜는 다음 날 0시) · 강조 쿼리 · "나") · `data/models/mail_search.dart` · `mail_repository`(`searchMessages` — 응답의 `Date` 로 서버 시각 · `searchIndexStatus` · `getMessage(highlight)`) · presentation(`pages/mail_search_page` · `providers/mail_search_controller`(화면마다 — `MailSearchArgs.instance`, autoDispose) · `mail_search_state`(`waitsForMore`) · `mail_search_view` · widgets `mail_search_bar`(+ 공용 `LineBreaksToSpacesFormatter`) · `mail_search_states` · `highlighted_text` · `mail_search_conditions_sheet`(2차)) · `core/utils/js_case.dart` | 탭 안 `/mail/search`(시작한 메일함은 `extra` 의 `MailSearchStart`, 검색어는 주소에 싣지 않는다). 이어 찾기는 한 차례 `kMailSearchFillMaxRequests`(5) · 20통, 모자라면 [더 찾기]로 멈춘다. 검색 세대 + `CancelToken`(새 검색 · 화면 닫기에 끊는다), 준비 상태는 따로 세대. 결과는 `MailChange` 와 붙잡힌 줄 다시 받기(보낼 시각 + 2초)만 반영한다. **강조 위치는 그 응답의 글에만**. 읽기 키는 `MessageReaderArgs(id, highlight)`, 결과에서 연 메일은 `MailSearchOpen`. 조건(2차)은 화면 상태 `MailSearchState.conditions` 로 이어 찾기 · 새로고침 · 다시 시도 · 찾을 곳 바꾸기에 이어지고, "나"는 `mailSelfProvider`. 시트 가림막은 테마의 바닥 시트 가림막(`AppColors.backdrop` — 옮기기 시트도), 날짜 창은 `showDatePicker(barrierColor:)` · 머리 22(`datePickerTheme`). 노랑 `AppColors.searchMark` 는 **짝: 웹 `--color-search-mark` · `mail_html.dart` 의 mark**. 시험 도구는 `FakeMailBackend` 의 `defaultSearch`(받은 시각 내림차순 · `searchCursorAfter` 는 그 줄 다음부터) · `move` · `searchResponder` · `searchGate` · `searchHighlights` · `highlightDetails` · `searchIndex` |
| HTTP 클라이언트 | `lib/core/api/dio_client.dart` · `dio_provider.dart` | Dio 둘: 화면용(인터셉터 줄 `ResponseInterceptor` → `AuthInterceptor` → `ErrorNotifyInterceptor`)과 갱신 · 다시 보내기 · 로그인 없는 앱 설정용(언래핑만 — `publicDioProvider`. 인증 인터셉터는 요청을 한 줄로 세워 앞 요청의 미리 갱신을 기다리게 하므로, 앱을 띄우자마자 묻는 앱 설정은 줄 밖으로 보낸다). baseUrl=dotenv `API_URL`(`api_config.dart`), User-Agent `ARASYSTEM-App/<판> (Android <판>; <기종>)`(`user_agent.dart`). `LogInterceptor` 는 넣지 않는다(비밀번호 · 토큰). 시험은 `httpClientAdapterProvider` 로 가짜 서버를 끼운다 |
| 언래핑 · 코드 정규화 | `lib/core/api/response_interceptor.dart` · `api_error_mapper.dart` | `{success,data}→data`(스트림 · 바이트는 그대로 — 그 **실패** 본문은 64KB 까지 읽어 `error.code` 를 꺼낸다(`decodeRawErrorBody`). 스트림을 받는 도중의 실패는 인터셉터를 거치지 않아 부르는 쪽이 `SYS_001` 로 알린다(`DownloadInterrupted`). 비동기 처리기는 무슨 일이 있어도 `handler.next` 로 끝낸다 — 던지면 요청이 매달린다), 200 + `success:false` 도 실패, 실패는 모두 `DioException.error` 에 `ApiException`. 상태 → 코드 폴백(웹 `resolveCodeByStatus` 와 같다) |
@@ -36,11 +36,13 @@ description: Flutter 모바일 앱(flutter/) 개발 규칙 — Dart Sound Null S
| **인증 인터셉터** | `lib/core/api/auth_interceptor.dart` · `token_refresher.dart` | `QueuedInterceptor`. Bearer 붙이기 · 액세스 토큰이 없거나 30초 안에 끝나면 미리 갱신 · 401 이면 갱신 한 번 → 원래 요청 한 번 다시(로그인 · 갱신 · 로그아웃 제외 — `ApiPaths.noRefresh`). 갱신은 `TokenRefresher` 가 **한 번에 하나**(동시 401 은 토큰 세대를 보고 묶는다). 갱신 401 · 유예 응답(`tokens: null`) → 토큰 지움 + `AUTH_002` + 세션 끝, 5xx · 연결 끊김은 세션을 두고 그 코드로 알림, 갱신 중 로그아웃이면 받은 토큰을 버리고 서버 세션도 끝낸다. 갱신의 일시 장애는 같은 토큰 세대에서 5초 동안 다시 쓴다(`kRefreshFailureReuse` — 연결이 끊긴 동안 줄 선 요청마다 갱신하지 않게). `AUTH_010` 은 세션 노티파이어에 다시 맞춰 달라고 하고(`requestRecheck` — 안전 한도 65초) renewed 면 한 번 다시 보낸다 · restricted · stale 은 알리지 않는다 · unknown 은 알린다(웹 `recheckRestriction` 과 같은 결). 같은 토큰으로 줄 선 `AUTH_010` 은 한 번만 묻는다(그사이 새 토큰이면 묻지 않고 다시 보내고, restricted 는 그 세대 동안 · unknown 은 5초 다시 쓴다). **다시 보낸 요청의 실패도 첫 요청과 같은 규칙**(401 · `AUTH_010` 마다 한 번씩 — `kAuthRetriedExtraKey` · `kRestrictionRetriedExtraKey`). **요청을 보낸 세션이 끝났으면**(세션 세대 `kSessionGenerationExtraKey` 가 다르다 — 로그아웃 · 세션 끝 · 그 뒤 다른 직원 로그인) 어떤 실패든 조용히 끝낸다 — 401 은 사용자가 로그아웃했으면 `AUTH_001` 그대로, 세션이 끝났으면 `AUTH_002`. 실패를 내보내기 전에 Bearer · 비밀 본문을 지운다(`withoutSecrets`). 처리기는 뜻밖의 예외에도 꼭 부른다(`SYS_001`). 이 인터셉터의 오류 처리 안에서 보내는 요청(갱신 · 세션 확인 · 다시 보내기)은 모두 안쪽 Dio — 같은 줄로 보내면 서로 기다린다 |
| 오류 알림 | `lib/core/api/error_notifier.dart` | 전역 `ScaffoldMessenger` 키 하나로 스낵바(웹 `showErrorUI` 자리). 같은 코드는 3초 안에 다시 띄우지 않는다. 요청마다 끄기: `Options(extra: {kSilentExtraKey: true})` 또는 코드 목록(`ApiOptions.silentFor`) — `request_flags.dart`. **비밀이 든 요청**(비밀번호 · 코드 · 리프레시 토큰이 본문에)은 `ApiOptions.secret(...)` 으로 보낸다 — 실패 예외의 본문을 비워 던져 화면이 `AsyncValue` 로 상태에 두어도 된다 |
| 토큰 보관 | `lib/core/storage/` — `token_store.dart` · `secure_store.dart` · `first_run_marker.dart` | 리프레시 토큰만 `flutter_secure_storage`(키 상수 `kRefreshTokenKey`), 액세스 토큰은 메모리. 새 토큰은 받자마자 저장(로그인 저장이 실패하면 그 세션을 끝내고 `SYS_001`, 갱신 저장이 실패하면 이번 실행은 메모리의 새 토큰으로 이어 가고 다음 읽기 · 저장 때 다시 쓴다). 보안 저장소 일은 부른 차례대로 하나씩(로그인 저장과 로그아웃 지우기가 엇갈리지 않게). 토큰 세대 `generation` · 세션 세대 `sessionGeneration`(지울 때만 오른다), 로그아웃은 `signOut()`(메모리를 곧바로 비우고 서버 logout 에 쓸 토큰을 돌려준다). 만료는 기기 시계로 보지 않는다 — 응답의 `Date`(서버 시각, `core/api/server_time.dart`)로 남은 시간을 재고 받은 때부터 센다. 첫 실행 표시(`shared_preferences` — 참 · 거짓 하나)가 없으면 보안 저장소를 모두 지운다(iOS Keychain 은 앱을 지워도 남는다). Android 는 백업을 껐다(`AndroidManifest` · `res/xml/data_extraction_rules.xml`) |
| 테마 | `lib/core/theme/` | 색은 웹 `tokens.css` 를 `ColorScheme` 에 손으로 대응(`app_colors.dart` · `app_theme.dart` — 시드 생성 쓰지 않음, Material 에 없는 색은 `AppExtraColors`). 간격 · 모서리 `AppSpacing` · `AppRadius`, 누르는 곳 최소 48dp(`AppSize.touchMin`). 글자는 `app_typography.dart` 한 곳 — 화면 글 15 · 입력 칸 16 · 위 막대 제목 18 · 메일 본문 15/1.75 는 확정(`docs/design/app/README.md`), 그 밖의 역할은 임시 값 |
| 테마 | `lib/core/theme/` | 색은 웹 `tokens.css` 를 `ColorScheme` 에 손으로 대응(`app_colors.dart` · `app_theme.dart` — 시드 생성 쓰지 않음, Material 에 없는 색은 `AppExtraColors`). 간격 · 모서리 `AppSpacing` · `AppRadius`, 누르는 곳 최소 48dp(`AppSize.touchMin`). 글자는 `app_typography.dart` 한 곳 — 화면 글 15 · 입력 칸 16 · 위 막대 제목 18 · 메일 본문 15/1.75 는 확정(`docs/design/app/README.md`), 그 밖의 역할은 임시 값. 테두리 없는 입력칸(칸 줄이 아래 선 · 초점을 그리는 쓰기 화면 · 검색 막대)은 `borderlessInputDecoration` — 테마의 입력칸 테두리를 물려받지 않는다 |
| 글꼴 | `assets/fonts/` · `assets/licenses/pretendard-ofl.txt` · `core/theme/app_fonts.dart` | Pretendard 1.3.9 정적 OTF 넷(400 · 500 · 600 · 700 — 웹 npm `pretendard` 와 같은 파일). OFL 1.1 글을 `LicenseRegistry` 에 올린다(설정 › 앱 정보 › 오픈소스 라이선스) |
| **새 메일 알림 (`App1Push`)** | `lib/core/push/` — 포트 `push_messaging.dart`(FCM 은 `firebase_push_messaging.dart` — 띄우지 못하면 `UnavailablePushMessaging`) · `notification_system.dart`(채널 · 알림 지우기 · 휴대폰 설정 열기 — 안드로이드 `NewMailNotifications.kt`, 채널 `kr.it.arasystem.app/notifications`) · `push_preferences.dart`(이 휴대폰의 값 — `shared_preferences`, 참 · 거짓과 FID 하나뿐) · `push_state.dart`(상태 · 설정 줄 모습 `pushRowStatusOf` · 권하기 줄 `showsPushOffer`) · `push_controller.dart`(앱에 하나 — `PushRegistrar` 를 구현) · `push_registration.dart`(서버 저장소 · `pushRegistrarProvider`) · `push_events.dart`(앞에서 받음 · 누름 — 라우터 `core/router/push_navigation.dart` 가 누른 알림을 늘 메일 받은편지함으로) · `push_target.dart` · 글 `push_texts.dart`. 화면 `features/mail/presentation/widgets/push_offer_line.dart` · `features/settings/presentation/widgets/settings_notification_row.dart` | **보낼 조건** = 받을 수 있는 휴대폰 ∧ 서버가 보낼 수 있음(app-config `pushAvailable` — 모르면 보낼 수 있는 것으로) ∧ 메일을 쓸 수 있는 세션(세션 노티파이어의 `register` · 제한 단계는 `pause`) ∧ 이 휴대폰에서 켬 ∧ 권한 ∧ 새 메일 채널. 맞추기 `_reconcile` 하나가 PUT · DELETE 를 고른다. **PUT** 은 세션이 열릴 때 한 번 · FID 가 바뀔 때 · 켤 때만(앞으로 올 때마다가 아니다), **DELETE** 는 끌 때와 권한 · 채널이 꺼진 것을 알았을 때만 — 로그아웃 · 세션 끝은 FCM `unregister()` 만(서버 줄은 서버가 세션과 함께 지운다, 풀지 못하면 `pendingUnregister` 로 다음 실행 · 앞으로 옴에 다시). **봤음** `DELETE …/unseen` 은 앞으로 올 때 · 앞에서 받은 새 메일 알림 때, 30초 간격 + 끝 한 번 — 앱이 앞에 있을 때만 보내고, 기다리는 동안 뒤로 가면 그 자리에서 보낸다(끝까지 기다리면 그사이 온 메일까지 본 것으로 센다). 앞에서 받은 알림 뒤의 봤음은 받은 때 + 1.5초(`kPushSeenAfterMessageDelay`)보다 일찍 보내지 않는다 — 서버는 seen_at 의 초부터 같은 초를 세고 받은 시각은 초 단위라, 곧바로 보내면 열지 않은 그 메일이 다음 알림에 다시 세어진다(2026-11-08 실기기). 켜기 · 끄기는 서버가 답한 뒤에 바꾼다(실패는 그 코드의 사전 문구 — 등록 소식이 없으면 `SYS_001`). **켜기가 실패하면 이 휴대폰의 값을 끔으로 둔다**(처음 값 켬에서 허락하고 켰어도 — 스위치는 꺼진 채, 저절로 다시 하지 않는다). 서버 일은 멈춘(`pause`) 바로 그 차례에도 보내지 않는다(`_canReachServer` 가 컨트롤러의 세션 값도 본다). FCM `register` · `unregister` 는 우리가 한 줄로 세운다(플러그인이 스레드 풀에서 돈다), `onRegistered` 는 방송이라 `register` 전에 듣는다. **풀기는 기다리기 전에 등록 세대를 올리고 등록을 내려 둔다** — 풀기가 실패하거나 오래 걸려도 같은 실행의 다음 로그인이 그 뒤에 다시 등록하고(같은 FID 의 PUT 이 서버의 앞 직원 줄을 지운다), 풀기 앞의 등록은 끝나도 마친 것으로 치지 않으며 "풀지 못한 등록" 표시도 지우지 않는다. 풀기가 줄에 있는 동안 온 등록 소식은 쓰지 않는다. 앱이 앞에 있으면 FCM 이 알림을 그리지 않는다 — 앞에서 받은 새 메일 알림은 메일 목록이 바로 새 메일을 확인한다(`mail_state_watcher`). 알림 데이터는 **모듈 이름과 종류**(`{ module: 'mail', kind: 'new-mail' }` · `session-ended`). FID · 주소 · 토큰을 로그 · 디버그 출력에 남기지 않는다. 시험 도구 `test/support/push_fakes.dart`(가짜 FCM · 알림창 · 값 · 서버) · `push_screen_harness.dart`(앱 전체 + 진짜 컨트롤러 — `ProviderHarness.recordPush = false`). 다른 시험대는 기기 등록을 기록하는 가짜(`RecordingPushRegistrar`)이고 컨트롤러는 FCM 이 없어 조용하다 |
| **앱 설정 · 업데이트 화면** | `lib/core/app_config/`(`app_config.dart` — 판 비교 `AppVersion` · 모양, `app_config_repository.dart`, `app_config_controller.dart` — `appUpdateRequiredProvider` · `pushAvailableProvider`) · `core/router/session_redirect.dart`(`updateRequired` 가 세션보다 먼저) · `features/app_update/`(모듈이 아니다 — 화면 · 글) · `core/app_download/`([새 버전 받기] — 받기 화면 주소 `app_download_link.dart` · 런처 `AppDownloadLauncher` · 글, 업데이트 화면과 설정 › 앱 정보가 함께 쓴다) | `GET /api/app-config` 는 띄울 때 한 번 · 앞으로 올 때 마지막으로 받은 지 1시간이 지났으면(실패는 1분 간격으로 다시) — 알리지 않고 막지 않는다. 404 `RES_001` 은 옛 서버(최소 판 없음 · 알림 쓸 수 없음). 판은 숫자 1~3칸 · 칸마다 4자리까지(서버의 판 칸 `APP_VERSION_PATTERN` 과 같다 — `-` · `+` 뒤는 버린다), 이 앱의 판을 모르면 막지 않는다. 업데이트 화면으로 옮길 때 쓰기 화면은 마저 저장한다(`ComposeController.detach(saveRemaining: true)` — 화면이 밖에서 닫힌 때도). 앱은 스토어에 올리지 않고 사내에 직접 나눠 준다(2026-11-08 — 빌드 · 서명 · 올리기는 `flutter/README.md` "사내 배포"). [새 버전 받기]는 포털의 휴대폰 앱 받기 화면 `<API_URL 의 출처>/app`(`appDownloadPageUrlOf` — `/api` 를 뗀 출처 + 경로, 짝: 웹 `mobile-app.ts` 의 `MOBILE_APP_PATH`)을 바깥 브라우저로(`LaunchMode.externalApplication` — `canLaunchUrl` 은 쓰지 않는다) 연다. 열지 못하면 스킴 없는 주소를 담아 알린다(`openAppDownloadPage` — 두 화면이 같은 알림). 앱 설정 계약은 받는 곳 주소를 싣지 않는다 |
| **설정 탭 (`App1Shell` · `App1Push` ④)** | `lib/features/settings/` — `settings_routes.dart`(`/settings` · `/settings/licenses` · 모듈이 내놓은 절) · `presentation/pages/settings_page.dart` · `widgets/settings_notification_row.dart` · `settings_texts.dart` | 한 화면(내 정보 · 알림 · 모듈 절 · 앱 정보(앱 버전 · [새 버전 받기] — 끝 아이콘이 바깥으로 여는 아이콘, 업데이트 화면과 같은 런처 · 오픈소스 라이선스) · 문의 · [로그아웃]). 문의는 로그인 화면과 같은 저장소(`supportContactProvider`), [로그아웃]은 확인 창(`shared/widgets/confirm_dialog.dart` — 처음 초점 [취소]). 스위치 색은 테마의 `switchTheme`(보드 — 켬 청록 · 끔 테두리 · 누를 수 없음 회색) |
| **설정 탭 (`App1Shell` · `App1Push` ④)** | `lib/features/settings/` — `settings_routes.dart`(`/settings` · `/settings/licenses` · `/settings/release-notes`(+ `/:noteId`) · 모듈이 내놓은 절) · `presentation/pages/settings_page.dart` · `widgets/settings_notification_row.dart` · `settings_texts.dart` | 한 화면(내 정보 · 알림 · 모듈 절 · 앱 정보(앱 버전 · 업데이트 내역(목록 → 글, 아래 행) · [새 버전 받기] — 끝 아이콘이 바깥으로 여는 아이콘, 업데이트 화면과 같은 런처 · 오픈소스 라이선스) · 문의 · [로그아웃]). 문의는 로그인 화면과 같은 저장소(`supportContactProvider`), [로그아웃]은 확인 창(`shared/widgets/confirm_dialog.dart` — 처음 초점 [취소]). 스위치 색은 테마의 `switchTheme`(보드 — 켬 청록 · 끔 테두리 · 누를 수 없음 회색) |
| **업데이트 내역 (`ReleaseNotes` B — 2026-11-10)** | `lib/features/settings/` — data(`models/release_note.dart` · `repositories/release_notes_repository.dart`) · presentation(`pages/release_notes_page.dart` · `release_note_page.dart` · providers `release_notes_providers.dart` · `release_notes_list_controller.dart` · `release_note_controller.dart` · widgets `release_notes_parts.dart` · 글 `release_notes_texts.dart`), 달력 날짜 `core/utils/calendar_date.dart`(`CalendarDate`) + `korean_format.dart` 의 `koreanCalendarDate`, 시험 도구 `test/support/release_notes_fakes.dart`(`FakeReleaseNotesBackend` · 보드의 네 글 `kBoardReleaseNotes`) | 계약 7절(`GET /api/release-notes?cursor=&limit=` · `GET /api/release-notes/{id}` — 올리는 API 없음, 글은 서버의 글 파일). 설정 › 앱 정보의 앱 버전 다음 줄 → 목록 `/settings/release-notes` → 글 `/settings/release-notes/:noteId`(둘 다 `parentNavigatorKey: rootNavigatorKey`, 글은 목록 라우트의 자식이라 주소로 바로 열어도 아래에 목록). 목록은 `releaseNotesListProvider`(autoDispose — 화면을 열 때마다 새로 받는다 · 첫 쪽 · [다시 시도]는 `silent`, [더 보기]는 알림 + 받은 줄 그대로 · 겹친 id 는 건너뜀), 글은 `releaseNoteProvider(id)`(autoDispose family · `silent` — 없는 글 404 `RES_001` 은 [목록으로], 그 밖은 [다시 시도]). 둘 다 `releaseNotesAccountProvider`(세션의 주소 — 메일의 `mailAccountProvider` 와 같은 규칙, 모듈끼리 import 하지 않아 따로)를 듣고 세션이 바뀌면 처음부터 · 세대(`_seq`)와 시작할 때의 Ref 로 늦은 응답을 버린다. 응답 모양이 틀리면 `ReleaseNotesMalformedResponse`(`SYS_001` — 인터셉터를 다 지난 뒤라 [더 보기]만 컨트롤러가 `errorNotifierProvider` 로 알린다, 메일 `DownloadInterrupted` 와 같은 결). **날짜는 `CalendarDate` 로만**(`YYYY-MM-DD` 를 글로 나눠 쥔다 — `DateTime` 으로 바꾸지 않는다, 없는 날은 모양 틀림). 다른 글 줄은 `context.pushReplacement`(쌓지 않는다 — 뒤로는 늘 목록, go_router 17 은 void 를 돌려준다). 글은 서버 글 그대로(마크다운 해석 · 링크 없음) · 한글 줄바꿈 `KeepAllText`(시험은 `findCopy`). [더 보기]는 받는 동안 누름만 무시한다(초점 지키기 — 메일 검색 [더 찾기]와 같다) — 도는 표시가 있어 시험은 `pumpAndSettle` 대신 `pump(100ms)` |
| 불러오기 상태 틀 | `lib/shared/widgets/load_states.dart` · `load_state_texts.dart` | 가운데 상태 `StateLayout`(아이콘 원 72 · 제목 · 글 · 단추) · 받지 못함 `LoadErrorView`(사전 문구 · 문의 코드 · [다시 시도] — `RES_001` + `onLeave` 면 `leaveLabel` 단추, 둘은 함께 준다) · 뼈대 `LoadingSkeleton`(화면 낭독기 한 줄 + 막대는 읽지 않음) · `SkeletonBar`(메일은 모서리 4, 업데이트 내역은 `rounded`) · `traceCodeOf`. 2026-11-10 메일 목록(`mail_list_states.dart` 의 `MailStateLayout` · `MailListError` · `_SkeletonBar`)에서 모양 · 동작 그대로 옮겼다 — 메일 목록 · 검색 · 읽기 · 쓰기와 업데이트 내역이 쓴다. 메일 줄 꼴의 뼈대(`MailListSkeleton`) · 빈 메일함 · 목록 끝 · 30일 줄은 메일에 남았다. `MailListTexts.retry` · `traceCode` 는 `LoadStateTexts` 를 가리킨다 |
| 자리 화면 | `lib/shared/widgets/placeholder_page.dart` | 화면은 캔버스로 확인받은 뒤 만든다 — 그때까지 이름(과 갈 곳)만 보이는 자리 화면. 확정되면 라우트 묶음의 builder 를 바꾼다 |
| 예제 feature(참조용) | `lib/features/user/` | 목록 페이지네이션(`{items, pageInfo}` — 단건 + 커서 목록) 예제로만 남겼다. 앱 API 에 `/users` 는 없다 |
| 시험 도구 | `test/support/` — `fakes.dart` · `provider_harness.dart` | 가짜 서버(`FakeServer` — HttpClientAdapter) · 메모리 보안 저장소 · 손 시계(`ManualClock`) · 알림 기록(`RecordingNotifier`). 고정 대기 없이 Completer 로 차례를 맞춘다 ([[testing]]) |
@@ -103,7 +105,7 @@ lib/
│ ├── settings/ ← 모듈 — 주소 /settings/... (다른 모듈의 설정 절을 받아 붙인다)
│ └── user/ ← 예제(참조용)
└── shared/
└── widgets/ ← 앱 공통 위젯 (자리 화면 등)
└── widgets/ ← 앱 공통 위젯 (자리 화면 · 확인 창 · 불러오기 상태 틀 등)
```
---
@@ -310,6 +312,11 @@ await tokenStore.clear(byUser: true); // 로그아웃 (세션 끝은 byUser
가짜 서버는 요청 본문을 읽지 않아 보내는 진행(onSendProgress)이 오지 않는다 — 진행을 보려면 `FakeServer.readRequestBodies` 를 켠다.
InkWell 에 길게 누르기 처리가 없으면 길게 눌렀다 뗄 때 onTap 이 돈다 — 열지 않을 줄은 길게 누르기를 받아 두고 아무것도 하지 않는다(검색 결과 줄).
두 줄 TextField 에도 Scrollable 이 있다 — 목록 스크롤은 ListView 아래에서 찾는다(`find.descendant`).
go_router 17 의 `context.pushReplacement` · `replace` 는 void 다(`push` 만 Future) — 바꿔 끼울 화면은 `pushReplacement`(새 page key — 앞 화면의 PageStorage 스크롤 자리를 물려받지 않는다, 업데이트 내역의 다른 글 줄).
짜임을 둘로 가르는 화면(읽기 화면의 본문 칸 있음 · 없음)은 두 짜임에 같은 자리의 같은 위젯을 두고 값(`ConstrainedBox` 의 maxHeight)만 바꾼다 — `Flexible` · `Expanded` 로 바꿔 끼우면 그 아래 State(펼친 받는 사람 등)가 새로 만들어진다. 시험은 위치로 본다(머리 스크롤 칸의 아래 끝 = 아래 막대의 위 끝).
`InputDecoration.collapsed` 는 테두리 없는 칸이 아니다 — `border` 만 없애고 `enabledBorder` · `focusedBorder` 등을 비워 두면 TextField 가 그 칸을 테마의 `inputDecorationTheme`(둥근 테두리 · 초점은 청록 2)로 채우고, InputDecorator 는 `border` 보다 그 칸들을 먼저 쓴다(2026-11-12 — 쓰기 화면의 칸마다 글자를 감싼 테두리). 칸 줄이 선을 따로 그리는 칸은 `borderlessInputDecoration`(`core/theme/app_theme.dart` — 여섯 테두리 모두 없음 · 여백 0)을 쓴다. `lib/` 에 `InputDecoration.collapsed(` 가 들어오면 시험(`test/core/theme/borderless_input_decoration_test.dart`)이 실패한다. 칸의 테두리는 `test/support/input_borders.dart` 의 `expectBorderlessInput`(평소 · 초점 · 꺼짐마다 InputDecorator 가 받은 꾸밈)으로 본다.
화면을 그림으로 견줄 때(임시 시험 — 저장소에 남기지 않는다): `setUpAll` 에서 `FontLoader('Pretendard')` 에 `assets/fonts/Pretendard-*.otf` 넷을, `FontLoader('MaterialIcons')` 에 SDK 의 `bin/cache/artifacts/material_fonts/materialicons-regular.otf` 를 올리고(그러지 않으면 글자가 네모), 맨 위 repaint boundary(RenderView)의 `OffsetLayer.toImage(paintBounds)` 를 `tester.runAsync` 안에서 PNG 로 쓴다. 화면 크기는 `MailAppHarness.pump(size:)`.
화면 아래 끝까지 닿는 스크롤(읽기 화면의 빈 본문 + 아래 막대 없는 예약 메일 등)은 `MediaQuery.paddingOf(context).bottom` 을 스크롤 끝 여백으로 더한다 — Scaffold 는 bottomNavigationBar 가 있을 때만 몸통의 아래 padding 을 0 으로 만든다. 시험은 `tester.view.padding = FakeViewPadding(bottom: 48 * 3)`(물리 픽셀 — 시험대는 3배) + `addTearDown(tester.view.resetPadding)`.
- [ ] **린트로 강제되는 항목**: `dynamic` 호출·`print`·`const` 누락·await 누락·작은따옴표·import 정렬 (`analysis_options.yaml`).
- [ ] **env**: `flutter/.env.example` → `.env` 복사 후 `API_URL` 설정 (Android 에뮬레이터는 `10.0.2.2`).
`.env` 는 `pubspec.yaml` 의 asset 이라 **없으면 `flutter analyze` 가 경고**한다.
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2
View File
@@ -43,6 +43,8 @@ COPY package*.json ./
RUN npm ci --omit=dev && npm cache clean --force
COPY --from=builder /app/dist ./dist
# 업데이트 내역 글(글 파일 하나가 글 하나 — 계약 7절) — 서버가 기동 · 요청 때 /app/release-notes 를 읽기만 한다(modules/release-notes — 쓰지 않는다)
COPY --from=builder /app/release-notes ./release-notes
# 업로드 저장 위치(compose 의 UPLOAD_DIR=/app/uploads)와 대용량 첨부 파일 위치(FILE_STORAGE_DIR=/app/files — 11단계) — node 소유로 만들어 둔다.
# 운영 compose 는 여기에 named volume 을 붙이는데, 새 볼륨은 이 디렉터리의 소유자를 물려받아 node 가 쓸 수 있다.
+51
View File
@@ -0,0 +1,51 @@
# 휴대폰 앱이 나왔고, 메일 검색과 여러 설정이 좋아졌습니다
- 11월 5일 새 웹메일로 옮긴 뒤 새로 생기거나 바뀐 것을 모았습니다. 앞으로 바뀌는 것도 이곳에서 알려 드리겠습니다.
## 휴대폰 앱
- 안드로이드 휴대폰에서 쓰는 ARA SYSTEM 앱이 나왔습니다. 메일을 읽고 쓰고 찾을 수 있고, 답장과 전달, 첨부 파일 보내기도 됩니다.
- 앱 받기: PC 웹메일에서 [설정] → [휴대폰 앱]을 열고 휴대폰 카메라로 QR 코드를 찍어 주세요.
휴대폰 브라우저에서 웹메일에 로그인한 뒤 [앱 내려받기]를 누르고, 설치를 묻는 안내가 나오면 허용해 주세요.
- 앱에는 웹메일과 같은 메일 주소와 비밀번호로 로그인합니다. 2단계 인증을 켜 두셨다면 인증 앱의 코드도 넣어 주세요.
- 새 메일이 오면 휴대폰 알림으로 알려 드립니다. 앱의 [설정] → [새 메일 알림]에서 켜고 끌 수 있고,
알림에는 새 메일이 몇 통인지만 나오며 보낸 사람이나 제목은 나오지 않습니다.
- 아이폰용 앱은 아직 없습니다. 아이폰에서는 브라우저로 웹메일을 쓰거나 기본 메일 앱에 회사 메일을 연결해 주세요.
연결 방법은 웹메일의 [설정] → [메일 프로그램 연결]에서 볼 수 있습니다.
## 웹메일과 휴대폰 앱
- 메일 검색이 빨라졌고, 보낸 사람, 받는 사람, 제목, 본문, 기간, 첨부로 나눠 찾을 수도 있습니다.
웹메일은 검색 칸의 [조건]을, 앱은 검색 화면의 [조건으로 찾기]를 눌러 주세요.
- 찾은 말에는 노란 표시가 붙어 목록과 본문에서 금방 찾을 수 있습니다.
- 메일을 읽다가 [이동]을 누르면 바로 다른 메일함으로 옮길 수 있습니다.
- 업데이트 뒤 하루쯤은 새 검색을 준비하느라 검색 화면에 안내가 나올 수 있습니다. 그동안에도 찾기는 됩니다.
## 웹메일 · 메일 읽기와 정리
- 메일을 열면 그 메일과 주고받은 메일이 아래에 대화로 모여 나옵니다.
[모두 펼치기]로 한꺼번에 읽고, 펼친 메일의 [답장]으로 바로 답할 수 있습니다.
- 목록 위의 [대화로 묶기]를 켜면 메일 목록도 대화별로 묶입니다.
- 목록의 메일을 끌어다 왼쪽 메일함에 놓으면 그 메일함으로 옮겨집니다.
- 메일함 이름 옆 [⋯] → [옮기기]로 메일함을 다른 메일함 아래로 옮길 수 있습니다.
하위 메일함이 있는 메일함도 [⋯] → [지우기]로 한꺼번에 지울 수 있고, 안에 있던 메일은 휴지통으로 옮겨집니다.
- 보낸 사람을 차단할 때 받은편지함에 있는 그 사람의 메일도 스팸메일함으로 함께 옮길 수 있습니다.
메일을 읽다가 [⋯] → [보낸 사람 차단]을 누르고, 창에서 받은편지함의 메일도 옮기는 칸을 켜 주세요.
- 예약한 메일을 기다리지 않고 바로 보낼 수 있습니다. 왼쪽의 [예약됨]에서 메일을 열고 [지금 보내기]를 눌러 주세요.
## 웹메일 · 주소록
- 다른 메일이나 휴대폰에서 내보낸 연락처 파일을 한 번에 가져올 수 있습니다.
[주소록]에서 [가져오기]를 누르고 파일을 고르면, 넣기 전에 몇 명이 들어가는지 먼저 확인할 수 있습니다.
- vCard, CSV, 엑셀 파일을 넣을 수 있고, 네이버, Google, 아이폰, Outlook에서 내보낸 파일도 됩니다.
- 이미 주소록에 있는 연락처는 건너뜁니다. 넣은 뒤 나오는 알림의 [되돌리기]를 누르면 이번에 넣은 연락처를 한꺼번에 지울 수 있습니다.
- 내 주소록을 파일로 내보낼 수도 있습니다. [주소록]에서 [내보내기]를 누르고 vCard나 CSV를 골라 주세요. 그룹 하나만 내보낼 수도 있습니다.
## 웹메일 · 설정
- 서명을 10개까지 만들어 두고 골라 쓸 수 있습니다. [설정] → [서명]에서 [새 서명]으로 만들고,
[메일에 넣을 서명]에서 [새 메일]과 [답장과 전달]에 넣을 서명을 따로 고를 수 있습니다.
- 메일을 쓰다가 아래의 [서명]을 누르면 다른 서명으로 바꿀 수 있습니다.
- 부재중 자동 응답을 굵게, 색깔 같은 글자 모양으로 꾸며 쓸 수 있습니다. [설정] → [부재중 자동 응답]
- 부재중 자동 응답에서 회사 밖에서 온 메일에는 [사내와 같은 응답], [보내지 않기], [따로 쓰기] 가운데 골라 답할 수 있습니다.
- 휴대폰을 바꾸셨다면 2단계 인증을 새 휴대폰으로 직접 옮길 수 있습니다.
[설정] → [2단계 인증] → [휴대폰 바꾸기]를 누르고 안내를 따라 주세요. 지금 휴대폰의 코드와 비밀번호가 필요합니다.
## 그 밖에
- 화면의 안내 글을 더 알기 쉬운 말로 다듬었습니다.
- 앞으로 새로 생기거나 바뀌는 것은 이 업데이트 내역에 올립니다. 웹메일과 앱 모두 [설정] → [업데이트 내역]에서 볼 수 있습니다.
+41
View File
@@ -0,0 +1,41 @@
# 업데이트 내역 글
직원이 웹메일과 휴대폰 앱의 설정에서 읽는 「업데이트 내역」의 글입니다. 글 하나가 파일 하나이고, 웹과 앱이 같은 글을 보여 줍니다. 올리는 화면은 없습니다.
## 글을 더하는 법
1. 이 폴더에 파일을 하나 더합니다. 파일 이름은 글의 날짜입니다(`2026-11-18.md`). 같은 날 두 번째 글은 `2026-11-18-2.md`로 씁니다.
2. 커밋하고 배포합니다(백엔드를 다시 빌드). 웹과 앱에 함께 나옵니다. Gitea 웹 화면에서 파일을 더하거나 고쳐도 됩니다.
고치거나 지울 때도 그 파일만 다룹니다. 목록에는 최근 글이 위에 나옵니다(날짜 차례, 같은 날은 뒤 번호가 위).
## 파일 안
```
# 메일 검색이 빨라지고 휴대폰 앱이 나왔습니다
<!-- 배포 날짜가 정해지면 파일 이름을 그날로 바꿉니다 -->
## 휴대폰 앱
- 안드로이드 휴대폰 앱이 나왔습니다. 웹메일의 [설정] → [휴대폰 앱]에서 받을 수 있습니다.
- 앱에서 메일을 읽고 쓰고 찾을 수 있고, 새 메일이 오면 휴대폰으로 알려 드립니다.
알림에는 새 메일이 몇 통인지만 나오고, 보낸 사람이나 제목은 나오지 않습니다.
```
| 줄 | 뜻 |
| --- | --- |
| `# 제목` | 파일의 첫 줄이고 꼭 있어야 합니다. 목록의 한 줄과 글 화면의 제목이 됩니다(100자까지). |
| `## 머리` | 묶음 머리입니다. 휴대폰 앱, 메일 검색처럼 어디 이야기인지 씁니다(40자까지). |
| `- 한 줄` | 앞에 점이 붙은 한 줄입니다(500자까지). 쓴 글이 그대로 나옵니다. |
| (빈칸 두 개) 글 | 앞 줄에 띄어쓰기 하나로 이어 붙습니다. 긴 줄을 파일에서 나눠 쓸 때 씁니다. |
| `<!-- … -->` | 메모입니다. 화면에 나오지 않습니다. 줄 앞에서 시작합니다(앞에 빈칸은 있어도 되지만, 글 뒤에 붙여 쓰지는 않습니다). 여러 줄로 써도 됩니다. |
| `- `(머리 없이) | 머리 없는 묶음입니다. 고친 것 한두 줄처럼 짧은 글에 씁니다. 제목 바로 아래에 씁니다. |
## 지킬 것
- 날짜는 파일 이름에서 읽으니 파일 안에 다시 쓰지 않습니다. 있는 날짜여야 합니다(`2026-02-30.md`는 안 됩니다).
- 파일 이름은 글 주소가 됩니다. 날짜를 고치면 주소도 바뀌어, 그전 주소로 들어오면 없는 글 화면이 나옵니다.
- 글 하나에 줄(`- `)이 하나는 있어야 하고, 묶음 머리 아래에도 줄이 하나는 있어야 합니다.
- 굵게, 링크, 그림, 표, `###`, 하위 목록은 쓸 수 없습니다. 쓴 글이 그대로 나옵니다. 단추나 메뉴 이름은 `[설정] → [휴대폰 앱]`처럼 씁니다.
- 이 폴더에는 이 안내(`README.md`)와 글 파일만 둡니다. 파일은 UTF-8로 저장합니다.
- 형식이 틀린 글은 시험(CI)이 막습니다. 미리 보려면 backend 폴더에서 `npm run test -- release-notes`를 돌립니다. 그래도 배포되면 서버는 그 글만 건너뛰고 로그에 파일 이름과 줄 번호를 남깁니다. 서버는 그대로 뜹니다.
- 앱은 옛 판이어도 새 글을 받으니, 앱 이야기를 쓸 때 새 판을 내지 않아도 됩니다.
+3
View File
@@ -18,6 +18,7 @@ import { FileStorageModule } from '@/modules/file-storage/file-storage.module';
import { HealthModule } from '@/modules/health/health.module';
import { MailModule } from '@/modules/mail/mail.module';
import { PushModule } from '@/modules/push/push.module';
import { ReleaseNotesModule } from '@/modules/release-notes/release-notes.module';
import { SystemSettingsModule } from '@/modules/system-settings/system-settings.module';
import { UploadModule } from '@/modules/upload/upload.module';
import { UserModule } from '@/modules/user/user.module';
@@ -89,6 +90,8 @@ const THROTTLE_DEFAULT_LIMIT = 100; // 구간당 IP별 최대 요청 수
AppConfigModule,
// 휴대폰 앱 받기 (사내 배포) — 최신 판 정보 · APK 파일 (로그인 필요). 올리기는 운영 도구다
AppReleasesModule,
// 업데이트 내역 — 목록 · 글 (로그인 필요). 글은 저장소의 backend/release-notes 폴더(글 파일 하나가 글 하나), 올리는 API 는 없다
ReleaseNotesModule,
UploadModule,
UserModule,
],
@@ -0,0 +1,115 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* 여러 서명 (docs/mail/api-contract.md 2절 "서명" — 16단계).
* mail_signatures 를 "한 사람에 한 줄(열쇠 = 주소)"에서 "서명마다 한 줄(열쇠 = id)"로 바꾼다 — 표를 새로 만들지 않고 고쳐 쓴다
* (퇴사 마무리는 그대로 주소로 이 표의 줄을 지운다 — modules/ops/ops-data.service.ts).
* - 지금 서명은 "기본 서명"이라는 이름의 서명 하나가 되고, 만든 시각은 저장한 시각이다. **내용이 비어 있던 서명(서식 · 텍스트가 모두 없거나 공백뿐)은 옮기지 않는다**(보드 결정)
* - 두 체크(is_for_new · is_for_reply — 그 서명을 새 메일 · 답장과 전달에 넣는다)는 「메일에 넣을 서명」 둘의 표시가 된다 — 켠 곳의 기본이 그 서명, 끈 곳은 넣지 않음.
* 칸 이름만 바꿔 값을 그대로 둔다(migration:generate 는 칸을 지우고 새로 만들어 값을 잃는다). 처음 값은 false(새로 만든 서명은 기본이 아니다)
* - 한 사람에 기본 하나씩 — 부분 유일 인덱스 둘. 목록(만든 차례)은 (user_email, created_at) 인덱스
* - 서명 그림 표(mail_signature_images)는 그대로 — 그림은 모든 서명이 함께 쓴다
* 되돌리기(down): 한 사람에 한 줄만 남긴다 — 「새 메일」 기본 → 「답장 · 전달」 기본 → 먼저 만든 서명 차례로 하나, **나머지 서명은 지운다**.
* 남긴 서명의 두 표시가 옛 두 체크가 된다. 옛 서버는 서명을 다음에 저장할 때 그 서명이 가리키지 않는 그림을 지운다.
* (엔티티: modules/mail-signature/mail-signature.entity.ts — migration:generate 의 결과에서 이름(PK · 인덱스)은 그대로 두고 값을 지키게 고쳤다)
*/
export class MultipleMailSignatures1794269810995 implements MigrationInterface {
name = 'MultipleMailSignatures1794269810995';
public async up(queryRunner: QueryRunner): Promise<void> {
// 내용이 비어 있던 서명은 옮기지 않는다 — 서식 · 텍스트 모두 없거나 공백뿐 (서비스는 빈 서식을 null 로 저장해 왔다)
await queryRunner.query(
`DELETE FROM "mail_signatures" WHERE ("body_html" IS NULL OR "body_html" !~ '\\S') AND ("body_text" IS NULL OR "body_text" !~ '\\S')`,
);
await queryRunner.query(
`ALTER TABLE "mail_signatures" ADD "id" uuid NOT NULL DEFAULT gen_random_uuid()`,
);
await queryRunner.query(
`ALTER TABLE "mail_signatures" ALTER COLUMN "id" DROP DEFAULT`,
);
await queryRunner.query(
`ALTER TABLE "mail_signatures" ADD "name" character varying(64) NOT NULL DEFAULT '기본 서명'`,
);
await queryRunner.query(
`ALTER TABLE "mail_signatures" ALTER COLUMN "name" DROP DEFAULT`,
);
await queryRunner.query(
`ALTER TABLE "mail_signatures" ADD "created_at" TIMESTAMP WITH TIME ZONE`,
);
await queryRunner.query(
`UPDATE "mail_signatures" SET "created_at" = "updated_at"`,
);
await queryRunner.query(
`ALTER TABLE "mail_signatures" ALTER COLUMN "created_at" SET NOT NULL`,
);
// 두 체크 → 「메일에 넣을 서명」 둘의 표시 (값은 그대로)
await queryRunner.query(
`ALTER TABLE "mail_signatures" RENAME COLUMN "is_for_new" TO "is_new_message_default"`,
);
await queryRunner.query(
`ALTER TABLE "mail_signatures" ALTER COLUMN "is_new_message_default" SET DEFAULT false`,
);
await queryRunner.query(
`ALTER TABLE "mail_signatures" RENAME COLUMN "is_for_reply" TO "is_reply_default"`,
);
await queryRunner.query(
`ALTER TABLE "mail_signatures" ALTER COLUMN "is_reply_default" SET DEFAULT false`,
);
// 열쇠 — 주소 → 서명 id
await queryRunner.query(
`ALTER TABLE "mail_signatures" DROP CONSTRAINT "PK_4846a94ce96c5f4fe400f449a7f"`,
);
await queryRunner.query(
`ALTER TABLE "mail_signatures" ADD CONSTRAINT "PK_d210af8f68aec58b45e5f2ea02b" PRIMARY KEY ("id")`,
);
await queryRunner.query(
`CREATE UNIQUE INDEX "UQ_mail_signatures_reply_default" ON "mail_signatures" ("user_email") WHERE "is_reply_default"`,
);
await queryRunner.query(
`CREATE UNIQUE INDEX "UQ_mail_signatures_new_message_default" ON "mail_signatures" ("user_email") WHERE "is_new_message_default"`,
);
await queryRunner.query(
`CREATE INDEX "IDX_mail_signatures_user_email_created_at" ON "mail_signatures" ("user_email", "created_at") `,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
// 한 사람에 한 줄만 — 「새 메일」 기본 → 「답장 · 전달」 기본 → 먼저 만든 서명 차례로 남기고 나머지는 지운다
await queryRunner.query(
`DELETE FROM "mail_signatures" AS "s" USING (SELECT "id", row_number() OVER (PARTITION BY "user_email" ORDER BY "is_new_message_default" DESC, "is_reply_default" DESC, "created_at" ASC, "id" ASC) AS "rank" FROM "mail_signatures") AS "ranked" WHERE "s"."id" = "ranked"."id" AND "ranked"."rank" > 1`,
);
await queryRunner.query(
`DROP INDEX "public"."IDX_mail_signatures_user_email_created_at"`,
);
await queryRunner.query(
`DROP INDEX "public"."UQ_mail_signatures_new_message_default"`,
);
await queryRunner.query(
`DROP INDEX "public"."UQ_mail_signatures_reply_default"`,
);
await queryRunner.query(
`ALTER TABLE "mail_signatures" DROP CONSTRAINT "PK_d210af8f68aec58b45e5f2ea02b"`,
);
await queryRunner.query(
`ALTER TABLE "mail_signatures" ADD CONSTRAINT "PK_4846a94ce96c5f4fe400f449a7f" PRIMARY KEY ("user_email")`,
);
// 남긴 서명의 표시 → 옛 두 체크 (처음 값은 옛 그대로 true)
await queryRunner.query(
`ALTER TABLE "mail_signatures" RENAME COLUMN "is_reply_default" TO "is_for_reply"`,
);
await queryRunner.query(
`ALTER TABLE "mail_signatures" ALTER COLUMN "is_for_reply" SET DEFAULT true`,
);
await queryRunner.query(
`ALTER TABLE "mail_signatures" RENAME COLUMN "is_new_message_default" TO "is_for_new"`,
);
await queryRunner.query(
`ALTER TABLE "mail_signatures" ALTER COLUMN "is_for_new" SET DEFAULT true`,
);
await queryRunner.query(
`ALTER TABLE "mail_signatures" DROP COLUMN "created_at"`,
);
await queryRunner.query(`ALTER TABLE "mail_signatures" DROP COLUMN "name"`);
await queryRunner.query(`ALTER TABLE "mail_signatures" DROP COLUMN "id"`);
}
}
@@ -43,6 +43,14 @@ export type TwoFactorEnableResult = 'enabled' | 'refused' | 'unknown';
*/
export type TwoFactorDisableResult = 'disabled' | 'code-rejected' | 'unknown';
/**
* 2단계 인증 휴대폰 바꾸기의 엔진 판단 (16단계 — 켠 채 새 비밀로) — 비밀번호는 부르는 쪽이 먼저 확인한다:
* - replaced: 바꿨다 — 옛 비밀은 바로 죽는다
* - code-rejected: 엔진이 받지 않았다 — 지금 휴대폰의 코드 틀림 (엔진은 비밀번호 틀림과 코드 틀림을 가르지 않는다)
* - unknown: 결과를 알 수 없게 끝났다 — 엔진은 otpUrl 을 가려 보여 주므로 다시 읽어도 가를 수 없다
*/
export type TwoFactorReplaceResult = 'replaced' | 'code-rejected' | 'unknown';
export interface AccountSecurityProvider {
/** 엔진에서 2단계 인증이 켜져 있는가 — 엔진이 기준이다 (계약 5절 "2단계 인증") */
isTwoFactorEnabled(user: string): Promise<boolean>;
@@ -64,6 +72,17 @@ export interface AccountSecurityProvider {
otpCode: string,
): Promise<TwoFactorDisableResult>;
/**
* 2단계 인증을 켠 채 휴대폰(비밀)을 바꾼다 (16단계) — 지금 비밀번호 · 지금 휴대폰의 코드 · 새 otpauth 주소를 한 요청으로.
* 2단계가 꺼지는 틈이 없다. 엔진은 새 비밀의 코드를 보지 않는다 — 새 휴대폰의 코드 확인은 부르는 쪽(TwoFactorService)이 먼저 한다
*/
replaceTwoFactor(
user: string,
currentPassword: string,
otpCode: string,
otpauthUrl: string,
): Promise<TwoFactorReplaceResult>;
/** 앱 비밀번호 목록 (차례는 부르는 쪽이 정한다) */
listAppPasswords(user: string): Promise<AppPasswordRecord[]>;
@@ -101,6 +101,53 @@ export class TwoFactorActivationDto {
currentPassword!: string;
}
/** POST /api/two-factor/enrollments/{id}/verification (16단계 — 휴대폰 바꾸기 ①) */
export class TwoFactorVerificationDto {
@ApiProperty({
description:
'새 휴대폰 인증 앱의 숫자 6자리 — 등록의 비밀로 서버가 확인한다(앞뒤 30초까지)',
example: '123456',
})
@OtpCodeField()
otpCode!: string;
}
/** POST /api/two-factor/enrollments/{id}/verification → 200 */
export class TwoFactorVerifiedDto {
@ApiProperty({
description:
'늘 true — 등록에 "확인됨"을 달았다(10분 안에 바꾸기를 보낸다)',
example: true,
})
verified!: true;
}
/** POST /api/two-factor/replacements (16단계 — 휴대폰 바꾸기 ②) */
export class TwoFactorReplacementDto {
@ApiProperty({
description:
'확인한 등록의 enrollmentId (POST /api/two-factor/enrollments → …/verification)',
example: '1f0c2a5e-3c1d-4b8e-9f57-6e2d1b0a9c44',
})
@IsUUID('all', { message: '2단계 인증 등록 정보가 올바르지 않습니다.' })
enrollmentId!: string;
@ApiProperty({
description: '지금 휴대폰 인증 앱의 숫자 6자리 — 엔진이 확인한다',
example: '654321',
})
@OtpCodeField()
currentOtpCode!: string;
@ApiProperty({
description: `지금 비밀번호 (${MAX_PASSWORD_LENGTH}자 이하, 앱 비밀번호는 받지 않는다)`,
format: 'password',
example: 'current-password',
})
@CurrentPasswordField()
currentPassword!: string;
}
/** POST /api/two-factor/deactivation */
export class TwoFactorDeactivationDto {
@ApiProperty({
@@ -0,0 +1,21 @@
import { Injectable, PipeTransform } from '@nestjs/common';
import { isUUID } from 'class-validator';
import { BusinessException } from '@/common/errors/business.exception';
/**
* 경로의 2단계 인증 등록 id (16단계 — `/api/two-factor/enrollments/{id}/verification`) — UUID 가 아니면 400 VAL_001.
* 켜기 · 바꾸기 본문의 enrollmentId(IsUUID)와 같은 규칙이다 (모양은 맞는데 없는 등록은 서비스가 409 AUTH_008)
*/
@Injectable()
export class TwoFactorEnrollmentIdPipe implements PipeTransform<
unknown,
string
> {
transform(value: unknown): string {
if (typeof value === 'string' && isUUID(value, 'all')) return value;
throw new BusinessException('VAL_001', {
context: { reason: 'malformed-two-factor-enrollment-id' },
});
}
}
@@ -12,13 +12,16 @@ export interface TwoFactorEnrollment {
readonly secret: Buffer;
/** 끝나는 때 (epoch ms) */
readonly expiresAt: number;
/** 새 휴대폰의 코드로 확인했다 — 휴대폰 바꾸기(16단계)는 확인한 등록으로만 한다 */
readonly verified?: boolean;
}
/**
* 기다리는 2단계 인증 등록 (계약 5절 "2단계 인증") — 직원마다 하나, 10분.
* 새로 시작하면 앞의 것을 버린다. 켜면(또는 시간이 지나면) 지운다 — 켠 뒤의 비밀은 엔진에만 있다(서버 · DB · 로그 어디에도 두지 않는다).
* 새로 시작하면 앞의 것을 버린다. 켜거나 휴대폰을 바꾸면(또는 시간이 지나면) 지운다 — 그 뒤의 비밀은 엔진에만 있다(서버 · DB · 로그 어디에도 두지 않는다).
* 켜기와 휴대폰 바꾸기(16단계 — 켜져 있을 때의 등록)가 같은 자리를 쓴다.
*
* ⚠️ 프로세스 메모리다 — 인스턴스를 늘리면 공유 저장소로 옮긴다(10분짜리 항목). 그대로면 다른 인스턴스로 간 켜기가 409 AUTH_008 이다.
* ⚠️ 프로세스 메모리다 — 인스턴스를 늘리면 공유 저장소로 옮긴다(10분짜리 항목). 그대로면 다른 인스턴스로 간 켜기 · 바꾸기가 409 AUTH_008 이다.
*/
@Injectable()
export class TwoFactorEnrollments {
@@ -45,6 +48,14 @@ export class TwoFactorEnrollments {
return enrollment.id === id ? enrollment : null;
}
/** 그 id 의 등록에 "확인됨"을 단다 (새 휴대폰의 코드가 맞았다) — 등록이 없거나 지났거나 바뀌었으면 false */
markVerified(email: string, id: string, now: number = Date.now()): boolean {
const enrollment = this.find(email, id, now);
if (!enrollment) return false;
this.pending.set(email, { ...enrollment, verified: true });
return true;
}
/** 등록을 버린다 — 비밀 바이트도 지운다 */
forget(email: string): void {
const enrollment = this.pending.get(email);
@@ -5,6 +5,7 @@ import {
HttpCode,
HttpStatus,
Ip,
Param,
Post,
UseGuards,
} from '@nestjs/common';
@@ -25,8 +26,12 @@ import {
TwoFactorActivationDto,
TwoFactorDeactivationDto,
TwoFactorEnrollmentDto,
TwoFactorReplacementDto,
TwoFactorStatusDto,
TwoFactorVerificationDto,
TwoFactorVerifiedDto,
} from './dto/two-factor.dto';
import { TwoFactorEnrollmentIdPipe } from './two-factor-enrollment-id.pipe';
import {
TwoFactorService,
type TwoFactorStatusView,
@@ -50,7 +55,7 @@ function toStatusDto(status: TwoFactorStatusView): TwoFactorStatusDto {
'AUTH_001',
'로그인 필요 (쿠키 없음 · 만료 · 끊은 세션) · 웹메일로 다루지 않는 계정(메일 엔진 관리자 등)',
)
// 13단계 — 상태 · 등록 · 켜기는 제한된 세션(비밀번호 바꾸기 · 2단계 켜기 전)도 부른다(@AllowRestrictedSession). 끄기는 403 AUTH_010
// 13단계 — 상태 · 등록 · 켜기는 제한된 세션(비밀번호 바꾸기 · 2단계 켜기 전)도 부른다(@AllowRestrictedSession). 끄기 · 휴대폰 바꾸기(16단계 — 확인 · 바꾸기)는 403 AUTH_010
@Controller('two-factor')
export class TwoFactorController {
constructor(private readonly twoFactor: TwoFactorService) {}
@@ -80,18 +85,19 @@ export class TwoFactorController {
@AllowRestrictedSession()
@NoStore()
@ApiOperation({
summary: '2단계 인증 등록 시작 — 비밀과 otpauth 주소 (꺼져 있을 때만)',
summary:
'2단계 인증 등록 시작 — 비밀과 otpauth 주소 (꺼져 있으면 켜기용 · 켜져 있으면 휴대폰 바꾸기용)',
description:
'서버가 160비트 비밀을 만들어 10분 동안 서버 메모리에만 둔다(켜면 엔진에만 남는다). 직원마다 기다리는 등록은 하나 — 새로 시작하면 앞의 것은 버린다. ' +
'서버가 160비트 비밀을 만들어 10분 동안 서버 메모리에만 둔다(켜거나 바꾸면 엔진에만 남는다). 직원마다 기다리는 등록은 하나 — 새로 시작하면 앞의 것은 버린다. ' +
'16단계부터 켜져 있어도 받는다(휴대폰 바꾸기 — …/verification → /api/two-factor/replacements). 켜진 채 이 등록으로 켜기를 보내면 409 AUTH_008 이다. ' +
'응답은 Cache-Control: private, no-store.',
})
@ApiStandardResponse(TwoFactorEnrollmentDto, {
status: 201,
description: '등록 — 비밀 · otpauth 주소는 이 응답에만',
})
@ApiErrorResponse('AUTH_008', '이미 켜져 있음 — 화면은 상태를 다시 받는다')
@ApiErrorResponse('REQ_001', '같은 직원의 2단계 인증 바꾸기가 처리 중')
@ApiErrorResponse('MAIL_001', '메일 엔진 장애')
@ApiErrorResponse('MAIL_001', '메일 엔진 설정 없음')
async enroll(
@CurrentUser() user: AuthenticatedUser,
): Promise<TwoFactorEnrollmentDto> {
@@ -104,6 +110,93 @@ export class TwoFactorController {
};
}
@Post('enrollments/:id/verification')
@HttpCode(HttpStatus.OK)
@ApiOperation({
summary: '휴대폰 바꾸기 ① — 새 휴대폰의 코드 확인 (16단계)',
description:
'새 휴대폰의 코드를 등록의 비밀로 확인하고(앞뒤 30초) 등록에 "확인됨"을 단다 — 엔진은 등록 때 코드를 보지 않는다. 엔진에는 아직 아무것도 바꾸지 않는다. ' +
'받은 코드는 2분 동안 다시 받지 않는다(코드 실패 한도 · 같은 코드 막기는 켜기 · 끄기와 같다). 확인은 등록에 남는다(10분) — [이전]으로 돌아와도 다시 확인하지 않아도 된다. ' +
'제한된 세션은 403 AUTH_010.',
})
@ApiStandardResponse(TwoFactorVerifiedDto, { description: '확인했다' })
@ApiErrorResponse(
'AUTH_006',
'AUTH_006 코드 틀림 · 2분 안에 다시 쓴 코드 / VAL_001 코드 · 등록 id 형식 오류 (모두 400)',
)
@ApiErrorResponse(
'AUTH_008',
'등록이 없음 · 10분 지남 · 더 새 등록으로 바뀜 — 화면은 처음부터 다시(새 등록)',
)
@ApiErrorResponse(
'AUTH_010',
'제한된 세션 — 먼저 비밀번호를 바꾸거나 2단계 인증을 켠다 (13단계)',
)
@ApiErrorResponse(
'REQ_001',
'코드 실패 한도(같은 주소 15분 5회 · 24시간 20회) · 처리 중',
)
async verifyEnrollment(
@CurrentUser() user: AuthenticatedUser,
@Param('id', TwoFactorEnrollmentIdPipe) id: string,
@Body() dto: TwoFactorVerificationDto,
@Ip() ip: string,
): Promise<TwoFactorVerifiedDto> {
await this.twoFactor.verifyEnrollment(
user.email,
{ enrollmentId: id, otpCode: dto.otpCode },
{ ip },
);
return { verified: true };
}
@Post('replacements')
@HttpCode(HttpStatus.OK)
@ApiOperation({
summary:
'휴대폰 바꾸기 ② — 지금 휴대폰의 코드와 지금 비밀번호로 바꾼다 (16단계)',
description:
'①에서 확인한 등록만 받는다. 지금 비밀번호를 먼저 확인하고(틀리면 AUTH_005 — 로그인 실패 한도로 센다, 그사이 2단계가 꺼졌으면 AUTH_008) ' +
'지금 휴대폰의 코드와 새 비밀을 엔진 요청 하나로 보낸다 — 2단계가 꺼지는 틈이 없고 옛 휴대폰의 코드는 바로 막힌다. ' +
'지금 코드가 틀리거나 2분 안에 다시 쓴 코드면 AUTH_006(코드 실패로 센다 — 등록은 남아 다시 보낼 수 있다). ' +
'결과를 모르면 503 MAIL_001 — 엔진이 비밀을 가려 보여 주어 다시 읽어도 가를 수 없다(등록은 남는다). 켠 때(enabledAt)는 그대로, 로그인된 기기는 끊지 않는다. ' +
'제한된 세션은 403 AUTH_010.',
})
@ApiStandardResponse(TwoFactorStatusDto, { description: '바꿨다' })
@ApiErrorResponse(
'AUTH_006',
'AUTH_006 지금 코드 틀림 · 2분 안에 다시 쓴 코드 / AUTH_005 지금 비밀번호 틀림 · 앱 비밀번호 / VAL_001 형식 오류 (모두 400)',
)
@ApiErrorResponse(
'AUTH_008',
'등록이 없음 · 10분 지남 · 더 새 등록 · 확인(①)하지 않은 등록 · 그사이 다른 곳에서 꺼짐 — 화면은 처음부터 다시',
)
@ApiErrorResponse(
'AUTH_010',
'제한된 세션 — 먼저 비밀번호를 바꾸거나 2단계 인증을 켠다 (13단계)',
)
@ApiErrorResponse('REQ_001', '코드 실패 한도 · 비밀번호 실패 한도 · 처리 중')
@ApiErrorResponse(
'MAIL_001',
'메일 엔진 장애 · 결과를 알 수 없음(가를 수 없다 — 다시 보내기 전에 새 휴대폰의 코드로 로그인되는지 본다)',
)
async replace(
@CurrentUser() user: AuthenticatedUser,
@Body() dto: TwoFactorReplacementDto,
@Ip() ip: string,
): Promise<TwoFactorStatusDto> {
const status = await this.twoFactor.replace(
user.email,
{
enrollmentId: dto.enrollmentId,
currentOtpCode: dto.currentOtpCode,
currentPassword: dto.currentPassword,
},
{ ip },
);
return toStatusDto(await this.twoFactor.withPolicy(user.email, status));
}
@Post('activation')
@AllowRestrictedSession()
@HttpCode(HttpStatus.OK)
@@ -16,6 +16,7 @@ import type {
AccountSecurityProvider,
TwoFactorDisableResult,
TwoFactorEnableResult,
TwoFactorReplaceResult,
} from './account-security.provider';
import { TwoFactorCodeGuard } from './two-factor-code-guard';
import { TwoFactorEnrollments } from './two-factor-enrollments';
@@ -114,7 +115,10 @@ describe('TwoFactorService (2단계 인증 켜기 · 끄기 — 계약 5절)', (
let provider: jest.Mocked<
Pick<
AccountSecurityProvider,
'isTwoFactorEnabled' | 'enableTwoFactor' | 'disableTwoFactor'
| 'isTwoFactorEnabled'
| 'enableTwoFactor'
| 'disableTwoFactor'
| 'replaceTwoFactor'
>
>;
let mail: { verifyCredentials: jest.Mock };
@@ -160,6 +164,10 @@ describe('TwoFactorService (2단계 인증 켜기 · 끄기 — 계약 5절)', (
engineEnabled = false;
return Promise.resolve('disabled');
}),
replaceTwoFactor: jest.fn<
Promise<TwoFactorReplaceResult>,
[string, string, string, string]
>(() => Promise.resolve('replaced')),
};
mail = {
verifyCredentials: jest.fn(() =>
@@ -376,7 +384,7 @@ describe('TwoFactorService (2단계 인증 켜기 · 끄기 — 계약 5절)', (
).toBe('AUTH_008');
});
it('그사이 다른 곳에서 켜졌으면 등록 · 켜기 모두 409 AUTH_008', async () => {
it('그사이 다른 곳에서 켜졌으면 켜기는 409 AUTH_008 — 등록은 켜져 있어도 받는다(16단계 휴대폰 바꾸기용 · 엔진에 묻지 않는다)', async () => {
const { enrollment, code } = await enroll();
engineEnabled = true;
@@ -395,9 +403,11 @@ describe('TwoFactorService (2단계 인증 켜기 · 끄기 — 계약 5절)', (
)
)?.code,
).toBe('AUTH_008');
expect((await failureOf(service.startEnrollment(ARA)))?.code).toBe(
'AUTH_008',
);
provider.isTwoFactorEnabled.mockClear();
await expect(service.startEnrollment(ARA)).resolves.toMatchObject({
secret: expect.stringMatching(/^[A-Z2-7]{32}$/) as unknown,
});
expect(provider.isTwoFactorEnabled).not.toHaveBeenCalled();
});
it('켜기의 결과를 모르면 엔진을 다시 읽어 켜졌으면 성공, 아니면 503 MAIL_001', async () => {
@@ -579,6 +589,251 @@ describe('TwoFactorService (2단계 인증 켜기 · 끄기 — 계약 5절)', (
}
});
describe('휴대폰 바꾸기 (16단계 — 켠 채 새 비밀로)', () => {
const OLD_CODE = '135790';
const enabledAt = new Date('2026-10-31T00:12:00.000Z');
beforeEach(() => {
engineEnabled = true;
table.rows.set(ARA, { userEmail: ARA, enabledAt });
// 엔진 대역 — 지금 휴대폰의 코드는 OLD_CODE 하나만 맞다
provider.replaceTwoFactor.mockImplementation((_user, _password, code) =>
Promise.resolve(code === OLD_CODE ? 'replaced' : 'code-rejected'),
);
});
/** 등록하고 새 휴대폰의 코드로 확인한다 */
const verified = async () => {
const { enrollment, code } = await enroll();
const newCode = code();
await service.verifyEnrollment(
ARA,
{ enrollmentId: enrollment.enrollmentId, otpCode: newCode },
{ ip: IP },
);
return { enrollment, newCode, code };
};
const replace = (
enrollmentId: string,
overrides: { currentOtpCode?: string; currentPassword?: string } = {},
ip = IP,
) =>
service.replace(
ARA,
{
enrollmentId,
currentOtpCode: OLD_CODE,
currentPassword: PASSWORD,
...overrides,
},
{ ip },
);
it('① 새 휴대폰의 코드를 등록의 비밀로 확인(엔진에 묻지 않는다) → ② 지금 비밀번호(본인 Basic 402) → 지금 코드와 새 주소를 엔진 한 요청 — 켠 때는 그대로, 등록은 지운다', async () => {
const { enrollment, newCode } = await verified();
expect(mail.verifyCredentials).not.toHaveBeenCalled();
expect(provider.replaceTwoFactor).not.toHaveBeenCalled();
expect(codes.isUsed(ARA, newCode)).toBe(true);
const status = await replace(enrollment.enrollmentId);
expect(status).toEqual({ isEnabled: true, enabledAt });
expect(mail.verifyCredentials).toHaveBeenCalledWith(ARA, PASSWORD);
expect(provider.replaceTwoFactor).toHaveBeenCalledWith(
ARA,
PASSWORD,
OLD_CODE,
enrollment.otpauthUrl,
);
expect(codes.isUsed(ARA, OLD_CODE)).toBe(true);
expect(table.rows.get(ARA)?.enabledAt).toEqual(enabledAt);
expect(logs).toContain(
'2단계 인증 휴대폰을 바꿨습니다: a***@arasystem.test',
);
expect(logs.join('\n')).not.toMatch(
new RegExp(`${OLD_CODE}|${newCode}|${PASSWORD}|otpauth`),
);
// 등록은 지웠다 — 같은 등록으로 다시 바꾸지 못한다
expect((await failureOf(replace(enrollment.enrollmentId)))?.code).toBe(
'AUTH_008',
);
});
it('확인하지 않은 등록은 409 AUTH_008 — 등록을 버린다(처음부터) · 없는 등록 · 다른 id 도 AUTH_008', async () => {
const { enrollment, code } = await enroll();
expect((await failureOf(replace(enrollment.enrollmentId)))?.code).toBe(
'AUTH_008',
);
// 버렸다 — 확인도 할 수 없다
expect(
(
await failureOf(
service.verifyEnrollment(
ARA,
{ enrollmentId: enrollment.enrollmentId, otpCode: code() },
{ ip: IP },
),
)
)?.code,
).toBe('AUTH_008');
expect(
(await failureOf(replace('00000000-0000-4000-8000-000000000000')))
?.code,
).toBe('AUTH_008');
expect(mail.verifyCredentials).not.toHaveBeenCalled();
expect(provider.replaceTwoFactor).not.toHaveBeenCalled();
});
it('① 새 휴대폰의 코드가 틀리면 400 AUTH_006 — 코드 실패로 센다(15분 5회 → REQ_001), 확인되지 않는다', async () => {
const { enrollment, code } = await enroll();
const wrong = code() === '000000' ? '111111' : '000000';
for (let i = 0; i < TWO_FACTOR_CODE_ADDRESS_LIMIT; i += 1) {
expect(
(
await failureOf(
service.verifyEnrollment(
ARA,
{ enrollmentId: enrollment.enrollmentId, otpCode: wrong },
{ ip: `198.51.100.${i}` },
),
)
)?.code,
).toBe('AUTH_006');
}
expect(
(
await failureOf(
service.verifyEnrollment(
ARA,
{ enrollmentId: enrollment.enrollmentId, otpCode: code() },
{ ip: IP },
),
)
)?.code,
).toBe('REQ_001');
expect((await failureOf(replace(enrollment.enrollmentId)))?.code).toBe(
'AUTH_008',
);
});
it('② 지금 비밀번호가 틀리면 400 AUTH_005(로그인 실패로 센다) · 앱 비밀번호 모양은 묻지 않고 AUTH_005 — 엔진에 보내지 않고 등록은 남는다', async () => {
const { enrollment } = await verified();
mail.verifyCredentials.mockResolvedValueOnce(INVALID);
expect(
(
await failureOf(
replace(enrollment.enrollmentId, { currentPassword: 'wrong' }),
)
)?.code,
).toBe('AUTH_005');
mail.verifyCredentials.mockClear();
expect(
(
await failureOf(
replace(enrollment.enrollmentId, {
currentPassword: `app_${'a'.repeat(36)}`,
}),
)
)?.context,
).toMatchObject({ reason: 'app-password-shape' });
expect(mail.verifyCredentials).not.toHaveBeenCalled();
expect(provider.replaceTwoFactor).not.toHaveBeenCalled();
// 등록은 남았다 — 비밀번호만 고쳐 다시 보낸다
await expect(replace(enrollment.enrollmentId)).resolves.toMatchObject({
isEnabled: true,
});
});
it('② 그사이 다른 곳에서 꺼졌으면(본인 Basic 200) 409 AUTH_008 — 엔진에 보내지 않고 등록을 버린다', async () => {
const { enrollment } = await verified();
engineEnabled = false;
expect((await failureOf(replace(enrollment.enrollmentId)))?.code).toBe(
'AUTH_008',
);
expect(provider.replaceTwoFactor).not.toHaveBeenCalled();
engineEnabled = true;
expect((await failureOf(replace(enrollment.enrollmentId)))?.code).toBe(
'AUTH_008',
);
});
it('② 엔진이 지금 코드를 받지 않으면(forbidden) 400 AUTH_006 — 코드 실패로 센다, 등록은 남아 맞는 코드로 다시 보낼 수 있다', async () => {
const { enrollment, newCode } = await verified();
expect(
(
await failureOf(
replace(enrollment.enrollmentId, { currentOtpCode: '246800' }),
)
)?.code,
).toBe('AUTH_006');
// 다시 쓴 코드(①에서 쓴 새 휴대폰의 코드)는 엔진에 묻지 않고 AUTH_006
provider.replaceTwoFactor.mockClear();
expect(
(
await failureOf(
replace(enrollment.enrollmentId, { currentOtpCode: newCode }),
)
)?.code,
).toBe('AUTH_006');
expect(provider.replaceTwoFactor).not.toHaveBeenCalled();
await expect(replace(enrollment.enrollmentId)).resolves.toMatchObject({
isEnabled: true,
});
});
it('② 결과를 모르면 503 MAIL_001 — 다시 읽지 않는다(가를 수 없다), 코드는 쓰지 않은 것으로, 등록은 남는다', async () => {
const { enrollment } = await verified();
provider.replaceTwoFactor.mockResolvedValueOnce('unknown');
provider.isTwoFactorEnabled.mockClear();
const failure = await failureOf(replace(enrollment.enrollmentId));
expect(failure?.code).toBe('MAIL_001');
expect(failure?.context).toMatchObject({
reason: 'two-factor-replace-outcome-unknown',
});
expect(provider.isTwoFactorEnabled).not.toHaveBeenCalled();
expect(codes.isUsed(ARA, OLD_CODE)).toBe(false);
await expect(replace(enrollment.enrollmentId)).resolves.toMatchObject({
isEnabled: true,
});
});
it('같은 직원의 바꾸기가 처리 중이면 확인 · 바꾸기 모두 429 REQ_001', async () => {
const { enrollment, code } = await enroll();
const release = locks.acquire(ARA);
try {
expect(
(
await failureOf(
service.verifyEnrollment(
ARA,
{ enrollmentId: enrollment.enrollmentId, otpCode: code() },
{ ip: IP },
),
)
)?.code,
).toBe('REQ_001');
expect((await failureOf(replace(enrollment.enrollmentId)))?.code).toBe(
'REQ_001',
);
} finally {
release();
}
});
it('켠 때 기록을 읽지 못해도 바꾸기는 성공 — enabledAt null · error 로그', async () => {
const { enrollment } = await verified();
table.repository.findOneBy.mockRejectedValueOnce(new Error('db down'));
await expect(replace(enrollment.enrollmentId)).resolves.toEqual({
isEnabled: true,
enabledAt: null,
});
expect(errors.join('\n')).toContain('켠 때 기록을 읽지 못했습니다');
});
});
describe('끄기', () => {
beforeEach(() => {
engineEnabled = true;
@@ -31,6 +31,7 @@ import { TwoFactorCodeGuard } from './two-factor-code-guard';
import {
TwoFactorEnrollments,
enrollmentExpiry,
type TwoFactorEnrollment,
} from './two-factor-enrollments';
import { TwoFactorLocks } from './two-factor-locks';
import { TwoFactorPolicyService } from './two-factor-policy.service';
@@ -74,7 +75,9 @@ function errorMessage(error: unknown): string {
/**
* 2단계 인증 켜기 · 끄기 (계약 5절 "2단계 인증") — **엔진이 기준이다**(켜짐은 엔진의 x:AccountPassword 로 본다).
*
* - 등록: 서버가 160비트 비밀을 만들어 10분 동안 메모리에만 둔다 (TwoFactorEnrollments). 이미 켜져 있으면 409 AUTH_008
* - 등록: 서버가 160비트 비밀을 만들어 10분 동안 메모리에만 둔다 (TwoFactorEnrollments). 켜져 있어도 받는다 — 휴대폰 바꾸기용(16단계)
* - 휴대폰 바꾸기(16단계): ① 새 휴대폰의 코드를 등록의 비밀로 확인해 등록에 "확인됨"을 단다(verifyEnrollment) ② 지금 비밀번호(본인 Basic 402) →
* 지금 휴대폰의 코드와 새 otpauth 주소를 엔진 한 요청으로(replace — 2단계가 꺼지는 틈이 없다). 결과를 모르면 MAIL_001(다시 읽어도 가를 수 없다)
* - 켜기: 받은 코드를 등록의 비밀로 **먼저** 확인한다(±1 단계 — 엔진은 등록 때 코드를 보지 않는다, 잘못 등록하면 직원이 끄지도 못한다).
* 둘 다 틀렸으면 코드부터 알린다. 그다음 지금 비밀번호를 본인 Basic 으로 확인하고(틀리면 AUTH_005 — 로그인 실패 한도로 센다,
* 402 면 그사이 다른 곳에서 켜짐 → AUTH_008) 대리 접근으로 엔진에 otpauth 주소를 넣는다.
@@ -134,18 +137,13 @@ export class TwoFactorService {
return this.reconcile(address, enabled);
}
/** POST /api/two-factor/enrollments — 꺼져 있을 때만 */
/**
* POST /api/two-factor/enrollments — 꺼져 있으면 켜기용, 켜져 있으면 휴대폰 바꾸기용(16단계) — 같은 등록이다.
* 엔진에 묻지 않는다: 켜기는 비밀번호 확인(402)이, 바꾸기는 엔진이 지금 코드로 가른다
*/
async startEnrollment(user: string): Promise<TwoFactorEnrollmentView> {
const address = this.requireEmployee(user);
return this.locks.run(address, async () => {
if (await this.provider.isTwoFactorEnabled(address)) {
throw new BusinessException('AUTH_008', {
context: {
reason: 'two-factor-already-enabled',
email: maskEmail(address),
},
});
}
return this.locks.run(address, () => {
const now = Date.now();
const enrollment = {
id: randomUUID(),
@@ -154,15 +152,181 @@ export class TwoFactorService {
};
this.enrollments.put(address, enrollment);
const secret = encodeBase32(enrollment.secret);
return {
return Promise.resolve({
enrollmentId: enrollment.id,
secret,
otpauthUrl: this.otpauthUrl(address, secret),
expiresAt: new Date(enrollment.expiresAt),
};
});
});
}
/**
* POST /api/two-factor/enrollments/{id}/verification — 새 휴대폰의 코드를 등록의 비밀로 확인하고 등록에 "확인됨"을 단다 (16단계 휴대폰 바꾸기 ①).
* 엔진은 등록 때 코드를 보지 않으므로 서버가 먼저 본다. 코드 확인은 켜기 · 끄기와 같은 코드 가드(실패 한도 · 2분 다시 쓰기 막기 — 받은 코드는 기억한다).
* 등록이 없음 · 10분 지남 · 더 새 등록이면 AUTH_008, 코드 틀림 AUTH_006. 엔진에 묻지 않는다
*/
async verifyEnrollment(
user: string,
input: { readonly enrollmentId: string; readonly otpCode: string },
client: SecurityClient,
): Promise<void> {
const address = this.requireEmployee(user);
const ip = client.ip || 'unknown';
return this.locks.run(address, async () => {
const enrollment = this.requireEnrollment(address, input.enrollmentId);
await this.codes.check<undefined>(
{ email: address, ip },
input.otpCode,
() =>
Promise.resolve(
verifyTotp(enrollment.secret, input.otpCode, Date.now()) === null
? { kind: 'rejected' as const }
: { kind: 'accepted' as const, value: undefined },
),
);
if (!this.enrollments.markVerified(address, input.enrollmentId)) {
// 코드를 보는 사이 10분이 지났다
throw this.enrollmentMissing(address, 'two-factor-enrollment-expired');
}
});
}
/**
* POST /api/two-factor/replacements — 2단계 인증을 켠 채 휴대폰을 바꾼다 (16단계 ②). 확인한 등록 · 지금 휴대폰의 코드 · 지금 비밀번호.
* 1. 등록이 확인됨이 아니면 AUTH_008(등록을 버린다 — 처음부터)
* 2. 지금 비밀번호를 본인 Basic 으로 — 402 여야 한다(켜짐 · 비밀번호 맞음). 401 · 앱 비밀번호 모양은 AUTH_005(로그인 실패 한도로 센다 — 모양은 세지 않는다),
* 200 이면 그사이 다른 곳에서 꺼졌다 → AUTH_008(등록을 버린다). 엔진은 비밀번호 틀림과 코드 틀림을 가르지 않아 비밀번호를 먼저 본다
* 3. 다시 쓴 코드 · 코드 실패 차지(끄기와 같은 결) → 엔진 한 요청(지금 코드 + 새 otpauth 주소). forbidden 은 지금 코드 틀림 AUTH_006,
* 결과를 모르면 MAIL_001(엔진은 otpUrl 을 가려 다시 읽어도 가를 수 없다 — 그 코드는 쓰지 않은 것으로, 등록은 남긴다)
* 4. 바꾸면 등록의 비밀을 지운다. 켠 때(enabledAt)는 그대로. 로그인된 기기는 끊지 않는다(켜기 · 끄기와 같다)
*/
async replace(
user: string,
input: {
readonly enrollmentId: string;
readonly currentOtpCode: string;
readonly currentPassword: string;
},
client: SecurityClient,
): Promise<TwoFactorStatus> {
const address = this.requireEmployee(user);
const ip = client.ip || 'unknown';
return this.locks.run(address, async () => {
const enrollment = this.requireEnrollment(address, input.enrollmentId);
if (enrollment.verified !== true) {
this.enrollments.forget(address);
throw this.enrollmentMissing(
address,
'two-factor-enrollment-unverified',
);
}
if (isAppPasswordShape(input.currentPassword)) {
throw this.currentPasswordRejected(address, 'app-password-shape');
}
const otpauthUrl = this.otpauthUrl(
address,
encodeBase32(enrollment.secret),
);
const ticket = this.attempts.begin({ email: address, ip });
try {
const check = await this.mail.verifyCredentials(
address,
input.currentPassword,
);
if (check.kind === 'invalid') {
ticket.fail();
throw this.currentPasswordRejected(
address,
'current-password-rejected',
);
}
if (check.kind === 'valid') {
// 엔진에서 그사이 꺼졌다 — 바꿀 것이 없다. 처음부터(켜기)
ticket.succeed();
this.enrollments.forget(address);
throw new BusinessException('AUTH_008', {
context: {
reason: 'two-factor-disabled-elsewhere',
email: maskEmail(address),
},
});
}
// 402 — 켜져 있고 비밀번호가 맞다. 코드 단계가 끝날 때까지 같은 주소의 다른 시도는 처리 중이다
ticket.verified();
await this.codes.check<undefined>(
{ email: address, ip },
input.currentOtpCode,
async () => {
const outcome = await this.provider.replaceTwoFactor(
address,
input.currentPassword,
input.currentOtpCode,
otpauthUrl,
);
if (outcome === 'code-rejected')
return { kind: 'rejected' as const };
if (outcome === 'unknown') {
throw new BusinessException('MAIL_001', {
context: {
reason: 'two-factor-replace-outcome-unknown',
email: maskEmail(address),
},
});
}
return { kind: 'accepted' as const, value: undefined };
},
);
ticket.succeed();
} finally {
ticket.abort();
}
// 엔진에서 바뀌었다 — 등록의 비밀을 지운다
this.enrollments.forget(address);
this.logger.log(`2단계 인증 휴대폰을 바꿨습니다: ${maskEmail(address)}`);
return { isEnabled: true, enabledAt: await this.readEnabledAt(address) };
});
}
/** 기다리는 등록 — 없음 · 10분 지남 · 더 새 등록이면 AUTH_008 */
private requireEnrollment(
address: string,
enrollmentId: string,
): TwoFactorEnrollment {
const enrollment = this.enrollments.find(address, enrollmentId);
if (!enrollment) {
throw this.enrollmentMissing(address, 'two-factor-enrollment-missing');
}
return enrollment;
}
private enrollmentMissing(
address: string,
reason: string,
): BusinessException {
return new BusinessException('AUTH_008', {
context: { reason, email: maskEmail(address) },
});
}
/** 켠 때 기록 — 읽지 못하면 error 로그만 남기고 null (엔진에서는 이미 바뀌었다) */
private async readEnabledAt(address: string): Promise<Date | null> {
try {
return (
(await this.settings.findOneBy({ userEmail: address }))?.enabledAt ??
null
);
} catch (error) {
this.logger.error(
`[SYS_001]${traceTag()} 2단계 인증 휴대폰을 바꿨지만 켠 때 기록을 읽지 못했습니다(엔진에서는 바뀌었다): user=${maskEmail(address)}, reason=${errorMessage(error)}`,
);
return null;
}
}
/** POST /api/two-factor/activation */
async activate(
user: string,
+4 -1
View File
@@ -320,12 +320,14 @@ export function weightAfterChanges(
export interface WrittenChanges {
readonly createdIds: ReadonlyMap<string, string>;
readonly cards: ReadonlyMap<string, AddressBookCard>;
/** 엔진이 받지 않은 만들기의 key — 가져오기(일부만 받음)에만 있다. 옮기지 않는다 */
readonly refusedKeys?: ReadonlySet<string>;
}
/**
* 엔진이 받은 바꾸기를 기억한 주소록에 옮긴다 — 판이 같을 때만 쓰므로(ifInState) 엔진의 새 내용과 같다.
* 연락처의 고치기 · 만든 카드는 어댑터가 준 모양(칸 안의 남의 정보와 합친 결과)을, 그룹 고치기는 보낸 이름 · 구성원을 쓴다.
* 만든 카드의 id 나 모양이 하나라도 없으면 null (옮길 수 없다 — 다시 읽는다)
* 엔진이 받지 않은 만들기(refusedKeys)는 건너뛴다. 그 밖의 만든 카드의 id 나 모양이 하나라도 없으면 null (옮길 수 없다 — 다시 읽는다)
*/
export function applyAddressBookWrite(
snapshot: AddressBookSnapshot,
@@ -352,6 +354,7 @@ export function applyAddressBookWrite(
.map((card) => applyGroupUpdate(card, updates.get(card.id)));
for (const card of changes.create) {
if (written.refusedKeys?.has(card.key)) continue;
const id = written.createdIds.get(card.key);
const read = id === undefined ? undefined : written.cards.get(id);
if (read === undefined) return null;
@@ -0,0 +1,291 @@
import {
contactExportContentType,
contactExportFileName,
contactsToCsv,
contactsToVcard,
CSV_HEADER,
foldVcardLine,
type ExportedContact,
} from './contact-export';
const contact = (
overrides: Partial<ExportedContact> = {},
): ExportedContact => ({
uid: 'urn:uuid:3f1c0a2e-0f1b-4f2a-9a1e-6c9f0a1b2c3d',
name: '박지훈',
emails: [{ address: 'jihoon.park@hanbit.example', label: 'work' }],
phones: [{ number: '010-3192-5540', label: 'mobile' }],
company: '한빛상사',
department: '구매팀',
title: '과장',
note: '견적 담당',
groupNames: ['거래처'],
...overrides,
});
/** 접힌 줄을 푼다 (CRLF + 공백 하나를 지운다 — RFC 2425 5.8.1) */
const unfold = (text: string) => text.replace(/\r\n /g, '');
describe('내보내기 — vCard 3.0', () => {
it('한 사람 — 줄 끝 CRLF, VERSION 은 BEGIN 바로 다음, 우리 칸만 (계약 3절 "내보내기")', () => {
expect(contactsToVcard([contact()])).toBe(
[
'BEGIN:VCARD',
'VERSION:3.0',
'UID:urn:uuid:3f1c0a2e-0f1b-4f2a-9a1e-6c9f0a1b2c3d',
'FN:박지훈',
'N:박지훈;;;;',
'EMAIL;TYPE=INTERNET,WORK:jihoon.park@hanbit.example',
'TEL;TYPE=CELL:010-3192-5540',
'ORG:한빛상사;구매팀',
'TITLE:과장',
'NOTE:견적 담당',
'CATEGORIES:거래처',
'END:VCARD',
'',
].join('\r\n'),
);
});
it('종류 — 이메일 WORK · HOME · INTERNET, 전화 CELL · WORK · HOME · VOICE', () => {
const text = contactsToVcard([
contact({
emails: [
{ address: 'a@x.example', label: 'work' },
{ address: 'b@x.example', label: 'personal' },
{ address: 'c@x.example', label: 'other' },
],
phones: [
{ number: '1', label: 'mobile' },
{ number: '2', label: 'work' },
{ number: '3', label: 'home' },
{ number: '4', label: 'other' },
],
}),
]);
expect(text).toContain('\r\nEMAIL;TYPE=INTERNET,WORK:a@x.example\r\n');
expect(text).toContain('\r\nEMAIL;TYPE=INTERNET,HOME:b@x.example\r\n');
expect(text).toContain('\r\nEMAIL;TYPE=INTERNET:c@x.example\r\n');
expect(text).toContain(
'\r\nTEL;TYPE=CELL:1\r\nTEL;TYPE=WORK:2\r\nTEL;TYPE=HOME:3\r\nTEL;TYPE=VOICE:4\r\n',
);
});
it('이스케이프 — \\ ; , 줄바꿈 (그룹 이름 안의 쉼표도)', () => {
const text = contactsToVcard([
contact({
name: '김; 아라, 팀장',
company: 'A;B',
department: null,
note: '첫 줄\n둘째 줄 C:\\temp',
groupNames: ['가,나', '다'],
}),
]);
expect(text).toContain('\r\nFN:김\\; 아라\\, 팀장\r\n');
expect(text).toContain('\r\nN:김\\; 아라\\, 팀장;;;;\r\n');
expect(text).toContain('\r\nORG:A\\;B;\r\n');
expect(text).toContain('\r\nNOTE:첫 줄\\n둘째 줄 C:\\\\temp\r\n');
expect(text).toContain('\r\nCATEGORIES:가\\,나,다\r\n');
});
it('빈 칸 — 이름이 없으면 FN 은 첫 이메일(N 은 비운다), 없는 칸 · uid 는 줄을 넣지 않는다, 회사가 없고 부서만 있으면 ORG:;부서', () => {
const text = contactsToVcard([
contact({
uid: null,
name: null,
emails: [
{ address: 'first@x.example', label: 'work' },
{ address: 'second@x.example', label: 'other' },
],
phones: [{ number: '010-3192-5540', label: 'mobile' }],
company: null,
department: '구매팀',
title: null,
note: null,
groupNames: [],
}),
]);
expect(text).toBe(
[
'BEGIN:VCARD',
'VERSION:3.0',
'FN:first@x.example',
'N:;;;;',
'EMAIL;TYPE=INTERNET,WORK:first@x.example',
'EMAIL;TYPE=INTERNET:second@x.example',
'TEL;TYPE=CELL:010-3192-5540',
'ORG:;구매팀',
'END:VCARD',
'',
].join('\r\n'),
);
});
it('이름 · 이메일이 없으면 FN 은 첫 전화번호, 그것도 없으면 빈 FN (N 은 늘 이름 칸 그대로)', () => {
const phoneOnly = contactsToVcard([
contact({
name: null,
emails: [],
phones: [
{ number: '02-555-0100', label: 'work' },
{ number: '010-3192-5540', label: 'mobile' },
],
}),
]);
expect(phoneOnly).toContain('\r\nFN:02-555-0100\r\nN:;;;;\r\n');
const nothing = contactsToVcard([
contact({ name: null, emails: [], phones: [] }),
]);
expect(nothing).toContain('\r\nFN:\r\nN:;;;;\r\n');
// 이름이 있으면 이름 — 주소 · 번호로 채우지 않는다
expect(contactsToVcard([contact()])).toContain('\r\nFN:박지훈\r\n');
});
it('줄 접기 — 75바이트마다(이어지는 줄은 공백 하나로 시작해 74바이트), 한글(3바이트)을 가르지 않는다', () => {
const line = `NOTE:${'가'.repeat(60)}`;
const folded = foldVcardLine(line);
const lines = folded.split('\r\n');
expect(lines.length).toBeGreaterThan(1);
for (const [index, part] of lines.entries()) {
expect(Buffer.byteLength(part, 'utf8')).toBeLessThanOrEqual(75);
if (index > 0) expect(part.startsWith(' ')).toBe(true);
// 가른 곳이 글자 사이다 — 바이트로 바꿔도 깨진 글자(U+FFFD)가 없다
expect(Buffer.from(part, 'utf8').toString('utf8')).not.toContain(
'\uFFFD',
);
}
expect(unfold(folded)).toBe(line);
// 75바이트 이하는 그대로
expect(foldVcardLine('A'.repeat(75))).toBe('A'.repeat(75));
expect(foldVcardLine('A'.repeat(76))).toBe(`${'A'.repeat(75)}\r\n A`);
// 이모지(4바이트 · 서로게이트 쌍)도 가르지 않는다
const emoji = foldVcardLine(`FN:${'😀'.repeat(30)}`);
expect(unfold(emoji)).toBe(`FN:${'😀'.repeat(30)}`);
for (const part of emoji.split('\r\n')) {
expect(Buffer.byteLength(part, 'utf8')).toBeLessThanOrEqual(75);
expect(/[\uD800-\uDBFF]$/.test(part)).toBe(false);
expect(/^ ?[\uDC00-\uDFFF]/.test(part)).toBe(false);
}
});
it('여러 명은 한 파일에 이어 붙인다 · 연락처가 없으면 빈 파일', () => {
const text = contactsToVcard([contact(), contact({ name: '김아라' })]);
expect(text.match(/BEGIN:VCARD/g)).toHaveLength(2);
expect(text.endsWith('END:VCARD\r\n')).toBe(true);
expect(contactsToVcard([])).toBe('');
});
});
describe('내보내기 — CSV', () => {
it('UTF-8 BOM · CRLF · 한국어 머리 — 같은 칸 여럿은 "; ", 이메일은 종류 없이 모두', () => {
const text = contactsToCsv([
contact({
emails: [
{ address: 'a@x.example', label: 'work' },
{ address: 'b@x.example', label: 'personal' },
],
phones: [
{ number: '010-1111-2222', label: 'mobile' },
{ number: '010-3333-4444', label: 'mobile' },
{ number: '02-555-0100', label: 'work' },
{ number: '031-000-0000', label: 'home' },
{ number: '070-1234-5678', label: 'other' },
],
groupNames: ['거래처', '협력사'],
}),
]);
expect(text.startsWith('\uFEFF')).toBe(true);
expect(text).toBe(
'\uFEFF' +
'이름,이메일,휴대폰,회사 전화,집 전화,기타 전화,회사,부서,직함,메모,그룹\r\n' +
'박지훈,a@x.example; b@x.example,010-1111-2222; 010-3333-4444,02-555-0100,031-000-0000,070-1234-5678,한빛상사,구매팀,과장,견적 담당,거래처; 협력사\r\n',
);
expect(CSV_HEADER).toHaveLength(11);
});
it("= + - @ 로 시작하는 칸은 앞에 ' (엑셀 수식 막기) · 쉼표 · 따옴표 · 줄바꿈은 따옴표로 감싼다", () => {
const text = contactsToCsv([
contact({
name: '=HYPERLINK("http://x")',
emails: [],
phones: [{ number: '+82 10-1234-5678', label: 'mobile' }],
company: '-주식회사',
department: '@영업',
title: '과장, 팀장',
note: '첫 줄\n둘째 줄',
groupNames: [],
}),
]);
const row = text.split('\r\n')[1];
expect(row).toBe(
`"'=HYPERLINK(""http://x"")",,'+82 10-1234-5678,,,,'-주식회사,'@영업,"과장, 팀장","첫 줄\n둘째 줄",`,
);
});
it("탭 · CR 로 시작하는 칸도 앞에 ' (OWASP CSV Injection) — CR 이 든 칸은 따옴표로 감싼다", () => {
const text = contactsToCsv([
contact({
name: '\t=1+1',
emails: [],
phones: [],
company: '\r=1+1',
department: null,
title: '가\t나',
note: null,
groupNames: [],
}),
]);
// 가운데의 탭은 그대로 — 첫 글자만 본다
expect(text.slice(text.indexOf('\r\n') + 2)).toBe(
`'\t=1+1,,,,,,"'\r=1+1",,가\t나,,\r\n`,
);
});
it('빈 칸은 비운다 · 연락처가 없으면 머리 줄만', () => {
const text = contactsToCsv([
contact({
name: null,
phones: [],
company: null,
department: null,
title: null,
note: null,
groupNames: [],
}),
]);
expect(text.split('\r\n')[1]).toBe(',jihoon.park@hanbit.example,,,,,,,,,');
expect(contactsToCsv([])).toBe(`\uFEFF${CSV_HEADER.join(',')}\r\n`);
});
});
describe('내보내기 — 파일 이름 · 형식', () => {
// 2026-11-10 15:30 UTC = 2026-11-11 00:30 KST
const at = new Date('2026-11-10T15:30:00.000Z');
it('주소록-YYYY-MM-DD(한국 날짜) · 그룹이면 주소록-<그룹 이름>-YYYY-MM-DD', () => {
expect(contactExportFileName('vcard', null, at)).toBe(
'주소록-2026-11-11.vcf',
);
expect(contactExportFileName('csv', '거래처', at)).toBe(
'주소록-거래처-2026-11-11.csv',
);
});
it('그룹 이름의 예약 글자는 _ · 앞뒤 공백과 점은 뗀다 · 남는 것이 없으면 그룹 칸을 빼고 · 50글자까지', () => {
expect(contactExportFileName('csv', ' 팀/파트:A* ', at)).toBe(
'주소록-팀_파트_A_-2026-11-11.csv',
);
expect(contactExportFileName('csv', ' .. ', at)).toBe(
'주소록-2026-11-11.csv',
);
expect(contactExportFileName('vcard', '가'.repeat(80), at)).toBe(
`주소록-${'가'.repeat(50)}-2026-11-11.vcf`,
);
});
it('Content-Type — text/vcard · text/csv (UTF-8)', () => {
expect(contactExportContentType('vcard')).toBe('text/vcard; charset=utf-8');
expect(contactExportContentType('csv')).toBe('text/csv; charset=utf-8');
});
});
@@ -0,0 +1,267 @@
import { kstDateString } from '@/shared/utils/kst-date';
import { MAX_CONTACT_GROUP_NAME_LENGTH } from './contacts.constants';
import type {
ContactEmailLabel,
ContactExportFormat,
ContactFields,
ContactPhoneLabel,
} from './contacts.types';
/**
* 주소록 내보내기 파일 (순수 함수) — 계약 3절 "내보내기" (16단계). 우리 칸만 쓴다(엔진 카드의 다른 칸은 읽지 않는다).
*
* vCard 3.0 (RFC 2426): 줄 끝 CRLF · 한 줄 75바이트에서 접는다(UTF-8 글자를 가르지 않게 — 이어지는 줄은 공백 하나로 시작) ·
* 글 값은 `\` `;` `,` 줄바꿈을 이스케이프. FN(이름 — 없으면 첫 이메일 → 첫 전화번호) · N(이름 전체를 성 칸에) · EMAIL · TEL · ORG(회사;부서) ·
* TITLE · NOTE · CATEGORIES(그룹 이름) · UID.
*
* CSV: UTF-8 BOM · CRLF · 한국어 머리(아래 CSV_HEADER) — 이메일은 종류 없이 모두, 같은 칸에 여럿이면 `; ` 로 잇는다.
* 칸이 `= + - @` · 탭 · CR 로 시작하면 앞에 `'` 를 붙인다(엑셀 수식 막기 — OWASP CSV Injection). 쉼표 · 따옴표 · 줄바꿈이 든 칸은 따옴표로 감싼다(RFC 4180).
* 우리 가져오기(화면)가 이 두 모양을 다시 받는다 — 머리 이름 · 차례를 바꾸면 frontend shared/utils/contact-import 도 함께
*/
/** 내보낼 연락처 하나 — 우리 칸 + 카드의 uid · 속한 그룹 이름(그룹 이름 차례) */
export interface ExportedContact extends ContactFields {
readonly uid: string | null;
readonly groupNames: readonly string[];
}
export const VCARD_CONTENT_TYPE = 'text/vcard; charset=utf-8';
export const CSV_CONTENT_TYPE = 'text/csv; charset=utf-8';
const CRLF = '\r\n';
/** vCard 한 줄의 바이트 상한 (줄 끝 제외 — 이어지는 줄은 앞의 공백까지 센다) */
const VCARD_LINE_OCTETS = 75;
const VCARD_EMAIL_TYPES: Record<ContactEmailLabel, string> = {
work: 'INTERNET,WORK',
personal: 'INTERNET,HOME',
other: 'INTERNET',
};
const VCARD_PHONE_TYPES: Record<ContactPhoneLabel, string> = {
mobile: 'CELL',
work: 'WORK',
home: 'HOME',
other: 'VOICE',
};
/** CSV 머리 — 칸 차례 (계약 3절 "내보내기") */
export const CSV_HEADER = [
'이름',
'이메일',
'휴대폰',
'회사 전화',
'집 전화',
'기타 전화',
'회사',
'부서',
'직함',
'메모',
'그룹',
] as const;
/** 같은 칸에 여럿을 잇는 글 */
const CSV_JOINER = '; ';
/** 엑셀이 수식으로 읽는 첫 글자(OWASP CSV Injection — `= + - @` · 탭 · CR) — 앞에 `'` 를 붙인다 */
const CSV_FORMULA_START = /^[=+\-@\t\r]/;
/** 따옴표로 감쌀 칸 */
const CSV_NEEDS_QUOTES = /[",\r\n]/;
/** UTF-8 BOM(U+FEFF) — 엑셀이 한글을 UTF-8 로 읽게 (소스에 보이지 않는 글자를 두지 않는다) */
const UTF8_BOM = String.fromCharCode(0xfeff);
/** 파일 이름 앞머리 · 형식마다 확장자 */
const FILE_NAME_PREFIX = '주소록';
const FILE_EXTENSIONS: Record<ContactExportFormat, string> = {
vcard: '.vcf',
csv: '.csv',
};
/** 파일 이름에 쓸 수 없는 글자(경로 구분자 · Windows 예약 글자) — '_' 로 */
const FILE_NAME_RESERVED = /[\\/:*?"<>|]/g;
/** 그룹 이름 앞뒤에서 떼는 글자 — 공백과 점 (앞의 점은 숨김 파일, Windows 는 끝의 점을 지운다) */
const FILE_NAME_EDGE = /^[\s.]$/u;
/** vCard 글 값의 이스케이프 — `\` `;` `,` 줄바꿈(\n) (한 번 훑는다) */
function escapeVcardText(value: string): string {
return value.replace(/[\\;,]|\r\n|\r|\n/g, (match) => {
if (match === '\\') return '\\\\';
if (match === ';') return '\\;';
if (match === ',') return '\\,';
return '\\n';
});
}
/** UTF-8 바이트 수 — 코드 포인트 하나 */
function utf8Length(codePoint: number): number {
if (codePoint < 0x80) return 1;
if (codePoint < 0x800) return 2;
if (codePoint < 0x10000) return 3;
return 4;
}
/** 한 줄을 75바이트마다 접는다 — 글자(코드 포인트)를 가르지 않는다. 이어지는 줄은 공백 하나로 시작한다 */
export function foldVcardLine(line: string): string {
const parts: string[] = [];
let current = '';
let bytes = 0;
let limit = VCARD_LINE_OCTETS;
for (const char of line) {
const size = utf8Length(char.codePointAt(0) ?? 0);
if (bytes + size > limit) {
parts.push(current);
current = '';
bytes = 0;
// 이어지는 줄 앞의 공백 하나도 75바이트에 든다
limit = VCARD_LINE_OCTETS - 1;
}
current += char;
bytes += size;
}
parts.push(current);
return parts.join(`${CRLF} `);
}
/**
* 보이는 이름(FN) — 이름, 없으면 첫 이메일, 그것도 없으면 첫 전화번호(vCard 3.0 은 FN 을 꼭 담는다 — 빈 FN 은 받는 쪽이 "이름 없음"으로 보인다).
* N 은 이름 칸 그대로 둔다(이름이 없으면 빈 칸 — 다시 가져올 때 주소를 이름으로 읽지 않게)
*/
function displayNameOf(contact: ExportedContact): string {
return (
contact.name ??
contact.emails[0]?.address ??
contact.phones[0]?.number ??
''
);
}
/** 연락처 하나의 vCard 3.0 */
function vcardOf(contact: ExportedContact): string {
const name = contact.name ?? '';
const lines = ['BEGIN:VCARD', 'VERSION:3.0'];
if (contact.uid !== null) lines.push(`UID:${escapeVcardText(contact.uid)}`);
lines.push(`FN:${escapeVcardText(displayNameOf(contact))}`);
lines.push(`N:${escapeVcardText(name)};;;;`);
for (const email of contact.emails) {
lines.push(
`EMAIL;TYPE=${VCARD_EMAIL_TYPES[email.label]}:${escapeVcardText(email.address)}`,
);
}
for (const phone of contact.phones) {
lines.push(
`TEL;TYPE=${VCARD_PHONE_TYPES[phone.label]}:${escapeVcardText(phone.number)}`,
);
}
if (contact.company !== null || contact.department !== null) {
lines.push(
`ORG:${escapeVcardText(contact.company ?? '')};${escapeVcardText(contact.department ?? '')}`,
);
}
if (contact.title !== null) {
lines.push(`TITLE:${escapeVcardText(contact.title)}`);
}
if (contact.note !== null) {
lines.push(`NOTE:${escapeVcardText(contact.note)}`);
}
if (contact.groupNames.length > 0) {
lines.push(
`CATEGORIES:${contact.groupNames.map(escapeVcardText).join(',')}`,
);
}
lines.push('END:VCARD');
return lines.map((line) => `${foldVcardLine(line)}${CRLF}`).join('');
}
/** vCard 3.0 파일 — 한 파일에 여러 장 (연락처가 없으면 빈 파일) */
export function contactsToVcard(contacts: readonly ExportedContact[]): string {
return contacts.map(vcardOf).join('');
}
/** CSV 한 칸 — 수식 막기 · 필요하면 따옴표 */
function csvCell(value: string): string {
const guarded = CSV_FORMULA_START.test(value) ? `'${value}` : value;
return CSV_NEEDS_QUOTES.test(guarded)
? `"${guarded.replace(/"/g, '""')}"`
: guarded;
}
/** 그 종류의 전화번호들 — 한 칸 */
function phonesOf(contact: ExportedContact, label: ContactPhoneLabel): string {
return contact.phones
.filter((phone) => phone.label === label)
.map((phone) => phone.number)
.join(CSV_JOINER);
}
function csvRow(contact: ExportedContact): string {
return [
contact.name ?? '',
contact.emails.map((email) => email.address).join(CSV_JOINER),
phonesOf(contact, 'mobile'),
phonesOf(contact, 'work'),
phonesOf(contact, 'home'),
phonesOf(contact, 'other'),
contact.company ?? '',
contact.department ?? '',
contact.title ?? '',
contact.note ?? '',
contact.groupNames.join(CSV_JOINER),
]
.map(csvCell)
.join(',');
}
/** CSV 파일 — BOM + 머리 줄 + 연락처마다 한 줄 (연락처가 없으면 머리 줄만) */
export function contactsToCsv(contacts: readonly ExportedContact[]): string {
const rows = [CSV_HEADER.join(','), ...contacts.map(csvRow)];
return `${UTF8_BOM}${rows.map((row) => `${row}${CRLF}`).join('')}`;
}
/** 앞뒤의 공백 · 점을 뗀다 — 반복 정규식 없이 한 번 훑는다(길이에 비례) */
function trimEdges(chars: readonly string[]): string[] {
let start = 0;
let end = chars.length;
while (start < end && FILE_NAME_EDGE.test(chars[start])) start += 1;
while (end > start && FILE_NAME_EDGE.test(chars[end - 1])) end -= 1;
return chars.slice(start, end);
}
/** 파일 이름의 그룹 이름 — 예약 글자는 '_', 앞뒤 공백 · 점은 떼고 50글자(코드 포인트)까지. 남는 것이 없으면 null */
function groupNamePart(name: string): string | null {
const chars = trimEdges(Array.from(name.replace(FILE_NAME_RESERVED, '_')));
const cleaned = trimEdges(chars.slice(0, MAX_CONTACT_GROUP_NAME_LENGTH)).join(
'',
);
return cleaned === '' ? null : cleaned;
}
/**
* 내려받을 파일 이름 — `주소록-YYYY-MM-DD.vcf` · `주소록-<그룹 이름>-YYYY-MM-DD.csv` (날짜는 한국 날짜).
* 헤더 인코딩은 shared/utils/content-disposition.ts 가 한다
*/
export function contactExportFileName(
format: ContactExportFormat,
groupName: string | null,
at: Date,
): string {
const group = groupName === null ? null : groupNamePart(groupName);
const parts = [
FILE_NAME_PREFIX,
...(group ? [group] : []),
kstDateString(at),
];
return `${parts.join('-')}${FILE_EXTENSIONS[format]}`;
}
/** 형식마다 Content-Type */
export function contactExportContentType(format: ContactExportFormat): string {
return format === 'vcard' ? VCARD_CONTENT_TYPE : CSV_CONTENT_TYPE;
}
/** 형식마다 파일 내용 */
export function contactExportContent(
format: ContactExportFormat,
contacts: readonly ExportedContact[],
): string {
return format === 'vcard'
? contactsToVcard(contacts)
: contactsToCsv(contacts);
}
@@ -0,0 +1,90 @@
import {
Controller,
Get,
HttpStatus,
Query,
Res,
StreamableFile,
UseGuards,
} from '@nestjs/common';
import {
ApiCookieAuth,
ApiOperation,
ApiProduces,
ApiResponse,
ApiTags,
} from '@nestjs/swagger';
import type { Response } from 'express';
import {
ApiErrorResponse,
ApiSessionErrors,
} from '@/common/decorators/api-standard-response.decorator';
import {
CurrentUser,
type AuthenticatedUser,
} from '@/common/decorators/current-user.decorator';
import { SkipTransform } from '@/common/decorators/skip-transform.decorator';
import { JwtAuthGuard } from '@/common/guards/jwt-auth.guard';
import { buildAttachmentDisposition } from '@/shared/utils/content-disposition';
import {
DOWNLOAD_CACHE_CONTROL,
DOWNLOAD_CSP,
} from '@/shared/utils/download-headers';
import { CSV_CONTENT_TYPE, VCARD_CONTENT_TYPE } from './contact-export';
import { CONTACT_EXPORTS_ROUTE } from './contacts.constants';
import { ContactsService } from './contacts.service';
import { ContactExportQueryDto } from './dto/contact-export.dto';
// 내 주소록 — 내보내기 (계약 3절 "내보내기", 16단계). 파일 응답이라 표준 래퍼 없이 내려준다 (실패는 표준 실패 JSON)
@ApiTags('Contacts')
@ApiCookieAuth()
@UseGuards(JwtAuthGuard)
@ApiSessionErrors('로그인 필요 (쿠키 없음·만료)')
@ApiErrorResponse('MAIL_001', '메일 엔진 장애')
@Controller(CONTACT_EXPORTS_ROUTE)
export class ContactExportsController {
constructor(private readonly contacts: ContactsService) {}
@Get()
@SkipTransform()
@ApiOperation({
summary: '주소록 내보내기 — vCard 3.0 · CSV 파일 (표준 래퍼 없음)',
description:
'내 주소록 전체 또는 그룹 하나(groupId)를 이름 차례로. 사내 목록은 넣지 않는다. 우리 칸만(엔진 카드의 다른 칸 — 주소 · 생일 · 사진 등은 넣지 않는다). ' +
'vcard: vCard 3.0 · CRLF · 75바이트 줄 접기 · FN(이름 — 없으면 첫 이메일 → 첫 전화번호) · N(이름 전체를 성 칸에) · EMAIL(TYPE=INTERNET,WORK / INTERNET,HOME / INTERNET) · TEL(CELL / WORK / HOME / VOICE) · ' +
'ORG(회사;부서) · TITLE · NOTE · CATEGORIES(그룹 이름) · UID. ' +
'csv: UTF-8 BOM · CRLF · 머리 「이름,이메일,휴대폰,회사 전화,집 전화,기타 전화,회사,부서,직함,메모,그룹」 — 같은 칸에 여럿이면 "; " 로 잇고, ' +
"= + - @ · 탭 · CR 로 시작하는 칸은 앞에 ' 를 붙인다(엑셀 수식 막기 — OWASP CSV Injection). " +
'파일 이름 「주소록-YYYY-MM-DD.vcf」 · 「주소록-<그룹 이름>-YYYY-MM-DD.csv」(한국 날짜) — Content-Disposition: attachment. Cache-Control: private, no-store. ' +
'화면은 첨부 받기처럼 이동 직전에 세션을 확인하고 <a href download> 로 받는다.',
})
@ApiProduces(VCARD_CONTENT_TYPE, CSV_CONTENT_TYPE)
@ApiResponse({
status: HttpStatus.OK,
description: '내보낸 파일 (.vcf · .csv)',
schema: { type: 'string', format: 'binary' },
})
@ApiErrorResponse('VAL_001', '모르는 형식 · 그룹 id 모양 · 모르는 쿼리')
@ApiErrorResponse('RES_001', '없는 groupId')
async export(
@CurrentUser() user: AuthenticatedUser,
@Query() query: ContactExportQueryDto,
@Res({ passthrough: true }) res: Response,
): Promise<StreamableFile> {
const file = await this.contacts.exportContacts(
user,
query.format,
query.groupId,
);
// 개인 정보 — 남기지 않는다. 브라우저가 열지 않고 내려받게(형식 짐작 막기 · 열어도 스크립트가 돌지 않게)
res.setHeader('Cache-Control', DOWNLOAD_CACHE_CONTROL);
res.setHeader('X-Content-Type-Options', 'nosniff');
res.setHeader('Content-Security-Policy', DOWNLOAD_CSP);
return new StreamableFile(file.content, {
type: file.contentType,
disposition: buildAttachmentDisposition(file.fileName),
length: file.content.length,
});
}
}
@@ -0,0 +1,171 @@
import {
deletionChunkSize,
importDuplicateIndex,
isExistingContact,
namePhonesKey,
phoneDigitsKey,
planGroupJoins,
type GroupRoom,
} from './contact-import';
import type { ContactFields, ContactPhone } from './contacts.types';
const mobile = (number: string): ContactPhone => ({ number, label: 'mobile' });
const person = (overrides: Partial<ContactFields> = {}) => ({
name: null,
emails: [],
phones: [],
...overrides,
});
describe('가져오기 — 이미 있는 연락처 (계약 3절 "가져오기")', () => {
it('전화 숫자 모음 — 숫자만 남기고 겹침 · 차례를 무시한다, 숫자가 없는 번호는 뺀다', () => {
expect(
phoneDigitsKey([
mobile('010-1234-5678'),
{ number: '(02) 555-0100', label: 'work' },
mobile('01012345678'),
mobile('#*'),
]),
).toBe('01012345678,025550100');
expect(phoneDigitsKey([])).toBe('');
});
it('이름 + 전화 열쇠 — 이름은 앞뒤 공백만 뺀다(가운데 공백 · 대소문자는 그대로), 이름이 없으면 null', () => {
expect(namePhonesKey({ name: ' 김아라 ', phones: [] })).toBe(
namePhonesKey({ name: '김아라', phones: [] }),
);
expect(namePhonesKey({ name: '김 아라', phones: [] })).not.toBe(
namePhonesKey({ name: '김아라', phones: [] }),
);
expect(namePhonesKey({ name: 'Ara', phones: [] })).not.toBe(
namePhonesKey({ name: 'ara', phones: [] }),
);
expect(namePhonesKey({ name: null, phones: [mobile('010')] })).toBeNull();
expect(namePhonesKey({ name: ' ', phones: [] })).toBeNull();
});
it('이메일이 있는 항목 — 그 가운데 하나라도 주소록에 있으면(앞뒤 공백 · 대소문자 무시) 이미 있음', () => {
const index = importDuplicateIndex([
person({
name: '박지훈',
emails: [{ address: 'Jihoon@Hanbit.example', label: 'work' }],
}),
]);
expect(
isExistingContact(
index,
person({
emails: [
{ address: 'new@x.example', label: 'work' },
{ address: ' jihoon@hanbit.EXAMPLE ', label: 'other' },
],
}),
),
).toBe(true);
// 이메일이 있으면 이름 · 전화는 보지 않는다
expect(
isExistingContact(
index,
person({
name: '박지훈',
emails: [{ address: 'other@x.example', label: 'work' }],
}),
),
).toBe(false);
});
it('이메일이 없는 항목 — 이름과 전화 숫자 모음이 모두 같은 연락처가 있으면 이미 있음 (주소록 쪽은 이메일이 있어도 된다)', () => {
const index = importDuplicateIndex([
person({
name: '김철수',
emails: [{ address: 'kim@x.example', label: 'work' }],
phones: [
mobile('010-1111-2222'),
{ number: '02 555 0100', label: 'work' },
],
}),
person({ name: '이영희' }),
]);
expect(
isExistingContact(
index,
person({
name: '김철수 ',
phones: [
{ number: '0255501 00', label: 'other' },
mobile('01011112222'),
],
}),
),
).toBe(true);
// 번호 하나만 같으면 다른 사람으로 본다
expect(
isExistingContact(
index,
person({ name: '김철수', phones: [mobile('010-1111-2222')] }),
),
).toBe(false);
// 둘 다 전화가 없으면 이름만 같아도
expect(isExistingContact(index, person({ name: '이영희' }))).toBe(true);
expect(isExistingContact(index, person({ name: '이영희2' }))).toBe(false);
});
});
describe('가져오기 — 그룹 넣기 계획', () => {
const room = (members: string[], count = members.length): GroupRoom => ({
memberUids: new Set(members),
memberCount: count,
});
it('보낸 차례대로 넣고, 한도에 닿은 그룹은 그 뒤 사람만 빼고(skipped) 나머지 그룹에는 넣는다', () => {
const rooms = new Map([
['g1', room(['u-old'], 2)],
['g2', room([])],
]);
const plan = planGroupJoins(
rooms,
[
{ uid: 'a', groupIds: ['g1', 'g2'] },
{ uid: 'b', groupIds: ['g1', 'g2'] },
{ uid: 'c', groupIds: ['g2'] },
],
3,
);
expect(plan.adds.get('g1')).toEqual(['a']);
expect(plan.adds.get('g2')).toEqual(['a', 'b', 'c']);
expect(plan.skipped.get('b')).toEqual(['g1']);
expect(plan.skipped.has('a')).toBe(false);
});
it('이미 구성원인 uid 는 세지도 넣지도 않는다 · 모르는 그룹은 건너뛴다', () => {
const plan = planGroupJoins(
new Map([['g1', room(['a'], 3)]]),
[
{ uid: 'a', groupIds: ['g1', 'nope'] },
{ uid: 'b', groupIds: ['g1'] },
],
3,
);
expect(plan.adds.get('g1')).toBeUndefined();
expect(plan.skipped.get('b')).toEqual(['g1']);
expect(plan.skipped.has('a')).toBe(false);
});
});
describe('한꺼번에 지우기 — 한 번 몫', () => {
it('지울 연락처 수 + 고칠 그룹 수(겹침 없이)가 상한을 넘지 않게 앞에서부터', () => {
expect(deletionChunkSize([[], [], []], 500)).toBe(3);
expect(deletionChunkSize([['g1'], ['g1'], ['g2']], 4)).toBe(2);
expect(deletionChunkSize([['g1'], ['g1'], ['g1']], 4)).toBe(3);
expect(
deletionChunkSize(
Array.from({ length: 500 }, () => ['g1']),
500,
),
).toBe(499);
// 첫 사람이 홀로 넘어도 한 명은 지운다
expect(deletionChunkSize([['a', 'b', 'c']], 2)).toBe(1);
expect(deletionChunkSize([], 500)).toBe(0);
});
});
@@ -0,0 +1,138 @@
import { emailKey } from './address-book';
import type { ContactFields, ContactPhone } from './contacts.types';
/**
* 가져오기 · 한꺼번에 지우기의 순수 함수 (계약 3절 "가져오기 · 내보내기" — 16단계).
*
* "이미 있는 연락처" 규칙 — 화면이 같은 파일 안의 겹침을 **같은 규칙**으로 본다(frontend shared/utils/contact-import — 한쪽을 고치면 둘 다):
* - 이메일이 있는 항목: 그 이메일 가운데 하나라도(앞뒤 공백을 빼고 소문자 — emailKey) 내 주소록의 어느 연락처에 있으면 이미 있음
* - 이메일이 없는 항목: 이름(앞뒤 공백을 뺀 글)이 같고 전화번호의 숫자 모음(숫자만 남긴 번호들 — 차례 · 겹침 무시)이 같은 연락처가 있으면 이미 있음
* (주소록 쪽 연락처는 이메일이 있어도 된다. 둘 다 전화가 없으면 이름만 같아도 이미 있음)
*/
const NON_DIGITS = /\D+/g;
/** 이름과 전화 숫자 모음을 잇는 글 — 이름 칸에 올 수 없는 제어 문자 */
const KEY_SEPARATOR = '\u0000';
/** 전화번호들의 숫자 모음 — 숫자만 남긴 번호를 겹침 없이 차례로 (숫자가 없는 번호는 뺀다) */
export function phoneDigitsKey(phones: readonly ContactPhone[]): string {
const digits = new Set<string>();
for (const phone of phones) {
const only = phone.number.replace(NON_DIGITS, '');
if (only !== '') digits.add(only);
}
return [...digits].sort().join(',');
}
/** 이메일이 없는 항목을 견주는 열쇠 — 이름(앞뒤 공백을 뺀 글) + 전화 숫자 모음. 이름이 없으면 null */
export function namePhonesKey(
fields: Pick<ContactFields, 'name' | 'phones'>,
): string | null {
const name = fields.name?.trim() ?? '';
return name === ''
? null
: `${name}${KEY_SEPARATOR}${phoneDigitsKey(fields.phones)}`;
}
/** 내 주소록의 "이미 있음" 찾기 자료 — 이메일 모음 · 이름 + 전화 열쇠 모음 */
export interface ImportDuplicateIndex {
readonly emails: ReadonlySet<string>;
readonly namePhones: ReadonlySet<string>;
}
/** 주소록의 연락처들로 찾기 자료를 만든다 — 연락처 수에 비례 */
export function importDuplicateIndex(
contacts: readonly Pick<ContactFields, 'name' | 'emails' | 'phones'>[],
): ImportDuplicateIndex {
const emails = new Set<string>();
const namePhones = new Set<string>();
for (const contact of contacts) {
for (const email of contact.emails) emails.add(emailKey(email.address));
const key = namePhonesKey(contact);
if (key !== null) namePhones.add(key);
}
return { emails, namePhones };
}
/** 이 항목이 내 주소록에 이미 있는가 (위 규칙) */
export function isExistingContact(
index: ImportDuplicateIndex,
item: Pick<ContactFields, 'name' | 'emails' | 'phones'>,
): boolean {
if (item.emails.length > 0) {
return item.emails.some((email) =>
index.emails.has(emailKey(email.address)),
);
}
const key = namePhonesKey(item);
return key !== null && index.namePhones.has(key);
}
/** 그룹 하나의 지금 자리 — 구성원 uid(이 주소록에 없는 카드를 가리키는 것 포함)와 센 구성원 수(이 주소록에 있는 연락처만) */
export interface GroupRoom {
readonly memberUids: ReadonlySet<string>;
readonly memberCount: number;
}
/** 그룹에 넣을 사람 하나 — uid 와 넣을 그룹 */
export interface GroupJoin {
readonly uid: string;
readonly groupIds: readonly string[];
}
export interface GroupJoinPlan {
/** 그룹 id → 새로 넣을 uid (넣는 차례) */
readonly adds: ReadonlyMap<string, readonly string[]>;
/** uid → 구성원 수 한도가 차서 넣지 못한 그룹 id (보낸 차례) */
readonly skipped: ReadonlyMap<string, readonly string[]>;
}
/**
* 가져온 사람들을 그룹에 넣는 계획 — 보낸 차례대로 넣고, 그룹이 max 명에 닿으면 그 뒤 사람은 그 그룹만 빼고(skipped) 나머지 그룹에는 넣는다.
* 이미 구성원인 uid 는 세지도 넣지도 않는다. 모르는 그룹 id 는 부르는 쪽이 먼저 막는다(여기서는 건너뛴다)
*/
export function planGroupJoins(
rooms: ReadonlyMap<string, GroupRoom>,
joins: readonly GroupJoin[],
max: number,
): GroupJoinPlan {
const counts = new Map<string, number>();
const adds = new Map<string, string[]>();
const skipped = new Map<string, string[]>();
for (const join of joins) {
for (const groupId of join.groupIds) {
const room = rooms.get(groupId);
if (!room) continue;
const added = adds.get(groupId) ?? [];
if (room.memberUids.has(join.uid) || added.includes(join.uid)) continue;
const count = counts.get(groupId) ?? room.memberCount;
if (count >= max) {
skipped.set(join.uid, [...(skipped.get(join.uid) ?? []), groupId]);
continue;
}
counts.set(groupId, count + 1);
added.push(join.uid);
adds.set(groupId, added);
}
}
return { adds, skipped };
}
/**
* 한꺼번에 지우기의 한 번 몫 — 앞에서부터 몇 명을 지울지. 지울 연락처 수 + 고칠 그룹 수(그 연락처가 든 그룹 — 겹침 없이)가 max 를 넘지 않게
* (엔진 한 요청의 카드 수 상한). 첫 사람이 홀로 넘어도 한 명은 지운다(그런 주소록은 없다 — 그룹 100개)
*/
export function deletionChunkSize(
groupsOf: readonly (readonly string[])[],
max: number,
): number {
const groups = new Set<string>();
let size = 0;
for (const ids of groupsOf) {
const fresh = ids.filter((id) => !groups.has(id));
if (size > 0 && size + 1 + groups.size + fresh.length > max) break;
for (const id of fresh) groups.add(id);
size += 1;
}
return size;
}
@@ -0,0 +1,149 @@
import {
Body,
Controller,
HttpCode,
HttpStatus,
Post,
UseGuards,
} from '@nestjs/common';
import { ApiCookieAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import {
ApiErrorResponse,
ApiSessionErrors,
ApiStandardResponse,
} from '@/common/decorators/api-standard-response.decorator';
import {
CurrentUser,
type AuthenticatedUser,
} from '@/common/decorators/current-user.decorator';
import { JwtAuthGuard } from '@/common/guards/jwt-auth.guard';
import {
CONTACT_DELETIONS_ROUTE,
CONTACT_IMPORT_CHECKS_ROUTE,
CONTACT_IMPORTS_ROUTE,
MAX_CONTACT_DELETIONS,
MAX_CONTACT_GROUP_MEMBERS,
MAX_CONTACT_IMPORT_BATCH,
MAX_CONTACTS,
} from './contacts.constants';
import { ContactsService } from './contacts.service';
import {
ContactDeletionDto,
ContactImportBatchDto,
ContactImportCheckDto,
ContactImportResultDto,
ContactsDeletedDto,
} from './dto/contact-import.dto';
const ITEM_RULES =
`한 사람의 칸 규칙은 연락처 추가(POST /api/contacts)와 같고 uid(urn:uuid: + 소문자 UUID — 화면이 만든다)를 더한다. ` +
`${MAX_CONTACT_IMPORT_BATCH}명까지 · 한 묶음 안의 같은 uid · 이름도 이메일도 없는 사람 · 같은 연락처 안의 같은 주소는 400 VAL_001(하나라도 걸리면 묶음 전체).`;
// 내 주소록 — 가져오기 확인 (계약 3절 "가져오기", 16단계). 파일은 화면이 읽는다 — 서버는 연락처 칸만 받는다
@ApiTags('Contacts')
@ApiCookieAuth()
@UseGuards(JwtAuthGuard)
@ApiSessionErrors('로그인 필요 (쿠키 없음·만료)')
@ApiErrorResponse('MAIL_001', '메일 엔진 장애')
@Controller(CONTACT_IMPORT_CHECKS_ROUTE)
export class ContactImportChecksController {
constructor(private readonly contacts: ContactsService) {}
@Post()
@HttpCode(HttpStatus.OK)
@ApiOperation({
summary: '가져올 연락처가 이미 있는지 확인 (만들지 않는다)',
description:
'사람마다 new · exists — 같은 이메일(앞뒤 공백 · 대소문자 무시)이 내 주소록에 있으면, 이메일이 없으면 이름(앞뒤 공백을 뺀 글)과 전화번호의 숫자 모음이 모두 같은 연락처가 있으면, ' +
`또는 같은 uid 의 연락처가 있으면 exists. remaining 은 더 넣을 수 있는 수(${MAX_CONTACTS.toLocaleString('en-US')} − 지금 연락처 수). ` +
`같은 파일 안의 겹침은 화면이 같은 규칙으로 본다. groupIds 는 보지 않는다. ${ITEM_RULES}`,
})
@ApiStandardResponse(ContactImportCheckDto, {
description: '사람마다 판정과 남은 자리',
})
@ApiErrorResponse(
'VAL_001',
'형식 · 길이 · 개수 · uid 모양 · 같은 uid 두 번 · 이름도 이메일도 없음 · 같은 주소 두 번 · 모르는 필드',
)
check(
@CurrentUser() user: AuthenticatedUser,
@Body() dto: ContactImportBatchDto,
) {
return this.contacts.checkImport(user, dto.contacts);
}
}
// 내 주소록 — 가져오기 넣기 (계약 3절 "가져오기", 16단계). 엔진 요청 하나에 묶음을 만든다
@ApiTags('Contacts')
@ApiCookieAuth()
@UseGuards(JwtAuthGuard)
@ApiSessionErrors('로그인 필요 (쿠키 없음·만료)')
@ApiErrorResponse('MAIL_001', '메일 엔진 장애')
@Controller(CONTACT_IMPORTS_ROUTE)
export class ContactImportsController {
constructor(private readonly contacts: ContactsService) {}
@Post()
@ApiOperation({
summary: '가져온 연락처 넣기 (100명씩)',
description:
'줄마다 보낸 uid 로 만든다 — 같은 uid 의 연락처가 이미 있으면(다시 보낸 묶음) 새로 만들지 않고 그 id 를 돌려준다(빠진 그룹만 마저 넣는다). ' +
`연락처 ${MAX_CONTACTS.toLocaleString('en-US')}개 · 크기 한도는 묶음 전체로 미리 본다 — 넘으면 409 CONTACT_002, 아무것도 만들지 않는다. ` +
`그룹 구성원 ${MAX_CONTACT_GROUP_MEMBERS}명을 넘기는 넣기는 그 그룹만 빼고 만들고 skippedGroupIds 에 적는다(실패가 아니다). ` +
'메일 서버가 일부만 받으면 받은 것만 items 에 — 빠진 uid 는 넣지 못한 것이다. 겹침(같은 이메일 등)은 보지 않는다(확인 경로의 몫). ' +
ITEM_RULES,
})
@ApiStandardResponse(ContactImportResultDto, {
status: 201,
description: '넣은 연락처(같은 uid 로 이미 있던 것 포함)',
})
@ApiErrorResponse(
'VAL_001',
'형식 · 길이 · 개수 · uid 모양 · 같은 uid 두 번 · 이름도 이메일도 없음 · 같은 주소 두 번 · 모르는 필드',
)
@ApiErrorResponse('RES_001', '없는 groupId')
@ApiErrorResponse(
'CONTACT_002',
`연락처 ${MAX_CONTACTS.toLocaleString('en-US')}개 · 크기 한도 — 묶음 전체로 보고 아무것도 만들지 않았다`,
)
importContacts(
@CurrentUser() user: AuthenticatedUser,
@Body() dto: ContactImportBatchDto,
) {
return this.contacts.importContacts(user, dto.contacts);
}
}
// 내 주소록 — 연락처 한꺼번에 지우기 (가져오기 [되돌리기] — 계약 3절 "가져오기", 16단계)
@ApiTags('Contacts')
@ApiCookieAuth()
@UseGuards(JwtAuthGuard)
@ApiSessionErrors('로그인 필요 (쿠키 없음·만료)')
@ApiErrorResponse('MAIL_001', '메일 엔진 장애')
@Controller(CONTACT_DELETIONS_ROUTE)
export class ContactDeletionsController {
constructor(private readonly contacts: ContactsService) {}
@Post()
@HttpCode(HttpStatus.OK)
@ApiOperation({
summary: '연락처 한꺼번에 지우기 (영구 삭제 · 되돌리기 표 없음)',
description:
`1~${MAX_CONTACT_DELETIONS}개. 지운 연락처를 가리키는 그룹 구성원도 함께 뺀다. 내 주소록에 없는 id(이미 지움 · 그룹 id)는 notFoundIds — 실패가 아니다. ` +
'메일 서버 한 요청의 상한을 넘으면 서버가 나눠 지운다 — 중간에 실패하면(503) 앞 몫은 지워진 채다(같은 목록을 다시 보내면 notFoundIds 로 나온다).',
})
@ApiStandardResponse(ContactsDeletedDto, {
description: '지운 id · 없던 id (보낸 차례)',
})
@ApiErrorResponse(
'VAL_001',
'형식 · 개수 · id 모양 · 같은 id 두 번 · 모르는 필드',
)
remove(
@CurrentUser() user: AuthenticatedUser,
@Body() dto: ContactDeletionDto,
) {
return this.contacts.removeContacts(user, dto.ids);
}
}
@@ -14,6 +14,28 @@ export const CONTACT_GROUPS_ROUTE = 'contact-groups';
export const CONTACT_RESTORATIONS_ROUTE = 'contact-restorations';
export const DIRECTORY_ENTRIES_ROUTE = 'directory-entries';
/**
* 16단계 라우트 — 가져오기의 확인 · 넣기, 한꺼번에 지우기(가져오기 [되돌리기]), 내보내기 (계약 3절 "가져오기 · 내보내기")
* (`/api/contact-import-checks` · `/api/contact-imports` · `/api/contact-deletions` · `/api/contact-exports`)
*/
export const CONTACT_IMPORT_CHECKS_ROUTE = 'contact-import-checks';
export const CONTACT_IMPORTS_ROUTE = 'contact-imports';
export const CONTACT_DELETIONS_ROUTE = 'contact-deletions';
export const CONTACT_EXPORTS_ROUTE = 'contact-exports';
/** 가져오기 · 확인 한 요청의 연락처 수 — 1~100 (화면은 100명씩 차례로 보낸다) */
export const MAX_CONTACT_IMPORT_BATCH = 100;
/** 한꺼번에 지우기 한 요청의 id 수 — 1~500 */
export const MAX_CONTACT_DELETIONS = 500;
/**
* 엔진 한 번의 바꾸기(ContactCard/set)에 담는 카드 수 — 만들기 · 고치기 · 지우기를 모두 센다.
* 엔진의 maxObjectsInSet(500 — 2026-11-10 세션 능력 확인)과 같다. 한꺼번에 지우기는 그룹 고치기와 합쳐 이것을 넘으면 나눠 쓴다
*/
export const MAX_CARD_CHANGES_PER_WRITE = 500;
/** 가져오기 항목의 uid — `urn:uuid:` + 소문자 UUID (화면이 만든다 — 다시 보낸 요청이 두 번 들어가지 않게) */
export const CONTACT_IMPORT_UID_PATTERN =
/^urn:uuid:[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
/**
* 주소록 한도 — 넘으면 409 CONTACT_002 (아무것도 바꾸지 않는다).
* ⚠️ CONTACT_002 의 문구(common/errors/error-codes.ts)에 같은 숫자가 있다 — contacts.constants.spec.ts 가 맞는지 본다
@@ -3,7 +3,13 @@ import { Module } from '@nestjs/common';
import { JmapContactsProvider } from '@/modules/mail/jmap/jmap-contacts.provider';
import { JmapDirectoryProvider } from '@/modules/mail/jmap/jmap-directory.provider';
import { MailEngineModule } from '@/modules/mail/mail-engine.module';
import { ContactExportsController } from './contact-exports.controller';
import { ContactGroupsController } from './contact-groups.controller';
import {
ContactDeletionsController,
ContactImportChecksController,
ContactImportsController,
} from './contact-imports.controller';
import { ContactRestorationsController } from './contact-restorations.controller';
import { ContactsController } from './contacts.controller';
import { CONTACTS_PROVIDER, DIRECTORY_PROVIDER } from './contacts.provider';
@@ -25,6 +31,11 @@ import { DirectoryService } from './directory.service';
ContactGroupsController,
ContactRestorationsController,
DirectoryEntriesController,
// 16단계 — 가져오기 확인 · 넣기 · 한꺼번에 지우기 · 내보내기
ContactImportChecksController,
ContactImportsController,
ContactDeletionsController,
ContactExportsController,
],
providers: [
{ provide: CONTACTS_PROVIDER, useClass: JmapContactsProvider },
@@ -103,16 +103,30 @@ export type AddressBookCardUpdate =
readonly memberUids?: readonly string[];
};
/** 한 번에 하는 바꾸기 — 엔진 요청 하나 (만들기 → 고치기 → 지우기 차례로 한다) */
/**
* 한 번에 하는 바꾸기 — 엔진 요청 하나 (만들기 → 고치기 → 지우기 차례로 한다).
* 카드 수(만들기 + 고치기 + 지우기)는 서비스가 MAX_CARD_CHANGES_PER_WRITE(엔진의 한 요청 상한) 안으로 맞춘다
*/
export interface AddressBookChanges {
readonly create: readonly NewAddressBookCard[];
readonly update: readonly AddressBookCardUpdate[];
readonly destroy: readonly string[];
}
/** 바꾸기의 선택 (16단계 — 가져오기 · 한꺼번에 지우기) */
export interface AddressBookWriteOptions {
/**
* 엔진이 받지 않은 **만들기**를 던지지 않고 결과(written 의 refusedKeys)에 적는다 — 가져오기.
* 받은 만들기 · 고치기는 들어간다. 고치기 · 지우기의 거절과 동시 고치기 거절(busy)은 이 선택과 상관없이 지금처럼 다룬다
*/
readonly allowPartialCreate?: boolean;
/** 지울 카드의 원본(되돌리기 표)을 읽지 않는다 — 한꺼번에 지우기. 그 판에 카드가 있는지는 그대로 본다(없으면 conflict) */
readonly withoutOriginals?: boolean;
}
/**
* 바꾸기의 결과
* - written: 모두 했다 — 새 판(엔진이 알려 주지 않으면 null) · 만든 카드의 id(key → id, 만든 것은 모두 있다) ·
* - written: 모두 했다 — 새 판(엔진이 알려 주지 않으면 null) · 만든 카드의 id(key → id, 만든 것은 모두 있다 — allowPartialCreate 면 refusedKeys 를 뺀 것) ·
* 만들거나 고친 연락처 · 만든 그룹의 지금 모양(id → 카드 — 엔진에 보낸 카드를 읽은 것, 그룹 고치기는 넣지 않는다) · 지운 카드의 원본(id → 원본)
* - conflict: 받은 판과 엔진의 지금 판이 달라 아무것도 하지 않았다 (그사이 다른 곳에서 바뀌었다 — 다시 읽고 다시 한다)
* - busy: 엔진이 같은 카드의 동시 고치기를 거절했다(serverUnavailable) — 일부가 들어갔을 수 있다. 다시 읽고 다시 한다
@@ -125,6 +139,8 @@ export type AddressBookWriteOutcome =
readonly createdIds: ReadonlyMap<string, string>;
readonly cards: ReadonlyMap<string, AddressBookCard>;
readonly removed: ReadonlyMap<string, RemovedCardOriginal>;
/** 엔진이 받지 않은 만들기의 key — allowPartialCreate 로 쓸 때만 있다(없으면 모두 받았다) */
readonly refusedKeys?: ReadonlySet<string>;
}
| { readonly kind: 'conflict' }
| {
@@ -143,12 +159,13 @@ export interface ContactsProvider {
* 바꾸기를 한 번에 — 받은 판(version)이 엔진의 지금 판과 같을 때만 한다(아니면 conflict, 아무것도 하지 않음).
* 고칠 연락처 · 지울 카드는 그 판 그대로 먼저 읽는다(칸 안의 남의 정보를 지키고 · 되돌리기 원본을 남기려고 — 그사이 바뀌었으면 conflict).
* 엔진이 동시 고치기를 거절하면 busy. 엔진이 일부를 받지 않으면 그 코드로 던진다(엔진 오류 종류에 따라 VAL_001 · MAIL_001 등 —
* 받은 것은 이미 바뀌었을 수 있다). 주소록이 없는데 만들 카드가 있으면 MAIL_001
* 받은 것은 이미 바뀌었을 수 있다 — options.allowPartialCreate 면 받지 않은 만들기만은 던지지 않고 결과에 적는다). 주소록이 없는데 만들 카드가 있으면 MAIL_001
*/
writeAddressBook(
user: string,
version: string,
changes: AddressBookChanges,
options?: AddressBookWriteOptions,
): Promise<AddressBookWriteOutcome>;
}
@@ -2,11 +2,14 @@ import { Logger } from '@nestjs/common';
import type { AuthenticatedUser } from '@/common/decorators/current-user.decorator';
import { BusinessException } from '@/common/errors/business.exception';
import { kstDateString } from '@/shared/utils/kst-date';
import { cardWeight } from './address-book';
import {
CONTACT_RESTORE_MAX_CARD_BYTES,
CONTACT_RESTORE_TTL_MS,
MAX_ADDRESS_BOOK_WEIGHT,
MAX_CARD_CHANGES_PER_WRITE,
MAX_CONTACT_DELETIONS,
MAX_CONTACT_GROUP_MEMBERS,
MAX_CONTACT_GROUPS,
MAX_CONTACTS,
@@ -17,6 +20,7 @@ import type {
AddressBookContactCard,
AddressBookGroupCard,
AddressBookSnapshot,
AddressBookWriteOptions,
AddressBookWriteOutcome,
ContactsProvider,
RemovedCardOriginal,
@@ -24,6 +28,7 @@ import type {
import { ContactsService } from './contacts.service';
import type {
ContactFields,
ContactImportItem,
ContactInput,
DirectoryEntry,
} from './contacts.types';
@@ -61,6 +66,10 @@ class FakeAddressBook implements ContactsProvider {
failure: BusinessException | null = null;
/** 다음 쓰기들이 busy — lost 는 아무것도 들어가지 않음, applied 는 들어간 뒤 busy (서비스는 결과를 모른다) */
readonly busy: ('lost' | 'applied')[] = [];
/** 엔진이 만들기를 받지 않을 uid (16단계 — 가져오기의 일부만 받기) */
readonly refuseUids = new Set<string>();
/** 쓰기마다 받은 선택 */
readonly writeOptions: (AddressBookWriteOptions | undefined)[] = [];
private nextId = 1;
readonly getAddressBookVersion = jest.fn(() =>
@@ -77,7 +86,9 @@ class FakeAddressBook implements ContactsProvider {
_user: string,
version: string,
changes: AddressBookChanges,
options?: AddressBookWriteOptions,
): Promise<AddressBookWriteOutcome> => {
this.writeOptions.push(options);
if (this.outsideChanges > 0) {
this.outsideChanges -= 1;
this.state += 1;
@@ -91,14 +102,30 @@ class FakeAddressBook implements ContactsProvider {
this.state += 1;
return Promise.reject(failure);
}
const refusedKeys = new Set(
changes.create
.filter((card) => this.refuseUids.has(card.uid))
.map((card) => card.key),
);
if (refusedKeys.size > 0 && !options?.allowPartialCreate) {
// 엔진이 받지 않은 만들기 — 일부만 받기가 아니면 그 코드로 던진다(받은 것은 들어갔다)
this.apply(changes, refusedKeys);
return Promise.reject(
new BusinessException('VAL_001', {
context: { reason: 'address-book-change-refused' },
}),
);
}
const removed = new Map<string, RemovedCardOriginal>();
for (const id of changes.destroy) {
const card = this.contacts.get(id) ?? this.groups.get(id);
if (card) removed.set(id, this.original(card));
if (!options?.withoutOriginals) {
for (const id of changes.destroy) {
const card = this.contacts.get(id) ?? this.groups.get(id);
if (card) removed.set(id, this.original(card));
}
}
const mode = this.busy.shift();
if (mode === 'lost') return Promise.resolve({ kind: 'busy', removed });
const { createdIds, cards } = this.apply(changes);
const { createdIds, cards } = this.apply(changes, refusedKeys);
if (mode === 'applied') {
return Promise.resolve({ kind: 'busy', removed });
}
@@ -108,6 +135,7 @@ class FakeAddressBook implements ContactsProvider {
createdIds,
cards,
removed,
...(options?.allowPartialCreate ? { refusedKeys } : {}),
});
},
);
@@ -165,7 +193,10 @@ class FakeAddressBook implements ContactsProvider {
return { source, bytes: Buffer.byteLength(source, 'utf8') };
}
private apply(changes: AddressBookChanges): {
private apply(
changes: AddressBookChanges,
refusedKeys: ReadonlySet<string> = new Set(),
): {
createdIds: Map<string, string>;
cards: Map<string, AddressBookCard>;
} {
@@ -173,6 +204,7 @@ class FakeAddressBook implements ContactsProvider {
const createdIds = new Map<string, string>();
const cards = new Map<string, AddressBookCard>();
for (const card of changes.create) {
if (refusedKeys.has(card.key)) continue;
const id = `n${this.nextId++}`;
createdIds.set(card.key, id);
if (card.kind === 'contact') {
@@ -1364,4 +1396,413 @@ describe('ContactsService (주소록)', () => {
expect(book.contacts.size).toBe(2);
});
});
describe('16단계 — 가져오기 · 한꺼번에 지우기 · 내보내기 (계약 3절 "가져오기 · 내보내기")', () => {
const uidOf = (n: number) =>
`urn:uuid:00000000-0000-4000-8000-${n.toString(16).padStart(12, '0')}`;
const item = (
n: number,
overrides: Partial<ContactImportItem> = {},
): ContactImportItem => ({
...emptyFields,
name: `사람${n}`,
emails: [{ address: `p${n}@import.example`, label: 'work' }],
groupIds: [],
uid: uidOf(n),
...overrides,
});
const logged = () =>
logSpy.mock.calls.map(([message]) => String(message)).join('\n');
describe('가져오기 확인 (checkImport)', () => {
it('같은 이메일(대소문자 무시) · 이름 + 전화 숫자 · 같은 uid 는 exists, 나머지는 new — remaining 은 3,000 − 지금 수, 쓰지 않는다', async () => {
book.seedContact('c1', {
name: '박지훈',
emails: [{ address: 'Jihoon@Hanbit.example', label: 'work' }],
});
book.seedContact('c2', {
name: '김철수',
phones: [{ number: '010-1111-2222', label: 'mobile' }],
});
const result = await service.checkImport(ARA, [
item(1, {
emails: [{ address: ' jihoon@hanbit.EXAMPLE', label: 'personal' }],
}),
item(2, {
name: '김철수',
emails: [],
phones: [{ number: '01011112222', label: 'other' }],
}),
item(3, { uid: 'urn:uuid:c1' }),
item(4),
item(5, { name: '김철수', emails: [], phones: [] }),
]);
expect(result).toEqual({
items: [
{ uid: uidOf(1), status: 'exists' },
{ uid: uidOf(2), status: 'exists' },
{ uid: 'urn:uuid:c1', status: 'exists' },
{ uid: uidOf(4), status: 'new' },
{ uid: uidOf(5), status: 'new' },
],
remaining: MAX_CONTACTS - 2,
});
expect(book.writeAddressBook).not.toHaveBeenCalled();
});
it('remaining 은 0 아래로 내려가지 않는다 (다른 프로그램이 3,000명 넘게 넣은 주소록)', async () => {
for (let index = 0; index < MAX_CONTACTS + 2; index += 1) {
book.seedContact(`s${index}`);
}
await expect(
service.checkImport(ARA, [item(1)]),
).resolves.toMatchObject({ remaining: 0 });
});
it('묶음 검사 — 0명 · 101명 · 같은 uid · 이름도 이메일도 없음 · 같은 주소 두 번은 엔진에 묻기 전에 VAL_001', async () => {
const batches: ContactImportItem[][] = [
[],
Array.from({ length: 101 }, (_, n) => item(n)),
[item(1), item(2, { uid: uidOf(1) })],
[item(1, { name: null, emails: [] })],
[
item(1, {
emails: [
{ address: 'a@x.example', label: 'work' },
{ address: 'A@X.example', label: 'other' },
],
}),
],
];
for (const batch of batches) {
expect((await failureOf(service.checkImport(ARA, batch))).code).toBe(
'VAL_001',
);
expect(
(await failureOf(service.importContacts(ARA, batch))).code,
).toBe('VAL_001');
}
expect(book.getAddressBookVersion).not.toHaveBeenCalled();
expect(book.writeAddressBook).not.toHaveBeenCalled();
});
});
describe('가져오기 넣기 (importContacts)', () => {
it('보낸 uid 로 엔진 요청 하나에 만들고 고른 그룹에 넣는다 — 응답은 보낸 차례, 다시 읽지 않고 바로 목록에 보인다', async () => {
book.seedGroup('g1', '거래처');
book.seedGroup('g2', '협력사');
const result = await service.importContacts(ARA, [
item(2, { groupIds: ['g1', 'g2'] }),
item(1, { groupIds: ['g1'] }),
]);
expect(book.writeAddressBook).toHaveBeenCalledTimes(1);
expect(book.writeOptions).toEqual([{ allowPartialCreate: true }]);
const [write] = book.writes;
expect(write.create.map((card) => card.uid)).toEqual([
uidOf(2),
uidOf(1),
]);
expect(result.items).toEqual([
{
uid: uidOf(2),
id: expect.any(String) as unknown,
skippedGroupIds: [],
},
{
uid: uidOf(1),
id: expect.any(String) as unknown,
skippedGroupIds: [],
},
]);
expect(book.groups.get('g1')?.memberUids).toEqual([uidOf(2), uidOf(1)]);
expect(book.groups.get('g2')?.memberUids).toEqual([uidOf(2)]);
const reads = book.reads;
const page = await service.list(ARA, listQuery());
expect(page.items.map((contact) => contact.name)).toEqual([
'사람1',
'사람2',
]);
expect(page.items[1].groupIds).toEqual(['g1', 'g2']);
expect(book.reads).toBe(reads);
});
it('같은 묶음을 다시 보내면(응답을 잃음) 만들지 않고 같은 id 를 준다 — 빠진 그룹만 마저 넣는다', async () => {
book.seedGroup('g1', '거래처');
const first = await service.importContacts(ARA, [item(1), item(2)]);
const writes = book.writes.length;
const again = await service.importContacts(ARA, [
item(1),
item(2, { groupIds: ['g1'] }),
]);
expect(again.items.map((entry) => entry.id)).toEqual(
first.items.map((entry) => entry.id),
);
expect(book.contacts.size).toBe(2);
// 다시 보낸 쓰기에는 만들기가 없고 그룹 넣기만 있다
expect(book.writes).toHaveLength(writes + 1);
expect(book.writes.at(-1)?.create).toEqual([]);
expect(book.groups.get('g1')?.memberUids).toEqual([uidOf(2)]);
});
it(`구성원 ${MAX_CONTACT_GROUP_MEMBERS}명이 찬 그룹은 그 그룹만 빼고(skippedGroupIds) 만들고 다른 그룹에는 넣는다`, async () => {
const members: string[] = [];
for (let index = 0; index < MAX_CONTACT_GROUP_MEMBERS - 1; index += 1) {
members.push(book.seedContact(`m${index}`).uid ?? '');
}
book.seedGroup('g1', '거의 참', members);
book.seedGroup('g2', '여유');
const result = await service.importContacts(ARA, [
item(1, { groupIds: ['g1', 'g2'] }),
item(2, { groupIds: ['g1', 'g2'] }),
]);
expect(result.items.map((entry) => entry.skippedGroupIds)).toEqual([
[],
['g1'],
]);
expect(book.groups.get('g1')?.memberUids).toHaveLength(
MAX_CONTACT_GROUP_MEMBERS,
);
expect(book.groups.get('g2')?.memberUids).toEqual([uidOf(1), uidOf(2)]);
expect(logged()).toContain('구성원 한도로 그룹에 넣지 못함 1건');
});
it(`연락처가 ${MAX_CONTACTS}명을 넘게 되면 묶음 전체를 CONTACT_002 — 아무것도 만들지 않는다 · 없는 그룹은 RES_001`, async () => {
for (let index = 0; index < MAX_CONTACTS - 1; index += 1) {
book.seedContact(`s${index}`);
}
const failure = await failureOf(
service.importContacts(ARA, [item(1), item(2)]),
);
expect(failure.code).toBe('CONTACT_002');
expect(failure.context?.reason).toBe('contacts-limit');
expect(
(
await failureOf(
service.importContacts(ARA, [item(1, { groupIds: ['nope'] })]),
)
).code,
).toBe('RES_001');
expect(book.writeAddressBook).not.toHaveBeenCalled();
// 한 명은 들어간다
await expect(
service.importContacts(ARA, [item(1)]),
).resolves.toMatchObject({ items: [{ uid: uidOf(1) }] });
});
it(`크기 ${MAX_ADDRESS_BOOK_WEIGHT.toLocaleString('en-US')} 를 넘게 하는 묶음은 CONTACT_002 — 아무것도 만들지 않는다`, async () => {
// 792만 남짓 — 한 명은 들어가고 100명 묶음(사람마다 1,100 남짓)은 넘는다
const note = 'x'.repeat(990_000);
for (let index = 0; index < 8; index += 1) {
book.seedContact(`big${index}`, { note });
}
const failure = await failureOf(
service.importContacts(
ARA,
Array.from({ length: 100 }, (_, n) =>
item(n, { note: 'y'.repeat(1_000) }),
),
),
);
expect(failure.code).toBe('CONTACT_002');
expect(failure.context?.reason).toBe('address-book-too-large');
expect(book.writeAddressBook).not.toHaveBeenCalled();
});
it('엔진이 일부만 받으면 받은 것만 돌려준다 — 던지지 않고, 기억한 주소록도 엔진과 같다(다시 읽지 않는다)', async () => {
book.refuseUids.add(uidOf(2));
const result = await service.importContacts(ARA, [
item(1),
item(2),
item(3),
]);
expect(result.items.map((entry) => entry.uid)).toEqual([
uidOf(1),
uidOf(3),
]);
expect(book.contacts.size).toBe(2);
const reads = book.reads;
const page = await service.list(ARA, listQuery());
expect(page.items.map((contact) => contact.name)).toEqual([
'사람1',
'사람3',
]);
expect(book.reads).toBe(reads);
expect(logged()).toContain('넣지 못함 1명');
});
it('엔진이 바쁘다고 했지만 들어갔으면(applied) 다음 시도가 같은 uid 로 찾아 두 번 만들지 않는다', async () => {
book.busy.push('applied');
const result = await service.importContacts(ARA, [item(1), item(2)]);
expect(result.items.map((entry) => entry.uid)).toEqual([
uidOf(1),
uidOf(2),
]);
expect(book.contacts.size).toBe(2);
expect(book.writeAddressBook).toHaveBeenCalledTimes(1);
});
it('그룹 카드가 쓰는 uid 는 만들지 않는다(엔진이 받지 않는다) — 응답에서 빠진다', async () => {
book.seedGroup('g1', '거래처');
const result = await service.importContacts(ARA, [
item(1, { uid: 'urn:uuid:g1' }),
item(2),
]);
expect(result.items.map((entry) => entry.uid)).toEqual([uidOf(2)]);
expect(book.contacts.size).toBe(1);
});
it('로그에는 개수만 — 이름 · 주소 · uid 는 남기지 않는다', async () => {
await service.importContacts(ARA, [item(1, { note: '비밀 메모' })]);
expect(logged()).toContain('연락처를 가져왔습니다');
expect(logged()).not.toMatch(
/사람1|import\.example|비밀 메모|urn:uuid|ara\.kim@/i,
);
});
});
describe('한꺼번에 지우기 (removeContacts)', () => {
it('지우고 그룹 구성원에서도 뺀다 — 없는 id · 그룹 id 는 notFoundIds(보낸 차례), 되돌리기 원본은 읽지 않는다', async () => {
book.seedContact('c1');
book.seedContact('c2');
book.seedContact('c3');
book.seedGroup('g1', '거래처', ['urn:uuid:c1', 'urn:uuid:c3']);
const result = await service.removeContacts(ARA, [
'c3',
'gone',
'c1',
'g1',
]);
expect(result).toEqual({
deletedIds: ['c3', 'c1'],
notFoundIds: ['gone', 'g1'],
});
expect(book.writeOptions).toEqual([{ withoutOriginals: true }]);
expect([...book.contacts.keys()]).toEqual(['c2']);
expect(book.groups.get('g1')?.memberUids).toEqual([]);
expect(logged()).toContain('연락처를 한꺼번에 지웠습니다: 2명');
});
it(`지울 연락처 + 고칠 그룹이 엔진 한 요청의 상한(${MAX_CARD_CHANGES_PER_WRITE})을 넘으면 앞에서부터 나눠 쓴다`, async () => {
const ids: string[] = [];
const uids: string[] = [];
for (let index = 0; index < MAX_CONTACT_DELETIONS; index += 1) {
const card = book.seedContact(`d${index}`);
ids.push(card.id);
uids.push(card.uid ?? '');
}
book.seedGroup('g1', '모두', uids);
const result = await service.removeContacts(ARA, ids);
expect(result.deletedIds).toHaveLength(MAX_CONTACT_DELETIONS);
expect(result.notFoundIds).toEqual([]);
expect(book.writes.map((write) => write.destroy.length)).toEqual([
MAX_CARD_CHANGES_PER_WRITE - 1,
1,
]);
for (const write of book.writes) {
expect(
write.destroy.length + write.update.length,
).toBeLessThanOrEqual(MAX_CARD_CHANGES_PER_WRITE);
}
expect(book.contacts.size).toBe(0);
expect(book.groups.get('g1')?.memberUids).toEqual([]);
});
it('같은 uid 의 다른 카드가 남으면 그 uid 는 그룹에 그대로 둔다', async () => {
book.seedContact('c1', { uid: 'urn:uuid:shared' });
book.seedContact('c2', { uid: 'urn:uuid:shared' });
book.seedGroup('g1', '거래처', ['urn:uuid:shared']);
await service.removeContacts(ARA, ['c1']);
expect(book.groups.get('g1')?.memberUids).toEqual(['urn:uuid:shared']);
expect(book.writes.at(-1)?.update).toEqual([]);
});
it('바쁘다고 했지만 들어갔으면 — 다음 시도에서 없어진 id 도 지운 것으로 센다(notFound 가 아니다)', async () => {
book.seedContact('c1');
book.seedContact('c2');
book.busy.push('applied');
await expect(
service.removeContacts(ARA, ['c1', 'c2']),
).resolves.toEqual({ deletedIds: ['c1', 'c2'], notFoundIds: [] });
});
it('0개 · 501개 · 같은 id 두 번은 VAL_001 — 엔진에 묻지 않는다', async () => {
for (const ids of [
[],
Array.from({ length: MAX_CONTACT_DELETIONS + 1 }, (_, n) => `x${n}`),
['c1', 'c1'],
]) {
expect((await failureOf(service.removeContacts(ARA, ids))).code).toBe(
'VAL_001',
);
}
expect(book.getAddressBookVersion).not.toHaveBeenCalled();
});
});
describe('내보내기 (exportContacts)', () => {
beforeEach(() => {
book.seedContact('c-pa', {
name: '박지훈',
emails: [{ address: 'jihoon@hanbit.example', label: 'work' }],
});
book.seedContact('c-ga', {
name: '가나다',
phones: [{ number: '010-1234-5678', label: 'mobile' }],
});
book.seedGroup('g1', '거래처', ['urn:uuid:c-pa']);
});
it('vCard — 내 주소록 전체를 이름 차례로, 그룹 이름은 CATEGORIES · 파일 이름은 한국 날짜', async () => {
const file = await service.exportContacts(ARA, 'vcard');
const text = file.content.toString('utf8');
expect(file.fileName).toBe(
`주소록-${kstDateString(new Date(now))}.vcf`,
);
expect(file.contentType).toBe('text/vcard; charset=utf-8');
expect(file.count).toBe(2);
expect(text.indexOf('FN:가나다')).toBeLessThan(
text.indexOf('FN:박지훈'),
);
expect(text).toContain('UID:urn:uuid:c-pa\r\n');
expect(text).toContain('CATEGORIES:거래처\r\n');
expect(logged()).toContain(
'주소록을 내보냈습니다: 형식=vcard, 연락처 2명',
);
expect(logged()).not.toMatch(/박지훈|가나다|hanbit|ara\.kim@/);
});
it('CSV — 그룹 하나만(groupId), 파일 이름에 그룹 이름 · 없는 그룹은 RES_001', async () => {
const file = await service.exportContacts(ARA, 'csv', 'g1');
const rows = file.content.toString('utf8').split('\r\n');
expect(file.fileName).toBe(
`주소록-거래처-${kstDateString(new Date(now))}.csv`,
);
expect(rows).toHaveLength(3);
expect(rows[1]).toBe('박지훈,jihoon@hanbit.example,,,,,,,,,거래처');
expect(
(await failureOf(service.exportContacts(ARA, 'csv', 'nope'))).code,
).toBe('RES_001');
});
});
});
});
@@ -20,6 +20,19 @@ import {
type WrittenChanges,
} from './address-book';
import { ContactBookCache } from './contact-book-cache';
import {
contactExportContent,
contactExportContentType,
contactExportFileName,
type ExportedContact,
} from './contact-export';
import {
deletionChunkSize,
importDuplicateIndex,
isExistingContact,
planGroupJoins,
type GroupRoom,
} from './contact-import';
import { cursorOffset, listScope, pageOf } from './contact-paging';
import {
ContactRestoreStore,
@@ -38,8 +51,11 @@ import {
CONTACT_RESTORE_TTL_MS,
CONTACT_WRITE_ATTEMPTS,
MAX_ADDRESS_BOOK_WEIGHT,
MAX_CARD_CHANGES_PER_WRITE,
MAX_CONTACT_DELETIONS,
MAX_CONTACT_GROUP_MEMBERS,
MAX_CONTACT_GROUPS,
MAX_CONTACT_IMPORT_BATCH,
MAX_CONTACTS,
MAX_GROUP_MEMBERS_PER_REQUEST,
} from './contacts.constants';
@@ -48,6 +64,7 @@ import {
type AddressBookCardUpdate,
type AddressBookChanges,
type AddressBookContactCard,
type AddressBookWriteOptions,
type ContactsProvider,
type NewAddressBookCard,
type RemovedCardOriginal,
@@ -56,14 +73,20 @@ import type {
Contact,
ContactDeleted,
ContactEmail,
ContactExportFile,
ContactExportFormat,
ContactFields,
ContactGroup,
ContactGroupDeleted,
ContactGroupMembersAdded,
ContactImportCheck,
ContactImportItem,
ContactImportResult,
ContactInput,
ContactPatch,
ContactPhone,
ContactRestored,
ContactsDeleted,
ContactSuggestionLine,
DirectoryEntry,
} from './contacts.types';
@@ -79,6 +102,8 @@ const NEW_CONTACT_KEY = 'contact';
const NEW_GROUP_KEY = 'group';
const NEW_MEMBER_KEY_PREFIX = 'member-';
const RESTORED_KEY = 'restored';
/** 가져오기의 한 사람 — 보낸 차례 번호를 붙인다 (import-0 …) */
const IMPORT_KEY_PREFIX = 'import-';
const NO_CHANGES: AddressBookChanges = { create: [], update: [], destroy: [] };
@@ -105,6 +130,8 @@ interface ChangePlan<T> {
readonly result: (after: BookView, written: WriteResult) => T;
/** 바꾼 뒤 남기는 info 로그 (개수 · id 만 — 연락처의 내용은 남기지 않는다) */
readonly log?: string;
/** 엔진에 쓰는 선택 — 가져오기(일부만 받기) · 한꺼번에 지우기(원본 읽지 않기) (16단계) */
readonly write?: AddressBookWriteOptions;
}
/** 읽는 중인 주소록 — token 은 이 읽기의 표 (그사이 바꾸기가 끝나면 빠져서 옛 결과를 기억하지 않는다) */
@@ -269,7 +296,9 @@ function joinGroups(groups: readonly BookGroup[], uid: string) {
* 거절하면(busy) 다시 읽어 다시 정한다 (CONTACT_WRITE_ATTEMPTS 번 — 넘으면 MAIL_001). 한도(연락처 3,000 · 그룹 100 · 구성원 500 · 크기 800만 —
* CONTACT_002) · 같은 이름의 그룹(CONTACT_001)도 그 판으로 본다. 쓴 뒤에는 같은 바꾸기를 기억한 주소록에 옮겨 다시 읽지 않는다 (address-book.ts)
* - 지우기는 되돌리기 표를 준다 — 엔진 카드 전체를 5분 기억한다 (contact-restore-store.ts)
* - 로그에는 개수 · id 만 — 이름 · 주소 · 전화번호 · 메모 · 되돌리기 표는 남기지 않는다
* - 16단계 — 가져오기(확인 · 100명씩 넣기 — 화면이 준 uid 로 두 번 들어가지 않게, 엔진이 일부만 받아도 던지지 않는다 · contact-import.ts),
* 한꺼번에 지우기(가져오기 [되돌리기] — 되돌리기 표 없음), 내보내기(vCard · CSV — contact-export.ts)
* - 로그에는 개수 · id 만 — 이름 · 주소 · 전화번호 · 메모 · 되돌리기 표 · 파일 내용은 남기지 않는다
*
* ⚠️ 프로세스 메모리다(기억한 주소록 · 바꾸기 차례 · 되돌리기 표). 인스턴스를 늘려도 주소록은 틀리지 않는다 — 판이 그대로일 때만 쓴다.
* 되돌리기 표는 공유 저장소로 옮겨야 한다(다른 인스턴스로 간 되돌리기는 404).
@@ -288,12 +317,15 @@ export class ContactsService {
private readonly loading = new Map<string, Loading>();
/** 같은 직원의 바꾸기 차례 — 끝나면 빠진다 */
private readonly writeQueues = new Map<string, Promise<void>>();
/** 되돌리기 표의 시계 · 내보낸 파일 이름의 날짜 */
private readonly now: () => number;
constructor(
@Inject(CONTACTS_PROVIDER) private readonly provider: ContactsProvider,
private readonly directory: DirectoryService,
@Optional() @Inject(CONTACTS_CLOCK) clock?: () => number,
) {
this.now = clock ?? (() => Date.now());
this.restorations = new ContactRestoreStore(
{
ttlMs: CONTACT_RESTORE_TTL_MS,
@@ -302,7 +334,7 @@ export class ContactsService {
maxTotalBytes: CONTACT_RESTORE_MAX_TOTAL_BYTES,
tokenBytes: CONTACT_RESTORE_TOKEN_BYTES,
},
clock ?? (() => Date.now()),
this.now,
);
}
@@ -840,6 +872,261 @@ export class ContactsService {
});
}
/**
* POST /api/contact-import-checks — 가져올 연락처가 내 주소록에 이미 있는지 · 더 넣을 수 있는 수 (계약 3절 "가져오기" — 16단계). 만들지 않는다.
* 이미 있음: 같은 uid 의 연락처(다시 보낸 묶음) 또는 contact-import.ts 의 규칙(이메일 · 이름 + 전화). 항목 검사(이름이나 이메일 · 같은 주소 · 같은 uid)는
* 넣기와 같다 — 하나라도 걸리면 VAL_001. groupIds 는 보지 않는다(새 그룹은 넣기 직전에 만든다)
*/
async checkImport(
user: AuthenticatedUser,
items: readonly ContactImportItem[],
): Promise<ContactImportCheck> {
this.assertImportItems(items);
const book = await this.freshBook(user.email);
const index = importDuplicateIndex(
book.contacts.map((entry) => entry.contact),
);
return {
items: items.map((item) => ({
uid: item.uid,
status:
book.contactsByUid.has(item.uid) || isExistingContact(index, item)
? 'exists'
: 'new',
})),
remaining: Math.max(0, MAX_CONTACTS - book.contacts.length),
};
}
/**
* POST /api/contact-imports — 가져온 연락처를 엔진 요청 하나로 만든다 (계약 3절 "가져오기" — 16단계).
* - 줄마다 화면이 준 uid 로 만든다. 같은 uid 의 연락처가 이미 있으면(다시 보낸 묶음 · 앞 시도가 들어감) 만들지 않고 그 연락처를 돌려준다 — 빠진 그룹만 마저 넣는다
* - 연락처 수 · 크기 한도는 묶음 전체로 본다 — 넘으면 CONTACT_002, 아무것도 만들지 않는다. 없는 그룹은 RES_001
* - 그룹 구성원 500명을 넘기는 넣기는 그 그룹만 빼고(skippedGroupIds) 만든다 — 실패가 아니다
* - 엔진이 일부만 받으면 받은 것만 돌려준다(빠진 uid 는 화면이 "넣지 못함"으로) — 던지지 않는다. 그룹 카드가 쓰는 uid 도 만들지 않는다(엔진이 받지 않는다)
* - 겹침(같은 이메일 등)은 보지 않는다 — 확인(checkImport)의 몫
*/
async importContacts(
actor: AuthenticatedUser,
items: readonly ContactImportItem[],
): Promise<ContactImportResult> {
this.assertImportItems(items);
const result = await this.change(actor, (book) => {
const groups = new Map<string, BookGroup>();
for (const item of items) {
for (const groupId of item.groupIds) {
if (!groups.has(groupId)) {
groups.set(groupId, this.requireGroup(book, groupId));
}
}
}
/** 이미 있는 같은 uid 의 연락처 */
const landed = new Map<string, BookContact>();
/** uid → 이 바꾸기 안의 key */
const keys = new Map<string, string>();
items.forEach((item, index) => {
const existing = book.contactsByUid.get(item.uid)?.[0];
if (existing) landed.set(item.uid, existing);
else if (!book.uidsInUse.has(item.uid)) {
keys.set(item.uid, `${IMPORT_KEY_PREFIX}${index}`);
}
});
if (book.contacts.length + keys.size > MAX_CONTACTS) {
throw overLimit('contacts-limit', {
contacts: book.contacts.length,
creating: keys.size,
});
}
const rooms = new Map<string, GroupRoom>(
[...groups].map(([id, group]) => [
id,
{
memberUids: new Set(group.card.memberUids),
memberCount: group.group.memberCount,
},
]),
);
const joins = planGroupJoins(
rooms,
items.filter((item) => landed.has(item.uid) || keys.has(item.uid)),
MAX_CONTACT_GROUP_MEMBERS,
);
const update: AddressBookCardUpdate[] = [];
for (const [groupId, group] of groups) {
const adding = joins.adds.get(groupId);
if (adding && adding.length > 0) {
update.push({
kind: 'group',
id: groupId,
memberUids: withMembers(group, adding),
});
}
}
return {
changes: {
create: items.flatMap((item): NewAddressBookCard[] => {
const key = keys.get(item.uid);
return key === undefined
? []
: [
{
kind: 'contact',
key,
uid: item.uid,
fields: fieldsOf(item),
},
];
}),
update,
destroy: [],
},
write: { allowPartialCreate: true },
result: (_after, written) => ({
items: items.flatMap((item) => {
const key = keys.get(item.uid);
const id =
landed.get(item.uid)?.card.id ??
(key === undefined ? undefined : written.createdIds.get(key));
return id === undefined
? []
: [
{
uid: item.uid,
id,
skippedGroupIds: [...(joins.skipped.get(item.uid) ?? [])],
},
];
}),
}),
};
});
const skipped = result.items.reduce(
(sum, item) => sum + item.skippedGroupIds.length,
0,
);
this.logger.log(
`${traceTag()} 연락처를 가져왔습니다: 보낸 ${items.length}명 가운데 ${result.items.length}명(이미 있던 같은 uid 포함), 넣지 못함 ${items.length - result.items.length}명, 구성원 한도로 그룹에 넣지 못함 ${skipped}건 (user=${maskEmail(normalizeUser(actor.email))})`,
);
return result;
}
/**
* POST /api/contact-deletions — 연락처 여러 개를 영구 삭제한다 (가져오기 [되돌리기] — 계약 3절 "가져오기" — 16단계). 되돌리기 표는 주지 않는다.
* 지운 연락처를 가리키는 그룹 구성원도 함께 뺀다(같은 uid 의 다른 카드가 남으면 그대로). 내 주소록에 없는 id(그룹 id 포함)는 notFoundIds.
* 엔진 한 요청에 담는 카드 수(지울 연락처 + 고칠 그룹)가 상한을 넘으면 앞에서부터 나눠 쓴다 — 중간에 실패하면 앞 몫은 지워진 채다(다시 보내면 notFoundIds)
*/
async removeContacts(
actor: AuthenticatedUser,
ids: readonly string[],
): Promise<ContactsDeleted> {
if (ids.length < 1 || ids.length > MAX_CONTACT_DELETIONS) {
throw new BusinessException('VAL_001', {
context: { reason: 'contact-deletions-count', requested: ids.length },
});
}
if (new Set(ids).size !== ids.length) {
throw new BusinessException('VAL_001', {
context: { reason: 'contact-deletions-duplicate' },
});
}
const deleted = new Set<string>();
let pending: readonly string[] = ids;
while (pending.length > 0) {
const batch = pending;
/** 이 몫에서 지우려 했던 id — 엔진이 바쁘다고 했는데 들어갔으면 다음 시도에서 없어진 것을 지운 것으로 센다 */
const tried = new Set<string>();
const step = await this.change(actor, (book, attempt) => {
const present = batch.filter((id) => book.contactsById.has(id));
const landed = attempt.uncertain
? batch.filter((id) => tried.has(id) && !book.contactsById.has(id))
: [];
const groupIdsByUid = new Map<string, string[]>();
for (const group of book.groups) {
for (const uid of group.card.memberUids) {
const groupIds = groupIdsByUid.get(uid) ?? [];
groupIds.push(group.card.id);
groupIdsByUid.set(uid, groupIds);
}
}
const size = deletionChunkSize(
present.map((id) => {
const uid = book.contactsById.get(id)?.card.uid ?? null;
return uid === null ? [] : (groupIdsByUid.get(uid) ?? []);
}),
MAX_CARD_CHANGES_PER_WRITE,
);
const now = present.slice(0, size);
for (const id of now) tried.add(id);
const update = this.leavingGroups(book, now);
return {
changes: { create: [], update, destroy: now },
write: { withoutOriginals: true },
result: () => ({
deleted: [...landed, ...now],
rest: present.slice(size),
}),
log: `연락처를 한꺼번에 지웠습니다: ${now.length}명, 그룹에서 뺌 ${update.length}개`,
};
});
for (const id of step.deleted) deleted.add(id);
pending = step.rest;
}
return {
deletedIds: ids.filter((id) => deleted.has(id)),
notFoundIds: ids.filter((id) => !deleted.has(id)),
};
}
/**
* GET /api/contact-exports — 내 주소록 전체 또는 그룹 하나를 vCard 3.0 · CSV 로 (계약 3절 "내보내기" — 16단계).
* 이름 차례, 우리 칸만(contact-export.ts). 사내 목록은 넣지 않는다. 없는 그룹은 RES_001. 로그에는 형식 · 개수 · 그룹 id 만
*/
async exportContacts(
user: AuthenticatedUser,
format: ContactExportFormat,
groupId?: string,
): Promise<ContactExportFile> {
const book = await this.freshBook(user.email);
const group =
groupId === undefined ? null : this.requireGroup(book, groupId);
const entries =
group === null
? book.contacts
: group.memberIds.flatMap((id) => {
const entry = book.contactsById.get(id);
return entry ? [entry] : [];
});
const groupNames = new Map(
book.groups.map((entry) => [entry.card.id, entry.card.name]),
);
const contacts = entries.map(
(entry): ExportedContact => ({
...fieldsOf(entry.contact),
uid: entry.card.uid,
groupNames: entry.contact.groupIds.flatMap((id) => {
const name = groupNames.get(id);
return name ? [name] : [];
}),
}),
);
const content = Buffer.from(contactExportContent(format, contacts), 'utf8');
this.logger.log(
`${traceTag()} 주소록을 내보냈습니다: 형식=${format}, 연락처 ${contacts.length}명, 범위=${group === null ? '전체' : `그룹 ${group.card.id}`} (user=${maskEmail(normalizeUser(user.email))})`,
);
return {
fileName: contactExportFileName(
format,
group?.card.name ?? null,
new Date(this.now()),
),
contentType: contactExportContentType(format),
content,
count: contacts.length,
};
}
/**
* GET /api/directory-entries — 사내 목록(directory.service.ts)에서 excludeGroupId 면 그 그룹(이 직원의 주소록) 구성원이 가진 주소를 뺀다.
* 그룹은 커서를 확인한 뒤에 찾는다 — 없는 그룹이면 404 RES_001
@@ -1071,6 +1358,7 @@ export class ContactsService {
user,
book.version,
planned.changes,
planned.write,
);
} catch (error) {
// 일부만 바뀌었을 수 있다 — 기억한 것을 버리고 다음 요청은 다시 읽는다
@@ -1175,6 +1463,57 @@ export class ContactsService {
}
}
/**
* 가져오기 · 확인의 묶음 — 1~100명, 사람마다 연락처 추가와 같은 검사(이름이나 이메일 · 같은 주소), 묶음 안의 같은 uid 는 안 된다.
* 하나라도 걸리면 엔진에 묻기 전에 VAL_001 (화면이 같은 규칙으로 미리 거른다)
*/
private assertImportItems(items: readonly ContactImportItem[]): void {
if (items.length < 1 || items.length > MAX_CONTACT_IMPORT_BATCH) {
throw new BusinessException('VAL_001', {
context: { reason: 'contact-import-count', requested: items.length },
});
}
const uids = new Set<string>();
for (const item of items) {
this.assertNameOrEmail(item);
this.assertDistinctEmails(item.emails);
if (uids.has(item.uid)) {
throw new BusinessException('VAL_001', {
context: { reason: 'contact-import-duplicate-uid' },
});
}
uids.add(item.uid);
}
}
/**
* 연락처들을 지울 때 고칠 그룹 — 지우는 카드의 uid 를 구성원에서 뺀다. 같은 uid 의 다른 카드가 남으면(이번에 지우지 않는 카드) 그 uid 는 그대로 둔다.
* 그룹 구성원을 한 번 훑는다
*/
private leavingGroups(
book: BookView,
ids: readonly string[],
): AddressBookCardUpdate[] {
const removing = new Set(ids);
const uids = new Set<string>();
for (const id of ids) {
const uid = book.contactsById.get(id)?.card.uid ?? null;
if (uid === null) continue;
const owners = book.contactsByUid.get(uid) ?? [];
if (owners.every((owner) => removing.has(owner.card.id))) uids.add(uid);
}
if (uids.size === 0) return [];
return book.groups
.filter((group) => group.card.memberUids.some((uid) => uids.has(uid)))
.map(
(group): AddressBookCardUpdate => ({
kind: 'group',
id: group.card.id,
memberUids: withMembers(group, [], uids),
}),
);
}
/** 크기 한도 — 이 바꾸기로 주소록이 800만을 넘게 커지면 CONTACT_002 (줄이는 쓰기 · 지우기는 늘 된다) */
private assertWeight(book: BookView, changes: AddressBookChanges): void {
const after = weightAfterChanges(book, changes);
@@ -104,6 +104,70 @@ export interface ContactGroupMembersAdded {
readonly createdCount: number;
}
/**
* 가져오기의 한 사람 (계약 3절 ContactImportItem — 16단계) — 연락처 추가 본문(ContactInput)과 같은 칸 + 화면이 만든 uid.
* 같은 uid 가 이미 주소록에 있으면(다시 보낸 요청) 새로 만들지 않고 그 연락처를 돌려준다
*/
export interface ContactImportItem extends ContactInput {
/** `urn:uuid:` + 소문자 UUID */
readonly uid: string;
}
/** 가져오기 확인의 판정 — new 넣을 연락처 · exists 이미 있는 연락처(계약 3절 "가져오기") */
export type ContactImportStatus = 'new' | 'exists';
export const CONTACT_IMPORT_STATUSES: readonly ContactImportStatus[] = [
'new',
'exists',
];
/** POST /api/contact-import-checks 의 응답 */
export interface ContactImportCheck {
/** 보낸 차례 그대로 한 줄씩 */
readonly items: readonly {
readonly uid: string;
readonly status: ContactImportStatus;
}[];
/** 더 넣을 수 있는 연락처 수 — 3,000 − 지금 연락처 수 (0 아래로는 내려가지 않는다) */
readonly remaining: number;
}
/** 가져온 연락처 하나 — 화면이 준 uid · 엔진의 카드 id · 구성원 500명이 차서 넣지 못한 그룹 */
export interface ContactImported {
readonly uid: string;
readonly id: string;
readonly skippedGroupIds: readonly string[];
}
/** POST /api/contact-imports 의 응답 — 넣은(또는 이미 있던 같은 uid 의) 연락처만, 보낸 차례 그대로. 빠진 uid 는 엔진이 받지 않은 것 */
export interface ContactImportResult {
readonly items: readonly ContactImported[];
}
/** POST /api/contact-deletions 의 응답 — 보낸 차례 그대로 */
export interface ContactsDeleted {
readonly deletedIds: readonly string[];
/** 내 주소록에 없는 id (이미 지움 · 그룹 id 포함) */
readonly notFoundIds: readonly string[];
}
/** 내보내기 형식 — vCard 3.0 · CSV(UTF-8 BOM) */
export type ContactExportFormat = 'vcard' | 'csv';
export const CONTACT_EXPORT_FORMATS: readonly ContactExportFormat[] = [
'vcard',
'csv',
];
/** 내보낼 파일 — 이름 · 형식 · 내용 */
export interface ContactExportFile {
readonly fileName: string;
readonly contentType: string;
readonly content: Buffer;
/** 담은 연락처 수 (로그) */
readonly count: number;
}
/** 사내 목록의 종류 — 직원(개인 계정) · 공용 주소(그룹 계정) */
export type DirectoryEntryKind = 'person' | 'shared';
@@ -0,0 +1,30 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsIn } from 'class-validator';
import {
CONTACT_EXPORT_FORMATS,
type ContactExportFormat,
} from '@/modules/contacts/contacts.types';
import { ContactGroupIdQuery } from './contact.dto';
/** GET /api/contact-exports 의 쿼리 (계약 3절 "내보내기" — 16단계) — 모르는 형식 · 쿼리는 400 VAL_001 */
export class ContactExportQueryDto {
@ApiProperty({
description:
'vcard — vCard 3.0(.vcf, 다른 메일 · 휴대폰으로 옮길 때) · csv — CSV(UTF-8 BOM, 엑셀에서 볼 때)',
enum: [...CONTACT_EXPORT_FORMATS],
example: 'vcard',
})
@IsIn(CONTACT_EXPORT_FORMATS, {
message: '내보낼 형식을 다시 골라 주세요.',
})
format!: ContactExportFormat;
@ApiPropertyOptional({
description:
'이 그룹의 구성원만 — 없으면 내 주소록 전체. 없는 그룹이면 404 RES_001',
example: 'jy',
})
@ContactGroupIdQuery()
groupId?: string;
}
@@ -0,0 +1,175 @@
import { BadRequestException } from '@nestjs/common';
import { plainToInstance } from 'class-transformer';
import { validate, type ValidationError } from 'class-validator';
import { createValidationPipe } from '@/app.setup';
import {
MAX_CONTACT_DELETIONS,
MAX_CONTACT_IMPORT_BATCH,
} from '@/modules/contacts/contacts.constants';
import { ContactExportQueryDto } from './contact-export.dto';
import {
ContactDeletionDto,
ContactImportBatchDto,
} from './contact-import.dto';
/** 걸린 규칙 이름 — 중첩된 칸까지 */
function constraintNames(errors: ValidationError[]): string[] {
return errors.flatMap((error) => [
...Object.keys(error.constraints ?? {}),
...constraintNames(error.children ?? []),
]);
}
/** 전역 ValidationPipe 와 같은 옵션으로 (stopAtFirstError 는 고른다 — 빼면 걸린 규칙을 모두 본다) */
async function namesOf<T extends object>(
type: new () => T,
body: object,
stopAtFirstError = false,
): Promise<string[]> {
const dto = plainToInstance(type, body);
return constraintNames(
await validate(dto, {
whitelist: true,
forbidNonWhitelisted: true,
stopAtFirstError,
}),
);
}
const UID = 'urn:uuid:3f1c0a2e-0f1b-4f2a-9a1e-6c9f0a1b2c3d';
const person = (overrides: object = {}) => ({
name: '박지훈',
emails: [{ address: 'jihoon.park@hanbit.example', label: 'work' }],
phones: [{ number: '010-3192-5540', label: 'mobile' }],
company: '한빛상사',
department: '구매팀',
title: '과장',
note: null,
groupIds: [],
uid: UID,
...overrides,
});
describe('가져오기 본문 (POST /api/contact-import-checks · /api/contact-imports — 16단계)', () => {
it('맞는 묶음 — 사람마다 연락처 추가 본문 + uid, 글은 앞뒤 공백을 지운다', async () => {
expect(
await namesOf(ContactImportBatchDto, { contacts: [person()] }),
).toEqual([]);
const dto = plainToInstance(ContactImportBatchDto, {
contacts: [person({ name: ' 김아라 ', company: ' ' })],
});
expect(await validate(dto)).toEqual([]);
expect(dto.contacts[0].name).toBe('김아라');
expect(dto.contacts[0].company).toBeNull();
});
it('uid — urn:uuid: + 소문자 UUID 만', async () => {
for (const uid of [
UID.toUpperCase(),
UID.replace('urn:uuid:', ''),
`${UID}0`,
'urn:uuid:not-a-uuid',
7,
null,
]) {
expect(
await namesOf(ContactImportBatchDto, { contacts: [person({ uid })] }),
).not.toEqual([]);
}
const missing: Record<string, unknown> = person();
delete missing.uid;
expect(
await namesOf(ContactImportBatchDto, { contacts: [missing] }),
).not.toEqual([]);
});
it(`묶음 — 배열 · 1~${MAX_CONTACT_IMPORT_BATCH}명, 사람마다 연락처 추가와 같은 칸 검사 · 모르는 칸은 안 된다`, async () => {
for (const contacts of [
null,
'x',
[],
Array.from({ length: MAX_CONTACT_IMPORT_BATCH + 1 }, () => person()),
[person({ emails: [{ address: 'not-an-email', label: 'work' }] })],
[person({ phones: [{ number: '내선 12', label: 'work' }] })],
[person({ note: '가'.repeat(1_001) })],
[person({ extra: true })],
]) {
expect(await namesOf(ContactImportBatchDto, { contacts })).not.toEqual(
[],
);
}
expect(
await namesOf(ContactImportBatchDto, {
contacts: Array.from({ length: MAX_CONTACT_IMPORT_BATCH }, () =>
person(),
),
}),
).toEqual([]);
});
it('개수가 넘으면 사람마다의 검사를 하지 않는다 — 형식 → 개수 → 중첩 차례 (stopAtFirstError)', async () => {
const names = await namesOf(
ContactImportBatchDto,
{
contacts: Array.from({ length: MAX_CONTACT_IMPORT_BATCH + 1 }, () =>
person({ emails: [{ address: 'bad', label: 'work' }] }),
),
},
true,
);
expect(names).toEqual(['arrayMaxSize']);
const pipe = createValidationPipe();
await expect(
pipe.transform(
{ contacts: [] },
{ type: 'body', metatype: ContactImportBatchDto },
),
).rejects.toBeInstanceOf(BadRequestException);
});
});
describe('한꺼번에 지우기 본문 (POST /api/contact-deletions — 16단계)', () => {
it(`id 1~${MAX_CONTACT_DELETIONS}개 · id 모양 · 겹치지 않게`, async () => {
expect(await namesOf(ContactDeletionDto, { ids: ['jw', 'jx'] })).toEqual(
[],
);
for (const ids of [
null,
[],
Array.from({ length: MAX_CONTACT_DELETIONS + 1 }, (_, n) => `c${n}`),
['jw', 'jw'],
['../x'],
[7],
]) {
expect(await namesOf(ContactDeletionDto, { ids })).not.toEqual([]);
}
expect(
await namesOf(ContactDeletionDto, {
ids: Array.from({ length: MAX_CONTACT_DELETIONS }, (_, n) => `c${n}`),
}),
).toEqual([]);
});
});
describe('내보내기 쿼리 (GET /api/contact-exports — 16단계)', () => {
it('format 은 vcard · csv 만(없으면 안 된다) · groupId 는 id 모양 · 모르는 쿼리는 안 된다', async () => {
expect(await namesOf(ContactExportQueryDto, { format: 'vcard' })).toEqual(
[],
);
expect(
await namesOf(ContactExportQueryDto, { format: 'csv', groupId: 'jy' }),
).toEqual([]);
for (const query of [
{},
{ format: 'xlsx' },
{ format: 'VCARD' },
{ format: ['vcard', 'csv'] },
{ format: 'csv', groupId: '../x' },
{ format: 'csv', limit: '10' },
]) {
expect(await namesOf(ContactExportQueryDto, query)).not.toEqual([]);
}
});
});
@@ -0,0 +1,202 @@
import { applyDecorators } from '@nestjs/common';
import { ApiProperty } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import {
ArrayMaxSize,
ArrayMinSize,
IsArray,
IsString,
Matches,
ValidateNested,
} from 'class-validator';
import { ArrayDistinct } from '@/common/decorators/array-distinct.decorator';
import { MaxCodeUnits } from '@/common/decorators/code-units.decorator';
import {
CONTACT_ID_PATTERN,
CONTACT_IMPORT_UID_PATTERN,
MAX_CONTACT_DELETIONS,
MAX_CONTACT_IMPORT_BATCH,
} from '@/modules/contacts/contacts.constants';
import {
CONTACT_IMPORT_STATUSES,
type ContactImportCheck,
type ContactImported,
type ContactImportItem,
type ContactImportResult,
type ContactImportStatus,
type ContactsDeleted,
} from '@/modules/contacts/contacts.types';
import { ContactInputDto } from './contact.dto';
/**
* 가져오기 · 한꺼번에 지우기 — 본문과 응답 (계약 3절 "가져오기 · 내보내기" — 16단계).
* 한 사람(ContactImportItem)은 연락처 추가 본문(ContactInputDto)과 같은 검사에 uid 를 더한 것이다. 이름이나 이메일 · 같은 연락처 안의 같은 주소 ·
* 묶음 안의 같은 uid 는 서비스가 본다(모두 400 VAL_001). 칸의 검사는 적은 차례대로 돈다(contact.dto.ts 머리말 — 형식 → 길이 · 개수 → 모양)
*/
/** `urn:uuid:` + 소문자 UUID 의 길이 */
const IMPORT_UID_LENGTH = 45;
function ImportUid(): PropertyDecorator {
return applyDecorators(
IsString({ message: '가져올 연락처의 표시가 올바르지 않습니다.' }),
MaxCodeUnits(IMPORT_UID_LENGTH, {
message: '가져올 연락처의 표시가 올바르지 않습니다.',
}),
Matches(CONTACT_IMPORT_UID_PATTERN, {
message: '가져올 연락처의 표시가 올바르지 않습니다.',
}),
);
}
/** 가져올 연락처 목록 — 형식 → 개수(1~100) → 사람마다(중첩) 차례로 (개수가 넘으면 중첩 검사를 하지 않는다 — stopAtFirstError) */
function ImportList(): PropertyDecorator {
return applyDecorators(
IsArray({ message: '가져올 연락처 목록 형식이 올바르지 않습니다.' }),
ArrayMinSize(1, { message: '가져올 연락처를 하나 이상 보내 주세요.' }),
ArrayMaxSize(MAX_CONTACT_IMPORT_BATCH, {
message: `한 번에 ${MAX_CONTACT_IMPORT_BATCH}명까지 보낼 수 있습니다.`,
}),
ValidateNested({ each: true }),
Type(() => ContactImportItemDto),
);
}
/** 지울 연락처 id 목록 — 형식 → 개수(1~500) → id 모양 → 겹침 */
function DeletionIdList(): PropertyDecorator {
return applyDecorators(
IsArray({ message: '지울 연락처 목록 형식이 올바르지 않습니다.' }),
ArrayMinSize(1, { message: '지울 연락처를 하나 이상 보내 주세요.' }),
ArrayMaxSize(MAX_CONTACT_DELETIONS, {
message: `한 번에 ${MAX_CONTACT_DELETIONS}명까지 지울 수 있습니다.`,
}),
Matches(CONTACT_ID_PATTERN, {
each: true,
message: '지울 연락처를 다시 골라 주세요.',
}),
ArrayDistinct({ message: '같은 연락처를 두 번 보낼 수 없습니다.' }),
);
}
export class ContactImportItemDto
extends ContactInputDto
implements ContactImportItem
{
@ApiProperty({
description:
'화면이 사람마다 만든 uid — `urn:uuid:` + 소문자 UUID. 엔진 카드의 uid 가 된다. 같은 uid 의 연락처가 이미 있으면(다시 보낸 묶음) 새로 만들지 않는다. 한 묶음 안에서 겹치면 400 VAL_001',
example: 'urn:uuid:3f1c0a2e-0f1b-4f2a-9a1e-6c9f0a1b2c3d',
maxLength: IMPORT_UID_LENGTH,
})
@ImportUid()
uid!: string;
}
/** 가져오기 · 확인의 본문 — 1~100명 */
export class ContactImportBatchDto {
@ApiProperty({
type: [ContactImportItemDto],
minItems: 1,
maxItems: MAX_CONTACT_IMPORT_BATCH,
description: `가져올 연락처 — 1~${MAX_CONTACT_IMPORT_BATCH}명. 칸 규칙은 연락처 추가(POST /api/contacts)와 같다`,
})
@ImportList()
contacts!: ContactImportItemDto[];
}
export class ContactImportCheckItemDto {
@ApiProperty({
description: '보낸 uid',
example: 'urn:uuid:3f1c0a2e-0f1b-4f2a-9a1e-6c9f0a1b2c3d',
})
uid!: string;
@ApiProperty({
description:
'new 넣을 연락처 · exists 이미 있는 연락처 — 같은 이메일(앞뒤 공백 · 대소문자 무시)이 내 주소록에 있음, 이메일이 없으면 이름(앞뒤 공백을 뺀 글)과 전화번호의 숫자 모음이 모두 같은 연락처가 있음, 또는 같은 uid 의 연락처가 있음',
enum: [...CONTACT_IMPORT_STATUSES],
example: 'new',
})
status!: ContactImportStatus;
}
/** POST /api/contact-import-checks → 200 */
export class ContactImportCheckDto implements ContactImportCheck {
@ApiProperty({
type: [ContactImportCheckItemDto],
description: '보낸 차례 그대로 한 줄씩',
})
items!: ContactImportCheckItemDto[];
@ApiProperty({
description:
'더 넣을 수 있는 연락처 수 — 3,000 − 지금 연락처 수 (0 아래로 내려가지 않는다). 화면은 이것으로 넣을 수를 미리 줄인다',
example: 2_660,
})
remaining!: number;
}
export class ContactImportedDto implements ContactImported {
@ApiProperty({
description: '보낸 uid',
example: 'urn:uuid:3f1c0a2e-0f1b-4f2a-9a1e-6c9f0a1b2c3d',
})
uid!: string;
@ApiProperty({
description:
'연락처 id — 새로 만든 카드, 또는 같은 uid 로 이미 있던 카드(다시 보낸 묶음)',
example: 'jw',
})
id!: string;
@ApiProperty({
type: [String],
description:
'구성원 500명이 차서 넣지 못한 그룹 id (보낸 groupIds 차례) — 실패가 아니다. 연락처는 만들었고 나머지 그룹에는 넣었다',
example: [],
})
skippedGroupIds!: string[];
}
/** POST /api/contact-imports → 201 */
export class ContactImportResultDto implements ContactImportResult {
@ApiProperty({
type: [ContactImportedDto],
description:
'넣은 연락처(같은 uid 로 이미 있던 것 포함) — 보낸 차례 그대로. 보낸 uid 가 빠져 있으면 메일 서버가 받지 않은 것이다(화면은 "넣지 못함"으로)',
})
items!: ContactImportedDto[];
}
/** POST /api/contact-deletions 의 본문 — 1~500개 · 겹치지 않게 */
export class ContactDeletionDto {
@ApiProperty({
type: [String],
minItems: 1,
maxItems: MAX_CONTACT_DELETIONS,
description: `지울 연락처 id — 1~${MAX_CONTACT_DELETIONS}개, 겹치지 않게(겹치면 400 VAL_001)`,
example: ['jw', 'jx'],
})
@DeletionIdList()
ids!: string[];
}
/** POST /api/contact-deletions → 200 */
export class ContactsDeletedDto implements ContactsDeleted {
@ApiProperty({
type: [String],
description: '지운 연락처 id — 보낸 차례 그대로',
example: ['jw'],
})
deletedIds!: string[];
@ApiProperty({
type: [String],
description:
'내 주소록에 없는 id — 이미 지움 · 그룹 id 등 (보낸 차례 그대로)',
example: ['jx'],
})
notFoundIds!: string[];
}
@@ -0,0 +1,121 @@
import { plainToInstance } from 'class-transformer';
import { validate } from 'class-validator';
import { SIGNATURE_NAME_MAX_LENGTH } from '@/modules/mail-signature/mail-signature.constants';
import {
MailSignatureDefaultsDto,
SaveMailSignatureDto,
UpdateLegacyMailSignatureDto,
} from './mail-signature.dto';
/** 전역 ValidationPipe 와 같은 옵션으로 검증한다 (app.setup.ts — stopAtFirstError 만 빼고 — 걸린 규칙을 모두 본다) — 걸린 규칙 이름만 */
async function constraintsOf(
type: new () => object,
body: object,
): Promise<string[]> {
const errors = await validate(plainToInstance(type, body), {
whitelist: true,
forbidNonWhitelisted: true,
});
return errors.flatMap((error) => Object.keys(error.constraints ?? {}));
}
describe('서명 만들기 · 고치기 본문 (POST /api/mail-signatures · PUT /api/mail-signatures/{id} — 16단계)', () => {
const save = (body: object) => constraintsOf(SaveMailSignatureDto, body);
it('이름은 앞뒤 공백을 지운 값으로 1~30자(JS 문자열 길이) — 본문 칸은 보내지 않거나 null 이어도 된다', async () => {
expect(await save({ name: '대외' })).toEqual([]);
expect(
await save({ name: '대외', bodyHtml: '<p>김아라</p>', body: '김아라' }),
).toEqual([]);
expect(await save({ name: '대외', bodyHtml: null, body: null })).toEqual(
[],
);
expect(await save({ name: 'x'.repeat(SIGNATURE_NAME_MAX_LENGTH) })).toEqual(
[],
);
expect(
plainToInstance(SaveMailSignatureDto, { name: ' 대외 ' }).name,
).toBe('대외');
// 이모지 하나는 2자로 센다 — 화면의 .length 와 같다
expect(
await save({ name: '🌴'.repeat(SIGNATURE_NAME_MAX_LENGTH / 2) }),
).toEqual([]);
});
it('이름이 비거나(공백뿐 포함) · 30자를 넘거나 · 제어 문자 · 글이 아니면 VAL_001', async () => {
expect(await save({ name: '' })).toEqual(['minCodeUnits']);
expect(await save({ name: ' ' })).toEqual(['minCodeUnits']);
expect(
await save({ name: 'x'.repeat(SIGNATURE_NAME_MAX_LENGTH + 1) }),
).toEqual(['maxCodeUnits']);
expect(
await save({ name: `${'🌴'.repeat(SIGNATURE_NAME_MAX_LENGTH / 2)}x` }),
).toEqual(['maxCodeUnits']);
for (const name of ['사내\n영문', '사내\t영문', 'a\u0000']) {
expect(await save({ name })).toEqual(['matches']);
}
// 앞뒤의 공백 · 줄바꿈은 지운 값으로 본다(trim)
expect(await save({ name: '\t사내\n' })).toEqual([]);
expect(await save({})).toEqual(expect.arrayContaining(['isString']));
expect(await save({ name: 1 })).toEqual(
expect.arrayContaining(['isString']),
);
});
it('본문 칸이 글이 아니거나 모르는 칸(옛 두 표시 포함)이면 VAL_001', async () => {
expect(await save({ name: '대외', bodyHtml: 1 })).toEqual(['isString']);
expect(await save({ name: '대외', body: [] })).toEqual(['isString']);
expect(await save({ name: '대외', isForNew: true })).toEqual([
'whitelistValidation',
]);
});
});
describe('메일에 넣을 서명 (PUT /api/mail-signature-defaults — 16단계)', () => {
const defaults = (body: object) =>
constraintsOf(MailSignatureDefaultsDto, body);
it('두 키를 모두 보낸다 — 서명 id 또는 null(넣지 않음). 모양은 서비스가 본다(내 서명이 아니면 404)', async () => {
const id = '0b6f7c1e-3a52-4c4e-9f0e-2d1f5a7b8c9d';
expect(await defaults({ newMessageId: id, replyId: null })).toEqual([]);
expect(await defaults({ newMessageId: null, replyId: null })).toEqual([]);
expect(await defaults({ newMessageId: 'not-a-uuid', replyId: id })).toEqual(
[],
);
});
it('빠진 키 · 글이 아닌 값 · 너무 긴 값 · 모르는 칸은 VAL_001', async () => {
expect(await defaults({ newMessageId: null })).toEqual(
expect.arrayContaining(['isString']),
);
expect(await defaults({ newMessageId: 1, replyId: null })).toEqual(
expect.arrayContaining(['isString']),
);
expect(
await defaults({ newMessageId: 'x'.repeat(65), replyId: null }),
).toEqual(['maxCodeUnits']);
expect(
await defaults({ newMessageId: null, replyId: null, forwardId: null }),
).toEqual(['whitelistValidation']);
});
});
describe('옛 경로 저장 본문 (PUT /api/mail-signature — 16단계 전의 웹 탭)', () => {
it('지금과 같은 네 칸 — 두 표시는 boolean 이어야 한다', async () => {
expect(
await constraintsOf(UpdateLegacyMailSignatureDto, {
bodyHtml: '<p>김아라</p>',
body: '김아라',
isForNew: true,
isForReply: false,
}),
).toEqual([]);
expect(
await constraintsOf(UpdateLegacyMailSignatureDto, {
isForNew: 'true',
isForReply: true,
}),
).toEqual(['isBoolean']);
});
});
@@ -1,17 +1,35 @@
import { applyDecorators } from '@nestjs/common';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsBoolean, IsOptional, IsString } from 'class-validator';
import {
IsBoolean,
IsOptional,
IsString,
Matches,
ValidateIf,
} from 'class-validator';
import {
MaxCodeUnits,
MinCodeUnits,
} from '@/common/decorators/code-units.decorator';
import { TrimString } from '@/common/decorators/trim-string.decorator';
import { PageInfoDto } from '@/common/dto/pagination.dto';
import {
SIGNATURES_MAX,
SIGNATURE_HTML_MAX_LENGTH,
SIGNATURE_IMAGES_PER_SIGNATURE,
SIGNATURE_IMAGE_MAX_BYTES,
SIGNATURE_NAME_MAX_LENGTH,
SIGNATURE_TEXT_MAX_LENGTH,
} from '@/modules/mail-signature/mail-signature.constants';
import type {
LegacyMailSignature,
MailSignature,
MailSignatureDefaults,
SignatureImage,
} from '@/modules/mail-signature/mail-signature.types';
import type { SignatureImageType } from '@/modules/mail-signature/signature-image-format';
import { SINGLE_LINE_TEXT_PATTERN } from '@/modules/mail/mail.constants';
const IMAGE_TYPES: SignatureImageType[] = [
'image/png',
@@ -19,24 +37,109 @@ const IMAGE_TYPES: SignatureImageType[] = [
'image/gif',
];
/** PUT /api/mail-signature — 계약 2절 "서명" */
export class UpdateMailSignatureDto {
/** 서명 id 칸의 길이 상한 — 서버가 만든 UUID(36자)라 넉넉히. 넘거나 형식이 틀린 id 는 없는 서명과 같다(404 RES_001) */
const SIGNATURE_ID_MAX_LENGTH = 64;
/** null 은 "넣지 않음"으로 받는다 — 키가 없으면(undefined) 형식 오류다 (전체 교체라 두 키를 모두 보낸다) */
const unlessNull = (_object: object, value: unknown) => value !== null;
const BODY_HTML_DESCRIPTION =
`서명 서식 HTML — 서식 본문과 같은 허용 목록 + 내 서명 그림(<img src="/api/mail-signature/images/{id}" width="1~600" alt="">). ` +
`정화한 뒤 ${SIGNATURE_HTML_MAX_LENGTH.toLocaleString('ko-KR')}자 이하, 서로 다른 그림 ${SIGNATURE_IMAGES_PER_SIGNATURE}장까지(넘으면 400 VAL_001). null·빈 문자열이면 서식 서명 없음. ` +
'어느 서명도 더는 가리키지 않는 그림은 저장할 때 지운다';
const BODY_HTML_EXAMPLE =
'<p style="margin:0"><strong>김아라</strong> · 아라시스템</p><img src="/api/mail-signature/images/3f1c0a2e-0f1b-4f2a-9a1e-6c9f0a1b2c3d" width="144" alt="">';
const BODY_DESCRIPTION = `텍스트 서명 — 텍스트 대체 본문에 넣는다. ${SIGNATURE_TEXT_MAX_LENGTH.toLocaleString('ko-KR')}자 이하(JS 문자열 길이, 넘으면 400 VAL_001)`;
/** 서명 이름 — 앞뒤 공백을 뺀 1~30자(JS 문자열 길이), 제어 문자 없음. 검사는 적은 차례대로(형식 → 길이 → 모양) */
function SignatureName() {
return applyDecorators(
ApiProperty({
description:
`서명 이름 — 앞뒤 공백을 뺀 1~${SIGNATURE_NAME_MAX_LENGTH}자(JS 문자열 길이), 제어 문자(줄바꿈 · 탭 등)는 쓸 수 없다. ` +
'내 다른 서명과 같은 이름(영문 대소문자 무시)이면 400 VAL_001',
example: '대외',
minLength: 1,
maxLength: SIGNATURE_NAME_MAX_LENGTH,
}),
TrimString(),
IsString({ message: '서명 이름 형식이 올바르지 않습니다.' }),
MinCodeUnits(1, { message: '서명 이름을 입력해 주세요.' }),
MaxCodeUnits(SIGNATURE_NAME_MAX_LENGTH, {
message: `서명 이름은 ${SIGNATURE_NAME_MAX_LENGTH}자 이하로 입력해 주세요.`,
}),
Matches(SINGLE_LINE_TEXT_PATTERN, {
message: '서명 이름에는 줄바꿈 같은 제어 문자를 쓸 수 없습니다.',
}),
);
}
/** 「메일에 넣을 서명」 칸 하나 — 내 서명 id 또는 null(넣지 않음). 키는 늘 보낸다 */
function DefaultSignatureId(description: string) {
return applyDecorators(
ApiProperty({
description: `${description} — 내 서명 id, null 이면 넣지 않음. 내 서명이 아닌 id(없음 · 남의 것 · 형식이 틀림)는 404 RES_001`,
nullable: true,
type: String,
example: '3f1c0a2e-0f1b-4f2a-9a1e-6c9f0a1b2c3d',
}),
ValidateIf(unlessNull),
IsString({ message: '서명을 다시 골라 주세요.' }),
MaxCodeUnits(SIGNATURE_ID_MAX_LENGTH, {
message: '서명을 다시 골라 주세요.',
}),
);
}
/** POST /api/mail-signatures · PUT /api/mail-signatures/{id} — 계약 2절 "서명" (16단계, 전체 교체) */
export class SaveMailSignatureDto {
@SignatureName()
name!: string;
@ApiPropertyOptional({
description:
`서명 서식 HTML — 서식 본문과 같은 허용 목록 + 내 서명 그림(<img src="/api/mail-signature/images/{id}" width="1~600" alt="">). ` +
`정화한 뒤 ${SIGNATURE_HTML_MAX_LENGTH.toLocaleString('ko-KR')}자 이하, 서로 다른 그림 ${SIGNATURE_IMAGES_PER_SIGNATURE}장까지(넘으면 400 VAL_001). null·빈 문자열이면 서식 서명 없음. ` +
'서명 HTML 이 더는 가리키지 않는 그림은 저장할 때 지운다',
description: BODY_HTML_DESCRIPTION,
nullable: true,
type: String,
example:
'<p style="margin:0"><strong>김아라</strong> · 아라시스템</p><img src="/api/mail-signature/images/3f1c0a2e-0f1b-4f2a-9a1e-6c9f0a1b2c3d" width="144" alt="">',
example: BODY_HTML_EXAMPLE,
})
@IsOptional()
@IsString({ message: '서명 형식이 올바르지 않습니다.' })
bodyHtml?: string | null;
@ApiPropertyOptional({
description: `텍스트 서명 — 텍스트 대체 본문에 넣는다. ${SIGNATURE_TEXT_MAX_LENGTH.toLocaleString('ko-KR')}자 이하(JS 문자열 길이, 넘으면 400 VAL_001)`,
description: BODY_DESCRIPTION,
nullable: true,
type: String,
example: '김아라 · 아라시스템',
})
@IsOptional()
@IsString({ message: '텍스트 서명 형식이 올바르지 않습니다.' })
body?: string | null;
}
/** GET · PUT /api/mail-signature-defaults — 계약 2절 "서명"의 「메일에 넣을 서명」 (16단계) */
export class MailSignatureDefaultsDto implements MailSignatureDefaults {
@DefaultSignatureId('새 메일에 넣는 서명')
newMessageId!: string | null;
@DefaultSignatureId('답장 · 전달에 넣는 서명(인용 위)')
replyId!: string | null;
}
/** PUT /api/mail-signature — 옛 경로(16단계 전의 웹 탭). 「새 메일」 기본 서명의 본문을 바꾸고 두 표시를 기본 둘로 옮긴다 */
export class UpdateLegacyMailSignatureDto {
@ApiPropertyOptional({
description: BODY_HTML_DESCRIPTION,
nullable: true,
type: String,
example: BODY_HTML_EXAMPLE,
})
@IsOptional()
@IsString({ message: '서명 형식이 올바르지 않습니다.' })
bodyHtml?: string | null;
@ApiPropertyOptional({
description: BODY_DESCRIPTION,
nullable: true,
type: String,
example: '김아라 · 아라시스템',
@@ -45,11 +148,19 @@ export class UpdateMailSignatureDto {
@IsString({ message: '텍스트 서명 형식이 올바르지 않습니다.' })
body?: string | null;
@ApiProperty({ description: '새 메일에 넣는다', example: true })
@ApiProperty({
description:
'true = 이 서명을 새 메일의 기본으로, false = 새 메일에 넣지 않음',
example: true,
})
@IsBoolean({ message: '새 메일에 넣을지 여부를 다시 선택해 주세요.' })
isForNew!: boolean;
@ApiProperty({ description: '답장·전달에 넣는다 (인용 위)', example: true })
@ApiProperty({
description:
'true = 이 서명을 답장·전달의 기본으로, false = 이 서명이 기본이었으면 넣지 않음(다른 서명이 기본이면 그대로)',
example: true,
})
@IsBoolean({ message: '답장·전달에 넣을지 여부를 다시 선택해 주세요.' })
isForReply!: boolean;
}
@@ -82,26 +193,86 @@ export class SignatureImageDto implements SignatureImage {
height!: number;
}
/** 서명 하나 (16단계) */
export class MailSignatureDto implements MailSignature {
@ApiProperty({
description: `서명 서식 HTML (정화한 값, ${SIGNATURE_HTML_MAX_LENGTH.toLocaleString('ko-KR')}자 이하) — 저장한 적 없거나 비었으면 null`,
description: '서명 id (서버가 만든 UUID)',
example: '0b6f7c1e-3a52-4c4e-9f0e-2d1f5a7b8c9d',
})
id!: string;
@ApiProperty({ description: '서명 이름 (1~30자)', example: '대외' })
name!: string;
@ApiProperty({
description: `서명 서식 HTML (정화한 값, ${SIGNATURE_HTML_MAX_LENGTH.toLocaleString('ko-KR')}자 이하) — 없으면 null`,
nullable: true,
type: String,
})
bodyHtml!: string | null;
@ApiProperty({
description: '텍스트 서명 — 저장한 적 없거나 비었으면 null',
description: '텍스트 서명 — 없으면 null',
nullable: true,
type: String,
})
body!: string | null;
@ApiProperty({ description: '새 메일에 넣는다 (기본 true)', example: true })
@ApiProperty({
description: `서명 HTML 이 쓰는 그림 — HTML 에 나온 순서 (${SIGNATURE_IMAGES_PER_SIGNATURE}장까지)`,
type: [SignatureImageDto],
})
images!: SignatureImageDto[];
@ApiProperty({
description: '마지막으로 저장한 시각 (ISO 8601 UTC)',
example: '2026-11-10T05:12:00.000Z',
})
updatedAt!: string;
}
/** GET /api/mail-signatures — 내 서명 목록 */
export class MailSignaturePageDto {
@ApiProperty({
type: [MailSignatureDto],
description: `만든 차례 — ${SIGNATURES_MAX}개까지`,
})
items!: MailSignatureDto[];
@ApiProperty({
type: PageInfoDto,
description: '한 번에 모두 내려준다 (hasNext=false, total = 서명 수)',
})
pageInfo!: PageInfoDto;
}
/** GET · PUT /api/mail-signature — 옛 경로의 서명 (앱 첫 판 · 16단계 전의 웹 탭) */
export class LegacyMailSignatureDto implements LegacyMailSignature {
@ApiProperty({
description: `옛 경로가 보이는 서명(「새 메일」 기본, 없으면 「답장 · 전달」 기본)의 서식 HTML (정화한 값, ${SIGNATURE_HTML_MAX_LENGTH.toLocaleString('ko-KR')}자 이하) — 기본 서명이 둘 다 없거나 비었으면 null`,
nullable: true,
type: String,
})
bodyHtml!: string | null;
@ApiProperty({
description:
'옛 경로가 보이는 서명(「새 메일」 기본, 없으면 「답장 · 전달」 기본)의 텍스트 — 기본 서명이 둘 다 없거나 비었으면 null',
nullable: true,
type: String,
})
body!: string | null;
@ApiProperty({
description:
'「새 메일」 기본 서명이 있다 — 없으면 false (그때 본문이 있으면 「답장 · 전달」 기본 서명이다)',
example: true,
})
isForNew!: boolean;
@ApiProperty({
description: '답장·전달에 넣는다 — 인용 위 (기본 true)',
description:
'「답장 · 전달」 기본이 이 서명이다 — 「새 메일」 기본과 다른 서명이면 false (앱 첫 판은 그때 답장에 서명을 넣지 않는다)',
example: true,
})
isForReply!: boolean;
@@ -113,7 +284,7 @@ export class MailSignatureDto implements MailSignature {
images!: SignatureImageDto[];
@ApiProperty({
description: '저장한 시각 (ISO 8601 UTC) — 저장한 적 없으면 null',
description: '저장한 시각 (ISO 8601 UTC) — 기본 서명이 둘 다 없으면 null',
nullable: true,
type: String,
example: '2026-10-27T05:12:00.000Z',
@@ -20,15 +20,28 @@ export const SIGNATURE_IMAGE_MAX_BYTES = INLINE_IMAGE_MAX_BYTES;
*/
export const SIGNATURE_IMAGE_MAX_DIMENSION = 4096;
/** 서명 하나에 넣을 수 있는 서로 다른 그림 수 — 넘으면 서명 저장(PUT)이 400 VAL_001 */
/** 서명 하나에 넣을 수 있는 서로 다른 그림 수 — 넘으면 서명 저장(POST · PUT)이 400 VAL_001 */
export const SIGNATURE_IMAGES_PER_SIGNATURE = 3;
/**
* 한 사람이 둘 수 있는 서명 그림 수 — 서명에 붙은 것 + 올리고 아직 저장하지 않은 것. 넘으면 올리기가 400 VAL_001.
* 편집기에서 그림을 넣었다 빼기를 되풀이해도(빼도 저장 전·10분 유예 동안은 남는다) 막히지 않게 서명 한도보다 넉넉히 둔다.
* 저장하지 않은 그림은 하루 뒤 정리 작업이, 서명이 더는 가리키지 않는 그림은 다음 저장이 지운다.
* 한 사람이 둘 수 있는 서명 그림 수 — 모든 서명에 붙은 것 + 올리고 아직 저장하지 않은 것. 넘으면 올리기가 400 VAL_001.
* 서명 여럿(16단계 — 10개 × 3장)이 함께 쓰는 칸이라 10 → 20 으로 늘렸다. 편집기에서 그림을 넣었다 빼기를 되풀이해도
* (빼도 저장 전·10분 유예 동안은 남는다) 막히지 않게 서명 하나의 한도보다 넉넉히 둔다.
* 저장하지 않은 그림은 하루 뒤 정리 작업이, 어느 서명도 더는 가리키지 않는 그림은 다음 저장이 지운다.
*/
export const SIGNATURE_IMAGES_STORED_MAX = 10;
export const SIGNATURE_IMAGES_STORED_MAX = 20;
/** 한 사람이 둘 수 있는 서명 수 (16단계) — 11번째 만들기는 400 VAL_001 */
export const SIGNATURES_MAX = 10;
/** 서명 이름의 길이 — 앞뒤 공백을 뺀 JS 문자열 길이 (16단계). 같은 이름(ASCII 대소문자 무시)은 400 VAL_001 */
export const SIGNATURE_NAME_MAX_LENGTH = 30;
/**
* 옛 서명(한 사람에 하나)을 옮긴 이름 — 마이그레이션 `MultipleMailSignatures` 와 옛 경로 `PUT /api/mail-signature` 가
* 기본 서명(「새 메일」 · 「답장 · 전달」)이 둘 다 없을 때 만드는 서명의 이름이다 (계약 2절 "서명" — 16단계)
*/
export const LEGACY_SIGNATURE_NAME = '기본 서명';
/** 서명 서식(HTML) 최대 길이 — 정화한 뒤 JS 문자열 길이 */
export const SIGNATURE_HTML_MAX_LENGTH = 10_000;
@@ -37,9 +37,9 @@ import { SkipTransform } from '@/common/decorators/skip-transform.decorator';
import { BusinessException } from '@/common/errors/business.exception';
import { JwtAuthGuard } from '@/common/guards/jwt-auth.guard';
import {
MailSignatureDto,
LegacyMailSignatureDto,
SignatureImageDto,
UpdateMailSignatureDto,
UpdateLegacyMailSignatureDto,
} from './dto/mail-signature.dto';
import {
SIGNATURE_HTML_MAX_LENGTH,
@@ -57,7 +57,8 @@ import { SignatureImageIdPipe } from './signature-image-id.pipe';
const IMAGE_CONTENT_TYPES = ['image/png', 'image/jpeg', 'image/gif'];
// 메일 서명 (계약 2절 "서명") — 로그인한 직원 자신의 서명·그림만 다룬다.
// 그림 업로드는 modules/upload 패턴을 따른다 (가드가 multer 보다 먼저 돌아 미인증 요청은 파일을 읽기 전에 막힌다).
// 16단계부터 서명은 여럿이다(mail-signatures.controller.ts) — 여기의 GET · PUT 은 「새 메일」 기본 서명(없으면 「답장 · 전달」 기본)을 다루는 옛 경로(앱 첫 판 · 옛 웹 탭)이고,
// 그림 경로는 모든 서명이 함께 쓴다. 그림 업로드는 modules/upload 패턴을 따른다 (가드가 multer 보다 먼저 돌아 미인증 요청은 파일을 읽기 전에 막힌다).
@ApiTags('Mail')
@ApiCookieAuth()
@UseGuards(JwtAuthGuard)
@@ -68,33 +69,37 @@ export class MailSignatureController {
@Get()
@ApiOperation({
summary: '내 서명',
summary: '「새 메일」 기본 서명 — 옛 경로 (앱 첫 판)',
description:
'저장한 적 없으면 기본값(bodyHtml·body null, isForNew·isForReply true, images [])을 돌려준다.',
'16단계부터 서명은 여럿이다(/api/mail-signatures). 이 경로는 「새 메일」 기본 서명을 15단계까지의 모양으로 준다 — ' +
'isForNew = 「새 메일」 기본 서명이 있음, isForReply = 「답장 · 전달」 기본이 같은 서명. ' +
'「새 메일」 기본이 없으면 「답장 · 전달」 기본 서명을 isForNew false · isForReply true 로 준다. ' +
'둘 다 없으면 { bodyHtml: null, body: null, isForNew: false, isForReply: false, images: [], updatedAt: null }.',
})
@ApiStandardResponse(MailSignatureDto, { description: '서명' })
@ApiStandardResponse(LegacyMailSignatureDto, { description: '서명' })
getSignature(@CurrentUser() user: AuthenticatedUser) {
return this.signatures.getSignature(user.email);
return this.signatures.getLegacySignature(user.email);
}
@Put()
@ApiOperation({
summary: '서명 저장 (전체 교체)',
summary: '「새 메일」 기본 서명 저장 — 옛 경로 (16단계 전의 웹 탭)',
description:
'옛 GET 이 보이는 서명(「새 메일」 기본, 없으면 「답장 · 전달」 기본, 둘 다 없으면 "기본 서명"이라는 이름의 서명 — 없으면 만든다)의 본문을 바꾸고, isForNew · isForReply 를 「메일에 넣을 서명」 둘로 옮긴다. ' +
`bodyHtml 은 서식 본문 허용 목록 + 내 서명 그림으로 정화해 저장한다(정화 뒤 ${SIGNATURE_HTML_MAX_LENGTH.toLocaleString('ko-KR')}자 이하), ` +
`body 는 ${SIGNATURE_TEXT_MAX_LENGTH.toLocaleString('ko-KR')}자 이하, 서로 다른 그림은 ${SIGNATURE_IMAGES_PER_SIGNATURE}장까지(같은 그림 두 번은 한 장). ` +
'서명 HTML 이 더는 가리키지 않는 그림은 지운다 — 막 올리고(10분 안) 아직 넣지 않은 그림은 남긴다.',
'어느 서명도 더는 가리키지 않는 그림은 지운다 — 막 올리고(10분 안) 아직 넣지 않은 그림은 남긴다.',
})
@ApiStandardResponse(MailSignatureDto, { description: '저장한 서명' })
@ApiStandardResponse(LegacyMailSignatureDto, { description: '저장한 서명' })
@ApiErrorResponse(
'VAL_001',
`형식 오류 · 텍스트 서명 2,000자 초과 · 정화한 서식 서명 10,000자 초과 · 서식 서명이 너무 깊게 중첩됨 · 그림 ${SIGNATURE_IMAGES_PER_SIGNATURE}장 초과`,
`형식 오류 · 텍스트 서명 2,000자 초과 · 정화한 서식 서명 10,000자 초과 · 서식 서명이 너무 깊게 중첩됨 · 그림 ${SIGNATURE_IMAGES_PER_SIGNATURE}장 초과 · 서명을 새로 만들어야 하는데 이미 10개`,
)
saveSignature(
@CurrentUser() user: AuthenticatedUser,
@Body() dto: UpdateMailSignatureDto,
@Body() dto: UpdateLegacyMailSignatureDto,
) {
return this.signatures.saveSignature(user.email, dto);
return this.signatures.saveLegacySignature(user.email, dto);
}
@Post('images')
@@ -103,8 +108,8 @@ export class MailSignatureController {
summary: '서명 그림 올리기 (파일 1개)',
description:
`PNG·JPEG·GIF 만 — 확장자·MIME·파일 앞부분(내용)을 모두 본다(WebP·SVG 는 받지 않는다). ` +
`한 장 ${SIGNATURE_IMAGE_MAX_BYTES.toLocaleString('ko-KR')}바이트 이하. 한 사람이 둘 수 있는 그림은 ${SIGNATURE_IMAGES_STORED_MAX}장까지 — 서명에 붙은 것 + 올리고 아직 저장하지 않은 것 ` +
`(서명 하나에는 ${SIGNATURE_IMAGES_PER_SIGNATURE}장 — PUT 이 본다). ` +
`한 장 ${SIGNATURE_IMAGE_MAX_BYTES.toLocaleString('ko-KR')}바이트 이하. 한 사람이 둘 수 있는 그림은 ${SIGNATURE_IMAGES_STORED_MAX}장까지 — 모든 서명에 붙은 것 + 올리고 아직 저장하지 않은 것 ` +
`(서명 하나에는 ${SIGNATURE_IMAGES_PER_SIGNATURE}장 — 서명 저장이 본다). ` +
'서명을 저장하지 않으면 하루 뒤 지운다.',
})
@ApiConsumes('multipart/form-data')
@@ -1,35 +1,63 @@
import { Column, Entity, PrimaryColumn } from 'typeorm';
import { Column, Entity, Index, PrimaryColumn } from 'typeorm';
/** 메일 주소 최대 길이 (RFC 5321 경로 한도) — mail.constants 의 MAX_EMAIL_LENGTH 와 같다 */
const EMAIL_COLUMN_LENGTH = 254;
/**
* 서명 이름 칸 — 이름은 30자(JS 문자열 길이 — SIGNATURE_NAME_MAX_LENGTH)까지라 넉넉히 둔다.
* varchar 는 코드 포인트로 세어 JS 길이 30 이면 늘 들어간다 (서로게이트 쌍 하나가 JS 로는 2자 · 여기서는 1자)
*/
const NAME_COLUMN_LENGTH = 64;
/**
* 메일 서명 (계약 2절 "서명") — 사용자마다 하나. 메일 엔진(JMAP Identity)이 아니라 우리 DB 에 둔다 —
* 엔진을 바꿔도 남고, DB 백업에 함께 들어간다.
* HTML 은 저장하기 전에 보내는 정화(서명 그림 포함)를 거친 값이다.
* 메일 서명 (계약 2절 "서명") — 16단계부터 한 사람에 여러 개(10개까지)다. 메일 엔진(JMAP Identity)이 아니라 우리 DB 에 둔다 —
* 엔진을 바꿔도 남고, DB 백업에 함께 들어간다. HTML 은 저장하기 전에 보내는 정화(서명 그림 포함)를 거친 값이다.
*
* 「메일에 넣을 서명」 둘(새 메일 · 답장과 전달)은 서명 줄의 표시로 둔다 — 한 사람에 하나씩만(부분 유일 인덱스).
* 서명을 지우면 표시도 함께 없어져 그 자리는 "넣지 않음"이 된다. 퇴사 마무리는 주소로 이 표의 줄을 지운다(modules/ops/ops-data.service.ts).
* 목록은 만든 차례(created_at, 같으면 id)다.
*/
@Entity({ name: 'mail_signatures' })
@Index('IDX_mail_signatures_user_email_created_at', ['userEmail', 'createdAt'])
@Index('UQ_mail_signatures_new_message_default', ['userEmail'], {
unique: true,
where: '"is_new_message_default"',
})
@Index('UQ_mail_signatures_reply_default', ['userEmail'], {
unique: true,
where: '"is_reply_default"',
})
export class MailSignatureEntity {
@PrimaryColumn({
/** 서버가 만든 UUID (소문자) */
@PrimaryColumn({ type: 'uuid' })
id!: string;
@Column({
name: 'user_email',
type: 'varchar',
length: EMAIL_COLUMN_LENGTH,
})
userEmail!: string;
/** 앞뒤 공백을 뺀 1~30자 — 같은 사람 안에서 겹치지 않는다(ASCII 대소문자 무시 — 서비스가 사용자 잠금 안에서 본다) */
@Column({ type: 'varchar', length: NAME_COLUMN_LENGTH })
name!: string;
@Column({ name: 'body_html', type: 'text', nullable: true })
bodyHtml!: string | null;
@Column({ name: 'body_text', type: 'text', nullable: true })
bodyText!: string | null;
/** 새 메일에 넣는다 */
@Column({ name: 'is_for_new', type: 'boolean', default: true })
isForNew!: boolean;
/** 「새 메일」에 넣는 서명 — 한 사람에 하나까지 */
@Column({ name: 'is_new_message_default', type: 'boolean', default: false })
isNewMessageDefault!: boolean;
/** 답장·전달에 넣는다 (인용 위) */
@Column({ name: 'is_for_reply', type: 'boolean', default: true })
isForReply!: boolean;
/** 「답장 · 전달」에 넣는 서명(인용 위) — 한 사람에 하나까지 */
@Column({ name: 'is_reply_default', type: 'boolean', default: false })
isReplyDefault!: boolean;
@Column({ name: 'created_at', type: 'timestamptz' })
createdAt!: Date;
@Column({ name: 'updated_at', type: 'timestamptz' })
updatedAt!: Date;
@@ -12,11 +12,15 @@ import { MailSignatureController } from './mail-signature.controller';
import { MailSignatureEntity } from './mail-signature.entity';
import { MailSignatureRepository } from './mail-signature.repository';
import { MailSignatureService } from './mail-signature.service';
import {
MailSignatureDefaultsController,
MailSignaturesController,
} from './mail-signatures.controller';
import { SIGNATURE_IMAGE_TYPES_BY_EXTENSION } from './signature-image-format';
import { SignatureImageStorage } from './signature-image-storage';
/**
* 메일 서명 모듈 (계약 2절 "서명") — 서명·그림은 서버 DB 와 업로드 볼륨에 둔다.
* 메일 서명 모듈 (계약 2절 "서명") — 서명·그림은 서버 DB 와 업로드 볼륨에 둔다. 16단계부터 한 사람에 서명 여러 개(10개까지).
* 메일 모듈이 발송·임시 저장 때 그림을 쓰도록 서비스를 내보낸다 (이 모듈은 메일 모듈을 가져오지 않는다).
*
* 그림 업로드: 한 장 300KB 이하라 메모리에 받는다 — 파일 내용(앞부분)을 본 뒤에만 디스크에 서버가 만든 이름으로 쓴다.
@@ -47,7 +51,13 @@ import { SignatureImageStorage } from './signature-image-storage';
},
}),
],
controllers: [MailSignatureController],
controllers: [
// 여러 서명 · 「메일에 넣을 서명」 둘 (16단계)
MailSignaturesController,
MailSignatureDefaultsController,
// 옛 경로(「새 메일」 기본 서명 — 앱 첫 판) · 서명 그림
MailSignatureController,
],
providers: [
MailSignatureRepository,
{
@@ -1,36 +1,75 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { DataSource, In, IsNull, LessThan, type Repository } from 'typeorm';
import {
DataSource,
In,
IsNull,
LessThan,
type EntityManager,
type Repository,
} from 'typeorm';
import { MailSignatureImageEntity } from './mail-signature-image.entity';
import { MailSignatureEntity } from './mail-signature.entity';
import type { MailSignatureDefaults } from './mail-signature.types';
/**
* 서명 저장을 사용자별로 차례대로 — `pg_advisory_xact_lock(이 값, hashtext(주소))` (트랜잭션이 끝나면 풀린다).
* 서명 행이 아직 없을 때도 잠긴다 (행 잠금 FOR UPDATE 는 없는 행을 잠그지 못한다). 첫 인자는 다른 잠금과 섞이지 않게 붙인 이름표 — 'SIG1' 의 ASCII.
* 서명 줄이 아직 없을 때도 잠긴다 (행 잠금 FOR UPDATE 는 없는 행을 잠그지 못한다). 첫 인자는 다른 잠금과 섞이지 않게 붙인 이름표 — 'SIG1' 의 ASCII.
*/
const SIGNATURE_SAVE_LOCK = 0x53494731;
/** 서명 저장 — 잠근 뒤 본 그림으로 저장할 서명을 정한다 (서비스가 그사이 없어진 그림을 서명에서 뺀다) */
export type PrepareSignature = (
images: readonly MailSignatureImageEntity[],
) => {
readonly signature: MailSignatureEntity;
readonly referencedImageIds: readonly string[];
};
/** 목록 차례 — 만든 차례, 같은 시각이면 id */
const SIGNATURE_ORDER = { createdAt: 'ASC', id: 'ASC' } as const;
/** 서명 저장 결과 */
export interface SavedSignature {
readonly signature: MailSignatureEntity;
/** 저장한 뒤 남은 그 사람의 그림 (올린 순서) */
/** 잠근 뒤 본 그 사람의 서명 · 그림 */
export interface SignatureSnapshot {
/** 만든 차례 */
readonly signatures: readonly MailSignatureEntity[];
/** 올린 차례 */
readonly images: readonly MailSignatureImageEntity[];
}
/** 새로 넣을 서명 — 기본 표시는 plan 의 defaults 로만 바꾼다(넣을 때는 늘 꺼짐) */
export type NewSignatureRow = Omit<
MailSignatureEntity,
'isNewMessageDefault' | 'isReplyDefault'
>;
/** 고칠 서명 — 이름 · 본문 · 저장 시각만 */
export type SignatureRowUpdate = Pick<
MailSignatureEntity,
'id' | 'name' | 'bodyHtml' | 'bodyText' | 'updatedAt'
>;
/** 잠근 뒤 정한 바꿀 것 — 서비스가 스냅숏을 보고 정한다(한도 · 이름 겹침은 서비스가 던진다 — 그러면 아무것도 바꾸지 않는다) */
export interface SignaturePlan {
readonly insert?: NewSignatureRow;
readonly update?: SignatureRowUpdate;
readonly removeId?: string;
/** 「메일에 넣을 서명」 둘을 이 값으로 — 없으면 그대로 (지운 서명의 표시는 줄과 함께 없어진다) */
readonly defaults?: MailSignatureDefaults;
/** 바꾼 뒤 모든 서명 HTML 이 가리키는 그림 (합집합) — 이 밖의 그림은 지운다(막 올린 것은 남긴다) */
readonly referencedImageIds: readonly string[];
}
export type PlanSignatureChange = (
snapshot: SignatureSnapshot,
) => SignaturePlan;
/** 서명 바꾸기 결과 */
export interface SignatureChangeResult {
/** 바꾼 뒤 그 사람의 서명 — 만든 차례, 기본 표시 포함 */
readonly signatures: MailSignatureEntity[];
/** 바꾼 뒤 남은 그 사람의 그림 (올린 차례) */
readonly images: MailSignatureImageEntity[];
/** 지운 그림 — 파일은 부르는 쪽이 커밋 뒤에 지운다 */
readonly removed: MailSignatureImageEntity[];
}
/**
* 서명·서명 그림 표 접근 — 판단(정화·한도·누가 볼 수 있나)은 MailSignatureService 가 한다.
* 그림을 찾을 때는 늘 주인(user_email)을 함께 조건에 넣는다 — 남의 그림 id 로는 아무것도 나오지 않는다.
* 서명·서명 그림 표 접근 — 판단(정화·한도·이름 겹침·누가 볼 수 있나)은 MailSignatureService 가 한다.
* 서명 · 그림을 찾을 때는 늘 주인(user_email)을 함께 조건에 넣는다 — 남의 id 로는 아무것도 나오지 않는다.
*/
@Injectable()
export class MailSignatureRepository {
@@ -42,8 +81,12 @@ export class MailSignatureRepository {
private readonly dataSource: DataSource,
) {}
findSignature(userEmail: string): Promise<MailSignatureEntity | null> {
return this.signatures.findOneBy({ userEmail });
/** 그 사람의 서명 — 만든 차례 */
listSignatures(userEmail: string): Promise<MailSignatureEntity[]> {
return this.signatures.find({
where: { userEmail },
order: SIGNATURE_ORDER,
});
}
/** 그 사람의 그림 — 올린 순서대로 */
@@ -70,31 +113,62 @@ export class MailSignatureRepository {
}
/**
* 서명을 저장한다 — 한 트랜잭션에서, 사용자별로 차례대로 (두 탭이 동시에 저장해도 서로의 그림을 엉뚱하게 지우지 않게).
* 1) 사용자 잠금 2) 그 사람의 그림을 잠가 읽는다(FOR UPDATE — 정리 작업이 그사이 지우지 못한다) 3) `prepare` 가 저장할 서명을 정한다
* 4) 서명을 넣거나 고치고, 서명이 가리키는 그림은 붙은 것으로 표시하고, 가리키지 않는 그림은 지운다 —
* 다만 한 번도 저장하지 않은 그림 중 `keepUploadedAfter` 뒤에 올린 것은 남긴다 (막 올린 그림 — SIGNATURE_IMAGE_UPLOAD_GRACE_MS).
* 서명을 바꾼다(만들기 · 고치기 · 지우기 · 기본 바꾸기) — 한 트랜잭션에서, 사용자별로 차례대로 (두 탭이 동시에 저장해도 서로의 그림을 엉뚱하게 지우지 않게).
* 1) 사용자 잠금 2) 그 사람의 서명과 그림을 읽는다(그림은 FOR UPDATE — 정리 작업이 그사이 지우지 못한다) 3) `plan` 이 바꿀 것을 정한다
* 4) 넣기 · 고치기 · 지우기 → 기본 표시(끄고 나서 켠다 — 부분 유일 인덱스는 줄마다 바로 보므로) →
* 모든 서명이 가리키는 그림은 붙은 것으로 표시하고, 어느 서명도 가리키지 않는 그림은 지운다 —
* 다만 한 번도 저장하지 않은 그림 중 `keepUploadedAfter` 뒤에 올린 것은 남긴다 (막 올린 그림 — SIGNATURE_IMAGE_UPLOAD_GRACE_MS)
* 5) 바꾼 뒤의 서명을 다시 읽어 돌려준다
*/
saveSignature(
change(
userEmail: string,
prepare: PrepareSignature,
plan: PlanSignatureChange,
now: Date,
keepUploadedAfter: Date,
): Promise<SavedSignature> {
): Promise<SignatureChangeResult> {
return this.dataSource.transaction(async (manager) => {
await manager.query('SELECT pg_advisory_xact_lock($1, hashtext($2))', [
SIGNATURE_SAVE_LOCK,
userEmail,
]);
const signatures = await manager.find(MailSignatureEntity, {
where: { userEmail },
order: SIGNATURE_ORDER,
});
const images = await manager.find(MailSignatureImageEntity, {
where: { userEmail },
order: { createdAt: 'ASC' },
lock: { mode: 'pessimistic_write' },
});
const { signature, referencedImageIds } = prepare(images);
await manager.upsert(MailSignatureEntity, signature, ['userEmail']);
const next = plan({ signatures, images });
const referenced = new Set(referencedImageIds);
if (next.insert) {
await manager.insert(MailSignatureEntity, {
...next.insert,
userEmail,
isNewMessageDefault: false,
isReplyDefault: false,
});
}
if (next.update) {
const { id, name, bodyHtml, bodyText, updatedAt } = next.update;
await manager.update(
MailSignatureEntity,
{ id, userEmail },
{ name, bodyHtml, bodyText, updatedAt },
);
}
if (next.removeId) {
await manager.delete(MailSignatureEntity, {
id: next.removeId,
userEmail,
});
}
if (next.defaults) {
await this.applyDefaults(manager, userEmail, next.defaults);
}
const referenced = new Set(next.referencedImageIds);
const attach = images
.filter(
(image) => referenced.has(image.id) && image.attachedAt === null,
@@ -105,7 +179,6 @@ export class MailSignatureRepository {
!referenced.has(image.id) &&
(image.attachedAt !== null || image.createdAt <= keepUploadedAfter),
);
if (attach.length > 0) {
await manager.update(
MailSignatureImageEntity,
@@ -118,9 +191,13 @@ export class MailSignatureRepository {
id: In(stale.map((image) => image.id)),
});
}
const attached = new Set(attach);
return {
signature,
signatures: await manager.find(MailSignatureEntity, {
where: { userEmail },
order: SIGNATURE_ORDER,
}),
images: images
.filter((image) => !stale.includes(image))
.map((image) =>
@@ -131,6 +208,33 @@ export class MailSignatureRepository {
});
}
/**
* 기본 표시를 그 값으로 — 칸마다 먼저 다른 줄의 표시를 끄고 그 줄을 켠다(한 문장으로 바꾸면 줄마다 보는 유일 인덱스에 걸릴 수 있다).
* id 는 서비스가 그 사람의 서명인지 본 값이다 (주인 조건도 함께 건다)
*/
private async applyDefaults(
manager: EntityManager,
userEmail: string,
defaults: MailSignatureDefaults,
): Promise<void> {
const columns = [
['is_new_message_default', defaults.newMessageId],
['is_reply_default', defaults.replyId],
] as const;
for (const [column, id] of columns) {
await manager.query(
`UPDATE "mail_signatures" SET "${column}" = false WHERE "user_email" = $1 AND "${column}" AND "id" IS DISTINCT FROM $2::uuid`,
[userEmail, id],
);
if (id !== null) {
await manager.query(
`UPDATE "mail_signatures" SET "${column}" = true WHERE "user_email" = $1 AND "id" = $2::uuid AND NOT "${column}"`,
[userEmail, id],
);
}
}
}
/** 서명에 붙지 않은 채 `before` 전에 올린 그림 (정리 작업용 — 부분 인덱스를 탄다) */
findUnattachedBefore(before: Date): Promise<MailSignatureImageEntity[]> {
return this.images.find({
@@ -8,6 +8,8 @@ import { BusinessException } from '@/common/errors/business.exception';
import { MAX_BODY_HTML_LENGTH } from '@/modules/mail/mail.constants';
import { MailSignatureImageEntity } from './mail-signature-image.entity';
import {
LEGACY_SIGNATURE_NAME,
SIGNATURES_MAX,
SIGNATURE_IMAGES_PER_SIGNATURE,
SIGNATURE_IMAGES_STORED_MAX,
SIGNATURE_IMAGE_MAX_BYTES,
@@ -17,34 +19,37 @@ import {
import { MailSignatureEntity } from './mail-signature.entity';
import type {
MailSignatureRepository,
PrepareSignature,
SavedSignature,
PlanSignatureChange,
SignatureChangeResult,
SignaturePlan,
} from './mail-signature.repository';
import { MailSignatureService } from './mail-signature.service';
import type { MailSignatureDefaults } from './mail-signature.types';
import { SignatureImageStorage } from './signature-image-storage';
const ARA = 'ara.kim@arasystem.test';
const DOYUN = 'doyun.lee@arasystem.test';
const src = (id: string) => `/api/mail-signature/images/${id}`;
const NOT_MINE = '11111111-1111-4111-8111-111111111111';
/**
* 표 대역 — MailSignatureRepository 와 같은 판단(주인 조건·저장 때 지울 그림)을 메모리에서 한다.
* 실제 SQL(트랜잭션·부분 인덱스)은 e2e 가 본다.
* 표 대역 — MailSignatureRepository 와 같은 판단(주인 조건 · 기본 표시 · 바꾼 뒤 지울 그림)을 메모리에서 한다.
* 실제 SQL(트랜잭션 · 사용자 잠금 · 부분 유일 인덱스)은 e2e 가 본다.
*/
class FakeRepository {
readonly signatures = new Map<string, MailSignatureEntity>();
signatures: MailSignatureEntity[] = [];
images: MailSignatureImageEntity[] = [];
/** 다음 insertImage 를 실패시킨다 */
failNextInsert = false;
/** 다음 저장이 잠그기 직전에 이 그림이 지워진다 (다른 탭의 저장·정리 작업이 먼저 끝난 경우) */
deletedBeforeNextSave: string | null = null;
readonly saveCalls: {
referenced: readonly string[];
/** 다음 바꾸기가 잠그기 직전에 이 그림이 지워진다 (다른 탭의 저장·정리 작업이 먼저 끝난 경우) */
deletedBeforeNextChange: string | null = null;
readonly changeCalls: {
plan: SignaturePlan;
keepUploadedAfter: Date;
}[] = [];
findSignature(user: string): Promise<MailSignatureEntity | null> {
return Promise.resolve(this.signatures.get(user) ?? null);
listSignatures(user: string): Promise<MailSignatureEntity[]> {
return Promise.resolve(this.mine(user).map((row) => ({ ...row })));
}
listImages(user: string): Promise<MailSignatureImageEntity[]> {
@@ -76,38 +81,91 @@ class FakeRepository {
return Promise.resolve();
}
saveSignature(
change(
user: string,
prepare: PrepareSignature,
plan: PlanSignatureChange,
now: Date,
keepUploadedAfter: Date,
): Promise<SavedSignature> {
if (this.deletedBeforeNextSave) {
const gone = this.deletedBeforeNextSave;
this.deletedBeforeNextSave = null;
): Promise<SignatureChangeResult> {
if (this.deletedBeforeNextChange) {
const gone = this.deletedBeforeNextChange;
this.deletedBeforeNextChange = null;
this.images = this.images.filter((row) => row.id !== gone);
}
const mine = this.images.filter((row) => row.userEmail === user);
const { signature, referencedImageIds: referenced } = prepare(mine);
this.saveCalls.push({ referenced, keepUploadedAfter });
this.signatures.set(signature.userEmail, signature);
for (const row of mine) {
if (referenced.includes(row.id) && row.attachedAt === null) {
const images = this.images.filter((row) => row.userEmail === user);
let next: SignaturePlan;
try {
next = plan({
signatures: this.mine(user).map((row) => ({ ...row })),
images,
});
} catch (error) {
// 정한 것을 던지면(한도 · 이름 겹침) 아무것도 바꾸지 않는다 — 실제 표는 트랜잭션을 되돌린다
return Promise.reject(
error instanceof Error ? error : new Error(String(error)),
);
}
this.changeCalls.push({ plan: next, keepUploadedAfter });
if (next.insert) {
this.signatures.push({
...next.insert,
isNewMessageDefault: false,
isReplyDefault: false,
});
}
if (next.update) {
const { id, name, bodyHtml, bodyText, updatedAt } = next.update;
this.signatures = this.signatures.map((row) =>
row.id === id && row.userEmail === user
? { ...row, name, bodyHtml, bodyText, updatedAt }
: row,
);
}
if (next.removeId) {
this.signatures = this.signatures.filter(
(row) => !(row.id === next.removeId && row.userEmail === user),
);
}
if (next.defaults) this.applyDefaults(user, next.defaults);
const referenced = new Set(next.referencedImageIds);
for (const row of images) {
if (referenced.has(row.id) && row.attachedAt === null) {
row.attachedAt = now;
}
}
const stale = mine.filter(
const stale = images.filter(
(row) =>
!referenced.includes(row.id) &&
!referenced.has(row.id) &&
(row.attachedAt !== null || row.createdAt <= keepUploadedAfter),
);
this.images = this.images.filter((row) => !stale.includes(row));
return Promise.resolve({
signature,
images: mine.filter((row) => !stale.includes(row)),
signatures: this.mine(user).map((row) => ({ ...row })),
images: images.filter((row) => !stale.includes(row)),
removed: stale,
});
}
/** 만든 차례 (서비스가 사람마다 만든 시각을 늘 늘려 준다) */
private mine(user: string): MailSignatureEntity[] {
return this.signatures
.filter((row) => row.userEmail === user)
.sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime());
}
private applyDefaults(user: string, defaults: MailSignatureDefaults): void {
this.signatures = this.signatures.map((row) =>
row.userEmail === user
? {
...row,
isNewMessageDefault: row.id === defaults.newMessageId,
isReplyDefault: row.id === defaults.replyId,
}
: row,
);
}
}
describe('MailSignatureService', () => {
@@ -139,7 +197,7 @@ describe('MailSignatureService', () => {
): Promise<MailSignatureImageEntity> {
const id =
overrides.id ??
`0000000${repository.images.length}-0000-4000-8000-000000000000`;
`${String(repository.images.length).padStart(8, '0')}-0000-4000-8000-000000000000`;
const row: MailSignatureImageEntity = {
id,
userEmail: ARA,
@@ -157,6 +215,10 @@ describe('MailSignatureService', () => {
return row;
}
/** 서명 하나를 만든다 (이름 · 텍스트만) */
const create = (name: string, extra: { bodyHtml?: string | null } = {}) =>
service.createSignature(ARA, { name, body: `${name} 서명`, ...extra });
beforeEach(() => {
dir = mkdtempSync(join(tmpdir(), 'signature-images-'));
repository = new FakeRepository();
@@ -175,110 +237,269 @@ describe('MailSignatureService', () => {
rmSync(dir, { recursive: true, force: true });
});
it('저장한 적 없으면 기본값을 돌려준다', async () => {
await expect(service.getSignature(ARA)).resolves.toEqual({
bodyHtml: null,
body: null,
isForNew: true,
isForReply: true,
images: [],
updatedAt: null,
describe('여러 서명 (16단계)', () => {
it('서명이 없으면 빈 목록 · 기본 둘 null — 첫 서명은 두 곳의 기본이 되고, 다음 서명은 기본을 바꾸지 않는다', async () => {
await expect(service.listSignatures(ARA)).resolves.toEqual({
items: [],
pageInfo: { hasNext: false, nextCursor: null, total: 0 },
});
await expect(service.getDefaults(ARA)).resolves.toEqual({
newMessageId: null,
replyId: null,
});
const first = await create('사내');
await expect(service.getDefaults(ARA)).resolves.toEqual({
newMessageId: first.id,
replyId: first.id,
});
const second = await create('대외');
await expect(service.getDefaults(ARA)).resolves.toEqual({
newMessageId: first.id,
replyId: first.id,
});
const list = await service.listSignatures(` ${ARA.toUpperCase()} `);
expect(list.items.map((item) => [item.id, item.name])).toEqual([
[first.id, '사내'],
[second.id, '대외'],
]);
expect(list.pageInfo).toEqual({
hasNext: false,
nextCursor: null,
total: 2,
});
// 다른 사람의 서명은 따로다 — 그 사람의 첫 서명도 그 사람의 기본이 된다
const doyun = await service.createSignature(DOYUN, { name: '사내' });
await expect(service.getDefaults(DOYUN)).resolves.toEqual({
newMessageId: doyun.id,
replyId: doyun.id,
});
});
});
describe('저장', () => {
it('서식 서명은 보내는 정화를 거쳐 저장한다 — 내 그림만 남고, 서명이 쓰는 그림을 함께 돌려준다', async () => {
it('서식은 보내는 정화를 거쳐 저장한다 — 내 그림만 남고, 서명이 쓰는 그림 · 저장 시각을 함께 돌려준다', async () => {
const mine = await storedImage();
const html = `<p onclick="x()"><strong>김아라</strong><script>alert(1)</script></p><img src="${src(mine.id)}" width="144" alt="" style="border:0"><img src="${src('11111111-1111-4111-8111-111111111111')}">`;
const html = `<p onclick="x()"><strong>김아라</strong><script>alert(1)</script></p><img src="${src(mine.id)}" width="144" alt="" style="border:0"><img src="${src(NOT_MINE)}">`;
const saved = await service.saveSignature(` ${ARA.toUpperCase()} `, {
const saved = await service.createSignature(ARA, {
name: '대외',
bodyHtml: html,
body: '김아라 · 아라시스템',
isForNew: true,
isForReply: false,
});
expect(saved).toEqual({
id: expect.stringMatching(/^[0-9a-f-]{36}$/) as string,
name: '대외',
bodyHtml: `<p><strong>김아라</strong></p><img src="${src(mine.id)}" width="144" alt="" />`,
body: '김아라 · 아라시스템',
isForNew: true,
isForReply: false,
images: [
{ id: mine.id, type: 'image/png', size: 33, width: 10, height: 10 },
],
updatedAt: expect.any(String) as string,
});
// 주소는 소문자로 모은다 — 같은 사람의 서명이 둘로 나뉘지 않게
expect(repository.signatures.has(ARA)).toBe(true);
expect(repository.saveCalls[0].referenced).toEqual([mine.id]);
await expect(service.getSignature(ARA)).resolves.toEqual(saved);
expect(repository.changeCalls[0].plan.referencedImageIds).toEqual([
mine.id,
]);
expect((await service.listSignatures(ARA)).items).toEqual([saved]);
});
it('막 올린 그림(10분 안)은 서명에 없어도 남기도록 기준 시각을 넘긴다 — 올리기와 저장이 엇갈려도 편집기의 그림이 사라지지 않게', async () => {
const before = Date.now();
it(`${SIGNATURES_MAX}개까지 — 11번째는 400 VAL_001, 같은 이름(영문 대소문자 무시)도 VAL_001 — 아무것도 저장하지 않는다`, async () => {
await create('Office');
expect(await failureOf(() => create('office'))).toEqual({
code: 'VAL_001',
reason: 'signature-name-taken',
});
// 영문 밖의 글자는 대소문자를 가리지 않고 그대로 견준다
await expect(create('ÖFFICE')).resolves.toMatchObject({ name: 'ÖFFICE' });
for (let index = 3; index <= SIGNATURES_MAX; index += 1) {
await create(`서명 ${index}`);
}
const before = repository.signatures.length;
expect(await failureOf(() => create('열한 번째'))).toEqual({
code: 'VAL_001',
reason: 'too-many-signatures',
});
expect(repository.signatures).toHaveLength(before);
});
await service.saveSignature(ARA, {
bodyHtml: null,
body: null,
isForNew: true,
isForReply: true,
it('고치기는 이름 · 본문을 바꾸고 기본 표시는 그대로 — 다른 서명과 같은 이름은 VAL_001, 자기 이름(대소문자만 바꿈)은 된다', async () => {
const a = await create('사내');
const b = await create('대외');
const updated = await service.updateSignature(ARA, a.id.toUpperCase(), {
name: '사내 (새)',
body: '바꾼 서명',
bodyHtml: '<p>바꾼 서명</p>',
});
expect(updated).toMatchObject({
id: a.id,
name: '사내 (새)',
body: '바꾼 서명',
bodyHtml: '<p>바꾼 서명</p>',
});
await expect(service.getDefaults(ARA)).resolves.toEqual({
newMessageId: a.id,
replyId: a.id,
});
const { keepUploadedAfter } = repository.saveCalls[0];
expect(
await failureOf(() =>
service.updateSignature(ARA, b.id, { name: '사내 (새)' }),
),
).toEqual({ code: 'VAL_001', reason: 'signature-name-taken' });
await expect(
service.updateSignature(ARA, b.id, { name: '대외' }),
).resolves.toMatchObject({ name: '대외', body: null, bodyHtml: null });
});
it('없는 서명 · 남의 서명은 RES_001 — 고치기 · 지우기 · 기본 고르기 모두, 아무것도 바꾸지 않는다', async () => {
const mine = await create('사내');
const doyuns = await service.createSignature(DOYUN, { name: '대외' });
for (const id of [doyuns.id, NOT_MINE, 'not-a-uuid']) {
expect(
await failureOf(() =>
service.updateSignature(ARA, id, { name: '바꿈' }),
),
).toEqual({ code: 'RES_001', reason: 'signature-not-found' });
expect(await failureOf(() => service.deleteSignature(ARA, id))).toEqual(
{ code: 'RES_001', reason: 'signature-not-found' },
);
expect(
await failureOf(() =>
service.setDefaults(ARA, { newMessageId: mine.id, replyId: id }),
),
).toEqual({ code: 'RES_001', reason: 'signature-not-found' });
}
await expect(service.getDefaults(ARA)).resolves.toEqual({
newMessageId: mine.id,
replyId: mine.id,
});
expect(repository.signatures).toHaveLength(2);
});
it('기본 고르기 — 두 곳을 따로 · 같은 서명 · 넣지 않음(null)으로, 한 사람에 하나씩', async () => {
const a = await create('사내');
const b = await create('대외');
await expect(
service.setDefaults(ARA, { newMessageId: b.id, replyId: a.id }),
).resolves.toEqual({ newMessageId: b.id, replyId: a.id });
await expect(
service.setDefaults(ARA, {
newMessageId: null,
replyId: b.id.toUpperCase(),
}),
).resolves.toEqual({ newMessageId: null, replyId: b.id });
expect(
repository.signatures.filter((row) => row.isReplyDefault),
).toHaveLength(1);
await expect(service.getDefaults(ARA)).resolves.toEqual({
newMessageId: null,
replyId: b.id,
});
});
it('지우면 그 서명을 기본으로 쓰던 곳은 넣지 않음 — 다른 서명이 저절로 기본이 되지 않는다', async () => {
const a = await create('사내');
const b = await create('대외');
await service.setDefaults(ARA, { newMessageId: a.id, replyId: b.id });
await service.deleteSignature(ARA, a.id);
await expect(service.getDefaults(ARA)).resolves.toEqual({
newMessageId: null,
replyId: b.id,
});
expect(
(await service.listSignatures(ARA)).items.map((item) => item.id),
).toEqual([b.id]);
});
it('그림 정리는 모든 서명 HTML 의 합집합 — 다른 서명이 쓰는 그림은 남기고, 지운 서명에만 쓰던 그림은 표 · 파일에서 지운다', async () => {
const shared = await storedImage();
const onlyA = await storedImage();
// 막 올린 그림 — 사내를 저장할 때는 아직 어느 서명에도 없지만 남는다(10분 안)
const onlyB = await storedImage({ createdAt: new Date() });
const img = (id: string) => `<img src="${src(id)}" width="96">`;
const a = await create('사내', {
bodyHtml: `${img(shared.id)}${img(onlyA.id)}`,
});
const b = await create('대외', {
bodyHtml: `${img(shared.id)}${img(onlyB.id)}`,
});
expect(repository.images.map((row) => row.id)).toEqual([
shared.id,
onlyA.id,
onlyB.id,
]);
// a 에서 shared 를 빼도 b 가 쓰므로 남는다
await service.updateSignature(ARA, a.id, {
name: '사내',
bodyHtml: img(onlyA.id),
});
expect(repository.images.map((row) => row.id)).toEqual([
shared.id,
onlyA.id,
onlyB.id,
]);
// a 를 지우면 a 에만 있던 그림이 사라진다
await service.deleteSignature(ARA, a.id);
expect(repository.images.map((row) => row.id)).toEqual([
shared.id,
onlyB.id,
]);
expect(existsSync(join(dir, onlyA.fileName))).toBe(false);
expect(existsSync(join(dir, shared.fileName))).toBe(true);
// 남은 서명이 그림을 모두 빼면 둘 다 지운다
await service.updateSignature(ARA, b.id, { name: '대외', body: '글만' });
expect(repository.images).toEqual([]);
});
it('막 올린 그림(10분 안)은 어느 서명에도 없어도 남기도록 기준 시각을 넘긴다 — 올리기와 저장이 엇갈려도 편집기의 그림이 사라지지 않게', async () => {
const before = Date.now();
const fresh = await storedImage({ createdAt: new Date() });
await create('사내');
const { keepUploadedAfter } = repository.changeCalls[0];
expect(keepUploadedAfter.getTime()).toBeGreaterThanOrEqual(
before - SIGNATURE_IMAGE_UPLOAD_GRACE_MS,
);
expect(keepUploadedAfter.getTime()).toBeLessThanOrEqual(
Date.now() - SIGNATURE_IMAGE_UPLOAD_GRACE_MS,
);
expect(repository.images.map((row) => row.id)).toEqual([fresh.id]);
});
it('잠그기 전에 다른 저장·정리 작업이 지운 그림은 서명에서 뺀다 — 없는 그림을 가리키는 서명을 남기지 않는다', async () => {
const kept = await storedImage();
const gone = await storedImage();
repository.deletedBeforeNextSave = gone.id;
repository.deletedBeforeNextChange = gone.id;
const saved = await service.saveSignature(ARA, {
const saved = await create('사내', {
bodyHtml: `<p>서명</p><img src="${src(kept.id)}" width="96"><img src="${src(gone.id)}" width="96">`,
body: null,
isForNew: true,
isForReply: true,
});
expect(saved.bodyHtml).toBe(
`<p>서명</p><img src="${src(kept.id)}" width="96" />`,
);
expect(saved.images.map((image) => image.id)).toEqual([kept.id]);
expect(repository.saveCalls[0].referenced).toEqual([kept.id]);
// 그림만 있던 서명에서 그림이 모두 사라지면 서식 서명이 없다
repository.deletedBeforeNextSave = kept.id;
const empty = await service.saveSignature(ARA, {
repository.deletedBeforeNextChange = kept.id;
const empty = await service.updateSignature(ARA, saved.id, {
name: '사내',
bodyHtml: `<img src="${src(kept.id)}">`,
body: '텍스트 서명',
isForNew: true,
isForReply: true,
});
expect(empty.bodyHtml).toBeNull();
expect(empty.body).toBe('텍스트 서명');
});
it('서명이 더는 가리키지 않는 그림은 표와 파일에서 지운다', async () => {
const kept = await storedImage({ attachedAt: new Date() });
const dropped = await storedImage({ attachedAt: new Date() });
await service.saveSignature(ARA, {
bodyHtml: `<img src="${src(kept.id)}">`,
body: null,
isForNew: true,
isForReply: true,
});
expect(repository.images.map((row) => row.id)).toEqual([kept.id]);
expect(existsSync(join(dir, kept.fileName))).toBe(true);
expect(existsSync(join(dir, dropped.fileName))).toBe(false);
});
it('지운 그림의 파일을 지우지 못해도 저장은 끝난다 — 경고만 (남은 파일은 정리 작업이 지운다)', async () => {
const dropped = await storedImage({ attachedAt: new Date() });
jest
@@ -286,12 +507,7 @@ describe('MailSignatureService', () => {
.mockRejectedValue(new Error('EACCES: permission denied'));
await expect(
service.saveSignature(ARA, {
bodyHtml: '<p>그림 없음</p>',
body: null,
isForNew: true,
isForReply: true,
}),
create('사내', { bodyHtml: '<p>그림 없음</p>' }),
).resolves.toMatchObject({ bodyHtml: '<p>그림 없음</p>', images: [] });
expect(repository.images).not.toContainEqual(dropped);
expect(warnSpy).toHaveBeenCalledWith(
@@ -300,12 +516,12 @@ describe('MailSignatureService', () => {
});
it('빈 서식·텍스트, 정화해서 비는 서식은 null 로 저장한다', async () => {
const row = await create('사내');
for (const bodyHtml of ['', '<script>x()</script>', null, undefined]) {
const saved = await service.saveSignature(ARA, {
const saved = await service.updateSignature(ARA, row.id, {
name: '사내',
bodyHtml,
body: '',
isForNew: false,
isForReply: true,
});
expect(saved.bodyHtml).toBeNull();
expect(saved.body).toBeNull();
@@ -322,27 +538,22 @@ describe('MailSignatureService', () => {
images.push(await storedImage());
}
const img = (id: string) => `<img src="${src(id)}" width="96">`;
const save = (bodyHtml: string) =>
service.saveSignature(ARA, {
bodyHtml,
body: null,
isForNew: true,
isForReply: true,
});
expect(
await failureOf(() =>
save(images.map((image) => img(image.id)).join('')),
create('사내', {
bodyHtml: images.map((image) => img(image.id)).join(''),
}),
),
).toEqual({ code: 'VAL_001', reason: 'signature-too-many-images' });
expect(repository.saveCalls).toHaveLength(0);
expect(repository.changeCalls).toHaveLength(0);
// 세 장 + 같은 그림 한 번 더 + 남의 그림 — 서로 다른 내 그림은 세 장이다
const [a, b, c] = images;
const saved = await save(
[a, b, c, a].map((image) => img(image.id)).join('') +
img('11111111-1111-4111-8111-111111111111'),
);
const saved = await create('사내', {
bodyHtml:
[a, b, c, a].map((image) => img(image.id)).join('') + img(NOT_MINE),
});
expect(saved.images.map((image) => image.id)).toEqual([a.id, b.id, c.id]);
});
@@ -363,24 +574,207 @@ describe('MailSignatureService', () => {
for (const [input, reason] of cases) {
expect(
await failureOf(() =>
service.saveSignature(ARA, {
isForNew: true,
isForReply: true,
...input,
}),
service.createSignature(ARA, { name: '사내', ...input }),
),
).toEqual({ code: 'VAL_001', reason });
}
expect(repository.saveCalls).toHaveLength(0);
expect(repository.changeCalls).toHaveLength(0);
// 경계 — 정확히 2,000자 · 정화한 결과 10,000자는 저장한다
const saved = await service.saveSignature(ARA, {
const saved = await service.createSignature(ARA, {
name: '사내',
body: '가'.repeat(2_000),
bodyHtml: `<p>${'가'.repeat(10_000 - 7)}</p>`,
});
expect(saved.bodyHtml).toHaveLength(10_000);
});
});
describe('옛 경로 — 「새 메일」 기본 서명 (앱 첫 판 · 옛 웹 탭)', () => {
it('「새 메일」 기본 서명을 네 칸으로(isForReply 는 답장 기본이 같은 서명일 때만) — 없으면 답장 기본 서명, 둘 다 없으면 본문 없음 · 두 표시 false', async () => {
await expect(service.getLegacySignature(ARA)).resolves.toEqual({
bodyHtml: null,
body: null,
isForNew: false,
isForReply: false,
images: [],
updatedAt: null,
});
const image = await storedImage();
const a = await create('사내', {
bodyHtml: `<p>사내</p><img src="${src(image.id)}" width="96">`,
});
const b = await create('대외');
await expect(service.getLegacySignature(ARA)).resolves.toEqual({
bodyHtml: a.bodyHtml,
body: '사내 서명',
isForNew: true,
isForReply: true,
images: a.images,
updatedAt: a.updatedAt,
});
await service.setDefaults(ARA, { newMessageId: a.id, replyId: b.id });
await expect(service.getLegacySignature(ARA)).resolves.toMatchObject({
body: '사내 서명',
isForNew: true,
isForReply: false,
});
// 「새 메일」 기본이 없으면 답장 기본 서명을 isForNew false 로 — 옛 두 체크가 새 메일 끔 · 답장 켬이던 직원이
// 앱 첫 판의 답장에서 서명을 잃지 않게 (리뷰 L4)
await service.setDefaults(ARA, { newMessageId: null, replyId: b.id });
await expect(service.getLegacySignature(ARA)).resolves.toEqual({
bodyHtml: null,
body: '대외 서명',
isForNew: false,
isForReply: true,
images: [],
updatedAt: b.updatedAt,
});
// 둘 다 없으면 다시 본문 없음
await service.setDefaults(ARA, { newMessageId: null, replyId: null });
await expect(service.getLegacySignature(ARA)).resolves.toEqual({
bodyHtml: null,
body: null,
isForNew: false,
isForReply: false,
images: [],
updatedAt: null,
});
});
it('저장 — 서명이 없으면 "기본 서명"을 만들고, 두 표시를 기본 둘로 옮긴다', async () => {
const saved = await service.saveLegacySignature(ARA, {
bodyHtml: '<p>김아라</p>',
body: '김아라',
isForNew: true,
isForReply: false,
});
expect(saved).toMatchObject({
bodyHtml: '<p>김아라</p>',
body: '김아라',
isForNew: true,
isForReply: false,
});
const [row] = (await service.listSignatures(ARA)).items;
expect(row.name).toBe(LEGACY_SIGNATURE_NAME);
await expect(service.getDefaults(ARA)).resolves.toEqual({
newMessageId: row.id,
replyId: null,
});
});
it('저장 — 「새 메일」 기본 서명의 본문만 바꾸고(이름 그대로), 답장 기본이 다른 서명이면 isForReply false 로는 그대로 둔다', async () => {
const a = await create('사내');
const b = await create('대외');
await service.setDefaults(ARA, { newMessageId: a.id, replyId: b.id });
await service.saveLegacySignature(ARA, {
body: '바꾼 글',
isForNew: true,
isForReply: false,
});
const items = (await service.listSignatures(ARA)).items;
expect(items.map((item) => [item.name, item.body])).toEqual([
['사내', '바꾼 글'],
['대외', '대외 서명'],
]);
await expect(service.getDefaults(ARA)).resolves.toEqual({
newMessageId: a.id,
replyId: b.id,
});
// isForReply true → 답장 기본도 이 서명으로
await service.saveLegacySignature(ARA, {
body: '바꾼 글',
isForNew: true,
isForReply: true,
});
expect(saved.bodyHtml).toHaveLength(10_000);
await expect(service.getDefaults(ARA)).resolves.toEqual({
newMessageId: a.id,
replyId: a.id,
});
});
it('저장 — 「새 메일」 기본이 없으면 옛 GET 이 보인 답장 기본 서명을 고친다 (새로 만들지 않는다)', async () => {
await create('사내');
const b = await create('대외');
await service.setDefaults(ARA, { newMessageId: null, replyId: b.id });
const saved = await service.saveLegacySignature(ARA, {
body: '바꾼 글',
isForNew: false,
isForReply: true,
});
expect(saved).toMatchObject({
body: '바꾼 글',
isForNew: false,
isForReply: true,
});
const items = (await service.listSignatures(ARA)).items;
expect(items.map((item) => [item.name, item.body])).toEqual([
['사내', '사내 서명'],
['대외', '바꾼 글'],
]);
await expect(service.getDefaults(ARA)).resolves.toEqual({
newMessageId: null,
replyId: b.id,
});
});
it('저장 — 「새 메일」을 끈 채로 거듭 저장해도 "기본 서명"이 늘지 않는다 (답장 기본으로 · 두 표시를 끄면 이름으로 다시 찾는다)', async () => {
for (let index = 0; index < 3; index += 1) {
await service.saveLegacySignature(ARA, {
body: `글 ${index}`,
isForNew: false,
isForReply: true,
});
}
const items = (await service.listSignatures(ARA)).items;
expect(items.map((item) => [item.name, item.body])).toEqual([
[LEGACY_SIGNATURE_NAME, '글 2'],
]);
await expect(service.getDefaults(ARA)).resolves.toEqual({
newMessageId: null,
replyId: items[0].id,
});
// 두 표시를 모두 끄면 기본 서명이 없어진다 — 그 뒤 저장은 이름으로 같은 서명을 다시 찾는다
for (let index = 3; index < 5; index += 1) {
await service.saveLegacySignature(ARA, {
body: `글 ${index}`,
isForNew: false,
isForReply: false,
});
}
const after = (await service.listSignatures(ARA)).items;
expect(after.map((item) => [item.name, item.body])).toEqual([
[LEGACY_SIGNATURE_NAME, '글 4'],
]);
await expect(service.getDefaults(ARA)).resolves.toEqual({
newMessageId: null,
replyId: null,
});
});
it(`저장 — 서명을 새로 만들어야 하는데 이미 ${SIGNATURES_MAX}개면 400 VAL_001`, async () => {
for (let index = 1; index <= SIGNATURES_MAX; index += 1) {
await create(`서명 ${index}`);
}
await service.setDefaults(ARA, { newMessageId: null, replyId: null });
expect(
await failureOf(() =>
service.saveLegacySignature(ARA, {
body: '글',
isForNew: true,
isForReply: true,
}),
),
).toEqual({ code: 'VAL_001', reason: 'too-many-signatures' });
});
});
@@ -467,7 +861,8 @@ describe('MailSignatureService', () => {
).resolves.toMatchObject({ size: SIGNATURE_IMAGE_MAX_BYTES });
});
it(`한 사람이 둘 수 있는 그림은 ${SIGNATURE_IMAGES_STORED_MAX}장까지 — 넘으면 400 VAL_001. 동시에 올려도 넘지 않는다`, async () => {
it(`한 사람이 둘 수 있는 그림은 ${SIGNATURE_IMAGES_STORED_MAX}장까지(서명 여럿이 함께 쓰는 칸) — 넘으면 400 VAL_001. 동시에 올려도 넘지 않는다`, async () => {
expect(SIGNATURE_IMAGES_STORED_MAX).toBe(20);
const results = await Promise.allSettled(
Array.from({ length: SIGNATURE_IMAGES_STORED_MAX + 2 }, () =>
upload(pngBytes(10, 10)),
@@ -525,7 +920,7 @@ describe('MailSignatureService', () => {
});
for (const [user, id] of [
[DOYUN, mine.id],
[ARA, '11111111-1111-4111-8111-111111111111'],
[ARA, NOT_MINE],
]) {
expect(await failureOf(() => service.openImage(user, id))).toEqual({
code: 'RES_001',
@@ -1,10 +1,12 @@
import { Injectable, Logger } from '@nestjs/common';
import { randomUUID } from 'crypto';
import {
type PaginatedResult,
toPaginatedResult,
} from '@/common/dto/pagination.dto';
import { BusinessException } from '@/common/errors/business.exception';
import { checkHtmlParseBudget } from '@/modules/mail/html/html-parse-budget';
import { MAX_BODY_HTML_LENGTH } from '@/modules/mail/mail.constants';
import { sanitizeOutgoingHtml } from '@/modules/mail/html/sanitize-outgoing-html';
import { sanitizeStoredHtml } from '@/modules/mail/html/sanitize-stored-html';
import {
findSignatureImageIds,
rewriteSignatureImages,
@@ -14,6 +16,8 @@ import { traceTag } from '@/shared/logger/request-context';
import { maskEmail } from '@/shared/utils/mask';
import { MailSignatureImageEntity } from './mail-signature-image.entity';
import {
LEGACY_SIGNATURE_NAME,
SIGNATURES_MAX,
SIGNATURE_HTML_MAX_LENGTH,
SIGNATURE_IMAGES_PER_SIGNATURE,
SIGNATURE_IMAGES_STORED_MAX,
@@ -22,9 +26,16 @@ import {
SIGNATURE_TEXT_MAX_LENGTH,
} from './mail-signature.constants';
import { MailSignatureEntity } from './mail-signature.entity';
import { MailSignatureRepository } from './mail-signature.repository';
import {
MailSignatureRepository,
type SignaturePlan,
type SignatureSnapshot,
} from './mail-signature.repository';
import type {
LegacyMailSignature,
LegacyMailSignatureInput,
MailSignature,
MailSignatureDefaults,
MailSignatureInput,
SignatureImage,
SignatureImageUpload,
@@ -39,18 +50,36 @@ import {
} from './signature-image-format';
import { SignatureImageStorage } from './signature-image-storage';
const DEFAULT_SIGNATURE: Omit<MailSignature, 'images'> = {
/** 옛 경로 — 「새 메일」 · 「답장 · 전달」 기본 서명이 모두 없을 때 (앱 첫 판은 본문이 없으면 서명을 넣지 않는다) */
const NO_LEGACY_SIGNATURE: LegacyMailSignature = {
bodyHtml: null,
body: null,
isForNew: true,
isForReply: true,
isForNew: false,
isForReply: false,
images: [],
updatedAt: null,
};
/** 정화 · 길이를 지난 서명 본문 — 그림은 잠근 뒤 다시 맞춘다 */
interface PreparedSignatureBody {
readonly bodyHtml: string | null;
readonly body: string | null;
}
function normalizeUser(email: string): string {
return email.trim().toLowerCase();
}
/** 서명 id · 그림 id — 서버가 만든 UUID(소문자). 경로 파이프가 소문자로 바꾸지만 본문에서 온 id(기본 고르기)도 같게 */
function normalizeId(id: string): string {
return id.trim().toLowerCase();
}
/** 이름 견주기 — ASCII 대소문자만 무시한다 (화면과 같다 — 한글 · 그 밖의 글자는 그대로 견준다) */
function nameKey(name: string): string {
return name.replace(/[A-Z]/g, (letter) => letter.toLowerCase());
}
function toImage(row: MailSignatureImageEntity): SignatureImage {
return {
id: row.id,
@@ -61,6 +90,67 @@ function toImage(row: MailSignatureImageEntity): SignatureImage {
};
}
/** 서명 HTML 이 쓰는 그림 — HTML 에 나온 순서 (표에 없는 그림은 뺀다) */
function imagesOf(
html: string | null,
images: readonly MailSignatureImageEntity[],
): SignatureImage[] {
if (!html) return [];
const byId = new Map(images.map((image) => [image.id, image]));
return findSignatureImageIds(html).flatMap((id) => {
const image = byId.get(id);
return image ? [toImage(image)] : [];
});
}
function toSignature(
row: MailSignatureEntity,
images: readonly MailSignatureImageEntity[],
): MailSignature {
return {
id: row.id,
name: row.name,
bodyHtml: row.bodyHtml,
body: row.bodyText,
images: imagesOf(row.bodyHtml, images),
updatedAt: row.updatedAt.toISOString(),
};
}
function defaultsOf(
signatures: readonly MailSignatureEntity[],
): MailSignatureDefaults {
return {
newMessageId: signatures.find((row) => row.isNewMessageDefault)?.id ?? null,
replyId: signatures.find((row) => row.isReplyDefault)?.id ?? null,
};
}
/**
* 새 서명의 만든 시각 — 그 사람의 마지막 서명보다 늦게(같은 밀리초면 1ms 뒤로). 목록은 만든 차례라
* 잠근 차례대로 늘 뒤에 붙는다 (같은 시각이면 id 차례가 되어 들쭉날쭉하지 않게)
*/
function createdAfter(
signatures: readonly MailSignatureEntity[],
now: Date,
): Date {
const last = Math.max(0, ...signatures.map((row) => row.createdAt.getTime()));
return new Date(Math.max(now.getTime(), last + 1));
}
/** 모든 서명 HTML 이 가리키는 그림 — 합집합 (지울 그림을 정하는 기준) */
function referencedImageIdsOf(
signatures: readonly Pick<MailSignatureEntity, 'bodyHtml'>[],
): string[] {
return [
...new Set(
signatures.flatMap((row) =>
row.bodyHtml ? findSignatureImageIds(row.bodyHtml) : [],
),
),
];
}
/** 올린 이름의 확장자 (소문자, 점 포함) */
function extensionOf(name: string): string {
const dot = name.lastIndexOf('.');
@@ -68,15 +158,19 @@ function extensionOf(name: string): string {
}
/**
* 메일 서명 (계약 2절 "서명") — 사용자마다 하나, 서버 DB 에 둔다.
* 메일 서명 (계약 2절 "서명") — 16단계부터 한 사람에 여러 개(10개까지), 서버 DB 에 둔다.
* - 서명 HTML 은 보내는 정화(서명 그림 포함)를 거친 뒤 저장한다 — 서명은 쓰기 화면이 본문에 넣어 그대로 보내므로 같은 규칙이다
* - 「메일에 넣을 서명」 둘(새 메일 · 답장과 전달)은 서명 줄의 표시다 — 서명이 하나도 없던 사람의 첫 서명이 두 곳의 기본이 된다
* - 서명 그림은 모든 서명이 함께 쓰는 칸(한 사람 20장)이다 — 저장 · 지우기 때 **어느 서명도 가리키지 않는** 그림을 지운다(막 올린 것은 남긴다)
* - 그림은 PNG·JPEG·GIF 만 — 확장자·MIME 은 multer 가, 파일 내용(앞부분)·크기는 여기서 본다
* - 그림은 주인만 본다 — 남의 그림 id 는 없는 것과 같다 (RES_001)
* - 서명 · 그림은 주인만 본다 — 남의 id 는 없는 것과 같다 (RES_001)
* - 옛 경로(`GET · PUT /api/mail-signature` — 앱 첫 판 · 16단계 전의 웹 탭)는 「새 메일」 기본 서명을 15단계까지의 모양으로 다룬다
* 서명 · 이름 · 글은 로그에 남기지 않는다 (사유 · 수 · 가린 주소만)
*/
@Injectable()
export class MailSignatureService {
private readonly logger = new Logger(MailSignatureService.name);
/** 사용자별 그림 올리기 차례 — 보관 한도(10장)를 동시 업로드로 넘지 못하게 (프로세스 메모리 — 인스턴스를 늘리면 DB 잠금으로) */
/** 사용자별 그림 올리기 차례 — 보관 한도(20장)를 동시 업로드로 넘지 못하게 (프로세스 메모리 — 인스턴스를 늘리면 DB 잠금으로) */
private readonly uploadQueues = new Map<string, Promise<void>>();
constructor(
@@ -84,76 +178,236 @@ export class MailSignatureService {
private readonly storage: SignatureImageStorage,
) {}
async getSignature(userEmail: string): Promise<MailSignature> {
/** 내 서명 — 만든 차례, 한 번에 모두 (hasNext 늘 false) */
async listSignatures(
userEmail: string,
): Promise<PaginatedResult<MailSignature>> {
const user = normalizeUser(userEmail);
const [row, images] = await Promise.all([
this.repository.findSignature(user),
const [rows, images] = await Promise.all([
this.repository.listSignatures(user),
this.repository.listImages(user),
]);
if (!row) return { ...DEFAULT_SIGNATURE, images: [] };
return this.toSignature(row, images);
return toPaginatedResult(
rows.map((row) => toSignature(row, images)),
{ hasNext: false, nextCursor: null, total: rows.length },
);
}
/**
* 서명을 저장한다. 서명 HTML 이 더는 가리키지 않는 그림은 지운다(표와 파일) — 막 올린 그림(10분 안)은 남긴다
* (올리기와 저장이 엇갈린 경우 — SIGNATURE_IMAGE_UPLOAD_GRACE_MS).
* 한도 — 텍스트 2,000자·정화한 HTML 10,000자(JS 문자열 길이), 정화 전 비용, 서로 다른 그림 3장 — 를 넘으면 VAL_001.
* 서명을 만든다 — 서명이 하나도 없던 사람이면 이 서명이 「새 메일」 · 「답장 · 전달」 두 곳의 기본이 된다.
* 11번째 · 같은 이름(ASCII 대소문자 무시)이면 VAL_001 (아무것도 저장하지 않는다)
*/
async saveSignature(
async createSignature(
userEmail: string,
input: MailSignatureInput,
): Promise<MailSignature> {
const user = normalizeUser(userEmail);
const body = input.body ? input.body : null;
if (body !== null && body.length > SIGNATURE_TEXT_MAX_LENGTH) {
throw new BusinessException('VAL_001', {
context: { reason: 'signature-text-too-long', length: body.length },
});
}
const bodyHtml = await this.sanitizeSignatureHtml(user, input.bodyHtml);
// 정화한 HTML 에는 내 그림만 남는다 — 같은 그림을 두 번 넣으면 한 장으로 센다
const referenced = bodyHtml ? findSignatureImageIds(bodyHtml) : [];
if (referenced.length > SIGNATURE_IMAGES_PER_SIGNATURE) {
throw new BusinessException('VAL_001', {
context: {
reason: 'signature-too-many-images',
count: referenced.length,
},
});
}
const now = new Date();
const saved = await this.repository.saveSignature(
user,
(images) => {
// 잠근 뒤 본 그림 기준으로 — 정화한 뒤 그사이 지워진 그림(다른 탭의 저장·정리 작업)은 서명에서 뺀다 (없는 그림을 가리키지 않게)
const existing = new Set(images.map((image) => image.id));
const html =
bodyHtml === null
? null
: rewriteSignatureImages(bodyHtml, (id) =>
existing.has(id) ? signatureImagePath(id) : null,
);
const kept = html !== null && html.trim() !== '' ? html : null;
return {
signature: {
userEmail: user,
bodyHtml: kept,
bodyText: body,
isForNew: input.isForNew,
isForReply: input.isForReply,
updatedAt: now,
const prepared = await this.prepareBody(user, input);
const id = randomUUID();
const result = await this.change(user, (snapshot, now) => {
if (snapshot.signatures.length >= SIGNATURES_MAX) {
throw new BusinessException('VAL_001', {
context: {
reason: 'too-many-signatures',
count: snapshot.signatures.length,
},
referencedImageIds: kept ? findSignatureImageIds(kept) : [],
};
},
now,
new Date(now.getTime() - SIGNATURE_IMAGE_UPLOAD_GRACE_MS),
);
await this.removeFiles(saved.removed, user);
});
}
this.assertNameFree(snapshot.signatures, input.name, null);
const bodyHtml = this.keepExistingImages(prepared.bodyHtml, snapshot);
const row = {
id,
userEmail: user,
name: input.name,
bodyHtml,
bodyText: prepared.body,
createdAt: createdAfter(snapshot.signatures, now),
updatedAt: now,
};
return {
insert: row,
// 첫 서명은 두 곳의 기본 — 지금까지 서명이 처음에 두 곳에 들어가던 것과 같다
...(snapshot.signatures.length === 0
? { defaults: { newMessageId: id, replyId: id } }
: {}),
referencedImageIds: referencedImageIdsOf([...snapshot.signatures, row]),
};
});
return this.signatureIn(result, id);
}
return this.toSignature(saved.signature, saved.images);
/**
* 서명을 고친다(이름 · 본문 — 전체 교체). 기본 표시는 그대로. 없는 서명 · 남의 서명은 RES_001, 다른 서명과 같은 이름은 VAL_001.
* 어느 서명도 더는 가리키지 않는 그림은 지운다 — 막 올린 그림(10분 안)은 남긴다 (올리기와 저장이 엇갈린 경우 — SIGNATURE_IMAGE_UPLOAD_GRACE_MS)
*/
async updateSignature(
userEmail: string,
signatureId: string,
input: MailSignatureInput,
): Promise<MailSignature> {
const user = normalizeUser(userEmail);
const id = normalizeId(signatureId);
const prepared = await this.prepareBody(user, input);
const result = await this.change(user, (snapshot, now) => {
this.requireSignature(snapshot, id);
this.assertNameFree(snapshot.signatures, input.name, id);
const update = {
id,
name: input.name,
bodyHtml: this.keepExistingImages(prepared.bodyHtml, snapshot),
bodyText: prepared.body,
updatedAt: now,
};
return {
update,
referencedImageIds: referencedImageIdsOf(
snapshot.signatures.map((row) => (row.id === id ? update : row)),
),
};
});
return this.signatureIn(result, id);
}
/**
* 서명을 지운다 — 이 서명을 기본으로 쓰던 곳은 "넣지 않음"이 된다(다른 서명이 저절로 기본이 되지 않는다).
* 이 서명에만 쓰던 그림은 함께 지운다. 없는 서명 · 남의 서명은 RES_001
*/
async deleteSignature(userEmail: string, signatureId: string): Promise<void> {
const user = normalizeUser(userEmail);
const id = normalizeId(signatureId);
await this.change(user, (snapshot) => {
this.requireSignature(snapshot, id);
return {
removeId: id,
referencedImageIds: referencedImageIdsOf(
snapshot.signatures.filter((row) => row.id !== id),
),
};
});
}
/** 「메일에 넣을 서명」 둘 — null 은 넣지 않음 */
async getDefaults(userEmail: string): Promise<MailSignatureDefaults> {
return defaultsOf(
await this.repository.listSignatures(normalizeUser(userEmail)),
);
}
/** 「메일에 넣을 서명」 둘을 바꾼다 — 내 서명이 아닌 id(없음 · 남의 것 · 형식이 틀림)는 RES_001 (아무것도 바꾸지 않는다) */
async setDefaults(
userEmail: string,
defaults: MailSignatureDefaults,
): Promise<MailSignatureDefaults> {
const user = normalizeUser(userEmail);
const wanted: MailSignatureDefaults = {
newMessageId:
defaults.newMessageId === null
? null
: normalizeId(defaults.newMessageId),
replyId: defaults.replyId === null ? null : normalizeId(defaults.replyId),
};
const result = await this.change(user, (snapshot) => {
for (const id of [wanted.newMessageId, wanted.replyId]) {
if (id !== null) this.requireSignature(snapshot, id);
}
return {
defaults: wanted,
referencedImageIds: referencedImageIdsOf(snapshot.signatures),
};
});
return defaultsOf(result.signatures);
}
/**
* 옛 경로 — 「새 메일」 기본 서명을 15단계까지의 모양으로 (앱 첫 판이 읽는다). 「새 메일」 기본이 없으면 「답장 · 전달」 기본 서명을
* isForNew false · isForReply true 로, 둘 다 없으면 본문 없음 · 두 표시 false.
* 「새 메일」 기본이 있을 때 isForReply 는 답장 기본이 같은 서명일 때만 true — 다른 서명이면 앱 첫 판은 답장에 서명을 넣지 않는다(알려진 한계)
*/
async getLegacySignature(userEmail: string): Promise<LegacyMailSignature> {
const user = normalizeUser(userEmail);
const [rows, images] = await Promise.all([
this.repository.listSignatures(user),
this.repository.listImages(user),
]);
return toLegacySignature(rows, images);
}
/**
* 옛 경로의 저장(16단계 전의 웹 탭) — 옛 GET 이 보이는 서명(「새 메일」 기본, 없으면 「답장 · 전달」 기본)의 본문을 바꾸고 두 표시를 기본 둘로 옮긴다.
* 기본 서명이 둘 다 없으면 "기본 서명"이라는 이름의 서명(ASCII 대소문자 무시 — 있으면 그것, 없으면 새로 만든다)을 쓴다 —
* 옛 탭이 두 표시를 끈 채 저장을 거듭해도 서명이 늘지 않게.
* isForNew → 「새 메일」 기본을 이 서명으로(false 면 넣지 않음), isForReply → 「답장 · 전달」 기본을 이 서명으로
* (false 면 이 서명이 기본이었을 때만 넣지 않음 — 다른 서명이 기본이면 그대로 둔다)
*/
async saveLegacySignature(
userEmail: string,
input: LegacyMailSignatureInput,
): Promise<LegacyMailSignature> {
const user = normalizeUser(userEmail);
const prepared = await this.prepareBody(user, input);
const newId = randomUUID();
const result = await this.change(user, (snapshot, now) => {
const current = defaultsOf(snapshot.signatures);
const target =
legacyTargetOf(snapshot.signatures) ??
snapshot.signatures.find(
(row) => nameKey(row.name) === nameKey(LEGACY_SIGNATURE_NAME),
) ??
null;
const bodyHtml = this.keepExistingImages(prepared.bodyHtml, snapshot);
const id = target?.id ?? newId;
const defaults: MailSignatureDefaults = {
newMessageId: input.isForNew
? id
: current.newMessageId === id
? null
: current.newMessageId,
replyId: input.isForReply
? id
: current.replyId === id
? null
: current.replyId,
};
if (target) {
const update = {
id,
name: target.name,
bodyHtml,
bodyText: prepared.body,
updatedAt: now,
};
return {
update,
defaults,
referencedImageIds: referencedImageIdsOf(
snapshot.signatures.map((row) => (row.id === id ? update : row)),
),
};
}
if (snapshot.signatures.length >= SIGNATURES_MAX) {
throw new BusinessException('VAL_001', {
context: {
reason: 'too-many-signatures',
count: snapshot.signatures.length,
},
});
}
const row = {
id,
userEmail: user,
name: LEGACY_SIGNATURE_NAME,
bodyHtml,
bodyText: prepared.body,
createdAt: createdAfter(snapshot.signatures, now),
updatedAt: now,
};
return {
insert: row,
defaults,
referencedImageIds: referencedImageIdsOf([...snapshot.signatures, row]),
};
});
return toLegacySignature(result.signatures, result.images);
}
/** 서명 그림을 올린다 — 내용이 PNG·JPEG·GIF 이고 확장자·MIME 과 같은 형식이어야 한다 (아니면 FILE_001) */
@@ -284,59 +538,125 @@ export class MailSignatureService {
return loaded;
}
/** 서명 HTML 정화 — 비었으면 null. 정화 전 비용·정화 뒤 길이 한도를 넘으면 VAL_001 */
private async sanitizeSignatureHtml(
/**
* 서명 본문 — 텍스트 2,000자, 서식은 정화 전 비용 · 정화 뒤 10,000자(JS 문자열 길이), 서로 다른 그림 3장(정화가 남의 그림은 지웠다 —
* 같은 그림 두 번은 한 장). 넘으면 VAL_001
*/
private async prepareBody(
user: string,
html: string | null | undefined,
): Promise<string | null> {
if (!html) return null;
// 정화 전 — 쓰기 화면의 서식 본문과 같은 길이 한도와 파서 비용 (정화가 이벤트 루프를 오래 붙잡지 않게)
if (html.length > MAX_BODY_HTML_LENGTH) {
input: { readonly bodyHtml?: string | null; readonly body?: string | null },
): Promise<PreparedSignatureBody> {
const body = input.body ? input.body : null;
if (body !== null && body.length > SIGNATURE_TEXT_MAX_LENGTH) {
throw new BusinessException('VAL_001', {
context: { reason: 'signature-html-too-long', length: html.length },
});
}
const budget = checkHtmlParseBudget(html);
if (!budget.ok) {
throw new BusinessException('VAL_001', {
context: {
reason: 'signature-html-too-complex',
detail: budget.reason,
},
context: { reason: 'signature-text-too-long', length: body.length },
});
}
const sanitized = sanitizeOutgoingHtml(html, {
signatureImageIds: await this.imageIdsOf(user),
const signatureImageIds = input.bodyHtml
? await this.imageIdsOf(user)
: undefined;
const sanitized = sanitizeStoredHtml(input.bodyHtml, {
maxLength: SIGNATURE_HTML_MAX_LENGTH,
signatureImageIds,
});
if (sanitized.length > SIGNATURE_HTML_MAX_LENGTH) {
if (!sanitized.ok) {
throw new BusinessException('VAL_001', {
context: {
reason: 'signature-html-too-long',
reason:
sanitized.problem === 'too-complex'
? 'signature-html-too-complex'
: 'signature-html-too-long',
length: sanitized.length,
},
});
}
return sanitized === '' ? null : sanitized;
const referenced = sanitized.html
? findSignatureImageIds(sanitized.html)
: [];
if (referenced.length > SIGNATURE_IMAGES_PER_SIGNATURE) {
throw new BusinessException('VAL_001', {
context: {
reason: 'signature-too-many-images',
count: referenced.length,
},
});
}
return { bodyHtml: sanitized.html, body };
}
private toSignature(
row: MailSignatureEntity,
images: readonly MailSignatureImageEntity[],
/**
* 잠근 뒤 본 그림 기준으로 — 정화한 뒤 그사이 지워진 그림(다른 탭의 저장 · 정리 작업)은 서명에서 뺀다 (없는 그림을 가리키지 않게).
* 남는 것이 없으면 null
*/
private keepExistingImages(
html: string | null,
snapshot: SignatureSnapshot,
): string | null {
if (html === null) return null;
const existing = new Set(snapshot.images.map((image) => image.id));
const kept = rewriteSignatureImages(html, (id) =>
existing.has(id) ? signatureImagePath(id) : null,
);
return kept.trim() !== '' ? kept : null;
}
/** 같은 이름(ASCII 대소문자 무시)의 다른 서명이 있으면 VAL_001 */
private assertNameFree(
signatures: readonly MailSignatureEntity[],
name: string,
exceptId: string | null,
): void {
const key = nameKey(name);
if (
signatures.some((row) => row.id !== exceptId && nameKey(row.name) === key)
) {
throw new BusinessException('VAL_001', {
context: { reason: 'signature-name-taken' },
});
}
}
/** 그 사람의 서명이어야 한다 — 없으면(남의 것 · 형식이 틀린 id 포함) RES_001 */
private requireSignature(snapshot: SignatureSnapshot, id: string): void {
if (!snapshot.signatures.some((row) => row.id === id)) {
throw new BusinessException('RES_001', {
context: { reason: 'signature-not-found' },
});
}
}
/** 잠근 뒤 바꾸고, 지운 그림의 파일을 지운다 */
private async change(
user: string,
plan: (snapshot: SignatureSnapshot, now: Date) => SignaturePlan,
) {
const now = new Date();
const result = await this.repository.change(
user,
(snapshot) => plan(snapshot, now),
now,
new Date(now.getTime() - SIGNATURE_IMAGE_UPLOAD_GRACE_MS),
);
await this.removeFiles(result.removed, user);
return result;
}
/** 바꾼 뒤의 서명 하나 — 방금 넣거나 고친 줄은 늘 있다 */
private signatureIn(
result: {
readonly signatures: readonly MailSignatureEntity[];
readonly images: readonly MailSignatureImageEntity[];
},
id: string,
): MailSignature {
const byId = new Map(images.map((image) => [image.id, image]));
const used = row.bodyHtml ? findSignatureImageIds(row.bodyHtml) : [];
return {
bodyHtml: row.bodyHtml,
body: row.bodyText,
isForNew: row.isForNew,
isForReply: row.isForReply,
images: used.flatMap((id) => {
const image = byId.get(id);
return image ? [toImage(image)] : [];
}),
updatedAt: row.updatedAt.toISOString(),
};
const row = result.signatures.find((candidate) => candidate.id === id);
if (!row) {
throw new BusinessException('SYS_001', {
context: { reason: 'signature-missing-after-save' },
});
}
return toSignature(row, result.images);
}
/** 지운 그림의 파일을 지운다 — 실패해도 서명 저장은 끝났다 (남은 파일은 정리 작업이 지운다) */
@@ -370,3 +690,38 @@ export class MailSignatureService {
return run;
}
}
/**
* 옛 경로가 다루는 서명 — 「새 메일」 기본, 없으면 「답장 · 전달」 기본, 둘 다 없으면 null.
* 답장 기본까지 보는 까닭: 옛 두 체크가 새 메일 끔 · 답장 켬이던 직원(옮기면 답장 기본만 있다)이 앱 첫 판의 답장에서 서명을 잃지 않게 (리뷰 L4).
* 옛 PUT 도 이 서명을 고친다 — 옛 탭이 읽은 서명을 그대로 저장하게
*/
function legacyTargetOf(
rows: readonly MailSignatureEntity[],
): MailSignatureEntity | null {
return (
rows.find((candidate) => candidate.isNewMessageDefault) ??
rows.find((candidate) => candidate.isReplyDefault) ??
null
);
}
/**
* 옛 경로의 모양 — 「새 메일」 기본 서명(isForNew true · isForReply 는 답장 기본도 그 서명일 때), 없으면 「답장 · 전달」 기본 서명
* (isForNew false · isForReply true), 둘 다 없으면 본문 없음 · 두 표시 false
*/
function toLegacySignature(
rows: readonly MailSignatureEntity[],
images: readonly MailSignatureImageEntity[],
): LegacyMailSignature {
const row = legacyTargetOf(rows);
if (!row) return NO_LEGACY_SIGNATURE;
return {
bodyHtml: row.bodyHtml,
body: row.bodyText,
isForNew: row.isNewMessageDefault,
isForReply: row.isReplyDefault,
images: imagesOf(row.bodyHtml, images),
updatedAt: row.updatedAt.toISOString(),
};
}
@@ -1,7 +1,7 @@
import type { SignatureImageType } from './signature-image-format';
/**
* 서명 도메인 타입 — 계약 2절 "서명"과 같은 모양이다.
* 서명 도메인 타입 — 계약 2절 "서명"과 같은 모양이다 (16단계부터 한 사람에 여러 개).
*/
export interface SignatureImage {
@@ -14,21 +14,51 @@ export interface SignatureImage {
readonly height: number;
}
/** 서명 하나 — `GET · POST /api/mail-signatures` · `PUT /api/mail-signatures/{id}` (16단계) */
export interface MailSignature {
/** 서명 서식 — 서식 본문 규칙 + 서명 그림. 정화 뒤 10,000자 이하 */
readonly id: string;
/** 앞뒤 공백을 뺀 1~30자 — 같은 사람 안에서 겹치지 않는다(ASCII 대소문자 무시) */
readonly name: string;
/** 서명 서식 — 서식 본문 규칙 + 서명 그림. 정화 뒤 10,000자 이하. 없으면 null */
readonly bodyHtml: string | null;
/** 텍스트 서명 (텍스트 대체 본문에 넣는다) — 2,000자 이하. 없으면 null */
readonly body: string | null;
/** 서명 HTML 이 쓰는 그림 (HTML 에 나온 순서) */
readonly images: SignatureImage[];
/** 마지막으로 저장한 시각 (ISO 8601 UTC) */
readonly updatedAt: string;
}
/** 서명 만들기 · 고치기 본문 (전체 교체) */
export interface MailSignatureInput {
/** 앞뒤 공백을 뺀 값 (요청 검증이 지운다) */
readonly name: string;
readonly bodyHtml?: string | null;
readonly body?: string | null;
}
/** 「메일에 넣을 서명」 둘 — `GET · PUT /api/mail-signature-defaults` (16단계). null = 넣지 않음 */
export interface MailSignatureDefaults {
readonly newMessageId: string | null;
readonly replyId: string | null;
}
/**
* 옛 경로의 서명 — `GET /api/mail-signature` (앱 첫 판 · 16단계 전의 웹 탭). 「새 메일」 기본 서명을 15단계까지의 네 칸으로 내준다
* (없으면 「답장 · 전달」 기본 서명을 isForNew false 로). isForNew = 「새 메일」 기본 서명이 있음, isForReply = 「답장 · 전달」 기본이 이 서명
*/
export interface LegacyMailSignature {
readonly bodyHtml: string | null;
/** 텍스트 서명 (텍스트 대체 본문에 넣는다) — 2,000자 이하 */
readonly body: string | null;
readonly isForNew: boolean;
readonly isForReply: boolean;
/** 서명 HTML 이 쓰는 그림 (HTML 에 나온 순서) */
readonly images: SignatureImage[];
/** 저장한 적 없으면 null */
/** 기본 서명이 둘 다 없으면 null */
readonly updatedAt: string | null;
}
/** PUT /api/mail-signature 본문 */
export interface MailSignatureInput {
/** 옛 경로의 저장 본문 — `PUT /api/mail-signature` */
export interface LegacyMailSignatureInput {
readonly bodyHtml?: string | null;
readonly body?: string | null;
readonly isForNew: boolean;
@@ -0,0 +1,184 @@
import {
Body,
Controller,
Delete,
Get,
HttpCode,
HttpStatus,
Param,
Post,
Put,
UseGuards,
} from '@nestjs/common';
import {
ApiCookieAuth,
ApiOperation,
ApiParam,
ApiResponse,
ApiTags,
} from '@nestjs/swagger';
import {
ApiErrorResponse,
ApiSessionErrors,
ApiStandardResponse,
} from '@/common/decorators/api-standard-response.decorator';
import {
CurrentUser,
type AuthenticatedUser,
} from '@/common/decorators/current-user.decorator';
import { SkipTransform } from '@/common/decorators/skip-transform.decorator';
import { JwtAuthGuard } from '@/common/guards/jwt-auth.guard';
import {
MailSignatureDefaultsDto,
MailSignatureDto,
MailSignaturePageDto,
SaveMailSignatureDto,
} from './dto/mail-signature.dto';
import {
SIGNATURES_MAX,
SIGNATURE_HTML_MAX_LENGTH,
SIGNATURE_IMAGES_PER_SIGNATURE,
SIGNATURE_NAME_MAX_LENGTH,
SIGNATURE_TEXT_MAX_LENGTH,
} from './mail-signature.constants';
import { MailSignatureService } from './mail-signature.service';
import { MailSignatureIdPipe } from './signature-image-id.pipe';
/** 서명 저장(만들기 · 고치기)의 400 VAL_001 사유 */
const SAVE_REJECTIONS =
`형식 오류 · 이름(앞뒤 공백을 뺀 1~${SIGNATURE_NAME_MAX_LENGTH}자 · 제어 문자 없음) · 같은 이름의 다른 서명(영문 대소문자 무시) · ` +
`텍스트 서명 ${SIGNATURE_TEXT_MAX_LENGTH.toLocaleString('ko-KR')}자 초과 · 정화한 서식 서명 ${SIGNATURE_HTML_MAX_LENGTH.toLocaleString('ko-KR')}자 초과 · ` +
`서식 서명이 너무 깊게 중첩됨 · 그림 ${SIGNATURE_IMAGES_PER_SIGNATURE}장 초과 · 모르는 필드`;
const SIGNATURE_ID_PARAM = {
name: 'id',
description: '서명 id (서버가 만든 UUID) — 형식이 틀리면 404 RES_001',
example: '0b6f7c1e-3a52-4c4e-9f0e-2d1f5a7b8c9d',
};
// 여러 서명 (계약 2절 "서명" — 16단계) — 로그인한 직원 자신의 서명만 다룬다. 서명 그림 경로는 그대로 /api/mail-signature/images (mail-signature.controller.ts)
@ApiTags('Mail')
@ApiCookieAuth()
@UseGuards(JwtAuthGuard)
@ApiSessionErrors('로그인 필요 (쿠키 없음·만료)')
@Controller('mail-signatures')
export class MailSignaturesController {
constructor(private readonly signatures: MailSignatureService) {}
@Get()
@ApiOperation({
summary: '내 서명 목록',
description: `만든 차례 · ${SIGNATURES_MAX}개까지 한 번에 모두 (hasNext=false). 「메일에 넣을 서명」은 /api/mail-signature-defaults`,
})
@ApiStandardResponse(MailSignaturePageDto, { description: '서명 목록' })
list(@CurrentUser() user: AuthenticatedUser) {
return this.signatures.listSignatures(user.email);
}
@Post()
@ApiOperation({
summary: '서명 만들기',
description:
`${SIGNATURES_MAX}개까지. 서명이 하나도 없던 직원이면 이 서명이 「새 메일」 · 「답장 · 전달」 두 곳의 기본이 된다(그 밖에는 기본을 바꾸지 않는다). ` +
'bodyHtml 은 서식 본문 허용 목록 + 내 서명 그림으로 정화해 저장한다. 어느 서명도 더는 가리키지 않는 그림은 지운다 — 막 올리고(10분 안) 아직 넣지 않은 그림은 남긴다.',
})
@ApiStandardResponse(MailSignatureDto, {
status: HttpStatus.CREATED,
description: '만든 서명',
})
@ApiErrorResponse(
'VAL_001',
`${SAVE_REJECTIONS} · 이미 ${SIGNATURES_MAX}개 — 저장하지 않았다`,
)
create(
@CurrentUser() user: AuthenticatedUser,
@Body() dto: SaveMailSignatureDto,
) {
return this.signatures.createSignature(user.email, dto);
}
@Put(':id')
@ApiOperation({
summary: '서명 고치기 (이름 · 본문 — 전체 교체)',
description:
'「메일에 넣을 서명」 표시는 그대로다. 어느 서명도 더는 가리키지 않는 그림은 지운다 — 막 올리고(10분 안) 아직 넣지 않은 그림은 남긴다.',
})
@ApiParam(SIGNATURE_ID_PARAM)
@ApiStandardResponse(MailSignatureDto, { description: '고친 서명' })
@ApiErrorResponse('VAL_001', `${SAVE_REJECTIONS} — 저장하지 않았다`)
@ApiErrorResponse('RES_001', '없는 서명 · 남의 서명 · 형식이 틀린 id')
update(
@CurrentUser() user: AuthenticatedUser,
@Param('id', MailSignatureIdPipe) id: string,
@Body() dto: SaveMailSignatureDto,
) {
return this.signatures.updateSignature(user.email, id, dto);
}
@Delete(':id')
@HttpCode(HttpStatus.NO_CONTENT)
@SkipTransform()
@ApiOperation({
summary: '서명 지우기',
description:
'이 서명을 기본으로 쓰던 곳(새 메일 · 답장과 전달)은 넣지 않음이 된다 — 다른 서명이 저절로 기본이 되지 않는다. ' +
'이 서명에만 쓰던 그림은 함께 지운다. 이미 보냈거나 임시 저장한 메일에 들어간 서명 글은 그대로다.',
})
@ApiParam(SIGNATURE_ID_PARAM)
@ApiResponse({
status: HttpStatus.NO_CONTENT,
description: '지웠다 — 본문 없음',
})
@ApiErrorResponse('RES_001', '없는 서명 · 남의 서명 · 형식이 틀린 id')
async remove(
@CurrentUser() user: AuthenticatedUser,
@Param('id', MailSignatureIdPipe) id: string,
): Promise<void> {
await this.signatures.deleteSignature(user.email, id);
}
}
// 「메일에 넣을 서명」 둘 (계약 2절 "서명" — 16단계) — 새 메일 · 답장과 전달에 저절로 넣는 서명. 15단계까지의 두 체크(isForNew · isForReply)를 대신한다
@ApiTags('Mail')
@ApiCookieAuth()
@UseGuards(JwtAuthGuard)
@ApiSessionErrors('로그인 필요 (쿠키 없음·만료)')
@Controller('mail-signature-defaults')
export class MailSignatureDefaultsController {
constructor(private readonly signatures: MailSignatureService) {}
@Get()
@ApiOperation({
summary: '메일에 넣을 서명',
description:
'새 메일 · 답장과 전달에 넣는 서명의 id — null 이면 넣지 않음. 서명이 하나도 없으면 둘 다 null',
})
@ApiStandardResponse(MailSignatureDefaultsDto, {
description: '메일에 넣을 서명',
})
get(@CurrentUser() user: AuthenticatedUser) {
return this.signatures.getDefaults(user.email);
}
@Put()
@ApiOperation({
summary: '메일에 넣을 서명 바꾸기 (전체 교체)',
description:
'두 키를 모두 보낸다(null = 넣지 않음). 두 곳이 같은 서명이어도 된다. 바꾼 뒤의 값을 돌려준다',
})
@ApiStandardResponse(MailSignatureDefaultsDto, {
description: '바꾼 뒤의 값',
})
@ApiErrorResponse('VAL_001', '형식 오류 · 빠진 키 · 모르는 필드')
@ApiErrorResponse(
'RES_001',
'내 서명이 아닌 id(없음 · 남의 것 · 형식이 틀림) — 아무것도 바꾸지 않았다',
)
update(
@CurrentUser() user: AuthenticatedUser,
@Body() dto: MailSignatureDefaultsDto,
) {
return this.signatures.setDefaults(user.email, dto);
}
}
@@ -2,10 +2,18 @@ import { Injectable, PipeTransform } from '@nestjs/common';
import { BusinessException } from '@/common/errors/business.exception';
/** 서명 그림 id — 서버가 만든 UUID */
const SIGNATURE_IMAGE_ID =
/** 서명 · 서명 그림 id — 서버가 만든 UUID */
const SERVER_UUID =
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
/** 경로의 UUID 를 소문자로 — 형식이 틀리면 "그런 것이 없다"와 같으므로 RES_001 (그 사유로) */
function serverUuid(value: unknown, reason: string): string {
if (typeof value === 'string' && SERVER_UUID.test(value)) {
return value.toLowerCase();
}
throw new BusinessException('RES_001', { context: { reason } });
}
/**
* 경로의 서명 그림 id 형식 확인 — 형식이 틀리면 "그런 그림이 없다"와 같으므로 RES_001 (DB 의 uuid 열에 넣기 전에 막는다 —
* 형식이 틀린 값이 DB 오류(SYS_001)로 새어 나가지 않게).
@@ -13,11 +21,14 @@ const SIGNATURE_IMAGE_ID =
@Injectable()
export class SignatureImageIdPipe implements PipeTransform<unknown, string> {
transform(value: unknown): string {
if (typeof value === 'string' && SIGNATURE_IMAGE_ID.test(value)) {
return value.toLowerCase();
}
throw new BusinessException('RES_001', {
context: { reason: 'malformed-signature-image-id' },
});
return serverUuid(value, 'malformed-signature-image-id');
}
}
/** 경로의 서명 id 형식 확인 (16단계 — `/api/mail-signatures/{id}`) — 그림 id 와 같은 규칙, 틀리면 RES_001 */
@Injectable()
export class MailSignatureIdPipe implements PipeTransform<unknown, string> {
transform(value: unknown): string {
return serverUuid(value, 'malformed-signature-id');
}
}
@@ -1,5 +1,5 @@
import { plainToInstance } from 'class-transformer';
import { validate } from 'class-validator';
import { validate, type ValidationError } from 'class-validator';
import {
MAX_VACATION_SUBJECT_LENGTH,
@@ -18,6 +18,22 @@ async function constraintsOf(body: object): Promise<string[]> {
return errors.flatMap((error) => Object.keys(error.constraints ?? {}));
}
/** 안쪽 객체(external — 16단계)까지 — `칸.규칙` 모양으로 모두 */
async function allConstraintsOf(body: object): Promise<string[]> {
const errors = await validate(plainToInstance(MailVacationDto, body), {
whitelist: true,
forbidNonWhitelisted: true,
});
const flatten = (list: ValidationError[], prefix: string): string[] =>
list.flatMap((error) => [
...Object.keys(error.constraints ?? {}).map(
(name) => `${prefix}${error.property}.${name}`,
),
...flatten(error.children ?? [], `${prefix}${error.property}.`),
]);
return flatten(errors, '');
}
describe('부재중 자동 응답 본문 (PUT /api/mail-vacation)', () => {
const enabled = {
isEnabled: true,
@@ -72,7 +88,7 @@ describe('부재중 자동 응답 본문 (PUT /api/mail-vacation)', () => {
).toEqual([]);
});
it(`요청 검증의 길이 한도는 켬 · 끔과 상관없이 제목 ${MAX_VACATION_SUBJECT_LENGTH_WHEN_OFF}자 · 내용 ${MAX_VACATION_TEXT_LENGTH_WHEN_OFF}자 (JS 문자열 길이) — 서식 본문은 받지 않는다`, async () => {
it(`요청 검증의 길이 한도는 켬 · 끔과 상관없이 제목 ${MAX_VACATION_SUBJECT_LENGTH_WHEN_OFF}자 · 내용 ${MAX_VACATION_TEXT_LENGTH_WHEN_OFF}자 (JS 문자열 길이) — 16단계부터 서식 본문(htmlBody)도 받는다`, async () => {
for (const isEnabled of [false, true]) {
expect(
await constraintsOf({
@@ -102,7 +118,7 @@ describe('부재중 자동 응답 본문 (PUT /api/mail-vacation)', () => {
);
expect(
await constraintsOf({ ...enabled, htmlBody: '<p>휴가</p>' }),
).toEqual(['whitelistValidation']);
).toEqual([]);
});
it(`꺼 둔 채로는 켤 때의 한도(제목 ${MAX_VACATION_SUBJECT_LENGTH}자 · 내용 ${MAX_VACATION_TEXT_LENGTH}자)를 넘는 글도 받는다 — 제목 151~200자 · 내용 601~4,000자 (켤 때의 한도는 서비스가 본다)`, async () => {
@@ -212,6 +228,134 @@ describe('부재중 자동 응답 본문 (PUT /api/mail-vacation)', () => {
});
});
describe('부재중 서식 본문 · 회사 밖 응답 (16단계)', () => {
const enabled = {
isEnabled: true,
fromDate: null,
toDate: null,
subject: '부재중입니다',
textBody: '11월 7일까지 휴가입니다.',
};
const external = {
mode: 'separate',
subject: 'Out of office',
textBody: 'I am away until Nov 7.',
htmlBody: '<p>I am away until <strong>Nov 7</strong>.</p>',
};
it('htmlBody · external 은 보내지 않아도 된다(옛 요청) — 보내면 htmlBody 는 글 · null, external 은 네 키(글 칸은 null 가능) · null', async () => {
expect(await allConstraintsOf(enabled)).toEqual([]);
expect(
await allConstraintsOf({
...enabled,
htmlBody: '<p>휴가</p>',
external,
}),
).toEqual([]);
expect(
await allConstraintsOf({ ...enabled, htmlBody: null, external: null }),
).toEqual([]);
for (const mode of ['same', 'none', 'separate']) {
expect(
await allConstraintsOf({
...enabled,
external: { mode, subject: null, textBody: null, htmlBody: null },
}),
).toEqual([]);
}
});
it('형식이 틀리면 VAL_001 — 모르는 고르기 · 빠진 키 · 글이 아닌 값 · 모르는 키 · 객체가 아님', async () => {
expect(await allConstraintsOf({ ...enabled, htmlBody: 1 })).toEqual([
'htmlBody.isString',
]);
expect(
await allConstraintsOf({
...enabled,
external: { ...external, mode: 'internal' },
}),
).toEqual(['external.mode.isIn']);
// 빠진 키 — 전체 교체라 null 로 보낸다(이 검사는 stopAtFirstError 를 빼 걸린 규칙이 모두 보인다)
expect(
await allConstraintsOf({ ...enabled, external: { mode: 'same' } }),
).toEqual(
expect.arrayContaining([
'external.subject.isString',
'external.textBody.isString',
'external.htmlBody.isString',
]),
);
expect(
await allConstraintsOf({
...enabled,
external: { ...external, subject: 1, htmlBody: [] },
}),
).toEqual(
expect.arrayContaining([
'external.subject.isString',
'external.htmlBody.isString',
]),
);
expect(
await allConstraintsOf({
...enabled,
external: { ...external, isActive: true },
}),
).toEqual(['external.isActive.whitelistValidation']);
for (const value of [[], 'same', 1]) {
expect(await allConstraintsOf({ ...enabled, external: value })).toEqual(
expect.arrayContaining(['external.isObject']),
);
}
});
it(`회사 밖 제목 · 내용은 사내 칸과 같은 형식 한도를 늘 본다 — ${MAX_VACATION_SUBJECT_LENGTH_WHEN_OFF}자 · ${MAX_VACATION_TEXT_LENGTH_WHEN_OFF}자 · 제어 문자 (켤 때의 150 · 600자는 서비스가)`, async () => {
for (const isEnabled of [false, true]) {
expect(
await allConstraintsOf({
...enabled,
isEnabled,
external: {
...external,
subject: 'x'.repeat(MAX_VACATION_SUBJECT_LENGTH_WHEN_OFF),
textBody: 'x'.repeat(MAX_VACATION_TEXT_LENGTH_WHEN_OFF),
},
}),
).toEqual([]);
}
expect(
await allConstraintsOf({
...enabled,
external: {
...external,
subject: 'x'.repeat(MAX_VACATION_SUBJECT_LENGTH_WHEN_OFF + 1),
textBody: 'x'.repeat(MAX_VACATION_TEXT_LENGTH_WHEN_OFF + 1),
},
}),
).toEqual([
'external.subject.maxCodeUnits',
'external.textBody.maxCodeUnits',
]);
expect(
await allConstraintsOf({
...enabled,
external: { ...external, subject: 'a\nb', textBody: 'a\u0000' },
}),
).toEqual(['external.subject.matches', 'external.textBody.matches']);
// 켤 때의 한도(150 · 600)를 넘는 글도 형식으로는 맞다 — 켤 때 따로 쓰기면 서비스가 VAL_001
expect(
await allConstraintsOf({
...enabled,
external: {
...external,
subject: 'x'.repeat(MAX_VACATION_SUBJECT_LENGTH + 1),
textBody: 'x'.repeat(MAX_VACATION_TEXT_LENGTH + 1),
},
}),
).toEqual([]);
});
});
describe('isUtcDateTime', () => {
it('JS toISOString 모양(소수 초 3자리까지)과 소수 초 없는 모양을 받는다', () => {
expect(isUtcDateTime('2026-11-02T15:00:00.000Z')).toBe(true);
+122 -10
View File
@@ -1,15 +1,22 @@
import { ApiProperty } from '@nestjs/swagger';
import { ApiProperty, ApiPropertyOptional, OmitType } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import {
IsBoolean,
IsIn,
IsObject,
IsOptional,
IsString,
Matches,
ValidateBy,
ValidateIf,
ValidateNested,
type ValidationOptions,
} from 'class-validator';
import { MaxCodeUnits } from '@/common/decorators/code-units.decorator';
import {
MAIL_VACATION_EXTERNAL_MODES,
MAX_VACATION_HTML_LENGTH,
MAX_VACATION_SUBJECT_LENGTH,
MAX_VACATION_SUBJECT_LENGTH_WHEN_OFF,
MAX_VACATION_TEXT_LENGTH,
@@ -18,7 +25,8 @@ import {
SINGLE_LINE_TEXT_PATTERN,
} from '@/modules/mail/mail.constants';
import type {
MailVacation,
MailVacationExternal,
MailVacationExternalMode,
MailVacationState,
} from '@/modules/mail/mail.types';
@@ -46,16 +54,84 @@ export function IsUtcDateTime(options: ValidationOptions) {
);
}
/** null 은 "날짜 없음"으로 받는다 — 키가 없으면(undefined) 형식 오류다 (전체 교체라 다섯 필드를 모두 보낸다) */
/** null 은 "날짜 없음" · "쓴 적 없음"으로 받는다 — 키가 없으면(undefined) 형식 오류다 (전체 교체라 키를 모두 보낸다) */
const unlessNull = (_object: object, value: unknown) => value !== null;
const HTML_BODY_DESCRIPTION =
`서식 본문 — 서식 본문과 같은 허용 목록으로 정화해 저장한다(그림은 넣을 수 없다). 정화 뒤 ${MAX_VACATION_HTML_LENGTH.toLocaleString('ko-KR')}자까지(켤 때만 본다 — 넘으면 400 VAL_001). ` +
'null · 빈 글이면 글만. 받는 사람에게는 글(textBody) + 서식 두 벌로 간다';
/**
* 회사 밖 응답 (16단계) — 네 키를 모두 보낸다(글 칸은 null 가능). 따로 쓰기(separate)이고 켤 때는 제목 · 내용이 1자 이상 ·
* 제목 150자 · 내용 600자 · 서식 10,000자(서비스가 본다). 다른 고르기여도 써 둔 글은 그대로 저장한다
*/
export class MailVacationExternalDto implements MailVacationExternal {
@ApiProperty({
description:
'회사 밖(봉투 보낸 사람의 도메인이 회사 메일 도메인이 아님)에서 온 메일에 — same: 사내와 같은 응답(처음 값) · none: 보내지 않기 · separate: 아래 제목 · 내용으로',
enum: MAIL_VACATION_EXTERNAL_MODES,
example: 'separate',
})
@IsIn(MAIL_VACATION_EXTERNAL_MODES, {
message: '회사 밖 응답을 다시 선택해 주세요.',
})
mode!: MailVacationExternalMode;
@ApiProperty({
description: `회사 밖에 보낼 제목 — 켜고 따로 쓰기면 1~${MAX_VACATION_SUBJECT_LENGTH}자, 그 밖에는 ${MAX_VACATION_SUBJECT_LENGTH_WHEN_OFF}자까지. 빈 글은 null 로 저장한다. 제어 문자는 쓸 수 없다`,
nullable: true,
type: String,
maxLength: MAX_VACATION_SUBJECT_LENGTH_WHEN_OFF,
example: 'Out of office',
})
@ValidateIf(unlessNull)
@IsString({ message: '회사 밖에 보낼 제목 형식이 올바르지 않습니다.' })
@MaxCodeUnits(MAX_VACATION_SUBJECT_LENGTH_WHEN_OFF, {
message: `회사 밖에 보낼 제목은 ${MAX_VACATION_SUBJECT_LENGTH_WHEN_OFF}자 이하로 입력해 주세요.`,
})
@Matches(SINGLE_LINE_TEXT_PATTERN, {
message: '회사 밖에 보낼 제목에는 줄바꿈 같은 제어 문자를 쓸 수 없습니다.',
})
subject!: string | null;
@ApiProperty({
description: `회사 밖에 보낼 내용(보이는 글) — 켜고 따로 쓰기면 1~${MAX_VACATION_TEXT_LENGTH}자, 그 밖에는 ${MAX_VACATION_TEXT_LENGTH_WHEN_OFF.toLocaleString('ko-KR')}자까지. 빈 글은 null 로 저장한다`,
nullable: true,
type: String,
maxLength: MAX_VACATION_TEXT_LENGTH_WHEN_OFF,
example: 'I am out of the office until November 7.',
})
@ValidateIf(unlessNull)
@IsString({ message: '회사 밖에 보낼 내용 형식이 올바르지 않습니다.' })
@MaxCodeUnits(MAX_VACATION_TEXT_LENGTH_WHEN_OFF, {
message: `회사 밖에 보낼 내용은 ${MAX_VACATION_TEXT_LENGTH_WHEN_OFF.toLocaleString('ko-KR')}자 이하로 입력해 주세요.`,
})
@Matches(MULTI_LINE_TEXT_PATTERN, {
message:
'회사 밖에 보낼 내용에는 탭 · 줄바꿈 밖의 제어 문자를 쓸 수 없습니다.',
})
textBody!: string | null;
@ApiProperty({
description: `회사 밖에 보낼 ${HTML_BODY_DESCRIPTION}`,
nullable: true,
type: String,
example:
'<p style="margin: 0">I am out of the office until <strong>November 7</strong>.</p>',
})
@ValidateIf(unlessNull)
@IsString({ message: '회사 밖에 보낼 내용 형식이 올바르지 않습니다.' })
htmlBody!: string | null;
}
/**
* 부재중 자동 응답 — PUT 본문(전체 교체). GET · PUT 응답은 이것에 isActive 를 더한 MailVacationStateDto 다 (계약 2절 "부재중 자동 응답", 7단계).
* 켤 때 제목 · 내용이 비었는지 · 켤 때의 길이(150자 · 600자), 시작이 끝보다 앞인지는 서비스가 본다 (400 VAL_001).
* 켤 때 제목 · 내용이 비었는지 · 켤 때의 길이(150자 · 600자 · 서식 10,000자), 시작이 끝보다 앞인지는 서비스가 본다 (400 VAL_001).
* 여기서는 켬 · 끔과 상관없는 한도(4단계의 200자 · 4,000자)만 본다 — 같은 속성에 ValidateIf 를 걸면 그 속성의 검사가 모두 빠져
* 켤 때만의 한도를 함께 걸 수 없다. 서식 본문은 받지 않는다.
* 켤 때만의 한도를 함께 걸 수 없다.
* 16단계의 htmlBody · external 은 보내지 않아도 된다 — 없으면 글만 · 사내와 같은 응답(회사 밖 글 칸 null)으로 저장한다(옛 화면이 보는 그대로)
*/
export class MailVacationDto implements MailVacation {
export class MailVacationDto {
@ApiProperty({
description: `켜짐 — 켜면 제목 · 내용이 1자 이상, 제목 ${MAX_VACATION_SUBJECT_LENGTH}자 · 내용 ${MAX_VACATION_TEXT_LENGTH}자까지여야 한다 (아니면 400 VAL_001)`,
example: true,
@@ -102,8 +178,8 @@ export class MailVacationDto implements MailVacation {
@ApiProperty({
description:
`자동 응답 내용 — 글자만(서식 없음). 켤 때는 1~${MAX_VACATION_TEXT_LENGTH.toLocaleString('ko-KR')}자(엔진의 바이트 한도 안), ` +
`꺼 둔 채로는 ${MAX_VACATION_TEXT_LENGTH_WHEN_OFF.toLocaleString('ko-KR')}자까지 (JS 문자열 길이). 서명은 붙지 않는다`,
`자동 응답 내용(보이는 글 — 서식을 뺀 글). 켤 때는 1~${MAX_VACATION_TEXT_LENGTH.toLocaleString('ko-KR')}자(엔진의 바이트 한도 안), ` +
`꺼 둔 채로는 ${MAX_VACATION_TEXT_LENGTH_WHEN_OFF.toLocaleString('ko-KR')}자까지 (JS 문자열 길이). 서식 본문(htmlBody)이 있으면 받는 쪽의 글 판이다. 서명은 붙지 않는다`,
maxLength: MAX_VACATION_TEXT_LENGTH_WHEN_OFF,
example: '11월 7일까지 휴가입니다. 급한 일은 이도윤 님께 연락해 주세요.',
})
@@ -115,16 +191,52 @@ export class MailVacationDto implements MailVacation {
message: '자동 응답 내용에는 탭 · 줄바꿈 밖의 제어 문자를 쓸 수 없습니다.',
})
textBody!: string;
@ApiPropertyOptional({
description: `${HTML_BODY_DESCRIPTION} (16단계 — 보내지 않으면 null)`,
nullable: true,
type: String,
example:
'<p style="margin: 0">11월 7일까지 <strong>휴가</strong>입니다.</p>',
})
@IsOptional()
@IsString({ message: '자동 응답 내용 형식이 올바르지 않습니다.' })
htmlBody?: string | null;
@ApiPropertyOptional({
description:
'회사 밖에서 온 메일에 하는 응답 (16단계) — 보내지 않거나 null 이면 사내와 같은 응답(글 칸 null)으로 저장한다',
type: MailVacationExternalDto,
})
@IsOptional()
@IsObject({ message: '회사 밖 응답 형식이 올바르지 않습니다.' })
@ValidateNested()
@Type(() => MailVacationExternalDto)
external?: MailVacationExternalDto | null;
}
/**
* GET · PUT /api/mail-vacation 의 응답 (계약 2절의 MailVacationState — 7단계) — 저장한 값 + 지금 동작하는지.
* PUT 본문에는 isActive 를 넣지 않는다 (넣으면 모르는 필드 — 400 VAL_001). 응답 문서용이라 검사 데코레이터를 더하지 않는다
* 16단계의 htmlBody · external 은 응답에 늘 있다. PUT 본문에는 isActive 를 넣지 않는다 (넣으면 모르는 필드 — 400 VAL_001).
* 응답 문서용이라 검사 데코레이터를 더하지 않는다
*/
export class MailVacationStateDto
extends MailVacationDto
extends OmitType(MailVacationDto, ['htmlBody', 'external'] as const)
implements MailVacationState
{
@ApiProperty({
description: `${HTML_BODY_DESCRIPTION} — 늘 있다(없으면 null)`,
nullable: true,
type: String,
})
htmlBody!: string | null;
@ApiProperty({
description: '회사 밖에서 온 메일에 하는 응답 — 늘 있다',
type: MailVacationExternalDto,
})
external!: MailVacationExternalDto;
@ApiProperty({
description:
'false = 메일 엔진에서 우리 규칙(메일 필터와 한 규칙)이 꺼져 있어 켜 둔 자동 응답이 지금은 동작하지 않는다 — 저장하면 다시 켜진다. ' +
@@ -775,4 +775,60 @@ describe('sanitizeOutgoingHtml (보내는 서식 본문)', () => {
}
});
});
/**
* 쓰기 화면의 서명 자리(16단계 — 계약 2절 "앱이 만드는 모양") — 빈 문단 하나 뒤의 **속성 없는 div 하나**가 서명 경계다.
* 웹 편집기도 같은 경계로 서명을 넣고 임시 저장 · 이어 쓰기에서 다시 찾으므로, 보내는 정화 · 임시 저장본 다시 읽기 · 받은 메일 정화를 지나도 남아야 한다
*/
describe('서명 자리 — 빈 문단 뒤 속성 없는 div (16단계)', () => {
const IMAGE_ID = '3f1c0a2e-0f1b-4f2a-9a1e-6c9f0a1b2c3d';
const signatureBlock =
'<div><p style="margin: 0"><strong>김아라</strong> · 인프라팀</p>' +
`<img src="/api/mail-signature/images/${IMAGE_ID}" width="144" alt=""></div>`;
const composed =
`<div style="font-family: ${DEFAULT_FONT}; font-size: 14px; line-height: 1.75">` +
'<p style="margin: 0">안녕하세요.</p><p style="margin: 0"><br></p>' +
signatureBlock +
'<div data-ara-quote-start=""></div>' +
'<blockquote style="margin: 0; padding-left: 12px; border-left: 2px solid #D0D5DD"><p style="margin: 0">원문</p></blockquote></div>';
/** 정화한 서명 자리 — 빈 문단 바로 뒤의 속성 없는 div, 안은 서명 */
const keptBoundary =
'<p style="margin:0"><br /></p><div><p style="margin:0"><strong>김아라</strong> · 인프라팀</p>' +
`<img src="/api/mail-signature/images/${IMAGE_ID}" width="144" alt="" /></div>`;
it('보낼 때 · 임시 저장할 때(인용 경계 표시를 남기는 정화)와 저장본을 다시 열 때(표시 없는 정화) 모두 경계 div 가 속성 없이 남는다', () => {
const ids = new Set([IMAGE_ID]);
for (const options of [
{ signatureImageIds: ids, keepQuoteStart: true },
{ signatureImageIds: ids },
]) {
const html = clean(composed, options);
expect(html).toContain(keptBoundary);
// 정화한 것을 다시 정화해도 같다 — 이어 쓰기를 거듭해도 경계가 그대로다
expect(clean(html, { signatureImageIds: ids })).toBe(
clean(composed, { signatureImageIds: ids }),
);
}
});
it('받은 메일 정화(보낸편지함 · 받는 사람이 다시 읽을 때)도 속성 없는 div 를 그대로 둔다', () => {
const sent = clean(composed, { signatureImageIds: new Set([IMAGE_ID]) });
const { html } = sanitizeMailHtml(sent);
expect(html).toContain(
'<p style="margin:0"><br /></p><div><p style="margin:0"><strong>김아라</strong> · 인프라팀</p>',
);
});
it('속성이 모두 지워진 div 도 속성 없는 div 가 된다 — 경계는 자리(빈 문단 바로 뒤)로 알아본다 (화면 · 앱이 함께 보는 알려진 모양)', () => {
for (const div of [
'<div style="">',
'<div style="position: absolute">',
'<div class="sig" data-x="1">',
]) {
expect(
clean(`<p style="margin: 0"><br></p>${div}<p>서명</p></div>`),
).toBe('<p style="margin:0"><br /></p><div><p>서명</p></div>');
}
});
});
});
@@ -0,0 +1,65 @@
import { MAX_BODY_HTML_LENGTH } from '@/modules/mail/mail.constants';
import { sanitizeStoredHtml } from './sanitize-stored-html';
describe('sanitizeStoredHtml — 저장해 두는 짧은 서식 글(서명 · 부재중 응답)의 정화', () => {
const IMAGE_ID = '3f1c0a2e-0f1b-4f2a-9a1e-6c9f0a1b2c3d';
const image = `<img src="/api/mail-signature/images/${IMAGE_ID}" width="96">`;
it('보내는 정화를 거친다 — 스크립트 · 이벤트 속성은 지우고, 그림은 넘겨준 서명 그림만 남긴다', () => {
expect(
sanitizeStoredHtml(
`<p onclick="x()"><strong>휴가</strong><script>x()</script></p>${image}`,
{ maxLength: null },
),
).toEqual({ ok: true, html: '<p><strong>휴가</strong></p>' });
expect(
sanitizeStoredHtml(`<p>서명</p>${image}`, {
maxLength: null,
signatureImageIds: new Set([IMAGE_ID]),
}),
).toEqual({
ok: true,
html: `<p>서명</p><img src="/api/mail-signature/images/${IMAGE_ID}" width="96" />`,
});
});
it('없거나 비었거나 정화해서 남는 것이 없으면 null', () => {
for (const html of [null, undefined, '', '<script>x()</script>', ' ']) {
expect(sanitizeStoredHtml(html, { maxLength: 10 })).toEqual({
ok: true,
html: null,
});
}
});
it('정화 뒤 길이는 maxLength 일 때만 본다 — 정화 전 길이 · 파서 비용은 늘 본다', () => {
const long = `<p>${'가'.repeat(10_000)}</p>`;
expect(sanitizeStoredHtml(long, { maxLength: 10_000 })).toEqual({
ok: false,
problem: 'too-long',
length: 10_007,
});
expect(sanitizeStoredHtml(long, { maxLength: null })).toEqual({
ok: true,
html: long,
});
expect(
sanitizeStoredHtml(`<p>${'가'.repeat(10_000 - 7)}</p>`, {
maxLength: 10_000,
}),
).toMatchObject({ ok: true });
expect(
sanitizeStoredHtml('x'.repeat(MAX_BODY_HTML_LENGTH + 1), {
maxLength: null,
}),
).toEqual({
ok: false,
problem: 'too-long',
length: MAX_BODY_HTML_LENGTH + 1,
});
expect(
sanitizeStoredHtml('<b>'.repeat(166_666), { maxLength: null }),
).toMatchObject({ ok: false, problem: 'too-complex' });
});
});
@@ -0,0 +1,54 @@
import { MAX_BODY_HTML_LENGTH } from '@/modules/mail/mail.constants';
import { checkHtmlParseBudget } from './html-parse-budget';
import { sanitizeOutgoingHtml } from './sanitize-outgoing-html';
/**
* 저장해 두고 다시 쓰는 짧은 서식 글의 정화 (순수 함수) — 서명(계약 2절 "서명")과 부재중 응답의 서식 본문(16단계 — "부재중 자동 응답").
* 쓰기 화면의 서식 본문과 같은 규칙이다:
* 1. 정화 전 — 길이(MAX_BODY_HTML_LENGTH — 500,000자)와 파서 비용(html-parse-budget.ts — 정화가 이벤트 루프를 오래 붙잡지 않게)
* 2. 보내는 정화(sanitize-outgoing-html.ts) — 그림은 `signatureImageIds` 에 든 그 사람의 서명 그림만 남는다(없으면 그림 없음 — 부재중)
* 3. 정화 뒤 — `maxLength`(JS 문자열 길이)를 넘으면 실패. null 이면 보지 않는다(부재중을 꺼 둔 채 저장할 때)
* 비었거나 정화해서 남는 것이 없으면 null 이다. 실패는 부르는 쪽이 400 VAL_001 로 던진다(사유만 — 글은 로그에 남기지 않는다)
*/
/** too-long: 정화 전 · 뒤의 길이 한도 초과, too-complex: 파서 비용 한도 초과(깊은 중첩 등) */
export type StoredHtmlProblem = 'too-long' | 'too-complex';
export type StoredHtmlResult =
| { readonly ok: true; readonly html: string | null }
| {
readonly ok: false;
readonly problem: StoredHtmlProblem;
/** 걸린 글의 길이 (JS 문자열 길이) — 로그용 */
readonly length: number;
};
export interface StoredHtmlOptions {
/** 정화한 뒤의 최대 길이 — null 이면 보지 않는다 */
readonly maxLength: number | null;
/** 남겨도 되는 서명 그림 id — 없으면 그림을 하나도 남기지 않는다 */
readonly signatureImageIds?: ReadonlySet<string>;
}
export function sanitizeStoredHtml(
html: string | null | undefined,
options: StoredHtmlOptions,
): StoredHtmlResult {
if (!html) return { ok: true, html: null };
if (html.length > MAX_BODY_HTML_LENGTH) {
return { ok: false, problem: 'too-long', length: html.length };
}
if (!checkHtmlParseBudget(html).ok) {
return { ok: false, problem: 'too-complex', length: html.length };
}
const sanitized = sanitizeOutgoingHtml(
html,
options.signatureImageIds
? { signatureImageIds: options.signatureImageIds }
: {},
);
if (options.maxLength !== null && sanitized.length > options.maxLength) {
return { ok: false, problem: 'too-long', length: sanitized.length };
}
return { ok: true, html: sanitized === '' ? null : sanitized };
}
@@ -11,11 +11,14 @@ import type {
import {
FILTER_SOURCE_FORMAT,
FILTER_SOURCE_FORMAT_WITH_BLOCKS,
FILTER_SOURCE_FORMAT_WITH_RICH_VACATION,
FILTER_SOURCE_MARKER,
LATEST_FILTER_SOURCE_FORMAT,
SAME_AS_INTERNAL_REPLY,
filterRulesVersion,
filterSourceLine,
readFilterSource,
usesRichVacation,
type FilterScriptSource,
} from './filter-source';
@@ -46,6 +49,8 @@ const vacation: MailVacation = {
toDate: null,
subject: '부재중 ${x} "따옴표"',
textBody: '첫 줄\r\n둘째 줄\n\t탭',
htmlBody: null,
external: SAME_AS_INTERNAL_REPLY,
};
const source: FilterScriptSource = {
@@ -186,7 +191,9 @@ describe('filterSourceLine · readFilterSource — 스크립트 머리의 원본
});
it('새 판(이 서버가 아는 가장 새 판보다 큰 정수)은 깨짐과 나눈다 — 나머지 모양은 보지 않는다', () => {
expect(LATEST_FILTER_SOURCE_FORMAT).toBe(FILTER_SOURCE_FORMAT_WITH_BLOCKS);
expect(LATEST_FILTER_SOURCE_FORMAT).toBe(
FILTER_SOURCE_FORMAT_WITH_RICH_VACATION,
);
expect(
readFilterSource(
scriptWithJson(stored({ v: LATEST_FILTER_SOURCE_FORMAT + 1 })),
@@ -422,6 +429,153 @@ describe('차단 목록(판 2 — 9단계 "보낸 사람 차단")', () => {
});
});
describe('부재중의 서식 본문 · 회사 밖 응답(판 3 — 16단계)', () => {
const rich: MailVacation = {
...vacation,
htmlBody:
'<p style="margin:0">11월 7일까지 <strong>휴가</strong>입니다.</p>',
external: {
mode: 'separate',
subject: 'Out of office',
textBody: 'I am away until Nov 7.',
htmlBody: '<p>I am away until <strong>Nov 7</strong>.</p>',
},
};
const richSource: FilterScriptSource = { ...source, vacation: rich };
const formatOf = (value: FilterScriptSource) =>
(
JSON.parse(
filterSourceLine(value).slice(FILTER_SOURCE_MARKER.length),
) as Record<string, unknown>
).v;
const storedV3 = (overrides: Record<string, unknown> = {}) =>
stored({
v: FILTER_SOURCE_FORMAT_WITH_RICH_VACATION,
vacation: rich,
blocked: [],
...overrides,
});
it('새 칸을 쓴 직원만 판 3 — 서식 본문 · 사내와 다른 회사 밖 응답 · 써 둔 회사 밖 글 가운데 하나라도. 아니면 판 1 · 2 그대로', () => {
expect(LATEST_FILTER_SOURCE_FORMAT).toBe(
FILTER_SOURCE_FORMAT_WITH_RICH_VACATION,
);
expect(formatOf(source)).toBe(FILTER_SOURCE_FORMAT);
expect(usesRichVacation(vacation)).toBe(false);
for (const changed of [
{ ...vacation, htmlBody: '<p>휴가</p>' },
{ ...vacation, external: { ...SAME_AS_INTERNAL_REPLY, mode: 'none' } },
{
...vacation,
external: { ...SAME_AS_INTERNAL_REPLY, mode: 'separate' },
},
// 사내와 같은 응답이어도 써 둔 회사 밖 글이 있으면 남겨야 한다
{
...vacation,
external: { ...SAME_AS_INTERNAL_REPLY, textBody: '써 둔 글' },
},
] as MailVacation[]) {
expect(usesRichVacation(changed)).toBe(true);
expect(formatOf({ ...source, vacation: changed })).toBe(
FILTER_SOURCE_FORMAT_WITH_RICH_VACATION,
);
}
// 판 3 은 차단 목록 칸을 늘 담는다(비었어도) — 차단이 있으면 그대로
const v3 = JSON.parse(
filterSourceLine(richSource).slice(FILTER_SOURCE_MARKER.length),
) as Record<string, unknown>;
expect(v3.blocked).toEqual([]);
expect(v3.vacation).toEqual(rich);
});
it('판 3 을 그대로 읽는다 — 차단 목록도. 판 1 · 2 는 글만 · 사내와 같은 응답으로', () => {
const blocked: BlockedSender[] = [
{ address: 'spam@example.net', blockedAt: '2026-10-29T01:02:03.000Z' },
];
for (const value of [richSource, { ...richSource, blocked }]) {
expect(readFilterSource(scriptWith(filterSourceLine(value)))).toEqual({
status: 'ok',
source: value,
});
}
// 판 1 · 2 에 새 칸이 적혀 있어도 보지 않는다 — 그 판을 쓰는 서버는 그 칸을 모른다
for (const v of [FILTER_SOURCE_FORMAT, FILTER_SOURCE_FORMAT_WITH_BLOCKS]) {
expect(
sourceOf(scriptWithJson(stored({ v, vacation: rich, blocked: [] })))
?.vacation,
).toEqual({
...vacation,
htmlBody: null,
external: SAME_AS_INTERNAL_REPLY,
});
}
});
it('판 3 의 서식 본문은 읽을 때 보내는 정화를 다시 거친다 — 다른 프로그램이 고친 원본의 스크립트 · 이벤트 속성 · 그림은 화면에 가지 않는다', () => {
const read = sourceOf(
scriptWithJson(
storedV3({
vacation: {
...rich,
htmlBody:
'<p onclick="x()">휴가<script>alert(1)</script></p><img src="https://evil.example/x.png">',
external: {
...rich.external,
htmlBody: '<a href="javascript:x()">링크</a><b>굵게</b>',
},
},
}),
),
);
expect(read?.vacation.htmlBody).toBe('<p>휴가</p>');
expect(read?.vacation.external.htmlBody).toBe(
'<span>링크</span><b>굵게</b>',
);
// 정화해서 비면 null — 글만
expect(
sourceOf(
scriptWithJson(
storedV3({ vacation: { ...rich, htmlBody: '<script>x()</script>' } }),
),
)?.vacation.htmlBody,
).toBeNull();
});
it.each<[string, Record<string, unknown>]>([
['회사 밖 응답 없음', { ...rich, external: undefined }],
['회사 밖 응답이 객체가 아님', { ...rich, external: 'same' }],
[
'모르는 고르기',
{ ...rich, external: { ...rich.external, mode: 'internal' } },
],
[
'회사 밖 제목이 글이 아님',
{ ...rich, external: { ...rich.external, subject: 1 } },
],
[
'회사 밖 글 칸 없음',
{ ...rich, external: { mode: 'same', subject: null, htmlBody: null } },
],
['서식 본문이 글이 아님', { ...rich, htmlBody: 1 }],
['서식 본문 칸 없음', { ...rich, htmlBody: undefined }],
[
'정화할 수 없는 서식(깊은 중첩)',
{ ...rich, htmlBody: '<b>'.repeat(166_666) },
],
])('판 3 의 부재중이 계약에 맞지 않으면 깨진 원본 — %s', (_label, value) => {
expect(
readFilterSource(scriptWithJson(storedV3({ vacation: value }))),
).toEqual(BROKEN);
});
it('판 3 의 차단 목록은 판 2 와 같은 규칙 — 칸이 없으면 깨진 원본', () => {
expect(
readFilterSource(scriptWithJson(storedV3({ blocked: undefined }))),
).toEqual(BROKEN);
});
});
describe('filterRulesVersion — 규칙의 지문(version)', () => {
const rules = source.rules;
+122 -25
View File
@@ -1,5 +1,6 @@
import { createHash } from 'crypto';
import { sanitizeStoredHtml } from '@/modules/mail/html/sanitize-stored-html';
import { parseBlockTarget } from '@/modules/mail/mail-blocks';
import {
MAIL_FILTER_FIELDS,
@@ -8,6 +9,7 @@ import {
findFilterRulesProblem,
} from '@/modules/mail/mail-filters';
import {
MAIL_VACATION_EXTERNAL_MODES,
MAX_MAIL_BLOCKS,
MAX_MAIL_FILTER_RULES,
} from '@/modules/mail/mail.constants';
@@ -17,6 +19,7 @@ import type {
MailFilterCondition,
MailFilterRule,
MailVacation,
MailVacationExternal,
} from '@/modules/mail/mail.types';
import { toUtcIso } from './jmap.mapper';
@@ -43,12 +46,19 @@ export const FILTER_SOURCE_MARKER = '# arasystem-source: ';
* - 2: 1 + 차단 목록 `blocked` (9단계 "보낸 사람 차단") — **차단한 주소가 있을 때만** 쓴다. 판 2 를 아는 서버는 판 1 을 "차단 없음"으로 읽는다.
* 판 2 를 모르는 서버로 되돌리면 차단한 주소가 있는 직원만 필터 · 부재중 저장이 503 MAIL_001 로 막힌다(계약 2절 — 되돌리기 계획:
* 판 2 를 읽기만 하는 서버를 먼저 배포할 수 있게 둔다)
* - 3: 2 + 부재중의 서식 본문 `vacation.htmlBody` · 회사 밖 응답 `vacation.external` (16단계) — **새 칸을 쓴 직원만** 쓴다
* (usesRichVacation — 서식 본문이 있거나 · 회사 밖 응답이 사내와 같지 않거나 · 회사 밖 글을 써 둔 경우). 판 3 은 `blocked` 를 늘 담는다(비었어도).
* 판 3 을 아는 서버는 판 1 · 2 를 "글만 · 사내와 같은 응답"으로 읽는다. 판 3 을 모르는 서버로 되돌리면 새 칸을 쓴 직원만
* 필터 · 부재중 · 차단 저장이 503 MAIL_001 로 막힌다 — 엔진의 응답(Sieve)은 그대로 돈다(계약 2절 "부재중 자동 응답"의 되돌리기 계획)
*/
export const FILTER_SOURCE_FORMAT = 1;
/** 차단 목록이 든 원본의 판 (9단계) */
export const FILTER_SOURCE_FORMAT_WITH_BLOCKS = 2;
/** 부재중의 서식 본문 · 회사 밖 응답이 든 원본의 판 (16단계) */
export const FILTER_SOURCE_FORMAT_WITH_RICH_VACATION = 3;
/** 이 서버가 아는 가장 새 판 — 이보다 크면 newer */
export const LATEST_FILTER_SOURCE_FORMAT = FILTER_SOURCE_FORMAT_WITH_BLOCKS;
export const LATEST_FILTER_SOURCE_FORMAT =
FILTER_SOURCE_FORMAT_WITH_RICH_VACATION;
/**
* 스크립트 글에서 원본을 읽은 결과
@@ -69,6 +79,14 @@ export interface FilterScriptSource {
readonly blocked: readonly BlockedSender[];
}
/** 회사 밖 응답의 처음 값 — 사내와 같은 응답, 따로 써 둔 글 없음 (16단계 — 판 1 · 2 의 원본 · 새 칸이 없는 옛 요청도 이것) */
export const SAME_AS_INTERNAL_REPLY: MailVacationExternal = {
mode: 'same',
subject: null,
textBody: null,
htmlBody: null,
};
/** 한 번도 설정하지 않은 부재중 자동 응답 (계약 2절 "부재중 자동 응답") */
export const NEVER_CONFIGURED_VACATION: MailVacation = {
isEnabled: false,
@@ -76,8 +94,25 @@ export const NEVER_CONFIGURED_VACATION: MailVacation = {
toDate: null,
subject: '',
textBody: '',
htmlBody: null,
external: SAME_AS_INTERNAL_REPLY,
};
/**
* 16단계의 새 칸을 쓰는가 — 서식 본문이 있거나 · 회사 밖 응답이 사내와 같지 않거나 · 회사 밖 글을 써 둔 경우. 그때만 원본 판 3 으로 쓴다
* (새 칸을 쓰지 않은 직원은 판 1 · 2 그대로 — 판 3 을 모르는 서버로 되돌려도 막히지 않는다)
*/
export function usesRichVacation(vacation: MailVacation): boolean {
const { external } = vacation;
return (
vacation.htmlBody !== null ||
external.mode !== 'same' ||
external.subject !== null ||
external.textBody !== null ||
external.htmlBody !== null
);
}
/** 원본을 읽지 못할 때(깨짐 · 새 판) 보이는 것 — 규칙 없음 · 부재중 꺼짐 · 차단 없음 (깨진 원본은 저장하면 덮어쓴다 · 새 판은 쓰지 않는다) */
export const EMPTY_FILTER_SOURCE: FilterScriptSource = {
rules: [],
@@ -102,7 +137,8 @@ function canonicalRule(rule: MailFilterRule): MailFilterRule {
};
}
function canonicalVacation(vacation: MailVacation): MailVacation {
/** 판 1 · 2 의 부재중 — 다섯 칸 */
function canonicalVacation(vacation: MailVacation) {
return {
isEnabled: vacation.isEnabled,
fromDate: vacation.fromDate,
@@ -112,6 +148,16 @@ function canonicalVacation(vacation: MailVacation): MailVacation {
};
}
/** 판 3 의 부재중 — 다섯 칸 + 서식 본문 · 회사 밖 응답 */
function canonicalRichVacation(vacation: MailVacation): MailVacation {
const { mode, subject, textBody, htmlBody } = vacation.external;
return {
...canonicalVacation(vacation),
htmlBody: vacation.htmlBody,
external: { mode, subject, textBody, htmlBody },
};
}
/** 같은 규칙이면 같은 사본 목록 — 저장 · 지문 · 응답이 이것을 쓴다 */
export function canonicalRules(
rules: readonly MailFilterRule[],
@@ -130,24 +176,28 @@ export function filterRulesVersion(rules: readonly MailFilterRule[]): string {
}
/**
* 원본 줄 — 표지 + JSON 한 줄 (판 · 규칙 · 부재중 · 차단). 차단한 주소가 없으면 판 1 그대로(blocked 없이) — 판 2 를 모르는 서버도 읽는다
* 원본 줄 — 표지 + JSON 한 줄 (판 · 규칙 · 부재중 · 차단). 가장 낮은 판으로 쓴다 — 부재중의 새 칸(16단계)을 쓰면 판 3,
* 아니고 차단한 주소가 있으면 판 2, 둘 다 아니면 판 1(blocked 없이) — 낮은 판만 아는 서버도 읽는다
*/
export function filterSourceLine(source: FilterScriptSource): string {
const base = {
rules: canonicalRules(source.rules),
vacation: canonicalVacation(source.vacation),
};
const rules = canonicalRules(source.rules);
const blocked = source.blocked.map(({ address, blockedAt }) => ({
address,
blockedAt,
}));
if (usesRichVacation(source.vacation)) {
return `${FILTER_SOURCE_MARKER}${JSON.stringify({
v: FILTER_SOURCE_FORMAT_WITH_RICH_VACATION,
rules,
vacation: canonicalRichVacation(source.vacation),
blocked,
})}`;
}
const base = { rules, vacation: canonicalVacation(source.vacation) };
return `${FILTER_SOURCE_MARKER}${JSON.stringify(
source.blocked.length === 0
blocked.length === 0
? { v: FILTER_SOURCE_FORMAT, ...base }
: {
v: FILTER_SOURCE_FORMAT_WITH_BLOCKS,
...base,
blocked: source.blocked.map(({ address, blockedAt }) => ({
address,
blockedAt,
})),
},
: { v: FILTER_SOURCE_FORMAT_WITH_BLOCKS, ...base, blocked },
)}`;
}
@@ -252,8 +302,9 @@ function readDate(raw: unknown): string | null | undefined {
}
/**
* 부재중 — 모양만 본다. 길이 한도는 보지 않는다: 저장해 둔 값은 4단계 요청 검증 · 엔진의 VacationResponse 한도 안에서 온 것이고
* (VacationResponse 에서 가져온 값은 우리 한도보다 길 수 있다), Sieve 에는 base64 로 들어가 글이 문법을 깨지 않는다
* 부재중(판 1 · 2) — 모양만 본다. 길이 한도는 보지 않는다: 저장해 둔 값은 4단계 요청 검증 · 엔진의 VacationResponse 한도 안에서 온 것이고
* (VacationResponse 에서 가져온 값은 우리 한도보다 길 수 있다), Sieve 에는 base64 로 들어가 글이 문법을 깨지 않는다.
* 16단계의 칸은 처음 값이다 — 글만 · 사내와 같은 응답
*/
function toVacation(raw: unknown): MailVacation | null {
if (
@@ -273,9 +324,52 @@ function toVacation(raw: unknown): MailVacation | null {
toDate,
subject: raw.subject,
textBody: raw.textBody,
htmlBody: null,
external: SAME_AS_INTERNAL_REPLY,
};
}
/**
* 원본의 서식 본문 — null 이면 null, 글이면 보내는 정화(그림 없음)를 **다시** 거친 값(비면 null), 정화할 수 없는 글(길이 · 파서 비용)이면 undefined(깨짐).
* 다시 정화하는 까닭: 다른 메일 프로그램(ManageSieve 등)이 고친 원본의 HTML 이 화면 · 받는 사람에게 그대로 가지 않게 — 우리가 쓴 값은 이미 정화한 값이라 그대로다
*/
function readVacationHtml(raw: unknown): string | null | undefined {
if (raw === null) return null;
if (typeof raw !== 'string') return undefined;
const sanitized = sanitizeStoredHtml(raw, { maxLength: null });
return sanitized.ok ? sanitized.html : undefined;
}
/** 회사 밖 응답(판 3) — 고르기는 셋 가운데 하나, 글 칸은 글 또는 null. 어긋나면 null(깨짐) */
function toVacationExternal(raw: unknown): MailVacationExternal | null {
if (
!isRecord(raw) ||
!isOneOf(raw.mode, MAIL_VACATION_EXTERNAL_MODES) ||
!isNullableString(raw.subject) ||
!isNullableString(raw.textBody)
) {
return null;
}
const htmlBody = readVacationHtml(raw.htmlBody);
if (htmlBody === undefined) return null;
return {
mode: raw.mode,
subject: raw.subject,
textBody: raw.textBody,
htmlBody,
};
}
/** 부재중(판 3) — 판 1 · 2 의 다섯 칸 + 서식 본문 · 회사 밖 응답. 어긋나면 null(깨짐) */
function toRichVacation(raw: unknown): MailVacation | null {
const base = toVacation(raw);
if (base === null || !isRecord(raw)) return null;
const htmlBody = readVacationHtml(raw.htmlBody);
const external = toVacationExternal(raw.external);
if (htmlBody === undefined || external === null) return null;
return { ...base, htmlBody, external };
}
/**
* 차단 목록 (판 2) — 500개까지 · 주소는 차단 대상 모양(소문자 — mail-blocks.ts)이고 겹치지 않음 · 차단한 시각은 읽을 수 있는 시각.
* 어긋나면 null(깨짐) — 다른 메일 프로그램이 고친 원본으로 Sieve 를 만들면 엔진의 변수 펼침 · 한도에 걸릴 수 있다
@@ -313,8 +407,9 @@ const BROKEN: FilterSourceReading = { status: 'broken' };
/**
* 스크립트 글에서 원본을 읽는다 (FilterSourceReading).
* 판(`v`)이 이 서버가 아는 가장 새 판보다 큰 정수면 나머지를 보지 않고 newer 다 — 모양이 달라졌을 수 있다.
* 아는 판이면 모양과 규칙을 계약대로 다시 본다(mail-filters.ts — 50개 · 겹친 id · 조건 수 · `${` 등, 판 2 는 차단 목록도) — 어긋나면 broken.
* 판 1 은 차단 없음이다. 다시 보는 까닭: 다른 메일 프로그램(ManageSieve 등)이 고친 원본으로 Sieve 를 다시 만들면 엔진 한도 · 변수 펼침에 걸릴 수 있다
* 아는 판이면 모양과 규칙을 계약대로 다시 본다(mail-filters.ts — 50개 · 겹친 id · 조건 수 · `${` 등, 판 2 · 3 은 차단 목록도,
* 판 3 은 부재중의 서식 본문(다시 정화) · 회사 밖 응답도) — 어긋나면 broken.
* 판 1 은 차단 없음, 판 1 · 2 는 부재중이 글만 · 사내와 같은 응답이다. 다시 보는 까닭: 다른 메일 프로그램(ManageSieve 등)이 고친 원본으로 Sieve 를 다시 만들면 엔진 한도 · 변수 펼침에 걸릴 수 있다
*/
export function readFilterSource(script: string): FilterSourceReading {
const line = findSourceLine(script);
@@ -337,7 +432,8 @@ export function readFilterSource(script: string): FilterSourceReading {
}
if (
(format !== FILTER_SOURCE_FORMAT &&
format !== FILTER_SOURCE_FORMAT_WITH_BLOCKS) ||
format !== FILTER_SOURCE_FORMAT_WITH_BLOCKS &&
format !== FILTER_SOURCE_FORMAT_WITH_RICH_VACATION) ||
!Array.isArray(parsed.rules) ||
parsed.rules.length > MAX_MAIL_FILTER_RULES
) {
@@ -353,12 +449,13 @@ export function readFilterSource(script: string): FilterSourceReading {
if (findFilterRulesProblem(rules)) return BROKEN;
const blocked =
format === FILTER_SOURCE_FORMAT_WITH_BLOCKS
? toBlocked(parsed.blocked)
: [];
format === FILTER_SOURCE_FORMAT ? [] : toBlocked(parsed.blocked);
if (blocked === null) return BROKEN;
const vacation = toVacation(parsed.vacation);
const vacation =
format === FILTER_SOURCE_FORMAT_WITH_RICH_VACATION
? toRichVacation(parsed.vacation)
: toVacation(parsed.vacation);
return vacation
? { status: 'ok', source: { rules, vacation, blocked } }
: BROKEN;
@@ -190,6 +190,81 @@ describe('JmapAccountSecurityProvider (x:AccountPassword · x:AppPassword —
expect(failure?.code).toBe('MAIL_001');
expect(JSON.stringify(failure?.context)).not.toContain('secret details');
});
it('휴대폰 바꾸기(16단계) — 켠 채 currentSecret + otpAuth {otpCode: 지금 코드, otpUrl: 새 주소} 한 요청, updated → replaced · forbidden → code-rejected · 결과를 모르면 unknown', async () => {
callAccountChange.mockResolvedValueOnce(
answered([
'x:AccountPassword/set',
{ updated: { singleton: null } },
's',
]),
);
await expect(
provider.replaceTwoFactor(ARA, 'pw', '123456', OTPAUTH),
).resolves.toBe('replaced');
expect(lastCalls(callAccountChange)).toEqual([
[
'x:AccountPassword/set',
{
accountId: 'x1',
update: {
singleton: {
currentSecret: 'pw',
otpAuth: { otpCode: '123456', otpUrl: OTPAUTH },
},
},
},
's',
],
]);
callAccountChange.mockResolvedValueOnce(
answered([
'x:AccountPassword/set',
{
notUpdated: {
singleton: {
type: 'forbidden',
description: 'Current secret is incorrect.',
},
},
},
's',
]),
);
await expect(
provider.replaceTwoFactor(ARA, 'pw', '000000', OTPAUTH),
).resolves.toBe('code-rejected');
const warn = jest.spyOn(Logger.prototype, 'warn');
callAccountChange.mockResolvedValueOnce({
kind: 'unknown',
detail: 'timeout',
});
await expect(
provider.replaceTwoFactor(ARA, 'pw', '123456', OTPAUTH),
).resolves.toBe('unknown');
const logged = warn.mock.calls
.map(([message]) => String(message))
.join('\n');
expect(logged).toContain('가를 수 없어 MAIL_001');
expect(logged).not.toMatch(/123456|otpauth|pw\b/);
callAccountChange.mockResolvedValueOnce(
answered([
'x:AccountPassword/set',
{ notUpdated: { singleton: { type: 'serverFail' } } },
's',
]),
);
expect(
(await failureOf(provider.replaceTwoFactor(ARA, 'pw', '1', OTPAUTH)))
?.context,
).toMatchObject({
reason: 'two-factor-replace-refused',
jmapType: 'serverFail',
});
});
});
describe('앱 비밀번호', () => {
@@ -8,6 +8,7 @@ import type {
CreatedAppPassword,
TwoFactorDisableResult,
TwoFactorEnableResult,
TwoFactorReplaceResult,
} from '@/modules/account-security/account-security.provider';
import { traceTag } from '@/shared/logger/request-context';
import { maskEmail } from '@/shared/utils/mask';
@@ -59,6 +60,8 @@ function toIso(value: unknown): string | null {
* - 켜기: `x:AccountPassword/set` 에 currentSecret + otpAuth.otpUrl — 엔진은 코드를 보지 않고 저장한다. 지금 비밀번호가 틀리거나
* 이미 켜져 있으면(지금 코드를 요구) forbidden
* - 끄기: currentSecret + otpAuth {otpCode, otpUrl: null} — 코드 틀림도 비밀번호 틀림과 같은 forbidden "Current secret is incorrect."
* - 휴대폰 바꾸기(16단계): 켠 채 currentSecret + otpAuth {otpCode: 지금 코드, otpUrl: 새 주소} 한 요청 → updated(옛 비밀은 바로 죽는다).
* 코드 없음 · 틀린 코드 · 틀린 비밀번호 모두 forbidden (2026-11-10 개발 엔진 확인)
* - 앱 비밀번호: 값은 엔진이 만들어 만들 때 한 번만 준다(우리가 넣으면 거절), 상한(기본 5)을 넘으면 overQuota, 지운 번호를 다시 쓴다
* 엔진 오류의 설명 문장 · 비밀번호 · 코드 · otpauth 주소 · 앱 비밀번호 값 · 이름은 로그 · 오류 context 에 남기지 않는다 (type 만)
*/
@@ -139,6 +142,33 @@ export class JmapAccountSecurityProvider implements AccountSecurityProvider {
return outcome === 'forbidden' ? 'code-rejected' : 'unknown';
}
async replaceTwoFactor(
user: string,
currentPassword: string,
otpCode: string,
otpauthUrl: string,
): Promise<TwoFactorReplaceResult> {
// 켠 채 지금 코드와 새 주소를 한 요청으로 — 엔진이 지금 코드를 확인하고 새 비밀로 바꾼다(옛 비밀은 바로 죽는다 — 2026-11-10 확인)
const reply = await this.jmap.callAccountChange(user, (accountId) => [
[
PASSWORD_SET,
{
accountId,
update: {
[PASSWORD_OBJECT_ID]: {
currentSecret: currentPassword,
otpAuth: { otpCode, otpUrl: otpauthUrl },
},
},
},
's',
],
]);
const outcome = this.passwordUpdate(user, reply, 'two-factor-replace');
if (outcome === 'updated') return 'replaced';
return outcome === 'forbidden' ? 'code-rejected' : 'unknown';
}
async listAppPasswords(user: string): Promise<AppPasswordRecord[]> {
const responses = await this.jmap.callAccount(user, (accountId) => [
[
@@ -251,7 +281,7 @@ export class JmapAccountSecurityProvider implements AccountSecurityProvider {
): 'updated' | 'forbidden' | 'unknown' {
if (reply.kind === 'unknown') {
this.logger.warn(
`[MAIL_001]${traceTag()} 2단계 인증 바꾸기의 결과를 알 수 없습니다 — 엔진을 다시 읽어 맞춥니다: user=${maskEmail(user)}, call=${call}, 원인=${reply.detail}`,
`[MAIL_001]${traceTag()} 2단계 인증 바꾸기의 결과를 알 수 없습니다 — ${this.unknownFollowUp(call)}: user=${maskEmail(user)}, call=${call}, 원인=${reply.detail}`,
);
return 'unknown';
}
@@ -275,7 +305,7 @@ export class JmapAccountSecurityProvider implements AccountSecurityProvider {
if (failure?.type === 'forbidden') return 'forbidden';
if (!failure) {
this.logger.warn(
`[MAIL_001]${traceTag()} 2단계 인증 바꾸기 응답이 결과를 말하지 않습니다 — 엔진을 다시 읽어 맞춥니다: user=${maskEmail(user)}, call=${call}`,
`[MAIL_001]${traceTag()} 2단계 인증 바꾸기 응답이 결과를 말하지 않습니다 — ${this.unknownFollowUp(call)}: user=${maskEmail(user)}, call=${call}`,
);
return 'unknown';
}
@@ -285,6 +315,13 @@ export class JmapAccountSecurityProvider implements AccountSecurityProvider {
});
}
/** 결과를 모를 때 부르는 쪽이 하는 일 — 켜기 · 끄기는 다시 읽어 맞추고, 휴대폰 바꾸기는 가릴 수 없어 MAIL_001 로 답한다 */
private unknownFollowUp(call: string): string {
return call === 'two-factor-replace'
? '엔진이 otpUrl 을 가려 다시 읽어도 가를 수 없어 MAIL_001 로 답합니다'
: '엔진을 다시 읽어 맞춥니다';
}
/** 만들기 거절 → 코드 (상한 AUTH_009, 그 밖은 MAIL_001 — type 만 남긴다) */
private createRefusal(error: JmapError): BusinessException {
if (error.type === 'overQuota') {
@@ -674,6 +674,147 @@ describe('JmapContactsProvider (엔진 주소록)', () => {
expect(logged).not.toMatch(/박지훈|jihoon|Invalid email|ara\.kim@/);
});
it('가져오기(allowPartialCreate) — 받지 않은 만들기는 던지지 않고 refusedKeys 로, 받은 것만 createdIds · 카드 모양 (warn 로그는 개수 · 종류만)', async () => {
engine.setReply = (args) => {
const create = args.create as Record<string, Record<string, unknown>>;
expect(Object.keys(create)).toEqual(['import-0', 'import-1']);
return [
'ContactCard/set',
{
newState: 's11',
created: { 'import-0': { id: 'n7' } },
notCreated: {
'import-1': {
type: 'invalidProperties',
description: 'Invalid 박지훈 jihoon@x',
},
},
updated: { g: null },
},
'w',
];
};
const outcome = await provider.writeAddressBook(
ARA,
version('book', 's10'),
{
create: [
{
kind: 'contact',
key: 'import-0',
uid: 'urn:uuid:a',
fields: { ...newPerson, name: '받음' },
},
{
kind: 'contact',
key: 'import-1',
uid: 'urn:uuid:b',
fields: { ...newPerson, name: '박지훈' },
},
],
update: [
{
kind: 'group',
id: 'g',
memberUids: ['urn:uuid:a', 'urn:uuid:b'],
},
],
destroy: [],
},
{ allowPartialCreate: true },
);
if (outcome.kind !== 'written') throw new Error('written 이 아니다');
expect(outcome.createdIds).toEqual(new Map([['import-0', 'n7']]));
expect(outcome.refusedKeys).toEqual(new Set(['import-1']));
expect([...outcome.cards.keys()]).toEqual(['n7']);
expect(outcome.version).toBe(version('book', 's11'));
const logged = warnSpy.mock.calls
.map(([message]) => String(message))
.join('\n');
expect(logged).toContain('받지 않음 1/2');
expect(logged).toContain('jmapType=invalidProperties');
expect(logged).not.toMatch(/박지훈|jihoon|Invalid|ara\.kim@/);
});
it('가져오기라도 고치기 · 지우기의 거절은 지금처럼 던진다 · 모두 받으면 refusedKeys 는 비어 있다', async () => {
engine.setReply = () => [
'ContactCard/set',
{
newState: 's11',
created: { 'import-0': { id: 'n7' } },
notUpdated: { g: { type: 'invalidProperties' } },
},
'w',
];
const changes: AddressBookChanges = {
create: [
{
kind: 'contact',
key: 'import-0',
uid: 'urn:uuid:a',
fields: newPerson,
},
],
update: [{ kind: 'group', id: 'g', memberUids: ['urn:uuid:a'] }],
destroy: [],
};
const error = await failureOf(
provider.writeAddressBook(ARA, version('book', 's10'), changes, {
allowPartialCreate: true,
}),
);
expect(error.code).toBe('VAL_001');
const outcome = await provider.writeAddressBook(
ARA,
version('book', 's10'),
{ ...changes, update: [] },
{ allowPartialCreate: true },
);
if (outcome.kind !== 'written') throw new Error('written 이 아니다');
expect(outcome.refusedKeys).toEqual(new Set());
expect(outcome.createdIds).toEqual(new Map([['import-0', 'n1']]));
});
it('한꺼번에 지우기(withoutOriginals) — 지울 카드는 id 만 읽어 있는지 보고, 원본은 주지 않는다', async () => {
engine.add({
id: 'c1',
uid: 'urn:uuid:c1',
name: { full: '하나' },
photos: { p: { href: 'https://x.example/p.jpg' } },
} as JmapContactCard);
const outcome = await provider.writeAddressBook(
ARA,
version('book', 's10'),
{ ...noChanges, destroy: ['c1'] },
{ withoutOriginals: true },
);
const [read] = engine.requests;
expect(read?.calls).toEqual([
[
'ContactCard/get',
{ accountId: 'acc', ids: ['c1'], properties: ['id'] },
'd',
],
]);
if (outcome.kind !== 'written') throw new Error('written 이 아니다');
expect(outcome.removed.size).toBe(0);
expect(engine.cardOf('c1')).toBeUndefined();
// 그 판에 없는 카드면 conflict (보내지 않는다)
const missing = await provider.writeAddressBook(
ARA,
version('book', 's11'),
{ ...noChanges, destroy: ['c1'] },
{ withoutOriginals: true },
);
expect(missing).toEqual({ kind: 'conflict' });
});
it('되돌리기 — 원본에서 id 만 빼고 uid · 기본 주소록을 바꿔 만든다. 엔진이 받지 않으면 MAIL_001(입력 오류가 아니다)', async () => {
const original = {
source: JSON.stringify({
@@ -7,6 +7,7 @@ import type {
AddressBookContactCard,
AddressBookGroupCard,
AddressBookSnapshot,
AddressBookWriteOptions,
AddressBookWriteOutcome,
ContactsProvider,
RemovedCardOriginal,
@@ -49,6 +50,9 @@ const READ_PAGE_SIZE = 500;
*/
export const ADDRESS_BOOK_READ_MAX_CARDS = 5_000;
/** 원본이 필요 없는 지우기(한꺼번에 지우기 — 16단계)에서 지울 카드가 있는지만 볼 때 읽는 칸 (id 는 늘 온다) */
const EXISTENCE_PROPERTIES = ['id'];
/** 카드에서 읽는 칸 — 사진 등 큰 칸은 읽지 않는다 */
const CARD_PROPERTIES = [
'uid',
@@ -118,6 +122,8 @@ function pickAddressBook(books: readonly JmapAddressBook[]): string | null {
* 만들기 · 고치기 · 지우기를 담아 읽은 상태 그대로일 때만(ifInState) 한다. 어긋나면 stateMismatch → conflict, 동시 고치기 거절은 busy.
* 엔진은 만든 뒤 uid 를 바꾸지 못하게 하고(invalidProperties), 구성원이 없는 그룹에 `members/<uid>` 패치를 받지 않는다 — 그래서 uid 는 만들 때
* 넣고, 구성원은 통째로 바꾼다 (2026-10-29 확인)
* - 가져오기(16단계 — allowPartialCreate): 받지 않은 만들기는 던지지 않고 refusedKeys 로 알린다(받은 것은 들어갔다). 한 요청에 300명 90ms(2026-11-10 확인),
* 엔진의 한 요청 상한(maxObjectsInSet 500)은 서비스가 맞춘다. 한꺼번에 지우기(withoutOriginals)는 지울 카드를 id 만 읽는다
* - 이름 · 주소 · 메모 등 카드 내용은 로그에 남기지 않는다 (개수 · 엔진 오류 종류만)
*/
@Injectable()
@@ -211,6 +217,7 @@ export class JmapContactsProvider implements ContactsProvider {
user: string,
version: string,
changes: AddressBookChanges,
options: AddressBookWriteOptions = {},
): Promise<AddressBookWriteOutcome> {
const { addressBookId, state } = decodeVersion(version);
if (changes.create.length > 0 && addressBookId === null) {
@@ -230,6 +237,7 @@ export class JmapContactsProvider implements ContactsProvider {
change.kind === 'contact' ? [change.id] : [],
),
changes.destroy,
options.withoutOriginals === true,
);
if (current === null) return { kind: 'conflict' };
const removed = current.originals;
@@ -287,8 +295,14 @@ export class JmapContactsProvider implements ContactsProvider {
this.warnBusy(user, 'set-error');
return { kind: 'busy', removed };
}
// 가져오기(16단계) — 받지 않은 만들기는 던지지 않고 적는다 (고치기 · 지우기의 거절은 아래에서 지금처럼 던진다)
const refusedKeys = options.allowPartialCreate
? this.refusedCreates(user, set, changes)
: new Set<string>();
const refusal =
firstSetError(set.notCreated) ??
(options.allowPartialCreate
? undefined
: firstSetError(set.notCreated)) ??
firstSetError(set.notUpdated) ??
firstSetError(set.notDestroyed);
if (refusal) {
@@ -318,6 +332,7 @@ export class JmapContactsProvider implements ContactsProvider {
const createdIds = new Map<string, string>();
for (const card of changes.create) {
if (refusedKeys.has(card.key)) continue;
const id = set.created?.[card.key]?.id;
if (typeof id !== 'string' || id === '') {
throw new BusinessException('MAIL_001', {
@@ -337,11 +352,35 @@ export class JmapContactsProvider implements ContactsProvider {
createdIds,
cards,
removed,
...(options.allowPartialCreate ? { refusedKeys } : {}),
};
}
/**
* 엔진이 받지 않은 만들기의 key (가져오기 — 16단계). 우리가 보낸 key 만 센다. 있으면 warn 로그 — 개수 · 엔진 오류 종류만
* (카드 내용 · 엔진 설명 문장은 남기지 않는다)
*/
private refusedCreates(
user: string,
set: JmapSetResult,
changes: AddressBookChanges,
): Set<string> {
const sent = new Set(changes.create.map((card) => card.key));
const refused = new Set(
Object.keys(set.notCreated ?? {}).filter((key) => sent.has(key)),
);
const first = firstSetError(set.notCreated);
if (refused.size > 0) {
this.logger.warn(
`[${mapJmapErrorType(first?.type)}]${traceTag()} 엔진이 가져오기의 일부 연락처를 받지 않았습니다 — 받은 것만 넣었습니다: user=${maskEmail(user)}, jmapType=${first?.type ?? 'unknown'}, 받지 않음 ${refused.size}/${changes.create.length}`,
);
}
return refused;
}
/**
* 고칠 연락처(우리가 다루는 칸)와 지울 카드(카드 전체 — 되돌리기 원본)를 그 판(state)에서 읽는다 — 엔진 요청 하나.
* withoutOriginals 면 지울 카드는 있는지만 본다(id 만 — 사진이 든 카드 500장을 통째로 읽지 않게, 원본은 주지 않는다).
* 판이 달라졌거나 카드가 없으면 null (그사이 다른 곳에서 바뀌었다 — 서비스가 다시 읽고 다시 정한다)
*/
private async readCurrentCards(
@@ -349,6 +388,7 @@ export class JmapContactsProvider implements ContactsProvider {
state: string,
updateIds: readonly string[],
destroyIds: readonly string[],
withoutOriginals: boolean,
): Promise<{
cards: ReadonlyMap<string, Record<string, unknown>>;
originals: ReadonlyMap<string, RemovedCardOriginal>;
@@ -370,8 +410,12 @@ export class JmapContactsProvider implements ContactsProvider {
? [
[
'ContactCard/get',
// 되돌리기 원본 — 다른 프로그램의 칸까지 모두
{ accountId, ids: [...destroyIds], properties: null },
// 되돌리기 원본 — 다른 프로그램의 칸까지 모두 (원본이 필요 없으면 있는지만)
{
accountId,
ids: [...destroyIds],
properties: withoutOriginals ? EXISTENCE_PROPERTIES : null,
},
'd',
] as JmapInvocation,
]
@@ -396,9 +440,11 @@ export class JmapContactsProvider implements ContactsProvider {
if (updating === null || destroying === null) return null;
return {
cards: updating,
originals: new Map(
[...destroying].map(([id, card]) => [id, cardOriginal(card)]),
),
originals: withoutOriginals
? new Map()
: new Map(
[...destroying].map(([id, card]) => [id, cardOriginal(card)]),
),
};
}
@@ -7,7 +7,10 @@ import type {
MailVacation,
} from '@/modules/mail/mail.types';
import {
FILTER_SOURCE_FORMAT_WITH_RICH_VACATION,
FILTER_SOURCE_MARKER,
NEVER_CONFIGURED_VACATION,
SAME_AS_INTERNAL_REPLY,
filterRulesVersion,
readFilterSource,
type FilterScriptSource,
@@ -57,6 +60,8 @@ const vacationOn: MailVacation = {
toDate: '2026-11-07T15:00:00.000Z',
subject: '부재중입니다',
textBody: '11월 7일까지 휴가입니다.',
htmlBody: null,
external: SAME_AS_INTERNAL_REPLY,
};
interface FakeScript {
@@ -139,8 +144,12 @@ class FakeSieveEngine {
),
};
/** 회사 메일 도메인 설정 (MAIL_SETTINGS 의 domain) — 부재중의 회사 밖 응답을 가를 때 */
readonly settings: { domain: string | null } = { domain: 'arasystem.test' };
readonly store = new JmapFilterScriptStore(
this.client as unknown as JmapClient,
this.settings,
);
/** 스크립트를 하나 둔다 (다른 메일 프로그램 · 앞선 저장이 만든 것) */
@@ -341,12 +350,15 @@ describe('JmapFilterScriptStore — 필터 · 부재중 자동 응답의 저장
version: filterRulesVersion([]),
isActive: true,
});
// 엔진의 서식 본문은 가져오지 않는다 — 글만 · 사내와 같은 응답(16단계 칸의 처음 값)
await expect(engine.store.getVacation(ARA)).resolves.toEqual({
isEnabled: true,
fromDate: '2026-11-02T15:00:00.000Z',
toDate: null,
subject: '4단계 부재중',
textBody: '휴가입니다.',
htmlBody: null,
external: SAME_AS_INTERNAL_REPLY,
isActive: true,
});
expect(engine.recorded[0]).toEqual({
@@ -392,6 +404,8 @@ describe('JmapFilterScriptStore — 필터 · 부재중 자동 응답의 저장
toDate: '2026-11-07T15:00:00.000Z',
subject: '',
textBody: '',
htmlBody: null,
external: SAME_AS_INTERNAL_REPLY,
};
await expect(engine.store.getVacation(ARA)).resolves.toEqual({
@@ -532,7 +546,7 @@ describe('JmapFilterScriptStore — 필터 · 부재중 자동 응답의 저장
context: {
reason: 'filter-source-newer',
format: 99,
supportedFormat: 2,
supportedFormat: FILTER_SOURCE_FORMAT_WITH_RICH_VACATION,
},
});
}
@@ -1060,6 +1074,55 @@ describe('JmapFilterScriptStore — 필터 · 부재중 자동 응답의 저장
expect(engine.ours()?.isActive).toBe(true);
});
it('회사 밖 따로 쓰기 · 서식 본문(16단계) — 회사 메일 도메인으로 봉투 보낸 사람을 가르는 글을 올리고, 원본은 판 3', async () => {
const engine = new FakeSieveEngine();
engine.settings.domain = 'arasystem.it.kr';
const rich: MailVacation = {
...vacationOn,
htmlBody: '<p>11월 7일까지 <strong>휴가</strong>입니다.</p>',
external: {
mode: 'separate',
subject: 'Out of office',
textBody: 'I am away until Nov 7.',
htmlBody: null,
},
};
await expect(engine.store.setVacation(ARA, rich)).resolves.toEqual({
...rich,
isActive: true,
});
const text = engine.blobs.get(engine.ours()!.blobId) ?? '';
expect(text).toContain(
'if envelope :domain :is "from" "arasystem.it.kr" {',
);
expect(text).toContain('multipart/alternative');
expect(text).toContain(':handle "ara-external-');
expect(text.split('\r\n')[1]).toContain(
`${FILTER_SOURCE_MARKER}{"v":${FILTER_SOURCE_FORMAT_WITH_RICH_VACATION},`,
);
expect(engine.ourSource()?.vacation).toEqual(rich);
await expect(engine.store.getVacation(ARA)).resolves.toEqual({
...rich,
isActive: true,
});
});
it('회사 메일 도메인 설정이 없으면 직원 주소의 도메인으로 가른다 (로그인한 직원은 늘 그 도메인이다)', async () => {
const engine = new FakeSieveEngine();
engine.settings.domain = null;
await engine.store.setVacation(ARA, {
...vacationOn,
external: { ...SAME_AS_INTERNAL_REPLY, mode: 'none' },
});
const text = engine.blobs.get(engine.ours()!.blobId) ?? '';
expect(text).toContain(
'if envelope :domain :is "from" "arasystem.test" {',
);
expect(text).not.toContain('else');
});
it('우리 스크립트가 없으면 만든다 — 규칙 없음 + 부재중, 켠다', async () => {
const engine = new FakeSieveEngine();
engine.addScript('vacation', 'require ["vacation"];\r\n', true);
@@ -1,7 +1,8 @@
import { Injectable, Logger } from '@nestjs/common';
import { Inject, Injectable, Logger } from '@nestjs/common';
import { BusinessException } from '@/common/errors/business.exception';
import { MAX_MAIL_BLOCKS } from '@/modules/mail/mail.constants';
import { MAIL_SETTINGS, type MailSettings } from '@/modules/mail/mail.settings';
import type {
BlockedSender,
MailBlockAdded,
@@ -121,7 +122,10 @@ interface ScriptSnapshot extends ScriptView {
readonly newerFormat: number | null;
}
/** 로그 · 오류 context 에 남기는 스크립트 요약 — 크기와 규칙 · 차단 수만 (규칙의 글 · 차단한 주소는 남기지 않는다) */
/**
* 로그 · 오류 context 에 남기는 스크립트 요약 — 크기와 규칙 · 차단 수, 부재중의 켬 · 서식 있음 · 회사 밖 고르기만
* (규칙의 글 · 차단한 주소 · 부재중 글은 남기지 않는다)
*/
function scriptSummary(
script: BuiltFilterScript,
source: FilterScriptSource,
@@ -131,10 +135,17 @@ function scriptSummary(
rules: source.rules.length,
enabledRules: source.rules.filter((rule) => rule.isEnabled).length,
vacation: source.vacation.isEnabled,
vacationHtml: source.vacation.htmlBody !== null,
vacationExternal: source.vacation.external.mode,
blocked: source.blocked.length,
};
}
/** 사용자 주소의 도메인 (소문자) — 회사 메일 도메인 설정이 없을 때만 쓴다(로그인한 직원은 늘 그 도메인이다) */
function domainOf(user: string): string {
return user.slice(user.lastIndexOf('@') + 1).toLowerCase();
}
function toFilterSet(view: ScriptView): MailFilterSet {
return {
rules: canonicalRules(view.source.rules),
@@ -175,7 +186,12 @@ function toBlockList(view: ScriptView): MailBlockList {
export class JmapFilterScriptStore {
private readonly logger = new Logger(JmapFilterScriptStore.name);
constructor(private readonly jmap: JmapClient) {}
constructor(
private readonly jmap: JmapClient,
/** 회사 메일 도메인 — 부재중의 회사 밖 응답(16단계)을 봉투 보낸 사람의 도메인으로 가를 때 */
@Inject(MAIL_SETTINGS)
private readonly settings: Pick<MailSettings, 'domain'>,
) {}
/** 지금 규칙 · 지문 · 동작하는지 */
async getFilters(user: string): Promise<MailFilterSet> {
@@ -407,7 +423,10 @@ export class JmapFilterScriptStore {
if (next.blocked.length > 0 && junkMailbox === null) {
junkMailbox = await this.junkMailbox(user);
}
const script = buildFilterScript(next, { junkMailbox });
const script = buildFilterScript(next, {
junkMailbox,
internalDomain: this.settings.domain ?? domainOf(user),
});
if (
script.bytes > FILTER_SCRIPT_MAX_BYTES ||
script.longestStringBytes > SIEVE_STRING_MAX_BYTES
@@ -429,7 +448,7 @@ export class JmapFilterScriptStore {
const saved = await this.save(user, current, blobId, script, next);
if (saved !== null) {
this.logger.debug(
`${traceTag()} 엔진 규칙(Sieve)을 저장했습니다: user=${maskEmail(user)}, ${script.bytes}바이트, 규칙 ${next.rules.length}개, 부재중 ${next.vacation.isEnabled ? '켬' : '끔'}, 차단 ${next.blocked.length}개, ${attempt}번째`,
`${traceTag()} 엔진 규칙(Sieve)을 저장했습니다: user=${maskEmail(user)}, ${script.bytes}바이트, 규칙 ${next.rules.length}개, 부재중 ${next.vacation.isEnabled ? '켬' : '끔'}(서식 ${next.vacation.htmlBody !== null ? '있음' : '없음'}, 회사 밖 ${next.vacation.external.mode}), 차단 ${next.blocked.length}개, ${attempt}번째`,
);
return { source: next, isActive: saved.isActive };
}
@@ -18,6 +18,7 @@ import {
import type { MailSettings } from '@/modules/mail/mail.settings';
import type {
DraftContent,
MailVacation,
MailWatch,
MailWatchListener,
OutgoingMessage,
@@ -198,7 +199,10 @@ function fakeClient(
client as unknown as JmapClient,
cache,
overrides.settings ?? settings,
new JmapFilterScriptStore(client as unknown as JmapClient),
new JmapFilterScriptStore(
client as unknown as JmapClient,
overrides.settings ?? settings,
),
new JmapFilterRunner(client as unknown as JmapClient),
new JmapConversations(client as unknown as JmapClient),
),
@@ -8024,12 +8028,15 @@ describe('JmapMailProvider', () => {
textBody: '11월 7일까지 휴가입니다.',
htmlBody: null,
};
const vacation = {
// 엔진의 서식 본문은 가져오지 않는다 — 글만 · 사내와 같은 응답(16단계 칸의 처음 값)
const vacation: MailVacation = {
isEnabled: true,
fromDate: '2026-11-02T15:00:00.000Z',
toDate: '2026-11-07T15:00:00.000Z',
subject: '부재중입니다',
textBody: '11월 7일까지 휴가입니다.',
htmlBody: null,
external: { mode: 'same', subject: null, textBody: null, htmlBody: null },
};
beforeEach(() => {
jest.spyOn(Logger.prototype, 'debug').mockImplementation(() => undefined);
@@ -133,6 +133,8 @@ describe('JmapOpsEngine — 관리 API · 대리 접근 (13단계)', () => {
toDate: null,
subject: '',
textBody: '',
htmlBody: null,
external: { mode: 'same', subject: null, textBody: null, htmlBody: null },
isActive: true,
};
filterScripts = {
@@ -899,12 +901,15 @@ describe('JmapOpsEngine — 관리 API · 대리 접근 (13단계)', () => {
match: 'always',
actions: { forwardTo: `doyun.lee@${DOMAIN}` },
});
// 운영자의 안내 글은 글만 · 회사 안팎 모두에게 (16단계 칸은 처음 값)
expect(filterScripts.setVacation).toHaveBeenCalledWith(ARA, {
isEnabled: true,
fromDate: null,
toDate: null,
subject: '부재 안내',
textBody: '퇴사했습니다.',
htmlBody: null,
external: { mode: 'same', subject: null, textBody: null, htmlBody: null },
});
filterScripts.getFilters.mockResolvedValue({
@@ -912,12 +917,20 @@ describe('JmapOpsEngine — 관리 API · 대리 접근 (13단계)', () => {
version: 'v2',
isActive: true,
});
const external = {
mode: 'separate' as const,
subject: 'Left the company',
textBody: 'Please contact doyun.',
htmlBody: null,
};
filterScripts.getVacation.mockResolvedValue({
isEnabled: true,
fromDate: null,
toDate: null,
subject: '부재 안내',
textBody: '퇴사했습니다.',
htmlBody: '<p>퇴사했습니다.</p>',
external,
isActive: true,
});
await expect(engine.getLockRules(ARA)).resolves.toEqual({
@@ -929,10 +942,16 @@ describe('JmapOpsEngine — 관리 API · 대리 접근 (13단계)', () => {
rules: [saved.rules[1]],
version: 'v2',
});
expect(filterScripts.setVacation).toHaveBeenLastCalledWith(
ARA,
expect.objectContaining({ isEnabled: false, subject: '부재 안내' }),
);
// 끄기만 한다 — 글 · 서식 · 회사 밖 응답은 그대로 (isActive 는 넘기지 않는다)
expect(filterScripts.setVacation).toHaveBeenLastCalledWith(ARA, {
isEnabled: false,
fromDate: null,
toDate: null,
subject: '부재 안내',
textBody: '퇴사했습니다.',
htmlBody: '<p>퇴사했습니다.</p>',
external,
});
});
it('필터가 이미 50개면 넘기기 규칙을 더하지 않는다 (too-many-rules)', async () => {
@@ -529,12 +529,20 @@ export class JmapOpsEngine implements OpsEngine {
});
}
if (rules.autoReply !== null) {
// 운영자의 안내 글 — 글만, 회사 안팎 모두에게 (16단계의 서식 본문 · 회사 밖 응답은 쓰지 않는다)
await this.filterScripts.setVacation(email, {
isEnabled: true,
fromDate: null,
toDate: null,
subject: rules.autoReply.subject,
textBody: rules.autoReply.body,
htmlBody: null,
external: {
mode: 'same',
subject: null,
textBody: null,
htmlBody: null,
},
});
}
}
@@ -549,12 +557,15 @@ export class JmapOpsEngine implements OpsEngine {
}
const vacation = await this.filterScripts.getVacation(email);
if (vacation.isEnabled) {
// 끄기만 한다 — 글 · 서식 · 회사 밖 응답은 그대로 둔다
await this.filterScripts.setVacation(email, {
isEnabled: false,
fromDate: vacation.fromDate,
toDate: vacation.toDate,
subject: vacation.subject,
textBody: vacation.textBody,
htmlBody: vacation.htmlBody,
external: vacation.external,
});
}
}
+4 -1
View File
@@ -355,7 +355,8 @@ export function toUsage(quota: JmapQuota): MailUsage {
* 엔진의 VacationResponse → 우리 모양 (계약 2절 "부재중 자동 응답") — 필터 · 부재중을 두는 우리 스크립트가 아직 없을 때 가져온다
* (4단계에 저장한 값 이어받기 — jmap-filter-script.ts).
* 엔진에 없는 값(null)의 제목·내용은 빈 문자열, 날짜는 ISO 8601 UTC 로 — 한 번도 설정하지 않은 계정은
* `{ isEnabled: false, fromDate: null, toDate: null, subject: '', textBody: '' }` 가 된다. 서식 본문(htmlBody)은 쓰지 않는다.
* `{ isEnabled: false, fromDate: null, toDate: null, subject: '', textBody: '', htmlBody: null, external: 사내와 같은 응답 }` 가 된다.
* 엔진의 서식 본문(htmlBody)은 가져오지 않는다 — 글만 · 사내와 같은 응답(16단계 칸의 처음 값 — filter-source.ts 의 SAME_AS_INTERNAL_REPLY 와 같다)
*/
export function toVacation(
response: JmapVacationResponse | undefined,
@@ -366,6 +367,8 @@ export function toVacation(
toDate: toUtcIso(response?.toDate),
subject: response?.subject ?? '',
textBody: response?.textBody ?? '',
htmlBody: null,
external: { mode: 'same', subject: null, textBody: null, htmlBody: null },
};
}
@@ -1,5 +1,6 @@
import { startCpuTimer } from '@/../test/cpu-time';
import {
alternativeMimeEntity,
base64TextBody,
decodeHeaderText,
encodeHeaderWords,
@@ -97,6 +98,68 @@ describe('base64TextBody · plainTextMimeEntity — 부재중 내용', () => {
});
});
describe('alternativeMimeEntity — 부재중 서식 본문 (16단계, 글 + 서식 두 벌)', () => {
/** 개체의 부분 — 경계로 나눠 머리 · base64 본문을 푼다 */
const partsOf = (entity: string) => {
const boundary = /boundary="([^"]+)"/.exec(entity)?.[1] ?? '';
return entity
.split(`--${boundary}`)
.slice(1, -1)
.map((part) => {
const [head, body] = part.replace(/^\r\n/, '').split('\r\n\r\n');
return {
head,
text: Buffer.from(body.replace(/\r\n/g, ''), 'base64').toString(
'utf8',
),
};
});
};
it('multipart/alternative — 글(text/plain) 다음 서식(text/html), 각 base64 · utf-8, 닫는 경계 뒤 줄바꿈 (엔진이 그대로 보낸 모양 — engine-facts E2)', () => {
const entity = alternativeMimeEntity(
'11월 7일까지 휴가입니다.\n급한 일은 이도윤 님께.',
'<p style="margin:0">11월 7일까지 <strong>휴가</strong>입니다.</p>',
);
const lines = entity.split('\r\n');
expect(lines.slice(0, 3)).toEqual([
'Content-Type: multipart/alternative; boundary="ara-vacation-alternative"',
'',
'--ara-vacation-alternative',
]);
expect(entity.endsWith('\r\n--ara-vacation-alternative--\r\n')).toBe(true);
expect(partsOf(entity)).toEqual([
{
head: 'Content-Type: text/plain; charset="utf-8"\r\nContent-Transfer-Encoding: base64',
text: '11월 7일까지 휴가입니다.\r\n급한 일은 이도윤 님께.',
},
{
head: 'Content-Type: text/html; charset="utf-8"\r\nContent-Transfer-Encoding: base64',
text: '<p style="margin:0">11월 7일까지 <strong>휴가</strong>입니다.</p>',
},
]);
});
it('ASCII 만 나오고 경계는 본문 줄과 겹치지 않는다 — 글 · 서식의 ${ · 역슬래시 · 따옴표 · 경계와 같은 글도 base64 안에만 있다', () => {
const entity = alternativeMimeEntity(
'${x} \\ "따옴표" --ara-vacation-alternative',
'<p>${x} --ara-vacation-alternative</p>',
);
expect(entity).toMatch(/^[\x20-\x7e\r\n]*$/);
expect(entity).not.toContain('${');
expect(entity).not.toContain('\\');
// 경계 줄은 셋뿐이다(여는 둘 · 닫는 하나)
expect(
entity.split('\r\n').filter((line) => line.startsWith('--')),
).toEqual([
'--ara-vacation-alternative',
'--ara-vacation-alternative',
'--ara-vacation-alternative--',
]);
});
});
/**
* 원래 머리글 → 엔진의 Sieve 가 견주는 글 (이미 받은 메일에 필터 적용 — 계약 2절 "이미 받은 메일에 적용"의 판정).
* 원래 글의 모양은 엔진이 주는 그대로다 — 앞에 공백 한 칸, 접힌 줄은 CRLF + 공백, encoded-word 그대로 (2026-10-29 실배달 메일에서 받은 값)
+31 -1
View File
@@ -1,6 +1,6 @@
/**
* 메일 머리글 · 본문의 글 모양 (순수 함수).
* - 쓰기: 부재중 자동 응답을 엔진 규칙(Sieve)에 넣을 때 — 제목은 RFC 2047 encoded-word, 내용은 base64 MIME 본문.
* - 쓰기: 부재중 자동 응답을 엔진 규칙(Sieve)에 넣을 때 — 제목은 RFC 2047 encoded-word, 내용은 base64 MIME 본문(서식이 있으면 글 + 서식 두 벌 — 16단계).
* 둘 다 ASCII 로만 나오므로 Sieve 문자열 안에 따옴표 · 역슬래시 · `${` 가 생기지 않는다
* (엔진은 require 와 상관없이 문자열의 `${…}` 를 늘 변수로 펼친다 — 계약 2절 "만드는 Sieve", 2026-10-28 확인).
* - 읽기: 이미 받은 메일에 필터를 적용할 때 원래 머리글을 엔진의 Sieve 가 보는 글로 푼다 (decodeHeaderText — filter-run-texts.ts)
@@ -84,6 +84,36 @@ export function plainTextMimeEntity(text: string): string {
].join(CRLF);
}
/**
* 글 + 서식 두 벌의 경계 (RFC 2046 §5.1.1) — base64 글자에 없는 `-` 가 들어 있어 본문 줄과 겹치지 않는다.
* 늘 같은 값이라 같은 부재중이면 같은 스크립트 글이 된다 (다시 올리지 않고 쓴다 — jmap-filter-script.ts)
*/
const ALTERNATIVE_BOUNDARY = 'ara-vacation-alternative';
/**
* 글 + 서식 두 벌의 MIME 개체 (16단계 — 부재중 서식 본문) — `multipart/alternative` 에 글(text/plain) · 서식(text/html) 차례로, 각 base64.
* 서식을 읽지 못하는 메일 프로그램은 글만 보인다. 엔진이 이 개체를 그대로 보낸다(2026-11-10 개발 엔진 — engine-facts E2).
* ASCII 로만 나오므로 Sieve 문자열에 넣어도 `${` · 따옴표 · 역슬래시가 생기지 않는다(경계의 따옴표는 Sieve 쪽이 이스케이프한다)
*/
export function alternativeMimeEntity(text: string, html: string): string {
return [
`Content-Type: multipart/alternative; boundary="${ALTERNATIVE_BOUNDARY}"`,
'',
`--${ALTERNATIVE_BOUNDARY}`,
'Content-Type: text/plain; charset="utf-8"',
'Content-Transfer-Encoding: base64',
'',
base64TextBody(text),
`--${ALTERNATIVE_BOUNDARY}`,
'Content-Type: text/html; charset="utf-8"',
'Content-Transfer-Encoding: base64',
'',
base64TextBody(html),
`--${ALTERNATIVE_BOUNDARY}--`,
'',
].join(CRLF);
}
/**
* encoded-word 하나 — `=?문자셋?B|Q?글?=` (RFC 2047 §2). 문자셋 · 글 칸에는 `?` · 공백이 없어 칸마다 다음 `?` 에서 멈춘다
* (되돌아가 볼 것이 없다 — 길이에 비례). 문자셋 뒤의 `*언어`(RFC 2231 §5)는 풀 때 뗀다
@@ -4,13 +4,16 @@ import type {
MailVacation,
} from '@/modules/mail/mail.types';
import {
FILTER_SOURCE_FORMAT_WITH_RICH_VACATION,
FILTER_SOURCE_MARKER,
NEVER_CONFIGURED_VACATION,
SAME_AS_INTERNAL_REPLY,
filterSourceLine,
readFilterSource,
type FilterScriptSource,
} from './filter-source';
import { buildFilterScript } from './sieve-script';
import { alternativeMimeEntity, plainTextMimeEntity } from './mime-text';
import { SIEVE_STRING_MAX_BYTES, buildFilterScript } from './sieve-script';
const b64 = (text: string) => Buffer.from(text, 'utf8').toString('base64');
@@ -38,6 +41,39 @@ const vacationOn: MailVacation = {
toDate: '2026-11-07T15:00:00.000Z',
subject: '부재중입니다',
textBody: '휴가입니다.\n급한 일은 이도윤 님께.',
htmlBody: null,
external: SAME_AS_INTERNAL_REPLY,
};
/** 개발 엔진과 같은 회사 메일 도메인 */
const DOMAIN = 'arasystem.test';
/** Sieve 따옴표 문자열을 푼다 — `\"` · `\\` 만 */
const unquote = (quoted: string) => quoted.replace(/\\(["\\])/g, '$1');
/** 스크립트의 `set "이름" "값";` 들 — 이름 → 값 */
const variablesOf = (text: string): Map<string, string> =>
new Map(
[...text.matchAll(/set "(part\d+)" "((?:[^"\\]|\\.)*)";/g)].map(
([, name, value]) => [name, unquote(value)],
),
);
/** 스크립트의 vacation 명령들 — 태그 · MIME 개체(변수로 이었으면 이은 값) */
const vacationCommandsOf = (text: string) => {
const variables = variablesOf(text);
return [
...text.matchAll(
/vacation :mime (?::handle "([^"]*)" )?(?::subject "([^"]*)" )?"((?:[^"\\]|\\.)*)";/g,
),
].map(([, handle, subject, argument]) => ({
handle: handle ?? null,
subject: subject ?? null,
entity: unquote(argument).replace(
/\$\{(part\d+)\}/g,
(_whole, name: string) => variables.get(name) ?? '',
),
}));
};
/** 대표 원본 — 하나라도(옮기기 · 표시 둘) · 꺼 둔 규칙 · 모든 메일(표시 · 전달) · 모두(받는 사람 · 본문 포함하지 않음, 없는 메일함) */
@@ -296,6 +332,236 @@ describe('buildFilterScript — 규칙 · 부재중 → 엔진 규칙(Sieve)', (
expect(sourceOf(text)?.vacation).toEqual(off);
});
it('서식 본문이 있으면 글 + 서식 두 벌(multipart/alternative) — 사내와 같은 응답이면 봉투 검사 · :handle 없이 하나 (16단계)', () => {
const html =
'<p style="margin:0">휴가입니다. <strong>급한 일</strong>은 이도윤 님께.</p>';
const { text } = buildFilterScript({
rules: [],
vacation: {
...vacationOn,
fromDate: null,
toDate: null,
htmlBody: html,
},
blocked: [],
});
expect(linesOf(text)[2]).toBe('require ["vacation"];');
expect(text).not.toContain('envelope');
expect(text).not.toContain(':handle');
expect(vacationCommandsOf(text)).toEqual([
{
handle: null,
subject: `=?UTF-8?B?${b64('부재중입니다')}?=`,
entity: alternativeMimeEntity(vacationOn.textBody, html),
},
]);
// 원본은 판 3 — 서식 본문을 쓴 직원만
expect(sourceOf(text)?.vacation.htmlBody).toBe(html);
expect(linesOf(text)[1]).toContain(
`"v":${FILTER_SOURCE_FORMAT_WITH_RICH_VACATION}`,
);
});
it('회사 밖 따로 쓰기 — 봉투 보낸 사람의 도메인으로 가르고(envelope :domain :is), 회사 밖 응답은 :handle 을 따로 둔다', () => {
const source: FilterScriptSource = {
rules: [],
vacation: {
...vacationOn,
external: {
mode: 'separate',
subject: 'Out of office',
textBody: 'I am away until Nov 7.',
htmlBody: '<p>I am away until <strong>Nov 7</strong>.</p>',
},
},
blocked: [],
};
const { text } = buildFilterScript(source, { internalDomain: DOMAIN });
const lines = linesOf(text);
expect(lines[2]).toBe(
'require ["date", "envelope", "relational", "vacation"];',
);
expect(lines.slice(3, 6)).toEqual([
'if not header :matches "X-Spam-Score" "spam*" {',
' if currentdate :zone "+0000" :value "ge" "iso8601" "2026-11-02T15:00:00Z" {',
' if currentdate :zone "+0000" :value "le" "iso8601" "2026-11-07T15:00:00Z" {',
]);
expect(lines[6]).toBe(
` if envelope :domain :is "from" "${DOMAIN}" {`,
);
expect(lines[7]).toMatch(/^ {8}vacation :mime :subject /);
expect(lines).toContain(' } else {');
expect(lines.slice(-5)).toEqual([' }', ' }', ' }', '}', '']);
const [internal, external] = vacationCommandsOf(text);
expect(internal).toEqual({
handle: null,
subject: `=?UTF-8?B?${b64('부재중입니다')}?=`,
entity: plainTextMimeEntity(vacationOn.textBody),
});
expect(external.subject).toBe(`=?UTF-8?B?${b64('Out of office')}?=`);
expect(external.entity).toBe(
alternativeMimeEntity(
'I am away until Nov 7.',
'<p>I am away until <strong>Nov 7</strong>.</p>',
),
);
expect(external.handle).toMatch(/^ara-external-[A-Za-z0-9_-]{16}$/);
expect(sourceOf(text)?.vacation).toEqual(source.vacation);
});
it('회사 밖 :handle 은 응답 내용의 지문 — 같은 글이면 같고, 글이 바뀌면 바뀐다', () => {
const handleOf = (subject: string) =>
vacationCommandsOf(
buildFilterScript(
{
rules: [],
vacation: {
...vacationOn,
external: {
mode: 'separate',
subject,
textBody: 'away',
htmlBody: null,
},
},
blocked: [],
},
{ internalDomain: DOMAIN },
).text,
)[1].handle;
expect(handleOf('Away')).toBe(handleOf('Away'));
expect(handleOf('Away')).not.toBe(handleOf('Away!'));
});
it('회사 밖 보내지 않기 — 사내 도메인일 때만 답한다(else 없음), 회사 밖에 써 둔 글은 원본에만', () => {
const vacation: MailVacation = {
...vacationOn,
fromDate: null,
toDate: null,
external: {
mode: 'none',
subject: '써 둔 제목',
textBody: '써 둔 글',
htmlBody: null,
},
};
const { text } = buildFilterScript(
{ rules: [], vacation, blocked: [] },
{ internalDomain: DOMAIN },
);
expect(linesOf(text).slice(2)).toEqual([
'require ["envelope", "vacation"];',
'if not header :matches "X-Spam-Score" "spam*" {',
` if envelope :domain :is "from" "${DOMAIN}" {`,
expect.stringMatching(/^ {4}vacation :mime :subject /) as string,
'Content-Transfer-Encoding: base64',
'',
b64('휴가입니다.\r\n급한 일은 이도윤 님께.'),
'";',
' }',
'}',
'',
]);
expect(text).not.toContain('else');
expect(vacationCommandsOf(text)).toHaveLength(1);
expect(sourceOf(text)?.vacation.external).toEqual(vacation.external);
});
it('회사 밖 응답을 가르는데 회사 메일 도메인을 받지 못하면 만들지 않는다 — 꺼져 있거나 사내와 같으면 필요 없다', () => {
const external = {
mode: 'separate' as const,
subject: 'x',
textBody: 'y',
htmlBody: null,
};
const source = (isEnabled: boolean): FilterScriptSource => ({
rules: [],
vacation: { ...vacationOn, isEnabled, external },
blocked: [],
});
expect(() => buildFilterScript(source(true))).toThrow();
expect(() =>
buildFilterScript(source(true), { internalDomain: null }),
).toThrow();
expect(() => buildFilterScript(source(false))).not.toThrow();
expect(() =>
buildFilterScript({ rules: [], vacation: vacationOn, blocked: [] }),
).not.toThrow();
});
it('MIME 개체가 문자열 하나 한도(4,096바이트)를 넘으면 variables 조각(3,000바이트 이하 · 줄 끝에서 끊음)으로 나눠 잇는다 — 이으면 원래 개체 그대로', () => {
const textBody =
'휴가 중이라 메일을 늦게 확인합니다. 급한 일은 담당자에게 연락해 주세요. '
.repeat(14)
.slice(0, 600);
const html = `<div style="font-family: 'Malgun Gothic', sans-serif">${textBody
.split('. ')
.map(
(sentence) =>
`<p style="margin: 0"><strong>${sentence}.</strong> <a href="https://example.com/">담당자</a></p>`,
)
.join('')}</div>`;
const built = buildFilterScript(
{
rules: [],
vacation: {
...vacationOn,
textBody,
htmlBody: html,
external: {
mode: 'separate',
subject: 'Away',
textBody,
htmlBody: html,
},
},
blocked: [],
},
{ internalDomain: DOMAIN },
);
const variables = variablesOf(built.text);
expect(linesOf(built.text)[2]).toBe(
'require ["date", "envelope", "relational", "vacation", "variables"];',
);
// 사내 · 회사 밖 모두 나뉘었다 — 변수 이름은 스크립트 안에서 겹치지 않는다
expect(variables.size).toBeGreaterThanOrEqual(4);
expect([...variables.keys()]).toEqual(
[...variables.keys()].map((_name, index) => `part${index}`),
);
for (const piece of variables.values()) {
expect(Buffer.byteLength(piece, 'utf8')).toBeLessThanOrEqual(3_000);
// 줄 끝(CRLF) 뒤에서만 끊는다 — 따옴표 문자열에 홀로 있는 CR · LF 가 없다
expect(piece.endsWith('\r\n')).toBe(true);
expect(piece.replace(/\r\n/g, '')).not.toMatch(/[\r\n]/);
}
const [internal, external] = vacationCommandsOf(built.text);
expect(internal.entity).toBe(alternativeMimeEntity(textBody, html));
expect(external.entity).toBe(alternativeMimeEntity(textBody, html));
expect(built.longestStringBytes).toBeLessThanOrEqual(
SIEVE_STRING_MAX_BYTES,
);
// 사용자 글의 ${ 는 Sieve 에 없다 — 우리 변수만 펼친다
expect(
[...built.text.matchAll(/\$\{([^}]*)\}/g)].map(([, name]) => name),
).toEqual([...variables.keys()].map((name) => name));
});
it('문자열 하나 한도 안이면 지금처럼 한 문자열 — 글만인 응답(한글 600자)은 나누지 않는다', () => {
const built = buildFilterScript({
rules: [],
vacation: { ...vacationOn, textBody: '가'.repeat(600) },
blocked: [],
});
expect(built.text).not.toContain('variables');
expect(built.text).not.toContain('set "');
});
it('제목 · 내용의 ${ · 따옴표 · 역슬래시는 Sieve 글에 그대로 나오지 않는다 — encoded-word · base64 (원본 주석에만)', () => {
const { text } = buildFilterScript({
rules: [],
+202 -12
View File
@@ -1,3 +1,5 @@
import { createHash } from 'crypto';
import type {
BlockedSender,
MailFilterCondition,
@@ -7,7 +9,11 @@ import type {
} from '@/modules/mail/mail.types';
import { filterSourceLine, type FilterScriptSource } from './filter-source';
import { toJmapUtcDate } from './jmap.mapper';
import { encodeHeaderWords, plainTextMimeEntity } from './mime-text';
import {
alternativeMimeEntity,
encodeHeaderWords,
plainTextMimeEntity,
} from './mime-text';
/**
* 메일 필터 · 부재중 자동 응답 → 엔진 규칙(Sieve 스크립트, RFC 5228) — 순수 함수. 계약 2절 "만드는 Sieve" (Stalwart 0.16.27, 2026-10-28 확인한 모양):
@@ -22,10 +28,17 @@ import { encodeHeaderWords, plainTextMimeEntity } from './mime-text';
* }
* if not header :matches "X-Spam-Score" "spam*" { ← 엔진이 스팸으로 분류한 메일에는 아무것도 하지 않는다
* 부재중 — 켜져 있으면 (기간은 currentdate, UTC)
* 회사 밖 응답이 사내와 같으면 vacation 하나, 아니면(16단계) if envelope :domain :is "from" "<회사 도메인>" { 사내 } else { 회사 밖 } — 보내지 않기면 else 없음
* 규칙 — 켠 것만 위에서부터: if <조건> { addflag → redirect :copy → fileinto :mailboxid + stop }
* }
* ```
*
* - 부재중의 사내 · 회사 밖은 **봉투 보낸 사람(return-path)의 도메인**으로 가른다(16단계) — 답장도 봉투 주소로만 가므로(엔진 확인 — engine-facts E2)
* 머리글 From 을 사내로 꾸며도 사내용 글은 그 봉투 주소(회사 밖)로 가지 않는다. 도메인은 그 도메인만(`:is` — 하위 도메인은 회사 밖)
* - 부재중 응답의 본문은 MIME 개체 하나(서식이 있으면 multipart/alternative — 글 + 서식, 각 base64)다. 엔진은 Sieve 문자열 하나를 4,096바이트로 막으므로
* 그보다 길면 `variables` 로 3,000바이트 이하의 조각에 나눠 담고(`set "part0" "…";`) `vacation :mime "${part0}${part1}…"` 로 잇는다
* (엔진 확인 — 이은 답장의 글 · 서식이 원래와 바이트까지 같다). 조각은 줄 끝(CRLF) 뒤에서만 끊는다 — 따옴표 문자열에 홀로 있는 CR · LF 는 문법 밖이다
*
* - 차단: 스팸 막기 감싸기 밖 · 부재중 앞 — 차단한 보낸 사람(머리글 From 의 주소 · 도메인, 대소문자 무시)의 메일은 스팸메일함으로 가고 멈춘다.
* 필터는 돌지 않고 부재중 응답도 나가지 않는다. 도메인은 그 도메인만(`:domain :is` — 하위 도메인은 따로). 스팸메일함은 id 로 가리키고,
* id 가 없으면 그 이름으로 들어간다(`:mailboxid` 의 대체 이름) — 엔진이 그대로 판정하는지 실제 배달로 맞췄다(계약 2절)
@@ -90,6 +103,28 @@ export interface JunkMailboxTarget {
export interface FilterScriptOptions {
/** 차단한 보낸 사람이 있으면 있어야 한다 — 없으면 만들지 않는다(던진다) */
readonly junkMailbox?: JunkMailboxTarget | null;
/**
* 회사 메일 도메인(`MAIL_DOMAIN`, 소문자) — 켠 부재중의 회사 밖 응답이 사내와 같지 않으면(16단계) 있어야 한다. 없으면 만들지 않는다(던진다)
*/
readonly internalDomain?: string | null;
}
/**
* 부재중 응답 MIME 개체를 나눌 조각 크기 (UTF-8 바이트) — 엔진의 문자열 하나 한도(SIEVE_STRING_MAX_BYTES)보다 넉넉히 작게.
* 2026-11-10 개발 엔진: 3,000바이트 조각을 이은 응답이 원래와 바이트까지 같았다 (engine-facts E2)
*/
const VACATION_CHUNK_BYTES = 3_000;
/** 회사 밖 응답의 :handle 앞부분 — 뒤에 응답 내용의 지문을 붙인다(내용이 바뀌면 같은 사람에게도 새 응답을 다시 보낸다 — 엔진 기본과 같은 결) */
const EXTERNAL_REPLY_HANDLE_PREFIX = 'ara-external-';
/** :handle 지문의 길이 (base64url 글자) */
const REPLY_HANDLE_HASH_LENGTH = 16;
/** 부재중 응답 하나 — 제목 · 보이는 글 · 서식(없으면 null) */
interface VacationReply {
readonly subject: string;
readonly textBody: string;
readonly htmlBody: string | null;
}
/**
@@ -108,23 +143,27 @@ type SieveExtension =
| 'body'
| 'copy'
| 'date'
| 'envelope'
| 'fileinto'
| 'imap4flags'
| 'mailbox'
| 'mailboxid'
| 'relational'
| 'vacation';
| 'vacation'
| 'variables';
const EXTENSION_ORDER: readonly SieveExtension[] = [
'body',
'copy',
'date',
'envelope',
'fileinto',
'imap4flags',
'mailbox',
'mailboxid',
'relational',
'vacation',
'variables',
];
/** 만든 스크립트 */
@@ -136,14 +175,20 @@ export interface BuiltFilterScript {
readonly longestStringBytes: number;
}
/** 스크립트를 만드는 동안 쓴 확장 · 가장 긴 문자열을 모은다 */
/** Sieve 따옴표 문자열 — `\` 와 `"` 만 이스케이프한다 (RFC 5228 §2.4.2) */
function sieveQuoted(value: string): string {
return `"${value.replace(/[\\"]/g, '\\$&')}"`;
}
/** 스크립트를 만드는 동안 쓴 확장 · 가장 긴 문자열 · 변수 이름을 모은다 */
class ScriptWriter {
readonly extensions = new Set<SieveExtension>();
longestStringBytes = 0;
private variables = 0;
/** Sieve 따옴표 문자열 — `\` 와 `"` 만 이스케이프한다 (RFC 5228 §2.4.2) */
quote(value: string): string {
const quoted = `"${value.replace(/[\\"]/g, '\\$&')}"`;
const quoted = sieveQuoted(value);
this.longestStringBytes = Math.max(
this.longestStringBytes,
Buffer.byteLength(quoted, 'utf8'),
@@ -154,6 +199,13 @@ class ScriptWriter {
use(...extensions: SieveExtension[]): void {
for (const extension of extensions) this.extensions.add(extension);
}
/** 조각을 담을 새 변수 이름 — 스크립트 안에서 겹치지 않게 차례로 (part0, part1, …) */
nextVariable(): string {
const name = `part${this.variables}`;
this.variables += 1;
return name;
}
}
function indent(depth: number, line: string): string {
@@ -219,20 +271,106 @@ function ruleBlock(
];
}
/**
* MIME 개체를 줄 끝(CRLF) 뒤에서 끊어 maxBytes 이하의 조각으로 — 이으면 원래 글이다. 한 줄이 maxBytes 보다 길면 그 줄만 글자 경계에서 자른다
* (우리 개체는 ASCII — 머리 · base64 줄은 76자라 그럴 일이 없다)
*/
function splitAtLineEnds(text: string, maxBytes: number): string[] {
const pieces: string[] = [];
let current = '';
let currentBytes = 0;
for (const line of text.match(/[^\n]*\n|[^\n]+$/g) ?? []) {
const lineBytes = Buffer.byteLength(line, 'utf8');
if (currentBytes > 0 && currentBytes + lineBytes > maxBytes) {
pieces.push(current);
current = '';
currentBytes = 0;
}
if (lineBytes > maxBytes) {
for (const character of line) {
const characterBytes = Buffer.byteLength(character, 'utf8');
if (currentBytes + characterBytes > maxBytes) {
pieces.push(current);
current = '';
currentBytes = 0;
}
current += character;
currentBytes += characterBytes;
}
continue;
}
current += line;
currentBytes += lineBytes;
}
if (current !== '') pieces.push(current);
return pieces;
}
/** 회사 밖 응답의 :handle — 응답 내용(제목 · MIME 개체)의 지문. 사내 응답과 늘 다르다 */
function externalReplyHandle(subject: string, entity: string): string {
const digest = createHash('sha256')
.update(`${subject}\n${entity}`, 'utf8')
.digest('base64url')
.slice(0, REPLY_HANDLE_HASH_LENGTH);
return `${EXTERNAL_REPLY_HANDLE_PREFIX}${digest}`;
}
/**
* 응답 하나 — `vacation :mime [:handle "…"] [:subject "…"] "<MIME 개체>";`. 서식이 있으면 글 + 서식 두 벌(multipart/alternative), 없으면 글만(지금과 같다).
* 개체가 문자열 하나 한도를 넘으면 앞에 조각 `set` 들을 두고 `"${part0}${part1}…"` 로 잇는다(variables).
* 제목이 비었으면(VacationResponse 에서 가져온 값 등) `:subject` 를 빼 엔진이 정하게 둔다
*/
function vacationReplyLines(
writer: ScriptWriter,
reply: VacationReply,
side: 'internal' | 'external',
): string[] {
writer.use('vacation');
const subject = encodeHeaderWords(reply.subject);
const entity =
reply.htmlBody === null
? plainTextMimeEntity(reply.textBody)
: alternativeMimeEntity(reply.textBody, reply.htmlBody);
const lines: string[] = [];
let argument: string;
if (
Buffer.byteLength(sieveQuoted(entity), 'utf8') <= SIEVE_STRING_MAX_BYTES
) {
argument = writer.quote(entity);
} else {
writer.use('variables');
const names = splitAtLineEnds(entity, VACATION_CHUNK_BYTES).map((piece) => {
const name = writer.nextVariable();
lines.push(`set ${writer.quote(name)} ${writer.quote(piece)};`);
return name;
});
argument = writer.quote(names.map((name) => `\${${name}}`).join(''));
}
// 사내 응답은 :handle 을 쓰지 않는다(엔진 기본 — 15단계까지와 같은 글이라 고르기를 바꿔도 같은 사람에게 다시 보내지 않는다)
const handle =
side === 'external'
? `:handle ${writer.quote(externalReplyHandle(subject, entity))} `
: '';
lines.push(
`vacation :mime ${handle}${subject ? `:subject ${writer.quote(subject)} ` : ''}${argument};`,
);
return lines;
}
/**
* 부재중 — 엔진이 VacationResponse 로 만들던 모양에 시간대만 더했다: 기간은 currentdate(시작 ge · 끝 le — 끝 시각 그 1초에도 답한다, 지금과 같다),
* 견주는 시각은 UTC(`:zone "+0000"` — 기간 값도 UTC 다), `vacation :mime` 에 base64 본문, 제목은 encoded-word.
* `:days` · `:handle` 은 쓰지 않는다(엔진 기본값 — 같은 사람에게 다시 답하는 간격).
* 제목이 비었으면(VacationResponse 에서 가져온 값 등) `:subject` 를 빼 엔진이 정하게 둔다
* `:days` 는 쓰지 않는다(엔진 기본값 — 같은 사람에게 다시 답하는 간격).
* 16단계 — 회사 밖 응답이 사내와 같지 않으면 봉투 보낸 사람의 도메인으로 가른다: 회사 도메인이면 사내 응답, 아니면 따로 쓴 응답(separate) · 없음(none).
* 회사 밖 응답은 `:handle` 을 따로 둔다(사내 응답과 같은 사람에게 다시 보내는 간격을 따로 센다)
*/
function vacationBlock(
writer: ScriptWriter,
vacation: MailVacation,
depth: number,
internalDomain: string | null,
): string[] {
writer.use('vacation');
const subject = encodeHeaderWords(vacation.subject);
const command = `vacation :mime ${subject ? `:subject ${writer.quote(subject)} ` : ''}${writer.quote(plainTextMimeEntity(vacation.textBody))};`;
/** 기간 검사 하나 — 지금 시각(UTC)을 그 시각과 견준다 */
const period = (relation: 'ge' | 'le', iso: string) =>
`currentdate :zone ${writer.quote(VACATION_PERIOD_ZONE)} :value "${relation}" "iso8601" ${writer.quote(toJmapUtcDate(iso))}`;
@@ -243,10 +381,54 @@ function vacationBlock(
];
if (periods.length > 0) writer.use('date', 'relational');
const inner = depth + periods.length;
const internalReply: VacationReply = {
subject: vacation.subject,
textBody: vacation.textBody,
htmlBody: vacation.htmlBody,
};
const { external } = vacation;
const replies: string[] = [];
if (external.mode === 'same') {
replies.push(
...vacationReplyLines(writer, internalReply, 'internal').map((line) =>
indent(inner, line),
),
);
} else {
if (!internalDomain) {
throw new Error('회사 밖 응답을 가르려면 회사 메일 도메인이 있어야 한다');
}
writer.use('envelope');
replies.push(
indent(
inner,
`if envelope :domain :is "from" ${writer.quote(internalDomain)} {`,
),
...vacationReplyLines(writer, internalReply, 'internal').map((line) =>
indent(inner + 1, line),
),
);
if (external.mode === 'separate') {
const externalReply: VacationReply = {
subject: external.subject ?? '',
textBody: external.textBody ?? '',
htmlBody: external.htmlBody,
};
replies.push(
indent(inner, '} else {'),
...vacationReplyLines(writer, externalReply, 'external').map((line) =>
indent(inner + 1, line),
),
);
}
replies.push(indent(inner, '}'));
}
const lines = periods.map((test, level) =>
indent(depth + level, `if ${test} {`),
);
lines.push(indent(depth + periods.length, command));
lines.push(...replies);
for (let level = periods.length - 1; level >= 0; level -= 1) {
lines.push(indent(depth + level, '}'));
}
@@ -295,7 +477,8 @@ function blockSection(
/**
* 규칙 · 부재중 · 차단 원본으로 엔진 규칙 글을 만든다 — 크기 한도는 부르는 쪽이 본다(FILTER_SCRIPT_MAX_BYTES · SIEVE_STRING_MAX_BYTES).
* 차단한 보낸 사람이 있는데 스팸메일함을 받지 못하면 던진다 (어댑터가 늘 넘긴다 — 없으면 그 앞에서 MAIL_001)
* 차단한 보낸 사람이 있는데 스팸메일함을 받지 못하면 던진다 (어댑터가 늘 넘긴다 — 없으면 그 앞에서 MAIL_001).
* 켠 부재중의 회사 밖 응답이 사내와 같지 않은데 회사 메일 도메인을 받지 못해도 던진다 (어댑터가 늘 넘긴다)
*/
export function buildFilterScript(
source: FilterScriptSource,
@@ -310,7 +493,14 @@ export function buildFilterScript(
}
const body: string[] = [];
if (source.vacation.isEnabled) {
body.push(...vacationBlock(writer, source.vacation, 1));
body.push(
...vacationBlock(
writer,
source.vacation,
1,
options.internalDomain ?? null,
),
);
}
for (const rule of source.rules) {
if (rule.isEnabled) body.push(...ruleBlock(writer, rule, 1));
@@ -1,3 +1,5 @@
import type { MailVacationExternalMode } from './mail.types';
/**
* 메일 API 한도·형식 상수 — 계약(docs/mail/api-contract.md)의 숫자를 한 곳에 모은다.
*/
@@ -83,6 +85,20 @@ export const MAX_VACATION_TEXT_LENGTH = 600;
export const MAX_VACATION_SUBJECT_LENGTH_WHEN_OFF = 200;
export const MAX_VACATION_TEXT_LENGTH_WHEN_OFF = 4_000;
/**
* 부재중 응답의 서식 본문(16단계) 최대 길이 — 보내는 정화(그림 없음)를 거친 뒤의 JS 문자열 길이. 서명과 같은 한도(SIGNATURE_HTML_MAX_LENGTH).
* 켤 때만 본다(회사 밖 서식 본문은 따로 쓰기일 때만) — 넘으면 400 VAL_001. 정화 전 길이 · 파서 비용은 늘 본다(html/sanitize-stored-html.ts).
* 엔진에는 base64 로 들어가고 Sieve 문자열 한 개 한도(4,096바이트)를 넘으면 variables 조각으로 나눈다 (jmap/sieve-script.ts)
*/
export const MAX_VACATION_HTML_LENGTH = 10_000;
/** 회사 밖 응답의 고르기 (16단계 — 계약 2절 "부재중 자동 응답") */
export const MAIL_VACATION_EXTERNAL_MODES = [
'same',
'none',
'separate',
] as const satisfies readonly MailVacationExternalMode[];
/**
* 한 줄 글자 — 제어 문자(C0 · DEL · C1 — 줄바꿈 · 탭 포함)가 없다. 메일함 이름 · 부재중 자동 응답 제목이 쓴다 (계약 2절)
*/
+201 -16
View File
@@ -874,19 +874,37 @@ describe('MailService', () => {
subject: '부재중입니다',
textBody: '11월 7일까지 휴가입니다.',
};
it('형식 규칙을 지나면 그대로 넘기고 엔진이 저장한 값을 돌려준다 — 로그에는 켬 · 끔만', async () => {
/** 16단계 칸이 없는 옛 요청이 저장되는 값 — 글만 · 사내와 같은 응답 */
const TEXT_ONLY = {
htmlBody: null,
external: {
mode: 'same' as const,
subject: null,
textBody: null,
htmlBody: null,
},
};
const echo = () =>
provider.setVacation.mockImplementation((_user, value) =>
Promise.resolve({ ...value, isActive: true }),
);
it('형식 규칙을 지나면 그대로 넘기고 엔진이 저장한 값을 돌려준다 — 새 칸이 없는 옛 요청은 글만 · 사내와 같은 응답, 로그에는 켬 · 끔 · 서식 · 회사 밖 고르기만', async () => {
echo();
await expect(service.updateVacation(user, vacation)).resolves.toEqual({
...vacation,
...TEXT_ONLY,
isActive: true,
});
expect(provider.setVacation).toHaveBeenCalledWith(user.email, vacation);
expect(provider.setVacation).toHaveBeenCalledWith(user.email, {
...vacation,
...TEXT_ONLY,
});
const [line] = logSpy.mock.calls[0] as [string];
expect(line).toContain('켬');
expect(line).toContain('서식 없음');
expect(line).toContain('회사 밖 same');
expect(line).not.toContain(vacation.subject);
expect(line).not.toContain(vacation.textBody);
expect(line).not.toContain(user.email);
@@ -911,6 +929,7 @@ describe('MailService', () => {
toDate: null,
subject: '',
textBody: '',
...TEXT_ONLY,
};
provider.setVacation.mockResolvedValue({
...neverConfigured,
@@ -934,9 +953,7 @@ describe('MailService', () => {
}
expect(provider.setVacation).not.toHaveBeenCalled();
provider.setVacation.mockImplementation((_user, value) =>
Promise.resolve({ ...value, isActive: true }),
);
echo();
await service.updateVacation(user, {
...vacation,
isEnabled: false,
@@ -950,9 +967,10 @@ describe('MailService', () => {
expect(
provider.setVacation.mock.calls.map(([, value]) => value),
).toEqual([
{ ...vacation, isEnabled: false, subject: ' ' },
{ ...vacation, ...TEXT_ONLY, isEnabled: false, subject: ' ' },
{
...vacation,
...TEXT_ONLY,
subject: ' 부재중입니다 ',
textBody: '\n휴가입니다.\n',
},
@@ -990,9 +1008,7 @@ describe('MailService', () => {
textLength: vacation.textBody.length,
});
provider.setVacation.mockImplementation((_user, value) =>
Promise.resolve({ ...value, isActive: true }),
);
echo();
// 한도까지는 켜도 받는다
const full = {
...vacation,
@@ -1001,6 +1017,7 @@ describe('MailService', () => {
};
await expect(service.updateVacation(user, full)).resolves.toEqual({
...full,
...TEXT_ONLY,
isActive: true,
});
// 꺼 두면 4단계에 저장해 둔 긴 글(영문 내용 1,000자 등)도 그대로 저장한다 — 끄기는 늘 된다
@@ -1012,6 +1029,7 @@ describe('MailService', () => {
};
await expect(service.updateVacation(user, off)).resolves.toEqual({
...off,
...TEXT_ONLY,
isActive: true,
});
expect(provider.setVacation).toHaveBeenCalledTimes(2);
@@ -1029,9 +1047,7 @@ describe('MailService', () => {
).toEqual({ code: 'VAL_001', reason: 'vacation-dates-reversed' });
expect(provider.setVacation).not.toHaveBeenCalled();
provider.setVacation.mockImplementation((_user, value) =>
Promise.resolve({ ...value, isActive: true }),
);
echo();
await service.updateVacation(user, {
...vacation,
fromDate: '2026-11-02T15:00:00.999Z',
@@ -1039,6 +1055,7 @@ describe('MailService', () => {
});
expect(provider.setVacation).toHaveBeenCalledWith(user.email, {
...vacation,
...TEXT_ONLY,
fromDate: '2026-11-02T15:00:00.000Z',
toDate: '2026-11-02T15:00:01.000Z',
});
@@ -1054,13 +1071,174 @@ describe('MailService', () => {
}
expect(provider.setVacation).not.toHaveBeenCalled();
provider.setVacation.mockImplementation((_user, value) =>
Promise.resolve({ ...value, isActive: true }),
);
echo();
await service.updateVacation(user, { ...vacation, toDate: null });
await service.updateVacation(user, { ...vacation, fromDate: null });
expect(provider.setVacation).toHaveBeenCalledTimes(2);
});
describe('서식 본문 · 회사 밖 응답 (16단계)', () => {
const external = {
mode: 'separate' as const,
subject: 'Out of office',
textBody: 'I am away until Nov 7.',
htmlBody: '<p>I am <strong>away</strong> until Nov 7.</p>',
};
it('서식 본문은 보내는 정화(그림 없음)를 거쳐 넘긴다 — 정화해서 비면 null(글만), 회사 밖 칸도 같다. 로그에는 서식 있음 · 고르기만', async () => {
echo();
const saved = await service.updateVacation(user, {
...vacation,
htmlBody:
'<p onclick="x()">11월 7일까지 <strong>휴가</strong>입니다.<script>x()</script></p><img src="https://evil.example/x.png"><img src="/api/mail-signature/images/3f1c0a2e-0f1b-4f2a-9a1e-6c9f0a1b2c3d">',
external: {
...external,
htmlBody: '<script>x()</script>',
},
});
expect(saved).toEqual({
...vacation,
htmlBody: '<p>11월 7일까지 <strong>휴가</strong>입니다.</p>',
external: { ...external, htmlBody: null },
isActive: true,
});
// 서식에 그림은 없다 — 서명 그림 주소도 정화가 지운다(그 사람의 그림 목록을 읽지 않는다)
expect(signatures.imageIdsOf).not.toHaveBeenCalled();
const [line] = logSpy.mock.calls[0] as [string];
expect(line).toContain('서식 있음');
expect(line).toContain('회사 밖 separate');
expect(line).not.toContain('Out of office');
expect(line).not.toContain('휴가');
});
it('회사 밖 글 칸의 빈 글은 null 로 — 다른 고르기여도 써 둔 글은 그대로 넘긴다, external 이 null 이면 사내와 같은 응답', async () => {
echo();
await service.updateVacation(user, {
...vacation,
external: { ...external, mode: 'none' },
});
await service.updateVacation(user, {
...vacation,
external: {
mode: 'same',
subject: '',
textBody: 'x',
htmlBody: '',
},
});
await service.updateVacation(user, { ...vacation, external: null });
expect(
provider.setVacation.mock.calls.map(([, value]) => value.external),
).toEqual([
{ ...external, mode: 'none' },
{ mode: 'same', subject: null, textBody: 'x', htmlBody: null },
TEXT_ONLY.external,
]);
});
it('따로 쓰기이고 켤 때만 회사 밖 제목 · 내용을 본다 — 비면 · 150 · 600자를 넘으면 VAL_001, 끈 채로 · 다른 고르기면 비어도 저장한다', async () => {
for (const [changed, reason] of [
[{ subject: null }, 'vacation-external-without-text'],
[{ textBody: ' ' }, 'vacation-external-without-text'],
[
{ subject: 'x'.repeat(MAX_VACATION_SUBJECT_LENGTH + 1) },
'vacation-external-text-too-long',
],
[
{ textBody: 'x'.repeat(MAX_VACATION_TEXT_LENGTH + 1) },
'vacation-external-text-too-long',
],
] as const) {
expect(
await failureOf(() =>
service.updateVacation(user, {
...vacation,
external: { ...external, ...changed },
}),
),
).toEqual({ code: 'VAL_001', reason });
}
expect(provider.setVacation).not.toHaveBeenCalled();
echo();
const empty = { subject: null, textBody: null, htmlBody: null };
await service.updateVacation(user, {
...vacation,
isEnabled: false,
external: { mode: 'separate', ...empty },
});
for (const mode of ['same', 'none'] as const) {
await service.updateVacation(user, {
...vacation,
external: { mode, ...empty },
});
}
expect(provider.setVacation).toHaveBeenCalledTimes(3);
});
it('켤 때 정화한 서식은 10,000자까지 — 넘으면 VAL_001(회사 밖은 따로 쓰기일 때만), 정화 전 비용은 늘 본다', async () => {
const long = `<p>${'가'.repeat(10_000)}</p>`;
expect(
await failureOf(() =>
service.updateVacation(user, { ...vacation, htmlBody: long }),
),
).toEqual({ code: 'VAL_001', reason: 'vacation-html-too-long' });
expect(
await failureOf(() =>
service.updateVacation(user, {
...vacation,
external: { ...external, htmlBody: long },
}),
),
).toEqual({
code: 'VAL_001',
reason: 'vacation-external-html-too-long',
});
for (const isEnabled of [true, false]) {
expect(
await failureOf(() =>
service.updateVacation(user, {
...vacation,
isEnabled,
htmlBody: '<b>'.repeat(166_666),
}),
),
).toEqual({ code: 'VAL_001', reason: 'vacation-html-too-complex' });
}
expect(
await failureOf(() =>
service.updateVacation(user, {
...vacation,
isEnabled: false,
htmlBody: 'x'.repeat(MAX_BODY_HTML_LENGTH + 1),
}),
),
).toEqual({ code: 'VAL_001', reason: 'vacation-html-too-long' });
expect(provider.setVacation).not.toHaveBeenCalled();
echo();
// 끈 채로는 · 다른 고르기의 회사 밖 서식은 10,000자를 넘어도 저장한다(켤 때 본다)
await service.updateVacation(user, {
...vacation,
isEnabled: false,
htmlBody: long,
});
await service.updateVacation(user, {
...vacation,
external: { ...external, mode: 'none', htmlBody: long },
});
// 경계 — 정화한 결과가 정확히 10,000자면 켜도 받는다
await service.updateVacation(user, {
...vacation,
htmlBody: `<p>${'가'.repeat(10_000 - 7)}</p>`,
});
expect(provider.setVacation).toHaveBeenCalledTimes(3);
});
});
});
it('스팸 표시만 바꾸는 PATCH 도 받는다 — 한 통 · 여러 통 모두 isJunk 를 그대로 넘긴다', async () => {
@@ -1814,6 +1992,13 @@ describe('MailService', () => {
toDate: null,
subject: '부재중입니다',
textBody: '휴가입니다.',
htmlBody: '<p>휴가입니다.</p>',
external: {
mode: 'none' as const,
subject: null,
textBody: null,
htmlBody: null,
},
isActive: false,
};
provider.getVacation.mockResolvedValue(state);
+70 -2
View File
@@ -64,6 +64,7 @@ import { highlightTermsOf } from '@/modules/mail-search/search-plan';
import { checkHtmlParseBudget } from './html/html-parse-budget';
import { sanitizeMailHtml } from './html/sanitize-mail-html';
import { sanitizeOutgoingHtml } from './html/sanitize-outgoing-html';
import { sanitizeStoredHtml } from './html/sanitize-stored-html';
import {
SIGNATURE_IMAGE_PATH_PREFIX,
findSignatureImageIds,
@@ -78,6 +79,7 @@ import {
MAX_BODY_LENGTH,
MAX_BODY_VALUE_BYTES,
MAX_RECIPIENTS,
MAX_VACATION_HTML_LENGTH,
MAX_VACATION_SUBJECT_LENGTH,
MAX_VACATION_TEXT_LENGTH,
SCHEDULED_SEND_MAX_LEAD_MS,
@@ -248,6 +250,34 @@ function assertHtmlBytes(html: string): void {
}
}
/**
* 부재중 응답의 서식 본문(16단계) — 보내는 정화(그림 없음)를 거친 값, 없거나 비면 null. 정화 전 길이 · 파서 비용은 늘 보고,
* 정화 뒤 MAX_VACATION_HTML_LENGTH 는 `enforceLength` 일 때만(켤 때 — 회사 밖은 따로 쓰기일 때) 본다. 넘으면 VAL_001 (사유 · 길이만 — 글은 남기지 않는다)
*/
function vacationHtml(
raw: string | null | undefined,
enforceLength: boolean,
reason: 'vacation-html' | 'vacation-external-html',
): string | null {
const sanitized = sanitizeStoredHtml(raw, {
maxLength: enforceLength ? MAX_VACATION_HTML_LENGTH : null,
});
if (!sanitized.ok) {
throw new BusinessException('VAL_001', {
context: {
reason: `${reason}-${sanitized.problem}`,
length: sanitized.length,
},
});
}
return sanitized.html;
}
/** 회사 밖 응답의 글 칸 — 빈 글은 "쓴 적 없음"(null) */
function writtenOrNull(value: string | null | undefined): string | null {
return value === undefined || value === null || value === '' ? null : value;
}
/** 읽음 · 중요 표시 · 이동 · 스팸 표시 — 하나 이상이어야 한다 (빈 요청은 VAL_001). 한 통·여러 통 PATCH 가 같이 쓴다 */
function toMessageUpdate(dto: UpdateMessageDto): MessageUpdate {
const { isRead, isFlagged, mailboxId, isJunk } = dto;
@@ -549,18 +579,35 @@ export class MailService {
* - 켤 때는 제목 150자 · 내용 600자까지 (엔진의 바이트 한도 안 — mail.constants.ts). 끈 채로는 요청 검증의 200자 · 4,000자까지 받는다 —
* 4단계에 저장해 둔 긴 글이 켜져 있어도 끌 수 있게 (계약 2절 "길이 한도의 근거")
* - 날짜는 초 단위로 맞춰 저장한다(소수 초를 버린다 — 엔진 규칙의 기간은 초 단위다). 시작 · 끝이 둘 다 있으면 **맞춘 값으로** 시작 < 끝
* 어긋나면 엔진에 묻지 않고 VAL_001. 형식(늘 보는 길이 · 제어 문자 · 날짜 모양)은 요청 검증이 본다. 로그에는 켬 · 끔만 (제목 · 내용 없이)
* - 서식 본문(16단계)은 보내는 정화(그림 없음)를 거쳐 저장한다 — 켤 때는 정화 뒤 10,000자까지(정화 전 길이 · 파서 비용은 늘 본다)
* - 회사 밖 응답(16단계) — 따로 쓰기이고 켤 때만 그 제목 · 내용을 사내와 같은 규칙(1자 이상 · 150 · 600자 · 서식 10,000자)으로 본다.
* 사내와 같은 응답 · 보내지 않기면 비어 있어도 저장하고, 따로 써 둔 글은 고르기와 상관없이 그대로 저장한다(빈 글은 null)
* - 새 칸이 없는 옛 요청은 글만 · 사내와 같은 응답으로 저장한다(옛 화면이 보는 그대로)
* 어긋나면 엔진에 묻지 않고 VAL_001. 형식(늘 보는 길이 · 제어 문자 · 날짜 모양)은 요청 검증이 본다. 로그에는 켬 · 끔 · 서식 있음 · 회사 밖 고르기만 (제목 · 내용 없이)
*/
async updateVacation(
user: AuthenticatedUser,
dto: MailVacationDto,
): Promise<MailVacationState> {
const external = dto.external ?? null;
const mode = external?.mode ?? 'same';
const vacation: MailVacation = {
isEnabled: dto.isEnabled,
fromDate: dto.fromDate === null ? null : toWholeSecondIso(dto.fromDate),
toDate: dto.toDate === null ? null : toWholeSecondIso(dto.toDate),
subject: dto.subject,
textBody: dto.textBody,
htmlBody: vacationHtml(dto.htmlBody, dto.isEnabled, 'vacation-html'),
external: {
mode,
subject: writtenOrNull(external?.subject),
textBody: writtenOrNull(external?.textBody),
htmlBody: vacationHtml(
external?.htmlBody,
dto.isEnabled && mode === 'separate',
'vacation-external-html',
),
},
};
if (
vacation.isEnabled &&
@@ -584,6 +631,27 @@ export class MailService {
},
});
}
if (vacation.isEnabled && mode === 'separate') {
const subject = vacation.external.subject ?? '';
const textBody = vacation.external.textBody ?? '';
if (subject.trim() === '' || textBody.trim() === '') {
throw new BusinessException('VAL_001', {
context: { reason: 'vacation-external-without-text' },
});
}
if (
subject.length > MAX_VACATION_SUBJECT_LENGTH ||
textBody.length > MAX_VACATION_TEXT_LENGTH
) {
throw new BusinessException('VAL_001', {
context: {
reason: 'vacation-external-text-too-long',
subjectLength: subject.length,
textLength: textBody.length,
},
});
}
}
if (
vacation.fromDate !== null &&
vacation.toDate !== null &&
@@ -596,7 +664,7 @@ export class MailService {
const saved = await this.provider.setVacation(user.email, vacation);
this.logger.log(
`${traceTag()} 부재중 자동 응답을 저장했습니다: user=${maskEmail(user.email)}, ${saved.isEnabled ? '켬' : '끔'}`,
`${traceTag()} 부재중 자동 응답을 저장했습니다: user=${maskEmail(user.email)}, ${saved.isEnabled ? '켬' : '끔'}, 서식 ${saved.htmlBody !== null ? '있음' : '없음'}, 회사 밖 ${saved.external.mode}`,
);
return saved;
}
+22 -1
View File
@@ -327,6 +327,23 @@ export interface MailboxBulkResult {
readonly remaining: number;
}
/**
* 회사 밖에서 온 메일(봉투 보낸 사람의 도메인이 `MAIL_DOMAIN` 이 아님)에 하는 응답 (16단계) —
* same: 사내와 같은 응답 · none: 보내지 않기 · separate: 따로 쓴 제목 · 내용으로
*/
export type MailVacationExternalMode = 'same' | 'none' | 'separate';
/** 회사 밖 응답 (16단계) — 다른 mode 로 바꿔도 따로 쓴 글은 남겨 둔다(함께 저장한다) */
export interface MailVacationExternal {
readonly mode: MailVacationExternalMode;
/** 따로 쓰기의 제목 — 쓴 적 없으면(빈 글) null */
readonly subject: string | null;
/** 따로 쓰기의 글 — 쓴 적 없으면(빈 글) null */
readonly textBody: string | null;
/** 따로 쓰기의 서식 본문(정화한 값 — 그림 없음) — 없으면 null(글만) */
readonly htmlBody: string | null;
}
/**
* 부재중 자동 응답 (계약 2절 "부재중 자동 응답") — 저장은 메일 필터와 같은 엔진 규칙에 함께 둔다 (계약 2절 "메일 필터"의 저장 자리)
*/
@@ -338,8 +355,12 @@ export interface MailVacation {
readonly toDate: string | null;
/** 엔진에 없으면(null) 빈 문자열 */
readonly subject: string;
/** 글자만(서식 없음) — 엔진에 없으면(null) 빈 문자열 */
/** 보이는 글(서식을 뺀 글) — 엔진에 없으면(null) 빈 문자열. 서식 본문이 있으면 받는 쪽에는 두 벌(글 + 서식)로 간다 */
readonly textBody: string;
/** 서식 본문(정화한 값 — 그림 없음, 16단계) — null 이면 글만 */
readonly htmlBody: string | null;
/** 회사 밖에서 온 메일에 하는 응답 (16단계) — 처음 값은 사내와 같은 응답 */
readonly external: MailVacationExternal;
}
/** 부재중 자동 응답의 GET · PUT 응답 (계약 2절 "부재중 자동 응답"의 MailVacationState — 7단계) */
@@ -42,6 +42,7 @@ import {
import { MailIdPipe } from './mail-id.pipe';
import {
MAX_MAILBOX_NAME_LENGTH,
MAX_VACATION_HTML_LENGTH,
MAX_VACATION_SUBJECT_LENGTH,
MAX_VACATION_SUBJECT_LENGTH_WHEN_OFF,
MAX_VACATION_TEXT_LENGTH,
@@ -234,7 +235,8 @@ export class MailVacationController {
@ApiOperation({
summary: '부재중 자동 응답',
description:
'한 번도 설정하지 않았으면 { isEnabled: false, fromDate: null, toDate: null, subject: "", textBody: "", isActive: true } 다. ' +
'한 번도 설정하지 않았으면 { isEnabled: false, fromDate: null, toDate: null, subject: "", textBody: "", htmlBody: null, ' +
'external: { mode: "same", subject: null, textBody: null, htmlBody: null }, isActive: true } 다(서식 본문 · 회사 밖 응답은 16단계). ' +
'isActive 는 메일 엔진에서 우리 규칙(메일 필터와 한 규칙)이 동작하는지다 — false 면 켜 두었어도 지금은 답하지 않는다(저장하면 다시 켜진다, 메일 필터의 isActive 와 같은 값).',
})
@ApiStandardResponse(MailVacationStateDto, {
@@ -253,7 +255,10 @@ export class MailVacationController {
`꺼 둔 채로는 제목 ${MAX_VACATION_SUBJECT_LENGTH_WHEN_OFF}자 · 내용 ${MAX_VACATION_TEXT_LENGTH_WHEN_OFF.toLocaleString('ko-KR')}자까지 받는다 — 전에 저장한 긴 글이 켜져 있어도 끌 수 있게. ` +
'시작 · 끝이 둘 다 있으면 시작이 끝보다 앞이어야 한다 — 날짜는 소수 초를 버린 값으로 저장하고, 순서도 그 값으로 본다. ' +
'제목에는 제어 문자(탭 · 줄바꿈 포함)를, 내용에는 탭 · 줄바꿈 밖의 제어 문자를 쓸 수 없다. ' +
'꺼도 쓴 제목 · 내용은 남겨 둔다. 서식 본문은 쓰지 않는다. 저장한 값을 돌려준다. 메일 필터 규칙은 그대로 둔다. ' +
'꺼도 쓴 제목 · 내용은 남겨 둔다. 저장한 값을 돌려준다. 메일 필터 규칙은 그대로 둔다. ' +
`16단계 — htmlBody(서식 본문, 그림 없음 — 정화해 저장, 켤 때 정화 뒤 ${MAX_VACATION_HTML_LENGTH.toLocaleString('ko-KR')}자까지)와 external(회사 밖에서 온 메일 — same 사내와 같은 응답 · none 보내지 않기 · separate 따로 쓰기)을 받는다. ` +
'둘 다 보내지 않아도 된다(글만 · 사내와 같은 응답으로 저장). 따로 쓰기이고 켤 때만 회사 밖 제목 · 내용을 사내와 같은 규칙으로 본다 — 다른 고르기여도 써 둔 글은 그대로 저장한다. ' +
'사내 · 회사 밖은 봉투 보낸 사람의 도메인(회사 메일 도메인인지)으로 가른다. ' +
'저장하면 우리 규칙이 다시 켜진다(isActive: true — 꺼져 있던 메일 필터도 함께 다시 동작한다). 본문에 isActive 는 넣지 않는다(모르는 필드 — 400 VAL_001).',
})
@ApiStandardResponse(MailVacationStateDto, {
@@ -261,7 +266,7 @@ export class MailVacationController {
})
@ApiErrorResponse(
'VAL_001',
`VAL_001 형식 · 길이(제목 ${MAX_VACATION_SUBJECT_LENGTH_WHEN_OFF}자 · 내용 ${MAX_VACATION_TEXT_LENGTH_WHEN_OFF.toLocaleString('ko-KR')}자, 켤 때는 제목 ${MAX_VACATION_SUBJECT_LENGTH}자 · 내용 ${MAX_VACATION_TEXT_LENGTH}자) · 제어 문자 · 켤 때 공백뿐인 제목 · 내용 · 시작이 끝보다 늦거나 같음(소수 초를 버린 값으로) · 모르는 필드(isActive 포함) ` +
`VAL_001 형식 · 길이(제목 ${MAX_VACATION_SUBJECT_LENGTH_WHEN_OFF}자 · 내용 ${MAX_VACATION_TEXT_LENGTH_WHEN_OFF.toLocaleString('ko-KR')}자, 켤 때는 제목 ${MAX_VACATION_SUBJECT_LENGTH}자 · 내용 ${MAX_VACATION_TEXT_LENGTH}자 · 정화한 서식 ${MAX_VACATION_HTML_LENGTH.toLocaleString('ko-KR')}자 — 따로 쓰기면 회사 밖 칸도) · 서식이 너무 깊게 중첩됨 · 제어 문자 · 켤 때 공백뿐인 제목 · 내용(따로 쓰기면 회사 밖 칸도) · 시작이 끝보다 늦거나 같음(소수 초를 버린 값으로) · 모르는 필드(isActive 포함) ` +
'/ MAIL_013 메일 필터와 함께 만든 엔진 규칙이 엔진 한도를 넘음 — 모두 400, 저장하지 않았다',
)
@ApiErrorResponse(
@@ -0,0 +1,97 @@
import { ApiProperty } from '@nestjs/swagger';
import { PageInfoDto } from '@/common/dto/pagination.dto';
import {
RELEASE_NOTE_HEADING_MAX_LENGTH,
RELEASE_NOTE_ITEM_MAX_LENGTH,
RELEASE_NOTE_TITLE_MAX_LENGTH,
} from '../release-notes.constants';
import type {
ReleaseNoteDetail,
ReleaseNoteSection,
ReleaseNoteSummary,
} from '../release-notes.types';
/** 목록의 한 줄 · 글 화면의 다른 글 줄 (계약 7절 `ReleaseNoteSummary`) */
export class ReleaseNoteSummaryDto implements ReleaseNoteSummary {
@ApiProperty({
description:
'글 id — 글 파일 이름에서 .md 를 뗀 것(`YYYY-MM-DD`, 같은 날 두 번째 글부터 `YYYY-MM-DD-2` …). 글 주소에 쓴다',
example: '2026-11-18',
})
id!: string;
@ApiProperty({
description:
'글의 날짜 — 달력 날짜 글(`YYYY-MM-DD`, 파일 이름에서). 시각이 아니다 — 화면은 시간대를 바꾸지 않고 그대로 쓴다(Date 로 읽지 않는다)',
example: '2026-11-18',
})
date!: string;
@ApiProperty({
description: `제목 — 글 파일의 첫 줄(# 제목), ${RELEASE_NOTE_TITLE_MAX_LENGTH}자까지`,
example: '메일 검색이 빨라지고 휴대폰 앱이 나왔습니다',
})
title!: string;
}
/** GET /api/release-notes 의 응답 */
export class ReleaseNotePageDto {
@ApiProperty({
type: [ReleaseNoteSummaryDto],
description:
'최근 글부터 — 날짜 내림차순, 같은 날은 뒤 번호가 먼저(`2026-11-18-2` → `2026-11-18`)',
})
items!: ReleaseNoteSummaryDto[];
@ApiProperty({ type: PageInfoDto, description: 'total 은 글 수' })
pageInfo!: PageInfoDto;
}
/** 묶음 (계약 7절 `ReleaseNoteSection`) */
export class ReleaseNoteSectionDto implements ReleaseNoteSection {
@ApiProperty({
description: `묶음 머리(## 머리, ${RELEASE_NOTE_HEADING_MAX_LENGTH}자까지) — 머리 없이 쓴 묶음은 null(글의 맨 앞에만 온다)`,
type: String,
nullable: true,
example: '휴대폰 앱',
})
heading!: string | null;
@ApiProperty({
description:
`묶음의 줄(- 한 줄, ${RELEASE_NOTE_ITEM_MAX_LENGTH}자까지 — 하나 이상). 서버가 다듬은 글 그대로(앞뒤 빈칸 지움 · 안의 빈칸 여럿은 하나로 · 이어 쓴 줄은 띄어쓰기 하나로 · NFC). ` +
'마크다운 · HTML 로 해석하지 않고 글로만 그린다',
type: [String],
example: [
'안드로이드 휴대폰 앱이 나왔습니다. 웹메일의 [설정] → [휴대폰 앱]에서 받을 수 있습니다.',
],
})
items!: string[];
}
/** GET /api/release-notes/{id} 의 응답 (계약 7절 `ReleaseNote`) */
export class ReleaseNoteDto
extends ReleaseNoteSummaryDto
implements ReleaseNoteDetail
{
@ApiProperty({
type: [ReleaseNoteSectionDto],
description: '묶음 — 파일에 쓴 차례, 하나 이상',
})
sections!: ReleaseNoteSectionDto[];
@ApiProperty({
description: '이전 글 — 바로 더 옛 글(목록에서 이 글 아래). 없으면 null',
type: ReleaseNoteSummaryDto,
nullable: true,
})
previous!: ReleaseNoteSummaryDto | null;
@ApiProperty({
description: '다음 글 — 바로 더 새 글(목록에서 이 글 위). 없으면 null',
type: ReleaseNoteSummaryDto,
nullable: true,
})
next!: ReleaseNoteSummaryDto | null;
}
@@ -0,0 +1,538 @@
import { startCpuTimer } from '@/../test/cpu-time';
import {
classifyReleaseNoteFile,
compareReleaseNotes,
describeReleaseNoteProblem,
isCalendarDate,
isReleaseNoteId,
parseReleaseNoteText,
type ReleaseNoteProblem,
} from './release-note-format';
import {
RELEASE_NOTE_HEADING_MAX_LENGTH,
RELEASE_NOTE_ITEM_MAX_LENGTH,
RELEASE_NOTE_PROBLEMS_MAX,
RELEASE_NOTE_TITLE_MAX_LENGTH,
} from './release-notes.constants';
/** 줄들을 이어 글 파일의 글로 */
const lines = (...rows: string[]) => rows.join('\n');
/** 맞는 글의 결과 — 틀리면 문제를 보이며 실패한다 */
function parsed(text: string) {
const result = parseReleaseNoteText(text);
if (!result.ok) {
throw new Error(
`틀린 글로 읽었다: ${result.problems.map(describeReleaseNoteProblem).join(', ')}`,
);
}
return result;
}
/** 틀린 글의 문제 — 맞는 글로 읽으면 실패한다 */
function problemsOf(text: string): readonly ReleaseNoteProblem[] {
const result = parseReleaseNoteText(text);
if (result.ok) throw new Error('맞는 글로 읽었다');
return result.problems;
}
const LINE_SEPARATOR = String.fromCharCode(0x2028);
const BYTE_ORDER_MARK = String.fromCharCode(0xfeff);
const BELL = String.fromCharCode(0x07);
/** 이모지 하나 — UTF-16 으로 두 칸 */
const EMOJI = String.fromCodePoint(0x1f600);
describe('parseReleaseNoteText — 맞는 글', () => {
it('보드 C 의 글 — 제목 · 메모 · 머리 · 줄, 빈칸 두 개로 시작한 줄은 앞 줄에 띄어쓰기 하나로 이어 붙는다', () => {
const result = parsed(
lines(
'# 메일 검색이 빨라지고 휴대폰 앱이 나왔습니다',
'<!-- 배포 날짜가 정해지면 파일 이름을 그날로 바꿉니다 -->',
'',
'## 휴대폰 앱',
'- 안드로이드 휴대폰 앱이 나왔습니다. 웹메일의 [설정] → [휴대폰 앱]에서 받을 수 있습니다.',
'- 앱에서 메일을 읽고 쓰고 찾을 수 있고, 새 메일이 오면 휴대폰으로 알려 드립니다.',
' 알림에는 새 메일이 몇 통인지만 나오고, 보낸 사람이나 제목은 나오지 않습니다.',
'',
'## 메일 검색',
'- 찾은 말에는 노란 표시가 붙습니다.',
'',
),
);
expect(result).toEqual({
ok: true,
title: '메일 검색이 빨라지고 휴대폰 앱이 나왔습니다',
sections: [
{
heading: '휴대폰 앱',
items: [
'안드로이드 휴대폰 앱이 나왔습니다. 웹메일의 [설정] → [휴대폰 앱]에서 받을 수 있습니다.',
'앱에서 메일을 읽고 쓰고 찾을 수 있고, 새 메일이 오면 휴대폰으로 알려 드립니다. 알림에는 새 메일이 몇 통인지만 나오고, 보낸 사람이나 제목은 나오지 않습니다.',
],
},
{ heading: '메일 검색', items: ['찾은 말에는 노란 표시가 붙습니다.'] },
],
});
});
it('머리 없이 바로 쓴 줄은 머리 없는 묶음(heading null) — 뒤에 머리가 오면 새 묶음', () => {
expect(
parsed(
lines(
'# 왼쪽 메뉴를 정리했습니다',
'- 아직 준비 중인 [ERP]를 왼쪽 메뉴에서 뺐습니다.',
'## 주소록',
'- 한 줄',
),
).sections,
).toEqual([
{
heading: null,
items: ['아직 준비 중인 [ERP]를 왼쪽 메뉴에서 뺐습니다.'],
},
{ heading: '주소록', items: ['한 줄'] },
]);
});
it('앞뒤 빈칸은 지우고 안의 빈칸 · 탭 여럿은 하나로 — 탭으로 시작한 줄도 이어 쓰기, 굵게 · 링크 꼴은 글 그대로', () => {
const result = parsed(
lines(
'# 제목 ',
'##\t머리\t ',
'- 한 줄\t\t끝 ',
'\t탭으로 이어 씀',
' 빈칸 여럿으로 이어 씀',
'- **굵게** [링크](https://example.com) `코드`',
),
);
expect(result.title).toBe('제목');
expect(result.sections).toEqual([
{
heading: '머리',
items: [
'한 줄 끝 탭으로 이어 씀 빈칸 여럿으로 이어 씀',
'**굵게** [링크](https://example.com) `코드`',
],
},
]);
});
it('빈 줄 · 메모(여러 줄 · 제목 앞 · 줄 사이)는 화면에 없다 — 빈 줄 · 메모를 지나도 이어 쓰기는 앞 - 줄에 붙는다', () => {
const result = parsed(
lines(
'<!-- 맨 앞 메모',
' 여러 줄 -->',
'',
'# 제목',
' <!-- 들여 쓴 메모 -->',
'- 첫 줄',
'<!-- 줄 사이 메모 -->',
'',
' 이어 쓴 글',
'<!-->',
'<!--->',
'- 둘째 줄',
),
);
expect(result).toEqual({
ok: true,
title: '제목',
sections: [{ heading: null, items: ['첫 줄 이어 쓴 글', '둘째 줄'] }],
});
});
it('CRLF 줄 끝 · 앞의 BOM 은 지운다', () => {
const result = parsed(
`${BYTE_ORDER_MARK}# 제목\r\n## 머리\r\n- 한 줄\r\n 이어 씀\r\n`,
);
expect(result.title).toBe('제목');
expect(result.sections).toEqual([
{ heading: '머리', items: ['한 줄 이어 씀'] },
]);
});
it('NFC 로 모은다 — 풀어 쓴 한글(NFD)도 같은 글 · 같은 길이', () => {
const decomposed = '업데이트 내역'.normalize('NFD');
expect(decomposed.length).toBeGreaterThan('업데이트 내역'.length);
const result = parsed(lines(`# ${decomposed}`, `- ${decomposed}`));
expect(result.title).toBe('업데이트 내역');
expect(result.sections[0].items).toEqual(['업데이트 내역']);
});
it('길이 한도 — 제목 100 · 머리 40 · 한 줄 500 까지는 맞다(UTF-16 — 이모지는 두 칸)', () => {
const title = '가'.repeat(RELEASE_NOTE_TITLE_MAX_LENGTH - 2) + EMOJI;
const heading = EMOJI + '나'.repeat(RELEASE_NOTE_HEADING_MAX_LENGTH - 2);
const item = '다'.repeat(RELEASE_NOTE_ITEM_MAX_LENGTH);
const result = parsed(lines(`# ${title}`, `## ${heading}`, `- ${item}`));
expect(result.title).toHaveLength(RELEASE_NOTE_TITLE_MAX_LENGTH);
expect(result.sections[0].heading).toHaveLength(
RELEASE_NOTE_HEADING_MAX_LENGTH,
);
expect(result.sections[0].items[0]).toHaveLength(
RELEASE_NOTE_ITEM_MAX_LENGTH,
);
});
it('- 만 쓴 줄도 이어 쓰기가 글을 채우면 맞다', () => {
expect(parsed(lines('# 제목', '-', ' 이어 쓴 글')).sections).toEqual([
{ heading: null, items: ['이어 쓴 글'] },
]);
});
});
describe('parseReleaseNoteText — 틀린 글 (줄 번호 · 까닭)', () => {
it('첫 글 줄이 # 제목이 아니면 그 줄에 title-missing — 나머지도 읽어 함께 알린다', () => {
expect(problemsOf(lines('', '## 머리', '- 한 줄', '맨 글'))).toEqual([
{ line: 2, reason: 'title-missing' },
{ line: 4, reason: 'unknown-line' },
]);
});
it('빈 파일 · 메모뿐인 파일은 title-missing · no-items (파일 전체 — 줄 번호 없음)', () => {
for (const text of ['', '\n\n', lines('<!-- 메모만', '-->')]) {
expect(problemsOf(text)).toEqual([
{ line: null, reason: 'title-missing' },
{ line: null, reason: 'no-items' },
]);
}
});
it('# 제목을 또 쓰면 title-again, 비면 title-empty, 길면 title-too-long', () => {
expect(problemsOf(lines('# 제목', '- 한 줄', '# 또 제목'))).toEqual([
{ line: 3, reason: 'title-again' },
]);
expect(problemsOf(lines('# ', '- 한 줄'))).toEqual([
{ line: 1, reason: 'title-empty' },
]);
expect(
problemsOf(
lines(
`# ${'가'.repeat(RELEASE_NOTE_TITLE_MAX_LENGTH - 1)}${EMOJI}`,
'- 줄',
),
),
).toEqual([{ line: 1, reason: 'title-too-long' }]);
});
it('머리가 비면 heading-empty, 길면 heading-too-long, 아래에 줄이 없으면 heading-without-items', () => {
expect(
problemsOf(
lines(
'# 제목',
'##',
'- 줄',
`## ${'나'.repeat(RELEASE_NOTE_HEADING_MAX_LENGTH + 1)}`,
'- 줄',
'## 줄 없는 머리',
'',
'## 마지막 머리',
'- 줄',
),
),
).toEqual([
{ line: 2, reason: 'heading-empty' },
{ line: 4, reason: 'heading-too-long' },
{ line: 6, reason: 'heading-without-items' },
]);
});
it('줄이 비면 item-empty, 이어 붙인 뒤 500자를 넘으면 그 줄의 시작에 item-too-long', () => {
const half = '다'.repeat(RELEASE_NOTE_ITEM_MAX_LENGTH / 2);
expect(
problemsOf(
lines('# 제목', '- ', '- 맞는 줄', `- ${half}`, ` ${half}`),
),
).toEqual([
{ line: 2, reason: 'item-empty' },
// 둘을 띄어쓰기 하나로 이으면 501자
{ line: 4, reason: 'item-too-long' },
]);
});
it('글에 줄이 하나도 없으면 no-items', () => {
expect(problemsOf(lines('# 제목', '<!-- 줄은 나중에 -->'))).toEqual([
{ line: null, reason: 'no-items' },
]);
});
it.each([
['맨 글', '그냥 쓴 글'],
['###', '### 작은 머리'],
['별표 목록', '* 별표 줄'],
['더하기 목록', '+ 더하기 줄'],
['번호 목록', '1. 번호 줄'],
['표', '| 칸 | 칸 |'],
['인용', '> 인용'],
['가로줄', '---'],
['한 칸 들여 쓴 줄', ' - 한 칸 들여 쓴 줄'],
['띄어쓰기 없는 머리', '##머리'],
['띄어쓰기 없는 줄', '-줄'],
])('쓸 수 없는 줄(%s)은 unknown-line', (_name, row) => {
expect(problemsOf(lines('# 제목', '- 한 줄', row))).toEqual([
{ line: 3, reason: 'unknown-line' },
]);
});
it('띄어쓰기 없는 제목(#제목)은 제목이 아니다 — 첫 글 줄이면 title-missing · unknown-line', () => {
expect(problemsOf(lines('#제목', '- 한 줄'))).toEqual([
{ line: 1, reason: 'title-missing' },
{ line: 1, reason: 'unknown-line' },
]);
});
it('이어 쓰기 — 앞에 - 줄이 없으면(제목 · 머리 바로 아래) continuation-without-item, 들여 쓴 - 는 nested-list', () => {
expect(
problemsOf(
lines(
'# 제목',
' 제목에 이어 씀',
'## 머리',
' 머리에 이어 씀',
'- 한 줄',
' - 하위 목록',
),
),
).toEqual([
{ line: 2, reason: 'continuation-without-item' },
{ line: 4, reason: 'continuation-without-item' },
{ line: 6, reason: 'nested-list' },
]);
});
it('메모 — 닫지 않으면 시작 줄에 memo-unclosed, 닫은 뒤 같은 줄에 글이 있으면 memo-trailing, 글 안의 <!-- 는 memo-inside-text', () => {
expect(
problemsOf(
lines(
'# 제목 <!-- 제목 메모 -->',
'- 한 줄 <!-- 줄 메모 -->',
'<!-- 메모 --> 뒤에 글',
'<!-- 여러 줄',
'x --> 닫은 뒤 글',
'- 둘째 줄',
'<!-- 닫지 않은 메모',
'- 메모 안이라 읽지 않는 줄',
),
),
).toEqual([
{ line: 1, reason: 'memo-inside-text' },
{ line: 2, reason: 'memo-inside-text' },
{ line: 3, reason: 'memo-trailing' },
{ line: 5, reason: 'memo-trailing' },
{ line: 7, reason: 'memo-unclosed' },
]);
});
it('보이지 않는 글자(제어 문자 · 줄 끝이 아닌 CR · 줄 구분 문자)는 control-character', () => {
expect(
problemsOf(
lines(
`# 제목${BELL}`,
`## 머리\r중간`,
`- 한 줄${LINE_SEPARATOR}둘째`,
'- 맞는 줄',
` 이어 씀${BELL}`,
),
),
).toEqual([
{ line: 1, reason: 'control-character' },
{ line: 2, reason: 'control-character' },
{ line: 3, reason: 'control-character' },
{ line: 5, reason: 'control-character' },
]);
});
it(`문제는 줄 차례로 앞의 ${RELEASE_NOTE_PROBLEMS_MAX}개까지 — 파일 전체의 문제가 먼저`, () => {
const many = Array.from({ length: 300 }, (_, index) => `맨 글 ${index}`);
const problems = problemsOf(lines('# 제목', ...many));
expect(problems).toHaveLength(RELEASE_NOTE_PROBLEMS_MAX);
expect(problems[0]).toEqual({ line: null, reason: 'no-items' });
expect(problems.slice(1)).toEqual(
Array.from({ length: RELEASE_NOTE_PROBLEMS_MAX - 1 }, (_, index) => ({
line: index + 2,
reason: 'unknown-line',
})),
);
});
it('다 읽은 뒤에 찾는 앞쪽 줄의 문제(1행 제목 길이 · 3행 줄 없는 머리)도 뒤에 줄 문제가 많아 앞의 20개에 든다', () => {
const many = Array.from({ length: 500 }, (_, index) => `맨 글 ${index}`);
const problems = problemsOf(
lines(
`# ${'가'.repeat(RELEASE_NOTE_TITLE_MAX_LENGTH + 1)}`,
'- 줄',
'## 줄 없는 머리',
...many,
),
);
expect(problems).toEqual([
{ line: 1, reason: 'title-too-long' },
{ line: 3, reason: 'heading-without-items' },
...Array.from({ length: RELEASE_NOTE_PROBLEMS_MAX - 2 }, (_, index) => ({
line: index + 4,
reason: 'unknown-line',
})),
]);
});
it('문제를 글로 — 줄 번호 · 까닭 · 설명만 (글 내용은 없다)', () => {
expect(
describeReleaseNoteProblem({ line: 12, reason: 'item-too-long' }),
).toBe('12행 item-too-long(한 줄은 500자까지입니다(이어 붙인 뒤))');
expect(describeReleaseNoteProblem({ line: null, reason: 'encoding' })).toBe(
'파일 encoding(UTF-8 글이 아닙니다)',
);
});
});
describe('파일 이름 · id · 날짜', () => {
it('글 파일 — 이름이 id, 앞 10자가 날짜, 같은 날 둘째부터 번호(2~99)', () => {
expect(classifyReleaseNoteFile('2026-11-18.md')).toEqual({
kind: 'note',
id: '2026-11-18',
date: '2026-11-18',
sequence: 1,
});
expect(classifyReleaseNoteFile('2026-11-18-2.md')).toEqual({
kind: 'note',
id: '2026-11-18-2',
date: '2026-11-18',
sequence: 2,
});
expect(classifyReleaseNoteFile('2026-11-18-99.md')).toMatchObject({
kind: 'note',
sequence: 99,
});
expect(classifyReleaseNoteFile('2024-02-29.md')).toMatchObject({
kind: 'note',
date: '2024-02-29',
});
});
it('쓰는 사람용 안내(README.md)는 글이 아니고 문제도 아니다', () => {
expect(classifyReleaseNoteFile('README.md')).toEqual({ kind: 'readme' });
});
it.each([
'2026-11-18-1.md',
'2026-11-18-0.md',
'2026-11-18-02.md',
'2026-11-18-100.md',
'2026-11-18-2-3.md',
'2026-11-18.MD',
'2026-11-18.txt',
'2026-11-18',
'2026-1-18.md',
'20261118.md',
'readme.md',
'notes.md',
'.gitkeep',
'.DS_Store',
])('글 파일 이름이 아니다: %s', (name) => {
expect(classifyReleaseNoteFile(name)).toEqual({
kind: 'not-a-note',
reason: 'file-name',
});
});
it.each([
'2026-02-30.md',
'2026-02-29.md',
'2100-02-29.md',
'2026-13-01.md',
'2026-00-10.md',
'2026-04-31.md',
'2026-11-00.md',
'0000-01-01.md',
])('없는 날짜: %s', (name) => {
expect(classifyReleaseNoteFile(name)).toEqual({
kind: 'not-a-note',
reason: 'date',
});
});
it('있는 날짜 — 윤년(4로 나누어지되 100은 아니고 400은 맞다)', () => {
expect(isCalendarDate(2024, 2, 29)).toBe(true);
expect(isCalendarDate(2000, 2, 29)).toBe(true);
expect(isCalendarDate(1900, 2, 29)).toBe(false);
expect(isCalendarDate(2026, 12, 31)).toBe(true);
expect(isCalendarDate(2026, 6, 31)).toBe(false);
});
it('경로 id 의 모양 — 맞는 모양만, 32자 넘거나 경로 · 확장자 · 다른 숫자 글자가 섞이면 아니다', () => {
for (const id of ['2026-11-18', '2026-11-18-2', '2026-11-18-42']) {
expect(isReleaseNoteId(id)).toBe(true);
}
for (const id of [
'',
'2026-11-18-1',
'2026-11-18.md',
'../2026-11-18',
'2026-11-18/../x',
'2026-11-18 ',
`2026-11-18${'-2'.repeat(20)}`,
'2026-11-18',
'x'.repeat(33),
]) {
expect(isReleaseNoteId(id)).toBe(false);
}
for (const value of [20261118, null, undefined, ['2026-11-18']]) {
expect(isReleaseNoteId(value)).toBe(false);
}
});
it('차례 — 최근 글부터(날짜 내림차순), 같은 날은 뒤 번호가 먼저', () => {
const notes = [
{ id: '2026-11-03', date: '2026-11-03', sequence: 1 },
{ id: '2026-11-18', date: '2026-11-18', sequence: 1 },
{ id: '2026-11-18-10', date: '2026-11-18', sequence: 10 },
{ id: '2025-12-31', date: '2025-12-31', sequence: 1 },
{ id: '2026-11-18-2', date: '2026-11-18', sequence: 2 },
];
expect([...notes].sort(compareReleaseNotes).map((note) => note.id)).toEqual(
[
'2026-11-18-10',
'2026-11-18-2',
'2026-11-18',
'2026-11-03',
'2025-12-31',
],
);
});
});
describe('parseReleaseNoteText — 입력 길이에 비례', () => {
it('20만 줄(이어 쓰기 · 메모 · 줄)과 빈칸이 많은 1MB 한 줄도 금방 끝난다', () => {
const continuation = Array.from({ length: 200_000 }, () => ' 이어 씀');
const memo = Array.from({ length: 200_000 }, () => '메모 안 글');
const items = Array.from(
{ length: 100_000 },
(_, index) => `- 줄 ${index}`,
);
// 배열로 잇는다 — 수십만 줄을 함수 인자로 펼치면 호출 스택이 넘친다
const text = ['# 제목', '- 첫 줄']
.concat(continuation, ['<!-- 긴 메모'], memo, ['-->'], items, [
`- ${'가 \t'.repeat(300_000)}`,
])
.join('\n');
const cpuMs = startCpuTimer();
const result = parseReleaseNoteText(text);
// 지금 Linux 컨테이너에서 수백 ms — 줄마다 앞의 글을 다시 훑는(제곱) 길로 돌아가면 이 한도를 크게 넘는다
expect(cpuMs()).toBeLessThan(5_000);
expect(result.ok).toBe(false);
});
});
@@ -0,0 +1,448 @@
import {
RELEASE_NOTE_FILE_EXTENSION,
RELEASE_NOTE_FILE_MAX_BYTES,
RELEASE_NOTE_FIRST_SEQUENCE,
RELEASE_NOTE_HEADING_MAX_LENGTH,
RELEASE_NOTE_ID_MAX_LENGTH,
RELEASE_NOTE_ID_PATTERN,
RELEASE_NOTE_ITEM_MAX_LENGTH,
RELEASE_NOTE_PROBLEMS_MAX,
RELEASE_NOTE_TITLE_MAX_LENGTH,
RELEASE_NOTES_README,
} from './release-notes.constants';
import type { ReleaseNoteSection } from './release-notes.types';
/**
* 업데이트 내역 글 파일 해석 (순수 함수 — 계약 7절 "글 파일").
*
* 파일 이름 `YYYY-MM-DD.md`(같은 날 둘째부터 `YYYY-MM-DD-2.md` …)가 글 id · 날짜이고, 파일 안은 마크다운의 아주 작은 일부(글만)다:
* 첫 글 줄 `# 제목`(꼭 — 한 번만), `## 머리`(묶음 시작), `- 한 줄`(그 묶음의 줄 — 머리 없이 쓰면 머리 없는 묶음),
* 빈칸 두 개 이상(또는 탭)으로 시작한 줄은 앞 `- ` 줄에 띄어쓰기 하나로 이어 붙인다. 빈 줄 · `<!-- 메모 -->`(여러 줄도)는 어디든 —
* 메모는 줄 앞에서 시작한다(앞 빈칸은 괜찮다 · 글 뒤에 붙이지 않는다).
* 그 밖의 줄(맨 글 · `###` · 표 · 한 칸 들여 쓴 줄 등)은 틀림. 굵게 · 링크 꼴은 해석하지 않는다(글 그대로).
*
* 틀리면 줄 번호를 단 문제 목록을 돌려준다 — 시험(CI)은 실패하고 서버는 그 글만 건너뛴다. 글 내용은 문제에 싣지 않는다(로그에 남는다).
* 입력 길이에 비례한다 — 줄을 한 번씩만 보고, 정규식은 앞부분만 보거나 한 번 훑는다
*/
/** 문제의 까닭 → 쓰는 사람에게 보일 말 (로그 · 시험 출력 — 글 내용은 넣지 않는다) */
export const RELEASE_NOTE_PROBLEM_TEXTS = {
'file-name':
'글 파일 이름이 아닙니다 — YYYY-MM-DD.md 로, 같은 날 두 번째 글부터 YYYY-MM-DD-2.md 로 씁니다',
'not-a-file': '보통 파일이 아닙니다(폴더 · 링크)',
date: '없는 날짜입니다',
'too-large': `파일이 너무 큽니다(${RELEASE_NOTE_FILE_MAX_BYTES / 1024}KB까지)`,
unreadable: '파일을 읽지 못했습니다',
encoding: 'UTF-8 글이 아닙니다',
'title-missing': '첫 줄은 # 제목이어야 합니다',
'title-again': '# 제목은 첫 줄에 한 번만 씁니다',
'title-empty': '제목이 비었습니다',
'title-too-long': `제목은 ${RELEASE_NOTE_TITLE_MAX_LENGTH}자까지입니다`,
'heading-empty': '묶음 머리가 비었습니다',
'heading-too-long': `묶음 머리는 ${RELEASE_NOTE_HEADING_MAX_LENGTH}자까지입니다`,
'heading-without-items': '묶음 머리 아래에 줄(- )이 없습니다',
'item-empty': '줄이 비었습니다',
'item-too-long': `한 줄은 ${RELEASE_NOTE_ITEM_MAX_LENGTH}자까지입니다(이어 붙인 뒤)`,
'continuation-without-item': '이어 붙일 줄(- )이 앞에 없습니다',
'nested-list': '하위 목록은 쓸 수 없습니다',
'unknown-line':
'쓸 수 없는 줄입니다 — # 제목, ## 머리, - 한 줄, 빈칸 두 개로 시작한 이어 쓰기, <!-- 메모 --> 만 씁니다',
'memo-unclosed': '메모(<!--)를 닫지(-->) 않았습니다',
'memo-trailing': '메모를 닫은(-->) 뒤 같은 줄에 글이 있습니다',
'memo-inside-text':
'글 안에 메모(<!--)가 있습니다 — 메모는 글 뒤에 붙이지 않고 줄 앞에서 시작합니다',
'control-character': '보이지 않는 글자(제어 문자)가 있습니다',
'no-items': '글에 줄(- )이 하나도 없습니다',
} as const;
export type ReleaseNoteProblemReason = keyof typeof RELEASE_NOTE_PROBLEM_TEXTS;
/** 글 파일의 문제 하나 — 줄 번호는 1부터, 파일 전체의 문제(이름 · 크기 · 글자 · 줄 없음 등)면 null */
export interface ReleaseNoteProblem {
readonly line: number | null;
readonly reason: ReleaseNoteProblemReason;
}
// ── 파일 이름 · id ──────────────────────────────────────────────────────
/** `YYYY-MM-DD` 의 길이 — id 의 앞부분이 날짜다 */
const DATE_LENGTH = 'YYYY-MM-DD'.length;
const MONTHS_PER_YEAR = 12;
const DAYS_PER_MONTH = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
const FEBRUARY = 2;
interface ReleaseNoteIdParts {
readonly year: number;
readonly month: number;
readonly day: number;
readonly sequence: number;
}
/** id 를 날짜 · 차례 번호로 — 모양(길이 · 글자)이 틀리면 null. 있는 날짜인지는 보지 않는다 */
function releaseNoteIdParts(id: string): ReleaseNoteIdParts | null {
if (id.length > RELEASE_NOTE_ID_MAX_LENGTH) return null;
const match = RELEASE_NOTE_ID_PATTERN.exec(id);
if (!match) return null;
return {
year: Number(match[1]),
month: Number(match[2]),
day: Number(match[3]),
sequence:
match[4] === undefined ? RELEASE_NOTE_FIRST_SEQUENCE : Number(match[4]),
};
}
/** 경로의 글 id 모양인가 — 길이를 먼저 보고 정규식(앞에서 한 번 훑는다). 맞아도 그런 글이 있는지는 목록이 정한다 */
export function isReleaseNoteId(value: unknown): value is string {
return typeof value === 'string' && releaseNoteIdParts(value) !== null;
}
function isLeapYear(year: number): boolean {
return (year % 4 === 0 && year % 100 !== 0) || year % 400 === 0;
}
/** 있는 날짜인가 (그레고리력 — 2026-02-30 · 2026-13-01 · 0000-01-01 은 없다) */
export function isCalendarDate(
year: number,
month: number,
day: number,
): boolean {
if (!Number.isInteger(year) || year < 1) return false;
if (!Number.isInteger(month) || month < 1 || month > MONTHS_PER_YEAR) {
return false;
}
const days =
month === FEBRUARY && isLeapYear(year)
? DAYS_PER_MONTH[month - 1] + 1
: DAYS_PER_MONTH[month - 1];
return Number.isInteger(day) && day >= 1 && day <= days;
}
/** 글 폴더의 파일 이름을 가른다 — 안내(README) · 글 · 글이 아님(이름 모양 · 없는 날짜) */
export type ReleaseNoteFileKind =
| { readonly kind: 'readme' }
| {
readonly kind: 'note';
readonly id: string;
readonly date: string;
readonly sequence: number;
}
| { readonly kind: 'not-a-note'; readonly reason: 'file-name' | 'date' };
export function classifyReleaseNoteFile(fileName: string): ReleaseNoteFileKind {
if (fileName === RELEASE_NOTES_README) return { kind: 'readme' };
if (!fileName.endsWith(RELEASE_NOTE_FILE_EXTENSION)) {
return { kind: 'not-a-note', reason: 'file-name' };
}
const id = fileName.slice(0, -RELEASE_NOTE_FILE_EXTENSION.length);
const parts = releaseNoteIdParts(id);
if (parts === null) return { kind: 'not-a-note', reason: 'file-name' };
if (!isCalendarDate(parts.year, parts.month, parts.day)) {
return { kind: 'not-a-note', reason: 'date' };
}
return {
kind: 'note',
id,
date: id.slice(0, DATE_LENGTH),
sequence: parts.sequence,
};
}
/** 목록 차례 — 최근 글부터: 날짜 내림차순, 같은 날은 뒤 번호가 먼저(`2026-11-18-2` → `2026-11-18`) */
export function compareReleaseNotes(
a: { readonly date: string; readonly sequence: number },
b: { readonly date: string; readonly sequence: number },
): number {
if (a.date !== b.date) return a.date < b.date ? 1 : -1;
return b.sequence - a.sequence;
}
// ── 파일 안 ─────────────────────────────────────────────────────────────
/** 줄의 꼴 — 앞부분만 본다(빈칸 두 개 · 탭으로 시작하면 이어 쓰기, `###` 은 쓰지 않는 줄) */
const CONTINUATION_LINE = /^(?:\t|[ \t]{2})/;
const DEEPER_HEADING_LINE = /^###/;
const HEADING_LINE = /^##(?:[ \t]|$)/;
const TITLE_LINE = /^#(?:[ \t]|$)/;
const ITEM_LINE = /^-(?:[ \t]|$)/;
const HEADING_MARKER_LENGTH = '##'.length;
const TITLE_MARKER_LENGTH = '#'.length;
const ITEM_MARKER_LENGTH = '-'.length;
const MEMO_START = '<!--';
const MEMO_END = '-->';
/** `<!-->` · `<!--->` 도 닫힌 메모다(HTML 과 같다) — 닫는 표시를 여는 표시의 셋째 글자부터 찾는다 */
const MEMO_END_SEARCH_OFFSET = 2;
/** 보이지 않는 글자 — C0(탭 빼고) · DEL · C1(아래 줄 · 문단 구분 문자와 함께). 화면에 글로 나가지 않게 틀린 글로 본다 */
const CONTROL_CHARACTER =
// eslint-disable-next-line no-control-regex
/[\u0000-\u0008\u000a-\u001f\u007f-\u009f]/;
/**
* 줄 · 문단 구분 문자(U+2028 · U+2029) — 보이지 않는 글자와 같이 본다.
* 글자 코드로 만든다 — 그 글자가 정규식 리터럴에 그대로 들어가면 줄 끝이라 문법 오류다(이스케이프가 풀린 채 저장되면)
*/
const LINE_SEPARATORS = [0x2028, 0x2029].map((code) =>
String.fromCharCode(code),
);
/** 보이지 않는 글자가 있는가 — 한 번씩 훑는다 */
function hasInvisibleCharacter(text: string): boolean {
return (
CONTROL_CHARACTER.test(text) ||
LINE_SEPARATORS.some((separator) => text.includes(separator))
);
}
/** 안의 빈칸 · 탭 여럿 — 하나로 (웹 · 앱이 같은 글을 그리게) */
const INNER_BLANKS = /[ \t]+/g;
/** 글 앞의 BOM(U+FEFF) — 지운다 (UTF-8 풀기가 이미 지우지만 해석만 따로 부를 때도) */
const BYTE_ORDER_MARK = String.fromCharCode(0xfeff);
const CARRIAGE_RETURN = '\r';
type LineKind = 'title' | 'heading' | 'item' | 'continuation' | 'other';
function lineKindOf(line: string): LineKind {
if (CONTINUATION_LINE.test(line)) return 'continuation';
if (DEEPER_HEADING_LINE.test(line)) return 'other';
if (HEADING_LINE.test(line)) return 'heading';
if (TITLE_LINE.test(line)) return 'title';
if (ITEM_LINE.test(line)) return 'item';
return 'other';
}
/** 화면에 나갈 글로 다듬는다 — NFC · 안의 빈칸 · 탭 여럿은 하나로 · 앞뒤 빈칸 지움 */
function tidy(text: string): string {
return text.normalize('NFC').replace(INNER_BLANKS, ' ').trim();
}
interface DraftItem {
readonly line: number;
readonly parts: string[];
}
interface DraftSection {
/** `## 머리` 의 줄 — 머리 없는 묶음은 null */
readonly line: number | null;
readonly heading: string | null;
readonly items: DraftItem[];
}
/** 해석 결과 — 맞으면 제목 · 묶음(다듬은 글), 틀리면 줄 차례의 문제(앞의 RELEASE_NOTE_PROBLEMS_MAX 개) */
export type ReleaseNoteTextParse =
| {
readonly ok: true;
readonly title: string;
readonly sections: readonly ReleaseNoteSection[];
}
| { readonly ok: false; readonly problems: readonly ReleaseNoteProblem[] };
/** 모아 둔 문제 하나와 차례 키(줄 번호 — 파일 전체의 문제는 0) */
interface KeptProblem {
readonly problem: ReleaseNoteProblem;
readonly order: number;
}
/**
* 문제 모음 — 줄 차례(파일 전체의 문제가 먼저 · 같은 줄은 찾은 차례)로 앞의 RELEASE_NOTE_PROBLEMS_MAX 개만 늘 지킨다.
* 다 읽은 뒤에 더하는 앞쪽 줄의 문제(1행 제목 길이 · 머리 아래 줄 없음 등)도 그 자리에 들어가고 뒤의 것이 밀려난다.
* 메모리는 상한만큼 — 쓰레기 파일이어도 일정하다. 상한이 작아(20) 자리를 뒤에서부터 훑어도 문제마다 일정한 비용이다
*/
class ProblemList {
private readonly kept: KeptProblem[] = [];
private count = 0;
/** 문제가 하나라도 있었나 (지킨 것 밖으로 밀려난 것까지) */
get hasProblems(): boolean {
return this.count > 0;
}
add(line: number | null, reason: ReleaseNoteProblemReason): void {
this.count += 1;
const order = line ?? 0;
// 같은 줄은 먼저 찾은 것이 앞 — 새 것은 차례 키가 더 작을 때만 앞으로 간다
let index = this.kept.length;
while (index > 0 && order < this.kept[index - 1].order) index -= 1;
if (index >= RELEASE_NOTE_PROBLEMS_MAX) return;
this.kept.splice(index, 0, { problem: { line, reason }, order });
if (this.kept.length > RELEASE_NOTE_PROBLEMS_MAX) this.kept.pop();
}
/** 글 조각(제목 · 머리 · 줄)에 메모 · 보이지 않는 글자가 없는지 */
checkText(text: string, line: number): void {
if (text.includes(MEMO_START)) this.add(line, 'memo-inside-text');
if (hasInvisibleCharacter(text)) this.add(line, 'control-character');
}
/** 줄 차례로 앞의 RELEASE_NOTE_PROBLEMS_MAX 개 */
sorted(): ReleaseNoteProblem[] {
return this.kept.map((entry) => entry.problem);
}
}
/** 한 줄씩 읽으며 제목 · 묶음 · 줄을 모은다 (다듬기 · 길이는 다 읽은 뒤) */
class ReleaseNoteReader {
readonly problems = new ProblemList();
readonly sections: DraftSection[] = [];
/** 제목 — 첫 글 줄이 `# ` 가 아니면 null */
title: { readonly text: string; readonly line: number } | null = null;
/** 글 줄(빈 줄 · 메모 밖)을 하나라도 보았나 — 첫 글 줄이 제목이어야 한다 */
private sawContent = false;
private section: DraftSection | null = null;
/** 이어 쓰기가 붙을 줄 — 머리 · 제목 줄에서 끊긴다(빈 줄 · 메모는 끊지 않는다) */
private item: DraftItem | null = null;
/** 열린 메모의 시작 줄 */
private memoLine: number | null = null;
read(text: string): void {
const source = text.startsWith(BYTE_ORDER_MARK) ? text.slice(1) : text;
const lines = source.split('\n');
for (let index = 0; index < lines.length; index += 1) {
const raw = lines[index];
const line = raw.endsWith(CARRIAGE_RETURN) ? raw.slice(0, -1) : raw;
this.readLine(line, index + 1);
}
if (this.memoLine !== null)
this.problems.add(this.memoLine, 'memo-unclosed');
if (!this.sawContent) this.problems.add(null, 'title-missing');
}
private readLine(line: string, lineNumber: number): void {
if (this.memoLine !== null) {
const end = line.indexOf(MEMO_END);
if (end < 0) return;
this.memoLine = null;
this.checkAfterMemo(line, end, lineNumber);
return;
}
const trimmed = line.trim();
if (trimmed === '') return;
if (trimmed.startsWith(MEMO_START)) {
const start = line.indexOf(MEMO_START);
const end = line.indexOf(MEMO_END, start + MEMO_END_SEARCH_OFFSET);
if (end < 0) {
this.memoLine = lineNumber;
return;
}
this.checkAfterMemo(line, end, lineNumber);
return;
}
const kind = lineKindOf(line);
if (!this.sawContent) {
this.sawContent = true;
if (kind === 'title') {
const text = line.slice(TITLE_MARKER_LENGTH);
this.problems.checkText(text, lineNumber);
this.title = { text, line: lineNumber };
return;
}
this.problems.add(lineNumber, 'title-missing');
}
switch (kind) {
case 'title':
this.problems.add(lineNumber, 'title-again');
this.item = null;
return;
case 'heading': {
const heading = line.slice(HEADING_MARKER_LENGTH);
this.problems.checkText(heading, lineNumber);
this.section = { line: lineNumber, heading, items: [] };
this.sections.push(this.section);
this.item = null;
return;
}
case 'item': {
const text = line.slice(ITEM_MARKER_LENGTH);
this.problems.checkText(text, lineNumber);
if (this.section === null) {
// 머리 없이 바로 쓴 줄 — 머리 없는 묶음 (글의 맨 앞에만 생긴다)
this.section = { line: null, heading: null, items: [] };
this.sections.push(this.section);
}
this.item = { line: lineNumber, parts: [text.trim()] };
this.section.items.push(this.item);
return;
}
case 'continuation':
if (this.item === null) {
this.problems.add(lineNumber, 'continuation-without-item');
} else if (ITEM_LINE.test(trimmed)) {
// 들여 쓴 `- ` — 마크다운에서는 하위 목록이지만 화면은 한 단계만 그린다
this.problems.add(lineNumber, 'nested-list');
} else {
this.problems.checkText(trimmed, lineNumber);
this.item.parts.push(trimmed);
}
return;
case 'other':
this.problems.add(lineNumber, 'unknown-line');
return;
}
}
/** 메모를 닫은 뒤 같은 줄에는 빈칸만 */
private checkAfterMemo(line: string, end: number, lineNumber: number): void {
if (line.slice(end + MEMO_END.length).trim() !== '') {
this.problems.add(lineNumber, 'memo-trailing');
}
}
}
/**
* 글 파일의 글(UTF-8 을 푼 것)을 해석한다 — 앞의 BOM · 줄 끝의 CR 은 지운다.
* 맞으면 다듬은 제목 · 묶음(파일에 쓴 차례), 틀리면 줄 번호를 단 문제(줄 차례로 앞의 RELEASE_NOTE_PROBLEMS_MAX 개)
*/
export function parseReleaseNoteText(text: string): ReleaseNoteTextParse {
const reader = new ReleaseNoteReader();
reader.read(text);
const { problems } = reader;
let title = '';
if (reader.title !== null) {
title = tidy(reader.title.text);
if (title === '') problems.add(reader.title.line, 'title-empty');
if (title.length > RELEASE_NOTE_TITLE_MAX_LENGTH) {
problems.add(reader.title.line, 'title-too-long');
}
}
let itemCount = 0;
const sections = reader.sections.map((draft): ReleaseNoteSection => {
let heading: string | null = null;
if (draft.heading !== null && draft.line !== null) {
heading = tidy(draft.heading);
if (heading === '') problems.add(draft.line, 'heading-empty');
if (heading.length > RELEASE_NOTE_HEADING_MAX_LENGTH) {
problems.add(draft.line, 'heading-too-long');
}
if (draft.items.length === 0) {
problems.add(draft.line, 'heading-without-items');
}
}
const items = draft.items.map((item) => {
const text = tidy(item.parts.join(' '));
if (text === '') problems.add(item.line, 'item-empty');
if (text.length > RELEASE_NOTE_ITEM_MAX_LENGTH) {
problems.add(item.line, 'item-too-long');
}
return text;
});
itemCount += items.length;
return { heading, items };
});
if (itemCount === 0) problems.add(null, 'no-items');
if (problems.hasProblems) {
return { ok: false, problems: problems.sorted() };
}
return { ok: true, title, sections };
}
/** 문제 하나를 로그 · 시험 출력용 글로 — `12행 item-too-long(한 줄은 500자까지입니다(이어 붙인 뒤))` (글 내용은 없다) */
export function describeReleaseNoteProblem(
problem: ReleaseNoteProblem,
): string {
const where = problem.line === null ? '파일' : `${problem.line}행`;
return `${where} ${problem.reason}(${RELEASE_NOTE_PROBLEM_TEXTS[problem.reason]})`;
}
@@ -0,0 +1,34 @@
import { BusinessException } from '@/common/errors/business.exception';
import { ReleaseNoteIdPipe } from './release-note-id.pipe';
describe('ReleaseNoteIdPipe — 경로의 글 id', () => {
const pipe = new ReleaseNoteIdPipe();
it('글 id 모양이면 그대로', () => {
for (const id of ['2026-11-18', '2026-11-18-2', '2026-11-18-99']) {
expect(pipe.transform(id)).toBe(id);
}
});
it('모양이 틀리면 400 이 아니라 404 RES_001 — 없는 글과 같다(경로로 쓰지 않는다)', () => {
for (const value of [
'',
'latest',
'2026-11-18-1',
'2026-11-18.md',
'../../etc/passwd',
'..%2F2026-11-18',
'x'.repeat(33),
42,
undefined,
]) {
let code: string | undefined;
try {
pipe.transform(value);
} catch (error) {
if (error instanceof BusinessException) code = error.code;
}
expect(code).toBe('RES_001');
}
});
});
@@ -0,0 +1,19 @@
import { Injectable, PipeTransform } from '@nestjs/common';
import { BusinessException } from '@/common/errors/business.exception';
import { isReleaseNoteId } from './release-note-format';
/**
* 경로의 업데이트 내역 글 id (`/api/release-notes/{id}`) — `YYYY-MM-DD` · `YYYY-MM-DD-N`(N 은 2~99) · 32자까지.
* 모양이 틀린 id 도 "그런 글이 없다"와 같으므로 400 이 아니라 404 RES_001 이다(손으로 고친 주소도 화면은 "없는 글").
* 파일 경로로 쓰지 않는다 — 서비스가 읽어 둔 목록에서 찾는다(경로 탈출 없음)
*/
@Injectable()
export class ReleaseNoteIdPipe implements PipeTransform<unknown, string> {
transform(value: unknown): string {
if (isReleaseNoteId(value)) return value;
throw new BusinessException('RES_001', {
context: { reason: 'malformed-release-note-id' },
});
}
}
@@ -0,0 +1,347 @@
import { Logger } from '@nestjs/common';
import {
mkdirSync,
mkdtempSync,
rmSync,
statSync,
symlinkSync,
unlinkSync,
utimesSync,
writeFileSync,
} from 'fs';
import { open } from 'fs/promises';
import { tmpdir } from 'os';
import { join } from 'path';
import { RELEASE_NOTE_FILE_MAX_BYTES } from './release-notes.constants';
import {
ReleaseNotesFolder,
readReleaseNoteFile,
readUpTo,
} from './release-notes-folder';
/** 맞는 글 하나 — 제목 · 줄 */
const note = (title: string, item = '한 줄') => `# ${title}\n- ${item}\n`;
describe('ReleaseNotesFolder — 글 폴더 읽기', () => {
let dir: string;
let errorLog: jest.SpyInstance;
let warnLog: jest.SpyInstance;
const pathOf = (name: string) => join(dir, name);
const write = (name: string, text: string | Buffer) =>
writeFileSync(pathOf(name), text);
/** 바뀐 시각을 옮긴다 (초) — 같은 크기로 고친 파일을 "바뀌었다"고 알리게 */
const touch = (name: string, seconds: number) => {
const { atime, mtime } = statSync(pathOf(name));
utimesSync(
pathOf(name),
atime,
new Date(mtime.getTime() + seconds * 1_000),
);
};
const loggedText = (spy: jest.SpyInstance) =>
spy.mock.calls.map((call: unknown[]) => String(call[0])).join('\n');
beforeEach(() => {
dir = mkdtempSync(join(tmpdir(), 'arasystem-release-notes-'));
errorLog = jest
.spyOn(Logger.prototype, 'error')
.mockImplementation(() => undefined);
warnLog = jest
.spyOn(Logger.prototype, 'warn')
.mockImplementation(() => undefined);
});
afterEach(() => {
jest.restoreAllMocks();
rmSync(dir, { recursive: true, force: true });
});
it('글 파일만 최근 글부터 읽는다 — 쓰는 사람용 안내(README.md)는 건너뛰고 로그도 없다', async () => {
write('README.md', '# 안내\n아무 글이나 써도 된다\n');
write('2026-11-05.md', note('화면의 안내 글을 다듬었습니다'));
write('2026-11-18.md', note('휴대폰 앱이 나왔습니다'));
write('2026-11-18-2.md', note('같은 날 두 번째 글'));
const snapshot = await new ReleaseNotesFolder(dir).read();
expect(snapshot.problems).toEqual([]);
expect(snapshot.notes).toEqual([
{
id: '2026-11-18-2',
date: '2026-11-18',
sequence: 2,
title: '같은 날 두 번째 글',
sections: [{ heading: null, items: ['한 줄'] }],
},
{
id: '2026-11-18',
date: '2026-11-18',
sequence: 1,
title: '휴대폰 앱이 나왔습니다',
sections: [{ heading: null, items: ['한 줄'] }],
},
{
id: '2026-11-05',
date: '2026-11-05',
sequence: 1,
title: '화면의 안내 글을 다듬었습니다',
sections: [{ heading: null, items: ['한 줄'] }],
},
]);
expect(errorLog).not.toHaveBeenCalled();
expect(warnLog).not.toHaveBeenCalled();
});
it('이름 · 크기 · 바뀐 시각이 같으면 앞서 읽은 결과를 쓴다 — 하나라도 바뀌면 그 파일을 다시 읽는다', async () => {
// 바뀐 시각을 초 단위의 같은 값으로 둔다 — 파일 시스템의 ns 시각을 그대로 되돌리지는 못한다
const fixed = new Date('2026-11-18T00:00:00.000Z');
const folder = new ReleaseNotesFolder(dir);
write('2026-11-18.md', note('가나다'));
utimesSync(pathOf('2026-11-18.md'), fixed, fixed);
const first = await folder.read();
// 아무것도 바뀌지 않았다 — 앞의 결과 그대로
expect(await folder.read()).toBe(first);
// 같은 크기 · 같은 바뀐 시각으로 글만 고치면 알아채지 못한다 — 이름 · 크기 · 시각만 본다는 증거
write('2026-11-18.md', note('가나라'));
utimesSync(pathOf('2026-11-18.md'), fixed, fixed);
expect((await folder.read()).notes[0].title).toBe('가나다');
// 바뀐 시각이 다르면 다시 읽는다
touch('2026-11-18.md', 10);
expect((await folder.read()).notes[0].title).toBe('가나라');
// 새 파일 · 지운 파일
write('2026-11-19.md', note('새 글'));
expect((await folder.read()).notes.map((item) => item.id)).toEqual([
'2026-11-19',
'2026-11-18',
]);
unlinkSync(pathOf('2026-11-18.md'));
expect((await folder.read()).notes.map((item) => item.id)).toEqual([
'2026-11-19',
]);
});
it('틀린 글 · 글이 아닌 파일 · 폴더는 건너뛰고 바뀔 때마다 error 로그 한 번 — 파일 이름 · 줄 번호 · 까닭만(글 내용은 없다)', async () => {
const folder = new ReleaseNotesFolder(dir);
write('2026-11-18.md', note('맞는 글'));
write('2026-11-19.md', '# 틀린 글\n비밀스러운 맨 글\n- 한 줄\n');
write('notes.txt', '메모');
write('2026-02-30.md', note('없는 날짜'));
mkdirSync(pathOf('2026-11-20.md'));
const snapshot = await folder.read();
expect(snapshot.notes.map((item) => item.id)).toEqual(['2026-11-18']);
expect(snapshot.problems).toEqual([
{ file: '2026-02-30.md', line: null, reason: 'date' },
{ file: '2026-11-19.md', line: 2, reason: 'unknown-line' },
{ file: '2026-11-20.md', line: null, reason: 'not-a-file' },
{ file: 'notes.txt', line: null, reason: 'file-name' },
]);
expect(errorLog).toHaveBeenCalledTimes(4);
const logged = loggedText(errorLog);
expect(logged).toContain('file="2026-11-19.md"');
expect(logged).toContain('2행 unknown-line');
expect(logged).toContain('[SYS_001]');
expect(logged).not.toContain('비밀스러운');
expect(logged).not.toContain('틀린 글');
// 그대로면 다시 남기지 않는다
await folder.read();
expect(errorLog).toHaveBeenCalledTimes(4);
// 그 파일이 바뀌면 그 파일만 다시 읽고 다시 남긴다
touch('2026-11-19.md', 10);
await folder.read();
expect(errorLog).toHaveBeenCalledTimes(5);
// 고치면 목록에 들어온다
write('2026-11-19.md', note('고친 글'));
touch('2026-11-19.md', 20);
expect((await folder.read()).notes.map((item) => item.id)).toEqual([
'2026-11-19',
'2026-11-18',
]);
expect(errorLog).toHaveBeenCalledTimes(5);
});
it('UTF-8 이 아닌 글은 encoding, 너무 큰 파일은 읽지 않고 too-large', async () => {
// EUC-KR 의 "가"(0xB0 0xA1) — UTF-8 로는 틀린 바이트
write('2026-11-18.md', Buffer.from([0x23, 0x20, 0xb0, 0xa1, 0x0a]));
write(
'2026-11-19.md',
`# 큰 글\n- ${'가'.repeat(RELEASE_NOTE_FILE_MAX_BYTES)}\n`,
);
const snapshot = await new ReleaseNotesFolder(dir).read();
expect(snapshot.notes).toEqual([]);
expect(snapshot.problems).toEqual([
{ file: '2026-11-18.md', line: null, reason: 'encoding' },
{ file: '2026-11-19.md', line: null, reason: 'too-large' },
]);
});
it('폴더가 없으면 빈 목록 + warn 한 번 — 생기면 읽고, 또 없어지면 다시 warn', async () => {
const missing = join(dir, 'release-notes');
const folder = new ReleaseNotesFolder(missing);
expect(await folder.read()).toEqual({ notes: [], problems: [] });
expect(await folder.read()).toEqual({ notes: [], problems: [] });
expect(warnLog).toHaveBeenCalledTimes(1);
expect(loggedText(warnLog)).toContain(missing);
mkdirSync(missing);
writeFileSync(join(missing, '2026-11-18.md'), note('생긴 글'));
expect((await folder.read()).notes.map((item) => item.id)).toEqual([
'2026-11-18',
]);
rmSync(missing, { recursive: true });
expect(await folder.read()).toEqual({ notes: [], problems: [] });
expect(warnLog).toHaveBeenCalledTimes(2);
expect(errorLog).not.toHaveBeenCalled();
});
it('함께 온 요청은 한 번 읽은 결과를 나눈다 — 틀린 글의 로그도 한 번', async () => {
write('2026-11-18.md', note('맞는 글'));
write('2026-11-19.md', '맨 글\n');
const folder = new ReleaseNotesFolder(dir);
const [a, b, c] = await Promise.all([
folder.read(),
folder.read(),
folder.read(),
]);
expect(b).toBe(a);
expect(c).toBe(a);
expect(errorLog).toHaveBeenCalledTimes(1);
});
it('링크로 둔 글 파일은 따라가지 않고 not-a-file (링크를 만들 수 없는 곳에서는 건너뛴다)', async () => {
const outside = mkdtempSync(join(tmpdir(), 'arasystem-release-outside-'));
try {
writeFileSync(join(outside, 'target.md'), note('폴더 밖의 글'));
if (!trySymlink(join(outside, 'target.md'), pathOf('2026-11-21.md'))) {
return;
}
write('2026-11-18.md', note('맞는 글'));
const snapshot = await new ReleaseNotesFolder(dir).read();
expect(snapshot.notes.map((item) => item.id)).toEqual(['2026-11-18']);
expect(snapshot.problems).toEqual([
{ file: '2026-11-21.md', line: null, reason: 'not-a-file' },
]);
expect(await readReleaseNoteFile(pathOf('2026-11-21.md'))).toEqual(
// Windows 에는 O_NOFOLLOW 가 없다 — 그곳에서 링크를 만들 수 있었으면 여기서 따라간다(Linux · CI 가 본다)
process.platform === 'win32'
? expect.objectContaining({ ok: true })
: { ok: false, reason: 'not-a-file' },
);
} finally {
rmSync(outside, { recursive: true, force: true });
}
});
it('기동 때 한 번 읽어 틀린 글을 바로 로그에 남긴다 — 읽지 못해도 던지지 않는다', async () => {
write('2026-11-19.md', '맨 글\n');
const folder = new ReleaseNotesFolder(dir);
await folder.onApplicationBootstrap();
expect(errorLog).toHaveBeenCalledTimes(1);
expect(loggedText(errorLog)).toContain('file="2026-11-19.md"');
// 요청 때는 기동 때 읽은 결과 그대로 — 다시 남기지 않는다
await folder.read();
expect(errorLog).toHaveBeenCalledTimes(1);
jest.spyOn(folder, 'read').mockRejectedValue(new Error('EACCES 흉내'));
await expect(folder.onApplicationBootstrap()).resolves.toBeUndefined();
expect(errorLog).toHaveBeenCalledTimes(2);
expect(loggedText(errorLog)).toContain('기동 때 읽지 못했습니다');
});
});
/** 링크를 만든다 — 권한이 없는 곳(Windows 의 보통 계정)에서는 false */
function trySymlink(target: string, path: string): boolean {
try {
symlinkSync(target, path);
return true;
} catch {
return false;
}
}
describe('readReleaseNoteFile — 한 번 열어 그 파일로만 본다', () => {
let dir: string;
const pathOf = (name: string) => join(dir, name);
beforeEach(() => {
dir = mkdtempSync(join(tmpdir(), 'arasystem-release-file-'));
});
afterEach(() => {
rmSync(dir, { recursive: true, force: true });
});
it('일반 파일은 바이트 그대로', async () => {
writeFileSync(pathOf('2026-11-18.md'), note('글'));
const read = await readReleaseNoteFile(pathOf('2026-11-18.md'));
expect(read).toEqual({ ok: true, bytes: Buffer.from(note('글')) });
});
it('폴더는 not-a-file, 없는 파일은 unreadable', async () => {
mkdirSync(pathOf('2026-11-19.md'));
expect(await readReleaseNoteFile(pathOf('2026-11-19.md'))).toEqual({
ok: false,
reason: 'not-a-file',
});
expect(await readReleaseNoteFile(pathOf('2026-11-20.md'))).toEqual({
ok: false,
reason: 'unreadable',
});
});
it('크기 한도를 넘으면 연 파일의 크기(fstat)로 too-large — 한도 그대로는 읽는다', async () => {
writeFileSync(
pathOf('large.md'),
Buffer.alloc(RELEASE_NOTE_FILE_MAX_BYTES + 1, 0x61),
);
writeFileSync(
pathOf('limit.md'),
Buffer.alloc(RELEASE_NOTE_FILE_MAX_BYTES, 0x61),
);
expect(await readReleaseNoteFile(pathOf('large.md'))).toEqual({
ok: false,
reason: 'too-large',
});
const limit = await readReleaseNoteFile(pathOf('limit.md'));
expect(limit.ok && limit.bytes.length).toBe(RELEASE_NOTE_FILE_MAX_BYTES);
});
it('readUpTo 는 정한 바이트까지만 읽는다 — 그사이 커진 파일도 끝까지 읽지 않는다', async () => {
writeFileSync(
pathOf('grown.md'),
Buffer.alloc(RELEASE_NOTE_FILE_MAX_BYTES * 2, 0x61),
);
const handle = await open(pathOf('grown.md'), 'r');
try {
const bytes = await readUpTo(handle, RELEASE_NOTE_FILE_MAX_BYTES + 1);
expect(bytes.length).toBe(RELEASE_NOTE_FILE_MAX_BYTES + 1);
expect((await readUpTo(handle, 10)).toString()).toBe('a'.repeat(10));
} finally {
await handle.close();
}
});
});
@@ -0,0 +1,333 @@
import { Logger, type OnApplicationBootstrap } from '@nestjs/common';
import { constants } from 'fs';
import { lstat, open, readdir, type FileHandle } from 'fs/promises';
import { join } from 'path';
import { traceTag } from '@/shared/logger/request-context';
import {
classifyReleaseNoteFile,
compareReleaseNotes,
describeReleaseNoteProblem,
parseReleaseNoteText,
type ReleaseNoteProblem,
type ReleaseNoteProblemReason,
} from './release-note-format';
import {
RELEASE_NOTE_FILE_MAX_BYTES,
RELEASE_NOTES_DIR,
} from './release-notes.constants';
import type { ReleaseNote } from './release-notes.types';
/** 건너뛴 파일의 문제 하나 — 파일 이름 · 줄 번호(파일 전체면 null) · 까닭. 글 내용은 없다 */
export interface ReleaseNoteFileProblem {
readonly file: string;
readonly line: number | null;
readonly reason: ReleaseNoteProblemReason;
}
/** 폴더를 읽은 결과 — 글(최근 글부터)과 건너뛴 파일의 문제(파일 이름 차례) */
export interface ReleaseNotesSnapshot {
readonly notes: readonly ReleaseNote[];
readonly problems: readonly ReleaseNoteFileProblem[];
}
const EMPTY_SNAPSHOT: ReleaseNotesSnapshot = Object.freeze({
notes: Object.freeze([]),
problems: Object.freeze([]),
});
/** 폴더에서 본 이름 하나 — 이름 · 보통 파일인지 · 크기 · 바뀐 시각(ms) */
interface FolderEntry {
readonly name: string;
readonly isFile: boolean;
readonly size: number;
readonly mtimeMs: number;
}
/** 이름 하나를 읽은 결과 — 글 · 안내(README — 건너뜀) · 문제 */
type EntryOutcome =
| { readonly kind: 'note'; readonly note: ReleaseNote }
| { readonly kind: 'skipped' }
| {
readonly kind: 'problems';
readonly problems: readonly ReleaseNoteProblem[];
};
interface CachedEntry {
readonly entry: FolderEntry;
readonly outcome: EntryOutcome;
}
/** UTF-8 만 — 틀린 바이트가 있으면 던진다(EUC-KR 로 저장한 글을 깨진 글로 내보내지 않게). 앞의 BOM 은 지운다 */
const UTF8 = new TextDecoder('utf-8', { fatal: true });
/**
* 글 파일 열기 — 링크를 따라가지 않고(O_NOFOLLOW — 그 경로가 링크면 ELOOP), 파이프 · 장치를 열 때 멈추지 않게(O_NONBLOCK — 일반 파일 읽기에는 영향이 없다).
* 일반 파일인지 · 크기는 연 뒤 fstat 으로 본다. Windows 에는 두 표시가 없다(0) — modules/app-releases/apk-file.ts 와 같다
*/
const OPEN_FLAGS =
constants.O_RDONLY |
(constants.O_NOFOLLOW ?? 0) |
(constants.O_NONBLOCK ?? 0);
function errorCodeOf(error: unknown): string | undefined {
return (error as NodeJS.ErrnoException | null)?.code;
}
function reasonOf(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
/** 글 파일 하나를 읽은 결과 — 바이트, 또는 글로 읽지 않는 까닭 */
export type ReleaseNoteFileRead =
| { readonly ok: true; readonly bytes: Buffer }
| {
readonly ok: false;
readonly reason: 'not-a-file' | 'too-large' | 'unreadable';
};
/** 연 파일의 처음부터 `limit` 바이트까지만 읽는다 — 더 길어도 거기서 멈춘다(넘었는지는 부르는 쪽이 길이로 본다) */
export async function readUpTo(
handle: FileHandle,
limit: number,
): Promise<Buffer> {
const buffer = Buffer.alloc(limit);
let filled = 0;
while (filled < limit) {
const { bytesRead } = await handle.read(
buffer,
filled,
limit - filled,
filled,
);
if (bytesRead === 0) break;
filled += bytesRead;
}
return buffer.subarray(0, filled);
}
/** 열지 못한 까닭 — 링크(ELOOP) · 폴더 · 특수 파일은 글이 아니고, 그 밖(권한 · 그사이 지워짐 등)은 읽지 못함 */
function openFailureReason(error: unknown): 'not-a-file' | 'unreadable' {
switch (errorCodeOf(error)) {
case 'ELOOP':
case 'EISDIR':
case 'ENXIO':
return 'not-a-file';
default:
return 'unreadable';
}
}
/**
* 글 파일을 한 번 열어(OPEN_FLAGS) 그 열린 파일로만 본다 — 일반 파일인지 · 크기(fstat), 그리고 크기 한도 + 1 바이트까지만 읽는다.
* 이름을 본(lstat) 뒤 같은 경로가 링크로 바뀌어도 따라가지 않고, 그사이 커진 파일도 끝까지 읽지 않는다
*/
export async function readReleaseNoteFile(
path: string,
): Promise<ReleaseNoteFileRead> {
let handle: FileHandle;
try {
handle = await open(path, OPEN_FLAGS);
} catch (error) {
return { ok: false, reason: openFailureReason(error) };
}
try {
const stats = await handle.stat();
if (!stats.isFile()) return { ok: false, reason: 'not-a-file' };
if (stats.size > RELEASE_NOTE_FILE_MAX_BYTES) {
return { ok: false, reason: 'too-large' };
}
const bytes = await readUpTo(handle, RELEASE_NOTE_FILE_MAX_BYTES + 1);
if (bytes.length > RELEASE_NOTE_FILE_MAX_BYTES) {
return { ok: false, reason: 'too-large' };
}
return { ok: true, bytes };
} catch {
return { ok: false, reason: 'unreadable' };
} finally {
await handle.close();
}
}
function problemsOf(reason: ReleaseNoteProblemReason): EntryOutcome {
return { kind: 'problems', problems: [{ line: null, reason }] };
}
function sameEntry(a: FolderEntry, b: FolderEntry): boolean {
return a.isFile === b.isFile && a.size === b.size && a.mtimeMs === b.mtimeMs;
}
/** 읽어 둔 결과로 글 목록 · 문제를 만든다 — 캐시는 파일 이름 차례로 넣었다 */
function snapshotOf(
cache: ReadonlyMap<string, CachedEntry>,
): ReleaseNotesSnapshot {
const notes: ReleaseNote[] = [];
const problems: ReleaseNoteFileProblem[] = [];
for (const [file, { outcome }] of cache) {
if (outcome.kind === 'note') notes.push(outcome.note);
if (outcome.kind === 'problems') {
for (const { line, reason } of outcome.problems) {
problems.push({ file, line, reason });
}
}
}
notes.sort(compareReleaseNotes);
return { notes, problems };
}
/**
* 업데이트 내역 글 폴더 (계약 7절 "읽기") — 요청 때 폴더를 본다: 파일 이름 · 크기 · 바뀐 시각이 앞과 같으면 앞서 읽은 결과를 쓴다
* (폴더가 통째로 그대로면 앞의 결과 그대로 — 파일 수십 개라 이름 보기 · lstat 비용은 작다). 개발 서버는 다시 띄우지 않아도 새 글이 보이고,
* 운영은 이미지 안의 폴더라 배포 때 바뀐다. 함께 온 요청은 한 번 읽은 결과를 나눈다.
*
* 틀린 글 · 글이 아닌 파일(다른 이름 · 폴더 · 링크)은 건너뛰고 바뀔 때마다 error 로그 한 번(파일 이름 · 줄 번호 · 까닭 — 글 내용은 싣지 않는다).
* 기동은 막지 않는다 — 기동 때 한 번 읽어 틀린 글의 로그가 배포 직후에 보인다. 폴더가 없으면 빈 목록 + warn 한 번.
* 시험(CI)은 실제 폴더를 읽어 문제가 하나도 없어야 한다(release-notes.content.spec.ts).
* 모듈은 useFactory 로 만든다(생성자의 폴더 인자를 DI 가 찾지 않게) — 시험은 임시 폴더를 넘긴다
*/
export class ReleaseNotesFolder implements OnApplicationBootstrap {
private readonly logger = new Logger(ReleaseNotesFolder.name);
private cache = new Map<string, CachedEntry>();
/** 앞서 본 폴더의 모양(이름 · 크기 · 바뀐 시각) — 같으면 앞의 결과를 그대로 */
private signature: string | null = null;
private snapshot: ReleaseNotesSnapshot = EMPTY_SNAPSHOT;
private loading: Promise<ReleaseNotesSnapshot> | null = null;
private warnedMissing = false;
constructor(readonly dir: string = RELEASE_NOTES_DIR) {}
/**
* 기동 때 한 번 읽는다 — 틀린 글의 error 로그가 첫 요청이 아니라 배포 직후의 로그에 보이게.
* 읽지 못해도 던지지 않는다(기동을 막지 않는다 — 요청 때 다시 읽는다)
*/
async onApplicationBootstrap(): Promise<void> {
try {
await this.read();
} catch (error) {
this.logger.error(
`[SYS_001] 업데이트 내역 폴더를 기동 때 읽지 못했습니다 — 요청 때 다시 읽습니다: dir=${this.dir}, reason=${reasonOf(error)}`,
);
}
}
/** 지금 폴더의 글 · 문제 — 읽는 중이면 그 결과를 함께 기다린다 */
read(): Promise<ReleaseNotesSnapshot> {
if (this.loading !== null) return this.loading;
const loading = this.load().finally(() => {
this.loading = null;
});
this.loading = loading;
return loading;
}
private async load(): Promise<ReleaseNotesSnapshot> {
const entries = await this.listEntries();
if (entries === null) return this.folderMissing();
this.warnedMissing = false;
const signature = JSON.stringify(entries);
if (signature === this.signature) return this.snapshot;
const next = new Map<string, CachedEntry>();
for (const entry of entries) {
const cached = this.cache.get(entry.name);
next.set(
entry.name,
cached !== undefined && sameEntry(cached.entry, entry)
? cached
: { entry, outcome: await this.readEntry(entry) },
);
}
this.cache = next;
this.signature = signature;
this.snapshot = snapshotOf(next);
return this.snapshot;
}
/** 폴더의 이름들(이름 차례) · 크기 · 바뀐 시각 — 폴더가 없으면 null. 링크는 따라가지 않는다(lstat) */
private async listEntries(): Promise<FolderEntry[] | null> {
let names: string[];
try {
names = await readdir(this.dir);
} catch (error) {
const code = errorCodeOf(error);
if (code === 'ENOENT' || code === 'ENOTDIR') return null;
throw error;
}
names.sort();
const entries: FolderEntry[] = [];
for (const name of names) {
try {
const stats = await lstat(join(this.dir, name));
entries.push({
name,
isFile: stats.isFile(),
size: stats.size,
mtimeMs: stats.mtimeMs,
});
} catch (error) {
// 이름을 본 뒤 지워졌다 — 없는 것으로
if (errorCodeOf(error) === 'ENOENT') continue;
throw error;
}
}
return entries;
}
/** 바뀐(또는 처음 본) 이름 하나를 읽는다 — 문제가 있으면 여기서 한 번 error 로그 */
private async readEntry(entry: FolderEntry): Promise<EntryOutcome> {
const outcome = await this.readOutcome(entry);
if (outcome.kind === 'problems') {
this.logger.error(
`[SYS_001]${traceTag()} 업데이트 내역 글을 건너뜁니다 — 글 파일을 고쳐 주세요: file=${JSON.stringify(entry.name)}, problems=${outcome.problems.map(describeReleaseNoteProblem).join(' · ')}`,
);
}
return outcome;
}
private async readOutcome(entry: FolderEntry): Promise<EntryOutcome> {
const kind = classifyReleaseNoteFile(entry.name);
if (kind.kind === 'readme') return { kind: 'skipped' };
if (!entry.isFile) return problemsOf('not-a-file');
if (kind.kind === 'not-a-note') return problemsOf(kind.reason);
if (entry.size > RELEASE_NOTE_FILE_MAX_BYTES)
return problemsOf('too-large');
// 이름을 본 뒤 링크로 바뀌었거나 커졌을 수 있다 — 연 파일로 다시 본다
const read = await readReleaseNoteFile(join(this.dir, entry.name));
if (!read.ok) return problemsOf(read.reason);
let text: string;
try {
text = UTF8.decode(read.bytes);
} catch {
return problemsOf('encoding');
}
const parsed = parseReleaseNoteText(text);
if (!parsed.ok) return { kind: 'problems', problems: parsed.problems };
return {
kind: 'note',
note: {
id: kind.id,
date: kind.date,
sequence: kind.sequence,
title: parsed.title,
sections: parsed.sections,
},
};
}
/** 폴더가 없다 — 빈 목록, warn 은 없어질 때마다 한 번 */
private folderMissing(): ReleaseNotesSnapshot {
if (!this.warnedMissing) {
this.warnedMissing = true;
this.logger.warn(
`[SYS_001]${traceTag()} 업데이트 내역 폴더가 없어 빈 목록으로 답합니다: dir=${this.dir}`,
);
}
this.cache = new Map();
this.signature = null;
this.snapshot = EMPTY_SNAPSHOT;
return EMPTY_SNAPSHOT;
}
}
@@ -0,0 +1,54 @@
import { join } from 'path';
/**
* 업데이트 내역 (계약 7절 "업데이트 내역") — 한도 · 형식 · 자리를 한 곳에 모은다.
* 직원이 웹메일 · 휴대폰 앱에서 무엇이 새로 생겼는지 읽는 게시판이고, 글은 운영자가 저장소의 글 파일로 쓴다(올리는 API 는 없다).
* 글자 수는 앞뒤 빈칸을 지운 값의 JS 문자열 길이(UTF-16 코드 단위 — 웹의 `.length` 와 같다)로 센다.
*/
/** 라우트 경로 (`/api/release-notes`) */
export const RELEASE_NOTES_ROUTE = 'release-notes';
/**
* 글 폴더 — 저장소의 `backend/release-notes/`(src 밖). `__dirname` 기준이라 ts(src/modules/release-notes)와
* 빌드 산출물(dist/modules/release-notes) 양쪽에서 같은 곳이다 — 운영 이미지는 `/app/release-notes`(backend/Dockerfile 의 COPY),
* 개발 컨테이너는 backend 를 `/app` 에 바인드 마운트하므로 파일을 더하면 다시 띄우지 않아도 보인다
*/
export const RELEASE_NOTES_DIR = join(
__dirname,
'..',
'..',
'..',
'release-notes',
);
/** 쓰는 사람용 안내 — 글이 아니라 건너뛴다(로그도 남기지 않는다) */
export const RELEASE_NOTES_README = 'README.md';
/** 글 파일의 확장자 — 이름은 `YYYY-MM-DD.md` · 같은 날 둘째부터 `YYYY-MM-DD-2.md` … (이름 = 글 id) */
export const RELEASE_NOTE_FILE_EXTENSION = '.md';
/**
* 글 id — 글 파일 이름에서 확장자를 뗀 것. 같은 날 둘째부터 `-2` … `-99`(`-1` 은 없다 — 첫 글은 번호 없이).
* 경로의 id 도 이 모양(과 길이)만 본다 — 파일 경로로 쓰지 않고 읽어 둔 목록에서 찾는다
*/
export const RELEASE_NOTE_ID_PATTERN =
/^(\d{4})-(\d{2})-(\d{2})(?:-([2-9]|[1-9]\d))?$/;
/** 경로 id 의 길이 한도 — 모양을 보기 전에 본다 */
export const RELEASE_NOTE_ID_MAX_LENGTH = 32;
/** 번호 없는 글(그날 첫 글)의 차례 번호 */
export const RELEASE_NOTE_FIRST_SEQUENCE = 1;
/** 길이 한도 — 제목 100자 · 묶음 머리 40자 · 한 줄 500자 (이어 붙인 뒤) */
export const RELEASE_NOTE_TITLE_MAX_LENGTH = 100;
export const RELEASE_NOTE_HEADING_MAX_LENGTH = 40;
export const RELEASE_NOTE_ITEM_MAX_LENGTH = 500;
/** 글 파일 하나의 크기 한도 — 넘으면 읽지 않고 건너뛴다(글은 몇 KB — 잘못 넣은 큰 파일을 막는다) */
export const RELEASE_NOTE_FILE_MAX_BYTES = 256 * 1024;
/** 글 하나에서 알려 주는 문제 수 — 고치고 다시 보면 나머지가 나온다 (로그 한 줄이 끝없이 길어지지 않게) */
export const RELEASE_NOTE_PROBLEMS_MAX = 20;
/** 목록 커서의 범위 — 거르는 값이 없는 목록 하나 (shared/utils/offset-cursor.ts) */
export const RELEASE_NOTES_CURSOR_SCOPE = 'release-notes';
@@ -0,0 +1,43 @@
import { Logger } from '@nestjs/common';
import { existsSync } from 'fs';
import { join } from 'path';
import { describeReleaseNoteProblem } from './release-note-format';
import {
RELEASE_NOTES_DIR,
RELEASE_NOTES_README,
} from './release-notes.constants';
import { ReleaseNotesFolder } from './release-notes-folder';
/**
* 실제 글 폴더(backend/release-notes)를 엄격하게 읽는다 — 운영자가 더한 글의 오타를 CI 가 잡는다.
* 서버는 틀린 글을 건너뛰고 뜨지만(로그만), 여기서는 틀린 글 · 글이 아닌 파일이 하나라도 있으면 실패한다.
* 실패하면 "파일 이름 줄 번호 까닭(설명)"이 보인다 — 글을 고치는 법은 그 폴더의 README.md
*/
describe('업데이트 내역 글 폴더 (backend/release-notes — 엄격하게)', () => {
beforeEach(() => {
jest.spyOn(Logger.prototype, 'error').mockImplementation(() => undefined);
jest.spyOn(Logger.prototype, 'warn').mockImplementation(() => undefined);
});
afterEach(() => {
jest.restoreAllMocks();
});
it('틀린 글 · 글이 아닌 파일이 하나도 없고 글이 하나 이상 있다', async () => {
const snapshot = await new ReleaseNotesFolder(RELEASE_NOTES_DIR).read();
expect(
snapshot.problems.map(
(problem) => `${problem.file} ${describeReleaseNoteProblem(problem)}`,
),
).toEqual([]);
expect(snapshot.notes.length).toBeGreaterThan(0);
});
it('쓰는 사람용 안내(README.md)가 있다', () => {
expect(existsSync(join(RELEASE_NOTES_DIR, RELEASE_NOTES_README))).toBe(
true,
);
});
});
@@ -0,0 +1,87 @@
import { Controller, Get, Param, Query, UseGuards } from '@nestjs/common';
import {
ApiCookieAuth,
ApiOperation,
ApiParam,
ApiTags,
} from '@nestjs/swagger';
import {
ApiErrorResponse,
ApiSessionErrors,
ApiStandardResponse,
} from '@/common/decorators/api-standard-response.decorator';
import { NoStore } from '@/common/decorators/no-store.decorator';
import {
CursorPaginationQueryDto,
type PaginatedResult,
} from '@/common/dto/pagination.dto';
import { JwtAuthGuard } from '@/common/guards/jwt-auth.guard';
import { ReleaseNoteIdPipe } from './release-note-id.pipe';
import {
RELEASE_NOTE_ID_MAX_LENGTH,
RELEASE_NOTES_ROUTE,
} from './release-notes.constants';
import { ReleaseNotesService } from './release-notes.service';
import type {
ReleaseNoteDetail,
ReleaseNoteSummary,
} from './release-notes.types';
import { ReleaseNoteDto, ReleaseNotePageDto } from './dto/release-note.dto';
/**
* 업데이트 내역 (계약 7절) — 직원이 웹메일 · 휴대폰 앱에서 무엇이 새로 생겼는지 읽는 게시판. 읽기만 있다 —
* 글은 운영자가 저장소의 `backend/release-notes/` 에 글 파일로 쓴다(올리는 API 는 없다).
* 로그인한 직원만(웹 쿠키 · 앱 Bearer — JwtAuthGuard). 제한된 세션(새 비밀번호 · 2단계 켜기 전)은 403 AUTH_010 — `@AllowRestrictedSession()` 을 달지 않는다.
* 요청 수는 기본 한도. 응답은 private, no-store — 글을 고친 배포가 바로 보이게
*/
@ApiTags('ReleaseNotes')
@ApiCookieAuth()
@UseGuards(JwtAuthGuard)
@ApiSessionErrors()
@Controller(RELEASE_NOTES_ROUTE)
export class ReleaseNotesController {
constructor(private readonly releaseNotes: ReleaseNotesService) {}
@Get()
@NoStore()
@ApiOperation({
summary: '업데이트 내역 목록 (커서 페이지네이션 · total 포함)',
description:
'최근 글부터 — 날짜 내림차순, 같은 날은 뒤 번호가 먼저(`2026-11-18-2` → `2026-11-18`). total 은 글 수. ' +
'커서는 위치라 차례 사이에 글을 더하면 다음 쪽에서 한두 개가 겹치거나 빠질 수 있다. 형식이 틀린 글 파일은 건너뛴다(목록에 없다). ' +
'Cache-Control: private, no-store.',
})
@ApiStandardResponse(ReleaseNotePageDto, {
description: '업데이트 내역 목록',
})
@ApiErrorResponse(
'VAL_001',
'잘못된 커서 · limit 범위 밖(1~100) · 모르는 쿼리',
)
list(
@Query() query: CursorPaginationQueryDto,
): Promise<PaginatedResult<ReleaseNoteSummary>> {
return this.releaseNotes.list(query);
}
@Get(':id')
@NoStore()
@ApiOperation({
summary: '업데이트 내역 글 하나 (묶음 · 이전 글 · 다음 글)',
description:
'previous 는 바로 더 옛 글(목록에서 이 글 아래), next 는 바로 더 새 글(목록에서 이 글 위) — 없으면 null. ' +
'글은 서버가 다듬은 글 그대로다 — 마크다운 · HTML 로 해석하지 않고 글로만 그린다. ' +
'없는 글 · 모양이 틀린 id 모두 404 RES_001(400 이 아니다 — 손으로 고친 주소도 "없는 글"). Cache-Control: private, no-store.',
})
@ApiParam({
name: 'id',
description: `글 id — 글 파일 이름(\`YYYY-MM-DD\` · 같은 날 두 번째 글부터 \`YYYY-MM-DD-2\` …, ${RELEASE_NOTE_ID_MAX_LENGTH}자까지)`,
example: '2026-11-18',
})
@ApiStandardResponse(ReleaseNoteDto, { description: '업데이트 내역 글' })
@ApiErrorResponse('RES_001', '없는 글 (모양이 틀린 id 포함)')
get(@Param('id', ReleaseNoteIdPipe) id: string): Promise<ReleaseNoteDetail> {
return this.releaseNotes.get(id);
}
}
@@ -0,0 +1,23 @@
import { Module } from '@nestjs/common';
import { ReleaseNotesController } from './release-notes.controller';
import { ReleaseNotesFolder } from './release-notes-folder';
import { ReleaseNotesService } from './release-notes.service';
/**
* 업데이트 내역 모듈 (계약 7절 — `GET /api/release-notes` · `/api/release-notes/{id}`) — 읽기만.
* 글은 저장소의 `backend/release-notes/`(글 파일 하나가 글 하나 — release-notes.constants.ts 의 RELEASE_NOTES_DIR).
* 폴더는 useFactory 로 만든다 — 시험은 ReleaseNotesFolder 를 임시 폴더로 바꿔 끼운다(env 를 따로 두지 않는다).
* 이 모듈은 다른 기능 모듈을 가져오지 않는다 (DB 표 없음)
*/
@Module({
controllers: [ReleaseNotesController],
providers: [
{
provide: ReleaseNotesFolder,
useFactory: () => new ReleaseNotesFolder(),
},
ReleaseNotesService,
],
})
export class ReleaseNotesModule {}
@@ -0,0 +1,186 @@
import { Logger } from '@nestjs/common';
import { mkdtempSync, rmSync, writeFileSync } from 'fs';
import { tmpdir } from 'os';
import { join } from 'path';
import { CursorPaginationQueryDto } from '@/common/dto/pagination.dto';
import { BusinessException } from '@/common/errors/business.exception';
import { encodeOffsetCursor } from '@/shared/utils/offset-cursor';
import { ReleaseNotesFolder } from './release-notes-folder';
import { ReleaseNotesService } from './release-notes.service';
/** 목록 쿼리 — 전역 파이프가 만든 것처럼(limit 기본 20) */
function queryOf(
values: Partial<Pick<CursorPaginationQueryDto, 'cursor' | 'limit'>> = {},
): CursorPaginationQueryDto {
return Object.assign(new CursorPaginationQueryDto(), values);
}
/** 던진 업무 오류의 코드 */
async function codeOf(work: Promise<unknown>): Promise<string> {
try {
await work;
} catch (error) {
if (error instanceof BusinessException) return error.code;
throw error;
}
throw new Error('던지지 않았다');
}
describe('ReleaseNotesService — 목록 · 글 (실제 폴더를 읽는다)', () => {
let dir: string;
let service: ReleaseNotesService;
const write = (name: string, text: string) =>
writeFileSync(join(dir, name), text);
/** 제목 · 묶음 하나의 글 */
const note = (title: string) =>
`# ${title}\n\n## 묶음\n- ${title}의 첫 줄\n- 둘째 줄\n`;
beforeEach(() => {
dir = mkdtempSync(join(tmpdir(), 'arasystem-release-notes-'));
jest.spyOn(Logger.prototype, 'error').mockImplementation(() => undefined);
jest.spyOn(Logger.prototype, 'warn').mockImplementation(() => undefined);
service = new ReleaseNotesService(new ReleaseNotesFolder(dir));
// 파일을 만든 차례와 상관없이 날짜 · 번호로 줄 세운다
write('2026-11-05.md', note('11월 5일 글'));
write('2026-11-18-2.md', note('18일 두 번째 글'));
write('2026-11-03.md', note('11월 3일 글'));
write('2026-11-18.md', note('18일 첫 글'));
write('2026-11-18-10.md', note('18일 열 번째 글'));
});
afterEach(() => {
jest.restoreAllMocks();
rmSync(dir, { recursive: true, force: true });
});
it('목록은 최근 글부터 — 날짜 내림차순, 같은 날은 뒤 번호가 먼저, 칸은 id · date · title 만, total 은 글 수', async () => {
const page = await service.list(queryOf());
expect(page).toEqual({
items: [
{ id: '2026-11-18-10', date: '2026-11-18', title: '18일 열 번째 글' },
{ id: '2026-11-18-2', date: '2026-11-18', title: '18일 두 번째 글' },
{ id: '2026-11-18', date: '2026-11-18', title: '18일 첫 글' },
{ id: '2026-11-05', date: '2026-11-05', title: '11월 5일 글' },
{ id: '2026-11-03', date: '2026-11-03', title: '11월 3일 글' },
],
pageInfo: { hasNext: false, nextCursor: null, total: 5 },
});
});
it('쪽 나누기 — nextCursor 로 끝까지 받고, 마지막 쪽은 hasNext false · nextCursor null', async () => {
const first = await service.list(queryOf({ limit: 2 }));
expect(first.items.map((item) => item.id)).toEqual([
'2026-11-18-10',
'2026-11-18-2',
]);
expect(first.pageInfo).toMatchObject({ hasNext: true, total: 5 });
expect(typeof first.pageInfo.nextCursor).toBe('string');
const second = await service.list(
queryOf({ limit: 2, cursor: first.pageInfo.nextCursor ?? undefined }),
);
expect(second.items.map((item) => item.id)).toEqual([
'2026-11-18',
'2026-11-05',
]);
const last = await service.list(
queryOf({ limit: 2, cursor: second.pageInfo.nextCursor ?? undefined }),
);
expect(last).toEqual({
items: [{ id: '2026-11-03', date: '2026-11-03', title: '11월 3일 글' }],
pageInfo: { hasNext: false, nextCursor: null, total: 5 },
});
});
it('틀린 커서 · 다른 목록의 커서는 폴더를 읽기 전에 400 VAL_001', async () => {
for (const cursor of [
'not-a-cursor',
'x'.repeat(300),
encodeOffsetCursor(2, 'contacts'),
]) {
expect(await codeOf(service.list(queryOf({ cursor })))).toBe('VAL_001');
}
});
it('글 — 묶음과 previous(바로 더 옛 글) · next(바로 더 새 글), 칸은 계약의 것만(차례 번호 없음)', async () => {
const detail = await service.get('2026-11-18');
expect(detail).toEqual({
id: '2026-11-18',
date: '2026-11-18',
title: '18일 첫 글',
sections: [{ heading: '묶음', items: ['18일 첫 글의 첫 줄', '둘째 줄'] }],
previous: { id: '2026-11-05', date: '2026-11-05', title: '11월 5일 글' },
next: {
id: '2026-11-18-2',
date: '2026-11-18',
title: '18일 두 번째 글',
},
});
expect(Object.keys(detail)).toEqual([
'id',
'date',
'title',
'sections',
'previous',
'next',
]);
});
it('맨 위 글은 next 가 null, 맨 아래 글은 previous 가 null', async () => {
expect(await service.get('2026-11-18-10')).toMatchObject({
previous: { id: '2026-11-18-2' },
next: null,
});
expect(await service.get('2026-11-03')).toMatchObject({
previous: null,
next: { id: '2026-11-05' },
});
});
it('없는 글은 404 RES_001', async () => {
for (const id of ['2026-11-19', '2026-11-18-3', '2026-02-30']) {
expect(await codeOf(service.get(id))).toBe('RES_001');
}
});
it('틀린 글은 목록 · 글에 없다 — 앞뒤 글은 맞는 글끼리 잇는다', async () => {
write('2026-11-10.md', '# 제목만 있는 글\n');
const page = await service.list(queryOf());
expect(page.items.map((item) => item.id)).not.toContain('2026-11-10');
expect(page.pageInfo.total).toBe(5);
expect(await codeOf(service.get('2026-11-10'))).toBe('RES_001');
expect(await service.get('2026-11-18')).toMatchObject({
previous: { id: '2026-11-05' },
});
});
it('글 파일을 더하면 다시 띄우지 않아도 다음 요청이 본다', async () => {
expect((await service.list(queryOf())).pageInfo.total).toBe(5);
write('2026-11-25.md', note('새 글'));
const page = await service.list(queryOf({ limit: 1 }));
expect(page.items).toEqual([
{ id: '2026-11-25', date: '2026-11-25', title: '새 글' },
]);
expect(page.pageInfo.total).toBe(6);
});
it('폴더가 없으면 빈 목록 — 글은 404 RES_001', async () => {
const empty = new ReleaseNotesService(
new ReleaseNotesFolder(join(dir, 'none')),
);
expect(await empty.list(queryOf())).toEqual({
items: [],
pageInfo: { hasNext: false, nextCursor: null, total: 0 },
});
expect(await codeOf(empty.get('2026-11-18'))).toBe('RES_001');
});
});
@@ -0,0 +1,87 @@
import { Injectable } from '@nestjs/common';
import {
type CursorPaginationQueryDto,
type PaginatedResult,
toPaginatedResult,
} from '@/common/dto/pagination.dto';
import { BusinessException } from '@/common/errors/business.exception';
import {
decodeOffsetCursor,
encodeOffsetCursor,
} from '@/shared/utils/offset-cursor';
import { RELEASE_NOTES_CURSOR_SCOPE } from './release-notes.constants';
import { ReleaseNotesFolder } from './release-notes-folder';
import type {
ReleaseNote,
ReleaseNoteDetail,
ReleaseNoteSummary,
} from './release-notes.types';
/** 응답에 싣는 칸만 — 읽어 둔 글의 차례 번호는 싣지 않는다 */
function summaryOf(note: ReleaseNote): ReleaseNoteSummary {
return { id: note.id, date: note.date, title: note.title };
}
/** 커서의 위치 — 없으면 0, 틀리면 400 VAL_001 (폴더를 읽기 전에 본다) */
function cursorOffset(cursor: string | undefined): number {
if (cursor === undefined) return 0;
const reading = decodeOffsetCursor(cursor, RELEASE_NOTES_CURSOR_SCOPE);
if ('problem' in reading) {
throw new BusinessException('VAL_001', {
context: { reason: `invalid-release-note-cursor:${reading.problem}` },
});
}
return reading.offset;
}
/**
* 업데이트 내역 (계약 7절) — 글 폴더(ReleaseNotesFolder)를 읽어 목록 · 글을 준다. 올리고 고치는 길은 없다(운영자가 글 파일을 더해 배포).
* 차례는 최근 글부터(날짜 내림차순, 같은 날은 뒤 번호가 먼저) — previous 는 바로 더 옛 글(목록의 아래), next 는 바로 더 새 글(목록의 위).
* 커서는 위치(offset-cursor) — 차례 사이에 글을 더하면 다음 쪽에서 한두 개가 겹치거나 빠질 수 있다(주소록과 같다)
*/
@Injectable()
export class ReleaseNotesService {
constructor(private readonly folder: ReleaseNotesFolder) {}
/** 목록 한 쪽 — total 은 글 수 */
async list(
query: CursorPaginationQueryDto,
): Promise<PaginatedResult<ReleaseNoteSummary>> {
const start = cursorOffset(query.cursor);
const { notes } = await this.folder.read();
const page = notes.slice(start, start + query.limit).map(summaryOf);
const next = start + page.length;
const hasNext = next < notes.length;
return toPaginatedResult(page, {
hasNext,
nextCursor: hasNext
? encodeOffsetCursor(next, RELEASE_NOTES_CURSOR_SCOPE)
: null,
total: notes.length,
});
}
/** 글 하나와 앞뒤 글 — 없으면 404 RES_001 (id 의 모양은 파이프가 보았다 — 여기서는 읽어 둔 목록에서만 찾는다) */
async get(id: string): Promise<ReleaseNoteDetail> {
const { notes } = await this.folder.read();
const index = notes.findIndex((note) => note.id === id);
if (index < 0) {
throw new BusinessException('RES_001', {
context: { reason: 'release-note-not-found', id },
});
}
const note = notes[index];
return {
id: note.id,
date: note.date,
title: note.title,
sections: note.sections.map((section) => ({
heading: section.heading,
items: [...section.items],
})),
previous: index + 1 < notes.length ? summaryOf(notes[index + 1]) : null,
next: index > 0 ? summaryOf(notes[index - 1]) : null,
};
}
}
@@ -0,0 +1,30 @@
/**
* 업데이트 내역의 모양 (계약 7절) — 응답과 읽어 둔 글이 함께 쓴다.
* `date` 는 달력 날짜 글(`YYYY-MM-DD` — 파일 이름에서)이고 시각이 아니다. 화면은 시간대를 바꾸지 않고 그대로 쓴다
*/
/** 목록의 한 줄 · 글 화면의 다른 글 줄 */
export interface ReleaseNoteSummary {
readonly id: string;
readonly date: string;
readonly title: string;
}
/** 묶음 — 머리(`## 머리`, 머리 없이 쓴 묶음은 null)와 줄(`- 한 줄`, 하나 이상) */
export interface ReleaseNoteSection {
readonly heading: string | null;
readonly items: readonly string[];
}
/** 글 하나 — 응답 (previous = 바로 더 옛 글, next = 바로 더 새 글 — 없으면 null) */
export interface ReleaseNoteDetail extends ReleaseNoteSummary {
readonly sections: readonly ReleaseNoteSection[];
readonly previous: ReleaseNoteSummary | null;
readonly next: ReleaseNoteSummary | null;
}
/** 읽어 둔 글 — 차례 번호(`sequence` — 같은 날 둘째부터 2 …, 번호 없는 글은 1)는 줄 세우기에만 쓰고 응답에 싣지 않는다 */
export interface ReleaseNote extends ReleaseNoteSummary {
readonly sequence: number;
readonly sections: readonly ReleaseNoteSection[];
}
File diff suppressed because it is too large Load Diff
+41 -1
View File
@@ -20,7 +20,8 @@
- 탭마다 이동 기록을 따로 지킨다(메일에서 읽던 화면이 설정 탭에 다녀와도 남는다). 읽기 · 쓰기는 탭 막대 없는 전체 화면이다.
- 여러 통 고르기 중에는 탭 막대를 숨긴다(잠깐의 상태이고, 다른 탭으로 가면 고른 것을 잃는다). ✕ · 뒤로 가기로 나오면 다시 보인다.
- 화면 낭독기 이름은 "앱 메뉴".
- **설정 탭**: 내 정보(이름 · 주소) · 알림(새 메일 알림 켬/끔) · 앱 정보(앱 버전 · [새 버전 받기] · 오픈소스 라이선스) · 문의 연락처 · [로그아웃]. 비밀번호 · 2단계 인증 같은 계정 설정은 웹에서 한다.
- **설정 탭**: 내 정보(이름 · 주소) · 알림(새 메일 알림 켬/끔) · 앱 정보(앱 버전 · 업데이트 내역 · [새 버전 받기] · 오픈소스 라이선스) · 문의 연락처 · [로그아웃]. 비밀번호 · 2단계 인증 같은 계정 설정은 웹에서 한다.
업데이트 내역(2026-11-10 — 아래 「업데이트 내역」)은 앱 버전 다음 줄이고 오픈소스 라이선스 줄과 같은 꼴이다.
[새 버전 받기](2026-11-08 — `App1Push` ④ 앱 정보 절)는 끝 아이콘이 바깥으로 여는 아이콘이고, 업데이트 화면의 단추처럼 포털의 휴대폰 앱 받기 화면을 브라우저로 연다(같은 실패 알림).
[로그아웃]은 데이터를 지우는 일이 아니라 회색 테두리 단추이고, 누르면 확인 창(처음 초점 [취소]) — 로그아웃하면 이 휴대폰의 새 메일 알림도 오지 않는다고 알린다.
@@ -81,6 +82,8 @@
- 짜임: 머리(제목 · 보낸 사람 · 받는 사람 · 시각 · 안내 줄 · 첨부)는 앱 화면으로 본문 위에 두고 남은 높이의 절반까지만 쓴다(넘으면 머리 안에서 스크롤).
본문 WebView 는 나머지를 다 쓰고 그 안에서 스크롤한다 — 스크립트를 꺼서 내용 높이를 잴 수 없다.
**첨부 목록은 본문 위**다(보드는 아래) — 본문이 WebView 안에서 스크롤하므로 아래에 두면 늘 화면 아래를 차지한다.
**본문에 보이는 것이 없으면 본문 칸을 두지 않고** 머리 전체(안내 줄 · 첨부)를 화면 전체에서 한 스크롤로 보인다 — 빈 본문이라는 글은 따로 두지 않는다(2026-11-12 — 첨부만 있는 메일에서 아래 절반이 빈 칸으로 남고 첨부 목록이 위 절반 안에서 잘렸다).
보이는 것은 공백 · 보이지 않는 글자(nbsp · 제로폭 · 방향 표시 · 한글 채움 문자 등)가 아닌 글자, 주소가 남은 그림(외부 그림은 받기 전에도), 가로줄 · 목록 표시 · 펼침 · 따옴표다 — 꾸밈(style · bgcolor · border)만으로 그린 상자 · 선은 치지 않는다. 보일 본문을 고르는 규칙은 그대로다(서식 본문이 비어 보여도 글자 본문으로 바꾸지 않는다 — 웹과 같은 본문).
- 본문 글꼴은 기기 글꼴이다(CSP · 파일 접근 끄기로 WebView 가 Pretendard 를 쓰지 못한다). 크기 15 · 줄 간격 1.75 · 여백 16 / 20 / 24 는 보드대로, 기기 글자 크기 설정을 따르고, 앱이 어두운 테마여도 본문 바탕은 흰색이다.
- 앱이 한 번 더 정화한다(서버 정화 뒤 — 허용 목록으로 다시 쓴다). 길이 2,000,000 · 중첩 비용 8,000,000 을 넘으면 정화하지 않고 글자 본문과 서버의 생략 안내와 같은 글을 보인다.
서식 본문은 6,144자보다 짧고 중첩 비용이 5,000 이하일 때만 화면에서 바로, 그 밖은 다른 isolate 에서 정화한다(개발 PC 에서 바로 하는 것이 2ms 안쪽 — 휴대폰은 몇 배 느리다).
@@ -112,6 +115,7 @@
- 진행 막대의 화면 낭독 값은 숫자만 된다(Flutter 3.41) — 이름("… 받는 중")만 싣고 "받는 중 64%"는 화면 글로만 보인다.
- iOS 를 붙일 때: 파일 열기 채널(지금은 "파일을 열지 못했습니다."), WKWebView 의 링크 길게 누르기 미리 보기 끄기(`setAllowsLinkPreview(false)` — 미리 보기는 판정을 거치지 않고 그 주소를 연다).
- 실기기에서 볼 것: 넓은 표 뉴스레터(가로 스크롤 · 확대), 기기 글자 크기, 링크 · 길게 누르기(WebView 안에서 이동하지 않는지), 같은 그림을 여러 번 쓴 뉴스레터, 2MB 본문을 정화하는 시간,
첨부만 있는 메일(본문 칸 없이 마지막 첨부까지 한 스크롤 · 그 메일에서 [이미지 보기]를 누르면 본문 칸이 생기는지),
첨부(PDF · 오피스 · hwp 가 한컴 뷰어에서 열리는지 · 막는 형식의 알림 · eml 을 어느 앱이 여는지 — 크롬이 MHTML 로 열면 스크립트가 꺼져 있는지), 파일 · content 접근이 꺼졌는지, 폴더 밖 경로는 열지 않는지,
TalkBack 읽는 차례(위 막대 → 머리 → 안내 → 첨부 → 본문 → 아래 막대), 뒤로 가기 몸짓, 로그아웃 뒤 받은 파일이 지워지는지.
@@ -174,11 +178,17 @@
- 전달 원본 · 저장본의 첨부와 대용량 파일은 싣고 빼기만 한다. 문제가 있으면 줄 테두리가 빨갛고 줄 아래에 까닭을 적는다(함께 보낼 수 없음 · 찾지 못함 `MAIL_004` · 올리다 멈춤 · 지워짐 · 위험한 파일).
- 복사본은 뺄 때 · 화면이 끝날 때 · 세션이 끝날 때 · 앱을 띄울 때 · 메일을 보는 직원이 바뀔 때 지운다.
- 글자 크기는 테마 크기로 맞췄다(15.5 → 16 · 14와 14.5 → 15 · 13 → 13 · 12.5 → 12).
- 입력칸(받는 사람 · 제목 · 본문)은 테두리 · 바탕 없이 글만 그리고, 선은 칸 줄의 아래 선(초점이면 청록 2)뿐이다 — 보드대로. 검색 막대의 칸도 테두리 없이 글만 그린다.
앱 테마의 입력칸 테두리를 물려받지 않게 `borderlessInputDecoration`(`core/theme/app_theme.dart`)을 쓴다(2026-11-12 — `InputDecoration.collapsed` 가 테마의 둥근 테두리를 물려받아 칸마다 글자를 바짝 감싼 테두리가 그려지고, 초점이면 칸 줄의 아래 선과 청록 테두리가 겹쳤다).
- 칸 줄(2026-11-12 — 보드에 맞춤): 최소 높이 56 · 여백 위 4 · 오른쪽 4 · 아래 4 · 왼쪽 16, 칸 이름 폭 72 · 글자 15(보드 14) · 칸 이름과 칸 사이 6. 아래 선은 자리를 차지하지 않아 초점으로 굵어져도 칸이 움직이지 않는다.
**받는 사람 칩이 여러 줄이면 칸 이름과 [참조 펼치기]는 첫 칩 줄에 맞춘다**(보드는 한 줄만 그렸다 — 줄 전체의 가운데로 내려가지 않게). 칩 ✕ 는 #4A5363, 제목 · 본문의 안내 글은 보드의 placeholder 색 #8A94A3(검색 칸과 같다).
본문 칸 최소 높이는 원본 메일이 아래에 있으면(답장 · 전달) 150, 없으면(새 메일 — 서명이 있어도) 240 이다(보드의 「새 메일 · 받는 사람 추천」 240 · 「답장 · 참조 펼침 · 첨부 · 원본 접힘」 150).
- **알려진 한계**: 앱이 안 보이게 될 때 칸에 쓰다 만 주소(칩이 되지 않은 글)는 저장에 들지 않고, 나가기 확인 창이 떠 있는 동안은 저장하지 않는다. 앱이 앞에 있는 채로 정리되면 마지막 몇 초를 잃는다.
고르는 동안 앱이 정리되면 고른 파일은 버린다(쓰던 내용은 고르는 창을 열 때 저장된다). 고르는 창에 10분 넘게 머물면 그 고르기는 시한으로 끝난다(알림이 창 뒤에서 뜰 수 있다 — 다시 고른다).
**알림 · 업데이트 화면으로 내려갈 때**(2026-11-07): 새 메일 알림을 누르거나 업데이트 화면으로 옮겨(다른 화면으로 `go`) 쓰기 화면이 내려가면, 쓰다 만 주소까지 임시보관함에 마저 저장하고
"임시보관함에 저장했습니다."(저장하지 못하면 그 코드의 사전 문구)로 알린다. 로그아웃 · 세션 끝 · 제한 단계로 옮겨 내려갈 때는 알리지 않는다(그 세션의 쓰기가 끝났다).
- **실기기에서 볼 것**: 삼성 · Gboard 자판의 칩 만들기(쉼표 · [다음] · 한글 조합 중 확정 · 여러 줄 붙여 넣기), 추천 목록이 자판에 가리는지, 예측 뒤로 가기, 홈 · 다른 앱으로 갔을 때 저장, 긴 원문 펼침 스크롤,
기기 글자 크기를 키웠을 때 칸 이름(숨은참조 — 폭 72 에서 낱말 가운데 꺾임),
서명 그림 폭, TalkBack 차례(칩 · 추천 · 원본 펼침 · 띠), 제스처 내비게이션에서 읽기 화면 알림 자리, 웹 ↔ 앱으로 저장본을 서로 다시 열기, 앱 로그인 서버(feat/app-auth)와 함께 실제 보내기 · 저장 · 서명 그림.
첨부(2차): 고르는 창의 출처마다(내 파일 · Google 드라이브 · 사진 · 다운로드) 이름 · 크기 · 확장자를 주는지, hwp · hwpx 가 창에 보이고 올라가는지 · HEIC 는 `FILE_001` 인지,
20MB 넘는 파일 · 크기를 알려 주지 않는 클라우드 파일이 한도에서 멈추는지, 합계에 걸리는 큰 파일을 내려받지 않고 바로 알리는지, 받는 동안의 진행 막대, 받다 망이 끊길 때(비행기 모드) 가져오기가 끝나는지, 여러 개 · 남은 자리보다 많이 고르기,
@@ -303,5 +313,35 @@
- 2026-11-08 갤럭시 S23(Android 16)에서 본 것: 등록 소식(자동 초기화를 끈 채) · 같은 알림 바꿔 쓰기와 앱으로 돌아올 때 지우기 · 보는 중에는 그리지 않음 · 뒤에 있을 때와 꺼져 있을 때 누르면 받은편지함 · 작은 아이콘(잘 보인다) · 홈 화면 배지 · 바꿔 쓴 알림도 다시 소리를 냄 · 로그아웃 때 FCM 풀기(서버 줄은 서버가 지운다). 서버 쪽은 계약 1절 "실기기 · 실제 키로 확인할 것".
- 알려진 한계(2026-11-08): FID 로 등록하는 길은 Google Play 서비스 26.12(versionCode 261200000) 이상에서만 탄다 — 그보다 옛 휴대폰은 SDK(firebase-messaging 26.0.1)가 옛 토큰 길로 돌아가 등록 소식(`onRegistered`)의 값이 FID 가 아닐 수 있고, 서버는 FID 모양만 받으므로 켜기가 실패할 수 있다(아직 고치지 않았다 — 등록에 드는 매니페스트 표시는 flutter/README "새 메일 알림").
### 업데이트 내역 (`ReleaseNotes` B — 2026-11-10 확정, 캔버스 v80)
직원이 무엇이 새로 생기거나 바뀌었는지 읽는 게시판(목록 → 글)이다. 웹과 같은 글을 보여 준다(서버가 글 파일을 읽어 준다 — 웹 · 앱을 거르지 않는다).
보드는 웹 · 앱을 한 장에 그린 `docs/design/webmail/ReleaseNotes.dc.html` 의 B(앱)이고, 웹 화면은 웹 디자인 README 「업데이트 내역」, 글 파일 · API 는 계약 7절이다.
- **자리**: 설정 탭 › 앱 정보 절의 앱 버전 다음 줄 "업데이트 내역"(52 · 끝에 › — 오픈소스 라이선스 줄과 같은 꼴). 누르면 목록 `/settings/release-notes`, 줄을 누르면 글 `/settings/release-notes/{id}` —
둘 다 탭 막대 없는 전체 화면이고 글은 목록 위에 쌓인다(주소로 바로 열어도 아래에 목록이 있다).
- **목록**(B2): 위 막대 64(뒤로 · "업데이트 내역" 18 · 아래 선). 줄 68(설정의 두 줄 칸과 같다) — 제목 16 · 날짜 13(회색 · 숫자 고른 폭) · ›, 줄 전체를 누른다. 최근 글부터 20개이고
더 있으면 목록 아래 [더 보기](폭 전체 48 · 회색 테두리 — 20개를 넘을 때만). 받는 동안은 그 단추만 바쁨(둥근 진행 표시 18 · 회색 글 · 다시 누르지 못한다),
더 받지 못하면 그 코드의 사전 문구로 알리고 받은 줄은 그대로다.
- **글**(B3 · B4): 위 막대는 뒤로만이고 늘 목록으로 간다. 날짜 13 · 제목 18 · 묶음(머리 15 · 점 목록 15 / 줄 간격 1.6 · 회색 점 5) 차례이고 길면 화면째 스크롤한다.
끝에 다른 글 줄 64 — "다음 글 · 날짜"(더 새 글)가 위, "이전 글 · 날짜"(더 옛 글)가 아래(목록과 같은 차례), 없는 쪽은 줄이 없다. [목록] 단추는 두지 않는다.
- **상태**: 목록 — 불러오는 중은 줄 자리의 막대(B7 — 줄 68 · 제목 막대 13 · 날짜 막대 104 × 10, 끝이 둥글다), 받지 못함은 그 자리에 사전 문구 · 문의 코드 · [다시 시도](B8 — 알림 없음),
글이 없으면 문서 그림 원과 "아직 알려 드릴 업데이트가 없습니다." 한 줄(B9). 글 — 없는 글(지워졌거나 주소가 틀림 — 404 `RES_001`)은 사전 문구 · 문의 코드 · [목록으로](B5 — 알림 없음),
그 밖의 실패는 [다시 시도](B6). 받지 못함 · 없는 글의 제목은 긴 문장이라 15, 글이 없음의 제목은 빈 메일함과 같은 크기(18)다.
- **글 규칙**: 서버가 다듬은 글 그대로 그린다(마크다운 해석 · 링크 · 그림 없음 — 대괄호 글도 꾸미지 않는다). 날짜는 달력 날짜 그대로 "2026년 11월 18일"이다(시각으로 바꾸지 않는다 — 시간대와 상관없이 그날).
새 글 표시(점 · 개수 · NEW) · 번호 · 작성자 · 조회 수는 두지 않는다.
- **구현하며 정한 것**(2026-11-10)
- 받지 못함 · 가운데 상태의 틀과 불러오는 중 막대는 메일 목록에 있던 것을 `shared/widgets/load_states.dart`(`StateLayout` · `LoadErrorView` · `LoadingSkeleton` · `SkeletonBar`)로 옮겨 메일과 함께 쓴다 —
메일 화면의 모양 · 동작은 그대로다. 목록의 막대 줄은 B7 대로 따로 그렸다(메일 목록의 막대 줄에는 사진 원이 있다).
- 글을 받는 동안의 막대(보드에 없음): 날짜 · 제목 · 머리 · 줄 넷 자리를 목록 막대와 같은 결(끝이 둥근 막대)로 잡아 둔다. 화면 낭독기는 "글 불러오는 중"(웹과 같은 글).
- 다른 글 줄을 누르면 지금 글을 그 글로 바꿔 끼운다(쌓지 않는다 — 뒤로는 늘 목록). 새 화면이 들어오는 넘김이고 스크롤은 맨 위부터다.
- 글이 없을 때의 그림은 Material `description_outlined`(접힌 모서리와 글 줄 — 웹 `file-text` 와 같은 꼴, 보드의 "article_outlined 쯤").
- [더 보기]는 받는 동안에도 단추를 그대로 두고 누름만 받지 않는다(초점이 옮겨 가지 않게 — 메일 검색의 [더 찾기]와 같다). 응답의 모양이 틀린 [더 보기](서버 쪽 문제)도 `SYS_001` 로 알린다.
- 화면 낭독기: 목록 줄은 "제목, 날짜", 다른 글 줄은 "다음 글, 날짜, 제목"(가운뎃점 대신 쉼표로 쉰다)을 단추 하나로 읽는다. 받는 동안의 [더 보기]는 꺼진 단추 "더 보기, 불러오는 중"이다
(앱에서 새로 쓴 글 — 보드는 바쁨 표시만). 위 막대의 뒤로는 "뒤로"다.
- 세션이 바뀌면(로그아웃 · 다른 직원) 목록 · 글을 처음부터 받고 앞 세션의 늦은 응답은 버린다. 목록은 화면을 열 때마다 새로 받는다(닫으면 버린다).
- **실기기에서 볼 것**: 큰 글자에서 줄 높이 · 점 위치 · [더 보기], TalkBack 차례(위 막대 → 날짜 → 제목 → 머리 → 줄 → 다른 글 줄)와 [더 보기]를 받는 동안 읽기,
다른 글로 옮길 때의 넘김 · 뒤로 가기 몸짓, 업데이트 내역 서버(feat/release-notes)와 함께 실제 글(첫 글의 날짜는 배포 날로 고친다).
### 처음 판에서 미룬 것
서식 편집 · 대용량 첨부 · 필터 · 주소록 화면 · 서명 고치기 · 계정 설정(웹에서) · 오프라인 저장 · iOS. 검색은 결과에서 여러 통 고르기를 미뤘다(읽기 화면의 같은 대화 · 목록 대화로 묶기도 — 보드 먼저).
File diff suppressed because it is too large Load Diff
+363 -3
View File
@@ -229,6 +229,24 @@
받지 않는 곳에 놓으면 브라우저가 그 파일을 열어 쓰던 메일을 잃기 때문이다 (2026-10-27 구현하며 정함).
- 이미지를 첨부로 돌렸을 때만 성공 알림 "이미지는 본문 대신 첨부 파일로 올렸습니다."를 띄운다(성공 알림과 같은 모양 · 5초).
### 받는 사람이 많을 때 · 낮은 화면 — 스크롤 (2026-11-10 사용자 보고 — 캔버스에는 없다)
전 직원 공지를 숨은참조로 쓰자 칸이 늘어 쓰기 칸이 넘쳤고, 바깥 스크롤 + 본문 스크롤의 이중 스크롤 속에 맨 아래 [보내기] 줄이 화면 밖으로 숨었다
(본문 위에서 휠을 돌리면 본문만 움직였다). 모양 · 크기는 그대로 두고 아래처럼 고쳤다.
- **[보내기] 줄은 늘 보인다** — 스크롤하는 칸(넓은 화면은 쓰기 칸, 좁은 화면은 문서)의 아래에 붙는다. 위 선 · 흰 바탕 · 여백은 그대로이고 넘칠 때만 내용 위에 뜬다.
[보내기 ▾] · [서명 ▾] 메뉴는 위로 연다. 겹침은 툴바 < [보내기] 줄 < 받는 사람 추천 목록 · 툴바의 메뉴(연 동안)다.
- **받는 사람 · 참조 · 숨은참조는 칩이 세 줄을 넘으면 그 칸 안에서 스크롤한다** — 보이는 높이 96: 맨 위에서는 칩 세 줄, 맨 아래(입력 자리)에서는
칩 두 줄 + 입력 줄로 줄 사이에서 끊겨 반쯤 잘린 칩이 보이지 않는다. 초점이 오거나 · 칩이 늘거나 · 글을 치면 맨 아래로 간다.
칩의 ✕ 로 지우면 초점은 입력칸으로 가되 칸의 자리는 그대로다(가운데 칩을 이어 지운다). 추천 목록은 칸 밖(줄 아래)에 뜬다.
- **넓은 화면(1120 이상)은 본문 편집 영역이 300 에서 200 까지 먼저 줄어든다** — 보통 높이(1366×768 · 1440×900)에서는 숨은참조 30명이어도
쓰기 칸 전체가 화면 안에 들고 본문만 스크롤한다(1366×768 에서 편집 영역 226). 그래도 넘치면(첨부가 많음 · 아주 낮은 창) 쓰기 칸이 스크롤하고
[보내기] 줄은 아래에 붙어 있다. 좁은 화면은 300 그대로이고 문서가 스크롤한다.
- [보내기] 줄에 가린 칸으로 키보드 · 코드의 초점이 오면 가린 만큼 올린다(WCAG 2.4.11 — 좁은 화면에서 Tab 으로 간 [파일 첨부]가 가렸다).
마우스 · 손가락으로 누른 칸은 그대로다 — 누른 채 내용이 움직이면 글이 골라진다.
- 넓은 화면에서 문서까지 168px 스크롤하던 것(화면 낭독 전용 글이 앱 틀 밖에 놓였다)도 고쳤다 — 쓰기 칸이 위치를 잡는다.
- 알려진 한계: 휴대폰 폭(360)에서는 [보내기] 줄이 세 줄(보내기 · 임시 저장 / 서명 · 저장 상태 / 보내는 사람 — 161px)로 화면의 1/5 을 덮는다 — 나중에 다듬는다.
## 2단계 — 임시 저장 · 서명 · 받는 사람 자동 완성 (2026-10-27 확정 — `Settings` · `ComposeAssist`)
### 임시 저장
@@ -302,6 +320,11 @@ API 와 서버 규칙은 계약 2절 "새 메일 확인" · "검색" · "여러
- **목록 보기 표**: 맨 앞에 고르기 칸(32 — 별 칸과 같은 밀도 예외). 상자 16px — 비었을 때 테두리 #8A94A3 1.5px, 고르면 #0E7C86 채움에 흰 체크.
머리의 상자는 불러온 메일 모두 고르기 · 풀기(일부만 골랐으면 – 표시). 고른 행 바탕 #E8F4F5(마우스를 올리면 #DDEFF0).
칸 = 고르기 32 · 별 32 · 보낸 사람 168 · 제목(나머지) · 날짜 84 · 크기 64 (칸 사이 12). 좁아질 때는 지금 규칙 그대로(1100 미만 크기 칸 숨김 · 600 미만 보낸 사람 112 · 날짜 72).
행 어디를 눌러도 메일을 열되 **고르기 칸 · 별 칸은 어디를 눌러도 열지 않는다** — 고르기 칸은 빈자리(단추 밖)를 눌러도 상자를 누른 것과 같이 고르고
(Shift 는 범위 · 고를 수 없는 줄이면 아무 일도 없다), 별 칸은 별 단추만 — 별 칸의 빈자리는 아무 일도 없다
(2026-11-10 사용자 보고 — 고르기 상자 가운데를 누르면 고르면서 메일까지 열렸다: 고르자 상자 그림이 다시 그려져 문서에서 떨어진 탓.
줄은 사건이 지나간 길로 가르고 상자 · 별 그림은 누름을 받지 않는다 — 대화로 묶은 표도 같다). Ctrl(⌘) · Shift + 클릭은 칸의 빈자리에서도 지금처럼 고른다.
분할 보기는 고르기 칸(36)이 줄의 링크 밖이라 이 탈이 없었다(같은 시험으로 확인).
- **분할 보기**: 행에 마우스를 올리거나 초점이 가면 왼쪽 36px 에 고르기 상자가 나타나고, 하나라도 고르면 모든 행에 보인다.
행을 누르면 지금처럼 메일을 연다. Ctrl(⌘)+클릭은 하나씩, Shift+클릭은 범위, Esc 는 선택 풀기 (두 보기 공통).
여러 통을 고르는 동안 본문 칸은 열어 둔 메일을 그대로 보인다.
@@ -337,7 +360,7 @@ API 와 서버 규칙은 계약 1절 "`PUT /api/auth/password`", 2절 "메일함
- [x] 부재중 자동 응답 사용(44). 끄면 아래 칸을 흐리게(쓴 글은 남긴다).
- 기간: (•) 끌 때까지 계속 / ( ) 기간 정하기 → 시작 · 끝 날짜 칸(44). "시작하는 날 0시부터 끝나는 날 밤 12시까지 답합니다. 시각은 이 PC의 시간으로 셉니다."
- 제목(44, 최대 폭 520) · 내용(여러 줄, 글자만 — "서식과 그림 없이 글자만 보내고, 서명은 붙지 않습니다.").
- 제목(44, 최대 폭 520) · 내용(여러 줄, 글자만 — "서식과 그림 없이 글자만 보내고, 서명은 붙지 않습니다."). — **16단계부터 내용 칸은 서식 편집기**(아래 16단계 절).
- [저장] → "저장했습니다. 10월 30일(금)부터 11월 3일(화)까지 자동으로 답합니다." / "끌 때까지 자동으로 답합니다." / 끄면 "자동 응답을 껐고, 써 둔 글은 그대로 남아 있습니다."
- 미리 보기 "보낸 사람이 받는 답장"(보낸 사람 · 제목 · 내용) + "같은 사람에게는 매번 보내지 않습니다."
- 켜져 있는 동안 메일 화면의 메일함 패널 아래(사용량 위)에 안내(바탕 #FFF7E6 · #7A5200, 높이 44 이상): "부재중 자동 응답 켜짐" / "11월 3일까지 · 설정" — 누르면 이 설정으로.
@@ -750,7 +773,7 @@ API 와 서버 규칙은 계약 2절 "실시간 알림".
- **꺼져 있을 때** 상자 아래:
- 갈색 경고 "켜면 메일 프로그램은 앱 비밀번호로 다시 연결해야 합니다" / "Outlook, 썬더버드, 휴대폰 메일 앱은 웹메일 비밀번호로 새로 연결할 수 없고, 이미 연결해 둔 프로그램도 다음에 다시 연결할 때 막힙니다. 켜기 전에 앱 비밀번호를 만들어 두면 편합니다."(앱 비밀번호 링크)
- 「켜는 방법」 1 · 2 · 3(청록 굵은 숫자 — 동그라미 없음): 인증 앱 설치(Google · Microsoft Authenticator 처럼 6자리 코드를 만드는 앱) → [2단계 인증 켜기]를 눌러 QR 찍기 → 코드와 지금 비밀번호를 넣으면 켜진다.
- **켜져 있을 때** 상자 아래: "휴대폰을 바꿀 때는 2단계 인증을 끈 뒤 새 휴대폰으로 다시 켜 주세요. 끌 때 지금 휴대폰의 코드가 필요합니다." · "휴대폰을 잃어버렸거나 인증 앱을 지웠으면 관리자에게 문의해 주세요." · "메일 프로그램은 앱 비밀번호로 연결합니다."
- **켜져 있을 때** 상자 아래: "휴대폰을 바꿀 때는 2단계 인증을 끈 뒤 새 휴대폰으로 다시 켜 주세요. 끌 때 지금 휴대폰의 코드가 필요합니다."(**16단계부터 [휴대폰 바꾸기] — 첫 줄은 16단계 절의 글**) · "휴대폰을 잃어버렸거나 인증 앱을 지웠으면 관리자에게 문의해 주세요." · "메일 프로그램은 앱 비밀번호로 연결합니다."
- **켜기 창**(모달 620 — `wide`, 필터 고치기 창과 같은 폭 · 처음 초점은 코드 칸)
- ① "인증 앱으로 QR 코드를 찍어 주세요" / "인증 앱에서 [+] → [QR 코드 스캔]을 고른 뒤 이 그림을 찍어 주세요." + QR 그림(160 — 실제 otpauth 주소는 오류 정정 M 에서 49칸이라 보드의 128 보다 키웠다)
- "찍을 수 없으면 이 키를 입력해 주세요" + 32자 키(4자씩 띄움, 고정폭) + [복사](36 — 「키를 복사했습니다.」) + "앱에는 ‘ARA SYSTEM (ara.kim@arasystem.it.kr)’로 저장됩니다."
@@ -894,7 +917,7 @@ API 와 서버 규칙은 계약 2절 "실시간 알림".
- 유예 중 · 꺼짐 — 머리 아래 갈색 상자(방패): **"11월 30일까지 2단계 인증을 켜 주세요"** / "회사 정책에 따라 그 뒤에는 2단계 인증을 켜야 로그인할 수 있습니다."
그 아래 상태 상자 · 경고 · 켜는 방법 · 켜기 창은 12단계 그대로.
- 켜짐 — 상태 상자의 [2단계 인증 끄기] 대신 회색 글 "회사 정책에 따라 끌 수 없습니다"(서버도 `VAL_001` 로 거절).
아래 안내의 첫 줄은 "휴대폰을 바꿀 때는 관리자에게 2단계 인증을 풀어 달라고 요청해 주세요. 다음에 로그인할 때 새 휴대폰으로 다시 켜면 됩니다."(나머지 두 줄은 12단계 그대로).
아래 안내의 첫 줄은 "휴대폰을 바꿀 때는 관리자에게 2단계 인증을 풀어 달라고 요청해 주세요. 다음에 로그인할 때 새 휴대폰으로 다시 켜면 됩니다."(**16단계부터 이 직원도 [휴대폰 바꾸기] — 첫 줄은 16단계 절의 글**)(나머지 두 줄은 12단계 그대로).
- **휴대폰을 바꿀 때**(필수인 직원): 관리자에게 「2단계 해제」를 요청한다(운영 도구 — 받은편지함 알림 메일이 간다) → 다음 로그인 때 이 켜기 단계로 새 휴대폰을 등록한다.
직원이 직접 하는 휴대폰 바꾸기는 미뤘다(아래).
@@ -1272,6 +1295,234 @@ PC 에서는 설정 › 계정 › 휴대폰 앱(A)이 QR 코드로 휴대폰에
메일함을 끌어 옮기기 · 순서 바꾸기(옮기기 창으로), 차단할 때 옮긴 메일의 한꺼번에 되돌리기, 예약됨 목록 줄의 단추, 옮기기 단축키, 휴대폰 앱의 [지금 보내기].
## 16단계 — 개인 설정 (2026-11-10 확정 — `Contacts16` · `Signature16` · `Settings16`)
직원이 혼자 할 수 있는 설정을 넓힌다 — 주소록 가져오기 · 내보내기, 여러 서명과 쓰기 화면 [서명 ▾], 부재중 응답의 서식 본문과 회사 밖 응답, 2단계 인증 휴대폰 바꾸기.
서버 약속은 계약 2절("서명" · "부재중 자동 응답") · 3절(주소록) · 5절("2단계 인증")이다. 글의 원본은 화면 상수(`shared/constants/contacts.ts` · `mail-signature.ts` · `mail-vacation.ts` · `account-security.ts`)이고 아래는 옮겨 적은 것이다.
새 크기 토큰 · 밀도 예외는 없다 — [사내 | 회사 밖] 미리 보기 전환 36 은 서명 미리 보기의 `.segmented`(`--size-segment-item-lg`)를 다시 쓴다. 새 아이콘은 upload 하나다. 문구는 위 「화면 문구 — 사용자에게 말하듯」을 따른다.
### 주소록 가져오기 · 내보내기 (`Contacts16`)
다른 메일이나 휴대폰에서 내보낸 연락처 파일을 주소록에 넣고, 내 주소록을 파일로 내려받는다. 서버 약속은 계약 3절("가져오기 · 내보내기").
새 크기 토큰 · 밀도 예외는 없다(머리 52 · 단추 44 그대로). 새 아이콘은 올리기(`upload`) 하나다.
- **자리**: 「전체 연락처」 목록 머리 오른쪽에 [가져오기] · [내보내기](도구 단추 — 그룹 머리의 [구성원 넣기]와 같은 꼴). 그룹 · 사내 목록의 머리는 그대로다(그룹 하나만 내보낼 때는 내보내기 창에서 고른다).
- 연락처가 하나도 없으면 [내보내기]를 끄고(`aria-disabled`) 까닭 "내보낼 연락처가 없습니다."(마우스를 올리면 뜨는 글 · 화면 낭독).
- 연락처가 3,000개면 [가져오기]도 끄고 패널의 3,000개 안내를 까닭으로 쓴다. 가져오는 동안(되돌리기 포함)은 [가져오기]가 바쁜 표시(`aria-busy`)이고 눌러도 아무 일이 없다.
- 빈 주소록 안내: [연락처 추가] 옆에 테두리 단추 [가져오기], 그 아래 "다른 메일이나 휴대폰에서 내보낸 연락처 파일이 있으면 [가져오기]로 한 번에 넣을 수 있습니다." + 지금 줄("메일을 읽다가 …").
- **가져오기 창 ① 파일 고르기**(넓은 창 620): 제목 "연락처 가져오기" · "네이버, Google, Outlook 같은 메일이나 휴대폰에서 내보낸 연락처 파일을 그대로 넣을 수 있습니다."
- 놓는 자리(168 · 회색 점선): 올리기 아이콘 · [파일 고르기](처음 초점 — .vcf · .csv · .xlsx 만 보이게 걸러 둔 파일 고르는 창) · "파일을 여기로 끌어다 놓아도 됩니다."
- 창 어디에 끌어다 놓아도 받는다 — 끄는 동안 자리가 2px 청록 점선 · 옅은 청록 바탕, "놓으면 이 파일에서 연락처를 찾습니다". 창 밖 주소록 화면에 놓은 파일은 지금처럼 무시한다.
- 읽는 동안: 도는 아이콘 · "파일을 읽는 중…" · 파일 이름. 그사이 [취소]는 읽기를 멈추고 창을 닫는다.
화면 낭독기에는 창을 열 때부터 있는 상태 영역(`role=status` — 글만 바꾼다)이 같은 글을 읽는다(글과 함께 생기는 영역은 읽지 않기도 한다).
읽다가 뜻밖에 멈추면(깨진 파일 등) "읽는 중"에 머물지 않고 "열지 못했습니다" 글로 돌아온다.
- 아래 "vCard(.vcf), CSV(.csv), 엑셀(.xlsx) 파일을 넣을 수 있습니다. 파일 하나는 20MB까지입니다." · [취소].
- 읽지 못하면 자리 아래 빨간 글로만(알림 없음) — 다른 파일을 고르거나 놓으면 지운다:
- 넣을 수 없는 형식 "넣을 수 없는 파일입니다. vCard(.vcf), CSV(.csv), 엑셀(.xlsx) 파일을 골라 주세요."
- 옛 엑셀 "옛 엑셀 파일(.xls)은 넣을 수 없습니다. 엑셀에서 [다른 이름으로 저장]을 눌러 .xlsx나 CSV 파일로 저장한 뒤 넣어 주세요."
- 20MB 넘음 "파일이 너무 큽니다. 파일 하나는 20MB까지 넣을 수 있습니다."(읽지 않고 바로)
- 연락처를 찾지 못함 "이 파일에서 연락처를 찾지 못했습니다. 이름이나 이메일이 든 열이 있는지 확인해 주세요."(vCard 는 앞 문장만)
- 열지 못함 "이 파일을 열지 못했습니다. 암호를 건 엑셀 파일이면 암호를 푼 뒤 넣어 주세요."
- 여러 파일 "파일은 한 번에 하나만 넣을 수 있습니다."
- 형식은 이름 끝과 파일 앞부분을 함께 본다 — 이름이 .csv 여도 안이 엑셀이면 엑셀로, 안이 옛 엑셀(.xls)이면 .xls 글.
- **가져오기 창 ② 미리 보기**: 파일 이름 + [다른 파일 고르기](파일 고르는 창을 바로 연다 — 고른 그룹과 체크 칸은 그대로) · "연락처 340명을 찾았습니다."(넣지 못하는 줄까지 센 수).
- 세 묶음(테두리 상자 — 글과 숫자로만, 뱃지 없음):
- "넣을 연락처 · 330명" — 앞 6명의 표(이름 64 · 이메일 · 전화 96 · 회사 · 그룹 76) + "그 밖에 324명".
- "이미 있는 연락처는 건너뜁니다 · 8명" — 눌러 펼친다(`aria-expanded`). 줄마다 "4번째 줄 · 박지훈 jihoon.park@example.com · 주소록에 있음"(또는 "2번째 줄과 같음").
- "넣지 못하는 줄 · 2개" — 눌러 펼친다. "57번째 줄 · 010-9034-6621 · 이름과 이메일이 모두 없습니다."(까닭은 갈색).
- 펼친 목록은 6줄 높이(186)까지 늘고 넘으면 목록 안에서 넘긴다. 줄 번호는 엑셀에서 보이는 행 번호(머리 줄이 1번째 줄), vCard 는 "12번째 연락처"이고 묶음 이름이 "넣지 못하는 연락처"다.
- 그룹: "파일에 적힌 그룹 4개 가운데 거래처, 세무는 이미 있는 그룹에 넣고 동호회, 학교는 새로 만듭니다."(이름이 많으면 앞 셋과 "외 N개", 없으면 "파일에 적힌 그룹이 없습니다.").
- 「가져온 연락처를 모두 넣을 그룹」: 넣지 않음(처음) · 이미 있는 그룹(고르기 칸 280×44 — "거래처 · 4명") · 새 그룹(이름 칸 280×44 · 50자) + "파일에 적힌 그룹에 더해 넣습니다."
새 그룹 이름이 이미 있는 그룹과 같으면 그 그룹에 넣는다. 이름을 비운 채 넣으려 하면 칸 아래 "그룹 이름을 넣어 주세요." 그룹이 100개면 「새 그룹」을 끄고 "그룹은 100개까지 만들 수 있습니다."
- 체크 칸 「주소, 생일, 홈페이지, 별명처럼 주소록에 칸이 없는 정보는 메모 끝에 붙이기」(처음 켜짐) + "메모가 1,000자를 넘게 되면 붙이지 않습니다." —
켜 두면 견본 "예: 강민지의 메모"(원래 메모는 흐리게 · 빈 줄 · "가져온 정보" · "회사 주소: 서울 중구 세종대로 110" · "생일: 1988년 3월 14일 (음력)" · "홈페이지: …" · "별명: …").
- 남은 자리보다 많으면 노란 상자 "연락처는 3,000개까지 둘 수 있어 앞에서부터 188명만 넣습니다. 나머지 142명은 넣지 않습니다." — 단추의 수도 그만큼.
- [연락처 330명 넣기](처음 초점) [취소]. 넣을 연락처가 없으면 [연락처 넣기]를 끄고(`aria-disabled`) 단추 왼쪽에 "넣을 연락처가 없습니다."
- Esc · [취소]는 묻지 않고 닫는다(아직 아무것도 넣지 않았다). 창이 화면보다 길면 창 안이 스크롤하고 단추 줄은 고정하지 않는다(필터 창과 같다).
창 안의 칸은 줄어들지 않는다 — 세 묶음 상자(둥근 모서리 때문에 `overflow: hidden`)는 `flex-shrink: 0`. 창(`<dialog>`)이 flex 열이라 overflow 를 건 칸은 내용보다 작게 눌릴 수 있다
(2026-11-10 실사용 시험 — 1366×768 · 1440×900 에서 상자가 눌려 [이미 있는 연락처는 건너뜁니다] · "그 밖에 N명"이 안 보이고 창도 스크롤하지 않았다). 내보내기 창도 같다.
- **넣는 동안 · 끝난 뒤**(알림 — 필터 적용 · 차단 정리와 같은 결): [연락처 N명 넣기]를 누르면 창을 닫고 진행 알림 "연락처를 넣는 중 · 120 / 330명"(넣은 수 / 넣을 수 — 아래 「요청 묶음」 하나가 끝날 때마다 고친다).
- 끝: 같은 자리에서 "연락처 330명을 넣었습니다. 이미 있던 8명은 건너뛰었습니다." + [되돌리기] + ✕ — **✕로 닫을 때까지 남는다**(저절로 닫히지 않는다).
건너뛴 수 · 한도에 걸린 수는 한 문장씩 덧붙인다 — "연락처는 3,000개까지 둘 수 있어 나머지 142명은 넣지 못했습니다." · "그룹은 100개, 그룹 하나에는 500명까지 둘 수 있어 24명은 그룹에 넣지 못했습니다."
- [되돌리기]: "가져온 연락처를 지우는 중 · 100 / 330명" → "가져온 연락처 330명과 새로 만든 그룹 2개를 지웠습니다."(새 그룹이 없으면 "가져온 연락처 330명을 지웠습니다.").
이번에 넣은 연락처와 이번에 새로 만든 그룹만 지운다 — 원래 있던 그룹에 넣은 것은 연락처를 지우며 함께 빠진다. 되돌리다 멈추면 "가져온 연락처 330명 가운데 100명을 지우고 멈췄습니다. 다시 시도해 주세요." + [다시 시도].
- 멈춤: 흰 오류 알림 "연락처 330명 가운데 120명을 넣고 멈췄습니다. 다시 시도해 주세요." + [다시 시도](남은 연락처부터 — 줄마다 화면이 붙인 표시(uid)가 있어 두 번 들어가지 않는다),
처음부터 넣지 못했으면 "연락처를 넣지 못했습니다. 다시 시도해 주세요." 멈춘 알림에는 [되돌리기]가 없다 — 다시 시도해 끝낸 뒤의 [되돌리기]가 이번에 넣은 연락처를 모두 지운다.
알려진 한계: 멈춘 알림을 다시 시도하지 않고 닫으면 그때까지 넣은 연락처는 남는다(같은 파일을 다시 넣으면 이미 있는 연락처로 건너뛴다).
- 넣는 동안 다른 화면으로 가도 이어 넣는다. 탭을 닫거나 로그아웃하면 멈추고 알림을 닫는다. 끝나거나 멈추면 연락처 수 · 그룹 목록 · 보고 있는 목록을 다시 받는다.
실패 까닭(연결 · 서버)은 지금처럼 오류 사전의 글로 알리고 진행 알림은 결과로만 바뀐다.
- **내보내기 창**(400 — 새 그룹 창과 같은 폭): 제목 "연락처 내보내기".
- 「내보낼 연락처」 내 주소록 전체("연락처 342명" — 처음) · 그룹 하나("고른 그룹의 구성원만" — 고르면 그룹 고르기 칸 44, 첫 그룹이 골라진다, "거래처 · 24명") + "내 주소록의 연락처만 내보내고 사내 목록은 넣지 않습니다."
- 「파일 형식」 vCard (.vcf) "다른 메일이나 휴대폰으로 옮길 때"(처음) · CSV (.csv) "엑셀에서 열어 볼 때". 고른 상자는 청록 테두리 · 옅은 청록 바탕.
- "파일 이름 주소록-2026-11-10.vcf"(그룹이면 "주소록-거래처-2026-11-10.csv" — 날짜는 이 PC 의 날짜, 파일 이름에 쓸 수 없는 글자는 _).
- 구성원이 없는 그룹을 고르면 [내보내기]를 끄고(`aria-disabled`) 그룹 칸 아래 "‘협력사 담당’ 그룹에는 아직 아무도 없어 내보낼 연락처가 없습니다." — 다른 그룹을 고르면 바로 돌아온다.
- [내보내기] [취소] — 누르면 창을 닫고 바로 내려받는다(첨부 받기처럼 이동 직전에 로그인을 확인, 파일은 서버가 만든다). 따로 알림은 띄우지 않는다.
- **받는 파일 · 칸 맞추기**: vCard 2.1 · 3.0 · 4.0(한 파일에 여러 명), CSV(네이버 · Google 새 · 옛 머리 · Outlook · 우리가 내보낸 꼴을 머리 줄로 알아보고, 모르는 꼴은 열 이름으로 짐작), 엑셀 .xlsx(첫 시트 · 첫 줄이 머리).
CSV 글자는 BOM, UTF-8, 아니면 EUC-KR(한국어 엑셀의 CSV) 차례. 성 + 이름은 이름 하나로(한글끼리는 붙여 쓴다 — 강 + 민지 = 강민지), 이메일과 전화는 종류대로(팩스와 직접 입력한 번호는 기타),
전화는 하이픈을 넣는다(010-1234-5678). 이미 있는 연락처는 같은 이메일(앞뒤 공백 · 대소문자 무시), 이메일이 없으면 이름과 전화번호가 모두 같을 때다.
### 설정 › 메일 › 서명 — 여러 서명 (`Signature16` 1~5)
서버 약속은 계약 2절 "서명"(16단계 — `/api/mail-signatures` · `/api/mail-signature-defaults`)이다. 글의 원본은 화면 상수(`shared/constants/mail-signature.ts`)이고 아래는 옮겨 적은 것이다.
새 크기 토큰 · 밀도 예외는 없다 — 목록 줄 52 는 `--size-header-min`, 칸 · 단추 44 는 보통 크기다.
- **배치**: 머리(제목 "서명" · 설명) → 「메일에 넣을 서명」(서명이 있을 때만) → 왼쪽 「내 서명」 목록(208) · 오른쪽 고른 서명 고치기(이름 · 서명 칸 · 안내 · [저장] … [이 서명 지우기] · 가는 선 · 미리 보기). 콘텐츠 열은 다른 설정 화면과 같은 800.
목록과 고치는 칸을 담은 칸이 640보다 좁으면 목록을 위로 올려 칸 폭을 쓰게 하고 고치는 칸을 그 아래에 둔다 — 창 폭이 아니라 그 칸의 폭을 본다(컨테이너 쿼리).
설명은 "보내는 메일 끝에 넣는 글입니다. 여러 개 만들어 두면 메일을 쓸 때 [서명]에서 골라 바꿀 수 있고, 그 메일에서만 고치거나 지울 수도 있습니다."
- **목록**: 만든 차례 · 줄 52 · 고른 줄은 메일함 패널의 지금 메일함처럼 청록 바탕 · 굵은 글자(aria-current). 이름 아래 회색 12 는 그 서명을 기본으로 넣는 메일("새 메일" · "답장과 전달" · "새 메일, 답장과 전달" — 뱃지 없음).
목록 아래 [새 서명](글자 단추 44) · "서명 3개를 만들었고, 10개까지 만들 수 있습니다." — 10개면 [새 서명]은 누를 수 없고(aria-disabled — 초점은 받는다) 글이
"서명은 10개까지 만들 수 있습니다. 쓰지 않는 서명을 지워 주세요."로 바뀐다(단추가 aria-describedby 로 가리킨다). 화면 낭독기는 목록을 「내 서명」으로, 고른 줄을 지금 항목으로 읽는다.
- **처음 고른 서명**: 새 메일에 넣는 서명, 새 메일이 넣지 않음이면 첫 서명.
- **메일에 넣을 서명**(지금의 두 체크를 대신한다): 칸 이름 84 · 고르기 240(서명 이름들 · 넣지 않음) · 아래 "메일을 쓰기 시작할 때 본문에 넣어 둡니다. 고르는 대로 바로 저장됩니다."
고르는 대로 저장하고, 끝나면 고른 칸 오른쪽에 초록 ✓ "저장했습니다"(role=status) — 다시 고르면 내린다. 저장은 한 번에 하나(보내기 · 읽기 설정과 같은 결 — 보내는 동안 고른 것은 끝난 뒤 다르면 보낸다),
끝내 저장하지 못하면 고르기 전 값으로 되돌리고 오류 알림. 고른 것은 나가기 확인의 까닭이 아니다.
- **이름**: 칸 320(좁으면 칸 폭) · 30자에서 더 들어가지 않는다 · 아래 "메일을 쓸 때 [서명]에서 고르는 이름입니다. 30자까지 쓸 수 있습니다."
[저장]할 때 비었으면(앞뒤 공백을 뺀다) "이름을 넣어 주세요.", 다른 서명과 같으면(영문 대소문자 무시) "같은 이름의 서명이 있습니다." — 안내 대신 빨간 글 · 칸 빨간 테두리(aria-invalid) · 이름 칸에 초점, 알림은 없다.
이름을 고치면 안내로 돌아간다. 저장하면 이름은 앞뒤 공백을 뺀 글로 바뀐다.
- **[저장] 뒤 글**: 기본으로 넣는 서명이면 지금 글 "저장했습니다. 다음에 메일을 쓸 때부터 들어갑니다.", 아니면 "저장했습니다." — 무엇이든 다시 바꾸면 내린다.
- **편집기 · 그림 · 미리 보기**는 지금 서명 화면 그대로(그림 서명마다 3장 · 크기 줄 · 화면 어디에 놓은 그림은 지금 고른 서명에). 미리 보기는 고른 서명이 [새 메일 | 답장]에 들어갈 자리를 늘 보인다 —
아래 글 "새 메일은 본문 끝에 서명을 넣습니다." · "답장과 전달에는 인용한 원문 위에 서명을 넣습니다."(그 메일에 넣을지는 「메일에 넣을 서명」이 정한다).
- **서명이 없을 때**(2): 점선 상자 안 펜 아이콘 36 · "아직 서명이 없습니다" · "보내는 메일 끝에 넣을 이름, 소속, 연락처를 만들어 두면 메일을 쓸 때마다 본문에 넣어 둡니다. 사내용과 대외용처럼 여러 개를 만들어 골라 쓸 수도 있습니다."(최대 400) ·
[새 서명](채운 단추). 「메일에 넣을 서명」 칸과 목록은 없다(고를 서명이 없다). 지금 서명을 쓰던 직원은 서버가 그 서명을 「기본 서명」으로 옮겨 두어 1의 모습으로 시작한다.
- **새 서명**(3): 목록 끝에 "새 서명 / 저장하지 않음" 줄 · 빈 이름 칸(안 글 "예: 사내, 대외, 영문")에 초점 · 빈 서명 칸 · [저장] [취소]. 처음 [저장]에 만들고 그 뒤로는 같은 칸에서 고친다(쓰던 자리 · 실행 취소 기록이 남는다).
첫 서명이면 두 곳의 기본이 그 서명이 된다(서버가 정한다). 그 밖의 새 서명은 기본을 바꾸지 않는다. [취소]는 새 줄을 빼고 전에 고른 서명을 연다(서명이 없었으면 2의 화면). 새 서명을 연 채 [새 서명]을 누르면 이름 칸으로 간다.
- **지우기**(4): [이 서명 지우기](빨간 글자 단추 · [저장]과 떨어뜨려 오른쪽 끝) → 확인 창(폭 360 · 처음 초점 [취소] · Esc 도 [취소]) "‘대외’ 서명을 지울까요?" +
기본이던 곳마다 다른 글(새 메일 · 답장과 전달 · 둘 다 · 기본 아님 "지운 서명은 되돌릴 수 없습니다.") · [지우기](빨강 채움) [취소].
지우면 알림 "‘영문’ 서명을 지웠습니다." · 목록의 첫 서명을 연다(초점은 그 줄) — 마지막 서명이면 2의 화면(초점은 [새 서명]). 그 서명을 기본으로 넣던 곳은 넣지 않음이 되고 다른 서명이 저절로 기본이 되지 않는다.
- **저장하지 않은 것이 있을 때**(5): 다른 서명을 고르면 "다른 서명을 고르면 ‘대외’ 서명에서 바꾼 내용이 사라집니다.", [새 서명]이면 "새 서명을 만들면 ‘대외’ 서명에서 바꾼 내용이 사라집니다."
— 제목 "저장하지 않은 서명이 있습니다" · [계속 고치기](처음 초점 · Esc) [바꾼 내용 버리기]. 바꾼 것이 없으면 묻지 않는다.
다른 화면으로 갈 때는 지금 나가기 확인 그대로("이 화면을 나가면 바꾼 서명이 사라집니다." · [계속 고치기] [나가기] — 저장하는 중 · 세션이 끝났을 때는 묻지 않고, 탭을 닫으면 브라우저가 묻는다).
### 쓰기 화면 [서명 ▾] (`Signature16` 6)
- **자리**: 아래 단추 줄 [보내기 ▾] [임시 저장] **[서명 ▾]** — 서명은 서식이 아니라 메일 전체에 붙는 것이라 편집기 툴바(좁은 폭에서 접는 차례)에 넣지 않았다. [임시 저장]과 같은 글자 단추(44)에 ▾ 12.
화면 낭독은 툴바의 글꼴 단추처럼 지금 서명까지 — "서명: 대외" · "서명: 넣지 않음"(본문의 서명 자리가 어느 서명과도 같지 않으면 "서명"만). 편집기를 불러오는 중 · 보내는 중에는 누를 수 없다.
- **메뉴**(편집기 메뉴 — 항목 40 · 폭 220, 긴 이름은 끝을 …): 서명(만든 차례 · 지금 본문에 든 서명에 ✓ · "기본" 표시 없음) · 넣지 않음 · 가는 선 · 서명 관리. 서명이 없으면 넣지 않음과 서명 관리만.
키보드는 [보내기 ▾]와 같다 — Enter · Space · 아래 화살표로 열면 첫 항목에 초점(마우스로 열면 메뉴 자체), Esc 는 메뉴만 닫고 [서명 ▾]로. 아래 자리가 모자라면 위로 연다(아래 줄에서는 보통 위).
- **고르면** 본문의 서명 자리만 그 서명으로 바뀐다 — 쓴 글 · 인용 · 첨부는 그대로이고, 서명 안을 고쳤다면 고친 것도 새 서명으로 바뀐다. 실행 취소(Ctrl+Z)로 되돌린다. 바꾸면 화면 낭독기에 "서명을 ‘사내’로 바꿨습니다."(알림 없음).
넣지 않음은 서명 자리와 그 앞의 빈 줄을 뺀다. 서명 자리를 지운 뒤 서명을 고르면 처음 넣는 자리(새 메일은 쓴 글 끝, 답장과 전달은 인용 위)에 빈 줄 하나 뒤로 넣는다.
- **처음 넣는 서명**: 새 메일은 「새 메일」 기본, 답장 · 전달은 「답장과 전달」 기본(넣지 않음이면 넣지 않는다). 이어 쓰는 임시 저장본에는 다시 넣지 않는다 — 서명 자리의 글이 저장한 서명과 같으면 그 서명에 ✓, 다르면 ✓ 없이.
- **서명 관리**: 설정 › 메일 › 서명으로 — 지금 나가기 규칙대로 먼저 임시 저장하고 저장하지 못했을 때만 묻는다.
- 받는 사람이 회사 밖이어도 저절로 바꾸지 않는다(처음 판 — 직원이 고르는 대로).
### 설정 › 메일 › 부재중 자동 응답 — 서식 본문 · 회사 밖 응답 (`Settings16` 1~4)
- 사용 · 기간 · 제목 칸 · [저장] · 저장한 뒤의 글은 4단계 그대로다.
- **내용 칸은 서명 칸과 같은 편집기**(지연 로딩 — 못 받으면 서명 설정과 같은 사전 문구 + [다시 시도]): 툴바 52 · 굵게 · 기울임 · 밑줄 | 글자 색 · 링크(단추 40×44 — 그림 단추 없음, 붙여 넣은 그림 · 파일은 받지 않는다) ·
본문 맑은 고딕 14 · 줄 간격 1.75 · 높이 150. 툴바의 화면 낭독 이름 "자동 응답 서식". 칸 아래 안내 "굵게, 기울임, 밑줄, 글자 색, 링크를 쓸 수 있습니다. 그림은 넣을 수 없고, 서명은 붙지 않습니다."
- 글자 수는 칸 이름 오른쪽 "132 / 600"(12 · 회색 — 주소록 메모와 같은 자리). 서식을 뺀 보이는 글로 센다(받는 쪽의 글자 본문과 같은 글 — 링크는 "글 (주소)"). 켜 둔 채 600자를 넘으면 굵게 빨갛게.
- 서식(굵게 · 기울임 · 밑줄 · 글자 색 · 링크)을 쓴 칸만 받는 사람에게 서식 본문과 글자 본문을 함께 보내고, 글만 쓴 칸은 글자 본문만 보낸다(서식 본문 null — 사내 · 회사 밖 칸 따로, 끄면서 하는 저장도 같다).
서버는 서식 본문 · 회사 밖 응답을 쓴 직원만 새 원본 판으로 저장한다 — 글만 쓰는 직원은 옛 판 그대로라 되돌리기 계획의 전제가 지켜진다(16단계 리뷰).
16단계 전에 저장한 글만 있는 응답은 줄마다 문단으로 열린다(빈 줄은 빈 문단).
- **회사 밖에서 온 메일**(내용 칸과 [저장] 사이 · fieldset): "보낸 사람 주소가 회사 메일 주소(@arasystem.it.kr)가 아닌 메일입니다."(도메인은 내 주소의 것) + 고르기 셋 —
보내기 · 읽기 설정의 외부 이미지 칸과 같은 모양(라디오 18 · 이름 굵게 · 아래 설명 12.5 · 줄 44):
사내와 같은 응답("회사 안팎 모두 위의 제목과 내용으로 답합니다." — 처음 값) · 보내지 않기("회사 밖에서 온 메일에는 자동으로 답하지 않습니다.") · 따로 쓰기("회사 밖에서 온 메일에 보낼 제목과 내용을 따로 씁니다.").
칸이 열리는 따로 쓰기가 맨 끝이다.
- 따로 쓰기를 고르면 바로 아래(고르기 글자 선 28 에 맞춰 들여 씀 · 사이 18)에 "회사 밖에 보낼 제목"(최대 폭 520 · 150자) · "회사 밖에 보낼 내용"(같은 편집기 · 툴바 이름 "회사 밖 자동 응답 서식" · 같은 글자 수 · 아래 안내 없음).
위의 제목 · 내용은 이때부터 사내에서 온 메일에만 간다.
- 다른 고르기로 바꾸면 두 칸을 닫지만 써 둔 글은 남겨 두고 함께 저장한다 — 다시 따로 쓰기를 고르면 그대로 나온다.
- 자동 응답을 끄면 이 묶음도 흐려진다(고르기 글 회색 · 칸은 다른 칸처럼).
- **미리 보기**: 머리 오른쪽 전환 [사내 | 회사 밖](36 · 화면 낭독 이름 "미리 볼 답장" — 처음은 사내 쪽, 사내와 같은 응답일 때도 둔다). 칸을 고친 값을 받는 사람이 보는 서식 그대로 바로 그린다.
아래 글: 사내와 같은 응답 — 사내 쪽 "받은 메일의 보낸 사람에게 위 글로 답합니다. 같은 사람에게는 매번 보내지 않습니다."(지금 글) · 회사 밖 쪽 "회사 밖에서 온 메일에도 같은 글로 답합니다. …" /
보내지 않기 · 따로 쓰기 — 사내 쪽 "사내에서 온 메일에 위 글로 답합니다. …" · 따로 쓰기의 회사 밖 쪽 "회사 밖에서 온 메일에 위 글로 답합니다. …"(제목 · 내용은 회사 밖에 보낼 것) /
보내지 않기의 회사 밖 쪽은 답장 대신 회색 상자(최소 높이 120) "회사 밖에서 온 메일에는 자동으로 답하지 않습니다." · 아래 글 없음 / 꺼 두었으면 두 쪽 모두 "꺼져 있어 자동으로 답하지 않습니다."
- **켤 때 검사**(켠 채 [저장]일 때만 — 꺼 둔 채로는 빈 칸 · 긴 글도 저장한다): 보는 차례 기간 · 제목 · 내용 · 회사 밖에 보낼 제목 · 회사 밖에 보낼 내용, 첫 칸으로 초점. 알림은 띄우지 않는다.
- 빈 칸은 사전 문구(`VAL_001`). 내용이 600자를 넘으면 칸 아래 "내용은 600자까지 쓸 수 있습니다." + 빨간 글자 수(회사 밖에 보낼 내용 칸도 같은 글).
- 글은 600자 안인데 서식이 많아 서식 본문이 10,000자를 넘으면 사전 문구(`VAL_001` — 서명과 같은 한도).
- 편집기 칸은 빨간 테두리와 aria-invalid(설명은 오류 → 안내 차례). 그 칸을 고치기 시작하면 아래 글이 사라진다.
- 회사 밖 두 칸은 따로 쓰기일 때만 본다 — 사내와 같은 응답 · 보내지 않기면 비어 있어도 저장한다. 서버가 거절하면 지금처럼 [저장] 옆에 사전 문구.
- 서버가 `MAIL_013`(필터 · 차단 · 부재중을 담는 메일 규칙이 한도를 넘음)으로 거절하면 같은 자리에 "자동 응답이 너무 깁니다. 서식이나 내용을 줄여 주세요."(알림 없음) —
부재중은 서식 본문이 두 번 들어가 필터가 없어도 넘을 수 있어 사전 문구("필터나 차단 목록이 …") 대신 이 글이다(16단계 리뷰). 저장하는 사이 화면을 떠났으면 같은 글을 알림으로.
### 설정 › 계정 › 2단계 인증 — 휴대폰 바꾸기 (`Settings16` 5~8)
- **켜져 있을 때** 상태 상자의 [2단계 인증 끄기] 바로 앞에 [휴대폰 바꾸기](테두리 44 · 아이콘 없음). 회사 정책으로 끌 수 없는 직원도 "회사 정책에 따라 끌 수 없습니다" 앞 같은 자리에 있다. 꺼져 있을 때는 없다(켜기 창으로).
아래 안내의 첫 줄은 두 직원 모두 "휴대폰을 바꿀 때는 [휴대폰 바꾸기]를 눌러 주세요. 새 휴대폰과 지금 휴대폰의 코드가 모두 필요합니다."(둘째 · 셋째 줄은 그대로).
- **바꾸기 창**(모달 620 — 켜기 창과 같은 폭) "2단계 인증 휴대폰 바꾸기" · 제목 아래 차례 "새 휴대폰 등록 › 지금 휴대폰으로 확인"(12.5 · 글로만 — 지금 걸음 청록 굵게 · 마친 걸음 앞 초록 체크 · 남은 걸음 회색, 뱃지 · 동그라미 숫자 없음).
- **① 새 휴대폰 등록**: "새 휴대폰을 등록하고 지금 휴대폰으로 확인하면 바뀝니다. 바꾸기를 마칠 때까지는 지금 휴대폰의 코드로 로그인합니다." →
1 "새 휴대폰의 인증 앱으로 QR 코드를 찍어 주세요"(QR 160 · 키 4자씩 두 줄 · [복사] 36 — 켜기 창과 같은 부품) + "바꾸지 않고 닫으면 이 키는 더 쓸 수 없습니다. 새 휴대폰의 인증 앱에 먼저 추가했다면 앱에서 그 계정을 지워 주세요." /
2 "새 휴대폰의 앱에 표시된 6자리 코드" → [다음] [취소]. 처음 초점은 코드 칸. 키는 창을 열 때마다 새로 만들고 QR · 키는 이 창 안에만 두었다가 닫으면 지운다.
[다음]은 새 휴대폰의 코드부터 확인한다(확인하는 동안 "확인하는 중…") — 맞으면 ②. 그동안 2단계 인증은 지금 휴대폰으로 그대로 켜져 있다.
- **② 지금 휴대폰으로 확인**: "새 휴대폰을 확인했습니다. 지금 휴대폰의 코드와 비밀번호를 넣으면 바뀌고, 그 뒤로는 새 휴대폰의 코드로만 로그인합니다." →
"지금 휴대폰의 앱에 표시된 6자리 코드"(처음 초점) · "지금 비밀번호"(최대 320 · 보기 단추) → 위에 선을 둔 문의 줄 "지금 휴대폰을 쓸 수 없나요? 관리자에게 2단계 인증을 풀어 달라고 요청한 뒤, 새 휴대폰으로 다시 켜 주세요."
(로그인 화면의 문의 줄과 같은 꼴 — 연락처가 있으면 다음 줄에) → [이전](왼쪽 · ‹) · [바꾸기] [취소]. 바꾸는 동안 "바꾸는 중…" · [바꾸기] · [이전] aria-disabled.
[이전]은 ①로(QR · 키는 그대로 · 새 휴대폰의 코드 칸은 비운다 — 같은 코드는 2분 동안 다시 받지 않으니 앱의 다음 코드를 넣는다).
- **틀렸을 때**(알림 없이 그 칸 아래 사전 문구 · 그 칸에 초점 · 틀린 코드는 지운다): ① 새 휴대폰의 코드 `AUTH_006` / ② 지금 비밀번호 `AUTH_005` · 지금 휴대폰의 코드 `AUTH_006`(서버가 비밀번호를 먼저 보고 한 칸만 알린다).
보내기 전에 6자리가 아니면 `AUTH_007` 문구, 비밀번호가 비었으면 `VAL_001` 문구(칸 차례 — 코드 → 비밀번호). 시도가 너무 많으면 `REQ_001` 알림.
- **10분이 지남 · 더 새 등록**(`AUTH_008` — ① · ② 어느 쪽이든): ①로 돌아와 창 위 빨간 상자에 사전 문구, 새 QR · 키로 다시 그리고 코드 칸을 비운다. 그사이 다른 곳에서 2단계 인증이 꺼졌으면 창을 닫고 사전 문구 알림(화면은 「꺼짐」).
- 세션이 바뀌면(로그아웃 · 다른 기기의 [다른 곳 모두 로그아웃]) 키 · 칸을 지우고 창을 닫는다(켜기 창과 같다).
- **바꾼 뒤**: 창을 닫고 단추 없는 알림 "2단계 인증 휴대폰을 바꿨습니다. 이제 새 휴대폰의 코드로 로그인합니다." · 초점은 [휴대폰 바꾸기]. 화면 · 켠 때(「2026년 10월 31일부터 켜짐」)는 그대로다.
[취소] · Esc 로 닫아도 서버가 이미 바꿨으면 화면을 맞추고 같은 알림을 띄운다. 로그인된 기기는 끊지 않고 알림 메일도 보내지 않는다(켜기 · 끄기와 같다).
### 처음 판에서 미룬 것
주소록: 칸 늘리기(주소 · 생일 · 홈페이지 · 별명 · 사진), 겹친 연락처 합치기, 모르는 CSV 의 열을 손으로 맞추기, 옛 엑셀(.xls), 여러 연락처 고르기 · 한꺼번에 지우기, 사내 목록 내보내기.
서명: 받는 사람에 따라 저절로 바꾸기, 휴대폰 앱에서 서명 고르기. 부재중: 「내 연락처에게만」, 그림 넣기. 2단계 인증: 복구 코드, 바꿀 때 알림 메일 · 계정 변경 기록.
## 업데이트 내역 (2026-11-10 확정 — `ReleaseNotes`)
직원이 웹메일과 휴대폰 앱에 무엇이 새로 생기거나 바뀌었는지 읽는 게시판이다. 업데이트할 때마다 글을 하나씩 더하고, 직원은 목록에서 글을 골라 들어가 읽은 뒤 [목록]이나 뒤로 가기로 돌아온다.
글은 운영자가 저장소의 `backend/release-notes/` 에 글마다 파일 하나(`2026-11-18.md` — 이름이 날짜이자 글 주소)로 쓰고, 서버가 웹 · 앱에 같은 글을 준다. 올리는 화면 · API 는 없다(쓰는 법은 보드 C 와 그 폴더의 `README.md`).
서버 약속은 계약 7절("업데이트 내역")이다 — 목록 `GET /api/release-notes?cursor=&limit=` → `{ items: [{ id, date, title }], pageInfo }`(최근 글부터 · 20개씩),
글 `GET /api/release-notes/{id}` → `{ id, date, title, sections: [{ heading, items }], previous, next }`(없는 글 · 모양이 틀린 id 는 404 `RES_001`). 로그인해야 보이고 제한된 세션은 막힌다.
글의 원본은 화면 상수 `frontend/src/shared/constants/release-notes.ts` 이고 아래는 옮겨 적은 것이다. 글 내용(제목 · 묶음 · 줄)은 서버가 다듬은 글 그대로 그린다 — 마크다운 해석 · v-html 없음, 대괄호 글([설정] → [휴대폰 앱])도 꾸미지 않는다.
새 크기 토큰 · 밀도 예외 · 새 아이콘은 없다(메뉴 아이콘은 이미 있는 문서 그림 `file-text`). 새 글 표시(점 · 개수 · NEW)와 번호 · 작성자 · 조회 수 칸은 두지 않는다. 앱 화면(보드 B)은 앱 디자인 README 가 적는다.
### 자리 · 주소
- 설정 왼쪽 메뉴의 셋째 묶음 「정보」(계정 · 메일 다음) › 「업데이트 내역」. 주소는 목록 `/settings/info/release-notes`, 글 `/settings/info/release-notes/{id}`(id 는 글 파일 이름 — `2026-11-18`, 같은 날 둘째 글은 `2026-11-18-2`).
- 글을 보는 동안에도 메뉴의 「업데이트 내역」이 고른 상태(청록 바탕)다 — 메일함 패널의 지금 메일함과 같은 결. 화면 낭독은 목록이면 `aria-current="page"`,
글이면 링크가 가리키는 곳(목록)과 지금 쪽이 달라 `aria-current="true"` 다. 탭 제목은 목록 · 글 모두 "업데이트 내역 · ARA SYSTEM".
- `/settings/info` 는 목록으로 바꿔 끼운다 — 주소창에 글 주소 뒤 `..`(`%2E%2E` 도)를 치면 브라우저가 `/settings/info/` 로 다듬는다. 다른 묶음 주소(`/settings/account` · `/settings/mail`)는 그대로다.
- 날짜는 쓴 날짜 그대로 "2026년 11월 18일"이다. 시각이 아니라 달력 날짜라 브라우저 시간대로 바꾸지 않는다(`formatCalendarDate`).
### 목록 (`ReleaseNotes` A1)
- 머리 "업데이트 내역" + "웹메일과 휴대폰 앱에 새로 생기거나 바뀐 것을 알려 드립니다." 콘텐츠 열은 다른 설정 화면과 같은 800.
- 표 꼴 상자(로그인 기록 표와 같은 테두리 · 모서리 10): 머리 줄(32 · #FBFBFC · 12 회색 굵게) "제목" · "날짜"(136). 머리 줄은 보이는 꼴만이고, 화면 낭독은 줄 링크가 제목과 날짜를 함께 읽는다.
글마다 한 줄(48 이상 · 위아래 13): 제목(14 · 500) · 날짜(12.5 회색 · 숫자 고정폭), 최근 글부터. 제목이 길면 줄을 바꾸고 날짜는 오른쪽 칸에 둔다.
- 줄 전체가 그 글로 가는 링크다 — 마우스를 올리면 옅은 청록 바탕(#F3F8F8), 키보드 초점은 줄 안쪽 2px 청록 테두리.
- 처음 20개를 받고, 더 있으면 상자 아래(사이 16) [더 보기](44 · 흰 바탕 테두리)로 20개씩 더 받는다. 받는 동안 그 단추만 바쁨(`aria-busy` · `aria-disabled` · 흐리게)이고,
더 받지 못하면(응답의 모양이 틀려도 — `SYS_001`) 알림(사전 문구)으로 알리고 받은 줄 · [더 보기]는 그대로 둔다.
- 들어올 때마다 새로 받고, 받은 뒤 목록의 맨 위가 보이게 스크롤을 맞춘다(글에서 돌아와도 — 아래 보완 절 "스크롤 자리"). [더 보기] 뒤에는 맞추지 않는다.
### 글 (`ReleaseNotes` A2)
- 위에 [← 목록으로] 줄(52 · 아래 선 — 목록 보기에서 연 메일의 [목록으로]와 같은 꼴, 청록 굵은 글 · 화살표 15). 어떤 상태에서도 위에 남는다.
- 글을 열거나 다른 글로 옮기면 받은 뒤 글의 맨 위(날짜 · 제목)가 보이게 스크롤을 맞춘다(아래 보완 절 "스크롤 자리").
- 날짜(12.5 · 500 회색) · 제목(h1 18 · 줄 간격 1.4), 사이 4. 그 아래(사이 22) 묶음(묶음 사이 18): 머리(h2 14 굵게) + 점 목록(13 · #344054 · 줄 간격 1.6 · 줄 사이 4 · 회색 점 4).
머리가 없는 묶음은 점 목록만이다. 묶음 글은 680 까지.
- 글 끝(사이 22)의 다른 글 줄 — 위아래 선 · 680 까지. 줄(48 이상)은 이름(64 · 12.5 회색 굵게) · 제목(14 · 500) · 날짜(12.5 회색)이고 목록 줄처럼 줄 전체를 누른다.
「다음 글」(바로 더 새 글)이 위, 「이전 글」(바로 더 옛 글)이 아래(목록과 같은 차례)다. 없는 쪽의 줄은 두지 않는다(글이 하나뿐이면 칸째 없다).
- 맨 아래 [목록](44 · 흰 바탕 테두리 · 안쪽 18). 설명 한 줄은 목록 화면에만 둔다.
### 상태 (`ReleaseNotes` A1 · A2)
- 목록 · 불러오는 중: 머리 줄은 두고 줄 자리를 막대 넷(제목 막대 54 · 38 · 62 · 46%, 날짜 막대 96)으로 잡아 둔다 — 메일 목록을 불러올 때와 같은 막대. 화면 낭독 "업데이트 내역 불러오는 중".
- 목록 · 받지 못함: 상자 자리에 사전 문구 · [다시 시도] · 문의 코드(다른 설정 화면의 `MailLoadError`). 알림은 띄우지 않는다.
- 목록 · 글이 없을 때: 상자와 [더 보기] 없이 문서 그림 36 · "아직 알려 드릴 업데이트가 없습니다." 한 줄(`StateMessage`).
- 글 · 받는 동안: 글 자리를 막대로 잡아 둔다(날짜 · 제목 · 묶음 자리 — 보드에는 그리지 않았다). 화면 낭독 "글 불러오는 중".
- 글 · 없는 글(지워졌거나 주소가 틀림 — 404 `RES_001`): 알림 없이 사전 문구("찾을 수 없습니다. 이미 지워졌거나 옮겨졌을 수 있습니다.") · [목록으로] · 문의 코드.
파일 이름(날짜)을 고치면 글 주소가 바뀌어, 그전 주소로 들어오면 이 화면이다.
- 글 · 받지 못함(그 밖의 실패): 사전 문구 · [다시 시도] · 문의 코드. 알림은 띄우지 않는다.
### 좁은 폭 (`ReleaseNotes` A3)
- 화면 폭이 840보다 좁으면(레일 72 · 메뉴 208 · 글 칸 560 이 한 줄에 들지 않으면) 메뉴가 위로 올라가고 목록 · 글은 그 아래로 내려간다 — 지금 다른 설정 화면과 같다(휴대폰 브라우저에서는 메뉴 아래로 내려야 글이 나온다).
- **목록 칸이 480보다 좁으면**(화면 폭이 아니라 그 칸의 폭) 머리 줄을 감추고 날짜를 제목 아래로 내린다(사이 2 · 줄 안쪽 12 16) — 이것만 새로 정한 것이다.
글 화면은 넓은 화면과 같은 차례 · 글자 크기로 한 칸에 흐른다(좌우 여백 32 그대로 — 폭 360 에서 글 폭 224).
### 글 규칙 (보드 C 요약 — 쓰는 법의 원본은 `backend/release-notes/README.md`)
- 글 하나가 파일 하나다(`YYYY-MM-DD.md`, 같은 날 둘째부터 `YYYY-MM-DD-2.md`). 첫 줄 `# 제목`(꼭) · `## 묶음 머리` · `- 한 줄`, 빈칸 두 개로 시작한 줄은 앞 줄에 띄어쓰기 하나로 이어 붙고,
`<!-- 메모 -->` 는 화면에 나오지 않는다. 머리 없이 바로 `- ` 를 쓰면 머리 없는 묶음이다.
- 길이는 제목 100자 · 묶음 머리 40자 · 한 줄 500자. 굵게 · 링크 · 그림은 없다 — 단추 · 메뉴 이름은 화면 글처럼 `[설정] → [휴대폰 앱]`.
- 글도 위 「화면 문구 — 사용자에게 말하듯」을 따른다(직원이 읽는 글이다 — 정중하고 부드럽게, 만든 쪽 말 없이). 웹과 앱이 같은 글을 보여 주니 어디 이야기인지는 묶음 머리로 가른다.
### 처음 판에서 미룬 것
새 글 알림(안 읽은 표시 · 한 번 뜨는 안내 줄 — 뱃지를 쓰지 않는 원칙이라 배포 때 직원 공지 메일에 [설정] → [업데이트 내역]을 적는다), 글 안의 링크 · 그림 · 굵게, 웹 · 앱 거르기, 검색.
## 구현하며 정한 보완 (2026-10-26 — 캔버스에는 아직 없다)
- 찾지 못한 첨부(보관 시간이 지난 업로드·지워진 원본)는 실패로 표시하고, 전달 원본에는 "빼기"만 둔다(다시 올릴 수 없다).
@@ -1754,3 +2005,112 @@ PC 에서는 설정 › 계정 › 휴대폰 앱(A)이 QR 코드로 휴대폰에
- 받는 사이 화면 밖으로 나갔으면 읽음으로 바꾸지 않고, 다시 들어올 때 읽음이다(읽음이 실패해도 드나들 때마다 다시 묻지 않는다).
- 화면에 들어왔는지 알 수 없는 브라우저(IntersectionObserver 없음)는 펼친 줄이 생기면 들어온 것으로 본다 — 그래도 하나씩 받는다.
- 펼칠 줄이 없으면(지금 메일만 받고 나머지를 받는 중) 단추가 없다. 그사이 지워진 메일(본문이 404)의 줄에는 머리 [답장]도 없다(아래 [답장]과 같다).
### 16단계 구현하며 정한 보완 — 주소록 (2026-11-10 — 캔버스에는 아직 없다)
- **파일은 이 브라우저에서 읽는다**(`frontend/src/shared/utils/contact-import/` — 순수 함수 · 새 의존성 없음): 글자 `text.ts` · CSV `csv.ts` · vCard `vcard.ts` · zip `zip.ts` · 엑셀 `xlsx.ts` · 머리 줄 → 칸 `table.ts` ·
값 다듬기 `values.ts` · 넣을 · 이미 있는 · 넣지 못하는 `records.ts` · 요청 묶음 `batches.ts` · 형식 가르기 `read-file.ts`.
모두 입력 길이 · 줄 수에 비례하게 읽는다(2026-11-10 리뷰 — 조각은 배열에 모아 한 번에 잇고, 큰 배열을 `push(...)` 로 펼쳐 넘기지 않고, 구분자 둘레 빈칸은 정규식이 아니라 나눈 뒤 trim).
- 엑셀은 zip 가운데 목록 → `DecompressionStream('deflate-raw')` → `DOMParser`(공유 문자열 · 첫 시트 · 칸 안 문자열). 첫 시트는 workbook 의 첫 `<sheet>`(관계 파일로 시트 파일을 찾는다).
푼 크기가 항목 하나 64MB · 모두 96MB 를 넘는 순간 멈춘다(압축 폭탄 — 목록에 적힌 크기는 믿지 않는다). DOCTYPE · ENTITY 가 든 XML · 암호를 건 항목 · ZIP64 는 "열지 못했습니다".
열은 XFD(16,384번째)까지만 읽고 넘는 칸은 버린다. 늘어놓는 칸 수(사이의 빈 칸 포함)의 합이 400만을 넘으면 "열지 못했습니다" — 먼 열에 칸 하나씩 둔 줄이 많으면 줄마다 빈 칸 16,384개로 부풀기 때문이다.
- 옛 오피스 파일(CFB)은 안의 목록 이름으로 가른다 — 암호를 건 .xlsx(EncryptedPackage)는 "열지 못했습니다", Workbook · Book 이 있으면 .xls 글, 그 밖은 "넣을 수 없는 파일".
- 2.1 QUOTED-PRINTABLE 은 줄 끝 "=" 로 접힌 줄을 먼저 바이트로 이은 뒤 CHARSET 대로 푼다(삼성 안드로이드는 한글 한 글자를 두 줄에 나눈다). 아이폰의 묶음 라벨(`item1.X-ABLabel`)로 종류를 정한다.
사진 · 로고 · 소리 · 열쇠(PHOTO · LOGO · SOUND · KEY)는 이어지는 줄까지 모으지 않고 건너뛴다(사진 든 vCard 는 이어지는 줄이 수만 개다).
- 'cp949' 라벨은 쓰지 않는다(RangeError) — EUC-KR 라벨이 브라우저에서 CP949 전체다. Node(ICU)의 euc-kr 은 CP949 확장 글자(똠 등)를 모르므로 시험은 KS X 1001 글자만 쓴다.
- **머리 줄 알아보기**: 머리 이름은 공백 · 기호를 빼고 소문자로 견준다("E-mail 1 - Value" → email1value, "회사·소속명" → 회사소속명).
네이버 열 이름의 띄어쓰기 · 괄호가 조금 달라도 낱말(휴대폰 · 회사 · 집 · Fax · 직접입력 · 생일 · 양력음력 · 기념일 · 우편번호 · 주소 · 메신저 …)로 알아본다.
"번호" 한 낱말 열은 전화로 보지 않는다(일련번호 열이 흔하다 — 네이버의 회사번호 · 집번호 · 기타번호는 따로 안다). Outlook 의 "Title"은 경칭(Mr. · Ms.)이라 쓰지 않고 "Job Title"이 직책이다.
네이버 CSV 처럼 값 대부분이 앞에 '를 달았으면 모두 떼고, 그 밖은 엑셀 수식 막기로 붙인 '(뒤가 = + - @)만 뗀다.
- **이름**: 성이 한글 · 한자 · 가나면 성 다음 이름(한글끼리는 붙여 쓴다), 그 밖은 이름 · 중간 이름 · 성 차례로 띄운다(영문 "John Smith").
- **종류**: CSV 의 종류를 모르는 이메일 칸은 회사(연락처 추가 창의 처음 값과 같다), vCard 의 종류가 없는 EMAIL 은 기타(우리 내보내기가 기타를 INTERNET 만으로 쓰므로 다시 가져와도 같다).
종류를 모르는 전화 칸(전화번호 · 연락처 · Google 의 빈 라벨)은 번호로 짐작한다 — 국내 휴대전화(01x)면 휴대전화, 아니면 기타. 엑셀이 숫자로 바꿔 앞의 0 이 빠진 휴대전화(1012345678)는 0 을 되살린다.
- **넣지 못하는 줄의 까닭** 차례는 이메일 형식 → 이름과 이메일이 모두 없음 → 한 줄 칸 100자 → 메모 1,000자. 한 줄 칸은 어느 칸인지를 앞에 붙인다("이름은 100자까지 쓸 수 있습니다." —
연락처 창은 칸 아래라 칸 이름이 없지만 목록에서는 가를 수 없어서). 이메일은 연락처 창과 같이 서버보다 좁게 본다. 한 연락처 안의 같은 이메일은 하나만, 이메일 · 전화는 20개까지.
- **형식에 맞지 않는 전화번호**(내선 · 글자가 든 번호 — "02-555-0147 내선 3")는 버리지 않고 가져온 정보의 "전화: …" 줄로 둔다(체크 칸을 끄면 넣지 않는다). 전각 숫자 · 여러 가지 줄표는 보통 글자로 고친다.
- **가져온 정보 줄**: "회사 주소 · 집 주소 · 기타 주소 · 주소(네이버 주소직접입력은 그 이름을 앞에) · 생일 · 기념일 · 홈페이지 · 별명 · 메신저 · 전화". 날짜는 "1988년 3월 14일"(해를 모르면 "3월 14일", 음력이면 뒤에 " (음력)"),
한글 주소는 큰 곳부터 띄어 쓰고 영문은 작은 곳부터 쉼표로, 우편번호는 뒤에 괄호로, 우리나라 이름은 쓰지 않는다.
값은 한 줄 글로 다듬는다(탭 · 줄바꿈 · 제어 문자는 띄어쓰기 — vCard 별명 · 형식이 틀린 전화 등), 붙인 메모는 연락처 창과 같은 메모 정리를 한 번 더 지난다 —
서버는 메모의 줄바꿈 밖 제어 문자를 받지 않아 하나라도 남으면 그 묶음 전체가 400 이다.
- **미리 보기**: 빈 묶음(이미 있는 연락처 0 · 넣지 못하는 줄 0)은 두지 않는다. 넣을 연락처가 없으면 표 · 그룹 · 메모 칸도 두지 않는다. 칸이 없는 정보가 든 연락처가 없으면 메모 체크 칸을 두지 않는다.
그룹 글은 이미 있는 그룹만이면 "파일에 적힌 그룹 거래처, 세무는 이미 있는 그룹에 넣습니다.", 새로 만들 그룹만이면 "파일에 적힌 그룹 동호회, 학교는 새로 만듭니다."(보드는 섞인 꼴만).
vCard 의 넣지 못하는 수는 "N명". 그룹이 하나도 없으면 「이미 있는 그룹」을 끈다(`aria-disabled`).
- **겹침 열쇠는 서버와 같다**(짝: backend `modules/contacts/contact-import.ts` 의 `phoneDigitsKey` · `namePhonesKey`): 이메일이 없으면 이름(앞뒤 공백만 뺌) + 전화 숫자 모음 —
번호마다 숫자만 남기고, 숫자가 없는 번호는 빼고, 겹침을 없앤 뒤 차례로 잇는다(같은 번호를 두 번 적은 줄도 한 번 적은 줄과 같은 사람이다).
- **요청 묶음**: 확인 · 넣기 · 되돌리기 본문은 100명 · JSON 값 8,000개 · 700KB 가운데 먼저 닿는 데서 끊는다(`batches.ts` — 값은 서버 `json-shape.ts` 와 같은 셈, 바이트는 UTF-8 길이).
칸을 한도까지 채운 연락처(이메일 · 전화 20개씩) 100명은 값 13,000개 남짓이라 61명 · 39명으로 나뉜다 — 서버 본문 한도(값 10,000 · 1MB)를 넘지 않게.
- **이미 있는지 묻기**: 넣을 수 있는 연락처만 위 묶음으로 서버에 묻는다(`POST /contact-import-checks`). 남은 자리를 다 채우면 더 묻지 않는다(요청 수 한도를 아끼려고) —
그래서 한도에 걸린 파일이면 묻지 않은 연락처 가운데 이미 있는 것도 "넣을 연락처"와 "나머지 N명"에 세진다(넣지 않으니 결과는 같다). 묻기가 실패하면 사전 문구 알림 뒤 파일 고르기로 돌아간다.
- **넣기**: 새 그룹을 먼저 만든다 — 그사이 같은 이름이 생겼으면(`CONTACT_001`) 그 그룹을 쓰고, 100개면(`CONTACT_002`) 그 그룹 없이 넣는다(결과의 "그룹에 넣지 못했습니다"에 센다).
만들기의 응답만 잃은 그룹(응답 없음 · 5xx — 서버에서는 만들어졌을 수 있다)이 [다시 시도]에서 `CONTACT_001` 이면 이번에 만든 그룹으로 센다([되돌리기]가 지운다).
응답을 잃은 적 없이 `CONTACT_001` 인 그룹(다른 창 · 기기가 만든 그룹)은 넣기만 하고 [되돌리기]는 지우지 않는다.
넣는 사이 주소록이 찼으면(409 `CONTACT_002`) 남은 자리를 다시 물어 그만큼만 넣고 나머지는 "넣지 못했습니다", 넣을 그룹이 그사이 지워졌으면(404 `RES_001`) 그룹을 다시 받아 그 그룹을 빼고 한 번 더 보낸다.
메일 서버가 일부만 받으면 결과에 "N명은 넣지 못했습니다."를 덧붙인다(보드에 없는 글).
- **멈춘 알림도 ✕까지 남는다**: 보드는 결과([되돌리기]) 알림만 적었지만, 멈춘 오류 알림의 [다시 시도]가 남은 연락처를 넣고 [되돌리기]를 받는 유일한 길이라 같이 남긴다(되돌리다 멈춘 알림도).
알림은 넘쳐도 다른 알림부터 닫는다. 결과 알림 위에 다른 결과가 쌓이면 셋까지 보인다.
- **차례**: 한 탭에서 가져오기 · 되돌리기는 하나씩 돈다. 도는 동안 누른 [되돌리기] · [다시 시도]는 버리지 않고 끝난 뒤에 한다 — 누른 뒤 세션이 바뀌었으면 하지 않는다(다른 직원의 주소록을 건드리지 않게).
- **내보내기**: 처음 초점은 [내보내기](내 주소록 전체 · vCard 를 바로 받게), 그룹이 하나도 없으면 「그룹 하나」를 고를 수 없다(`disabled`). 화면의 파일 이름 날짜는 이 PC 의 날짜이고
서버가 붙이는 이름은 한국 시간이다 — 자정 무렵 바깥 시간대의 PC 에서는 하루 다를 수 있다(알려진 한계).
- **알림 부품**: ✕로 닫을 때까지 남는 알림은 `toast.store` 의 `sticky`(진행 알림의 `succeed` · `fail` 옵션 — `useToast`). 성공 알림에도 ✕ 를 단다(브랜드 바탕용 흐린 청록, 마우스를 올리면 흰 글자).
지금 쓰는 알림의 시간 · 모양은 그대로다.
### 16단계 구현하며 정한 보완 — 서명 (2026-11-10 — 캔버스에는 아직 없다)
- **서명 자리**(`components/mail/editor/mail-signature-block.ts`): 편집기 안에서는 블록 노드 하나(`mailSignature` — 안은 서명의 문단)이고, 보낼 때 · 저장할 때 빈 문단 뒤의 **속성 없는 `<div>`** 로 나간다 —
앱과 같은 경계(계약 2절 "앱이 만드는 모양")라 웹 · 앱 어느 쪽 저장본을 다시 열어도 그 자리를 찾는다. 안의 문단은 다른 문단처럼 `margin: 0` 이 붙는다.
다시 열 때는 "맨 위 단계(문서 · 하나뿐인 감싸는 글꼴 div 바로 아래)에서 빈 문단 바로 뒤의 속성 없는 div 이고 글이나 그림이 있다"로 찾는다 — 원문 인용 안 · 꾸민 div · 빈 div(인용 경계 표시가 남은 것)는 서명 자리가 아니다.
쓰기 화면이 처음 넣는 서명은 표시(`data-mail-signature` — 서버 정화가 지운다)로 안다.
- **서명 앞의 빈 줄은 늘 둔다**(2026-11-10 리뷰 M3 — 빈 줄에 글을 쓰거나 지운 채 저장하면 다시 열 때 서명 자리를 잃었다): 그 빈 줄에 글을 쓰면 쓴 글 아래 · 서명 위에 새 빈 줄이 생기고,
Backspace · Delete 로 빈 줄을 지우면 다시 생긴다(커서는 쓰던 자리 · 윗줄 끝, 실행 취소는 그 입력과 함께 — 쓴 글은 지우지 않는다).
한글을 조합하는 중에는 미뤘다가 조합이 끝나면(화살표 · 다른 칸 누르기 등 — 다른 입력이 없어도) 넣고(2026-11-10 실사용 시험 — 띄어쓰기를 쳐야 돌아왔다),
보내고 저장하는 모양도 빠진 빈 줄을 한 번 더 넣는다. 조합 중에 저장한 뒤 편집기가 넣은 빈 줄은 바꿈으로 세지 않는다(저장한 본문과 같다).
알려진 한계: 16단계 앞에 저장한 본문(서명이 문단으로만 들어 있다)은 서명 자리를 찾지 못한다 — 그 서명은 글로 남고, [서명 ▾]는 ✓ 없이 열리며 고르면 처음 넣는 자리에 새로 넣는다.
- **서명 자리는 하나**: 서명 안에서 복사해 붙여 넣으면 편집기가 서명 자리째 붙이는데, 그때 처음부터 있던 것만 두고 나머지는 글로 푼다(쓰기 화면의 편집기에서만 — 붙여 넣기 · 끌어 놓기 모두).
- **어느 서명이 들었나**: 서명 자리의 글(공백 · nbsp 를 하나로 줄여)과 그림 주소를 저장한 서명(편집기로 읽은 모양)과 견준다 — 만든 차례로 먼저 맞는 서명에 ✓. 본문이 바뀔 때마다 다시 잰다(서명 안을 고치면 ✓ 가 빠진다).
- **글(보드에 없어 지었다)**: 넣지 않음을 고른 뒤 낭독은 같은 틀 "서명을 ‘넣지 않음’으로 바꿨습니다." — 방향 조사는 이름 끝소리로 고른다("‘영문’으로" · 영문 이름은 "(으)로").
새 서명(저장하지 않은 줄)을 고치다 다른 서명을 고르면 "다른 서명을 고르면 새 서명에 쓴 내용이 사라집니다.", 쓴 것이 있을 때의 [취소]는 "취소하면 새 서명에 쓴 내용이 사라집니다."("‘새 서명’ 서명에서"가 되지 않게).
- **서버 거절**: 저장이 400 `VAL_001` 이면 목록을 조용히 다시 받아 그사이 다른 창에서 같은 이름을 만들었으면 이름 칸의 빨간 글, 아니면 서명 칸 아래 사전 문구(본문 길이 · 그림 3장 등 — 지금과 같다).
지우려던 서명이 이미 없으면(404) 지운 것으로 본다. 고치던 서명이 다시 받은 목록에서 사라졌으면(다른 창에서 지움) 쓰던 것은 두고 새 서명으로 본다([저장]하면 새로 만든다).
한 사람이 보관하는 그림 20장을 넘는 올리기 거절(`VAL_001`)은 지금처럼 알림이다.
- **쓰기 화면이 서명을 못 받았으면** 서명 없이 열고 알리지 않는다(지금과 같다). [서명 ▾]를 열 때 다시 받아 본다.
- **공용 편집기**(`SignatureEditor` — 부재중 자동 응답의 내용 칸이 함께 쓴다): prop `bodyId`(편집 영역 id · 툴바 aria-controls) · `toolbarLabel`(툴바 이름) · `allowImages`(false 면 그림 단추 · 그림 노드가 없고 붙여 넣거나 끌어 놓은 파일은 받지 않는다) ·
`invalid`(aria-invalid · 빨간 테두리) · `disabled`(읽기만 · 툴바 끔 · 흐리게). 편집 영역 최소 높이는 CSS 변수 `--signature-editor-min-height`(기본 132), 보이는 글은 `getText()`. 기본값은 서명 칸 그대로다.
### 16단계 구현하며 정한 보완 — 부재중 · 휴대폰 바꾸기 (2026-11-10 — 캔버스에는 아직 없다)
- **휴대폰을 바꿨는지 모를 때**(서버가 503 `MAIL_001` — 엔진이 새 비밀을 가려 다시 읽어도 가를 수 없다 · 시간 초과 · 연결 끊김 · 앞단 5xx 의 `SYS_001`): 바꿨다고도 못 바꿨다고도 하지 않는다(보드에 없는 새 글).
사전 문구 알림은 끄고 창은 ② 그대로 · 지금 휴대폰의 코드 칸만 비운 채 창 위 빨간 상자에
"휴대폰을 바꿨는지 확인하지 못했습니다. 지금 휴대폰에 다음 코드가 뜨면 넣고 [바꾸기]를 다시 눌러 주세요. 코드가 맞지 않다고 나오면 이미 바뀌었을 수 있으니, 다음에 로그인할 때 새 휴대폰의 코드를 넣어 주세요." —
이미 바뀌었으면 지금 휴대폰의 코드는 막혀 [바꾸기]가 `AUTH_006` 으로 거절하므로, 그 상자를 남겨 둔 채 칸 아래 글이 함께 보인다.
창을 닫은 뒤에 왔으면 알림 "휴대폰을 바꿨는지 확인하지 못했습니다. 다음에 로그인할 때 새 휴대폰의 코드가 맞지 않으면 지금 휴대폰의 코드를 넣어 주세요."
- 닫은 창에 늦게 온 거절(지금 비밀번호 · 지금 코드)은 칸 대신 사전 문구 알림, 다시 연 창에는 넣지 않는다(켜기 · 끄기 창과 같다). ①의 늦은 확인 결과는 버린다(엔진을 바꾸지 않는다).
- 켜기 창과 바꾸기 창이 한 화면에 함께 있어 키 이름표 id 가 겹치지 않게 QR · 키 부품(`TwoFactorEnrollmentScan`)이 머리 글 · 닫을 때의 안내 · 키 이름표 id 를 받는다.
- 부재중 내용의 글자 수는 켜 둔 동안 600을 넘는 순간부터 빨갛게 보인다(보드는 [저장]을 누른 모습만 — 칸 아래 글은 지금처럼 [저장]을 눌렀을 때만).
꺼 둔 채로는 넘어도 회색이다(켤 때만 센다).
- 부재중 양식은 편집기와 함께 지연 로딩한다 — 청크를 받는 동안은 칸이 비어 있고, 못 받으면 서명 설정과 같은 사전 문구 + [다시 시도].
- 회사 밖 칸을 쓴 적 없으면 제목 · 내용을 null 로 보낸다(빈 글을 남기지 않는다 — 서버도 빈 글을 null 로 저장한다).
### 업데이트 내역 구현하며 정한 보완 (2026-11-10 — 캔버스에는 아직 없다)
데이터 · 경로는 위 "업데이트 내역" 머리말(계약 7절)과 같다.
- **기록**: 다른 글 줄은 기록을 바꿔 끼운다 — 몇 글을 넘겨 봐도 뒤로 가기는 목록으로 간다(메일 읽기 화면의 이전 · 다음과 같다).
[← 목록으로] · [목록] · 없는 글의 [목록으로]는 바로 앞 기록이 목록이면 뒤로 가기, 아니면 목록으로 바꿔 끼운다(메일의 [목록으로]와 같다). 링크라 새 탭으로도 열 수 있다.
- **[← 목록으로] 줄은 스크롤해도 위에 남는다**(읽기 화면의 도구 줄과 같다 — 긴 글을 읽다가도 바로 돌아간다).
그 줄 밑에 초점이 가리지 않게(WCAG 2.4.11) 글 칸의 제목 · 링크 · 단추는 줄 높이 + 8(61)만큼 띄워 스크롤한다(`scroll-margin-top`).
- **스크롤 자리**(2026-11-10 실사용 시험): 같은 문서 안의 이동이라 앞 화면의 스크롤 자리가 남아, 목록을 내린 채 글을 열면 날짜 · 제목이 위로 지나가 빈 글처럼 보였고
글에서 목록으로 돌아오면 목록의 맨 아래 근처가 보였다. 글을 열거나 다른 글로 옮기면 받은 뒤 글의 맨 위가, 목록은 받은 뒤 목록의 맨 위가 보이게 맞춘다
(`composables/useSettingsPageScroll.ts` — 라우터 전역 `scrollBehavior` 는 메일 등 다른 화면의 뒤로 가기까지 바꿔 쓰지 않는다). 스크롤하는 것이 폭마다 달라 둘 다 맞춘다:
넓은 화면(1120 이상)은 글 칸을 맨 위로, 좁은 화면은 문서를 — 맨 위로 올려도 제목이 화면 안이면 맨 위로(메뉴가 옆에 있는 폭), 아니면(위에 메뉴가 긴 휴대폰 · 낮은 창) 글 칸의 위 끝을 화면 위 끝에.
문서가 맨 위에 있으면(처음 연 화면) 그대로 둔다 — 다른 설정 화면처럼 휴대폰에서는 메뉴부터 보인다. 없는 글 · 받지 못함도 그 안내가 보이게 같은 규칙으로 맞춘다.
- **메뉴가 고른 상태**: 목록 · 글 라우트를 화면 없는 상위 라우트로 묶었다(`router/routes.ts`). 설정 메뉴는 그 항목을 고른 상태면 청록 바탕을 그리고,
`aria-current` 는 정확히 그 화면이면 `page` · 아래에 묶은 화면이면 `true` 다(RouterLink 의 `isExactActive` · `isActive`). 다른 메뉴 항목은 아래에 묶은 화면이 없어 그대로다.
- **초점**: 다른 글 줄을 누르면 그 줄이 사라져 초점을 잃는다 — 새 글을 받아 맨 위를 보이게 맞춘 뒤 제목(h1)으로 옮긴다(스크롤 없이 · 키보드로 왔을 때만 고리를 그린다).
[더 보기]로 마지막 쪽을 받아 단추가 사라졌으면 새로 붙은 첫 줄로 옮긴다(단추가 남으면 초점도 그대로).
- **주소의 글 id 가 글 id 꼴이 아니면**(`2026-11-18` · `2026-11-18-2` 꼴이 아님 · 32자 넘음) 묻지 않고 없는 글 화면이다(문의 코드 없음).
"." · ".." 같은 경로 조각은 브라우저가 주소를 다듬으며 다른 경로(목록 API 등)로 보내서 꼴부터 거른다.
- **목록을 받지 못했는데 404 여도**(그 경로가 없는 옛 서버) 빠져나갈 곳이 없어 [다시 시도]다.
- **응답의 모양**: 계약의 모양 그대로일 때만 읽는다 — 줄 · 묶음 · 다른 글 하나라도 틀리면(날짜가 실제 있는 달력 날짜가 아님 포함) 받지 못함(`SYS_001` 사전 문구)으로 본다(앱과 같은 규칙).
처음 목록 · 글은 칸 자리에 그리고(알림 없음), [더 보기]는 다른 실패와 같은 길로 알린다(받은 줄 · [더 보기]는 그대로).
- **다른 글 칸이 480보다 좁으면** 날짜를 제목 아래로 내린다(보드 A3 에는 그리지 않았다 — 목록 줄과 같은 기준). 이름 칸 64 는 그대로다.
- 목록 · 글은 들어올 때마다 새로 받는다(배포하며 글을 더하거나 고친다). 세션이 바뀌면(로그아웃 · 만료 · 다른 계정) 늦게 온 응답을 버리고 받아 둔 목록 · 글을 지운다.
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+936
View File
@@ -0,0 +1,936 @@
<!doctype html>
<html lang="ko">
<head>
<meta charset="utf-8">
<title>메일 16단계 · 여러 서명 · 쓰기 화면에서 서명 바꾸기</title>
<script src="./support.js"></script>
</head>
<body>
<x-dc>
<helmet>
<style>
@font-face{font-family:'Pretendard';src:url('/_blob/16067b9aa5137abf808e3dbef30c42e0') format('woff2-variations'),url('/_blob/16067b9aa5137abf808e3dbef30c42e0') format('woff2');font-weight:45 920;font-style:normal;font-display:swap}
body{margin:0}
a{color:#0E7C86}a:hover{color:#0A5F67}
:focus-visible{outline:2px solid #0E7C86;outline-offset:2px}
.on-dark:focus-visible{outline-color:#9ED6DB}
.primary{background:#0E7C86;color:#FFFFFF}
.primary:hover{background:#0A5F67}
.ghost{background:transparent;color:#344054}
.ghost:hover{background:#F2F4F7;color:#171C26}
.ghost-off{background:transparent;color:#98A2B3}
.danger{background:transparent;color:#B42318}
.danger:hover{background:#FEF3F2;color:#912018}
.danger-solid{background:#B42318;color:#FFFFFF}
.danger-solid:hover{background:#912018}
.menu-link{color:#344054}
.menu-link:hover{background:#EEF0F3}
.menu-item{background:transparent;color:#171C26}
.menu-item:hover{background:#F2F4F7}
.field{box-sizing:border-box;border:1px solid #D0D5DD;border-radius:8px;background:#FFFFFF;font:inherit;color:#171C26}
.field::placeholder{color:#667085}
.field:focus{outline:none;border-color:#0E7C86;box-shadow:0 0 0 1px #0E7C86}
.field.is-focus{border-color:#0E7C86;box-shadow:0 0 0 1px #0E7C86}
.field.is-error{border-color:#B42318}
.sig-row{background:#FFFFFF}
.sig-row:hover{background:#F8F9FB}
.sig-row.is-on,.sig-row.is-on:hover{background:#E3F2F3}
.tb{background:transparent;color:#344054}
.tb:hover,.tb[aria-expanded="true"]{background:#F2F4F7;color:#171C26}
.seg{background:transparent;color:#4A5363}
.seg[aria-pressed="true"]{background:#FFFFFF;color:#171C26;box-shadow:0 1px 2px rgba(16,24,28,.14)}
.se p{margin:0}
</style>
</helmet>
<div style="width: 1440px; height: 7955px; box-sizing: border-box; display: flex; flex-direction: column; padding-bottom: 56px; 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; word-break: keep-all; overflow-wrap: anywhere">
<div style="flex: 0 0 auto; box-sizing: border-box; padding: 40px 48px 0; display: flex; flex-direction: column; gap: 6px">
<h1 style="margin: 0; max-width: 1200px; font-size: 24px; font-weight: 700; letter-spacing: -0.02em">메일 16단계 · 여러 서명 · 쓰기 화면에서 서명 바꾸기</h1>
<p style="margin: 0; max-width: 1180px; font-size: 13.5px; color: #4A5363">설정 › 메일 › 서명에서 서명을 10개까지 만들어 두고, 새 메일과 답장, 전달에 넣을 서명을 따로 고릅니다. 쓰기 화면에서는 아래 단추 줄의 [서명 ▾]로 그 메일의 서명만 바꿉니다. 1의 목록 줄과 미리 보기 전환, 6의 메뉴 항목은 눌러 볼 수 있습니다. 화면 글은 지금 화면 상수의 글을 다시 쓰고, 새로 지은 글은 7에 모았습니다. 예시 직원은 김아라이고 서명은 사내, 대외, 영문 셋입니다. 초안이며 사용자 확인 전입니다(2026-11-10).</p>
</div>
<section aria-labelledby="s1-h" style="flex: 0 0 auto; box-sizing: border-box; padding: 40px 48px 18px; display: flex; flex-direction: column; gap: 14px">
<div style="display: flex; align-items: baseline; gap: 16px">
<h2 id="s1-h" style="margin: 0; font-size: 19px; line-height: 28px; font-weight: 700; color: #0B3640; white-space: nowrap">1. 설정 › 메일 › 서명 · 서명이 셋일 때</h2>
<p style="margin: 0; font-size: 13.5px; color: #4A5363">위에서 새 메일과 답장, 전달에 넣을 서명을 고르고, 아래 왼쪽 목록에서 고른 서명을 오른쪽에서 고칩니다. 대외를 고른 모습이고 목록의 줄과 미리 보기 전환을 눌러 볼 수 있습니다. 창 폭 1440, 콘텐츠 열은 지금처럼 최대 800입니다.</p>
</div>
</section>
<div role="region" aria-label="설정 › 메일 › 서명 화면" style="flex: 0 0 auto; box-sizing: border-box; display: flex; background: #FFFFFF; border-top: 1px solid #E6E8EC; border-bottom: 1px solid #E6E8EC">
<nav aria-label="사내 시스템" style="flex: 0 0 72px; box-sizing: border-box; display: flex; flex-direction: column; align-items: center; gap: 14px; padding: 10px 0 12px; background: #0B3640">
<a href="#" style="display: inline-flex; align-items: center; justify-content: center; width: 60px; min-height: 44px; border-radius: 10px; text-decoration: none">
<img src="/_blob/2b9ef4349333debc969432c859eaf1bf" alt="ARA SYSTEM" width="48" height="17" style="display: block; width: 48px; height: auto">
</a>
<ul style="list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 4px">
<li><a href="#" style="display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 3px; width: 60px; min-height: 56px; border-radius: 10px; font-size: 11px; text-decoration: none; color: #A3C2C6; font-weight: 500"><svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z"></path><polyline points="22,6 12,13 2,6"></polyline></svg><span>메일</span></a></li>
<li><a href="#" style="display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 3px; width: 60px; min-height: 56px; border-radius: 10px; font-size: 11px; text-decoration: none; color: #A3C2C6; font-weight: 500"><svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="4" width="18" height="18" rx="2" ry="2"></rect><line x1="16" y1="2" x2="16" y2="6"></line><line x1="8" y1="2" x2="8" y2="6"></line><line x1="3" y1="10" x2="21" y2="10"></line></svg><span>캘린더</span></a></li>
<li><a href="#" style="display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 3px; width: 60px; min-height: 56px; border-radius: 10px; font-size: 11px; text-decoration: none; color: #A3C2C6; font-weight: 500"><svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"></path></svg><span>파일</span></a></li>
<li><a href="#" style="display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 3px; width: 60px; min-height: 56px; border-radius: 10px; font-size: 11px; text-decoration: none; color: #A3C2C6; font-weight: 500"><svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"></path><circle cx="9" cy="7" r="4"></circle><path d="M23 21v-2a4 4 0 0 0-3-3.87"></path><path d="M16 3.13a4 4 0 0 1 0 7.75"></path></svg><span>주소록</span></a></li>
</ul>
<div style="margin-top: auto"><a href="#" aria-current="page" style="display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 3px; width: 60px; min-height: 56px; border-radius: 10px; font-size: 11px; text-decoration: none; background: #155A64; color: #FFFFFF; font-weight: 600"><svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="3"></circle><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"></path></svg><span>설정</span></a></div>
</nav>
<div style="flex: 1 1 auto; min-width: 0; display: flex; flex-direction: column">
<header style="display: flex; align-items: center; flex-wrap: wrap; gap: 8px 16px; box-sizing: border-box; min-height: 56px; padding: 6px 12px 6px 20px; border-bottom: 1px solid #E6E8EC">
<span style="font-size: 16px; font-weight: 700; letter-spacing: -0.01em">설정</span>
<div style="flex: 1 1 280px; max-width: 520px; display: flex; align-items: center; gap: 8px; box-sizing: border-box; height: 40px; padding: 0 12px; border: 1px solid #8A94A3; border-radius: 8px; background: #FFFFFF">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#667085" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="11" cy="11" r="8"></circle><line x1="21" y1="21" x2="16.65" y2="16.65"></line></svg>
<input type="search" aria-label="통합 검색 (준비 중)" placeholder="통합 검색" disabled style="flex: 1 1 auto; min-width: 0; border: 0; background: transparent; font: inherit; color: #171C26">
</div>
<div style="margin-left: auto; display: flex; align-items: center; gap: 10px">
<span aria-hidden="true" style="display: inline-flex; align-items: center; justify-content: center; width: 32px; height: 32px; border-radius: 50%; background: #E3F2F3; color: #0A5F67; font-size: 13px; font-weight: 600">김</span>
<div style="display: flex; flex-direction: column; line-height: 1.3">
<span style="font-weight: 600">김아라</span>
<span style="font-size: 12px; color: #667085">ara.kim@arasystem.it.kr</span>
</div>
<a href="#" style="display: inline-flex; align-items: center; gap: 6px; min-height: 44px; padding: 0 10px; border-radius: 8px; color: #4A5363; font-weight: 500; text-decoration: none">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"></path><polyline points="16 17 21 12 16 7"></polyline><line x1="21" y1="12" x2="9" y2="12"></line></svg>
<span>로그아웃</span>
</a>
</div>
</header>
<div style="flex: 1 1 auto; display: flex; align-items: stretch">
<nav aria-label="설정 메뉴" style="flex: 0 0 208px; box-sizing: border-box; display: flex; flex-direction: column; gap: 4px; padding: 14px 10px; background: #F8F9FB; border-right: 1px solid #E6E8EC">
<p style="margin: 4px 10px 4px; font-size: 12px; font-weight: 600; color: #667085">계정</p>
<ul style="list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 1px">
<li><a href="#" class="menu-link" style="display: flex; align-items: center; gap: 10px; min-height: 40px; box-sizing: border-box; padding: 0 10px; border-radius: 7px; font-weight: 500; text-decoration: none"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="11" width="18" height="11" rx="2" ry="2"></rect><path d="M7 11V7a5 5 0 0 1 10 0v4"></path></svg><span>비밀번호</span></a></li>
<li><a href="#" class="menu-link" style="display: flex; align-items: center; gap: 10px; min-height: 40px; box-sizing: border-box; padding: 0 10px; border-radius: 7px; font-weight: 500; text-decoration: none"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"></path><polyline points="9 12 11 14 15 10"></polyline></svg><span>2단계 인증</span></a></li>
<li><a href="#" class="menu-link" style="display: flex; align-items: center; gap: 10px; min-height: 40px; box-sizing: border-box; padding: 0 10px; border-radius: 7px; font-weight: 500; text-decoration: none"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 2l-2 2m-7.61 7.61a5.5 5.5 0 1 1-7.778 7.778 5.5 5.5 0 0 1 7.777-7.777zm0 0L15.5 7.5m0 0l3 3L22 7l-3-3m-3.5 3.5L19 4"></path></svg><span>앱 비밀번호</span></a></li>
<li><a href="#" class="menu-link" style="display: flex; align-items: center; gap: 10px; min-height: 40px; box-sizing: border-box; padding: 0 10px; border-radius: 7px; font-weight: 500; text-decoration: none"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M18 8V6a2 2 0 0 0-2-2H4a2 2 0 0 0-2 2v7a2 2 0 0 0 2 2h8"></path><path d="M10 19v-3.96 3.15"></path><path d="M7 19h5"></path><rect x="16" y="12" width="6" height="10" rx="2"></rect></svg><span>메일 프로그램 연결</span></a></li>
<li><a href="#" class="menu-link" style="display: flex; align-items: center; gap: 10px; min-height: 40px; box-sizing: border-box; padding: 0 10px; border-radius: 7px; font-weight: 500; text-decoration: none"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="5" y="2" width="14" height="20" rx="2" ry="2"></rect><line x1="12" y1="18" x2="12.01" y2="18"></line></svg><span>휴대폰 앱</span></a></li>
<li><a href="#" class="menu-link" style="display: flex; align-items: center; gap: 10px; min-height: 40px; box-sizing: border-box; padding: 0 10px; border-radius: 7px; font-weight: 500; text-decoration: none"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"></path><path d="M3 3v5h5"></path><path d="M12 7v5l4 2"></path></svg><span>로그인 기록</span></a></li>
</ul>
<p style="margin: 14px 10px 4px; font-size: 12px; font-weight: 600; color: #667085">메일</p>
<ul style="list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 1px">
<li><a href="#" aria-current="page" style="display: flex; align-items: center; gap: 10px; min-height: 40px; box-sizing: border-box; padding: 0 10px; border-radius: 7px; background: #E3F2F3; color: #0A5F67; font-weight: 600; text-decoration: none"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 20h9"></path><path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z"></path></svg><span>서명</span></a></li>
<li><a href="#" class="menu-link" style="display: flex; align-items: center; gap: 10px; min-height: 40px; box-sizing: border-box; padding: 0 10px; border-radius: 7px; font-weight: 500; text-decoration: none"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="4" width="18" height="18" rx="2" ry="2"></rect><line x1="16" y1="2" x2="16" y2="6"></line><line x1="8" y1="2" x2="8" y2="6"></line><line x1="3" y1="10" x2="21" y2="10"></line><line x1="10" y1="14" x2="14" y2="18"></line><line x1="14" y1="14" x2="10" y2="18"></line></svg><span>부재중 자동 응답</span></a></li>
<li><a href="#" class="menu-link" style="display: flex; align-items: center; gap: 10px; min-height: 40px; box-sizing: border-box; padding: 0 10px; border-radius: 7px; font-weight: 500; text-decoration: none"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polygon points="22 3 2 3 10 12.46 10 19 14 21 14 12.46 22 3"></polygon></svg><span>필터</span></a></li>
<li><a href="#" class="menu-link" style="display: flex; align-items: center; gap: 10px; min-height: 40px; box-sizing: border-box; padding: 0 10px; border-radius: 7px; font-weight: 500; text-decoration: none"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="22" y1="2" x2="11" y2="13"></line><polygon points="22 2 15 22 11 13 2 9 22 2"></polygon></svg><span>보내기 · 읽기</span></a></li>
<li><a href="#" class="menu-link" style="display: flex; align-items: center; gap: 10px; min-height: 40px; box-sizing: border-box; padding: 0 10px; border-radius: 7px; font-weight: 500; text-decoration: none"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="10"></circle><line x1="4.93" y1="4.93" x2="19.07" y2="19.07"></line></svg><span>차단한 주소</span></a></li>
<li><a href="#" class="menu-link" style="display: flex; align-items: center; gap: 10px; min-height: 40px; box-sizing: border-box; padding: 0 10px; border-radius: 7px; font-weight: 500; text-decoration: none"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="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>대용량 첨부</span></a></li>
<li><a href="#" class="menu-link" style="display: flex; align-items: center; gap: 10px; min-height: 40px; box-sizing: border-box; padding: 0 10px; border-radius: 7px; font-weight: 500; text-decoration: none"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9"></path><path d="M13.73 21a2 2 0 0 1-3.46 0"></path></svg><span>알림</span></a></li>
</ul>
</nav>
<main style="flex: 1 1 auto; min-width: 0; box-sizing: border-box; display: flex; flex-direction: column">
<div style="max-width: 800px; box-sizing: border-box; padding: 20px 32px 36px; display: flex; flex-direction: column; gap: 22px">
<div style="display: flex; flex-direction: column; gap: 4px">
<h1 style="margin: 0; font-size: 18px; font-weight: 700; letter-spacing: -0.01em">서명</h1>
<p style="margin: 0; color: #4A5363">보내는 메일 끝에 넣는 글입니다. 여러 개 만들어 두면 메일을 쓸 때 [서명]에서 골라 바꿀 수 있고, 그 메일에서만 고치거나 지울 수도 있습니다.</p>
</div>
<fieldset style="display: flex; flex-direction: column; gap: 6px; margin: 0; padding: 0; border: 0">
<legend style="margin: 0 0 6px; padding: 0; font-weight: 600; color: #171C26">메일에 넣을 서명</legend>
<div style="display: flex; align-items: center; gap: 12px">
<label for="f1-def-new" style="flex: 0 0 84px; color: #344054">새 메일</label>
<select id="f1-def-new" class="field" aria-describedby="f1-def-hint" style="width: 240px; height: 44px; padding: 0 10px"><option>사내</option><option selected>대외</option><option>영문</option><option>넣지 않음</option></select>
</div>
<div style="display: flex; align-items: center; gap: 12px">
<label for="f1-def-reply" style="flex: 0 0 84px; color: #344054">답장과 전달</label>
<select id="f1-def-reply" class="field" aria-describedby="f1-def-hint" style="width: 240px; height: 44px; padding: 0 10px"><option selected>사내</option><option>대외</option><option>영문</option><option>넣지 않음</option></select>
</div>
<p id="f1-def-hint" style="margin: 2px 0 0; font-size: 12.5px; color: #667085">메일을 쓰기 시작할 때 본문에 넣어 둡니다. 고르는 대로 바로 저장됩니다.</p>
</fieldset>
<div style="display: flex; align-items: flex-start; gap: 24px">
<div style="flex: 0 0 208px; display: flex; flex-direction: column; gap: 8px">
<p id="f1-list-label" style="margin: 0; font-weight: 600; color: #171C26">내 서명</p>
<ul aria-labelledby="f1-list-label" style="list-style: none; margin: 0; padding: 0; border: 1px solid #E6E8EC; border-radius: 10px; overflow: hidden">
<sc-for list="{{ f1Rows }}" as="r" hint-placeholder-count="3">
<li style="border-top: {{ r.borderTop }}">
<button type="button" class="{{ r.cls }}" aria-current="{{ r.current }}" onClick="{{ r.pick }}" style="display: flex; flex-direction: column; justify-content: center; gap: 1px; width: 100%; min-height: 52px; box-sizing: border-box; padding: 6px 12px; border: 0; font: inherit; text-align: left; cursor: pointer">
<span style="max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-weight: {{ r.fw }}; color: {{ r.fg }}">{{ r.name }}</span>
<sc-if value="{{ r.hasUses }}" hint-placeholder-val="{{ false }}"><span style="font-size: 12px; line-height: 1.4; color: {{ r.subColor }}">{{ r.uses }}</span></sc-if>
</button>
</li>
</sc-for>
</ul>
<button type="button" class="ghost" style="align-self: flex-start; display: inline-flex; align-items: center; gap: 8px; min-height: 44px; margin-left: -2px; padding: 0 12px 0 10px; border: 0; border-radius: 8px; font: inherit; font-weight: 500; 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="12" y1="5" x2="12" y2="19"></line><line x1="5" y1="12" x2="19" y2="12"></line></svg><span>새 서명</span></button>
<p style="margin: 0; font-size: 12.5px; color: #667085">서명 3개를 만들었고, 10개까지 만들 수 있습니다.</p>
</div>
<div style="flex: 1 1 auto; min-width: 0; display: flex; flex-direction: column; gap: 22px">
<div style="display: flex; flex-direction: column; gap: 6px">
<label for="f1-name" style="font-weight: 600; color: #171C26">이름</label>
<input id="f1-name" class="field" type="text" value="{{ selName }}" maxlength="30" aria-describedby="f1-name-help" style="width: 320px; max-width: 100%; height: 44px; padding: 0 12px">
<p id="f1-name-help" style="margin: 0; font-size: 12.5px; color: #667085">메일을 쓸 때 [서명]에서 고르는 이름입니다. 30자까지 쓸 수 있습니다.</p>
</div>
<div style="display: flex; flex-direction: column; gap: 8px">
<p id="f1-sig-label" style="margin: 0; font-weight: 600; color: #171C26">서명 내용</p>
<div style="display: flex; flex-direction: column; border: 1px solid #D0D5DD; border-radius: 10px; background: #FFFFFF">
<div role="toolbar" aria-label="서명 서식" aria-controls="f1-sig-body" style="display: flex; align-items: center; gap: 2px; min-height: 52px; box-sizing: border-box; padding: 4px 8px; border-bottom: 1px solid #EEF0F3">
<button type="button" class="tb" aria-label="굵게" title="굵게" style="width: 40px; height: 44px; display: inline-flex; align-items: center; justify-content: center; padding: 0; border: 0; border-radius: 8px; cursor: pointer"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M6 4h8a4 4 0 0 1 0 8H6z"></path><path d="M6 12h9a4 4 0 0 1 0 8H6z"></path></svg></button>
<button type="button" class="tb" aria-label="기울임" title="기울임" style="width: 40px; height: 44px; display: inline-flex; align-items: center; justify-content: center; padding: 0; border: 0; border-radius: 8px; cursor: pointer"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="19" y1="4" x2="10" y2="4"></line><line x1="14" y1="20" x2="5" y2="20"></line><line x1="15" y1="4" x2="9" y2="20"></line></svg></button>
<button type="button" class="tb" aria-label="밑줄" title="밑줄" style="width: 40px; height: 44px; display: inline-flex; align-items: center; justify-content: center; padding: 0; border: 0; border-radius: 8px; cursor: pointer"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M6 3v7a6 6 0 0 0 12 0V3"></path><line x1="4" y1="21" x2="20" y2="21"></line></svg></button>
<span aria-hidden="true" style="flex: 0 0 auto; width: 1px; height: 20px; margin: 0 4px; background: #E6E8EC"></span>
<button type="button" class="tb" aria-label="글자 색" title="글자 색" style="width: 40px; height: 44px; display: inline-flex; 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="#1F2733" stroke="none"></rect></svg></button>
<button type="button" class="tb" aria-label="링크" title="링크" style="width: 40px; height: 44px; display: inline-flex; align-items: center; justify-content: center; padding: 0; border: 0; border-radius: 8px; cursor: pointer"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"></path><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"></path></svg></button>
<button type="button" class="tb" aria-label="그림 넣기" title="그림 넣기" style="width: 40px; height: 44px; display: inline-flex; align-items: center; justify-content: center; padding: 0; border: 0; border-radius: 8px; cursor: pointer"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="3" width="18" height="18" rx="2" ry="2"></rect><circle cx="8.5" cy="8.5" r="1.5"></circle><polyline points="21 15 16 10 5 21"></polyline></svg></button>
</div>
<div id="f1-sig-body" class="se" role="textbox" aria-multiline="true" aria-labelledby="f1-sig-label" tabindex="0" style="min-height: 132px; box-sizing: border-box; padding: 14px 16px; font-family: 'Malgun Gothic', '맑은 고딕', 'Apple SD Gothic Neo', sans-serif; font-size: 14px; line-height: 1.75; color: #1F2733">
<sc-if value="{{ selIn }}" hint-placeholder-val="{{ false }}"><p style="margin: 0"><strong>김아라</strong> · 인프라팀</p><p style="margin: 0">내선 [내선 번호]</p></sc-if>
<sc-if value="{{ selOut }}" hint-placeholder-val="{{ true }}"><p style="margin: 0"><strong>김아라</strong> · 인프라팀</p><p style="margin: 0">ARA SYSTEM · [전화번호]</p><p style="margin: 0"><a href="#">ara.kim@arasystem.it.kr</a></p><p style="margin: 8px 0 0"><span role="img" aria-label="[회사 로고]" style="display: inline-flex; align-items: center; justify-content: center; box-sizing: border-box; width: 144px; height: 43px; border: 1px dashed #98A2B3; border-radius: 4px; color: #667085; font-family: Pretendard, -apple-system, 'Apple SD Gothic Neo', 'Malgun Gothic', system-ui, sans-serif; font-size: 12px">[회사 로고]</span></p></sc-if>
<sc-if value="{{ selEn }}" hint-placeholder-val="{{ false }}"><p style="margin: 0"><strong>Ara Kim</strong> · Infrastructure Team</p><p style="margin: 0">ARA SYSTEM · [전화번호]</p><p style="margin: 0"><a href="#">ara.kim@arasystem.it.kr</a></p></sc-if>
</div>
</div>
<p style="margin: 0; font-size: 12.5px; color: #667085">굵게, 기울임, 밑줄, 글자 색, 링크, 그림을 쓸 수 있습니다. 그림은 PNG, JPG, GIF 파일을 한 장 300KB 이하로 3장까지 넣을 수 있고 붙여 넣거나 끌어다 놓아도 됩니다. 그림은 메일에 함께 실어 보내서 받는 사람 쪽에서 외부 이미지처럼 막히지 않습니다.</p>
</div>
<div style="display: flex; flex-wrap: wrap; align-items: center; gap: 12px">
<button type="button" class="primary" style="display: inline-flex; align-items: center; min-height: 44px; padding: 0 22px; border: 0; border-radius: 8px; font: inherit; font-size: 13.5px; font-weight: 600; cursor: pointer">저장</button>
<button type="button" class="danger" style="margin-left: auto; display: inline-flex; align-items: center; gap: 6px; min-height: 44px; padding: 0 12px; border: 0; border-radius: 8px; font: inherit; font-weight: 600; cursor: pointer"><svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="3 6 5 6 21 6"></polyline><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path></svg><span>이 서명 지우기</span></button>
</div>
<hr style="width: 100%; height: 1px; margin: 0; border: 0; background: #EEF0F3">
<section aria-labelledby="f1-prev-h" style="display: flex; flex-direction: column; gap: 12px">
<div style="display: flex; flex-wrap: wrap; align-items: center; gap: 8px 14px">
<h2 id="f1-prev-h" style="margin: 0; font-size: 14px; font-weight: 700">미리 보기</h2>
<div role="group" aria-label="미리 볼 메일" style="display: inline-flex; gap: 2px; padding: 2px; border-radius: 8px; background: #F2F4F7">
<sc-for list="{{ f1Modes }}" as="m" hint-placeholder-count="2">
<button type="button" class="seg" aria-pressed="{{ m.pressed }}" onClick="{{ m.pick }}" style="min-height: 36px; padding: 0 14px; border: 0; border-radius: 6px; font: inherit; font-weight: 600; cursor: pointer">{{ m.label }}</button>
</sc-for>
</div>
</div>
<div style="box-sizing: border-box; padding: 16px 20px 20px; border: 1px solid #E6E8EC; border-radius: 10px; background: #FFFFFF; font-family: 'Malgun Gothic', '맑은 고딕', 'Apple SD Gothic Neo', sans-serif; font-size: 14px; line-height: 1.75; color: #1F2733">
<p style="margin: 0; color: #667085">(여기에 쓴 내용)</p>
<p style="margin: 0"><br></p>
<sc-if value="{{ selIn }}" hint-placeholder-val="{{ false }}"><div><p style="margin: 0"><strong>김아라</strong> · 인프라팀</p><p style="margin: 0">내선 [내선 번호]</p></div></sc-if>
<sc-if value="{{ selOut }}" hint-placeholder-val="{{ true }}"><div><p style="margin: 0"><strong>김아라</strong> · 인프라팀</p><p style="margin: 0">ARA SYSTEM · [전화번호]</p><p style="margin: 0"><a href="#">ara.kim@arasystem.it.kr</a></p><p style="margin: 8px 0 0"><span role="img" aria-label="[회사 로고]" style="display: inline-flex; align-items: center; justify-content: center; box-sizing: border-box; width: 144px; height: 43px; border: 1px dashed #98A2B3; border-radius: 4px; color: #667085; font-family: Pretendard, -apple-system, 'Apple SD Gothic Neo', 'Malgun Gothic', system-ui, sans-serif; font-size: 12px">[회사 로고]</span></p></div></sc-if>
<sc-if value="{{ selEn }}" hint-placeholder-val="{{ false }}"><div><p style="margin: 0"><strong>Ara Kim</strong> · Infrastructure Team</p><p style="margin: 0">ARA SYSTEM · [전화번호]</p><p style="margin: 0"><a href="#">ara.kim@arasystem.it.kr</a></p></div></sc-if>
<sc-if value="{{ previewReply }}" hint-placeholder-val="{{ false }}">
<blockquote style="margin: 24px 0 0; padding: 2px 0 2px 14px; border-left: 2px solid #D0D5DD; color: #4A5363">
<div style="margin: 0 0 12px; font-size: 12.5px; line-height: 1.6">
<p style="margin: 0; font-weight: 600">원본 메일</p>
<table style="border-collapse: collapse; font-size: 12.5px; line-height: 1.6">
<tbody>
<tr><th scope="row" style="padding: 0 12px 0 0; text-align: left; vertical-align: top; font-weight: 400; color: #667085; white-space: nowrap">보낸 사람</th><td style="padding: 0">(보낸 사람)</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">(보낸 날짜)</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">(원래 제목)</td></tr>
</tbody>
</table>
</div>
<p style="margin: 0">(원래 메일 내용)</p>
</blockquote>
</sc-if>
</div>
<p style="margin: 0; font-size: 12.5px; color: #667085">{{ previewNote }}</p>
</section>
</div>
</div>
</div>
</main>
</div>
</div>
</div>
<section style="flex: 0 0 auto; box-sizing: border-box; padding: 20px 48px 0; display: flex; gap: 24px; align-items: flex-start">
<div style="flex: 0 0 440px; display: flex; flex-direction: column; gap: 10px">
<div style="display: flex; flex-direction: column; gap: 1px">
<h3 style="margin: 0; font-size: 13px; font-weight: 700; color: #344054">메일에 넣을 서명을 고른 뒤</h3>
<p style="margin: 0; font-size: 12.5px; color: #667085">답장과 전달을 대외에서 사내로 바꾼 모습 · 저장 단추 없이 바로 저장</p>
</div>
<div style="box-sizing: border-box; padding: 18px 20px 20px; border: 1px solid #E6E8EC; border-radius: 12px; background: #FFFFFF">
<fieldset style="display: flex; flex-direction: column; gap: 6px; margin: 0; padding: 0; border: 0">
<legend style="margin: 0 0 6px; padding: 0; font-weight: 600; color: #171C26">메일에 넣을 서명</legend>
<div style="display: flex; align-items: center; gap: 12px">
<label for="f1s-def-new" style="flex: 0 0 84px; color: #344054">새 메일</label>
<select id="f1s-def-new" class="field" aria-describedby="f1s-def-hint" style="width: 200px; height: 44px; padding: 0 10px"><option>사내</option><option selected>대외</option><option>영문</option><option>넣지 않음</option></select>
</div>
<div style="display: flex; align-items: center; gap: 12px">
<label for="f1s-def-reply" style="flex: 0 0 84px; color: #344054">답장과 전달</label>
<select id="f1s-def-reply" class="field" aria-describedby="f1s-def-hint" style="width: 200px; height: 44px; padding: 0 10px"><option selected>사내</option><option>대외</option><option>영문</option><option>넣지 않음</option></select>
<span role="status" style="display: inline-flex; align-items: center; gap: 6px; font-size: 12.5px; color: #067647; white-space: nowrap"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="20 6 9 17 4 12"></polyline></svg><span>저장했습니다</span></span>
</div>
<p id="f1s-def-hint" style="margin: 2px 0 0; font-size: 12.5px; color: #667085">메일을 쓰기 시작할 때 본문에 넣어 둡니다. 고르는 대로 바로 저장됩니다.</p>
</fieldset>
</div>
</div>
<div style="flex: 1 1 auto; min-width: 0">
<div style="display: flex; flex-direction: column; gap: 10px; padding: 20px; background: #FFFFFF; border: 1px solid #E6E8EC; border-radius: 12px">
<h3 style="margin: 0; font-size: 14px; font-weight: 700">정한 것</h3>
<ul style="margin: 0; padding-left: 18px; display: flex; flex-direction: column; gap: 6px; color: #344054">
<li>배치는 목록 왼쪽(208), 고치는 칸 오른쪽(504)입니다. 서명이 늘어도 고치는 칸이 아래로 밀려나지 않고, 고른 줄과 고치는 칸이 나란히 보입니다. 고치는 칸은 지금(736)보다 좁지만 서명 글, 툴바, 미리 보기가 다 들어갑니다.</li>
<li>목록과 고치는 칸을 담은 칸이 640보다 좁아지면 목록을 위로 올리고 고치는 칸을 그 아래에 둡니다. 창 폭이 아니라 그 칸의 폭을 봅니다(컨테이너 쿼리). 콘텐츠 열은 다른 설정 화면과 같은 800 그대로입니다.</li>
<li>목록은 만든 차례이고 줄 높이는 52입니다. 고른 줄은 메일함 패널의 지금 메일함처럼 청록 바탕에 굵은 글자입니다(aria-current). 이름 아래 작은 회색 글은 그 서명을 기본으로 넣는 메일이고, 뱃지는 쓰지 않습니다.</li>
<li>화면을 열면 새 메일에 넣는 서명이 골라져 있습니다. 새 메일이 넣지 않음이면 첫 서명이 골라져 있습니다.</li>
<li>메일에 넣을 서명 두 칸은 고르는 대로 바로 저장합니다. 보내기 · 읽기 설정처럼 저장이 끝나면 고른 칸 오른쪽에 저장했습니다(초록 ✓)를 보이고(왼쪽 아래), 다시 고르면 내립니다. 저장하지 못하면 고르기 전 값으로 되돌리고 오류 알림을 띄웁니다. 지금의 두 체크(새 메일, 답장과 전달)를 대신합니다.</li>
<li>이름과 서명 내용은 [저장]으로 저장합니다. 저장한 뒤의 글은 기본으로 넣는 서명이면 지금 그대로 "저장했습니다. 다음에 메일을 쓸 때부터 들어갑니다."이고, 기본이 아닌 서명이면 "저장했습니다."만 둡니다.</li>
<li>편집기, 그림(서명마다 3장), 그림을 고르면 나오는 크기 줄, 미리 보기와 그 아래 글은 지금 서명 화면 그대로입니다. 화면에 끌어다 놓은 그림은 지금 고른 서명에 넣습니다.</li>
<li>[이 서명 지우기]는 [저장]과 떨어뜨려 오른쪽 끝에 둡니다(4). 화면 낭독기는 목록을 내 서명 목록으로, 고른 줄을 지금 항목으로 읽습니다.</li>
</ul>
</div>
</div>
</section>
<section aria-labelledby="s2-h" style="flex: 0 0 auto; box-sizing: border-box; padding: 48px 48px 0; display: flex; flex-direction: column; gap: 14px">
<div style="display: flex; align-items: baseline; gap: 16px">
<h2 id="s2-h" style="margin: 0; font-size: 19px; line-height: 28px; font-weight: 700; color: #0B3640; white-space: nowrap">2. 서명이 없을 때</h2>
<p style="margin: 0; font-size: 13.5px; color: #4A5363">처음 쓰는 직원입니다. 메일에 넣을 서명 칸과 목록 대신 안내와 [새 서명]만 둡니다. 지금 서명을 쓰던 직원은 그 서명을 기본 서명이라는 이름으로 옮겨 둔 채 시작합니다(오른쪽).</p>
</div>
<div style="display: flex; gap: 24px; align-items: flex-start">
<div style="flex: 0 0 800px; display: flex; flex-direction: column; gap: 10px">
<div style="display: flex; flex-direction: column; gap: 1px">
<h3 style="margin: 0; font-size: 13px; font-weight: 700; color: #344054">처음 쓰는 직원</h3>
<p style="margin: 0; font-size: 12.5px; color: #667085">서명 화면의 콘텐츠 열만 그렸습니다</p>
</div>
<div style="box-sizing: border-box; padding: 20px 32px 36px; display: flex; flex-direction: column; gap: 22px; border: 1px solid #E6E8EC; border-radius: 12px; background: #FFFFFF">
<div style="display: flex; flex-direction: column; gap: 4px">
<h1 style="margin: 0; font-size: 18px; font-weight: 700; letter-spacing: -0.01em">서명</h1>
<p style="margin: 0; color: #4A5363">보내는 메일 끝에 넣는 글입니다. 여러 개 만들어 두면 메일을 쓸 때 [서명]에서 골라 바꿀 수 있고, 그 메일에서만 고치거나 지울 수도 있습니다.</p>
</div>
<div style="display: flex; flex-direction: column; align-items: center; gap: 8px; padding: 40px 24px; border: 1px dashed #D0D5DD; border-radius: 10px; text-align: center">
<svg width="36" height="36" viewBox="0 0 24 24" fill="none" stroke="#98A2B3" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 20h9"></path><path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z"></path></svg>
<p style="margin: 4px 0 0; font-size: 14px; font-weight: 600">아직 서명이 없습니다</p>
<p style="max-width: 400px; margin: 0; font-size: 12.5px; color: #667085">보내는 메일 끝에 넣을 이름, 소속, 연락처를 만들어 두면 메일을 쓸 때마다 본문에 넣어 둡니다. 사내용과 대외용처럼 여러 개를 만들어 골라 쓸 수도 있습니다.</p>
<button type="button" class="primary" style="margin-top: 6px; display: inline-flex; align-items: center; gap: 6px; min-height: 44px; padding: 0 16px; border: 0; border-radius: 8px; font: inherit; font-weight: 600; cursor: pointer"><svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="12" y1="5" x2="12" y2="19"></line><line x1="5" y1="12" x2="19" y2="12"></line></svg><span>새 서명</span></button>
</div>
</div>
</div>
<div style="flex: 1 1 auto; min-width: 0; display: flex; flex-direction: column; gap: 10px">
<div style="display: flex; flex-direction: column; gap: 1px">
<h3 style="margin: 0; font-size: 13px; font-weight: 700; color: #344054">지금 서명을 쓰던 직원</h3>
<p style="margin: 0; font-size: 12.5px; color: #667085">두 체크를 모두 켜 두었던 직원 · 고치는 칸은 1과 같아 뺐습니다</p>
</div>
<div style="box-sizing: border-box; padding: 20px 24px 24px; display: flex; flex-direction: column; gap: 22px; border: 1px solid #E6E8EC; border-radius: 12px; background: #FFFFFF">
<fieldset style="display: flex; flex-direction: column; gap: 6px; margin: 0; padding: 0; border: 0">
<legend style="margin: 0 0 6px; padding: 0; font-weight: 600; color: #171C26">메일에 넣을 서명</legend>
<div style="display: flex; align-items: center; gap: 12px">
<label for="f2-def-new" style="flex: 0 0 84px; color: #344054">새 메일</label>
<select id="f2-def-new" class="field" aria-describedby="f2-def-hint" style="width: 240px; height: 44px; padding: 0 10px"><option selected>기본 서명</option><option>넣지 않음</option></select>
</div>
<div style="display: flex; align-items: center; gap: 12px">
<label for="f2-def-reply" style="flex: 0 0 84px; color: #344054">답장과 전달</label>
<select id="f2-def-reply" class="field" aria-describedby="f2-def-hint" style="width: 240px; height: 44px; padding: 0 10px"><option selected>기본 서명</option><option>넣지 않음</option></select>
</div>
<p id="f2-def-hint" style="margin: 2px 0 0; font-size: 12.5px; color: #667085">메일을 쓰기 시작할 때 본문에 넣어 둡니다. 고르는 대로 바로 저장됩니다.</p>
</fieldset>
<div style="width: 208px; display: flex; flex-direction: column; gap: 8px">
<p id="f2-list-label" style="margin: 0; font-weight: 600; color: #171C26">내 서명</p>
<ul aria-labelledby="f2-list-label" style="list-style: none; margin: 0; padding: 0; border: 1px solid #E6E8EC; border-radius: 10px; overflow: hidden">
<li style="border-top: 0"><button type="button" class="sig-row is-on" aria-current="true" style="display: flex; flex-direction: column; justify-content: center; gap: 1px; width: 100%; min-height: 52px; box-sizing: border-box; padding: 6px 12px; border: 0; font: inherit; text-align: left; cursor: pointer"><span style="max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-weight: 600; color: #0A5F67">기본 서명</span><span style="font-size: 12px; line-height: 1.4; color: #0A5F67">새 메일, 답장과 전달</span></button></li>
</ul>
<button type="button" class="ghost" style="align-self: flex-start; display: inline-flex; align-items: center; gap: 8px; min-height: 44px; margin-left: -2px; padding: 0 12px 0 10px; border: 0; border-radius: 8px; font: inherit; font-weight: 500; 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="12" y1="5" x2="12" y2="19"></line><line x1="5" y1="12" x2="19" y2="12"></line></svg><span>새 서명</span></button>
<p style="margin: 0; font-size: 12.5px; color: #667085">서명 1개를 만들었고, 10개까지 만들 수 있습니다.</p>
</div>
</div>
</div>
</div>
<div style="display: flex; flex-direction: column; gap: 10px; padding: 20px; background: #FFFFFF; border: 1px solid #E6E8EC; border-radius: 12px">
<h3 style="margin: 0; font-size: 14px; font-weight: 700">정한 것</h3>
<ul style="margin: 0; padding-left: 18px; display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 6px 40px; color: #344054">
<li>서명이 하나도 없으면 메일에 넣을 서명 칸을 감춥니다. 고를 서명이 없어서입니다. 첫 서명을 저장하면 새 메일, 답장과 전달 두 곳의 기본이 모두 그 서명이 되고 1의 모습이 됩니다(지금 두 체크가 처음에 켜져 있는 것과 같습니다).</li>
<li>지금 서명이 있던 직원은 그 서명이 기본 서명이라는 이름으로 옮겨지고, 지금 두 체크에서 켠 곳의 기본이 됩니다. 끈 곳은 넣지 않음입니다. 내용이 비어 있던 서명은 옮기지 않습니다.</li>
<li>서명이 없을 때 쓰기 화면의 [서명 ▾]에는 넣지 않음과 서명 관리만 있습니다(6 오른쪽 아래).</li>
</ul>
</div>
</section>
<section aria-labelledby="s3-h" style="flex: 0 0 auto; box-sizing: border-box; padding: 48px 48px 0; display: flex; flex-direction: column; gap: 14px">
<div style="display: flex; align-items: baseline; gap: 16px">
<h2 id="s3-h" style="margin: 0; font-size: 19px; line-height: 28px; font-weight: 700; color: #0B3640; white-space: nowrap">3. 새 서명</h2>
<p style="margin: 0; font-size: 13.5px; color: #4A5363">[새 서명]을 누르면 목록 끝에 새 줄이 생기고, 오른쪽에 빈 이름 칸과 서명 칸이 열립니다. 이름은 1~30자이고 같은 이름은 쓸 수 없습니다.</p>
</div>
<div style="display: flex; gap: 24px; align-items: flex-start">
<div style="flex: 0 0 800px; display: flex; flex-direction: column; gap: 10px">
<div style="display: flex; flex-direction: column; gap: 1px">
<h3 style="margin: 0; font-size: 13px; font-weight: 700; color: #344054">[새 서명]을 누른 뒤</h3>
<p style="margin: 0; font-size: 12.5px; color: #667085">1의 아래쪽(목록과 고치는 칸)만 그렸습니다 · 이름 칸에 초점</p>
</div>
<div style="box-sizing: border-box; padding: 24px 32px 28px; border: 1px solid #E6E8EC; border-radius: 12px; background: #FFFFFF">
<div style="display: flex; align-items: flex-start; gap: 24px">
<div style="flex: 0 0 208px; display: flex; flex-direction: column; gap: 8px">
<p id="f3-list-label" style="margin: 0; font-weight: 600; color: #171C26">내 서명</p>
<ul aria-labelledby="f3-list-label" style="list-style: none; margin: 0; padding: 0; border: 1px solid #E6E8EC; border-radius: 10px; overflow: hidden">
<li style="border-top: 0"><button type="button" class="sig-row" aria-current="false" style="display: flex; flex-direction: column; justify-content: center; gap: 1px; width: 100%; min-height: 52px; box-sizing: border-box; padding: 6px 12px; border: 0; font: inherit; text-align: left; cursor: pointer"><span style="max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-weight: 500; color: #171C26">사내</span><span style="font-size: 12px; line-height: 1.4; color: #667085">답장과 전달</span></button></li>
<li style="border-top: 1px solid #EEF0F3"><button type="button" class="sig-row" aria-current="false" style="display: flex; flex-direction: column; justify-content: center; gap: 1px; width: 100%; min-height: 52px; box-sizing: border-box; padding: 6px 12px; border: 0; font: inherit; text-align: left; cursor: pointer"><span style="max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-weight: 500; color: #171C26">대외</span><span style="font-size: 12px; line-height: 1.4; color: #667085">새 메일</span></button></li>
<li style="border-top: 1px solid #EEF0F3"><button type="button" class="sig-row" aria-current="false" style="display: flex; flex-direction: column; justify-content: center; gap: 1px; width: 100%; min-height: 52px; box-sizing: border-box; padding: 6px 12px; border: 0; font: inherit; text-align: left; cursor: pointer"><span style="max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-weight: 500; color: #171C26">영문</span></button></li>
<li style="border-top: 1px solid #EEF0F3"><button type="button" class="sig-row is-on" aria-current="true" style="display: flex; flex-direction: column; justify-content: center; gap: 1px; width: 100%; min-height: 52px; box-sizing: border-box; padding: 6px 12px; border: 0; font: inherit; text-align: left; cursor: pointer"><span style="max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-weight: 600; color: #0A5F67">새 서명</span><span style="font-size: 12px; line-height: 1.4; color: #0A5F67">저장하지 않음</span></button></li>
</ul>
<button type="button" class="ghost" style="align-self: flex-start; display: inline-flex; align-items: center; gap: 8px; min-height: 44px; margin-left: -2px; padding: 0 12px 0 10px; border: 0; border-radius: 8px; font: inherit; font-weight: 500; 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="12" y1="5" x2="12" y2="19"></line><line x1="5" y1="12" x2="19" y2="12"></line></svg><span>새 서명</span></button>
<p style="margin: 0; font-size: 12.5px; color: #667085">서명 3개를 만들었고, 10개까지 만들 수 있습니다.</p>
</div>
<div style="flex: 1 1 auto; min-width: 0; display: flex; flex-direction: column; gap: 22px">
<div style="display: flex; flex-direction: column; gap: 6px">
<label for="f3-name" style="font-weight: 600; color: #171C26">이름</label>
<input id="f3-name" class="field is-focus" type="text" value="" placeholder="예: 사내, 대외, 영문" maxlength="30" aria-describedby="f3-name-help" style="width: 320px; max-width: 100%; height: 44px; padding: 0 12px">
<p id="f3-name-help" style="margin: 0; font-size: 12.5px; color: #667085">메일을 쓸 때 [서명]에서 고르는 이름입니다. 30자까지 쓸 수 있습니다.</p>
</div>
<div style="display: flex; flex-direction: column; gap: 8px">
<p id="f3-sig-label" style="margin: 0; font-weight: 600; color: #171C26">서명 내용</p>
<div style="display: flex; flex-direction: column; border: 1px solid #D0D5DD; border-radius: 10px; background: #FFFFFF">
<div role="toolbar" aria-label="서명 서식" aria-controls="f3-sig-body" style="display: flex; align-items: center; gap: 2px; min-height: 52px; box-sizing: border-box; padding: 4px 8px; border-bottom: 1px solid #EEF0F3">
<button type="button" class="tb" aria-label="굵게" title="굵게" style="width: 40px; height: 44px; display: inline-flex; align-items: center; justify-content: center; padding: 0; border: 0; border-radius: 8px; cursor: pointer"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M6 4h8a4 4 0 0 1 0 8H6z"></path><path d="M6 12h9a4 4 0 0 1 0 8H6z"></path></svg></button>
<button type="button" class="tb" aria-label="기울임" title="기울임" style="width: 40px; height: 44px; display: inline-flex; align-items: center; justify-content: center; padding: 0; border: 0; border-radius: 8px; cursor: pointer"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="19" y1="4" x2="10" y2="4"></line><line x1="14" y1="20" x2="5" y2="20"></line><line x1="15" y1="4" x2="9" y2="20"></line></svg></button>
<button type="button" class="tb" aria-label="밑줄" title="밑줄" style="width: 40px; height: 44px; display: inline-flex; align-items: center; justify-content: center; padding: 0; border: 0; border-radius: 8px; cursor: pointer"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M6 3v7a6 6 0 0 0 12 0V3"></path><line x1="4" y1="21" x2="20" y2="21"></line></svg></button>
<span aria-hidden="true" style="flex: 0 0 auto; width: 1px; height: 20px; margin: 0 4px; background: #E6E8EC"></span>
<button type="button" class="tb" aria-label="글자 색" title="글자 색" style="width: 40px; height: 44px; display: inline-flex; 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="#1F2733" stroke="none"></rect></svg></button>
<button type="button" class="tb" aria-label="링크" title="링크" style="width: 40px; height: 44px; display: inline-flex; align-items: center; justify-content: center; padding: 0; border: 0; border-radius: 8px; cursor: pointer"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"></path><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"></path></svg></button>
<button type="button" class="tb" aria-label="그림 넣기" title="그림 넣기" style="width: 40px; height: 44px; display: inline-flex; align-items: center; justify-content: center; padding: 0; border: 0; border-radius: 8px; cursor: pointer"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="3" width="18" height="18" rx="2" ry="2"></rect><circle cx="8.5" cy="8.5" r="1.5"></circle><polyline points="21 15 16 10 5 21"></polyline></svg></button>
</div>
<div id="f3-sig-body" class="se" role="textbox" aria-multiline="true" aria-labelledby="f3-sig-label" tabindex="0" style="min-height: 132px; box-sizing: border-box; padding: 14px 16px; font-family: 'Malgun Gothic', '맑은 고딕', 'Apple SD Gothic Neo', sans-serif; font-size: 14px; line-height: 1.75; color: #1F2733"></div>
</div>
<p style="margin: 0; font-size: 12.5px; color: #667085">굵게, 기울임, 밑줄, 글자 색, 링크, 그림을 쓸 수 있습니다. 그림은 PNG, JPG, GIF 파일을 한 장 300KB 이하로 3장까지 넣을 수 있고 붙여 넣거나 끌어다 놓아도 됩니다. 그림은 메일에 함께 실어 보내서 받는 사람 쪽에서 외부 이미지처럼 막히지 않습니다.</p>
</div>
<div style="display: flex; flex-wrap: wrap; align-items: center; gap: 8px">
<button type="button" class="primary" style="display: inline-flex; align-items: center; min-height: 44px; padding: 0 22px; border: 0; border-radius: 8px; font: inherit; font-size: 13.5px; font-weight: 600; cursor: pointer">저장</button>
<button type="button" class="ghost" style="min-height: 44px; padding: 0 16px; border: 0; border-radius: 8px; font: inherit; font-weight: 500; cursor: pointer">취소</button>
</div>
</div>
</div>
</div>
</div>
<div style="flex: 1 1 auto; min-width: 0; display: flex; flex-direction: column; gap: 22px">
<div style="display: flex; flex-direction: column; gap: 10px">
<div style="display: flex; flex-direction: column; gap: 1px">
<h3 style="margin: 0; font-size: 13px; font-weight: 700; color: #344054">이름 없이 [저장]을 눌렀을 때</h3>
<p style="margin: 0; font-size: 12.5px; color: #667085">칸 아래 안내 대신 빨간 글 · 알림은 띄우지 않습니다</p>
</div>
<div style="box-sizing: border-box; display: flex; flex-direction: column; gap: 6px; padding: 16px 20px 18px; border: 1px solid #E6E8EC; border-radius: 12px; background: #FFFFFF">
<label for="f3a-name" style="font-weight: 600; color: #171C26">이름</label>
<input id="f3a-name" class="field is-error" type="text" value="" placeholder="예: 사내, 대외, 영문" maxlength="30" aria-invalid="true" aria-describedby="f3a-err" style="width: 320px; max-width: 100%; height: 44px; padding: 0 12px">
<p id="f3a-err" style="display: flex; align-items: flex-start; gap: 6px; margin: 0; font-size: 12.5px; color: #B42318"><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-top: 2px"><circle cx="12" cy="12" r="10"></circle><line x1="12" y1="8" x2="12" y2="12"></line><line x1="12" y1="16" x2="12.01" y2="16"></line></svg><span>이름을 넣어 주세요.</span></p>
</div>
</div>
<div style="display: flex; flex-direction: column; gap: 10px">
<div style="display: flex; flex-direction: column; gap: 1px">
<h3 style="margin: 0; font-size: 13px; font-weight: 700; color: #344054">같은 이름이 있을 때</h3>
<p style="margin: 0; font-size: 12.5px; color: #667085">대외가 이미 있습니다</p>
</div>
<div style="box-sizing: border-box; display: flex; flex-direction: column; gap: 6px; padding: 16px 20px 18px; border: 1px solid #E6E8EC; border-radius: 12px; background: #FFFFFF">
<label for="f3b-name" style="font-weight: 600; color: #171C26">이름</label>
<input id="f3b-name" class="field is-error" type="text" value="대외" maxlength="30" aria-invalid="true" aria-describedby="f3b-err" style="width: 320px; max-width: 100%; height: 44px; padding: 0 12px">
<p id="f3b-err" style="display: flex; align-items: flex-start; gap: 6px; margin: 0; font-size: 12.5px; color: #B42318"><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-top: 2px"><circle cx="12" cy="12" r="10"></circle><line x1="12" y1="8" x2="12" y2="12"></line><line x1="12" y1="16" x2="12.01" y2="16"></line></svg><span>같은 이름의 서명이 있습니다.</span></p>
</div>
</div>
<div style="display: flex; flex-direction: column; gap: 10px">
<div style="display: flex; flex-direction: column; gap: 1px">
<h3 style="margin: 0; font-size: 13px; font-weight: 700; color: #344054">10개를 다 만들었을 때</h3>
<p style="margin: 0; font-size: 12.5px; color: #667085">목록 끝 두 줄만 · [새 서명]은 초점은 받고 누를 수 없습니다</p>
</div>
<div style="box-sizing: border-box; padding: 16px 20px 18px; border: 1px solid #E6E8EC; border-radius: 12px; background: #FFFFFF">
<div style="width: 208px; display: flex; flex-direction: column; gap: 8px">
<ul aria-label="내 서명" style="list-style: none; margin: 0; padding: 0; border: 1px solid #E6E8EC; border-radius: 10px; overflow: hidden">
<li style="border-top: 0"><button type="button" class="sig-row" aria-current="false" style="display: flex; flex-direction: column; justify-content: center; gap: 1px; width: 100%; min-height: 52px; box-sizing: border-box; padding: 6px 12px; border: 0; font: inherit; text-align: left; cursor: pointer"><span style="max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-weight: 500; color: #171C26">행사 안내</span></button></li>
<li style="border-top: 1px solid #EEF0F3"><button type="button" class="sig-row" aria-current="false" style="display: flex; flex-direction: column; justify-content: center; gap: 1px; width: 100%; min-height: 52px; box-sizing: border-box; padding: 6px 12px; border: 0; font: inherit; text-align: left; cursor: pointer"><span style="max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-weight: 500; color: #171C26">견적용</span></button></li>
</ul>
<button type="button" class="ghost-off" aria-disabled="true" aria-describedby="f3c-full" style="align-self: flex-start; display: inline-flex; align-items: center; gap: 8px; min-height: 44px; margin-left: -2px; padding: 0 12px 0 10px; border: 0; border-radius: 8px; font: inherit; font-weight: 500; cursor: not-allowed"><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="12" y1="5" x2="12" y2="19"></line><line x1="5" y1="12" x2="19" y2="12"></line></svg><span>새 서명</span></button>
<p id="f3c-full" style="margin: 0; font-size: 12.5px; color: #667085">서명은 10개까지 만들 수 있습니다. 쓰지 않는 서명을 지워 주세요.</p>
</div>
</div>
</div>
</div>
</div>
<div style="display: flex; flex-direction: column; gap: 10px; padding: 20px; background: #FFFFFF; border: 1px solid #E6E8EC; border-radius: 12px">
<h3 style="margin: 0; font-size: 14px; font-weight: 700">정한 것</h3>
<ul style="margin: 0; padding-left: 18px; display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 6px 40px; color: #344054">
<li>새 줄은 처음 [저장]할 때 만들어지고, 그 전까지 이름 아래에 저장하지 않음이라고 적습니다. [취소]를 누르면 새 줄이 사라지고 전에 고른 서명이 열립니다. 쓴 것이 있으면 5처럼 묻습니다. 새 서명에는 [이 서명 지우기] 대신 [취소]가 있습니다.</li>
<li>이름은 앞뒤 공백을 빼고 1~30자이고, 31자째는 칸에 들어가지 않습니다. 같은 이름인지는 영문 대소문자를 가리지 않고 봅니다.</li>
<li>이름이 비었거나 같은 이름이 있으면 [저장]할 때 칸 아래 안내를 빨간 글로 바꾸고 이름 칸에 초점을 둡니다. 알림은 띄우지 않고, 이름을 고치면 안내로 돌아갑니다.</li>
<li>그사이 다른 창에서 같은 이름을 만들었으면 서버가 거절하고 화면은 같은 빨간 글을 보입니다. 오류 코드는 구현 때 정합니다.</li>
<li>서명이 10개면 [새 서명]은 누를 수 없고(aria-disabled, 초점은 받습니다) 목록 아래 글이 그 까닭으로 바뀝니다. 필터의 50개와 같은 꼴입니다.</li>
<li>새로 만든 서명은 메일에 넣을 서명 두 칸을 바꾸지 않습니다. 첫 서명만 두 곳의 기본이 됩니다(2).</li>
</ul>
</div>
</section>
<section aria-labelledby="s4-h" style="flex: 0 0 auto; box-sizing: border-box; padding: 48px 48px 0; display: flex; flex-direction: column; gap: 14px">
<div style="display: flex; align-items: baseline; gap: 16px">
<h2 id="s4-h" style="margin: 0; font-size: 19px; line-height: 28px; font-weight: 700; color: #0B3640; white-space: nowrap">4. 서명 지우기</h2>
<p style="margin: 0; font-size: 13.5px; color: #4A5363">[이 서명 지우기]를 누르면 묻고 지웁니다. 그 서명을 기본으로 넣던 메일이 있으면, 지운 뒤 그 메일에는 서명을 넣지 않는다는 것을 함께 알립니다.</p>
</div>
<div style="display: flex; gap: 24px; align-items: flex-start">
<div style="flex: 0 0 420px; display: flex; flex-direction: column; gap: 10px">
<div style="display: flex; flex-direction: column; gap: 1px">
<h3 style="margin: 0; font-size: 13px; font-weight: 700; color: #344054">기본으로 넣던 서명</h3>
<p style="margin: 0; font-size: 12.5px; color: #667085">대외는 새 메일에 넣는 서명 · 처음 초점은 [취소]</p>
</div>
<div style="box-sizing: border-box; height: 280px; display: flex; align-items: center; justify-content: center; border-radius: 12px; background: rgba(11, 54, 64, 0.32)"><div role="alertdialog" aria-modal="true" aria-labelledby="f4a-title" aria-describedby="f4a-msg" style="width: 360px; box-sizing: border-box; display: flex; flex-direction: column; gap: 8px; padding: 18px 18px 14px; border: 1px solid #E6E8EC; border-radius: 12px; background: #FFFFFF; box-shadow: 0 14px 36px rgba(16, 24, 28, 0.14)">
<h3 id="f4a-title" style="margin: 0; font-size: 15px; font-weight: 700">‘대외’ 서명을 지울까요?</h3>
<p id="f4a-msg" style="margin: 0; color: #4A5363">새 메일에 넣는 서명이라 지우면 앞으로 새 메일에는 서명을 넣지 않습니다. 지운 서명은 되돌릴 수 없습니다.</p>
<div style="display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 8px; margin-top: 4px">
<button type="button" class="danger-solid" style="min-height: 44px; padding: 0 16px; border: 0; border-radius: 8px; font: inherit; font-weight: 600; cursor: pointer">지우기</button><button type="button" class="ghost" style="outline: 2px solid #0E7C86; outline-offset: 2px; min-height: 44px; padding: 0 16px; border: 0; border-radius: 8px; font: inherit; font-weight: 500; cursor: pointer">취소</button>
</div>
</div></div>
</div>
<div style="flex: 0 0 420px; display: flex; flex-direction: column; gap: 10px">
<div style="display: flex; flex-direction: column; gap: 1px">
<h3 style="margin: 0; font-size: 13px; font-weight: 700; color: #344054">기본이 아닌 서명</h3>
<p style="margin: 0; font-size: 12.5px; color: #667085">영문</p>
</div>
<div style="box-sizing: border-box; height: 280px; display: flex; align-items: center; justify-content: center; border-radius: 12px; background: rgba(11, 54, 64, 0.32)"><div role="alertdialog" aria-modal="true" aria-labelledby="f4b-title" aria-describedby="f4b-msg" style="width: 360px; box-sizing: border-box; display: flex; flex-direction: column; gap: 8px; padding: 18px 18px 14px; border: 1px solid #E6E8EC; border-radius: 12px; background: #FFFFFF; box-shadow: 0 14px 36px rgba(16, 24, 28, 0.14)">
<h3 id="f4b-title" style="margin: 0; font-size: 15px; font-weight: 700">‘영문’ 서명을 지울까요?</h3>
<p id="f4b-msg" style="margin: 0; color: #4A5363">지운 서명은 되돌릴 수 없습니다.</p>
<div style="display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 8px; margin-top: 4px">
<button type="button" class="danger-solid" style="min-height: 44px; padding: 0 16px; border: 0; border-radius: 8px; font: inherit; font-weight: 600; cursor: pointer">지우기</button><button type="button" class="ghost" style="outline: 2px solid #0E7C86; outline-offset: 2px; min-height: 44px; padding: 0 16px; border: 0; border-radius: 8px; font: inherit; font-weight: 500; cursor: pointer">취소</button>
</div>
</div></div>
<div style="display: flex; flex-direction: column; gap: 8px; margin-top: 12px">
<div style="display: flex; flex-direction: column; gap: 1px">
<h3 style="margin: 0; font-size: 13px; font-weight: 700; color: #344054">지운 뒤</h3>
<p style="margin: 0; font-size: 12.5px; color: #667085">알림 · 목록의 첫 서명을 엽니다</p>
</div>
<div role="status" style="width: 400px; box-sizing: border-box; display: flex; align-items: center; gap: 10px; min-height: 44px; padding: 8px 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; min-width: 0; font-weight: 500">‘영문’ 서명을 지웠습니다.</span></div>
</div>
</div>
<div style="flex: 1 1 auto; min-width: 0">
<div style="display: flex; flex-direction: column; gap: 10px; padding: 20px; background: #FFFFFF; border: 1px solid #E6E8EC; border-radius: 12px">
<h3 style="margin: 0; font-size: 14px; font-weight: 700">정한 것</h3>
<ul style="margin: 0; padding-left: 18px; display: flex; flex-direction: column; gap: 6px; color: #344054">
<li>처음 초점은 [취소]이고 Esc도 [취소]와 같습니다. 지금 메일함 지우기 창과 같은 모양(폭 360)입니다.</li>
<li>기본으로 넣던 곳은 넣지 않음이 됩니다. 다른 서명이 저절로 기본이 되지 않습니다.</li>
<li>두 곳 모두의 기본이면 둘째 글은 "새 메일에도, 답장과 전달에도 넣는 서명이라 지우면 앞으로 어느 메일에도 서명을 넣지 않습니다. 지운 서명은 되돌릴 수 없습니다."이고, 답장과 전달만이면 "답장과 전달에 넣는 서명이라 지우면 앞으로 답장과 전달에는 서명을 넣지 않습니다. 지운 서명은 되돌릴 수 없습니다."입니다.</li>
<li>지운 뒤에는 목록의 첫 서명을 엽니다. 마지막 서명을 지우면 2의 빈 화면이 됩니다.</li>
<li>이미 보냈거나 임시 저장한 메일에 들어간 서명은 그대로 남습니다. 그 서명에만 쓰던 그림은 함께 지웁니다.</li>
</ul>
</div>
</div>
</div>
</section>
<section aria-labelledby="s5-h" style="flex: 0 0 auto; box-sizing: border-box; padding: 48px 48px 0; display: flex; flex-direction: column; gap: 14px">
<div style="display: flex; align-items: baseline; gap: 16px">
<h2 id="s5-h" style="margin: 0; font-size: 19px; line-height: 28px; font-weight: 700; color: #0B3640; white-space: nowrap">5. 저장하지 않고 다른 서명을 고르거나 화면을 떠날 때</h2>
<p style="margin: 0; font-size: 13.5px; color: #4A5363">지금 나가기 확인과 같은 창입니다. 다른 서명을 고를 때만 글과 둘째 단추가 다릅니다.</p>
</div>
<div style="display: flex; gap: 24px; align-items: flex-start">
<div style="flex: 0 0 420px; display: flex; flex-direction: column; gap: 10px">
<div style="display: flex; flex-direction: column; gap: 1px">
<h3 style="margin: 0; font-size: 13px; font-weight: 700; color: #344054">대외를 고치다 목록에서 사내를 눌렀을 때</h3>
<p style="margin: 0; font-size: 12.5px; color: #667085">처음 초점은 [계속 고치기]</p>
</div>
<div style="box-sizing: border-box; height: 260px; display: flex; align-items: center; justify-content: center; border-radius: 12px; background: rgba(11, 54, 64, 0.32)"><div role="alertdialog" aria-modal="true" aria-labelledby="f5a-title" aria-describedby="f5a-msg" style="width: 360px; box-sizing: border-box; display: flex; flex-direction: column; gap: 8px; padding: 18px 18px 14px; border: 1px solid #E6E8EC; border-radius: 12px; background: #FFFFFF; box-shadow: 0 14px 36px rgba(16, 24, 28, 0.14)">
<h3 id="f5a-title" style="margin: 0; font-size: 15px; font-weight: 700">저장하지 않은 서명이 있습니다</h3>
<p id="f5a-msg" style="margin: 0; color: #4A5363">다른 서명을 고르면 ‘대외’ 서명에서 바꾼 내용이 사라집니다.</p>
<div style="display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 8px; margin-top: 4px">
<button type="button" class="primary" style="outline: 2px solid #0E7C86; outline-offset: 2px; min-height: 44px; padding: 0 16px; border: 0; border-radius: 8px; font: inherit; font-weight: 600; cursor: pointer">계속 고치기</button><button type="button" class="ghost" style="min-height: 44px; padding: 0 16px; border: 0; border-radius: 8px; font: inherit; font-weight: 500; cursor: pointer">바꾼 내용 버리기</button>
</div>
</div></div>
</div>
<div style="flex: 0 0 420px; display: flex; flex-direction: column; gap: 10px">
<div style="display: flex; flex-direction: column; gap: 1px">
<h3 style="margin: 0; font-size: 13px; font-weight: 700; color: #344054">다른 화면으로 갈 때</h3>
<p style="margin: 0; font-size: 12.5px; color: #667085">지금 서명 화면의 나가기 확인 그대로</p>
</div>
<div style="box-sizing: border-box; height: 260px; display: flex; align-items: center; justify-content: center; border-radius: 12px; background: rgba(11, 54, 64, 0.32)"><div role="alertdialog" aria-modal="true" aria-labelledby="f5b-title" aria-describedby="f5b-msg" style="width: 360px; box-sizing: border-box; display: flex; flex-direction: column; gap: 8px; padding: 18px 18px 14px; border: 1px solid #E6E8EC; border-radius: 12px; background: #FFFFFF; box-shadow: 0 14px 36px rgba(16, 24, 28, 0.14)">
<h3 id="f5b-title" style="margin: 0; font-size: 15px; font-weight: 700">저장하지 않은 서명이 있습니다</h3>
<p id="f5b-msg" style="margin: 0; color: #4A5363">이 화면을 나가면 바꾼 서명이 사라집니다.</p>
<div style="display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 8px; margin-top: 4px">
<button type="button" class="primary" style="outline: 2px solid #0E7C86; outline-offset: 2px; min-height: 44px; padding: 0 16px; border: 0; border-radius: 8px; font: inherit; font-weight: 600; cursor: pointer">계속 고치기</button><button type="button" class="ghost" style="min-height: 44px; padding: 0 16px; border: 0; border-radius: 8px; font: inherit; font-weight: 500; cursor: pointer">나가기</button>
</div>
</div></div>
</div>
<div style="flex: 1 1 auto; min-width: 0">
<div style="display: flex; flex-direction: column; gap: 10px; padding: 20px; background: #FFFFFF; border: 1px solid #E6E8EC; border-radius: 12px">
<h3 style="margin: 0; font-size: 14px; font-weight: 700">정한 것</h3>
<ul style="margin: 0; padding-left: 18px; display: flex; flex-direction: column; gap: 6px; color: #344054">
<li>처음 초점은 [계속 고치기]이고 Esc도 같습니다. [바꾼 내용 버리기]는 바꾼 것을 버리고 누른 서명을 엽니다.</li>
<li>[새 서명]을 누를 때의 글은 "새 서명을 만들면 ‘대외’ 서명에서 바꾼 내용이 사라집니다."이고 단추는 같습니다.</li>
<li>바꾼 것이 없으면 묻지 않습니다. 메일에 넣을 서명 두 칸은 고를 때 바로 저장되므로 묻는 까닭이 되지 않습니다.</li>
<li>탭을 닫거나 새로 고칠 때는 지금처럼 브라우저가 묻습니다. 저장하는 중이거나 세션이 끝났을 때는 묻지 않습니다(지금과 같습니다).</li>
</ul>
</div>
</div>
</div>
</section>
<section aria-labelledby="s6-h" style="flex: 0 0 auto; box-sizing: border-box; padding: 48px 48px 0; display: flex; flex-direction: column; gap: 14px">
<div style="display: flex; align-items: baseline; gap: 16px">
<h2 id="s6-h" style="margin: 0; font-size: 19px; line-height: 28px; font-weight: 700; color: #0B3640; white-space: nowrap">6. 쓰기 화면 [서명 ▾]</h2>
<p style="margin: 0; font-size: 13.5px; color: #4A5363">아래 단추 줄의 [임시 저장] 다음에 둡니다. 고르면 본문 안의 서명 자리만 그 서명으로 바뀌고, 쓴 글과 인용은 그대로입니다. 메뉴 항목을 누르면 본문의 서명이 바뀝니다.</p>
</div>
<div style="display: flex; gap: 24px; align-items: flex-start">
<div style="flex: 0 0 800px; display: flex; flex-direction: column; gap: 10px">
<div style="display: flex; flex-direction: column; gap: 1px">
<h3 style="margin: 0; font-size: 13px; font-weight: 700; color: #344054">새 메일 · [서명 ▾]를 연 모습</h3>
<p style="margin: 0; font-size: 12.5px; color: #667085">사내 동료에게 쓰는 새 메일 · 새 메일 기본인 대외가 들어 있습니다</p>
</div>
<div role="group" aria-label="새 메일 쓰기" style="position: relative; box-sizing: border-box; display: flex; flex-direction: column; border: 1px solid #E6E8EC; border-radius: 12px; background: #FFFFFF">
<div style="display: flex; align-items: center; gap: 2px; min-height: 52px; box-sizing: border-box; padding: 4px 18px 4px 32px; border-bottom: 1px solid #E6E8EC">
<h3 style="flex: 1 1 auto; margin: 0; font-size: 15px; font-weight: 700; letter-spacing: -0.01em">새 메일</h3>
<button type="button" class="ghost" aria-label="이 메일 삭제" title="삭제" style="width: 44px; height: 44px; display: inline-flex; align-items: center; justify-content: center; padding: 0; border: 0; border-radius: 8px; cursor: pointer"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="3 6 5 6 21 6"></polyline><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path></svg></button>
<button type="button" class="ghost" aria-label="쓰기 닫기" title="닫기" style="width: 44px; height: 44px; display: inline-flex; align-items: center; justify-content: center; padding: 0; border: 0; border-radius: 8px; cursor: pointer"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line></svg></button>
</div>
<div style="display: flex; align-items: center; gap: 6px 12px; min-height: 44px; box-sizing: border-box; padding: 4px 22px 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="display: inline-flex; align-items: center; min-height: 28px; padding: 0 8px; border-radius: 4px; background: #F2F4F7; color: #171C26">이도윤</span>
<span style="flex: 1 1 auto"></span>
<button type="button" 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>
<div style="display: flex; align-items: center; gap: 6px 12px; min-height: 44px; box-sizing: border-box; padding: 4px 22px 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="font-size: 14px; font-weight: 600">다음 주 정기 점검 일정 공유</span>
</div>
<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">
<button type="button" class="tb" aria-label="실행 취소" title="실행 취소" style="width: 40px; height: 44px; display: inline-flex; align-items: center; justify-content: center; padding: 0; border: 0; border-radius: 8px; cursor: pointer"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="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" aria-label="다시 실행" title="다시 실행" style="width: 40px; height: 44px; display: inline-flex; align-items: center; justify-content: center; padding: 0; border: 0; border-radius: 8px; cursor: pointer"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="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>
<button type="button" class="tb" aria-haspopup="menu" aria-expanded="false" 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; font: inherit; font-weight: 500; cursor: pointer"><span style="flex: 1 1 auto; min-width: 0; 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" class="tb" aria-haspopup="menu" aria-expanded="false" 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; 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>
<button type="button" class="tb" aria-label="굵게" title="굵게" style="width: 40px; height: 44px; display: inline-flex; align-items: center; justify-content: center; padding: 0; border: 0; border-radius: 8px; cursor: pointer"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M6 4h8a4 4 0 0 1 0 8H6z"></path><path d="M6 12h9a4 4 0 0 1 0 8H6z"></path></svg></button>
<button type="button" class="tb" aria-label="기울임" title="기울임" style="width: 40px; height: 44px; display: inline-flex; align-items: center; justify-content: center; padding: 0; border: 0; border-radius: 8px; cursor: pointer"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="19" y1="4" x2="10" y2="4"></line><line x1="14" y1="20" x2="5" y2="20"></line><line x1="15" y1="4" x2="9" y2="20"></line></svg></button>
<button type="button" class="tb" aria-label="밑줄" title="밑줄" style="width: 40px; height: 44px; display: inline-flex; align-items: center; justify-content: center; padding: 0; border: 0; border-radius: 8px; cursor: pointer"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M6 3v7a6 6 0 0 0 12 0V3"></path><line x1="4" y1="21" x2="20" y2="21"></line></svg></button>
<span aria-hidden="true" style="flex: 0 0 auto; width: 1px; height: 20px; margin: 0 4px; background: #E6E8EC"></span>
<button type="button" class="tb" aria-label="글자 색: 기본" title="글자 색: 기본" style="width: 40px; height: 44px; display: inline-flex; 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="#1F2733" stroke="none"></rect></svg></button>
<button type="button" class="tb" aria-label="배경 색: 없음" title="배경 색: 없음" style="width: 40px; height: 44px; display: inline-flex; align-items: center; justify-content: center; padding: 0; border: 0; border-radius: 8px; cursor: pointer"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3.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" class="tb" aria-label="글머리 목록" title="글머리 목록" style="width: 40px; height: 44px; display: inline-flex; align-items: center; justify-content: center; padding: 0; border: 0; border-radius: 8px; cursor: pointer"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="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" aria-label="번호 목록" title="번호 목록" style="width: 40px; height: 44px; display: inline-flex; align-items: center; justify-content: center; padding: 0; border: 0; border-radius: 8px; cursor: pointer"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="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" class="tb" aria-label="링크" title="링크" style="width: 40px; height: 44px; display: inline-flex; align-items: center; justify-content: center; padding: 0; border: 0; border-radius: 8px; cursor: pointer"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"></path><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"></path></svg></button>
<button type="button" class="tb" aria-label="서식 더보기" title="서식 더보기" style="width: 40px; height: 44px; display: inline-flex; align-items: center; justify-content: center; padding: 0; border: 0; border-radius: 8px; cursor: pointer"><svg width="16" height="16" viewBox="0 0 24 24" fill="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: 300px; box-sizing: border-box; padding: 16px 32px 28px; font-family: 'Malgun Gothic', '맑은 고딕', 'Apple SD Gothic Neo', sans-serif; font-size: 14px; line-height: 1.75; color: #1F2733">
<p style="margin: 0">도윤님, 안녕하세요.</p>
<p style="margin: 0"><br></p>
<p style="margin: 0">다음 주 정기 점검 일정을 공유드립니다.</p>
<p style="margin: 0">11월 21일 금요일 오후 10시부터 한 시간 동안 메일 서버를 점검합니다.</p>
<p style="margin: 0"><br></p>
<p style="margin: 0">감사합니다.</p>
<sc-if value="{{ cHasSig }}" hint-placeholder-val="{{ true }}">
<p style="margin: 0"><br></p>
<div>
<sc-if value="{{ cIn }}" hint-placeholder-val="{{ false }}"><p style="margin: 0"><strong>김아라</strong> · 인프라팀</p><p style="margin: 0">내선 [내선 번호]</p></sc-if>
<sc-if value="{{ cOut }}" hint-placeholder-val="{{ true }}"><p style="margin: 0"><strong>김아라</strong> · 인프라팀</p><p style="margin: 0">ARA SYSTEM · [전화번호]</p><p style="margin: 0"><a href="#">ara.kim@arasystem.it.kr</a></p><p style="margin: 8px 0 0"><span role="img" aria-label="[회사 로고]" style="display: inline-flex; align-items: center; justify-content: center; box-sizing: border-box; width: 144px; height: 43px; border: 1px dashed #98A2B3; border-radius: 4px; color: #667085; font-family: Pretendard, -apple-system, 'Apple SD Gothic Neo', 'Malgun Gothic', system-ui, sans-serif; font-size: 12px">[회사 로고]</span></p></sc-if>
<sc-if value="{{ cEn }}" hint-placeholder-val="{{ false }}"><p style="margin: 0"><strong>Ara Kim</strong> · Infrastructure Team</p><p style="margin: 0">ARA SYSTEM · [전화번호]</p><p style="margin: 0"><a href="#">ara.kim@arasystem.it.kr</a></p></sc-if>
</div>
</sc-if>
</div>
<div style="display: flex; flex-wrap: wrap; align-items: center; gap: 4px 10px; padding: 10px 32px 14px">
<button type="button" class="ghost" 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; 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 · 합계 25MB · 최대 20개 · 더 큰 파일은 파일당 2GB, 10개까지 링크로 보냅니다</span>
</div>
<div style="display: flex; flex-wrap: wrap; align-items: center; gap: 8px; padding: 10px 32px; border-top: 1px solid #E6E8EC">
<div style="display: inline-flex; align-items: stretch">
<button type="button" class="primary" style="display: inline-flex; align-items: center; gap: 8px; min-height: 44px; padding: 0 16px 0 18px; border: 0; border-radius: 8px 0 0 8px; font: inherit; font-size: 13.5px; font-weight: 600; cursor: pointer"><svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="22" y1="2" x2="11" y2="13"></line><polygon points="22 2 15 22 11 13 2 9 22 2"></polygon></svg><span>보내기</span></button>
<span aria-hidden="true" style="width: 1px; background: #0E7C86; box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.35)"></span>
<button type="button" class="primary" aria-label="보내기 방법 더 보기" title="보내기 방법 더 보기" aria-haspopup="menu" aria-expanded="false" style="width: 44px; min-height: 44px; display: inline-flex; align-items: center; justify-content: center; padding: 0; border: 0; border-radius: 0 8px 8px 0; cursor: pointer"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="6 9 12 15 18 9"></polyline></svg></button>
</div>
<button type="button" class="ghost" style="min-height: 44px; padding: 0 14px; border: 0; border-radius: 8px; font: inherit; font-weight: 500; cursor: pointer">임시 저장</button>
<div style="position: relative; display: inline-flex">
<button type="button" class="tb" aria-haspopup="menu" aria-expanded="true" aria-controls="f6-menu" aria-label="{{ cButtonLabel }}" style="display: inline-flex; align-items: center; gap: 4px; min-height: 44px; padding: 0 10px 0 14px; border: 0; border-radius: 8px; font: inherit; font-weight: 500; cursor: pointer"><span>서명</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"><polyline points="6 9 12 15 18 9"></polyline></svg></button>
<div id="f6-menu" role="menu" aria-label="서명" style="position: absolute; left: 0; bottom: calc(100% + 4px); z-index: 5; width: 220px; box-sizing: border-box; display: flex; flex-direction: column; padding: 6px; border: 1px solid #E6E8EC; border-radius: 10px; background: #FFFFFF; box-shadow: 0 10px 28px rgba(16, 24, 28, 0.12)">
<sc-for list="{{ cMenu }}" as="c" hint-placeholder-count="4">
<button type="button" role="menuitemradio" aria-checked="{{ c.checked }}" class="menu-item" onClick="{{ c.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; text-align: left; cursor: pointer">
<span style="flex: 1 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap">{{ c.label }}</span>
<span style="flex: 0 0 auto; display: inline-flex; width: 16px; height: 16px">
<sc-if value="{{ c.isChecked }}" 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>
</sc-for>
<hr style="width: 100%; height: 1px; margin: 4px 0; border: 0; background: #EEF0F3">
<button type="button" role="menuitem" class="menu-item" 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">서명 관리</button>
</div>
</div>
<span role="status" style="display: inline-flex; align-items: center; gap: 6px; font-size: 12px; color: #667085"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="#0E7C86" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="20 6 9 17 4 12"></polyline></svg><span>오후 3:12에 임시 저장됨</span></span>
<span style="margin-left: auto; font-size: 12px; color: #667085"><span style="margin-right: 6px">보내는 사람</span>김아라 &lt;ara.kim@arasystem.it.kr&gt;</span>
</div>
</div>
</div>
<div style="flex: 1 1 auto; min-width: 0; display: flex; flex-direction: column; gap: 22px">
<div style="display: flex; flex-direction: column; gap: 10px; padding: 20px; background: #FFFFFF; border: 1px solid #E6E8EC; border-radius: 12px">
<h3 style="margin: 0; font-size: 14px; font-weight: 700">정한 것</h3>
<ul style="margin: 0; padding-left: 18px; display: flex; flex-direction: column; gap: 6px; color: #344054">
<li>자리를 아래 단추 줄로 고른 까닭: 편집기 툴바는 서식 단추가 좁은 폭에서 정해진 차례로 더보기에 접히는데, 서명은 서식이 아니라 메일 전체에 붙는 것이라 그 차례에 넣지 않았습니다. 아래 줄은 보내기 직전에 보는 곳이고, 메뉴는 [보내기 ▾]와 같은 부품이라 아래 자리가 모자라면 위로 엽니다(아래 줄에서는 보통 위). Gmail도 서명을 아래 줄에 둡니다.</li>
<li>단추는 [임시 저장]과 같은 글자 단추(44)에 ▾를 붙입니다. 화면 낭독은 툴바의 글꼴 단추처럼 지금 서명까지 읽습니다(서명: 대외).</li>
<li>메뉴는 편집기 메뉴(항목 40, 폭 220)입니다. 서명은 만든 차례이고 지금 본문에 든 서명에 ✓를 붙입니다. 그 아래 넣지 않음, 가는 선, 서명 관리 차례입니다.</li>
<li>서명 관리는 설정 › 메일 › 서명으로 갑니다. 쓰던 메일은 지금 나가기 규칙대로 먼저 임시 저장하고, 저장하지 못했을 때만 묻습니다.</li>
<li>바꾸면 화면 낭독기에 "서명을 ‘사내’로 바꿨습니다."를 읽어 주고, 알림은 띄우지 않습니다.</li>
<li>키보드는 [보내기 ▾]와 같습니다. Enter, Space, 아래 화살표로 열면 첫 항목에 초점이 가고, Esc는 메뉴만 닫고 [서명 ▾]로 돌아갑니다.</li>
</ul>
</div>
<div style="display: flex; flex-direction: column; gap: 10px">
<div style="display: flex; flex-direction: column; gap: 1px">
<h3 style="margin: 0; font-size: 13px; font-weight: 700; color: #344054">서명이 없을 때의 메뉴</h3>
<p style="margin: 0; font-size: 12.5px; color: #667085">2의 직원 · 넣지 않음과 서명 관리만</p>
</div>
<div role="menu" aria-label="서명" style="width: 220px; box-sizing: border-box; display: flex; flex-direction: column; padding: 6px; border: 1px solid #E6E8EC; border-radius: 10px; background: #FFFFFF; box-shadow: 0 10px 28px rgba(16, 24, 28, 0.12)">
<button type="button" role="menuitemradio" aria-checked="true" class="menu-item" 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"><span style="flex: 1 1 auto; min-width: 0">넣지 않음</span><span style="flex: 0 0 auto; display: inline-flex; width: 16px; height: 16px"><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></span></button>
<hr style="width: 100%; height: 1px; margin: 4px 0; border: 0; background: #EEF0F3">
<button type="button" role="menuitem" class="menu-item" 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"><span style="flex: 1 1 auto; min-width: 0">서명 관리</span><span style="flex: 0 0 auto; display: inline-flex; width: 16px; height: 16px"></span></button>
</div>
</div>
</div>
</div>
<div style="display: flex; flex-direction: column; gap: 10px; padding-top: 18px">
<h3 style="margin: 0; font-size: 14px; font-weight: 700; color: #0B3640">새 메일에서 바꾸기 전과 뒤</h3>
<div style="display: flex; align-items: flex-start; gap: 16px">
<div style="flex: 1 1 0; min-width: 0; display: flex; flex-direction: column; gap: 10px">
<div style="display: flex; flex-direction: column; gap: 1px">
<h3 style="margin: 0; font-size: 13px; font-weight: 700; color: #344054">바꾸기 전</h3>
<p style="margin: 0; font-size: 12.5px; color: #667085">새 메일에 기본으로 넣은 대외</p>
</div>
<div style="box-sizing: border-box; padding: 16px 32px 24px; border: 1px solid #E6E8EC; border-radius: 10px; background: #FFFFFF; font-family: 'Malgun Gothic', '맑은 고딕', 'Apple SD Gothic Neo', sans-serif; font-size: 14px; line-height: 1.75; color: #1F2733">
<p style="margin: 0">도윤님, 안녕하세요.</p>
<p style="margin: 0"><br></p>
<p style="margin: 0">다음 주 정기 점검 일정을 공유드립니다.</p>
<p style="margin: 0">11월 21일 금요일 오후 10시부터 한 시간 동안 메일 서버를 점검합니다.</p>
<p style="margin: 0"><br></p>
<p style="margin: 0">감사합니다.</p>
<p style="margin: 0"><br></p>
<div style="position: relative; outline: 2px dashed #DC6803; outline-offset: 6px; border-radius: 2px"><p style="margin: 0"><strong>김아라</strong> · 인프라팀</p><p style="margin: 0">ARA SYSTEM · [전화번호]</p><p style="margin: 0"><a href="#">ara.kim@arasystem.it.kr</a></p><p style="margin: 8px 0 0"><span role="img" aria-label="[회사 로고]" style="display: inline-flex; align-items: center; justify-content: center; box-sizing: border-box; width: 144px; height: 43px; border: 1px dashed #98A2B3; border-radius: 4px; color: #667085; font-family: Pretendard, -apple-system, 'Apple SD Gothic Neo', 'Malgun Gothic', system-ui, sans-serif; font-size: 12px">[회사 로고]</span></p><span aria-hidden="true" style="position: absolute; right: -6px; top: -19px; padding: 1px 8px; border-radius: 10px; background: #FEF0C7; color: #93370D; font-family: Pretendard, -apple-system, 'Apple SD Gothic Neo', 'Malgun Gothic', system-ui, sans-serif; font-size: 11.5px; font-weight: 600; line-height: 20px; white-space: nowrap">서명 자리</span></div>
</div>
</div>
<span aria-hidden="true" style="flex: 0 0 auto; align-self: center; display: inline-flex; color: #98A2B3"><svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="5" y1="12" x2="19" y2="12"></line><polyline points="12 5 19 12 12 19"></polyline></svg></span>
<div style="flex: 1 1 0; min-width: 0; display: flex; flex-direction: column; gap: 10px">
<div style="display: flex; flex-direction: column; gap: 1px">
<h3 style="margin: 0; font-size: 13px; font-weight: 700; color: #344054">[서명 ▾]에서 사내를 고른 뒤</h3>
<p style="margin: 0; font-size: 12.5px; color: #667085">쓴 글은 그대로이고 서명 자리만 바뀝니다</p>
</div>
<div style="box-sizing: border-box; padding: 16px 32px 24px; border: 1px solid #E6E8EC; border-radius: 10px; background: #FFFFFF; font-family: 'Malgun Gothic', '맑은 고딕', 'Apple SD Gothic Neo', sans-serif; font-size: 14px; line-height: 1.75; color: #1F2733">
<p style="margin: 0">도윤님, 안녕하세요.</p>
<p style="margin: 0"><br></p>
<p style="margin: 0">다음 주 정기 점검 일정을 공유드립니다.</p>
<p style="margin: 0">11월 21일 금요일 오후 10시부터 한 시간 동안 메일 서버를 점검합니다.</p>
<p style="margin: 0"><br></p>
<p style="margin: 0">감사합니다.</p>
<p style="margin: 0"><br></p>
<div style="position: relative; outline: 2px dashed #DC6803; outline-offset: 6px; border-radius: 2px"><p style="margin: 0"><strong>김아라</strong> · 인프라팀</p><p style="margin: 0">내선 [내선 번호]</p><span aria-hidden="true" style="position: absolute; right: -6px; top: -19px; padding: 1px 8px; border-radius: 10px; background: #FEF0C7; color: #93370D; font-family: Pretendard, -apple-system, 'Apple SD Gothic Neo', 'Malgun Gothic', system-ui, sans-serif; font-size: 11.5px; font-weight: 600; line-height: 20px; white-space: nowrap">서명 자리</span></div>
</div>
</div>
</div>
</div>
<div style="display: flex; flex-direction: column; gap: 10px; padding-top: 18px">
<h3 style="margin: 0; font-size: 14px; font-weight: 700; color: #0B3640">답장에서 바꾸기 전과 뒤</h3>
<div style="display: flex; align-items: flex-start; gap: 16px">
<div style="flex: 1 1 0; min-width: 0; display: flex; flex-direction: column; gap: 10px">
<div style="display: flex; flex-direction: column; gap: 1px">
<h3 style="margin: 0; font-size: 13px; font-weight: 700; color: #344054">바꾸기 전</h3>
<p style="margin: 0; font-size: 12.5px; color: #667085">회사 밖 사람에게 답장 · 답장과 전달 기본인 사내가 들어 있습니다</p>
</div>
<div style="box-sizing: border-box; padding: 16px 32px 24px; border: 1px solid #E6E8EC; border-radius: 10px; background: #FFFFFF; font-family: 'Malgun Gothic', '맑은 고딕', 'Apple SD Gothic Neo', sans-serif; font-size: 14px; line-height: 1.75; color: #1F2733">
<p style="margin: 0">최도윤님, 안녕하세요.</p>
<p style="margin: 0"><br></p>
<p style="margin: 0">보내 주신 견적서 잘 받았습니다. 검토한 뒤 이번 주 안에 회신드리겠습니다.</p>
<p style="margin: 0"><br></p>
<div style="position: relative; outline: 2px dashed #DC6803; outline-offset: 6px; border-radius: 2px"><p style="margin: 0"><strong>김아라</strong> · 인프라팀</p><p style="margin: 0">내선 [내선 번호]</p><span aria-hidden="true" style="position: absolute; right: -6px; top: -19px; padding: 1px 8px; border-radius: 10px; background: #FEF0C7; color: #93370D; font-family: Pretendard, -apple-system, 'Apple SD Gothic Neo', 'Malgun Gothic', system-ui, sans-serif; font-size: 11.5px; font-weight: 600; line-height: 20px; white-space: nowrap">서명 자리</span></div>
<blockquote style="margin: 16px 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.choi@example.net&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. 11. 10. (화) 오전 9:20</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">견적서 보내 드립니다</td></tr>
</tbody>
</table>
</div>
<p style="margin: 0">김아라님, 요청하신 견적서를 첨부해 보내 드립니다.</p>
</blockquote>
</div>
</div>
<span aria-hidden="true" style="flex: 0 0 auto; align-self: center; display: inline-flex; color: #98A2B3"><svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="5" y1="12" x2="19" y2="12"></line><polyline points="12 5 19 12 12 19"></polyline></svg></span>
<div style="flex: 1 1 0; min-width: 0; display: flex; flex-direction: column; gap: 10px">
<div style="display: flex; flex-direction: column; gap: 1px">
<h3 style="margin: 0; font-size: 13px; font-weight: 700; color: #344054">[서명 ▾]에서 대외를 고른 뒤</h3>
<p style="margin: 0; font-size: 12.5px; color: #667085">인용은 그대로이고 그 위의 서명 자리만 바뀝니다</p>
</div>
<div style="box-sizing: border-box; padding: 16px 32px 24px; border: 1px solid #E6E8EC; border-radius: 10px; background: #FFFFFF; font-family: 'Malgun Gothic', '맑은 고딕', 'Apple SD Gothic Neo', sans-serif; font-size: 14px; line-height: 1.75; color: #1F2733">
<p style="margin: 0">최도윤님, 안녕하세요.</p>
<p style="margin: 0"><br></p>
<p style="margin: 0">보내 주신 견적서 잘 받았습니다. 검토한 뒤 이번 주 안에 회신드리겠습니다.</p>
<p style="margin: 0"><br></p>
<div style="position: relative; outline: 2px dashed #DC6803; outline-offset: 6px; border-radius: 2px"><p style="margin: 0"><strong>김아라</strong> · 인프라팀</p><p style="margin: 0">ARA SYSTEM · [전화번호]</p><p style="margin: 0"><a href="#">ara.kim@arasystem.it.kr</a></p><p style="margin: 8px 0 0"><span role="img" aria-label="[회사 로고]" style="display: inline-flex; align-items: center; justify-content: center; box-sizing: border-box; width: 144px; height: 43px; border: 1px dashed #98A2B3; border-radius: 4px; color: #667085; font-family: Pretendard, -apple-system, 'Apple SD Gothic Neo', 'Malgun Gothic', system-ui, sans-serif; font-size: 12px">[회사 로고]</span></p><span aria-hidden="true" style="position: absolute; right: -6px; top: -19px; padding: 1px 8px; border-radius: 10px; background: #FEF0C7; color: #93370D; font-family: Pretendard, -apple-system, 'Apple SD Gothic Neo', 'Malgun Gothic', system-ui, sans-serif; font-size: 11.5px; font-weight: 600; line-height: 20px; white-space: nowrap">서명 자리</span></div>
<blockquote style="margin: 16px 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.choi@example.net&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. 11. 10. (화) 오전 9:20</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">견적서 보내 드립니다</td></tr>
</tbody>
</table>
</div>
<p style="margin: 0">김아라님, 요청하신 견적서를 첨부해 보내 드립니다.</p>
</blockquote>
</div>
</div>
</div>
</div>
<div style="display: flex; flex-direction: column; gap: 10px; padding: 20px; background: #FFFFFF; border: 1px solid #E6E8EC; border-radius: 12px">
<h3 style="margin: 0; font-size: 14px; font-weight: 700">서명 자리</h3>
<ul style="margin: 0; padding-left: 18px; display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 6px 40px; color: #344054">
<li>서명 자리는 빈 줄 하나 뒤의 서명 덩어리입니다. 앱이 쓰는 경계와 같아서 임시 저장한 뒤 다시 열어도 찾습니다. 주황 점선과 서명 자리 꼬리표는 보드에만 있는 표시입니다.</li>
<li>바꾸면 그 자리만 새 서명으로 바꿉니다. 쓴 글, 인용, 첨부는 그대로이고, 서명 안을 고쳤다면 고친 것도 새 서명으로 바뀝니다. 실행 취소(Ctrl+Z)로 되돌릴 수 있습니다.</li>
<li>넣지 않음을 고르면 서명 자리와 그 앞의 빈 줄을 뺍니다. 서명 자리를 지운 뒤 다른 서명을 고르면 새 메일은 본문 끝, 답장과 전달은 인용 위에 넣습니다(처음 넣는 자리와 같습니다).</li>
<li>이어 쓰는 임시 저장본은 서명 자리의 글이 저장한 서명과 같으면 그 서명에 ✓를 붙이고, 다르면(고쳤거나 지운 서명) ✓ 없이 엽니다. 이어 쓸 때 서명을 다시 넣지 않는 것은 지금과 같습니다.</li>
<li>받는 사람이 회사 밖이어도 저절로 바꾸지 않습니다. 위 답장처럼 답장 기본이 사내여도 직원이 [서명 ▾]에서 고릅니다.</li>
</ul>
</div>
</section>
<section aria-labelledby="s7-h" style="flex: 0 0 auto; box-sizing: border-box; padding: 52px 48px 0; display: flex; flex-direction: column; gap: 14px">
<div style="display: flex; align-items: baseline; gap: 16px">
<h2 id="s7-h" style="margin: 0; font-size: 19px; line-height: 28px; font-weight: 700; color: #0B3640; white-space: nowrap">7. 정한 것</h2>
<p style="margin: 0; font-size: 13.5px; color: #4A5363">3절 추천안(2026-11-10 사용자 확정)과 이 보드에서 정한 것을 모았습니다.</p>
</div>
<div style="display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 20px; align-items: start">
<section style="display: flex; flex-direction: column; gap: 10px; padding: 20px; background: #FFFFFF; border: 1px solid #E6E8EC; border-radius: 12px">
<h3 style="margin: 0; font-size: 14px; font-weight: 700; color: #171C26">서명 설정</h3>
<ul style="margin: 0; padding-left: 18px; display: flex; flex-direction: column; gap: 6px; color: #344054">
<li>서명은 10개까지 만들고, 이름은 앞뒤 공백을 뺀 1~30자이며 겹치지 않습니다.</li>
<li>메일에 넣을 서명은 새 메일, 답장과 전달 두 곳에 하나씩 고르거나 넣지 않음으로 둡니다. 지금의 두 체크를 대신하고, 고르는 대로 바로 저장합니다.</li>
<li>그림은 서명마다 3장 그대로이고, 한 사람이 보관하는 그림은 10장에서 20장으로 늘립니다. 여러 서명이 그림을 함께 쓰고, 저장할 때 어느 서명도 쓰지 않는 그림을 지웁니다.</li>
<li>지금 서명은 기본 서명이라는 이름으로 옮기고, 지금 두 체크대로 기본을 정합니다.</li>
<li>[저장] 뒤 글은 기본으로 넣는 서명이면 "저장했습니다. 다음에 메일을 쓸 때부터 들어갑니다.", 기본이 아닌 서명이면 "저장했습니다."입니다.</li>
</ul>
</section>
<section style="display: flex; flex-direction: column; gap: 10px; padding: 20px; background: #FFFFFF; border: 1px solid #E6E8EC; border-radius: 12px">
<h3 style="margin: 0; font-size: 14px; font-weight: 700; color: #171C26">쓰기 화면</h3>
<ul style="margin: 0; padding-left: 18px; display: flex; flex-direction: column; gap: 6px; color: #344054">
<li>[서명 ▾]는 아래 단추 줄의 [임시 저장] 다음에 둡니다.</li>
<li>바꾸면 본문의 서명 자리만 바뀝니다. 쓴 글, 인용, 첨부는 그대로입니다.</li>
<li>받는 사람에 따라 저절로 바꾸지 않습니다(처음 판). 직원이 고르는 대로이고, 기본 둘로 어떤 서명이 들어갈지 미리 알 수 있습니다.</li>
<li>서명 관리는 설정 › 메일 › 서명으로 가고 지금 나가기 규칙을 따릅니다.</li>
</ul>
</section>
<section style="display: flex; flex-direction: column; gap: 10px; padding: 20px; background: #FFFFFF; border: 1px solid #E6E8EC; border-radius: 12px">
<h3 style="margin: 0; font-size: 14px; font-weight: 700; color: #171C26">휴대폰 앱 · 서버</h3>
<ul style="margin: 0; padding-left: 18px; display: flex; flex-direction: column; gap: 6px; color: #344054">
<li>앱 첫 판은 지금처럼 서명 하나만 받습니다. 서버가 그 자리에 새 메일 기본 서명을 내주어 앱은 새 메일에 그 서명을 넣고, 답장과 전달 기본이 같은 서명일 때만 답장에도 넣습니다. 다르면 앱의 답장에는 서명이 들어가지 않습니다(알려진 한계).</li>
<li>앱에서 서명 고르기는 앱 다음 판 후보입니다.</li>
<li>서버는 서명 목록, 만들기, 고치기, 지우기와 기본 둘을 다루는 새 경로를 둡니다. 경로 이름과 같은 이름을 거절하는 오류 코드는 구현 때 정합니다. 보낼 때 서명 그림을 메일에 함께 싣는 것은 지금 그대로입니다.</li>
</ul>
</section>
<section style="display: flex; flex-direction: column; gap: 10px; padding: 20px; background: #FFFFFF; border: 1px solid #E6E8EC; border-radius: 12px">
<h3 style="margin: 0; font-size: 14px; font-weight: 700; color: #171C26">처음 판에서 미룬 것</h3>
<ul style="margin: 0; padding-left: 18px; display: flex; flex-direction: column; gap: 6px; color: #344054">
<li>받는 사람에 따라 서명을 저절로 바꾸기, 앱에서 서명 고르기.</li>
<li>목록 차례 바꾸기(목록은 만든 차례입니다).</li>
</ul>
</section>
</div>
<section style="display: flex; flex-direction: column; gap: 10px; padding: 20px; background: #FFFFFF; border: 1px solid #E6E8EC; border-radius: 12px">
<h3 style="margin: 0; font-size: 14px; font-weight: 700; color: #171C26">새로 지은 화면 글</h3>
<p style="margin: 0; font-size: 12.5px; color: #667085">여기 없는 글은 지금 화면 상수의 글을 그대로 씁니다. 글 속 이름(대외, 영문, 사내)은 그때의 서명 이름으로 바뀝니다.</p>
<ul style="list-style: none; margin: 0; padding: 0; display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 0 40px">
<li style="display: flex; flex-direction: column; gap: 1px; padding: 6px 0; border-top: 1px solid #EEF0F3"><span style="font-size: 12px; color: #667085">설정 화면 설명</span><span style="color: #171C26">보내는 메일 끝에 넣는 글입니다. 여러 개 만들어 두면 메일을 쓸 때 [서명]에서 골라 바꿀 수 있고, 그 메일에서만 고치거나 지울 수도 있습니다. (앞 문장은 지금 글)</span></li>
<li style="display: flex; flex-direction: column; gap: 1px; padding: 6px 0; border-top: 1px solid #EEF0F3"><span style="font-size: 12px; color: #667085">기본 고르기 묶음 이름</span><span style="color: #171C26">메일에 넣을 서명</span></li>
<li style="display: flex; flex-direction: column; gap: 1px; padding: 6px 0; border-top: 1px solid #EEF0F3"><span style="font-size: 12px; color: #667085">기본 고르기의 고를 것</span><span style="color: #171C26">넣지 않음</span></li>
<li style="display: flex; flex-direction: column; gap: 1px; padding: 6px 0; border-top: 1px solid #EEF0F3"><span style="font-size: 12px; color: #667085">기본 고르기 아래 글</span><span style="color: #171C26">메일을 쓰기 시작할 때 본문에 넣어 둡니다. (뒤 문장 "고르는 대로 바로 저장됩니다."는 보내기 · 읽기 설정의 글)</span></li>
<li style="display: flex; flex-direction: column; gap: 1px; padding: 6px 0; border-top: 1px solid #EEF0F3"><span style="font-size: 12px; color: #667085">목록 이름</span><span style="color: #171C26">내 서명</span></li>
<li style="display: flex; flex-direction: column; gap: 1px; padding: 6px 0; border-top: 1px solid #EEF0F3"><span style="font-size: 12px; color: #667085">목록 줄 아래 회색 글 (두 곳의 기본일 때)</span><span style="color: #171C26">새 메일, 답장과 전달</span></li>
<li style="display: flex; flex-direction: column; gap: 1px; padding: 6px 0; border-top: 1px solid #EEF0F3"><span style="font-size: 12px; color: #667085">목록 아래 단추</span><span style="color: #171C26">새 서명</span></li>
<li style="display: flex; flex-direction: column; gap: 1px; padding: 6px 0; border-top: 1px solid #EEF0F3"><span style="font-size: 12px; color: #667085">목록 아래 글</span><span style="color: #171C26">서명 3개를 만들었고, 10개까지 만들 수 있습니다.</span></li>
<li style="display: flex; flex-direction: column; gap: 1px; padding: 6px 0; border-top: 1px solid #EEF0F3"><span style="font-size: 12px; color: #667085">10개일 때 목록 아래 글</span><span style="color: #171C26">서명은 10개까지 만들 수 있습니다. 쓰지 않는 서명을 지워 주세요.</span></li>
<li style="display: flex; flex-direction: column; gap: 1px; padding: 6px 0; border-top: 1px solid #EEF0F3"><span style="font-size: 12px; color: #667085">새 줄 아래 회색 글</span><span style="color: #171C26">저장하지 않음</span></li>
<li style="display: flex; flex-direction: column; gap: 1px; padding: 6px 0; border-top: 1px solid #EEF0F3"><span style="font-size: 12px; color: #667085">이름 칸 안 글</span><span style="color: #171C26">예: 사내, 대외, 영문</span></li>
<li style="display: flex; flex-direction: column; gap: 1px; padding: 6px 0; border-top: 1px solid #EEF0F3"><span style="font-size: 12px; color: #667085">이름 칸 아래 글</span><span style="color: #171C26">메일을 쓸 때 [서명]에서 고르는 이름입니다. 30자까지 쓸 수 있습니다.</span></li>
<li style="display: flex; flex-direction: column; gap: 1px; padding: 6px 0; border-top: 1px solid #EEF0F3"><span style="font-size: 12px; color: #667085">같은 이름일 때</span><span style="color: #171C26">같은 이름의 서명이 있습니다.</span></li>
<li style="display: flex; flex-direction: column; gap: 1px; padding: 6px 0; border-top: 1px solid #EEF0F3"><span style="font-size: 12px; color: #667085">[저장] 뒤 글 (기본이 아닌 서명)</span><span style="color: #171C26">저장했습니다. (기본으로 넣는 서명은 지금 글 "저장했습니다. 다음에 메일을 쓸 때부터 들어갑니다." 그대로)</span></li>
<li style="display: flex; flex-direction: column; gap: 1px; padding: 6px 0; border-top: 1px solid #EEF0F3"><span style="font-size: 12px; color: #667085">지우기 단추</span><span style="color: #171C26">이 서명 지우기</span></li>
<li style="display: flex; flex-direction: column; gap: 1px; padding: 6px 0; border-top: 1px solid #EEF0F3"><span style="font-size: 12px; color: #667085">빈 화면 제목</span><span style="color: #171C26">아직 서명이 없습니다</span></li>
<li style="display: flex; flex-direction: column; gap: 1px; padding: 6px 0; border-top: 1px solid #EEF0F3"><span style="font-size: 12px; color: #667085">빈 화면 글</span><span style="color: #171C26">보내는 메일 끝에 넣을 이름, 소속, 연락처를 만들어 두면 메일을 쓸 때마다 본문에 넣어 둡니다. 사내용과 대외용처럼 여러 개를 만들어 골라 쓸 수도 있습니다.</span></li>
<li style="display: flex; flex-direction: column; gap: 1px; padding: 6px 0; border-top: 1px solid #EEF0F3"><span style="font-size: 12px; color: #667085">기본 서명이 옮겨질 때의 이름</span><span style="color: #171C26">기본 서명</span></li>
<li style="display: flex; flex-direction: column; gap: 1px; padding: 6px 0; border-top: 1px solid #EEF0F3"><span style="font-size: 12px; color: #667085">지우기 창 제목</span><span style="color: #171C26">‘대외’ 서명을 지울까요?</span></li>
<li style="display: flex; flex-direction: column; gap: 1px; padding: 6px 0; border-top: 1px solid #EEF0F3"><span style="font-size: 12px; color: #667085">지우기 창 글 (새 메일 기본)</span><span style="color: #171C26">새 메일에 넣는 서명이라 지우면 앞으로 새 메일에는 서명을 넣지 않습니다. 지운 서명은 되돌릴 수 없습니다.</span></li>
<li style="display: flex; flex-direction: column; gap: 1px; padding: 6px 0; border-top: 1px solid #EEF0F3"><span style="font-size: 12px; color: #667085">지우기 창 글 (답장과 전달 기본)</span><span style="color: #171C26">답장과 전달에 넣는 서명이라 지우면 앞으로 답장과 전달에는 서명을 넣지 않습니다. 지운 서명은 되돌릴 수 없습니다.</span></li>
<li style="display: flex; flex-direction: column; gap: 1px; padding: 6px 0; border-top: 1px solid #EEF0F3"><span style="font-size: 12px; color: #667085">지우기 창 글 (두 곳의 기본)</span><span style="color: #171C26">새 메일에도, 답장과 전달에도 넣는 서명이라 지우면 앞으로 어느 메일에도 서명을 넣지 않습니다. 지운 서명은 되돌릴 수 없습니다.</span></li>
<li style="display: flex; flex-direction: column; gap: 1px; padding: 6px 0; border-top: 1px solid #EEF0F3"><span style="font-size: 12px; color: #667085">지우기 창 글 (기본 아님)</span><span style="color: #171C26">지운 서명은 되돌릴 수 없습니다.</span></li>
<li style="display: flex; flex-direction: column; gap: 1px; padding: 6px 0; border-top: 1px solid #EEF0F3"><span style="font-size: 12px; color: #667085">지운 뒤 알림</span><span style="color: #171C26">‘영문’ 서명을 지웠습니다.</span></li>
<li style="display: flex; flex-direction: column; gap: 1px; padding: 6px 0; border-top: 1px solid #EEF0F3"><span style="font-size: 12px; color: #667085">다른 서명을 고를 때의 확인 글</span><span style="color: #171C26">다른 서명을 고르면 ‘대외’ 서명에서 바꾼 내용이 사라집니다.</span></li>
<li style="display: flex; flex-direction: column; gap: 1px; padding: 6px 0; border-top: 1px solid #EEF0F3"><span style="font-size: 12px; color: #667085">[새 서명]을 누를 때의 확인 글</span><span style="color: #171C26">새 서명을 만들면 ‘대외’ 서명에서 바꾼 내용이 사라집니다.</span></li>
<li style="display: flex; flex-direction: column; gap: 1px; padding: 6px 0; border-top: 1px solid #EEF0F3"><span style="font-size: 12px; color: #667085">확인 창 둘째 단추</span><span style="color: #171C26">바꾼 내용 버리기</span></li>
<li style="display: flex; flex-direction: column; gap: 1px; padding: 6px 0; border-top: 1px solid #EEF0F3"><span style="font-size: 12px; color: #667085">쓰기 화면 단추 (화면 낭독)</span><span style="color: #171C26">서명 (서명: 대외)</span></li>
<li style="display: flex; flex-direction: column; gap: 1px; padding: 6px 0; border-top: 1px solid #EEF0F3"><span style="font-size: 12px; color: #667085">쓰기 화면 메뉴</span><span style="color: #171C26">넣지 않음, 서명 관리</span></li>
<li style="display: flex; flex-direction: column; gap: 1px; padding: 6px 0; border-top: 1px solid #EEF0F3"><span style="font-size: 12px; color: #667085">서명을 바꾼 뒤 (화면 낭독)</span><span style="color: #171C26">서명을 ‘사내’로 바꿨습니다.</span></li>
</ul>
</section>
</section>
</div>
</x-dc>
<script type="text/x-dc" data-dc-script data-props='{"$preview":{"width":1440,"height":7955}}'>
class Component extends DCLogic {
state = { sel: 'out', mode: 'new', csig: 'out' };
renderVals() {
const st = this.state;
const sel = st.sel || 'out';
const mode = st.mode || 'new';
const csig = st.csig || 'out';
// 서명 셋 — 만든 차례. uses 는 그 서명을 기본으로 넣는 메일(새 메일 기본 = 대외, 답장과 전달 기본 = 사내)
const SIGS = [
{ id: 'in', name: '사내', uses: '답장과 전달' },
{ id: 'out', name: '대외', uses: '새 메일' },
{ id: 'en', name: '영문', uses: '' },
];
const f1Rows = SIGS.map((s, i) => {
const on = s.id === sel;
return {
name: s.name,
uses: s.uses,
hasUses: s.uses !== '',
current: on ? 'true' : 'false',
cls: on ? 'sig-row is-on' : 'sig-row',
fg: on ? '#0A5F67' : '#171C26',
fw: on ? '600' : '500',
subColor: on ? '#0A5F67' : '#667085',
borderTop: i === 0 ? '0' : '1px solid #EEF0F3',
pick: () => this.setState({ sel: s.id }),
};
});
const selSig = SIGS.find((s) => s.id === sel) || SIGS[1];
const MODES = [
{ id: 'new', label: '새 메일' },
{ id: 'reply', label: '답장' },
];
const f1Modes = MODES.map((m) => ({
label: m.label,
pressed: m.id === mode ? 'true' : 'false',
pick: () => this.setState({ mode: m.id }),
}));
// 쓰기 화면 [서명 ▾] — 서명(만든 차례) · 넣지 않음, 지금 본문에 든 것에 체크
const MENU = [
{ id: 'in', label: '사내' },
{ id: 'out', label: '대외' },
{ id: 'en', label: '영문' },
{ id: 'none', label: '넣지 않음' },
];
const cMenu = MENU.map((m) => ({
label: m.label,
checked: m.id === csig ? 'true' : 'false',
isChecked: m.id === csig,
pick: () => this.setState({ csig: m.id }),
}));
const cName = (MENU.find((m) => m.id === csig) || MENU[1]).label;
return {
f1Rows: f1Rows,
f1Modes: f1Modes,
selName: selSig.name,
selIn: sel === 'in',
selOut: sel === 'out',
selEn: sel === 'en',
previewReply: mode === 'reply',
previewNote: mode === 'reply' ? '답장과 전달에는 인용한 원문 위에 서명을 넣습니다.' : '새 메일은 본문 끝에 서명을 넣습니다.',
cMenu: cMenu,
cIn: csig === 'in',
cOut: csig === 'out',
cEn: csig === 'en',
cHasSig: csig !== 'none',
cButtonLabel: '서명: ' + cName,
};
}
}
</script>
</body>
</html>
+9 -3
View File
@@ -62,9 +62,13 @@
"SettingsMobileApp.dc.html": { "x": 0, "y": 38770, "w": 1440, "h": 3209, "title": "설정 · 휴대폰 앱 · 앱 받기 화면", "is_interactive": true },
"Reader15.dc.html": { "x": 0, "y": 42600, "w": 1440, "h": 8043, "title": "읽기 화면 · 이동 · 지금 보내기 · 같은 대화", "is_interactive": true },
"ListDrag15.dc.html": { "x": 1520, "y": 42600, "w": 1440, "h": 3845, "title": "목록 · 끌어다 놓기 · 상단 바 좁은 폭", "is_interactive": true },
"Mailbox15.dc.html": { "x": 3040, "y": 42600, "w": 1440, "h": 4937, "title": "메일함 옮기기 · 하위까지 지우기 · 차단할 때 받은 메일", "is_interactive": true }
"Mailbox15.dc.html": { "x": 3040, "y": 42600, "w": 1440, "h": 4937, "title": "메일함 옮기기 · 하위까지 지우기 · 차단할 때 받은 메일", "is_interactive": true },
"Contacts16.dc.html": { "x": 0, "y": 51270, "w": 1440, "h": 7536, "title": "주소록 · 가져오기 · 내보내기", "is_interactive": true },
"Signature16.dc.html": { "x": 1520, "y": 51270, "w": 1440, "h": 7955, "title": "설정 · 여러 서명 · 쓰기 화면 서명 바꾸기", "is_interactive": true },
"Settings16.dc.html": { "x": 3040, "y": 51270, "w": 1440, "h": 8807, "title": "설정 · 부재중 서식 · 회사 밖 응답 · 휴대폰 바꾸기", "is_interactive": true },
"ReleaseNotes.dc.html": { "x": 0, "y": 60700, "w": 1440, "h": 8464, "title": "설정 · 업데이트 내역 (웹 · 휴대폰 앱 · 쓰는 법)", "is_interactive": true }
},
"order": ["Main.dc.html", "Login.dc.html", "Compose.dc.html", "States.dc.html", "MainList.dc.html", "MainListRead.dc.html", "ComposeEditor.dc.html", "Settings.dc.html", "ComposeAssist.dc.html", "Search.dc.html", "BulkSelect.dc.html", "ListStates.dc.html", "SettingsAccount.dc.html", "SettingsVacation.dc.html", "MailboxManage.dc.html", "MailActions4.dc.html", "SettingsNotify.dc.html", "MailRealtime.dc.html", "SettingsFilter.dc.html", "MailFilterStates.dc.html", "MailFilterApply.dc.html", "MailFilterApplyStates.dc.html", "Contacts.dc.html", "ContactsEdit.dc.html", "ContactsMail.dc.html", "MailReadTools.dc.html", "MailPrint.dc.html", "MailSendLater.dc.html", "SettingsSendRead.dc.html", "ComposeLargeFiles.dc.html", "LargeFileBlock.dc.html", "MailReadLargeFiles.dc.html", "SharePage.dc.html", "SettingsLargeFiles.dc.html", "LoginTwoFactor.dc.html", "SettingsTwoFactor.dc.html", "SettingsAppPasswords.dc.html", "SettingsLoginHistory.dc.html", "LoginPasswordChange.dc.html", "LoginTwoFactorSetup.dc.html", "SettingsMailClients.dc.html", "MailNoticesCutover.dc.html", "Search14.dc.html", "Thread14.dc.html", "ListThreads14.dc.html", "App1Login.dc.html", "App1MailList.dc.html", "App1Reader.dc.html", "App1Compose.dc.html", "App1Shell.dc.html", "App1Search.dc.html", "App1SearchStates.dc.html", "App1SearchConditions.dc.html", "App1Push.dc.html", "SettingsMobileApp.dc.html", "Reader15.dc.html", "ListDrag15.dc.html", "Mailbox15.dc.html"],
"order": ["Main.dc.html", "Login.dc.html", "Compose.dc.html", "States.dc.html", "MainList.dc.html", "MainListRead.dc.html", "ComposeEditor.dc.html", "Settings.dc.html", "ComposeAssist.dc.html", "Search.dc.html", "BulkSelect.dc.html", "ListStates.dc.html", "SettingsAccount.dc.html", "SettingsVacation.dc.html", "MailboxManage.dc.html", "MailActions4.dc.html", "SettingsNotify.dc.html", "MailRealtime.dc.html", "SettingsFilter.dc.html", "MailFilterStates.dc.html", "MailFilterApply.dc.html", "MailFilterApplyStates.dc.html", "Contacts.dc.html", "ContactsEdit.dc.html", "ContactsMail.dc.html", "MailReadTools.dc.html", "MailPrint.dc.html", "MailSendLater.dc.html", "SettingsSendRead.dc.html", "ComposeLargeFiles.dc.html", "LargeFileBlock.dc.html", "MailReadLargeFiles.dc.html", "SharePage.dc.html", "SettingsLargeFiles.dc.html", "LoginTwoFactor.dc.html", "SettingsTwoFactor.dc.html", "SettingsAppPasswords.dc.html", "SettingsLoginHistory.dc.html", "LoginPasswordChange.dc.html", "LoginTwoFactorSetup.dc.html", "SettingsMailClients.dc.html", "MailNoticesCutover.dc.html", "Search14.dc.html", "Thread14.dc.html", "ListThreads14.dc.html", "App1Login.dc.html", "App1MailList.dc.html", "App1Reader.dc.html", "App1Compose.dc.html", "App1Shell.dc.html", "App1Search.dc.html", "App1SearchStates.dc.html", "App1SearchConditions.dc.html", "App1Push.dc.html", "SettingsMobileApp.dc.html", "Reader15.dc.html", "ListDrag15.dc.html", "Mailbox15.dc.html", "Contacts16.dc.html", "Signature16.dc.html", "Settings16.dc.html", "ReleaseNotes.dc.html"],
"notes": {
"flow": { "x": 0, "y": -300, "text": "arasystem 웹메일 — 1단계 화면 (로그인 → 메일함 → 쓰기)", "kind": "title1", "maxW": 4480 },
"phase2": { "x": 0, "y": 2180, "text": "2단계 화면 — 임시 저장 · 받는 사람 자동 완성 · 서명 (2026-10-27 확정)", "kind": "title1", "maxW": 2960 },
@@ -84,7 +88,9 @@
"app1search2": { "x": 5918, "y": 33150, "text": "앱 검색 2차 — 조건으로 찾기 · 날짜 · 조건 줄 — 2026-11-07 확정(글은 화면 상수가 기준)", "kind": "title1", "maxW": 1784 },
"app1push": { "x": 0, "y": 34600, "text": "앱 새 메일 알림 — 허락 묻기 · 설정 · 휴대폰 알림 · 업데이트 · 앱 아이콘 — 2026-11-07 확정(글은 화면 상수가 기준) · 11-08 업데이트 화면 · 앱 정보를 사내 배포에 맞게 바꿈(확정)", "kind": "title1", "maxW": 2222 },
"appdist": { "x": 0, "y": 38470, "text": "휴대폰 앱 받기 — 설정 › 휴대폰 앱 · 휴대폰 받기 화면(로그인한 직원만) — 2026-11-08 확정(글은 화면 상수가 기준)", "kind": "title1", "maxW": 1440 },
"phase15": { "x": 0, "y": 42300, "text": "15단계 화면 — 메일 · 메일함 다루기(이동 · 끌어다 놓기 · 메일함 옮기기 · 지금 보내기 · 같은 대화) — 2026-11-09 확정(글은 화면 상수가 기준)", "kind": "title1", "maxW": 4480 }
"phase15": { "x": 0, "y": 42300, "text": "15단계 화면 — 메일 · 메일함 다루기(이동 · 끌어다 놓기 · 메일함 옮기기 · 지금 보내기 · 같은 대화) — 2026-11-09 확정(글은 화면 상수가 기준)", "kind": "title1", "maxW": 4480 },
"phase16": { "x": 0, "y": 50970, "text": "16단계 화면 — 개인 설정(주소록 가져오기 · 내보내기 · 여러 서명 · 부재중 서식 · 회사 밖 응답 · 휴대폰 바꾸기) — 2026-11-10 확정(글은 화면 상수가 기준)", "kind": "title1", "maxW": 4480 },
"releasenotes": { "x": 0, "y": 60400, "text": "업데이트 내역(게시판 — 목록에서 글로) — 웹 설정 › 정보 · 앱 설정 › 앱 정보 · 글 파일 쓰는 법 — 2026-11-10 확정(글은 화면 상수가 기준)", "kind": "title1", "maxW": 1440 }
},
"designSystems": []
}
+305 -27
View File
@@ -8,6 +8,7 @@
- 성공 `{ "success": true, "data": … }` / 실패 `{ "success": false, "error": { "code", "message", "traceId" } }`
- 날짜·시각은 ISO 8601 UTC 문자열, 목록은 `{ items, pageInfo }`
(예외: 7절 업데이트 내역의 `date` 는 시각이 아닌 달력 날짜 글 `YYYY-MM-DD` 다 — `new Date()` 로 읽지 않고 시간대를 바꾸지 않은 채 그대로 쓴다)
- 이 단계에서 쓰는 에러 코드: `AUTH_001` `AUTH_002`(클라이언트 전용) `AUTH_003` `AUTH_004` `AUTH_005` `VAL_001` `RES_001` `REQ_001` `FILE_001` `FILE_002` `MAIL_001` `MAIL_002` `MAIL_003` `MAIL_004` `MAIL_005` `MAIL_006` `MAIL_007` `MAIL_008` `MAIL_009` `MAIL_010` `MAIL_011` `MAIL_012` `MAIL_013` `MAIL_014` `MAIL_015` `CONTACT_001` `CONTACT_002` `SYS_001`
(11단계 — 4절 "대용량 첨부"가 `FILE_003` ~ `FILE_008` · `MAIL_016` 을 더한다. 12단계 — 5절 "계정 보안"이 `AUTH_006` ~ `AUTH_009` 를, 13단계 — 6절 "전환 준비"가 `AUTH_010` 을 더한다)
- **적용 범위: 웹(브라우저)은 HttpOnly 쿠키뿐이고 토큰을 본문으로 주지 않는다.**
@@ -415,8 +416,8 @@ HTML 은 정화하기 **전에** 길이(HTML 부분이 여럿이면 이어 붙
| `DELETE /api/mailboxes/{id}` | `withChildren=true`(선택 — 15단계 "하위 메일함까지 지우기") | 200 `MailboxDeletion` — 안의 메일을 휴지통으로 옮긴 뒤 지운다(하위까지면 나무 전체) | 400 `VAL_001`(기본 메일함 · 하위 메일함이 있는데 `withChildren=true` 가 아님 · 하위에 기본 메일함 · 다른 값), 404 `RES_001`, 409 `MAIL_015`(남은 메일이 붙잡힌 메일뿐 — 9단계 "보내기 취소 · 예약 발송"), 429 `REQ_001`(같은 메일함(하위까지면 나무의 메일함)의 처리가 진행 중), 503 `MAIL_001` |
| `PATCH /api/mailboxes/{id}/messages` | `{ isRead: true }` — 그 메일함 전체를 읽음으로 | 200 `MailboxBulkResult` | 400 `VAL_001`, 404 `RES_001`, 429 `REQ_001`(같은 메일함의 처리가 진행 중), 503 `MAIL_001` |
| `DELETE /api/mailboxes/{id}/messages` | — 휴지통 · 스팸메일함 비우기(영구 삭제) | 200 `MailboxBulkResult` | 400 `VAL_001`(휴지통 · 스팸메일함이 아님), 404 `RES_001`, 409 `MAIL_015`(남은 메일이 붙잡힌 메일뿐 — 9단계), 429 `REQ_001`(같은 메일함의 처리가 진행 중), 503 `MAIL_001` |
| `GET /api/mail-vacation` | — | 200 `MailVacationState` (아래 "부재중 자동 응답" — `MailVacation` + 7단계의 `isActive`) | 503 `MAIL_001` |
| `PUT /api/mail-vacation` | `MailVacation` (전체 교체 — `isActive` 는 보내지 않는다) | 200 `MailVacationState`(저장한 값) | 400 `VAL_001` · `MAIL_013`(필터와 함께 만든 엔진 규칙이 한도를 넘음 — 6단계), 503 `MAIL_001` |
| `GET /api/mail-vacation` | — | 200 `MailVacationState` (아래 "부재중 자동 응답" — `MailVacation` + 7단계의 `isActive`. 16단계의 `htmlBody` · `external` 은 늘 담는다) | 503 `MAIL_001` |
| `PUT /api/mail-vacation` | `MailVacation` (전체 교체 — `isActive` 는 보내지 않는다. 16단계의 `htmlBody` · `external` 은 보내지 않아도 된다 — 글만 · 사내와 같은 응답) | 200 `MailVacationState`(저장한 값) | 400 `VAL_001` · `MAIL_013`(필터와 함께 만든 엔진 규칙이 한도를 넘음 — 6단계), 503 `MAIL_001` |
| `GET /api/mail-filters` | — | 200 `MailFilterSet` (아래 "메일 필터", 6단계) | 503 `MAIL_001` |
| `PUT /api/mail-filters` | `{ rules, version }` (전체 교체) | 200 `MailFilterSet` | 400 `VAL_001` · `MAIL_013`(만든 규칙이 엔진 한도를 넘음), 409 `MAIL_012`(그사이 다른 곳에서 필터가 바뀜 — 저장 안 함), 503 `MAIL_001` |
| `POST /api/mail-filter-runs` | `MailFilterRunRequest` — 받은편지함에 이미 있는 메일에 규칙 하나를 적용한다(아래 "이미 받은 메일에 적용", 7단계) | 200 `MailFilterRun` — 붙잡힌 메일(9단계)은 옮기지 않고 표시만 | 400 `VAL_001`(규칙 검사 · 할 일 없음 · 깨진 cursor), 429 `REQ_001`(같은 직원의 적용이 진행 중), 503 `MAIL_001` |
@@ -440,9 +441,15 @@ HTML 은 정화하기 **전에** 길이(HTML 부분이 여럿이면 이어 붙
| `GET /api/drafts/{id}` | — | 200 `Draft` (아래 "임시 저장") | 404 `RES_001`(없음·초안이 아님), 503 `MAIL_001` |
| `POST /api/drafts` | 초안 본문 | 201 `DraftSaved` | 400 `VAL_001`·`MAIL_002`·`MAIL_003`·`MAIL_004`·`MAIL_006`·`MAIL_007`·`MAIL_016`(대용량 첨부 — 11단계, 4절)·`FILE_001`, 413 `FILE_002`, 503 `MAIL_001` |
| `PUT /api/drafts/{id}` | 초안 본문 (전체 교체) | 200 `DraftSaved` — **새 id** | 위와 같음 + 404 `RES_001`(초안이 아닌 id — 지우지 않음) + 409 `MAIL_008`(다른 곳에서 이미 바뀜 — 저장 안 함) |
| `GET /api/mail-signature` | — | 200 `MailSignature` (아래 "서명") | — |
| `PUT /api/mail-signature` | `{ bodyHtml, body, isForNew, isForReply }` | 200 `MailSignature` | 400 `VAL_001` |
| `POST /api/mail-signature/images` | multipart `file` 1개 | 201 `SignatureImage` | 400 `FILE_001`·`VAL_001`(올려 둔 그림 10장 초과), 413 `FILE_002`(300KB 초과) |
| `GET /api/mail-signatures` | — | 200 `{ items: MailSignature[], pageInfo }` — 만든 차례, 10개까지 한 번에(hasNext=false · `total`) (아래 "서명", 16단계) | — |
| `POST /api/mail-signatures` | `{ name, bodyHtml, body }` | 201 `MailSignature` — 서명이 하나도 없던 직원이면 「새 메일」 · 「답장 · 전달」 두 곳의 기본이 된다 | 400 `VAL_001`(이름 · 같은 이름 · 본문 한도 · 11번째) |
| `PUT /api/mail-signatures/{id}` | `{ name, bodyHtml, body }` (전체 교체 — 기본 표시는 그대로) | 200 `MailSignature` | 400 `VAL_001`, 404 `RES_001`(없는 · 남의 · 형식이 틀린 id) |
| `DELETE /api/mail-signatures/{id}` | — | 204 — 이 서명을 기본으로 쓰던 곳은 넣지 않음, 이 서명에만 쓰던 그림은 함께 지운다 | 404 `RES_001` |
| `GET /api/mail-signature-defaults` | — | 200 `MailSignatureDefaults` (「메일에 넣을 서명」 둘 — 16단계) | — |
| `PUT /api/mail-signature-defaults` | `{ newMessageId, replyId }` (두 키 모두 — null 은 넣지 않음) | 200 `MailSignatureDefaults` | 400 `VAL_001`, 404 `RES_001`(내 서명이 아닌 id — 바꾸지 않음) |
| `GET /api/mail-signature` | — | 200 `LegacyMailSignature` — 「새 메일」 기본 서명을 15단계까지의 모양으로 (옛 경로 — 앱 첫 판이 읽는다) | — |
| `PUT /api/mail-signature` | `{ bodyHtml, body, isForNew, isForReply }` | 200 `LegacyMailSignature` (옛 경로 — 16단계 전의 웹 탭) | 400 `VAL_001` |
| `POST /api/mail-signature/images` | multipart `file` 1개 | 201 `SignatureImage` | 400 `FILE_001`·`VAL_001`(올려 둔 그림 20장 초과 — 16단계부터, 전에는 10장), 413 `FILE_002`(300KB 초과) |
| `GET /api/mail-signature/images/{id}` | — | 그림 파일 (표준 래퍼 없음, 본인 그림만) | 404 `RES_001` |
| `GET /api/recipient-suggestions` | `q`(선택, 1~100자) · `limit`(기본 20, 최대 100) · `cursor` | 200 `{ items: RecipientSuggestion[], pageInfo }` (아래 "받는 사람 추천") | 400 `VAL_001`, 503 `MAIL_001` |
| `GET /api/scheduled-messages` | `cursor` · `limit`(기본 20, 최대 100) | 200 `{ items: MessageSummary[], pageInfo }` — 예약한 메일, 보낼 시각 차례, `total` 포함 (아래 "보내기 취소 · 예약 발송", 9단계) | 400 `VAL_001`, 503 `MAIL_001` |
@@ -726,16 +733,30 @@ interface DraftSaved { id: string; attachments: Attachment[]; updatedAt: string
- 저장 응답을 받지 못하면(시간 초과 등) 서버는 이미 새 저장본을 만들었을 수 있다 — 다음 PUT 은 `MAIL_008` 이 되어 클라이언트가 새로 남기므로
임시보관함에 한 통이 더 남을 수 있다 (잃는 내용은 없다).
### 서명 (`/api/mail-signature`)
### 서명 (`/api/mail-signatures` · `/api/mail-signature-defaults` · 옛 경로 `/api/mail-signature`) — 16단계부터 여러 개
```ts
interface MailSignature {
interface MailSignature { // 16단계 — 한 사람에 10개까지
id: string // 서버가 만든 UUID
name: string // 앞뒤 공백을 뺀 1~30자(JS 문자열 길이) — 제어 문자 없음, 내 다른 서명과 겹치지 않는다(영문 대소문자 무시)
bodyHtml: string | null // 서명 서식 — 서식 본문 규칙 + 서명 그림. 정화 뒤 10,000자 이하
body: string | null // 텍스트 서명 (텍스트 대체 본문에 넣는다) — 2,000자 이하
isForNew: boolean // 새 메일에 넣는다 (기본 true)
isForReply: boolean // 답장·전달에 넣는다 — 인용 위 (기본 true)
images: SignatureImage[] // 서명이 쓰는 그림
updatedAt: string | null // 저장한 적 없으면 null
images: SignatureImage[] // 서명이 쓰는 그림 (HTML 에 나온 순서)
updatedAt: string // 마지막으로 저장한 시각
}
interface MailSignatureDefaults { // 「메일에 넣을 서명」 — null 은 넣지 않음 (15단계까지의 두 체크를 대신한다)
newMessageId: string | null // 새 메일 (본문 끝)
replyId: string | null // 답장 · 전달 (인용 위)
}
interface LegacyMailSignature { // 옛 경로 GET · PUT /api/mail-signature — 15단계까지의 MailSignature 모양 (앱 첫 판이 읽는다)
bodyHtml: string | null // 「새 메일」 기본 서명의 것 — 없으면 「답장 · 전달」 기본 서명의 것
body: string | null
isForNew: boolean // 「새 메일」 기본 서명이 있다
isForReply: boolean // 「답장 · 전달」 기본이 이 서명이다
images: SignatureImage[]
updatedAt: string | null // 기본 서명이 둘 다 없으면 null
}
interface SignatureImage {
@@ -747,19 +768,42 @@ interface SignatureImage {
}
```
- 사용자마다 하나다. 저장한 적 없으면 기본값(`bodyHtml: null`·`body: null`·둘 다 `true`·`images: []`)을 돌려준다. 서버 DB 에 둔다(메일 엔진에 두지 않는다).
- `PUT` 의 한도 — 넘으면 400 `VAL_001`: 텍스트 서명 2,000자, 서식 서명은 정화 전에 서식 본문과 같은 한도(500,000자·파서 비용)와 정화 뒤 10,000자,
서명 그림 3장(서로 다른 그림 기준).
- 서버 DB 에 둔다(메일 엔진에 두지 않는다). 목록은 **만든 차례**이고 10개까지 한 번에 준다(`hasNext: false` · `total` = 서명 수).
- 만들기 · 고치기(`POST` · `PUT /api/mail-signatures/{id}` — 전체 교체)의 한도 — 넘으면 400 `VAL_001`(아무것도 저장하지 않는다):
이름(위 — 같은 이름은 그사이 다른 창에서 만든 것도 서버가 막는다), 11번째 만들기, 텍스트 서명 2,000자,
서식 서명은 정화 전에 서식 본문과 같은 한도(500,000자·파서 비용)와 정화 뒤 10,000자, 서명 그림 3장(서로 다른 그림 기준).
없는 서명 · 남의 서명 · 형식이 틀린 id 는 404 `RES_001`. 같은 직원의 서명 저장 · 지우기 · 기본 바꾸기는 서버가 차례로 한다(사용자 잠금).
- **「메일에 넣을 서명」**: 서명이 하나도 없던 직원의 첫 서명(`POST`)은 「새 메일」 · 「답장 · 전달」 두 곳의 기본이 된다(15단계까지 두 체크가 처음에 켜져 있던 것과 같다).
그 밖의 만들기 · 고치기는 기본을 바꾸지 않는다. 지우면(`DELETE` → 204) 그 서명을 기본으로 쓰던 곳은 넣지 않음(null)이 된다 — 다른 서명이 저절로 기본이 되지 않는다.
`PUT /api/mail-signature-defaults` 는 두 키를 모두 보내고(null = 넣지 않음, 두 곳이 같은 서명이어도 된다) 바꾼 뒤의 값을 돌려준다 —
내 서명이 아닌 id 는 404 `RES_001`(아무것도 바꾸지 않는다). 기본은 한 곳에 하나다(표의 부분 유일 인덱스).
- 이미 보냈거나 임시 저장한 메일에 들어간 서명 글은 서명을 고치거나 지워도 그대로다.
- **옛 경로**(앱 첫 판 · 16단계 전의 웹 탭 — 그대로 둔다):
- `GET /api/mail-signature` — 「새 메일」 기본 서명을 위 `LegacyMailSignature` 로. 「새 메일」 기본이 없으면 「답장 · 전달」 기본 서명을 `isForNew: false` · `isForReply: true` 로
(옛 두 체크가 새 메일 끔 · 답장 켬이던 직원 — 옮기면 답장 기본만 있다 — 이 앱 첫 판의 답장에서 서명을 잃지 않게).
둘 다 없으면 `{ bodyHtml: null, body: null, isForNew: false, isForReply: false, images: [], updatedAt: null }`
(15단계까지는 저장한 적 없으면 두 표시가 `true` 였다 — 본문이 없으니 앱이 넣는 것은 같다). 「새 메일」 기본이 있을 때 `isForReply` 는 「답장 · 전달」 기본이 같은 서명일 때만 `true` —
두 곳이 서로 다른 서명이면 앱 첫 판은 답장에 서명을 넣지 않는다(알려진 한계 — 앱에서 서명 고르기는 앱 다음 판 후보).
- `PUT /api/mail-signature` — 옛 GET 이 보이는 서명(「새 메일」 기본, 없으면 「답장 · 전달」 기본, 둘 다 없으면 "기본 서명"이라는 이름의 서명 — 그것도 없으면 만든다)의 본문을 바꾸고 두 표시를 기본 둘로 옮긴다:
`isForNew` → 「새 메일」 기본을 이 서명으로(false 면 넣지 않음), `isForReply` → 「답장 · 전달」 기본을 이 서명으로(false 면 이 서명이 기본이었을 때만 넣지 않음 — 다른 서명이 기본이면 그대로).
한도 · 정화는 위와 같다(새로 만들어야 하는데 이미 10개면 400 `VAL_001`).
- **옮기기**(마이그레이션 `MultipleMailSignatures` — 표 `mail_signatures` 를 서명마다 한 줄로): 지금 서명은 "기본 서명"이라는 이름의 서명 하나가 되고,
두 체크(`isForNew` · `isForReply`)에서 켠 곳의 기본이 된다(끈 곳은 넣지 않음). **내용이 비어 있던 서명(서식 · 텍스트 모두 없거나 공백뿐)은 옮기지 않는다.**
되돌리기(down)는 한 사람에 한 줄만 남긴다 — 「새 메일」 기본 → 「답장 · 전달」 기본 → 먼저 만든 서명 차례로 하나, 나머지 서명은 지운다(남긴 서명의 두 표시가 옛 두 체크가 된다).
- **쓰기 화면의 서명 자리**: 앱과 같은 경계 — 빈 문단 하나 뒤의 **속성 없는 `<div>` 하나**(위 "서식 본문"의 "앱이 만드는 모양"). 보내는 정화 · 임시 저장본을 다시 열 때의 정화 ·
받은 메일 정화를 지나도 그대로 남는다(서버 시험 — `sanitize-outgoing-html.spec.ts`). 다만 속성이 모두 지워지는 div(허용하지 않는 style 뿐 · class · data-*)도
속성 없는 div 가 되므로, 경계는 자리(빈 문단 바로 뒤)로 알아본다.
- **서명 그림**: `POST /api/mail-signature/images` (multipart `file`) — PNG·JPEG·GIF 만(확장자·MIME·파일 앞부분 모두 검사, WebP·SVG 는 받지 않는다),
한 장 300KB 이하(넘으면 413 `FILE_002` — 받은 메일의 본문 그림 한도와 같아서, 보낸 메일을 우리 웹메일로 다시 읽어도 그림이 보인다),
가로·세로 각각 4096px 이하(넘으면 400 `FILE_001` — 작은 파일 안의 아주 큰 그림이 받는 쪽을 무겁게 하지 않게).
서명에는 3장까지 넣는다(편집기가 먼저 막는다). 발송 본문·임시 저장본도 서로 다른 서명 그림 3장까지다(넘으면 400 `VAL_001`).
올려 두기만 한 그림을 포함해 한 사람 10장까지 둘 수 있다(넘으면 올리기가 400 `VAL_001`).
올려 두기만 한 그림을 포함해 한 사람 20장까지 둘 수 있다(넘으면 올리기가 400 `VAL_001` — 여러 서명이 함께 쓰는 칸이라 16단계에 10 → 20).
서명 HTML 은 그림을 `<img src="/api/mail-signature/images/{id}" width="144" alt="">` 로 가리킨다(`width` 1~600 — 작게 96 · 보통 144 · 원본은 원래 폭, 600 이하).
- `GET /api/mail-signature/images/{id}` 는 그 사람의 그림만 내준다(남의 것은 404 `RES_001`) — 편집기·미리 보기용이고, 받는 사람은 이 주소를 쓰지 않는다.
저장할 때 확인한 형식 그대로, `Content-Disposition: inline` · `X-Content-Type-Options: nosniff` · `Cache-Control: private, max-age=86400`
(그림 id 의 내용은 바뀌지 않는다 — 새로 올리면 새 id).
- `PUT` 은 서명 HTML 이 더는 가리키지 않는 그림을 지운다 — 다만 한 번도 저장하지 않은 그림 가운데 10분 안에 올린 것은 남긴다
- 서명을 저장 · 지울 때(옛 경로 `PUT` 포함) **어느 서명의 HTML 도 더는 가리키지 않는 그림**(모든 서명의 합집합 밖)을 지운다 — 다른 서명이 쓰는 그림은 남고,
지운 서명에만 쓰던 그림은 함께 지워진다. 다만 한 번도 저장하지 않은 그림 가운데 10분 안에 올린 것은 남긴다
(그림을 넣자마자 저장하면 올리기와 저장이 엇갈릴 수 있다). 올리고 저장하지 않은 그림은 하루 뒤 지운다.
- **보낼 때**: 쓰기 화면이 서명을 본문에 보통 글로 넣어 둔 그대로 보낸다 — 서명 그림은 위 "서식 본문"의 그림 예외로 메일 안에 함께 실린다
(`multipart/related` 의 그림 부분 — `Content-Disposition: inline`, `Content-ID: <sig-{그림 id}@{보내는 사람 도메인}>`, 이름 `signature-1.png` …,
@@ -1144,7 +1188,7 @@ interface MailboxBulkResult {
다른 탭에서 먼저 끝냈으면 실패로 보이지 않는다 — 모두 읽음 · 비우기의 0통은 "…에 안 읽은 메일이 없습니다" · "…이 이미 비어 있습니다", 지우기의 404 `RES_001` 은 지운 것으로 본다.
메일함 지우기의 진행은 `processed` 로 센다(`movedToTrash` 는 휴지통으로 간 것만이라 다른 메일함에도 든 메일만 남으면 늘지 않는다).
### 부재중 자동 응답 (`/api/mail-vacation`) — 4단계
### 부재중 자동 응답 (`/api/mail-vacation`) — 4단계 · 16단계(서식 본문 · 회사 밖 응답)
```ts
interface MailVacation {
@@ -1152,7 +1196,15 @@ interface MailVacation {
fromDate: string | null // ISO 8601 UTC — null 이면 켠 때부터
toDate: string | null // null 이면 끌 때까지
subject: string // 켤 때 150자까지(꺼 둔 채로는 200자까지 — 아래 "길이 한도의 근거") — 켤 때(isEnabled true)는 공백이 아닌 글자 1자 이상. 제어 문자(메일함 이름과 같은 범위 — 탭 · 줄바꿈 포함)는 쓰지 않는다(한 줄짜리 머리글)
textBody: string // 켤 때 600자까지(꺼 둔 채로는 4,000자까지) — 켤 때는 공백이 아닌 글자 1자 이상. 글자만(서식 없음) — 제어 문자는 탭 · LF · CR 만. 서명은 붙지 않는다
textBody: string // 보이는 글(서식을 뺀 글) — 켤 때 600자까지(꺼 둔 채로는 4,000자까지), 켤 때는 공백이 아닌 글자 1자 이상. 제어 문자는 탭 · LF · CR 만. 서명은 붙지 않는다
htmlBody: string | null // 16단계 — 서식 본문(그림 없음). 정화해 저장한다 — 켤 때 정화 뒤 10,000자까지. null = 글만. PUT 에 없으면 null
external: MailVacationExternal // 16단계 — 회사 밖에서 온 메일에 하는 응답. PUT 에 없거나 null 이면 사내와 같은 응답(글 칸 null)
}
interface MailVacationExternal { // 16단계 — PUT 에 담으면 네 키를 모두 보낸다(글 칸은 null 가능)
mode: 'same' | 'none' | 'separate' // 사내와 같은 응답(처음 값) · 보내지 않기 · 따로 쓰기
subject: string | null // 따로 쓰기의 제목 — 켜고 따로 쓰기면 1~150자(그 밖에는 200자까지 · 제어 문자 없음). 빈 글은 null 로 저장
textBody: string | null // 따로 쓰기의 보이는 글 — 켜고 따로 쓰기면 1~600자(그 밖에는 4,000자까지 · 탭 · LF · CR 밖의 제어 문자 없음). 빈 글은 null
htmlBody: string | null // 따로 쓰기의 서식 본문(그림 없음) — 정화해 저장, 켜고 따로 쓰기면 정화 뒤 10,000자까지
}
interface MailVacationState extends MailVacation { // GET · PUT 의 응답 (7단계)
isActive: boolean // false = 엔진에서 우리 규칙(필터와 한 스크립트)이 꺼져 있어 켜 둔 자동 응답이 지금은 동작하지 않는다 — 저장하면 다시 켜진다.
@@ -1166,11 +1218,12 @@ interface MailVacationState extends MailVacation { // GET · PUT 의 응답 (7
이 한도는 **켤 때만** 본다 — 꺼 둔 채로는 4단계 한도(200자 · 4,000자)까지 받는다. 4단계에 엔진 한도 안으로 저장해 둔 긴 글(영문 내용 1,000자 등)이 켜져 있으면
화면은 꺼 둘 때 칸이 흐려 고칠 수 없는데, 끄기는 늘 저장되어야 하기 때문이다. 다시 켤 때 150 · 600 에 걸리면 줄인 뒤 켠다.
- 한 번도 설정하지 않은 계정은 `{ isEnabled: false, fromDate: null, toDate: null, subject: "", textBody: "" }` 로 온다(엔진의 null 은 빈 문자열로). 끈 채로는 빈 제목 · 내용도 저장된다.
- 한 번도 설정하지 않은 계정은 `{ isEnabled: false, fromDate: null, toDate: null, subject: "", textBody: "", htmlBody: null, external: { mode: "same", subject: null, textBody: null, htmlBody: null } }` 로 온다
(엔진의 null 은 빈 문자열로 — 엔진 VacationResponse 의 서식 본문은 가져오지 않는다). 끈 채로는 빈 제목 · 내용도 저장된다.
제목 · 내용은 쓴 그대로 저장한다 — "공백이 아닌 글자" 검사만 앞뒤 공백을 뺀 값(JS `trim()` — NBSP · U+3000 포함)으로 한다. 제어 문자 검사는 꺼 둔 채로도 한다.
- 저장 자리 — 6단계부터 메일 필터와 같은 엔진 규칙(Sieve 스크립트 `arasystem`)에 둔다(아래 "메일 필터"의 "저장 자리"). 4단계에는 엔진의 JMAP `VacationResponse`(singleton)였다.
서식 본문은 없다(글자만). 저장은 전체 교체이고 응답은 저장한 값이다. 꺼도 쓴 제목 · 내용은 남겨 둔다.
15단계까지 서식 본문은 없었다(글자만 — 16단계는 아래 "서식 본문 · 회사 밖 응답"). 저장은 전체 교체이고 응답은 저장한 값이다. 꺼도 쓴 제목 · 내용은 남겨 둔다.
필터와 한 스크립트라서 부재중을 저장해도 스크립트를 다시 켠다(필터가 꺼져 있었으면 함께 다시 동작한다). 필터 규칙은 건드리지 않는다.
- 6단계부터 바뀐 동작: 엔진이 스팸으로 분류한 메일에는 답하지 않는다(필터와 같은 스팸 막기 — "메일 필터"의 "만드는 Sieve").
- 우리 스크립트가 엔진에서 꺼져 있으면(`isActive: false`) 부재중도 동작하지 않는다 — `isEnabled` 는 저장한 값이라 그때도 `true` 다.
@@ -1181,6 +1234,45 @@ interface MailVacationState extends MailVacation { // GET · PUT 의 응답 (7
둘 다 있으면 버린 값으로 `fromDate < toDate`(아니면 400 `VAL_001`). 화면은 날짜만 고르고, 브라우저 시간대의 시작일 0시 · 끝나는 날 다음 날 0시를 UTC 로 보낸다.
- 같은 사람에게 매번 답하지는 않는다 — JMAP 규격(RFC 8621 8절)이 "보낸 사람마다 적당한 기간에 한 번" 을 요구하고 엔진이 따른다.
6단계부터는 Sieve `vacation`(RFC 5230)이 같은 일을 한다 — 간격은 엔진 기본값이다.
- **서식 본문 · 회사 밖 응답 (16단계)**:
- **서식 본문**(`htmlBody`)은 서명과 같은 정화(쓰기 화면의 서식 본문 허용 목록 — **그림은 없다**)를 거쳐 저장한다. 정화 전 길이(500,000자) · 파서 비용은 켬 · 끔과 상관없이 보고,
정화 뒤 10,000자(JS 문자열 길이)는 켤 때만 본다(회사 밖 서식은 따로 쓰기이고 켤 때만) — 넘으면 400 `VAL_001`. 정화해서 남는 것이 없으면 null(글만).
보이는 글(`textBody`)은 화면이 서식을 뺀 글로 함께 보낸다 — 켤 때의 600자 한도는 이 글로 본다(지금 그대로).
- 받는 사람에게는 서식이 있으면 **글 + 서식 두 벌**(`multipart/alternative` — text/plain · text/html, 각 base64)로 간다. 서식을 읽지 못하는 메일 프로그램은 글만 보인다.
서식이 없으면 지금처럼 글만(text/plain)이다.
- **회사 밖 응답**(`external`) — 사내 · 회사 밖은 **봉투 보낸 사람(return-path)의 도메인**으로 가른다: `MAIL_DOMAIN` 과 같으면 사내(하위 도메인은 회사 밖).
엔진은 답장을 봉투 주소로만 보내므로 머리글 From 을 사내로 꾸며도 사내용 글은 그 봉투 주소(회사 밖)로 가지 않는다(2026-11-10 개발 엔진 — 실제 배달로 확인).
`same` = 지금처럼 모두에게 같은 응답(처음 값), `none` = 회사 밖에는 답하지 않는다, `separate` = 회사 밖에는 따로 쓴 제목 · 내용으로.
다른 고르기로 바꿔도 따로 써 둔 회사 밖 글은 그대로 저장한다(빈 글은 null). 회사 밖 칸은 따로 쓰기이고 켤 때만 사내와 같은 규칙(1자 이상 · 150 · 600자 · 서식 10,000자)으로 보고,
사내와 같은 응답 · 보내지 않기면 비어 있어도 저장한다.
- 같은 사람에게 다시 보내는 간격은 엔진 기본 그대로이고, 사내 · 회사 밖은 따로 센다 — 회사 밖 응답에 `:handle` 을 따로 둔다(응답 내용의 지문 — 글을 바꾸면 다시 보낸다,
사내 응답은 15단계까지와 같은 모양이라 고르기를 바꿔도 같은 사람에게 다시 보내지 않는다).
- 옛 요청(16단계 칸 없음)은 글만 · 사내와 같은 응답으로 저장한다 — 옛 화면이 보는 그대로. 응답(GET · PUT)에는 두 칸을 늘 담는다.
- **만드는 Sieve**(아래 "메일 필터"의 "만드는 Sieve"에 더하는 것):
```
if not header :matches "X-Spam-Score" "spam*" {
if currentdate … { ← 기간이 있으면 (지금 그대로)
if envelope :domain :is "from" "<MAIL_DOMAIN>" { ← 회사 밖 응답이 사내와 같지 않을 때만
vacation :mime :subject "<사내 제목>" "<MIME 개체>";
} else { ← 따로 쓰기만 (보내지 않기면 else 가 없다)
vacation :mime :handle "ara-external-<지문>" :subject "<회사 밖 제목>" "<MIME 개체>";
}
}
필터 규칙 …
}
```
require 에 `envelope`(가를 때) · `variables`(조각이 있을 때)를 더한다. MIME 개체가 Sieve 문자열 한 개 한도(4,096바이트)를 넘으면 `variables` 로 3,000바이트 이하 조각에 나눠 담고
(`set "part0" "…";` — 조각은 줄 끝(CRLF) 뒤에서만 끊는다) `vacation :mime "${part0}${part1}…"` 로 잇는다 — 2026-11-10 개발 엔진: 이은 답장의 글 · 서식이 원래와 바이트까지 같다.
사용자 글 · 서식은 base64 · encoded-word 안에만 있어 Sieve 글에 `${` 가 생기지 않는다(지금처럼). 스크립트 90,000바이트 한도(400 `MAIL_013`)는 그대로다 —
서식 · 회사 밖 글 · 차단 목록이 함께 크면 걸릴 수 있다(알려진 한계).
- **원본 판 3** — 머리 주석의 원본(JSON)은 **새 칸을 쓴 직원만** 판 3 으로 쓴다(서식 본문이 있거나 · 회사 밖 응답이 사내와 같지 않거나 · 회사 밖 글을 써 둔 경우 — 판 3 은 차단 목록 칸을 늘 담는다).
새 칸을 쓰지 않은 직원은 판 1 · 2 그대로다. 판 3 의 서식 본문은 읽을 때 다시 정화한다(다른 메일 프로그램이 고친 원본의 HTML 이 화면에 그대로 가지 않게 — 정화할 수 없으면 깨진 원본).
**전제 — 서식이 없는 글이면 화면은 `htmlBody: null` 을 보낸다**(웹 설정 화면 — 글만 쓴 직원은 판 1 · 2 에 머문다). 서버는 서식이 있는지 가리지 않고
정화한 `htmlBody` 가 남으면(null · 빈 값이 아니면) 판 3 으로 쓴다 — 그래서 판 3 은 서식을 쓰거나 회사 밖 응답 칸을 쓴 직원뿐이고, 아래 되돌리기 계획이 막는 직원도 그들뿐이다.
- **되돌리기 계획**: 판 3 을 모르는 서버(15단계까지)로 되돌리면 **새 칸을 쓴 직원만** 필터 · 부재중 · 차단 저장이 503 `MAIL_001` 이고 부재중 화면은 꺼짐으로 보인다 —
엔진의 응답(Sieve)은 그대로 돈다(옛 서버가 새 판을 지우지 않게 — 6단계의 규칙). 그 직원들이 저장해야 하면 16단계 서버로 다시 올린다
(판 3 을 읽기만 하는 서버를 먼저 배포할 수 있게 둔다 — 9단계 판 2 와 같은 길).
- 로그: 켬 · 끔 · 서식 있음 · 회사 밖 고르기만 남긴다(제목 · 글 · 서식은 남기지 않는다).
- **클라이언트**: 설정 › 메일 › 부재중 자동 응답. 켜져 있으면 메일 화면의 메일함 패널 아래(사용량 위)에 "부재중 자동 응답 켜짐 · 11월 3일까지"(누르면 설정)를 보인다 —
메일 화면에 들어올 때 한 번 받고, 시간이 지나면 다시 계산한다(끝나는 시각이 지나면 숨기고, 시작 전이면 "10월 30일 ~ 11월 3일 · 설정").
@@ -1319,6 +1411,7 @@ interface MailFilterActions {
- 4단계 `/api/mail-vacation` 은 계약 그대로 두고 저장 자리만 이 스크립트로 옮긴다 — 엔진의 VacationResponse 는 더 쓰지 않는다.
스크립트가 아직 없는 계정은 처음 읽을 때 VacationResponse 의 값을 가져온다(개발 데이터 이어받기). 엔진의 `vacation` 스크립트는 고칠 수 없다(엔진이 `forbidden`).
- 스크립트 머리의 주석에 규칙 · 부재중 원본(JSON — 판 번호 `v` 를 함께 둔다)을 두고, 읽을 때는 그것만 믿는다(엔진의 Sieve 를 다시 해석하지 않는다).
판은 쓰는 칸에 따라 가장 낮은 것으로 쓴다 — 1: 규칙 · 부재중, 2: + 차단 목록(9단계 — 아래 "보낸 사람 차단"), 3: + 부재중의 서식 본문 · 회사 밖 응답(16단계 — 위 "부재중 자동 응답").
- 스크립트가 아직 없으면 빈 규칙 + `isActive: true`, 부재중은 엔진의 VacationResponse 값(위 이어받기).
- 스크립트는 있는데 원본이 없거나 깨졌으면 빈 규칙 + `isActive: false`, 부재중은 꺼짐 — 저장하면(필터든 부재중이든) 덮어쓴다.
- 원본의 판이 이 서버가 아는 것보다 새로우면(새 서버를 배포했다 되돌린 경우) 읽기는 위와 같지만 **쓰기는 거절한다**(503 `MAIL_001`) — 옛 서버가 새 판의 규칙을 지우지 않게.
@@ -1341,6 +1434,7 @@ interface MailFilterActions {
(그 메일은 엔진 판정대로 들어간다). 엔진의 스팸 필터는 바깥에서 오는(인증 없는) 메일에만 돌아 사내 메일에는 이 머리글이 없다 — 규칙이 그대로 적용된다.
- 부재중: 엔진이 하던 대로 `vacation :mime`(본문 base64) — 제목은 RFC 2047 로 인코딩해 넣는다. Stalwart 는 require 와 상관없이 문자열의 `${…}` 를 늘 변수로 펼쳐서
그대로 넣으면 지워진다(같은 날 확인). 기간은 `currentdate :zone "+0000"`(엔진 시간대에 기대지 않게 — 같은 날 확인). 같은 사람에게 다시 답하는 간격은 엔진 기본값(지금과 같다).
16단계의 서식 본문(글 + 서식 두 벌) · 회사 밖 응답(봉투 도메인으로 가름) · 긴 개체의 `variables` 조각은 위 "부재중 자동 응답"의 "만드는 Sieve".
- 규칙 값에 `${` 는 받지 않는다(400 `VAL_001`) — 펼쳐져 빈 값이 되면 `:contains` 가 모든 메일에 맞는다. 문자열은 `"` · `\` 를 이스케이프한다.
`%{…}`(Stalwart 의 식 모양)는 펼치지 않아 막지 않는다 — `%{"a"}` · `%{}` · `%{1+1}` · `%{subject}` 모두 글자 그대로 견주었다(같은 날 확인).
- 크기: 엔진 한도는 스크립트 102,400바이트 · 문자열 4,096바이트다. 만든 스크립트가 90,000바이트를 넘으면 저장하지 않고 400 `MAIL_013`.
@@ -1684,11 +1778,12 @@ interface MailSettings {
### 이 단계에서 하지 않는 것
탭을 모두 닫아도 오는 알림(웹 푸시 — 엔진은 `webpush-vapid` 를 지원한다), 알림 소리(운영체제 설정을 따른다), 휴대폰 푸시, 본문 안 이미지(인라인 cid — 붙여 넣은 이미지는 첨부로, 서명 그림만 예외), 메일 안의 배경 그림 보이기 · 보낸 사람마다 고르는 "이미지 늘 보기" 목록(9단계는 설정 세 가지로),
여러 개의 서명(자체 검색 색인 · 조건 검색 · 검색어 강조는 14단계에서 했다 — 위 "검색 — 14단계"),
받는 사람에 따라 서명을 저절로 바꾸기 · 앱에서 서명 고르기(16단계 서명은 기본 둘과 쓰기 화면의 [서명 ▾]로 — 자체 검색 색인 · 조건 검색 · 검색어 강조는 14단계에서 했다),
메일함을 끌어 옮기기 · 메일함 순서 바꾸기(15단계는 ⋯ 메뉴 「옮기기」 창으로 — 메일을 끌어다 메일함에 놓기는 15단계에서 했다, 서버는 지금 `PATCH /api/messages` 그대로), 옮기기 단축키,
부재중 응답의 서식 본문 · 사내/외부 구분(앱 비밀번호 · 2단계 인증은 12단계에서 했다), 7일 넘는 예약(엔진 한도), 예약됨 목록 줄의 [지금 보내기](15단계는 읽기 화면의 예약 안내 줄에만),
부재중 응답의 「내 연락처에게만」(엔진 Sieve 는 주소록을 보지 못한다) · 부재중 응답의 그림(앱 비밀번호 · 2단계 인증은 12단계에서 했다), 7일 넘는 예약(엔진 한도), 예약됨 목록 줄의 [지금 보내기](15단계는 읽기 화면의 예약 안내 줄에만),
차단 정리의 되돌리기 · 받은편지함 밖 메일함의 정리(15단계는 받은편지함만 — 위 "보낸 사람 차단"의 "받은편지함 정리").
15단계에 한 것: 메일함 옮기기 · 하위 메일함까지 지우기(위 "메일함 관리"), 차단할 때 받은편지함 정리(위 "보낸 사람 차단"), 예약한 메일 지금 보내기(위 "보내기 취소 · 예약 발송").
16단계에 한 것: 여러 서명 · 「메일에 넣을 서명」 둘(위 "서명"), 부재중 응답의 서식 본문 · 회사 밖 응답(위 "부재중 자동 응답").
---
@@ -1790,6 +1885,10 @@ interface DirectoryEntry {
| `POST /api/contact-groups/{id}/members` | `{ contactIds?, directoryIds? }` — 합쳐 1~100개 | 200 `ContactGroupMembersAdded` | 400 `VAL_001`, 404 `RES_001`(없는 그룹 · 연락처 · 사내 목록 id), 409 `CONTACT_002`, 503 `MAIL_001` |
| `GET /api/directory-entries` | `q`(선택, 1~254자) · `kind`(선택 — `person` · `shared`) · `excludeGroupId`(선택 — 그 그룹 구성원의 주소를 뺀다. 구성원 넣기 창) · `cursor` · `limit`(기본 20, 최대 100) | 200 `{ items: DirectoryEntry[], pageInfo }` — 이름 차례, `total` 포함 | 400 `VAL_001`, 404 `RES_001`(없는 그룹), 503 `MAIL_001`(사내 목록을 읽지 못함 — 아래 "사내 목록") |
| `GET /api/directory-entries/{id}` | — | 200 `DirectoryEntry` | 404 `RES_001`(없거나 빼는 계정), 503 `MAIL_001` |
| `POST /api/contact-import-checks` — 16단계 | `{ contacts: ContactImportItem[] }` — 1~100명 | 200 `ContactImportCheck` — 사람마다 `new` · `exists`, 남은 자리(아래 "가져오기 · 내보내기") | 400 `VAL_001`, 503 `MAIL_001` |
| `POST /api/contact-imports` — 16단계 | `{ contacts: ContactImportItem[] }` — 1~100명 | 201 `ContactImportResult` — 넣은 연락처(같은 uid 로 이미 있던 것 포함), 보낸 차례 | 400 `VAL_001`, 404 `RES_001`(없는 groupId), 409 `CONTACT_002`(연락처 3,000개 · 크기 — 묶음 전체로, 하나도 만들지 않음), 503 `MAIL_001` |
| `POST /api/contact-deletions` — 16단계 | `{ ids }` — 1~500개, 겹치지 않게 | 200 `ContactsDeleted` — 영구 삭제(되돌리기 표 없음) | 400 `VAL_001`, 503 `MAIL_001` |
| `GET /api/contact-exports` — 16단계 | `format`(`vcard` · `csv`) · `groupId`(선택 — 그 그룹만) | 200 파일 — `text/vcard` · `text/csv`(표준 래퍼 없음, `attachment`) | 400 `VAL_001`, 404 `RES_001`(없는 그룹), 503 `MAIL_001` |
- **에러 코드(8단계에 더함)**: `CONTACT_001` 409 — 같은 이름의 그룹이 있다(만들기 · 이름 바꾸기 — 화면은 칸 아래에, 알림 없이).
`CONTACT_002` 409 — 주소록 한도(연락처 3,000개 · 그룹 100개 · 그룹 하나에 구성원 500명 · 아래 "크기")를 넘는다 — 아무것도 바꾸지 않는다
@@ -1875,6 +1974,62 @@ interface DirectoryEntry {
- **읽기 화면 ⋯**: 보낸 사람 주소가 내 주소록에 없으면 「보낸 사람을 주소록에 추가」(이름 · 주소를 채운 연락처 추가 창), 있으면 「주소록에서 보기」.
판단은 메뉴를 열 때 `GET /api/contacts?q=<보낸 사람 주소>` 로 알림 없이 하고, 주소가 같은(대소문자 무시) 연락처가 있는지 본다.
### 가져오기 · 내보내기 — 16단계 (2026-11-10)
화면은 디자인 원본 `Contacts16`(디자인 README "16단계"). **파일은 화면(브라우저)이 읽는다** — vCard(2.1 · 3.0 · 4.0, 여러 명) · CSV(네이버 · Google · Outlook · 우리가 내보낸 꼴) · 엑셀 .xlsx 를
화면이 풀어 미리 보기까지 하고, 넣기로 한 연락처만 100명씩 서버로 보낸다(파일은 서버로 가지 않는다 — 개인 정보 · 새 서버 파서 없음). 서버는 연락처 추가와 같은 검사 · 같은 한도로 한 요청에 엔진 요청 하나로 만든다.
```ts
interface ContactImportItem extends ContactInput { // 연락처 추가 본문(ContactInput)의 칸 · 검사 그대로 +
uid: string // 'urn:uuid:' + 소문자 UUID — 화면이 사람마다 만든다(엔진 카드의 uid 가 된다)
}
interface ContactImportCheck { // POST /api/contact-import-checks → 200
items: { uid: string; status: 'new' | 'exists' }[] // 보낸 차례
remaining: number // 3,000 − 지금 연락처 수 (0 아래로 내려가지 않는다)
}
interface ContactImportResult { // POST /api/contact-imports → 201
items: { uid: string; id: string; skippedGroupIds: string[] }[] // 넣은 연락처만, 보낸 차례 — 빠진 uid 는 넣지 못한 것
}
interface ContactsDeleted { deletedIds: string[]; notFoundIds: string[] } // POST /api/contact-deletions → 200 (보낸 차례)
```
- **확인**(`POST /api/contact-import-checks`): 만들지 않는다. `groupIds` 는 보지 않는다(새 그룹은 넣기 직전에 만든다). 사람마다 아래 가운데 하나면 `exists`, 아니면 `new`:
- 이메일이 있는 사람 — 그 이메일 가운데 하나라도(앞뒤 공백을 빼고 소문자로 견준다) 내 주소록의 어느 연락처에 있다.
- 이메일이 없는 사람 — 이름(앞뒤 공백만 뺀 글 — 가운데 공백 · 대소문자는 그대로)이 같고 **전화번호의 숫자 모음**이 같은 연락처가 있다.
숫자 모음은 번호마다 숫자만 남긴 글의 모음이다(겹침 · 차례는 보지 않는다, 숫자가 없는 번호는 뺀다 — 번호 하나만 같으면 다른 사람이다). 주소록 쪽 연락처는 이메일이 있어도 된다.
둘 다 전화가 없으면 이름만 같아도 `exists`.
- 같은 uid 의 연락처가 이미 있다(다시 보낸 묶음).
**같은 파일 안의 겹침은 화면이 같은 규칙으로 본다**(파일 앞쪽에 같은 사람이 있으면 건너뛴다) — 짝: backend `modules/contacts/contact-import.ts` · frontend `shared/utils/contact-import`(한쪽을 고치면 둘 다).
- **넣기**(`POST /api/contact-imports`): 엔진 `ContactCard/set` 한 번(300명에 90ms — 2026-11-10 확인).
- 사람마다 보낸 uid 로 만든다. 같은 uid 의 연락처가 이미 있으면(응답을 잃고 다시 보낸 묶음) 새로 만들지 않고 그 id 를 돌려준다 — 빠진 그룹만 마저 넣는다. 두 번 들어가지 않는다.
- 연락처 3,000개 · 크기(800만 — 위 "크기") 한도는 **묶음 전체로** 미리 본다 — 넘으면 409 `CONTACT_002`, 하나도 만들지 않는다(화면은 `remaining` 으로 미리 줄인다).
- 그룹 구성원 500명을 넘기는 넣기는 그 그룹만 빼고 만들고 `skippedGroupIds` 에 적는다 — 실패가 아니다(보낸 차례대로 넣다가 찬 그룹부터 뺀다). 없는 그룹은 404 `RES_001`(연락처 추가와 같다).
- 검사(연락처 추가와 같은 칸 규칙 · 이름이나 이메일 · 같은 연락처 안의 같은 주소 · uid 모양 · 묶음 안의 같은 uid)에 걸리는 사람이 하나라도 있으면 400 `VAL_001` — 묶음 전체다(화면이 같은 규칙으로 미리 거른다). 겹침은 보지 않는다(확인의 몫).
- 메일 서버가 일부만 받으면 받은 것만 `items` 에 — **보낸 uid 가 빠져 있으면 넣지 못한 것이다**(화면은 "넣지 못함"으로). 던지지 않는다. 그룹 카드가 이미 쓰는 uid 도 만들지 않는다(엔진이 같은 주소록의 같은 uid 를 받지 않는다).
- **새 그룹**은 화면이 넣기 전에 지금 `POST /api/contact-groups` 로 만든다(같은 이름 409 `CONTACT_001` 이면 목록에서 그 그룹을 쓰고, 100개 한도 409 `CONTACT_002` 면 그 그룹 없이). 되돌릴 때 그 그룹은 `DELETE /api/contact-groups/{id}`.
- **한꺼번에 지우기**(`POST /api/contact-deletions`): 가져오기 [되돌리기] 몫이다. 영구 삭제이고 되돌리기 표는 주지 않는다. 지운 연락처를 가리키는 그룹 구성원도 함께 뺀다(같은 uid 의 다른 카드가 남으면 그대로).
내 주소록에 없는 id(이미 지움 · 그룹 id)는 `notFoundIds` — 실패가 아니다. 엔진 한 요청에 담는 카드 수(지울 연락처 + 고칠 그룹)가 엔진 상한(`maxObjectsInSet` 500)을 넘으면 서버가 앞에서부터 나눠 지운다 —
중간에 503 이면 앞 몫은 지워진 채다(같은 목록을 다시 보내면 그 id 는 `notFoundIds`). 지울 카드는 id 만 읽는다(사진이 든 카드를 통째로 읽지 않는다).
- **요청 수 한도**는 지금 기본(경로 · IP 1분 100회) — 100명씩이면 3,000명도 30번이다. 사무실이 IP 하나로 나가면 그동안 다른 직원의 같은 경로 요청과 나눠 쓴다.
- **본문 한도**는 앱 공통(JSON 1MB · 값 10,000개). 보통 100명이 50KB 남짓이고, 칸이 많은 연락처(네이버 꼴 — 전화 20개 · 이메일 4개 · 그룹 4개)로 채워도 100명이 값 8,600개 남짓이다.
칸을 한도까지 채운 연락처가 몰려 400 `VAL_001`(본문 모양) · 413 `FILE_002` 가 나면 화면은 묶음을 줄여 보낸다(권장).
- **내보내기**(`GET /api/contact-exports`): 파일 응답(표준 래퍼 없음 — 실패는 표준 실패 JSON). 화면은 첨부 받기처럼 이동 직전에 세션을 확인하고 `<a href download>` 로 받는다.
- 내 주소록 전체 또는 그룹 하나(`groupId`), 이름 차례. 사내 목록은 넣지 않는다(회사 직원 명단을 파일로 밖에 내보내는 길을 만들지 않는다). 우리 칸만 — 엔진 카드의 다른 칸(주소 · 생일 · 사진 등)은 넣지 않는다.
- 머리글: `Content-Disposition: attachment` — 이름 `주소록-YYYY-MM-DD.vcf` · `주소록-<그룹 이름>-YYYY-MM-DD.csv`(날짜는 한국 날짜, 그룹 이름의 `\ / : * ? " < > |` 는 `_`, 앞뒤 공백 · 점은 떼고 50글자까지) ·
`Cache-Control: private, no-store` · `X-Content-Type-Options: nosniff` · `Content-Security-Policy: default-src 'none'; sandbox`.
- **vCard 3.0**(`text/vcard; charset=utf-8`): 줄 끝 CRLF · 한 줄 75바이트에서 접는다(UTF-8 글자를 가르지 않는다 — 이어지는 줄은 공백 하나로 시작) · 글 값의 `\` `;` `,` 줄바꿈은 이스케이프.
`UID`(카드에 있으면) · `FN`(이름 — 없으면 첫 이메일, 그것도 없으면 첫 전화번호 — vCard 3.0 은 FN 을 꼭 담는다) · `N`(이름 전체를 성 칸에 — `N:김아라;;;;`, 이름이 없으면 `N:;;;;` — 다시 가져올 때 주소를 이름으로 읽지 않게) · `EMAIL;TYPE=INTERNET,WORK`(회사) / `INTERNET,HOME`(개인) / `INTERNET`(기타) ·
`TEL;TYPE=CELL`(휴대전화) / `WORK` / `HOME` / `VOICE`(기타) · `ORG:회사;부서` · `TITLE` · `NOTE` · `CATEGORIES`(그룹 이름, 쉼표로). 한 파일에 여러 명이다.
- **CSV**(`text/csv; charset=utf-8`): UTF-8 BOM · CRLF · 머리 `이름,이메일,휴대폰,회사 전화,집 전화,기타 전화,회사,부서,직함,메모,그룹` — 이메일은 종류 없이 모두, 같은 칸에 여럿이면 `; ` 로 잇는다.
칸이 `= + - @` · 탭 · CR 로 시작하면 앞에 `'` 를 붙인다(엑셀 수식 막기 — OWASP CSV Injection, 다시 가져올 때 떼어 읽는다, 네이버 CSV 와 같은 꼴). 쉼표 · 따옴표 · 줄바꿈이 든 칸은 따옴표로 감싼다(RFC 4180 — 메모의 줄바꿈은 LF 그대로).
우리 가져오기가 이 머리 줄을 알아본다 — 머리 이름 · 차례를 바꾸면 frontend `shared/utils/contact-import` 도 함께.
- 없는 그룹 404 `RES_001`, 모르는 `format`(없음 포함) · 모르는 쿼리 400 `VAL_001`.
- **로그**: 개수 · 형식 · 그룹 id 만 — 연락처 내용 · 파일 내용 · uid 는 남기지 않는다.
- **알려진 한계**: 한 파일에 여러 명이 든 vCard 를 Outlook 클래식은 첫 사람만 읽는다. 그룹 이름에 `;` 가 있으면 CSV 의 그룹 칸이 둘로 읽힌다. 넣기의 일부를 엔진이 받지 않으면 그 사람의 uid 가 그룹 구성원에 남는다
(그룹 구성원 수는 주소록에 있는 연락처만 세므로 보이지 않는다 — 같은 uid 로 다시 넣으면 그 그룹에 들어간다).
- **이 단계에서 하지 않는 것**: 주소록 칸 늘리기(주소 · 생일 · 홈페이지 · 별명 · 사진 — 화면이 메모 끝 "가져온 정보" 줄로 붙인다), 겹친 연락처 합치기, 모르는 CSV 의 열을 손으로 맞추기, 옛 .xls,
서버에서 파일 읽기, 사내 목록 내보내기, 여러 연락처를 골라 지우는 화면(서버 길은 한꺼번에 지우기로 있다), 앱의 주소록.
---
## 4. 대용량 첨부 — 11단계 (2026-10-30 확정)
@@ -2650,7 +2805,7 @@ interface FileUploadCreate {
// GET /api/two-factor → 200
{ "isEnabled": true, "enabledAt": "2026-10-31T00:12:00.000Z" } // enabledAt — 웹메일에서 켠 때. 엔진 관리 화면에서 켰으면 null
// POST /api/two-factor/enrollments → 201 (꺼져 있을 때만, Cache-Control: no-store)
// POST /api/two-factor/enrollments → 201 (꺼져 있으면 켜기용 · 16단계부터 켜져 있으면 휴대폰 바꾸기용 — 아래 "휴대폰 바꾸기", Cache-Control: no-store)
{ "enrollmentId": "…", "secret": "JBSWY3DPEHPK3PXP…(32자)",
"otpauthUrl": "otpauth://totp/ARA%20SYSTEM:ara.kim%40arasystem.it.kr?secret=…&issuer=ARA%20SYSTEM&algorithm=SHA1&digits=6&period=30",
"expiresAt": "2026-10-31T00:22:00.000Z" }
@@ -2672,20 +2827,62 @@ interface FileUploadCreate {
3. 대리 접근으로 `currentSecret` + `otpAuth.otpUrl` 을 보낸다. 켠 코드는 2분 기억하고 등록의 비밀은 바로 지운다(`enabledAt` 기록이 실패해도 성공 — 엔진이 기준).
- 끄기: 비밀번호를 본인 Basic 으로 먼저 확인(402) → 다시 쓴 코드인지 확인 → 대리 접근으로 `currentSecret` + `otpAuth: { otpCode, otpUrl: null }`. 이미 꺼져 있으면 200 이다(같은 결과). 엔진에서 꺼진 뒤 `enabledAt` 기록 지우기가 실패해도 200 이다(error 로그 — 엔진이 기준).
- 켜고 끄는 일은 웹메일 세션을 끊지 않는다 — 로그인 기록 화면에 [다른 곳 모두 로그아웃]이 따로 있다. 이미 열린 IMAP 연결은 다시 연결할 때까지 남는다(알려진 한계).
- 같은 직원의 2단계 인증 바꾸기(등록 · 켜기 · 끄기 · 2단계를 켠 직원의 비밀번호 바꾸기)는 한 번에 하나다 — 처리 중이면 429 `REQ_001`.
- 휴대폰 바꾸기: 끈 뒤 다시 켠다(켜진 채 다시 등록하기는 처음 판에 없다).
- 같은 직원의 2단계 인증 바꾸기(등록 · 켜기 · 끄기 · 2단계를 켠 직원의 비밀번호 바꾸기 · 16단계 휴대폰 바꾸기의 확인 · 바꾸기)는 한 번에 하나다 — 처리 중이면 429 `REQ_001`.
- 휴대폰 바꾸기: 16단계부터 켠 채 바꾼다 — 아래 "휴대폰 바꾸기 (16단계)". 등록은 켜져 있어도 받는다(엔진에 묻지 않는다) — 켜진 채 받은 등록으로 켜기를 보내면 비밀번호 확인이 402 라 409 `AUTH_008` 이다.
- 휴대폰을 잃었으면 운영자가 엔진 관리 화면에서 그 직원의 OTP 를 지운다(직원 비밀번호 없이 된다 — 절차는 mail/README).
| 실패 | 코드 |
| --- | --- |
| 코드 틀림 · 다시 쓴 코드 | 400 `AUTH_006` |
| 지금 비밀번호 틀림 · 앱 비밀번호 모양 | 400 `AUTH_005` |
| 등록이 없음 · 10분 지남 · 더 새 등록으로 바뀜 · 그사이 다른 곳에서 켜짐(등록 · 켜기) | 409 `AUTH_008` — 화면은 처음부터 다시 |
| 등록이 없음 · 10분 지남 · 더 새 등록으로 바뀜 · 그사이 다른 곳에서 켜짐(켜기 — 16단계부터 등록은 켜져 있어도 201) | 409 `AUTH_008` — 화면은 처음부터 다시 |
| 형식 오류 | 400 `VAL_001` |
| 코드 · 비밀번호 실패 한도, 처리 중 | 429 `REQ_001` |
| 웹메일로 다루지 않는 계정 | 401 `AUTH_001` |
| 엔진 장애 · 결과를 모르는 실패 | 503 `MAIL_001` — 켜기 · 끄기의 결과를 모르면 엔진을 다시 읽어 맞춘다(바뀌었으면 성공으로) |
### 휴대폰 바꾸기 (16단계 — 2026-11-10)
2단계 인증을 **켠 채** 새 휴대폰으로 옮긴다 — 2단계가 꺼지는 틈이 없어 필수 정책인 직원도 바꾼다(화면은 [끄기]를 숨긴 직원에게도 [휴대폰 바꾸기]를 보인다). 화면은 디자인 원본 `Settings16`(창 하나 · 두 걸음).
```jsonc
// 0) 등록 — 켜져 있어도 받는다 (응답은 켜기와 같은 꼴, Cache-Control: no-store)
// POST /api/two-factor/enrollments → 201 { "enrollmentId": "…", "secret": "…", "otpauthUrl": "otpauth://…", "expiresAt": "…" }
// ① 새 휴대폰의 코드 — POST /api/two-factor/enrollments/{enrollmentId}/verification → 200
{ "otpCode": "123456" }
// → { "verified": true }
// ② 지금 휴대폰의 코드 + 지금 비밀번호 — POST /api/two-factor/replacements → 200 (GET /api/two-factor 와 같은 모양)
{ "enrollmentId": "…", "currentOtpCode": "654321", "currentPassword": "…" }
```
- **①** 새 휴대폰의 코드를 등록의 비밀로 확인하고(앞뒤 한 단계) 등록에 "확인됨"을 단다 — 엔진은 등록 때 코드를 보지 않으므로 서버가 먼저 본다. 엔진에는 아직 아무것도 바꾸지 않는다.
코드 확인은 켜기 · 끄기와 같은 코드 가드다(코드 실패 한도 · 받은 코드는 2분 동안 다시 받지 않는다). 확인은 등록에 남는다(10분) — [이전]으로 돌아와도 다시 확인하지 않아도 된다(같은 코드로 다시 확인하면 2분 안에는 `AUTH_006`).
- **②** 서버의 차례
1. 등록이 "확인됨"이 아니면 409 `AUTH_008` — 그 등록은 버린다(처음부터).
2. 앱 비밀번호 모양의 지금 비밀번호는 엔진에 묻지 않고 400 `AUTH_005`(세지 않는다). 지금 비밀번호를 본인 Basic 으로 본다 — 402(켜짐 · 비밀번호 맞음)여야 한다.
401 이면 400 `AUTH_005`(로그인 실패 한도로 센다), 200 이면 그사이 다른 곳에서 꺼진 것이라 409 `AUTH_008`(등록을 버린다 — 화면은 상태를 다시 받는다).
엔진은 비밀번호 틀림과 지금 코드 틀림을 가르지 않으므로(둘 다 `forbidden` "Current secret is incorrect.") 비밀번호를 먼저 본다.
3. 다시 쓴 코드인지 보고 코드 실패 한 번을 차지한다(끄기와 같은 결) → 대리 접근 `x:AccountPassword/set` 에 `currentSecret` + `otpAuth: { otpCode: <지금 코드>, otpUrl: <새 otpauth 주소> }` 한 요청
(2026-11-10 개발 엔진 — `updated`, 옛 비밀은 바로 죽고 거듭 바꿀 수 있다).
4. `forbidden` 이면 지금 코드 틀림 400 `AUTH_006`(코드 실패로 센다 — 등록은 남아 맞는 코드로 다시 보낸다). 결과를 모르면(시간 초과 · 연결 끊김 · 5xx) 503 `MAIL_001` —
엔진이 `otpUrl` 을 가려(`****`) 다시 읽어도 바뀌었는지 가를 수 없다. 그 코드는 쓰지 않은 것으로 두고 등록도 남긴다.
5. 바뀌면 등록의 비밀을 지운다(새 비밀은 엔진에만). 두 코드는 2분 동안 다시 받지 않는다. 켠 때(`enabledAt`)는 그대로 — 로그인된 기기는 끊지 않고 알림 메일도 보내지 않는다(켜기 · 끄기와 같다).
- 제한된 세션은 ① · ② 모두 403 `AUTH_010`(등록은 지금처럼 제한된 세션도 부른다). 같은 직원의 2단계 바꾸기와 함께 돌지 않는다(처리 중 429 `REQ_001`).
- 지금 휴대폰을 잃었으면 지금처럼 운영자의 「2단계 해제」 뒤 다시 켠다(필수 정책이면 다음 로그인의 켜기 단계 — 화면은 창 ②에 문의 줄).
- 로그 · 오류 context 에 코드 · 비밀번호 · otpauth 주소 · 엔진 설명 문장은 없다(가린 주소 · 사유만). 바꾸면 info "2단계 인증 휴대폰을 바꿨습니다: a***@…".
| 실패 | 코드 |
| --- | --- |
| 코드 · 등록 id 형식(경로의 id 가 UUID 가 아님 포함) | 400 `VAL_001` |
| ① 새 휴대폰의 코드 틀림 · ② 지금 휴대폰의 코드 틀림 · 2분 안에 다시 쓴 코드 | 400 `AUTH_006` |
| ② 지금 비밀번호 틀림 · 앱 비밀번호 모양 | 400 `AUTH_005` |
| 등록이 없음 · 10분 지남 · 더 새 등록 · ②에서 확인(①)하지 않은 등록 · 그사이 다른 곳에서 꺼짐 | 409 `AUTH_008` — 화면은 처음부터(새 등록) |
| 제한된 세션 | 403 `AUTH_010` |
| 코드 · 비밀번호 실패 한도, 처리 중 | 429 `REQ_001` |
| 엔진 장애 · ②의 결과를 모름 | 503 `MAIL_001` |
### 앱 비밀번호 (`/api/app-passwords`)
```jsonc
@@ -2859,10 +3056,11 @@ interface FileUploadCreate {
- 끊은 세션 `DELETE /api/auth/sessions/{id}` 의 모양이 틀린 id 는 404 `RES_001`(없는 것과 같다).
- 엔진 관리 화면에서 켠 2단계 인증은 `enabledAt` 이 없다.
- 복구 코드가 없다 — 휴대폰을 잃으면 운영자에게 문의한다.
- 휴대폰 바꾸기(16단계)의 ②가 503 `MAIL_001`(결과를 모름)로 끝난 뒤 같은 등록으로 다시 보내면, 그사이 바뀌었다면 옛 휴대폰의 코드가 죽어 `AUTH_006` 이다 — 새 휴대폰의 코드로 로그인되는지로 안다.
### 이 단계에서 하지 않는 것
복구 코드, 이 브라우저 기억, 문자 · 메일 코드, 보안 키(WebAuthn) · 패스키, 관리자가 2단계를 강제하기(13단계), 켜진 채 휴대폰 바꾸기,
복구 코드, 이 브라우저 기억, 문자 · 메일 코드, 보안 키(WebAuthn) · 패스키, 관리자가 2단계를 강제하기(13단계), 켜진 채 휴대폰 바꾸기(16단계에서 했다 — 위 "휴대폰 바꾸기"),
앱 비밀번호 이름 바꾸기 · 만료 · 허용 IP, 새 기기 로그인 알림 메일, 계정 변경 기록(비밀번호 바꾸기 · 2단계 켜기 등), 메일 프로그램의 OAuth 로그인, 모바일 앱.
### 정한 것 (2026-10-31 추천대로)
@@ -3091,3 +3289,83 @@ interface FileUploadCreate {
웹메일 관리자 화면(공통 기반 때), 비밀번호 스스로 재설정(복구 연락처 필요), 직원이 켜진 채 새 휴대폰으로 바꾸기(관리자의 「2단계 해제」로 한다),
공용 주소로 보내기 · 공유 메일함(Group), 새 기기 로그인 알림 메일, 관리자 열람 기능, 주소 바꾸기, 모바일 앱.
---
## 7. 업데이트 내역 (2026-11-10 정함 · 서버)
> 직원이 웹메일과 휴대폰 앱에서 무엇이 새로 생기거나 바뀌었는지 읽는 게시판이다. 업데이트할 때마다 글을 하나씩 더한다(목록에서 글을 골라 들어가 읽는다).
> 글은 운영자가 저장소의 `backend/release-notes/` 에 **글마다 파일 하나**로 쓴다 — 올리고 고치는 API 는 없다(파일을 더해 커밋 → 배포). 웹과 앱이 같은 글을 보고 거르지 않는다(어디 이야기인지는 묶음 머리가 말한다).
> 보드 `docs/design/webmail/ReleaseNotes.dc.html`(웹 A · 앱 B · 쓰는 법 C), 서버는 `modules/release-notes`. 새 에러 코드 · DB 표 · env 는 없다.
### 타입
```ts
interface ReleaseNoteSummary {
id: string // 글 파일 이름에서 .md 를 뗀 것 — "2026-11-18", 같은 날 두 번째 글부터 "2026-11-18-2" …
date: string // 달력 날짜 "YYYY-MM-DD"(파일 이름에서) — 시각이 아니다
title: string // 첫 줄의 # 제목 — 100자까지
}
interface ReleaseNoteSection {
heading: string | null // ## 머리 — 40자까지. 머리 없이 쓴 묶음은 null(글의 맨 앞에만 온다)
items: string[] // - 한 줄 — 500자까지, 하나 이상
}
interface ReleaseNote extends ReleaseNoteSummary {
sections: ReleaseNoteSection[] // 파일에 쓴 차례 — 하나 이상
previous: ReleaseNoteSummary | null // 바로 더 옛 글(목록에서 이 글 아래) — 없으면 null
next: ReleaseNoteSummary | null // 바로 더 새 글(목록에서 이 글 위) — 없으면 null
}
```
### 엔드포인트
| 메서드 · 경로 | 요청 | 성공 | 주요 실패 |
| --- | --- | --- | --- |
| `GET /api/release-notes` | `cursor` · `limit`(기본 20, 최대 100) | 200 `{ items: ReleaseNoteSummary[], pageInfo }` — 최근 글부터, `total` 은 글 수 | 400 `VAL_001`(틀린 커서 · limit 범위 밖 · 모르는 쿼리) |
| `GET /api/release-notes/{id}` | — | 200 `ReleaseNote` | 404 `RES_001` — 없는 글 · 모양이 틀린 id |
- 두 경로 모두 로그인 필요(웹 쿠키 · 앱 Bearer — `JwtAuthGuard`), 없으면 401 `AUTH_001`. 제한된 세션(6절)은 403 `AUTH_010`(허용 목록에 넣지 않는다 — 그 단계를 마치면 본다).
요청 수는 기본 한도(경로마다 IP 당 1분 100회 — 넘으면 429 `REQ_001`). 응답은 `Cache-Control: private, no-store`(글을 고친 배포가 바로 보이게).
- **차례**: 날짜 내림차순, 같은 날은 뒤 번호가 먼저(`2026-11-18-2` → `2026-11-18`). `previous` · `next` 는 이 차례의 아래 · 위다(목록 화면과 같은 차례).
- `cursor` 는 불투명 문자열(위치)이다. 차례 사이에 글을 더하면 다음 쪽에서 한두 개가 겹치거나 빠질 수 있다(3절과 같다).
- **id**: 서버는 모양(`YYYY-MM-DD` 또는 `YYYY-MM-DD-N` — N 은 2~99, 32자까지)만 보고 읽어 둔 목록에서 찾는다 — 파일 경로로 쓰지 않는다.
모양이 틀린 id 도 400 이 아니라 404 `RES_001` 이다(손으로 고친 주소도 "없는 글"). 파일 이름(날짜)을 고치면 글 주소도 바뀌어 옛 주소는 404 다.
- **글은 서버가 다듬은 그대로다** — 앞뒤 빈칸을 지우고, 안의 빈칸 · 탭 여럿은 하나로, 이어 쓴 줄은 띄어쓰기 하나로 붙이고, NFC 로 모은다.
마크다운(굵게 · 링크 꼴)은 해석하지 않고 쓴 글 그대로다 — 클라이언트도 글로만 그린다(v-html · 마크다운 해석 없음). 단추 · 메뉴 이름은 글에 `[설정] → [휴대폰 앱]` 처럼 쓴다.
- **날짜(`date`)는 달력 날짜 글이다** — 화면은 시간대를 바꾸지 않고 "2026년 11월 18일"로 쓴다. `new Date('2026-11-18')` 로 읽지 않는다(UTC 자정이라 음수 시간대에서 하루 앞이 된다).
### 글 파일 (`backend/release-notes/`)
- 글 하나 = 파일 하나. 이름 `YYYY-MM-DD.md`(글의 날짜 — 있는 날짜여야 한다: `2026-02-30` 은 틀림), 같은 날 둘째부터 `YYYY-MM-DD-2.md` … `-99`(`-1` 은 없다 — 첫 글은 번호 없이). 이름이 글 id 다.
- 폴더에는 `README.md`(쓰는 사람용 안내 — 서버는 읽지 않는다)와 글 파일만 둔다. 그 밖의 것(다른 이름 · 폴더 · 링크)은 글이 아니다.
- 파일 안 — 마크다운의 아주 작은 일부(글만). UTF-8, 256KB 까지:
| 줄 | 뜻 |
| --- | --- |
| `# 제목` | 첫 글 줄(앞의 빈 줄 · 메모는 된다) — 꼭 있어야 하고 한 번만. 100자까지 |
| `## 머리` | 묶음 시작 — 40자까지. 아래에 줄이 하나는 있어야 한다 |
| `- 한 줄` | 그 묶음의 한 줄 — 500자까지(이어 붙인 뒤). 머리 없이 바로 쓰면 머리 없는 묶음(`heading: null` — 제목 바로 아래에만) |
| 빈칸 두 개 이상(또는 탭)으로 시작한 줄 | 앞 `- ` 줄에 띄어쓰기 하나로 이어 붙는다(빈 줄 · 메모를 사이에 두어도). 들여 쓴 `- `(하위 목록)는 틀림 |
| `<!-- … -->` | 메모 — 화면에 나오지 않는다. 줄 앞에서 시작한다(앞 빈칸은 괜찮다 · 글 뒤에 붙이지 않는다). 여러 줄도 된다. 닫은 뒤 같은 줄에 글을 쓰지 않는다 |
| 빈 줄 | 어디든 |
그 밖의 줄(맨 글 · `###` · `* ` · `1. ` · 표 · 인용 · 한 칸 들여 쓴 줄 등), 글 안의 `<!--`, 보이지 않는 글자(제어 문자 · 줄 구분 문자)는 틀림. 글 하나에 줄이 하나는 있어야 한다.
글자 수는 앞뒤 빈칸을 지운 뒤의 JS 문자열 길이(UTF-16 — 웹의 `.length` 와 같다). 줄 끝의 CR(CRLF) · 앞의 BOM 은 지운다.
- **읽기**: 서버는 기동 때 한 번, 그 뒤로는 요청 때 폴더를 본다 — 파일 이름 · 크기 · 바뀐 시각이 앞과 같으면 앞서 읽은 결과를 쓴다(개발 서버는 다시 띄우지 않아도 새 글이 보인다).
운영은 이미지 안의 폴더(`/app/release-notes` — `backend/Dockerfile` 이 넣는다)라 배포해야 바뀐다. 함께 온 요청은 한 번 읽은 결과를 나눈다.
글 파일은 한 번 열어(링크를 따라가지 않는다) 그 열린 파일로 일반 파일인지 · 크기를 보고 256KB + 1 바이트까지만 읽는다.
- **틀린 글**: 시험(CI — `release-notes.content.spec.ts` 가 실제 폴더를 엄격하게 읽는다)이 실패한다. 그래도 배포되면 서버는 그 파일만 건너뛰고
바뀔 때마다 error 로그를 한 번 남긴다(파일 이름 · 줄 번호 · 까닭 — 줄 차례로 앞의 20개, 글 내용은 싣지 않는다). 기동 때 읽으므로 배포 직후의 로그에 보인다.
기동은 막지 않는다(기동 때 읽지 못해도 — 요청 때 다시 읽는다). 폴더가 없으면 빈 목록 + warn 한 번.
### 클라이언트
- **웹**: 설정 메뉴의 셋째 묶음 "정보" › "업데이트 내역" — 목록 `/settings/info/release-notes`, 글 `/settings/info/release-notes/{id}`(글을 보는 동안에도 메뉴 항목이 고른 상태). 404 `RES_001` 은 알림 없이 "없는 글" 화면.
- **앱**: 설정 탭 › 앱 정보 › "업데이트 내역" — 목록 `/settings/release-notes`, 글 `/settings/release-notes/{id}`(둘 다 탭 막대 없는 전체 화면). 앱은 옛 판이어도 서버의 새 글을 본다(새 APK 를 내지 않아도 된다).
- 화면 · 상태(불러오는 중 · 받지 못함 · 글 없음 · 없는 글)는 디자인 README(웹 · 앱)의 "업데이트 내역" 절.
### 이 단계에서 하지 않는 것
올리고 고치는 API · 화면, 새 글 표시(점 · 개수 · NEW — 뱃지를 쓰지 않는다), 웹 · 앱 거르기, 글 안의 링크 · 그림 · 굵게, 검색, 번호 · 작성자 · 조회 수.
+14 -12
View File
@@ -3,14 +3,14 @@
그룹웨어 전체의 작업 차례다. 단계 번호는 모듈마다 따로 센다 — 메일의 "N단계"는 API 계약([`docs/mail/api-contract.md`](./mail/api-contract.md))과
화면 디자인([`docs/design/webmail/README.md`](./design/webmail/README.md))의 "N단계"와 같다. 단계마다 **조사 → 캔버스(화면 보드) → 확정 → 구현 → 배포** 차례로 한다.
마지막 정리: 2026-11-09
마지막 정리: 2026-11-12
## 지금
- **운영**: 메일 1~13단계. 2026-11-05 옛 메일 서버(mailcow)에서 전 직원이 옮겨 왔다(메일만 옮겼다 — 주소록 · 달력 · 필터 · 부재중 · 서명은 직원이 새로).
- **배포를 기다림**: 메일 14단계 · 앱 서버(앱 로그인 · 새 메일 알림) · 휴대폰 앱 받기 — main 에 합쳤고, 전환 뒤 안정화 1~2주를 지나 11월 셋째 주에 운영에 올린다.
- **개발 중**: 메일 15단계(메일 · 메일함 다루기).
- **다음**: 메일 16단계(개인 설정).
- **운영**: 메일 1~16단계 · 휴대폰 앱 첫 판(안드로이드 1.0.0) · 업데이트 내역. 2026-11-05 옛 메일 서버(mailcow)에서 전 직원이 옮겨 왔고(메일만 옮겼다 — 주소록 · 달력 · 필터 · 부재중 · 서명은 직원이 새로),
2026-11-12 0시에 14 · 15 · 16단계 · 앱 서버(앱 로그인 · 새 메일 알림 · 휴대폰 앱 받기) · 업데이트 내역을 운영에 올리고 휴대폰 앱을 나눠 주기 시작했다.
- **고치는 중**: 배포 뒤 받은 의견 — 앱 읽기 화면(본문 없이 첨부만 있는 메일) · 앱 쓰기 화면 입력칸. 앱 다음 판(1.0.1)으로 나간다.
- **다음**: 아래 "추후 후보" 가운데 직원 요청이 실제로 있는 것을 고른다. 그다음은 공통 기반(조직 · 권한).
## 1부 — 메일
@@ -29,11 +29,11 @@
| 11 | 대용량 첨부 — 링크로 보내기 · 조각 올리기 · 30일 보관 · 로그인 없는 내려받기 화면 · 악성 검사 | 운영 |
| 12 | 계정 보안 — 2단계 인증 · 앱 비밀번호 · 로그인 기록 · 로그인된 기기 | 운영 |
| 13 | 전환 준비 — 새 비밀번호 · 2단계 필수 · 메일 프로그램 연결 안내 · 운영 도구 · 백업 · 감시 · 휴지통 · 스팸 30일 | 운영 |
| 14 | 자체 검색 색인 · 조건 검색 · 검색어 강조 · 같은 대화 · 대화로 묶어 보기 | main 에 합침 — 배포 기다림 |
| 15 | 메일 · 메일함 다루기 — 읽기 화면 [이동] · 끌어다 놓기 · 메일함 옮기기 · 하위까지 지우기 · 차단할 때 받은편지함 정리 · 예약 메일 [지금 보내기] · 같은 대화 [모두 펼치기] | 개발 중 |
| 16 | 개인 설정 — **주소록 가져오기 · 내보내기**(vCard · CSV) · 여러 서명(사내 · 대외 · 영문) · 부재중 응답의 서식 본문과 사내/외부 구분 · 2단계 인증 휴대폰 직접 바꾸기 | 다음 |
| 14 | 자체 검색 색인 · 조건 검색 · 검색어 강조 · 같은 대화 · 대화로 묶어 보기 | 운영(2026-11-12) |
| 15 | 메일 · 메일함 다루기 — 읽기 화면 [이동] · 끌어다 놓기 · 메일함 옮기기 · 하위까지 지우기 · 차단할 때 받은편지함 정리 · 예약 메일 [지금 보내기] · 같은 대화 [모두 펼치기] | 운영(2026-11-12) |
| 16 | 개인 설정 — **주소록 가져오기 · 내보내기**(vCard · CSV · 엑셀) · 여러 서명(사내 · 대외 · 영문) · 부재중 응답의 서식 본문과 사내/외부 구분 · 2단계 인증 휴대폰 직접 바꾸기 | 운영(2026-11-12) |
- 15단계부터는 직원이 쓰며 바라는 것을 먼저 본다 — 16단계가 끝날 즈음 아래 "추후 후보" 가운데 요청이 실제로 있는 것을 골라 다음 단계로 넣는다.
- 15단계부터는 직원이 쓰며 바라는 것을 먼저 본다 — 아래 "추후 후보" 가운데 요청이 실제로 있는 것을 골라 다음 단계로 넣는다.
- 2026-11-09 계획에서 뺀 것: 수신 확인 · 사내 발송 취소(사내 받는 사람에게만 되고, 흔한 실수는 보내기 취소가 막는다 — 아래 후보로).
## 공통 기반 — 조직 · 권한
@@ -48,8 +48,8 @@
| 판 | 내용 | 상태 |
| --- | --- | --- |
| 앱 1단계 | 로그인(코드 · 새 비밀번호 · 2단계 켜기) · 메일(목록 · 읽기 · 쓰기 · 첨부 · 검색 · 이동 · 삭제) · 새 메일 알림(안드로이드) | main 에 합침 — 서버 배포 뒤 직원 배포 |
| 사내 배포 | 스토어 없이 우리 키로 서명한 APK 를 포털의 휴대폰 앱 받기 화면에서 | main 에 합침 — 서명 키 준비됨 |
| 앱 1단계 | 로그인(코드 · 새 비밀번호 · 2단계 켜기) · 메일(목록 · 읽기 · 쓰기 · 첨부 · 검색 · 이동 · 삭제) · 새 메일 알림(안드로이드) | 운영 — 1.0.0(2026-11-12) |
| 사내 배포 | 스토어 없이 우리 키로 서명한 APK 를 포털의 휴대폰 앱 받기 화면에서 | 운영 — 1.0.0 을 올림(2026-11-12) |
| 다음 판 후보 | 주소록 · 대용량 첨부 · 필터 · 서식 편집 · 예약 메일 [지금 보내기] | 첫 판 반응을 보고 고른다 |
## 추후 후보 (필요해지면)
@@ -60,9 +60,11 @@
7일 넘는 예약(메일 엔진 한도라 어렵다).
- **검색**(14단계에서 미룸): 강조한 곳 사이 다음 · 이전 이동, 검색어 줄임말(`from:` 꼴), 첨부 파일 안의 글 찾기, 끝이 바뀐 말 찾기(형태소 분석).
- **메일함 정리**(15단계에서 미룸): 메일함을 끌어 옮기기 · 순서 바꾸기, 옮기기 단축키, 예약됨 목록 줄의 [지금 보내기], 차단할 때 옮긴 메일 한꺼번에 되돌리기 · 받은편지함 밖 메일함 정리.
- **개인 설정**(16단계에서 미룸): 주소록 칸 늘리기(주소 · 생일 · 홈페이지 · 별명 · 사진), 겹친 연락처 합치기, 모르는 CSV 의 열을 손으로 맞추기, 옛 엑셀(.xls), 여러 연락처 고르기 · 한꺼번에 지우기,
받는 사람에 따라 서명 저절로 바꾸기, 앱에서 서명 고르기, 부재중 응답을 내 연락처에게만 · 그림 넣기.
- **대용량 첨부**(11단계에서 미룸 — 파일 저장소 때 함께): 보관 기간 고르기 · 연장, 다시 보내기, 받는 사람 확인 코드 · 비밀번호, 사내 직원만 받기, 받는 사람별 기록,
처음 받음 · 만료 전 알림, 미리 보기, 모두 받기(zip), 폴더 올리기, 링크 → 메일에 담기 되돌리기.
- **계정 · 보안**(12 · 13단계에서 미룸 — 공통 기반 때 함께): 복구 코드, 이 브라우저 기억, 패스키, 새 기기 로그인 알림 메일, 계정 변경 기록, 로그인 기록의 나라 · 지역,
- **계정 · 보안**(12 · 13 · 16단계에서 미룸 — 공통 기반 때 함께): 복구 코드, 이 브라우저 기억, 패스키, 새 기기 로그인 알림 메일, 2단계 휴대폰을 바꿀 때 알림 메일, 계정 변경 기록, 로그인 기록의 나라 · 지역,
앱 비밀번호 이름 바꾸기 · 만료, 비밀번호 스스로 재설정, 공용 주소로 보내기 · 공유 메일함, 웹메일 관리자 화면.
- **앱**: 앱 안에서 받아 바로 설치, 최소 판이 아닌 새 판 알림, 아이폰, Google 개발자 인증(2027년 전 세계 — 그 전에 등록할지 정한다).
+3 -2
View File
@@ -137,8 +137,9 @@ flutter build apk --release
Copy-Item build\app\outputs\flutter-apk\app-release.apk "$env:USERPROFILE\Desktop\ARA-SYSTEM-1.0.1.apk" # 이름은 ARA-SYSTEM-<버전>.apk
```
- 나오는 파일은 `build/app/outputs/flutter-apk/app-release.apk` 하나다(모든 ABI — arm64-v8a · armeabi-v7a · x86_64). 저장소 밖으로 옮기며 이름을 `ARA-SYSTEM-<버전>.apk` 로 바꾼다(받기 화면이 주는 파일 이름과 같은 꼴).
- 올리기 전에 본다:
- 나오는 파일은 `build/app/outputs/flutter-apk/app-release.apk` 하나다(모든 ABI — arm64-v8a · armeabi-v7a · x86_64 — 그래서 1.0.0 이 64.4MB 다). 저장소 밖으로 옮기며 이름을 `ARA-SYSTEM-<버전>.apk` 로 바꾼다(받기 화면이 주는 파일 이름과 같은 꼴).
- 올리기 전에 본다 — `apksigner` 는 Java 로 돈다. PATH 에 `java` 가 없으면 결과 없이 끝나니(2026-11-11 개발 PC) 먼저 Android Studio 의 Java 를 잡는다:
`$env:JAVA_HOME = "C:\Program Files\Android\Android Studio\jbr"; $env:Path = "$env:JAVA_HOME\bin;$env:Path"`
- 우리 키로 서명했는지 — `& "$env:LOCALAPPDATA\Android\Sdk\build-tools\36.1.0\apksigner.bat" verify --print-certs <파일>` 의 `certificate DN` 이 keytool 에 넣은 이름이다(`CN=Android Debug` 면 debug 키다).
- 파일 안의 판이 파일 이름과 같은지 — `& "$env:LOCALAPPDATA\Android\Sdk\build-tools\36.1.0\aapt2.exe" dump badging <파일> | Select-String "^package:"` 의
`versionName` 이 파일 이름의 버전, `versionCode` 가 `pubspec.yaml` 의 `+` 뒤 숫자다(예 `versionCode='2' versionName='1.0.1'`).
+16 -2
View File
@@ -171,10 +171,10 @@ abstract final class AppSize {
/// 쓰기 화면의 첨부 줄 파일 아이콘 칸
static const double composeFileIconBox = 36;
/// 본문 칸의 최소 높이 — 새 메일(원본 메일이 아래에 없다)
/// 본문 칸의 최소 높이 — 원본 메일이 아래에 없는 쓰기(새 메일 등 — 서명은 있어도)
static const double composeBodyMinHeight = 240;
/// 본문 칸의 최소 높이 — 답장 · 전달(아래에 서명 · 원본 메일)
/// 본문 칸의 최소 높이 — 답장 · 전달(아래에 원본 메일)
static const double composeBodyMinHeightWithQuote = 150;
/// 원본 메일 접힌 줄의 최소 높이
@@ -229,4 +229,18 @@ abstract final class AppSize {
/// 둥근 진행 표시(켜는 중 · 끄는 중)의 지름과 고리 두께
static const double progressRing = 22;
static const double progressRingStroke = 2.6;
// ── 앱 보드에서 정한 크기 (업데이트 내역 `ReleaseNotes` B — 2026-11-10 확정) ──
// 목록 줄은 설정의 두 줄 칸([settingsRowMinHeight] 68)과 같다
/// 글 화면 끝의 다른 글 줄(다음 글 · 이전 글)의 최소 높이
static const double noteLinkRowMinHeight = 64;
/// 글의 점 목록 — 점의 지름(점 칸 폭은 `AppSpacing.s16`)
static const double bulletDot = 5;
/// 목록을 불러오는 동안 줄의 제목 막대 높이(날짜 막대는 `AppSpacing.s10`)
static const double skeletonTitleBarHeight = 13;
/// 목록을 불러오는 동안 줄의 날짜 막대 폭
static const double skeletonDateBarWidth = 104;
}
+28
View File
@@ -231,6 +231,34 @@ abstract final class AppTheme {
);
}
/// 테두리 · 바탕 · 안쪽 여백이 없는 입력칸 꾸밈 — 칸을 담은 줄이 아래 선 · 초점 표시를 따로 그리는 칸
/// (쓰기 화면의 받는 사람 · 제목 · 본문 — `ComposeFieldRow`, 메일 검색 막대).
///
/// `InputDecoration.collapsed` 는 쓰지 않는다 — collapsed 는 `border` 만 없애고 `enabledBorder` · `focusedBorder` 등을 비워 두는데,
/// TextField 가 그 빈 칸을 테마의 입력칸 테두리(위 `inputDecorationTheme` — 둥근 테두리 · 초점은 청록 2)로 채우고, InputDecorator 는
/// `border` 보다 이 칸들을 먼저 쓴다. 그래서 여섯 테두리를 모두 [InputBorder.none] 으로 적는다
/// (2026-11-12 — 쓰기 화면의 칸마다 글자를 바짝 감싼 둥근 테두리가 그려지고, 초점이 오면 칸 줄의 아래 선과 청록 테두리가 겹쳤다).
/// 여백 · 높이는 collapsed 와 같다(안쪽 여백 0 · `isCollapsed`). [hint] 와 [hintText] 는 하나만 준다
InputDecoration borderlessInputDecoration({
String? hintText,
TextStyle? hintStyle,
Widget? hint,
}) => InputDecoration(
hintText: hintText,
hintStyle: hintStyle,
hint: hint,
isCollapsed: true,
isDense: false,
contentPadding: EdgeInsets.zero,
filled: false,
border: InputBorder.none,
enabledBorder: InputBorder.none,
focusedBorder: InputBorder.none,
disabledBorder: InputBorder.none,
errorBorder: InputBorder.none,
focusedErrorBorder: InputBorder.none,
);
/// Material 색 역할에 없는 웹 토큰 — `Theme.of(context).extension<AppExtraColors>()!` 로 쓴다
@immutable
class AppExtraColors extends ThemeExtension<AppExtraColors> {
+53
View File
@@ -0,0 +1,53 @@
import 'package:flutter/foundation.dart';
/// 달력 날짜 — 시각이 아닌 "그날"(서버의 `YYYY-MM-DD` 글, 예: 업데이트 내역 글의 쓴 날 — 계약 7절).
///
/// 시간대를 바꾸지 않는다 — 글을 직접 나눠 연 · 월 · 일 숫자로만 쥔다. `DateTime.parse('2026-11-18')` 은 기기 시간대의 자정이고
/// 웹의 `new Date('2026-11-18')` 은 UTC 자정이라(음수 시간대에서 하루 앞) 어느 쪽이든 "시각"으로 바꾸면 그날이 흔들릴 수 있다.
@immutable
class CalendarDate {
const CalendarDate(this.year, this.month, this.day);
final int year;
final int month;
final int day;
/// `YYYY-MM-DD` 글 → 날짜. 모양이 다르거나 없는 날(2026-02-30 · 2026-13-01)이면 null
static CalendarDate? tryParse(Object? value) {
if (value is! String) return null;
final RegExpMatch? match = _pattern.firstMatch(value);
if (match == null) return null;
final int year = int.parse(match.group(1)!);
final int month = int.parse(match.group(2)!);
final int day = int.parse(match.group(3)!);
if (month < 1 || month > _monthsInYear) return null;
if (day < 1 || day > _daysInMonth(year, month)) return null;
return CalendarDate(year, month, day);
}
/// 숫자 칸 셋 — 연 넷 · 월 둘 · 일 둘(ASCII 숫자만)
static final RegExp _pattern = RegExp(r'^([0-9]{4})-([0-9]{2})-([0-9]{2})$');
static const int _monthsInYear = 12;
/// 그달의 날 수 — 다음 달 0일(= 이달 마지막 날). UTC 로 만들어 시간대 · 서머타임과 상관없이 날만 센다
static int _daysInMonth(int year, int month) =>
DateTime.utc(year, month + 1, 0).day;
@override
bool operator ==(Object other) =>
other is CalendarDate &&
other.year == year &&
other.month == month &&
other.day == day;
@override
int get hashCode => Object.hash(year, month, day);
/// `YYYY-MM-DD` — 서버가 준 글과 같은 모양
@override
String toString() =>
'${year.toString().padLeft(4, '0')}-'
'${month.toString().padLeft(2, '0')}-'
'${day.toString().padLeft(2, '0')}';
}
@@ -2,8 +2,11 @@
///
/// `intl` 의 날짜 형식은 로캘 자료를 먼저 읽어야 해서(앱은 MaterialApp 이 읽는다) 순수 함수 시험에서 쓰기 번거롭다 —
/// 웹 `Intl.DateTimeFormat('ko-KR')` 이 내는 글 그대로를 여기서 만든다. 시각은 **기기 시간대**로 바꾼 값(`toLocal()`)을 받는다.
/// 달력 날짜([CalendarDate] — 시각이 아닌 "그날")는 시간대를 바꾸지 않고 그대로 쓴다.
library;
import 'package:arasystem_app/core/utils/calendar_date.dart';
/// 개수 — 1234 → "1,234" (웹 `formatCount` 와 같다)
String formatCount(int value) {
final String digits = value.abs().toString();
@@ -49,6 +52,11 @@ String koreanScheduleTime(DateTime local) =>
String koreanFullDateTime(DateTime local) =>
'${local.year}년 ${koreanScheduleTime(local)}';
/// 달력 날짜 — "2026년 11월 18일" (웹 `{ year: 'numeric', month: 'long', day: 'numeric' }` — 업데이트 내역의 쓴 날).
/// 시각이 아니라 시간대를 바꾸지 않는다
String koreanCalendarDate(CalendarDate date) =>
'${date.year}년 ${date.month}월 ${date.day}일';
/// 같은 달력 날인가 (둘 다 같은 시간대로 바꾼 값)
bool isSameCalendarDay(DateTime a, DateTime b) =>
a.year == b.year && a.month == b.month && a.day == b.day;

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