Skip to main content

io_email/imap/
client.rs

1//! Std-blocking IMAP client.
2//!
3//! Holds an inner [`ImapClientStd`] (from io-imap) wrapping the stream
4//! and its fragmentizer, plus the per-connection knobs the shared-API
5//! IMAP methods need: the `auto_select` policy, the optional `auto_id`
6//! payload, and the last-known capability list discovered at login.
7//!
8//! [`ImapClientStd::run`] pumps io-email IMAP coroutines directly
9//! against the inner client's stream and fragmentizer; the inner
10//! client's own request/response helpers stay reachable through
11//! [`ImapClientStd::inner`] for protocol-specific paths (select, idle,
12//! enable, ...) that the shared API does not cover.
13
14use core::{num::NonZeroU32, sync::atomic::AtomicBool};
15
16use alloc::{
17    string::{String, ToString},
18    sync::Arc,
19    vec,
20    vec::Vec,
21};
22
23use std::{
24    io::{self, ErrorKind, Read, Write},
25    sync::mpsc::Sender,
26};
27
28use io_imap::{
29    client::{ImapClientStd as InnerImapClientStd, ImapClientStdError as InnerImapClientStdError},
30    coroutine::*,
31    types::{
32        core::{IString, NString},
33        fetch::{MacroOrMessageDataItemNames, MessageDataItem, MessageDataItemName},
34        mailbox::Mailbox as ImapMailbox,
35        response::Capability,
36        sequence::SequenceSet,
37    },
38};
39#[cfg(any(
40    feature = "rustls-ring",
41    feature = "rustls-aws",
42    feature = "native-tls"
43))]
44use pimalaya_stream::{sasl::Sasl, tls::Tls};
45use thiserror::Error;
46#[cfg(any(
47    feature = "rustls-ring",
48    feature = "rustls-aws",
49    feature = "native-tls"
50))]
51use url::Url;
52
53#[cfg(feature = "search")]
54use crate::{
55    envelope::imap::search::{ImapEnvelopeSearch, ImapEnvelopeSearchError},
56    search::query::SearchEmailsQuery,
57};
58use crate::{
59    envelope::{
60        event::WatchEvent,
61        imap::{
62            diff::{
63                ImapState, envelope_from_items, flag_update_from_items, new_message_item_names,
64                new_message_window,
65            },
66            list::{ImapEnvelopeList, ImapEnvelopeListError},
67            watch::{ImapWatchMailbox, ImapWatchMailboxError, ImapWatchMailboxYield},
68        },
69        types::{Envelope, EnvelopeDiff, FlagUpdate},
70    },
71    flag::{
72        imap::store::{ImapFlagStore, ImapFlagStoreError},
73        types::{Flag, FlagOp},
74    },
75    imap::convert::parse_mailbox,
76    mailbox::{
77        imap::{
78            create::{ImapMailboxCreate, ImapMailboxCreateError},
79            delete::{ImapMailboxDelete, ImapMailboxDeleteError},
80            list::{ImapMailboxList, ImapMailboxListError},
81        },
82        types::Mailbox,
83    },
84    message::imap::{
85        add::{ImapMessageAdd, ImapMessageAddError},
86        copy::{ImapMessageCopy, ImapMessageCopyError},
87        delete::{ImapMessageDelete, ImapMessageDeleteError},
88        get::{ImapMessageGet, ImapMessageGetError},
89        r#move::{ImapMessageMove, ImapMessageMoveError},
90    },
91};
92
93/// Errors surfaced by [`ImapClientStd`] while running a coroutine.
94///
95/// One variant per shared-API IMAP coroutine.
96#[derive(Debug, Error)]
97pub enum ImapClientError {
98    #[error(transparent)]
99    Io(#[from] io::Error),
100    #[error(transparent)]
101    MailboxList(#[from] ImapMailboxListError),
102    #[error(transparent)]
103    EnvelopeList(#[from] ImapEnvelopeListError),
104    #[cfg(feature = "search")]
105    #[error(transparent)]
106    EnvelopeSearch(#[from] ImapEnvelopeSearchError),
107    #[error(transparent)]
108    FlagStore(#[from] ImapFlagStoreError),
109    #[error(transparent)]
110    MailboxCreate(#[from] ImapMailboxCreateError),
111    #[error(transparent)]
112    MailboxDelete(#[from] ImapMailboxDeleteError),
113    #[error(transparent)]
114    MessageAdd(#[from] ImapMessageAddError),
115    #[error(transparent)]
116    MessageCopy(#[from] ImapMessageCopyError),
117    #[error(transparent)]
118    MessageDelete(#[from] ImapMessageDeleteError),
119    #[error(transparent)]
120    MessageGet(#[from] ImapMessageGetError),
121    #[error(transparent)]
122    MessageMove(#[from] ImapMessageMoveError),
123    #[error(transparent)]
124    WatchMailbox(#[from] ImapWatchMailboxError),
125    #[error(transparent)]
126    Inner(#[from] InnerImapClientStdError),
127}
128
129const READ_BUFFER_SIZE: usize = 16 * 1024;
130
131/// Light IMAP client built on top of the io-imap type-erased inner.
132///
133/// `auto_select` is the per-message policy flag the IMAP coroutines
134/// read at construction time; flip it off when the caller already
135/// pre-selects the target mailbox. `capabilities` is the live list
136/// discovered at login; `watch_mailbox` needs `QRESYNC` to be
137/// present.
138///
139/// The RFC 2971 `auto_id` knob lives on the inner io-imap client
140/// (`inner.auto_id`) because the auth coroutines themselves chain
141/// the `ID` round-trip; set it before any auth_*/login call (or pass
142/// it through [`Self::connect`]).
143pub struct ImapClientStd {
144    pub inner: InnerImapClientStd,
145    pub auto_select: bool,
146    pub capabilities: Vec<Capability<'static>>,
147}
148
149impl ImapClientStd {
150    /// Wraps an already-connected stream with a fresh inner client,
151    /// the default `auto_select = true` policy, and an empty
152    /// capability list. Callers that intend to use `watch_mailbox`
153    /// should populate `capabilities` after login.
154    pub fn new<S: Read + Write + Send + 'static>(stream: S) -> Self {
155        Self {
156            inner: InnerImapClientStd::new(stream),
157            auto_select: true,
158            capabilities: Vec::new(),
159        }
160    }
161
162    /// Pumps any standard-shape IMAP coroutine
163    /// (`Yield = ImapYield`, `Return = Result<T, E>`) against the
164    /// inner client's stream and fragmentizer until it terminates.
165    ///
166    /// Reaches into [`Self::inner`] for raw field access rather than
167    /// delegating to [`InnerImapClientStd::run`] so error variants
168    /// route through [`ImapClientError`] directly.
169    pub fn run<C, T, E>(&mut self, mut coroutine: C) -> Result<T, ImapClientError>
170    where
171        C: ImapCoroutine<Yield = ImapYield, Return = Result<T, E>>,
172        ImapClientError: From<E>,
173    {
174        let mut buf = [0u8; READ_BUFFER_SIZE];
175        let mut arg: Option<&[u8]> = None;
176
177        loop {
178            match coroutine.resume(&mut self.inner.fragmentizer, arg.take()) {
179                ImapCoroutineState::Complete(Ok(out)) => return Ok(out),
180                ImapCoroutineState::Complete(Err(err)) => return Err(err.into()),
181                ImapCoroutineState::Yielded(ImapYield::WantsRead) => {
182                    let n = self.inner.stream.read(&mut buf)?;
183                    arg = Some(&buf[..n]);
184                }
185                ImapCoroutineState::Yielded(ImapYield::WantsWrite(bytes)) => {
186                    self.inner.stream.write_all(&bytes)?;
187                }
188            }
189        }
190    }
191
192    /// Sends a NOOP to keep the connection alive (RFC 3501 §6.1.2).
193    /// Sole purpose is to reset the server's inactivity timer on
194    /// long-idle TUI sessions; the response is discarded.
195    pub fn ping(&mut self) -> Result<(), ImapClientError> {
196        Ok(self.inner.noop()?)
197    }
198
199    /// Lists every mailbox visible to the session. When
200    /// `with_counts` is set, follows up with one `STATUS` per row
201    /// to populate [`Mailbox::total`] / [`Mailbox::unread`].
202    pub fn list_mailboxes(&mut self, with_counts: bool) -> Result<Vec<Mailbox>, ImapClientError> {
203        self.run(ImapMailboxList::new(with_counts))
204    }
205
206    /// Lists envelopes from `mailbox`. `page = None` and
207    /// `page_size = None` fetch the whole mailbox. Page 1 is the
208    /// most recent window.
209    pub fn list_envelopes(
210        &mut self,
211        mailbox: &str,
212        page: Option<u32>,
213        page_size: Option<u32>,
214        with_attachment: bool,
215    ) -> Result<Vec<Envelope>, ImapClientError> {
216        self.run(ImapEnvelopeList::new(
217            mailbox,
218            page,
219            page_size,
220            with_attachment,
221        )?)
222    }
223
224    /// Searches envelopes in `mailbox` against the shared query.
225    /// Pagination is applied to the SORT-ordered UID list before
226    /// FETCH.
227    #[cfg(feature = "search")]
228    pub fn search_envelopes(
229        &mut self,
230        mailbox: &str,
231        query: Option<&SearchEmailsQuery>,
232        page: Option<u32>,
233        page_size: Option<u32>,
234        with_attachment: bool,
235    ) -> Result<Vec<Envelope>, ImapClientError> {
236        self.run(ImapEnvelopeSearch::new(
237            mailbox,
238            query,
239            page,
240            page_size,
241            with_attachment,
242        )?)
243    }
244
245    /// Adds, sets, or removes `flags` on a UID set. When
246    /// [`Self::auto_select`] is on, the target mailbox is SELECTed
247    /// first; sync engines flip it off and pre-select once per
248    /// batch.
249    pub fn store_flags(
250        &mut self,
251        mailbox: &str,
252        ids: &[&str],
253        flags: &[Flag],
254        op: FlagOp,
255    ) -> Result<(), ImapClientError> {
256        let auto_select = self.auto_select;
257        self.run(ImapFlagStore::new(mailbox, ids, flags, op, auto_select)?)
258    }
259
260    /// Fetches one message's raw RFC 5322 bytes without flipping
261    /// the `\Seen` flag. Honours [`Self::auto_select`].
262    pub fn get_message(&mut self, mailbox: &str, id: &str) -> Result<Vec<u8>, ImapClientError> {
263        let auto_select = self.auto_select;
264        self.run(ImapMessageGet::new(mailbox, id, auto_select)?)
265    }
266
267    /// Appends `raw` to `mailbox` with the given flags. Returns the
268    /// appended UID, resolved via UIDPLUS when available or via
269    /// `UID SEARCH HEADER Message-ID` as a fallback.
270    pub fn add_message(
271        &mut self,
272        mailbox: &str,
273        flags: &[Flag],
274        raw: Vec<u8>,
275    ) -> Result<String, ImapClientError> {
276        self.run(ImapMessageAdd::new(mailbox, flags, raw)?)
277    }
278
279    /// Creates `name` as a new mailbox (RFC 3501 §6.3.3).
280    pub fn create_mailbox(&mut self, name: &str) -> Result<(), ImapClientError> {
281        self.run(ImapMailboxCreate::new(name)?)
282    }
283
284    /// Deletes `name` (RFC 3501 §6.3.4).
285    pub fn delete_mailbox(&mut self, name: &str) -> Result<(), ImapClientError> {
286        self.run(ImapMailboxDelete::new(name)?)
287    }
288
289    /// Marks `id` as `\Deleted` then EXPUNGEs. Honours
290    /// [`Self::auto_select`].
291    pub fn delete_message(&mut self, mailbox: &str, id: &str) -> Result<(), ImapClientError> {
292        let auto_select = self.auto_select;
293        self.run(ImapMessageDelete::new(mailbox, id, auto_select)?)
294    }
295
296    /// Copies a UID set from `from` to `to` (RFC 3501 §6.4.7).
297    /// Honours [`Self::auto_select`].
298    pub fn copy_messages(
299        &mut self,
300        from: &str,
301        to: &str,
302        ids: &[&str],
303    ) -> Result<(), ImapClientError> {
304        let auto_select = self.auto_select;
305        self.run(ImapMessageCopy::new(from, to, ids, auto_select)?)
306    }
307
308    /// Moves a UID set from `from` to `to` (RFC 6851). Honours
309    /// [`Self::auto_select`].
310    pub fn move_messages(
311        &mut self,
312        from: &str,
313        to: &str,
314        ids: &[&str],
315    ) -> Result<(), ImapClientError> {
316        let auto_select = self.auto_select;
317        self.run(ImapMessageMove::new(from, to, ids, auto_select)?)
318    }
319
320    /// Watches `mailbox` for envelope-level deltas, forwarding every
321    /// event through the caller-supplied [`Sender`].
322    ///
323    /// **Blocks** the current thread: drives the IDLE + QRESYNC
324    /// coroutine in a loop, fans socket reads / writes against
325    /// [`Self::inner`]'s stream, and pushes each yielded
326    /// [`WatchEvent`] into `tx`. Returns `Ok(())` when `shutdown`
327    /// flips (cooperative: the inner watcher winds IDLE down at the
328    /// next loop tick) or when the receiver behind `tx` is dropped;
329    /// returns `Err` when the protocol layer errors out.
330    ///
331    /// The caller must set a read timeout on the inner stream before
332    /// invoking this method so the shutdown flag is polled at every
333    /// timeout tick instead of only on server traffic. `WouldBlock`
334    /// and `TimedOut` errors are treated as "no new bytes" and let
335    /// the coroutine re-yield `WantsRead`.
336    ///
337    /// [`Self::capabilities`] must advertise `QRESYNC` (RFC 7162);
338    /// populate it via login or an explicit `CAPABILITY` round-trip
339    /// before reaching here.
340    pub fn watch_mailbox(
341        &mut self,
342        mailbox: &str,
343        shutdown: Arc<AtomicBool>,
344        tx: Sender<WatchEvent>,
345    ) -> Result<(), ImapClientError> {
346        let mut coroutine = ImapWatchMailbox::new(mailbox, &self.capabilities, shutdown)?;
347        let mut buf = [0u8; READ_BUFFER_SIZE];
348        let mut bytes: Option<&[u8]> = None;
349
350        loop {
351            match coroutine.resume(&mut self.inner.fragmentizer, bytes) {
352                ImapCoroutineState::Complete(result) => return Ok(result?),
353                ImapCoroutineState::Yielded(ImapWatchMailboxYield::WantsRead) => {
354                    match self.inner.stream.read(&mut buf) {
355                        Ok(n) => bytes = Some(&buf[..n]),
356                        Err(err) if err.kind() == ErrorKind::WouldBlock => bytes = None,
357                        Err(err) if err.kind() == ErrorKind::TimedOut => bytes = None,
358                        Err(err) => return Err(err.into()),
359                    }
360                }
361                ImapCoroutineState::Yielded(ImapWatchMailboxYield::WantsWrite(out)) => {
362                    self.inner.stream.write_all(&out)?;
363                    bytes = None;
364                }
365                ImapCoroutineState::Yielded(ImapWatchMailboxYield::Event(evt)) => {
366                    if tx.send(evt).is_err() {
367                        return Ok(());
368                    }
369                    bytes = None;
370                }
371            }
372        }
373    }
374
375    /// Returns the QRESYNC-driven envelope delta for `mailbox`.
376    ///
377    /// Decodes `state` into a checkpoint, opens `SELECT (QRESYNC …)`
378    /// with `(uid_validity, highest_mod_seq)`, then fetches new UIDs
379    /// above the cached high-water mark. Surfaces
380    /// [`EnvelopeDiff::FullListRequired`] when QRESYNC is missing from
381    /// [`Self::capabilities`], when UIDVALIDITY bumped, or when no
382    /// usable checkpoint was supplied; otherwise returns
383    /// [`EnvelopeDiff::Incremental`] with the new state, the flag
384    /// updates, the new envelopes and the vanished UIDs.
385    pub fn diff_envelopes(
386        &mut self,
387        mailbox: &str,
388        state: Option<&[u8]>,
389    ) -> Result<EnvelopeDiff, ImapClientError> {
390        let mbox = parse_mailbox(mailbox).map_err(ImapEnvelopeListError::from)?;
391
392        if !self.capabilities.contains(&Capability::QResync) {
393            return Ok(EnvelopeDiff::FullListRequired { new_state: None });
394        }
395
396        let cached = state.and_then(ImapState::decode);
397
398        let Some(cached) = cached else {
399            return self.diff_baseline(mbox);
400        };
401
402        let Some(uid_validity_nz) = NonZeroU32::new(cached.uid_validity) else {
403            return self.diff_baseline(mbox);
404        };
405
406        let capabilities = self.capabilities.clone();
407        let select_data = match self.inner.select_qresync(
408            mbox.clone(),
409            uid_validity_nz,
410            cached.highest_mod_seq,
411            &capabilities,
412        ) {
413            Ok(data) => data,
414            Err(_) => return self.diff_baseline(mbox),
415        };
416
417        let server_uid_validity = select_data
418            .uid_validity
419            .map(NonZeroU32::get)
420            .unwrap_or(cached.uid_validity);
421        if server_uid_validity != cached.uid_validity {
422            return self.diff_baseline(mbox);
423        }
424
425        let flag_updates: Vec<FlagUpdate> = select_data
426            .changed
427            .iter()
428            .filter_map(|fetch| flag_update_from_items(fetch.items.as_ref()))
429            .collect();
430
431        let vanished_ids: Vec<String> = select_data
432            .vanished_earlier
433            .iter()
434            .map(|uid| uid.get().to_string())
435            .collect();
436
437        let mut new_envelopes: Vec<Envelope> = Vec::new();
438        if let Some(window) = new_message_window(cached.highest_uid) {
439            if let Ok(sequence_set) = SequenceSet::try_from(window.as_str()) {
440                let data = self
441                    .inner
442                    .fetch(sequence_set, new_message_item_names(), true)?;
443                new_envelopes = data
444                    .into_iter()
445                    .map(|(_, items)| envelope_from_items(items.into_inner()))
446                    .collect();
447            }
448        }
449
450        let highest_uid = new_envelopes
451            .iter()
452            .filter_map(|e| e.id.parse::<u32>().ok())
453            .max()
454            .unwrap_or(cached.highest_uid);
455
456        let new_highest_mod_seq = select_data
457            .highest_mod_seq
458            .unwrap_or(cached.highest_mod_seq);
459
460        let new_state = ImapState {
461            uid_validity: server_uid_validity,
462            highest_mod_seq: new_highest_mod_seq,
463            highest_uid,
464        }
465        .encode();
466
467        Ok(EnvelopeDiff::Incremental {
468            new_state,
469            flag_updates,
470            new_envelopes,
471            vanished_ids,
472        })
473    }
474
475    /// Captures a fresh IMAP checkpoint via a plain SELECT plus a
476    /// `UID FETCH *` to read the highest UID. Used on first sync, when
477    /// the stored state is unusable, or when UIDVALIDITY bumped.
478    fn diff_baseline(
479        &mut self,
480        mbox: ImapMailbox<'static>,
481    ) -> Result<EnvelopeDiff, ImapClientError> {
482        let select = self.inner.select(mbox)?;
483        let Some(uid_validity) = select.uid_validity.map(NonZeroU32::get) else {
484            return Ok(EnvelopeDiff::FullListRequired { new_state: None });
485        };
486
487        let exists = select.exists.unwrap_or(0);
488        let mut highest_uid: u32 = 0;
489        if exists > 0 {
490            let sequence_set: SequenceSet = "*"
491                .try_into()
492                .expect("`*` is a valid sequence set spelling");
493            let item_names =
494                MacroOrMessageDataItemNames::MessageDataItemNames(vec![MessageDataItemName::Uid]);
495            let data = self.inner.fetch(sequence_set, item_names, false)?;
496            highest_uid = data
497                .into_values()
498                .flat_map(|items| items.into_inner().into_iter())
499                .filter_map(|item| match item {
500                    MessageDataItem::Uid(u) => Some(u.get()),
501                    _ => None,
502                })
503                .max()
504                .unwrap_or(0);
505        }
506
507        let highest_mod_seq = select.highest_mod_seq.unwrap_or(0);
508
509        let new_state = ImapState {
510            uid_validity,
511            highest_mod_seq,
512            highest_uid,
513        }
514        .encode();
515
516        Ok(EnvelopeDiff::FullListRequired {
517            new_state: Some(new_state),
518        })
519    }
520}
521
522#[cfg(any(
523    feature = "rustls-ring",
524    feature = "rustls-aws",
525    feature = "native-tls"
526))]
527impl ImapClientStd {
528    /// Opens a TCP / TLS connection to `url`, runs the optional STARTTLS
529    /// upgrade and the SASL authentication, then wraps the authenticated stream
530    /// with the io-email knobs.
531    ///
532    /// Delegates the protocol dance to [`InnerImapClientStd::connect`], which
533    /// also sets a 5 s read timeout on the underlying socket so
534    /// [`Self::watch_mailbox`] can poll its shutdown flag at every timeout
535    /// tick. `auto_id` is forwarded to the inner connect and triggers an RFC
536    /// 2971 `ID` round-trip after authentication (see
537    /// [`InnerImapClientStd::auto_id`]).
538    pub fn connect(
539        url: &Url,
540        tls: &Tls,
541        starttls: bool,
542        sasl: Option<impl Into<Sasl>>,
543        auto_id: Option<Vec<(IString<'static>, NString<'static>)>>,
544    ) -> Result<Self, ImapClientError> {
545        let (inner, capabilities) = InnerImapClientStd::connect(url, tls, starttls, sasl, auto_id)?;
546
547        Ok(Self {
548            inner,
549            auto_select: true,
550            capabilities,
551        })
552    }
553}