chore: 앱 서버 브랜치(14단계 · 문구 다듬기 · main 을 합친 판)를 앱 첫 판 브랜치에 합친다
CI (Flutter) / Flutter (analyze · test) (pull_request) Successful in 22m26s
CI / Backend (lint · type-check · test · e2e · build) (pull_request) Successful in 13m39s
CI / Frontend (lint · type-check · test · build) (pull_request) Successful in 13m20s
CI / Infra (compose · Dockerfile · nginx · 운영 스크립트) (pull_request) Successful in 2m2s
CI / 에러 코드 SSOT 동기화 검사 (pull_request) Successful in 11m12s
CI (Flutter) / Flutter (analyze · test) (push) Successful in 22m15s
CI / Backend (lint · type-check · test · e2e · build) (push) Successful in 13m53s
CI / Frontend (lint · type-check · test · build) (push) Successful in 13m13s
CI / Infra (compose · Dockerfile · nginx · 운영 스크립트) (push) Successful in 1m56s
CI / 에러 코드 SSOT 동기화 검사 (push) Successful in 11m48s

이 PR 이 앱 서버 PR 뒤에 빨리 감기로 합쳐지고 CI 가 합친 모습 그대로를 시험하게 한다.
계약 1절의 앱 절은 앱 서버 쪽이 들고 있고 이 브랜치는 2절 줄만 더했다 — 충돌 없음.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit was merged in pull request #21.
This commit is contained in:
2026-11-08 12:39:17 +09:00
222 changed files with 11922 additions and 925 deletions
+168 -34
View File
@@ -78,7 +78,7 @@
두 번 이상 지난 토큰이나 30초가 지난 이전 토큰은 위 규칙대로 세션을 끊는다.
- 세션은 로그인 후 **최대 30일**(절대 만료)이다. 그 안에서도 리프레시 토큰을 14일 동안 쓰지 않으면 끝난다.
- 액세스 토큰은 서버 조회 없이 검증한다. 그래서 세션을 끊어도 이미 발급된 액세스 토큰은 만료(최대 1시간)까지 유효하다.
→ 12단계부터는 끊은 세션을 바로 막는다(액세스 토큰의 세션 id + 서버가 기억한 끊은 세션 — 5절 "로그인된 브라우저").
→ 12단계부터는 끊은 세션을 바로 막는다(액세스 토큰의 세션 id + 서버가 기억한 끊은 세션 — 5절 "로그인된 기기").
### `POST /api/auth/logout`
@@ -131,22 +131,24 @@
성공하면 복원 완료, 실패하면 로그아웃 상태로 시작한다.
→ 백엔드는 **액세스 쿠키가 없거나 만료돼도** 리프레시 쿠키만으로 `/auth/refresh` 가 동작해야 한다.
### 앱 로그인 (앱 전용 토큰 경로) — 앱 1단계 (2026-11-05 정함 · **서버 미구현**)
### 앱 로그인 (앱 전용 토큰 경로) — 앱 1단계 (2026-11-05 정함 · 2026-11-06 서버)
> 2026-11-05 사용자가 앱 전용 토큰 경로를 정했다(웹의 쿠키를 앱에 쓰지 않는다). 앱(`flutter/`)은 이 절대로 만들고 가짜 서버로 시험한다 — 서버는 다음 차례에 이 절대로 만든다.
> 2026-11-05 사용자가 앱 전용 토큰 경로를 정했다(웹의 쿠키를 앱에 쓰지 않는다). 앱(`flutter/`)과 서버(`modules/auth` 의 `app-auth.controller.ts`)가 이 절대로 만들었다.
> **세션 규칙은 웹과 같다**: 1회용 회전 · 해시만 저장 · 재사용 감지 · 30초 유예 · 끊은 세션은 `sid` 로 바로 막기 · 절대 30일 · 미사용 14일 · 로그인 실패 카운터 · 2단계 코드 가드 · 앱 비밀번호 모양 거절 · 제한된 세션(6절). 다른 것은 토큰을 나르는 길뿐이다.
| 경로 | 본문 | 응답 |
| --- | --- | --- |
| `POST /api/auth/app/login` | 웹 login 과 같다(`email` · `password` · `otpCode?`) | 웹 login 응답과 같은 평평한 모양(`status` · `email` · `name` · `restriction` · `twoFactorDeadline`) + `tokens`. `signedIn`(웹이 쿠키를 심는 바로 그 경우)에만 `tokens` 가 차고, 그 밖(`twoFactorRequired` 등)은 `tokens: null` |
| `POST /api/auth/app/refresh` | `{ refreshToken }` | 웹 refresh 와 같은 칸(`email` · `name` · `restriction` · `twoFactorDeadline` — 6절: 늘 싣는다) + `tokens`. 30초 유예 안의 다시 쓰기는 웹처럼 새 토큰 없이 200(`tokens: null`). 끝난 세션 · 틀린 토큰은 401 `AUTH_001` |
| `POST /api/auth/app/logout` | `{ refreshToken? }`(보안 저장소를 읽지 못했으면 빈 본문 `{}`) + 머리 `Authorization: Bearer`(있으면) | 웹 logout 과 같다 — 리프레시 토큰으로 세션을 찾고, 없거나 틀리면 액세스 토큰의 `sid` 로 끝낸다. 어느 쪽도 맞지 않아도 성공으로 답한다 |
| `POST /api/auth/app/refresh` | `{ refreshToken }`(글 — 없거나 글이 아니면 400 `VAL_001`) | 웹 refresh 와 같은 칸(`email` · `name` · `restriction` · `twoFactorDeadline` — 6절: 늘 싣는다) + `tokens`. 30초 유예 안의 다시 쓰기는 웹처럼 새 토큰 없이 200(`tokens: null`) — **앱 세션은 그때 서버가 그 세션을 끝낸다**(아래). 끝난 세션 · 틀린 토큰(빈 글 · 2,048자 넘는 글 포함 — 서명을 보기 전에)은 401 `AUTH_001` |
| `POST /api/auth/app/logout` | `{ refreshToken? }`(보안 저장소를 읽지 못했으면 빈 본문 `{}`) + 머리 `Authorization: Bearer`(있으면) | 웹 logout 과 같다 — 리프레시 토큰으로 세션을 찾고, 없거나 틀리면(빈 글 · 2,048자 넘는 글 포함) 액세스 토큰의 `sid` 로 끝낸다(액세스 토큰은 만료됐어도 서명이 맞으면 — 세션을 끝내기만 한다). 어느 쪽도 맞지 않아도 200. 본문 모양이 틀리면(`refreshToken` 이 글이 아님 · 모르는 칸) 400 `VAL_001` |
- `tokens = { accessToken, accessExpiresAt, refreshToken, refreshExpiresAt }`(시각은 ISO 8601 UTC) — 토큰이 든 응답은 `Cache-Control: no-store`.
- 보호 API 는 `Authorization: Bearer <accessToken>`. **앱 토큰에만 표시 `cli: 'app'`** 를 싣고, Bearer 는 그 표시가 있는 토큰만 · 쿠키는 표시가 없는(웹) 토큰만 받는다(서로 바꿔 쓰지 못하게). `GET /api/auth/me` · 6절의 제한 허용 경로도 Bearer 로 그대로 — 제한(`rst`)과 403 `AUTH_010` 도 웹과 같다.
- 실패 카운터 · 요청 수 한도 · 2단계 코드 가드 · 앱 비밀번호 모양 거절은 웹 login 과 같은 함수를 지난다(앱 경로가 한도를 비켜 가지 않게). 출처 검사(OriginGuard)는 앱(`Origin` · `Sec-Fetch-Site` 없음)을 지금 규칙 그대로 통과시킨다.
- `tokens = { accessToken, accessExpiresAt, refreshToken, refreshExpiresAt }`(시각은 ISO 8601 UTC) — 로그인 · 갱신 응답은 `Cache-Control: private, no-store`(처리기가 낸 오류 응답도 — 가드 · 본문 파서가 먼저 막은 응답(403 출처 · 429 한도 · 400 · 413 · 415)에는 토큰이 없어 이 머리가 없다).
- 보호 API 는 `Authorization: Bearer <accessToken>`. **앱 토큰에만 표시 `cli: 'app'`** 를 싣고, Bearer 는 그 표시와 `sid` 가 있는 토큰만 · 쿠키는 표시가 없는(웹) 토큰만 받는다(서로 바꿔 쓰지 못하게 — 리프레시 토큰도, 세션 표의 `client` 도 맞아야 한다. 다르면 401 이고 재사용으로 보지 않는다). `Authorization: Bearer` 가 있으면 그것만 보고 쿠키는 보지 않는다. `GET /api/auth/me` · 6절의 제한 허용 경로도 Bearer 로 그대로 — 제한(`rst`)과 403 `AUTH_010` 도 웹과 같다.
- 실패 카운터 · 요청 수 한도 · 2단계 코드 가드 · 앱 비밀번호 모양 거절은 웹 login 과 같은 함수를 지난다(앱 경로가 한도를 비켜 가지 않게 — 로그인 요청 수 한도는 웹 · 앱이 IP 마다 1분 60회 한 칸을 함께 쓴다). 출처 검사(OriginGuard)는 앱(`Origin` · `Sec-Fetch-Site` 없음)을 지금 규칙 그대로 통과시킨다.
- **"지금 세션"**: 앱 요청은 리프레시 쿠키가 없으므로 액세스 토큰의 `sid` 로 안다 — `PUT /api/auth/password`(이 세션만 남기기) · `/api/auth/sessions`(isCurrent · 다른 곳 모두 끊기) · 앱 logout.
- 세션 표에 `client`(`web` | `app`) 칸을 둔다. 앱은 User-Agent `ARASYSTEM-App/<앱 판> (Android <판>; <기종>)` 을 보내고, 서버는 로그인된 기기 · 로그인 기록에 "ARA SYSTEM 앱 · Android" 꼴로 보이고 로그인 기록 `method` 는 `app` 이다.
- 세션 표에 `client`(`web` | `app`) 칸을 둔다(마이그레이션 `AddAppSessionClient` — 기본 `web`). 앱은 User-Agent `ARASYSTEM-App/<앱 판> (Android <판>; <기종>)` 을 보내고, 서버는 로그인된 기기 · 로그인 기록에 "ARA SYSTEM 앱 · Android" 꼴로 보이고 로그인 기록 `method` 는 `app` 이다.
**앱 이름은 진짜 클라이언트(세션의 `client` · 로그인 기록의 `method`)로만 붙인다** — 웹 세션이 앱 꼴 User-Agent 를 보내도 앱 이름을 받지 않는다(비밀번호를 훔친 사람이 직원 본인의 앱처럼 보이지 않게). 세션 목록 항목에 `client` 칸을 싣는다(5절).
- **앱 세션의 갱신 유예는 그 세션을 끝낸다**(2026-11-06 리뷰): 앱은 갱신을 한 번에 하나만 하므로 앱 세션의 유예는 "회전 응답을 잃음" 아니면 "훔친 토큰을 누가 먼저 씀"이다. 서버는 그 세션을 끊고(사유 `app-refresh-grace`) 200 `tokens: null` 로 답한다 — 앱이 다음 실행 직후라 조용한 logout 을 보내지 못해도 남는 세션이 없다. 웹의 유예는 그대로다(탭 여러 개).
**앱이 할 일**
- 리프레시 토큰은 `flutter_secure_storage`(키 `kRefreshTokenKey`)에만, 액세스 토큰은 메모리에만 둔다. 새 리프레시 토큰은 받자마자 저장한다 — 회전 직후 앱이 죽으면 다음 실행의 옛 토큰이 30초 뒤 재사용으로 세션을 끊는다(알려진 한계).
@@ -154,12 +156,138 @@
- 보호 API 의 401 이면 갱신을 **한 번에 하나**(동시 401 을 한 갱신에 묶는다) → 원래 요청 한 번 다시. 앱 login · refresh · logout 은 갱신하지 않는다. 갱신의 401 이면 토큰을 지우고 `AUTH_002`(클라이언트 전용) + 로그인 화면, 5xx · 연결 끊김은 세션을 그대로 둔다.
`accessExpiresAt` 30초 전에는 미리 갱신한다.
- **유예 응답(`tokens: null`)은 세션 끝으로 다룬다** — 앱은 갱신을 한 번에 하나만 하므로 그 응답은 "앞 갱신의 응답을 잃었다"는 뜻이고 이어 쓸 리프레시 토큰이 없다(서버는 해시만 두어 같은 토큰을 다시 줄 수 없다).
메모리의 액세스 토큰이 아직 있으면 그것을 Bearer 로 실어 조용히 앱 logout 을 부른다 — 서버의 그 세션이 "로그인된 기기"에 14일 남지 않게(`sid` 로 끝낸다).
메모리의 액세스 토큰이 아직 있으면 그것을 Bearer 로 실어 조용히 앱 logout 을 부른다(서버도 그 세션을 이미 끝냈다 — 남는 것이 없게 두 겹).
나중에 푸시를 따로 받는 백그라운드 작업이 생겨도 갱신은 앱과 같은 자물쇠를 함께 쓴다 — 유예가 세션 끝이므로 둘이 따로 갱신하면 서로의 세션을 끊는다.
- **알려진 한계**: 회전 응답을 잃고 30초가 지난 뒤 같은 토큰을 다시 보내면 재사용으로 세션이 끊긴다(서버 로그에 탈취 의심 warn) — 모바일 망에서는 웹보다 잦을 수 있다.
- `AUTH_010` 은 알림 없이 me 를 다시 받아 그 단계로 — me 에 제한이 없으면 웹처럼 갱신한 뒤 원래 요청을 한 번 다시 보낸다.
- 로그아웃 · 다른 계정으로 로그인 · 세션 끝 뒤에 도착한 응답(로그인 · 코드 · 갱신 · 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 이면 보내지 않는다.**
같은 초를 넣으므로 그 메일과 같은 초에 `/unseen` 을 받으면 그 메일은 다음 알림에 다시 든다 — 앱은 앞에서 받은 알림 뒤의 `/unseen` 을 1.5초 늦춘다(아래 "클라이언트 — 앱").
보내는 때는 웹 `new-mail` 이 있을 때뿐이다(안 읽음 표시 바꾸기 · 옛 메일 옮기기로는 보내지 않는다).
- **간격**: 같은 직원은 30초에 한 번까지 — 첫 알림은 바로, 그사이 온 것은 30초 끝에 그때의 N 으로 한 번. 같은 직원의 보내기는 차례로 한다.
- **따라잡기**: 붙잡을 때마다(서버를 다시 띄움 · 다시 붙잡음) 그 전 10분 안 · 마지막으로 보낸 뒤 · 본 뒤에 온 안 읽은 메일이 있으면 그 기기에 그때의 N 으로 한 번 — 이미 알린 · 이미 본 메일로는 다시 울리지 않는다.
"보낸 뒤 · 본 뒤"는 `last_pushed_at`(그 알림의 N 을 센 때) · `seen_at` 의 **다음 초부터**다 — 받은 시각은 초라 그 초에 받은 메일은 그 알림에 이미 들었거나 이미 보았다고 본다(2026-11-07 리뷰). N 은 위처럼 `seen_at` 의 초부터 센다.
그 초에 센 · 본 뒤 들어온 메일은 따로 새 메일 신호를 낸다 — 그 알림이 나가기 전(그 1초 안 · 30초 간격의 끝을 기다리는 사이)에 서버가 멈추면 놓친다. 따라잡기로 보내도 30초 간격에 든다.
**붙잡은 직후의 메일 하나에는 한 번만 울린다**(2026-11-08 리뷰): 붙잡은 뒤 새 메일 신호가 먼저 왔으면 따라잡기는 건너뛴다(신호 쪽이 그때의 N 으로 보낸다).
따라잡기로 보낸 뒤 첫 신호 보내기는 따라잡기와 같은 규칙(`seen_at` · `last_pushed_at` 의 다음 초부터)으로 새로 받은 메일이 있는 기기에만 — 따라잡기가 센 메일의 신호가 뒤늦게 와도 간격 끝에 다시 울리지 않는다.
그래서 따라잡기가 센 그 초에 센 뒤 들어온 메일은 그 보내기에서 빠진다(다음 알림의 N 에는 든다).
**엔진의 `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": "<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 프로젝트 <id>)". 서비스 계정은 알림 전용 하나 + 역할 "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` 의 런처 배지(삼성).
**2026-11-08 갤럭시 S23(Android 16 · Google Play 서비스 26.42)에서 확인한 것**: FCM 이 `message.fid` 와 메시지 칸을 받았다(오류 없음 — 배달된 그 초에 보내 1초 안에 표시), FID 22자,
같은 tag 로 바꿔 씀(알림 하나가 "새 메일 2통" · 개수 2로), 중요도 높음 · 잠금 화면 표시 · 강조색 · 작은 아이콘, 앱이 앞에 있으면 그리지 않음,
앱을 닫아 프로세스가 없어도 휴대폰이 앱을 깨워 표시, 누르면 받은편지함(닫힌 앱도), `notification_count` 의 런처 배지(삼성 홈 화면), 바꿔 쓴 알림도 다시 소리를 낸다. 아직 보지 않은 것: 오류 본문의 모양 · 절전(Doze) 중 도착 · 다른 곳에서 비밀번호를 바꿨을 때의 세션 끝 알림.
- **알려진 한계**: Google 은 기기마다 언제 몇 통(N)이 왔는지는 안다(내용 · 주소는 가지 않는다). 푸시는 최선을 다할 뿐이다(FCM 장애 · 강제 종료한 앱 · 배터리 제한). 웹에서 다 읽어도 이미 뜬 알림은 다음 알림 · 앱을 열 때까지 그대로다.
나에게 보낸 메일도 N 에 든다(그 메일이 알림의 방아쇠가 되지는 않는다). 서버가 10분 넘게 멈췄으면 그동안 온 메일은 다음 알림의 N 으로만 알린다.
**인스턴스를 늘리면 알림이 겹친다**(붙잡기 · 간격이 프로세스 메모리 — nestjs 스킬의 Redis 목록).
Google Play 서비스가 26.12(versionCode 261200000)보다 옛 휴대폰은 SDK 가 FID 대신 옛 토큰 길로 돌아가 등록 소식의 값이 FID 모양이 아닐 수 있다 — `:` 가 섞이고 64자를 넘으면
`PUSH_FID_PATTERN` 에 걸려 PUT 이 400 `VAL_001` 이다(스위치로 켜면 그 사전 문구, 저절로 보낸 PUT 은 앞으로 올 때마다 다시). 후속 거리: 앱이 FID 모양이 아닌 값을 받으면 "이 휴대폰에서는 받을 수 없음"으로 둔다.
**클라이언트 — 앱**(Flutter — 앱 디자인 README "새 메일 알림 · 업데이트 · 앱 아이콘", 2026-11-07)
- 등록은 FCM 의 FID 로 한다(`register()` · `onRegistered` — 자동 초기화는 끈다). 안드로이드는 매니페스트에 `firebase_messaging_installation_id_enabled` = `true` 가 있어야 한다 — 없으면 SDK(firebase-messaging 26.0.1)가 `register()` · `unregister()` 를 곧바로 거절한다(2026-11-08 실기기). 보낼 조건 = 받을 수 있는 휴대폰 ∧ `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초에 한 번(그사이 다시 오면 끝에 한 번 더), 조용히. 앱이 앞에 있을 때만 보내고, 기다리는 동안(30초 간격 끝 · 아래 1.5초) 뒤로 가면 그 자리에서 보낸다(N 을 "마지막으로 본 뒤"로 세게).
앞에서 받은 알림 뒤의 unseen 은 **받은 때부터 1.5초가 지나기 전에는 보내지 않는다** — 서버가 `seen_at` 의 초부터 같은 초를 세므로, 그 알림을 일으킨 메일과 같은 초에 보내면 목록으로 보고 열지 않은 그 메일이 다음 알림에 다시 세어진다(2026-11-08 실기기 — 한 통이 "2통"이 됨).
그 1.5초 안에 뒤로 가면 그 자리에서 보내므로, 그 좁은 경우에는 같은 초가 되어 그 메일이 다음 알림에 다시 들 수 있다(받아들였다).
- 세 경로의 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`(이름 "새 메일" · 중요도 높음)은 앱이 만든다.
---
@@ -809,6 +937,7 @@ interface MailState {
받기가 끝난 때부터 10초 안에 온 소식은 모아 한 번 받고, 받는 중에 온 소식은 끝난 뒤 한 번 더 받는다. [더 보기]로 늘린 대화는 저절로 다시 받지 않는다(붙잡힌 메일만 `GET /api/messages/{id}` 로 고친다).
알려진 한계: 같은 메일함에서 한 통이 들어오며 한 통이 빠지면 통 수가 같아 그 소식으로는 다시 받지 않는다.
- 펼친 줄은 검색 쿼리 없이 `GET /api/messages/{id}` 로 받고, 받은 뒤에도 펼쳐 있으며 안 읽은 메일이면 `PATCH` 로 읽음으로 바꾼다(읽기 화면과 같은 길). 그 메일이 그사이 지워졌으면(404) 대화를 다시 받아 그 줄을 뺀다.
- 줄의 읽음(2026-11-07): 안 읽은 줄을 목록처럼 굵게 그린다. 이 탭이 바꾼 읽음은 줄에 바로 적고, 받는 동안 바꾼 것 · 받기를 시작할 때 보내던 것은 받은 값보다 앞세운다(받은 값은 그 바꿈이 엔진에 닿기 전일 수 있다). 다른 탭 · 기기의 읽음은 다음 받기 때 보인다(읽음만 바뀐 소식으로는 다시 받지 않는다).
- 줄 차례: 받은 메일은 응답 차례 그대로, 붙잡힌 메일(`pendingSend` — 보내기 취소 · 예약)은 받은 줄 뒤에 `sendAt` 차례로 둔다. 줄의 미리 보기는 우리 원문 인용 머리 "> 원본 메일"부터 잘라 내고(남는 글이 없으면 제목), 펼친 줄은 첨부 목록 대신 첨부 수만 안내한다(2026-11-07).
#### `GET /api/conversations` — 대화로 묶어 보기
@@ -1040,6 +1169,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` 없이 — 엔진 한도 · 엔진이 내려간 동안은 세지 않는다).
@@ -1054,7 +1185,7 @@ data: {"reason":"token-expiring"}
그래서 서버와 엔진 사이가 10분 넘게 끊겼다 다시 이으면, 그동안 온 메일은 `state` 로만 알린다(목록에는 나타나고 바탕화면 알림은 없다).
"보낸 사람이 나"는 토큰의 주소와 발신 ID(별칭) 주소로 본다(대소문자 무시).
- 오래 열린 엔진 연결은 엔진의 동시 요청 한도(4)를 잡아먹지 않는다 — 한 직원의 스트림 8개를 연 채 그 직원 · 다른 직원의 보통 요청과 동시 요청 6개가 모두 됐다(2026-10-28 확인).
- 연결은 액세스 토큰이 끝날 때 닫는다(최대 1시간 — 그 전에 `end`). 12단계부터 로그아웃 · 비밀번호 변경 · 세션 끊기로 끊은 세션의 연결은 다음 ping 때 `end` 없이 닫는다(계정을 더 쓸 수 없을 때와 같은 길 — 5절 "로그인된 브라우저").
- 연결은 액세스 토큰이 끝날 때 닫는다(최대 1시간 — 그 전에 `end`). 12단계부터 로그아웃 · 비밀번호 변경 · 세션 끊기로 끊은 세션의 연결은 다음 ping 때 `end` 없이 닫는다(계정을 더 쓸 수 없을 때와 같은 길 — 5절 "로그인된 기기").
서버가 종료될 때는 열린 연결을 `end` 로 먼저 닫는다 — 오래 열린 연결이 종료를 붙잡지 않게.
알려진 한계: 잠자기 · VPN 재연결 · 망 바꿈으로 반쯤 열린 연결은 서버가 ping 을 쓰다 실패할 때(OS 기본 15분 안팎)까지 자리를 잡는다 —
그사이 그 직원의 연결이 5개에 닿으면 새 연결은 429 이고 화면은 1분 확인으로 이어진다.
@@ -1164,7 +1295,7 @@ interface MailFilterActions {
- `MAIL_012` — 목록 위 줄 "다른 곳에서 필터가 바뀌어 저장하지 않았습니다. 다시 불러온 뒤 고쳐 주세요." + [다시 불러오기]. 고치던 창은 닫지 않는다 —
창 안에도 같은 안내와 [다시 불러오기]를 둔다(모달이라 뒤의 줄을 누를 수 없다). 창에서 다시 불러오면 쓴 내용은 그대로이고, 다음 [저장]은 다시 받은 목록에
그 필터를 넣는다(같은 id 자리 — 그사이 다른 곳에서 지워졌으면 맨 아래).
- `isActive: false` — 목록 위 줄 "다른 곳에서 메일 설정이 바뀌어 지금은 필터와 부재중 자동 응답이 되지 않습니다." + [다시 켜기](지금 목록 그대로 저장).
- `isActive: false` — 목록 위 줄 "다른 곳에서 메일 설정이 바뀌어 지금은 필터와 부재중 자동 응답이 꺼져 있습니다." + [다시 켜기](지금 목록 그대로 저장).
- 읽기 화면 ⋯ 「이런 메일로 필터 만들기」 → `/settings/mail/filters?from=<보낸 사람 주소>` — 새 필터 창이 "보낸 사람 · 포함 · 그 주소"로 채워져 열린다(초점은 할 일의 첫 칸).
50개를 다 만들었는지 알려고 ⋯ 메뉴를 열 때 `GET` 을 한다(받아 둔 목록이 없거나 50개일 때만 — 알림 없이).
- 검사는 서버와 같은 규칙(이름 50 · 값 200 · 조건 5 · 규칙 50 · `${` · 제어 문자 · 할 일 하나 이상 · 전달은 사내 주소이고 자기 주소 아님) — 칸 아래에만 알린다.
@@ -1179,7 +1310,7 @@ interface MailFilterActions {
### 이미 받은 메일에 적용 (`/api/mail-filter-runs`) — 7단계
필터를 저장할 때 고르면(고치기 창 「받은편지함에 이미 있는 메일에도 적용」) 받은편지함에 이미 있는 메일에도 그 규칙 하나를 적용한다.
필터를 저장할 때 고르면(고치기 창 「받은편지함에 이미 있는 메일도 정리하기」) 받은편지함에 이미 있는 메일에도 그 규칙 하나를 적용한다.
엔진의 Sieve 는 메일을 받을 때만 돌기 때문에 서버가 받은편지함을 읽어 같은 규칙으로 판정하고 표시 · 옮기기를 한다.
```ts
@@ -1236,12 +1367,12 @@ interface MailFilterRun {
- 엔진 한도: `Email/query` 는 한 번에 5,000통까지(`queryMaxResults`)라 차례마다 나눠 읽는다. 측정(개발 엔진, 2026-10-29, 시험 메일 5,000통): 보낸 사람 조건으로 4,500통을 확인하고
450통을 옮기는 데 1.3초, 본문 2,000통 읽기 1초 안팎 — 받은편지함 수만 통도 머리글 조건이면 한 요청, 본문 조건이면 몇 요청이다.
- 로그 — 규칙의 글은 남기지 않는다(필터와 같다). 확인 · 적용 수 · 차례 수 · 걸린 시간만.
- **클라이언트**: 고치기 창의 「받은편지함에 이미 있는 메일에도 적용」 — 창을 열 때마다 꺼져 있다(필터의 설정이 아니다). `PUT /api/mail-filters` 가 성공한 뒤 저장한 그 규칙
- **클라이언트**: 고치기 창의 「받은편지함에 이미 있는 메일도 정리하기」 — 창을 열 때마다 꺼져 있다(필터의 설정이 아니다). `PUT /api/mail-filters` 가 성공한 뒤 저장한 그 규칙
(응답 목록의 같은 id — 앞뒤 공백을 지운 값)으로 이 요청을 `cursor` 가 null 이 될 때까지 보낸다.
- 칸을 끈다(칸 아래 설명이 까닭을 말한다): 이 탭에서 다른 적용이 진행 중, 그리고 **이미 받은 메일에 할 일이 없는 필터** — 읽음 · 중요 표시가 없고
옮기기도 없거나 받은편지함으로 · 지워진 메일함으로 옮기는 것뿐(까닭 문구는 지워진 메일함 → 받은편지함 → 전달만 차례로 고른다). 서버는 그런 규칙도 받는다(맞은 수만 센다).
- 진행 알림 "‘{이름}’ 필터로 받은편지함을 정리하는 중 · {확인} / {전체}통 확인"(확인 = scanned 합, 전체 = 확인 + remaining) → 끝나면 그 자리에서
"‘{이름}’ 필터로 받은편지함의 메일 N통을 정리했습니다."(N = matched 합) + 옮기는 필터이고 1통 이상이면 [{메일함} 보기], 0통이면 "받은편지함에 ‘{이름}’ 필터에 맞는 메일이 없습니다."
"‘{이름}’ 필터로 받은편지함의 메일 N통을 정리했습니다."(N = matched 합) + 옮기는 필터이고 1통 이상이면 [{메일함} 보기], 0통이면 "받은편지함에는 ‘{이름}’ 필터에 걸리는 메일이 없습니다."
- 멈춤: "‘{이름}’ 필터로 {전체}통 가운데 {확인}통까지 확인하고 멈췄습니다. 그동안 N통을 정리했습니다. 다시 시도해 주세요." + [다시 시도](마지막 cursor 로 이어서).
처음 요청부터 실패하면 "필터는 저장했지만 받은편지함은 정리하지 못했습니다. 다시 시도해 주세요." + [다시 시도](처음부터).
429 는 "다른 창에서 필터로 받은편지함을 정리하는 중입니다. 끝난 뒤 다시 시도해 주세요." — 도중의 429 면 [다시 시도](이어서)도 붙인다.
@@ -2209,13 +2340,13 @@ interface FileUploadCreate {
| 코드 | HTTP | 뜻 | 쓰는 곳 | 문구 (제안) |
| --- | --- | --- | --- | --- |
| `FILE_003` | 409 | 올리기 · 파일의 지금 상태와 맞지 않는 요청 — 끝낸 올리기에 조각, 같은 조각을 동시에, 받은 조각과 내용이 다른 조각, 빠진 조각이 있는데 끝내기, 끝내기가 진행 중, 살아 있는 링크에 실린 파일 지우기. 아무것도 바꾸지 않았다 | 조각 · 끝내기 · 지우기 | "파일 상태가 그사이 바뀌어 처리하지 못했습니다. 다시 시도해 주세요." |
| `FILE_004` | 400 | 조각의 `Content-Digest` 가 받은 바이트와 다르다 — 그 조각을 받지 않았다(같은 조각을 다시) | 조각 | "파일을 올리는 중에 일부가 제대로 전달되지 않았습니다. 다시 시도해 주세요." |
| `FILE_003` | 409 | 올리기 · 파일의 지금 상태와 맞지 않는 요청 — 끝낸 올리기에 조각, 같은 조각을 동시에, 받은 조각과 내용이 다른 조각, 빠진 조각이 있는데 끝내기, 끝내기가 진행 중, 살아 있는 링크에 실린 파일 지우기. 아무것도 바꾸지 않았다 | 조각 · 끝내기 · 지우기 | "그사이 파일이 바뀌어 하던 일을 마치지 못했습니다. 다시 시도해 주세요." |
| `FILE_004` | 400 | 조각의 `Content-Digest` 가 받은 바이트와 다르다 — 그 조각을 받지 않았다(같은 조각을 다시) | 조각 | "파일 일부가 제대로 올라가지 않았습니다. 다시 시도해 주세요." |
| `FILE_005` | 409 | 직원 보관 한도(보관량 · 보내지 않은 파일 수)를 넘는다 — 만들지 않았다 | 만들기 | "대용량 첨부 보관 한도를 넘어 더 올릴 수 없습니다. 보낸 파일의 링크를 끊거나 보내지 않은 파일을 지우면 공간이 생깁니다." |
| `FILE_006` | 507 | 서버 저장 공간이 모자란다 — 서버 총량 · 디스크 여유 · 쓰다 `ENOSPC` | 만들기 · 조각 | "서버의 저장 공간이 모자라 지금은 파일을 올릴 수 없습니다. 잠시 뒤 다시 시도해 주세요." |
| `FILE_006` | 507 | 서버 저장 공간이 모자란다 — 서버 총량 · 디스크 여유 · 쓰다 `ENOSPC` | 만들기 · 조각 | "회사 저장 공간이 모자라 지금은 파일을 올릴 수 없습니다. 잠시 뒤 다시 시도해 주세요." |
| `FILE_007` | 410 | 더 내려받을 수 없는 파일 — 기간 지남 · 공유 멈춤 · 횟수 초과 · 위험 · 찾을 수 없음 | 공개 받기 · 우리 웹메일 받기 | "이 파일은 더 내려받을 수 없습니다. 필요하면 보낸 사람에게 문의해 주세요." |
| `FILE_008` | 409 | 아직 보안 검사 중인 파일 — 끝나면 받는다 | 공개 받기 · 우리 웹메일 받기 | "보안 검사가 끝나지 않은 파일입니다. 잠시 뒤 다시 시도해 주세요." |
| `MAIL_016` | 400 | 보내거나 저장할 대용량 파일을 쓸 수 없다 — 없음 · 다른 직원 것 · 지움 · 기간 지남, (보낼 때) 올리는 중 · 위험. 메일은 나가지 않았다 | 발송 · 임시 저장 | "대용량 첨부 파일을 보낼 수 없습니다. 해당 파일을 빼거나 다시 올린 뒤 보내 주세요." |
| `MAIL_016` | 400 | 보내거나 저장할 대용량 파일을 쓸 수 없다 — 없음 · 다른 직원 것 · 지움 · 기간 지남, (보낼 때) 올리는 중 · 위험. 메일은 나가지 않았다 | 발송 · 임시 저장 | "보낼 수 없는 대용량 첨부 파일이 있습니다. 그 파일을 빼거나 다시 올린 뒤 보내 주세요." |
- 문구에 숫자를 넣지 않는다 — 한도가 env 로 바뀐다(`MAIL_006` · `CONTACT_002` 처럼 상수와 견주는 테스트를 둘 수 없다).
- 그 밖은 지금 코드를 쓴다 — 형식 400 `FILE_001`, 한 파일 · 조각 본문 413 `FILE_002`, 개수 · 모양 400 `VAL_001`, 없음 404 `RES_001`, 요청 수 · 동시 수 429 `REQ_001`, 엔진 503 `MAIL_001`.
@@ -2316,7 +2447,7 @@ interface FileUploadCreate {
| 등록 시간 | 등록을 시작하고 10분 안에 켠다. 직원마다 기다리는 등록은 하나다(새로 시작하면 앞의 것은 버린다) |
| 앱 비밀번호 | 엔진 상한(기본 5개 — `x:Authentication.maxAppPasswords`). 이름 1~40자(앞뒤 공백을 지운 값, JS 문자열 길이). 권한은 메일 프로그램만 |
| 로그인 기록 | 90일 보존(날마다 정리) |
| 로그인된 브라우저 | 끝나지 않은 웹메일 세션과 앱 세션(같은 세션 표 — 1절 "앱 로그인"의 `client`) — 1절 세션 규칙 그대로(절대 30일 · 미사용 14일). 앱 서버를 만들 때 화면 이름도 "로그인된 기기"로 함께 정한다 |
| 로그인된 기기 | 끝나지 않은 웹메일 세션과 앱 세션(같은 세션 표 — 1절 "앱 로그인"의 `client`) — 1절 세션 규칙 그대로(절대 30일 · 미사용 14일). 화면 이름은 앱 서버와 함께 "로그인된 브라우저"에서 바꿨다(2026-11-06) |
### 로그인 (`POST /api/auth/login`) — 1절에 더하는 것
@@ -2455,14 +2586,15 @@ interface FileUploadCreate {
엔진은 지운 번호를 다시 쓴다 — 서버 기록은 같은 번호 · 같은 `createdAt` 일 때만 잇는다(만들 때 서버가 기록을 새로 둔다).
- 지운 앱 비밀번호로 이미 열린 IMAP 연결은 엔진이 끊지 않는다(알려진 한계 — 다음 연결부터 막힌다).
### 로그인된 브라우저 (`/api/auth/sessions`)
### 로그인된 기기 (`/api/auth/sessions`)
경로가 `/api/auth` 아래인 까닭: 리프레시 쿠키(Path=`/api/auth`)로 이 요청의 세션을 안다(비밀번호 바꾸기와 같다).
경로가 `/api/auth` 아래인 까닭: 리프레시 쿠키(Path=`/api/auth`)로 이 요청의 세션을 안다(비밀번호 바꾸기와 같다 — 앱은 액세스 토큰의 `sid`).
12단계에 "로그인된 브라우저"로 만들었고, 앱 세션이 함께 들어가 2026-11-06 화면 이름을 바꿨다.
```jsonc
// GET /api/auth/sessions → 200 (지금 세션이 맨 앞, 그다음 최근 활동 차례)
{ "items": [ { "id": "…", "createdAt": "…", "lastActiveAt": "…", "ip": "211.186.95.252",
"device": { "browser": "Chrome", "os": "Windows" }, "isCurrent": true } ],
"device": { "browser": "Chrome", "os": "Windows" }, "client": "web", "isCurrent": true } ],
"pageInfo": { "hasNext": false, "nextCursor": null, "total": 3 } }
// DELETE /api/auth/sessions/{id} → 200 (data: null)
@@ -2474,7 +2606,9 @@ interface FileUploadCreate {
- `ip` · `device` 는 로그인 · 갱신 때 서버가 둔다.
- IP 는 `TRUST_PROXY` 기준 클라이언트 IP 다.
- 브라우저는 서버가 User-Agent 를 짧은 이름(`Edge` · `Chrome` · `Firefox` · `Safari` · `Whale` · `Samsung Internet` / `Windows` · `macOS` · `iOS` · `Android` · `Linux` · `ChromeOS`)으로 바꾼다 — 서버 순수 함수 하나, 모르면 `null`.
앱 세션(User-Agent `ARASYSTEM-App/<판> (Android <판>; <기종>)` — 1절 "앱 로그인")은 `ARA SYSTEM 앱` · `Android` 다.
앱 세션(`client: "app"` — 1절 "앱 로그인")은 브라우저가 `ARA SYSTEM 앱`, 운영체제는 User-Agent `ARASYSTEM-App/<판> (Android <판>; <기종>)` 의 괄호 첫 칸(`Android` · `iOS` — 모르거나 User-Agent 가 없으면 `null`)이다.
**앱 이름은 `client` 로만 붙인다** — 웹 세션이 앱 꼴 User-Agent 를 보내면 브라우저는 `null` 이다(꾸민 User-Agent 로 직원 본인의 앱처럼 보이지 않게).
- `client`: `"web"` | `"app"`(2026-11-06 더함 — 화면은 이것으로 앱 세션을 세고 휴대폰 아이콘을 보인다).
- 12단계 전에 연 세션은 둘 다 `null` 이다.
- **끊은 세션은 바로 막는다.** 액세스 토큰에 세션 id(`sid`)를 싣는다. 서버는 끊은 세션 id 를 1시간(액세스 토큰의 가장 긴 수명) 기억하고, 가드가 그 토큰을 401 `AUTH_001` 로 막는다 — DB 를 묻지 않는다.
- 끊긴 브라우저는 다음 요청에서 갱신이 실패해(세션이 끊김) 로그인 화면으로 간다. 열린 실시간 알림 연결은 다음 ping(25초) 때 끝낸다.
@@ -2500,7 +2634,7 @@ interface FileUploadCreate {
| 칸 | 값 |
| --- | --- |
| `method` | `webmail` · `app`(앱 로그인 — 1절 "앱 로그인", 서버를 만들 때 더한다) · `imap`(메일 프로그램 받기) · `smtp`(메일 프로그램 보내기). 웹은 모르는 값을 그대로 보이므로 `app` 을 더할 때 화면의 이름 글도 함께 더한다 |
| `method` | `webmail` · `app`(앱 로그인 — 1절 "앱 로그인", 2026-11-06 — 기기는 앱 세션과 같은 규칙) · `imap`(메일 프로그램 받기) · `smtp`(메일 프로그램 보내기) |
| `isTwoFactor` | 웹메일 · 앱 성공이 코드까지 거쳤는지 |
| `failureReason` | `wrongPassword`(비밀번호 틀림 — 웹메일에 넣은 앱 비밀번호 포함) · `wrongCode`(코드 틀림 · 다시 쓴 코드) · `appPasswordRequired`(2단계를 켠 계정에 메일 프로그램이 비밀번호로 — 엔진 `auth.mfa-required`) |
| `appPassword` | 메일 프로그램이 앱 비밀번호로 들어온 경우. `name` 은 그때의 이름이다(지운 뒤에도 남는다) |
@@ -2566,7 +2700,7 @@ interface FileUploadCreate {
| 코드 | HTTP | 상황 | 문구(제안) |
| --- | --- | --- | --- |
| `AUTH_006` | 400 | 2단계 인증 코드가 틀림 · 2분 안에 다시 씀(로그인 · 비밀번호 바꾸기 · 켜기 · 끄기) | "코드가 올바르지 않습니다. 휴대폰의 날짜와 시간이 자동으로 맞춰지도록 설정되어 있는지 확인해 주세요." |
| `AUTH_006` | 400 | 2단계 인증 코드가 틀림 · 2분 안에 다시 씀(로그인 · 비밀번호 바꾸기 · 켜기 · 끄기) | "코드가 맞지 않습니다. 휴대폰 설정에서 날짜와 시간을 자동으로 맞추게 해 두었는지 확인해 주세요." |
| `AUTH_007` | 400 | 2단계 인증을 켠 직원의 요청에 코드가 없음(다른 곳에서 켠 뒤의 화면) | "인증 앱의 코드를 입력해 주세요." |
| `AUTH_008` | 409 | 등록이 없음 · 시간이 지남 · 그사이 다른 곳에서 바뀜 | "2단계 인증 설정이 그사이 바뀌었거나 시간이 지났습니다. 처음부터 다시 해 주세요." |
| `AUTH_009` | 409 | 앱 비밀번호 상한 | "5개까지 만들 수 있습니다. 쓰지 않는 앱 비밀번호를 지운 뒤 만들어 주세요." — 엔진 상한을 바꾸면 이 문구와 화면 상수도 |
@@ -2589,7 +2723,7 @@ interface FileUploadCreate {
- 2단계를 켜거나 앱 비밀번호를 지워도 이미 열린 IMAP 연결은 엔진이 끊지 않는다(지운 앱 비밀번호의 연결은 다음 명령에서 멈춘다).
- 웹훅이 잃는 기록이 있다(엔진 재시작 · 서버가 5분 넘게 받지 못함 · 한도를 넘는 묶음). 같은 초 · 같은 연결의 똑같은 이벤트는 하나로 합쳐진다.
- 성공에 쓴 코드는 2분 동안 다시 받지 않으므로, 30초 안에 두 곳에서 연달아 로그인하면 두 번째는 `AUTH_006` 이고 다음 코드를 기다려야 한다.
- 로그인 기록의 `appPassword.name` 은 웹메일 밖(엔진 관리 화면)에서 만든 앱 비밀번호면 `null` 이다. `device` 는 웹메일 기록에만 있고, User-Agent 가 없거나 알아보지 못하면 `null` 이다.
- 로그인 기록의 `appPassword.name` 은 웹메일 밖(엔진 관리 화면)에서 만든 앱 비밀번호면 `null` 이다. `device` 는 웹메일 · 앱 기록에만 있고(앱은 로그인된 기기와 같은 규칙), User-Agent 가 없거나 알아보지 못하면 `null` 이다.
- 끊은 세션 `DELETE /api/auth/sessions/{id}` 의 모양이 틀린 id 는 404 `RES_001`(없는 것과 같다).
- 엔진 관리 화면에서 켠 2단계 인증은 `enabledAt` 이 없다.
- 복구 코드가 없다 — 휴대폰을 잃으면 운영자에게 문의한다.
@@ -2605,7 +2739,7 @@ interface FileUploadCreate {
2. 코드 실패 한도는 같은 주소 15분 5회 · 24시간 20회다(위 "한도 · 규칙"). 비밀번호를 아는 공격자가 1년 내내 시도해도 맞힐 확률이 약 2% 다.
3. 2단계를 켜거나 꺼도 다른 세션을 끊지 않는다 — 로그인 기록 화면에서 따로 끊는다.
4. 비밀번호를 바꾸면 앱 비밀번호도 모두 지운다(위 "비밀번호 바꾸기").
5. 다른 곳 로그아웃은 바로 적용한다 — 끊은 세션 id 를 가드가 막는다(위 "로그인된 브라우저"). 로그아웃 · 비밀번호 바꾸기의 세션 끊기도 같다.
5. 다른 곳 로그아웃은 바로 적용한다 — 끊은 세션 id 를 가드가 막는다(위 "로그인된 기기"). 로그아웃 · 비밀번호 바꾸기의 세션 끊기도 같다.
---
@@ -2751,18 +2885,18 @@ interface FileUploadCreate {
| 퇴사 마무리 | 잠긴 계정만. 엔진 계정 지우기(메일 영구 삭제 — 주소를 다시 쳐서 확인) + 우리 DB 의 그 주소 행 정리 — 같은 주소를 다시 쓸 때 섞이지 않게 |
| 링크 끊기 | 신고된 공개 링크 주소로 → `revoked_by = operator` |
| 공용 주소 | MailingList 만들기 · 구성원 넣기 / 빼기 · 목록(구성원 받은편지함으로 복사) |
| 현황 | 직원마다 잠금 · 2단계 · 앱 비밀번호 수 · 마지막 웹메일 로그인 · 메일함 사용량 · 이전 중(끝나는 날) |
| 현황 | 직원마다 잠금 · 2단계 · 앱 비밀번호 수 · 마지막 로그인(웹메일 · 앱 — 2026-11-06 부터 앱도 센다) · 메일함 사용량 · 이전 중(끝나는 날) |
| 정책 · 설정 | 2단계 필수(켜기 · 시작일 · 예외), 문의 연락처 |
| 메일 이전 (2026-11-02 더함) | 이전 현황(이전 중인 계정 · 끝나는 때 · 메일함 사용량) · 이전용 비밀번호 다시 만들기(한 사람 또는 이전 중인 계정 모두 — 잃었거나 30일을 늘릴 때) · 이전 마치기(한 사람 또는 이전 중인 계정 모두 → 직원용 임시 비밀번호) |
- 차례가 있는 명령
- 비밀번호 재설정: 앱 비밀번호 지우기(대리 접근) → 웹메일 세션 끊기(DB) → 알림 메일 넣기(대리 접근) → 엔진에 임시 비밀번호 + 만료 → 세션 한 번 더 끊기.
- 비밀번호 재설정: 앱 비밀번호 지우기(대리 접근) → 웹메일 · 앱 세션 끊기(DB) → 알림 메일 넣기(대리 접근) → 엔진에 임시 비밀번호 + 만료 → 세션 한 번 더 끊기.
만료 뒤에는 대리 접근 메서드가 막히고, 알림이 말하는 일(로그아웃 · 앱 비밀번호 지움)은 넣기 전에 끝나 있어야 하기 때문이다. 첫 세션 끊기가 실패하면 알림을 넣기 전에 멈추고,
엔진 바꾸기가 실패하면 넣은 알림 메일을 지운다. 마지막 끊기는 그사이 옛 비밀번호로 새로 로그인한 세션을 위한 것이다.
- 잠금: 받는 메일 넘기기 · 자동 응답(대리 접근으로 Sieve)을 먼저 걸고 → `authenticate` 빼기 → 세션 끊기. 뺀 뒤에는 대리 접근이 막혀 Sieve 를 새로 걸 수 없다(이미 건 Sieve 는 계속 돈다).
- 퇴사 마무리: 엔진 계정을 지운 뒤 우리 DB 에서 그 주소의 행을 한 트랜잭션으로 지운다 — 로그인 기록 · 앱 비밀번호 사용 · 2단계 설정 · 서명 · 설정 · 사내 목록 기록 · 대용량 파일 · 링크,
- 퇴사 마무리: 엔진 계정을 지운 뒤 우리 DB 에서 그 주소의 행을 한 트랜잭션으로 지운다 — 로그인 기록 · 앱 비밀번호 사용 · 2단계 설정 · 서명 · 설정 · 사내 목록 기록 · 대용량 파일 · 링크 · 검색 색인 · 앱 알림 등록(앱 1단계 — 휴대폰 주소),
그리고 2단계 필수의 예외 주소 목록과 공용 주소(MailingList)의 구성원에서도 뺀다(실행 전 보여 주는 바뀔 것에 든다).
세션 행은 바로 지우지 않는다 — 끊은 기록을 서버가 15초 동기화로 읽어야 하므로 끝난 세션 정리 cron 에 맡긴다(로그인된 브라우저 목록은 살아 있는 세션만 보인다).
세션 행은 바로 지우지 않는다 — 끊은 기록을 서버가 15초 동기화로 읽어야 하므로 끝난 세션 정리 cron 에 맡긴다(로그인된 기기 목록은 살아 있는 세션만 보인다).
디스크 파일(대용량 · 서명 그림)은 그 뒤에 지우고 못 지운 것은 정리 cron 에 맡긴다. 실행 기록(`ops_audit_log`)은 남기고, 읽기만 하는 현황은 기록하지 않는다.
- 비밀번호 재설정은 잠긴 계정을 거절한다(대리 접근이 막혀 있다 — 잠금 풀기가 재설정을 함께 권한다). 이미 만료된 계정을 다시 재설정하면 앱 비밀번호 지우기 · 알림 넣기를 건너뛴다
(엔진이 막는다 — 엔진 관리 화면에서 만료를 걸어 앱 비밀번호가 남은 드문 경우는 `[못 함]` 으로 알리고 이어 간다).
@@ -2790,7 +2924,7 @@ interface FileUploadCreate {
비밀번호를 바꾸기 전에 그 주소의 웹메일 세션을 끊는다 — 임시 비밀번호(만료)로 들어와 새 비밀번호 단계에 묶인 세션이, 만료가 미래로 바뀌면 다음 갱신에서 제한이 풀리기 때문이다(끊지 못하면 바꾸지 않는다).
한 사람일 때는 이전 중이거나 비밀번호가 만료된 계정(30일이 지난 이전 계정 · 아직 바꾸지 않은 임시 비밀번호 — 엔진에서는 가를 수 없다 — 바뀔 것에 "지금 비밀번호는 더 쓰지 못합니다")만 받는다. 만료된 계정은 "모두"에 넣지 않는다.
직원이 이미 비밀번호를 정한 계정(만료 없음)은 막는다 — 걸면 직원이 로그인하지 못한다. 잠긴 계정 · 2단계 인증을 켠 계정(비밀번호로는 IMAP 에 들어가지 못한다)도 막는다.
- 이전 마치기의 차례(계정마다): 앱 비밀번호가 있으면 지우기 → 웹메일 세션 끊기 → **이전 중 로그인 기록 지우기**(우리 DB `login_events` — imapsync 의 IMAP 로그인이 엔진 웹훅으로 쌓인다. 직원이 첫날 모르는 IMAP 로그인 수십 줄을 보지 않게)
- 이전 마치기의 차례(계정마다): 앱 비밀번호가 있으면 지우기 → 웹메일 · 앱 세션 끊기 → **이전 중 로그인 기록 지우기**(우리 DB `login_events` — imapsync 의 IMAP 로그인이 엔진 웹훅으로 쌓인다. 직원이 첫날 모르는 IMAP 로그인 수십 줄을 보지 않게)
→ 엔진에 직원용 임시 비밀번호 + 만료 → 세션 한 번 더 끊기. **받은편지함 알림 메일은 넣지 않는다**(재설정 알림은 "관리자가 비밀번호를 바꿈"이라 새 직원이 놀란다).
한 계정이 실패해도 나머지를 이어 하고, 끝에 주소 · 임시 비밀번호 표를 한 번 보인다(앞에서 만든 것은 실패가 나도 반드시 보인다). 로그인 기록을 지우지 못한 계정은 비밀번호를 바꾸지 않고 멈춘다(넘긴 뒤에는 지울 명령이 없다).
잠긴 계정은 마치지 않는다. y/N 뒤 계정마다 다시 읽어 그사이 비밀번호 처지가 바뀌었으면(다른 운영자가 먼저 마침 등) 하지 않는다. 세션 끊는 사유는 운영 도구의 `ended-by-operator` 그대로다.