Commit Graph

209 Commits

Author SHA1 Message Date
ttipo 7bf95c1335 docs(rules): 7단계 부품 — 필터 적용 · 모달 다시 띄우기 · 확장 때 옮길 잠금
CI / Frontend (lint · type-check · test · build) (pull_request) Successful in 11m19s
CI / Infra (compose · Dockerfile · nginx) (pull_request) Successful in 20s
CI / 에러 코드 SSOT 동기화 검사 (pull_request) Successful in 10m38s
CI (Flutter) / Flutter (analyze · test) (pull_request) Successful in 20m12s
CI / Backend (lint · type-check · test · e2e · build) (pull_request) Successful in 11m40s
- vue3 스킬: 이미 받은 메일에 필터 적용 행, 진행 알림 결과의 단추, ModalDialog 의 reshow(),
  모달 창의 나가기 확인(useDialogLeaveConfirm), 부재중 isActive 줄
- nestjs 스킬: mail-filter-runs 의 부품(판정 · 견줄 글 · HTML 글자 · cursor 2판 · 본문 상한)과
  엔진 Sieve 와 짝인 파일을 함께 고치고 실배달로 다시 맞추는 규칙, 인스턴스를 늘리면 옮길 잠금에 filterRuns

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 08:38:10 +09:00
ttipo aef67aa310 feat(frontend): 필터 고치기 창 — 이미 받은 메일에도 적용 · 나가기 확인, 부재중 동작 안 함 줄
- 고치기 창의 「받은편지함에 이미 있는 메일에도 적용」(창을 열 때마다 꺼짐): 저장한 뒤 cursor 가 끝날 때까지
  보내며 진행 알림을 고치고, 끝나면 그 자리에서 결과(적용한 수 · [{메일함} 보기], 멈추면 [다시 시도]로 이어서)
- 이미 받은 메일에 할 일이 없는 필터(지워진 메일함 · 받은편지함으로만 · 전달만)와 이 탭에서 적용 중에는 칸을 끈다.
  실패 알림은 응답이 온 때 세션이 그대로일 때만, 나아가지 못한 응답이 세 번이면 멈춘다
- 고치기 창의 나가기 확인([취소] · Esc · 다른 화면 · 탭 닫기 — 6단계의 한계를 고친다). 브라우저가 스스로 닫은 창은
  ModalDialog 의 reshow() 로 다시 띄우고 초점을 닫히기 전 자리로 돌려준다
- 부재중 화면: 켜 둔 자동 응답이 엔진에서 꺼져 있으면(isActive: false) 머리 아래 노란 줄
- 진행 알림의 결과에도 단추를 붙인다(useToast)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 08:38:01 +09:00
ttipo 6fd0911b86 feat(backend): 이미 받은 메일에 필터 적용 POST /api/mail-filter-runs · 부재중 isActive
본문의 규칙 하나로 받은편지함을 최근 메일부터 판정하고 표시 · 옮기기만 한다(전달 · 지우기 없음).
한 요청은 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>
2026-10-29 08:37:51 +09:00
ttipo ab4124ba94 fix(docker): mail setup — 엔진 임시 업로드 한도를 첨부 합계의 20배로
엔진(Stalwart 0.16.27)은 계정마다 1시간 동안 올린 양을 50MB 까지만 받는다(x:Jmap 의 uploadQuota).
20MB 첨부 셋이면 그 직원의 첨부 올리기와 필터 · 부재중 저장(Sieve 스크립트도 업로드로 올린다)이
한 시간까지 429 REQ_001 로 막혔다(2026-10-29 개발 엔진에서 확인).

- setup 이 uploadQuota 를 첨부 합계 한도(MAIL_ATTACHMENT_TOTAL_MAX_SIZE_MB — 백엔드와 같은 계산)의
  20배 이상으로 올린다. 더 크게 둔 값은 내리지 않고, 개수 · 기간 · 한 번 크기는 그대로다
- 첨부 한도 env 를 바꾸면 setup 도 다시 돌린다(env 예시 · mail/README.md · docker 스킬 · env 규칙)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 08:35:20 +09:00
ttipo 5ddc849c11 docs: 7단계(이미 받은 메일에 필터 적용) 계약과 디자인
필터를 저장할 때 받은편지함에 이미 있는 메일에도 그 필터 하나를 적용한다. 엔진의 Sieve 는 받을 때만
돌고 엔진 검색은 낱말 단위라, 서버가 받은편지함을 읽어 Sieve 와 같은 규칙으로 판정한다.

- 계약 2절 "이미 받은 메일에 적용"(POST /api/mail-filter-runs): 판정 규칙은 실엔진 배달로 맞췄다 —
  HTML 은 닫는 p · br 만 줄바꿈, style · script · head · template 은 글째 빼고, 속성이 붙은 태그는
  엔진처럼 알아보지 않는다. 머리글은 접힌 줄을 띄어쓰기 하나로, 인코딩된 글은 홀로 선 것만 푼다
- 시작한 때 받은편지함에 있던 메일만 보고(위 경계), 마지막으로 남긴 메일이 빠지면 그 초부터 다시 읽는다.
  본문 값은 한 통 1MiB 까지 — 바깥 메일 한 통으로 서버가 메모리를 크게 쓰지 않게
- 부재중 응답에 isActive(엔진에서 우리 규칙이 꺼졌는지), 엔진의 임시 업로드 한도, 바깥 주소 자동 전달은
  허용하지 않는다는 정책(2026-10-29 결정)
- 디자인 캔버스 확정본 MailFilterApply · MailFilterApplyStates 와 README "7단계"(구현하며 정한 보완 포함)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 08:03:42 +09:00
ttipo 188b9e84f4 test(backend): 이미지 정화 CPU 단언 — 입력 4MB 로 옛 경로와 한도 사이를 넓힌다
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>
2026-10-29 00:54:53 +09:00
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