generated from ttipo/project-base
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>
This commit is contained in:
@@ -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단계 인증(엔진은 지원한다), 부재중 응답의 서식 본문 · 사내/외부 구분, 인쇄.
|
||||
|
||||
Reference in New Issue
Block a user