use std::io::{Read, Write};
use crate::error::{Error, Result};
use crate::tls::reject_pipelined_plaintext;
use crate::url::{percent_decode, Url};
use crate::websocket::base64_encode;
const MAX_LITERAL_BYTES: usize = 64 * 1024 * 1024;
const MAX_RESPONSE_BYTES: usize = 64 * 1024 * 1024;
const IO_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(60);
pub fn fetch(url: &Url) -> Result<Vec<u8>> {
fetch_with(url, &crate::net::NetConfig::default())
}
pub(crate) fn fetch_with(url: &Url, cfg: &crate::net::NetConfig) -> Result<Vec<u8>> {
let dial = || -> Result<Box<dyn crate::net::NetStream>> {
let sock = cfg.connect(&url.host, url.port)?;
sock.set_read_timeout(cfg.io_timeout())?;
sock.set_write_timeout(Some(IO_TIMEOUT))?;
Ok(sock)
};
match url.scheme.as_str() {
"imap" => run(Stream::Plain(dial()?), url, cfg),
"imaps" => {
let sock = dial()?;
let tls = cfg.tls_connect(sock, &url.host)?;
run(Stream::Tls(Box::new(tls)), url, cfg)
}
other => Err(Error::UnsupportedScheme(other.to_string())),
}
}
use crate::net::MaybeTlsStream as Stream;
fn run(mut sock: Stream, url: &Url, cfg: &crate::net::NetConfig) -> Result<Vec<u8>> {
let mut buf = LineReader::new();
let greeting = buf.read_response(&mut sock, "*")?;
let first = greeting.lines().next().unwrap_or("");
if !first.starts_with("* OK") && !first.starts_with("* PREAUTH") {
return Err(Error::BadResponse(format!(
"imap greeting was not OK: {first}"
)));
}
let preauth = first.starts_with("* PREAUTH");
let mut tagger = Tagger::new();
let mut caps = parse_capability_code(first)
.or_else(|| parse_capability_line(&greeting))
.map(Caps::from_tokens)
.unwrap_or_default();
if caps.is_empty() {
caps = request_capability(&mut sock, &mut buf, &mut tagger)?;
}
if sock.is_plain() && caps.has("STARTTLS") {
let tag = tagger.next();
sock.write_all(format!("{tag} STARTTLS\r\n").as_bytes())?;
sock.flush()?;
let resp = buf.read_response(&mut sock, &tag)?;
require_ok(&resp, &tag, "STARTTLS")?;
reject_pipelined_plaintext("imap", buf.is_clear())?;
sock.upgrade(&url.host, &cfg.tls)?;
caps = request_capability(&mut sock, &mut buf, &mut tagger)?;
}
require_tls_ok(cfg.require_tls, sock.is_plain())?;
if !preauth {
if let Some(userinfo) = url.userinfo.as_deref() {
let (user, pass) = crate::url::split_userinfo(userinfo);
let (user, pass) = (percent_decode(user), percent_decode(pass));
reject_ctl(&user, "imap user")?;
reject_ctl(&pass, "imap password")?;
authenticate(&mut sock, &mut buf, &mut tagger, &caps, &user, &pass)?;
}
}
let target = parse_path(&url.path)?;
if let Some(mbox) = target.mailbox.as_deref() {
reject_ctl(mbox, "imap mailbox")?;
}
if let Some(s) = target.section.as_deref() {
reject_ctl(s, "imap section")?;
}
if let Some(q) = target.query.as_deref() {
reject_ctl(q, "imap search query")?;
}
let section = target.section.as_deref().unwrap_or("");
let body = match (target.mailbox.as_deref(), target.uid, target.mailindex) {
(None, _, _) => {
let tag = tagger.next();
let cmd = format!("{tag} LIST \"\" \"*\"\r\n");
sock.write_all(cmd.as_bytes())?;
sock.flush()?;
let resp = buf.read_response(&mut sock, &tag)?;
require_ok(&resp, &tag, "LIST")?;
collect_untagged(&resp, "LIST").into_bytes()
}
(Some(mbox), None, None) if target.query.is_some() => {
select_mailbox(&mut sock, &mut buf, &mut tagger, mbox, target.uidvalidity)?;
let tag = tagger.next();
let q = target.query.as_deref().unwrap_or("");
sock.write_all(format!("{tag} SEARCH {q}\r\n").as_bytes())?;
sock.flush()?;
let resp = buf.read_response(&mut sock, &tag)?;
require_ok(&resp, &tag, "SEARCH")?;
collect_untagged(&resp, "SEARCH").into_bytes()
}
(Some(mbox), None, None) => {
select_mailbox(&mut sock, &mut buf, &mut tagger, mbox, target.uidvalidity)?;
let tag = tagger.next();
let cmd = format!("{tag} FETCH 1:* (UID)\r\n");
sock.write_all(cmd.as_bytes())?;
sock.flush()?;
let resp = buf.read_response(&mut sock, &tag)?;
require_ok(&resp, &tag, "FETCH")?;
collect_untagged(&resp, "FETCH").into_bytes()
}
(Some(mbox), uid, mindex) => {
select_mailbox(&mut sock, &mut buf, &mut tagger, mbox, target.uidvalidity)?;
let tag = tagger.next();
let cmd = match (uid, mindex) {
(Some(n), _) => format!("{tag} UID FETCH {n} BODY[{section}]\r\n"),
(None, Some(n)) => format!("{tag} FETCH {n} BODY[{section}]\r\n"),
(None, None) => unreachable!("handled by the arms above"),
};
sock.write_all(cmd.as_bytes())?;
sock.flush()?;
let (resp, literals) = buf.read_response_with_literals(&mut sock, &tag)?;
require_ok(&resp, &tag, "FETCH")?;
literals.into_iter().next().unwrap_or_default()
}
};
let tag = tagger.next();
let _ = sock.write_all(format!("{tag} LOGOUT\r\n").as_bytes());
let _ = sock.flush();
let _ = buf.read_response(&mut sock, &tag);
Ok(body)
}
fn require_tls_ok(require_tls: bool, still_plain: bool) -> Result<()> {
if require_tls && still_plain {
return Err(Error::BadResponse(
"imap: TLS required (--ssl-reqd) but server did not offer STARTTLS".into(),
));
}
Ok(())
}
fn request_capability<S: Read + Write>(
sock: &mut S,
buf: &mut LineReader,
tagger: &mut Tagger,
) -> Result<Caps> {
let tag = tagger.next();
sock.write_all(format!("{tag} CAPABILITY\r\n").as_bytes())?;
sock.flush()?;
let resp = buf.read_response(sock, &tag)?;
require_ok(&resp, &tag, "CAPABILITY")?;
Ok(parse_capability_line(&resp)
.map(Caps::from_tokens)
.unwrap_or_default())
}
#[derive(Debug, PartialEq, Eq)]
enum AuthMethod {
SaslPlain,
SaslLogin,
LoginCommand,
}
fn choose_auth(caps: &Caps) -> Option<AuthMethod> {
if caps.has("AUTH=PLAIN") {
return Some(AuthMethod::SaslPlain);
}
if caps.has("AUTH=LOGIN") {
return Some(AuthMethod::SaslLogin);
}
if caps.has("LOGINDISABLED") {
return None;
}
Some(AuthMethod::LoginCommand)
}
fn authenticate<S: Read + Write>(
sock: &mut S,
buf: &mut LineReader,
tagger: &mut Tagger,
caps: &Caps,
user: &str,
pass: &str,
) -> Result<()> {
match choose_auth(caps) {
Some(AuthMethod::SaslPlain) => {
auth_plain(sock, buf, tagger, caps.has("SASL-IR"), user, pass)
}
Some(AuthMethod::SaslLogin) => auth_login_sasl(sock, buf, tagger, user, pass),
Some(AuthMethod::LoginCommand) => login_command(sock, buf, tagger, user, pass),
None => Err(Error::BadResponse(
"imap: server offers no usable authentication mechanism \
(LOGINDISABLED and no AUTH=PLAIN/LOGIN); a STARTTLS/imaps \
connection may be required"
.into(),
)),
}
}
fn auth_plain<S: Read + Write>(
sock: &mut S,
buf: &mut LineReader,
tagger: &mut Tagger,
sasl_ir: bool,
user: &str,
pass: &str,
) -> Result<()> {
let tag = tagger.next();
let initial = sasl_plain_initial(user, pass);
if sasl_ir {
let cmd = format!("{tag} AUTHENTICATE PLAIN {initial}\r\n");
sock.write_all(cmd.as_bytes())?;
sock.flush()?;
} else {
sock.write_all(format!("{tag} AUTHENTICATE PLAIN\r\n").as_bytes())?;
sock.flush()?;
let cont = buf.read_response(sock, &tag)?;
if is_tagged_done(&cont, &tag) {
return require_ok(&cont, &tag, "AUTHENTICATE PLAIN");
}
sock.write_all(format!("{initial}\r\n").as_bytes())?;
sock.flush()?;
}
let resp = buf.read_response(sock, &tag)?;
require_ok(&resp, &tag, "AUTHENTICATE PLAIN")
}
fn auth_login_sasl<S: Read + Write>(
sock: &mut S,
buf: &mut LineReader,
tagger: &mut Tagger,
user: &str,
pass: &str,
) -> Result<()> {
let tag = tagger.next();
sock.write_all(format!("{tag} AUTHENTICATE LOGIN\r\n").as_bytes())?;
sock.flush()?;
let cont = buf.read_response(sock, &tag)?;
if is_tagged_done(&cont, &tag) {
return require_ok(&cont, &tag, "AUTHENTICATE LOGIN");
}
sock.write_all(format!("{}\r\n", base64_encode(user.as_bytes())).as_bytes())?;
sock.flush()?;
let cont = buf.read_response(sock, &tag)?;
if is_tagged_done(&cont, &tag) {
return require_ok(&cont, &tag, "AUTHENTICATE LOGIN");
}
sock.write_all(format!("{}\r\n", base64_encode(pass.as_bytes())).as_bytes())?;
sock.flush()?;
let resp = buf.read_response(sock, &tag)?;
require_ok(&resp, &tag, "AUTHENTICATE LOGIN")
}
fn login_command<S: Read + Write>(
sock: &mut S,
buf: &mut LineReader,
tagger: &mut Tagger,
user: &str,
pass: &str,
) -> Result<()> {
let tag = tagger.next();
let cmd = format!(
"{tag} LOGIN {} {}\r\n",
quote_imap_string(user),
quote_imap_string(pass)
);
sock.write_all(cmd.as_bytes())?;
sock.flush()?;
let resp = buf.read_response(sock, &tag)?;
require_ok(&resp, &tag, "LOGIN")
}
fn sasl_plain_initial(user: &str, pass: &str) -> String {
let mut raw = Vec::with_capacity(user.len() + pass.len() + 2);
raw.push(0);
raw.extend_from_slice(user.as_bytes());
raw.push(0);
raw.extend_from_slice(pass.as_bytes());
base64_encode(&raw)
}
fn is_tagged_done(resp: &str, tag: &str) -> bool {
is_tagged_line(last_line(resp), tag)
}
fn last_line(resp: &str) -> &str {
resp.lines().rev().find(|l| !l.is_empty()).unwrap_or("")
}
fn is_tagged_line(line: &str, tag: &str) -> bool {
line.strip_prefix(tag)
.is_some_and(|rest| rest.is_empty() || rest.starts_with(' '))
}
#[derive(Default)]
struct Caps {
set: Vec<String>,
}
impl Caps {
fn from_tokens<I, S>(tokens: I) -> Self
where
I: IntoIterator<Item = S>,
S: AsRef<str>,
{
let set = tokens
.into_iter()
.map(|t| t.as_ref().to_ascii_uppercase())
.filter(|t| !t.is_empty())
.collect();
Self { set }
}
fn has(&self, cap: &str) -> bool {
self.set.iter().any(|c| c.eq_ignore_ascii_case(cap))
}
fn is_empty(&self) -> bool {
self.set.is_empty()
}
}
fn parse_capability_code(line: &str) -> Option<Vec<&str>> {
let start = line.find('[')?;
let end = line[start..].find(']')? + start;
let inside = &line[start + 1..end];
let mut toks = inside.split_whitespace();
if !toks.next()?.eq_ignore_ascii_case("CAPABILITY") {
return None;
}
let caps: Vec<&str> = toks.collect();
if caps.is_empty() {
None
} else {
Some(caps)
}
}
fn parse_capability_line(resp: &str) -> Option<Vec<&str>> {
for line in resp.lines() {
if let Some(rest) = line.strip_prefix("* ") {
let mut toks = rest.split_whitespace();
if let Some(first) = toks.next() {
if first.eq_ignore_ascii_case("CAPABILITY") {
let caps: Vec<&str> = toks.collect();
if !caps.is_empty() {
return Some(caps);
}
}
}
}
}
None
}
fn select_mailbox(
sock: &mut Stream,
buf: &mut LineReader,
tagger: &mut Tagger,
mbox: &str,
uidvalidity: Option<u32>,
) -> Result<()> {
let tag = tagger.next();
let cmd = format!("{tag} SELECT {}\r\n", quote_imap_string(mbox));
sock.write_all(cmd.as_bytes())?;
sock.flush()?;
let resp = buf.read_response(sock, &tag)?;
require_ok(&resp, &tag, "SELECT")?;
if let Some(want) = uidvalidity {
let got = parse_uidvalidity(&resp);
if got != Some(want) {
return Err(Error::BadResponse(format!(
"imap: UIDVALIDITY mismatch (URL {want}, server {got:?})"
)));
}
}
Ok(())
}
fn require_ok(resp: &str, tag: &str, what: &str) -> Result<()> {
let last = last_line(resp);
let rest = last.strip_prefix(tag).unwrap_or("").trim_start();
let status = rest.split_whitespace().next().unwrap_or("");
if status.eq_ignore_ascii_case("OK") {
Ok(())
} else {
Err(Error::BadResponse(format!("imap {what} failed: {last}")))
}
}
fn reject_ctl(s: &str, what: &str) -> Result<()> {
crate::url::reject_ctl("imap", what, s)
}
fn collect_untagged(resp: &str, kind: &str) -> String {
let mut out = String::new();
for line in resp.lines() {
if let Some(rest) = line.strip_prefix("* ") {
let mut toks = rest.split_whitespace();
let first = toks.next().unwrap_or("");
let second = toks.next().unwrap_or("");
if first.eq_ignore_ascii_case(kind) || second.eq_ignore_ascii_case(kind) {
out.push_str(line);
out.push_str("\r\n");
}
}
}
out
}
fn quote_imap_string(s: &str) -> String {
let mut out = String::with_capacity(s.len() + 2);
out.push('"');
for c in s.chars() {
if c == '\\' || c == '"' {
out.push('\\');
}
out.push(c);
}
out.push('"');
out
}
#[derive(Debug, Default, PartialEq, Eq)]
struct ImapPath {
mailbox: Option<String>,
uidvalidity: Option<u32>,
uid: Option<u32>,
mailindex: Option<u32>,
section: Option<String>,
query: Option<String>,
}
fn parse_path(path: &str) -> Result<ImapPath> {
let (path, query) = match path.split_once('?') {
Some((p, q)) => (p, Some(percent_decode(q))),
None => (path, None),
};
let trimmed = path.strip_prefix('/').unwrap_or(path);
let (mbox_part, params) = trimmed.split_once(';').unwrap_or((trimmed, ""));
let mbox_part = mbox_part.strip_suffix('/').unwrap_or(mbox_part);
let mut p = ImapPath {
mailbox: (!mbox_part.is_empty()).then(|| percent_decode(mbox_part)),
query: query.filter(|q| !q.is_empty()),
..ImapPath::default()
};
let num = |name: &str, v: &str| {
v.parse::<u32>()
.map_err(|_| Error::InvalidUrl(format!("imap: bad {name} value {v:?}")))
};
for param in params.split(';').filter(|s| !s.is_empty()) {
let param = param.strip_suffix('/').unwrap_or(param);
let Some((name, value)) = param.split_once('=') else {
continue;
};
let value = percent_decode(value);
match name.to_ascii_uppercase().as_str() {
"UID" => p.uid = Some(num(name, &value)?),
"UIDVALIDITY" => p.uidvalidity = Some(num(name, &value)?),
"MAILINDEX" => p.mailindex = Some(num(name, &value)?),
"SECTION" => p.section = Some(value),
_ => {}
}
}
Ok(p)
}
fn parse_uidvalidity(resp: &str) -> Option<u32> {
let upper = resp.to_ascii_uppercase();
let i = upper.find("[UIDVALIDITY ")?;
let rest = &resp[i + "[UIDVALIDITY ".len()..];
let end = rest.find(']')?;
rest[..end].trim().parse().ok()
}
fn extract_literal_size(line: &str) -> Option<usize> {
let trimmed = line.trim_end_matches(['\r', '\n']);
let trimmed = trimmed.trim_end();
let rest = trimmed.strip_suffix('}')?;
let open = rest.rfind('{')?;
let inside = &rest[open + 1..];
let inside = inside.strip_suffix('+').unwrap_or(inside);
if inside.is_empty() || inside.chars().any(|c| !c.is_ascii_digit()) {
return None;
}
inside.parse().ok()
}
struct Tagger {
n: u32,
}
impl Tagger {
fn new() -> Self {
Self { n: 0 }
}
fn next(&mut self) -> String {
self.n += 1;
format!("a{:03}", self.n)
}
}
struct LineReader {
buf: Vec<u8>,
}
impl LineReader {
fn new() -> Self {
Self { buf: Vec::new() }
}
fn is_clear(&self) -> bool {
self.buf.is_empty()
}
fn read_line<S: Read>(&mut self, sock: &mut S) -> Result<String> {
let mut tmp = [0u8; 4096];
let mut scanned = 0usize;
loop {
if let Some(rel) = find_crlf(&self.buf[scanned..]) {
let pos = scanned + rel;
let line = String::from_utf8_lossy(&self.buf[..pos]).into_owned();
self.buf.drain(..pos + 2);
return Ok(line);
}
scanned = self.buf.len().saturating_sub(1);
if self.buf.len() > MAX_RESPONSE_BYTES {
return Err(Error::BadResponse(format!(
"imap: response line exceeds maximum {MAX_RESPONSE_BYTES} bytes"
)));
}
let n = sock.read(&mut tmp)?;
if n == 0 {
if self.buf.is_empty() {
return Err(Error::UnexpectedEof);
}
let line_bytes = std::mem::take(&mut self.buf);
return Ok(String::from_utf8_lossy(&line_bytes).into_owned());
}
self.buf.extend_from_slice(&tmp[..n]);
}
}
fn read_exact<S: Read>(&mut self, sock: &mut S, n: usize) -> Result<Vec<u8>> {
let mut out = Vec::with_capacity(n);
if !self.buf.is_empty() {
let take = self.buf.len().min(n);
out.extend_from_slice(&self.buf[..take]);
self.buf.drain(..take);
}
let mut tmp = [0u8; 4096];
while out.len() < n {
let want = (n - out.len()).min(tmp.len());
let got = sock.read(&mut tmp[..want])?;
if got == 0 {
return Err(Error::UnexpectedEof);
}
out.extend_from_slice(&tmp[..got]);
}
Ok(out)
}
fn read_response<S: Read>(&mut self, sock: &mut S, tag: &str) -> Result<String> {
let (text, _literals) = self.read_response_with_literals(sock, tag)?;
Ok(text)
}
fn read_response_with_literals<S: Read>(
&mut self,
sock: &mut S,
tag: &str,
) -> Result<(String, Vec<Vec<u8>>)> {
let mut text = String::new();
let mut literals: Vec<Vec<u8>> = Vec::new();
loop {
let line = self.read_line(sock)?;
text.push_str(&line);
text.push_str("\r\n");
if text.len() > MAX_RESPONSE_BYTES {
return Err(Error::BadResponse(format!(
"imap: response exceeds maximum {MAX_RESPONSE_BYTES} bytes"
)));
}
if let Some(n) = extract_literal_size(&line) {
if n > MAX_LITERAL_BYTES {
return Err(Error::BadResponse(format!(
"imap: literal size {n} exceeds maximum {MAX_LITERAL_BYTES}"
)));
}
if text.len().saturating_add(n) > MAX_RESPONSE_BYTES {
return Err(Error::BadResponse(format!(
"imap: response exceeds maximum {MAX_RESPONSE_BYTES} bytes"
)));
}
let bytes = self.read_exact(sock, n)?;
text.push_str(&String::from_utf8_lossy(&bytes));
literals.push(bytes);
continue;
}
let trimmed = line.trim_end();
if trimmed == "+" || trimmed.starts_with("+ ") {
return Ok((text, literals));
}
if is_tagged_line(trimmed, tag) {
return Ok((text, literals));
}
}
}
}
fn find_crlf(buf: &[u8]) -> Option<usize> {
buf.windows(2).position(|w| w == b"\r\n")
}
#[cfg(test)]
mod tests {
use super::*;
use std::io;
fn pp(path: &str) -> (Option<String>, Option<u32>) {
let p = parse_path(path).unwrap();
(p.mailbox, p.uid)
}
#[test]
fn quote_plain_ascii() {
assert_eq!(quote_imap_string("alice"), "\"alice\"");
assert_eq!(quote_imap_string(""), "\"\"");
}
#[test]
fn quote_escapes_backslash_and_quote() {
assert_eq!(quote_imap_string("say \"hi\""), "\"say \\\"hi\\\"\"");
assert_eq!(quote_imap_string("a\\b"), "\"a\\\\b\"");
assert_eq!(quote_imap_string("p\\a\"ss"), "\"p\\\\a\\\"ss\"");
}
#[test]
fn quote_passes_other_chars_through() {
assert_eq!(quote_imap_string("INBOX/Sent"), "\"INBOX/Sent\"");
assert_eq!(quote_imap_string("a b c"), "\"a b c\"");
}
#[test]
fn reject_ctl_flags_control_bytes() {
assert!(reject_ctl("alice", "imap user").is_ok());
assert!(reject_ctl("p@ss:word!", "imap password").is_ok());
assert!(reject_ctl("INBOX/Sent", "imap mailbox").is_ok());
assert!(reject_ctl("alice\r\na001 DELETE INBOX", "imap user").is_err());
assert!(reject_ctl("alice\npass", "imap user").is_err());
assert!(reject_ctl("alice\0", "imap user").is_err());
assert!(reject_ctl("alice\x7f", "imap user").is_err()); }
#[test]
fn parse_path_root() {
assert_eq!(pp("/"), (None, None));
assert_eq!(pp(""), (None, None));
}
#[test]
fn parse_path_mailbox_only() {
assert_eq!(pp("/INBOX"), (Some("INBOX".into()), None));
assert_eq!(pp("/Stuff/Sub"), (Some("Stuff/Sub".into()), None));
}
#[test]
fn parse_path_mailbox_with_uid() {
assert_eq!(pp("/INBOX;UID=42"), (Some("INBOX".into()), Some(42)));
assert_eq!(pp("/Drafts;uid=7"), (Some("Drafts".into()), Some(7)));
assert_eq!(
pp("/Stuff/Sub;UID=999"),
(Some("Stuff/Sub".into()), Some(999))
);
}
#[test]
fn parse_path_ignores_unknown_params() {
assert_eq!(
pp("/INBOX;TYPE=LIST;UID=5"),
(Some("INBOX".into()), Some(5))
);
}
#[test]
fn parse_path_percent_decodes_mailbox() {
assert_eq!(pp("/My%20Mail"), (Some("My Mail".into()), None));
assert_eq!(pp("/a%2Fb;UID=1"), (Some("a/b".into()), Some(1)));
}
#[test]
fn literal_size_basic() {
assert_eq!(extract_literal_size("* 1 FETCH (BODY[] {42}\r\n"), Some(42));
assert_eq!(extract_literal_size("* 1 FETCH (BODY[] {42}"), Some(42));
assert_eq!(extract_literal_size("foo {0}\r\n"), Some(0));
}
#[test]
fn literal_size_with_plus() {
assert_eq!(extract_literal_size("foo {123+}\r\n"), Some(123));
}
#[test]
fn literal_size_rejects_non_literal() {
assert_eq!(extract_literal_size("a001 OK FETCH completed\r\n"), None);
assert_eq!(extract_literal_size("plain line"), None);
assert_eq!(extract_literal_size("{}"), None);
assert_eq!(extract_literal_size("{abc}"), None);
assert_eq!(extract_literal_size("{12 34}"), None);
}
#[test]
fn collect_untagged_filters_by_kind() {
let resp = "* LIST () \"/\" INBOX\r\n\
* LIST () \"/\" Drafts\r\n\
* 5 EXISTS\r\n\
a001 OK LIST completed\r\n";
let out = collect_untagged(resp, "LIST");
assert!(out.contains("INBOX"));
assert!(out.contains("Drafts"));
assert!(!out.contains("EXISTS"));
assert!(!out.contains("a001"));
}
#[test]
fn collect_untagged_matches_numeric_responses() {
let resp = "* 1 FETCH (UID 100)\r\n\
* 2 FETCH (UID 101)\r\n\
a002 OK FETCH completed\r\n";
let out = collect_untagged(resp, "FETCH");
assert!(out.contains("UID 100"));
assert!(out.contains("UID 101"));
assert!(!out.contains("a002"));
}
#[test]
fn require_ok_accepts_ok_rejects_no_bad() {
assert!(require_ok("* untagged\r\na001 OK done\r\n", "a001", "X").is_ok());
assert!(require_ok("a001 NO bad creds\r\n", "a001", "X").is_err());
assert!(require_ok("a001 BAD syntax\r\n", "a001", "X").is_err());
}
#[test]
fn tagger_increments() {
let mut t = Tagger::new();
assert_eq!(t.next(), "a001");
assert_eq!(t.next(), "a002");
assert_eq!(t.next(), "a003");
}
#[test]
fn capability_code_from_greeting() {
let line = "* OK [CAPABILITY IMAP4rev2 STARTTLS LOGINDISABLED] ready";
let toks = parse_capability_code(line).expect("caps from code");
let caps = Caps::from_tokens(toks);
assert!(caps.has("IMAP4rev2"));
assert!(caps.has("starttls")); assert!(caps.has("LOGINDISABLED"));
assert!(!caps.has("AUTH=PLAIN"));
}
#[test]
fn capability_code_absent_or_wrong_keyword() {
assert!(parse_capability_code("* OK ready, no brackets").is_none());
assert!(parse_capability_code("* OK [UIDVALIDITY 1] hi").is_none());
assert!(parse_capability_code("* OK [CAPABILITY] empty").is_none());
}
#[test]
fn capability_line_parsed() {
let resp = "* CAPABILITY IMAP4rev1 AUTH=PLAIN AUTH=LOGIN IDLE\r\n\
a001 OK CAPABILITY completed\r\n";
let caps = Caps::from_tokens(parse_capability_line(resp).expect("caps"));
assert!(caps.has("IMAP4rev1"));
assert!(caps.has("AUTH=PLAIN"));
assert!(caps.has("AUTH=LOGIN"));
assert!(caps.has("IDLE"));
assert!(!caps.has("STARTTLS"));
}
#[test]
fn capability_line_missing_returns_none() {
let resp = "a001 OK CAPABILITY completed\r\n";
assert!(parse_capability_line(resp).is_none());
}
#[test]
fn choose_auth_prefers_sasl_plain() {
let caps = Caps::from_tokens(["AUTH=PLAIN", "AUTH=LOGIN", "LOGINDISABLED"]);
assert_eq!(choose_auth(&caps), Some(AuthMethod::SaslPlain));
}
#[test]
fn choose_auth_falls_back_to_sasl_login() {
let caps = Caps::from_tokens(["AUTH=LOGIN", "LOGINDISABLED"]);
assert_eq!(choose_auth(&caps), Some(AuthMethod::SaslLogin));
}
#[test]
fn choose_auth_falls_back_to_login_command() {
let caps = Caps::from_tokens(["IMAP4rev1", "IDLE"]);
assert_eq!(choose_auth(&caps), Some(AuthMethod::LoginCommand));
}
#[test]
fn choose_auth_login_disabled_without_sasl_is_none() {
let caps = Caps::from_tokens(["IMAP4rev1", "LOGINDISABLED"]);
assert_eq!(choose_auth(&caps), None);
}
#[test]
fn choose_auth_empty_caps_uses_login_command() {
let caps = Caps::default();
assert_eq!(choose_auth(&caps), Some(AuthMethod::LoginCommand));
}
#[test]
fn sasl_plain_initial_is_nul_user_nul_pass_base64() {
let got = sasl_plain_initial("alice", "secret");
let expected = base64_encode(b"\x00alice\x00secret");
assert_eq!(got, expected);
assert_eq!(got, "AGFsaWNlAHNlY3JldA==");
}
#[test]
fn sasl_plain_initial_empty_password() {
let got = sasl_plain_initial("bob", "");
assert_eq!(got, base64_encode(b"\x00bob\x00"));
}
struct MockIo {
to_read: std::io::Cursor<Vec<u8>>,
written: Vec<u8>,
}
impl MockIo {
fn new(script: &[u8]) -> Self {
Self {
to_read: std::io::Cursor::new(script.to_vec()),
written: Vec::new(),
}
}
fn sent(&self) -> String {
String::from_utf8_lossy(&self.written).into_owned()
}
}
impl Read for MockIo {
fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
self.to_read.read(buf)
}
}
impl Write for MockIo {
fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
self.written.extend_from_slice(buf);
Ok(buf.len())
}
fn flush(&mut self) -> io::Result<()> {
Ok(())
}
}
#[test]
fn auth_plain_sends_correct_initial_response() {
let mut io = MockIo::new(b"a001 OK authenticated\r\n");
let mut lr = LineReader::new();
let mut tagger = Tagger::new();
auth_plain(&mut io, &mut lr, &mut tagger, true, "alice", "secret").expect("auth plain ok");
let sent = io.sent();
assert!(
sent.starts_with("a001 AUTHENTICATE PLAIN AGFsaWNlAHNlY3JldA=="),
"unexpected client output: {sent:?}"
);
}
#[test]
fn auth_plain_without_sasl_ir_waits_for_continuation() {
let mut io = MockIo::new(b"+ \r\na001 OK authenticated\r\n");
let mut lr = LineReader::new();
let mut tagger = Tagger::new();
auth_plain(&mut io, &mut lr, &mut tagger, false, "alice", "secret").expect("auth plain ok");
assert_eq!(
io.sent(),
"a001 AUTHENTICATE PLAIN\r\nAGFsaWNlAHNlY3JldA==\r\n"
);
}
#[test]
fn parse_path_rfc5092_forms() {
let p = parse_path("/INBOX/;UID=1/;SECTION=TEXT").unwrap();
assert_eq!(p.mailbox.as_deref(), Some("INBOX"));
assert_eq!(p.uid, Some(1));
assert_eq!(p.section.as_deref(), Some("TEXT"));
let p = parse_path("/INBOX;UIDVALIDITY=50/;UID=3").unwrap();
assert_eq!((p.uidvalidity, p.uid), (Some(50), Some(3)));
let p = parse_path("/INBOX;MAILINDEX=2").unwrap();
assert_eq!((p.mailindex, p.uid), (Some(2), None));
let p = parse_path("/INBOX?NEW").unwrap();
assert_eq!(p.query.as_deref(), Some("NEW"));
let p = parse_path("/INBOX?SUBJECT%20hi").unwrap();
assert_eq!(p.query.as_deref(), Some("SUBJECT hi"));
assert!(parse_path("/INBOX;UID=abc").is_err());
}
#[test]
fn parse_uidvalidity_from_select_response() {
let resp = "* 3 EXISTS\r\n* OK [UIDVALIDITY 3857529045] UIDs valid\r\na001 OK done";
assert_eq!(parse_uidvalidity(resp), Some(3857529045));
assert_eq!(parse_uidvalidity("a001 OK"), None);
}
#[test]
fn auth_login_sasl_walks_two_prompts() {
let script = b"+ VXNlcm5hbWU6\r\n+ UGFzc3dvcmQ6\r\na001 OK welcome\r\n";
let mut io = MockIo::new(script);
let mut lr = LineReader::new();
let mut tagger = Tagger::new();
auth_login_sasl(&mut io, &mut lr, &mut tagger, "bob", "pw").expect("auth login ok");
let sent = io.sent();
assert!(sent.contains("a001 AUTHENTICATE LOGIN\r\n"), "{sent:?}");
assert!(sent.contains("Ym9i\r\n"), "username b64 missing: {sent:?}");
assert!(sent.contains("cHc=\r\n"), "password b64 missing: {sent:?}");
}
#[test]
fn read_response_aborts_on_endless_untagged_lines() {
let line = b"* 1 FETCH (UID 1)\r\n"; let n = MAX_RESPONSE_BYTES / line.len() + 2;
let mut script = Vec::with_capacity(n * line.len());
for _ in 0..n {
script.extend_from_slice(line);
}
let mut io = MockIo::new(&script);
let mut lr = LineReader::new();
match lr.read_response_with_literals(&mut io, "a001") {
Err(Error::BadResponse(m)) => assert!(m.contains("maximum"), "got {m}"),
other => panic!("expected BadResponse(maximum), got {other:?}"),
}
}
#[test]
fn read_line_aborts_on_endless_crlf_less_stream() {
let data = vec![b'x'; MAX_RESPONSE_BYTES + 4096];
let mut io = MockIo::new(&data);
let mut lr = LineReader::new();
match lr.read_line(&mut io) {
Err(Error::BadResponse(m)) => assert!(m.contains("maximum"), "got {m}"),
other => panic!("expected BadResponse(maximum), got {other:?}"),
}
}
#[test]
fn read_response_aborts_when_literals_sum_past_cap() {
let big = MAX_LITERAL_BYTES;
let script = format!("* 1 FETCH (BODY[] {{{big}}}\r\n");
let mut io = MockIo::new(script.as_bytes());
let mut lr = LineReader::new();
match lr.read_response_with_literals(&mut io, "a001") {
Err(Error::BadResponse(m)) => assert!(m.contains("maximum"), "got {m}"),
other => panic!("expected BadResponse(maximum), got {other:?}"),
}
}
#[test]
fn login_command_quotes_and_sends() {
let mut io = MockIo::new(b"a001 OK logged in\r\n");
let mut lr = LineReader::new();
let mut tagger = Tagger::new();
login_command(&mut io, &mut lr, &mut tagger, "alice", "se cret").expect("login ok");
assert_eq!(io.sent(), "a001 LOGIN \"alice\" \"se cret\"\r\n");
}
#[test]
fn starttls_offered_drives_upgrade_decision() {
let caps = Caps::from_tokens(["IMAP4rev1", "STARTTLS", "LOGINDISABLED"]);
assert!(caps.has("STARTTLS"));
assert_eq!(choose_auth(&caps), None);
}
#[test]
fn starttls_command_flow_against_mock() {
let script = b"* CAPABILITY IMAP4rev1 STARTTLS LOGINDISABLED\r\n\
a001 OK CAPABILITY completed\r\n\
a002 OK Begin TLS negotiation now\r\n";
let mut io = MockIo::new(script);
let mut lr = LineReader::new();
let mut tagger = Tagger::new();
let caps = request_capability(&mut io, &mut lr, &mut tagger).expect("caps");
assert!(caps.has("STARTTLS"));
let tag = tagger.next();
io.write_all(format!("{tag} STARTTLS\r\n").as_bytes())
.unwrap();
let resp = lr.read_response(&mut io, &tag).expect("starttls resp");
assert!(require_ok(&resp, &tag, "STARTTLS").is_ok());
let sent = io.sent();
assert!(sent.contains("a001 CAPABILITY\r\n"), "{sent:?}");
assert!(sent.contains("a002 STARTTLS\r\n"), "{sent:?}");
}
#[test]
fn starttls_rejects_pipelined_plaintext_injection() {
let script = b"a001 OK Begin TLS negotiation now\r\n\
* CAPABILITY IMAP4rev1 AUTH=PLAIN\r\n\
a002 OK injected\r\n";
let mut src: &[u8] = script;
let mut lr = LineReader::new();
let resp = lr.read_response(&mut src, "a001").expect("starttls ok");
assert!(require_ok(&resp, "a001", "STARTTLS").is_ok());
assert!(
!lr.is_clear(),
"expected pipelined plaintext to remain buffered after STARTTLS OK"
);
}
#[test]
fn starttls_clear_when_server_waits_for_handshake() {
let script = b"a001 OK Begin TLS negotiation now\r\n";
let mut src: &[u8] = script;
let mut lr = LineReader::new();
let resp = lr.read_response(&mut src, "a001").expect("starttls ok");
assert!(require_ok(&resp, "a001", "STARTTLS").is_ok());
assert!(lr.is_clear(), "no pipelined data: reader must be clear");
}
#[test]
fn line_reader_inlines_literal_bytes() {
let wire = b"* 1 FETCH (BODY[] {5}\r\nhello)\r\na001 OK FETCH completed\r\n";
let mut src: &[u8] = wire;
let mut lr = LineReader::new();
let (text, literals) = lr
.read_response_with_literals(&mut src, "a001")
.expect("read response");
assert_eq!(literals, vec![b"hello".to_vec()]);
assert!(text.contains("hello"));
assert!(text.contains("a001 OK"));
}
#[test]
fn line_reader_returns_on_continuation() {
let wire = b"+ go ahead\r\n";
let mut src: &[u8] = wire;
let mut lr = LineReader::new();
let resp = lr.read_response(&mut src, "a001").expect("cont");
assert!(resp.starts_with("+ go ahead"));
assert!(!is_tagged_done(&resp, "a001"));
}
#[test]
fn line_reader_rejects_oversized_literal() {
let big = MAX_LITERAL_BYTES + 1;
let wire = format!("* 1 FETCH (BODY[] {{{big}}}\r\n");
let mut src: &[u8] = wire.as_bytes();
let mut lr = LineReader::new();
let err = lr
.read_response_with_literals(&mut src, "a001")
.expect_err("oversized literal must be rejected");
assert!(matches!(err, Error::BadResponse(_)));
}
#[test]
fn require_tls_ok_passes_when_upgraded_or_disabled() {
assert!(require_tls_ok(false, true).is_ok());
assert!(require_tls_ok(true, false).is_ok());
}
#[test]
fn require_tls_errors_on_plaintext_before_login() {
let script = b"* CAPABILITY IMAP4rev1 AUTH=PLAIN\r\n\
a001 OK CAPABILITY completed\r\n";
let mut io = MockIo::new(script);
let mut lr = LineReader::new();
let mut tagger = Tagger::new();
let caps = request_capability(&mut io, &mut lr, &mut tagger).expect("caps");
assert!(!caps.has("STARTTLS"), "server has no STARTTLS");
match require_tls_ok(true, true) {
Err(Error::BadResponse(m)) => assert!(m.contains("TLS required"), "got {m}"),
other => panic!("expected TLS required error, got {other:?}"),
}
let sent = io.sent();
assert!(sent.contains("a001 CAPABILITY\r\n"), "{sent:?}");
assert!(!sent.contains("LOGIN"), "no LOGIN on plaintext: {sent:?}");
assert!(
!sent.contains("AUTHENTICATE"),
"no AUTH on plaintext: {sent:?}"
);
}
#[test]
fn line_reader_handles_simple_tagged_response() {
let wire = b"* OK greeting\r\na001 OK LOGIN completed\r\n";
let mut src: &[u8] = wire;
let mut lr = LineReader::new();
let resp = lr.read_response(&mut src, "a001").unwrap();
assert!(resp.starts_with("* OK greeting"));
assert!(resp.contains("a001 OK LOGIN completed"));
}
}