Skip to main content

io_jmap/rfc8620/
changes.rs

1//! Generic JMAP `Foo/changes` coroutine (RFC 8620 §5.2): wraps [`JmapSend`]
2//! with a `since_state` cursor and decodes the changed-id lists.
3//!
4//! # Example
5//!
6//! ```rust,no_run
7//! use io_jmap::rfc8620::changes::{JmapChanges, JmapChangesOptions};
8//! use secrecy::SecretString;
9//! use url::Url;
10//!
11//! let auth = SecretString::from("Bearer xyz");
12//! let api_url: Url = "https://api.example.com/jmap/".parse().unwrap();
13//! let coroutine = JmapChanges::new(
14//!     "a1".into(),
15//!     &auth,
16//!     &api_url,
17//!     "Email/changes",
18//!     vec!["urn:ietf:params:jmap:mail".into()],
19//!     "s1",
20//!     JmapChangesOptions::default(),
21//! )
22//! .unwrap();
23//! # let _ = coroutine;
24//! ```
25
26use alloc::{string::String, vec::Vec};
27
28use secrecy::SecretString;
29use serde::{Deserialize, Serialize};
30use thiserror::Error;
31use url::Url;
32
33use crate::{
34    coroutine::*,
35    jmap_try,
36    rfc8620::{error::JmapMethodError, request::JmapBatch, send::*},
37};
38
39/// Failure causes during a JMAP `Foo/changes` flow.
40#[derive(Debug, Error)]
41pub enum JmapChangesError {
42    /// The response carried no method response.
43    #[error("JMAP Foo/changes failed: missing response in method_responses")]
44    MissingResponse,
45    /// The inner send coroutine failed.
46    #[error("JMAP Foo/changes failed: {0}")]
47    Send(#[from] JmapSendError),
48    /// The method arguments could not be serialized.
49    #[error("JMAP Foo/changes failed: serialize args: {0}")]
50    SerializeArgs(#[source] serde_json::Error),
51    /// The method response could not be parsed.
52    #[error("JMAP Foo/changes failed: parse response: {0}")]
53    ParseResponse(#[source] serde_json::Error),
54    /// The server returned a method-level error.
55    #[error("JMAP Foo/changes failed: {0}")]
56    Method(#[from] JmapMethodError),
57}
58
59/// Options for [`JmapChanges::new`].
60#[derive(Clone, Debug, Default)]
61pub struct JmapChangesOptions {
62    /// Server-side cap on the number of changes returned. `None` lets the
63    /// server pick.
64    pub max_changes: Option<u64>,
65}
66
67/// Successful terminal output of [`JmapChanges`] and of the per-type wrappers
68/// ([`JmapMailboxChanges`], [`JmapEmailChanges`], [`JmapThreadChanges`]).
69///
70/// [`JmapMailboxChanges`]: crate::rfc8621::mailbox::changes::JmapMailboxChanges
71/// [`JmapEmailChanges`]: crate::rfc8621::email::changes::JmapEmailChanges
72/// [`JmapThreadChanges`]: crate::rfc8621::thread::changes::JmapThreadChanges
73#[derive(Clone, Debug)]
74pub struct JmapChangesOutput {
75    /// The new server state after the call.
76    pub new_state: String,
77    /// Whether more changes are available via a follow-up call.
78    pub has_more_changes: bool,
79    /// Ids of objects created since the requested state.
80    pub created: Vec<String>,
81    /// Ids of objects updated since the requested state.
82    pub updated: Vec<String>,
83    /// Ids of objects destroyed since the requested state.
84    pub destroyed: Vec<String>,
85    /// Whether the server indicated the connection can be reused.
86    pub keep_alive: bool,
87}
88
89/// Generic I/O-free coroutine for the JMAP `Foo/changes` method (RFC 8620
90/// §5.2).
91pub struct JmapChanges {
92    state: State,
93}
94
95impl JmapChanges {
96    /// Builds a single-call `Foo/changes` batch and wraps it in [`JmapSend`].
97    pub fn new(
98        account_id: String,
99        http_auth: &SecretString,
100        api_url: &Url,
101        method: impl Into<String>,
102        capabilities: Vec<String>,
103        since_state: impl Into<String>,
104        opts: JmapChangesOptions,
105    ) -> Result<Self, JmapChangesError> {
106        let since_state = since_state.into();
107        let args = serde_json::to_value(ChangesArgs {
108            account_id: &account_id,
109            since_state: &since_state,
110            max_changes: opts.max_changes,
111        })
112        .map_err(JmapChangesError::SerializeArgs)?;
113
114        let mut batch = JmapBatch::new();
115        batch.add(method, args);
116        let request = batch.into_request(capabilities);
117
118        Ok(Self {
119            state: State::Send(JmapSend::new(http_auth, api_url, request)?),
120        })
121    }
122
123    /// Wraps a pre-built [`JmapSend`].
124    pub fn from_send(send: JmapSend) -> Self {
125        Self {
126            state: State::Send(send),
127        }
128    }
129}
130
131impl JmapCoroutine for JmapChanges {
132    type Yield = JmapYield;
133    type Return = Result<JmapChangesOutput, JmapChangesError>;
134
135    fn resume(&mut self, arg: Option<&[u8]>) -> JmapCoroutineState<Self::Yield, Self::Return> {
136        match &mut self.state {
137            State::Send(send) => {
138                let JmapSendOutput {
139                    response,
140                    keep_alive,
141                } = jmap_try!(send, arg);
142
143                let Some((name, args, _)) = response.method_responses.into_iter().next() else {
144                    return JmapCoroutineState::Complete(Err(JmapChangesError::MissingResponse));
145                };
146
147                if name == "error" {
148                    let err = serde_json::from_value::<JmapMethodError>(args)
149                        .unwrap_or(JmapMethodError::Unknown);
150                    return JmapCoroutineState::Complete(Err(err.into()));
151                }
152
153                match serde_json::from_value::<ChangesResponse>(args) {
154                    Ok(r) => JmapCoroutineState::Complete(Ok(JmapChangesOutput {
155                        new_state: r.new_state,
156                        has_more_changes: r.has_more_changes,
157                        created: r.created,
158                        updated: r.updated,
159                        destroyed: r.destroyed,
160                        keep_alive,
161                    })),
162                    Err(err) => {
163                        JmapCoroutineState::Complete(Err(JmapChangesError::ParseResponse(err)))
164                    }
165                }
166            }
167        }
168    }
169}
170
171enum State {
172    Send(JmapSend),
173}
174
175#[derive(Serialize)]
176#[serde(rename_all = "camelCase")]
177struct ChangesArgs<'a> {
178    account_id: &'a str,
179    since_state: &'a str,
180    #[serde(skip_serializing_if = "Option::is_none")]
181    max_changes: Option<u64>,
182}
183
184#[derive(Deserialize)]
185#[serde(rename_all = "camelCase")]
186struct ChangesResponse {
187    new_state: String,
188    has_more_changes: bool,
189    created: Vec<String>,
190    updated: Vec<String>,
191    destroyed: Vec<String>,
192}
193
194#[cfg(test)]
195mod tests {
196    use alloc::{format, string::ToString, vec};
197
198    use crate::rfc8620::changes::*;
199
200    fn make_auth() -> SecretString {
201        SecretString::from("Bearer test")
202    }
203
204    fn make_url() -> Url {
205        "https://api.example.com/jmap/".parse().unwrap()
206    }
207
208    fn make_changes() -> JmapChanges {
209        JmapChanges::new(
210            "a1".to_string(),
211            &make_auth(),
212            &make_url(),
213            "Email/changes",
214            vec!["urn:ietf:params:jmap:mail".to_string()],
215            "s1",
216            JmapChangesOptions::default(),
217        )
218        .unwrap()
219    }
220
221    fn build_http_reply(body: &[u8]) -> Vec<u8> {
222        let head = format!(
223            "HTTP/1.1 200 OK\r\nContent-Length: {}\r\nContent-Type: application/json\r\n\r\n",
224            body.len()
225        );
226        let mut bytes = head.into_bytes();
227        bytes.extend_from_slice(body);
228        bytes
229    }
230
231    #[test]
232    fn success_returns_ok() {
233        let mut cor = make_changes();
234        expect_wants_write(&mut cor, None);
235        expect_wants_read(&mut cor);
236
237        let body = br#"{
238            "methodResponses": [["Email/changes", {"newState":"s2","hasMoreChanges":false,"created":["e1"],"updated":[],"destroyed":["e2"]}, "c0"]],
239            "sessionState": "s2"
240        }"#;
241        let reply = build_http_reply(body);
242        let out = expect_complete_ok(&mut cor, &reply);
243        assert_eq!(out.new_state, "s2");
244        assert_eq!(out.created, vec!["e1".to_string()]);
245        assert_eq!(out.destroyed, vec!["e2".to_string()]);
246    }
247
248    #[test]
249    fn method_error_returns_method_error() {
250        let mut cor = make_changes();
251        expect_wants_write(&mut cor, None);
252        expect_wants_read(&mut cor);
253
254        let body = br#"{
255            "methodResponses": [["error", {"type":"cannotCalculateChanges"}, "c0"]],
256            "sessionState": "s1"
257        }"#;
258        let reply = build_http_reply(body);
259        let err = expect_complete_err(&mut cor, &reply);
260        assert!(matches!(err, JmapChangesError::Method(_)));
261    }
262
263    #[test]
264    fn missing_response_returns_missing_response() {
265        let mut cor = make_changes();
266        expect_wants_write(&mut cor, None);
267        expect_wants_read(&mut cor);
268
269        let reply = build_http_reply(br#"{"methodResponses":[], "sessionState":"s"}"#);
270        let err = expect_complete_err(&mut cor, &reply);
271        assert!(matches!(err, JmapChangesError::MissingResponse));
272    }
273
274    #[test]
275    fn parse_error_returns_parse_response() {
276        let mut cor = make_changes();
277        expect_wants_write(&mut cor, None);
278        expect_wants_read(&mut cor);
279
280        let body = br#"{
281            "methodResponses": [["Email/changes", {"newState":42}, "c0"]],
282            "sessionState": "s"
283        }"#;
284        let reply = build_http_reply(body);
285        let err = expect_complete_err(&mut cor, &reply);
286        assert!(matches!(err, JmapChangesError::ParseResponse(_)));
287    }
288
289    #[test]
290    fn has_more_changes_flag_propagates() {
291        let mut cor = make_changes();
292        expect_wants_write(&mut cor, None);
293        expect_wants_read(&mut cor);
294
295        let body = br#"{
296            "methodResponses": [["Email/changes", {"newState":"s2","hasMoreChanges":true,"created":[],"updated":[],"destroyed":[]}, "c0"]],
297            "sessionState": "s2"
298        }"#;
299        let reply = build_http_reply(body);
300        let out = expect_complete_ok(&mut cor, &reply);
301        assert!(out.has_more_changes);
302    }
303
304    fn expect_wants_write(cor: &mut JmapChanges, arg: Option<&[u8]>) -> Vec<u8> {
305        match cor.resume(arg) {
306            JmapCoroutineState::Yielded(JmapYield::WantsWrite(bytes)) => bytes,
307            state => panic!("expected WantsWrite, got {state:?}"),
308        }
309    }
310
311    fn expect_wants_read(cor: &mut JmapChanges) {
312        match cor.resume(None) {
313            JmapCoroutineState::Yielded(JmapYield::WantsRead) => {}
314            state => panic!("expected WantsRead, got {state:?}"),
315        }
316    }
317
318    fn expect_complete_ok(cor: &mut JmapChanges, reply: &[u8]) -> JmapChangesOutput {
319        match cor.resume(Some(reply)) {
320            JmapCoroutineState::Complete(Ok(out)) => out,
321            state => panic!("expected Complete(Ok), got {state:?}"),
322        }
323    }
324
325    fn expect_complete_err(cor: &mut JmapChanges, reply: &[u8]) -> JmapChangesError {
326        match cor.resume(Some(reply)) {
327            JmapCoroutineState::Complete(Err(err)) => err,
328            state => panic!("expected Complete(Err), got {state:?}"),
329        }
330    }
331}