3076 lines
98 KiB
Rust
3076 lines
98 KiB
Rust
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<AppConfig> {
|
|
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<AppConfig> {
|
|
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<AppConfig> {
|
|
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<SessionStore> {
|
|
Arc::new(SessionStore::new(Duration::from_secs(3600)))
|
|
}
|
|
|
|
fn test_imap_client() -> Arc<dyn ImapClient> {
|
|
Arc::new(MockImapClient::new())
|
|
}
|
|
|
|
fn test_smtp_client() -> Arc<dyn SmtpClient> {
|
|
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"),
|
|
"<!DOCTYPE html><html><body>SPA</body></html>",
|
|
)
|
|
.expect("should write index.html");
|
|
dir
|
|
}
|
|
|
|
fn test_search_engine(data_dir: &str) -> Arc<crate::search::engine::SearchEngine> {
|
|
Arc::new(crate::search::engine::SearchEngine::new(
|
|
std::path::PathBuf::from(data_dir),
|
|
))
|
|
}
|
|
|
|
fn test_event_bus() -> Arc<crate::realtime::events::EventBus> {
|
|
Arc::new(crate::realtime::events::EventBus::new())
|
|
}
|
|
|
|
fn test_idle_manager() -> Arc<crate::realtime::idle::IdleManager> {
|
|
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::<SocketAddr>(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::<IpAddr>().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::<SocketAddr>("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::<IpAddr>().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::<IpAddr>().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::<IpAddr>().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::<IpAddr>().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<dyn ImapClient> = 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<dyn ImapClient> = 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<dyn ImapClient> = 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<dyn ImapClient> = 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("<p>Hello</p><script>alert('xss')</script><b>bold</b>".to_string()),
|
|
attachments: vec![],
|
|
raw_headers: String::new(),
|
|
}]);
|
|
let imap_client: Arc<dyn ImapClient> = 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("<b>bold</b>"));
|
|
assert!(html.contains("<p>Hello</p>"));
|
|
|
|
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<dyn ImapClient> = 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<dyn ImapClient> = 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<dyn ImapClient> = 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<dyn ImapClient> = 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("<move-42@example.com>"),
|
|
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("<move-42@example.com>".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<dyn ImapClient> = 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<dyn ImapClient> = 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<u8> = 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<dyn ImapClient> = 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<dyn ImapClient> = 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<dyn ImapClient> = 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<dyn ImapClient> = 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<dyn ImapClient> = 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<dyn SmtpClient> = 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<dyn SmtpClient> = 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<dyn SmtpClient> = 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<dyn ImapClient> = 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<Body> {
|
|
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<dyn SmtpClient> = 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<dyn ImapClient> = 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);
|
|
}
|