diff --git a/.claude/rules/env-structure.md b/.claude/rules/env-structure.md index 7ef7c51..6f0f15e 100644 --- a/.claude/rules/env-structure.md +++ b/.claude/rules/env-structure.md @@ -51,7 +51,10 @@ description: 환경변수 단일 진실 공급원(SSOT) 구조 — 루트 .env.{ 규칙: - **새 설정값은 루트 `.env.{APP_ENV}` 에 먼저 추가**하고, 필요한 서비스에 `docker-compose.yml` 의 `environment` / `args` 로 전달한다. -- 서비스 자체 env 파일에는 **루트에서 내려주지 않는 고유 값만** 작성한다. (예: 백엔드 `JWT_SECRET`) +- 서비스 자체 env 파일에는 **루트에서 내려주지 않는 고유 값만** 작성한다. (예: 백엔드 `JWT_SECRET`, + 앱 새 메일 알림의 `FCM_SERVICE_ACCOUNT_KEY` — 앱 1단계, **선택**: Firebase 서비스 계정 JSON 키를 통째로 base64 한 줄. 비면 알림만 끄고 기동한다(운영 필수 아님 — 기동 warn · + `GET /api/app-config` 의 `pushAvailable: false`). 값이 있으면 모든 환경에서 키 모양을 보고 틀리면 기동하지 않는다(어느 칸이 틀렸는지만 — 값은 싣지 않는다, `env.validation.ts` 의 `validateFcmSettings`). + 개발 · 운영 키를 따로 만든다. 매일 백업이 `backend/.env.production` 을 함께 올린다) - **compose 는 정의되지 않은 변수를 빈 문자열로 주입한다.** 코드에서 `??` 기본값만 쓰면 `Number('')===0` 같은 함정에 빠진다 — 숫자 설정값은 `shared/utils/env.ts` 의 `parsePositiveInt()` 처럼 빈 문자열까지 방어하는 파서를 거친다. (기존 `.env` 를 쓰는 사람이 새 키를 추가하지 않아도 안전해야 한다) diff --git a/.claude/rules/logging.md b/.claude/rules/logging.md index b4baea0..b02ce59 100644 --- a/.claude/rules/logging.md +++ b/.claude/rules/logging.md @@ -31,6 +31,7 @@ 아래 값은 **어떤 레벨에서도 원문 그대로 남기지 않는다.** - 비밀번호, 토큰(JWT·리프레시·API 키), 쿠키, `Authorization` 헤더 +- 앱 푸시(앱 1단계 — FCM)의 휴대폰 주소(FID — 지문도 남기지 않는다: 이어 붙여 기기를 좇지 못하게) · 서비스 계정 키 · 개인 키 · 서비스 계정 JWT · Google access token · FCM · Google 의 오류 설명 글(상태 · 오류 코드만 남긴다) - 주민등록번호·카드번호·계좌번호 등 개인 식별/금융 정보 - 요청 본문 전체 덤프 (`JSON.stringify(req.body)` 금지 — 필요한 필드만 선택해 남긴다) - DB 쿼리 로그(매개변수) — **개발 환경도** 남기지 않는다. 메일 글 · 제목 · 주소가 DB 에 있다(14단계 검색 색인). TypeORM `logging` 은 개발에서 오류 · 경고 · 마이그레이션만, 운영 · 시험은 끈다 (`shared/config/database.config.ts` 의 `databaseLogging`) diff --git a/.claude/rules/security.md b/.claude/rules/security.md index 7332d88..ef7916c 100644 --- a/.claude/rules/security.md +++ b/.claude/rules/security.md @@ -102,6 +102,16 @@ 같은 까닭으로 요청 대상의 날 `\`(경로) · `#` · 0x80 이상 바이트는 nginx · 백엔드 모두 400 이다 — Express 는 이런 대상을 옛 `url.parse` 로 다시 읽어 `\` 를 `/` 로 바꿔, nginx 가 닫은 `/api/internal/` · `/api/health` 가 백엔드에서 열렸다(2026-10-31 리뷰). +### 앱 푸시 (휴대폰 새 메일 알림 — 앱 1단계 · FCM) + +- **글은 "새 메일 N통"뿐이다** — 주소 · 이름 · 보낸 사람 · 제목 · 메일 id · 세션 id 는 Google 로 보내지 않는다. 메시지는 한 곳(`modules/push/fcm/fcm-message.ts`)에서 종류 · 개수만 받아 만든다(받는 칸이 없다), + 새 메일을 알아채는 쪽도 엔진 연결에서 신호만 받는다. Google 은 기기마다 언제 몇 통이 왔는지는 안다(알려진 한계). +- **휴대폰 주소(FID)는 한 세션에만** — 같은 FID 를 다른 세션이 등록하면 앞 줄을 지운다(같은 휴대폰에서 다른 직원이 로그인해도 앞 직원의 알림이 가지 않게). 직원마다 10개까지. +- **보낼 때 살아 있는 · 제한 없는 앱 세션만 고른다** — 끊은 세션(로그아웃 · 비밀번호 바꾸기 · 운영 도구)은 DB 의 `revoked_at` 으로 바로 빠진다. 등록 경로는 앱 토큰만(웹 쿠키 세션 403 `AUTH_003`). +- **서비스 계정은 알림 전용 하나 + 역할 "Firebase Cloud Messaging API Admin" 하나** — Firebase 의 기본 Admin SDK 계정(Firestore · Auth 까지 쓴다)을 쓰지 않는다. 키는 개발 · 운영 따로, + `backend/.env.{env}` 의 `FCM_SERVICE_ACCOUNT_KEY` 에만 둔다(로그 · 오류 · 응답에 싣지 않는다). 새면 그 키를 Google Cloud 콘솔에서 지우고 새 키로 — 그 키로는 우리 앱 사용자에게 꾸민 알림을 보낼 수 있다(메일은 읽지 못한다). +- 바깥 주소는 상수 두 곳(`oauth2.googleapis.com` · `fcm.googleapis.com`)뿐이다 — 키 파일의 `token_uri` 를 따라가지 않고(다르면 기동하지 않는다) 다른 곳으로 보내는 응답(3xx)도 따라가지 않는다. + ## 네트워크 · 인프라 - 보안 헤더(`helmet`) 적용 diff --git a/.claude/skills/docker/SKILL.md b/.claude/skills/docker/SKILL.md index 2f5ff5d..6b76d52 100644 --- a/.claude/skills/docker/SKILL.md +++ b/.claude/skills/docker/SKILL.md @@ -187,6 +187,9 @@ sudo docker compose --env-file .env.production logs -f backend 그 막기는 사설 대역에 기댄다 — `APP_NETWORK_SUBNET` · `EDGE_NETWORK_SUBNET` 은 사설 대역으로 둔다(mail setup 은 앱 네트워크만 검사한다). 서버 자신의 공인 주소로 풀리는 이름은 막지 않는다(공인 주소라서) — 그 요청은 호스트의 80 · 443 에 닿으므로, 같은 외부 프록시 뒤에 "내부 주소면 허용"하는 다른 사이트를 두지 않는다 (메일 엔진의 443 은 setup 이 403 으로 막고, 중계는 응답이 그림일 때만 돌려준다 — 2026-10-29 리뷰). + **앱 새 메일 알림(앱 1단계 — FCM)** 도 같은 길로 나간다: backend → `oauth2.googleapis.com:443`(서비스 계정 JWT → access token) · `fcm.googleapis.com:443`(보내기). + 막히면 휴대폰 알림만 오지 않는다(로그 warn · error — 다른 기능은 영향 없음). **서버 시계가 맞아야 한다**(NTP — `timedatectl` 의 "System clock synchronized: yes") — JWT 의 `iat` 가 틀리면 Google 이 토큰 교환을 거절한다. + 키(`FCM_SERVICE_ACCOUNT_KEY` — 서비스 계정 JSON 을 통째로 base64 한 줄)는 `backend/.env.{APP_ENV}` 에 둔다(compose 는 바꿀 것이 없다 — `env_file`). 비면 알림만 끄고 뜬다 — 넣었으면 기동 로그 "앱 새 메일 알림: 켬 (Firebase 프로젝트 …)"를 본다. - **본문 한도는 `MAIL_ATTACHMENT_MAX_SIZE_MB` + 1MB 이상**, 읽기 시간 제한은 150s 이상. 작으면 백엔드에 닿기 전에 413 이 나고 사용자에게는 `FILE_002` 로 보인다 (프론트 nginx 는 자동으로 맞지만 외부 프록시는 직접 맞춘다). - **실시간 알림 `/api/mail-events` 를 흘려보낸다** (운영 확인 항목, 계약 2절 "실시간 알림"): @@ -498,7 +501,7 @@ node --env-file=.env.development mail/scripts/setup.mjs | --- | --- | --- | | Node | `node:24.22.1-alpine3.24` | 아래 "Node 버전" | | nginx (운영 frontend) | `nginxinc/nginx-unprivileged:1.30.6-alpine3.24` | `frontend/Dockerfile` 의 `FROM … AS nginx-config`, 이 문서. 1.29.3 아래로 내리지 않는다 (`add_header_inherit`) | - | Stalwart (mail) | `stalwartlabs/stalwart:v0.16.27` | 루트 `.env.development.example`·`.env.production.example` 의 `MAIL_IMAGE`(각자의 로컬 `.env.*` 도), `mail/README.md` 머리말, 이 문서. `mail/scripts/setup.mjs` 는 0.16 의 관리 API·bootstrap 동작에 맞춰져 있다 — 마이너(0.17 등)를 올리면 개발에서 setup 처음 설치·재실행과 웹메일을 다시 확인한다. setup 의 리졸버 확인은 메일 이미지의 perl(`perl-base`)로 묻는다 — 새 이미지에 perl 이 있는지도 본다 | + | Stalwart (mail) | `stalwartlabs/stalwart:v0.16.27` | 루트 `.env.development.example`·`.env.production.example` 의 `MAIL_IMAGE`(각자의 로컬 `.env.*` 도), `mail/README.md` 머리말, 이 문서. `mail/scripts/setup.mjs` 는 0.16 의 관리 API·bootstrap 동작에 맞춰져 있다 — 마이너(0.17 등)를 올리면 개발에서 setup 처음 설치·재실행과 웹메일을 다시 확인한다. 앱 알림의 N 은 `Email/query` 의 `after` 가 그 초를 넣지 않는 것에 맞춰 한 초 앞을 넘긴다(`jmap-mail.provider.ts` 의 `inboxUnreadSinceFilter` — 2026-11-08 확인) — 올리면 이것도 다시 본다. setup 의 리졸버 확인은 메일 이미지의 perl(`perl-base`)로 묻는다 — 새 이미지에 perl 이 있는지도 본다 | | 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 의 이미지를 읽어 쓴다) | @@ -592,7 +595,7 @@ node --env-file=.env.development mail/scripts/setup.mjs backend 가 `getaddrinfo ENOTFOUND db` 로 뜨지 않는다 (2026-10-26 개발 환경에서 겪음). - **env 파일**: 실행 전 아래 두 파일이 모두 있어야 한다. 없으면 `*.example` 을 복사해 생성한다. - 루트 `.env.{APP_ENV}` — 설정 SSOT ([[env-structure]]) - - `backend/.env.{APP_ENV}` — compose `env_file` 대상이라 **비어 있어도 존재해야 한다** + - `backend/.env.{APP_ENV}` — compose `env_file` 대상이라 **비어 있어도 존재해야 한다** (백엔드 고유 시크릿 — `JWT_SECRET`, 앱 알림을 쓰면 `FCM_SERVICE_ACCOUNT_KEY` — 매일 백업에 함께 든다) 복사 후 `change-me-*` placeholder 값을 반드시 교체한다 ([[security]]). - **Graceful shutdown**: backend 는 `init: true`(tini)로 PID 1 을 두어 SIGTERM 이 애플리케이션까지 전달되게 하고, `stop_grace_period: 30s` 동안 처리 중인 요청을 마치도록 한다. diff --git a/.claude/skills/nestjs/SKILL.md b/.claude/skills/nestjs/SKILL.md index e734265..85f289a 100644 --- a/.claude/skills/nestjs/SKILL.md +++ b/.claude/skills/nestjs/SKILL.md @@ -44,6 +44,8 @@ description: NestJS 백엔드(backend/) 개발 규칙 — 컨트롤러/서비스 | 계정 보안 (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` — 지금 세션은 `AuthService.requestSessionId`: 웹은 리프레시 쿠키 · 앱은 액세스 토큰의 sid) · **끊은 세션 기억 `revoked-sessions.ts`**(액세스 토큰의 `sid` — 1시간, 기동 때 DB 에서 채운다, 끊는 모든 길이 `revokeWhere` 한 곳을 지난다 — 가드가 막고 실시간 알림은 다음 ping 때 닫는다). User-Agent → 짧은 이름 `shared/utils/user-agent.ts`(앱 이름 `ARA SYSTEM 앱` 은 세션의 `client` · 로그인 기록의 `method` 가 app 일 때만 `describeAppUserAgent` 로 — 웹 세션이 앱 꼴 UA 를 보내면 브라우저 null. 운영체제는 앱 UA `ARASYSTEM-App/<판> (Android <판>; <기종>)` 의 첫 괄호 칸 — 짝: flutter `kUserAgentProduct`). 세션 목록 항목에 `client`(web · app). 로그인 기록 `login-events.*`(표 `login_events` — 방식 webmail · app · imap · smtp, 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 `, 글은 디자인 README 13단계(링크 없음 · 이름 · 파일 이름 · 연락처 이스케이프 · 보내는 정화 통과), 실패는 warn(원래 일은 성공), 위험 파일은 처음 판정 때 한 번(`markInfected`). **운영 도구**(`sudo docker exec -it -backend node dist/cli/ops.js`): Nest 앱 컨텍스트(ScheduleModule 없음 · `FILE_SCANNER` null · `migrationsRun` false), 한국어 메뉴 → 바뀔 것 → y/N → 실행 → `ops_audit_log`. **메일 이전**(2026-11-02 — 전환의 imapsync): 「입사」의 이전용 비밀번호는 엔진 비밀번호 만료를 30일 뒤로 건다(그때까지 IMAP 정상 — 임시 비밀번호의 지난 만료는 IMAP 이 끊긴다), 이전 중 = 만료가 미래(31일 안)이고 실행 기록(`ops_audit_log`)의 마지막 판정 기록이 이전용(`hire-migration` · `migration-password` — 이전용 명령은 failure 도 센다) — 판정은 `modules/ops/migration-state.ts` 하나(엔진 만료만으로는 기본 만료의 찌꺼기와 가를 수 없다), 「메일 이전」(`modules/ops/migration-commands.ts`)의 이전 마치기가 직원용 임시 비밀번호로 바꾼다. 이전용 비밀번호는 실행 한 번 안에서 같은 값 — `migration-password.ts` 의 `MigrationPasswordSession`(싱글턴 · 메모리에만 · `OpsCli.run()` 의 finally 에서 지움), 약함 거절의 다시 만들기는 `temp-password.ts` 의 `applyFreshPassword`. 관리자 비밀번호는 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) | | 메일 검색 색인 (14단계 1차 — 색인 바탕 · 2차 — 검색 · 강조 · 3차 — 대화) | `modules/mail-search` · `modules/mail/jmap/jmap-index-source.ts` · `shared/utils/search-text.ts` · `subject-base.ts` | 표 둘(마이그레이션 `AddMailSearchIndex`): `mail_search_messages`(직원 주소 + 엔진 메일 id 마다 한 줄 — 원문(NFC) · 접기 글 `fold_*` · 글자 두 개 묶음 `grams` · 메일함 · 답장 관계(GIN 둘 — 직원 주소(lower 식) · 묶음 · 메일함을 한 색인에 담은 찾기 GIN `IDX_mail_search_messages_user_grams_mailboxes` 와 refs GIN. 마이그레이션이 손으로 만들고 엔티티에는 `synchronize: false` 로 이름만)) · `mail_search_states`(직원마다 한 줄 — building · ready, 엔진 Email state, 채우기 자리, 멈춘 까닭, 실패 수). 포트 `MAIL_INDEX_SOURCE`(`mail-index-source.ts` — 엔진 교체는 `mail-search.module.ts` 의 useClass 만): 어댑터는 textBody 의 글자 부분만 읽는다(첨부 안의 글은 읽지 않는다) — 본문 값은 부분 256KiB · 한 통 1MiB, 한 요청의 메일 전체 크기 합 32MiB · 16MiB 넘는 메일은 혼자. 접기 `foldText`(NFC 뒤 코드 포인트 하나씩 — 전각 → 반각 · 소문자, 원문과 위치가 같다) · 묶음 `gramsOf` · 맞은 곳 `matchRanges` · 말머리 떼기 `subjectBase` — **짝: frontend `shared/utils/search-match.ts`**, 같은 표본 표 `backend/test/search-text-samples.ts`(한쪽을 고치면 둘 다). 상태 줄은 로그인(`AuthService`) · 비밀번호 바꾸기 · `GET /api/search-index` 가 만들고 멈춤을 푼다(로그인한 적 없는 계정은 색인하지 않는다). 맞추기 `mail-search-sync.service.ts`(Email/changes — 실시간 state 소식 300ms 모음 · 5분 cron · 검색 바로 전 1.5초, 직원마다 하나(`MailSearchRuns`) · 서버 전체 4(`ConcurrencyGate`), cannotCalculate → 전체 대조), 채우기 `mail-search-build.service.ts`(밤 창 KST 22:00~02:45 · 05:00~07:30 — 1분 cron, 최신부터 50통씩 anchor, 다 읽으면 새 차례의 처음에 따라잡기 → 전체 대조 → ready). **시간 끝은 실패가 아니다** — `IndexDeadlineError`(이어 하기 — 실패 수 · 경고 없음). 색인 쓰기는 상태 줄을 `FOR UPDATE` 로 잠근 한 트랜잭션 — 퇴사 마무리(`ops-data.service.ts`)는 상태 줄 → 색인 줄 차례로 지운다. 맞추고 채우는 직원은 사내 도메인(`MAIL_DOMAIN`) 주소뿐이다(같은 DB 의 e2e 줄은 건드리지 않는다). cron 은 `timeZone: 'Asia/Seoul'`. 묶음 크기 · 시간 한도는 `mail-search.constants.ts` 한 곳(잰 뒤 바꾼다). 로그는 코드 · 사유 · 가린 주소만(메일 글 · 제목 · 엔진 설명 문장 없음), 조건 검색 쿼리(`from` · `to` · `subject` · `body`)는 `maskUrlForLog` 가 길이만, 개발의 DB 쿼리 로그도 끈다(`database.config.ts`). **2차(검색 · 강조)**: `GET /api/messages` 의 조건 `from` · `to`(참조 포함) · `subject` · `body`(검색어와 같은 검사 — `dto/search-query-fields.ts` 의 `SearchTextField`) · `after` · `before`(UTC) · `hasAttachment=true`. 목록 · 검색은 `modules/mail/message-list.service.ts` — 색인이 준비됐으면(ready · 지금 판 `INDEX_FORMAT` — 판이 다르면 서버가 뜰 때 building 으로 되돌려 밤에 다시 채운다) 색인, 아니면 엔진(조건은 `jmap/search-terms.ts` 의 `searchFieldConditions`), 쪽을 넘기는 중에는 커서의 방식대로(`mail-search/search-cursor.ts` — 색인 커서 2판 `{v:2,m:'i'}`, 엔진 커서는 1판). 첫 쪽의 색인 찾기가 DB 장애면 엔진으로 찾는다. 색인 찾기: 계획 `search-plan.ts`(낱말 · 칸 · 한 글자 낱말 `SINGLE_CHAR_TERMS_SKIP_BODY` · 두 글자 낱말뿐이면 묶음만으로 `exactByGrams`) → SQL `search-sql.ts` → `mail-search-query.service.ts`. **후보는 찾기 GIN 하나에서만 거른다** — `IDX_mail_search_messages_user_grams_mailboxes`(마이그레이션 `SearchIndexUserGramsMailboxes`, 2026-11-06 실사용 시험)가 직원 · 묶음 · 메일함을 한 번에 맞춰 맞은 줄만 읽는다. 주소는 `lower("user_email")` 식(그대로면 btree 로 그 직원 줄을 다 읽고 묶음 배열(TOAST)을 풀었다 — 260~700ms → 10ms), 메일함은 `"mailbox_ids" @> $n` 그대로(`= ANY` 면 그 직원의 묶음 줄을 모두 읽는다). **메일함만 담은 GIN 을 다시 만들지 않는다** — 메일함 id 가 계정마다 같은 꼴이라 posting 이 회사 전체인데, 계획기가 그것만으로 메일함 줄을 다 읽고 묶음 배열을 줄마다(다른 직원 줄까지) 풀었다(1만 통 메일함 1.1초 · 남의 큰 메일함과 id 가 같은 빈 메일함 1초 — 고친 뒤 15ms · 1ms). e2e 가 EXPLAIN(그 색인 하나 · 색인 조건에 메일함)과 GIN 목록을 본다. 확인(strpos — 접은 본문을 풀어 한 줄 0.15~0.3ms)은 후보 id 의 줄만, 후보가 많으면 100줄부터 4배씩 쪽이 찰 때까지 · 한 요청 3,000줄까지(넘으면 이어 찾기 커서 — 쪽이 limit 보다 적어도 hasNext), total 은 첫 쪽에서 1,000까지(넘거나 다 세지 못하면 null). 상수는 `mail-search.constants.ts`. 강조: 목록 `search-highlight.ts`(제목 · 본문에서 맞은 곳 둘레의 미리 보기 — 색인 방식에서만. 미리 보기는 인용 줄(`>`) 밖의 맞은 곳 먼저 → 인용 안에서만 맞고 검색어 낱말이 모두 색인의 다른 칸(접은 칸 넷 — `highlightTextsSql`)에도 있으면 인용 밖 첫 글 → 아니면 `>` 를 뗀 인용 조각. 본문 강조는 두 글자 이상 낱말만 — `search-plan.ts` 의 `bodyHighlightOf`, 읽기 화면 `highlightTermsOf` 도 같은 계획에서 꺼낸다), 읽기 화면 `modules/mail/message-highlight.ts` + **정화한 뒤** HTML 의 글 조각에 `` 를 넣는 `html/html-mark.ts`(태그 · 속성 · 주석 · style 안은 그대로, 길이에 비례 · 1,000개까지). 위치는 NFC 글의 UTF-16 [시작, 끝) — 강조가 있으면 제목 · 첨부 이름 · textBody 를 NFC 로 싣는다. **3차(대화)**: 대화 키 — 색인 쓰기(`mail-search-index.repository.ts` 의 `linkThreads`)가 넣은 메일 M 의 refs 가 가리키는 메일 · M 을 가리키는 메일 가운데 말머리를 뗀 제목(`subject_base`)이 같은 것의 키를 가장 작은 키로 합친다(쌍 읽기 한 쿼리 + UPDATE 한 문장 · 합치기 `thread-keys.ts`, Message-ID 가 없으면 혼자, 넣기는 이미 있는 줄의 대화 키를 덮지 않는다). 같은 대화 `GET /api/messages/{id}/conversation` · 묶은 목록 `GET /api/conversations?mailboxId=` — `modules/mail/conversation.controller.ts` · `conversation.service.ts`(색인이 준비됐으면 색인 · 아니면 엔진의 스레드 — 응답의 `basis`, 커서가 방식을 잇는다 `mail-search/conversation-cursor.ts` — `m` 이 색인 방식이면 **입구에서 decode**: 꾸민 커서는 맞추기 · 엔진 · DB 전에 400). 색인 쿼리 `mail-search/conversation-sql.ts` · `mail-conversation-query.service.ts` — **늘 그 직원 주소의 btree 로 먼저 좁힌다**(메일함은 `$n = ANY("mailbox_ids")` 꼴 — 메일함 posting 은 회사 전체라 그 직원의 줄에서 거른다. 메일함 칸을 담은 GIN 은 검색의 찾기 GIN 하나뿐이고 대화 쿼리는 쓰지 않는다), 같은 대화는 그 메일의 키와 쪽을 한 문장(LEFT JOIN LATERAL — 색인에 없음과 빈 쪽을 가른다). 묶은 목록 줄: messageIds 최신부터 `CONVERSATION_MESSAGE_IDS_MAX`(500)까지 · 참여자는 **첫 보낸 사람 칸**(`from_email` · `from_name` — 마이그레이션 `AddMailSearchSender`, 색인 판 4. 보낸 사람 글을 다시 해석하지 않는다 — 이름 안의 `<주소>` 는 주소가 아니다)에서 주소마다 한 번 · 10명까지 DB 가 고른다(대화마다 LATERAL `DISTINCT ON`), `pendingSendIds`(messageIds 가운데 붙잡힌 메일 — 기다리는 제출은 쪽마다 한 번: 색인은 `getSummariesWithHeld`, 엔진은 목록 요청 안). 10명 흉내(10만 줄)에서 큰 메일함 첫 쪽 15~80ms · 자세히 · 참여자 1~2ms. 엔진 쪽은 `jmap/jmap-conversations.ts`(Thread/get · collapseThreads) — **거르기는 값싼 속성 · 요약 속성은 내보낼 메일만**, 상한 · 시간은 `mail-search.constants.ts` 의 `CONVERSATION_ENGINE_*`(같은 대화 한 요청 1,000통 — 그 이하 스레드만 total, 넘으면 커서 자리부터 · total null / 묶은 목록 스레드마다 최신 500 · 한 요청 2,000(`shareScan`) — 상한에 걸린 줄은 읽지 못한 메일을 거르지 않고 통 수에 더하고 나머지는 읽은 최신 쪽으로 / 한 요청 15초 — 훑는 호출은 마지막 요약 읽기 몫 3초를 뺀 제한 시간, 마지막 읽기만 남은 시간을 다, 남은 3초면 더 훑지 않고 시간에 끊긴 훑기는 읽은 데까지로 답한다. 참여자 이름은 색인과 같은 `participantName`(100 코드 포인트)). 엔진의 Thread/get emailIds 는 받은 시각 오래된 차례다(2026-11-06 개발 엔진 200 스레드 확인). 휴지통 · 스팸메일함 역할과 내부 발송 키워드는 `jmap/mail-scope.ts` 한 곳, 보이기 경로의 기다리는 제출 상한 경고는 `pending-send.ts` 의 `warnIfHeldQueryFull`. 설정 `groupByConversation`(`modules/mail-user-settings` — 처음 끔, 서버는 화면이 부르면 묶을 뿐 설정을 보지 않는다) | +| 앱 새 메일 알림 (앱 1단계 — FCM) | `modules/push` · `modules/mail/new-mail-push.service.ts` · `MailEventsService.hold` · `common/guards/app-client.guard.ts` | 계약 1절 "앱 새 메일 알림 · 앱 설정". **등록** `PUT`·`DELETE /api/push-registrations/current` · `DELETE …/current/unseen`(`push-registrations.controller.ts` — `@UseGuards(JwtAuthGuard, AppClientGuard)` + `@ApiBearerAuth()`: 앱 토큰만, 웹 쿠키 세션은 403 `AUTH_003`, 제한된 세션 403 `AUTH_010`). 표 `push_registrations`(마이그레이션 `AddPushRegistrations` — 세션(sid)마다 한 줄 · FID 유일 · 세션 표와 외래 키 없음). PUT 은 한 트랜잭션(`push-registrations.repository.ts` — 세션이 살아 있는 · 제한 없는 앱 세션인지 → 같은 FID 의 다른 줄 지우기(같은 FID 는 `pg_advisory_xact_lock('PSH1', hashtext(fid))` 로 차례) → upsert(새 줄만 `seen_at` = 지금) → 직원마다 10줄). 보낼 대상 · 붙잡을 직원은 늘 세션 표와 함께 고른다(살아 있는 · 제한 없는 앱 세션 — 끊은 세션은 `revoked_at` 으로 바로 빠진다), 끝난 세션의 줄은 5분 정리 `push-cleanup.service.ts`(그 휴대폰을 직원이 건드리지 않고 끝난 세션 — 저절로 끝남 · 다른 곳의 비밀번호 바꾸기 · 앱 갱신 유예 · 토큰 재사용 감지 — 의 휴대폰에만 한 번 session-ended, 끊은 까닭 표는 `push.constants.ts` 의 `SESSION_ENDED_NOTICE` 한 곳(`Record` — 까닭을 더하면 거기서 정해야 컴파일된다) — `DELETE … RETURNING`). 등록이 바뀐 소식은 `PushRegistrationsService.onChanged`(메일 쪽이 듣는다 — 푸시 모듈은 메일을 모른다, 사내 도메인만 `MailEngineModule` 의 `MAIL_SETTINGS` 에서). **보내기** 포트 `PUSH_SENDER`(`push-sender.port.ts` — 알림은 종류 · 개수뿐) · 구현 `configured-push-sender.ts`(env `FCM_SERVICE_ACCOUNT_KEY` — 없으면 보내지 않고 기동은 한다) · `fcm/`: 키 읽기 `fcm-credentials.ts`(env 검사와 같은 함수 — 까닭에 값을 싣지 않는다), OAuth `fcm-auth.ts`(RS256 JWT → `oauth2.googleapis.com/token`, 만료 5분 전까지 기억 · 함께 부르면 한 번), 보내기 `fcm-sender.ts`(`message.fid` — 실패 표는 계약, 시도 3번 · 20초 · 서버 전체 4 · 429 는 `Retry-After` 가 있을 때만 그때 한 번 · 보내기 직전(다시 하기마다) 부른 쪽의 `stillValid` — 그 세션의 줄이 아직 그 FID 인가(`isDeliverable`) · 로그는 종류마다 한 시간에 한 번 · 서버 종료로 끊긴 것은 로그 없이), 메시지 `fcm-message.ts`(순수 함수 한 곳 — 글은 `push-text.ts`), HTTP `fcm-transport.ts`(전역 fetch · 두 주소 고정 · 응답 64KB — 시험만 `forTesting`). 새 의존성 없음. **새 메일** `NewMailPushService`(메일 모듈): 보낼 기기가 있는 사내 직원의 엔진 연결을 붙잡고(`MailEventsService.hold` — SSE 와 같은 연결 · 같은 판정, 신호만 받는다 · 연결 상한에 세지 않는다, 함께 여는 수 4, 실패하면 1 · 5 · 15 · 60분 — 계정 문제는 60분부터), 새 메일 신호 → 같은 직원 30초에 한 번(첫 알림 바로 · 끝에 한 번) → 기기마다 N(`MailProvider.countInboxUnreadSince` — 받은편지함 · 안 읽음 · 초안 아님 · `seen_at` 뒤, 한 요청 8개씩) → N ≥ 1 이면 보낸다 · 받지 않는 기기는 FID 가 그대로일 때만 지운다. 붙잡을 때마다 따라잡기(10분 안 · `seen_at` · `last_pushed_at` 의 **다음 초**부터 — 받은 시각은 초, N 은 `seen_at` 의 초부터 같은 초를 넣는다. 엔진의 `after` 는 그 초를 넣지 않아(0.16.27 — 2026-11-08 확인) 어댑터가 한 초 앞을 넘긴다). 붙잡기가 끝나면(`ended` — 계정 문제 60분 · 그 밖 1분 뒤 다시, 서버 종료가 아닌데 허브가 닫혀도 알린다) 간격 끝 차례도 지운다, 등록이 바뀐 소식은 기다림을 풀고 바로 붙잡아 본다. 같은 직원의 보내기는 `KeyedQueue` 로 차례 | +| 앱 설정 (앱 1단계) | `modules/app-config` | `GET /api/app-config`(로그인 없음 · 기본 요청 수 한도 · `Cache-Control: no-store`) → `{ minSupportedVersion: { android, ios: null }, pushAvailable }`. 최소 판은 코드 상수 `app-config.constants.ts` — 옛 앱을 막을 API 변경과 같은 커밋에서 올린다. `pushAvailable` = `PUSH_SENDER.available` | | 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]]) | @@ -54,14 +56,14 @@ description: NestJS 백엔드(backend/) 개발 규칙 — 컨트롤러/서비스 | 파일 업로드 | `modules/upload` | **로그인 필요**(`JwtAuthGuard`). 로컬 디스크 저장 + 형식·크기 검증 + 안전한 파일명 생성 ([[security]]). 한글 파일명은 multer `defParamCharset: 'utf8'` | | 헬스체크 | `modules/health` | `@SkipThrottle`+`@SkipTransform` — 기계가 읽는 엔드포인트 패턴 ([[response-format]]) | | 요청 컨텍스트 | `shared/logger/request-context.ts` | 요청마다 추적 ID 를 AsyncLocalStorage 에 둔다 — 필터를 거치지 않는 로그도 `traceTag()` 로 `[traceId]` 를 붙인다 ([[logging]]). 요청이 끝난 뒤에도 이어지는 일(실시간 알림의 엔진 연결 등)은 `runOutsideRequestContext()` 로 연다 — 처음 요청의 추적 ID 를 물려받지 않게 | -| 순수 유틸 | `shared/utils/` | 순수 함수 분리 위치 (예: `mask.ts` 로그용 이메일 마스킹·URL 의 검색어 가리기(`maskUrlForLog` — `q` 와 `cursor`(검색어 해시를 담는다)는 길이만), `settle.ts` 정해진 시간까지만 기다리기, `duration.ts` 기간 파싱, `content-disposition.ts` 다운로드 헤더, `cookies.ts`, `trust-proxy.ts`, `request-origin.ts`) | +| 순수 유틸 | `shared/utils/` | 순수 함수 분리 위치 (예: `mask.ts` 로그용 이메일 마스킹·URL 의 검색어 가리기(`maskUrlForLog` — `q` 와 `cursor`(검색어 해시를 담는다)는 길이만), `settle.ts` 정해진 시간까지만 기다리기, `duration.ts` 기간 파싱, `content-disposition.ts` 다운로드 헤더, `cookies.ts`, `trust-proxy.ts`, `request-origin.ts`, 함께 도는 일의 수 `concurrency-gate.ts` · 열쇠마다 한 번에 하나 `keyed-queue.ts` — 검색 색인 · 앱 알림이 함께 쓴다) | > **확장 시 따르는 규칙** (기능이 필요해지면 규칙대로 구현): > - **보호 라우트**: 인증은 구현되어 있다 (`modules/auth` + `common/guards/jwt-auth.guard.ts`). 새 보호 라우트는 컨트롤러·핸들러에 > `@UseGuards(JwtAuthGuard)` + `@ApiCookieAuth()` 를 걸고 `@CurrentUser()` 로 직원을 받는다 (전역 가드가 아니다 — `users`·`health` 예제는 공개, `upload` 는 로그인 필요). > 가드는 웹 쿠키 · 앱 Bearer 를 다 받는다 — 문서의 Bearer 는 `main.ts` 가 붙이므로 `@ApiBearerAuth()` 는 달지 않는다. "이 요청의 세션"이 필요하면 `AuthService.requestSessionId(user, 리프레시 쿠키)` 를 쓴다(앱은 쿠키가 없다 — sid). > 13단계부터 그 클래스에 `@ApiSessionErrors()`(AUTH_001 + AUTH_010 — 문서)도 단다. 제한된 세션(새 비밀번호 · 2단계 켜기 전)도 불러야 하는 핸들러만 `@AllowRestrictedSession()` — 계약 6절 목록뿐이고, 달지 않은 보호 라우트는 403 `AUTH_010` 이다. -> 권한(AUTH_003) 구분이 필요해지면 역할 가드를 `common/guards/` 에 추가한다. +> 권한(AUTH_003) 구분이 필요해지면 역할 가드를 `common/guards/` 에 추가한다. 앱 토큰만 받는 경로(앱 알림 등록 등)는 `@UseGuards(JwtAuthGuard, AppClientGuard)`(`common/guards/app-client.guard.ts` — 웹 쿠키 세션은 403 `AUTH_003`) + `@ApiBearerAuth()` 만. > 반면 출처 검사(`OriginGuard`)는 **전역**이다 — 웹훅처럼 다른 사이트가 정당하게 부르는 엔드포인트가 생기면 예외 표시를 따로 만든다 > - **메일 엔진 교체**: `modules/mail/mail.module.ts` 의 `MAIL_PROVIDER` 구현(useClass)만 바꾼다 — 서비스·컨트롤러는 포트만 안다 > - **DB 영속화**: TypeORM 엔티티/리포지토리 추가 (예제의 in-memory 저장 대체). @@ -262,6 +264,9 @@ SwaggerModule.setup('api-docs', app, document); > 그대로면 인스턴스마다 따로 세고 기억해 같은 코드를 다른 인스턴스에서 다시 쓸 수 있다 → 공유 저장소(`SET NX PX` · 카운터). 끊은 세션 기억(`revoked-sessions.ts` — 1시간, 기동 때 DB 에서 채움) — > 13단계부터 15초마다 DB 에서 채우므로 인스턴스 사이도 15초 안에 맞는다(운영 도구가 끊은 세션도 같은 길). 2단계 필수 정책 · 문의 연락처의 10초 기억(`system-settings`)도 인스턴스마다 따로지만 10초 안에 맞는다. 검사가 겹치면 위험 파일 알림 메일도 겹칠 수 있다(조건부 UPDATE 로 줄였다). 로그인 기록 정리 cron · 웹훅 거절 로그 묶음은 인스턴스마다 돌아도 무해하다. > 검색 색인(14단계)의 직원마다 차례(`mail-search-runs.ts` 의 `MailSearchRuns`) · 맞추기의 소식 모음 · 기다리는 맞추기 · 5분 차례 겹침 막기(`mail-search-sync.service.ts` 의 debounces · queued · syncingAll) · 서버 전체 맞추기 수(`ConcurrencyGate` — 4) · 1분 채우기 겹침 막기(`mail-search-build.service.ts` 의 running) — 그대로면 인스턴스마다 따로 돌아, **두 인스턴스의 1분 차례가 같은 직원을 함께 채우고**(쓰기는 상태 줄 잠금으로 차례가 되지만 같은 묶음을 두 번 읽는다) 같은 직원을 함께 맞추며, 엔진에 가는 맞추기 수가 인스턴스 수만큼 는다 → 직원마다 잠금(`SET NX PX` · `pg_advisory_lock`) · 공유 셈. 표 쓰기 자체는 DB 라 그대로 맞다. + > 앱 새 메일 알림(앱 1단계)의 직원마다 엔진 연결 붙잡기 · 30초 간격 · 보내기 차례(`modules/mail/new-mail-push.service.ts` 의 held · pacing · sends) — 인스턴스마다 붙잡으면 같은 새 메일을 인스턴스 수만큼 보낸다 + > (N 은 엔진에서 세므로 수는 같고 알림만 겹친다) → 한 인스턴스만 붙잡게(`pg_try_advisory_lock` 을 쥔 인스턴스가 모두 맡는다 — 세션 단위 잠금이라 그 연결을 붙잡는다, 그 인스턴스가 죽으면 다른 인스턴스가 잇는다) 또는 직원마다 잠금. + > FCM 보내기의 동시 수(`fcm-sender.ts` 의 4)도 인스턴스마다다. 등록 · `seen_at` · `last_pushed_at` 은 DB 라 그대로 맞다. 세션 끝 알림은 지운 인스턴스만 보낸다(`DELETE … RETURNING`). FCM OAuth 토큰 기억은 인스턴스마다 하나여도 무해하다. > 대용량 첨부(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 라 그대로 맞다: @@ -435,6 +440,8 @@ dc exec backend npm uninstall <패키지> **엔진 Basic 의 402 는 장애가 아니다**(12단계) — 비밀번호는 맞고 그 직원이 2단계 인증(OTP)을 켰다는 뜻이다. 웹메일 로그인은 코드 단계로, 비밀번호 바꾸기는 대리 접근 + `otpAuth.otpCode` 로 간다(지금까지 `MAIL_001` 로 다뤄 OTP 를 켠 직원이 들어오지 못했다). 2단계 · 앱 비밀번호 · 웹훅 설정을 고칠 때는 계약 5절의 엔진 사실(엔진은 등록 때 코드를 보지 않는다 · 같은 코드를 다시 받는다 · 앱 비밀번호 기본 권한은 앱 비밀번호를 또 만든다)을 함께 본다. **발송 제출은 envelope 을 직접 준다**(`jmap/envelope.ts`) — 주지 않으면 엔진이 받지 않는 주소(모르는 최상위 도메인 등)를 아무 말 없이 빼고 나머지에게만 보낸다(2026-10-29 확인). + **앱 푸시(FCM)로 나가는 글에는 메일 내용 · 주소를 싣지 않는다**(앱 1단계) — 메시지는 `modules/push/fcm/fcm-message.ts` 한 곳에서 종류 · 개수만으로 만든다(받는 칸이 없다). + 새 메일을 알리는 쪽은 엔진 연결을 붙잡아 **신호만** 받는다(`MailEventsService.hold` — 보낸 사람 · 제목을 넘기지 않는다). FID · 서비스 계정 키 · access token · FCM 오류 글은 로그에 남기지 않는다. **대용량 첨부의 표시**(11단계 — `data-ara-large-files` · `` · `data-ara-quote-start`)는 서버만 넣는다 — 두 정화기가 사용자 HTML 의 `data-ara-*` · 주석을 지우는지 정화를 고칠 때 함께 본다(지우지 못하면 남이 꾸민 덩이가 첨부처럼 보인다). 보내기에 실패해도 링크는 "확실한 실패"(`SendNotStartedException`)에서만 거둔다 — 결과를 모르면 남긴다(메일이 나갔을 수 있다). - [ ] **env**: DB/Redis/CORS/포트는 루트 `.env` 에서 주입된다. 백엔드 고유 시크릿(`JWT_SECRET` 등)만 `backend/.env.{env}` 에 작성(compose `env_file` 로 로드). diff --git a/docs/mail/api-contract.md b/docs/mail/api-contract.md index ca5bcdc..c53e61f 100644 --- a/docs/mail/api-contract.md +++ b/docs/mail/api-contract.md @@ -163,7 +163,120 @@ - 로그아웃 · 다른 계정으로 로그인 · 세션 끝 뒤에 도착한 응답(로그인 · 코드 · 갱신 · me)은 쓰지 않는다 — 버린 로그인 결과에 토큰이 있으면 그 토큰으로 조용히 앱 logout 을 부른다(웹의 "로그아웃 뒤 늦은 갱신"과 같은 결). - 코드 단계 · 제한 단계에서 다시 쓸 비밀번호는 화면 메모리에만 10분 — 성공 · 다른 계정 · 떠남 · 제한이 풀릴 때 지운다(웹과 같다). -**이 절 다음(앱 첫 판 안 — 따로 차례)**: 푸시 기기 등록(`POST`·`DELETE /api/push-devices` — 앱 세션에 묶어 logout · 세션 끊기 · 비밀번호 바꾸기 · 퇴사 마무리 때 함께 지운다, 내용은 "새 메일 N통"만), 앱 최소 판 확인(공개 `GET /api/app-config` → `{ minVersion, latestVersion, storeUrl }`). +### 앱 새 메일 알림 · 앱 설정 — 앱 1단계 (2026-11-07 정함 · 서버) + +> 2026-11-07 사용자가 추천안대로 정했다(앞 메모의 `/api/push-devices` · `{ minVersion, latestVersion, storeUrl }` 를 이 절로 바꾼다). +> 서버는 `modules/push`(등록 · 끝난 세션 정리 · 보내기 — 메일을 모른다) · `modules/mail/new-mail-push.service.ts`(새 메일 알아채기 · N · 간격 · 따라잡기) · `modules/app-config` 가 이 절대로 만들었다. +> 보내는 곳은 FCM HTTP v1 하나 — 새 의존성 없이(node crypto 의 RS256 + 전역 fetch). 휴대폰 주소는 FCM 등록 토큰이 아니라 **FID**(Firebase 설치 id — v1 의 `token` 칸은 deprecated, `message.fid` 로 보낸다)다. + +| 경로 | 본문 | 응답 | +| --- | --- | --- | +| `GET /api/app-config` (로그인 없음) | — | `{ minSupportedVersion: { android: "1.0.0", ios: null }, pushAvailable: boolean }` · `Cache-Control: no-store` | +| `PUT /api/push-registrations/current` (앱 토큰만) | `{ fid, platform: "android", appVersion }` | 200 `{ updatedAt }` (ISO 8601 UTC · `Cache-Control: private, no-store`) | +| `DELETE /api/push-registrations/current` (앱 토큰만) | — | 204 (등록이 없어도) | +| `DELETE /api/push-registrations/current/unseen` (앱 토큰만) | — | 204 (등록이 없어도) | + +- **app-config**: 최소 판은 서버 코드 상수(`modules/app-config/app-config.constants.ts`) — 옛 앱이 쓸 수 없게 서버 API 를 바꾸는 배포가 **같은 커밋에서** 올린다. `ios: null` 은 막지 않는다는 뜻(iOS 앱이 아직 없다). + `pushAvailable` 은 서버가 FCM 키를 읽었는가(키가 막힌 것 — OAuth 거절 — 은 로그로만 알린다). 요청 수는 기본 한도(경로마다 IP 당 1분 100회 — 문의 연락처와 같다), 실패는 429 `REQ_001` 뿐이다. +- **본문**: `fid` 는 영문 · 숫자 · `-` · `_` 64자 이하(Firebase 의 FID 는 22자), `platform` 은 `android` 만, `appVersion` 은 `숫자.숫자.숫자`(칸마다 1~4자리 · 앞자리 0 없이 — 앱의 `PackageInfo.version`). 모르는 칸은 400. +- **실패**: 웹(쿠키) 세션 403 `AUTH_003`(`AppClientGuard` — 토큰은 맞지만 이 경로를 쓸 수 없는 클라이언트) · 제한된 세션(6절) 403 `AUTH_010` · 토큰 없음 · 그사이 끝난 · 끊긴 세션 401 `AUTH_001` · 본문 400 `VAL_001` · 요청 수 429 `REQ_001`. 새 오류 코드는 없다. +- **등록 — 세션(sid)마다 한 줄**(표 `push_registrations` — 마이그레이션 `AddPushRegistrations`, 세션 표와 외래 키로 묶지 않는다). PUT 은 한 트랜잭션이다: + 그 세션이 살아 있는 · 제한 없는 앱 세션인지 보고(아니면 아무것도 바꾸지 않는다) → 같은 FID 의 다른 줄(다른 세션 · 다른 직원 — 같은 휴대폰에서 다른 직원이 로그인)을 지운다 → + 넣거나 바꾼다(FID · 플랫폼 · 판 · `updatedAt`) → 한 직원 10줄을 넘으면 가장 오래 바꾸지 않은 줄부터 지운다. 같은 FID 의 등록은 DB 잠금으로 차례가 된다(함께 등록해도 한 줄만 남는다). + **새 줄이면 세는 시작(`seen_at`) = 지금**, 있던 줄은 세는 시작 · 마지막으로 보낸 때를 건드리지 않는다. FCM 키가 없어도(`pushAvailable: false`) 저장한다 — 키를 넣으면 다시 등록하지 않아도 알림이 시작된다. + 켬 · 끔은 휴대폰이 들고 있다 — 끄면 앱이 DELETE 한다(서버에 켜짐 칸은 없다). +- **`/unseen`**: 그 줄의 `seen_at` = 지금(서버 시각) — "이 휴대폰에서 앱을 봤다". 다음 알림의 N 을 그때부터 센다. +- **새 메일 알아채기**: 보낼 기기(살아 있는 · 제한 없는 앱 세션의 등록)가 있는 사내 직원은 SSE 연결이 없어도 서버가 그 직원의 엔진 연결을 **붙잡아 둔다**(2절 "실시간 알림"의 연결 — `MailEventsService.hold`, 직원마다 여전히 하나). + 판정은 웹의 `new-mail` 그대로다(받은편지함 · 초안 아님 · 읽지 않음 · 보낸 사람이 나 아님 · 본 때보다 10분 넘게 앞서 받은 메일 빼기). 붙잡을 직원은 서버가 뜰 때 · 5분마다 · 등록이 바뀐 직원은 그때 맞춘다(함께 여는 수 4). + 붙잡기에 실패하면 1 · 5 · 15 · 60분 뒤 다시(계정을 쓸 수 없음 — `AUTH_001` · 비밀번호 만료 · 잠김 · 엔진 연결이 끝남 — 은 60분부터). FCM 키가 없으면 아무도 붙잡지 않는다. + 곁 효과: 알림을 켠 직원은 웹을 닫아도 14단계 검색 색인의 실시간 맞추기가 돈다. +- **N**: 받은편지함의 안 읽은 메일(초안 빼고) 가운데 **이 휴대폰에서 마지막으로 앱을 본 뒤**(`seen_at` — 같은 초 포함) 받은 수 — 보낼 때 엔진에 센다(기기마다). PC 에서 읽은 메일은 빠진다. **0 이면 보내지 않는다.** + 보내는 때는 웹 `new-mail` 이 있을 때뿐이다(안 읽음 표시 바꾸기 · 옛 메일 옮기기로는 보내지 않는다). +- **간격**: 같은 직원은 30초에 한 번까지 — 첫 알림은 바로, 그사이 온 것은 30초 끝에 그때의 N 으로 한 번. 같은 직원의 보내기는 차례로 한다. +- **따라잡기**: 붙잡을 때마다(서버를 다시 띄움 · 다시 붙잡음) 그 전 10분 안 · 마지막으로 보낸 뒤 · 본 뒤에 온 안 읽은 메일이 있으면 그 기기에 그때의 N 으로 한 번 — 이미 알린 · 이미 본 메일로는 다시 울리지 않는다. + "보낸 뒤 · 본 뒤"는 `last_pushed_at`(그 알림의 N 을 센 때) · `seen_at` 의 **다음 초부터**다 — 받은 시각은 초라 그 초에 받은 메일은 그 알림에 이미 들었거나 이미 보았다고 본다(2026-11-07 리뷰). N 은 위처럼 `seen_at` 의 초부터 센다. + 그 초에 센 · 본 뒤 들어온 메일은 따로 새 메일 신호를 낸다 — 그 알림이 나가기 전(그 1초 안 · 30초 간격의 끝을 기다리는 사이)에 서버가 멈추면 놓친다. 따라잡기로 보내도 30초 간격에 든다. + **엔진의 `after` 는 그 초를 넣지 않는다**("그 초보다 뒤" — RFC 8621 은 "같거나 뒤"라 적는다, 2026-11-08 개발 엔진 0.16.27 에서 확인) — 서버는 한 초 앞을 넘겨 같은 초를 센다(`jmap-mail.provider.ts`). 엔진 판을 올리면 다시 본다(같은 초를 넣게 바뀌면 따라잡기가 이미 알린 메일로 한 번 더 울린다). +- **보내는 메시지** (FCM v1 `messages:send` 의 `message` — 서버 `modules/push/fcm/fcm-message.ts` 한 곳, 받는 것은 FID 와 개수뿐): + ```json + { + "fid": "", + "notification": { "title": "새 메일 3통" }, + "data": { "module": "mail", "kind": "new-mail" }, + "android": { + "collapse_key": "new-mail", + "priority": "HIGH", + "ttl": "86400s", + "notification": { + "tag": "new-mail", + "channel_id": "new_mail", + "notification_priority": "PRIORITY_HIGH", + "visibility": "PUBLIC", + "notification_count": 3 + } + } + } + ``` + 글은 제목 "새 메일 N통"뿐이다(천 단위 쉼표 — 원본은 서버 `modules/push/push-text.ts`). 주소 · 이름 · 보낸 사람 · 제목 · 메일 id · 세션 id 는 싣지 않는다 — 누르면 늘 메일 탭 받은편지함(`data.module`). + 꺼져 있던 휴대폰은 마지막 것 하나만 받고(`collapse_key`), 24시간이 지나면 버린다(`ttl`). +- **그 휴대폰을 직원이 건드리지 않았는데 앱 세션이 끝나면** 5분 정리가 그 등록을 지운 뒤(`DELETE … RETURNING` — 한 번만) 그 휴대폰에 한 번 보낸다 — `kind: "session-ended"`, 같은 tag · 채널 · `collapse_key`(옛 "새 메일 N통"을 바꿔 쓴다), `priority: NORMAL`, + 글은 보드 `App1Push` ⑧(2026-11-07 확정) 그대로 — 제목 "새 메일 알림이 멈췄습니다" · 내용 "안전을 위해 로그아웃했습니다. 앱을 열어 다시 로그인해 주세요."(원본 `push-text.ts`). + 끝난 까닭(세션 표의 `revoked_reason`)마다 — 표는 서버 `modules/push/push.constants.ts` 의 `SESSION_ENDED_NOTICE` 한 곳(까닭을 더하면 거기서 정해야 컴파일된다): + + | 끝난 까닭 | 값 | 알림 | + | --- | --- | --- | + | 저절로 끝남(미사용 14일 · 절대 30일) | 없음(끊기지 않았다) | 보낸다 | + | 다른 곳에서 비밀번호를 바꿈 · 바꾸다 결과를 알 수 없게 됨 | `password-changed` · `password-change-uncertain` | 보낸다 | + | 앱 갱신 유예(회전 응답을 잃었거나 토큰이 두 번 쓰였다) | `app-refresh-grace` | 보낸다 | + | 리프레시 토큰 재사용 감지 | `refresh-token-reused` | 보낸다 | + | 그 휴대폰에서 로그아웃 | `logout` | 보내지 않는다 | + | 로그인된 기기에서 끊기 · 다른 곳 모두 로그아웃 | `ended-by-user` | 보내지 않는다 | + | 운영 도구(재설정 · 잠금 · 이전용 비밀번호 다시 만들기 · 이전 마치기 · 퇴사 마무리) | `ended-by-operator` | 보내지 않는다 | + | 계정을 쓸 수 없음(갱신 때) | `account-unavailable` | 보내지 않는다 | + | 2단계를 켜 끊긴 세션(제한된 세션) | `two-factor-not-verified` | 보내지 않는다 | + + 제한이 붙은 세션 · 까닭이 없거나 표에 없는 값(다른 판이 남긴 줄) · 세션 줄이 없는 등록에는 보내지 않는다. + 보내기 직전 그 FID 를 다른 등록이 쓰고 있으면(그 휴대폰에서 다른 직원 · 같은 직원이 다시 로그인했다) 보내지 않는다. +- **등록이 사라지는 길**: 보낼 때는 살아 있는 · 제한 없는 앱 세션의 줄만 고른다 — 끊은 세션(위 표의 까닭 모두)은 DB 의 `revoked_at` 으로 **바로** 빠진다(15초 기억과 상관없이). + 끝난 세션의 줄은 5분 정리(`modules/push/push-cleanup.service.ts`)가 지운다 — 위 표에서 "보낸다"인 세션의 휴대폰에만 지운 뒤 한 번 알린다. 제한이 붙은 세션의 줄은 남는다(보내지 않는다 — 풀리면 다시). 같은 휴대폰에서 다른 직원이 등록하면 앞 줄을 지운다(앞 직원의 붙잡기도 다시 고른다 — 알리지 않는다). + **보내기 직전(다시 하기마다) 그 세션의 줄이 아직 그 FID 이고 세션이 살아 있는지 다시 본다** — 센 뒤 · 다시 하기를 기다리는 사이 그 휴대폰이 다른 직원에게 옮겨 갔으면 보내지 않는다. + FCM 이 받지 않는 휴대폰(아래 "실패")은 그 줄을 지운다 — 그사이 PUT 이 FID 를 바꿨으면 둔다. 퇴사 마무리(6절)는 그 주소의 줄을 한 트랜잭션에 지운다("앱 알림 등록"). +- **FCM 의 실패**(`modules/push/fcm/fcm-sender.ts`): `UNREGISTERED`(404) · `SENDER_ID_MISMATCH`(403) · `message.fid` 칸의 `INVALID_ARGUMENT`(400) → 그 줄을 지운다 / + 401 → access token 을 버리고 새 토큰으로 한 번 더 / 429(`QUOTA_EXCEEDED`) → 바로 다시 보내지 않는다 — `Retry-After` 가 있고 남은 시간 안이면 그때 한 번, 아니면 이번 알림은 버린다(다음 새 메일이 그때의 N 으로 다시 간다 — 같은 직원은 30초에 한 번이라 한도에 닿을 일이 드물다) / + 5xx · 연결 오류 · 시간 초과 → 다시(1 · 4초 ±20% — 응답의 `Retry-After` 는 남은 시간 안에서만, 시도 3번 · 시도마다 10초 · 한 번 보내기 20초 안) / + 그 밖(400 · 403 `PERMISSION_DENIED` · 오류 코드 없는 404 · `THIRD_PARTY_AUTH_ERROR` · 3xx · OAuth 거절) → 지우지 않는다(프로젝트 id 가 틀린 404 로 모든 등록을 지우지 않게) · error. 서버 전체에서 함께 보내는 수 4. + OAuth access token 은 만료 5분 전까지 기억한다(함께 부르면 교환은 한 번). +- **로그**: FID(지문도) · 서비스 계정 키 · 개인 키 · JWT · access token · FCM · Google 의 오류 설명 글은 남기지 않는다 — 상태 · 오류 코드만, 주소는 가린다(`maskEmail`). + 보낸 것은 debug("앱 알림: user=a***@…, 기기 1, N 3"), 끝내 못 보냄은 warn · 설정 오류는 error(종류마다 한 시간에 한 번 — 묻힌 수를 다음 줄에), 붙잡기가 이어서 실패하면 warn 한 번 · 다시 되면 info 한 번. + 서버 종료로 끊긴 보내기는 로그를 남기지 않는다(배포마다 남는 잡음이 되지 않게). +- **환경 변수**: `backend/.env.{env}` 의 `FCM_SERVICE_ACCOUNT_KEY` = 서비스 계정 JSON 키 파일을 통째로 base64 한 줄(JWT_SECRET 과 같은 자리 — compose 는 바꿀 것이 없다, 매일 백업에 함께 든다). + 비면 알림만 끈다(기동은 한다 — 운영 필수가 아니다, 기동 로그 warn). 값이 있으면 **모든 환경에서** 모양(base64 → JSON → `service_account` · `project_id` · `client_email` · `private_key_id` · PEM RSA 2048 이상 · `token_uri`)을 보고 틀리면 기동하지 않는다(어느 칸이 틀렸는지만 — 값은 싣지 않는다). + 켜지면 기동 로그 한 줄 "앱 새 메일 알림: 켬 (Firebase 프로젝트 )". 서비스 계정은 알림 전용 하나 + 역할 "Firebase Cloud Messaging API Admin" 하나, 키는 개발 · 운영 따로. +- **운영 확인**: backend 컨테이너 → `oauth2.googleapis.com:443` · `fcm.googleapis.com:443`(외부 이미지 중계와 같은 길 — 호스트 방화벽 · NCP ACG 의 나가는 규칙), 서버 시계(NTP — JWT 의 `iat`, 틀리면 토큰 교환이 거절된다). +- **실기기 · 실제 키로 확인할 것**(첫 보내기 때): FCM 이 `message.fid` 와 메시지 칸(`priority "HIGH"` · `notification_priority` · `visibility` · `notification_count` · `ttl` 글)을 받는지, + 오류 본문의 모양(`UNREGISTERED` · `SENDER_ID_MISMATCH` · 429 · 503 의 `Retry-After`) — **잘못된 FID 를 `message.fid` 가 아닌 칸(예: `message.token`)으로 알리면 지금은 그 줄을 지우지 않고 설정 오류(error — 한 시간에 한 번)로만 남긴다**(안전한 쪽 — 오류 본문을 보고 칸 이름을 맞춘다), + 실기기의 FID 길이 · 글자(22자 base64url 로 본다), 같은 tag 로 바꿔 쓴 알림이 다시 울리는지 · `notification_count` 의 런처 배지(삼성). +- **알려진 한계**: Google 은 기기마다 언제 몇 통(N)이 왔는지는 안다(내용 · 주소는 가지 않는다). 푸시는 최선을 다할 뿐이다(FCM 장애 · 강제 종료한 앱 · 배터리 제한). 웹에서 다 읽어도 이미 뜬 알림은 다음 알림 · 앱을 열 때까지 그대로다. + 나에게 보낸 메일도 N 에 든다(그 메일이 알림의 방아쇠가 되지는 않는다). 서버가 10분 넘게 멈췄으면 그동안 온 메일은 다음 알림의 N 으로만 알린다. + **인스턴스를 늘리면 알림이 겹친다**(붙잡기 · 간격이 프로세스 메모리 — nestjs 스킬의 Redis 목록). + +**클라이언트 — 앱**(Flutter — 앱 디자인 README "새 메일 알림 · 업데이트 · 앱 아이콘", 2026-11-07) +- 등록은 FCM 의 FID 로 한다(`register()` · `onRegistered` — 자동 초기화는 끈다). 보낼 조건 = 받을 수 있는 휴대폰 ∧ `pushAvailable` 이 거짓이 아님(모르면 보낼 수 있는 것으로) ∧ 메일을 쓸 수 있는 세션(제한 단계가 아님) ∧ 이 휴대폰에서 켬(처음 켜짐) ∧ 알림 권한 ∧ 새 메일 채널. + `pushAvailable: false` 면 PUT 하지 않는다 — 그 전에 등록된 줄은 이어지고, 새 휴대폰은 앱이 앱 설정을 다시 받은 뒤(앞으로 올 때 — 1시간마다) 등록한다. +- **PUT** 은 FID 를 받았을 때(등록 뒤 첫 소식 · FID 가 바뀜) · 세션이 열릴 때마다 한 번(앱을 띄울 때 — 지난 실행의 FID 로 바로) · 설정 스위치를 켤 때만 보낸다. `appVersion` 은 앱 판(모르면 `0.0.0`). + 스위치로 켤 때만 실패를 알리고(그 코드의 사전 문구) 이 휴대폰의 값을 끔으로 둔다(처음 값 켬에서 허락하고 켰어도 — 스위치는 꺼진 채, 저절로 다시 하지 않는다). + 저절로 보낸 PUT 의 실패는 조용히 다음 기회(앞으로 옴 · FID 바뀜 · 다음 로그인)에 다시 한다 — 등록이 된 뒤로는 앞으로 와도 보내지 않는다. +- **DELETE** 는 설정 스위치를 끌 때(지운 뒤 꺼진다 — 실패면 켬 그대로, 알린다)와 앞으로 올 때 권한 · 새 메일 채널이 꺼진 것을 알았을 때(조용히)만. + **로그아웃 · 세션 끝에는 부르지 않는다** — 앱은 토큰을 지운 뒤 서버 logout 과 함께 알림창을 지우고 FCM `unregister()` 로 FID 를 버린다(서로 기다리지 않는다). + 풀지 못하면 다음 실행 · 로그아웃 상태로 앞으로 올 때 다시 푼다. 그사이 같은 휴대폰에서 다른 직원이 로그인하면 풀기 뒤에 다시 등록해 같은 FID 로 PUT 한다(서버가 같은 FID 의 앞 줄을 지운다). + 그 밖의 앞 직원 줄은 서버가 지운다 — 끊은 세션은 보낼 때 바로 빠지고, 끝난 세션의 줄은 5분 정리가 지운다. +- **unseen** 은 앱이 앞으로 올 때와 앞에서 새 메일 알림을 받았을 때 보낸다 — 30초에 한 번(그사이 다시 오면 끝에 한 번 더), 조용히. 앱이 앞에 있을 때만 보내고, 끝을 기다리는 동안 뒤로 가면 그 자리에서 보낸다(N 을 "마지막으로 본 뒤"로 세게). +- 세 경로의 404 `RES_001`(그 경로가 없는 옛 서버)은 조용히 넘기고 등록 없이 지낸다. +- **app-config** 는 앱을 띄우자마자 한 번 묻는다 — 인증 인터셉터 줄 밖이라 세션 되살리기(미리 갱신)를 기다리지 않는다. 앞으로 올 때 마지막으로 받은 지 1시간이 지났으면 다시(실패는 1분 간격으로 다시 — 알리지 않는다). + 404 는 옛 서버(최소 판 없음 · `pushAvailable` 거짓). 이 앱의 판이 `minSupportedVersion.android` 보다 낮으면 어느 화면에서든(로그인 전 포함) 업데이트 화면이다(쓰던 메일은 임시보관함에 저장) — 모양이 틀리거나 받지 못했거나 이 앱의 판을 모르면 막지 않는다. 서버가 최소 판을 내리면 다음 확인에서 풀린다. +- **받는 알림**: 앱이 앞에 있으면 휴대폰이 그리지 않는다 — 앱은 앞에서 받은 `new-mail` 로 새 메일을 바로 확인하고(2절 "새 메일 확인"의 클라이언트 — 앱), 앞으로 올 때 알림창의 `new-mail` 알림(tag)을 지운다. + 누르면 늘 메일 탭 받은편지함이다 — `kind` 로 가르지 않는다(`session-ended` 를 눌렀으면 받은편지함의 첫 요청이 세션 끝을 알아 로그인 화면으로 간다). + 작은 아이콘 · 색은 서버가 싣지 않는다 — 앱 매니페스트의 기본값(`ic_stat_ara` · #0E7C86)이고, 채널 `new_mail`(이름 "새 메일" · 중요도 높음)은 앱이 만든다. --- @@ -1020,6 +1133,8 @@ data: {"reason":"token-expiring"} 스트림이 열린 뒤 계정을 더 쓸 수 없게 되면(엔진이 대리 접근을 거절) `end` 없이 닫는다 — 클라이언트는 끊김으로 보고 다시 이어 401 → 갱신 실패 → 세션 만료로 간다. - **서버**: 직원마다 엔진 연결 하나를 그 직원의 모든 연결에 나눠 쓴다(마지막 연결이 닫히고 30초 뒤 닫는다 — 새로 고침에 다시 열지 않게). + **앱 새 메일 알림을 켠 직원은 연결이 없어도 엔진 연결을 붙잡는다**(앱 1단계 — 1절 "앱 새 메일 알림 · 앱 설정"): 같은 엔진 연결을 함께 쓰고(직원마다 여전히 하나) 연결 상한(5)에 세지 않는다. + 붙잡은 쪽은 새 메일 신호(같은 판정 — 보낸 사람 · 제목 없이)와 끝남만 받는다. 놓으면 남은 연결이 없을 때 30초 뒤 닫는다. 엔진 연결은 대리 접근 자격 · `types=Mailbox,Email` · `ping=30` 이다 — 엔진은 30초보다 짧게 주지 않는다(10초를 달라고 해도 30초, 2026-10-28 확인). 75초 동안 아무것도 오지 않으면 끊긴 것으로 보고 다시 잇는다(1 · 2 · 5 · 10 · 30초 간격에 ±20% — 엔진이 다시 뜰 때 모든 직원이 같은 순간에 붙지 않게). 그동안 클라이언트에는 `ping` 의 `upstream: false` 로 알린다. 같은 기준으로 이어서 3번 묻지 못하면 기준을 버리고 처음 상태로 다시 잡는다(그 차례는 `new-mail` 없이 — 엔진 한도 · 엔진이 내려간 동안은 세지 않는다). @@ -2743,7 +2858,7 @@ interface FileUploadCreate { 만료 뒤에는 대리 접근 메서드가 막히고, 알림이 말하는 일(로그아웃 · 앱 비밀번호 지움)은 넣기 전에 끝나 있어야 하기 때문이다. 첫 세션 끊기가 실패하면 알림을 넣기 전에 멈추고, 엔진 바꾸기가 실패하면 넣은 알림 메일을 지운다. 마지막 끊기는 그사이 옛 비밀번호로 새로 로그인한 세션을 위한 것이다. - 잠금: 받는 메일 넘기기 · 자동 응답(대리 접근으로 Sieve)을 먼저 걸고 → `authenticate` 빼기 → 세션 끊기. 뺀 뒤에는 대리 접근이 막혀 Sieve 를 새로 걸 수 없다(이미 건 Sieve 는 계속 돈다). - - 퇴사 마무리: 엔진 계정을 지운 뒤 우리 DB 에서 그 주소의 행을 한 트랜잭션으로 지운다 — 로그인 기록 · 앱 비밀번호 사용 · 2단계 설정 · 서명 · 설정 · 사내 목록 기록 · 대용량 파일 · 링크, + - 퇴사 마무리: 엔진 계정을 지운 뒤 우리 DB 에서 그 주소의 행을 한 트랜잭션으로 지운다 — 로그인 기록 · 앱 비밀번호 사용 · 2단계 설정 · 서명 · 설정 · 사내 목록 기록 · 대용량 파일 · 링크 · 검색 색인 · 앱 알림 등록(앱 1단계 — 휴대폰 주소), 그리고 2단계 필수의 예외 주소 목록과 공용 주소(MailingList)의 구성원에서도 뺀다(실행 전 보여 주는 바뀔 것에 든다). 세션 행은 바로 지우지 않는다 — 끊은 기록을 서버가 15초 동기화로 읽어야 하므로 끝난 세션 정리 cron 에 맡긴다(로그인된 기기 목록은 살아 있는 세션만 보인다). 디스크 파일(대용량 · 서명 그림)은 그 뒤에 지우고 못 지운 것은 정리 cron 에 맡긴다. 실행 기록(`ops_audit_log`)은 남기고, 읽기만 하는 현황은 기록하지 않는다.