jmap_base_client/sse.rs
1//! SSE types and frame parser for JMAP push notifications.
2//! Spec: RFC 8620 §7.3 (Push via Server-Sent Events)
3//! Wire format: RFC 8895 (Server-Sent Events)
4
5use std::collections::HashMap;
6
7use jmap_types::{Id, State};
8
9use crate::push;
10
11/// A parsed SSE frame: the event and the `id:` line value (if any).
12///
13/// # `id` field semantics
14///
15/// RFC 8895 §9.2 distinguishes three id states:
16/// - A frame with no `id:` field → last event ID is **unchanged**.
17/// - A frame with a bare `id:` field (no value) → last event ID is **reset**.
18/// - A frame with `id: <value>` → last event ID is **updated** to `<value>`.
19///
20/// This implementation conflates the first two cases: both produce `id: None`.
21/// Callers implementing reconnect with `Last-Event-ID` should treat `None` as
22/// "no change" and retain the previously-seen ID. The "reset" semantic is not
23/// representable without a tri-state type; this simplification is intentional
24/// for JMAP, where bare `id:` reset frames are rare in practice.
25#[derive(Debug, Clone, PartialEq, Eq)]
26#[non_exhaustive]
27pub struct SseFrame {
28 pub event: SseEvent,
29 pub id: Option<String>,
30}
31
32/// A parsed SSE event from a JMAP event source (RFC 8620 §7.3).
33#[derive(Debug, Clone, PartialEq, Eq)]
34#[non_exhaustive]
35pub enum SseEvent {
36 /// A "state" event: maps accountId → (typeName → newState).
37 ///
38 /// Triggers a `/changes` call for each type listed. Wire format:
39 /// `{"@type":"StateChange","changed":{"<accountId>":{"<TypeName>":"<state>"}}}`
40 StateChange(push::StateChange),
41 /// Unrecognized event type, keepalive, or parse failure.
42 ///
43 /// `event_type` carries the value of the SSE `event:` field for
44 /// diagnostics — e.g. `"ping"` for a keepalive, `"state"` when the
45 /// state payload failed to parse. An empty string means the frame had
46 /// no `event:` field (a keepalive comment or bare data).
47 ///
48 /// Callers should silently ignore this variant; log `event_type` when
49 /// debugging unexpected parse failures.
50 Unknown { event_type: String },
51}
52
53/// Parse a single SSE block (the text between two blank lines) into an [`SseFrame`].
54///
55/// Returns an [`SseFrame`] with `event = SseEvent::Unknown` for empty blocks,
56/// keepalives, or unrecognized event types. Never panics. Malformed `data:`
57/// JSON is silently ignored and returns `Unknown` rather than propagating an
58/// error.
59///
60/// `SseFrame::id` carries the value of the `id:` line, if present. Callers
61/// should track this and send it as `Last-Event-ID` on reconnect per RFC 8620
62/// §7.3.
63pub fn parse_sse_block(block: &str) -> SseFrame {
64 let mut event_type: Option<&str> = None;
65 let mut data_lines: Vec<&str> = Vec::new();
66 let mut id: Option<String> = None;
67
68 for line in block.lines() {
69 // RFC 8895 §9.1: if value starts with U+0020 SPACE, remove exactly that one space.
70 if let Some(value) = line.strip_prefix("event:") {
71 event_type = Some(value.strip_prefix(' ').unwrap_or(value));
72 } else if let Some(value) = line.strip_prefix("data:") {
73 data_lines.push(value.strip_prefix(' ').unwrap_or(value));
74 } else if let Some(value) = line.strip_prefix("id:") {
75 let v = value.strip_prefix(' ').unwrap_or(value);
76 id = if v.is_empty() {
77 None
78 } else {
79 Some(v.to_owned())
80 };
81 }
82 // Comments (lines starting with ':') and unknown fields are silently ignored.
83 }
84
85 let event = match event_type {
86 Some("state") => match data_lines.as_slice() {
87 [] => SseEvent::Unknown {
88 event_type: "state".to_owned(),
89 }, // no data: lines
90 [single] => parse_state_data("state", single),
91 _ => parse_state_data("state", &data_lines.join("\n")),
92 },
93 Some(t) => SseEvent::Unknown {
94 event_type: t.to_owned(),
95 },
96 None => SseEvent::Unknown {
97 event_type: String::new(),
98 },
99 };
100
101 SseFrame { event, id }
102}
103
104/// Parse the data payload of a "state" event.
105///
106/// `event_type` is passed through to `SseEvent::Unknown` on failure so
107/// callers can distinguish a parse error on a "state" event from a parse
108/// error on some other type.
109///
110/// Accepts both the bare `{"changed":{...}}` shape and the shape with
111/// `"@type":"StateChange"` per RFC 8620 §7.3 (StateChange object definition).
112/// The `@type` field is stripped before deserialization; only `changed` is used.
113fn parse_state_data(event_type: &str, data: &str) -> SseEvent {
114 match try_parse_state_change(data) {
115 Some(sc) => SseEvent::StateChange(sc),
116 None => SseEvent::Unknown {
117 event_type: event_type.to_owned(),
118 },
119 }
120}
121
122/// Try to parse a StateChange payload; returns `None` on any parse failure.
123fn try_parse_state_change(data: &str) -> Option<push::StateChange> {
124 let mut v = serde_json::from_str::<serde_json::Value>(data).ok()?;
125 let obj = v.as_object_mut()?;
126 let changed_val = obj.remove("changed")?;
127 let changed =
128 serde_json::from_value::<HashMap<Id, HashMap<String, State>>>(changed_val).ok()?;
129 Some(push::StateChange { changed })
130}
131
132#[cfg(test)]
133mod tests {
134 use super::*;
135
136 /// Oracle: spec §7 "state" event format.
137 #[test]
138 fn parse_state_event() {
139 let block = "event: state\ndata: {\"changed\":{\"acc1\":{\"Message\":\"s42\"}}}";
140 let SseFrame { event, .. } = parse_sse_block(block);
141 match event {
142 SseEvent::StateChange(sc) => {
143 assert_eq!(
144 sc.changed
145 .get("acc1")
146 .and_then(|m| m.get("Message"))
147 .map(|s| s.as_ref()),
148 Some("s42"),
149 "changed[acc1][Message] must equal s42"
150 );
151 }
152 other => panic!("expected StateChange, got {other:?}"),
153 }
154 }
155
156 /// Oracle: spec §7 "state" event format — @type field is present.
157 /// The @type field must be accepted and ignored; only "changed" matters.
158 #[test]
159 fn parse_state_event_with_type_field() {
160 let block = "event: state\ndata: {\"@type\":\"StateChange\",\"changed\":{\"acc1\":{\"Message\":\"s42\"}}}";
161 let SseFrame { event, .. } = parse_sse_block(block);
162 match event {
163 SseEvent::StateChange(sc) => {
164 assert_eq!(
165 sc.changed
166 .get("acc1")
167 .and_then(|m| m.get("Message"))
168 .map(|s| s.as_ref()),
169 Some("s42"),
170 "changed[acc1][Message] must equal s42"
171 );
172 }
173 other => panic!("expected StateChange, got {other:?}"),
174 }
175 }
176
177 /// Oracle: RFC 8895 §9 — unrecognized event type must yield Unknown.
178 #[test]
179 fn parse_unknown_event() {
180 let block = "event: ping\ndata: {}";
181 let SseFrame { event, .. } = parse_sse_block(block);
182 assert!(
183 matches!(event, SseEvent::Unknown { .. }),
184 "unrecognized event type must yield Unknown"
185 );
186 }
187
188 /// Oracle: RFC 8895 §9 — empty block (keepalive) must yield Unknown.
189 #[test]
190 fn parse_empty_block() {
191 let SseFrame { event, id } = parse_sse_block("");
192 assert!(
193 matches!(event, SseEvent::Unknown { .. }),
194 "empty block must yield Unknown"
195 );
196 assert!(id.is_none(), "empty block must have no id");
197 }
198
199 /// Oracle: security requirement §G — malformed JSON in data must yield
200 /// Unknown, never panic or propagate an error.
201 #[test]
202 fn parse_malformed_data_json() {
203 let block = "event: state\ndata: not-json";
204 let SseFrame { event, .. } = parse_sse_block(block);
205 assert!(
206 matches!(event, SseEvent::Unknown { .. }),
207 "malformed JSON must yield Unknown, not panic or error"
208 );
209 }
210
211 /// Oracle: RFC 8895 §9 — `id:` line value must be returned in `SseFrame::id`.
212 #[test]
213 fn parse_id_line() {
214 let block = "id: evt-42\nevent: state\ndata: {\"changed\":{}}";
215 let SseFrame { event, id } = parse_sse_block(block);
216 assert_eq!(id.as_deref(), Some("evt-42"), "id must be evt-42");
217 assert!(
218 matches!(event, SseEvent::StateChange(_)),
219 "must still parse as StateChange"
220 );
221 }
222
223 /// Oracle: RFC 8895 §9 — multiple `data:` lines must be joined with `\n`.
224 ///
225 /// Two data: lines are collected and joined. If only the first line were
226 /// used, a complete single-line state JSON would parse as StateChange. Because
227 /// the second data: line is appended (joined with '\n'), the combined
228 /// string is invalid JSON, so the result must be Unknown — proving both
229 /// lines are captured.
230 #[test]
231 fn parse_multiline_data() {
232 // First data: line alone is a complete, valid state JSON object.
233 // Second data: line appends "extra", making the joined string invalid JSON.
234 // Result must be Unknown (not StateChange), proving both lines are joined.
235 let block = concat!(
236 "event: state\n",
237 "data: {\"changed\":{\"acc1\":{\"Message\":\"s1\"}}}\n",
238 "data: extra"
239 );
240 let SseFrame { event, .. } = parse_sse_block(block);
241 assert!(
242 matches!(event, SseEvent::Unknown { .. }),
243 "both data: lines must be joined: first-line-valid JSON + second line = Unknown"
244 );
245 }
246
247 /// Verify SseEvent does not contain Typing or Presence variants.
248 /// This match will fail to compile if either variant is ever reintroduced.
249 #[test]
250 fn sse_event_no_typing_or_presence() {
251 let e = SseEvent::Unknown {
252 event_type: String::new(),
253 };
254 match e {
255 SseEvent::StateChange(_) => {}
256 SseEvent::Unknown { .. } => {}
257 }
258 }
259}