Compare commits

...

5 Commits

Author SHA1 Message Date
ttipo 181d2a4cbf docs(rules): 5단계 실시간 알림 부품 · SSE 운영 규칙
CI (Flutter) / Flutter (analyze · test) (pull_request) Has been cancelled
CI / Backend (lint · type-check · test · e2e · build) (pull_request) Has been cancelled
CI / Frontend (lint · type-check · test · build) (pull_request) Has been cancelled
CI / Infra (compose · Dockerfile · nginx) (pull_request) Has been cancelled
CI / 에러 코드 SSOT 동기화 검사 (pull_request) Has been cancelled
- nestjs: 실시간 알림 구조(mail-events · jmap-mail-watch · new-mail · sse), 요청 컨텍스트 밖의 일, 인스턴스를 늘릴 때의 연결 한도,
  오래 열린 응답은 BeforeApplicationShutdown 에서 먼저 끝내기
- vue3: 실시간 새 메일 · 바탕화면 알림 · 실시간 테스트 도구, 토스트 error(), 밀도 예외 --size-notice-action
- docker: /api/mail-events 의 nginx 규칙(90s · 버퍼링 끔 · 로그)과 외부 리버스 프록시 요구사항(버퍼링 · 압축 · 전체 시간 상한 · HTTP/2 · 연결 수)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 13:32:58 +09:00
ttipo 458f12980d feat(docker): nginx 실시간 알림 경로 /api/mail-events
- 이 경로만 버퍼링 · 캐시 · 압축을 끄고 X-Accel-Buffering 을 앞으로 넘긴다(외부 nginx 계열 프록시도 이 응답만 버퍼링을 끈다)
- 읽기 사이 시간 제한 90s — 서버 ping 25초 · 화면 감시 75초보다 길고, 머리글이 오지 않으면 90초에 504 JSON
- 성공(200)도 접근 로그에 남긴다 — 연결이 끝날 때 한 줄이고, 시작된 스트림은 어떻게 끝나도 200 이라 rt= 로 끊김을 본다

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 13:32:57 +09:00
ttipo 50165bd3b7 feat(frontend): 실시간 새 메일 · 바탕화면 알림 · 설정 › 알림
- 로그인한 동안 앱 전체에서 실시간 연결 — 브라우저마다 하나(Web Locks 잠금 + BroadcastChannel 로 다른 탭에 나눔), fetch 로 읽기,
  401 은 세션 갱신 한 번, 자리 잡은 뒤에만 다시 잇기 간격을 처음부터(열리자마자 끝나는 연결에서 갱신 폭주 막기), 탭을 닫으면 이어받기
- 메일 화면: 연결이 살아 있는 동안 1분 확인을 멈추고, 바뀌면 1초 모아 맞추기 · 숨은 탭은 탭 제목 (N) 만
- 바탕화면 알림: 웹메일을 보고 있지 않을 때만(보이고 초점이 있는 탭 없음), 한 통 · 여러 통 · 제목 숨김 문구, 누르면 그 메일로, 돌아오면 닫기
- 설정 › 메일 › 알림(허락 상태 · 제목 숨김 · 시험 알림 · 연결 상태)과 목록 위 한 번 권하기 줄, 알림 아이콘

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 13:32:09 +09:00
ttipo f725908053 feat(backend): 실시간 알림 GET /api/mail-events (엔진 EventSource 중계)
- 직원마다 엔진 EventSource 하나를 그 직원의 연결들이 나눠 쓴다(fan-out) — state · 받은편지함 안 읽음 · 새 메일 계산은 직원마다 한 번
- 새 메일: Email/changes → Email/get 으로 받은편지함 · 초안 아님 · 안 읽음 · 본인 아님 · 10분 넘게 앞서지 않은 메일만,
  셀 수 없거나(cannotCalculateChanges · 400 notRequest) 2,000건 넘게 바뀌면 state 만 보내고 기준을 다시 잡는다
- 연결 5개 한도, 25초 ping(upstream), 토큰 만료 30초 전 end(남은 시간이 짧으면 끝날 때), 서버 종료 때 end,
  처리 전에 끊긴 응답 정리, 처음 엔진 연결 10초 상한
- 엔진 연결: 75초 무소식이면 다시 잇기(±20%), 엔진 429 는 끊지 않고 다시 묻기, 같은 기준 3번 실패면 기준 다시 잡기, 5분 안전판
- 액세스 토큰의 만료 시각을 AuthenticatedUser 에 싣는다(선택 필드), 요청이 끝난 뒤에도 이어지는 일은 요청 컨텍스트 밖에서

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 13:32:08 +09:00
ttipo e27e9feb0b docs: 5단계(실시간 새 메일 · 바탕화면 알림) 계약과 디자인
- 계약 2절 "실시간 알림": GET /api/mail-events(SSE) — state(+inboxUnread) · new-mail · ping(upstream) · end 이벤트,
  직원마다 엔진 연결 하나 · 연결 5개 · 25초 ping · 토큰 만료 30초 전 end, 새 메일 판단(10분 넘게 앞선 메일 제외) · 5분 안전판,
  클라이언트 규칙(브라우저마다 연결 하나 · fetch 로 읽기 · 다시 잇기 · 1분 확인과의 관계 · 바탕화면 알림 조건)
- 엔진 확인 사실(Stalwart 0.16.27): EventSource 는 대리 접근으로 열린다, ping 은 30초보다 짧지 않다, EmailDelivery 는 초안에도 바뀐다,
  스트림은 동시 요청 한도에 들지 않는다, 해석 못 하는 sinceState 는 400 notRequest
- 디자인 캔버스 5단계 보드(SettingsNotify · MailRealtime)와 README "5단계" · "5단계 구현하며 정한 보완"

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 13:31:38 +09:00
89 changed files with 10347 additions and 73 deletions
+18 -1
View File
@@ -72,7 +72,13 @@ sudo docker compose --env-file .env.production logs -f backend
발송은 정확히 이 경로만이라 `/api/messages/{id}` 는 1m. 임시 저장은 `/api/drafts` 아래 전부 — 본문이 있는 것은 `POST`·`PUT` 뿐이다),
그 밖은 1m(백엔드 JSON 파서 한도). 앞의 두 값은 루트 env 한 곳만 고치면 백엔드와 nginx 가 함께 바뀐다 (frontend 컨테이너만 다시 만들면 된다).
JSON 한도(1m·4m)는 계약과 백엔드 코드의 고정값이라 env 로 바꾸지 않는다 — 바꾸려면 계약·백엔드 파서·`frontend/nginx.conf` 를 함께 고친다.
- 시간 제한: `/api/messages`·`/api/drafts`·`/api/message-deletions` 90s, `/api/mail-attachments` 150s, 그 밖의 `/api` 는 nginx 기본 60s (`proxy_read_timeout`).
- 시간 제한: `/api/messages`·`/api/drafts`·`/api/message-deletions`·`/api/mail-events` 90s, `/api/mail-attachments` 150s, 그 밖의 `/api` 는 nginx 기본 60s (`proxy_read_timeout`).
응답 전체가 아니라 백엔드에서 "두 번 읽는 사이"의 시간이다 — 실시간 알림은 1시간 이어져도 25초마다 오는 ping 이 새로 세게 한다(머리글이 오지 않으면 90초에 504).
- **실시간 알림(SSE) `/api/mail-events`**(계약 2절 "실시간 알림"): 이 경로만 버퍼링 · 캐시 · 압축을 끄고 `X-Accel-Buffering` 을 앞으로 넘긴다
(외부 프록시가 nginx 계열이면 그쪽도 이 응답만 버퍼링을 끈다). 버퍼링을 켜 두면 머리글을 첫 이벤트까지, 백엔드가 청크 없이 닫아 끝내는 응답이면 끝까지 붙잡는다(2026-10-28 확인).
머리글 · 오류 JSON · 접근 로그는 `/api` 와 같다. ⚠️ 서버의 ping 간격을 바꾸면 90s 도 함께 본다.
열린 연결 하나가 nginx 연결 둘(클라이언트 쪽 · 백엔드 쪽)을 1시간까지 잡는다(이미지 기본값 워커마다 1024).
열린 연결이 있으면 frontend 컨테이너를 멈출 때 nginx 가 기다리다 유예 시간을 다 쓰고 강제 종료된다 — 종료 코드 137 은 이 때문이고 오류가 아니다(화면은 다시 잇는다).
- 캐시: `index.html`(직접 요청·`/`·SPA 폴백 모두)은 `Cache-Control: no-cache` — 배포 뒤 옛 index.html 이 지워진 해시 자산을 부르지 않게.
`/assets/`(Vite 산출물 — 파일명에 내용 해시)만 `max-age=30일, public, immutable` 이다.
해시가 없는 공개 파일(`frontend/public` → 루트, 예: `favicon.ico`)은 같은 이름으로 바뀌므로 `no-cache`(매번 재검증, 안 바뀌면 304).
@@ -86,6 +92,8 @@ sudo docker compose --env-file .env.production logs -f backend
Referer 를 읽는 코드는 없다 — 출처 검사(`OriginGuard`)는 `Sec-Fetch-Site`·`Origin` 을 본다.
새 메일 확인 `GET /api/mail-state`(탭마다 1분에 한 번)는 성공(2xx·304)하면 남기지 않는다 — 로깅 규칙 "반복되는 정상 흐름".
실패(401·502 등)는 남긴다 (server 단계 `access_log … if=$log_access` 하나 — `/api/` 블록을 나누지 않는다).
실시간 알림 `/api/mail-events` 는 성공도 남긴다 — 연결이 끝날 때 한 줄(브라우저마다 대략 1시간에 한 번)이고 `rt=` 가 이어진 시간이다.
시작된 스트림은 어떻게 끝나도 200 이라, 끊김은 짧은 `rt=` 와 오류 로그의 `upstream prematurely closed connection` 으로 본다.
오류 로그(413·502·504 등)에는 nginx 가 요청 줄·upstream·referrer 를 쿼리째 남긴다 — 장애 원인 줄을 잃지 않으려고 그대로 둔다.
`Origin`·`Sec-Fetch-Site`·`Idempotency-Key`·`Cookie` 는 그대로 넘긴다. `/api` 응답에는 화면용 보안 헤더를 붙이지 않는다(백엔드 helmet 이 정한다).
- 백엔드 주소는 요청마다 도커 DNS 로 다시 풀어(`resolver`), 백엔드를 다시 만들어도 nginx 를 재시작할 필요가 없다.
@@ -116,6 +124,15 @@ sudo docker compose --env-file .env.production logs -f backend
`X-Forwarded-For` 를 꾸며 IP 를 바꿀 수 있다. 외부 프록시 IP 는 frontend 가 보는 주소(proxy 네트워크 안)이며 고정해 둔다(`ipv4_address` 등).
- **본문 한도는 `MAIL_ATTACHMENT_MAX_SIZE_MB` + 1MB 이상**, 읽기 시간 제한은 150s 이상. 작으면 백엔드에 닿기 전에 413 이 나고
사용자에게는 `FILE_002` 로 보인다 (프론트 nginx 는 자동으로 맞지만 외부 프록시는 직접 맞춘다).
- **실시간 알림 `/api/mail-events` 를 흘려보낸다** (운영 확인 항목, 계약 2절 "실시간 알림"):
- 응답 버퍼링을 끈다 — nginx 계열(nginx · Nginx Proxy Manager · OpenResty)은 우리가 넘기는 `X-Accel-Buffering: no` 를 기본으로 따른다
(`proxy_ignore_headers` 로 이 머리글을 무시하게 했다면 `proxy_buffering off` 를 직접 건다). 다른 제품은 `text/event-stream` 을 바로 흘려보내는지 확인한다.
- `text/event-stream` 은 압축하지 않는다 — nginx 의 gzip 은 백엔드의 `Cache-Control: no-transform` 을 보지 않는다.
- 유휴(읽기) 제한은 ping 25초보다 넉넉하면 된다(위 "150s 이상"이면 충분). 응답 **전체** 시간에 상한을 두는 설정(요청 최대 시간 · 일부 로드밸런서의 고정 제한)이
있으면 1시간 + 여유(3900s) 이상 — 짧으면 그 주기로 끊겨 다시 잇기를 되풀이한다(동작은 하지만 그때마다 잠시 1분 확인으로 돌아간다).
- 클라이언트가 끊으면 우리 쪽 연결도 바로 닫는다(nginx 기본 `proxy_ignore_client_abort off`). 남은 연결이 쌓이면 직원당 5개 한도로 429 `REQ_001` 이 난다.
- 클라이언트 쪽(TLS 를 끝내는 곳)은 HTTP/2 를 권한다 — HTTP/1.1 이면 브라우저의 주소당 연결 6개 가운데 하나를 1시간 차지한다. 외부 프록시 → frontend:8080 은 HTTP/1.1 그대로.
- 최대 연결 수를 "직원 수 × 브라우저 수 × 2 + 평소 요청"보다 넉넉히 둔다(열린 연결마다 외부 프록시도 연결 둘을 잡는다).
- **IPv6 를 받는지 확인한다** (운영 확인 항목). 백엔드는 IPv6 클라이언트를 /64 단위로 묶어 센다 — 외부 프록시가 IPv6 를 받는다면
`X-Forwarded-For` 에 원래 IPv6 주소가 그대로 들어가는지 본다.
- **접근 로그에 쿼리를 남기지 않는다** (운영 확인 항목). 화면 주소(`/mail/search?q=…` · `?draft=<id>`)와 API 주소(`/api/messages?q=…` ·
+6 -3
View File
@@ -30,7 +30,7 @@ description: NestJS 백엔드(backend/) 개발 규칙 — 컨트롤러/서비스
| 현재 사용자 데코레이터 | `src/common/decorators/current-user.decorator.ts` | `@CurrentUser()` → `AuthenticatedUser { email, name }` |
| 길이 검사 데코레이터 | `src/common/decorators/code-units.decorator.ts` | `MaxCodeUnits` · `MinCodeUnits` — JS 문자열 길이(UTF-16 코드 단위)로 센다(웹의 `.length` 와 같다). class-validator 의 `MinLength`·`MaxLength` 는 서로게이트 쌍 · 이모지를 덜 세어 클라이언트가 센 길이와 어긋난다 — 클라이언트와 같은 한도를 거는 문자열은 이것을 쓴다 |
| 직원 인증 | `modules/auth` | 쿠키 JWT 로그인·`me`·`refresh`·`logout` (계약 `docs/mail/api-contract.md` 1절) · 비밀번호 변경 `PUT /api/auth/password`(4단계 — 직원 본인 자격으로 엔진의 `x:AccountPassword/set`, 로그인과 같은 실패 카운터 · 진행 중 잠금, 바뀌면 이 요청의 세션만 남기고 그 직원의 세션을 끊는다 — 결과를 모르는 실패에도 끊는다). 설정 `auth.settings.ts`, 쿠키 `auth-cookies.ts`, 로그인 실패 횟수 `login-attempt.service.ts`, **로그인 세션(DB)** `auth-session.entity.ts`·`auth-session.policy.ts`(1회용 회전·재사용 감지)·`auth-session.cleanup.ts`(끝난 세션 정리 cron). **메일 엔진 관리자 차단** — 정적 목록(`employee-address.ts` + `MAIL_BLOCKED_ACCOUNTS`)과 로그인·리프레시 때 엔진 권한 확인(`jmap/jmap.client.ts`) 두 겹 |
| 메일 (웹메일 API) | `modules/mail` | 엔진 포트 `mail.provider.ts`(`MAIL_PROVIDER`) + Stalwart JMAP 어댑터 `jmap/`, 받은 메일 HTML 정화 `html/sanitize-mail-html.ts`, 보내는 서식 본문 정화(허용 목록) `html/sanitize-outgoing-html.ts`, 두 정화 앞의 사전 검사(길이·파서 스택 비용, 선형 시간) `html/html-parse-budget.ts`, 첨부 형식 `attachment.policy.ts`, 설정 `mail.settings.ts`, 발송 중복 방지(`Idempotency-Key`) `send-idempotency.ts`, 첨부 임시 디스크 저장 `mail-upload-storage.ts`, 임시 저장 `draft.controller.ts`(엔진 초안 — 새로 만들고 다시 읽은 뒤 옛것을 지운다), 서명 그림 주소 규칙 `html/signature-images.ts`(보낼 때 `cid:` 로), 본문 그림 판정 `html/inline-images.ts`, 받는 사람 추천 `recipient-suggestions.service.ts`(사용자별 10분 캐시) · 순서 `recipient-ranking.ts` · 커서 `recipient-suggestion-cursor.ts`, 검색어 → 엔진 조건 `jmap/search-terms.ts`(한국어 조사 표는 여기 한 곳 — 낱말 안 OR · 낱말끼리 AND, 따옴표 구절), 여러 통 `PATCH /api/messages`(엔진 요청 한 번) · 휴지통에서 영구 삭제 `message-deletion.controller.ts`(휴지통의 메일만, 조건부 지우기) — 본문 `dto/bulk-messages.dto.ts`, 새 메일 확인 `GET /api/mail-state`(`mailbox.controller.ts`), 메일함 관리 · 전체 읽음 · 비우기(`mailbox.controller.ts`, 본문 `dto/mailbox.dto.ts` — 오래 걸리는 처리는 어댑터(`BulkRun`)가 500통씩 · 한 요청 25초 안에서(엔진 호출마다 남은 시간만큼) 하고 남은 수를 돌려준다, 같은 직원 · 같은 메일함은 한 번에 하나(`mail.service.ts` 의 `mailboxOperations` — 진행 중이면 `REQ_001`), 지우기는 안의 메일을 휴지통으로 옮긴 뒤) · 부재중 자동 응답 `MailVacationController`(`dto/mail-vacation.dto.ts`) · 원문 `GET /api/messages/{id}/source`(이름 `attachment.policy.ts` 의 `messageSourceFileName`) · 스팸 표시 `isJunk` (계약 2절) |
| 메일 (웹메일 API) | `modules/mail` | 엔진 포트 `mail.provider.ts`(`MAIL_PROVIDER`) + Stalwart JMAP 어댑터 `jmap/`, 받은 메일 HTML 정화 `html/sanitize-mail-html.ts`, 보내는 서식 본문 정화(허용 목록) `html/sanitize-outgoing-html.ts`, 두 정화 앞의 사전 검사(길이·파서 스택 비용, 선형 시간) `html/html-parse-budget.ts`, 첨부 형식 `attachment.policy.ts`, 설정 `mail.settings.ts`, 발송 중복 방지(`Idempotency-Key`) `send-idempotency.ts`, 첨부 임시 디스크 저장 `mail-upload-storage.ts`, 임시 저장 `draft.controller.ts`(엔진 초안 — 새로 만들고 다시 읽은 뒤 옛것을 지운다), 서명 그림 주소 규칙 `html/signature-images.ts`(보낼 때 `cid:` 로), 본문 그림 판정 `html/inline-images.ts`, 받는 사람 추천 `recipient-suggestions.service.ts`(사용자별 10분 캐시) · 순서 `recipient-ranking.ts` · 커서 `recipient-suggestion-cursor.ts`, 검색어 → 엔진 조건 `jmap/search-terms.ts`(한국어 조사 표는 여기 한 곳 — 낱말 안 OR · 낱말끼리 AND, 따옴표 구절), 여러 통 `PATCH /api/messages`(엔진 요청 한 번) · 휴지통에서 영구 삭제 `message-deletion.controller.ts`(휴지통의 메일만, 조건부 지우기) — 본문 `dto/bulk-messages.dto.ts`, 새 메일 확인 `GET /api/mail-state`(`mailbox.controller.ts`), 메일함 관리 · 전체 읽음 · 비우기(`mailbox.controller.ts`, 본문 `dto/mailbox.dto.ts` — 오래 걸리는 처리는 어댑터(`BulkRun`)가 500통씩 · 한 요청 25초 안에서(엔진 호출마다 남은 시간만큼) 하고 남은 수를 돌려준다, 같은 직원 · 같은 메일함은 한 번에 하나(`mail.service.ts` 의 `mailboxOperations` — 진행 중이면 `REQ_001`), 지우기는 안의 메일을 휴지통으로 옮긴 뒤) · 부재중 자동 응답 `MailVacationController`(`dto/mail-vacation.dto.ts`) · 원문 `GET /api/messages/{id}/source`(이름 `attachment.policy.ts` 의 `messageSourceFileName`) · 스팸 표시 `isJunk` (계약 2절) · 실시간 알림 `GET /api/mail-events`(5단계 — SSE, `mail-events.controller.ts` · 엔진을 모르는 `mail-events.service.ts`(직원마다 연결 5개 · 25초 ping · 토큰 만료 30초 전 `end` · 종료 때 `end`) · 엔진 쪽 `jmap/jmap-mail-watch.ts`(직원마다 엔진 EventSource 하나 · 75초 무소식이면 다시 잇기) · `jmap/jmap-push.ts`(스트림 읽기) · `jmap/new-mail.ts`(새 메일 거르기), SSE 형식 `shared/utils/sse.ts`. 머리글은 스트림을 열기로 정하면 바로(`flushHeaders`), 처리기에 닿기 전에 끊긴 응답(`res.destroyed`)도 정리한다) |
| 메일 서명 | `modules/mail-signature` | 사용자마다 서명 하나(DB) + 서명 그림. 그림 형식은 내용으로 판별(매직 바이트·머리의 가로·세로, 새 의존성 없음) `signature-image-format.ts`, 파일은 업로드 볼륨의 `mail-signatures/` (`signature-image-storage.ts`), 서명에 붙지 않은 그림 정리 cron `mail-signature.cleanup.ts`. 메일 모듈이 발송·임시 저장 때 `MailSignatureService` 로 그림을 쓴다 (계약 2절 "서명") |
| 사내 목록 | `modules/mail-directory` | 웹메일 로그인에 성공한 직원(관리자·게이트웨이·막은 계정 제외)을 `AuthService` 가 기록 → 받는 사람 추천의 "사내". 엔진의 계정 목록(Principal) 권한은 쓰지 않는다(일부러 막아 두었다) |
| 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 로 묶음, 로그인 실패 카운터와 같은 함수) |
@@ -42,7 +42,7 @@ description: NestJS 백엔드(backend/) 개발 규칙 — 컨트롤러/서비스
| 예제 모듈(참조용) | `modules/user`(DTO+Swagger+페이지네이션) | 새 모듈 패턴 참고. in-memory 데모 저장소 |
| 파일 업로드 | `modules/upload` | **로그인 필요**(`JwtAuthGuard`). 로컬 디스크 저장 + 형식·크기 검증 + 안전한 파일명 생성 ([[security]]). 한글 파일명은 multer `defParamCharset: 'utf8'` |
| 헬스체크 | `modules/health` | `@SkipThrottle`+`@SkipTransform` — 기계가 읽는 엔드포인트 패턴 ([[response-format]]) |
| 요청 컨텍스트 | `shared/logger/request-context.ts` | 요청마다 추적 ID 를 AsyncLocalStorage 에 둔다 — 필터를 거치지 않는 로그도 `traceTag()` 로 `[traceId]` 를 붙인다 ([[logging]]) |
| 요청 컨텍스트 | `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`) |
> **확장 시 따르는 규칙** (기능이 필요해지면 규칙대로 구현):
@@ -230,7 +230,8 @@ SwaggerModule.setup('api-docs', app, document);
> 같은 주소의 진행 중 로그인·비밀번호 변경·같은 `Idempotency-Key` 의 진행 중 발송(`SET NX PX` 잠금), `@nestjs/throttler` 저장소,
> 같은 직원 · 같은 메일함의 오래 걸리는 처리(`mail.service.ts` 의 `mailboxOperations` — 그대로면 두 인스턴스가 같은 휴지통을 함께 비우며 서로 어긋난다 → 잠금),
> 사용자별 서명 그림 올리기 차례(`mail-signature.service.ts` 의 `uploadQueues` — 그대로면 동시 업로드가 보관 상한을 넘을 수 있다 → 잠금),
> 받는 사람 추천 캐시(`recipient-suggestions.service.ts` — 틀리지는 않지만 보낸 직후 다른 인스턴스가 10분까지 옛 목록을 준다 → 공유 캐시나 무효화 알림):
> 받는 사람 추천 캐시(`recipient-suggestions.service.ts` — 틀리지는 않지만 보낸 직후 다른 인스턴스가 10분까지 옛 목록을 준다 → 공유 캐시나 무효화 알림),
> 실시간 알림의 직원당 연결 5개(`mail-events.service.ts` — 기능은 맞지만 인스턴스마다 따로 세고, 엔진 연결도 인스턴스마다 하나씩 생긴다 → 필요하면 공유 셈):
> 0. 컨테이너 활성화 — 루트 `.env` 에 `COMPOSE_PROFILES=redis` ([[docker]])
> 1. Redis store 패키지 설치 (**컨테이너 안에서** — 위 의존성 주의 항목 참조)
> 2. `app.module.ts` 의 `CacheModule.register` 를 `registerAsync` + Redis store 로 교체
@@ -250,6 +251,8 @@ SwaggerModule.setup('api-docs', app, document);
- **Graceful shutdown**: `app.enableShutdownHooks()` 가 적용되어 있다. 종료 시 정리가 필요한 리소스(큐 소비자, 워커, 외부 연결)는
`OnApplicationShutdown` 훅으로 정리한다. **`process.on(signal)` 로 직접 잡지 말 것** — Nest 의 종료 흐름에 간섭해 종료 코드가 달라진다
(`shutdown.logger.ts` 가 참고 패턴).
**오래 열린 응답(SSE 등)은 `BeforeApplicationShutdown` 에서 먼저 끝낸다** — `app.close()` 가 HTTP 서버를 닫을 때 열린 연결이 끝나기를 기다려 `stop_grace_period`(30초)를
다 쓰고 강제 종료된다 (`mail-events.service.ts` 가 `end {server-shutdown}` 를 보내고 닫는다).
- **Health Check**: `@nestjs/terminus` 로 구성. 주의점 두 가지 —
`pingCheck` 기본 타임아웃 1초는 짧아 유휴 커넥션 재연결 시 오판하므로 늘려서 쓰고,
표준 응답 래퍼/에러 코드에서 제외해 원인이 보이게 한다 ([[response-format]])
+6 -3
View File
@@ -32,12 +32,12 @@ description: Vue 3 프론트엔드(frontend/) 개발 규칙 — Composition API
| 전역 셸 | `src/components/shell/AppShell.vue` | 앱 레일 + 상단 바 + 모듈 영역. 레일 모듈 목록은 `src/shared/constants/app-modules.ts` 하나 |
| 디자인 토큰·글꼴 | `src/styles/tokens.css` · `src/styles/base.css` | 색·간격·글자 크기 변수. 글꼴은 npm `pretendard` 자체 호스팅(`main.ts` 에서 import — CSP `font-src 'self'`) |
| 브랜드 자산 | `src/assets/brand/` · `src/shared/constants/brand.ts` | 흰 로고(전체·ARA 마크). 원본은 `docs/brand/` |
| 토스트 | `src/stores/toast.store.ts` · `src/components/feedback/ToastHost.vue` | 오류는 `useApi.ts` 의 `showErrorUI()`, 성공 안내는 `composables/useToast.ts`. 알림 안의 단추는 `{ label, run }`(예: 임시 저장본 [되돌리기]), 이동 링크는 지금처럼. 단추가 있는 알림은 10초(`TOAST_ACTION_DURATION_MS`), 마우스 · 초점이 있는 동안은 `hold`·`release` 로 멈춘다. **단추는 띄운 세션에서만 움직인다** — `useToast` 가 `authStore.generation` 에 묶고, 로그아웃 · 세션 만료 · 다른 계정 로그인 때 단추가 있는 알림을 닫는다(메일 id 는 계정마다 겹칠 수 있다). 오래 걸리는 처리는 **진행 알림** `useToast().progress(글)` — 저절로 닫히지 않고(넘쳐도 다른 알림부터 닫는다) 글을 고치다가(`update`) 끝나면 그 자리에서 결과가 된다(`succeed` · `fail` — `toast.store` 의 `update`). 진행 알림도 세션이 바뀌면 닫는다 |
| 토스트 | `src/stores/toast.store.ts` · `src/components/feedback/ToastHost.vue` | 오류는 `useApi.ts` 의 `showErrorUI()`, 성공 안내는 `composables/useToast.ts`(API 가 아닌 화면 쪽 실패 — 예: 브라우저가 알림을 막음 — 는 `useToast().error()`). 알림 안의 단추는 `{ label, run }`(예: 임시 저장본 [되돌리기]), 이동 링크는 지금처럼. 단추가 있는 알림은 10초(`TOAST_ACTION_DURATION_MS`), 마우스 · 초점이 있는 동안은 `hold`·`release` 로 멈춘다. **단추는 띄운 세션에서만 움직인다** — `useToast` 가 `authStore.generation` 에 묶고, 로그아웃 · 세션 만료 · 다른 계정 로그인 때 단추가 있는 알림을 닫는다(메일 id 는 계정마다 겹칠 수 있다). 오래 걸리는 처리는 **진행 알림** `useToast().progress(글)` — 저절로 닫히지 않고(넘쳐도 다른 알림부터 닫는다) 글을 고치다가(`update`) 끝나면 그 자리에서 결과가 된다(`succeed` · `fail` — `toast.store` 의 `update`). 진행 알림도 세션이 바뀌면 닫는다 |
| 모달 대화상자 | `src/components/feedback/ModalDialog.vue` | 네이티브 `<dialog>`(초점·Esc·닫힘 처리)와 모양. 세션 만료(`SessionExpiredDialog`)·나가기 확인(공용 `components/feedback/LeaveConfirmDialog.vue` — 쓰기 화면 `ComposeLeaveDialog.vue`·서명 설정 · 부재중 설정이 쓴다) · 메일함 대화상자(`components/mail/Mailbox*Dialog.vue` — 기본 슬롯에 이름 칸)가 쓴다 — 새 대화상자도 이것으로 만든다. 되돌릴 수 없는 일의 확인은 처음 초점을 「취소」에 둔다 |
| 외부 HTML 격리 렌더링 | `src/components/mail/MailBodyFrame.vue` · `src/shared/utils/mail-html.ts` | 메일 본문 등 외부 HTML 은 이 컴포넌트로만 그린다 (sandbox·내부 CSP 근거는 파일 머리말). 보여 줄 HTML 고르기는 `displayedHtmlBody()` 하나다 — null 이거나 공백뿐이면 textBody (읽기 화면·인용 공용, 서버의 `isBodyTruncated` 와 같은 규칙) |
| 메일 모듈 | `src/pages/mail/*` · `src/components/mail/*` | API 계약 `docs/mail/api-contract.md`, 디자인 원본 `docs/design/webmail/` |
| 콘텐츠 열 토큰 | `src/styles/tokens.css` 의 `--content-*` · `--content-toolbar-start` | 읽기·쓰기 화면이 같은 최대 폭·여백·32px 시작선을 쓴다 (새 화면도 이 토큰으로) |
| 밀도 예외 크기 | `src/styles/tokens.css` 의 `--size-compact-control` · `--size-segment-item` · `--size-segment-item-lg` · `--size-editor-tool` · `--size-editor-menu-item` · `--size-editor-swatch-cell` · `--size-editor-table-cell` · `--table-col-check` · `--size-row-check` · `--size-search-clear-width`/`-height` · `--size-notice-bar` · `--size-mailbox-more` (주석에 "밀도 예외") | 클릭 영역 44px 기준의 의도된 예외(목록 표의 별·보기 전환, 설정 화면의 미리 보기 전환 36(`.segmented`)·서명 그림 크기 줄 36(작게·보통·원본·그림 빼기), 편집기 툴바 버튼 40×44·메뉴 항목 40·색 견본 40·표 고르기 칸 22, 3단계의 목록 표 고르기 칸 32·분할 행 고르기 칸 폭 36·검색 칸 ✕ 40×38·새 메일 알림 줄 40·검색 범위 전환 36·이동 메뉴 항목 40, 4단계의 메일함 행 ⋯ 32×40 · ⋯ 메뉴 항목 40)에만 쓴다 — 사용자 확정(캔버스). 그 밖의 `--size-editor-*`(툴바 높이·글꼴 단추·본문 최소 높이 등)는 예외가 아니라 보통 크기다 |
| 밀도 예외 크기 | `src/styles/tokens.css` 의 `--size-compact-control` · `--size-segment-item` · `--size-segment-item-lg` · `--size-editor-tool` · `--size-editor-menu-item` · `--size-editor-swatch-cell` · `--size-editor-table-cell` · `--table-col-check` · `--size-row-check` · `--size-search-clear-width`/`-height` · `--size-notice-bar` · `--size-mailbox-more` · `--size-notice-action` (주석에 "밀도 예외") | 클릭 영역 44px 기준의 의도된 예외(목록 표의 별·보기 전환, 설정 화면의 미리 보기 전환 36(`.segmented`)·서명 그림 크기 줄 36(작게·보통·원본·그림 빼기), 편집기 툴바 버튼 40×44·메뉴 항목 40·색 견본 40·표 고르기 칸 22, 3단계의 목록 표 고르기 칸 32·분할 행 고르기 칸 폭 36·검색 칸 ✕ 40×38·새 메일 알림 줄 40·검색 범위 전환 36·이동 메뉴 항목 40, 4단계의 메일함 행 ⋯ 32×40 · ⋯ 메뉴 항목 40, 5단계의 목록 위 알림 권하기 줄 40 · 줄 안의 단추 36)에만 쓴다 — 사용자 확정(캔버스). 그 밖의 `--size-editor-*`(툴바 높이·글꼴 단추·본문 최소 높이 등)는 예외가 아니라 보통 크기다 |
| 사용자별 화면 설정 | `src/stores/mail-view.store.ts` | localStorage(키에 사용자 email, try/catch, 실패 시 기본값). 서버 설정 API 가 생기면 교체 |
| 화면 이동 규칙 | `src/composables/useMailNavigation.ts` | 이전/다음은 replace, "목록으로"는 뒤로 가기 또는 replace (기록이 쌓이지 않게) |
| 발송 키 규칙 | `src/shared/utils/idempotency-key.ts` 의 `shouldRenewSendKey()` | `Idempotency-Key` 는 성공과 `MAIL_005` 때만 바꾼다 — 그 밖의 실패 뒤 다시 보내기는 같은 키 (계약 2절 "발송 본문") |
@@ -48,9 +48,12 @@ description: Vue 3 프론트엔드(frontend/) 개발 규칙 — Composition API
| 메일 서명 | `src/stores/mail-signature.store.ts` · `components/mail/editor/signature/` · `shared/utils/mail-signature.ts` · `pages/settings/` | 서명은 메일 화면에 들어올 때 받아 두고 쓰기 화면이 넣는다(새 메일은 끝, 답장·전달은 인용 위 — 이어 쓰기는 다시 넣지 않는다). 설정 화면(`SettingsLayout.vue` 왼쪽 메뉴 — 묶음 · 항목은 `shared/constants/settings-menu.ts` + `MailSignaturePage.vue`)은 들어올 때마다 새로 받는다. 미리 보기는 편집기 스키마의 `DOMSerializer` 로 그린다. 그림 3장은 서명 칸 안의 그림 수로(서버 보관 상한 10장은 서버가 센다) |
| 메일 스토어 구성 | `src/stores/mail.store.ts`(조립 · 공개 API) + `src/stores/mail/` — `mailboxes.ts` · `list.ts` · `selection.ts` · `message.ts` · `bulk.ts` · `mailbox-admin.ts`(4단계 메일함 관리) | Pinia 스토어는 `useMailStore` 하나이고 기능별 setup 모듈을 조립한다(스토어를 여러 개로 나누지 않는다 — 서로 부르는 고리가 생긴다). 목록 출처는 `shared/utils/mail-list-source.ts`(메일함 · 검색). **늦게 온 응답 버리기**: 목록 맞추기는 이 탭의 목록 변경 세대(`localGeneration` — 읽음 · 중요 · 빼기 · 옮기기마다 오른다)가 그사이 바뀌었으면 응답을 버리고 한 번 더 맞춘다. 여러 통 처리 · 한 통 삭제는 시작할 때의 출처 순번(`sourceSeq`)과 다르면 목록을 건드리지 않고 개수만 맞춘다 |
| 새 메일 확인 · 탭 제목 | `src/composables/useMailStatePolling.ts`(메일 화면 `MailLayout` 이 시작) · `useListArrivalNotice.ts` + `components/mail/MailNewNotice.vue` · `useMailTabTitle.ts` + `shared/utils/document-title.ts` | 화면이 보이는 동안 60초마다 `GET /mail-state`, 탭으로 돌아오면 10초 지났을 때 바로, 한 번에 하나, 실패는 `silent`. 바뀌면 `syncCounts()` + 보이는 목록 맞추기(불러온 만큼 · 최대 100통, 검색 결과는 건너뜀). 목록을 내려 본 채면 "새 메일 N통 · 맨 위로" 줄. 받은편지함 안 읽음이 있으면 탭 제목 앞 `(N) ` (메일 라우트에서만) — 계약 2절 "새 메일 확인" |
| 실시간 새 메일 (5단계) | `src/composables/useMailRealtime.ts`(`App.vue` 가 시작 — 로그인한 동안 앱 전체) · `stores/mail-realtime.store.ts` · `shared/utils/sse-parser.ts` · `mail-events.ts` · `mail-realtime.ts` · `browser-support.ts` | **브라우저마다 연결 하나** — Web Locks 잠금(이름에 사용자 주소)을 받은 탭만 `GET /api/mail-events` 를 읽고 BroadcastChannel 로 나눈다(메시지에 사용자 주소, 다른 계정 것은 버린다). `fetch` 로 읽는다 — `EventSource` 는 401 을 몰라 쓰지 않는다. 75초 무소식 감시는 요청 시작부터, 401 → 세션 갱신 한 번(그 바로 뒤의 시도만 "갱신 직후"), 다시 잇기 2 · 5 · 15 · 30 · 60초(±20%) — 간격은 연결이 **자리 잡은 뒤**(정상 ping 또는 20초)에만 처음부터 다시 세고, 자리 잡기 전의 `end` 는 실패처럼 기다린다(열리자마자 끝나는 연결에서 갱신 폭주를 막는다). 메일 화면(`useMailStatePolling`)은 이 소식을 받아 연결이 살아 있는 동안 1분 확인을 멈추고, `state` 가 바뀌면 보이는 탭은 맞추기(1초 모아서 — `MAIL_REALTIME_SYNC_DELAY_MS`) · 숨은 탭은 `inboxUnread` 로 탭 제목만(`setInboxUnread`) — 계약 2절 "실시간 알림" |
| 바탕화면 알림 (5단계) | `src/composables/useNewMailNotifier.ts` · `useDesktopNotification.ts` · `stores/mail-notify.store.ts`(브라우저 · 사용자마다 localStorage) · `shared/utils/desktop-notify.ts` · 목록 위 권하기 `components/mail/MailNotifyOffer.vue` · 설정 `pages/settings/MailNotifyPage.vue`(+ `components/settings/NotifySettingsForm.vue` · `RealtimeLinkStatus.vue`) · 아이콘 `assets/brand/notification-icon.png` · 문구 `shared/constants/mail-notify.ts` | 연결한 탭이 `new-mail` 을 받고 설정이 켜져 있고 허락됐고 **웹메일을 보고 있는 탭이 없을 때만**(보이고 초점이 있음 — `visibilityState === 'visible' && document.hasFocus()`, 다른 탭에 짧게 묻는다) 띄운다 — tag 하나로 앞 알림을 바꿔 쓰고, 사용자가 돌아오면 닫는다. 허락을 묻는 동안 단추는 `disabled` 가 아니라 `aria-disabled`(초점을 잃지 않게). 허락은 사용자가 누른 뒤에만(`Notification.requestPermission()`). 누르면 그 메일로(`useMailNavigation` 의 `openFromNotification` — 세션이 바뀌었으면 이동하지 않는다) |
| 실시간 테스트 도구 | `src/test-utils/event-stream.ts`(가짜 SSE 서버) · `notification.ts`(가짜 Notification) · `web-locks.ts`(가짜 `navigator.locks` · BroadcastChannel) | 실시간 · 알림 테스트는 이것들과 손으로 돌리는 시계(`manual-clock.ts`)로 — 진짜 타이머 · 고정 대기 없이 |
| 메일 검색 | `src/components/mail/MailSearchBox.vue`(상단 바의 search 슬롯) · `composables/useMailSearchNavigation.ts` · `MailSearchHeader.vue` · `MailSearchScope.vue` · `MailSearchEmpty.vue` · 라우트 `/mail/search` · `/mail/search/:messageId` | Enter 로만 찾는다(`isImeComposing()` 이면 무시), ✕ · Esc 는 검색을 시작한 메일함으로. 주소 `?q=…&in=<메일함 id \| all>` — 검색 화면 안의 다시 찾기 · 범위 바꾸기는 replace. 서버와 같은 한도(200자 · 낱말 8개)를 먼저 본다. 조사 처리 · 한계는 서버 몫(계약 2절 "검색") |
| 여러 통 처리 · 삭제 | `src/components/mail/MailCheckbox.vue` · `MailSelectionToolbar.vue`(+ `MailBulkButton` · `MailBulkMoveButton`) · `MailPurgeDialog.vue` · `MessageDeleteButton.vue` · `composables/useBulkMailActions.ts` · `useMessageRemoval.ts` · `useMailMoveFeedback.ts` · `shared/utils/mail-selection.ts` · `mail-bulk.ts` | 100통씩 차례로. 읽음 · 중요는 먼저 바꾸고 `failedIds` 만 되돌리고, 이동 · 삭제는 응답의 id 만 뺀다. **웹은 `DELETE /api/messages/{id}` 를 쓰지 않는다** — 휴지통 밖 삭제는 `PATCH` 이동(+ 되돌리기), 휴지통에서는 확인 대화상자(처음 초점 「취소」) 뒤 `POST /message-deletions`. 읽기 화면도 그 메일의 `mailboxIds` 로 같은 규칙을 탄다. 도구 줄은 role="toolbar" 로빙 tabindex(바쁜 · 꺼진 단추는 건너뛴다) — 계약 2절 "여러 통 한꺼번에" |
| 설정 › 비밀번호 · 부재중 (4단계) | `src/pages/settings/AccountPasswordPage.vue` · `MailVacationPage.vue` · `components/settings/`(`SettingsPageFrame` 공용 틀 · `PasswordChangeForm` · `PasswordField` · `Vacation*`) · `stores/mail-vacation.store.ts` · `shared/utils/password-change.ts` · `mail-vacation.ts` · 메뉴 `shared/constants/settings-menu.ts` | 메뉴는 묶음(계정 › 비밀번호 / 메일 › 서명 · 부재중 자동 응답), 라우트 이름은 `SETTINGS_ROUTES` 하나. 비밀번호는 서버와 같은 규칙을 먼저 보고, 서버 거절(`AUTH_005` · `VAL_001`)은 칸 아래에만(알림 없음) — 성공하면 세 칸을 비운다. 부재중은 날짜만 고르고 브라우저 시간대의 시작일 0시 · 끝나는 날 다음 날 0시를 UTC 로 보낸다(변환은 `mail-vacation.ts` 한 곳 — 되읽을 때는 1ms 앞 날짜). 켤 때만 검사하고 꺼 둔 채로는 빈 값도 저장한다. 부재중 상태는 메일 화면에 들어올 때 받고, 패널 안내 `components/mail/MailVacationNotice.vue` 가 1분마다 다시 계산한다(`useClock` · `VACATION_NOTICE_REFRESH_MS`). 저장 응답은 세션 세대(`authStore.generation`)가 바뀌었으면 쓰지 않는다 — 계약 1절 · 2절 "부재중 자동 응답" |
| 설정 › 비밀번호 · 부재중 (4단계) | `src/pages/settings/AccountPasswordPage.vue` · `MailVacationPage.vue` · `components/settings/`(`SettingsPageFrame` 공용 틀 · `PasswordChangeForm` · `PasswordField` · `Vacation*`) · `stores/mail-vacation.store.ts` · `shared/utils/password-change.ts` · `mail-vacation.ts` · 메뉴 `shared/constants/settings-menu.ts` | 메뉴는 묶음(계정 › 비밀번호 / 메일 › 서명 · 부재중 자동 응답 · 알림 — 5단계), 라우트 이름은 `SETTINGS_ROUTES` 하나. 비밀번호는 서버와 같은 규칙을 먼저 보고, 서버 거절(`AUTH_005` · `VAL_001`)은 칸 아래에만(알림 없음) — 성공하면 세 칸을 비운다. 부재중은 날짜만 고르고 브라우저 시간대의 시작일 0시 · 끝나는 날 다음 날 0시를 UTC 로 보낸다(변환은 `mail-vacation.ts` 한 곳 — 되읽을 때는 1ms 앞 날짜). 켤 때만 검사하고 꺼 둔 채로는 빈 값도 저장한다. 부재중 상태는 메일 화면에 들어올 때 받고, 패널 안내 `components/mail/MailVacationNotice.vue` 가 1분마다 다시 계산한다(`useClock` · `VACATION_NOTICE_REFRESH_MS`). 저장 응답은 세션 세대(`authStore.generation`)가 바뀌었으면 쓰지 않는다 — 계약 1절 · 2절 "부재중 자동 응답" |
| 메일함 관리 · 오래 걸리는 처리 (4단계) | `src/components/mail/MailboxTree.vue` · `MailboxPanelRow.vue` · `MailboxMoreButton.vue`(⋯ 메뉴) · `MailboxDialogs.vue`(+ `Mailbox*Dialog.vue`) · `MailboxEmptyButton.vue`(목록 머리 「비우기」) · `composables/useMailboxActions.ts` · `stores/mail/mailbox-admin.ts` · `shared/utils/mailbox-operation.ts` · 상수 `shared/constants/mailbox-manage.ts` | 만들기 · 이름 바꾸기는 칸 안 실패(`MAIL_009` · `VAL_001`)를 알림 없이 칸 아래에. 모두 읽음 · 비우기 · 지우기는 남은 수가 0(지우기는 `deleted: true`)이 될 때까지 다시 보내며 진행 알림을 고친다 — 진행은 응답의 `processed`, 세션이 바뀌면 멈춘다(`epoch` — 늦게 온 만들기 · 이름 바꾸기 응답도 버린다), 같은 메일함의 같은 처리는 하나, **휴지통 비우기와 메일함 지우기는 함께 돌리지 않는다**(비우기는 그사이 휴지통에 들어온 메일도 지운다 — 판정은 스토어의 `isMailboxOperationBlocked` 하나, 메뉴 · 단추 · 대화상자 확인이 모두 이것을 본다). 끝난 뒤 누른 단추가 사라져 초점을 잃었으면 패널의 지금 메일함 항목으로 옮긴다. ⋯ 메뉴는 보이는 영역 안에 다 들게 연다(`shared/utils/popup-placement.ts` — 아래가 모자라면 위로). 되돌릴 수 없는 일은 확인 대화상자(처음 초점 「취소」) 뒤에, 확인한 그 메일함만 — 계약 2절 "메일함 관리" |
| 스팸 · 원문 (4단계) | `src/components/mail/MessageSpamButton.vue`(읽기 화면) · 도구 줄의 스팸 단추(`MailSelectionToolbar.vue`) · `MessageMoreMenu.vue`(⋯ › 원문 내려받기) | 스팸 신고 · 스팸 아님은 이동과 `isJunk` 를 한 요청에 보내고(`mailStore.moveMessages(…, { isJunk })`), [되돌리기]는 원래 메일함으로 반대 값을 보낸다. 원문(.eml)은 `useMail().downloadMessageSource()` — 첨부처럼 `confirmSession()` 을 먼저 거친다 — 계약 2절 "스팸 · 원문" |
| 시계 주입 | `src/composables/useClock.ts` · `src/test-utils/manual-clock.ts` | 타이머를 쓰는 로직(자동 저장 등)은 시계를 주입받는다 — 테스트는 손으로 돌리는 시계로 시간을 넘긴다(고정 대기 없이, [[testing]]) |
@@ -9,6 +9,11 @@ export interface AuthenticatedUser {
readonly email: string;
/** 표시 이름 */
readonly name: string;
/**
* 액세스 토큰이 끝나는 시각 (epoch ms) — 가드(`JwtAuthGuard` · `JwtStrategy`)를 거친 요청에만 있다.
* 로그인 · 갱신 응답의 직원 정보에는 없다. 오래 열린 연결(실시간 알림)을 토큰이 끝나기 전에 닫는 데 쓴다
*/
readonly accessTokenExpiresAt?: number;
}
/**
@@ -19,6 +19,21 @@ describe('JwtStrategy.validate', () => {
).toEqual({ email: 'ara.kim@arasystem.test', name: '김아라' });
});
it('토큰의 만료(exp, 초)를 epoch ms 로 함께 넘긴다 — 실시간 알림이 토큰보다 오래 열려 있지 않게', () => {
const user = strategy.validate({
sub: 'ara.kim@arasystem.test',
typ: 'access',
name: '김아라',
exp: 1_793_000_000,
});
expect(user).toStrictEqual({
email: 'ara.kim@arasystem.test',
name: '김아라',
accessTokenExpiresAt: 1_793_000_000_000,
});
});
it('리프레시 토큰을 액세스 쿠키에 넣으면 AUTH_001', () => {
expect(() =>
strategy.validate({ sub: 'ara.kim@arasystem.test', typ: 'refresh' }),
+11 -1
View File
@@ -11,6 +11,9 @@ import { ACCESS_TOKEN_COOKIE, JWT_ALGORITHM } from './auth.constants';
import { AUTH_SETTINGS, type AuthSettings } from './auth.settings';
import type { AuthTokenPayload } from './auth-token.types';
/** JWT 의 시각(exp)은 초 단위다 */
const MS_PER_SECOND = 1000;
/** 액세스 토큰은 Authorization 헤더가 아니라 HttpOnly 쿠키에서만 읽는다 (계약 1절) */
function accessTokenFromCookie(request: Request): string | null {
return readCookie(request, ACCESS_TOKEN_COOKIE) ?? null;
@@ -42,6 +45,13 @@ export class JwtStrategy extends PassportStrategy(Strategy, JWT_STRATEGY) {
context: { reason: 'not-an-access-token' },
});
}
return { email: payload.sub, name: payload.name };
return {
email: payload.sub,
name: payload.name,
// 만료(exp, 초)는 passport-jwt 가 이미 확인했다 — 오래 열린 연결이 토큰보다 오래 가지 않게 넘겨 둔다 (실시간 알림)
...(typeof payload.exp === 'number'
? { accessTokenExpiresAt: payload.exp * MS_PER_SECOND }
: {}),
};
}
}
@@ -0,0 +1,646 @@
import { Logger } from '@nestjs/common';
import { BusinessException } from '@/common/errors/business.exception';
import type {
MailLiveState,
MailWatch,
MailWatchListener,
NewMailNotice,
} from '@/modules/mail/mail.types';
import {
FakeEventStream,
PING_EVENT,
settleAsync as settle,
stateChangeEvent,
} from '@/../test/fake-event-stream';
import type { JmapEventStream } from './jmap.client';
import {
JmapMailWatch,
WATCH_BASE_FAILURES_BEFORE_RESET,
WATCH_DEBOUNCE_MS,
WATCH_RATE_LIMIT_RETRY_MS,
WATCH_SAFETY_REFRESH_MS,
WATCH_SILENCE_MS,
WATCH_START_TIMEOUT_MS,
type WatchEngine,
type WatchPosition,
type WatchReading,
} from './jmap-mail-watch';
const ACCOUNT = 'd';
/** 이 계정의 메일 변화 신호 (Stalwart 의 StateChange) */
const stateEvent = (account = ACCOUNT) => stateChangeEvent(account);
const position = (emailState: string): WatchPosition => ({
emailState,
inboxId: 'a',
ownAddresses: new Set(['ara.kim@arasystem.test']),
});
const reading = (
state: string,
inboxUnread: number,
newMail: NewMailNotice | null = null,
): WatchReading => ({
live: { state, inboxUnread },
position: position(`email-${state}`),
newMail,
});
const NEW_MAIL: NewMailNotice = {
count: 1,
messages: [
{
id: 'm1',
from: { name: '박서준', email: 'seojun.park@arasystem.test' },
subject: '안녕하세요',
receivedAt: '2026-10-28T05:42:00.000Z',
},
],
};
type Event =
| ['state', MailLiveState]
| ['new-mail', NewMailNotice]
| ['upstream', boolean]
| ['ended'];
function recorder(): { events: Event[]; listener: MailWatchListener } {
const events: Event[] = [];
return {
events,
listener: {
state: (live) => events.push(['state', live]),
newMail: (notice) => events.push(['new-mail', notice]),
upstream: (connected) => events.push(['upstream', connected]),
ended: () => events.push(['ended']),
},
};
}
/** 엔진 대역 — 연 스트림을 모아 두고, 상태 묻기는 테스트가 정한 값을 돌려준다 */
function fakeEngine() {
const streams: FakeEventStream[] = [];
const openStream = (signal: AbortSignal): Promise<JmapEventStream> => {
const stream = new FakeEventStream(signal);
streams.push(stream);
return Promise.resolve({ accountId: ACCOUNT, body: stream.body() });
};
const engine = {
open: jest.fn(openStream),
snapshot: jest.fn((timeoutMs?: number) => {
void timeoutMs;
return Promise.resolve(reading('s0', 2));
}),
changes: jest.fn((from: WatchPosition) => {
void from;
return Promise.resolve(reading('s0', 2));
}),
};
return {
engine,
streams,
openStream,
/** 마지막으로 연 스트림 */
latest: () => streams[streams.length - 1],
};
}
/** 로그 spy 가 받은 문장 */
const messagesOf = (spy: jest.SpyInstance): string[] =>
(spy.mock.calls as unknown[][]).map(([message]) => String(message));
async function advance(ms: number): Promise<void> {
await jest.advanceTimersByTimeAsync(ms);
await settle();
}
describe('JmapMailWatch (실시간 알림의 엔진 연결)', () => {
let warnSpy: jest.SpyInstance;
let logSpy: jest.SpyInstance;
const watches: MailWatch[] = [];
/** 간격의 흔들림 — 0.5 면 흔들리지 않는다 (테스트마다 바꿀 수 있다) */
let random: () => number = () => 0.5;
const start = async (
engine: WatchEngine,
listener: MailWatchListener,
): Promise<MailWatch> => {
const watch = await JmapMailWatch.start(
engine,
listener,
'a***@arasystem.test',
{ random: () => random() },
);
watches.push(watch);
await settle();
return watch;
};
beforeEach(() => {
random = () => 0.5;
// 스트림 대역은 promise 로 돈다 — 타이머만 손으로 돌린다
jest.useFakeTimers({
doNotFake: ['nextTick', 'queueMicrotask', 'setImmediate'],
});
warnSpy = jest
.spyOn(Logger.prototype, 'warn')
.mockImplementation(() => undefined);
logSpy = jest
.spyOn(Logger.prototype, 'log')
.mockImplementation(() => undefined);
});
afterEach(() => {
for (const watch of watches.splice(0)) watch.close();
jest.useRealTimers();
jest.restoreAllMocks();
});
it('스트림을 연 뒤 지금 상태를 묻고(처음 연결의 남은 시간 안에서) 그것을 처음 상태로 돌려준다 — 그때는 아무것도 알리지 않는다', async () => {
const { engine } = fakeEngine();
const { events, listener } = recorder();
const watch = await start(engine, listener);
expect(watch.initial).toEqual({ state: 's0', inboxUnread: 2 });
expect(engine.open).toHaveBeenCalledTimes(1);
expect(engine.snapshot).toHaveBeenCalledTimes(1);
const [timeoutMs] = engine.snapshot.mock.calls[0];
expect(timeoutMs).toBeGreaterThan(0);
expect(timeoutMs).toBeLessThanOrEqual(WATCH_START_TIMEOUT_MS);
// 열기가 묻기보다 먼저다 — 연 뒤의 변화는 스트림에 쌓여 놓치지 않는다
expect(engine.open.mock.invocationCallOrder[0]).toBeLessThan(
engine.snapshot.mock.invocationCallOrder[0],
);
expect(events).toEqual([]);
});
it('처음 연결이 실패하면 그 실패를 던지고 연 스트림을 닫는다', async () => {
const { engine, streams } = fakeEngine();
engine.snapshot.mockRejectedValueOnce(
new BusinessException('MAIL_001', { context: { reason: 'timeout' } }),
);
await expect(
JmapMailWatch.start(engine, recorder().listener, 'a***'),
).rejects.toMatchObject({ code: 'MAIL_001' });
expect(streams[0].aborted).toBe(true);
});
it(`처음 연결이 ${WATCH_START_TIMEOUT_MS / 1000}초 안에 끝나지 않으면 MAIL_001 — 기다리던 스트림 열기도 닫는다`, async () => {
const { engine } = fakeEngine();
let openSignal: AbortSignal | null = null;
engine.open.mockImplementationOnce(
(signal: AbortSignal) =>
new Promise<JmapEventStream>((_resolve, reject) => {
openSignal = signal;
signal.addEventListener('abort', () => reject(new Error('aborted')));
}),
);
const started = JmapMailWatch.start(engine, recorder().listener, 'a***');
const settled = started.catch((error: unknown) => error);
await advance(WATCH_START_TIMEOUT_MS - 1);
expect(openSignal).not.toBeNull();
expect((openSignal as AbortSignal | null)?.aborted).toBe(false);
await advance(1);
const error = (await settled) as BusinessException;
expect(error.code).toBe('MAIL_001');
expect(error.context).toMatchObject({ reason: 'watch-start-timeout' });
expect((openSignal as AbortSignal | null)?.aborted).toBe(true);
expect(engine.snapshot).not.toHaveBeenCalled();
});
it(`이 계정의 변화 신호는 ${WATCH_DEBOUNCE_MS}ms 모아 한 번만 묻는다 — 달라진 state, 그다음 new-mail`, async () => {
const { engine, latest } = fakeEngine();
const { events, listener } = recorder();
await start(engine, listener);
engine.changes.mockResolvedValueOnce(reading('s1', 3, NEW_MAIL));
latest().send(stateEvent());
await advance(100);
latest().send(stateEvent() + stateEvent());
await advance(WATCH_DEBOUNCE_MS - 101);
expect(engine.changes).not.toHaveBeenCalled();
await advance(1);
expect(engine.changes).toHaveBeenCalledTimes(1);
expect(engine.changes).toHaveBeenCalledWith(position('email-s0'));
expect(events).toEqual([
['state', { state: 's1', inboxUnread: 3 }],
['new-mail', NEW_MAIL],
]);
// 다음 변화는 새 기준부터 묻는다
latest().send(stateEvent());
await advance(WATCH_DEBOUNCE_MS);
expect(engine.changes).toHaveBeenLastCalledWith(position('email-s1'));
});
it('ping · 다른 계정의 변화 · 형식이 틀린 이벤트로는 묻지 않는다 — 상태가 그대로면 state 도 보내지 않는다', async () => {
const { engine, latest } = fakeEngine();
const { events, listener } = recorder();
await start(engine, listener);
latest().send(
PING_EVENT + stateEvent('other') + 'event: state\ndata: {}\n\n',
);
await advance(WATCH_DEBOUNCE_MS * 2);
expect(engine.changes).not.toHaveBeenCalled();
latest().send(stateEvent());
await advance(WATCH_DEBOUNCE_MS);
expect(engine.changes).toHaveBeenCalledTimes(1);
// 묻긴 했지만 처음 상태와 같다
expect(events).toEqual([]);
});
it('묻는 동안 온 신호는 그 묻기가 끝난 뒤 한 번 더 묻는다 — 한 번에 하나', async () => {
const { engine, latest } = fakeEngine();
const { events, listener } = recorder();
await start(engine, listener);
let answer: (value: WatchReading) => void = () => undefined;
engine.changes.mockImplementationOnce(
() =>
new Promise<WatchReading>((resolve) => {
answer = resolve;
}),
);
engine.changes.mockResolvedValueOnce(reading('s2', 1));
latest().send(stateEvent());
await advance(WATCH_DEBOUNCE_MS);
expect(engine.changes).toHaveBeenCalledTimes(1);
latest().send(stateEvent());
await advance(WATCH_DEBOUNCE_MS * 3);
expect(engine.changes).toHaveBeenCalledTimes(1);
answer(reading('s1', 2));
await settle();
await advance(WATCH_DEBOUNCE_MS);
expect(engine.changes).toHaveBeenCalledTimes(2);
expect(engine.changes).toHaveBeenLastCalledWith(position('email-s1'));
expect(events).toEqual([
['state', { state: 's1', inboxUnread: 2 }],
['state', { state: 's2', inboxUnread: 1 }],
]);
});
it(`엔진에서 ${WATCH_SILENCE_MS / 1000}초 동안 아무것도 오지 않으면 끊긴 것으로 보고 upstream: false — 1초 뒤 다시 이어 지난 기준부터 묻고, 달라졌으면 state · new-mail`, async () => {
const { engine, streams } = fakeEngine();
const { events, listener } = recorder();
await start(engine, listener);
// 받은 것이 있으면 그때부터 다시 잰다
await advance(WATCH_SILENCE_MS - 5_000);
streams[0].send(PING_EVENT);
await advance(WATCH_SILENCE_MS - 1);
expect(events).toEqual([]);
expect(streams[0].aborted).toBe(false);
await advance(1);
expect(streams[0].aborted).toBe(true);
expect(events).toEqual([['upstream', false]]);
engine.changes.mockResolvedValueOnce(reading('s1', 4, NEW_MAIL));
await advance(999);
expect(engine.open).toHaveBeenCalledTimes(1);
await advance(1);
expect(engine.open).toHaveBeenCalledTimes(2);
expect(engine.changes).toHaveBeenCalledWith(position('email-s0'));
expect(events).toEqual([
['upstream', false],
['upstream', true],
['state', { state: 's1', inboxUnread: 4 }],
['new-mail', NEW_MAIL],
]);
// 잠깐의 끊김은 로그에 남기지 않는다
expect(warnSpy).not.toHaveBeenCalled();
});
it('다시 이었을 때 상태가 그대로면 upstream: true 만 알린다 — 엔진이 스트림을 닫아도 다시 잇는다', async () => {
const { engine, streams } = fakeEngine();
const { events, listener } = recorder();
await start(engine, listener);
streams[0].end();
await settle();
expect(events).toEqual([['upstream', false]]);
await advance(1_000);
expect(engine.open).toHaveBeenCalledTimes(2);
expect(events).toEqual([
['upstream', false],
['upstream', true],
]);
});
it('다시 잇기가 이어서 실패하면 1 · 2 · 5 · 10 · 30 · 30초 간격 — 세 번 이어서 실패하면 warn 한 번(가린 주소), 다시 이으면 info 한 번', async () => {
const { engine, streams, openStream } = fakeEngine();
const { events, listener } = recorder();
await start(engine, listener);
// 엔진에서 무엇이든 받은(건강한) 연결이 끊긴다
streams[0].send(PING_EVENT);
await settle();
engine.open.mockRejectedValue(
new BusinessException('MAIL_001', { context: { reason: 'network' } }),
);
streams[0].end();
await settle();
const openedAfter = async (ms: number) => {
await advance(ms);
return engine.open.mock.calls.length;
};
expect(await openedAfter(999)).toBe(1);
expect(await openedAfter(1)).toBe(2);
expect(await openedAfter(1_999)).toBe(2);
expect(await openedAfter(1)).toBe(3);
expect(await openedAfter(4_999)).toBe(3);
expect(warnSpy).not.toHaveBeenCalled();
expect(await openedAfter(1)).toBe(4);
expect(warnSpy).toHaveBeenCalledTimes(1);
expect(messagesOf(warnSpy)[0]).toContain('user=a***@arasystem.test');
expect(messagesOf(warnSpy)[0]).toContain('원인=MAIL_001/network');
expect(await openedAfter(9_999)).toBe(4);
expect(await openedAfter(1)).toBe(5);
expect(await openedAfter(29_999)).toBe(5);
expect(await openedAfter(1)).toBe(6);
expect(await openedAfter(30_000)).toBe(7);
expect(warnSpy).toHaveBeenCalledTimes(1);
engine.open.mockImplementation(openStream);
expect(await openedAfter(30_000)).toBe(8);
expect(events).toEqual([
['upstream', false],
['upstream', true],
]);
// 붙기만 해서는 회복으로 적지 않는다 — 엔진에서 이벤트를 받아야(정말로 이어져야) info 한 번
expect(logSpy).not.toHaveBeenCalled();
streams[streams.length - 1].send(PING_EVENT);
await settle();
expect(logSpy).toHaveBeenCalledTimes(1);
expect(messagesOf(logSpy)[0]).toContain('다시 이었습니다');
});
it('다시 이은 연결이 아무것도 받지 못하고 또 끊기면 간격을 처음부터 세지 않는다 (끊기고 붙기를 되풀이하는 엔진을 1초마다 두드리지 않게)', async () => {
const { engine, streams } = fakeEngine();
await start(engine, recorder().listener);
streams[0].end();
await advance(1_000);
expect(engine.open).toHaveBeenCalledTimes(2);
streams[1].end();
await advance(1_999);
expect(engine.open).toHaveBeenCalledTimes(2);
await advance(1);
expect(engine.open).toHaveBeenCalledTimes(3);
// 이번에는 받은 것이 있다 — 다음 끊김은 다시 1초부터
streams[2].send(PING_EVENT);
await settle();
streams[2].end();
await advance(1_000);
expect(engine.open).toHaveBeenCalledTimes(4);
});
it('변화를 묻다 실패하면 끊긴 것과 같게 다룬다 — upstream: false, 다시 이으며 지난 기준부터 다시 묻는다', async () => {
const { engine, streams } = fakeEngine();
const { events, listener } = recorder();
await start(engine, listener);
engine.changes
.mockRejectedValueOnce(
new BusinessException('MAIL_001', { context: { reason: 'timeout' } }),
)
.mockResolvedValueOnce(reading('s1', 5));
streams[0].send(stateEvent());
await advance(WATCH_DEBOUNCE_MS);
expect(streams[0].aborted).toBe(true);
expect(events).toEqual([['upstream', false]]);
await advance(1_000);
expect(engine.changes).toHaveBeenCalledTimes(2);
expect(engine.changes).toHaveBeenLastCalledWith(position('email-s0'));
expect(events).toEqual([
['upstream', false],
['upstream', true],
['state', { state: 's1', inboxUnread: 5 }],
]);
});
it('계정을 더 쓸 수 없으면(AUTH_001) 다시 잇지 않고 ended 를 알린다', async () => {
const { engine, streams } = fakeEngine();
const { events, listener } = recorder();
await start(engine, listener);
engine.open.mockRejectedValue(
new BusinessException('AUTH_001', {
context: { reason: 'impersonation-rejected' },
}),
);
streams[0].end();
await advance(1_000);
await advance(120_000);
expect(engine.open).toHaveBeenCalledTimes(2);
expect(events).toEqual([['upstream', false], ['ended']]);
});
it('닫으면 스트림을 닫고 그 뒤로는 아무것도 알리거나 묻지 않는다', async () => {
const { engine, streams } = fakeEngine();
const { events, listener } = recorder();
const watch = await start(engine, listener);
streams[0].send(stateEvent());
watch.close();
await advance(WATCH_SILENCE_MS * 2);
expect(streams[0].aborted).toBe(true);
expect(engine.changes).not.toHaveBeenCalled();
expect(engine.open).toHaveBeenCalledTimes(1);
expect(events).toEqual([]);
});
it('다시 잇는 간격은 ±20% 흔들린다 — 엔진이 재시작해도 모든 직원이 같은 순간에 다시 붙지 않게', async () => {
const { engine, latest } = fakeEngine();
await start(engine, recorder().listener);
// 가장 짧게 — 1초의 0.8배
random = () => 0;
latest().end();
await advance(799);
expect(engine.open).toHaveBeenCalledTimes(1);
await advance(1);
expect(engine.open).toHaveBeenCalledTimes(2);
// 가장 길게 — 2초의 1.2배 가까이 (아무것도 받지 못한 연결이라 간격이 이어서 는다)
random = () => 0.999;
latest().end();
await advance(2_398);
expect(engine.open).toHaveBeenCalledTimes(2);
await advance(1);
expect(engine.open).toHaveBeenCalledTimes(3);
});
it('엔진 한도(429 — REQ_001)는 끊김이 아니다 — 스트림 · upstream 은 그대로 두고 5초 뒤 다시 묻는다 (그사이 온 신호는 거기에 합친다)', async () => {
const { engine, streams } = fakeEngine();
const { events, listener } = recorder();
await start(engine, listener);
engine.changes
.mockRejectedValueOnce(
new BusinessException('REQ_001', {
context: { reason: 'mail-engine-rate-limit' },
}),
)
.mockResolvedValueOnce(reading('s1', 3));
streams[0].send(stateEvent());
await advance(WATCH_DEBOUNCE_MS);
expect(engine.changes).toHaveBeenCalledTimes(1);
expect(streams[0].aborted).toBe(false);
streams[0].send(stateEvent());
await advance(WATCH_RATE_LIMIT_RETRY_MS - 1);
expect(engine.changes).toHaveBeenCalledTimes(1);
await advance(1);
expect(engine.changes).toHaveBeenCalledTimes(2);
expect(engine.changes).toHaveBeenLastCalledWith(position('email-s0'));
expect(events).toEqual([['state', { state: 's1', inboxUnread: 3 }]]);
expect(engine.open).toHaveBeenCalledTimes(1);
});
it(`같은 기준으로 이어서 ${WATCH_BASE_FAILURES_BEFORE_RESET}번 묻지 못하면 그 기준을 버리고 처음 상태로 다시 잡는다 — 새 메일 없이, 끝없이 끊고 다시 잇지 않게`, async () => {
const { engine, streams, latest } = fakeEngine();
const { events, listener } = recorder();
await start(engine, listener);
engine.changes.mockRejectedValue(
new BusinessException('MAIL_001', {
context: { reason: 'jmap-method-error' },
}),
);
engine.snapshot.mockResolvedValueOnce(reading('s9', 1));
streams[0].send(PING_EVENT + stateEvent());
await advance(WATCH_DEBOUNCE_MS);
expect(events).toEqual([['upstream', false]]);
// 스트림은 열리는데 묻기가 같은 기준에서 또 실패한다 — 1초 · 2초 뒤
await advance(1_000);
await advance(2_000);
expect(engine.changes).toHaveBeenCalledTimes(3);
expect(engine.snapshot).toHaveBeenCalledTimes(1);
await advance(5_000);
expect(engine.changes).toHaveBeenCalledTimes(3);
expect(engine.snapshot).toHaveBeenCalledTimes(2);
// 다시 잡을 때는 기본 제한 시간 (처음 연결의 남은 시간이 아니다)
expect(engine.snapshot).toHaveBeenLastCalledWith();
expect(events).toEqual([
['upstream', false],
['upstream', true],
['state', { state: 's9', inboxUnread: 1 }],
]);
// 그 뒤로는 새 기준부터 묻는다
engine.changes.mockResolvedValueOnce(reading('s9', 1));
latest().send(stateEvent());
await advance(WATCH_DEBOUNCE_MS);
expect(engine.changes).toHaveBeenLastCalledWith(position('email-s9'));
});
it(`신호가 없어도 마지막으로 물은 지 ${WATCH_SAFETY_REFRESH_MS / 60_000}분이 지나면 한 번 묻는다 (엔진이 신호를 빠뜨린 경우) — 그사이 물었으면 그때부터 다시 잰다`, async () => {
const { engine, streams } = fakeEngine();
const { events, listener } = recorder();
await start(engine, listener);
/** 엔진의 ping 을 30초마다 흉내 내며 시간을 보낸다 (무소식 감시에 걸리지 않게) */
const keepAlive = async (ms: number) => {
for (let left = ms; left > 0; left -= 30_000) {
streams[0].send(PING_EVENT);
await advance(Math.min(30_000, left));
}
};
engine.changes.mockResolvedValueOnce(reading('s1', 3, NEW_MAIL));
await keepAlive(WATCH_SAFETY_REFRESH_MS - 1);
expect(engine.changes).not.toHaveBeenCalled();
await advance(1 + WATCH_DEBOUNCE_MS);
expect(engine.changes).toHaveBeenCalledTimes(1);
expect(events).toEqual([
['state', { state: 's1', inboxUnread: 3 }],
['new-mail', NEW_MAIL],
]);
// 2분 뒤 변화 신호로 물었다 — 다음 안전판은 그때부터 5분 뒤
engine.changes.mockResolvedValue(reading('s1', 3));
await keepAlive(120_000);
streams[0].send(stateEvent());
await advance(WATCH_DEBOUNCE_MS);
expect(engine.changes).toHaveBeenCalledTimes(2);
await keepAlive(WATCH_SAFETY_REFRESH_MS - 1);
expect(engine.changes).toHaveBeenCalledTimes(2);
await advance(1 + WATCH_DEBOUNCE_MS);
expect(engine.changes).toHaveBeenCalledTimes(3);
});
it('붙자마자 다시 끊기기를 되풀이하는 엔진 — warn 은 한 번만, 회복 info 는 엔진에서 이벤트를 받을 때까지 남기지 않는다', async () => {
const { engine, latest } = fakeEngine();
await start(engine, recorder().listener);
// 매번 열리기는 하지만 아무것도 보내지 않고 닫힌다
for (let round = 0; round < 6; round += 1) {
latest().end();
await advance(30_000);
}
expect(engine.open).toHaveBeenCalledTimes(7);
expect(warnSpy).toHaveBeenCalledTimes(1);
expect(logSpy).not.toHaveBeenCalled();
latest().send(PING_EVENT);
await settle();
expect(logSpy).toHaveBeenCalledTimes(1);
});
it('끊기기 전에 시작한 묻기가 있으면 다시 잇기는 그것이 끝난 뒤 새 기준부터 묻는다 — 새 메일을 두 번 알리지 않는다', async () => {
const { engine, streams } = fakeEngine();
const { events, listener } = recorder();
await start(engine, listener);
let answer: (value: WatchReading) => void = () => undefined;
engine.changes
.mockImplementationOnce(
() =>
new Promise<WatchReading>((resolve) => {
answer = resolve;
}),
)
.mockResolvedValueOnce(reading('s1', 3));
streams[0].send(PING_EVENT + stateEvent());
await advance(WATCH_DEBOUNCE_MS);
expect(engine.changes).toHaveBeenCalledTimes(1);
// 묻는 사이 엔진이 스트림을 닫는다 — 다시 이을 때(1초)가 지나도 묻기가 끝나기를 기다린다
streams[0].end();
await advance(5_000);
expect(events).toEqual([['upstream', false]]);
expect(engine.open).toHaveBeenCalledTimes(1);
answer(reading('s1', 3, NEW_MAIL));
await settle();
expect(engine.open).toHaveBeenCalledTimes(2);
expect(engine.changes).toHaveBeenCalledTimes(2);
expect(engine.changes).toHaveBeenLastCalledWith(position('email-s1'));
expect(events).toEqual([
['upstream', false],
['state', { state: 's1', inboxUnread: 3 }],
['new-mail', NEW_MAIL],
['upstream', true],
]);
});
});
@@ -0,0 +1,541 @@
import { Logger } from '@nestjs/common';
import { BusinessException } from '@/common/errors/business.exception';
import type {
MailLiveState,
MailWatch,
MailWatchListener,
NewMailNotice,
} from '@/modules/mail/mail.types';
import { SseParser } from '@/shared/utils/sse';
import type { JmapEventStream } from './jmap.client';
import { PUSH_STATE_EVENT, isMailStateChange } from './jmap-push';
/**
* 실시간 알림의 메일 엔진 연결 하나 (계약 2절 "실시간 알림") — 직원 한 명에 하나, 그 직원의 모든 클라이언트 연결이 나눠 쓴다(MailEventsService).
*
* - 엔진의 EventSource 를 열고 지금 상태를 물은 뒤 읽기 시작한다 (연 뒤에 물으므로 그사이의 변화도 놓치지 않는다 — 소식은 스트림에 쌓인다)
* - 이 계정의 메일함 · 메일 변화 신호는 WATCH_DEBOUNCE_MS 동안 모아 한 번만 묻는다 (한 번에 하나, 묻는 동안 온 신호는 끝난 뒤 다시)
* - 신호가 없어도 마지막으로 물은 지 WATCH_SAFETY_REFRESH_MS 가 지나면 한 번 묻는다 (엔진이 신호를 빠뜨린 경우의 안전판)
* - 엔진에서 WATCH_SILENCE_MS 동안 아무것도 오지 않으면 끊긴 것으로 보고 다시 잇는다 (WATCH_RECONNECT_DELAYS_MS 간격 ± 흔들림)
* - 다시 이으면 지난 기준부터 다시 물어 달라진 상태 · 그동안의 새 메일을 알린다. 엔진에 묻기가 실패해도 끊긴 것과 같게 다룬다 —
* 다만 엔진 한도(REQ_001 — 429)는 끊긴 것이 아니다: 스트림은 그대로 두고 WATCH_RATE_LIMIT_RETRY_MS 뒤 다시 묻는다
* - 같은 기준으로 이어서 WATCH_BASE_FAILURES_BEFORE_RESET 번 묻지 못하면 그 기준을 버리고 처음 상태로 다시 잡는다 (새 메일 없이)
* - 계정을 더 쓸 수 없으면(AUTH_001 — 계정이 사라짐 · 막은 계정) 다시 잇지 않고 끝낸다
* - 로그: 열고 닫기 · 한두 번의 끊김은 남기지 않는다 (반복되는 정상 흐름). 다시 잇기가 이어서 실패하면 warn(가린 주소) 한 번,
* 그 뒤 엔진에서 이벤트를 다시 받으면(정말로 이어졌을 때) info 한 번. 새 메일의 제목 · 보낸 사람은 어떤 레벨에도 남기지 않는다
*
* 엔진 호출은 받은 함수(WatchEngine)로만 한다 — 호출 모양 · 새 메일 계산은 jmap-mail.provider.ts 에 있다.
*/
/**
* 처음 연결(스트림을 열고 지금 상태를 묻기)의 상한 — 그동안 클라이언트는 응답 머리를 기다린다(앞단 nginx 는 90초 뒤 504).
* 다른 엔진 호출과 같은 10초 — 넘으면 MAIL_001 (스트림 · 진행 중인 묻기는 닫고 결과를 버린다)
*/
export const WATCH_START_TIMEOUT_MS = 10_000;
/** 이만큼 엔진에서 아무것도 오지 않으면 끊긴 것으로 본다 — 엔진의 ping(30초)을 두 번 놓친 뒤 */
export const WATCH_SILENCE_MS = 75_000;
/** 다시 잇는 간격 — 이어서 실패할수록 길게, 끝 값을 계속 쓴다. 엔진에서 무엇이든 받은 연결이 생기면 처음부터 다시 센다 */
export const WATCH_RECONNECT_DELAYS_MS: readonly number[] = [
1_000, 2_000, 5_000, 10_000, 30_000,
];
/**
* 다시 잇기 · 다시 묻기 간격의 흔들림 (±20%) — 엔진이 재시작하면 모든 직원의 연결이 함께 끊기므로,
* 흔들지 않으면 모두 같은 순간에 다시 붙는다
*/
export const WATCH_RETRY_JITTER = 0.2;
/** 엔진 신호를 모으는 시간 — 한 번의 변경(배달 · 옮기기)에도 신호가 여럿 온다 */
export const WATCH_DEBOUNCE_MS = 300;
/** 다시 잇기가 이만큼 이어서 실패하면 warn 을 남긴다 — 엔진 재시작(몇 초) 같은 잠깐의 끊김은 남기지 않는다 */
export const WATCH_WARN_AFTER_ATTEMPTS = 3;
/** 엔진 한도(429)에 걸린 묻기를 다시 하는 간격 (± 흔들림) — 묻기만 막혔으니 스트림은 그대로 둔다 */
export const WATCH_RATE_LIMIT_RETRY_MS = 5_000;
/**
* 같은 기준으로 이어서 이만큼 묻지 못하면 기준을 버리고 처음 상태로 다시 잡는다 (새 메일 없이) —
* 그 기준에서 늘 나는 오류(cannotCalculateChanges · 요청 거부 밖의 메서드 오류 등)로 끊고 다시 잇기를 끝없이 하지 않게
*/
export const WATCH_BASE_FAILURES_BEFORE_RESET = 3;
/**
* 신호 없이도 마지막으로 물은 지 이만큼 지나면 한 번 묻는다 — 엔진이 신호를 빠뜨려도 낡은 값이 오래 남지 않게.
* 연결이 살아 있는 동안 클라이언트는 1분 확인을 멈추므로 이것이 그 대신이다
*/
export const WATCH_SAFETY_REFRESH_MS = 5 * 60_000;
/** 새 메일 계산의 기준 — 어댑터 안에서만 쓴다 (엔진 상태 문자열이 들어 있다) */
export interface WatchPosition {
/** Email/changes 를 시작할 엔진의 메일 상태 */
readonly emailState: string;
/** 받은편지함 id — 없으면 null (안 읽은 수 0, 새 메일 없음) */
readonly inboxId: string | null;
/** 본인 주소(소문자) — 직원 주소와 발신 ID 주소 */
readonly ownAddresses: ReadonlySet<string>;
}
/** 엔진에 물은 결과 — 지금 상태 · 다음 기준 · 새 메일(셀 수 없거나 없으면 null) */
export interface WatchReading {
readonly live: MailLiveState;
readonly position: WatchPosition;
readonly newMail: NewMailNotice | null;
}
/** 엔진 호출 — 어댑터(JmapMailProvider.watchMail)가 직원 주소를 묶어 넘긴다 */
export interface WatchEngine {
/** 변화 소식 스트림을 연다 (응답 머리까지 받으면 돌려준다) — `signal` 로 닫는다 */
open(signal: AbortSignal): Promise<JmapEventStream>;
/** 지금 상태와 기준을 처음 잡는다 (새 메일은 없다) — `timeoutMs` 안에 (세션 조회 포함, 없으면 기본 제한 시간) */
snapshot(timeoutMs?: number): Promise<WatchReading>;
/** 기준부터 바뀐 것 — 지금 상태 · 다음 기준 · 새 메일 */
changes(position: WatchPosition): Promise<WatchReading>;
}
export interface WatchOptions {
/** [0, 1) 난수 — 간격의 흔들림에 쓴다 (테스트가 정해 넣는다) */
readonly random?: () => number;
}
/** 엔진 연결 한 번 (스트림 하나) */
interface Connection {
readonly controller: AbortController;
/** 엔진에서 이벤트(ping 포함)를 하나라도 받았다 — 받은 뒤로는 다시 잇기 간격을 처음부터 센다 */
healthy: boolean;
/** 우리가 끊었으면 그 까닭 (무소식 · 엔진에 묻기 실패) */
reason: string | null;
}
/** 다시 잇지 않을 실패 — 계정을 더 쓸 수 없다 (사라진 계정 · 막은 계정 — jmap.client.ts 의 AUTH_001) */
function isAccountUnusable(error: unknown): boolean {
return error instanceof BusinessException && error.code === 'AUTH_001';
}
/** 엔진 한도(HTTP 429 — jmap.client.ts 의 REQ_001) — 끊긴 것이 아니라 묻기만 막혔다 */
function isRateLimited(error: unknown): boolean {
return error instanceof BusinessException && error.code === 'REQ_001';
}
/**
* 로그용 실패 요약 — 코드와 사유만 (엔진의 설명 문구 · 주소는 넣지 않는다).
* 스트림을 읽다 끊긴 것은 오류 이름과 원인 코드 (예: TypeError/UND_ERR_SOCKET — fetch 본문의 연결이 끊김)
*/
function describeFailure(error: unknown): string {
if (error instanceof BusinessException) {
const reason: unknown = error.context?.reason;
return typeof reason === 'string' ? `${error.code}/${reason}` : error.code;
}
if (!(error instanceof Error)) return 'unknown';
const { cause } = error as { cause?: unknown };
const causeCode =
cause instanceof Error
? ((cause as NodeJS.ErrnoException).code ?? cause.name)
: null;
return causeCode ? `${error.name}/${causeCode}` : error.name;
}
/** 프로세스를 붙잡지 않는 타이머 — 종료를 늦추지 않게 */
function timer(callback: () => void, ms: number): NodeJS.Timeout {
const handle = setTimeout(callback, ms);
handle.unref();
return handle;
}
export class JmapMailWatch {
private readonly logger = new Logger(JmapMailWatch.name);
private readonly random: () => number;
private closed = false;
/** 엔진과 이어져 있다 — 끊긴 뒤 다시 이을 때까지 false */
private connected = false;
private connection: Connection | null = null;
private position!: WatchPosition;
private latest!: MailLiveState;
private silenceTimer: NodeJS.Timeout | null = null;
/** 다음 묻기 — 신호를 모으는 중이거나 엔진 한도로 미룬 묻기 */
private askTimer: NodeJS.Timeout | null = null;
private retryTimer: NodeJS.Timeout | null = null;
private safetyTimer: NodeJS.Timeout | null = null;
/** 진행 중인 엔진 묻기 — 한 번에 하나 (다시 잇기는 이것이 끝난 뒤 묻는다) */
private refreshing: Promise<void> | null = null;
/** 묻는 동안 신호가 왔다 — 끝나면 한 번 더 */
private signalledWhileRefreshing = false;
/** 마지막으로 건강했던 연결 뒤로 다시 이은 횟수 */
private attempts = 0;
/** 같은 기준으로 이어서 묻지 못한 횟수 — 엔진 한도(429)와 스트림을 열지 못한 것은 세지 않는다 */
private failuresAtBase = 0;
/** 이번 끊김에 warn 을 남겼다 — 다시 건강한 연결이 생길 때까지 다시 남기지 않는다 */
private warned = false;
private constructor(
private readonly engine: WatchEngine,
private readonly listener: MailWatchListener,
/** 로그에 남기는 직원 — 가린 주소 */
private readonly label: string,
options: WatchOptions,
) {
this.random = options.random ?? Math.random;
}
/**
* 엔진 연결을 열고 처음 상태를 잡는다 — 실패는 그 코드로 던지고 아무것도 남기지 않는다.
* @param label 로그용 직원 표시 (가린 주소)
*/
static async start(
engine: WatchEngine,
listener: MailWatchListener,
label: string,
options: WatchOptions = {},
): Promise<MailWatch> {
const watch = new JmapMailWatch(engine, listener, label, options);
const initial = await watch.connectFirst();
return { initial, close: () => watch.close() };
}
/**
* 처음 연결 — WATCH_START_TIMEOUT_MS 안에 스트림을 열고 지금 상태를 물어야 한다(넘으면 MAIL_001).
* 넘거나 실패하면 닫는다 — 늦게 끝난 열기 · 묻기는 닫힌 연결이라 버려진다(스트림은 abort 로 닫힌다)
*/
private async connectFirst(): Promise<MailLiveState> {
const connection = this.begin();
const deadline = Date.now() + WATCH_START_TIMEOUT_MS;
let expiry: NodeJS.Timeout | null = null;
const timedOut = new Promise<never>((_resolve, reject) => {
expiry = timer(
() =>
reject(
new BusinessException('MAIL_001', {
context: { reason: 'watch-start-timeout' },
}),
),
WATCH_START_TIMEOUT_MS,
);
});
const work = (async () => {
const stream = await this.engine.open(connection.controller.signal);
// 그사이 시간이 다 됐으면 묻지 않는다 (스트림은 close 의 abort 로 이미 닫혔다)
if (this.closed) throw new Error('watch closed');
const reading = await this.engine.snapshot(
Math.max(1, deadline - Date.now()),
);
return { stream, reading };
})();
try {
const { stream, reading } = await Promise.race([work, timedOut]);
this.position = reading.position;
this.latest = reading.live;
this.connected = true;
this.armSafety();
this.consume(connection, stream);
return reading.live;
} catch (error) {
this.close();
throw error;
} finally {
this.clearTimer(expiry);
}
}
/** 닫는다 — 이 뒤로는 listener 를 부르지 않는다 */
close(): void {
if (this.closed) return;
this.closed = true;
this.connected = false;
this.clearTimers();
this.connection?.controller.abort();
this.connection = null;
}
private begin(): Connection {
const connection: Connection = {
controller: new AbortController(),
healthy: false,
reason: null,
};
this.connection = connection;
return connection;
}
/** 스트림을 읽기 시작한다 — 무소식 시간은 지금부터 잰다 */
private consume(connection: Connection, stream: JmapEventStream): void {
this.armSilence(connection);
void this.readStream(connection, stream);
}
private async readStream(
connection: Connection,
stream: JmapEventStream,
): Promise<void> {
const decoder = new TextDecoder();
const parser = new SseParser();
let reason = 'stream-ended';
try {
for await (const chunk of stream.body) {
if (connection !== this.connection) return;
this.armSilence(connection);
for (const message of parser.push(
decoder.decode(chunk, { stream: true }),
)) {
if (!connection.healthy) this.becameHealthy(connection);
if (
message.event === PUSH_STATE_EVENT &&
isMailStateChange(message.data, stream.accountId)
) {
this.signal();
}
}
}
} catch (error) {
reason = describeFailure(error);
}
this.lost(connection, connection.reason ?? reason);
}
/**
* 엔진에서 이벤트를 처음 받았다 — 정말로 이어졌다. 다시 잇기 간격을 처음부터 센다.
* 끊김을 warn 으로 남겼으면 여기서 info 한 번 (붙자마자 다시 끊기는 엔진을 회복으로 적지 않게)
*/
private becameHealthy(connection: Connection): void {
connection.healthy = true;
this.attempts = 0;
if (this.warned) {
this.warned = false;
this.logger.log(
`메일 엔진 실시간 연결을 다시 이었습니다: user=${this.label}`,
);
}
}
/** 우리가 연결을 끊는다 — 읽던 쪽이 lost 로 이어 받는다 */
private abort(connection: Connection, reason: string): void {
connection.reason ??= reason;
connection.controller.abort();
}
private armSilence(connection: Connection): void {
this.clearTimer(this.silenceTimer);
this.silenceTimer = timer(
() => this.abort(connection, 'silence'),
WATCH_SILENCE_MS,
);
}
/** 마지막으로 물은 뒤 WATCH_SAFETY_REFRESH_MS 가 지나면 신호 없이 한 번 묻는다 */
private armSafety(): void {
this.clearTimer(this.safetyTimer);
this.safetyTimer = timer(() => {
this.safetyTimer = null;
this.signal();
}, WATCH_SAFETY_REFRESH_MS);
}
/** 흔든 간격 — `ms` 의 ±WATCH_RETRY_JITTER */
private jittered(ms: number): number {
return Math.round(ms * (1 + WATCH_RETRY_JITTER * (2 * this.random() - 1)));
}
/** 엔진 연결이 끊겼다 — 클라이언트에 알리고 다시 잇는다 */
private lost(connection: Connection, reason: string): void {
if (this.closed || connection !== this.connection) return;
this.connection = null;
connection.controller.abort();
this.clearTimer(this.silenceTimer);
this.clearTimer(this.askTimer);
this.clearTimer(this.safetyTimer);
this.silenceTimer = null;
this.askTimer = null;
this.safetyTimer = null;
this.signalledWhileRefreshing = false;
if (this.connected) {
this.connected = false;
this.notify(() => this.listener.upstream(false));
}
this.scheduleReconnect(reason);
}
private scheduleReconnect(reason: string): void {
if (this.attempts >= WATCH_WARN_AFTER_ATTEMPTS && !this.warned) {
this.warned = true;
this.logger.warn(
`[MAIL_001] 메일 엔진 실시간 연결이 이어서 실패합니다 — 클라이언트는 1분 확인으로 돌아가 있고, 다시 잇는 중입니다: user=${this.label}, 이어서=${this.attempts}번, 원인=${reason}`,
);
}
const delay = this.jittered(
WATCH_RECONNECT_DELAYS_MS[
Math.min(this.attempts, WATCH_RECONNECT_DELAYS_MS.length - 1)
],
);
this.attempts += 1;
this.retryTimer = timer(() => {
this.retryTimer = null;
void this.reconnect();
}, delay);
}
/**
* 엔진에 묻는다 — 지난 기준부터 바뀐 것. 같은 기준으로 이어서 WATCH_BASE_FAILURES_BEFORE_RESET 번 묻지 못했으면
* 그 기준을 버리고 처음 상태로 다시 잡는다 (새 메일 없이 — 기준이 망가졌으면 셀 수 없다)
*/
private ask(): Promise<WatchReading> {
return this.failuresAtBase >= WATCH_BASE_FAILURES_BEFORE_RESET
? this.engine.snapshot()
: this.engine.changes(this.position);
}
/** 물은 결과를 받았다 — 기준을 옮기고 달라진 것을 알린다 */
private accept(reading: WatchReading): void {
this.failuresAtBase = 0;
this.position = reading.position;
this.emit(reading);
if (this.connected) this.armSafety();
}
private async reconnect(): Promise<void> {
// 끊기기 전에 시작한 묻기가 있으면 끝난 뒤에 — 같은 기준으로 두 번 물어 새 메일을 두 번 알리지 않게
if (this.refreshing) await this.refreshing;
if (this.closed) return;
const connection = this.begin();
let stream: JmapEventStream;
try {
stream = await this.engine.open(connection.controller.signal);
} catch (error) {
this.failedReconnect(connection, error);
return;
}
let reading: WatchReading;
try {
reading = await this.ask();
} catch (error) {
// 스트림은 열렸는데 묻지 못했다 — 엔진 한도가 아니면 이 기준으로 실패한 것으로 센다
if (!isRateLimited(error)) this.failuresAtBase += 1;
this.failedReconnect(connection, error);
return;
}
if (this.closed || connection !== this.connection) {
connection.controller.abort();
return;
}
this.connected = true;
this.notify(() => this.listener.upstream(true));
this.accept(reading);
this.consume(connection, stream);
}
private failedReconnect(connection: Connection, error: unknown): void {
if (this.closed || connection !== this.connection) return;
this.connection = null;
connection.controller.abort();
if (isAccountUnusable(error)) {
this.end();
return;
}
this.scheduleReconnect(describeFailure(error));
}
/** 이 계정의 변화 신호(또는 안전판) — 모아서 한 번 묻는다 */
private signal(): void {
if (this.closed || !this.connected) return;
if (this.refreshing) {
this.signalledWhileRefreshing = true;
return;
}
if (this.askTimer) return;
this.scheduleAsk(WATCH_DEBOUNCE_MS);
}
private scheduleAsk(delay: number): void {
this.clearTimer(this.askTimer);
this.askTimer = timer(() => {
this.askTimer = null;
this.refresh();
}, delay);
}
private refresh(): void {
const connection = this.connection;
if (this.closed || !this.connected || !connection || this.refreshing) {
return;
}
this.refreshing = this.ask()
.then(
(reading) => {
if (this.closed) return;
this.accept(reading);
},
(error: unknown) => {
if (this.closed) return;
if (isAccountUnusable(error)) {
this.end();
return;
}
if (isRateLimited(error)) {
// 엔진 한도 — 끊긴 것이 아니다. 스트림은 두고 잠시 뒤 다시 묻는다 (upstream 은 그대로)
if (this.connected) {
this.scheduleAsk(this.jittered(WATCH_RATE_LIMIT_RETRY_MS));
}
return;
}
// 엔진에 묻지 못했다 — 끊긴 것과 같게 다룬다 (다시 이으며 지난 기준부터 다시 묻는다)
this.failuresAtBase += 1;
this.abort(connection, describeFailure(error));
},
)
.finally(() => {
this.refreshing = null;
if (this.signalledWhileRefreshing) {
this.signalledWhileRefreshing = false;
this.signal();
}
});
}
/** 달라진 상태 → state, 새 메일 → new-mail (같은 변화의 state 다음) */
private emit(reading: WatchReading): void {
const { live, newMail } = reading;
if (
live.state !== this.latest.state ||
live.inboxUnread !== this.latest.inboxUnread
) {
this.latest = live;
this.notify(() => this.listener.state(live));
}
if (newMail) this.notify(() => this.listener.newMail(newMail));
}
/** 계정을 더 쓸 수 없다 — 닫고 알린다 */
private end(): void {
this.close();
this.notify(() => this.listener.ended());
}
/** listener 의 실수가 엔진 연결의 흐름을 끊지 않게 */
private notify(call: () => void): void {
try {
call();
} catch (error) {
this.logger.error(
`[SYS_001] 실시간 알림을 전하지 못했습니다: user=${this.label}, 원인=${describeFailure(error)}`,
error instanceof Error ? error.stack : undefined,
);
}
}
private clearTimers(): void {
for (const handle of [
this.silenceTimer,
this.askTimer,
this.retryTimer,
this.safetyTimer,
]) {
this.clearTimer(handle);
}
this.silenceTimer = null;
this.askTimer = null;
this.retryTimer = null;
this.safetyTimer = null;
}
private clearTimer(handle: NodeJS.Timeout | null): void {
if (handle) clearTimeout(handle);
}
}
@@ -8,13 +8,24 @@ import { Readable } from 'stream';
import { BusinessException } from '@/common/errors/business.exception';
import { MAX_BODY_VALUE_BYTES } from '@/modules/mail/mail.constants';
import type { MailSettings } from '@/modules/mail/mail.settings';
import type { DraftContent, OutgoingMessage } from '@/modules/mail/mail.types';
import type {
DraftContent,
MailWatch,
MailWatchListener,
OutgoingMessage,
} from '@/modules/mail/mail.types';
import {
FakeEventStream,
settleAsync,
stateChangeEvent,
} from '@/../test/fake-event-stream';
import {
JMAP_INLINE_UPLOAD_TIMEOUT_MS,
JMAP_SEND_TIMEOUT_MS,
} from './jmap.client';
import type { JmapClient, JmapSession } from './jmap.client';
import { decodeCursor, encodeCursor } from './jmap-cursor';
import { WATCH_DEBOUNCE_MS, WATCH_START_TIMEOUT_MS } from './jmap-mail-watch';
import { JmapMailProvider, buildThreadHeaders } from './jmap-mail.provider';
import { MAILBOX_PROPERTIES } from './jmap.mapper';
import type { JmapInvocation } from './jmap.types';
@@ -143,6 +154,7 @@ function fakeClient(
upload: jest.fn(),
download: jest.fn(),
downloadBytes: jest.fn(),
openEventSource: jest.fn(),
};
const store = new Map<string, unknown>();
const cache = {
@@ -1337,6 +1349,422 @@ describe('JmapMailProvider', () => {
});
});
describe('실시간 알림 (watchMail)', () => {
/** 처음 상태 응답 — 모든 메일함 · 메일 상태 · 발신 ID */
const snapshotAnswer = (
mailboxState = 'sb2',
emailState = 'saj',
): JmapInvocation[] => [
[
'Mailbox/get',
{ state: mailboxState, list: stalwartMailboxes, notFound: [] },
'm',
],
['Email/get', { state: emailState, list: [], notFound: [] }, 'e'],
[
'Identity/get',
{
list: [
{ id: 'i1', email: ARA },
{ id: 'i2', email: 'Ara@Alias.ArasYstem.test' },
],
},
'i',
],
];
/** 변화 응답 — Email/changes · 생긴 메일 · 받은편지함 · 메일 상태 */
const changesAnswer = (
changes: JmapInvocation,
created: unknown[],
mailboxState: string,
emailState: string,
inboxUnread = 3,
): JmapInvocation[] => [
changes,
['Email/get', { state: emailState, list: created, notFound: [] }, 'n'],
[
'Mailbox/get',
{
state: mailboxState,
list: [{ id: 'a', role: 'inbox', unreadEmails: inboxUnread }],
notFound: [],
},
'm',
],
['Email/get', { state: emailState, list: [], notFound: [] }, 'e'],
];
const changed = (
newState: string,
created: string[],
hasMoreChanges = false,
): JmapInvocation => [
'Email/changes',
{
oldState: 'old',
newState,
hasMoreChanges,
created,
updated: [],
destroyed: [],
},
'c',
];
const delivered = (id: string, extra: Record<string, unknown> = {}) => ({
id,
mailboxIds: { a: true },
keywords: {},
from: [{ name: '이도윤', email: DOYUN }],
subject: `제목 ${id}`,
receivedAt: '2026-10-28T05:42:00Z',
...extra,
});
type Event = [string, unknown];
/** 엔진 대역 위에서 연결을 열고, 알리는 것을 모은다 */
async function watch(responses: JmapInvocation[][]) {
const fake = fakeClient(responses);
const streams: FakeEventStream[] = [];
fake.client.openEventSource.mockImplementation(
(_user: string, _options: unknown, signal: AbortSignal) => {
const stream = new FakeEventStream(signal);
streams.push(stream);
return Promise.resolve({ accountId: 'd', body: stream.body() });
},
);
const events: Event[] = [];
const listener: MailWatchListener = {
state: (live) => events.push(['state', live]),
newMail: (notice) => events.push(['new-mail', notice]),
upstream: (connected) => events.push(['upstream', connected]),
ended: () => events.push(['ended', null]),
};
const opened = await fake.provider.watchMail(ARA, listener);
opens.push(opened);
await settleAsync();
return { ...fake, opened, streams, events };
}
/** 이 계정의 변화 신호를 보내고 모으는 시간이 지나기를 기다린다 */
async function signal(stream: FakeEventStream): Promise<void> {
stream.send(stateChangeEvent('d'));
await jest.advanceTimersByTimeAsync(WATCH_DEBOUNCE_MS);
await settleAsync();
}
const opens: MailWatch[] = [];
beforeEach(() => {
// 변화를 보는 시각 — delivered() 의 받은 시각(05:42)은 그 3분 전이다 (10분 넘게 앞선 메일은 알리지 않는다)
jest.useFakeTimers({
now: Date.parse('2026-10-28T05:45:00Z'),
doNotFake: ['nextTick', 'queueMicrotask', 'setImmediate'],
});
});
afterEach(() => {
for (const opened of opens.splice(0)) opened.close();
jest.useRealTimers();
});
it('대리 접근 스트림(types=Mailbox,Email · ping 30)을 연 뒤 요청 한 번으로 처음 상태를 묻는다 — state 는 GET /api/mail-state 와 같은 값', async () => {
const { client, recorded, opened } = await watch([snapshotAnswer()]);
expect(client.openEventSource).toHaveBeenCalledWith(
ARA,
{ types: ['Mailbox', 'Email'], pingSeconds: 30 },
expect.any(AbortSignal),
);
expect(recorded).toHaveLength(1);
expect(recorded[0].calls).toEqual([
[
'Mailbox/get',
{
accountId: 'd',
ids: null,
properties: ['id', 'role', 'unreadEmails'],
},
'm',
],
['Email/get', { accountId: 'd', ids: [] }, 'e'],
[
'Identity/get',
{ accountId: 'd', ids: null, properties: ['email'] },
'i',
],
]);
// 처음 연결의 남은 시간 안에서 묻는다 (클라이언트가 응답 머리를 기다린다)
const { timeoutMs } = recorded[0].options as { timeoutMs: number };
expect(timeoutMs).toBeGreaterThan(0);
expect(timeoutMs).toBeLessThanOrEqual(WATCH_START_TIMEOUT_MS);
const polled = await fakeClient([
[
['Mailbox/get', { state: 'sb2', list: [] }, 'm'],
['Email/get', { state: 'saj', list: [] }, 'e'],
],
]).provider.getMailState(ARA);
expect(opened.initial).toEqual({ state: polled.state, inboxUnread: 2 });
});
it('이 계정의 변화 신호 → 지난 메일 상태부터 Email/changes · 생긴 메일 · 받은편지함 · 메일 상태를 한 요청으로 — 새 메일만 골라 state 다음 new-mail', async () => {
const { recorded, streams, events } = await watch([
snapshotAnswer(),
changesAnswer(
changed('sak', ['m1', 'm2', 'm3', 'm4']),
[
delivered('m1'),
delivered('m2', { keywords: { $draft: true } }),
delivered('m3', {
from: [{ name: '나', email: 'ara@alias.arasystem.test' }],
}),
delivered('m4', { keywords: { $seen: true } }),
],
'sb3',
'sal',
),
changesAnswer(changed('sam', []), [], 'sb3', 'sam'),
]);
await signal(streams[0]);
expect(recorded[1].calls).toEqual([
[
'Email/changes',
{ accountId: 'd', sinceState: 'saj', maxChanges: 500 },
'c',
],
[
'Email/get',
{
accountId: 'd',
'#ids': { resultOf: 'c', name: 'Email/changes', path: '/created' },
properties: [
'id',
'mailboxIds',
'keywords',
'from',
'subject',
'receivedAt',
],
},
'n',
],
// 받은편지함은 처음에 찾아 두었다
[
'Mailbox/get',
{
accountId: 'd',
ids: ['a'],
properties: ['id', 'role', 'unreadEmails'],
},
'm',
],
['Email/get', { accountId: 'd', ids: [] }, 'e'],
]);
const polled = await fakeClient([
[
['Mailbox/get', { state: 'sb3', list: [] }, 'm'],
['Email/get', { state: 'sal', list: [] }, 'e'],
],
]).provider.getMailState(ARA);
expect(events).toEqual([
['state', { state: polled.state, inboxUnread: 3 }],
[
'new-mail',
{
count: 1,
messages: [
{
id: 'm1',
from: { name: '이도윤', email: DOYUN },
subject: '제목 m1',
receivedAt: '2026-10-28T05:42:00.000Z',
},
],
},
],
]);
// 다음 기준은 Email/changes 의 newState — 같은 요청에서 그 뒤에 생긴 메일을 다음에 센다
await signal(streams[0]);
expect(argsOf(recorded[2], 0)).toMatchObject({ sinceState: 'sak' });
});
it('변화를 본 시각보다 10분 넘게 앞서 받은 메일은 알리지 않는다(IMAP 으로 올린 옛 메일 · imapsync) — state 는 그대로', async () => {
const { streams, events } = await watch([
snapshotAnswer(),
changesAnswer(
changed('sak', ['old', 'fresh']),
[
delivered('old', { receivedAt: '2019-05-01T09:00:00Z' }),
delivered('fresh'),
],
'sb3',
'sal',
),
]);
await signal(streams[0]);
expect(events.map(([type]) => type)).toEqual(['state', 'new-mail']);
expect(events[1][1]).toMatchObject({
count: 1,
messages: [{ id: 'fresh' }],
});
});
it('셀 수 없으면(cannotCalculateChanges) new-mail 없이 state 만 — 다음 기준은 지금 메일 상태', async () => {
const { recorded, streams, events } = await watch([
snapshotAnswer(),
changesAnswer(
['error', { type: 'cannotCalculateChanges' }, 'c'],
[],
'sb3',
'sal',
).map((invocation) =>
invocation[2] === 'n'
? ([
'error',
{ type: 'invalidResultReference' },
'n',
] as JmapInvocation)
: invocation,
),
changesAnswer(changed('sam', []), [], 'sb3', 'sam'),
]);
await signal(streams[0]);
expect(events.map(([type]) => type)).toEqual(['state']);
await signal(streams[0]);
expect(argsOf(recorded[2], 0)).toMatchObject({ sinceState: 'sal' });
});
it('엔진이 요청 전체를 거부하면(해석하지 못하는 옛 기준 — Stalwart 는 400 notRequest) 셀 수 없는 것과 같게: 처음 상태를 다시 잡아 새 기준으로, new-mail 없이', async () => {
const { client, recorded, streams, events } = await watch([
snapshotAnswer(),
snapshotAnswer('sb9', 'fresh'),
changesAnswer(changed('fresh2', []), [], 'sb9', 'fresh2'),
]);
client.call.mockImplementationOnce(
(
_user: string,
build: (accountId: string) => JmapInvocation[],
options?: unknown,
) => {
// 요청 모양은 기록하고(준비한 응답은 쓰지 않는다), 엔진은 요청 전체를 거부한다 (jmap.client.ts 가 invalidRequestCode 로 던진다)
recorded.push({ calls: build(session.accountId), options });
return Promise.reject(
new BusinessException('MAIL_001', {
context: { reason: 'request-rejected', jmapType: 'notRequest' },
}),
);
},
);
await signal(streams[0]);
// 거부된 요청은 거부를 가를 수 있게 invalidRequestCode 를 단다
expect(recorded[1].calls[0][0]).toBe('Email/changes');
expect(recorded[1].options).toEqual({ invalidRequestCode: 'MAIL_001' });
// 처음 상태를 다시 잡는다 (모든 메일함 · 메일 상태 · 발신 ID)
expect(recorded[2].calls.map(([name]) => name)).toEqual([
'Mailbox/get',
'Email/get',
'Identity/get',
]);
expect(events.map(([type]) => type)).toEqual(['state']);
expect(events).not.toContainEqual(['upstream', false]);
await signal(streams[0]);
expect(argsOf(recorded[3], 0)).toMatchObject({ sinceState: 'fresh' });
});
it('변화가 한 번에 다 오지 않으면(hasMoreChanges) newState 부터 이어 묻는다 — 4번을 넘으면 이번에는 new-mail 없이', async () => {
const paged = await watch([
snapshotAnswer(),
changesAnswer(
changed('p1', ['m1'], true),
[delivered('m1')],
'sb3',
'p9',
),
changesAnswer(changed('p2', ['m2']), [delivered('m2')], 'sb3', 'p9'),
]);
await signal(paged.streams[0]);
expect(argsOf(paged.recorded[2], 0)).toMatchObject({ sinceState: 'p1' });
expect(paged.events[1]).toEqual([
'new-mail',
expect.objectContaining({ count: 2 }),
]);
const flooded = await watch([
snapshotAnswer(),
...['f1', 'f2', 'f3', 'f4'].map((state) =>
changesAnswer(
changed(state, ['m1'], true),
[delivered('m1')],
'sb3',
'now',
),
),
changesAnswer(changed('later', []), [], 'sb3', 'later'),
]);
await signal(flooded.streams[0]);
expect(flooded.recorded).toHaveLength(5);
expect(flooded.events.map(([type]) => type)).toEqual(['state']);
await signal(flooded.streams[0]);
expect(argsOf(flooded.recorded[5], 0)).toMatchObject({
sinceState: 'now',
});
});
it('받은편지함을 찾지 못하면(id 가 바뀜) 모든 메일함에서 다시 찾아 안 읽은 수와 새 메일을 그것으로 센다', async () => {
const { recorded, streams, events } = await watch([
snapshotAnswer(),
changesAnswer(
changed('sak', ['m1']),
[delivered('m1', { mailboxIds: { z: true } })],
'sb3',
'sal',
).map((invocation) =>
invocation[2] === 'm'
? ([
'Mailbox/get',
{ state: 'sb3', list: [], notFound: ['a'] },
'm',
] as JmapInvocation)
: invocation,
),
[
[
'Mailbox/get',
{
state: 'sb3',
list: [
{ id: 'z', name: 'Inbox', role: 'inbox', unreadEmails: 7 },
],
},
'm',
],
],
]);
await signal(streams[0]);
expect(recorded[2].calls[0][0]).toBe('Mailbox/get');
expect(argsOf(recorded[2])).toMatchObject({ ids: null });
expect(events).toEqual([
['state', expect.objectContaining({ inboxUnread: 7 })],
['new-mail', expect.objectContaining({ count: 1 })],
]);
});
});
describe('여러 통 바꾸기 (updateMessages)', () => {
let warnSpy: jest.SpyInstance;
@@ -23,9 +23,12 @@ import type {
MailboxBulkResult,
MailboxCreate,
MailboxDeletion,
MailLiveState,
MailState,
MailUsage,
MailVacation,
MailWatch,
MailWatchListener,
MessageDeletionResult,
MessageDetailSource,
MessageListQuery,
@@ -53,6 +56,12 @@ import {
type JmapSession,
} from './jmap.client';
import { decodeCursor, encodeCursor } from './jmap-cursor';
import {
JmapMailWatch,
type WatchPosition,
type WatchReading,
} from './jmap-mail-watch';
import { PUSH_PING_SECONDS, PUSH_TYPES } from './jmap-push';
import {
isMissingAttachmentBlob,
jmapFailure,
@@ -79,6 +88,7 @@ import {
} from './jmap.mapper';
import type {
JmapBlob,
JmapChangesResult,
JmapEmail,
JmapEmailFilter,
JmapEmailFilterCondition,
@@ -92,6 +102,7 @@ import type {
JmapSetResult,
JmapVacationResponse,
} from './jmap.types';
import { NEW_MAIL_PROPERTIES, selectNewMail } from './new-mail';
import { searchTextConditions, withSearchConditions } from './search-terms';
const QUOTA_CAPABILITY = 'urn:ietf:params:jmap:quota';
@@ -558,6 +569,90 @@ function combineMailStates(mailboxState: string, emailState: string): string {
return `${encode(mailboxState)}.${encode(emailState)}`;
}
/** 메일 상태를 읽은 것 — 클라이언트에 주는 값과, 실시간 알림이 다음에 물을 엔진의 메일 상태 · 함께 읽은 메일함 */
interface MailStatesRead {
/** GET /api/mail-state · 실시간 알림의 state (combineMailStates) */
readonly state: string;
readonly emailState: string;
readonly mailboxes: JmapMailbox[];
}
/**
* 같은 요청의 `Mailbox/get`('m') · `Email/get`('e') 응답에서 상태를 읽는다 — 새 메일 확인(getMailState)과 실시간 알림이
* **같은 함수**로 만든다(값이 어긋나면 클라이언트가 쓸데없이 다시 맞춘다). 엔진이 상태를 알려 주지 않으면 MAIL_001
*/
function readMailStates(responses: JmapInvocation[]): MailStatesRead {
const mailboxes = expectResult<JmapGetResult<JmapMailbox>>(
responses,
'm',
'Mailbox/get',
);
const emails = expectResult<JmapGetResult<JmapEmail>>(
responses,
'e',
'Email/get',
);
if (!isState(mailboxes.state) || !isState(emails.state)) {
throw new BusinessException('MAIL_001', {
context: { reason: 'mail-state-missing' },
});
}
return {
state: combineMailStates(mailboxes.state, emails.state),
emailState: emails.state,
mailboxes: mailboxes.list ?? [],
};
}
/** 실시간 알림이 메일함에서 읽는 속성 — 받은편지함을 찾고 안 읽은 수를 센다 */
const LIVE_MAILBOX_PROPERTIES = ['id', 'role', 'unreadEmails'];
/** Email/changes 한 번에 받는 변화 수 — created 를 같은 요청의 Email/get 으로 읽으므로 엔진의 maxObjectsInGet(500) 이하 */
const LIVE_MAX_CHANGES = 500;
/** 한 번 알릴 때 Email/changes 를 이어 묻는 횟수 — 넘게 바뀌었으면(대량 가져오기 등) 이번에는 새 메일을 알리지 않는다 */
const LIVE_MAX_CHANGE_PAGES = 4;
/** Email/changes 의 메서드 오류 — 그 상태부터는 셀 수 없다 (너무 오래됨 등, RFC 8620 §5.2) */
const CANNOT_CALCULATE_CHANGES = 'cannotCalculateChanges';
/** 엔진이 요청 전체를 거부했다(HTTP 400 notRequest) — jmap.client.ts 의 call 이 invalidRequestCode 로 바꿔 던진 것 */
function isRejectedRequest(error: unknown): boolean {
return (
error instanceof BusinessException &&
error.context?.reason === 'request-rejected'
);
}
function findInbox(mailboxes: readonly JmapMailbox[]): JmapMailbox | undefined {
return mailboxes.find((mailbox) => mailbox.role?.toLowerCase() === 'inbox');
}
/** 실시간 알림의 상태 — 받은편지함이 없으면 안 읽은 수 0 */
function toLiveState(
states: MailStatesRead,
inbox: JmapMailbox | undefined,
): MailLiveState {
return { state: states.state, inboxUnread: inbox?.unreadEmails ?? 0 };
}
/** 메일함 · 메일 상태를 읽는 호출 ('m' · 'e' — readMailStates 가 읽는다). 받은편지함을 알면 그것만 읽는다 */
function liveStateCalls(
accountId: string,
inboxId: string | null,
): JmapInvocation[] {
return [
[
'Mailbox/get',
{
accountId,
ids: inboxId === null ? null : [inboxId],
properties: LIVE_MAILBOX_PROPERTIES,
},
'm',
],
['Email/get', { accountId, ids: [] }, 'e'],
];
}
/** 메일에 함께 싣는 그림 — 엔진에 올린 blob */
interface UploadedInlineImage {
readonly blobId: string;
@@ -1188,22 +1283,29 @@ export class JmapMailProvider implements MailProvider {
['Mailbox/get', { accountId, ids: [] }, 'm'],
['Email/get', { accountId, ids: [] }, 'e'],
]);
const mailboxes = expectResult<JmapGetResult<JmapMailbox>>(
responses,
'm',
'Mailbox/get',
// 실시간 알림의 state 도 이 함수로 만든다
return { state: readMailStates(responses).state };
}
/**
* 실시간 알림 (계약 2절 "실시간 알림") — 엔진의 EventSource(대리 접근 · types=Mailbox,Email · ping=30 · closeafter=no)를 하나 연다.
* 연결 · 다시 잇기 · 무소식 감시 · 신호 모으기는 JmapMailWatch, 엔진에 묻는 모양은 readLiveSnapshot · readLiveChanges 다.
*/
watchMail(user: string, listener: MailWatchListener): Promise<MailWatch> {
return JmapMailWatch.start(
{
open: (signal) =>
this.jmap.openEventSource(
user,
{ types: PUSH_TYPES, pingSeconds: PUSH_PING_SECONDS },
signal,
),
snapshot: (timeoutMs) => this.readLiveSnapshot(user, timeoutMs),
changes: (position) => this.readLiveChanges(user, position),
},
listener,
maskEmail(user),
);
const emails = expectResult<JmapGetResult<JmapEmail>>(
responses,
'e',
'Email/get',
);
if (!isState(mailboxes.state) || !isState(emails.state)) {
throw new BusinessException('MAIL_001', {
context: { reason: 'mail-state-missing' },
});
}
return { state: combineMailStates(mailboxes.state, emails.state) };
}
async listMessages(
@@ -2392,6 +2494,164 @@ export class JmapMailProvider implements MailProvider {
return list;
}
/**
* 실시간 알림의 처음 상태 — 요청 한 번: 모든 메일함(받은편지함을 찾는다) · 메일 상태 · 발신 ID(본인 주소).
* 상태는 getMailState 와 같은 함수(readMailStates)로 만든다. 발신 ID 를 읽지 못하면 직원 주소만 본인으로 본다.
* `timeoutMs` 는 처음 연결의 남은 시간 — 세션 조회까지 그 안에서 한다 (클라이언트가 응답 머리를 기다린다). 없으면 기본 제한 시간
*/
private async readLiveSnapshot(
user: string,
timeoutMs?: number,
): Promise<WatchReading> {
const responses = await this.jmap.call(
user,
(accountId) => [
...liveStateCalls(accountId, null),
['Identity/get', { accountId, ids: null, properties: ['email'] }, 'i'],
],
timeoutMs === undefined ? {} : { timeoutMs },
);
const states = readMailStates(responses);
const inbox = findInbox(states.mailboxes);
const identities = findResult(responses, 'i', 'Identity/get');
const identityEmails = identities.ok
? ((identities.args as JmapGetResult<JmapIdentity>).list ?? []).map(
(identity) => identity.email,
)
: [];
const ownAddresses = new Set(
[user, ...identityEmails].flatMap((email) =>
typeof email === 'string' && email.trim() !== ''
? [email.trim().toLowerCase()]
: [],
),
);
return {
live: toLiveState(states, inbox),
position: {
emailState: states.emailState,
inboxId: inbox?.id ?? null,
ownAddresses,
},
newMail: null,
};
}
/**
* 실시간 알림의 변화 — 기준(지난 메일 상태)부터 생긴 메일을 엔진이 알려 준 대로(Email/changes 의 created) 읽어 새 메일을 고르고,
* 같은 요청에서 지금 상태를 읽는다 (요청 한 번에 Email/changes · Email/get(#created) · 받은편지함 · 메일 상태).
* - 생성은 배달이 아니다 — 변화를 본 시각보다 10분 넘게 앞서 받은 메일(IMAP 으로 올린 옛 메일 · 옮겨 넣은 메일)은 고르지 않는다 (new-mail.ts)
* - 변화가 한 번에 다 오지 않으면(hasMoreChanges) 이어 묻는다 — LIVE_MAX_CHANGE_PAGES 번을 넘으면 이번에는 새 메일 없이
* - 셀 수 없으면(cannotCalculateChanges) 새 메일 없이 상태만 — 다음 기준은 지금 상태
* - 다음 기준은 마지막 Email/changes 의 newState — 같은 요청에서 그 뒤에 생긴 메일은 다음에 센다(놓치지 않는다)
* - 받은편지함을 찾지 못하면(id 가 바뀜) 모든 메일함에서 다시 찾는다
* - 엔진이 요청 전체를 거부하면(400 notRequest) 셀 수 없는 것과 같게 — 처음 상태를 다시 잡아 새 기준으로 (새 메일 없이).
* Stalwart 0.16.27 은 해석하지 못하는 sinceState(엔진 판 · 데이터가 바뀐 뒤의 옛 기준 등)를 cannotCalculateChanges 가 아니라
* 이렇게 답한다 (2026-10-28 개발 엔진에서 확인). 그대로 던지면 다시 이을 때마다 같은 기준으로 거부되어 끝없이 다시 잇는다
*/
private async readLiveChanges(
user: string,
position: WatchPosition,
): Promise<WatchReading> {
try {
return await this.countLiveChanges(user, position);
} catch (error) {
if (!isRejectedRequest(error)) throw error;
return this.readLiveSnapshot(user);
}
}
/** readLiveChanges 의 본체 — 엔진이 요청을 거부하면 `request-rejected` 로 던진다 */
private async countLiveChanges(
user: string,
position: WatchPosition,
): Promise<WatchReading> {
const created: JmapEmail[] = [];
let since = position.emailState;
let countable = true;
let responses: JmapInvocation[] = [];
for (let page = 1; ; page += 1) {
const sinceState = since;
responses = await this.jmap.call(
user,
(accountId) => [
[
'Email/changes',
{ accountId, sinceState, maxChanges: LIVE_MAX_CHANGES },
'c',
],
[
'Email/get',
{
accountId,
'#ids': {
resultOf: 'c',
name: 'Email/changes',
path: '/created',
},
properties: NEW_MAIL_PROPERTIES,
},
'n',
],
...liveStateCalls(accountId, position.inboxId),
],
// 요청 전체 거부를 엔진 장애와 가른다 (readLiveChanges 가 받는다)
{ invalidRequestCode: 'MAIL_001' },
);
const changes = findResult(responses, 'c', 'Email/changes');
if (!changes.ok) {
if (changes.error.type !== CANNOT_CALCULATE_CHANGES) {
throw jmapFailure(changes.error, { method: 'Email/changes' });
}
countable = false;
break;
}
const result = changes.args as JmapChangesResult;
if (!isState(result.newState)) {
throw new BusinessException('MAIL_001', {
context: { reason: 'changes-state-missing' },
});
}
const { list = [] } = expectResult<JmapGetResult<JmapEmail>>(
responses,
'n',
'Email/get',
);
created.push(...list);
since = result.newState;
if (result.hasMoreChanges !== true) break;
if (page >= LIVE_MAX_CHANGE_PAGES) {
countable = false;
break;
}
}
const states = readMailStates(responses);
let inbox = findInbox(states.mailboxes);
if (!inbox && position.inboxId !== null) {
inbox = findInbox(await this.loadMailboxes(user));
}
return {
live: toLiveState(states, inbox),
position: {
...position,
emailState: countable ? since : states.emailState,
inboxId: inbox?.id ?? null,
},
// 변화를 본 시각 — 그보다 오래전에 받은 메일(IMAP 으로 올린 옛 메일 등)은 알리지 않는다 (new-mail.ts)
newMail:
countable && inbox
? selectNewMail(created, inbox.id, position.ownAddresses, Date.now())
: null,
};
}
/**
* 메일함의 메일 한 차례(최대 BULK_BATCH_SIZE 통) — id · 들어 있는 메일함과 그때의 Email 상태(ifInState 에 쓴다),
* 이 메일함의 전체 수. 조회와 읽기를 한 요청으로 한다 (같은 순간의 상태). 그 메일함에 든 것만 돌려준다
@@ -0,0 +1,51 @@
import { isMailStateChange } from './jmap-push';
/** Stalwart 0.16.27 의 StateChange 모양 (2026-10-28 개발 엔진에서 받은 것) */
const stateChange = (changed: Record<string, Record<string, string>>) =>
JSON.stringify({ '@type': 'StateChange', changed });
describe('isMailStateChange', () => {
it('이 계정의 메일 · 메일함 변화면 true — 배달처럼 여러 종류가 함께 와도', () => {
expect(
isMailStateChange(
stateChange({
d: {
Thread: 's1',
Mailbox: 's2',
EmailDelivery: 's3',
Email: 's4',
},
}),
'd',
),
).toBe(true);
expect(isMailStateChange(stateChange({ d: { Mailbox: 's2' } }), 'd')).toBe(
true,
);
});
it('다른 계정(공유 메일함)만의 변화 · 메일 · 메일함이 아닌 종류만의 변화는 false', () => {
expect(isMailStateChange(stateChange({ x: { Email: 's1' } }), 'd')).toBe(
false,
);
expect(
isMailStateChange(
stateChange({ d: { Thread: 's1', EmailDelivery: 's2' } }),
'd',
),
).toBe(false);
});
it('형식이 틀린 data 는 false — JSON 아님 · @type 다름 · changed 없음', () => {
for (const data of [
'not json',
'{"interval": 30}',
JSON.stringify({ '@type': 'Other', changed: { d: { Email: 's1' } } }),
JSON.stringify({ '@type': 'StateChange' }),
JSON.stringify({ '@type': 'StateChange', changed: { d: 'Email' } }),
'null',
]) {
expect(isMailStateChange(data, 'd')).toBe(false);
}
});
});
@@ -0,0 +1,47 @@
/**
* 메일 엔진의 변화 소식(JMAP push — RFC 8620 7) 해석 (순수 함수).
*
* Stalwart 0.16.27 의 EventSource (2026-10-28 개발 엔진에서 확인):
* - 변화: `event: state` / `data: {"@type":"StateChange","changed":{"<accountId>":{"Email":"…","Mailbox":"…", …}}}`
* - 연결 유지: `event: ping` / `data: {"interval": 30}` — 30초보다 짧게 오지 않는다(10을 달라고 해도 30초, 0 이면 오지 않는다)
* - 연결 직후에는 아무것도 오지 않는다 (첫 변화나 첫 ping 까지 조용하다)
* - 초안을 만들어도 `EmailDelivery` 가 바뀐다 — 새 메일 판단에 쓰지 않는다 (new-mail.ts 는 Email/changes 로 한다)
*/
/** 엔진에 달라는 종류 — 메일함 · 메일 (계약 2절 "실시간 알림") */
export const PUSH_TYPES = ['Mailbox', 'Email'] as const;
/** 엔진의 연결 유지 간격(초) — 엔진이 받아 주는 가장 짧은 값 */
export const PUSH_PING_SECONDS = 30;
/** 변화 소식의 SSE 이벤트 이름 (ping 은 연결 유지) */
export const PUSH_STATE_EVENT = 'state';
const STATE_CHANGE_TYPE = 'StateChange';
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
/**
* `state` 이벤트의 data 가 이 계정의 메일함 · 메일 변화인가.
* 다른 계정(공유 메일함)만의 변화 · 다른 종류만의 변화(Thread 등) · 형식이 틀린 data 는 false —
* 형식이 틀려도 연결은 살아 있는 것으로 본다(끊김은 받은 바이트로 잰다).
*/
export function isMailStateChange(data: string, accountId: string): boolean {
let parsed: unknown;
try {
parsed = JSON.parse(data);
} catch {
return false;
}
if (!isRecord(parsed) || parsed['@type'] !== STATE_CHANGE_TYPE) return false;
const { changed } = parsed;
if (!isRecord(changed)) return false;
const account = changed[accountId];
if (!isRecord(account)) return false;
return PUSH_TYPES.some((type) =>
Object.prototype.hasOwnProperty.call(account, type),
);
}
@@ -32,6 +32,8 @@ const sessionBody = {
downloadUrl:
'http://localhost:8180/jmap/download/{accountId}/{blobId}/{name}?accept={type}',
uploadUrl: 'http://localhost:8180/jmap/upload/{accountId}/',
eventSourceUrl:
'http://localhost:8180/jmap/eventsource/?types={types}&closeafter={closeafter}&ping={ping}',
primaryAccounts: { 'urn:ietf:params:jmap:mail': 'd' },
};
@@ -448,6 +450,8 @@ describe('JmapClient', () => {
uploadPath: '/jmap/upload/{accountId}/',
// 계정 기능을 알려 주지 않는 응답 — 엔진 한도를 모르는 것으로 둔다
maxAttachmentsBytes: null,
eventSourcePath:
'/jmap/eventsource/?types={types}&closeafter={closeafter}&ping={ping}',
});
});
@@ -875,6 +879,197 @@ describe('JmapClient', () => {
});
});
describe('변화 소식 스트림 (openEventSource)', () => {
const OPTIONS = { types: ['Mailbox', 'Email'], pingSeconds: 30 } as const;
/** Stalwart 의 EventSource 응답 — text/event-stream, 본문은 닫을 때까지 이어진다 */
const eventStream = (text = '') =>
new Response(text, {
status: 200,
headers: { 'content-type': 'text/event-stream' },
});
const errorLogOf = () =>
jest
.spyOn(
Reflect.get(client, 'logger') as { error: (message: string) => void },
'error',
)
.mockImplementation(() => undefined);
it('세션의 eventSourceUrl 경로를 내부 주소에 붙여 대리 접근 자격으로 연다 — types 는 쉼표로 잇고 closeafter=no · ping', async () => {
fetchMock
.mockResolvedValueOnce(json(sessionBody))
.mockResolvedValueOnce(eventStream('event: ping\ndata: {}\n\n'));
const stream = await client.openEventSource(
ARA,
OPTIONS,
new AbortController().signal,
);
const { url, init, authorization } = requestAt(1);
expect(url).toBe(
'http://mail:8080/jmap/eventsource/?types=Mailbox,Email&closeafter=no&ping=30',
);
expect(decodeBasic(authorization)).toBe(
`${ARA}%mail-gateway@arasystem.test:gateway-secret`,
);
expect((init.headers as Record<string, string>).accept).toBe(
'text/event-stream',
);
expect(init.redirect).toBe('manual');
expect(stream.accountId).toBe('d');
const decoder = new TextDecoder();
let text = '';
for await (const chunk of stream.body) text += decoder.decode(chunk);
expect(text).toBe('event: ping\ndata: {}\n\n');
});
it('응답 머리까지만 제한 시간(10초)을 둔다 — 그 뒤 본문은 넘긴 signal 로 닫을 때까지 이어진다', async () => {
jest.useFakeTimers({
doNotFake: ['nextTick', 'queueMicrotask', 'setImmediate'],
});
fetchMock
.mockResolvedValueOnce(json(sessionBody))
.mockResolvedValueOnce(eventStream());
const reader = new AbortController();
await client.openEventSource(ARA, OPTIONS, reader.signal);
const { signal } = requestAt(1).init;
await jest.advanceTimersByTimeAsync(120_000);
expect(signal?.aborted).toBe(false);
reader.abort();
expect(signal?.aborted).toBe(true);
});
it('응답 머리가 10초 안에 오지 않으면 MAIL_001 (timeout) · 읽는 쪽이 먼저 닫으면 closed', async () => {
jest.useFakeTimers();
const hang = (_url: string, init: RequestInit) =>
new Promise<Response>((_resolve, reject) => {
init.signal?.addEventListener('abort', () =>
reject(new DOMException('aborted', 'AbortError')),
);
});
fetchMock
.mockResolvedValueOnce(json(sessionBody))
.mockImplementationOnce(hang);
const pending = client
.openEventSource(ARA, OPTIONS, new AbortController().signal)
.catch((error: unknown) => error);
await jest.advanceTimersByTimeAsync(10_000);
expect(((await pending) as BusinessException).context).toMatchObject({
reason: 'timeout',
call: 'event-source',
});
// 세션은 캐시에 있다 — 이번에는 스트림 요청만
fetchMock.mockImplementationOnce(hang);
const reader = new AbortController();
const closed = client
.openEventSource(ARA, OPTIONS, reader.signal)
.catch((error: unknown) => error);
await jest.advanceTimersByTimeAsync(1_000);
reader.abort();
const error = (await closed) as BusinessException;
expect(error.code).toBe('MAIL_001');
expect(error.context).toMatchObject({ reason: 'closed' });
});
it('이 경로를 알기 전에 캐시한 세션이면 세션을 다시 받는다 — 엔진이 알려 주지 않으면 스트림을 요청하지 않고 MAIL_001', async () => {
const { eventSourceUrl: _omitted, ...withoutEventSource } = sessionBody;
void _omitted;
await cache.set(`mail:jmap-session:${ARA}`, {
username: ARA,
accountId: 'd',
apiPath: '/jmap/',
downloadPath:
'/jmap/download/{accountId}/{blobId}/{name}?accept={type}',
uploadPath: '/jmap/upload/{accountId}/',
});
fetchMock
.mockResolvedValueOnce(json(sessionBody))
.mockResolvedValueOnce(eventStream());
await client.openEventSource(ARA, OPTIONS, new AbortController().signal);
expect(requestAt(0).url).toBe('http://mail:8080/jmap/session');
expect(requestAt(1).url).toContain('/jmap/eventsource/');
fetchMock.mockReset();
// 부를 때마다 새 응답 — Response 본문은 한 번만 읽힌다
fetchMock.mockImplementation(() =>
Promise.resolve(json(withoutEventSource)),
);
const other = 'doyun.lee@arasystem.test';
const error = (await client
.openEventSource(other, OPTIONS, new AbortController().signal)
.catch((caught: unknown) => caught)) as BusinessException;
expect(error.code).toBe('MAIL_001');
expect(error.context).toMatchObject({ reason: 'event-source-missing' });
expect(
(fetchMock.mock.calls as [string][]).every(([url]) =>
url.endsWith('/jmap/session'),
),
).toBe(true);
});
it('대리 접근 401 · 403 은 다른 호출과 같은 분류 · 로그 — 계정이 사라짐 AUTH_001, 대리 권한 없음 MAIL_001 + error 로그', async () => {
const errorLog = errorLogOf();
fetchMock
.mockResolvedValueOnce(json(sessionBody))
.mockResolvedValueOnce(json({}, 401))
// 게이트웨이 자신은 들어간다 — 계정이 사라졌다
.mockResolvedValueOnce(json({}, 200));
expect(
await codeOf(
client.openEventSource(ARA, OPTIONS, new AbortController().signal),
),
).toBe('AUTH_001');
fetchMock
.mockResolvedValueOnce(json(sessionBody))
.mockResolvedValueOnce(json({}, 403));
const code = await runWithRequestContext({ requestId: 'trace-es' }, () =>
codeOf(
client.openEventSource(ARA, OPTIONS, new AbortController().signal),
),
);
expect(code).toBe('MAIL_001');
expect(errorLog).toHaveBeenCalledWith(
expect.stringMatching(
/^\[MAIL_001\]\[trace-es\] .*대리 접근 권한이 없습니다/,
),
);
});
it('엔진 한도 429 는 REQ_001 · 5xx · text/event-stream 이 아닌 답은 MAIL_001', async () => {
fetchMock.mockResolvedValueOnce(json(sessionBody));
fetchMock.mockResolvedValueOnce(json({}, 429));
expect(
await codeOf(
client.openEventSource(ARA, OPTIONS, new AbortController().signal),
),
).toBe('REQ_001');
fetchMock.mockResolvedValueOnce(json({}, 502));
expect(
await codeOf(
client.openEventSource(ARA, OPTIONS, new AbortController().signal),
),
).toBe('MAIL_001');
fetchMock.mockResolvedValueOnce(json({ ok: true }));
const error = (await client
.openEventSource(ARA, OPTIONS, new AbortController().signal)
.catch((caught: unknown) => caught)) as BusinessException;
expect(error.code).toBe('MAIL_001');
expect(error.context).toMatchObject({ reason: 'not-event-stream' });
});
});
describe('첨부 업로드·다운로드', () => {
beforeEach(() => {
fetchMock.mockResolvedValueOnce(json(sessionBody));
+116 -1
View File
@@ -152,6 +152,13 @@ export const JMAP_INLINE_UPLOAD_TIMEOUT_MS = 15_000;
const DOWNLOAD_HEADERS_TIMEOUT_MS = 15_000;
/** 작은 부분(본문 내장 이미지)을 메모리로 읽을 때의 전체 제한 시간 */
const SMALL_DOWNLOAD_TIMEOUT_MS = 10_000;
/**
* 변화 소식 스트림은 응답 머리까지만 제한한다 — 엔진은 연결 직후 아무것도 보내지 않고(첫 변화나 첫 ping 까지 조용하다,
* 2026-10-28 확인) 본문은 닫을 때까지 이어진다. 끊김은 읽는 쪽이 무소식 시간으로 안다 (jmap-mail-watch.ts)
*/
const EVENT_SOURCE_HEADERS_TIMEOUT_MS = 10_000;
/** 변화 소식 스트림의 형식 — 다른 형식으로 답하면(엔진 설정 · 앞단 문제) 엔진 장애로 본다 */
const EVENT_STREAM_TYPE = /^text\/event-stream\s*(?:;|$)/i;
/** 직원별 JMAP 세션 캐시 — 계정 삭제가 늦게 반영되는 상한이기도 하다 */
const SESSION_CACHE_TTL_MS = 5 * 60_000;
@@ -175,6 +182,27 @@ export interface JmapSession {
readonly uploadPath: string;
/** 엔진의 메일 한 통 첨부 합계 한도(바이트) — 알려 주지 않으면 null (캐시된 옛 세션에는 없을 수 있다) */
readonly maxAttachmentsBytes?: number | null;
/**
* 변화 소식 스트림(EventSource) 경로 템플릿 — `{types}` · `{closeafter}` · `{ping}` 이 남아 있다.
* 알려 주지 않으면 null (캐시된 옛 세션에는 없을 수 있다 — openEventSource 가 세션을 다시 받는다)
*/
readonly eventSourcePath?: string | null;
}
/** 엔진의 변화 소식 스트림(JMAP EventSource)을 열 때 정하는 것 */
export interface JmapEventSourceOptions {
/** 받을 종류 — Mailbox · Email 등 (RFC 8620 7.3 의 types) */
readonly types: readonly string[];
/** 엔진이 연결 유지(ping)를 보내는 간격(초) — 0 이면 보내지 않는다 */
readonly pingSeconds: number;
}
/** 열린 변화 소식 스트림 — 본문은 openEventSource 에 넘긴 signal 로 닫을 때까지 이어진다 */
export interface JmapEventStream {
/** 이 세션의 메일 계정 id — StateChange 의 `changed` 에서 이 계정을 찾는다 */
readonly accountId: string;
/** text/event-stream 본문 (받은 바이트 그대로) */
readonly body: AsyncIterable<Uint8Array>;
}
export interface JmapDownload {
@@ -791,6 +819,89 @@ export class JmapClient {
};
}
/**
* 그 직원의 변화 소식 스트림(JMAP EventSource, RFC 8620 7.3)을 연다 — 대리 접근 자격, 실시간 알림이 쓴다 (계약 2절 "실시간 알림").
* - 주소는 세션의 `eventSourceUrl` 경로를 MAIL_JMAP_URL 에 붙인다(다른 세션 주소와 같다). `closeafter=no` — 닫을 때까지 이어진다
* - `types` 는 이름마다 인코딩하고 쉼표로 잇는다 (쉼표는 쿼리에 그대로 둘 수 있는 구분자다)
* - 응답 머리까지만 제한 시간(EVENT_SOURCE_HEADERS_TIMEOUT_MS)을 두고, 본문은 `signal` 로 닫을 때까지 읽는다 —
* 일반 호출의 10초 제한을 쓰지 않는다
* - 401 · 403 은 다른 호출과 같은 분류 · 로그(assertImpersonationAccepted), 429 는 REQ_001,
* 그 밖의 실패 · text/event-stream 이 아닌 답은 MAIL_001
*/
async openEventSource(
user: string,
options: JmapEventSourceOptions,
signal: AbortSignal,
): Promise<JmapEventStream> {
let session = await this.getSession(user);
// 이 경로를 알기 전에 캐시한 세션이면 다시 받는다
if (!session.eventSourcePath) {
session = await this.getSession(user, { fresh: true });
}
if (!session.eventSourcePath) {
throw engineFailure({
reason: 'event-source-missing',
call: 'event-source',
});
}
const path = expandTemplate(session.eventSourcePath, {
closeafter: 'no',
ping: String(options.pingSeconds),
}).replace('{types}', options.types.map(encodeURIComponent).join(','));
const { jmapBaseUrl } = this.requireConfigured();
const authorization = this.impersonation(user);
// 머리 제한 시간과 읽는 쪽의 닫기를 한 신호로 — 본문도 이 신호로 닫힌다
const controller = new AbortController();
const close = () => controller.abort();
if (signal.aborted) close();
else signal.addEventListener('abort', close, { once: true });
const timer = setTimeout(close, EVENT_SOURCE_HEADERS_TIMEOUT_MS);
let response: Response;
try {
response = await fetch(`${jmapBaseUrl}${path}`, {
headers: { authorization, accept: 'text/event-stream' },
// 엔진이 다른 곳으로 보내면 따라가지 않는다 (인증 헤더가 다른 호스트로 가지 않게)
redirect: 'manual',
signal: controller.signal,
});
} catch (error) {
signal.removeEventListener('abort', close);
// 읽는 쪽이 닫았으면 closed, 머리를 기다리다 끊었으면 timeout
const reason = signal.aborted
? 'closed'
: controller.signal.aborted
? 'timeout'
: 'network';
throw engineFailure({
reason,
call: 'event-source',
cause: describeError(error),
});
} finally {
clearTimeout(timer);
}
try {
await this.assertImpersonationAccepted(response, user);
await this.assertOk(response, 'event-source');
const type = response.headers.get('content-type') ?? '';
if (!EVENT_STREAM_TYPE.test(type) || !response.body) {
await discard(response);
throw engineFailure({
reason: 'not-event-stream',
call: 'event-source',
contentType: type,
});
}
return { accountId: session.accountId, body: response.body };
} catch (error) {
signal.removeEventListener('abort', close);
throw error;
}
}
/**
* 작은 blob 을 메모리로 읽는다. `maxBytes` 를 넘으면 즉시 멈추고 null.
* (본문 내장 이미지를 data: URI 로 넣을 때 쓴다)
@@ -1001,7 +1112,7 @@ export class JmapClient {
private toSession(resource: JmapSessionResource, user: string): JmapSession {
const accountId = resource.primaryAccounts?.[MAIL_CAPABILITY];
const { apiUrl, downloadUrl, uploadUrl } = resource;
const { apiUrl, downloadUrl, uploadUrl, eventSourceUrl } = resource;
if (!accountId || !apiUrl || !downloadUrl || !uploadUrl) {
throw engineFailure({ reason: 'session-incomplete', call: 'session' });
@@ -1013,6 +1124,10 @@ export class JmapClient {
downloadPath: toRelativeReference(downloadUrl),
uploadPath: toRelativeReference(uploadUrl),
maxAttachmentsBytes: readAttachmentsLimit(resource, accountId),
// 실시간 알림만 쓴다 — 없다고 다른 기능까지 막지 않는다 (openEventSource 가 MAIL_001)
eventSourcePath: eventSourceUrl
? toRelativeReference(eventSourceUrl)
: null,
};
}
}
@@ -24,6 +24,8 @@ export interface JmapSessionResource {
apiUrl?: string;
downloadUrl?: string;
uploadUrl?: string;
/** 변화 소식 스트림 주소 템플릿 (RFC 8620 7.3) — `{types}` · `{closeafter}` · `{ping}` */
eventSourceUrl?: string;
primaryAccounts?: Record<string, string>;
/** 계정별 기능·한도 — 메일 기능의 `maxSizeAttachmentsPerEmail` 를 읽는다 */
accounts?: Record<
@@ -122,6 +124,17 @@ export interface JmapGetResult<T> {
notFound?: string[];
}
/** Foo/changes 결과 (RFC 8620 §5.2) — sinceState 부터 생기고 · 바뀌고 · 없어진 id */
export interface JmapChangesResult {
oldState?: string;
newState?: string;
/** true 면 maxChanges 에 걸려 남은 변화가 있다 — newState 부터 다시 묻는다 */
hasMoreChanges?: boolean;
created?: string[];
updated?: string[];
destroyed?: string[];
}
export interface JmapSetResult {
/** 이 요청이 바꾸기 전의 상태 (엔진이 모르면 null) */
oldState?: string | null;
@@ -0,0 +1,174 @@
import {
NEW_MAIL_MAX_AGE_MS,
NEW_MAIL_PREVIEW_LIMIT,
selectNewMail,
} from './new-mail';
import type { JmapEmail } from './jmap.types';
const INBOX = 'a';
const ME = 'ara.kim@arasystem.test';
const OWN = new Set([ME, 'ara@alias.arasystem.test']);
/** 변화를 본 시각 — 아래 메일들은 그 몇 분 전에 받았다 */
const NOW = Date.parse('2026-10-28T05:45:00Z');
/** 받은편지함에 막 배달된 안 읽은 메일 — 필드를 바꿔 가며 거르는지 본다 */
const delivered = (
id: string,
receivedAt: string,
extra: Partial<JmapEmail> = {},
): JmapEmail => ({
id,
mailboxIds: { [INBOX]: true },
keywords: {},
from: [{ name: '박서준', email: 'seojun.park@arasystem.test' }],
subject: `제목 ${id}`,
receivedAt,
...extra,
});
describe('selectNewMail', () => {
it('받은편지함에 있고 · 초안이 아니고 · 읽지 않았고 · 내가 보내지 않은 메일만 — 다른 메일함 · 초안 · 읽음 · 본인(별칭 · 대소문자 포함)은 뺀다', () => {
const notice = selectNewMail(
[
delivered('m1', '2026-10-28T05:40:00Z'),
delivered('draft', '2026-10-28T05:41:00Z', {
keywords: { $draft: true },
}),
delivered('seen', '2026-10-28T05:41:00Z', {
keywords: { $seen: true },
}),
delivered('mine', '2026-10-28T05:41:00Z', {
from: [{ name: '김아라', email: 'ARA.KIM@arasystem.test' }],
}),
delivered('alias', '2026-10-28T05:41:00Z', {
from: [{ name: null, email: 'ara@alias.arasystem.test' }],
}),
delivered('spam', '2026-10-28T05:41:00Z', {
mailboxIds: { c: true },
}),
delivered('removed', '2026-10-28T05:41:00Z', {
mailboxIds: { [INBOX]: false },
}),
],
INBOX,
OWN,
NOW,
);
expect(notice).toEqual({
count: 1,
messages: [
{
id: 'm1',
from: { name: '박서준', email: 'seojun.park@arasystem.test' },
subject: '제목 m1',
receivedAt: '2026-10-28T05:40:00.000Z',
},
],
});
});
it('받은 시각 새것부터 최대 5통 · count 는 거른 전체 수 · 받은편지함과 다른 메일함에 함께 있어도 알린다', () => {
const emails = Array.from({ length: 7 }, (_, index) =>
delivered(`m${index}`, `2026-10-28T05:4${index}:00Z`, {
mailboxIds: { [INBOX]: true, archive: true },
}),
);
const notice = selectNewMail(emails, INBOX, OWN, NOW);
expect(notice?.count).toBe(7);
expect(notice?.messages.map((message) => message.id)).toEqual([
'm6',
'm5',
'm4',
'm3',
'm2',
]);
expect(notice?.messages).toHaveLength(NEW_MAIL_PREVIEW_LIMIT);
});
it('주소 · 제목은 메일 목록과 같은 규칙 — 이름이 없으면 name null, 보낸 사람이 없으면 from null, 제목이 없으면 빈 문자열', () => {
const notice = selectNewMail(
[
delivered('noname', '2026-10-28T05:42:00Z', {
from: [{ name: ' ', email: 'client@partner.test' }],
subject: null,
}),
delivered('nofrom', '2026-10-28T05:41:00Z', { from: null }),
],
INBOX,
OWN,
NOW,
);
expect(notice?.messages).toEqual([
{
id: 'noname',
from: { name: null, email: 'client@partner.test' },
subject: '',
receivedAt: '2026-10-28T05:42:00.000Z',
},
{
id: 'nofrom',
from: null,
subject: '제목 nofrom',
receivedAt: '2026-10-28T05:41:00.000Z',
},
]);
});
it('알릴 것이 없으면 null', () => {
expect(selectNewMail([], INBOX, OWN, NOW)).toBeNull();
expect(
selectNewMail(
[
delivered('seen', '2026-10-28T05:00:00Z', {
keywords: { $seen: true },
}),
],
INBOX,
OWN,
NOW,
),
).toBeNull();
});
it(`변화를 본 시각보다 ${NEW_MAIL_MAX_AGE_MS / 60_000}분 넘게 앞서 받은 메일은 뺀다 — IMAP 으로 올린 옛 메일 · 메일 서버를 옮길 때(imapsync) 넣는 메일이 알림으로 쏟아지지 않게`, () => {
const notice = selectNewMail(
[
delivered('fresh', '2026-10-28T05:44:00Z'),
// 딱 10분 전은 넣는다
delivered('edge', new Date(NOW - NEW_MAIL_MAX_AGE_MS).toISOString()),
delivered(
'late',
new Date(NOW - NEW_MAIL_MAX_AGE_MS - 1).toISOString(),
),
// 옮겨 온 옛 메일 (받은 시각이 원래 값으로 남는다)
delivered('migrated', '2025-03-02T01:00:00Z'),
// 받은 시각을 읽지 못한 메일
delivered('unknown', 'not a date'),
// 엔진 시계가 조금 앞서도 새 메일이다
delivered('ahead', '2026-10-28T05:46:00Z'),
],
INBOX,
OWN,
NOW,
);
expect(notice?.count).toBe(3);
expect(notice?.messages.map(({ id }) => id)).toEqual([
'ahead',
'fresh',
'edge',
]);
expect(
selectNewMail(
[delivered('migrated', '2025-03-02T01:00:00Z')],
INBOX,
OWN,
NOW,
),
).toBeNull();
});
});
+80
View File
@@ -0,0 +1,80 @@
import type { NewMailMessage, NewMailNotice } from '@/modules/mail/mail.types';
import { toSummary } from './jmap.mapper';
import type { JmapEmail } from './jmap.types';
/**
* 새 메일 판단 (계약 2절 "실시간 알림"의 `new-mail`) — 순수 함수.
* 엔진의 Email/changes 가 알려 준 "지난 상태부터 생긴 메일"(created)을 받아 알릴 것만 고른다.
* 엔진의 EmailDelivery 신호는 초안을 만들어도 바뀌므로 쓰지 않는다 (2026-10-28 확인).
*/
/** 새로 생긴 메일에서 읽는 속성 — 고르는 데(메일함 · 키워드 · 보낸 사람)와 알림에 싣는 것(제목 · 받은 시각)만 */
export const NEW_MAIL_PROPERTIES = [
'id',
'mailboxIds',
'keywords',
'from',
'subject',
'receivedAt',
];
/** `new-mail` 에 싣는 최대 통수 — count 는 거른 전체 수다 */
export const NEW_MAIL_PREVIEW_LIMIT = 5;
/**
* 변화를 본 시각보다 이만큼 넘게 앞서 받은 메일은 알리지 않는다 (state 는 그대로 보낸다).
* Email/changes 의 created 는 "배달"이 아니라 "생성"이다 — 메일 프로그램이 IMAP 으로 올린(APPEND) 옛 메일,
* 메일 서버를 옮길 때(mailcow → 이 엔진, imapsync) 수천 통을 넣는 것도 생성이라 그대로 두면 알림이 쏟아진다.
* 그런 메일은 받은 시각(receivedAt — 옮겨도 원래 값이 남는다)이 오래됐다.
* 10분은 늦게 배달된 메일 · 끊겼다 다시 이은 동안 온 메일 · 5분 안전판(jmap-mail-watch.ts)으로 찾은 메일을 넉넉히 넣는다
*/
export const NEW_MAIL_MAX_AGE_MS = 10 * 60_000;
/** 알릴 메일인가 — 받은편지함에 있고 · `$draft` 아님 · `$seen` 아님 · 보낸 사람이 본인이 아님 */
function isNotifiable(
email: JmapEmail,
inboxId: string,
ownAddresses: ReadonlySet<string>,
): boolean {
if (email.mailboxIds?.[inboxId] !== true) return false;
if (email.keywords?.$draft === true || email.keywords?.$seen === true) {
return false;
}
const sender = email.from?.[0]?.email?.trim().toLowerCase();
return !(sender && ownAddresses.has(sender));
}
/** 받은 시각(ms) — 읽지 못하면 가장 오래된 것으로 */
function receivedTime(message: NewMailMessage): number {
const time = Date.parse(message.receivedAt);
return Number.isNaN(time) ? 0 : time;
}
/**
* 새로 생긴 메일 가운데 알릴 것 — 받은 시각 새것부터 NEW_MAIL_PREVIEW_LIMIT 통, count 는 거른 전체 수. 없으면 null.
* 주소 · 제목은 메일 목록과 같은 규칙으로 만든다(toSummary) — 이름이 없으면 name 은 null, 제목이 없으면 빈 문자열.
* @param ownAddresses 본인 주소(소문자) — 나에게 보낸 메일 · 내 다른 주소(발신 ID)로 보낸 메일은 알리지 않는다
* @param observedAt 변화를 본 시각(epoch ms) — 그보다 NEW_MAIL_MAX_AGE_MS 넘게 앞서 받은 메일은 뺀다 (받은 시각을 읽지 못한 메일도)
*/
export function selectNewMail(
emails: readonly JmapEmail[],
inboxId: string,
ownAddresses: ReadonlySet<string>,
observedAt: number,
): NewMailNotice | null {
const oldest = observedAt - NEW_MAIL_MAX_AGE_MS;
const messages = emails
.filter((email) => isNotifiable(email, inboxId, ownAddresses))
.map((email): NewMailMessage => {
const { id, from, subject, receivedAt } = toSummary(email);
return { id, from, subject, receivedAt };
})
.filter((message) => receivedTime(message) >= oldest)
.sort((a, b) => receivedTime(b) - receivedTime(a));
if (messages.length === 0) return null;
return {
count: messages.length,
messages: messages.slice(0, NEW_MAIL_PREVIEW_LIMIT),
};
}
@@ -0,0 +1,155 @@
import { EventEmitter } from 'events';
import type { Response } from 'express';
import type { AuthenticatedUser } from '@/common/decorators/current-user.decorator';
import {
MailEventsController,
eventStreamSink,
} from './mail-events.controller';
import type {
MailEventSink,
MailEventsOpenOptions,
MailEventsService,
} from './mail-events.service';
const USER: AuthenticatedUser = {
email: 'ara.kim@arasystem.test',
name: '김아라',
accessTokenExpiresAt: 1_793_000_000_000,
};
/** express 응답 대역 — 컨트롤러 · 연결이 쓰는 것만 */
class FakeResponse extends EventEmitter {
destroyed = false;
writableEnded = false;
headersSent = false;
statusCode = 0;
readonly headers = new Map<string, string>();
readonly written: string[] = [];
status(code: number): this {
this.statusCode = code;
return this;
}
setHeader(name: string, value: string): void {
this.headers.set(name.toLowerCase(), value);
}
flushHeaders(): void {
this.headersSent = true;
}
write(chunk: string): boolean {
this.written.push(chunk);
return true;
}
end(): void {
this.writableEnded = true;
this.emit('close');
}
}
const asResponse = (res: FakeResponse) => res as unknown as Response;
describe('MailEventsController', () => {
/** 서비스 대역 — 받은 연결 · 옵션을 모아 둔다 */
function controllerWithFakeService() {
const opened: { sink: MailEventSink; options: MailEventsOpenOptions }[] =
[];
const open = jest.fn(
(_user: string, sink: MailEventSink, options: MailEventsOpenOptions) => {
opened.push({ sink, options });
return Promise.resolve();
},
);
const controller = new MailEventsController({
open,
} as unknown as MailEventsService);
return { controller, open, opened };
}
it("처리기에 닿기 전에 끊긴 응답('close' 가 이미 지나감)은 열기 전에 끊긴 것으로 넘긴다", async () => {
const { controller, opened } = controllerWithFakeService();
const res = new FakeResponse();
res.destroyed = true;
await controller.stream(USER, asResponse(res));
expect(opened[0].options.signal.aborted).toBe(true);
// 두 번째 그물 — 그런 응답에는 쓰지 않고 false
expect(
opened[0].sink.send({ type: 'ping', data: { upstream: true } }),
).toBe(false);
expect(res.written).toEqual([]);
expect(res.headersSent).toBe(false);
});
it('연결한 뒤 클라이언트가 끊으면(res close) signal 을 끊는다 — 토큰 만료 시각을 넘긴다', async () => {
const { controller, open, opened } = controllerWithFakeService();
const res = new FakeResponse();
await controller.stream(USER, asResponse(res));
expect(open.mock.calls[0][0]).toBe(USER.email);
expect(opened[0].options.tokenExpiresAt).toBe(USER.accessTokenExpiresAt);
expect(opened[0].options.signal.aborted).toBe(false);
res.emit('close');
expect(opened[0].options.signal.aborted).toBe(true);
});
it('만료를 모르는 토큰이면 null 을 넘긴다 (서비스가 가장 긴 수명으로 닫는다)', async () => {
const { controller, opened } = controllerWithFakeService();
await controller.stream(
{ email: USER.email, name: USER.name },
asResponse(new FakeResponse()),
);
expect(opened[0].options.tokenExpiresAt).toBeNull();
});
});
describe('eventStreamSink (HTTP 응답 하나의 실시간 알림 연결)', () => {
it('처음 보낼 때 200 · 머리글을 바로 보내고(flushHeaders) 이벤트를 쓴다 — 머리글은 한 번만', () => {
const res = new FakeResponse();
const sink = eventStreamSink(asResponse(res));
expect(
sink.send({ type: 'state', data: { state: 's0', inboxUnread: 2 } }),
).toBe(true);
expect(sink.send({ type: 'ping', data: { upstream: true } })).toBe(true);
expect(res.statusCode).toBe(200);
expect(Object.fromEntries(res.headers)).toEqual({
'content-type': 'text/event-stream; charset=utf-8',
'cache-control': 'no-cache, no-transform',
'x-accel-buffering': 'no',
});
expect(res.headersSent).toBe(true);
expect(res.written).toEqual([
'event: state\ndata: {"state":"s0","inboxUnread":2}\n\n',
'event: ping\ndata: {"upstream":true}\n\n',
]);
});
it('끝났거나 끊긴 응답에는 쓰지 않고 false — 닫기는 한 번만 끝낸다', () => {
const ended = new FakeResponse();
const sink = eventStreamSink(asResponse(ended));
sink.close();
sink.close();
expect(ended.writableEnded).toBe(true);
expect(sink.send({ type: 'ping', data: { upstream: true } })).toBe(false);
const destroyed = new FakeResponse();
destroyed.destroyed = true;
expect(
eventStreamSink(asResponse(destroyed)).send({
type: 'end',
data: { reason: 'server-shutdown' },
}),
).toBe(false);
expect(destroyed.written).toEqual([]);
});
});
@@ -0,0 +1,126 @@
import { Controller, Get, HttpStatus, Res, UseGuards } from '@nestjs/common';
import {
ApiCookieAuth,
ApiOperation,
ApiProduces,
ApiResponse,
ApiTags,
} from '@nestjs/swagger';
import type { Response } from 'express';
import { ApiErrorResponse } from '@/common/decorators/api-standard-response.decorator';
import {
CurrentUser,
type AuthenticatedUser,
} from '@/common/decorators/current-user.decorator';
import { SkipTransform } from '@/common/decorators/skip-transform.decorator';
import { JwtAuthGuard } from '@/common/guards/jwt-auth.guard';
import { formatSseEvent } from '@/shared/utils/sse';
import {
MAIL_EVENTS_LINGER_MS,
MAIL_EVENTS_PING_MS,
MAX_MAIL_EVENT_CONNECTIONS,
MailEventsService,
TOKEN_EXPIRY_LEAD_MS,
type MailEventSink,
} from './mail-events.service';
/** 실시간 알림의 응답 형식 (계약 2절 "실시간 알림") */
const EVENT_STREAM_CONTENT_TYPE = 'text/event-stream; charset=utf-8';
/** 앞단(nginx · 외부 프록시)이 모아 두거나 고치지 않게 — 이벤트가 바로 가야 한다 */
const EVENT_STREAM_CACHE_CONTROL = 'no-cache, no-transform';
const SECONDS = 1000;
/**
* HTTP 응답 하나를 실시간 알림 연결로 — 처음 보낼 때 머리를 쓰고(그 전까지는 실패를 표준 JSON 으로 답할 수 있다),
* 끊긴 응답에는 쓰지 않고 false 를 돌려준다(서비스가 그 연결을 뺀다).
*/
export function eventStreamSink(res: Response): MailEventSink {
return {
send(event) {
if (res.writableEnded || res.destroyed) return false;
if (!res.headersSent) {
res.status(HttpStatus.OK);
res.setHeader('Content-Type', EVENT_STREAM_CONTENT_TYPE);
res.setHeader('Cache-Control', EVENT_STREAM_CACHE_CONTROL);
res.setHeader('X-Accel-Buffering', 'no');
res.flushHeaders();
}
res.write(formatSseEvent(event.type, event.data));
return true;
},
close() {
if (!res.writableEnded) res.end();
},
};
}
// 실시간 알림 (계약 2절 "실시간 알림") — 판단은 모두 MailEventsService 가 한다.
// 요청 수 한도는 다른 API 와 같다(SkipThrottle 하지 않는다) — 다시 잇기는 가끔이다(토큰 갱신 · 서버 재시작 · 끊김).
@ApiTags('Mail')
@ApiCookieAuth()
@UseGuards(JwtAuthGuard)
@Controller('mail-events')
export class MailEventsController {
constructor(private readonly events: MailEventsService) {}
@Get()
@SkipTransform()
@ApiOperation({
summary: '실시간 알림 — 새 메일 · 메일함 변화 (SSE, 표준 래퍼 없음)',
description:
'text/event-stream 으로 이어서 보낸다 — 각 이벤트는 `event: <이름>` · `data: <JSON 한 줄>` · 빈 줄. ' +
'이벤트: ' +
'`state` {state, inboxUnread} — 연결 직후 한 번, 그 뒤 메일함 · 메일이 바뀔 때마다(메일 엔진 신호를 0.3초 모아서 — 신호가 없어도 5분에 한 번 확인한다). ' +
'state 는 GET /api/mail-state 의 state 와 같은 값, inboxUnread 는 받은편지함의 안 읽은 수다. ' +
'`new-mail` {count, messages: [{id, from: {name, email} | null, subject, receivedAt}]} — 받은편지함에 새로 배달된 메일' +
'($draft · $seen · 내가 보낸 메일 · 변화를 본 시각보다 10분 넘게 앞서 받은 메일(옮겨 넣은 옛 메일 등)은 빼고), ' +
'받은 시각 새것부터 최대 5통(count 는 전체 수), 같은 변화의 state 다음에 온다. ' +
'from 의 name 은 없으면 null, subject 는 없으면 빈 문자열(메일 목록과 같다). ' +
`\`ping\` {upstream} — ${MAIL_EVENTS_PING_MS / SECONDS}초마다. upstream: false 면 서버와 메일 엔진 사이가 끊겨 다시 잇는 중이다` +
'(그동안 클라이언트는 1분 확인으로) — 바뀐 때는 바로 한 번 더 온다. ' +
`\`end\` {reason: token-expiring | server-shutdown} — 서버가 곧 닫는다(액세스 토큰이 끝나기 ${TOKEN_EXPIRY_LEAD_MS / SECONDS}초 전 · 서버 종료 · 재시작). ` +
`머리글: Content-Type: ${EVENT_STREAM_CONTENT_TYPE} · Cache-Control: ${EVENT_STREAM_CACHE_CONTROL} · X-Accel-Buffering: no. ` +
`직원마다 메일 엔진 연결 하나를 그 직원의 모든 연결이 나눠 쓴다(마지막 연결이 닫히고 ${MAIL_EVENTS_LINGER_MS / SECONDS}초 뒤 닫는다). ` +
`한 직원의 연결은 서버 인스턴스마다 ${MAX_MAIL_EVENT_CONNECTIONS}개까지다. 처음 state 를 보내기 전의 실패만 표준 실패 형식(JSON)이다.`,
})
@ApiProduces(EVENT_STREAM_CONTENT_TYPE)
@ApiResponse({
status: HttpStatus.OK,
description: '이벤트 스트림 (닫을 때까지 이어진다)',
schema: {
type: 'string',
example:
'event: state\ndata: {"state":"c2Iy.c2Fq","inboxUnread":3}\n\n' +
'event: ping\ndata: {"upstream":true}\n\n',
},
})
@ApiErrorResponse(
'AUTH_001',
'로그인 필요 (쿠키 없음 · 토큰 만료) — 클라이언트는 /auth/refresh 를 한 번 시도한 뒤 다시 잇는다',
)
@ApiErrorResponse(
'REQ_001',
`한 직원의 연결이 ${MAX_MAIL_EVENT_CONNECTIONS}개를 넘음 (브라우저마다 하나 — 기기 · 브라우저 ${MAX_MAIL_EVENT_CONNECTIONS}개) · 요청 수 한도 · 메일 엔진 한도`,
)
@ApiErrorResponse(
'MAIL_001',
'처음 연결 때 메일 엔진에 연결할 수 없음 · 서버 종료 중',
)
async stream(
@CurrentUser() user: AuthenticatedUser,
@Res() res: Response,
): Promise<void> {
// 클라이언트가 끊으면(기다리는 중이든 연결한 뒤든) 바로 정리한다
const disconnected = new AbortController();
res.once('close', () => disconnected.abort());
// 처리기에 닿기 전에(가드 · 미들웨어를 지나는 사이) 끊겼으면 'close' 는 이미 지나가 다시 오지 않는다 — 끊긴 것으로 다룬다
if (res.destroyed) disconnected.abort();
await this.events.open(user.email, eventStreamSink(res), {
tokenExpiresAt: user.accessTokenExpiresAt ?? null,
signal: disconnected.signal,
});
}
}
@@ -0,0 +1,566 @@
import { Logger } from '@nestjs/common';
import { BusinessException } from '@/common/errors/business.exception';
import {
currentRequestId,
runWithRequestContext,
} from '@/shared/logger/request-context';
import {
MAIL_EVENTS_LINGER_MS,
MAIL_EVENTS_PING_MS,
MAX_MAIL_EVENT_CONNECTIONS,
MailEventsService,
TOKEN_EXPIRY_LEAD_MS,
type MailEvent,
type MailEventSink,
} from './mail-events.service';
import type { MailProvider } from './mail.provider';
import type {
MailLiveState,
MailWatch,
MailWatchListener,
NewMailNotice,
} from './mail.types';
const ARA = 'ara.kim@arasystem.test';
const DOYUN = 'doyun.lee@arasystem.test';
const NOW = Date.parse('2026-10-28T05:00:00Z');
const INITIAL: MailLiveState = { state: 's0', inboxUnread: 2 };
const NEW_MAIL: NewMailNotice = {
count: 1,
messages: [
{
id: 'm1',
from: { name: '박서준', email: 'seojun.park@arasystem.test' },
subject: '안녕하세요',
receivedAt: '2026-10-28T05:42:00.000Z',
},
],
};
/** 엔진 연결 대역 — 연 것마다 listener 와 close 를 모아 둔다. `hold` 면 테스트가 열기를 끝낸다 */
function fakeProvider() {
const opened: {
user: string;
listener: MailWatchListener;
close: jest.Mock;
traceId: string | undefined;
}[] = [];
let held: {
resolve: () => void;
reject: (error: unknown) => void;
} | null = null;
let hold = false;
const watchMail = jest.fn(
(user: string, listener: MailWatchListener): Promise<MailWatch> => {
const close = jest.fn();
opened.push({ user, listener, close, traceId: currentRequestId() });
const watch = { initial: INITIAL, close };
if (!hold) return Promise.resolve(watch);
return new Promise<MailWatch>((resolve, reject) => {
held = { resolve: () => resolve(watch), reject };
});
},
);
return {
provider: { watchMail } as unknown as MailProvider,
watchMail,
opened,
/** 다음 열기를 테스트가 끝낼 때까지 붙잡는다 */
holdNext: () => {
hold = true;
},
release: () => {
hold = false;
held?.resolve();
held = null;
},
fail: (error: unknown) => {
hold = false;
held?.reject(error);
held = null;
},
};
}
/** 클라이언트 연결 대역 — 보낸 이벤트와 닫힘을 기록한다. `die` 면 응답이 끊긴 것처럼 보내기가 false 다 ('close' 는 오지 않는다) */
function client() {
const events: MailEvent[] = [];
const state = { closed: false, dead: false };
const disconnect = new AbortController();
const sink: MailEventSink = {
send: (event) => {
if (state.dead) return false;
events.push(event);
return true;
},
close: () => {
state.closed = true;
// 응답을 끝내면 연결도 닫힌다 (컨트롤러의 res 'close' → abort)
disconnect.abort();
},
};
return {
sink,
events,
get closed() {
return state.closed;
},
/** 클라이언트가 끊는다 */
leave: () => disconnect.abort(),
/** 응답이 끊겼는데 'close' 가 오지 않았다 (처리기에 닿기 전에 끊긴 응답 등) */
die: () => {
state.dead = true;
},
signal: disconnect.signal,
};
}
type Client = ReturnType<typeof client>;
async function flush(): Promise<void> {
for (let round = 0; round < 5; round += 1) {
await new Promise((resolve) => setImmediate(resolve));
}
}
describe('MailEventsService (실시간 알림의 연결 관리)', () => {
let fake: ReturnType<typeof fakeProvider>;
let service: MailEventsService;
const connect = (
user: string,
tokenExpiresAt: number | null = NOW + 3_600_000,
) => {
const connection = client();
const opening = service.open(user, connection.sink, {
tokenExpiresAt,
signal: connection.signal,
});
return { connection, opening };
};
const connected = async (
user: string,
tokenExpiresAt?: number | null,
): Promise<Client> => {
const { connection, opening } = connect(user, tokenExpiresAt);
await opening;
return connection;
};
const advance = async (ms: number) => {
await jest.advanceTimersByTimeAsync(ms);
await flush();
};
beforeEach(() => {
jest.useFakeTimers({
now: NOW,
doNotFake: ['nextTick', 'queueMicrotask', 'setImmediate'],
});
fake = fakeProvider();
service = new MailEventsService(fake.provider);
});
afterEach(() => {
service.beforeApplicationShutdown();
jest.useRealTimers();
jest.restoreAllMocks();
});
it('한 직원의 연결 여럿은 엔진 연결 하나를 나눠 쓴다 — 각자 처음 state 를 받고, 엔진 소식(계산은 한 번)은 모두에게', async () => {
const first = await connected(ARA);
const second = await connected(ARA);
const third = await connected(ARA);
const other = await connected(DOYUN);
expect(fake.watchMail).toHaveBeenCalledTimes(2);
expect(fake.opened.map(({ user }) => user)).toEqual([ARA, DOYUN]);
for (const connection of [first, second, third, other]) {
expect(connection.events).toEqual([{ type: 'state', data: INITIAL }]);
}
const [ara] = fake.opened;
ara.listener.state({ state: 's1', inboxUnread: 3 });
ara.listener.newMail(NEW_MAIL);
for (const connection of [first, second, third]) {
expect(connection.events.slice(1)).toEqual([
{ type: 'state', data: { state: 's1', inboxUnread: 3 } },
{ type: 'new-mail', data: NEW_MAIL },
]);
}
expect(other.events).toHaveLength(1);
// 나중에 들어온 연결은 마지막으로 알게 된 상태로 시작한다 (엔진에 다시 묻지 않는다)
const late = await connected(ARA);
expect(late.events).toEqual([
{ type: 'state', data: { state: 's1', inboxUnread: 3 } },
]);
expect(fake.watchMail).toHaveBeenCalledTimes(2);
});
it('엔진 연결을 여는 중에 온 연결은 함께 기다린다 — 여는 것은 한 번', async () => {
fake.holdNext();
const a = connect(ARA);
const b = connect(ARA);
await flush();
expect(fake.watchMail).toHaveBeenCalledTimes(1);
expect(a.connection.events).toEqual([]);
fake.release();
await Promise.all([a.opening, b.opening]);
expect(a.connection.events).toEqual([{ type: 'state', data: INITIAL }]);
expect(b.connection.events).toEqual([{ type: 'state', data: INITIAL }]);
});
it('엔진 연결은 요청 컨텍스트 밖에서 연다 — 여러 요청이 함께 쓰므로 처음 요청의 추적 ID 를 물려받지 않는다', async () => {
await runWithRequestContext({ requestId: 'trace-first' }, () =>
connected(ARA),
);
expect(fake.opened[0].traceId).toBeUndefined();
});
it(`한 직원 ${MAX_MAIL_EVENT_CONNECTIONS}개를 넘으면 REQ_001 — 엔진 연결을 기다리는 연결도 센다, 하나가 닫히면 다시 된다`, async () => {
fake.holdNext();
const waiting = Array.from({ length: MAX_MAIL_EVENT_CONNECTIONS }, () =>
connect(ARA),
);
const sixth = connect(ARA);
await expect(sixth.opening).rejects.toMatchObject({ code: 'REQ_001' });
expect(sixth.connection.events).toEqual([]);
fake.release();
await Promise.all(waiting.map(({ opening }) => opening));
await expect(connect(ARA).opening).rejects.toMatchObject({
code: 'REQ_001',
});
// 다른 직원은 따로 센다
await connected(DOYUN);
waiting[0].connection.leave();
const again = await connected(ARA);
expect(again.events).toEqual([{ type: 'state', data: INITIAL }]);
expect(fake.watchMail).toHaveBeenCalledTimes(2);
});
it(`마지막 연결이 닫히고 ${MAIL_EVENTS_LINGER_MS / 1000}초 뒤 엔진 연결을 닫는다 — 그 안에 다시 오면(새로 고침) 그대로 쓴다`, async () => {
const first = await connected(ARA);
first.leave();
await advance(MAIL_EVENTS_LINGER_MS - 1);
expect(fake.opened[0].close).not.toHaveBeenCalled();
const reloaded = await connected(ARA);
expect(fake.watchMail).toHaveBeenCalledTimes(1);
expect(reloaded.events).toEqual([{ type: 'state', data: INITIAL }]);
await advance(MAIL_EVENTS_LINGER_MS);
expect(fake.opened[0].close).not.toHaveBeenCalled();
reloaded.leave();
await advance(MAIL_EVENTS_LINGER_MS - 1);
expect(fake.opened[0].close).not.toHaveBeenCalled();
await advance(1);
expect(fake.opened[0].close).toHaveBeenCalledTimes(1);
// 닫은 뒤의 연결은 엔진 연결을 새로 연다
await connected(ARA);
expect(fake.watchMail).toHaveBeenCalledTimes(2);
});
it(`ping 은 ${MAIL_EVENTS_PING_MS / 1000}초마다 — 엔진과 끊기면 바로 upstream: false 를 알리고 그동안의 ping 도 false, 다시 이으면 true`, async () => {
const watcher = await connected(ARA);
await advance(MAIL_EVENTS_PING_MS - 1);
expect(watcher.events).toHaveLength(1);
await advance(1);
expect(watcher.events.at(-1)).toEqual({
type: 'ping',
data: { upstream: true },
});
const { listener } = fake.opened[0];
listener.upstream(false);
expect(watcher.events.at(-1)).toEqual({
type: 'ping',
data: { upstream: false },
});
await advance(MAIL_EVENTS_PING_MS);
expect(watcher.events.at(-1)).toEqual({
type: 'ping',
data: { upstream: false },
});
// 끊긴 동안 들어온 연결은 처음 state 다음에 바로 알 수 있다
const late = await connected(ARA);
expect(late.events).toEqual([
{ type: 'state', data: INITIAL },
{ type: 'ping', data: { upstream: false } },
]);
listener.upstream(true);
for (const connection of [watcher, late]) {
expect(connection.events.at(-1)).toEqual({
type: 'ping',
data: { upstream: true },
});
}
// 같은 값이면 다시 알리지 않는다
const count = watcher.events.length;
listener.upstream(true);
expect(watcher.events).toHaveLength(count);
});
it(`액세스 토큰이 끝나기 ${TOKEN_EXPIRY_LEAD_MS / 1000}초 전에 end{token-expiring} 를 보내고 닫는다 — 다른 연결은 그대로, 남은 시간이 그보다 짧으면 토큰이 실제로 끝날 때`, async () => {
const expiring = await connected(ARA, NOW + 600_000);
const longer = await connected(ARA, NOW + 3_600_000);
await advance(600_000 - TOKEN_EXPIRY_LEAD_MS - 1);
expect(expiring.closed).toBe(false);
await advance(1);
expect(expiring.events.at(-1)).toEqual({
type: 'end',
data: { reason: 'token-expiring' },
});
expect(expiring.closed).toBe(true);
expect(longer.closed).toBe(false);
// 세션 끝 30초 안에서는 갱신한 토큰도 짧다 — 바로 끝내면 클라이언트가 갱신 → 다시 잇기를 쉬지 않고 되풀이한다
const almostExpired = await connected(ARA, Date.now() + 10_000);
await advance(10_000 - 1);
// (허브의 ping 은 그사이 올 수 있다)
expect(almostExpired.events.map(({ type }) => type)).not.toContain('end');
expect(almostExpired.closed).toBe(false);
await advance(1);
expect(almostExpired.events.at(-1)).toEqual({
type: 'end',
data: { reason: 'token-expiring' },
});
expect(almostExpired.closed).toBe(true);
// 리드보다 조금 길면 그만큼 뒤에 (31초 남음 → 1초 뒤)
const justLonger = await connected(ARA, Date.now() + 31_000);
await advance(999);
expect(justLonger.closed).toBe(false);
await advance(1);
expect(justLonger.closed).toBe(true);
});
it('토큰의 만료를 모르면 액세스 토큰의 가장 긴 수명(1시간) 뒤에 닫는다', async () => {
const unknown = await connected(ARA, null);
await advance(3_600_000 - TOKEN_EXPIRY_LEAD_MS - 1);
expect(unknown.closed).toBe(false);
await advance(1);
expect(unknown.closed).toBe(true);
});
it('서버 종료 — 모든 연결에 end{server-shutdown} 을 보내고 닫고, 엔진 연결도 닫는다 — 그 뒤의 연결은 MAIL_001', async () => {
const a = await connected(ARA);
const b = await connected(DOYUN);
service.beforeApplicationShutdown();
for (const connection of [a, b]) {
expect(connection.events.at(-1)).toEqual({
type: 'end',
data: { reason: 'server-shutdown' },
});
expect(connection.closed).toBe(true);
}
expect(fake.opened.map(({ close }) => close.mock.calls.length)).toEqual([
1, 1,
]);
await expect(connect(ARA).opening).rejects.toMatchObject({
code: 'MAIL_001',
});
});
it('엔진 연결을 여는 중에 서버가 종료되면 — 열린 것을 바로 닫고 기다리던 연결은 MAIL_001', async () => {
fake.holdNext();
const waiting = connect(ARA);
await flush();
service.beforeApplicationShutdown();
fake.release();
await expect(waiting.opening).rejects.toMatchObject({ code: 'MAIL_001' });
expect(fake.opened[0].close).toHaveBeenCalledTimes(1);
expect(waiting.connection.events).toEqual([]);
});
it('처음 엔진 연결이 실패하면 함께 기다린 연결이 모두 그 실패를 받는다(보낸 것 없음) — 다음 연결은 처음부터 다시 연다', async () => {
fake.holdNext();
const a = connect(ARA);
const b = connect(ARA);
await flush();
fake.fail(
new BusinessException('MAIL_001', { context: { reason: 'timeout' } }),
);
await expect(a.opening).rejects.toMatchObject({ code: 'MAIL_001' });
await expect(b.opening).rejects.toMatchObject({ code: 'MAIL_001' });
expect(a.connection.events).toEqual([]);
const retried = await connected(ARA);
expect(fake.watchMail).toHaveBeenCalledTimes(2);
expect(retried.events).toEqual([{ type: 'state', data: INITIAL }]);
});
it('엔진 연결을 기다리는 사이 클라이언트가 끊으면 아무것도 보내지 않고 자리를 돌려준다 — 남은 연결이 없으면 잠시 뒤 엔진 연결을 닫는다', async () => {
fake.holdNext();
const gone = connect(ARA);
await flush();
gone.connection.leave();
fake.release();
await gone.opening;
expect(gone.connection.events).toEqual([]);
await advance(MAIL_EVENTS_LINGER_MS);
expect(fake.opened[0].close).toHaveBeenCalledTimes(1);
});
it('계정을 더 쓸 수 없어 엔진 연결이 끝나면(ended) 모든 연결을 닫는다 — 다음 연결은 엔진 연결을 다시 연다', async () => {
const a = await connected(ARA);
const b = await connected(ARA);
fake.opened[0].listener.ended();
expect(a.closed).toBe(true);
expect(b.closed).toBe(true);
// end 를 보내지 않는다 — 다시 이으면 원인 코드(AUTH_001 등)를 받는다
expect(a.events).toEqual([{ type: 'state', data: INITIAL }]);
await connected(ARA);
expect(fake.watchMail).toHaveBeenCalledTimes(2);
});
it('한 연결에 보내지 못하면 그 연결만 닫는다 — 다른 연결은 그대로 받는다 (오류 로그)', async () => {
const errorSpy = jest
.spyOn(Logger.prototype, 'error')
.mockImplementation(() => undefined);
const healthy = await connected(ARA);
const broken = client();
await service.open(
ARA,
{
send: (event) => {
if (event.type !== 'state' || broken.events.length > 0) {
throw new Error('socket gone');
}
broken.events.push(event);
return true;
},
close: () => broken.sink.close(),
},
{ tokenExpiresAt: NOW + 3_600_000, signal: broken.signal },
);
fake.opened[0].listener.newMail(NEW_MAIL);
expect(healthy.events.at(-1)).toEqual({ type: 'new-mail', data: NEW_MAIL });
expect(broken.closed).toBe(true);
expect(errorSpy).toHaveBeenCalledTimes(1);
// 닫힌 연결은 상한에서 빠졌다
for (let i = 0; i < MAX_MAIL_EVENT_CONNECTIONS - 1; i += 1) {
await connected(ARA);
}
});
it('처리기에 닿기 전에 끊긴 연결(signal 이 이미 끊김)은 자리를 잡지도 엔진 연결을 열지도 않는다', async () => {
const gone = client();
gone.leave();
await service.open(ARA, gone.sink, {
tokenExpiresAt: NOW + 3_600_000,
signal: gone.signal,
});
expect(fake.watchMail).not.toHaveBeenCalled();
expect(gone.events).toEqual([]);
});
it("응답이 이미 끊겨 보내기가 false 면('close' 가 오지 않은 응답) 그 연결을 빼고 자리를 돌려준다 — 오류 로그 없이, 남은 연결이 없으면 잠시 뒤 엔진 연결을 닫는다", async () => {
const errorSpy = jest
.spyOn(Logger.prototype, 'error')
.mockImplementation(() => undefined);
const ghost = client();
ghost.die();
await service.open(ARA, ghost.sink, {
tokenExpiresAt: NOW + 3_600_000,
signal: ghost.signal,
});
expect(ghost.closed).toBe(true);
expect(errorSpy).not.toHaveBeenCalled();
// 자리를 돌려받았다 — 5개를 다 열 수 있다
for (let i = 0; i < MAX_MAIL_EVENT_CONNECTIONS; i += 1) {
(await connected(DOYUN)).leave();
}
await advance(MAIL_EVENTS_LINGER_MS);
expect(fake.opened[0].close).toHaveBeenCalledTimes(1);
});
it('열려 있다가 끊긴 응답은 다음에 보낼 때(소식 · ping) 빠진다 — 그 자리를 다시 쓸 수 있다', async () => {
const open = await Promise.all(
Array.from({ length: MAX_MAIL_EVENT_CONNECTIONS }, () => connected(ARA)),
);
await expect(connect(ARA).opening).rejects.toMatchObject({
code: 'REQ_001',
});
open[0].die();
await advance(MAIL_EVENTS_PING_MS);
expect(open[0].closed).toBe(true);
const again = await connected(ARA);
expect(again.events).toEqual([{ type: 'state', data: INITIAL }]);
});
it('보내다 난 오류의 로그에는 그 요청의 추적 ID 가 붙는다', async () => {
const errorSpy = jest
.spyOn(Logger.prototype, 'error')
.mockImplementation(() => undefined);
const broken = client();
await runWithRequestContext({ requestId: 'trace-send' }, () =>
service.open(
ARA,
{
send: () => {
throw new Error('socket gone');
},
close: () => broken.sink.close(),
},
{ tokenExpiresAt: NOW + 3_600_000, signal: broken.signal },
),
);
expect(String(errorSpy.mock.calls[0]?.[0])).toMatch(
/^\[SYS_001\]\[trace-send\] /,
);
expect(broken.closed).toBe(true);
});
it('엔진 연결이 끝날 때(ended) 응답 닫기가 던져도 나머지 연결 · 허브는 정리된다', async () => {
const throwing = client();
await service.open(
ARA,
{
send: (event) => throwing.sink.send(event),
close: () => {
throw new Error('already closed');
},
},
{ tokenExpiresAt: NOW + 3_600_000, signal: throwing.signal },
);
const other = await connected(ARA);
expect(() => fake.opened[0].listener.ended()).not.toThrow();
expect(other.closed).toBe(true);
await connected(ARA);
expect(fake.watchMail).toHaveBeenCalledTimes(2);
});
});
@@ -0,0 +1,390 @@
import {
BeforeApplicationShutdown,
Inject,
Injectable,
Logger,
} from '@nestjs/common';
import { BusinessException } from '@/common/errors/business.exception';
import { ACCESS_TTL_MAX_SECONDS } from '@/modules/auth/auth.constants';
import {
runOutsideRequestContext,
traceTag,
} from '@/shared/logger/request-context';
import { maskEmail } from '@/shared/utils/mask';
import { MAIL_PROVIDER, type MailProvider } from './mail.provider';
import type {
MailLiveState,
MailWatch,
MailWatchListener,
NewMailNotice,
} from './mail.types';
/** 한 직원의 연결 상한 — 브라우저마다 하나라 기기 · 브라우저 5개 (계약 2절 "실시간 알림"). 서버 인스턴스마다 센다 */
export const MAX_MAIL_EVENT_CONNECTIONS = 5;
/** 클라이언트 연결 유지(ping) 간격 */
export const MAIL_EVENTS_PING_MS = 25_000;
/** 마지막 연결이 닫힌 뒤 엔진 연결을 남겨 두는 시간 — 새로 고침에 다시 열지 않게 */
export const MAIL_EVENTS_LINGER_MS = 30_000;
/**
* 액세스 토큰이 끝나기 이만큼 전에 `end {token-expiring}` 를 보내고 닫는다 — 클라이언트가 세션을 갱신하고 다시 잇는다.
* 남은 시간이 이보다 짧으면 끝날 때 보낸다 (expiryDelay)
*/
export const TOKEN_EXPIRY_LEAD_MS = 30_000;
/** 토큰의 만료를 알 수 없을 때 연결을 닫는 시간 — 액세스 토큰의 가장 긴 수명 (env 검증의 상한) */
const FALLBACK_CONNECTION_LIFETIME_MS = ACCESS_TTL_MAX_SECONDS * 1000;
/** 서버가 곧 닫는 까닭 (`end` 이벤트) */
export type MailEventsEndReason = 'token-expiring' | 'server-shutdown';
/** 실시간 알림의 이벤트 (계약 2절 "실시간 알림") — type 이 SSE 의 event 이름이다 */
export type MailEvent =
| { readonly type: 'state'; readonly data: MailLiveState }
| { readonly type: 'new-mail'; readonly data: NewMailNotice }
| { readonly type: 'ping'; readonly data: { readonly upstream: boolean } }
| {
readonly type: 'end';
readonly data: { readonly reason: MailEventsEndReason };
};
/** 클라이언트 연결 하나 — 컨트롤러가 HTTP 응답으로 만든다 (어느 것도 던지지 않아야 한다) */
export interface MailEventSink {
/**
* 이벤트 하나를 보낸다 — 처음 보낼 때 응답 머리를 쓴다.
* 이미 끊긴 응답이면 쓰지 않고 false — 서비스가 그 연결을 빼고 자리를 돌려준다
* ('close' 를 듣기 전에 끊긴 응답은 그 신호가 오지 않으므로 이것이 두 번째 그물이다)
*/
send(event: MailEvent): boolean;
/** 응답을 끝낸다 */
close(): void;
}
export interface MailEventsOpenOptions {
/** 액세스 토큰이 끝나는 시각(epoch ms) — 모르면 null (액세스 토큰의 가장 긴 수명 뒤에 닫는다) */
readonly tokenExpiresAt: number | null;
/** 클라이언트가 끊으면 abort — 기다리는 중이든 연결한 뒤든 바로 정리한다 */
readonly signal: AbortSignal;
}
/**
* `end {token-expiring}` 를 보낼 때까지 — 토큰이 끝나기 TOKEN_EXPIRY_LEAD_MS 전.
* 남은 시간이 그보다 짧으면(세션 끝 30초 안에서는 갱신한 토큰도 그렇다 — 액세스 토큰 수명은 세션 남은 시간을 넘지 않는다)
* 토큰이 실제로 끝날 때 보낸다. 바로 보내면 클라이언트가 갱신 → 다시 잇기를 쉬지 않고 되풀이한다 —
* 끝날 때 보내면 세션 끝에서는 갱신이 401 로 실패해 정상적인 세션 만료로 끝난다.
* 만료를 모르면 액세스 토큰의 가장 긴 수명을 남은 시간으로 본다
*/
function expiryDelay(tokenExpiresAt: number | null): number {
const expiresAt =
tokenExpiresAt ?? Date.now() + FALLBACK_CONNECTION_LIFETIME_MS;
const remaining = Math.max(0, expiresAt - Date.now());
return remaining > TOKEN_EXPIRY_LEAD_MS
? remaining - TOKEN_EXPIRY_LEAD_MS
: remaining;
}
interface Client {
readonly sink: MailEventSink;
expiryTimer: NodeJS.Timeout | null;
removed: boolean;
}
/** 한 직원의 엔진 연결과 그것을 나눠 쓰는 클라이언트 연결들 */
interface Hub {
readonly user: string;
readonly clients: Set<Client>;
/** 엔진 연결을 기다리는 연결 수 — 연결 상한에 함께 센다 */
pending: number;
watch: MailWatch | null;
/** 엔진 연결을 여는 중 — 기다리는 연결이 함께 기다린다 */
starting: Promise<void> | null;
/** 마지막으로 알게 된 상태 — 나중에 들어온 연결의 처음 state */
live: MailLiveState | null;
/** 엔진과 이어져 있다 — ping 의 upstream */
upstream: boolean;
pingTimer: NodeJS.Timeout | null;
lingerTimer: NodeJS.Timeout | null;
/** 닫혀 더 쓰지 않는다 (맵에서도 빠졌다) */
closed: boolean;
}
/**
* 실시간 알림의 연결 관리 (계약 2절 "실시간 알림") — 메일 엔진을 모른다.
*
* - 직원마다 엔진 연결 하나(MailProvider.watchMail)를 그 직원의 모든 연결에 나눈다 — 상태 · 새 메일 계산은 직원마다 한 번이다
* - 첫 연결 때 열고, 마지막 연결이 닫히고 MAIL_EVENTS_LINGER_MS 뒤 닫는다. 한 직원 MAX_MAIL_EVENT_CONNECTIONS 개 — 넘으면 REQ_001
* - 연결마다: 처음 state, 그 뒤 state · new-mail, MAIL_EVENTS_PING_MS 마다 ping(엔진과 끊긴 동안 upstream: false — 바뀐 때는 바로 한 번 더),
* 액세스 토큰이 끝나기 TOKEN_EXPIRY_LEAD_MS 전에(남은 시간이 그보다 짧으면 끝날 때) end{token-expiring}.
* 응답이 이미 끊겼으면(보내기가 false) 그 연결을 빼고 자리를 돌려준다
* - 서버 종료(beforeApplicationShutdown — HTTP 서버를 닫기 전)에 모든 연결에 end{server-shutdown} 을 보내고 닫는다 —
* 오래 열린 연결이 종료를 붙잡지 않게
* - 열고 닫기는 로그에 남기지 않는다 (반복되는 정상 흐름)
*
* ⚠️ 연결 상한 · 엔진 연결은 서버 인스턴스마다다 — 인스턴스를 늘려도 기능은 맞다(인스턴스마다 엔진 연결 하나),
* 다만 한 직원이 인스턴스마다 5개까지 열 수 있다.
*/
@Injectable()
export class MailEventsService implements BeforeApplicationShutdown {
private readonly logger = new Logger(MailEventsService.name);
private readonly hubs = new Map<string, Hub>();
private shuttingDown = false;
constructor(@Inject(MAIL_PROVIDER) private readonly provider: MailProvider) {}
/**
* 연결 하나를 연다 — 처음 state 를 보낸 뒤부터 소식을 보낸다. 돌아온 뒤로 연결은 `signal` 이 끊길 때까지 이어진다.
* 처음 state 를 보내기 전의 실패는 던진다 — 그때는 아무것도 보내지 않았다 (컨트롤러가 표준 실패 JSON 으로 답한다):
* 연결 상한 REQ_001, 처음 엔진 연결 실패 MAIL_001 · AUTH_001 · REQ_001, 서버 종료 중 MAIL_001
*/
async open(
user: string,
sink: MailEventSink,
options: MailEventsOpenOptions,
): Promise<void> {
// 처리기에 닿기 전에 이미 끊겼다 — 자리를 잡지도, 엔진 연결을 열지도 않는다
if (options.signal.aborted) return;
if (this.shuttingDown) throw this.shuttingDownFailure();
const hub = this.hubFor(user);
if (hub.clients.size + hub.pending >= MAX_MAIL_EVENT_CONNECTIONS) {
throw new BusinessException('REQ_001', {
context: {
reason: 'mail-events-connection-limit',
user: maskEmail(user),
limit: MAX_MAIL_EVENT_CONNECTIONS,
},
});
}
hub.pending += 1;
this.cancelLinger(hub);
try {
await this.ensureWatch(hub);
} catch (error) {
hub.pending -= 1;
// 엔진 연결을 열지 못했다 — 함께 기다린 연결이 모두 끝나면 허브를 버린다 (다음 연결이 처음부터 다시 연다)
if (
!hub.watch &&
!hub.starting &&
hub.clients.size === 0 &&
hub.pending === 0
) {
this.closeHub(hub);
}
throw error;
}
hub.pending -= 1;
if (this.shuttingDown) throw this.shuttingDownFailure();
if (hub.closed || !hub.watch || !hub.live) {
// 기다리는 사이 엔진 연결이 끝났다 (계정을 더 쓸 수 없음 등) — 클라이언트는 다시 이으며 원인 코드를 받는다
throw new BusinessException('MAIL_001', {
context: { reason: 'mail-watch-ended', user: maskEmail(user) },
});
}
if (options.signal.aborted) {
// 기다리는 사이 클라이언트가 끊었다
this.lingerIfIdle(hub);
return;
}
const client: Client = { sink, expiryTimer: null, removed: false };
hub.clients.add(client);
this.ensurePing(hub);
this.send(hub, client, { type: 'state', data: hub.live });
if (!hub.upstream) {
this.send(hub, client, { type: 'ping', data: { upstream: false } });
}
// 처음 보내기에서 이미 끊긴 응답이었다 (send 가 닫았다)
if (client.removed) return;
client.expiryTimer = setTimeout(
() => this.end(hub, client, 'token-expiring'),
expiryDelay(options.tokenExpiresAt),
);
client.expiryTimer.unref();
options.signal.addEventListener('abort', () => this.remove(hub, client), {
once: true,
});
}
/** 서버 종료 — HTTP 서버를 닫기 전에 모든 연결을 end{server-shutdown} 으로 닫고 엔진 연결도 닫는다 */
beforeApplicationShutdown(): void {
this.shuttingDown = true;
for (const hub of [...this.hubs.values()]) {
for (const client of [...hub.clients]) {
this.end(hub, client, 'server-shutdown');
}
this.closeHub(hub);
}
}
private shuttingDownFailure(): BusinessException {
return new BusinessException('MAIL_001', {
context: { reason: 'server-shutting-down' },
});
}
private hubFor(user: string): Hub {
const existing = this.hubs.get(user);
if (existing) return existing;
const hub: Hub = {
user,
clients: new Set(),
pending: 0,
watch: null,
starting: null,
live: null,
upstream: true,
pingTimer: null,
lingerTimer: null,
closed: false,
};
this.hubs.set(user, hub);
return hub;
}
/**
* 엔진 연결을 연다 (이미 열렸으면 그대로) — 여는 중이면 함께 기다린다. 실패하면 기다리던 연결이 모두 그 실패를 받는다.
* 요청 컨텍스트 밖에서 연다 — 엔진 연결은 여러 요청이 함께 쓰므로 처음 요청의 추적 ID 를 물려받지 않게
*/
private ensureWatch(hub: Hub): Promise<void> {
if (hub.watch) return Promise.resolve();
hub.starting ??= runOutsideRequestContext(() =>
this.provider.watchMail(hub.user, this.listenerFor(hub)),
)
.then((watch) => {
if (this.shuttingDown || hub.closed) {
// 여는 사이 서버 종료가 시작됐다 — 열린 것을 바로 닫는다
watch.close();
return;
}
hub.watch = watch;
// 여는 사이 온 소식이 있으면 그것이 더 새롭다
hub.live ??= watch.initial;
})
.finally(() => {
hub.starting = null;
});
return hub.starting;
}
/** 엔진 연결이 알리는 것을 그 직원의 모든 연결에 나눈다 */
private listenerFor(hub: Hub): MailWatchListener {
return {
state: (live) => {
hub.live = live;
this.broadcast(hub, { type: 'state', data: live });
},
newMail: (notice) =>
this.broadcast(hub, { type: 'new-mail', data: notice }),
upstream: (connected) => {
if (hub.upstream === connected) return;
hub.upstream = connected;
// 바뀐 때는 다음 ping 을 기다리지 않고 바로 알린다 — 클라이언트가 1분 확인으로 빨리 돌아가게
this.broadcast(hub, { type: 'ping', data: { upstream: connected } });
},
ended: () => {
// 계정을 더 쓸 수 없다 — 연결을 닫는다. 클라이언트가 다시 이으면 원인 코드(AUTH_001 등)를 받는다
hub.watch = null;
for (const client of [...hub.clients]) this.closeClient(hub, client);
this.closeHub(hub);
},
};
}
private broadcast(hub: Hub, event: MailEvent): void {
for (const client of [...hub.clients]) this.send(hub, client, event);
}
/**
* 한 연결에 보낸다 — 이미 끊긴 응답(false)이면 그 연결을 빼고 자리를 돌려준다(로그 없이 — 클라이언트가 떠난 것이다).
* 보내다 난 오류는 그 연결만 닫는다 — 다른 연결 · 엔진 연결의 흐름을 끊지 않게
*/
private send(hub: Hub, client: Client, event: MailEvent): void {
let delivered: boolean;
try {
delivered = client.sink.send(event);
} catch (error) {
this.logger.error(
`[SYS_001]${traceTag()} 실시간 알림을 보내지 못해 연결을 닫습니다: user=${maskEmail(hub.user)}, event=${event.type}`,
error instanceof Error ? error.stack : undefined,
);
delivered = false;
}
if (!delivered) this.closeClient(hub, client);
}
/** 서버가 곧 닫는다고 알리고 닫는다 */
private end(hub: Hub, client: Client, reason: MailEventsEndReason): void {
if (client.removed) return;
this.send(hub, client, { type: 'end', data: { reason } });
this.closeClient(hub, client);
}
private closeClient(hub: Hub, client: Client): void {
if (client.removed) return;
try {
client.sink.close();
} catch {
// 이미 끊긴 응답 — 정리만 한다
}
this.remove(hub, client);
}
/** 연결을 뺀다 (몇 번 불러도 한 번만) — 남은 연결이 없으면 엔진 연결을 잠시 남겼다 닫는다 */
private remove(hub: Hub, client: Client): void {
if (client.removed) return;
client.removed = true;
if (client.expiryTimer) clearTimeout(client.expiryTimer);
client.expiryTimer = null;
hub.clients.delete(client);
this.lingerIfIdle(hub);
}
private lingerIfIdle(hub: Hub): void {
if (
hub.closed ||
hub.clients.size > 0 ||
hub.pending > 0 ||
hub.lingerTimer
) {
return;
}
hub.lingerTimer = runOutsideRequestContext(() =>
setTimeout(() => this.closeHub(hub), MAIL_EVENTS_LINGER_MS),
);
hub.lingerTimer.unref();
}
private cancelLinger(hub: Hub): void {
if (hub.lingerTimer) clearTimeout(hub.lingerTimer);
hub.lingerTimer = null;
}
private ensurePing(hub: Hub): void {
if (hub.pingTimer) return;
hub.pingTimer = runOutsideRequestContext(() =>
setInterval(
() =>
this.broadcast(hub, {
type: 'ping',
data: { upstream: hub.upstream },
}),
MAIL_EVENTS_PING_MS,
),
);
hub.pingTimer.unref();
}
/** 허브를 닫는다 — 엔진 연결 · 타이머를 정리하고 맵에서 뺀다 (남은 연결은 부른 쪽이 먼저 닫는다) */
private closeHub(hub: Hub): void {
if (hub.closed) return;
hub.closed = true;
this.cancelLinger(hub);
if (hub.pingTimer) clearInterval(hub.pingTimer);
hub.pingTimer = null;
hub.watch?.close();
hub.watch = null;
if (this.hubs.get(hub.user) === hub) this.hubs.delete(hub.user);
}
}
+5
View File
@@ -13,6 +13,8 @@ import { DraftController } from './draft.controller';
import { JmapClient } from './jmap/jmap.client';
import { JmapMailProvider } from './jmap/jmap-mail.provider';
import { MailAttachmentController } from './mail-attachment.controller';
import { MailEventsController } from './mail-events.controller';
import { MailEventsService } from './mail-events.service';
import {
MailUploadTempCleanup,
mailUploadStorage,
@@ -104,6 +106,7 @@ function attachmentUploadOptions(config: ConfigService): MulterModuleOptions {
MailboxController,
MailQuotaController,
MailStateController,
MailEventsController,
MailVacationController,
MessageController,
MessageDeletionController,
@@ -120,6 +123,8 @@ function attachmentUploadOptions(config: ConfigService): MulterModuleOptions {
JmapClient,
{ provide: MAIL_PROVIDER, useClass: JmapMailProvider },
MailService,
// 실시간 알림 — 직원마다 엔진 연결 하나를 나눠 쓰고, 서버 종료 때 열린 연결을 먼저 닫는다 (beforeApplicationShutdown)
MailEventsService,
RecipientSuggestionService,
MailUploadTempCleanup,
],
+13
View File
@@ -12,6 +12,8 @@ import type {
MailState,
MailUsage,
MailVacation,
MailWatch,
MailWatchListener,
MessageDeletionResult,
MessageDetailSource,
MessageListQuery,
@@ -119,6 +121,17 @@ export interface MailProvider {
*/
getMailState(user: string): Promise<MailState>;
/**
* 실시간 알림 (계약 2절 "실시간 알림") — 그 직원의 메일함 · 메일 변화를 엔진에서 받는 연결 하나를 연다.
* 처음 상태(state — getMailState 와 같은 값 · 받은편지함의 안 읽은 수)를 잡은 뒤 돌려준다 —
* 그 전의 실패는 그 코드로 던지고 아무것도 남기지 않는다 (엔진에 연결할 수 없음 MAIL_001 · 계정을 쓸 수 없음 AUTH_001 · 엔진 한도 REQ_001).
* 그 뒤로는 `listener` 로 알린다 — 바뀐 상태(엔진 신호를 모아서 · 신호가 없어도 몇 분에 한 번 확인),
* 새 메일(받은편지함 · 초안 아님 · 읽지 않음 · 본인이 보내지 않음 · 변화를 본 시각보다 오래전에 받지 않음),
* 엔진 연결이 끊김 · 다시 이음, 끝남(계정을 더 쓸 수 없음). 끊기면 스스로 다시 잇고, 다시 이으면 지난 상태와 비교해 달라진 것을 알린다.
* `close()` 까지 이어진다. 한 직원에게 여러 개를 열지 않는다 — 여러 연결이 나눠 쓰는 일은 서비스(MailEventsService)가 한다.
*/
watchMail(user: string, listener: MailWatchListener): Promise<MailWatch>;
/**
* 메일 목록 (받은 시각 내림차순). `mailboxId` 가 없으면 휴지통·스팸메일함을 뺀 모든 메일함에서 찾는다 (검색).
* `text` 는 엔진의 낱말 검색 규칙에 맞춰 넓혀 찾는다 (한국어 조사 — 계약 2절 "검색"). 없는 메일함은 RES_001.
@@ -67,6 +67,7 @@ function fakeProvider(): ProviderMocks {
getAttachmentsTotalLimit:
mockOf<'getAttachmentsTotalLimit'>().mockResolvedValue(25 * 1024 * 1024),
getMailState: mockOf<'getMailState'>(),
watchMail: mockOf<'watchMail'>(),
listMessages: mockOf<'listMessages'>(),
getMessage: mockOf<'getMessage'>(),
updateMessage: mockOf<'updateMessage'>(),
+50
View File
@@ -227,6 +227,56 @@ export interface MailState {
readonly state: string;
}
/** 실시간 알림의 상태 (계약 2절 "실시간 알림"의 `state` 이벤트) */
export interface MailLiveState {
/** GET /api/mail-state 의 state 와 같은 값 (같은 함수로 만든다) */
readonly state: string;
/** 받은편지함의 안 읽은 수 */
readonly inboxUnread: number;
}
/** 받은편지함에 새로 배달된 메일 한 통 (`new-mail` 이벤트) — 주소 · 제목은 메일 목록과 같은 규칙 */
export interface NewMailMessage {
readonly id: string;
/** 보낸 사람 — 이름이 없으면 name 은 null, 주소가 없는 메일이면 null (목록의 from 과 같다) */
readonly from: MailAddress | null;
/** 제목이 없으면 빈 문자열 */
readonly subject: string;
/** ISO 8601 UTC */
readonly receivedAt: string;
}
/** 새 메일 소식 (`new-mail` 이벤트) */
export interface NewMailNotice {
/** 알릴 메일 전체 수 */
readonly count: number;
/** 받은 시각 새것부터 최대 5통 */
readonly messages: NewMailMessage[];
}
/**
* 메일 엔진 연결(MailProvider.watchMail)이 알리는 것 — 엔진을 모르는 서비스(MailEventsService)가 받는다.
* 어느 것도 던지지 않아야 한다 (엔진 연결의 흐름 안에서 부른다)
*/
export interface MailWatchListener {
/** 메일함 · 메일이 바뀌었다 — 바뀐 뒤의 상태 (처음 상태는 watchMail 이 돌려준다) */
state(state: MailLiveState): void;
/** 받은편지함에 새로 배달된 메일 — 같은 변화의 state 다음에 온다 */
newMail(notice: NewMailNotice): void;
/** 엔진 연결이 끊겨 다시 잇는 중(false) · 다시 이었다(true) */
upstream(connected: boolean): void;
/** 더 이을 수 없어 끝났다 (계정을 더 쓸 수 없음 — AUTH_001) — 이 뒤로는 아무것도 오지 않는다 */
ended(): void;
}
/** 열린 메일 엔진 연결 (MailProvider.watchMail) */
export interface MailWatch {
/** 연결하며 엔진에 물은 처음 상태 */
readonly initial: MailLiveState;
/** 엔진 연결을 닫는다 — 이 뒤로는 listener 를 부르지 않는다 */
close(): void;
}
/** 여러 통을 바꾼 결과 (PATCH /api/messages) — 요청한 id 는 둘 중 한 곳에만 있다 */
export interface MessagesPatchResult {
readonly updatedIds: string[];
@@ -1,5 +1,6 @@
import {
currentRequestId,
runOutsideRequestContext,
runWithRequestContext,
traceTag,
} from './request-context';
@@ -34,4 +35,19 @@ describe('요청 컨텍스트', () => {
'b',
]);
});
it('요청 안에서 컨텍스트 밖으로 시작한 작업은 그 요청의 추적 ID 를 물려받지 않는다 — 요청은 그대로 이어진다', async () => {
const seen = await runWithRequestContext(
{ requestId: 'trace-2' },
async () => {
const detached = runOutsideRequestContext(async () => {
await new Promise((resolve) => setTimeout(resolve, 1));
return currentRequestId();
});
return { detached: await detached, request: currentRequestId() };
},
);
expect(seen).toEqual({ detached: undefined, request: 'trace-2' });
});
});
@@ -21,6 +21,15 @@ export function runWithRequestContext<T>(
return storage.run(context, callback);
}
/**
* 요청 컨텍스트 **밖에서** `callback` 과 그 뒤의 비동기 흐름을 실행한다.
* 여러 요청이 함께 쓰는 오래 사는 작업(실시간 알림의 메일 엔진 연결 등)을 요청 안에서 시작할 때 쓴다 —
* 그렇지 않으면 그 작업의 로그가 처음 시작한 요청의 추적 ID 를 계속 달고 다닌다.
*/
export function runOutsideRequestContext<T>(callback: () => T): T {
return storage.exit(callback);
}
/** 지금 처리 중인 요청의 추적 ID (요청 밖이면 undefined) */
export function currentRequestId(): string | undefined {
return storage.getStore()?.requestId;
+113
View File
@@ -0,0 +1,113 @@
import { SseFormatError, SseParser, formatSseEvent } from './sse';
describe('formatSseEvent', () => {
it('event · data(JSON 한 줄) · 빈 줄로 만든다', () => {
expect(
formatSseEvent('state', { state: 'c2Iy.c2Fq', inboxUnread: 3 }),
).toBe('event: state\ndata: {"state":"c2Iy.c2Fq","inboxUnread":3}\n\n');
});
it('값 안의 줄바꿈은 JSON 이스케이프로 남아 한 줄이다 — 다른 필드 · 이벤트가 끼어들지 못한다', () => {
const text = formatSseEvent('new-mail', {
subject: '제목\r\n\r\nevent: end\ndata: {}',
});
expect(text.split('\n')).toEqual([
'event: new-mail',
'data: {"subject":"제목\\r\\n\\r\\nevent: end\\ndata: {}"}',
'',
'',
]);
});
it('이벤트 이름에 줄바꿈 · 공백 · 콜론이 있으면 만들지 않는다', () => {
for (const name of ['state\ndata: x', 'new mail', 'a:b', '']) {
expect(() => formatSseEvent(name, {})).toThrow();
}
});
it('만든 것을 해석기로 다시 읽으면 같은 이름 · 데이터다', () => {
const parser = new SseParser();
const data = { count: 2, messages: [{ subject: '줄\n바꿈' }] };
expect(parser.push(formatSseEvent('new-mail', data))).toEqual([
{ event: 'new-mail', data: JSON.stringify(data) },
]);
});
});
describe('SseParser', () => {
it('메일 엔진(Stalwart)의 state · ping 이벤트를 읽는다', () => {
const parser = new SseParser();
expect(
parser.push(
'event: state\ndata: {"@type":"StateChange","changed":{"d":{"Email":"s1"}}}\n\n' +
'event: ping\ndata: {"interval": 30}\n\n',
),
).toEqual([
{
event: 'state',
data: '{"@type":"StateChange","changed":{"d":{"Email":"s1"}}}',
},
{ event: 'ping', data: '{"interval": 30}' },
]);
});
it('조각 사이에서 끊긴 줄 · 이벤트를 이어서 읽는다 (CRLF 의 CR 과 LF 가 다른 조각이어도)', () => {
const parser = new SseParser();
const text =
'event: state\r\ndata: {"a":1}\r\n\r\nevent: ping\r\ndata: {}\r\n\r\n';
const messages = [...text].flatMap((char) => parser.push(char));
expect(messages).toEqual([
{ event: 'state', data: '{"a":1}' },
{ event: 'ping', data: '{}' },
]);
});
it('줄 끝은 LF · CRLF · CR 모두 받는다', () => {
for (const lineBreak of ['\n', '\r\n', '\r']) {
const parser = new SseParser();
expect(
parser.push(`event: x${lineBreak}data: 1${lineBreak}${lineBreak}`),
).toEqual([{ event: 'x', data: '1' }]);
}
});
it('data 여러 줄은 줄바꿈으로 잇고, 이름이 없으면 message · 콜론 뒤 공백은 하나만 뗀다', () => {
const parser = new SseParser();
expect(parser.push('data: 두 칸\ndata:붙임\ndata\n\n')).toEqual([
{ event: 'message', data: ' 두 칸\n붙임\n' },
]);
});
it('주석 · id · retry 와 data 없는 이벤트는 내보내지 않는다 — 맨 앞 BOM 도 뗀다', () => {
const parser = new SseParser();
expect(
parser.push(': 연결 유지\nretry: 1000\nid: 7\n\nevent: empty\n\n'),
).toEqual([]);
expect(parser.push('data: 다음\n\n')).toEqual([
{ event: 'message', data: '다음' },
]);
});
it('끝나지 않은 이벤트는 내보내지 않고 기다린다', () => {
const parser = new SseParser();
expect(parser.push('event: state\ndata: {"a"')).toEqual([]);
expect(parser.push(':1}\n')).toEqual([]);
expect(parser.push('\n')).toEqual([{ event: 'state', data: '{"a":1}' }]);
});
it('한 줄이나 한 이벤트의 data 가 한도를 넘으면 SseFormatError — 줄바꿈 없이 끝없이 보내도 쌓아 두지 않는다', () => {
const longLine = new SseParser(10);
expect(() => longLine.push('data: 01234567890')).toThrow(SseFormatError);
const longEvent = new SseParser(20);
expect(longEvent.push('data: 0123456789\n')).toEqual([]);
expect(() => longEvent.push('data: 0123456789\n')).toThrow(SseFormatError);
});
});
+146
View File
@@ -0,0 +1,146 @@
/**
* Server-Sent Events(`text/event-stream` — WHATWG HTML "Server-sent events") 형식.
*
* - 쓰기(`formatSseEvent`): 실시간 알림(GET /api/mail-events)이 브라우저에 보내는 이벤트 — 순수 함수
* - 읽기(`SseParser`): 메일 엔진의 JMAP EventSource(RFC 8620 7.3) — 받은 글자만 다루는 해석기(입출력 없음)
*/
/** 이벤트 이름 — 줄바꿈이 들어가면 다른 필드가 끼어들 수 있어 영문 · 숫자 · `-` · `_` 만 받는다 */
const EVENT_NAME_PATTERN = /^[A-Za-z0-9_-]+$/;
/** SSE 의 줄 끝 — CRLF · LF · CR 셋 다 (WHATWG 규칙) */
const LINE_BREAK = /\r\n|\r|\n/;
const LF = 0x0a;
const CR = 0x0d;
/** 스트림 맨 앞에 올 수 있는 BOM — 해석하기 전에 뗀다 */
const BYTE_ORDER_MARK = '';
/**
* 이벤트 하나를 만든다 — `event: <이름>` · `data: <JSON>` · 빈 줄.
* JSON 은 문자열 안의 줄바꿈을 `\n` 으로 적으므로 보통 한 줄이다 — 그래도 줄마다 `data:` 를 붙인다(형식 규칙).
*/
export function formatSseEvent(name: string, data: object): string {
if (!EVENT_NAME_PATTERN.test(name)) {
throw new Error(`SSE 이벤트 이름으로 쓸 수 없는 값입니다: ${name}`);
}
const lines = JSON.stringify(data).split(LINE_BREAK);
return `event: ${name}\n${lines.map((line) => `data: ${line}`).join('\n')}\n\n`;
}
/** 상대가 SSE 형식을 지키지 않았다 (한 줄 · 한 이벤트가 너무 김) — 읽는 쪽이 연결을 닫는다 */
export class SseFormatError extends Error {
override readonly name = 'SseFormatError';
}
/** 받은 이벤트 하나 */
export interface SseMessage {
/** `event` 필드 — 없으면 `message` */
readonly event: string;
/** `data` 줄들을 줄바꿈으로 이은 것 */
readonly data: string;
}
/**
* 한 줄 · 한 이벤트의 data 최대 길이 (JS 문자열 길이) — 상대가 줄바꿈 없이 끝없이 보내도 메모리를 붙잡지 않게.
* 메일 엔진의 StateChange 는 계정 하나에 수백 자다.
*/
export const SSE_MAX_FIELD_LENGTH = 1_048_576;
/** 이벤트 이름이 없을 때의 이름 (WHATWG 규칙) */
const DEFAULT_EVENT_NAME = 'message';
/**
* `text/event-stream` 해석기 — 디코딩한 글자를 받은 대로 넣으면 끝난 이벤트를 돌려준다.
* 줄이 조각 사이에서 끊겨도(CRLF 의 CR 과 LF 가 다른 조각이어도) 이어서 읽는다.
* 주석(`:` 로 시작) · `id` · `retry` 는 쓰지 않는다. data 가 없는 이벤트는 내보내지 않는다(WHATWG 규칙).
* 한 줄이나 한 이벤트의 data 가 `maxLength` 를 넘으면 SseFormatError — 읽는 쪽이 연결을 닫는다.
*/
export class SseParser {
/** 아직 줄 끝을 만나지 못한 글자 */
private pending = '';
/** 지난 조각이 CR 로 끝났다 — 다음 조각 맨 앞의 LF 는 같은 줄 끝이다 */
private skipLeadingLineFeed = false;
private started = false;
private eventName = '';
private dataLines: string[] = [];
private dataLength = 0;
constructor(private readonly maxLength = SSE_MAX_FIELD_LENGTH) {}
push(chunk: string): SseMessage[] {
let text = chunk;
if (!this.started && text !== '') {
this.started = true;
if (text.startsWith(BYTE_ORDER_MARK)) text = text.slice(1);
}
if (this.skipLeadingLineFeed && text !== '') {
this.skipLeadingLineFeed = false;
if (text.charCodeAt(0) === LF) text = text.slice(1);
}
const messages: SseMessage[] = [];
let start = 0;
for (let index = 0; index < text.length; index += 1) {
const code = text.charCodeAt(index);
if (code !== LF && code !== CR) continue;
this.readLine(this.pending + text.slice(start, index), messages);
this.pending = '';
if (code === CR) {
if (index + 1 < text.length) {
if (text.charCodeAt(index + 1) === LF) index += 1;
} else {
this.skipLeadingLineFeed = true;
}
}
start = index + 1;
}
this.pending += text.slice(start);
if (this.pending.length > this.maxLength) {
throw new SseFormatError('SSE 한 줄이 너무 깁니다');
}
return messages;
}
private readLine(line: string, messages: SseMessage[]): void {
if (line.length > this.maxLength) {
throw new SseFormatError('SSE 한 줄이 너무 깁니다');
}
if (line === '') {
this.dispatch(messages);
return;
}
// 주석 — 연결 유지용으로 보내는 서버가 있다
if (line.startsWith(':')) return;
const colon = line.indexOf(':');
const field = colon < 0 ? line : line.slice(0, colon);
let value = colon < 0 ? '' : line.slice(colon + 1);
if (value.startsWith(' ')) value = value.slice(1);
if (field === 'event') {
this.eventName = value;
} else if (field === 'data') {
this.dataLength += value.length + 1;
if (this.dataLength > this.maxLength) {
throw new SseFormatError('SSE 이벤트의 data 가 너무 깁니다');
}
this.dataLines.push(value);
}
}
private dispatch(messages: SseMessage[]): void {
if (this.dataLines.length > 0) {
messages.push({
event: this.eventName || DEFAULT_EVENT_NAME,
data: this.dataLines.join('\n'),
});
}
this.eventName = '';
this.dataLines = [];
this.dataLength = 0;
}
}
+204 -1
View File
@@ -3,7 +3,7 @@ import { JwtService } from '@nestjs/jwt';
import { Test, TestingModule } from '@nestjs/testing';
import { createHash } from 'crypto';
import { existsSync, unlinkSync } from 'fs';
import type { Server } from 'http';
import { get as httpGet, type IncomingMessage, type Server } from 'http';
import { basename, join } from 'path';
import { Readable } from 'stream';
import request from 'supertest';
@@ -34,6 +34,8 @@ import type {
MailboxCreate,
MailboxDeletion,
MailVacation,
MailWatch,
MailWatchListener,
MessageDeletionResult,
MessageListQuery,
MessageSource,
@@ -141,6 +143,9 @@ const TAKEN_MAILBOX_NAME = '이미 있는 이름';
/** 대역 메일 엔진의 비우기가 테스트가 문을 열 때까지 끝나지 않는 메일함 — 같은 메일함의 처리 중 잠금 확인용 */
const SLOW_MAILBOX = 'slowbox';
/** 대역 메일 엔진이 실시간 알림의 처음 연결을 받지 않는 주소 (503 MAIL_001 경로) */
const EVENTS_ENGINE_DOWN = 'events.down@e2e.arasystem.test';
/** 한 번도 설정하지 않은 계정의 부재중 자동 응답 (계약 2절) */
const NEVER_CONFIGURED_VACATION: MailVacation = {
isEnabled: false,
@@ -196,6 +201,53 @@ function listenOnAnyPort(server: Server): Promise<void> {
return new Promise((resolve) => server.listen(0, () => resolve()));
}
/** 열린 실시간 알림 연결 — 응답 머리와 받은 글자를 모은다 */
interface EventStreamClient {
readonly response: IncomingMessage;
/** 지금까지 받은 글자 */
received(): string;
/** 클라이언트가 끊는다 */
close(): void;
}
/**
* 실시간 알림(SSE)을 연다 — 닫을 때까지 끝나지 않는 응답이라 supertest(본문을 다 받을 때까지 기다린다) 대신 http.get 으로 읽는다.
* 연결을 모아 두지 않는다(agent: false) — 끊으면 바로 서버에서도 끊긴다
*/
function openEventStream(
server: Server,
cookie: string,
): Promise<EventStreamClient> {
const address = server.address();
const port = typeof address === 'object' && address ? address.port : 0;
return new Promise((resolve, reject) => {
const request = httpGet(
{
host: '127.0.0.1',
port,
path: '/api/mail-events',
headers: { cookie, accept: 'text/event-stream' },
agent: false,
},
(response) => {
let text = '';
response.setEncoding('utf8');
response.on('data', (chunk: string) => {
text += chunk;
});
// 끊은 뒤의 오류(ECONNRESET 등)는 테스트와 상관없다
response.on('error', () => undefined);
resolve({
response,
received: () => text,
close: () => request.destroy(),
});
},
);
request.on('error', reject);
});
}
/** 서버를 닫는다 — 열린 연결이 모두 끝나야 끝난다 */
function closeServer(server: Server): Promise<void> {
return new Promise((resolve, reject) =>
@@ -269,6 +321,13 @@ describe('응답 계약 (e2e)', () => {
update: MessageUpdate;
}[] = [];
/** 5단계 — 대역 엔진에 열린 실시간 알림 연결 (직원마다 하나여야 한다) */
const liveWatches: {
user: string;
listener: MailWatchListener;
closed: boolean;
}[] = [];
/** 이 앱의 서명 키로 토큰을 만든다 (CI 처럼 JWT_SECRET 이 없으면 임시 키) */
const sign = (payload: object, expiresIn: number) =>
jwt.sign(payload, {
@@ -385,6 +444,28 @@ describe('응답 계약 (e2e)', () => {
Promise.resolve(correspondents.get(user) ?? []),
// 3단계 — 엔진 쪽 판단(검색 조건 · 조건부 영구 삭제)은 jmap-mail.provider.spec.ts 가 본다. 여기서는 받은 값만 기록한다
getMailState: () => Promise.resolve({ state: MAIL_STATE }),
// 5단계 — 엔진 연결(다시 잇기 · 새 메일 계산)은 jmap-mail-watch.spec.ts · jmap-mail.provider.spec.ts 가 본다.
// 여기서는 처음 상태만 주고, 소식은 테스트가 listener 로 흘려 넣는다
watchMail: (
user: string,
listener: MailWatchListener,
): Promise<MailWatch> => {
if (user === EVENTS_ENGINE_DOWN) {
return Promise.reject(
new BusinessException('MAIL_001', {
context: { reason: 'e2e-engine-down' },
}),
);
}
const watch = { user, listener, closed: false };
liveWatches.push(watch);
return Promise.resolve({
initial: { state: MAIL_STATE, inboxUnread: 2 },
close: () => {
watch.closed = true;
},
});
},
listMessages: (user: string, query: MessageListQuery) => {
listQueries.push({ user, query });
return Promise.resolve({
@@ -935,6 +1016,7 @@ describe('응답 계약 (e2e)', () => {
],
['get', '/api/recipient-suggestions'],
['get', '/api/mail-state'],
['get', '/api/mail-events'],
['get', '/api/messages?q=%ED%9A%8C%EC%9D%98'],
['patch', '/api/messages'],
['post', '/api/message-deletions'],
@@ -2443,6 +2525,127 @@ describe('응답 계약 (e2e)', () => {
});
});
/**
* 5단계 — 실시간 알림 (계약 2절 "실시간 알림").
* 엔진 연결(다시 잇기 · 무소식 감시 · 새 메일 계산)과 연결 관리(나눠 쓰기 · 30초 · 토큰 만료 · 종료)는 단위 테스트가 본다 —
* 여기서는 실제 HTTP 의 머리글 · 이벤트 형식 · 연결 전 실패의 표준 JSON 을 본다.
* 응답이 끝나지 않으므로 서버를 미리 열어 http.get 으로 읽는다 (로그인 동시 시도 테스트와 같은 이유로 테스트가 끝나면 닫는다)
*/
describe('5단계 — 실시간 알림 (GET /api/mail-events)', () => {
/** 받은 글자에 빈 줄로 끝난 이벤트가 n 개 쌓일 때까지 기다린다 */
const eventsIn = (text: string) =>
text.split('\n\n').filter((block) => block !== '');
const waitForEvents = (stream: EventStreamClient, count: number) =>
waitUntil(
() =>
stream.received().endsWith('\n\n') &&
eventsIn(stream.received()).length >= count,
GATE_DEADLINE_MS,
);
/** 서버를 미리 열고, 끝나면 연 연결을 모두 끊고 닫는다 */
async function withListeningServer(
run: (httpServer: Server, opened: EventStreamClient[]) => Promise<void>,
): Promise<void> {
const httpServer = app.getHttpServer();
const startedServer = !httpServer.listening;
if (startedServer) await listenOnAnyPort(httpServer);
const opened: EventStreamClient[] = [];
try {
await run(httpServer, opened);
} finally {
for (const stream of opened) stream.close();
if (startedServer) await closeServer(httpServer);
}
}
it(
'200 text/event-stream — 머리글(charset · no-cache, no-transform · X-Accel-Buffering: no)과 처음 state, 그 뒤 엔진 소식을 이어서 보낸다',
async () => {
const ME = 'events.me@e2e.arasystem.test';
await withListeningServer(async (httpServer, opened) => {
const stream = await openEventStream(httpServer, cookieOf(ME));
opened.push(stream);
await waitForEvents(stream, 1);
const { headers, statusCode } = stream.response;
expect(statusCode).toBe(200);
expect(headers['content-type']).toBe(
'text/event-stream; charset=utf-8',
);
expect(headers['cache-control']).toBe('no-cache, no-transform');
expect(headers['x-accel-buffering']).toBe('no');
expect(typeof headers['x-request-id']).toBe('string');
// 표준 래퍼 없이 — 상태 값은 GET /api/mail-state 와 같다
expect(stream.received()).toBe(
`event: state\ndata: ${JSON.stringify({ state: MAIL_STATE, inboxUnread: 2 })}\n\n`,
);
const watch = liveWatches.find(({ user }) => user === ME);
expect(watch).toBeDefined();
const newMail = {
count: 1,
messages: [
{
id: 'm1',
from: {
name: '박서준',
email: 'seojun.park@e2e.arasystem.test',
},
subject: '줄\n바꿈이 있는 제목',
receivedAt: '2026-10-28T05:42:00.000Z',
},
],
};
watch?.listener.state({ state: 'c2Iz.c2Fr', inboxUnread: 3 });
watch?.listener.newMail(newMail);
await waitForEvents(stream, 3);
expect(eventsIn(stream.received()).slice(1)).toEqual([
`event: state\ndata: ${JSON.stringify({ state: 'c2Iz.c2Fr', inboxUnread: 3 })}`,
`event: new-mail\ndata: ${JSON.stringify(newMail)}`,
]);
});
},
GATED_TEST_TIMEOUT_MS,
);
it('처음 연결 때 엔진에 연결할 수 없으면 스트림을 열지 않고 503 MAIL_001 (표준 실패 JSON)', async () => {
const res = await request(server)
.get('/api/mail-events')
.set('Cookie', cookieOf(EVENTS_ENGINE_DOWN))
.expect(503);
expect(res.headers['content-type']).toMatch(/^application\/json/);
expect(body(res).success).toBe(false);
expect(body(res).error?.code).toBe('MAIL_001');
expect(body(res).error?.traceId).toBe(res.headers['x-request-id']);
});
it(
'한 직원의 연결이 5개를 넘으면 429 REQ_001 (표준 실패 JSON) — 엔진 연결은 하나를 나눠 쓴다',
async () => {
const ME = 'events.limit@e2e.arasystem.test';
await withListeningServer(async (httpServer, opened) => {
for (let i = 0; i < 5; i += 1) {
const stream = await openEventStream(httpServer, cookieOf(ME));
opened.push(stream);
await waitForEvents(stream, 1);
expect(stream.response.statusCode).toBe(200);
}
const res = await request(httpServer)
.get('/api/mail-events')
.set('Cookie', cookieOf(ME))
.expect(429);
expect(body(res).error?.code).toBe('REQ_001');
expect(liveWatches.filter(({ user }) => user === ME)).toHaveLength(1);
});
},
GATED_TEST_TIMEOUT_MS,
);
});
/**
* 출처 검사(CSRF) — 상태를 바꾸는 요청은 Sec-Fetch-Site(same-origin·none) 또는 허용 목록의 Origin 이어야 한다.
* 둘 다 없으면 브라우저가 아닌 클라이언트로 보고 통과시킨다 (docs/mail/api-contract.md 머리말).
+70
View File
@@ -0,0 +1,70 @@
/**
* 메일 엔진의 변화 소식 스트림(JMAP EventSource) 대역 — 단위 테스트 공용.
* fetch 본문처럼 바이트를 흘려보내고, 읽는 쪽이 닫으면(abort) 읽던 자리에 오류를 낸다.
* 테스트가 `send` · `end` 로 엔진 쪽을 흉내 낸다. 타이머를 쓰지 않는다 (promise 로만 돈다 — 가짜 타이머와 함께 써도 된다).
*/
const encoder = new TextEncoder();
export class FakeEventStream {
/** 읽는 쪽이 닫았다 */
aborted = false;
private readonly queue: (Uint8Array | Error | null)[] = [];
private wake: (() => void) | null = null;
constructor(signal: AbortSignal) {
const abort = () => {
this.aborted = true;
this.push(new Error('aborted'));
};
if (signal.aborted) abort();
else signal.addEventListener('abort', abort, { once: true });
}
/** 엔진이 글자를 보낸다 */
send(text: string): void {
this.push(encoder.encode(text));
}
/** 엔진이 스트림을 닫는다 */
end(): void {
this.push(null);
}
async *body(): AsyncGenerator<Uint8Array> {
for (;;) {
while (this.queue.length === 0) {
await new Promise<void>((resolve) => {
this.wake = resolve;
});
}
const item = this.queue.shift();
if (item === undefined || item === null) return;
if (item instanceof Error) throw item;
yield item;
}
}
private push(item: Uint8Array | Error | null): void {
this.queue.push(item);
this.wake?.();
this.wake = null;
}
}
/** 이 계정의 메일 · 메일함 변화 신호 — Stalwart 0.16.27 의 StateChange 모양 (2026-10-28 확인) */
export function stateChangeEvent(accountId: string): string {
return `event: state\ndata: ${JSON.stringify({
'@type': 'StateChange',
changed: { [accountId]: { Email: 'x', Mailbox: 'y' } },
})}\n\n`;
}
/** 엔진의 연결 유지 신호 */
export const PING_EVENT = 'event: ping\ndata: {"interval": 30}\n\n';
/** 미뤄 둔 비동기 처리(스트림 읽기 · 엔진 묻기의 결과)를 마저 돌린다 — 타이머는 움직이지 않는다 (setImmediate 는 가짜로 두지 않는다) */
export async function settleAsync(): Promise<void> {
for (let round = 0; round < 10; round += 1) {
await new Promise((resolve) => setImmediate(resolve));
}
}
+213
View File
@@ -0,0 +1,213 @@
<!doctype html>
<html lang="ko">
<head>
<meta charset="utf-8">
<title>실시간 · 바탕화면 알림 상태 모음</title>
<script src="./support.js"></script>
</head>
<body>
<x-dc>
<helmet>
<style>
@font-face{font-family:'Pretendard';src:url('/_blob/16067b9aa5137abf808e3dbef30c42e0') format('woff2-variations'),url('/_blob/16067b9aa5137abf808e3dbef30c42e0') format('woff2');font-weight:45 920;font-style:normal;font-display:swap}
body{margin:0}
a{color:#0E7C86}a:hover{color:#0A5F67}
:focus-visible{outline:2px solid #0E7C86;outline-offset:2px}
.on-dark:focus-visible{outline-color:#9ED6DB}
.ghost{background:transparent;color:#344054}
.ghost:hover{background:#F2F4F7;color:#171C26}
.bar-action{background:transparent;color:#0A5F67}
.bar-action:hover{background:#E3F2F3}
.bar-close{background:transparent;color:#667085}
.bar-close:hover{background:#E3F2F3;color:#344054}
</style>
</helmet>
<div style="width: 1440px; min-height: 1520px; box-sizing: border-box; padding: 48px; display: flex; flex-direction: column; gap: 28px; background: #F6F7F9; color: #171C26; font-family: Pretendard, -apple-system, 'Apple SD Gothic Neo', 'Malgun Gothic', system-ui, sans-serif; font-size: 13px; line-height: 1.5">
<div style="display: flex; flex-direction: column; gap: 6px; max-width: 920px">
<h1 style="margin: 0; font-size: 24px; font-weight: 700; letter-spacing: -0.02em">실시간 새 메일 · 바탕화면 알림</h1>
<p style="margin: 0; font-size: 13.5px; color: #4A5363">5단계에서 바뀌는 것들입니다. 메일 화면의 모양은 그대로이고, 새 메일이 1분을 기다리지 않고 바로 나타납니다. 바탕화면 알림은 설정 › 메일 › 알림에서 켜고, 「설정 · 알림」 보드에서 눌러 볼 수 있습니다.</p>
</div>
<section style="display: flex; flex-direction: column; gap: 14px; padding: 20px; background: #FFFFFF; border: 1px solid #E6E8EC; border-radius: 12px">
<div style="display: flex; flex-direction: column; gap: 2px">
<h2 style="margin: 0; font-size: 14px; font-weight: 700">목록 위에서 한 번 권하기 — 알림을 아직 고르지 않았을 때</h2>
<p style="margin: 0; font-size: 12px; color: #667085">새 메일 알림 줄과 같은 높이 40 (밀도 예외) · 닫으면 다시 보이지 않는다</p>
</div>
<div style="display: grid; grid-template-columns: minmax(0, 1fr) 360px; gap: 24px; align-items: start">
<div style="display: flex; flex-direction: column; border: 1px solid #E6E8EC; border-radius: 10px; overflow: hidden; background: #FFFFFF">
<div style="display: flex; align-items: center; gap: 10px; min-height: 52px; box-sizing: border-box; padding: 0 12px 0 16px; border-bottom: 1px solid #EEF0F3">
<span style="font-size: 15px; font-weight: 700">받은편지함</span>
<span style="font-size: 12px; color: #667085">전체 128 · 안 읽음 3</span>
</div>
<div role="region" aria-label="바탕화면 알림 권하기" style="display: flex; align-items: center; gap: 10px; height: 40px; box-sizing: border-box; padding: 0 4px 0 14px; background: #F3F8F8; border-bottom: 1px solid #E3EEEF; color: #0B3640">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="#0A5F67" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" style="flex: 0 0 auto"><path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9"></path><path d="M13.73 21a2 2 0 0 1-3.46 0"></path></svg>
<span style="flex: 1 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap">바탕화면 알림을 켜 두면 다른 창을 보는 동안에도 새 메일을 알려 드립니다.</span>
<button type="button" class="bar-action" style="flex: 0 0 auto; height: 36px; padding: 0 10px; border: 0; border-radius: 6px; font: inherit; font-weight: 600; cursor: pointer">알림 켜기</button>
<button type="button" class="bar-close" aria-label="닫기" title="닫기" style="flex: 0 0 auto; width: 36px; height: 36px; display: inline-flex; align-items: center; justify-content: center; border: 0; border-radius: 6px; cursor: pointer"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line></svg></button>
</div>
<div style="display: flex; flex-direction: column">
<div style="display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 2px 12px; padding: 10px 16px; border-bottom: 1px solid #F2F4F7">
<span style="font-weight: 700">박서준</span><span style="font-size: 12px; font-weight: 600; color: #344054">오후 2:42</span>
<span style="font-weight: 700; color: #171C26">11월 납품분 견적서 보내 드립니다</span>
</div>
<div style="display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 2px 12px; padding: 10px 16px">
<span style="color: #344054">이도윤</span><span style="font-size: 12px; color: #667085">오후 1:05</span>
<span style="color: #4A5363">Re: 메일 서버 이전 일정 공유</span>
</div>
</div>
</div>
<ul style="margin: 0; padding: 0 0 0 18px; display: flex; flex-direction: column; gap: 6px; font-size: 12.5px; color: #4A5363">
<li>알림을 켜지도 닫지도 않은 사람에게 메일 화면에서 한 번만 보입니다(브라우저 · 사용자마다).</li>
<li>[알림 켜기] → 브라우저가 묻습니다 → 허용하면 "바탕화면 알림을 켰습니다." 알림.</li>
<li>[닫기]를 누르면 다시 보이지 않습니다 — 설정 › 알림에서 언제든 켤 수 있습니다.</li>
<li>브라우저가 이미 막았거나 지원하지 않으면 보이지 않습니다.</li>
</ul>
</div>
</section>
<div style="display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 20px; align-items: start">
<section style="display: flex; flex-direction: column; gap: 14px; padding: 20px; background: #FFFFFF; border: 1px solid #E6E8EC; border-radius: 12px">
<div style="display: flex; flex-direction: column; gap: 2px">
<h2 style="margin: 0; font-size: 14px; font-weight: 700">바탕화면 알림 — 한 통</h2>
<p style="margin: 0; font-size: 12px; color: #667085">제목: 보낸 사람 · 내용: 메일 제목</p>
</div>
<div role="img" aria-label="한 통이 왔을 때의 바탕화면 알림 예" style="display: flex; flex-direction: column; gap: 10px; padding: 12px 14px 14px; border-radius: 10px; background: #FFFFFF; border: 1px solid #E3E5E8; box-shadow: 0 10px 28px rgba(16, 24, 28, 0.16)">
<div style="display: flex; align-items: center; gap: 8px; font-size: 11.5px; color: #667085">
<span aria-hidden="true" style="display: inline-flex; width: 14px; height: 14px; border-radius: 3px; background: #0B3640"></span><span>Microsoft Edge</span><span style="margin-left: auto">지금</span>
</div>
<div style="display: flex; gap: 12px">
<span aria-hidden="true" style="flex: 0 0 44px; display: inline-flex; align-items: center; justify-content: center; width: 44px; height: 44px; border-radius: 10px; background: #0B3640"><img src="/_blob/2b9ef4349333debc969432c859eaf1bf" alt="" width="34" height="12" style="display: block; width: 34px; height: auto"></span>
<div style="min-width: 0; display: flex; flex-direction: column; gap: 2px">
<span style="font-size: 13.5px; font-weight: 700">박서준</span>
<span style="font-size: 13px; color: #344054">11월 납품분 견적서 보내 드립니다</span>
<span style="font-size: 11.5px; color: #98A2B3">portal.arasystem.it.kr</span>
</div>
</div>
</div>
<p style="margin: 0; font-size: 12.5px; color: #4A5363">누르면 웹메일 탭으로 와서 그 메일을 엽니다. 이름이 없는 보낸 사람은 주소를 씁니다.</p>
</section>
<section style="display: flex; flex-direction: column; gap: 14px; padding: 20px; background: #FFFFFF; border: 1px solid #E6E8EC; border-radius: 12px">
<div style="display: flex; flex-direction: column; gap: 2px">
<h2 style="margin: 0; font-size: 14px; font-weight: 700">여러 통이 한꺼번에</h2>
<p style="margin: 0; font-size: 12px; color: #667085">한 번에 묶어 하나만</p>
</div>
<div role="img" aria-label="여러 통이 왔을 때의 바탕화면 알림 예" style="display: flex; flex-direction: column; gap: 10px; padding: 12px 14px 14px; border-radius: 10px; background: #FFFFFF; border: 1px solid #E3E5E8; box-shadow: 0 10px 28px rgba(16, 24, 28, 0.16)">
<div style="display: flex; align-items: center; gap: 8px; font-size: 11.5px; color: #667085">
<span aria-hidden="true" style="display: inline-flex; width: 14px; height: 14px; border-radius: 3px; background: #0B3640"></span><span>Microsoft Edge</span><span style="margin-left: auto">지금</span>
</div>
<div style="display: flex; gap: 12px">
<span aria-hidden="true" style="flex: 0 0 44px; display: inline-flex; align-items: center; justify-content: center; width: 44px; height: 44px; border-radius: 10px; background: #0B3640"><img src="/_blob/2b9ef4349333debc969432c859eaf1bf" alt="" width="34" height="12" style="display: block; width: 34px; height: auto"></span>
<div style="min-width: 0; display: flex; flex-direction: column; gap: 2px">
<span style="font-size: 13.5px; font-weight: 700">새 메일 3통</span>
<span style="font-size: 13px; color: #344054">박서준 · 이도윤 외 1명</span>
<span style="font-size: 11.5px; color: #98A2B3">portal.arasystem.it.kr</span>
</div>
</div>
</div>
<p style="margin: 0; font-size: 12.5px; color: #4A5363">누르면 받은편지함을 엽니다. 새 알림은 앞의 알림을 바꿔 써서 바탕화면에 쌓이지 않습니다.</p>
</section>
<section style="display: flex; flex-direction: column; gap: 14px; padding: 20px; background: #FFFFFF; border: 1px solid #E6E8EC; border-radius: 12px">
<div style="display: flex; flex-direction: column; gap: 2px">
<h2 style="margin: 0; font-size: 14px; font-weight: 700">메일 제목 숨김</h2>
<p style="margin: 0; font-size: 12px; color: #667085">설정에서 「메일 제목도 보이기」를 껐을 때</p>
</div>
<div role="img" aria-label="제목을 숨긴 바탕화면 알림 예" style="display: flex; flex-direction: column; gap: 10px; padding: 12px 14px 14px; border-radius: 10px; background: #FFFFFF; border: 1px solid #E3E5E8; box-shadow: 0 10px 28px rgba(16, 24, 28, 0.16)">
<div style="display: flex; align-items: center; gap: 8px; font-size: 11.5px; color: #667085">
<span aria-hidden="true" style="display: inline-flex; width: 14px; height: 14px; border-radius: 3px; background: #0B3640"></span><span>Microsoft Edge</span><span style="margin-left: auto">지금</span>
</div>
<div style="display: flex; gap: 12px">
<span aria-hidden="true" style="flex: 0 0 44px; display: inline-flex; align-items: center; justify-content: center; width: 44px; height: 44px; border-radius: 10px; background: #0B3640"><img src="/_blob/2b9ef4349333debc969432c859eaf1bf" alt="" width="34" height="12" style="display: block; width: 34px; height: auto"></span>
<div style="min-width: 0; display: flex; flex-direction: column; gap: 2px">
<span style="font-size: 13.5px; font-weight: 700">박서준</span>
<span style="font-size: 13px; color: #344054">새 메일이 왔습니다</span>
<span style="font-size: 11.5px; color: #98A2B3">portal.arasystem.it.kr</span>
</div>
</div>
</div>
<p style="margin: 0; font-size: 12.5px; color: #4A5363">여러 통이면 "새 메일 3통" · "박서준 · 이도윤 외 1명" 그대로입니다(제목만 뺍니다).</p>
</section>
</div>
<div style="display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 20px; align-items: start">
<section style="display: flex; flex-direction: column; gap: 12px; padding: 20px; background: #FFFFFF; border: 1px solid #E6E8EC; border-radius: 12px">
<h2 style="margin: 0; font-size: 14px; font-weight: 700">언제 알리나</h2>
<ul style="margin: 0; padding: 0 0 0 18px; display: flex; flex-direction: column; gap: 6px; color: #344054">
<li><strong style="font-weight: 600">받은편지함에 새로 배달된 안 읽은 메일</strong>만 — 내가 보낸 메일 · 임시 저장 · 다른 메일 프로그램이 다른 메일함으로 옮긴 메일은 알리지 않습니다.</li>
<li><strong style="font-weight: 600">웹메일 화면을 보고 있으면 알리지 않습니다</strong> — 목록 · 메일함 숫자 · 탭 제목 "(3)" 에 바로 보이므로. 다른 탭 · 다른 프로그램을 보고 있을 때만.</li>
<li>웹메일 탭이 여러 개여도 <strong style="font-weight: 600">한 번</strong>만 알립니다(브라우저마다 연결 하나).</li>
<li>운영체제의 방해 금지 · 집중 모드에서는 운영체제가 숨깁니다. 소리도 운영체제 설정을 따릅니다.</li>
<li>웹메일 탭을 모두 닫으면 알리지 않습니다 — 탭을 닫아도 오는 알림은 다음 단계입니다.</li>
</ul>
</section>
<section style="display: flex; flex-direction: column; gap: 12px; padding: 20px; background: #FFFFFF; border: 1px solid #E6E8EC; border-radius: 12px">
<h2 style="margin: 0; font-size: 14px; font-weight: 700">새 메일이 화면에 나타나는 때</h2>
<div style="display: flex; align-items: center; gap: 10px; flex-wrap: wrap">
<span style="padding: 6px 10px; border-radius: 8px; background: #F2F4F7; font-weight: 600">상대가 보냄</span>
<span aria-hidden="true" style="color: #98A2B3">→</span>
<span style="padding: 6px 10px; border-radius: 8px; background: #E3F2F3; color: #0A5F67; font-weight: 600">1초 안</span>
<span aria-hidden="true" style="color: #98A2B3">→</span>
<span style="padding: 6px 10px; border-radius: 8px; background: #F2F4F7; font-weight: 600">목록 · 메일함 숫자 · 탭 제목</span>
</div>
<p style="margin: 0; color: #344054">지금은 1분마다 확인해서 최대 1분 늦습니다. 화면 모양은 그대로이고, 목록을 내려 보고 있으면 지금처럼 "새 메일 N통 · 맨 위로" 줄이 뜹니다.</p>
<p style="margin: 0; font-size: 12.5px; color: #667085">실시간 연결이 끊기면 저절로 1분 확인으로 돌아가고 다시 잇습니다 — 화면에는 알리지 않습니다(설정 › 알림에서 연결 상태를 볼 수 있습니다).</p>
</section>
<section style="display: flex; flex-direction: column; gap: 14px; padding: 20px; background: #FFFFFF; border: 1px solid #E6E8EC; border-radius: 12px">
<div style="display: flex; flex-direction: column; gap: 2px">
<h2 style="margin: 0; font-size: 14px; font-weight: 700">설정 › 알림 — 브라우저가 막았을 때 · 지원하지 않을 때</h2>
<p style="margin: 0; font-size: 12px; color: #667085">켜기 칸 아래의 안내가 바뀐다</p>
</div>
<div style="display: flex; flex-direction: column; gap: 6px; padding: 12px 14px; border: 1px solid #E6E8EC; border-radius: 10px">
<label style="display: flex; align-items: center; gap: 10px; min-height: 44px; color: #98A2B3">
<input type="checkbox" disabled style="width: 18px; height: 18px; margin: 0">
<span style="font-weight: 600">새 메일이 오면 바탕화면 알림 보내기</span>
</label>
<p style="margin: 0 0 0 28px; display: flex; align-items: flex-start; gap: 6px; font-size: 12.5px; color: #B42318">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" style="flex: 0 0 auto; margin-top: 2px"><circle cx="12" cy="12" r="10"></circle><line x1="12" y1="8" x2="12" y2="12"></line><line x1="12" y1="16" x2="12.01" y2="16"></line></svg>
<span>브라우저가 이 사이트의 알림을 막고 있습니다. 주소창 왼쪽의 사이트 정보(자물쇠) › 알림을 「허용」으로 바꾼 뒤 이 화면을 새로 고쳐 주세요.</span>
</p>
</div>
<div style="display: flex; flex-direction: column; gap: 6px; padding: 12px 14px; border: 1px solid #E6E8EC; border-radius: 10px">
<label style="display: flex; align-items: center; gap: 10px; min-height: 44px; color: #98A2B3">
<input type="checkbox" disabled style="width: 18px; height: 18px; margin: 0">
<span style="font-weight: 600">새 메일이 오면 바탕화면 알림 보내기</span>
</label>
<p style="margin: 0 0 0 28px; font-size: 12.5px; color: #667085">이 브라우저는 바탕화면 알림을 지원하지 않습니다. 새 메일은 그래도 바로 목록에 나타납니다.</p>
</div>
</section>
<section style="display: flex; flex-direction: column; gap: 14px; padding: 20px; background: #FFFFFF; border: 1px solid #E6E8EC; border-radius: 12px">
<div style="display: flex; flex-direction: column; gap: 2px">
<h2 style="margin: 0; font-size: 14px; font-weight: 700">알림 문구</h2>
<p style="margin: 0; font-size: 12px; color: #667085">오른쪽 아래 · 화면 안 알림</p>
</div>
<div role="status" style="display: flex; align-items: center; gap: 10px; min-height: 44px; box-sizing: border-box; padding: 6px 14px; border-radius: 10px; background: #0B3640; color: #FFFFFF">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#9FE0B5" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" style="flex: 0 0 auto"><polyline points="20 6 9 17 4 12"></polyline></svg>
<span style="flex: 1 1 auto; font-weight: 500">바탕화면 알림을 켰습니다.</span>
</div>
<div role="alert" style="display: flex; align-items: center; gap: 10px; padding: 6px 6px 6px 14px; border-radius: 10px; background: #0B3640; color: #FFFFFF">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#FDA29B" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" style="flex: 0 0 auto"><circle cx="12" cy="12" r="10"></circle><line x1="12" y1="8" x2="12" y2="12"></line><line x1="12" y1="16" x2="12.01" y2="16"></line></svg>
<span style="flex: 1 1 auto; font-weight: 500">브라우저가 알림을 막고 있습니다. 푸는 방법은 설정 › 알림에 있습니다.</span>
<a href="SettingsNotify.dc.html" class="on-dark" style="flex: 0 0 auto; display: inline-flex; align-items: center; min-height: 44px; padding: 0 10px; border-radius: 6px; color: #9ED6DB; font-weight: 600; text-decoration: none">설정 열기</a>
</div>
<p style="margin: 0; font-size: 12.5px; color: #4A5363">목록 위 [알림 켜기] · 설정의 켜기 칸에서 브라우저가 묻고 난 뒤의 결과입니다. 「차단」을 누른 사람에게 브라우저는 다시 묻지 않습니다.</p>
</section>
</div>
</div>
</x-dc>
<script type="text/x-dc" data-dc-script data-props='{"$preview":{"width":1440,"height":1520}}'>
class Component extends DCLogic {
renderVals() {
return {};
}
}
</script>
</body>
</html>
+66 -2
View File
@@ -1,14 +1,15 @@
# 웹메일 디자인 원본 (1~4단계)
# 웹메일 디자인 원본 (1~5단계)
확정된 디자인 캔버스의 원본이다. 화면 구현의 기준으로 쓴다.
- 캔버스: https://claude.ai/artifact/WJvVsKvXepxn3p75CGJAr2 (확정본: 2026-10-28, 버전 23)
- 캔버스: https://claude.ai/artifact/WJvVsKvXepxn3p75CGJAr2 (확정본: 2026-10-28, 버전 27)
- 화면: `Login`(로그인) · `Main`(분할 보기: 메일함·목록·본문) · `MainList`(목록 보기: 게시판형 표) · `MainListRead`(목록 보기에서 연 메일)
· `Compose`(메일 쓰기 — 서식 편집기) · `ComposeEditor`(메일 쓰기 · 편집기 상태: 포커스·툴바·메뉴·팝오버·인용·이미지 첨부)
· `States`(오류·빈 상태·피드백 모음)
· 2단계: `Settings`(설정 · 메일 서명) · `ComposeAssist`(메일 쓰기 · 임시 저장 · 받는 사람 자동 완성 · 서명) — 아래 "2단계"
· 3단계: `Search`(메일 검색 · 분할 보기) · `BulkSelect`(여러 통 선택 · 목록 보기) · `ListStates`(새 메일 · 검색 · 여러 통 처리 상태) — 아래 "3단계"
· 4단계: `SettingsAccount`(설정 · 비밀번호) · `SettingsVacation`(설정 · 부재중 자동 응답) · `MailboxManage`(메일함 관리 · 휴지통 비우기) · `MailActions4`(스팸 · 원문 · 오래 걸리는 처리) — 아래 "4단계"
· 5단계: `SettingsNotify`(설정 · 알림) · `MailRealtime`(실시간 새 메일 · 바탕화면 알림 상태) — 아래 "5단계"
- 파일 형식은 캔버스 전용(`<x-dc>` 마크업 + `DCLogic` 클래스)이다. 그대로 쓰지 말고 **구조·간격·색·문구의 기준**으로만 본다.
`Compose` 의 툴바·메뉴는 눌러 볼 수 있는 시안이다 — 버튼 상태만 바뀌고 본문 글자에 서식이 입혀지지는 않는다.
@@ -309,6 +310,42 @@ API 와 서버 규칙은 계약 1절 "`PUT /api/auth/password`", 2절 "메일함
- 알림 문구: "메일 2통을 스팸메일함으로 옮겼습니다." [되돌리기] · "메일 1통을 받은편지함으로 옮겼습니다 (스팸 아님)." [되돌리기] ·
"‘협력사’ 메일함을 만들었습니다." · "‘거래처’ 메일함을 지웠습니다. 안의 메일 12통은 휴지통에 있습니다." · "휴지통을 비웠습니다 — 메일 21통." (비우기 · 지우기는 되돌리기 없음)
## 5단계 — 실시간 새 메일 · 바탕화면 알림 (2026-10-28 확정 — `SettingsNotify` · `MailRealtime`)
API 와 서버 규칙은 계약 2절 "실시간 알림".
### 설정 › 메일 › 알림
- 메뉴의 "메일" 묶음 끝에 「알림」(종 아이콘).
- [x] 새 메일이 오면 바탕화면 알림 보내기(44). 켜면 브라우저가 허락을 묻는다. 칸 아래 안내(12.5px):
묻기 전 "켜면 브라우저가 알림을 허락할지 한 번 묻습니다 — 「허용」을 눌러 주세요." · 허용됨 "이 브라우저에서 알림이 허용되어 있습니다."(#067647, 체크 아이콘) ·
막힘(칸을 끄고 빨강 #B42318) "브라우저가 이 사이트의 알림을 막고 있습니다. 주소창 왼쪽의 사이트 정보(자물쇠) › 알림을 「허용」으로 바꾼 뒤 이 화면을 새로 고쳐 주세요." ·
지원하지 않음 "이 브라우저는 바탕화면 알림을 지원하지 않습니다. 새 메일은 그래도 바로 목록에 나타납니다."
- 알림에 보일 것: [x] 메일 제목도 보이기 — "끄면 보낸 사람 이름만 보입니다 — 화면을 다른 사람과 함께 보는 자리라면 꺼 두세요." (알림을 끄면 흐리게)
- [시험 알림 보내기](보조 단추 44 · 종 아이콘) — 알림 "ARA 웹메일" / "알림이 잘 옵니다. 새 메일이 오면 이렇게 알려 드립니다."
- 실시간 연결: ● 초록 "연결되어 있습니다 — 새 메일이 오면 바로 목록에 나타납니다." / 끊겼을 때 "연결할 수 없어 1분마다 확인합니다." (저절로 다시 잇는다)
- 아래 안내 상자(바탕 #F3F8F8): 이 브라우저에만 적용 · 웹메일 탭이 하나라도 열려 있어야 · 웹메일 화면을 보고 있을 때는 보내지 않음.
### 목록 위에서 한 번 권하기
- 알림을 켜지도 닫지도 않은 사람에게 메일 화면의 목록 머리 아래에 40 줄(바탕 #F3F8F8 — 새 메일 알림 줄과 같은 높이, 밀도 예외):
종 아이콘 + "바탕화면 알림을 켜 두면 다른 창을 보는 동안에도 새 메일을 알려 드립니다." [알림 켜기](강조 글자 굵게) [✕ 닫기].
- [알림 켜기] → 브라우저가 묻는다 → 허용: 알림 "바탕화면 알림을 켰습니다." / 막힘: 오류 알림 "브라우저가 알림을 막고 있습니다. 푸는 방법은 설정 › 알림에 있습니다." [설정 열기].
- [✕]는 다시 보이지 않는다(브라우저 · 사용자마다). 브라우저가 이미 막았거나 지원하지 않으면 보이지 않는다.
### 바탕화면 알림
- 모양은 운영체제 · 브라우저가 그린다 — 정하는 것은 제목 · 내용 · 아이콘(ARA 마크, 바탕 #0B3640 둥근 사각)뿐이다.
- 한 통: 제목 = 보낸 사람(이름, 없으면 주소), 내용 = 메일 제목. 여러 통: "새 메일 3통" / "박서준 · 이도윤 외 1명". 제목 숨김: 내용 "새 메일이 왔습니다".
- 새 알림은 앞의 알림을 바꿔 쓴다(쌓이지 않는다). 누르면 웹메일 탭으로 와서 그 메일을 연다(여러 통이면 받은편지함).
- 받은편지함에 새로 배달된 안 읽은 메일만(내가 보낸 메일 · 임시 저장 · 다른 메일함으로 옮겨진 메일은 아님), 웹메일을 보고 있지 않을 때만(보이고 초점이 있는 탭이 없을 때), 탭이 여러 개여도 한 번.
### 실시간 반영
- 화면 모양은 그대로다 — 새 메일이 1초 안에 목록 · 메일함 숫자 · 탭 제목 "(N)" 에 나타난다(지금은 최대 1분). 목록을 내려 보고 있으면 지금처럼 "새 메일 N통 · 맨 위로" 줄.
- 다른 탭을 보는 동안에도 탭 제목의 "(N)" 이 바로 바뀐다.
- 실시간 연결이 끊기면 화면에는 알리지 않고 1분 확인으로 돌아간다(설정 › 알림의 연결 상태로 볼 수 있다).
## 구현하며 정한 보완 (2026-10-26 — 캔버스에는 아직 없다)
- 찾지 못한 첨부(보관 시간이 지난 업로드·지워진 원본)는 실패로 표시하고, 전달 원본에는 "빼기"만 둔다(다시 올릴 수 없다).
@@ -402,3 +439,30 @@ API 와 서버 규칙은 계약 1절 "`PUT /api/auth/password`", 2절 "메일함
- 목록 머리 「비우기」의 확인은 연 그 메일함만 비운다 — 확인 창이 열린 채 다른 메일함으로 가면(뒤로 가기 등) 창을 닫는다.
- 서버가 처리 수 0 을 세 번 연달아 주면 멈추고 실패로 알린다(같은 요청을 끝없이 보내지 않게).
- **스팸**: 「되돌리기」는 원래 메일함으로 옮기며 표시를 반대로 바꾼다 — 원래 아무 표시도 없던 메일은 "스팸 아님" 표시가 남는다(계약 2절 "스팸 · 원문").
### 5단계 구현하며 정한 보완 (2026-10-28 — 캔버스에는 아직 없다)
- **알림을 막았을 때의 알림**은 보드의 어두운 바탕 대신 기존 오류 알림 모양(흰 카드)을 쓴다. [설정 열기]는 브랜드 색.
- **"웹메일을 보고 있음"** 은 같은 사용자로 로그인한 이 앱의 탭 가운데 보이고 초점이 있는 탭이 있다는 뜻이다 — 설정 화면을 보고 있어도 보내지 않는다.
두 번째 모니터에 웹메일을 띄워 두고 다른 창에서 일하면 보낸다(권하기 문구 "다른 창을 보는 동안에도"와 맞게).
- **설정 › 알림**
- 허락은 했는데 알림을 꺼 둔 상태: 칸은 비우고 안내는 초록 "이 브라우저에서 알림이 허용되어 있습니다."(보드에 없는 상태).
- 실시간을 쓸 수 없는 브라우저(Web Locks · BroadcastChannel · 스트림 읽기 가운데 하나라도 없음)는 바탕화면 알림도 "지원하지 않습니다"로 본다 — 알림은 실시간으로만 온다.
- 연결 상태 글: 처음 잇는 중 "연결하는 중입니다…", 끊김 "연결할 수 없어 1분마다 확인합니다." 아래에 "저절로 다시 잇습니다. 회사 방화벽 · 프록시가 오래 열린 연결을 끊으면 이렇게 됩니다.",
지원하지 않음 "이 브라우저는 실시간 연결을 지원하지 않습니다."
- 주소는 `/settings/mail/notify`, 탭 제목은 "메일 알림"(머리글은 "알림").
- 켜기 칸에서 허락을 받은 결과는 칸 아래 안내로만 보인다(화면 알림은 목록 위 권하기 줄에서만 — 설정 화면에서 [설정 열기]는 어색하다).
- 연결이 끊긴 동안은 "연결할 수 없어 1분마다 확인합니다." 아래에 "연결될 때까지 바탕화면 알림은 오지 않습니다."를 더한다.
- 알림을 만들 수 없는 브라우저(휴대폰 Chrome 은 `new Notification()` 을 막는다)는 처음 실패할 때 "지원하지 않습니다"로 바꾼다.
- 한국어 안내 글은 낱말 가운데서 줄을 바꾸지 않는다(`word-break: keep-all`, 긴 주소는 `overflow-wrap: anywhere`).
- **목록 위 권하기 줄**은 메일함 목록에만 둔다(검색 결과에는 두지 않는다). 줄이 사라지면 초점을 목록 칸으로 옮긴다.
분할 보기의 좁은 목록 칸(384)에서는 짧은 글 "바탕화면 알림으로 새 메일 받기"를 쓴다(긴 글은 툴팁) — 긴 글은 잘려 끝까지 읽을 수 없었다.
- **바탕화면 알림**
- "외 N명"은 서버가 보낸 새 메일(최대 5통)에서 겹치지 않는 보낸 사람(주소 기준)으로 센다.
- 시험 알림도 새 메일 알림과 같은 자리(tag)를 쓴다. 누르면 창만 앞으로 온다.
- 알림을 눌렀을 때 받은편지함을 아직 모르면 메일함을 받아 본 뒤 열고, 그래도 모르면 메일 첫 화면으로 간다. 누르기 전에 로그아웃 · 다른 계정 로그인이 있었으면 이동하지 않는다.
- 아이콘은 192px PNG(`frontend/src/assets/brand/notification-icon.png` — ARA 마크를 #0B3640 둥근 사각 가운데에).
- 사용자가 웹메일로 돌아오면(어느 탭이든 보이고 초점이 생기면) 띄운 알림을 닫는다 — 알림 센터에 지난 알림이 남지 않게.
- 허락을 묻는 동안 단추 · 칸은 눌러도 다시 묻지 않을 뿐 초점을 잃지 않는다(`aria-disabled`). 막힘 · 지원 안 함으로 칸이 꺼지면 초점을 안내 글로 옮긴다.
- **탭 제목 "(N)"** 은 지금처럼 메일 화면에서만 붙인다(메일 밖 화면에는 받은편지함 수의 기준이 없다). 메일 밖에 있는 동안 생긴 바뀜은 남겨 두었다가 메일 화면으로 돌아오면 맞춘다.
- **실시간 반영 시간**: 실시간이 부른 목록 맞추기는 1초 모아서 한 번 한다(한 통을 읽어도 바뀜이 오므로 — 연달은 바뀜에 목록을 여러 번 받지 않게). 그래서 새 메일은 "1초 안"이 아니라 2초 안팎에 목록에 나타난다.
+222
View File
@@ -0,0 +1,222 @@
<!doctype html>
<html lang="ko">
<head>
<meta charset="utf-8">
<title>설정 · 알림</title>
<script src="./support.js"></script>
</head>
<body>
<x-dc>
<helmet>
<style>
@font-face{font-family:'Pretendard';src:url('/_blob/16067b9aa5137abf808e3dbef30c42e0') format('woff2-variations'),url('/_blob/16067b9aa5137abf808e3dbef30c42e0') format('woff2');font-weight:45 920;font-style:normal;font-display:swap}
body{margin:0}
a{color:#0E7C86}a:hover{color:#0A5F67}
:focus-visible{outline:2px solid #0E7C86;outline-offset:2px}
.ghost{background:transparent;color:#4A5363}
.ghost:hover{background:#F2F4F7;color:#171C26}
.secondary{background:#FFFFFF;color:#344054;border:1px solid #D0D5DD !important}
.secondary:hover{background:#F8F9FB;color:#171C26}
.secondary:disabled{background:#F8F9FB;color:#98A2B3;cursor:not-allowed}
.menu-link{color:#344054}
.menu-link:hover{background:#EEF0F3}
</style>
</helmet>
<div style="position: relative; min-height: 100vh; display: flex; background: #FFFFFF; color: #171C26; font-family: Pretendard, -apple-system, 'Apple SD Gothic Neo', 'Malgun Gothic', system-ui, sans-serif; font-size: 13px; line-height: 1.5">
<nav aria-label="사내 시스템" style="flex: 0 0 72px; box-sizing: border-box; display: flex; flex-direction: column; align-items: center; gap: 14px; padding: 10px 0 12px; background: #0B3640">
<a href="Main.dc.html" style="display: inline-flex; align-items: center; justify-content: center; width: 60px; min-height: 44px; border-radius: 10px; text-decoration: none">
<img src="/_blob/2b9ef4349333debc969432c859eaf1bf" alt="ARA SYSTEM" width="48" height="17" style="display: block; width: 48px; height: auto">
</a>
<ul style="list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 4px">
<li>
<a href="Main.dc.html" style="display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 3px; width: 60px; min-height: 56px; border-radius: 10px; color: #A3C2C6; font-size: 11px; font-weight: 500; text-decoration: none">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z"></path><polyline points="22,6 12,13 2,6"></polyline></svg>
<span>메일</span>
</a>
</li>
<li>
<a href="#" style="display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 3px; width: 60px; min-height: 56px; border-radius: 10px; color: #A3C2C6; font-size: 11px; font-weight: 500; text-decoration: none">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="4" width="18" height="18" rx="2" ry="2"></rect><line x1="16" y1="2" x2="16" y2="6"></line><line x1="8" y1="2" x2="8" y2="6"></line><line x1="3" y1="10" x2="21" y2="10"></line></svg>
<span>캘린더</span>
</a>
</li>
<li>
<a href="#" style="display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 3px; width: 60px; min-height: 56px; border-radius: 10px; color: #A3C2C6; font-size: 11px; font-weight: 500; text-decoration: none">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"></path></svg>
<span>파일</span>
</a>
</li>
<li>
<a href="#" style="display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 3px; width: 60px; min-height: 56px; border-radius: 10px; color: #A3C2C6; font-size: 11px; font-weight: 500; text-decoration: none">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="2" y="7" width="20" height="14" rx="2" ry="2"></rect><path d="M16 21V5a2 2 0 0 0-2-2h-4a2 2 0 0 0-2 2v16"></path></svg>
<span>ERP</span>
</a>
</li>
<li>
<a href="#" style="display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 3px; width: 60px; min-height: 56px; border-radius: 10px; color: #A3C2C6; font-size: 11px; font-weight: 500; text-decoration: none">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"></path><circle cx="9" cy="7" r="4"></circle><path d="M23 21v-2a4 4 0 0 0-3-3.87"></path><path d="M16 3.13a4 4 0 0 1 0 7.75"></path></svg>
<span>주소록</span>
</a>
</li>
</ul>
<a href="Settings.dc.html" aria-current="page" style="margin-top: auto; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 3px; width: 60px; min-height: 56px; border-radius: 10px; background: #155A64; color: #FFFFFF; font-size: 11px; font-weight: 600; text-decoration: none">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="3"></circle><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"></path></svg>
<span>설정</span>
</a>
</nav>
<div style="flex: 1 1 auto; min-width: 0; display: flex; flex-direction: column">
<header style="display: flex; align-items: center; flex-wrap: wrap; gap: 8px 16px; box-sizing: border-box; min-height: 56px; padding: 6px 12px 6px 20px; border-bottom: 1px solid #E6E8EC">
<span style="font-size: 16px; font-weight: 700; letter-spacing: -0.01em">설정</span>
<div style="flex: 1 1 280px; max-width: 520px; display: flex; align-items: center; gap: 8px; box-sizing: border-box; height: 40px; padding: 0 12px; border: 1px solid #8A94A3; border-radius: 8px; background: #FFFFFF">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#667085" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="11" cy="11" r="8"></circle><line x1="21" y1="21" x2="16.65" y2="16.65"></line></svg>
<input type="search" aria-label="통합 검색" placeholder="통합 검색" style="flex: 1 1 auto; min-width: 0; border: 0; background: transparent; font: inherit; color: #171C26">
</div>
<div style="margin-left: auto; display: flex; align-items: center; gap: 10px">
<span aria-hidden="true" style="display: inline-flex; align-items: center; justify-content: center; width: 32px; height: 32px; border-radius: 50%; background: #E3F2F3; color: #0A5F67; font-size: 13px; font-weight: 600">김</span>
<div style="display: flex; flex-direction: column; line-height: 1.3">
<span style="font-weight: 600">김아라</span>
<span style="font-size: 12px; color: #667085">ara.kim@arasystem.it.kr</span>
</div>
<a href="Login.dc.html" style="display: inline-flex; align-items: center; gap: 6px; min-height: 44px; padding: 0 10px; border-radius: 8px; color: #4A5363; font-weight: 500; text-decoration: none">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"></path><polyline points="16 17 21 12 16 7"></polyline><line x1="21" y1="12" x2="9" y2="12"></line></svg>
<span>로그아웃</span>
</a>
</div>
</header>
<div style="flex: 1 1 auto; display: flex; flex-wrap: wrap; align-items: stretch">
<nav aria-label="설정 메뉴" style="flex: 1 1 208px; box-sizing: border-box; display: flex; flex-direction: column; gap: 4px; padding: 14px 10px; background: #F8F9FB; border-right: 1px solid #E6E8EC">
<p style="margin: 4px 10px 4px; font-size: 12px; font-weight: 600; color: #667085">계정</p>
<ul style="list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 1px">
<li><a href="SettingsAccount.dc.html" class="menu-link" style="display: flex; align-items: center; gap: 10px; min-height: 40px; box-sizing: border-box; padding: 0 10px; border-radius: 7px; font-weight: 500; text-decoration: none">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="11" width="18" height="11" rx="2" ry="2"></rect><path d="M7 11V7a5 5 0 0 1 10 0v4"></path></svg>
<span>비밀번호</span>
</a></li>
</ul>
<p style="margin: 14px 10px 4px; font-size: 12px; font-weight: 600; color: #667085">메일</p>
<ul style="list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 1px">
<li><a href="Settings.dc.html" class="menu-link" style="display: flex; align-items: center; gap: 10px; min-height: 40px; box-sizing: border-box; padding: 0 10px; border-radius: 7px; font-weight: 500; text-decoration: none">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 20h9"></path><path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z"></path></svg>
<span>서명</span>
</a></li>
<li><a href="SettingsVacation.dc.html" class="menu-link" style="display: flex; align-items: center; gap: 10px; min-height: 40px; box-sizing: border-box; padding: 0 10px; border-radius: 7px; font-weight: 500; text-decoration: none">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="4" width="18" height="18" rx="2" ry="2"></rect><line x1="16" y1="2" x2="16" y2="6"></line><line x1="8" y1="2" x2="8" y2="6"></line><line x1="3" y1="10" x2="21" y2="10"></line><line x1="10" y1="14" x2="14" y2="18"></line><line x1="14" y1="14" x2="10" y2="18"></line></svg>
<span>부재중 자동 응답</span>
</a></li>
<li><a href="#" aria-current="page" style="display: flex; align-items: center; gap: 10px; min-height: 40px; box-sizing: border-box; padding: 0 10px; border-radius: 7px; background: #E3F2F3; color: #0A5F67; font-weight: 600; text-decoration: none">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9"></path><path d="M13.73 21a2 2 0 0 1-3.46 0"></path></svg>
<span>알림</span>
</a></li>
</ul>
</nav>
<main style="flex: 999 1 560px; min-width: 0; box-sizing: border-box; display: flex; flex-direction: column">
<div style="max-width: 800px; box-sizing: border-box; padding: 20px 32px 36px; display: flex; flex-direction: column; gap: 22px">
<div style="display: flex; flex-direction: column; gap: 4px">
<h1 style="margin: 0; font-size: 18px; font-weight: 700; letter-spacing: -0.01em">알림</h1>
<p style="margin: 0; color: #4A5363">새 메일은 1분을 기다리지 않고 바로 목록에 나타납니다. 다른 창을 보는 동안에도 알고 싶으면 바탕화면 알림을 켜 두세요.</p>
</div>
<div style="display: flex; flex-direction: column; gap: 6px">
<label style="display: flex; align-items: center; gap: 10px; min-height: 44px; cursor: pointer">
<input type="checkbox" checked="{{ on }}" onChange="{{ toggleOn }}" style="width: 18px; height: 18px; margin: 0; accent-color: #0E7C86; cursor: pointer">
<span style="font-weight: 600">새 메일이 오면 바탕화면 알림 보내기</span>
</label>
<sc-if value="{{ askedNow }}" hint-placeholder-val="{{ false }}">
<p role="status" style="margin: 0 0 0 28px; display: flex; align-items: center; gap: 6px; font-size: 12.5px; color: #067647">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="20 6 9 17 4 12"></polyline></svg>
<span>이 브라우저에서 알림이 허용되어 있습니다.</span>
</p>
</sc-if>
<sc-if value="{{ firstAsk }}" hint-placeholder-val="{{ true }}">
<p style="margin: 0 0 0 28px; font-size: 12.5px; color: #667085">켜면 브라우저가 알림을 허락할지 한 번 묻습니다 — 「허용」을 눌러 주세요.</p>
</sc-if>
</div>
<fieldset disabled="{{ off }}" style="display: flex; flex-direction: column; gap: 0; margin: 0; padding: 0; border: 0">
<legend style="margin: 0 0 4px; padding: 0; font-weight: 600; color: #171C26">알림에 보일 것</legend>
<label style="display: flex; align-items: center; gap: 10px; min-height: 44px; cursor: pointer">
<input type="checkbox" checked="{{ showSubject }}" onChange="{{ toggleSubject }}" style="width: 18px; height: 18px; margin: 0; accent-color: #0E7C86; cursor: pointer">
<span>메일 제목도 보이기</span>
</label>
<p style="margin: 0 0 0 28px; font-size: 12.5px; color: #667085">끄면 보낸 사람 이름만 보입니다 — 화면을 다른 사람과 함께 보는 자리라면 꺼 두세요.</p>
</fieldset>
<div style="display: flex; flex-wrap: wrap; align-items: center; gap: 12px">
<button type="button" class="secondary" disabled="{{ off }}" onClick="{{ test }}" style="display: inline-flex; align-items: center; gap: 8px; min-height: 44px; padding: 0 16px; border: 0; border-radius: 8px; font: inherit; font-weight: 600; cursor: pointer">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9"></path><path d="M13.73 21a2 2 0 0 1-3.46 0"></path></svg>
<span>시험 알림 보내기</span>
</button>
<span style="font-size: 12.5px; color: #667085">바탕화면 오른쪽 아래(맥은 오른쪽 위)에 알림이 뜨면 됩니다.</span>
</div>
<hr style="width: 100%; height: 1px; margin: 0; border: 0; background: #EEF0F3">
<section aria-labelledby="realtime-title" style="display: flex; flex-direction: column; gap: 8px">
<h2 id="realtime-title" style="margin: 0; font-size: 14px; font-weight: 700">실시간 연결</h2>
<p role="status" style="margin: 0; display: flex; align-items: center; gap: 8px">
<span aria-hidden="true" style="width: 8px; height: 8px; border-radius: 50%; background: #12B76A"></span>
<span>연결되어 있습니다 — 새 메일이 오면 바로 목록에 나타납니다.</span>
</p>
<p style="margin: 0; font-size: 12.5px; color: #667085">연결할 수 없을 때는 "연결할 수 없어 1분마다 확인합니다."로 바뀌고, 저절로 다시 잇습니다. 회사 방화벽 · 프록시가 오래 열린 연결을 끊으면 이렇게 됩니다.</p>
</section>
<div style="display: flex; flex-direction: column; gap: 6px; box-sizing: border-box; max-width: 620px; padding: 14px 16px; border-radius: 10px; background: #F3F8F8; color: #344054">
<p style="margin: 0; display: flex; gap: 8px"><span aria-hidden="true">·</span><span>알림 설정은 <strong style="font-weight: 600">이 브라우저에만</strong> 적용됩니다. 다른 PC · 브라우저에서는 그곳에서 따로 켜 주세요.</span></p>
<p style="margin: 0; display: flex; gap: 8px"><span aria-hidden="true">·</span><span>웹메일 탭이 하나라도 열려 있어야 알림이 옵니다. 탭을 모두 닫으면 오지 않습니다.</span></p>
<p style="margin: 0; display: flex; gap: 8px"><span aria-hidden="true">·</span><span>웹메일 화면을 보고 있을 때는 바탕화면 알림을 보내지 않습니다 — 목록에 바로 보이므로.</span></p>
</div>
</div>
</main>
</div>
</div>
<sc-if value="{{ testShown }}" hint-placeholder-val="{{ false }}">
<div role="img" aria-label="시험 알림 (운영체제가 그리는 모양의 예)" style="position: absolute; right: 24px; bottom: 24px; width: 364px; box-sizing: border-box; display: flex; flex-direction: column; gap: 10px; padding: 12px 14px 14px; border-radius: 10px; background: #FFFFFF; border: 1px solid #E3E5E8; box-shadow: 0 14px 36px rgba(16, 24, 28, 0.22)">
<div style="display: flex; align-items: center; gap: 8px; font-size: 11.5px; color: #667085">
<span aria-hidden="true" style="display: inline-flex; width: 14px; height: 14px; border-radius: 3px; background: #0B3640"></span>
<span>Microsoft Edge</span>
<span style="margin-left: auto">지금</span>
</div>
<div style="display: flex; gap: 12px">
<span aria-hidden="true" style="flex: 0 0 44px; display: inline-flex; align-items: center; justify-content: center; width: 44px; height: 44px; border-radius: 10px; background: #0B3640">
<img src="/_blob/2b9ef4349333debc969432c859eaf1bf" alt="" width="34" height="12" style="display: block; width: 34px; height: auto">
</span>
<div style="min-width: 0; display: flex; flex-direction: column; gap: 2px">
<span style="font-size: 13.5px; font-weight: 700">ARA 웹메일</span>
<span style="font-size: 13px; color: #344054">알림이 잘 옵니다. 새 메일이 오면 이렇게 알려 드립니다.</span>
<span style="font-size: 11.5px; color: #98A2B3">portal.arasystem.it.kr</span>
</div>
</div>
</div>
</sc-if>
</div>
</x-dc>
<script type="text/x-dc" data-dc-script data-props='{"$preview":{"width":1440,"height":900}}'>
class Component extends DCLogic {
state = { on: false, showSubject: true, testShown: false };
renderVals() {
const st = this.state;
return {
on: st.on,
off: !st.on,
askedNow: st.on,
firstAsk: !st.on,
showSubject: st.showSubject,
testShown: st.on && st.testShown,
toggleOn: () => this.setState({ on: !st.on, testShown: false }),
toggleSubject: () => this.setState({ showSubject: !st.showSubject }),
test: () => this.setState({ testShown: true }),
};
}
}
</script>
</body>
</html>
+6 -3
View File
@@ -20,14 +20,17 @@
"SettingsAccount.dc.html": { "x": 0, "y": 6640, "w": 1440, "h": 900, "title": "설정 · 비밀번호", "expand": "fill", "is_interactive": true },
"SettingsVacation.dc.html": { "x": 1520, "y": 6640, "w": 1440, "h": 1060, "title": "설정 · 부재중 자동 응답", "expand": "fill", "is_interactive": true },
"MailboxManage.dc.html": { "x": 3040, "y": 6640, "w": 1440, "h": 900, "title": "메일함 관리 · 휴지통 비우기", "expand": "fill", "is_interactive": true },
"MailActions4.dc.html": { "x": 4560, "y": 6640, "w": 1440, "h": 1320, "title": "스팸 · 원문 · 오래 걸리는 처리 상태" }
"MailActions4.dc.html": { "x": 4560, "y": 6640, "w": 1440, "h": 1320, "title": "스팸 · 원문 · 오래 걸리는 처리 상태" },
"SettingsNotify.dc.html": { "x": 0, "y": 8560, "w": 1440, "h": 900, "title": "설정 · 알림", "expand": "fill", "is_interactive": true },
"MailRealtime.dc.html": { "x": 1520, "y": 8560, "w": 1440, "h": 1520, "title": "실시간 새 메일 · 바탕화면 알림 상태" }
},
"order": ["Main.dc.html", "Login.dc.html", "Compose.dc.html", "States.dc.html", "MainList.dc.html", "MainListRead.dc.html", "ComposeEditor.dc.html", "Settings.dc.html", "ComposeAssist.dc.html", "Search.dc.html", "BulkSelect.dc.html", "ListStates.dc.html", "SettingsAccount.dc.html", "SettingsVacation.dc.html", "MailboxManage.dc.html", "MailActions4.dc.html"],
"order": ["Main.dc.html", "Login.dc.html", "Compose.dc.html", "States.dc.html", "MainList.dc.html", "MainListRead.dc.html", "ComposeEditor.dc.html", "Settings.dc.html", "ComposeAssist.dc.html", "Search.dc.html", "BulkSelect.dc.html", "ListStates.dc.html", "SettingsAccount.dc.html", "SettingsVacation.dc.html", "MailboxManage.dc.html", "MailActions4.dc.html", "SettingsNotify.dc.html", "MailRealtime.dc.html"],
"notes": {
"flow": { "x": 0, "y": -300, "text": "arasystem 웹메일 — 1단계 화면 (로그인 → 메일함 → 쓰기)", "kind": "title1", "maxW": 4480 },
"phase2": { "x": 0, "y": 2180, "text": "2단계 화면 — 임시 저장 · 받는 사람 자동 완성 · 서명 (2026-10-27 확정)", "kind": "title1", "maxW": 2960 },
"phase3": { "x": 0, "y": 4180, "text": "3단계 화면 — 새 메일 · 검색 · 여러 통 처리 (2026-10-27 확정)", "kind": "title1", "maxW": 4480 },
"phase4": { "x": 0, "y": 6340, "text": "4단계 화면 — 계정 · 메일 설정 (비밀번호 · 부재중 응답 · 메일함 관리 · 스팸 · 원문) — 2026-10-28 확정", "kind": "title1", "maxW": 6000 }
"phase4": { "x": 0, "y": 6340, "text": "4단계 화면 — 계정 · 메일 설정 (비밀번호 · 부재중 응답 · 메일함 관리 · 스팸 · 원문) — 2026-10-28 확정", "kind": "title1", "maxW": 6000 },
"phase5": { "x": 0, "y": 8260, "text": "5단계 화면 — 실시간 새 메일 · 바탕화면 알림 — 2026-10-28 확정", "kind": "title1", "maxW": 2960 }
},
"designSystems": []
}
+85 -4
View File
@@ -212,6 +212,7 @@ HTML 은 정화하기 **전에** 길이(HTML 부분이 여럿이면 이어 붙
| `PUT /api/mail-vacation` | `MailVacation` (전체 교체) | 200 `MailVacation` | 400 `VAL_001`, 503 `MAIL_001` |
| `GET /api/mail-quota` | — | 200 `MailQuota` | `MAIL_001` |
| `GET /api/mail-state` | — | 200 `MailState` (아래 "새 메일 확인") | 503 `MAIL_001` |
| `GET /api/mail-events` | — | `text/event-stream` (표준 래퍼 없음 — 아래 "실시간 알림", 5단계) | 401 `AUTH_001`, 429 `REQ_001`(한 직원의 연결 5개 초과), 503 `MAIL_001` |
| `GET /api/messages` | `mailboxId` · `cursor` · `limit`(기본 20, 최대 100) · `q` — `mailboxId` 와 `q` 중 하나는 있어야 한다(`q` 만 있으면 휴지통·스팸메일함을 뺀 모든 메일함에서 찾는다 — 아래 "검색") | 200 `{ items: MessageSummary[], pageInfo }` — 받은 시각 내림차순, `total` 포함 | 400 `VAL_001`, `MAIL_001` |
| `GET /api/messages/{id}` | — | 200 `MessageDetail` — **조회만으로 읽음 처리하지 않는다** | 404 `RES_001` |
| `PATCH /api/messages/{id}` | `{ isRead?, isFlagged?, mailboxId?, isJunk? }` (하나 이상 — `isJunk` 는 4단계 "스팸 · 원문") | 200 `MessageSummary` | 404 `RES_001`, 400 `VAL_001` |
@@ -491,9 +492,9 @@ interface MailState {
- 받은편지함에 안 읽은 메일이 있으면 탭 제목 앞에 `(N) ` 을 붙인다 (디자인 README "3단계").
- 요청 수 한도(`THROTTLE_LIMIT`, 기본 1분 100회)는 경로마다 · IP 마다 센다 — 사무실 전체가 한 IP 로 나가면 보이는 탭이 100개를 넘을 때
1분 확인이 429 `REQ_001` 로 조용히 실패한다(다음 주기에 다시). 값이 바뀌었을 때의 맞추기(`GET /api/messages` · `/api/mailboxes` · `/api/mail-quota`)는
평소 사용과 같은 칸을 쓴다 — `GET /api/messages` 는 대략 "쓰고 있는 탭 수 × 1분에 1번 + 평소 목록 · 더 보기"다. 운영 전환 때 직원 수에 맞춰 정한다.
- 실시간 푸시는 이 단계에서 하지 않는다. 엔진은 JMAP EventSource 를 지원하므로(2026-10-27 확인) 1분이 모자라면 백엔드가 SSE 로 중계하는 방식으로 바꾼다 —
그때는 nginx · 외부 프록시의 응답 버퍼링과 시간 제한도 함께 바꿔야 한다.
평소 사용과 같은 칸을 쓴다 — `GET /api/messages` 는 대략 "쓰고 있는 탭 수 × 1분에 1번 + 평소 목록 · 더 보기"다(3단계).
5단계부터는 실시간 연결이 살아 있는 동안 1분 확인 대신 "바뀔 때마다(1초 모아서) 보이는 탭 수 × 1번"이다 — 사무실이 바쁠수록 늘 수 있다. 운영 전환 때 직원 수에 맞춰 정한다.
- 5단계부터는 실시간 연결(아래 "실시간 알림")이 살아 있는 동안 1분 확인을 멈추고, 끊기면 1분 확인으로 돌아간다. 이 API 와 규칙은 그대로다.
### 검색 (`GET /api/messages` 의 `q`) — 3단계
@@ -644,8 +645,88 @@ interface MailVacation {
- **원문 내려받기**: `GET /api/messages/{id}/source` — 엔진의 원본(blob)을 그대로 흘려보낸다(`message/rfc822`, `Cache-Control: private, no-store`). 파일 이름은 제목(없으면 "메일")에
`.eml` — 다운로드 헤더는 첨부와 같은 규칙(`shared/utils/content-disposition.ts`). 화면은 첨부 내려받기처럼 이동 전에 세션을 확인한다(`confirmSession()`).
### 실시간 알림 (`GET /api/mail-events`) — 5단계
서버가 메일 엔진(Stalwart)의 JMAP EventSource(RFC 8620 7.3)를 직원마다 하나 열고, 그 소식을 브라우저에 SSE(`text/event-stream`)로 흘려보낸다. 표준 응답 래퍼는 없다.
```text
event: state
data: {"state":"<GET /api/mail-state 의 state 와 같은 값>","inboxUnread":3}
event: new-mail
data: {"count":2,"messages":[{"id":"…","from":{"name":"박서준","email":"seojun.park@arasystem.it.kr"},"subject":"…","receivedAt":"2026-10-28T05:42:00.000Z"}]}
event: ping
data: {"upstream":true}
event: end
data: {"reason":"token-expiring"}
```
| 이벤트 | 언제 · 뜻 |
| --- | --- |
| `state` | 연결 직후 한 번, 그 뒤 메일함 · 메일이 바뀔 때마다(엔진 신호를 0.3초 모아서). `state` 는 `GET /api/mail-state` 와 같은 값, `inboxUnread` 는 받은편지함의 안 읽은 수 |
| `new-mail` | 받은편지함에 새로 배달된 메일이 있을 때 — `$draft` 아님 · `$seen` 아님 · 보낸 사람이 나 아님. 새것부터 최대 5통(`count` 는 전체 수). 같은 변화의 `state` 다음에 온다 |
| `ping` | 25초마다(직원마다 한 간격 — 새 연결의 첫 ping 은 더 빨리 올 수 있다), 그리고 `upstream` 이 바뀌면 그때 바로. `upstream: false` 면 서버와 엔진 사이가 끊겨 다시 잇는 중이다 — 그동안 클라이언트는 1분 확인으로 돌아간다. 다시 잇는 중에 새로 연결하면 마지막으로 알던 `state` 와 `ping {upstream:false}` 가 바로 온다 |
| `end` | 서버가 곧 닫는다 — `token-expiring`(액세스 토큰이 끝나기 30초 전 — 남은 시간이 30초 이하인 토큰(세션이 끝나기 30초 안에 갱신한 토큰)이면 토큰이 실제로 끝날 때) · `server-shutdown`(서버 종료 · 재시작) |
| 연결 전 실패 (표준 실패 형식 JSON) | 코드 |
| --- | --- |
| 로그인 안 됨 · 토큰 만료 | 401 `AUTH_001` |
| 한 직원의 연결이 5개를 넘음(브라우저마다 하나라 기기 · 브라우저 5개) · 요청 수 한도(경로마다 IP 당) · 엔진 한도 | 429 `REQ_001` |
| 엔진에 연결할 수 없음(처음 연결 10초 상한) · 기다리는 사이 엔진 연결이 끝남 · 서버 종료 중 | 503 `MAIL_001` |
스트림이 열린 뒤 계정을 더 쓸 수 없게 되면(엔진이 대리 접근을 거절) `end` 없이 닫는다 — 클라이언트는 끊김으로 보고 다시 이어 401 → 갱신 실패 → 세션 만료로 간다.
- **서버**: 직원마다 엔진 연결 하나를 그 직원의 모든 연결에 나눠 쓴다(마지막 연결이 닫히고 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` 없이 — 엔진 한도 · 엔진이 내려간 동안은 세지 않는다).
엔진이 묻기를 한도(429)로 막으면 엔진 연결은 두고 5초(±20%) 뒤 다시 묻는다(`upstream` 은 true 그대로 — 다시 잇는 중이면 다시 잇기 간격대로).
**5분 안전판**: 엔진 신호가 없어도 마지막으로 물은 지 5분이 지나면 한 번 묻고, 달라졌으면 `state`(와 `new-mail`)를 보낸다 — 연결이 살아 있는 동안 클라이언트는 1분 확인을 멈추므로, 엔진이 신호를 빠뜨려도 낡은 값이 5분 넘게 남지 않게.
- **새 메일 판단**: 엔진의 `Email/changes`(지난 state 부터 생긴 메일) → `Email/get` 으로 한다. 엔진의 `EmailDelivery` 신호는 초안을 만들어도 바뀌므로 쓰지 않는다(2026-10-28 확인).
배달에서 신호까지 0.5초 남짓이다(같은 날 개발 엔진). 다음 경우에는 `new-mail` 없이 `state` 만 보내고 기준을 다시 잡는다:
바뀐 것을 셀 수 없음(`cannotCalculateChanges`), 엔진이 옛 기준을 거부함(Stalwart 0.16.27 은 해석하지 못하는 `sinceState` 에 HTTP 400 `notRequest` 로 요청 전체를 거부한다 — 2026-10-28 확인),
한 번에 2,000건(500 × 4) 넘게 바뀜.
`Email/changes` 의 생성은 "배달"이 아니라 "만들어짐"이다 — 받은 시각(`receivedAt`)이 변화를 본 때보다 10분 넘게 앞선 메일은 알리지 않는다
(메일 프로그램의 올리기 · mailcow 에서 옮길 때의 IMAP 가져오기(imapsync)가 옛 메일을 넣어도 알림이 쏟아지지 않게). 받은 시각을 읽지 못한 메일도 빼고, 엔진 시계가 앞서 미래 시각인 메일은 넣는다.
그래서 서버와 엔진 사이가 10분 넘게 끊겼다 다시 이으면, 그동안 온 메일은 `state` 로만 알린다(목록에는 나타나고 바탕화면 알림은 없다).
"보낸 사람이 나"는 토큰의 주소와 발신 ID(별칭) 주소로 본다(대소문자 무시).
- 오래 열린 엔진 연결은 엔진의 동시 요청 한도(4)를 잡아먹지 않는다 — 한 직원의 스트림 8개를 연 채 그 직원 · 다른 직원의 보통 요청과 동시 요청 6개가 모두 됐다(2026-10-28 확인).
- 연결은 액세스 토큰이 끝날 때 닫는다(최대 1시간 — 그 전에 `end`). 로그아웃 · 비밀번호 변경으로 세션을 끊어도 다른 API 처럼 액세스 토큰이 끝날 때까지는 이어진다(1절 세션 규칙).
서버가 종료될 때는 열린 연결을 `end` 로 먼저 닫는다 — 오래 열린 연결이 종료를 붙잡지 않게.
알려진 한계: 잠자기 · VPN 재연결 · 망 바꿈으로 반쯤 열린 연결은 서버가 ping 을 쓰다 실패할 때(OS 기본 15분 안팎)까지 자리를 잡는다 —
그사이 그 직원의 연결이 5개에 닿으면 새 연결은 429 이고 화면은 1분 확인으로 이어진다.
- 응답 머리글: `Content-Type: text/event-stream`, `Cache-Control: no-cache, no-transform`, `X-Accel-Buffering: no`. 서버는 스트림을 열기로 정하면 머리글을 바로 보낸다.
앞단 nginx 는 이 경로만 버퍼링 · 캐시 · 압축을 끄고, 읽기 사이 시간 제한을 90초로 둔다(응답 전체가 아니라 두 번 읽는 사이 — ping 25초 · 클라이언트 감시 75초보다 길게,
머리글이 오지 않으면 90초에 504). 외부 리버스 프록시도 버퍼링을 끄고, 응답 전체 시간에 상한이 있으면 1시간 넘게 둔다(운영 확인 항목 — docker 스킬).
앱 로그에는 열고 닫는 것을 남기지 않는다(반복되는 정상 흐름) — 엔진 연결이 이어서 실패하면 warn. nginx 접근 로그에는 연결이 끝날 때 한 줄이 남는다(`rt=` 가 이어진 시간).
- **클라이언트**
- 브라우저마다 연결 하나 — 탭이 여러 개면 한 탭만 연결하고(Web Locks) 다른 탭에 소식을 나눈다(BroadcastChannel). 탭마다 열면 HTTP/1.1 의 주소당 연결 6개를 금방 다 쓴다.
연결한 탭을 닫으면 다른 탭이 이어받는다. Web Locks 가 없는 브라우저는 실시간 없이 1분 확인만 한다.
연결은 로그인한 동안 **앱 전체**에서 유지한다(설정 등 메일 밖 화면에서도 바탕화면 알림 · 연결 상태가 이어지게). 목록 맞추기 · 1분 확인 멈춤은 메일 화면이 있을 때만 한다.
- `fetch` 로 읽는다 — 브라우저의 `EventSource` 는 401 을 알 수 없어 세션을 갱신할 수 없다. 401 이면 세션을 한 번 갱신하고 다시 잇는다(갱신도 실패하면 지금의 세션 만료 흐름).
- 연결이 살아 있는 동안(75초 안에 `ping` · `state` 가 오고 `upstream: true`)은 1분 확인을 멈춘다. 75초는 요청을 시작한 때부터 센다(머리글이 오지 않는 연결도 끊게).
끊기면(읽기 오류 포함 — 서버의 `end` 가 아닌 끝) 1분 확인으로 돌아가고 2 · 5 · 15 · 30 · 60초 간격으로 다시 잇는다.
연결이 **자리 잡으면**(첫 `state` 뒤 `upstream: true` 인 ping 이 오거나 20초가 지나면) 처음 간격부터 다시 센다. 자리 잡은 연결의 `end` 는 바로 다시 잇는다
(`token-expiring` 은 세션을 갱신한 뒤, `server-shutdown` 은 0~3초 흔들어 — 배포 때 모든 브라우저가 같은 순간에 붙지 않게). 그동안은 끊김으로 보지 않는다.
**자리 잡기 전에 온 `end` 는 까닭과 상관없이 실패처럼 간격을 둔다** — 열리자마자 끝나는 연결(세션 끝 직전의 짧은 토큰 · 응답을 짧게 끊는 프록시)에서
갱신 · 다시 잇기를 왕복 시간 속도로 되풀이하지 않게(갱신 한도는 IP 마다라 같은 사무실 직원까지 막는다). 다시 잇는 간격의 흔들림은 ±20%.
- 세션을 갱신한 **바로 뒤의 다시 잇기**가 또 401 이면 세션 만료로 본다(axios 의 401 재시도와 같다 — 그 시도가 다른 까닭으로 끝나면 이 표시는 지운다).
갱신이 장애(5xx · 네트워크)로 실패하면 위 간격으로 기다렸다 다시 갱신해 본다. 한 이벤트가 1MB 를 넘으면 실패로 끊는다.
- 연결한 탭이 그만둘 때(닫힘 · 로그아웃 · `pagehide`)와 잠금을 이어받은 탭이 연결하기 전에 다른 탭에 끊김을 알린다 — 이어받은 탭의 연결이 열릴 때까지 모든 탭이 1분 확인으로 돌아간다
(탭 닫기 · 탭 폐기처럼 그만두는 탭이 알리지 못한 경우도 덮는다).
살아 있던 연결이 끊기면 1분 확인이 바로 한 번 묻고, 그 뒤 60초마다 묻는다.
- `state` 가 바뀌면 보이는 탭은 지금의 1분 확인이 하는 일(개수 · 보이는 목록 맞추기)을 그대로 하고, 숨은 탭은 `inboxUnread` 로 탭 제목만 맞춘 뒤 다시 보일 때 맞춘다.
실시간이 부른 맞추기는 1초 모아서 한 번 한다(한 통을 읽어도 state 가 바뀐다 — 연달은 바뀜에 목록을 여러 번 받지 않게).
- **바탕화면 알림**(설정 › 메일 › 알림 — 브라우저 · 사용자마다 localStorage): 켜져 있고 브라우저가 허락했을 때, `new-mail` 을 받았는데 **웹메일을 보고 있는 탭**(보이고 초점이 있는 탭 — `visibilityState === 'visible' && document.hasFocus()`)이 하나도 없으면 알린다.
두 번째 모니터에 웹메일을 띄워 두고 다른 창에서 일할 때도 알린다("다른 창을 보는 동안에도").
한 통이면 제목 = 보낸 사람(이름, 없으면 주소) · 내용 = 메일 제목(「메일 제목도 보이기」를 끄면 "새 메일이 왔습니다"), 여러 통이면 "새 메일 N통" · "박서준 · 이도윤 외 1명".
같은 tag 로 앞 알림을 바꿔 쓴다(쌓이지 않는다). 누르면 그 탭으로 와서 메일을 연다(여러 통이면 받은편지함). 사용자가 웹메일로 돌아오면(어느 탭이든 보이고 초점이 생기면) 띄운 알림을 닫는다.
허락은 사용자가 누른 뒤에만 묻는다(목록 위 한 번 권하기 · 설정의 켜기 칸). `new Notification()` 을 막는 브라우저(휴대폰 Chrome)는 처음 실패할 때 "지원하지 않음"으로 바꾼다.
### 이 단계에서 하지 않는 것
실시간 푸시(SSE · 데스크톱 알림 · 소리), 본문 안 이미지(인라인 cid — 붙여 넣은 이미지는 첨부로, 서명 그림만 예외), 외부 이미지 보기(이미지 중계),
탭을 모두 닫아도 오는 알림(웹 푸시 — 엔진은 `webpush-vapid` 를 지원한다), 알림 소리(운영체제 설정을 따른다), 휴대폰 푸시, 본문 안 이미지(인라인 cid — 붙여 넣은 이미지는 첨부로, 서명 그림만 예외), 외부 이미지 보기(이미지 중계),
여러 개의 서명, 엔진 계정 목록 기반의 사내 주소록, 자체 검색 색인(붙여 쓴 말 · 앞부분 · 제목 말머리로 찾기), 조건 검색(보낸 사람 · 기간 · 첨부 있음), 검색어 강조,
끌어다 놓아 옮기기, 메일함 옮기기(다른 상위로) · 하위 메일함까지 한꺼번에 지우기, 앱 비밀번호 · 2단계 인증(엔진은 지원한다), 부재중 응답의 서식 본문 · 사내/외부 구분, 인쇄.
+29
View File
@@ -34,6 +34,9 @@ map $http_referer $log_referer {
# 새 메일 확인(GET /api/mail-state — 화면이 보이는 동안 탭마다 1분에 한 번)은 성공(2xx·304)하면 접근 로그를 남기지 않는다 —
# 로깅 규칙 "반복되는 정상 흐름(헬스체크, 폴링)은 로그를 남기지 않는다". 실패(401·5xx 등, 오류용 이름 있는 location 포함)는 남긴다.
# 0 이면 server 단계 access_log 의 if= 가 그 줄을 쓰지 않는다. $uri 는 쿼리를 뺀 경로다 (이 요청은 내부 이동이 없다)
# 실시간 알림(GET /api/mail-events, SSE)은 성공도 남긴다 — 줄은 연결이 끝날 때 하나(브라우저마다 대략 1시간에 한 번 — 1분 확인의 수십분의 1)이고
# rt= 가 연결이 이어진 시간이다. 스트림이 시작된 뒤에는 어떻게 끝나도(서버의 end · 탭 닫기 · 백엔드가 중간에 끊김) 상태가 200 이라,
# 200 을 빼면 일찍 끊기는 연결의 흔적까지 사라진다 (2026-10-28 확인). 연결 전 실패(401·429·503·502 등)는 물론 남는다.
map "$request_method:$uri:$status" $log_access {
"~^GET:/api/mail-state:(?:2[0-9][0-9]|304)$" 0;
default 1;
@@ -228,6 +231,32 @@ server {
proxy_read_timeout 90s;
proxy_pass $backend_upstream;
}
# 실시간 알림(GET /api/mail-events, 계약 2절 "실시간 알림") — SSE(text/event-stream). 브라우저마다 연결 하나가 액세스 토큰이 끝날 때까지
# (최대 1시간 — 그 30초 전에 서버가 end 를 보내고 닫는다) 열려 있고, 서버가 25초마다 ping 을 보낸다. 아래 동작은 2026-10-28 가짜 백엔드로 확인했다.
# - 버퍼링을 끈다: 켜 두면 머리글을 첫 이벤트가 올 때까지 붙잡고, 백엔드가 청크 없이 연결을 닫아 끝내는 응답을 보내면 끝날 때까지 전부 붙잡는다.
# 백엔드가 보내는 X-Accel-Buffering: no 로도 꺼지지만 그 머리글에 기대지 않는다. 캐시는 지금 어디에도 없다 — 나중에 /api 에 생겨도 이 경로는 빼 둔다.
# - 압축을 끈다: gzip_types 에 text/event-stream 이 없어 지금도 압축하지 않지만, 목록을 넓혀도 이 경로는 압축하지 않게
# (백엔드가 Cache-Control: no-transform 으로 바꾸지 말라고 해도 nginx 의 gzip 은 압축한다).
# - 읽기 시간 제한 90s(다른 90s 경로와 같은 값)는 응답 전체가 아니라 백엔드에서 "두 번 읽는 사이"의 시간이다 — 연결이 1시간 이어져도
# 25초마다 오는 ping 이 매번 새로 세게 한다. 서버 ping 25초 · 화면 감시 75초보다 길어 정상 연결은 끊지 않는다 (ping 이 멈추면 화면이 먼저 끊는다).
# 머리글을 기다릴 때도 같은 값이라, 백엔드가 머리글을 보내지 않으면 90초에 504 로 끊는다 (@api_unavailable 의 JSON SYS_001).
# ⚠️ 서버의 ping 간격(계약 2절)을 바꾸면 이 값도 함께 본다 — ping 간격보다 넉넉히 길어야 한다.
# 클라이언트 쪽 쓰기 제한(send_timeout 60s)은 쓰기가 막혔을 때만 세므로 그대로 둔다.
# - X-Accel-Buffering 을 넘긴다: nginx 는 이 머리글을 기본으로 지우는데, 외부 리버스 프록시가 nginx 계열이면 이것을 보고 그쪽도 이 응답만
# 버퍼링을 끈다 — 기본 설정의 nginx 가 머리글을 첫 이벤트까지 붙잡지 않고 바로 보낸다 (docker 스킬 "외부 리버스 프록시 요구사항").
# 브라우저는 이 머리글을 쓰지 않는다.
# 머리글(proxy_set_header — 여기 쓰지 않는다) · 오류 응답(@api_unavailable 등) · 접근 로그는 위 /api 와 server 단계 것을 그대로 물려받는다.
# 열린 연결 하나가 nginx 연결 둘(클라이언트 쪽 · 백엔드 쪽)을 1시간까지 잡는다 — 수용량은 이미지 기본 nginx.conf 를 따른다
# (워커마다 worker_connections 1024, 워커 수 auto).
location ^~ /api/mail-events {
proxy_buffering off;
proxy_cache off;
gzip off;
proxy_read_timeout 90s;
proxy_pass_header X-Accel-Buffering;
proxy_pass $backend_upstream;
}
}
# /api 의 nginx 오류 응답 — message 는 비운다 (문구는 클라이언트가 code 로 사전에서 찾는다, 레지스트리와 겹치지 않게).
+9
View File
@@ -5,10 +5,19 @@ import { RouterView } from 'vue-router'
import SessionExpiredDialog from '@/components/feedback/SessionExpiredDialog.vue'
import ToastHost from '@/components/feedback/ToastHost.vue'
import { useClock } from '@/composables/useClock'
import { useLogoutSync } from '@/composables/useLogoutBroadcast'
import { useMailRealtime } from '@/composables/useMailRealtime'
import { useNewMailNotifier } from '@/composables/useNewMailNotifier'
// 다른 탭에서 로그아웃하면 이 탭도 로그인 화면으로 간다 (계약 1절 "클라이언트 처리 규칙")
useLogoutSync()
// 실시간 새 메일 — 로그인한 동안 앱 전체에서 돈다(설정 등 다른 화면에서도 바탕화면 알림 · 연결 상태가 이어지게).
// 메일 화면은 이 소식을 받아 목록 · 개수를 맞춘다 (계약 2절 "실시간 알림" — MailLayout 의 useMailStatePolling).
// 사용자가 웹메일 탭(어느 탭이든)으로 돌아오면 띄운 바탕화면 알림을 닫는다
const newMailNotifier = useNewMailNotifier()
useMailRealtime({ clock: useClock(), onNewMail: newMailNotifier.notify, onUserReturn: newMailNotifier.dismiss })
</script>
<template>
Binary file not shown.

After

Width:  |  Height:  |  Size: 8.3 KiB

+16 -1
View File
@@ -1,6 +1,7 @@
<script setup lang="ts">
// 화면 오른쪽 아래 알림 (디자인 원본 States "오류 알림" · "보내기 완료" · MailActions4 "오래 걸리는 처리")
// 오류: 흰 카드 + 문의 코드(traceId 앞 8자리) + 닫기 / 성공: 브랜드 바탕 + 이동 링크 또는 그 자리의 단추(되돌리기 등)
// 오류: 흰 카드 + 문의 코드(traceId 앞 8자리) + (이동 링크 — 예: 바탕화면 알림을 막아 둠 [설정 열기]) + 닫기
// 성공: 브랜드 바탕 + 이동 링크 또는 그 자리의 단추(되돌리기 등)
// 진행: 성공과 같은 바탕 + 돌아가는 아이콘 — 끝날 때까지 남았다가 그 자리에서 결과로 바뀐다 (toast.store 의 update)
// 마우스가 올라가 있거나 초점이 알림 안에 있는 동안은 닫히지 않는다 (toast.store 의 hold · release — WCAG 2.2.1)
import AppIcon from '@/components/AppIcon.vue'
@@ -216,6 +217,20 @@ function runAction(toast: Toast) {
outline-color: var(--color-on-brand-link);
}
/* 흰 오류 카드 위의 단추 · 링크 (예: 바탕화면 알림을 막아 둠 — [설정 열기]) — 브랜드 바탕용 밝은 색은 흰 바탕에서 보이지 않는다 */
.toast--error .toast__action {
align-self: center;
color: var(--color-brand);
}
.toast--error .toast__action:hover {
color: var(--color-brand-strong);
}
.toast--error .toast__action:focus-visible {
outline-color: var(--color-brand);
}
.toast__close {
flex: 0 0 auto;
display: inline-flex;
@@ -1,15 +1,18 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { mount } from '@vue/test-utils'
import { createPinia, setActivePinia, type Pinia } from 'pinia'
import type { Mailbox, MessageSummary } from '@/shared/types/mail'
import { useAuthStore } from '@/stores/auth.store'
import { useMailStore } from '@/stores/mail.store'
import { FakeBroadcastChannel } from '@/test-utils/broadcast'
import { flushPromises, installMockApi, ok, restoreApiAdapter } from '@/test-utils/mock-api'
import { FakeNotification } from '@/test-utils/notification'
import { createTestRouter } from '@/test-utils/router'
import { installFakeLocks, uninstallFakeLocks } from '@/test-utils/web-locks'
import MessageList from './MessageList.vue'
// 목록 틀 — 새 메일 알림 줄 · Esc (디자인 README "3단계 › 새 메일" · "여러 통 처리")
// 목록 틀 — 새 메일 알림 줄 · 바탕화면 알림 권하기 · Esc (디자인 README "3단계 › 새 메일" · "여러 통 처리" · "5단계")
const INBOX: Mailbox = {
id: 'inbox',
@@ -127,6 +130,44 @@ describe('MailListFrame (목록 칸의 틀)', () => {
expect(wrapper.find('.new-mail-notice').exists()).toBe(false)
})
describe('바탕화면 알림 권하기 (디자인 README "5단계 › 목록 위에서 한 번 권하기")', () => {
beforeEach(() => {
window.localStorage.clear()
installFakeLocks()
vi.stubGlobal('BroadcastChannel', FakeBroadcastChannel)
vi.stubGlobal('Notification', FakeNotification)
FakeNotification.reset('default')
})
afterEach(() => {
uninstallFakeLocks()
vi.unstubAllGlobals()
})
it('메일함 목록 머리 아래에 둔다 — 검색 결과에는 두지 않는다', async () => {
const { wrapper, store } = await mountList([summary('m1')])
const top = wrapper.get('.mail-list-frame__top')
expect(top.find('.list-header + .notify-offer').exists()).toBe(true)
store.listSource = { kind: 'search', q: '회의', scope: 'all' }
await flushPromises()
expect(wrapper.find('.notify-offer').exists()).toBe(false)
})
it('[✕] 로 닫으면 누른 단추와 함께 사라진 초점을 목록 칸으로 옮긴다', async () => {
const { wrapper, section } = await mountList([summary('m1')])
const close = wrapper.get('.notify-offer__close')
;(close.element as HTMLElement).focus()
await close.trigger('click')
await flushPromises()
expect(wrapper.find('.notify-offer').exists()).toBe(false)
expect(document.activeElement).toBe(section)
})
})
it('Esc 는 고른 것을 푼다 — 도구 줄에 있던 초점은 목록 칸으로 옮긴다', async () => {
const { wrapper, store, section } = await mountList([summary('m1'), summary('m2')])
store.toggleCheck('m1')
+16 -2
View File
@@ -1,16 +1,17 @@
<script setup lang="ts">
// 목록 칸의 틀 — 분할 보기(MessageList)와 목록 보기(MessageTable)가 함께 쓴다 (디자인 원본 Main · MainList · Search · BulkSelect · ListStates)
// - 머리 묶음(스크롤해도 위에 남는다): 메일함 머리 · 검색 결과 머리 + 범위 줄 — 하나라도 고르면 머리 자리가 도구 줄로 바뀐다 —
// 그 아래 "새 메일 N통 · 맨 위로"(useListArrivalNotice)
// 그 아래 바탕화면 알림 권하기(메일함 목록에서만 · 한 번 — MailNotifyOffer, 5단계), "새 메일 N통 · 맨 위로"(useListArrivalNotice)
// - 상태: 불러오는 중(뼈대) · 못 불러옴 · 행(slot) + 메일 더 보기 · 빈 메일함 · 검색 결과 없음
// - Esc 는 고른 것을 푼다 — 이 칸 안의 키만 받는다 (전역 단축키가 아니다 · 메뉴 · 대화상자 안의 Esc 는 그쪽 것이다)
// - 목록 보기는 메일을 열면 이 칸이 사라진다 — 스크롤을 기억해 "목록으로" 돌아오면 되살리고 보던 메일로 초점을 돌려준다
import { computed, ref, watch } from 'vue'
import { computed, nextTick, ref, watch } from 'vue'
import StateMessage from '@/components/feedback/StateMessage.vue'
import MailListHeader from '@/components/mail/MailListHeader.vue'
import MailLoadError from '@/components/mail/MailLoadError.vue'
import MailNewNotice from '@/components/mail/MailNewNotice.vue'
import MailNotifyOffer from '@/components/mail/MailNotifyOffer.vue'
import MailSearchEmpty from '@/components/mail/MailSearchEmpty.vue'
import MailSearchHeader from '@/components/mail/MailSearchHeader.vue'
import MailSearchScope from '@/components/mail/MailSearchScope.vue'
@@ -70,6 +71,14 @@ function focusSection() {
sectionRef.value?.focus({ preventScroll: true })
}
/** 알림 권하기 줄이 사라졌다 — 누른 단추와 함께 초점이 사라졌으면 목록 칸으로 */
function onOfferLeave() {
void nextTick(() => {
const active = document.activeElement
if (active === null || active === document.body) focusSection()
})
}
function onKeydown(event: KeyboardEvent) {
if (event.key !== 'Escape' || !selecting.value || event.defaultPrevented || isImeComposing(event)) return
const target = event.target instanceof Element ? event.target : null
@@ -119,6 +128,11 @@ watch(
:variant="variant"
/>
<MailSearchScope v-if="search" />
<MailNotifyOffer
v-else
:variant="variant"
@leave="onOfferLeave"
/>
<div
class="mail-list-frame__notice"
role="status"
@@ -0,0 +1,172 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { flushPromises, mount } from '@vue/test-utils'
import { createPinia, setActivePinia, type Pinia } from 'pinia'
import { useAuthStore } from '@/stores/auth.store'
import { mailNotifyStorageKey, useMailNotifyStore } from '@/stores/mail-notify.store'
import { useToastStore } from '@/stores/toast.store'
import { FakeBroadcastChannel } from '@/test-utils/broadcast'
import { FakeNotification } from '@/test-utils/notification'
import { createTestRouter } from '@/test-utils/router'
import { installFakeLocks, uninstallFakeLocks } from '@/test-utils/web-locks'
import MailNotifyOffer from './MailNotifyOffer.vue'
// 목록 위에서 한 번 권하기 (디자인 원본 MailRealtime · README "5단계 › 목록 위에서 한 번 권하기")
const USER = { email: 'ara.kim@arasystem.test', name: '김아라' }
describe('MailNotifyOffer (바탕화면 알림 권하기 줄)', () => {
let pinia: Pinia
beforeEach(() => {
pinia = createPinia()
setActivePinia(pinia)
window.localStorage.clear()
installFakeLocks()
vi.stubGlobal('BroadcastChannel', FakeBroadcastChannel)
vi.stubGlobal('Notification', FakeNotification)
FakeNotification.reset('default')
useAuthStore().markAuthenticated(USER)
})
afterEach(() => {
useToastStore().clear()
uninstallFakeLocks()
vi.unstubAllGlobals()
document.body.innerHTML = ''
})
async function mountOffer(variant: 'split' | 'list' = 'list') {
const router = createTestRouter()
await router.push('/mail/inbox')
await router.isReady()
const wrapper = mount(MailNotifyOffer, {
props: { variant },
global: { plugins: [pinia, router] },
attachTo: document.body,
})
await flushPromises()
return wrapper
}
it('알림을 켜지도 닫지도 않았고 브라우저가 아직 묻지 않았으면 보인다 — 종 아이콘 · 글 · [알림 켜기] · [✕ 닫기]', async () => {
const wrapper = await mountOffer()
const bar = wrapper.get('[role="region"]')
expect(bar.attributes('aria-label')).toBe('바탕화면 알림 권하기')
expect(bar.text()).toContain('바탕화면 알림을 켜 두면 다른 창을 보는 동안에도 새 메일을 알려 드립니다.')
expect(bar.get('.notify-offer__action').text()).toBe('알림 켜기')
expect(bar.get('.notify-offer__close').attributes('aria-label')).toBe('닫기')
})
it('분할 보기의 좁은 목록 칸에서는 짧은 글 "바탕화면 알림으로 새 메일 받기" — 긴 글은 title 로', async () => {
const split = await mountOffer('split')
const text = split.get('.notify-offer__text')
expect(text.text()).toBe('바탕화면 알림으로 새 메일 받기')
expect(text.attributes('title')).toBe('바탕화면 알림을 켜 두면 다른 창을 보는 동안에도 새 메일을 알려 드립니다.')
split.unmount()
const list = await mountOffer('list')
expect(list.get('.notify-offer__text').text()).toBe(
'바탕화면 알림을 켜 두면 다른 창을 보는 동안에도 새 메일을 알려 드립니다.',
)
})
it('허락을 묻는 동안 단추를 끄지 않고 aria-disabled — 초점이 남고, 다시 눌러도 다시 묻지 않는다', async () => {
const wrapper = await mountOffer()
const answer = FakeNotification.holdAnswer()
const action = wrapper.get('.notify-offer__action')
;(action.element as HTMLElement).focus()
await action.trigger('click')
await flushPromises()
expect(action.attributes('disabled')).toBeUndefined()
expect(action.attributes('aria-disabled')).toBe('true')
expect(document.activeElement).toBe(action.element)
await action.trigger('click')
await flushPromises()
expect(FakeNotification.requestCount).toBe(1)
answer('default')
await flushPromises()
expect(wrapper.get('.notify-offer__action').attributes('aria-disabled')).toBeUndefined()
expect(document.activeElement).toBe(action.element)
})
it.each([
['브라우저가 이미 막았으면', () => FakeNotification.reset('denied')],
['브라우저가 이미 허락했으면', () => FakeNotification.reset('granted')],
['지원하지 않으면', () => vi.stubGlobal('Notification', undefined)],
[
'닫은 적이 있으면',
() =>
window.localStorage.setItem(mailNotifyStorageKey(USER.email), JSON.stringify({ offerDismissed: true })),
],
['이미 켰으면', () => window.localStorage.setItem(mailNotifyStorageKey(USER.email), JSON.stringify({ enabled: true }))],
])('%s 보이지 않는다', async (_label, arrange) => {
// 설정 스토어는 줄을 그릴 때 만들어진다 — 그 전에 저장값 · 허락을 정한다
arrange()
const wrapper = await mountOffer()
expect(wrapper.find('[role="region"]').exists()).toBe(false)
})
it('[알림 켜기] → 허용하면 켜고 "바탕화면 알림을 켰습니다." — 줄은 사라진다', async () => {
const wrapper = await mountOffer()
FakeNotification.nextAnswer = 'granted'
await wrapper.get('.notify-offer__action').trigger('click')
await flushPromises()
expect(useMailNotifyStore().settings.enabled).toBe(true)
expect(useToastStore().toasts).toMatchObject([{ kind: 'success', message: '바탕화면 알림을 켰습니다.' }])
expect(wrapper.find('[role="region"]').exists()).toBe(false)
expect(wrapper.emitted('leave')).toHaveLength(1)
})
it('[알림 켜기] → 막으면 오류 알림 + [설정 열기](설정 › 알림) — 줄은 사라진다', async () => {
const wrapper = await mountOffer()
FakeNotification.nextAnswer = 'denied'
await wrapper.get('.notify-offer__action').trigger('click')
await flushPromises()
expect(useMailNotifyStore().settings.enabled).toBe(false)
expect(useToastStore().toasts).toMatchObject([
{
kind: 'error',
message: '브라우저가 알림을 막고 있습니다. 푸는 방법은 설정 › 알림에 있습니다.',
action: { label: '설정 열기', to: { name: 'settings-mail-notify' } },
},
])
expect(wrapper.find('[role="region"]').exists()).toBe(false)
})
it('[알림 켜기] → 그냥 닫으면 줄은 그대로 · 알림도 없다', async () => {
const wrapper = await mountOffer()
FakeNotification.nextAnswer = 'default'
await wrapper.get('.notify-offer__action').trigger('click')
await flushPromises()
expect(wrapper.find('[role="region"]').exists()).toBe(true)
expect(useToastStore().toasts).toEqual([])
expect(wrapper.emitted('leave')).toBeUndefined()
})
it('[✕] → 다시 보이지 않는다 (브라우저 · 사용자마다 저장)', async () => {
const wrapper = await mountOffer()
await wrapper.get('.notify-offer__close').trigger('click')
expect(wrapper.find('[role="region"]').exists()).toBe(false)
expect(JSON.parse(window.localStorage.getItem(mailNotifyStorageKey(USER.email)) ?? '{}')).toMatchObject({
offerDismissed: true,
})
expect(FakeNotification.requestCount).toBe(0)
expect(wrapper.emitted('leave')).toHaveLength(1)
})
})
@@ -0,0 +1,170 @@
<script setup lang="ts">
// 목록 위에서 한 번 권하기 — 바탕화면 알림을 켜지도 닫지도 않은 사람에게 (디자인 원본 MailRealtime · README "5단계 › 목록 위에서 한 번 권하기")
// 목록 머리 아래 40 줄(바탕 #F3F8F8 — 새 메일 알림 줄과 같은 높이, 밀도 예외 — 줄 안의 단추는 36):
// 종 아이콘 + 글 + [알림 켜기](강조 글자 굵게) + [✕ 닫기]. 분할 · 목록 보기 모두 (목록 틀 MailListFrame 이 메일함 목록에 둔다).
// 분할 보기의 좁은 목록 칸(384)에서는 짧은 글 "바탕화면 알림으로 새 메일 받기"(긴 글은 title) — 단추와 함께 한 줄에 든다 (2026-10-28 화면 점검)
// - 보이는 조건: 지원하고 · 브라우저가 아직 묻지 않았고(default) · 켜지 않았고 · 닫은 적이 없을 때 (mail-notify.store 의 offerVisible)
// - [알림 켜기] → 브라우저가 묻는다 → 허용: 켜고 "바탕화면 알림을 켰습니다." · 막힘: 오류 알림 + [설정 열기] · 그냥 닫음: 줄은 그대로
// 묻는 동안은 단추를 끄지 않고 aria-disabled 로만 막는다 — 초점이 그대로 남게 (다시 누르는 것은 스토어가 막는다)
// - [✕] → 다시 보이지 않는다 (브라우저 · 사용자마다)
// 줄이 사라지면 leave 를 알린다 — 누른 단추가 없어져 초점을 잃지 않게 목록 틀이 목록 칸에 초점을 준다
import { computed, onMounted } from 'vue'
import AppIcon from '@/components/AppIcon.vue'
import { useToast } from '@/composables/useToast'
import { MAIL_NOTIFY_TEXT } from '@/shared/constants/mail-notify'
import { SETTINGS_ROUTES } from '@/shared/constants/settings-menu'
import { useMailNotifyStore } from '@/stores/mail-notify.store'
interface Props {
/** 분할 보기의 목록 칸(좁다) · 목록 보기의 표 (넓다) */
variant?: 'split' | 'list'
}
interface Emits {
(e: 'leave'): void
}
const props = withDefaults(defineProps<Props>(), {
variant: 'list',
})
const emit = defineEmits<Emits>()
const notifyStore = useMailNotifyStore()
const toast = useToast()
const text = computed(() => (props.variant === 'split' ? MAIL_NOTIFY_TEXT.offerShortText : MAIL_NOTIFY_TEXT.offerText))
async function turnOn() {
if (notifyStore.requesting) return
const result = await notifyStore.turnOn()
if (result === 'granted') {
toast.success(MAIL_NOTIFY_TEXT.enabledToast)
emit('leave')
} else if (result === 'denied') {
toast.error(MAIL_NOTIFY_TEXT.blockedToast, {
label: MAIL_NOTIFY_TEXT.openSettings,
to: { name: SETTINGS_ROUTES.notify },
})
emit('leave')
}
}
function dismiss() {
notifyStore.dismissOffer()
emit('leave')
}
// 사이트 설정에서 허락을 바꿨을 수 있다 — 목록을 열 때 다시 읽는다
onMounted(() => notifyStore.refreshPermission())
</script>
<template>
<div
v-if="notifyStore.offerVisible"
:class="['notify-offer', `notify-offer--${variant}`]"
role="region"
:aria-label="MAIL_NOTIFY_TEXT.offerRegion"
>
<AppIcon
name="bell"
:size="15"
:stroke-width="1.9"
class="notify-offer__icon"
/>
<span
class="notify-offer__text"
:title="MAIL_NOTIFY_TEXT.offerText"
>{{ text }}</span>
<button
type="button"
class="notify-offer__action"
:aria-disabled="notifyStore.requesting ? 'true' : undefined"
@click="turnOn"
>
{{ MAIL_NOTIFY_TEXT.offerAction }}
</button>
<button
type="button"
class="notify-offer__close"
:aria-label="MAIL_NOTIFY_TEXT.offerClose"
:title="MAIL_NOTIFY_TEXT.offerClose"
@click="dismiss"
>
<AppIcon
name="x"
:size="14"
:stroke-width="2"
/>
</button>
</div>
</template>
<style scoped>
.notify-offer {
display: flex;
align-items: center;
gap: var(--space-10);
min-height: var(--size-notice-bar);
padding: 0 var(--space-4) 0 var(--space-14);
border-bottom: var(--border-width) solid var(--color-brand-tint-border);
background: var(--color-brand-tint);
color: var(--color-brand-surface);
}
.notify-offer__icon {
color: var(--color-brand-strong);
}
/* 좁으면 글이 먼저 줄어든다 — 단추는 늘 보인다 */
.notify-offer__text {
flex: 1 1 auto;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.notify-offer__action,
.notify-offer__close {
flex: 0 0 auto;
display: inline-flex;
align-items: center;
justify-content: center;
height: var(--size-notice-action);
border: 0;
border-radius: var(--radius-sm);
background: transparent;
}
.notify-offer__action {
padding: 0 var(--space-10);
color: var(--color-brand-strong);
font-weight: 600;
}
.notify-offer__action:hover:not([aria-disabled='true']),
.notify-offer__close:hover {
background: var(--color-brand-subtle);
}
/* 허락을 묻는 동안 — 끄지 않고 흐리게만 (초점이 남는다) */
.notify-offer__action[aria-disabled='true'] {
opacity: 0.6;
cursor: progress;
}
.notify-offer__close {
width: var(--size-notice-action);
color: var(--color-text-muted);
}
.notify-offer__close:hover {
color: var(--color-text-strong);
}
.notify-offer__action:focus-visible,
.notify-offer__close:focus-visible {
outline-offset: calc(var(--focus-ring-width) * -1);
}
</style>
@@ -0,0 +1,281 @@
<script setup lang="ts">
// 바탕화면 알림 켜기 · 알림에 보일 것 · 시험 알림 (디자인 원본 SettingsNotify · MailRealtime "막혔을 때 · 지원하지 않을 때")
// - [x] 새 메일이 오면 바탕화면 알림 보내기(44) — 켜면 브라우저가 허락을 묻는다(누른 뒤에만). 칸 아래 안내(12.5px):
// 묻기 전(회색) · 허용됨(초록 + 체크) · 막힘(칸을 끄고 빨강 + 경고) · 지원하지 않음(칸을 끄고 회색) — notifyEnableState
// 허락하지 않았으면(막음 · 그냥 닫음) 칸은 비운 채 돌아간다. 묻는 동안은 칸을 끄지 않고 aria-disabled 로만 막는다(초점이 그대로 남게).
// 막혀서(또는 지원하지 않아서) 칸이 꺼지면 초점을 안내 글로 옮긴다 — 왜 꺼졌는지 바로 읽게
// - 알림에 보일 것: [x] 메일 제목도 보이기 — 알림을 끄면 흐리게(누를 수 없다)
// - [시험 알림 보내기](보조 단추 44 · 종 아이콘) — 지금 탭에서 바로 "ARA 웹메일" / "알림이 잘 옵니다. …" (알림을 끄면 누를 수 없다).
// 못 띄우면 조용히 넘어가지 않는다 — 만들 수 없는 브라우저(휴대폰 Chrome 등)면 "지원하지 않음", 허락이 사라졌으면 그 안내로 바꾸고 초점을 옮긴다
// 고정 id — #notify-enabled · #notify-enabled-note · #notify-show-subject · #notify-test
import { computed, nextTick, ref } from 'vue'
import AppIcon from '@/components/AppIcon.vue'
import { showDesktopNotification } from '@/composables/useDesktopNotification'
import type { IconName } from '@/shared/constants/icons'
import { MAIL_NOTIFY_TEXT } from '@/shared/constants/mail-notify'
import { notifyEnableState, type NotifyEnableNote } from '@/shared/utils/desktop-notify'
import { useMailNotifyStore } from '@/stores/mail-notify.store'
interface EnableNote {
text: string
tone: 'muted' | 'success' | 'danger'
icon: IconName | null
}
const ENABLE_NOTES: Readonly<Record<NotifyEnableNote, EnableNote>> = {
ask: { text: MAIL_NOTIFY_TEXT.noteAsk, tone: 'muted', icon: null },
granted: { text: MAIL_NOTIFY_TEXT.noteGranted, tone: 'success', icon: 'check' },
blocked: { text: MAIL_NOTIFY_TEXT.noteBlocked, tone: 'danger', icon: 'alert-circle' },
unsupported: { text: MAIL_NOTIFY_TEXT.noteUnsupported, tone: 'muted', icon: null },
}
const notifyStore = useMailNotifyStore()
const noteRef = ref<HTMLElement | null>(null)
const enableState = computed(() => notifyEnableState(notifyStore.permission, notifyStore.settings))
const note = computed(() => ENABLE_NOTES[enableState.value.note])
/** 알림을 끈 동안은 보일 것 · 시험 알림을 흐리게 */
const optionsDisabled = computed(() => !enableState.value.checked)
/** 칸 · 단추가 꺼져 초점을 잃을 때 — 까닭을 적은 안내 글로 옮긴다 */
async function focusNote() {
await nextTick()
noteRef.value?.focus()
}
async function onToggle(event: Event) {
const input = event.target as HTMLInputElement
if (notifyStore.requesting) {
// 허락을 묻는 중 — 방금 누른 것만 되돌린다 (칸은 aria-disabled · 켜려던 모양 그대로 답을 기다린다)
input.checked = !input.checked
return
}
if (!input.checked) {
notifyStore.turnOff()
return
}
const result = await notifyStore.turnOn()
// 허락하지 않았으면 칸을 되돌린다 — 그려야 할 값(비움)이 그대로라 화면이 다시 그리지 않는다
input.checked = enableState.value.checked
if (enableState.value.disabled) await focusNote()
else if (result !== 'granted') input.focus()
}
function onShowSubject(event: Event) {
notifyStore.setShowSubject((event.target as HTMLInputElement).checked)
}
async function sendTest() {
const result = showDesktopNotification({ title: MAIL_NOTIFY_TEXT.testTitle, body: MAIL_NOTIFY_TEXT.testBody })
if (result.status === 'shown') return
if (result.status === 'unsupported') notifyStore.markUnsupported()
else notifyStore.refreshPermission()
// 칸 · 이 단추가 꺼진다 — 바뀐 안내 글로
await focusNote()
}
</script>
<template>
<div class="notify-form">
<div class="notify-form__enable">
<label :class="['notify-form__toggle', { 'is-disabled': enableState.disabled }]">
<input
id="notify-enabled"
type="checkbox"
class="notify-form__checkbox"
:checked="enableState.checked"
:disabled="enableState.disabled"
:aria-disabled="notifyStore.requesting ? 'true' : undefined"
aria-describedby="notify-enabled-note"
@change="onToggle"
>
<span>{{ MAIL_NOTIFY_TEXT.enable }}</span>
</label>
<p
id="notify-enabled-note"
ref="noteRef"
:class="['form-message', 'notify-form__note', `notify-form__note--${note.tone}`]"
role="status"
tabindex="-1"
:data-note="enableState.note"
>
<AppIcon
v-if="note.icon"
:name="note.icon"
:size="15"
:stroke-width="note.tone === 'success' ? 2.2 : 1.9"
/>
<span>{{ note.text }}</span>
</p>
</div>
<fieldset
class="notify-form__content"
:disabled="optionsDisabled"
>
<legend class="notify-form__legend">
{{ MAIL_NOTIFY_TEXT.contentLegend }}
</legend>
<label class="notify-form__option">
<input
id="notify-show-subject"
type="checkbox"
class="notify-form__checkbox"
:checked="notifyStore.settings.showSubject"
aria-describedby="notify-show-subject-hint"
@change="onShowSubject"
>
<span>{{ MAIL_NOTIFY_TEXT.showSubject }}</span>
</label>
<p
id="notify-show-subject-hint"
class="form-message notify-form__note"
>
{{ MAIL_NOTIFY_TEXT.showSubjectHint }}
</p>
</fieldset>
<div class="notify-form__test">
<button
id="notify-test"
type="button"
class="notify-form__test-button"
:disabled="optionsDisabled"
@click="sendTest"
>
<AppIcon
name="bell"
:size="15"
/>
<span>{{ MAIL_NOTIFY_TEXT.test }}</span>
</button>
<span class="notify-form__test-hint">{{ MAIL_NOTIFY_TEXT.testHint }}</span>
</div>
</div>
</template>
<style scoped>
.notify-form {
display: flex;
flex-direction: column;
gap: var(--space-22);
}
.notify-form__enable {
display: flex;
flex-direction: column;
gap: var(--space-6);
}
.notify-form__toggle,
.notify-form__option {
display: flex;
align-items: center;
gap: var(--space-10);
min-height: var(--size-touch);
cursor: pointer;
}
.notify-form__toggle {
font-weight: 600;
}
.notify-form__toggle.is-disabled {
color: var(--color-icon-muted);
cursor: default;
}
.notify-form__checkbox {
width: var(--size-option-input);
height: var(--size-option-input);
margin: 0;
accent-color: var(--color-brand);
}
/* 칸 아래 안내 — 고르기 상자 글자 선(28)에 맞춰 들여 쓴다 */
.notify-form__note {
margin-left: var(--space-28);
}
/* 프로그램이 옮긴 초점(칸이 꺼졌을 때)에는 고리를 그리지 않는다 — 글을 읽게 할 뿐이다 */
.notify-form__note:focus {
outline: none;
}
.notify-form__checkbox[aria-disabled='true'] {
cursor: progress;
}
.notify-form__note--success {
color: var(--color-success-text);
}
.notify-form__note--danger {
color: var(--color-danger);
}
.notify-form__content {
display: flex;
flex-direction: column;
margin: 0;
padding: 0;
border: 0;
}
.notify-form__content:disabled {
color: var(--color-icon-muted);
}
.notify-form__content:disabled .notify-form__option {
cursor: default;
}
.notify-form__content:disabled .notify-form__note {
color: var(--color-icon-muted);
}
.notify-form__legend {
margin: 0 0 var(--space-4);
padding: 0;
font-weight: 600;
color: var(--color-text);
}
.notify-form__test {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--space-12);
}
.notify-form__test-button {
display: inline-flex;
align-items: center;
gap: var(--space-8);
min-height: var(--size-touch);
padding: 0 var(--space-16);
border: var(--border-width) solid var(--color-border-strong);
border-radius: var(--radius-md);
background: var(--color-surface);
color: var(--color-text-strong);
font-weight: 600;
}
.notify-form__test-button:hover:not(:disabled) {
background: var(--color-surface-panel);
color: var(--color-text);
}
.notify-form__test-button:disabled {
background: var(--color-surface-panel);
color: var(--color-icon-muted);
cursor: not-allowed;
}
.notify-form__test-hint {
font-size: var(--font-size-label);
color: var(--color-text-muted);
}
</style>
@@ -0,0 +1,101 @@
<script setup lang="ts">
// 설정 › 알림의 "실시간 연결" (디자인 원본 SettingsNotify) — 이 탭이 보는 연결 상태 (stores/mail-realtime.store.ts)
// ● 초록 "연결되어 있습니다 — 새 메일이 오면 바로 목록에 나타납니다." / 끊겼을 때 "연결할 수 없어 1분마다 확인합니다." (저절로 다시 잇는다)
// 끊긴 동안은 상태 줄 아래에 "연결될 때까지 바탕화면 알림은 오지 않습니다." (2026-10-28 화면 점검 반영)
// 메일 화면에서는 끊겨도 알리지 않는다 — 여기서만 볼 수 있다 (디자인 README "5단계 › 실시간 반영")
import { computed } from 'vue'
import { MAIL_NOTIFY_TEXT } from '@/shared/constants/mail-notify'
import { realtimeLinkText } from '@/shared/utils/mail-realtime'
import { useMailRealtimeStore } from '@/stores/mail-realtime.store'
const realtimeStore = useMailRealtimeStore()
const link = computed(() => realtimeLinkText(realtimeStore.status))
</script>
<template>
<section
class="realtime-link"
aria-labelledby="notify-realtime-title"
>
<h2
id="notify-realtime-title"
class="realtime-link__title"
>
{{ MAIL_NOTIFY_TEXT.realtimeTitle }}
</h2>
<!-- 상태가 바뀌면 끊긴 동안의 알림까지 함께 읽힌다 -->
<div
class="realtime-link__live"
role="status"
>
<p
id="notify-realtime-status"
class="realtime-link__status"
:data-status="realtimeStore.status"
>
<span
:class="['realtime-link__dot', { 'is-live': link.live }]"
aria-hidden="true"
/>
<span>{{ link.text }}</span>
</p>
<p
v-if="link.notice"
id="notify-realtime-notice"
class="realtime-link__notice"
>
{{ link.notice }}
</p>
</div>
<p class="form-message">
{{ link.hint }}
</p>
</section>
</template>
<style scoped>
.realtime-link {
display: flex;
flex-direction: column;
gap: var(--space-8);
}
.realtime-link__title {
margin: 0;
font-size: var(--font-size-body);
font-weight: 700;
}
.realtime-link__live {
display: flex;
flex-direction: column;
gap: var(--space-2);
}
.realtime-link__status {
display: flex;
align-items: center;
gap: var(--space-8);
margin: 0;
}
.realtime-link__dot {
flex: 0 0 auto;
width: var(--size-status-dot);
height: var(--size-status-dot);
border-radius: var(--radius-round);
background: var(--color-icon-muted);
}
.realtime-link__dot.is-live {
background: var(--color-status-live);
}
/* 끊긴 동안 — 상태 줄의 글자 선(점 8 + 간격 8)에 맞춰 들여 쓴다 */
.realtime-link__notice {
margin: 0 0 0 calc(var(--size-status-dot) + var(--space-8));
color: var(--color-text-strong);
}
</style>
@@ -1,6 +1,7 @@
<script setup lang="ts">
// 설정 화면의 틀 — 제목(18px) · 설명 + 내용 (디자인 원본 SettingsAccount · SettingsVacation)
// 콘텐츠 열은 읽기 · 쓰기 화면과 같은 값이다 — 최대 800 · 좌우 32 · 위 20 · 아래 36 (tokens.css --content-*), 묶음 사이 22.
// 한국어 안내 글은 낱말 가운데에서 줄을 바꾸지 않는다(keep-all) — 긴 주소 · 영문은 넘치지 않게 어디서든 끊는다(anywhere) (2026-10-28 화면 점검)
// 사용 예: <SettingsPageFrame title-id="password-page-title" :title="…" :description="…">…</SettingsPageFrame>
interface Props {
/** 제목(h1)의 id — main 이 aria-labelledby 로 가리킨다 */
@@ -40,6 +41,8 @@ defineProps<Props>()
min-width: 0;
display: flex;
flex-direction: column;
word-break: keep-all;
overflow-wrap: anywhere;
}
.settings-page__column {
+5 -2
View File
@@ -217,8 +217,11 @@ function shouldRefresh(code: string, config: InternalAxiosRequestConfig): boolea
)
}
/** 갱신 실패가 "세션이 끝났다"는 뜻인지 — 네트워크·5xx 장애면 세션 만료로 단정하지 않는다 */
function isSessionEnd(code: ErrorCode): boolean {
/**
* 갱신 실패가 "세션이 끝났다"는 뜻인지 — 네트워크·5xx 장애면 세션 만료로 단정하지 않는다.
* axios 를 거치지 않는 연결(실시간 알림 — useMailRealtime)도 같은 규칙으로 가른다
*/
export function isSessionEnd(code: ErrorCode): boolean {
return code === 'AUTH_001' || code === 'AUTH_002'
}
@@ -0,0 +1,74 @@
import notificationIconUrl from '@/assets/brand/notification-icon.png'
import { DESKTOP_NOTIFY_TAG } from '@/shared/constants/mail-notify'
import { supportsDesktopNotification } from '@/shared/utils/browser-support'
import type { DesktopNotifyPermission } from '@/shared/utils/desktop-notify'
// 브라우저 바탕화면 알림(Notification) — 지원 · 허락 확인, 허락 묻기, 띄우기 (디자인 README "5단계 › 바탕화면 알림")
// 모양은 운영체제 · 브라우저가 그린다 — 정하는 것은 제목 · 내용 · 아이콘(ARA 마크, 바탕 #0B3640 둥근 사각)뿐이다.
// 아이콘은 assets/brand/notification-icon.png (192px — logo-ara-white.png 를 둥근 사각 가운데 34/44 폭으로 얹었다).
// 알림은 하나의 tag 로 앞 알림을 바꿔 쓴다(renotify 로 다시 알린다) — 바탕화면에 쌓이지 않는다.
// 허락은 사용자가 단추 · 칸을 누른 뒤에만 묻는다 (requestNotificationPermission 은 누른 처리 안에서 기다리기 전에 부른다).
// 휴대폰 Chrome 처럼 Notification 은 있는데 페이지에서 만들 수 없는(만들면 예외를 던지는) 브라우저가 있다 — 그 결과를
// 'unsupported' 로 돌려주고, 부르는 쪽이 설정(mail-notify.store 의 markUnsupported)을 "지원하지 않음"으로 바꾼다.
/** lib.dom 에 아직 없는 renotify — 같은 tag 로 바꿔 쓸 때도 다시 알린다 (지원하지 않는 브라우저는 무시한다) */
type DesktopNotificationOptions = NotificationOptions & { renotify?: boolean }
export function currentNotificationPermission(): DesktopNotifyPermission {
if (!supportsDesktopNotification()) return 'unsupported'
try {
return Notification.permission
} catch {
return 'unsupported'
}
}
/**
* 허락을 묻는다 — 이미 정했으면(허용 · 차단) 브라우저는 묻지 않고 그 값을 준다. 그냥 닫으면 'default' 다.
* 누른 처리 안에서 기다리기 전에 부른다(브라우저가 사용자 동작 없이 묻는 것을 막는다). 실패하면 지금 값을 돌려준다.
* 콜백 방식(옛 Safari)은 받지 않는다 — 그런 브라우저는 Web Locks 도 없어 지원하지 않는 것으로 본다 (browser-support.ts)
*/
export async function requestNotificationPermission(): Promise<DesktopNotifyPermission> {
if (!supportsDesktopNotification()) return 'unsupported'
try {
return await Notification.requestPermission()
} catch {
return currentNotificationPermission()
}
}
export interface DesktopNotificationInput {
title: string
body: string
/** 눌렀을 때 — 창을 앞으로 가져오고 알림을 닫은 뒤에 부른다 */
onClick?: () => void
}
/**
* 띄운 결과 — shown: 띄웠다(close 로 닫는다) · not-allowed: 허락이 없다(지원하지 않음 포함) ·
* unsupported: 만들 수 없는 브라우저다(만들면 예외 — 휴대폰 Chrome 등)
*/
export type DesktopNotificationResult = { status: 'shown'; close: () => void } | { status: 'not-allowed' | 'unsupported' }
export function showDesktopNotification(input: DesktopNotificationInput): DesktopNotificationResult {
if (currentNotificationPermission() !== 'granted') return { status: 'not-allowed' }
const options: DesktopNotificationOptions = {
body: input.body,
tag: DESKTOP_NOTIFY_TAG,
renotify: true,
icon: notificationIconUrl,
}
let notification: Notification
try {
notification = new Notification(input.title, options)
} catch {
// 페이지에서 바로 띄우지 못한다(서비스 워커만 — 휴대폰 Chrome 등)
return { status: 'unsupported' }
}
notification.onclick = () => {
window.focus()
notification.close()
input.onClick?.()
}
return { status: 'shown', close: () => notification.close() }
}
@@ -60,6 +60,19 @@ export function useMailNavigation() {
await router.replace(mailboxId ? mailboxLocation(mailboxId) : { name: 'mail' })
}
/**
* 바탕화면 알림을 눌렀을 때 (디자인 README "5단계 › 바탕화면 알림") — 받은편지함의 그 메일, 여러 통이면(messageId 없음) 받은편지함.
* 사용자가 고른 이동이라 기록에 쌓는다 — 목록에서 메일을 여는 것과 같다 (이미 그 화면이면 그대로).
* 받은편지함을 모르면 /mail 로 — /mail 은 메일함을 받은 뒤 기본 메일함(받은편지함)으로 바꿔 끼운다
*/
async function openFromNotification(inboxId: string | null, messageId: string | null): Promise<void> {
if (!inboxId) {
await router.push({ name: 'mail' })
return
}
await router.push(messageId ? messageLocation(inboxId, messageId) : mailboxLocation(inboxId))
}
return {
mailboxLocation,
messageLocation,
@@ -69,5 +82,6 @@ export function useMailNavigation() {
showSibling,
showSiblingIn,
replaceWithMailbox,
openFromNotification,
}
}
@@ -0,0 +1,889 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { createPinia, setActivePinia, type Pinia } from 'pinia'
import { effectScope, watch, type EffectScope } from 'vue'
import {
isUserViewingPage,
useMailRealtime,
type MailRealtimeOptions,
type NewMailContext,
} from '@/composables/useMailRealtime'
import {
MAIL_EVENTS_CHANNEL_NAME,
MAIL_EVENTS_SILENCE_MS,
MAIL_EVENTS_STABLE_MS,
MAIL_EVENTS_VISIBILITY_WAIT_MS,
} from '@/shared/constants/mail-notify'
import type { NewMailSignal } from '@/shared/utils/mail-events'
import {
mailEventsLockName,
packRealtimeMessage,
unpackRealtimeMessage,
type MailRealtimeStatus,
type RealtimeMessage,
} from '@/shared/utils/mail-realtime'
import { SSE_MAX_FIELD_LENGTH } from '@/shared/utils/sse-parser'
import { useAuthStore } from '@/stores/auth.store'
import { useMailRealtimeStore } from '@/stores/mail-realtime.store'
import { useToastStore } from '@/stores/toast.store'
import { FakeBroadcastChannel } from '@/test-utils/broadcast'
import { createEventStreamServer, type EventStreamServer } from '@/test-utils/event-stream'
import { createManualClock, type ManualClock } from '@/test-utils/manual-clock'
import { fail, flushPromises, installMockApi, ok, restoreApiAdapter, type MockRequest } from '@/test-utils/mock-api'
import { installFakeLocks, uninstallFakeLocks, type FakeLockManager } from '@/test-utils/web-locks'
// 실시간 새 메일의 연결 (계약 2절 "실시간 알림 (GET /api/mail-events)")
// 가짜 서버(SSE) · 가짜 잠금(Web Locks) · 가짜 채널(BroadcastChannel) · 손으로 돌리는 시계로 확인한다 (고정 대기 없이).
// 다시 잇는 간격의 흔들림은 가운데 값(0.5 — 흔들림 없음)으로 둔다. 서버 종료 뒤 흔들림(0~3초)은 그래서 1.5초다.
const USER = { email: 'ara.kim@arasystem.test', name: '김아라' }
const OTHER_USER = { email: 'doyun.lee@arasystem.test', name: '이도윤' }
/** 흔들림 0.5 일 때 서버 종료(end) 뒤 다시 잇기까지 */
const SHUTDOWN_SPREAD_AT_HALF_MS = 1_500
interface Tab {
scope: EffectScope
pinia: Pinia
realtime: ReturnType<typeof useMailRealtimeStore>
auth: ReturnType<typeof useAuthStore>
/** 연결 상태가 바뀐 차례 */
statuses: MailRealtimeStatus[]
}
describe('useMailRealtime (실시간 연결)', () => {
let clock: ManualClock
let server: EventStreamServer
let locks: FakeLockManager
let tabs: Tab[]
let apiRequests: MockRequest[]
beforeEach(() => {
clock = createManualClock()
server = createEventStreamServer()
locks = installFakeLocks()
vi.stubGlobal('BroadcastChannel', FakeBroadcastChannel)
tabs = []
apiRequests = installMockApi(({ url }) => (url === '/auth/refresh' ? ok(USER) : ok(null)))
})
afterEach(() => {
tabs.forEach((tab) => tab.scope.stop())
FakeBroadcastChannel.closeAll()
uninstallFakeLocks()
vi.unstubAllGlobals()
vi.restoreAllMocks()
restoreApiAdapter()
useToastStore().clear()
})
/** 탭 하나 — 탭마다 스토어(Pinia)가 따로다. 잠금 · 채널은 브라우저 하나를 함께 쓴다 */
function openTab(options: MailRealtimeOptions = {}, user = USER): Tab {
const pinia = createPinia()
setActivePinia(pinia)
const auth = useAuthStore()
auth.markAuthenticated(user)
const scope = effectScope()
const realtime = useMailRealtimeStore(pinia)
const statuses: MailRealtimeStatus[] = []
scope.run(() => {
watch(
() => realtime.status,
(status) => statuses.push(status),
{ flush: 'sync' },
)
useMailRealtime({ clock, random: () => 0.5, fetch: server.fetch, isUserViewing: () => false, ...options })
})
const tab: Tab = { scope, pinia, realtime, auth, statuses }
tabs.push(tab)
return tab
}
/** 잠금 · 응답 · 채널 전달(다음 작업)이 끝나기를 몇 번 양보해 기다린다 */
async function settle() {
for (let round = 0; round < 6; round += 1) await flushPromises()
}
async function tick(ms: number) {
clock.advance(ms)
await settle()
}
/** 같은 브라우저의 다른 탭이 쓰는 채널 — 받은 메시지를 모은다 */
function rawTab(email = USER.email) {
const channel = new FakeBroadcastChannel(MAIL_EVENTS_CHANNEL_NAME)
const received: RealtimeMessage[] = []
channel.addEventListener('message', (event) => {
const message = unpackRealtimeMessage((event as MessageEvent<unknown>).data, email)
if (message) received.push(message)
})
return {
channel,
received,
post: (message: RealtimeMessage, from = email) => channel.postMessage(packRealtimeMessage(from, message)),
}
}
/** 다른 탭이 잠금을 쥐고 있다 — 돌려준 함수로 놓는다(그 탭이 stop 없이 닫힌 것처럼) */
function holdLockElsewhere(): () => void {
let release: () => void = () => undefined
void navigator.locks.request(
mailEventsLockName(USER.email),
() =>
new Promise<void>((resolve) => {
release = resolve
}),
)
return () => release()
}
/** 연결이 자리 잡게 한다 — 첫 state 와 정상 ping */
async function openAndSettle() {
server.last().emit('state', { state: 's1', inboxUnread: 0 })
server.last().emit('ping', { upstream: true })
await settle()
}
const refreshCount = () => apiRequests.filter((request) => request.url === '/auth/refresh').length
it('로그인한 탭이 잠금을 받아 /api/mail-events 를 fetch 로 연다 — 같은 주소 · 쿠키 · SSE', async () => {
const tab = openTab()
await settle()
expect(server.connections).toHaveLength(1)
const [connection] = server.connections
expect(connection?.url).toBe('/api/mail-events')
expect(connection?.init).toMatchObject({
method: 'GET',
cache: 'no-store',
credentials: 'same-origin',
headers: { Accept: 'text/event-stream' },
})
expect(locks.isHeld(mailEventsLockName(USER.email))).toBe(true)
expect(tab.realtime.status).toBe('connecting')
})
it('state 를 받으면 live — 처음 값은 기준, 바뀌면 메일 화면이 맞출 일 · 받은편지함의 안 읽은 수', async () => {
const tab = openTab()
await settle()
server.last().emit('state', { state: 's1', inboxUnread: 2 })
await settle()
expect(tab.realtime.status).toBe('live')
expect(tab.realtime.pendingSync).toBe(false)
server.last().emit('state', { state: 's2', inboxUnread: 3 })
await settle()
expect(tab.realtime.pendingSync).toBe(true)
expect(tab.realtime.inboxUnread).toBe(3)
})
it('조각으로 와도 · 주석 줄이 섞여도 읽는다', async () => {
const tab = openTab()
await settle()
server.last().send(': hello\n\nevent: sta')
server.last().send('te\r\ndata: {"state":"s1","inboxUnread":0}\r')
server.last().send('\n\r\n')
await settle()
expect(tab.realtime.status).toBe('live')
})
it('ping.upstream 이 false 면 끊김(1분 확인으로) — 연결은 그대로 두고, true 가 오면 다시 live', async () => {
const tab = openTab()
await settle()
server.last().emit('state', { state: 's1', inboxUnread: 0 })
await settle()
server.last().emit('ping', { upstream: false })
await settle()
expect(tab.realtime.status).toBe('down')
expect(server.last().aborted).toBe(false)
server.last().emit('ping', { upstream: true })
await settle()
expect(tab.realtime.status).toBe('live')
expect(server.connections).toHaveLength(1)
})
describe('다시 잇기', () => {
it('실패(5xx · 429 · 네트워크)는 2 · 5 · 15 · 30 · 60초 간격으로 다시 잇는다 — 자리 잡으면 처음 간격부터', async () => {
server.reply(
{ kind: 'status', status: 503 },
{ kind: 'network' },
{ kind: 'status', status: 429 },
{ kind: 'status', status: 502 },
{ kind: 'status', status: 503 },
{ kind: 'status', status: 503 },
)
const tab = openTab()
await settle()
expect(server.connections).toHaveLength(1)
expect(tab.realtime.status).toBe('down')
for (const [delay, total] of [
[2_000, 2],
[5_000, 3],
[15_000, 4],
[30_000, 5],
[60_000, 6],
[60_000, 7],
] as const) {
await tick(delay - 1)
expect(server.connections).toHaveLength(total - 1)
await tick(1)
expect(server.connections).toHaveLength(total)
}
// 일곱째는 열리고 자리 잡는다(정상 ping) — 그 뒤 끊기면 다시 2초부터
await openAndSettle()
expect(tab.realtime.status).toBe('live')
server.last().fail()
await settle()
expect(tab.realtime.status).toBe('down')
await tick(1_999)
expect(server.connections).toHaveLength(7)
await tick(1)
expect(server.connections).toHaveLength(8)
})
it('서버가 중간에 죽으면(읽기 오류) end 가 아니라 보통의 실패다 — 바로가 아니라 2초 뒤에', async () => {
const tab = openTab()
await settle()
server.last().emit('state', { state: 's1', inboxUnread: 0 })
await settle()
server.last().fail()
await settle()
expect(tab.realtime.status).toBe('down')
expect(server.connections).toHaveLength(1)
await tick(2_000)
expect(server.connections).toHaveLength(2)
})
it('서버가 그냥 닫아도(end 없이) 보통의 실패다', async () => {
openTab()
await settle()
server.last().emit('state', { state: 's1', inboxUnread: 0 })
await settle()
server.last().close()
await settle()
expect(server.connections).toHaveLength(1)
await tick(2_000)
expect(server.connections).toHaveLength(2)
})
it('state 뒤 바로 닫는 서버 — 자리 잡기 전에 끊기면 간격이 2 · 5 · 15초로 늘어난다 (2초마다 되풀이하지 않는다)', async () => {
openTab()
await settle()
for (const [delay, total] of [
[2_000, 2],
[5_000, 3],
[15_000, 4],
] as const) {
server.last().emit('state', { state: 's1', inboxUnread: 0 })
server.last().close()
await settle()
await tick(delay - 1)
expect(server.connections).toHaveLength(total - 1)
await tick(1)
expect(server.connections).toHaveLength(total)
}
})
it('정상 ping 이 없어도 20초 이어지면 자리 잡은 것이다 — 그 뒤 끊기면 2초부터', async () => {
server.reply({ kind: 'status', status: 503 }, { kind: 'status', status: 503 })
openTab()
await settle()
await tick(2_000)
await tick(5_000)
expect(server.connections).toHaveLength(3)
server.last().emit('state', { state: 's1', inboxUnread: 0 })
// 연결 직후의 upstream: false 알림은 자리 잡은 것으로 세지 않는다
server.last().emit('ping', { upstream: false })
await settle()
await tick(MAIL_EVENTS_STABLE_MS)
server.last().close()
await settle()
await tick(2_000)
expect(server.connections).toHaveLength(4)
// 자리 잡기 전(20초 안)에 끊기면 이어서 센다 — 다음은 5초
server.last().emit('state', { state: 's1', inboxUnread: 0 })
await settle()
await tick(MAIL_EVENTS_STABLE_MS - 1)
server.last().close()
await settle()
await tick(4_999)
expect(server.connections).toHaveLength(4)
await tick(1)
expect(server.connections).toHaveLength(5)
})
it('text/event-stream 이 아니면(프록시가 index.html 을 준 경우 등) 실패로 본다', async () => {
server.reply({ kind: 'stream', contentType: 'text/html' })
const tab = openTab()
await settle()
expect(tab.realtime.status).toBe('down')
await tick(2_000)
expect(server.connections).toHaveLength(2)
})
it('줄 끝 없이 1MB 를 넘게 보내면 실패로 끊고 다시 잇는다', async () => {
const tab = openTab()
await settle()
server.last().emit('state', { state: 's1', inboxUnread: 0 })
await settle()
server.last().send(`data: ${'x'.repeat(SSE_MAX_FIELD_LENGTH)}`)
await settle()
expect(server.connections[0]?.aborted).toBe(true)
expect(tab.realtime.status).toBe('down')
await tick(2_000)
expect(server.connections).toHaveLength(2)
})
it('자리 잡은 연결이 end(서버 종료)로 끝나면 0~3초 흔들어 다시 잇는다 — 끊김으로 알리지 않는다', async () => {
const tab = openTab()
await settle()
await openAndSettle()
server.last().emit('end', { reason: 'server-shutdown' })
await settle()
expect(server.connections[0]?.aborted).toBe(true)
await tick(SHUTDOWN_SPREAD_AT_HALF_MS - 1)
expect(server.connections).toHaveLength(1)
await tick(1)
expect(server.connections).toHaveLength(2)
expect(tab.realtime.status).toBe('live')
expect(tab.statuses).not.toContain('down')
expect(refreshCount()).toBe(0)
})
it('자리 잡은 연결의 end 가 token-expiring 이면 세션을 갱신한 뒤 바로 다시 잇는다', async () => {
openTab()
await settle()
await openAndSettle()
server.last().emit('end', { reason: 'token-expiring' })
await settle()
expect(refreshCount()).toBe(1)
expect(server.connections).toHaveLength(2)
})
it('열리기 전(첫 state 전)에 온 end 는 실패처럼 기다린다', async () => {
openTab()
await settle()
server.last().emit('end', { reason: 'server-shutdown' })
await settle()
expect(server.connections).toHaveLength(1)
await tick(2_000)
expect(server.connections).toHaveLength(2)
})
it('state 뒤 바로 end(token-expiring)를 보내는 서버(세션 끝 무렵) — 갱신하지 않고 간격이 늘어난다', async () => {
openTab()
await settle()
for (const [delay, total] of [
[2_000, 2],
[5_000, 3],
[15_000, 4],
] as const) {
server.last().emit('state', { state: 's1', inboxUnread: 0 })
server.last().emit('end', { reason: 'token-expiring' })
await settle()
expect(server.connections).toHaveLength(total - 1)
await tick(delay)
expect(server.connections).toHaveLength(total)
}
expect(refreshCount()).toBe(0)
})
it('갱신한 바로 뒤의 연결이 ping 전에 또 token-expiring 이면 다시 갱신하지 않고 기다린다', async () => {
openTab()
await settle()
await openAndSettle()
server.last().emit('end', { reason: 'token-expiring' })
await settle()
expect(refreshCount()).toBe(1)
expect(server.connections).toHaveLength(2)
server.last().emit('state', { state: 's1', inboxUnread: 0 })
server.last().emit('end', { reason: 'token-expiring' })
await settle()
expect(refreshCount()).toBe(1)
expect(server.connections).toHaveLength(2)
await tick(2_000)
expect(server.connections).toHaveLength(3)
})
it('75초 동안 아무것도 오지 않으면 끊고 다시 잇는다 — 머리글이 오지 않는 연결도 fetch 를 시작할 때부터 센다', async () => {
server.reply({ kind: 'hang' })
const tab = openTab()
await settle()
await tick(MAIL_EVENTS_SILENCE_MS - 1)
expect(server.connections[0]?.aborted).toBe(false)
await tick(1)
expect(server.connections[0]?.aborted).toBe(true)
expect(tab.realtime.status).toBe('down')
await tick(2_000)
expect(server.connections).toHaveLength(2)
// 열린 뒤에도 같다 — 마지막 신호에서 75초 (그만큼 이어졌으니 자리 잡은 것 — 다음은 2초)
server.last().emit('state', { state: 's1', inboxUnread: 0 })
await settle()
await tick(MAIL_EVENTS_SILENCE_MS - 1)
expect(tab.realtime.status).toBe('live')
await tick(1)
expect(tab.realtime.status).toBe('down')
expect(server.connections[1]?.aborted).toBe(true)
await tick(2_000)
expect(server.connections).toHaveLength(3)
})
it('ping 이 오면 75초를 다시 센다', async () => {
const tab = openTab()
await settle()
server.last().emit('state', { state: 's1', inboxUnread: 0 })
await settle()
await tick(50_000)
server.last().emit('ping', { upstream: true })
await settle()
await tick(50_000)
expect(tab.realtime.status).toBe('live')
expect(server.connections).toHaveLength(1)
})
})
describe('401 · 세션', () => {
it('401 이면 세션을 한 번 갱신하고 바로 다시 잇는다', async () => {
server.reply({ kind: 'status', status: 401 })
const tab = openTab()
await settle()
expect(refreshCount()).toBe(1)
expect(server.connections).toHaveLength(2)
server.last().emit('state', { state: 's1', inboxUnread: 0 })
await settle()
expect(tab.realtime.status).toBe('live')
expect(tab.auth.isLoggedIn).toBe(true)
})
it('갱신도 세션 끝(401)으로 실패하면 지금의 세션 만료 흐름 — 다시 잇지 않는다', async () => {
server.reply({ kind: 'status', status: 401 })
restoreApiAdapter()
apiRequests = installMockApi(() => fail(401, 'AUTH_001'))
const tab = openTab()
await settle()
expect(tab.auth.sessionExpired).toBe(true)
expect(tab.realtime.status).toBe('off')
await tick(120_000)
expect(server.connections).toHaveLength(1)
expect(locks.isHeld(mailEventsLockName(USER.email))).toBe(false)
})
it('갱신한 바로 뒤의 다시 잇기도 401 이면 세션 만료 (axios 의 401 재시도와 같다)', async () => {
server.reply({ kind: 'status', status: 401 }, { kind: 'status', status: 401 })
const tab = openTab()
await settle()
expect(refreshCount()).toBe(1)
expect(server.connections).toHaveLength(2)
expect(tab.auth.sessionExpired).toBe(true)
})
it('갱신 뒤의 다시 잇기가 401 이 아닌 까닭(503)으로 끝나면 "갱신 직후"를 지운다 — 나중의 401 은 다시 갱신하고 만료하지 않는다', async () => {
server.reply({ kind: 'status', status: 401 }, { kind: 'status', status: 503 }, { kind: 'status', status: 401 })
const tab = openTab()
await settle()
expect(refreshCount()).toBe(1)
expect(server.connections).toHaveLength(2)
await tick(2_000)
expect(server.connections).toHaveLength(4)
expect(refreshCount()).toBe(2)
expect(tab.auth.sessionExpired).toBe(false)
expect(tab.auth.isLoggedIn).toBe(true)
})
it('갱신이 장애(503)로 실패하면 세션은 그대로 두고 기다렸다 다시 — 그 뒤의 401 은 다시 갱신해 본다', async () => {
server.reply({ kind: 'status', status: 401 }, { kind: 'status', status: 401 })
restoreApiAdapter()
const refreshReplies = [fail(503, 'MAIL_001'), ok(USER)]
apiRequests = installMockApi(() => refreshReplies.shift() ?? ok(USER))
const tab = openTab()
await settle()
expect(refreshCount()).toBe(1)
expect(tab.auth.isLoggedIn).toBe(true)
expect(tab.realtime.status).toBe('down')
await tick(2_000)
expect(refreshCount()).toBe(2)
expect(server.connections).toHaveLength(3)
expect(tab.auth.sessionExpired).toBe(false)
})
it('로그아웃하면 잠금을 놓고 연결을 닫는다 — 다시 로그인하면 새로 잇는다', async () => {
const tab = openTab()
await settle()
server.last().emit('state', { state: 's1', inboxUnread: 0 })
await settle()
tab.auth.clearAuth()
await settle()
expect(server.connections[0]?.aborted).toBe(true)
expect(locks.isHeld(mailEventsLockName(USER.email))).toBe(false)
expect(tab.realtime.status).toBe('off')
await tick(120_000)
expect(server.connections).toHaveLength(1)
tab.auth.startSession(USER)
await settle()
expect(server.connections).toHaveLength(2)
expect(tab.realtime.status).toBe('connecting')
})
it('다른 계정으로 로그인하면 그 계정의 잠금으로 새로 잇는다', async () => {
const tab = openTab()
await settle()
tab.auth.startSession(OTHER_USER)
await settle()
expect(server.connections[0]?.aborted).toBe(true)
expect(server.connections).toHaveLength(2)
expect(locks.isHeld(mailEventsLockName(USER.email))).toBe(false)
expect(locks.isHeld(mailEventsLockName(OTHER_USER.email))).toBe(true)
})
it('토큰 갱신(같은 세션)으로는 다시 잇지 않는다', async () => {
const tab = openTab()
await settle()
tab.auth.markAuthenticated({ ...USER })
await settle()
expect(server.connections).toHaveLength(1)
expect(server.connections[0]?.aborted).toBe(false)
})
})
describe('브라우저마다 연결 하나 (Web Locks · BroadcastChannel)', () => {
it('두 번째 탭은 연결하지 않고 잠금을 기다리며, 첫 탭이 나눈 소식으로 같은 상태가 된다', async () => {
const first = openTab()
await settle()
const second = openTab()
await settle()
expect(server.connections).toHaveLength(1)
expect(locks.waitingCount(mailEventsLockName(USER.email))).toBe(1)
server.last().emit('state', { state: 's1', inboxUnread: 1 })
await settle()
expect(first.realtime.status).toBe('live')
expect(second.realtime.status).toBe('live')
server.last().emit('state', { state: 's2', inboxUnread: 2 })
await settle()
expect(first.realtime.pendingSync).toBe(true)
expect(second.realtime.pendingSync).toBe(true)
expect(second.realtime.inboxUnread).toBe(2)
server.last().emit('ping', { upstream: false })
await settle()
expect(second.realtime.status).toBe('down')
})
it('연결한 탭을 닫으면 다른 탭이 잠금을 받아 이어서 연결한다', async () => {
const first = openTab()
await settle()
const second = openTab()
await settle()
server.last().emit('state', { state: 's1', inboxUnread: 1 })
await settle()
first.scope.stop()
await settle()
expect(server.connections[0]?.aborted).toBe(true)
expect(server.connections).toHaveLength(2)
// 이어받은 탭은 자기 연결이 열릴 때까지 "잇는 중" (앞 탭의 소식으로 정한 상태는 버린다)
expect(second.realtime.status).toBe('connecting')
server.last().emit('state', { state: 's1', inboxUnread: 1 })
await settle()
expect(second.realtime.status).toBe('live')
})
it('연결한 탭이 stop 없이 닫혀도(탭 닫기 · 새로 고침 · 폐기 — 잠금만 넘어간다) 이어받은 탭이 끊김을 나눠 다른 탭이 1분 확인으로', async () => {
const release = holdLockElsewhere()
const closingLeader = rawTab()
const second = openTab()
const third = openTab()
await settle()
closingLeader.post({ type: 'state', state: 's1', inboxUnread: 0 })
await settle()
expect(second.realtime.status).toBe('live')
expect(third.realtime.status).toBe('live')
// 브라우저가 앞 탭을 없앴다 — 채널도 닫히고 아무것도 보내지 않는다
server.reply({ kind: 'hang' })
closingLeader.channel.close()
release()
await settle()
expect(server.connections).toHaveLength(1)
const leader = [second, third].find((tab) => tab.realtime.status === 'connecting')
const follower = [second, third].find((tab) => tab !== leader)
expect(leader).toBeDefined()
expect(follower?.realtime.status).toBe('down')
})
it('연결한 탭이 닫힐 때(pagehide) 끊김을 나눈다', async () => {
const other = rawTab()
openTab()
await settle()
server.last().emit('state', { state: 's1', inboxUnread: 0 })
await settle()
other.received.length = 0
window.dispatchEvent(new Event('pagehide'))
await settle()
expect(other.received).toEqual([{ type: 'down' }])
})
it('새로 연 탭은 hello 로 지금 상태를 받는다 — 마지막 state 를 기준으로 삼는다', async () => {
openTab()
await settle()
server.last().emit('state', { state: 's1', inboxUnread: 4 })
await settle()
const second = openTab()
await settle()
expect(second.realtime.status).toBe('live')
expect(second.realtime.observeState('s1')).toBe(false)
expect(second.realtime.observeState('s2')).toBe(true)
})
it('끊긴 채 hello 에 답할 때는 기준값(baseline)과 끊김만 — 다른 탭이 live 로 깜빡이지 않는다', async () => {
openTab()
await settle()
server.last().emit('state', { state: 's1', inboxUnread: 0 })
await settle()
const second = openTab()
await settle()
server.reply({ kind: 'status', status: 503 }, { kind: 'status', status: 503 })
server.last().fail()
await settle()
expect(second.realtime.status).toBe('down')
second.statuses.length = 0
const third = openTab()
await settle()
expect(second.statuses).toEqual([])
expect(third.realtime.status).toBe('down')
expect(third.statuses).not.toContain('live')
expect(third.realtime.observeState('s1')).toBe(false)
})
it('기다리는 탭은 75초 동안 소식이 없으면 끊김으로 본다 (연결한 탭이 멈췄을 때)', async () => {
const holder = rawTab()
holdLockElsewhere()
const tab = openTab()
await settle()
holder.post({ type: 'state', state: 's1', inboxUnread: 0 })
await settle()
expect(tab.realtime.status).toBe('live')
await tick(MAIL_EVENTS_SILENCE_MS)
expect(tab.realtime.status).toBe('down')
expect(server.connections).toHaveLength(0)
})
it('다른 계정의 소식은 버린다', async () => {
const holder = rawTab()
holdLockElsewhere()
const tab = openTab()
await settle()
holder.post({ type: 'state', state: 's1', inboxUnread: 0 }, OTHER_USER.email)
await settle()
expect(tab.realtime.status).toBe('connecting')
holder.post({ type: 'state', state: 's1', inboxUnread: 0 })
await settle()
expect(tab.realtime.status).toBe('live')
})
it('연결한 탭은 받은 소식을 사용자 주소와 함께 다른 탭에 나눈다 — 연결하기 전에 끊김을 한 번', async () => {
const other = rawTab()
openTab()
await settle()
server.last().emit('state', { state: 's1', inboxUnread: 2 })
server.last().emit('ping', { upstream: true })
await settle()
expect(other.received).toEqual([
{ type: 'hello' },
{ type: 'down' },
{ type: 'state', state: 's1', inboxUnread: 2 },
{ type: 'ping', upstream: true },
])
})
it('Web Locks 가 없는 브라우저는 실시간 없이 1분 확인만 한다', async () => {
uninstallFakeLocks()
const tab = openTab()
await settle()
expect(tab.realtime.status).toBe('unsupported')
expect(server.connections).toHaveLength(0)
})
})
describe('새 메일 · 사용자가 보고 있는 탭 (연결한 탭)', () => {
it('new-mail 을 onNewMail 에 넘긴다 — 사용자가 보고 있는 탭이 있는지 다른 탭에 묻는 길과 함께', async () => {
const onNewMail = vi.fn<(signal: NewMailSignal, context: NewMailContext) => void>()
openTab({ onNewMail })
await settle()
server.last().emit('new-mail', {
count: 1,
messages: [{ id: 'm1', from: { name: '박서준', email: 'seojun.park@arasystem.test' }, subject: '견적서', receivedAt: '' }],
})
await settle()
expect(onNewMail).toHaveBeenCalledTimes(1)
const [signal, context] = onNewMail.mock.calls[0] ?? []
expect(signal).toEqual({
count: 1,
messages: [{ id: 'm1', from: { name: '박서준', email: 'seojun.park@arasystem.test' }, subject: '견적서', receivedAt: '' }],
})
expect(context?.isUserViewing()).toBe(false)
})
it('보고 있는 탭 묻기 — 그 탭이 답하면 true, 답이 없으면 기다린 뒤 false', async () => {
const contexts: NewMailContext[] = []
openTab({ onNewMail: (_signal, context) => contexts.push(context) })
await settle()
server.last().emit('new-mail', { count: 1, messages: [{ id: 'm1' }] })
await settle()
const context = contexts[0]
if (!context) throw new Error('새 메일을 받지 못했다')
// 답이 없다 — 기다린 뒤 false
const silent = context.askOtherTabsViewing()
let answered: boolean | null = null
void silent.then((value) => {
answered = value
})
await tick(MAIL_EVENTS_VISIBILITY_WAIT_MS - 1)
expect(answered).toBeNull()
await tick(1)
expect(answered).toBe(false)
// 보고 있는 탭이 답한다
const viewingTab = rawTab()
viewingTab.channel.addEventListener('message', (event) => {
const message = unpackRealtimeMessage((event as MessageEvent<unknown>).data, USER.email)
if (message?.type === 'visibility-query') viewingTab.post({ type: 'visibility-reply', id: message.id })
})
const asked = context.askOtherTabsViewing()
await settle()
await expect(asked).resolves.toBe(true)
})
it('기다리는 탭은 사용자가 보고 있을 때만 묻는 말에 답한다', async () => {
let viewing = false
const holder = rawTab()
holdLockElsewhere()
openTab({ isUserViewing: () => viewing })
await settle()
holder.post({ type: 'visibility-query', id: 'q1' })
await settle()
expect(holder.received.filter((message) => message.type === 'visibility-reply')).toEqual([])
viewing = true
holder.post({ type: 'visibility-query', id: 'q2' })
await settle()
expect(holder.received.filter((message) => message.type === 'visibility-reply')).toEqual([
{ type: 'visibility-reply', id: 'q2' },
])
})
it('"보고 있음"은 보이고 초점이 있을 때 — 두 번째 모니터에 띄워 두고 다른 창에서 일하면 보고 있지 않다', () => {
let visibility: DocumentVisibilityState = 'visible'
Object.defineProperty(document, 'visibilityState', { configurable: true, get: () => visibility })
const hasFocus = vi.spyOn(document, 'hasFocus')
try {
hasFocus.mockReturnValue(true)
expect(isUserViewingPage()).toBe(true)
hasFocus.mockReturnValue(false)
expect(isUserViewingPage()).toBe(false)
visibility = 'hidden'
hasFocus.mockReturnValue(true)
expect(isUserViewingPage()).toBe(false)
} finally {
Reflect.deleteProperty(document, 'visibilityState')
}
})
it('사용자가 연결한 탭으로 돌아오면(초점) onUserReturn — 띄운 바탕화면 알림을 닫는다', async () => {
const onUserReturn = vi.fn()
let viewing = false
openTab({ onUserReturn, isUserViewing: () => viewing })
await settle()
window.dispatchEvent(new Event('focus'))
expect(onUserReturn).not.toHaveBeenCalled()
viewing = true
window.dispatchEvent(new Event('focus'))
expect(onUserReturn).toHaveBeenCalledTimes(1)
})
it('다른 탭으로 돌아오면 그 탭이 attended 를 보내고 연결한 탭이 onUserReturn', async () => {
const onUserReturn = vi.fn()
const other = rawTab()
openTab({ onUserReturn })
await settle()
other.post({ type: 'attended' })
await settle()
expect(onUserReturn).toHaveBeenCalledTimes(1)
})
it('기다리는 탭으로 돌아오면 연결한 탭에 attended 를 보낸다', async () => {
const holder = rawTab()
holdLockElsewhere()
openTab({ isUserViewing: () => true })
await settle()
document.dispatchEvent(new Event('visibilitychange'))
await settle()
expect(holder.received.filter((message) => message.type === 'attended')).toHaveLength(1)
})
})
})
+545
View File
@@ -0,0 +1,545 @@
import { onScopeDispose, watch } from 'vue'
import { buildApiUrl, isSessionEnd, refreshSession } from './useApi'
import { systemClock, type Clock, type ClockTimer } from './useClock'
import {
MAIL_EVENTS_CHANNEL_NAME,
MAIL_EVENTS_CONTENT_TYPE,
MAIL_EVENTS_END_TOKEN_EXPIRING,
MAIL_EVENTS_PATH,
MAIL_EVENTS_SHUTDOWN_SPREAD_MS,
MAIL_EVENTS_SILENCE_MS,
MAIL_EVENTS_STABLE_MS,
MAIL_EVENTS_VISIBILITY_WAIT_MS,
} from '@/shared/constants/mail-notify'
import { supportsMailRealtime } from '@/shared/utils/browser-support'
import { parseMailEvent, type MailServerEvent, type NewMailSignal } from '@/shared/utils/mail-events'
import {
mailEventsLockName,
packRealtimeMessage,
reconnectDelayMs,
unpackRealtimeMessage,
type RealtimeMessage,
} from '@/shared/utils/mail-realtime'
import { createSseParser } from '@/shared/utils/sse-parser'
import { useAuthStore } from '@/stores/auth.store'
import { useMailRealtimeStore, type MailRealtimeStatus } from '@/stores/mail-realtime.store'
// 실시간 새 메일 — 계약 2절 "실시간 알림 (GET /api/mail-events)" 의 클라이언트. App.vue 가 한 번 부르고, 로그인한 동안 앱 전체에서 돈다
// (설정 등 다른 화면에서도 바탕화면 알림 · 연결 상태가 이어지게 — 메일 화면이 할 일은 useMailStatePolling 이 이 소식을 받아서 한다).
//
// 브라우저마다 연결 하나 — 탭마다 열면 HTTP/1.1 의 주소당 연결 6개를 금방 다 써서 다른 요청이 멈춘다.
// - 잠금(navigator.locks — 이름에 사용자 주소)을 받은 탭 하나만 연결한다. 그 탭을 닫으면 잠금이 풀려 다른 탭이 이어받는다
// - 연결한 탭은 받은 소식(state · ping · 끊김)을 BroadcastChannel 로 다른 탭에 나눈다. 메시지에 사용자 주소를 넣고 다른 계정의 것은 버린다
// - 모든 탭(연결한 탭 포함)이 같은 규칙을 탄다: 75초 안에 ping · state 가 오고 upstream 이 true 면 live(1분 확인을 멈춘다), 아니면 down.
// state 는 마지막으로 본 값과 비교해 바뀌었으면 메일 화면이 맞출 일로 남긴다 (stores/mail-realtime.store.ts)
// - 잠금을 받은 탭은 연결하기 전에 down 을 한 번 나눈다 — 앞 탭이 stop 없이 닫혔어도(탭 닫기 · 새로 고침 · 폐기) 다른 탭이 1분 확인으로
// 돌아가게. 연결한 탭이 닫힐 때(pagehide)도 나눈다
// - 새로 연 탭은 hello 를 보내고, 연결한 탭은 지금 상태를 다시 보낸다 — 끊긴 채면 살아 있다는 신호로 세지 않는 기준값(baseline)과 끊김만
// - Web Locks · BroadcastChannel · fetch 스트림 가운데 하나라도 없으면 실시간 없이 지금의 1분 확인만 한다 (status: unsupported)
//
// 연결 (연결한 탭)
// - fetch 로 읽는다 — 브라우저의 EventSource 는 401 을 알 수 없어 세션을 갱신할 수 없다. 같은 주소라 쿠키가 함께 간다
// - 401 → 세션을 한 번 갱신하고(refreshSession) 바로 다시 잇는다. 그 다시 잇기(바로 다음 시도)도 401 이면 세션 만료 — axios 의 401 재시도와 같다.
// 다음 시도가 401 이 아닌 까닭으로 끝나면 "갱신 직후"는 지운다(나중의 401 은 다시 갱신해 본다).
// 갱신이 세션 끝(401)으로 실패해도 세션 만료 흐름(대화상자). 갱신이 장애(5xx · 네트워크)로 실패하면 아래처럼 기다렸다 다시
// - 그 밖의 실패(연결 안 됨 · 5xx · 429 · 스트림이 끊김 · 읽기 오류 · text/event-stream 이 아님 · 너무 긴 줄) → 2 · 5 · 15 · 30 · 60초(±20%) 뒤 다시.
// 연결이 자리 잡으면(첫 state 뒤 정상 ping · 20초) 처음 간격부터 다시 센다 — 열리자마자 끊기는 연결은 간격이 계속 늘어난다
// - end — 자리 잡은 연결이면 다시 잇는다: token-expiring 은 세션을 갱신한 뒤 바로, 그 밖(서버 종료 · 재시작)은 0~3초 흔들어서.
// 자리 잡기 전에 온 end 는 까닭과 상관없이 실패처럼 기다린다 — 세션 끝 무렵 서버가 state 뒤 바로 end(token-expiring) 해도
// 갱신 · 연결을 왕복 시간 속도로 되풀이해 갱신 한도를 다 쓰지 않게 (2026-10-28 리뷰)
// - 75초 동안 아무것도 오지 않으면(머리글을 기다리는 동안 포함) 끊긴 것으로 보고 연결을 끊은 뒤 위처럼 다시 잇는다
// - new-mail 은 연결한 탭만 받는다 — onNewMail 이 바탕화면 알림을 정한다(사용자가 보고 있는 탭이 있는지 다른 탭에 묻는다)
// - 사용자가 어느 웹메일 탭이든 보게 되면(보이고 초점이 있음) 연결한 탭에서 onUserReturn — 띄운 바탕화면 알림을 닫는다
//
// 로그아웃 · 세션 만료 · 다른 계정 로그인(authStore.generation) 때 잠금을 놓고 연결을 닫는다.
export interface NewMailContext {
/** 사용자가 이 탭을 보고 있는지 — 보이고(visibilityState) 초점이 있을 때(hasFocus) */
isUserViewing: () => boolean
/** 다른 탭 가운데 사용자가 보고 있는 탭이 있는지 — 그 탭의 답을 잠깐(MAIL_EVENTS_VISIBILITY_WAIT_MS) 기다린다 */
askOtherTabsViewing: () => Promise<boolean>
}
export type EventStreamFetch = (input: string, init: RequestInit) => Promise<Response>
export interface MailRealtimeOptions {
/** 연결한 탭이 새 메일을 받았을 때 — 바탕화면 알림을 정한다 (useNewMailNotifier). 결과(Promise 포함)는 기다리지 않는다 */
onNewMail?: (signal: NewMailSignal, context: NewMailContext) => unknown
/** 사용자가 웹메일 탭(어느 탭이든)을 보게 됐다 — 연결한 탭에서 부른다 (띄운 바탕화면 알림을 닫는다) */
onUserReturn?: () => void
/** 테스트는 손으로 돌리는 시계를 넘긴다 (test-utils/manual-clock.ts) */
clock?: Clock
/** 다시 잇는 간격의 흔들림 (0~1) — 테스트는 고정값을 넘긴다 */
random?: () => number
/** 테스트는 가짜 서버를 넘긴다 (test-utils/event-stream.ts) */
fetch?: EventStreamFetch
/** 사용자가 이 탭을 보고 있는지 — 기본은 isUserViewingPage */
isUserViewing?: () => boolean
}
/**
* 한 연결의 끝 — 401 · 실패 · 서버가 알린 끝(end).
* established: 자리 잡았다(첫 state 뒤 정상 ping 을 받았거나 MAIL_EVENTS_STABLE_MS 동안 이어졌다) — 다시 잇는 간격을 처음부터 센다
*/
type StreamOutcome =
| { kind: 'unauthorized' }
| { kind: 'failed'; established: boolean }
| { kind: 'ended'; established: boolean; reason: string }
/** 세션 갱신의 결과 — 이어서 잇는다 · 세션이 끝났다 · 장애(기다렸다 다시) */
type RenewOutcome = 'renewed' | 'ended' | 'failed'
interface RealtimeSession {
stop: () => void
}
type SignalMessage = Extract<RealtimeMessage, { type: 'ping' | 'state' | 'down' }>
const defaultFetch: EventStreamFetch = (input, init) => fetch(input, init)
/**
* 사용자가 이 탭을 보고 있는지 — 보이고(visibilityState) 초점이 있을 때(hasFocus).
* 두 번째 모니터에 웹메일을 띄워 두고 다른 창에서 일하면 "보고 있지 않음"이다 — 그때도 바탕화면 알림이 온다
*/
export function isUserViewingPage(): boolean {
if (typeof document === 'undefined') return false
return document.visibilityState === 'visible' && document.hasFocus()
}
/** 서버가 돌려준 응답이 SSE 인지 — 프록시가 index.html(200)을 돌려준 경우 등을 거른다 */
function isEventStream(response: Response): boolean {
return (response.headers.get('Content-Type') ?? '').toLowerCase().startsWith(MAIL_EVENTS_CONTENT_TYPE)
}
/** 읽지 않을 응답 본문을 닫는다 — 연결을 붙잡지 않게 */
function discardBody(response: Response): void {
void response.body?.cancel().catch(() => undefined)
}
export function useMailRealtime(options: MailRealtimeOptions = {}): void {
const authStore = useAuthStore()
const realtimeStore = useMailRealtimeStore()
const clock = options.clock ?? systemClock
const random = options.random ?? Math.random
const fetchStream = options.fetch ?? defaultFetch
const isUserViewing = options.isUserViewing ?? isUserViewingPage
let session: RealtimeSession | null = null
function startSession(email: string, generation: number): RealtimeSession {
const channel = new BroadcastChannel(MAIL_EVENTS_CHANNEL_NAME)
const lockRequest = new AbortController()
/** 탭마다 다른 앞머리 — 보고 있는 탭 묻기의 답을 가려낸다 */
const queryPrefix = random().toString(36).slice(2)
const pendingQueries = new Map<string, (viewing: boolean) => void>()
let querySeq = 0
let stopped = false
let releaseLock: (() => void) | null = null
let leader: { stop: () => void; replyHello: () => void } | null = null
// ── 연결 상태 (모든 탭 같은 규칙) ─────────────────────
let lastSignalAt: number | null = null
let upstreamOk = true
let everDown = false
let silenceTimer: ClockTimer | null = null
function publishStatus() {
let next: MailRealtimeStatus
if (lastSignalAt === null) next = everDown ? 'down' : 'connecting'
else next = upstreamOk ? 'live' : 'down'
realtimeStore.setStatus(next)
}
/** 75초 안에 다음 신호가 없으면 끊긴 것으로 본다 (처음 잇는 중이어도 같다) */
function armSilence() {
if (silenceTimer !== null) clock.clearTimeout(silenceTimer)
silenceTimer = clock.setTimeout(() => {
silenceTimer = null
lastSignalAt = null
everDown = true
publishStatus()
}, MAIL_EVENTS_SILENCE_MS)
}
/** ping · state · 끊김을 이 탭에 적는다 — 연결한 탭은 자기 소식, 다른 탭은 채널로 받은 소식 */
function applySignal(message: SignalMessage) {
if (message.type === 'down') {
lastSignalAt = null
everDown = true
if (silenceTimer !== null) clock.clearTimeout(silenceTimer)
silenceTimer = null
publishStatus()
return
}
lastSignalAt = clock.now()
// state 는 서버가 엔진의 바뀜을 받았다는 뜻이다 — 서버 · 엔진 사이가 이어져 있다
upstreamOk = message.type === 'state' ? true : message.upstream
armSilence()
publishStatus()
if (message.type === 'state') realtimeStore.observeState(message.state, message.inboxUnread)
}
function post(message: RealtimeMessage) {
if (stopped) return
try {
channel.postMessage(packRealtimeMessage(email, message))
} catch {
// 닫힌 채널 — 나누지 못해도 이 탭은 그대로 간다
}
}
/** 연결한 탭의 소식 — 이 탭에 적고 다른 탭에 나눈다 */
function share(message: SignalMessage) {
applySignal(message)
post(message)
}
// ── 사용자가 보고 있는 탭 묻기 (바탕화면 알림 전에) ─────────────
function askOtherTabsViewing(): Promise<boolean> {
if (stopped) return Promise.resolve(false)
querySeq += 1
const id = `${queryPrefix}-${querySeq}`
return new Promise<boolean>((resolve) => {
let timer: ClockTimer | null = null
const settle = (viewing: boolean) => {
if (!pendingQueries.delete(id)) return
if (timer !== null) clock.clearTimeout(timer)
resolve(viewing)
}
pendingQueries.set(id, settle)
timer = clock.setTimeout(() => settle(false), MAIL_EVENTS_VISIBILITY_WAIT_MS)
post({ type: 'visibility-query', id })
})
}
const newMailContext: NewMailContext = { isUserViewing, askOtherTabsViewing }
/** 사용자가 이 탭을 보게 됐다 — 연결한 탭이면 바로, 아니면 연결한 탭에 알린다 (띄운 바탕화면 알림을 닫는다) */
function onUserMaybeReturned() {
if (stopped || !isUserViewing()) return
if (leader) options.onUserReturn?.()
else post({ type: 'attended' })
}
/** 연결한 탭이 닫힌다 — 다른 탭이 1분 확인으로 돌아가게 (잠금은 브라우저가 풀고, 다른 탭이 이어받는다) */
function onPageHide() {
if (leader) post({ type: 'down' })
}
function onChannelMessage(event: MessageEvent<unknown>) {
if (stopped) return
const message = unpackRealtimeMessage(event.data, email)
if (!message) return
switch (message.type) {
case 'ping':
case 'state':
case 'down':
// 연결한 탭은 자기 연결의 소식만 믿는다 (잠금을 막 넘긴 탭의 늦은 끊김 등은 버린다)
if (!leader) applySignal(message)
break
case 'baseline':
// 살아 있다는 신호가 아니다 — 기준값으로만 쓴다
if (!leader) realtimeStore.observeState(message.state, message.inboxUnread)
break
case 'hello':
leader?.replyHello()
break
case 'visibility-query':
if (isUserViewing()) post({ type: 'visibility-reply', id: message.id })
break
case 'visibility-reply':
pendingQueries.get(message.id)?.(true)
break
case 'attended':
if (leader) options.onUserReturn?.()
break
}
}
// ── 연결한 탭 ────────────────────────────────────
function startLeader(): { stop: () => void; replyHello: () => void } {
let leaderStopped = false
let abortStream: (() => void) | null = null
let wake: (() => void) | null = null
let lastState: { state: string; inboxUnread: number | null } | null = null
/** 자리 잡은 연결 뒤로 이어서 실패한 횟수 — 다시 잇는 간격을 정한다 */
let attempt = 0
const active = () => !leaderStopped && !stopped
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => {
const timer = clock.setTimeout(() => {
wake = null
resolve()
}, ms)
wake = () => {
clock.clearTimeout(timer)
wake = null
resolve()
}
})
}
/** 세션 만료 흐름 — 갱신 요청의 401 은 인터셉터가 이미 만료로 다뤘다. 여기서는 보장만 한다 (confirmSession 과 같다) */
function expire() {
if (authStore.generation === generation) authStore.expireSession()
}
async function renew(): Promise<RenewOutcome> {
const outcome = await refreshSession()
if (outcome.ok) return 'renewed'
if (isSessionEnd(outcome.error.code)) {
expire()
return 'ended'
}
return 'failed'
}
function handleServerEvent(event: Exclude<MailServerEvent, { type: 'end' }>) {
switch (event.type) {
case 'state':
lastState = { state: event.state, inboxUnread: event.inboxUnread }
share({ type: 'state', state: event.state, inboxUnread: event.inboxUnread })
break
case 'ping':
share({ type: 'ping', upstream: event.upstream })
break
case 'new-mail':
// 알림을 정하는 사이에도 계속 읽는다 (다른 탭의 답을 잠깐 기다린다) — 실패해도 연결과는 상관없다
void Promise.resolve()
.then(() => options.onNewMail?.({ count: event.count, messages: event.messages }, newMailContext))
.catch(() => undefined)
break
}
}
async function readStream(): Promise<StreamOutcome> {
const request = new AbortController()
abortStream = () => request.abort()
let watchdog: ClockTimer | null = null
/** 첫 state 를 받은 때 */
let openedAt: number | null = null
/** 정상 ping(upstream: true)을 받았다 — 연결 직후의 upstream: false 알림은 세지 않는다 */
let sawHealthyPing = false
const established = () =>
sawHealthyPing || (openedAt !== null && clock.now() - openedAt >= MAIL_EVENTS_STABLE_MS)
// 머리글을 기다리는 동안 · 읽는 동안 모두 75초 안에 무엇이든 와야 한다 — 아니면 끊고 다시 잇는다
const armWatchdog = () => {
if (watchdog !== null) clock.clearTimeout(watchdog)
watchdog = clock.setTimeout(() => request.abort(), MAIL_EVENTS_SILENCE_MS)
}
try {
armWatchdog()
const response = await fetchStream(buildApiUrl(MAIL_EVENTS_PATH), {
method: 'GET',
headers: { Accept: MAIL_EVENTS_CONTENT_TYPE },
cache: 'no-store',
credentials: 'same-origin',
signal: request.signal,
})
if (response.status === 401) {
discardBody(response)
return { kind: 'unauthorized' }
}
if (!response.ok || !response.body || !isEventStream(response)) {
discardBody(response)
return { kind: 'failed', established: false }
}
const reader = response.body.getReader()
const decoder = new TextDecoder()
const parser = createSseParser()
for (;;) {
const chunk = await reader.read()
if (chunk.done || !active()) break
for (const raw of parser.push(decoder.decode(chunk.value, { stream: true }))) {
armWatchdog()
const event = parseMailEvent(raw)
if (!event) continue
if (event.type === 'end') return { kind: 'ended', established: established(), reason: event.reason }
if (event.type === 'state' && openedAt === null) openedAt = clock.now()
if (event.type === 'ping' && event.upstream) sawHealthyPing = true
handleServerEvent(event)
if (!active()) break
}
}
return { kind: 'failed', established: established() }
} catch {
// 네트워크 · 읽기 오류(서버가 중간에 죽음) · 너무 긴 줄 · 그만둠(75초 · 로그아웃) — 아래 run 이 가른다
return { kind: 'failed', established: established() }
} finally {
if (watchdog !== null) clock.clearTimeout(watchdog)
request.abort()
abortStream = null
}
}
/** 401 · token-expiring 뒤 — 세션을 갱신한다. 이어서 잇을지(now) · 그만둘지(stop) · 기다렸다 잇을지(backoff) */
async function renewThen(): Promise<'now' | 'stop' | 'backoff'> {
const renewed = await renew()
if (!active() || renewed === 'ended') return 'stop'
return renewed === 'renewed' ? 'now' : 'backoff'
}
async function run(): Promise<void> {
/** 이번 시도가 세션을 갱신한 바로 뒤의 다시 잇기다 — 이번에도 401 이면 세션 만료. 다른 까닭으로 끝나면 지운다 */
let retryingAfterRenew = false
while (active()) {
const outcome = await readStream()
if (!active()) return
const afterRenew = retryingAfterRenew
retryingAfterRenew = false
if (outcome.kind !== 'unauthorized' && outcome.established) attempt = 0
let next: 'now' | 'spread' | 'backoff' | 'stop' = 'backoff'
if (outcome.kind === 'unauthorized') {
if (afterRenew) {
share({ type: 'down' })
expire()
return
}
next = await renewThen()
} else if (outcome.kind === 'ended' && outcome.established) {
next = outcome.reason === MAIL_EVENTS_END_TOKEN_EXPIRING ? await renewThen() : 'spread'
}
if (next === 'stop') return
if (next === 'now') {
retryingAfterRenew = true
continue
}
if (next === 'spread') {
// 서버가 곧 닫는다고 알렸다(종료 · 재시작) — 끊김으로 알리지 않고, 모든 브라우저가 같은 순간에 붙지 않게 조금 흔들어 다시 잇는다
await sleep(Math.round(Math.min(1, Math.max(0, random())) * MAIL_EVENTS_SHUTDOWN_SPREAD_MS))
continue
}
share({ type: 'down' })
await sleep(reconnectDelayMs(attempt, random()))
attempt += 1
}
}
void run()
return {
stop: () => {
leaderStopped = true
abortStream?.()
wake?.()
},
/**
* 새로 연 탭에게 지금 상태를 다시 보낸다 — 살아 있으면 마지막 state(신호), 끊긴 채면 기준값(baseline)과 끊김만
* (끊긴 채 state 를 보내면 다른 탭이 잠깐 live 가 되었다가 down 으로 돌아가며 1분 확인을 한 번 더 한다)
*/
replyHello: () => {
const status = realtimeStore.status
if (status === 'live') {
post(lastState ? { type: 'state', ...lastState } : { type: 'ping', upstream: true })
return
}
if (lastState) post({ type: 'baseline', ...lastState })
if (status === 'down') post({ type: 'down' })
},
}
}
function stop() {
if (stopped) return
// 연결을 맡던 탭이면 다른 탭이 1분 확인으로 돌아가게 알리고 놓는다 (다른 탭이 잠금을 받아 이어서 연결한다)
if (leader) post({ type: 'down' })
stopped = true
lockRequest.abort()
leader?.stop()
leader = null
releaseLock?.()
releaseLock = null
if (silenceTimer !== null) clock.clearTimeout(silenceTimer)
silenceTimer = null
pendingQueries.forEach((settle) => settle(false))
channel.removeEventListener('message', onChannelMessage)
channel.close()
if (typeof window !== 'undefined') {
window.removeEventListener('pagehide', onPageHide)
window.removeEventListener('focus', onUserMaybeReturned)
}
if (typeof document !== 'undefined') document.removeEventListener('visibilitychange', onUserMaybeReturned)
}
/** 잠금을 쓸 수 없는 환경(보안 제한 등) — 실시간 없이 1분 확인만 한다 */
function giveUp() {
stop()
realtimeStore.setStatus('unsupported')
}
channel.addEventListener('message', onChannelMessage)
if (typeof window !== 'undefined') {
window.addEventListener('pagehide', onPageHide)
window.addEventListener('focus', onUserMaybeReturned)
}
if (typeof document !== 'undefined') document.addEventListener('visibilitychange', onUserMaybeReturned)
publishStatus()
armSilence()
post({ type: 'hello' })
try {
navigator.locks
.request(mailEventsLockName(email), { signal: lockRequest.signal }, () =>
new Promise<void>((resolve) => {
// 잠금을 받은 동안 이 탭이 연결한다 — 돌려준 Promise 가 끝나면(stop) 잠금이 풀린다
if (stopped) {
resolve()
return
}
releaseLock = resolve
// 이제 이 탭의 연결이 기준이다 — 앞 탭의 소식으로 정한 상태는 버리고, 이 연결이 열릴 때까지 "잇는 중".
// 다른 탭에는 끊김을 알린다 — 앞 탭이 stop 없이 닫혔으면(탭 닫기 · 새로 고침 · 폐기) 다른 탭은 아직 live 로 알고 있다
lastSignalAt = null
everDown = false
armSilence()
publishStatus()
post({ type: 'down' })
leader = startLeader()
}),
)
.catch(() => {
// 기다리다 그만둔 요청(AbortError)이면 할 일이 없다. 그 밖의 거절은 잠금을 쓸 수 없는 것이다
if (!stopped) giveUp()
})
} catch {
giveUp()
}
return { stop }
}
function restart() {
session?.stop()
session = null
const email = authStore.user?.email
if (!email) {
realtimeStore.setStatus('off')
return
}
if (!supportsMailRealtime()) {
realtimeStore.setStatus('unsupported')
return
}
try {
session = startSession(email, authStore.generation)
} catch {
// 채널을 만들지 못했다 — 실시간 없이 1분 확인만 한다
realtimeStore.setStatus('unsupported')
}
}
// 로그인 · 세션 복원으로 시작하고, 로그아웃 · 세션 만료 · 다른 계정 로그인 때 그만둔다(새 세션이면 다시 시작).
// 토큰 갱신(같은 세션)으로는 다시 시작하지 않는다 — 세대와 주소가 그대로다 (값마다 비교하도록 배열 소스로 둔다)
watch([() => authStore.generation, () => authStore.user?.email ?? null], restart, { immediate: true })
onScopeDispose(() => {
session?.stop()
session = null
realtimeStore.setStatus('off')
})
}
@@ -4,7 +4,9 @@ import { effectScope, type EffectScope } from 'vue'
import { useMailStatePolling } from '@/composables/useMailStatePolling'
import { MAIL_STATE_POLL_INTERVAL_MS, MAIL_STATE_RECHECK_GAP_MS } from '@/shared/constants/mail'
import { MAIL_REALTIME_SYNC_DELAY_MS } from '@/shared/constants/mail-notify'
import { useAuthStore } from '@/stores/auth.store'
import { useMailRealtimeStore } from '@/stores/mail-realtime.store'
import { useToastStore } from '@/stores/toast.store'
import { createManualClock, type ManualClock } from '@/test-utils/manual-clock'
import {
@@ -17,7 +19,7 @@ import {
type MockResult,
} from '@/test-utils/mock-api'
// 새 메일 확인 (계약 2절 "새 메일 확인") — 손으로 돌리는 시계와 화면 보임 상태로 확인한다 (고정 대기 없이)
// 새 메일 확인 (계약 2절 "새 메일 확인" · "실시간 알림") — 손으로 돌리는 시계와 화면 보임 상태로 확인한다 (고정 대기 없이)
describe('useMailStatePolling (새 메일 확인)', () => {
let scope: EffectScope
@@ -26,6 +28,7 @@ describe('useMailStatePolling (새 메일 확인)', () => {
let states: MockResult[]
let requests: MockRequest[]
const onChange = vi.fn()
const onHiddenChange = vi.fn()
beforeEach(() => {
setActivePinia(createPinia())
@@ -35,6 +38,7 @@ describe('useMailStatePolling (새 메일 확인)', () => {
visibility = 'visible'
Object.defineProperty(document, 'visibilityState', { configurable: true, get: () => visibility })
onChange.mockReset()
onHiddenChange.mockReset()
states = []
requests = installMockApi(() => states.shift() ?? ok({ state: 'same' }))
})
@@ -47,7 +51,7 @@ describe('useMailStatePolling (새 메일 확인)', () => {
})
function start() {
scope.run(() => useMailStatePolling({ onChange, clock }))
scope.run(() => useMailStatePolling({ onChange, onHiddenChange, clock }))
}
function setVisibility(next: DocumentVisibilityState) {
@@ -169,4 +173,151 @@ describe('useMailStatePolling (새 메일 확인)', () => {
expect(asked()).toBe(1)
expect(clock.pendingCount()).toBe(0)
})
describe('실시간 연결과 함께 (계약 2절 "실시간 알림")', () => {
it('실시간 연결이 살아 있는 동안은 묻지 않는다 — 끊기면 바로 한 번 묻고 다시 1분마다, 살아나면 멈춘다', async () => {
const realtime = useMailRealtimeStore()
realtime.setStatus('live')
start()
await tick(MAIL_STATE_POLL_INTERVAL_MS * 3)
expect(asked()).toBe(0)
realtime.setStatus('down')
await flushPromises()
expect(asked()).toBe(1)
await tick(MAIL_STATE_POLL_INTERVAL_MS)
expect(asked()).toBe(2)
realtime.setStatus('live')
await flushPromises()
await tick(MAIL_STATE_POLL_INTERVAL_MS * 3)
expect(asked()).toBe(2)
expect(clock.pendingCount()).toBe(0)
})
it('살아 있는 동안 탭으로 돌아와도 묻지 않는다 (10초 규칙은 1분 확인을 할 때만)', async () => {
useMailRealtimeStore().setStatus('live')
start()
setVisibility('hidden')
await tick(MAIL_STATE_RECHECK_GAP_MS * 6)
setVisibility('visible')
await flushPromises()
expect(asked()).toBe(0)
})
it('실시간 소식으로 바뀌면(보이는 탭) 1초 모았다가 맞춘다 — 1분 확인과 같은 기준이라 같은 값은 다시 맞추지 않는다', async () => {
const realtime = useMailRealtimeStore()
realtime.setStatus('live')
start()
realtime.observeState('s1', 0)
await flushPromises()
expect(onChange).not.toHaveBeenCalled()
realtime.observeState('s2', 1)
await flushPromises()
await tick(MAIL_REALTIME_SYNC_DELAY_MS - 1)
expect(onChange).not.toHaveBeenCalled()
await tick(1)
expect(onChange).toHaveBeenCalledTimes(1)
expect(onHiddenChange).not.toHaveBeenCalled()
// 연결이 끊긴 뒤의 1분 확인이 같은 값을 받으면 바뀐 것이 아니다
states = [ok({ state: 's2' })]
realtime.setStatus('down')
await flushPromises()
expect(asked()).toBe(1)
expect(onChange).toHaveBeenCalledTimes(1)
})
it('모으는 사이 온 바뀜은 함께 한 번에 맞춘다 (내가 여러 통을 읽어 state 가 이어서 바뀌어도)', async () => {
const realtime = useMailRealtimeStore()
realtime.setStatus('live')
start()
realtime.observeState('s1', 0)
// 첫 바뀜부터 1초 — 그사이 바뀜은 함께 (다시 세지 않는다)
realtime.observeState('s2', 1)
await flushPromises()
await tick(300)
realtime.observeState('s3', 0)
await flushPromises()
await tick(300)
realtime.observeState('s4', 1)
await flushPromises()
await tick(MAIL_REALTIME_SYNC_DELAY_MS - 600 - 1)
expect(onChange).not.toHaveBeenCalled()
await tick(1)
expect(onChange).toHaveBeenCalledTimes(1)
await tick(MAIL_REALTIME_SYNC_DELAY_MS * 3)
expect(onChange).toHaveBeenCalledTimes(1)
expect(clock.pendingCount()).toBe(0)
})
it('모으는 동안 숨으면 맞추지 않고 다시 보일 때 바로 맞춘다', async () => {
const realtime = useMailRealtimeStore()
realtime.setStatus('live')
start()
realtime.observeState('s1', 0)
realtime.observeState('s2', 1)
await flushPromises()
setVisibility('hidden')
await tick(MAIL_REALTIME_SYNC_DELAY_MS)
expect(onChange).not.toHaveBeenCalled()
setVisibility('visible')
await flushPromises()
expect(onChange).toHaveBeenCalledTimes(1)
})
it('숨은 탭 — 받은편지함의 안 읽은 수만 넘기고(탭 제목), 다시 보일 때 한 번 맞춘다', async () => {
const realtime = useMailRealtimeStore()
realtime.setStatus('live')
start()
realtime.observeState('s1', 2)
setVisibility('hidden')
realtime.observeState('s2', 3)
await flushPromises()
realtime.observeState('s3', 4)
await flushPromises()
expect(onHiddenChange.mock.calls).toEqual([[3], [4]])
expect(onChange).not.toHaveBeenCalled()
setVisibility('visible')
await flushPromises()
expect(onChange).toHaveBeenCalledTimes(1)
setVisibility('hidden')
setVisibility('visible')
await flushPromises()
expect(onChange).toHaveBeenCalledTimes(1)
})
it('메일 화면 밖(설정 등)에 있는 동안 생긴 바뀜은 메일 화면이 생길 때 맞춘다', async () => {
const realtime = useMailRealtimeStore()
realtime.setStatus('live')
realtime.observeState('s1', 0)
realtime.observeState('s2', 1)
start()
await flushPromises()
expect(onChange).toHaveBeenCalledTimes(1)
expect(asked()).toBe(0)
})
it('1분 확인이 본 바뀜도 같은 길로 맞춘다 · 세션이 바뀌면 지난 기준을 버린다', async () => {
states = [ok({ state: 's1' }), ok({ state: 's2' }), ok({ state: 'other-user' })]
start()
await flushPromises()
await tick(MAIL_STATE_POLL_INTERVAL_MS)
expect(onChange).toHaveBeenCalledTimes(1)
useAuthStore().startSession({ email: 'doyun.lee@arasystem.test', name: '이도윤' })
await flushPromises()
expect(asked()).toBe(3)
expect(onChange).toHaveBeenCalledTimes(1)
})
})
})
+83 -19
View File
@@ -3,19 +3,32 @@ import { onScopeDispose, watch } from 'vue'
import { systemClock, type Clock, type ClockTimer } from './useClock'
import { useMail } from './useMail'
import { MAIL_STATE_POLL_INTERVAL_MS, MAIL_STATE_RECHECK_GAP_MS } from '@/shared/constants/mail'
import { MAIL_REALTIME_SYNC_DELAY_MS } from '@/shared/constants/mail-notify'
import { useAuthStore } from '@/stores/auth.store'
import { useMailRealtimeStore } from '@/stores/mail-realtime.store'
// 새 메일 확인 (계약 2절 "새 메일 확인" · 디자인 README "3단계 › 새 메일")
// - 로그인한 동안, 화면이 보이는 동안(visibilityState === 'visible')만 60초마다 GET /mail-state 를 묻는다
// - 탭이 다시 보이면 마지막으로 물은 지 10초가 지났을 때 바로 한 번 (안 지났으면 원래 차례에)
// - 한 번에 하나씩 — 묻는 중이면 새로 묻지 않는다
// - 처음 받은 값은 기준으로만 쓰고, 값이 바뀌면 onChange 를 부른다 (메일함 개수 · 보이는 목록 맞추기는 부르는 쪽이)
// - 실패(MAIL_001 · 네트워크)는 알리지 않고(silent) 다음 차례에 다시 묻는다. 401 은 지금의 세션 흐름(갱신 1회 → 만료 대화상자)을 탄다
// 실시간 푸시가 아니라 1분 확인이다 — 모자라면 백엔드가 SSE 로 중계하는 방식으로 바꾼다 (계약 같은 절)
// 메일 화면이 새 메일 · 다른 창의 변화를 알아채고 맞춘다
// (계약 2절 "새 메일 확인" · "실시간 알림" · 디자인 README "3단계 › 새 메일" · "5단계 › 실시간 반영")
// 메일 화면(MailLayout)이 있는 동안만 돈다. 바뀜은 두 곳에서 오고, 둘 다 같은 기준(마지막으로 본 state — mail-realtime.store)으로 비교한다:
// 1) 실시간 소식 — 로그인한 동안 앱 전체에서 도는 연결(useMailRealtime)의 state. 이 탭이 연결했든 다른 탭이 연결했든 같다
// 2) 1분 확인(GET /mail-state) — 실시간 연결이 살아 있지 않을 때만
// - 로그인한 동안, 화면이 보이는 동안(visibilityState === 'visible')만 60초마다
// - 탭이 다시 보이면 마지막으로 물은 지 10초가 지났을 때 바로 한 번 (안 지났으면 원래 차례에)
// - 실시간 연결이 끊기면 바로 한 번 묻고 다시 60초마다 — 살아나면 멈춘다
// - 한 번에 하나씩. 실패(MAIL_001 · 네트워크)는 알리지 않고(silent) 다음 차례에. 401 은 지금의 세션 흐름(갱신 1회 → 만료 대화상자)
// 처음 본 값은 기준으로만 쓴다.
// 바뀌었으면:
// - 화면이 보이면 onChange (메일함 개수 · 보이는 목록 맞추기 — 부르는 쪽이). 실시간 소식이 알린 바뀜은 첫 바뀜부터 1초 모았다가
// 한 번에 (MAIL_REALTIME_SYNC_DELAY_MS — 내가 한 통만 읽어도 state 가 바뀌어, 바뀔 때마다 탭마다 다시 받으면 요청이 많다).
// 1분 확인이 본 바뀜은 바로
// - 숨었으면 onHiddenChange(받은편지함의 안 읽은 수 — 탭 제목 "(N) " 만 맞춘다) 하고, 다시 보일 때 onChange (바로)
// - 메일 화면 밖(설정 등)에 있는 동안 생긴 바뀜은 메일 화면으로 돌아오면 onChange (바로)
export interface MailStatePollingOptions {
/** 지난 값과 달라졌을 때 */
/** 바뀌었고 화면이 보일 때 */
onChange: () => void
/** 숨은 탭에서 실시간 소식으로 바뀌었을 때 — 받은편지함의 안 읽은 수 */
onHiddenChange?: (inboxUnread: number) => void
/** 테스트는 손으로 돌리는 시계를 넘긴다 (test-utils/manual-clock.ts) */
clock?: Clock
}
@@ -32,15 +45,21 @@ function isPageVisible(): boolean {
export function useMailStatePolling(options: MailStatePollingOptions): MailStatePollingHandle {
const { getMailState } = useMail()
const authStore = useAuthStore()
const realtimeStore = useMailRealtimeStore()
const clock = options.clock ?? systemClock
/** 지난 값 — 처음 받은 값은 기준으로만 쓴다 */
let baseline: string | null = null
let lastAskedAt: number | null = null
let timer: ClockTimer | null = null
/** 실시간 소식이 알린 바뀜을 모으는 중 — 끝나면 한 번에 맞춘다 */
let syncTimer: ClockTimer | null = null
let inFlight = false
let stopped = false
/** 1분 확인을 할 때인지 — 실시간 연결이 살아 있으면 묻지 않는다 */
function shouldPoll(): boolean {
return !stopped && isPageVisible() && authStore.isLoggedIn && !realtimeStore.isLive
}
function cancelTimer() {
if (timer !== null) clock.clearTimeout(timer)
timer = null
@@ -48,7 +67,7 @@ export function useMailStatePolling(options: MailStatePollingOptions): MailState
function schedule(delay: number) {
cancelTimer()
if (stopped || !isPageVisible() || !authStore.isLoggedIn) return
if (!shouldPoll()) return
timer = clock.setTimeout(
() => {
timer = null
@@ -59,7 +78,7 @@ export function useMailStatePolling(options: MailStatePollingOptions): MailState
}
async function ask(): Promise<void> {
if (stopped || inFlight || !isPageVisible() || !authStore.isLoggedIn) return
if (inFlight || !shouldPoll()) return
inFlight = true
lastAskedAt = clock.now()
const session = authStore.generation
@@ -67,9 +86,8 @@ export function useMailStatePolling(options: MailStatePollingOptions): MailState
const { state } = await getMailState({ silent: true })
// 묻는 사이 로그아웃 · 세션 만료 · 다른 계정 로그인이 있었으면 버린다
if (stopped || authStore.generation !== session) return
const changed = baseline !== null && state !== baseline
baseline = state
if (changed) options.onChange()
// 1분 확인이 본 바뀜은 기다리지 않고 맞춘다 (이미 최대 1분 늦었다)
if (realtimeStore.observeState(state)) syncPending()
} catch {
// 알리지 않는다 — 다음 차례에 다시 묻는다
} finally {
@@ -78,13 +96,35 @@ export function useMailStatePolling(options: MailStatePollingOptions): MailState
}
}
function cancelSyncTimer() {
if (syncTimer !== null) clock.clearTimeout(syncTimer)
syncTimer = null
}
/** 남은 바뀜을 맞춘다 — 화면이 보일 때만 (숨었으면 다시 보일 때) */
function syncPending() {
if (stopped || !isPageVisible()) return
cancelSyncTimer()
if (realtimeStore.takePendingSync()) options.onChange()
}
/** 실시간 소식이 알린 바뀜 — 첫 바뀜부터 잠깐 모았다가 한 번에 맞춘다 (모으는 사이 온 바뀜은 함께) */
function scheduleSync() {
if (syncTimer !== null) return
syncTimer = clock.setTimeout(() => {
syncTimer = null
syncPending()
}, MAIL_REALTIME_SYNC_DELAY_MS)
}
function onVisibilityChange() {
if (stopped) return
if (!isPageVisible()) {
cancelTimer()
return
}
if (inFlight) return
syncPending()
if (inFlight || realtimeStore.isLive) return
const elapsed = lastAskedAt === null ? Number.POSITIVE_INFINITY : clock.now() - lastAskedAt
if (elapsed >= MAIL_STATE_RECHECK_GAP_MS) void ask()
else schedule(MAIL_STATE_POLL_INTERVAL_MS - elapsed)
@@ -93,21 +133,45 @@ export function useMailStatePolling(options: MailStatePollingOptions): MailState
function stop() {
stopped = true
cancelTimer()
cancelSyncTimer()
if (typeof document !== 'undefined') document.removeEventListener('visibilitychange', onVisibilityChange)
}
// 세션이 바뀌면(다시 로그인 등) 지난 값을 버리고 새로 기준을 잡는다
// 바뀜이 생기면 — 보이면 잠깐 모았다가 맞추고(1분 확인이 본 것은 이미 맞췄다), 숨었으면 탭 제목의 안 읽은 수만 바로
watch(
() => realtimeStore.changeSeq,
() => {
if (stopped) return
if (!isPageVisible()) {
if (realtimeStore.inboxUnread !== null) options.onHiddenChange?.(realtimeStore.inboxUnread)
return
}
if (realtimeStore.pendingSync) scheduleSync()
},
)
// 실시간 연결이 살아나면 1분 확인을 멈추고, 끊기면 바로 한 번 묻고 다시 1분마다
watch(
() => realtimeStore.isLive,
(live) => {
if (live) cancelTimer()
else void ask()
},
)
// 세션이 바뀌면(다시 로그인 등) 새로 기준을 잡는다 — 지난 기준은 스토어가 버렸다
watch(
() => authStore.generation,
() => {
baseline = null
if (authStore.isLoggedIn) void ask()
else cancelTimer()
},
)
if (typeof document !== 'undefined') document.addEventListener('visibilitychange', onVisibilityChange)
// 바로 한 번 물어 기준을 잡는다 — 1분 뒤에 처음 물으면 그 사이에 온 메일은 2분 뒤에야 알아챈다
// 메일 화면 밖에 있는 동안 생긴 바뀜을 맞추고, 바로 한 번 물어 기준을 잡는다
// (1분 뒤에 처음 물으면 그 사이에 온 메일은 2분 뒤에야 알아챈다 — 실시간이 살아 있으면 묻지 않는다)
syncPending()
void ask()
onScopeDispose(stop)
@@ -83,6 +83,27 @@ describe('useMailTabTitle (탭 제목)', () => {
expect(document.title).toBe('(3) 메일 · ARA SYSTEM')
})
it('숨은 탭 — 실시간 소식의 안 읽은 수(setInboxUnread)로 바로 맞춘다 (계약 2절 "실시간 알림")', async () => {
useMailStore().mailboxes = [inbox(3)]
await mountHost()
useMailStore().setInboxUnread(5)
await flushPromises()
expect(document.title).toBe('(5) 메일 · ARA SYSTEM')
useMailStore().setInboxUnread(0)
await flushPromises()
expect(document.title).toBe('메일 · ARA SYSTEM')
})
it('메일함을 아직 받지 않았으면 안 읽은 수를 고칠 메일함이 없다 — 받은 뒤의 값을 쓴다', async () => {
await mountHost()
useMailStore().setInboxUnread(5)
await flushPromises()
expect(document.title).toBe('메일 · ARA SYSTEM')
})
it('메일 모듈 밖에서는 붙이지 않는다', async () => {
useMailStore().mailboxes = [inbox(3)]
const router = await mountHost()
@@ -0,0 +1,277 @@
import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest'
import { mount } from '@vue/test-utils'
import { createPinia, setActivePinia, type Pinia } from 'pinia'
import { defineComponent, h } from 'vue'
import type { Router } from 'vue-router'
import type { NewMailContext } from '@/composables/useMailRealtime'
import { useNewMailNotifier } from '@/composables/useNewMailNotifier'
import type { Mailbox } from '@/shared/types/mail'
import type { NewMailItem, NewMailSignal } from '@/shared/utils/mail-events'
import { useAuthStore } from '@/stores/auth.store'
import { useMailNotifyStore } from '@/stores/mail-notify.store'
import { useMailStore } from '@/stores/mail.store'
import { FakeBroadcastChannel } from '@/test-utils/broadcast'
import { flushPromises, installMockApi, ok, restoreApiAdapter, type MockRequest } from '@/test-utils/mock-api'
import { FakeNotification } from '@/test-utils/notification'
import { createTestRouter } from '@/test-utils/router'
import { installFakeLocks, uninstallFakeLocks } from '@/test-utils/web-locks'
// 새 메일 바탕화면 알림 (계약 2절 "실시간 알림" · 디자인 README "5단계 › 바탕화면 알림")
// 설정을 켰고 · 브라우저가 허락했고 · 보이는 웹메일 탭이 하나도 없을 때만 — 한 통 · 여러 통 · 제목 숨김 문구, 누르면 그 메일로
const USER = { email: 'ara.kim@arasystem.test', name: '김아라' }
const INBOX: Mailbox = {
id: 'inbox',
name: '받은편지함',
role: 'inbox',
parentId: null,
sortOrder: 0,
totalCount: 3,
unreadCount: 3,
}
function item(id: string, name: string, email: string, subject = `제목 ${id}`): NewMailItem {
return { id, from: { name, email }, subject, receivedAt: '2026-10-28T05:42:00.000Z' }
}
const ONE: NewMailSignal = { count: 1, messages: [item('m1', '박서준', 'seojun.park@arasystem.test', '11월 납품분 견적서')] }
const THREE: NewMailSignal = {
count: 3,
messages: [
item('m3', '박서준', 'seojun.park@arasystem.test'),
item('m2', '이도윤', 'doyun.lee@arasystem.test'),
item('m1', '최지호', 'jiho.choi@arasystem.test'),
],
}
describe('useNewMailNotifier (새 메일 바탕화면 알림)', () => {
let pinia: Pinia
let router: Router
let notify: ReturnType<typeof useNewMailNotifier>['notify']
let dismiss: ReturnType<typeof useNewMailNotifier>['dismiss']
let viewing: boolean
let otherTabViewing: boolean
let askOtherTabsViewing: Mock<() => Promise<boolean>>
let apiRequests: MockRequest[]
beforeEach(async () => {
pinia = createPinia()
setActivePinia(pinia)
window.localStorage.clear()
installFakeLocks()
vi.stubGlobal('BroadcastChannel', FakeBroadcastChannel)
vi.stubGlobal('Notification', FakeNotification)
FakeNotification.reset('granted')
useAuthStore().markAuthenticated(USER)
apiRequests = installMockApi(() =>
ok({ items: [INBOX], pageInfo: { hasNext: false, nextCursor: null, total: 1 } }),
)
viewing = false
otherTabViewing = false
askOtherTabsViewing = vi.fn(() => Promise.resolve(otherTabViewing))
router = createTestRouter()
await router.push('/settings/mail/notify')
await router.isReady()
const Host = defineComponent({
setup() {
const notifier = useNewMailNotifier()
notify = notifier.notify
dismiss = notifier.dismiss
return () => h('div')
},
})
mount(Host, { global: { plugins: [pinia, router] } })
})
afterEach(() => {
FakeBroadcastChannel.closeAll()
uninstallFakeLocks()
vi.unstubAllGlobals()
vi.restoreAllMocks()
restoreApiAdapter()
})
const context = (): NewMailContext => ({ isUserViewing: () => viewing, askOtherTabsViewing })
async function enable() {
await useMailNotifyStore().turnOn()
}
it('설정을 켜지 않았으면 알리지 않는다 — 다른 탭에 묻지도 않는다', async () => {
await expect(notify(ONE, context())).resolves.toBe(false)
expect(FakeNotification.shown).toEqual([])
expect(askOtherTabsViewing).not.toHaveBeenCalled()
})
it('브라우저가 허락하지 않았으면 알리지 않는다', async () => {
await enable()
FakeNotification.permission = 'denied'
await expect(notify(ONE, context())).resolves.toBe(false)
expect(FakeNotification.shown).toEqual([])
})
it('사용자가 이 탭을 보고 있으면(보이고 초점이 있음) 알리지 않는다 — 목록 · 탭 제목에 바로 보이므로', async () => {
await enable()
viewing = true
await expect(notify(ONE, context())).resolves.toBe(false)
expect(askOtherTabsViewing).not.toHaveBeenCalled()
expect(FakeNotification.shown).toEqual([])
})
it('사용자가 다른 탭을 보고 있으면 알리지 않는다', async () => {
await enable()
otherTabViewing = true
await expect(notify(ONE, context())).resolves.toBe(false)
expect(askOtherTabsViewing).toHaveBeenCalledTimes(1)
expect(FakeNotification.shown).toEqual([])
})
it('보고 있는 탭이 없으면 한 통 — 제목은 보낸 사람, 내용은 메일 제목, tag 하나로 바꿔 쓰고 다시 알린다 · ARA 아이콘', async () => {
await enable()
await expect(notify(ONE, context())).resolves.toBe(true)
expect(FakeNotification.shown).toHaveLength(1)
const [shown] = FakeNotification.shown
expect(shown?.title).toBe('박서준')
expect(shown?.options).toMatchObject({ body: '11월 납품분 견적서', tag: 'arasystem-new-mail', renotify: true })
expect(shown?.options.icon).toMatch(/notification-icon/)
})
it('여러 통 — "새 메일 3통" · "박서준 · 이도윤 외 1명"', async () => {
await enable()
await notify(THREE, context())
expect(FakeNotification.shown.map((shown) => [shown.title, shown.options.body])).toEqual([
['새 메일 3통', '박서준 · 이도윤 외 1명'],
])
})
it('「메일 제목도 보이기」를 끄면 한 통의 내용은 "새 메일이 왔습니다" — 여러 통은 그대로', async () => {
await enable()
useMailNotifyStore().setShowSubject(false)
await notify(ONE, context())
await notify(THREE, context())
expect(FakeNotification.shown.map((shown) => [shown.title, shown.options.body])).toEqual([
['박서준', '새 메일이 왔습니다'],
['새 메일 3통', '박서준 · 이도윤 외 1명'],
])
})
it('누르면 창을 앞으로 가져오고 알림을 닫은 뒤 그 메일을 연다 — 기록에 쌓는다', async () => {
await enable()
useMailStore().mailboxes = [INBOX]
const focus = vi.spyOn(window, 'focus').mockImplementation(() => undefined)
const push = vi.spyOn(router, 'push')
await notify(ONE, context())
FakeNotification.shown[0]?.click()
await flushPromises()
expect(focus).toHaveBeenCalledTimes(1)
expect(FakeNotification.shown[0]?.closed).toBe(true)
expect(router.currentRoute.value.fullPath).toBe('/mail/inbox/m1')
expect(push).toHaveBeenCalledTimes(1)
})
it('여러 통이면 받은편지함을 연다', async () => {
await enable()
useMailStore().mailboxes = [INBOX]
vi.spyOn(window, 'focus').mockImplementation(() => undefined)
await notify(THREE, context())
FakeNotification.shown[0]?.click()
await flushPromises()
expect(router.currentRoute.value.fullPath).toBe('/mail/inbox')
})
it('받은편지함을 아직 모르면(설정 화면 등) 메일함을 받아 본 뒤 연다', async () => {
await enable()
vi.spyOn(window, 'focus').mockImplementation(() => undefined)
await notify(ONE, context())
FakeNotification.shown[0]?.click()
await flushPromises()
await flushPromises()
expect(apiRequests.map((request) => request.url)).toEqual(['/mailboxes'])
expect(router.currentRoute.value.fullPath).toBe('/mail/inbox/m1')
})
it('다른 탭의 답을 기다리는 사이 로그아웃하면 알리지 않는다', async () => {
await enable()
let answer: (value: boolean) => void = () => undefined
askOtherTabsViewing.mockImplementation(
() =>
new Promise<boolean>((resolve) => {
answer = resolve
}),
)
const pending = notify(ONE, context())
useAuthStore().clearAuth()
answer(false)
await expect(pending).resolves.toBe(false)
expect(FakeNotification.shown).toEqual([])
})
it('답을 기다리는 사이 사용자가 돌아왔으면(보게 됨) 알리지 않는다', async () => {
await enable()
askOtherTabsViewing.mockImplementation(() => {
viewing = true
return Promise.resolve(false)
})
await expect(notify(ONE, context())).resolves.toBe(false)
expect(FakeNotification.shown).toEqual([])
})
it('만들 수 없는 브라우저(휴대폰 Chrome 등 — 만들면 예외)면 알리지 못하고 설정을 "지원하지 않음"으로 바꾼다', async () => {
await enable()
FakeNotification.constructorThrows = true
await expect(notify(ONE, context())).resolves.toBe(false)
const notifyStore = useMailNotifyStore()
expect(notifyStore.permission).toBe('unsupported')
expect(notifyStore.canNotify).toBe(false)
// 다음 새 메일은 묻지도 않는다
await expect(notify(ONE, context())).resolves.toBe(false)
expect(askOtherTabsViewing).toHaveBeenCalledTimes(1)
})
it('사용자가 웹메일로 돌아오면(dismiss) 띄운 알림을 닫는다 — 띄운 것이 없으면 할 일이 없다', async () => {
await enable()
expect(() => dismiss()).not.toThrow()
await notify(ONE, context())
expect(FakeNotification.shown[0]?.closed).toBe(false)
dismiss()
expect(FakeNotification.shown[0]?.closed).toBe(true)
})
it('누르기 전에 세션이 바뀌었으면(다른 계정) 이동하지 않는다 — 메일 id 는 계정마다 겹칠 수 있다', async () => {
await enable()
useMailStore().mailboxes = [INBOX]
vi.spyOn(window, 'focus').mockImplementation(() => undefined)
await notify(ONE, context())
useAuthStore().startSession({ email: 'doyun.lee@arasystem.test', name: '이도윤' })
FakeNotification.shown[0]?.click()
await flushPromises()
expect(router.currentRoute.value.fullPath).toBe('/settings/mail/notify')
})
})
@@ -0,0 +1,75 @@
import { showDesktopNotification } from './useDesktopNotification'
import type { NewMailContext } from './useMailRealtime'
import { useMailNavigation } from './useMailNavigation'
import { newMailNotificationContent, type NewMailNotificationTarget } from '@/shared/utils/desktop-notify'
import type { NewMailSignal } from '@/shared/utils/mail-events'
import { useAuthStore } from '@/stores/auth.store'
import { useMailNotifyStore } from '@/stores/mail-notify.store'
import { useMailStore } from '@/stores/mail.store'
// 새 메일 바탕화면 알림 — 계약 2절 "실시간 알림" · 디자인 README "5단계 › 바탕화면 알림"
// new-mail 을 받은 연결 탭(useMailRealtime)이 부른다. 아래가 모두 맞을 때만 알린다:
// - 설정 › 메일 › 알림을 켰다 (이 브라우저 · 이 사용자) · 브라우저가 허락했다(Notification.permission === 'granted')
// - 사용자가 보고 있는 웹메일 탭이 하나도 없다 — "보고 있음"은 보이고(visibilityState) 초점이 있음(hasFocus).
// 이 탭이 그렇지 않고, 다른 탭에 물어 그런 탭의 답이 없을 때. 두 번째 모니터에 웹메일을 띄워 두고 다른 창에서 일하면 알린다
// 알림은 하나의 tag 로 앞 알림을 바꿔 쓴다. 누르면 이 탭으로 와서 그 메일을 연다(여러 통이면 받은편지함) — 기록에 쌓는다(목록에서 여는 것과 같다).
// 사용자가 어느 웹메일 탭이든 보게 되면 띄운 알림을 닫는다 (dismiss — useMailRealtime 의 onUserReturn).
// 만들 수 없는 브라우저(휴대폰 Chrome 등)로 드러나면 설정을 "지원하지 않음"으로 바꾼다 (mail-notify.store 의 markUnsupported).
// 묻는 사이 · 누르기 전에 세션이 바뀌었으면(로그아웃 · 다른 계정) 아무것도 하지 않는다 — 메일 id 는 계정마다 겹칠 수 있다.
// App.vue 의 setup 에서 부른다 (라우터를 쓴다).
export function useNewMailNotifier() {
const authStore = useAuthStore()
const notifyStore = useMailNotifyStore()
const mailStore = useMailStore()
const { openFromNotification } = useMailNavigation()
/** 마지막으로 띄운 알림을 닫는다 — 띄운 것이 없으면 null */
let closeShown: (() => void) | null = null
/** 알림을 눌렀다 — 받은편지함을 아직 모르면 받아 본 뒤, 그래도 모르면 메일 첫 화면으로 */
async function open(target: NewMailNotificationTarget, session: number): Promise<void> {
closeShown = null
if (authStore.generation !== session) return
const inbox = await mailStore.resolveMailboxByRole('inbox')
if (authStore.generation !== session) return
await openFromNotification(
'mailbox' in inbox ? inbox.mailbox.id : null,
target.kind === 'message' ? target.messageId : null,
)
}
/** 알릴 수 있는지 — 설정 · 브라우저의 허락을 지금 다시 본다 */
function canNotify(): boolean {
notifyStore.refreshPermission()
return notifyStore.canNotify
}
/** @returns 알렸는지 */
async function notify(signal: NewMailSignal, context: NewMailContext): Promise<boolean> {
if (!canNotify() || context.isUserViewing()) return false
const session = authStore.generation
if (await context.askOtherTabsViewing()) return false
// 답을 기다리는 사이 설정을 끄거나(다른 탭) 세션이 바뀌었거나 사용자가 돌아왔을 수 있다
if (authStore.generation !== session || !canNotify() || context.isUserViewing()) return false
const content = newMailNotificationContent(signal, { showSubject: notifyStore.settings.showSubject })
if (!content) return false
const result = showDesktopNotification({
title: content.title,
body: content.body,
onClick: () => void open(content.target, session),
})
if (result.status === 'unsupported') notifyStore.markUnsupported()
if (result.status !== 'shown') return false
closeShown = result.close
return true
}
/** 사용자가 웹메일로 돌아왔다 — 띄운 알림을 닫는다 (목록 · 탭 제목에 바로 보이므로) */
function dismiss(): void {
closeShown?.()
closeShown = null
}
return { notify, dismiss }
}
+9 -1
View File
@@ -39,6 +39,14 @@ export function useToast() {
return store.show({ kind: 'success', message, action: action ? sessionBound(action) : undefined })
}
/**
* 화면이 직접 띄우는 오류 알림 — API 오류가 아니라 브라우저 상태를 알릴 때만 쓴다 (예: 바탕화면 알림을 막아 둠 + [설정 열기]).
* API 오류는 인터셉터가 사전 문구로 이미 띄운다 — 호출부에서 다시 띄우지 않는다
*/
function error(message: string, action?: ToastAction): number {
return store.show({ kind: 'error', message, action: action ? sessionBound(action) : undefined })
}
/** 진행 알림을 띄운다 — 저절로 닫히지 않는다. 끝나면 succeed · fail · dismiss 중 하나를 부른다 */
function progress(message: string): ProgressToast {
const session = authStore.generation
@@ -60,5 +68,5 @@ export function useToast() {
}
}
return { success, progress, dismiss: store.dismiss }
return { success, error, progress, dismiss: store.dismiss }
}
+5 -2
View File
@@ -1,7 +1,8 @@
<script setup lang="ts">
// 메일 모듈 레이아웃 — 전역 셸 + 메일함 패널 + 하위 화면(목록·본문 / 검색 결과 / 쓰기)
// 패널은 하위 화면이 바뀌어도 다시 그리지 않는다.
// 메일 화면에 있는 동안: 상단 검색 칸은 "메일 검색", 1분마다 새 메일 확인, 탭 제목 앞에 받은편지함의 안 읽은 수 (디자인 README "3단계")
// 메일 화면에 있는 동안: 상단 검색 칸은 "메일 검색", 새 메일 · 다른 창의 변화를 맞춘다(실시간 소식 — 끊겼으면 1분 확인),
// 탭 제목 앞에 받은편지함의 안 읽은 수 (디자인 README "3단계" · "5단계 › 실시간 반영"). 실시간 연결 자체는 앱 전체에서 돈다(App.vue)
// 들어올 때 부재중 자동 응답을 한 번 받는다 — 켜져 있으면 메일함 패널 아래에 안내 (계약 2절 "부재중 자동 응답")
import AppShell from '@/components/shell/AppShell.vue'
import MailSearchBox from '@/components/mail/MailSearchBox.vue'
@@ -24,13 +25,15 @@ void useMailSignatureStore().ensureSignature()
// 메일함 패널 아래의 부재중 안내 — 못 받으면 알리지 않고 그리지 않는다 (지난 값은 둔다)
void useMailVacationStore().load()
// 새 메일 확인 — 바뀌었으면 메일함 개수 · 사용량과 보이는 메일함 목록을 맞춘다 (검색 결과는 저절로 바꾸지 않는다 — 스토어가 거른다)
// 새 메일 확인 — 바뀌었으면 메일함 개수 · 사용량과 보이는 메일함 목록을 맞춘다 (검색 결과는 저절로 바꾸지 않는다 — 스토어가 거른다).
// 숨은 탭은 받은편지함의 안 읽은 수만 고쳐 탭 제목 "(N) " 을 맞추고, 다시 보일 때 맞춘다
useMailStatePolling({
clock: useClock(),
onChange: () => {
mailStore.syncCounts()
void mailStore.syncVisibleList()
},
onHiddenChange: (inboxUnread) => mailStore.setInboxUnread(inboxUnread),
})
useMailTabTitle()
</script>
@@ -0,0 +1,247 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { flushPromises, mount } from '@vue/test-utils'
import { createPinia, setActivePinia, type Pinia } from 'pinia'
import { useAuthStore } from '@/stores/auth.store'
import { useMailNotifyStore } from '@/stores/mail-notify.store'
import { useMailRealtimeStore } from '@/stores/mail-realtime.store'
import { FakeBroadcastChannel } from '@/test-utils/broadcast'
import { FakeNotification } from '@/test-utils/notification'
import { createTestRouter } from '@/test-utils/router'
import { installFakeLocks, uninstallFakeLocks } from '@/test-utils/web-locks'
import MailNotifyPage from './MailNotifyPage.vue'
// 설정 › 메일 › 알림 (디자인 원본 SettingsNotify · MailRealtime "막혔을 때 · 지원하지 않을 때" · README "5단계")
const USER = { email: 'ara.kim@arasystem.test', name: '김아라' }
describe('MailNotifyPage (설정 › 메일 › 알림)', () => {
let pinia: Pinia
beforeEach(() => {
pinia = createPinia()
setActivePinia(pinia)
window.localStorage.clear()
installFakeLocks()
vi.stubGlobal('BroadcastChannel', FakeBroadcastChannel)
vi.stubGlobal('Notification', FakeNotification)
FakeNotification.reset('default')
useAuthStore().markAuthenticated(USER)
})
afterEach(() => {
uninstallFakeLocks()
vi.unstubAllGlobals()
document.body.innerHTML = ''
})
async function mountPage() {
const router = createTestRouter()
await router.push('/settings/mail/notify')
await router.isReady()
const wrapper = mount(MailNotifyPage, { global: { plugins: [pinia, router] }, attachTo: document.body })
await flushPromises()
return wrapper
}
function enableInput(wrapper: Awaited<ReturnType<typeof mountPage>>) {
return wrapper.get('#notify-enabled').element as HTMLInputElement
}
it('제목 · 설명 — 새 메일은 1분을 기다리지 않고 바로 목록에 나타난다', async () => {
const wrapper = await mountPage()
expect(wrapper.get('h1').text()).toBe('알림')
expect(wrapper.get('main').attributes('aria-labelledby')).toBe('notify-page-title')
expect(wrapper.text()).toContain('새 메일은 1분을 기다리지 않고 바로 목록에 나타납니다.')
})
it('묻기 전 — 칸은 비었고 "켜면 브라우저가 … 「허용」을 눌러 주세요.", 보일 것 · 시험 알림은 흐리게', async () => {
const wrapper = await mountPage()
expect(enableInput(wrapper).checked).toBe(false)
expect(enableInput(wrapper).disabled).toBe(false)
expect(wrapper.get('#notify-enabled-note').text()).toBe(
'켜면 브라우저가 알림을 허락할지 한 번 묻습니다 — 「허용」을 눌러 주세요.',
)
expect(wrapper.get('fieldset').attributes('disabled')).toBeDefined()
expect(wrapper.get('#notify-test').attributes('disabled')).toBeDefined()
})
it('켜기 → 브라우저가 묻고 허용 → 체크 · 초록 "허용되어 있습니다" · 시험 알림은 지금 탭에서 바로', async () => {
const wrapper = await mountPage()
FakeNotification.nextAnswer = 'granted'
await wrapper.get('#notify-enabled').setValue(true)
await flushPromises()
expect(FakeNotification.requestCount).toBe(1)
expect(enableInput(wrapper).checked).toBe(true)
const note = wrapper.get('#notify-enabled-note')
expect(note.text()).toBe('이 브라우저에서 알림이 허용되어 있습니다.')
expect(note.classes()).toContain('notify-form__note--success')
expect(wrapper.get('fieldset').attributes('disabled')).toBeUndefined()
expect(useMailNotifyStore().settings.enabled).toBe(true)
await wrapper.get('#notify-test').trigger('click')
expect(FakeNotification.shown.map((shown) => [shown.title, shown.options.body])).toEqual([
['ARA 웹메일', '알림이 잘 옵니다. 새 메일이 오면 이렇게 알려 드립니다.'],
])
})
it('켜기 → 막음 → 칸을 끄고(누를 수 없다) 빨간 안내로 푸는 방법 — 초점은 그 안내 글로', async () => {
const wrapper = await mountPage()
FakeNotification.nextAnswer = 'denied'
enableInput(wrapper).focus()
await wrapper.get('#notify-enabled').setValue(true)
await flushPromises()
expect(enableInput(wrapper).checked).toBe(false)
expect(enableInput(wrapper).disabled).toBe(true)
const note = wrapper.get('#notify-enabled-note')
expect(note.text()).toBe(
'브라우저가 이 사이트의 알림을 막고 있습니다. 주소창 왼쪽의 사이트 정보(자물쇠) › 알림을 「허용」으로 바꾼 뒤 이 화면을 새로 고쳐 주세요.',
)
expect(note.classes()).toContain('notify-form__note--danger')
expect(useMailNotifyStore().settings.enabled).toBe(false)
expect(document.activeElement).toBe(note.element)
})
it('허락을 묻는 동안 칸을 끄지 않고 aria-disabled 로만 막는다 — 초점이 남고, 다시 눌러도 다시 묻지 않는다', async () => {
const wrapper = await mountPage()
const answer = FakeNotification.holdAnswer()
enableInput(wrapper).focus()
await wrapper.get('#notify-enabled').setValue(true)
await flushPromises()
expect(enableInput(wrapper).disabled).toBe(false)
expect(enableInput(wrapper).getAttribute('aria-disabled')).toBe('true')
expect(document.activeElement).toBe(enableInput(wrapper))
// 묻는 창이 떠 있는 동안 다시 누른다 — 되돌리고 묻지 않는다
await wrapper.get('#notify-enabled').setValue(false)
await flushPromises()
expect(FakeNotification.requestCount).toBe(1)
answer('granted')
await flushPromises()
expect(enableInput(wrapper).checked).toBe(true)
expect(enableInput(wrapper).getAttribute('aria-disabled')).toBeNull()
expect(useMailNotifyStore().settings.enabled).toBe(true)
expect(document.activeElement).toBe(enableInput(wrapper))
})
it('시험 알림을 만들 수 없는 브라우저(휴대폰 Chrome 등)면 조용히 넘어가지 않고 "지원하지 않습니다"로 바꾼다 — 초점은 안내 글로', async () => {
FakeNotification.reset('granted')
const wrapper = await mountPage()
await wrapper.get('#notify-enabled').setValue(true)
await flushPromises()
FakeNotification.constructorThrows = true
await wrapper.get('#notify-test').trigger('click')
await flushPromises()
const note = wrapper.get('#notify-enabled-note')
expect(note.text()).toBe('이 브라우저는 바탕화면 알림을 지원하지 않습니다. 새 메일은 그래도 바로 목록에 나타납니다.')
expect(enableInput(wrapper).disabled).toBe(true)
expect(wrapper.get('#notify-test').attributes('disabled')).toBeDefined()
expect(document.activeElement).toBe(note.element)
})
it('켜기 → 그냥 닫음 → 칸은 비운 채 그대로 (다시 누르면 또 묻는다)', async () => {
const wrapper = await mountPage()
FakeNotification.nextAnswer = 'default'
await wrapper.get('#notify-enabled').setValue(true)
await flushPromises()
expect(enableInput(wrapper).checked).toBe(false)
expect(wrapper.get('#notify-enabled-note').attributes('data-note')).toBe('ask')
FakeNotification.nextAnswer = 'granted'
await wrapper.get('#notify-enabled').setValue(true)
await flushPromises()
expect(FakeNotification.requestCount).toBe(2)
expect(enableInput(wrapper).checked).toBe(true)
})
it('끄기 — 체크를 풀면 꺼지고 보일 것 · 시험 알림이 흐려진다 (허락은 그대로)', async () => {
FakeNotification.reset('granted')
const wrapper = await mountPage()
await wrapper.get('#notify-enabled').setValue(true)
await flushPromises()
expect(enableInput(wrapper).checked).toBe(true)
await wrapper.get('#notify-enabled').setValue(false)
await flushPromises()
expect(useMailNotifyStore().settings.enabled).toBe(false)
expect(wrapper.get('fieldset').attributes('disabled')).toBeDefined()
expect(wrapper.get('#notify-test').attributes('disabled')).toBeDefined()
expect(wrapper.get('#notify-enabled-note').text()).toBe('이 브라우저에서 알림이 허용되어 있습니다.')
})
it('지원하지 않는 브라우저 — 칸을 끄고 "지원하지 않습니다. 새 메일은 그래도 바로 목록에"', async () => {
vi.stubGlobal('Notification', undefined)
const wrapper = await mountPage()
expect(enableInput(wrapper).disabled).toBe(true)
expect(wrapper.get('#notify-enabled-note').text()).toBe(
'이 브라우저는 바탕화면 알림을 지원하지 않습니다. 새 메일은 그래도 바로 목록에 나타납니다.',
)
})
it('메일 제목도 보이기 — 끄면 저장한다 ("끄면 보낸 사람 이름만 보입니다")', async () => {
FakeNotification.reset('granted')
const wrapper = await mountPage()
await wrapper.get('#notify-enabled').setValue(true)
await flushPromises()
const subject = wrapper.get('#notify-show-subject')
expect((subject.element as HTMLInputElement).checked).toBe(true)
expect(wrapper.text()).toContain('끄면 보낸 사람 이름만 보입니다 — 화면을 다른 사람과 함께 보는 자리라면 꺼 두세요.')
await subject.setValue(false)
expect(useMailNotifyStore().settings.showSubject).toBe(false)
})
it('실시간 연결 — 연결됨(초록 점) · 끊김 "연결할 수 없어 1분마다 확인합니다." · 잇는 중', async () => {
const realtime = useMailRealtimeStore()
realtime.setStatus('live')
const wrapper = await mountPage()
const status = () => wrapper.get('#notify-realtime-status')
expect(wrapper.get('h2').text()).toBe('실시간 연결')
expect(status().text()).toBe('연결되어 있습니다 — 새 메일이 오면 바로 목록에 나타납니다.')
expect(status().find('.realtime-link__dot').classes()).toContain('is-live')
expect(wrapper.find('#notify-realtime-notice').exists()).toBe(false)
realtime.setStatus('down')
await flushPromises()
expect(status().text()).toBe('연결할 수 없어 1분마다 확인합니다.')
expect(status().find('.realtime-link__dot').classes()).not.toContain('is-live')
// 끊긴 동안 — 상태 줄 바로 아래, "저절로 다시 잇습니다…" 안내 앞
expect(wrapper.get('#notify-realtime-notice').text()).toBe('연결될 때까지 바탕화면 알림은 오지 않습니다.')
expect(wrapper.find('[role="status"] #notify-realtime-notice').exists()).toBe(true)
expect(wrapper.text()).toContain('저절로 다시 잇습니다.')
realtime.setStatus('connecting')
await flushPromises()
expect(status().text()).toBe('연결하는 중입니다…')
expect(wrapper.find('#notify-realtime-notice').exists()).toBe(false)
})
it('아래 안내 상자 — 이 브라우저에만 · 탭이 하나라도 열려 있어야 · 보고 있을 때는 보내지 않음', async () => {
const wrapper = await mountPage()
const notes = wrapper.get('[data-notify-notes]')
expect(notes.findAll('p').map((note) => note.findAll('span').at(-1)?.text())).toEqual([
'알림 설정은 이 브라우저에만 적용됩니다. 다른 PC · 브라우저에서는 그곳에서 따로 켜 주세요.',
'웹메일 탭이 하나라도 열려 있어야 알림이 옵니다. 탭을 모두 닫으면 오지 않습니다.',
'웹메일 화면을 보고 있을 때는 바탕화면 알림을 보내지 않습니다 — 목록에 바로 보이므로.',
])
expect(notes.get('strong').text()).toBe('이 브라우저에만')
})
})
@@ -0,0 +1,92 @@
<script setup lang="ts">
// 설정 › 메일 › 알림 (디자인 원본 SettingsNotify · README "5단계 › 설정 › 메일 › 알림") — /settings/mail/notify
// 위에서부터 바탕화면 알림 켜기 · 알림에 보일 것 · 시험 알림(NotifySettingsForm) — 가로줄 — 실시간 연결(RealtimeLinkStatus) — 안내 상자(바탕 #F3F8F8):
// 이 브라우저에만 적용 · 웹메일 탭이 하나라도 열려 있어야 · 웹메일 화면을 보고 있을 때는 보내지 않음.
// 설정은 이 브라우저 · 사용자마다 저장한다(mail-notify.store). 들어올 때 · 탭으로 돌아올 때 브라우저의 허락을 다시 읽는다 — 사이트 설정에서 바꿨을 수 있다.
import { onBeforeUnmount, onMounted } from 'vue'
import NotifySettingsForm from '@/components/settings/NotifySettingsForm.vue'
import RealtimeLinkStatus from '@/components/settings/RealtimeLinkStatus.vue'
import SettingsPageFrame from '@/components/settings/SettingsPageFrame.vue'
import { MAIL_NOTIFY_TEXT } from '@/shared/constants/mail-notify'
import { useMailNotifyStore } from '@/stores/mail-notify.store'
const notifyStore = useMailNotifyStore()
function refreshPermission() {
if (document.visibilityState === 'visible') notifyStore.refreshPermission()
}
onMounted(() => {
notifyStore.refreshPermission()
window.addEventListener('focus', refreshPermission)
document.addEventListener('visibilitychange', refreshPermission)
})
onBeforeUnmount(() => {
window.removeEventListener('focus', refreshPermission)
document.removeEventListener('visibilitychange', refreshPermission)
})
</script>
<template>
<SettingsPageFrame
title-id="notify-page-title"
:title="MAIL_NOTIFY_TEXT.title"
:description="MAIL_NOTIFY_TEXT.description"
>
<NotifySettingsForm />
<hr class="notify-page__rule">
<RealtimeLinkStatus />
<div
class="notify-page__notes"
data-notify-notes
>
<p class="notify-page__note">
<span aria-hidden="true">·</span>
<span>{{ MAIL_NOTIFY_TEXT.noteBrowserBefore }}<strong class="notify-page__strong">{{
MAIL_NOTIFY_TEXT.noteBrowserStrong
}}</strong>{{ MAIL_NOTIFY_TEXT.noteBrowserAfter }}</span>
</p>
<p class="notify-page__note">
<span aria-hidden="true">·</span>
<span>{{ MAIL_NOTIFY_TEXT.noteOpenTab }}</span>
</p>
<p class="notify-page__note">
<span aria-hidden="true">·</span>
<span>{{ MAIL_NOTIFY_TEXT.noteVisible }}</span>
</p>
</div>
</SettingsPageFrame>
</template>
<style scoped>
.notify-page__rule {
width: 100%;
height: var(--border-width);
margin: 0;
border: 0;
background: var(--color-border-subtle);
}
.notify-page__notes {
display: flex;
flex-direction: column;
gap: var(--space-6);
max-width: var(--note-max-width);
padding: var(--space-14) var(--space-16);
border-radius: var(--radius-lg);
background: var(--color-brand-tint);
color: var(--color-text-strong);
}
.notify-page__note {
display: flex;
gap: var(--space-8);
margin: 0;
}
.notify-page__strong {
font-weight: 600;
}
</style>
@@ -88,10 +88,13 @@ reload()
padding: var(--content-block-start) var(--content-inline) var(--content-block-end);
}
/* 한국어 안내 글은 낱말 가운데에서 줄을 바꾸지 않는다 — 다른 설정 화면(SettingsPageFrame)과 같다. 서명 칸 · 미리 보기는 보내는 모양 그대로 둔다 */
.signature-page__intro {
display: flex;
flex-direction: column;
gap: var(--space-4);
word-break: keep-all;
overflow-wrap: anywhere;
}
.signature-page__title {
@@ -9,7 +9,7 @@ import { useAuthStore } from '@/stores/auth.store'
import { createTestRouter } from '@/test-utils/router'
import SettingsLayout from './SettingsLayout.vue'
// 설정 메뉴 — 계정 › 비밀번호 / 메일 › 서명 · 부재중 자동 응답 (디자인 README "4단계 › 설정 메뉴")
// 설정 메뉴 — 계정 › 비밀번호 / 메일 › 서명 · 부재중 자동 응답 · 알림 (디자인 README "4단계 › 설정 메뉴" · "5단계")
describe('SettingsLayout', () => {
let pinia: Pinia
@@ -34,7 +34,7 @@ describe('SettingsLayout', () => {
return { wrapper, router }
}
it('묶음 둘 — 계정(비밀번호) · 메일(서명 · 부재중 자동 응답), 묶음 이름이 목록의 이름이다', async () => {
it('묶음 둘 — 계정(비밀번호) · 메일(서명 · 부재중 자동 응답 · 알림), 묶음 이름이 목록의 이름이다', async () => {
const { wrapper } = await mountAt('/settings/account/password')
const menu = wrapper.get('nav[aria-label="설정 메뉴"]')
@@ -46,6 +46,7 @@ describe('SettingsLayout', () => {
[
['서명', '/settings/mail/signature'],
['부재중 자동 응답', '/settings/mail/vacation'],
['알림', '/settings/mail/notify'],
],
])
})
@@ -66,5 +67,6 @@ describe('SettingsLayout', () => {
expect(router.currentRoute.value.fullPath).toBe('/settings/account/password')
expect(router.currentRoute.value.meta.title).toBe('비밀번호')
expect(router.resolve({ name: 'settings-mail-vacation' }).fullPath).toBe('/settings/mail/vacation')
expect(router.resolve({ name: 'settings-mail-notify' }).fullPath).toBe('/settings/mail/notify')
})
})
@@ -1,6 +1,6 @@
<script setup lang="ts">
// 설정 모듈 레이아웃 (디자인 원본 Settings · SettingsAccount · SettingsVacation) — 전역 셸 + 왼쪽 설정 메뉴(208 · #F8F9FB) + 하위 화면
// 메뉴는 묶음 둘: 계정 › 비밀번호 / 메일 › 서명 · 부재중 자동 응답 (디자인 README "4단계 › 설정 메뉴", 목록은 settings-menu.ts).
// 메뉴는 묶음 둘: 계정 › 비밀번호 / 메일 › 서명 · 부재중 자동 응답 · 알림 (디자인 README "4단계 › 설정 메뉴" · "5단계", 목록은 settings-menu.ts).
// 묶음 이름은 12px #667085 굵게, 항목은 40 · 아이콘 16. 지금 화면의 항목은 aria-current="page" (RouterLink).
import AppIcon from '@/components/AppIcon.vue'
import AppShell from '@/components/shell/AppShell.vue'
+7 -1
View File
@@ -47,7 +47,7 @@ export const routes: RouteRecordRaw[] = [
],
},
{
// 설정 — 왼쪽 메뉴(계정 › 비밀번호 / 메일 › 서명 · 부재중 자동 응답) + 하위 화면.
// 설정 — 왼쪽 메뉴(계정 › 비밀번호 / 메일 › 서명 · 부재중 자동 응답 · 알림) + 하위 화면.
// /settings 는 첫 메뉴(계정 › 비밀번호)로 바꿔 끼운다 (앱 레일의 "설정")
path: '/settings',
component: () => import('@/pages/settings/SettingsLayout.vue'),
@@ -72,6 +72,12 @@ export const routes: RouteRecordRaw[] = [
component: () => import('@/pages/settings/MailVacationPage.vue'),
meta: { title: '부재중 자동 응답' },
},
{
path: 'mail/notify',
name: SETTINGS_ROUTES.notify,
component: () => import('@/pages/settings/MailNotifyPage.vue'),
meta: { title: '메일 알림' },
},
],
},
{
+4
View File
@@ -248,6 +248,10 @@ export const ICONS = {
],
/** 메일함 패널의 "새 메일함" */
plus: [line(12, 5, 12, 19), line(5, 12, 19, 12)],
// ── 5단계: 실시간 새 메일 · 바탕화면 알림 (디자인 원본 SettingsNotify · MailRealtime) ──
/** 설정 메뉴의 알림 · 시험 알림 · 목록 위 권하기 줄 */
bell: [path('M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9'), path('M13.73 21a2 2 0 0 1-3.46 0')],
} as const satisfies Record<string, readonly IconShape[]>
export type IconName = keyof typeof ICONS
@@ -0,0 +1,111 @@
// 실시간 새 메일 · 바탕화면 알림 상수 — 계약 2절 "실시간 알림 (GET /api/mail-events)" · 디자인 README "5단계"
// (디자인 원본 SettingsNotify · MailRealtime). 시간 값은 계약과 같다 — 서버는 25초마다 ping 을 보낸다.
// ── 실시간 연결 ─────────────────────────────────────────
/** SSE 주소 — 같은 주소(origin)의 /api 아래라 쿠키가 함께 간다 (buildApiUrl 로 만든다) */
export const MAIL_EVENTS_PATH = '/mail-events'
/** SSE 응답의 Content-Type — 다르면(프록시가 index.html 을 돌려준 경우 등) 실패로 본다 */
export const MAIL_EVENTS_CONTENT_TYPE = 'text/event-stream'
/** 이만큼 ping · state 가 없으면 끊긴 것으로 본다 (서버 ping 25초의 세 번) — 연결한 탭은 연결을 끊고 다시 잇는다 (머리글을 기다리는 동안도) */
export const MAIL_EVENTS_SILENCE_MS = 75_000
/**
* 끊긴 뒤 다시 잇기까지 — 실패할수록 길게, 마지막 값에서 멈춘다.
* 연결이 자리 잡으면(MAIL_EVENTS_STABLE_MS) 처음부터 다시 센다 — 열리자마자 끊기는 연결은 간격이 계속 늘어난다
*/
export const MAIL_EVENTS_RECONNECT_DELAYS_MS: readonly number[] = [2_000, 5_000, 15_000, 30_000, 60_000]
/** 다시 잇는 간격의 흔들림(±20%) — 서버가 다시 뜰 때 브라우저들이 한꺼번에 몰리지 않게 */
export const MAIL_EVENTS_RECONNECT_JITTER = 0.2
/**
* 연결이 자리 잡았다고 보는 때 — 첫 state 뒤 정상 ping(upstream: true)을 받았거나 이만큼 이어졌을 때.
* 그 전에 끊기거나 end 가 오면(까닭과 상관없이) 실패처럼 간격을 둔다 — 열리자마자 끝나는 연결(세션 끝 무렵 서버가 state 뒤 바로
* end(token-expiring) 하는 등)을 왕복 시간 속도로 되풀이해 갱신 한도를 다 쓰지 않게 (2026-10-28 리뷰)
*/
export const MAIL_EVENTS_STABLE_MS = 20_000
/** 자리 잡은 연결이 end(서버 종료 · 재시작)로 끝났을 때 다시 잇기 전의 흔들림 — 0~3초 (배포 때 모든 브라우저가 같은 순간에 붙지 않게) */
export const MAIL_EVENTS_SHUTDOWN_SPREAD_MS = 3_000
/** end 의 까닭 — 액세스 토큰이 곧 끝난다: 세션을 갱신한 뒤 다시 잇는다 (그 밖의 까닭은 조금 흔들어 다시 잇는다) */
export const MAIL_EVENTS_END_TOKEN_EXPIRING = 'token-expiring'
/**
* 실시간 소식으로 바뀐 것을 메일 화면이 맞추기 전에 모으는 시간 — 첫 바뀜부터 이만큼 기다렸다 한 번에 (목록 · 개수를 다시 받는다).
* 내가 한 통만 읽어도 state 가 바뀌어, 바뀔 때마다 보이는 탭마다 다시 받으면 요청이 많다 (1분 확인이 본 바뀜은 기다리지 않는다)
*/
export const MAIL_REALTIME_SYNC_DELAY_MS = 1_000
/**
* 같은 브라우저의 탭끼리 쓰는 이름 — 잠금(Web Locks)으로 한 탭만 연결하고, 소식은 채널(BroadcastChannel)로 나눈다.
* 잠금 이름 끝에 사용자 주소를 붙인다 (mailEventsLockName). 채널 메시지에도 주소를 넣어 다른 계정의 소식은 버린다
*/
export const MAIL_EVENTS_LOCK_PREFIX = 'arasystem.mail-events:'
export const MAIL_EVENTS_CHANNEL_NAME = 'arasystem.mail-events'
/** 새 메일을 받은 연결 탭이 "사용자가 보고 있는 탭이 있는지"(보이고 초점이 있음) 다른 탭의 답을 기다리는 시간 — 넘으면 없는 것으로 본다 */
export const MAIL_EVENTS_VISIBILITY_WAIT_MS = 300
// ── 바탕화면 알림 ────────────────────────────────────────
/** 하나의 tag 로 앞 알림을 바꿔 쓴다 — 바탕화면에 쌓이지 않는다 (renotify 로 다시 알린다) */
export const DESKTOP_NOTIFY_TAG = 'arasystem-new-mail'
/** 설정(켜기 · 제목 보이기 · 권하기 닫음)을 브라우저에 둔다 — 키 끝에 사용자 주소 (mail-view.store 와 같은 방식) */
export const MAIL_NOTIFY_STORAGE_PREFIX = 'arasystem.mail.notify:'
/** 여러 통 알림의 "박서준 · 이도윤 외 1명" — 이름을 몇 명까지 보이는지 */
export const DESKTOP_NOTIFY_NAMED_SENDERS = 2
export const MAIL_NOTIFY_TEXT = {
// 설정 › 메일 › 알림 (디자인 원본 SettingsNotify)
menu: '알림',
title: '알림',
description:
'새 메일은 1분을 기다리지 않고 바로 목록에 나타납니다. 다른 창을 보는 동안에도 알고 싶으면 바탕화면 알림을 켜 두세요.',
enable: '새 메일이 오면 바탕화면 알림 보내기',
noteAsk: '켜면 브라우저가 알림을 허락할지 한 번 묻습니다 — 「허용」을 눌러 주세요.',
noteGranted: '이 브라우저에서 알림이 허용되어 있습니다.',
noteBlocked:
'브라우저가 이 사이트의 알림을 막고 있습니다. 주소창 왼쪽의 사이트 정보(자물쇠) › 알림을 「허용」으로 바꾼 뒤 이 화면을 새로 고쳐 주세요.',
noteUnsupported: '이 브라우저는 바탕화면 알림을 지원하지 않습니다. 새 메일은 그래도 바로 목록에 나타납니다.',
contentLegend: '알림에 보일 것',
showSubject: '메일 제목도 보이기',
showSubjectHint: '끄면 보낸 사람 이름만 보입니다 — 화면을 다른 사람과 함께 보는 자리라면 꺼 두세요.',
test: '시험 알림 보내기',
testHint: '바탕화면 오른쪽 아래(맥은 오른쪽 위)에 알림이 뜨면 됩니다.',
realtimeTitle: '실시간 연결',
realtimeLive: '연결되어 있습니다 — 새 메일이 오면 바로 목록에 나타납니다.',
realtimeLiveHint:
'연결할 수 없을 때는 "연결할 수 없어 1분마다 확인합니다."로 바뀌고, 저절로 다시 잇습니다. 회사 방화벽 · 프록시가 오래 열린 연결을 끊으면 이렇게 됩니다.',
realtimeDown: '연결할 수 없어 1분마다 확인합니다.',
/** 끊긴 동안 — 상태 줄 바로 아래 (2026-10-28 화면 점검 반영) */
realtimeDownNotice: '연결될 때까지 바탕화면 알림은 오지 않습니다.',
realtimeDownHint: '저절로 다시 잇습니다. 회사 방화벽 · 프록시가 오래 열린 연결을 끊으면 이렇게 됩니다.',
/** 처음 잇는 동안 (캔버스에는 없다 — 2026-10-28 구현하며 정함) */
realtimeConnecting: '연결하는 중입니다…',
/** Web Locks · BroadcastChannel 이 없는 브라우저 — 1분 확인만 한다 (캔버스에는 없다 — 2026-10-28 구현하며 정함) */
realtimeUnsupportedHint: '이 브라우저는 실시간 연결을 지원하지 않습니다.',
/** 아래 안내 상자 — 첫 줄의 "이 브라우저에만"은 굵게 */
noteBrowserBefore: '알림 설정은 ',
noteBrowserStrong: '이 브라우저에만',
noteBrowserAfter: ' 적용됩니다. 다른 PC · 브라우저에서는 그곳에서 따로 켜 주세요.',
noteOpenTab: '웹메일 탭이 하나라도 열려 있어야 알림이 옵니다. 탭을 모두 닫으면 오지 않습니다.',
noteVisible: '웹메일 화면을 보고 있을 때는 바탕화면 알림을 보내지 않습니다 — 목록에 바로 보이므로.',
// 시험 알림 — 지금 탭에서 바로 보낸다
testTitle: 'ARA 웹메일',
testBody: '알림이 잘 옵니다. 새 메일이 오면 이렇게 알려 드립니다.',
// 새 메일 알림 (디자인 원본 MailRealtime)
/** 「메일 제목도 보이기」를 껐을 때 한 통 알림의 내용 */
hiddenSubject: '새 메일이 왔습니다',
manyTitle: (count: string) => `새 메일 ${count}통`,
/** "박서준 · 이도윤 외 1명" — names 는 이미 " · " 로 이은 이름들 */
sendersMore: (names: string, rest: string) => `${names} 외 ${rest}명`,
senderSeparator: ' · ',
// 목록 위에서 한 번 권하기 (디자인 원본 MailRealtime)
offerRegion: '바탕화면 알림 권하기',
offerText: '바탕화면 알림을 켜 두면 다른 창을 보는 동안에도 새 메일을 알려 드립니다.',
/** 분할 보기의 좁은 목록 칸(384)에서 — 긴 글은 title 로 (2026-10-28 화면 점검 반영) */
offerShortText: '바탕화면 알림으로 새 메일 받기',
offerAction: '알림 켜기',
offerClose: '닫기',
/** 허락을 받아 켰을 때 (성공 알림) */
enabledToast: '바탕화면 알림을 켰습니다.',
/** 브라우저가 막았을 때 (오류 알림 + 설정 열기) */
blockedToast: '브라우저가 알림을 막고 있습니다. 푸는 방법은 설정 › 알림에 있습니다.',
openSettings: '설정 열기',
} as const
+1 -1
View File
@@ -196,7 +196,7 @@ export const DRAFT_NO_RECIPIENTS = '(받는 사람 없음)'
export const MAIL_NO_SUBJECT = '(제목 없음)'
// ── 새 메일 확인 (계약 2절 "새 메일 확인" · 디자인 README "3단계 › 새 메일") ─────────────
/** 화면이 보이는 동안 이 간격으로 바뀐 것이 있는지 묻는다 */
/** 화면이 보이는 동안 이 간격으로 바뀐 것이 있는지 묻는다 — 실시간 연결이 살아 있으면 묻지 않는다 (5단계, mail-notify.ts) */
export const MAIL_STATE_POLL_INTERVAL_MS = 60_000
/** 탭이 다시 보일 때 마지막으로 물은 지 이만큼 지났으면 바로 한 번 묻는다 */
export const MAIL_STATE_RECHECK_GAP_MS = 10_000
@@ -1,5 +1,6 @@
// 설정 모듈의 왼쪽 메뉴 — 묶음(계정 · 메일) 아래 항목 (디자인 README "4단계 › 설정 메뉴")
// 라우트(router/routes.ts) · 메뉴(SettingsLayout.vue) · 다른 화면의 링크(메일함 패널의 부재중 안내)가 이 이름을 함께 쓴다.
// 설정 모듈의 왼쪽 메뉴 — 묶음(계정 · 메일) 아래 항목 (디자인 README "4단계 › 설정 메뉴" · "5단계 › 설정 › 메일 › 알림")
// 라우트(router/routes.ts) · 메뉴(SettingsLayout.vue) · 다른 화면의 링크(메일함 패널의 부재중 안내 · 알림을 막아 둔 오류 알림의 [설정 열기])가
// 이 이름을 함께 쓴다.
import type { IconName } from '@/shared/constants/icons'
@@ -8,6 +9,7 @@ export const SETTINGS_ROUTES = {
password: 'settings-account-password',
signature: 'settings-mail-signature',
vacation: 'settings-mail-vacation',
notify: 'settings-mail-notify',
} as const
export interface SettingsMenuItem {
@@ -35,6 +37,7 @@ export const SETTINGS_MENU: readonly SettingsMenuGroup[] = [
items: [
{ routeName: SETTINGS_ROUTES.signature, label: '서명', icon: 'edit' },
{ routeName: SETTINGS_ROUTES.vacation, label: '부재중 자동 응답', icon: 'calendar-x' },
{ routeName: SETTINGS_ROUTES.notify, label: '알림', icon: 'bell' },
],
},
]
@@ -0,0 +1,43 @@
import { describe, expect, it } from 'vitest'
import { supportsDesktopNotification, supportsMailRealtime, type BrowserScope } from './browser-support'
// 실시간 새 메일 · 바탕화면 알림을 쓸 수 있는 브라우저인지 (계약 2절 "실시간 알림" — Web Locks 가 없으면 1분 확인만)
const fn = () => undefined
function fullScope(): BrowserScope {
return {
navigator: { locks: { request: fn } },
BroadcastChannel: fn,
fetch: fn,
ReadableStream: fn,
TextDecoder: fn,
AbortController: fn,
Notification: fn,
}
}
describe('browser-support', () => {
it('Web Locks · BroadcastChannel · fetch 스트림이 다 있으면 실시간을 쓴다', () => {
expect(supportsMailRealtime(fullScope())).toBe(true)
})
it('하나라도 없으면 실시간을 쓰지 않는다 (지금의 1분 확인만)', () => {
expect(supportsMailRealtime({ ...fullScope(), navigator: {} })).toBe(false)
expect(supportsMailRealtime({ ...fullScope(), navigator: { locks: {} } })).toBe(false)
expect(supportsMailRealtime({ ...fullScope(), BroadcastChannel: undefined })).toBe(false)
expect(supportsMailRealtime({ ...fullScope(), ReadableStream: undefined })).toBe(false)
expect(supportsMailRealtime({})).toBe(false)
})
it('바탕화면 알림은 Notification 과 실시간이 함께 있어야 한다', () => {
expect(supportsDesktopNotification(fullScope())).toBe(true)
expect(supportsDesktopNotification({ ...fullScope(), Notification: undefined })).toBe(false)
expect(supportsDesktopNotification({ ...fullScope(), navigator: {} })).toBe(false)
})
it('테스트 환경(jsdom)의 전역에는 Web Locks 가 없다 — 앱 전체 테스트는 1분 확인만 한다', () => {
expect(supportsMailRealtime()).toBe(false)
})
})
@@ -0,0 +1,31 @@
// 이 브라우저가 쓸 수 있는 기능 — 전역 객체를 받아 본다 (기본은 globalThis, 테스트는 가짜 전역을 넘긴다)
// 실시간 새 메일(계약 2절 "실시간 알림")은 Web Locks(탭 하나만 연결) · BroadcastChannel(소식 나누기) · fetch 스트림이 다 있어야 한다.
// 하나라도 없으면 실시간 없이 지금의 1분 확인만 한다. 바탕화면 알림은 실시간 소식으로만 오므로 실시간도 쓸 수 있어야 한다.
/** 보는 것만 적은 전역 — 값이 함수인지만 확인한다 */
export interface BrowserScope {
navigator?: { locks?: { request?: unknown } }
BroadcastChannel?: unknown
fetch?: unknown
ReadableStream?: unknown
TextDecoder?: unknown
AbortController?: unknown
Notification?: unknown
}
const browserGlobals = globalThis as unknown as BrowserScope
export function supportsMailRealtime(scope: BrowserScope = browserGlobals): boolean {
return (
typeof scope.navigator?.locks?.request === 'function' &&
typeof scope.BroadcastChannel === 'function' &&
typeof scope.fetch === 'function' &&
typeof scope.ReadableStream === 'function' &&
typeof scope.TextDecoder === 'function' &&
typeof scope.AbortController === 'function'
)
}
export function supportsDesktopNotification(scope: BrowserScope = browserGlobals): boolean {
return typeof scope.Notification === 'function' && supportsMailRealtime(scope)
}
@@ -0,0 +1,166 @@
import { describe, expect, it } from 'vitest'
import type { NewMailItem, NewMailSignal } from './mail-events'
import {
DEFAULT_MAIL_NOTIFY_SETTINGS,
newMailNotificationContent,
newMailSenders,
notifyEnableState,
parseMailNotifySettings,
shouldOfferDesktopNotify,
} from './desktop-notify'
// 바탕화면 알림의 규칙 (디자인 README "5단계 › 바탕화면 알림 · 목록 위에서 한 번 권하기 · 설정 › 메일 › 알림")
function item(id: string, name: string | null, email: string, subject = `제목 ${id}`): NewMailItem {
return { id, from: { name, email }, subject, receivedAt: '2026-10-28T05:42:00.000Z' }
}
function signal(messages: NewMailItem[], count = messages.length): NewMailSignal {
return { count, messages }
}
const SEOJUN = 'seojun.park@arasystem.it.kr'
const DOYUN = 'doyun.lee@arasystem.it.kr'
const JIHO = 'jiho.choi@arasystem.it.kr'
describe('newMailNotificationContent (알림 제목 · 내용)', () => {
it('한 통 — 제목은 보낸 사람 이름, 내용은 메일 제목, 누르면 그 메일', () => {
const content = newMailNotificationContent(signal([item('m1', '박서준', SEOJUN, '11월 납품분 견적서 보내 드립니다')]), {
showSubject: true,
})
expect(content).toEqual({
title: '박서준',
body: '11월 납품분 견적서 보내 드립니다',
target: { kind: 'message', messageId: 'm1' },
})
})
it('한 통 — 이름이 없으면 주소, 제목이 비었으면 목록과 같은 "(제목 없음)"', () => {
expect(newMailNotificationContent(signal([item('m1', null, SEOJUN, '')]), { showSubject: true })).toMatchObject({
title: SEOJUN,
body: '(제목 없음)',
})
expect(newMailNotificationContent(signal([item('m1', ' ', SEOJUN, ' ')]), { showSubject: true })).toMatchObject({
title: SEOJUN,
body: '(제목 없음)',
})
})
it('한 통 — 「메일 제목도 보이기」를 끄면 내용은 "새 메일이 왔습니다"', () => {
expect(newMailNotificationContent(signal([item('m1', '박서준', SEOJUN, '비밀 제목')]), { showSubject: false })).toEqual({
title: '박서준',
body: '새 메일이 왔습니다',
target: { kind: 'message', messageId: 'm1' },
})
})
it('여러 통 — "새 메일 N통" · "박서준 · 이도윤 외 1명", 누르면 받은편지함 (제목 숨김이어도 같다)', () => {
const three = signal([item('m3', '박서준', SEOJUN), item('m2', '이도윤', DOYUN), item('m1', '최지호', JIHO)])
for (const showSubject of [true, false]) {
expect(newMailNotificationContent(three, { showSubject })).toEqual({
title: '새 메일 3통',
body: '박서준 · 이도윤 외 1명',
target: { kind: 'inbox' },
})
}
})
it('여러 통 — 전체 수는 count 로 센다 (서버는 새것부터 5통까지만 보낸다)', () => {
const many = signal([item('m9', '박서준', SEOJUN), item('m8', '이도윤', DOYUN)], 1234)
expect(newMailNotificationContent(many, { showSubject: true })).toMatchObject({
title: '새 메일 1,234통',
body: '박서준 · 이도윤',
})
})
it('보낼 것이 없으면 null', () => {
expect(newMailNotificationContent(signal([], 0), { showSubject: true })).toBeNull()
})
})
describe('newMailSenders (여러 통의 보낸 사람)', () => {
it('같은 사람은 한 번만 — 둘까지 이름, 나머지는 사람 수', () => {
expect(newMailSenders(signal([item('m3', '박서준', SEOJUN), item('m2', '박서준', SEOJUN.toUpperCase())]))).toBe('박서준')
expect(newMailSenders(signal([item('m3', '박서준', SEOJUN), item('m2', null, DOYUN)]))).toBe(`박서준 · ${DOYUN}`)
expect(
newMailSenders(
signal([
item('m5', '박서준', SEOJUN),
item('m4', '이도윤', DOYUN),
item('m3', '박서준', SEOJUN),
item('m2', '최지호', JIHO),
item('m1', '한예린', 'yerin.han@arasystem.it.kr'),
]),
),
).toBe('박서준 · 이도윤 외 2명')
})
it('보낸 사람이 없는 메일은 건너뛴다', () => {
expect(
newMailSenders(signal([{ id: 'm2', from: null, subject: '', receivedAt: '' }, item('m1', '이도윤', DOYUN)])),
).toBe('이도윤')
})
})
describe('shouldOfferDesktopNotify (목록 위에서 한 번 권하기)', () => {
const settings = { enabled: false, offerDismissed: false }
it('브라우저가 아직 묻지 않았고 · 켜지 않았고 · 닫은 적이 없을 때만 보인다', () => {
expect(shouldOfferDesktopNotify({ permission: 'default', settings })).toBe(true)
expect(shouldOfferDesktopNotify({ permission: 'default', settings: { ...settings, enabled: true } })).toBe(false)
expect(shouldOfferDesktopNotify({ permission: 'default', settings: { ...settings, offerDismissed: true } })).toBe(false)
})
it('브라우저가 이미 막았거나 · 허락했거나 · 지원하지 않으면 보이지 않는다', () => {
expect(shouldOfferDesktopNotify({ permission: 'denied', settings })).toBe(false)
expect(shouldOfferDesktopNotify({ permission: 'granted', settings })).toBe(false)
expect(shouldOfferDesktopNotify({ permission: 'unsupported', settings })).toBe(false)
})
})
describe('notifyEnableState (설정의 켜기 칸)', () => {
it('묻기 전 — 칸은 비어 있고 누를 수 있다 (켠 적이 있어도 브라우저가 허락을 거두었으면 비운다)', () => {
expect(notifyEnableState('default', { enabled: false })).toEqual({ checked: false, disabled: false, note: 'ask' })
expect(notifyEnableState('default', { enabled: true })).toEqual({ checked: false, disabled: false, note: 'ask' })
})
it('허용됨 — 켰으면 체크, 꺼 두었으면 비운 채 "허용되어 있습니다"', () => {
expect(notifyEnableState('granted', { enabled: true })).toEqual({ checked: true, disabled: false, note: 'granted' })
expect(notifyEnableState('granted', { enabled: false })).toEqual({ checked: false, disabled: false, note: 'granted' })
})
it('막힘 · 지원하지 않음 — 칸을 끄고 누를 수 없다', () => {
expect(notifyEnableState('denied', { enabled: true })).toEqual({ checked: false, disabled: true, note: 'blocked' })
expect(notifyEnableState('unsupported', { enabled: false })).toEqual({
checked: false,
disabled: true,
note: 'unsupported',
})
})
})
describe('parseMailNotifySettings (저장한 설정 읽기)', () => {
it('없거나 깨졌으면 기본값 — 꺼짐 · 제목 보이기 · 권하기 닫지 않음', () => {
expect(DEFAULT_MAIL_NOTIFY_SETTINGS).toEqual({ enabled: false, showSubject: true, offerDismissed: false })
expect(parseMailNotifySettings(null)).toEqual(DEFAULT_MAIL_NOTIFY_SETTINGS)
expect(parseMailNotifySettings('{깨짐')).toEqual(DEFAULT_MAIL_NOTIFY_SETTINGS)
expect(parseMailNotifySettings('"on"')).toEqual(DEFAULT_MAIL_NOTIFY_SETTINGS)
})
it('항목마다 읽고, 모양이 틀린 항목만 기본값', () => {
expect(parseMailNotifySettings('{"enabled":true,"showSubject":false,"offerDismissed":true}')).toEqual({
enabled: true,
showSubject: false,
offerDismissed: true,
})
expect(parseMailNotifySettings('{"enabled":"yes","showSubject":false}')).toEqual({
enabled: false,
showSubject: false,
offerDismissed: false,
})
})
})
+142
View File
@@ -0,0 +1,142 @@
// 바탕화면 알림의 규칙 — 알림 제목 · 내용, 목록 위 권하기를 보일지, 설정 화면의 켜기 칸, 저장한 설정 읽기 (순수 함수만 둔다)
// 디자인 README "5단계 › 바탕화면 알림 · 목록 위에서 한 번 권하기 · 설정 › 메일 › 알림" · 계약 2절 "실시간 알림"
// 알림을 띄우는 일은 composables/useDesktopNotification.ts, 보낼지 정하는 일은 composables/useNewMailNotifier.ts 가 한다.
import { MAIL_NO_SUBJECT } from '@/shared/constants/mail'
import { DESKTOP_NOTIFY_NAMED_SENDERS, MAIL_NOTIFY_TEXT } from '@/shared/constants/mail-notify'
import { displayName, normalizeEmail } from '@/shared/utils/mail-address'
import { formatCount } from '@/shared/utils/format'
import type { NewMailSignal } from '@/shared/utils/mail-events'
/** 브라우저의 알림 허락 — 지원하지 않으면 'unsupported' */
export type DesktopNotifyPermission = NotificationPermission | 'unsupported'
/** 브라우저 · 사용자마다 저장하는 설정 (localStorage) */
export interface MailNotifySettings {
/** 새 메일이 오면 바탕화면 알림 보내기 — 기본 꺼짐 */
enabled: boolean
/** 메일 제목도 보이기 — 기본 켬 (끄면 보낸 사람만) */
showSubject: boolean
/** 목록 위의 권하기 줄을 닫았다 — 다시 보이지 않는다 */
offerDismissed: boolean
}
export const DEFAULT_MAIL_NOTIFY_SETTINGS: Readonly<MailNotifySettings> = {
enabled: false,
showSubject: true,
offerDismissed: false,
}
/** 저장한 값을 읽는다 — 없거나 깨졌으면 기본값, 항목마다 모양이 틀리면 그 항목만 기본값 */
export function parseMailNotifySettings(raw: string | null): MailNotifySettings {
if (raw === null) return { ...DEFAULT_MAIL_NOTIFY_SETTINGS }
let value: unknown
try {
value = JSON.parse(raw)
} catch {
return { ...DEFAULT_MAIL_NOTIFY_SETTINGS }
}
if (typeof value !== 'object' || value === null) return { ...DEFAULT_MAIL_NOTIFY_SETTINGS }
const stored = value as Partial<Record<keyof MailNotifySettings, unknown>>
const pick = (key: keyof MailNotifySettings): boolean => {
const field = stored[key]
return typeof field === 'boolean' ? field : DEFAULT_MAIL_NOTIFY_SETTINGS[key]
}
return { enabled: pick('enabled'), showSubject: pick('showSubject'), offerDismissed: pick('offerDismissed') }
}
// ── 새 메일 알림 ───────────────────────────────────────
/** 알림을 누르면 갈 곳 — 한 통이면 그 메일, 여러 통이면 받은편지함 */
export type NewMailNotificationTarget = { kind: 'message'; messageId: string } | { kind: 'inbox' }
export interface NewMailNotificationContent {
title: string
body: string
target: NewMailNotificationTarget
}
/**
* "박서준 · 이도윤 외 1명" — 받은 메일(새것부터)의 보낸 사람을 겹치지 않게 앞에서 둘까지, 나머지는 사람 수로.
* 서버가 보낸 메일(최대 5통)의 보낸 사람으로만 센다 — 그보다 많이 왔으면 나머지 메일의 보낸 사람은 알 수 없다
*/
export function newMailSenders(signal: NewMailSignal): string {
const seen = new Set<string>()
const names: string[] = []
for (const message of signal.messages) {
if (!message.from) continue
const key = normalizeEmail(message.from.email)
if (seen.has(key)) continue
seen.add(key)
names.push(displayName(message.from))
}
const shown = names.slice(0, DESKTOP_NOTIFY_NAMED_SENDERS).join(MAIL_NOTIFY_TEXT.senderSeparator)
const rest = names.length - DESKTOP_NOTIFY_NAMED_SENDERS
return rest > 0 ? MAIL_NOTIFY_TEXT.sendersMore(shown, formatCount(rest)) : shown
}
/**
* 새 메일 알림의 제목 · 내용 (디자인 원본 MailRealtime)
* - 한 통: 제목 = 보낸 사람(이름, 없으면 주소) · 내용 = 메일 제목(비면 목록과 같은 "(제목 없음)"). 제목 숨김이면 "새 메일이 왔습니다"
* - 여러 통: "새 메일 3통" · "박서준 · 이도윤 외 1명" (제목 숨김이어도 그대로 — 제목이 들어가지 않는다)
* 보낼 것이 없으면 null
*/
export function newMailNotificationContent(
signal: NewMailSignal,
options: { showSubject: boolean },
): NewMailNotificationContent | null {
if (signal.count <= 0) return null
const [first] = signal.messages
if (signal.count === 1 && first) {
const title = displayName(first.from) || MAIL_NOTIFY_TEXT.manyTitle(formatCount(1))
const subject = first.subject.trim() ? first.subject : MAIL_NO_SUBJECT
return {
title,
body: options.showSubject ? subject : MAIL_NOTIFY_TEXT.hiddenSubject,
target: { kind: 'message', messageId: first.id },
}
}
return {
title: MAIL_NOTIFY_TEXT.manyTitle(formatCount(signal.count)),
body: newMailSenders(signal),
target: { kind: 'inbox' },
}
}
// ── 목록 위에서 한 번 권하기 · 설정 화면의 켜기 칸 ─────────────
/**
* 목록 위의 권하기 줄을 보일지 — 지원하고, 브라우저가 아직 묻지 않았고(default), 켜지 않았고, 닫은 적이 없을 때.
* 브라우저가 이미 막았거나(denied) 허락했으면(granted) 보이지 않는다
*/
export function shouldOfferDesktopNotify(state: {
permission: DesktopNotifyPermission
settings: Pick<MailNotifySettings, 'enabled' | 'offerDismissed'>
}): boolean {
return state.permission === 'default' && !state.settings.enabled && !state.settings.offerDismissed
}
/** 켜기 칸 아래 안내 — 묻기 전 · 허용됨 · 막힘 · 지원하지 않음 */
export type NotifyEnableNote = 'ask' | 'granted' | 'blocked' | 'unsupported'
export interface NotifyEnableState {
/** 켜기 칸에 체크 — 설정을 켰고 브라우저가 허락했을 때만 */
checked: boolean
/** 켜기 칸을 누를 수 없다 — 막힘 · 지원하지 않음 */
disabled: boolean
note: NotifyEnableNote
}
/**
* 설정 › 메일 › 알림의 켜기 칸 (디자인 원본 SettingsNotify · MailRealtime "막혔을 때 · 지원하지 않을 때").
* 허락했는데 설정을 꺼 둔 채면 칸은 비우고 안내는 "허용되어 있습니다"다 — 다시 켤 때 브라우저가 묻지 않는다
*/
export function notifyEnableState(
permission: DesktopNotifyPermission,
settings: Pick<MailNotifySettings, 'enabled'>,
): NotifyEnableState {
if (permission === 'unsupported') return { checked: false, disabled: true, note: 'unsupported' }
if (permission === 'denied') return { checked: false, disabled: true, note: 'blocked' }
if (permission === 'granted') return { checked: settings.enabled, disabled: false, note: 'granted' }
return { checked: false, disabled: false, note: 'ask' }
}
@@ -0,0 +1,87 @@
import { describe, expect, it } from 'vitest'
import { parseMailEvent } from './mail-events'
// 실시간 알림의 서버 이벤트 (계약 2절 "실시간 알림") — 모양이 틀린 것은 버린다
const event = (name: string, data: string) => ({ event: name, data, id: null })
describe('parseMailEvent (실시간 알림의 서버 이벤트)', () => {
it('state — state 와 받은편지함의 안 읽은 수', () => {
expect(parseMailEvent(event('state', '{"state":"s1","inboxUnread":3}'))).toEqual({
type: 'state',
state: 's1',
inboxUnread: 3,
})
})
it('state — 안 읽은 수가 없거나 이상하면 null, state 가 없으면 버린다', () => {
expect(parseMailEvent(event('state', '{"state":"s1"}'))).toMatchObject({ inboxUnread: null })
expect(parseMailEvent(event('state', '{"state":"s1","inboxUnread":-1}'))).toMatchObject({ inboxUnread: null })
expect(parseMailEvent(event('state', '{"state":"s1","inboxUnread":1.5}'))).toMatchObject({ inboxUnread: null })
expect(parseMailEvent(event('state', '{"inboxUnread":3}'))).toBeNull()
expect(parseMailEvent(event('state', '{"state":""}'))).toBeNull()
expect(parseMailEvent(event('state', 'not json'))).toBeNull()
expect(parseMailEvent(event('state', '["s1"]'))).toBeNull()
})
it('new-mail — 메일 목록과 전체 수 (보낸 사람 · 제목이 없으면 null · 빈 글)', () => {
const parsed = parseMailEvent(
event(
'new-mail',
JSON.stringify({
count: 7,
messages: [
{
id: 'm1',
from: { name: '박서준', email: 'seojun.park@arasystem.it.kr' },
subject: '견적서',
receivedAt: '2026-10-28T05:42:00.000Z',
},
{ id: 'm2', from: null, receivedAt: '2026-10-28T05:41:00.000Z' },
{ id: 'm3', from: { email: 'doyun.lee@arasystem.it.kr' }, subject: '' },
],
}),
),
)
expect(parsed).toEqual({
type: 'new-mail',
count: 7,
messages: [
{
id: 'm1',
from: { name: '박서준', email: 'seojun.park@arasystem.it.kr' },
subject: '견적서',
receivedAt: '2026-10-28T05:42:00.000Z',
},
{ id: 'm2', from: null, subject: '', receivedAt: '2026-10-28T05:41:00.000Z' },
{ id: 'm3', from: { name: null, email: 'doyun.lee@arasystem.it.kr' }, subject: '', receivedAt: '' },
],
})
})
it('new-mail — id 없는 메일은 빼고, 전체 수가 이상하면 받은 메일 수로 센다 · 하나도 없으면 버린다', () => {
expect(
parseMailEvent(event('new-mail', '{"count":"x","messages":[{"id":"m1"},{"subject":"id 없음"},null]}')),
).toMatchObject({ count: 1, messages: [{ id: 'm1' }] })
expect(parseMailEvent(event('new-mail', '{"count":0,"messages":[]}'))).toBeNull()
expect(parseMailEvent(event('new-mail', '{"count":2}'))).toBeNull()
})
it('ping — upstream: false 일 때만 끊김이다 (모양이 틀려도 살아 있다는 신호)', () => {
expect(parseMailEvent(event('ping', '{"upstream":true}'))).toEqual({ type: 'ping', upstream: true })
expect(parseMailEvent(event('ping', '{"upstream":false}'))).toEqual({ type: 'ping', upstream: false })
expect(parseMailEvent(event('ping', ''))).toEqual({ type: 'ping', upstream: true })
})
it('end — 까닭 (없으면 빈 글)', () => {
expect(parseMailEvent(event('end', '{"reason":"token-expiring"}'))).toEqual({ type: 'end', reason: 'token-expiring' })
expect(parseMailEvent(event('end', ''))).toEqual({ type: 'end', reason: '' })
})
it('모르는 이벤트는 버린다', () => {
expect(parseMailEvent(event('message', '{"state":"s1"}'))).toBeNull()
expect(parseMailEvent(event('mailbox-changed', '{}'))).toBeNull()
})
})
+110
View File
@@ -0,0 +1,110 @@
// 실시간 알림의 서버 이벤트 읽기 — 계약 2절 "실시간 알림 (GET /api/mail-events)" (순수 함수만 둔다)
// SSE 이벤트(sse-parser.ts)의 data(JSON)를 모양까지 확인해 아래 네 가지로 바꾼다. 모르는 이벤트 · 모양이 틀린 것은 null (버린다).
// state — 메일함 · 메일이 바뀌었다 (GET /mail-state 의 state 와 같은 값) + 받은편지함의 안 읽은 수
// new-mail — 받은편지함에 새로 배달된 메일 (새것부터 최대 5통, count 는 전체 수)
// ping — 25초마다. upstream: false 면 서버와 메일 엔진 사이가 끊겨 다시 잇는 중이다
// end — 서버가 곧 닫는다 (token-expiring · server-shutdown)
import type { Address } from '@/shared/types/mail'
import type { SseEvent } from '@/shared/utils/sse-parser'
export interface MailStateSignal {
state: string
/** 받은편지함의 안 읽은 수 — 모르면 null */
inboxUnread: number | null
}
export interface NewMailItem {
id: string
from: Address | null
/** 제목이 없으면 빈 문자열 */
subject: string
receivedAt: string
}
export interface NewMailSignal {
/** 새로 온 메일 수 — messages 보다 많을 수 있다 (서버는 새것부터 5통까지만 보낸다) */
count: number
messages: NewMailItem[]
}
export type MailServerEvent =
| ({ type: 'state' } & MailStateSignal)
| ({ type: 'new-mail' } & NewMailSignal)
| { type: 'ping'; upstream: boolean }
| { type: 'end'; reason: string }
type JsonObject = Record<string, unknown>
function parseObject(data: string): JsonObject | null {
try {
const value: unknown = JSON.parse(data)
return typeof value === 'object' && value !== null && !Array.isArray(value) ? (value as JsonObject) : null
} catch {
return null
}
}
function isCount(value: unknown): value is number {
return typeof value === 'number' && Number.isInteger(value) && value >= 0
}
function addressOf(value: unknown): Address | null {
if (typeof value !== 'object' || value === null) return null
const candidate = value as JsonObject
if (typeof candidate.email !== 'string' || candidate.email.trim() === '') return null
const name = typeof candidate.name === 'string' ? candidate.name : null
return { name, email: candidate.email }
}
function newMailItemOf(value: unknown): NewMailItem | null {
if (typeof value !== 'object' || value === null) return null
const candidate = value as JsonObject
if (typeof candidate.id !== 'string' || candidate.id === '') return null
return {
id: candidate.id,
from: addressOf(candidate.from),
subject: typeof candidate.subject === 'string' ? candidate.subject : '',
receivedAt: typeof candidate.receivedAt === 'string' ? candidate.receivedAt : '',
}
}
function stateOf(data: string): MailServerEvent | null {
const body = parseObject(data)
if (!body || typeof body.state !== 'string' || body.state === '') return null
return { type: 'state', state: body.state, inboxUnread: isCount(body.inboxUnread) ? body.inboxUnread : null }
}
function newMailOf(data: string): MailServerEvent | null {
const body = parseObject(data)
if (!body || !Array.isArray(body.messages)) return null
const messages = body.messages.map(newMailItemOf).filter((item): item is NewMailItem => item !== null)
// 전체 수가 이상하면 받은 메일 수로 센다
const count = Math.max(isCount(body.count) ? body.count : 0, messages.length)
return count > 0 ? { type: 'new-mail', count, messages } : null
}
/** ping 은 모양이 틀려도 살아 있다는 신호로 본다 — 끊겼다는 것은 upstream: false 로만 안다 */
function pingOf(data: string): MailServerEvent {
return { type: 'ping', upstream: parseObject(data)?.upstream !== false }
}
function endOf(data: string): MailServerEvent {
const reason = parseObject(data)?.reason
return { type: 'end', reason: typeof reason === 'string' ? reason : '' }
}
export function parseMailEvent(event: SseEvent): MailServerEvent | null {
switch (event.event) {
case 'state':
return stateOf(event.data)
case 'new-mail':
return newMailOf(event.data)
case 'ping':
return pingOf(event.data)
case 'end':
return endOf(event.data)
default:
return null
}
}
@@ -0,0 +1,111 @@
import { describe, expect, it } from 'vitest'
import {
mailEventsLockName,
packRealtimeMessage,
realtimeLinkText,
reconnectDelayMs,
unpackRealtimeMessage,
} from './mail-realtime'
// 실시간 연결의 규칙 (계약 2절 "실시간 알림" — 클라이언트)
describe('realtimeLinkText (설정 › 알림의 실시간 연결 줄)', () => {
it('연결됨은 초록 점과 "연결되어 있습니다", 끊김 · 지원 안 함은 "연결할 수 없어 1분마다 확인합니다."', () => {
expect(realtimeLinkText('live')).toMatchObject({
text: '연결되어 있습니다 — 새 메일이 오면 바로 목록에 나타납니다.',
notice: null,
live: true,
})
expect(realtimeLinkText('down')).toEqual({
text: '연결할 수 없어 1분마다 확인합니다.',
notice: '연결될 때까지 바탕화면 알림은 오지 않습니다.',
hint: '저절로 다시 잇습니다. 회사 방화벽 · 프록시가 오래 열린 연결을 끊으면 이렇게 됩니다.',
live: false,
})
expect(realtimeLinkText('unsupported')).toEqual({
text: '연결할 수 없어 1분마다 확인합니다.',
notice: null,
hint: '이 브라우저는 실시간 연결을 지원하지 않습니다.',
live: false,
})
expect(realtimeLinkText('connecting')).toMatchObject({ text: '연결하는 중입니다…', notice: null, live: false })
expect(realtimeLinkText('off')).toMatchObject({ text: '연결할 수 없어 1분마다 확인합니다.', live: false })
})
})
describe('reconnectDelayMs (다시 잇는 간격)', () => {
it('2 · 5 · 15 · 30 · 60초, 그 뒤로는 60초에서 멈춘다 (가운데 값이면 흔들림 없음)', () => {
expect([0, 1, 2, 3, 4, 5, 9].map((attempt) => reconnectDelayMs(attempt, 0.5))).toEqual([
2_000, 5_000, 15_000, 30_000, 60_000, 60_000, 60_000,
])
})
it('±20% 흔들림 — 0 이면 80%, 1 이면 120% (범위 밖의 값은 끝에 맞춘다)', () => {
expect(reconnectDelayMs(0, 0)).toBe(1_600)
expect(reconnectDelayMs(0, 1)).toBe(2_400)
expect(reconnectDelayMs(4, 0)).toBe(48_000)
expect(reconnectDelayMs(4, 1)).toBe(72_000)
expect(reconnectDelayMs(0, -3)).toBe(1_600)
expect(reconnectDelayMs(0, 7)).toBe(2_400)
expect(reconnectDelayMs(-1, 0.5)).toBe(2_000)
})
})
describe('mailEventsLockName (연결을 맡을 탭의 잠금)', () => {
it('사용자 주소를 붙인다 — 대소문자 · 앞뒤 빈칸은 같은 사용자', () => {
expect(mailEventsLockName('ara.kim@arasystem.it.kr')).toBe('arasystem.mail-events:ara.kim@arasystem.it.kr')
expect(mailEventsLockName(' Ara.Kim@ArASystem.it.kr ')).toBe(mailEventsLockName('ara.kim@arasystem.it.kr'))
expect(mailEventsLockName('doyun.lee@arasystem.it.kr')).not.toBe(mailEventsLockName('ara.kim@arasystem.it.kr'))
})
})
describe('탭끼리 주고받는 메시지', () => {
const EMAIL = 'ara.kim@arasystem.it.kr'
it('싸서 보낸 메시지를 같은 사용자는 그대로 읽는다', () => {
const messages = [
{ type: 'ping', upstream: false },
{ type: 'state', state: 's2', inboxUnread: 4 },
{ type: 'state', state: 's3', inboxUnread: null },
{ type: 'baseline', state: 's3', inboxUnread: 1 },
{ type: 'down' },
{ type: 'hello' },
{ type: 'visibility-query', id: 'q1' },
{ type: 'visibility-reply', id: 'q1' },
{ type: 'attended' },
] as const
for (const message of messages) {
expect(unpackRealtimeMessage(packRealtimeMessage(EMAIL, message), EMAIL)).toEqual(message)
}
expect(unpackRealtimeMessage(packRealtimeMessage('Ara.Kim@arasystem.it.kr', { type: 'hello' }), EMAIL)).toEqual({
type: 'hello',
})
})
it('다른 계정 · 다른 앱 · 다른 판 · 모양이 틀린 메시지는 버린다', () => {
const packed = packRealtimeMessage(EMAIL, { type: 'state', state: 's1', inboxUnread: 1 })
expect(unpackRealtimeMessage(packed, 'doyun.lee@arasystem.it.kr')).toBeNull()
expect(unpackRealtimeMessage({ ...packed, app: 'arasystem.auth' }, EMAIL)).toBeNull()
expect(unpackRealtimeMessage({ ...packed, v: 2 }, EMAIL)).toBeNull()
expect(unpackRealtimeMessage({ type: 'logout' }, EMAIL)).toBeNull()
expect(unpackRealtimeMessage('ping', EMAIL)).toBeNull()
expect(unpackRealtimeMessage({ ...packed, message: { type: 'state', state: '' } }, EMAIL)).toBeNull()
expect(unpackRealtimeMessage({ ...packed, message: { type: 'ping' } }, EMAIL)).toBeNull()
expect(unpackRealtimeMessage({ ...packed, message: { type: 'visibility-reply' } }, EMAIL)).toBeNull()
expect(unpackRealtimeMessage({ ...packed, message: { type: 'baseline' } }, EMAIL)).toBeNull()
expect(unpackRealtimeMessage({ ...packed, message: { type: 'new-mail' } }, EMAIL)).toBeNull()
})
it('state 의 안 읽은 수가 이상하면 null 로 읽는다', () => {
const packed = packRealtimeMessage(EMAIL, { type: 'state', state: 's1', inboxUnread: 1 })
expect(unpackRealtimeMessage({ ...packed, message: { type: 'state', state: 's1', inboxUnread: -2 } }, EMAIL)).toEqual({
type: 'state',
state: 's1',
inboxUnread: null,
})
})
})
+148
View File
@@ -0,0 +1,148 @@
// 실시간 연결의 규칙 — 다시 잇는 간격 · 잠금 이름 · 탭끼리 주고받는 메시지 (순수 함수만 둔다)
// 계약 2절 "실시간 알림" 의 클라이언트 규칙: 브라우저마다 연결 하나(Web Locks), 소식은 BroadcastChannel 로 다른 탭에 나눈다.
// 연결은 composables/useMailRealtime.ts 가 한다.
import {
MAIL_EVENTS_LOCK_PREFIX,
MAIL_EVENTS_RECONNECT_DELAYS_MS,
MAIL_EVENTS_RECONNECT_JITTER,
MAIL_NOTIFY_TEXT,
} from '@/shared/constants/mail-notify'
import { normalizeEmail } from '@/shared/utils/mail-address'
/**
* 이 탭이 보는 실시간 연결 상태
* - off: 로그인하지 않았다 · unsupported: 이 브라우저는 실시간을 쓰지 않는다(1분 확인만)
* - connecting: 처음 잇는 중 · live: 75초 안에 ping · state 가 왔고 서버 · 엔진 사이도 이어져 있다 · down: 그 밖(1분 확인으로 돌아간다)
*/
export type MailRealtimeStatus = 'off' | 'unsupported' | 'connecting' | 'live' | 'down'
/** 설정 › 알림의 "실시간 연결" 줄 */
export interface RealtimeLinkText {
/** 상태 줄 (점 옆) */
text: string
/** 상태 줄 바로 아래의 알림 — 끊긴 동안 "연결될 때까지 바탕화면 알림은 오지 않습니다." (없으면 null) */
notice: string | null
/** 맨 아래 안내 (12.5px) */
hint: string
/** 초록 점 */
live: boolean
}
/** 설정 › 알림의 "실시간 연결" 줄 — 상태 · 끊긴 동안의 알림 · 아래 안내 · 초록 점인지 (디자인 원본 SettingsNotify) */
export function realtimeLinkText(status: MailRealtimeStatus): RealtimeLinkText {
switch (status) {
case 'live':
return { text: MAIL_NOTIFY_TEXT.realtimeLive, notice: null, hint: MAIL_NOTIFY_TEXT.realtimeLiveHint, live: true }
case 'connecting':
return {
text: MAIL_NOTIFY_TEXT.realtimeConnecting,
notice: null,
hint: MAIL_NOTIFY_TEXT.realtimeLiveHint,
live: false,
}
case 'unsupported':
return {
text: MAIL_NOTIFY_TEXT.realtimeDown,
notice: null,
hint: MAIL_NOTIFY_TEXT.realtimeUnsupportedHint,
live: false,
}
default:
return {
text: MAIL_NOTIFY_TEXT.realtimeDown,
notice: MAIL_NOTIFY_TEXT.realtimeDownNotice,
hint: MAIL_NOTIFY_TEXT.realtimeDownHint,
live: false,
}
}
}
/**
* 끊긴 뒤 다시 잇기까지 — attempt 는 연결이 열린 뒤 이어서 실패한 횟수(0부터). 2 · 5 · 15 · 30 · 60초에 ±20% 흔들림.
* random 은 0~1 (Math.random — 테스트는 고정값을 넘긴다)
*/
export function reconnectDelayMs(attempt: number, random: number): number {
const delays = MAIL_EVENTS_RECONNECT_DELAYS_MS
const last = delays.length - 1
const base = delays[Math.min(Math.max(0, Math.floor(attempt)), last)] ?? delays[last] ?? 0
const spread = Math.min(1, Math.max(0, random))
const factor = 1 - MAIL_EVENTS_RECONNECT_JITTER + 2 * MAIL_EVENTS_RECONNECT_JITTER * spread
return Math.round(base * factor)
}
/** 연결을 맡을 탭을 고르는 잠금 이름 — 사용자마다 따로 (같은 브라우저에서 계정을 바꿔도 섞이지 않게) */
export function mailEventsLockName(email: string): string {
return `${MAIL_EVENTS_LOCK_PREFIX}${normalizeEmail(email)}`
}
// ── 탭끼리 주고받는 메시지 ──────────────────────────────────
// 연결한 탭 → 모든 탭: ping · state · down (모든 탭이 같은 규칙으로 연결 상태를 정하고 바뀜을 맞춘다)
// 잠금을 막 받은 탭도 연결하기 전에 down 을 한 번 보낸다 — 앞 탭이 stop 없이 닫혔어도(탭 닫기 · 새로 고침 · 폐기) 다른 탭이 알게
// 새로 연 탭 → 연결한 탭: hello (지금 상태를 다시 보내 달라)
// 연결한 탭 → baseline: 끊긴 채일 때의 마지막 state — 살아 있다는 신호로 세지 않고 기준값으로만 쓴다
// 연결한 탭 → 모든 탭: visibility-query (사용자가 보고 있는 탭이 있는가 — 바탕화면 알림 전에) / 보고 있는 탭 → visibility-reply
// 사용자가 보게 된 탭 → 연결한 탭: attended (띄운 바탕화면 알림을 닫는다)
export type RealtimeMessage =
| { type: 'ping'; upstream: boolean }
| { type: 'state'; state: string; inboxUnread: number | null }
| { type: 'baseline'; state: string; inboxUnread: number | null }
| { type: 'down' }
| { type: 'hello' }
| { type: 'visibility-query'; id: string }
| { type: 'visibility-reply'; id: string }
| { type: 'attended' }
const MESSAGE_APP = 'arasystem.mail-events'
/** 메시지 모양을 바꾸면 올린다 — 배포 전에 연 탭(옛 판)의 메시지를 새 판이 잘못 읽지 않게 */
const MESSAGE_VERSION = 1
/** 채널에 싣는 모양 — app · v 로 이 앱의 메시지인지, email 로 같은 사용자인지 본다 */
interface RealtimeEnvelope {
app: typeof MESSAGE_APP
v: typeof MESSAGE_VERSION
email: string
message: RealtimeMessage
}
export function packRealtimeMessage(email: string, message: RealtimeMessage): RealtimeEnvelope {
return { app: MESSAGE_APP, v: MESSAGE_VERSION, email: normalizeEmail(email), message }
}
type Loose = Record<string, unknown>
function isLoose(value: unknown): value is Loose {
return typeof value === 'object' && value !== null
}
function messageOf(value: unknown): RealtimeMessage | null {
if (!isLoose(value)) return null
switch (value.type) {
case 'ping':
return typeof value.upstream === 'boolean' ? { type: 'ping', upstream: value.upstream } : null
case 'state':
case 'baseline': {
if (typeof value.state !== 'string' || value.state === '') return null
const unread = value.inboxUnread
const inboxUnread = typeof unread === 'number' && Number.isInteger(unread) && unread >= 0 ? unread : null
return { type: value.type, state: value.state, inboxUnread }
}
case 'down':
case 'hello':
case 'attended':
return { type: value.type }
case 'visibility-query':
case 'visibility-reply':
return typeof value.id === 'string' && value.id !== '' ? { type: value.type, id: value.id } : null
default:
return null
}
}
/** 채널로 받은 값을 읽는다 — 이 앱 · 이 판 · 같은 사용자의 메시지가 아니면 null (버린다) */
export function unpackRealtimeMessage(data: unknown, email: string): RealtimeMessage | null {
if (!isLoose(data) || data.app !== MESSAGE_APP || data.v !== MESSAGE_VERSION) return null
if (typeof data.email !== 'string' || data.email !== normalizeEmail(email)) return null
return messageOf(data.message)
}
@@ -0,0 +1,111 @@
import { describe, expect, it } from 'vitest'
import { createSseParser, SSE_MAX_FIELD_LENGTH, SseLimitError, type SseEvent } from './sse-parser'
// SSE 읽기 (계약 2절 "실시간 알림" — text/event-stream) — HTML 표준의 줄 · 필드 규칙
/** 조각들을 차례로 넣고 나온 이벤트를 모두 모은다 */
function parseChunks(chunks: readonly string[]): SseEvent[] {
const parser = createSseParser()
return chunks.flatMap((chunk) => parser.push(chunk))
}
describe('createSseParser (SSE 읽기)', () => {
it('event · data 줄을 빈 줄에서 이벤트 하나로 낸다 — 콜론 뒤 빈칸 하나는 뺀다', () => {
const events = parseChunks(['event: state\ndata: {"state":"s1","inboxUnread":3}\n\n'])
expect(events).toEqual([{ event: 'state', data: '{"state":"s1","inboxUnread":3}', id: null }])
})
it('이름이 없으면 message, data 가 여러 줄이면 줄바꿈으로 잇는다 · 빈칸 하나만 뺀다', () => {
const events = parseChunks(['data: 첫 줄\ndata:둘째 줄\ndata: 셋째(빈칸 둘)\n\n'])
expect(events).toEqual([{ event: 'message', data: '첫 줄\n둘째 줄\n 셋째(빈칸 둘)', id: null }])
})
it('주석 줄(":" 로 시작)은 무시하고 · data 가 없는 이벤트는 내지 않는다', () => {
const events = parseChunks([': keep-alive\n\n', 'event: ping\n\n', ':\n', 'event: ping\ndata: {"upstream":true}\n\n'])
expect(events).toEqual([{ event: 'ping', data: '{"upstream":true}', id: null }])
})
it('빈 data 줄 하나는 빈 글의 이벤트다 · 콜론 없는 줄은 필드 이름만(값은 빈 글)', () => {
const events = parseChunks(['data:\n\n', 'event: end\ndata\n\n'])
expect(events).toEqual([
{ event: 'message', data: '', id: null },
{ event: 'end', data: '', id: null },
])
})
it('CRLF · CR · LF 줄 끝을 모두 받는다', () => {
const events = parseChunks(['event: a\r\ndata: 1\r\n\r\n', 'event: b\rdata: 2\r\r', 'event: c\ndata: 3\n\n'])
expect(events.map((event) => [event.event, event.data])).toEqual([
['a', '1'],
['b', '2'],
['c', '3'],
])
})
it('CRLF 가 조각 경계에서 갈라져도 줄 끝 하나로 본다 — 빈 이벤트를 하나 더 만들지 않는다', () => {
const events = parseChunks(['event: state\r', '\ndata: x\r', '\n\r', '\n'])
expect(events).toEqual([{ event: 'state', data: 'x', id: null }])
})
it('CR 뒤에 빈 조각이 와도 다음 조각의 LF 를 그 CR 에 붙이고, 그 뒤의 LF 는 빈 줄로 센다', () => {
const events = parseChunks(['data: x\r', '', '\n', '\n'])
expect(events).toEqual([{ event: 'message', data: 'x', id: null }])
})
it('조각이 줄 · 필드 이름 · 글자 가운데에서 끊겨도 된다', () => {
const text = 'event: new-mail\ndata: {"count":1,"messages":[{"subject":"견적서"}]}\n\nevent: ping\ndata: {"upstream":false}\n\n'
const oneByOne = parseChunks([...text])
expect(oneByOne).toEqual([
{ event: 'new-mail', data: '{"count":1,"messages":[{"subject":"견적서"}]}', id: null },
{ event: 'ping', data: '{"upstream":false}', id: null },
])
expect(parseChunks([text.slice(0, 7), text.slice(7, 30), text.slice(30)])).toEqual(oneByOne)
})
it('빈 줄로 닫히지 않은 이벤트는 아직 내지 않는다 (스트림이 끝나면 버린다)', () => {
const parser = createSseParser()
expect(parser.push('event: state\ndata: {"state":"s1"}\n')).toEqual([])
expect(parser.push('\n')).toEqual([{ event: 'state', data: '{"state":"s1"}', id: null }])
expect(parser.push('event: state\ndata: 끝나지 않음')).toEqual([])
})
it('맨 앞의 BOM 은 빼고 · 마지막 id 를 이벤트에 붙인다 (NUL 이 든 id 는 무시)', () => {
const events = parseChunks(['id: 7\ndata: a\n\n', 'id: x\0y\ndata: b\n\n'])
expect(events).toEqual([
{ event: 'message', data: 'a', id: '7' },
{ event: 'message', data: 'b', id: '7' },
])
})
it('줄 끝 없이 상한을 넘게 받으면 SseLimitError — 끝없이 붙잡지 않는다 (기본 1MB, 백엔드와 같다)', () => {
expect(SSE_MAX_FIELD_LENGTH).toBe(1_048_576)
const parser = createSseParser(10)
expect(parser.push('data: 12')).toEqual([])
expect(() => parser.push('3456')).toThrow(SseLimitError)
})
it('한 이벤트의 data 가 여러 줄로 상한을 넘어도 SseLimitError — 상한까지는 받는다', () => {
const parser = createSseParser(10)
expect(parser.push('data: 12345\ndata: 1234\n\n')).toEqual([{ event: 'message', data: '12345\n1234', id: null }])
expect(() => parser.push('data: 12345\ndata: 12345\n')).toThrow(SseLimitError)
})
it('이름은 이벤트마다 새로 정한다 — 앞 이벤트의 이름이 다음 이벤트에 남지 않는다', () => {
const events = parseChunks(['event: state\ndata: 1\n\ndata: 2\n\n'])
expect(events.map((event) => event.event)).toEqual(['state', 'message'])
})
})
+120
View File
@@ -0,0 +1,120 @@
// SSE(text/event-stream) 읽기 — 순수 함수만 둔다 (HTML 표준 "Interpreting an event stream" 과 같은 규칙)
// 받은 글 조각을 차례로 넣으면, 그 조각으로 끝난(빈 줄로 닫힌) 이벤트를 돌려준다. 조각이 줄 · 글자 가운데에서 끊겨도 된다.
// - 줄 끝: CRLF · LF · CR — CRLF 가 조각 경계에서 갈라져도(앞 조각 끝 CR, 다음 조각 첫 LF) 줄 끝 하나로 본다
// - 줄 모양: "필드:값"(콜론 뒤 빈칸 하나는 뺀다) · ":" 로 시작하면 주석(무시 — 서버의 연결 유지용) · 콜론이 없으면 필드 이름만(값은 빈 글)
// - 필드: event(이벤트 이름) · data(여러 줄이면 줄바꿈으로 잇는다) · id — 그 밖(retry 등)은 쓰지 않는다
// - 빈 줄에서 이벤트를 낸다. data 줄이 하나도 없으면 내지 않는다(이름만 있는 이벤트). 이름이 없으면 'message'
// - 스트림 맨 앞의 BOM(U+FEFF)은 뺀다
// 빈 줄로 닫히지 않은 채 스트림이 끝난 이벤트는 버린다(표준과 같다) — 부르는 쪽이 따로 할 일은 없다.
// 한 줄(줄 끝을 아직 못 받은 글)이나 한 이벤트의 data 가 상한(maxLength)을 넘으면 SseLimitError — 읽는 쪽이 연결을 실패로 끊는다
// (상대가 줄 끝 없이 끝없이 보내도 메모리를 붙잡지 않게 — 백엔드 shared/utils/sse.ts 와 같은 1MB).
/** 한 줄 · 한 이벤트 data 의 최대 길이 (JS 문자열 길이) — 실시간 알림의 이벤트는 수백 자다 */
export const SSE_MAX_FIELD_LENGTH = 1_048_576
/** 한 줄이나 한 이벤트가 상한을 넘었다 */
export class SseLimitError extends Error {
constructor() {
super('SSE 줄 · 이벤트가 상한을 넘었다')
this.name = 'SseLimitError'
}
}
export interface SseEvent {
/** 이벤트 이름 — event 줄이 없으면 'message' */
event: string
/** data 줄들을 줄바꿈(\n)으로 이은 것 */
data: string
/** 마지막으로 받은 id — 없으면 null (이 앱은 쓰지 않는다) */
id: string | null
}
export interface SseParser {
/** 받은 글 조각을 넣는다 — 이 조각으로 끝난 이벤트를 받은 차례대로 돌려준다 */
push: (chunk: string) => SseEvent[]
}
const DEFAULT_EVENT_NAME = 'message'
const BYTE_ORDER_MARK = 0xfeff
const LF = '\n'
const CR = '\r'
export function createSseParser(maxLength = SSE_MAX_FIELD_LENGTH): SseParser {
/** 아직 줄 끝을 받지 못한 글 */
let buffer = ''
/** 앞 조각이 CR 로 끝났다 — 다음 조각의 첫 LF 는 그 CR 과 한 줄 끝(CRLF)이다 */
let pendingCr = false
let started = false
let eventName = ''
let dataLines: string[] = []
/** 지금 이벤트의 data 길이 (줄바꿈 포함) */
let dataLength = 0
let lastId: string | null = null
function dispatch(events: SseEvent[]) {
if (dataLines.length > 0) {
events.push({ event: eventName || DEFAULT_EVENT_NAME, data: dataLines.join(LF), id: lastId })
}
eventName = ''
dataLines = []
dataLength = 0
}
function addData(value: string) {
dataLength += value.length + (dataLines.length > 0 ? LF.length : 0)
if (dataLength > maxLength) throw new SseLimitError()
dataLines.push(value)
}
function processLine(line: string, events: SseEvent[]) {
if (line === '') {
dispatch(events)
return
}
if (line.startsWith(':')) return
const colon = line.indexOf(':')
const field = colon === -1 ? line : line.slice(0, colon)
let value = colon === -1 ? '' : line.slice(colon + 1)
if (value.startsWith(' ')) value = value.slice(1)
if (field === 'event') eventName = value
else if (field === 'data') addData(value)
// 표준: NUL 이 든 id 는 무시한다
else if (field === 'id' && !value.includes('\0')) lastId = value
}
function push(chunk: string): SseEvent[] {
let text = chunk
if (!started && text.length > 0) {
started = true
if (text.charCodeAt(0) === BYTE_ORDER_MARK) text = text.slice(1)
}
if (pendingCr && text.length > 0) {
if (text.startsWith(LF)) text = text.slice(1)
pendingCr = false
}
buffer += text
const events: SseEvent[] = []
let lineStart = 0
for (let index = 0; index < buffer.length; index += 1) {
const char = buffer[index]
if (char !== LF && char !== CR) continue
processLine(buffer.slice(lineStart, index), events)
if (char === CR) {
if (index + 1 < buffer.length) {
if (buffer[index + 1] === LF) index += 1
} else {
pendingCr = true
}
}
lineStart = index + 1
}
buffer = buffer.slice(lineStart)
if (buffer.length > maxLength) throw new SseLimitError()
return events
}
return { push }
}
@@ -0,0 +1,181 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { createPinia, setActivePinia } from 'pinia'
import { nextTick } from 'vue'
import { FakeBroadcastChannel } from '@/test-utils/broadcast'
import { FakeNotification } from '@/test-utils/notification'
import { installFakeLocks, uninstallFakeLocks } from '@/test-utils/web-locks'
import { useAuthStore } from './auth.store'
import { mailNotifyStorageKey, useMailNotifyStore } from './mail-notify.store'
// 바탕화면 알림 설정 — 브라우저 · 사용자마다 localStorage (디자인 README "5단계 › 설정 › 메일 › 알림" · mail-view.store 와 같은 방식)
const ARA = { email: 'ara.kim@arasystem.test', name: '김아라' }
const DOYUN = { email: 'doyun.lee@arasystem.test', name: '이도윤' }
describe('mail-notify.store (바탕화면 알림 설정)', () => {
beforeEach(() => {
window.localStorage.clear()
setActivePinia(createPinia())
installFakeLocks()
vi.stubGlobal('BroadcastChannel', FakeBroadcastChannel)
vi.stubGlobal('Notification', FakeNotification)
FakeNotification.reset('default')
})
afterEach(() => {
uninstallFakeLocks()
vi.unstubAllGlobals()
vi.restoreAllMocks()
})
function loggedInStore(user = ARA) {
useAuthStore().markAuthenticated(user)
return useMailNotifyStore()
}
it('기본값은 꺼짐 · 메일 제목 보이기 · 권하기 닫지 않음 — 브라우저가 묻기 전이면 권하기 줄이 보인다', () => {
const store = loggedInStore()
expect(store.settings).toEqual({ enabled: false, showSubject: true, offerDismissed: false })
expect(store.permission).toBe('default')
expect(store.canNotify).toBe(false)
expect(store.offerVisible).toBe(true)
})
it('켜기 — 브라우저가 묻고, 허용하면 켠 것을 사용자 주소가 든 키로 저장한다', async () => {
const store = loggedInStore()
FakeNotification.nextAnswer = 'granted'
await expect(store.turnOn()).resolves.toBe('granted')
expect(FakeNotification.requestCount).toBe(1)
expect(store.settings.enabled).toBe(true)
expect(store.canNotify).toBe(true)
expect(store.offerVisible).toBe(false)
expect(JSON.parse(window.localStorage.getItem(mailNotifyStorageKey(ARA.email)) ?? '{}')).toMatchObject({ enabled: true })
expect(mailNotifyStorageKey('Ara.Kim@ArASystem.test')).toBe(mailNotifyStorageKey(ARA.email))
})
it('켜기 — 막으면 켜지 않고(권하기 줄도 사라진다), 그냥 닫으면 그대로 다시 물을 수 있다', async () => {
const store = loggedInStore()
FakeNotification.nextAnswer = 'default'
await expect(store.turnOn()).resolves.toBe('default')
expect(store.settings.enabled).toBe(false)
expect(store.offerVisible).toBe(true)
FakeNotification.nextAnswer = 'denied'
await expect(store.turnOn()).resolves.toBe('denied')
expect(store.settings.enabled).toBe(false)
expect(store.permission).toBe('denied')
expect(store.offerVisible).toBe(false)
// 막힌 뒤에는 브라우저가 다시 묻지 않는다 — 묻지 않고 그대로 돌려준다
await expect(store.turnOn()).resolves.toBe('denied')
expect(FakeNotification.requestCount).toBe(2)
})
it('이미 허락했으면 묻지 않고 켠다 · 끄면 꺼진다(허락은 그대로)', async () => {
FakeNotification.reset('granted')
const store = loggedInStore()
await expect(store.turnOn()).resolves.toBe('granted')
expect(FakeNotification.requestCount).toBe(0)
expect(store.canNotify).toBe(true)
store.turnOff()
expect(store.settings.enabled).toBe(false)
expect(store.permission).toBe('granted')
expect(store.offerVisible).toBe(false)
})
it('지원하지 않는 브라우저 — 묻지 않고 unsupported, 권하기 줄도 없다', async () => {
vi.stubGlobal('Notification', undefined)
const store = loggedInStore()
expect(store.permission).toBe('unsupported')
await expect(store.turnOn()).resolves.toBe('unsupported')
expect(store.settings.enabled).toBe(false)
expect(store.offerVisible).toBe(false)
})
it('권하기 줄을 닫으면 다시 보이지 않는다 (저장한다) · 메일 제목 보이기도 저장한다', () => {
const store = loggedInStore()
store.dismissOffer()
store.setShowSubject(false)
expect(store.offerVisible).toBe(false)
expect(JSON.parse(window.localStorage.getItem(mailNotifyStorageKey(ARA.email)) ?? '{}')).toEqual({
enabled: false,
showSubject: false,
offerDismissed: true,
})
})
it('로그인한 사용자가 바뀌면 그 사용자의 설정을 다시 읽는다 (로그아웃이면 기본값)', async () => {
window.localStorage.setItem(
mailNotifyStorageKey(ARA.email),
JSON.stringify({ enabled: true, showSubject: false, offerDismissed: true }),
)
const authStore = useAuthStore()
const store = useMailNotifyStore()
authStore.markAuthenticated(ARA)
await nextTick()
expect(store.settings).toEqual({ enabled: true, showSubject: false, offerDismissed: true })
authStore.startSession(DOYUN)
await nextTick()
expect(store.settings).toEqual({ enabled: false, showSubject: true, offerDismissed: false })
authStore.clearAuth()
await nextTick()
expect(store.settings.enabled).toBe(false)
})
it('다른 탭이 같은 사용자의 설정을 바꾸면 따라간다 (storage 이벤트) — 다른 키는 무시한다', () => {
const store = loggedInStore()
window.dispatchEvent(
new StorageEvent('storage', {
key: mailNotifyStorageKey(DOYUN.email),
newValue: JSON.stringify({ enabled: true }),
}),
)
expect(store.settings.enabled).toBe(false)
window.dispatchEvent(
new StorageEvent('storage', {
key: mailNotifyStorageKey(ARA.email),
newValue: JSON.stringify({ enabled: true, showSubject: true, offerDismissed: true }),
}),
)
expect(store.settings).toEqual({ enabled: true, showSubject: true, offerDismissed: true })
})
it('저장소를 쓸 수 없어도(사생활 보호 모드 등) 기본값으로 동작하고, 바꾼 값은 이번 화면에서 유지한다', async () => {
vi.spyOn(Storage.prototype, 'getItem').mockImplementation(() => {
throw new Error('차단됨')
})
vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => {
throw new Error('차단됨')
})
const store = loggedInStore()
await nextTick()
expect(store.settings.enabled).toBe(false)
expect(() => store.dismissOffer()).not.toThrow()
expect(store.settings.offerDismissed).toBe(true)
})
it('브라우저의 허락은 저장하지 않고 다시 읽는다 — 사이트 설정에서 바꿨을 수 있다', () => {
const store = loggedInStore()
FakeNotification.permission = 'denied'
expect(store.permission).toBe('default')
store.refreshPermission()
expect(store.permission).toBe('denied')
})
})
+157
View File
@@ -0,0 +1,157 @@
import { computed, ref, watch } from 'vue'
import { defineStore } from 'pinia'
import { currentNotificationPermission, requestNotificationPermission } from '@/composables/useDesktopNotification'
import { MAIL_NOTIFY_STORAGE_PREFIX } from '@/shared/constants/mail-notify'
import {
DEFAULT_MAIL_NOTIFY_SETTINGS,
parseMailNotifySettings,
shouldOfferDesktopNotify,
type DesktopNotifyPermission,
type MailNotifySettings,
} from '@/shared/utils/desktop-notify'
import { normalizeEmail } from '@/shared/utils/mail-address'
import { useAuthStore } from '@/stores/auth.store'
// 바탕화면 알림 설정 — 켜기(기본 꺼짐) · 메일 제목도 보이기(기본 켬) · 목록 위 권하기를 닫았는지 (디자인 README "5단계")
// 서버 설정이 아니라 브라우저 · 사용자마다 localStorage 에 둔다 (계약 2절 "실시간 알림" — mail-view.store 와 같은 방식:
// 키에 사용자 주소, 저장소를 못 쓰면 기본값). 다른 탭에서 바꾸면(storage 이벤트) 이 탭도 따라간다 —
// 알림을 보내는 연결 탭이 그 값을 바로 쓰고, 목록 위의 권하기 줄도 다른 탭에서 켜거나 닫으면 사라진다.
// 브라우저의 허락(Notification.permission)은 저장하지 않고 그때그때 읽는다 (permission — 설정 화면 · 권하기 줄이 본다).
// 띄우려 했더니 만들 수 없는 브라우저였으면(휴대폰 Chrome 등 — showDesktopNotification 의 'unsupported') markUnsupported 로
// 이 화면을 연 동안은 "지원하지 않음"으로 둔다 (설정 화면의 안내가 그렇게 바뀐다).
export function mailNotifyStorageKey(email: string): string {
return `${MAIL_NOTIFY_STORAGE_PREFIX}${normalizeEmail(email)}`
}
// 사생활 보호 모드 · 저장소 차단 · 용량 초과 등으로 localStorage 가 실패해도 기본값으로 동작해야 한다
function readStored(key: string): string | null {
try {
return window.localStorage.getItem(key)
} catch {
return null
}
}
function writeStored(key: string, value: string) {
try {
window.localStorage.setItem(key, value)
} catch {
// 기억하지 못해도 이번 화면에서는 바꾼 값으로 계속 쓴다
}
}
export const useMailNotifyStore = defineStore('mail-notify', () => {
const authStore = useAuthStore()
const settings = ref<MailNotifySettings>({ ...DEFAULT_MAIL_NOTIFY_SETTINGS })
/** 브라우저의 알림 허락 — 지원하지 않으면 'unsupported' */
const permission = ref<DesktopNotifyPermission>(currentNotificationPermission())
/** 허락을 묻는 중 — 칸 · 단추를 다시 누르지 못하게 */
const requesting = ref<boolean>(false)
/** 알림을 만들 수 없는 브라우저로 드러났다 — 화면을 새로 열 때까지 */
let constructorUnsupported = false
let storageKey: string | null = null
/** 알림을 보낼 수 있다 — 설정을 켰고 브라우저가 허락했다 */
const canNotify = computed<boolean>(() => settings.value.enabled && permission.value === 'granted')
/** 목록 위의 권하기 줄 */
const offerVisible = computed<boolean>(() =>
shouldOfferDesktopNotify({ permission: permission.value, settings: settings.value }),
)
function loadFor(email: string | null) {
storageKey = email ? mailNotifyStorageKey(email) : null
settings.value = storageKey ? parseMailNotifySettings(readStored(storageKey)) : { ...DEFAULT_MAIL_NOTIFY_SETTINGS }
refreshPermission()
}
function update(patch: Partial<MailNotifySettings>) {
settings.value = { ...settings.value, ...patch }
if (storageKey) writeStored(storageKey, JSON.stringify(settings.value))
}
/** 브라우저의 허락을 다시 읽는다 — 사이트 설정에서 바꿨을 수 있다 */
function refreshPermission() {
permission.value = constructorUnsupported ? 'unsupported' : currentNotificationPermission()
}
/** 띄우려 했더니 만들 수 없는 브라우저였다 — "지원하지 않음"으로 바꾼다 (설정은 그대로 둔다) */
function markUnsupported() {
constructorUnsupported = true
permission.value = 'unsupported'
}
/**
* 켠다 — 브라우저가 아직 묻지 않았으면 허락을 묻는다(누른 처리 안에서 바로 부른다). 허락하면 켜고 'granted'.
* 막았으면 'denied', 그냥 닫았으면 'default' — 둘 다 켜지 않는다
*/
async function turnOn(): Promise<DesktopNotifyPermission> {
if (requesting.value) return permission.value
refreshPermission()
if (permission.value === 'granted') {
update({ enabled: true })
return 'granted'
}
if (permission.value !== 'default') return permission.value
requesting.value = true
try {
const result = await requestNotificationPermission()
permission.value = result
if (result === 'granted') update({ enabled: true })
return result
} finally {
requesting.value = false
}
}
function turnOff() {
update({ enabled: false })
}
function setShowSubject(showSubject: boolean) {
update({ showSubject })
}
/** 목록 위의 권하기 줄을 닫는다 — 다시 보이지 않는다 */
function dismissOffer() {
update({ offerDismissed: true })
}
// 다른 탭이 같은 사용자의 설정을 바꾸면 따라간다 (storage 이벤트는 바꾼 탭이 아닌 탭에만 온다)
function onStorage(event: StorageEvent) {
if (storageKey && event.key === storageKey) settings.value = parseMailNotifySettings(event.newValue)
}
if (typeof window !== 'undefined') window.addEventListener('storage', onStorage)
// 사이트 설정에서 허락을 바꾸면 바로 따라간다 — Permissions API 가 있는 브라우저만 (없으면 화면이 열릴 때 다시 읽는다)
try {
void navigator.permissions?.query({ name: 'notifications' }).then(
(status) => {
status.addEventListener('change', refreshPermission)
},
() => undefined,
)
} catch {
// 묻지 못하는 브라우저 — 위와 같다
}
// 로그인한 사용자가 바뀌면 그 사용자의 설정을 다시 읽는다 (로그아웃이면 기본값)
watch(() => authStore.user?.email ?? null, loadFor, { immediate: true })
return {
settings,
permission,
requesting,
canNotify,
offerVisible,
refreshPermission,
markUnsupported,
turnOn,
turnOff,
setShowSubject,
dismissOffer,
}
})
@@ -0,0 +1,70 @@
import { computed, ref, watch } from 'vue'
import { defineStore } from 'pinia'
import type { MailRealtimeStatus } from '@/shared/utils/mail-realtime'
import { useAuthStore } from '@/stores/auth.store'
export type { MailRealtimeStatus } from '@/shared/utils/mail-realtime'
// 실시간 새 메일 — 이 탭이 보는 연결 상태와 "바뀜" (계약 2절 "실시간 알림" · "새 메일 확인")
// 연결은 앱 전체에서 로그인한 동안 돈다(composables/useMailRealtime.ts — App.vue). 연결은 브라우저마다 한 탭만 하고,
// 그 소식(state · ping)은 모든 탭이 같은 규칙으로 받는다. 이 스토어는 그 결과만 둔다:
// - status: 연결 상태 — 메일 화면은 live 인 동안 1분 확인을 멈추고, 설정 › 알림이 보여 준다
// - 바뀜: 1분 확인 · 실시간 소식이 같은 기준(마지막으로 본 state)으로 비교한다. 바뀌었으면 메일 화면이 맞출 일(pendingSync)을 남긴다 —
// 메일 화면이 보이면 바로, 숨었거나 다른 화면이면 다시 보일 때 · 메일 화면으로 돌아올 때 맞춘다 (useMailStatePolling)
export const useMailRealtimeStore = defineStore('mail-realtime', () => {
const authStore = useAuthStore()
const status = ref<MailRealtimeStatus>('off')
/** 실시간 연결이 살아 있다 — 1분 확인을 멈춘다 */
const isLive = computed<boolean>(() => status.value === 'live')
/** 메일 화면이 아직 맞추지 않은 바뀜이 있다 */
const pendingSync = ref<boolean>(false)
/** 마지막 바뀜과 함께 온 받은편지함의 안 읽은 수 — 모르면 null (1분 확인은 이 수를 주지 않는다) */
const inboxUnread = ref<number | null>(null)
/** 바뀜을 볼 때마다 오른다 — 메일 화면이 이 값을 지켜본다 */
const changeSeq = ref<number>(0)
/** 마지막으로 본 state — 화면에 그리지 않으므로 반응형이 아니다 */
let baseline: string | null = null
function setStatus(next: MailRealtimeStatus) {
status.value = next
}
/**
* state 를 본다 — 처음 본 값은 기준으로만 쓰고, 지난 값과 다르면 바뀜으로 남긴다.
* @returns 바뀌었는지
*/
function observeState(state: string, unread: number | null = null): boolean {
const changed = baseline !== null && state !== baseline
baseline = state
if (!changed) return false
inboxUnread.value = unread
pendingSync.value = true
changeSeq.value += 1
return true
}
/** 메일 화면이 맞추기 시작한다 — 남은 바뀜이 있었으면 true (한 번만) */
function takePendingSync(): boolean {
if (!pendingSync.value) return false
pendingSync.value = false
return true
}
// 세션이 바뀌면(로그아웃 · 세션 만료 · 다른 계정 로그인) 지난 기준을 버린다 — 다른 계정의 state 와 비교하지 않게.
// 바로(sync) 버린다 — 새 세션의 첫 state 가 옛 기준과 비교되지 않게
watch(
() => authStore.generation,
() => {
baseline = null
pendingSync.value = false
inboxUnread.value = null
},
{ flush: 'sync' },
)
return { status, isLive, pendingSync, inboxUnread, changeSeq, setStatus, observeState, takePendingSync }
})
+1
View File
@@ -105,6 +105,7 @@ export const useMailStore = defineStore('mail', () => {
loadQuota: mailboxState.loadQuota,
ensureQuota: mailboxState.ensureQuota,
syncCounts: mailboxState.syncCounts,
setInboxUnread: mailboxState.setInboxUnread,
// 보이는 목록
listSource: list.listSource,
currentMailboxId: list.currentMailboxId,
+10
View File
@@ -161,6 +161,15 @@ export function createMailboxesState(mailApi: MailApi) {
mailboxes.value = mailboxes.value.map((mailbox) => (mailbox.id === id ? { ...mailbox, ...counts } : mailbox))
}
/**
* 받은편지함의 안 읽은 수만 바로 고친다 — 숨은 탭이 실시간 소식(inboxUnread)으로 탭 제목 "(N) " 을 맞출 때 (계약 2절 "실시간 알림").
* 다시 보이면 메일함 목록을 서버 기준으로 다시 받는다. 메일함을 아직 받지 않았으면 할 일이 없다
*/
function setInboxUnread(count: number) {
const inbox = mailboxByRole('inbox')
if (inbox && inbox.unreadCount !== count) setCounts(inbox.id, { unreadCount: count })
}
function reset() {
mailboxSeq += 1
quotaSeq += 1
@@ -192,6 +201,7 @@ export function createMailboxesState(mailApi: MailApi) {
upsertMailbox,
removeMailbox,
setCounts,
setInboxUnread,
reset,
}
}
+8
View File
@@ -44,6 +44,7 @@
--color-brand-strong: #0a5f67; /* 강조 글자 · 눌린 상태 */
--color-brand-subtle: #e3f2f3; /* 선택한 행 · 현재 메일함 · 아바타 */
--color-brand-tint: #f3f8f8; /* 여러 통 처리 도구 줄 · 새 메일 알림 줄 바탕 (디자인 원본 BulkSelect · ListStates) */
--color-brand-tint-border: #e3eeef; /* 목록 위 알림 권하기 줄의 아래 선 (디자인 원본 MailRealtime) */
--color-on-brand: #ffffff;
--color-on-brand-text: #e6eff0;
--color-on-brand-muted: #a3c2c6;
@@ -80,6 +81,7 @@
--color-warning-surface: #fff7e6;
--color-warning-text: #7a5200; /* 임시 저장 "다른 곳에서 바뀐 저장본" 안내 */
--color-success-text: #067647; /* 서명 "저장했습니다" (디자인 원본 Settings) */
--color-status-live: #12b76a; /* 실시간 연결 "연결되어 있습니다" 점 (디자인 원본 SettingsNotify) */
--color-star: #b7791f;
/* ── 간격 (이름의 숫자 = px) ─────────────── */
@@ -153,6 +155,12 @@
* 메일함 행의 ⋯ 단추 32×40(행 높이 --size-row-min — 목록 표의 별 칸과 같은 폭) · ⋯ 메뉴 항목 40(--size-editor-menu-item)
*/
--size-mailbox-more: 2rem; /* 32px — 메일함 행의 ⋯ 단추 폭 · 밀도 예외 */
/*
* 5단계(실시간 새 메일 · 바탕화면 알림) 밀도 예외 — 클릭 영역 44px 기준의 의도된 예외 (디자인 README "5단계 › 목록 위에서 한 번 권하기", 사용자 확정 캔버스).
* 목록 위 권하기 줄 높이 40(--size-notice-bar — 새 메일 알림 줄과 같다) · 줄 안의 [알림 켜기] 높이 36 · [✕ 닫기] 36×36
*/
--size-notice-action: 2.25rem; /* 36px — 알림 권하기 줄 안의 단추 · 밀도 예외 */
--size-status-dot: 0.5rem; /* 8px — 설정 › 알림의 실시간 연결 점 */
--size-option-input: 1.125rem; /* 18px — 설정 화면의 고르기 상자 · 라디오 (줄 전체가 44px 로 눌린다) */
--form-max-width: 26.25rem; /* 420px — 비밀번호 칸 (디자인 원본 SettingsAccount) */
--form-wide-max-width: 32.5rem; /* 520px — 자동 응답 제목 칸 (디자인 원본 SettingsVacation) */
+125
View File
@@ -0,0 +1,125 @@
// 단위 테스트 전용 — SSE(text/event-stream) 를 돌려주는 가짜 fetch. 연결마다 테스트가 이벤트를 보내고 · 닫고 · 끊는다.
// 응답 차례는 테스트가 정한다(reply) — 정하지 않으면 200 스트림. 클라이언트가 그만두면(AbortController) 스트림이 AbortError 로 끝난다.
// 사용: const server = createEventStreamServer(); useMailRealtime({ fetch: server.fetch, … })
// 앱 코드에서 import 하지 않는다 (번들에 들어가지 않는다).
/** 연결 하나의 응답 — 200 스트림 · 다른 상태(본문 JSON) · 네트워크 오류 · 머리글이 오지 않음(그만둘 때까지) */
export type EventStreamReply =
| { kind: 'stream'; contentType?: string }
| { kind: 'status'; status: number; body?: unknown }
| { kind: 'network' }
| { kind: 'hang' }
export interface EventStreamConnection {
url: string
init: RequestInit
/** 클라이언트가 그만두었다 */
readonly aborted: boolean
/** SSE 글을 그대로 보낸다 (조각 시험용) */
send: (text: string) => void
/** 이벤트 하나 — data 는 JSON 으로 */
emit: (event: string, data: unknown) => void
/** 서버가 닫는다 (스트림 끝) */
close: () => void
/** 연결이 끊긴다 (읽기 오류) */
fail: () => void
}
export interface EventStreamServer {
fetch: (input: string, init?: RequestInit) => Promise<Response>
/** 받은 연결 — 스트림 응답만이 아니라 모든 요청 */
readonly connections: EventStreamConnection[]
/** 다음 요청들의 응답 차례 */
reply: (...replies: EventStreamReply[]) => void
/** 마지막 연결 */
last: () => EventStreamConnection
}
const encoder = new TextEncoder()
function abortError(): DOMException {
return new DOMException('그만두었다', 'AbortError')
}
export function createEventStreamServer(): EventStreamServer {
const connections: EventStreamConnection[] = []
const queue: EventStreamReply[] = []
function open(url: string, init: RequestInit, reply: EventStreamReply): Promise<Response> {
let aborted = false
let controller: ReadableStreamDefaultController<Uint8Array> | null = null
let finished = false
let rejectPending: ((reason: unknown) => void) | null = null
const connection: EventStreamConnection = {
url,
init,
get aborted() {
return aborted
},
send: (text) => {
if (!finished) controller?.enqueue(encoder.encode(text))
},
emit: (event, data) => connection.send(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`),
close: () => {
if (finished) return
finished = true
controller?.close()
},
fail: () => {
if (finished) return
finished = true
controller?.error(new TypeError('network error'))
},
}
connections.push(connection)
init.signal?.addEventListener('abort', () => {
aborted = true
rejectPending?.(abortError())
if (!finished) {
finished = true
controller?.error(abortError())
}
})
if (reply.kind === 'network') return Promise.reject(new TypeError('Failed to fetch'))
if (reply.kind === 'hang') {
return new Promise<Response>((_resolve, reject) => {
rejectPending = reject
})
}
if (reply.kind === 'status') {
return Promise.resolve(
new Response(JSON.stringify(reply.body ?? { success: false }), {
status: reply.status,
headers: { 'Content-Type': 'application/json' },
}),
)
}
const body = new ReadableStream<Uint8Array>({
start(streamController) {
controller = streamController
},
})
return Promise.resolve(
new Response(body, {
status: 200,
headers: { 'Content-Type': reply.contentType ?? 'text/event-stream; charset=utf-8' },
}),
)
}
return {
fetch: (input, init = {}) => open(input, init, queue.shift() ?? { kind: 'stream' }),
connections,
reply: (...replies) => {
queue.push(...replies)
},
last: () => {
const connection = connections.at(-1)
if (!connection) throw new Error('아직 연결이 없다')
return connection
},
}
}
+76
View File
@@ -0,0 +1,76 @@
// 단위 테스트 전용 — 브라우저 Notification 대역. 띄운 알림을 모아 두고, 허락 묻기의 답을 테스트가 정한다.
// 사용: vi.stubGlobal('Notification', FakeNotification) — 테스트마다 FakeNotification.reset() 으로 비운다.
// - nextAnswer: 다음 requestPermission 의 답 · holdAnswer(): 답을 미뤄 두고(묻는 창이 떠 있는 동안) 나중에 준다
// - constructorThrows: 만들면 예외 — 휴대폰 Chrome 처럼 페이지에서 바로 띄울 수 없는 브라우저
// 앱 코드에서 import 하지 않는다 (번들에 들어가지 않는다).
export class FakeNotification {
static permission: NotificationPermission = 'default'
/** requestPermission 이 줄 답 — 정하지 않으면 지금 값 */
static nextAnswer: NotificationPermission | null = null
static requestCount = 0
static constructorThrows = false
static readonly shown: FakeNotification[] = []
/** 다음 허락 묻기의 답을 미룬다 (holdAnswer) */
private static holding = false
/** 미뤄 둔 답을 주는 함수 */
private static held: ((answer: NotificationPermission) => void) | null = null
readonly title: string
readonly options: NotificationOptions & { renotify?: boolean }
onclick: ((this: FakeNotification, event: Event) => unknown) | null = null
closed = false
constructor(title: string, options: NotificationOptions & { renotify?: boolean } = {}) {
if (FakeNotification.constructorThrows) {
throw new TypeError("Failed to construct 'Notification': Illegal constructor.")
}
if (FakeNotification.permission !== 'granted') throw new TypeError('허락하지 않은 알림')
this.title = title
this.options = options
FakeNotification.shown.push(this)
}
static requestPermission(): Promise<NotificationPermission> {
FakeNotification.requestCount += 1
if (FakeNotification.held === null && FakeNotification.holding) {
FakeNotification.holding = false
return new Promise((resolve) => {
FakeNotification.held = (answer) => {
FakeNotification.held = null
FakeNotification.permission = answer
resolve(answer)
}
})
}
const answer = FakeNotification.nextAnswer ?? FakeNotification.permission
FakeNotification.nextAnswer = null
FakeNotification.permission = answer
return Promise.resolve(answer)
}
/** 다음 허락 묻기의 답을 미룬다 — 돌려준 함수로 답한다 (묻는 창이 떠 있는 동안을 시험한다) */
static holdAnswer(): (answer: NotificationPermission) => void {
FakeNotification.holding = true
return (answer) => FakeNotification.held?.(answer)
}
/** 사용자가 알림을 눌렀다 */
click(): void {
this.onclick?.call(this, new Event('click'))
}
close(): void {
this.closed = true
}
static reset(permission: NotificationPermission = 'default'): void {
FakeNotification.permission = permission
FakeNotification.nextAnswer = null
FakeNotification.requestCount = 0
FakeNotification.constructorThrows = false
FakeNotification.holding = false
FakeNotification.held = null
FakeNotification.shown.length = 0
}
}
+6
View File
@@ -54,6 +54,12 @@ export function createTestRouter(): Router {
component: Empty,
meta: { requiresAuth: true },
},
{
path: '/settings/mail/notify',
name: 'settings-mail-notify',
component: Empty,
meta: { requiresAuth: true },
},
],
})
}
+95
View File
@@ -0,0 +1,95 @@
// 단위 테스트 전용 — Web Locks(navigator.locks) 대역. 같은 이름의 잠금은 한 번에 하나만 주고, 나머지는 줄을 세운다.
// 표준처럼 잠금은 콜백이 돌려준 Promise 가 끝날 때 풀리고, signal 로 그만두면 기다리던 요청만 AbortError 로 거절한다.
// 사용: installFakeLocks() — 끝나면 uninstallFakeLocks(). 여러 "탭"이 같은 navigator.locks 를 쓴다.
// 앱 코드에서 import 하지 않는다 (번들에 들어가지 않는다).
type Callback = (lock: Lock | null) => unknown
interface Waiter {
name: string
callback: Callback
resolve: (value: unknown) => void
reject: (reason: unknown) => void
}
function abortError(): DOMException {
return new DOMException('잠금 요청을 그만두었다', 'AbortError')
}
export class FakeLockManager {
private readonly held = new Map<string, Waiter>()
private readonly queues = new Map<string, Waiter[]>()
request(name: string, ...rest: [Callback] | [LockOptions, Callback]): Promise<unknown> {
const [options, callback] = rest.length === 1 ? [{} as LockOptions, rest[0]] : rest
return new Promise((resolve, reject) => {
if (options.signal?.aborted) {
reject(abortError())
return
}
const waiter: Waiter = { name, callback, resolve, reject }
options.signal?.addEventListener('abort', () => {
const queue = this.queues.get(name) ?? []
const index = queue.indexOf(waiter)
if (index === -1) return
queue.splice(index, 1)
reject(abortError())
})
const queue = this.queues.get(name) ?? []
queue.push(waiter)
this.queues.set(name, queue)
this.grant(name)
})
}
/** 지금 그 잠금을 가진 요청이 있는지 */
isHeld(name: string): boolean {
return this.held.has(name)
}
/** 그 잠금을 기다리는 요청 수 */
waitingCount(name: string): number {
return this.queues.get(name)?.length ?? 0
}
private grant(name: string): void {
if (this.held.has(name)) return
const next = this.queues.get(name)?.shift()
if (!next) return
this.held.set(name, next)
const lock = { name, mode: 'exclusive' } as Lock
void Promise.resolve()
.then(() => next.callback(lock))
.then(
(value) => {
this.release(name)
next.resolve(value)
},
(error: unknown) => {
this.release(name)
next.reject(error)
},
)
}
private release(name: string): void {
this.held.delete(name)
this.grant(name)
}
}
let previous: PropertyDescriptor | undefined
/** navigator.locks 를 가짜로 바꾼다 */
export function installFakeLocks(): FakeLockManager {
const locks = new FakeLockManager()
previous = Object.getOwnPropertyDescriptor(navigator, 'locks')
Object.defineProperty(navigator, 'locks', { configurable: true, value: locks })
return locks
}
export function uninstallFakeLocks(): void {
if (previous) Object.defineProperty(navigator, 'locks', previous)
else Reflect.deleteProperty(navigator, 'locks')
previous = undefined
}