use std::collections::HashMap;
use std::fs;
use std::io;
use std::net::{IpAddr, Ipv4Addr, SocketAddr};
use elektromail::{Server, ServerConfig};
use serde_json::Value;
use tempfile::tempdir;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
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 = build_request(method, path, body, &[]);
stream.write_all(request.as_bytes()).await?;
let mut response = String::new();
stream.read_to_string(&mut response).await?;
Ok(response)
}
async fn http_request_with_headers(
addr: SocketAddr,
method: &str,
path: &str,
body: &str,
headers: &[(&str, &str)],
) -> io::Result<String> {
let mut stream = TcpStream::connect(addr).await?;
let request = build_request(method, path, body, headers);
stream.write_all(request.as_bytes()).await?;
let mut response = String::new();
stream.read_to_string(&mut response).await?;
Ok(response)
}
fn build_request(method: &str, path: &str, body: &str, headers: &[(&str, &str)]) -> String {
let mut request = if body.is_empty() {
format!(
"{} {} HTTP/1.1\r\nHost: localhost\r\nConnection: close\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",
method,
path,
body.len()
)
};
for (key, value) in headers {
let _ = std::fmt::Write::write_fmt(&mut request, format_args!("{key}: {value}\r\n"));
}
request.push_str("\r\n");
if !body.is_empty() {
request.push_str(body);
}
request
}
fn http_addr() -> SocketAddr {
SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 0)
}
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 json_body(body: &str) -> Value {
serde_json::from_str(body).unwrap_or(Value::Null)
}
#[tokio::test]
async fn http_create_user_and_list() -> io::Result<()> {
let config = ServerConfig {
http_addr: Some(http_addr()),
..Default::default()
};
let server = Server::start(config).await?;
let http_addr = server.http_addr().expect("HTTP should be enabled");
let password = ["api", "pass"].concat();
let body = serde_json::json!({"user": "apiuser", "password": password}).to_string();
let response = http_request(http_addr, "POST", "/users", &body).await?;
let (status, _headers, _body) = parse_response(&response);
assert_eq!(status, 201, "User create should succeed: {}", response);
let response = http_request(http_addr, "GET", "/users", "").await?;
let (status, _headers, body) = parse_response(&response);
assert_eq!(status, 200, "Users list should succeed: {}", response);
let json = json_body(&body);
let users = json
.get("users")
.and_then(Value::as_array)
.cloned()
.unwrap_or_default();
assert!(users.iter().any(|u| u == "apiuser"));
server.stop().await?;
Ok(())
}
#[tokio::test]
async fn http_users_include_email() -> io::Result<()> {
let config = ServerConfig {
http_addr: Some(http_addr()),
..Default::default()
};
let server = Server::start(config).await?;
let http_addr = server.http_addr().expect("HTTP should be enabled");
let password = ["mail", "pass"].concat();
let body = serde_json::json!({
"user": "mailuser",
"password": password,
"email": "mailuser@example.com"
})
.to_string();
let response = http_request(http_addr, "POST", "/users", &body).await?;
let (status, _headers, _body) = parse_response(&response);
assert_eq!(status, 201);
let response = http_request(http_addr, "GET", "/users?include_email=true", "").await?;
let (status, _headers, body) = parse_response(&response);
assert_eq!(status, 200, "Users list should succeed: {}", response);
let json = json_body(&body);
let users = json
.get("users")
.and_then(Value::as_array)
.cloned()
.unwrap_or_default();
assert!(
users.iter().any(|entry| {
entry.get("user") == Some(&Value::from("mailuser"))
&& entry.get("email") == Some(&Value::from("mailuser@example.com"))
}),
"Expected user entry with email: {}",
response
);
server.stop().await?;
Ok(())
}
#[tokio::test]
async fn http_config_snapshot() -> io::Result<()> {
let config = ServerConfig {
http_addr: Some(http_addr()),
..Default::default()
};
let server = Server::start(config).await?;
let http_addr = server.http_addr().expect("HTTP should be enabled");
let response = http_request(http_addr, "GET", "/config", "").await?;
let (status, _headers, body) = parse_response(&response);
assert_eq!(status, 200, "Config should succeed: {}", response);
let json = json_body(&body);
assert!(
json.get("smtp_addr")
.and_then(Value::as_str)
.unwrap_or("")
.contains("127.0.0.1")
);
assert!(
json.get("imap_addr")
.and_then(Value::as_str)
.unwrap_or("")
.contains("127.0.0.1")
);
assert!(
json.get("http_addr")
.and_then(Value::as_str)
.unwrap_or("")
.contains("127.0.0.1")
);
assert!(json.get("smtp_port").is_some());
assert!(json.get("imap_port").is_some());
assert!(json.get("http_port").is_some());
assert!(json.get("smtp_enabled").is_some());
assert!(json.get("imap_enabled").is_some());
assert!(json.get("http_enabled").is_some());
assert!(json.get("smtp_starttls").is_some());
assert!(json.get("imap_starttls").is_some());
assert!(json.get("storage").is_some());
assert!(json.get("storage_path").is_some());
assert!(json.get("http_token_required").is_some());
assert!(json.get("auth_disabled").is_some());
assert!(json.get("user_count").is_some());
server.stop().await?;
Ok(())
}
#[tokio::test]
async fn http_purge_clears_messages_keeps_users() -> io::Result<()> {
let config = ServerConfig {
http_addr: Some(http_addr()),
..Default::default()
};
let server = Server::start(config).await?;
let http_addr = server.http_addr().expect("HTTP should be enabled");
let password = ["purge", "pass"].concat();
let body = serde_json::json!({"user": "purgeuser", "password": password}).to_string();
let response = http_request(http_addr, "POST", "/users", &body).await?;
let (status, _headers, _body) = parse_response(&response);
assert_eq!(status, 201, "User create should succeed: {}", response);
let response = http_request(
http_addr,
"POST",
"/inject",
r#"{"user": "purgeuser", "mailbox": "INBOX", "raw": "Subject: Purge Test\r\n\r\nBody"}"#,
)
.await?;
let (status, _headers, _body) = parse_response(&response);
assert_eq!(status, 201, "Inject should succeed: {}", response);
let response = http_request(
http_addr,
"GET",
"/messages?user=purgeuser&mailbox=INBOX",
"",
)
.await?;
let (status, _headers, body) = parse_response(&response);
assert_eq!(status, 200, "List should succeed: {}", response);
let json = json_body(&body);
let messages = json
.get("messages")
.and_then(Value::as_array)
.cloned()
.unwrap_or_default();
assert_eq!(messages.len(), 1);
let response = http_request(http_addr, "POST", "/purge", "").await?;
let (status, _headers, _body) = parse_response(&response);
assert_eq!(status, 200, "Purge should succeed: {}", response);
let response = http_request(
http_addr,
"GET",
"/messages?user=purgeuser&mailbox=INBOX",
"",
)
.await?;
let (status, _headers, body) = parse_response(&response);
assert_eq!(status, 200, "List should succeed: {}", response);
let json = json_body(&body);
let messages = json
.get("messages")
.and_then(Value::as_array)
.cloned()
.unwrap_or_default();
assert!(
messages.is_empty(),
"Messages should be purged: {}",
response
);
let response = http_request(http_addr, "GET", "/users", "").await?;
let (status, _headers, body) = parse_response(&response);
assert_eq!(status, 200, "Users list should succeed: {}", response);
let json = json_body(&body);
let users = json
.get("users")
.and_then(Value::as_array)
.cloned()
.unwrap_or_default();
assert!(users.iter().any(|u| u == "purgeuser"));
server.stop().await?;
Ok(())
}
#[tokio::test]
async fn http_reset_restores_preloaded_baseline() -> io::Result<()> {
let temp = tempdir()?;
let user_dir = temp.path().join("seed");
let inbox_dir = user_dir.join("INBOX");
fs::create_dir_all(&inbox_dir)?;
fs::write(
inbox_dir.join("seed.eml"),
"Subject: Seed\r\nFrom: seed@example.com\r\n\r\nHello.\r\n",
)?;
let config = ServerConfig {
http_addr: Some(http_addr()),
preload_dir: Some(temp.path().to_path_buf()),
..Default::default()
};
let server = Server::start(config).await?;
let http_addr = server.http_addr().expect("HTTP should be enabled");
let response = http_request(http_addr, "GET", "/messages?user=seed&mailbox=INBOX", "").await?;
let (status, _headers, body) = parse_response(&response);
assert_eq!(status, 200, "List should succeed: {}", response);
let json = json_body(&body);
let messages = json
.get("messages")
.and_then(Value::as_array)
.cloned()
.unwrap_or_default();
assert_eq!(messages.len(), 1, "Expected seed message: {}", response);
assert!(
messages
.iter()
.any(|msg| msg.get("subject") == Some(&Value::from("Seed"))),
"Expected seed subject: {}",
response
);
let response = http_request(
http_addr,
"POST",
"/inject",
r#"{"user": "seed", "mailbox": "INBOX", "raw": "Subject: Added\r\n\r\nBody"}"#,
)
.await?;
let (status, _headers, _body) = parse_response(&response);
assert_eq!(status, 201, "Inject should succeed: {}", response);
let response = http_request(http_addr, "POST", "/reset", "").await?;
let (status, _headers, _body) = parse_response(&response);
assert_eq!(status, 200, "Reset should succeed: {}", response);
let response = http_request(http_addr, "GET", "/messages?user=seed&mailbox=INBOX", "").await?;
let (status, _headers, body) = parse_response(&response);
assert_eq!(status, 200, "List should succeed: {}", response);
let json = json_body(&body);
let messages = json
.get("messages")
.and_then(Value::as_array)
.cloned()
.unwrap_or_default();
assert_eq!(messages.len(), 1, "Expected reset to seed: {}", response);
assert!(
messages
.iter()
.any(|msg| msg.get("subject") == Some(&Value::from("Seed"))),
"Expected seed subject after reset: {}",
response
);
server.stop().await?;
Ok(())
}
#[tokio::test]
async fn http_messages_pagination_and_raw() -> io::Result<()> {
let config = ServerConfig {
http_addr: Some(http_addr()),
..Default::default()
};
let server = Server::start(config).await?;
let http_addr = server.http_addr().expect("HTTP should be enabled");
let password = ["page", "pass"].concat();
let body = serde_json::json!({"user": "pageuser", "password": password}).to_string();
let response = http_request(http_addr, "POST", "/users", &body).await?;
let (status, _headers, _body) = parse_response(&response);
assert_eq!(status, 201, "User create should succeed: {}", response);
let subjects = ["One", "Two", "Three"];
for subject in subjects {
let body = format!(
"{{\"user\": \"pageuser\", \"mailbox\": \"INBOX\", \"raw\": \"Subject: {}\\r\\n\\r\\nBody\"}}",
subject
);
let response = http_request(http_addr, "POST", "/inject", &body).await?;
let (status, _headers, _body) = parse_response(&response);
assert_eq!(status, 201, "Inject should succeed: {}", response);
}
let response = http_request(
http_addr,
"GET",
"/messages?user=pageuser&mailbox=INBOX&limit=1&offset=1&include_raw=true",
"",
)
.await?;
let (status, _headers, body) = parse_response(&response);
assert_eq!(status, 200, "List should succeed: {}", response);
let json = json_body(&body);
let messages = json
.get("messages")
.and_then(Value::as_array)
.cloned()
.unwrap_or_default();
assert_eq!(messages.len(), 1, "Pagination should return one message");
let message = messages.first().cloned().unwrap_or(Value::Null);
assert_eq!(message.get("subject").and_then(Value::as_str), Some("Two"));
assert!(
message
.get("raw")
.and_then(Value::as_str)
.unwrap_or("")
.contains("Subject: Two")
);
server.stop().await?;
Ok(())
}
#[tokio::test]
async fn http_create_user_missing_fields() -> io::Result<()> {
let config = ServerConfig {
http_addr: Some(http_addr()),
..Default::default()
};
let server = Server::start(config).await?;
let http_addr = server.http_addr().expect("HTTP should be enabled");
let response = http_request(http_addr, "POST", "/users", r#"{"user": ""}"#).await?;
let (status, _headers, _body) = parse_response(&response);
assert_eq!(status, 400, "Missing fields should 400: {}", response);
server.stop().await?;
Ok(())
}
#[tokio::test]
async fn http_get_message_by_uid() -> io::Result<()> {
let config = ServerConfig {
http_addr: Some(http_addr()),
..Default::default()
};
let server = Server::start(config).await?;
let http_addr = server.http_addr().expect("HTTP should be enabled");
let password = ["uid", "pass"].concat();
let body = serde_json::json!({"user": "uiduser", "password": password}).to_string();
let response = http_request(http_addr, "POST", "/users", &body).await?;
let (status, _headers, _body) = parse_response(&response);
assert_eq!(status, 201);
let response = http_request(
http_addr,
"POST",
"/inject",
r#"{"user": "uiduser", "mailbox": "INBOX", "raw": "Subject: UID\r\n\r\nBody"}"#,
)
.await?;
let (status, _headers, _body) = parse_response(&response);
assert_eq!(status, 201);
let response = http_request(
http_addr,
"GET",
"/messages/1?user=uiduser&mailbox=INBOX&include_raw=true",
"",
)
.await?;
let (status, _headers, body) = parse_response(&response);
assert_eq!(status, 200, "Message should return: {}", response);
let json = json_body(&body);
let message = json.get("message").cloned().unwrap_or(Value::Null);
assert_eq!(message.get("subject").and_then(Value::as_str), Some("UID"));
assert!(
message
.get("raw")
.and_then(Value::as_str)
.unwrap_or("")
.contains("Subject: UID")
);
server.stop().await?;
Ok(())
}
#[tokio::test]
async fn http_get_message_not_found() -> io::Result<()> {
let config = ServerConfig {
http_addr: Some(http_addr()),
..Default::default()
};
let server = Server::start(config).await?;
let http_addr = server.http_addr().expect("HTTP should be enabled");
let response = http_request(
http_addr,
"GET",
"/messages/99?user=nouser&mailbox=INBOX",
"",
)
.await?;
let (status, _headers, _body) = parse_response(&response);
assert_eq!(status, 404, "Missing message should 404: {}", response);
server.stop().await?;
Ok(())
}
#[tokio::test]
async fn http_requires_token_when_configured() -> io::Result<()> {
let config = ServerConfig {
http_addr: Some(http_addr()),
http_token: Some("secret-token".to_string()),
..Default::default()
};
let server = Server::start(config).await?;
let http_addr = server.http_addr().expect("HTTP should be enabled");
let response = http_request(http_addr, "GET", "/config", "").await?;
let (status, _headers, _body) = parse_response(&response);
assert_eq!(status, 401, "Missing token should 401: {}", response);
let response = http_request_with_headers(
http_addr,
"GET",
"/config",
"",
&[("Authorization", "Bearer secret-token")],
)
.await?;
let (status, _headers, _body) = parse_response(&response);
assert_eq!(status, 200, "Token should allow access: {}", response);
server.stop().await?;
Ok(())
}