From 3f66f39f9db929c3e655854a04e42cabcae18394 Mon Sep 17 00:00:00 2001 From: ttipo Date: Sun, 8 Nov 2026 01:22:58 +0900 Subject: [PATCH] =?UTF-8?q?fix(backend):=20=EB=B6=99=EC=9E=A1=EC=9D=80=20?= =?UTF-8?q?=EC=A7=81=ED=9B=84=20=EB=93=A4=EC=96=B4=EC=98=A8=20=EB=A9=94?= =?UTF-8?q?=EC=9D=BC=20=ED=95=98=EB=82=98=EC=97=90=20=EC=95=8C=EB=A6=BC?= =?UTF-8?q?=EC=9D=B4=20=EB=91=90=20=EB=B2=88=20=EA=B0=80=EC=A7=80=20?= =?UTF-8?q?=EC=95=8A=EA=B2=8C=20=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 엔진 연결을 붙잡자마자 메일이 오면 따라잡기와 그 메일의 새 메일 신호가 함께 보내 알림이 두 번 갔다(커밋 전 다시 리뷰 N1). 붙잡은 뒤 신호를 이미 받았으면 따라잡기를 건너뛰고, 따라잡기 뒤 간격 끝의 보내기는 따라잡기가 센 뒤에 받은 메일이 있을 때만 보낸다 (기록 저장이 실패해도 겹치지 않게 메모리로 본다). 옛 주석의 "로그인이 저절로 끝남"을 끝난 까닭 표로 고치고 계약 · 스킬의 문장도 맞췄다. Co-Authored-By: Claude Opus 5.5 --- .claude/skills/nestjs/SKILL.md | 2 +- .../mail/new-mail-push.service.spec.ts | 79 ++++++++- .../src/modules/mail/new-mail-push.service.ts | 157 +++++++++++++----- .../src/modules/push/fcm/fcm-message.spec.ts | 2 +- backend/src/modules/push/fcm/fcm-message.ts | 2 +- backend/src/modules/push/push-sender.port.ts | 2 +- backend/src/modules/push/push-text.ts | 2 +- docs/mail/api-contract.md | 3 + 8 files changed, 202 insertions(+), 47 deletions(-) diff --git a/.claude/skills/nestjs/SKILL.md b/.claude/skills/nestjs/SKILL.md index 85f289a..92e71ee 100644 --- a/.claude/skills/nestjs/SKILL.md +++ b/.claude/skills/nestjs/SKILL.md @@ -44,7 +44,7 @@ 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단계 — 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]]) | diff --git a/backend/src/modules/mail/new-mail-push.service.spec.ts b/backend/src/modules/mail/new-mail-push.service.spec.ts index ed012c8..17c5241 100644 --- a/backend/src/modules/mail/new-mail-push.service.spec.ts +++ b/backend/src/modules/mail/new-mail-push.service.spec.ts @@ -124,7 +124,17 @@ function setup( isDeliverable: jest.fn((sessionId: string, fid: string) => Promise.resolve(!moved.has(`${sessionId}/${fid}`)), ), - recordPushed: jest.fn(() => Promise.resolve()), + // 표처럼 — 보낸 기기의 last_pushed_at 을 그 N 을 센 때로 (따라잡기 뒤 첫 보내기가 이것을 본다) + recordPushed: jest.fn((sessionIds: readonly string[], at: Date) => { + for (const list of Object.values(targets)) { + list.forEach((entry, index) => { + if (sessionIds.includes(entry.sessionId)) { + list[index] = { ...entry, lastPushedAt: at }; + } + }); + } + return Promise.resolve(); + }), forgetDevice: jest.fn(() => Promise.resolve()), onChanged: jest.fn((next: (user: string) => void) => { listener = next; @@ -553,7 +563,8 @@ describe('NewMailPushService (앱 새 메일 알림 — 붙잡기 · 간격 · N { kind: 'new-mail', count: 1 }, ]); - current.unread[ARA].push(NOW); + // 따라잡기가 센 다음 초에 받은 메일 — 간격 끝 보내기는 따라잡기 뒤 새로 받은 메일이 있을 때만이다(리뷰 N1) + current.unread[ARA].push(NOW + 1_000); current.newMail(ARA); await flush(); expect(current.sent).toHaveLength(1); @@ -564,6 +575,70 @@ describe('NewMailPushService (앱 새 메일 알림 — 붙잡기 · 간격 · N ]); }); + it('붙잡은 직후 따라잡기가 세는 사이 그 메일의 새 메일 신호가 오면 한 번만 울린다 — 따라잡기는 건너뛰고 신호 쪽이 그때의 N 으로 (리뷰 N1)', async () => { + current = setup(); + // 붙잡는 순간에 막 들어온 메일 — 따라잡기 창 안 · 본 뒤 + current.unread[ARA] = [NOW - 1_000]; + const counting = + current.provider.countInboxUnreadSince.getMockImplementation(); + if (!counting) throw new Error('세기 대역이 있어야 한다'); + let open!: () => void; + const gate = new Promise((resolve) => { + open = resolve; + }); + // 따라잡기의 세기만 문이 열릴 때까지 붙잡는다 + current.provider.countInboxUnreadSince.mockImplementationOnce( + async (user: string, since: readonly Date[]) => { + await gate; + return counting(user, since); + }, + ); + current.service.onApplicationBootstrap(); + await flush(); + expect(current.fake.holds).toHaveLength(1); + + // 세기가 엔진에서 돌아오기 전에 그 메일의 신호가 온다(배달 → 신호 0.8초 남짓) + current.newMail(ARA); + open(); + await flush(); + expect(current.sent).toEqual([ + { fid: 'fid-s1', notice: { kind: 'new-mail', count: 1 } }, + ]); + + // 간격이 지나도 더 울리지 않는다 + await advance(NEW_MAIL_PUSH_INTERVAL_MS); + expect(current.sent).toHaveLength(1); + }); + + it('따라잡기로 보낸 뒤 그 메일의 신호가 늦게 오면 간격 끝에 다시 보내지 않는다 — 그사이 새로 받은 메일이 있을 때만, 보낸 때를 표에 적지 못해도 (리뷰 N1)', async () => { + current = setup(); + current.unread[ARA] = [NOW - 1_000]; + // 표의 last_pushed_at 이 바뀌지 않은 것처럼 — 따라잡기가 보낸 기기 · 센 때는 서비스가 메모리에 둔다 + current.registrations.recordPushed.mockImplementation(() => + Promise.resolve(), + ); + current.service.onApplicationBootstrap(); + await flush(); + expect(current.sent).toEqual([ + { fid: 'fid-s1', notice: { kind: 'new-mail', count: 1 } }, + ]); + + // 따라잡기가 센 메일의 신호가 따라잡기 뒤에 온다 + jest.setSystemTime(NOW + 800); + current.newMail(ARA); + await advance(NEW_MAIL_PUSH_INTERVAL_MS); + expect(current.sent).toHaveLength(1); + + // 그 뒤의 신호는 보통대로 — 새로 받은 메일이 있으면 (건너뛴 보내기도 간격에 들어) 다음 간격 끝에 그때의 N 으로 + current.unread[ARA].push(Date.now()); + current.newMail(ARA); + await advance(NEW_MAIL_PUSH_INTERVAL_MS); + expect(current.sent.map(({ notice }) => notice)).toEqual([ + { kind: 'new-mail', count: 1 }, + { kind: 'new-mail', count: 2 }, + ]); + }); + it('보내기 직전 그 휴대폰이 다른 세션으로 옮겨 갔으면(isDeliverable false) 그 기기에는 보내지 않는다 — 보낸 때도 적지 않는다 (리뷰 L1)', async () => { const env = await start({ targets: { [ARA]: [target('phone'), target('moved')] }, diff --git a/backend/src/modules/mail/new-mail-push.service.ts b/backend/src/modules/mail/new-mail-push.service.ts index 6c69454..c01744a 100644 --- a/backend/src/modules/mail/new-mail-push.service.ts +++ b/backend/src/modules/mail/new-mail-push.service.ts @@ -60,6 +60,20 @@ interface Pacing { lastFlushAt: number | null; /** 간격 끝에 보낼 차례 */ timer: NodeJS.Timeout | null; + /** + * 따라잡기가 보냈고 그 뒤 신호 보내기가 아직 없다 — 다음 보내기 한 번은 새로 받은 메일이 있는 기기에만(리뷰 N1). 없으면 null. + * 따라잡기가 센 메일의 새 메일 신호가 뒤늦게 와서 같은 메일로 한 번 더 울리지 않게 + */ + afterCatchUp: CatchUpPushed | null; +} + +/** + * 따라잡기가 보낸 기기(세션)와 그 N 을 센 때 — 메모리에 둔다(표의 last_pushed_at 저장이 실패해도 같은 메일로 다시 울리지 않게). + * 보내지 못한 기기는 넣지 않는다(그 기기에는 그 메일이 아직 가지 않았다) + */ +interface CatchUpPushed { + readonly sessions: ReadonlySet; + readonly countedAt: Date; } /** 붙잡기에 실패한 직원 — 다음 시도 */ @@ -110,7 +124,8 @@ const RECEIVED_AT_RESOLUTION_MS = 1_000; * 그 시각의 다음 초(ms) — 따라잡기가 "새로 온 것"을 볼 시작. 없으면 0. * 세기는 그 초부터 같은 초를 넣으므로(MailProvider.countInboxUnreadSince — N 을 덜 세지 않게) 그대로면 그 초에 받은 메일 * — 그 알림의 N 에 이미 들었거나(last_pushed_at) 이미 본(seen_at) — 이 "새로 온 것"으로 다시 세져 따라잡기가 한 번 더 울린다(2026-11-07 리뷰 M1). - * 센 · 본 뒤 같은 초에 들어온 메일은 붙잡은 연결이 따로 새 메일 신호를 냈으므로 놓치는 것은 그 1초 안에 서버가 멈춘 경우뿐이다 + * 센 · 본 뒤 같은 초에 들어온 메일은 따로 새 메일 신호를 낸다 — 그 알림이 나가기 전(그 1초 안 · 30초 간격의 끝을 기다리는 사이)에 서버가 멈추면 놓친다. + * 따라잡기 뒤 첫 보내기(Pacing.afterCatchUp)도 같은 규칙이라, 따라잡기가 센 그 초에 센 뒤 들어온 메일은 그 보내기에서 빠진다 — 다음 알림의 N 에는 든다 */ function nextSecondOf(time: Date | null): number { if (time === null) return 0; @@ -138,7 +153,8 @@ function timer(callback: () => void, ms: number): NodeJS.Timeout { * N(받은편지함의 안 읽은 메일 가운데 그 기기에서 마지막으로 앱을 본 뒤 받은 수 — 엔진에 센다. 웹에서 읽은 메일은 빠진다) → N 이 1 이상인 기기에 "새 메일 N통". * FCM 이 받지 않는 기기는 등록을 지운다(FID 가 그대로일 때만). 같은 직원의 보내기는 차례로(KeyedQueue) * - **따라잡기**: 새로 붙잡을 때마다(서버를 다시 띄움 · 다시 붙잡음) 그 전 CATCH_UP_WINDOW_MS 안에 온 · 마지막으로 보낸 뒤 · 본 뒤의 안 읽은 메일이 있으면 - * 그 기기에 그때의 N 으로 한 번 — 이미 알린 메일은 다시 알리지 않는다(last_pushed_at) + * 그 기기에 그때의 N 으로 한 번 — 이미 알린 메일은 다시 알리지 않는다(last_pushed_at). 붙잡은 직후의 메일 하나에 두 번 울리지 않게(리뷰 N1): + * 붙잡은 뒤 새 메일 신호가 먼저 왔으면 따라잡기는 건너뛰고(신호 쪽이 그때의 N 으로), 따라잡기로 보낸 뒤 첫 신호 보내기는 그 뒤 새로 받은 메일이 있는 기기에만 * - 보내는 글에는 개수만 — 새 메일의 보낸 사람 · 제목 · id 는 이 서비스에 오지도 않는다(붙잡은 쪽은 신호만 받는다) * 로그: 보낸 것은 debug(가린 주소 · 기기 수 · N), 붙잡기가 이어서 실패하면 warn 한 번 · 다시 되면 info 한 번. 메일 글 · FID · 토큰은 남기지 않는다 * @@ -158,6 +174,11 @@ export class NewMailPushService private wanted = new Set(); private readonly retries = new Map(); private readonly pacing = new Map(); + /** + * 이번 붙잡기 뒤 새 메일 신호를 받은 직원 — 따라잡기는 건너뛴다(신호 쪽이 그때의 N 으로 보낸다 — 붙잡은 직후의 메일 하나에 두 번 울리지 않게, 리뷰 N1). + * 붙잡기를 시작할 때 지운다(그 붙잡기의 연결이 낸 신호만 센다) + */ + private readonly signaledSinceHold = new Set(); private readonly holdGate = new ConcurrencyGate(HOLD_CONCURRENCY); /** 같은 직원의 보내기 · 따라잡기는 차례로 */ private readonly sends = new KeyedQueue(); @@ -269,6 +290,7 @@ export class NewMailPushService this.retries.clear(); for (const release of this.held.values()) release(); this.held.clear(); + this.signaledSinceHold.clear(); } private isCompany(user: string): boolean { @@ -298,6 +320,8 @@ export class NewMailPushService } private async hold(user: string): Promise { + // 이 붙잡기의 연결이 낸 신호만 센다 (holder 는 hold 가 끝나기 바로 전에 켜진다 — 그 전에 지운다) + this.signaledSinceHold.delete(user); let release: () => void; try { release = await this.events.hold(user, this.holderFor(user)); @@ -314,7 +338,7 @@ export class NewMailPushService } this.held.set(user, release); this.recovered(user); - // 붙잡기 전에 온 메일 — 서버를 다시 띄운 사이 · 다시 붙잡기까지의 사이 + // 붙잡기 전에 온 메일 — 서버를 다시 띄운 사이 · 다시 붙잡기까지의 사이 (그사이 신호가 오면 건너뛴다 — catchUp) void this.sends.run(user, () => this.catchUp(user)); } @@ -332,6 +356,7 @@ export class NewMailPushService */ private onHoldEnded(user: string, cause: MailEventsHoldEnd): void { this.held.delete(user); + this.signaledSinceHold.delete(user); this.clearPacing(user); if (this.stopping || !this.wanted.has(user)) return; this.scheduleRetry( @@ -350,6 +375,7 @@ export class NewMailPushService private release(user: string): void { const release = this.held.get(user); this.held.delete(user); + this.signaledSinceHold.delete(user); release?.(); this.clearPacing(user); } @@ -419,7 +445,7 @@ export class NewMailPushService private pacingOf(user: string): Pacing { let pacing = this.pacing.get(user); if (!pacing) { - pacing = { lastFlushAt: null, timer: null }; + pacing = { lastFlushAt: null, timer: null, afterCatchUp: null }; this.pacing.set(user, pacing); } return pacing; @@ -428,6 +454,7 @@ export class NewMailPushService /** 새 메일 신호 — 첫 알림은 바로, 간격 안이면 끝에 한 번 (이미 기다리는 차례가 있으면 그것이 그때의 N 으로 보낸다) */ private onNewMail(user: string): void { if (this.stopping) return; + this.signaledSinceHold.add(user); const pacing = this.pacingOf(user); if (pacing.timer) return; const wait = @@ -446,19 +473,30 @@ export class NewMailPushService private flush(user: string, pacing: Pacing): void { pacing.lastFlushAt = Date.now(); - void this.sends.run(user, () => this.pushNewMail(user)); + const afterCatchUp = pacing.afterCatchUp; + pacing.afterCatchUp = null; + void this.sends.run(user, () => this.pushNewMail(user, afterCatchUp)); } - /** 보낼 기기마다 N 을 세어 보낸다 (던지지 않는다) */ - private async pushNewMail(user: string): Promise { + /** + * 보낼 기기마다 N 을 세어 보낸다 (던지지 않는다). + * 따라잡기 뒤 첫 보내기(`afterCatchUp`)면 따라잡기처럼 새로 온 것이 있는 기기에만 — 따라잡기가 보낸 기기는 그 따라잡기가 센 초 다음부터 본다. + * 따라잡기가 센 메일의 신호가 뒤늦게 와서 간격 끝에 같은 메일로 한 번 더 울리지 않게(리뷰 N1) + */ + private async pushNewMail( + user: string, + afterCatchUp: CatchUpPushed | null, + ): Promise { try { const targets = await this.registrations.targetsOf(user); if (targets.length === 0) return; const countedAt = new Date(); - const counts = await this.provider.countInboxUnreadSince( - user, - targets.map((target) => target.seenAt), - ); + const counts = afterCatchUp + ? await this.freshCounts(user, targets, 0, afterCatchUp) + : await this.provider.countInboxUnreadSince( + user, + targets.map((target) => target.seenAt), + ); await this.deliver(user, targets, counts, countedAt); } catch (error) { this.reportFailure(user, error); @@ -473,44 +511,82 @@ export class NewMailPushService */ private async catchUp(user: string): Promise { try { + // 붙잡은 뒤 새 메일 신호를 이미 받았다 — 신호 쪽이 그때의 N 으로 보낸다(같은 메일로 두 번 울리지 않게 — 리뷰 N1) + if (this.signaledSinceHold.has(user)) return; const targets = await this.registrations.targetsOf(user); if (targets.length === 0) return; const countedAt = new Date(); - const windowStart = countedAt.getTime() - CATCH_UP_WINDOW_MS; - const freshSince = targets.map((target) => - Math.max( - nextSecondOf(target.seenAt), - nextSecondOf(target.lastPushedAt), - windowStart, - ), - ); - const times = [ - ...new Set([ - ...freshSince, - ...targets.map((target) => target.seenAt.getTime()), - ]), - ]; - const counted = await this.provider.countInboxUnreadSince( + const counts = await this.freshCounts( user, - times.map((time) => new Date(time)), - ); - const countAt = new Map( - times.map((time, index) => [time, counted[index] ?? 0]), - ); - const counts = targets.map((target, index) => - (countAt.get(freshSince[index]) ?? 0) > 0 - ? (countAt.get(target.seenAt.getTime()) ?? 0) - : 0, + targets, + countedAt.getTime() - CATCH_UP_WINDOW_MS, ); + // 세는 사이 신호가 왔다 — 위와 같다 + if (this.signaledSinceHold.has(user)) return; if (counts.every((count) => count < 1)) return; - // 따라잡기도 간격에 든다 — 바로 뒤의 새 메일은 간격 끝에 한 번 - this.pacingOf(user).lastFlushAt = Date.now(); - await this.deliver(user, targets, counts, countedAt); + // 따라잡기도 간격에 든다 — 바로 뒤의 새 메일은 간격 끝에 한 번, 그 보내기는 이 따라잡기가 센 뒤 새로 받은 메일이 있는 기기에만. + // 보내는 동안의 신호도 그 규칙을 타게 보낼 기기 모두로 먼저 적고, 보낸 뒤 실제로 보낸 기기로 좁힌다(그사이 보내기가 가져갔으면 그대로) + const pacing = this.pacingOf(user); + pacing.lastFlushAt = Date.now(); + const planned: CatchUpPushed = { + sessions: new Set( + targets + .filter((_, index) => (counts[index] ?? 0) >= 1) + .map((target) => target.sessionId), + ), + countedAt, + }; + pacing.afterCatchUp = planned; + const pushed = await this.deliver(user, targets, counts, countedAt); + if (this.pacing.get(user) === pacing && pacing.afterCatchUp === planned) { + pacing.afterCatchUp = + pushed.length > 0 ? { sessions: new Set(pushed), countedAt } : null; + } } catch (error) { this.reportFailure(user, error); } } + /** + * 기기마다 N — 단 "새로 온 것"(본 때 · 마지막으로 보낸 때의 다음 초부터, `windowStart` 뒤 — 따라잡기가 보낸 기기는 그 센 때의 다음 초부터도)이 + * 없는 기기는 0 (따라잡기 · 따라잡기 뒤 첫 보내기). 엔진에는 기기마다 두 시각(새로 온 것을 볼 시작 · N 의 시작)을 한 번에 묻는다(같은 시각은 한 번만) + */ + private async freshCounts( + user: string, + targets: readonly PushTarget[], + windowStart: number, + afterCatchUp: CatchUpPushed | null = null, + ): Promise { + const freshSince = targets.map((target) => + Math.max( + nextSecondOf(target.seenAt), + nextSecondOf(target.lastPushedAt), + afterCatchUp?.sessions.has(target.sessionId) + ? nextSecondOf(afterCatchUp.countedAt) + : 0, + windowStart, + ), + ); + const times = [ + ...new Set([ + ...freshSince, + ...targets.map((target) => target.seenAt.getTime()), + ]), + ]; + const counted = await this.provider.countInboxUnreadSince( + user, + times.map((time) => new Date(time)), + ); + const countAt = new Map( + times.map((time, index) => [time, counted[index] ?? 0]), + ); + return targets.map((target, index) => + (countAt.get(freshSince[index]) ?? 0) > 0 + ? (countAt.get(target.seenAt.getTime()) ?? 0) + : 0, + ); + } + /** * N 이 1 이상인 기기에 보낸다 — 보낸 기기는 그 N 을 센 때를 적고, 받지 않는 기기는 등록을 지운다. * 보내기 직전(다시 하기마다) 그 세션의 등록이 아직 그 FID 이고 세션이 살아 있는지 다시 본다 — 그사이 그 휴대폰이 다른 직원에게 옮겨 갔으면 보내지 않는다 @@ -520,7 +596,7 @@ export class NewMailPushService targets: readonly PushTarget[], counts: readonly number[], countedAt: Date, - ): Promise { + ): Promise { const pushed: string[] = []; const pushedCounts: number[] = []; await Promise.all( @@ -545,11 +621,12 @@ export class NewMailPushService } }), ); - if (pushed.length === 0) return; + if (pushed.length === 0) return pushed; await this.registrations.recordPushed(pushed, countedAt); this.logger.debug( `앱 알림: user=${maskEmail(user)}, 기기 ${pushed.length}, N ${pushedCounts.join('·')}`, ); + return pushed; } private async forget(user: string, target: PushTarget): Promise { diff --git a/backend/src/modules/push/fcm/fcm-message.spec.ts b/backend/src/modules/push/fcm/fcm-message.spec.ts index c5508d3..d133c45 100644 --- a/backend/src/modules/push/fcm/fcm-message.spec.ts +++ b/backend/src/modules/push/fcm/fcm-message.spec.ts @@ -63,7 +63,7 @@ describe('fcmMessageOf (앱 알림의 FCM 메시지 — 계약 1절 "푸시 모 ).toBe(true); }); - it('로그인이 저절로 끝남 — 같은 tag · 채널 · collapse_key 로 옛 "새 메일 N통"을 바꿔 쓴다, 제목 · 본문은 초안 글, 개수 칸 없음', () => { + it('세션 끝 알림(SESSION_ENDED_NOTICE 의 까닭) — 같은 tag · 채널 · collapse_key 로 옛 "새 메일 N통"을 바꿔 쓴다, 제목 · 본문은 확정 글(push-text.ts), 개수 칸 없음', () => { expect(fcmMessageOf(FID, { kind: 'session-ended' })).toEqual({ fid: FID, notification: { diff --git a/backend/src/modules/push/fcm/fcm-message.ts b/backend/src/modules/push/fcm/fcm-message.ts index 476ea00..fd77fb2 100644 --- a/backend/src/modules/push/fcm/fcm-message.ts +++ b/backend/src/modules/push/fcm/fcm-message.ts @@ -67,7 +67,7 @@ export function fcmMessageOf(fid: string, notice: PushNotice): FcmMessage { }, }; } - // 로그인이 저절로 끝났다 — 같은 tag · 채널로 옛 "새 메일 N통"을 바꿔 쓴다. 급하지 않아 보통 우선순위 + // 그 휴대폰을 건드리지 않았는데 앱 세션이 끝났다(까닭은 push.constants.ts 의 SESSION_ENDED_NOTICE) — 같은 tag · 채널로 옛 "새 메일 N통"을 바꿔 쓴다. 급하지 않아 보통 우선순위 return { fid, notification: { diff --git a/backend/src/modules/push/push-sender.port.ts b/backend/src/modules/push/push-sender.port.ts index 9243f86..8020e10 100644 --- a/backend/src/modules/push/push-sender.port.ts +++ b/backend/src/modules/push/push-sender.port.ts @@ -4,7 +4,7 @@ */ export const PUSH_SENDER = Symbol('PUSH_SENDER'); -/** 보낼 알림 — 새 메일 N통 · 로그인이 저절로 끝남 */ +/** 보낼 알림 — 새 메일 N통 · 그 휴대폰을 건드리지 않았는데 앱 세션이 끝남(까닭은 push.constants.ts 의 SESSION_ENDED_NOTICE) */ export type PushNotice = | { readonly kind: 'new-mail'; readonly count: number } | { readonly kind: 'session-ended' }; diff --git a/backend/src/modules/push/push-text.ts b/backend/src/modules/push/push-text.ts index 0669e94..ed52749 100644 --- a/backend/src/modules/push/push-text.ts +++ b/backend/src/modules/push/push-text.ts @@ -14,7 +14,7 @@ export function newMailTitle(count: number): string { /** * 그 휴대폰을 직원이 건드리지 않았는데 앱 세션이 끝났을 때(저절로 끝남 · 다른 곳에서 비밀번호를 바꿈 · 앱 갱신 유예 · 토큰 재사용 감지) - * 그 휴대폰에 한 번 보내는 알림 — 초안이다(보드 `App1Push` ⑦ 에서 확정되면 바꾼다). 보내는 까닭은 push.constants.ts 의 SESSION_ENDED_NOTICE 한 곳. + * 그 휴대폰에 한 번 보내는 알림 — 글은 보드 `App1Push` ⑧(2026-11-07 확정) 그대로다. 보내는 까닭은 push.constants.ts 의 SESSION_ENDED_NOTICE 한 곳. * 직원 · 운영자가 끊은 세션 · 계정을 쓸 수 없는 세션 · 제한된 세션에는 보내지 않는다 */ export const SESSION_ENDED_TEXT = { diff --git a/docs/mail/api-contract.md b/docs/mail/api-contract.md index c53e61f..d644493 100644 --- a/docs/mail/api-contract.md +++ b/docs/mail/api-contract.md @@ -196,6 +196,9 @@ - **따라잡기**: 붙잡을 때마다(서버를 다시 띄움 · 다시 붙잡음) 그 전 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