jmap_chat_client/sse.rs
1//! SSE types and frame parser for JMAP Chat push notifications.
2//!
3//! Wraps the base-client [`jmap_base_client::parse_sse_block`] and
4//! interprets the chat-specific `"typing"` and `"presence"` event types that the base
5//! client leaves as [`SseEvent::Unknown`].
6//!
7//! Spec: draft-atwood-jmap-chat-push-00 §§ typing, presence
8//! Wire format: RFC 8895 (Server-Sent Events)
9
10use jmap_base_client::SseEvent;
11use jmap_chat_types::Presence;
12use jmap_types::Id;
13
14/// A parsed SSE event from the JMAP Chat event source.
15///
16/// Extends the base-client [`SseEvent`] with
17/// chat-specific variants for typing indicators and presence updates.
18#[non_exhaustive]
19#[derive(Debug, Clone)]
20pub enum ChatSseEvent {
21 /// A "state" event: maps accountId → (typeName → newState).
22 ///
23 /// Triggers a `/changes` call for each type listed.
24 /// Wire: `{"@type":"StateChange","changed":{"<accountId>":{"<TypeName>":"<state>"}}}`
25 StateChange(jmap_base_client::StateChange),
26
27 /// A "typing" indicator event. Not persisted; no state token.
28 ///
29 /// Wire: `{"chatId":"<id>","senderId":"<id>","typing":<bool>}`
30 Typing {
31 /// The chat in which typing is occurring.
32 chat_id: Id,
33 /// The sender contact id.
34 sender_id: Id,
35 /// `true` = started typing, `false` = stopped.
36 typing: bool,
37 },
38
39 /// A "presence" update event. Not persisted.
40 ///
41 /// Wire: `{"contactId":"<id>","presence":"<state>","lastActiveAt":"..."|null,...}`
42 Presence {
43 /// The contact whose presence changed.
44 contact_id: Id,
45 /// Presence state.
46 presence: Presence,
47 /// ISO 8601 timestamp of last activity, or `None` if absent/null.
48 last_active_at: Option<String>,
49 /// Free-text status message, or `None` if absent/null.
50 status_text: Option<String>,
51 /// Status emoji, or `None` if absent/null.
52 status_emoji: Option<String>,
53 },
54
55 /// Unrecognized event type, keepalive, or parse failure.
56 ///
57 /// `event_type` carries the value of the SSE `event:` field for
58 /// diagnostics — e.g. `"ping"` for a keepalive. Callers should silently
59 /// ignore this variant and log `event_type` when debugging.
60 Unknown {
61 /// The raw value of the SSE `event:` field; empty string if absent.
62 event_type: String,
63 },
64}
65
66/// A parsed JMAP Chat SSE frame: event plus the `id:` line value (if any).
67///
68/// # `id` field semantics
69///
70/// Mirrors [`jmap_base_client::SseFrame`]: `None` means the frame had no
71/// `id:` line or a bare `id:` reset. Callers should retain the previously-seen
72/// ID across reconnects and send it as `Last-Event-ID` per RFC 8620 §7.3.
73#[non_exhaustive]
74#[derive(Debug, Clone)]
75pub struct ChatSseFrame {
76 /// The parsed event payload.
77 pub event: ChatSseEvent,
78 /// The value of the SSE `id:` line, if any (used for `Last-Event-ID` on reconnect).
79 pub id: Option<String>,
80}
81
82/// Parse a single SSE block (text between two blank lines) into a [`ChatSseFrame`].
83///
84/// Delegates to [`jmap_base_client::parse_sse_block`] for low-level SSE framing,
85/// then interprets chat-specific event types:
86///
87/// - `"state"` → [`ChatSseEvent::StateChange`]
88/// - `"typing"` → [`ChatSseEvent::Typing`] (or `Unknown` on JSON parse failure)
89/// - `"presence"` → [`ChatSseEvent::Presence`] (or `Unknown` on JSON parse failure)
90/// - everything else → [`ChatSseEvent::Unknown`]
91///
92/// Never panics. Malformed JSON is silently ignored and produces `Unknown`.
93///
94/// # Edge-case contract
95///
96/// The behaviour for the corner cases an SSE consumer is likely to hit:
97///
98/// - **Empty block** (`""`): returns
99/// `ChatSseFrame { event: ChatSseEvent::Unknown { event_type: "" }, id: None }`.
100/// Callers writing a stream-resume loop should skip these rather
101/// than treat them as end-of-stream — SSE permits arbitrary
102/// intervening blank-line keep-alives.
103/// - **Block with no `event:` field**: same as empty block —
104/// `ChatSseEvent::Unknown { event_type: "" }`.
105/// - **Block with `event:` but no `data:`**: the typed-event arms
106/// (`typing`, `presence`, `state`) fall through to
107/// `ChatSseEvent::Unknown { event_type: "<that-type>" }` because the
108/// payload deserialise fails on empty input.
109/// - **Multiple `event:` lines**: the base parser keeps the last
110/// assignment (later `event:` lines overwrite earlier ones).
111/// Multiple `data:` lines are joined with `\n` then JSON-parsed
112/// (RFC 8895 §9.1).
113/// - **Line endings**: `block.lines()` handles `\n` and `\r\n`
114/// transparently; trailing empty lines are folded.
115/// - **Comments** (lines starting with `:`) and unknown field names:
116/// silently ignored per RFC 8895 §9.1.
117/// - **`id:` line**: passes through to [`ChatSseFrame::id`]. Empty
118/// value (`id:\n` or `id: \n`) maps to `None`; non-empty maps to
119/// `Some(value)`. Track this and send on reconnect as
120/// `Last-Event-ID` per RFC 8620 §7.3.
121/// - **Non-UTF-8 input**: not possible — the function takes `&str`,
122/// so the caller has already produced valid UTF-8. Use
123/// `String::from_utf8_lossy` upstream if your SSE transport may
124/// surface invalid bytes.
125pub fn parse_chat_sse_block(block: &str) -> ChatSseFrame {
126 let frame = jmap_base_client::parse_sse_block(block);
127 let event = match frame.event {
128 SseEvent::StateChange(sc) => ChatSseEvent::StateChange(sc),
129 SseEvent::Unknown { event_type, data } => match event_type.as_str() {
130 "typing" => parse_typing_data(&data).unwrap_or(ChatSseEvent::Unknown { event_type }),
131 "presence" => {
132 parse_presence_data(&data).unwrap_or(ChatSseEvent::Unknown { event_type })
133 }
134 _ => ChatSseEvent::Unknown { event_type },
135 },
136 // SseEvent is #[non_exhaustive]: forward any future base-client variants as Unknown.
137 _ => ChatSseEvent::Unknown {
138 event_type: String::new(),
139 },
140 };
141 ChatSseFrame {
142 event,
143 id: frame.id,
144 }
145}
146
147// ---------------------------------------------------------------------------
148// Private helpers
149// ---------------------------------------------------------------------------
150
151/// Wire shape of the "typing" event data field.
152#[derive(serde::Deserialize)]
153struct TypingPayload {
154 #[serde(rename = "chatId")]
155 chat_id: Id,
156 #[serde(rename = "senderId")]
157 sender_id: Id,
158 typing: bool,
159}
160
161/// Wire shape of the "presence" event data field.
162///
163/// `lastActiveAt`, `statusText`, and `statusEmoji` are JSON strings or `null`.
164/// A `null` value is treated the same as absence: both yield `None`.
165#[derive(serde::Deserialize)]
166struct PresencePayload {
167 #[serde(rename = "contactId")]
168 contact_id: Id,
169 presence: Presence,
170 #[serde(rename = "lastActiveAt")]
171 last_active_at: Option<String>,
172 #[serde(rename = "statusText")]
173 status_text: Option<String>,
174 #[serde(rename = "statusEmoji")]
175 status_emoji: Option<String>,
176}
177
178fn parse_typing_data(data: &str) -> Option<ChatSseEvent> {
179 let p: TypingPayload = serde_json::from_str(data).ok()?;
180 Some(ChatSseEvent::Typing {
181 chat_id: p.chat_id,
182 sender_id: p.sender_id,
183 typing: p.typing,
184 })
185}
186
187fn parse_presence_data(data: &str) -> Option<ChatSseEvent> {
188 let p: PresencePayload = serde_json::from_str(data).ok()?;
189 Some(ChatSseEvent::Presence {
190 contact_id: p.contact_id,
191 presence: p.presence,
192 last_active_at: p.last_active_at,
193 status_text: p.status_text,
194 status_emoji: p.status_emoji,
195 })
196}
197
198#[cfg(test)]
199mod tests {
200 use super::*;
201
202 /// Oracle: "state" SSE event is promoted to ChatSseEvent::StateChange.
203 /// Wire format from RFC 8620 §7.3 state change example.
204 #[test]
205 fn parse_state_event_promotes_to_state_change() {
206 let block = "event: state\ndata: {\"changed\":{\"acc1\":{\"Message\":\"s42\"}}}";
207 let ChatSseFrame { event, .. } = parse_chat_sse_block(block);
208 match event {
209 ChatSseEvent::StateChange(sc) => {
210 assert_eq!(
211 sc.changed
212 .get("acc1")
213 .and_then(|m| m.get("Message"))
214 .map(|s| s.as_ref()),
215 Some("s42"),
216 "changed[acc1][Message] must equal s42"
217 );
218 }
219 other => panic!("expected StateChange, got {other:?}"),
220 }
221 }
222
223 /// Oracle: "typing" SSE event with valid JSON produces Typing variant.
224 /// Wire format from draft-atwood-jmap-chat-push-00.
225 #[test]
226 fn parse_typing_event_valid_json() {
227 let block = "event: typing\ndata: {\"chatId\":\"c1\",\"senderId\":\"u1\",\"typing\":true}";
228 let ChatSseFrame { event, .. } = parse_chat_sse_block(block);
229 match event {
230 ChatSseEvent::Typing {
231 chat_id,
232 sender_id,
233 typing,
234 } => {
235 assert_eq!(chat_id.as_ref(), "c1");
236 assert_eq!(sender_id.as_ref(), "u1");
237 assert!(typing, "typing must be true");
238 }
239 other => panic!("expected Typing, got {other:?}"),
240 }
241 }
242
243 /// Oracle: "presence" SSE event with all fields present.
244 /// Wire format from draft-atwood-jmap-chat-push-00.
245 #[test]
246 fn parse_presence_event_all_fields() {
247 let block = concat!(
248 "event: presence\n",
249 "data: {\"contactId\":\"ct1\",\"presence\":\"online\",",
250 "\"lastActiveAt\":\"2024-01-01T00:00:00Z\",",
251 "\"statusText\":\"in a meeting\",\"statusEmoji\":\"📅\"}"
252 );
253 let ChatSseFrame { event, .. } = parse_chat_sse_block(block);
254 match event {
255 ChatSseEvent::Presence {
256 contact_id,
257 presence,
258 last_active_at,
259 status_text,
260 status_emoji,
261 } => {
262 assert_eq!(contact_id.as_ref(), "ct1");
263 assert_eq!(presence, Presence::Online);
264 assert_eq!(last_active_at.as_deref(), Some("2024-01-01T00:00:00Z"));
265 assert_eq!(status_text.as_deref(), Some("in a meeting"));
266 assert_eq!(status_emoji.as_deref(), Some("📅"));
267 }
268 other => panic!("expected Presence, got {other:?}"),
269 }
270 }
271
272 /// Oracle: "typing" event with malformed JSON degrades to Unknown.
273 /// Security requirement: never panic on bad server data.
274 #[test]
275 fn parse_typing_malformed_json_degrades_to_unknown() {
276 let block = "event: typing\ndata: not-json";
277 let ChatSseFrame { event, .. } = parse_chat_sse_block(block);
278 match event {
279 ChatSseEvent::Unknown { event_type } => {
280 assert_eq!(
281 event_type, "typing",
282 "Unknown must carry original event_type"
283 );
284 }
285 other => panic!("expected Unknown, got {other:?}"),
286 }
287 }
288
289 /// Oracle: "presence" event with malformed JSON degrades to Unknown.
290 /// Security requirement: never panic on bad server data.
291 #[test]
292 fn parse_presence_malformed_json_degrades_to_unknown() {
293 let block = "event: presence\ndata: {\"not\":\"valid-presence\"}";
294 let ChatSseFrame { event, .. } = parse_chat_sse_block(block);
295 // Presence is missing required `contactId` field — must degrade to Unknown.
296 assert!(
297 matches!(event, ChatSseEvent::Unknown { .. }),
298 "invalid presence JSON must yield Unknown"
299 );
300 }
301
302 /// Oracle: unrecognized event type produces Unknown with the original event_type.
303 /// RFC 8895 §9 forward-compatibility requirement.
304 #[test]
305 fn parse_unknown_event_type_preserved() {
306 let block = "event: ping\ndata: {}";
307 let ChatSseFrame { event, .. } = parse_chat_sse_block(block);
308 match event {
309 ChatSseEvent::Unknown { event_type } => {
310 assert_eq!(event_type, "ping");
311 }
312 other => panic!("expected Unknown, got {other:?}"),
313 }
314 }
315
316 /// Oracle: `id:` line value is threaded through to ChatSseFrame::id.
317 /// RFC 8895 §9 / RFC 8620 §7.3: callers use this for Last-Event-ID on reconnect.
318 #[test]
319 fn id_line_propagated_through_frame() {
320 let block = "id: evt-99\nevent: state\ndata: {\"changed\":{}}";
321 let ChatSseFrame { id, .. } = parse_chat_sse_block(block);
322 assert_eq!(id.as_deref(), Some("evt-99"));
323 }
324}