use std::collections::HashMap;
use std::io;
use std::net::{IpAddr, Ipv4Addr, SocketAddr};
use base64::{Engine as _, engine::general_purpose};
use elektromail::{AuthConfig, Server, ServerConfig};
use tokio::io::{AsyncBufReadExt, AsyncReadExt, AsyncWriteExt, BufReader};
use tokio::net::TcpStream;
async fn http_request(
addr: SocketAddr,
method: &str,
path: &str,
body: &str,
) -> io::Result<String> {
let mut stream = TcpStream::connect(addr).await?;
let request = if body.is_empty() {
format!(
"{} {} HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n",
method, path
)
} else {
format!(
"{} {} HTTP/1.1\r\nHost: localhost\r\nContent-Length: {}\r\nContent-Type: application/json\r\nConnection: close\r\n\r\n{}",
method,
path,
body.len(),
body
)
};
stream.write_all(request.as_bytes()).await?;
let mut response = String::new();
stream.read_to_string(&mut response).await?;
Ok(response)
}
fn parse_response(response: &str) -> (u16, HashMap<String, String>, String) {
let (head, body) = response.split_once("\r\n\r\n").unwrap_or((response, ""));
let mut lines = head.split("\r\n");
let status_line = lines.next().unwrap_or("");
let status_code = status_line
.split_whitespace()
.nth(1)
.and_then(|code| code.parse::<u16>().ok())
.unwrap_or(0);
let mut headers = HashMap::new();
for line in lines {
if let Some((key, value)) = line.split_once(':') {
headers.insert(key.trim().to_ascii_lowercase(), value.trim().to_string());
}
}
(status_code, headers, body.to_string())
}
fn http_addr() -> SocketAddr {
SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 0)
}
async fn imap_login(addr: SocketAddr, user: &str, pass: &str) -> io::Result<String> {
let stream = TcpStream::connect(addr).await?;
let (reader, mut writer) = stream.into_split();
let mut reader = BufReader::new(reader);
let mut line = String::new();
reader.read_line(&mut line).await?;
writer
.write_all(format!("a1 LOGIN {} {}\r\n", user, pass).as_bytes())
.await?;
let mut response = String::new();
loop {
line.clear();
let bytes = reader.read_line(&mut line).await?;
if bytes == 0 {
break;
}
response.push_str(&line);
if line.starts_with("a1 ") {
break;
}
}
writer.write_all(b"a2 LOGOUT\r\n").await?;
line.clear();
let _ = reader.read_line(&mut line).await?;
line.clear();
let _ = reader.read_line(&mut line).await?;
Ok(response)
}
async fn smtp_auth(addr: SocketAddr, user: &str, pass: &str) -> io::Result<String> {
let stream = TcpStream::connect(addr).await?;
let (reader, mut writer) = stream.into_split();
let mut reader = BufReader::new(reader);
let mut line = String::new();
reader.read_line(&mut line).await?;
writer.write_all(b"EHLO localhost\r\n").await?;
loop {
line.clear();
let bytes = reader.read_line(&mut line).await?;
if bytes == 0 {
break;
}
if line.starts_with("250 ") {
break;
}
}
let auth = general_purpose::STANDARD.encode(format!("\0{}\0{}", user, pass));
writer
.write_all(format!("AUTH PLAIN {}\r\n", auth).as_bytes())
.await?;
line.clear();
let _ = reader.read_line(&mut line).await?;
let response = line.clone();
writer.write_all(b"QUIT\r\n").await?;
line.clear();
let _ = reader.read_line(&mut line).await?;
Ok(response)
}
#[tokio::test]
async fn runtime_user_add_allows_imap_and_smtp_auth() -> io::Result<()> {
let config = ServerConfig {
http_addr: Some(http_addr()),
auth: Some(AuthConfig::from_users("seed:seedpass")),
..Default::default()
};
let server = Server::start(config).await?;
let http_addr = server.http_addr().expect("HTTP should be enabled");
let imap_addr = server.imap_addr();
let smtp_addr = server.smtp_addr();
let imap_before = imap_login(imap_addr, "runtime", "runtimepass").await?;
assert!(
imap_before.contains("a1 NO"),
"Expected IMAP auth failure: {}",
imap_before
);
let smtp_before = smtp_auth(smtp_addr, "runtime", "runtimepass").await?;
assert!(
smtp_before.contains("535"),
"Expected SMTP auth failure: {}",
smtp_before
);
let response = http_request(
http_addr,
"POST",
"/users",
r#"{"user":"runtime","password":"runtimepass"}"#,
)
.await?;
let (status, _headers, _body) = parse_response(&response);
assert_eq!(status, 201, "User create should succeed: {}", response);
let imap_after = imap_login(imap_addr, "runtime", "runtimepass").await?;
assert!(
imap_after.contains("a1 OK"),
"Expected IMAP auth success: {}",
imap_after
);
let smtp_after = smtp_auth(smtp_addr, "runtime", "runtimepass").await?;
assert!(
smtp_after.contains("235"),
"Expected SMTP auth success: {}",
smtp_after
);
server.stop().await?;
Ok(())
}