Skip to main content

io_jmap/rfc8620/push_subscription/
set.rs

1//! JMAP `PushSubscription/set` coroutine (RFC 8620 §7.2.2): builds a custom
2//! set batch (no generic [`JmapSet`](crate::rfc8620::set::JmapSet) reuse
3//! because the method takes no `accountId` or `ifInState` and returns no
4//! `oldState`/`newState`).
5//!
6//! # Example
7//!
8//! ```rust,no_run
9//! use std::{
10//!     io::{Read, Write},
11//!     net::TcpStream,
12//! };
13//!
14//! use io_jmap::{
15//!     coroutine::{JmapCoroutine, JmapCoroutineState, JmapYield},
16//!     rfc8620::{
17//!         session::JmapSession,
18//!         push_subscription::set::{
19//!             JmapPushSubscriptionCreate, JmapPushSubscriptionSet, JmapPushSubscriptionSetArgs,
20//!         },
21//!     },
22//! };
23//! use secrecy::SecretString;
24//!
25//! // Ready stream needed (TCP-connected, TLS-negociated)
26//! let mut stream = TcpStream::connect("api.example.com:443").unwrap();
27//! let mut buf = [0u8; 4096];
28//!
29//! let session: JmapSession = serde_json::from_str(r#"{
30//!     "username": "",
31//!     "accounts": {},
32//!     "primaryAccounts": {},
33//!     "capabilities": {},
34//!     "apiUrl": "https://api.example.com/jmap/",
35//!     "downloadUrl": "",
36//!     "uploadUrl": "",
37//!     "eventSourceUrl": "",
38//!     "state": ""
39//! }"#).unwrap();
40//! let auth = SecretString::from("Bearer xyz");
41//! let mut args = JmapPushSubscriptionSetArgs::default();
42//! args.create(
43//!     "c1",
44//!     JmapPushSubscriptionCreate {
45//!         device_client_id: "a889-ffea-910".into(),
46//!         url: "https://push.example.com/?device=X8980fc".into(),
47//!         ..Default::default()
48//!     },
49//! );
50//! let mut coroutine = JmapPushSubscriptionSet::new(&session, &auth, args).unwrap();
51//! let mut arg = None;
52//!
53//! let out = loop {
54//!     match coroutine.resume(arg.take()) {
55//!         JmapCoroutineState::Yielded(JmapYield::WantsWrite(bytes)) => {
56//!             stream.write_all(&bytes).unwrap();
57//!         }
58//!         JmapCoroutineState::Yielded(JmapYield::WantsRead) => {
59//!             let n = stream.read(&mut buf).unwrap();
60//!             arg = Some(&buf[..n]);
61//!         }
62//!         JmapCoroutineState::Complete(Ok(out)) => break out,
63//!         JmapCoroutineState::Complete(Err(err)) => panic!("{err}"),
64//!     }
65//! };
66//!
67//! println!("created {} push subscriptions", out.created.len());
68//! ```
69
70use alloc::{collections::BTreeMap, string::String, vec, vec::Vec};
71
72use secrecy::SecretString;
73use serde::{Deserialize, Serialize};
74use thiserror::Error;
75
76use crate::{
77    coroutine::*,
78    jmap_try,
79    rfc8620::{
80        JMAP_CORE_CAPABILITY, error::JmapMethodError, error::JmapSetError,
81        push_subscription::JmapPushSubscription, request::JmapBatch, send::*, session::JmapSession,
82    },
83};
84
85/// A partial [`JmapPushSubscription`] for `PushSubscription/set` create
86/// requests.
87///
88/// `verificationCode` MUST NOT be set on create (RFC 8620 §7.2): the server
89/// pushes a [`super::JmapPushVerification`] to `url` and the client copies the
90/// code back via [`JmapPushSubscriptionUpdate`].
91#[derive(Clone, Debug, Default, Serialize)]
92#[serde(rename_all = "camelCase")]
93pub struct JmapPushSubscriptionCreate {
94    /// An ID unique to the client + device, containing no unobfuscated
95    /// device ID (RFC 8620 §7.2).
96    pub device_client_id: String,
97    /// Absolute `https://` URL the server will POST push messages to.
98    pub url: String,
99    /// Client-generated encryption keys; when supplied, the server MUST
100    /// encrypt all pushed data with them.
101    #[serde(skip_serializing_if = "Option::is_none")]
102    pub keys: Option<JmapPushSubscriptionKeys>,
103    /// RFC 3339 expiry time; the server may clamp it.
104    #[serde(skip_serializing_if = "Option::is_none")]
105    pub expires: Option<String>,
106    /// Type names to restrict pushes to; `None` pushes all types.
107    #[serde(skip_serializing_if = "Option::is_none")]
108    pub types: Option<Vec<String>>,
109}
110
111/// Patch object for `PushSubscription/set` update requests; only `Some`
112/// fields are serialized.
113///
114/// `url` and `keys` are immutable (RFC 8620 §7.2.2): to change them, destroy
115/// the subscription and create a new one.
116#[derive(Clone, Debug, Default, Serialize)]
117#[serde(rename_all = "camelCase")]
118pub struct JmapPushSubscriptionUpdate {
119    /// The code from the pushed [`super::JmapPushVerification`]; an invalid
120    /// code is rejected with an `invalidProperties` set error.
121    #[serde(skip_serializing_if = "Option::is_none")]
122    pub verification_code: Option<String>,
123    /// New RFC 3339 expiry time extending (or shortening) the subscription
124    /// lifetime; the server may clamp it.
125    #[serde(skip_serializing_if = "Option::is_none")]
126    pub expires: Option<String>,
127    /// Type names to restrict pushes to.
128    #[serde(skip_serializing_if = "Option::is_none")]
129    pub types: Option<Vec<String>>,
130}
131
132/// Client-generated Web Push encryption keys (RFC 8620 §7.2), both encoded
133/// in URL-safe base64 as specified by RFC 8291.
134#[derive(Clone, Debug, Serialize, Deserialize)]
135pub struct JmapPushSubscriptionKeys {
136    /// The P-256 ECDH public key.
137    pub p256dh: String,
138    /// The authentication secret.
139    pub auth: String,
140}
141
142/// Failure causes during a JMAP `PushSubscription/set` flow.
143#[derive(Debug, Error)]
144pub enum JmapPushSubscriptionSetError {
145    /// The response carried no method response.
146    #[error("JMAP PushSubscription/set failed: missing response in method_responses")]
147    MissingResponse,
148    /// The inner send coroutine failed.
149    #[error("JMAP PushSubscription/set failed: {0}")]
150    Send(#[from] JmapSendError),
151    /// The method arguments could not be serialized.
152    #[error("JMAP PushSubscription/set failed: serialize args: {0}")]
153    SerializeArgs(#[source] serde_json::Error),
154    /// The method response could not be parsed.
155    #[error("JMAP PushSubscription/set failed: parse response: {0}")]
156    ParseResponse(#[source] serde_json::Error),
157    /// The server returned a method-level error.
158    #[error("JMAP PushSubscription/set failed: {0}")]
159    Method(#[from] JmapMethodError),
160}
161
162/// Arguments for a `PushSubscription/set` request.
163#[derive(Clone, Debug, Default)]
164pub struct JmapPushSubscriptionSetArgs {
165    /// The subscriptions to create, keyed by client id.
166    pub create: BTreeMap<String, JmapPushSubscriptionCreate>,
167    /// The patches to apply, keyed by subscription id.
168    pub update: BTreeMap<String, JmapPushSubscriptionUpdate>,
169    /// The ids of the objects to destroy.
170    pub destroy: Vec<String>,
171}
172
173impl JmapPushSubscriptionSetArgs {
174    /// Queues an object to create under the given client id.
175    pub fn create(
176        &mut self,
177        client_id: impl Into<String>,
178        subscription: JmapPushSubscriptionCreate,
179    ) -> &mut Self {
180        self.create.insert(client_id.into(), subscription);
181        self
182    }
183
184    /// Queues a patch for the object with the given id.
185    pub fn update(
186        &mut self,
187        id: impl Into<String>,
188        patch: JmapPushSubscriptionUpdate,
189    ) -> &mut Self {
190        self.update.insert(id.into(), patch);
191        self
192    }
193
194    /// Queues the object with the given id for destruction.
195    pub fn destroy(&mut self, id: impl Into<String>) -> &mut Self {
196        self.destroy.push(id.into());
197        self
198    }
199}
200
201/// Successful terminal output of [`JmapPushSubscriptionSet`].
202///
203/// No state strings: `PushSubscription/set` returns no `oldState`/`newState`
204/// (RFC 8620 §7.2.2).
205#[derive(Clone, Debug)]
206pub struct JmapPushSubscriptionSetOutput {
207    /// The created subscriptions, keyed by client id.
208    pub created: BTreeMap<String, JmapPushSubscription>,
209    /// The updated subscriptions, keyed by id.
210    pub updated: BTreeMap<String, Option<JmapPushSubscription>>,
211    /// Ids of the destroyed objects.
212    pub destroyed: Vec<String>,
213    /// The failed creates, keyed by client id.
214    pub not_created: BTreeMap<String, JmapSetError>,
215    /// The failed updates, keyed by id.
216    pub not_updated: BTreeMap<String, JmapSetError>,
217    /// The failed destroys, keyed by id.
218    pub not_destroyed: BTreeMap<String, JmapSetError>,
219    /// Whether the server indicated the connection can be reused.
220    pub keep_alive: bool,
221}
222
223/// I/O-free coroutine for the JMAP `PushSubscription/set` method.
224pub struct JmapPushSubscriptionSet {
225    state: State,
226}
227
228impl JmapPushSubscriptionSet {
229    /// Prepares the method call request and builds the coroutine.
230    pub fn new(
231        session: &JmapSession,
232        http_auth: &SecretString,
233        args: JmapPushSubscriptionSetArgs,
234    ) -> Result<Self, JmapPushSubscriptionSetError> {
235        let json_args = serde_json::to_value(PushSubscriptionSetRequest {
236            create: args.create,
237            update: args.update,
238            destroy: args.destroy,
239        })
240        .map_err(JmapPushSubscriptionSetError::SerializeArgs)?;
241
242        let mut batch = JmapBatch::new();
243        batch.add("PushSubscription/set", json_args);
244        let request = batch.into_request(vec![JMAP_CORE_CAPABILITY.into()]);
245
246        Ok(Self {
247            state: State::Send(JmapSend::new(http_auth, &session.api_url, request)?),
248        })
249    }
250}
251
252impl JmapCoroutine for JmapPushSubscriptionSet {
253    type Yield = JmapYield;
254    type Return = Result<JmapPushSubscriptionSetOutput, JmapPushSubscriptionSetError>;
255
256    fn resume(&mut self, arg: Option<&[u8]>) -> JmapCoroutineState<Self::Yield, Self::Return> {
257        match &mut self.state {
258            State::Send(send) => {
259                let JmapSendOutput {
260                    response,
261                    keep_alive,
262                } = jmap_try!(send, arg);
263
264                let Some((name, args, _)) = response.method_responses.into_iter().next() else {
265                    return JmapCoroutineState::Complete(Err(
266                        JmapPushSubscriptionSetError::MissingResponse,
267                    ));
268                };
269
270                if name == "error" {
271                    let err = serde_json::from_value::<JmapMethodError>(args)
272                        .unwrap_or(JmapMethodError::Unknown);
273                    return JmapCoroutineState::Complete(Err(err.into()));
274                }
275
276                match serde_json::from_value::<PushSubscriptionSetResponse>(args) {
277                    Ok(r) => JmapCoroutineState::Complete(Ok(JmapPushSubscriptionSetOutput {
278                        created: r.created.unwrap_or_default(),
279                        updated: r.updated.unwrap_or_default(),
280                        destroyed: r.destroyed.unwrap_or_default(),
281                        not_created: r.not_created.unwrap_or_default(),
282                        not_updated: r.not_updated.unwrap_or_default(),
283                        not_destroyed: r.not_destroyed.unwrap_or_default(),
284                        keep_alive,
285                    })),
286                    Err(err) => JmapCoroutineState::Complete(Err(
287                        JmapPushSubscriptionSetError::ParseResponse(err),
288                    )),
289                }
290            }
291        }
292    }
293}
294
295enum State {
296    Send(JmapSend),
297}
298
299#[derive(Serialize)]
300#[serde(rename_all = "camelCase")]
301struct PushSubscriptionSetRequest {
302    #[serde(skip_serializing_if = "BTreeMap::is_empty")]
303    create: BTreeMap<String, JmapPushSubscriptionCreate>,
304    #[serde(skip_serializing_if = "BTreeMap::is_empty")]
305    update: BTreeMap<String, JmapPushSubscriptionUpdate>,
306    #[serde(skip_serializing_if = "Vec::is_empty")]
307    destroy: Vec<String>,
308}
309
310#[derive(Deserialize)]
311#[serde(rename_all = "camelCase")]
312struct PushSubscriptionSetResponse {
313    #[serde(default)]
314    created: Option<BTreeMap<String, JmapPushSubscription>>,
315    #[serde(default)]
316    updated: Option<BTreeMap<String, Option<JmapPushSubscription>>>,
317    #[serde(default)]
318    destroyed: Option<Vec<String>>,
319    #[serde(default)]
320    not_created: Option<BTreeMap<String, JmapSetError>>,
321    #[serde(default)]
322    not_updated: Option<BTreeMap<String, JmapSetError>>,
323    #[serde(default)]
324    not_destroyed: Option<BTreeMap<String, JmapSetError>>,
325}
326
327#[cfg(test)]
328mod tests {
329    use alloc::{format, string::ToString};
330
331    use crate::rfc8620::push_subscription::set::*;
332
333    fn make_auth() -> SecretString {
334        SecretString::from("Bearer test")
335    }
336
337    fn make_session() -> JmapSession {
338        serde_json::from_str(
339            r#"{
340                "username": "",
341                "accounts": {},
342                "primaryAccounts": {},
343                "capabilities": {},
344                "apiUrl": "https://api.example.com/jmap/",
345                "downloadUrl": "",
346                "uploadUrl": "",
347                "eventSourceUrl": "",
348                "state": ""
349            }"#,
350        )
351        .unwrap()
352    }
353
354    fn make_set() -> JmapPushSubscriptionSet {
355        let mut args = JmapPushSubscriptionSetArgs::default();
356        args.create(
357            "c1",
358            JmapPushSubscriptionCreate {
359                device_client_id: "a889-ffea-910".to_string(),
360                url: "https://push.example.com/?device=X8980fc".to_string(),
361                ..Default::default()
362            },
363        );
364        JmapPushSubscriptionSet::new(&make_session(), &make_auth(), args).unwrap()
365    }
366
367    fn build_http_reply(body: &[u8]) -> Vec<u8> {
368        let head = format!(
369            "HTTP/1.1 200 OK\r\nContent-Length: {}\r\nContent-Type: application/json\r\n\r\n",
370            body.len()
371        );
372        let mut bytes = head.into_bytes();
373        bytes.extend_from_slice(body);
374        bytes
375    }
376
377    #[test]
378    fn request_omits_account_id_and_if_in_state() {
379        let mut cor = make_set();
380        let bytes = expect_wants_write(&mut cor, None);
381        let request = String::from_utf8(bytes).unwrap();
382        assert!(!request.contains("accountId"));
383        assert!(!request.contains("ifInState"));
384    }
385
386    #[test]
387    fn success_returns_ok_without_state() {
388        let mut cor = make_set();
389        expect_wants_write(&mut cor, None);
390        expect_wants_read(&mut cor);
391
392        let body = br#"{
393            "methodResponses": [["PushSubscription/set", {
394                "created": {
395                    "c1": {
396                        "id": "P1",
397                        "keys": null,
398                        "expires": "2018-07-13T02:14:29Z"
399                    }
400                }
401            }, "c0"]],
402            "sessionState": "s1"
403        }"#;
404        let reply = build_http_reply(body);
405        let out = expect_complete_ok(&mut cor, &reply);
406        assert_eq!(out.created["c1"].id, "P1");
407        assert_eq!(
408            out.created["c1"].expires.as_deref(),
409            Some("2018-07-13T02:14:29Z")
410        );
411    }
412
413    #[test]
414    fn updated_echo_without_id_parses() {
415        let mut cor = make_set();
416        expect_wants_write(&mut cor, None);
417        expect_wants_read(&mut cor);
418
419        let body = br#"{
420            "methodResponses": [["PushSubscription/set", {
421                "updated": {
422                    "P1": { "expires": "2018-07-15T02:22:50Z" }
423                }
424            }, "c0"]],
425            "sessionState": "s1"
426        }"#;
427        let reply = build_http_reply(body);
428        let out = expect_complete_ok(&mut cor, &reply);
429        let echo = out.updated["P1"].as_ref().unwrap();
430        assert!(echo.id.is_empty());
431        assert_eq!(echo.expires.as_deref(), Some("2018-07-15T02:22:50Z"));
432    }
433
434    #[test]
435    fn invalid_verification_code_surfaces_in_not_updated() {
436        let mut cor = make_set();
437        expect_wants_write(&mut cor, None);
438        expect_wants_read(&mut cor);
439
440        let body = br#"{
441            "methodResponses": [["PushSubscription/set", {
442                "notUpdated": {
443                    "P1": {
444                        "type": "invalidProperties",
445                        "properties": ["verificationCode"]
446                    }
447                }
448            }, "c0"]],
449            "sessionState": "s1"
450        }"#;
451        let reply = build_http_reply(body);
452        let out = expect_complete_ok(&mut cor, &reply);
453        assert_eq!(out.not_updated["P1"].r#type, "invalidProperties");
454    }
455
456    #[test]
457    fn method_error_returns_method_error() {
458        let mut cor = make_set();
459        expect_wants_write(&mut cor, None);
460        expect_wants_read(&mut cor);
461
462        let body = br#"{
463            "methodResponses": [["error", {"type":"invalidArguments"}, "c0"]],
464            "sessionState": "s1"
465        }"#;
466        let reply = build_http_reply(body);
467        let err = expect_complete_err(&mut cor, &reply);
468        assert!(matches!(err, JmapPushSubscriptionSetError::Method(_)));
469    }
470
471    #[test]
472    fn missing_response_returns_missing_response() {
473        let mut cor = make_set();
474        expect_wants_write(&mut cor, None);
475        expect_wants_read(&mut cor);
476
477        let reply = build_http_reply(br#"{"methodResponses":[], "sessionState":"s1"}"#);
478        let err = expect_complete_err(&mut cor, &reply);
479        assert!(matches!(err, JmapPushSubscriptionSetError::MissingResponse));
480    }
481
482    #[test]
483    fn parse_error_returns_parse_response() {
484        let mut cor = make_set();
485        expect_wants_write(&mut cor, None);
486        expect_wants_read(&mut cor);
487
488        let body = br#"{
489            "methodResponses": [["PushSubscription/set", {"created":42}, "c0"]],
490            "sessionState": "s1"
491        }"#;
492        let reply = build_http_reply(body);
493        let err = expect_complete_err(&mut cor, &reply);
494        assert!(matches!(
495            err,
496            JmapPushSubscriptionSetError::ParseResponse(_)
497        ));
498    }
499
500    #[test]
501    fn http_error_surfaces_as_send_error() {
502        let mut cor = make_set();
503        expect_wants_write(&mut cor, None);
504        expect_wants_read(&mut cor);
505
506        let reply = b"HTTP/1.1 401 Unauthorized\r\nContent-Length: 0\r\n\r\n";
507        let err = expect_complete_err(&mut cor, reply);
508        assert!(matches!(
509            err,
510            JmapPushSubscriptionSetError::Send(JmapSendError::HttpStatus(401))
511        ));
512    }
513
514    fn expect_wants_write(cor: &mut JmapPushSubscriptionSet, arg: Option<&[u8]>) -> Vec<u8> {
515        match cor.resume(arg) {
516            JmapCoroutineState::Yielded(JmapYield::WantsWrite(bytes)) => bytes,
517            state => panic!("expected WantsWrite, got {state:?}"),
518        }
519    }
520
521    fn expect_wants_read(cor: &mut JmapPushSubscriptionSet) {
522        match cor.resume(None) {
523            JmapCoroutineState::Yielded(JmapYield::WantsRead) => {}
524            state => panic!("expected WantsRead, got {state:?}"),
525        }
526    }
527
528    fn expect_complete_ok(
529        cor: &mut JmapPushSubscriptionSet,
530        reply: &[u8],
531    ) -> JmapPushSubscriptionSetOutput {
532        match cor.resume(Some(reply)) {
533            JmapCoroutineState::Complete(Ok(out)) => out,
534            state => panic!("expected Complete(Ok), got {state:?}"),
535        }
536    }
537
538    fn expect_complete_err(
539        cor: &mut JmapPushSubscriptionSet,
540        reply: &[u8],
541    ) -> JmapPushSubscriptionSetError {
542        match cor.resume(Some(reply)) {
543            JmapCoroutineState::Complete(Err(err)) => err,
544            state => panic!("expected Complete(Err), got {state:?}"),
545        }
546    }
547}