use std::collections::BTreeSet;
use std::net::SocketAddr;
use std::sync::{Arc, Mutex};
use axum::{
Json, Router,
body::Body,
extract::{Path, Query, State},
http::{StatusCode, header},
middleware::{Next, from_fn_with_state},
response::{IntoResponse, Response},
routing::{get, post},
};
use serde::{Deserialize, Serialize};
use tokio::{net::TcpListener, sync::broadcast};
use crate::auth::SharedUserStore;
use crate::store::{StorageBackend, Store, StoreSnapshot, current_internal_date};
const OPENAPI_JSON: &str = include_str!("../assets/openapi.json");
const DOCS_HTML_BASE: &str = r#"<!doctype html>
<html>
<head>
<meta charset="utf-8">
<title>Elektromail API</title>
<link rel="stylesheet" href="https://unpkg.com/swagger-ui-dist@5/swagger-ui.css">
<style>
body { margin: 0; background: #f5f5f5; }
#swagger-ui { max-width: 960px; margin: 0 auto; padding: 24px; }
</style>
</head>
<body>
<div id="swagger-ui"></div>
<script src="https://unpkg.com/swagger-ui-dist@5/swagger-ui-bundle.js"></script>
<script>
window.onload = function() {
SwaggerUIBundle(__SWAGGER_CONFIG__);
};
</script>
</body>
</html>
"#;
#[derive(Clone)]
pub(crate) struct HttpMeta {
pub smtp_addr: SocketAddr,
pub imap_addr: SocketAddr,
pub http_addr: SocketAddr,
pub storage: StorageBackend,
pub http_token: Option<String>,
}
#[derive(Clone)]
struct HttpState {
store: Arc<Mutex<Store>>,
auth: SharedUserStore,
meta: HttpMeta,
seed_snapshot: StoreSnapshot,
}
#[derive(Serialize)]
struct StatusResponse {
status: String,
}
#[derive(Serialize)]
struct ErrorResponse {
error: String,
}
#[derive(Serialize)]
struct InjectResponse {
uid: u32,
}
#[derive(Serialize)]
struct UsersResponse {
users: Vec<String>,
}
#[derive(Serialize)]
struct UserEntry {
user: String,
#[serde(skip_serializing_if = "Option::is_none")]
email: Option<String>,
}
#[derive(Serialize)]
struct UsersDetailResponse {
users: Vec<UserEntry>,
}
#[derive(Serialize)]
struct ConfigResponse {
smtp_addr: String,
imap_addr: String,
http_addr: String,
smtp_port: u16,
imap_port: u16,
http_port: u16,
smtp_enabled: bool,
imap_enabled: bool,
http_enabled: bool,
smtp_starttls: bool,
imap_starttls: bool,
storage: String,
storage_path: Option<String>,
http_token_required: bool,
auth_disabled: bool,
user_count: usize,
}
#[derive(Serialize)]
struct MessageEntry {
uid: u32,
size: usize,
subject: String,
from: String,
#[serde(skip_serializing_if = "Option::is_none")]
raw: Option<String>,
}
#[derive(Serialize)]
struct MessagesResponse {
messages: Vec<MessageEntry>,
}
#[derive(Serialize)]
struct MessageResponse {
message: MessageEntry,
}
#[derive(Deserialize)]
struct InjectRequest {
user: String,
mailbox: Option<String>,
raw: String,
}
#[derive(Deserialize)]
struct CreateUserRequest {
user: String,
password: String,
email: Option<String>,
}
#[derive(Deserialize)]
struct MessagesQuery {
user: Option<String>,
mailbox: Option<String>,
uid: Option<String>,
limit: Option<String>,
offset: Option<String>,
include_raw: Option<String>,
}
#[derive(Deserialize)]
struct UsersQuery {
include_email: Option<String>,
}
#[derive(Deserialize)]
struct MessageQuery {
user: Option<String>,
mailbox: Option<String>,
include_raw: Option<String>,
}
pub(crate) async fn run_http(
listener: TcpListener,
store: Arc<Mutex<Store>>,
auth: SharedUserStore,
meta: HttpMeta,
mut shutdown_rx: broadcast::Receiver<()>,
) {
let seed_snapshot = {
let mut guard = store.lock().expect("store lock poisoned");
guard.snapshot()
};
let state = HttpState {
store,
auth,
meta,
seed_snapshot,
};
let app = Router::new()
.route("/reset", post(handle_reset))
.route("/purge", post(handle_purge))
.route("/inject", post(handle_inject))
.route("/users", get(handle_list_users).post(handle_create_user))
.route("/config", get(handle_config))
.route(
"/messages",
get(handle_list_messages).delete(handle_delete_message),
)
.route("/messages/:uid", get(handle_get_message))
.route("/openapi.json", get(handle_openapi))
.route("/docs", get(handle_docs))
.fallback(handle_not_found)
.layer(from_fn_with_state(state.clone(), auth_middleware))
.with_state(state);
let shutdown = async move {
let _ = shutdown_rx.recv().await;
};
let _ = axum::serve(listener, app)
.with_graceful_shutdown(shutdown)
.await;
}
async fn handle_reset(State(state): State<HttpState>) -> Response {
{
let mut guard = state.store.lock().expect("store lock poisoned");
guard.restore_snapshot(&state.seed_snapshot);
}
state.auth.reset_to_seed();
json_response(
StatusCode::OK,
StatusResponse {
status: "ok".to_string(),
},
)
}
async fn handle_purge(State(state): State<HttpState>) -> Response {
{
let mut guard = state.store.lock().expect("store lock poisoned");
guard.purge_messages();
}
json_response(
StatusCode::OK,
StatusResponse {
status: "ok".to_string(),
},
)
}
async fn handle_inject(
State(state): State<HttpState>,
payload: Result<Json<InjectRequest>, axum::extract::rejection::JsonRejection>,
) -> Response {
let Ok(Json(payload)) = payload else {
return json_error(StatusCode::BAD_REQUEST, "Invalid JSON");
};
if payload.user.is_empty() || payload.raw.is_empty() {
return json_error(StatusCode::BAD_REQUEST, "Missing user or raw message");
}
let mailbox = payload.mailbox.unwrap_or_else(|| "INBOX".to_string());
let uid = {
let mut guard = state.store.lock().expect("store lock poisoned");
guard.append(
&payload.user,
&mailbox,
payload.raw.into_bytes(),
current_internal_date(),
);
let messages = guard.list(&payload.user, &mailbox);
let uid = messages.last().map(|m| m.uid).unwrap_or(0);
drop(guard);
uid
};
json_response(StatusCode::CREATED, InjectResponse { uid })
}
async fn handle_create_user(
State(state): State<HttpState>,
payload: Result<Json<CreateUserRequest>, axum::extract::rejection::JsonRejection>,
) -> Response {
let Ok(Json(payload)) = payload else {
return json_error(StatusCode::BAD_REQUEST, "Invalid JSON");
};
if payload.user.is_empty() || payload.password.is_empty() {
return json_error(StatusCode::BAD_REQUEST, "Missing user or password");
}
state
.auth
.add_user(&payload.user, &payload.password, payload.email.as_deref());
json_response(
StatusCode::CREATED,
StatusResponse {
status: "ok".to_string(),
},
)
}
async fn handle_list_users(
State(state): State<HttpState>,
Query(query): Query<UsersQuery>,
) -> Response {
let include_email = match parse_bool(query.include_email, "include_email") {
Ok(value) => value.unwrap_or(false),
Err(response) => return *response,
};
let mut users = BTreeSet::new();
for user in state.auth.list_users() {
users.insert(user);
}
let store_users = {
let guard = state.store.lock().expect("store lock poisoned");
guard.list_users()
};
for user in store_users {
users.insert(user);
}
if include_email {
let users: Vec<UserEntry> = users
.into_iter()
.map(|user| UserEntry {
email: state.auth.email_for(&user),
user,
})
.collect();
json_response(StatusCode::OK, UsersDetailResponse { users })
} else {
let users: Vec<String> = users.into_iter().collect();
json_response(StatusCode::OK, UsersResponse { users })
}
}
async fn handle_config(State(state): State<HttpState>) -> Response {
let storage = match &state.meta.storage {
StorageBackend::InMemory => "memory".to_string(),
StorageBackend::Sqlite(_) => "sqlite".to_string(),
};
let storage_path = match &state.meta.storage {
StorageBackend::Sqlite(path) => Some(path.clone()),
StorageBackend::InMemory => None,
};
let smtp_port = state.meta.smtp_addr.port();
let imap_port = state.meta.imap_addr.port();
let http_port = state.meta.http_addr.port();
json_response(
StatusCode::OK,
ConfigResponse {
smtp_addr: state.meta.smtp_addr.to_string(),
imap_addr: state.meta.imap_addr.to_string(),
http_addr: state.meta.http_addr.to_string(),
smtp_port,
imap_port,
http_port,
smtp_enabled: true,
imap_enabled: true,
http_enabled: true,
smtp_starttls: true,
imap_starttls: true,
storage,
storage_path,
http_token_required: state.meta.http_token.is_some(),
auth_disabled: state.auth.auth_disabled(),
user_count: state.auth.user_count(),
},
)
}
async fn handle_list_messages(
State(state): State<HttpState>,
Query(query): Query<MessagesQuery>,
) -> Response {
let user = query.user.unwrap_or_default();
if user.is_empty() {
return json_error(StatusCode::BAD_REQUEST, "Missing user parameter");
}
let mailbox = query.mailbox.unwrap_or_else(|| "INBOX".to_string());
let limit = match parse_usize(query.limit, "limit") {
Ok(value) => value,
Err(response) => return *response,
};
let offset = match parse_usize(query.offset, "offset") {
Ok(value) => value,
Err(response) => return *response,
};
let include_raw = match parse_bool(query.include_raw, "include_raw") {
Ok(value) => value,
Err(response) => return *response,
};
let messages = {
let mut guard = state.store.lock().expect("store lock poisoned");
guard.list(&user, &mailbox)
};
let offset = offset.unwrap_or(0);
let limit = limit.unwrap_or(messages.len());
let messages = messages
.into_iter()
.skip(offset)
.take(limit)
.map(|message| {
let subject =
crate::imap::fetch::header_value(&message.data, "Subject").unwrap_or_default();
let from = crate::imap::fetch::header_value(&message.data, "From").unwrap_or_default();
let raw = if include_raw.unwrap_or(false) {
Some(String::from_utf8_lossy(&message.data).to_string())
} else {
None
};
MessageEntry {
uid: message.uid,
size: message.data.len(),
subject,
from,
raw,
}
})
.collect();
json_response(StatusCode::OK, MessagesResponse { messages })
}
async fn handle_get_message(
State(state): State<HttpState>,
Path(uid): Path<u32>,
Query(query): Query<MessageQuery>,
) -> Response {
let user = query.user.unwrap_or_default();
if user.is_empty() {
return json_error(StatusCode::BAD_REQUEST, "Missing user parameter");
}
let mailbox = query.mailbox.unwrap_or_else(|| "INBOX".to_string());
let include_raw = match parse_bool(query.include_raw, "include_raw") {
Ok(value) => value.unwrap_or(false),
Err(response) => return *response,
};
let message = {
let mut guard = state.store.lock().expect("store lock poisoned");
guard
.list(&user, &mailbox)
.into_iter()
.find(|message| message.uid == uid)
};
let Some(message) = message else {
return json_error(StatusCode::NOT_FOUND, "Message not found");
};
let subject = crate::imap::fetch::header_value(&message.data, "Subject").unwrap_or_default();
let from = crate::imap::fetch::header_value(&message.data, "From").unwrap_or_default();
let raw = if include_raw {
Some(String::from_utf8_lossy(&message.data).to_string())
} else {
None
};
json_response(
StatusCode::OK,
MessageResponse {
message: MessageEntry {
uid: message.uid,
size: message.data.len(),
subject,
from,
raw,
},
},
)
}
async fn handle_delete_message(
State(state): State<HttpState>,
Query(query): Query<MessagesQuery>,
) -> Response {
let user = query.user.unwrap_or_default();
let mailbox = query.mailbox.unwrap_or_else(|| "INBOX".to_string());
let uid = query
.uid
.as_deref()
.and_then(|value| value.parse::<u32>().ok())
.unwrap_or(0);
if user.is_empty() || uid == 0 {
return json_error(StatusCode::BAD_REQUEST, "Missing user or uid parameter");
}
{
let mut guard = state.store.lock().expect("store lock poisoned");
guard.delete_by_uid(&user, &mailbox, uid);
}
json_response(
StatusCode::OK,
StatusResponse {
status: "ok".to_string(),
},
)
}
async fn handle_openapi() -> Response {
(
StatusCode::OK,
[(header::CONTENT_TYPE, "application/json")],
OPENAPI_JSON,
)
.into_response()
}
async fn handle_docs(State(state): State<HttpState>) -> Response {
let html = docs_html(state.meta.http_token.as_deref());
(StatusCode::OK, [(header::CONTENT_TYPE, "text/html")], html).into_response()
}
async fn handle_not_found() -> Response {
json_error(StatusCode::NOT_FOUND, "Not found")
}
fn json_response<T: Serialize>(status: StatusCode, value: T) -> Response {
(status, Json(value)).into_response()
}
fn json_error(status: StatusCode, message: &str) -> Response {
json_response(
status,
ErrorResponse {
error: message.to_string(),
},
)
}
fn parse_usize(value: Option<String>, label: &str) -> Result<Option<usize>, Box<Response>> {
match value {
None => Ok(None),
Some(raw) => raw.parse::<usize>().map(Some).map_err(|_| {
Box::new(json_error(
StatusCode::BAD_REQUEST,
&format!("Invalid {label}"),
))
}),
}
}
fn parse_bool(value: Option<String>, label: &str) -> Result<Option<bool>, Box<Response>> {
match value {
None => Ok(None),
Some(raw) => raw.parse::<bool>().map(Some).map_err(|_| {
Box::new(json_error(
StatusCode::BAD_REQUEST,
&format!("Invalid {label}"),
))
}),
}
}
async fn auth_middleware(
State(state): State<HttpState>,
request: axum::http::Request<Body>,
next: Next,
) -> Response {
if let Some(token) = &state.meta.http_token {
let expected = format!("Bearer {token}");
let header_value = request
.headers()
.get(header::AUTHORIZATION)
.and_then(|value| value.to_str().ok())
.unwrap_or("");
let mut authorized = header_value == expected;
if !authorized {
let path = request.uri().path();
if matches!(path, "/docs" | "/openapi.json") {
if let Some(query) = request.uri().query() {
if let Some(query_token) = token_from_query(query) {
authorized = query_token == *token;
}
}
}
}
if !authorized {
return json_error(StatusCode::UNAUTHORIZED, "Unauthorized");
}
}
next.run(request).await
}
fn docs_html(token: Option<&str>) -> String {
let openapi_url = match token {
Some(token) => format!("/openapi.json?token={}", percent_encode(token)),
None => "/openapi.json".to_string(),
};
let mut config = format!("{{ url: '{openapi_url}', dom_id: '#swagger-ui'");
if let Some(token) = token {
let token_json = serde_json::to_string(token).unwrap_or_else(|_| "\"\"".to_string());
let _ = std::fmt::Write::write_fmt(
&mut config,
format_args!(
", requestInterceptor: (req) => {{ req.headers['Authorization'] = 'Bearer ' + {token_json}; return req; }}"
),
);
}
config.push_str(" }");
DOCS_HTML_BASE.replace("__SWAGGER_CONFIG__", &config)
}
fn token_from_query(query: &str) -> Option<String> {
for pair in query.split('&') {
let (key, value) = pair.split_once('=').unwrap_or((pair, ""));
if key == "token" {
return percent_decode(value);
}
}
None
}
fn percent_decode(value: &str) -> Option<String> {
let bytes = value.as_bytes();
let mut out = Vec::with_capacity(bytes.len());
let mut i = 0;
while i < bytes.len() {
match bytes[i] {
b'%' if i + 2 < bytes.len() => {
let hi = from_hex(bytes[i + 1])?;
let lo = from_hex(bytes[i + 2])?;
out.push((hi << 4) | lo);
i += 3;
}
b'+' => {
out.push(b' ');
i += 1;
}
byte => {
out.push(byte);
i += 1;
}
}
}
String::from_utf8(out).ok()
}
fn from_hex(byte: u8) -> Option<u8> {
match byte {
b'0'..=b'9' => Some(byte - b'0'),
b'a'..=b'f' => Some(byte - b'a' + 10),
b'A'..=b'F' => Some(byte - b'A' + 10),
_ => None,
}
}
fn percent_encode(value: &str) -> String {
let mut out = String::new();
for byte in value.as_bytes() {
let ch = *byte as char;
if ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.' | '~') {
out.push(ch);
} else {
let _ = std::fmt::Write::write_fmt(&mut out, format_args!("%{:02X}", byte));
}
}
out
}