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}