엔진(Stalwart 0.16.27)의 TOTP 와 앱 비밀번호를 쓰되, 엔진이 하지 않는 것을 서버가 맡는다.
- 등록 때 첫 코드 확인, 성공에 쓴 코드 2분 막기(확인 중인 같은 코드도), 코드 실패 한도(15분 5 · 24시간 20)
- 웹메일 로그인 · 비밀번호 바꾸기에서 앱 비밀번호 모양 거절, 앱 비밀번호는 메일 프로그램 권한만
- 엔진 Basic 의 402 를 장애(MAIL_001)로 다루던 것을 고쳐 코드 단계로 — 402 면 정식 주소로 한도 · 기록
- 비밀번호를 바꾸면 앱 비밀번호도 지우고, 끊은 세션은 액세스 토큰의 sid 로 바로 막는다
- 메일 프로그램 로그인은 엔진 웹훅(소켓 주소 · HMAC · 내용 지문 · 1,000행씩)으로 받아 90일 남긴다
- 요청 대상에 날 \ · # 가 있으면 라우팅 전에 400 — Express 가 이를 다시 읽어 앞단이 닫은 경로가 열렸다
에러 코드 AUTH_006 ~ AUTH_009 와 웹 · 앱 사전, 마이그레이션 AddAccountSecurity 를 함께 넣었다.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- file-storage: /api/file-uploads(만들기 · 16MiB 조각 PUT — Content-Digest · datasync · 같은 조각은 하나만 · 끝내기 · 상태 · 지우기),
/api/file-quota. 보관량(직원 · 서버 · 디스크 여유)은 pg_advisory_xact_lock 안에서 센다. 저장 포트 FILE_BLOB_STORE(지금은 로컬 볼륨).
ClamAV INSTREAM 을 직접 구현했다 — 판정 글은 한 곳(암호 · 한도 → 검사하지 못함, 스트림 크기 한도 → 설정 오류로 검사 중 그대로 + error),
다 보낸 뒤 판정 대기 30분 + 1MB 마다 1초 · TCP keepalive. 검사 차례는 DB pending(동시 2), 정리 cron.
- file-sharing · mail: 메일 한 통에 링크 하나(토큰 256비트 — DB 에는 SHA-256 만). 보낼 때 서버가 본문에 덩이를 넣고(표시는 서버만 —
두 정화기가 사용자 HTML 의 data-ara-* · 주석을 지운다), 링크는 확실한 실패(SendNotStartedException)에서만 거둔다.
공개 /api/public-links/{token} · 파일 받기(Range · 조건부 UPDATE 로 받은 수), 링크 끊기 · 목록 · 읽기 화면의 largeFiles.
- 공개 호스트 미들웨어는 원문 · 라우터 경로 · WHATWG URL · 퍼센트를 푼 원문을 모두 보고(리뷰 — 절대 주소 꼴 · \ 요청이 막기를 지났다),
라우팅은 대소문자를 가린다(createHttpAdapter — /FILES/ 같은 변형이 앞단 상한을 비켜 갔다).
- env: SHARE_PUBLIC_URL(nginx 와 같은 모양 검사 · 운영에서 포털과 같은 호스트면 기동 중단) · MAIL_LARGE_FILE_* · FILE_STORAGE_* · CLAMAV_*.
- 에러 코드 FILE_003 ~ FILE_008 · MAIL_016 — 웹 · 앱 사전 다시 생성. 마이그레이션 둘.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
첫 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>
본문 크기 한도 안에서도 작은 객체 수만 개면 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>
- /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>
메일 쓰기에 서식 편집기가 들어오면서 서버가 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>
첫 사내 모듈인 웹메일의 백엔드다. 메일 엔진은 외부(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>