mod auth;
mod bootstrap;
mod config;
mod dto;
mod error;
mod health;
mod identity;
mod request_id;
mod routes;
mod service;
mod shutdown;
mod state;
use std::net::SocketAddr;
use std::sync::Arc;
use std::time::Duration;
use axum::middleware;
use axum::routing::{get, post};
use axum::Router;
use config::{Config, ConfigError};
use mail4agent_store_sqlite::{migrations, Db, DbConfig, DbError, MigrationRunner, SqliteMailStore};
use service::MailboxService;
use state::AppState;
#[derive(Debug, thiserror::Error)]
enum MainError {
#[error("config: {0}")]
Config(#[from] ConfigError),
#[error("database: {0}")]
Db(#[from] DbError),
#[error("build tokio runtime: {0}")]
Runtime(std::io::Error),
#[error("bootstrap: {0}")]
Bootstrap(#[from] bootstrap::BootstrapError),
#[error("bind {0}: {1}")]
Bind(SocketAddr, std::io::Error),
#[error("serve: {0}")]
Serve(std::io::Error),
}
fn main() -> Result<(), MainError> {
tracing_subscriber::fmt()
.with_env_filter(
tracing_subscriber::EnvFilter::try_from_default_env()
.unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info")),
)
.init();
let config = Config::load()?;
let db_path = config.resolve_db_path()?;
tracing::info!(db = %db_path.display(), bind = %config.bind, "mail4agent starting");
let db_config = DbConfig::new(db_path);
let db = Db::open(&db_config)?;
db.run_migrations_blocking(MigrationRunner::new(migrations()))?;
let engine_store = SqliteMailStore::new(db.clone());
let reader_store = SqliteMailStore::new(db);
let service = Arc::new(MailboxService::new(engine_store, reader_store));
let runtime = tokio::runtime::Runtime::new().map_err(MainError::Runtime)?;
runtime.block_on(serve(service, config.bind))
}
async fn serve(service: Arc<MailboxService>, bind: SocketAddr) -> Result<(), MainError> {
bootstrap::ensure_bootstrap_operator(&service).await?;
let app = Arc::new(AppState { service: service.clone(), bind_addr: bind });
let authenticated_guard =
auth::TierGuard { service: service.clone(), required: auth::Tier::Authenticated };
let authenticated_router = Router::new()
.route("/mail/send", post(routes::mail::send))
.route("/mail/inbox", post(routes::mail::inbox))
.route("/mail/ack", post(routes::mail::ack))
.route("/mail/get", post(routes::mail::get))
.route("/mail/unread", post(routes::mail::unread))
.route("/mail/whoami", post(routes::mail::whoami))
.route("/mail/status", post(routes::mail::status))
.route("/mail/directory", post(routes::mail::directory))
.route("/mcp", post(routes::mcp::handle_mcp_post).delete(routes::mcp::handle_mcp_delete))
.route_layer(middleware::from_fn_with_state(authenticated_guard, auth::require_tier))
.with_state(app.clone());
let admin_guard = auth::TierGuard { service: service.clone(), required: auth::Tier::Admin };
let admin_router = Router::new()
.route("/admin/participant", post(routes::admin::register_participant))
.route("/admin/participant/rotate", post(routes::admin::rotate_participant))
.route("/admin/participant/remove", post(routes::admin::remove_participant))
.route("/admin/room", post(routes::admin::create_room))
.route("/admin/room/member/add", post(routes::admin::add_room_member))
.route("/admin/room/member/remove", post(routes::admin::remove_room_member))
.route("/admin/listener", post(routes::admin::set_listener))
.route("/admin/listener/remove", post(routes::admin::remove_listener))
.route_layer(middleware::from_fn_with_state(admin_guard, auth::require_tier))
.with_state(app.clone());
let health_state = Arc::new(health::HealthState::new("mail4agent", env!("CARGO_PKG_VERSION")));
let health_router = Router::new().route(
"/health",
get(move || {
let health_state = health_state.clone();
async move { health::handle(health_state).await }
}),
);
let router = Router::new()
.merge(authenticated_router)
.merge(admin_router)
.merge(health_router)
.layer(middleware::from_fn(request_id::request_id_layer));
let listener = tokio::net::TcpListener::bind(bind).await.map_err(|e| MainError::Bind(bind, e))?;
tracing::info!(addr = %bind, "mail4agent listening");
let (shutdown_tx, mut shutdown_rx) = tokio::sync::watch::channel(false);
let serve_handle = tokio::spawn(async move {
axum::serve(listener, router.into_make_service_with_connect_info::<SocketAddr>())
.with_graceful_shutdown(async move {
let _ = shutdown_rx.changed().await;
})
.await
});
shutdown::graceful_shutdown_signal().await;
tracing::info!("shutdown signal received");
let _ = shutdown_tx.send(true);
match tokio::time::timeout(Duration::from_secs(30), serve_handle).await {
Ok(Ok(Ok(()))) => tracing::info!("mail4agent stopped"),
Ok(Ok(Err(err))) => return Err(MainError::Serve(err)),
Ok(Err(join_err)) => tracing::error!(error = %join_err, "serve task panicked"),
Err(_) => tracing::warn!("shutdown drain exceeded 30s -- exiting anyway"),
}
Ok(())
}