oxedyne/fe2o3/fe2o3_net/src/imap/client.rs
82.6 KiB, 270 runs
created by r1870400018:13340, which is this file's identity for as long as the history lasts, whatever it is later renamed to
download · who wrote it · its history
| 1 | //! Client-side IMAP4rev1 (RFC 3501) for reading a remote mailbox. |
| 2 | //! |
| 3 | //! The counterpart to [`crate::imap::server`]: where that serves a local |
| 4 | //! Maildir to a mail reader, this connects *out* to somebody else's IMAP |
| 5 | //! server and pulls messages down. It is what a program needs in order to |
| 6 | //! treat a hosted mailbox (Gmail, Fastmail, a corporate Dovecot) as a |
| 7 | //! source of raw RFC 5322 messages. |
| 8 | //! |
| 9 | //! What it does: |
| 10 | //! |
| 11 | //! - Connects with implicit TLS (the 993 case), STARTTLS (143), or plain. |
| 12 | //! - `LOGIN` with a password, or `AUTHENTICATE XOAUTH2` with a bearer |
| 13 | //! token, whichever the account requires. |
| 14 | //! - `CAPABILITY`, `LIST`, `SELECT`/`EXAMINE`, `UID SEARCH`, `UID FETCH`, |
| 15 | //! `UID STORE`, `APPEND`, `LOGOUT`. |
| 16 | //! |
| 17 | //! What it does not do: `IDLE`, `CONDSTORE`, `QRESYNC`, compression. A |
| 18 | //! caller wanting to know what changed polls, which is what a caller |
| 19 | //! without a long-lived socket has to do anyway. |
| 20 | //! |
| 21 | //! The awkward part of IMAP is the wire format, and it is handled once, |
| 22 | //! here. A response is a line, except when it is a line with a literal |
| 23 | //! (`{1234}` followed by exactly that many raw bytes, which may contain |
| 24 | //! anything at all including CRLF) spliced into the middle of it. So the |
| 25 | //! reader assembles a *logical* line: the text with each literal lifted |
| 26 | //! out into a side queue, which the tokeniser then puts back in order. |
| 27 | //! Parsing the text as a line and hoping no message body contains a CRLF |
| 28 | //! is the classic way to write an IMAP client that works until somebody |
| 29 | //! sends you an attachment. |
| 30 | //! |
| 31 | //! # Example |
| 32 | //! |
| 33 | //! ```no_run |
| 34 | //! # use oxedyne_fe2o3_core::prelude::*; |
| 35 | //! # use oxedyne_fe2o3_net::imap::client::{FetchWhat, ImapClient, ImapConfig, Security}; |
| 36 | //! # async fn f() -> Outcome<()> { |
| 37 | //! let cfg = ImapConfig::new("imap.example.com", 993, Security::ImplicitTls); |
| 38 | //! let mut c = res!(ImapClient::connect(&cfg).await); |
| 39 | //! res!(c.login("alice@example.com", "app-password").await); |
| 40 | //! let inbox = res!(c.select("INBOX").await); |
| 41 | //! let uids = res!(c.uid_search(&fmt!("UID {}:*", inbox.uid_next.saturating_sub(10))).await); |
| 42 | //! for msg in res!(c.uid_fetch(&uids, FetchWhat::Full).await) { |
| 43 | //! println!("{} bytes, flags {:?}", msg.body.len(), msg.flags); |
| 44 | //! } |
| 45 | //! res!(c.logout().await); |
| 46 | //! # Ok(()) |
| 47 | //! # } |
| 48 | //! ``` |
| 49 | //! |
| 50 | //! [Written with AI entirely](https://need2know.ai/entirely-ai/code)\ |
| 51 | //! Anthropic Claude |
| 52 | |
| 53 | use crate::tls::{ |
| 54 | self, |
| 55 | ClientStream, |
| 56 | }; |
| 57 | |
| 58 | use oxedyne_fe2o3_core::prelude::*; |
| 59 | |
| 60 | use std::{ |
| 61 | collections::VecDeque, |
| 62 | net::SocketAddr, |
| 63 | sync::Arc, |
| 64 | time::Duration, |
| 65 | }; |
| 66 | |
| 67 | use tokio::{ |
| 68 | io::{ |
| 69 | AsyncBufReadExt, |
| 70 | AsyncReadExt, |
| 71 | AsyncWriteExt, |
| 72 | BufReader, |
| 73 | }, |
| 74 | net::TcpStream, |
| 75 | time::timeout, |
| 76 | }; |
| 77 | use tokio_rustls::rustls::ClientConfig; |
| 78 | |
| 79 | |
| 80 | // Generous, because a large `UID FETCH` against a slow mailbox is a legitimate |
| 81 | // multi-second read. |
| 82 | pub const IMAP_CLIENT_TIMEOUT: Duration = Duration::from_secs(60); |
| 83 | |
| 84 | // A server announcing a 4 GB literal is either broken or hostile, and either way |
| 85 | // the client should not try to allocate for it. |
| 86 | pub const MAX_LITERAL_BYTES: usize = 64 * 1024 * 1024; |
| 87 | |
| 88 | |
| 89 | // ┌───────────────────────────────────────────────────────────────────────────┐ |
| 90 | // │ CONFIGURATION │ |
| 91 | // └───────────────────────────────────────────────────────────────────────────┘ |
| 92 | |
| 93 | /// How the connection is protected. |
| 94 | #[derive(Clone, Copy, Debug, Eq, PartialEq)] |
| 95 | pub enum Security { |
| 96 | ImplicitTls, // TLS from the first byte, the usual case on port 993 |
| 97 | // In the clear, then `STARTTLS` before authenticating, on port 143. Where the |
| 98 | // upgrade fails the client refuses to send the credential. |
| 99 | StartTls, |
| 100 | Plain, // a test server on loopback, and nothing else |
| 101 | } |
| 102 | |
| 103 | /// Where to connect and how. |
| 104 | #[derive(Clone, Debug)] |
| 105 | pub struct ImapConfig { |
| 106 | pub host: String, // also the name the certificate is validated against |
| 107 | pub port: u16, // conventionally 993 (implicit TLS) or 143 (STARTTLS) |
| 108 | pub security: Security, |
| 109 | pub timeout: Duration, // per IO |
| 110 | // Dialled instead of resolving `host`; the certificate is still validated against `host`, so |
| 111 | // pinning the address weakens nothing. A server connecting to a host its *user* named must |
| 112 | // resolve the name, satisfy itself the answer is somewhere it is willing to go (see |
| 113 | // crate::addr::resolve_public), and then connect to that address -- not re-resolve the name |
| 114 | // and hope for the same answer twice. This field is how it does the last part. |
| 115 | pub addr: Option<SocketAddr>, |
| 116 | } |
| 117 | |
| 118 | impl ImapConfig { |
| 119 | |
| 120 | /// The default timeout, and no pinned address. |
| 121 | pub fn new<S: Into<String>>(host: S, port: u16, security: Security) -> Self { |
| 122 | Self { |
| 123 | host: host.into(), |
| 124 | port, |
| 125 | security, |
| 126 | timeout: IMAP_CLIENT_TIMEOUT, |
| 127 | addr: None, |
| 128 | } |
| 129 | } |
| 130 | |
| 131 | pub fn with_timeout(mut self, timeout: Duration) -> Self { |
| 132 | self.timeout = timeout; |
| 133 | self |
| 134 | } |
| 135 | |
| 136 | pub fn with_addr(mut self, addr: SocketAddr) -> Self { |
| 137 | self.addr = Some(addr); |
| 138 | self |
| 139 | } |
| 140 | } |
| 141 | |
| 142 | |
| 143 | // ┌───────────────────────────────────────────────────────────────────────────┐ |
| 144 | // │ RESULT TYPES │ |
| 145 | // └───────────────────────────────────────────────────────────────────────────┘ |
| 146 | |
| 147 | /// One mailbox as reported by `LIST`. |
| 148 | #[derive(Clone, Debug)] |
| 149 | pub struct MailboxInfo { |
| 150 | pub name: String, // as the server spells it |
| 151 | pub delimiter: Option<char>, // `None` for a flat namespace |
| 152 | pub attrs: Vec<String>, // e.g. `\HasChildren`, `\Noselect`, `\Sent` |
| 153 | } |
| 154 | |
| 155 | impl MailboxInfo { |
| 156 | /// A mailbox that exists only to hold children cannot itself be selected. |
| 157 | pub fn selectable(&self) -> bool { |
| 158 | !self.attrs.iter().any(|a| a.eq_ignore_ascii_case("\\Noselect")) |
| 159 | } |
| 160 | |
| 161 | /// The RFC 6154 special-use role, where the server declared one. This is how a client |
| 162 | /// recognises the Sent folder on a mailbox whose |
| 163 | /// names are localised -- Gmail spells it "[Gmail]/Gesendet" for a German |
| 164 | /// account, but the attribute is `\Sent` everywhere. |
| 165 | pub fn special_use(&self) -> Option<SpecialUse> { |
| 166 | for a in &self.attrs { |
| 167 | let role = match a.to_uppercase().as_str() { |
| 168 | "\\SENT" => Some(SpecialUse::Sent), |
| 169 | "\\DRAFTS" => Some(SpecialUse::Drafts), |
| 170 | "\\TRASH" => Some(SpecialUse::Trash), |
| 171 | "\\JUNK" => Some(SpecialUse::Junk), |
| 172 | "\\ARCHIVE" => Some(SpecialUse::Archive), |
| 173 | "\\ALL" => Some(SpecialUse::All), |
| 174 | "\\FLAGGED" => Some(SpecialUse::Flagged), |
| 175 | _ => None, |
| 176 | }; |
| 177 | if role.is_some() { |
| 178 | return role; |
| 179 | } |
| 180 | } |
| 181 | None |
| 182 | } |
| 183 | } |
| 184 | |
| 185 | /// The special-use roles of RFC 6154, one per mailbox at most. |
| 186 | #[derive(Clone, Copy, Debug, PartialEq, Eq)] |
| 187 | pub enum SpecialUse { |
| 188 | Sent, |
| 189 | Drafts, |
| 190 | Trash, // deleted messages await expunge |
| 191 | Junk, // spam |
| 192 | Archive, // filed out of the inbox |
| 193 | All, // every message however filed, Gmail's "All Mail" |
| 194 | Flagged, // starred or flagged |
| 195 | } |
| 196 | |
| 197 | /// The state of a mailbox after `SELECT` or `EXAMINE`. |
| 198 | /// |
| 199 | /// `uid_validity` is the one field a synchronising caller must persist: |
| 200 | /// if it changes, every UID it has cached is meaningless and the mailbox |
| 201 | /// must be re-read from scratch. |
| 202 | #[derive(Clone, Debug, Default)] |
| 203 | pub struct MailboxStatus { |
| 204 | pub name: String, |
| 205 | pub exists: u32, // messages present |
| 206 | pub recent: u32, // messages flagged `\Recent` |
| 207 | pub uid_validity: u32, // UID namespace generation |
| 208 | pub uid_next: u32, // the UID the next arrival will be given |
| 209 | pub flags: Vec<String>, // defined in this mailbox |
| 210 | pub read_only: bool, // `EXAMINE`, or a server that downgraded the `SELECT` |
| 211 | } |
| 212 | |
| 213 | /// How much of each message to pull down. |
| 214 | #[derive(Clone, Copy, Debug, Eq, PartialEq)] |
| 215 | pub enum FetchWhat { |
| 216 | Meta, // UID, flags, internal date, size; no body at all |
| 217 | Headers, // and the RFC 5322 header block |
| 218 | Full, // and the whole message |
| 219 | } |
| 220 | |
| 221 | impl FetchWhat { |
| 222 | /// `BODY.PEEK` rather than `BODY`, so reading a message does not silently mark it `\Seen` -- |
| 223 | /// a sync should be invisible to whoever is reading the mailbox elsewhere. |
| 224 | fn items(&self) -> &'static str { |
| 225 | match self { |
| 226 | Self::Meta => "(UID FLAGS INTERNALDATE RFC822.SIZE)", |
| 227 | Self::Headers => "(UID FLAGS INTERNALDATE RFC822.SIZE BODY.PEEK[HEADER])", |
| 228 | Self::Full => "(UID FLAGS INTERNALDATE RFC822.SIZE BODY.PEEK[])", |
| 229 | } |
| 230 | } |
| 231 | } |
| 232 | |
| 233 | /// One message returned by `UID FETCH`. |
| 234 | #[derive(Clone, Debug, Default)] |
| 235 | pub struct FetchedMessage { |
| 236 | pub seq: u32, // sequence number in the current mailbox view |
| 237 | pub uid: u32, // stable within the mailbox's current `uid_validity` |
| 238 | pub flags: Vec<String>, // e.g. `\Seen`, `\Answered` |
| 239 | // `INTERNALDATE` exactly as the server gave it, e.g. `01-Jan-2026 09:15:00 +0000`. Left as |
| 240 | // the server's string, because only the caller knows what calendar it wants it in. |
| 241 | pub internal_date: String, |
| 242 | pub size: u32, // `RFC822.SIZE`, which may exceed `body.len()` |
| 243 | pub body: Vec<u8>, // whole message, header block, or empty, per FetchWhat |
| 244 | } |
| 245 | |
| 246 | /// What a `UID STORE` does to the named flags. |
| 247 | #[derive(Clone, Copy, Debug, Eq, PartialEq)] |
| 248 | pub enum FlagOp { |
| 249 | Add, // leaving the others alone |
| 250 | Remove, // leaving the others alone |
| 251 | Set, // replacing the flag set entirely |
| 252 | } |
| 253 | |
| 254 | impl FlagOp { |
| 255 | /// `.SILENT` throughout: the client already knows what it asked for, and the untagged FETCH |
| 256 | /// the server would otherwise send back is a round trip spent on nothing. |
| 257 | fn item(&self) -> &'static str { |
| 258 | match self { |
| 259 | Self::Add => "+FLAGS.SILENT", |
| 260 | Self::Remove => "-FLAGS.SILENT", |
| 261 | Self::Set => "FLAGS.SILENT", |
| 262 | } |
| 263 | } |
| 264 | } |
| 265 | |
| 266 | /// The completion status of a tagged command. |
| 267 | #[derive(Clone, Copy, Debug, Eq, PartialEq)] |
| 268 | enum Status { |
| 269 | Ok, |
| 270 | No, // refused |
| 271 | Bad, // not understood |
| 272 | } |
| 273 | |
| 274 | /// One logical response line: the text, with every literal lifted out |
| 275 | /// into `literals` in the order it appeared. |
| 276 | #[derive(Clone, Debug)] |
| 277 | struct RawLine { |
| 278 | text: String, // each literal appears only as its `{n}` marker |
| 279 | literals: Vec<Vec<u8>>, // the payloads, in order |
| 280 | } |
| 281 | |
| 282 | /// Everything one tagged command produced. Only a successful command |
| 283 | /// yields one: a `NO` or a `BAD` becomes an error carrying the server's |
| 284 | /// own words, so there is no status to carry here. |
| 285 | #[derive(Clone, Debug)] |
| 286 | struct Response { |
| 287 | untagged: Vec<RawLine>, // the `*` lines that arrived before completion |
| 288 | text: String, // the completion line, which may carry a response code |
| 289 | } |
| 290 | |
| 291 | |
| 292 | // ┌───────────────────────────────────────────────────────────────────────────┐ |
| 293 | // │ CLIENT │ |
| 294 | // └───────────────────────────────────────────────────────────────────────────┘ |
| 295 | |
| 296 | /// A connected IMAP client. One connection, one mailbox selected at a |
| 297 | /// time, exactly as the protocol is. |
| 298 | pub struct ImapClient { |
| 299 | // Buffered, because literal reads need byte precision. An `Option` only so that a STARTTLS |
| 300 | // upgrade can move the socket out, wrap it and put it back; it is `None` for no other reason, |
| 301 | // and any use of a `None` stream is a failed upgrade and a dead connection. |
| 302 | stream: Option<BufReader<ClientStream>>, |
| 303 | tag: u32, // monotonic, never reused |
| 304 | caps: Vec<String>, // as last advertised, upper-cased |
| 305 | timeout: Duration, // per IO, from the config |
| 306 | host: String, // kept for error messages and the TLS upgrade |
| 307 | } |
| 308 | |
| 309 | impl ImapClient { |
| 310 | |
| 311 | /// Protects the connection as configured and reads the greeting. Does not authenticate. |
| 312 | pub async fn connect(cfg: &ImapConfig) -> Outcome<Self> { |
| 313 | let tls_cfg = Arc::new(res!(tls::default_client_config())); |
| 314 | Self::connect_with(cfg, tls_cfg).await |
| 315 | } |
| 316 | |
| 317 | /// Connect using a caller-supplied rustls config, for a private CA or |
| 318 | /// a pinned root. |
| 319 | pub async fn connect_with( |
| 320 | cfg: &ImapConfig, |
| 321 | tls_cfg: Arc<ClientConfig>, |
| 322 | ) |
| 323 | -> Outcome<Self> |
| 324 | { |
| 325 | let addr = match cfg.addr { |
| 326 | Some(a) => a.to_string(), |
| 327 | None => fmt!("{}:{}", cfg.host, cfg.port), |
| 328 | }; |
| 329 | let plain = match timeout(cfg.timeout, TcpStream::connect(&addr)).await { |
| 330 | Ok(Ok(s)) => s, |
| 331 | Ok(Err(e)) => return Err(err!(e, |
| 332 | "Connecting to IMAP server {}.", addr; |
| 333 | IO, Network)), |
| 334 | Err(_) => return Err(err!( |
| 335 | "Timeout connecting to IMAP server {}.", addr; |
| 336 | IO, Network, Timeout)), |
| 337 | }; |
| 338 | |
| 339 | let stream = match cfg.security { |
| 340 | Security::ImplicitTls => |
| 341 | res!(tls::upgrade(plain, &cfg.host, tls_cfg.clone(), cfg.timeout).await), |
| 342 | Security::StartTls | Security::Plain => |
| 343 | ClientStream::Plain(plain), |
| 344 | }; |
| 345 | |
| 346 | let mut client = Self { |
| 347 | stream: Some(BufReader::new(stream)), |
| 348 | tag: 0, |
| 349 | caps: Vec::new(), |
| 350 | timeout: cfg.timeout, |
| 351 | host: cfg.host.clone(), |
| 352 | }; |
| 353 | |
| 354 | // The greeting: an untagged OK, PREAUTH or BYE. It may carry a |
| 355 | // CAPABILITY list, saving a round trip. |
| 356 | let greeting = res!(client.read_line().await); |
| 357 | let up = greeting.text.to_uppercase(); |
| 358 | if up.starts_with("* BYE") { |
| 359 | return Err(err!( |
| 360 | "IMAP server {} refused the connection: {}", cfg.host, greeting.text; |
| 361 | IO, Network, Wire)); |
| 362 | } |
| 363 | if !up.starts_with("* OK") && !up.starts_with("* PREAUTH") { |
| 364 | return Err(err!( |
| 365 | "Expected an IMAP greeting from {}, got: {}", cfg.host, greeting.text; |
| 366 | IO, Network, Wire)); |
| 367 | } |
| 368 | client.absorb_capabilities(&greeting.text); |
| 369 | |
| 370 | if cfg.security == Security::StartTls { |
| 371 | res!(client.starttls(tls_cfg).await); |
| 372 | } |
| 373 | if client.caps.is_empty() { |
| 374 | res!(client.capability().await); |
| 375 | } |
| 376 | Ok(client) |
| 377 | } |
| 378 | |
| 379 | /// Upgrade a plain connection in place. The credentials have not been |
| 380 | /// sent yet, and if the upgrade fails they never will be. |
| 381 | async fn starttls(&mut self, tls_cfg: Arc<ClientConfig>) -> Outcome<()> { |
| 382 | if self.caps.is_empty() { |
| 383 | res!(self.capability().await); |
| 384 | } |
| 385 | if !self.has_cap("STARTTLS") { |
| 386 | return Err(err!( |
| 387 | "IMAP server {} does not offer STARTTLS, and the connection \ |
| 388 | is not otherwise protected.", self.host; |
| 389 | Security, Unimplemented)); |
| 390 | } |
| 391 | res!(self.command("STARTTLS").await); |
| 392 | |
| 393 | // Anything the server pipelined behind its STARTTLS response is |
| 394 | // sitting in the read buffer, unprotected, and would be indis- |
| 395 | // tinguishable from what the TLS peer says next. That is a |
| 396 | // downgrade attack (RFC 2595 §3.1), so refuse rather than discard. |
| 397 | let buffered = res!(self.take_stream()); |
| 398 | if !buffered.buffer().is_empty() { |
| 399 | return Err(err!( |
| 400 | "IMAP server {} sent data after its STARTTLS response, before \ |
| 401 | the handshake. Refusing to continue.", self.host; |
| 402 | IO, Network, Security)); |
| 403 | } |
| 404 | let plain = match buffered.into_inner().into_plain() { |
| 405 | Some(s) => s, |
| 406 | None => return Err(err!( |
| 407 | "STARTTLS issued on an already-protected connection."; |
| 408 | Invalid, Bug)), |
| 409 | }; |
| 410 | let upgraded = res!(tls::upgrade(plain, &self.host, tls_cfg, self.timeout).await); |
| 411 | self.stream = Some(BufReader::new(upgraded)); |
| 412 | |
| 413 | // Capabilities before and after TLS are allowed to differ, and the |
| 414 | // pre-TLS set must not be trusted: re-ask. |
| 415 | self.caps.clear(); |
| 416 | res!(self.capability().await); |
| 417 | Ok(()) |
| 418 | } |
| 419 | |
| 420 | /// The live stream, or an error if a failed upgrade has left none. |
| 421 | fn stream_mut(&mut self) -> Outcome<&mut BufReader<ClientStream>> { |
| 422 | match self.stream.as_mut() { |
| 423 | Some(s) => Ok(s), |
| 424 | None => Err(err!( |
| 425 | "The IMAP connection to {} was dropped by a failed TLS \ |
| 426 | upgrade.", self.host; |
| 427 | IO, Network, Missing)), |
| 428 | } |
| 429 | } |
| 430 | |
| 431 | /// Move the stream out, for the one operation that needs to own it. |
| 432 | fn take_stream(&mut self) -> Outcome<BufReader<ClientStream>> { |
| 433 | match self.stream.take() { |
| 434 | Some(s) => Ok(s), |
| 435 | None => Err(err!( |
| 436 | "The IMAP connection to {} was dropped by a failed TLS \ |
| 437 | upgrade.", self.host; |
| 438 | IO, Network, Missing)), |
| 439 | } |
| 440 | } |
| 441 | |
| 442 | /// A refresh replaces rather than accumulates: the set a server offers |
| 443 | /// legitimately changes across a STARTTLS or a login, and a stale |
| 444 | /// entry left in the list is a capability the client believes in and |
| 445 | /// the server has withdrawn. |
| 446 | pub async fn capability(&mut self) -> Outcome<&[String]> { |
| 447 | let resp = res!(self.command("CAPABILITY").await); |
| 448 | let mut caps: Vec<String> = Vec::new(); |
| 449 | for line in &resp.untagged { |
| 450 | caps.extend(parse_capabilities(&line.text)); |
| 451 | } |
| 452 | caps.sort(); |
| 453 | caps.dedup(); |
| 454 | self.caps = caps; |
| 455 | Ok(&self.caps) |
| 456 | } |
| 457 | |
| 458 | /// Does the server advertise this capability? Compared case-insensitively. |
| 459 | pub fn has_cap(&self, cap: &str) -> bool { |
| 460 | let want = cap.to_uppercase(); |
| 461 | self.caps.iter().any(|c| *c == want) |
| 462 | } |
| 463 | |
| 464 | /// For a consumer mailbox the password is an app password, not the account password. |
| 465 | pub async fn login(&mut self, user: &str, pass: &str) -> Outcome<()> { |
| 466 | if self.has_cap("LOGINDISABLED") { |
| 467 | return Err(err!( |
| 468 | "IMAP server {} has disabled password login on this \ |
| 469 | connection.", self.host; |
| 470 | Unauthorised, Security)); |
| 471 | } |
| 472 | let cmd = fmt!("LOGIN {} {}", quoted(user), quoted(pass)); |
| 473 | // The password is in the command, so keep it out of any error. |
| 474 | let resp = res!(self.command_hushed(&cmd, "LOGIN").await); |
| 475 | self.absorb_capabilities_from(&resp); |
| 476 | if self.caps.is_empty() { |
| 477 | res!(self.capability().await); |
| 478 | } |
| 479 | Ok(()) |
| 480 | } |
| 481 | |
| 482 | /// SASL `XOAUTH2`, the mechanism the large providers require of a registered application. |
| 483 | pub async fn authenticate_xoauth2(&mut self, user: &str, token: &str) -> Outcome<()> { |
| 484 | if !self.has_cap("AUTH=XOAUTH2") { |
| 485 | return Err(err!( |
| 486 | "IMAP server {} does not offer XOAUTH2.", self.host; |
| 487 | Unimplemented, Mismatch)); |
| 488 | } |
| 489 | let raw = fmt!("user={}\u{1}auth=Bearer {}\u{1}\u{1}", user, token); |
| 490 | let cmd = fmt!("AUTHENTICATE XOAUTH2 {}", base64::encode(raw.as_bytes())); |
| 491 | let resp = res!(self.command_hushed(&cmd, "AUTHENTICATE XOAUTH2").await); |
| 492 | self.absorb_capabilities_from(&resp); |
| 493 | if self.caps.is_empty() { |
| 494 | res!(self.capability().await); |
| 495 | } |
| 496 | Ok(()) |
| 497 | } |
| 498 | |
| 499 | /// List mailboxes under `reference` matching `pattern` (`"*"` for all, |
| 500 | /// `"%"` for one level). |
| 501 | pub async fn list(&mut self, reference: &str, pattern: &str) -> Outcome<Vec<MailboxInfo>> { |
| 502 | // Ask for the RFC 6154 roles when the server offers them, so a |
| 503 | // localised "[Gmail]/Gesendet" still carries `\Sent`. A server |
| 504 | // without the capability gets the plain command it expects. |
| 505 | let cmd = if self.has_cap("SPECIAL-USE") { |
| 506 | fmt!("LIST {} {} RETURN (SPECIAL-USE)", quoted(reference), quoted(pattern)) |
| 507 | } else { |
| 508 | fmt!("LIST {} {}", quoted(reference), quoted(pattern)) |
| 509 | }; |
| 510 | let resp = res!(self.command(&cmd).await); |
| 511 | let mut out = Vec::new(); |
| 512 | for line in &resp.untagged { |
| 513 | if let Some(mb) = res!(parse_list_line(line)) { |
| 514 | out.push(mb); |
| 515 | } |
| 516 | } |
| 517 | Ok(out) |
| 518 | } |
| 519 | |
| 520 | pub async fn select(&mut self, mailbox: &str) -> Outcome<MailboxStatus> { |
| 521 | self.select_impl(mailbox, false).await |
| 522 | } |
| 523 | |
| 524 | /// Read-only, so nothing the client does can change a flag. The safe choice for a sync that |
| 525 | /// must not disturb the mailbox. |
| 526 | pub async fn examine(&mut self, mailbox: &str) -> Outcome<MailboxStatus> { |
| 527 | self.select_impl(mailbox, true).await |
| 528 | } |
| 529 | |
| 530 | async fn select_impl(&mut self, mailbox: &str, read_only: bool) -> Outcome<MailboxStatus> { |
| 531 | let verb = if read_only { "EXAMINE" } else { "SELECT" }; |
| 532 | let cmd = fmt!("{} {}", verb, quoted(mailbox)); |
| 533 | let resp = res!(self.command(&cmd).await); |
| 534 | |
| 535 | let mut st = MailboxStatus { |
| 536 | name: mailbox.to_string(), |
| 537 | read_only, |
| 538 | ..Default::default() |
| 539 | }; |
| 540 | for line in &resp.untagged { |
| 541 | res!(absorb_select_line(&mut st, &line.text)); |
| 542 | } |
| 543 | // A server may downgrade a SELECT to read-only, and says so in the |
| 544 | // completion line's response code. |
| 545 | if resp.text.to_uppercase().contains("[READ-ONLY]") { |
| 546 | st.read_only = true; |
| 547 | } |
| 548 | Ok(st) |
| 549 | } |
| 550 | |
| 551 | /// `criteria` is the raw IMAP search key, e.g. `ALL`, `UID 1234:*`, `UNSEEN`, or |
| 552 | /// `SINCE 01-Jan-2026`. |
| 553 | pub async fn uid_search(&mut self, criteria: &str) -> Outcome<Vec<u32>> { |
| 554 | let cmd = fmt!("UID SEARCH {}", criteria); |
| 555 | let resp = res!(self.command(&cmd).await); |
| 556 | let mut uids: Vec<u32> = Vec::new(); |
| 557 | for line in &resp.untagged { |
| 558 | let up = line.text.to_uppercase(); |
| 559 | if !up.starts_with("* SEARCH") { continue; } |
| 560 | for tok in line.text.split_whitespace().skip(2) { |
| 561 | if let Ok(n) = tok.parse::<u32>() { |
| 562 | uids.push(n); |
| 563 | } |
| 564 | } |
| 565 | } |
| 566 | uids.sort_unstable(); |
| 567 | uids.dedup(); |
| 568 | Ok(uids) |
| 569 | } |
| 570 | |
| 571 | /// An empty `uids` is a no-op rather than a command, because `UID FETCH ` with no set is a |
| 572 | /// syntax error and a caller passing an empty search result is not doing anything wrong. |
| 573 | pub async fn uid_fetch( |
| 574 | &mut self, |
| 575 | uids: &[u32], |
| 576 | what: FetchWhat, |
| 577 | ) |
| 578 | -> Outcome<Vec<FetchedMessage>> |
| 579 | { |
| 580 | if uids.is_empty() { |
| 581 | return Ok(Vec::new()); |
| 582 | } |
| 583 | let cmd = fmt!("UID FETCH {} {}", uid_set(uids), what.items()); |
| 584 | let resp = res!(self.command(&cmd).await); |
| 585 | let mut out: Vec<FetchedMessage> = Vec::new(); |
| 586 | for line in &resp.untagged { |
| 587 | if let Some(msg) = res!(parse_fetch_line(line)) { |
| 588 | out.push(msg); |
| 589 | } |
| 590 | } |
| 591 | Ok(out) |
| 592 | } |
| 593 | |
| 594 | /// `None` where the server does not return it: expunged between the search and the fetch, |
| 595 | /// which is a race the caller cannot prevent and should not treat as an error. |
| 596 | pub async fn uid_fetch_one(&mut self, uid: u32) -> Outcome<Option<FetchedMessage>> { |
| 597 | let mut msgs = res!(self.uid_fetch(&[uid], FetchWhat::Full).await); |
| 598 | Ok(if msgs.is_empty() { None } else { Some(msgs.remove(0)) }) |
| 599 | } |
| 600 | |
| 601 | pub async fn uid_store_flags( |
| 602 | &mut self, |
| 603 | uids: &[u32], |
| 604 | op: FlagOp, |
| 605 | flags: &[&str], |
| 606 | ) |
| 607 | -> Outcome<()> |
| 608 | { |
| 609 | if uids.is_empty() { |
| 610 | return Ok(()); |
| 611 | } |
| 612 | let cmd = fmt!("UID STORE {} {} ({})", |
| 613 | uid_set(uids), op.item(), flags.join(" ")); |
| 614 | res!(self.command(&cmd).await); |
| 615 | Ok(()) |
| 616 | } |
| 617 | |
| 618 | /// `body` is a raw RFC 5322 message: this is how a sent message is filed in `Sent` after |
| 619 | /// SMTP has delivered it. |
| 620 | pub async fn append( |
| 621 | &mut self, |
| 622 | mailbox: &str, |
| 623 | flags: &[&str], |
| 624 | body: &[u8], |
| 625 | ) |
| 626 | -> Outcome<()> |
| 627 | { |
| 628 | let tag = self.next_tag(); |
| 629 | let flag_part = if flags.is_empty() { |
| 630 | String::new() |
| 631 | } else { |
| 632 | fmt!(" ({})", flags.join(" ")) |
| 633 | }; |
| 634 | let cmd = fmt!("{} APPEND {}{} {{{}}}\r\n", |
| 635 | tag, quoted(mailbox), flag_part, body.len()); |
| 636 | res!(self.write_all(cmd.as_bytes()).await); |
| 637 | |
| 638 | // The server must answer a synchronising literal with a `+` |
| 639 | // continuation before the bytes may be sent. |
| 640 | let cont = res!(self.read_line().await); |
| 641 | if !cont.text.starts_with('+') { |
| 642 | return Err(err!( |
| 643 | "APPEND to '{}' was refused: {}", mailbox, cont.text; |
| 644 | IO, Network, Wire)); |
| 645 | } |
| 646 | res!(self.write_all(body).await); |
| 647 | res!(self.write_all(b"\r\n").await); |
| 648 | |
| 649 | let resp = res!(self.read_until_tag(&tag, "APPEND").await); |
| 650 | let _ = resp; |
| 651 | Ok(()) |
| 652 | } |
| 653 | |
| 654 | pub async fn logout(&mut self) -> Outcome<()> { |
| 655 | res!(self.command("LOGOUT").await); |
| 656 | if let Some(s) = self.stream.as_mut() { |
| 657 | let _ = s.get_mut().shutdown().await; |
| 658 | } |
| 659 | Ok(()) |
| 660 | } |
| 661 | |
| 662 | // ── Command plumbing ───────────────────────────────────────── |
| 663 | |
| 664 | fn next_tag(&mut self) -> String { |
| 665 | self.tag += 1; |
| 666 | fmt!("a{:04}", self.tag) |
| 667 | } |
| 668 | |
| 669 | /// A `NO` or a `BAD` is an error carrying the server's own words, which are usually the most |
| 670 | /// useful thing anyone will say about the failure. |
| 671 | async fn command(&mut self, cmd: &str) -> Outcome<Response> { |
| 672 | let verb = cmd.split_whitespace().next().unwrap_or(cmd).to_string(); |
| 673 | self.command_hushed(cmd, &verb).await |
| 674 | } |
| 675 | |
| 676 | /// As [`Self::command`], but naming the command in errors rather than |
| 677 | /// echoing it -- for commands whose text contains a credential. |
| 678 | async fn command_hushed(&mut self, cmd: &str, label: &str) -> Outcome<Response> { |
| 679 | let tag = self.next_tag(); |
| 680 | let line = fmt!("{} {}\r\n", tag, cmd); |
| 681 | res!(self.write_all(line.as_bytes()).await); |
| 682 | self.read_until_tag(&tag, label).await |
| 683 | } |
| 684 | |
| 685 | /// Read untagged lines until the one carrying `tag`, then judge it. |
| 686 | async fn read_until_tag(&mut self, tag: &str, label: &str) -> Outcome<Response> { |
| 687 | let mut untagged: Vec<RawLine> = Vec::new(); |
| 688 | loop { |
| 689 | let line = res!(self.read_line().await); |
| 690 | if line.text.starts_with("* ") || line.text == "*" { |
| 691 | untagged.push(line); |
| 692 | continue; |
| 693 | } |
| 694 | if line.text.starts_with('+') { |
| 695 | return Err(err!( |
| 696 | "IMAP server asked for a literal in reply to {}, which \ |
| 697 | sends none.", label; |
| 698 | IO, Network, Wire)); |
| 699 | } |
| 700 | let rest = match line.text.strip_prefix(tag) { |
| 701 | Some(r) => r.trim_start(), |
| 702 | None => { |
| 703 | // A tag we did not send: the connection is out of step |
| 704 | // and nothing read after this can be trusted. |
| 705 | return Err(err!( |
| 706 | "IMAP response carried tag other than '{}': {}", |
| 707 | tag, line.text; |
| 708 | IO, Network, Wire)); |
| 709 | } |
| 710 | }; |
| 711 | let (status, text) = res!(parse_completion(rest)); |
| 712 | return match status { |
| 713 | Status::Ok => Ok(Response { untagged, text }), |
| 714 | Status::No => Err(err!( |
| 715 | "IMAP server refused {}: {}", label, text; |
| 716 | IO, Network, Invalid)), |
| 717 | Status::Bad => Err(err!( |
| 718 | "IMAP server rejected {} as malformed: {}", label, text; |
| 719 | IO, Network, Wire)), |
| 720 | }; |
| 721 | } |
| 722 | } |
| 723 | |
| 724 | /// Merge any `[CAPABILITY ...]` response code carried on a command's |
| 725 | /// completion line into the cached set. |
| 726 | fn absorb_capabilities_from(&mut self, resp: &Response) { |
| 727 | let text = resp.text.clone(); |
| 728 | self.absorb_capabilities(&text); |
| 729 | } |
| 730 | |
| 731 | /// Pull a `CAPABILITY` list out of a line, whether it arrived as an |
| 732 | /// untagged `* CAPABILITY ...` or as a `[CAPABILITY ...]` response |
| 733 | /// code inside a greeting or completion. |
| 734 | fn absorb_capabilities(&mut self, text: &str) { |
| 735 | self.caps.extend(parse_capabilities(text)); |
| 736 | self.caps.sort(); |
| 737 | self.caps.dedup(); |
| 738 | } |
| 739 | |
| 740 | // ── Wire ───────────────────────────────────────────────────── |
| 741 | |
| 742 | async fn write_all(&mut self, bytes: &[u8]) -> Outcome<()> { |
| 743 | let host = self.host.clone(); |
| 744 | let deadline = self.timeout; |
| 745 | let w = res!(self.stream_mut()).get_mut(); |
| 746 | match timeout(deadline, w.write_all(bytes)).await { |
| 747 | Ok(Ok(())) => (), |
| 748 | Ok(Err(e)) => return Err(err!(e, |
| 749 | "Writing to IMAP server {}.", host; |
| 750 | IO, Network, Write)), |
| 751 | Err(_) => return Err(err!( |
| 752 | "Timeout writing to IMAP server {}.", host; |
| 753 | IO, Network, Timeout)), |
| 754 | } |
| 755 | match timeout(deadline, w.flush()).await { |
| 756 | Ok(Ok(())) => Ok(()), |
| 757 | Ok(Err(e)) => Err(err!(e, |
| 758 | "Flushing to IMAP server {}.", host; |
| 759 | IO, Network, Write)), |
| 760 | Err(_) => Err(err!( |
| 761 | "Timeout flushing to IMAP server {}.", host; |
| 762 | IO, Network, Timeout)), |
| 763 | } |
| 764 | } |
| 765 | |
| 766 | /// Read one *logical* response line: CRLF-terminated text, except that |
| 767 | /// a trailing `{n}` is a literal whose `n` raw bytes follow, after |
| 768 | /// which the line continues. The literals are lifted out; what returns |
| 769 | /// is the text with its `{n}` markers still in place, and the payloads |
| 770 | /// alongside in order. |
| 771 | async fn read_line(&mut self) -> Outcome<RawLine> { |
| 772 | let mut text = String::new(); |
| 773 | let mut literals = Vec::new(); |
| 774 | loop { |
| 775 | let chunk = res!(self.read_crlf_line().await); |
| 776 | text.push_str(&chunk); |
| 777 | let n = match trailing_literal_len(&chunk) { |
| 778 | Some(n) => n, |
| 779 | None => break, |
| 780 | }; |
| 781 | if n > MAX_LITERAL_BYTES { |
| 782 | return Err(err!( |
| 783 | "IMAP server {} announced a {}-byte literal, over the \ |
| 784 | {}-byte limit.", self.host, n, MAX_LITERAL_BYTES; |
| 785 | IO, Network, Excessive)); |
| 786 | } |
| 787 | let mut buf = vec![0u8; n]; |
| 788 | let host = self.host.clone(); |
| 789 | let deadline = self.timeout; |
| 790 | let rd = res!(self.stream_mut()); |
| 791 | match timeout(deadline, rd.read_exact(&mut buf)).await { |
| 792 | Ok(Ok(_)) => (), |
| 793 | Ok(Err(e)) => return Err(err!(e, |
| 794 | "Reading a {}-byte literal from IMAP server {}.", n, host; |
| 795 | IO, Network, Read)), |
| 796 | Err(_) => return Err(err!( |
| 797 | "Timeout reading a {}-byte literal from IMAP server {}.", |
| 798 | n, host; |
| 799 | IO, Network, Timeout)), |
| 800 | } |
| 801 | literals.push(buf); |
| 802 | } |
| 803 | Ok(RawLine { text, literals }) |
| 804 | } |
| 805 | |
| 806 | /// Without its terminator. |
| 807 | async fn read_crlf_line(&mut self) -> Outcome<String> { |
| 808 | let mut buf: Vec<u8> = Vec::with_capacity(256); |
| 809 | let host = self.host.clone(); |
| 810 | let deadline = self.timeout; |
| 811 | let rd = res!(self.stream_mut()); |
| 812 | let n = match timeout(deadline, rd.read_until(b'\n', &mut buf)).await { |
| 813 | Ok(Ok(n)) => n, |
| 814 | Ok(Err(e)) => return Err(err!(e, |
| 815 | "Reading from IMAP server {}.", host; |
| 816 | IO, Network, Read)), |
| 817 | Err(_) => return Err(err!( |
| 818 | "Timeout reading from IMAP server {}.", host; |
| 819 | IO, Network, Timeout)), |
| 820 | }; |
| 821 | if n == 0 { |
| 822 | return Err(err!( |
| 823 | "IMAP server {} closed the connection.", host; |
| 824 | IO, Network, Read)); |
| 825 | } |
| 826 | while buf.last() == Some(&b'\n') || buf.last() == Some(&b'\r') { |
| 827 | buf.pop(); |
| 828 | } |
| 829 | // Response text is 7-bit ASCII plus, in practice, whatever a |
| 830 | // server puts in a mailbox name. Lossy is right: a malformed byte |
| 831 | // in a mailbox name must not fail the sync. |
| 832 | Ok(String::from_utf8_lossy(&buf).into_owned()) |
| 833 | } |
| 834 | } |
| 835 | |
| 836 | |
| 837 | // ┌───────────────────────────────────────────────────────────────────────────┐ |
| 838 | // │ PARSING │ |
| 839 | // └───────────────────────────────────────────────────────────────────────────┘ |
| 840 | |
| 841 | /// One element of an IMAP response. |
| 842 | #[derive(Clone, Debug, Eq, PartialEq)] |
| 843 | enum Tok { |
| 844 | Atom(String), // a bare word: a number, a flag, `NIL`, a `BODY[...]` item name |
| 845 | Quoted(String), // unescaped |
| 846 | Literal(Vec<u8>), // the payload, spliced back in from the reader's side queue |
| 847 | List(Vec<Tok>), // parenthesised |
| 848 | } |
| 849 | |
| 850 | impl Tok { |
| 851 | /// An atom or a quoted string only. |
| 852 | fn as_str(&self) -> Option<&str> { |
| 853 | match self { |
| 854 | Self::Atom(s) | Self::Quoted(s) => Some(s), |
| 855 | _ => None, |
| 856 | } |
| 857 | } |
| 858 | |
| 859 | /// Whether it arrived as a literal or a string. `NIL` comes back empty, which is what a |
| 860 | /// server means by it here. |
| 861 | fn as_bytes(&self) -> Vec<u8> { |
| 862 | match self { |
| 863 | Self::Literal(b) => b.clone(), |
| 864 | Self::Quoted(s) => s.as_bytes().to_vec(), |
| 865 | Self::Atom(s) => if s.eq_ignore_ascii_case("NIL") { |
| 866 | Vec::new() |
| 867 | } else { |
| 868 | s.as_bytes().to_vec() |
| 869 | }, |
| 870 | Self::List(_) => Vec::new(), |
| 871 | } |
| 872 | } |
| 873 | } |
| 874 | |
| 875 | /// Tokenise a response line, splicing each `{n}` marker back into the |
| 876 | /// literal that followed it on the wire. |
| 877 | fn tokenise(text: &str, literals: &[Vec<u8>]) -> Outcome<Vec<Tok>> { |
| 878 | let chars: Vec<char> = text.chars().collect(); |
| 879 | let mut queue: VecDeque<Vec<u8>> = literals.iter().cloned().collect(); |
| 880 | let mut pos = 0usize; |
| 881 | let toks = res!(tokenise_until(&chars, &mut pos, &mut queue, None)); |
| 882 | Ok(toks) |
| 883 | } |
| 884 | |
| 885 | /// Tokenise until `close` (or the end of the input when `close` is |
| 886 | /// `None`). Recursive, because IMAP lists nest. |
| 887 | fn tokenise_until( |
| 888 | chars: &[char], |
| 889 | pos: &mut usize, |
| 890 | queue: &mut VecDeque<Vec<u8>>, |
| 891 | close: Option<char>, |
| 892 | ) |
| 893 | -> Outcome<Vec<Tok>> |
| 894 | { |
| 895 | let mut out: Vec<Tok> = Vec::new(); |
| 896 | while *pos < chars.len() { |
| 897 | let c = chars[*pos]; |
| 898 | if c.is_whitespace() { |
| 899 | *pos += 1; |
| 900 | continue; |
| 901 | } |
| 902 | if Some(c) == close { |
| 903 | *pos += 1; |
| 904 | return Ok(out); |
| 905 | } |
| 906 | match c { |
| 907 | '(' => { |
| 908 | *pos += 1; |
| 909 | let inner = res!(tokenise_until(chars, pos, queue, Some(')'))); |
| 910 | out.push(Tok::List(inner)); |
| 911 | } |
| 912 | ')' => { |
| 913 | // An unbalanced close: the caller wanted end-of-input. |
| 914 | return Err(err!( |
| 915 | "Unbalanced ')' in IMAP response at character {}.", pos; |
| 916 | Invalid, Input, Decode)); |
| 917 | } |
| 918 | '"' => { |
| 919 | *pos += 1; |
| 920 | let mut s = String::new(); |
| 921 | let mut closed = false; |
| 922 | while *pos < chars.len() { |
| 923 | let d = chars[*pos]; |
| 924 | *pos += 1; |
| 925 | if d == '\\' && *pos < chars.len() { |
| 926 | s.push(chars[*pos]); |
| 927 | *pos += 1; |
| 928 | continue; |
| 929 | } |
| 930 | if d == '"' { closed = true; break; } |
| 931 | s.push(d); |
| 932 | } |
| 933 | if !closed { |
| 934 | return Err(err!( |
| 935 | "Unterminated quoted string in IMAP response."; |
| 936 | Invalid, Input, Decode)); |
| 937 | } |
| 938 | out.push(Tok::Quoted(s)); |
| 939 | } |
| 940 | '{' => { |
| 941 | // A literal marker. Its payload was read off the wire and |
| 942 | // is waiting in the queue, in order. |
| 943 | while *pos < chars.len() && chars[*pos] != '}' { |
| 944 | *pos += 1; |
| 945 | } |
| 946 | if *pos >= chars.len() { |
| 947 | return Err(err!( |
| 948 | "Unterminated literal marker in IMAP response."; |
| 949 | Invalid, Input, Decode)); |
| 950 | } |
| 951 | *pos += 1; // past the '}' |
| 952 | match queue.pop_front() { |
| 953 | Some(bytes) => out.push(Tok::Literal(bytes)), |
| 954 | None => return Err(err!( |
| 955 | "IMAP response has more literal markers than \ |
| 956 | literals were read."; |
| 957 | Invalid, Input, Decode)), |
| 958 | } |
| 959 | } |
| 960 | _ => { |
| 961 | // An atom, which may embed a bracketed section -- |
| 962 | // `BODY[HEADER.FIELDS (FROM TO)]` is one token, spaces and |
| 963 | // parentheses and all. |
| 964 | let mut s = String::new(); |
| 965 | let mut depth = 0usize; |
| 966 | while *pos < chars.len() { |
| 967 | let d = chars[*pos]; |
| 968 | if depth == 0 { |
| 969 | if d.is_whitespace() || d == '(' || d == ')' { break; } |
| 970 | if Some(d) == close { break; } |
| 971 | } |
| 972 | if d == '[' { depth += 1; } |
| 973 | if d == ']' { depth = depth.saturating_sub(1); } |
| 974 | s.push(d); |
| 975 | *pos += 1; |
| 976 | } |
| 977 | out.push(Tok::Atom(s)); |
| 978 | } |
| 979 | } |
| 980 | } |
| 981 | if close.is_some() { |
| 982 | return Err(err!( |
| 983 | "IMAP response ended inside a parenthesised list."; |
| 984 | Invalid, Input, Decode)); |
| 985 | } |
| 986 | Ok(out) |
| 987 | } |
| 988 | |
| 989 | /// If a line ends with a synchronising or non-synchronising literal |
| 990 | /// marker (`{123}` or `{123+}`), the length it announces. |
| 991 | fn trailing_literal_len(line: &str) -> Option<usize> { |
| 992 | let trimmed = line.trim_end(); |
| 993 | if !trimmed.ends_with('}') { return None; } |
| 994 | let open = ok!(trimmed.rfind('{')); |
| 995 | let inner = &trimmed[open + 1..trimmed.len() - 1]; |
| 996 | let digits = inner.strip_suffix('+').unwrap_or(inner); |
| 997 | if digits.is_empty() || !digits.chars().all(|c| c.is_ascii_digit()) { |
| 998 | return None; |
| 999 | } |
| 1000 | digits.parse::<usize>().ok() |
| 1001 | } |
| 1002 | |
| 1003 | /// A completion line is `OK ...`, `NO ...` or `BAD ...`. |
| 1004 | fn parse_completion(rest: &str) -> Outcome<(Status, String)> { |
| 1005 | let mut it = rest.splitn(2, char::is_whitespace); |
| 1006 | let word = it.next().unwrap_or(""); |
| 1007 | let text = it.next().unwrap_or("").trim().to_string(); |
| 1008 | let status = match word.to_uppercase().as_str() { |
| 1009 | "OK" => Status::Ok, |
| 1010 | "NO" => Status::No, |
| 1011 | "BAD" => Status::Bad, |
| 1012 | other => return Err(err!( |
| 1013 | "IMAP completion line has unknown status '{}'.", other; |
| 1014 | Invalid, Input, Decode)), |
| 1015 | }; |
| 1016 | Ok((status, text)) |
| 1017 | } |
| 1018 | |
| 1019 | /// Pull capability names out of `* CAPABILITY ...` or a `[CAPABILITY ...]` |
| 1020 | /// response code, upper-cased. |
| 1021 | fn parse_capabilities(text: &str) -> Vec<String> { |
| 1022 | let up = text.to_uppercase(); |
| 1023 | let body = if let Some(i) = up.find("[CAPABILITY ") { |
| 1024 | let start = i + "[CAPABILITY ".len(); |
| 1025 | match up[start..].find(']') { |
| 1026 | Some(e) => &up[start..start + e], |
| 1027 | None => return Vec::new(), |
| 1028 | } |
| 1029 | } else if let Some(i) = up.find("* CAPABILITY ") { |
| 1030 | &up[i + "* CAPABILITY ".len()..] |
| 1031 | } else { |
| 1032 | return Vec::new(); |
| 1033 | }; |
| 1034 | body.split_whitespace().map(|s| s.to_string()).collect() |
| 1035 | } |
| 1036 | |
| 1037 | /// Reads `* LIST (\HasNoChildren) "/" "INBOX"`; `None` for an untagged line that is not a |
| 1038 | /// `LIST` reply. |
| 1039 | fn parse_list_line(line: &RawLine) -> Outcome<Option<MailboxInfo>> { |
| 1040 | let up = line.text.to_uppercase(); |
| 1041 | if !up.starts_with("* LIST") && !up.starts_with("* LSUB") { |
| 1042 | return Ok(None); |
| 1043 | } |
| 1044 | let toks = res!(tokenise(&line.text, &line.literals)); |
| 1045 | // `*`, `LIST`, (attrs), delimiter, name |
| 1046 | if toks.len() < 5 { |
| 1047 | return Ok(None); |
| 1048 | } |
| 1049 | let attrs = match &toks[2] { |
| 1050 | Tok::List(items) => items.iter() |
| 1051 | .filter_map(|t| t.as_str().map(|s| s.to_string())) |
| 1052 | .collect(), |
| 1053 | _ => Vec::new(), |
| 1054 | }; |
| 1055 | let delimiter = toks[3].as_str() |
| 1056 | .filter(|s| !s.eq_ignore_ascii_case("NIL")) |
| 1057 | .and_then(|s| s.chars().next()); |
| 1058 | let name = String::from_utf8_lossy(&toks[4].as_bytes()).into_owned(); |
| 1059 | if name.is_empty() { |
| 1060 | return Ok(None); |
| 1061 | } |
| 1062 | Ok(Some(MailboxInfo { name, delimiter, attrs })) |
| 1063 | } |
| 1064 | |
| 1065 | /// Fold one untagged line of a `SELECT`/`EXAMINE` reply into the status. |
| 1066 | /// Unrecognised lines are ignored -- a server is free to volunteer more |
| 1067 | /// than the client asked for. |
| 1068 | fn absorb_select_line(st: &mut MailboxStatus, text: &str) -> Outcome<()> { |
| 1069 | let up = text.to_uppercase(); |
| 1070 | let parts: Vec<&str> = up.split_whitespace().collect(); |
| 1071 | |
| 1072 | // `* 42 EXISTS` / `* 3 RECENT` |
| 1073 | if parts.len() >= 3 && parts[0] == "*" { |
| 1074 | if let Ok(n) = parts[1].parse::<u32>() { |
| 1075 | match parts[2] { |
| 1076 | "EXISTS" => { st.exists = n; return Ok(()); } |
| 1077 | "RECENT" => { st.recent = n; return Ok(()); } |
| 1078 | _ => (), |
| 1079 | } |
| 1080 | } |
| 1081 | } |
| 1082 | // `* OK [UIDVALIDITY 1234]` / `* OK [UIDNEXT 5678]` |
| 1083 | if let Some(v) = bracket_value(&up, "UIDVALIDITY") { |
| 1084 | st.uid_validity = v; |
| 1085 | } |
| 1086 | if let Some(v) = bracket_value(&up, "UIDNEXT") { |
| 1087 | st.uid_next = v; |
| 1088 | } |
| 1089 | // `* FLAGS (\Answered \Flagged ...)` |
| 1090 | if up.starts_with("* FLAGS") { |
| 1091 | if let (Some(a), Some(b)) = (text.find('('), text.rfind(')')) { |
| 1092 | if b > a { |
| 1093 | st.flags = text[a + 1..b] |
| 1094 | .split_whitespace() |
| 1095 | .map(|s| s.to_string()) |
| 1096 | .collect(); |
| 1097 | } |
| 1098 | } |
| 1099 | } |
| 1100 | if up.contains("[READ-ONLY]") { |
| 1101 | st.read_only = true; |
| 1102 | } |
| 1103 | Ok(()) |
| 1104 | } |
| 1105 | |
| 1106 | /// The number inside a `[NAME 123]` response code, if present. |
| 1107 | fn bracket_value(up: &str, name: &str) -> Option<u32> { |
| 1108 | let pat = fmt!("[{} ", name); |
| 1109 | let i = ok!(up.find(&pat)); |
| 1110 | let start = i + pat.len(); |
| 1111 | let end = ok!(up[start..].find(']')); |
| 1112 | up[start..start + end].trim().parse::<u32>().ok() |
| 1113 | } |
| 1114 | |
| 1115 | /// Reads `* 12 FETCH (UID 345 FLAGS (\Seen) ... BODY[] {4523}...)`; `None` for an untagged line |
| 1116 | /// that is not a `FETCH` reply. |
| 1117 | fn parse_fetch_line(line: &RawLine) -> Outcome<Option<FetchedMessage>> { |
| 1118 | let toks = res!(tokenise(&line.text, &line.literals)); |
| 1119 | if toks.len() < 3 { |
| 1120 | return Ok(None); |
| 1121 | } |
| 1122 | if toks[0].as_str() != Some("*") { |
| 1123 | return Ok(None); |
| 1124 | } |
| 1125 | match toks[1].as_str().map(|s| s.parse::<u32>()) { |
| 1126 | Some(Ok(_)) => (), |
| 1127 | _ => return Ok(None), |
| 1128 | } |
| 1129 | if !toks[2].as_str().map(|s| s.eq_ignore_ascii_case("FETCH")).unwrap_or(false) { |
| 1130 | return Ok(None); |
| 1131 | } |
| 1132 | let seq = match toks[1].as_str().and_then(|s| s.parse::<u32>().ok()) { |
| 1133 | Some(n) => n, |
| 1134 | None => return Ok(None), |
| 1135 | }; |
| 1136 | let items = match toks.get(3) { |
| 1137 | Some(Tok::List(items)) => items, |
| 1138 | _ => return Err(err!( |
| 1139 | "IMAP FETCH reply has no data list: {}", line.text; |
| 1140 | Invalid, Input, Decode)), |
| 1141 | }; |
| 1142 | |
| 1143 | let mut msg = FetchedMessage { seq, ..Default::default() }; |
| 1144 | let mut i = 0usize; |
| 1145 | while i < items.len() { |
| 1146 | let key = match items[i].as_str() { |
| 1147 | Some(s) => s.to_uppercase(), |
| 1148 | None => { i += 1; continue; } |
| 1149 | }; |
| 1150 | let val = match items.get(i + 1) { |
| 1151 | Some(v) => v, |
| 1152 | None => break, |
| 1153 | }; |
| 1154 | i += 2; |
| 1155 | match key.as_str() { |
| 1156 | "UID" => { |
| 1157 | if let Some(n) = val.as_str().and_then(|s| s.parse::<u32>().ok()) { |
| 1158 | msg.uid = n; |
| 1159 | } |
| 1160 | } |
| 1161 | "RFC822.SIZE" => { |
| 1162 | if let Some(n) = val.as_str().and_then(|s| s.parse::<u32>().ok()) { |
| 1163 | msg.size = n; |
| 1164 | } |
| 1165 | } |
| 1166 | "FLAGS" => { |
| 1167 | if let Tok::List(fs) = val { |
| 1168 | msg.flags = fs.iter() |
| 1169 | .filter_map(|t| t.as_str().map(|s| s.to_string())) |
| 1170 | .collect(); |
| 1171 | } |
| 1172 | } |
| 1173 | "INTERNALDATE" => { |
| 1174 | if let Some(s) = val.as_str() { |
| 1175 | msg.internal_date = s.to_string(); |
| 1176 | } |
| 1177 | } |
| 1178 | _ => { |
| 1179 | // Every body-ish item -- `BODY[]`, `BODY[HEADER]`, |
| 1180 | // `RFC822`, `RFC822.HEADER` -- carries the bytes we want, |
| 1181 | // and the first one that does wins. Anything else is an |
| 1182 | // item the caller did not ask for, and is skipped. |
| 1183 | if key.starts_with("BODY[") || key == "RFC822" || key == "RFC822.HEADER" { |
| 1184 | if msg.body.is_empty() { |
| 1185 | msg.body = val.as_bytes(); |
| 1186 | } |
| 1187 | } |
| 1188 | } |
| 1189 | } |
| 1190 | } |
| 1191 | if msg.uid == 0 { |
| 1192 | // Without a UID the message cannot be addressed again, so it is |
| 1193 | // useless to a synchronising caller. A server that omits it after |
| 1194 | // being asked for it is broken. |
| 1195 | return Err(err!( |
| 1196 | "IMAP FETCH reply for sequence {} carried no UID.", seq; |
| 1197 | Invalid, Input, Missing)); |
| 1198 | } |
| 1199 | Ok(Some(msg)) |
| 1200 | } |
| 1201 | |
| 1202 | /// Render a UID list as a compact IMAP sequence set, collapsing runs into |
| 1203 | /// ranges: `[1,2,3,7,9,10]` becomes `1:3,7,9:10`. A mailbox synced after a |
| 1204 | /// week away yields a set of thousands of consecutive UIDs, and a server |
| 1205 | /// is entitled to reject a command line that long. |
| 1206 | fn uid_set(uids: &[u32]) -> String { |
| 1207 | let mut sorted: Vec<u32> = uids.to_vec(); |
| 1208 | sorted.sort_unstable(); |
| 1209 | sorted.dedup(); |
| 1210 | |
| 1211 | let mut out = String::new(); |
| 1212 | let mut i = 0usize; |
| 1213 | while i < sorted.len() { |
| 1214 | let start = sorted[i]; |
| 1215 | let mut end = start; |
| 1216 | while i + 1 < sorted.len() && sorted[i + 1] == end + 1 { |
| 1217 | i += 1; |
| 1218 | end = sorted[i]; |
| 1219 | } |
| 1220 | if !out.is_empty() { out.push(','); } |
| 1221 | if start == end { |
| 1222 | out.push_str(&fmt!("{}", start)); |
| 1223 | } else { |
| 1224 | out.push_str(&fmt!("{}:{}", start, end)); |
| 1225 | } |
| 1226 | i += 1; |
| 1227 | } |
| 1228 | out |
| 1229 | } |
| 1230 | |
| 1231 | /// An IMAP quoted-string, with the two characters that need escaping escaped. |
| 1232 | fn quoted(s: &str) -> String { |
| 1233 | let mut out = String::with_capacity(s.len() + 2); |
| 1234 | out.push('"'); |
| 1235 | for c in s.chars() { |
| 1236 | if c == '"' || c == '\\' { |
| 1237 | out.push('\\'); |
| 1238 | } |
| 1239 | out.push(c); |
| 1240 | } |
| 1241 | out.push('"'); |
| 1242 | out |
| 1243 | } |
| 1244 | |
| 1245 | // ┌───────────────────────────────────────────────────────────────────────────┐ |
| 1246 | // │ TESTS │ |
| 1247 | // └───────────────────────────────────────────────────────────────────────────┘ |
| 1248 | |
| 1249 | #[cfg(test)] |
| 1250 | mod tests { |
| 1251 | use super::*; |
| 1252 | |
| 1253 | use std::sync::Mutex; |
| 1254 | |
| 1255 | use tokio::net::TcpListener; |
| 1256 | |
| 1257 | fn line(text: &str, lits: Vec<Vec<u8>>) -> RawLine { |
| 1258 | RawLine { text: text.to_string(), literals: lits } |
| 1259 | } |
| 1260 | |
| 1261 | |
| 1262 | // ┌───────────────────────────────────────────────────────────────────────┐ |
| 1263 | // │ A SCRIPTED SERVER │ |
| 1264 | // └───────────────────────────────────────────────────────────────────────┘ |
| 1265 | |
| 1266 | // The round-trip against fe2o3's own ImapServer lives in |
| 1267 | // fe2o3_mail/tests/imap_roundtrip.rs and proves the happy path. What it |
| 1268 | // cannot pose is a server behaving badly -- a BYE greeting, a literal larger |
| 1269 | // than the client will allocate for, a tag the client never sent, bytes |
| 1270 | // pipelined behind a STARTTLS response. Those are the cases where this client |
| 1271 | // either refuses or is quietly compromised, so they are scripted here. |
| 1272 | |
| 1273 | const USER: &str = "alice@example.com"; |
| 1274 | const PASS: &str = "app-password-not-a-real-one"; |
| 1275 | |
| 1276 | /// Every line the server was sent, in order, plus a `<literal N>` marker and |
| 1277 | /// its payload wherever the client sent one. |
| 1278 | type Transcript = Arc<Mutex<Vec<String>>>; |
| 1279 | |
| 1280 | /// Serve `greeting`, then answer each command the client sends with the next |
| 1281 | /// entry of `script`, verbatim. `%T%` in an entry becomes the tag the client |
| 1282 | /// used on the command being answered, so a script need not track them. |
| 1283 | /// |
| 1284 | /// A command ending in a `{n}` literal marker is answered as scripted; where |
| 1285 | /// that answer begins with `+`, the `n` bytes and their CRLF are read off and |
| 1286 | /// logged before the next command. Running off the end of the script closes |
| 1287 | /// the connection, which is a legitimate thing for a server to do and one the |
| 1288 | /// client must not hang on. |
| 1289 | async fn scripted( |
| 1290 | greeting: &'static str, |
| 1291 | script: Vec<&'static str>, |
| 1292 | ) |
| 1293 | -> Outcome<(SocketAddr, Transcript)> |
| 1294 | { |
| 1295 | let listener = res!(TcpListener::bind("127.0.0.1:0").await |
| 1296 | .map_err(|e| err!(e, "Binding the scripted IMAP server."; IO, Network))); |
| 1297 | let addr = res!(listener.local_addr() |
| 1298 | .map_err(|e| err!(e, "Reading its address."; IO, Network))); |
| 1299 | |
| 1300 | let seen: Transcript = Arc::new(Mutex::new(Vec::new())); |
| 1301 | let log = seen.clone(); |
| 1302 | |
| 1303 | tokio::spawn(async move { |
| 1304 | let (sock, _) = match listener.accept().await { |
| 1305 | Ok(x) => x, |
| 1306 | Err(_) => return, |
| 1307 | }; |
| 1308 | let (r, mut w) = sock.into_split(); |
| 1309 | let mut rd = BufReader::new(r); |
| 1310 | |
| 1311 | let _ = w.write_all(greeting.as_bytes()).await; |
| 1312 | |
| 1313 | let mut script = script.into_iter(); |
| 1314 | while let Some(reply) = script.next() { |
| 1315 | let mut buf: Vec<u8> = Vec::with_capacity(256); |
| 1316 | match rd.read_until(b'\n', &mut buf).await { |
| 1317 | Ok(0) | Err(_) => return, |
| 1318 | Ok(_) => (), |
| 1319 | } |
| 1320 | while buf.last() == Some(&b'\n') || buf.last() == Some(&b'\r') { |
| 1321 | buf.pop(); |
| 1322 | } |
| 1323 | let cmd = String::from_utf8_lossy(&buf).into_owned(); |
| 1324 | if let Ok(mut g) = log.lock() { |
| 1325 | g.push(cmd.clone()); |
| 1326 | } |
| 1327 | let tag = cmd.split_whitespace().next().unwrap_or("").to_string(); |
| 1328 | let out = reply.replace("%T%", &tag); |
| 1329 | let _ = w.write_all(out.as_bytes()).await; |
| 1330 | |
| 1331 | // A synchronising literal the client is now entitled to send. It |
| 1332 | // is not a command, so the entry that follows goes out without |
| 1333 | // waiting for another line -- the client is waiting on the |
| 1334 | // completion and will send nothing until it has it. |
| 1335 | if out.starts_with('+') { |
| 1336 | if let Some(n) = trailing_literal_len(&cmd) { |
| 1337 | let mut lit = vec![0u8; n]; |
| 1338 | if rd.read_exact(&mut lit).await.is_err() { |
| 1339 | return; |
| 1340 | } |
| 1341 | let mut tail = Vec::new(); |
| 1342 | let _ = rd.read_until(b'\n', &mut tail).await; |
| 1343 | if let Ok(mut g) = log.lock() { |
| 1344 | g.push(fmt!("<literal {}>", n)); |
| 1345 | g.push(String::from_utf8_lossy(&lit).into_owned()); |
| 1346 | } |
| 1347 | match script.next() { |
| 1348 | Some(done) => { |
| 1349 | let _ = w.write_all( |
| 1350 | done.replace("%T%", &tag).as_bytes()).await; |
| 1351 | }, |
| 1352 | None => return, |
| 1353 | } |
| 1354 | } |
| 1355 | } |
| 1356 | } |
| 1357 | }); |
| 1358 | |
| 1359 | Ok((addr, seen)) |
| 1360 | } |
| 1361 | |
| 1362 | fn cfg(addr: SocketAddr) -> ImapConfig { |
| 1363 | ImapConfig::new(fmt!("127.0.0.1"), addr.port(), Security::Plain) |
| 1364 | .with_addr(addr) |
| 1365 | .with_timeout(Duration::from_secs(10)) |
| 1366 | } |
| 1367 | |
| 1368 | fn lines_of(t: &Transcript) -> Outcome<Vec<String>> { |
| 1369 | match t.lock() { |
| 1370 | Ok(g) => Ok(g.clone()), |
| 1371 | Err(_) => Err(err!("The server's transcript was poisoned."; Lock, Poisoned)), |
| 1372 | } |
| 1373 | } |
| 1374 | |
| 1375 | /// A greeting that already carries the capability list, which is the common |
| 1376 | /// case and saves a round trip. |
| 1377 | const GREET: &str = "* OK [CAPABILITY IMAP4rev1 SPECIAL-USE AUTH=PLAIN] ready\r\n"; |
| 1378 | |
| 1379 | // ── The greeting ────────────────────────────────────────────── |
| 1380 | |
| 1381 | /// A `BYE` greeting is a refusal, and the server's own words are the most |
| 1382 | /// useful thing anybody will say about it. |
| 1383 | #[tokio::test] |
| 1384 | async fn test_a_bye_greeting_is_a_refusal_00() -> Outcome<()> { |
| 1385 | let (addr, _) = res!(scripted( |
| 1386 | "* BYE Too many connections from your IP\r\n", vec![]).await); |
| 1387 | let out = ImapClient::connect(&cfg(addr)).await; |
| 1388 | let msg = match out { |
| 1389 | Err(e) => fmt!("{}", e), |
| 1390 | Ok(_) => return Err(err!( |
| 1391 | "A BYE greeting was treated as a usable connection."; Test, Invalid)), |
| 1392 | }; |
| 1393 | req!(true, msg.contains("Too many connections"), |
| 1394 | "the server's own words were dropped: {}", msg); |
| 1395 | Ok(()) |
| 1396 | } |
| 1397 | |
| 1398 | /// Anything that is not `OK` or `PREAUTH` is not an IMAP server, and talking |
| 1399 | /// on regardless is how a client sends a password to whatever answered. |
| 1400 | #[tokio::test] |
| 1401 | async fn test_a_greeting_that_is_not_a_greeting_is_refused_00() -> Outcome<()> { |
| 1402 | let (addr, seen) = res!(scripted("+OK POP3 server ready\r\n", vec![]).await); |
| 1403 | req!(true, ImapClient::connect(&cfg(addr)).await.is_err(), |
| 1404 | "a POP3 greeting was accepted as IMAP"); |
| 1405 | req!(true, res!(lines_of(&seen)).is_empty(), |
| 1406 | "the client sent a command to something that never greeted it"); |
| 1407 | Ok(()) |
| 1408 | } |
| 1409 | |
| 1410 | /// The greeting's `[CAPABILITY ...]` is taken at its word: a client that asks |
| 1411 | /// again anyway has spent a round trip on nothing, on every connection. |
| 1412 | #[tokio::test] |
| 1413 | async fn test_a_capability_in_the_greeting_saves_the_round_trip_00() -> Outcome<()> { |
| 1414 | let (addr, seen) = res!(scripted(GREET, vec![]).await); |
| 1415 | let c = res!(ImapClient::connect(&cfg(addr)).await); |
| 1416 | req!(true, c.has_cap("IMAP4REV1")); |
| 1417 | req!(true, c.has_cap("SPECIAL-USE")); |
| 1418 | req!(true, res!(lines_of(&seen)).is_empty(), |
| 1419 | "the client asked for CAPABILITY it had already been given"); |
| 1420 | Ok(()) |
| 1421 | } |
| 1422 | |
| 1423 | /// A greeting without one leaves the client not knowing what the server can |
| 1424 | /// do, so it must ask before doing anything that depends on the answer. |
| 1425 | #[tokio::test] |
| 1426 | async fn test_a_bare_greeting_is_followed_by_capability_00() -> Outcome<()> { |
| 1427 | let (addr, seen) = res!(scripted( |
| 1428 | "* OK ready\r\n", |
| 1429 | vec!["* CAPABILITY IMAP4rev1 STARTTLS\r\n%T% OK done\r\n"], |
| 1430 | ).await); |
| 1431 | let c = res!(ImapClient::connect(&cfg(addr)).await); |
| 1432 | req!(true, c.has_cap("STARTTLS")); |
| 1433 | let lines = res!(lines_of(&seen)); |
| 1434 | req!(1, lines.len()); |
| 1435 | req!(true, lines[0].to_uppercase().ends_with("CAPABILITY"), |
| 1436 | "the client did not ask for CAPABILITY: {:?}", lines); |
| 1437 | Ok(()) |
| 1438 | } |
| 1439 | |
| 1440 | /// A refresh replaces the set rather than adding to it. A capability left |
| 1441 | /// behind is one the client believes in and the server has withdrawn -- |
| 1442 | /// after a login, that is a command sent to a server that will reject it. |
| 1443 | #[tokio::test] |
| 1444 | async fn test_a_capability_refresh_replaces_rather_than_accumulates_00() -> Outcome<()> { |
| 1445 | let (addr, _) = res!(scripted( |
| 1446 | "* OK [CAPABILITY IMAP4rev1 STARTTLS LOGINDISABLED] ready\r\n", |
| 1447 | vec!["* CAPABILITY IMAP4rev1 IDLE\r\n%T% OK done\r\n"], |
| 1448 | ).await); |
| 1449 | let mut c = res!(ImapClient::connect(&cfg(addr)).await); |
| 1450 | req!(true, c.has_cap("LOGINDISABLED")); |
| 1451 | res!(c.capability().await); |
| 1452 | req!(false, c.has_cap("LOGINDISABLED"), |
| 1453 | "a withdrawn capability survived the refresh"); |
| 1454 | req!(false, c.has_cap("STARTTLS"), "a withdrawn capability survived the refresh"); |
| 1455 | req!(true, c.has_cap("IDLE")); |
| 1456 | Ok(()) |
| 1457 | } |
| 1458 | |
| 1459 | // ── Authentication ──────────────────────────────────────────── |
| 1460 | |
| 1461 | /// A refusal carries the server's words -- `[AUTHENTICATIONFAILED]` is what |
| 1462 | /// tells a person their app password is the problem -- and never the password. |
| 1463 | #[tokio::test] |
| 1464 | async fn test_a_refused_login_keeps_the_words_and_not_the_password_00() -> Outcome<()> { |
| 1465 | let (addr, seen) = res!(scripted( |
| 1466 | GREET, |
| 1467 | vec!["%T% NO [AUTHENTICATIONFAILED] Invalid credentials (Failure)\r\n"], |
| 1468 | ).await); |
| 1469 | let mut c = res!(ImapClient::connect(&cfg(addr)).await); |
| 1470 | let msg = match c.login(USER, PASS).await { |
| 1471 | Err(e) => fmt!("{}", e), |
| 1472 | Ok(()) => return Err(err!("A refused LOGIN was reported as a success."; |
| 1473 | Test, Invalid)), |
| 1474 | }; |
| 1475 | req!(true, msg.contains("AUTHENTICATIONFAILED"), |
| 1476 | "the server's response code was dropped: {}", msg); |
| 1477 | req!(false, msg.contains(PASS), "the password leaked into the error: {}", msg); |
| 1478 | // It did go over the wire, which is what LOGIN is; the point is that the |
| 1479 | // error does not repeat it into a log file. |
| 1480 | req!(true, res!(lines_of(&seen)).iter().any(|l| l.contains(PASS))); |
| 1481 | Ok(()) |
| 1482 | } |
| 1483 | |
| 1484 | /// `LOGINDISABLED` means the password will be refused, so it is not sent. A |
| 1485 | /// client that tries anyway has put the credential on the wire to learn |
| 1486 | /// something the server already told it. |
| 1487 | #[tokio::test] |
| 1488 | async fn test_logindisabled_withholds_the_password_00() -> Outcome<()> { |
| 1489 | let (addr, seen) = res!(scripted( |
| 1490 | "* OK [CAPABILITY IMAP4rev1 LOGINDISABLED STARTTLS] ready\r\n", |
| 1491 | vec!["%T% OK never reached\r\n"], |
| 1492 | ).await); |
| 1493 | let mut c = res!(ImapClient::connect(&cfg(addr)).await); |
| 1494 | req!(true, c.login(USER, PASS).await.is_err(), |
| 1495 | "the client sent a password to a server that had disabled it"); |
| 1496 | req!(false, res!(lines_of(&seen)).iter().any(|l| l.contains(PASS)), |
| 1497 | "the password crossed the wire anyway"); |
| 1498 | Ok(()) |
| 1499 | } |
| 1500 | |
| 1501 | /// XOAUTH2 is refused outright where the server does not offer it, rather |
| 1502 | /// than sent and rejected -- a bearer token spent on a server that cannot |
| 1503 | /// use it is a token in somebody's log. |
| 1504 | #[tokio::test] |
| 1505 | async fn test_xoauth2_is_not_offered_to_a_server_without_it_00() -> Outcome<()> { |
| 1506 | let (addr, seen) = res!(scripted(GREET, vec!["%T% OK never reached\r\n"]).await); |
| 1507 | let mut c = res!(ImapClient::connect(&cfg(addr)).await); |
| 1508 | req!(true, c.authenticate_xoauth2(USER, "ya29.a-bearer-token").await.is_err()); |
| 1509 | req!(false, res!(lines_of(&seen)).iter().any(|l| l.contains("ya29")), |
| 1510 | "the token crossed the wire to a server that does not speak XOAUTH2"); |
| 1511 | Ok(()) |
| 1512 | } |
| 1513 | |
| 1514 | /// STARTTLS was asked for and the server does not offer it. The credential is |
| 1515 | /// not sent in the clear as a fallback. |
| 1516 | #[tokio::test] |
| 1517 | async fn test_starttls_absent_refuses_the_connection_00() -> Outcome<()> { |
| 1518 | let (addr, seen) = res!(scripted( |
| 1519 | "* OK [CAPABILITY IMAP4rev1 AUTH=PLAIN] ready\r\n", vec![]).await); |
| 1520 | let c = ImapConfig::new(fmt!("127.0.0.1"), addr.port(), Security::StartTls) |
| 1521 | .with_addr(addr) |
| 1522 | .with_timeout(Duration::from_secs(10)); |
| 1523 | let msg = match ImapClient::connect(&c).await { |
| 1524 | Err(e) => fmt!("{}", e), |
| 1525 | Ok(_) => return Err(err!( |
| 1526 | "A connection that could not be secured was returned as usable."; |
| 1527 | Test, Invalid)), |
| 1528 | }; |
| 1529 | req!(true, msg.contains("STARTTLS"), "the error did not name STARTTLS: {}", msg); |
| 1530 | req!(false, res!(lines_of(&seen)).iter().any(|l| l.to_uppercase().contains("LOGIN")), |
| 1531 | "the client began to log in over an unprotected connection"); |
| 1532 | Ok(()) |
| 1533 | } |
| 1534 | |
| 1535 | /// RFC 2595 §3.1: anything the server pipelines behind its `STARTTLS` reply |
| 1536 | /// arrived unprotected and is indistinguishable from what the TLS peer says |
| 1537 | /// next. Discarding it silently is the downgrade attack; the connection must |
| 1538 | /// be refused. |
| 1539 | #[tokio::test] |
| 1540 | async fn test_data_pipelined_behind_starttls_is_refused_00() -> Outcome<()> { |
| 1541 | let (addr, _) = res!(scripted( |
| 1542 | "* OK [CAPABILITY IMAP4rev1 STARTTLS] ready\r\n", |
| 1543 | // The OK, and then an injected untagged line the real peer never sent. |
| 1544 | vec!["%T% OK Begin TLS negotiation now\r\n* OK [CAPABILITY IMAP4rev1 \ |
| 1545 | AUTH=PLAIN LOGINDISABLED] injected\r\n"], |
| 1546 | ).await); |
| 1547 | let c = ImapConfig::new(fmt!("127.0.0.1"), addr.port(), Security::StartTls) |
| 1548 | .with_addr(addr) |
| 1549 | .with_timeout(Duration::from_secs(10)); |
| 1550 | let msg = match ImapClient::connect(&c).await { |
| 1551 | Err(e) => fmt!("{}", e), |
| 1552 | Ok(_) => return Err(err!( |
| 1553 | "Bytes injected before the handshake were accepted as the peer's."; |
| 1554 | Test, Invalid)), |
| 1555 | }; |
| 1556 | req!(true, msg.contains("after its STARTTLS response"), |
| 1557 | "the refusal did not name the reason: {}", msg); |
| 1558 | Ok(()) |
| 1559 | } |
| 1560 | |
| 1561 | // ── The wire, where a line is not a line ────────────────────── |
| 1562 | |
| 1563 | /// The reader's whole job: a literal announced mid-line, whose payload |
| 1564 | /// contains CRLF, a close paren and a `{n}` of its own. A client that reads |
| 1565 | /// FETCH replies line by line loses the message here, and the bug does not |
| 1566 | /// show until somebody sends an attachment. |
| 1567 | #[tokio::test] |
| 1568 | async fn test_a_literal_containing_crlf_survives_the_reader_00() -> Outcome<()> { |
| 1569 | // 62 bytes, counted: a body with two blank lines, a close paren and a |
| 1570 | // fake literal marker in it. |
| 1571 | let body = "Subject: awkward\r\n\r\n) not the end {17}\r\nlast line\r\n"; |
| 1572 | req!(51, body.len(), "the scripted literal length must match the payload"); |
| 1573 | let script = vec![ |
| 1574 | "%T% OK [READ-WRITE] SELECT completed\r\n", |
| 1575 | "* 1 FETCH (UID 9 FLAGS (\\Seen) RFC822.SIZE 51 BODY[] {51}\r\n\ |
| 1576 | Subject: awkward\r\n\r\n) not the end {17}\r\nlast line\r\n)\r\n\ |
| 1577 | %T% OK FETCH completed\r\n", |
| 1578 | ]; |
| 1579 | let (addr, _) = res!(scripted(GREET, script).await); |
| 1580 | let mut c = res!(ImapClient::connect(&cfg(addr)).await); |
| 1581 | res!(c.select("INBOX").await); |
| 1582 | let msgs = res!(c.uid_fetch(&[9], FetchWhat::Full).await); |
| 1583 | req!(1, msgs.len(), "the FETCH reply was lost"); |
| 1584 | req!(body.as_bytes().to_vec(), msgs[0].body, |
| 1585 | "the literal came back changed: {:?}", String::from_utf8_lossy(&msgs[0].body)); |
| 1586 | req!(9, msgs[0].uid); |
| 1587 | req!(vec![fmt!("\\Seen")], msgs[0].flags); |
| 1588 | Ok(()) |
| 1589 | } |
| 1590 | |
| 1591 | /// A server announcing more than the client will allocate for is refused |
| 1592 | /// before the allocation, not after it. |
| 1593 | #[tokio::test] |
| 1594 | async fn test_an_oversized_literal_is_refused_before_allocating_00() -> Outcome<()> { |
| 1595 | let (addr, _) = res!(scripted( |
| 1596 | GREET, |
| 1597 | vec![ |
| 1598 | "%T% OK SELECT completed\r\n", |
| 1599 | // 64 MiB + 1, announced and never sent. |
| 1600 | "* 1 FETCH (UID 9 BODY[] {67108865}\r\n", |
| 1601 | ], |
| 1602 | ).await); |
| 1603 | let mut c = res!(ImapClient::connect(&cfg(addr)).await); |
| 1604 | res!(c.select("INBOX").await); |
| 1605 | let msg = match c.uid_fetch(&[9], FetchWhat::Full).await { |
| 1606 | Err(e) => fmt!("{}", e), |
| 1607 | Ok(_) => return Err(err!( |
| 1608 | "A 64 MiB literal was accepted from a server that never sent it."; |
| 1609 | Test, Invalid)), |
| 1610 | }; |
| 1611 | req!(true, msg.contains("67108865"), "the error did not name the size: {}", msg); |
| 1612 | Ok(()) |
| 1613 | } |
| 1614 | |
| 1615 | /// A tag the client never sent means the connection is out of step, and |
| 1616 | /// nothing read after it belongs to the command that is waiting. Matching it |
| 1617 | /// loosely is how one command's reply is read as another's. |
| 1618 | #[tokio::test] |
| 1619 | async fn test_a_foreign_tag_takes_the_connection_out_of_step_00() -> Outcome<()> { |
| 1620 | let (addr, _) = res!(scripted( |
| 1621 | GREET, |
| 1622 | vec!["a9999 OK SELECT completed\r\n"], |
| 1623 | ).await); |
| 1624 | let mut c = res!(ImapClient::connect(&cfg(addr)).await); |
| 1625 | let msg = match c.select("INBOX").await { |
| 1626 | Err(e) => fmt!("{}", e), |
| 1627 | Ok(_) => return Err(err!( |
| 1628 | "A reply carrying somebody else's tag was accepted."; Test, Invalid)), |
| 1629 | }; |
| 1630 | req!(true, msg.contains("a9999"), "the error did not name the tag seen: {}", msg); |
| 1631 | Ok(()) |
| 1632 | } |
| 1633 | |
| 1634 | /// A server that hangs up mid-command is an error naming the close, not a |
| 1635 | /// hang and not an empty success. |
| 1636 | #[tokio::test] |
| 1637 | async fn test_a_closed_connection_is_named_00() -> Outcome<()> { |
| 1638 | let (addr, _) = res!(scripted(GREET, vec![]).await); |
| 1639 | let mut c = res!(ImapClient::connect(&cfg(addr)).await); |
| 1640 | let msg = match c.select("INBOX").await { |
| 1641 | Err(e) => fmt!("{}", e), |
| 1642 | Ok(_) => return Err(err!( |
| 1643 | "A closed connection was reported as a SELECT."; Test, Invalid)), |
| 1644 | }; |
| 1645 | req!(true, msg.contains("closed the connection"), |
| 1646 | "the error did not say the server hung up: {}", msg); |
| 1647 | Ok(()) |
| 1648 | } |
| 1649 | |
| 1650 | /// `NO` and `BAD` are different failures and are reported as such: one is a |
| 1651 | /// server refusing, the other a client having sent nonsense. |
| 1652 | #[tokio::test] |
| 1653 | async fn test_no_and_bad_are_reported_apart_00() -> Outcome<()> { |
| 1654 | let (addr, _) = res!(scripted(GREET, |
| 1655 | vec!["%T% NO Mailbox does not exist\r\n"]).await); |
| 1656 | let mut c = res!(ImapClient::connect(&cfg(addr)).await); |
| 1657 | let msg = match c.select("nosuch").await { |
| 1658 | Err(e) => fmt!("{}", e), |
| 1659 | Ok(_) => return Err(err!("A NO was a success."; Test, Invalid)), |
| 1660 | }; |
| 1661 | req!(true, msg.contains("refused"), "a NO did not read as a refusal: {}", msg); |
| 1662 | |
| 1663 | let (addr, _) = res!(scripted(GREET, vec!["%T% BAD Command unrecognised\r\n"]).await); |
| 1664 | let mut c = res!(ImapClient::connect(&cfg(addr)).await); |
| 1665 | let msg = match c.select("INBOX").await { |
| 1666 | Err(e) => fmt!("{}", e), |
| 1667 | Ok(_) => return Err(err!("A BAD was a success."; Test, Invalid)), |
| 1668 | }; |
| 1669 | req!(true, msg.contains("malformed"), "a BAD did not read as malformed: {}", msg); |
| 1670 | Ok(()) |
| 1671 | } |
| 1672 | |
| 1673 | // ── What the client puts on the wire ────────────────────────── |
| 1674 | |
| 1675 | /// A week's worth of consecutive UIDs is a command line a server is entitled |
| 1676 | /// to reject, so they go over collapsed into ranges. Asserted from what the |
| 1677 | /// server received, not from the function that builds it. |
| 1678 | #[tokio::test] |
| 1679 | async fn test_a_uid_set_reaches_the_server_collapsed_00() -> Outcome<()> { |
| 1680 | let (addr, seen) = res!(scripted( |
| 1681 | GREET, |
| 1682 | vec![ |
| 1683 | "%T% OK SELECT completed\r\n", |
| 1684 | "%T% OK FETCH completed\r\n", |
| 1685 | ], |
| 1686 | ).await); |
| 1687 | let mut c = res!(ImapClient::connect(&cfg(addr)).await); |
| 1688 | res!(c.select("INBOX").await); |
| 1689 | res!(c.uid_fetch(&[1, 2, 3, 7, 9, 10], FetchWhat::Meta).await); |
| 1690 | let lines = res!(lines_of(&seen)); |
| 1691 | req!(true, lines.iter().any(|l| l.contains("UID FETCH 1:3,7,9:10")), |
| 1692 | "the UID set was not collapsed on the wire: {:?}", lines); |
| 1693 | Ok(()) |
| 1694 | } |
| 1695 | |
| 1696 | /// An empty set is no command at all: `UID FETCH ` with nothing after it is a |
| 1697 | /// syntax error, and a caller passing an empty search result has done nothing |
| 1698 | /// wrong. |
| 1699 | #[tokio::test] |
| 1700 | async fn test_an_empty_fetch_sends_nothing_00() -> Outcome<()> { |
| 1701 | let (addr, seen) = res!(scripted(GREET, vec!["%T% OK SELECT completed\r\n"]).await); |
| 1702 | let mut c = res!(ImapClient::connect(&cfg(addr)).await); |
| 1703 | res!(c.select("INBOX").await); |
| 1704 | let before = res!(lines_of(&seen)).len(); |
| 1705 | req!(true, res!(c.uid_fetch(&[], FetchWhat::Full).await).is_empty()); |
| 1706 | res!(c.uid_store_flags(&[], FlagOp::Add, &["\\Seen"]).await); |
| 1707 | req!(before, res!(lines_of(&seen)).len(), |
| 1708 | "a command was sent for an empty UID set"); |
| 1709 | Ok(()) |
| 1710 | } |
| 1711 | |
| 1712 | /// `RETURN (SPECIAL-USE)` is asked for only where the server said it |
| 1713 | /// understands it. Sent to a server without the capability it is a `LIST` the |
| 1714 | /// server may reject outright, and then no folders are listed at all. |
| 1715 | #[tokio::test] |
| 1716 | async fn test_special_use_is_asked_for_only_when_offered_00() -> Outcome<()> { |
| 1717 | let (addr, seen) = res!(scripted(GREET, |
| 1718 | vec!["* LIST (\\HasNoChildren) \"/\" \"INBOX\"\r\n%T% OK LIST completed\r\n"]).await); |
| 1719 | let mut c = res!(ImapClient::connect(&cfg(addr)).await); |
| 1720 | res!(c.list("", "*").await); |
| 1721 | req!(true, res!(lines_of(&seen)).iter().any(|l| l.contains("RETURN (SPECIAL-USE)")), |
| 1722 | "SPECIAL-USE was offered and not asked for"); |
| 1723 | |
| 1724 | let (addr, seen) = res!(scripted( |
| 1725 | "* OK [CAPABILITY IMAP4rev1] ready\r\n", |
| 1726 | vec!["* LIST (\\HasNoChildren) \"/\" \"INBOX\"\r\n%T% OK LIST completed\r\n"], |
| 1727 | ).await); |
| 1728 | let mut c = res!(ImapClient::connect(&cfg(addr)).await); |
| 1729 | res!(c.list("", "*").await); |
| 1730 | req!(false, res!(lines_of(&seen)).iter().any(|l| l.contains("SPECIAL-USE")), |
| 1731 | "SPECIAL-USE was asked of a server that never offered it"); |
| 1732 | Ok(()) |
| 1733 | } |
| 1734 | |
| 1735 | /// `EXAMINE` rather than `SELECT`, because a sync that marks mail read is a |
| 1736 | /// sync somebody notices. The gateway's fetch path uses this one. |
| 1737 | #[tokio::test] |
| 1738 | async fn test_examine_is_read_only_on_the_wire_00() -> Outcome<()> { |
| 1739 | let (addr, seen) = res!(scripted( |
| 1740 | GREET, |
| 1741 | vec!["* 42 EXISTS\r\n* OK [UIDVALIDITY 1234567890]\r\n\ |
| 1742 | * OK [UIDNEXT 4321]\r\n%T% OK [READ-ONLY] EXAMINE completed\r\n"], |
| 1743 | ).await); |
| 1744 | let mut c = res!(ImapClient::connect(&cfg(addr)).await); |
| 1745 | let st = res!(c.examine("INBOX").await); |
| 1746 | req!(true, res!(lines_of(&seen)).iter().any(|l| l.contains("EXAMINE \"INBOX\"")), |
| 1747 | "EXAMINE was not the verb sent"); |
| 1748 | req!(42, st.exists); |
| 1749 | req!(1_234_567_890, st.uid_validity); |
| 1750 | req!(4_321, st.uid_next); |
| 1751 | req!(true, st.read_only); |
| 1752 | Ok(()) |
| 1753 | } |
| 1754 | |
| 1755 | /// A server may downgrade a `SELECT` and say so only in the completion line's |
| 1756 | /// response code. A client that misses it believes it may set flags. |
| 1757 | #[tokio::test] |
| 1758 | async fn test_a_downgraded_select_is_read_only_00() -> Outcome<()> { |
| 1759 | let (addr, _) = res!(scripted( |
| 1760 | GREET, |
| 1761 | vec!["* 3 EXISTS\r\n%T% OK [READ-ONLY] SELECT completed\r\n"], |
| 1762 | ).await); |
| 1763 | let mut c = res!(ImapClient::connect(&cfg(addr)).await); |
| 1764 | let st = res!(c.select("INBOX").await); |
| 1765 | req!(true, st.read_only, "the downgrade in the completion line was missed"); |
| 1766 | Ok(()) |
| 1767 | } |
| 1768 | |
| 1769 | /// `.SILENT` on every `STORE`: the client already knows what it asked for, |
| 1770 | /// and the untagged FETCH the server would send back is a round trip spent on |
| 1771 | /// nothing. |
| 1772 | #[tokio::test] |
| 1773 | async fn test_a_store_is_silent_on_the_wire_00() -> Outcome<()> { |
| 1774 | let (addr, seen) = res!(scripted( |
| 1775 | GREET, |
| 1776 | vec!["%T% OK SELECT completed\r\n", "%T% OK STORE completed\r\n"], |
| 1777 | ).await); |
| 1778 | let mut c = res!(ImapClient::connect(&cfg(addr)).await); |
| 1779 | res!(c.select("INBOX").await); |
| 1780 | res!(c.uid_store_flags(&[4, 5], FlagOp::Add, &["\\Seen"]).await); |
| 1781 | let lines = res!(lines_of(&seen)); |
| 1782 | req!(true, lines.iter().any(|l| l.contains("UID STORE 4:5 +FLAGS.SILENT (\\Seen)")), |
| 1783 | "the STORE was not what was expected: {:?}", lines); |
| 1784 | Ok(()) |
| 1785 | } |
| 1786 | |
| 1787 | // ── APPEND, the one command with a literal going the other way ── |
| 1788 | |
| 1789 | /// The body waits for the `+` continuation. Sending it before the server has |
| 1790 | /// asked is how a client and a server disagree about where a message ends. |
| 1791 | #[tokio::test] |
| 1792 | async fn test_append_waits_for_the_continuation_00() -> Outcome<()> { |
| 1793 | let body = b"Subject: filed\r\n\r\nA sent message.\r\n"; |
| 1794 | let (addr, seen) = res!(scripted( |
| 1795 | GREET, |
| 1796 | vec!["+ Ready for literal data\r\n", "%T% OK [APPENDUID 1 12] APPEND completed\r\n"], |
| 1797 | ).await); |
| 1798 | let mut c = res!(ImapClient::connect(&cfg(addr)).await); |
| 1799 | res!(c.append("Sent", &["\\Seen"], body).await); |
| 1800 | |
| 1801 | let lines = res!(lines_of(&seen)); |
| 1802 | // The command, then the marker the fixture logs when it reads the |
| 1803 | // literal, then the payload -- in that order, which is the point. |
| 1804 | let cmd = res!(lines.iter().position(|l| l.contains("APPEND \"Sent\"")) |
| 1805 | .ok_or_else(|| err!("No APPEND was sent: {:?}", lines; Test, Missing))); |
| 1806 | let lit = res!(lines.iter().position(|l| l.starts_with("<literal ")) |
| 1807 | .ok_or_else(|| err!("The body never arrived: {:?}", lines; Test, Missing))); |
| 1808 | req!(true, cmd < lit, "the body was sent before the command"); |
| 1809 | req!(true, lines[cmd].contains(&fmt!("{{{}}}", body.len())), |
| 1810 | "the announced length was wrong: {}", lines[cmd]); |
| 1811 | req!(true, lines[cmd].contains("(\\Seen)"), "the flags were dropped"); |
| 1812 | req!(fmt!("<literal {}>", body.len()), lines[lit]); |
| 1813 | req!(String::from_utf8_lossy(body).into_owned(), lines[lit + 1]); |
| 1814 | Ok(()) |
| 1815 | } |
| 1816 | |
| 1817 | /// A refusal instead of a continuation stops the body: a server that will not |
| 1818 | /// take the message must not be sent it anyway. |
| 1819 | #[tokio::test] |
| 1820 | async fn test_a_refused_append_sends_no_body_00() -> Outcome<()> { |
| 1821 | let body = b"Subject: filed\r\n\r\nA sent message.\r\n"; |
| 1822 | let (addr, seen) = res!(scripted( |
| 1823 | GREET, |
| 1824 | vec!["%T% NO [OVERQUOTA] Mailbox is full\r\n"], |
| 1825 | ).await); |
| 1826 | let mut c = res!(ImapClient::connect(&cfg(addr)).await); |
| 1827 | let msg = match c.append("Sent", &[], body).await { |
| 1828 | Err(e) => fmt!("{}", e), |
| 1829 | Ok(()) => return Err(err!("A refused APPEND was a success."; Test, Invalid)), |
| 1830 | }; |
| 1831 | req!(true, msg.contains("OVERQUOTA"), "the server's words were dropped: {}", msg); |
| 1832 | req!(false, res!(lines_of(&seen)).iter().any(|l| l.starts_with("<literal ")), |
| 1833 | "the body was sent to a mailbox that had refused it"); |
| 1834 | Ok(()) |
| 1835 | } |
| 1836 | |
| 1837 | #[test] |
| 1838 | fn test_uid_set_collapses_runs() { |
| 1839 | assert_eq!(uid_set(&[1, 2, 3, 7, 9, 10]), "1:3,7,9:10"); |
| 1840 | assert_eq!(uid_set(&[5]), "5"); |
| 1841 | assert_eq!(uid_set(&[3, 1, 2]), "1:3"); |
| 1842 | assert_eq!(uid_set(&[]), ""); |
| 1843 | } |
| 1844 | |
| 1845 | #[test] |
| 1846 | fn test_quoted_escapes() { |
| 1847 | assert_eq!(quoted("plain"), "\"plain\""); |
| 1848 | assert_eq!(quoted("a\"b"), "\"a\\\"b\""); |
| 1849 | assert_eq!(quoted("a\\b"), "\"a\\\\b\""); |
| 1850 | } |
| 1851 | |
| 1852 | #[test] |
| 1853 | fn test_trailing_literal_len() { |
| 1854 | assert_eq!(trailing_literal_len("* 1 FETCH (BODY[] {42}"), Some(42)); |
| 1855 | assert_eq!(trailing_literal_len("* 1 FETCH (BODY[] {42+}"), Some(42)); |
| 1856 | assert_eq!(trailing_literal_len("a001 OK done"), None); |
| 1857 | assert_eq!(trailing_literal_len("* OK [UIDNEXT 5]"), None); |
| 1858 | } |
| 1859 | |
| 1860 | #[test] |
| 1861 | fn test_tokenise_nested_and_bracketed() { |
| 1862 | let toks = tokenise( |
| 1863 | "* 1 FETCH (UID 9 FLAGS (\\Seen \\Answered) BODY[HEADER.FIELDS (FROM TO)] \"x\")", |
| 1864 | &[], |
| 1865 | ).unwrap(); |
| 1866 | assert_eq!(toks[0], Tok::Atom(fmt!("*"))); |
| 1867 | assert_eq!(toks[1], Tok::Atom(fmt!("1"))); |
| 1868 | assert_eq!(toks[2], Tok::Atom(fmt!("FETCH"))); |
| 1869 | let items = match &toks[3] { |
| 1870 | Tok::List(v) => v.clone(), |
| 1871 | other => panic!("expected a list, got {:?}", other), |
| 1872 | }; |
| 1873 | assert_eq!(items[0], Tok::Atom(fmt!("UID"))); |
| 1874 | assert_eq!(items[1], Tok::Atom(fmt!("9"))); |
| 1875 | assert_eq!(items[3], Tok::List(vec![ |
| 1876 | Tok::Atom(fmt!("\\Seen")), |
| 1877 | Tok::Atom(fmt!("\\Answered")), |
| 1878 | ])); |
| 1879 | // The bracketed section stays one atom, spaces and all. |
| 1880 | assert_eq!(items[4], Tok::Atom(fmt!("BODY[HEADER.FIELDS (FROM TO)]"))); |
| 1881 | assert_eq!(items[5], Tok::Quoted(fmt!("x"))); |
| 1882 | } |
| 1883 | |
| 1884 | #[test] |
| 1885 | fn test_parse_fetch_with_literal_body() { |
| 1886 | let body = b"From: a@b.co\r\nSubject: hi\r\n\r\nbody\r\n".to_vec(); |
| 1887 | let l = line( |
| 1888 | &fmt!("* 12 FETCH (UID 345 FLAGS (\\Seen) INTERNALDATE \ |
| 1889 | \"01-Jan-2026 09:15:00 +0000\" RFC822.SIZE {} BODY[] {{{}}})", |
| 1890 | body.len(), body.len()), |
| 1891 | vec![body.clone()], |
| 1892 | ); |
| 1893 | let msg = parse_fetch_line(&l).unwrap().unwrap(); |
| 1894 | assert_eq!(msg.seq, 12); |
| 1895 | assert_eq!(msg.uid, 345); |
| 1896 | assert_eq!(msg.flags, vec![fmt!("\\Seen")]); |
| 1897 | assert_eq!(msg.internal_date, "01-Jan-2026 09:15:00 +0000"); |
| 1898 | assert_eq!(msg.size as usize, body.len()); |
| 1899 | assert_eq!(msg.body, body); |
| 1900 | } |
| 1901 | |
| 1902 | /// A body containing a CRLF and even a `)` must survive: this is |
| 1903 | /// precisely what a line-oriented parser gets wrong. |
| 1904 | #[test] |
| 1905 | fn test_literal_body_containing_crlf_and_paren() { |
| 1906 | let body = b"Subject: x\r\n\r\nline one\r\n) not the end\r\n".to_vec(); |
| 1907 | let l = line( |
| 1908 | &fmt!("* 1 FETCH (UID 2 BODY[] {{{}}})", body.len()), |
| 1909 | vec![body.clone()], |
| 1910 | ); |
| 1911 | let msg = parse_fetch_line(&l).unwrap().unwrap(); |
| 1912 | assert_eq!(msg.body, body); |
| 1913 | assert_eq!(msg.uid, 2); |
| 1914 | } |
| 1915 | |
| 1916 | #[test] |
| 1917 | fn test_parse_fetch_headers_only() { |
| 1918 | let hdr = b"From: a@b.co\r\n\r\n".to_vec(); |
| 1919 | let l = line( |
| 1920 | &fmt!("* 3 FETCH (UID 7 RFC822.SIZE 999 BODY[HEADER] {{{}}})", hdr.len()), |
| 1921 | vec![hdr.clone()], |
| 1922 | ); |
| 1923 | let msg = parse_fetch_line(&l).unwrap().unwrap(); |
| 1924 | assert_eq!(msg.body, hdr); |
| 1925 | assert_eq!(msg.size, 999); // the whole message, not the fetch |
| 1926 | } |
| 1927 | |
| 1928 | #[test] |
| 1929 | fn test_parse_fetch_without_uid_is_an_error() { |
| 1930 | let l = line("* 1 FETCH (FLAGS (\\Seen))", vec![]); |
| 1931 | assert!(parse_fetch_line(&l).is_err()); |
| 1932 | } |
| 1933 | |
| 1934 | #[test] |
| 1935 | fn test_non_fetch_untagged_line_is_skipped() { |
| 1936 | let l = line("* 42 EXISTS", vec![]); |
| 1937 | assert!(parse_fetch_line(&l).unwrap().is_none()); |
| 1938 | } |
| 1939 | |
| 1940 | #[test] |
| 1941 | fn test_parse_list_line() { |
| 1942 | let l = line("* LIST (\\HasNoChildren) \"/\" \"INBOX\"", vec![]); |
| 1943 | let mb = parse_list_line(&l).unwrap().unwrap(); |
| 1944 | assert_eq!(mb.name, "INBOX"); |
| 1945 | assert_eq!(mb.delimiter, Some('/')); |
| 1946 | assert_eq!(mb.attrs, vec![fmt!("\\HasNoChildren")]); |
| 1947 | assert!(mb.selectable()); |
| 1948 | |
| 1949 | let l = line("* LIST (\\Noselect \\HasChildren) \".\" \"[Gmail]\"", vec![]); |
| 1950 | let mb = parse_list_line(&l).unwrap().unwrap(); |
| 1951 | assert_eq!(mb.name, "[Gmail]"); |
| 1952 | assert_eq!(mb.delimiter, Some('.')); |
| 1953 | assert!(!mb.selectable()); |
| 1954 | } |
| 1955 | |
| 1956 | #[test] |
| 1957 | fn test_special_use_survives_localised_names() { |
| 1958 | // A German Gmail account: the NAME is localised, the ROLE is not. |
| 1959 | let l = line("* LIST (\\HasNoChildren \\Sent) \"/\" \"[Gmail]/Gesendet\"", vec![]); |
| 1960 | let mb = parse_list_line(&l).unwrap().unwrap(); |
| 1961 | assert_eq!(mb.name, "[Gmail]/Gesendet"); |
| 1962 | assert_eq!(mb.special_use(), Some(SpecialUse::Sent)); |
| 1963 | |
| 1964 | let l = line("* LIST (\\HasNoChildren \\All) \"/\" \"[Gmail]/Alle Nachrichten\"", vec![]); |
| 1965 | let mb = parse_list_line(&l).unwrap().unwrap(); |
| 1966 | assert_eq!(mb.special_use(), Some(SpecialUse::All)); |
| 1967 | |
| 1968 | // No role declared: an ordinary user folder. |
| 1969 | let l = line("* LIST (\\HasNoChildren) \"/\" \"receipts\"", vec![]); |
| 1970 | let mb = parse_list_line(&l).unwrap().unwrap(); |
| 1971 | assert_eq!(mb.special_use(), None); |
| 1972 | } |
| 1973 | |
| 1974 | #[test] |
| 1975 | fn test_select_lines_fold_into_status() { |
| 1976 | let mut st = MailboxStatus::default(); |
| 1977 | absorb_select_line(&mut st, "* 42 EXISTS").unwrap(); |
| 1978 | absorb_select_line(&mut st, "* 3 RECENT").unwrap(); |
| 1979 | absorb_select_line(&mut st, "* OK [UIDVALIDITY 1234567890]").unwrap(); |
| 1980 | absorb_select_line(&mut st, "* OK [UIDNEXT 4321]").unwrap(); |
| 1981 | absorb_select_line(&mut st, "* FLAGS (\\Answered \\Seen)").unwrap(); |
| 1982 | assert_eq!(st.exists, 42); |
| 1983 | assert_eq!(st.recent, 3); |
| 1984 | assert_eq!(st.uid_validity, 1_234_567_890); |
| 1985 | assert_eq!(st.uid_next, 4_321); |
| 1986 | assert_eq!(st.flags, vec![fmt!("\\Answered"), fmt!("\\Seen")]); |
| 1987 | assert!(!st.read_only); |
| 1988 | } |
| 1989 | |
| 1990 | #[test] |
| 1991 | fn test_capabilities_from_greeting_and_untagged() { |
| 1992 | let a = parse_capabilities("* OK [CAPABILITY IMAP4rev1 STARTTLS AUTH=PLAIN] ready"); |
| 1993 | assert!(a.contains(&fmt!("STARTTLS"))); |
| 1994 | assert!(a.contains(&fmt!("IMAP4REV1"))); |
| 1995 | let b = parse_capabilities("* CAPABILITY IMAP4rev1 IDLE AUTH=XOAUTH2"); |
| 1996 | assert!(b.contains(&fmt!("AUTH=XOAUTH2"))); |
| 1997 | assert!(parse_capabilities("* 12 EXISTS").is_empty()); |
| 1998 | } |
| 1999 | |
| 2000 | #[test] |
| 2001 | fn test_parse_completion() { |
| 2002 | assert_eq!(parse_completion("OK LOGIN completed").unwrap().0, Status::Ok); |
| 2003 | assert_eq!(parse_completion("NO [AUTHENTICATIONFAILED] bad").unwrap().0, Status::No); |
| 2004 | assert_eq!(parse_completion("BAD nonsense").unwrap().0, Status::Bad); |
| 2005 | assert!(parse_completion("WAT something").is_err()); |
| 2006 | } |
| 2007 | |
| 2008 | #[test] |
| 2009 | fn test_fetch_items_peek_not_seen() { |
| 2010 | // Fetching must not silently mark mail as read. |
| 2011 | assert!(FetchWhat::Full.items().contains("BODY.PEEK[]")); |
| 2012 | assert!(!FetchWhat::Full.items().contains("BODY[]")); |
| 2013 | } |
| 2014 | } |