Skip to main content

rmut_core/
imap.rs

1//! Minimal IMAP4rev1 client: exactly the commands rmut needs (LOGIN,
2//! LIST, SELECT, UID FETCH/STORE, EXPUNGE, APPEND, NOOP, LOGOUT), over
3//! plain TCP or TLS. Responses are read as logical lines with RFC 3501
4//! `{N}` literals collected alongside the text.
5
6use anyhow::{Context, Result, ensure};
7
8use crate::maildir::Flags;
9use crate::net::{self, Conn};
10
11pub struct Client {
12    conn: Conn,
13    tag: u32,
14}
15
16/// One logical response line; the bytes of each `{N}` literal are in
17/// `literals`, in order of appearance in `text`.
18#[derive(Debug)]
19struct Line {
20    text: String,
21    literals: Vec<Vec<u8>>,
22}
23
24#[derive(Debug, Clone, PartialEq, Eq)]
25pub struct Folder {
26    pub name: String,
27    pub no_select: bool,
28}
29
30#[derive(Debug, Clone, Copy, Default)]
31pub struct Select {
32    pub exists: u32,
33    pub uidvalidity: u32,
34}
35
36/// What a NOOP's untagged responses amounted to.
37#[derive(Debug, Clone, Copy, PartialEq, Eq)]
38pub enum Changes {
39    None,
40    /// Only new arrivals: fetching from the last known UID suffices.
41    NewOnly,
42    /// Flag changes / expunges / anything else: reconcile everything.
43    Full,
44}
45
46/// One message from a FETCH response; `body` holds whatever body
47/// section the command asked for (headers or the full message).
48#[derive(Debug)]
49pub struct Fetched {
50    pub uid: u32,
51    pub flags: Flags,
52    pub size: u64,
53    pub body: Option<Vec<u8>>,
54}
55
56/// How many read timeouts make up mutt's ~25 minute IDLE window, so a
57/// short io timeout does not turn into a chatty re-IDLE loop.
58fn idle_waits(timeout: std::time::Duration) -> u32 {
59    const WINDOW_SECS: u64 = 25 * 60;
60    (WINDOW_SECS / timeout.as_secs().max(1)).max(1) as u32
61}
62
63impl Client {
64    pub fn connect(host: &str, port: u16, tls: bool) -> Result<Client> {
65        Client::connect_with(host, port, tls, &net::Cutoff::default())
66    }
67
68    /// The same, with a way to cut the connection short from another
69    /// thread (mutt's Ctrl+G).
70    pub fn connect_with(host: &str, port: u16, tls: bool, cutoff: &net::Cutoff) -> Result<Client> {
71        // Port 993 is TLS from the first byte; on any other port the
72        // session is upgraded with STARTTLS before LOGIN (unless
73        // imap_tls = false, for tests).
74        let implicit = tls && port == 993;
75        let mut conn = Conn::new(
76            net::connect(host, port, implicit, cutoff)?,
77            format!("{host}:{port}"),
78        );
79        let greeting = read_line(&mut conn)?;
80        ensure!(
81            greeting.text.starts_with("* OK") || greeting.text.starts_with("* PREAUTH"),
82            "unexpected IMAP greeting: {}",
83            greeting.text
84        );
85        if tls && !implicit {
86            conn.write_all(b"rmut0 STARTTLS\r\n")?;
87            loop {
88                let line = read_line(&mut conn)?;
89                if let Some(rest) = line.text.strip_prefix("rmut0 ") {
90                    ensure!(
91                        rest.starts_with("OK"),
92                        "server refused STARTTLS: {}",
93                        line.text
94                    );
95                    break;
96                }
97            }
98            let tcp = conn.into_stream().into_tcp()?;
99            conn = Conn::new(net::wrap_tls(tcp, host)?, format!("{host}:{port}"));
100        }
101        Ok(Client { conn, tag: 0 })
102    }
103
104    pub fn login(&mut self, user: &str, password: &str) -> Result<()> {
105        match (quoted(user), quoted(password)) {
106            (Some(u), Some(p)) => {
107                self.command(&format!("LOGIN {u} {p}"))
108                    .context("IMAP login")?;
109            }
110            _ => {
111                // Something unquotable, so send both as literals.
112                let tag = self.next_tag();
113                self.conn
114                    .write_all(format!("{tag} LOGIN {{{}}}\r\n", user.len()).as_bytes())?;
115                self.expect_continuation()?;
116                self.conn
117                    .write_all(format!("{user} {{{}}}\r\n", password.len()).as_bytes())?;
118                self.expect_continuation()?;
119                self.conn.write_all(format!("{password}\r\n").as_bytes())?;
120                self.finish(&tag).context("IMAP login")?;
121            }
122        }
123        Ok(())
124    }
125
126    /// SASL AUTHENTICATE with one client response (the OAuth
127    /// mechanisms): send it on the server's first continuation; a
128    /// second continuation carries an error blob, which an empty line
129    /// converts into the tagged NO.
130    pub fn authenticate(&mut self, mechanism: &str, response_b64: &str) -> Result<()> {
131        let tag = self.next_tag();
132        self.conn
133            .write_all(format!("{tag} AUTHENTICATE {mechanism}\r\n").as_bytes())?;
134        let mut response = Some(response_b64);
135        loop {
136            let line = read_line(&mut self.conn)?;
137            if let Some(rest) = line
138                .text
139                .strip_prefix(tag.as_str())
140                .and_then(|r| r.strip_prefix(' '))
141            {
142                ensure!(rest.starts_with("OK"), "server said: {rest}");
143                return Ok(());
144            }
145            if line.text.starts_with('+') {
146                match response.take() {
147                    Some(payload) => self.conn.write_all(format!("{payload}\r\n").as_bytes())?,
148                    None => self.conn.write_all(b"\r\n")?,
149                }
150            }
151        }
152    }
153
154    pub fn list(&mut self) -> Result<Vec<Folder>> {
155        let lines = self.command("LIST \"\" \"*\"")?;
156        Ok(lines.iter().filter_map(parse_list).collect())
157    }
158
159    pub fn select(&mut self, mailbox: &str) -> Result<Select> {
160        let lines = self.command(&format!("SELECT {}", mailbox_arg(mailbox)?))?;
161        let mut sel = Select::default();
162        for line in &lines {
163            if let Some(n) = line
164                .text
165                .strip_prefix("* ")
166                .and_then(|r| r.strip_suffix(" EXISTS"))
167                .and_then(|n| n.parse().ok())
168            {
169                sel.exists = n;
170            }
171            if let Some(n) = number_after(&line.text, "[UIDVALIDITY ") {
172                sel.uidvalidity = n as u32;
173            }
174        }
175        Ok(sel)
176    }
177
178    /// UID and flags of the messages in `set` (e.g. `1:*`).
179    pub fn uid_fetch_flags(&mut self, set: &str) -> Result<Vec<Fetched>> {
180        let lines = self.command(&format!("UID FETCH {set} (UID FLAGS)"))?;
181        Ok(lines.iter().filter_map(parse_fetch).collect())
182    }
183
184    /// Flags, size, and the full header block, for indexing new mail.
185    pub fn uid_fetch_headers(&mut self, set: &str) -> Result<Vec<Fetched>> {
186        let lines = self.command(&format!(
187            "UID FETCH {set} (UID FLAGS RFC822.SIZE BODY.PEEK[HEADER])"
188        ))?;
189        Ok(lines.iter().filter_map(parse_fetch).collect())
190    }
191
192    /// The complete raw message.
193    pub fn uid_fetch_full(&mut self, uid: u32) -> Result<Vec<u8>> {
194        let lines = self.command(&format!("UID FETCH {uid} (UID BODY.PEEK[])"))?;
195        lines
196            .iter()
197            .filter_map(parse_fetch)
198            .find_map(|f| if f.uid == uid { f.body } else { None })
199            .with_context(|| format!("server returned no body for UID {uid}"))
200    }
201
202    /// Overwrite the message's flags (deletion goes via `uid_delete`).
203    pub fn uid_store_flags(&mut self, uid: u32, flags: Flags) -> Result<()> {
204        self.command(&format!(
205            "UID STORE {uid} FLAGS.SILENT ({})",
206            imap_flags(flags)
207        ))
208        .map(drop)
209    }
210
211    pub fn uid_delete(&mut self, set: &str) -> Result<()> {
212        self.command(&format!("UID STORE {set} +FLAGS.SILENT (\\Deleted)"))
213            .map(drop)
214    }
215
216    pub fn expunge(&mut self) -> Result<()> {
217        self.command("EXPUNGE").map(drop)
218    }
219
220    /// Folder management: CREATE, DELETE, RENAME, SUBSCRIBE,
221    /// UNSUBSCRIBE, each a single tagged command. The server's tagged
222    /// NO/BAD becomes an error through `finish`.
223    pub fn create_mailbox(&mut self, mailbox: &str) -> Result<()> {
224        let arg = mailbox_arg(mailbox)?;
225        self.command(&format!("CREATE {arg}")).map(drop)
226    }
227
228    pub fn delete_mailbox(&mut self, mailbox: &str) -> Result<()> {
229        let arg = mailbox_arg(mailbox)?;
230        self.command(&format!("DELETE {arg}")).map(drop)
231    }
232
233    pub fn rename_mailbox(&mut self, from: &str, to: &str) -> Result<()> {
234        let from = mailbox_arg(from)?;
235        let to = mailbox_arg(to)?;
236        self.command(&format!("RENAME {from} {to}")).map(drop)
237    }
238
239    pub fn subscribe_mailbox(&mut self, mailbox: &str, on: bool) -> Result<()> {
240        let arg = mailbox_arg(mailbox)?;
241        let verb = if on { "SUBSCRIBE" } else { "UNSUBSCRIBE" };
242        self.command(&format!("{verb} {arg}")).map(drop)
243    }
244
245    /// Server-side copy into another folder ($trash before a purge).
246    pub fn uid_copy(&mut self, set: &str, mailbox: &str) -> Result<()> {
247        self.command(&format!("UID COPY {set} {}", mailbox_arg(mailbox)?))
248            .map(drop)
249    }
250
251    pub fn append(&mut self, mailbox: &str, flags: Flags, body: &[u8]) -> Result<()> {
252        let arg = mailbox_arg(mailbox)?;
253        let tag = self.next_tag();
254        self.conn.write_all(
255            format!(
256                "{tag} APPEND {arg} ({}) {{{}}}\r\n",
257                imap_flags(flags),
258                body.len()
259            )
260            .as_bytes(),
261        )?;
262        self.expect_continuation()?;
263        self.conn.write_all(body)?;
264        self.conn.write_all(b"\r\n")?;
265        self.finish(&tag).map(drop)
266    }
267
268    /// Server-side body search: UIDs whose text contains `text`
269    /// (ASCII only; anything else needs CHARSET negotiation, so the
270    /// caller falls back to matching locally).
271    pub fn uid_search_body(&mut self, text: &str) -> Result<Vec<u32>> {
272        let arg = quoted(text).context("search text needs a charset")?;
273        let lines = self.command(&format!("UID SEARCH BODY {arg}"))?;
274        let mut out = Vec::new();
275        for line in &lines {
276            if let Some(rest) = line.text.trim_end().strip_prefix("* SEARCH") {
277                out.extend(
278                    rest.split_whitespace()
279                        .filter_map(|t| t.parse::<u32>().ok()),
280                );
281            }
282        }
283        Ok(out)
284    }
285
286    /// NOOP, classifying the server's untagged report: nothing, only
287    /// new arrivals (EXISTS/RECENT), or anything else (flag changes,
288    /// expunges, unknown lines) that needs a full reconciliation.
289    pub fn noop_changes(&mut self) -> Result<Changes> {
290        let lines = self.command("NOOP")?;
291        if lines.is_empty() {
292            return Ok(Changes::None);
293        }
294        let new_only = lines.iter().all(|l| {
295            let t = l.text.trim_end();
296            t.ends_with(" EXISTS") || t.ends_with(" RECENT")
297        });
298        let arrivals = lines.iter().any(|l| l.text.trim_end().ends_with(" EXISTS"));
299        Ok(if new_only && arrivals {
300            Changes::NewOnly
301        } else {
302            Changes::Full
303        })
304    }
305
306    /// True when the server advertises IDLE (RFC 2177).
307    pub fn supports_idle(&mut self) -> Result<bool> {
308        let lines = self.command("CAPABILITY")?;
309        Ok(lines.iter().any(|l| {
310            l.text
311                .to_ascii_uppercase()
312                .split_whitespace()
313                .any(|word| word == "IDLE")
314        }))
315    }
316
317    /// UNSEEN count of a mailbox (STATUS must not target the currently
318    /// selected one).
319    pub fn status_unseen(&mut self, mailbox: &str) -> Result<u32> {
320        let lines = self.command(&format!("STATUS {} (UNSEEN)", mailbox_arg(mailbox)?))?;
321        Ok(lines
322            .iter()
323            .find_map(|l| number_after(&l.text, "UNSEEN "))
324            .unwrap_or(0) as u32)
325    }
326
327    /// RFC 2177 IDLE: block until the server announces a change, `stop`
328    /// is set (checked whenever the socket's read timeout fires), or
329    /// ~25 minutes pass; re-issue before the server's half-hour
330    /// limit. True = the mailbox changed.
331    pub fn idle(&mut self, stop: &std::sync::atomic::AtomicBool) -> Result<bool> {
332        use std::sync::atomic::Ordering;
333        let tag = self.next_tag();
334        self.conn.write_all(format!("{tag} IDLE\r\n").as_bytes())?;
335        self.expect_continuation()?;
336        let mut event = false;
337        let mut waits = 0;
338        // However long a read waits, keep the IDLE itself to about
339        // 25 minutes: the timeout is the heartbeat, not the limit.
340        let budget = idle_waits(net::io_timeout());
341        while !stop.load(Ordering::Relaxed) && waits < budget {
342            match read_line(&mut self.conn) {
343                Ok(line) if line.text.starts_with('*') => {
344                    event = true;
345                    break;
346                }
347                Ok(_) => {}
348                Err(err) if net::is_timeout(&err) => waits += 1,
349                Err(err) => return Err(err),
350            }
351        }
352        self.conn.write_all(b"DONE\r\n")?;
353        self.finish(&tag)?;
354        Ok(event)
355    }
356
357    /// Best-effort; the connection is unusable afterwards.
358    pub fn logout(&mut self) {
359        let tag = self.next_tag();
360        let _ = self.conn.write_all(format!("{tag} LOGOUT\r\n").as_bytes());
361    }
362
363    // ---- protocol plumbing ----
364
365    fn next_tag(&mut self) -> String {
366        self.tag += 1;
367        format!("a{}", self.tag)
368    }
369
370    fn command(&mut self, cmd: &str) -> Result<Vec<Line>> {
371        let tag = self.next_tag();
372        self.conn.write_all(format!("{tag} {cmd}\r\n").as_bytes())?;
373        self.finish(&tag)
374    }
375
376    /// Collect untagged lines until our tagged OK/NO/BAD.
377    fn finish(&mut self, tag: &str) -> Result<Vec<Line>> {
378        let mut lines = Vec::new();
379        loop {
380            let line = read_line(&mut self.conn)?;
381            if let Some(rest) = line
382                .text
383                .strip_prefix(tag)
384                .and_then(|r| r.strip_prefix(' '))
385            {
386                ensure!(rest.starts_with("OK"), "server said: {rest}");
387                return Ok(lines);
388            }
389            lines.push(line);
390        }
391    }
392
393    fn expect_continuation(&mut self) -> Result<()> {
394        loop {
395            let line = read_line(&mut self.conn)?;
396            if line.text.starts_with('+') {
397                return Ok(());
398            }
399            ensure!(
400                line.text.starts_with('*'),
401                "expected continuation, server said: {}",
402                line.text
403            );
404        }
405    }
406}
407
408fn read_line(conn: &mut Conn) -> Result<Line> {
409    let mut text = Vec::new();
410    let mut literals = Vec::new();
411    loop {
412        loop {
413            let b = conn.read_byte()?;
414            if b == b'\n' {
415                if text.last() == Some(&b'\r') {
416                    text.pop();
417                }
418                break;
419            }
420            text.push(b);
421            ensure!(text.len() <= 1 << 20, "response line too long");
422        }
423        match literal_len(&text) {
424            Some(n) => {
425                ensure!(n <= 256 << 20, "literal too large ({n} bytes)");
426                let mut lit = Vec::with_capacity(n.min(1 << 20));
427                conn.read_exact_to(&mut lit, n)?;
428                literals.push(lit);
429            }
430            None => break,
431        }
432    }
433    Ok(Line {
434        text: String::from_utf8_lossy(&text).into_owned(),
435        literals,
436    })
437}
438
439/// Byte count when the line ends with an RFC 3501 literal marker `{N}`.
440fn literal_len(text: &[u8]) -> Option<usize> {
441    if text.last() != Some(&b'}') {
442        return None;
443    }
444    let open = text.iter().rposition(|&b| b == b'{')?;
445    let digits = &text[open + 1..text.len() - 1];
446    if digits.is_empty() || !digits.iter().all(u8::is_ascii_digit) {
447        return None;
448    }
449    std::str::from_utf8(digits).ok()?.parse().ok()
450}
451
452/// The string as an IMAP quoted string, when its bytes allow one
453/// (printable ASCII; quotes and backslashes escaped).
454fn quoted(s: &str) -> Option<String> {
455    if !s.bytes().all(|b| (0x20..0x7f).contains(&b)) {
456        return None;
457    }
458    Some(format!(
459        "\"{}\"",
460        s.replace('\\', "\\\\").replace('"', "\\\"")
461    ))
462}
463
464fn mailbox_arg(mailbox: &str) -> Result<String> {
465    quoted(mailbox).with_context(|| format!("unsupported mailbox name: {mailbox}"))
466}
467
468fn number_after(text: &str, marker: &str) -> Option<u64> {
469    let rest = &text[text.find(marker)? + marker.len()..];
470    let end = rest
471        .find(|c: char| !c.is_ascii_digit())
472        .unwrap_or(rest.len());
473    rest[..end].parse().ok()
474}
475
476/// `* LIST (\Noselect) "/" "INBOX/sub"`; name may be quoted, a
477/// literal, or a bare atom.
478fn parse_list(line: &Line) -> Option<Folder> {
479    let rest = line.text.strip_prefix("* LIST ")?;
480    let close = rest.find(')')?;
481    let no_select = rest[..close].to_ascii_lowercase().contains("\\noselect");
482    let mut rest = rest[close + 1..].trim_start();
483    if let Some(after) = rest.strip_prefix("NIL") {
484        rest = after;
485    } else if rest.starts_with('"') {
486        rest = take_quoted(rest)?.1;
487    } else {
488        return None;
489    }
490    let rest = rest.trim_start();
491    let name = if rest.starts_with('"') {
492        take_quoted(rest)?.0
493    } else if rest.starts_with('{') {
494        String::from_utf8_lossy(line.literals.first()?).into_owned()
495    } else {
496        rest.to_string()
497    };
498    Some(Folder { name, no_select })
499}
500
501/// Parse a leading quoted string: (contents, rest after closing quote).
502fn take_quoted(s: &str) -> Option<(String, &str)> {
503    let bytes = s.as_bytes();
504    if bytes.first() != Some(&b'"') {
505        return None;
506    }
507    let mut out = Vec::new();
508    let mut i = 1;
509    while i < bytes.len() {
510        match bytes[i] {
511            b'\\' if i + 1 < bytes.len() => {
512                out.push(bytes[i + 1]);
513                i += 2;
514            }
515            b'"' => {
516                return Some((String::from_utf8_lossy(&out).into_owned(), &s[i + 1..]));
517            }
518            b => {
519                out.push(b);
520                i += 1;
521            }
522        }
523    }
524    None
525}
526
527/// `* 12 FETCH (UID 34 FLAGS (\Seen) RFC822.SIZE 120 BODY[...] {N})`.
528/// Attribute order is up to the server, so scan by keyword; the body is
529/// the line's (only) literal since that's all our FETCHes ask for.
530fn parse_fetch(line: &Line) -> Option<Fetched> {
531    let rest = line.text.strip_prefix("* ")?;
532    let (_, attrs) = rest.split_once(" FETCH ")?;
533    let uid = number_after(attrs, "UID ")? as u32;
534    let flags = attrs
535        .find("FLAGS (")
536        .map(|i| {
537            let inner = &attrs[i + "FLAGS (".len()..];
538            parse_flags(&inner[..inner.find(')').unwrap_or(inner.len())])
539        })
540        .unwrap_or_default();
541    Some(Fetched {
542        uid,
543        flags,
544        size: number_after(attrs, "RFC822.SIZE ").unwrap_or(0),
545        body: line.literals.first().cloned(),
546    })
547}
548
549fn parse_flags(inner: &str) -> Flags {
550    let mut flags = Flags::default();
551    for token in inner.split_whitespace() {
552        match token.to_ascii_lowercase().as_str() {
553            "\\seen" => flags.seen = true,
554            "\\answered" => flags.answered = true,
555            "\\flagged" => flags.flagged = true,
556            "\\deleted" => flags.deleted = true,
557            "\\draft" => flags.draft = true,
558            _ => {}
559        }
560    }
561    flags
562}
563
564/// Maildir flags as an IMAP flag list, S/R/F/D/T → standard flags.
565fn imap_flags(flags: Flags) -> String {
566    let mut out: Vec<&str> = Vec::new();
567    if flags.seen {
568        out.push("\\Seen");
569    }
570    if flags.answered {
571        out.push("\\Answered");
572    }
573    if flags.flagged {
574        out.push("\\Flagged");
575    }
576    if flags.draft {
577        out.push("\\Draft");
578    }
579    if flags.deleted {
580        out.push("\\Deleted");
581    }
582    out.join(" ")
583}
584
585#[cfg(test)]
586mod tests {
587    use super::*;
588    use crate::testserver;
589
590    #[test]
591    fn starttls_refusal_is_an_error() {
592        // tls on a non-993 port means STARTTLS; a server that refuses
593        // it must fail the connect, never a plaintext LOGIN.
594        let (port, handle) = testserver::imap(vec![testserver::Expect::fail(
595            "STARTTLS",
596            "NO too old for that",
597        )]);
598        let err = match Client::connect("127.0.0.1", port, true) {
599            Err(err) => err,
600            Ok(_) => panic!("connect must fail when STARTTLS is refused"),
601        };
602        assert!(
603            err.to_string().contains("refused STARTTLS"),
604            "unexpected error: {err:#}"
605        );
606        handle.join().unwrap();
607    }
608
609    #[test]
610    fn literal_len_only_at_line_end() {
611        assert_eq!(literal_len(b"* 1 FETCH (BODY[] {42}"), Some(42));
612        assert_eq!(literal_len(b"a1 OK done"), None);
613        assert_eq!(literal_len(b"{} no digits"), None);
614        assert_eq!(literal_len(b"{12} trailing"), None);
615    }
616
617    #[test]
618    fn quoted_escapes_and_rejects() {
619        assert_eq!(quoted("plain"), Some("\"plain\"".into()));
620        assert_eq!(quoted(r#"a"b\c"#), Some(r#""a\"b\\c""#.into()));
621        assert_eq!(quoted("naïve"), None);
622        assert_eq!(quoted("nl\n"), None);
623    }
624
625    #[test]
626    fn take_quoted_handles_escapes() {
627        assert_eq!(
628            take_quoted(r#""IN \"Q\" BOX" rest"#),
629            Some(("IN \"Q\" BOX".into(), " rest"))
630        );
631        assert_eq!(take_quoted("\"unterminated"), None);
632    }
633
634    #[test]
635    fn parse_list_variants() {
636        let line = |text: &str| Line {
637            text: text.into(),
638            literals: vec![],
639        };
640        let f = parse_list(&line(r#"* LIST (\HasNoChildren) "/" "INBOX/sub""#)).unwrap();
641        assert_eq!(f.name, "INBOX/sub");
642        assert!(!f.no_select);
643        let f = parse_list(&line(r#"* LIST (\Noselect) "." Public"#)).unwrap();
644        assert_eq!(f.name, "Public");
645        assert!(f.no_select);
646        let lit = Line {
647            text: r#"* LIST () "/" {9}"#.into(),
648            literals: vec![b"Wei\xc3\x9fes B".to_vec()],
649        };
650        assert_eq!(parse_list(&lit).unwrap().name, "Weißes B");
651        assert!(parse_list(&line("* STATUS foo")).is_none());
652    }
653
654    #[test]
655    fn parse_fetch_reads_uid_flags_size_and_body() {
656        let f = parse_fetch(&Line {
657            text: "* 3 FETCH (UID 77 FLAGS (\\Seen \\Flagged $Junk) RFC822.SIZE 1234 BODY[HEADER] {20})"
658                .into(),
659            literals: vec![b"Subject: x\r\n\r\n".to_vec()],
660        })
661        .unwrap();
662        assert_eq!(f.uid, 77);
663        assert!(f.flags.seen && f.flags.flagged && !f.flags.deleted);
664        assert_eq!(f.size, 1234);
665        assert_eq!(f.body.unwrap(), b"Subject: x\r\n\r\n");
666        // Flags-only fetch, server order reversed.
667        let f = parse_fetch(&Line {
668            text: "* 1 FETCH (FLAGS (\\Deleted) UID 5)".into(),
669            literals: vec![],
670        })
671        .unwrap();
672        assert_eq!(f.uid, 5);
673        assert!(f.flags.deleted && f.body.is_none());
674    }
675
676    #[test]
677    fn imap_flags_roundtrip_through_parse() {
678        let flags = Flags {
679            seen: true,
680            answered: true,
681            flagged: false,
682            deleted: true,
683            draft: true,
684        };
685        assert_eq!(parse_flags(&imap_flags(flags)), flags);
686        assert_eq!(imap_flags(Flags::default()), "");
687    }
688
689    #[test]
690    fn session_against_scripted_server() {
691        let header = "Subject: hello\r\nFrom: a@example.com\r\n\r\n";
692        let (port, handle) = testserver::imap(vec![
693            testserver::Expect::new("LOGIN \"jane\" \"secret\"", String::new()),
694            testserver::Expect::new(
695                "SELECT \"INBOX\"",
696                "* 2 EXISTS\r\n* OK [UIDVALIDITY 99] UIDs valid\r\n".into(),
697            ),
698            testserver::Expect::new(
699                "UID FETCH 1:* (UID FLAGS RFC822.SIZE BODY.PEEK[HEADER])",
700                format!(
701                    "* 1 FETCH (UID 10 FLAGS (\\Seen) RFC822.SIZE 500 BODY[HEADER] {{{}}}\r\n{})\r\n",
702                    header.len(),
703                    header
704                ),
705            ),
706            testserver::Expect::new(
707                "UID STORE 10 FLAGS.SILENT (\\Seen \\Flagged)",
708                String::new(),
709            ),
710            testserver::Expect::new("UID STORE 10,11 +FLAGS.SILENT (\\Deleted)", String::new()),
711            testserver::Expect::new("EXPUNGE", "* 1 EXPUNGE\r\n".into()),
712            testserver::Expect::new("APPEND \"Sent\" (\\Seen)", String::new()),
713            testserver::Expect::new("NOOP", "* 3 EXISTS\r\n".into()),
714            testserver::Expect::new("LOGOUT", String::new()),
715        ]);
716        let mut client = Client::connect("127.0.0.1", port, false).unwrap();
717        client.login("jane", "secret").unwrap();
718        let sel = client.select("INBOX").unwrap();
719        assert_eq!(sel.exists, 2);
720        assert_eq!(sel.uidvalidity, 99);
721        let fetched = client.uid_fetch_headers("1:*").unwrap();
722        assert_eq!(fetched.len(), 1);
723        assert_eq!(fetched[0].uid, 10);
724        assert_eq!(fetched[0].body.as_deref().unwrap(), header.as_bytes());
725        client
726            .uid_store_flags(
727                10,
728                Flags {
729                    seen: true,
730                    flagged: true,
731                    ..Default::default()
732                },
733            )
734            .unwrap();
735        client.uid_delete("10,11").unwrap();
736        client.expunge().unwrap();
737        client
738            .append(
739                "Sent",
740                Flags {
741                    seen: true,
742                    ..Default::default()
743                },
744                b"From: a@b\r\n\r\nhi\r\n",
745            )
746            .unwrap();
747        assert_eq!(client.noop_changes().unwrap(), Changes::NewOnly);
748        client.logout();
749        handle.join().unwrap();
750    }
751
752    #[test]
753    fn folder_management_commands() {
754        let (port, handle) = testserver::imap(vec![
755            testserver::Expect::new("CREATE \"Archive/2026\"", String::new()),
756            testserver::Expect::new("SUBSCRIBE \"Archive/2026\"", String::new()),
757            testserver::Expect::new("RENAME \"Archive/2026\" \"Archive/old\"", String::new()),
758            testserver::Expect::new("UNSUBSCRIBE \"Archive/old\"", String::new()),
759            testserver::Expect::fail("DELETE \"Archive/old\"", "NO [CANNOT] not allowed"),
760            testserver::Expect::new("LOGOUT", String::new()),
761        ]);
762        let mut client = Client::connect("127.0.0.1", port, false).unwrap();
763        client.create_mailbox("Archive/2026").unwrap();
764        client.subscribe_mailbox("Archive/2026", true).unwrap();
765        client
766            .rename_mailbox("Archive/2026", "Archive/old")
767            .unwrap();
768        client.subscribe_mailbox("Archive/old", false).unwrap();
769        // A server that refuses becomes an error, not a silent success.
770        assert!(client.delete_mailbox("Archive/old").is_err());
771        client.logout();
772        handle.join().unwrap();
773    }
774
775    #[test]
776    fn noop_classifies_changes() {
777        let (port, handle) = testserver::imap(vec![
778            testserver::Expect::new("NOOP", String::new()),
779            testserver::Expect::new("NOOP", "* 4 EXISTS\r\n* 1 RECENT\r\n".into()),
780            testserver::Expect::new("NOOP", "* 2 EXPUNGE\r\n".into()),
781            testserver::Expect::new(
782                "NOOP",
783                "* 1 FETCH (FLAGS (\\Seen))\r\n* 5 EXISTS\r\n".into(),
784            ),
785            testserver::Expect::new("NOOP", "* 0 RECENT\r\n".into()),
786        ]);
787        let mut client = Client::connect("127.0.0.1", port, false).unwrap();
788        assert_eq!(client.noop_changes().unwrap(), Changes::None);
789        assert_eq!(client.noop_changes().unwrap(), Changes::NewOnly);
790        assert_eq!(client.noop_changes().unwrap(), Changes::Full);
791        assert_eq!(client.noop_changes().unwrap(), Changes::Full);
792        // RECENT without EXISTS says nothing certain: reconcile.
793        assert_eq!(client.noop_changes().unwrap(), Changes::Full);
794        handle.join().unwrap();
795    }
796
797    #[test]
798    fn authenticate_oauth_success_and_failure() {
799        // XOAUTH2 for user=jane token=tok, precomputed base64.
800        let blob = "dXNlcj1qYW5lAWF1dGg9QmVhcmVyIHRvawEB";
801        let (port, handle) = testserver::imap(vec![
802            testserver::Expect::untagged("AUTHENTICATE XOAUTH2", "+ \r\n".into()),
803            testserver::Expect::new(blob, String::new()),
804            // Second attempt: the server answers the response with an
805            // error blob; the client's empty line fetches the NO.
806            testserver::Expect::untagged("AUTHENTICATE XOAUTH2", "+ \r\n".into()),
807            testserver::Expect::untagged(blob, "+ eyJzdGF0dXMiOiI0MDEifQ==\r\n".into()),
808            testserver::Expect::fail("", "NO [AUTHENTICATIONFAILED] bad token"),
809        ]);
810        let mut client = Client::connect("127.0.0.1", port, false).unwrap();
811        client.authenticate("XOAUTH2", blob).unwrap();
812        let err = client.authenticate("XOAUTH2", blob).unwrap_err();
813        assert!(
814            format!("{err:#}").contains("AUTHENTICATIONFAILED"),
815            "{err:#}"
816        );
817        handle.join().unwrap();
818    }
819
820    #[test]
821    fn capability_and_status() {
822        let (port, handle) = testserver::imap(vec![
823            testserver::Expect::new("CAPABILITY", "* CAPABILITY IMAP4rev1 IDLE\r\n".into()),
824            testserver::Expect::new(
825                "STATUS \"Archive\" (UNSEEN)",
826                "* STATUS \"Archive\" (UNSEEN 3)\r\n".into(),
827            ),
828            testserver::Expect::new("CAPABILITY", "* CAPABILITY IMAP4rev1\r\n".into()),
829        ]);
830        let mut client = Client::connect("127.0.0.1", port, false).unwrap();
831        assert!(client.supports_idle().unwrap());
832        assert_eq!(client.status_unseen("Archive").unwrap(), 3);
833        // IMAP4rev1 must not read as IDLE support.
834        assert!(!client.supports_idle().unwrap());
835        handle.join().unwrap();
836    }
837
838    #[test]
839    fn the_idle_window_is_the_same_however_long_a_read_waits() {
840        // mutt re-issues IDLE before the server's half hour; the read
841        // timeout is only how often the stop flag is looked at.
842        assert_eq!(idle_waits(std::time::Duration::from_secs(60)), 25);
843        assert_eq!(idle_waits(std::time::Duration::from_secs(30)), 50);
844        assert_eq!(idle_waits(std::time::Duration::from_secs(5)), 300);
845        assert_eq!(idle_waits(std::time::Duration::from_secs(0)), 1500);
846    }
847
848    #[test]
849    fn idle_reports_events_and_stop() {
850        use std::sync::atomic::{AtomicBool, Ordering};
851        let (port, handle) = testserver::imap(vec![
852            testserver::Expect::untagged("IDLE", "+ idling\r\n* 3 EXISTS\r\n".into()),
853            testserver::Expect::new("DONE", String::new()),
854            testserver::Expect::untagged("IDLE", "+ idling\r\n".into()),
855            testserver::Expect::new("DONE", String::new()),
856            testserver::Expect::new("LOGOUT", String::new()),
857        ]);
858        let mut client = Client::connect("127.0.0.1", port, false).unwrap();
859        let stop = AtomicBool::new(false);
860        // The server announces a change: idle reports it.
861        assert!(client.idle(&stop).unwrap());
862        // Stop already set: idle sends DONE straight away, no event.
863        stop.store(true, Ordering::Relaxed);
864        assert!(!client.idle(&stop).unwrap());
865        client.logout();
866        handle.join().unwrap();
867    }
868
869    #[test]
870    fn login_failure_is_reported() {
871        let (port, handle) = testserver::imap(vec![testserver::Expect::fail(
872            "LOGIN",
873            "NO [AUTHENTICATIONFAILED] bad credentials",
874        )]);
875        let mut client = Client::connect("127.0.0.1", port, false).unwrap();
876        let err = client.login("jane", "wrong").unwrap_err();
877        assert!(format!("{err:#}").contains("AUTHENTICATIONFAILED"));
878        handle.join().unwrap();
879    }
880}