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;
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(PlainStream(sock), url)
}
"imaps" => {
let sock = TcpStream::connect((url.host.as_str(), url.port))?;
let tls = connect_over(sock, &url.host)?;
run(TlsRw(tls), url)
}
other => Err(Error::UnsupportedScheme(other.to_string())),
}
}
trait ImapIo: Read + Write {}
struct PlainStream(TcpStream);
impl Read for PlainStream {
fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
self.0.read(buf)
}
}
impl Write for PlainStream {
fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
self.0.write(buf)
}
fn flush(&mut self) -> io::Result<()> {
self.0.flush()
}
}
impl ImapIo for PlainStream {}
struct TlsRw(TlsStream<TcpStream>);
impl Read for TlsRw {
fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
self.0.read(buf)
}
}
impl Write for TlsRw {
fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
self.0.write(buf)
}
fn flush(&mut self) -> io::Result<()> {
self.0.flush()
}
}
impl ImapIo for TlsRw {}
fn run<S: ImapIo>(mut sock: S, 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 mut tagger = Tagger::new();
if let Some(userinfo) = url.userinfo.as_deref() {
let (user, pass) = split_userinfo(userinfo);
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(&mut sock, &tag)?;
require_ok(&resp, &tag, "LOGIN")?;
}
let (mailbox, uid) = parse_path(&url.path);
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 select_mailbox<S: ImapIo>(
sock: &mut S,
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 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.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 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 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_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"));
}
}