elektromail 0.1.1

A minimal, Rust-based IMAP + SMTP mail server for local development and testing
Documentation
//! Integration tests for OpenAPI spec and docs UI.

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) -> io::Result<String> {
    http_request_with_headers(addr, method, path, &[]).await
}

async fn http_request_with_headers(
    addr: SocketAddr,
    method: &str,
    path: &str,
    headers: &[(&str, &str)],
) -> io::Result<String> {
    let mut stream = TcpStream::connect(addr).await?;

    let request = build_request(method, path, 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, headers: &[(&str, &str)]) -> String {
    let mut request = format!(
        "{} {} HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n",
        method, path
    );
    for (key, value) in headers {
        let _ = std::fmt::Write::write_fmt(&mut request, format_args!("{key}: {value}\r\n"));
    }
    request.push_str("\r\n");
    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())
}

#[tokio::test]
async fn http_openapi_json_served() -> 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", "/openapi.json").await?;
    let (status, headers, body) = parse_response(&response);
    assert_eq!(status, 200, "Should return 200: {}", response);
    let content_type = headers.get("content-type").cloned().unwrap_or_default();
    assert!(
        content_type
            .to_ascii_lowercase()
            .contains("application/json"),
        "Should return JSON content type: {}",
        response
    );
    let json: Value = serde_json::from_str(&body).unwrap_or(Value::Null);
    assert!(json.get("openapi").is_some(), "Should contain OpenAPI");

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

#[tokio::test]
async fn http_docs_html_served() -> 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", "/docs").await?;
    let (status, headers, body) = parse_response(&response);
    assert_eq!(status, 200, "Should return 200: {}", response);
    let content_type = headers.get("content-type").cloned().unwrap_or_default();
    assert!(
        content_type.to_ascii_lowercase().contains("text/html"),
        "Should return HTML content type: {}",
        response
    );
    assert!(
        body.contains("SwaggerUIBundle"),
        "Should include Swagger UI bundle: {}",
        response
    );

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

#[tokio::test]
async fn http_docs_require_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", "/docs").await?;
    let (status, _headers, _body) = parse_response(&response);
    assert_eq!(status, 401, "Docs should require token: {}", response);

    let response = http_request(http_addr, "GET", "/openapi.json").await?;
    let (status, _headers, _body) = parse_response(&response);
    assert_eq!(status, 401, "OpenAPI should require token: {}", response);

    let response = http_request(http_addr, "GET", "/docs?token=secret-token").await?;
    let (status, _headers, body) = parse_response(&response);
    assert_eq!(status, 200, "Docs should allow token query: {}", response);
    assert!(
        body.contains("requestInterceptor"),
        "Docs should inject auth interceptor: {}",
        response
    );

    let response = http_request(http_addr, "GET", "/openapi.json?token=secret-token").await?;
    let (status, _headers, body) = parse_response(&response);
    assert_eq!(
        status, 200,
        "OpenAPI should allow token query: {}",
        response
    );
    let json: Value = serde_json::from_str(&body).unwrap_or(Value::Null);
    assert!(json.get("openapi").is_some(), "Should contain OpenAPI");

    let response = http_request_with_headers(
        http_addr,
        "GET",
        "/openapi.json",
        &[("Authorization", "Bearer secret-token")],
    )
    .await?;
    let (status, _headers, _body) = parse_response(&response);
    assert_eq!(
        status, 200,
        "OpenAPI should allow auth header: {}",
        response
    );

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