docs(rules): 13단계 부품 — 제한된 세션 · 운영 도구 · 백업 · 감시 · 스킬 표 · env
CI (Flutter) / Flutter (analyze · test) (pull_request) Successful in 20m37s
CI / Backend (lint · type-check · test · e2e · build) (pull_request) Successful in 12m57s
CI / Frontend (lint · type-check · test · build) (pull_request) Failing after 7m54s
CI / Infra (compose · Dockerfile · nginx · 운영 스크립트) (pull_request) Successful in 2m5s
CI / 에러 코드 SSOT 동기화 검사 (pull_request) Successful in 12m3s

- 스킬 표: nestjs(제한된 세션 · 2단계 필수 정책 · 받은편지함 알림 · 운영 도구 · 15초 동기화 · 만료 계정의 엔진 사실), vue3(제한된 세션 화면 · 로그인 틀 단계 · 유예 줄 · 메일 프로그램 연결 · 30일 줄),
  docker(이미지 표 — PostgreSQL · restic · shellcheck, 자동 설정 server, 호스트 systemd 백업 · 감시 절, setup 의 관리자 단단히 · 휴지통 30일, 로그 앵커 규칙, CI infra 잡).
- 규칙: security(제한된 세션 · 운영 도구 비밀 · 관리자 계정 · 알림 메일 링크 없음 · 퇴사 마무리 · 백업 비밀 · restic 의 root 예외), logging(컨테이너 로그 보존 · 실행 기록 · 자동 설정 쿼리),
  env-structure(자동 설정 env · DB_IMAGE · ops.env 예외 · OPS_ALLOW_NO_TTY), error-codes(AUTH_006 ~ AUTH_010), checklist(13단계 운영 준비), release-ops 담당 범위(mail/ · ops/).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-02 15:37:30 +09:00
