Skip to main content

imap_client/
client.rs

1//! Async I/O dispatcher: pipelines commands by tag, routes tagged status
2//! responses back to the originating future, broadcasts untagged events.
3//!
4//! ## Routing semantics
5//!
6//! Each command is assigned a monotonically-increasing tag. The dispatcher
7//! parses every frame: tagged status frames go back to the awaiting caller
8//! via a `oneshot`; untagged frames (data, status, continuation) go to the
9//! broadcast channel. Tagged frames whose status is `NO` or `BAD` are
10//! converted to [`ClientError::CommandFailed`] before being delivered, so
11//! callers don't need to re-parse the wire bytes to discover failure.
12//!
13//! ## Cancellation
14//!
15//! If a caller drops the returned future before the tagged response arrives,
16//! the `oneshot::Sender` becomes useless but no resource leak occurs — the
17//! pending entry is removed on the next match attempt.
18
19use bytes::BytesMut;
20use std::collections::HashMap;
21use std::sync::Arc;
22use std::time::Duration;
23use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt};
24use tokio::sync::{Mutex, broadcast, mpsc, oneshot};
25
26use crate::error::ClientError;
27use imap_core::ast::{Response, Status};
28use imap_core::parser::{MAX_LITERAL_SIZE, parse_response};
29
30/// Reply channel for tagged-response delivery.
31type TaggedReply = oneshot::Sender<Result<Vec<u8>, ClientError>>;
32type PendingCommands = Arc<Mutex<HashMap<String, TaggedReply>>>;
33
34/// Capacity (number of buffered messages) of the untagged-event broadcast
35/// channel. Slow consumers experience [`broadcast::error::RecvError::Lagged`]
36/// once they fall this far behind.
37const EVENT_CHANNEL_CAP: usize = 1024;
38
39/// Hard ceiling on the bytes buffered for a single in-flight response frame.
40/// Bounds the read buffer so a hostile server cannot exhaust memory by
41/// streaming a frame that never terminates (e.g. an unterminated quoted
42/// string, or a line with no CRLF) — the parser would otherwise keep
43/// returning [`ParseError::Incomplete`](imap_core::error::ParseError) while
44/// the buffer grows without limit. Sized to admit one maximal literal plus
45/// protocol overhead.
46const MAX_FRAME_SIZE: usize = MAX_LITERAL_SIZE + 64 * 1024;
47
48/// Items written by [`write_loop`].
49enum WriteRequest {
50    /// A tagged command. The dispatcher registers `tag` → `reply_tx`
51    /// before writing `bytes`, so a fast server can never beat us to the
52    /// pending-map insertion.
53    Command {
54        bytes: Vec<u8>,
55        tag: String,
56        reply_tx: TaggedReply,
57    },
58    /// A raw byte sequence. Used for IDLE's `DONE` and continuation
59    /// payloads where no tagged response is expected immediately.
60    Raw { bytes: Vec<u8> },
61}
62
63/// Async dispatcher around a single tokio I/O stream.
64pub struct RawClient {
65    write_tx: mpsc::Sender<WriteRequest>,
66    event_tx: broadcast::Sender<Vec<u8>>,
67    tag_counter: u64,
68    pub default_timeout: Duration,
69}
70
71impl RawClient {
72    pub fn new<S>(stream: S) -> Self
73    where
74        S: AsyncRead + AsyncWrite + Send + 'static,
75    {
76        let (read_half, write_half) = tokio::io::split(stream);
77        let (write_tx, write_rx) = mpsc::channel(32);
78        let (event_tx, _) = broadcast::channel(EVENT_CHANNEL_CAP);
79
80        let pending_commands = Arc::new(Mutex::new(HashMap::new()));
81
82        tokio::spawn(read_loop(
83            read_half,
84            Arc::clone(&pending_commands),
85            event_tx.clone(),
86            MAX_FRAME_SIZE,
87        ));
88        tokio::spawn(write_loop(
89            write_half,
90            write_rx,
91            Arc::clone(&pending_commands),
92        ));
93
94        Self {
95            write_tx,
96            event_tx,
97            tag_counter: 1,
98            default_timeout: Duration::from_secs(30),
99        }
100    }
101
102    pub fn events(&self) -> broadcast::Receiver<Vec<u8>> {
103        self.event_tx.subscribe()
104    }
105
106    /// Allocate a fresh tag. Tags are formatted `A<counter>` and are
107    /// monotonically increasing for the lifetime of the connection.
108    fn next_tag(&mut self) -> String {
109        let tag = format!("A{:04}", self.tag_counter);
110        self.tag_counter = self.tag_counter.wrapping_add(1);
111        tag
112    }
113
114    /// Send `cmd` as a tagged command, await the tagged status response.
115    ///
116    /// On success returns the raw frame bytes (including the trailing CRLF).
117    /// On a `NO`/`BAD` tagged response returns
118    /// [`ClientError::CommandFailed`] containing the server's resp-text.
119    pub async fn execute_command(&mut self, cmd: &str) -> Result<Vec<u8>, ClientError> {
120        self.execute_command_with_timeout(cmd, self.default_timeout)
121            .await
122    }
123
124    pub async fn execute_command_with_timeout(
125        &mut self,
126        cmd: &str,
127        timeout: Duration,
128    ) -> Result<Vec<u8>, ClientError> {
129        let (_tag, rx) = self.send_command_async(cmd).await?;
130        match tokio::time::timeout(timeout, rx).await {
131            Ok(Ok(res)) => res,
132            Ok(Err(_)) => Err(ClientError::ConnectionClosed),
133            Err(_) => Err(ClientError::Timeout),
134        }
135    }
136
137    /// Send a command and return a receiver for its tagged response without
138    /// awaiting. Used by long-running commands (e.g. IDLE) where the caller
139    /// needs to interleave other I/O before the tagged reply arrives.
140    pub async fn send_command_async(
141        &mut self,
142        cmd: &str,
143    ) -> Result<(String, oneshot::Receiver<Result<Vec<u8>, ClientError>>), ClientError> {
144        let tag = self.next_tag();
145        let bytes = format!("{} {}\r\n", tag, cmd).into_bytes();
146        let (reply_tx, reply_rx) = oneshot::channel();
147        self.write_tx
148            .send(WriteRequest::Command {
149                bytes,
150                tag: tag.clone(),
151                reply_tx,
152            })
153            .await
154            .map_err(|_| ClientError::ConnectionClosed)?;
155        Ok((tag, reply_rx))
156    }
157
158    /// Send raw bytes on the wire. Used for IDLE's `DONE` and other
159    /// continuation payloads where no new tag is allocated.
160    pub async fn send_raw(&mut self, bytes: Vec<u8>) -> Result<(), ClientError> {
161        self.write_tx
162            .send(WriteRequest::Raw { bytes })
163            .await
164            .map_err(|_| ClientError::ConnectionClosed)
165    }
166
167    /// Cheap clone of the write side, suitable for handing to a long-lived
168    /// task (e.g. an IDLE handle) so it can send `DONE` without holding a
169    /// mutable borrow on the session.
170    pub fn writer(&self) -> WriterHandle {
171        WriterHandle {
172            write_tx: self.write_tx.clone(),
173        }
174    }
175}
176
177/// Cloneable write-only handle to a [`RawClient`]. Used to send raw bytes
178/// (e.g. IDLE `DONE`, AUTHENTICATE continuation payloads) from background
179/// tasks that don't own the session.
180#[derive(Clone)]
181pub struct WriterHandle {
182    write_tx: mpsc::Sender<WriteRequest>,
183}
184
185impl WriterHandle {
186    pub async fn send_raw(&self, bytes: Vec<u8>) -> Result<(), ClientError> {
187        self.write_tx
188            .send(WriteRequest::Raw { bytes })
189            .await
190            .map_err(|_| ClientError::ConnectionClosed)
191    }
192}
193
194async fn write_loop<W>(
195    mut write_half: W,
196    mut rx: mpsc::Receiver<WriteRequest>,
197    pending_commands: PendingCommands,
198) where
199    W: AsyncWrite + Unpin,
200{
201    while let Some(req) = rx.recv().await {
202        match req {
203            WriteRequest::Command {
204                bytes,
205                tag,
206                reply_tx,
207            } => {
208                // Register the pending entry BEFORE writing so the read
209                // loop cannot miss a match against a fast server reply.
210                pending_commands.lock().await.insert(tag, reply_tx);
211                if write_half.write_all(&bytes).await.is_err() {
212                    break;
213                }
214            }
215            WriteRequest::Raw { bytes } => {
216                if write_half.write_all(&bytes).await.is_err() {
217                    break;
218                }
219            }
220        }
221    }
222}
223
224/// Drain every pending tagged-command waiter, delivering a freshly built
225/// error to each. `make_err` is a factory because [`ClientError`] is not
226/// `Clone` (it wraps `std::io::Error`).
227async fn fail_all_pending(pending: &PendingCommands, make_err: impl Fn() -> ClientError) {
228    let mut map = pending.lock().await;
229    for (_, tx) in map.drain() {
230        let _ = tx.send(Err(make_err()));
231    }
232}
233
234async fn read_loop<R>(
235    mut read_half: R,
236    pending_commands: PendingCommands,
237    event_tx: broadcast::Sender<Vec<u8>>,
238    max_frame_size: usize,
239) where
240    R: AsyncRead + Unpin,
241{
242    let mut buffer = BytesMut::with_capacity(8192);
243
244    loop {
245        match read_half.read_buf(&mut buffer).await {
246            Ok(0) => {
247                // EOF: notify any pending commands that we're closing.
248                fail_all_pending(&pending_commands, || ClientError::ConnectionClosed).await;
249                break;
250            }
251            Ok(_) => {
252                while !buffer.is_empty() {
253                    // Extract owned routing info from the parse so we can
254                    // mutate the buffer afterwards.
255                    let routing = match parse_response(&buffer) {
256                        Ok((remaining, response)) => {
257                            let consumed = buffer.len() - remaining.len();
258                            let routing = match &response {
259                                Response::Status(s) => s
260                                    .tag
261                                    .map(|tag| (tag.to_string(), s.status, s.text.to_string())),
262                                _ => None,
263                            };
264                            (consumed, routing)
265                        }
266                        Err(imap_core::error::ParseError::Incomplete) => break,
267                        Err(_) => {
268                            // Malformed frame — drop the buffer to resync.
269                            // (A real protocol error; we close the loop.)
270                            buffer.clear();
271                            break;
272                        }
273                    };
274                    let (consumed, routing) = routing;
275                    let frame = buffer.split_to(consumed).to_vec();
276                    dispatch_frame(routing, frame, &pending_commands, &event_tx).await;
277                }
278
279                // Complete frames have been split off; any residue is a single
280                // still-incomplete frame. Refuse to buffer it past the ceiling
281                // so a server streaming a never-terminating frame cannot
282                // exhaust memory.
283                if buffer.len() > max_frame_size {
284                    fail_all_pending(&pending_commands, || ClientError::FrameTooLarge {
285                        max: max_frame_size,
286                    })
287                    .await;
288                    break;
289                }
290            }
291            Err(_) => {
292                fail_all_pending(&pending_commands, || ClientError::ConnectionClosed).await;
293                break;
294            }
295        }
296    }
297}
298
299/// Route a parsed frame to either a pending tagged-command future or the
300/// broadcast channel. Tagged `NO`/`BAD` responses are converted to
301/// [`ClientError::CommandFailed`] here so callers don't have to re-parse.
302async fn dispatch_frame(
303    routing: Option<(String, Status, String)>,
304    frame: Vec<u8>,
305    pending_commands: &PendingCommands,
306    event_tx: &broadcast::Sender<Vec<u8>>,
307) {
308    if let Some((tag, status, text)) = routing {
309        let mut map = pending_commands.lock().await;
310        if let Some(tx) = map.remove(&tag) {
311            let result = match status {
312                Status::Ok => Ok(frame),
313                Status::No | Status::Bad => Err(ClientError::CommandFailed(text)),
314                Status::Bye => Err(ClientError::ConnectionClosed),
315                // PREAUTH is only valid as an untagged greeting; if it
316                // somehow appears tagged we surface the raw text.
317                Status::PreAuth => Err(ClientError::CommandFailed(text)),
318            };
319            let _ = tx.send(result);
320            return;
321        }
322    }
323    // Untagged or no matching pending entry — broadcast.
324    let _ = event_tx.send(frame);
325}
326
327#[cfg(test)]
328mod tests {
329    use super::*;
330    use std::time::Duration;
331    use tokio::io::{AsyncReadExt, AsyncWriteExt, duplex};
332
333    #[tokio::test]
334    async fn test_tagged_response_matching() {
335        let (client_io, mut server_io) = duplex(1024);
336        let mut client = RawClient::new(client_io);
337
338        let command_task = tokio::spawn(async move { client.execute_command("NOOP").await });
339
340        let mut buf = [0u8; 1024];
341        let n = server_io.read(&mut buf).await.unwrap();
342        let cmd = String::from_utf8_lossy(&buf[..n]);
343        assert!(cmd.contains("NOOP"));
344        let tag = cmd.split_whitespace().next().unwrap();
345
346        server_io
347            .write_all(format!("{} OK NOOP completed\r\n", tag).as_bytes())
348            .await
349            .unwrap();
350
351        let result = command_task.await.unwrap().unwrap();
352        assert!(String::from_utf8_lossy(&result).contains("OK"));
353    }
354
355    #[tokio::test]
356    async fn test_no_response_becomes_error() {
357        let (client_io, mut server_io) = duplex(1024);
358        let mut client = RawClient::new(client_io);
359
360        let command_task = tokio::spawn(async move { client.execute_command("LOGIN x y").await });
361
362        let mut buf = [0u8; 1024];
363        let n = server_io.read(&mut buf).await.unwrap();
364        let tag = String::from_utf8_lossy(&buf[..n])
365            .split_whitespace()
366            .next()
367            .unwrap()
368            .to_owned();
369        server_io
370            .write_all(format!("{} NO authentication failed\r\n", tag).as_bytes())
371            .await
372            .unwrap();
373
374        let result = command_task.await.unwrap();
375        match result {
376            Err(ClientError::CommandFailed(text)) => {
377                assert_eq!(text, "authentication failed")
378            }
379            other => panic!("expected CommandFailed, got {:?}", other),
380        }
381    }
382
383    #[tokio::test]
384    async fn test_bad_response_becomes_error() {
385        let (client_io, mut server_io) = duplex(1024);
386        let mut client = RawClient::new(client_io);
387
388        let command_task = tokio::spawn(async move { client.execute_command("BOGUS").await });
389
390        let mut buf = [0u8; 1024];
391        let n = server_io.read(&mut buf).await.unwrap();
392        let tag = String::from_utf8_lossy(&buf[..n])
393            .split_whitespace()
394            .next()
395            .unwrap()
396            .to_owned();
397        server_io
398            .write_all(format!("{} BAD unknown command\r\n", tag).as_bytes())
399            .await
400            .unwrap();
401
402        let result = command_task.await.unwrap();
403        assert!(matches!(result, Err(ClientError::CommandFailed(_))));
404    }
405
406    #[tokio::test]
407    async fn test_untagged_event_broadcasting() {
408        let (client_io, mut server_io) = duplex(1024);
409        let client = RawClient::new(client_io);
410        let mut events = client.events();
411
412        server_io.write_all(b"* 5 EXISTS\r\n").await.unwrap();
413
414        let event = events.recv().await.unwrap();
415        assert_eq!(String::from_utf8_lossy(&event), "* 5 EXISTS\r\n");
416    }
417
418    #[tokio::test]
419    async fn test_partial_read_reassembly() {
420        let (client_io, mut server_io) = duplex(1024);
421        let mut client = RawClient::new(client_io);
422
423        let command_task = tokio::spawn(async move { client.execute_command("NOOP").await });
424
425        let mut buf = [0u8; 1024];
426        let n = server_io.read(&mut buf).await.unwrap();
427        let tag = String::from_utf8_lossy(&buf[..n])
428            .split_whitespace()
429            .next()
430            .unwrap()
431            .to_string();
432
433        let response = format!("{} OK NOOP completed\r\n", tag);
434        for byte in response.as_bytes() {
435            server_io.write_all(&[*byte]).await.unwrap();
436            tokio::task::yield_now().await;
437        }
438
439        let result = command_task.await.unwrap().unwrap();
440        assert!(String::from_utf8_lossy(&result).contains("OK"));
441    }
442
443    #[tokio::test]
444    async fn test_command_timeout() {
445        let (client_io, _server_io) = duplex(1024);
446        let mut client = RawClient::new(client_io);
447        client.default_timeout = Duration::from_millis(50);
448
449        let result = client.execute_command("NOOP").await;
450        assert!(matches!(result, Err(ClientError::Timeout)));
451    }
452
453    #[tokio::test]
454    async fn test_search_parsing() {
455        let (client_io, mut server_io) = duplex(1024);
456        let mut client = RawClient::new(client_io);
457        let mut events = client.events();
458
459        let command_task =
460            tokio::spawn(async move { client.execute_command("SEARCH FROM \"alice\"").await });
461
462        let mut buf = [0u8; 1024];
463        let n = server_io.read(&mut buf).await.unwrap();
464        let cmd = String::from_utf8_lossy(&buf[..n]);
465        let tag = cmd.split_whitespace().next().unwrap();
466
467        server_io.write_all(b"* SEARCH 1 2 3\r\n").await.unwrap();
468        server_io
469            .write_all(format!("{} OK SEARCH completed\r\n", tag).as_bytes())
470            .await
471            .unwrap();
472
473        let _result = command_task.await.unwrap().unwrap();
474
475        let event = events.recv().await.unwrap();
476        assert_eq!(String::from_utf8_lossy(&event), "* SEARCH 1 2 3\r\n");
477    }
478
479    #[tokio::test]
480    async fn test_send_raw() {
481        let (client_io, mut server_io) = duplex(1024);
482        let mut client = RawClient::new(client_io);
483
484        client.send_raw(b"DONE\r\n".to_vec()).await.unwrap();
485
486        let mut buf = [0u8; 1024];
487        let n = server_io.read(&mut buf).await.unwrap();
488        assert_eq!(&buf[..n], b"DONE\r\n");
489    }
490
491    #[tokio::test]
492    async fn test_connection_closed_on_eof() {
493        let (client_io, server_io) = duplex(1024);
494        let mut client = RawClient::new(client_io);
495        client.default_timeout = Duration::from_secs(1);
496
497        let task = tokio::spawn(async move { client.execute_command("NOOP").await });
498        // Give the command time to register, then drop the server side.
499        tokio::time::sleep(Duration::from_millis(20)).await;
500        drop(server_io);
501
502        let result = task.await.unwrap();
503        assert!(matches!(
504            result,
505            Err(ClientError::ConnectionClosed) | Err(ClientError::Timeout)
506        ));
507    }
508
509    #[tokio::test]
510    async fn test_connection_closed_immediate() {
511        let (client_io, server_io) = duplex(1024);
512        let mut client = RawClient::new(client_io);
513        drop(server_io);
514
515        let result = client.execute_command("NOOP").await;
516        assert!(matches!(
517            result,
518            Err(ClientError::ConnectionClosed) | Err(ClientError::Timeout)
519        ));
520    }
521
522    #[tokio::test]
523    async fn test_unterminated_frame_is_bounded() {
524        // A server that streams a frame which never terminates (no CRLF)
525        // must not grow the read buffer without limit. Drive `read_loop`
526        // directly with a tiny ceiling so the test stays fast — exercising
527        // the real 64 MiB `MAX_FRAME_SIZE` would allocate 64 MiB.
528        let (client_io, mut server_io) = duplex(8192);
529
530        let pending: PendingCommands = Arc::new(Mutex::new(HashMap::new()));
531        let (event_tx, _event_rx) = broadcast::channel(EVENT_CHANNEL_CAP);
532
533        let (reply_tx, reply_rx) = oneshot::channel();
534        pending.lock().await.insert("A0001".to_string(), reply_tx);
535
536        let max_frame_size = 64;
537        let loop_task = tokio::spawn(read_loop(
538            client_io,
539            Arc::clone(&pending),
540            event_tx,
541            max_frame_size,
542        ));
543
544        // Valid resp-text bytes that never reach a CRLF -> parser stays
545        // `Incomplete` while the buffer grows past the ceiling.
546        server_io.write_all(b"* OK ").await.unwrap();
547        server_io
548            .write_all(&vec![b'a'; max_frame_size * 2])
549            .await
550            .unwrap();
551
552        let result = reply_rx.await.unwrap();
553        assert!(
554            matches!(result, Err(ClientError::FrameTooLarge { max }) if max == max_frame_size),
555            "expected FrameTooLarge, got {result:?}"
556        );
557        // The loop must terminate rather than spin on the oversized buffer.
558        loop_task.await.unwrap();
559    }
560}