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