parent ad6cea79fc
commit 6c8c54d117
9 changed files with 75 additions and 3 deletions
+32 -1
View File
@@ -97,6 +97,10 @@ sudo docker compose --env-file .env.production logs -f backend
`CORS_ORIGIN` 의 호스트 이름들과 견준다(백엔드 `file-sharing.settings.ts` 와 같은 판정 — 포트는 보지 않는다).
다르면 공개 server 가 화면 `/s/` · 화면 자산 · `/api/public-links/` **만** 열고(그 밖의 화면 404 · API 404 `RES_001` — 백엔드에 넘기지 않는다) 포털은 그 둘을 404 로 닫는다.
비었거나 같으면(개발) 포털이 함께 열고 공개 server 이름은 어느 요청에도 맞지 않는 `share.invalid` 다.
- **메일 프로그램 자동 설정 (13단계)**: server 가 하나 더 있다 — `autoconfig.<MAIL_DOMAIN>` · `autodiscover.<MAIL_DOMAIN>` 에 Thunderbird XML(두 경로) · Outlook POX 고정 응답(IMAP 993 · SMTP 465, `text/xml; charset=utf-8`).
요청 값(주소 · 쿼리)을 되돌려 쓰지 않고 그 밖의 경로 · `/api` 는 404. 값은 `MAIL_DOMAIN` · `MAIL_HOSTNAME`(`nginx-env.envsh` — 비면 꺼짐, 모양이 틀리거나 포털 · 공개 호스트와 겹치면 기동 중단).
엔진의 자동 설정은 공개하지 않은 POP3 를 알리고 입력을 XML 에 되돌려 써서 쓰지 않는다. 전 직원 전환 때 DNS(두 이름 A → 외부 프록시, SRV `_imaps._tcp` · `_submissions._tcp` · 쓰지 않는 것은 "."),
외부 프록시 Proxy Host(같은 frontend:8080 · **접근 로그 끔** — 쿼리에 직원 주소)를 만든다. 엔진 인증서의 Additional Hostnames 에는 이 두 이름을 넣지 않는다(mail/README "메일 운영 설정").
- 공개 경로 · 오류 location 은 템플릿이 아닌 스니펫이다(이미지의 `/etc/nginx/snippets/` — `${NGINX_*}` 를 쓰지 않는다):
`nginx-share-open.conf` · `nginx-share-closed.conf`(server 단계 — 화면 `/s/` 만), `nginx-share-api-open.conf` · `nginx-share-api-closed.conf`(`/api/` 블록 안 — 공개 API, `~*`),
`nginx-api-errors.conf`(이름 있는 오류 location — `@api_not_found` 404 `RES_001` 포함). 공개 화면 `/s/` 는 `no-referrer`(주소에 토큰) · `noindex` · `no-cache`.
@@ -212,7 +216,7 @@ sudo docker compose --env-file .env.production logs -f backend
- **업로드 파일은 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`.
백업은 매일 백업(`ops/` — 아래 "백업 · 감시")에 든다.
- **대용량 첨부 파일은 named volume `<PROJECT_NAME>_files`**(`/app/files` — 경로는 compose 가 `FILE_STORAGE_DIR` 로 고정, 루트 env 로 바꾸지 않는다)에 쌓인다. **개발도 named volume** 이다 —
바인드 마운트면 2GB 를 Windows 너머로 쓰고 Defender 가 EICAR 시험 파일을 지운다. 그래서 `backend/Dockerfile` 의 builder 단계가 `/app/files` 를 node 소유로 만들어
개발 볼륨도 소유자를 물려받는다(2026-10-30 확인). **백업하지 않는다** — 30일짜리 전송물이고 기록은 DB 에 있다(서버를 되살리면 그 전의 링크는 파일이 없어 받지 못한다).
@@ -330,6 +334,8 @@ docker compose -f docker-compose.runner.yml --env-file .env up -d
"닫는 경로 시험"(12단계)은 같은 가짜 백엔드로 두 server × 닫는 경로 셋(`/api/internal` · `/api/health` · 공개 API)의 변형이 nginx 가 직접 낸 JSON 인지 본다 —
대소문자 · 겹 `/` · `%2F` · `%69` · `%5C` · `..` · absolute-form · 끝 `/` 는 404, 날 `\` · `#` · 0xA0 는 400, 쿼리의 `\` 는 통과, `/api/internals` 같은 이웃 경로는 백엔드로 넘어간다
(경계 확인은 wget — busybox `nc` 는 표준 입력이 끝나면 닫아 프록시를 거친 응답을 받지 못한다). 정적 검사는 include 와 `if ($request_target_rejected)` 를 지시어로만 센다(주석 처리한 줄은 세지 않는다).
13단계부터: 세 조합(운영 · 개발 · remote-proxy)의 서비스 수와 `logging` 수 견주기, `DB_IMAGE` 판 · digest 정규식, 자동 설정 server 의 렌더링 · 값 거절(겹침 포함) · 실제 요청 시험,
운영 스크립트(`ops/`) shellcheck(scratch 이미지 — 빌드 컨텍스트로 넘긴다).
ClamAV 설정은 compose 의 이미지로 `clamconf` 를 돌려 한도 · 경보 값을 본다 — `clamconf` 는 틀린 항목에도 종료 코드 0 이라 출력의 `ERROR` 를 찾고,
`-n`(기본값과 같은 항목을 숨김) 없이 한 번 더 돌려 기본값에 기대는 `SelfCheck = "600"` · `HeuristicScanPrecedence disabled` 를 줄 그대로 확인한다. 이미지 줄은 끝까지 읽는 awk 로 고른다(위 pipefail 규칙).
@@ -410,6 +416,11 @@ node --env-file=.env.development mail/scripts/setup.mjs
키는 엔진 설정에 그대로 저장된다(메일 볼륨 백업에 있다 — Spamhaus 키와 같은 성격). 402 를 "비밀번호 맞음"으로 보는 것은 테스트 직원(웹메일에서 2단계를 켰을 수 있다)뿐이다 —
게이트웨이가 402 면(OTP 를 켜면 백엔드의 대리 접근이 모두 막혀 웹메일 전체가 선다) 아무것도 바꾸기 전에 멈추고, 관리자도 그렇다.
엔진 DNS 가 Custom(운영 — 리졸버)이어도 웹훅은 `backend` 를 푼다 — 메일 컨테이너의 resolv.conf 가 도커 DNS(127.0.0.11)다(2026-10-31 개발에서 Custom 으로 바꿔 확인) (mail/README.md "계정 보안 (12단계 — 인프라)").
- setup 은 엔진 관리자를 단단히 한다(13단계): 관리자 역할(System Administrator)의 빼는 권한에 `impersonate` — 관리자 비밀번호로 `직원%관리자` 대리 로그인(바깥 993 · JMAP)이 막힌다(관리 API 는 그대로).
영구 관리자 비밀번호의 허용 IP 를 `APP_NETWORK_SUBNET` 으로 — 관리 API 주소가 앱 네트워크 안일 때만 걸고, 건 뒤 다시 로그인해 보고, 막히면 복구 관리자로 되돌린다. **운영에서 처음 걸 때는 복구 관리자가 있어야 한다**(없으면 걸지 않고 종료 1).
운영자가 대역 안으로 좁혀 둔 값은 넓히지 않는다. 관리 화면 SSH 터널은 mail 컨테이너의 **앱 네트워크 주소**로 연다(다른 망 주소로 열면 403 — 엔진이 보는 출발 주소가 그 망의 게이트웨이).
휴지통 · 스팸메일함 30일(`x:DataRetention.expungeTrashAfter`)과 정리 시각 KST 03:00 · 04:00 · 04:30(컨테이너 UTC 차이를 읽어)도 맞춘다 — 그 메일함에 들어간 때부터 세므로 imapsync 로 옮긴 메일도 옮긴 때부터다.
자동 비우기는 특수 메일함("Deleted Items" · "Junk Mail")에만 걸린다 — imapsync 는 `--f1f2` 로 폴더를 맞춘다(mail/README imapsync 절). 이미 운영 중인 서버는 mail/README "13단계 전부터 운영 중인 서버".
- 운영의 복구 관리자(`MAIL_RECOVERY_ADMIN`)는 `.env.production` 에 두지 않고 셸 일회성 override + `setup.mjs --recovery` 로만 쓴다
(파일에 있거나, 끝난 뒤 mail 컨테이너에 남아 있으면 setup 이 실패한다 — 절차는 mail/README.md "운영 배포").
- 영구 관리자 아이디는 `MAIL_ADMIN_LOCAL_PART`(기본 `admin`) — setup 과 백엔드가 함께 읽는다 (compose 가 backend 에 넘긴다).
@@ -461,6 +472,20 @@ node --env-file=.env.development mail/scripts/setup.mjs
---
## 백업 · 감시 (호스트 systemd — compose 밖, 13단계)
서버가 스스로 지키고 되살리는 일은 compose 가 아니라 호스트의 systemd 가 돌린다. 설치 · 복원 차례 · 알림 대응은 [`mail/README.md`](../../../mail/README.md) "백업" · "감시"가 SSOT 다.
- **자리**: 저장소의 `ops/` 를 서버의 `/opt/arasystem-ops` 로 `install -m 0644` 해 root 소유 사본으로 돌린다(저장소 폴더를 그대로 쓰지 않는다). 설정 · 비밀은 `/etc/arasystem/ops.env` · `restic-password`(root 0600) — [[env-structure]] 의 예외.
- **유닛 셋**: `arasystem-backup.timer`(매일 03:30 KST · 부팅 뒤 따라잡기), `arasystem-check.timer`(15분 점검), `arasystem-mail-recover.service`(부팅 때 — 백업 표시가 남았으면 엔진을 띄운다, `Before=arasystem-backup.service`). 모두 `Wants=` + `After=docker.service`.
- **백업**(restic → Object Storage, 보존 일 7 · 주 4 · 월 6, 일요일 prune · check 5%): 메일 볼륨은 켜 둔 채 미리 올리고(warm — restic 종료 3 은 받아들임) → 엔진을 잠깐 멈춰(9초 안팎) 바뀐 것만(final — 상한 `BACKUP_FINAL_TIMEOUT` 15분) → 다시 띄운다.
DB `pg_dump -Fc`(+ `pg_restore -l` 확인) · 업로드 볼륨 · env 둘. 어떻게 끝나도 엔진을 다시 띄운다(EXIT trap + `ExecStopPost`, 멈추는 중 신호면 멈춤을 끝낸 뒤). 비밀 사본(env · 덤프)은 끝날 때 지운다.
**매일 03:30 에 웹메일이 10초 안팎 `MAIL_001`** 이다(엔진 멈춤). 백업과 복원 연습은 같은 잠금 — 잠긴 동안 손으로 부른 엔진 띄우기는 건너뛴다(final 도중 띄우면 일관되지 않은 스냅숏이 final 이 된다).
- **복원**(`arasystem-restore.sh` — snapshots · check · drill · files · volumes · db): final 태그 스냅숏만, 쓰는 컨테이너가 있거나 볼륨이 비지 않았으면 멈춘다(운영 볼륨을 덮지 않게), 새 서버는 `.env.production` 없이 ops.env 의 `PROJECT_NAME` 으로 files 부터. 분기마다 `drill`.
- **15분 점검**: 디스크 두 곳 · 컨테이너 health · 백엔드 health · 메일 대기열 · 인증서 셋 · 백업 나이(26시간 이상) · ClamAV 서명 · Spamhaus 키 → Healthchecks.io(정상 · 실패 + 까닭 한 줄). 바깥은 UptimeRobot(포털 HTTPS · mx1:993 · mx1:25) — 알림은 우리 메일 서버가 아닌 주소로.
- **비-root 예외**: restic 보조 컨테이너는 root 로 돌되 `--cap-drop ALL` + 필요한 권한만(읽기 `DAC_READ_SEARCH`, 복원은 `DAC_OVERRIDE` · `CHOWN` · `FOWNER`) · `no-new-privileges` ([[security]]). 연습 엔진(Stalwart)을 `no-new-privileges` 로 띄우려면 `NET_BIND_SERVICE` 가 든다.
- 비밀은 명령줄에 싣지 않는다 — `docker exec -e 이름` · 표준 입력 · 읽기 전용 마운트, Healthchecks 주소도 `curl -K -`.
## 규칙
- **이미지 버전 고정 — 떠 있는 태그(`stable-alpine`·`v0.16`·`24-alpine`)를 쓰지 않는다.** 그날의 최신이 들어오면 호스트마다 버전이 달라지고,
@@ -474,6 +499,10 @@ node --env-file=.env.development mail/scripts/setup.mjs
| Mailpit (개발) | `axllent/mailpit:v1.32.0` | `.env.development.example` 의 `MAILPIT_IMAGE` |
| unbound (메일 리졸버) | `klutchell/unbound:1.26.2@sha256:50cbeb32…` | `docker-compose.yml` 의 `mail-resolver`, `mail/README.md` 「DNS 리졸버와 스팸 차단 목록」의 이미지 칸(판 · digest 앞부분), 이 문서. 커뮤니티 이미지라 **digest 까지** 묶는다 — 올릴 때는 `imagetools inspect` 의 manifest list digest 로 함께 바꾼다. CI 의 unbound 설정 검사는 compose 의 이미지를 읽어 쓴다 |
| ClamAV (악성 검사 — 11단계) | `clamav/clamav:1.4.6_base@sha256:e7399106…` | `docker-compose.yml` 의 `clamav` · `clamav-updater`(같은 값 둘), `mail/README.md` "악성 검사 (ClamAV)"의 이미지 칸, 이 문서. LTS(1.4 — 2024-08 부터 3년)만 쓴다 — 같은 날 나온 1.5.4 대신 고른 까닭: 지원 기간, 1.5 의 새 ZIP 코드에서 나온 CVE-2026-20337. 공식 이미지지만 digest 까지 묶는다(CI 의 `clamconf` 검사는 compose 의 이미지를 읽어 쓴다) |
| PostgreSQL (db — 13단계부터 고정) | `postgres:15.19-alpine3.24@sha256:54e0dd8e…` | 루트 예시 env 둘의 `DB_IMAGE`(각자의 로컬 env 도), CI 의 `DB_IMAGE` 정규식, 이 문서. `docker-compose.remote-proxy.yml` 의 `frontend-bind-check` 가 같은 이미지를 빌려 쓴다. 15.19 는 2026-08-13 보안 릴리스, 이 digest 는 2026-10-21 alpine 재빌드라 운영 반영은 11-04 뒤. **메이저(15)를 올리려면 덤프 · 복원** — 데이터 형식이 다르다. db · redis 의 watchtower 표시는 뺐다(판은 사람이 올린다) |
| restic (백업 — 13단계) | `restic/restic:0.19.1@sha256:136600b6…` | `ops/backup/arasystem-backup.sh` · `arasystem-restore.sh` 의 `RESTIC_IMAGE` 두 곳, 이 문서 |
| shellcheck (CI — 13단계) | `koalaman/shellcheck:v0.11.0@sha256:61862eba…` | `.github/workflows/ci.yml` |
| Redis (선택 — 기본 꺼짐) | `redis:7-alpine`(아직 떠 있는 태그 — 기존 드리프트) | 켤 때 판 · digest 로 고정하고 이 표에 줄을 둔다 |
| CI 러너 | `gitea/act_runner:0.6.1` | `ci/docker-compose.runner.yml`. 잡 이미지(`RUNNER_LABELS` 의 `gitea/runner-images:ubuntu-latest`)는 각자의 `ci/.env` 값이라 아직 떠 있는 태그다 |
- **갱신 규칙**(공통): 일반 패치는 **배포 후 2주가 지나면** 올린다 (공급망 위험 — 갓 나온 버전을 피한다). **보안 릴리스는 CI 가 통과하는 대로** 올린다.
@@ -495,6 +524,8 @@ node --env-file=.env.development mail/scripts/setup.mjs
24.22.1 은 2026-10-20 보안 릴리스라 보안 릴리스 규칙으로 바로 고정했다.
- 올린 뒤에는 운영 이미지 두 개 빌드, CI 와 같은 명령(아래 "의존성은 컨테이너 안에서"처럼 임시 폴더 복사본에서 lint·type-check·test·build),
dev 재빌드(`dc up -d --build -V`)로 확인한다.
- **로그 보존 — 모든 서비스에 `logging` 앵커**(json-file · max-size · max-file · compress — 13단계). 기본 10m×5, mail · frontend · backend 는 20m×10. 앵커는 파일을 넘지 못해 dev · remote-proxy 는 자기 앵커를 둔다.
새 서비스에도 붙인다 — CI 가 세 조합의 서비스 수와 logging 수를 견준다. 로그 한도는 컨테이너를 다시 만들어야 걸린다.
- **빌드 최소화**: 의존성(package.json / pubspec 등) 변경이 없으면 `--build` 없이 기동한다.
- **의존성을 바꿨다면 `--renew-anon-volumes` 를 함께 준다.**
dev 는 `/app/node_modules` 를 익명 볼륨으로 마운트하는데, `--build` 만으로는 **예전 볼륨이 그대로 재사용된다.**
+6 -1
View File
@@ -42,6 +42,7 @@ description: NestJS 백엔드(backend/) 개발 규칙 — 컨트롤러/서비스
| 파일 저장 · 조각 올리기 (11단계) | `modules/file-storage` | `/api/file-uploads`(만들기 · 16MiB 조각 `PUT` · 끝내기 · 상태 · 지우기) · `/api/file-quota`. 조각은 `Content-Digest`(SHA-256)를 보고 `datasync` 뒤에 적는다, 같은 조각은 `ChunkGate`(프로세스 메모리)가 하나만 들인다, 조각 번호는 `file-id.pipe.ts`(매개변수 타입 `unknown` — 아래 점검 "경로 숫자"). 저장 포트 `FILE_BLOB_STORE` — 지금은 로컬 디스크 어댑터(named volume `/app/files`, 키는 서버가 만든 UUID 만 — `assertKey`). 보관량(직원 · 서버 총량 · 디스크 여유)은 만들기를 `pg_advisory_xact_lock` 안에서 센다(인스턴스를 늘려도 맞다). 끝내기는 `completion_started_at` · 상태 전이 결과를 본다. 악성 검사 `clamav-client.ts`(INSTREAM 직접 구현 — 판정 글은 `CLAMAV_REPLY` · `classifyClamavReply` 한 곳: 암호 · 압축 안 한도 → 검사하지 못함, 스트림 크기 한도 → 설정 오류로 검사 중 그대로 + error) · 검사 차례 `file-scan.service.ts`(DB pending · 동시 2) · 정리 cron `file-storage.cleanup.ts`. 확장자 허용 목록만 본다 — [[security]] "파일 업로드"의 예외. 계약 4절 |
| 공개 링크 · 내려받기 (11단계) | `modules/file-sharing` · `modules/mail/mail-large-files.service.ts` · `large-file-block.ts` · `share-link.controller.ts` | 메일 한 통에 링크 하나(토큰 256비트 — DB 에는 SHA-256 만). 보낼 때 서버가 정화 · 지문 · 중복 확인 뒤 본문에 덩이를 넣는다(`large-file-block.ts` — 표시 `<!--ara-large-files:v1-->` · `data-ara-large-files` 는 서버만 넣고 두 정화기가 사용자 HTML 에서 지운다, 경계 `bodyQuoteStart`). 링크는 "확실한 실패"(`SendNotStartedException` — 형식으로 가른다)에서만 거둔다. 임시 저장은 머리글 `X-Ara-Large-Files`(보내는 메일에는 없다). 공개 경로 `/api/public-links/{token}`(로그인 없음) — 공개 호스트 미들웨어 `public-host.ts`(공개 호스트에서는 이 경로만 · 포털에서는 닫음, Host 를 스스로 정규화. 경로는 원문 · 라우터의 `req.path` · WHATWG URL · 퍼센트를 푼 원문 네 모양을 모두 본다 — 공개 호스트는 모두 공개 경로일 때만, 포털은 하나라도 공개 경로면 닫고, 읽지 못한 모양이 있으면 둘 다 닫는다. 라우터가 읽는 모양을 빼면 절대 주소 꼴 · `\` 요청이 막기를 지난다 — 2026-10-30), 파일 응답 `serveFile`(octet-stream · attachment · nosniff · CSP sandbox · no-store · Range `shared/utils/http-range.ts`), 받은 수는 조건부 UPDATE 한 문장, 종료 때 진행 중 응답을 `BeforeApplicationShutdown` 에서 끊는다. 읽기 화면의 `largeFiles` 는 표시 · 우리 출처 토큰 · 링크 주인 == From 이 모두 맞을 때만. 기간은 `shared/utils/kst-date.ts`(보낸 날부터 30일째 23:59:59 KST). 계약 4절 |
| 계정 보안 (12단계) | `modules/account-security` · `modules/auth` 의 세션 부분 · `modules/mail/jmap/jmap-account-security.provider.ts` | 2단계 인증(엔진 TOTP — 계약 5절): 등록 `two-factor-enrollments.ts`(비밀 160비트 · 메모리 10분 · 직원마다 하나) · 켜기 · 끄기 `two-factor.service.ts`(엔진이 기준 — `enabledAt` 만 표 `two_factor_settings`) · **코드 가드 `two-factor-code-guard.ts`** — 실패 차지 · 다시 쓴 코드 확인 · (주소, 코드) "확인 중" 차지를 await 없이 한 번에 하고(같은 코드의 동시 요청이 둘 다 지나가지 않게), 받으면 그 자리에서 2분 기억한다. 결과는 받음 · 거절 · 쓰지 않음(`unused` — 새 비밀번호 거절처럼 코드와 상관없는 실패는 세지도 기억하지도 않는다). 코드 실패 카운터는 `login-attempt.service.ts`(같은 주소 15분 5 · 24시간 20 + IP 의 로그인 실패 한도). TOTP · base32 는 `shared/utils/totp.ts`(RFC 6238 — 새 의존성 없음, ±1 단계 상수 시간 비교). 잠금 `two-factor-locks.ts`(같은 직원의 2단계 바꾸기는 한 번에 하나). 로그인 2단계는 `auth.service.ts` — 앱 비밀번호 모양 거절 → 실패 차지 → 본인 Basic → **402 면 대리 접근 프로필로 정식 주소**(막는 계정의 별칭은 `AUTH_004`) → 코드는 엔진 `POST /api/auth`(mfaToken). 앱 비밀번호 `app-passwords.*`(엔진 `x:AppPassword` — 권한 `Replace` 셋, 직원마다 만들기는 한 번에 하나, 마지막 사용은 표 `app_password_usages` — 번호 · createdAt 이 같을 때만 잇는다, 비밀번호를 바꾸면 모두 지운다). 로그인된 브라우저 `modules/auth/auth-sessions.*`(경로 `/api/auth/sessions` — 리프레시 쿠키로 지금 세션을 안다) · **끊은 세션 기억 `revoked-sessions.ts`**(액세스 토큰의 `sid` — 1시간, 기동 때 DB 에서 채운다, 끊는 모든 길이 `revokeWhere` 한 곳을 지난다 — 가드가 막고 실시간 알림은 다음 ping 때 닫는다). User-Agent → 짧은 이름 `shared/utils/user-agent.ts`. 로그인 기록 `login-events.*`(표 `login_events` — 90일 정리 cron `login-events.cleanup.ts`, 위치 커서 `login-event-cursor.ts`). 엔진 웹훅 `mail-engine-events.*` — 본문을 받기 전에 소켓 주소(`APP_NETWORK_SUBNET` — `shared/utils/cidr.ts`)와 키 유무를 보고, 원문 HMAC 을 상수 시간으로, 이 경로만 JSON 한도를 따로(4MB · 배열 10,000 · 값 200,000), 겹침은 내용 지문(`dedupe_key` 유일 인덱스 — 엔진은 다시 보낼 때 id 를 새로 붙인다), 1,000행씩 `ON CONFLICT DO NOTHING`, DB 실패는 5xx(엔진이 다시 보낸다), 빈 묶음 200(setup 의 받는 곳 확인). 마이그레이션 `AddAccountSecurity` |
| 전환 준비 (13단계) | `common/decorators/allow-restricted-session.decorator.ts` · `JwtAuthGuard` · `modules/account-security/two-factor-policy.service.ts` · `modules/system-settings` · `modules/system-mail` · `src/cli/ops.ts` + `modules/ops` · `modules/mail/jmap/jmap-ops-engine.ts` · `jmap-system-mailbox.ts` | **제한된 세션**(계약 6절): 세션 표의 `restriction`(`passwordChange` · `twoFactorSetup`)을 액세스 토큰(`rst`)에도 싣고, `JwtAuthGuard`(Reflector)가 `@AllowRestrictedSession()` 을 단 핸들러(`/api/auth/*` · 2단계 켜기 셋)만 지나게 한다 — 그 밖은 403 `AUTH_010`. 제한이 실린 토큰만 요청마다 세션 표의 지금 값을 읽는다(PK 한 번). 푸는 요청은 그 세션(`sid`)만 다음 제한으로 바꾸고(`replaceRestriction` — 조건부 UPDATE), 2단계를 켜면 그 직원의 다른 `twoFactorSetup` 세션은 끊는다(`revokeRestrictedOf` — 사유 `two-factor-not-verified`, 갱신에서도). **비밀번호 만료 알아채기**: 게이트웨이 대리 접근 세션에 메일 계정이 없고 `/api/account` 권한이 다섯 안이면 `PasswordExpiredException`(`jmap.client.ts` 의 `isPasswordExpiredPermissions` — 로그인 · 갱신 때 엔진 기준, 풀렸으면 지움). **2단계 필수 정책 · 문의 연락처**: 표 `system_settings`(한 줄 — 프로세스마다 10초 기억), `GET /api/support-contact`(로그인 없음 · `{ text }`), `twoFactorDeadline` 은 유예 중이고 아직 안 켠 직원만. **받은편지함 알림 메일**: 대리 접근 `Email/import`(안 읽음 · 필터 · 스팸 안 거침), 보낸 사람 `ARA SYSTEM <postmaster@도메인>`, 글은 디자인 README 13단계(링크 없음 · 이름 · 파일 이름 · 연락처 이스케이프 · 보내는 정화 통과), 실패는 warn(원래 일은 성공), 위험 파일은 처음 판정 때 한 번(`markInfected`). **운영 도구**(`sudo docker exec -it <PROJECT_NAME>-backend node dist/cli/ops.js`): Nest 앱 컨텍스트(ScheduleModule 없음 · `FILE_SCANNER` null · `migrationsRun` false), 한국어 메뉴 → 바뀔 것 → y/N → 실행 → `ops_audit_log`. 관리자 비밀번호는 TTY 에서만 · 입력 기록 0(`historySize: 0`) — 자동 시험만 `OPS_ALLOW_NO_TTY=1`. 관리자 Basic 은 `x:Account` · `x:MailingList` 에만, 직원 메일함 · Sieve · 앱 비밀번호는 게이트웨이 대리 접근(관리자에 impersonate 가 없다). 명령 열 개와 차례는 계약 6절(재설정: 앱 비밀번호 → 세션 끊기 → 알림 → 만료 → 세션 한 번 더 / 잠금: Sieve → authenticate 빼기 → 세션 / 퇴사 마무리: 엔진 계정 → 주소 키 표 한 트랜잭션(세션 행은 남김) · 예외 목록 · MailingList 구성원 → 디스크). 끊은 세션 기억은 15초마다 DB 에서 채운다(`revoked-sessions.ts` — 운영 도구는 다른 프로세스). 마이그레이션 `AddCutoverPrep`(`auth_sessions.restriction` · `system_settings` · `ops_audit_log`). Swagger 는 가드를 건 컨트롤러에 `@ApiSessionErrors()`(AUTH_001 + AUTH_010) |
| 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]]) |
@@ -57,6 +58,7 @@ description: NestJS 백엔드(backend/) 개발 규칙 — 컨트롤러/서비스
> **확장 시 따르는 규칙** (기능이 필요해지면 규칙대로 구현):
> - **보호 라우트**: 인증은 구현되어 있다 (`modules/auth` + `common/guards/jwt-auth.guard.ts`). 새 보호 라우트는 컨트롤러·핸들러에
> `@UseGuards(JwtAuthGuard)` + `@ApiCookieAuth()` 를 걸고 `@CurrentUser()` 로 직원을 받는다 (전역 가드가 아니다 — `users`·`health` 예제는 공개, `upload` 는 로그인 필요).
> 13단계부터 그 클래스에 `@ApiSessionErrors()`(AUTH_001 + AUTH_010 — 문서)도 단다. 제한된 세션(새 비밀번호 · 2단계 켜기 전)도 불러야 하는 핸들러만 `@AllowRestrictedSession()` — 계약 6절 목록뿐이고, 달지 않은 보호 라우트는 403 `AUTH_010` 이다.
> 권한(AUTH_003) 구분이 필요해지면 역할 가드를 `common/guards/` 에 추가한다.
> 반면 출처 검사(`OriginGuard`)는 **전역**이다 — 웹훅처럼 다른 사이트가 정당하게 부르는 엔드포인트가 생기면 예외 표시를 따로 만든다
> - **메일 엔진 교체**: `modules/mail/mail.module.ts` 의 `MAIL_PROVIDER` 구현(useClass)만 바꾼다 — 서비스·컨트롤러는 포트만 안다
@@ -251,7 +253,7 @@ SwaggerModule.setup('api-docs', app, document);
> 계정 보안(12단계)의 메모리 상태 — 기다리는 2단계 등록(`two-factor-enrollments.ts` — 비밀 10분), 코드 가드의 "확인 중" 차지 · 성공에 쓴 코드 기억(`two-factor-code-guard.ts` — 2분),
> 코드 실패 카운터(`login-attempt.service.ts` 의 codeFailures · dailyCodeFailures), 2단계 바꾸기 잠금(`two-factor-locks.ts`) · 앱 비밀번호 만들기 차례(`AppPasswordsService.creating`) —
> 그대로면 인스턴스마다 따로 세고 기억해 같은 코드를 다른 인스턴스에서 다시 쓸 수 있다 → 공유 저장소(`SET NX PX` · 카운터). 끊은 세션 기억(`revoked-sessions.ts` — 1시간, 기동 때 DB 에서 채움) —
> 그대로면 다른 인스턴스에서 끊은 세션의 액세스 토큰이 만료까지 쓰인다 → 공유 저장소나 끊을 때 알림. 로그인 기록 정리 cron · 웹훅 거절 로그 묶음은 인스턴스마다 돌아도 무해하다.
> 13단계부터 15초마다 DB 에서 채우므로 인스턴스 사이도 15초 안에 맞는다(운영 도구가 끊은 세션도 같은 길). 2단계 필수 정책 · 문의 연락처의 10초 기억(`system-settings`)도 인스턴스마다 따로지만 10초 안에 맞는다. 검사가 겹치면 위험 파일 알림 메일도 겹칠 수 있다(조건부 UPDATE 로 줄였다). 로그인 기록 정리 cron · 웹훅 거절 로그 묶음은 인스턴스마다 돌아도 무해하다.
> 대용량 첨부(11단계)의 같은 조각 게이트 · 조각 동시 수(`file-storage.service.ts` 의 `ChunkGate` — 그대로면 두 인스턴스가 같은 조각을 함께 쓴다 → 잠금), 검사 차례(`file-scan.service.ts` — 행 잠금이 없어
> 두 인스턴스가 같은 파일을 두 번 검사한다 → `FOR UPDATE SKIP LOCKED`), 정리 cron 둘(인스턴스마다 돈다), 파일 바이트(로컬 볼륨 → 같은 저장 포트 `FILE_BLOB_STORE` 의 공유 저장 어댑터(S3 호환)) —
> 만들기 잠금(`pg_advisory_xact_lock`) · 받은 수 셈(조건부 UPDATE)은 DB 라 그대로 맞다:
@@ -419,6 +421,9 @@ dc exec backend npm uninstall <패키지>
메일을 지우지 않는다 — `Email/set update` 만 쓴다(옮기기는 `ifInState` — 어긋나면 다시 확인, 표시는 조건 없이).
**붙잡힌 메일**(9단계 — 보내기 취소 · 예약을 기다리는 보낸 사본)을 옮기거나 지우는 새 경로를 만들면 `pending-send.ts` 의 `readHeldEmails` 로 먼저 묻고 막는다
(`MAIL_015` · `failedIds` · `skippedIds` — 읽지 못하면 바꾸지 않고 `MAIL_001`). 엔진은 옮기거나 지운 메일도 시간이 되면 보낸다.
**비밀번호가 만료된 계정**(13단계 — 관리자가 `credentials/0/expiresAt` 을 지난 날로)은 권한이 다섯(`authenticate` · `authenticateWithAlias` · `sysAccountPasswordGet` · `sysAccountPasswordUpdate` · `emailReceive`)뿐이다 —
대리 접근으로 메일 · `Email/import` · Sieve · `x:AppPassword` 가 막히고, 동적 관리자 확인(`assertNoManagementAccess`)도 보지 못한다(정적 막는 목록이 지킨다). 서버가 넣는 알림 메일을 지우는 `jmap-system-mailbox.ts` 의 remove 는
조건 없는 destroy 의 예외다(방금 넣은 그 메일만). `x:Account/get` 은 properties 에 `@type` 을 적어야 계정 종류를 돌려준다(0.16.27).
**엔진 Basic 의 402 는 장애가 아니다**(12단계) — 비밀번호는 맞고 그 직원이 2단계 인증(OTP)을 켰다는 뜻이다. 웹메일 로그인은 코드 단계로, 비밀번호 바꾸기는 대리 접근 + `otpAuth.otpCode` 로 간다(지금까지 `MAIL_001` 로 다뤄 OTP 를 켠 직원이 들어오지 못했다).
2단계 · 앱 비밀번호 · 웹훅 설정을 고칠 때는 계약 5절의 엔진 사실(엔진은 등록 때 코드를 보지 않는다 · 같은 코드를 다시 받는다 · 앱 비밀번호 기본 권한은 앱 비밀번호를 또 만든다)을 함께 본다.
**발송 제출은 envelope 을 직접 준다**(`jmap/envelope.ts`) — 주지 않으면 엔진이 받지 않는 주소(모르는 최상위 도메인 등)를 아무 말 없이 빼고 나머지에게만 보낸다(2026-10-29 확인).
+1
View File
@@ -65,6 +65,7 @@ description: Vue 3 프론트엔드(frontend/) 개발 규칙 — Composition API
| 보낸 사람 차단 · 보내기 · 읽기 설정 (9단계) | `src/composables/useSenderBlock.ts` · `components/mail/MessageBlockDialog.vue` · `stores/mail-block.store.ts` · `shared/utils/mail-block.ts` · `shared/constants/mail-block.ts`(`PUBLIC_MAIL_DOMAINS`) · `pages/settings/MailBlocksPage.vue` · `MailSendReadPage.vue` · `stores/mail-settings.store.ts` · `composables/useMailSettings.ts` · 설정 틀의 `title-aside` 슬롯(`SettingsPageFrame`) | 차단은 엔진의 Sieve(필터 · 부재중과 같은 스크립트 — 계약 2절 "보낸 사람 차단"). 여러 사람이 쓰는 메일 서비스 목록은 **짝: backend `mail.constants.ts`**(30개 · 같은 차례 — 한쪽을 고치면 둘 다). 차단 · 풀기가 성공하면 엔진 규칙이 켜진 것으로 본다(`isActive`). [되돌리기]는 원래 메일함으로. 설정은 고르는 대로 `PATCH` — **칸마다 저장은 한 번에 하나**(보내는 동안 고른 값은 끝난 뒤 다르면 보낸다, 끝내 실패하면 서버가 확인한 값으로 — 6단계 필터의 `saving` 과 같은 결) |
| 대용량 첨부 (11단계) | 올리기 엔진 `src/composables/useLargeFileUploads.ts` · 쓰기 화면 잇기 `useComposeLargeFiles.ts` · 첨부 칸 `components/mail/ComposeAttachmentGroups.vue` · `ComposeLargeFileItem.vue` · 읽기 `MessageAttachments.vue`(+ `MessageLargeFileItem` · `MessageLargeFileLinkBar` · `LargeFileRevokeDialog`) · 검사 다시 묻기 `useLargeFileScanRefresh.ts` · 위험 알림 `useLargeFileAlerts.ts`(실시간 `large-file`) · 설정 `pages/settings/MailLargeFilesPage.vue` + `components/settings/LargeFile*.vue` · `stores/large-files.store.ts` · API `useLargeFiles.ts` · 공개 화면 `pages/share/SharePage.vue` + `usePublicShare.ts` · `shared/utils/large-file.ts` · `large-file-status.ts` · `kst-date.ts` · `share-links.ts` · `shared/constants/large-file.ts` | 20MB 넘는 파일 · 합계 25MB 를 넘기는 파일 · 메일에 못 담는 형식은 저절로 링크로(묻는 창 없음). **올리기**: 고르자마자 서버에 모두 만들고(임시 저장본에 바로 실림) 조각(16MiB · `Content-Digest`)은 파일 3개까지만 함께, 끊기면 2 · 5 · 15 · 30초로 다시, 1분 넘으면 멈춤 + [다시 시도], 404 는 저절로 다시 올리지 않는다, 새로고침 뒤 같은 파일(이름 · 크기 · 고친 시각 + 맨 앞 조각 SHA-256)을 다시 고르면 잇는다. 다 올릴 때까지 [보내기] · ▾ 는 `aria-disabled` + 까닭(누르면 role=status). **본문**: 대용량 파일이 있을 때만 첫 원문 인용 앞에 `<div data-ara-quote-start="">` 와 `bodyQuoteStart`(텍스트 인용 줄의 처음)를 보낸다 — 서버가 그 자리에 덩이를 넣는다. 다시 연 저장본의 원문 인용은 `mail-extensions.ts` 의 `isMailQuoteBlock`("원본 메일" 머리 문단 + 머리 표)으로 알아본다. **읽기**: 첨부 칸의 대용량 항목은 서버의 `largeFiles` 에서만 오고, 본문 덩이는 서버만 붙일 수 있는 `data-ara-large-files` 요소만 `<template>` 안에서 접는다(`mail-html.ts`). **공개 화면** `/s/:token` 은 `meta.public`(세션 복원을 건너뛴다 — 이 라우트 하나뿐), 요청은 `credentials: 'omit'` · `no-store` · `no-referrer`, v-html 없음. 서버 거절은 사전 문구(`getErrorMessage`), 화면 상수 글은 서버에 묻기 전 검사뿐 — 계약 4절, 디자인 README "11단계" |
| 계정 보안 (12단계) | 로그인 코드 단계 `src/components/auth/LoginCodeStep.vue` · `composables/useLoginChallenge.ts` · 설정 `pages/settings/AccountTwoFactorPage.vue` · `AccountAppPasswordsPage.vue` · `AccountLoginHistoryPage.vue`(+ 비밀번호 화면의 코드 칸 `PasswordChangeForm`) · `components/settings/`(`OtpCodeField` · `TwoFactorEnableDialog` + `TwoFactorEnrollmentScan` · `TwoFactorDisableDialog` · `AppPassword*` · `MailClientSettings` · `LoginSessionList` · `LoginEventTable` · `EndOtherSessionsDialog`) · QR `components/QrCodeImage.vue`(`uqr` 0.1.3 — SVG `<rect>` v-for, v-html 없음) · API `composables/useAccountSecurity.ts` · 스토어 `two-factor` · `app-passwords` · `login-history` · 상수 `shared/constants/account-security.ts`(상한 5 · 이름 40자 · 10분 · `MAIL_CLIENT_SETTINGS`) | **비밀은 화면 메모리에만** — 코드 단계의 비밀번호는 반응형이 아닌 클로저(`useLoginChallenge` — 성공 · [다른 계정으로 로그인] · 떠날 때 · 10분(타이머와 시계 `startedAt` 둘 다)에 지운다, 화면이 사라진 뒤의 늦은 `twoFactorRequired` 로는 시작하지 않는다), 2단계 키 · otpauth 주소 · 앱 비밀번호 값은 창 안에만 두고 닫을 때 지운다. **비밀이 드는 요청은 Pinia 액션에서 부르지 않는다** — devtools 타임라인이 액션의 인자 · 결과를 기록한다(창이 `useAccountSecurity` 로 보내고 스토어에는 비밀 없는 결과만 — `apply(status)` · `add(값을 뺀 줄)`). 세션 세대(`authStore.generation`)가 바뀌면 비밀이 든 창을 모두 닫는다(12단계부터 다른 기기의 로그아웃이 이 세션을 바로 끊는다). 창은 "몇 번째로 연 창인가"로 늦은 응답을 거른다 — 단 서버가 이미 켰거나 만든 결과는 반영한다. `login()` 은 `{ status }` 를 돌려준다. 코드로 나눈다: `AUTH_006` 칸 아래 · `AUTH_007` 코드 칸 보이기(6자리가 아닐 때도 이 문구) · `AUTH_008` 처음부터(새 키) · `AUTH_009` 상한. 지금 브라우저 줄이 없으면 [다른 곳 모두 로그아웃]은 "이 브라우저를 포함해"로 묻는다 — 계약 5절, 디자인 README "12단계" |
| 전환 준비 (13단계) | 제한된 세션 `stores/auth.store.ts`(`restriction` · `twoFactorDeadline` · `restrictionChecks` · `canUseApp`) · `shared/utils/auth-session.ts` · `router/index.ts` 가드 + `composables/useSessionRestriction.ts`(App) · `useApi.ts` 의 `recheckRestriction` · 로그인 틀 단계 `components/auth/LoginPasswordChangeStep.vue` · `LoginTwoFactorSetupStep.vue` · `LoginTwoFactorScan.vue`(QR 148 — `QrCodeImage` 의 `compact`) · `LoginAccountRow.vue` · `LoginSupportLine.vue` · `composables/useLoginSecret.ts` · `shared/constants/session-restriction.ts` · 문의 연락처 `stores/support-contact.store.ts` · `shared/utils/support-contact.ts` · 유예 줄 `components/mail/TwoFactorGraceNotice.vue`(AppShell 의 `notice` 슬롯 — MailLayout 에만) · 메일 프로그램 연결 `pages/settings/AccountMailClientsPage.vue` + `components/settings/MailClientGuideList.vue` · `shared/constants/mail-clients.ts` · 30일 줄 `MailListFrame`(`MAILBOX_RETENTION_NOTICES` · `MAILBOX_EMPTY_HINTS`) | **제한된 세션**(계약 6절): login · me · refresh 의 `restriction` 이 있으면 보호 화면 대신 로그인 틀의 그 단계(새 비밀번호 → 2단계 켜기)로 — 403 `AUTH_010` 은 알림 없이 me 를 다시 받고(제한 없음이면 refresh 후 원래 요청 한 번만), 서버가 제한을 확인할 때마다(`restrictionChecks`) 다시 옮긴다. 제한된 동안은 `canUseApp` 이 false 라 나가기 확인을 묻지 않고(세션이 끝난 것처럼 — 2026-11-02 리뷰: 확인에서 이동을 취소하면 메일 화면에 갇혔다) 실시간 알림 · 새 메일 확인을 열지 않는다. 모르는 제한 이름은 `unknown` — 갱신 · 다시 보내기 없이 사전 문구 + [다른 계정으로 로그인]만. **비밀**: 로그인 때 넣은 비밀번호는 반응형이 아닌 클로저에 10분(서버가 마지막으로 확인한 때부터) — 성공 · 다른 계정 · 떠남 · `isLoggedIn` false 때 지운다, 거절(`AUTH_005`)되면 지우고 칸을 보인다. 성공한 로그인 코드도 지운다(`resetCodeStep`). 단계를 마친 뒤 refresh 가 장애면 me 를 먼저 묻는다(제한이 있으면 그 단계 · 없으면 이어 감 · me 도 실패면 위쪽 상자 + [다시 시도]). 2단계를 켠 직원의 새 비밀번호 단계는 다음 코드를 받는다(로그인 코드는 2분 다시 못 쓴다). **유예 줄**: 높이 40 · [지금 켜기] 36(안내 줄 밀도 예외 재사용), 날짜는 `twoFactorDeadline` 의 1ms 앞(올해가 아니면 해), 닫기 없음, 마감이 있으면 `GET /two-factor` 를 한 번 물어 이미 켠 직원에게는 숨긴다. 설정 › 2단계 인증은 `isRequired` · `deadline` — 필수 · 켜짐이면 끄기 대신 글. **메일 프로그램 연결**: 펼침 줄 52(`--size-header-min` 재사용 — 예외 아님) · 한 번에 하나 · 처음 모두 접힘(h3 > button · `aria-expanded` · region), 글은 상수가 원본(디자인 README 13단계), 앱 비밀번호 화면에 바로가기 줄, 아이콘 `monitor-smartphone`. `PasswordField` · `OtpCodeField` 의 `variant="login"`(로그인 틀 칸 모양), `PasswordField` 의 `readonly`. 12단계 코드 단계의 도움말은 문의 줄로 바뀌었다(`LOGIN_TWO_FACTOR_TEXT.help` 삭제) — 계약 6절, 디자인 README "13단계" |
| 시계 주입 | `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 "나가기 전 확인"). **고치던 모달 창**(필터 고치기 창 — 7단계)은 `composables/useDialogLeaveConfirm.ts` — 화면의 확인에 "창 닫기" 길을 더한다: [취소] · Esc 는 `asking: 'close'`([계속 고치기]면 창을 그대로 — 브라우저가 닫았으면 `reshow()`), 다른 화면은 `'route'`, 탭 닫기 · 새로고침은 `beforeunload`. 보내는 중 · 세션이 끝났을 때는 묻지 않는다 |
| 읽기 화면 본문 안내 | `src/shared/utils/mail-body-notices.ts` · `shared/constants/mail.ts` 의 `MAIL_BODY_NOTICES` | 서버의 `isHtmlBodyOmitted`·`isBodyTruncated`(계약 2절 "읽기 한도")를 외부 이미지 차단과 같은 안내 줄로 보인다(`.reader__notice[data-notice]`) |