- 직원마다 엔진 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>
- 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>
- 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>
- /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>
"같은 주소로 동시에 보낸 30건 — 엔진 확인은 1번" 테스트는 대역 엔진이 고정 500ms 를 붙잡는 동안 30건이 모두 닿기를 바랐다.
바쁜 기계(이 호스트는 다른 프로젝트의 CI 러너도 돈다)에서 요청이 500ms 넘게 퍼지면 늦은 요청이 엔진을 한 번 더 불러 실패할 수 있었다.
- 고정 대기 대신 문(gate)을 쓴다 — 엔진은 테스트가 열 때까지 답하지 않고, 테스트는 나머지 29건이 REQ_001 로 돌아온 뒤에 연다.
증명하는 것은 그대로다: 한 주소의 확인이 엔진에 가 있는 동안 같은 주소의 다른 시도는 엔진에 묻지 않고 거절된다
- supertest 는 듣고 있지 않은 서버를 요청마다 열고 닫아, 동시에 보낸 요청이 닫히는 중인 서버에 거절될 수 있었다 —
이 테스트는 서버를 미리 열고 끝나면 닫는다
- 기한(10초)을 넘기면 멈추지 않고 분명히 실패하고, 문은 언제나 연다 (finally)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- 본문이 너무 길면 일반 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>
다른 작업과 함께 돌면 혼자 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>
메일 쓰기에 서식 편집기가 들어오면서 서버가 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>