pub mod attachments; pub mod auth; pub mod calendar; pub mod contact_groups; pub mod contacts; pub mod diagnostics; pub mod display_preferences; pub mod drafts; pub mod folder_mgmt; pub mod folder_preferences; pub mod folders; pub mod health; pub mod identities; pub mod mail_preferences; pub mod messages; pub mod notification_preferences; pub mod pgp; pub mod quota; pub mod response_templates; pub mod search; pub mod send; pub mod sieve; pub mod special_folders; pub mod tags; pub mod trusted_senders; pub mod unsubscribe; use std::net::{IpAddr, Ipv4Addr, SocketAddr}; use std::sync::Arc; use std::time::Duration; use axum::extract::{ConnectInfo, DefaultBodyLimit}; use axum::http::Request; use axum::routing::{delete, get, patch, post, put}; use axum::{Extension, Router, middleware}; use tower_governor::GovernorError; use tower_governor::GovernorLayer; use tower_governor::governor::GovernorConfigBuilder; use tower_governor::key_extractor::KeyExtractor; use tower_http::trace::TraceLayer; use crate::auth::csrf::csrf_protection; use crate::auth::middleware::auth_guard; use crate::auth::session::SessionStore; use crate::config::{AppConfig, TrustedProxy}; use crate::imap::client::ImapClient; use crate::realtime::events::EventBus; use crate::realtime::idle::IdleManager; use crate::smtp::client::SmtpClient; #[derive(Debug, Clone)] struct ProxyAwareIpExtractor { trusted_proxies: Vec, } impl ProxyAwareIpExtractor { fn is_trusted(&self, ip: &IpAddr) -> bool { self.trusted_proxies .iter() .any(|trusted| trusted.contains(ip)) } fn forwarded_client_ip( &self, headers: &axum::http::HeaderMap, peer_ip: IpAddr, ) -> Option { let mut current_hop = peer_ip; for value in headers.get_all("x-forwarded-for").iter().rev() { for entry in value.to_str().ok()?.split(',').rev() { if !self.is_trusted(¤t_hop) { return Some(current_hop); } current_hop = entry.trim().parse::().ok()?; } } Some(current_hop) } } impl KeyExtractor for ProxyAwareIpExtractor { type Key = IpAddr; fn extract(&self, req: &Request) -> Result { let peer_ip = req .extensions() .get::>() .map(|ci| ci.0.ip()) .unwrap_or(IpAddr::V4(Ipv4Addr::LOCALHOST)); if self.is_trusted(&peer_ip) && let Some(client_ip) = self.forwarded_client_ip(req.headers(), peer_ip) { return Ok(client_ip); } Ok(peer_ip) } } pub fn create_router( config: Arc, store: Arc, imap_client: Arc, smtp_client: Arc, search_engine: Arc, event_bus: Arc, idle_manager: Arc, ) -> Router { let governor_conf = GovernorConfigBuilder::default() .key_extractor(ProxyAwareIpExtractor { trusted_proxies: config.parsed_trusted_proxies(), }) .period(Duration::from_secs(12)) .burst_size(5) .finish() .expect("valid governor config"); let public_auth = Router::new() .route("/login", post(auth::login)) .route("/policy", get(auth::auth_policy)) .layer(middleware::from_fn(csrf_protection)) .layer(GovernorLayer::new(governor_conf)); let browser_routes = Router::new() .route("/accounts", get(auth::list_accounts)) .route( "/accounts/{id}", delete(auth::remove_account).layer(middleware::from_fn(csrf_protection)), ); let protected_auth = Router::new() .route("/session", get(auth::get_session)) .route("/sessions", get(auth::list_active_sessions)) .route("/sessions/revoke-others", post(auth::revoke_other_sessions)) .route("/sessions/{id}", delete(auth::revoke_active_session)) .route("/logout", post(auth::logout)) .layer(middleware::from_fn(auth_guard)) .layer(middleware::from_fn(csrf_protection)); let auth_router = Router::new() .merge(public_auth) .merge(browser_routes) .merge(protected_auth); let attachment_body_limit = config .max_attachment_size_bytes() .saturating_add(1024 * 1024); let protected_data = Router::new() .route( "/folders", get(folders::list_folders).post(folder_mgmt::create_folder), ) .route( "/folders/{name}", patch(folder_mgmt::rename_folder).delete(folder_mgmt::delete_folder), ) .route( "/folders/{name}/subscribe", patch(folder_mgmt::subscribe_folder), ) .route( "/folders/{name}/mark-all-read", post(folder_mgmt::mark_all_read), ) .route("/folders/{name}/empty", post(folder_mgmt::empty_folder)) .route( "/folders/{folder}/import-eml", post(messages::import_eml).layer(DefaultBodyLimit::max(52 * 1024 * 1024)), ) .route("/folders/{folder}/messages", get(messages::list_messages)) .route( "/messages/{folder}/{uid}", get(messages::get_message).delete(messages::delete_message_handler), ) .route( "/messages/{folder}/{uid}/flags", patch(messages::update_flags), ) .route( "/messages/{folder}/{uid}/attachments/{attachment_id}", get(messages::download_attachment), ) .route( "/messages/{folder}/{uid}/source", get(messages::download_eml_source), ) .route("/messages/move", post(messages::move_message_handler)) .route("/messages/copy", post(messages::copy_message_handler)) .route("/messages/send", post(send::send_message_handler)) .route( "/messages/{folder}/{uid}/unsubscribe", post(unsubscribe::unsubscribe_message), ) .route( "/drafts", get(drafts::list_drafts_handler).post(drafts::upsert_draft_handler), ) .route( "/drafts/{id}", get(drafts::get_draft_handler).delete(drafts::delete_draft_handler), ) .route( "/drafts/{draft_id}/attachments", post(attachments::upload_attachment) .layer(DefaultBodyLimit::max(attachment_body_limit)), ) .route( "/drafts/{draft_id}/attachments/{attachment_id}", delete(attachments::delete_attachment), ) .route( "/drafts/{draft_id}/attachments/{attachment_id}/content", get(attachments::get_attachment_content), ) .route("/search", get(search::search_messages)) .route( "/response-templates", get(response_templates::list).post(response_templates::create), ) .route( "/response-templates/{id}", put(response_templates::update).delete(response_templates::delete), ) .route( "/search/saved", get(search::list_saved_searches).post(search::create_saved_search), ) .route( "/search/saved/{id}", axum::routing::put(search::update_saved_search).delete(search::delete_saved_search), ) .route( "/contact-groups", get(contact_groups::list_groups_handler).post(contact_groups::create_group_handler), ) .route( "/contact-groups/{id}", put(contact_groups::update_group_handler).delete(contact_groups::delete_group_handler), ) .route( "/contact-groups/{id}/members", get(contact_groups::list_members_handler).post(contact_groups::add_member_handler), ) .route( "/contact-groups/{id}/members/{contact_id}", delete(contact_groups::remove_member_handler), ) .route( "/tags", get(tags::list_tags_handler).post(tags::create_tag_handler), ) .route("/tags/reconcile", post(tags::reconcile_tags_handler)) .route( "/tags/{id}", put(tags::update_tag_handler).delete(tags::delete_tag_handler), ) .route( "/tags/{id}/messages", post(tags::tag_message_handler).get(tags::list_tag_messages_handler), ) .route("/tags/{id}/messages/bulk", post(tags::bulk_tag_handler)) .route( "/tags/{id}/messages/{folder}/{uid}", delete(tags::untag_message_handler), ) .route( "/messages/{folder}/{uid}/tags", get(tags::get_message_tags_handler), ) .route( "/contacts", get(contacts::list_contacts_handler).post(contacts::create_contact_handler), ) .route( "/contacts/{id}/photo", get(contacts::get_contact_photo_handler) .post(contacts::upload_contact_photo_handler) .delete(contacts::delete_contact_photo_handler) .layer(DefaultBodyLimit::max(3 * 1024 * 1024)), ) .route( "/contacts/duplicates", get(contacts::duplicate_contacts_handler), ) .route("/contacts/merge", post(contacts::merge_contacts_handler)) .route("/contacts/export", get(contacts::export_contacts_handler)) .route( "/contacts/import", post(contacts::import_contacts_handler).layer(DefaultBodyLimit::max(11 * 1024 * 1024)), ) .route( "/contacts/autocomplete/all", get(contacts::autocomplete_all_handler), ) .route( "/contacts/autocomplete", get(contacts::autocomplete_handler), ) .route( "/contacts/{id}", get(contacts::get_contact_handler).delete(contacts::delete_contact_handler), ) .route( "/contacts/{contact_id}/groups", get(contact_groups::list_contact_groups_handler), ) .route( "/contacts/{id}/export", get(contacts::export_single_contact_handler), ) .route( "/identities", get(identities::list_identities_handler) .post(identities::create_identity_handler) .layer(DefaultBodyLimit::max(4 * 1024 * 1024)), ) .route( "/identities/{id}", get(identities::get_identity_handler) .put(identities::update_identity_handler) .delete(identities::delete_identity_handler) .layer(DefaultBodyLimit::max(4 * 1024 * 1024)), ) .route( "/pgp/keys", get(pgp::list_keys) .post(pgp::import_key) .layer(DefaultBodyLimit::max(3 * 1024 * 1024)), ) .route("/pgp/keys/generate", post(pgp::generate_key)) .route("/pgp/keys/{fingerprint}/export", get(pgp::export_key)) .route( "/pgp/keys/{fingerprint}/test-passphrase", post(pgp::test_passphrase), ) .route( "/pgp/keys/{fingerprint}/change-passphrase", post(pgp::change_passphrase), ) .route("/pgp/keys/{fingerprint}/trust", patch(pgp::set_trust)) .route("/pgp/keys/{fingerprint}", delete(pgp::delete_key)) .route( "/pgp/preferences", get(pgp::get_preferences).put(pgp::update_preferences), ) .route("/pgp/discover", post(pgp::discover_key)) .route("/pgp/recipients", post(pgp::recipient_status)) .route("/pgp/verify", post(pgp::verify)) .route( "/pgp/decrypt", post(pgp::decrypt).layer(DefaultBodyLimit::max(12 * 1024 * 1024)), ) .route( "/pgp/messages/{folder}/{uid}/process", post(pgp::process_message), ) .route( "/settings/display", get(display_preferences::get_display_preferences) .put(display_preferences::update_display_preferences), ) .route( "/settings/special-folders", get(special_folders::get_special_folders).put(special_folders::update_special_folders), ) .route( "/settings/notifications", get(notification_preferences::get_notification_preferences) .put(notification_preferences::update_notification_preferences), ) .route( "/settings/mailbox", get(mail_preferences::get_mail_preferences) .put(mail_preferences::update_mail_preferences), ) .route( "/settings/trusted-senders", get(trusted_senders::list_trusted_senders).post(trusted_senders::add_trusted_sender), ) .route( "/settings/trusted-senders/{email}", delete(trusted_senders::remove_trusted_sender), ) .route( "/folders/{folder}/preferences", get(folder_preferences::get_folder_preferences) .put(folder_preferences::update_folder_preferences), ) .route( "/calendar/events", get(calendar::list_events).post(calendar::create_event), ) .route( "/calendar/events/import-ics", post(calendar::import_ics).layer(DefaultBodyLimit::max(11 * 1024 * 1024)), ) .route("/calendar/events/export-ics", get(calendar::export_ics)) .route( "/calendar/events/{id}", get(calendar::get_event) .put(calendar::update_event) .delete(calendar::delete_event), ) .route( "/calendar/settings", get(calendar::get_calendar_settings).put(calendar::update_calendar_settings), ) .route( "/calendar/meeting-templates", get(calendar::list_meeting_templates).post(calendar::create_meeting_template), ) .route( "/calendar/meeting-templates/{id}", delete(calendar::delete_meeting_template), ) .route("/quota", get(quota::get_quota)) .route("/diagnostics/imap", get(diagnostics::imap_diagnostics)) .route("/diagnostics/smtp", get(diagnostics::smtp_diagnostics)) .route("/diagnostics/sync", get(diagnostics::sync_diagnostics)) .route( "/diagnostics/imap/reconnect", post(diagnostics::reconnect_imap), ) .route("/diagnostics/search", get(diagnostics::search_diagnostics)) .route( "/diagnostics/search/rebuild", post(diagnostics::rebuild_search_index), ) .route("/sieve/status", get(sieve::status)) .route("/sieve/scripts", get(sieve::list_scripts)) .route( "/sieve/scripts/{name}", get(sieve::get_script) .put(sieve::put_script) .delete(sieve::delete_script), ) .route( "/sieve/scripts/{name}/activate", post(sieve::activate_script), ) .route("/sieve/scripts/{name}/rename", post(sieve::rename_script)) .route("/sieve/deactivate", post(sieve::deactivate_script)) .route("/sieve/check", post(sieve::check_script)) .layer(middleware::from_fn(auth_guard)) .layer(middleware::from_fn(csrf_protection)); let ws_route = Router::new().route("/ws", get(crate::realtime::ws::ws_handler)); let api_router = Router::new() .route("/health", get(health::health_check)) .route("/ready", get(health::readiness_check)) .nest("/auth", auth_router) .merge(ws_route) .merge(protected_data); let inner = Router::new() .nest("/api", api_router) .route("/", get(crate::ui::serve_ui)) .fallback(crate::ui::serve_ui); let router = match config.normalized_base_path() { "/" => inner, base_path => { let base_path_slash = format!("{}/", base_path.trim_end_matches('/')); Router::new() .route(&base_path_slash, get(crate::ui::serve_ui)) .nest(base_path, inner) } }; let router = router .layer(Extension(idle_manager)) .layer(Extension(event_bus)) .layer(Extension(smtp_client)) .layer(Extension(search_engine)) .layer(Extension(imap_client)) .layer(Extension(store)) .layer(Extension(config.clone())) .layer(TraceLayer::new_for_http()); router } #[cfg(test)] mod tests;