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