elektromail 0.1.1

A minimal, Rust-based IMAP + SMTP mail server for local development and testing
Documentation
//! Integration tests for the HTTP control plane.

use std::collections::HashMap;
use std::io;
use std::net::{IpAddr, Ipv4Addr, SocketAddr};

use elektromail::{Server, ServerConfig};
use serde_json::Value;
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 = 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 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_reset_clears_state() -> 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");

    // Create a user
    let password = ["test", "pass"].concat();
    let create_body = serde_json::json!({"user": "testuser", "password": password}).to_string();
    let response = http_request(http_addr, "POST", "/users", &create_body).await?;
    let (status, _headers, _body) = parse_response(&response);
    assert_eq!(status, 201, "User create should succeed: {}", response);

    // Inject a message
    let inject_body =
        r#"{"user": "testuser", "mailbox": "INBOX", "raw": "Subject: Test\r\n\r\nBody"}"#;
    let response = http_request(http_addr, "POST", "/inject", inject_body).await?;
    let (status, _headers, body) = parse_response(&response);
    assert_eq!(status, 201, "Inject should succeed: {}", response);
    let json = json_body(&body);
    assert!(json.get("uid").is_some(), "Should return UID: {}", response);

    // Check users list
    let response = http_request(http_addr, "GET", "/users", "").await?;
    let (status, _headers, body) = parse_response(&response);
    assert_eq!(status, 200, "Users should list: {}", 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 == "testuser"),
        "User should exist: {}",
        response
    );

    // Reset
    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 json = json_body(&body);
    assert_eq!(json.get("status").and_then(Value::as_str), Some("ok"));

    // Verify users cleared
    let response = http_request(http_addr, "GET", "/users", "").await?;
    let (status, _headers, body) = parse_response(&response);
    assert_eq!(status, 200, "Users should list: {}", 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 == "testuser"),
        "Users should be cleared: {}",
        response
    );

    server.stop().await?;
    Ok(())
}

#[tokio::test]
async fn http_inject_and_list_messages() -> 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");

    // Inject a message
    let inject_body = r#"{"user": "alice", "mailbox": "INBOX", "raw": "Subject: Hello World\r\nFrom: sender@test.com\r\n\r\nMessage body"}"#;
    let response = http_request(http_addr, "POST", "/inject", inject_body).await?;
    let (status, _headers, _body) = parse_response(&response);
    assert_eq!(status, 201, "Inject should succeed: {}", response);

    // List messages
    let response = http_request(http_addr, "GET", "/messages?user=alice&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
            .iter()
            .any(|msg| msg.get("subject") == Some(&Value::from("Hello World"))),
        "Should contain subject: {}",
        response
    );
    assert!(
        messages
            .iter()
            .any(|msg| msg.get("from") == Some(&Value::from("sender@test.com"))),
        "Should contain from: {}",
        response
    );

    server.stop().await?;
    Ok(())
}

#[tokio::test]
async fn http_delete_message() -> 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");

    // Inject a message
    let inject_body =
        r#"{"user": "bob", "mailbox": "INBOX", "raw": "Subject: Delete me\r\n\r\nBody"}"#;
    let response = http_request(http_addr, "POST", "/inject", inject_body).await?;
    let (status, _headers, body) = parse_response(&response);
    assert_eq!(status, 201, "Inject should succeed: {}", response);
    let json = json_body(&body);
    assert_eq!(json.get("uid").and_then(Value::as_u64), Some(1));

    // Verify message exists
    let response = http_request(http_addr, "GET", "/messages?user=bob&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
            .iter()
            .any(|msg| msg.get("subject") == Some(&Value::from("Delete me"))),
        "Message should exist: {}",
        response
    );

    // Delete the message
    let response = http_request(
        http_addr,
        "DELETE",
        "/messages?user=bob&mailbox=INBOX&uid=1",
        "",
    )
    .await?;
    let (status, _headers, _body) = parse_response(&response);
    assert_eq!(status, 200, "Delete should succeed: {}", response);

    // Verify message is gone
    let response = http_request(http_addr, "GET", "/messages?user=bob&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
            .iter()
            .any(|msg| msg.get("subject") == Some(&Value::from("Delete me"))),
        "Message should be deleted: {}",
        response
    );

    server.stop().await?;
    Ok(())
}

#[tokio::test]
async fn http_inject_invalid_json() -> 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", "/inject", "{").await?;
    let (status, _headers, body) = parse_response(&response);
    assert_eq!(status, 400, "Invalid JSON should 400: {}", response);
    let json = json_body(&body);
    assert_eq!(
        json.get("error").and_then(Value::as_str),
        Some("Invalid JSON")
    );

    server.stop().await?;
    Ok(())
}

#[tokio::test]
async fn http_404_for_unknown_path() -> 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", "/unknown", "").await?;
    let (status, _headers, _body) = parse_response(&response);
    assert_eq!(status, 404, "Unknown path should 404: {}", response);

    server.stop().await?;
    Ok(())
}

#[tokio::test]
async fn http_405_for_invalid_method() -> 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", "/reset", "").await?;
    let (status, _headers, _body) = parse_response(&response);
    assert_eq!(status, 405, "GET /reset should be 405: {}", response);

    let response = http_request(http_addr, "POST", "/config", "").await?;
    let (status, _headers, _body) = parse_response(&response);
    assert_eq!(status, 405, "POST /config should be 405: {}", response);

    server.stop().await?;
    Ok(())
}