use std::io::{self, Read, Write};
use std::net::TcpStream;
use crate::error::{Error, Result};
use crate::tls::{connect_over, TlsStream};
use crate::url::Url;
use crate::websocket::base64_encode;
const MAX_LITERAL_BYTES: usize = 64 * 1024 * 1024;
pub fn fetch(url: &Url) -> Result<Vec<u8>> {
match url.scheme.as_str() {
"imap" => {
let sock = TcpStream::connect((url.host.as_str(), url.port))?;
run(Stream::Plain(sock), url)
}
"imaps" => {
let sock = TcpStream::connect((url.host.as_str(), url.port))?;
let tls = connect_over(sock, &url.host)?;
run(Stream::Tls(Box::new(tls)), url)
}
other => Err(Error::UnsupportedScheme(other.to_string())),
}
}
enum Stream {
Plain(TcpStream),
Tls(Box<TlsStream<TcpStream>>),
Poisoned,
}
impl Read for Stream {
fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
match self {
Stream::Plain(s) => s.read(buf),
Stream::Tls(s) => s.read(buf),
Stream::Poisoned => Err(io::Error::other("imap: stream poisoned")),
}
}
}
impl Write for Stream {
fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
match self {
Stream::Plain(s) => s.write(buf),
Stream::Tls(s) => s.write(buf),
Stream::Poisoned => Err(io::Error::other("imap: stream poisoned")),
}
}
fn flush(&mut self) -> io::Result<()> {
match self {
Stream::Plain(s) => s.flush(),
Stream::Tls(s) => s.flush(),
Stream::Poisoned => Err(io::Error::other("imap: stream poisoned")),
}
}
}
impl Stream {
fn is_plain(&self) -> bool {
matches!(self, Stream::Plain(_))
}
fn start_tls(&mut self, host: &str) -> Result<()> {
let plain = match std::mem::replace(self, Stream::Poisoned) {
Stream::Plain(s) => s,
other => {
*self = other;
return Err(Error::BadResponse(
"imap: STARTTLS requested on a non-plaintext connection".into(),
));
}
};
let tls = connect_over(plain, host)?;
*self = Stream::Tls(Box::new(tls));
Ok(())
}
}
fn run(mut sock: Stream, url: &Url) -> 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")?;
sock.start_tls(&url.host)?;
caps = request_capability(&mut sock, &mut buf, &mut tagger)?;
}
if !preauth {
if let Some(userinfo) = url.userinfo.as_deref() {
let (user, pass) = split_userinfo(userinfo);
reject_ctl(user, "imap user")?;
reject_ctl(pass, "imap password")?;
authenticate(&mut sock, &mut buf, &mut tagger, &caps, user, pass)?;
}
}
let (mailbox, uid) = parse_path(&url.path);
if let Some(mbox) = mailbox.as_deref() {
reject_ctl(mbox, "imap mailbox")?;
}
let body = match (mailbox.as_deref(), uid) {
(None, 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) => {
select_mailbox(&mut sock, &mut buf, &mut tagger, mbox)?;
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), Some(n)) => {
select_mailbox(&mut sock, &mut buf, &mut tagger, mbox)?;
let tag = tagger.next();
let cmd = format!("{tag} UID FETCH {n} BODY[]\r\n");
sock.write_all(cmd.as_bytes())?;
sock.flush()?;
let (resp, literals) = buf.read_response_with_literals(&mut sock, &tag)?;
require_ok(&resp, &tag, "UID FETCH")?;
literals.into_iter().next().unwrap_or_default()
}
(None, Some(_)) => {
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()
}
};
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 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, 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,
user: &str,
pass: &str,
) -> Result<()> {
let tag = tagger.next();
let initial = sasl_plain_initial(user, pass);
let cmd = format!("{tag} AUTHENTICATE PLAIN {initial}\r\n");
sock.write_all(cmd.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 {
let last = resp.lines().rev().find(|l| !l.is_empty()).unwrap_or("");
last.starts_with(&format!("{tag} ")) || last == tag
}
#[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 {
let needle = cap.to_ascii_uppercase();
self.set.contains(&needle)
}
fn is_empty(&self) -> bool {
self.set.is_empty()
}
}
fn parse_capability_code(line: &str) -> Option<Vec<String>> {
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<String> = toks.map(|s| s.to_string()).collect();
if caps.is_empty() {
None
} else {
Some(caps)
}
}
fn parse_capability_line(resp: &str) -> Option<Vec<String>> {
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<String> = toks.map(|s| s.to_string()).collect();
if !caps.is_empty() {
return Some(caps);
}
}
}
}
}
None
}
fn select_mailbox(
sock: &mut Stream,
buf: &mut LineReader,
tagger: &mut Tagger,
mbox: &str,
) -> 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")
}
fn require_ok(resp: &str, tag: &str, what: &str) -> Result<()> {
let last = resp.lines().rev().find(|l| !l.is_empty()).unwrap_or("");
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<()> {
if let Some(b) = s.bytes().find(|b| *b < 0x20 || *b == 0x7f) {
return Err(Error::BadResponse(format!(
"imap: {what} contains illegal control byte {b:#04x}"
)));
}
Ok(())
}
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
}
fn split_userinfo(s: &str) -> (&str, &str) {
match s.find(':') {
Some(i) => (&s[..i], &s[i + 1..]),
None => (s, ""),
}
}
fn parse_path(path: &str) -> (Option<String>, Option<u32>) {
let trimmed = path.strip_prefix('/').unwrap_or(path);
if trimmed.is_empty() {
return (None, None);
}
let (mbox_part, params) = match trimmed.find(';') {
Some(i) => (&trimmed[..i], &trimmed[i + 1..]),
None => (trimmed, ""),
};
let mut uid = None;
for param in params.split(';') {
if let Some(v) = param
.strip_prefix("UID=")
.or_else(|| param.strip_prefix("uid="))
{
if let Ok(n) = v.parse::<u32>() {
uid = Some(n);
}
}
}
let mbox = if mbox_part.is_empty() {
None
} else {
Some(percent_decode(mbox_part))
};
(mbox, uid)
}
fn percent_decode(s: &str) -> String {
let bytes = s.as_bytes();
let mut out = Vec::with_capacity(bytes.len());
let mut i = 0;
while i < bytes.len() {
if bytes[i] == b'%' && i + 2 < bytes.len() {
if let (Some(h), Some(l)) = (hex(bytes[i + 1]), hex(bytes[i + 2])) {
out.push((h << 4) | l);
i += 3;
continue;
}
}
out.push(bytes[i]);
i += 1;
}
String::from_utf8(out).unwrap_or_else(|e| {
String::from_utf8_lossy(&e.into_bytes()).into_owned()
})
}
fn hex(b: u8) -> Option<u8> {
match b {
b'0'..=b'9' => Some(b - b'0'),
b'a'..=b'f' => Some(10 + b - b'a'),
b'A'..=b'F' => Some(10 + b - b'A'),
_ => None,
}
}
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 read_line<S: Read>(&mut self, sock: &mut S) -> Result<String> {
let mut tmp = [0u8; 4096];
loop {
if let Some(pos) = find_crlf(&self.buf) {
let line_bytes: Vec<u8> = self.buf.drain(..pos + 2).collect();
let without_crlf = &line_bytes[..line_bytes.len() - 2];
return Ok(String::from_utf8_lossy(without_crlf).into_owned());
}
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 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}"
)));
}
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 trimmed.starts_with(&format!("{tag} ")) || 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::*;
#[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 userinfo_with_and_without_password() {
assert_eq!(split_userinfo("alice:secret"), ("alice", "secret"));
assert_eq!(split_userinfo("alice"), ("alice", ""));
assert_eq!(split_userinfo("alice:"), ("alice", ""));
assert_eq!(split_userinfo(":only-pass"), ("", "only-pass"));
}
#[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!(parse_path("/"), (None, None));
assert_eq!(parse_path(""), (None, None));
}
#[test]
fn parse_path_mailbox_only() {
assert_eq!(parse_path("/INBOX"), (Some("INBOX".into()), None));
assert_eq!(parse_path("/Stuff/Sub"), (Some("Stuff/Sub".into()), None));
}
#[test]
fn parse_path_mailbox_with_uid() {
assert_eq!(
parse_path("/INBOX;UID=42"),
(Some("INBOX".into()), Some(42))
);
assert_eq!(
parse_path("/Drafts;uid=7"),
(Some("Drafts".into()), Some(7))
);
assert_eq!(
parse_path("/Stuff/Sub;UID=999"),
(Some("Stuff/Sub".into()), Some(999))
);
}
#[test]
fn parse_path_ignores_unknown_params() {
assert_eq!(
parse_path("/INBOX;TYPE=LIST;UID=5"),
(Some("INBOX".into()), Some(5))
);
}
#[test]
fn parse_path_percent_decodes_mailbox() {
assert_eq!(parse_path("/My%20Mail"), (Some("My Mail".into()), None));
assert_eq!(parse_path("/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, "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_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 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 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 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"));
}
}