Skip to main content

jmap_types/
push.rs

1//! RFC 8620 §7 push wire types: [`StateChange`], [`PushSubscription`],
2//! [`PushKeys`], and [`PushVerification`].
3//!
4//! These are shared by servers (which build and send them) and clients
5//! (which receive them over SSE, WebSocket, or a push endpoint).
6//!
7//! # The `@type` discriminant
8//!
9//! [`StateChange`] and [`PushVerification`] carry an `@type` property that
10//! "MUST be the string" naming the object (RFC 8620 §7.1, §7.2.2). Their
11//! (De)Serialize impls are hand-written rather than derived: serialize always
12//! emits the `@type` string; deserialize consumes it, rejects any other value,
13//! and never leaves it in `extra`. An absent `@type` is accepted, because a
14//! receiver that dispatched on `@type` (or on an SSE event name) has already
15//! established what the object is.
16
17use std::collections::BTreeMap;
18use std::fmt;
19
20use serde::de::DeserializeOwned;
21use serde::ser::SerializeMap;
22use serde::{Deserialize, Deserializer, Serialize, Serializer};
23
24use crate::id::{Id, State, UTCDate};
25
26/// `@type` value of a [`StateChange`] (RFC 8620 §7.1).
27const STATE_CHANGE_TYPE: &str = "StateChange";
28
29/// `@type` value of a [`PushVerification`] (RFC 8620 §7.2.2).
30const PUSH_VERIFICATION_TYPE: &str = "PushVerification";
31
32/// Placeholder printed by the `Debug` impls in place of secret values.
33const REDACTED: &str = "[REDACTED]";
34
35/// A state change push notification (RFC 8620 §7.1).
36///
37/// Sent by the server over SSE, WebSocket, or to a [`PushSubscription`] URL.
38/// The wire form always carries `"@type": "StateChange"`; see the
39/// [module docs](self) for how the discriminant is handled.
40///
41/// # `extra` equality is feature-flag-dependent (bd:JMAP-6r7c.43)
42///
43/// The derived `PartialEq` / `Eq` impl compares `extra` as a
44/// `serde_json::Map`, which is order-insensitive under the workspace default
45/// (BTreeMap-backed) and order-sensitive if any crate in the dependency graph
46/// enables `serde_json/preserve_order`.
47#[non_exhaustive]
48#[derive(Debug, Clone, PartialEq, Eq)]
49pub struct StateChange {
50    /// For each account that changed: maps data-type name to the new [`State`] token.
51    ///
52    /// Outer key: account [`Id`].  Inner key: JMAP data-type name (e.g. `"Email"`).
53    /// Inner value: new opaque state string; pass to `Email/changes` etc. as `sinceState`.
54    pub changed: BTreeMap<Id, BTreeMap<String, State>>,
55
56    /// Catch-all for vendor / site / private extension fields not covered
57    /// by the typed fields above. Preserves unknown fields across
58    /// deserialize/serialize round-trip per workspace extras-preservation
59    /// policy (see workspace AGENTS.md). Never contains `@type`.
60    pub extra: serde_json::Map<String, serde_json::Value>,
61}
62
63impl StateChange {
64    /// A `StateChange` with the given `changed` map and no extension fields.
65    pub fn new(changed: BTreeMap<Id, BTreeMap<String, State>>) -> Self {
66        Self {
67            changed,
68            extra: serde_json::Map::new(),
69        }
70    }
71}
72
73impl Serialize for StateChange {
74    fn serialize<S: Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
75        let mut map = s.serialize_map(None)?;
76        map.serialize_entry("@type", STATE_CHANGE_TYPE)?;
77        map.serialize_entry("changed", &self.changed)?;
78        for (k, v) in &self.extra {
79            map.serialize_entry(k, v)?;
80        }
81        map.end()
82    }
83}
84
85impl<'de> Deserialize<'de> for StateChange {
86    fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
87        let mut extra = serde_json::Map::deserialize(d)?;
88        take_type(&mut extra, STATE_CHANGE_TYPE)?;
89        let changed = take_field(&mut extra, "changed")?;
90        Ok(StateChange { changed, extra })
91    }
92}
93
94/// A push subscription (RFC 8620 §7.2).
95///
96/// Every property other than `deviceClientId` is optional here because the
97/// same type carries a create request (no `id`, `verificationCode` or
98/// `expires`), a `PushSubscription/get` result (`url` and `keys` are never
99/// returned, RFC 8620 §7.2.1), and the server's stored record. `None` fields
100/// are omitted on serialize; a JSON `null` deserializes to `None`.
101///
102/// Extension properties, such as `chatPush` from
103/// draft-atwood-jmap-chat-push-00, are carried in [`extra`](Self::extra).
104///
105/// The `Debug` impl redacts `url`, `keys.auth` and `verificationCode`.
106#[non_exhaustive]
107#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
108#[serde(rename_all = "camelCase")]
109pub struct PushSubscription {
110    /// The `id` property: server-set and immutable.
111    #[serde(default, skip_serializing_if = "Option::is_none")]
112    pub id: Option<Id>,
113    /// The `deviceClientId` property: identifies the client and device.
114    pub device_client_id: String,
115    /// The `url` property: absolute URL the server POSTs pushes to. The RFC
116    /// requires it to begin with `"https://"`; this type does not check that.
117    #[serde(default, skip_serializing_if = "Option::is_none")]
118    pub url: Option<String>,
119    /// The `keys` property: RFC 8291 encryption keys, or `None` for none.
120    #[serde(default, skip_serializing_if = "Option::is_none")]
121    pub keys: Option<PushKeys>,
122    /// The `verificationCode` property: `None` until the client echoes the
123    /// code from the [`PushVerification`].
124    #[serde(default, skip_serializing_if = "Option::is_none")]
125    pub verification_code: Option<String>,
126    /// The `expires` property: the server sends nothing after this time.
127    #[serde(default, skip_serializing_if = "Option::is_none")]
128    pub expires: Option<UTCDate>,
129    /// The `types` property: data types to push; `None` means all types.
130    #[serde(default, skip_serializing_if = "Option::is_none")]
131    pub types: Option<Vec<String>>,
132    /// Catch-all for extension properties (e.g. `chatPush`) and vendor /
133    /// site / private fields not covered by the typed fields above, per
134    /// workspace extras-preservation policy (see workspace AGENTS.md).
135    #[serde(flatten, default, skip_serializing_if = "serde_json::Map::is_empty")]
136    pub extra: serde_json::Map<String, serde_json::Value>,
137}
138
139impl fmt::Debug for PushSubscription {
140    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
141        f.debug_struct("PushSubscription")
142            .field("id", &self.id)
143            .field("device_client_id", &self.device_client_id)
144            .field("url", &self.url.as_ref().map(|_| REDACTED))
145            .field("keys", &self.keys)
146            .field(
147                "verification_code",
148                &self.verification_code.as_ref().map(|_| REDACTED),
149            )
150            .field("expires", &self.expires)
151            .field("types", &self.types)
152            .field("extra", &self.extra)
153            .finish()
154    }
155}
156
157/// The `keys` object of a [`PushSubscription`] (RFC 8620 §7.2).
158///
159/// "The object MUST have the following properties: p256dh ... auth", so both
160/// are required on deserialize. The `Debug` impl redacts `auth`.
161#[non_exhaustive]
162#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
163pub struct PushKeys {
164    /// The `p256dh` property: the client's P-256 ECDH public key (RFC 8291),
165    /// URL-safe base64.
166    pub p256dh: String,
167    /// The `auth` property: the RFC 8291 authentication secret, URL-safe
168    /// base64.
169    pub auth: String,
170    /// Catch-all for vendor / site / private extension fields, per
171    /// workspace extras-preservation policy (see workspace AGENTS.md).
172    #[serde(flatten, default, skip_serializing_if = "serde_json::Map::is_empty")]
173    pub extra: serde_json::Map<String, serde_json::Value>,
174}
175
176impl fmt::Debug for PushKeys {
177    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
178        f.debug_struct("PushKeys")
179            .field("p256dh", &self.p256dh)
180            .field("auth", &REDACTED)
181            .field("extra", &self.extra)
182            .finish()
183    }
184}
185
186/// The object pushed to a new subscription's URL (RFC 8620 §7.2.2).
187///
188/// The wire form always carries `"@type": "PushVerification"`; see the
189/// [module docs](self) for how the discriminant is handled. The `Debug` impl
190/// redacts `verificationCode`.
191#[non_exhaustive]
192#[derive(Clone, PartialEq, Eq)]
193pub struct PushVerification {
194    /// The `pushSubscriptionId` property: the id of the new subscription.
195    pub push_subscription_id: Id,
196    /// The `verificationCode` property: the code the client must set on the
197    /// subscription before the server sends it anything else.
198    pub verification_code: String,
199    /// Catch-all for vendor / site / private extension fields, per
200    /// workspace extras-preservation policy (see workspace AGENTS.md).
201    /// Never contains `@type`.
202    pub extra: serde_json::Map<String, serde_json::Value>,
203}
204
205impl fmt::Debug for PushVerification {
206    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
207        f.debug_struct("PushVerification")
208            .field("push_subscription_id", &self.push_subscription_id)
209            .field("verification_code", &REDACTED)
210            .field("extra", &self.extra)
211            .finish()
212    }
213}
214
215impl Serialize for PushVerification {
216    fn serialize<S: Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
217        let mut map = s.serialize_map(None)?;
218        map.serialize_entry("@type", PUSH_VERIFICATION_TYPE)?;
219        map.serialize_entry("pushSubscriptionId", &self.push_subscription_id)?;
220        map.serialize_entry("verificationCode", &self.verification_code)?;
221        for (k, v) in &self.extra {
222            map.serialize_entry(k, v)?;
223        }
224        map.end()
225    }
226}
227
228impl<'de> Deserialize<'de> for PushVerification {
229    fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
230        let mut extra = serde_json::Map::deserialize(d)?;
231        take_type(&mut extra, PUSH_VERIFICATION_TYPE)?;
232        let push_subscription_id = take_field(&mut extra, "pushSubscriptionId")?;
233        let verification_code = take_field(&mut extra, "verificationCode")?;
234        Ok(PushVerification {
235            push_subscription_id,
236            verification_code,
237            extra,
238        })
239    }
240}
241
242/// Removes `@type` from `map`, failing if it is present and not `expected`.
243fn take_type<E: serde::de::Error>(
244    map: &mut serde_json::Map<String, serde_json::Value>,
245    expected: &str,
246) -> Result<(), E> {
247    match map.remove("@type") {
248        None => Ok(()),
249        Some(serde_json::Value::String(s)) if s == expected => Ok(()),
250        Some(other) => Err(E::custom(format!(
251            "@type must be \"{expected}\", found {other}"
252        ))),
253    }
254}
255
256/// Removes the required property `key` from `map` and deserializes it.
257fn take_field<T: DeserializeOwned, E: serde::de::Error>(
258    map: &mut serde_json::Map<String, serde_json::Value>,
259    key: &'static str,
260) -> Result<T, E> {
261    let value = map.remove(key).ok_or_else(|| E::missing_field(key))?;
262    serde_json::from_value(value).map_err(E::custom)
263}
264
265#[cfg(test)]
266mod tests {
267    use super::*;
268    use serde_json::json;
269
270    /// `StateChange.extra` captures unknown fields on deserialize and
271    /// flattens them on serialize (round-trip).
272    #[test]
273    fn state_change_preserves_vendor_extras() {
274        let raw = json!({
275            "changed": {
276                "acc1": { "Email": "s42" }
277            },
278            "acmeCorpSequence": 17
279        });
280        let obj: StateChange = serde_json::from_value(raw).expect("StateChange must deserialize");
281        assert_eq!(
282            obj.extra.get("acmeCorpSequence").and_then(|v| v.as_u64()),
283            Some(17)
284        );
285
286        // Round-trip: serializing back must reproduce the vendor field.
287        let v = serde_json::to_value(&obj).expect("StateChange must serialize");
288        assert_eq!(v["acmeCorpSequence"], json!(17));
289    }
290
291    /// bd:JMAP-6r7c.43 — regression-guard for the workspace's
292    /// serde_json/preserve_order posture. Two StateChange values with the
293    /// same `extra` keys inserted in different orders MUST compare equal
294    /// under the workspace's default configuration (BTreeMap-backed
295    /// serde_json::Map). If a future Cargo.lock or workspace-level feature
296    /// change accidentally enables `preserve_order`, this assertion fails
297    /// loudly and surfaces the SemVer-policy break before downstream
298    /// consumers hit silently-different equality semantics.
299    ///
300    /// Both values are deserialized from JSON (the wire path) so the test
301    /// exercises the same construction code path consumers use.
302    #[test]
303    fn extra_equality_is_order_insensitive_under_workspace_flags() {
304        // Two equivalent JSON payloads with different key-insertion orders
305        // for the vendor extras. Under BTreeMap-backed Map the keys are
306        // re-sorted lexicographically on deserialize, so the resulting
307        // structures compare equal.
308        let raw_a = json!({
309            "changed": {"acc1": {"Email": "s1"}},
310            "vendorA": 1,
311            "vendorB": 2
312        });
313        let raw_b = json!({
314            "changed": {"acc1": {"Email": "s1"}},
315            "vendorB": 2,
316            "vendorA": 1
317        });
318
319        let a: StateChange = serde_json::from_value(raw_a).expect("a must deserialize");
320        let b: StateChange = serde_json::from_value(raw_b).expect("b must deserialize");
321
322        assert_eq!(
323            a, b,
324            "extra-map equality is order-insensitive under the workspace's \
325             default serde_json::Map (BTreeMap-backed); if this fails, \
326             check whether preserve_order has been enabled in the dep graph"
327        );
328    }
329}