Compare commits

..

52 Commits

Author SHA1 Message Date
ttipo be473078cc docs(rules): CI 러너 이름 · CPU 시간 한도 규칙
CI (Flutter) / Flutter (analyze · test) (pull_request) Successful in 19m55s
CI / Backend (lint · type-check · test · e2e · build) (pull_request) Successful in 11m6s
CI / Frontend (lint · type-check · test · build) (pull_request) Successful in 11m0s
CI / Infra (compose · Dockerfile · nginx) (pull_request) Successful in 14s
CI / 에러 코드 SSOT 동기화 검사 (pull_request) Successful in 10m7s
CI (Flutter) / Flutter (analyze · test) (push) Has been cancelled
CI / Backend (lint · type-check · test · e2e · build) (push) Has been cancelled
CI / Frontend (lint · type-check · test · build) (push) Has been cancelled
CI / Infra (compose · Dockerfile · nginx) (push) Has been cancelled
CI / 에러 코드 SSOT 동기화 검사 (push) Has been cancelled
docker 스킬에 러너의 compose 프로젝트 이름(RUNNER_NAME)과 이미지 판을, testing 규칙에
CPU 시간 한도는 지금 값의 10배쯤 둔다는 것을 적는다(CPU 시간도 부하에서 는다).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 22:28:12 +09:00
ttipo 52c6ebecb6 test(backend): CPU 시간 한도를 부하에 견디게 — CI 에서 평소의 4~7배
첫 CI(같은 PC 의 러너)에서 백엔드 단위 테스트의 CPU 시간 단언 두 곳이 넘쳤다 — CI 와 같은
Linux 컨테이너에서 메모리가 모자라고 테스트가 함께 돌 때 평소의 4~7배로 재졌다
(이미지 많은 정화 1.6초 → 5.75초 · 한도 5초, 한도 가까운 본문의 DTO 검사 0.08초 → 0.59초 · 한도 0.5초).

한도를 평소의 10배쯤으로 다시 잡는다 — 막으려는 옛 경로(제곱)는 여전히 확실히 넘는다.
여유가 6.7배뿐이던 HTML 비용 사전 검사도 함께 늘린다(0.3초 · 옛 경로 172초).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 22:28:12 +09:00
ttipo ec1919e4bf fix(docker): CI 러너 — compose 프로젝트 이름을 러너 이름으로 · 이미지 판 고정
프로젝트 이름이 비어 있으면 폴더 이름 ci 가 되어, 같은 템플릿을 쓰는 다른 프로젝트의 러너와
프로젝트 · 서비스(ci / runner)가 겹친다. 그러면 up 이 그쪽 컨테이너를 지우고 이 러너로 다시 만든다
(같은 PC 의 다른 프로젝트 러너가 project=ci 였다 — 띄우기 전에 찾았다). 이름을 RUNNER_NAME 으로 둔다.

러너 이미지는 latest 대신 같은 digest 인 0.6.1 로 고정한다(이미지 버전 고정 규칙).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 22:28:12 +09:00
ttipo a39f8bd630 docs(rules): 6단계 메일 필터 부품 · JSON 본문 모양 한도 · Sieve 규칙
CI (Flutter) / Flutter (analyze · test) (pull_request) Successful in 24m1s
CI / Backend (lint · type-check · test · e2e · build) (pull_request) Failing after 7m45s
CI / Frontend (lint · type-check · test · build) (pull_request) Successful in 11m39s
CI / Infra (compose · Dockerfile · nginx) (pull_request) Successful in 23s
CI / 에러 코드 SSOT 동기화 검사 (pull_request) Successful in 10m20s
nestjs · vue3 스킬에 6단계 파일과 규칙을 적는다(Sieve 글 만들기 · 스팸 판정 · 원본 판 ·
되돌리기 차례 · 메뉴의 꺼진 항목). security 에는 JSON 본문 모양 한도와 ArrayUnique 금지를 적는다.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 21:00:01 +09:00
ttipo 894550732e feat(frontend): 설정 › 메일 › 필터 · 읽기 화면에서 필터 만들기
확정 보드(SettingsFilter · MailFilterStates)대로 필터 목록 · 켜기 · 순서 · 지우기(되돌리기) ·
고치기 창을 만들고, 읽기 화면 ⋯ 에 「이런 메일로 필터 만들기」를 둔다.

- 저장은 한 번에 하나다. 되돌리기는 누른 차례대로, 앞 저장이 끝난 뒤에 보낸다 —
  연달아 누르면 하나가 사라지던 경합을 리뷰에서 찾았다
- 다른 곳에서 먼저 바꿨으면 창 안에서도 다시 불러올 수 있고, 쓴 내용은 지킨다
- 메뉴의 꺼진 항목은 aria-disabled 로 둔다(편집기 메뉴 공용) — 키보드로 가서 까닭을 들을 수 있다

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 20:59:53 +09:00
ttipo 7ce234750d fix(backend): JSON 본문 모양 한도 · UTF-8 만 — 본문 검증 DoS
본문 크기 한도 안에서도 작은 객체 수만 개면 DTO 변환 · 검사가 서버를 수 초 멈추고
수백 MB 를 썼다(리뷰 측정: 1MB 필터 본문 7.8초 · +329MB, 깊은 중첩은 500 SYS_001,
4MB 발송 본문은 한 건으로 메모리 초과 추정). DTO 의 개수 한도로는 막지 못한다 —
변환은 항목마다 객체를 만들고, 검사는 한도에 걸려도 항목마다 중첩 검사를 한다.

- 모든 JSON 본문을 DTO 보다 앞에서 잰다(배열 1,000 · 값 10,000 · 깊이 32).
  읽기 전에 UTF-8 바이트를 한 번 훑고, 읽은 뒤에 한 번 더 본다. 넘으면 400 VAL_001
- JSON 은 UTF-8 만 받는다. 다른 utf-* 본문은 바이트로 셀 수 없다
- 전역 파이프에 stopAtFirstError 를 켠다. 항목 수의 제곱이던 ArrayUnique 대신
  Set 으로 보는 ArrayDistinct 를 쓴다

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 20:59:42 +09:00
ttipo b4d30482fe feat(backend): 메일 필터 API · 부재중을 Sieve 스크립트로
GET·PUT /api/mail-filters 를 더한다. 4단계 부재중 자동 응답의 저장 자리는 엔진의
VacationResponse 에서 우리 Sieve 스크립트(arasystem)로 옮긴다 — API 는 그대로다.

- 엔진은 계정마다 켜진 스크립트가 하나라 필터와 부재중을 한 스크립트에 둔다.
  읽을 때는 머리 주석의 원본(JSON)만 믿고, 쓸 때는 읽은 상태 그대로일 때만 쓴다(ifInState)
- 다른 곳에서 먼저 바꿨으면 409 MAIL_012, 만든 글이 90,000바이트를 넘으면 400 MAIL_013
- 스팸은 엔진의 분류 표시(X-Spam-Score)를 보고 건너뛴다. 처음에는 spamtest(정수로 내린
  점수)로 막았는데, 실엔진에서 9.7점 스팸에 규칙과 부재중 응답이 나가는 것을 찾았다
- 엔진의 오류 설명은 로그에 줄 · 칸만 남긴다. 새 판 원본은 덮어쓰지 않는다(배포를 되돌릴 때)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 20:59:31 +09:00
ttipo 41b5fdbe22 docs: 6단계(메일 필터) 계약과 디자인 · JSON 본문 모양 한도
메일 필터(GET·PUT /api/mail-filters)와, 부재중 자동 응답의 저장 자리를 엔진의 Sieve
스크립트 하나로 옮기는 계약을 적는다. 확정한 캔버스 보드(SettingsFilter · MailFilterStates)와
구현하며 정한 보완도 함께 둔다.

- 엔진(Stalwart 0.16.27)은 계정마다 켜진 스크립트가 하나뿐이다. 엔진의 부재중도 스크립트라
  따로 두면 하나를 켤 때 다른 하나가 꺼진다. 그래서 한 스크립트에 함께 둔다
- 스팸 막기는 엔진의 분류 표시(X-Spam-Score)로 한다. spamtest 는 점수를 정수로 내린 값이라
  10 으로 막으면 5~10점 스팸을 놓친다(실엔진에서 확인)
- 모든 JSON 본문에 모양 한도(배열 1,000 · 값 10,000 · 깊이 32)를 걸고 UTF-8 만 받는다.
  DTO 의 개수 한도로는 본문 검증 DoS 를 막지 못했다(리뷰 측정)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 20:58:51 +09:00
ttipo b6aa73c611 docs(rules): 받는 사람별 발송 결과 · 엔진 한도 규칙
- nestjs: jmap/delivery-status.ts, 조건 없는 destroy 대상에 "받는 사람이 모두 거절한 발송의 보낸 사본"
- vue3: 오류 알림의 일부 거절 쓰임, 부재중 길이는 켤 때만
- docker: mail setup 의 받는 쪽 한도

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 17:11:27 +09:00
ttipo 4d95fc28d8 fix(docker): mail setup — 받는 사람 단위 받는 쪽 한도를 인증 없는 세션에만
- 엔진 기본 "Sender address to recipient throttle"(같은 보낸 도메인 → 한 받는 사람, 시간당 25)의 match 를 is_empty(authenticated_as) 로 —
  사내 메일이 동료 모두 합쳐 한 사람에게 25통을 넘으면 웹메일 발송이 452 로 거절되던 문제. 바깥에서 들어오는 메일에는 그대로
- "Sender IP throttle"(초당 연결 5개)은 그대로 — 연결 단계 한도라 웹메일 발송에는 걸리지 않고, 조건을 붙이면 MAIL FROM 단계로 옮겨진다
- mail/README.md "엔진 한도" — 기본값 · 바꾸는 까닭 · 확인 결과 · 운영 전에 정할 것

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 17:11:26 +09:00
ttipo 3634cee955 fix(frontend): 받는 사람별 발송 결과 알림 · 보내는 중 세션 바뀜 막기 · 부재중 한도
- 일부 거절: 보낸 것으로 떠나며 "보냈지만 N명은 받지 못했습니다 — 주소 (까닭)" 오류 모양 알림 + [보낸편지함 보기]
- MAIL_010: 받는 사람 안내 띠 · 초점(알림 없음, 칸은 aria-invalid · aria-describedby), MAIL_011: 인터셉터 알림 — 둘 다 같은 키로 다시 보낸다
- 보내기를 시작한 세션이 바뀌면(로그아웃 · 만료 · 다른 계정 로그인) 보내지 않고, 결과 알림 · 보내지 못한 메일의 저장본 남기기도 하지 않는다
  — 다른 계정의 쿠키로 앞 사용자의 메일이 나가거나 저장되던 틈
- 응답의 rejectedRecipients 는 sendMessage 한 곳에서 배열로 맞춘다(필드가 없는 옛 서버)
- 부재중 자동 응답 제목 150 · 내용 600 (켤 때 검사)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 17:11:26 +09:00
ttipo 19c20eb617 fix(backend): 발송의 받는 사람별 결과 · 부재중 길이 한도
- 제출 다음에 EmailSubmission/get 으로 받는 사람별 결과를 읽는다(jmap/delivery-status.ts) — 일부 거절은 201 { id, rejectedRecipients },
  모두 거절이면 이 요청이 만든 보낸 사본을 지우고 400 MAIL_010(5xx 하나라도) · 503 MAIL_011(모두 4xx).
  이어 쓴 저장본은 지우지 않고 같은 키 잠금도 붙잡지 않는다 — 같은 키로 다시 보내면 새로 보낸다
- 같은 키 재시도는 처음 메일의 제출 기록으로 결과를 다시 만든다. 결과 읽기는 발송 55초 안에서 최대 5초, 읽지 못하면 받은 것으로 본다(warn)
- 로그는 가린 주소와 SMTP 상태 코드만
- 부재중 자동 응답: 켤 때 제목 150 · 내용 600, 꺼 둔 채로는 200 · 4,000
- 에러 코드 MAIL_010 · MAIL_011 과 생성 사전(frontend · flutter)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 17:11:25 +09:00
ttipo ac418e3aa9 docs: 발송의 받는 사람별 결과 · 부재중 길이 한도 (6단계 앞 수정)
- 계약 2절 "받는 사람별 결과" — 엔진이 제출 때 거절한 받는 사람(deliveryStatus)을 201 의 rejectedRecipients 로 알리고,
  모두 거절이면 보내지 않은 것으로 다룬다(보낸 사본을 지우고 MAIL_010 · MAIL_011). 같은 키 재시도 · 분류 규칙 · 알려진 한계 · 로그
- 부재중 자동 응답 — 켤 때 제목 150 · 내용 600. 엔진이 UTF-8 511 · 2,047바이트로 막아 4단계의 200 · 4,000 은 한글로 저장되지 않았다.
  꺼 둔 채로는 4단계 한도까지 받는다(켜진 옛 긴 글도 끌 수 있게)
- 엔진의 받는 쪽 한도는 로그인한 직원의 발송에 걸지 않는다(mail setup)
- 디자인 README — 일부 거절 알림 · MAIL_010 안내 띠 · 부재중 길이

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 17:11:13 +09:00
ttipo 181d2a4cbf docs(rules): 5단계 실시간 알림 부품 · SSE 운영 규칙
CI (Flutter) / Flutter (analyze · test) (pull_request) Has been cancelled
CI / Backend (lint · type-check · test · e2e · build) (pull_request) Has been cancelled
CI / Frontend (lint · type-check · test · build) (pull_request) Has been cancelled
CI / Infra (compose · Dockerfile · nginx) (pull_request) Has been cancelled
CI / 에러 코드 SSOT 동기화 검사 (pull_request) Has been cancelled
- nestjs: 실시간 알림 구조(mail-events · jmap-mail-watch · new-mail · sse), 요청 컨텍스트 밖의 일, 인스턴스를 늘릴 때의 연결 한도,
  오래 열린 응답은 BeforeApplicationShutdown 에서 먼저 끝내기
- vue3: 실시간 새 메일 · 바탕화면 알림 · 실시간 테스트 도구, 토스트 error(), 밀도 예외 --size-notice-action
- docker: /api/mail-events 의 nginx 규칙(90s · 버퍼링 끔 · 로그)과 외부 리버스 프록시 요구사항(버퍼링 · 압축 · 전체 시간 상한 · HTTP/2 · 연결 수)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 13:32:58 +09:00
ttipo 458f12980d feat(docker): nginx 실시간 알림 경로 /api/mail-events
- 이 경로만 버퍼링 · 캐시 · 압축을 끄고 X-Accel-Buffering 을 앞으로 넘긴다(외부 nginx 계열 프록시도 이 응답만 버퍼링을 끈다)
- 읽기 사이 시간 제한 90s — 서버 ping 25초 · 화면 감시 75초보다 길고, 머리글이 오지 않으면 90초에 504 JSON
- 성공(200)도 접근 로그에 남긴다 — 연결이 끝날 때 한 줄이고, 시작된 스트림은 어떻게 끝나도 200 이라 rt= 로 끊김을 본다

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 13:32:57 +09:00
ttipo 50165bd3b7 feat(frontend): 실시간 새 메일 · 바탕화면 알림 · 설정 › 알림
- 로그인한 동안 앱 전체에서 실시간 연결 — 브라우저마다 하나(Web Locks 잠금 + BroadcastChannel 로 다른 탭에 나눔), fetch 로 읽기,
  401 은 세션 갱신 한 번, 자리 잡은 뒤에만 다시 잇기 간격을 처음부터(열리자마자 끝나는 연결에서 갱신 폭주 막기), 탭을 닫으면 이어받기
- 메일 화면: 연결이 살아 있는 동안 1분 확인을 멈추고, 바뀌면 1초 모아 맞추기 · 숨은 탭은 탭 제목 (N) 만
- 바탕화면 알림: 웹메일을 보고 있지 않을 때만(보이고 초점이 있는 탭 없음), 한 통 · 여러 통 · 제목 숨김 문구, 누르면 그 메일로, 돌아오면 닫기
- 설정 › 메일 › 알림(허락 상태 · 제목 숨김 · 시험 알림 · 연결 상태)과 목록 위 한 번 권하기 줄, 알림 아이콘

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 13:32:09 +09:00
ttipo f725908053 feat(backend): 실시간 알림 GET /api/mail-events (엔진 EventSource 중계)
- 직원마다 엔진 EventSource 하나를 그 직원의 연결들이 나눠 쓴다(fan-out) — state · 받은편지함 안 읽음 · 새 메일 계산은 직원마다 한 번
- 새 메일: Email/changes → Email/get 으로 받은편지함 · 초안 아님 · 안 읽음 · 본인 아님 · 10분 넘게 앞서지 않은 메일만,
  셀 수 없거나(cannotCalculateChanges · 400 notRequest) 2,000건 넘게 바뀌면 state 만 보내고 기준을 다시 잡는다
- 연결 5개 한도, 25초 ping(upstream), 토큰 만료 30초 전 end(남은 시간이 짧으면 끝날 때), 서버 종료 때 end,
  처리 전에 끊긴 응답 정리, 처음 엔진 연결 10초 상한
- 엔진 연결: 75초 무소식이면 다시 잇기(±20%), 엔진 429 는 끊지 않고 다시 묻기, 같은 기준 3번 실패면 기준 다시 잡기, 5분 안전판
- 액세스 토큰의 만료 시각을 AuthenticatedUser 에 싣는다(선택 필드), 요청이 끝난 뒤에도 이어지는 일은 요청 컨텍스트 밖에서

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 13:32:08 +09:00
ttipo e27e9feb0b docs: 5단계(실시간 새 메일 · 바탕화면 알림) 계약과 디자인
- 계약 2절 "실시간 알림": GET /api/mail-events(SSE) — state(+inboxUnread) · new-mail · ping(upstream) · end 이벤트,
  직원마다 엔진 연결 하나 · 연결 5개 · 25초 ping · 토큰 만료 30초 전 end, 새 메일 판단(10분 넘게 앞선 메일 제외) · 5분 안전판,
  클라이언트 규칙(브라우저마다 연결 하나 · fetch 로 읽기 · 다시 잇기 · 1분 확인과의 관계 · 바탕화면 알림 조건)
- 엔진 확인 사실(Stalwart 0.16.27): EventSource 는 대리 접근으로 열린다, ping 은 30초보다 짧지 않다, EmailDelivery 는 초안에도 바뀐다,
  스트림은 동시 요청 한도에 들지 않는다, 해석 못 하는 sinceState 는 400 notRequest
- 디자인 캔버스 5단계 보드(SettingsNotify · MailRealtime)와 README "5단계" · "5단계 구현하며 정한 보완"

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 13:31:38 +09:00
ttipo 2d9c6b87d6 docs(rules): 4단계 공용 부품 · AUTH_005 · 메일함 처리 규칙
CI (Flutter) / Flutter (analyze · test) (pull_request) Has been cancelled
CI / Backend (lint · type-check · test · e2e · build) (pull_request) Has been cancelled
CI / Frontend (lint · type-check · test · build) (pull_request) Has been cancelled
CI / Infra (compose · Dockerfile · nginx) (pull_request) Has been cancelled
CI / 에러 코드 SSOT 동기화 검사 (pull_request) Has been cancelled
- error-codes 공통 코드 표에 AUTH_005(400 — 401 이 아닌 까닭)
- nestjs: 비밀번호 변경 · 길이 검사 데코레이터 · 메일함 처리 잠금(인스턴스를 늘리면 옮길 것),
  비우기 · 메일함 지우기의 영구 삭제 규칙(conditionalEmailSet 은 ifInState 필수, onDestroyRemoveEmails false)
- vue3: 설정 화면 · 메일함 관리 · 스팸 · 원문 부품, 진행 알림, 테두리 입력칸, 밀도 예외 --size-mailbox-more, 라우트 표

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 06:52:43 +09:00
ttipo af1e581be1 feat(frontend): 설정(비밀번호 · 부재중 응답) · 메일함 관리 · 스팸 신고 · 원문 내려받기 화면
- 설정 메뉴를 계정 · 메일 묶음으로, 비밀번호 바꾸기(칸 아래 오류 · 30초 한도)와 부재중 자동 응답(기간 · 미리 보기 · 나가기 확인),
  메일함 패널의 부재중 안내(1분마다 다시 계산)
- 메일함 패널의 내 메일함 묶음 · ⋯ 메뉴(보이는 영역 안에 열기) · 대화상자(만들기 · 이름 바꾸기 · 지우기), 목록 머리 비우기
  — 확인한 그 메일함만, 휴지통 비우기와 메일함 지우기는 함께 돌리지 않는다
- 오래 걸리는 처리의 진행 알림("…하는 중 · 600 / 1,234통" → 결과), 세션이 바뀌면 멈추고 늦게 온 응답은 쓰지 않는다
- 읽기 화면 · 여러 통 도구 줄의 스팸 신고 / 스팸 아님 + 되돌리기, ⋯ › 원문 내려받기 (.eml)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 06:47:12 +09:00
ttipo 05fc3909dd feat(backend): 비밀번호 변경 · 메일함 관리 · 부재중 자동 응답 · 스팸 표시 · 원문 API
- PUT /api/auth/password: 직원 본인 자격으로 엔진의 x:AccountPassword/set(게이트웨이 · 관리자 권한 없이), 로그인과 같은 실패 카운터 · 진행 중 잠금.
  바뀌면 리프레시 쿠키의 세션만 남기고 끊는다 — 결과를 모르는 실패에도 끊는다. 엔진이 바꾸기만 거절하면 세지 않는다
- 메일함 만들기 · 이름 바꾸기 · 지우기(안의 메일은 휴지통으로, onDestroyRemoveEmails false), 모두 읽음 · 휴지통 / 스팸메일함 비우기(조건부 지우기)
  — 500통씩 · 한 요청 25초 상한(엔진 호출마다 남은 시간), 같은 직원 · 같은 메일함은 하나씩(REQ_001), 진행 없는 차례가 이어지면 멈춘다
- GET · PUT /api/mail-vacation(켤 때만 제목 · 내용 필수, 제어 문자 검사, 초 단위), PATCH /api/messages(/{id}) 의 isJunk,
  GET /api/messages/{id}/source(message/rfc822)
- 에러 코드 AUTH_005 · MAIL_009 추가와 클라이언트 사전 다시 생성, 공용 길이 검사 데코레이터(JS 문자열 길이)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 06:13:36 +09:00
ttipo cbfccaa499 docs: 4단계(계정 · 메일 설정) 계약과 디자인
- 계약 1절: PUT /api/auth/password — 지금 비밀번호 틀림은 400 AUTH_005(401 이면 클라이언트가 갱신 · 재시도한다),
  로그인과 같은 실패 한도, 바뀌면 이 세션만 남기고 다른 로그인을 끊는다(결과를 모를 때도)
- 계약 2절: 메일함 관리(만들기 · 이름 바꾸기 · 지우기 · 모두 읽음 · 비우기 — 500통씩 · 한 요청 25초, 같은 메일함은 하나씩),
  부재중 자동 응답(JMAP VacationResponse), 스팸 표시(isJunk) · 원문(.eml)
- 디자인 캔버스 4단계 보드(SettingsAccount · SettingsVacation · MailboxManage · MailActions4)와 README "4단계" · "4단계 구현하며 정한 보완"

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 03:41:45 +09:00
ttipo 32576b0ad1 docs(rules): 3단계 공용 부품·웹 DELETE 금지·로그 예외
CI (Flutter) / Flutter (analyze · test) (pull_request) Has been cancelled
CI / Backend (lint · type-check · test · e2e · build) (pull_request) Has been cancelled
CI / Frontend (lint · type-check · test · build) (pull_request) Has been cancelled
CI / Infra (compose · Dockerfile · nginx) (pull_request) Has been cancelled
CI / 에러 코드 SSOT 동기화 검사 (pull_request) Has been cancelled
vue3 스킬에 메일 스토어 구성 · 새 메일 확인 · 메일 검색 · 여러 통 처리 행과 새 밀도 예외, nestjs 스킬에 검색 · 영구 삭제 부품,
docker 스킬에 로그 · Referrer-Policy · 외부 프록시의 쿼리 없는 로그, 로그 규칙에 cursor 와 nginx 오류 로그 예외를 더했다.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 23:56:10 +09:00
ttipo 5b6a97e5e1 feat(docker): 접근 로그에서 쿼리 빼기·Referrer-Policy strict-origin
검색어가 화면 주소(/mail/search?q=…)에도 들어가서 화면 요청과 Referer 까지 접근 로그에서 쿼리를 뺐다.
Referer 로 검색어가 실리지 않게 화면의 Referrer-Policy 를 strict-origin 으로 둔다. 성공한 1분 확인은 접근 로그를 남기지 않는다(폴링).
휴지통 영구 삭제는 엔진을 여러 번 기다리므로 발송·임시 저장과 같은 90s.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 23:56:10 +09:00
ttipo 192807f943 feat(frontend): 새 메일 확인·메일 검색·여러 통 선택과 처리 화면
- 화면이 보이는 동안 1분마다 바뀐 것을 묻고 목록·메일함 숫자·탭 제목 "(N)" 을 맞춘다 — 늦게 온 응답은 목록 변경 세대로 버린다
- 상단 검색 칸 → /mail/search (범위 전환, 결과 없음 안내에 엔진 한계)
- 표·분할 보기에서 여러 통 고르기와 도구 줄, 이동 메뉴, 휴지통 이동 + 되돌리기, 휴지통에서만 확인 뒤 영구 삭제
- 읽기 화면의 삭제도 DELETE 대신 휴지통 이동 · 확인 뒤 영구 삭제 — 다른 창이 휴지통에 넣은 메일을 확인 없이 지우지 않게
- 되돌리기 알림은 띄운 세션에서만 움직인다 — 메일 id 는 계정마다 겹칠 수 있다
- 메일 스토어를 기능별 setup 모듈로 나눴다 (공개 API 그대로)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 23:56:09 +09:00
ttipo 090ee361a3 feat(backend): 새 메일 확인·전체 메일함 검색(한국어 조사)·여러 통 수정·휴지통 영구 삭제 API
- GET /api/mail-state: 메일함·메일 상태를 합친 불투명 값 — 화면이 1분마다 바뀐 것이 있는지만 묻는다
- GET /api/messages: mailboxId 를 선택으로(없으면 휴지통·스팸메일함을 뺀 전체). 엔진은 낱말이 통째로 같을 때만 맞으므로
  q 를 낱말·구절로 나눠 낱말마다 조사를 붙인 형태를 OR 로 찾는다 (jmap/search-terms.ts)
- PATCH /api/messages: 읽음·중요·이동을 100통까지 엔진 요청 한 번에. 없는 메일함이면 아무것도 바꾸지 않고 404
- POST /api/message-deletions: 휴지통에 있는 메일만, 확인한 상태 그대로일 때만(ifInState) 지운다 — 그사이 되돌린 메일을 지우지 않게
- 로그의 cursor 도 길이만 남긴다 — 커서에 검색어의 지문이 들어 있다

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 23:56:09 +09:00
ttipo 7585b3bfac docs: 3단계(새 메일·검색·여러 통 처리) 계약과 디자인
캔버스 버전 21 로 확정한 3단계 화면(Search · BulkSelect · ListStates)과 계약 2절의 새 절(새 메일 확인 · 검색 · 여러 통 한꺼번에).
개발 엔진(Stalwart 0.16.27)에서 직접 확인한 검색 한계 — 낱말이 통째로 같을 때만 맞고 제목 맨 앞 말머리는 색인 밖이다 — 와
그 보완(조사 붙인 형태를 함께 찾기), 휴지통 전용 영구 삭제의 조건(ifInState)을 함께 적었다.
웹 화면은 DELETE /api/messages/{id} 를 쓰지 않는다 — 다른 창이 휴지통에 넣은 메일을 확인 없이 영구 삭제하지 않게.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 23:56:09 +09:00
ttipo 419b2009ab docs: 받는 사람 추천 캐시 상한을 코드(500명)에 맞춤
리뷰 때 메모리 상한으로 SUGGESTION_CACHE_MAX_USERS 를 500 으로 줄였는데 계약은 1,000명으로 남아 있었다.

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 19:21:52 +09:00
ttipo fe8a99e728 test(backend): 동시 로그인 e2e 를 시간에 기대지 않게
"같은 주소로 동시에 보낸 30건 — 엔진 확인은 1번" 테스트는 대역 엔진이 고정 500ms 를 붙잡는 동안 30건이 모두 닿기를 바랐다.
바쁜 기계(이 호스트는 다른 프로젝트의 CI 러너도 돈다)에서 요청이 500ms 넘게 퍼지면 늦은 요청이 엔진을 한 번 더 불러 실패할 수 있었다.

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 07:49:40 +09:00
ttipo f77179a508 feat(frontend): 메일 쓰기 서식 편집기와 입력칸 포커스 통일
메일 쓰기 화면은 입력칸마다 포커스 모양이 달랐고, 본문은 서식 없는 글만 쓸 수 있었다.

- 글자 입력 행(받는 사람·참조·숨은참조·제목·본문)의 포커스를 아래 선 하나(.compose-field)로 통일했다
- 본문을 TipTap 서식 편집기로 바꿨다 — 글꼴·크기·굵게·색·목록·링크·정렬·들여쓰기·인용·표·가로줄, 단축키,
  좁은 폭에서 묶음째 접는 툴바. 편집기는 쓰기 화면에서 따로 불러온다(지연 로딩)
- 보낼 때는 기본 글꼴(맑은 고딕) div 와 인라인 style 의 HTML 에 텍스트 대체 본문을 함께 보낸다 (여러 칸 공백은 nbsp, 탭은 8칸 자리까지)
- 붙여 넣은 글·원문 인용은 서버 정화와 같은 값 형식만 읽는다 — 편집기에서 숨은 글이 받는 사람에게 보이지 않게
- 답장·전달 인용은 "원본 메일" 머리와 함께 서식째 옮긴다. 초안은 내 글 몫 5%를 남기고, 보내기 직전에 다시 재서 인용을 줄인다
- 본문에 붙여 넣거나 끌어다 놓은 이미지·파일은 첨부로 올린다 — 화면 어디에 놓아도 (브라우저가 파일을 열어 쓰던 메일을 잃지 않게)

의존성: @tiptap/* 3.31.4 (MIT 무료 패키지만, 모두 같은 판으로 고정)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 07:49:40 +09:00
ttipo 8fc73aa541 fix(backend): 숫자 env 를 앞자리 0 없는 10진수로만 받기
같은 값을 읽는 nginx 템플릿(frontend/nginx-env.envsh)은 셸 산술 때문에 `08` 같은 값에서 기동을 멈추는데
백엔드는 받아들여, 백엔드만 뜨고 frontend 가 뜨지 않는 조합이 있었다.

- 숫자 env 는 앞자리 0 없는 10진수만 받는다 (기간의 숫자, TRUST_PROXY 홉 수·CIDR 접두 포함) — `08`·`+5`·`5.0`·`1e3` 은 기동 실패
- nginx 도 읽는 MAIL_ATTACHMENT_MAX_SIZE_MB·UPLOAD_MAX_SIZE_MB 는 nginx 와 같이 6자리 이하
- PORT 도 parsePositiveInt 로 읽는다 — compose 가 빈 문자열을 넣으면 Number('') 가 0 이라 임의 포트로 떴다

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 07:49:40 +09:00
ttipo 70103e4968 feat(backend): 메일 서식 본문 발송과 정화 전 비용 검사
메일 쓰기에 서식 편집기가 들어오면서 서버가 HTML 본문을 받아 보낸다. 편집기의 출력은 믿지 않고 서버가 다시 정화한다.

- 서식 본문(bodyHtml)은 보내는 메일 전용 허용 목록으로 다시 정화해 텍스트 대체 본문과 multipart/alternative 로 보낸다
  (style 은 속성마다 값 형식을 처음부터 끝까지 맞추고, 링크는 http·https·mailto 만). 정화해서 비면 텍스트 메일로 보낸다
- 받은 메일 정화가 편집기 서식을 모두 남기도록 넓혔다 — 보낸편지함·받는 사람 화면에서 서식이 사라지지 않게
- 발송 POST /api/messages 만 JSON 4MB (서식 본문·긴 인용). 중복 방지 지문에 정화한 서식 본문을 넣는다
- htmlparser2 는 중첩 깊이의 제곱으로 느려져, 깊게 중첩한 2MB 메일 한 통에 3분 가까이 서버가 멈췄다.
  정화 전에 선형 시간으로 파서 스택 비용을 재고, 넘으면 받은 메일은 htmlBody 없이(텍스트로), 서식 본문은 VAL_001
- 정화한 서식 본문이 2,000,000바이트(본문 읽기 한도)를 넘으면 VAL_001 — 다시 읽을 때 표시 없이 잘리지 않게
- 받은 메일 정화의 다른 제곱 시간 경로 2개(style 의 url() 정규식, 주소 없는 img 를 지우는 필터)도 고쳤다
- htmlparser2 를 직접 의존성으로 선언한다 — sanitize-html 이 쓰는 12.0.0 과 같은 판이라 lock 은 루트 한 줄만 바뀐다

계약: docs/mail/api-contract.md 2절 "서식 본문"·"읽기 한도"

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 07:49:39 +09:00
ttipo d9ad6fc178 docs: 메일 모듈에 맞춰 README·규칙·스킬 문서 갱신
구현한 인증·메일·인프라에 맞춰 공통 규칙과 스택별 스킬 문서를 실제 파일과 일치시킨다.

- security: 세션 회전·로그인 실패 한도·출처 검사·프록시 신뢰·네트워크 분리·게이트웨이 비밀 등급,
  외부로 나가는 지문은 받는 쪽이 모르는 비밀로 HMAC, 의존성 유예 표(2026-10-27 점검)와 보안 릴리스 예외
- error-codes: AUTH_004 와 세션 만료(AUTH_002) 흐름
- env-structure·checklist: 메일 엔진 env 와 초기 설정 순서(관리자 아이디는 bootstrap 전에)
- nestjs·vue3·docker 스킬: 기본 제공 골격 표와 확장 규칙, Node·npm·이미지 고정 위치

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 01:32:55 +09:00
ttipo a5afbc040e docs(design): 웹메일 디자인 원본과 브랜드 로고 추가
사용자가 확정한 디자인 캔버스(버전 11)의 원본이다. 화면 구현의 기준으로 쓴다.

- 로그인·메일함(분할·목록 보기)·읽기·쓰기·상태 화면의 캔버스 원본과 자산 대응표
- 디자인 원칙(뱃지 없음·높은 밀도·콘텐츠 열 규칙·밀도 예외)과 구현하며 정한 보완
- 흰 로고 원본 (어두운 브랜드 바탕 전용)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 01:32:09 +09:00
ttipo 5ed9fb922a docs(mail): 인증·메일 API 계약 문서 추가
백엔드와 프론트를 병렬로 만들기 위한 계약(SSOT)이다. 구현과 어긋나면 이 문서를 고친다.

- 쿠키 인증과 세션 규칙(1회용 회전·재사용 감지·30초 유예·탭 간 로그아웃)
- 메일 API 와 실패 코드, 중복 발송 방지(Idempotency-Key·내용 지문)와 남은 한계
- 첨부 한도(파일당·합계·개수), 출처 검사, 프록시가 직접 내는 오류 형식

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 01:31:59 +09:00
ttipo 468a4377ee feat(frontend): 전역 셸·로그인·세션 처리와 웹메일 화면
확정된 디자인(docs/design/webmail)을 기준으로 만든 웹메일 화면이다.

- 앞으로 붙일 사내 모듈(캘린더·파일·ERP 등)을 위한 전역 앱 레일과 셸,
  Pretendard 자체 호스팅, 바다 청록 브랜드 토큰과 콘텐츠 열 토큰
- 로그인과 세션 처리: 401 이면 한 번 갱신한 뒤 다시 보내고, 실패하면 세션 만료 대화상자를 띄운다.
  로그아웃은 다른 탭에도 알리고, 로그아웃 뒤에 늦게 온 갱신 결과는 버린다
- 메일함(분할·목록 보기), 읽기(외부 HTML 은 sandbox iframe 으로만 그린다),
  쓰기(발송 키 규칙, 첨부 형식·크기·합계·개수 사전 검사, 답장·전달 초안 길이 맞춤)
- 오류는 코드로만 분기하고, 문구는 생성된 에러 사전에서 가져온다

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 01:31:59 +09:00
ttipo 71f2e01d83 feat(backend): 직원 인증(쿠키 JWT·DB 세션)과 웹메일 API(Stalwart JMAP 중계)
첫 사내 모듈인 웹메일의 백엔드다. 메일 엔진은 외부(Stalwart)에 두고 백엔드가 JMAP 으로 중계한다.

- 직원 로그인 = 메일 계정: 엔진으로 비밀번호를 확인한 뒤 HttpOnly 쿠키 JWT 를 발급한다.
  리프레시 세션은 DB 에 해시로 저장하고 1회용으로 회전한다 (이미 쓴 토큰이 오면 세션 폐기)
- 로그인 실패 한도는 엔진에 묻기 전에 원자적으로 차지하고, IPv6 는 /64 로 묶어 센다
- 상태를 바꾸는 요청은 출처(Sec-Fetch-Site·Origin)를 검사하고, 클라이언트 IP 는 TRUST_PROXY 로만 해석한다
- 메일함·목록·읽기·발송·첨부 API: 받은 HTML 은 정화해서 내보내고, 발송은 Idempotency-Key 로 중복을 막는다
- 메일 엔진 관리자 계정은 로그인·대리 접근 대상에서 막는다 (게이트웨이가 관리자까지 대리할 수 있다)
- 에러 코드 AUTH_004·MAIL_001~006 을 추가하고 클라이언트 에러 사전을 다시 생성했다

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 01:31:38 +09:00
ttipo 238d0ba838 chore(docker): 메일 엔진·네트워크 분리·같은 주소 배포와 Node 24 고정
웹메일 1단계를 개발 환경에서 띄우고 운영에 올리기 위한 인프라 구성이다.

- 메일 엔진 Stalwart(v0.16.27)와 개발용 Mailpit 을 compose profile `mail` 로 추가한다.
  초기 설정은 mail/scripts/setup.mjs 가 맡는다 — 여러 번 실행해도 안전하고,
  운영에서는 사전 검사가 실패하면 아무것도 바꾸지 않고 멈춘다
- 화면과 API 를 한 주소로 배포한다: 프론트 nginx 가 /api 를 백엔드로 넘기고,
  본문 한도·시간 제한·헤더 전달을 env 와 맞춘다. nginx 가 직접 내는 오류도 표준 실패 형식이다
- 인터넷을 향한 nginx 는 edge 네트워크로 백엔드에만 닿고, DB·메일 엔진은 app-network 에 둔다
  (메일 엔진의 신뢰 대역도 app-network 뿐)
- Node 24.22.1·npm 11.6.2·nginx 1.30.6 을 Dockerfile 과 CI 에 한 값으로 고정한다
- 개발 호스트 포트는 127.0.0.1 로 한정하고, data/ 는 클론 때부터 사용자 소유가 되게 .gitkeep 을 추적한다

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 01:31:21 +09:00
602 changed files with 117201 additions and 1277 deletions
+8 -2
View File
@@ -9,13 +9,19 @@
- [ ] 루트 `.env.{env}.example` → `.env.{env}` 복사 후 **placeholder 값 교체** (`change-me-*` 가 남아 있으면 안 된다, [[env-structure]])
운영은 `BACKEND_API_URL`·`CORS_ORIGIN` 을 **실제 도메인**으로 — `localhost`/`example.com` 이 남으면 기동 실패한다
운영은 `TRUST_PROXY`(앞단 프록시 홉 수 — 외부 리버스 프록시 뒤면 2, 없으면 1)도 필수다. 앱 네트워크 대역 `APP_NETWORK_SUBNET` 은
호스트의 다른 도커·사내 네트워크와 겹치지 않는 값으로 둔다 (메일 엔진이 이 대역만 신뢰한다 — [[docker]])
- [ ] `backend/.env.{env}.example` → `backend/.env.{env}` 복사 후 `JWT_SECRET` 등 고유 시크릿 작성 (compose `env_file` 로 로드 — 파일이 없으면 기동 실패)
- [ ] `flutter/.env.example` → `flutter/.env` 복사 후 `API_URL` 확인 (Android 에뮬레이터: `10.0.2.2`)
— **이 파일이 없으면 `flutter analyze` 가 asset 경고를 낸다**
- [ ] 프로젝트 식별 값 교체 (`PROJECT_NAME` 등 루트 env 키)
- [ ] 의존성 설치: `cd backend && npm ci`, `cd frontend && npm ci`, `cd flutter && flutter pub get`
- [ ] Docker 개발 환경 기동으로 동작 확인 — backend + db ([[docker]])
(Redis 와 프론트엔드 컨테이너는 기본 비활성이다 — 필요하면 루트 `.env` 에 `COMPOSE_PROFILES=redis,frontend`)
- [ ] Docker 개발 환경 기동으로 동작 확인 — backend + db + 메일 엔진(mail·mailpit) ([[docker]])
(예시 env 의 기본값이 `COMPOSE_PROFILES=mail` 이다. Redis 와 프론트엔드 컨테이너는 기본 비활성 — 필요하면 `COMPOSE_PROFILES=mail,redis,frontend`)
- [ ] 메일 엔진 초기 설정 — `node --env-file=.env.development mail/scripts/setup.mjs` (처음 한 번, env 를 바꾸면 다시 — 여러 번 실행해도 안전)
운영은 **처음 setup(bootstrap) 전에** 엔진 관리자 아이디 `MAIL_ADMIN_LOCAL_PART` 를 정한다 — 옮겨 올 실제 메일함(예: `admin@`)과 겹치지 않게.
bootstrap 뒤에는 바꿀 수 없다(setup 이 막는다). 저장소는 `sudo` 없이 클론한다 — `data/` 가 사용자 소유여야 setup 이 상태 파일을 쓴다
직원 로그인 = 메일 계정이라 이 단계 전에는 로그인할 수 없다. 개발 테스트 계정·관리 화면·Mailpit 은 [`mail/README.md`](../../mail/README.md)
- [ ] 프론트엔드 dev 서버는 **호스트에서** 기동 — `cd frontend && npm run dev` (→ <http://localhost:5173>)
컨테이너 vite 는 바인드 마운트 때문에 첫 서빙까지 수 분이 걸려 profile 뒤로 숨겨 두었다 ([[docker]])
+32 -5
View File
@@ -13,11 +13,21 @@ description: 환경변수 단일 진실 공급원(SSOT) 구조 — 루트 .env.{
│ (docker compose --env-file 로 로드)
├─▶ backend : env_file + environment 로 DB/Redis/CORS/포트 주입
│ (backend/.env.{APP_ENV} 는 "존재"만 하면 됨, 내용 최소)
├─▶ frontend : BACKEND_API_URL → build arg(VITE_API_BASE_URL) 로 주입
├─▶ frontend : 빌드 arg VITE_API_BASE_URL=/api 고정 (같은 주소 배포 — env 로 바꾸지 않는다)
│ 런타임 env MAIL_ATTACHMENT_MAX_SIZE_MB·UPLOAD_MAX_SIZE_MB → nginx 본문 한도,
│ NGINX_BACKEND_UPSTREAM=http://${PROJECT_NAME}-backend:${BACKEND_PORT} (frontend/nginx-env.envsh — 값이 틀리면 기동 중단)
│ (호스트에서 `npm run dev` 로 띄울 때는 compose 를 거치지 않으므로
│ vite.config.ts 가 이 파일을 `loadEnv` 로 직접 읽어 FRONTEND_PORT/BACKEND_PORT 사용)
├─▶ network : APP_NETWORK_SUBNET → app-network 서브넷 고정 (메일 엔진의 신뢰 대역 — 사설 대역 /24~/28, frontend 는 붙지 않는다)
│ EDGE_NETWORK_SUBNET → edge(frontend ↔ backend) 서브넷 고정 (TRUST_PROXY 주소 목록용)
├─▶ db : DB_USER/DB_PASSWORD/DB_NAME, DB_DATA_DIR 볼륨
└─▶ redis : REDIS_PASSWORD, REDIS_DATA_DIR 볼륨
├─▶ redis : REDIS_PASSWORD, REDIS_DATA_DIR 볼륨
├─▶ uploads : 운영은 named volume <PROJECT_NAME>_uploads (env 없음), 개발만 UPLOAD_DATA_DIR 바인드 마운트
├─▶ mail : MAIL_PUBLIC_URL·MAIL_RECOVERY_ADMIN → STALWART_* (backend 에는 MAIL_JMAP_URL·MAIL_DOMAIN·MAIL_GATEWAY_*·MAIL_ATTACHMENT_*·
│ MAIL_ADMIN_LOCAL_PART·MAIL_BLOCKED_ACCOUNTS)
│ 운영의 MAIL_RECOVERY_ADMIN 은 파일에 두지 않고 셸 일회성 override 로만 준다 (mail/README.md "운영 배포")
│ MAIL_ADMIN_LOCAL_PART 는 setup.mjs 와 backend 가 함께 읽는다 — bootstrap 전에만 정할 수 있다
└─▶ mail/scripts/setup.mjs : `node --env-file=.env.{APP_ENV}` 로 같은 파일을 직접 읽는다 (mail/README.md)
```
규칙:
@@ -27,24 +37,41 @@ description: 환경변수 단일 진실 공급원(SSOT) 구조 — 루트 .env.{
- **compose 는 정의되지 않은 변수를 빈 문자열로 주입한다.** 코드에서 `??` 기본값만 쓰면 `Number('')===0` 같은 함정에 빠진다 —
숫자 설정값은 `shared/utils/env.ts` 의 `parsePositiveInt()` 처럼 빈 문자열까지 방어하는 파서를 거친다.
(기존 `.env` 를 쓰는 사람이 새 키를 추가하지 않아도 안전해야 한다)
- **숫자 값은 앞자리 0 없는 10진수로만 쓴다** — `08`·`+5`·`5.0`·`1e3`·`0x10` 은 env 검증이 기동을 막는다
(기간 `015m`, `TRUST_PROXY` 홉 수·CIDR 접두도 같다. 규칙은 `shared/utils/env.ts` 의 `isPositiveIntString()`).
같은 값을 읽는 nginx 템플릿(`frontend/nginx-env.envsh`)이 셸 산술 때문에 `08` 을 거부하므로 백엔드도 같은 규칙을 쓴다 (2026-10-27).
nginx 본문 한도가 되는 `MAIL_ATTACHMENT_MAX_SIZE_MB`·`UPLOAD_MAX_SIZE_MB` 는 템플릿과 같이 **6자리(999999) 이하**만 받는다
(백엔드만 뜨고 frontend 가 뜨지 않는 조합을 막는다 — `env.validation.ts` 의 `NGINX_SHARED_INT_KEYS`).
- `docker-compose.yml` 의 `env_file` 지시자 때문에 `backend/.env.{APP_ENV}` 파일은 비어 있어도 **존재해야 한다.**
- **프론트엔드 dev 서버는 compose 를 거치지 않는다** — 호스트에서 직접 띄우므로([[docker]])
`vite.config.ts` 가 루트 `.env.{mode}` 를 `loadEnv` 로 읽어 포트·프록시 대상을 정한다.
포트를 `frontend/.env` 나 `vite.config.ts` 에 중복 작성하지 말 것 — 루트가 SSOT 다.
(읽은 값은 dev 서버 설정에만 쓰고 클라이언트 번들에는 넣지 않는다 — `VITE_` 없는 값의 번들 노출 금지, [[security]])
- **Flutter 는 예외** — docker-compose 대상이 아니므로 `flutter/.env` 에서 `API_URL` 을 독립적으로 관리한다.
- **루트 env 를 Node 스크립트가 직접 읽을 때**(`node --env-file=…`) 두 가지를 주의한다.
- Node 는 **셸에 이미 있는 변수를 덮어쓰지 않는다** — 셸에 `APP_ENV=development` 가 남은 채 `.env.production` 을 주면 개발 값으로 돈다.
스크립트는 env 파일 값과 셸 값이 다르면 중단해야 한다 (`mail/scripts/setup.mjs` 의 `findShellOverrides()`).
- Node 는 따옴표 없는 값의 `#` 부터를 **공백이 없어도** 주석으로 자르고, compose 는 그대로 읽는다.
비밀번호 등은 영문·숫자로만 만든다 (`#`·따옴표·`$`·공백 금지).
- 모든 env 파일은 커밋 금지, `*.example` 만 커밋한다. (`.gitignore` 의 `!*.example` 재포함 규칙)
- **설정이 잘못되면 서버가 뜨지 않아야 한다(fail-fast).** 조용히 기본값으로 굴러가다 운영에서 터지는 것이 더 위험하다.
검증은 `backend/src/shared/config/env.validation.ts` 가 담당하며 `ConfigModule.forRoot({ validate })` 에 연결되어 있다.
- 숫자 항목은 형식 검사 (모든 환경)
- 운영 환경: `DB_HOST`/`DB_USER`/`DB_PASSWORD`/`DB_NAME` 필수
- 운영 환경 필수: `DB_HOST`/`DB_USER`/`DB_PASSWORD`/`DB_NAME`, `JWT_SECRET`, `MAIL_JMAP_URL`/`MAIL_DOMAIN`/`MAIL_GATEWAY_*`,
`CORS_ORIGIN`(화면의 공개 주소 — 상태 변경 요청의 출처 검사), `TRUST_PROXY`(백엔드 앞 프록시 홉 수 — 운영에서 `true`·`0`·`false` 는 거부)
- 운영 환경: `change-me`·`password` 같은 **예시 값이 그대로 배포되는 것을 차단**
- 운영 환경: `CORS_ORIGIN`·`BACKEND_API_URL` 에 **로컬 주소(`localhost`·`127.0.0.1` 등)나
예시 도메인(`example.com`)이 남아 있으면 차단** — 기동은 되는데 브라우저만 조용히 실패하는 조합을 막는다.
로컬에서 운영 이미지를 검증하느라 일부러 localhost 를 쓸 때만 `ALLOW_LOCALHOST_URL=true` 로 건너뛴다
(`BACKEND_API_URL` 은 앱이 쓰지 않지만 이 검사를 위해 compose 가 backend 에 주입한다)
(`BACKEND_API_URL` 은 앱도 프론트 빌드도 쓰지 않는다 — 같은 주소 배포라 "화면 주소 + /api" 이고, 이 검사를 위해서만 compose 가 backend 에 주입한다)
- 문제는 하나씩이 아니라 **한 번에 모아서** 보여준다 (재기동 반복 방지)
- 새 필수 설정을 추가하면 이 파일의 목록과 테스트도 함께 갱신한다
> 루트 env의 대표 키: `PROJECT_NAME`, `APP_ENV`, `BUILD_MODE`, 각종 `*_PORT`, `BACKEND_API_URL`, `CORS_ORIGIN`, `DB_*`/`DB_DATA_DIR`, `REDIS_*`/`REDIS_DATA_DIR`, `THROTTLE_TTL_MS`/`THROTTLE_LIMIT`. 실제 목록은 `.env.development.example` 참조.
> 루트 env의 대표 키: `PROJECT_NAME`, `APP_ENV`, `BUILD_MODE`, 각종 `*_PORT`, `APP_NETWORK_SUBNET`·`EDGE_NETWORK_SUBNET`, `BACKEND_API_URL`, `CORS_ORIGIN`, `TRUST_PROXY`,
> `DB_*`/`DB_DATA_DIR`, `REDIS_*`/`REDIS_DATA_DIR`, `THROTTLE_TTL_MS`/`THROTTLE_LIMIT`, `UPLOAD_MAX_SIZE_MB`(·개발 전용 `UPLOAD_DATA_DIR`),
> 메일 `MAIL_IMAGE`(패치까지 고정)·`MAIL_DOMAIN`·`MAIL_HOSTNAME`·`MAIL_PUBLIC_URL`·`MAIL_ADMIN_LOCAL_PART`(관리자 아이디, 기본 `admin` — setup·backend 공용, bootstrap 전에만)·
> `MAIL_ADMIN_PASSWORD`·`MAIL_RECOVERY_ADMIN`(운영은 일회성 override)·`MAIL_GATEWAY_USER`/`MAIL_GATEWAY_PASSWORD`·
> `MAIL_JMAP_URL`·`MAIL_ATTACHMENT_MAX_SIZE_MB`/`MAIL_ATTACHMENT_TOTAL_MAX_SIZE_MB`·`MAIL_TRUSTED_NETWORKS`(비우면 `APP_NETWORK_SUBNET`, 그보다 넓으면 setup 실패)·
> `MAIL_BLOCKED_ACCOUNTS`(선택 — 관리자 외에 웹메일 로그인·대리 접근을 막을 주소, 콤마. 비어 있어도 관리자는 항상 막힌다)
> (개발 전용 `MAIL_ADMIN_PORT`·`MAIL_DEV_USER_PASSWORD`·`MAILPIT_IMAGE`/`MAILPIT_UI_PORT`). 실제 목록은 `.env.development.example` 참조.
> 실행/배포 명령은 [[docker]], 보안 규칙은 [[security]] 참조.
+4 -1
View File
@@ -34,6 +34,8 @@ backend/src/common/errors/error-codes.ts ← 여기만 수정한다
| AUTH_001 | 401 | 미인증 상태로 보호 자원 접근 | "로그인이 필요한 서비스입니다. 로그인 후 이용해 주세요." |
| AUTH_002 | — | **클라이언트 전용** · 세션 만료 | "안전을 위해 로그아웃 되었습니다. 다시 로그인해 주세요." |
| AUTH_003 | 403 | 권한 부족 | "해당 메뉴나 기능에 접근할 수 있는 권한이 없습니다." |
| AUTH_004 | 401 | 로그인 자격 증명 불일치 (계정 없음·비밀번호 틀림을 구분하지 않음) | "이메일 또는 비밀번호가 올바르지 않습니다." |
| AUTH_005 | 400 | 로그인한 채 본인 확인 실패 — 비밀번호 변경의 지금 비밀번호가 틀림. **401 이 아니다** — 클라이언트는 401 이면 세션을 갱신하고 같은 요청을 다시 보낸다 | "지금 비밀번호가 올바르지 않습니다." |
| VAL_001 | 400 / 422 | 입력 검증 실패 | "입력하신 정보를 다시 확인해 주세요. (필수값 누락 또는 형식 오류)" |
| RES_001 | 404 | 리소스 없음 | "요청하신 정보나 페이지를 찾을 수 없습니다." |
| BIZ_001 | 400 | 그 외 비즈니스 규칙 위반 | "요청하신 작업을 완료하지 못했습니다. 다시 시도해 주세요." |
@@ -43,7 +45,8 @@ backend/src/common/errors/error-codes.ts ← 여기만 수정한다
서버는 이 코드를 **절대 내려주지 않는다.** 클라이언트가 다음 조건에서 스스로 생성한다.
- **인증된 세션이 있는 상태**에서 401 응답을 받았을 때 → `AUTH_001` 을 `AUTH_002` 로 승격하고 세션을 정리한다.
- 웹: `useApi.ts` 의 `toAuthCode()` (`authStore.clearAuth()` 호출)
웹은 그 전에 `/auth/refresh` 를 한 번 시도하고, 갱신까지 실패했을 때만 승격한다 (`docs/mail/api-contract.md` 1절).
- 웹: `useApi.ts` 의 `toAuthCode()` — `authStore.expireSession()` 을 불러 세션 만료 대화상자(`SessionExpiredDialog.vue`)를 띄운다
- 앱: `dio_client.dart` 의 `_toApiException()` (`jwt_token` 삭제)
## 도메인 코드 추가
+3
View File
@@ -33,6 +33,9 @@
- 비밀번호, 토큰(JWT·리프레시·API 키), 쿠키, `Authorization` 헤더
- 주민등록번호·카드번호·계좌번호 등 개인 식별/금융 정보
- 요청 본문 전체 덤프 (`JSON.stringify(req.body)` 금지 — 필요한 필드만 선택해 남긴다)
- 사용자가 친 검색어(쿼리의 `q` — 메일 검색 · 받는 사람 추천) — 요청 URL 을 남길 때는 `shared/utils/mask.ts` 의 `maskUrlForLog()` 를 거친다(`q` · `cursor` 는 길이만 — 목록 커서에는 검색어의 지문이 들어 있어 후보 낱말을 대입해 볼 수 있다).
앞단 nginx 도 접근 로그에 쿼리를 남기지 않는다 — `/api` 와 화면 요청 모두(메일 검색은 화면 주소 `/mail/search?q=…` 에도 검색어가 들어간다, [[docker]]).
**예외**: nginx 오류 로그(413 · 502 · 504 등)는 장애 원인 줄을 잃지 않게 요청 줄 · referrer 를 쿼리째 남긴다 — 오류 로그를 볼 수 있는 사람을 운영 담당으로 한정한다
```typescript
// ❌ 금지
+59 -2
View File
@@ -13,14 +13,35 @@
- JWT는 HttpOnly·Secure 쿠키 사용 (웹 `localStorage` 금지) / 모바일은 `flutter_secure_storage` 사용 (키: `jwt_token`)
- Access Token 만료는 짧게(15분~1시간), Refresh Token 은 HttpOnly 쿠키로만 전달
- 리프레시 토큰은 서버 세션에 **해시만** 저장하고 **1회용**으로 회전한다. 이미 쓴 토큰이 다시 오면 탈취로 보고 세션 전체를 끊는다.
동시 갱신(탭 여러 개)을 위한 유예는 "바로 이전 토큰 + 짧은 시간"으로 좁히고, 유예 응답에서는 새 토큰을 주지 않는다 (훔친 토큰으로 얻는 것이 없게)
- 로그인 실패 한도는 자격 확인 **전에** 실패 한 번을 원자적으로 차지하고 성공하면 돌려준다.
확인 뒤에 세면 동시에 들어온 요청 수백 건이 모두 한도 검사를 통과한다 (구현 [[nestjs]])
- 비밀번호는 단방향 해시(`bcrypt`/`argon2`)로 저장한다. 평문·가역 암호화 금지
- 인증 실패 응답에서 **원인을 구분해 알려주지 않는다** (아이디 없음/비밀번호 틀림 → 동일하게 AUTH_001)
- **메일 게이트웨이(대리 접근) 계정은 관리자 계정까지 대리할 수 있다** (Stalwart 0.16 확인) — 게이트웨이 비밀번호는 관리자급 비밀로 다룬다.
백엔드는 엔진 관리자 계정을 로그인·대리 접근 대상에서 막는다: 정적 목록(엔진에 묻기 전) + 로그인·리프레시 때 엔진 권한 확인 ([[nestjs]])
- 인증 실패 응답에서 **원인을 구분해 알려주지 않는다** (아이디 없음/비밀번호 틀림 → 동일하게 AUTH_004)
- 로그인 시도의 실패는 AUTH_004, 로그인 없이 보호 자원에 접근한 것은 AUTH_001 이다.
로그인 실패에 AUTH_001 을 쓰면 "로그인이 필요한 서비스입니다"가 떠 사용자가 원인을 알 수 없다.
## 입력 · 출력
- 입력값은 DTO(`class-validator`) 검증을 거친 뒤 서비스 계층에 전달한다. 전역 `ValidationPipe` 의 `whitelist`/`forbidNonWhitelisted` 를 끄지 않는다
- JSON 본문은 DTO 에 닿기 전에 **모양부터 잰다** — 배열 하나 1,000개 · 값 10,000개 · 깊이 32단, UTF-8 만 (`app.setup.ts` 의 `JSON_SHAPE_LIMITS`, `shared/utils/json-shape.ts`).
DTO 의 개수 한도로는 막지 못한다 — 변환이 항목마다 객체를 만들고, 검사는 개수 한도에 걸려도 항목마다 중첩 검사를 해서 1MB 본문 하나로 서버가 수 초 멈췄다 (2026-10-28 리뷰).
class-validator 의 `ArrayUnique` 는 항목 수의 제곱으로 느려 쓰지 않는다 (`common/decorators/array-distinct.decorator.ts` 의 `ArrayDistinct`)
- TypeORM ORM 쿼리 사용으로 SQL Injection 차단 (Raw 쿼리 시 파라미터 바인딩 필수)
- 외부로 나가는 데이터(보내는 메일의 헤더 등)에 비교용 지문을 실어야 하면 평범한 해시가 아니라 **받는 쪽이 모르는 비밀로 HMAC** 한다
(예: 메일마다 다른 무작위 `Idempotency-Key`). 평범한 해시는 받은 사람이 후보 값(숨은참조 주소 등)을 대입해 맞춰 볼 수 있다.
- **서버 서명 키(`JWT_SECRET` 등)나 거기서 파생한 키를 쓰지 않는다** — 평문을 아는 받은 사람이 (평문, HMAC) 쌍으로
서명 키 후보를 오프라인에서 대입해 볼 수 있고, 맞히면 토큰을 위조할 수 있다 (2026-10-26 리뷰)
- 에러 응답에 스택·쿼리·내부 호스트명을 싣지 않는다 — 미정의 예외는 항상 `SYS_001` 로 수렴시킨다 ([[error-codes]])
- 사용자가 쓴 HTML(메일 서식 본문)은 **서버가 허용 목록으로 다시 정화**한 뒤 내보낸다 — 클라이언트 편집기의 출력을 믿지 않는다.
style 은 속성마다 값 형식을 처음부터 끝까지(`^…$`) 맞춘다 (`modules/mail/html/sanitize-outgoing-html.ts`, 계약 2절 "서식 본문")
- 받은 메일·서식 본문처럼 **밖에서 온 HTML 은 정화하기 전에 비용부터 잰다** — 정화기(htmlparser2)는 중첩 깊이의 제곱으로 느려져
메일 한 통으로 서버 전체를 수 분 멈출 수 있다 (2026-10-27 리뷰). 넘으면 정화하지 않는다 (`modules/mail/html/html-parse-budget.ts`, [[nestjs]])
- 브라우저에서 외부 HTML 을 고쳐 쓸 때는 떨어진 문서(`<template>`·`DOMParser`) 안에서 끝낸다 — 화면 문서로 옮기면
`img onerror` 같은 처리기가 실행된다. CSP 가 막아도 그것에만 기대지 않는다 ([[vue3]] 체크리스트 "외부 HTML")
### 파일 업로드
템플릿은 `modules/upload` 에 로컬 디스크 기준 골격을 제공한다. 새 업로드 엔드포인트는 이 패턴을 복제한다.
@@ -32,6 +53,9 @@
- **저장 파일명은 서버가 생성**한다(UUID + 검증된 확장자). 클라이언트가 보낸 이름을 경로에 쓰면
`../../etc/passwd` 같은 경로 탈출이 가능하다. 원본 이름은 표시용 메타데이터로만 보관한다.
- 크기 한도를 설정한다 (`UPLOAD_MAX_SIZE_MB`, 기본 5MB). 초과 시 `FILE_002`(413).
- 큰 업로드(메일 첨부 등)는 **메모리가 아니라 임시 디스크**로 받아 스트리밍으로 넘기고, 성공·실패와 관계없이 지운다.
메모리에 받으면 동시 업로드 수 × 한도만큼 상주해 컨테이너가 OOM 으로 죽고 모든 요청이 끊긴다.
임시 디스크를 tmpfs 로 두면 메모리 한도에 잡혀 같은 문제가 된다.
- 업로드 디렉터리를 **정적 파일로 그대로 서빙하지 않는다.** 내려줄 때는 컨트롤러를 거쳐
`Content-Type` 을 서버가 지정하고 `Content-Disposition: attachment` 를 붙인다.
@@ -45,6 +69,14 @@
- 보안 헤더(`helmet`) 적용
- CORS 는 화이트리스트 방식. 차단 시 `callback(null, false)` 로 **헤더를 부여하지 않아** 브라우저가 막게 한다
(`callback(new Error(...))` 를 쓰면 500(SYS_001)이 되어 원인 파악이 어려워진다)
- 쿠키로 인증하므로 상태를 바꾸는 요청은 **출처를 검사**한다 (`OriginGuard`, 전역 — 계약 `docs/mail/api-contract.md` 머리말).
비교 기준은 `Host` 가 아니라 허용 목록(`CORS_ORIGIN`)이다. 프록시 뒤에서는 `Host` 가 바뀌어 정상 요청까지 막힌다.
- 클라이언트 IP 는 `TRUST_PROXY`(홉 수 또는 주소 목록)로만 해석한다. 모든 홉을 믿으면(`true`) `X-Forwarded-For` 위조로
IP 기준 한도(로그인 실패·rate limit)가 무력화된다. 입구는 프론트 nginx 하나로 둔다 —
프록시를 건너뛰어 백엔드에 닿는 경로가 있으면 홉 수가 어긋나 위조가 가능해진다.
- **인터넷을 향한 컨테이너(프론트 nginx)는 백엔드에만 닿게** 한다 — DB·메일 엔진과 같은 네트워크에 두지 않는다
(`edge`: frontend↔backend / `app-network`: backend↔db↔mail, [[docker]]). 메일 엔진의 신뢰 대역은 `app-network` 뿐이다.
- 컨테이너가 쓰는 데이터(업로드 등)는 **named volume** 에 둔다 — 호스트 바인드 마운트는 root 소유로 생겨 비-root 컨테이너가 쓰지 못한다.
- Rate Limiting: **`@nestjs/throttler` 하나로 일원화**되어 있다 (`app.module.ts` 의 `APP_GUARD`).
초과 시 429 → 전역 필터가 **`REQ_001`** 로 매핑한다.
- 기본 정책은 루트 `.env` 의 `THROTTLE_TTL_MS`/`THROTTLE_LIMIT` (미설정 시 60초/100회)
@@ -85,7 +117,32 @@
2. **수정이 다른 것을 깨뜨리는가** — 생태계 버전이 어긋나면 더 큰 위험
3. 미룬다면 **언제 다시 볼지** 조건을 적어둔다
> **현재 유예 중인 항목**: 없음 (`npm audit` 0건)
> **현재 유예 중인 항목** (2026-10-27 점검):
> - frontend `eslint-plugin-vue@9` → `postcss-selector-parser` (moderate, 선택자 파싱 CPU 과다 사용)
> - 도달 경로: 개발 시 린트에서만 돈다. 입력은 우리 소스 코드뿐이라 외부 공격 경로가 없다. 운영 번들·이미지에 포함되지 않는다.
> - 미루는 이유: 수정판이 `eslint-plugin-vue@10`(메이저)이라 린트 규칙이 바뀌고 설정 이관이 필요하다.
> - 다시 볼 시점: ESLint 스택을 올릴 때, 늦어도 2027-01.
> - backend (2026-10-27 기준 7건 — high 3·moderate 4, 운영 의존성만 보면 6건 — 아래 5종):
>
> | 항목 | 도달 경로 | 미루는 이유 | 다시 볼 시점 |
> | --- | --- | --- | --- |
> | `body-parser` 2.3.0 (high, 압축 본문 DoS) | 없음 — Nest 기본 파서를 끄고 JSON 파서만 `inflate:false` 로 둔다. 압축 본문은 해제 전에 415 (실서버 확인) | 수정판 2.4.0 이 2026-10-22 배포 (2주 미만) | 2026-11-05 에 lock 갱신 (express 5.3.0 이 `^2.3.0` 이라 범위 안) |
> | `multer` 2.4.0 (high, 비동기 fileFilter DoS) | 없음 — 우리 fileFilter(upload·mail)는 모두 동기 | 2.5.0 이 2026-10-22 배포 (2주 미만). `@nestjs/platform-express` 최신 12.2.0 도 2.4.0 고정이라 override 를 올릴 근거가 아직 없음 | 2026-11-05 이후 override `^2.5.0` 여부 결정 |
> | `js-yaml` 5.3.0 (moderate, `@nestjs/swagger` 하위) | 없음 — swagger 는 `dump` 만 쓰고 우리 코드는 YAML 을 파싱하지 않는다 | swagger 11.x 가 5.3.0 으로 정확히 고정 (12.x 는 5.5.0 이지만 메이저) | swagger 11.x 가 ≥5.4.1 을 선언한 릴리스 + 2주. 또는 override `^5.4.3` 검토 (swagger 12 가 5.x 를 쓰는 것이 근거) |
> | `fast-uri` 3.1.8 (moderate, 개발 전용) | 없음 — Nest CLI 의 ajv 에서만 쓰이고 운영 이미지에 없다 | 3.1.9 가 2026-10-19 배포 (2주 미만) | 2026-11-02 |
> | `sanitize-html` 2.18.0 (moderate, 버린 iframe 의 대체 내용 재귀 — GHSA-c9rx) | 없음 — 우리 설정은 iframe 을 내용째 버려 대체 내용을 다시 정화하지 않는다 (5만 단계 중첩으로 확인, 2만 단계 회귀 테스트로 고정) | 수정판 2.18.1 이 2026-10-14 배포 (2주 미만) | 2026-10-28 이후 2.18.1 |
>
> 2026-10-27 Node 24 전환과 함께 sanitize-html 고정(2.17.5)을 풀어 이전 권고 4건을 해결했다.
> 2.18 이 쓰는 htmlparser2 계열은 ESM 전용이라 Jest 설정에서 CJS 로 변환한다 ([[nestjs]] "의존성 추가/제거").
>
> 참고: `@nestjs/platform-express` 11.2.7(2026-09-30) 이상은 multer 2.4.0 을 직접 선언한다 — 올리면 아래 multer override 를 지울 수 있다.
>
> 같은 점검에서 정리한 것: frontend 22건 → 2건 (axios 1.20.0 · vue 3.5.43 · vite 8.3.4 · oxfmt 0.72.0 으로 갱신, lock 은 Linux 컨테이너에서 생성),
> backend 12건 → 7건 (critical 0 — proxy-addr 2.0.8 등).
> 새 버전은 배포 후 2주 이상 지난 패치 버전을 골랐다 (공급망 위험 — 갓 나온 버전은 피한다).
> **예외: 우리에게 닿는 취약점을 고치는 보안 릴리스**는 2주를 기다리지 않고 CI 가 통과하는 대로 올린다 — 알려진 취약점을 2주 더 두는 위험이 더 크다
> (예: Node 24.22.1 — 2026-10-20 보안 릴리스를 6일 만에 고정, 고정 위치는 [[docker]]).
> 도달 경로가 없다고 확인한 취약점이면 평소처럼 2주를 기다린다 (예: 위 표의 sanitize-html 2.18.1).
### 전이 의존성 취약점은 overrides 로 끊는다
+5
View File
@@ -13,6 +13,11 @@ description: 세 스택 공통 테스트 규칙 — 테스트 위치/네이밍,
- **무엇을 테스트하나**: 순수 함수(utils), 서비스/비즈니스 로직, 에러 코드 매핑 분기를 우선한다. 프레임워크 기본 동작은 테스트하지 않는다.
- **공유 계약은 반드시 테스트한다** — 아래 "계약 테스트" 절 참조.
- **테스트는 결정적(deterministic)** 으로 — 외부 네트워크/시간/랜덤에 의존하지 않도록 모킹한다.
- 성능 단언("입력이 커져도 오래 걸리지 않는다")은 **벽시계가 아니라 계산량**으로 잰다 — 프로세스 CPU 시간이나 비용 수치.
CPU 시간 한도는 **지금 값의 10배쯤** 둔다 — CPU 시간도 부하에서 는다(2026-10-28: CI 와 같은 Linux 컨테이너에서 메모리가 모자라고 테스트가 함께 돌 때
평소의 4~7배 — 여유가 3~6배뿐이던 한도 두 곳이 CI 를 실패시켰다). 막으려는 옛 경로(제곱 등)는 그 한도를 확실히 넘어야 한다 — 둘 사이가 좁으면 입력을 키운다.
벽시계는 다른 작업이 CPU 를 나눠 쓰면 몇 배로 늘어 CI 에서 흔들린다 (2026-10-27: 혼자 0.8초인 정화가 부하에서 9초).
비동기 결과를 기다릴 때도 고정 대기(`setTimeout(20)`) 대신 조건이 될 때까지 짧게 다시 보고 넉넉한 기한을 둔다.
- **커밋 전 실행** — 변경 영역의 테스트가 통과해야 한다. ([[checklist]])
- **CI 가 강제한다** — `.github/workflows/ci.yml` 이 세 스택의 lint·test·build 와 에러 코드 동기화를 PR 마다 검사한다.
- 테스트 설명(it/test 제목)은 한국어로 "무엇을 검증하는지" 명확히 작성한다.
+231 -4
View File
@@ -18,6 +18,7 @@ description: Docker Compose 기반 로컬·운영 오케스트레이션 실행/
```bash
# 코드만 수정했을 때 (의존성 변경 없음) — backend + db 가 뜬다
# (예시 env 의 기본값 COMPOSE_PROFILES=mail 이면 메일 엔진 mail + 개발용 mailpit 도 함께 — 아래 "메일" 절)
docker compose -f docker-compose.yml -f docker-compose.dev.yml --env-file .env.development up -d
# 의존성 변경 시에만 --build
@@ -29,8 +30,8 @@ docker compose -f docker-compose.yml -f docker-compose.dev.yml --env-file .env.d
## 운영 환경
> ⚠️ `.env.production` 의 `BACKEND_API_URL`·`CORS_ORIGIN` 을 **실제 도메인으로 교체**해야 한다.
> `localhost` 나 예시 도메인(`example.com`)이 남아 있으면 백엔드가 기동에 실패한다 ([[env-structure]]).
> ⚠️ `.env.production` 의 `BACKEND_API_URL`·`CORS_ORIGIN` 을 **실제 도메인으로 교체**해야 한다 (예시: `https://portal.arasystem.it.kr`).
> `localhost` 나 예시 도메인(`example.com`)이 남아 있으면 백엔드가 기동에 실패한다 ([[env-structure]]). `CORS_ORIGIN`·`TRUST_PROXY` 는 운영 필수다.
> 로컬에서 운영 이미지를 검증하느라 일부러 localhost 를 쓰는 경우에만 `ALLOW_LOCALHOST_URL=true` 를 함께 준다.
```bash
@@ -41,6 +42,109 @@ sudo docker compose --env-file .env.production up -d --build
sudo docker compose --env-file .env.production logs -f backend
```
### 같은 주소(origin) 배포 — 입구는 프론트 nginx 하나
```
클라이언트 ─TLS─▶ 외부 리버스 프록시 ─(proxy)─▶ <PROJECT_NAME>-frontend :8080 ─┬─ / 화면(SPA)
└─ /api/ ─(edge)─▶ <PROJECT_NAME>-backend :3000
│ (app-network = 메일 엔진의 신뢰 대역)
├─▶ db · redis
└─▶ mail :8080 (JMAP·관리 API)
```
| 네트워크 | 붙는 컨테이너 | 비고 |
| --- | --- | --- |
| `proxy` (external) | 외부 리버스 프록시 · frontend | 다른 프로젝트와 함께 쓸 수 있다. 운영 전용 (dev 오버라이드가 지운다) |
| `<PROJECT_NAME>_edge` | frontend · backend | `EDGE_NETWORK_SUBNET`(기본 `172.16.72.0/24`) 고정 — TRUST_PROXY 주소 목록용 |
| `<PROJECT_NAME>_app-network` | backend · db · redis · mail (dev 는 mailpit 도) | `APP_NETWORK_SUBNET`(기본 `172.16.71.0/24`) 고정 — **메일 엔진의 신뢰 대역**. frontend 는 붙이지 않는다 |
- 브라우저는 화면과 같은 주소의 `/api` 를 부른다 (docs/mail/api-contract.md 머리말). 프론트 빌드는 `VITE_API_BASE_URL=/api` 로 고정이다.
**backend 는 proxy 네트워크에 붙지 않는다** — 바깥에서는 nginx 의 `/api` 로만 들어온다.
- **인터넷을 향한 frontend 는 app-network 에 붙지 않는다** (edge 로만 backend 에 닿는다). 붙으면 nginx 가 `mail:8080`(평문 관리 API)에
닿고, 그 주소가 자동 차단 예외·게이트웨이 로그인 대역에 든다. mail setup 이 앱 네트워크에 붙은 컨테이너를 확인한다 (운영은 그 밖의 컨테이너가 있으면 중단).
- **외부 리버스 프록시는 `<PROJECT_NAME>-frontend:8080` 으로 붙인다** (proxy 네트워크). 서비스 이름(`frontend`)은 다른 프로젝트와 겹칠 수 있다.
같은 이유로 nginx 의 백엔드 주소도 `http://<PROJECT_NAME>-backend:<BACKEND_PORT>` 다 (compose 의 `NGINX_BACKEND_UPSTREAM`) —
proxy 네트워크에 `backend` 라는 다른 컨테이너가 있으면 그쪽으로 풀릴 수 있기 때문이다.
- `frontend/nginx.conf` 는 **템플릿**이다. 컨테이너가 뜰 때 이미지 entrypoint 가 `frontend/nginx-env.envsh` 로 값을 만들고
`${NGINX_*}` 만 envsubst 로 채운다 (nginx 의 `$uri` 등은 그대로). 값은 루트 env 에서 온다:
- 본문 한도: `/api/mail-attachments` = `MAIL_ATTACHMENT_MAX_SIZE_MB` + 1MB(기본 21m), `/api/files` = `UPLOAD_MAX_SIZE_MB` + 1MB(기본 6m),
**메일 발송 `POST /api/messages` 와 임시 저장 `/api/drafts` = 4m 고정**(서식 본문·긴 인용 — 계약 2절, 백엔드도 이 요청들만 4MB 파서.
발송은 정확히 이 경로만이라 `/api/messages/{id}` 는 1m. 임시 저장은 `/api/drafts` 아래 전부 — 본문이 있는 것은 `POST`·`PUT` 뿐이다),
그 밖은 1m(백엔드 JSON 파서 한도). 앞의 두 값은 루트 env 한 곳만 고치면 백엔드와 nginx 가 함께 바뀐다 (frontend 컨테이너만 다시 만들면 된다).
JSON 한도(1m·4m)는 계약과 백엔드 코드의 고정값이라 env 로 바꾸지 않는다 — 바꾸려면 계약·백엔드 파서·`frontend/nginx.conf` 를 함께 고친다.
- 시간 제한: `/api/messages`·`/api/drafts`·`/api/message-deletions`·`/api/mail-events` 90s, `/api/mail-attachments` 150s, 그 밖의 `/api` 는 nginx 기본 60s (`proxy_read_timeout`).
응답 전체가 아니라 백엔드에서 "두 번 읽는 사이"의 시간이다 — 실시간 알림은 1시간 이어져도 25초마다 오는 ping 이 새로 세게 한다(머리글이 오지 않으면 90초에 504).
- **실시간 알림(SSE) `/api/mail-events`**(계약 2절 "실시간 알림"): 이 경로만 버퍼링 · 캐시 · 압축을 끄고 `X-Accel-Buffering` 을 앞으로 넘긴다
(외부 프록시가 nginx 계열이면 그쪽도 이 응답만 버퍼링을 끈다). 버퍼링을 켜 두면 머리글을 첫 이벤트까지, 백엔드가 청크 없이 닫아 끝내는 응답이면 끝까지 붙잡는다(2026-10-28 확인).
머리글 · 오류 JSON · 접근 로그는 `/api` 와 같다. ⚠️ 서버의 ping 간격을 바꾸면 90s 도 함께 본다.
열린 연결 하나가 nginx 연결 둘(클라이언트 쪽 · 백엔드 쪽)을 1시간까지 잡는다(이미지 기본값 워커마다 1024).
열린 연결이 있으면 frontend 컨테이너를 멈출 때 nginx 가 기다리다 유예 시간을 다 쓰고 강제 종료된다 — 종료 코드 137 은 이 때문이고 오류가 아니다(화면은 다시 잇는다).
- 캐시: `index.html`(직접 요청·`/`·SPA 폴백 모두)은 `Cache-Control: no-cache` — 배포 뒤 옛 index.html 이 지워진 해시 자산을 부르지 않게.
`/assets/`(Vite 산출물 — 파일명에 내용 해시)만 `max-age=30일, public, immutable` 이다.
해시가 없는 공개 파일(`frontend/public` → 루트, 예: `favicon.ico`)은 같은 이름으로 바뀌므로 `no-cache`(매번 재검증, 안 바뀌면 304).
보안 헤더는 어느 경우에도 붙는다(`add_header_inherit merge`).
- 헤더: `X-Forwarded-For` 는 이어 붙이고, `X-Request-Id` 는 nginx 의 `$request_id` 로 덮어써 백엔드 traceId 와 nginx 로그 `rid=` 가 같다.
- 로그: 접근 로그는 화면·`/api` 모두 쿼리 없이 남긴다 — 검색어 `q` 가 `/api` 주소뿐 아니라 화면 주소(`/mail/search?q=…`)에도 들어간다 (계약 2절 "검색").
`log_format main_rid` 하나를 server 단계에만 걸어, 오류용 이름 있는 location 과 location 을 고르기 전에 거부된 요청(헤더가 너무 큰 400)도 같은 형식이다.
요청 줄은 쿼리만 뺀 원래 요청 줄(SPA 폴백 뒤에도 `/index.html` 이 아니라 부른 화면 경로)이고, Referer 도 `?` 앞까지만 남긴다.
화면 응답은 `Referrer-Policy: strict-origin` 이라 브라우저가 같은 주소의 요청에도 origin 만 싣는다 — 화면 주소의 검색어·`?draft=` id 가
Referer 로 오류 로그·외부 프록시에 가지 않게. 접근 로그의 Referer 자르기는 그대로 둔다(배포 전에 연 탭·브라우저가 아닌 클라이언트는 주소째 보낼 수 있다).
Referer 를 읽는 코드는 없다 — 출처 검사(`OriginGuard`)는 `Sec-Fetch-Site`·`Origin` 을 본다.
새 메일 확인 `GET /api/mail-state`(탭마다 1분에 한 번)는 성공(2xx·304)하면 남기지 않는다 — 로깅 규칙 "반복되는 정상 흐름".
실패(401·502 등)는 남긴다 (server 단계 `access_log … if=$log_access` 하나 — `/api/` 블록을 나누지 않는다).
실시간 알림 `/api/mail-events` 는 성공도 남긴다 — 연결이 끝날 때 한 줄(브라우저마다 대략 1시간에 한 번)이고 `rt=` 가 이어진 시간이다.
시작된 스트림은 어떻게 끝나도 200 이라, 끊김은 짧은 `rt=` 와 오류 로그의 `upstream prematurely closed connection` 으로 본다.
오류 로그(413·502·504 등)에는 nginx 가 요청 줄·upstream·referrer 를 쿼리째 남긴다 — 장애 원인 줄을 잃지 않으려고 그대로 둔다.
`Origin`·`Sec-Fetch-Site`·`Idempotency-Key`·`Cookie` 는 그대로 넘긴다. `/api` 응답에는 화면용 보안 헤더를 붙이지 않는다(백엔드 helmet 이 정한다).
- 백엔드 주소는 요청마다 도커 DNS 로 다시 풀어(`resolver`), 백엔드를 다시 만들어도 nginx 를 재시작할 필요가 없다.
- **nginx 가 직접 끝내는 `/api` 오류도 표준 실패 형식(JSON)이다** — 본문 한도 초과 413 `FILE_002`, 백엔드 응답 없음 502·시간 초과 504
(그리고 500·503) `SYS_001`. `message` 는 비우고(클라이언트가 코드로 사전 문구를 쓴다) `traceId` 는 nginx 요청 ID 다 (응답 헤더 `X-Request-Id`·로그 `rid=` 와 같다).
상태 코드는 원래 값 그대로다. 백엔드가 보낸 4xx·5xx 는 손대지 않는다 (`proxy_intercept_errors` 꺼짐).
- **`/api/health` 는 바깥에서 404 다** (`RES_001` JSON — 없는 경로와 같다). 실패하면 DB 오류 문자열 등 원인이 담기기 때문이다
(대소문자·끝 `/` 도 막는다). 내부에서는 백엔드에 직접 묻는다:
```bash
# 상태 코드와 원본 본문 (503 이어도 원인이 보인다) — 백엔드 이미지에 node 가 있다
sudo docker exec "${PROJECT_NAME}-backend" node -e "fetch('http://127.0.0.1:' + process.env.PORT + '/api/health').then(async (r) => console.log(r.status, await r.text()))"
# 모니터링 도구를 컨테이너로 둔다면 edge 네트워크에 붙여 http://<PROJECT_NAME>-backend:<BACKEND_PORT>/api/health 를 본다
```
- 설정 문법 검사 (이미지로, 화면 빌드 없이): `docker build --target nginx-config -t <tag> frontend && docker run --rm -e PROJECT_NAME=x <tag> nginx -t`
(CI 의 infra 잡이 같은 검사를 한다). 값을 바꿔 렌더링 결과를 보려면
`docker run --rm -e PROJECT_NAME=x -e MAIL_ATTACHMENT_MAX_SIZE_MB=30 <tag> nginx -T | grep client_max_body_size`.
`PROJECT_NAME`(또는 `NGINX_BACKEND_UPSTREAM`) 없이 띄우면 일부러 기동을 멈춘다.
- **템플릿 값이 잘못되면 frontend 가 뜨지 않는다** (`frontend/nginx-env.envsh`, fail-fast) — `docker logs <PROJECT_NAME>-frontend` 의 `nginx-env.envsh:` 줄을 본다.
- `MAIL_ATTACHMENT_MAX_SIZE_MB`·`UPLOAD_MAX_SIZE_MB`: 빈 값은 기본값(20·5), 그 밖은 0 으로 시작하지 않는 6자리 이하 양의 정수만 (`08`·`010` 거부 — 셸 산술이 8진수로 읽는다)
- `NGINX_BACKEND_UPSTREAM`: `http://호스트[:포트]` 만. 경로(끝의 `/` 포함)를 붙이면 모든 `/api` 요청이 그 경로 하나로 가므로 거부한다
- **외부 리버스 프록시 요구사항** (클라이언트 → 외부 프록시 → nginx → 백엔드 — 이 compose 의 운영 경로는 이것뿐이다.
frontend 는 호스트 포트를 열지 않고 TLS 도 하지 않는다):
- **`X-Forwarded-For` 를 이어 붙이거나 `$remote_addr` 로 새로 넣는다.** 클라이언트가 보낸 값을 그대로 넘기면 백엔드가 보는 IP 가 위조된다
(로그인 실패 한도가 IP 기준이다).
- **`TRUST_PROXY`**: proxy 네트워크를 이 프로젝트의 외부 프록시만 쓰면 홉 수 `2`.
**다른 프로젝트와 함께 쓰면 주소 목록을 권장한다** — `<EDGE_NETWORK_SUBNET>,<외부 프록시 IP>` (예: `172.16.72.0/24,172.20.0.10`).
홉 수는 "오른쪽에서 몇 번째 주소"만 믿으므로, 같은 네트워크의 다른 컨테이너가 외부 프록시를 건너뛰고 frontend 에 바로 붙으면
`X-Forwarded-For` 를 꾸며 IP 를 바꿀 수 있다. 외부 프록시 IP 는 frontend 가 보는 주소(proxy 네트워크 안)이며 고정해 둔다(`ipv4_address` 등).
- **본문 한도는 `MAIL_ATTACHMENT_MAX_SIZE_MB` + 1MB 이상**, 읽기 시간 제한은 150s 이상. 작으면 백엔드에 닿기 전에 413 이 나고
사용자에게는 `FILE_002` 로 보인다 (프론트 nginx 는 자동으로 맞지만 외부 프록시는 직접 맞춘다).
- **실시간 알림 `/api/mail-events` 를 흘려보낸다** (운영 확인 항목, 계약 2절 "실시간 알림"):
- 응답 버퍼링을 끈다 — nginx 계열(nginx · Nginx Proxy Manager · OpenResty)은 우리가 넘기는 `X-Accel-Buffering: no` 를 기본으로 따른다
(`proxy_ignore_headers` 로 이 머리글을 무시하게 했다면 `proxy_buffering off` 를 직접 건다). 다른 제품은 `text/event-stream` 을 바로 흘려보내는지 확인한다.
- `text/event-stream` 은 압축하지 않는다 — nginx 의 gzip 은 백엔드의 `Cache-Control: no-transform` 을 보지 않는다.
- 유휴(읽기) 제한은 ping 25초보다 넉넉하면 된다(위 "150s 이상"이면 충분). 응답 **전체** 시간에 상한을 두는 설정(요청 최대 시간 · 일부 로드밸런서의 고정 제한)이
있으면 1시간 + 여유(3900s) 이상 — 짧으면 그 주기로 끊겨 다시 잇기를 되풀이한다(동작은 하지만 그때마다 잠시 1분 확인으로 돌아간다).
- 클라이언트가 끊으면 우리 쪽 연결도 바로 닫는다(nginx 기본 `proxy_ignore_client_abort off`). 남은 연결이 쌓이면 직원당 5개 한도로 429 `REQ_001` 이 난다.
- 클라이언트 쪽(TLS 를 끝내는 곳)은 HTTP/2 를 권한다 — HTTP/1.1 이면 브라우저의 주소당 연결 6개 가운데 하나를 1시간 차지한다. 외부 프록시 → frontend:8080 은 HTTP/1.1 그대로.
- 최대 연결 수를 "직원 수 × 브라우저 수 × 2 + 평소 요청"보다 넉넉히 둔다(열린 연결마다 외부 프록시도 연결 둘을 잡는다).
- **IPv6 를 받는지 확인한다** (운영 확인 항목). 백엔드는 IPv6 클라이언트를 /64 단위로 묶어 센다 — 외부 프록시가 IPv6 를 받는다면
`X-Forwarded-For` 에 원래 IPv6 주소가 그대로 들어가는지 본다.
- **접근 로그에 쿼리를 남기지 않는다** (운영 확인 항목). 화면 주소(`/mail/search?q=…` · `?draft=<id>`)와 API 주소(`/api/messages?q=…` ·
`/api/recipient-suggestions?q=…`)에 검색어 · 임시 저장 id 가 들어간다 — 프론트 nginx 와 같은 기준([[logging]]).
화면의 `Referrer-Policy: strict-origin` 덕분에 Referer 로는 새지 않지만, 새로고침 · 직접 들어온 요청 줄은 외부 프록시가 그대로 받는다.
- 업로드 중 임시 디스크: 백엔드는 첨부를 `/tmp/arasystem-mail-uploads`(컨테이너 쓰기 계층)에, nginx 는 요청 본문을 자기 임시 폴더에 받는다.
필요한 공간은 동시 업로드 수 × 20MB 정도. backend 를 `read_only` 로 바꾸면 그 경로에 **tmpfs 가 아닌 볼륨**을 붙인다 (tmpfs 는 메모리 한도에 잡힌다).
- **업로드 파일은 named volume `<PROJECT_NAME>_uploads` 에 쌓인다** (`/api/files`, 메일 서명 그림은 그 안의 `mail-signatures/` — 서명 표와 함께 백업한다). 호스트 디렉터리(`./data/uploads`)를 쓰지 않는 이유 —
`sudo docker compose` 가 없는 디렉터리를 root 소유로 만들어 backend(node, UID 1000)가 쓰지 못한다. 새 named volume 은 이미지의
`/app/uploads`(node 소유) 소유자를 물려받는다 (2026-10-27 운영 이미지로 확인). 개발만 `./data/uploads` 바인드 마운트다.
백업은 메일 볼륨과 같은 방식: `sudo docker run --rm -v "${PROJECT_NAME}_uploads:/uploads:ro" -v "$PWD/backup:/backup" alpine tar czf "/backup/uploads-$(date +%Y%m%d).tar.gz" -C / uploads`.
---
## 프론트엔드는 호스트에서 (개발 환경)
@@ -49,6 +153,9 @@ sudo docker compose --env-file .env.production logs -f backend
cd frontend && npm install && npm run dev # → http://localhost:5173
```
- **호스트 Node 는 24 LTS 를 권장한다** — 이미지·CI(`24.22.1` 고정, 아래 규칙 "Node 버전")와 같은 메이저다. 프론트 `engines` 가 `^20.19.0 || >=22.12.0` 이라
20.19 이상이면 돌아가지만, 버전이 다르면 이미지·CI 에서만 나는 문제를 늦게 발견한다. (메일 setup 스크립트는 `--env-file` 때문에 20.6 이상)
- **포트는 루트 `.env.{APP_ENV}` 가 SSOT 다** ([[env-structure]]). `vite.config.ts` 가 루트 env 를 직접 읽어
`FRONTEND_PORT` 로 열고, `/api` 프록시 대상은 `http://localhost:${BACKEND_PORT}`(백엔드 컨테이너가
호스트에 노출한 포트)로 잡는다. 포트를 바꿀 때 루트 env 한 줄만 고치면 된다.
@@ -97,7 +204,8 @@ dc ps
# 종료 — 컨테이너 중지·삭제 (DB/Redis 데이터 볼륨은 보존)
dc down
# ⚠️ 데이터까지 삭제 — DB/Redis 볼륨 제거 (복구 불가, 신중히)
# ⚠️ 데이터까지 삭제 — 명명 볼륨 제거 (복구 불가, 신중히)
# 메일 볼륨(<PROJECT_NAME>_mail-config·_mail-data)이 지워져 메일함·계정이 전부 사라진다 (운영은 업로드 볼륨 _uploads 도)
dc down -v
# 특정 서비스만 재빌드·기동 (예: 의존성 바뀐 backend)
@@ -134,8 +242,13 @@ docker compose -f docker-compose.runner.yml --env-file .env up -d
```
- 러너가 없으면 워크플로는 **"Waiting for a runner"** 로 대기만 한다.
- compose 프로젝트 이름은 `RUNNER_NAME` 이다(`name:`) — 폴더 이름 `ci` 로 두면 같은 템플릿을 쓰는 다른 프로젝트의 러너와 겹쳐 `up` 이 그쪽 컨테이너를 다시 만든다.
같은 PC 에 러너가 여럿이면 `RUNNER_NAME` 을 겹치지 않게 짓는다(이 저장소는 `arasystem-runner`).
- `RUNNER_LABELS` 의 라벨이 워크플로의 `runs-on` 과 **일치해야** 잡을 집는다.
- 러너는 도커 소켓에 접근하므로 사실상 호스트 관리자 권한을 갖는다 — 신뢰할 수 있는 워크플로만 실행되게 한다.
- `ci.yml` 의 **infra 잡**(compose `config -q` · `docker build --check` · nginx 단계 `nginx -t`)은 잡 컨테이너 안에서 docker 를 쓴다.
act_runner 기본 설정(`container.docker_host` 비움)이 호스트 도커 소켓을 잡 컨테이너에 붙여 주고, 잡 이미지 `gitea/runner-images:ubuntu-latest` 에
docker CLI·compose 가 들어 있다 (2026-10-27 확인: docker 29.7, compose 5.4). 러너 설정에서 `docker_host: "-"` 로 소켓을 막았다면 이 잡만 실패한다.
설치·문제 해결은 [`ci/README.md`](../../../ci/README.md) 참조.
@@ -164,8 +277,70 @@ dc --profile redis up -d
---
## 메일 (Stalwart · Mailpit) — profile `mail`
직원 로그인 = 메일 계정이라 **예시 env 는 개발·운영 모두 `COMPOSE_PROFILES=mail`** 이다.
구조·운영 배포·도메인 전환 절차는 [`mail/README.md`](../../../mail/README.md) 가 SSOT 다.
```bash
# 처음 띄운 뒤 한 번 (env 를 바꿨을 때도) — 여러 번 실행해도 안전하다
node --env-file=.env.development mail/scripts/setup.mjs
```
- 첫 실행은 bootstrap 후 **mail 컨테이너를 스스로 재시작**한다. 셸에 `APP_ENV` 등이 남아 env 파일 값과 다르면 중단한다
(Node 의 `--env-file` 은 기존 환경변수를 덮어쓰지 않는다).
- 개발: 관리 화면 `http://localhost:${MAIL_ADMIN_PORT}/admin/`, Mailpit `http://localhost:${MAILPIT_UI_PORT}` (둘 다 127.0.0.1 한정).
setup 이 외부 발송 경로를 Mailpit 으로 돌리고 리로드한 **뒤에야** 외부 주소 메일이 Mailpit 에 쌓인다 — 그 전에는 실제로 발송된다.
- 백엔드는 `MAIL_JMAP_URL`(http://mail:8080)·`MAIL_DOMAIN`·`MAIL_GATEWAY_USER`·`MAIL_GATEWAY_PASSWORD`·`MAIL_ATTACHMENT_MAX_SIZE_MB`(기본 20)·
`MAIL_ATTACHMENT_TOTAL_MAX_SIZE_MB`(기본 25) 를 compose 로 받는다.
- 데이터는 named volume `<PROJECT_NAME>_mail-config`·`<PROJECT_NAME>_mail-data` (UID 2000 소유가 필요해 바인드 마운트를 쓰지 않는다).
- setup 이 잠그는 것: 공개 443 의 JMAP·관리 API·웹 포털(→ 403, MTA-STS 정책 파일만 연다), 직원 역할의 계정 목록(Principal) 조회.
- setup 은 엔진의 받는 쪽 한도 "같은 보낸 도메인 → 한 받는 사람"(기본 시간당 25)을 **인증 없는 세션에만** 건다 — 사내 메일은 보낸 도메인이 같아 동료 모두 합쳐
한 사람에게 25통을 넘으면 웹메일 발송이 거절됐다(2026-10-28). 보낸 IP 마다 초당 연결 5개 한도는 그대로. 한도 값 · 직원마다 내보내는 양의 한도는 운영 전에 정한다 (mail/README.md "엔진 한도").
신뢰 대역은 고정한 app-network 서브넷(`APP_NETWORK_SUBNET`) 안으로만 받는다 — 더 넓으면 실패한다.
- setup 은 **바꾸기 전에** 앱 네트워크 서브넷·구성원(frontend 등이 붙어 있지 않은지)·백엔드 주소를 확인하고, 어긋나면 아무것도 바꾸지 않고 멈춘다.
**운영은 docker 를 쓸 수 있는 사용자로 호스트에서 돌린다** (docker 그룹 또는 `sudo -E`) — docker 가 없으면 바로 실패한다.
- 운영의 복구 관리자(`MAIL_RECOVERY_ADMIN`)는 `.env.production` 에 두지 않고 셸 일회성 override + `setup.mjs --recovery` 로만 쓴다
(파일에 있거나, 끝난 뒤 mail 컨테이너에 남아 있으면 setup 이 실패한다 — 절차는 mail/README.md "운영 배포").
- 영구 관리자 아이디는 `MAIL_ADMIN_LOCAL_PART`(기본 `admin`) — setup 과 백엔드가 함께 읽는다 (compose 가 backend 에 넘긴다).
**bootstrap 전에만 정할 수 있다** — 바꾼 값이 엔진의 관리자와 다르면 setup 이 멈춘다. 다른 관리자 계정은 `MAIL_BLOCKED_ACCOUNTS` 에 넣는다.
- ⚠️ **게이트웨이 비밀번호 = 관리자급 비밀.** Stalwart 0.16 은 대리 접근 대상을 제한하지 못해, 신뢰 대역 안에서는 게이트웨이로 관리자 계정도
대리 접근된다 (setup 이 매번 `[주의]` 로 보여 준다). 막는 일은 백엔드가 한다 — mail/README.md "보안".
---
## 규칙
- **이미지 버전 고정 — 떠 있는 태그(`stable-alpine`·`v0.16`·`24-alpine`)를 쓰지 않는다.** 그날의 최신이 들어오면 호스트마다 버전이 달라지고,
설정이 기대하는 기능이 없는 옛 이미지가 남은 호스트에서는 기동에 실패한다 (예: nginx.conf 의 `add_header_inherit` 는 1.29.3 이상).
| 이미지 | 지금 값 | 함께 고치는 곳 (값이 모두 같아야 한다) |
| --- | --- | --- |
| Node | `node:24.22.1-alpine3.24` | 아래 "Node 버전" |
| nginx (운영 frontend) | `nginxinc/nginx-unprivileged:1.30.6-alpine3.24` | `frontend/Dockerfile` 의 `FROM … AS nginx-config`, 이 문서. 1.29.3 아래로 내리지 않는다 (`add_header_inherit`) |
| Stalwart (mail) | `stalwartlabs/stalwart:v0.16.27` | 루트 `.env.development.example`·`.env.production.example` 의 `MAIL_IMAGE`(각자의 로컬 `.env.*` 도), `mail/README.md` 머리말, 이 문서. `mail/scripts/setup.mjs` 는 0.16 의 관리 API·bootstrap 동작에 맞춰져 있다 — 마이너(0.17 등)를 올리면 개발에서 setup 처음 설치·재실행과 웹메일을 다시 확인한다 |
| Mailpit (개발) | `axllent/mailpit:v1.32.0` | `.env.development.example` 의 `MAILPIT_IMAGE` |
| CI 러너 | `gitea/act_runner:0.6.1` | `ci/docker-compose.runner.yml`. 잡 이미지(`RUNNER_LABELS` 의 `gitea/runner-images:ubuntu-latest`)는 각자의 `ci/.env` 값이라 아직 떠 있는 태그다 |
- **갱신 규칙**(공통): 일반 패치는 **배포 후 2주가 지나면** 올린다 (공급망 위험 — 갓 나온 버전을 피한다). **보안 릴리스는 CI 가 통과하는 대로** 올린다.
새 태그가 있는지와 digest 는 `docker buildx imagetools inspect <이미지>:<태그>` 로 확인한다 (2026-10-27: Stalwart `v0.16`·`v0.16.27`, nginx `stable-alpine`·`1.30.6-alpine3.24` 가 각각 같은 digest).
- `MAIL_IMAGE` 를 바꾸면(같은 digest 라도 태그 문자열이 다르면) 다음 `up` 에서 mail 컨테이너가 다시 만들어진다 — 데이터는 named volume 이라 그대로다.
- **Node 버전 — 24 LTS 를 패치까지 고정한다 (지금 `24.22.1`, 이미지 `node:24.22.1-alpine3.24`).**
메이저 태그(`node:24-alpine`)는 빌드할 때마다 그날의 최신 패치를 받아 갓 나온 버전이 그대로 들어오고, 이미지와 CI 의 패치가 어긋날 수 있다.
- **함께 고치는 곳** (값이 모두 같아야 한다):
- `backend/Dockerfile` 의 `ARG NODE_VERSION` (builder·production 이 함께 쓴다)
- `frontend/Dockerfile` 의 `ARG NODE_VERSION` (build-stage)
- `.github/workflows/ci.yml` 의 `env.NODE_VERSION` (backend·frontend·error-codes-sync 세 잡이 함께 쓴다)
- `mail/README.md` 의 운영 setup (B) 가 받는 공식 Node 배포 파일 (`node-v<버전>-linux-x64.tar.xz`)
- 이 문서 (위 버전 표기, "프론트엔드는 호스트에서"의 호스트 권장 버전)
- 메이저를 바꾸거나 alpine 이 3.24 에서 바뀌면 Dockerfile 두 개의 `-alpine3.24` 도 함께 고친다
(새 태그가 있는지 `docker pull node:<버전>-alpine<알파인>` 으로 먼저 확인한다)
- **갱신 규칙**: 일반 패치 릴리스는 **배포 후 2주가 지나면** 올린다 (공급망 위험 — 갓 나온 버전을 피한다).
**보안 릴리스는 CI 가 통과하는 대로** 올린다 (알려진 취약점 수정이 2주 대기보다 우선한다).
릴리스 날짜·보안 여부는 <https://nodejs.org/dist/index.json> 의 `date`·`security` 로 확인한다.
24.22.1 은 2026-10-20 보안 릴리스라 보안 릴리스 규칙으로 바로 고정했다.
- 올린 뒤에는 운영 이미지 두 개 빌드, CI 와 같은 명령(아래 "의존성은 컨테이너 안에서"처럼 임시 폴더 복사본에서 lint·type-check·test·build),
dev 재빌드(`dc up -d --build -V`)로 확인한다.
- **빌드 최소화**: 의존성(package.json / pubspec 등) 변경이 없으면 `--build` 없이 기동한다.
- **의존성을 바꿨다면 `--renew-anon-volumes` 를 함께 준다.**
dev 는 `/app/node_modules` 를 익명 볼륨으로 마운트하는데, `--build` 만으로는 **예전 볼륨이 그대로 재사용된다.**
@@ -174,6 +349,51 @@ dc --profile redis up -d
dc up -d --build --renew-anon-volumes backend
```
(`-V` 는 같은 옵션의 축약형. DB 볼륨은 named volume 이라 영향받지 않는다)
- **의존성은 컨테이너 안에서, npm 은 `11.6.2` 로** ([[nestjs]] 의 Windows 주의 항목).
lock 을 바꾸는(`npm install`/`uninstall`) npm 과 검사하는(`npm ci` — 이미지 빌드·CI) npm 을 같은 버전으로 고정했다.
npm 은 버전마다 lock 의 중첩 항목(chokidar·readdirp·@emnapi 등)을 다르게 정리해서, 10.x·11.6·11.2x 를 섞어 쓰면 lock 이 매번 바뀐다.
- 컨테이너(`dc exec backend npm …`)는 이미지에 고정된 버전이다. 호스트에서 lock 을 만질 일이 있으면 `npm i -g npm@11.6.2` 로 맞춘다.
- 바꿀 때는 `backend/Dockerfile`·`frontend/Dockerfile` 의 `ARG NPM_VERSION`, `.github/workflows/ci.yml` 의 `NPM_VERSION`, 이 문서를 **함께** 고친다.
- ⚠️ **형식만 맞추려고 lock 을 통째로 다시 쓰지 않는다** (`npm install --package-lock-only`).
2026-10-26 backend 에서 npm 11.6.2 로 이렇게 다시 쓰자 `@emnapi/core`·`@emnapi/runtime` 이 빠지면서
`npm ci` 가 `Invalid: lock file's @emnapi/wasi-threads@1.2.1 does not satisfy @emnapi/wasi-threads@1.2.3` 로 실패했다(되돌림).
lock 은 의존성을 실제로 바꿀 때만 컨테이너 안 `npm install <패키지>` 로 고치고, 고친 뒤에는 **임시 폴더에 복사해 `npm ci`** 로 확인한다
(저장소 폴더를 그대로 마운트하면 호스트의 Windows `node_modules` 를 읽어 엉뚱하게 실패한다).
- ⚠️ **2026-10-27 에는 컨테이너의 `npm install <패키지>` 도 같은 항목을 지웠다** — backend 에 `htmlparser2@^12.0.0` 을 직접 의존성으로 더하자
`@emnapi/core`·`@emnapi/runtime` 과 중첩 `chokidar` 가 빠져 `npm ci` 가 위와 같은 오류로 실패했다.
그 패키지는 이미 lock 에 같은 판으로 있었으므로(sanitize-html 의 의존성) npm 이 루트 `dependencies` 에 더한 한 줄만 원래 lock 에 옮기고,
임시 복사본에서 `npm ci`·`npm ci --omit=dev` 와 `require` 로 확인했다 — **lock 에 없던 패키지에는 쓸 수 없는 방법**이다.
- **원인과 해결 (2026-10-27)**: npm 11.6.1~11.6.2(arborist 9.1.5~9.1.6)의 버그다. `npm ci` 는 lock 의 의존 표시를 그대로 믿지만
`npm install` 은 늘 다시 계산하는데, 들어오는 peer 간선이 하나라도 있는 패키지를 일반 의존성이어도 `peer` 로 남기고 `peer`+`optional` 인 패키지를 모두 지운다.
jest 가 쓰는 `unrs-resolver@1.12.x` 가 그 모양이다 — 선택 설치인 wasm32 바인딩의 일반 의존성 `@emnapi/core`·`@emnapi/runtime` 이
`@napi-rs/wasm-runtime@^1.1.4` 의 필수 peer 이기도 하다. 그래서 backend lock 의 `unrs-resolver` 를 **1.11.1** 로 내렸다
(바인딩이 peer 없는 `@napi-rs/wasm-runtime@0.2.12` 를 쓴다. 개발 전용, `npm audit` 결과 같음).
- **npm 이 11.6.2 인 동안 `unrs-resolver` 를 1.12.x 로 올리지 않는다** — `npm update` 로 범위 안 최신을 받아도 1.12.2 가 되어 다시 깨진다.
npm 을 11.6.3 이상으로 올리면 원인이 사라진다(그때 lock 의 `peer` 표시가 한 번 크게 바뀌지만 `npm ci` 는 통과한다).
- 중첩 `chokidar@4.0.3`(@angular-devkit 의 optional peer — 루트는 chokidar 5)은 npm 이 판과 무관하게 일부러 지우는 항목이라 lock 에 없다
(`npm ls` 의 chokidar invalid·readdirp extraneous 표시는 정상).
- lock 을 바꾼 뒤에는 임시 복사본에서 `npm ci` 와 함께 **인자 없는 `npm install` 이 lock 을 바이트 그대로 두는지**도 본다.
frontend lock 은 같은 문제가 없다(2026-10-27 확인).
- **dev backend 의 `/app/dist` 는 익명 볼륨이다.** 호스트에서 `npm run build` 를 하면 바인드 마운트된 `backend/dist` 가 지워졌다
다시 생기면서 컨테이너의 Nest watch 가 끊겨 앱이 옛 코드로 계속 돈다 — 그래서 산출물을 호스트와 나눴다.
`nest-cli.json` 의 `deleteOutDir` 는 마운트 지점을 지우려다 EBUSY 로 죽으므로, dev `command` 는 기동 때 볼륨 내용만 비우고
`deleteOutDir` 만 끈 설정 사본으로 `start:dev` 를 돌린다 (`-V` 로 재생성하면 이미지의 산출물로 다시 채워진다).
- **dev backend 메모리 한도는 2g** — watch 컴파일과 타입 검사가 함께 돌아 base 의 1g 에서 OOM 으로 죽었다 (운영은 1g 그대로).
- **dev backend 는 컨테이너를 기동할 때 한 번 `migration:run` 을 한다** (`dc down -v` 등으로 DB 를 새로 만든 뒤에도 바로 뜨게).
watch 재시작은 이 단계를 다시 돌지 않는다 — 그래서 생성만 하고 검토 전인 마이그레이션이 파일을 저장할 때마다 적용되지는 않는다.
⚠️ 다만 **컨테이너를 다시 띄우면** 그때 있는 마이그레이션이 적용된다. 마이그레이션을 고치는 중(revert → 수정 → run)에는
backend 를 재기동하지 말고, 검토를 마친 뒤 재기동한다. 실패하면 기동이 멈춘다(`set -e`) — `dc logs backend` 로 원인을 본다.
운영은 `migrationsRun` 으로 앱이 뜰 때 적용된다.
- **app-network 는 이름(`${PROJECT_NAME}_app-network`)과 서브넷(`APP_NETWORK_SUBNET`, 기본 `172.16.71.0/24`)을 고정한다.**
메일 엔진이 이 대역만 신뢰하기 때문이다. 그래서 **backend·db·redis·mail(개발은 mailpit 도)만 붙인다** — frontend 는 `edge`
(`${PROJECT_NAME}_edge`, `EDGE_NETWORK_SUBNET` 기본 `172.16.72.0/24`)로만 backend 에 닿는다. 새 서비스를 붙일 때도 신뢰 대역에 넣어도 되는지부터 본다.
- mail setup 이 검사하는 서브넷 조건: 사설 대역, 네트워크 주소로, 접두 길이 `/24`~`/28`. 넓을수록 실제 클라이언트 주소(사무실 LAN·VPN)와 겹쳐
그 접속까지 믿게 될 위험이 커지고, `/28` 보다 좁으면 컨테이너 주소가 모자란다 (근거: `setup.mjs` 의 `APP_NETWORK_PREFIX_RANGE`).
- 기본값은 도커 기본 주소 풀 밖이라 자동 배정 네트워크와 겹치지 않지만, 같은 호스트의 다른 프로젝트가 같은 값을 쓰면
`up` 이 "Pool overlaps" 로 실패한다 → 다른 /24 로 바꾸고 mail setup 을 다시 실행한다 (`EDGE_NETWORK_SUBNET` 도 같다).
⚠️ 서브넷을 처음 고정하거나 바꿀 때, 네트워크 구성을 바꿀 때는 **`dc down` 후 `dc up -d`** (또는 `dc up -d --force-recreate`)로 전부 다시 만든다.
그냥 `up -d` 만 하면 네트워크는 새로 만들어지지만 재생성되지 않은 컨테이너가 서비스 이름(`db`·`mail` 등) 별칭을 잃어
backend 가 `getaddrinfo ENOTFOUND db` 로 뜨지 않는다 (2026-10-26 개발 환경에서 겪음).
- **env 파일**: 실행 전 아래 두 파일이 모두 있어야 한다. 없으면 `*.example` 을 복사해 생성한다.
- 루트 `.env.{APP_ENV}` — 설정 SSOT ([[env-structure]])
- `backend/.env.{APP_ENV}` — compose `env_file` 대상이라 **비어 있어도 존재해야 한다**
@@ -188,5 +408,12 @@ dc --profile redis up -d
- 프론트엔드 운영 컨테이너는 `nginx-unprivileged` 라 **8080** 포트를 쓴다 (`expose`·healthcheck·nginx.conf 가 모두 8080).
- 프론트엔드 dev 컨테이너는 vite 를 **5173** 으로 띄운다 (비-root 는 80 포트 바인드 불가).
- 포트를 바꿀 때는 Dockerfile `EXPOSE` · `nginx.conf` 의 `listen` · compose `expose`/`healthcheck` 를 **함께** 고쳐야 한다.
- **DB/Redis 호스트 포트**: dev 에서만 열며 `127.0.0.1` 로 한정한다. 접두어 없이 쓰면 LAN 전체에 노출된다 ([[security]]).
- **DB/Redis·backend·frontend(dev 컨테이너) 호스트 포트**: dev 에서만 열며 `127.0.0.1` 로 한정한다. 접두어 없이 쓰면 LAN 전체에 노출된다 ([[security]]).
backend(3000·3001)는 호스트의 vite 개발 서버·도구만 붙으면 된다 — Android 에뮬레이터의 `10.0.2.2` 는 호스트 127.0.0.1 로 가서 그대로 되고,
같은 LAN 의 실기기는 닿지 않는다. 운영 backend 는 호스트 포트를 열지 않는다(프론트 nginx 의 `/api` 로만).
profile `frontend` 의 vite 컨테이너도 `127.0.0.1:${FRONTEND_PORT}` 다 (호스트 vite 와 같은 범위).
- **메일 포트**: 8080(HTTP JMAP·관리 API)은 어떤 환경에서도 공개하지 않는다 (dev 는 `127.0.0.1:${MAIL_ADMIN_PORT}` 만).
운영의 25·465·993·443 은 `0.0.0.0`(IPv4)로만 바인딩한다 — IPv6 접속은 docker-proxy 가 중계해 도커 게이트웨이 주소로 보이고,
그러면 신뢰 대역으로 취급되어 자동 차단·게이트웨이 IP 제한이 무력화된다. dev 오버라이드는 `ports: !override` 로 운영 포트를 지운다.
- **Mailpit**: 이미지 기본이 root 라 `user: "65534:65534"` 로 내린다. dev 전용(운영 compose 에 없다).
- **개발/운영 분리**: 개발은 `docker-compose.yml + docker-compose.dev.yml`, 운영은 `docker-compose.yml` 단독 + `.env.production`.
+81 -18
View File
@@ -17,32 +17,48 @@ description: NestJS 백엔드(backend/) 개발 규칙 — 컨트롤러/서비스
| --- | --- | --- |
| **에러 코드 레지스트리** | `src/common/errors/error-codes.ts` | 코드↔메시지↔HTTP 상태 **SSOT**. `npm run sync:error-codes` 로 클라이언트 사전 생성 |
| **BusinessException** | `src/common/errors/business.exception.ts` | 에러 코드가 **생성자 필수 인자** — 코드 없는 실패를 만들 수 없다 |
| 표준 응답 인터셉터 | `src/common/interceptors/transform.interceptor.ts` | main.ts 전역 등록 — 컨트롤러는 raw 데이터만 반환. `@SkipTransform()` 으로 제외 |
| 표준 응답 인터셉터 | `src/common/interceptors/transform.interceptor.ts` | `app.setup.ts` 에서 전역 등록 — 컨트롤러는 raw 데이터만 반환. `@SkipTransform()` 으로 제외 |
| 전역 예외 필터 | `src/common/filters/http-exception.filter.ts` | 레지스트리 조회로 `{success:false,error:{code,message,traceId}}` 생성 ([[error-codes]]) |
| 요청 추적 미들웨어 | `src/common/middleware/request-id.middleware.ts` | `x-request-id` 발급 → 로그·응답 `traceId` ([[logging]]) |
| 요청 추적 미들웨어 | `src/common/middleware/request-id.middleware.ts` | `x-request-id` 발급 → 로그·응답 `traceId` ([[logging]]). `configureApp()` **맨 앞**의 express 미들웨어라 본문 파서 오류(413·400·415)에도 붙는다. 들어온 값은 `^[A-Za-z0-9._-]{1,128}$` 일 때만 이어받고 아니면 새로 발급(로그 위조 방지) |
| Swagger 래퍼 데코레이터 | `src/common/decorators/api-standard-response.decorator.ts` | `ApiStandardResponse` / `ApiErrorResponse` — 문서를 실응답과 일치시킨다 |
| 페이지네이션 DTO | `src/common/dto/pagination.dto.ts` | 커서/오프셋 쿼리 + `PageInfoDto` + `toPaginatedResult()` |
| 전역 ValidationPipe | `main.ts` | `whitelist/forbidNonWhitelisted/transform` |
| Swagger | `main.ts` → `/api-docs` | 모든 엔드포인트 데코레이터 |
| 보안 미들웨어 | `main.ts` | helmet · CORS 화이트리스트 |
| Rate Limiting | `app.module.ts` 의 `ThrottlerGuard`(APP_GUARD) | 초과 시 429 → `REQ_001`. 루트 `.env` 의 `THROTTLE_*` 로 조정 |
| 전역 HTTP 설정 | `src/app.setup.ts` 의 `configureApp()` | prefix · helmet · cookie-parser · JSON 본문 파서(1mb — 발송 `POST /api/messages` 와 임시 저장 `POST /api/drafts`·`PUT /api/drafts/{id}` 만 4mb(`SEND_MESSAGE_JSON_BODY_LIMIT`, 앞단 nginx 와 같은 값), JSON 만 · **UTF-8 만**. `express.json` 두 개를 직접 붙인다 — 읽기 전 확인 `verify` 를 쓰려고(Nest 의 `useBodyParser` 는 verify 를 받지 않는다)) · 모양 한도 `rejectOversizedJsonBody`(아래 행) · ValidationPipe(`createValidationPipe()` — `whitelist/forbidNonWhitelisted/transform/stopAtFirstError`) · 필터 · 인터셉터. **main.ts 와 e2e 가 같은 함수를 쓴다** (앱은 `bodyParser: false` 로 생성) |
| **JSON 본문 모양 한도** | `src/shared/utils/json-shape.ts` + `app.setup.ts` 의 `JSON_SHAPE_LIMITS` | 배열 하나 1,000 · 값(객체 · 배열 · 원시) 10,000 · 깊이 32. 읽기 전(UTF-8 바이트를 한 번 훑는다 — `verifyJsonBody`, 재귀 없이 넘는 순간 멈춘다)과 읽은 뒤(`rejectOversizedJsonBody`) 두 번 보고, 넘으면 DTO 변환 · 검사 전에 400 `VAL_001`(본문 파서는 가드보다 앞 — 로그인 전 요청도 400). DTO 의 개수 한도로는 막지 못해 둔 것이다(2026-10-28 리뷰 — 1MB 에 작은 객체 8만 개로 7.8초 · +329MB, 깊은 중첩은 500). 새 계약의 본문이 한도를 넘으면 여기부터 올린다 ([[security]]) |
| Swagger | `main.ts` → `/api-docs` | 모든 엔드포인트 데코레이터. 인증은 쿠키(`addCookieAuth('access_token')`)만 등록 — Authorization 헤더 인증은 없다 |
| 보안 미들웨어 | `app.setup.ts`(helmet · `TRUST_PROXY`) · `main.ts`(CORS 화이트리스트 — 운영은 `CORS_ORIGIN` 만) | 클라이언트 IP 는 `TRUST_PROXY`(홉 수 또는 IP·CIDR 목록, 운영 필수) 기준 — `shared/utils/trust-proxy.ts` |
| **출처 검사(CSRF)** | `src/common/guards/origin.guard.ts` (APP_GUARD) | 상태 변경 요청은 `Sec-Fetch-Site`(same-origin·none) 또는 허용 목록(`CORS_ORIGIN`)의 `Origin` 이어야 통과, 아니면 `AUTH_003`. 둘 다 없으면 통과. 규칙 `shared/utils/request-origin.ts` |
| **인증 가드** | `src/common/guards/jwt-auth.guard.ts` | `JwtAuthGuard` — 액세스 토큰 쿠키 검증, 실패는 `AUTH_001`. 전략은 `modules/auth/jwt.strategy.ts` |
| 현재 사용자 데코레이터 | `src/common/decorators/current-user.decorator.ts` | `@CurrentUser()` → `AuthenticatedUser { email, name }` |
| 길이 검사 데코레이터 | `src/common/decorators/code-units.decorator.ts` | `MaxCodeUnits` · `MinCodeUnits` — JS 문자열 길이(UTF-16 코드 단위)로 센다(웹의 `.length` 와 같다). class-validator 의 `MinLength`·`MaxLength` 는 서로게이트 쌍 · 이모지를 덜 세어 클라이언트가 센 길이와 어긋난다 — 클라이언트와 같은 한도를 거는 문자열은 이것을 쓴다 |
| 공백 · 중복 데코레이터 | `src/common/decorators/trim-string.decorator.ts` · `array-distinct.decorator.ts` | `TrimString()` — 앞뒤 공백을 지운 값으로 검사하고 그 값으로 저장한다(메일함 이름 · 필터의 이름 · 찾을 글 · 전달 주소). `ArrayDistinct(options)` — 배열 항목이 겹치지 않는지 Set 으로 한 번 훑는다(선형 — 원시 값은 값으로, 객체는 같은 객체일 때만. 객체 배열의 id 중복은 서비스가 Set 으로 본다 — 예: 필터 규칙 `findFilterRulesProblem`). class-validator 의 `ArrayUnique` 는 `indexOf` 비교라 항목 수의 제곱이다 — **쓰지 않는다** |
| 직원 인증 | `modules/auth` | 쿠키 JWT 로그인·`me`·`refresh`·`logout` (계약 `docs/mail/api-contract.md` 1절) · 비밀번호 변경 `PUT /api/auth/password`(4단계 — 직원 본인 자격으로 엔진의 `x:AccountPassword/set`, 로그인과 같은 실패 카운터 · 진행 중 잠금, 바뀌면 이 요청의 세션만 남기고 그 직원의 세션을 끊는다 — 결과를 모르는 실패에도 끊는다). 설정 `auth.settings.ts`, 쿠키 `auth-cookies.ts`, 로그인 실패 횟수 `login-attempt.service.ts`, **로그인 세션(DB)** `auth-session.entity.ts`·`auth-session.policy.ts`(1회용 회전·재사용 감지)·`auth-session.cleanup.ts`(끝난 세션 정리 cron). **메일 엔진 관리자 차단** — 정적 목록(`employee-address.ts` + `MAIL_BLOCKED_ACCOUNTS`)과 로그인·리프레시 때 엔진 권한 확인(`jmap/jmap.client.ts`) 두 겹 |
| 메일 (웹메일 API) | `modules/mail` | 엔진 포트 `mail.provider.ts`(`MAIL_PROVIDER`) + Stalwart JMAP 어댑터 `jmap/`, 받은 메일 HTML 정화 `html/sanitize-mail-html.ts`, 보내는 서식 본문 정화(허용 목록) `html/sanitize-outgoing-html.ts`, 두 정화 앞의 사전 검사(길이·파서 스택 비용, 선형 시간) `html/html-parse-budget.ts`, 첨부 형식 `attachment.policy.ts`, 설정 `mail.settings.ts`, 발송 중복 방지(`Idempotency-Key`) `send-idempotency.ts`, 발송의 받는 사람별 결과 `jmap/delivery-status.ts`(제출 다음에 따로 읽는다 — 일부 거절은 201 의 `rejectedRecipients`, 모두 거절이면 보낸 사본을 지우고 `MAIL_010` · `MAIL_011`, 계약 2절 "받는 사람별 결과"), 첨부 임시 디스크 저장 `mail-upload-storage.ts`, 임시 저장 `draft.controller.ts`(엔진 초안 — 새로 만들고 다시 읽은 뒤 옛것을 지운다), 서명 그림 주소 규칙 `html/signature-images.ts`(보낼 때 `cid:` 로), 본문 그림 판정 `html/inline-images.ts`, 받는 사람 추천 `recipient-suggestions.service.ts`(사용자별 10분 캐시) · 순서 `recipient-ranking.ts` · 커서 `recipient-suggestion-cursor.ts`, 검색어 → 엔진 조건 `jmap/search-terms.ts`(한국어 조사 표는 여기 한 곳 — 낱말 안 OR · 낱말끼리 AND, 따옴표 구절), 여러 통 `PATCH /api/messages`(엔진 요청 한 번) · 휴지통에서 영구 삭제 `message-deletion.controller.ts`(휴지통의 메일만, 조건부 지우기) — 본문 `dto/bulk-messages.dto.ts`, 새 메일 확인 `GET /api/mail-state`(`mailbox.controller.ts`), 메일함 관리 · 전체 읽음 · 비우기(`mailbox.controller.ts`, 본문 `dto/mailbox.dto.ts` — 오래 걸리는 처리는 어댑터(`BulkRun`)가 500통씩 · 한 요청 25초 안에서(엔진 호출마다 남은 시간만큼) 하고 남은 수를 돌려준다, 같은 직원 · 같은 메일함은 한 번에 하나(`mail.service.ts` 의 `mailboxOperations` — 진행 중이면 `REQ_001`), 지우기는 안의 메일을 휴지통으로 옮긴 뒤) · 부재중 자동 응답 `MailVacationController`(`dto/mail-vacation.dto.ts` — 6단계부터 저장 자리는 아래 필터의 Sieve 스크립트) · 메일 필터 `mail-filter.controller.ts`(6단계 — `GET`·`PUT /api/mail-filters`, 본문 `dto/mail-filter.dto.ts`, 칸 사이 규칙 `mail-filters.ts`. 엔진 쪽은 Sieve 스크립트 하나(`arasystem` — 필터와 **부재중을 함께**, 엔진의 VacationResponse 는 쓰지 않고 처음 읽을 때만 이어받는다): 저장소 `jmap/jmap-filter-script.ts`(읽은 상태 그대로일 때만 쓰고 켠다 · 그사이 바뀌면 다시 읽기 · 같은 규칙이면 옛 version 이어도 저장), 글 만들기 `jmap/sieve-script.ts`, 머리 주석의 원본(JSON · 판 `FILTER_SOURCE_FORMAT`) `jmap/filter-source.ts`, 부재중 제목 · 본문 인코딩 `jmap/mime-text.ts`, JMAP 결과 읽기 `jmap/jmap-results.ts`) · 원문 `GET /api/messages/{id}/source`(이름 `attachment.policy.ts` 의 `messageSourceFileName`) · 스팸 표시 `isJunk` (계약 2절) · 실시간 알림 `GET /api/mail-events`(5단계 — SSE, `mail-events.controller.ts` · 엔진을 모르는 `mail-events.service.ts`(직원마다 연결 5개 · 25초 ping · 토큰 만료 30초 전 `end` · 종료 때 `end`) · 엔진 쪽 `jmap/jmap-mail-watch.ts`(직원마다 엔진 EventSource 하나 · 75초 무소식이면 다시 잇기) · `jmap/jmap-push.ts`(스트림 읽기) · `jmap/new-mail.ts`(새 메일 거르기), SSE 형식 `shared/utils/sse.ts`. 머리글은 스트림을 열기로 정하면 바로(`flushHeaders`), 처리기에 닿기 전에 끊긴 응답(`res.destroyed`)도 정리한다) |
| 메일 서명 | `modules/mail-signature` | 사용자마다 서명 하나(DB) + 서명 그림. 그림 형식은 내용으로 판별(매직 바이트·머리의 가로·세로, 새 의존성 없음) `signature-image-format.ts`, 파일은 업로드 볼륨의 `mail-signatures/` (`signature-image-storage.ts`), 서명에 붙지 않은 그림 정리 cron `mail-signature.cleanup.ts`. 메일 모듈이 발송·임시 저장 때 `MailSignatureService` 로 그림을 쓴다 (계약 2절 "서명") |
| 사내 목록 | `modules/mail-directory` | 웹메일 로그인에 성공한 직원(관리자·게이트웨이·막은 계정 제외)을 `AuthService` 가 기록 → 받는 사람 추천의 "사내". 엔진의 계정 목록(Principal) 권한은 쓰지 않는다(일부러 막아 두었다) |
| Rate Limiting | `app.module.ts` 의 `ClientIpThrottlerGuard`(APP_GUARD, `common/guards/client-ip-throttler.guard.ts`) | 초과 시 429 → `REQ_001`. 루트 `.env` 의 `THROTTLE_*` 로 조정. `ThrottlerGuard` 를 상속해 tracker 만 바꿨다 — IP 는 `shared/utils/client-ip.ts` 의 `clientIpKey()`(IPv6 는 /64 로 묶음, 로그인 실패 카운터와 같은 함수) |
| 로깅 | `shared/logger/winston.config.ts` | `console.log` 대신 사용 ([[logging]]) |
| env 검증 | `shared/config/env.validation.ts` | 잘못된 설정이면 **기동 실패**(fail-fast) ([[env-structure]]) |
| DB 설정 | `shared/config/database.config.ts` | 앱·CLI 공유 SSOT. `synchronize` 기본 off |
| 마이그레이션 | `src/data-source.ts` + `src/migrations/` | `npm run migration:*` (아래 "DB 마이그레이션") |
| 마이그레이션 | `src/data-source.ts` + `src/migrations/` | `npm run migration:*` (아래 "DB 마이그레이션"). 첫 마이그레이션은 로그인 세션 표(`CreateAuthSessions`). e2e 는 시작할 때 `runMigrations()` 로 표를 만든다 |
| Graceful shutdown | `main.ts` 의 `enableShutdownHooks()` + `shared/logger/shutdown.logger.ts` | SIGTERM 시 요청/커넥션 정리 후 종료 |
| 예제 모듈(참조용) | `modules/user`(DTO+Swagger+페이지네이션) | 새 모듈 패턴 참고. in-memory 데모 저장소 |
| 파일 업로드 | `modules/upload` | 로컬 디스크 저장 + 형식·크기 검증 + 안전한 파일명 생성 ([[security]]) |
| 파일 업로드 | `modules/upload` | **로그인 필요**(`JwtAuthGuard`). 로컬 디스크 저장 + 형식·크기 검증 + 안전한 파일명 생성 ([[security]]). 한글 파일명은 multer `defParamCharset: 'utf8'` |
| 헬스체크 | `modules/health` | `@SkipThrottle`+`@SkipTransform` — 기계가 읽는 엔드포인트 패턴 ([[response-format]]) |
| 순수 유틸 | `shared/utils/` | 순수 함수 분리 위치 |
| 요청 컨텍스트 | `shared/logger/request-context.ts` | 요청마다 추적 ID 를 AsyncLocalStorage 에 둔다 — 필터를 거치지 않는 로그도 `traceTag()` 로 `[traceId]` 를 붙인다 ([[logging]]). 요청이 끝난 뒤에도 이어지는 일(실시간 알림의 엔진 연결 등)은 `runOutsideRequestContext()` 로 연다 — 처음 요청의 추적 ID 를 물려받지 않게 |
| 순수 유틸 | `shared/utils/` | 순수 함수 분리 위치 (예: `mask.ts` 로그용 이메일 마스킹·URL 의 검색어 가리기(`maskUrlForLog` — `q` 와 `cursor`(검색어 해시를 담는다)는 길이만), `settle.ts` 정해진 시간까지만 기다리기, `duration.ts` 기간 파싱, `content-disposition.ts` 다운로드 헤더, `cookies.ts`, `trust-proxy.ts`, `request-origin.ts`) |
> **확장 시 따르는 규칙** (기능이 필요해지면 규칙대로 구현):
> - **인증(JWT)**: `@nestjs/jwt`·`@nestjs/passport` 설치 → `common/guards/` 생성 → AUTH_001/AUTH_003 흐름 검증
> - **보호 라우트**: 인증은 구현되어 있다 (`modules/auth` + `common/guards/jwt-auth.guard.ts`). 새 보호 라우트는 컨트롤러·핸들러에
> `@UseGuards(JwtAuthGuard)` + `@ApiCookieAuth()` 를 걸고 `@CurrentUser()` 로 직원을 받는다 (전역 가드가 아니다 — `users`·`health` 예제는 공개, `upload` 는 로그인 필요).
> 권한(AUTH_003) 구분이 필요해지면 역할 가드를 `common/guards/` 에 추가한다.
> 반면 출처 검사(`OriginGuard`)는 **전역**이다 — 웹훅처럼 다른 사이트가 정당하게 부르는 엔드포인트가 생기면 예외 표시를 따로 만든다
> - **메일 엔진 교체**: `modules/mail/mail.module.ts` 의 `MAIL_PROVIDER` 구현(useClass)만 바꾼다 — 서비스·컨트롤러는 포트만 안다
> - **DB 영속화**: TypeORM 엔티티/리포지토리 추가 (예제의 in-memory 저장 대체).
> `app.module.ts` 는 이미 `TypeOrmModule.forRootAsync` 로 연결되어 있다.
> 스키마는 **마이그레이션으로 관리**하며 `synchronize` 는 기본 off 다 (아래 "DB 마이그레이션" 참조)
> - **도메인 에러 코드**: `error-codes.ts` 의 `DOMAIN_ERROR_CODES` 에 등록 → `npm run sync:error-codes`
> - `common/{guards,pipes}/` 는 해당 관심사 등장 시 생성 (아래 "모듈 구조"는 목표 구조)
> - `common/pipes/` 는 해당 관심사 등장 시 생성 (`common/guards/` 에는 인증 가드가 있다. 모듈 전용 파이프는 모듈 안에 둔다 — 예: `modules/mail/mail-id.pipe.ts`).
> 아래 "모듈 구조"는 목표 구조
---
@@ -127,7 +143,7 @@ export class TransformInterceptor<T> implements NestInterceptor {
}
```
- `main.ts`에서 전역 등록: `app.useGlobalInterceptors(new TransformInterceptor())`
- `app.setup.ts` 의 `configureApp()` 에서 전역 등록 (main.ts·e2e 공용): `app.useGlobalInterceptors(new TransformInterceptor(app.get(Reflector)))`
- 실패 응답(`success: false`)은 아래 Exception Filter가 생성한다.
- 응답 포맷 SSOT는 [[response-format]] 참조.
@@ -168,7 +184,7 @@ const config = new DocumentBuilder()
.setTitle('API 문서')
.setDescription('프로젝트 API 명세서')
.setVersion('1.0')
.addBearerAuth()
.addCookieAuth(ACCESS_TOKEN_COOKIE) // 인증은 HttpOnly 쿠키뿐 — 보호 라우트에 @ApiCookieAuth() (Bearer 는 등록하지 않는다)
.build();
const document = SwaggerModule.createDocument(app, config);
@@ -190,7 +206,11 @@ SwaggerModule.setup('api-docs', app, document);
## 인증 (JWT)
> 📐 **확장 지점.** 이 템플릿은 JWT 인증을 강제하지 않는다(규칙만 제공). **인증이 필요해지면** 아래 규칙대로 구현한다: `@nestjs/jwt`·`@nestjs/passport` 설치 → `common/guards/` 생성 → AUTH_001/AUTH_003 흐름 검증.
> **구현 위치.** 쿠키 기반 JWT 가 구현되어 있다 — `modules/auth`(로그인·`me`·`refresh`·`logout`, 계약 `docs/mail/api-contract.md` 1절),
> 가드 `common/guards/jwt-auth.guard.ts`, 전략 `modules/auth/jwt.strategy.ts`, `@CurrentUser()` `common/decorators/current-user.decorator.ts`.
> 서명 키·만료(`JWT_*`)는 `modules/auth/auth.settings.ts` 한 곳에서 읽는다. 직원 자격 확인은 메일 엔진 포트(`MAIL_PROVIDER`)로 한다.
> 리프레시 세션은 DB(`auth_sessions`)에 둔다 — 토큰은 해시만 저장, 1회용 회전, 이미 쓴 토큰이 오면 세션 폐기, 절대 30일·미사용 14일.
> 액세스 토큰은 세션을 조회하지 않는다 (끊어도 만료까지 유효 — 그래서 `JWT_ACCESS_EXPIRES_IN` 은 env 검증이 5분~1시간으로 막는다).
- Access Token: 만료 시간 짧게 (15분 ~ 1시간)
- Refresh Token: HttpOnly Secure 쿠키로 전달
@@ -206,7 +226,14 @@ SwaggerModule.setup('api-docs', app, document);
> 📐 **Redis 는 확장 지점이다.** compose 에서 **기본 비활성**(profile)이며 애플리케이션도 사용하지 않는다.
> 단일 인스턴스에서는 in-memory 로 충분하다.
>
> Redis 가 실제로 필요해지는 시점(다중 인스턴스 운영, 세션·캐시 공유, 분산 락)에 연결한다:
> Redis 가 실제로 필요해지는 시점(다중 인스턴스 운영, 세션·캐시 공유, 분산 락)에 연결한다.
> ⚠️ 인스턴스를 늘리면 **프로세스 메모리에 둔 한도·잠금**도 함께 옮겨야 한다 — 그대로 두면 인스턴스마다 따로 센다:
> 로그인 실패 카운터(`modules/auth/fixed-window-counter.ts` → `INCR`+`PEXPIRE` 를 한 Lua 스크립트로),
> 같은 주소의 진행 중 로그인·비밀번호 변경·같은 `Idempotency-Key` 의 진행 중 발송(`SET NX PX` 잠금), `@nestjs/throttler` 저장소,
> 같은 직원 · 같은 메일함의 오래 걸리는 처리(`mail.service.ts` 의 `mailboxOperations` — 그대로면 두 인스턴스가 같은 휴지통을 함께 비우며 서로 어긋난다 → 잠금),
> 사용자별 서명 그림 올리기 차례(`mail-signature.service.ts` 의 `uploadQueues` — 그대로면 동시 업로드가 보관 상한을 넘을 수 있다 → 잠금),
> 받는 사람 추천 캐시(`recipient-suggestions.service.ts` — 틀리지는 않지만 보낸 직후 다른 인스턴스가 10분까지 옛 목록을 준다 → 공유 캐시나 무효화 알림),
> 실시간 알림의 직원당 연결 5개(`mail-events.service.ts` — 기능은 맞지만 인스턴스마다 따로 세고, 엔진 연결도 인스턴스마다 하나씩 생긴다 → 필요하면 공유 셈):
> 0. 컨테이너 활성화 — 루트 `.env` 에 `COMPOSE_PROFILES=redis` ([[docker]])
> 1. Redis store 패키지 설치 (**컨테이너 안에서** — 위 의존성 주의 항목 참조)
> 2. `app.module.ts` 의 `CacheModule.register` 를 `registerAsync` + Redis store 로 교체
@@ -214,14 +241,20 @@ SwaggerModule.setup('api-docs', app, document);
> 4. 연결 실패 시 앱이 죽지 않도록 폴백 전략을 정한다
>
> 끝까지 쓰지 않을 프로젝트라면 compose 에서 redis 서비스와 관련 env 를 지우는 편이 낫다.
- **Rate Limiting**: `@nestjs/throttler` 로 일원화 (`app.module.ts` 의 `APP_GUARD`). 초과 시 429 → `REQ_001`.
- **Rate Limiting**: `@nestjs/throttler` 로 일원화 (`app.module.ts` 의 `APP_GUARD` = `ClientIpThrottlerGuard`). 초과 시 429 → `REQ_001`.
IP 를 세는 모든 곳은 `clientIpKey()` 를 쓴다 — IPv6 는 주소를 계속 바꿀 수 있어 원문 그대로 세면 IP 한도가 무력해진다.
라우트 단위 조정은 `@Throttle({ default: { ttl, limit } })`, 제외는 `@SkipThrottle()` (헬스체크에 적용되어 있다).
로그인 무차별 대입은 **실패 횟수**로 막는다 — `modules/auth/login-attempt.service.ts` (비밀번호 변경의 지금 비밀번호 틀림도 같은 카운터로 센다).
메일 엔진에 묻기 **전에** 실패 한 번을 IP·주소에 원자적으로 차지하고(동시 요청으로 한도를 넘지 못하게), 같은 주소의 진행 중 시도는 기다리지 않고 `REQ_001`.
성공·엔진 장애는 차지한 자리를 돌려준다(성공은 세지 않는다). 요청 수 상한은 그와 별개로 넉넉하게 — 로그인 라우트의 `@Throttle`(IP 당 1분 60회, 사무실 NAT 고려).
**rate limiter 를 중복으로 추가하지 말 것** ([[security]])
- **Pagination**: 목록 API는 반드시 페이지네이션 적용 — `common/dto/pagination.dto.ts` 의 쿼리 DTO + `toPaginatedResult()` 사용. 응답은 `{items, pageInfo}` 고정 ([[response-format]])
- **Logging**: `winston` 또는 `@nestjs/common Logger` 사용, `console.log` 금지. 레벨 기준·민감정보 마스킹은 [[logging]]
- **Graceful shutdown**: `app.enableShutdownHooks()` 가 적용되어 있다. 종료 시 정리가 필요한 리소스(큐 소비자, 워커, 외부 연결)는
`OnApplicationShutdown` 훅으로 정리한다. **`process.on(signal)` 로 직접 잡지 말 것** — Nest 의 종료 흐름에 간섭해 종료 코드가 달라진다
(`shutdown.logger.ts` 가 참고 패턴).
**오래 열린 응답(SSE 등)은 `BeforeApplicationShutdown` 에서 먼저 끝낸다** — `app.close()` 가 HTTP 서버를 닫을 때 열린 연결이 끝나기를 기다려 `stop_grace_period`(30초)를
다 쓰고 강제 종료된다 (`mail-events.service.ts` 가 `end {server-shutdown}` 를 보내고 닫는다).
- **Health Check**: `@nestjs/terminus` 로 구성. 주의점 두 가지 —
`pingCheck` 기본 타임아웃 1초는 짧아 유휴 커넥션 재연결 시 오판하므로 늘려서 쓰고,
표준 응답 래퍼/에러 코드에서 제외해 원인이 보이게 한다 ([[response-format]])
@@ -297,6 +330,13 @@ dc exec backend npm uninstall <패키지>
- 변경 후 호스트의 `package.json`·`package-lock.json` 이 갱신됐는지 확인하고 **함께 커밋**한다 (bind mount 라 호스트에 반영된다).
- 호스트에서 실행해버렸다면 `git checkout backend/package-lock.json` 으로 되돌린 뒤 위 방법으로 다시 한다.
- 커밋 전 `docker compose ... build backend` 로 `npm ci` 가 통과하는지 확인한다. CI 도 같은 검사를 한다.
⚠️ npm 이 11.6.2 인 동안 jest 의 `unrs-resolver` 를 1.12.x 로 올리지 않는다(`npm update` 포함) — 올리면 컨테이너의 `npm install` 이 lock 을 깨뜨린다.
원인과 확인 방법은 [[docker]] "의존성은 컨테이너 안에서" 의 2026-10-27 항목.
- **운영 의존성이 실제로 들어가는지**도 본다 — 임시 복사본에서 `npm ci --omit=dev` 후 새 운영 의존성을 `require` 해 본다.
lock 의 `dev` 표시가 틀리면 운영 이미지에서만 패키지가 빠져 앱이 뜨지 않는다 (2026-10-27 htmlparser2 사례).
- **ESM 전용 패키지**가 들어와 Jest 가 `Cannot use import statement outside a module` 로 실패하면, jest 설정 두 곳
(`package.json` 의 `jest` · `test/jest-e2e.json`)의 `transform`·`transformIgnorePatterns` 목록에 그 패키지를 더한다
(이미 있는 ts-jest 로 CJS 변환 — 지금은 htmlparser2 계열 6개).
- 전이 의존성의 취약점이 `npm audit fix` 로 안 풀리면 `package.json` 의 `overrides` 를 쓴다 ([[security]]).
현재 `multer: ^2.4.0` 이 걸려 있다 — `@nestjs/platform-express` 를 v12 로 올릴 때 제거한다.
@@ -310,13 +350,36 @@ dc exec backend npm uninstall <패키지>
- [ ] **에러**: 사용자 노출 실패는 `BusinessException('CODE')` 로 던진다. 코드 없는 실패 금지.
- [ ] **에러 코드 추가 시**: `error-codes.ts` 만 수정 → `npm run sync:error-codes` 실행 → 세 스택 목록 테스트 갱신 ([[error-codes]]).
- [ ] **목록 API**: 페이지네이션 적용, 응답은 `{items, pageInfo}`.
- [ ] **DTO**: 모든 입력 DTO 에 `class-validator` + 한국어 메시지 + `@ApiProperty()`.
- [ ] **DTO**: 모든 입력 DTO 에 `class-validator` + 한국어 메시지 + `@ApiProperty()`. 배열 중복은 `ArrayDistinct`(`ArrayUnique` 금지 — 항목 수의 제곱).
DTO spec 은 전역 파이프 옵션에서 `stopAtFirstError` 만 빼고 검사한다(걸린 규칙을 모두 보려고 — 통과 · 실패 판정은 같다).
- [ ] **Swagger**: 새 엔드포인트에 `@ApiTags`/`@ApiOperation` + `@ApiStandardResponse`/`@ApiErrorResponse`.
- [ ] **경로 alias**: `@/` 사용 (`tsconfig.json` paths + jest `moduleNameMapper` 설정 완료). 깊은 상대경로 금지.
- [ ] **lint/test**: 커밋 전 `npm run lint` (`--max-warnings 0`) + `npm run test` 통과. 자동 수정은 `npm run lint:fix`.
성능 단언은 `backend/test/cpu-time.ts` 의 `startCpuTimer()`(프로세스 CPU 시간)로 잰다 — 벽시계 한도는 부하에서 흔들린다 ([[testing]]).
- [ ] **의존성 변경 시**: 호스트가 아니라 **컨테이너 안에서** `npm install/uninstall` (위 주의 항목 참조). 변경 후 Docker 빌드 확인.
- [ ] **에러 던지기**: `HttpException` 직접 생성은 린트로 차단된다 — `BusinessException(code)` 사용.
- [ ] **새 필수 env 추가 시**: `shared/config/env.validation.ts` 에 검증 규칙과 테스트를 함께 추가 ([[env-structure]]).
- [ ] **DB 전환 시**: in-memory Map → TypeORM 엔티티/리포지토리로 교체.
- [ ] **인증 추가 시**: `@nestjs/jwt`·`@nestjs/passport` 설치 → `common/guards/` 생성 → AUTH_001/003 흐름 확인.
- [ ] **보호 라우트**: `@UseGuards(JwtAuthGuard)` + `@ApiCookieAuth()` + `@CurrentUser()` — 가드 없이 `@CurrentUser()` 를 쓰면 AUTH_001 로 막힌다 ([[security]]).
- [ ] **전역 설정 변경 시**: `src/app.setup.ts` 만 고친다 — main.ts 와 e2e 가 같은 함수를 쓴다.
- [ ] **메일 엔진 코드**: JMAP 타입·호출은 `modules/mail/jmap/` 밖으로 내보내지 않는다. 받은 메일 HTML 은 `html/sanitize-mail-html.ts` 를 거친 뒤에만 내보낸다.
보내는 서식 본문(`bodyHtml`)은 `html/sanitize-outgoing-html.ts` 를 거친 뒤에만 엔진에 넘긴다 — 편집기가 새 서식을 쓰게 되면 이 허용 목록과
받은 메일 정화(다시 읽을 때 남아야 한다)를 함께 넓힌다 (계약 2절 "서식 본문").
HTML 을 정화하는 경로는 먼저 `html/html-parse-budget.ts` 의 `checkHtmlParseBudget()` 을 부른다 — sanitize-html 이 쓰는 htmlparser2 는
중첩 깊이의 제곱으로 느려져 2MB 한 통에 수 분 동안 이벤트 루프를 멈춘다 (2026-10-27 리뷰). 정규식·변환도 입력 길이에 비례하게 둔다.
htmlparser2 판을 올리면 `html-parse-budget.spec.ts` 의 Parser 비교 테스트가 어긋남을 잡는다 — 실패하면 바뀐 Parser 규칙을 옮긴다.
**엔진의 `Email/set destroy` 는 메일함과 상관없이 영구 삭제다** — 사용자 메일을 영구 삭제하는 경로(임시 저장 교체의 옛 초안 · 보낸 뒤 `draftId` ·
휴지통에서 영구 삭제 · 4단계의 휴지통 · 스팸메일함 비우기)는 모두 `jmap-mail.provider.ts` 의 조건부 지우기를 쓴다: 지울 대상인지 확인(임시보관함에만 있는 사용자 초안 /
휴지통(비우기는 그 메일함)에만 있는 메일 — 다른 메일함에도 있으면 그 메일함에서만 뺀다) → 확인한 상태 그대로일 때만 `ifInState` 로 지우기 → 어긋나면 다시 확인
(한 통 · 여러 통은 합계 `CONDITIONAL_DESTROY_ATTEMPTS`, 비우기는 500통 차례마다 이어서 그만큼). 확인과 지우기 사이에
다른 탭이 옮기거나 되돌린 메일까지 지우지 않게 하기 위해서다 (2026-10-27, Stalwart 0.16.27 이 `stateMismatch` 로 거절하는 것을 확인).
`conditionalEmailSet` 은 `ifInState` 를 필수로 받는다 — 조건 없는 destroy 는 그 요청이 방금 만든 메일(실패한 저장의 되돌림 · 받는 사람이 모두 거절한 발송의 보낸 사본 — `discardQuietly`)에만, 따로 쓴다.
같은 요청에서 만들기와 지우기를 함께 하지 않는다 — 엔진은 만들기가 실패해도 지우기를 한다.
**메일함 지우기(`Mailbox/set destroy`)는 `onDestroyRemoveEmails: false` 만 쓴다** — `true` 는 안의 메일을 영구 삭제한다. 안의 메일은 먼저 휴지통으로 옮기고,
그사이 들어온 메일이 있으면 엔진이 `mailboxHasEmail` 로 거절하므로 다시 옮긴다 (2026-10-28 확인).
**Sieve 스크립트**(6단계 — 필터 · 부재중)는 `jmap/sieve-script.ts` 한 곳에서 만든다: 사용자 글은 `quote()` 만 거친다(`\` · `"` 이스케이프), `${` 는 받지 않는다
(엔진이 require 와 상관없이 변수로 펼친다 — `%{…}` 는 펼치지 않는다), 부재중 제목은 RFC 2047 · 본문은 `vacation :mime` base64, 만든 글이 90,000바이트를 넘으면 `MAIL_013`.
**스팸 판정은 엔진의 분류 표시**(`X-Spam-Score` 가 `spam` 으로 시작)로 한다 — 점수 문턱을 따로 두지 않는다(`spamtest` 는 점수를 정수로 내린 값이라 10 으로 막다가 5~10점 스팸을 놓쳤다, 2026-10-28 실엔진).
날짜 비교는 `currentdate :zone "+0000"`. 엔진 오류의 설명 문장은 로그에 남기지 않는다(줄 · 칸만 — 만난 문자열에 규칙의 글이 들어 있다).
원본 판(`FILTER_SOURCE_FORMAT`)을 올린 배포를 되돌리면 새 판으로 저장한 직원의 필터 · 부재중 저장이 503 `MAIL_001` 로 막힌다(옛 서버가 새 판을 지우지 않게) — 판을 올릴 때는 되돌리기 계획을 함께 둔다.
- [ ] **env**: DB/Redis/CORS/포트는 루트 `.env` 에서 주입된다. 백엔드 고유 시크릿(`JWT_SECRET` 등)만 `backend/.env.{env}` 에 작성(compose `env_file` 로 로드).
+60 -11
View File
@@ -18,14 +18,50 @@ description: Vue 3 프론트엔드(frontend/) 개발 규칙 — Composition API
| 중앙 Axios 인스턴스 | `src/composables/useApi.ts` | baseURL=`VITE_API_BASE_URL`, `withCredentials` |
| 응답 언래핑 인터셉터 | `useApi.ts` | `{success,data}→data`, 에러 코드 정규화(`ApiError`), 중복 알림 억제 |
| 에러 코드 사전 | `src/shared/constants/error-codes.generated.ts` | **자동 생성 파일 — 직접 수정 금지** ([[error-codes]]) |
| 세션 만료 처리 | `useApi.ts` 의 `toAuthCode()` | 로그인 상태에서 401 → `AUTH_002` 승격 + `authStore.clearAuth()` |
| 세션 만료 처리 | `useApi.ts` 의 `refreshSession()` · 응답 인터셉터 | 로그인 상태에서 401 → `/auth/refresh` 한 번 시도 후 원 요청 재시도. 실패하면 `AUTH_002` 승격 + `authStore.expireSession()` → `components/feedback/SessionExpiredDialog.vue` (흐름: `docs/mail/api-contract.md` 1절). 갱신은 시작할 때 세션 세대(`authStore.generation`)를 잡고, 끝났을 때 바뀌었으면 성공이어도 버린다. 로그인은 진행 중인 갱신이 끝난 뒤(`settleRefresh()`)에 보낸다 |
| 이동 전 세션 확인 | `useApi.ts` 의 `confirmSession()` | `<a href>` 다운로드처럼 axios 를 거치지 않는 이동 직전에 부른다 — me → refresh 1회 → 실패하면 세션 만료 |
| 탭 간 로그아웃 | `src/composables/useLogoutBroadcast.ts` (`App.vue` 의 `useLogoutSync()`) | 로그아웃은 요청 **전에** BroadcastChannel 로 다른 탭에 알린다 — 받은 탭은 로그아웃으로 다루고 로그인 화면으로. 로그아웃 뒤 늦게 온 `/auth/refresh` 성공은 버리고 `/auth/logout` 을 한 번 더 부른다 (계약 1절) |
| 요청 단위 알림 끄기 | `useApi.ts` 의 `silent` 요청 옵션 | `true` 또는 코드 배열 (`api.get(url, { silent: ['AUTH_001'] })`). 세션 만료 대화상자는 끄지 않는다. 끈 코드를 나중에 알림으로 알려야 하면(그 칸이 사라진 뒤 등) `notifyApiError()` — 같은 `showErrorUI()` 를 거친다 |
| 페이지네이션 타입 | `src/shared/types/pagination.ts` | 목록 응답 공유 계약 ([[response-format]]) |
| 도메인 Composable 예제 | `src/composables/useUser.ts` | 새 도메인 API 패턴 참고 |
| 인증 Composable 예제 | `src/composables/useAuth.ts` | 로그인/로그아웃 흐름(쿠키 기반) 시작점 |
| Pinia 스토어 예제 | `src/stores/user.store.ts` | 새 스토어 패턴 참고 |
| 인증 스토어 | `src/stores/auth.store.ts` | 세션 인증 여부(`isLoggedIn`) 관리 |
| 도메인 Composable 예제 | `src/composables/useUser.ts` · `useMail.ts` | 새 도메인 API 패턴 참고 (`useMail` 이 실제 모듈 예) |
| 인증 Composable | `src/composables/useAuth.ts` | 로그인·로그아웃·`restoreSession()`(me 가 AUTH_001 이면 refresh 1회, 알림 없음) — 쿠키 기반. 비밀번호 바꾸기 `changePassword()`(4단계 — 한도 30초 `PASSWORD_CHANGE_TIMEOUT_MS`, 지금 비밀번호 틀림은 400 `AUTH_005` 라 갱신 · 재시도를 타지 않는다) |
| Pinia 스토어 예제 | `src/stores/user.store.ts` · `mail.store.ts` | 새 스토어 패턴 참고 (`mail.store` 는 늦게 온 응답 무시 패턴 포함) |
| 인증 스토어 | `src/stores/auth.store.ts` | 로그인 사용자(email·name) · 세션 복원(`restore`) · 만료 상태(`expireSession`) · 로그아웃 상태(`clearAuth()` 가 켜는 `signedOut` — 다시 로그인할 때까지 늦게 온 갱신·복원 결과를 쓰지 않는다) |
| 공통 컴포넌트 예제 | `src/components/BaseButton.vue` | 재사용 컴포넌트 기준 패턴(타입 Props/Emits·scoped·rem·a11y) |
| 라우터 | `src/router/index.ts` | lazy import + `auth.store` 연동 `requiresAuth` 가드 |
| 라우터 | `src/router/index.ts` · 라우트 표 `src/router/routes.ts`(라우터와 라우트 테스트가 함께 쓴다) | lazy import + 첫 네비게이션 전 세션 복원 + `requiresAuth` 가드(미인증 → `{ name: 'login', query: { redirect } }`) |
| 전역 셸 | `src/components/shell/AppShell.vue` | 앱 레일 + 상단 바 + 모듈 영역. 레일 모듈 목록은 `src/shared/constants/app-modules.ts` 하나 |
| 디자인 토큰·글꼴 | `src/styles/tokens.css` · `src/styles/base.css` | 색·간격·글자 크기 변수. 글꼴은 npm `pretendard` 자체 호스팅(`main.ts` 에서 import — CSP `font-src 'self'`) |
| 브랜드 자산 | `src/assets/brand/` · `src/shared/constants/brand.ts` | 흰 로고(전체·ARA 마크). 원본은 `docs/brand/` |
| 토스트 | `src/stores/toast.store.ts` · `src/components/feedback/ToastHost.vue` | 오류는 `useApi.ts` 의 `showErrorUI()`, 성공 안내는 `composables/useToast.ts`(API 가 아닌 화면 쪽 실패 — 예: 브라우저가 알림을 막음 · 보냈지만 받는 사람 일부가 받지 못함(+ [보낸편지함 보기], 문장은 `shared/utils/mail-send-result.ts`) — 는 `useToast().error()`). 알림 안의 단추는 `{ label, run }`(예: 임시 저장본 [되돌리기]), 이동 링크는 지금처럼. 단추가 있는 알림은 10초(`TOAST_ACTION_DURATION_MS`), 마우스 · 초점이 있는 동안은 `hold`·`release` 로 멈춘다. **단추는 띄운 세션에서만 움직인다** — `useToast` 가 `authStore.generation` 에 묶고, 로그아웃 · 세션 만료 · 다른 계정 로그인 때 단추가 있는 알림을 닫는다(메일 id 는 계정마다 겹칠 수 있다). 오래 걸리는 처리는 **진행 알림** `useToast().progress(글)` — 저절로 닫히지 않고(넘쳐도 다른 알림부터 닫는다) 글을 고치다가(`update`) 끝나면 그 자리에서 결과가 된다(`succeed` · `fail` — `toast.store` 의 `update`). 진행 알림도 세션이 바뀌면 닫는다 |
| 모달 대화상자 | `src/components/feedback/ModalDialog.vue` | 네이티브 `<dialog>`(초점·Esc·닫힘 처리)와 모양. 세션 만료(`SessionExpiredDialog`)·나가기 확인(공용 `components/feedback/LeaveConfirmDialog.vue` — 쓰기 화면 `ComposeLeaveDialog.vue`·서명 설정 · 부재중 설정이 쓴다) · 메일함 대화상자(`components/mail/Mailbox*Dialog.vue` — 기본 슬롯에 이름 칸) · 필터 고치기 창(`components/settings/MailFilterDialog.vue` — 칸이 많은 창은 `wide`: 폭 620)이 쓴다 — 새 대화상자도 이것으로 만든다. 되돌릴 수 없는 일의 확인은 처음 초점을 「취소」에 둔다. 초점을 지키려고 `aria-disabled` 로 막은 단추에는 마우스를 올린 모양이 걸리지 않는다 |
| 외부 HTML 격리 렌더링 | `src/components/mail/MailBodyFrame.vue` · `src/shared/utils/mail-html.ts` | 메일 본문 등 외부 HTML 은 이 컴포넌트로만 그린다 (sandbox·내부 CSP 근거는 파일 머리말). 보여 줄 HTML 고르기는 `displayedHtmlBody()` 하나다 — null 이거나 공백뿐이면 textBody (읽기 화면·인용 공용, 서버의 `isBodyTruncated` 와 같은 규칙) |
| 메일 모듈 | `src/pages/mail/*` · `src/components/mail/*` | API 계약 `docs/mail/api-contract.md`, 디자인 원본 `docs/design/webmail/` |
| 콘텐츠 열 토큰 | `src/styles/tokens.css` 의 `--content-*` · `--content-toolbar-start` | 읽기·쓰기 화면이 같은 최대 폭·여백·32px 시작선을 쓴다 (새 화면도 이 토큰으로) |
| 밀도 예외 크기 | `src/styles/tokens.css` 의 `--size-compact-control` · `--size-segment-item` · `--size-segment-item-lg` · `--size-editor-tool` · `--size-editor-menu-item` · `--size-editor-swatch-cell` · `--size-editor-table-cell` · `--table-col-check` · `--size-row-check` · `--size-search-clear-width`/`-height` · `--size-notice-bar` · `--size-mailbox-more` · `--size-notice-action` · `--size-filter-control` · `--size-filter-remove` (주석에 "밀도 예외") | 클릭 영역 44px 기준의 의도된 예외(목록 표의 별·보기 전환, 설정 화면의 미리 보기 전환 36(`.segmented`)·서명 그림 크기 줄 36(작게·보통·원본·그림 빼기), 편집기 툴바 버튼 40×44·메뉴 항목 40·색 견본 40·표 고르기 칸 22, 3단계의 목록 표 고르기 칸 32·분할 행 고르기 칸 폭 36·검색 칸 ✕ 40×38·새 메일 알림 줄 40·검색 범위 전환 36·이동 메뉴 항목 40, 4단계의 메일함 행 ⋯ 32×40 · ⋯ 메뉴 항목 40, 5단계의 목록 위 알림 권하기 줄 40 · 줄 안의 단추 36, 6단계의 필터 조건 칸 40 · 조건 ✕ 36 · 필터 행 ▲▼ 32×40 · 켜기 스위치 누르는 곳 40×44 · 고치기 창 안 [다시 불러오기] 36)에만 쓴다 — 사용자 확정(캔버스). 그 밖의 `--size-editor-*`(툴바 높이·글꼴 단추·본문 최소 높이 등)는 예외가 아니라 보통 크기다 |
| 사용자별 화면 설정 | `src/stores/mail-view.store.ts` | localStorage(키에 사용자 email, try/catch, 실패 시 기본값). 서버 설정 API 가 생기면 교체 |
| 화면 이동 규칙 | `src/composables/useMailNavigation.ts` | 이전/다음은 replace, "목록으로"는 뒤로 가기 또는 replace (기록이 쌓이지 않게) |
| 발송 키 규칙 | `src/shared/utils/idempotency-key.ts` 의 `shouldRenewSendKey()` | `Idempotency-Key` 는 성공과 `MAIL_005` 때만 바꾼다 — 그 밖의 실패 뒤 다시 보내기는 같은 키 (계약 2절 "발송 본문"). UUID 만들기는 `shared/utils/uuid.ts` 하나(필터 id 와 공용 — `crypto.randomUUID` 가 없으면 대체 경로) |
| 첨부 사전 검사 | `src/shared/utils/mail-attachment.ts` 의 `precheckAttachment()` | 형식·파일당·합계 한도(`mail.store` 의 `attachmentMaxBytes`·`attachmentTotalMaxBytes`, 서버 값이 없으면 대체값). 넘는 파일은 올리지 않는다 |
| 메일 본문 편집기 | `src/components/mail/editor/` — `MailEditor.vue` (쓰기 화면이 `defineAsyncComponent` 로 불러온다) | TipTap core (MIT 무료 패키지만, `@tiptap/*` 는 모두 같은 버전으로 고정). 툴바 배치·좁은 폭에서 접는 순서·단축키는 `docs/design/webmail/README.md` "메일 쓰기". 보낼 모양은 `mail-serialize.ts` — 서식 본문 `bodyHtml`(기본 글꼴 div, 인라인 style) + 텍스트 대체 본문 `body` (계약 2절 "서식 본문"). 답장·전달 초안 한도는 `fit-draft.ts`(처음 맞출 때 내 글 몫 5%를 남기고, 보내기 직전 `getContentForSend()` 가 다시 재서 인용을 더 줄인다 — 내 글만으로 넘으면 보내지 않고 칸 아래에 알린다), 붙여 넣거나 끌어다 놓은 파일은 첨부로(`shared/utils/editor-files.ts`, 화면 어디에 놓아도 — `composables/useWindowFileDrop.ts`). **읽어 들이는 style 값은 서버 정화와 같은 형식만** 받는다 — 규칙 `shared/utils/outgoing-html-rules.ts`(짝: backend `sanitize-outgoing-html.ts`, 같은 표본 표 `test-utils/outgoing-style-samples.ts` — 한쪽을 고치면 둘 다), 적용 `editor/mail-format-extensions.ts`. 편집기 청크를 못 받으면 `ComposeEditorLoadError.vue`("다시 시도"), 단축키 표기는 플랫폼별(`shared/utils/keyboard-shortcut.ts`). **그림은 서명 그림만** 받는다 — 노드 `editor/signature-image.ts`(쓰기 본문·서명 칸 공용, 규칙은 `outgoing-html-rules.ts` 의 그림 부분 — 짝: backend `html/signature-images.ts`·`keepSignatureImage`, 표본 `IMAGE_SAMPLES`). 메뉴(`editor/EditorMenu.vue` + `EditorPopup.vue` — 이동 ▾ · 메일함 ⋯ · 읽기 화면 ⋯ 도 같은 부품)의 꺼진 항목은 `disabled` 가 아니라 `aria-disabled` — 화살표로 가서 초점은 받되 고를 수 없다(WAI-ARIA 메뉴). 누를 수 없는 까닭은 항목의 `note`(이름 아래 작은 글, `aria-describedby`) |
| 임시 저장 | `src/composables/useDraftSaving.ts` · `components/mail/ComposeDraftStatus.vue` · `composables/useComposeDraftLink.ts`(주소 맞추기 · 옛 id 기록 · 보내지 못한 메일을 저장본으로) · `composables/useDraftTrash.ts`(휴지통 · 되돌리기) | 3초 멈춤 · 쉬지 않고 쓰면 30초 · 10초 간격 · 한 번에 하나(계약 2절 "임시 저장", 상수 `shared/constants/mail.ts` 의 `DRAFT_*`). `MAIL_008`·PUT 404 는 막지 않고 POST 로 새로. 찾지 못한 첨부가 남았거나 본문이 한도를 넘는 동안은 저장하지 않고, 실을 수 없는 첨부가 든 남의 저장본은 첫 저장을 POST 로(원본 보존). 보내기·버리기 전에는 `suspend`+`settle` — 보내기는 settle 뒤 첨부 상태를 다시 본다. 저장할 때마다 주소를 `?draft=<새 id>` 로 맞추고(떠나기 전 저장 제외), 옛 id 로 돌아오면 `mail.store` 의 `latestDraftId()` 로 마지막 저장본을 연다. 쓰기 화면 휴지통은 DELETE 가 아니라 PATCH(휴지통으로 이동) — 이미 휴지통인 메일은 DELETE 가 영구 삭제한다 |
| 받는 사람 자동 완성 | `src/composables/useRecipientSuggestions.ts` · `shared/utils/recipient-match.ts` · `components/mail/RecipientField.vue` · `RecipientSuggestionList.vue` | 검색어는 input · compositionupdate 의 `target.value`(한글 조합 중 글자 포함)로 따로 두고, 칩 확정은 v-model. Enter 등의 조합 판정은 `shared/utils/keyboard-shortcut.ts` 의 `isImeComposing()`(`isComposing` 또는 keyCode 229 — Safari · macOS) — 새 입력 칸의 Enter 처리도 이것을 쓴다. 쓰기 화면을 열 때 `limit=100` 한 번, 모자랄 때만 250ms 뒤 `q` 로(초성·자모만 친 글자는 묻지 않는다, 늦은 응답은 버린다). 초성 검색·맞은 글자 강조는 `recipient-match.ts`(강조는 `<strong>` 조각 — v-html 없음). combobox 접근성(aria-activedescendant · listbox · 조합 중 Enter 무시), 완전한 주소면 미리 고르지 않는다 |
| 메일 서명 | `src/stores/mail-signature.store.ts` · `components/mail/editor/signature/` · `shared/utils/mail-signature.ts` · `pages/settings/` | 서명은 메일 화면에 들어올 때 받아 두고 쓰기 화면이 넣는다(새 메일은 끝, 답장·전달은 인용 위 — 이어 쓰기는 다시 넣지 않는다). 설정 화면(`SettingsLayout.vue` 왼쪽 메뉴 — 묶음 · 항목은 `shared/constants/settings-menu.ts` + `MailSignaturePage.vue`)은 들어올 때마다 새로 받는다. 미리 보기는 편집기 스키마의 `DOMSerializer` 로 그린다. 그림 3장은 서명 칸 안의 그림 수로(서버 보관 상한 10장은 서버가 센다) |
| 메일 스토어 구성 | `src/stores/mail.store.ts`(조립 · 공개 API) + `src/stores/mail/` — `mailboxes.ts` · `list.ts` · `selection.ts` · `message.ts` · `bulk.ts` · `mailbox-admin.ts`(4단계 메일함 관리) | Pinia 스토어는 `useMailStore` 하나이고 기능별 setup 모듈을 조립한다(스토어를 여러 개로 나누지 않는다 — 서로 부르는 고리가 생긴다). 목록 출처는 `shared/utils/mail-list-source.ts`(메일함 · 검색). **늦게 온 응답 버리기**: 목록 맞추기는 이 탭의 목록 변경 세대(`localGeneration` — 읽음 · 중요 · 빼기 · 옮기기마다 오른다)가 그사이 바뀌었으면 응답을 버리고 한 번 더 맞춘다. 여러 통 처리 · 한 통 삭제는 시작할 때의 출처 순번(`sourceSeq`)과 다르면 목록을 건드리지 않고 개수만 맞춘다 |
| 새 메일 확인 · 탭 제목 | `src/composables/useMailStatePolling.ts`(메일 화면 `MailLayout` 이 시작) · `useListArrivalNotice.ts` + `components/mail/MailNewNotice.vue` · `useMailTabTitle.ts` + `shared/utils/document-title.ts` | 화면이 보이는 동안 60초마다 `GET /mail-state`, 탭으로 돌아오면 10초 지났을 때 바로, 한 번에 하나, 실패는 `silent`. 바뀌면 `syncCounts()` + 보이는 목록 맞추기(불러온 만큼 · 최대 100통, 검색 결과는 건너뜀). 목록을 내려 본 채면 "새 메일 N통 · 맨 위로" 줄. 받은편지함 안 읽음이 있으면 탭 제목 앞 `(N) ` (메일 라우트에서만) — 계약 2절 "새 메일 확인" |
| 실시간 새 메일 (5단계) | `src/composables/useMailRealtime.ts`(`App.vue` 가 시작 — 로그인한 동안 앱 전체) · `stores/mail-realtime.store.ts` · `shared/utils/sse-parser.ts` · `mail-events.ts` · `mail-realtime.ts` · `browser-support.ts` | **브라우저마다 연결 하나** — Web Locks 잠금(이름에 사용자 주소)을 받은 탭만 `GET /api/mail-events` 를 읽고 BroadcastChannel 로 나눈다(메시지에 사용자 주소, 다른 계정 것은 버린다). `fetch` 로 읽는다 — `EventSource` 는 401 을 몰라 쓰지 않는다. 75초 무소식 감시는 요청 시작부터, 401 → 세션 갱신 한 번(그 바로 뒤의 시도만 "갱신 직후"), 다시 잇기 2 · 5 · 15 · 30 · 60초(±20%) — 간격은 연결이 **자리 잡은 뒤**(정상 ping 또는 20초)에만 처음부터 다시 세고, 자리 잡기 전의 `end` 는 실패처럼 기다린다(열리자마자 끝나는 연결에서 갱신 폭주를 막는다). 메일 화면(`useMailStatePolling`)은 이 소식을 받아 연결이 살아 있는 동안 1분 확인을 멈추고, `state` 가 바뀌면 보이는 탭은 맞추기(1초 모아서 — `MAIL_REALTIME_SYNC_DELAY_MS`) · 숨은 탭은 `inboxUnread` 로 탭 제목만(`setInboxUnread`) — 계약 2절 "실시간 알림" |
| 바탕화면 알림 (5단계) | `src/composables/useNewMailNotifier.ts` · `useDesktopNotification.ts` · `stores/mail-notify.store.ts`(브라우저 · 사용자마다 localStorage) · `shared/utils/desktop-notify.ts` · 목록 위 권하기 `components/mail/MailNotifyOffer.vue` · 설정 `pages/settings/MailNotifyPage.vue`(+ `components/settings/NotifySettingsForm.vue` · `RealtimeLinkStatus.vue`) · 아이콘 `assets/brand/notification-icon.png` · 문구 `shared/constants/mail-notify.ts` | 연결한 탭이 `new-mail` 을 받고 설정이 켜져 있고 허락됐고 **웹메일을 보고 있는 탭이 없을 때만**(보이고 초점이 있음 — `visibilityState === 'visible' && document.hasFocus()`, 다른 탭에 짧게 묻는다) 띄운다 — tag 하나로 앞 알림을 바꿔 쓰고, 사용자가 돌아오면 닫는다. 허락을 묻는 동안 단추는 `disabled` 가 아니라 `aria-disabled`(초점을 잃지 않게). 허락은 사용자가 누른 뒤에만(`Notification.requestPermission()`). 누르면 그 메일로(`useMailNavigation` 의 `openFromNotification` — 세션이 바뀌었으면 이동하지 않는다) |
| 실시간 테스트 도구 | `src/test-utils/event-stream.ts`(가짜 SSE 서버) · `notification.ts`(가짜 Notification) · `web-locks.ts`(가짜 `navigator.locks` · BroadcastChannel) | 실시간 · 알림 테스트는 이것들과 손으로 돌리는 시계(`manual-clock.ts`)로 — 진짜 타이머 · 고정 대기 없이 |
| 메일 검색 | `src/components/mail/MailSearchBox.vue`(상단 바의 search 슬롯) · `composables/useMailSearchNavigation.ts` · `MailSearchHeader.vue` · `MailSearchScope.vue` · `MailSearchEmpty.vue` · 라우트 `/mail/search` · `/mail/search/:messageId` | Enter 로만 찾는다(`isImeComposing()` 이면 무시), ✕ · Esc 는 검색을 시작한 메일함으로. 주소 `?q=…&in=<메일함 id \| all>` — 검색 화면 안의 다시 찾기 · 범위 바꾸기는 replace. 서버와 같은 한도(200자 · 낱말 8개)를 먼저 본다. 조사 처리 · 한계는 서버 몫(계약 2절 "검색") |
| 여러 통 처리 · 삭제 | `src/components/mail/MailCheckbox.vue` · `MailSelectionToolbar.vue`(+ `MailBulkButton` · `MailBulkMoveButton`) · `MailPurgeDialog.vue` · `MessageDeleteButton.vue` · `composables/useBulkMailActions.ts` · `useMessageRemoval.ts` · `useMailMoveFeedback.ts` · `shared/utils/mail-selection.ts` · `mail-bulk.ts` | 100통씩 차례로. 읽음 · 중요는 먼저 바꾸고 `failedIds` 만 되돌리고, 이동 · 삭제는 응답의 id 만 뺀다. **웹은 `DELETE /api/messages/{id}` 를 쓰지 않는다** — 휴지통 밖 삭제는 `PATCH` 이동(+ 되돌리기), 휴지통에서는 확인 대화상자(처음 초점 「취소」) 뒤 `POST /message-deletions`. 읽기 화면도 그 메일의 `mailboxIds` 로 같은 규칙을 탄다. 도구 줄은 role="toolbar" 로빙 tabindex(바쁜 · 꺼진 단추는 건너뛴다) — 계약 2절 "여러 통 한꺼번에" |
| 설정 › 비밀번호 · 부재중 (4단계) | `src/pages/settings/AccountPasswordPage.vue` · `MailVacationPage.vue` · `components/settings/`(`SettingsPageFrame` 공용 틀 — 머리 오른쪽 단추는 `actions` 슬롯 · `PasswordChangeForm` · `PasswordField` · `Vacation*`) · `stores/mail-vacation.store.ts` · `shared/utils/password-change.ts` · `mail-vacation.ts` · 메뉴 `shared/constants/settings-menu.ts` | 메뉴는 묶음(계정 › 비밀번호 / 메일 › 서명 · 부재중 자동 응답 · 필터(6단계) · 알림(5단계)), 라우트 이름은 `SETTINGS_ROUTES` 하나. 비밀번호는 서버와 같은 규칙을 먼저 보고, 서버 거절(`AUTH_005` · `VAL_001`)은 칸 아래에만(알림 없음) — 성공하면 세 칸을 비운다. 부재중은 날짜만 고르고 브라우저 시간대의 시작일 0시 · 끝나는 날 다음 날 0시를 UTC 로 보낸다(변환은 `mail-vacation.ts` 한 곳 — 되읽을 때는 1ms 앞 날짜). 켤 때만 검사하고 꺼 둔 채로는 빈 값도 저장한다(길이 150 · 600 도 켤 때만 — 꺼 둔 채로는 4단계 한도까지, 끄기는 늘 저장되게). 부재중 상태는 메일 화면에 들어올 때 받고, 패널 안내 `components/mail/MailVacationNotice.vue` 가 1분마다 다시 계산한다(`useClock` · `VACATION_NOTICE_REFRESH_MS`). 저장 응답은 세션 세대(`authStore.generation`)가 바뀌었으면 쓰지 않는다 — 계약 1절 · 2절 "부재중 자동 응답" |
| 메일함 관리 · 오래 걸리는 처리 (4단계) | `src/components/mail/MailboxTree.vue` · `MailboxPanelRow.vue` · `MailboxMoreButton.vue`(⋯ 메뉴) · `MailboxDialogs.vue`(+ `Mailbox*Dialog.vue`) · `MailboxEmptyButton.vue`(목록 머리 「비우기」) · `composables/useMailboxActions.ts` · `stores/mail/mailbox-admin.ts` · `shared/utils/mailbox-operation.ts` · 상수 `shared/constants/mailbox-manage.ts` | 만들기 · 이름 바꾸기는 칸 안 실패(`MAIL_009` · `VAL_001`)를 알림 없이 칸 아래에. 모두 읽음 · 비우기 · 지우기는 남은 수가 0(지우기는 `deleted: true`)이 될 때까지 다시 보내며 진행 알림을 고친다 — 진행은 응답의 `processed`, 세션이 바뀌면 멈춘다(`epoch` — 늦게 온 만들기 · 이름 바꾸기 응답도 버린다), 같은 메일함의 같은 처리는 하나, **휴지통 비우기와 메일함 지우기는 함께 돌리지 않는다**(비우기는 그사이 휴지통에 들어온 메일도 지운다 — 판정은 스토어의 `isMailboxOperationBlocked` 하나, 메뉴 · 단추 · 대화상자 확인이 모두 이것을 본다). 끝난 뒤 누른 단추가 사라져 초점을 잃었으면 패널의 지금 메일함 항목으로 옮긴다. ⋯ 메뉴는 보이는 영역 안에 다 들게 연다(`shared/utils/popup-placement.ts` — 아래가 모자라면 위로). 되돌릴 수 없는 일은 확인 대화상자(처음 초점 「취소」) 뒤에, 확인한 그 메일함만 — 계약 2절 "메일함 관리" |
| 스팸 · 원문 (4단계) | `src/components/mail/MessageSpamButton.vue`(읽기 화면) · 도구 줄의 스팸 단추(`MailSelectionToolbar.vue`) · `MessageMoreMenu.vue`(⋯ › 원문 내려받기) | 스팸 신고 · 스팸 아님은 이동과 `isJunk` 를 한 요청에 보내고(`mailStore.moveMessages(…, { isJunk })`), [되돌리기]는 원래 메일함으로 반대 값을 보낸다. 원문(.eml)은 `useMail().downloadMessageSource()` — 첨부처럼 `confirmSession()` 을 먼저 거친다 — 계약 2절 "스팸 · 원문" |
| 메일 필터 (6단계) | `src/pages/settings/MailFilterPage.vue` · `components/settings/MailFilter*.vue`(목록 · 행 · 목록 위 줄 `MailFilterNotice` · 빈 상태 · 안내 상자 `MailFilterNotes` · 고치기 창 `MailFilterDialog` + 조건 `MailFilterConditionFields` · `MailFilterConditionRow` · 할 일 `MailFilterActionFields`) · `mail-filter-ui.ts` · `stores/mail-filter.store.ts` · `composables/useMailFilterActions.ts`(켜기 · 순서 · 지우기 · 되돌리기 알림) · `useMailFilterDialog.ts`(창 상태) · `shared/utils/mail-filter.ts`(검사 · 요약 · 옮길 곳 · 되돌릴 자리) · `shared/constants/mail-filter.ts` · 읽기 화면 ⋯ 「이런 메일로 필터 만들기」(`MessageMoreMenu.vue` → `/settings/mail/filters?from=`) | 바꾸는 일은 모두 `PUT /api/mail-filters` 전체 교체(받은 `version`)이고 **저장은 한 번에 하나**(`saving`). 켜기 · 순서는 화면을 먼저 바꾸고 실패하면(그사이 목록이 안 바뀌었으면) 되돌린다. 창의 저장은 그 순간 목록의 켜짐 값을 쓴다. [되돌리기]는 지운 필터만 앞 · 뒤 필터 기준 자리에 다시 넣고, 누른 차례대로 앞 저장이 끝난 뒤 보낸다(`restoreQueue` — 기다림과 `send()` 사이에 await 를 두지 않는다 — 2026-10-28 리뷰의 경합). `MAIL_012` 는 목록 위 줄 + 창 안 안내와 [다시 불러오기](쓴 내용을 지키고 다시 받은 목록 위에 저장), 늦게 온 409 는 보낼 때의 순번(seq)이 그대로일 때만 줄을 켠다. 검사는 서버와 같은 규칙(전달 주소의 @ 앞은 dot-atom · 64자로 서버보다 좁게)이고 거절은 칸 아래(`VAL_001` · `MAIL_013` 은 알림을 끈다). 필터 화면이 떠 있지 않을 때 실패한 되돌리기는 알림으로(`attachPage` · `pageVisible`). 세션이 바뀌면 늦은 응답을 버린다(`authStore.generation`) — 계약 2절 "메일 필터", 디자인 README "6단계" |
| 시계 주입 | `src/composables/useClock.ts` · `src/test-utils/manual-clock.ts` | 타이머를 쓰는 로직(자동 저장 등)은 시계를 주입받는다 — 테스트는 손으로 돌리는 시계로 시간을 넘긴다(고정 대기 없이, [[testing]]) |
| 나가기 전 확인 | `src/composables/useLeaveConfirm.ts` · `src/shared/utils/compose-changes.ts` | 화면을 연 뒤(또는 마지막으로 저장한 뒤) 바꾼 것이 있으면 앱 안 이동(라우트 떠남·바뀜)은 확인 대화상자, 탭 닫기·새로고침은 `beforeunload`. 쓰기 화면은 떠나기 전에 먼저 저장하고 저장하지 못한 것이 있을 때만 묻는다(서명 설정은 저장하지 않은 서명이 있으면 묻는다). 가드가 도는 중은 `guarding`. 바꿈 여부는 연 직후(저장한 뒤에는 마지막으로 저장한 판)의 스냅숏과 비교한다(편집기는 `isChanged()`). 떠나기 전 저장은 5초까지만 기다린다. 보낸 뒤·보내는 중·세션이 끝났을 때는 묻지 않는다 — 보내는 중에 떠나면 결과는 토스트로만 알리고 떠난 양식·새 양식을 건드리지 않는다(양식 세대로 늦게 온 결과를 버린다, 디자인 README "나가기 전 확인") |
| 읽기 화면 본문 안내 | `src/shared/utils/mail-body-notices.ts` · `shared/constants/mail.ts` 의 `MAIL_BODY_NOTICES` | 서버의 `isHtmlBodyOmitted`·`isBodyTruncated`(계약 2절 "읽기 한도")를 외부 이미지 차단과 같은 안내 줄로 보인다(`.reader__notice[data-notice]`) |
| 쓰기 칸 포커스 | `src/styles/base.css` 의 `.compose-field` | 글자 입력 행(받는 사람·참조·숨은참조·제목·본문)은 아래 선 하나로 포커스를 보인다. 새 입력 행에도 이 클래스를 붙인다 — 칸마다 포커스 규칙을 따로 만들지 않는다 |
| 테두리 입력칸 | `src/styles/base.css` 의 `.form-input` · `.form-input__control` · `.form-message` | 설정 화면 · 메일함 이름 대화상자의 테두리 있는 칸(44)과 칸 아래 안내 · 오류(12.5px). 칸 안에 단추가 있는 상자(비밀번호 보기)는 상자에 `.form-input`, 입력 요소에 `.form-input__control` — 입력 요소에 초점이 있을 때만 상자에 초점을 보인다. 오류는 입력 요소의 `aria-invalid="true"`. 쓰기 화면의 밑줄 칸(`.compose-field`)과는 다른 규칙이다 |
| env 타입 선언 | `frontend/env.d.ts` | `VITE_API_BASE_URL` |
| dev API 프록시 | `vite.config.ts` 의 `server.proxy` | `/api` → 백엔드. 없으면 vite 가 SPA 폴백으로 **HTML 을 200 으로** 돌려줘 조용히 실패한다 |
| dev 서버 포트 | `vite.config.ts` 의 `loadEnv` | 루트 `.env.{mode}` 의 `FRONTEND_PORT`/`BACKEND_PORT` 를 읽는다. `strictPort` 로 포트 충돌 시 fail-fast ([[env-structure]]) |
@@ -36,8 +72,8 @@ description: Vue 3 프론트엔드(frontend/) 개발 규칙 — Composition API
> 두 포트 모두 `vite.config.ts` 가 **루트 `.env.{mode}` 에서 직접 읽는다** — 프론트 쪽에 포트를 중복 작성하지 않는다.
> **확장 시 따르는 규칙:**
> - **로그인 연동**: 실제 `/auth/login` 엔드포인트 연결 → 성공 시 `useAuth` 가 `authStore.markAuthenticated()` 호출
> - **로그인 페이지**: 추가 시 라우터 가드 리다이렉트를 `{ name: 'login' }` 으로 변경
> - **새 사내 모듈**(캘린더·파일·ERP 등): `app-modules.ts` 에 항목을 활성화하고, 라우트 + `AppShell` 안의 모듈 패널을 메일 모듈처럼 만든다. 셸을 새로 만들지 않는다.
> - **새 화면**: 구현 전에 디자인 캔버스로 먼저 확인받고, 확정본을 `docs/design/` 에 둔 뒤 그 기준으로 만든다. 뱃지(안 읽음 점·개수 알약·태그)는 쓰지 않는다.
> - **공통 컴포넌트**: `BaseButton` 패턴을 따라 `src/components/` 에 추가
---
@@ -146,7 +182,7 @@ try {
}
```
- Toast 라이브러리를 도입하면 `useApi.ts` 의 `showErrorUI()` **한 함수만** 교체한다.
- 알림 UI 는 `useApi.ts` 의 `showErrorUI()` 가 토스트(`toast.store`)로 띄운다. 다른 UI 로 바꿀 때도 **이 함수 하나만** 교체한다.
```typescript
// composables/useUser.ts — 도메인별 API Composable
@@ -219,11 +255,24 @@ router.beforeEach((to) => {
- [ ] **컴포넌트**: `<script setup lang="ts">` + `<style scoped>` + rem 단위. 인라인 스타일·Options API 금지.
- [ ] **타입**: Props/Emits 는 인터페이스로 정의.
- [ ] **라우트**: 페이지는 lazy import, 보호 라우트는 `meta.requiresAuth`.
- [ ] **auth 가드**: `router/index.ts` 가드는 `auth.store.ts` 의 `isLoggedIn` 과 이미 연결되어 있다. 로그인 페이지 추가 시 리다이렉트 대상을 `{ name: 'login' }` 으로 바꾼다.
- [ ] **auth 가드**: `router/index.ts` 가드는 첫 네비게이션 전에 세션을 복원한 뒤 `isLoggedIn` 으로 판정하고, 미인증은 `{ name: 'login', query: { redirect } }` 로 보낸다. 새 보호 화면은 `meta.requiresAuth` 만 붙인다.
- [ ] **외부 HTML**: 메일 본문처럼 외부에서 온 HTML 은 `MailBodyFrame` 으로만 그린다. `v-html` 로 직접 넣지 않는다.
외부 HTML 을 고쳐 쓸 때(붙여 넣기·원문 인용 다듬기)는 `<template>`·`DOMParser` 같은 **떨어진 문서 안에서 끝내고** `template.innerHTML` 로 직렬화한다
(`mail-serialize.ts` 의 `rewriteInert()`). 화면 문서의 요소로 옮기면(`append`) 입양되면서 `img onerror` 같은 처리기가 실행된다 —
운영 CSP 가 막아도 그것에만 기대지 않는다 (2026-10-27 사례, 회귀 테스트는 `mail-serialize.spec.ts`).
편집기로 읽어 들이는 서식은 **서버가 남기는 형식만** 받는다 (`outgoing-html-rules.ts`) — 서버만 지우면 편집기에서 숨은 글(`font-size:0` 등)이
받는 사람에게 보이는 식으로 화면과 보낸 메일이 달라진다 (2026-10-27 리뷰).
이어 쓰는 임시 저장본의 `bodyHtml`·서명 HTML 도 같은 경로로 편집기에 옮긴다 (다른 메일 프로그램이 만든 초안도 있다).
- [ ] **lint/test**: 커밋 전 `npm run lint` (`--max-warnings 0`) + `npm run type-check` + `npm run test:unit` 통과. 자동 수정은 `npm run lint:fix`.
테스트 시간 한도는 전역 20초(`vitest.config.ts` — jsdom·편집기 테스트가 부하에서 5~13초)이고, 긴 초안을 맞추는 테스트는 `src/test-utils/timeouts.ts` 의 한도를 쓴다.
"몇 ms 안에 끝난다"를 검사하는 성능 단언은 벽시계 대신 계산량(CPU 시간·비용)으로 잰다 — 벽시계는 다른 작업이 많으면 몇 배로 늘어
CI 에서 흔들린다 (2026-10-27 사례, backend 는 `backend/test/cpu-time.ts`).
- [ ] **린트로 강제되는 항목**: Options API·인라인 스타일·`console`·`any` 는 빌드가 실패한다.
- [ ] **env**: `VITE_API_BASE_URL` 은 빌드 시 루트 `BACKEND_API_URL` 에서 주입된다 — `frontend/.env` 에 별도 작성 불필요.
- [ ] **env**: `VITE_API_BASE_URL` 은 운영 빌드에서 `/api` 로 고정된다 (같은 주소 배포 — 운영은 프론트 nginx, 개발은 vite 프록시가 `/api` 를 백엔드로 넘긴다).
`frontend/.env` 에 별도 작성 불필요 — 다른 주소를 넣으면 쿠키(Path=/api)·CSP `connect-src 'self'`·출처 검사가 맞지 않는다 ([[docker]] "같은 주소 배포").
- [ ] **dev 서버**: 프론트엔드는 호스트에서 `npm run dev` 로 띄운다 (컨테이너는 `--profile frontend` 선택, [[docker]]).
- [ ] **dev 화면이 운영 빌드와 다를 때**(여백·폭이 통째로 사라지는 등): dev 서버가 전역 CSS(`styles/tokens.css`)의 변경을 놓쳐 옛 토큰을 내보내는 경우가 있었다.
`curl -s localhost:5173/src/styles/tokens.css` 에 새 변수가 없으면 dev 서버를 재시작하고 브라우저를 새로고침한다 (2026-10-26 사례 — 코드 문제 아님).
- [ ] **포트**: `vite.config.ts` 에 포트를 새로 하드코딩하지 않는다 — 루트 `.env.{mode}` 의 `FRONTEND_PORT`/`BACKEND_PORT` 가 SSOT ([[env-structure]]).
- [ ] **dev 에서 API 가 안 될 때**: 응답이 JSON 이 아니라 HTML 이면 vite 프록시(`server.proxy`) 문제다.
`baseURL` 이 `/api` 상대경로라 프록시가 없으면 index.html 이 200 으로 돌아온다.
+86 -7
View File
@@ -20,6 +20,18 @@ WEBSOCKET_PORT=3001
DB_PORT=5432
REDIS_PORT=6379
# ==========================================
# 내부 네트워크 (compose app-network · edge)
#
# 서브넷을 고정한다 — 메일 엔진은 app-network 대역만 신뢰한다 (게이트웨이 로그인·자동 차단 예외, mail/README.md).
# APP_NETWORK_SUBNET : backend·db·redis·mail(·mailpit) — 사설 대역, /24 ~ /28 (mail setup 이 검사한다)
# EDGE_NETWORK_SUBNET : frontend ↔ backend 전용 (frontend 는 app-network 에 붙지 않는다)
# 도커 기본 주소 풀(172.17~31.x, 192.168.x) 밖의 값이라 자동으로 배정되는 네트워크와 겹치지 않는다.
# 같은 호스트의 다른 프로젝트와 겹치면 up 이 "Pool overlaps" 로 실패한다 → 다른 /24 로 바꾸고 setup 을 다시 실행한다.
# ==========================================
APP_NETWORK_SUBNET=172.16.71.0/24
EDGE_NETWORK_SUBNET=172.16.72.0/24
# ==========================================
# Database 설정
# ==========================================
@@ -37,10 +49,16 @@ REDIS_PASSWORD=change-me-in-local-env
REDIS_DATA_DIR=./data/redis
# ==========================================
# API 경로 설정
# 화면·API 주소 — 같은 주소(origin) 배포 (docs/mail/api-contract.md 머리말)
#
# 브라우저는 화면과 같은 주소의 /api 를 부른다 (개발은 vite 프록시, 운영은 프론트 nginx 가 백엔드로 넘긴다).
# - CORS_ORIGIN : 화면의 주소(origin). 상태 변경 요청의 출처 검사에 쓴다 — FRONTEND_PORT 를 바꾸면 함께 고칠 것
# - BACKEND_API_URL : 화면 주소 + /api. 앱은 쓰지 않고, 운영 기동 때 공개 주소인지 검사하는 데만 쓴다
# - TRUST_PROXY : 백엔드 앞 프록시 홉 수. 개발은 vite 프록시 하나라 1 (비우면 1)
# ==========================================
BACKEND_API_URL=http://localhost:3000/api
BACKEND_API_URL=http://localhost:5173/api
CORS_ORIGIN=http://localhost:5173
TRUST_PROXY=1
# ==========================================
# Rate Limiting (API 남용 방지)
@@ -61,21 +79,82 @@ DB_SYNCHRONIZE=false
# ==========================================
# 선택적 서비스 (Compose profiles)
#
# 기본값(비어 있음) = redis 와 frontend 컨테이너를 기동하지 않는다.
# 기본값 = mail — 메일 엔진(Stalwart)과 개발용 Mailpit 을 띄운다.
# 직원 로그인이 메일 엔진에 의존하므로 개발에서도 켜 두는 것을 권장한다.
# (redis 와 frontend 컨테이너는 기동하지 않는다)
#
# redis 캐시·세션 공유가 필요해질 때 → COMPOSE_PROFILES=redis
# frontend vite 를 컨테이너로 띄워야 할 때 → COMPOSE_PROFILES=frontend
# 둘 다 → COMPOSE_PROFILES=redis,frontend
# mail 메일 엔진 + Mailpit (기본) → COMPOSE_PROFILES=mail
# redis 캐시·세션 공유가 필요해질 때 → COMPOSE_PROFILES=mail,redis
# frontend vite 를 컨테이너로 띄워야 할 때 → COMPOSE_PROFILES=mail,frontend
#
# 일회성으로 켜려면: docker compose --profile redis ... up -d
#
# ⚠️ 개발 환경의 프론트엔드는 호스트에서 띄우는 것이 기본이다 (`cd frontend && npm run dev`).
# 컨테이너 vite 는 바인드 마운트 때문에 첫 서빙까지 수 분이 걸린다.
# ==========================================
COMPOSE_PROFILES=
COMPOSE_PROFILES=mail
# ==========================================
# 파일 업로드 (로컬 디스크)
#
# UPLOAD_DATA_DIR 는 개발 전용 — 호스트 디렉터리에 바로 쌓여 열어 볼 수 있다 (운영은 named volume <PROJECT_NAME>_uploads).
# 리눅스 호스트는 처음 한 번 mkdir -p data/uploads (없으면 도커가 root 소유로 만들어 backend 가 쓰지 못한다)
# ==========================================
UPLOAD_DATA_DIR=./data/uploads
UPLOAD_MAX_SIZE_MB=5
# ==========================================
# 메일 엔진 (Stalwart) — profile `mail`
#
# 직원 로그인 = 메일 계정. 백엔드는 내부 JMAP 으로 메일 엔진에 붙는다.
# 처음 띄운 뒤 한 번 실행한다 (여러 번 실행해도 안전하다):
# node --env-file=.env.development mail/scripts/setup.mjs
# 자세한 내용: mail/README.md
#
# 개발 도메인은 외부와 섞이지 않는 예약 TLD(.test)를 쓴다.
# ⚠️ 약한 비밀번호는 Stalwart 의 강도 정책이 거부한다. 복사 후 무작위 값으로 교체할 것.
# ⚠️ 비밀번호는 영문·숫자로만 만든다. # · 따옴표 · $ · 공백은 compose 와 Node(--env-file)가
# 서로 다르게 읽어(Node 는 # 부터 주석으로 자른다) 컨테이너와 setup 스크립트의 값이 어긋난다.
# ==========================================
# 패치까지 고정한다 — setup 스크립트가 0.16 의 동작(관리 API·bootstrap)에 맞춰져 있다 (docker 스킬 "이미지 버전 고정")
MAIL_IMAGE=stalwartlabs/stalwart:v0.16.27
MAIL_DOMAIN=arasystem.test
# 서버 호스트명 — 최초 bootstrap 때만 반영된다 (이후 변경은 관리 화면에서)
MAIL_HOSTNAME=mail.arasystem.test
# 관리 화면·setup 스크립트용 HTTP 포트 (127.0.0.1 로만 열린다)
MAIL_ADMIN_PORT=8180
# JMAP·OAuth 디스커버리 기준 주소. 개발은 관리 화면 주소 — MAIL_ADMIN_PORT 를 바꾸면 함께 고칠 것
MAIL_PUBLIC_URL=http://localhost:8180
# 영구 관리자 아이디 (주소는 <이 값>@MAIL_DOMAIN, 미설정 시 admin) — setup 스크립트가 이 이름으로 관리자를 만들고,
# 백엔드는 이 계정의 웹메일 로그인·대리 접근을 막는다 (두 쪽이 이 값 하나를 읽는다).
# ⚠️ bootstrap 전에만 정할 수 있다. bootstrap 뒤에 이 값만 바꾸면 엔진 관리자는 그대로 남고 백엔드는 새 이름만 막게 되므로,
# setup 이 중단한다 (되돌리는 절차: mail/README.md "문제 해결"). 옮겨 올 메일함에 같은 주소가 있으면 다른 이름으로 정한다.
# 영문·숫자·. _ - 만, 앞뒤는 영문·숫자, 64자 이하 (백엔드와 같은 규칙)
MAIL_ADMIN_LOCAL_PART=admin
# 영구 관리자 비밀번호 — setup 스크립트가 이 값으로 맞춘다
MAIL_ADMIN_PASSWORD=change-me-in-local-env
# 최초 기동(bootstrap)용 임시 관리자 "아이디:비밀번호". ⚠️ 값이 있는 동안은 정상 모드에서도 관리자로 로그인된다
# (개발은 127.0.0.1 에만 열려 있어 이 파일에 둔다 — 운영은 파일에 두지 않고 일회성 override 로만 쓴다)
MAIL_RECOVERY_ADMIN=admin:change-me-in-local-env
# 백엔드가 직원 메일함을 대리 접근(impersonate)할 때 쓰는 서비스 계정 (주소 전체).
# ⚠️ 이 비밀번호는 전 직원 메일함의 열쇠다
MAIL_GATEWAY_USER=mail-gateway@arasystem.test
MAIL_GATEWAY_PASSWORD=change-me-in-local-env
# 백엔드가 쓰는 내부 JMAP 주소 (compose 내부망). 보통 바꿀 일이 없다
MAIL_JMAP_URL=http://mail:8080
# 첨부 1개 최대 크기(MB, 미설정 시 20). 프론트 nginx 의 본문 한도가 이 값 + 1MB 로 따라간다
# (그 앞에 외부 리버스 프록시를 두면 그쪽 한도도 맞춘다 — docker 스킬 "운영 환경")
MAIL_ATTACHMENT_MAX_SIZE_MB=20
# 메일 한 통의 첨부 합계(MB, 미설정 시 25) — 파일당 한도 이상이어야 한다
MAIL_ATTACHMENT_TOTAL_MAX_SIZE_MB=25
# 게이트웨이 계정을 쓸 수 있고 자동 차단에서 빠지는 대역 (콤마 구분).
# 비우면 APP_NETWORK_SUBNET 과 같다. 그 안의 더 좁은 대역만 쓸 수 있다 — 더 넓으면 setup 이 실패한다
MAIL_TRUSTED_NETWORKS=
# (선택) 웹메일 로그인·대리 접근을 막을 계정 — 주소 전체를 콤마로. 비어 있어도 위 관리자(MAIL_ADMIN_LOCAL_PART)는 항상 막힌다.
# 관리자를 더 만들었다면 여기에 넣는다 (setup 이 빠진 관리자 계정을 [주의] 로 알려 준다)
# MAIL_BLOCKED_ACCOUNTS=ops@arasystem.test
# 개발용 테스트 직원 계정(ara.kim@ 등)의 공통 비밀번호 — 개발 환경에서만 만들어진다
MAIL_DEV_USER_PASSWORD=change-me-in-local-env
# 개발 전용 메일 수집기 — 사내 도메인 밖으로 보낸 메일이 여기에만 쌓인다
MAILPIT_IMAGE=axllent/mailpit:v1.32.0
MAILPIT_UI_PORT=8025
+97 -14
View File
@@ -16,6 +16,19 @@ WEBSOCKET_PORT=3001
DB_PORT=5432
REDIS_PORT=6379
# ==========================================
# 내부 네트워크 (compose app-network · edge)
#
# 서브넷을 고정한다 — 메일 엔진은 app-network 대역만 신뢰한다 (게이트웨이 로그인·자동 차단 예외, mail/README.md).
# APP_NETWORK_SUBNET : backend·db·redis·mail — 사설 대역, /24 ~ /28 (mail setup 이 검사한다)
# EDGE_NETWORK_SUBNET : frontend(nginx) ↔ backend 전용. 인터넷을 향한 frontend 는 app-network 에 붙지 않는다.
# TRUST_PROXY 를 주소 목록으로 쓸 때 이 대역을 넣는다 (아래)
# 도커 기본 주소 풀(172.17~31.x, 192.168.x) 밖의 값이라 자동으로 배정되는 네트워크와 겹치지 않는다.
# 같은 호스트의 다른 프로젝트와 겹치면 up 이 "Pool overlaps" 로 실패한다 → 다른 /24 로 바꾸고 setup 을 다시 실행한다.
# ==========================================
APP_NETWORK_SUBNET=172.16.71.0/24
EDGE_NETWORK_SUBNET=172.16.72.0/24
# ==========================================
# Database 설정
# ==========================================
@@ -33,21 +46,31 @@ REDIS_PASSWORD=CHANGE-ME-BEFORE-DEPLOY
REDIS_DATA_DIR=./data/redis
# ==========================================
# API 경로 설정
# 화면·API 주소 — 같은 주소(origin) 배포 (docs/mail/api-contract.md 머리말)
#
# ⚠️ 운영에서는 **반드시 실제 공개 도메인**으로 교체한다.
# - BACKEND_API_URL : 브라우저가 호출할 API 주소 (프론트엔드 번들에 그대로 박힌다)
# - CORS_ORIGIN : 백엔드가 허용할 오리진 (scheme+host[+port], 여러 개면 콤마 구분)
# 브라우저는 포털 주소의 /api 를 부르고 프론트 nginx 가 백엔드로 넘긴다. 운영의 입구는 프론트 nginx 하나다.
# - CORS_ORIGIN : 화면의 공개 주소(origin, 여러 개면 콤마). 상태 변경 요청의 출처 검사에 쓴다 — 운영 필수
# - BACKEND_API_URL : 화면 주소 + /api. 앱은 쓰지 않고, 운영 기동 때 공개 주소인지 검사하는 데만 쓴다
#
# 아래 example.com 이나 localhost 가 남아 있으면 **백엔드가 기동에 실패한다**
# localhost 나 example.com 이 남아 있으면 **백엔드가 기동에 실패한다**
# (backend/src/shared/config/env.validation.ts — fail-fast).
# 기동은 되는데 브라우저만 조용히 실패하는 상황을 막기 위한 검사다.
#
# 로컬에서 운영 이미지를 검증하느라 일부러 localhost 를 쓰는 경우에만
# ALLOW_LOCALHOST_URL=true 로 이 검사를 건너뛴다. 실제 배포에는 설정하지 않는다.
#
# - TRUST_PROXY : 백엔드 앞 프록시 — 실제 클라이언트 IP(로그인 실패 한도의 기준)를 읽는다. 운영 필수
# 이 compose 의 운영 경로는 클라이언트 → 외부 리버스 프록시(TLS) → 프론트 nginx → 백엔드 하나뿐이다.
# (프론트 nginx 는 호스트 포트를 열지 않고 TLS 도 하지 않으므로 외부 프록시 없이는 들어올 수 없다 — 1 은 해당 없음)
# 2 = 홉 수. proxy 네트워크를 이 프로젝트의 외부 프록시만 쓸 때
# 주소 목록 = <EDGE_NETWORK_SUBNET>,<외부 프록시 IP> (예: 172.16.72.0/24,172.20.0.10) — proxy 네트워크를
# 다른 프로젝트와 함께 쓴다면 이쪽을 권장한다. 홉 수는 "몇 번째 주소"만 믿어서, 같은 네트워크의 다른
# 컨테이너가 외부 프록시를 건너뛰고 frontend 에 바로 붙으면 X-Forwarded-For 를 꾸며 IP 를 바꿀 수 있다.
# 외부 프록시 IP 는 frontend 가 보는 주소(proxy 네트워크 안 주소)이며 고정해 둔다(ipv4_address 등).
# 넓은 사설 대역을 통째로 넣지 않는다.
# true·0·false 는 운영에서 기동이 거부된다.
# ==========================================
BACKEND_API_URL=https://api.example.com/api
CORS_ORIGIN=https://app.example.com
BACKEND_API_URL=https://portal.arasystem.it.kr/api
CORS_ORIGIN=https://portal.arasystem.it.kr
TRUST_PROXY=2
# ==========================================
# Rate Limiting (API 남용 방지)
@@ -68,15 +91,75 @@ DB_SYNCHRONIZE=false
# ==========================================
# 선택적 서비스 (Compose profiles)
#
# 기본값(비어 있음) = redis 를 기동하지 않는다.
# 캐시·세션 공유가 필요해지면 아래를 redis 로 바꾸면 컨테이너가 함께 뜬다.
# COMPOSE_PROFILES=redis
# 기본값 = mail — 메일 엔진(Stalwart)을 띄운다. 직원 로그인이 메일 엔진에 의존한다.
# (redis 는 기동하지 않는다)
# 캐시·세션 공유가 필요해지면 redis 를 추가하면 컨테이너가 함께 뜬다.
# COMPOSE_PROFILES=mail,redis
# 일회성으로 켜려면: docker compose --profile redis ... up -d
# ==========================================
COMPOSE_PROFILES=
COMPOSE_PROFILES=mail
# ==========================================
# 파일 업로드 (로컬 디스크)
#
# 저장 위치는 named volume <PROJECT_NAME>_uploads 다 (docker-compose.yml). 호스트 디렉터리를 쓰지 않는다 —
# sudo docker compose 가 없는 디렉터리를 root 소유로 만들어 backend(node)가 쓰지 못한다.
# (UPLOAD_DATA_DIR 는 개발 전용이라 운영에는 두지 않는다. 백업은 docker 스킬 "운영 환경")
# ==========================================
UPLOAD_DATA_DIR=./data/uploads
UPLOAD_MAX_SIZE_MB=5
# ==========================================
# 메일 엔진 (Stalwart) — profile `mail`
#
# 운영 기본 가정 = 메일 전용 호스트: 25·465·993·443 을 이 호스트에 직접 연다 (docker-compose.yml).
# 첫 배포 후 setup 스크립트를 한 번 실행한다 — mail/README.md "운영 배포".
#
# ⚠️ 비밀번호는 모두 강한 무작위 값으로 교체한다. CHANGE-ME 가 남아 있으면 setup 스크립트가 중단한다.
# 영문·숫자로만 만든다 — # · 따옴표 · $ · 공백은 compose 와 Node 가 다르게 읽는다.
# 예: node -e "console.log(require('crypto').randomBytes(24).toString('hex'))"
# ⚠️ 8080(관리 API)은 공개하지 않는다. setup 스크립트는 내부망에서 실행한다 (README 참조).
# 공개 443 의 JMAP·관리 API·웹 포털은 setup 이 막는다 (MTA-STS 정책 파일만 열어 둔다).
# ==========================================
# 패치까지 고정한다 — setup 스크립트가 0.16 의 동작(관리 API·bootstrap)에 맞춰져 있다 (docker 스킬 "이미지 버전 고정")
MAIL_IMAGE=stalwartlabs/stalwart:v0.16.27
MAIL_DOMAIN=arasystem.it.kr
# 새 엔진의 호스트명 (EHLO·PTR·TLS 인증서) — 최초 bootstrap 때만 반영된다.
# mailcow 가 mail.arasystem.it.kr 를 쓰는 전환 기간에는 새 이름(mx1.)을 쓴다 (mail/README.md "전환 절차")
MAIL_HOSTNAME=mx1.arasystem.it.kr
# JMAP·OAuth 디스커버리 기준 주소 — 새 엔진에 실제로 닿는 주소여야 한다
MAIL_PUBLIC_URL=https://mx1.arasystem.it.kr
# 영구 관리자 아이디 (주소는 <이 값>@MAIL_DOMAIN, 미설정 시 admin) — setup 스크립트가 이 이름으로 관리자를 만들고,
# 백엔드는 이 계정의 웹메일 로그인·대리 접근을 막는다 (두 쪽이 이 값 하나를 읽는다).
# ⚠️ 운영 bootstrap 전에 정한다. mailcow 에 실제 admin@ 메일함이 있으면 다른 이름으로 한다
# (그대로 두면 엔진 관리자와 주소가 겹쳐 그 메일함은 옮겨 와도 웹메일에 로그인할 수 없다).
# ⚠️ bootstrap 뒤에 이 값만 바꾸면 엔진 관리자는 그대로 남고 백엔드는 새 이름만 막게 되므로, setup 이 중단한다
# (되돌리는 절차: mail/README.md "문제 해결"). 영문·숫자·. _ - 만, 앞뒤는 영문·숫자, 64자 이하 (백엔드와 같은 규칙)
MAIL_ADMIN_LOCAL_PART=admin
# 영구 관리자 비밀번호 — setup 스크립트가 이 값으로 맞춘다
MAIL_ADMIN_PASSWORD=CHANGE-ME-BEFORE-DEPLOY
# 복구 관리자(MAIL_RECOVERY_ADMIN, "아이디:비밀번호")는 이 파일에 적지 않는다 — 최초 bootstrap·관리자 복구 때
# 셸에서 일회성으로만 준다. 값이 있는 동안은 정상 모드에서도 관리자로 로그인되기 때문이다.
# 이 파일에 값이 있거나, 끝난 뒤에도 mail 컨테이너에 남아 있으면 setup 이 실패한다 (mail/README.md "운영 배포").
# read -rsp '임시 복구 비밀번호: ' P; echo; export MAIL_RECOVERY_ADMIN="admin:$P"
# docker compose --env-file .env.production up -d mail
# node --env-file=.env.production mail/scripts/setup.mjs --recovery
# unset MAIL_RECOVERY_ADMIN P; docker compose --env-file .env.production up -d mail # 복구 관리자 끄기
# node --env-file=.env.production mail/scripts/setup.mjs # 꺼졌는지 확인
# 백엔드가 직원 메일함을 대리 접근(impersonate)할 때 쓰는 서비스 계정 (주소 전체).
# ⚠️ 이 비밀번호는 전 직원 메일함의 열쇠다
MAIL_GATEWAY_USER=mail-gateway@arasystem.it.kr
MAIL_GATEWAY_PASSWORD=CHANGE-ME-BEFORE-DEPLOY
# 백엔드가 쓰는 내부 JMAP 주소 (compose 내부망)
MAIL_JMAP_URL=http://mail:8080
# 첨부 1개 최대 크기(MB, 미설정 시 20). 프론트 nginx 의 본문 한도는 이 값 + 1MB 로 자동으로 따라간다.
# ⚠️ 그 앞의 외부 리버스 프록시 본문 한도는 직접 이 값 + 1MB 이상으로 둔다 —
# 작으면 프록시가 먼저 413 을 내고 사용자에게는 FILE_002 로 보인다 (docker 스킬 "운영 환경")
MAIL_ATTACHMENT_MAX_SIZE_MB=20
# 메일 한 통의 첨부 합계(MB, 미설정 시 25) — 파일당 한도 이상이어야 한다
MAIL_ATTACHMENT_TOTAL_MAX_SIZE_MB=25
# 게이트웨이 계정을 쓸 수 있고 자동 차단에서 빠지는 대역 (콤마 구분).
# 비우면 APP_NETWORK_SUBNET 과 같다. 그 안의 더 좁은 대역만 쓸 수 있다 — 더 넓으면 setup 이 실패한다
MAIL_TRUSTED_NETWORKS=
# (선택) 웹메일 로그인·대리 접근을 막을 계정 — 주소 전체를 콤마로. 비어 있어도 위 관리자(MAIL_ADMIN_LOCAL_PART)는 항상 막힌다.
# 관리자를 더 만들었다면 여기에 넣는다 (setup 이 빠진 관리자 계정을 [주의] 로 알려 준다)
# MAIL_BLOCKED_ACCOUNTS=ops@arasystem.it.kr
+72 -5
View File
@@ -10,9 +10,17 @@ concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
# lock(package-lock.json)을 쓰고 검사하는 npm 버전 — backend·frontend Dockerfile 의 NPM_VERSION, docker 스킬 문서와 같은 값.
# setup-node 의 Node 에 딸린 npm(Node 24 는 11.19 등 — 패치마다 바뀐다)은 lock 의 중첩 항목을 다르게 정리하므로 그대로 쓰지 않는다.
# Node 는 24 LTS 패치까지 고정 — backend·frontend Dockerfile 의 ARG NODE_VERSION 과 같은 값 (세 잡이 이 값 하나를 쓴다).
# 갱신 규칙(일반 패치는 배포 2주 뒤, 보안 릴리스는 CI 통과 즉시)은 docker 스킬 문서 "Node 버전".
env:
NPM_VERSION: '11.6.2'
NODE_VERSION: '24.22.1'
jobs:
backend:
name: Backend (lint · test · e2e · build)
name: Backend (lint · type-check · test · e2e · build)
runs-on: ubuntu-latest
# 멈춘 잡이 러너를 무한 점유하지 않도록 상한을 둔다
timeout-minutes: 20
@@ -43,16 +51,23 @@ jobs:
- uses: actions/setup-node@v4
with:
node-version: '20'
node-version: ${{ env.NODE_VERSION }}
cache: npm
cache-dependency-path: backend/package-lock.json
- name: npm 버전 고정
run: npm install -g npm@${{ env.NPM_VERSION }} && npm --version
- run: npm ci
# Warning 0 (.claude/rules/checklist.md)
- name: Lint
run: npm run lint
# tsc --noEmit -p tsconfig.json — 빌드(tsconfig.build.json)가 빼는 spec·test 파일까지 타입 검사한다
- name: Type check
run: npm run type-check
- name: Test
run: npm run test
@@ -72,7 +87,7 @@ jobs:
run: npm run build
frontend:
name: Frontend (lint · type-check · test)
name: Frontend (lint · type-check · test · build)
runs-on: ubuntu-latest
# 멈춘 잡이 러너를 무한 점유하지 않도록 상한을 둔다
timeout-minutes: 15
@@ -84,10 +99,13 @@ jobs:
- uses: actions/setup-node@v4
with:
node-version: '20'
node-version: ${{ env.NODE_VERSION }}
cache: npm
cache-dependency-path: frontend/package-lock.json
- name: npm 버전 고정
run: npm install -g npm@${{ env.NPM_VERSION }} && npm --version
- run: npm ci
- name: Lint
@@ -99,6 +117,52 @@ jobs:
- name: Test
run: npm run test:unit -- --run
# 운영 이미지와 같은 vite 빌드 (타입 검사는 위에서 했으므로 build-only)
- name: Build
run: npm run build-only
infra:
name: Infra (compose · Dockerfile · nginx)
runs-on: ubuntu-latest
# 멈춘 잡이 러너를 무한 점유하지 않도록 상한을 둔다
timeout-minutes: 10
# 잡 컨테이너 안에서 docker 를 쓴다 — act_runner 기본 설정(docker_host 비움)이 호스트 도커 소켓을 잡 컨테이너에 붙여 주고,
# 잡 이미지(gitea/runner-images:ubuntu-latest)에 docker CLI·compose 가 들어 있다 (docker 스킬 "CI 러너").
# 빌드 컨텍스트는 API 로 올라가므로 잡 컨테이너 안의 체크아웃으로 빌드할 수 있다 (-v 바인드 마운트는 호스트 경로라 쓰지 않는다)
steps:
- uses: actions/checkout@v4
- name: compose 문법·변수 치환 (예시 env)
run: |
# env_file 대상(backend/.env.*)은 커밋되지 않는다 — 빈 파일로 자리만 채운다
touch backend/.env.development backend/.env.production
docker compose -f docker-compose.yml -f docker-compose.dev.yml --env-file .env.development.example config -q
docker compose -f docker-compose.yml -f docker-compose.dev.yml --env-file .env.development.example --profile frontend config -q
docker compose -f docker-compose.yml --env-file .env.production.example config -q
- name: Dockerfile 검사
run: |
docker build --check backend
docker build --check frontend
# 운영 이미지의 nginx 단계만 만든다 (화면 빌드 없음 — frontend/Dockerfile 의 nginx-config)
- name: nginx 설정 검사
run: |
tag="ci-nginx-config:${{ github.run_id }}"
docker build --target nginx-config -t "$tag" frontend
docker run --rm -e PROJECT_NAME=ci "$tag" nginx -t
# 잘못된 값은 기동을 멈춰야 한다 (frontend/nginx-env.envsh — 앞자리 0, 경로가 붙은 백엔드 주소)
if docker run --rm -e PROJECT_NAME=ci -e MAIL_ATTACHMENT_MAX_SIZE_MB=08 "$tag" nginx -t; then
echo "::error::MAIL_ATTACHMENT_MAX_SIZE_MB=08 을 받아들였습니다 (거부해야 한다)"; exit 1
fi
if docker run --rm -e NGINX_BACKEND_UPSTREAM=http://ci-backend:3000/ "$tag" nginx -t; then
echo "::error::경로가 붙은 NGINX_BACKEND_UPSTREAM 을 받아들였습니다 (거부해야 한다)"; exit 1
fi
- name: 정리
if: always()
run: docker image rm -f "ci-nginx-config:${{ github.run_id }}" || true
error-codes-sync:
name: 에러 코드 SSOT 동기화 검사
runs-on: ubuntu-latest
@@ -109,10 +173,13 @@ jobs:
- uses: actions/setup-node@v4
with:
node-version: '20'
node-version: ${{ env.NODE_VERSION }}
cache: npm
cache-dependency-path: backend/package-lock.json
- name: npm 버전 고정
run: npm install -g npm@${{ env.NPM_VERSION }} && npm --version
- name: Install backend deps
working-directory: backend
run: npm ci
+5 -2
View File
@@ -11,14 +11,17 @@
# ==========================================
# ⚠️ 루트 기준으로 고정한다(앞의 /). 'data/' 로 두면 모든 depth 의 data 디렉터리가 무시되어
# flutter/lib/features/<도메인>/data/ 같은 소스 코드까지 커밋되지 않는다.
/data/
# 디렉터리(/data/)가 아니라 내용(/data/*)을 무시하고 .gitkeep 만 추적한다 — 그래야 클론할 때 data/ 가
# 클론한 사용자 소유로 생긴다. 없으면 운영의 `sudo docker compose up` 이 root 소유로 만들어
# 메일 setup 스크립트가 data/mail/ 에 쓰지 못한다 (mail/README.md "운영 배포").
# (개발 DB·업로드 등 data/ 아래 내용은 모두 무시된다)
/data/*
!/data/.gitkeep
/db-data/
/redis-data/
# CI 러너 등록 정보
ci/runner-data/
# 업로드 파일 (로컬 저장) — 코드가 아니다
/data/uploads/
backend/uploads/
# ==========================================
+12 -4
View File
@@ -12,10 +12,15 @@ NestJS(백엔드) · Vue 3(웹) · Flutter(모바일)를 하나의 저장소에
| `flutter/` | Flutter + Riverpod | 모바일 앱 | - |
| `db` | PostgreSQL | 데이터베이스 | 5432 |
| `redis` | Redis | 캐시 / 세션 (**기본 비활성**) | 6379 |
| `mail` | Stalwart (JMAP) | 사내 메일 엔진 — 직원 로그인 = 메일 계정 (profile `mail`, [`mail/README.md`](./mail/README.md)) | 운영 25·465·993·443 / 개발 관리 8180 |
| `mailpit` | Mailpit | **개발 전용** — 사내 도메인 밖으로 보낸 메일 수집 | 8025 |
## 사전 요구사항
- Docker / Docker Compose — backend·frontend·db·redis 구동
- Docker / Docker Compose — backend·frontend·db·redis 구동 (컨테이너 안 런타임은 Node 24 LTS — `node:24.22.1-alpine3.24` 고정, 갱신 규칙은 docker 스킬 "Node 버전")
- Node.js **24 LTS 권장** — 호스트에서 프론트엔드 dev 서버·메일 setup 스크립트를 실행할 때 필요하다.
프론트 `engines` 는 `^20.19.0 || >=22.12.0` 이라 20.19 이상이면 돌아가지만, 이미지·CI(24)와 맞추는 것을 권장한다
(메일 setup 스크립트는 `--env-file` 때문에 20.6 이상)
- Flutter SDK `^3.11` — 모바일 앱 실행용 (compose 대상 아님)
## 빠른 시작 (Docker)
@@ -28,9 +33,12 @@ cp .env.development.example .env.development # ← 여기
cp backend/.env.development.example backend/.env.development # (존재 필요: JWT 등 고유 시크릿)
# frontend 는 compose 가 빌드 시 값을 주입하므로 자체 env 복사 불필요
# 2) 백엔드 스택 기동 (backend + db)
# 2) 백엔드 스택 기동 (backend + db, 기본 COMPOSE_PROFILES=mail 이면 mail + mailpit 포함)
docker compose -f docker-compose.yml -f docker-compose.dev.yml --env-file .env.development up -d
# 2-1) 메일 엔진 초기화 — 처음 한 번 (여러 번 실행해도 안전, mail/README.md)
node --env-file=.env.development mail/scripts/setup.mjs
# 3) 의존성 변경 시에만 --build
docker compose -f docker-compose.yml -f docker-compose.dev.yml --env-file .env.development up -d --build
@@ -92,7 +100,7 @@ cd flutter && cp .env.example .env && flutter pub get && flutter run
| ----------------------------- | ---------------------------------------- | --------------------- |
| `.env.{env}.example` | 루트 — 모든 설정의 SSOT (compose 주입) | ✅ 여기에 작성 |
| `backend/.env.{env}.example` | 백엔드 — 고유 시크릿(JWT 등)만 | 최소 (존재 필수) |
| `frontend/.env.{env}.example` | 프론트 — 빌드 시 루트 `BACKEND_API_URL` 주입 | 작성 불필요 |
| `frontend/.env.{env}.example` | 프론트 — API 주소는 같은 출처의 `/api` 고정(빌드 arg) | 작성 불필요 |
| `flutter/.env.example` | 모바일 — `API_URL` (compose 비대상) | ✅ 독립 관리 |
`{env}` = `development` | `production`
@@ -191,7 +199,7 @@ lint 는 `--max-warnings 0` 이라 경고 하나만 있어도 실패합니다.
├── docker-compose.dev.yml # 개발 오버라이드 (핫리로드, 포트 노출)
├── CLAUDE.md # 프로젝트 개요 + 공통 규칙 @import
├── .github/
│ ├── workflows/ci.yml # lint·test·build + 에러 코드 동기화 검사
│ ├── workflows/ci.yml # lint·test·build + 에러 코드 동기화 + 인프라(compose·Dockerfile·nginx) 검사
│ └── pull_request_template.md
├── ci/ # CI 러너 (Gitea Actions) — 앱 스택과 분리
└── .claude/
+19
View File
@@ -0,0 +1,19 @@
# ==========================================
# 이미지 빌드 컨텍스트 제외 목록
#
# 이미지에는 소스와 lock 파일만 들어가야 한다.
#
# node_modules : 호스트(Windows)에서 설치한 node_modules 가 `COPY . .` 로 들어가면
# `npm ci` 가 설치한 Linux 용 의존성을 덮어쓴다 (네이티브 모듈이 깨진다).
# dev 의 익명 볼륨(/app/node_modules)도 이미지 내용으로 초기화되므로 같은 문제가 생긴다.
# .env* : 비밀값이 이미지 레이어에 영구히 남는다. 값은 compose 가 런타임에 주입한다.
# ==========================================
node_modules
dist
coverage
uploads
logs
*.log
.env
.env.*
.git
+8 -1
View File
@@ -9,7 +9,14 @@
# docker-compose 의 env_file 지시자 때문에 파일은 비어 있어도 존재해야 한다.
# ==========================================
# JWT 서명 키 — 인증 도입 시 사용. 환경마다 다른 값을 쓰고 절대 커밋하지 않는다.
# JWT 서명 키 — 액세스·리프레시 토큰을 서명한다. 환경마다 다른 값을 쓰고 절대 커밋하지 않는다.
# 반드시 **무작위로 만든다.** 사람이 지은 문장·단어 조합·회사명·날짜 등은 쓰지 않는다
# (토큰 하나만 손에 넣으면 오프라인에서 키를 대입해 볼 수 있고, 맞히면 모든 직원의 토큰을 위조할 수 있다).
# 만드는 법: openssl rand -base64 48
# (openssl 이 없으면) node -e "console.log(require('crypto').randomBytes(48).toString('base64url'))"
# 운영은 32자 이상이 아니면 기동하지 않는다 (src/shared/config/env.validation.ts).
JWT_SECRET=change-me-generate-a-long-random-string
# 만료 — 액세스 5분~1시간, 리프레시(미사용 만료) 30일 이하이며 액세스보다 길어야 한다 (env 검증).
# 세션 절대 만료(로그인 후 30일)는 코드 상수다.
JWT_ACCESS_EXPIRES_IN=1h
JWT_REFRESH_EXPIRES_IN=14d
+8 -1
View File
@@ -9,7 +9,14 @@
# docker-compose 의 env_file 지시자 때문에 파일은 비어 있어도 존재해야 한다.
# ==========================================
# JWT 서명 키 — 인증 도입 시 사용. 환경마다 다른 값을 쓰고 절대 커밋하지 않는다.
# JWT 서명 키 — 액세스·리프레시 토큰을 서명한다. 환경마다 다른 값을 쓰고 절대 커밋하지 않는다.
# 반드시 **무작위로 만든다.** 사람이 지은 문장·단어 조합·회사명·날짜 등은 쓰지 않는다
# (토큰 하나만 손에 넣으면 오프라인에서 키를 대입해 볼 수 있고, 맞히면 모든 직원의 토큰을 위조할 수 있다).
# 만드는 법: openssl rand -base64 48
# (openssl 이 없으면) node -e "console.log(require('crypto').randomBytes(48).toString('base64url'))"
# 운영은 32자 이상이 아니면 기동하지 않는다 (src/shared/config/env.validation.ts).
JWT_SECRET=change-me-generate-a-long-random-string
# 만료 — 액세스 5분~1시간, 리프레시(미사용 만료) 30일 이하이며 액세스보다 길어야 한다 (env 검증).
# 세션 절대 만료(로그인 후 30일)는 코드 상수다.
JWT_ACCESS_EXPIRES_IN=1h
JWT_REFRESH_EXPIRES_IN=14d
+21 -6
View File
@@ -1,9 +1,18 @@
FROM node:20-alpine AS builder
# lock(package-lock.json)을 쓰고 검사하는 npm 은 한 버전으로 고정한다.
# npm 은 버전마다 lock 의 중첩 항목(chokidar·readdirp·@emnapi 등)을 다르게 정리해서, 섞어 쓰면 lock 이 매번 바뀐다.
# 바꿀 때는 frontend/Dockerfile · .github/workflows/ci.yml 의 NPM_VERSION · docker 스킬 문서를 함께 고친다.
ARG NPM_VERSION=11.6.2
# 런타임은 Node 24 LTS — 패치까지 고정한다 (메이저 태그는 빌드할 때마다 그날의 최신 패치를 받는다).
# 갱신 규칙(일반 패치는 배포 2주 뒤, 보안 릴리스는 CI 통과 즉시)과 함께 고치는 곳은 docker 스킬 문서 "Node 버전" 참조.
ARG NODE_VERSION=24.22.1
FROM node:${NODE_VERSION}-alpine3.24 AS builder
ARG NPM_VERSION
WORKDIR /app
# 호스트의 npm 11 이 작성한 package-lock.json 형식과 호환되도록 npm 을 11 로 업그레이드
# (전역 설치는 root 권한이 필요하므로 USER 전환 전에 수행)
RUN npm install -g npm@11
# 전역 설치는 root 권한이 필요하므로 USER 전환 전에 수행
RUN npm install -g npm@${NPM_VERSION}
# 개발 환경(docker-compose.dev.yml)은 이 스테이지를 그대로 구동하므로 비-root 로 전환한다.
# /app 소유권을 넘겨야 익명 볼륨(node_modules)도 node 소유로 생성되어 캐시 쓰기가 가능하다.
@@ -17,13 +26,14 @@ RUN npm ci
COPY --chown=node:node . .
RUN npm run build
FROM node:20-alpine AS production
FROM node:${NODE_VERSION}-alpine3.24 AS production
ARG NPM_VERSION
WORKDIR /app
ENV NODE_ENV=production
# 운영 stage 도 동일 npm 버전 사용
RUN npm install -g npm@11
RUN npm install -g npm@${NPM_VERSION}
# 운영용 의존성만 설치
COPY package*.json ./
@@ -31,6 +41,11 @@ RUN npm ci --omit=dev && npm cache clean --force
COPY --from=builder /app/dist ./dist
# 업로드 저장 위치(compose 의 UPLOAD_DIR=/app/uploads) — node 소유로 만들어 둔다.
# 운영 compose 는 여기에 named volume 을 붙이는데, 새 볼륨은 이 디렉터리의 소유자를 물려받아 node 가 쓸 수 있다.
# (호스트 디렉터리를 바인드 마운트하면 sudo docker compose 가 root 소유로 만들어 업로드가 실패한다)
RUN mkdir -p /app/uploads && chown node:node /app/uploads
EXPOSE 3000
# 비-root 실행 (보안 규칙)
+629 -246
View File
File diff suppressed because it is too large Load Diff
+28 -2
View File
@@ -14,6 +14,7 @@
"start:prod": "node dist/main",
"lint": "eslint \"{src,apps,libs,test,scripts}/**/*.ts\" --max-warnings 0",
"lint:fix": "eslint \"{src,apps,libs,test,scripts}/**/*.ts\" --fix",
"type-check": "tsc --noEmit -p tsconfig.json",
"test": "jest",
"test:watch": "jest --watch",
"test:cov": "jest --coverage",
@@ -32,6 +33,8 @@
"@nestjs/common": "^11.0.1",
"@nestjs/config": "^4.0.3",
"@nestjs/core": "^11.0.1",
"@nestjs/jwt": "^11.0.2",
"@nestjs/passport": "^11.0.5",
"@nestjs/platform-express": "^11.0.1",
"@nestjs/schedule": "^6.1.1",
"@nestjs/swagger": "^11.2.6",
@@ -41,11 +44,16 @@
"cache-manager": "^6.4.0",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.4",
"cookie-parser": "^1.4.7",
"helmet": "^8.1.0",
"htmlparser2": "^12.0.0",
"nest-winston": "^1.10.0",
"passport": "^0.7.0",
"passport-jwt": "^4.0.1",
"pg": "^8.20.0",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1",
"sanitize-html": "^2.18.0",
"typeorm": "^0.3.28",
"winston": "^3.17.0"
},
@@ -57,10 +65,13 @@
"@nestjs/testing": "^11.0.1",
"@swc/cli": "^0.8.1",
"@swc/core": "^1.15.24",
"@types/cookie-parser": "^1.4.10",
"@types/express": "^5.0.0",
"@types/jest": "^30.0.0",
"@types/multer": "^2.2.0",
"@types/node": "^22.10.7",
"@types/node": "^24.19.2",
"@types/passport-jwt": "^4.0.1",
"@types/sanitize-html": "^2.16.2",
"@types/supertest": "^6.0.2",
"chokidar": "^5.0.0",
"eslint": "^9.18.0",
@@ -93,12 +104,27 @@
},
"testRegex": ".*\\.spec\\.ts$",
"transform": {
"/node_modules/(htmlparser2|domhandler|domutils|entities|dom-serializer|domelementtype)/.+\\.js$": [
"ts-jest",
{
"tsconfig": {
"allowJs": true,
"module": "commonjs",
"target": "es2022",
"esModuleInterop": true,
"isolatedModules": true
}
}
],
"^.+\\.(t|j)s$": "ts-jest"
},
"collectCoverageFrom": [
"**/*.(t|j)s"
],
"coverageDirectory": "../coverage",
"testEnvironment": "node"
"testEnvironment": "node",
"transformIgnorePatterns": [
"/node_modules/(?!(htmlparser2|domhandler|domutils|entities|dom-serializer|domelementtype)/)"
]
}
}
+20 -10
View File
@@ -1,13 +1,16 @@
import { MiddlewareConsumer, Module, NestModule } from '@nestjs/common';
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { ConfigModule } from '@nestjs/config';
import { ScheduleModule } from '@nestjs/schedule';
import { ThrottlerGuard, ThrottlerModule } from '@nestjs/throttler';
import { ThrottlerModule } from '@nestjs/throttler';
import { CacheModule } from '@nestjs/cache-manager';
import { APP_GUARD } from '@nestjs/core';
import { RequestIdMiddleware } from '@/common/middleware/request-id.middleware';
import { ClientIpThrottlerGuard } from '@/common/guards/client-ip-throttler.guard';
import { OriginGuard } from '@/common/guards/origin.guard';
import { AuthModule } from '@/modules/auth/auth.module';
import { HealthModule } from '@/modules/health/health.module';
import { MailModule } from '@/modules/mail/mail.module';
import { UploadModule } from '@/modules/upload/upload.module';
import { UserModule } from '@/modules/user/user.module';
import { parsePositiveInt } from '@/shared/utils/env';
@@ -58,20 +61,27 @@ const THROTTLE_DEFAULT_LIMIT = 100; // 구간당 IP별 최대 요청 수
}),
}),
HealthModule,
// 직원 로그인(쿠키 JWT) — 자격 확인은 MailModule 의 메일 엔진 포트로
AuthModule,
// 웹메일 API (Stalwart JMAP 어댑터)
MailModule,
UploadModule,
UserModule,
],
providers: [
ShutdownLogger,
// 전역 가드는 등록 순서대로 돈다 — 다른 사이트에서 온 상태 변경 요청은 요청 수를 세기 전에 막는다
{
provide: APP_GUARD,
useClass: ThrottlerGuard,
useClass: OriginGuard,
},
// 요청 수 한도 — ThrottlerGuard 그대로, 클라이언트 키만 IPv6 /64 로 묶는다 (두 번째 limiter 가 아니다)
{
provide: APP_GUARD,
useClass: ClientIpThrottlerGuard,
},
],
})
export class AppModule implements NestModule {
// 모든 요청에 추적 ID 를 부여한다 (로그·에러 응답 traceId 와 동일 값)
configure(consumer: MiddlewareConsumer): void {
consumer.apply(RequestIdMiddleware).forRoutes('*');
}
}
// 요청 추적 ID 는 app.setup.ts 의 configureApp() 이 **맨 앞** express 미들웨어로 붙인다 —
// 본문 파서 오류(413·400·415)에도 traceId 가 남도록 Nest 미들웨어(본문 파서 뒤)로 두지 않는다
export class AppModule {}
+333
View File
@@ -0,0 +1,333 @@
import { BadRequestException, HttpStatus } from '@nestjs/common';
import type { Request, Response } from 'express';
import type { IncomingMessage, ServerResponse } from 'http';
import { startCpuTimer } from '@/../test/cpu-time';
import { BusinessException } from '@/common/errors/business.exception';
import { MessageDeletionDto } from '@/modules/mail/dto/bulk-messages.dto';
import { MailFilterUpdateDto } from '@/modules/mail/dto/mail-filter.dto';
import { SendMessageDto } from '@/modules/mail/dto/send-message.dto';
import {
JSON_SHAPE_LIMITS,
createValidationPipe,
isDraftSaveRequest,
isSendMessageRequest,
rejectOversizedJsonBody,
verifyJsonBody,
} from './app.setup';
/** 본문 파서의 읽기 전 확인을 부른다 — 던진 것을 돌려준다 (던지지 않으면 undefined) */
function verifyFailure(text: string, encoding = 'utf-8'): unknown {
try {
verifyJsonBody(
{} as IncomingMessage,
{} as ServerResponse,
Buffer.from(text, 'utf8'),
encoding,
);
} catch (error) {
return error;
}
return undefined;
}
/** 읽은 뒤의 확인을 부른다 — next 에 넘긴 것 */
function afterParse(body: unknown): unknown[] {
const next = jest.fn();
rejectOversizedJsonBody({ body } as Request, {} as Response, next);
return next.mock.calls[0] as unknown[];
}
const repeat = (unit: string, count: number) =>
Array.from({ length: count }, () => unit).join(',');
describe('JSON 본문의 모양 한도 (JSON_SHAPE_LIMITS — DTO 변환 · 검사 전)', () => {
it('읽기 전 확인 — 넘으면 BusinessException VAL_001(상태 400 — body-parser 가 이 상태를 쓴다), 까닭은 로그용 context 에만', () => {
const failure = verifyFailure(`{"ids":[${repeat('1', 1_001)}]}`);
expect(failure).toBeInstanceOf(BusinessException);
expect((failure as BusinessException).code).toBe('VAL_001');
expect((failure as BusinessException).getStatus()).toBe(
HttpStatus.BAD_REQUEST,
);
// http-errors 는 감싼 오류의 status 를 상태 코드로 쓴다 (body-parser 의 createError(403, err))
expect((failure as { status?: unknown }).status).toBe(400);
expect((failure as BusinessException).context).toEqual({
reason: 'json-shape-limit',
problem: 'array-too-long',
stage: 'before-parse',
});
expect(verifyFailure(`{"ids":[${repeat('1', 1_000)}]}`)).toBeUndefined();
});
it('JSON 본문은 UTF-8 만 — 다른 글자 인코딩(바이트로 셀 수 없다)은 작은 본문도 400 VAL_001', () => {
// body-parser 는 `utf-` 로 시작하는 charset 을 모두 받아 소문자로 넘긴다
for (const encoding of [
'utf-16',
'utf-16le',
'utf-16be',
'utf-32',
'utf-7',
]) {
const failure = verifyFailure('{"ids":[]}', encoding);
expect(failure).toBeInstanceOf(BusinessException);
expect((failure as { status?: unknown }).status).toBe(400);
expect((failure as BusinessException).code).toBe('VAL_001');
expect((failure as BusinessException).context).toEqual({
reason: 'json-not-utf8',
});
}
});
it('UTF-8 로 읽히는 다른 이름(utf-_8 등)도 UTF-8 로 보고 모양을 본다 — 이름 글자 그대로 견주면 확인을 건너뛴다', () => {
for (const encoding of [
'utf-8',
'utf-_8',
'utf--8',
'utf-8.',
'utf-8:2026',
]) {
expect(verifyFailure('{"ids":[]}', encoding)).toBeUndefined();
expect(
(
verifyFailure(
`{"ids":[${repeat('1', 1_001)}]}`,
encoding,
) as BusinessException
).context,
).toEqual({
reason: 'json-shape-limit',
problem: 'array-too-long',
stage: 'before-parse',
});
}
});
it('읽은 뒤 확인 — 한도 안이면 그대로 넘기고(next()), 본문이 없는 요청도 지난다. 넘으면 next(VAL_001)', () => {
expect(afterParse({ rules: [], version: 'v' })).toEqual([]);
expect(afterParse(undefined)).toEqual([]);
const [failure] = afterParse({
ids: Array.from({ length: 5_000 }, () => 1),
});
expect(failure).toBeInstanceOf(BusinessException);
expect((failure as BusinessException).context).toEqual({
reason: 'json-shape-limit',
problem: 'array-too-long',
stage: 'after-parse',
});
});
it('한도는 계약의 어떤 본문보다 넉넉하다 — 배열 1,000 · 값 10,000 · 깊이 32', () => {
expect(JSON_SHAPE_LIMITS).toEqual({
maxArrayLength: 1_000,
maxValues: 10_000,
maxDepth: 32,
});
});
describe('리뷰에서 잰 모양이 400 으로 0.5초 안에 끝난다 (본문 파서의 읽기 전 확인 — 본문은 재기 전에 만든다)', () => {
it.each([
[
'필터 rules 에 {"id":N} 8만 개 (1MB)',
`{"version":"v","rules":[${Array.from(
{ length: 80_000 },
(_, index) => `{"id":${index}}`,
).join(',')}]}`,
],
[
'규칙 하나의 conditions 에 {} 15만 개',
`{"version":"v","rules":[{"id":"r","conditions":[${repeat('{}', 150_000)}]}]}`,
],
[
'규칙 하나의 conditions 에 {} 30만 개 (900KB)',
`{"version":"v","rules":[{"id":"r","conditions":[${repeat('{}', 300_000)}]}]}`,
],
[
'발송 to 에 {} 139만 개 (4MB 경로의 최대)',
`{"subject":"x","body":"x","to":[${repeat('{}', 1_390_000)}]}`,
],
[
'여러 통 ids 에 문자열 6만 개',
`{"ids":[${Array.from(
{ length: 60_000 },
(_, index) => `"${index.toString(36)}"`,
).join(',')}]}`,
],
[
'로그인 본문에 10만 단 중첩 (600KB — 전에는 DTO 변환의 재귀가 넘쳐 500)',
`{"email":"a@b.c","password":"x","z":${'{"a":'.repeat(100_000)}1${'}'.repeat(100_000)}}`,
],
])('%s', (_label, text) => {
const cpuMs = startCpuTimer();
const failure = verifyFailure(text);
expect(cpuMs()).toBeLessThan(500);
expect((failure as BusinessException).code).toBe('VAL_001');
});
});
});
describe('전역 DTO 검증 (createValidationPipe — stopAtFirstError)', () => {
/**
* 모양 한도 안의 가장 무거운 본문들 — 본문 파서 · 모양 확인을 지난 뒤 DTO 변환 · 검사까지 해도 400 이 싸게 난다.
* CPU 시간(test/cpu-time.ts) — 지금 0.02~0.08초. 한도 2초는 부하를 견디는 값이다: 2026-10-28 CI 와 같은 Linux 컨테이너에서
* 메모리가 모자라고 테스트가 함께 돌 때 0.59초까지 재졌다(평소의 7배 — 그때 한도 0.5초로 CI 가 실패했다)
*/
const DTO_CPU_BUDGET_MS = 2_000;
const filtersWithManyUnknowns = {
version: 'v',
rules: Array.from({ length: 50 }, (_, index) => ({
id: `r${index}`,
name: '',
isEnabled: true,
match: 'all',
conditions: Array.from({ length: 5 }, () => ({
field: 'subject',
operator: 'contains',
value: 'x',
extra: Array.from({ length: 32 }, () => 1),
})),
actions: {
moveToMailboxId: null,
markAsRead: true,
flag: false,
forwardTo: null,
},
})),
};
it.each<[string, new () => object, unknown]>([
[
'필터 rules 1,000개 (배열 한도까지)',
MailFilterUpdateDto,
{
version: 'v',
rules: Array.from({ length: 1_000 }, (_, index) => ({ id: index })),
},
],
[
'규칙 하나의 conditions 1,000개',
MailFilterUpdateDto,
{
version: 'v',
rules: [
{ id: 'r', conditions: Array.from({ length: 1_000 }, () => ({})) },
],
},
],
[
'필터 50개 × 조건 5개마다 모르는 배열 — 값 약 9,800개 (값 한도 가까이)',
MailFilterUpdateDto,
filtersWithManyUnknowns,
],
[
'발송 to 1,000명',
SendMessageDto,
{
subject: 'x',
body: 'x',
to: Array.from({ length: 1_000 }, () => ({})),
},
],
[
'여러 통 ids 1,000개',
MessageDeletionDto,
{ ids: Array.from({ length: 1_000 }, (_, index) => `m${index}`) },
],
])(
'%s — 모양 확인을 지나도 DTO 검사가 2초 안에 400 (CPU 시간)',
async (_label, metatype, body) => {
const text = JSON.stringify(body);
const pipe = createValidationPipe();
const cpuMs = startCpuTimer();
expect(verifyFailure(text)).toBeUndefined();
const parsed: unknown = JSON.parse(text);
expect(afterParse(parsed)).toEqual([]);
await expect(
pipe.transform(parsed, { type: 'body', metatype }),
).rejects.toBeInstanceOf(BadRequestException);
expect(cpuMs()).toBeLessThan(DTO_CPU_BUDGET_MS);
},
);
it('검사 결과는 그대로다 — 맞는 본문은 지나고, 한도를 넘거나 모르는 칸이 있으면 400', async () => {
const pipe = createValidationPipe();
const valid = {
rules: [],
version: 'Cbd1Lkq0H3S7mJ5yV0w2qg8qkQvJ8sB7bW2nZ1y3u9E',
};
await expect(
pipe.transform(valid, { type: 'body', metatype: MailFilterUpdateDto }),
).resolves.toBeInstanceOf(MailFilterUpdateDto);
for (const body of [
{ ...valid, isActive: true },
{ ...valid, rules: Array.from({ length: 51 }, () => ({})) },
{ ...valid, version: 'a b' },
]) {
await expect(
pipe.transform(body, { type: 'body', metatype: MailFilterUpdateDto }),
).rejects.toBeInstanceOf(BadRequestException);
}
});
});
describe('임시 저장 요청 판정 (발송과 같은 JSON 본문 4MB)', () => {
it('POST /api/drafts 와 PUT /api/drafts/{id} 는 임시 저장 요청이다 — 대소문자·끝의 / ·쿼리는 가리지 않는다', () => {
for (const [method, url] of [
['POST', '/api/drafts'],
['POST', '/API/Drafts/'],
['PUT', '/api/drafts/faaaaabi'],
['PUT', '/api/drafts/faaaaabi/?x=1'],
] as const) {
expect(isDraftSaveRequest({ method, url })).toBe(true);
}
});
it('조회·다른 경로·하위 경로는 1MB 그대로다', () => {
for (const [method, url] of [
['GET', '/api/drafts/faaaaabi'],
['PUT', '/api/drafts'],
['POST', '/api/drafts/faaaaabi'],
['PUT', '/api/drafts/faaaaabi/attachments'],
['PATCH', '/api/drafts/faaaaabi'],
['POST', '/api/draftsx'],
['PUT', '/drafts/faaaaabi'],
] as const) {
expect(isDraftSaveRequest({ method, url })).toBe(false);
}
expect(isDraftSaveRequest({ method: 'POST', url: undefined })).toBe(false);
});
});
describe('발송 요청 판정 (발송만 JSON 본문 4MB)', () => {
it('POST /api/messages 는 발송 요청이다 — 대소문자·끝의 / ·쿼리는 가리지 않는다 (라우터와 같다)', () => {
for (const url of [
'/api/messages',
'/api/messages/',
'/API/Messages',
'/api/messages?draft=1',
]) {
expect(isSendMessageRequest({ method: 'POST', url })).toBe(true);
}
});
it('다른 메서드·다른 경로는 1MB 그대로다 — 메일 수정·첨부 업로드·로그인 포함', () => {
for (const [method, url] of [
['PATCH', '/api/messages/eaaaaab'],
['GET', '/api/messages'],
['PUT', '/api/messages'],
['POST', '/api/messages/eaaaaab'],
['POST', '/api/messages/eaaaaab/attachments'],
['POST', '/api/mail-attachments'],
['POST', '/api/auth/login'],
['POST', '/messages'],
['POST', '/api/messagesx'],
] as const) {
expect(isSendMessageRequest({ method, url })).toBe(false);
}
expect(isSendMessageRequest({ method: 'POST', url: undefined })).toBe(
false,
);
});
});
+230
View File
@@ -0,0 +1,230 @@
import { RequestMethod, ValidationPipe } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import type { NestExpressApplication } from '@nestjs/platform-express';
import cookieParser from 'cookie-parser';
import { json, type NextFunction, type Request, type Response } from 'express';
import helmet from 'helmet';
import type { IncomingMessage, ServerResponse } from 'http';
import { BusinessException } from '@/common/errors/business.exception';
import { HttpExceptionFilter } from '@/common/filters/http-exception.filter';
import { TransformInterceptor } from '@/common/interceptors/transform.interceptor';
import { requestIdMiddleware } from '@/common/middleware/request-id.middleware';
import { DRAFTS_ROUTE, MESSAGES_ROUTE } from '@/modules/mail/mail.constants';
import {
findJsonShapeProblem,
findJsonTextShapeProblem,
type JsonShapeLimits,
type JsonShapeProblem,
} from '@/shared/utils/json-shape';
import { resolveTrustProxy } from '@/shared/utils/trust-proxy';
/** 글로벌 prefix — 모든 라우트가 /api/* (클라이언트 baseURL 과 일치) */
export const API_PREFIX = 'api';
/**
* JSON 본문 최대 크기 — 발송(`POST /api/messages`)·임시 저장(`POST /api/drafts`·`PUT /api/drafts/{id}`)을 뺀 모든 API.
* 텍스트 본문 100,000자(한글은 UTF-8 로 3바이트라 약 300KB)도 이 안에 든다 (기본값 100KB 로는 잘린다).
*/
export const JSON_BODY_LIMIT = '1mb';
/**
* 발송·임시 저장 요청의 JSON 본문 최대 크기 (계약 2절) — 서식 본문(최대 500,000자: 한글이면 약 1.5MB 에
* JSON 이스케이프가 더해진다)과 텍스트 대체 본문(최대 100,000자)이 함께 오고, 답장·전달은 원문 인용이 길다.
* 임시 저장 본문은 발송 본문과 같은 필드·한도라 같은 파서를 쓴다.
* 앞단 nginx 도 이 경로들을 같은 값(4m)으로 둬야 한다 (release-ops — frontend/nginx.conf). 넘으면 413 FILE_002.
*/
export const SEND_MESSAGE_JSON_BODY_LIMIT = '4mb';
/**
* JSON 본문의 모양 한도 — 두 본문 파서(1MB · 4MB)의 본문 모두에, 읽기 전(verifyJsonBody)과 읽은 뒤(rejectOversizedJsonBody)
* DTO 변환 · 검사 **전에** 건다 (shared/utils/json-shape.ts 머리말).
* 지금 계약의 어떤 본문보다 넉넉히 둔다: 가장 긴 배열은 받는 사람 · id 100(발송 본문의 첨부 목록 상한도 100), 가장 깊은 본문은 5단
* (필터 — 본문 › rules › 규칙 › conditions › 조건), 값이 가장 많은 본문은 필터 50개를 모두 채운 저장(약 1,600개).
* 넘으면 400 VAL_001 — 이 한도를 넘는 계약을 새로 만들면 여기부터 올린다.
* 한도까지 채운 본문도 DTO 변환 · 검사가 0.1초 안쪽에 끝난다 (app.setup.spec.ts 가 CPU 시간으로 본다 — 부하를 견디게 한도는 2초)
*/
export const JSON_SHAPE_LIMITS: JsonShapeLimits = {
maxArrayLength: 1_000,
maxValues: 10_000,
maxDepth: 32,
};
const SEND_MESSAGE_PATH = `/${API_PREFIX}/${MESSAGES_ROUTE}`;
const DRAFTS_PATH = `/${API_PREFIX}/${DRAFTS_ROUTE}`;
/** 라우터가 보는 경로 — Express 처럼 대소문자·끝의 '/' 를 가리지 않고 쿼리는 뺀다 */
function routedPath(req: Pick<IncomingMessage, 'url'>): string {
return (req.url ?? '').split('?')[0].replace(/\/+$/, '').toLowerCase();
}
/**
* 발송 요청인가 — `POST /api/messages`.
* 라우터가 발송 핸들러로 보내는 모양이면 모두 해당한다.
*/
export function isSendMessageRequest(
req: Pick<IncomingMessage, 'method' | 'url'>,
): boolean {
return req.method === 'POST' && routedPath(req) === SEND_MESSAGE_PATH;
}
/**
* 임시 저장 요청인가 — `POST /api/drafts`(처음 저장) · `PUT /api/drafts/{id}`(바꿔 저장). 조회(GET)는 본문이 없다.
*/
export function isDraftSaveRequest(
req: Pick<IncomingMessage, 'method' | 'url'>,
): boolean {
const path = routedPath(req);
if (req.method === 'POST') return path === DRAFTS_PATH;
if (req.method !== 'PUT' || !path.startsWith(`${DRAFTS_PATH}/`)) {
return false;
}
const id = path.slice(DRAFTS_PATH.length + 1);
return id !== '' && !id.includes('/');
}
/** 모양 한도를 넘은 본문의 실패 — 400 VAL_001. 로그에는 넘은 한도의 이름과 본 자리(읽기 전 · 뒤)만 남는다 */
function jsonShapeFailure(
problem: JsonShapeProblem,
stage: 'before-parse' | 'after-parse',
): BusinessException {
return new BusinessException('VAL_001', {
context: { reason: 'json-shape-limit', problem, stage },
});
}
/** 글자 인코딩 이름의 비교용 모양 — body-parser 가 본문을 글자로 바꿀 때(iconv-lite)와 같게 소문자 영숫자만 남긴다 */
function canonicalCharset(label: string): string {
return label.toLowerCase().replace(/:\d{4}$|[^0-9a-z]/g, '');
}
/** UTF-8 의 비교용 이름 — `utf-8` · `UTF-8` · `utf-_8` 이 모두 이것이다 (body-parser 는 이 본문을 모두 UTF-8 로 읽는다) */
const UTF8_CHARSET = 'utf8';
/**
* 본문 파서의 읽기 전 확인(body-parser 의 verify) — UTF-8 본문을 JSON.parse 하기 **전에** 한 번 훑어 모양 한도를 넘으면 멈춘다.
* JSON.parse 도 작은 객체가 아주 많으면 무겁다 (4MB 에 `{}` 139만 개를 읽는 데만 CPU 0.67초 — 2026-10-28 측정).
*
* JSON 본문은 UTF-8 만 받는다 (RFC 8259 8.1 — 웹 · 앱 모두 UTF-8 로 보낸다). body-parser 는 `utf-` 로 시작하는 charset 을
* 모두 받지만(utf-16 · utf-32 · utf-7) 그 본문은 바이트로 셀 수 없어 JSON.parse 전에 끊지 못한다 — 그래서 400 VAL_001 로 거절한다.
* 이름은 견줄 모양으로 바꿔 본다: 글자 그대로 `utf-8` 과 견주면 `utf-_8` 처럼 UTF-8 로 읽히는 다른 이름이 확인을 건너뛴다.
*
* 여기서 던진 오류는 body-parser 가 http-errors 로 감싸며 상태를 오류에서 가져와 400 이 되고(BusinessException 의 status),
* 전역 필터가 VAL_001 로 답한다 (app.e2e-spec.ts 가 실제 응답으로 본다). 본문 파서는 가드보다 앞이라 로그인 전의 요청도 같다
*/
export function verifyJsonBody(
_req: IncomingMessage,
_res: ServerResponse,
buffer: Buffer,
encoding: string,
): void {
if (canonicalCharset(encoding) !== UTF8_CHARSET) {
throw new BusinessException('VAL_001', {
context: { reason: 'json-not-utf8' },
});
}
const problem = findJsonTextShapeProblem(buffer, JSON_SHAPE_LIMITS);
if (problem !== null) throw jsonShapeFailure(problem, 'before-parse');
}
/**
* 본문 파서가 읽은 JSON 본문의 모양을 본다 (JSON_SHAPE_LIMITS) — 넘으면 DTO 변환 · 검사로 넘기지 않고 400 VAL_001.
* 지금은 읽기 전 확인을 지난 UTF-8 본문만 오므로 같은 판정을 한 번 더 하는 셈이다(값 10,000개 안이라 가볍다) —
* 본문 파서를 새로 붙이거나 읽기 전 확인이 빠져도 DTO 변환 · 검사 전에 끊도록 남겨 둔다
*/
export function rejectOversizedJsonBody(
req: Request,
_res: Response,
next: NextFunction,
): void {
const body: unknown = req.body;
const problem = findJsonShapeProblem(body, JSON_SHAPE_LIMITS);
if (problem === null) {
next();
return;
}
next(jsonShapeFailure(problem, 'after-parse'));
}
/**
* 전역 DTO 검증 — 첫 실패에서 그 칸의 남은 검사를 멈춘다(stopAtFirstError).
* 배열 길이 한도에 걸린 칸은 항목마다의 중첩 검사를 하지 않는다 — 실패 응답은 늘 VAL_001 이라 어느 제약이 먼저 걸렸는지는 쓰지 않는다
* (도메인 코드 — 받는 사람 합계 MAIL_002 · 첨부 수 MAIL_006 · 본문 길이 MAIL_007 등 — 는 검증을 지난 뒤 서비스가 고른다)
*/
export function createValidationPipe(): ValidationPipe {
return new ValidationPipe({
whitelist: true, // DTO 에 없는 값은 버린다
forbidNonWhitelisted: true, // DTO 에 없는 필드가 오면 400
transform: true, // DTO·기본 타입으로 변환
stopAtFirstError: true,
});
}
/** JSON 본문인가 — body-parser 의 기본 판정('application/json', 매개변수는 보지 않는다)과 같다 */
function isJsonBody(req: IncomingMessage): boolean {
const mediaType = (req.headers['content-type'] ?? '')
.split(';')[0]
.trim()
.toLowerCase();
return mediaType === 'application/json';
}
/**
* HTTP 파이프라인 전역 설정 — main.ts 와 e2e 테스트가 **같은 함수**를 쓴다
* (따로 적으면 e2e 가 실제 응답과 다른 설정으로 돌게 된다).
*
* 앱은 `{ bodyParser: false }` 로 만들어야 한다 — 본문 파서를 여기서 직접 등록한다.
* CORS·Swagger·종료 훅은 배포 환경에 묶인 설정이라 main.ts 에 둔다.
*/
export function configureApp(app: NestExpressApplication): void {
// 요청 추적 ID — **가장 먼저** 붙인다. 본문 파서·helmet 보다 앞이라 그 단계의 실패(413·400·415)에도
// 응답 traceId 와 로그가 남고, 이후 모든 처리가 요청 컨텍스트(traceTag) 안에서 돈다
app.use(requestIdMiddleware);
// 믿을 프록시 뒤에서 실제 클라이언트 IP 를 읽는다 — 로그인 실패 한도·요청 수 한도의 기준 (TRUST_PROXY).
// 운영 체인: 클라이언트 → 외부 리버스 프록시 → 프론트 nginx → 백엔드. 값이 없으면(비운영) 1
app.set('trust proxy', resolveTrustProxy(process.env.TRUST_PROXY));
// 글로벌 prefix — 모든 라우트가 /api/* (클라이언트 baseURL 과 일치)
app.setGlobalPrefix(API_PREFIX, {
exclude: [{ path: '/', method: RequestMethod.GET }],
});
// 보안 헤더
app.use(helmet());
// 인증 쿠키(access_token·refresh_token)를 읽는다
app.use(cookieParser());
// JSON 만 받는다 — urlencoded 파서를 등록하지 않으면 다른 사이트의 <form> 으로 로그인·발송을 시도하는
// 요청(CSRF)은 본문이 비어 VAL_001 이 된다. JSON 요청은 CORS 사전 확인을 거치므로 화이트리스트가 막는다.
// inflate=false: 압축된 요청 본문을 받지 않는다 (브라우저는 보내지 않는다 — 압축 폭탄·body-parser DoS 경로 차단)
//
// 경로별 한도: 발송·임시 저장 요청만 4MB 파서가 읽고(type 판정으로 고른다), 나머지는 1MB 파서가 읽는다.
// 같은 요청을 두 번 읽지 않는다 — body-parser 는 이미 다 읽은 요청을 건너뛴다(read.js 의 isFinished 확인).
// 순서가 중요하다: 4MB 파서가 먼저여야 발송 본문이 1MB 파서에서 413 이 되지 않는다.
// 두 파서 모두 읽기 전 확인(verify — UTF-8 만 · 모양 한도)을 건다 — Nest 의 useBodyParser 는 verify 를 rawBody 용으로 남겨 두고 받지 않아(타입에서 뺀다)
// 같은 파서(express.json = body-parser)를 직접 붙인다 (useBodyParser 도 안에서 이렇게 붙인다)
app.use(
json({
limit: SEND_MESSAGE_JSON_BODY_LIMIT,
inflate: false,
type: (req: IncomingMessage) =>
(isSendMessageRequest(req) || isDraftSaveRequest(req)) &&
isJsonBody(req),
verify: verifyJsonBody,
}),
);
app.use(
json({ limit: JSON_BODY_LIMIT, inflate: false, verify: verifyJsonBody }),
);
// 두 파서가 읽은 본문 모두 — 배열 길이 · 값 수 · 깊이가 한도를 넘으면 DTO 변환 · 검사 전에 400 (JSON_SHAPE_LIMITS)
app.use(rejectOversizedJsonBody);
// 전역 DTO 검증
app.useGlobalPipes(createValidationPipe());
// 표준 실패·성공 응답
app.useGlobalFilters(new HttpExceptionFilter());
app.useGlobalInterceptors(new TransformInterceptor(app.get(Reflector)));
}
@@ -0,0 +1,39 @@
import { validate } from 'class-validator';
import { startCpuTimer } from '@/../test/cpu-time';
import { ArrayDistinct } from './array-distinct.decorator';
class Ids {
@ArrayDistinct({ message: '같은 값이 두 번 들어 있습니다.' })
ids!: unknown;
}
async function constraintsOf(ids: unknown): Promise<string[]> {
const target = Object.assign(new Ids(), { ids });
const errors = await validate(target);
return errors.flatMap((error) => Object.keys(error.constraints ?? {}));
}
describe('ArrayDistinct — 배열 항목이 겹치지 않는다 (선형)', () => {
it('겹치는 값이 있으면 arrayDistinct — 원시 값은 값으로 견준다', async () => {
expect(await constraintsOf(['m1', 'm2'])).toEqual([]);
expect(await constraintsOf([])).toEqual([]);
expect(await constraintsOf(['m1', 'm2', 'm1'])).toEqual(['arrayDistinct']);
expect(await constraintsOf([1, 1])).toEqual(['arrayDistinct']);
// 문자열 '1' 과 숫자 1 은 다른 값이다 (ArrayUnique 와 같다)
expect(await constraintsOf(['1', 1])).toEqual([]);
});
it('배열이 아니면 실패한다', async () => {
for (const ids of ['m1', null, undefined, { 0: 'm1' }]) {
expect(await constraintsOf(ids)).toEqual(['arrayDistinct']);
}
});
it('항목 수에 비례한다 — 20만 개도 0.5초 안 (ArrayUnique 는 6만 개에 4.7초였다)', async () => {
const ids = Array.from({ length: 200_000 }, (_, index) => `m${index}`);
const cpuMs = startCpuTimer();
expect(await constraintsOf(ids)).toEqual([]);
expect(cpuMs()).toBeLessThan(500);
});
});
@@ -0,0 +1,19 @@
import { ValidateBy, type ValidationOptions } from 'class-validator';
/**
* 배열 항목이 겹치지 않는다 — Set 으로 한 번 훑는다(항목 수에 비례). 배열이 아니면 실패한다(IsArray 가 함께 알린다).
* class-validator 의 ArrayUnique 는 항목마다 indexOf 로 앞을 다시 훑어 항목 수의 제곱이다 — id 6만 개에 4.7초 (2026-10-28 리뷰).
* 견주는 방법은 ArrayUnique 와 같다(같은 값 — 원시 값은 값으로, 객체는 같은 객체일 때만)
*/
export function ArrayDistinct(options: ValidationOptions): PropertyDecorator {
return ValidateBy(
{
name: 'arrayDistinct',
validator: {
validate: (value: unknown) =>
Array.isArray(value) && new Set(value).size === value.length,
},
},
options,
);
}
@@ -0,0 +1,44 @@
import { ValidateBy, type ValidationOptions } from 'class-validator';
/**
* 문자열 길이 검사 — JS 문자열 길이(UTF-16 코드 단위)로 센다. 웹 클라이언트가 세는 방법(`value.length`)과 같다.
* class-validator 의 MinLength·MaxLength(validator.js)는 서로게이트 쌍·이모지 표시 선택자를 덜 세어
* 클라이언트가 센 길이와 어긋난다 (modules/mail/dto/send-message.dto.ts 의 본문 한도와 같은 이유).
* 문자열이 아니면 실패한다 — 형식 오류는 IsString 이 함께 알린다.
*/
/** 최대 길이 (JS 문자열 길이) */
export function MaxCodeUnits(
max: number,
options: ValidationOptions,
): PropertyDecorator {
return ValidateBy(
{
name: 'maxCodeUnits',
constraints: [max],
validator: {
validate: (value: unknown) =>
typeof value === 'string' && value.length <= max,
},
},
options,
);
}
/** 최소 길이 (JS 문자열 길이) */
export function MinCodeUnits(
min: number,
options: ValidationOptions,
): PropertyDecorator {
return ValidateBy(
{
name: 'minCodeUnits',
constraints: [min],
validator: {
validate: (value: unknown) =>
typeof value === 'string' && value.length >= min,
},
},
options,
);
}
@@ -0,0 +1,39 @@
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
import type { Request } from 'express';
import { BusinessException } from '@/common/errors/business.exception';
/** 인증된 직원 — 액세스 토큰에서 복원한 값 (요청마다 메일 엔진에 묻지 않는다) */
export interface AuthenticatedUser {
/** 직원 메일 주소 (= 메일 계정) */
readonly email: string;
/** 표시 이름 */
readonly name: string;
/**
* 액세스 토큰이 끝나는 시각 (epoch ms) — 가드(`JwtAuthGuard` · `JwtStrategy`)를 거친 요청에만 있다.
* 로그인 · 갱신 응답의 직원 정보에는 없다. 오래 열린 연결(실시간 알림)을 토큰이 끝나기 전에 닫는 데 쓴다
*/
readonly accessTokenExpiresAt?: number;
}
/**
* 가드(`JwtAuthGuard`)가 검증해 `req.user` 에 넣은 직원 정보를 꺼낸다.
* 가드 없이 쓰면 사용자가 없으므로 AUTH_001 로 막는다 (가드 누락이 조용히 통과하지 않게).
*
* 사용 예:
* @UseGuards(JwtAuthGuard)
* me(@CurrentUser() user: AuthenticatedUser) { ... }
*/
export const CurrentUser = createParamDecorator(
(_data: unknown, context: ExecutionContext): AuthenticatedUser => {
const request = context.switchToHttp().getRequest<Request>();
const user = request.user as AuthenticatedUser | undefined;
if (!user) {
throw new BusinessException('AUTH_001', {
context: { reason: 'current-user-without-guard' },
});
}
return user;
},
);
@@ -0,0 +1,24 @@
import { plainToInstance } from 'class-transformer';
import { TrimString } from './trim-string.decorator';
class Named {
@TrimString()
name!: unknown;
}
const nameOf = (name: unknown) => plainToInstance(Named, { name }).name;
describe('TrimString — DTO 변환에서 앞뒤 공백을 지운다', () => {
it('문자열은 JS trim() 과 같게 — 탭 · 줄바꿈 · NBSP · U+3000 도 지운다, 가운데는 그대로', () => {
expect(nameOf(' 거래처 메일 ')).toBe('거래처 메일');
expect(nameOf('\t\n\u00a0\u3000가 나\u3000')).toBe('가 나');
expect(nameOf(' ')).toBe('');
});
it('문자열이 아니면 그대로 둔다 — 형식 검사(IsString 등)가 막는다', () => {
expect(nameOf(null)).toBeNull();
expect(nameOf(42)).toBe(42);
expect(nameOf([' a '])).toEqual([' a ']);
});
});
@@ -0,0 +1,16 @@
import { Transform, type TransformFnParams } from 'class-transformer';
/**
* 앞뒤 공백을 지운다 — DTO 변환(class-transformer) 단계라 검사(class-validator)는 지운 값으로 한다.
* 문자열이 아니면 그대로 두어 IsString · IsEmail 같은 검사가 막는다. 지우는 공백은 JS `trim()` 과 같다(NBSP · U+3000 포함 — 웹의 `.trim()` 과 같게 센다).
* 메일함 이름(dto/mailbox.dto.ts) · 메일 필터의 이름 · 찾을 글 · 전달 주소(dto/mail-filter.dto.ts)가 쓴다
*/
export function TrimString(): PropertyDecorator {
return Transform(trimStringValue);
}
/** TrimString 의 변환 — 문자열만 앞뒤 공백을 지운다 */
export function trimStringValue({ value }: TransformFnParams): unknown {
const raw: unknown = value;
return typeof raw === 'string' ? raw.trim() : raw;
}
+21 -1
View File
@@ -44,9 +44,29 @@ describe('에러 코드 레지스트리', () => {
expect(resolveCodeByStatus(HttpStatus.BAD_GATEWAY)).toBe('SYS_001');
});
it('지금 비밀번호 틀림(AUTH_005)은 401 이 아니라 400 이다 — 웹 클라이언트는 401 이면 세션을 갱신하고 같은 요청을 다시 보낸다', () => {
expect(ERROR_CODES.AUTH_005.status).toBe(HttpStatus.BAD_REQUEST);
// 400 의 역매핑은 그대로 VAL_001 이다 (AUTH_005 는 서버가 명시적으로만 던진다)
expect(resolveCodeByStatus(HttpStatus.BAD_REQUEST)).toBe('VAL_001');
});
it('같은 이름의 메일함(MAIL_009)은 409 다', () => {
expect(ERROR_CODES.MAIL_009.status).toBe(HttpStatus.CONFLICT);
});
it('받는 사람을 엔진이 모두 받지 않은 발송 — 5xx 가 섞이면 400 MAIL_010, 모두 4xx(지금은 받을 수 없음)면 503 MAIL_011', () => {
expect(ERROR_CODES.MAIL_010.status).toBe(HttpStatus.BAD_REQUEST);
expect(ERROR_CODES.MAIL_011.status).toBe(HttpStatus.SERVICE_UNAVAILABLE);
});
it('메일 필터 — 그사이 규칙이 바뀜은 409 MAIL_012, 만든 엔진 규칙이 엔진 한도를 넘음은 400 MAIL_013', () => {
expect(ERROR_CODES.MAIL_012.status).toBe(HttpStatus.CONFLICT);
expect(ERROR_CODES.MAIL_013.status).toBe(HttpStatus.BAD_REQUEST);
});
it('클라이언트 전용 코드(AUTH_002)는 서버가 상태 역매핑으로 선택하지 않는다', () => {
const clientOnlyCodes = Object.entries(ERROR_CODES)
.filter(([, definition]) => definition.clientOnly)
.filter(([, definition]) => 'clientOnly' in definition)
.map(([code]) => code);
expect(clientOnlyCodes).toContain('AUTH_002');
+88
View File
@@ -40,6 +40,17 @@ const COMMON_ERROR_CODES = {
status: HttpStatus.FORBIDDEN,
message: '해당 메뉴나 기능에 접근할 수 있는 권한이 없습니다.',
},
// 로그인 자격 증명 불일치 — 계정 없음/비밀번호 틀림을 구분하지 않는다 (.claude/rules/security.md)
AUTH_004: {
status: HttpStatus.UNAUTHORIZED,
message: '이메일 또는 비밀번호가 올바르지 않습니다.',
},
// 비밀번호 바꾸기(PUT /api/auth/password)의 지금 비밀번호가 틀림 — 세션은 그대로다.
// 401 이 아니라 400 인 까닭: 웹 클라이언트는 401 이면 세션을 갱신하고 같은 요청을 다시 보낸다 (계약 1절)
AUTH_005: {
status: HttpStatus.BAD_REQUEST,
message: '지금 비밀번호가 올바르지 않습니다.',
},
VAL_001: {
status: HttpStatus.BAD_REQUEST,
message: '입력하신 정보를 다시 확인해 주세요. (필수값 누락 또는 형식 오류)',
@@ -73,6 +84,83 @@ const DOMAIN_ERROR_CODES = {
status: HttpStatus.PAYLOAD_TOO_LARGE,
message: '파일 크기가 허용 범위를 초과했습니다.',
},
// 메일 — 외부 메일 엔진(Stalwart) 장애를 SYS_001(우리 버그)과 구분하기 위해 분리한다
MAIL_001: {
status: HttpStatus.SERVICE_UNAVAILABLE,
message: '메일 서버와 연결할 수 없습니다. 잠시 후 다시 시도해 주세요.',
},
MAIL_002: {
status: HttpStatus.BAD_REQUEST,
message: '받는 사람 주소를 다시 확인해 주세요.',
},
MAIL_003: {
status: HttpStatus.BAD_REQUEST,
message:
'메일함 용량이 부족합니다. 불필요한 메일을 정리한 뒤 다시 시도해 주세요.',
},
// 발송 때 엔진에서 첨부(blob)를 찾지 못함 — 올려 두고 오래 지나 만료된 업로드·그사이 지워진 원본 메일의 첨부(전달)·잘못된 첨부 id.
// 문구는 원인을 단정하지 않는다 — 올린 파일은 다시 올리고, 전달하는 원본 첨부는 빼야 한다 (계약 2절)
MAIL_004: {
status: HttpStatus.BAD_REQUEST,
message:
'첨부 파일을 찾을 수 없습니다. 해당 첨부를 빼거나 다시 올린 뒤 보내 주세요.',
},
// 같은 Idempotency-Key 로 이미 보낸 메일과 내용(받는 사람·제목·본문·첨부)이 다르다 — 보내지 않는다.
// 첫 메일이 실제로 나갔을 수 있으므로 클라이언트는 보낸편지함을 확인하게 하고 새 키로 다시 보내게 한다
MAIL_005: {
status: HttpStatus.CONFLICT,
message:
'이미 보낸 메일과 내용이 달라 보내지 않았습니다. 보낸편지함을 확인한 뒤 다시 보내 주세요.',
},
// 첨부 개수 한도 초과 — 업로드한 파일과 전달하는 원본 첨부를 함께 센다.
// 문구의 숫자는 attachment.policy.ts 의 MAX_ATTACHMENTS_PER_MESSAGE 와 같아야 한다 (attachment.policy.spec.ts 가 확인한다)
MAIL_006: {
status: HttpStatus.BAD_REQUEST,
message: '첨부는 한 메일에 최대 20개까지 보낼 수 있습니다.',
},
// 본문이 너무 길다 — 텍스트 100,000자·서식 본문 500,000자 초과, 서식 본문이 정화 전 비용 한도나 정화 뒤 2,000,000바이트를 넘음.
// 메일은 나가지 않는다. 클라이언트는 보내기 전에 같은 한도로 미리 막고 본문 아래에 이 문구를 보인다 (계약 2절)
MAIL_007: {
status: HttpStatus.BAD_REQUEST,
message:
'본문이 너무 길어 보낼 수 없습니다. 내용을 줄인 뒤 다시 보내 주세요.',
},
// 임시 저장본 교체(PUT /api/drafts/{id}) 때 옛 초안이 이미 없다 — 다른 창·기기에서 먼저 고쳤거나 지웠다. 아무것도 저장하지 않는다.
// 클라이언트는 막지 않고 지금 내용을 새 임시 저장본(POST)으로 남긴다 (계약 2절 "임시 저장")
MAIL_008: {
status: HttpStatus.CONFLICT,
message:
'임시 저장한 메일이 다른 곳에서 바뀌었거나 지워졌습니다. 지금 내용을 새 임시 저장본으로 남겨 주세요.',
},
// 메일함 만들기·이름 바꾸기 때 같은 상위 안에 같은 이름이 이미 있다 — 엔진이 alreadyExists 로 거절한다 (계약 2절 "메일함 관리")
MAIL_009: {
status: HttpStatus.CONFLICT,
message: '같은 이름의 메일함이 이미 있습니다.',
},
// 발송했지만 엔진이 받는 사람을 모두 거절했다 — 그중 하나라도 5xx(없는 주소 · 거절). 아무에게도 가지 않아 보낸 사본을 지웠다 (계약 2절 "받는 사람별 결과")
MAIL_010: {
status: HttpStatus.BAD_REQUEST,
message: '받는 사람 주소로 메일을 보낼 수 없습니다. 주소를 확인해 주세요.',
},
// 발송했지만 엔진이 받는 사람을 모두 4xx(지금은 받을 수 없음 — 한도 등)로 거절했다. 보낸 사본을 지웠다 — 같은 키로 다시 보내면 된다
MAIL_011: {
status: HttpStatus.SERVICE_UNAVAILABLE,
message:
'받는 쪽이 지금은 메일을 받을 수 없습니다. 잠시 뒤 다시 보내 주세요.',
},
// 필터 저장(PUT /api/mail-filters) 때 그사이 다른 곳(다른 탭 · PC)에서 규칙이 바뀌었다 — 저장하지 않았다.
// 화면은 목록 위 줄 + [다시 불러오기] 로 알린다 (계약 2절 "메일 필터"). 부재중 응답만 바뀐 것은 걸리지 않는다
MAIL_012: {
status: HttpStatus.CONFLICT,
message:
'다른 곳에서 필터가 바뀌어 저장하지 않았습니다. 다시 불러온 뒤 고쳐 주세요.',
},
// 필터 · 부재중 응답으로 만든 엔진 규칙(Sieve 스크립트)이 엔진 한도를 넘는다 — 저장하지 않았다 (계약 2절 "메일 필터"의 크기)
MAIL_013: {
status: HttpStatus.BAD_REQUEST,
message:
'필터가 너무 길어 저장할 수 없습니다. 조건을 줄이거나 쓰지 않는 필터를 지워 주세요.',
},
} as const satisfies Record<string, ErrorCodeDefinition>;
export const ERROR_CODES = {
@@ -21,7 +21,11 @@ interface CapturedResponse {
}
/** 필터 실행 후 실제로 내려간 응답을 캡처한다. */
function runFilter(exception: unknown, requestId?: string): CapturedResponse {
function runFilter(
exception: unknown,
requestId?: string,
url = '/api/users/1',
): CapturedResponse {
const captured = {} as CapturedResponse;
const response = {
@@ -37,7 +41,7 @@ function runFilter(exception: unknown, requestId?: string): CapturedResponse {
const request = {
method: 'GET',
url: '/api/users/1',
url,
header: () => requestId,
};
@@ -107,6 +111,67 @@ describe('HttpExceptionFilter', () => {
expect(JSON.stringify(result.body)).not.toContain('File too large');
});
it('Express 미들웨어의 4xx(http-errors)는 상태로 역매핑한다 — 본문 크기 초과가 SYS_001 로 보이면 안 된다', () => {
// body-parser 는 한도를 넘으면 { status: 413, expose: true } 인 오류를 next(err) 로 넘긴다
const tooLarge = Object.assign(new Error('request entity too large'), {
status: 413,
statusCode: 413,
expose: true,
type: 'entity.too.large',
});
const result = runFilter(tooLarge);
expect(result.status).toBe(HttpStatus.PAYLOAD_TOO_LARGE);
expect(result.body.error.code).toBe('FILE_002');
expect(JSON.stringify(result.body)).not.toContain('entity too large');
});
it('expose 가 없거나 5xx 인 오류 객체는 SYS_001 로 수렴한다', () => {
const notExposed = Object.assign(new Error('내부'), { status: 400 });
const serverSide = Object.assign(new Error('내부'), {
status: 502,
expose: true,
});
expect(runFilter(notExposed).body.error.code).toBe('SYS_001');
expect(runFilter(serverSide).status).toBe(HttpStatus.INTERNAL_SERVER_ERROR);
});
it('5xx BusinessException 도 context 를 로그에 남긴다 (응답에는 싣지 않는다)', () => {
const errorLog = jest.spyOn(Logger.prototype, 'error');
errorLog.mockClear();
const result = runFilter(
new BusinessException('MAIL_001', {
context: { reason: 'timeout', jmapType: 'serverFail' },
}),
);
expect(result.status).toBe(HttpStatus.SERVICE_UNAVAILABLE);
expect(JSON.stringify(result.body)).not.toContain('serverFail');
expect(errorLog).toHaveBeenCalledWith(
expect.stringContaining('"jmapType":"serverFail"'),
expect.anything(),
);
});
it('로그의 요청 주소에서 검색어(q)와 커서(cursor — 검색어 해시를 담는다)는 길이만 남긴다 — 나머지 쿼리는 그대로', () => {
const warnLog = jest.spyOn(Logger.prototype, 'warn');
warnLog.mockClear();
runFilter(
new BusinessException('VAL_001'),
'trace-q',
'/api/recipient-suggestions?q=%EB%8F%84%EC%9C%A4&limit=20&cursor=bad',
);
const logged = warnLog.mock.calls.map(([message]) => String(message));
expect(logged).toEqual([
'[VAL_001][trace-q] GET /api/recipient-suggestions?q=<2자>&limit=20&cursor=<3자>',
]);
});
it('미정의 예외는 내부 정보 없이 SYS_001 로 수렴한다', () => {
const result = runFilter(new Error('DB 커넥션 풀 고갈: host=10.0.0.5'));
@@ -17,10 +17,14 @@ import {
resolveCodeByStatus,
} from '@/common/errors/error-codes';
import { REQUEST_ID_HEADER } from '@/common/middleware/request-id.middleware';
import { maskUrlForLog } from '@/shared/utils/mask';
/** 이 값 이상이면 서버 책임 오류로 보고 error 레벨로 남긴다. */
const SERVER_ERROR_STATUS_THRESHOLD = 500;
/** 클라이언트 오류(4xx)의 시작 */
const CLIENT_ERROR_STATUS_THRESHOLD = 400;
/**
* 전역 Exception Filter — 모든 실패 응답을 표준 포맷으로 변환한다.
* 에러 코드 ↔ 메시지 ↔ HTTP 상태는 error-codes.ts 레지스트리가 SSOT 이며,
@@ -81,7 +85,19 @@ export class HttpExceptionFilter implements ExceptionFilter {
};
}
// 3. 미정의 예외 — 내부 정보가 새지 않도록 항상 SYS_001 로 수렴시킨다.
// 3. Express 미들웨어(body-parser 등)가 만든 http-errors 객체 — 클라이언트 책임(4xx)이면 상태로 역매핑한다.
// (본문 크기 초과 413 이 SYS_001(500)로 보이면 원인을 오해하게 된다)
const clientStatus = this.extractClientErrorStatus(exception);
if (clientStatus !== null) {
const code = resolveCodeByStatus(clientStatus);
return {
status: clientStatus,
code,
message: getErrorDefinition(code).message,
};
}
// 4. 미정의 예외 — 내부 정보가 새지 않도록 항상 SYS_001 로 수렴시킨다.
return {
status: HttpStatus.INTERNAL_SERVER_ERROR,
code: FALLBACK_ERROR_CODE,
@@ -89,6 +105,25 @@ export class HttpExceptionFilter implements ExceptionFilter {
};
}
/**
* http-errors 형식(`status` + `expose: true`)의 클라이언트 오류면 상태 코드를 꺼낸다.
* `expose` 는 "메시지를 클라이언트에 보여도 되는 4xx" 에만 true 이므로, 5xx·임의 객체는 걸러진다.
*/
private extractClientErrorStatus(exception: unknown): number | null {
if (!(exception instanceof Error)) return null;
const { status, expose } = exception as Error & {
status?: unknown;
expose?: unknown;
};
const isClientError =
typeof status === 'number' &&
status >= CLIENT_ERROR_STATUS_THRESHOLD &&
status < SERVER_ERROR_STATUS_THRESHOLD;
return isClientError && expose === true ? status : null;
}
/** 예외 본문에 error code 가 실려 있으면 꺼낸다. */
private extractCode(body: unknown): ErrorCode | null {
if (typeof body === 'object' && body !== null && 'code' in body) {
@@ -106,21 +141,23 @@ export class HttpExceptionFilter implements ExceptionFilter {
request: Request,
traceId?: string,
): void {
const where = `${request.method} ${request.url}`;
// 검색어(q)는 길이만 — 사람 이름·주소가 든다 (shared/utils/mask.ts)
const where = `${request.method} ${maskUrlForLog(request.url)}`;
const prefix = `[${code}]${traceId ? `[${traceId}]` : ''} ${where}`;
// context 는 로그 전용 개발자 정보다 — 5xx(예: 외부 메일 엔진 장애 MAIL_001)도 원인을 알 수 있게 함께 남긴다
const context =
exception instanceof BusinessException && exception.context
? ` ${JSON.stringify(exception.context)}`
: '';
if (status >= SERVER_ERROR_STATUS_THRESHOLD) {
this.logger.error(
prefix,
`${prefix}${context}`,
exception instanceof Error ? exception.stack : String(exception),
);
return;
}
const context =
exception instanceof BusinessException && exception.context
? ` ${JSON.stringify(exception.context)}`
: '';
this.logger.warn(`${prefix}${context}`);
}
}
@@ -0,0 +1,44 @@
import { ThrottlerGuard } from '@nestjs/throttler';
import { ClientIpThrottlerGuard } from './client-ip-throttler.guard';
/** getTracker 만 확인한다 — 한도 계산은 throttler 의 몫이다 */
function trackerOf(req: Record<string, unknown>): Promise<string> {
const guard = Object.create(
ClientIpThrottlerGuard.prototype,
) as ClientIpThrottlerGuard;
return (
guard as unknown as {
getTracker: (r: Record<string, unknown>) => Promise<string>;
}
).getTracker(req);
}
describe('ClientIpThrottlerGuard', () => {
it('ThrottlerGuard 를 그대로 쓰고 세는 키만 바꾼다 (두 번째 limiter 가 아니다)', () => {
expect(Object.getPrototypeOf(ClientIpThrottlerGuard.prototype)).toBe(
ThrottlerGuard.prototype,
);
});
it('IPv6 는 /64 로 묶는다 — 로그인 실패 한도와 같은 키', async () => {
await expect(trackerOf({ ip: '2001:db8:1:2::5' })).resolves.toBe(
'2001:db8:1:2::/64',
);
await expect(trackerOf({ ip: '2001:db8:1:2:aaaa::9' })).resolves.toBe(
'2001:db8:1:2::/64',
);
});
it('IPv4-mapped 주소는 IPv4 로, IPv4 는 그대로', async () => {
await expect(trackerOf({ ip: '::ffff:203.0.113.7' })).resolves.toBe(
'203.0.113.7',
);
await expect(trackerOf({ ip: '203.0.113.7' })).resolves.toBe('203.0.113.7');
});
it('IP 를 알 수 없으면 한 묶음(unknown)으로 센다', async () => {
await expect(trackerOf({})).resolves.toBe('unknown');
await expect(trackerOf({ ip: '' })).resolves.toBe('unknown');
});
});
@@ -0,0 +1,19 @@
import { Injectable } from '@nestjs/common';
import { ThrottlerGuard } from '@nestjs/throttler';
import { clientIpKey } from '@/shared/utils/client-ip';
/**
* 전역 요청 수 한도(APP_GUARD) — `@nestjs/throttler` 의 ThrottlerGuard 에서 **클라이언트를 세는 키만** 바꾼다.
* 두 번째 limiter 가 아니다 (한도·저장소·`@Throttle`/`@SkipThrottle` 은 그대로 throttler 가 맡는다).
*
* 키는 로그인 실패 한도와 같은 `clientIpKey()` — IPv6 는 /64 로 묶고, IPv4-mapped 주소는 IPv4 로 본다.
* 그래서 같은 회선에서 IPv6 주소를 바꿔 가며 요청 수 한도를 피하지 못한다.
*/
@Injectable()
export class ClientIpThrottlerGuard extends ThrottlerGuard {
protected override getTracker(req: Record<string, unknown>): Promise<string> {
const ip = typeof req.ip === 'string' && req.ip !== '' ? req.ip : 'unknown';
return Promise.resolve(clientIpKey(ip));
}
}
@@ -0,0 +1,71 @@
import { BusinessException } from '@/common/errors/business.exception';
import { JwtAuthGuard } from './jwt-auth.guard';
const user = { email: 'ara.kim@arasystem.test', name: '김아라' };
/** 가드가 던진 예외의 에러 코드를 꺼낸다 */
function codeOf(run: () => unknown): string | undefined {
try {
run();
} catch (error) {
return error instanceof BusinessException ? error.code : 'NOT_BUSINESS';
}
return undefined;
}
describe('JwtAuthGuard.handleRequest', () => {
const guard = new JwtAuthGuard();
it('검증된 사용자는 그대로 통과시킨다', () => {
expect(guard.handleRequest(null, user, undefined)).toBe(user);
});
it('토큰이 없으면 AUTH_001 이다', () => {
expect(
codeOf(() =>
guard.handleRequest(null, false, new Error('No auth token')),
),
).toBe('AUTH_001');
});
it('만료·위조 토큰도 원인을 구분하지 않고 AUTH_001 이다', () => {
for (const message of [
'jwt expired',
'invalid signature',
'jwt malformed',
]) {
expect(
codeOf(() => guard.handleRequest(null, false, new Error(message))),
).toBe('AUTH_001');
}
});
it('예상치 못한 오류도 코드 없는 실패로 새지 않고 AUTH_001 이다', () => {
expect(
codeOf(() => guard.handleRequest(new Error('boom'), false, undefined)),
).toBe('AUTH_001');
});
it('실패 사유는 로그 context 에만 남긴다', () => {
try {
guard.handleRequest(null, false, new Error('jwt expired'));
} catch (error) {
expect((error as BusinessException).context).toEqual({
reason: 'jwt expired',
});
expect((error as BusinessException).getResponse()).not.toHaveProperty(
'reason',
);
}
});
it('전략이 코드와 함께 거부한 예외는 그대로 전달한다', () => {
const rejected = new BusinessException('AUTH_001', {
context: { reason: 'wrong-token-type' },
});
expect(() => guard.handleRequest(rejected, false, undefined)).toThrow(
rejected,
);
});
});
@@ -0,0 +1,42 @@
import { Injectable } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
import type { AuthenticatedUser } from '@/common/decorators/current-user.decorator';
import { BusinessException } from '@/common/errors/business.exception';
/** passport 전략 이름 — `modules/auth/jwt.strategy.ts` 가 이 이름으로 등록한다 */
export const JWT_STRATEGY = 'jwt';
/**
* 액세스 토큰 쿠키로 보호하는 가드.
* 토큰이 없거나·만료됐거나·위조됐으면 원인을 구분하지 않고 AUTH_001 로 응답한다.
* (구체적인 사유는 로그 context 에만 남긴다 — 클라이언트는 AUTH_001 이면 /auth/refresh 를 한 번 시도한다)
*
* 사용 예:
* @UseGuards(JwtAuthGuard)
* @ApiCookieAuth()
*/
@Injectable()
export class JwtAuthGuard extends AuthGuard(JWT_STRATEGY) {
handleRequest<TUser = AuthenticatedUser>(
error: unknown,
user: unknown,
info: unknown,
): TUser {
// 전략의 validate() 가 코드와 함께 거부한 경우 그대로 전달한다
if (error instanceof BusinessException) throw error;
if (error || !user) {
throw new BusinessException('AUTH_001', {
context: { reason: describeFailure(error ?? info) },
});
}
return user as TUser;
}
}
/** 토큰 원문은 남기지 않고 실패 종류만 남긴다 (예: 'No auth token', 'jwt expired') */
function describeFailure(cause: unknown): string {
if (cause instanceof Error) return cause.message;
return 'unauthenticated';
}
@@ -0,0 +1,120 @@
import type { ExecutionContext } from '@nestjs/common';
import type { ConfigService } from '@nestjs/config';
import { BusinessException } from '@/common/errors/business.exception';
import { OriginGuard } from './origin.guard';
function guardWith(env: Record<string, string>): OriginGuard {
const config = {
get: (key: string) => env[key],
} as unknown as ConfigService;
return new OriginGuard(config);
}
function httpContext(
method: string,
headers: Record<string, string> = {},
): ExecutionContext {
const request = {
method,
header: (name: string) => headers[name.toLowerCase()],
};
return {
getType: () => 'http',
switchToHttp: () => ({ getRequest: () => request }),
} as unknown as ExecutionContext;
}
function codeOf(run: () => unknown): string | undefined {
try {
run();
} catch (error) {
return error instanceof BusinessException ? error.code : 'NOT_BUSINESS';
}
return undefined;
}
describe('OriginGuard', () => {
const production = guardWith({
NODE_ENV: 'production',
CORS_ORIGIN: 'https://mail.arasystem.it.kr',
});
it('조회(GET·HEAD·OPTIONS)는 검사하지 않는다', () => {
for (const method of ['GET', 'HEAD', 'OPTIONS']) {
expect(
production.canActivate(
httpContext(method, { 'sec-fetch-site': 'cross-site' }),
),
).toBe(true);
}
});
it.each(['POST', 'PUT', 'PATCH', 'DELETE'])(
'%s 는 다른 사이트에서 오면 403 AUTH_003',
(method) => {
expect(
codeOf(() =>
production.canActivate(
httpContext(method, {
'sec-fetch-site': 'cross-site',
origin: 'https://evil.test',
}),
),
),
).toBe('AUTH_003');
},
);
it('같은 주소의 화면에서 온 요청은 통과한다', () => {
expect(
production.canActivate(
httpContext('POST', {
'sec-fetch-site': 'same-origin',
origin: 'https://mail.arasystem.it.kr',
}),
),
).toBe(true);
});
it('운영에서는 localhost 출처를 허용하지 않는다 (CORS_ORIGIN 만)', () => {
expect(
codeOf(() =>
production.canActivate(
httpContext('POST', { origin: 'http://localhost:5173' }),
),
),
).toBe('AUTH_003');
});
it('개발에서는 vite dev 서버 출처를 허용한다', () => {
const development = guardWith({ NODE_ENV: 'development' });
expect(
development.canActivate(
httpContext('POST', {
'sec-fetch-site': 'same-site',
origin: 'http://localhost:5173',
}),
),
).toBe(true);
});
it('출처 정보가 없는 요청(브라우저가 아닌 클라이언트)은 통과한다', () => {
expect(production.canActivate(httpContext('POST'))).toBe(true);
});
it('거부 사유는 로그 context 에만 남긴다', () => {
try {
production.canActivate(
httpContext('DELETE', { origin: 'https://evil.test' }),
);
fail('예외가 발생해야 한다');
} catch (error) {
expect((error as BusinessException).context).toEqual({
reason: 'cross-site-request',
secFetchSite: null,
origin: 'https://evil.test',
});
}
});
});
+57
View File
@@ -0,0 +1,57 @@
import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import type { Request } from 'express';
import { BusinessException } from '@/common/errors/business.exception';
import {
buildAllowedOrigins,
checkRequestOrigin,
} from '@/shared/utils/request-origin';
/** 상태를 바꾸는 메서드 — 이 요청만 출처를 검사한다 (조회는 다른 사이트가 응답을 읽지 못한다) */
const STATE_CHANGING_METHODS = new Set(['POST', 'PUT', 'PATCH', 'DELETE']);
/**
* 출처 검사(CSRF 방어) — 전역 가드(APP_GUARD, app.module.ts).
*
* 쿠키 인증이라 다른 사이트가 사용자의 브라우저로 요청을 보내면 쿠키가 함께 간다.
* SameSite 쿠키와 JSON 전용 본문 파서가 1차로 막고, 이 가드가 브라우저가 붙이는 출처 정보로 한 번 더 막는다.
* 규칙은 `shared/utils/request-origin.ts` (docs/mail/api-contract.md 머리말) — 거부하면 403 AUTH_003.
*
* 가드는 인터셉터보다 먼저 돌므로 거부된 업로드는 파일을 읽기 전에 끝난다.
* 웹훅처럼 다른 사이트가 정당하게 부르는 엔드포인트가 생기면 이 가드에 예외 표시를 따로 만든다.
*/
@Injectable()
export class OriginGuard implements CanActivate {
private readonly allowedOrigins: ReadonlySet<string>;
constructor(config: ConfigService) {
this.allowedOrigins = new Set(
buildAllowedOrigins((key) => config.get<string>(key)),
);
}
canActivate(context: ExecutionContext): boolean {
if (context.getType() !== 'http') return true;
const request = context.switchToHttp().getRequest<Request>();
if (!STATE_CHANGING_METHODS.has(request.method.toUpperCase())) return true;
const secFetchSite = request.header('sec-fetch-site');
const origin = request.header('origin');
if (
checkRequestOrigin({ secFetchSite, origin }, this.allowedOrigins) !==
'rejected'
) {
return true;
}
throw new BusinessException('AUTH_003', {
context: {
reason: 'cross-site-request',
secFetchSite: secFetchSite ?? null,
origin: origin ?? null,
},
});
}
}
@@ -0,0 +1,78 @@
import type { CallHandler, ExecutionContext } from '@nestjs/common';
import { existsSync, mkdtempSync, rmSync, writeFileSync } from 'fs';
import { tmpdir } from 'os';
import { join } from 'path';
import { lastValueFrom, of, throwError } from 'rxjs';
import { DeleteUploadedFileInterceptor } from './delete-uploaded-file.interceptor';
function contextWith(file?: { path: string }): ExecutionContext {
const request = { file };
return {
switchToHttp: () => ({ getRequest: () => request }),
} as unknown as ExecutionContext;
}
/** 임시 파일이 지워지기를 기다리는 최대 시간 — 바쁜 CI 에서도 넉넉하게 */
const REMOVAL_DEADLINE_MS = 2_000;
/** 지워졌는지 다시 보는 간격 */
const REMOVAL_POLL_MS = 10;
/**
* 파일이 지워질 때까지 기다린다 — 기한 안에 지워졌으면 true, 기한이 지나도 남아 있으면 false.
* 인터셉터는 finalize 안에서 삭제를 시작만 하고 기다리지 않는다(`void this.remove(...)`) — 응답이 끝난 뒤에도 rm 이 진행 중일 수 있다.
* 예전에는 고정으로 20ms 만 기다렸는데, 부하가 큰 CI 에서 rm 이 그보다 늦어 가끔 실패했다 (2026-10-27).
* 그래서 지워질 때까지 자주 확인하고, 기한 안에 지워졌는지를 검사한다 — 삭제가 빠지면 기한 뒤에 그대로 실패한다.
*/
async function waitUntilRemoved(path: string): Promise<boolean> {
const deadline = Date.now() + REMOVAL_DEADLINE_MS;
while (existsSync(path)) {
if (Date.now() >= deadline) return false;
await new Promise((resolve) => setTimeout(resolve, REMOVAL_POLL_MS));
}
return true;
}
describe('DeleteUploadedFileInterceptor', () => {
let dir: string;
let file: string;
const interceptor = new DeleteUploadedFileInterceptor();
beforeEach(() => {
dir = mkdtempSync(join(tmpdir(), 'upload-cleanup-'));
file = join(dir, 'upload.bin');
writeFileSync(file, 'temp');
});
afterEach(() => rmSync(dir, { recursive: true, force: true }));
it('핸들러가 성공하면 임시 파일을 지운다', async () => {
const next: CallHandler = { handle: () => of({ id: 'blob' }) };
await expect(
lastValueFrom(interceptor.intercept(contextWith({ path: file }), next)),
).resolves.toEqual({ id: 'blob' });
await expect(waitUntilRemoved(file)).resolves.toBe(true);
});
it('핸들러가 실패해도 임시 파일을 지운다', async () => {
const next: CallHandler = {
handle: () => throwError(() => new Error('engine down')),
};
await expect(
lastValueFrom(interceptor.intercept(contextWith({ path: file }), next)),
).rejects.toThrow('engine down');
await expect(waitUntilRemoved(file)).resolves.toBe(true);
});
it('파일이 없는 요청은 그냥 통과한다', async () => {
const next: CallHandler = { handle: () => of(null) };
await expect(
lastValueFrom(interceptor.intercept(contextWith(), next)),
).resolves.toBeNull();
});
});
@@ -0,0 +1,46 @@
import {
CallHandler,
ExecutionContext,
Injectable,
Logger,
NestInterceptor,
} from '@nestjs/common';
import type { Request } from 'express';
import { rm } from 'fs/promises';
import { Observable, finalize } from 'rxjs';
import { traceTag } from '@/shared/logger/request-context';
/**
* 임시 업로드 파일 정리 — 핸들러가 끝나면(성공·실패 모두) multer 가 디스크에 받아 둔 파일을 지운다.
*
* 파일을 계속 보관하는 업로드(modules/upload)가 아니라, 받은 파일을 다른 곳(메일 엔진)으로 넘기고
* 버리는 엔드포인트에 쓴다. 반드시 FileInterceptor **뒤에** 둔다 (그래야 req.file 이 채워져 있다):
*
* @UseInterceptors(FileInterceptor('file'), DeleteUploadedFileInterceptor)
*
* multer 자체의 실패(크기 초과·연결 끊김)는 multer 가 받던 파일을 지운다 — 이 인터셉터까지 오지 않는다.
*/
@Injectable()
export class DeleteUploadedFileInterceptor implements NestInterceptor {
private readonly logger = new Logger(DeleteUploadedFileInterceptor.name);
intercept(context: ExecutionContext, next: CallHandler): Observable<unknown> {
const request = context.switchToHttp().getRequest<Request>();
return next.handle().pipe(finalize(() => void this.remove(request)));
}
private async remove(request: Request): Promise<void> {
const path = request.file?.path;
if (!path) return;
try {
await rm(path, { force: true });
} catch (error) {
// 지우지 못해도 응답은 이미 정해졌다 — 남은 파일은 기동 시 정리가 지운다
this.logger.warn(
`[SYS_001]${traceTag()} 임시 업로드 파일을 지우지 못했습니다: ${error instanceof Error ? error.message : String(error)}`,
);
}
}
}
@@ -0,0 +1,76 @@
import type { NextFunction, Request, Response } from 'express';
import { currentRequestId } from '@/shared/logger/request-context';
import {
REQUEST_ID_HEADER,
requestIdMiddleware,
resolveRequestId,
} from './request-id.middleware';
const UUID_PATTERN =
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
describe('resolveRequestId', () => {
it('영문·숫자·.·_·- 1~128자면 이어받는다 (nginx $request_id 등)', () => {
for (const value of [
'3f1c0a2e0f1b4f2a9a1e6c9f0a1b2c3d',
'req_2026-10-26.001',
'a'.repeat(128),
]) {
expect(resolveRequestId(value)).toBe(value);
}
});
it.each([
['없음', undefined],
['빈 값', ''],
['공백', 'has space'],
['따옴표·꺾쇠', '"><script>'],
['줄바꿈 흉내', 'a\\r\\nb'],
['너무 김', 'a'.repeat(129)],
['한글', '추적번호'],
])('그 밖의 값은 버리고 새로 만든다 — %s', (_label, value) => {
const resolved = resolveRequestId(value);
expect(resolved).toMatch(UUID_PATTERN);
expect(resolved).not.toBe(value);
});
});
describe('requestIdMiddleware', () => {
function run(incoming?: string) {
const headers: Record<string, string> = {};
if (incoming !== undefined) headers[REQUEST_ID_HEADER] = incoming;
const req = {
headers,
header: (name: string) => headers[name.toLowerCase()],
} as unknown as Request;
const setHeader = jest.fn();
const res = { setHeader } as unknown as Response;
let seenInContext: string | undefined;
const next: NextFunction = () => {
seenInContext = currentRequestId();
};
requestIdMiddleware(req, res, next);
return { headers, setHeader, seenInContext };
}
it('정한 ID 를 요청 헤더에 다시 쓰고(예외 필터가 읽는다) 응답 헤더로 돌려주며, 이후 처리는 그 컨텍스트 안에서 돈다', () => {
const { headers, setHeader, seenInContext } = run('trace-abc');
expect(headers[REQUEST_ID_HEADER]).toBe('trace-abc');
expect(setHeader).toHaveBeenCalledWith(REQUEST_ID_HEADER, 'trace-abc');
expect(seenInContext).toBe('trace-abc');
});
it('형식이 틀린 값은 요청 헤더에서도 새 ID 로 바꾼다 (원래 값이 로그에 실리지 않게)', () => {
const { headers, setHeader } = run('bad value <x>');
expect(headers[REQUEST_ID_HEADER]).toMatch(UUID_PATTERN);
expect(setHeader).toHaveBeenCalledWith(
REQUEST_ID_HEADER,
headers[REQUEST_ID_HEADER],
);
});
});
@@ -1,25 +1,41 @@
import { Injectable, NestMiddleware } from '@nestjs/common';
import { randomUUID } from 'crypto';
import { NextFunction, Request, Response } from 'express';
import type { NextFunction, Request, Response } from 'express';
import { runWithRequestContext } from '@/shared/logger/request-context';
/** 요청 추적 헤더명 — 로그·에러 응답의 traceId 와 동일한 값을 사용한다. */
export const REQUEST_ID_HEADER = 'x-request-id';
/**
* 요청 추적 ID 미들웨어.
* 프록시/클라이언트가 보낸 값이 있으면 이어받고, 없으면 새로 발급한다.
* 발급된 ID 는 응답 헤더로도 돌려주어 클라이언트가 문의 시 제시할 수 있게 한다.
* 이어받을 수 있는 요청 ID 형식 — 영문·숫자·`.`·`_`·`-` 1~128자.
* 프록시(nginx `$request_id` 등)가 붙인 값은 이어받고, 그 밖의 값(공백·따옴표·아주 긴 값 등)은 버리고 새로 만든다.
* 클라이언트가 보낸 임의 문자열이 로그·응답 헤더에 그대로 실리지 않게 하기 위해서다.
*/
@Injectable()
export class RequestIdMiddleware implements NestMiddleware {
use(req: Request, res: Response, next: NextFunction): void {
const incoming = req.header(REQUEST_ID_HEADER);
const requestId =
incoming && incoming.trim() !== '' ? incoming : randomUUID();
const REQUEST_ID_PATTERN = /^[A-Za-z0-9._-]{1,128}$/;
req.headers[REQUEST_ID_HEADER] = requestId;
res.setHeader(REQUEST_ID_HEADER, requestId);
next();
}
/** 들어온 값이 쓸 수 있는 형식이면 그것을, 아니면 새 UUID */
export function resolveRequestId(incoming: string | undefined): string {
return incoming !== undefined && REQUEST_ID_PATTERN.test(incoming)
? incoming
: randomUUID();
}
/**
* 요청 추적 ID 미들웨어 (express) — `configureApp()` 이 **가장 먼저** 등록한다.
* 본문 파서보다 앞에 있어야 본문 파서의 실패(413·400·415)에도 응답 traceId 와 로그가 붙는다.
*
* 발급한 ID 는 요청 헤더에 다시 써 두고(예외 필터가 읽는다) 응답 헤더로도 돌려준다.
* 이후의 처리는 요청 컨텍스트 안에서 돌아, 어느 계층의 로그에서도 `traceTag()` 로 같은 ID 를 붙일 수 있다.
*/
export function requestIdMiddleware(
req: Request,
res: Response,
next: NextFunction,
): void {
const requestId = resolveRequestId(req.header(REQUEST_ID_HEADER));
req.headers[REQUEST_ID_HEADER] = requestId;
res.setHeader(REQUEST_ID_HEADER, requestId);
runWithRequestContext({ requestId }, next);
}
+34 -46
View File
@@ -1,58 +1,58 @@
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
import { Logger, RequestMethod, ValidationPipe } from '@nestjs/common';
import { Logger } from '@nestjs/common';
import type { NestExpressApplication } from '@nestjs/platform-express';
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
import { WinstonModule } from 'nest-winston';
import helmet from 'helmet';
import type {
Express,
Request as ExpressRequest,
Response as ExpressResponse,
} from 'express';
import { Reflector } from '@nestjs/core';
import { HttpExceptionFilter } from '@/common/filters/http-exception.filter';
import { TransformInterceptor } from '@/common/interceptors/transform.interceptor';
import { configureApp } from '@/app.setup';
import { ACCESS_TOKEN_COOKIE } from '@/modules/auth/auth.constants';
import { winstonConfig } from '@/shared/logger/winston.config';
import { parsePositiveInt } from '@/shared/utils/env';
import {
buildAllowedOrigins,
normalizeOrigin,
} from '@/shared/utils/request-origin';
/** PORT 가 없거나 비었을 때 듣는 포트 */
const DEFAULT_PORT = 3000;
async function bootstrap() {
const app = await NestFactory.create(AppModule, {
const app = await NestFactory.create<NestExpressApplication>(AppModule, {
logger: WinstonModule.createLogger(winstonConfig),
// 본문 파서는 configureApp 이 직접 등록한다 (JSON 만, 크기 한도 지정)
bodyParser: false,
});
// 리버스 프록시(Nginx, Docker 배포 환경 등) 뒤에서 올바른 클라이언트 IP 식별 지원을 위해 설정합니다.
const expressApp = app.getHttpAdapter().getInstance() as Express;
expressApp.set('trust proxy', 1);
// 글로벌 prefix — 모든 라우트가 /api/* 로 정규화됨 (Flutter, Web 양쪽 baseURL과 일치)
app.setGlobalPrefix('api', {
// 헬스체크 / 루트 상태 엔드포인트는 prefix 미적용
exclude: [{ path: '/', method: RequestMethod.GET }],
});
// 기본 주소('/') 접속 시 상태 확인용 텍스트 응답 추가
// 기본 주소('/') 접속 시 상태 확인용 텍스트 응답 (컨테이너 healthcheck 가 읽는다)
const expressApp = app.getHttpAdapter().getInstance();
expressApp.get('/', (_req: ExpressRequest, res: ExpressResponse) => {
res.send('Backend API Server is running 정상 구동 중입니다.');
});
// 1. 보안 헤더 (Helmet) - XSS 방지 및 기본 웹 보안
app.use(helmet());
// 1. 공통 HTTP 파이프라인 — prefix · helmet · 쿠키 · 본문 파서 · 검증 · 표준 응답
// e2e 테스트도 같은 함수를 쓴다 (app.setup.ts)
configureApp(app);
// 2. 화이트리스트 기반 CORS 정책
const frontendUrl = process.env.FRONTEND_URL || 'http://localhost:5173';
const corsOriginEnv = process.env.CORS_ORIGIN;
// 2. 화이트리스트 기반 CORS 정책 — 운영은 CORS_ORIGIN 만, localhost 기본값은 개발에서만.
// 화면과 API 를 같은 주소로 배포하면 CORS 는 쓰이지 않는다 (docs/mail/api-contract.md 머리말).
// 같은 목록을 상태 변경 요청의 출처 검사(OriginGuard)도 쓴다.
const allowedOrigins = new Set(
buildAllowedOrigins((key) => process.env[key]),
);
app.enableCors({
origin: (
origin: string | undefined,
callback: (err: Error | null, allow?: boolean) => void,
) => {
// 서버간의 통신(origin이 undefined)이거나 명시된 리스트일 때만 허용
const whitelist = [frontendUrl, 'http://localhost:3000'];
if (corsOriginEnv) whitelist.push(corsOriginEnv);
if (!origin || whitelist.indexOf(origin) !== -1) {
// 서버 간 통신(origin 없음)이거나 목록에 있는 출처일 때만 허용
const normalized = origin === undefined ? null : normalizeOrigin(origin);
if (!origin || (normalized !== null && allowedOrigins.has(normalized))) {
callback(null, true);
} else {
// Error 를 넘기면 500(SYS_001)이 되어 원인 파악이 어려우므로,
@@ -67,37 +67,25 @@ async function bootstrap() {
// 라우트 단위 제어(@Throttle / @SkipThrottle)가 가능하고, 한도 초과는 REQ_001(429)로 응답된다.
// (전역 예외 필터가 429 → REQ_001 로 매핑한다 — .claude/rules/error-codes.md)
// 4. 전역 DTO 유효성 파이프 (강력한 검증 단계)
app.useGlobalPipes(
new ValidationPipe({
whitelist: true, // DTO에 불필요한 값이 오면 삭제
forbidNonWhitelisted: true, // DTO에 없는 필드가 주입되면 400 에러 발생
transform: true, // 네트워크 데이터를 DTO나 기본 타입으로 자동 변환
}),
);
// 5. 프론트엔드 연동을 위한 규격화된 에러 & 성공 페이로드 인터셉터 적용
app.useGlobalFilters(new HttpExceptionFilter());
app.useGlobalInterceptors(new TransformInterceptor(app.get(Reflector)));
// 6. Swagger API 문서 설정
// 4. Swagger API 문서 설정 — 인증은 HttpOnly 쿠키(access_token)뿐이다 (Authorization 헤더는 읽지 않는다)
const config = new DocumentBuilder()
.setTitle('Backend API Documentation')
.setDescription('The API documentation conforming to the global guidelines')
.setVersion('1.0')
.addBearerAuth()
.addCookieAuth(ACCESS_TOKEN_COOKIE)
.build();
const document = SwaggerModule.createDocument(app, config);
SwaggerModule.setup('api-docs', app, document);
// 7. Graceful shutdown — SIGTERM 수신 시 새 요청을 받지 않고
// 5. Graceful shutdown — SIGTERM 수신 시 새 요청을 받지 않고
// 처리 중인 요청을 마친 뒤 DB/Redis 커넥션을 정리하고 종료한다.
// (이 설정이 없으면 배포·재시작 때 처리 중이던 요청이 그대로 끊긴다)
app.enableShutdownHooks();
// 종료 로그는 ShutdownLogger(OnApplicationShutdown)가 남긴다.
const port = Number(process.env.PORT ?? 3000);
// compose 는 정의되지 않은 변수를 빈 문자열로 넣는다 — Number('') 는 0 이라 parsePositiveInt 로 기본값을 쓴다
const port = parsePositiveInt(process.env.PORT, DEFAULT_PORT);
await app.listen(port, '0.0.0.0');
Logger.log(`🚀 Backend running on http://localhost:${port}/api`, 'Bootstrap');
Logger.log(
@@ -0,0 +1,33 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* 로그인 세션 표 (docs/mail/api-contract.md 1절 "세션 규칙") — 이 프로젝트의 첫 테이블.
* 리프레시 토큰은 해시만 저장하고, 1회용 회전·재사용 감지·로그아웃 폐기를 이 표로 판단한다.
* 인덱스: expires_at(만료 정리) · revoked_at 부분 인덱스(폐기된 행만 — 정리 작업의 두 번째 조건)
* (자동 생성: npm run migration:generate — 엔티티는 modules/auth/auth-session.entity.ts)
*/
export class CreateAuthSessions1793010690903 implements MigrationInterface {
name = 'CreateAuthSessions1793010690903';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`CREATE TABLE "auth_sessions" ("id" uuid NOT NULL, "user_email" character varying(254) NOT NULL, "token_hash" character(64) NOT NULL, "previous_token_hash" character(64), "rotated_at" TIMESTAMP WITH TIME ZONE, "created_at" TIMESTAMP WITH TIME ZONE NOT NULL, "expires_at" TIMESTAMP WITH TIME ZONE NOT NULL, "absolute_expires_at" TIMESTAMP WITH TIME ZONE NOT NULL, "revoked_at" TIMESTAMP WITH TIME ZONE, "revoked_reason" character varying(32), CONSTRAINT "PK_641507381f32580e8479efc36cd" PRIMARY KEY ("id"))`,
);
await queryRunner.query(
`CREATE INDEX "IDX_auth_sessions_expires_at" ON "auth_sessions" ("expires_at") `,
);
await queryRunner.query(
`CREATE INDEX "IDX_auth_sessions_revoked_at" ON "auth_sessions" ("revoked_at") WHERE "revoked_at" IS NOT NULL`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`DROP INDEX "public"."IDX_auth_sessions_revoked_at"`,
);
await queryRunner.query(
`DROP INDEX "public"."IDX_auth_sessions_expires_at"`,
);
await queryRunner.query(`DROP TABLE "auth_sessions"`);
}
}
@@ -0,0 +1,44 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* 메일 서명·서명 그림·사내 목록 표 (docs/mail/api-contract.md 2절 "서명" · "받는 사람 추천").
* - mail_signatures: 사용자마다 서명 하나 (정화한 HTML·텍스트·넣을 메일)
* - mail_signature_images: 서명 그림 정보 — 파일은 업로드 볼륨의 mail-signatures 폴더. 주인 조회용 인덱스(user_email),
* 정리 작업용 부분 인덱스(attached_at 이 비어 있는 행의 created_at)
* - mail_directory: 웹메일 로그인에 성공한 직원 (받는 사람 추천의 "사내")
* 새 표만 만든다 — 기존 표(auth_sessions)는 건드리지 않는다.
* (자동 생성: npm run migration:generate — 엔티티는 modules/mail-signature/*.entity.ts · modules/mail-directory/mail-directory.entity.ts)
*/
export class AddMailSignaturesAndDirectory1793084109734 implements MigrationInterface {
name = 'AddMailSignaturesAndDirectory1793084109734';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`CREATE TABLE "mail_directory" ("email" character varying(254) NOT NULL, "name" character varying(200) NOT NULL, "last_login_at" TIMESTAMP WITH TIME ZONE NOT NULL, CONSTRAINT "PK_d79c6055eed1d875a53a13a71f8" PRIMARY KEY ("email"))`,
);
await queryRunner.query(
`CREATE TABLE "mail_signature_images" ("id" uuid NOT NULL, "user_email" character varying(254) NOT NULL, "content_type" character varying(32) NOT NULL, "size" integer NOT NULL, "width" integer NOT NULL, "height" integer NOT NULL, "file_name" character varying(64) NOT NULL, "created_at" TIMESTAMP WITH TIME ZONE NOT NULL, "attached_at" TIMESTAMP WITH TIME ZONE, CONSTRAINT "PK_f52f2c8dbb00f2973088763a776" PRIMARY KEY ("id"))`,
);
await queryRunner.query(
`CREATE INDEX "IDX_mail_signature_images_unattached" ON "mail_signature_images" ("created_at") WHERE "attached_at" IS NULL`,
);
await queryRunner.query(
`CREATE INDEX "IDX_mail_signature_images_user_email" ON "mail_signature_images" ("user_email") `,
);
await queryRunner.query(
`CREATE TABLE "mail_signatures" ("user_email" character varying(254) NOT NULL, "body_html" text, "body_text" text, "is_for_new" boolean NOT NULL DEFAULT true, "is_for_reply" boolean NOT NULL DEFAULT true, "updated_at" TIMESTAMP WITH TIME ZONE NOT NULL, CONSTRAINT "PK_4846a94ce96c5f4fe400f449a7f" PRIMARY KEY ("user_email"))`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP TABLE "mail_signatures"`);
await queryRunner.query(
`DROP INDEX "public"."IDX_mail_signature_images_user_email"`,
);
await queryRunner.query(
`DROP INDEX "public"."IDX_mail_signature_images_unattached"`,
);
await queryRunner.query(`DROP TABLE "mail_signature_images"`);
await queryRunner.query(`DROP TABLE "mail_directory"`);
}
}
@@ -0,0 +1,86 @@
import type { Response } from 'express';
import {
clearSessionCookies,
sessionCookieOptions,
setSessionCookies,
} from './auth-cookies';
import type { AuthSettings } from './auth.settings';
const settings: AuthSettings = {
jwtSecret: 'secret',
accessTtlSeconds: 3600,
refreshTtlSeconds: 14 * 24 * 3600,
secureCookies: false,
};
function fakeResponse() {
return {
cookie: jest.fn(),
clearCookie: jest.fn(),
};
}
describe('세션 쿠키', () => {
it('액세스 쿠키: HttpOnly · SameSite=Lax · Path=/api · Max-Age=액세스 만료', () => {
expect(sessionCookieOptions('access', settings)).toEqual({
httpOnly: true,
secure: false,
sameSite: 'lax',
path: '/api',
maxAge: 3600 * 1000,
});
});
it('리프레시 쿠키: HttpOnly · SameSite=Strict · Path=/api/auth · Max-Age=리프레시 만료', () => {
expect(sessionCookieOptions('refresh', settings)).toEqual({
httpOnly: true,
secure: false,
sameSite: 'strict',
path: '/api/auth',
maxAge: 14 * 24 * 3600 * 1000,
});
});
it('운영에서는 Secure 를 붙인다', () => {
const production = { ...settings, secureCookies: true };
expect(sessionCookieOptions('access', production).secure).toBe(true);
expect(sessionCookieOptions('refresh', production).secure).toBe(true);
});
it('두 쿠키를 함께 심고, 지울 때는 같은 Path·속성으로 지운다', () => {
const res = fakeResponse();
setSessionCookies(
res as unknown as Response,
{ accessToken: 'a.b.c', refreshToken: 'd.e.f', refreshTtlSeconds: 600 },
settings,
);
clearSessionCookies(res as unknown as Response, settings);
expect(res.cookie).toHaveBeenCalledWith(
'access_token',
'a.b.c',
expect.objectContaining({ path: '/api' }),
);
// 리프레시 쿠키 Max-Age 는 세션의 남은 수명을 따른다 (절대 만료가 가까우면 짧아진다)
expect(res.cookie).toHaveBeenCalledWith(
'refresh_token',
'd.e.f',
expect.objectContaining({ path: '/api/auth', maxAge: 600 * 1000 }),
);
expect(res.clearCookie).toHaveBeenCalledWith('access_token', {
httpOnly: true,
secure: false,
sameSite: 'lax',
path: '/api',
});
expect(res.clearCookie).toHaveBeenCalledWith('refresh_token', {
httpOnly: true,
secure: false,
sameSite: 'strict',
path: '/api/auth',
});
});
});
+73
View File
@@ -0,0 +1,73 @@
import type { CookieOptions, Response } from 'express';
import {
ACCESS_TOKEN_COOKIE,
ACCESS_TOKEN_COOKIE_PATH,
REFRESH_TOKEN_COOKIE,
REFRESH_TOKEN_COOKIE_PATH,
} from './auth.constants';
import type { AuthSettings } from './auth.settings';
/**
* 세션 쿠키 — 계약 1절. 토큰은 응답 본문에 싣지 않고 HttpOnly 쿠키로만 주고받는다.
*
* | 쿠키 | SameSite | Path | Max-Age |
* | access_token | Lax | /api | JWT_ACCESS_EXPIRES_IN |
* | refresh_token | Strict | /api/auth | 세션의 남은 미사용 기간 (JWT_REFRESH_EXPIRES_IN, 절대 만료 30일 이내) |
*/
export interface SessionTokens {
readonly accessToken: string;
readonly refreshToken: string;
/** 리프레시 토큰의 남은 수명(초) — 세션 절대 만료가 가까우면 JWT_REFRESH_EXPIRES_IN 보다 짧다 */
readonly refreshTtlSeconds: number;
}
type CookieKind = 'access' | 'refresh';
/** 지울 때도 같은 Path·속성을 써야 브라우저가 같은 쿠키로 알아본다 */
function baseOptions(kind: CookieKind, settings: AuthSettings): CookieOptions {
return {
httpOnly: true,
secure: settings.secureCookies,
sameSite: kind === 'access' ? 'lax' : 'strict',
path:
kind === 'access' ? ACCESS_TOKEN_COOKIE_PATH : REFRESH_TOKEN_COOKIE_PATH,
};
}
export function sessionCookieOptions(
kind: CookieKind,
settings: AuthSettings,
ttlSeconds: number = kind === 'access'
? settings.accessTtlSeconds
: settings.refreshTtlSeconds,
): CookieOptions {
// express 의 maxAge 는 밀리초다 (헤더에는 초 단위 Max-Age 와 Expires 로 나간다)
return { ...baseOptions(kind, settings), maxAge: ttlSeconds * 1000 };
}
export function setSessionCookies(
res: Response,
tokens: SessionTokens,
settings: AuthSettings,
): void {
res.cookie(
ACCESS_TOKEN_COOKIE,
tokens.accessToken,
sessionCookieOptions('access', settings),
);
res.cookie(
REFRESH_TOKEN_COOKIE,
tokens.refreshToken,
sessionCookieOptions('refresh', settings, tokens.refreshTtlSeconds),
);
}
export function clearSessionCookies(
res: Response,
settings: AuthSettings,
): void {
res.clearCookie(ACCESS_TOKEN_COOKIE, baseOptions('access', settings));
res.clearCookie(REFRESH_TOKEN_COOKIE, baseOptions('refresh', settings));
}
@@ -0,0 +1,37 @@
import { Logger } from '@nestjs/common';
import { ENDED_SESSION_RETENTION_MS } from './auth.constants';
import { AuthSessionCleanup } from './auth-session.cleanup';
import type { AuthSessionRepository } from './auth-session.repository';
describe('AuthSessionCleanup', () => {
afterEach(() => jest.restoreAllMocks());
it('보존 기간(7일)보다 먼저 끝난 세션을 지운다', async () => {
jest.spyOn(Logger.prototype, 'debug').mockImplementation(() => undefined);
const deleteEndedBefore = jest.fn().mockResolvedValue(3);
const cleanup = new AuthSessionCleanup({
deleteEndedBefore,
} as unknown as AuthSessionRepository);
const now = new Date('2026-10-26T09:00:00.000Z');
await expect(cleanup.purgeEndedSessions(now)).resolves.toBe(3);
expect(deleteEndedBefore).toHaveBeenCalledWith(
new Date(now.getTime() - ENDED_SESSION_RETENTION_MS),
);
});
it('DB 오류가 나도 앱을 멈추지 않고 오류 로그만 남긴다 (다음 주기에 다시 한다)', async () => {
const error = jest
.spyOn(Logger.prototype, 'error')
.mockImplementation(() => undefined);
const cleanup = new AuthSessionCleanup({
deleteEndedBefore: jest.fn().mockRejectedValue(new Error('db down')),
} as unknown as AuthSessionRepository);
await expect(cleanup.purgeEndedSessions()).resolves.toBe(0);
expect(error).toHaveBeenCalledWith(
expect.stringContaining('[SYS_001] 끝난 세션 정리에 실패했습니다'),
);
});
});
@@ -0,0 +1,37 @@
import { Injectable, Logger } from '@nestjs/common';
import { Cron, CronExpression } from '@nestjs/schedule';
import { ENDED_SESSION_RETENTION_MS } from './auth.constants';
import { AuthSessionRepository } from './auth-session.repository';
/**
* 끝난 세션 정리 — 만료·폐기되고 보존 기간(7일)이 지난 세션을 지운다.
* 보존 기간 동안은 "왜 로그아웃됐나요" 문의에 폐기 사유(revoked_reason)로 답할 수 있다.
*
* ⚠️ 여러 인스턴스로 늘리면 모든 인스턴스가 같은 시각에 같은 DELETE 를 실행한다 (결과는 같지만 낭비).
*/
@Injectable()
export class AuthSessionCleanup {
private readonly logger = new Logger(AuthSessionCleanup.name);
constructor(private readonly sessions: AuthSessionRepository) {}
@Cron(CronExpression.EVERY_HOUR, { name: 'auth-session-cleanup' })
async purgeEndedSessions(now: Date = new Date()): Promise<number> {
try {
const removed = await this.sessions.deleteEndedBefore(
new Date(now.getTime() - ENDED_SESSION_RETENTION_MS),
);
// 반복되는 정상 작업이라 info 로 남기지 않는다 (.claude/rules/logging.md)
if (removed > 0)
this.logger.debug(`끝난 세션 ${removed}건을 지웠습니다.`);
return removed;
} catch (error) {
// 정리 작업이 실패해도 앱은 계속 돈다 — 다음 주기에 다시 시도한다
this.logger.error(
`[SYS_001] 끝난 세션 정리에 실패했습니다: ${error instanceof Error ? error.message : String(error)}`,
);
return 0;
}
}
}
@@ -0,0 +1,77 @@
import { Column, Entity, Index, PrimaryColumn } from 'typeorm';
/** 메일 주소 최대 길이 (RFC 5321 경로 한도) — mail.constants 의 MAX_EMAIL_LENGTH 와 같다 */
const EMAIL_COLUMN_LENGTH = 254;
/** SHA-256 16진 문자열 길이 */
const TOKEN_HASH_LENGTH = 64;
/** 폐기 사유 길이 */
const REVOKED_REASON_LENGTH = 32;
/** 세션을 끝낸 사유 — 로그·문의 대응용 */
export type SessionRevokedReason =
| 'logout'
| 'refresh-token-reused'
| 'account-unavailable'
/** 비밀번호를 바꿨다 — 바꾼 세션 밖의 그 직원 세션 (계약 1절 "PUT /api/auth/password") */
| 'password-changed'
/** 비밀번호 바꾸기의 결과를 알 수 없다(엔진이 바꿨을 수 있다) — 바꾼 세션 밖의 그 직원 세션 */
| 'password-change-uncertain';
/**
* 로그인 세션 (계약 1절 "세션 규칙") — 리프레시 토큰의 1회용 회전과 폐기를 서버에서 판단한다.
*
* - 리프레시 토큰 원문은 저장하지 않는다. SHA-256 해시만 둔다 (DB 가 새어도 토큰을 만들 수 없다)
* - 액세스 토큰은 이 표를 보지 않는다 (서버 조회 없이 검증 — 세션을 끊어도 만료까지는 유효하다)
* - 끝난 세션은 정리 작업이 일정 기간 뒤에 지운다 (auth-session.cleanup.ts)
*/
@Entity({ name: 'auth_sessions' })
export class AuthSessionEntity {
/** 세션 id — 리프레시 토큰의 `sid` 클레임. 토큰을 서명하기 전에 정해야 하므로 앱이 만든다 */
@PrimaryColumn({ type: 'uuid' })
id!: string;
@Column({ name: 'user_email', type: 'varchar', length: EMAIL_COLUMN_LENGTH })
userEmail!: string;
/** 지금 쓸 수 있는 리프레시 토큰의 해시 */
@Column({ name: 'token_hash', type: 'char', length: TOKEN_HASH_LENGTH })
tokenHash!: string;
/** 바로 이전 토큰의 해시 — 동시 갱신 유예 판단용 (그보다 오래된 토큰이 오면 재사용) */
@Column({
name: 'previous_token_hash',
type: 'char',
length: TOKEN_HASH_LENGTH,
nullable: true,
})
previousTokenHash!: string | null;
/** 마지막 회전 시각 */
@Column({ name: 'rotated_at', type: 'timestamptz', nullable: true })
rotatedAt!: Date | null;
@Column({ name: 'created_at', type: 'timestamptz' })
createdAt!: Date;
/** 미사용 만료 — 회전할 때마다 늘어나지만 절대 만료를 넘지 않는다 */
@Index('IDX_auth_sessions_expires_at')
@Column({ name: 'expires_at', type: 'timestamptz' })
expiresAt!: Date;
/** 절대 만료 — 로그인 후 30일 */
@Column({ name: 'absolute_expires_at', type: 'timestamptz' })
absoluteExpiresAt!: Date;
/** 폐기 시각 — 정리 작업이 이 열로 찾으므로 폐기된 행만 담는 부분 인덱스를 둔다 (대부분의 행은 null) */
@Index('IDX_auth_sessions_revoked_at', { where: '"revoked_at" IS NOT NULL' })
@Column({ name: 'revoked_at', type: 'timestamptz', nullable: true })
revokedAt!: Date | null;
@Column({
name: 'revoked_reason',
type: 'varchar',
length: REVOKED_REASON_LENGTH,
nullable: true,
})
revokedReason!: SessionRevokedReason | null;
}
@@ -0,0 +1,98 @@
import {
classifyRefresh,
hashRefreshToken,
nextIdleExpiry,
type SessionSnapshot,
} from './auth-session.policy';
const ARA = 'ara.kim@arasystem.test';
const NOW = new Date('2026-10-26T09:00:00.000Z');
const GRACE_MS = 30_000;
const DAY_MS = 24 * 60 * 60 * 1000;
const CURRENT = hashRefreshToken('current.token');
const PREVIOUS = hashRefreshToken('previous.token');
const OLDER = hashRefreshToken('older.token');
function session(overrides: Partial<SessionSnapshot> = {}): SessionSnapshot {
return {
userEmail: ARA,
tokenHash: CURRENT,
previousTokenHash: PREVIOUS,
rotatedAt: new Date(NOW.getTime() - 5_000),
expiresAt: new Date(NOW.getTime() + DAY_MS),
absoluteExpiresAt: new Date(NOW.getTime() + 10 * DAY_MS),
revokedAt: null,
...overrides,
};
}
const classify = (snapshot: SessionSnapshot | null, hash: string, now = NOW) =>
classifyRefresh(snapshot, { sub: ARA }, hash, now, GRACE_MS);
describe('classifyRefresh', () => {
it('지금 토큰이면 current', () => {
expect(classify(session(), CURRENT)).toBe('current');
});
it('바로 이전 토큰이 유예 시간 안에 오면 grace (동시 갱신)', () => {
expect(classify(session(), PREVIOUS)).toBe('grace');
});
it('바로 이전 토큰이라도 유예 시간이 지나면 재사용(reused)', () => {
const rotatedLongAgo = session({
rotatedAt: new Date(NOW.getTime() - GRACE_MS),
});
expect(classify(rotatedLongAgo, PREVIOUS)).toBe('reused');
});
it('두 번 이상 지난 토큰은 시간과 무관하게 재사용(reused)', () => {
expect(classify(session(), OLDER)).toBe('reused');
});
it('회전한 적 없는 세션에 다른 토큰이 오면 재사용(reused)', () => {
expect(
classify(session({ previousTokenHash: null, rotatedAt: null }), OLDER),
).toBe('reused');
});
it('끊긴 세션은 어떤 토큰이든 revoked', () => {
const revoked = session({ revokedAt: new Date(NOW.getTime() - 1) });
expect(classify(revoked, CURRENT)).toBe('revoked');
expect(classify(revoked, OLDER)).toBe('revoked');
});
it('미사용 만료·절대 만료가 지나면 expired', () => {
expect(classify(session({ expiresAt: NOW }), CURRENT)).toBe('expired');
expect(classify(session({ absoluteExpiresAt: NOW }), CURRENT)).toBe(
'expired',
);
});
it('세션이 없거나 주인이 다르면 missing', () => {
expect(classify(null, CURRENT)).toBe('missing');
expect(
classify(session({ userEmail: 'doyun.lee@arasystem.test' }), CURRENT),
).toBe('missing');
});
});
describe('hashRefreshToken', () => {
it('SHA-256 16진 64자 — 원문을 담지 않는다', () => {
const hash = hashRefreshToken('header.payload.signature');
expect(hash).toMatch(/^[0-9a-f]{64}$/);
expect(hash).not.toContain('payload');
});
});
describe('nextIdleExpiry', () => {
it('지금 + 미사용 기간, 단 절대 만료를 넘지 않는다', () => {
const absolute = new Date(NOW.getTime() + 20 * DAY_MS);
expect(nextIdleExpiry(NOW, 14 * 24 * 3600, absolute)).toEqual(
new Date(NOW.getTime() + 14 * DAY_MS),
);
expect(nextIdleExpiry(NOW, 30 * 24 * 3600, absolute)).toEqual(absolute);
});
});
@@ -0,0 +1,72 @@
import { createHash, timingSafeEqual } from 'crypto';
/** 판단에 필요한 세션 값 — 엔티티의 일부 */
export interface SessionSnapshot {
readonly userEmail: string;
readonly tokenHash: string;
readonly previousTokenHash: string | null;
readonly rotatedAt: Date | null;
readonly expiresAt: Date;
readonly absoluteExpiresAt: Date;
readonly revokedAt: Date | null;
}
/**
* 리프레시 요청이 세션의 어느 상태에 해당하는가 (계약 1절 "세션 규칙").
*
* - current: 지금 토큰 → 회전한다
* - grace: 바로 이전 토큰이 회전 직후 유예 시간 안에 왔다 → 동시 갱신(탭 여러 개). 새 토큰 없이 성공만
* - reused: 이미 쓴 토큰 → 탈취로 보고 세션 전체를 끊는다
* - revoked / expired / missing: 끝난 세션 → AUTH_001
*/
export type RefreshState =
| 'current'
| 'grace'
| 'reused'
| 'revoked'
| 'expired'
| 'missing';
/** 리프레시 토큰을 저장할 형태 — SHA-256 16진 (토큰은 무작위 jti 를 담아 엔트로피가 충분하다) */
export function hashRefreshToken(token: string): string {
return createHash('sha256').update(token).digest('hex');
}
function sameHash(a: string, b: string): boolean {
const left = Buffer.from(a, 'hex');
const right = Buffer.from(b, 'hex');
return left.length === right.length && timingSafeEqual(left, right);
}
export function classifyRefresh(
session: SessionSnapshot | null,
claims: { readonly sub: string },
presentedHash: string,
now: Date,
graceMs: number,
): RefreshState {
// 서명이 맞는데 주인이 다르면 우리가 만든 토큰이 아니다 — 없는 세션과 같게 다룬다
if (!session || session.userEmail !== claims.sub) return 'missing';
if (session.revokedAt !== null) return 'revoked';
if (session.expiresAt <= now || session.absoluteExpiresAt <= now) {
return 'expired';
}
if (sameHash(session.tokenHash, presentedHash)) return 'current';
const withinGrace =
session.previousTokenHash !== null &&
session.rotatedAt !== null &&
sameHash(session.previousTokenHash, presentedHash) &&
now.getTime() - session.rotatedAt.getTime() < graceMs;
return withinGrace ? 'grace' : 'reused';
}
/** 다음 미사용 만료 — 지금 + 미사용 기간, 단 절대 만료를 넘지 않는다 */
export function nextIdleExpiry(
now: Date,
idleTtlSeconds: number,
absoluteExpiresAt: Date,
): Date {
const idle = now.getTime() + idleTtlSeconds * 1000;
return new Date(Math.min(idle, absoluteExpiresAt.getTime()));
}
@@ -0,0 +1,112 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { IsNull, MoreThan, Not, type Repository } from 'typeorm';
import {
AuthSessionEntity,
type SessionRevokedReason,
} from './auth-session.entity';
export interface RotateSessionCommand {
readonly id: string;
/** 요청에 실려 온 토큰의 해시 — 지금 토큰이 이것일 때만 바꾼다 */
readonly presentedHash: string;
readonly nextHash: string;
readonly nextExpiresAt: Date;
readonly now: Date;
}
/**
* 세션 표 접근 — 판단(회전·재사용·만료)은 AuthService 와 auth-session.policy.ts 가 한다.
*/
@Injectable()
export class AuthSessionRepository {
constructor(
@InjectRepository(AuthSessionEntity)
private readonly sessions: Repository<AuthSessionEntity>,
) {}
async create(session: AuthSessionEntity): Promise<void> {
await this.sessions.insert(session);
}
findById(id: string): Promise<AuthSessionEntity | null> {
return this.sessions.findOneBy({ id });
}
/**
* 1회용 회전 — **조건부 UPDATE 한 번**으로 한다.
* 지금 토큰이 요청의 토큰이고 세션이 살아 있을 때만 바뀐다. 같은 토큰으로 동시에 온 두 요청 중
* 하나만 성공한다 (PostgreSQL 은 먼저 잡은 쪽이 커밋한 뒤 나머지의 WHERE 를 다시 평가한다).
* @returns 바꿨으면 true
*/
async rotate(command: RotateSessionCommand): Promise<boolean> {
const result = await this.sessions.update(
{
id: command.id,
tokenHash: command.presentedHash,
revokedAt: IsNull(),
expiresAt: MoreThan(command.now),
absoluteExpiresAt: MoreThan(command.now),
},
{
tokenHash: command.nextHash,
previousTokenHash: command.presentedHash,
rotatedAt: command.now,
expiresAt: command.nextExpiresAt,
},
);
return result.affected === 1;
}
/** 세션을 끝낸다 — 이미 끝난 세션은 사유를 덮어쓰지 않는다 */
async revoke(
id: string,
reason: SessionRevokedReason,
now: Date,
): Promise<void> {
await this.sessions.update(
{ id, revokedAt: IsNull() },
{ revokedAt: now, revokedReason: reason },
);
}
/**
* 그 직원의 살아 있는 세션을 모두 끝낸다 — `keepId` 세션만 남긴다 (없으면 모두) → 끝낸 수.
* 이미 끝난(만료·폐기) 세션은 건드리지 않는다 (끝난 사유를 덮어쓰지 않게). 비밀번호를 바꿨을 때 쓴다.
* user_email 에는 인덱스가 없다 — 표가 작고(끝난 세션은 7일 뒤 정리) 드문 일이라 순차 조회로 충분하다.
*/
async revokeAllOf(
userEmail: string,
reason: SessionRevokedReason,
now: Date,
keepId: string | null,
): Promise<number> {
const result = await this.sessions.update(
{
userEmail,
revokedAt: IsNull(),
expiresAt: MoreThan(now),
absoluteExpiresAt: MoreThan(now),
...(keepId !== null ? { id: Not(keepId) } : {}),
},
{ revokedAt: now, revokedReason: reason },
);
return result.affected ?? 0;
}
/**
* `before` 보다 먼저 끝난(만료·폐기) 세션을 지운다 → 지운 수.
* 미사용 만료(expires_at)는 항상 절대 만료 이하로 저장하므로(nextIdleExpiry) 절대 만료를 따로 볼 필요가 없다.
* 두 조건 모두 인덱스를 탄다 — expires_at 인덱스, revoked_at 부분 인덱스(폐기된 행만).
*/
async deleteEndedBefore(before: Date): Promise<number> {
const result = await this.sessions
.createQueryBuilder()
.delete()
.from(AuthSessionEntity)
.where('expires_at < :before OR revoked_at < :before', { before })
.execute();
return result.affected ?? 0;
}
}
@@ -0,0 +1,19 @@
/**
* JWT 페이로드.
* `typ` 으로 액세스/리프레시를 구분한다 — 리프레시 토큰을 액세스 쿠키에 넣어도 통하지 않고, 그 반대도 마찬가지다.
*/
export type AuthTokenType = 'access' | 'refresh';
export interface AuthTokenPayload {
/** 직원 메일 주소 */
readonly sub: string;
readonly typ: AuthTokenType;
/** 표시 이름 — 액세스 토큰에만 넣는다 (/auth/me 가 메일 엔진에 묻지 않고 답한다) */
readonly name?: string;
/** 세션 id — 리프레시 토큰에만 넣는다 (auth_sessions.id). 회전·폐기를 이 세션 기준으로 판단한다 */
readonly sid?: string;
/** 토큰 id — 같은 순간에 발급한 토큰도 서로 다른 값(해시)이 되게 한다 */
readonly jti?: string;
readonly iat?: number;
readonly exp?: number;
}
@@ -0,0 +1,79 @@
/**
* 인증 상수 — 계약(docs/mail/api-contract.md 1절)의 쿠키 규칙과 한도를 한 곳에 모은다.
*/
/** 액세스 JWT 쿠키 — 모든 API 경로로 간다 */
export const ACCESS_TOKEN_COOKIE = 'access_token';
export const ACCESS_TOKEN_COOKIE_PATH = '/api';
/** 리프레시 JWT 쿠키 — 인증 경로로만 간다 (다른 API 요청에 실려 다니지 않게) */
export const REFRESH_TOKEN_COOKIE = 'refresh_token';
export const REFRESH_TOKEN_COOKIE_PATH = '/api/auth';
const MINUTE_SECONDS = 60;
const HOUR_SECONDS = 60 * MINUTE_SECONDS;
const DAY_SECONDS = 24 * HOUR_SECONDS;
/** env 가 없을 때의 만료 기본값 (JWT_ACCESS_EXPIRES_IN · JWT_REFRESH_EXPIRES_IN) */
export const DEFAULT_ACCESS_TTL_SECONDS = HOUR_SECONDS;
/** 리프레시 = 세션의 **미사용** 만료 — 이 기간 동안 갱신하지 않으면 세션이 끝난다 (계약 1절) */
export const DEFAULT_REFRESH_TTL_SECONDS = 14 * DAY_SECONDS;
/**
* 액세스 토큰 만료 허용 범위 (env 검증) — 액세스 토큰은 서버 조회 없이 검증되므로
* 세션을 끊어도 만료까지 유효하다. 그래서 짧게 둔다 (.claude/rules/security.md).
*/
export const ACCESS_TTL_MIN_SECONDS = 5 * MINUTE_SECONDS;
export const ACCESS_TTL_MAX_SECONDS = HOUR_SECONDS;
/** 세션 절대 만료 — 로그인 후 이 기간이 지나면 갱신해도 끝난다 (계약 1절) */
export const SESSION_ABSOLUTE_TTL_SECONDS = 30 * DAY_SECONDS;
/** 리프레시(미사용 만료)는 절대 만료를 넘을 수 없다 */
export const REFRESH_TTL_MAX_SECONDS = SESSION_ABSOLUTE_TTL_SECONDS;
/**
* 직전 리프레시 토큰의 유예 시간 (밀리초).
* 탭 두 개가 같은 리프레시 쿠키로 동시에 갱신하면 한쪽은 이미 회전된 토큰을 내게 된다.
* 회전 직후 이 시간 안에 **바로 이전** 토큰이 오면 탈취로 보지 않고, 새 토큰 없이 성공만 돌려준다
* (이긴 쪽 응답이 이미 새 쿠키를 구웠다). 유예 중에도 토큰을 새로 주지 않으므로 훔친 토큰으로 얻는 것은 없다.
* 이 시간이 지난 뒤 오거나 두 번 이상 지난 토큰이 오면 재사용으로 보고 세션 전체를 끊는다.
*/
export const REFRESH_REUSE_GRACE_MS = 30_000;
/** 끝난 세션(만료·폐기)을 지우기 전까지 남겨 두는 기간 — "왜 로그아웃됐나요" 문의 확인용 */
export const ENDED_SESSION_RETENTION_MS = 7 * DAY_SECONDS * 1000;
/** 서명 알고리즘 — 검증 때도 이것만 받는다 (alg 바꿔치기 방지) */
export const JWT_ALGORITHM = 'HS256';
/**
* 로그인 **실패** 한도 (계약 1절) — 성공한 로그인은 세지 않는다.
* 사무실처럼 여러 직원이 한 IP 를 쓰면 성공까지 세는 빡빡한 한도는 정상 로그인까지 막기 때문이다.
* 메일 엔진은 백엔드(신뢰 대역)를 자동 차단에서 빼므로 무차별 대입 방어는 백엔드 몫이다 (mail/README.md).
* 두 한도 모두 없는 주소에도 똑같이 적용해 계정 존재 여부를 드러내지 않는다.
*/
/** 같은 IP 에서 1분에 실패 20회 — 여러 계정을 돌아가며 찔러 보는 공격 */
export const LOGIN_IP_FAILURE_LIMIT = 20;
export const LOGIN_IP_FAILURE_WINDOW_MS = 60_000;
/** 같은 주소로 15분에 실패 10회 — IP 를 바꿔 가며 한 계정을 노리는 공격 */
export const LOGIN_ADDRESS_FAILURE_LIMIT = 10;
export const LOGIN_ADDRESS_FAILURE_WINDOW_MS = 15 * 60_000;
/**
* 로그인 요청 수 상한 (IP 당 1분) — 실패 한도와 별개로 요청 자체를 넉넉하게 제한한다.
* 실패 한도가 무차별 대입을 막고, 이 상한은 한 IP 가 엔진 확인을 폭주시키지 못하게 한다.
* 사무실 NAT(여러 직원이 한 IP)를 고려해 정상 사용으로는 닿지 않는 값으로 둔다.
*/
export const LOGIN_REQUEST_LIMIT = 60;
export const LOGIN_REQUEST_WINDOW_MS = 60_000;
/** 비밀번호 최대 길이 — 아주 긴 값으로 메일 엔진의 해시 계산을 오래 붙잡지 못하게 한다 */
export const MAX_PASSWORD_LENGTH = 256;
/**
* 비밀번호 바꾸기의 새 비밀번호 (계약 1절 "PUT /api/auth/password") — 8~128자, 길이는 JS 문자열 길이(UTF-16 코드 단위)로 센다(웹 클라이언트와 같다).
* 엔진도 8자 미만을 거절한다(2026-10-28 확인). 지금 비밀번호는 로그인과 같은 검사(MAX_PASSWORD_LENGTH) — 로그인보다 긴 비밀번호로는 들어올 수 없다
*/
export const NEW_PASSWORD_MIN_LENGTH = 8;
export const NEW_PASSWORD_MAX_LENGTH = 128;
@@ -0,0 +1,222 @@
import { GUARDS_METADATA } from '@nestjs/common/constants';
import type { Request, Response } from 'express';
import { BusinessException } from '@/common/errors/business.exception';
import { JwtAuthGuard } from '@/common/guards/jwt-auth.guard';
import { AuthController } from './auth.controller';
import type { AuthService } from './auth.service';
import type { AuthSettings } from './auth.settings';
const settings: AuthSettings = {
jwtSecret: 'secret',
accessTtlSeconds: 3600,
refreshTtlSeconds: 1209600,
secureCookies: false,
};
const session = {
user: { email: 'ara.kim@arasystem.test', name: '김아라' },
tokens: {
accessToken: 'new.access',
refreshToken: 'new.refresh',
refreshTtlSeconds: 1209600,
},
};
function fakeResponse() {
return { cookie: jest.fn(), clearCookie: jest.fn() };
}
/** 핸들러 함수 객체 — Nest 는 라우트 메타데이터(가드·한도)를 이 함수에 붙인다 */
function handlerOf(
name: 'login' | 'me' | 'refresh' | 'logout' | 'changePassword',
): object {
const descriptor = Object.getOwnPropertyDescriptor(
AuthController.prototype,
name,
);
return descriptor?.value as object;
}
describe('AuthController', () => {
let auth: {
login: jest.Mock;
refresh: jest.Mock;
logout: jest.Mock;
changePassword: jest.Mock;
};
let controller: AuthController;
beforeEach(() => {
auth = {
login: jest.fn(),
refresh: jest.fn(),
logout: jest.fn().mockResolvedValue(undefined),
changePassword: jest.fn().mockResolvedValue(undefined),
};
controller = new AuthController(auth as unknown as AuthService, settings);
});
it('/auth/refresh 에는 가드가 없다 — 액세스 쿠키 없이 리프레시 쿠키만으로 동작한다', () => {
const refreshGuards: unknown = Reflect.getMetadata(
GUARDS_METADATA,
handlerOf('refresh'),
);
const meGuards: unknown = Reflect.getMetadata(
GUARDS_METADATA,
handlerOf('me'),
);
expect(refreshGuards).toBeUndefined();
expect(meGuards).toEqual([JwtAuthGuard]);
expect(
Reflect.getMetadata(GUARDS_METADATA, AuthController),
).toBeUndefined();
});
it('로그인에는 넉넉한 요청 수 상한(IP 당 1분 60회)이 따로 걸린다 — 무차별 대입은 실패 횟수로 막는다', () => {
const handler = handlerOf('login');
expect(Reflect.getMetadata('THROTTLER:LIMITdefault', handler)).toBe(60);
expect(Reflect.getMetadata('THROTTLER:TTLdefault', handler)).toBe(60_000);
// 다른 인증 엔드포인트는 전역 기본값을 쓴다
expect(
Reflect.getMetadata('THROTTLER:LIMITdefault', handlerOf('refresh')),
).toBeUndefined();
});
it('로그인은 클라이언트 IP 를 서비스에 넘기고 두 쿠키를 굽는다', async () => {
auth.login.mockResolvedValue(session);
const res = fakeResponse();
const user = await controller.login(
{ email: 'ara.kim@arasystem.test', password: 'pw' },
'203.0.113.10',
res as unknown as Response,
);
expect(auth.login).toHaveBeenCalledWith(
'ara.kim@arasystem.test',
'pw',
'203.0.113.10',
);
expect(user).toEqual(session.user);
expect(res.cookie).toHaveBeenCalledTimes(2);
});
it('리프레시는 리프레시 쿠키만 읽어 서비스에 넘기고 두 쿠키를 새로 굽는다', async () => {
auth.refresh.mockResolvedValue(session);
const res = fakeResponse();
const req = { cookies: { refresh_token: 'old.refresh' } };
const user = await controller.refresh(
req as unknown as Request,
res as unknown as Response,
);
expect(auth.refresh).toHaveBeenCalledWith('old.refresh');
expect(user).toEqual(session.user);
expect(res.cookie).toHaveBeenCalledWith(
'access_token',
'new.access',
expect.anything(),
);
expect(res.cookie).toHaveBeenCalledWith(
'refresh_token',
'new.refresh',
expect.anything(),
);
});
it('동시 갱신 유예(tokens: null)면 쿠키를 굽지 않는다 — 먼저 끝난 요청의 새 쿠키를 덮어쓰지 않게', async () => {
auth.refresh.mockResolvedValue({ user: session.user, tokens: null });
const res = fakeResponse();
const user = await controller.refresh(
{ cookies: { refresh_token: 'old.refresh' } } as unknown as Request,
res as unknown as Response,
);
expect(user).toEqual(session.user);
expect(res.cookie).not.toHaveBeenCalled();
expect(res.clearCookie).not.toHaveBeenCalled();
});
it('리프레시가 AUTH_001 이면 두 쿠키를 지운다', async () => {
auth.refresh.mockRejectedValue(new BusinessException('AUTH_001'));
const res = fakeResponse();
await expect(
controller.refresh(
{ cookies: {} } as unknown as Request,
res as unknown as Response,
),
).rejects.toBeInstanceOf(BusinessException);
expect(res.clearCookie).toHaveBeenCalledWith(
'access_token',
expect.anything(),
);
expect(res.clearCookie).toHaveBeenCalledWith(
'refresh_token',
expect.anything(),
);
});
it('메일 엔진 장애(MAIL_001)면 세션을 남긴다 (쿠키를 지우지 않는다)', async () => {
auth.refresh.mockRejectedValue(new BusinessException('MAIL_001'));
const res = fakeResponse();
await expect(
controller.refresh(
{ cookies: { refresh_token: 'old.refresh' } } as unknown as Request,
res as unknown as Response,
),
).rejects.toBeInstanceOf(BusinessException);
expect(res.clearCookie).not.toHaveBeenCalled();
});
it('비밀번호 바꾸기는 로그인이 필요하고(JwtAuthGuard), 직원 · 두 비밀번호 · IP · 리프레시 쿠키를 서비스에 넘겨 null 을 돌려준다 — 쿠키는 굽지도 지우지도 않는다', async () => {
expect(
Reflect.getMetadata(GUARDS_METADATA, handlerOf('changePassword')),
).toEqual([JwtAuthGuard]);
// 요청 수 상한은 전역 기본값 — 무차별 대입은 로그인과 같은 실패 한도가 막는다
expect(
Reflect.getMetadata(
'THROTTLER:LIMITdefault',
handlerOf('changePassword'),
),
).toBeUndefined();
const user = { email: 'ara.kim@arasystem.test', name: '김아라' };
const req = { cookies: { refresh_token: 'this.refresh' } };
await expect(
controller.changePassword(
user,
{ currentPassword: 'old-password', newPassword: 'new-password-1' },
'203.0.113.10',
req as unknown as Request,
),
).resolves.toBeNull();
expect(auth.changePassword).toHaveBeenCalledWith(
user,
'old-password',
'new-password-1',
'203.0.113.10',
'this.refresh',
);
});
it('로그아웃은 리프레시 쿠키의 세션을 끊고, 항상 null 을 돌려주며 두 쿠키를 지운다', async () => {
const res = fakeResponse();
await expect(
controller.logout(
{ cookies: { refresh_token: 'old.refresh' } } as unknown as Request,
res as unknown as Response,
),
).resolves.toBeNull();
expect(auth.logout).toHaveBeenCalledWith('old.refresh');
expect(res.clearCookie).toHaveBeenCalledTimes(2);
});
});
+206
View File
@@ -0,0 +1,206 @@
import {
Body,
Controller,
Get,
HttpCode,
HttpStatus,
Inject,
Ip,
Post,
Put,
Req,
Res,
UseGuards,
} from '@nestjs/common';
import { ApiCookieAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { Throttle } from '@nestjs/throttler';
import type { Request, Response } from 'express';
import {
ApiErrorResponse,
ApiStandardResponse,
} from '@/common/decorators/api-standard-response.decorator';
import {
CurrentUser,
type AuthenticatedUser,
} from '@/common/decorators/current-user.decorator';
import { BusinessException } from '@/common/errors/business.exception';
import { JwtAuthGuard } from '@/common/guards/jwt-auth.guard';
import { readCookie } from '@/shared/utils/cookies';
import { clearSessionCookies, setSessionCookies } from './auth-cookies';
import {
LOGIN_REQUEST_LIMIT,
LOGIN_REQUEST_WINDOW_MS,
REFRESH_TOKEN_COOKIE,
} from './auth.constants';
import { AuthService } from './auth.service';
import { AUTH_SETTINGS, type AuthSettings } from './auth.settings';
import { AuthUserDto } from './dto/auth-user.dto';
import { ChangePasswordDto } from './dto/change-password.dto';
import { LoginDto } from './dto/login.dto';
/**
* 직원 인증 — 쿠키 기반 (계약 1절). 토큰은 응답 본문에 없다.
* 쿠키 굽기·지우기는 HTTP 응답 처리라 여기서 하고, 판단은 모두 AuthService 가 한다.
*/
@ApiTags('Auth')
@Controller('auth')
export class AuthController {
constructor(
private readonly auth: AuthService,
@Inject(AUTH_SETTINGS) private readonly settings: AuthSettings,
) {}
// 무차별 대입은 **실패** 횟수로 막는다 (LoginAttemptService: IP 1분 20회 · 주소 15분 10회 — 엔진에 묻기 전에 차지).
// 요청 수 상한은 그와 별개로 넉넉하게 — 사무실 NAT 에서 정상 로그인으로는 닿지 않는 값 (IP 당 1분 60회)
@Post('login')
@Throttle({
default: { ttl: LOGIN_REQUEST_WINDOW_MS, limit: LOGIN_REQUEST_LIMIT },
})
@HttpCode(HttpStatus.OK)
@ApiOperation({
summary: '로그인 — 성공하면 access_token·refresh_token 쿠키를 굽는다',
})
@ApiStandardResponse(AuthUserDto, { description: '로그인 성공' })
@ApiErrorResponse(
'AUTH_004',
'계정 없음·비밀번호 틀림·사내 도메인이 아닌 주소·메일 엔진 관리자 계정 (구분하지 않는다)',
)
@ApiErrorResponse('VAL_001', '형식 오류')
@ApiErrorResponse(
'REQ_001',
'시도 과다 — 같은 IP 1분에 실패 20회 · 같은 주소 15분에 실패 10회(성공은 세지 않는다) · 같은 주소의 로그인이 처리 중 · IP 당 1분 60회 요청',
)
@ApiErrorResponse('MAIL_001', '메일 엔진 장애')
async login(
@Body() dto: LoginDto,
@Ip() ip: string,
@Res({ passthrough: true }) res: Response,
): Promise<AuthUserDto> {
const result = await this.auth.login(dto.email, dto.password, ip);
if (result.tokens) setSessionCookies(res, result.tokens, this.settings);
return result.user;
}
@Get('me')
@UseGuards(JwtAuthGuard)
@ApiCookieAuth()
@ApiOperation({ summary: '현재 로그인한 직원 (앱 시작 시 세션 복원용)' })
@ApiStandardResponse(AuthUserDto, { description: '로그인 상태' })
@ApiErrorResponse(
'AUTH_001',
'로그인 안 됨 — 앱 시작 시에는 정상 상태이므로 알림을 띄우지 않는다',
)
me(@CurrentUser() user: AuthenticatedUser): AuthUserDto {
return { email: user.email, name: user.name };
}
/**
* 가드를 걸지 않는다 — 액세스 쿠키가 없거나 만료된 상태에서 부르는 엔드포인트다 (세션 복원).
* 리프레시 쿠키만 검증한다.
*/
@Post('refresh')
@HttpCode(HttpStatus.OK)
@ApiOperation({
summary: '토큰 회전 — 리프레시 쿠키만으로 두 쿠키를 새로 굽는다 (1회용)',
description:
'액세스 쿠키가 없거나 만료돼도 동작한다. 실패(AUTH_001)하면 두 쿠키를 지운다. ' +
'이미 쓴 리프레시 토큰이 오면 세션 전체를 끊는다. 다른 탭이 방금 회전한 직후의 이전 토큰은 30초 동안 ' +
'새 쿠키 없이 200 으로 답한다 (이긴 요청이 이미 새 쿠키를 구웠다).',
})
@ApiStandardResponse(AuthUserDto, { description: '회전 성공' })
@ApiErrorResponse(
'AUTH_001',
'리프레시 쿠키 없음·만료·위조·이미 쓴 토큰(세션 폐기)·로그아웃한 세션·계정 비활성 — 쿠키도 지운다',
)
@ApiErrorResponse('MAIL_001', '메일 엔진 장애 — 쿠키는 그대로 둔다')
async refresh(
@Req() req: Request,
@Res({ passthrough: true }) res: Response,
): Promise<AuthUserDto> {
try {
const result = await this.auth.refresh(
readCookie(req, REFRESH_TOKEN_COOKIE),
);
// 동시 갱신 유예면 쿠키를 굽지 않는다 — 먼저 끝난 요청이 구운 새 쿠키를 덮어쓰지 않게
if (result.tokens) setSessionCookies(res, result.tokens, this.settings);
return result.user;
} catch (error) {
// 더는 쓸 수 없는 세션이면 쿠키를 지운다. 메일 엔진 장애처럼 일시적인 실패는 세션을 남긴다
if (error instanceof BusinessException && error.code === 'AUTH_001') {
clearSessionCookies(res, this.settings);
}
throw error;
}
}
/**
* 비밀번호 바꾸기 — 판단은 모두 AuthService 가 한다. 리프레시 쿠키(Path=/api/auth 라 이 경로에도 온다)로 이 세션을 알아
* 그 세션만 남기고 그 직원의 다른 세션을 끊는다. 쿠키는 굽지도 지우지도 않는다 (이 세션은 그대로 이어 쓴다).
* 요청 수 상한은 전역 기본값 — 무차별 대입은 로그인과 같은 실패 횟수 한도가 막는다.
*/
@Put('password')
@UseGuards(JwtAuthGuard)
@ApiCookieAuth()
@HttpCode(HttpStatus.OK)
@ApiOperation({
summary: '비밀번호 바꾸기 — 지금 비밀번호로 메일 엔진에 본인 변경을 보낸다',
description:
'메일 엔진에 그 직원 본인의 자격(지금 비밀번호)으로 보낸다 — 게이트웨이 대리 접근·관리자 권한을 쓰지 않는다. ' +
'바뀌면 이 요청의 세션만 남기고 그 직원의 다른 로그인 세션을 모두 끊는다 (휴대폰·PC 메일 프로그램은 새 비밀번호를 다시 넣어야 한다). ' +
'지금 비밀번호 틀림은 로그인 실패와 같은 한도(같은 IP 1분 20회 · 같은 주소 15분 10회)로 함께 센다. ' +
'지금 비밀번호의 길이 한도는 로그인 비밀번호와 같다.',
})
@ApiStandardResponse(undefined, {
description: '바꿨다 (data: null)',
})
// 같은 400 이라 한 항목에 함께 적는다 (Swagger 는 상태 코드마다 하나만 남긴다)
@ApiErrorResponse(
'AUTH_005',
'AUTH_005 지금 비밀번호가 틀림 · 지금 비밀번호로 들어갔지만 메일 엔진이 바꾸기를 거절함(변경 권한 없음 등 — 이것은 실패로 세지 않는다) — 세션은 그대로다. ' +
'401 이 아니다(클라이언트는 401 이면 세션을 갱신하고 같은 요청을 다시 보낸다) · ' +
'VAL_001 새 비밀번호가 8자 미만 · 128자 초과(JS 문자열 길이) · 지금 비밀번호와 같음 · 메일 엔진 규칙에 어긋남, 형식 오류 (모두 400)',
)
@ApiErrorResponse(
'REQ_001',
'지금 비밀번호 틀림이 잦음(로그인 실패와 같은 한도) · 같은 주소의 로그인·변경이 처리 중',
)
@ApiErrorResponse(
'AUTH_001',
'로그인 필요 (쿠키 없음·만료) · 웹메일로 다루지 않는 계정(메일 엔진 관리자 등)',
)
@ApiErrorResponse(
'MAIL_001',
'메일 엔진 장애 (바뀌지 않았다) · 결과를 알 수 없게 끝남(시간 초과 · 연결 끊김 · 엔진 5xx — 엔진이 바꿨을 수 있어 이 세션만 남기고 다른 세션은 끊어 두었다)',
)
async changePassword(
@CurrentUser() user: AuthenticatedUser,
@Body() dto: ChangePasswordDto,
@Ip() ip: string,
@Req() req: Request,
): Promise<null> {
await this.auth.changePassword(
user,
dto.currentPassword,
dto.newPassword,
ip,
readCookie(req, REFRESH_TOKEN_COOKIE),
);
return null;
}
@Post('logout')
@HttpCode(HttpStatus.OK)
@ApiOperation({
summary: '로그아웃 — 항상 200, 서버의 세션을 끊고 두 쿠키를 지운다',
})
@ApiStandardResponse(undefined, { description: '로그아웃 (data: null)' })
async logout(
@Req() req: Request,
@Res({ passthrough: true }) res: Response,
): Promise<null> {
await this.auth.logout(readCookie(req, REFRESH_TOKEN_COOKIE));
clearSessionCookies(res, this.settings);
return null;
}
}
+54
View File
@@ -0,0 +1,54 @@
import { Logger, Module } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { JwtModule } from '@nestjs/jwt';
import { PassportModule } from '@nestjs/passport';
import { TypeOrmModule } from '@nestjs/typeorm';
import { MailModule } from '@/modules/mail/mail.module';
import { MailDirectoryModule } from '@/modules/mail-directory/mail-directory.module';
import { AuthSessionCleanup } from './auth-session.cleanup';
import { AuthSessionEntity } from './auth-session.entity';
import { AuthSessionRepository } from './auth-session.repository';
import { AuthController } from './auth.controller';
import { AuthService } from './auth.service';
import { AUTH_SETTINGS, buildAuthSettings } from './auth.settings';
import { JwtStrategy } from './jwt.strategy';
import { LoginAttemptService } from './login-attempt.service';
/**
* 직원 인증 모듈 — 직원 계정 = 메일 계정 (자격 확인은 MailModule 의 포트로).
*
* 서명 키는 모듈 등록 값이 아니라 AUTH_SETTINGS 하나에서 읽는다 —
* 임시 키(비운영, JWT_SECRET 없음)가 서명과 검증(JwtStrategy)에서 어긋나지 않게 하기 위해서다.
* 보호 라우트는 `@UseGuards(JwtAuthGuard)` (common/guards) 로 건다.
*
* 로그인 세션은 DB(auth_sessions)에 둔다 — 스키마는 마이그레이션으로만 바꾼다 (src/migrations).
*/
@Module({
imports: [
PassportModule.register({ session: false }),
JwtModule.register({}),
TypeOrmModule.forFeature([AuthSessionEntity]),
MailModule,
// 로그인에 성공한 직원을 사내 목록에 기록한다 (받는 사람 추천)
MailDirectoryModule,
],
controllers: [AuthController],
providers: [
{
provide: AUTH_SETTINGS,
inject: [ConfigService],
useFactory: (config: ConfigService) =>
buildAuthSettings(
(key) => config.get<string>(key),
(message) => new Logger('AuthModule').warn(message),
),
},
AuthService,
AuthSessionRepository,
AuthSessionCleanup,
LoginAttemptService,
JwtStrategy,
],
})
export class AuthModule {}
File diff suppressed because it is too large Load Diff
+640
View File
@@ -0,0 +1,640 @@
import { Inject, Injectable, Logger } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { randomUUID } from 'crypto';
import type { AuthenticatedUser } from '@/common/decorators/current-user.decorator';
import { BusinessException } from '@/common/errors/business.exception';
import { MAIL_PROVIDER, type MailProvider } from '@/modules/mail/mail.provider';
import { MAIL_SETTINGS, type MailSettings } from '@/modules/mail/mail.settings';
import type {
MailAccountProfile,
PasswordChangeOutcome,
} from '@/modules/mail/mail.types';
import { MailDirectoryService } from '@/modules/mail-directory/mail-directory.service';
import { traceTag } from '@/shared/logger/request-context';
import { maskEmail } from '@/shared/utils/mask';
import type { SessionTokens } from './auth-cookies';
import type {
AuthSessionEntity,
SessionRevokedReason,
} from './auth-session.entity';
import {
classifyRefresh,
hashRefreshToken,
nextIdleExpiry,
type RefreshState,
} from './auth-session.policy';
import { AuthSessionRepository } from './auth-session.repository';
import type { AuthTokenPayload } from './auth-token.types';
import {
JWT_ALGORITHM,
REFRESH_REUSE_GRACE_MS,
SESSION_ABSOLUTE_TTL_SECONDS,
} from './auth.constants';
import { AUTH_SETTINGS, type AuthSettings } from './auth.settings';
import { isEmployeeAddress, normalizeEmail } from './employee-address';
import {
LoginAttemptService,
type LoginAttemptTicket,
} from './login-attempt.service';
export interface AuthResult {
readonly user: AuthenticatedUser;
/**
* 구울 쿠키. 동시 갱신 유예(같은 세션의 다른 요청이 방금 회전함)면 null —
* 이긴 요청의 응답이 이미 새 쿠키를 구웠으므로 덮어쓰지 않는다.
*/
readonly tokens: SessionTokens | null;
}
/** 리프레시 토큰에서 꺼낸 값 */
interface RefreshClaims {
readonly sub: string;
readonly sid: string;
}
/** 세션 id 형식 — DB 의 uuid 열에 넣기 전에 확인한다 (형식이 틀리면 DB 오류가 SYS_001 로 새어 나간다) */
const SESSION_ID_PATTERN =
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
function toUser(profile: MailAccountProfile): AuthenticatedUser {
return { email: profile.email, name: profile.name };
}
function errorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
/**
* 직원 로그인 — 직원 계정 = 메일 계정.
* 비밀번호는 로그인할 때만 메일 엔진에 확인하고 저장하지 않는다.
* 로그인하면 서버에 세션이 생기고(auth_sessions), 리프레시 토큰은 1회용으로 회전한다 (계약 1절 "세션 규칙").
*
* 로그에는 비밀번호·토큰을 절대 남기지 않고, 주소는 마스킹한다 (.claude/rules/logging.md).
*/
@Injectable()
export class AuthService {
private readonly logger = new Logger(AuthService.name);
constructor(
@Inject(MAIL_PROVIDER) private readonly mail: MailProvider,
@Inject(MAIL_SETTINGS) private readonly mailSettings: MailSettings,
@Inject(AUTH_SETTINGS) private readonly settings: AuthSettings,
private readonly jwt: JwtService,
private readonly attempts: LoginAttemptService,
private readonly sessions: AuthSessionRepository,
private readonly directory: MailDirectoryService,
) {}
/**
* 계정 없음·비밀번호 틀림·사내 도메인이 아닌 주소는 구분하지 않고 AUTH_004 (.claude/rules/security.md).
* 메일 엔진에 묻기 **전에** 실패 한 번을 IP·주소에 미리 차지한다 — 동시 요청으로 한도를 넘지 못하게 (LoginAttemptService).
* 성공·엔진 장애는 차지한 자리를 돌려준다 (성공은 세지 않는다).
*/
async login(
email: string,
password: string,
clientIp: string,
): Promise<AuthResult> {
const address = normalizeEmail(email);
if (!this.mailSettings.domain) {
throw new BusinessException('MAIL_001', {
context: { reason: 'mail-not-configured' },
});
}
// IP 를 알 수 없는 요청(소켓이 이미 닫힘 등)도 한 묶음으로 센다.
// 로그인할 수 없는 주소(사내 도메인 밖·게이트웨이·관리자)는 엔진에 묻지 않고 바로 거절하므로 주소 카운터에 넣지 않는다(IP 만 센다) —
// 아무 주소나 뿌려 주소 카운터를 채우지 못하게. 응답은 같은 AUTH_004 라 이 차이로 드러나는 것은 없다
const ticket = this.attempts.begin({
email: this.isLoginAddress(address) ? address : null,
ip: clientIp || 'unknown',
});
let profile: MailAccountProfile;
try {
profile = await this.authenticate(address, password);
ticket.succeed();
} catch (error) {
// AUTH_004 로 응답하는 경우만 실패로 센다 — 엔진 장애(MAIL_001)·엔진 한도(REQ_001)는 사용자 잘못이 아니다
if (error instanceof BusinessException && error.code === 'AUTH_004') {
ticket.fail();
} else {
ticket.abort();
}
throw error;
}
const tokens = await this.startSession(profile);
await this.recordInDirectory(profile);
this.logger.log(`로그인: ${maskEmail(profile.email)}`);
return { user: toUser(profile), tokens };
}
/**
* 로그인한 직원을 사내 목록에 기록한다 (받는 사람 추천 — 계약 2절). 관리자·게이트웨이·막은 계정은 넣지 않는다.
* 기록하지 못해도 로그인은 그대로 성공한다 — 추천에서 빠질 뿐이다.
*/
private async recordInDirectory(profile: MailAccountProfile): Promise<void> {
if (!this.isLoginAddress(profile.email)) return;
try {
await this.directory.recordLogin({
email: profile.email,
name: profile.name,
});
} catch (error) {
this.logger.warn(
`[SYS_001]${traceTag()} 사내 목록에 로그인한 직원을 기록하지 못했습니다: user=${maskEmail(profile.email)}, reason=${errorMessage(error)}`,
);
}
}
/**
* 리프레시 쿠키만으로 두 토큰을 새로 발급한다(1회용 회전) — 액세스 쿠키가 없거나 만료돼도 된다.
*
* - 이미 쓴 토큰이 오면 탈취로 보고 그 세션 전체를 끊는다 → AUTH_001
* - 회전 직후의 바로 이전 토큰은 유예한다(탭 여러 개의 동시 갱신) → 새 토큰 없이 성공
* - 계정이 아직 살아 있는지 메일 엔진에 다시 확인한다 (사라졌으면 세션을 끊고 AUTH_001)
* - 메일 엔진 장애는 MAIL_001 그대로 — 회전하기 전에 확인하므로 같은 토큰으로 다시 시도할 수 있다
*/
async refresh(refreshToken: string | undefined): Promise<AuthResult> {
if (!refreshToken) {
throw new BusinessException('AUTH_001', {
context: { reason: 'refresh-token-missing' },
});
}
const claims = await this.verifyRefreshToken(refreshToken, false);
const presentedHash = hashRefreshToken(refreshToken);
const now = new Date();
const session = await this.sessions.findById(claims.sid);
return this.resolveRefresh(
this.classify(session, claims, presentedHash, now),
session,
claims,
presentedHash,
now,
);
}
/**
* 비밀번호 바꾸기 (계약 1절 "PUT /api/auth/password") — 메일 엔진에 그 직원 **본인의 자격**(지금 비밀번호)으로 보낸다.
*
* - 새 비밀번호가 지금과 같으면 엔진에 묻지 않고 VAL_001
* - 막는 계정(관리자 등 로그인할 수 없는 주소)은 엔진에 묻지 않고 AUTH_001 — 웹메일로 다루지 않는 계정이다
* - 지금 비밀번호 틀림은 로그인 실패와 **같은 한도 · 같은 카운터**로 센다 — 엔진에 묻기 전에 실패 한 번을 IP·주소에 차지하고
* (LoginAttemptService), 틀리면 남기고(400 AUTH_005 — 401 이 아니다: 클라이언트는 401 이면 갱신하고 다시 보낸다),
* 바뀌면 로그인 성공처럼 돌려준다. 같은 주소의 로그인·변경이 처리 중이면 REQ_001
* - 세지 않는 실패: 지금 비밀번호로 들어갔는데 엔진이 바꾸기를 거절함(400 AUTH_005 — 맞혀 보기가 아니다) · 엔진 규칙 거절(VAL_001) · 엔진 장애
* - 바뀌면 그 직원의 **다른** 로그인 세션을 모두 끊는다 — 이 요청의 리프레시 쿠키(Path=/api/auth 라 함께 온다)가 가리키는 세션만 남긴다.
* 쿠키로 이 세션을 알 수 없으면 모두 끊는다 (이 탭도 액세스 토큰이 끝나면 다시 로그인한다).
* 결과를 알 수 없게 끝났어도(엔진이 바꿨을 수 있다) 다른 세션은 끊어 두고 503 MAIL_001 로 답한다.
* 시도는 끊기가 끝난 뒤에 끝낸다 — 그동안 같은 주소의 로그인은 처리 중(REQ_001)이라 새 비밀번호로 막 만든 세션이 함께 끊기지 않는다
* 비밀번호는 로그 · 오류 context 에 남기지 않는다. 바꾼 일은 info 로그에 가린 주소와 끊은 세션 수만.
*/
async changePassword(
user: AuthenticatedUser,
currentPassword: string,
newPassword: string,
clientIp: string,
refreshToken: string | undefined,
): Promise<void> {
const address = normalizeEmail(user.email);
if (newPassword === currentPassword) {
throw new BusinessException('VAL_001', {
context: { reason: 'new-password-same-as-current' },
});
}
if (!this.mailSettings.domain) {
throw new BusinessException('MAIL_001', {
context: { reason: 'mail-not-configured' },
});
}
if (!this.isLoginAddress(address)) {
throw new BusinessException('AUTH_001', {
context: {
reason: 'password-change-not-allowed',
email: maskEmail(address),
},
});
}
// 로그인과 같은 카운터 — 지금 비밀번호를 맞혀 보는 창구가 로그인 한도를 비켜 가지 못하게
const ticket = this.attempts.begin({
email: address,
ip: clientIp || 'unknown',
});
let outcome: PasswordChangeOutcome;
try {
outcome = await this.mail.changePassword(
address,
currentPassword,
newPassword,
);
} catch (error) {
// 결과가 확실한 엔진 장애(MAIL_001) · 엔진 한도(REQ_001) — 바뀌지 않았고 사용자 잘못도 아니다. 세지 않는다
ticket.abort();
throw error;
}
switch (outcome) {
case 'current-password-rejected':
ticket.fail();
throw new BusinessException('AUTH_005', {
context: {
reason: 'current-password-rejected',
email: maskEmail(address),
},
});
case 'current-password-refused':
// 같은 값으로 엔진에 들어가기는 했다 — 맞혀 보기가 아니라 세지 않는다 (변경 권한을 빼면 맞는 비밀번호로 로그인까지 막히지 않게).
// 엔진의 거절 종류는 어댑터가 warn 으로 남겼다
ticket.abort();
throw new BusinessException('AUTH_005', {
context: {
reason: 'current-password-refused-by-mail-engine',
email: maskEmail(address),
},
});
case 'new-password-rejected':
// 지금 비밀번호는 맞았다 — 실패로 세지 않는다
ticket.abort();
throw new BusinessException('VAL_001', {
context: {
reason: 'new-password-rejected-by-mail-engine',
email: maskEmail(address),
},
});
case 'outcome-unknown': {
// 엔진이 바꿨을 수 있다 — 탈취가 의심돼 바꾸는 중일 수 있으니 다른 세션은 끊어 두고 실패로 답한다 (이 세션은 남긴다)
const revoked = await this.revokeBeforeSettling(
ticket,
'abort',
address,
refreshToken,
'password-change-uncertain',
);
throw new BusinessException('MAIL_001', {
context: {
reason: 'password-change-outcome-unknown',
email: maskEmail(address),
revokedOtherSessions: revoked,
},
});
}
case 'changed': {
const revoked = await this.revokeBeforeSettling(
ticket,
'succeed',
address,
refreshToken,
'password-changed',
);
this.logger.log(
`비밀번호 변경: ${maskEmail(address)}, 끊은 다른 세션 ${revoked}개`,
);
}
}
}
/**
* 다른 세션을 끊은 **뒤에** 시도를 끝낸다 → 끊은 수. 끊는 동안은 같은 주소의 로그인이 진행 중(REQ_001)으로 막혀,
* 새 비밀번호로 막 로그인한 세션이 함께 끊기지 않는다. 끊다 실패해도 시도는 반드시 끝낸다 (그 주소가 계속 막히지 않게)
*/
private async revokeBeforeSettling(
ticket: LoginAttemptTicket,
settle: 'succeed' | 'abort',
address: string,
refreshToken: string | undefined,
reason: SessionRevokedReason,
): Promise<number> {
try {
return await this.revokeOtherSessions(address, refreshToken, reason);
} finally {
if (settle === 'succeed') ticket.succeed();
else ticket.abort();
}
}
/**
* 비밀번호를 바꾼(또는 바꿨을 수 있는) 뒤 그 직원의 다른 세션을 끊는다 → 끊은 수.
* 여기서 실패해도 던지지 않는다 — 비밀번호는 이미 바뀌었을 수 있어, 실패로 답하면 사용자가 옛 비밀번호로 다시 시도해
* AUTH_005 를 받는다. 대신 error 로그를 남긴다 (다른 세션이 남아 있다 — 운영자가 끊을 수 있게 가린 주소와 함께)
*/
private async revokeOtherSessions(
address: string,
refreshToken: string | undefined,
reason: SessionRevokedReason,
): Promise<number> {
const keepId = await this.sessionIdOf(refreshToken, address);
try {
return await this.sessions.revokeAllOf(
address,
reason,
new Date(),
keepId,
);
} catch (error) {
this.logger.error(
`[SYS_001]${traceTag()} 비밀번호 바꾸기 뒤 다른 로그인 세션을 끊지 못했습니다 — 남은 세션은 만료까지 쓸 수 있습니다: user=${maskEmail(address)}, reason=${errorMessage(error)}`,
);
return 0;
}
}
/** 리프레시 쿠키가 가리키는 그 직원의 세션 id — 없거나 · 위조거나 · 다른 직원의 토큰이면 null (만료는 따지지 않는다 — 고르기만 한다) */
private async sessionIdOf(
refreshToken: string | undefined,
address: string,
): Promise<string | null> {
if (!refreshToken) return null;
try {
const claims = await this.verifyRefreshToken(refreshToken, true);
return normalizeEmail(claims.sub) === address ? claims.sid : null;
} catch {
return null;
}
}
/**
* 로그아웃 — 서버의 세션을 끊는다. 항상 성공으로 끝난다(계약: 항상 200):
* 토큰이 없거나 이미 쓸 수 없어도, 세션 폐기에 실패해도(오류 로그만) 쿠키는 컨트롤러가 지운다.
*/
async logout(refreshToken: string | undefined): Promise<void> {
if (!refreshToken) return;
let claims: RefreshClaims;
try {
// 만료된 토큰으로도 그 세션은 끊을 수 있게 한다 (서명은 확인한다)
claims = await this.verifyRefreshToken(refreshToken, true);
} catch {
return;
}
try {
await this.sessions.revoke(claims.sid, 'logout', new Date());
this.logger.log(`로그아웃: ${maskEmail(claims.sub)}`);
} catch (error) {
this.logger.error(
`[SYS_001]${traceTag()} 로그아웃 세션 폐기에 실패했습니다 (쿠키는 지운다): ${errorMessage(error)}`,
);
}
}
/**
* 로그인할 수 있는 주소인가 — 사내 도메인 주소이고 게이트웨이·관리자(막는 계정)가 아니어야 한다.
* 관리자 주소는 엔진에 묻기 전에 막는다: 공개된 로그인 창이 관리자 비밀번호를 시험해 보는 창구가 되지 않게.
*/
private isLoginAddress(address: string): boolean {
const { domain, gatewayUser, blockedAccounts } = this.mailSettings;
return (
domain !== null &&
isEmployeeAddress(address, domain, gatewayUser, blockedAccounts)
);
}
private async authenticate(
address: string,
password: string,
): Promise<MailAccountProfile> {
if (!this.isLoginAddress(address)) {
throw this.loginFailed(address, 'not-employee-address');
}
const account = await this.mail.verifyCredentials(address, password);
// 엔진이 돌려준 정식 주소로 한 번 더 — 별칭으로 들어와도 관리자·게이트웨이 계정이면 막는다
if (!account || !this.isLoginAddress(account)) {
throw this.loginFailed(address, 'rejected');
}
return this.loadProfileForLogin(account, address);
}
private classify(
session: AuthSessionEntity | null,
claims: RefreshClaims,
presentedHash: string,
now: Date,
): RefreshState {
return classifyRefresh(
session,
claims,
presentedHash,
now,
REFRESH_REUSE_GRACE_MS,
);
}
private async resolveRefresh(
state: RefreshState,
session: AuthSessionEntity | null,
claims: RefreshClaims,
presentedHash: string,
now: Date,
): Promise<AuthResult> {
if (state === 'current' && session) {
return this.rotate(session, claims, presentedHash, now);
}
if (state === 'grace') {
const profile = await this.mail.getProfile(claims.sub);
return { user: toUser(profile), tokens: null };
}
if (state === 'reused') {
await this.sessions.revoke(claims.sid, 'refresh-token-reused', now);
this.logger.warn(
`[AUTH_001]${traceTag()} 이미 쓴 리프레시 토큰이 다시 와서 세션을 끊었습니다 (탈취 의심): user=${maskEmail(claims.sub)}, sid=${claims.sid}`,
);
}
throw new BusinessException('AUTH_001', {
context: { reason: `session-${state}`, sid: claims.sid },
});
}
private async rotate(
session: AuthSessionEntity,
claims: RefreshClaims,
presentedHash: string,
now: Date,
): Promise<AuthResult> {
const profile = await this.loadProfileForRefresh(claims, now);
const expiresAt = nextIdleExpiry(
now,
this.settings.refreshTtlSeconds,
session.absoluteExpiresAt,
);
const tokens = await this.signTokens(profile, claims.sid, expiresAt, now);
const rotated = await this.sessions.rotate({
id: claims.sid,
presentedHash,
nextHash: hashRefreshToken(tokens.refreshToken),
nextExpiresAt: expiresAt,
now,
});
if (rotated) return { user: toUser(profile), tokens };
// 그 사이 다른 요청이 먼저 회전했거나 세션을 끊었다 — 지금 상태로 다시 판단한다
const latest = await this.sessions.findById(claims.sid);
const state = this.classify(latest, claims, presentedHash, now);
if (state === 'grace') return { user: toUser(profile), tokens: null };
if (state === 'current') {
// 조건부 UPDATE 와 같은 조건인데 바뀌지 않았다 — 서버 쪽 불일치(시계 어긋남 등)라 사용자 잘못이 아니다.
// 세션을 끊지 않는다: AUTH_001 이 아니라 SYS_001(500)로 답해 컨트롤러가 쿠키를 지우지 않게 하고,
// 클라이언트는 같은 리프레시 쿠키로 다시 시도할 수 있다. 5xx 라 예외 필터가 error 로 남긴다
throw new BusinessException('SYS_001', {
context: { reason: 'rotation-conflict', sid: claims.sid },
});
}
return this.resolveRefresh(state, latest, claims, presentedHash, now);
}
/** 계정이 사라졌으면 세션을 끊는다. 메일 엔진 장애는 그대로 던진다 (세션은 남는다) */
private async loadProfileForRefresh(
claims: RefreshClaims,
now: Date,
): Promise<MailAccountProfile> {
try {
return await this.mail.getProfile(claims.sub, { fresh: true });
} catch (error) {
if (error instanceof BusinessException && error.code === 'AUTH_001') {
await this.sessions.revoke(claims.sid, 'account-unavailable', now);
}
throw error;
}
}
/** 비밀번호는 맞지만 메일함에 접근할 수 없는 계정도 같은 실패로 응답한다 (원인을 드러내지 않는다) */
private async loadProfileForLogin(
account: string,
address: string,
): Promise<MailAccountProfile> {
try {
return await this.mail.getProfile(account, { fresh: true });
} catch (error) {
if (error instanceof BusinessException && error.code === 'AUTH_001') {
throw this.loginFailed(address, 'mailbox-unavailable');
}
throw error;
}
}
/** 새 세션 — 절대 만료 30일, 미사용 만료는 리프레시 기간 */
private async startSession(
profile: MailAccountProfile,
): Promise<SessionTokens> {
const now = new Date();
const id = randomUUID();
const absoluteExpiresAt = new Date(
now.getTime() + SESSION_ABSOLUTE_TTL_SECONDS * 1000,
);
const expiresAt = nextIdleExpiry(
now,
this.settings.refreshTtlSeconds,
absoluteExpiresAt,
);
const tokens = await this.signTokens(profile, id, expiresAt, now);
await this.sessions.create({
id,
userEmail: profile.email,
tokenHash: hashRefreshToken(tokens.refreshToken),
previousTokenHash: null,
rotatedAt: null,
createdAt: now,
expiresAt,
absoluteExpiresAt,
revokedAt: null,
revokedReason: null,
});
return tokens;
}
private async verifyRefreshToken(
token: string,
ignoreExpiration: boolean,
): Promise<RefreshClaims> {
let payload: AuthTokenPayload;
try {
payload = await this.jwt.verifyAsync<AuthTokenPayload>(token, {
secret: this.settings.jwtSecret,
algorithms: [JWT_ALGORITHM],
ignoreExpiration,
});
} catch (error) {
throw new BusinessException('AUTH_001', {
context: {
reason: 'invalid-refresh-token',
cause: errorMessage(error),
},
});
}
const { typ, sub, sid } = payload;
if (
typ !== 'refresh' ||
typeof sub !== 'string' ||
typeof sid !== 'string' ||
!SESSION_ID_PATTERN.test(sid)
) {
// 액세스 토큰을 리프레시 쿠키에 넣은 경우·세션 저장 전에 발급된 토큰 등
throw new BusinessException('AUTH_001', {
context: { reason: 'not-a-refresh-token' },
});
}
return { sub, sid };
}
/**
* 두 토큰을 서명한다. 리프레시 토큰은 세션의 미사용 만료에, 액세스 토큰은 그보다 늦지 않게 끝난다
* (세션보다 오래 사는 토큰을 만들지 않는다).
*/
private async signTokens(
profile: MailAccountProfile,
sessionId: string,
refreshExpiresAt: Date,
now: Date,
): Promise<SessionTokens> {
const { jwtSecret, accessTtlSeconds } = this.settings;
const refreshTtlSeconds = Math.max(
1,
Math.floor((refreshExpiresAt.getTime() - now.getTime()) / 1000),
);
const access: AuthTokenPayload = {
sub: profile.email,
typ: 'access',
name: profile.name,
};
const refresh: AuthTokenPayload = {
sub: profile.email,
typ: 'refresh',
sid: sessionId,
};
const [accessToken, refreshToken] = await Promise.all([
this.jwt.signAsync(access, {
secret: jwtSecret,
algorithm: JWT_ALGORITHM,
expiresIn: Math.min(accessTtlSeconds, refreshTtlSeconds),
jwtid: randomUUID(),
}),
this.jwt.signAsync(refresh, {
secret: jwtSecret,
algorithm: JWT_ALGORITHM,
expiresIn: refreshTtlSeconds,
jwtid: randomUUID(),
}),
]);
return { accessToken, refreshToken, refreshTtlSeconds };
}
private loginFailed(email: string, reason: string): BusinessException {
return new BusinessException('AUTH_004', {
context: { reason, email: maskEmail(email) },
});
}
}
@@ -0,0 +1,73 @@
import { buildAuthSettings } from './auth.settings';
const reader =
(values: Record<string, string>) =>
(key: string): string | undefined =>
values[key];
describe('buildAuthSettings', () => {
it('env 의 서명 키와 만료 기간을 초 단위로 쓴다', () => {
const warn = jest.fn();
const settings = buildAuthSettings(
reader({
NODE_ENV: 'development',
JWT_SECRET: 'configured-secret-value',
JWT_ACCESS_EXPIRES_IN: '15m',
JWT_REFRESH_EXPIRES_IN: '7d',
}),
warn,
);
expect(settings).toEqual({
jwtSecret: 'configured-secret-value',
accessTtlSeconds: 15 * 60,
refreshTtlSeconds: 7 * 24 * 60 * 60,
secureCookies: false,
});
expect(warn).not.toHaveBeenCalled();
});
it('만료 기간이 없으면 계약 기본값(1h · 14d)', () => {
const settings = buildAuthSettings(
reader({ JWT_SECRET: 'configured-secret-value' }),
jest.fn(),
);
expect(settings.accessTtlSeconds).toBe(60 * 60);
expect(settings.refreshTtlSeconds).toBe(14 * 24 * 60 * 60);
});
it('비운영에서 서명 키가 없으면 임시 키로 기동하고 경고한다 (CI e2e)', () => {
const warn = jest.fn<void, [string]>();
const first = buildAuthSettings(reader({ NODE_ENV: 'test' }), warn);
const second = buildAuthSettings(
reader({ NODE_ENV: 'test', JWT_SECRET: ' ' }),
warn,
);
expect(first.jwtSecret.length).toBeGreaterThanOrEqual(64);
expect(second.jwtSecret).not.toBe(first.jwtSecret);
expect(warn).toHaveBeenCalledTimes(2);
expect(warn.mock.calls[0][0]).toContain('임시 서명 키');
});
it('운영에서 서명 키가 없으면 기동하지 않는다', () => {
expect(() =>
buildAuthSettings(reader({ NODE_ENV: 'production' }), jest.fn()),
).toThrow(/JWT_SECRET/);
});
it('운영에서만 Secure 쿠키를 쓴다', () => {
const settings = buildAuthSettings(
reader({
NODE_ENV: 'production',
JWT_SECRET: 'q7Vd2mXk9LpR4tWz8nBc6yHs3gJf5aEu',
}),
jest.fn(),
);
expect(settings.secureCookies).toBe(true);
});
});
+58
View File
@@ -0,0 +1,58 @@
import { randomBytes } from 'crypto';
import { parseDurationSeconds } from '@/shared/utils/duration';
import {
DEFAULT_ACCESS_TTL_SECONDS,
DEFAULT_REFRESH_TTL_SECONDS,
} from './auth.constants';
/**
* 인증 설정 — 백엔드 고유 시크릿(`backend/.env.{APP_ENV}`)에서 읽는다.
* 쿠키 Max-Age 와 토큰 만료가 같은 값에서 나오도록 초 단위로 한 번만 해석한다.
*/
export const AUTH_SETTINGS = Symbol('AUTH_SETTINGS');
export interface AuthSettings {
readonly jwtSecret: string;
readonly accessTtlSeconds: number;
readonly refreshTtlSeconds: number;
/** 운영에서만 Secure 쿠키 (개발은 http://localhost) */
readonly secureCookies: boolean;
}
/** JWT_SECRET 이 없을 때 만드는 임시 키 길이 (바이트) */
const TEMPORARY_SECRET_BYTES = 48;
/**
* env 값을 읽어 인증 설정을 만든다.
* JWT_SECRET 이 없으면 **비운영에서만** 무작위 임시 키로 기동하고 경고를 남긴다
* (재시작하면 모든 세션이 끊긴다). 운영은 env 검증이 기동을 막지만 여기서도 한 번 더 막는다.
*/
export function buildAuthSettings(
read: (key: string) => string | undefined,
warn: (message: string) => void,
): AuthSettings {
const isProduction = read('NODE_ENV')?.trim() === 'production';
let jwtSecret = read('JWT_SECRET')?.trim();
if (!jwtSecret) {
if (isProduction) {
throw new Error('JWT_SECRET 가 설정되지 않았습니다. (운영 환경 필수)');
}
jwtSecret = randomBytes(TEMPORARY_SECRET_BYTES).toString('base64url');
warn(
'JWT_SECRET 이 없어 임시 서명 키로 기동합니다. 재시작하면 모든 로그인 세션이 끊깁니다. (개발·테스트 전용 — 운영은 기동하지 않는다)',
);
}
return {
jwtSecret,
accessTtlSeconds:
parseDurationSeconds(read('JWT_ACCESS_EXPIRES_IN')) ??
DEFAULT_ACCESS_TTL_SECONDS,
refreshTtlSeconds:
parseDurationSeconds(read('JWT_REFRESH_EXPIRES_IN')) ??
DEFAULT_REFRESH_TTL_SECONDS,
secureCookies: isProduction,
};
}
@@ -0,0 +1,15 @@
import { ApiProperty } from '@nestjs/swagger';
import type { AuthenticatedUser } from '@/common/decorators/current-user.decorator';
/** 로그인한 직원 — 토큰은 본문에 없고 HttpOnly 쿠키로만 간다 */
export class AuthUserDto implements AuthenticatedUser {
@ApiProperty({
description: '직원 메일 주소',
example: 'ara.kim@arasystem.it.kr',
})
email!: string;
@ApiProperty({ description: '표시 이름', example: '김아라' })
name!: string;
}
@@ -0,0 +1,86 @@
import { plainToInstance } from 'class-transformer';
import { validate } from 'class-validator';
import {
MAX_PASSWORD_LENGTH,
NEW_PASSWORD_MAX_LENGTH,
NEW_PASSWORD_MIN_LENGTH,
} from '@/modules/auth/auth.constants';
import { ChangePasswordDto } from './change-password.dto';
/** 전역 ValidationPipe 와 같은 옵션으로 검증한다 (app.setup.ts — stopAtFirstError 만 빼고 — 걸린 규칙을 모두 본다, 통과 · 실패는 같다) — 걸린 규칙 이름만 */
async function constraintsOf(body: object): Promise<string[]> {
const errors = await validate(plainToInstance(ChangePasswordDto, body), {
whitelist: true,
forbidNonWhitelisted: true,
});
return errors.flatMap((error) => Object.keys(error.constraints ?? {}));
}
describe('비밀번호 바꾸기 요청 본문 (PUT /api/auth/password)', () => {
const valid = { currentPassword: 'old-password', newPassword: 'new-pass' };
it(`새 비밀번호는 ${NEW_PASSWORD_MIN_LENGTH}~${NEW_PASSWORD_MAX_LENGTH}자 — 밖이면 형식 오류`, async () => {
expect(await constraintsOf(valid)).toEqual([]);
expect(
await constraintsOf({
...valid,
newPassword: 'x'.repeat(NEW_PASSWORD_MAX_LENGTH),
}),
).toEqual([]);
expect(
await constraintsOf({
...valid,
newPassword: 'x'.repeat(NEW_PASSWORD_MIN_LENGTH - 1),
}),
).toEqual(['minCodeUnits']);
expect(
await constraintsOf({
...valid,
newPassword: 'x'.repeat(NEW_PASSWORD_MAX_LENGTH + 1),
}),
).toEqual(['maxCodeUnits']);
});
it('길이는 JS 문자열 길이(UTF-16 코드 단위)로 센다 — 웹 클라이언트와 같은 수 (이모지는 2자)', async () => {
// 이모지 4개 = 8 코드 단위 — validator.js 의 MinLength 는 4자로 세어 막았을 것이다
expect(await constraintsOf({ ...valid, newPassword: '🔒🔒🔒🔒' })).toEqual(
[],
);
// 이모지 64개 + 1자 = 129 코드 단위 — 넘는다
expect(
await constraintsOf({ ...valid, newPassword: `${'🔒'.repeat(64)}x` }),
).toEqual(['maxCodeUnits']);
});
it(`지금 비밀번호는 로그인과 같은 한도(${MAX_PASSWORD_LENGTH}자) — 비었거나 길면 형식 오류, 문자열이 아니면 형식 오류`, async () => {
expect(
await constraintsOf({
...valid,
currentPassword: 'x'.repeat(MAX_PASSWORD_LENGTH),
}),
).toEqual([]);
expect(await constraintsOf({ ...valid, currentPassword: '' })).toEqual([
'isNotEmpty',
]);
expect(
await constraintsOf({
...valid,
currentPassword: 'x'.repeat(MAX_PASSWORD_LENGTH + 1),
}),
).toEqual(['maxLength']);
expect(
await constraintsOf({ ...valid, currentPassword: 12345678 }),
).toEqual(expect.arrayContaining(['isString']));
expect(await constraintsOf({ newPassword: 'new-pass' })).toEqual(
expect.arrayContaining(['isString']),
);
});
it('모르는 필드는 거부한다 (확인 칸은 화면만 본다)', async () => {
expect(
await constraintsOf({ ...valid, newPasswordConfirm: 'new-pass' }),
).toEqual(['whitelistValidation']);
});
});
@@ -0,0 +1,47 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsNotEmpty, IsString, MaxLength } from 'class-validator';
import {
MaxCodeUnits,
MinCodeUnits,
} from '@/common/decorators/code-units.decorator';
import {
MAX_PASSWORD_LENGTH,
NEW_PASSWORD_MAX_LENGTH,
NEW_PASSWORD_MIN_LENGTH,
} from '@/modules/auth/auth.constants';
/**
* PUT /api/auth/password — 계약 1절.
* 지금 비밀번호는 로그인(dto/login.dto.ts)과 같은 검사 — 로그인할 수 있었던 비밀번호는 여기서도 통과하고, 그보다 긴 값은 들어올 수 없다.
* 새 비밀번호의 길이는 JS 문자열 길이로 센다(웹 클라이언트와 같다). 지금 비밀번호와 같으면 서비스가 400 VAL_001 (엔진에 묻지 않는다).
* 비밀번호는 저장하지 않고 로그에 남기지 않는다.
*/
export class ChangePasswordDto {
@ApiProperty({
description: `지금 비밀번호 — 메일 엔진이 확인한다 (로그인과 같은 한도, ${MAX_PASSWORD_LENGTH}자 이하)`,
format: 'password',
maxLength: MAX_PASSWORD_LENGTH,
example: 'current-password',
})
@IsString({ message: '지금 비밀번호를 입력해 주세요.' })
@IsNotEmpty({ message: '지금 비밀번호를 입력해 주세요.' })
@MaxLength(MAX_PASSWORD_LENGTH, { message: '지금 비밀번호가 너무 깁니다.' })
currentPassword!: string;
@ApiProperty({
description: `새 비밀번호 — ${NEW_PASSWORD_MIN_LENGTH}~${NEW_PASSWORD_MAX_LENGTH}자, 지금 비밀번호와 달라야 한다 (메일 엔진의 규칙에 어긋나면 400 VAL_001)`,
format: 'password',
minLength: NEW_PASSWORD_MIN_LENGTH,
maxLength: NEW_PASSWORD_MAX_LENGTH,
example: 'new-password-1234',
})
@IsString({ message: '새 비밀번호를 입력해 주세요.' })
@MinCodeUnits(NEW_PASSWORD_MIN_LENGTH, {
message: `새 비밀번호는 ${NEW_PASSWORD_MIN_LENGTH}자 이상이어야 합니다.`,
})
@MaxCodeUnits(NEW_PASSWORD_MAX_LENGTH, {
message: `새 비밀번호는 ${NEW_PASSWORD_MAX_LENGTH}자 이하여야 합니다.`,
})
newPassword!: string;
}
+26
View File
@@ -0,0 +1,26 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsEmail, IsNotEmpty, IsString, MaxLength } from 'class-validator';
import { MAX_PASSWORD_LENGTH } from '@/modules/auth/auth.constants';
import { MAX_EMAIL_LENGTH } from '@/modules/mail/mail.constants';
/** POST /api/auth/login — 직원 메일 계정 */
export class LoginDto {
@ApiProperty({
description: '직원 메일 주소 (사내 도메인)',
example: 'ara.kim@arasystem.it.kr',
})
@IsEmail({}, { message: '올바른 이메일 형식을 입력해 주세요.' })
@MaxLength(MAX_EMAIL_LENGTH, { message: '이메일이 너무 깁니다.' })
email!: string;
@ApiProperty({
description: '메일 계정 비밀번호 — 메일 엔진에 확인만 하고 저장하지 않는다',
format: 'password',
example: 'your-password',
})
@IsString({ message: '비밀번호를 입력해 주세요.' })
@IsNotEmpty({ message: '비밀번호를 입력해 주세요.' })
@MaxLength(MAX_PASSWORD_LENGTH, { message: '비밀번호가 너무 깁니다.' })
password!: string;
}
@@ -0,0 +1,85 @@
import {
isEmployeeAddress,
isLocalPart,
normalizeEmail,
} from './employee-address';
const DOMAIN = 'arasystem.test';
const GATEWAY = 'mail-gateway@arasystem.test';
describe('로그인 주소 정책', () => {
it('사내 도메인의 직원 주소를 받는다', () => {
for (const email of [
'ara.kim@arasystem.test',
'doyun.lee@arasystem.test',
'a@arasystem.test',
'team_lead-01@arasystem.test',
]) {
expect(isEmployeeAddress(email, DOMAIN, GATEWAY)).toBe(true);
}
});
it('사내 도메인이 아니면 거절한다 (하위 도메인·비슷한 도메인 포함)', () => {
for (const email of [
'ara.kim@gmail.com',
'ara.kim@mail.arasystem.test',
'ara.kim@arasystem.test.evil.example',
'ara.kim@xarasystem.test',
]) {
expect(isEmployeeAddress(email, DOMAIN, GATEWAY)).toBe(false);
}
});
it('게이트웨이 계정은 직접 로그인할 수 없다', () => {
expect(isEmployeeAddress(GATEWAY, DOMAIN, GATEWAY)).toBe(false);
});
it('막는 계정(메일 관리자 등)은 사내 도메인 주소여도 로그인할 수 없다 — 다른 직원은 그대로', () => {
const blocked = new Set(['admin@arasystem.test', 'ops@arasystem.test']);
expect(
isEmployeeAddress('admin@arasystem.test', DOMAIN, GATEWAY, blocked),
).toBe(false);
expect(
isEmployeeAddress('ops@arasystem.test', DOMAIN, GATEWAY, blocked),
).toBe(false);
expect(
isEmployeeAddress('ara.kim@arasystem.test', DOMAIN, GATEWAY, blocked),
).toBe(true);
});
it('대리 접근 구분자(%)·콜론 등이 섞인 주소는 거절한다', () => {
for (const email of [
'ara.kim%mail-gateway@arasystem.test',
'ara:kim@arasystem.test',
'"ara kim"@arasystem.test',
'.ara@arasystem.test',
'ara.@arasystem.test',
'@arasystem.test',
]) {
expect(isEmployeeAddress(email, DOMAIN, GATEWAY)).toBe(false);
}
});
it('로컬 부분(아이디) 형식 — 직원 주소와 같은 규칙, 대소문자는 가리지 않는다 (관리자 아이디 검증에도 쓴다)', () => {
for (const value of ['admin', 'Mail-Admin', 'a', 'ara_ops.1']) {
expect(isLocalPart(value)).toBe(true);
}
for (const value of [
'admin@arasystem.test',
'admin%gw',
'ad min',
'.admin',
'',
'a'.repeat(65),
]) {
expect(isLocalPart(value)).toBe(false);
}
});
it('비교 전에 공백을 지우고 소문자로 맞춘다', () => {
expect(normalizeEmail(' Ara.Kim@AraSystem.TEST ')).toBe(
'ara.kim@arasystem.test',
);
});
});
@@ -0,0 +1,52 @@
/**
* 로그인 주소 정책 (순수 함수).
* 직원 계정 = 사내 도메인의 메일 계정. 그 밖의 주소는 메일 엔진에 묻지도 않고 거절한다.
*/
/**
* 사내 주소의 로컬 부분 — 영문 소문자·숫자·`.`·`_`·`-` (앞뒤는 영문·숫자, 64자 이하).
* 특히 `%`·`:` 를 막는다. Stalwart 는 `<대상>%<계정>` 을 대리 접근으로 해석하므로,
* 로그인 주소에 `%` 가 섞이면 백엔드(신뢰 대역)를 통해 대리 접근 로그인이 시도될 수 있다.
* ⚠️ mail/scripts/setup.mjs 의 LOCAL_PART_PATTERN 과 같은 규칙이다 — MAIL_ADMIN_LOCAL_PART 를 두 쪽이 함께 읽으므로,
* 한쪽을 바꾸면 다른 쪽도 함께 바꾼다.
*/
const LOCAL_PART_PATTERN = /^[a-z0-9](?:[a-z0-9._-]{0,62}[a-z0-9])?$/;
/**
* 사내 주소의 로컬 부분(아이디) 형식인가 — 대소문자는 가리지 않는다 (주소는 소문자로 맞춰 비교한다).
* 메일 엔진 관리자 아이디(MAIL_ADMIN_LOCAL_PART) 검증에도 쓴다 — env.validation.ts
*/
export function isLocalPart(value: string): boolean {
return LOCAL_PART_PATTERN.test(value.toLowerCase());
}
/** 비교용 정규화 — 앞뒤 공백 제거 + 소문자 */
export function normalizeEmail(email: string): string {
return email.trim().toLowerCase();
}
/**
* 로그인할 수 있는 직원 주소인지 확인한다.
* - 사내 도메인(MAIL_DOMAIN) 주소만
* - 게이트웨이 서비스 계정은 직접 로그인할 수 없다
* - 막는 계정(메일 관리자 `<MAIL_ADMIN_LOCAL_PART>@<도메인>`·MAIL_BLOCKED_ACCOUNTS)도 직원 주소가 아니다 (mail.settings.ts 의 blockedAccounts)
*/
export function isEmployeeAddress(
email: string,
domain: string,
gatewayUser: string | null,
blockedAccounts: ReadonlySet<string> = new Set(),
): boolean {
const at = email.lastIndexOf('@');
if (at <= 0) return false;
const localPart = email.slice(0, at);
const host = email.slice(at + 1);
return (
host === domain &&
LOCAL_PART_PATTERN.test(localPart) &&
email !== gatewayUser &&
!blockedAccounts.has(email)
);
}
@@ -0,0 +1,150 @@
import {
FixedWindowCounter,
type ReserveResult,
type WindowSlot,
} from './fixed-window-counter';
const T0 = 1_000_000;
const WINDOW = 60_000;
/** 자리를 차지했다고 확인하고 그 자리를 꺼낸다 */
function slotOf(result: ReserveResult): WindowSlot {
if (!result.ok) throw new Error(`자리를 얻지 못했다: ${result.reason}`);
return result.slot;
}
describe('FixedWindowCounter', () => {
it('한도까지 자리를 내주고, 한도에 닿으면 limit 으로 거절', () => {
const counter = new FixedWindowCounter(3, WINDOW, 100);
expect(counter.reserve('a', T0).ok).toBe(true);
expect(counter.reserve('a', T0).ok).toBe(true);
expect(counter.reserve('a', T0).ok).toBe(true);
expect(counter.reserve('a', T0)).toEqual({ ok: false, reason: 'limit' });
expect(counter.isExhausted('a', T0)).toBe(true);
// 다른 키는 따로 센다
expect(counter.reserve('b', T0).ok).toBe(true);
});
it('구간은 첫 기록부터 고정이다 — 기록할 때마다 늘어나지 않는다', () => {
const counter = new FixedWindowCounter(2, WINDOW, 100);
counter.reserve('a', T0);
counter.reserve('a', T0 + WINDOW - 1);
expect(counter.reserve('a', T0 + WINDOW - 1).ok).toBe(false);
// 첫 기록 + WINDOW 가 지나면 0 부터 다시 센다
expect(counter.count('a', T0 + WINDOW)).toBe(0);
expect(counter.reserve('a', T0 + WINDOW).ok).toBe(true);
});
it('돌려준 자리는 다시 쓸 수 있다', () => {
const counter = new FixedWindowCounter(1, WINDOW, 100);
const slot = slotOf(counter.reserve('a', T0));
expect(counter.reserve('a', T0).ok).toBe(false);
counter.release(slot, T0 + 1);
expect(counter.count('a', T0 + 1)).toBe(0);
expect(counter.reserve('a', T0 + 1).ok).toBe(true);
});
it('구간이 바뀐 뒤에 돌려주면 새 구간의 수를 건드리지 않는다', () => {
const counter = new FixedWindowCounter(5, WINDOW, 100);
const oldSlot = slotOf(counter.reserve('a', T0));
counter.reserve('a', T0 + WINDOW); // 새 구간의 첫 기록
counter.release(oldSlot, T0 + WINDOW + 1);
expect(counter.count('a', T0 + WINDOW + 1)).toBe(1);
});
it('clear 는 그 키의 기록을 지운다', () => {
const counter = new FixedWindowCounter(1, WINDOW, 100);
counter.reserve('a', T0);
counter.clear('a');
expect(counter.reserve('a', T0).ok).toBe(true);
});
it('sweep 은 끝난 구간만 지운다', () => {
const counter = new FixedWindowCounter(5, WINDOW, 100);
counter.reserve('old', T0);
counter.reserve('new', T0 + 10_000);
expect(counter.sweep(T0 + WINDOW)).toEqual({
removed: 1,
evicted: 0,
rejected: 0,
});
expect(counter.size).toBe(1);
expect(counter.count('new', T0 + WINDOW)).toBe(1);
});
describe('키 개수 상한', () => {
it('상한에 닿으면 가장 먼저 끝나는 잠기지 않은 구간을 밀어내고, 밀어낸 수를 알려 준다', () => {
const counter = new FixedWindowCounter(5, WINDOW, 3);
counter.reserve('k1', T0);
counter.reserve('k2', T0 + 1);
counter.reserve('k3', T0 + 2);
expect(counter.reserve('k4', T0 + 3).ok).toBe(true);
expect(counter.size).toBe(3);
expect(counter.count('k1', T0 + 3)).toBe(0);
expect(counter.count('k4', T0 + 3)).toBe(1);
expect(counter.sweep(T0 + 3).evicted).toBe(1);
expect(counter.sweep(T0 + 3).evicted).toBe(0);
});
it('잠금 중인 구간(한도에 닿은 것)은 밀어내지 않는다 — 그 다음의 잠기지 않은 구간을 밀어낸다', () => {
const counter = new FixedWindowCounter(2, WINDOW, 3);
// locked 는 한도(2)에 닿아 잠겨 있다
counter.reserve('locked', T0);
counter.reserve('locked', T0);
counter.reserve('open', T0 + 1);
counter.reserve('other', T0 + 2);
expect(counter.reserve('new', T0 + 3).ok).toBe(true);
expect(counter.isExhausted('locked', T0 + 3)).toBe(true);
expect(counter.count('open', T0 + 3)).toBe(0);
});
it('밀어낼 것이 없으면(모두 잠금 중) 새 키를 capacity 로 거절한다 — fail-closed, 이미 아는 키는 그대로 센다', () => {
const counter = new FixedWindowCounter(1, WINDOW, 2);
counter.reserve('a', T0);
counter.reserve('b', T0);
expect(counter.reserve('new', T0 + 1)).toEqual({
ok: false,
reason: 'capacity',
});
expect(counter.isExhausted('a', T0 + 1)).toBe(true);
expect(counter.reserve('a', T0 + 1)).toEqual({
ok: false,
reason: 'limit',
});
expect(counter.sweep(T0 + 1)).toMatchObject({ rejected: 1 });
});
it('잠긴 구간도 끝나면 다시 자리가 난다', () => {
const counter = new FixedWindowCounter(1, WINDOW, 1);
counter.reserve('a', T0);
expect(counter.reserve('new', T0 + 1).ok).toBe(false);
expect(counter.reserve('new', T0 + WINDOW).ok).toBe(true);
});
it('끝난 구간은 밀어내도 경고 대상(evicted)으로 세지 않는다', () => {
const counter = new FixedWindowCounter(5, WINDOW, 1);
counter.reserve('k1', T0);
counter.reserve('k2', T0 + WINDOW);
expect(counter.sweep(T0 + WINDOW).evicted).toBe(0);
});
});
});
@@ -0,0 +1,164 @@
/** 차지한 자리 — 돌려줄 때 같은 구간인지 확인하는 데 쓴다 */
export interface WindowSlot {
readonly key: string;
/** 자리를 차지한 구간의 끝 (ms) */
readonly windowEndsAt: number;
}
/**
* 자리 차지 결과.
* - `limit`: 그 키가 이번 구간의 한도에 닿았다
* - `capacity`: 기억하는 키가 상한에 닿았고 밀어낼 수 있는 기록(끝났거나 잠기지 않은 구간)이 없다 — 새 키를 받지 않는다(fail-closed)
*/
export type ReserveResult =
| { readonly ok: true; readonly slot: WindowSlot }
| { readonly ok: false; readonly reason: 'limit' | 'capacity' };
interface WindowState {
count: number;
endsAt: number;
}
/**
* 상한에서 밀어낼 기록을 찾을 때 앞(가장 먼저 끝나는 쪽)에서부터 볼 최대 개수.
* 잠긴 기록이 앞을 채우고 있어도 요청마다 전체를 훑지 않게 한다 (그 안에 없으면 새 키를 거절한다).
*/
const MAX_EVICTION_SCAN = 256;
/**
* 고정 구간 카운터 (프로세스 메모리) — 로그인 실패 한도의 저장소.
*
* **확인과 증가가 한 동기 구간에서 끝난다**(await 없음). 그래서 동시에 들어온 요청들이
* "아직 한 자리 남았다"를 함께 보고 모두 통과하는 일이 없다 — Node 는 한 스레드라 이 구간에 끼어들 수 없다.
*
* 기억하는 키 개수에 상한이 있다(많은 IP·주소로 메모리를 채우는 공격 대비). 상한에 닿으면
* - 끝난 구간과 **잠기지 않은**(한도에 닿지 않은) 구간만 밀어낸다 — 잠금 중인 구간을 밀어내면 그 공격이 풀린다
* - 밀어낼 것이 없으면 새 키를 받지 않는다(fail-closed) — 이미 기억하는 키는 그대로 센다
*
* ⚠️ 인스턴스마다 따로 센다. 백엔드를 여러 대로 늘리면 한도가 인스턴스 수만큼 늘어나므로,
* 그때는 Redis 의 원자 연산(INCR + PEXPIRE 를 한 Lua 스크립트로)으로 옮겨야 한다 (.claude/skills/nestjs/SKILL.md).
*
* 구간은 첫 기록부터 `windowMs` 동안이다 (기록할 때마다 늘어나지 않는다).
*/
export class FixedWindowCounter {
private readonly windows = new Map<string, WindowState>();
private evictedSinceSweep = 0;
private rejectedSinceSweep = 0;
constructor(
readonly limit: number,
readonly windowMs: number,
/** 기억하는 키 개수 상한 — 많은 IP 로 무작위 주소를 뿌려 메모리를 채우는 공격 대비 */
private readonly maxKeys: number,
) {}
/** 지금 구간에 센 수 (구간이 끝났으면 0) */
count(key: string, now: number = Date.now()): number {
return this.current(key, now)?.count ?? 0;
}
isExhausted(key: string, now: number = Date.now()): boolean {
return this.count(key, now) >= this.limit;
}
/** 자리 하나를 차지한다 — 확인과 증가가 한 번에 일어난다 */
reserve(key: string, now: number = Date.now()): ReserveResult {
const state = this.current(key, now);
if (state) {
if (state.count >= this.limit) return { ok: false, reason: 'limit' };
state.count += 1;
return { ok: true, slot: { key, windowEndsAt: state.endsAt } };
}
if (!this.makeRoom(now)) {
this.rejectedSinceSweep += 1;
return { ok: false, reason: 'capacity' };
}
const endsAt = now + this.windowMs;
this.windows.set(key, { count: 1, endsAt });
return { ok: true, slot: { key, windowEndsAt: endsAt } };
}
/** 차지한 자리를 돌려준다 — 그 구간이 아직 이어질 때만 (구간이 바뀌었으면 이미 0 부터 다시 센다) */
release(slot: WindowSlot, now: number = Date.now()): void {
const state = this.current(slot.key, now);
if (!state || state.endsAt !== slot.windowEndsAt) return;
state.count -= 1;
if (state.count <= 0) this.windows.delete(slot.key);
}
clear(key: string): void {
this.windows.delete(key);
}
/**
* 끝난 구간을 지운다. 지난 정리 이후 상한 때문에 밀려난 키 수(evicted)와
* 상한이 차서 거절한 새 키 수(rejected)도 함께 돌려준다 (로그용)
*/
sweep(now: number = Date.now()): {
removed: number;
evicted: number;
rejected: number;
} {
const removed = this.removeEnded(now);
const result = {
removed,
evicted: this.evictedSinceSweep,
rejected: this.rejectedSinceSweep,
};
this.evictedSinceSweep = 0;
this.rejectedSinceSweep = 0;
return result;
}
get size(): number {
return this.windows.size;
}
/**
* 구간 길이가 모두 같으므로 Map 의 삽입 순서 = 구간이 끝나는 순서다.
* 앞에서부터 끝난 것만 지우고, 아직 이어지는 구간을 만나면 멈춘다.
*/
private removeEnded(now: number): number {
let removed = 0;
for (const [key, state] of this.windows) {
if (state.endsAt > now) break;
this.windows.delete(key);
removed += 1;
}
return removed;
}
private current(key: string, now: number): WindowState | undefined {
const state = this.windows.get(key);
if (state && state.endsAt <= now) {
this.windows.delete(key);
return undefined;
}
return state;
}
/**
* 새 키를 넣을 자리를 만든다 → 만들었으면 true.
* 가장 먼저 끝나는 쪽(삽입 순서)부터 최대 MAX_EVICTION_SCAN 개를 보며, 끝났거나 잠기지 않은 구간 하나를 밀어낸다.
* 잠금 중인 구간(한도에 닿은 것)은 건너뛴다.
*/
private makeRoom(now: number): boolean {
if (this.windows.size < this.maxKeys) return true;
let scanned = 0;
for (const [key, state] of this.windows) {
if (scanned >= MAX_EVICTION_SCAN) break;
scanned += 1;
const ended = state.endsAt <= now;
if (ended || state.count < this.limit) {
this.windows.delete(key);
if (!ended) this.evictedSinceSweep += 1;
return true;
}
}
return false;
}
}
@@ -0,0 +1,48 @@
import { BusinessException } from '@/common/errors/business.exception';
import { JwtStrategy } from './jwt.strategy';
const strategy = new JwtStrategy({
jwtSecret: 'unit-test-secret-unit-test-secret-1234',
accessTtlSeconds: 3600,
refreshTtlSeconds: 1209600,
secureCookies: false,
});
describe('JwtStrategy.validate', () => {
it('액세스 토큰이면 직원 정보를 돌려준다', () => {
expect(
strategy.validate({
sub: 'ara.kim@arasystem.test',
typ: 'access',
name: '김아라',
}),
).toEqual({ email: 'ara.kim@arasystem.test', name: '김아라' });
});
it('토큰의 만료(exp, 초)를 epoch ms 로 함께 넘긴다 — 실시간 알림이 토큰보다 오래 열려 있지 않게', () => {
const user = strategy.validate({
sub: 'ara.kim@arasystem.test',
typ: 'access',
name: '김아라',
exp: 1_793_000_000,
});
expect(user).toStrictEqual({
email: 'ara.kim@arasystem.test',
name: '김아라',
accessTokenExpiresAt: 1_793_000_000_000,
});
});
it('리프레시 토큰을 액세스 쿠키에 넣으면 AUTH_001', () => {
expect(() =>
strategy.validate({ sub: 'ara.kim@arasystem.test', typ: 'refresh' }),
).toThrow(BusinessException);
try {
strategy.validate({ sub: 'ara.kim@arasystem.test', typ: 'refresh' });
} catch (error) {
expect((error as BusinessException).code).toBe('AUTH_001');
}
});
});
+57
View File
@@ -0,0 +1,57 @@
import { Inject, Injectable } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport';
import type { Request } from 'express';
import { ExtractJwt, Strategy } from 'passport-jwt';
import type { AuthenticatedUser } from '@/common/decorators/current-user.decorator';
import { BusinessException } from '@/common/errors/business.exception';
import { JWT_STRATEGY } from '@/common/guards/jwt-auth.guard';
import { readCookie } from '@/shared/utils/cookies';
import { ACCESS_TOKEN_COOKIE, JWT_ALGORITHM } from './auth.constants';
import { AUTH_SETTINGS, type AuthSettings } from './auth.settings';
import type { AuthTokenPayload } from './auth-token.types';
/** JWT 의 시각(exp)은 초 단위다 */
const MS_PER_SECOND = 1000;
/** 액세스 토큰은 Authorization 헤더가 아니라 HttpOnly 쿠키에서만 읽는다 (계약 1절) */
function accessTokenFromCookie(request: Request): string | null {
return readCookie(request, ACCESS_TOKEN_COOKIE) ?? null;
}
/**
* 액세스 토큰 검증 전략 — `JwtAuthGuard` 가 이 전략을 쓴다.
* 서명·만료는 passport-jwt 가 확인하고, 여기서는 토큰 종류(typ)와 필드 모양을 확인한다.
*/
@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy, JWT_STRATEGY) {
constructor(@Inject(AUTH_SETTINGS) settings: AuthSettings) {
super({
jwtFromRequest: ExtractJwt.fromExtractors([accessTokenFromCookie]),
secretOrKey: settings.jwtSecret,
algorithms: [JWT_ALGORITHM],
ignoreExpiration: false,
});
}
validate(payload: AuthTokenPayload): AuthenticatedUser {
if (
payload.typ !== 'access' ||
typeof payload.sub !== 'string' ||
typeof payload.name !== 'string'
) {
// 리프레시 토큰을 액세스 쿠키에 넣은 경우 등
throw new BusinessException('AUTH_001', {
context: { reason: 'not-an-access-token' },
});
}
return {
email: payload.sub,
name: payload.name,
// 만료(exp, 초)는 passport-jwt 가 이미 확인했다 — 오래 열린 연결이 토큰보다 오래 가지 않게 넘겨 둔다 (실시간 알림)
...(typeof payload.exp === 'number'
? { accessTokenExpiresAt: payload.exp * MS_PER_SECOND }
: {}),
};
}
}
@@ -0,0 +1,314 @@
import { Logger } from '@nestjs/common';
import { BusinessException } from '@/common/errors/business.exception';
import {
LOGIN_ADDRESS_FAILURE_LIMIT,
LOGIN_ADDRESS_FAILURE_WINDOW_MS,
LOGIN_IP_FAILURE_LIMIT,
LOGIN_IP_FAILURE_WINDOW_MS,
} from './auth.constants';
import { FixedWindowCounter } from './fixed-window-counter';
import {
LoginAttemptService,
type LoginAttemptSource,
} from './login-attempt.service';
const ARA = 'ara.kim@arasystem.test';
const IP = '203.0.113.10';
function reasonOf(run: () => unknown): unknown {
try {
run();
} catch (error) {
if (error instanceof BusinessException) {
return { code: error.code, ...error.context };
}
throw error;
}
return 'allowed';
}
describe('LoginAttemptService', () => {
let service: LoginAttemptService;
let now: number;
/** 시도 하나를 시작하자마자 결과를 정한다 */
const attempt = (
source: LoginAttemptSource,
outcome: 'fail' | 'succeed' | 'abort',
) => service.begin(source)[outcome]();
beforeEach(() => {
service = new LoginAttemptService();
now = 1_700_000_000_000;
jest.spyOn(Date, 'now').mockImplementation(() => now);
});
afterEach(() => jest.restoreAllMocks());
it('같은 주소로 실패 10회가 쌓이면 11번째 시도는 REQ_001 — IP 를 바꿔도 마찬가지', () => {
for (let i = 0; i < LOGIN_ADDRESS_FAILURE_LIMIT; i += 1) {
attempt({ email: ARA, ip: `198.51.100.${i}` }, 'fail');
}
expect(reasonOf(() => service.begin({ email: ARA, ip: IP }))).toEqual({
code: 'REQ_001',
reason: 'login-failures-exceeded',
scope: 'address',
email: 'a***@arasystem.test',
});
});
it('같은 IP 에서 실패 20회가 쌓이면 처음 보는 주소도 REQ_001', () => {
for (let i = 0; i < LOGIN_IP_FAILURE_LIMIT; i += 1) {
attempt({ email: `guess${i}@arasystem.test`, ip: IP }, 'fail');
}
expect(
reasonOf(() =>
service.begin({ email: 'new.person@arasystem.test', ip: IP }),
),
).toMatchObject({ code: 'REQ_001', scope: 'ip' });
// 다른 IP 는 영향이 없다
expect(
reasonOf(() =>
service.begin({ email: 'new.person@arasystem.test', ip: '192.0.2.1' }),
),
).toBe('allowed');
});
it('성공은 세지 않는다 — 같은 IP 에서 한도보다 많이 성공해도 막히지 않는다 (사무실 NAT)', () => {
for (let i = 0; i < LOGIN_IP_FAILURE_LIMIT * 3; i += 1) {
attempt({ email: `staff${i}@arasystem.test`, ip: IP }, 'succeed');
}
expect(reasonOf(() => service.begin({ email: ARA, ip: IP }))).toBe(
'allowed',
);
});
it('성공하면 그 주소의 실패 기록을 지운다 — IP 기록은 남긴다', () => {
for (let i = 0; i < LOGIN_ADDRESS_FAILURE_LIMIT - 1; i += 1) {
attempt({ email: ARA, ip: IP }, 'fail');
}
attempt({ email: ARA, ip: IP }, 'succeed');
// 주소: 다시 10번 실패할 수 있다
for (let i = 0; i < LOGIN_ADDRESS_FAILURE_LIMIT; i += 1) {
attempt({ email: ARA, ip: `198.51.100.${i}` }, 'fail');
}
expect(
reasonOf(() => service.begin({ email: ARA, ip: '192.0.2.9' })),
).toMatchObject({ scope: 'address' });
// IP: 앞의 실패 9회는 지워지지 않았다 → 11번 더 실패하면 한도(20)
for (let i = 0; i < LOGIN_IP_FAILURE_LIMIT - 9; i += 1) {
attempt({ email: `guess${i}@arasystem.test`, ip: IP }, 'fail');
}
expect(
reasonOf(() => service.begin({ email: 'x@arasystem.test', ip: IP })),
).toMatchObject({ scope: 'ip' });
});
it('판단하지 못한 시도(엔진 장애)는 두 자리를 모두 돌려준다', () => {
for (let i = 0; i < LOGIN_ADDRESS_FAILURE_LIMIT * 3; i += 1) {
attempt({ email: ARA, ip: IP }, 'abort');
}
expect(reasonOf(() => service.begin({ email: ARA, ip: IP }))).toBe(
'allowed',
);
});
it('같은 주소의 시도가 진행 중이면 다음 시도는 기다리지 않고 REQ_001 — 끝나면 다시 받는다', () => {
const first = service.begin({ email: ARA, ip: IP });
expect(
reasonOf(() => service.begin({ email: ARA, ip: '192.0.2.1' })),
).toMatchObject({ code: 'REQ_001', reason: 'login-in-progress' });
// 다른 주소는 같은 IP 라도 동시에 진행할 수 있다
expect(
reasonOf(() => service.begin({ email: 'doyun@arasystem.test', ip: IP })),
).toBe('allowed');
first.fail();
expect(reasonOf(() => service.begin({ email: ARA, ip: IP }))).toBe(
'allowed',
);
});
it('결과는 한 번만 반영된다 — 실패 뒤 abort 가 불려도 실패가 지워지지 않는다', () => {
for (let i = 0; i < LOGIN_ADDRESS_FAILURE_LIMIT; i += 1) {
const ticket = service.begin({ email: ARA, ip: IP });
ticket.fail();
ticket.abort();
ticket.succeed();
}
expect(reasonOf(() => service.begin({ email: ARA, ip: IP }))).toMatchObject(
{ scope: 'address' },
);
});
it('구간이 지나면 다시 시도할 수 있다 (IP 1분 · 주소 15분)', () => {
for (let i = 0; i < LOGIN_IP_FAILURE_LIMIT; i += 1) {
attempt({ email: `guess${i}@arasystem.test`, ip: IP }, 'fail');
}
now += LOGIN_IP_FAILURE_WINDOW_MS;
expect(
reasonOf(() => service.begin({ email: 'later@arasystem.test', ip: IP })),
).toBe('allowed');
for (let i = 0; i < LOGIN_ADDRESS_FAILURE_LIMIT; i += 1) {
attempt({ email: ARA, ip: `198.51.100.${i}` }, 'fail');
}
now += LOGIN_ADDRESS_FAILURE_WINDOW_MS - 1;
expect(
reasonOf(() => service.begin({ email: ARA, ip: '192.0.2.2' })),
).toMatchObject({ scope: 'address' });
now += 1;
expect(reasonOf(() => service.begin({ email: ARA, ip: '192.0.2.3' }))).toBe(
'allowed',
);
});
it('주소 한도로 거부되면 IP 자리도 돌려준다 — 막힌 주소를 두드려 IP 한도를 대신 소모시키지 못한다', () => {
for (let i = 0; i < LOGIN_ADDRESS_FAILURE_LIMIT; i += 1) {
attempt({ email: ARA, ip: `198.51.100.${i}` }, 'fail');
}
for (let i = 0; i < LOGIN_IP_FAILURE_LIMIT * 2; i += 1) {
expect(
reasonOf(() => service.begin({ email: ARA, ip: IP })),
).toMatchObject({ scope: 'address' });
}
expect(
reasonOf(() => service.begin({ email: 'doyun@arasystem.test', ip: IP })),
).toBe('allowed');
});
it('정리 작업은 상한 때문에 일찍 지운 기록이 있을 때만 경고한다', () => {
const warn = jest
.spyOn(Logger.prototype, 'warn')
.mockImplementation(() => undefined);
service.sweep(now);
expect(warn).not.toHaveBeenCalled();
});
describe('IP 묶기 (요청 수 한도와 같은 clientIpKey)', () => {
it('IPv6 는 /64 로 묶는다 — 같은 회선에서 주소를 바꿔 가며 IP 한도를 피하지 못한다', () => {
for (let i = 0; i < LOGIN_IP_FAILURE_LIMIT; i += 1) {
attempt(
{ email: `guess${i}@arasystem.test`, ip: `2001:db8:7:7::${i + 1}` },
'fail',
);
}
expect(
reasonOf(() =>
service.begin({
email: 'new@arasystem.test',
ip: '2001:db8:7:7:abcd::1',
}),
),
).toMatchObject({ scope: 'ip', ip: '2001:db8:7:7::/64' });
// 다른 /64 는 영향이 없다
expect(
reasonOf(() =>
service.begin({ email: 'new@arasystem.test', ip: '2001:db8:7:8::1' }),
),
).toBe('allowed');
});
it('IPv4-mapped 주소와 IPv4 주소는 같은 IP 로 센다', () => {
for (let i = 0; i < LOGIN_IP_FAILURE_LIMIT / 2; i += 1) {
attempt({ email: `a${i}@arasystem.test`, ip: '203.0.113.7' }, 'fail');
attempt(
{ email: `b${i}@arasystem.test`, ip: '::ffff:203.0.113.7' },
'fail',
);
}
expect(
reasonOf(() =>
service.begin({ email: 'c@arasystem.test', ip: '203.0.113.7' }),
),
).toMatchObject({ scope: 'ip' });
});
});
describe('사내 도메인이 아닌 주소 (email: null)', () => {
it('주소 카운터에 넣지 않고 IP 만 센다 — 진행 중 잠금도 없다', () => {
// 같은 IP 에서 여러 번 실패해도 주소 한도는 걸리지 않고, IP 한도만 쌓인다
const tickets = Array.from({ length: 3 }, () =>
service.begin({ email: null, ip: IP }),
);
tickets.forEach((ticket) => ticket.fail());
for (let i = 0; i < LOGIN_IP_FAILURE_LIMIT - 3; i += 1) {
attempt({ email: null, ip: IP }, 'fail');
}
expect(
reasonOf(() => service.begin({ email: null, ip: IP })),
).toMatchObject({ scope: 'ip' });
});
it('사내 주소의 주소 한도와 섞이지 않는다', () => {
for (let i = 0; i < LOGIN_ADDRESS_FAILURE_LIMIT; i += 1) {
attempt({ email: null, ip: `198.51.100.${i}` }, 'fail');
}
expect(
reasonOf(() => service.begin({ email: ARA, ip: '192.0.2.5' })),
).toBe('allowed');
});
});
describe('기억하는 키 상한 (fail-closed)', () => {
/** 상한을 작게 바꾼 서비스 — 내부 카운터를 같은 규칙의 작은 카운터로 바꿔 끼운다 */
function withCapacity(maxKeys: number): LoginAttemptService {
const small = new LoginAttemptService();
Reflect.set(
small,
'ipFailures',
new FixedWindowCounter(
LOGIN_IP_FAILURE_LIMIT,
LOGIN_IP_FAILURE_WINDOW_MS,
maxKeys,
),
);
return small;
}
it('상한이 차고 모두 잠금 중이면 새 IP 는 REQ_001(login-tracking-full) 이고, 처음 한 번 error 로그를 남긴다', () => {
const error = jest
.spyOn(Logger.prototype, 'error')
.mockImplementation(() => undefined);
const small = withCapacity(1);
// 한 IP 를 한도까지 채워 잠근다 (밀어낼 수 없는 기록)
for (let i = 0; i < LOGIN_IP_FAILURE_LIMIT; i += 1) {
small.begin({ email: null, ip: '198.51.100.1' }).fail();
}
for (const ip of ['198.51.100.2', '198.51.100.3']) {
try {
small.begin({ email: null, ip });
fail('거절해야 한다');
} catch (rejection) {
expect((rejection as BusinessException).code).toBe('REQ_001');
expect((rejection as BusinessException).context).toMatchObject({
reason: 'login-tracking-full',
});
}
}
expect(error).toHaveBeenCalledTimes(1);
expect(error.mock.calls[0][0]).toMatch(/\[REQ_001\].*fail-closed/);
// 정리 주기에 거절 건수를 error 로 합산하고, 다음 주기에는 다시 처음 한 번 알린다
small.sweep(now);
expect(error).toHaveBeenCalledTimes(2);
expect(error.mock.calls[1][0]).toMatch(/2건을 거절했습니다/);
});
});
});
@@ -0,0 +1,204 @@
import { Injectable, Logger } from '@nestjs/common';
import { Interval } from '@nestjs/schedule';
import { createHash } from 'crypto';
import { BusinessException } from '@/common/errors/business.exception';
import { clientIpKey } from '@/shared/utils/client-ip';
import { maskEmail } from '@/shared/utils/mask';
import {
LOGIN_ADDRESS_FAILURE_LIMIT,
LOGIN_ADDRESS_FAILURE_WINDOW_MS,
LOGIN_IP_FAILURE_LIMIT,
LOGIN_IP_FAILURE_WINDOW_MS,
} from './auth.constants';
import {
FixedWindowCounter,
type ReserveResult,
type WindowSlot,
} from './fixed-window-counter';
/** 끝난 구간을 지우는 주기 */
const SWEEP_INTERVAL_MS = 60_000;
/**
* 기억하는 IP·주소 개수 상한 (카운터마다). 키 하나는 수백 바이트라 상한에서도 수십 MB 안쪽이다.
* 넘치면 끝났거나 잠기지 않은 구간만 밀어내고, 밀어낼 것이 없으면 새 키를 거절한다 (fixed-window-counter.ts).
*/
const MAX_TRACKED_KEYS = 100_000;
type Scope = 'ip' | 'address';
type RejectReason =
| 'login-failures-exceeded'
| 'login-in-progress'
| 'login-tracking-full';
/** 로그인 시도 하나를 세는 기준 */
export interface LoginAttemptSource {
/**
* 정규화한 로그인 주소 — 사내 도메인 주소만 넣는다(없는 주소도 그대로 센다).
* 사내 도메인이 아닌 주소는 null: 엔진에 묻지 않고 바로 거절하므로 IP 만 센다.
* (도메인 규칙은 공개 정보라 이 차이로 알 수 있는 것은 없다)
*/
readonly email: string | null;
/** 클라이언트 IP — 믿을 수 있는 프록시 뒤에서 읽은 값 (app.setup.ts 의 TRUST_PROXY). IPv6 는 /64 로 묶어 센다 */
readonly ip: string;
}
/**
* 진행 중인 로그인 시도 하나. 결과가 정해지면 셋 중 하나를 **한 번** 부른다 (두 번째부터는 무시).
*/
export interface LoginAttemptTicket {
/** 실패(AUTH_004) — 미리 차지한 실패 한 번을 그대로 남긴다 */
fail(): void;
/** 성공 — 그 주소의 실패 기록을 지우고, 차지했던 IP 자리를 돌려준다 (성공은 세지 않는다) */
succeed(): void;
/** 판단하지 못함(메일 엔진 장애 등) — 차지했던 자리를 모두 돌려준다 */
abort(): void;
}
/**
* 로그인 **실패** 횟수 제한 (계약 1절).
*
* - 같은 IP 에서 1분에 실패 20회 / 같은 주소로 15분에 실패 10회를 넘으면 REQ_001.
* 한도를 넘은 동안은 비밀번호가 맞아도 막는다 (무차별 대입이 계속 정답을 시험하지 못하게)
* - IP 는 `clientIpKey()` 로 묶는다 — IPv6 는 /64, IPv4-mapped 는 IPv4 (요청 수 한도와 같은 키)
* - **메일 엔진에 묻기 전에** 실패 한 번을 IP·주소 양쪽에 미리 차지한다(`begin`).
* 확인과 차지가 한 동기 구간이라, 동시에 보낸 요청 100건이 "아직 한 번 남았다"를 함께 보고
* 모두 엔진까지 가는 일이 없다. 결과가 성공이거나 판단할 수 없으면 차지한 자리를 돌려준다.
* - 같은 주소의 시도가 진행 중이면 다음 시도는 기다리지 않고 REQ_001 — 한 주소는 한 번에 하나만 엔진에 묻는다.
* - 성공하면 그 **주소**의 기록만 지운다. IP 기록은 지우지 않는다
* (계정 하나를 아는 공격자가 성공 로그인으로 IP 카운터를 비워 가며 다른 계정을 찔러 보지 못하게)
* - 기억하는 키가 상한에 닿고 밀어낼 기록도 없으면 새 IP·주소는 REQ_001 (fail-closed) + error 로그
*
* 저장소는 프로세스 메모리다. ⚠️ 여러 인스턴스로 늘리면 인스턴스마다 따로 세므로
* Redis 원자 연산으로 옮겨야 한다 (fixed-window-counter.ts).
*/
@Injectable()
export class LoginAttemptService {
private readonly logger = new Logger(LoginAttemptService.name);
private readonly ipFailures = new FixedWindowCounter(
LOGIN_IP_FAILURE_LIMIT,
LOGIN_IP_FAILURE_WINDOW_MS,
MAX_TRACKED_KEYS,
);
private readonly addressFailures = new FixedWindowCounter(
LOGIN_ADDRESS_FAILURE_LIMIT,
LOGIN_ADDRESS_FAILURE_WINDOW_MS,
MAX_TRACKED_KEYS,
);
/** 엔진 확인이 진행 중인 주소 */
private readonly inFlight = new Set<string>();
/** 상한이 차서 거절하기 시작했다는 error 로그를 이번 정리 주기에 이미 남겼는가 (로그 폭주 방지) */
private capacityAlarmRaised = false;
/**
* 시도를 시작한다 — 한도를 넘었거나 같은 주소의 시도가 진행 중이면 REQ_001.
* 이 메서드 안에는 await 가 없다 (확인과 차지 사이에 다른 요청이 끼어들지 못한다).
*/
begin(source: LoginAttemptSource): LoginAttemptTicket {
const now = Date.now();
const ipKey = digest(clientIpKey(source.ip));
const addressKey = source.email === null ? null : digest(source.email);
if (addressKey !== null && this.inFlight.has(addressKey)) {
throw this.rejected(source, 'address', 'login-in-progress');
}
const ip = this.ipFailures.reserve(ipKey, now);
if (!ip.ok) throw this.rejected(source, 'ip', this.reasonOf(ip));
let addressSlot: WindowSlot | null = null;
if (addressKey !== null) {
const address = this.addressFailures.reserve(addressKey, now);
if (!address.ok) {
this.ipFailures.release(ip.slot, now);
throw this.rejected(source, 'address', this.reasonOf(address));
}
addressSlot = address.slot;
this.inFlight.add(addressKey);
}
let settled = false;
const settle = (finish: () => void) => {
if (settled) return;
settled = true;
if (addressKey !== null) this.inFlight.delete(addressKey);
finish();
};
return {
fail: () => settle(() => undefined),
succeed: () =>
settle(() => {
this.ipFailures.release(ip.slot);
if (addressKey !== null) this.addressFailures.clear(addressKey);
}),
abort: () =>
settle(() => {
this.ipFailures.release(ip.slot);
if (addressSlot !== null) this.addressFailures.release(addressSlot);
}),
};
}
/**
* 끝난 구간을 지운다. 상한 때문에 밀려난 기록이 있었다면 warn(대량 시도 징후),
* 상한이 차서 새 키를 거절했다면 error (정상 사용자도 막히고 있다 — 즉시 확인이 필요하다)
*/
@Interval(SWEEP_INTERVAL_MS)
sweep(now: number = Date.now()): void {
const ip = this.ipFailures.sweep(now);
const address = this.addressFailures.sweep(now);
this.capacityAlarmRaised = false;
const evicted = ip.evicted + address.evicted;
if (evicted > 0) {
this.logger.warn(
`[REQ_001] 로그인 실패 기록이 상한(${MAX_TRACKED_KEYS})에 닿아 잠기지 않은 기록 ${evicted}건을 일찍 지웠습니다 — 많은 IP·주소로 시도하는 공격일 수 있습니다.`,
);
}
const rejected = ip.rejected + address.rejected;
if (rejected > 0) {
this.logger.error(
`[REQ_001] 로그인 실패 기록이 상한(${MAX_TRACKED_KEYS})에 닿고 밀어낼 기록이 없어 지난 주기에 새 IP·주소의 로그인 ${rejected}건을 거절했습니다 (fail-closed).`,
);
}
}
private reasonOf(
result: Extract<ReserveResult, { ok: false }>,
): RejectReason {
if (result.reason === 'limit') return 'login-failures-exceeded';
// 상한이 찼다 — 정상 사용자도 막힐 수 있는 상태라 처음 한 번은 바로 error 로 알린다 (나머지는 정리 주기에 합계로)
if (!this.capacityAlarmRaised) {
this.capacityAlarmRaised = true;
this.logger.error(
`[REQ_001] 로그인 실패 기록이 상한(${MAX_TRACKED_KEYS})에 닿았고 밀어낼 수 있는 기록(끝났거나 잠기지 않은 것)이 없어 새 IP·주소의 로그인을 거절합니다 (fail-closed). 대량 시도가 의심됩니다.`,
);
}
return 'login-tracking-full';
}
private rejected(
source: LoginAttemptSource,
scope: Scope,
reason: RejectReason,
): BusinessException {
return new BusinessException('REQ_001', {
context: {
reason,
scope,
...(scope === 'ip'
? { ip: clientIpKey(source.ip) }
: { email: maskEmail(source.email ?? '') }),
},
});
}
}
/** 키에 주소·IP 원문을 남기지 않는다 (Redis 로 옮겨도 목록이 드러나지 않게) */
function digest(subject: string): string {
return createHash('sha256').update(subject).digest('base64url');
}
@@ -0,0 +1,25 @@
import { Column, Entity, PrimaryColumn } from 'typeorm';
/** 메일 주소 최대 길이 (RFC 5321 경로 한도) — mail.constants 의 MAX_EMAIL_LENGTH 와 같다 */
const EMAIL_COLUMN_LENGTH = 254;
/** 표시 이름 최대 길이 — mail.constants 의 MAX_DISPLAY_NAME_LENGTH 와 같다 */
export const DIRECTORY_NAME_LENGTH = 200;
/**
* 사내 목록 (계약 2절 "받는 사람 추천") — 웹메일 로그인에 성공한 직원의 주소·이름.
* 메일 엔진의 계정 목록(Principal) 권한은 쓰지 않는다 — 일부러 막아 두었다 (mail/scripts/setup.mjs).
* 메일 엔진 관리자·게이트웨이·막은 계정은 넣지 않는다 (로그인할 수도 없다).
*/
@Entity({ name: 'mail_directory' })
export class MailDirectoryEntryEntity {
/** 정규화한(소문자) 직원 주소 */
@PrimaryColumn({ type: 'varchar', length: EMAIL_COLUMN_LENGTH })
email!: string;
/** 마지막으로 로그인했을 때 메일 엔진이 알려 준 표시 이름 */
@Column({ type: 'varchar', length: DIRECTORY_NAME_LENGTH })
name!: string;
@Column({ name: 'last_login_at', type: 'timestamptz' })
lastLoginAt!: Date;
}
@@ -0,0 +1,16 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { MailDirectoryEntryEntity } from './mail-directory.entity';
import { MailDirectoryService } from './mail-directory.service';
/**
* 사내 목록 모듈 — 인증 모듈(로그인 때 기록)과 메일 모듈(받는 사람 추천)이 함께 쓴다.
* 두 모듈 사이에 두어 서로를 가져오지 않게 한다 (AuthModule → MailModule 방향만 있다).
*/
@Module({
imports: [TypeOrmModule.forFeature([MailDirectoryEntryEntity])],
providers: [MailDirectoryService],
exports: [MailDirectoryService],
})
export class MailDirectoryModule {}
@@ -0,0 +1,62 @@
import { MoreThanOrEqual, type Repository } from 'typeorm';
import type { MailDirectoryEntryEntity } from './mail-directory.entity';
import {
MAIL_DIRECTORY_ACTIVE_DAYS,
MAX_DIRECTORY_CONTACTS,
MailDirectoryService,
} from './mail-directory.service';
describe('MailDirectoryService (사내 목록)', () => {
let upsert: jest.Mock;
let find: jest.Mock;
let service: MailDirectoryService;
beforeEach(() => {
upsert = jest.fn().mockResolvedValue(undefined);
find = jest.fn().mockResolvedValue([]);
service = new MailDirectoryService({
upsert,
find,
} as unknown as Repository<MailDirectoryEntryEntity>);
});
it('로그인한 직원을 주소 하나에 한 줄로 넣거나 고친다 — 주소는 소문자, 이름은 앞뒤 공백 없이 200자까지', async () => {
const now = new Date('2026-10-27T03:00:00Z');
await service.recordLogin(
{ email: ' Doyun.Lee@ARASYSTEM.test ', name: ` ${'가'.repeat(250)} ` },
now,
);
expect(upsert).toHaveBeenCalledWith(
{
email: 'doyun.lee@arasystem.test',
name: '가'.repeat(200),
lastLoginAt: now,
},
['email'],
);
});
it(`최근 ${MAIL_DIRECTORY_ACTIVE_DAYS}일 안에 로그인한 직원만, 최근에 로그인한 사람부터 상한까지 주소·이름만 읽는다 (퇴사자는 추천하지 않는다)`, async () => {
const now = new Date('2026-10-27T03:00:00Z');
find.mockResolvedValue([
{
email: 'doyun.lee@arasystem.test',
name: '이도윤',
lastLoginAt: new Date(),
},
]);
await expect(
service.listContacts(MAX_DIRECTORY_CONTACTS, now),
).resolves.toEqual([{ email: 'doyun.lee@arasystem.test', name: '이도윤' }]);
expect(find).toHaveBeenCalledWith({
// 2026-10-27 의 90일 전 = 2026-07-29
where: { lastLoginAt: MoreThanOrEqual(new Date('2026-07-29T03:00:00Z')) },
order: { lastLoginAt: 'DESC' },
take: MAX_DIRECTORY_CONTACTS,
});
});
});
@@ -0,0 +1,63 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { MoreThanOrEqual, type Repository } from 'typeorm';
import type { DirectoryContact } from '@/modules/mail/mail.types';
import {
DIRECTORY_NAME_LENGTH,
MailDirectoryEntryEntity,
} from './mail-directory.entity';
/** 추천에 읽어 오는 사내 목록 상한 — 최근에 로그인한 사람부터 (추천 목록 자체는 1,000개까지다) */
export const MAX_DIRECTORY_CONTACTS = 2000;
/**
* 이 기간 안에 로그인한 직원만 "사내"로 추천한다 — 퇴사자가 계속 사내 맨 앞에 나와 반송되지 않게.
* 웹메일을 쓰는 직원은 세션이 길어야 30일이라 그 안에 다시 로그인한다. 표에서 지우지는 않는다 (다시 로그인하면 돌아온다).
*/
export const MAIL_DIRECTORY_ACTIVE_DAYS = 90;
const DAY_MS = 24 * 60 * 60 * 1000;
/**
* 사내 목록 — 로그인에 성공한 직원을 기록하고(AuthService), 받는 사람 추천이 읽는다(RecipientSuggestionService).
* 누가 넣을지(관리자·게이트웨이 제외)는 부르는 쪽이 정한다.
*/
@Injectable()
export class MailDirectoryService {
constructor(
@InjectRepository(MailDirectoryEntryEntity)
private readonly entries: Repository<MailDirectoryEntryEntity>,
) {}
/** 로그인한 직원을 넣거나 이름·마지막 로그인 시각을 고친다 (주소 하나에 한 줄 — INSERT … ON CONFLICT) */
async recordLogin(
contact: DirectoryContact,
now: Date = new Date(),
): Promise<void> {
await this.entries.upsert(
{
email: contact.email.trim().toLowerCase(),
name: contact.name.trim().slice(0, DIRECTORY_NAME_LENGTH),
lastLoginAt: now,
},
['email'],
);
}
/** 최근 MAIL_DIRECTORY_ACTIVE_DAYS 일 안에 로그인한 직원 — 최근에 로그인한 사람부터 */
async listContacts(
limit: number = MAX_DIRECTORY_CONTACTS,
now: Date = new Date(),
): Promise<DirectoryContact[]> {
const activeSince = new Date(
now.getTime() - MAIL_DIRECTORY_ACTIVE_DAYS * DAY_MS,
);
const rows = await this.entries.find({
where: { lastLoginAt: MoreThanOrEqual(activeSince) },
order: { lastLoginAt: 'DESC' },
take: limit,
});
return rows.map(({ email, name }) => ({ email, name }));
}
}
@@ -0,0 +1,122 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsBoolean, IsOptional, IsString } from 'class-validator';
import {
SIGNATURE_HTML_MAX_LENGTH,
SIGNATURE_IMAGES_PER_SIGNATURE,
SIGNATURE_IMAGE_MAX_BYTES,
SIGNATURE_TEXT_MAX_LENGTH,
} from '@/modules/mail-signature/mail-signature.constants';
import type {
MailSignature,
SignatureImage,
} from '@/modules/mail-signature/mail-signature.types';
import type { SignatureImageType } from '@/modules/mail-signature/signature-image-format';
const IMAGE_TYPES: SignatureImageType[] = [
'image/png',
'image/jpeg',
'image/gif',
];
/** PUT /api/mail-signature — 계약 2절 "서명" */
export class UpdateMailSignatureDto {
@ApiPropertyOptional({
description:
`서명 서식 HTML — 서식 본문과 같은 허용 목록 + 내 서명 그림(<img src="/api/mail-signature/images/{id}" width="1~600" alt="">). ` +
`정화한 뒤 ${SIGNATURE_HTML_MAX_LENGTH.toLocaleString('ko-KR')}자 이하, 서로 다른 그림 ${SIGNATURE_IMAGES_PER_SIGNATURE}장까지(넘으면 400 VAL_001). null·빈 문자열이면 서식 서명 없음. ` +
'서명 HTML 이 더는 가리키지 않는 그림은 저장할 때 지운다',
nullable: true,
type: String,
example:
'<p style="margin:0"><strong>김아라</strong> · 아라시스템</p><img src="/api/mail-signature/images/3f1c0a2e-0f1b-4f2a-9a1e-6c9f0a1b2c3d" width="144" alt="">',
})
@IsOptional()
@IsString({ message: '서명 형식이 올바르지 않습니다.' })
bodyHtml?: string | null;
@ApiPropertyOptional({
description: `텍스트 서명 — 텍스트 대체 본문에 넣는다. ${SIGNATURE_TEXT_MAX_LENGTH.toLocaleString('ko-KR')}자 이하(JS 문자열 길이, 넘으면 400 VAL_001)`,
nullable: true,
type: String,
example: '김아라 · 아라시스템',
})
@IsOptional()
@IsString({ message: '텍스트 서명 형식이 올바르지 않습니다.' })
body?: string | null;
@ApiProperty({ description: '새 메일에 넣는다', example: true })
@IsBoolean({ message: '새 메일에 넣을지 여부를 다시 선택해 주세요.' })
isForNew!: boolean;
@ApiProperty({ description: '답장·전달에 넣는다 (인용 위)', example: true })
@IsBoolean({ message: '답장·전달에 넣을지 여부를 다시 선택해 주세요.' })
isForReply!: boolean;
}
export class SignatureImageDto implements SignatureImage {
@ApiProperty({
description:
'그림 id — 서명 HTML 의 <img src="/api/mail-signature/images/{id}"> 에 쓴다',
example: '3f1c0a2e-0f1b-4f2a-9a1e-6c9f0a1b2c3d',
})
id!: string;
@ApiProperty({
description: '파일 내용으로 확인한 형식',
enum: IMAGE_TYPES,
example: 'image/png',
})
type!: SignatureImageType;
@ApiProperty({
description: `크기 (바이트, ${SIGNATURE_IMAGE_MAX_BYTES.toLocaleString('ko-KR')} 이하)`,
example: 18342,
})
size!: number;
@ApiProperty({ description: '원본 가로 픽셀', example: 480 })
width!: number;
@ApiProperty({ description: '원본 세로 픽셀', example: 120 })
height!: number;
}
export class MailSignatureDto implements MailSignature {
@ApiProperty({
description: `서명 서식 HTML (정화한 값, ${SIGNATURE_HTML_MAX_LENGTH.toLocaleString('ko-KR')}자 이하) — 저장한 적 없거나 비었으면 null`,
nullable: true,
type: String,
})
bodyHtml!: string | null;
@ApiProperty({
description: '텍스트 서명 — 저장한 적 없거나 비었으면 null',
nullable: true,
type: String,
})
body!: string | null;
@ApiProperty({ description: '새 메일에 넣는다 (기본 true)', example: true })
isForNew!: boolean;
@ApiProperty({
description: '답장·전달에 넣는다 — 인용 위 (기본 true)',
example: true,
})
isForReply!: boolean;
@ApiProperty({
description: `서명 HTML 이 쓰는 그림 — HTML 에 나온 순서 (${SIGNATURE_IMAGES_PER_SIGNATURE}장까지)`,
type: [SignatureImageDto],
})
images!: SignatureImageDto[];
@ApiProperty({
description: '저장한 시각 (ISO 8601 UTC) — 저장한 적 없으면 null',
nullable: true,
type: String,
example: '2026-10-27T05:12:00.000Z',
})
updatedAt!: string | null;
}
@@ -0,0 +1,55 @@
import { Column, Entity, Index, PrimaryColumn } from 'typeorm';
/** 메일 주소 최대 길이 (RFC 5321 경로 한도) — mail.constants 의 MAX_EMAIL_LENGTH 와 같다 */
const EMAIL_COLUMN_LENGTH = 254;
/** 'image/jpeg' 등 */
const CONTENT_TYPE_LENGTH = 32;
/** 서버가 만든 파일 이름 — `<uuid>.<확장자>` */
const FILE_NAME_LENGTH = 64;
/**
* 서명 그림 (계약 2절 "서명") — 파일은 업로드 볼륨의 하위 폴더에 두고(mail-signature.constants.ts), 여기에는 정보만 둔다.
* - 파일 이름은 서버가 만든다 (UUID + 내용으로 확인한 형식의 확장자). 올린 이름은 저장하지 않는다
* - `attached_at`: 서명을 저장할 때 그 HTML 이 가리키면 채운다. 비어 있고 하루가 지난 그림은 정리 작업이 지운다
*/
@Entity({ name: 'mail_signature_images' })
@Index('IDX_mail_signature_images_user_email', ['userEmail'])
@Index('IDX_mail_signature_images_unattached', ['createdAt'], {
where: '"attached_at" IS NULL',
})
export class MailSignatureImageEntity {
@PrimaryColumn({ type: 'uuid' })
id!: string;
@Column({ name: 'user_email', type: 'varchar', length: EMAIL_COLUMN_LENGTH })
userEmail!: string;
/** 파일 내용으로 확인한 형식 (클라이언트가 보낸 값이 아니다) */
@Column({
name: 'content_type',
type: 'varchar',
length: CONTENT_TYPE_LENGTH,
})
contentType!: string;
/** 바이트 */
@Column({ type: 'integer' })
size!: number;
/** 원본 픽셀 — 파일 머리에서 읽었다 */
@Column({ type: 'integer' })
width!: number;
@Column({ type: 'integer' })
height!: number;
@Column({ name: 'file_name', type: 'varchar', length: FILE_NAME_LENGTH })
fileName!: string;
@Column({ name: 'created_at', type: 'timestamptz' })
createdAt!: Date;
/** 저장한 서명이 처음 가리킨 시각 — null 이면 올리기만 한 그림 */
@Column({ name: 'attached_at', type: 'timestamptz', nullable: true })
attachedAt!: Date | null;
}
@@ -0,0 +1,144 @@
import { Logger } from '@nestjs/common';
import { mkdtempSync, rmSync, utimesSync, writeFileSync } from 'fs';
import { tmpdir } from 'os';
import { join } from 'path';
import type { MailSignatureImageEntity } from './mail-signature-image.entity';
import { MailSignatureImageCleanup } from './mail-signature.cleanup';
import type { MailSignatureRepository } from './mail-signature.repository';
import { SignatureImageStorage } from './signature-image-storage';
const NOW = new Date('2026-10-27T04:00:00Z');
const HOUR = 60 * 60 * 1000;
const idOf = (n: number) => `0000000${n}-0000-4000-8000-000000000000`;
describe('MailSignatureImageCleanup (하루 한 번 서명 그림 정리)', () => {
let dir: string;
let storage: SignatureImageStorage;
let repository: {
findUnattachedBefore: jest.Mock<
Promise<MailSignatureImageEntity[]>,
[Date]
>;
deleteUnattachedImages: jest.Mock<
Promise<Pick<MailSignatureImageEntity, 'id' | 'fileName'>[]>,
[readonly string[]]
>;
listFileNames: jest.Mock<Promise<Set<string>>, []>;
};
let cleanup: MailSignatureImageCleanup;
/** 파일을 만들고 수정 시각을 `hoursAgo` 시간 전으로 둔다 */
function file(n: number, hoursAgo: number): string {
const fileName = `${idOf(n)}.png`;
const path = join(dir, fileName);
writeFileSync(path, 'png');
const at = new Date(NOW.getTime() - hoursAgo * HOUR);
utimesSync(path, at, at);
return fileName;
}
beforeEach(() => {
dir = mkdtempSync(join(tmpdir(), 'signature-cleanup-'));
storage = new SignatureImageStorage(dir);
repository = {
findUnattachedBefore: jest
.fn<Promise<MailSignatureImageEntity[]>, [Date]>()
.mockResolvedValue([]),
deleteUnattachedImages: jest
.fn<
Promise<Pick<MailSignatureImageEntity, 'id' | 'fileName'>[]>,
[readonly string[]]
>()
.mockImplementation((ids) =>
Promise.resolve(ids.map((id) => ({ id, fileName: `${id}.png` }))),
),
listFileNames: jest
.fn<Promise<Set<string>>, []>()
.mockResolvedValue(new Set()),
};
cleanup = new MailSignatureImageCleanup(
repository as unknown as MailSignatureRepository,
storage,
);
});
afterEach(() => {
jest.restoreAllMocks();
rmSync(dir, { recursive: true, force: true });
});
it('서명에 붙지 않은 채 하루가 지난 그림을 표와 파일에서 지운다', async () => {
const stale = file(1, 30);
repository.findUnattachedBefore.mockResolvedValue([
{ id: idOf(1), fileName: stale } as MailSignatureImageEntity,
]);
repository.listFileNames.mockResolvedValue(new Set());
await expect(cleanup.purgeUnattachedImages(NOW)).resolves.toBe(1);
expect(repository.findUnattachedBefore).toHaveBeenCalledWith(
new Date(NOW.getTime() - 24 * HOUR),
);
expect(repository.deleteUnattachedImages).toHaveBeenCalledWith([idOf(1)]);
expect(await storage.list()).toEqual([]);
});
it('표에 없는 파일은 하루가 지났을 때만 지운다 — 표에 있는 파일·막 쓴 파일·다른 이름의 파일은 그대로', async () => {
const known = file(1, 48);
const orphan = file(2, 25);
const fresh = file(3, 1);
writeFileSync(join(dir, 'README.txt'), '그림이 아니다');
repository.listFileNames.mockResolvedValue(new Set([known]));
await expect(cleanup.purgeUnattachedImages(NOW)).resolves.toBe(1);
const left = (await storage.list()).map((stored) => stored.fileName).sort();
expect(left).toEqual([known, fresh].sort());
expect(left).not.toContain(orphan);
});
it('찾은 뒤 그사이 서명에 붙은 그림은 지우지 않는다 — 실제로 지운 행의 파일만 지운다', async () => {
const stillUnattached = file(1, 30);
const attachedMeanwhile = file(2, 30);
repository.findUnattachedBefore.mockResolvedValue([
{ id: idOf(1), fileName: stillUnattached } as MailSignatureImageEntity,
{ id: idOf(2), fileName: attachedMeanwhile } as MailSignatureImageEntity,
]);
// 저장소는 지우는 순간 attached_at IS NULL 을 다시 본다 — 2번은 그사이 붙어 지우지 않았다
repository.deleteUnattachedImages.mockResolvedValue([
{ id: idOf(1), fileName: stillUnattached },
]);
repository.listFileNames.mockResolvedValue(new Set([attachedMeanwhile]));
await expect(cleanup.purgeUnattachedImages(NOW)).resolves.toBe(1);
expect(repository.deleteUnattachedImages).toHaveBeenCalledWith([
idOf(1),
idOf(2),
]);
expect((await storage.list()).map((stored) => stored.fileName)).toEqual([
attachedMeanwhile,
]);
});
it('정리에 실패해도 던지지 않는다 — 오류 로그를 남기고 다음 주기에 다시 한다', async () => {
const errorSpy = jest
.spyOn(Logger.prototype, 'error')
.mockImplementation(() => undefined);
repository.findUnattachedBefore.mockRejectedValue(new Error('db down'));
await expect(cleanup.purgeUnattachedImages(NOW)).resolves.toBe(0);
expect(errorSpy).toHaveBeenCalledWith(
expect.stringContaining('[SYS_001] 서명 그림 정리에 실패했습니다'),
);
});
it('지울 것이 없으면 아무것도 지우지 않는다 (폴더가 아직 없어도)', async () => {
rmSync(dir, { recursive: true, force: true });
await expect(cleanup.purgeUnattachedImages(NOW)).resolves.toBe(0);
expect(repository.deleteUnattachedImages).toHaveBeenCalledWith([]);
});
});
@@ -0,0 +1,60 @@
import { Injectable, Logger } from '@nestjs/common';
import { Cron, CronExpression } from '@nestjs/schedule';
import { UNATTACHED_SIGNATURE_IMAGE_RETENTION_MS } from './mail-signature.constants';
import { MailSignatureRepository } from './mail-signature.repository';
import { SignatureImageStorage } from './signature-image-storage';
/**
* 서명 그림 정리 — 하루에 한 번 (auth-session.cleanup.ts 와 같은 모양).
* - 올리고 서명에 저장하지 않은 채 하루가 지난 그림을 지운다 (표와 파일)
* - 표에 없는 파일(올리다 끊긴 것 등)도 하루가 지났으면 지운다
*
* ⚠️ 여러 인스턴스로 늘리면 모든 인스턴스가 같은 시각에 같은 일을 한다 (결과는 같지만 낭비).
*/
@Injectable()
export class MailSignatureImageCleanup {
private readonly logger = new Logger(MailSignatureImageCleanup.name);
constructor(
private readonly repository: MailSignatureRepository,
private readonly storage: SignatureImageStorage,
) {}
@Cron(CronExpression.EVERY_DAY_AT_4AM, {
name: 'mail-signature-image-cleanup',
})
async purgeUnattachedImages(now: Date = new Date()): Promise<number> {
try {
const before = new Date(
now.getTime() - UNATTACHED_SIGNATURE_IMAGE_RETENTION_MS,
);
// 찾은 뒤 그사이 서명에 붙은 그림은 지우지 않는다 — 실제로 지운 행의 파일만 지운다
const unattached = await this.repository.findUnattachedBefore(before);
const deleted = await this.repository.deleteUnattachedImages(
unattached.map((image) => image.id),
);
for (const image of deleted) await this.storage.remove(image.fileName);
const known = await this.repository.listFileNames();
const orphans = (await this.storage.list()).filter(
(file) => !known.has(file.fileName) && file.modifiedAt < before,
);
for (const file of orphans) await this.storage.remove(file.fileName);
const removed = deleted.length + orphans.length;
// 반복되는 정상 작업이라 info 로 남기지 않는다 (.claude/rules/logging.md)
if (removed > 0) {
this.logger.debug(`서명 그림 ${removed}개를 지웠습니다.`);
}
return removed;
} catch (error) {
// 정리 작업이 실패해도 앱은 계속 돈다 — 다음 주기에 다시 시도한다
this.logger.error(
`[SYS_001] 서명 그림 정리에 실패했습니다: ${error instanceof Error ? error.message : String(error)}`,
);
return 0;
}
}
}
@@ -0,0 +1,57 @@
import { join } from 'path';
import { INLINE_IMAGE_MAX_BYTES } from '@/modules/mail/html/inline-images';
import { UPLOAD_DIR } from '@/modules/upload/upload.config';
/**
* 서명 한도 — 계약 2절 "서명"의 숫자를 한 곳에 모은다.
*/
/**
* 서명 그림 한 장 최대 크기 — 받은 메일의 내장 그림을 본문에 넣는 한도와 **같은 값**이다.
* 더 크게 받으면 보낸 메일을 우리 웹메일로 다시 읽을 때(보낸편지함·받는 사람) 그림이 빠진다 (html/inline-images.ts).
*/
export const SIGNATURE_IMAGE_MAX_BYTES = INLINE_IMAGE_MAX_BYTES;
/**
* 서명 그림의 가로·세로 상한(픽셀, 각각) — 넘으면 올리기가 400 FILE_001.
* 300KB 안에도 수만×수만 픽셀(압축 폭탄)이 들 수 있고, PNG 머리의 가로·세로(32비트)는 표의 integer 열을 넘을 수 있다.
* 서명에 넣는 폭은 600px 까지라(보내는 정화의 width) 충분히 넉넉하다.
*/
export const SIGNATURE_IMAGE_MAX_DIMENSION = 4096;
/** 서명 하나에 넣을 수 있는 서로 다른 그림 수 — 넘으면 서명 저장(PUT)이 400 VAL_001 */
export const SIGNATURE_IMAGES_PER_SIGNATURE = 3;
/**
* 한 사람이 둘 수 있는 서명 그림 수 — 서명에 붙은 것 + 올리고 아직 저장하지 않은 것. 넘으면 올리기가 400 VAL_001.
* 편집기에서 그림을 넣었다 빼기를 되풀이해도(빼도 저장 전·10분 유예 동안은 남는다) 막히지 않게 서명 한도보다 넉넉히 둔다.
* 저장하지 않은 그림은 하루 뒤 정리 작업이, 서명이 더는 가리키지 않는 그림은 다음 저장이 지운다.
*/
export const SIGNATURE_IMAGES_STORED_MAX = 10;
/** 서명 서식(HTML) 최대 길이 — 정화한 뒤 JS 문자열 길이 */
export const SIGNATURE_HTML_MAX_LENGTH = 10_000;
/** 텍스트 서명 최대 길이 — JS 문자열 길이 (웹 클라이언트가 세는 방법과 같다) */
export const SIGNATURE_TEXT_MAX_LENGTH = 2_000;
/** 올리고 서명에 저장하지 않은 그림을 지우기까지의 시간 */
export const UNATTACHED_SIGNATURE_IMAGE_RETENTION_MS = 24 * 60 * 60 * 1000;
/**
* 서명 저장(PUT)이 지우지 않고 남겨 두는, 막 올린 그림의 나이.
* 올리기와 저장이 동시에 오면(그림을 넣자마자 [저장]) 저장 본문에는 아직 그 그림이 없다 — 그때 지우면
* 편집기에 넣은 그림이 사라진다. 이 시간보다 오래된, 한 번도 저장하지 않은 그림은 저장할 때 지운다
* (그 밖에는 하루 뒤 정리 작업이 지운다).
*/
export const SIGNATURE_IMAGE_UPLOAD_GRACE_MS = 10 * 60_000;
/**
* 서명 그림 파일 위치 — 업로드 볼륨(modules/upload 와 같은 UPLOAD_DIR) 안의 하위 폴더.
* 운영은 named volume, 개발은 바인드 마운트다 (.claude/skills/docker/SKILL.md).
*/
export const SIGNATURE_IMAGE_DIR = join(UPLOAD_DIR, 'mail-signatures');
/** 그림 응답의 브라우저 캐시 — 그림 id 는 내용이 바뀌지 않는다(새로 올리면 새 id). 본인만 보므로 private */
export const SIGNATURE_IMAGE_CACHE_CONTROL = 'private, max-age=86400';
@@ -0,0 +1,178 @@
import {
Body,
Controller,
Get,
HttpStatus,
Param,
Post,
Put,
Res,
StreamableFile,
UploadedFile,
UseGuards,
UseInterceptors,
} from '@nestjs/common';
import { FileInterceptor } from '@nestjs/platform-express';
import {
ApiBody,
ApiConsumes,
ApiCookieAuth,
ApiOperation,
ApiProduces,
ApiResponse,
ApiTags,
} from '@nestjs/swagger';
import type { Response } from 'express';
import {
ApiErrorResponse,
ApiStandardResponse,
} from '@/common/decorators/api-standard-response.decorator';
import {
CurrentUser,
type AuthenticatedUser,
} from '@/common/decorators/current-user.decorator';
import { SkipTransform } from '@/common/decorators/skip-transform.decorator';
import { BusinessException } from '@/common/errors/business.exception';
import { JwtAuthGuard } from '@/common/guards/jwt-auth.guard';
import {
MailSignatureDto,
SignatureImageDto,
UpdateMailSignatureDto,
} from './dto/mail-signature.dto';
import {
SIGNATURE_HTML_MAX_LENGTH,
SIGNATURE_IMAGES_PER_SIGNATURE,
SIGNATURE_IMAGES_STORED_MAX,
SIGNATURE_IMAGE_CACHE_CONTROL,
SIGNATURE_IMAGE_MAX_BYTES,
SIGNATURE_TEXT_MAX_LENGTH,
} from './mail-signature.constants';
import { MailSignatureService } from './mail-signature.service';
import { SIGNATURE_IMAGE_EXTENSIONS } from './signature-image-format';
import { SignatureImageIdPipe } from './signature-image-id.pipe';
/** 서명 그림 응답 형식 — 저장할 때 파일 내용으로 확인한 형식 그대로 */
const IMAGE_CONTENT_TYPES = ['image/png', 'image/jpeg', 'image/gif'];
// 메일 서명 (계약 2절 "서명") — 로그인한 직원 자신의 서명·그림만 다룬다.
// 그림 업로드는 modules/upload 패턴을 따른다 (가드가 multer 보다 먼저 돌아 미인증 요청은 파일을 읽기 전에 막힌다).
@ApiTags('Mail')
@ApiCookieAuth()
@UseGuards(JwtAuthGuard)
@ApiErrorResponse('AUTH_001', '로그인 필요 (쿠키 없음·만료)')
@Controller('mail-signature')
export class MailSignatureController {
constructor(private readonly signatures: MailSignatureService) {}
@Get()
@ApiOperation({
summary: '내 서명',
description:
'저장한 적 없으면 기본값(bodyHtml·body null, isForNew·isForReply true, images [])을 돌려준다.',
})
@ApiStandardResponse(MailSignatureDto, { description: '서명' })
getSignature(@CurrentUser() user: AuthenticatedUser) {
return this.signatures.getSignature(user.email);
}
@Put()
@ApiOperation({
summary: '서명 저장 (전체 교체)',
description:
`bodyHtml 은 서식 본문 허용 목록 + 내 서명 그림으로 정화해 저장한다(정화 뒤 ${SIGNATURE_HTML_MAX_LENGTH.toLocaleString('ko-KR')}자 이하), ` +
`body 는 ${SIGNATURE_TEXT_MAX_LENGTH.toLocaleString('ko-KR')}자 이하, 서로 다른 그림은 ${SIGNATURE_IMAGES_PER_SIGNATURE}장까지(같은 그림 두 번은 한 장). ` +
'서명 HTML 이 더는 가리키지 않는 그림은 지운다 — 막 올리고(10분 안) 아직 넣지 않은 그림은 남긴다.',
})
@ApiStandardResponse(MailSignatureDto, { description: '저장한 서명' })
@ApiErrorResponse(
'VAL_001',
`형식 오류 · 텍스트 서명 2,000자 초과 · 정화한 서식 서명 10,000자 초과 · 서식 서명이 너무 깊게 중첩됨 · 그림 ${SIGNATURE_IMAGES_PER_SIGNATURE}장 초과`,
)
saveSignature(
@CurrentUser() user: AuthenticatedUser,
@Body() dto: UpdateMailSignatureDto,
) {
return this.signatures.saveSignature(user.email, dto);
}
@Post('images')
@UseInterceptors(FileInterceptor('file'))
@ApiOperation({
summary: '서명 그림 올리기 (파일 1개)',
description:
`PNG·JPEG·GIF 만 — 확장자·MIME·파일 앞부분(내용)을 모두 본다(WebP·SVG 는 받지 않는다). ` +
`한 장 ${SIGNATURE_IMAGE_MAX_BYTES.toLocaleString('ko-KR')}바이트 이하. 한 사람이 둘 수 있는 그림은 ${SIGNATURE_IMAGES_STORED_MAX}장까지 — 서명에 붙은 것 + 올리고 아직 저장하지 않은 것 ` +
`(서명 하나에는 ${SIGNATURE_IMAGES_PER_SIGNATURE}장 — PUT 이 본다). ` +
'서명을 저장하지 않으면 하루 뒤 지운다.',
})
@ApiConsumes('multipart/form-data')
@ApiBody({
schema: {
type: 'object',
properties: { file: { type: 'string', format: 'binary' } },
required: ['file'],
},
})
@ApiStandardResponse(SignatureImageDto, {
status: HttpStatus.CREATED,
description: '올린 그림 — 이 id 를 서명 HTML 의 img 주소에 쓴다',
})
@ApiErrorResponse(
'FILE_001',
'허용되지 않는 형식 · 내용이 확장자·MIME 과 다른 형식 (400)',
)
@ApiErrorResponse(
'VAL_001',
`파일 없음 · 둘 수 있는 그림 ${SIGNATURE_IMAGES_STORED_MAX}장 초과 (400)`,
)
@ApiErrorResponse('FILE_002', '한 장 300KB 초과')
uploadImage(
@CurrentUser() user: AuthenticatedUser,
@UploadedFile() file?: Express.Multer.File,
) {
if (!file?.buffer) {
// 파일 없이 보낸 요청 — multer 는 오류를 내지 않으므로 여기서 막는다
throw new BusinessException('VAL_001', {
context: { reason: 'signature-image-file-missing' },
});
}
return this.signatures.uploadImage(user.email, {
originalName: file.originalname,
mimeType: file.mimetype,
data: file.buffer,
});
}
@Get('images/:id')
@SkipTransform()
@ApiOperation({
summary: '서명 그림 (표준 래퍼 없음, 내 그림만)',
description:
'편집기·미리 보기용 — 받는 사람은 이 주소를 쓰지 않는다(보낼 때 메일 안에 함께 싣는다). ' +
'형식은 저장할 때 확인한 것, X-Content-Type-Options: nosniff, Content-Disposition: inline, 캐시는 private.',
})
@ApiProduces(...IMAGE_CONTENT_TYPES)
@ApiResponse({
status: HttpStatus.OK,
description: '그림 파일',
schema: { type: 'string', format: 'binary' },
})
@ApiErrorResponse('RES_001', '없는 그림 · 남의 그림 · 형식이 틀린 id')
async downloadImage(
@CurrentUser() user: AuthenticatedUser,
@Param('id', SignatureImageIdPipe) id: string,
@Res({ passthrough: true }) res: Response,
): Promise<StreamableFile> {
const image = await this.signatures.openImage(user.email, id);
// 본인만 보는 그림 — 공유 캐시에 남기지 않는다. 내용은 바뀌지 않으므로(새로 올리면 새 id) 브라우저는 하루 둔다
res.setHeader('Cache-Control', SIGNATURE_IMAGE_CACHE_CONTROL);
res.setHeader('X-Content-Type-Options', 'nosniff');
return new StreamableFile(image.data, {
type: image.type,
disposition: `inline; filename="signature.${SIGNATURE_IMAGE_EXTENSIONS[image.type]}"`,
length: image.data.length,
});
}
}
@@ -0,0 +1,36 @@
import { Column, Entity, PrimaryColumn } from 'typeorm';
/** 메일 주소 최대 길이 (RFC 5321 경로 한도) — mail.constants 의 MAX_EMAIL_LENGTH 와 같다 */
const EMAIL_COLUMN_LENGTH = 254;
/**
* 메일 서명 (계약 2절 "서명") — 사용자마다 하나. 메일 엔진(JMAP Identity)이 아니라 우리 DB 에 둔다 —
* 엔진을 바꿔도 남고, DB 백업에 함께 들어간다.
* HTML 은 저장하기 전에 보내는 정화(서명 그림 포함)를 거친 값이다.
*/
@Entity({ name: 'mail_signatures' })
export class MailSignatureEntity {
@PrimaryColumn({
name: 'user_email',
type: 'varchar',
length: EMAIL_COLUMN_LENGTH,
})
userEmail!: string;
@Column({ name: 'body_html', type: 'text', nullable: true })
bodyHtml!: string | null;
@Column({ name: 'body_text', type: 'text', nullable: true })
bodyText!: string | null;
/** 새 메일에 넣는다 */
@Column({ name: 'is_for_new', type: 'boolean', default: true })
isForNew!: boolean;
/** 답장·전달에 넣는다 (인용 위) */
@Column({ name: 'is_for_reply', type: 'boolean', default: true })
isForReply!: boolean;
@Column({ name: 'updated_at', type: 'timestamptz' })
updatedAt!: Date;
}
@@ -0,0 +1,62 @@
import { Module } from '@nestjs/common';
import { MulterModule } from '@nestjs/platform-express';
import { TypeOrmModule } from '@nestjs/typeorm';
import { memoryStorage } from 'multer';
import { extname } from 'path';
import { BusinessException } from '@/common/errors/business.exception';
import { MailSignatureImageEntity } from './mail-signature-image.entity';
import { MailSignatureImageCleanup } from './mail-signature.cleanup';
import { SIGNATURE_IMAGE_MAX_BYTES } from './mail-signature.constants';
import { MailSignatureController } from './mail-signature.controller';
import { MailSignatureEntity } from './mail-signature.entity';
import { MailSignatureRepository } from './mail-signature.repository';
import { MailSignatureService } from './mail-signature.service';
import { SIGNATURE_IMAGE_TYPES_BY_EXTENSION } from './signature-image-format';
import { SignatureImageStorage } from './signature-image-storage';
/**
* 메일 서명 모듈 (계약 2절 "서명") — 서명·그림은 서버 DB 와 업로드 볼륨에 둔다.
* 메일 모듈이 발송·임시 저장 때 그림을 쓰도록 서비스를 내보낸다 (이 모듈은 메일 모듈을 가져오지 않는다).
*
* 그림 업로드: 한 장 300KB 이하라 메모리에 받는다 — 파일 내용(앞부분)을 본 뒤에만 디스크에 서버가 만든 이름으로 쓴다.
* 형식 사전 검사는 확장자 + MIME 짝(FILE_001), 크기 초과는 multer → 413(FILE_002).
*/
@Module({
imports: [
TypeOrmModule.forFeature([MailSignatureEntity, MailSignatureImageEntity]),
MulterModule.register({
storage: memoryStorage(),
limits: { fileSize: SIGNATURE_IMAGE_MAX_BYTES, files: 1, fields: 10 },
// 브라우저는 파일명을 UTF-8 그대로 보낸다 — 기본값(latin1)이면 한글 이름이 깨진다
defParamCharset: 'utf8',
fileFilter: (_req, file, callback) => {
const type = SIGNATURE_IMAGE_TYPES_BY_EXTENSION.get(
extname(file.originalname).toLowerCase(),
);
if (!type || type !== file.mimetype) {
callback(
new BusinessException('FILE_001', {
context: { reason: 'signature-image-type-not-allowed' },
}),
false,
);
return;
}
callback(null, true);
},
}),
],
controllers: [MailSignatureController],
providers: [
MailSignatureRepository,
{
provide: SignatureImageStorage,
useFactory: () => new SignatureImageStorage(),
},
MailSignatureService,
MailSignatureImageCleanup,
],
exports: [MailSignatureService],
})
export class MailSignatureModule {}
@@ -0,0 +1,167 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { DataSource, In, IsNull, LessThan, type Repository } from 'typeorm';
import { MailSignatureImageEntity } from './mail-signature-image.entity';
import { MailSignatureEntity } from './mail-signature.entity';
/**
* 서명 저장을 사용자별로 차례대로 — `pg_advisory_xact_lock(이 값, hashtext(주소))` (트랜잭션이 끝나면 풀린다).
* 서명 행이 아직 없을 때도 잠긴다 (행 잠금 FOR UPDATE 는 없는 행을 잠그지 못한다). 첫 인자는 다른 잠금과 섞이지 않게 붙인 이름표 — 'SIG1' 의 ASCII.
*/
const SIGNATURE_SAVE_LOCK = 0x53494731;
/** 서명 저장 — 잠근 뒤 본 그림으로 저장할 서명을 정한다 (서비스가 그사이 없어진 그림을 서명에서 뺀다) */
export type PrepareSignature = (
images: readonly MailSignatureImageEntity[],
) => {
readonly signature: MailSignatureEntity;
readonly referencedImageIds: readonly string[];
};
/** 서명 저장 결과 */
export interface SavedSignature {
readonly signature: MailSignatureEntity;
/** 저장한 뒤 남은 그 사람의 그림 (올린 순서) */
readonly images: MailSignatureImageEntity[];
/** 지운 그림 — 파일은 부르는 쪽이 커밋 뒤에 지운다 */
readonly removed: MailSignatureImageEntity[];
}
/**
* 서명·서명 그림 표 접근 — 판단(정화·한도·누가 볼 수 있나)은 MailSignatureService 가 한다.
* 그림을 찾을 때는 늘 주인(user_email)을 함께 조건에 넣는다 — 남의 그림 id 로는 아무것도 나오지 않는다.
*/
@Injectable()
export class MailSignatureRepository {
constructor(
@InjectRepository(MailSignatureEntity)
private readonly signatures: Repository<MailSignatureEntity>,
@InjectRepository(MailSignatureImageEntity)
private readonly images: Repository<MailSignatureImageEntity>,
private readonly dataSource: DataSource,
) {}
findSignature(userEmail: string): Promise<MailSignatureEntity | null> {
return this.signatures.findOneBy({ userEmail });
}
/** 그 사람의 그림 — 올린 순서대로 */
listImages(userEmail: string): Promise<MailSignatureImageEntity[]> {
return this.images.find({
where: { userEmail },
order: { createdAt: 'ASC' },
});
}
findImage(
id: string,
userEmail: string,
): Promise<MailSignatureImageEntity | null> {
return this.images.findOneBy({ id, userEmail });
}
countImages(userEmail: string): Promise<number> {
return this.images.countBy({ userEmail });
}
async insertImage(image: MailSignatureImageEntity): Promise<void> {
await this.images.insert(image);
}
/**
* 서명을 저장한다 — 한 트랜잭션에서, 사용자별로 차례대로 (두 탭이 동시에 저장해도 서로의 그림을 엉뚱하게 지우지 않게).
* 1) 사용자 잠금 2) 그 사람의 그림을 잠가 읽는다(FOR UPDATE — 정리 작업이 그사이 지우지 못한다) 3) `prepare` 가 저장할 서명을 정한다
* 4) 서명을 넣거나 고치고, 서명이 가리키는 그림은 붙은 것으로 표시하고, 가리키지 않는 그림은 지운다 —
* 다만 한 번도 저장하지 않은 그림 중 `keepUploadedAfter` 뒤에 올린 것은 남긴다 (막 올린 그림 — SIGNATURE_IMAGE_UPLOAD_GRACE_MS).
*/
saveSignature(
userEmail: string,
prepare: PrepareSignature,
now: Date,
keepUploadedAfter: Date,
): Promise<SavedSignature> {
return this.dataSource.transaction(async (manager) => {
await manager.query('SELECT pg_advisory_xact_lock($1, hashtext($2))', [
SIGNATURE_SAVE_LOCK,
userEmail,
]);
const images = await manager.find(MailSignatureImageEntity, {
where: { userEmail },
order: { createdAt: 'ASC' },
lock: { mode: 'pessimistic_write' },
});
const { signature, referencedImageIds } = prepare(images);
await manager.upsert(MailSignatureEntity, signature, ['userEmail']);
const referenced = new Set(referencedImageIds);
const attach = images
.filter(
(image) => referenced.has(image.id) && image.attachedAt === null,
)
.map((image) => image.id);
const stale = images.filter(
(image) =>
!referenced.has(image.id) &&
(image.attachedAt !== null || image.createdAt <= keepUploadedAfter),
);
if (attach.length > 0) {
await manager.update(
MailSignatureImageEntity,
{ id: In(attach) },
{ attachedAt: now },
);
}
if (stale.length > 0) {
await manager.delete(MailSignatureImageEntity, {
id: In(stale.map((image) => image.id)),
});
}
const attached = new Set(attach);
return {
signature,
images: images
.filter((image) => !stale.includes(image))
.map((image) =>
attached.has(image.id) ? { ...image, attachedAt: now } : image,
),
removed: stale,
};
});
}
/** 서명에 붙지 않은 채 `before` 전에 올린 그림 (정리 작업용 — 부분 인덱스를 탄다) */
findUnattachedBefore(before: Date): Promise<MailSignatureImageEntity[]> {
return this.images.find({
where: { attachedAt: IsNull(), createdAt: LessThan(before) },
});
}
/**
* 서명에 붙지 않은 그림을 지운다 — 지우는 그 순간에도 붙지 않았을 때만(`attached_at IS NULL` 을 다시 본다).
* 찾은 뒤 그사이 서명에 붙은 그림은 남는다 (서명 저장은 그림 행을 잠그므로 저장이 끝난 뒤의 값으로 다시 본다).
* @returns 실제로 지운 그림 — 이 그림들의 파일만 지운다
*/
async deleteUnattachedImages(
ids: readonly string[],
): Promise<Pick<MailSignatureImageEntity, 'id' | 'fileName'>[]> {
if (ids.length === 0) return [];
const result = await this.images
.createQueryBuilder()
.delete()
.where('id IN (:...ids)', { ids: [...ids] })
.andWhere('attached_at IS NULL')
// 배열은 엔티티 속성 이름이다 — SQL 은 RETURNING "id", "file_name" (행의 키는 열 이름)
.returning(['id', 'fileName'])
.execute();
const rows = (result.raw ?? []) as { id: string; file_name: string }[];
return rows.map((row) => ({ id: row.id, fileName: row.file_name }));
}
/** 표에 있는 모든 그림의 파일 이름 (정리 작업이 표에 없는 파일을 찾는다) */
async listFileNames(): Promise<Set<string>> {
const rows = await this.images.find({ select: { fileName: true } });
return new Set(rows.map((row) => row.fileName));
}
}
@@ -0,0 +1,577 @@
import { Logger } from '@nestjs/common';
import { existsSync, mkdtempSync, rmSync } from 'fs';
import { tmpdir } from 'os';
import { join } from 'path';
import { gifBytes, pngBytes } from '@/../test/image-fixtures';
import { BusinessException } from '@/common/errors/business.exception';
import { MAX_BODY_HTML_LENGTH } from '@/modules/mail/mail.constants';
import { MailSignatureImageEntity } from './mail-signature-image.entity';
import {
SIGNATURE_IMAGES_PER_SIGNATURE,
SIGNATURE_IMAGES_STORED_MAX,
SIGNATURE_IMAGE_MAX_BYTES,
SIGNATURE_IMAGE_MAX_DIMENSION,
SIGNATURE_IMAGE_UPLOAD_GRACE_MS,
} from './mail-signature.constants';
import { MailSignatureEntity } from './mail-signature.entity';
import type {
MailSignatureRepository,
PrepareSignature,
SavedSignature,
} from './mail-signature.repository';
import { MailSignatureService } from './mail-signature.service';
import { SignatureImageStorage } from './signature-image-storage';
const ARA = 'ara.kim@arasystem.test';
const DOYUN = 'doyun.lee@arasystem.test';
const src = (id: string) => `/api/mail-signature/images/${id}`;
/**
* 표 대역 — MailSignatureRepository 와 같은 판단(주인 조건·저장 때 지울 그림)을 메모리에서 한다.
* 실제 SQL(트랜잭션·부분 인덱스)은 e2e 가 본다.
*/
class FakeRepository {
readonly signatures = new Map<string, MailSignatureEntity>();
images: MailSignatureImageEntity[] = [];
/** 다음 insertImage 를 실패시킨다 */
failNextInsert = false;
/** 다음 저장이 잠그기 직전에 이 그림이 지워진다 (다른 탭의 저장·정리 작업이 먼저 끝난 경우) */
deletedBeforeNextSave: string | null = null;
readonly saveCalls: {
referenced: readonly string[];
keepUploadedAfter: Date;
}[] = [];
findSignature(user: string): Promise<MailSignatureEntity | null> {
return Promise.resolve(this.signatures.get(user) ?? null);
}
listImages(user: string): Promise<MailSignatureImageEntity[]> {
return Promise.resolve(this.images.filter((row) => row.userEmail === user));
}
findImage(
id: string,
user: string,
): Promise<MailSignatureImageEntity | null> {
return Promise.resolve(
this.images.find((row) => row.id === id && row.userEmail === user) ??
null,
);
}
countImages(user: string): Promise<number> {
return Promise.resolve(
this.images.filter((row) => row.userEmail === user).length,
);
}
insertImage(row: MailSignatureImageEntity): Promise<void> {
if (this.failNextInsert) {
this.failNextInsert = false;
return Promise.reject(new Error('db down'));
}
this.images.push(row);
return Promise.resolve();
}
saveSignature(
user: string,
prepare: PrepareSignature,
now: Date,
keepUploadedAfter: Date,
): Promise<SavedSignature> {
if (this.deletedBeforeNextSave) {
const gone = this.deletedBeforeNextSave;
this.deletedBeforeNextSave = null;
this.images = this.images.filter((row) => row.id !== gone);
}
const mine = this.images.filter((row) => row.userEmail === user);
const { signature, referencedImageIds: referenced } = prepare(mine);
this.saveCalls.push({ referenced, keepUploadedAfter });
this.signatures.set(signature.userEmail, signature);
for (const row of mine) {
if (referenced.includes(row.id) && row.attachedAt === null) {
row.attachedAt = now;
}
}
const stale = mine.filter(
(row) =>
!referenced.includes(row.id) &&
(row.attachedAt !== null || row.createdAt <= keepUploadedAfter),
);
this.images = this.images.filter((row) => !stale.includes(row));
return Promise.resolve({
signature,
images: mine.filter((row) => !stale.includes(row)),
removed: stale,
});
}
}
describe('MailSignatureService', () => {
let dir: string;
let repository: FakeRepository;
let storage: SignatureImageStorage;
let service: MailSignatureService;
let warnSpy: jest.SpyInstance;
const upload = (data: Buffer, name = 'logo.png', mimeType = 'image/png') =>
service.uploadImage(ARA, { originalName: name, mimeType, data });
async function failureOf(
run: () => Promise<unknown>,
): Promise<{ code?: string; reason?: unknown }> {
try {
await run();
} catch (error) {
return error instanceof BusinessException
? { code: error.code, reason: error.context?.reason }
: { code: 'NOT_BUSINESS' };
}
return {};
}
/** 이미 올려 둔 그림 한 장 (표 + 파일) */
async function storedImage(
overrides: Partial<MailSignatureImageEntity> = {},
): Promise<MailSignatureImageEntity> {
const id =
overrides.id ??
`0000000${repository.images.length}-0000-4000-8000-000000000000`;
const row: MailSignatureImageEntity = {
id,
userEmail: ARA,
contentType: 'image/png',
size: 33,
width: 10,
height: 10,
fileName: `${id}.png`,
createdAt: new Date('2026-10-26T00:00:00Z'),
attachedAt: null,
...overrides,
};
repository.images.push(row);
await storage.write(row.fileName, pngBytes(row.width, row.height));
return row;
}
beforeEach(() => {
dir = mkdtempSync(join(tmpdir(), 'signature-images-'));
repository = new FakeRepository();
storage = new SignatureImageStorage(dir);
service = new MailSignatureService(
repository as unknown as MailSignatureRepository,
storage,
);
warnSpy = jest
.spyOn(Logger.prototype, 'warn')
.mockImplementation(() => undefined);
});
afterEach(() => {
jest.restoreAllMocks();
rmSync(dir, { recursive: true, force: true });
});
it('저장한 적 없으면 기본값을 돌려준다', async () => {
await expect(service.getSignature(ARA)).resolves.toEqual({
bodyHtml: null,
body: null,
isForNew: true,
isForReply: true,
images: [],
updatedAt: null,
});
});
describe('저장', () => {
it('서식 서명은 보내는 정화를 거쳐 저장한다 — 내 그림만 남고, 서명이 쓰는 그림을 함께 돌려준다', async () => {
const mine = await storedImage();
const html = `<p onclick="x()"><strong>김아라</strong><script>alert(1)</script></p><img src="${src(mine.id)}" width="144" alt="" style="border:0"><img src="${src('11111111-1111-4111-8111-111111111111')}">`;
const saved = await service.saveSignature(` ${ARA.toUpperCase()} `, {
bodyHtml: html,
body: '김아라 · 아라시스템',
isForNew: true,
isForReply: false,
});
expect(saved).toEqual({
bodyHtml: `<p><strong>김아라</strong></p><img src="${src(mine.id)}" width="144" alt="" />`,
body: '김아라 · 아라시스템',
isForNew: true,
isForReply: false,
images: [
{ id: mine.id, type: 'image/png', size: 33, width: 10, height: 10 },
],
updatedAt: expect.any(String) as string,
});
// 주소는 소문자로 모은다 — 같은 사람의 서명이 둘로 나뉘지 않게
expect(repository.signatures.has(ARA)).toBe(true);
expect(repository.saveCalls[0].referenced).toEqual([mine.id]);
await expect(service.getSignature(ARA)).resolves.toEqual(saved);
});
it('막 올린 그림(10분 안)은 서명에 없어도 남기도록 기준 시각을 넘긴다 — 올리기와 저장이 엇갈려도 편집기의 그림이 사라지지 않게', async () => {
const before = Date.now();
await service.saveSignature(ARA, {
bodyHtml: null,
body: null,
isForNew: true,
isForReply: true,
});
const { keepUploadedAfter } = repository.saveCalls[0];
expect(keepUploadedAfter.getTime()).toBeGreaterThanOrEqual(
before - SIGNATURE_IMAGE_UPLOAD_GRACE_MS,
);
expect(keepUploadedAfter.getTime()).toBeLessThanOrEqual(
Date.now() - SIGNATURE_IMAGE_UPLOAD_GRACE_MS,
);
});
it('잠그기 전에 다른 저장·정리 작업이 지운 그림은 서명에서 뺀다 — 없는 그림을 가리키는 서명을 남기지 않는다', async () => {
const kept = await storedImage();
const gone = await storedImage();
repository.deletedBeforeNextSave = gone.id;
const saved = await service.saveSignature(ARA, {
bodyHtml: `<p>서명</p><img src="${src(kept.id)}" width="96"><img src="${src(gone.id)}" width="96">`,
body: null,
isForNew: true,
isForReply: true,
});
expect(saved.bodyHtml).toBe(
`<p>서명</p><img src="${src(kept.id)}" width="96" />`,
);
expect(saved.images.map((image) => image.id)).toEqual([kept.id]);
expect(repository.saveCalls[0].referenced).toEqual([kept.id]);
// 그림만 있던 서명에서 그림이 모두 사라지면 서식 서명이 없다
repository.deletedBeforeNextSave = kept.id;
const empty = await service.saveSignature(ARA, {
bodyHtml: `<img src="${src(kept.id)}">`,
body: '텍스트 서명',
isForNew: true,
isForReply: true,
});
expect(empty.bodyHtml).toBeNull();
expect(empty.body).toBe('텍스트 서명');
});
it('서명이 더는 가리키지 않는 그림은 표와 파일에서 지운다', async () => {
const kept = await storedImage({ attachedAt: new Date() });
const dropped = await storedImage({ attachedAt: new Date() });
await service.saveSignature(ARA, {
bodyHtml: `<img src="${src(kept.id)}">`,
body: null,
isForNew: true,
isForReply: true,
});
expect(repository.images.map((row) => row.id)).toEqual([kept.id]);
expect(existsSync(join(dir, kept.fileName))).toBe(true);
expect(existsSync(join(dir, dropped.fileName))).toBe(false);
});
it('지운 그림의 파일을 지우지 못해도 저장은 끝난다 — 경고만 (남은 파일은 정리 작업이 지운다)', async () => {
const dropped = await storedImage({ attachedAt: new Date() });
jest
.spyOn(storage, 'remove')
.mockRejectedValue(new Error('EACCES: permission denied'));
await expect(
service.saveSignature(ARA, {
bodyHtml: '<p>그림 없음</p>',
body: null,
isForNew: true,
isForReply: true,
}),
).resolves.toMatchObject({ bodyHtml: '<p>그림 없음</p>', images: [] });
expect(repository.images).not.toContainEqual(dropped);
expect(warnSpy).toHaveBeenCalledWith(
expect.stringContaining('서명 그림 파일을 지우지 못했습니다'),
);
});
it('빈 서식·텍스트, 정화해서 비는 서식은 null 로 저장한다', async () => {
for (const bodyHtml of ['', '<script>x()</script>', null, undefined]) {
const saved = await service.saveSignature(ARA, {
bodyHtml,
body: '',
isForNew: false,
isForReply: true,
});
expect(saved.bodyHtml).toBeNull();
expect(saved.body).toBeNull();
}
});
it(`서명 하나에 서로 다른 그림은 ${SIGNATURE_IMAGES_PER_SIGNATURE}장까지 — 넘으면 400 VAL_001 (같은 그림 두 번은 한 장 · 남의 그림은 정화가 지워 세지 않는다)`, async () => {
const images: MailSignatureImageEntity[] = [];
for (
let index = 0;
index < SIGNATURE_IMAGES_PER_SIGNATURE + 1;
index += 1
) {
images.push(await storedImage());
}
const img = (id: string) => `<img src="${src(id)}" width="96">`;
const save = (bodyHtml: string) =>
service.saveSignature(ARA, {
bodyHtml,
body: null,
isForNew: true,
isForReply: true,
});
expect(
await failureOf(() =>
save(images.map((image) => img(image.id)).join('')),
),
).toEqual({ code: 'VAL_001', reason: 'signature-too-many-images' });
expect(repository.saveCalls).toHaveLength(0);
// 세 장 + 같은 그림 한 번 더 + 남의 그림 — 서로 다른 내 그림은 세 장이다
const [a, b, c] = images;
const saved = await save(
[a, b, c, a].map((image) => img(image.id)).join('') +
img('11111111-1111-4111-8111-111111111111'),
);
expect(saved.images.map((image) => image.id)).toEqual([a.id, b.id, c.id]);
});
it('한도를 넘으면 400 VAL_001 — 저장하지 않는다 (텍스트 2,000자 · 정화한 서식 10,000자 · 정화 전 길이·비용)', async () => {
const cases: [Record<string, string>, string][] = [
[{ body: '가'.repeat(2_001) }, 'signature-text-too-long'],
[
{ bodyHtml: `<p>${'가'.repeat(10_000)}</p>` },
'signature-html-too-long',
],
[
{ bodyHtml: 'x'.repeat(MAX_BODY_HTML_LENGTH + 1) },
'signature-html-too-long',
],
[{ bodyHtml: '<b>'.repeat(166_666) }, 'signature-html-too-complex'],
];
for (const [input, reason] of cases) {
expect(
await failureOf(() =>
service.saveSignature(ARA, {
isForNew: true,
isForReply: true,
...input,
}),
),
).toEqual({ code: 'VAL_001', reason });
}
expect(repository.saveCalls).toHaveLength(0);
// 경계 — 정확히 2,000자 · 정화한 결과 10,000자는 저장한다
const saved = await service.saveSignature(ARA, {
body: '가'.repeat(2_000),
bodyHtml: `<p>${'가'.repeat(10_000 - 7)}</p>`,
isForNew: true,
isForReply: true,
});
expect(saved.bodyHtml).toHaveLength(10_000);
});
});
describe('그림 올리기', () => {
it('PNG 를 내용으로 확인하고 서버가 만든 이름으로 저장한다 — 가로·세로는 파일 머리에서 읽는다', async () => {
const data = pngBytes(480, 120, 2_048);
const image = await upload(data, '로고 (최종).PNG');
expect(image).toEqual({
id: expect.stringMatching(
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/,
) as string,
type: 'image/png',
size: 2_048,
width: 480,
height: 120,
});
const [row] = repository.images;
expect(row).toMatchObject({
userEmail: ARA,
fileName: `${image.id}.png`,
attachedAt: null,
});
expect(existsSync(join(dir, `${image.id}.png`))).toBe(true);
});
it('내용이 확장자·MIME 과 다른 형식이면 FILE_001 — 아무것도 저장하지 않는다', async () => {
for (const [data, name, mimeType] of [
// PNG 내용인데 이름은 JPEG
[pngBytes(10, 10), 'logo.jpg', 'image/jpeg'],
// GIF 내용인데 PNG 라고 한다
[gifBytes(10, 10), 'logo.png', 'image/png'],
// 그림이 아니다
[Buffer.from('<svg onload="x()"></svg>'), 'logo.png', 'image/png'],
// 이름과 MIME 이 서로 다르다
[pngBytes(10, 10), 'logo.png', 'image/gif'],
] as const) {
expect(await failureOf(() => upload(data, name, mimeType))).toEqual({
code: 'FILE_001',
reason: 'signature-image-content-mismatch',
});
}
expect(repository.images).toHaveLength(0);
});
it(`가로·세로가 각각 ${SIGNATURE_IMAGE_MAX_DIMENSION} 픽셀을 넘으면 FILE_001 — 아무것도 저장하지 않는다 (작은 파일 안의 거대한 그림 · 32비트 PNG 값)`, async () => {
for (const [width, height] of [
[SIGNATURE_IMAGE_MAX_DIMENSION + 1, 10],
[10, SIGNATURE_IMAGE_MAX_DIMENSION + 1],
// 표의 integer(2^31-1)를 넘는 PNG 가로 — 예전에는 INSERT 가 500 이 됐다
[2 ** 31, 10],
[2 ** 32 - 1, 2 ** 32 - 1],
]) {
expect(await failureOf(() => upload(pngBytes(width, height)))).toEqual({
code: 'FILE_001',
reason: 'signature-image-dimensions',
});
}
expect(repository.images).toHaveLength(0);
expect(await storage.list()).toEqual([]);
await expect(
upload(
pngBytes(
SIGNATURE_IMAGE_MAX_DIMENSION,
SIGNATURE_IMAGE_MAX_DIMENSION,
),
),
).resolves.toMatchObject({
width: SIGNATURE_IMAGE_MAX_DIMENSION,
height: SIGNATURE_IMAGE_MAX_DIMENSION,
});
});
it('300KB 를 넘으면 FILE_002 (multer 가 먼저 막지만 한 번 더)', async () => {
expect(
await failureOf(() =>
upload(pngBytes(10, 10, SIGNATURE_IMAGE_MAX_BYTES + 1)),
),
).toEqual({ code: 'FILE_002', reason: 'signature-image-too-large' });
await expect(
upload(pngBytes(10, 10, SIGNATURE_IMAGE_MAX_BYTES)),
).resolves.toMatchObject({ size: SIGNATURE_IMAGE_MAX_BYTES });
});
it(`한 사람이 둘 수 있는 그림은 ${SIGNATURE_IMAGES_STORED_MAX}장까지 — 넘으면 400 VAL_001. 동시에 올려도 넘지 않는다`, async () => {
const results = await Promise.allSettled(
Array.from({ length: SIGNATURE_IMAGES_STORED_MAX + 2 }, () =>
upload(pngBytes(10, 10)),
),
);
expect(results.filter((r) => r.status === 'fulfilled')).toHaveLength(
SIGNATURE_IMAGES_STORED_MAX,
);
const rejected = results.flatMap((result): unknown[] =>
result.status === 'rejected' ? [result.reason] : [],
);
expect(rejected).toHaveLength(2);
for (const reason of rejected) {
expect(reason).toBeInstanceOf(BusinessException);
expect((reason as BusinessException).code).toBe('VAL_001');
expect((reason as BusinessException).context?.reason).toBe(
'signature-image-storage-full',
);
}
expect(repository.images).toHaveLength(SIGNATURE_IMAGES_STORED_MAX);
// 다른 사람은 따로 센다
await expect(
service.uploadImage(DOYUN, {
originalName: 'logo.png',
mimeType: 'image/png',
data: pngBytes(10, 10),
}),
).resolves.toMatchObject({ type: 'image/png' });
});
it('표에 넣지 못하면 쓴 파일을 지우고 실패를 그대로 던진다 — 다음 올리기는 된다', async () => {
repository.failNextInsert = true;
expect(await failureOf(() => upload(pngBytes(10, 10)))).toEqual({
code: 'NOT_BUSINESS',
});
expect(repository.images).toHaveLength(0);
expect(await storage.list()).toEqual([]);
await expect(upload(pngBytes(10, 10))).resolves.toMatchObject({
type: 'image/png',
});
});
});
describe('그림 읽기', () => {
it('주인에게는 파일과 형식을 준다 — 남의 그림·없는 그림은 RES_001', async () => {
const mine = await storedImage({ width: 7, height: 3 });
await expect(service.openImage(ARA, mine.id)).resolves.toEqual({
data: pngBytes(7, 3),
type: 'image/png',
});
for (const [user, id] of [
[DOYUN, mine.id],
[ARA, '11111111-1111-4111-8111-111111111111'],
]) {
expect(await failureOf(() => service.openImage(user, id))).toEqual({
code: 'RES_001',
reason: 'signature-image-not-found',
});
}
});
it('표에는 있는데 파일이 없으면 RES_001 — 경고를 남긴다', async () => {
const mine = await storedImage();
await storage.remove(mine.fileName);
expect(await failureOf(() => service.openImage(ARA, mine.id))).toEqual({
code: 'RES_001',
reason: 'signature-image-not-found',
});
expect(warnSpy).toHaveBeenCalledWith(
expect.stringContaining('서명 그림 파일이 없습니다'),
);
});
it('발송용 — 내 그림 id 전부, 그리고 요청한 순서대로 내 그림만 읽는다 (파일이 없는 그림은 빼고 경고)', async () => {
const first = await storedImage();
const second = await storedImage({ attachedAt: new Date() });
const missing = await storedImage();
await storage.remove(missing.fileName);
const others = await storedImage({ userEmail: DOYUN });
await expect(service.imageIdsOf(ARA)).resolves.toEqual(
new Set([first.id, second.id, missing.id]),
);
const loaded = await service.loadImages(ARA, [
second.id,
others.id,
missing.id,
first.id,
]);
expect(loaded.map((image) => image.id)).toEqual([second.id, first.id]);
expect(loaded[0]).toEqual({
id: second.id,
type: 'image/png',
data: pngBytes(10, 10),
});
expect(warnSpy).toHaveBeenCalledTimes(2);
await expect(service.loadImages(ARA, [])).resolves.toEqual([]);
});
});
});
@@ -0,0 +1,372 @@
import { Injectable, Logger } from '@nestjs/common';
import { randomUUID } from 'crypto';
import { BusinessException } from '@/common/errors/business.exception';
import { checkHtmlParseBudget } from '@/modules/mail/html/html-parse-budget';
import { MAX_BODY_HTML_LENGTH } from '@/modules/mail/mail.constants';
import { sanitizeOutgoingHtml } from '@/modules/mail/html/sanitize-outgoing-html';
import {
findSignatureImageIds,
rewriteSignatureImages,
signatureImagePath,
} from '@/modules/mail/html/signature-images';
import { traceTag } from '@/shared/logger/request-context';
import { maskEmail } from '@/shared/utils/mask';
import { MailSignatureImageEntity } from './mail-signature-image.entity';
import {
SIGNATURE_HTML_MAX_LENGTH,
SIGNATURE_IMAGES_PER_SIGNATURE,
SIGNATURE_IMAGES_STORED_MAX,
SIGNATURE_IMAGE_MAX_BYTES,
SIGNATURE_IMAGE_UPLOAD_GRACE_MS,
SIGNATURE_TEXT_MAX_LENGTH,
} from './mail-signature.constants';
import { MailSignatureEntity } from './mail-signature.entity';
import { MailSignatureRepository } from './mail-signature.repository';
import type {
MailSignature,
MailSignatureInput,
SignatureImage,
SignatureImageUpload,
StoredSignatureImage,
} from './mail-signature.types';
import {
SIGNATURE_IMAGE_EXTENSIONS,
SIGNATURE_IMAGE_TYPES_BY_EXTENSION,
type SignatureImageType,
detectSignatureImage,
isWithinSignatureImageDimensions,
} from './signature-image-format';
import { SignatureImageStorage } from './signature-image-storage';
const DEFAULT_SIGNATURE: Omit<MailSignature, 'images'> = {
bodyHtml: null,
body: null,
isForNew: true,
isForReply: true,
updatedAt: null,
};
function normalizeUser(email: string): string {
return email.trim().toLowerCase();
}
function toImage(row: MailSignatureImageEntity): SignatureImage {
return {
id: row.id,
type: row.contentType as SignatureImageType,
size: row.size,
width: row.width,
height: row.height,
};
}
/** 올린 이름의 확장자 (소문자, 점 포함) */
function extensionOf(name: string): string {
const dot = name.lastIndexOf('.');
return dot >= 0 ? name.slice(dot).toLowerCase() : '';
}
/**
* 메일 서명 (계약 2절 "서명") — 사용자마다 하나, 서버 DB 에 둔다.
* - 서명 HTML 은 보내는 정화(서명 그림 포함)를 거친 뒤 저장한다 — 서명은 쓰기 화면이 본문에 넣어 그대로 보내므로 같은 규칙이다
* - 그림은 PNG·JPEG·GIF 만 — 확장자·MIME 은 multer 가, 파일 내용(앞부분)·크기는 여기서 본다
* - 그림은 주인만 본다 — 남의 그림 id 는 없는 것과 같다 (RES_001)
*/
@Injectable()
export class MailSignatureService {
private readonly logger = new Logger(MailSignatureService.name);
/** 사용자별 그림 올리기 차례 — 보관 한도(10장)를 동시 업로드로 넘지 못하게 (프로세스 메모리 — 인스턴스를 늘리면 DB 잠금으로) */
private readonly uploadQueues = new Map<string, Promise<void>>();
constructor(
private readonly repository: MailSignatureRepository,
private readonly storage: SignatureImageStorage,
) {}
async getSignature(userEmail: string): Promise<MailSignature> {
const user = normalizeUser(userEmail);
const [row, images] = await Promise.all([
this.repository.findSignature(user),
this.repository.listImages(user),
]);
if (!row) return { ...DEFAULT_SIGNATURE, images: [] };
return this.toSignature(row, images);
}
/**
* 서명을 저장한다. 서명 HTML 이 더는 가리키지 않는 그림은 지운다(표와 파일) — 막 올린 그림(10분 안)은 남긴다
* (올리기와 저장이 엇갈린 경우 — SIGNATURE_IMAGE_UPLOAD_GRACE_MS).
* 한도 — 텍스트 2,000자·정화한 HTML 10,000자(JS 문자열 길이), 정화 전 비용, 서로 다른 그림 3장 — 를 넘으면 VAL_001.
*/
async saveSignature(
userEmail: string,
input: MailSignatureInput,
): Promise<MailSignature> {
const user = normalizeUser(userEmail);
const body = input.body ? input.body : null;
if (body !== null && body.length > SIGNATURE_TEXT_MAX_LENGTH) {
throw new BusinessException('VAL_001', {
context: { reason: 'signature-text-too-long', length: body.length },
});
}
const bodyHtml = await this.sanitizeSignatureHtml(user, input.bodyHtml);
// 정화한 HTML 에는 내 그림만 남는다 — 같은 그림을 두 번 넣으면 한 장으로 센다
const referenced = bodyHtml ? findSignatureImageIds(bodyHtml) : [];
if (referenced.length > SIGNATURE_IMAGES_PER_SIGNATURE) {
throw new BusinessException('VAL_001', {
context: {
reason: 'signature-too-many-images',
count: referenced.length,
},
});
}
const now = new Date();
const saved = await this.repository.saveSignature(
user,
(images) => {
// 잠근 뒤 본 그림 기준으로 — 정화한 뒤 그사이 지워진 그림(다른 탭의 저장·정리 작업)은 서명에서 뺀다 (없는 그림을 가리키지 않게)
const existing = new Set(images.map((image) => image.id));
const html =
bodyHtml === null
? null
: rewriteSignatureImages(bodyHtml, (id) =>
existing.has(id) ? signatureImagePath(id) : null,
);
const kept = html !== null && html.trim() !== '' ? html : null;
return {
signature: {
userEmail: user,
bodyHtml: kept,
bodyText: body,
isForNew: input.isForNew,
isForReply: input.isForReply,
updatedAt: now,
},
referencedImageIds: kept ? findSignatureImageIds(kept) : [],
};
},
now,
new Date(now.getTime() - SIGNATURE_IMAGE_UPLOAD_GRACE_MS),
);
await this.removeFiles(saved.removed, user);
return this.toSignature(saved.signature, saved.images);
}
/** 서명 그림을 올린다 — 내용이 PNG·JPEG·GIF 이고 확장자·MIME 과 같은 형식이어야 한다 (아니면 FILE_001) */
uploadImage(
userEmail: string,
upload: SignatureImageUpload,
): Promise<SignatureImage> {
const user = normalizeUser(userEmail);
const info = detectSignatureImage(upload.data);
const declared = SIGNATURE_IMAGE_TYPES_BY_EXTENSION.get(
extensionOf(upload.originalName),
);
if (!info || info.type !== declared || info.type !== upload.mimeType) {
return Promise.reject(
new BusinessException('FILE_001', {
context: { reason: 'signature-image-content-mismatch' },
}),
);
}
// 가로·세로 각각 4096 픽셀까지 — 작은 파일 안의 거대한 그림(압축 폭탄)·표의 integer 를 넘는 값을 받지 않는다
if (!isWithinSignatureImageDimensions(info)) {
return Promise.reject(
new BusinessException('FILE_001', {
context: {
reason: 'signature-image-dimensions',
width: info.width,
height: info.height,
},
}),
);
}
if (upload.data.length > SIGNATURE_IMAGE_MAX_BYTES) {
return Promise.reject(
new BusinessException('FILE_002', {
context: {
reason: 'signature-image-too-large',
size: upload.data.length,
},
}),
);
}
return this.inUploadQueue(user, async () => {
const count = await this.repository.countImages(user);
if (count >= SIGNATURE_IMAGES_STORED_MAX) {
throw new BusinessException('VAL_001', {
context: { reason: 'signature-image-storage-full', count },
});
}
const id = randomUUID();
const row: MailSignatureImageEntity = {
id,
userEmail: user,
contentType: info.type,
size: upload.data.length,
width: info.width,
height: info.height,
fileName: `${id}.${SIGNATURE_IMAGE_EXTENSIONS[info.type]}`,
createdAt: new Date(),
attachedAt: null,
};
await this.storage.write(row.fileName, upload.data);
try {
await this.repository.insertImage(row);
} catch (error) {
// 표에 넣지 못한 파일은 남기지 않는다
await this.storage.remove(row.fileName).catch(() => undefined);
throw error;
}
return toImage(row);
});
}
/** 그 사람의 그림 파일 — 남의 그림·없는 그림은 RES_001 */
async openImage(
userEmail: string,
id: string,
): Promise<{ data: Buffer; type: SignatureImageType }> {
const user = normalizeUser(userEmail);
const row = await this.repository.findImage(id, user);
const data = row ? await this.storage.read(row.fileName) : null;
if (!row || !data) {
if (row) {
this.logger.warn(
`[RES_001]${traceTag()} 서명 그림 파일이 없습니다: user=${maskEmail(user)}, id=${id}`,
);
}
throw new BusinessException('RES_001', {
context: { reason: 'signature-image-not-found' },
});
}
return { data, type: row.contentType as SignatureImageType };
}
/** 보내는 정화가 남겨도 되는 그림 id — 그 사람의 그림 전부 (서명에 붙었든 아니든) */
async imageIdsOf(userEmail: string): Promise<ReadonlySet<string>> {
const images = await this.repository.listImages(normalizeUser(userEmail));
return new Set(images.map((image) => image.id));
}
/**
* 메일에 함께 실을 그림을 읽는다 — 그 사람의 그림만, 요청한 순서대로. 파일이 없는 그림은 빼고 경고만 남긴다
* (부르는 쪽이 그 img 를 지운다).
*/
async loadImages(
userEmail: string,
ids: readonly string[],
): Promise<StoredSignatureImage[]> {
if (ids.length === 0) return [];
const user = normalizeUser(userEmail);
const rows = new Map(
(await this.repository.listImages(user)).map((row) => [row.id, row]),
);
const loaded: StoredSignatureImage[] = [];
for (const id of ids) {
const row = rows.get(id);
const data = row ? await this.storage.read(row.fileName) : null;
if (!row || !data) {
this.logger.warn(
`[RES_001]${traceTag()} 서명 그림을 메일에 싣지 못해 뺍니다: user=${maskEmail(user)}, id=${id}`,
);
continue;
}
loaded.push({ id, type: row.contentType as SignatureImageType, data });
}
return loaded;
}
/** 서명 HTML 정화 — 비었으면 null. 정화 전 비용·정화 뒤 길이 한도를 넘으면 VAL_001 */
private async sanitizeSignatureHtml(
user: string,
html: string | null | undefined,
): Promise<string | null> {
if (!html) return null;
// 정화 전 — 쓰기 화면의 서식 본문과 같은 길이 한도와 파서 비용 (정화가 이벤트 루프를 오래 붙잡지 않게)
if (html.length > MAX_BODY_HTML_LENGTH) {
throw new BusinessException('VAL_001', {
context: { reason: 'signature-html-too-long', length: html.length },
});
}
const budget = checkHtmlParseBudget(html);
if (!budget.ok) {
throw new BusinessException('VAL_001', {
context: {
reason: 'signature-html-too-complex',
detail: budget.reason,
},
});
}
const sanitized = sanitizeOutgoingHtml(html, {
signatureImageIds: await this.imageIdsOf(user),
});
if (sanitized.length > SIGNATURE_HTML_MAX_LENGTH) {
throw new BusinessException('VAL_001', {
context: {
reason: 'signature-html-too-long',
length: sanitized.length,
},
});
}
return sanitized === '' ? null : sanitized;
}
private toSignature(
row: MailSignatureEntity,
images: readonly MailSignatureImageEntity[],
): MailSignature {
const byId = new Map(images.map((image) => [image.id, image]));
const used = row.bodyHtml ? findSignatureImageIds(row.bodyHtml) : [];
return {
bodyHtml: row.bodyHtml,
body: row.bodyText,
isForNew: row.isForNew,
isForReply: row.isForReply,
images: used.flatMap((id) => {
const image = byId.get(id);
return image ? [toImage(image)] : [];
}),
updatedAt: row.updatedAt.toISOString(),
};
}
/** 지운 그림의 파일을 지운다 — 실패해도 서명 저장은 끝났다 (남은 파일은 정리 작업이 지운다) */
private async removeFiles(
removed: readonly MailSignatureImageEntity[],
user: string,
): Promise<void> {
for (const image of removed) {
try {
await this.storage.remove(image.fileName);
} catch (error) {
this.logger.warn(
`[SYS_001]${traceTag()} 서명 그림 파일을 지우지 못했습니다: user=${maskEmail(user)}, reason=${error instanceof Error ? error.message : String(error)}`,
);
}
}
}
/** 같은 사용자의 작업을 차례로 돌린다 (앞 작업이 실패해도 다음 작업은 돈다) */
private inUploadQueue<T>(user: string, task: () => Promise<T>): Promise<T> {
const previous = this.uploadQueues.get(user) ?? Promise.resolve();
const run = previous.then(task, task);
const tail = run.then(
() => undefined,
() => undefined,
);
this.uploadQueues.set(user, tail);
void tail.then(() => {
if (this.uploadQueues.get(user) === tail) this.uploadQueues.delete(user);
});
return run;
}
}
@@ -0,0 +1,50 @@
import type { SignatureImageType } from './signature-image-format';
/**
* 서명 도메인 타입 — 계약 2절 "서명"과 같은 모양이다.
*/
export interface SignatureImage {
readonly id: string;
readonly type: SignatureImageType;
/** 바이트 */
readonly size: number;
/** 원본 픽셀 */
readonly width: number;
readonly height: number;
}
export interface MailSignature {
/** 서명 서식 — 서식 본문 규칙 + 서명 그림. 정화 뒤 10,000자 이하 */
readonly bodyHtml: string | null;
/** 텍스트 서명 (텍스트 대체 본문에 넣는다) — 2,000자 이하 */
readonly body: string | null;
readonly isForNew: boolean;
readonly isForReply: boolean;
/** 서명 HTML 이 쓰는 그림 (HTML 에 나온 순서) */
readonly images: SignatureImage[];
/** 저장한 적 없으면 null */
readonly updatedAt: string | null;
}
/** PUT /api/mail-signature 본문 */
export interface MailSignatureInput {
readonly bodyHtml?: string | null;
readonly body?: string | null;
readonly isForNew: boolean;
readonly isForReply: boolean;
}
/** 메일에 함께 실을 그림 — 발송 때 읽는다 */
export interface StoredSignatureImage {
readonly id: string;
readonly type: SignatureImageType;
readonly data: Buffer;
}
/** 올린 그림 파일 (multer 가 메모리에 받은 것 — 한 장 300KB 이하) */
export interface SignatureImageUpload {
readonly originalName: string;
readonly mimeType: string;
readonly data: Buffer;
}
@@ -0,0 +1,131 @@
import {
gifBytes as gif,
jpegBytes as jpeg,
pngBytes as png,
} from '@/../test/image-fixtures';
import { SIGNATURE_IMAGE_MAX_DIMENSION } from './mail-signature.constants';
import {
detectSignatureImage,
isWithinSignatureImageDimensions,
} from './signature-image-format';
describe('isWithinSignatureImageDimensions (서명 그림 크기 상한)', () => {
it(`가로·세로가 각각 1~${SIGNATURE_IMAGE_MAX_DIMENSION} 픽셀이면 받는다`, () => {
expect(isWithinSignatureImageDimensions({ width: 1, height: 1 })).toBe(
true,
);
expect(
isWithinSignatureImageDimensions({
width: SIGNATURE_IMAGE_MAX_DIMENSION,
height: SIGNATURE_IMAGE_MAX_DIMENSION,
}),
).toBe(true);
});
it('하나라도 넘거나 0 이면 받지 않는다 — PNG 머리의 32비트 값(표의 integer 를 넘는다) 포함', () => {
for (const [width, height] of [
[SIGNATURE_IMAGE_MAX_DIMENSION + 1, 1],
[1, SIGNATURE_IMAGE_MAX_DIMENSION + 1],
[0, 10],
[2 ** 31, 10],
]) {
expect(isWithinSignatureImageDimensions({ width, height })).toBe(false);
}
// PNG 는 머리에 32비트로 적는다 — 읽기는 하고(형식은 맞다) 크기 검사가 거른다
const huge = detectSignatureImage(png(2 ** 32 - 1, 2 ** 32 - 1));
expect(huge).toEqual({
type: 'image/png',
width: 2 ** 32 - 1,
height: 2 ** 32 - 1,
});
expect(huge && isWithinSignatureImageDimensions(huge)).toBe(false);
});
});
describe('detectSignatureImage (서명 그림 형식·크기)', () => {
it('PNG·GIF(87a·89a)·JPEG(기본·점진) 의 형식과 가로·세로를 읽는다', () => {
expect(detectSignatureImage(png(640, 120))).toEqual({
type: 'image/png',
width: 640,
height: 120,
});
expect(detectSignatureImage(gif(300, 80))).toEqual({
type: 'image/gif',
width: 300,
height: 80,
});
expect(detectSignatureImage(gif(1, 2, 'GIF87a'))).toEqual({
type: 'image/gif',
width: 1,
height: 2,
});
expect(detectSignatureImage(jpeg(1024, 256))).toEqual({
type: 'image/jpeg',
width: 1024,
height: 256,
});
// 점진(SOF2) · 표지 앞 채움 바이트
expect(
detectSignatureImage(jpeg(96, 48, { sof: 0xc2, fill: true })),
).toEqual({ type: 'image/jpeg', width: 96, height: 48 });
});
it('내용이 그 형식이 아니면 받지 않는다 — WebP·SVG·HTML·빈 파일', () => {
for (const data of [
Buffer.from('RIFF\x00\x00\x00\x00WEBPVP8 ', 'binary'),
Buffer.from('<svg xmlns="http://www.w3.org/2000/svg"></svg>'),
Buffer.from('<html><script>alert(1)</script></html>'),
Buffer.alloc(0),
]) {
expect(detectSignatureImage(data)).toBeNull();
}
});
it('머리가 잘렸거나 망가졌거나 크기가 0 이면 받지 않는다', () => {
expect(detectSignatureImage(png(10, 10).subarray(0, 20))).toBeNull();
expect(detectSignatureImage(png(0, 10))).toBeNull();
expect(detectSignatureImage(gif(10, 0))).toBeNull();
expect(detectSignatureImage(gif(10, 10).subarray(0, 8))).toBeNull();
expect(detectSignatureImage(jpeg(0, 10))).toBeNull();
// IHDR 가 첫 덩어리가 아니다
const broken = png(10, 10);
broken.write('IDAT', 12, 'ascii');
expect(detectSignatureImage(broken)).toBeNull();
// JPEG: SOF 전에 그림 자료(SOS)가 오거나, 조각 길이가 파일 밖을 가리킨다
expect(
detectSignatureImage(
Buffer.from([0xff, 0xd8, 0xff, 0xda, 0x00, 0x02, 0xff, 0xd9]),
),
).toBeNull();
expect(
detectSignatureImage(Buffer.from([0xff, 0xd8, 0xff, 0xe0, 0x7f, 0xff])),
).toBeNull();
expect(
detectSignatureImage(Buffer.from([0xff, 0xd8, 0xff, 0xd9])),
).toBeNull();
});
it('긴 JPEG 도 조각 길이를 따라 앞으로만 간다 (입력 길이에 비례)', () => {
// 64KB 짜리 주석(COM) 조각 4개 뒤에 SOF
const comment = Buffer.alloc(0xffff + 2);
comment[0] = 0xff;
comment[1] = 0xfe;
comment.writeUInt16BE(0xffff, 2);
const data = Buffer.concat([
Buffer.from([0xff, 0xd8]),
comment,
comment,
comment,
comment,
jpeg(20, 10).subarray(2),
]);
expect(detectSignatureImage(data)).toEqual({
type: 'image/jpeg',
width: 20,
height: 10,
});
});
});
@@ -0,0 +1,161 @@
/**
* 서명 그림 형식 확인 (순수 함수) — 파일 앞부분(매직 바이트)으로 형식을 알아내고 머리에서 가로·세로 픽셀을 읽는다.
* 확장자·MIME 은 클라이언트가 정하므로 믿지 않는다 — 이름이 .png 여도 내용이 PNG 가 아니면 받지 않는다 (.claude/rules/security.md).
* 그림을 풀지(디코드) 않는다 — 머리만 읽으므로 네이티브 라이브러리·새 의존성이 필요 없다.
*/
import { SIGNATURE_IMAGE_MAX_DIMENSION } from './mail-signature.constants';
export type SignatureImageType = 'image/png' | 'image/jpeg' | 'image/gif';
export interface SignatureImageInfo {
readonly type: SignatureImageType;
readonly width: number;
readonly height: number;
}
/**
* 받을 수 있는 크기인가 — 가로·세로가 각각 1~SIGNATURE_IMAGE_MAX_DIMENSION(4096) 픽셀.
* (0 은 detectSignatureImage 가 이미 거른다)
*/
export function isWithinSignatureImageDimensions(
info: Pick<SignatureImageInfo, 'width' | 'height'>,
): boolean {
return (
info.width >= 1 &&
info.height >= 1 &&
info.width <= SIGNATURE_IMAGE_MAX_DIMENSION &&
info.height <= SIGNATURE_IMAGE_MAX_DIMENSION
);
}
/** 형식별 저장 확장자 — 서버가 만드는 파일 이름에 쓴다 */
export const SIGNATURE_IMAGE_EXTENSIONS: Readonly<
Record<SignatureImageType, string>
> = {
'image/png': 'png',
'image/jpeg': 'jpg',
'image/gif': 'gif',
};
/** 받는 확장자 → 형식 (업로드 사전 검사 — 확장자와 MIME 이 이 짝이어야 한다) */
export const SIGNATURE_IMAGE_TYPES_BY_EXTENSION: ReadonlyMap<
string,
SignatureImageType
> = new Map([
['.png', 'image/png'],
['.jpg', 'image/jpeg'],
['.jpeg', 'image/jpeg'],
['.gif', 'image/gif'],
]);
const PNG_SIGNATURE = Buffer.from([
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a,
]);
/** PNG 첫 덩어리 IHDR — 길이(4) 'IHDR'(4) 가로(4) 세로(4) … */
const PNG_IHDR_LENGTH = 13;
const PNG_IHDR_TYPE = Buffer.from('IHDR', 'ascii');
const GIF_SIGNATURES = [
Buffer.from('GIF87a', 'ascii'),
Buffer.from('GIF89a', 'ascii'),
];
/** GIF 논리 화면 가로·세로 (little-endian 16비트) 위치 */
const GIF_WIDTH_OFFSET = 6;
const GIF_HEIGHT_OFFSET = 8;
const JPEG_SOI = [0xff, 0xd8, 0xff];
const JPEG_MARKER_PREFIX = 0xff;
/** 길이 없이 홀로 오는 표지 — TEM, RST0~7 */
const JPEG_STANDALONE = new Set([
0x01, 0xd0, 0xd1, 0xd2, 0xd3, 0xd4, 0xd5, 0xd6, 0xd7,
]);
/** 그림 자료가 시작되거나 끝나는 표지 — 그 전에 SOF 가 있어야 한다 */
const JPEG_SOS = 0xda;
const JPEG_EOI = 0xd9;
/** 프레임 머리(SOF0~15) 중 프레임이 아닌 것 — DHT(C4)·JPG(C8)·DAC(CC) */
const JPEG_NOT_SOF = new Set([0xc4, 0xc8, 0xcc]);
const JPEG_SOF_FIRST = 0xc0;
const JPEG_SOF_LAST = 0xcf;
function startsWith(data: Buffer, prefix: Buffer | readonly number[]): boolean {
if (data.length < prefix.length) return false;
for (let index = 0; index < prefix.length; index++) {
if (data[index] !== prefix[index]) return false;
}
return true;
}
function sized(
type: SignatureImageType,
width: number,
height: number,
): SignatureImageInfo | null {
return width > 0 && height > 0 ? { type, width, height } : null;
}
function readPng(data: Buffer): SignatureImageInfo | null {
// 서명 8 + 길이 4 + 형식 4 + 가로 4 + 세로 4
if (data.length < 24) return null;
if (data.readUInt32BE(8) !== PNG_IHDR_LENGTH) return null;
if (!data.subarray(12, 16).equals(PNG_IHDR_TYPE)) return null;
return sized('image/png', data.readUInt32BE(16), data.readUInt32BE(20));
}
function readGif(data: Buffer): SignatureImageInfo | null {
if (data.length < GIF_HEIGHT_OFFSET + 2) return null;
return sized(
'image/gif',
data.readUInt16LE(GIF_WIDTH_OFFSET),
data.readUInt16LE(GIF_HEIGHT_OFFSET),
);
}
/**
* JPEG 은 표지(0xFF xx)와 길이로 이어진 조각을 따라가 첫 프레임 머리(SOFn)에서 크기를 읽는다.
* 조각 길이를 따라 앞으로만 가므로 입력 길이에 비례해 끝난다. SOF 전에 그림 자료(SOS)·끝(EOI)이 오면 받지 않는다.
*/
function readJpeg(data: Buffer): SignatureImageInfo | null {
let index = 2;
while (index < data.length) {
if (data[index] !== JPEG_MARKER_PREFIX) return null;
// 표지 앞의 채움 바이트(0xFF 여러 개)는 건너뛴다
while (index < data.length && data[index] === JPEG_MARKER_PREFIX) index++;
if (index >= data.length) return null;
const marker = data[index];
index++;
if (JPEG_STANDALONE.has(marker)) continue;
if (marker === JPEG_SOS || marker === JPEG_EOI) return null;
if (index + 2 > data.length) return null;
const length = data.readUInt16BE(index);
if (length < 2 || index + length > data.length) return null;
const isFrame =
marker >= JPEG_SOF_FIRST &&
marker <= JPEG_SOF_LAST &&
!JPEG_NOT_SOF.has(marker);
if (isFrame) {
// 길이(2) 정밀도(1) 세로(2) 가로(2)
if (length < 7) return null;
return sized(
'image/jpeg',
data.readUInt16BE(index + 5),
data.readUInt16BE(index + 3),
);
}
index += length;
}
return null;
}
/** 내용으로 형식과 크기를 알아낸다 — PNG·JPEG·GIF 가 아니거나 머리가 망가졌으면 null */
export function detectSignatureImage(data: Buffer): SignatureImageInfo | null {
if (startsWith(data, PNG_SIGNATURE)) return readPng(data);
if (GIF_SIGNATURES.some((signature) => startsWith(data, signature))) {
return readGif(data);
}
if (startsWith(data, JPEG_SOI)) return readJpeg(data);
return null;
}
@@ -0,0 +1,23 @@
import { Injectable, PipeTransform } from '@nestjs/common';
import { BusinessException } from '@/common/errors/business.exception';
/** 서명 그림 id — 서버가 만든 UUID */
const SIGNATURE_IMAGE_ID =
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
/**
* 경로의 서명 그림 id 형식 확인 — 형식이 틀리면 "그런 그림이 없다"와 같으므로 RES_001 (DB 의 uuid 열에 넣기 전에 막는다 —
* 형식이 틀린 값이 DB 오류(SYS_001)로 새어 나가지 않게).
*/
@Injectable()
export class SignatureImageIdPipe implements PipeTransform<unknown, string> {
transform(value: unknown): string {
if (typeof value === 'string' && SIGNATURE_IMAGE_ID.test(value)) {
return value.toLowerCase();
}
throw new BusinessException('RES_001', {
context: { reason: 'malformed-signature-image-id' },
});
}
}
@@ -0,0 +1,70 @@
import { mkdtempSync, rmSync, statSync } from 'fs';
import { tmpdir } from 'os';
import { join } from 'path';
import { SignatureImageStorage } from './signature-image-storage';
const NAME = '3f1c0a2e-0f1b-4f2a-9a1e-6c9f0a1b2c3d.png';
describe('SignatureImageStorage (서명 그림 파일)', () => {
let root: string;
let dir: string;
let storage: SignatureImageStorage;
beforeEach(() => {
root = mkdtempSync(join(tmpdir(), 'signature-storage-'));
// 아직 없는 하위 폴더 — 처음 쓸 때 만든다
dir = join(root, 'mail-signatures');
storage = new SignatureImageStorage(dir);
});
afterEach(() => rmSync(root, { recursive: true, force: true }));
it('쓰고 읽고 지운다 — 없는 파일은 null, 두 번 지워도 된다', async () => {
await expect(storage.read(NAME)).resolves.toBeNull();
await expect(storage.list()).resolves.toEqual([]);
await storage.write(NAME, Buffer.from('png'));
await expect(storage.read(NAME)).resolves.toEqual(Buffer.from('png'));
expect((await storage.list()).map((file) => file.fileName)).toEqual([NAME]);
await storage.remove(NAME);
await storage.remove(NAME);
await expect(storage.read(NAME)).resolves.toBeNull();
});
it('같은 이름을 덮어쓰지 않는다', async () => {
await storage.write(NAME, Buffer.from('first'));
await expect(storage.write(NAME, Buffer.from('second'))).rejects.toThrow();
await expect(storage.read(NAME)).resolves.toEqual(Buffer.from('first'));
});
it('서버가 만든 이름(UUID.png·jpg·gif)이 아니면 경로로 쓰지 않는다', async () => {
for (const name of [
'../../etc/passwd',
`../${NAME}`,
`${NAME}/x`,
NAME.toUpperCase(),
'3f1c0a2e-0f1b-4f2a-9a1e-6c9f0a1b2c3d.svg',
'3f1c0a2e-0f1b-4f2a-9a1e-6c9f0a1b2c3d.jpeg',
'',
]) {
await expect(storage.write(name, Buffer.from('x'))).rejects.toThrow(
'서명 그림 파일 이름 형식이 올바르지 않습니다.',
);
await expect(storage.read(name)).rejects.toThrow();
await expect(storage.remove(name)).rejects.toThrow();
}
});
it('그룹은 쓸 수 없고 다른 사람은 읽을 수도 없다 (POSIX — Windows 는 권한 비트가 없다)', async () => {
await storage.write(NAME, Buffer.from('png'));
if (process.platform !== 'win32') {
// 만들 때 0o640 — umask 가 더 좁히는 것은 괜찮다
const mode = statSync(join(dir, NAME)).mode;
expect(mode & 0o027).toBe(0);
}
});
});
@@ -0,0 +1,89 @@
import { mkdir, readFile, readdir, stat, unlink, writeFile } from 'fs/promises';
import { join } from 'path';
import { SIGNATURE_IMAGE_DIR } from './mail-signature.constants';
/** 서버가 만든 파일 이름만 다룬다 — 바깥 값이 경로가 되지 않게 (`../../etc/passwd` 같은 값) */
const STORED_FILE_NAME =
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\.(?:png|jpg|gif)$/;
/** 파일 권한 — 소유자만 쓴다 (그룹은 읽기만) */
const FILE_MODE = 0o640;
export interface StoredFile {
readonly fileName: string;
readonly modifiedAt: Date;
}
function isMissing(error: unknown): boolean {
return (error as NodeJS.ErrnoException | null)?.code === 'ENOENT';
}
/**
* 서명 그림 파일 저장소 — 업로드 볼륨의 하위 폴더(SIGNATURE_IMAGE_DIR).
* 폴더 그대로 정적 서빙하지 않는다 — 내려줄 때는 컨트롤러가 형식을 정해 준다 (.claude/rules/security.md "파일 업로드").
* 모듈은 useFactory 로 만든다 (생성자의 폴더 인자를 DI 가 찾지 않게) — 테스트는 임시 폴더를 넘긴다.
*/
export class SignatureImageStorage {
constructor(private readonly dir: string = SIGNATURE_IMAGE_DIR) {}
async write(fileName: string, data: Buffer): Promise<void> {
await mkdir(this.dir, { recursive: true });
// wx — 같은 이름이 이미 있으면 덮어쓰지 않고 실패한다 (UUID 라 생기지 않지만 지키는 선)
await writeFile(this.pathOf(fileName), data, {
flag: 'wx',
mode: FILE_MODE,
});
}
/** 없으면 null */
async read(fileName: string): Promise<Buffer | null> {
try {
return await readFile(this.pathOf(fileName));
} catch (error) {
if (isMissing(error)) return null;
throw error;
}
}
/** 지운다 — 이미 없으면 그대로 둔다 */
async remove(fileName: string): Promise<void> {
try {
await unlink(this.pathOf(fileName));
} catch (error) {
if (!isMissing(error)) throw error;
}
}
/** 폴더 안의 그림 파일 (정리 작업용). 폴더가 없으면 빈 목록 */
async list(): Promise<StoredFile[]> {
let names: string[];
try {
names = await readdir(this.dir);
} catch (error) {
if (isMissing(error)) return [];
throw error;
}
const files: StoredFile[] = [];
for (const fileName of names.filter((name) =>
STORED_FILE_NAME.test(name),
)) {
try {
const { mtime } = await stat(join(this.dir, fileName));
files.push({ fileName, modifiedAt: mtime });
} catch (error) {
if (!isMissing(error)) throw error;
}
}
return files;
}
private pathOf(fileName: string): string {
if (!STORED_FILE_NAME.test(fileName)) {
// 서버가 만든 이름이 아니다 — 버그이므로 응답 코드 없는 내부 오류로 둔다 (SYS_001)
throw new Error('서명 그림 파일 이름 형식이 올바르지 않습니다.');
}
return join(this.dir, fileName);
}
}
@@ -0,0 +1,208 @@
import { ERROR_CODES } from '@/common/errors/error-codes';
import {
MAX_ATTACHMENT_LIST_LENGTH,
MAX_ATTACHMENTS_PER_MESSAGE,
allowedAttachmentExtensions,
isAllowedAttachment,
messageSourceFileName,
normalizeMimeType,
resolveAttachmentType,
resolveAttachmentsTotalLimit,
sanitizeFileName,
} from './attachment.policy';
const MB = 1024 * 1024;
describe('첨부 개수 한도', () => {
it('레지스트리의 MAIL_006 문구가 말하는 개수는 한도 상수와 같다 — 문구와 실제 거절 기준이 어긋나지 않게', () => {
expect(ERROR_CODES.MAIL_006.message).toContain(
`${MAX_ATTACHMENTS_PER_MESSAGE}개`,
);
});
it('형식 검증(DTO)의 목록 상한은 개수 한도보다 넉넉하다 — 한도를 넘긴 요청이 VAL_001 이 아니라 MAIL_006 을 받게', () => {
expect(MAX_ATTACHMENT_LIST_LENGTH).toBeGreaterThan(
MAX_ATTACHMENTS_PER_MESSAGE,
);
});
});
describe('첨부 합계 한도 (resolveAttachmentsTotalLimit)', () => {
it('설정 한도와 엔진 한도 중 작은 값을 쓴다', () => {
expect(resolveAttachmentsTotalLimit(25 * MB, 50_000_000)).toBe(25 * MB);
expect(resolveAttachmentsTotalLimit(25 * MB, 10 * MB)).toBe(10 * MB);
});
it('엔진이 한도를 알려 주지 않으면 설정 한도만 쓴다', () => {
for (const unknown of [null, undefined, 0, -1, 1.5, Number.NaN]) {
expect(resolveAttachmentsTotalLimit(25 * MB, unknown)).toBe(25 * MB);
}
});
});
describe('메일 첨부 형식 정책', () => {
it('계약의 허용 형식을 모두 받는다 (확장자 + MIME)', () => {
const allowed: Array<[string, string]> = [
['보고서.pdf', 'application/pdf'],
['a.doc', 'application/msword'],
[
'a.docx',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
],
['a.xls', 'application/vnd.ms-excel'],
[
'체크리스트.xlsx',
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
],
['a.ppt', 'application/vnd.ms-powerpoint'],
[
'a.pptx',
'application/vnd.openxmlformats-officedocument.presentationml.presentation',
],
['기안.hwp', 'application/x-hwp'],
['기안.hwpx', 'application/hwp+zip'],
['메모.txt', 'text/plain'],
['목록.csv', 'text/csv'],
['사진.png', 'image/png'],
['사진.jpg', 'image/jpeg'],
['사진.JPEG', 'image/jpeg'],
['움짤.gif', 'image/gif'],
['사진.webp', 'image/webp'],
['묶음.zip', 'application/zip'],
];
for (const [name, mime] of allowed) {
expect(isAllowedAttachment(name, mime)).toBe(true);
}
});
it('브라우저·OS 가 보내는 다른 MIME 표기도 받는다', () => {
expect(
isAllowedAttachment('묶음.zip', 'application/x-zip-compressed'),
).toBe(true);
expect(isAllowedAttachment('목록.csv', 'application/vnd.ms-excel')).toBe(
true,
);
expect(isAllowedAttachment('기안.hwp', 'application/haansofthwp')).toBe(
true,
);
// 한컴오피스가 없는 PC 는 octet-stream 으로 보낸다
expect(isAllowedAttachment('기안.hwp', 'application/octet-stream')).toBe(
true,
);
expect(isAllowedAttachment('메모.txt', 'text/plain; charset=utf-8')).toBe(
true,
);
});
it('브라우저가 그릴 수 있는 형식은 octet-stream 으로 위장하면 거부한다', () => {
for (const name of ['사진.png', '보고서.pdf', '메모.txt', '사진.jpg']) {
expect(isAllowedAttachment(name, 'application/octet-stream')).toBe(false);
}
});
it('확장자와 MIME 이 어긋나면 거부한다 (위장 방지)', () => {
expect(isAllowedAttachment('payload.png', 'text/html')).toBe(false);
expect(isAllowedAttachment('payload.html', 'image/png')).toBe(false);
expect(isAllowedAttachment('noext', 'application/pdf')).toBe(false);
});
it('실행·렌더링 가능한 형식은 허용 목록에 없다', () => {
const extensions = allowedAttachmentExtensions();
for (const dangerous of [
'.html',
'.htm',
'.svg',
'.js',
'.exe',
'.sh',
'.bat',
]) {
expect(extensions).not.toContain(dangerous);
}
expect(isAllowedAttachment('icon.svg', 'image/svg+xml')).toBe(false);
});
it('발송 형식은 확장자로 정한다 — 허용 목록 밖이면 null', () => {
expect(resolveAttachmentType('체크리스트.XLSX')).toBe(
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
);
expect(resolveAttachmentType('사진.jpeg')).toBe('image/jpeg');
expect(resolveAttachmentType('virus.exe')).toBeNull();
expect(resolveAttachmentType('README')).toBeNull();
});
it('MIME 을 소문자·매개변수 없이 맞춘다', () => {
expect(normalizeMimeType(' Text/Plain; charset=UTF-8 ')).toBe('text/plain');
});
});
describe('sanitizeFileName', () => {
it('경로를 버리고 파일명만 남긴다 (경로 탈출 방지)', () => {
expect(sanitizeFileName('../../etc/passwd')).toBe('passwd');
expect(sanitizeFileName('C:\\Users\\kim\\보고서.pdf')).toBe('보고서.pdf');
});
it('제어 문자와 방향 제어 문자를 지운다 (확장자 뒤집기 위장 방지)', () => {
expect(sanitizeFileName('a\r\nb.txt')).toBe('ab.txt');
expect(sanitizeFileName('invoice\u202Etxt.exe')).toBe('invoicetxt.exe');
});
it('너무 긴 이름은 확장자를 남기고 자른다', () => {
const long = `${'가'.repeat(300)}.xlsx`;
const result = sanitizeFileName(long);
expect(Array.from(result)).toHaveLength(255);
expect(result.endsWith('.xlsx')).toBe(true);
});
it('남는 것이 없으면 기본 이름을 쓴다', () => {
expect(sanitizeFileName('')).toBe('첨부파일');
expect(sanitizeFileName('dir/')).toBe('첨부파일');
});
});
describe('원문 파일 이름 (messageSourceFileName)', () => {
it('제목 + .eml — 제목이 없거나 정리하고 비면 "메일"', () => {
expect(messageSourceFileName('메일 서버 이전 일정 공유')).toBe(
'메일 서버 이전 일정 공유.eml',
);
for (const subject of ['', ' ', '...', '\u0000\u202E']) {
expect(messageSourceFileName(subject)).toBe('메일.eml');
}
});
it('경로 구분자와 Windows 에서 못 쓰는 문자는 _ 로, 제어 · 방향 제어 문자는 지우고, 공백(탭 · 줄바꿈)은 하나로', () => {
expect(messageSourceFileName('Re: 견적서/수정본 <최종>?')).toBe(
'Re_ 견적서_수정본 _최종__.eml',
);
expect(messageSourceFileName('..\\..\\secret|"x"*')).toBe(
'_.._secret__x__.eml',
);
expect(messageSourceFileName('주간\t보고\r\n (10월)')).toBe(
'주간 보고 (10월).eml',
);
expect(messageSourceFileName('계약서\u202Egpj.exe')).toBe(
'계약서gpj.exe.eml',
);
});
it('앞뒤의 공백 · 점을 떼고 80글자(코드 포인트)까지 — 자른 끝의 공백 · 점도 뗀다', () => {
expect(messageSourceFileName(' .숨김 파일. ')).toBe('숨김 파일.eml');
expect(messageSourceFileName('가'.repeat(100))).toBe(
`${'가'.repeat(80)}.eml`,
);
expect(messageSourceFileName('😀'.repeat(81))).toBe(
`${'😀'.repeat(80)}.eml`,
);
expect(messageSourceFileName(`${'가'.repeat(79)} 끝`)).toBe(
`${'가'.repeat(79)}.eml`,
);
});
it('아주 긴 제목도 입력 길이에 비례해 처리한다 (반복 정규식 없이)', () => {
const long = `${' .'.repeat(200_000)}x${' .'.repeat(200_000)}`;
expect(messageSourceFileName(long)).toBe('x.eml');
});
});
@@ -0,0 +1,307 @@
import { extname } from 'path';
import { MAX_FILE_NAME_LENGTH } from './mail.constants';
/**
* 메일 첨부 형식·크기 정책 (순수 함수) — `modules/upload/upload.config.ts` 와 같은 원칙을 따른다.
*
* - **허용 목록**만 받는다. 확장자와 MIME 을 **둘 다** 검사한다 (.claude/rules/security.md "파일 업로드")
* - 저장·발송에 쓰는 형식은 클라이언트가 보낸 MIME 이 아니라 **확장자로 정한 정식 형식**이다
* - 브라우저가 실행할 수 있는 형식(.html·.svg·.js 등)은 넣지 않는다
*/
const OCTET_STREAM = 'application/octet-stream';
/**
* 메일 한 통에 붙일 수 있는 첨부 개수 — 넘으면 400 MAIL_006 (계약 2절. 화면은 첨부를 추가할 때 미리 막는다).
* 업로드한 파일과 전달하는 원본 첨부를 함께 센다. **개수 한도는 이 상수 하나**다 —
* 발송 검사(mail.service.ts)·API 문서가 이 값을 쓰고, 레지스트리의 MAIL_006 문구는 테스트가 이 값과 맞춰 본다.
*/
export const MAX_ATTACHMENTS_PER_MESSAGE = 20;
/**
* 발송 본문 검증(DTO)이 받는 첨부 목록 길이 상한 — 한도가 아니라 **남용 방지용**이다 (넘으면 400 VAL_001).
*
* 100 으로 둔 근거:
* - 한도(20)보다 넉넉해야 한다. 파일을 한꺼번에 많이 고른 경우(21~100개)는 검증을 통과시켜 MAIL_006 으로 답한다 —
* VAL_001 문구("입력하신 정보를 다시 확인해 주세요")로는 무엇을 고칠지 알 수 없다.
* - 끝은 있어야 한다. 화면으로는 만들 수 없는 길이는 형식 남용으로 보고 서비스·메일 엔진까지 보내지 않는다.
* (본문 해석·검증 비용 자체는 발송 JSON 본문 한도 4MB 가 묶는다 — app.setup.ts 의 SEND_MESSAGE_JSON_BODY_LIMIT)
* - 같은 DTO 의 다른 목록(받는 사람·참조·숨은 참조) 상한도 100 이다 (mail.constants.ts 의 MAX_RECIPIENTS).
*/
export const MAX_ATTACHMENT_LIST_LENGTH = 100;
interface AttachmentTypeRule {
/** 서버가 저장·발송에 쓰는 정식 형식 */
readonly type: string;
/** 업로드 때 받아들이는 MIME */
readonly accept: readonly string[];
}
/**
* 확장자 → 형식.
*
* 문서·압축 형식(브라우저가 직접 그리지 않는 형식)은 `application/octet-stream` 도 받는다.
* 브라우저는 OS 에 등록되지 않은 형식(한컴오피스가 없는 PC 의 .hwp 등)을 octet-stream 으로 보내기 때문이다.
* 이미지·PDF·텍스트처럼 브라우저가 그릴 수 있는 형식은 정확한 MIME 만 받는다.
* 어느 쪽이든 내려줄 때는 항상 octet-stream + attachment 이므로 내용이 실행되지 않는다.
*/
const ATTACHMENT_TYPES: ReadonlyMap<string, AttachmentTypeRule> = new Map([
['.pdf', { type: 'application/pdf', accept: ['application/pdf'] }],
[
'.doc',
{
type: 'application/msword',
accept: ['application/msword', OCTET_STREAM],
},
],
[
'.docx',
{
type: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
accept: [
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
OCTET_STREAM,
],
},
],
[
'.xls',
{
type: 'application/vnd.ms-excel',
accept: ['application/vnd.ms-excel', OCTET_STREAM],
},
],
[
'.xlsx',
{
type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
accept: [
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
OCTET_STREAM,
],
},
],
[
'.ppt',
{
type: 'application/vnd.ms-powerpoint',
accept: ['application/vnd.ms-powerpoint', OCTET_STREAM],
},
],
[
'.pptx',
{
type: 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
accept: [
'application/vnd.openxmlformats-officedocument.presentationml.presentation',
OCTET_STREAM,
],
},
],
[
'.hwp',
{
type: 'application/x-hwp',
accept: [
'application/x-hwp',
'application/haansofthwp',
'application/vnd.hancom.hwp',
OCTET_STREAM,
],
},
],
[
'.hwpx',
{
type: 'application/hwp+zip',
accept: [
'application/hwp+zip',
'application/haansofthwpx',
'application/vnd.hancom.hwpx',
'application/x-hwpx',
OCTET_STREAM,
],
},
],
['.txt', { type: 'text/plain', accept: ['text/plain'] }],
[
'.csv',
{
type: 'text/csv',
// 엑셀이 설치된 Windows 는 .csv 를 application/vnd.ms-excel 로 보낸다
accept: [
'text/csv',
'application/csv',
'text/x-csv',
'application/vnd.ms-excel',
'text/plain',
],
},
],
['.png', { type: 'image/png', accept: ['image/png'] }],
['.jpg', { type: 'image/jpeg', accept: ['image/jpeg', 'image/pjpeg'] }],
['.jpeg', { type: 'image/jpeg', accept: ['image/jpeg', 'image/pjpeg'] }],
['.gif', { type: 'image/gif', accept: ['image/gif'] }],
['.webp', { type: 'image/webp', accept: ['image/webp'] }],
[
'.zip',
{
type: 'application/zip',
// Windows 의 Chrome 은 application/x-zip-compressed 로 보낸다
accept: [
'application/zip',
'application/x-zip-compressed',
'application/x-zip',
'multipart/x-zip',
OCTET_STREAM,
],
},
],
]);
/** 정리 후 이름이 비면 쓰는 이름 */
const FALLBACK_FILE_NAME = '첨부파일';
/** 'Text/Plain; charset=UTF-8' → 'text/plain' */
export function normalizeMimeType(mimeType: string): string {
return mimeType.split(';')[0].trim().toLowerCase();
}
function extensionOf(fileName: string): string {
return extname(fileName).toLowerCase();
}
/** 업로드 허용 여부 — 확장자가 허용 목록에 있고, MIME 이 그 확장자에 맞아야 통과한다 */
export function isAllowedAttachment(
fileName: string,
mimeType: string,
): boolean {
const rule = ATTACHMENT_TYPES.get(extensionOf(fileName));
return (
rule !== undefined && rule.accept.includes(normalizeMimeType(mimeType))
);
}
/** 파일명 확장자로 정식 형식을 정한다. 허용 목록에 없으면 null */
export function resolveAttachmentType(fileName: string): string | null {
return ATTACHMENT_TYPES.get(extensionOf(fileName))?.type ?? null;
}
/** 안내·문서용 허용 확장자 목록 */
export function allowedAttachmentExtensions(): string[] {
return [...ATTACHMENT_TYPES.keys()];
}
/**
* 메일 한 통의 첨부 합계 한도(바이트) — 설정 한도(MAIL_ATTACHMENT_TOTAL_MAX_SIZE_MB)와 메일 엔진 한도 중 작은 값.
*
* **이 함수 하나만** 한도를 정한다. 발송 검사(어댑터의 FILE_002)와 `GET /api/mail-quota` 의
* `attachmentTotalMaxBytes` 가 같은 값을 써야 화면이 미리 막는 값과 서버가 거절하는 값이 어긋나지 않는다.
* 엔진이 한도를 알려 주지 않으면(null·0 이하·정수가 아님) 설정 한도만 쓴다.
*/
export function resolveAttachmentsTotalLimit(
configuredBytes: number,
engineBytes: number | null | undefined,
): number {
const engineKnown =
typeof engineBytes === 'number' &&
Number.isSafeInteger(engineBytes) &&
engineBytes > 0;
return engineKnown ? Math.min(configuredBytes, engineBytes) : configuredBytes;
}
/**
* 화면 표시·헤더용으로 파일명을 정리한다.
* - 경로 구분자 앞부분을 버린다 (클라이언트가 보낸 경로를 쓰지 않는다)
* - 제어 문자와 방향 제어 문자(확장자를 거꾸로 보이게 하는 U+202E 등)를 지운다
* - 길이를 제한하되 확장자는 남긴다
*/
export function sanitizeFileName(rawName: string): string {
const lastSegment = rawName.split(/[\\/]/).pop() ?? '';
const cleaned = Array.from(lastSegment)
.filter((char) => !isControlOrBidiChar(char.codePointAt(0) ?? 0))
.join('')
.trim();
const chars = Array.from(cleaned);
if (chars.length === 0) return FALLBACK_FILE_NAME;
if (chars.length <= MAX_FILE_NAME_LENGTH) return cleaned;
const extensionChars = Array.from(extname(cleaned));
if (extensionChars.length >= MAX_FILE_NAME_LENGTH) {
return chars.slice(0, MAX_FILE_NAME_LENGTH).join('');
}
return (
chars.slice(0, MAX_FILE_NAME_LENGTH - extensionChars.length).join('') +
extensionChars.join('')
);
}
/**
* 원문 파일 이름의 제목 부분 최대 글자 수(코드 포인트). 한글(UTF-8 3바이트)뿐이면 `.eml` 까지 240+4 바이트라
* 파일 시스템의 이름 한도(255바이트) 안에 든다. 4바이트 문자(이모지 등)가 많으면 넘을 수 있는데,
* 그때는 브라우저가 저장하면서 이름을 줄인다 (서버는 더 자르지 않는다)
*/
const SOURCE_NAME_MAX_CHARS = 80;
/** 제목이 없거나 정리하고 비면 쓰는 이름 */
const SOURCE_FALLBACK_NAME = '메일';
/** 원문 파일의 확장자 */
const SOURCE_EXTENSION = '.eml';
/** 파일 이름에 쓸 수 없는 문자(경로 구분자와 Windows 예약 문자) — '_' 로 바꾼다 */
const FILE_NAME_RESERVED_CHARS: ReadonlySet<string> = new Set([
'\\',
'/',
':',
'*',
'?',
'"',
'<',
'>',
'|',
]);
/** 이름 앞뒤에서 떼는 문자 — 공백과 점(앞의 점은 숨김 파일, Windows 는 끝의 점을 지운다) */
const NAME_EDGE_CHARS: ReadonlySet<string> = new Set([' ', '.']);
const WHITESPACE = /\s/u;
/** 앞뒤의 공백·점을 뗀다 — 반복 정규식 없이 한 번 훑는다 (긴 제목에서도 입력 길이에 비례) */
function trimNameEdges(chars: readonly string[]): string[] {
let start = 0;
let end = chars.length;
while (start < end && NAME_EDGE_CHARS.has(chars[start])) start += 1;
while (end > start && NAME_EDGE_CHARS.has(chars[end - 1])) end -= 1;
return chars.slice(start, end);
}
/**
* 원문 내려받기 파일 이름 — 제목(없으면 "메일") + `.eml` (계약 2절 "스팸 · 원문").
* 공백(탭 · 줄바꿈 포함)은 하나로 줄이고, 제어 · 방향 제어 문자는 지우고, 경로 구분자와 Windows 에서 못 쓰는 문자는 '_' 로 바꾼다.
* 앞뒤의 공백 · 점을 떼고 SOURCE_NAME_MAX_CHARS 글자까지 자른다. 헤더 인코딩은 content-disposition.ts 가 한다
*/
export function messageSourceFileName(subject: string): string {
const chars: string[] = [];
for (const char of subject) {
if (WHITESPACE.test(char)) {
if (chars.at(-1) !== ' ') chars.push(' ');
continue;
}
if (isControlOrBidiChar(char.codePointAt(0) ?? 0)) continue;
chars.push(FILE_NAME_RESERVED_CHARS.has(char) ? '_' : char);
}
const base = trimNameEdges(
trimNameEdges(chars).slice(0, SOURCE_NAME_MAX_CHARS),
).join('');
return `${base || SOURCE_FALLBACK_NAME}${SOURCE_EXTENSION}`;
}
/** C0·C1 제어 문자, DEL, 방향 제어 문자 */
function isControlOrBidiChar(codePoint: number): boolean {
return (
codePoint <= 0x1f ||
(codePoint >= 0x7f && codePoint <= 0x9f) ||
codePoint === 0x200e ||
codePoint === 0x200f ||
(codePoint >= 0x202a && codePoint <= 0x202e) ||
(codePoint >= 0x2066 && codePoint <= 0x2069)
);
}
@@ -0,0 +1,119 @@
import {
Body,
Controller,
Get,
HttpStatus,
Param,
Post,
Put,
UseGuards,
} from '@nestjs/common';
import { ApiCookieAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import {
ApiErrorResponse,
ApiStandardResponse,
} from '@/common/decorators/api-standard-response.decorator';
import {
CurrentUser,
type AuthenticatedUser,
} from '@/common/decorators/current-user.decorator';
import { JwtAuthGuard } from '@/common/guards/jwt-auth.guard';
import { MAX_ATTACHMENTS_PER_MESSAGE } from './attachment.policy';
import { DraftDto, DraftSavedDto } from './dto/mail-response.dto';
import { DraftBodyDto } from './dto/send-message.dto';
import { MailIdPipe } from './mail-id.pipe';
import { DRAFTS_ROUTE } from './mail.constants';
import { MailService } from './mail.service';
/** 저장 요청의 400 실패 — 발송 본문과 같은 한도 */
const SAVE_BAD_REQUEST =
`MAIL_002 받는 사람 합계 100명 초과 · MAIL_003 메일함 용량 · MAIL_004 첨부를 찾을 수 없음 · MAIL_006 첨부 ${MAX_ATTACHMENTS_PER_MESSAGE}개 초과 · ` +
'VAL_001 형식(제목 255자 초과·draftId 같은 모르는 필드 포함) · FILE_001 첨부 형식 (모두 400)';
// 임시 저장 (계약 2절 "임시 저장") — 목록·버리기·되돌리기는 메일 API(GET·DELETE·PATCH /api/messages) 그대로다.
// 저장 본문은 발송 본문과 같은 필드·한도이고 JSON 본문 4MB 까지 받는다 (app.setup.ts — 앞단 nginx 도 같은 값이어야 한다).
@ApiTags('Mail')
@ApiCookieAuth()
@UseGuards(JwtAuthGuard)
@ApiErrorResponse(
'AUTH_001',
'로그인 필요 (쿠키 없음·만료) — 클라이언트는 /auth/refresh 를 한 번 시도한다',
)
@Controller(DRAFTS_ROUTE)
export class DraftController {
constructor(private readonly mail: MailService) {}
@Get(':id')
@ApiOperation({
summary: '이어 쓸 임시 저장본',
description:
'서식 본문은 보내는 정화(서식 본문 허용 목록 + 내 서명 그림)를 거친 값이다 — 다른 메일 프로그램이 만든 초안도 같다. ' +
'임시보관함의 초안이 아니면(발송 중에 생긴 내부 초안 포함) 404.',
})
@ApiStandardResponse(DraftDto, { description: '임시 저장본' })
@ApiErrorResponse('RES_001', '없는 메일 · 임시보관함의 초안이 아님')
@ApiErrorResponse('MAIL_001', '메일 엔진 장애')
getDraft(
@CurrentUser() user: AuthenticatedUser,
@Param('id', MailIdPipe) id: string,
) {
return this.mail.getDraft(user, id);
}
@Post()
@ApiOperation({
summary: '임시 저장 — 처음 저장',
description:
'발송 본문과 같은 필드·한도(받는 사람 0명도 된다, draftId 없음). 발송 중복 방지용 Message-ID·지문 헤더는 넣지 않는다. ' +
'본문이 너무 길면 400 MAIL_007.',
})
@ApiStandardResponse(DraftSavedDto, {
status: HttpStatus.CREATED,
description: '저장한 임시 저장본 — 다음 저장은 PUT /api/drafts/{id}',
})
@ApiErrorResponse('MAIL_002', SAVE_BAD_REQUEST)
@ApiErrorResponse(
'MAIL_007',
'MAIL_007 본문이 너무 김 (400) — 발송과 같은 한도 (텍스트 100,000자·서식 500,000자·정화 전 비용·정화 뒤 2,000,000바이트)',
)
@ApiErrorResponse('FILE_002', '첨부 합계 크기 초과 · JSON 본문 4MB 초과')
@ApiErrorResponse('MAIL_001', '메일 엔진 장애')
createDraft(
@CurrentUser() user: AuthenticatedUser,
@Body() dto: DraftBodyDto,
) {
return this.mail.createDraft(user, dto);
}
@Put(':id')
@ApiOperation({
summary: '임시 저장 — 바꿔 저장 (전체 교체, 새 id)',
description:
'메일은 고칠 수 없어 새 초안을 만든 뒤 옛것을 지운다 — 응답의 id·attachments 로 바꿔 쓴다. ' +
'{id} 가 임시보관함의 초안이 아니면 아무것도 지우지 않고 404 RES_001, ' +
'옛 초안이 이미 없으면(다른 창·기기에서 먼저 고쳤거나 지웠음) 저장하지 않고 409 MAIL_008 — 클라이언트는 POST 로 새로 남긴다.',
})
@ApiStandardResponse(DraftSavedDto, {
description: '새 임시 저장본 (새 id)',
})
@ApiErrorResponse('MAIL_002', SAVE_BAD_REQUEST)
@ApiErrorResponse(
'MAIL_007',
'MAIL_007 본문이 너무 김 (400) — 발송과 같은 한도',
)
@ApiErrorResponse('RES_001', '임시보관함의 초안이 아님 (지우지 않았다)')
@ApiErrorResponse(
'MAIL_008',
'다른 곳에서 먼저 바뀌었거나 지워짐 — 저장하지 않았다 (POST 로 새로 남긴다)',
)
@ApiErrorResponse('FILE_002', '첨부 합계 크기 초과 · JSON 본문 4MB 초과')
@ApiErrorResponse('MAIL_001', '메일 엔진 장애')
replaceDraft(
@CurrentUser() user: AuthenticatedUser,
@Param('id', MailIdPipe) id: string,
@Body() dto: DraftBodyDto,
) {
return this.mail.replaceDraft(user, id, dto);
}
}
@@ -0,0 +1,102 @@
import { plainToInstance } from 'class-transformer';
import { validate } from 'class-validator';
import { MAX_BULK_MESSAGE_IDS } from '@/modules/mail/mail.constants';
import { MessageDeletionDto, UpdateMessagesDto } from './bulk-messages.dto';
/** 전역 ValidationPipe 와 같은 옵션으로 검증한다 (app.setup.ts — stopAtFirstError 만 빼고 — 걸린 규칙을 모두 본다, 통과 · 실패는 같다) */
async function constraintsOf(
type: typeof UpdateMessagesDto | typeof MessageDeletionDto,
body: object,
): Promise<string[]> {
const errors = await validate(plainToInstance(type, body), {
whitelist: true,
forbidNonWhitelisted: true,
});
return errors.flatMap((error) => Object.keys(error.constraints ?? {}));
}
const ids = (count: number) =>
Array.from({ length: count }, (_, index) => `m${index}`);
describe('여러 통 요청 본문 (PATCH /api/messages · POST /api/message-deletions)', () => {
it.each([
['PATCH', UpdateMessagesDto, { isRead: true }],
['영구 삭제', MessageDeletionDto, {}],
] as const)(
`%s — ids 는 1~${MAX_BULK_MESSAGE_IDS}개, 같은 id 두 번 · 형식이 틀린 id 는 형식 오류`,
async (_name, type, changes) => {
expect(await constraintsOf(type, { ids: ['m1'], ...changes })).toEqual(
[],
);
expect(
await constraintsOf(type, {
ids: ids(MAX_BULK_MESSAGE_IDS),
...changes,
}),
).toEqual([]);
expect(await constraintsOf(type, { ids: [], ...changes })).toEqual([
'arrayMinSize',
]);
expect(
await constraintsOf(type, {
ids: ids(MAX_BULK_MESSAGE_IDS + 1),
...changes,
}),
).toEqual(['arrayMaxSize']);
expect(
await constraintsOf(type, { ids: ['m1', 'm1'], ...changes }),
).toEqual(['arrayDistinct']);
for (const bad of ['../m1', '', 'm 1', 1, null]) {
expect(
await constraintsOf(type, { ids: ['m1', bad], ...changes }),
).toEqual(['matches']);
}
expect(await constraintsOf(type, { ...changes })).toContain('isArray');
expect(await constraintsOf(type, { ids: 'm1', ...changes })).toContain(
'isArray',
);
},
);
it('PATCH 의 바꿀 것은 한 통 PATCH 와 같은 규칙이다 — null 은 형식 오류, 모르는 필드는 거부', async () => {
expect(
await constraintsOf(UpdateMessagesDto, {
ids: ['m1'],
isRead: false,
isFlagged: true,
mailboxId: 'b',
}),
).toEqual([]);
expect(
await constraintsOf(UpdateMessagesDto, { ids: ['m1'], isRead: null }),
).toEqual(['isBoolean']);
expect(
await constraintsOf(UpdateMessagesDto, {
ids: ['m1'],
mailboxId: '../b',
}),
).toEqual(['matches']);
expect(
await constraintsOf(MessageDeletionDto, { ids: ['m1'], isRead: true }),
).toEqual(['whitelistValidation']);
});
it('스팸 표시(isJunk)도 한 통 PATCH 와 같다 — true · false, null · 문자열은 형식 오류', async () => {
for (const isJunk of [true, false]) {
expect(
await constraintsOf(UpdateMessagesDto, {
ids: ['m1'],
mailboxId: 'c',
isJunk,
}),
).toEqual([]);
}
for (const isJunk of [null, 'true']) {
expect(
await constraintsOf(UpdateMessagesDto, { ids: ['m1'], isJunk }),
).toEqual(['isBoolean']);
}
});
});

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