use std::io::{BufRead, BufReader, Read, Write};
use std::net::TcpStream;
use crate::error::{Error, Result};
use crate::tls::{connect_over, TlsStream};
use crate::url::Url;
pub fn fetch(url: &Url) -> Result<Vec<u8>> {
let userinfo = url
.userinfo
.as_deref()
.ok_or_else(|| Error::BadResponse("pop3: missing userinfo".into()))?;
let (user, pass) = split_userinfo(userinfo);
let action = parse_path(&url.path)
.ok_or_else(|| Error::InvalidUrl(format!("pop3 path: {}", url.path)))?;
let tcp = TcpStream::connect((url.host.as_str(), url.port))?;
if url.is_tls() {
let tls = connect_over(tcp, &url.host)?;
let mut session = Session::new(BufReader::new(IoAdapter::Tls(tls)));
run(&mut session, user, pass, action)
} else {
let mut session = Session::new(BufReader::new(IoAdapter::Plain(tcp)));
run(&mut session, user, pass, action)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Action {
List,
Retr(u32),
}
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<Action> {
let trimmed = path.strip_prefix('/').unwrap_or(path);
if trimmed.is_empty() {
return Some(Action::List);
}
if trimmed.contains('/') || trimmed.contains('?') || trimmed.contains('#') {
return None;
}
trimmed.parse::<u32>().ok().map(Action::Retr)
}
fn un_dot_stuff(body: &[u8]) -> Vec<u8> {
let mut out = Vec::with_capacity(body.len());
let mut i = 0;
let mut at_line_start = true;
while i < body.len() {
if at_line_start && body[i] == b'.' {
i += 1;
at_line_start = false;
continue;
}
let b = body[i];
out.push(b);
i += 1;
at_line_start = b == b'\n';
}
out
}
enum IoAdapter {
Plain(TcpStream),
Tls(TlsStream<TcpStream>),
}
impl Read for IoAdapter {
fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
match self {
IoAdapter::Plain(s) => s.read(buf),
IoAdapter::Tls(s) => s.read(buf),
}
}
}
impl Write for IoAdapter {
fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
match self {
IoAdapter::Plain(s) => s.write(buf),
IoAdapter::Tls(s) => s.write(buf),
}
}
fn flush(&mut self) -> std::io::Result<()> {
match self {
IoAdapter::Plain(s) => s.flush(),
IoAdapter::Tls(s) => s.flush(),
}
}
}
struct Session<R: Read + Write> {
io: BufReader<R>,
}
impl<R: Read + Write> Session<R> {
fn new(io: BufReader<R>) -> Self {
Self { io }
}
fn send(&mut self, cmd: &str) -> Result<()> {
let inner = self.io.get_mut();
inner.write_all(cmd.as_bytes())?;
inner.write_all(b"\r\n")?;
inner.flush()?;
Ok(())
}
fn read_line(&mut self) -> Result<String> {
let mut buf = Vec::new();
let n = self.io.read_until(b'\n', &mut buf)?;
if n == 0 {
return Err(Error::UnexpectedEof);
}
while matches!(buf.last(), Some(b'\n') | Some(b'\r')) {
buf.pop();
}
String::from_utf8(buf).map_err(|_| Error::BadResponse("pop3: non-UTF8 status line".into()))
}
fn read_status(&mut self) -> Result<String> {
let line = self.read_line()?;
if let Some(rest) = line.strip_prefix("+OK") {
Ok(rest.strip_prefix(' ').unwrap_or(rest).to_string())
} else if let Some(rest) = line.strip_prefix("-ERR") {
let text = rest.strip_prefix(' ').unwrap_or(rest);
Err(Error::BadResponse(format!("pop3: {text}")))
} else {
Err(Error::BadResponse(format!(
"pop3: unexpected status line: {line}"
)))
}
}
fn read_multiline(&mut self) -> Result<Vec<u8>> {
let mut out = Vec::new();
loop {
let mut line = Vec::new();
let n = self.io.read_until(b'\n', &mut line)?;
if n == 0 {
return Err(Error::UnexpectedEof);
}
let is_terminator = matches!(line.as_slice(), b".\r\n" | b".\n");
if is_terminator {
return Ok(out);
}
out.extend_from_slice(&line);
}
}
}
fn run<R: Read + Write>(
session: &mut Session<R>,
user: &str,
pass: &str,
action: Action,
) -> Result<Vec<u8>> {
session.read_status()?;
session.send(&format!("USER {user}"))?;
session.read_status()?;
session.send(&format!("PASS {pass}"))?;
session.read_status()?;
let payload = match action {
Action::List => {
session.send("LIST")?;
session.read_status()?;
session.read_multiline()?
}
Action::Retr(n) => {
session.send(&format!("RETR {n}"))?;
session.read_status()?;
let raw = session.read_multiline()?;
un_dot_stuff(&raw)
}
};
let _ = session.send("QUIT");
let _ = session.read_status();
Ok(payload)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn un_dot_stuff_strips_leading_dot_on_each_line() {
let input = b"hello\r\n..dotted\r\n.line\r\nplain\r\n";
let got = un_dot_stuff(input);
assert_eq!(got, b"hello\r\n.dotted\r\nline\r\nplain\r\n");
}
#[test]
fn un_dot_stuff_handles_empty_body() {
assert_eq!(un_dot_stuff(b""), b"");
}
#[test]
fn un_dot_stuff_handles_first_line_dot() {
let input = b"..first\r\nbody\r\n";
assert_eq!(un_dot_stuff(input), b".first\r\nbody\r\n");
}
#[test]
fn un_dot_stuff_does_not_consume_dot_in_middle() {
let input = b"a.b\r\n.x\r\n";
assert_eq!(un_dot_stuff(input), b"a.b\r\nx\r\n");
}
#[test]
fn un_dot_stuff_handles_trailing_partial_line() {
let input = b".end";
assert_eq!(un_dot_stuff(input), b"end");
}
#[test]
fn parse_path_root_means_list() {
assert_eq!(parse_path("/"), Some(Action::List));
assert_eq!(parse_path(""), Some(Action::List));
}
#[test]
fn parse_path_numeric_means_retr() {
assert_eq!(parse_path("/1"), Some(Action::Retr(1)));
assert_eq!(parse_path("/42"), Some(Action::Retr(42)));
assert_eq!(parse_path("/0"), Some(Action::Retr(0)));
}
#[test]
fn parse_path_rejects_garbage() {
assert_eq!(parse_path("/abc"), None);
assert_eq!(parse_path("/1/2"), None);
assert_eq!(parse_path("/1?x=1"), None);
assert_eq!(parse_path("/-1"), None);
assert_eq!(parse_path("/1.0"), None);
}
#[test]
fn split_userinfo_splits_on_first_colon() {
assert_eq!(split_userinfo("alice:secret"), ("alice", "secret"));
assert_eq!(split_userinfo("alice"), ("alice", ""));
assert_eq!(split_userinfo("alice:s:e:c"), ("alice", "s:e:c"));
assert_eq!(split_userinfo(":pass"), ("", "pass"));
}
}