Skip to main content

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