elektromail 0.1.1

A minimal, Rust-based IMAP + SMTP mail server for local development and testing
Documentation
//! Runtime auth store integration tests.

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(())
}