Skip to main content

io_jmap/rfc8620/
session_get.rs

1//! JMAP session discovery coroutine (RFC 8620 §2): GETs either
2//! `/.well-known/jmap` (for a base URL) or the supplied URL directly, returning
3//! the parsed [`JmapSession`].
4//!
5//! # Example
6//!
7//! ```rust,no_run
8//! use std::{
9//!     io::{Read, Write},
10//!     net::TcpStream,
11//! };
12//!
13//! use io_jmap::{
14//!     coroutine::{JmapCoroutine, JmapCoroutineState},
15//!     rfc8620::{coroutine::JmapRedirectYield, session_get::JmapSessionGet},
16//! };
17//! use secrecy::SecretString;
18//! use url::Url;
19//!
20//! // Ready stream needed (TCP-connected, TLS-negociated)
21//! let mut stream = TcpStream::connect("api.example.com:443").unwrap();
22//!
23//! let mut buf = [0u8; 4096];
24//!
25//! let url: Url = "https://api.example.com/".parse().unwrap();
26//! let auth = SecretString::from("Bearer xyz");
27//! let mut coroutine = JmapSessionGet::new(&auth, &url);
28//! let mut arg = None;
29//!
30//! let out = loop {
31//!     match coroutine.resume(arg.take()) {
32//!         JmapCoroutineState::Yielded(JmapRedirectYield::WantsWrite(bytes)) => {
33//!             stream.write_all(&bytes).unwrap();
34//!         }
35//!         JmapCoroutineState::Yielded(JmapRedirectYield::WantsRead) => {
36//!             let n = stream.read(&mut buf).unwrap();
37//!             arg = Some(&buf[..n]);
38//!         }
39//!         JmapCoroutineState::Yielded(JmapRedirectYield::WantsRedirect { .. }) => {
40//!             unimplemented!("open a new connection and start over");
41//!         }
42//!         JmapCoroutineState::Complete(Ok(out)) => break out,
43//!         JmapCoroutineState::Complete(Err(err)) => panic!("{err}"),
44//!     }
45//! };
46//!
47//! println!("{:?}", out.session);
48//! ```
49
50use io_http::{
51    coroutine::*,
52    rfc9110::request::HttpRequest,
53    rfc9112::send::{Http11Send, Http11SendError},
54};
55use log::{debug, trace};
56use secrecy::{ExposeSecret, SecretString};
57use thiserror::Error;
58use url::Url;
59
60use crate::{
61    coroutine::*,
62    rfc8620::{coroutine::JmapRedirectYield, session::JmapSession},
63};
64
65/// Failure causes during the JMAP session-get flow.
66#[derive(Debug, Error)]
67pub enum JmapSessionGetError {
68    /// The server answered with a non-2xx status.
69    #[error("JMAP session-get failed: HTTP {0}")]
70    HttpStatus(u16),
71    /// The session advertises no primary mail account.
72    #[error("JMAP session-get failed: no primary account for the mail capability")]
73    NoPrimaryMailAccount,
74    /// The inner HTTP/1.1 send coroutine failed.
75    #[error("JMAP session-get failed: {0}")]
76    Send(#[from] Http11SendError),
77    /// The session object could not be parsed.
78    #[error("JMAP session-get failed: parse session: {0}")]
79    ParseSession(#[source] serde_json::Error),
80}
81
82/// Successful terminal output of [`JmapSessionGet`].
83#[derive(Clone, Debug)]
84pub struct JmapSessionGetOutput {
85    /// The discovered session object.
86    pub session: JmapSession,
87    /// Whether the server indicated the connection can be reused.
88    pub keep_alive: bool,
89}
90
91/// I/O-free coroutine to fetch a JMAP session (RFC 8620 §2).
92///
93/// If `url` has a non-root path (e.g.
94/// `https://api.fastmail.com/jmap/session/`), GETs that path directly
95/// as the session endpoint. Otherwise GETs `/.well-known/jmap` for
96/// automatic discovery.
97///
98/// When the server responds with a 3xx redirect, the coroutine yields
99/// [`JmapRedirectYield::WantsRedirect`]. The caller is responsible for
100/// opening a new connection and retrying with a new coroutine.
101pub struct JmapSessionGet {
102    state: State,
103}
104
105impl JmapSessionGet {
106    /// `url` is either a base URL for discovery (`https://mail.example.com`,
107    /// triggering `GET /.well-known/jmap`) or a direct session endpoint
108    /// (`https://api.example.com/jmap/session/`, used as-is).
109    pub fn new(http_auth: &SecretString, url: &Url) -> Self {
110        let host = url.host_str().unwrap_or("localhost");
111
112        let session_url = match url.path() {
113            "" | "/" => {
114                let mut u = url.clone();
115                u.set_path("/.well-known/jmap");
116                u
117            }
118            _ => url.clone(),
119        };
120
121        debug!("prepare session fetch request");
122        trace!("session url: {session_url}");
123
124        let http_request = HttpRequest::get(session_url)
125            .header("Host", host)
126            .header("Accept", "application/json")
127            .header("Authorization", http_auth.expose_secret());
128
129        Self {
130            state: State::Send(Http11Send::new(http_request)),
131        }
132    }
133}
134
135impl JmapCoroutine for JmapSessionGet {
136    type Yield = JmapRedirectYield;
137    type Return = Result<JmapSessionGetOutput, JmapSessionGetError>;
138
139    fn resume(&mut self, arg: Option<&[u8]>) -> JmapCoroutineState<Self::Yield, Self::Return> {
140        match &mut self.state {
141            State::Send(send) => match send.resume(arg) {
142                HttpCoroutineState::Yielded(y) => JmapCoroutineState::Yielded(y.into()),
143                HttpCoroutineState::Complete(Err(err)) => {
144                    JmapCoroutineState::Complete(Err(err.into()))
145                }
146                HttpCoroutineState::Complete(Ok(out)) => {
147                    if !out.response.status.is_success() {
148                        let err = JmapSessionGetError::HttpStatus(*out.response.status);
149                        return JmapCoroutineState::Complete(Err(err));
150                    }
151
152                    match serde_json::from_slice::<JmapSession>(&out.response.body) {
153                        Ok(session) => JmapCoroutineState::Complete(Ok(JmapSessionGetOutput {
154                            session,
155                            keep_alive: out.keep_alive,
156                        })),
157                        Err(err) => JmapCoroutineState::Complete(Err(
158                            JmapSessionGetError::ParseSession(err),
159                        )),
160                    }
161                }
162            },
163        }
164    }
165}
166
167enum State {
168    Send(Http11Send),
169}
170
171#[cfg(test)]
172mod tests {
173    use alloc::{format, vec::Vec};
174
175    use crate::rfc8620::session_get::*;
176
177    fn make_auth() -> SecretString {
178        SecretString::from("Bearer test")
179    }
180
181    fn make_url() -> Url {
182        "https://api.example.com/".parse().unwrap()
183    }
184
185    fn session_json() -> &'static [u8] {
186        br#"{
187            "capabilities": {},
188            "accounts": {},
189            "primaryAccounts": {},
190            "username": "alice",
191            "apiUrl": "https://api.example.com/jmap/",
192            "downloadUrl": "https://api.example.com/jmap/download/{accountId}/{blobId}/{name}?accept={type}",
193            "uploadUrl": "https://api.example.com/jmap/upload/{accountId}/",
194            "eventSourceUrl": "https://api.example.com/jmap/eventsource/?types={types}&closeafter={closeafter}&ping={ping}",
195            "state": "abc"
196        }"#
197    }
198
199    #[test]
200    fn success_returns_ok() {
201        let mut cor = JmapSessionGet::new(&make_auth(), &make_url());
202
203        expect_wants_write(&mut cor, None);
204        expect_wants_read(&mut cor);
205
206        let body = session_json();
207        let reply = format!(
208            "HTTP/1.1 200 OK\r\nContent-Length: {}\r\nContent-Type: application/json\r\n\r\n",
209            body.len()
210        );
211        let mut bytes = reply.into_bytes();
212        bytes.extend_from_slice(body);
213        let out = expect_complete_ok(&mut cor, &bytes);
214        assert_eq!(out.session.username, "alice");
215    }
216
217    #[test]
218    fn http_error_returns_status() {
219        let mut cor = JmapSessionGet::new(&make_auth(), &make_url());
220
221        expect_wants_write(&mut cor, None);
222        expect_wants_read(&mut cor);
223
224        let reply = b"HTTP/1.1 401 Unauthorized\r\nContent-Length: 0\r\n\r\n";
225        let err = expect_complete_err(&mut cor, reply);
226        assert!(matches!(err, JmapSessionGetError::HttpStatus(401)));
227    }
228
229    #[test]
230    fn redirect_yields_redirect() {
231        let mut cor = JmapSessionGet::new(&make_auth(), &make_url());
232
233        expect_wants_write(&mut cor, None);
234        expect_wants_read(&mut cor);
235
236        let reply = b"HTTP/1.1 301 Moved Permanently\r\nLocation: https://api2.example.com/.well-known/jmap\r\nContent-Length: 0\r\n\r\n";
237        match cor.resume(Some(reply)) {
238            JmapCoroutineState::Yielded(JmapRedirectYield::WantsRedirect { url, .. }) => {
239                assert_eq!(url.host_str(), Some("api2.example.com"));
240            }
241            state => panic!("expected WantsRedirect, got {state:?}"),
242        }
243    }
244
245    #[test]
246    fn invalid_json_returns_parse_error() {
247        let mut cor = JmapSessionGet::new(&make_auth(), &make_url());
248
249        expect_wants_write(&mut cor, None);
250        expect_wants_read(&mut cor);
251
252        let body = b"{not json";
253        let reply = format!("HTTP/1.1 200 OK\r\nContent-Length: {}\r\n\r\n", body.len());
254        let mut bytes = reply.into_bytes();
255        bytes.extend_from_slice(body);
256        let err = expect_complete_err(&mut cor, &bytes);
257        assert!(matches!(err, JmapSessionGetError::ParseSession(_)));
258    }
259
260    #[test]
261    fn uses_well_known_path_for_base_url() {
262        let mut cor = JmapSessionGet::new(&make_auth(), &make_url());
263        let bytes = expect_wants_write(&mut cor, None);
264        let req = core::str::from_utf8(&bytes).expect("utf8 request");
265        assert!(req.contains("/.well-known/jmap"));
266    }
267
268    fn expect_wants_write(cor: &mut JmapSessionGet, arg: Option<&[u8]>) -> Vec<u8> {
269        match cor.resume(arg) {
270            JmapCoroutineState::Yielded(JmapRedirectYield::WantsWrite(bytes)) => bytes,
271            state => panic!("expected WantsWrite, got {state:?}"),
272        }
273    }
274
275    fn expect_wants_read(cor: &mut JmapSessionGet) {
276        match cor.resume(None) {
277            JmapCoroutineState::Yielded(JmapRedirectYield::WantsRead) => {}
278            state => panic!("expected WantsRead, got {state:?}"),
279        }
280    }
281
282    fn expect_complete_ok(cor: &mut JmapSessionGet, reply: &[u8]) -> JmapSessionGetOutput {
283        match cor.resume(Some(reply)) {
284            JmapCoroutineState::Complete(Ok(out)) => out,
285            state => panic!("expected Complete(Ok), got {state:?}"),
286        }
287    }
288
289    fn expect_complete_err(cor: &mut JmapSessionGet, reply: &[u8]) -> JmapSessionGetError {
290        match cor.resume(Some(reply)) {
291            JmapCoroutineState::Complete(Err(err)) => err,
292            state => panic!("expected Complete(Err), got {state:?}"),
293        }
294    }
295}