Skip to main content

io_imap/rfc4315/
expunge_uid.rs

1//! IMAP UID EXPUNGE coroutine returning the expunged sequence numbers.
2//!
3//! `UID EXPUNGE` (RFC 4315, UIDPLUS) permanently removes only the
4//! `\Deleted` messages whose UID is in the given set, leaving any other
5//! `\Deleted` message in the mailbox untouched — unlike the plain
6//! [`crate::rfc3501::expunge::ImapMailboxExpunge`], which removes every
7//! `\Deleted` message. Only usable when the server advertises `UIDPLUS`.
8//!
9//! # Example
10//!
11//! ```rust,no_run
12//! use std::{
13//!     io::{Read, Write},
14//!     net::TcpStream,
15//! };
16//!
17//! use io_imap::{
18//!     codec::fragmentizer::Fragmentizer,
19//!     coroutine::{ImapCoroutine, ImapCoroutineState, ImapYield},
20//!     rfc4315::expunge_uid::ImapMessageExpungeUid,
21//! };
22//!
23//! // Ready stream needed (TCP-connected, TLS-negotiated, IMAP-authenticated)
24//! let mut stream = TcpStream::connect("localhost:143").unwrap();
25//!
26//! let mut fragmentizer = Fragmentizer::new(50 * 1024 * 1024);
27//! let mut buf = [0u8; 4096];
28//!
29//! let sequence_set = "1:3".try_into().unwrap();
30//! let mut coroutine = ImapMessageExpungeUid::new(sequence_set);
31//! let mut arg = None;
32//!
33//! let expunged = loop {
34//!     match coroutine.resume(&mut fragmentizer, arg.take()) {
35//!         ImapCoroutineState::Yielded(ImapYield::WantsWrite(bytes)) => {
36//!             stream.write_all(&bytes).unwrap();
37//!         }
38//!         ImapCoroutineState::Yielded(ImapYield::WantsRead) => {
39//!             let n = stream.read(&mut buf).unwrap();
40//!             arg = Some(&buf[..n]);
41//!         }
42//!         ImapCoroutineState::Complete(Ok(expunged)) => break expunged,
43//!         ImapCoroutineState::Complete(Err(err)) => panic!("{err}"),
44//!     }
45//! };
46//!
47//! println!("{expunged:?}");
48//! ```
49
50use core::{fmt, num::NonZeroU32};
51
52use alloc::{string::String, string::ToString, vec::Vec};
53
54use imap_codec::{
55    CommandCodec,
56    fragmentizer::Fragmentizer,
57    imap_types::{
58        command::{Command, CommandBody},
59        core::TagGenerator,
60        response::{Data, StatusKind, Tagged},
61        sequence::SequenceSet,
62    },
63};
64use log::trace;
65use thiserror::Error;
66
67use crate::{coroutine::*, imap_try, send::*};
68
69/// Failure causes during the IMAP UID EXPUNGE flow.
70#[derive(Clone, Debug, Error)]
71pub enum ImapMessageExpungeUidError {
72    /// The server rejected the command with a NO response.
73    #[error("IMAP UID EXPUNGE failed: NO {0}")]
74    No(String),
75    /// The server rejected the command with a BAD response.
76    #[error("IMAP UID EXPUNGE failed: BAD {0}")]
77    Bad(String),
78    /// The server closed the session with an untagged BYE.
79    #[error("IMAP UID EXPUNGE failed: BYE {0}")]
80    Bye(String),
81    /// The exchange ended without a tagged response from the server.
82    #[error("IMAP UID EXPUNGE failed: server did not return a tagged response")]
83    MissingTagged,
84    /// The underlying send/receive exchange failed (EOF, decode, framing).
85    #[error("IMAP UID EXPUNGE failed: {0}")]
86    Send(#[from] ImapSendError),
87}
88
89/// I/O-free IMAP UID EXPUNGE coroutine.
90pub struct ImapMessageExpungeUid {
91    state: State,
92}
93
94impl ImapMessageExpungeUid {
95    /// Builds a UID EXPUNGE coroutine removing the `\Deleted` messages
96    /// whose UID is in `sequence_set` from the selected mailbox.
97    pub fn new(sequence_set: SequenceSet) -> Self {
98        let command = Command {
99            tag: TagGenerator::new().generate(),
100            body: CommandBody::ExpungeUid { sequence_set },
101        };
102
103        trace!("send IMAP command {command:?}");
104
105        let state = State::Send(ImapSend::new(CommandCodec::new(), command));
106
107        Self { state }
108    }
109}
110
111impl ImapCoroutine for ImapMessageExpungeUid {
112    type Yield = ImapYield;
113    type Return = Result<Vec<NonZeroU32>, ImapMessageExpungeUidError>;
114
115    fn resume(
116        &mut self,
117        fragmentizer: &mut Fragmentizer,
118        arg: Option<&[u8]>,
119    ) -> ImapCoroutineState<Self::Yield, Self::Return> {
120        match &mut self.state {
121            State::Send(send) => {
122                let out = imap_try!(send, fragmentizer, arg);
123
124                if let Some(bye) = out.bye {
125                    let err = ImapMessageExpungeUidError::Bye(bye.text.to_string());
126                    return ImapCoroutineState::Complete(Err(err));
127                }
128
129                let Some(Tagged { body, .. }) = out.tagged else {
130                    let err = ImapMessageExpungeUidError::MissingTagged;
131                    return ImapCoroutineState::Complete(Err(err));
132                };
133
134                let mut expunged = Vec::new();
135                for data in out.data {
136                    if let Data::Expunge(seq) = data {
137                        expunged.push(seq);
138                    }
139                }
140
141                match body.kind {
142                    StatusKind::Ok => ImapCoroutineState::Complete(Ok(expunged)),
143                    StatusKind::No => {
144                        let err = ImapMessageExpungeUidError::No(body.text.to_string());
145                        ImapCoroutineState::Complete(Err(err))
146                    }
147                    StatusKind::Bad => {
148                        let err = ImapMessageExpungeUidError::Bad(body.text.to_string());
149                        ImapCoroutineState::Complete(Err(err))
150                    }
151                }
152            }
153        }
154    }
155}
156
157enum State {
158    Send(ImapSend<CommandCodec>),
159}
160
161impl fmt::Display for State {
162    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
163        match self {
164            Self::Send(_) => f.write_str("send uid expunge"),
165        }
166    }
167}
168
169#[cfg(test)]
170mod tests {
171    use core::str;
172
173    use alloc::{borrow::ToOwned, format};
174
175    use crate::rfc4315::expunge_uid::*;
176
177    fn sequence_set() -> SequenceSet {
178        "1:3".try_into().expect("valid sequence set")
179    }
180
181    #[test]
182    fn sends_uid_expunge_and_collects_expunged_seqs() {
183        let mut expunge = ImapMessageExpungeUid::new(sequence_set());
184        let mut frag = Fragmentizer::new(50 * 1024 * 1024);
185
186        let bytes = expect_wants_write(&mut expunge, &mut frag, None);
187        let line = str::from_utf8(&bytes).expect("utf8 command");
188        let tag = first_word(line).to_owned();
189        assert!(line.trim_end().ends_with("UID EXPUNGE 1:3"));
190
191        expect_wants_read(&mut expunge, &mut frag);
192
193        let reply = format!("* 3 EXPUNGE\r\n* 3 EXPUNGE\r\n{tag} OK UID EXPUNGE completed\r\n");
194        let seqs = expect_complete_ok(&mut expunge, &mut frag, reply.as_bytes());
195        assert_eq!(2, seqs.len());
196        assert_eq!(3, seqs[0].get());
197        assert_eq!(3, seqs[1].get());
198    }
199
200    #[test]
201    fn empty_returns_empty_vec() {
202        let mut expunge = ImapMessageExpungeUid::new(sequence_set());
203        let mut frag = Fragmentizer::new(50 * 1024 * 1024);
204
205        let bytes = expect_wants_write(&mut expunge, &mut frag, None);
206        let tag = first_word(str::from_utf8(&bytes).expect("utf8 command")).to_owned();
207
208        expect_wants_read(&mut expunge, &mut frag);
209
210        let reply = format!("{tag} OK UID EXPUNGE completed\r\n");
211        let seqs = expect_complete_ok(&mut expunge, &mut frag, reply.as_bytes());
212        assert!(seqs.is_empty());
213    }
214
215    #[test]
216    fn tagged_no_returns_no_error() {
217        let mut expunge = ImapMessageExpungeUid::new(sequence_set());
218        let mut frag = Fragmentizer::new(50 * 1024 * 1024);
219
220        let bytes = expect_wants_write(&mut expunge, &mut frag, None);
221        let tag = first_word(str::from_utf8(&bytes).expect("utf8 command")).to_owned();
222
223        expect_wants_read(&mut expunge, &mut frag);
224
225        let reply = format!("{tag} NO mailbox is read-only\r\n");
226        let err = expect_complete_err(&mut expunge, &mut frag, reply.as_bytes());
227        let ImapMessageExpungeUidError::No(text) = err else {
228            panic!("expected ImapMessageExpungeUidError::No, got {err:?}");
229        };
230        assert_eq!(text, "mailbox is read-only");
231    }
232
233    fn expect_wants_write(
234        cor: &mut ImapMessageExpungeUid,
235        frag: &mut Fragmentizer,
236        arg: Option<&[u8]>,
237    ) -> Vec<u8> {
238        match cor.resume(frag, arg) {
239            ImapCoroutineState::Yielded(ImapYield::WantsWrite(bytes)) => bytes,
240            state => panic!("expected WantsWrite, got {state:?}"),
241        }
242    }
243
244    fn expect_wants_read(cor: &mut ImapMessageExpungeUid, frag: &mut Fragmentizer) {
245        match cor.resume(frag, None) {
246            ImapCoroutineState::Yielded(ImapYield::WantsRead) => {}
247            state => panic!("expected WantsRead, got {state:?}"),
248        }
249    }
250
251    fn expect_complete_ok(
252        cor: &mut ImapMessageExpungeUid,
253        frag: &mut Fragmentizer,
254        reply: &[u8],
255    ) -> Vec<NonZeroU32> {
256        match cor.resume(frag, Some(reply)) {
257            ImapCoroutineState::Complete(Ok(value)) => value,
258            state => panic!("expected Complete(Ok), got {state:?}"),
259        }
260    }
261
262    fn expect_complete_err(
263        cor: &mut ImapMessageExpungeUid,
264        frag: &mut Fragmentizer,
265        reply: &[u8],
266    ) -> ImapMessageExpungeUidError {
267        match cor.resume(frag, Some(reply)) {
268            ImapCoroutineState::Complete(Err(err)) => err,
269            state => panic!("expected Complete(Err), got {state:?}"),
270        }
271    }
272
273    fn first_word(line: &str) -> &str {
274        line.split_whitespace()
275            .next()
276            .expect("first whitespace-separated token")
277    }
278}