Skip to main content

jmap_types/
push.rs

1//! RFC 8620 §7 push wire types: [`StateChange`], [`PushSubscription`],
2//! [`PushKeys`], and [`PushVerification`], plus the RFC 8030 §5.3 Web Push
3//! [`Urgency`].
4//!
5//! These are shared by servers (which build and send them) and clients
6//! (which receive them over SSE, WebSocket, or a push endpoint).
7//!
8//! # The `@type` discriminant
9//!
10//! [`StateChange`] and [`PushVerification`] carry an `@type` property that
11//! "MUST be the string" naming the object (RFC 8620 §7.1, §7.2.2). Their
12//! (De)Serialize impls are hand-written rather than derived: serialize always
13//! emits the `@type` string; deserialize consumes it, rejects any other value,
14//! and never leaves it in `extra`. An absent `@type` is accepted, because a
15//! receiver that dispatched on `@type` (or on an SSE event name) has already
16//! established what the object is.
17
18use std::collections::BTreeMap;
19use std::fmt;
20
21use serde::de::DeserializeOwned;
22use serde::ser::SerializeMap;
23use serde::{Deserialize, Deserializer, Serialize, Serializer};
24
25use crate::id::{Id, State, UTCDate};
26
27/// The Web Push `Urgency` header (RFC 8030 §5.3), shared by every crate
28/// that sends or classifies a push: `urgency-option = ("very-low" / "low"
29/// / "normal" / "high")`. Variants order "in order of increasing
30/// urgency", so `max` picks the most urgent. "A push message without the
31/// Urgency header field defaults to a value of "normal"."
32#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
33pub enum Urgency {
34    /// `very-low`
35    VeryLow,
36    /// `low`
37    Low,
38    /// `normal`, the default when the header is absent.
39    Normal,
40    /// `high`
41    High,
42}
43
44impl Urgency {
45    /// The header value.
46    pub const fn as_str(self) -> &'static str {
47        match self {
48            Self::VeryLow => "very-low",
49            Self::Low => "low",
50            Self::Normal => "normal",
51            Self::High => "high",
52        }
53    }
54}
55
56impl Serialize for Urgency {
57    fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
58        serializer.serialize_str(self.as_str())
59    }
60}
61
62/// Accepts only the four `urgency-option` strings: RFC 8030 §5.3 closes
63/// the set, so any other string is an error rather than an `Other` value.
64impl<'de> Deserialize<'de> for Urgency {
65    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
66        const VARIANTS: &[&str] = &["very-low", "low", "normal", "high"];
67        let s = String::deserialize(deserializer)?;
68        match s.as_str() {
69            "very-low" => Ok(Self::VeryLow),
70            "low" => Ok(Self::Low),
71            "normal" => Ok(Self::Normal),
72            "high" => Ok(Self::High),
73            other => Err(serde::de::Error::unknown_variant(other, VARIANTS)),
74        }
75    }
76}
77
78/// `@type` value of a [`StateChange`] (RFC 8620 §7.1).
79const STATE_CHANGE_TYPE: &str = "StateChange";
80
81/// `@type` value of a [`PushVerification`] (RFC 8620 §7.2.2).
82const PUSH_VERIFICATION_TYPE: &str = "PushVerification";
83
84/// Placeholder printed by the `Debug` impls in place of secret values.
85const REDACTED: &str = "[REDACTED]";
86
87/// A state change push notification (RFC 8620 §7.1).
88///
89/// Sent by the server over SSE, WebSocket, or to a [`PushSubscription`] URL.
90/// The wire form always carries `"@type": "StateChange"` (RFC 8620 §7.1):
91/// serialize always emits it; deserialize rejects any other value, accepts
92/// its absence and never leaves it in `extra`.
93///
94/// # `extra` equality is feature-flag-dependent (bd:JMAP-6r7c.43)
95///
96/// The derived `PartialEq` / `Eq` impl compares `extra` as a
97/// `serde_json::Map`, which is order-insensitive under the workspace default
98/// (BTreeMap-backed) and order-sensitive if any crate in the dependency graph
99/// enables `serde_json/preserve_order`.
100#[non_exhaustive]
101#[derive(Debug, Clone, PartialEq, Eq)]
102pub struct StateChange {
103    /// For each account that changed: maps data-type name to the new [`State`] token.
104    ///
105    /// Outer key: account [`Id`].  Inner key: JMAP data-type name (e.g. `"Email"`).
106    /// Inner value: new opaque state string; pass to `Email/changes` etc. as `sinceState`.
107    pub changed: BTreeMap<Id, BTreeMap<String, State>>,
108
109    /// Catch-all for vendor / site / private extension fields not covered
110    /// by the typed fields above. Preserves unknown fields across
111    /// deserialize/serialize round-trip per workspace extras-preservation
112    /// policy (see workspace AGENTS.md). Never contains `@type`.
113    pub extra: serde_json::Map<String, serde_json::Value>,
114}
115
116impl StateChange {
117    /// A `StateChange` with the given `changed` map and no extension fields.
118    pub fn new(changed: BTreeMap<Id, BTreeMap<String, State>>) -> Self {
119        Self {
120            changed,
121            extra: serde_json::Map::new(),
122        }
123    }
124}
125
126impl Serialize for StateChange {
127    fn serialize<S: Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
128        let mut map = s.serialize_map(None)?;
129        map.serialize_entry("@type", STATE_CHANGE_TYPE)?;
130        map.serialize_entry("changed", &self.changed)?;
131        for (k, v) in &self.extra {
132            map.serialize_entry(k, v)?;
133        }
134        map.end()
135    }
136}
137
138impl<'de> Deserialize<'de> for StateChange {
139    fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
140        let mut extra = serde_json::Map::deserialize(d)?;
141        take_type(&mut extra, STATE_CHANGE_TYPE)?;
142        let changed = take_field(&mut extra, "changed")?;
143        Ok(StateChange { changed, extra })
144    }
145}
146
147/// A push subscription (RFC 8620 §7.2).
148///
149/// Every property other than `deviceClientId` is optional here because the
150/// same type carries a create request (no `id`, `verificationCode` or
151/// `expires`), a `PushSubscription/get` result (`url` and `keys` are never
152/// returned, RFC 8620 §7.2.1), and the server's stored record. `None` fields
153/// are omitted on serialize; a JSON `null` deserializes to `None`.
154///
155/// Extension properties, such as `chatPush` from
156/// draft-atwood-jmap-chat-push-00, are carried in [`extra`](Self::extra).
157///
158/// The `Debug` impl redacts `url`, `keys.auth` and `verificationCode`.
159#[non_exhaustive]
160#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
161#[serde(rename_all = "camelCase")]
162pub struct PushSubscription {
163    /// The `id` property: server-set and immutable.
164    #[serde(default, skip_serializing_if = "Option::is_none")]
165    pub id: Option<Id>,
166    /// The `deviceClientId` property: identifies the client and device.
167    pub device_client_id: String,
168    /// The `url` property: absolute URL the server POSTs pushes to. The RFC
169    /// requires it to begin with `"https://"`; this type does not check that.
170    #[serde(default, skip_serializing_if = "Option::is_none")]
171    pub url: Option<String>,
172    /// The `keys` property: RFC 8291 encryption keys, or `None` for none.
173    #[serde(default, skip_serializing_if = "Option::is_none")]
174    pub keys: Option<PushKeys>,
175    /// The `verificationCode` property: `None` until the client echoes the
176    /// code from the [`PushVerification`].
177    #[serde(default, skip_serializing_if = "Option::is_none")]
178    pub verification_code: Option<String>,
179    /// The `expires` property: the server sends nothing after this time.
180    #[serde(default, skip_serializing_if = "Option::is_none")]
181    pub expires: Option<UTCDate>,
182    /// The `types` property: data types to push; `None` means all types.
183    #[serde(default, skip_serializing_if = "Option::is_none")]
184    pub types: Option<Vec<String>>,
185    /// Catch-all for extension properties (e.g. `chatPush`) and vendor /
186    /// site / private fields not covered by the typed fields above, per
187    /// workspace extras-preservation policy (see workspace AGENTS.md).
188    #[serde(flatten, default, skip_serializing_if = "serde_json::Map::is_empty")]
189    pub extra: serde_json::Map<String, serde_json::Value>,
190}
191
192impl fmt::Debug for PushSubscription {
193    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
194        f.debug_struct("PushSubscription")
195            .field("id", &self.id)
196            .field("device_client_id", &self.device_client_id)
197            .field("url", &self.url.as_ref().map(|_| REDACTED))
198            .field("keys", &self.keys)
199            .field(
200                "verification_code",
201                &self.verification_code.as_ref().map(|_| REDACTED),
202            )
203            .field("expires", &self.expires)
204            .field("types", &self.types)
205            .field("extra", &self.extra)
206            .finish()
207    }
208}
209
210/// `PushSubscription` has no property selector enum; `PushSubscription/get`
211/// takes property names directly.
212impl crate::JmapObject for PushSubscription {
213    const TYPE_NAME: &'static str = "PushSubscription";
214    type Property = ();
215}
216
217/// Property attributes from RFC 8620 §7.2 (rfc8620.txt:3593, 3597, 3618,
218/// 3623): `id` is "(immutable; server-set)"; `deviceClientId`, `url` and
219/// `keys` are "(immutable)"; `verificationCode`, `expires` and `types`
220/// carry no attribute.
221impl crate::SetObject for PushSubscription {
222    type Patch = crate::PatchObject;
223    const SERVER_SET: &'static [&'static str] = &["id"];
224    const CLIENT_SETTABLE: &'static [&'static str] = &[
225        "deviceClientId",
226        "url",
227        "keys",
228        "verificationCode",
229        "expires",
230        "types",
231    ];
232    const IMMUTABLE: &'static [&'static str] = &["deviceClientId", "url", "keys"];
233}
234
235/// The `keys` object of a [`PushSubscription`] (RFC 8620 §7.2).
236///
237/// "The object MUST have the following properties: p256dh ... auth", so both
238/// are required on deserialize. The `Debug` impl redacts `auth`.
239#[non_exhaustive]
240#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
241pub struct PushKeys {
242    /// The `p256dh` property: the client's P-256 ECDH public key (RFC 8291),
243    /// URL-safe base64.
244    pub p256dh: String,
245    /// The `auth` property: the RFC 8291 authentication secret, URL-safe
246    /// base64.
247    pub auth: String,
248    /// Catch-all for vendor / site / private extension fields, per
249    /// workspace extras-preservation policy (see workspace AGENTS.md).
250    #[serde(flatten, default, skip_serializing_if = "serde_json::Map::is_empty")]
251    pub extra: serde_json::Map<String, serde_json::Value>,
252}
253
254impl fmt::Debug for PushKeys {
255    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
256        f.debug_struct("PushKeys")
257            .field("p256dh", &self.p256dh)
258            .field("auth", &REDACTED)
259            .field("extra", &self.extra)
260            .finish()
261    }
262}
263
264/// The object pushed to a new subscription's URL (RFC 8620 §7.2.2).
265///
266/// The wire form always carries `"@type": "PushVerification"` (RFC 8620
267/// §7.2.2): serialize always emits it; deserialize rejects any other value,
268/// accepts its absence and never leaves it in `extra`. The `Debug` impl
269/// redacts `verificationCode`.
270#[non_exhaustive]
271#[derive(Clone, PartialEq, Eq)]
272pub struct PushVerification {
273    /// The `pushSubscriptionId` property: the id of the new subscription.
274    pub push_subscription_id: Id,
275    /// The `verificationCode` property: the code the client must set on the
276    /// subscription before the server sends it anything else.
277    pub verification_code: String,
278    /// Catch-all for vendor / site / private extension fields, per
279    /// workspace extras-preservation policy (see workspace AGENTS.md).
280    /// Never contains `@type`.
281    pub extra: serde_json::Map<String, serde_json::Value>,
282}
283
284impl fmt::Debug for PushVerification {
285    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
286        f.debug_struct("PushVerification")
287            .field("push_subscription_id", &self.push_subscription_id)
288            .field("verification_code", &REDACTED)
289            .field("extra", &self.extra)
290            .finish()
291    }
292}
293
294impl Serialize for PushVerification {
295    fn serialize<S: Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
296        let mut map = s.serialize_map(None)?;
297        map.serialize_entry("@type", PUSH_VERIFICATION_TYPE)?;
298        map.serialize_entry("pushSubscriptionId", &self.push_subscription_id)?;
299        map.serialize_entry("verificationCode", &self.verification_code)?;
300        for (k, v) in &self.extra {
301            map.serialize_entry(k, v)?;
302        }
303        map.end()
304    }
305}
306
307impl<'de> Deserialize<'de> for PushVerification {
308    fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
309        let mut extra = serde_json::Map::deserialize(d)?;
310        take_type(&mut extra, PUSH_VERIFICATION_TYPE)?;
311        let push_subscription_id = take_field(&mut extra, "pushSubscriptionId")?;
312        let verification_code = take_field(&mut extra, "verificationCode")?;
313        Ok(PushVerification {
314            push_subscription_id,
315            verification_code,
316            extra,
317        })
318    }
319}
320
321/// Removes `@type` from `map`, failing if it is present and not `expected`.
322fn take_type<E: serde::de::Error>(
323    map: &mut serde_json::Map<String, serde_json::Value>,
324    expected: &str,
325) -> Result<(), E> {
326    match map.remove("@type") {
327        None => Ok(()),
328        Some(serde_json::Value::String(s)) if s == expected => Ok(()),
329        Some(serde_json::Value::String(_)) => {
330            Err(E::custom(format!("@type must be \"{expected}\"")))
331        }
332        Some(other) => Err(E::custom(format!(
333            "@type must be a string, found {}",
334            crate::json_type_name(&other)
335        ))),
336    }
337}
338
339/// Removes the required property `key` from `map` and deserializes it.
340fn take_field<T: DeserializeOwned, E: serde::de::Error>(
341    map: &mut serde_json::Map<String, serde_json::Value>,
342    key: &'static str,
343) -> Result<T, E> {
344    let value = map.remove(key).ok_or_else(|| E::missing_field(key))?;
345    serde_json::from_value(value).map_err(E::custom)
346}
347
348#[cfg(test)]
349mod tests {
350    use super::*;
351
352    /// Oracle: hand-written JSON whose `@type` carries a canary. RFC 8620
353    /// §3.6.1 makes an error `description` "unstructured English text
354    /// suitable for displaying to a developer", so the client value must not
355    /// be echoed; only its JSON type (RFC 8259 §3 "object") is named.
356    #[test]
357    fn push_verification_type_error_does_not_echo_value() {
358        for (raw, want) in [
359            (
360                r#"{"@type":{"note":"CANARY-kwqn"},"pushSubscriptionId":"p","verificationCode":"c"}"#,
361                "@type must be a string, found object",
362            ),
363            (
364                r#"{"@type":"CANARY-kwqn","pushSubscriptionId":"p","verificationCode":"c"}"#,
365                "@type must be \"PushVerification\"",
366            ),
367        ] {
368            let err = serde_json::from_str::<PushVerification>(raw)
369                .unwrap_err()
370                .to_string();
371            assert!(!err.contains("CANARY-kwqn"), "{err}");
372            assert!(err.contains(want), "{err}");
373        }
374    }
375
376    /// Every `PushSubscription` property RFC 8620 §7.2 defines is classified
377    /// once. The struct literal makes a new field a compile error here.
378    #[test]
379    fn push_subscription_properties_are_classified() {
380        let sub = PushSubscription {
381            id: Some(Id::from("ps1")),
382            device_client_id: "dev".into(),
383            url: Some("https://push.example.com/x".into()),
384            keys: Some(PushKeys {
385                p256dh: "p".into(),
386                auth: "a".into(),
387                extra: serde_json::Map::new(),
388            }),
389            verification_code: Some("code".into()),
390            expires: Some(UTCDate::from("2026-01-01T00:00:00Z")),
391            types: Some(vec!["Email".into()]),
392            extra: serde_json::Map::new(),
393        };
394        assert_eq!(crate::check_property_classification(&sub), Ok(()));
395    }
396    use serde_json::json;
397
398    /// `StateChange.extra` captures unknown fields on deserialize and
399    /// flattens them on serialize (round-trip).
400    #[test]
401    fn state_change_preserves_vendor_extras() {
402        let raw = json!({
403            "changed": {
404                "acc1": { "Email": "s42" }
405            },
406            "acmeCorpSequence": 17
407        });
408        let obj: StateChange = serde_json::from_value(raw).expect("StateChange must deserialize");
409        assert_eq!(
410            obj.extra.get("acmeCorpSequence").and_then(|v| v.as_u64()),
411            Some(17)
412        );
413
414        // Round-trip: serializing back must reproduce the vendor field.
415        let v = serde_json::to_value(&obj).expect("StateChange must serialize");
416        assert_eq!(v["acmeCorpSequence"], json!(17));
417    }
418
419    /// bd:JMAP-6r7c.43 — regression-guard for the workspace's
420    /// serde_json/preserve_order posture. Two StateChange values with the
421    /// same `extra` keys inserted in different orders MUST compare equal
422    /// under the workspace's default configuration (BTreeMap-backed
423    /// serde_json::Map). If a future Cargo.lock or workspace-level feature
424    /// change accidentally enables `preserve_order`, this assertion fails
425    /// loudly and surfaces the SemVer-policy break before downstream
426    /// consumers hit silently-different equality semantics.
427    ///
428    /// Both values are deserialized from JSON (the wire path) so the test
429    /// exercises the same construction code path consumers use.
430    #[test]
431    fn extra_equality_is_order_insensitive_under_workspace_flags() {
432        // Two equivalent JSON payloads with different key-insertion orders
433        // for the vendor extras. Under BTreeMap-backed Map the keys are
434        // re-sorted lexicographically on deserialize, so the resulting
435        // structures compare equal.
436        let raw_a = json!({
437            "changed": {"acc1": {"Email": "s1"}},
438            "vendorA": 1,
439            "vendorB": 2
440        });
441        let raw_b = json!({
442            "changed": {"acc1": {"Email": "s1"}},
443            "vendorB": 2,
444            "vendorA": 1
445        });
446
447        let a: StateChange = serde_json::from_value(raw_a).expect("a must deserialize");
448        let b: StateChange = serde_json::from_value(raw_b).expect("b must deserialize");
449
450        assert_eq!(
451            a, b,
452            "extra-map equality is order-insensitive under the workspace's \
453             default serde_json::Map (BTreeMap-backed); if this fails, \
454             check whether preserve_order has been enabled in the dep graph"
455        );
456    }
457
458    /// Oracle: RFC 8030 §5.3 grammar `urgency-option = ("very-low" / "low"
459    /// / "normal" / "high")`, listed "In order of increasing urgency".
460    #[test]
461    fn urgency_wire_values_in_increasing_order() {
462        let ordered = [
463            Urgency::VeryLow,
464            Urgency::Low,
465            Urgency::Normal,
466            Urgency::High,
467        ];
468        let wire: Vec<&str> = ordered.iter().map(|u| u.as_str()).collect();
469        assert_eq!(wire, ["very-low", "low", "normal", "high"]);
470        assert!(ordered.windows(2).all(|w| w[0] < w[1]));
471    }
472
473    /// Oracle: RFC 8030 §5.3 `urgency-option = ("very-low" / "low" /
474    /// "normal" / "high")`. Each string round-trips as a JSON string; a
475    /// string outside the grammar, or another case, is rejected.
476    #[test]
477    fn urgency_serde_accepts_only_rfc8030_values() {
478        for (wire, urgency) in [
479            ("very-low", Urgency::VeryLow),
480            ("low", Urgency::Low),
481            ("normal", Urgency::Normal),
482            ("high", Urgency::High),
483        ] {
484            let parsed: Urgency = serde_json::from_value(json!(wire)).expect(wire);
485            assert_eq!(parsed, urgency);
486            assert_eq!(serde_json::to_value(urgency).expect(wire), json!(wire));
487        }
488        for bad in ["urgent", "High", "very_low", ""] {
489            assert!(
490                serde_json::from_value::<Urgency>(json!(bad)).is_err(),
491                "{bad:?}"
492            );
493        }
494    }
495}