메일 16단계(개인 설정)의 서버다.
- 주소록: POST /api/contact-import-checks · contact-imports · contact-deletions 로 여러 명을 한 번에
확인 · 만들기 · 지우기(화면이 준 uid 로 다시 보내도 두 번 들어가지 않는다), GET /api/contact-exports 로
vCard 3.0 · CSV(BOM · 한국어 머리 · 수식 막기) 내보내기 — 하나씩 만들면 사무실 요청 수 한도에 걸린다.
- 여러 서명: /api/mail-signatures · /api/mail-signature-defaults, 서명마다 한 줄로 바꾸는 마이그레이션
(지금 서명은 "기본 서명"으로 — 값 보존), 그림 정리는 모든 서명의 합집합. 옛 /api/mail-signature 는 앱 첫 판을 위해 그대로.
- 부재중: 서식 본문(multipart — 문자열 4,096바이트는 variables 조각으로)과 회사 밖 응답(봉투 도메인으로 가름 —
답장도 그 주소로만), 원본 판 3 은 새 칸을 쓴 직원만.
- 2단계 인증: 켠 채 휴대폰 바꾸기(새 코드 확인 → 지금 코드 · 비밀번호로 엔진 요청 하나 — 꺼지는 틈 없음).
새 에러 코드 · 새 의존성 없음.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
루트 README · CLAUDE.md · 점검 목록이 아직 "풀스택 모노레포 템플릿"으로 소개하고 있었다 — 사내 그룹웨어(메일 운영 중)에 맞게
지금 상태 · 구성(메일 엔진 · 리졸버 · ClamAV · 운영 스크립트 포함) · 문서 지도 · 디렉터리 구조로 다시 쓰고,
백엔드를 호스트에서 npm ci 하라던 옛 안내(컨테이너 안에서만 — 규칙과 반대)를 뺐다.
docs/roadmap.md 에 메일 1~16단계 상태(16단계 첫 항목: 주소록 가져오기 · 내보내기) · 공통 기반 · 2부 · 앱 · 추후 후보 모음을 둔다.
backend · frontend README 는 프레임워크가 만든 영어 안내 대신 이 저장소의 실행 · 검사 · 참고 문서로,
규칙 · 스킬 · 역할 문서의 "템플릿" 표현("템플릿 기본 제공 골격" 등)은 "기본 골격"으로 바꿨다(nginx 설정 템플릿은 그대로).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- PATCH /api/mailboxes/{id} 에 parentId — 맨 위(null) · 자기 하위로는 VAL_001 · 새 상위에 같은 이름(대소문자 무시)은 MAIL_009 · 역할 메일함 막기
- DELETE /api/mailboxes/{id}?withChildren=true — 나무를 깊은 것부터 지금 지우기와 같은 차례로(휴지통으로 → destroy)
- POST /api/mail-block-runs — 받은편지함에서 차단한 보낸 사람의 메일을 스팸메일함으로(Sieve 차단 규칙과 같은 판정,
필터 적용과 같은 cursor · 잠금, 도는 동안 스팸메일함 잠금)
- POST /api/scheduled-messages/{id}/release — 예약 제출을 취소하고 원래 envelope 으로 다시 제출(보내기 취소 설정만큼 붙잡음).
결과를 모르는 실패면 다시 내지 않고 503 — 같은 요청이 이어 낸다(두 통이 나가지 않게), 엔진이 분명히 거절했을 때만 임시보관함으로
엔진 사실은 개발 엔진(Stalwart 0.16.27)으로 확인했다: parentId 바꾸기 · 고리 거절, destroy 는 적은 차례대로,
from 검색은 비슷한 주소 · 표시 이름의 주소까지 넓게 잡힘, 취소 뒤 다시 제출하면 받는 사람에게 한 통.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
앱을 스토어 없이 사내에만 나눠 주기로 해(2026-11-08), 로그인한 직원만 최신 안드로이드 APK 의
정보와 파일을 받게 한다(GET /api/app-releases/android/latest · /latest/file — 제한된 세션은 막힌다).
올리기는 API 없이 서버의 운영 도구로 한다(파일 검사 · SHA-256 · 임시 이름에 쓴 뒤 바꾸기 · 실행 기록).
최신은 가장 늦게 올린 판이라 옛 APK 를 다시 올려 되돌릴 수 있고, app-config 의 최소 판보다 낮은 판은 올리지 않는다.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
휴대폰 앱 받기(다음 커밋)가 대용량 첨부 받기와 같은 머리글을 쓰게 하려고,
file-sharing 의 writeFileHeaders 가 만들던 머리글을 shared/utils/download-headers.ts 로 옮겼다.
머리글 이름 · 값 · 차례는 그대로다(새 단위 시험과 대용량 받기의 단위 · e2e 가 지킨다).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
이 PR 이 앱 서버 PR 뒤에 빨리 감기로 합쳐지고 CI 가 합친 모습 그대로를 시험하게 한다.
계약 1절의 앱 절은 앱 서버 쪽이 들고 있고 이 브랜치는 2절 줄만 더했다 — 충돌 없음.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
이 PR 이 문구 다듬기 PR 뒤에 빨리 감기로 합쳐지고 CI 가 합친 모습 그대로를 시험하게 한다.
충돌 넷은 모두 설정 › 로그인된 기기의 글이다 — 앱 쪽은 앱 세션이 함께 보여 "브라우저"를 "기기"로,
다듬기 쪽은 같은 안내 문장을 규칙 9 로("여기에 보이지 않습니다" → "이 목록에 없습니다") 바꿨다.
뜻은 앱 쪽 · 문장은 다듬은 글로 풀었고(account-security.ts · LoginSessionList.vue 주석 · 디자인 README),
시험은 두 쪽이 더한 상수를 모두 남기고 글자 그대로 견주던 줄을 상수로 바꿨다(AccountLoginHistoryPage.spec.ts).
합친 뒤 backend tsc · lint 0 · 단위 3,701 · e2e 333, frontend type-check · lint 0 · 단위 2,774 통과.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
14단계 검색 브랜치에 main(#16 — 백엔드 의존성 넷 · 유예 표)을 합친 판을 받는다.
이 PR 이 14단계 PR 뒤에 빨리 감기로 합쳐지고 CI 가 합친 모습 그대로를 시험하게 한다. 충돌 없음.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
#16(백엔드 의존성 넷 · 유예 표)이 갈래점 ba80956 뒤에 main 에 들어갔다 — 이 PR 이 빨리 감기로 합쳐지고 CI 가 합친 모습 그대로를 시험하게 한다. 충돌 없음.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Stalwart 0.16.27 의 Email/query after 는 그 초를 넣지 않는다(2026-11-08 개발 엔진에서 확인 — RFC 8621 은 "같거나 뒤").
그래서 색인이 준비되기 전에 쓰는 엔진 길의 조건 검색이 계약의 "after 이상"보다 1초 좁았다(시작 날 0시 정각에 받은 메일이 빠짐).
시각을 초로 올린 뒤 한 초 앞을 넘겨 색인 길(>=)과 같은 경계로 맞춘다(toJmapAfterInclusive).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
엔진 연결을 붙잡자마자 메일이 오면 따라잡기와 그 메일의 새 메일 신호가 함께 보내 알림이 두 번 갔다(커밋 전 다시 리뷰 N1).
붙잡은 뒤 신호를 이미 받았으면 따라잡기를 건너뛰고, 따라잡기 뒤 간격 끝의 보내기는 따라잡기가 센 뒤에 받은 메일이 있을 때만 보낸다
(기록 저장이 실패해도 겹치지 않게 메모리로 본다). 옛 주석의 "로그인이 저절로 끝남"을 끝난 까닭 표로 고치고 계약 · 스킬의 문장도 맞췄다.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
앱 첫 판의 새 메일 알림을 서버가 보낸다. 알림 글은 "새 메일 N통"뿐이라 보낸 사람 · 제목 · 메일 id 는 Google 을 거치지 않는다.
- 기기 등록: PUT · DELETE /api/push-registrations/current, 봤음 DELETE /api/push-registrations/current/unseen.
앱 토큰만 받고(AppClientGuard — 웹 세션은 AUTH_003), 세션마다 한 줄, 같은 FID 는 한 줄만 둔다
(같은 휴대폰에 다른 직원이 로그인하면 앞 직원의 줄을 지운다). 마이그레이션 AddPushRegistrations.
- 새 메일 알아채기: 5단계 엔진 연결을 푸시를 켠 직원에게 붙잡아 둔다(MailEventsService.hold).
웹 실시간 알림과 같은 판정이고, 같은 직원은 30초에 한 번까지, N 은 보낼 때 엔진에서 센다(웹에서 읽은 메일은 빠진다).
Stalwart 의 Email/query after 는 그 초를 넣지 않아 어댑터가 한 초 앞을 넘긴다(dev 엔진에서 확인).
- 보내기는 새 의존성 없이 FCM HTTP v1 의 fid 대상으로(RS256 JWT · OAuth 토큰 기억 · 다시 하기 · 보내기 직전 다시 보기).
- 직원이 그 휴대폰을 건드리지 않았는데 앱 세션이 끝나면(저절로 끝남 · 다른 곳에서 비밀번호 바꿈 등)
그 기기에 "새 메일 알림이 멈췄습니다"를 한 번 보낸다(SESSION_ENDED_NOTICE).
- GET /api/app-config(로그인 없음 · no-store) — 최소 판과 알림을 쓸 수 있는지.
- 서비스 계정 키 FCM_SERVICE_ACCOUNT_KEY(backend env — 없으면 기동하고 알림만 끈다), 퇴사 마무리에 알림 등록을 더한다.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
앱 새 메일 알림(푸시)도 14단계 검색 색인처럼 서버 전체 동시 수와 직원마다 한 번에 하나를 쓴다.
푸시 모듈이 mail-search 를 가져오지 않게 ConcurrencyGate 를 shared/utils 로 옮기고,
직원마다 차례를 KeyedQueue 로 뽑아 MailSearchRuns 가 상속하게 했다. 14단계 동작은 같다.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
앱 새 메일 알림(푸시)의 서버 일을 앱 세션과 14단계 위에서 하려고 합친다.
겹친 곳은 두 곳이다. auth.module.ts 는 import 둘을 함께 두고,
AuthService.login() 은 enrollAfterLogin(14단계)을 남긴 뒤 recordSignIn(앱 로그인)으로 기록한다.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 사용자가 "자연스러운 문구가 아니라 시스템, 설계 문구 형식"이라고 짚은
글을 고친다(규칙 9 · 10 — 화면 설명 · 번역투 빼기). "일시적인 시스템 오류가
발생했습니다" · "요청하신 정보나 페이지를 찾을 수 없습니다" · "허용 범위를 초과했습니다"
같은 안내문 말투를 무엇이 안 됐고 무엇을 하면 되는지로 바꾼다.
적용하며 셋을 더 다듬었다: REQ_001 은 가장 자주 보는 로그인 · 코드 실패 한도와 진행
중인 일에 함께 맞게, MAIL_003 은 용량이 휴지통도 세므로 휴지통 비우기까지, FILE_001
은 전달하는 원본 첨부에도 맞게. 코드 · 상태 · 뜻은 그대로이고, 웹 · 앱 사전은 sync 로
다시 만들었다.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
앱 디자인 README 「검색」 절, flutter 스킬 표 · 함정 두 줄 · CI 경로, 계약 2절
"검색 — 14단계"의 앱 클라이언트 줄과 목록 항목의 searchHighlight 칸이 늘 있다는
줄, 두 표본 표 머리말(앱 시험이 백엔드 표를 읽는다)을 적는다.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
앱 검색 화면(App1Search)은 계약 2절 "검색 — 14단계", 웹 글 원본과
백엔드 표본 표(backend/test/search-text-samples.ts)를 짝 시험으로 읽는다.
feat/mail-search-index 는 아직 main 에 없어서, 갈래점이 ba80956 인 앱 브랜치에
먼저 합친다. 14단계가 main 에 들어가면 이 브랜치를 main 위로 옮긴다.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
앱 디자인 README 「쓰기」(첨부 · 나가기 · 알려진 한계 · 실기기에서 볼 것),
flutter 스킬(받은 파일 · 고른 파일 · 형식 줄, 메일 쓰기 줄, Kotlin 을 고쳤을 때의
apk 빌드, 시험 함정), 백엔드 형식 표(ATTACHMENT_TYPES)에 앱 형식 표와의 짝 주석.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
14단계 실사용 시험(2026-11-06)에서 나온 것을 고친다.
- 1만 통 메일함 안 검색이 1.1초였다. 계획기가 메일함만 담은 GIN(메일함 id
가 계정마다 같은 꼴이라 posting 이 회사 전체)으로 그 메일함 줄을 다 읽고
묶음 배열을 줄마다 풀었다. 직원 · 묶음 · 메일함을 한 색인에 담은 GIN
하나로 바꾸고 옛 GIN 둘을 지운다(마이그레이션 SearchIndexUserGramsMailboxes
— 큰 메일함 15ms · 작은 메일함 1ms · 전체 15ms)
- 한 글자 낱말뿐이면 본문을 찾지 않는데 본문 글자를 강조했다 — 본문 강조는
본문을 찾은 두 글자 이상 낱말만
- 답장의 결과 미리 보기가 원문 인용 조각이었다 — 인용 줄 밖의 맞은 곳을
먼저, 인용 안에서만 맞고 다른 칸에서도 맞았으면 본문 첫머리, 인용 조각은
줄 앞 `>` 를 뗀다
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
"등록 → 켜기 → … → 끄기" 시나리오는 끄기에 "그때 단계 −1" 코드를 썼다. 켜기와 끄기 사이에
30초 경계를 넘으면 그 코드가 켤 때 쓴 코드와 같아져, 서버의 다시 쓴 코드 막기가 AUTH_006 으로
거절해 가끔 실패했다(코드는 맞게 거절한 것이다). 이미 쓴 코드가 아닌 것을 0 → +1 → −1 단계에서
고르고(0 · +1 은 요청이 경계를 넘어도 서버의 ±1 창 안), −1 만 남았는데 경계가 1초 안이면
경계를 기다린 뒤 다시 고른다. 따로 다섯 번 돌려 모두 통과했다.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
계약 1절 "앱 로그인"이다(웹의 쿠키를 앱에 쓰지 않는다). 세션 규칙은 웹과 같고 토큰을 나르는
길만 다르다.
- POST /api/auth/app/login · refresh · logout — 같은 AuthService.login 을 지나 실패 카운터 ·
2단계 코드 가드 · 앱 비밀번호 모양 거절 · 관리자 차단이 웹과 같고, 로그인 요청 수 한도는
웹 · 앱이 한 칸을 함께 쓴다. 토큰 응답은 private, no-store
- Bearer 는 앱 표시(cli: app)와 UUID sid 가 있는 액세스 토큰만, 쿠키는 웹 토큰만 — 리프레시 토큰 ·
세션 표의 client 도 맞아야 하고 다르면 401(재사용으로 보지 않음)
- 앱 세션의 갱신 유예는 그 세션을 끝낸다(app-refresh-grace — 응답을 잃었거나 토큰을 훔친 것이다)
- 지금 세션은 앱이면 액세스 토큰의 sid(비밀번호 바꾸기 · 로그인된 기기 · 앱 logout), 로그아웃은
리프레시 토큰이 틀리면 sid 로 끝낸다
- 세션 표 client 칸(마이그레이션 AddAppSessionClient — 기본 web), 세션 목록 항목에 client,
앱 이름 "ARA SYSTEM 앱"은 client · method 로만(꾸민 User-Agent 로 앱처럼 보이지 않게),
로그인 기록 method app
- 운영 도구: 현황의 마지막 로그인이 앱도 세고, 세션 끊기 글 · 직원 알림 메일이 앱을 말한다
- Swagger 에 Bearer 를 등록하고 쿠키로 보호하는 작업마다 "또는"으로 붙인다
리뷰 반영: 유예에서 앱 세션 끊기, 앱 이름을 진짜 클라이언트로만, sid 없는 앱 토큰 거절,
빈 · 너무 긴 리프레시 토큰은 401(로그아웃은 sid 로), 시험(웹 쿠키 + 앱 logout 등).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
14단계 3차다. 같은 대화(답장 · 전달로 이어진 메일)를 읽기 화면에 보이고, 목록을 대화로
묶어 볼 수 있게 한다. 색인이 준비된 직원은 우리 대화 키로, 채우는 중인 직원은 엔진의
스레드로 묶는다(응답의 basis — 첫 쪽에서 정하고 커서가 잇는다).
- 대화 키: 색인 쓰기 때 넣은 메일의 부모 · 자식(References · In-Reply-To) 가운데 말머리
(회신 · 답장 · 전달 포함)를 뗀 제목이 같은 것을 가장 작은 키로 합친다. 늦게 온 원본도
잇고, 다시 넣어도 키는 풀리지 않는다. 색인 판 4
- GET /api/messages/{id}/conversation — 오래된 차례, 휴지통 · 스팸메일함에만 든 메일과
발송 중인 내부 초안은 빼고 지금 메일은 늘 넣는다
- GET /api/conversations?mailboxId= — 그 메일함의 대화 한 줄씩(참여자 10명 · 통 수 ·
messageIds 최신 500 · pendingSendIds). 직원 주소 btree 로 먼저 좁힌다(10명 흉내 큰 메일함
첫 쪽 81ms)
- 설정 groupByConversation(처음 끔) — 서버는 저장만 하고 묶을지는 화면이 정한다
- 마이그레이션 AddMailSearchSender — 참여자는 따로 둔 첫 보낸 사람 칸에서만 읽는다
리뷰 반영: 엔진 방식에 개수 · 시간 상한(같은 대화 한 요청 1,000통 · 묶은 목록 스레드마다
500 · 한 요청 2,000 · 15초, 요약 속성은 내보낼 메일만), Mailbox/get 장애는 503,
꾸민 커서는 엔진 · DB 보다 먼저 400, 같은 대화 한 문장, 이름 안의 <주소>로 참여자를
꾸미지 못하게, 로그 추적 ID. 검색 표본 표 머리 주석(frontend 로 옮겼다)도 함께 고친다.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
14단계 2차다. 색인이 준비된 직원은 서버의 자체 색인으로, 채우는 중인 직원은 지금처럼
엔진으로 찾는다(방식은 첫 쪽에서 정하고 커서가 잇는다).
- 조건 from · to(참조 포함) · subject · body · after · before · hasAttachment — q 와 합친 낱말 8개까지
- 부분 일치(대소문자 · 전각 · NFC), 구절은 줄바꿈 · NBSP 로 갈린 글에도, 한 글자 낱말뿐이면
제목 · 사람 · 첨부 이름에서만(2026-11-05 사용자 확정)
- 찾기: GIN(lower(user_email), grams) 후보 → 기본 키로 확인, 한 요청 확인 3,000줄 상한 +
이어 찾기 커서, total 은 첫 쪽에서 1,000 까지(못 세면 null) — 1만 통에서 가장 무거운 검색 0.8초
- 목록 항목 searchHighlight(제목 · 맞은 말 둘레 미리 보기), 읽기 화면은 정화한 HTML 의 글
조각에 <mark> · 제목 · 첨부 이름 · 글 본문 위치
- 마이그레이션 SearchIndexUserGrams(btree_gin), 색인 판 2(공백 접기) — 판이 바뀌면 그 직원의
색인을 지우고 밤에 다시 채운다
리뷰 반영: 옛 판 줄 지우기, 꾸민 커서 · NUL 은 VAL_001 · U+FFFD, 한 글자 길 한 트랜잭션,
다른 직원 줄 격리 e2e.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
14단계 자체 검색 색인의 1차다. 검색 API 를 색인으로 바꾸는 일과 대화 키 잇기는 다음 차례다.
- 마이그레이션 AddMailSearchIndex 하나: 표 mail_search_messages · mail_search_states 와
mail_settings.group_by_conversation 열(3차의 대화로 묶어 보기 설정 — 같은 배포에서 한 번에 만든다)
- 접기 · 글자 두 개 묶음 · 맞은 곳 · 말머리 떼기 순수 함수와 표본 표(나중에 frontend 와 짝)
- 엔진 포트 MAIL_INDEX_SOURCE + JMAP 어댑터: Email/changes 맞추기(실시간 · 5분 · 검색 전,
서버 전체 동시 4), 밤 창(KST 22:00~02:45 · 05:00~07:30) 지난 메일 채우기, 전체 대조
- GET /api/search-index, 로그인 · 새 비밀번호 때 상태 줄, 퇴사 마무리에 표 둘
- 로그 가림에 조건 검색 이름 넷, 개발 환경 DB 쿼리 로그 끔(메일 글이 DB 에 든다)
리뷰 반영: 시간 끝은 실패가 아니라 이어 하기, 본문 읽기 묶음의 메일 크기 상한(32MiB ·
16MiB 넘으면 혼자), 겹친 id 빼기, 사내 도메인 주소만 색인(개발 서버 cron 이 e2e 줄을 건드리지 않게).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
security.md 유예 표에서 11-05 를 기다리던 항목 — body-parser 2.4.0(lock 만, qs 6.16.1 ·
iconv-lite 0.7.3 이 따라옴) · multer override ^2.5.0(platform-express 12.3.0 이 2.5.0 을
선언한다 — 11.x 최신도 2.4.0 고정이라 override 를 남긴다) · sanitize-html 2.18.1 · fast-uri 3.1.9.
모두 도달 경로가 없던 권고라 2주 규칙대로 골랐다(--before 로 그보다 새 판은 받지 않았다).
audit 29 → 23(운영 11 → 5, high 3 → 0). js-yaml 은 swagger 11.4.8(11-04)이 2주가 안 돼 그대로 둔다.
npm 11.6.2 컨테이너에서 바꿨다. 임시 복사본에서 npm ci, 인자 없는 npm install 이 lock 을 바이트
그대로 두는지, npm ci --omit=dev 뒤 require, lint · type-check · 단위 3,203 · e2e 266 · build 통과.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
화면 문구(ddfb2d1)와 같은 기준으로 서버가 짓는 글을 고친다.
- 오류 문구 41개 가운데 7개: AUTH_006(「자동」) · VAL_001(괄호 꼬리표 — 길이 한도에도 나와 "형식이나 길이"로) ·
FILE_003 · FILE_004 · MAIL_001(메일 서버 → 메일 서비스) · MAIL_013 · CONTACT_002. 코드 · 상태는 그대로이고
sync:error-codes 로 frontend · flutter 사전을 다시 만들었다.
- 받은편지함 알림 메일 세 통: 줄표 · "이 계정의" · "판정"을 빼고, 메뉴 경로를 화면처럼 [설정] → [앱 비밀번호] 꼴로.
링크를 넣지 않는 규칙 · 이스케이프 · 정화는 그대로다.
- 본문에 넣는 대용량 첨부 덩이의 기한 줄 · 영어 줄. 표시 · 표 모양은 그대로이고, 배포 전에 붙잡아 둔 발송의
텍스트 덩이(옛 글)도 보내기 취소 때 떼도록 LEGACY_BLOCK_WORDINGS 를 둔다(7일 뒤 지워도 된다).
임시 복사본 Linux 컨테이너에서 lint · jest 3,209 · build, frontend lint · type-check · 단위 2,354,
flutter error_lexicon 테스트 · analyze 통과.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
문서만 바꾼 PR(#8, run 138)의 CI 에서 할당이 많은 성능 단언 셋이 넘쳤다(받는 사람 맞추기 3.29초 · JSON 모양 1.35초 · DTO 2.94초).
프로세스 user + system 시간에는 V8 의 GC · JIT 도우미 스레드와 페이지 폴트 몫이 들어가 기계 사정을 따라 크게 흔들린다.
같은 Linux 컨테이너에서 전체 테스트 16회: 프로세스 user + system 은 4회 넘침, 스레드 user 는 0회.
막으려는 옛 경로(제곱)는 거의 user 시간이라 그대로 잡힌다(받는 사람 맞추기 옛 방법 51~70초).
받는 사람 맞추기 한도는 CI 쪽 값(0.17~0.25초)의 10배쯤인 2.5초로 올렸다.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
이 spec 은 fetch 를 spyOn 으로 감싸기만 해, 가짜 응답을 넣지 않은 호출이 진짜
http://mail:8080 으로 나갔다. 13단계부터 메일 계정이 없는 세션은 계정 정보를 한 번
더 묻는데, 그 요청이 CI 컨테이너에서 이름 풀기를 기다리다 테스트 5초를 넘겼다
(2026-11-03 CI — 개발 컨테이너에서는 개발 엔진이 실제로 받았다).
- 가짜 응답이 없는 fetch 는 곧바로 연결 실패로 거절하고 주소를 남긴다 — afterEach 가
남은 주소가 있으면 그 테스트를 실패시킨다. jest-mock 30 의 mockReset 은 구현까지
지우므로 resetFetchMock 이 기본을 다시 건다.
- 메일 계정이 없는 세션 · 실시간 연결 403 두 테스트에 계정 정보 응답을 넣고 요청을 단언.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
전 직원 전환의 imapsync 는 새 엔진에 IMAP 으로 들어가야 하는데, 입사 · 재설정의 임시 비밀번호는
만료라 IMAP 이 끊긴다. 직원이 비밀번호를 바꾸면 마지막 차이분을 옮길 길도 없었다.
- 「입사」의 옮겨 올 메일 질문 → 이전용 비밀번호(엔진 만료 30일 뒤 · 실행 한 번 안에서 같은 값 · 메모리에만)
- 메뉴 「11. 메일 이전」 — 이전 현황 · 이전용 비밀번호 다시 만들기(세션을 먼저 끊는다) · 이전 마치기
(앱 비밀번호 → 세션 → imapsync 로그인 기록 지우기 → 임시 비밀번호 + 만료 → 세션, 알림 메일 없음, 끝에 표 한 번)
- 이전 중 = 엔진 만료가 미래(31일 안) + 실행 기록의 마지막 판정 기록이 이전용(hire-migration · migration-password)
— 엔진 기본 만료의 찌꺼기를 이전 중으로 읽지 않게
- 재설정 · 잠금 풀기 가드, 현황의 이전 중 표시, 엔진 기본 만료는 첫 화면 알림만
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
전 직원 전환 때 관리자가 계정을 다루고(입사 · 비밀번호 재설정 · 2단계 해제 · 잠금 · 퇴사 마무리 · 공용 주소) 회사가 2단계 인증을 필수로 할 수 있게 했다.
- 제한된 세션(AUTH_010): 임시 비밀번호(엔진 만료) · 2단계 필수인 직원은 로그인은 되지만 새 비밀번호 · 2단계 켜기만 지난다. 푸는 요청은 그 세션만 다음 제한으로 바꾸고,
2단계를 켜면 2단계를 거친 적 없는 다른 세션은 끊는다. 만료는 게이트웨이 대리 접근 세션의 권한(다섯)으로 로그인 · 갱신 때 알아챈다.
- 운영 도구 CLI(dist/cli/ops.js): 바뀔 것 → y/N → 실행 → ops_audit_log. 관리자 비밀번호는 TTY 에서만 · 입력 기록 0, 재설정은 앱 비밀번호 → 세션 → 알림 → 만료 차례(만료 뒤 대리 접근이 막힌다).
- 받은편지함 알림 메일(Email/import — 링크 없음): 재설정 · 2단계 해제 · 위험 파일 판정. 끊은 세션 기억은 15초마다 DB 에서 채운다.
- 2단계를 켠 잠긴 계정도 로그인은 AUTH_004 — 엔진이 잠긴 계정에도 2단계를 먼저 봐, 게이트웨이 자신의 권한으로 잠김과 설정 문제를 가른다(실사용 시험에서 찾음).
검증: lint 0 · 단위 3,138 · e2e 266 · 리뷰 두 번 · 실사용 시험(브라우저 + 운영 도구) 89/89.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
엔진(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>
- ClamAV 1.4.6 LTS 를 둘로 나눴다: clamd 는 백엔드와만 닿는 internal 네트워크(바깥 길 없음 · 서명 볼륨 읽기 전용),
freshclam 은 자기 바깥 길에만. 비-root · cap_drop ALL. 운영은 늘 뜨고 개발은 profile clamav.
clamd.conf 는 한 파일 한도 2047M 까지 끝까지 검사하고 암호 · 한도 초과를 판정으로 알린다(실측표는 mail/README).
검사 중에 죽으면 남던 임시 파일을 띄울 때 지운다.
- nginx 를 포털 · 공개 내려받기(files.) 두 server 로 가른다(SHARE_PUBLIC_URL 과 CORS_ORIGIN 의 호스트 비교 — 같으면 포털이 함께 연다).
조각 PUT 17m, 받기는 버퍼링 · 압축 끔 + X-Accel-Buffering 넘김 + 머리글 버퍼 16k,
limit_conn 은 X-Forwarded-For 마지막 칸으로 IP 마다 공개 10 · 앱 안 30, 전체 공개 400 · 앱 안 200.
받기 location 은 /api/ 블록 안의 대소문자를 가리지 않는 정규식이다(리뷰 — /FILES/ 같은 변형이 상한을 비켜 갔다).
접근 로그는 경로 모양과 43자 토큰 모양으로 토큰을 가린다.
- 파일은 named volume <PROJECT_NAME>_files(개발도 — builder 단계가 /app/files 를 node 소유로 만든다), 백업하지 않는다.
- CI infra 잡: nginx 두 꼴 · 받기 경로 변형 시험(가짜 백엔드) · clamconf(-n 없이 기본값까지) · 틀린 SHARE_PUBLIC_URL 거부.
- mail/README: 악성 검사 · 공개 호스트 · NPM 칸(Advanced 다섯 줄) · 메모리 예산 · 문제 해결.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
시범 동안 새 서버에 없는 사내 주소는 엔진이 mailcow 로 중계하므로 웹메일이 곧바로 not-found 로 알리지 못하고
나중의 반송 메일로 알게 된다. 사내 목록도 새 서버의 계정(시범 사용자)뿐이다.
클라이언트 · 서버 코드는 바꾸지 않았다 — 계약과 받는 사람별 결과 주석에 그 차이를 적었다.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
계약 2절 9단계. 보내기 취소 · 예약은 엔진 제출의 HOLDFOR · HOLDUNTIL 로 붙잡고(7일 안), 취소는 undoStatus: canceled 뒤 그 메일을 임시보관함의 초안으로 되돌린다.
외부 이미지는 서버가 대신 받아 온다 — 모든 주소가 공인일 때만 푼 주소로 고정 연결 · 80/443 · 다시 가기 3번 · 10초 · 5MB · 앞 바이트로 그림 형식, 중계 주소의 표는 AES-GCM 으로 암호화.
차단은 필터 · 부재중과 같은 Sieve 스크립트의 맨 앞, 설정은 DB 표 mail_settings(마이그레이션 AddMailSettings).
리뷰로 고친 것:
- 붙잡힌 메일을 옮기거나 지워도 엔진은 시간이 되면 보낸다 — 한 통 · 여러 통 PATCH · DELETE · 영구 삭제 · 메일함 지우기 · 비우기 · 필터 적용이 엔진에 먼저 묻고 막는다(MAIL_015 · failedIds · skippedIds).
읽지 못하거나 다 받았는지 응답의 total 로 확인하지 못하면 바꾸지 않는다(MAIL_001), 보낼 시각을 읽지 못한 기다리는 제출도 붙잡힌 것으로 본다.
- 발송은 늘 envelope 을 직접 준다 — 엔진이 오타 도메인 같은 주소를 말없이 빼고 보냈다. 거절한 주소를 not-found 로 빼고 다시 제출한다
(한 차례에 하나 — 거절 글의 따옴표 토큰과 정확히 같은 주소부터, 20차례까지), 같은 키 재시도는 제출의 envelope 으로 같은 답을 다시 만든다.
- 이미지 중계: 서버 16 · 직원 6 동시 수(받는 수가 적은 직원부터), 자리는 응답이 끝날 때까지, 버퍼는 한 번에 — 메모리가 동시 수 × 5MB 를 넘지 않게.
- 다시 취소해도 200(취소 표시가 있으면 옮기지 않는다), 엔진이 확인한 취소만 취소로 본다, 모두 거절된 붙잡은 발송은 취소한 뒤 사본을 지운다.
- 차단 주소는 dot-atom 만, 이미 있는 주소를 POST 하면 꺼진 규칙을 켠다, 로그의 중계 주소 가리기는 대소문자 무시.
- 연결 · 이름 풀기를 실제 소켓으로 시험한다(테스트 전용 포트 · DNS 서버 — 앱은 80/443 고정).
에러 코드 MAIL_014(이미 보냄) · MAIL_015(붙잡힌 메일)를 더하고 MAIL_013 문구에 차단 목록을 넣었다(뜻은 그대로). 클라이언트 사전을 생성했다.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
계약 3절. 내 주소록은 엔진의 JMAP Contacts(직원마다 기본 주소록)에, 사내 목록은 엔진 계정 목록을 사내 목록 계정으로만 읽는다.
엔진 검색은 낱말 단위이고 이름 정렬을 못 해서, 직원마다 주소록을 엔진 state 로 확인하는 캐시에 두고 서버가 찾고(초성 · 전화 숫자) 정렬한다.
쓰기는 직원마다 차례로 · ifInState 로 하고, 어긋나거나 엔진이 잠깐 거절하면 다시 읽어 세 번까지.
리뷰로 고친 것:
- 전화번호 정규식이 입력 길이의 제곱이었다(1MB 본문 하나로 약 24분 CPU) — 정규식을 나누고, DTO 검사를 applyDecorators 로 묶어 형식 → 길이 → 모양 차례로 돌린다.
- 고치기는 PATCH(보낸 칸만 검사) — 칸 안의 남의 속성(label · contexts · fax · pref · role · 둘째 조직)은 지금 카드와 합쳐 지킨다(CardDAV 와 함께 쓰려고).
- [되돌리기]는 지우기 직전에 읽은 카드 전체를 5분 기억했다가 같은 uid 로 다시 만든다(되돌리기 표 — 우리 규칙 밖의 값 · 다른 프로그램의 칸도 그대로).
- 큰 주소록이 캐시에 들지 못해 요청마다 엔진을 통째로 다시 읽었다 — 크기 한도 800만을 쓰기에 걸고 캐시의 한 사람 상한과 맞췄다.
- 받는 사람 추천의 q 를 화면과 같은 맞추기(초성 · 조합 중 글자)로 — 주소록이 크면 미리 받은 100줄이 주소록으로 차서 사내 · 최근 주소의 초성 찾기가 사라졌다.
- excludeGroupId · 사내 항목 하나 · q 254자, 판은 읽기 전에 물은 것으로, 한글 이름 조각은 붙여 잇는다.
에러 코드 CONTACT_001(같은 이름 그룹) · CONTACT_002(주소록 한도)를 더하고 클라이언트 사전을 생성했다.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
본문의 규칙 하나로 받은편지함을 최근 메일부터 판정하고 표시 · 옮기기만 한다(전달 · 지우기 없음).
한 요청은 25초 안에서 하고 cursor 로 이어 하며, 같은 직원은 한 번에 하나다(진행 중이면 429 REQ_001).
- 판정(mail-filter-match.ts)은 엔진 Sieve(sieve-script.ts 의 FIELD_TESTS)를 그대로 옮겼다. 견줄 글은
원래 머리글(mime-text.ts 의 decodeHeaderText)과 텍스트 · HTML · 텍스트 첨부(html/html-text.ts)에서 뽑는다.
실엔진 배달 94통(HTML 75 · 머리글 19)을 표 테스트로 고정했다 — HTML5 에만 있는 이름 참조는 알려진 한계
- 이어 할 자리(cursor 2판)는 위 경계 · 남긴 마지막 메일과 그 받은 시각이다. 시작한 뒤 온 메일은 보지 않고,
그 메일이 빠지면 그 초부터 다시 읽는다 — 리뷰가 가짜 엔진으로 재현한 건너뜀을 고쳤다
- 옮기기는 읽은 상태 그대로일 때만(ifInState), 본문 값은 부분마다 256KiB · 한 통 1MiB 까지
- 오래 걸리는 처리의 시간 규칙(BulkRun)을 jmap/bulk-run.ts 로 옮겨 메일함 처리와 함께 쓴다
- 부재중 GET · PUT 응답에 isActive — 엔진에서 우리 Sieve 스크립트가 꺼져 있으면 false(본문으로는 받지 않는다)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2MB 로 다시 재 보니 옛 경로(주소 없는 img 를 exclusiveFilter 로 지울 때마다 결과를 다시 자름)가
26~28초뿐이라 한도 15초와 2배도 차이 나지 않았다 — 빠른 PC 에서는 옛 경로로 돌아가도 통과할 수 있었다.
주석의 옛 기준(27만 개에 15초)은 벽시계로 잰 옛 값이었다.
입력을 4MB(66만 개)로 키우고 한도를 25초로 둔다 — 지금 경로는 부하 아래 2.0~2.4초(10~12배 여유),
옛 경로는 100~134초(Node 22 호스트) · 154초(Node 24 Linux 컨테이너)로 한도의 4배를 넘는다.
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>
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>
- 제출 다음에 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>
- 직원마다 엔진 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>
컨테이너에서 아무 패키지나 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>
다른 작업과 함께 돌면 혼자 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>