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`.
93pub fn parse_chat_sse_block(block: &str) -> ChatSseFrame {
94    let frame = jmap_base_client::parse_sse_block(block);
95    let event = match frame.event {
96        SseEvent::StateChange(sc) => ChatSseEvent::StateChange(sc),
97        SseEvent::Unknown { event_type, data } => match event_type.as_str() {
98            "typing" => parse_typing_data(&data).unwrap_or(ChatSseEvent::Unknown { event_type }),
99            "presence" => {
100                parse_presence_data(&data).unwrap_or(ChatSseEvent::Unknown { event_type })
101            }
102            _ => ChatSseEvent::Unknown { event_type },
103        },
104        // SseEvent is #[non_exhaustive]: forward any future base-client variants as Unknown.
105        _ => ChatSseEvent::Unknown {
106            event_type: String::new(),
107        },
108    };
109    ChatSseFrame {
110        event,
111        id: frame.id,
112    }
113}
114
115// ---------------------------------------------------------------------------
116// Private helpers
117// ---------------------------------------------------------------------------
118
119/// Wire shape of the "typing" event data field.
120#[derive(serde::Deserialize)]
121struct TypingPayload {
122    #[serde(rename = "chatId")]
123    chat_id: Id,
124    #[serde(rename = "senderId")]
125    sender_id: Id,
126    typing: bool,
127}
128
129/// Wire shape of the "presence" event data field.
130///
131/// `lastActiveAt`, `statusText`, and `statusEmoji` are JSON strings or `null`.
132/// A `null` value is treated the same as absence: both yield `None`.
133#[derive(serde::Deserialize)]
134struct PresencePayload {
135    #[serde(rename = "contactId")]
136    contact_id: Id,
137    presence: Presence,
138    #[serde(rename = "lastActiveAt")]
139    last_active_at: Option<String>,
140    #[serde(rename = "statusText")]
141    status_text: Option<String>,
142    #[serde(rename = "statusEmoji")]
143    status_emoji: Option<String>,
144}
145
146fn parse_typing_data(data: &str) -> Option<ChatSseEvent> {
147    let p: TypingPayload = serde_json::from_str(data).ok()?;
148    Some(ChatSseEvent::Typing {
149        chat_id: p.chat_id,
150        sender_id: p.sender_id,
151        typing: p.typing,
152    })
153}
154
155fn parse_presence_data(data: &str) -> Option<ChatSseEvent> {
156    let p: PresencePayload = serde_json::from_str(data).ok()?;
157    Some(ChatSseEvent::Presence {
158        contact_id: p.contact_id,
159        presence: p.presence,
160        last_active_at: p.last_active_at,
161        status_text: p.status_text,
162        status_emoji: p.status_emoji,
163    })
164}
165
166#[cfg(test)]
167mod tests {
168    use super::*;
169
170    /// Oracle: "state" SSE event is promoted to ChatSseEvent::StateChange.
171    /// Wire format from RFC 8620 §7.3 state change example.
172    #[test]
173    fn parse_state_event_promotes_to_state_change() {
174        let block = "event: state\ndata: {\"changed\":{\"acc1\":{\"Message\":\"s42\"}}}";
175        let ChatSseFrame { event, .. } = parse_chat_sse_block(block);
176        match event {
177            ChatSseEvent::StateChange(sc) => {
178                assert_eq!(
179                    sc.changed
180                        .get("acc1")
181                        .and_then(|m| m.get("Message"))
182                        .map(|s| s.as_ref()),
183                    Some("s42"),
184                    "changed[acc1][Message] must equal s42"
185                );
186            }
187            other => panic!("expected StateChange, got {other:?}"),
188        }
189    }
190
191    /// Oracle: "typing" SSE event with valid JSON produces Typing variant.
192    /// Wire format from draft-atwood-jmap-chat-push-00.
193    #[test]
194    fn parse_typing_event_valid_json() {
195        let block = "event: typing\ndata: {\"chatId\":\"c1\",\"senderId\":\"u1\",\"typing\":true}";
196        let ChatSseFrame { event, .. } = parse_chat_sse_block(block);
197        match event {
198            ChatSseEvent::Typing {
199                chat_id,
200                sender_id,
201                typing,
202            } => {
203                assert_eq!(chat_id.as_ref(), "c1");
204                assert_eq!(sender_id.as_ref(), "u1");
205                assert!(typing, "typing must be true");
206            }
207            other => panic!("expected Typing, got {other:?}"),
208        }
209    }
210
211    /// Oracle: "presence" SSE event with all fields present.
212    /// Wire format from draft-atwood-jmap-chat-push-00.
213    #[test]
214    fn parse_presence_event_all_fields() {
215        let block = concat!(
216            "event: presence\n",
217            "data: {\"contactId\":\"ct1\",\"presence\":\"online\",",
218            "\"lastActiveAt\":\"2024-01-01T00:00:00Z\",",
219            "\"statusText\":\"in a meeting\",\"statusEmoji\":\"📅\"}"
220        );
221        let ChatSseFrame { event, .. } = parse_chat_sse_block(block);
222        match event {
223            ChatSseEvent::Presence {
224                contact_id,
225                presence,
226                last_active_at,
227                status_text,
228                status_emoji,
229            } => {
230                assert_eq!(contact_id.as_ref(), "ct1");
231                assert_eq!(presence, Presence::Online);
232                assert_eq!(last_active_at.as_deref(), Some("2024-01-01T00:00:00Z"));
233                assert_eq!(status_text.as_deref(), Some("in a meeting"));
234                assert_eq!(status_emoji.as_deref(), Some("📅"));
235            }
236            other => panic!("expected Presence, got {other:?}"),
237        }
238    }
239
240    /// Oracle: "typing" event with malformed JSON degrades to Unknown.
241    /// Security requirement: never panic on bad server data.
242    #[test]
243    fn parse_typing_malformed_json_degrades_to_unknown() {
244        let block = "event: typing\ndata: not-json";
245        let ChatSseFrame { event, .. } = parse_chat_sse_block(block);
246        match event {
247            ChatSseEvent::Unknown { event_type } => {
248                assert_eq!(
249                    event_type, "typing",
250                    "Unknown must carry original event_type"
251                );
252            }
253            other => panic!("expected Unknown, got {other:?}"),
254        }
255    }
256
257    /// Oracle: "presence" event with malformed JSON degrades to Unknown.
258    /// Security requirement: never panic on bad server data.
259    #[test]
260    fn parse_presence_malformed_json_degrades_to_unknown() {
261        let block = "event: presence\ndata: {\"not\":\"valid-presence\"}";
262        let ChatSseFrame { event, .. } = parse_chat_sse_block(block);
263        // Presence is missing required `contactId` field — must degrade to Unknown.
264        assert!(
265            matches!(event, ChatSseEvent::Unknown { .. }),
266            "invalid presence JSON must yield Unknown"
267        );
268    }
269
270    /// Oracle: unrecognized event type produces Unknown with the original event_type.
271    /// RFC 8895 §9 forward-compatibility requirement.
272    #[test]
273    fn parse_unknown_event_type_preserved() {
274        let block = "event: ping\ndata: {}";
275        let ChatSseFrame { event, .. } = parse_chat_sse_block(block);
276        match event {
277            ChatSseEvent::Unknown { event_type } => {
278                assert_eq!(event_type, "ping");
279            }
280            other => panic!("expected Unknown, got {other:?}"),
281        }
282    }
283
284    /// Oracle: `id:` line value is threaded through to ChatSseFrame::id.
285    /// RFC 8895 §9 / RFC 8620 §7.3: callers use this for Last-Event-ID on reconnect.
286    #[test]
287    fn id_line_propagated_through_frame() {
288        let block = "id: evt-99\nevent: state\ndata: {\"changed\":{}}";
289        let ChatSseFrame { id, .. } = parse_chat_sse_block(block);
290        assert_eq!(id.as_deref(), Some("evt-99"));
291    }
292}