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}