use super::*; use axum::body::Body; use axum::extract::ConnectInfo; use axum::http::{HeaderValue, Request, StatusCode}; use http_body_util::BodyExt; use std::fs; use std::net::{IpAddr, SocketAddr}; use std::time::Duration; use tempfile::TempDir; use tower::ServiceExt; use tower_governor::key_extractor::KeyExtractor; use crate::imap::client::mock::MockImapClient; use crate::imap::client::{ EmailAddress, ImapAttachment, ImapError, ImapFolder, ImapMessageBody, ImapMessageHeader, }; use crate::smtp::client::SmtpError; use crate::smtp::client::mock::MockSmtpClient; fn test_config(_static_dir: &str) -> Arc { Arc::new(AppConfig { host: "127.0.0.1".to_string(), port: 3100, imap_host: None, imap_port: 993, smtp_host: None, smtp_port: 587, tls_enabled: true, data_dir: "/tmp/oxi-test".to_string(), session_timeout_hours: 24, environment: "development".to_string(), base_path: None, sieve_host: None, sieve_port: 4190, sieve_tls_mode: "starttls".to_string(), sieve_allow_insecure_auth: false, sieve_timeout_secs: 10, trusted_proxies: None, outgoing_headers: Vec::new(), ..AppConfig::default() }) } fn test_config_with_imap(_static_dir: &str, data_dir: &str) -> Arc { Arc::new(AppConfig { host: "127.0.0.1".to_string(), port: 3100, imap_host: Some("imap.example.com".to_string()), imap_port: 993, smtp_host: None, smtp_port: 587, tls_enabled: true, data_dir: data_dir.to_string(), session_timeout_hours: 24, environment: "development".to_string(), base_path: None, sieve_host: None, sieve_port: 4190, sieve_tls_mode: "starttls".to_string(), sieve_allow_insecure_auth: false, sieve_timeout_secs: 10, trusted_proxies: None, outgoing_headers: Vec::new(), ..AppConfig::default() }) } fn test_config_with_smtp(_static_dir: &str, data_dir: &str) -> Arc { Arc::new(AppConfig { host: "127.0.0.1".to_string(), port: 3001, imap_host: Some("imap.example.com".to_string()), imap_port: 993, smtp_host: Some("smtp.example.com".to_string()), smtp_port: 587, tls_enabled: true, data_dir: data_dir.to_string(), session_timeout_hours: 24, environment: "development".to_string(), base_path: None, sieve_host: None, sieve_port: 4190, sieve_tls_mode: "starttls".to_string(), sieve_allow_insecure_auth: false, sieve_timeout_secs: 10, trusted_proxies: None, outgoing_headers: Vec::new(), ..AppConfig::default() }) } fn test_store() -> Arc { Arc::new(SessionStore::new(Duration::from_secs(3600))) } fn test_imap_client() -> Arc { Arc::new(MockImapClient::new()) } fn test_smtp_client() -> Arc { Arc::new(MockSmtpClient::new()) } fn setup_static_dir() -> TempDir { let dir = TempDir::new().expect("should create temp dir"); fs::write( dir.path().join("index.html"), "SPA", ) .expect("should write index.html"); dir } fn test_search_engine(data_dir: &str) -> Arc { Arc::new(crate::search::engine::SearchEngine::new( std::path::PathBuf::from(data_dir), )) } fn test_event_bus() -> Arc { Arc::new(crate::realtime::events::EventBus::new()) } fn test_idle_manager() -> Arc { Arc::new(crate::realtime::idle::IdleManager::new()) } fn test_proxy_extractor(trusted_proxies: Option<&str>) -> ProxyAwareIpExtractor { let mut config = (*test_config("/tmp")).clone(); config.trusted_proxies = trusted_proxies.map(str::to_string); ProxyAwareIpExtractor { trusted_proxies: config.parsed_trusted_proxies(), } } fn extract_ip(extractor: &ProxyAwareIpExtractor, peer: &str, forwarded: Option<&str>) -> IpAddr { let peer_addr = SocketAddr::new(peer.parse().unwrap(), 443); let mut request = Request::builder().uri("/api/auth/login").body(()).unwrap(); request .extensions_mut() .insert(ConnectInfo::(peer_addr)); if let Some(forwarded) = forwarded { request .headers_mut() .insert("x-forwarded-for", HeaderValue::from_str(forwarded).unwrap()); } extractor.extract(&request).unwrap() } fn setup_test_account(store: &SessionStore, email: &str) -> (String, String, String) { let browser_id = store.create_browser(); let (token, account_id) = store.add_account_to_browser( &browser_id, email.to_string(), "password".to_string(), crate::auth::user_data::hash_email(email), "imap.example.com".to_string(), 993, true, "smtp.example.com".to_string(), 587, true, ); (browser_id, account_id, token) } fn auth_headers(browser_id: &str, account_id: &str, token: &str) -> Vec<(&'static str, String)> { vec![ ( "cookie", format!("rustbox_browser={browser_id}; rustbox_session_{account_id}={token}"), ), ("x-active-account", account_id.to_string()), ] } fn provision_user_db(data_dir: &str, user_hash: &str) { let _conn = crate::db::pool::open_user_db(data_dir, user_hash).unwrap(); } #[test] fn trusted_ipv4_proxy_uses_forwarded_client_ip() { let extractor = test_proxy_extractor(Some("10.0.0.0/8")); let client_ip = extract_ip( &extractor, "10.0.0.10", Some("203.0.113.9, 10.1.1.1, 10.2.2.2"), ); assert_eq!(client_ip, "203.0.113.9".parse::().unwrap()); } #[test] fn repeated_forwarded_headers_use_the_full_proxy_chain() { let extractor = test_proxy_extractor(Some("10.0.0.0/8")); let mut request = Request::builder().uri("/api/auth/login").body(()).unwrap(); request .extensions_mut() .insert(ConnectInfo::("10.0.0.10:443".parse().unwrap())); request .headers_mut() .append("x-forwarded-for", HeaderValue::from_static("203.0.113.99")); request.headers_mut().append( "x-forwarded-for", HeaderValue::from_static("198.51.100.20, 10.1.1.1"), ); assert_eq!( extractor.extract(&request).unwrap(), "198.51.100.20".parse::().unwrap() ); } #[test] fn untrusted_peer_ignores_spoofed_forwarded_header() { let extractor = test_proxy_extractor(Some("10.0.0.0/8")); let client_ip = extract_ip(&extractor, "198.51.100.20", Some("203.0.113.9, 10.1.1.1")); assert_eq!(client_ip, "198.51.100.20".parse::().unwrap()); } #[test] fn trusted_ipv6_cidr_uses_forwarded_client_ip() { let extractor = test_proxy_extractor(Some("2001:db8:abcd::/48")); let client_ip = extract_ip( &extractor, "2001:db8:abcd::10", Some("2001:db8::123, 2001:db8:abcd::20"), ); assert_eq!(client_ip, "2001:db8::123".parse::().unwrap()); } #[test] fn malformed_forwarded_header_falls_back_to_peer_ip() { let extractor = test_proxy_extractor(Some("10.0.0.0/8")); let client_ip = extract_ip(&extractor, "10.0.0.10", Some("203.0.113.9, garbage")); assert_eq!(client_ip, "10.0.0.10".parse::().unwrap()); } #[tokio::test] async fn api_health_works_with_static_fallback() { let dir = setup_static_dir(); let config = test_config(dir.path().to_str().unwrap()); let store = test_store(); let app = create_router( config, store, test_imap_client(), test_smtp_client(), test_search_engine("/tmp/oxi-test"), test_event_bus(), test_idle_manager(), ); let response = app .oneshot( Request::builder() .uri("/api/health") .body(Body::empty()) .unwrap(), ) .await .unwrap(); assert_eq!(response.status(), StatusCode::OK); let body = response.into_body().collect().await.unwrap().to_bytes(); let json: serde_json::Value = serde_json::from_slice(&body).unwrap(); assert_eq!(json["status"], "ok"); } #[tokio::test] async fn disabled_optional_features_are_not_registered() { let dir = setup_static_dir(); let mut config = (*test_config(dir.path().to_str().unwrap())).clone(); config.sieve_enabled = false; config.diagnostics_enabled = false; let app = create_router( Arc::new(config), test_store(), test_imap_client(), test_smtp_client(), test_search_engine("/tmp/oxi-test"), test_event_bus(), test_idle_manager(), ); for uri in ["/api/sieve/status", "/api/diagnostics/imap"] { let response = app .clone() .oneshot(Request::builder().uri(uri).body(Body::empty()).unwrap()) .await .unwrap(); assert_eq!(response.status(), StatusCode::NOT_FOUND, "{uri}"); } } #[tokio::test] async fn auth_policy_exposes_optional_feature_switches() { let dir = setup_static_dir(); let mut config = (*test_config(dir.path().to_str().unwrap())).clone(); config.sieve_enabled = false; config.diagnostics_enabled = false; config.about_enabled = false; let app = create_router( Arc::new(config), test_store(), test_imap_client(), test_smtp_client(), test_search_engine("/tmp/oxi-test"), test_event_bus(), test_idle_manager(), ); let response = app .oneshot( Request::builder() .uri("/api/auth/policy") .body(Body::empty()) .unwrap(), ) .await .unwrap(); assert_eq!(response.status(), StatusCode::OK); let body = response.into_body().collect().await.unwrap().to_bytes(); let json: serde_json::Value = serde_json::from_slice(&body).unwrap(); assert_eq!(json["sieveEnabled"], false); assert_eq!(json["diagnosticsEnabled"], false); assert_eq!(json["aboutEnabled"], false); } #[tokio::test] async fn security_headers_are_opt_in() { let dir = setup_static_dir(); let app = create_router( test_config(dir.path().to_str().unwrap()), test_store(), test_imap_client(), test_smtp_client(), test_search_engine("/tmp/oxi-test"), test_event_bus(), test_idle_manager(), ); let response = app .oneshot( Request::builder() .uri("/api/health") .body(Body::empty()) .unwrap(), ) .await .unwrap(); assert!(response.headers().get("content-security-policy").is_none()); let mut config = (*test_config(dir.path().to_str().unwrap())).clone(); config.security_headers_enabled = true; let app = create_router( Arc::new(config), test_store(), test_imap_client(), test_smtp_client(), test_search_engine("/tmp/oxi-test"), test_event_bus(), test_idle_manager(), ); let response = app .oneshot( Request::builder() .uri("/api/health") .body(Body::empty()) .unwrap(), ) .await .unwrap(); assert_eq!(response.headers()["x-frame-options"], "DENY"); assert!( response.headers()["content-security-policy"] .to_str() .unwrap() .contains("default-src 'self'") ); } #[tokio::test] async fn root_serves_embedded_rustbox_ui() { let dir = setup_static_dir(); let config = test_config(dir.path().to_str().unwrap()); let app = create_router( config, test_store(), test_imap_client(), test_smtp_client(), test_search_engine("/tmp/oxi-test"), test_event_bus(), test_idle_manager(), ); let response = app .oneshot(Request::builder().uri("/").body(Body::empty()).unwrap()) .await .unwrap(); assert_eq!(response.status(), StatusCode::OK); let body = response.into_body().collect().await.unwrap().to_bytes(); let html = String::from_utf8(body.to_vec()).unwrap(); assert!(html.contains("RustBox")); assert!(html.contains("assets/app.")); assert!(html.contains(".js")); } #[tokio::test] async fn embedded_css_is_served_by_rustbox() { let dir = setup_static_dir(); let config = test_config(dir.path().to_str().unwrap()); let app = create_router( config, test_store(), test_imap_client(), test_smtp_client(), test_search_engine("/tmp/oxi-test"), test_event_bus(), test_idle_manager(), ); let response = app .oneshot( Request::builder() .uri("/assets/app.css") .body(Body::empty()) .unwrap(), ) .await .unwrap(); assert_eq!(response.status(), StatusCode::OK); assert_eq!( response.headers()["content-type"], "text/css; charset=utf-8" ); let body = response.into_body().collect().await.unwrap().to_bytes(); assert!( String::from_utf8(body.to_vec()) .unwrap() .contains("--accent") ); } #[tokio::test] async fn base_path_serves_the_same_embedded_ui() { let dir = setup_static_dir(); let mut config = (*test_config(dir.path().to_str().unwrap())).clone(); config.base_path = Some("/mail".to_string()); let app = create_router( Arc::new(config), test_store(), test_imap_client(), test_smtp_client(), test_search_engine("/tmp/oxi-test"), test_event_bus(), test_idle_manager(), ); for uri in ["/mail", "/mail/"] { let response = app .clone() .oneshot(Request::builder().uri(uri).body(Body::empty()).unwrap()) .await .unwrap(); assert_eq!(response.status(), StatusCode::OK, "{uri}"); let body = response.into_body().collect().await.unwrap().to_bytes(); let html = String::from_utf8(body.to_vec()).unwrap(); assert!(html.contains("data-base-path=\"/mail\""), "{uri}"); assert!(html.contains("/mail/assets/app."), "{uri}"); assert!(html.contains(".js"), "{uri}"); } } #[tokio::test] async fn login_without_csrf_header_returns_403() { let dir = setup_static_dir(); let config = test_config(dir.path().to_str().unwrap()); let store = test_store(); let app = create_router( config, store, test_imap_client(), test_smtp_client(), test_search_engine("/tmp/oxi-test"), test_event_bus(), test_idle_manager(), ); let response = app .oneshot( Request::builder() .method("POST") .uri("/api/auth/login") .header("content-type", "application/json") .body(Body::from(r#"{"email":"test@test.com","password":"pass"}"#)) .unwrap(), ) .await .unwrap(); assert_eq!(response.status(), StatusCode::FORBIDDEN); } #[tokio::test] async fn login_no_imap_host_returns_503() { let dir = setup_static_dir(); let config = test_config(dir.path().to_str().unwrap()); let store = test_store(); let app = create_router( config, store, test_imap_client(), test_smtp_client(), test_search_engine("/tmp/oxi-test"), test_event_bus(), test_idle_manager(), ); let response = app .oneshot( Request::builder() .method("POST") .uri("/api/auth/login") .header("content-type", "application/json") .header("x-requested-with", "XMLHttpRequest") .body(Body::from(r#"{"email":"test@test.com","password":"pass"}"#)) .unwrap(), ) .await .unwrap(); assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE); let body = response.into_body().collect().await.unwrap().to_bytes(); let json: serde_json::Value = serde_json::from_slice(&body).unwrap(); assert_eq!(json["error"]["code"], "SERVICE_UNAVAILABLE"); assert_eq!( json["error"]["message"], "IMAP server is not configured in RustBox" ); } #[tokio::test] async fn login_empty_email_returns_400() { let dir = setup_static_dir(); let mut cfg = (*test_config(dir.path().to_str().unwrap())).clone(); cfg.imap_host = Some("127.0.0.1".to_string()); let config = Arc::new(cfg); let store = test_store(); let app = create_router( config, store, test_imap_client(), test_smtp_client(), test_search_engine("/tmp/oxi-test"), test_event_bus(), test_idle_manager(), ); let response = app .oneshot( Request::builder() .method("POST") .uri("/api/auth/login") .header("content-type", "application/json") .header("x-requested-with", "XMLHttpRequest") .body(Body::from(r#"{"email":"","password":"pass"}"#)) .unwrap(), ) .await .unwrap(); assert_eq!(response.status(), StatusCode::BAD_REQUEST); let body = response.into_body().collect().await.unwrap().to_bytes(); let json: serde_json::Value = serde_json::from_slice(&body).unwrap(); assert_eq!(json["error"]["code"], "BAD_REQUEST"); } #[tokio::test] async fn login_empty_password_returns_400() { let dir = setup_static_dir(); let mut cfg = (*test_config(dir.path().to_str().unwrap())).clone(); cfg.imap_host = Some("127.0.0.1".to_string()); let config = Arc::new(cfg); let store = test_store(); let app = create_router( config, store, test_imap_client(), test_smtp_client(), test_search_engine("/tmp/oxi-test"), test_event_bus(), test_idle_manager(), ); let response = app .oneshot( Request::builder() .method("POST") .uri("/api/auth/login") .header("content-type", "application/json") .header("x-requested-with", "XMLHttpRequest") .body(Body::from(r#"{"email":"test@test.com","password":""}"#)) .unwrap(), ) .await .unwrap(); assert_eq!(response.status(), StatusCode::BAD_REQUEST); } #[tokio::test] async fn login_unreachable_imap_returns_503() { let dir = setup_static_dir(); let mut cfg = (*test_config(dir.path().to_str().unwrap())).clone(); cfg.imap_host = Some("127.0.0.1".to_string()); cfg.imap_port = 19999; cfg.tls_enabled = false; let config = Arc::new(cfg); let store = test_store(); let app = create_router( config, store, test_imap_client(), test_smtp_client(), test_search_engine("/tmp/oxi-test"), test_event_bus(), test_idle_manager(), ); let response = app .oneshot( Request::builder() .method("POST") .uri("/api/auth/login") .header("content-type", "application/json") .header("x-requested-with", "XMLHttpRequest") .body(Body::from(r#"{"email":"test@test.com","password":"pass"}"#)) .unwrap(), ) .await .unwrap(); assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE); let body = response.into_body().collect().await.unwrap().to_bytes(); let json: serde_json::Value = serde_json::from_slice(&body).unwrap(); assert_eq!(json["error"]["code"], "SERVER_UNREACHABLE"); assert!( json["error"]["message"] .as_str() .unwrap() .contains("Connection refused") ); } #[tokio::test] async fn get_session_without_auth_returns_401() { let dir = setup_static_dir(); let config = test_config(dir.path().to_str().unwrap()); let store = test_store(); let app = create_router( config, store, test_imap_client(), test_smtp_client(), test_search_engine("/tmp/oxi-test"), test_event_bus(), test_idle_manager(), ); let response = app .oneshot( Request::builder() .uri("/api/auth/session") .body(Body::empty()) .unwrap(), ) .await .unwrap(); assert_eq!(response.status(), StatusCode::UNAUTHORIZED); } #[tokio::test] async fn get_session_with_valid_session_returns_200() { let dir = setup_static_dir(); let config = test_config(dir.path().to_str().unwrap()); let store = test_store(); let (browser_id, account_id, token) = setup_test_account(&store, "alice@example.com"); let app = create_router( config, store, test_imap_client(), test_smtp_client(), test_search_engine("/tmp/oxi-test"), test_event_bus(), test_idle_manager(), ); let mut req = Request::builder().uri("/api/auth/session"); for (name, value) in auth_headers(&browser_id, &account_id, &token) { req = req.header(name, value); } let response = app.oneshot(req.body(Body::empty()).unwrap()).await.unwrap(); assert_eq!(response.status(), StatusCode::OK); let body = response.into_body().collect().await.unwrap().to_bytes(); let json: serde_json::Value = serde_json::from_slice(&body).unwrap(); assert_eq!(json["user"]["email"], "alice@example.com"); } #[tokio::test] async fn logout_without_auth_returns_401() { let dir = setup_static_dir(); let config = test_config(dir.path().to_str().unwrap()); let store = test_store(); let app = create_router( config, store, test_imap_client(), test_smtp_client(), test_search_engine("/tmp/oxi-test"), test_event_bus(), test_idle_manager(), ); let response = app .oneshot( Request::builder() .method("POST") .uri("/api/auth/logout") .header("x-requested-with", "XMLHttpRequest") .body(Body::empty()) .unwrap(), ) .await .unwrap(); assert_eq!(response.status(), StatusCode::UNAUTHORIZED); } #[tokio::test] async fn logout_with_valid_session_returns_200() { let dir = setup_static_dir(); let config = test_config(dir.path().to_str().unwrap()); let store = test_store(); let (browser_id, account_id, token) = setup_test_account(&store, "alice@example.com"); let app = create_router( config, store.clone(), test_imap_client(), test_smtp_client(), test_search_engine("/tmp/oxi-test"), test_event_bus(), test_idle_manager(), ); let mut req = Request::builder() .method("POST") .uri("/api/auth/logout") .header("x-requested-with", "XMLHttpRequest"); for (name, value) in auth_headers(&browser_id, &account_id, &token) { req = req.header(name, value); } let response = app.oneshot(req.body(Body::empty()).unwrap()).await.unwrap(); assert_eq!(response.status(), StatusCode::OK); let body = response.into_body().collect().await.unwrap().to_bytes(); let json: serde_json::Value = serde_json::from_slice(&body).unwrap(); assert_eq!(json["status"], "logged_out"); assert!(store.get(&token).is_none()); } #[tokio::test] async fn base_path_mounts_protected_data_routes_and_scopes_logout_cookies() { for (base_path, prefix, cookie_path) in [ (None, "", "/"), (Some(""), "", "/"), (Some("/"), "", "/"), (Some("/oxi"), "/oxi", "/oxi"), ] { let static_dir = setup_static_dir(); let data_dir = TempDir::new().unwrap(); let mut config = (*test_config_with_imap( static_dir.path().to_str().unwrap(), data_dir.path().to_str().unwrap(), )) .clone(); config.base_path = base_path.map(str::to_string); let store = test_store(); let (browser_id, account_id, token) = setup_test_account(&store, "alice@example.com"); provision_user_db( data_dir.path().to_str().unwrap(), &crate::auth::user_data::hash_email("alice@example.com"), ); let app = create_router( Arc::new(config), store, test_imap_client(), test_smtp_client(), test_search_engine(data_dir.path().to_str().unwrap()), test_event_bus(), test_idle_manager(), ); let mut request = Request::builder().uri(format!("{prefix}/api/folders")); for (name, value) in auth_headers(&browser_id, &account_id, &token) { request = request.header(name, value); } let response = app .clone() .oneshot(request.body(Body::empty()).unwrap()) .await .unwrap(); assert_eq!(response.status(), StatusCode::OK, "{base_path:?}"); let body = response.into_body().collect().await.unwrap().to_bytes(); let json: serde_json::Value = serde_json::from_slice(&body).unwrap(); assert!(json["folders"].is_array(), "{base_path:?}: {json}"); let mut request = Request::builder() .method("POST") .uri(format!("{prefix}/api/auth/logout")) .header("x-requested-with", "XMLHttpRequest"); for (name, value) in auth_headers(&browser_id, &account_id, &token) { request = request.header(name, value); } let response = app .oneshot(request.body(Body::empty()).unwrap()) .await .unwrap(); assert_eq!(response.status(), StatusCode::OK, "{base_path:?}"); let cookies: Vec<_> = response.headers().get_all("set-cookie").iter().collect(); assert_eq!(cookies.len(), 2); for cookie in cookies { let attributes: Vec<_> = cookie.to_str().unwrap().split(';').map(str::trim).collect(); assert!(attributes.contains(&format!("Path={cookie_path}").as_str())); assert!(attributes.contains(&"Max-Age=0")); } } } #[tokio::test] async fn logout_clears_cookie() { let dir = setup_static_dir(); let config = test_config(dir.path().to_str().unwrap()); let store = test_store(); let (browser_id, account_id, token) = setup_test_account(&store, "alice@example.com"); let app = create_router( config, store, test_imap_client(), test_smtp_client(), test_search_engine("/tmp/oxi-test"), test_event_bus(), test_idle_manager(), ); let mut req = Request::builder() .method("POST") .uri("/api/auth/logout") .header("x-requested-with", "XMLHttpRequest"); for (name, value) in auth_headers(&browser_id, &account_id, &token) { req = req.header(name, value); } let response = app.oneshot(req.body(Body::empty()).unwrap()).await.unwrap(); assert_eq!(response.status(), StatusCode::OK); let set_cookie = response.headers().get_all("set-cookie"); let cookie_str = format!("{set_cookie:?}"); assert!(cookie_str.contains("rustbox_browser=;") || cookie_str.contains("Max-Age=0")); } #[tokio::test] async fn get_folders_returns_200_with_folder_list() { let static_dir = setup_static_dir(); let data_dir = TempDir::new().unwrap(); let config = test_config_with_imap( static_dir.path().to_str().unwrap(), data_dir.path().to_str().unwrap(), ); let store = test_store(); let (browser_id, account_id, token) = setup_test_account(&store, "alice@example.com"); provision_user_db( data_dir.path().to_str().unwrap(), &crate::auth::user_data::hash_email("alice@example.com"), ); let mock = MockImapClient::new().with_folders(vec![ ImapFolder { name: "INBOX".to_string(), delimiter: Some("/".to_string()), attributes: vec!["\\HasNoChildren".to_string()], }, ImapFolder { name: "Sent".to_string(), delimiter: Some("/".to_string()), attributes: vec![], }, ]); let imap_client: Arc = Arc::new(mock); let app = create_router( config, store, imap_client, test_smtp_client(), test_search_engine(data_dir.path().to_str().unwrap()), test_event_bus(), test_idle_manager(), ); let mut req = Request::builder() .uri("/api/folders") .header("x-requested-with", "XMLHttpRequest"); for (name, value) in auth_headers(&browser_id, &account_id, &token) { req = req.header(name, value); } let response = app.oneshot(req.body(Body::empty()).unwrap()).await.unwrap(); assert_eq!(response.status(), StatusCode::OK); let body = response.into_body().collect().await.unwrap().to_bytes(); let json: serde_json::Value = serde_json::from_slice(&body).unwrap(); let folders = json["folders"].as_array().unwrap(); assert_eq!(folders.len(), 2); assert_eq!(folders[0]["name"], "INBOX"); assert_eq!(folders[1]["name"], "Sent"); } #[tokio::test] async fn get_folders_returns_503_when_imap_fails() { let static_dir = setup_static_dir(); let data_dir = TempDir::new().unwrap(); let config = test_config_with_imap( static_dir.path().to_str().unwrap(), data_dir.path().to_str().unwrap(), ); let store = test_store(); let (browser_id, account_id, token) = setup_test_account(&store, "alice@example.com"); let mock = MockImapClient::new().with_error(ImapError::ConnectionFailed("test failure".to_string())); let imap_client: Arc = Arc::new(mock); let app = create_router( config, store, imap_client, test_smtp_client(), test_search_engine(data_dir.path().to_str().unwrap()), test_event_bus(), test_idle_manager(), ); let mut req = Request::builder() .uri("/api/folders") .header("x-requested-with", "XMLHttpRequest"); for (name, value) in auth_headers(&browser_id, &account_id, &token) { req = req.header(name, value); } let response = app.oneshot(req.body(Body::empty()).unwrap()).await.unwrap(); assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE); } #[tokio::test] async fn get_folders_returns_401_without_auth() { let static_dir = setup_static_dir(); let data_dir = TempDir::new().unwrap(); let config = test_config_with_imap( static_dir.path().to_str().unwrap(), data_dir.path().to_str().unwrap(), ); let store = test_store(); let app = create_router( config, store, test_imap_client(), test_smtp_client(), test_search_engine("/tmp/oxi-test"), test_event_bus(), test_idle_manager(), ); let response = app .oneshot( Request::builder() .uri("/api/folders") .body(Body::empty()) .unwrap(), ) .await .unwrap(); assert_eq!(response.status(), StatusCode::UNAUTHORIZED); } #[tokio::test] async fn get_messages_returns_200_with_paginated_list() { let static_dir = setup_static_dir(); let data_dir = TempDir::new().unwrap(); let config = test_config_with_imap( static_dir.path().to_str().unwrap(), data_dir.path().to_str().unwrap(), ); let store = test_store(); let (browser_id, account_id, token) = setup_test_account(&store, "alice@example.com"); provision_user_db( data_dir.path().to_str().unwrap(), &crate::auth::user_data::hash_email("alice@example.com"), ); let mock = MockImapClient::new().with_headers(vec![ ImapMessageHeader { uid: 1, subject: Some("Hello World".to_string()), from: vec![EmailAddress { name: Some("Alice".to_string()), address: "alice@example.com".to_string(), }], to: vec![EmailAddress { name: None, address: "bob@example.com".to_string(), }], date: Some("2024-01-01T10:00:00Z".to_string()), flags: vec!["\\Seen".to_string()], has_attachments: false, size: 2048, message_id: None, in_reply_to: None, references: None, cc: vec![], reaction: None, }, ImapMessageHeader { uid: 2, subject: Some("Second message".to_string()), from: vec![EmailAddress { name: Some("Bob".to_string()), address: "bob@example.com".to_string(), }], to: vec![EmailAddress { name: None, address: "alice@example.com".to_string(), }], date: Some("2024-01-02T10:00:00Z".to_string()), flags: vec![], has_attachments: false, size: 4096, message_id: None, in_reply_to: None, references: None, cc: vec![], reaction: None, }, ]); let imap_client: Arc = Arc::new(mock); let app = create_router( config, store, imap_client, test_smtp_client(), test_search_engine(data_dir.path().to_str().unwrap()), test_event_bus(), test_idle_manager(), ); let mut req = Request::builder() .uri("/api/folders/INBOX/messages?page=0&per_page=50") .header("x-requested-with", "XMLHttpRequest"); for (name, value) in auth_headers(&browser_id, &account_id, &token) { req = req.header(name, value); } let response = app.oneshot(req.body(Body::empty()).unwrap()).await.unwrap(); assert_eq!(response.status(), StatusCode::OK); let body = response.into_body().collect().await.unwrap().to_bytes(); let json: serde_json::Value = serde_json::from_slice(&body).unwrap(); assert_eq!(json["total_count"], 2); assert_eq!(json["page"], 0); assert_eq!(json["per_page"], 50); let messages = json["messages"].as_array().unwrap(); assert_eq!(messages.len(), 2); } #[tokio::test] async fn get_message_returns_sanitized_html() { let static_dir = setup_static_dir(); let data_dir = TempDir::new().unwrap(); let config = test_config_with_imap( static_dir.path().to_str().unwrap(), data_dir.path().to_str().unwrap(), ); let store = test_store(); let (browser_id, account_id, token) = setup_test_account(&store, "alice@example.com"); provision_user_db( data_dir.path().to_str().unwrap(), &crate::auth::user_data::hash_email("alice@example.com"), ); let mock = MockImapClient::new() .with_headers(vec![ImapMessageHeader { uid: 42, subject: Some("Test Subject".to_string()), from: vec![EmailAddress { name: Some("Alice".to_string()), address: "alice@example.com".to_string(), }], to: vec![EmailAddress { name: None, address: "bob@example.com".to_string(), }], date: Some("2024-01-01T10:00:00Z".to_string()), flags: vec!["\\Seen".to_string()], has_attachments: false, size: 1024, message_id: None, in_reply_to: None, references: None, cc: vec![], reaction: None, }]) .with_bodies(vec![ImapMessageBody { uid: 42, text_plain: Some("Hello plain text".to_string()), text_html: Some("

Hello

bold".to_string()), attachments: vec![], raw_headers: String::new(), }]); let imap_client: Arc = Arc::new(mock); let app = create_router( config.clone(), store.clone(), imap_client.clone(), test_smtp_client(), test_search_engine(data_dir.path().to_str().unwrap()), test_event_bus(), test_idle_manager(), ); let mut req1 = Request::builder() .uri("/api/folders/INBOX/messages") .header("x-requested-with", "XMLHttpRequest"); for (name, value) in auth_headers(&browser_id, &account_id, &token) { req1 = req1.header(name, value); } let response = app .oneshot(req1.body(Body::empty()).unwrap()) .await .unwrap(); assert_eq!(response.status(), StatusCode::OK); let app2 = create_router( config, store, imap_client, test_smtp_client(), test_search_engine(data_dir.path().to_str().unwrap()), test_event_bus(), test_idle_manager(), ); let mut req2 = Request::builder() .uri("/api/messages/INBOX/42") .header("x-requested-with", "XMLHttpRequest"); for (name, value) in auth_headers(&browser_id, &account_id, &token) { req2 = req2.header(name, value); } let response = app2 .oneshot(req2.body(Body::empty()).unwrap()) .await .unwrap(); assert_eq!(response.status(), StatusCode::OK); let body = response.into_body().collect().await.unwrap().to_bytes(); let json: serde_json::Value = serde_json::from_slice(&body).unwrap(); assert_eq!(json["uid"], 42); assert_eq!(json["subject"], "Test Subject"); let html = json["html"].as_str().unwrap(); assert!(html.contains("script")); assert!(html.contains("bold")); assert!(html.contains("

Hello

")); assert_eq!(json["text"], "Hello plain text"); assert!(json["flags"].is_array()); assert!(json["to_addresses"].is_array()); } #[tokio::test] async fn update_flags_returns_200() { let static_dir = setup_static_dir(); let data_dir = TempDir::new().unwrap(); let config = test_config_with_imap( static_dir.path().to_str().unwrap(), data_dir.path().to_str().unwrap(), ); let store = test_store(); let (browser_id, account_id, token) = setup_test_account(&store, "alice@example.com"); let user_hash = crate::auth::user_data::hash_email("alice@example.com"); provision_user_db(data_dir.path().to_str().unwrap(), &user_hash); let conn = crate::db::pool::open_user_db(data_dir.path().to_str().unwrap(), &user_hash).unwrap(); crate::db::folders::upsert_folder(&conn, "INBOX", None, None, "", true, 0, 0, 0, 0).unwrap(); crate::db::messages::upsert_message( &conn, "INBOX", 1, None, None, None, "Test", "a@b.com", "A", "[]", "[]", "2024-01-01", "", 0, false, "", None, ) .unwrap(); drop(conn); let mock = MockImapClient::new(); let imap_client: Arc = Arc::new(mock); let app = create_router( config, store, imap_client, test_smtp_client(), test_search_engine(data_dir.path().to_str().unwrap()), test_event_bus(), test_idle_manager(), ); let mut req = Request::builder() .method("PATCH") .uri("/api/messages/INBOX/1/flags") .header("x-requested-with", "XMLHttpRequest") .header("content-type", "application/json"); for (name, value) in auth_headers(&browser_id, &account_id, &token) { req = req.header(name, value); } let response = app .oneshot( req.body(Body::from(r#"{"flags":["\\Seen","\\Flagged"],"add":true}"#)) .unwrap(), ) .await .unwrap(); assert_eq!(response.status(), StatusCode::OK); } #[tokio::test] async fn update_flags_rejects_invalid_flag_characters() { let static_dir = setup_static_dir(); let data_dir = TempDir::new().unwrap(); let config = test_config_with_imap( static_dir.path().to_str().unwrap(), data_dir.path().to_str().unwrap(), ); let store = test_store(); let (browser_id, account_id, token) = setup_test_account(&store, "alice@example.com"); let mock = MockImapClient::new(); let imap_client: Arc = Arc::new(mock); let app = create_router( config, store, imap_client, test_smtp_client(), test_search_engine(data_dir.path().to_str().unwrap()), test_event_bus(), test_idle_manager(), ); let mut req = Request::builder() .method("PATCH") .uri("/api/messages/INBOX/1/flags") .header("x-requested-with", "XMLHttpRequest") .header("content-type", "application/json"); for (name, value) in auth_headers(&browser_id, &account_id, &token) { req = req.header(name, value); } let response = app .oneshot( req.body(Body::from(r#"{"flags":["\\Seen \\Flagged"],"add":true}"#)) .unwrap(), ) .await .unwrap(); assert_eq!(response.status(), StatusCode::BAD_REQUEST); let body = response.into_body().collect().await.unwrap().to_bytes(); let json: serde_json::Value = serde_json::from_slice(&body).unwrap(); assert_eq!(json["error"]["code"], "BAD_REQUEST"); } #[tokio::test] async fn update_flags_rejects_empty_flag() { let static_dir = setup_static_dir(); let data_dir = TempDir::new().unwrap(); let config = test_config_with_imap( static_dir.path().to_str().unwrap(), data_dir.path().to_str().unwrap(), ); let store = test_store(); let (browser_id, account_id, token) = setup_test_account(&store, "alice@example.com"); let mock = MockImapClient::new(); let imap_client: Arc = Arc::new(mock); let app = create_router( config, store, imap_client, test_smtp_client(), test_search_engine(data_dir.path().to_str().unwrap()), test_event_bus(), test_idle_manager(), ); let mut req = Request::builder() .method("PATCH") .uri("/api/messages/INBOX/1/flags") .header("x-requested-with", "XMLHttpRequest") .header("content-type", "application/json"); for (name, value) in auth_headers(&browser_id, &account_id, &token) { req = req.header(name, value); } let response = app .oneshot( req.body(Body::from(r#"{"flags":[""],"add":true}"#)) .unwrap(), ) .await .unwrap(); assert_eq!(response.status(), StatusCode::BAD_REQUEST); } #[tokio::test] async fn move_message_returns_200() { let static_dir = setup_static_dir(); let data_dir = TempDir::new().unwrap(); let config = test_config_with_imap( static_dir.path().to_str().unwrap(), data_dir.path().to_str().unwrap(), ); let store = test_store(); let (browser_id, account_id, token) = setup_test_account(&store, "alice@example.com"); let user_hash = crate::auth::user_data::hash_email("alice@example.com"); provision_user_db(data_dir.path().to_str().unwrap(), &user_hash); let conn = crate::db::pool::open_user_db(data_dir.path().to_str().unwrap(), &user_hash).unwrap(); crate::db::folders::upsert_folder(&conn, "INBOX", None, None, "", true, 0, 0, 0, 0).unwrap(); crate::db::messages::upsert_message( &conn, "INBOX", 42, None, None, None, "Test", "a@b.com", "A", "[]", "[]", "2024-01-01", "", 0, false, "", None, ) .unwrap(); drop(conn); let mock = MockImapClient::new(); let imap_client: Arc = Arc::new(mock); let app = create_router( config, store, imap_client, test_smtp_client(), test_search_engine(data_dir.path().to_str().unwrap()), test_event_bus(), test_idle_manager(), ); let mut req = Request::builder() .method("POST") .uri("/api/messages/move") .header("x-requested-with", "XMLHttpRequest") .header("content-type", "application/json"); for (name, value) in auth_headers(&browser_id, &account_id, &token) { req = req.header(name, value); } let response = app .oneshot( req.body(Body::from( r#"{"from_folder":"INBOX","to_folder":"Archive","uid":42}"#, )) .unwrap(), ) .await .unwrap(); assert_eq!(response.status(), StatusCode::OK); } #[tokio::test] async fn move_message_returns_resolved_undo_uid() { let static_dir = setup_static_dir(); let data_dir = TempDir::new().unwrap(); let config = test_config_with_imap( static_dir.path().to_str().unwrap(), data_dir.path().to_str().unwrap(), ); let store = test_store(); let (browser_id, account_id, token) = setup_test_account(&store, "alice@example.com"); let user_hash = crate::auth::user_data::hash_email("alice@example.com"); provision_user_db(data_dir.path().to_str().unwrap(), &user_hash); let conn = crate::db::pool::open_user_db(data_dir.path().to_str().unwrap(), &user_hash).unwrap(); crate::db::folders::upsert_folder(&conn, "INBOX", None, None, "", true, 0, 0, 0, 0).unwrap(); crate::db::folders::upsert_folder(&conn, "Archive", None, None, "", true, 0, 0, 0, 0).unwrap(); crate::db::messages::upsert_message( &conn, "INBOX", 42, Some(""), None, None, "Test", "a@b.com", "A", "[]", "[]", "2024-01-01", "", 0, false, "", None, ) .unwrap(); drop(conn); let moved_header = ImapMessageHeader { uid: 101, subject: Some("Test".into()), from: Vec::new(), to: Vec::new(), cc: Vec::new(), date: None, flags: Vec::new(), has_attachments: false, size: 0, message_id: Some("".into()), in_reply_to: None, references: None, reaction: None, }; let mock = MockImapClient::new() .with_folder_status(crate::imap::types::FolderStatus { uid_validity: 1, exists: 0, uid_next: 100, }) .with_headers(vec![moved_header]); let imap_client: Arc = Arc::new(mock); let app = create_router( config, store, imap_client, test_smtp_client(), test_search_engine(data_dir.path().to_str().unwrap()), test_event_bus(), test_idle_manager(), ); let mut req = Request::builder() .method("POST") .uri("/api/messages/move") .header("x-requested-with", "XMLHttpRequest") .header("content-type", "application/json"); for (name, value) in auth_headers(&browser_id, &account_id, &token) { req = req.header(name, value); } let response = app .oneshot( req.body(Body::from( r#"{"from_folder":"INBOX","to_folder":"Archive","uid":42}"#, )) .unwrap(), ) .await .unwrap(); assert_eq!(response.status(), StatusCode::OK); let body = response.into_body().collect().await.unwrap().to_bytes(); let json: serde_json::Value = serde_json::from_slice(&body).unwrap(); assert_eq!(json["undo_move"]["from_folder"], "Archive"); assert_eq!(json["undo_move"]["to_folder"], "INBOX"); assert_eq!(json["undo_move"]["uid"], 101); } #[tokio::test] async fn delete_message_returns_200() { let static_dir = setup_static_dir(); let data_dir = TempDir::new().unwrap(); let config = test_config_with_imap( static_dir.path().to_str().unwrap(), data_dir.path().to_str().unwrap(), ); let store = test_store(); let (browser_id, account_id, token) = setup_test_account(&store, "alice@example.com"); let user_hash = crate::auth::user_data::hash_email("alice@example.com"); provision_user_db(data_dir.path().to_str().unwrap(), &user_hash); let conn = crate::db::pool::open_user_db(data_dir.path().to_str().unwrap(), &user_hash).unwrap(); crate::db::folders::upsert_folder(&conn, "INBOX", None, None, "", true, 0, 0, 0, 0).unwrap(); crate::db::messages::upsert_message( &conn, "INBOX", 7, None, None, None, "Test", "a@b.com", "A", "[]", "[]", "2024-01-01", "", 0, false, "", None, ) .unwrap(); drop(conn); let mock = MockImapClient::new(); let imap_client: Arc = Arc::new(mock); let app = create_router( config, store, imap_client, test_smtp_client(), test_search_engine(data_dir.path().to_str().unwrap()), test_event_bus(), test_idle_manager(), ); let mut req = Request::builder() .method("DELETE") .uri("/api/messages/INBOX/7") .header("x-requested-with", "XMLHttpRequest"); for (name, value) in auth_headers(&browser_id, &account_id, &token) { req = req.header(name, value); } let response = app.oneshot(req.body(Body::empty()).unwrap()).await.unwrap(); assert_eq!(response.status(), StatusCode::OK); } #[tokio::test] async fn download_attachment_returns_binary_with_correct_headers() { let static_dir = setup_static_dir(); let data_dir = TempDir::new().unwrap(); let config = test_config_with_imap( static_dir.path().to_str().unwrap(), data_dir.path().to_str().unwrap(), ); let store = test_store(); let (browser_id, account_id, token) = setup_test_account(&store, "alice@example.com"); let user_hash = crate::auth::user_data::hash_email("alice@example.com"); provision_user_db(data_dir.path().to_str().unwrap(), &user_hash); let attachment_data: Vec = vec![0xDE, 0xAD, 0xBE, 0xEF]; let mock = MockImapClient::new().with_bodies(vec![ImapMessageBody { uid: 42, text_plain: Some("text".to_string()), text_html: None, attachments: vec![ImapAttachment { filename: Some("document.pdf".to_string()), content_type: "application/pdf".to_string(), size: 4, data: attachment_data.clone(), content_id: None, }], raw_headers: String::new(), }]); let imap_client: Arc = Arc::new(mock); let app = create_router( config, store, imap_client, test_smtp_client(), test_search_engine(data_dir.path().to_str().unwrap()), test_event_bus(), test_idle_manager(), ); let mut req = Request::builder() .uri("/api/messages/INBOX/42/attachments/0") .header("x-requested-with", "XMLHttpRequest"); for (name, value) in auth_headers(&browser_id, &account_id, &token) { req = req.header(name, value); } let response = app.oneshot(req.body(Body::empty()).unwrap()).await.unwrap(); assert_eq!(response.status(), StatusCode::OK); let ct = response .headers() .get("content-type") .unwrap() .to_str() .unwrap(); assert_eq!(ct, "application/pdf"); let cd = response .headers() .get("content-disposition") .unwrap() .to_str() .unwrap(); assert!(cd.contains("inline")); assert!(cd.contains("document.pdf")); let body = response.into_body().collect().await.unwrap().to_bytes(); assert_eq!(body.as_ref(), &attachment_data); } #[tokio::test] async fn download_attachment_strips_control_chars_from_filename() { for (filename, expected_filename) in [ ("doc\r\nument.pdf", "document.pdf"), ("\r\n\0\u{7f}", "attachment_0"), ] { let static_dir = setup_static_dir(); let data_dir = TempDir::new().unwrap(); let config = test_config_with_imap( static_dir.path().to_str().unwrap(), data_dir.path().to_str().unwrap(), ); let store = test_store(); let (browser_id, account_id, token) = setup_test_account(&store, "alice@example.com"); let user_hash = crate::auth::user_data::hash_email("alice@example.com"); provision_user_db(data_dir.path().to_str().unwrap(), &user_hash); let mock = MockImapClient::new().with_bodies(vec![ImapMessageBody { uid: 1, text_plain: Some("text".to_string()), text_html: None, attachments: vec![ImapAttachment { filename: Some(filename.to_string()), content_type: "application/pdf".to_string(), size: 4, data: vec![0u8; 4], content_id: None, }], raw_headers: String::new(), }]); let imap_client: Arc = Arc::new(mock); let app = create_router( config, store, imap_client, test_smtp_client(), test_search_engine(data_dir.path().to_str().unwrap()), test_event_bus(), test_idle_manager(), ); let mut req = Request::builder() .uri("/api/messages/INBOX/1/attachments/0") .header("x-requested-with", "XMLHttpRequest"); for (name, value) in auth_headers(&browser_id, &account_id, &token) { req = req.header(name, value); } let response = app.oneshot(req.body(Body::empty()).unwrap()).await.unwrap(); assert_eq!(response.status(), StatusCode::OK); let cd = response .headers() .get("content-disposition") .unwrap() .to_str() .unwrap(); assert!(cd.contains(expected_filename), "got: {cd}"); assert!(!cd.contains('\r')); assert!(!cd.contains('\n')); } } #[tokio::test] async fn download_attachment_returns_404_for_invalid_index() { let static_dir = setup_static_dir(); let data_dir = TempDir::new().unwrap(); let config = test_config_with_imap( static_dir.path().to_str().unwrap(), data_dir.path().to_str().unwrap(), ); let store = test_store(); let (browser_id, account_id, token) = setup_test_account(&store, "alice@example.com"); let user_hash = crate::auth::user_data::hash_email("alice@example.com"); provision_user_db(data_dir.path().to_str().unwrap(), &user_hash); let mock = MockImapClient::new().with_bodies(vec![ImapMessageBody { uid: 42, text_plain: Some("text".to_string()), text_html: None, attachments: vec![ImapAttachment { filename: Some("document.pdf".to_string()), content_type: "application/pdf".to_string(), size: 4, data: vec![0xDE, 0xAD, 0xBE, 0xEF], content_id: None, }], raw_headers: String::new(), }]); let imap_client: Arc = Arc::new(mock); let app = create_router( config, store, imap_client, test_smtp_client(), test_search_engine(data_dir.path().to_str().unwrap()), test_event_bus(), test_idle_manager(), ); let mut req = Request::builder() .uri("/api/messages/INBOX/42/attachments/99") .header("x-requested-with", "XMLHttpRequest"); for (name, value) in auth_headers(&browser_id, &account_id, &token) { req = req.header(name, value); } let response = app.oneshot(req.body(Body::empty()).unwrap()).await.unwrap(); assert_eq!(response.status(), StatusCode::NOT_FOUND); } #[tokio::test] async fn download_attachment_returns_400_for_non_numeric_id() { let static_dir = setup_static_dir(); let data_dir = TempDir::new().unwrap(); let config = test_config_with_imap( static_dir.path().to_str().unwrap(), data_dir.path().to_str().unwrap(), ); let store = test_store(); let (browser_id, account_id, token) = setup_test_account(&store, "alice@example.com"); let user_hash = crate::auth::user_data::hash_email("alice@example.com"); provision_user_db(data_dir.path().to_str().unwrap(), &user_hash); let mock = MockImapClient::new().with_bodies(vec![ImapMessageBody { uid: 42, text_plain: Some("text".to_string()), text_html: None, attachments: vec![ImapAttachment { filename: Some("document.pdf".to_string()), content_type: "application/pdf".to_string(), size: 4, data: vec![0xDE, 0xAD, 0xBE, 0xEF], content_id: None, }], raw_headers: String::new(), }]); let imap_client: Arc = Arc::new(mock); let app = create_router( config, store, imap_client, test_smtp_client(), test_search_engine(data_dir.path().to_str().unwrap()), test_event_bus(), test_idle_manager(), ); let mut req = Request::builder() .uri("/api/messages/INBOX/42/attachments/abc") .header("x-requested-with", "XMLHttpRequest"); for (name, value) in auth_headers(&browser_id, &account_id, &token) { req = req.header(name, value); } let response = app.oneshot(req.body(Body::empty()).unwrap()).await.unwrap(); assert_eq!(response.status(), StatusCode::BAD_REQUEST); } #[tokio::test] async fn send_returns_200_on_success() { let static_dir = setup_static_dir(); let data_dir = TempDir::new().unwrap(); let config = test_config_with_smtp( static_dir.path().to_str().unwrap(), data_dir.path().to_str().unwrap(), ); let store = test_store(); let (browser_id, account_id, token) = setup_test_account(&store, "alice@example.com"); let user_hash = crate::auth::user_data::hash_email("alice@example.com"); let conn = crate::db::pool::open_user_db(data_dir.path().to_str().unwrap(), &user_hash).unwrap(); for folder in ["Sent", "INBOX"] { crate::db::folders::insert_folder_if_new(&conn, folder, None, "").unwrap(); crate::db::folders::update_folder_status(&conn, folder, 1, 0).unwrap(); assert!(crate::db::folders::is_folder_fresh(&conn, folder, 30).unwrap()); } let mock_smtp: Arc = Arc::new(MockSmtpClient::new()); let mock_imap = Arc::new(MockImapClient::new()); let app = create_router( config, store, mock_imap.clone(), mock_smtp, test_search_engine(data_dir.path().to_str().unwrap()), test_event_bus(), test_idle_manager(), ); let mut req = Request::builder() .method("POST") .uri("/api/messages/send") .header("x-requested-with", "XMLHttpRequest") .header("content-type", "application/json"); for (name, value) in auth_headers(&browser_id, &account_id, &token) { req = req.header(name, value); } let response = app .oneshot( req.body(Body::from( r#"{"to":["bob@example.com"],"subject":"Hello","text_body":"Hi Bob"}"#, )) .unwrap(), ) .await .unwrap(); assert_eq!(response.status(), StatusCode::OK); let body = response.into_body().collect().await.unwrap().to_bytes(); let json: serde_json::Value = serde_json::from_slice(&body).unwrap(); assert_eq!(json["status"], "sent"); assert!(json["message_id"].as_str().is_some()); let appended = mock_imap.appended.lock().unwrap(); assert_eq!(appended.len(), 1); assert_eq!(appended[0].0, "Sent"); assert!(!appended[0].1.is_empty()); assert!(crate::db::folders::is_folder_messages_invalidated(&conn, "Sent").unwrap()); assert!(!crate::db::folders::is_folder_fresh(&conn, "Sent", 30).unwrap()); assert!(crate::db::folders::is_folder_fresh(&conn, "INBOX", 30).unwrap()); } #[tokio::test] async fn send_succeeds_when_sent_append_fails() { let static_dir = setup_static_dir(); let data_dir = TempDir::new().unwrap(); let config = test_config_with_smtp( static_dir.path().to_str().unwrap(), data_dir.path().to_str().unwrap(), ); let store = test_store(); let (browser_id, account_id, token) = setup_test_account(&store, "alice@example.com"); let user_hash = crate::auth::user_data::hash_email("alice@example.com"); let conn = crate::db::pool::open_user_db(data_dir.path().to_str().unwrap(), &user_hash).unwrap(); crate::db::folders::insert_folder_if_new(&conn, "Sent", None, "").unwrap(); crate::db::folders::update_folder_status(&conn, "Sent", 1, 0).unwrap(); let mock_smtp: Arc = Arc::new(MockSmtpClient::new()); let mock_imap = Arc::new( MockImapClient::new().with_error(ImapError::ProtocolError("APPEND rejected".to_string())), ); let app = create_router( config, store, mock_imap.clone(), mock_smtp, test_search_engine(data_dir.path().to_str().unwrap()), test_event_bus(), test_idle_manager(), ); let mut req = Request::builder() .method("POST") .uri("/api/messages/send") .header("x-requested-with", "XMLHttpRequest") .header("content-type", "application/json"); for (name, value) in auth_headers(&browser_id, &account_id, &token) { req = req.header(name, value); } let response = app .oneshot( req.body(Body::from( r#"{"to":["bob@example.com"],"subject":"Hello","text_body":"Hi Bob"}"#, )) .unwrap(), ) .await .unwrap(); assert_eq!(response.status(), StatusCode::OK); let body = response.into_body().collect().await.unwrap().to_bytes(); let json: serde_json::Value = serde_json::from_slice(&body).unwrap(); assert_eq!(json["status"], "sent"); assert!(json["message_id"].as_str().is_some()); assert!(mock_imap.appended.lock().unwrap().is_empty()); assert!(crate::db::folders::is_folder_fresh(&conn, "Sent", 30).unwrap()); } #[tokio::test] async fn send_returns_400_without_recipients() { let static_dir = setup_static_dir(); let data_dir = TempDir::new().unwrap(); let config = test_config_with_smtp( static_dir.path().to_str().unwrap(), data_dir.path().to_str().unwrap(), ); let store = test_store(); let (browser_id, account_id, token) = setup_test_account(&store, "alice@example.com"); let user_hash = crate::auth::user_data::hash_email("alice@example.com"); provision_user_db(data_dir.path().to_str().unwrap(), &user_hash); let app = create_router( config, store, test_imap_client(), test_smtp_client(), test_search_engine(data_dir.path().to_str().unwrap()), test_event_bus(), test_idle_manager(), ); let mut req = Request::builder() .method("POST") .uri("/api/messages/send") .header("x-requested-with", "XMLHttpRequest") .header("content-type", "application/json"); for (name, value) in auth_headers(&browser_id, &account_id, &token) { req = req.header(name, value); } let response = app .oneshot( req.body(Body::from( r#"{"to":[],"subject":"Hello","text_body":"Hi"}"#, )) .unwrap(), ) .await .unwrap(); assert_eq!(response.status(), StatusCode::BAD_REQUEST); let body = response.into_body().collect().await.unwrap().to_bytes(); let json: serde_json::Value = serde_json::from_slice(&body).unwrap(); assert_eq!(json["error"]["code"], "BAD_REQUEST"); } #[tokio::test] async fn send_returns_400_with_empty_body_and_subject() { let static_dir = setup_static_dir(); let data_dir = TempDir::new().unwrap(); let config = test_config_with_smtp( static_dir.path().to_str().unwrap(), data_dir.path().to_str().unwrap(), ); let store = test_store(); let (browser_id, account_id, token) = setup_test_account(&store, "alice@example.com"); let user_hash = crate::auth::user_data::hash_email("alice@example.com"); provision_user_db(data_dir.path().to_str().unwrap(), &user_hash); let app = create_router( config, store, test_imap_client(), test_smtp_client(), test_search_engine(data_dir.path().to_str().unwrap()), test_event_bus(), test_idle_manager(), ); let mut req = Request::builder() .method("POST") .uri("/api/messages/send") .header("x-requested-with", "XMLHttpRequest") .header("content-type", "application/json"); for (name, value) in auth_headers(&browser_id, &account_id, &token) { req = req.header(name, value); } let response = app .oneshot( req.body(Body::from( r#"{"to":["bob@example.com"],"subject":"","text_body":""}"#, )) .unwrap(), ) .await .unwrap(); assert_eq!(response.status(), StatusCode::BAD_REQUEST); } #[tokio::test] async fn send_returns_503_when_session_has_no_smtp_endpoint() { let static_dir = setup_static_dir(); let data_dir = TempDir::new().unwrap(); let config = test_config_with_imap( static_dir.path().to_str().unwrap(), data_dir.path().to_str().unwrap(), ); let store = test_store(); let browser_id = store.create_browser(); let email = "alice@example.com"; let (token, account_id) = store.add_account_to_browser( &browser_id, email.to_string(), "password".to_string(), crate::auth::user_data::hash_email(email), "imap.example.com".to_string(), 993, true, String::new(), 587, true, ); let user_hash = crate::auth::user_data::hash_email(email); provision_user_db(data_dir.path().to_str().unwrap(), &user_hash); let app = create_router( config, store, test_imap_client(), test_smtp_client(), test_search_engine(data_dir.path().to_str().unwrap()), test_event_bus(), test_idle_manager(), ); let mut req = Request::builder() .method("POST") .uri("/api/messages/send") .header("x-requested-with", "XMLHttpRequest") .header("content-type", "application/json"); for (name, value) in auth_headers(&browser_id, &account_id, &token) { req = req.header(name, value); } let response = app .oneshot( req.body(Body::from( r#"{"to":["bob@example.com"],"subject":"Hello","text_body":"Hi"}"#, )) .unwrap(), ) .await .unwrap(); assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE); let body = response.into_body().collect().await.unwrap().to_bytes(); let json: serde_json::Value = serde_json::from_slice(&body).unwrap(); assert_eq!(json["error"]["code"], "SERVICE_UNAVAILABLE"); } #[tokio::test] async fn send_returns_503_when_smtp_fails() { let static_dir = setup_static_dir(); let data_dir = TempDir::new().unwrap(); let config = test_config_with_smtp( static_dir.path().to_str().unwrap(), data_dir.path().to_str().unwrap(), ); let store = test_store(); let (browser_id, account_id, token) = setup_test_account(&store, "alice@example.com"); let user_hash = crate::auth::user_data::hash_email("alice@example.com"); provision_user_db(data_dir.path().to_str().unwrap(), &user_hash); let failing_smtp: Arc = Arc::new( MockSmtpClient::new().with_error(SmtpError::SendFailed("relay denied".to_string())), ); let app = create_router( config, store, test_imap_client(), failing_smtp, test_search_engine(data_dir.path().to_str().unwrap()), test_event_bus(), test_idle_manager(), ); let mut req = Request::builder() .method("POST") .uri("/api/messages/send") .header("x-requested-with", "XMLHttpRequest") .header("content-type", "application/json"); for (name, value) in auth_headers(&browser_id, &account_id, &token) { req = req.header(name, value); } let response = app .oneshot( req.body(Body::from( r#"{"to":["bob@example.com"],"subject":"Hello","text_body":"Hi"}"#, )) .unwrap(), ) .await .unwrap(); assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE); let body = response.into_body().collect().await.unwrap().to_bytes(); let json: serde_json::Value = serde_json::from_slice(&body).unwrap(); assert!( json["error"]["message"] .as_str() .unwrap() .contains("relay denied") ); } #[tokio::test] async fn quota_without_server_limit_uses_cached_sizes() { let static_dir = setup_static_dir(); let data_dir = TempDir::new().unwrap(); let path = data_dir.path().to_str().unwrap(); let config = test_config_with_imap(static_dir.path().to_str().unwrap(), path); let store = test_store(); let (browser_id, account_id, token) = setup_test_account(&store, "alice@example.com"); let hash = crate::auth::user_data::hash_email("alice@example.com"); let conn = crate::db::pool::open_user_db(path, &hash).unwrap(); crate::db::folders::upsert_folder(&conn, "INBOX", None, None, "", true, 1, 1, 1, 0).unwrap(); crate::db::messages::upsert_message( &conn, "INBOX", 1, None, None, None, "subject", "alice@example.com", "Alice", "[]", "[]", "", "", 4096, false, "", None, ) .unwrap(); drop(conn); let app = create_router( config, store, test_imap_client(), test_smtp_client(), test_search_engine(path), test_event_bus(), test_idle_manager(), ); let mut req = Request::builder().uri("/api/quota"); for (name, value) in auth_headers(&browser_id, &account_id, &token) { req = req.header(name, value); } let response = app.oneshot(req.body(Body::empty()).unwrap()).await.unwrap(); assert_eq!(response.status(), StatusCode::OK); let body = response.into_body().collect().await.unwrap().to_bytes(); let json: serde_json::Value = serde_json::from_slice(&body).unwrap(); assert_eq!(json["usage_bytes"], 4096); assert!(json["limit_bytes"].is_null()); } #[tokio::test] async fn export_contact_strips_control_chars_from_filename() { for (name, expected_filename) in [ ("Bob\nSmith", "BobSmith.vcf"), ("\r\n\0\u{7f}", "contact.vcf"), ] { let static_dir = setup_static_dir(); let data_dir = TempDir::new().unwrap(); let config = test_config_with_imap( static_dir.path().to_str().unwrap(), data_dir.path().to_str().unwrap(), ); let store = test_store(); let (browser_id, account_id, token) = setup_test_account(&store, "alice@example.com"); let user_hash = crate::auth::user_data::hash_email("alice@example.com"); provision_user_db(data_dir.path().to_str().unwrap(), &user_hash); let conn = crate::db::pool::open_user_db(data_dir.path().to_str().unwrap(), &user_hash).unwrap(); let contact = crate::db::contacts::Contact { id: "c1".to_string(), email: "bob@example.com".to_string(), name: name.to_string(), company: String::new(), notes: String::new(), phone: String::new(), job_title: String::new(), address: String::new(), website: String::new(), birthday: String::new(), emails: Vec::new(), phones: Vec::new(), addresses: Vec::new(), is_favorite: false, last_contacted: None, contact_count: 0, source: "manual".to_string(), created_at: "2024-01-01 00:00:00".to_string(), updated_at: "2024-01-01 00:00:00".to_string(), has_photo: false, }; crate::db::contacts::upsert_contact(&conn, &contact).unwrap(); drop(conn); let imap_client: Arc = Arc::new(MockImapClient::new()); let app = create_router( config, store, imap_client, test_smtp_client(), test_search_engine(data_dir.path().to_str().unwrap()), test_event_bus(), test_idle_manager(), ); let mut req = Request::builder() .uri("/api/contacts/c1/export") .header("x-requested-with", "XMLHttpRequest"); for (name, value) in auth_headers(&browser_id, &account_id, &token) { req = req.header(name, value); } let response = app.oneshot(req.body(Body::empty()).unwrap()).await.unwrap(); assert_eq!(response.status(), StatusCode::OK); let cd = response .headers() .get("content-disposition") .unwrap() .to_str() .unwrap(); assert!(cd.contains(expected_filename), "got: {cd}"); assert!(!cd.contains('\n')); } }