Skip to main content

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}