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, FolderStatusExtended, 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(), // Existing send tests assert on the immediate, synchronous send // outcome (status/body), not the "Undo Send" staged-dispatch flow; // keep that behavior disabled here so they keep testing what they // were written to test. undo_send.rs/routes/send.rs have their own // dedicated tests for the staged-dispatch path. undo_send_window_secs: 0, ..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_only_login_requirements_before_authentication() { let dir = setup_static_dir(); let mut config = (*test_config(dir.path().to_str().unwrap())).clone(); config.remember_me_enabled = true; config.sieve_enabled = false; config.diagnostics_enabled = false; config.about_enabled = false; config.multi_account_enabled = false; let store = test_store(); let (browser_id, account_id, token) = setup_test_account(&store, "alice@example.com"); let app = create_router( Arc::new(config), store, test_imap_client(), test_smtp_client(), test_search_engine("/tmp/oxi-test"), test_event_bus(), test_idle_manager(), ); let response = app .clone() .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["rememberMeEnabled"], true); for key in [ "rememberMeDays", "sessionTimeoutHours", "pgpEnabled", "sieveEnabled", "diagnosticsEnabled", "aboutEnabled", "multiAccountEnabled", ] { assert!(json.get(key).is_none(), "public policy leaked {key}"); } let unauth = app .clone() .oneshot( Request::builder() .uri("/api/auth/session-policy") .body(Body::empty()) .unwrap(), ) .await .unwrap(); assert_eq!(unauth.status(), StatusCode::UNAUTHORIZED); let mut request = Request::builder() .uri("/api/auth/session-policy") .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); 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); assert_eq!(json["multiAccountEnabled"], false); assert!(json.get("rememberMeDays").is_none()); assert!(json.get("sessionTimeoutHours").is_none()); } #[tokio::test] async fn security_headers_are_opt_in_and_content_type_aware() { 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("/").body(Body::empty()).unwrap()) .await .unwrap(); assert!(response.headers().get("content-security-policy").is_none()); assert!(response.headers().get("x-content-type-options").is_none()); let disabled_api = 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(), ) .oneshot( Request::builder() .uri("/api/health") .body(Body::empty()) .unwrap(), ) .await .unwrap(); assert!( disabled_api .headers() .get("x-content-type-options") .is_none() ); assert!( disabled_api .headers() .get("cross-origin-resource-policy") .is_none() ); assert!( disabled_api .headers() .get("content-security-policy") .is_none() ); assert!(disabled_api.headers().get("x-frame-options").is_none()); let mut config = (*test_config(dir.path().to_str().unwrap())).clone(); config.security_headers_enabled = true; let config = Arc::new(config); let html = create_router( config.clone(), test_store(), test_imap_client(), test_smtp_client(), test_search_engine("/tmp/oxi-test"), test_event_bus(), test_idle_manager(), ) .oneshot(Request::builder().uri("/").body(Body::empty()).unwrap()) .await .unwrap(); assert_eq!(html.headers()["x-content-type-options"], "nosniff"); assert_eq!(html.headers()["x-frame-options"], "DENY"); assert_eq!(html.headers()["referrer-policy"], "no-referrer"); assert_eq!( html.headers()["permissions-policy"], "camera=(), microphone=(), geolocation=()" ); assert_eq!(html.headers()["cross-origin-opener-policy"], "same-origin"); let html_csp = html.headers()["content-security-policy"].to_str().unwrap(); assert!(html_csp.contains("default-src 'self'")); assert!(html_csp.contains("script-src 'self';")); assert!(html_csp.contains("script-src-attr 'none'")); assert!(html_csp.contains("style-src 'self'; style-src-attr 'unsafe-inline'")); assert!(!html_csp.contains("style-src 'self' 'unsafe-inline'")); assert!(!html_csp.contains("unsafe-inline'; script-src")); assert!(!html_csp.contains("script-src 'self' 'unsafe-inline'")); let css = create_router( config.clone(), test_store(), test_imap_client(), test_smtp_client(), test_search_engine("/tmp/oxi-test"), test_event_bus(), test_idle_manager(), ) .oneshot( Request::builder() .uri("/assets/app.css") .body(Body::empty()) .unwrap(), ) .await .unwrap(); assert_eq!(css.headers()["x-content-type-options"], "nosniff"); assert_eq!(css.headers()["referrer-policy"], "no-referrer"); assert_eq!(css.headers()["cross-origin-resource-policy"], "same-origin"); assert!(css.headers().get("x-frame-options").is_none()); assert!(css.headers().get("permissions-policy").is_none()); assert!(css.headers().get("cross-origin-opener-policy").is_none()); assert!(css.headers().get("content-security-policy").is_none()); let api = create_router( config, test_store(), test_imap_client(), test_smtp_client(), test_search_engine("/tmp/oxi-test"), test_event_bus(), test_idle_manager(), ) .oneshot( Request::builder() .uri("/api/health") .body(Body::empty()) .unwrap(), ) .await .unwrap(); assert_eq!(api.headers()["x-content-type-options"], "nosniff"); assert_eq!(api.headers()["cache-control"], "no-store"); assert_eq!(api.headers()["cross-origin-resource-policy"], "same-origin"); assert_eq!( api.headers()["content-security-policy"], "frame-ancestors 'none'" ); assert_eq!(api.headers()["x-frame-options"], "DENY"); assert!(api.headers().get("referrer-policy").is_none()); assert!(api.headers().get("permissions-policy").is_none()); assert!(api.headers().get("cross-origin-opener-policy").is_none()); } #[tokio::test] async fn pdf_api_responses_allow_same_origin_preview_frames() { let app = Router::new() .route( "/api/test.pdf", get(|| async { Response::builder() .header("content-type", "application/pdf") .body(Body::from("%PDF-1.4\n")) .unwrap() }), ) .layer(middleware::from_fn(add_security_headers)); let response = app .oneshot( Request::builder() .uri("/api/test.pdf") .body(Body::empty()) .unwrap(), ) .await .unwrap(); assert_eq!(response.headers()["x-frame-options"], "SAMEORIGIN"); assert_eq!( response.headers()["content-security-policy"], "frame-ancestors 'self'" ); assert_eq!( response.headers()["cross-origin-resource-policy"], "same-origin" ); } #[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 folder_refresh_uses_server_counts_without_relisting_fresh_folder_tree() { 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) .expect("open user DB"); crate::db::folders::upsert_folder( &conn, "INBOX", Some("/"), None, "\\HasNoChildren", true, 3444, 1, 1, 10, ) .unwrap(); crate::db::folders::touch_all_folders(&conn).unwrap(); drop(conn); let mock = MockImapClient::new().with_folder_status_extended(FolderStatusExtended { uid_validity: 1, exists: 3444, uid_next: 3445, unseen: 12, highest_modseq: 11, }); let app = create_router( config, store, Arc::new(mock), test_smtp_client(), test_search_engine(data_dir.path().to_str().unwrap()), test_event_bus(), test_idle_manager(), ); let mut request = Request::builder() .uri("/api/folders?refresh=true") .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); let body = response.into_body().collect().await.unwrap().to_bytes(); let json: serde_json::Value = serde_json::from_slice(&body).unwrap(); let inbox = json["folders"] .as_array() .unwrap() .iter() .find(|folder| folder["name"] == "INBOX") .expect("INBOX remains in the cached folder tree"); assert_eq!(inbox["total_count"], 3444); assert_eq!(inbox["unread_count"], 12); } #[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_messages_repairs_cached_missing_date_from_imap_header() { 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"); 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, 1, 1, 1, 0).unwrap(); crate::db::messages::upsert_message( &conn, "INBOX", 77, None, None, None, "No Date header", "sender@example.com", "Sender", "[]", "[]", "", "", 123, false, "", None, ) .unwrap(); crate::db::folders::mark_folder_messages_fresh(&conn, "INBOX").unwrap(); drop(conn); let repaired_date = "Tue, 3 Nov 2026 14:38:39 +0100"; let mock = Arc::new(MockImapClient::new().with_headers(vec![ImapMessageHeader { uid: 77, subject: Some("No Date header".to_string()), from: vec![EmailAddress { name: Some("Sender".to_string()), address: "sender@example.com".to_string(), }], to: vec![EmailAddress { name: None, address: "alice@example.com".to_string(), }], date: Some(repaired_date.to_string()), flags: vec![], has_attachments: false, size: 123, message_id: None, in_reply_to: None, references: None, cc: vec![], reaction: None, }])); let imap_client: Arc = mock.clone(); 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["messages"][0]["uid"], 77); assert_eq!(json["messages"][0]["date"], repaired_date); assert!( mock.header_requests .lock() .unwrap() .iter() .any(|(folder, range)| folder == "INBOX" && range == "77") ); } #[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 attach_message_source_creates_eml_draft_attachment() { 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 raw = b"From: sender@example.com\r\nTo: alice@example.com\r\nSubject: forwarded\r\n\r\nhello\r\n" .to_vec(); let mock = MockImapClient::new().with_raw_message(42, raw.clone()); 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/drafts/forward-test/attachments/from-message") .header("content-type", "application/json") .header("x-requested-with", "XMLHttpRequest"); for (name, value) in auth_headers(&browser_id, &account_id, &token) { req = req.header(name, value); } let response = app .clone() .oneshot( req.body(Body::from(r#"{"folder":"INBOX","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(); let attachment = &json["attachments"][0]; assert_eq!(attachment["filename"], "message-42.eml"); assert_eq!(attachment["content_type"], "message/rfc822"); assert_eq!(attachment["size"], raw.len() as i64); let attachment_id = attachment["id"].as_str().unwrap(); let mut content_req = Request::builder() .uri(format!( "/api/drafts/forward-test/attachments/{attachment_id}/content" )) .header("x-requested-with", "XMLHttpRequest"); for (name, value) in auth_headers(&browser_id, &account_id, &token) { content_req = content_req.header(name, value); } let content_response = app .oneshot(content_req.body(Body::empty()).unwrap()) .await .unwrap(); assert_eq!(content_response.status(), StatusCode::OK); assert_eq!( content_response .headers() .get("content-type") .unwrap() .to_str() .unwrap(), "message/rfc822" ); let content = content_response .into_body() .collect() .await .unwrap() .to_bytes(); assert_eq!(content.as_ref(), raw.as_slice()); } #[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')); } } fn multipart_upload_request( uri: &str, filename: &str, payload: &[u8], auth: &[(&'static str, String)], ) -> Request { let boundary = "rustbox-test-boundary"; let mut body = Vec::new(); body.extend_from_slice( format!( "--{boundary}\r\nContent-Disposition: form-data; name=\"file\"; filename=\"{filename}\"\r\nContent-Type: application/octet-stream\r\n\r\n" ) .as_bytes(), ); body.extend_from_slice(payload); body.extend_from_slice(format!("\r\n--{boundary}--\r\n").as_bytes()); let mut req = Request::builder() .method("POST") .uri(uri) .header( "content-type", format!("multipart/form-data; boundary={boundary}"), ) .header("x-requested-with", "XMLHttpRequest"); for (name, value) in auth { req = req.header(*name, value.as_str()); } req.body(Body::from(body)).unwrap() } async fn error_code_of(response: axum::response::Response) -> String { let body = response.into_body().collect().await.unwrap().to_bytes(); let json: serde_json::Value = serde_json::from_slice(&body).unwrap(); json["error"]["code"] .as_str() .unwrap_or_default() .to_string() } #[tokio::test] async fn upload_over_the_message_size_limit_is_rejected_while_streaming() { let static_dir = setup_static_dir(); let data_dir = TempDir::new().unwrap(); let mut cfg = (*test_config_with_imap( static_dir.path().to_str().unwrap(), data_dir.path().to_str().unwrap(), )) .clone(); cfg.max_attachment_size_mb = 10; cfg.max_message_size_mb = 1; 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( Arc::new(cfg), store, Arc::new(MockImapClient::new()), test_smtp_client(), test_search_engine(data_dir.path().to_str().unwrap()), test_event_bus(), test_idle_manager(), ); let auth = auth_headers(&browser_id, &account_id, &token); // A single 2 MiB file cannot fit a 1 MiB message even though it is below // the per-attachment limit. let response = app .clone() .oneshot(multipart_upload_request( "/api/drafts/limit-draft/attachments", "big.bin", &vec![7u8; 2 * 1024 * 1024], &auth, )) .await .unwrap(); assert_eq!(response.status(), StatusCode::PAYLOAD_TOO_LARGE); assert_eq!(error_code_of(response).await, "PAYLOAD_TOO_LARGE"); // Two files that each fit but not together: the second one is refused. for (index, expected) in [(1, StatusCode::OK), (2, StatusCode::PAYLOAD_TOO_LARGE)] { let response = app .clone() .oneshot(multipart_upload_request( "/api/drafts/limit-draft-2/attachments", &format!("part-{index}.bin"), &vec![1u8; 500_000], &auth, )) .await .unwrap(); assert_eq!(response.status(), expected, "upload #{index}"); } let conn = crate::db::pool::open_user_db(data_dir.path().to_str().unwrap(), &user_hash).unwrap(); let stored = crate::db::drafts::get_draft_attachments(&conn, "limit-draft-2").unwrap(); assert_eq!(stored.len(), 1); assert!( crate::db::drafts::get_draft_attachments(&conn, "limit-draft") .unwrap() .is_empty() ); } #[tokio::test] async fn send_rejects_oversized_draft_attachments_before_reading_any_blob() { 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::drafts::upsert_draft( &conn, "huge-draft", "", "", "", "", "", None, None, None, false, "normal", "html", "none", false, false, false, ) .unwrap(); // The recorded size is far above max_message_size_mb and the blob does not // exist: a 413 (not a 500 read error) proves nothing was loaded. crate::db::drafts::add_draft_attachment( &conn, &crate::db::drafts::NewDraftAttachment { id: "huge-att", draft_id: "huge-draft", filename: "huge.bin", content_type: "application/octet-stream", size: 200 * 1024 * 1024, storage_key: "missing/blob", checksum_sha256: None, }, ) .unwrap(); let mock_smtp: Arc = Arc::new(MockSmtpClient::new()); let app = create_router( config, store, Arc::new(MockImapClient::new()), 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":"Big","text_body":"x","draft_id":"huge-draft"}"#, )) .unwrap(), ) .await .unwrap(); assert_eq!(response.status(), StatusCode::PAYLOAD_TOO_LARGE); assert_eq!(error_code_of(response).await, "PAYLOAD_TOO_LARGE"); } #[tokio::test] async fn sends_with_attachments_release_their_build_slot() { let static_dir = setup_static_dir(); let data_dir = TempDir::new().unwrap(); let mut cfg = (*test_config_with_smtp( static_dir.path().to_str().unwrap(), data_dir.path().to_str().unwrap(), )) .clone(); // A single slot and a short wait: the second send only succeeds when the // first one gave its permit back. cfg.max_concurrent_message_builds = 1; cfg.message_build_wait_secs = 1; 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_imap = Arc::new(MockImapClient::new()); let app = create_router( Arc::new(cfg), store, mock_imap.clone(), Arc::new(MockSmtpClient::new()), test_search_engine(data_dir.path().to_str().unwrap()), test_event_bus(), test_idle_manager(), ); let auth = auth_headers(&browser_id, &account_id, &token); for draft_id in ["slot-draft-1", "slot-draft-2"] { let upload = app .clone() .oneshot(multipart_upload_request( &format!("/api/drafts/{draft_id}/attachments"), "note.txt", b"hello attachment", &auth, )) .await .unwrap(); assert_eq!(upload.status(), StatusCode::OK); 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 { req = req.header(*name, value.as_str()); } let response = app .clone() .oneshot( req.body(Body::from(format!( r#"{{"to":["bob@example.com"],"subject":"With file","text_body":"see attached","draft_id":"{draft_id}"}}"# ))) .unwrap(), ) .await .unwrap(); assert_eq!(response.status(), StatusCode::OK, "send for {draft_id}"); } assert_eq!(mock_imap.appended.lock().unwrap().len(), 2); } #[tokio::test] async fn filter_match_narrows_imap_candidates_from_the_cache() { 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(); for (uid, from) in [(1u32, "news@shop.example"), (2u32, "friend@example.com")] { crate::db::messages::upsert_message( &conn, "INBOX", uid, None, None, None, "Hello", from, "", "[]", "[]", "2024-01-01", "", 100, false, "", None, ) .unwrap(); } drop(conn); let header = |uid: u32, address: &str| ImapMessageHeader { uid, subject: Some("Hello".to_string()), from: vec![EmailAddress { name: None, address: address.to_string(), }], to: vec![], date: None, flags: vec![], has_attachments: false, size: 100, message_id: None, in_reply_to: None, references: None, cc: vec![], reaction: None, }; // The mock answers every search with all UIDs, i.e. a pure superset, so // the exact answer has to come from the cache. let mock = MockImapClient::new().with_headers(vec![ header(1, "news@shop.example"), header(2, "friend@example.com"), ]); 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/filters/match") .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 body = r#"{"folder":"INBOX","rule":{"test":{"kind":"from-domain","match":"is","value":"shop.example"}}}"#; let response = app .oneshot(req.body(Body::from(body)).unwrap()) .await .unwrap(); assert_eq!(response.status(), StatusCode::OK); let bytes = response.into_body().collect().await.unwrap().to_bytes(); let json: serde_json::Value = serde_json::from_slice(&bytes).unwrap(); assert_eq!(json["uids"], serde_json::json!([1])); assert_eq!(json["matched"], 1); assert_eq!(json["uncertain"], 0); }